文件
phonerail/firmware/test/test_t_thermal.c
T
zhou yang和Claude Opus 5.5 a32fa06c43 固件:A12 命令处理器 + A13 USB 分帧 + A14 WiFi/MQTT(含 C1 配网)
- A12 单一命令处理器(cmd*.c):一行 JSON 进出、seq 全局判重(32 条按值)、
  限幅 / 空槽一律拒 / 拒绝记事件;命令号与错误码冻结(进 flash)。新增 power 命令,
  fan 改为只抬不压的下限(t_thermal_set_floor)。rail_set_mv 注明不拿锁,调用方包 xact。
- A13 USB-Serial-JTAG 分帧(ulink*.c):@C / @R 前缀 + CRC-16,日志行首 @ 垫空格,
  任意切块可切;关掉 USB 副控制台(它绕开驱动写 FIFO)。PC 端客户端 tools/prlink.py。
- A14 + C1(netcfg / mlink / cmd_net):topic phonerail/<mac>/{cmd,rsp,tele,state},
  mqtts(evse-ca 编进固件)、1 s 遥测、遗嘱离线;wifi / mqtt 配置只收 USB、存 NVS,
  net 永不回显密码。
- 协议定稿写进需求文档 §10.8(A12 / A13 / A14 三节);N7 撤销改 D7(机架管理前后端)。

宿主机 23 个 ctest / 400 例全绿;板 B 已烧 0.1.0-a14,经 WiFi → 公网 → 8883 TLS 真连网验过。
固件 0xE2220 / 1 MB 分区只剩 12%,C2 前要重排分区表。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 12:33:18 +08:00

