文件
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

351 行
16 KiB
C

/* 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();
}