文件
zhou yang和Claude Opus 5.5 ece65f7c19 固件 0.1.19~0.1.21-c5:配网热点(SoftAP + 网页)、风扇 40 ℃ 以下断电
- 0.1.19 配网热点:没配 WiFi / 连续 60 s 连不上时自己开 SoftAP,192.168.4.1 网页里加删 WiFi、改 MQTT;
  配置拼成 wifi / mqtt 命令走唯一的命令处理器(新链路号 3 CMD_LINK_AP),MQTT 仍不收配置命令
- 0.1.20 热点口令固定 88888888
- 0.1.21 风扇:板温 >=40 ℃ 上电、<38 ℃ 断电,40~65 ℃ 线性调速;fan 命令加可选 sec(下限到点自动交回)
- mqtt 命令加 keep_pass;net 回应加 ap;status 的 fan 加 left
- 新增 test_prov(13 例),test_t_thermal +3;29 ctest 全绿

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 14:35:44 +08:00

641 行
29 KiB
C

此文件含有模棱两可的 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(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();
}