497 行
23 KiB
C
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
/* A16 / R-6:风扇闭环与 TACH 采样窗口(需求 FR-10 / FR-13 / TR-6,计划 §5.10)。
这一包的判据全部长在三件**写错了不会报错**的事情上:
① `FAN_EN` 复位态是「不转」。忘了主动拉高 ⇒ 温度闭环、PWM、遥测全部正常,
只是一点风都没有。功能测试看不出来,因为没有任何一个返回值会变。
② PWM 经 Q6 反相。写反了 ⇒ **越热转得越慢**,而遥测里「占空比随温度上升」
这句话仍然是对的 —— 看仪表盘的人永远发现不了。
③ TACH 在 PWM 关断期间没有脉冲(TR-6)。按「这一圈没数到脉冲就是停转」写
⇒ 低占空比下**稳定误报**,大家很快学会无视风扇告警,等到真停转那天
告警照常发出来、照常没人看。
所以下面的断言一律打在**假风扇**这个可观测面上(转速、边沿数、引脚电平),
不去对着模块自己的影子变量断言 —— 那是自己证明自己。 */
#include <string.h>
#include "board_pins.h"
#include "evlog.h"
#include "hal_pwm.h"
#include "hal_time.h"
#include "sim_hal.h"
#include "t_thermal.h"
#include "tasks.h"
#include "unity.h"
/* 假风扇按设计文档 §22.6 的数:满速 11000 RPM、0 % PWM 仍转 3300 RPM、2 PPR。 */
#define FAN_RPM_FULL 11000u
#define FAN_RPM_MIN 3300u
/* 一圈 1 s —— 和任务表里 `t_thermal` 的周期一致(tasks.c 定死,本包不改它)。 */
static uint32_t period_ms(void) { return task_desc(TASK_THERMAL)->period_ms; }
/* ---- 喂进来的两样东西(温度与 TACH 计数器,见 t_thermal.h 的「外部喂进来」)---- */
static int32_t s_temp_mc[T_THERMAL_NTC_COUNT];
static bool s_temp_ok[T_THERMAL_NTC_COUNT];
static bool temp_src(void *ctx, int idx, int32_t *mc)
{
(void)ctx;
if (!s_temp_ok[idx]) {
return false;
}
*mc = s_temp_mc[idx];
return true;
}
/* TACH 直接取假风扇的累计边沿数 —— 板上这个数来自 PCNT。 */
static bool tach_src(void *ctx, int idx, uint32_t *edges)
{
(void)ctx;
*edges = sim_fan_edges(idx);
return true;
}
/* 下一条落盘记录会拿到的序号(和 test_evlog.c 一个写法)。 */
static uint32_t next_seq(void)
{
evlog_stats_t st;
evlog_stats(&st);
return st.next_seq;
}
static void set_temp(int32_t mc)
{
for (int i = 0; i < T_THERMAL_NTC_COUNT; i++) {
s_temp_mc[i] = mc;
s_temp_ok[i] = true;
}
}
static void fans_up(void)
{
for (int i = 0; i < T_THERMAL_FAN_COUNT; i++) {
sim_fan_attach(i, T_THERMAL_PWM_CH, BOARD_PIN_FAN_EN,
FAN_RPM_FULL, FAN_RPM_MIN, T_THERMAL_TACH_PPR);
/* **关断期不吐脉冲** —— TR-6 说的就是这种风扇,也是最坏情况。 */
sim_fan_set_gated(i, true);
}
}
void setUp(void)
{
sim_hal_reset();
fans_up();
set_temp(25000); /* 25 °C:常温开机 */
t_thermal_set_floor(0); /* 下限是模块级静态量,不随 init 清零(t_thermal.h) */
}
void tearDown(void) {}
/* 跑 n 圈,每圈把虚拟时钟往前拨一个周期。真板上是 `sched_periodic_wait()` 睡过去的。 */
static void run(thermal_snap_t *s, uint32_t n)
{
for (uint32_t i = 0; i < n; i++) {
sim_time_advance_ms(period_ms());
TEST_ASSERT_EQUAL_INT(HAL_OK, t_thermal_step(s));
}
}
/* 起模块 + 跑过启动宽限(`T_THERMAL_SPINUP_MS`),让停转判据开始工作。 */
static void boot(thermal_snap_t *s)
{
TEST_ASSERT_EQUAL_INT(HAL_OK, t_thermal_init(s, temp_src, tach_src, NULL));
run(s, T_THERMAL_SPINUP_MS / 1000u + 1u);
}
/* ---- ① 上电就得把 12V 打开 ---------------------------------------------- */
/* `FAN_EN`(IO48)复位时高阻 + R749 下拉 ⇒ 12V 是断的。固件不主动拉高就一直没风。
⚠️ 光断言引脚电平还不够 —— 引脚为高但顺序写错(先使能供电、后配 PWM)在真板上
也是一段全速乱转,所以这里还断言**假风扇真的转起来了**。
写错了会怎样:删掉 `t_thermal_init()` 里最后那行 `hal_gpio_write(FAN_EN, 1)`,
整块板子的行为只有一处变化 —— 不散热。任何看返回值的测试都是绿的。 */
static void test_init_drives_fan_en_high_and_fans_actually_spin(void)
{
/* 起之前:谁也没配过这个脚,更没人驱动它。 */
TEST_ASSERT_EQUAL_INT_MESSAGE(-1, sim_gpio_level(BOARD_PIN_FAN_EN), "起之前不该有人动 FAN_EN");
TEST_ASSERT_EQUAL_UINT32(0, sim_fan_rpm(0));
thermal_snap_t s;
TEST_ASSERT_EQUAL_INT(HAL_OK, t_thermal_init(&s, temp_src, tach_src, NULL));
TEST_ASSERT_EQUAL_INT_MESSAGE(1, sim_gpio_level(BOARD_PIN_FAN_EN), "FAN_EN 没被拉高 = 没风");
/* 先写 0 再写 1(§22.6 的上电顺序:先关、配好 PWM、再给电)。 */
TEST_ASSERT_EQUAL_UINT32_MESSAGE(2, sim_gpio_writes(BOARD_PIN_FAN_EN), "上电顺序不是「先 0 后 1」");
TEST_ASSERT_EQUAL_INT(BOARD_PIN_FAN_PWM, sim_pwm_pin(T_THERMAL_PWM_CH));
TEST_ASSERT_EQUAL_UINT32(T_THERMAL_PWM_HZ, sim_pwm_freq(T_THERMAL_PWM_CH));
/* 两只都要转 —— 一路 PWM 喂两个座(J26/J27),漏掉一只就是半个风道。 */
TEST_ASSERT_TRUE_MESSAGE(sim_fan_rpm(0) > 0, "进风扇没转");
TEST_ASSERT_TRUE_MESSAGE(sim_fan_rpm(1) > 0, "排风扇没转");
}
/* ---- ② PWM 反相:越热必须越快 ------------------------------------------- */
/* Q6 是低边管,栅极高 = 把风扇的 PWM 线拉低 ⇒ `D_gpio = 1 − D_fan`。
⚠️ 断言分两层:写进 hal_pwm 的值是反的(接口层),**假风扇的转速跟着温度涨**
(物理层)。只断言第一层的话,把 `gpio_duty_of()` 和调用处一起改反仍然是绿的。
写错了会怎样:温度越高风扇越慢,而遥测里 `duty` 照常单调上升 —— 看不出来。 */
static void test_hotter_never_means_slower(void)
{
thermal_snap_t s;
boot(&s);
uint16_t prev_duty = 0;
uint32_t prev_rpm = 0;
for (int32_t t = 20000; t <= 90000; t += 5000) {
set_temp(t);
run(&s, 1);
TEST_ASSERT_TRUE_MESSAGE(s.duty >= prev_duty, "温度升了,占空比反而降");
TEST_ASSERT_TRUE_MESSAGE(sim_fan_rpm(0) >= prev_rpm, "温度升了,风扇反而慢了(PWM 反相写反了)");
/* 接口层:真写进去的是反相后的值。 */
TEST_ASSERT_EQUAL_INT_MESSAGE((int)(1000u - s.duty), sim_pwm_permille(T_THERMAL_PWM_CH),
"写进 hal_pwm 的不是 1000 − 风扇侧占空比");
prev_duty = s.duty;
prev_rpm = sim_fan_rpm(0);
}
TEST_ASSERT_EQUAL_UINT16_MESSAGE(1000, s.duty, "90 °C 还没满速");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(FAN_RPM_FULL, sim_fan_rpm(0), "满速时假风扇没到额定转速");
}
/* 纯函数那一层:曲线形状 + 两个端点。分段线性写成整数除法很容易在端点差 1,
而端点正是「安静」和「救命」两件事各自的极限。 */
static void test_duty_curve_is_monotonic_and_bounded(void)
{
TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, t_thermal_duty_for(0, true));
TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, t_thermal_duty_for(T_THERMAL_TEMP_LO_MC, true));
TEST_ASSERT_EQUAL_UINT16(1000, t_thermal_duty_for(T_THERMAL_TEMP_HI_MC, true));
TEST_ASSERT_EQUAL_UINT16(1000, t_thermal_duty_for(200000, true));
uint16_t prev = 0;
for (int32_t t = -10000; t <= 120000; t += 250) {
const uint16_t d = t_thermal_duty_for(t, true);
TEST_ASSERT_TRUE_MESSAGE(d >= prev, "占空比曲线不单调");
TEST_ASSERT_TRUE(d <= 1000);
prev = d;
}
}
/* 温度读不到 ⇒ **满速**。需求 §10.10 对「温度读不到」给的处置就是这一条:
不要按「读不到温度 = 不热」处理。
写错了会怎样:NTC 开路(一根线的事)之后风扇按 0 °C 跑最低速,机器照常工作,
直到夏天某个下午集体降频 —— 而日志里温度那一栏很干净,因为它根本没有值。 */
static void test_unreadable_temperature_means_full_speed(void)
{
thermal_snap_t s;
boot(&s);
set_temp(25000);
run(&s, 1);
TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, s.duty);
s_temp_ok[0] = false;
s_temp_ok[1] = false;
run(&s, 1);
TEST_ASSERT_FALSE(s.temp_valid);
TEST_ASSERT_EQUAL_UINT16_MESSAGE(1000, s.duty, "温度读不到时没有转满速");
TEST_ASSERT_EQUAL_UINT32(1, s.temp_errors);
}
/* A12 的 `fan` 命令:上位机给的是**下限**,只抬不压。
凉的时候按下限转;热到曲线要得更高时照曲线来;温度读不到时仍然满速 ——
手动值要是能压住曲线,远程一条命令就能把散热关掉。 */
static void test_manual_floor_only_raises_the_duty(void)
{
thermal_snap_t s;
boot(&s);
set_temp(25000);
t_thermal_set_floor(800);
run(&s, 1);
TEST_ASSERT_EQUAL_UINT16(800, s.duty);
set_temp(T_THERMAL_TEMP_HI_MC);
run(&s, 1);
TEST_ASSERT_EQUAL_UINT16(1000, s.duty);
t_thermal_set_floor(T_THERMAL_DUTY_MIN / 2); /* 比曲线下限还低:没有效果 */
set_temp(25000);
run(&s, 1);
TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, s.duty);
t_thermal_set_floor(2000);
TEST_ASSERT_EQUAL_UINT16(1000, t_thermal_floor()); /* 夹到 1000 */
t_thermal_set_floor(0);
run(&s, 1);
TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, s.duty);
}
/* 两路 NTC 取**高**的那一个:一路在电感区、一路在 MOS 区,凉的那一路完全可能是
「那半边没在用」。取平均的现象是 90 °C 的热点被 30 °C 的凉点平成 60 °C —— 一个
哪儿都不存在、但看着很健康的数,而且平均值**永远不会**触发降载。 */
static void test_two_ntc_take_the_hotter_one(void)
{
thermal_snap_t s;
boot(&s);
s_temp_ok[0] = true; s_temp_mc[0] = 30000;
s_temp_ok[1] = true; s_temp_mc[1] = 90000;
run(&s, 1);
TEST_ASSERT_EQUAL_INT32_MESSAGE(90000, s.temp_max_mc, "两路 NTC 没取高的那一个");
TEST_ASSERT_EQUAL_UINT16(1000, s.duty);
}
/* ---- ③ TR-6:采样窗口 ---------------------------------------------------- */
/* **本包的核心用例。** 风扇转得好好的,只是占空比在下限(30 %),而这种风扇在
PWM 关断期间一个脉冲都没有 ⇒ 按「一个固定墙钟窗口里脉冲数不够就是停转」写的
实现会**稳定误报**。
本用例先证明这个坑是真的(低占空比下每秒脉冲数掉到满速的约 1/6),
再证明我们没掉进去(跑 60 s 一次都没判停转),最后证明我们不是靠「干脆永远不判」
混过去的(窗口照常在结算)—— 把误报换成漏报是这条判据最容易出的「修复」。 */
static void test_low_duty_windows_are_not_mistaken_for_a_stall(void)
{
thermal_snap_t s;
boot(&s);
set_temp(25000); /* 凉 ⇒ 占空比压到下限 */
run(&s, 1);
TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, s.duty);
/* 坑本身:同一只转着的风扇,每秒脉冲数只有满速的一小截。
满速是 11000 RPM × 2 PPR / 60 = 366 脉冲/s;按它的一半(183)做门限的话,
下面这 60 圈**每一圈都会**被判成停转。 */
const uint32_t naive_threshold = FAN_RPM_FULL * T_THERMAL_TACH_PPR / 60u / 2u;
uint32_t worst_per_second = 0xFFFFFFFFu;
const uint32_t edges_0 = sim_fan_edges(0);
for (int i = 0; i < 60; i++) {
const uint32_t before = sim_fan_edges(0);
run(&s, 1);
const uint32_t d = sim_fan_edges(0) - before;
if (d < worst_per_second) {
worst_per_second = d;
}
TEST_ASSERT_TRUE_MESSAGE(sim_fan_rpm(0) > 0, "假风扇自己停了,这个用例就白测了");
TEST_ASSERT_FALSE_MESSAGE(s.fan[0].stalled, "低占空比被误判成停转(TR-6)");
TEST_ASSERT_FALSE_MESSAGE(s.fan[1].stalled, "低占空比被误判成停转(TR-6)");
}
TEST_ASSERT_TRUE_MESSAGE(worst_per_second < naive_threshold,
"假风扇没有重现 TR-6 的场景(关断期照样吐脉冲),这个用例失去意义");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, s.fan[0].stalls, "误报了停转");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, s.fan[1].stalls, "误报了停转");
/* 没有靠「永远不判」混过去:60 s 里窗口按导通时间结算了好几个,转速也出来了。 */
TEST_ASSERT_TRUE_MESSAGE(s.fan[0].windows >= 3, "窗口根本没结算过 —— 把误报换成了漏报");
TEST_ASSERT_TRUE_MESSAGE(s.fan[0].rpm_valid, "没算出转速");
TEST_ASSERT_TRUE_MESSAGE(s.fan[0].rpm > 0, "转速算成了 0");
/* ---- 机制本身:窗口**跟着占空比自动拉长了** ----
这两条才是判据成立的理由。固定墙钟窗口(比如「每圈判一次」)在这 60 s 里会
结算约 57 个窗口、每个窗口只有 56 个脉冲 —— 离门限只剩 14 倍余量,而占空比
再低一截余量就没了,那时它才开始误报。**误报是有门槛的**:按今天这个占空比
测不出来,等将来限功率把风扇压到 5 % 才出事,那时没人会想到是这里。
按导通时间拉长窗口之后,「窗口里该有多少脉冲」与占空比无关,余量恒定。 */
const uint32_t settled = s.fan[0].windows;
const uint32_t counted = sim_fan_edges(0) - edges_0;
TEST_ASSERT_TRUE_MESSAGE(settled <= 12, "窗口没有跟着占空比拉长(还是一圈判一次)");
TEST_ASSERT_TRUE_MESSAGE(counted / settled >= 50u * T_THERMAL_TACH_MIN_EDGES,
"每个窗口的脉冲数离门限太近 —— 占空比再低一截就要误报了");
}
/* 真停转(PWM 满速 + 风扇一个脉冲也不吐)必须判得出来,而且要记一条事件。
写错了会怎样:风扇卡死/线脱落之后板子一声不吭地继续满载跑,
现象是「某台机器偶尔重启」,查到风扇上要花几天。 */
static void test_a_real_stall_is_caught_and_logged(void)
{
TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init());
evlog_flush(0); /* 把 BOOT 那条落掉,后面的序号就是本用例自己的 */
const uint32_t seq = next_seq();
thermal_snap_t s;
boot(&s);
/* 70 °C:满速(导通时间攒得最快,判据也最快),但**够不着降载门限** ——
本用例数的是日志条数,混进一条降载事件就分不清是谁记的了。 */
set_temp(70000);
run(&s, 2);
TEST_ASSERT_EQUAL_UINT16(1000, s.duty);
sim_fan_set_stalled(0, true);
sim_fan_set_stalled(1, true);
run(&s, T_THERMAL_TACH_ON_MS / 1000u + 2u);
TEST_ASSERT_TRUE_MESSAGE(s.fan[0].stalled, "真停转没判出来");
TEST_ASSERT_TRUE_MESSAGE(s.fan[1].stalled, "真停转没判出来");
TEST_ASSERT_EQUAL_UINT32(1, s.fan[0].stalls);
/* 持续停着不会每个窗口再记一条 —— 256 条的 RAM 缓冲经不起每 3 s 一条,
真正救命的过压/反灌事件会被挤掉,而日志看上去「记录得非常完整」。 */
run(&s, 20);
TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, s.fan[0].stalls, "停转事件在刷屏");
TEST_ASSERT_EQUAL_UINT32(2, evlog_flush(0)); /* 两只风扇各一条 */
evlog_rec_t r[2];
memset(r, 0, sizeof(r));
TEST_ASSERT_EQUAL_UINT32(2, evlog_read(seq, r, 2));
TEST_ASSERT_EQUAL_UINT8(LOG_EV_FAN_STALL, r[0].type);
TEST_ASSERT_EQUAL_UINT8_MESSAGE(0, r[0].rail, "风扇是整板级事件,rail 应当是 0");
TEST_ASSERT_EQUAL_UINT8_MESSAGE(0, r[0].arg, "第一条应当是进风扇(arg = 风扇号)");
TEST_ASSERT_EQUAL_UINT8(LOG_EV_FAN_STALL, r[1].type);
TEST_ASSERT_EQUAL_UINT8_MESSAGE(1, r[1].arg, "第二条应当是排风扇");
}
/* 两路 TACH 是**独立**的两套(Q9/Q10 各一路)。一只停了不能把另一只也报停 ——
报串了的现象是「两只一起坏」,于是维修先去查共用的 12V/PWM,而真相是某一只的
线掉了。这在真板上是最容易出现的单点故障(KF2510 插头)。 */
static void test_two_tach_channels_do_not_bleed_into_each_other(void)
{
TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init());
evlog_flush(0);
thermal_snap_t s;
boot(&s);
set_temp(70000); /* 满速但不降载,理由同上一个用例 */
run(&s, 2);
sim_fan_set_stalled(0, true); /* 只停进风那只 */
run(&s, T_THERMAL_TACH_ON_MS / 1000u + 2u);
TEST_ASSERT_TRUE_MESSAGE(s.fan[0].stalled, "停掉的那只没报");
TEST_ASSERT_FALSE_MESSAGE(s.fan[1].stalled, "另一只被连坐了");
TEST_ASSERT_EQUAL_UINT32(1, s.fan[0].stalls);
TEST_ASSERT_EQUAL_UINT32(0, s.fan[1].stalls);
TEST_ASSERT_TRUE_MESSAGE(s.fan[1].rpm > 0, "还在转的那只转速被算成 0");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, evlog_flush(0), "事件条数不是 1");
}
/* 启动宽限:`FAN_EN` 刚拉高的头几秒风扇还在起转,这时候判停转就是**每次上电必报**。
报多了的代价不是噪声,是这条告警从此没人看 —— 等到真停转那天它照常发出来、
照常被划过去。
⚠️ 宽限时长必须盖得住「播种一圈 + 一个完整判据窗口」,否则第一个窗口在宽限
到期之后才结算,宽限判断成了死代码(见 `T_THERMAL_SPINUP_MS` 上的警告)。
本用例就是钉这件事的:把宽限判断整段删掉,它必须变红。 */
static void test_spinup_grace_suppresses_the_first_windows(void)
{
thermal_snap_t s;
TEST_ASSERT_EQUAL_INT(HAL_OK, t_thermal_init(&s, temp_src, tach_src, NULL));
set_temp(70000); /* 满速:窗口攒得最快,也就最容易在宽限内结算出一个假停转 */
/* 起转期间一个脉冲也没有(真风扇就是这样:12V 刚通,转子还没动)。 */
sim_fan_set_stalled(0, true);
sim_fan_set_stalled(1, true);
run(&s, T_THERMAL_SPINUP_MS / 1000u - 1u); /* 还在宽限内 */
TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, s.fan[0].stalls, "启动宽限内就报了停转");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, s.fan[1].stalls, "启动宽限内就报了停转");
sim_fan_set_stalled(0, false);
sim_fan_set_stalled(1, false);
run(&s, 10);
TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, s.fan[0].stalls, "起转完成后仍然报了停转");
TEST_ASSERT_FALSE(s.fan[0].stalled);
/* 宽限过后判据要**真的在工作**(不是被宽限永久关掉了)。 */
TEST_ASSERT_TRUE_MESSAGE(s.fan[0].windows > 0, "宽限之后判据没恢复");
}
/* ---- ④ 过温降载:记一条,并且只记一条 ----------------------------------- */
/* 温度在门限上抖是常态(±0.5 °C 的 ADC 噪声足够)。没有迟滞的现象是每秒一条
降载事件,4 分多钟就把 256 条的 RAM 环形缓冲刷满一圈,把过压/反灌那些真正
救命的事件挤掉 —— 而 `evlog` 只会在下一次落盘时留下一条 LOST,没人会注意。 */
static void test_derate_is_edge_triggered_with_hysteresis(void)
{
TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init());
evlog_flush(0);
const uint32_t seq = next_seq();
thermal_snap_t s;
boot(&s);
set_temp(T_THERMAL_DERATE_MC - 1000);
run(&s, 1);
TEST_ASSERT_FALSE(s.derate);
set_temp(T_THERMAL_DERATE_MC + 500);
run(&s, 1);
TEST_ASSERT_TRUE(s.derate);
TEST_ASSERT_EQUAL_UINT32(1, s.derates);
/* 在门限上抖 20 次:迟滞没做的话这里会变成 20 条事件。 */
for (int i = 0; i < 20; i++) {
set_temp(T_THERMAL_DERATE_MC + ((i % 2) ? 500 : -500));
run(&s, 1);
}
TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, s.derates, "降载事件在刷屏(迟滞没做)");
/* 真凉下来才解除,再热起来才允许记第二条。 */
set_temp(T_THERMAL_DERATE_CLEAR_MC - 1000);
run(&s, 1);
TEST_ASSERT_FALSE(s.derate);
set_temp(T_THERMAL_DERATE_MC + 500);
run(&s, 1);
TEST_ASSERT_EQUAL_UINT32(2, s.derates);
TEST_ASSERT_EQUAL_UINT32(2, evlog_flush(0));
evlog_rec_t r[2];
memset(r, 0, sizeof(r));
TEST_ASSERT_EQUAL_UINT32(2, evlog_read(seq, r, 2));
TEST_ASSERT_EQUAL_UINT8(LOG_EV_THERMAL_DERATE, r[0].type);
/* `evlog.h` 对这个类型的约定:bus_mv 放温度 m°C。放错了日志里就是一个
看着完全合法、但意思全错的数(比如把它当成电压)。 */
TEST_ASSERT_EQUAL_INT32_MESSAGE(T_THERMAL_DERATE_MC + 500, r[0].bus_mv,
"降载事件里没记温度(evlog.h:bus_mv = 温度 m°C)");
}
/* `t_thermal` 一次也不许碰 flash:唯一碰 flash 的是 `t_log`(`evlog.h` 文件头)。
在 1 s 周期的任务里写 flash 的现象是**偶发的几十毫秒卡顿**,而它和高优先级的
`t_safety` 抢的是同一颗芯片的总线 —— 20 ms 的巡检节拍会跟着抖,查起来毫无线索。 */
static void test_thermal_never_touches_flash(void)
{
TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init());
evlog_flush(0);
const uint32_t writes = sim_store_writes();
thermal_snap_t s;
boot(&s);
set_temp(95000); /* 过温 ⇒ 记降载 */
run(&s, 2);
sim_fan_set_stalled(0, true);
run(&s, 10); /* 停转 ⇒ 再记事件 */
TEST_ASSERT_TRUE(s.derates > 0 && s.fan[0].stalls > 0);
TEST_ASSERT_EQUAL_UINT32_MESSAGE(writes, sim_store_writes(), "t_thermal 自己落盘了");
}
/* ---- ⑤ 没 init 就 step ---------------------------------------------------- */
/* 启动顺序排错(A15 的降级矩阵)时,这个函数会在 init 失败之后照样被调到。
返回 HAL_ERR_STATE 而不是「拿一个全 0 的快照继续跑」—— 后者的现象是
风扇停在 0 %、温度报 0 °C、TACH 永远读不到,看上去一切正常。
⚠️ 这一条**必须排在第一个跑**:`s_inited` 是模块级状态,`sim_hal_reset()`
管不着它(那是假板子的开关,不是固件的),init 过一次就回不去了。 */
static void test_step_before_init_is_refused(void)
{
thermal_snap_t s;
memset(&s, 0, sizeof(s));
TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, t_thermal_step(&s));
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, t_thermal_step(NULL));
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_step_before_init_is_refused); /* 必须第一个,见函数上的警告 */
RUN_TEST(test_init_drives_fan_en_high_and_fans_actually_spin);
RUN_TEST(test_hotter_never_means_slower);
RUN_TEST(test_duty_curve_is_monotonic_and_bounded);
RUN_TEST(test_unreadable_temperature_means_full_speed);
RUN_TEST(test_manual_floor_only_raises_the_duty);
RUN_TEST(test_two_ntc_take_the_hotter_one);
RUN_TEST(test_low_duty_windows_are_not_mistaken_for_a_stall);
RUN_TEST(test_a_real_stall_is_caught_and_logged);
RUN_TEST(test_two_tach_channels_do_not_bleed_into_each_other);
RUN_TEST(test_spinup_grace_suppresses_the_first_windows);
RUN_TEST(test_derate_is_edge_triggered_with_hysteresis);
RUN_TEST(test_thermal_never_touches_flash);
return UNITY_END();
}