固件 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 行删除
+206
查看文件
@@ -0,0 +1,206 @@
/* 配网热点的平台无关部分。判据表与表单格式在 prov.h。 */
#include "prov.h"
#include <stdio.h>
#include <string.h>
#include "cmd.h"
#include "netcfg.h"
static bool s_ap;
static bool s_was_up;
static uint32_t s_since; /* STA 上一次「有地址 ↔ 没地址」翻转的时刻(启动算一次) */
static bool s_aged; /* 翻转之后已经过了门限(过了就锁住:32 位时钟 49.7 天回绕,放着不管会「变年轻」) */
static bool s_touch; /* hold 窗口还开着(过期即清,同上) */
static uint32_t s_touch_at;
static int s_prev_sta;
void prov_init(uint32_t now)
{
s_ap = false;
s_was_up = false;
s_since = now;
s_aged = false;
s_touch = false;
s_touch_at = now;
s_prev_sta = 0;
}
prov_out_t prov_step(const prov_in_t *in, uint32_t now)
{
if (in->wifi_up != s_was_up) {
s_was_up = in->wifi_up;
s_since = now;
s_aged = false;
}
if (!s_aged && (uint32_t)(now - s_since) >= (in->wifi_up ? PROV_LINGER_MS : PROV_FAIL_MS)) {
s_aged = true;
}
if (in->touched || in->ap_sta > s_prev_sta) { /* 有人刚连上热点也算一次 */
s_touch = true;
s_touch_at = now;
}
s_prev_sta = in->ap_sta;
if (s_touch && (uint32_t)(now - s_touch_at) >= PROV_HOLD_MS) {
s_touch = false;
}
prov_out_t o = { .ap = false, .hold = false, .why = PROV_WHY_OFF };
if (in->wifi_up) {
if (s_ap && in->ap_sta > 0 && !s_aged) {
o.ap = true;
o.why = PROV_WHY_LINGER;
}
} else if (in->n_wifi <= 0) {
o.ap = true;
o.why = PROV_WHY_NOCFG;
} else if (s_aged || s_ap) {
/* `|| s_ap`:开着就不因为「刚配了一组、还没满 60 s」而关掉 —— 那正是人在网页上点完保存的那一刻。 */
o.ap = true;
o.why = PROV_WHY_FAIL;
}
o.hold = o.ap && !in->wifi_up && in->ap_sta > 0 && s_touch;
s_ap = o.ap;
return o;
}
const char *prov_why_str(prov_why_t w)
{
switch (w) {
case PROV_WHY_OFF: return "off";
case PROV_WHY_NOCFG: return "nocfg";
case PROV_WHY_FAIL: return "fail";
case PROV_WHY_LINGER: return "linger";
}
return "?";
}
/* ---- 名字与口令 ------------------------------------------------------------ */
void prov_ssid(const char *id, char out[PROV_SSID_MAX])
{
const size_t n = strlen(id);
snprintf(out, PROV_SSID_MAX, "PhoneRail-%s", n > 6 ? id + n - 6 : id);
}
/* ---- 表单 → 命令行 --------------------------------------------------------- */
#define FIELD_MAX 64 /* = CMD_JSON_STR_MAX − 1:再长解析器也不收 */
#define FIELDS 5
/* 把正文按 \n 切成至多 FIELDS 段(行尾的 \r 去掉),拷进 f[]。返回段数;−1 = 太长 / 段太多,−2 = 有坏字符。 */
static int split(const char *body, char f[FIELDS][FIELD_MAX + 1])
{
int n = 0;
size_t len = 0;
f[0][0] = '\0';
for (const char *p = body;; p++) {
if (*p == '\n' || *p == '\0') {
if (len > 0 && f[n][len - 1] == '\r') {
len--;
}
f[n][len] = '\0';
for (size_t i = 0; i < len; i++) {
const unsigned char c = (unsigned char)f[n][i];
if (c < 0x20 || c == 0x7F || c == '"' || c == '\\') {
return -2;
}
}
n++;
if (*p == '\0') {
return n;
}
if (n >= FIELDS) {
return -1;
}
len = 0;
continue;
}
if (len >= FIELD_MAX) {
return -1;
}
f[n][len++] = *p;
}
}
size_t prov_build_line(const char *body, uint32_t seq, char *out, size_t cap, const char **err)
{
static const char *dummy;
if (err == NULL) {
err = &dummy;
}
*err = "bad_form";
if (body == NULL || out == NULL || cap == 0 || seq == 0) {
return 0;
}
char f[FIELDS][FIELD_MAX + 1];
const int n = split(body, f);
if (n < 0) {
*err = (n == -2) ? "bad_char" : "too_long";
return 0;
}
for (int i = n; i < FIELDS; i++) {
f[i][0] = '\0';
}
int w;
if (strcmp(f[0], "wifi") == 0 && n >= 2 && n <= 3 && f[1][0] != '\0') {
w = snprintf(out, cap, "{\"seq\":%lu,\"cmd\":\"wifi\",\"ssid\":\"%s\",\"pass\":\"%s\"}",
(unsigned long)seq, f[1], f[2]);
} else if (strcmp(f[0], "del") == 0 && n == 2 && f[1][0] != '\0') {
w = snprintf(out, cap, "{\"seq\":%lu,\"cmd\":\"wifi\",\"ssid\":\"%s\",\"del\":true}",
(unsigned long)seq, f[1]);
} else if (strcmp(f[0], "mqtt") == 0 && n >= 2 && f[1][0] != '\0') {
unsigned long tele = 0;
for (const char *p = f[4]; *p != '\0'; p++) {
if (*p < '0' || *p > '9' || tele > NETCFG_TELE_MAX_MS) {
return 0;
}
tele = tele * 10u + (unsigned long)(*p - '0');
}
/* 口令留空 = 沿用已存的那份:网页上看不到旧口令(永不回显),不该逼人每次重输。 */
if (f[3][0] == '\0') {
w = snprintf(out, cap,
"{\"seq\":%lu,\"cmd\":\"mqtt\",\"uri\":\"%s\",\"user\":\"%s\",\"keep_pass\":true,"
"\"tele_ms\":%lu}",
(unsigned long)seq, f[1], f[2], tele);
} else {
w = snprintf(out, cap,
"{\"seq\":%lu,\"cmd\":\"mqtt\",\"uri\":\"%s\",\"user\":\"%s\",\"pass\":\"%s\","
"\"tele_ms\":%lu}",
(unsigned long)seq, f[1], f[2], f[3], tele);
}
} else {
return 0;
}
if (w <= 0 || (size_t)w >= cap || w >= CMD_LINE_MAX) {
*err = "too_long";
out[0] = '\0';
return 0;
}
*err = NULL;
return (size_t)w;
}
size_t prov_json_esc(const char *s, char *out, size_t cap)
{
size_t n = 0;
for (; *s != '\0'; s++) {
const unsigned char c = (unsigned char)*s;
const bool esc = c < 0x20 || c == 0x7F || c == '"' || c == '\\';
if (n + (esc ? 6u : 1u) >= cap) {
if (cap > 0) {
out[0] = '\0';
}
return 0;
}
if (esc) {
n += (size_t)snprintf(out + n, cap - n, "\\u%04x", (unsigned)c);
} else {
out[n++] = (char)c;
}
}
if (cap > 0) {
out[n] = '\0';
}
return n;
}