固件 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]>
这个提交包含在:
zhou yang
2026-10-06 14:35:44 +08:00
共同撰写人 Claude Opus 5.5
父节点 3bf4548c62
当前提交 ece65f7c19
共修改 24 个文件,包含 1925 行新增和 324 行删除
+14
查看文件
@@ -592,6 +592,20 @@ static void test_fan_floor(void)
TEST_ASSERT_EQUAL_UINT16(700, t_thermal_floor());
ASSERT_HAS(send("{\"seq\":2,\"cmd\":\"fan\",\"duty\":0}"), "\"floor\":0");
TEST_ASSERT_EQUAL_UINT16(0, t_thermal_floor());
/* 0.1.21 `sec`:带时限的下限(到期回落在 test_t_thermal 里测)。没给 sec 的回应里不带它。 */
ASSERT_LACKS(r, "\"sec\"");
ASSERT_HAS(send("{\"seq\":3,\"cmd\":\"fan\",\"duty\":600,\"sec\":90}"), "\"floor\":600,\"sec\":90");
TEST_ASSERT_EQUAL_UINT32(90000, t_thermal_floor_left_ms());
ASSERT_HAS(send("{\"seq\":4,\"cmd\":\"fan\",\"duty\":600}"), "\"floor\":600"); /* 不带 sec = 改成一直保持 */
TEST_ASSERT_EQUAL_UINT32(0, t_thermal_floor_left_ms());
ASSERT_HAS(send("{\"seq\":5,\"cmd\":\"fan\",\"duty\":600,\"sec\":86401}"), "\"err\":\"range\"");
ASSERT_HAS(send("{\"seq\":6,\"cmd\":\"fan\",\"duty\":600,\"sec\":-1}"), "\"err\":\"range\"");
ASSERT_HAS(send("{\"seq\":7,\"cmd\":\"fan\",\"duty\":600,\"sec\":\"90\"}"), "\"err\":\"arg\"");
TEST_ASSERT_EQUAL_UINT16(600, t_thermal_floor());
ASSERT_HAS(send("{\"seq\":8,\"cmd\":\"fan\",\"duty\":0,\"sec\":90}"), "\"floor\":0"); /* 0 就是交回自动,时限无意义 */
TEST_ASSERT_EQUAL_UINT32(0, t_thermal_floor_left_ms());
t_thermal_set_floor(0);
}
/* 0.1.14 hub:改模式 / 保持窗口,两条链路都收;越界记事件、缺参数不记;
+350
查看文件
@@ -0,0 +1,350 @@
/* 0.1.19:配网热点(prov.h)的平台无关部分,加上「配置命令从哪条链路收」的新口径。
测的是上板不好查、写错了又很疼的几件事:
· 热点**什么时候开、什么时候关** —— 开早了白白露一个入口,关早了人正填着表单就断;
· STA 连不上时热点**不许自己消失**(消失 = 又只剩拆机插 USB);
· 网页表单拼出来的命令行**拼不出别的命令**,带引号的字段进不来;
· 配置命令:热点链路收、MQTT 照旧拒;`keep_pass` 不丢旧口令;`net` 报热点状态。 */
#include <stdio.h>
#include <string.h>
#include "cmd.h"
#include "evlog.h"
#include "netcfg.h"
#include "prov.h"
#include "sim_hal.h"
#include "unity.h"
static char s_out[CMD_OUT_MAX];
static netcfg_t s_saved;
static int s_saves;
static hal_err_t fake_save(void *ctx, const netcfg_t *cfg)
{
(void)ctx;
s_saved = *cfg;
s_saves++;
return HAL_OK;
}
void setUp(void)
{
sim_hal_reset();
memset(&s_saved, 0, sizeof(s_saved));
s_saves = 0;
netcfg_init(NULL, fake_save, NULL, NULL);
TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init());
cmd_init(NULL);
prov_init(0);
}
void tearDown(void) {}
static const char *send_on(cmd_link_t link, const char *line)
{
cmd_handle_line(link, line, s_out, sizeof(s_out));
return s_out;
}
#define ASSERT_HAS(r, n) UNITY_TEST_ASSERT_NOT_NULL(strstr((r), (n)), __LINE__, (r))
#define ASSERT_LACKS(r, n) UNITY_TEST_ASSERT_NULL(strstr((r), (n)), __LINE__, (r))
static prov_out_t step(int n_wifi, bool up, int sta, bool touched, uint32_t now)
{
const prov_in_t in = { .n_wifi = n_wifi, .wifi_up = up, .ap_sta = sta, .touched = touched };
return prov_step(&in, now);
}
/* ---- 判据 ------------------------------------------------------------------ */
/* ⭐ 一组都没配:第一拍就开,不等 60 s。 */
static void test_no_config_opens_at_once(void)
{
const prov_out_t o = step(0, false, 0, false, 0);
TEST_ASSERT_TRUE(o.ap);
TEST_ASSERT_EQUAL_INT(PROV_WHY_NOCFG, o.why);
TEST_ASSERT_FALSE(o.hold);
}
/* ⭐ 配了但连不上:满 60 s 才开;之后一直开着,哪怕再过几天。 */
static void test_failure_opens_after_a_minute_and_stays(void)
{
TEST_ASSERT_FALSE(step(1, false, 0, false, 0).ap);
TEST_ASSERT_FALSE(step(1, false, 0, false, PROV_FAIL_MS - 1).ap);
prov_out_t o = step(1, false, 0, false, PROV_FAIL_MS);
TEST_ASSERT_TRUE(o.ap);
TEST_ASSERT_EQUAL_INT(PROV_WHY_FAIL, o.why);
/* 时钟回绕(49.7 天)之后「断了多久」会变小 —— 不许因此关掉。 */
TEST_ASSERT_TRUE(step(1, false, 0, false, 0xFFFFFF00u).ap);
TEST_ASSERT_TRUE(step(1, false, 0, false, 0x00000100u).ap);
}
/* 连上过又掉:从掉的那一刻重新数 60 s(路由器重启一下不该立刻冒热点)。 */
static void test_a_drop_restarts_the_minute(void)
{
TEST_ASSERT_FALSE(step(1, true, 0, false, 1000).ap);
TEST_ASSERT_FALSE(step(1, true, 0, false, 500000).ap);
TEST_ASSERT_FALSE(step(1, false, 0, false, 500100).ap);
TEST_ASSERT_FALSE(step(1, false, 0, false, 500100 + PROV_FAIL_MS - 1).ap);
TEST_ASSERT_TRUE(step(1, false, 0, false, 500100 + PROV_FAIL_MS).ap);
/* 没满 60 s 就连回来了:计时作废。 */
prov_init(0);
(void)step(1, false, 0, false, 0);
(void)step(1, true, 0, false, 30000);
(void)step(1, false, 0, false, 40000);
TEST_ASSERT_FALSE(step(1, false, 0, false, 70000).ap); /* 离启动 70 s,但离这次掉线才 30 s */
}
/* ⭐ STA 连上了:热点上没人立刻关;有人再留 60 s(让网页看到「连上了」),到点照关。 */
static void test_closes_when_sta_comes_up(void)
{
TEST_ASSERT_TRUE(step(1, false, 0, false, PROV_FAIL_MS).ap);
TEST_ASSERT_FALSE(step(1, true, 0, false, PROV_FAIL_MS + 100).ap);
prov_init(0);
TEST_ASSERT_TRUE(step(1, false, 1, false, PROV_FAIL_MS).ap);
prov_out_t o = step(1, true, 1, false, 100000);
TEST_ASSERT_TRUE(o.ap);
TEST_ASSERT_EQUAL_INT(PROV_WHY_LINGER, o.why);
TEST_ASSERT_FALSE(o.hold); /* 已经连上了,没有重连可放慢 */
TEST_ASSERT_TRUE(step(1, true, 1, false, 100000 + PROV_LINGER_MS - 1).ap);
TEST_ASSERT_FALSE(step(1, true, 1, false, 100000 + PROV_LINGER_MS).ap);
/* 留的那一分钟里人走了:马上关,不等到点。 */
prov_init(0);
(void)step(1, false, 1, false, PROV_FAIL_MS);
TEST_ASSERT_TRUE(step(1, true, 1, false, 100000).ap);
TEST_ASSERT_FALSE(step(1, true, 0, false, 101000).ap);
/* 关了就是关了:连着的时候不会再自己开。 */
TEST_ASSERT_FALSE(step(1, true, 1, false, 102000).ap);
}
/* ⭐ 没配网时开的热点:人在网页上刚存了第一组 WiFi(n 0 → 1,离启动还不到 60 s)——
不许在这一刻把热点关掉,要等 STA 真的连上。 */
static void test_first_config_does_not_close_the_ap(void)
{
TEST_ASSERT_TRUE(step(0, false, 1, true, 5000).ap);
prov_out_t o = step(1, false, 1, true, 6000);
TEST_ASSERT_TRUE(o.ap);
TEST_ASSERT_EQUAL_INT(PROV_WHY_FAIL, o.why);
TEST_ASSERT_TRUE(o.hold);
TEST_ASSERT_TRUE(step(1, true, 1, false, 9000).ap); /* 连上了:linger */
}
/* 放慢重连:热点上有人 + 最近 5 分钟动过网页。连上热点本身算一次动作;状态轮询不算。 */
static void test_hold_follows_activity(void)
{
TEST_ASSERT_FALSE(step(1, false, 0, false, PROV_FAIL_MS).hold); /* 没人 */
TEST_ASSERT_TRUE(step(1, false, 1, false, 70000).hold); /* 刚连上 */
TEST_ASSERT_TRUE(step(1, false, 1, false, 70000 + PROV_HOLD_MS - 1).hold);
TEST_ASSERT_FALSE(step(1, false, 1, false, 70000 + PROV_HOLD_MS).hold); /* 连着但 5 分钟没动:恢复正常节奏 */
TEST_ASSERT_TRUE(step(1, false, 1, true, 900000).hold); /* 又点了一下 */
TEST_ASSERT_FALSE(step(1, false, 1, false, 900000 + PROV_HOLD_MS).hold);
/* 过期的窗口不许在时钟回绕后复活(人一直连着没走)。 */
TEST_ASSERT_FALSE(step(1, false, 1, false, 900000u + 0xFFFFFF00u).hold);
TEST_ASSERT_TRUE(step(1, false, 1, true, 5000).hold);
TEST_ASSERT_FALSE(step(1, false, 0, false, 6000).hold); /* 人走了 */
TEST_ASSERT_TRUE(step(1, false, 0, false, 7000).ap); /* 热点还在 */
TEST_ASSERT_GREATER_OR_EQUAL_UINT32(10000, PROV_SLOW_MS); /* 一次连接尝试 ≈3 s,间隔得明显比它长 */
}
/* ---- 名字与口令 ------------------------------------------------------------ */
static void test_ssid_and_password(void)
{
char ssid[PROV_SSID_MAX];
prov_ssid("b81f3fc9c8bc", ssid);
TEST_ASSERT_EQUAL_STRING("PhoneRail-c9c8bc", ssid);
prov_ssid("", ssid);
TEST_ASSERT_EQUAL_STRING("PhoneRail-", ssid);
/* 口令是固定值:WPA2 收 8~63 位;装得进 net_status_t,也不需要 JSON 转义。 */
TEST_ASSERT_EQUAL_STRING("88888888", PROV_PASS);
TEST_ASSERT_TRUE(strlen(PROV_PASS) >= 8 && strlen(PROV_PASS) <= 63);
TEST_ASSERT_NULL(strpbrk(PROV_PASS, "\"\\"));
}
/* ---- 表单 → 命令行 --------------------------------------------------------- */
static const char *build(const char *body, const char **err)
{
static char line[CMD_LINE_MAX];
line[0] = '\0';
const size_t n = prov_build_line(body, 77, line, sizeof(line), err);
TEST_ASSERT_EQUAL_UINT(n, strlen(line));
return line;
}
/* ⭐ 拼出来的行能被命令处理器原样吃下去、存进后端。 */
static void test_form_becomes_the_usb_commands(void)
{
const char *err = "x";
const char *l = build("wifi\nfarm 2g\ns3cret pass", &err);
TEST_ASSERT_NULL(err);
TEST_ASSERT_EQUAL_STRING("{\"seq\":77,\"cmd\":\"wifi\",\"ssid\":\"farm 2g\",\"pass\":\"s3cret pass\"}", l);
ASSERT_HAS(send_on(CMD_LINK_AP, l), "\"ok\":true,\"n\":1");
TEST_ASSERT_EQUAL_STRING("s3cret pass", s_saved.wifi[0].pass);
/* 开放网络:口令那一行可以是空的,也可以整行没有;行尾的 \r 不算内容。 */
TEST_ASSERT_EQUAL_STRING("{\"seq\":77,\"cmd\":\"wifi\",\"ssid\":\"open\",\"pass\":\"\"}", build("wifi\nopen\n", &err));
TEST_ASSERT_EQUAL_STRING("{\"seq\":77,\"cmd\":\"wifi\",\"ssid\":\"open\",\"pass\":\"\"}", build("wifi\r\nopen\r", &err));
/* 中文名字(UTF-8)原样过。 */
ASSERT_HAS(build("wifi\n\xE5\xAE\xB6\n12345678", &err), "\"ssid\":\"\xE5\xAE\xB6\"");
TEST_ASSERT_EQUAL_STRING("{\"seq\":77,\"cmd\":\"wifi\",\"ssid\":\"farm 2g\",\"del\":true}", build("del\nfarm 2g", &err));
cmd_init(NULL); /* 清判重窗口:这里每条都用 seq 77 */
ASSERT_HAS(send_on(CMD_LINK_AP, build("del\nfarm 2g", &err)), "\"ok\":true,\"n\":0");
l = build("mqtt\nmqtts://home.example:8883\nboard-b\nBrokerPw77\n2000", &err);
TEST_ASSERT_EQUAL_STRING("{\"seq\":77,\"cmd\":\"mqtt\",\"uri\":\"mqtts://home.example:8883\",\"user\":\"board-b\","
"\"pass\":\"BrokerPw77\",\"tele_ms\":2000}", l);
cmd_init(NULL); /* 清判重窗口:这里每条都用 seq 77 */
ASSERT_HAS(send_on(CMD_LINK_AP, l), "\"ok\":true");
TEST_ASSERT_EQUAL_UINT32(2000, s_saved.tele_ms);
}
/* ⭐ MQTT 口令留空 = 不改:只换地址时旧口令还在;给了 keep_pass 又给 pass 是自相矛盾。 */
static void test_keep_pass_keeps_the_stored_password(void)
{
send_on(CMD_LINK_USB, "{\"seq\":1,\"cmd\":\"mqtt\",\"uri\":\"mqtt://old:1883\",\"user\":\"u\",\"pass\":\"BrokerPw77\"}");
const char *err = NULL;
const char *l = build("mqtt\nmqtt://new:1883\nu2\n\n", &err);
TEST_ASSERT_EQUAL_STRING("{\"seq\":77,\"cmd\":\"mqtt\",\"uri\":\"mqtt://new:1883\",\"user\":\"u2\","
"\"keep_pass\":true,\"tele_ms\":0}", l);
const char *r = send_on(CMD_LINK_AP, l);
ASSERT_HAS(r, "\"ok\":true");
ASSERT_LACKS(r, "BrokerPw77");
TEST_ASSERT_EQUAL_STRING("mqtt://new:1883", s_saved.uri);
TEST_ASSERT_EQUAL_STRING("u2", s_saved.user);
TEST_ASSERT_EQUAL_STRING("BrokerPw77", s_saved.mpass);
ASSERT_HAS(send_on(CMD_LINK_USB, "{\"seq\":3,\"cmd\":\"mqtt\",\"uri\":\"mqtt://x:1\",\"pass\":\"other\","
"\"keep_pass\":true}"), "\"err\":\"arg\"");
ASSERT_HAS(send_on(CMD_LINK_USB, "{\"seq\":4,\"cmd\":\"mqtt\",\"uri\":\"mqtt://x:1\",\"keep_pass\":1}"),
"\"err\":\"arg\"");
TEST_ASSERT_EQUAL_STRING("BrokerPw77", s_saved.mpass);
/* keep_pass:false 就是老行为:没给 pass = 清空。 */
ASSERT_HAS(send_on(CMD_LINK_USB, "{\"seq\":5,\"cmd\":\"mqtt\",\"uri\":\"mqtt://x:1\",\"keep_pass\":false}"),
"\"ok\":true");
TEST_ASSERT_EQUAL_STRING("", s_saved.mpass);
}
/* ⭐ 拼不出别的命令;能把 JSON 撑破的字符进不来。 */
static void test_form_rejects_what_it_should(void)
{
static const struct {
const char *body;
const char *err;
} k[] = {
{ "", "bad_form" },
{ "wifi", "bad_form" },
{ "wifi\n\n12345678", "bad_form" }, /* 没名字 */
{ "wifi\na\nb\nc", "bad_form" }, /* 多了一行 */
{ "del\na\nb", "bad_form" },
{ "mqtt\n", "bad_form" },
{ "mqtt\nmqtt://h\nu\np\n12x", "bad_form" }, /* tele 不是数字 */
{ "mqtt\nmqtt://h\nu\np\n99999999999999", "bad_form" },
{ "power\n1", "bad_form" }, /* 别的命令 */
{ "status", "bad_form" },
{ "wifi\na\"b\n12345678", "bad_char" },
{ "wifi\na\n1234\\5678", "bad_char" },
{ "wifi\na\tb\n12345678", "bad_char" },
{ "wifi\na\n12345678\",\"cmd\":\"power", "bad_char" }, /* 想往行里塞第二个键 */
{ "wifi\na\nb\nc\nd\ne", "too_long" }, /* 行数超了 */
{ "wifi\n0123456789012345678901234567890123456789012345678901234567890123456789\nx", "too_long" },
};
for (size_t i = 0; i < sizeof(k) / sizeof(k[0]); i++) {
const char *err = NULL;
char line[CMD_LINE_MAX] = "junk";
TEST_ASSERT_EQUAL_UINT_MESSAGE(0, prov_build_line(k[i].body, 1, line, sizeof(line), &err), k[i].body);
TEST_ASSERT_EQUAL_STRING_MESSAGE(k[i].err, err, k[i].body);
}
/* seq 0 不是合法序号;缓冲不够不越界。 */
char small[16];
TEST_ASSERT_EQUAL_UINT(0, prov_build_line("wifi\na\n12345678", 0, small, sizeof(small), NULL));
TEST_ASSERT_EQUAL_UINT(0, prov_build_line("wifi\na\n12345678", 1, small, sizeof(small), NULL));
TEST_ASSERT_EQUAL_STRING("", small);
/* 三个字段都顶到头(64 + 32 + 64)的 mqtt 行也装得进一行命令。 */
char body[256];
char f64[65];
memset(f64, 'p', 64);
f64[64] = '\0';
snprintf(body, sizeof(body), "mqtt\nmqtts://%.*s\n%.32s\n%s\n60000", 56, f64, f64, f64);
char line[CMD_LINE_MAX];
const char *err = "x";
TEST_ASSERT_GREATER_THAN_UINT(0, prov_build_line(body, 0xFFFFFFFFu, line, sizeof(line), &err));
ASSERT_HAS(send_on(CMD_LINK_AP, line), "\"ok\":true");
TEST_ASSERT_EQUAL_STRING(f64, s_saved.mpass);
}
static void test_json_escape(void)
{
char out[64];
TEST_ASSERT_EQUAL_UINT(4, prov_json_esc("farm", out, sizeof(out)));
TEST_ASSERT_EQUAL_STRING("farm", out);
prov_json_esc("a\"b\\c\n", out, sizeof(out));
TEST_ASSERT_EQUAL_STRING("a\\u0022b\\u005cc\\u000a", out);
prov_json_esc("\xE5\xAE\xB6", out, sizeof(out));
TEST_ASSERT_EQUAL_STRING("\xE5\xAE\xB6", out);
char tiny[8];
TEST_ASSERT_EQUAL_UINT(0, prov_json_esc("12345678", tiny, sizeof(tiny)));
TEST_ASSERT_EQUAL_STRING("", tiny);
TEST_ASSERT_EQUAL_UINT(0, prov_json_esc("ab\"", tiny, sizeof(tiny))); /* 转义写到一半不留半截 */
TEST_ASSERT_EQUAL_STRING("", tiny);
}
/* ---- 链路口径 -------------------------------------------------------------- */
/* ⭐ 配置命令:热点链路和 USB 一样收;MQTT 照旧拒(0.1.19 没有放开这一条)。 */
static void test_ap_link_may_configure_but_mqtt_still_may_not(void)
{
ASSERT_HAS(send_on(CMD_LINK_AP, "{\"seq\":1,\"cmd\":\"wifi\",\"ssid\":\"farm\",\"pass\":\"12345678\"}"),
"\"ok\":true");
ASSERT_HAS(send_on(CMD_LINK_AP, "{\"seq\":2,\"cmd\":\"mqtt\",\"uri\":\"mqtt://h:1883\"}"), "\"ok\":true");
TEST_ASSERT_EQUAL_INT(2, s_saves);
ASSERT_HAS(send_on(CMD_LINK_WIFI, "{\"seq\":3,\"cmd\":\"wifi\",\"ssid\":\"evil\",\"pass\":\"12345678\"}"),
"\"err\":\"link\"");
ASSERT_HAS(send_on(CMD_LINK_WIFI, "{\"seq\":4,\"cmd\":\"mqtt\",\"uri\":\"mqtt://1.2.3.4\",\"keep_pass\":true}"),
"\"err\":\"link\"");
TEST_ASSERT_EQUAL_INT(2, s_saves);
TEST_ASSERT_EQUAL_INT(3, CMD_LINK_AP); /* 进事件日志的编号(CMD_REBOOT_ARG),只许追加 */
}
/* `net` 报热点状态;热点口令能读到,WiFi / MQTT 口令照旧读不到。 */
static void test_net_reports_the_ap(void)
{
send_on(CMD_LINK_USB, "{\"seq\":1,\"cmd\":\"wifi\",\"ssid\":\"farm\",\"pass\":\"TopSecret99\"}");
ASSERT_HAS(send_on(CMD_LINK_AP, "{\"cmd\":\"net\"}"),
"\"ap\":{\"on\":false,\"why\":\"off\",\"sta\":0,\"ssid\":\"\",\"pass\":\"\"}");
net_status_t st;
net_status_get(&st);
st.ap_on = true;
st.ap_why = PROV_WHY_FAIL;
st.ap_sta = 1;
snprintf(st.ap_ssid, sizeof(st.ap_ssid), "PhoneRail-c9c8bc");
snprintf(st.ap_pass, sizeof(st.ap_pass), "%s", PROV_PASS);
net_status_set(&st);
const char *r = send_on(CMD_LINK_WIFI, "{\"cmd\":\"net\"}");
ASSERT_HAS(r, "\"ap\":{\"on\":true,\"why\":\"fail\",\"sta\":1,\"ssid\":\"PhoneRail-c9c8bc\",\"pass\":\"88888888\"}");
ASSERT_LACKS(r, "TopSecret99");
TEST_ASSERT_TRUE(sizeof(st.ap_ssid) >= PROV_SSID_MAX && sizeof(st.ap_pass) > strlen(PROV_PASS));
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_no_config_opens_at_once);
RUN_TEST(test_failure_opens_after_a_minute_and_stays);
RUN_TEST(test_a_drop_restarts_the_minute);
RUN_TEST(test_closes_when_sta_comes_up);
RUN_TEST(test_first_config_does_not_close_the_ap);
RUN_TEST(test_hold_follows_activity);
RUN_TEST(test_ssid_and_password);
RUN_TEST(test_form_becomes_the_usb_commands);
RUN_TEST(test_keep_pass_keeps_the_stored_password);
RUN_TEST(test_form_rejects_what_it_should);
RUN_TEST(test_json_escape);
RUN_TEST(test_ap_link_may_configure_but_mqtt_still_may_not);
RUN_TEST(test_net_reports_the_ap);
return UNITY_END();
}
+149 -5
查看文件
@@ -83,7 +83,7 @@ void setUp(void)
{
sim_hal_reset();
fans_up();
set_temp(25000); /* 25 °C:常温开机 */
set_temp(T_THERMAL_TEMP_LO_MC); /* 曲线起点:风扇开着、按下限转(再凉就断电了,见最后一组用例) */
t_thermal_set_floor(0); /* 下限是模块级静态量,不随 init 清零(t_thermal.h) */
}
void tearDown(void) {}
@@ -186,7 +186,7 @@ static void test_unreadable_temperature_means_full_speed(void)
{
thermal_snap_t s;
boot(&s);
set_temp(25000);
set_temp(T_THERMAL_TEMP_LO_MC);
run(&s, 1);
TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, s.duty);
@@ -205,7 +205,7 @@ static void test_manual_floor_only_raises_the_duty(void)
{
thermal_snap_t s;
boot(&s);
set_temp(25000);
set_temp(T_THERMAL_TEMP_LO_MC);
t_thermal_set_floor(800);
run(&s, 1);
TEST_ASSERT_EQUAL_UINT16(800, s.duty);
@@ -215,7 +215,7 @@ static void test_manual_floor_only_raises_the_duty(void)
TEST_ASSERT_EQUAL_UINT16(1000, s.duty);
t_thermal_set_floor(T_THERMAL_DUTY_MIN / 2); /* 比曲线下限还低:没有效果 */
set_temp(25000);
set_temp(T_THERMAL_TEMP_LO_MC);
run(&s, 1);
TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, s.duty);
@@ -253,7 +253,7 @@ static void test_low_duty_windows_are_not_mistaken_for_a_stall(void)
{
thermal_snap_t s;
boot(&s);
set_temp(25000); /* 凉 ⇒ 占空比压到下限 */
set_temp(T_THERMAL_TEMP_LO_MC); /* 凉 ⇒ 占空比压到下限 */
run(&s, 1);
TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, s.duty);
@@ -476,6 +476,147 @@ static void test_step_before_init_is_refused(void)
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();
@@ -492,5 +633,8 @@ int main(void)
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();
}