/* A16 / R-6:风扇闭环与 TACH 采样窗口(需求 FR-10 / FR-13 / TR-6,计划 §5.10)。 这一包的判据全部长在三件**写错了不会报错**的事情上: ① `FAN_EN` 复位态是「不转」。忘了主动拉高 ⇒ 温度闭环、PWM、遥测全部正常, 只是一点风都没有。功能测试看不出来,因为没有任何一个返回值会变。 ② PWM 经 Q6 反相。写反了 ⇒ **越热转得越慢**,而遥测里「占空比随温度上升」 这句话仍然是对的 —— 看仪表盘的人永远发现不了。 ③ TACH 在 PWM 关断期间没有脉冲(TR-6)。按「这一圈没数到脉冲就是停转」写 ⇒ 低占空比下**稳定误报**,大家很快学会无视风扇告警,等到真停转那天 告警照常发出来、照常没人看。 所以下面的断言一律打在**假风扇**这个可观测面上(转速、边沿数、引脚电平), 不去对着模块自己的影子变量断言 —— 那是自己证明自己。 */ #include #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(T_THERMAL_TEMP_LO_MC); /* 曲线起点:风扇开着、按下限转(再凉就断电了,见最后一组用例) */ 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(T_THERMAL_TEMP_LO_MC); 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(T_THERMAL_TEMP_LO_MC); 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(T_THERMAL_TEMP_LO_MC); 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(T_THERMAL_TEMP_LO_MC); /* 凉 ⇒ 占空比压到下限 */ 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)); } /* ---- ⑤ 0.1.21:凉了断电 -------------------------------------------------- */ /* ⭐ 40 °C 以下风扇断电:撤的是 `FAN_EN`(四线风扇 0 % PWM 仍在转),并且带迟滞 —— 38 °C 以下才关、回到 40 °C 才开,中间维持原状。 写错了会怎样:没有迟滞 ⇒ 板温在 40 °C 上下晃时风扇几秒启停一次;只压占空比不断电 ⇒ 风扇照转。 */ static void test_cool_board_cuts_fan_power_with_hysteresis(void) { thermal_snap_t s; boot(&s); TEST_ASSERT_TRUE(s.fan_en); TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, s.duty); TEST_ASSERT_EQUAL_UINT32(1, s.fan_starts); set_temp(T_THERMAL_FAN_OFF_MC); /* 正好在关断点上:还开着(低于才关) */ run(&s, 3); TEST_ASSERT_TRUE(s.fan_en); TEST_ASSERT_TRUE(sim_fan_rpm(0) > 0); set_temp(T_THERMAL_FAN_OFF_MC - 1); run(&s, 1); TEST_ASSERT_FALSE(s.fan_en); TEST_ASSERT_EQUAL_INT_MESSAGE(0, sim_gpio_level(BOARD_PIN_FAN_EN), "凉了没撤 FAN_EN"); TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, sim_fan_rpm(0), "风扇还在转"); TEST_ASSERT_EQUAL_UINT32(0, sim_fan_rpm(1)); TEST_ASSERT_EQUAL_UINT16_MESSAGE(0, s.duty, "断着电时遥测里的占空比该是 0"); set_temp(T_THERMAL_FAN_ON_MC - 1); /* 回到两个门限之间:不开 */ run(&s, 5); TEST_ASSERT_FALSE(s.fan_en); TEST_ASSERT_EQUAL_UINT32(1, s.fan_starts); set_temp(T_THERMAL_FAN_ON_MC); run(&s, 1); TEST_ASSERT_TRUE(s.fan_en); TEST_ASSERT_EQUAL_INT(1, sim_gpio_level(BOARD_PIN_FAN_EN)); TEST_ASSERT_TRUE(sim_fan_rpm(0) > 0); TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, s.duty); TEST_ASSERT_EQUAL_UINT32(2, s.fan_starts); /* 在门限之间来回晃 20 圈:一次都不许再启停。 */ for (int i = 0; i < 20; i++) { set_temp((i & 1) ? T_THERMAL_FAN_ON_MC - 100 : T_THERMAL_FAN_OFF_MC + 100); run(&s, 1); } TEST_ASSERT_TRUE(s.fan_en); TEST_ASSERT_EQUAL_UINT32_MESSAGE(2, s.fan_starts, "门限之间晃动引起了启停"); } /* ⭐ 断电期间不报停转;重新上电先过启动宽限;转起来之后判据照常工作。 一只**本来就没插**的风扇(一直判停转)不因为每次启停再各记一条事件。 */ static void test_power_cycling_the_fan_does_not_raise_stall_alarms(void) { TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); evlog_flush(0); const uint32_t seq = next_seq(); thermal_snap_t s; sim_fan_set_stalled(1, true); /* 第二只没插 */ boot(&s); set_temp(70000); run(&s, T_THERMAL_TACH_ON_MS / 1000u + 2u); TEST_ASSERT_FALSE(s.fan[0].stalled); TEST_ASSERT_TRUE(s.fan[1].stalled); TEST_ASSERT_EQUAL_UINT32(1, s.fan[1].stalls); for (int k = 0; k < 3; k++) { set_temp(30000); /* 凉:断电 */ run(&s, 40); /* 比任何判据窗口都长 */ TEST_ASSERT_FALSE(s.fan_en); TEST_ASSERT_FALSE_MESSAGE(s.fan[0].stalled, "断着电被判成了停转"); TEST_ASSERT_TRUE(s.fan[0].rpm_valid); TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, s.fan[0].rpm, "断着电转速没报 0"); set_temp(70000); /* 热:上电满速 */ run(&s, T_THERMAL_SPINUP_MS / 1000u + T_THERMAL_TACH_ON_MS / 1000u + 3u); TEST_ASSERT_TRUE(s.fan_en); TEST_ASSERT_FALSE(s.fan[0].stalled); TEST_ASSERT_TRUE_MESSAGE(s.fan[0].rpm > 5000, "重新上电后转速没恢复"); } TEST_ASSERT_EQUAL_UINT32(0, s.fan[0].stalls); TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, s.fan[1].stalls, "没插的那只每次启停都又记了一次"); evlog_flush(0); TEST_ASSERT_EQUAL_UINT32_MESSAGE(seq + 1, next_seq(), "启停期间多记了事件"); /* 上着电时真停转照样抓得到(判据没有被「断电不判」顺手关掉)。 */ sim_fan_set_stalled(0, true); run(&s, T_THERMAL_TACH_ON_MS / 1000u + 2u); TEST_ASSERT_TRUE(s.fan[0].stalled); } /* ⭐ 凉着也必须有风的两种情况:温度读不到(§10.10)、上位机给了下限。 带时限的下限到期自动回落 —— 上位机挂了,风扇不会永远停在它最后要的转速上。 */ static void test_cool_fan_still_runs_when_asked_or_blind(void) { thermal_snap_t s; boot(&s); set_temp(30000); run(&s, 2); TEST_ASSERT_FALSE(s.fan_en); s_temp_ok[0] = false; s_temp_ok[1] = false; run(&s, 1); TEST_ASSERT_TRUE_MESSAGE(s.fan_en, "温度读不到时风扇没开"); TEST_ASSERT_EQUAL_UINT16(1000, s.duty); TEST_ASSERT_TRUE(sim_fan_rpm(0) > 0); set_temp(30000); run(&s, 1); TEST_ASSERT_FALSE(s.fan_en); t_thermal_set_floor_for(600, 30000); /* 30 s */ run(&s, 1); TEST_ASSERT_TRUE_MESSAGE(s.fan_en, "上位机要风时风扇没开"); TEST_ASSERT_EQUAL_UINT16(600, s.duty); TEST_ASSERT_EQUAL_UINT32(29000, t_thermal_floor_left_ms()); run(&s, 28); TEST_ASSERT_TRUE(s.fan_en); TEST_ASSERT_EQUAL_UINT16(600, t_thermal_floor()); run(&s, 1); /* 第 30 秒:到期 */ TEST_ASSERT_EQUAL_UINT16_MESSAGE(0, t_thermal_floor(), "下限到期没回落"); TEST_ASSERT_EQUAL_UINT32(0, t_thermal_floor_left_ms()); TEST_ASSERT_FALSE(s.fan_en); /* 续期 = 再发一次:时限从这一刻重新算。 */ t_thermal_set_floor_for(500, 10000); run(&s, 8); t_thermal_set_floor_for(500, 10000); run(&s, 8); TEST_ASSERT_TRUE(s.fan_en); run(&s, 3); TEST_ASSERT_FALSE(s.fan_en); /* 不带时限的下限一直保持(老行为),时钟回绕也不会让它「到期」。 */ t_thermal_set_floor(400); run(&s, 1); sim_time_advance_ms(0x80000000u); run(&s, 1); TEST_ASSERT_EQUAL_UINT16(400, t_thermal_floor()); TEST_ASSERT_TRUE(s.fan_en); t_thermal_set_floor(0); } 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); RUN_TEST(test_cool_board_cuts_fan_power_with_hysteresis); RUN_TEST(test_power_cycling_the_fan_does_not_raise_stall_alarms); RUN_TEST(test_cool_fan_still_runs_when_asked_or_blind); return UNITY_END(); }