diff --git a/firmware/HANDOFF.md b/firmware/HANDOFF.md
index 2c72495..6d3d770 100644
--- a/firmware/HANDOFF.md
+++ b/firmware/HANDOFF.md
@@ -1,79 +1,44 @@
# 交接
-更新:2026-09-16 完成 **R-0 ~ R-6**(整个 R2 迁移,计划 v2.0 §6.2)
+更新:2026-09-27 完成 **A14 + C1**(WiFi/MQTT 链路 + 经 USB 配网,需求 §10.8「A14 定稿的 MQTT 链路」)
## 一句话现状
-固件已经整体从 R1(单板 / 六片不同地址 INA226 / 74HC595 键链 / `VBUS_EN` 是 GPIO)
-迁到 **R2**(底板 + 5 片同构刀片 / 全是 0x40 与 0x20 / TCA9548A 分通道 /
-`VBUS_EN` 在刀片 PCA9555 的 P0.4 上)。**19 个 ctest、351 个用例全绿**,`idf.py build` 通过。
-器件层与拓扑层全部换掉,**判据阈值与时间常量一个没动**(它们来自电路计算,R2 没改电路)。
-
-## 2026-09-27 首次上板(底板,未插刀片)修的三处
-- `app_main.c` **HUB_RST 只在上电复位(`ESP_RST_POWERON`)时脉冲**。原先每次重启都脉冲 ⇒
- esptool 经 USB 复位即断口,只能走 J33 手动下载。现在 USB 复位(原因 11)不碰 Hub,可直接经 USB 反复烧。
-- `t_safety.c` **空槽不走总线**:原先五路无条件读,空槽 NAK 每次十几 ms,槽 1 就吃光 10 ms 预算,
- 槽 2~5 与主干 0x43 一圈都没读到(输入侧监控全瞎)。新用例
- `test_empty_slots_are_never_touched_and_the_input_still_reads`(去掉修复即红)。
-- `app_main.c` 关掉 `i2c.master` 驱动日志:它的 NAK 日志在持锁期间同步打印,拖垮节拍。
-- 上板实测:空机 `skipped=0 i2c_err=0 过载 0 最迟 0 ms`;I²C 扫描主干 0x22/0x23/0x43/0x4A/0x4B/0x70 全在,
- 另有 0x47 应答(推测 DAC63204 广播地址)。扫描固件在 `tools/i2c_scan/`(独立工程,只读、不写任何输出)。
+板 B(`b81f3fc9c8bc`)经 WiFi `CANBOX-5G` → 公网 → home 服务器 mosquitto 8883(TLS)**真连网跑通**:
+retained state、1 s 遥测、命令 / 判重 / `link` / `empty` / `parse` 回应全对。两条链路(USB + MQTT)都已上板。
+**23 个 ctest、400 个用例全绿**,`idf.py build` 通过(**0xE2220,分区只剩 12%**)。板 B 烧的是 0.1.0-a14,配置已存 NVS。
## 下一步
-**A12**:命令处理器(语义 + 序号判重 + 限幅)。R2 多一条验收:
-**空槽(`PRSNT#` 为高)的命令一律拒**(`slot_present()` 已备好)。
-另外 A15 开工前先把启动序列从 `app_main.c` 拆成宿主机可编的函数 —— 见下面「还没有拦网的」。
+固件还欠 **A15**(启动顺序 + 降级矩阵)/ **A16**(风扇闭环接进 t_thermal 任务体)。
+或者转**服务器侧前后端**(需求 D7):后端以 `phonerail-backend` 订阅 `phonerail/+/rsp|tele|state`,经 `/cmd` 下发。
## 本次新增/修改的文件
-- `main/i2cbus.{h,c}` **新** — mux 原子事务:拿锁 → 选通道 → 跑回调 → 放锁;通道影子 + 失败作废
-- `main/slot.{h,c}` **新** — 一片刀片的 I/O 门面:路号 → mux 通道 → 影子下标只在这里换算一次
-- `components/drv/tca9548a.{h,c}` / `ch224a.{h,c}` **新** — I²C 八路多路器 / PD 受电(只读)
-- `main/t_thermal.{h,c}` **新** — 风扇闭环(一路 PWM 两只风扇 + `FAN_EN` 总开关 + 两路 TACH)
-- `main/board_pins.h` — 整表重写(R2 的 GPIO 与 R1 **一个都不一样**)
-- `components/drv/pca9555.{h,c}` — 重写:P0.0~4 是输出、五实例影子、初始化顺序是安全本身
-- `components/drv/ina226.{h,c}` — 五路同址 0x40 + 输入侧 0x43
-- `main/rail_cfg.{h,c}` — 换列:+`mux_ch` +`prsnt_n`,−`vbus_en`;新增 `RAIL_SELFTEST_MUX`
-- `main/t_safety.{h,c}` — 一圈改成「五次 `xact` + 主干一次」;+整圈锁预算;+mux 失联/在位两条判据
-- `main/t_actuator.{h,c}` — 595 链 → PCA9555 Port0;本文件现在**要拿 I²C 锁**
-- `main/app_main.c` — R2 启动顺序(含 `HUB_RST` 只脉冲一次、`ina226_set_reversed(0x43,true)`)
-- `main/evlog.h` — 末尾追加 `LOG_EV_MUX_LOST=14 / SLOT_EMPTY=15 / PD_LIMIT=16`
-- sim:假 TCA9548A(关着的支路一律 NAK)、五实例假 PCA9555、假风扇
-- `host/qtest.sh` **新** — 单个测试的快编快跑,迁移期并行开工用(DoD 仍认 `host/test.sh`)
-- `components/drv/include/sr595.h` — 顶部加「⛔ R2 已退役」横幅,代码与测试保留不动
+- `main/netcfg.{h,c}` **新** — 配置校验 / 持有 / 存储回调;网络状态;「密码永不外泄」
+- `main/mlink.{h,c}` **新** — topic、入站消息校验、WiFi 退避、遥测节拍、上线消息
+- `main/cmd_net.c` **新** — `wifi` / `mqtt`(只收 USB)/ `net` 三条命令;新增错误码 `link`(11)、命令号 10~12(冻结)
+- `main/mlink_esp.{h,c}` **新**(只进 IDF)— NVS、WiFi STA、esp-mqtt(TLS,enqueue 不阻塞)、遥测
+- `main/certs/evse_ca.pem` **新** — 服务器 mosquitto 的自签 CA(公钥证书),`EMBED_TXTFILES`
+- `main/cmd_json.h` — 字符串上限 32 → 65(WPA2 口令 63)
+- `main/app_main.c` / `CMakeLists.txt` — 接线;REQUIRES 加 wifi / netif / event / timer / nvs / mqtt
+- `test/test_net.c`(10 例)**新**;`test_cmd.c` 冻结编号更新;版本号 `0.1.0-a14`
+
+## 服务器上改了什么(home 服务器,mosquitto 1.6.9,evse 项目共用)
+- `evse.passwd` 加用户 `b81f3fc9c8bc`、`phonerail-backend`;`evse.acl` 末尾追加 phonerail 段(`pattern … phonerail/%u/…`)。
+- ⛔ **修了 evse 原有的漏洞**:8883 listener 缺 `cafile` ⇒ TLS 根本没生效、**公网明文**。补 `cafile /etc/mosquitto/certs/ca.crt` 后重启;
+ 改前 8883 上零成功连接(日志自 09-24 起),evse-backend 走本机 1884,重启后 5 s 自动重连。
+- 三个文件都有 `.bak-20260927-*` 备份。口令在 `/root/phonerail/mqtt.env`(700 目录,仅 root)。
## 验收命令与结果
```
-$ bash host/test.sh
-100% tests passed, 0 tests failed out of 19 # 351 个用例
-
-$ .\build_idf.ps1
-Project build complete. # phonerail.bin 0x3fa90,分区剩 75%
+$ bash host/test.sh # 100% tests passed, 0 tests failed out of 23(400 例)
+$ python tools/prlink.py COM5 net # "wifi":"up","ip":"192.168.8.108","mqtt":"up"
+服务器:mosquitto_sub -h 127.0.0.1 -p 1884 -u phonerail-backend … -t 'phonerail/#' -v
```
+注错验证过:放开「只收 USB」、去掉 JSON 字符串清洗 → 各自有用例变红。
## 踩到的坑(只写会再犯的)
-- ⛔⛔ **PCA9555 的初始化顺序就是安全本身。** Output Port 复位值是 **0xFF**,
- 所以必须 `OUTPUT=0 → POLINV=0 → CONFIG1 → CONFIG0` ——**方向最后改**。
- 反过来的那一刻 P0.0~P0.4 一起输出 1 = 三个键全按下 + EDL 短接 + VBUS 上电。
- 注错实测:把顺序调过来,33 条用例里**只有 1 条变红**,终态完全正确 ——
- 这就是为什么它在真板上查不出来。拦网是 `sim_pca9555_unsafe_enable()`。
-- ⛔ **`check_mux()` 的两段式不能拿「复位计数器和自己的快照比」做状态**:
- 复位之后把后者同步成前者,两个数永远相等 ⇒ 每圈都走「先复位」分支,
- `MUX_RST#` 每 20 ms 脉冲一次而 `MUX_LOST` 一次也不出。状态要单独存(`mux_step`)。
-- ⛔ **R2 一圈要拿六次锁**(R1 只拿一次)。每次 5 ms 超时看着没问题,
- 六次加起来 **30 ms > 20 ms 周期** ⇒ 总线繁忙时巡检成片丢拍,而每次 `xact` 都按时返回了。
- 现在有 `T_SAFETY_LOCK_BUDGET_MS`(10 ms)这条整圈的闸。
-- ⚠️ **「断 VBUS 在锁内、关 eFuse 在锁外」这条,光断言「写的时候持着锁、通道对」拦不住**:
- 把它挪到锁外之后 `slot_vbus_enable()` 自己也会拿锁、也会选对通道,两条断言照样过。
- 真正的硬凭据是**「这一写发生在本圈第几次拿锁里」**(对 = 第 rail 次,错 = 第 7 次)。
-- ⚠️ **`slot_present()` 读的是缓存**,`t_safety` 每圈要先 `slot_prsnt_poll()`。
- 不 poll 的现象是「拔掉刀片永远判不出来」,而快照里那五个 bool 看着完全正常。
-- ⚠️ 假总线上 `hal_i2c_write_read()` 会让 `n_write` 和 `n_read` **各 +1**,
- 所以「巡检有没有写刀片」不能看 `n_write`,要看最后一次写的寄存器号。
-- ⚠️ `i2cbus_init()` **必定留下一次 `MUX_RST#` 低脉冲**(先 config(OUTPUT) 再写 1),
- 测试里所有复位断言都要走增量。
-
-## 还没有拦网的(不是遗漏,是已知边界)
-- **启动序列一行也测不到**:`app_main.c` 只进 IDF 目标。像 `ina226_set_reversed(0x43,true)`
- 这种「漏了就把正常供电判成反灌」的单行,现在唯一的拦网是人眼。A15 先拆函数。
-- **mux 通道整体循环错位**(0→1→2→3→4→0)自检全过:五路互不重复、都不越界、都不等于 5。
- 现象是「每一路读到的都是隔壁那台手机」。唯一判据是上板 **B1c 五槽逐一单插**。
-- **选通道「写下去不报错但没生效」**:假器件写了就生效,宿主机造不出来。见计划 §11。
-- **真并发**:sim 单线程,`i2cbus_xact` 把「拿锁 + 选通道」绑成原子的价值只能间接证明。
+- ⛔ **`openssl s_client … | grep "Verify return code"` 不能证明 TLS 通了**:握手失败、没拿到证书时它也打 `0 (ok)`。要看 `Cipher is` / `Protocol`。
+- ⛔ mosquitto 1.6:listener 只有 certfile/keyfile、没有 cafile/capath ⇒ **静默退化成明文**。TLS 选项改动要 restart,HUP 不生效。
+- esp-tls 的「Certificate verified.」在握手失败时也会打,别被它带偏;服务器抓包(tcpdump 看 RST 时机)最快。
+- 服务器 pid 文件不在 `/var/run/mosquitto.pid`,HUP 用 `pgrep -x mosquitto`。`mosquitto_passwd -b`(1.6.9)会把文件内容打到终端,不是出错。
+- 本机(192.168.8.x)与服务器(10.5.5.x)不同网段,板子只能走公网 `home.example:8883`;**明文 mqtt:// 只许用在同一局域网**。
+- 固件 0xE2220 / 1 MB:WiFi + MQTT + TLS ≈ 600 KB。**C2(OTA 双分区)前必须重排分区表**(模组是 16 MB,sdkconfig 只按 8 MB 用)。
+- WiFi 断开原因码 3(AUTH_LEAVE)是重配网时我们主动断的,不是故障;201 = 找不到 AP(比如只有 5 GHz)。
diff --git a/firmware/host/CMakeLists.txt b/firmware/host/CMakeLists.txt
index 5b22cac..168a956 100644
--- a/firmware/host/CMakeLists.txt
+++ b/firmware/host/CMakeLists.txt
@@ -32,6 +32,14 @@ set(PHONERAIL_CORE_SRCS
${FW_ROOT}/main/t_actuator.c
${FW_ROOT}/main/t_thermal.c
${FW_ROOT}/main/evlog.c
+ ${FW_ROOT}/main/cmd_json.c
+ ${FW_ROOT}/main/cmd.c
+ ${FW_ROOT}/main/cmd_exec.c
+ ${FW_ROOT}/main/cmd_report.c
+ ${FW_ROOT}/main/ulink.c
+ ${FW_ROOT}/main/cmd_net.c
+ ${FW_ROOT}/main/netcfg.c
+ ${FW_ROOT}/main/mlink.c
)
# HAL:接口在 include/,宿主机链 sim/ 的实现(esp/ 那份只有 IDF 目标编)。
diff --git a/firmware/host/qtest.sh b/firmware/host/qtest.sh
index dc74b19..ed726f4 100644
--- a/firmware/host/qtest.sh
+++ b/firmware/host/qtest.sh
@@ -19,7 +19,9 @@ INC="-Imain -Icomponents/phonerail_hal/include -Icomponents/phonerail_hal/sim
# app_main.c 是 ESP 专用入口,宿主机不编。
CORE="main/version.c main/rail.c main/rail_cfg.c main/i2cbus.c main/slot.c
- main/sched.c main/tasks.c main/t_safety.c main/t_actuator.c main/evlog.c"
+ main/sched.c main/tasks.c main/t_safety.c main/t_actuator.c main/evlog.c
+ main/cmd_json.c main/cmd.c main/cmd_exec.c main/cmd_report.c main/ulink.c
+ main/cmd_net.c main/netcfg.c main/mlink.c"
[ -f main/t_thermal.c ] && CORE="$CORE main/t_thermal.c"
DRV="components/drv/ina226.c components/drv/dac63204.c components/drv/sr595.c
diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt
index e57cbb1..695902b 100644
--- a/firmware/main/CMakeLists.txt
+++ b/firmware/main/CMakeLists.txt
@@ -1,8 +1,10 @@
# ESP-IDF 侧的 main 组件。
-# app_main.c 是 ESP 专用入口;其余 .c 必须同时在宿主机上编得过
+# app_main.c / ulink_esp.c / mlink_esp.c 是 ESP 专用;其余 .c 必须同时在宿主机上编得过
# (新增文件时两处都要加:这里 + host/CMakeLists.txt 的 PHONERAIL_CORE_SRCS)。
idf_component_register(
SRCS "app_main.c"
+ "ulink_esp.c"
+ "mlink_esp.c"
"version.c"
"rail.c"
"rail_cfg.c"
@@ -14,5 +16,15 @@ idf_component_register(
"t_actuator.c"
"t_thermal.c"
"evlog.c"
+ "cmd_json.c"
+ "cmd.c"
+ "cmd_exec.c"
+ "cmd_report.c"
+ "ulink.c"
+ "cmd_net.c"
+ "netcfg.c"
+ "mlink.c"
INCLUDE_DIRS "."
- REQUIRES phonerail_hal drv)
+ EMBED_TXTFILES "certs/evse_ca.pem"
+ REQUIRES phonerail_hal drv esp_driver_usb_serial_jtag
+ esp_wifi esp_netif esp_event esp_timer nvs_flash mqtt)
diff --git a/firmware/main/app_main.c b/firmware/main/app_main.c
index b62f407..ae434d1 100644
--- a/firmware/main/app_main.c
+++ b/firmware/main/app_main.c
@@ -1,11 +1,15 @@
/* ESP32-S3 入口。只在 ESP-IDF 目标里编译,宿主机构建不包含本文件。 */
#include
+#include
#include "board_pins.h"
+#include "cmd.h"
+#include "cmd_queue.h"
#include "esp_log.h"
#include "esp_system.h"
#include "evlog.h"
#include "freertos/FreeRTOS.h"
+#include "freertos/queue.h"
#include "freertos/task.h"
#include "hal_gpio.h"
#include "hal_i2c.h"
@@ -21,6 +25,8 @@
#include "t_actuator.h"
#include "t_safety.h"
#include "tasks.h"
+#include "mlink_esp.h"
+#include "ulink_esp.h"
static const char *TAG = "phonerail";
@@ -141,28 +147,66 @@ static void task_thermal(void *arg)
}
}
-/* A9 的三个状态机。任务体还是三行:推一步 / 拿它算出来的下一个截止时刻 / 睡到那时。
- 判据与时序全在 `t_actuator_step()` 里,那份在宿主机上跑得到。 */
+/* ---- 命令队列(A12)--------------------------------------------------------
+ 适配器(A13 USB / A14 WiFi)投递一行,执行器任务取出来调 `cmd_handle_line()`。
+ 处理器放在执行器任务里而不是链路任务里,是因为 `t_actuator_*` 的状态机没有锁:
+ 请求函数与 `t_actuator_step()` 必须同一个任务调(cmd.h「在哪个任务里跑」)。
+ 深度 4:人点按钮 + 上位机轮询 status,四条排队已经是异常情形了。 */
+typedef struct {
+ cmd_link_t link;
+ cmd_reply_fn reply;
+ void *ctx;
+ char line[CMD_LINE_MAX];
+} cmd_msg_t;
+
+#define CMD_QUEUE_DEPTH 4
+static QueueHandle_t s_cmd_q;
+
+bool cmd_queue_submit(cmd_link_t link, const char *line, cmd_reply_fn reply, void *ctx)
+{
+ if (s_cmd_q == NULL || line == NULL) {
+ return false;
+ }
+ cmd_msg_t m = { .link = link, .reply = reply, .ctx = ctx };
+ const size_t n = strlen(line);
+ if (n >= sizeof(m.line)) {
+ return false; /* 超长行整行拒收,不截断(截断的 JSON 可能碰巧仍然合法) */
+ }
+ memcpy(m.line, line, n + 1);
+ return xQueueSend(s_cmd_q, &m, 0) == pdTRUE; /* 不等:满了就是满了,让适配器去报 */
+}
+
+/* A9 的三个状态机 + A12 的命令。任务体:推一步 → 带着「下一个截止时刻」去等命令 →
+ 有命令就处理、回应,然后回到开头再推一步。判据与时序全在宿主机上跑得到的那两个函数里。 */
static void task_actuator(void *arg)
{
(void)arg;
+ static char reply[CMD_OUT_MAX]; /* 静态:1.5 KB 放栈上会把 4 KB 的栈吃掉一大半 */
+ cmd_msg_t m;
for (;;) {
- /* A12 会把这里换成「带超时地等命令队列」:超时值就是这个返回值。 */
const TickType_t t = pdMS_TO_TICKS(t_actuator_step());
/* ⚠️ `CONFIG_FREERTOS_HZ=100` ⇒ 一个 tick 是 10 ms,`pdMS_TO_TICKS(5)`
- 是 **0**,而 `vTaskDelay(0)` 只是让一次 CPU —— 那会让这个中优先级任务
- 在收尾重试时空转,把 t_link / t_log 饿死。至少睡一个 tick。
+ 是 **0**,而「等 0 tick」只是查一眼就回来 —— 那会让这个中优先级任务
+ 在收尾重试时空转,把 t_link / t_log 饿死。至少等一个 tick。
早醒没关系:每个状态都是拿绝对时刻算的,早来就再返回一个剩余时间。 */
- vTaskDelay(t == 0 ? 1 : t);
+ if (xQueueReceive(s_cmd_q, &m, t == 0 ? 1 : t) == pdTRUE) {
+ const size_t n = cmd_handle_line(m.link, m.line, reply, sizeof(reply));
+ if (m.reply != NULL) {
+ m.reply(m.ctx, reply, n);
+ }
+ }
}
}
+/* 两条传输链路(§10.8)。本任务只收字节、切行、投进命令队列 ——
+ **不执行命令**(那在执行器任务里,cmd.h)。USB(A13)在这里收包;MQTT(A14)的收包在
+ esp-mqtt 自己的任务里,这里只管它的生命周期与遥测节拍。 */
static void task_link(void *arg)
{
(void)arg;
for (;;) {
- /* A12~A14:命令处理器 + 两条传输链路。 */
- vTaskDelay(pdMS_TO_TICKS(100));
+ ulink_esp_poll(100); /* 最多等 100 ms 一包;没有 USB 驱动时退化成睡 100 ms */
+ mlink_esp_poll();
}
}
@@ -351,6 +395,27 @@ void app_main(void)
而没 init 的执行器只会直接返回,那时序列还在跑,电关掉几秒又会被抬回来。 */
t_actuator_init(act_pg_from_safety, NULL);
+ /* 命令处理器(A12)。status 读巡检快照;热管理快照等 A16 把任务体接上再给。
+ 队列要在执行器任务起来之前建好 —— 那个任务第一件事就是等它。 */
+ const cmd_env_t cmd_env = { .safety = &s_snap, .thermal = NULL };
+ cmd_init(&cmd_env);
+ s_cmd_q = xQueueCreate(CMD_QUEUE_DEPTH, sizeof(cmd_msg_t));
+ if (s_cmd_q == NULL) {
+ ESP_LOGE(TAG, "命令队列建不起来");
+ abort();
+ }
+ /* A13:USB 命令链路。装驱动 + 把日志复制一份到 USB(UART0 照旧)。
+ 失败不 abort:USB 命令链路不可用,供电与别的链路照常(§10.2)。 */
+ err = ulink_esp_init();
+ if (err != HAL_OK) {
+ ESP_LOGE(TAG, "USB 命令链路不可用:%s", hal_err_str(err));
+ }
+ /* A14:WiFi / MQTT 命令链路(配置在 NVS,经 USB 下发)。失败同样不 abort。 */
+ err = mlink_esp_init();
+ if (err != HAL_OK) {
+ ESP_LOGE(TAG, "WiFi 命令链路不可用:%s", hal_err_str(err));
+ }
+
/* 事件日志(§10.7)。**要在接事件出口之前 init**,否则第一条事件进的是
一个还没扫过 flash 的缓冲。
⚠️ 失败**不 abort**:没有 `evlog` 分区时日志落不了盘,但五路照供电
diff --git a/firmware/main/certs/evse_ca.pem b/firmware/main/certs/evse_ca.pem
new file mode 100644
index 0000000..6aebff5
--- /dev/null
+++ b/firmware/main/certs/evse_ca.pem
@@ -0,0 +1,19 @@
+-----BEGIN CERTIFICATE-----
+MIIDBTCCAe2gAwIBAgIUUMTOcO93mWYWo3VvgCGkJERm6G0wDQYJKoZIhvcNAQEL
+BQAwEjEQMA4GA1UEAwwHZXZzZS1jYTAeFw0yNjA5MjMyMTMyMzVaFw0zNjA5MjAy
+MTMyMzVaMBIxEDAOBgNVBAMMB2V2c2UtY2EwggEiMA0GCSqGSIb3DQEBAQUAA4IB
+DwAwggEKAoIBAQC9wscg+xE/LD09tLoevuj9y/c5WXdWgZMcAQZzjwzdOYX2cdkq
+BomMi0kKMzZmdJkdD6BC+z4q7ANMj4ggSG8NWxszoNE3F/noRA6ZNhUwYdHIbu4K
+bE2hnLvLJ129S9rQEZP76bgfM99MjRcgu/wFS253cTy1aJWTHwCrpfTGvEkRFWC4
+VJifoUbfVDVQMBTF/iJIO0Oo+GhlzdCxeNIZukvI1Iy1D3UuP6Wh72HWCX9L1tf9
+pVmTgVORc4r97hDySzwOnM5Ww/B9GEkWdUtGf7YEo6NTEzGK57qR2FUpTWRO7F5B
+Sr1eNAedvJwXR8zZw+nnbauPkZAOwkOqzGyXAgMBAAGjUzBRMB0GA1UdDgQWBBQ/
+DY6BjupiOFojpyB9hMu9tlQxYjAfBgNVHSMEGDAWgBQ/DY6BjupiOFojpyB9hMu9
+tlQxYjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCGuqNGhWSe
+Wy8ZWKSqvHxE0UMKPBwOpCtlZM7NQ6g92andqFzsmS0isYjySELXbT7AEa2pnZuv
++iN5Dm7tmu0KLha16r7PvUU1WgDRj4MthHM6mkocFCwOZoq5tdQYLaM00XlB6oiR
+d81FrXBk0aTqGI8Gs/+xjNpqtenKt4kKY3cR2MsR90IjGUwyEDWMJfrjJ+dzA7+r
+4f8jZsdwY2Y8yF5awyFdw837Kg/jTAQDpHO3NLG+LX9cMoFqKDEfLVbauLfomJHS
+rODa3jKEUNnxiLwoa3p6+vs7Fejux0y37giAnVMNdvU7OOM94DDmSQ+0lGuc7uWw
+oU6MRtkYINxZ
+-----END CERTIFICATE-----
diff --git a/firmware/main/cmd.c b/firmware/main/cmd.c
new file mode 100644
index 0000000..648450c
--- /dev/null
+++ b/firmware/main/cmd.c
@@ -0,0 +1,217 @@
+/* 单一命令处理器:解析 → 序号判重 → 限幅 / 空槽 → 执行 → 回应。契约在 cmd.h。 */
+#include "cmd.h"
+
+#include
+
+#include "cmd_priv.h"
+#include "evlog.h"
+
+/* ---- 名字表(线上用的,全 ASCII) ---------------------------------------- */
+
+static const char *const k_names[CMD_COUNT] = {
+ [CMD_NONE] = "", [CMD_STATUS] = "status", [CMD_SET_VOLTAGE] = "set_voltage",
+ [CMD_POWER_CYCLE] = "power_cycle", [CMD_KEY] = "key", [CMD_EDL] = "edl",
+ [CMD_VBUS] = "vbus", [CMD_FAN] = "fan", [CMD_EVENTS] = "events",
+ [CMD_POWER] = "power", [CMD_WIFI] = "wifi", [CMD_MQTT] = "mqtt",
+ [CMD_NET] = "net",
+};
+
+static const char *const k_errs[CMD_ERR_COUNT] = {
+ [CMD_OK] = "ok", [CMD_ERR_PARSE] = "parse", [CMD_ERR_UNKNOWN] = "unknown_cmd",
+ [CMD_ERR_NO_SEQ] = "no_seq", [CMD_ERR_ARG] = "arg", [CMD_ERR_RANGE] = "range",
+ [CMD_ERR_EMPTY] = "empty", [CMD_ERR_BUSY] = "busy", [CMD_ERR_STATE] = "state",
+ [CMD_ERR_IO] = "io", [CMD_ERR_OVERFLOW] = "overflow", [CMD_ERR_LINK] = "link",
+};
+
+const char *cmd_name(cmd_id_t id) { return (id > CMD_NONE && id < CMD_COUNT) ? k_names[id] : "?"; }
+const char *cmd_err_str(cmd_err_t e) { return (e < CMD_ERR_COUNT) ? k_errs[e] : "?"; }
+
+/* 会动东西的命令:必须带 seq、进判重窗口。status / events 幂等,不进。 */
+static bool mutates(cmd_id_t id)
+{
+ return id != CMD_STATUS && id != CMD_EVENTS && id != CMD_NET;
+}
+
+/* ---- 状态 ---------------------------------------------------------------- */
+
+typedef struct {
+ uint32_t seq;
+ uint8_t cmd;
+ uint8_t err;
+} seen_t;
+
+static cmd_env_t s_env;
+
+const cmd_env_t *cmd_env(void) { return &s_env; }
+static seen_t s_seen[CMD_SEQ_WINDOW];
+static uint32_t s_seen_n; /* 已填几条(≤ 窗口) */
+static uint32_t s_seen_head; /* 下一条写哪 */
+static cmd_stats_t s_stats;
+
+void cmd_init(const cmd_env_t *env)
+{
+ memset(&s_env, 0, sizeof(s_env));
+ if (env != NULL) {
+ s_env = *env;
+ }
+ memset(s_seen, 0, sizeof(s_seen));
+ s_seen_n = 0;
+ s_seen_head = 0;
+ memset(&s_stats, 0, sizeof(s_stats));
+}
+
+void cmd_stats(cmd_stats_t *st) { *st = s_stats; }
+
+static const seen_t *seen_find(uint32_t seq)
+{
+ for (uint32_t i = 0; i < s_seen_n; i++) {
+ if (s_seen[i].seq == seq) {
+ return &s_seen[i];
+ }
+ }
+ return NULL;
+}
+
+static void seen_put(uint32_t seq, cmd_id_t id, cmd_err_t err)
+{
+ s_seen[s_seen_head] = (seen_t){ .seq = seq, .cmd = (uint8_t)id, .err = (uint8_t)err };
+ s_seen_head = (s_seen_head + 1u) % CMD_SEQ_WINDOW;
+ if (s_seen_n < CMD_SEQ_WINDOW) {
+ s_seen_n++;
+ }
+}
+
+static cmd_err_t do_status(ctx_t *c)
+{
+ cmd_report_status(c->w, &s_env);
+ return CMD_OK;
+}
+
+static cmd_err_t do_events(ctx_t *c)
+{
+ int64_t from = 0;
+ const int g = cj_get_int(c->o, "from", &from);
+ if (g < 0 || (g == 1 && (from < 0 || from > (int64_t)UINT32_MAX))) {
+ return CMD_ERR_ARG;
+ }
+ cmd_report_events(c->w, (uint32_t)from);
+ return CMD_OK;
+}
+
+typedef cmd_err_t (*handler_t)(ctx_t *c);
+
+static const handler_t k_handlers[CMD_COUNT] = {
+ [CMD_STATUS] = do_status, [CMD_SET_VOLTAGE] = do_set_voltage,
+ [CMD_POWER_CYCLE] = do_power_cycle, [CMD_KEY] = do_key, [CMD_EDL] = do_edl,
+ [CMD_VBUS] = do_vbus, [CMD_FAN] = do_fan, [CMD_EVENTS] = do_events,
+ [CMD_POWER] = do_power, [CMD_WIFI] = do_wifi, [CMD_MQTT] = do_mqtt,
+ [CMD_NET] = do_net,
+};
+
+static cmd_id_t lookup(const char *name)
+{
+ for (int i = CMD_NONE + 1; i < CMD_COUNT; i++) {
+ if (strcmp(k_names[i], name) == 0) {
+ return (cmd_id_t)i;
+ }
+ }
+ return CMD_NONE;
+}
+
+/* ---- 入口 ---------------------------------------------------------------- */
+
+/* 拒绝记事件的只有「越界 / 空槽 / 状态不许 / 链路不许」四类:它们是操作者(或上位机逻辑)
+ 真的想做一件不该做的事。解析错、缺参数、忙、判重不记 —— USB 线上一串乱码
+ 不该把 256 条的 RAM 缓冲刷掉(evlog.h)。
+ 事件字段:arg = 命令号,bus_mv = 越界的那个值,current_ua = 拒绝原因(cmd_err_t)。 */
+static void log_reject(const ctx_t *c, cmd_id_t id, cmd_err_t e)
+{
+ if (e != CMD_ERR_RANGE && e != CMD_ERR_EMPTY && e != CMD_ERR_STATE && e != CMD_ERR_LINK) {
+ return;
+ }
+ const uint8_t rail = (c->rail >= 1 && c->rail <= PHONERAIL_RAIL_COUNT) ? (uint8_t)c->rail : 0;
+ evlog_put(rail, LOG_EV_CMD_REJECT, (uint8_t)id, (int32_t)c->bad, (int32_t)e);
+}
+
+static size_t reply(char *out, size_t len, bool has_seq, uint32_t seq, cmd_err_t e, bool dup,
+ const char *extra)
+{
+ cj_writer_t w;
+ cj_w_init(&w, out, len);
+ cj_w(&w, "{");
+ if (has_seq) {
+ cj_w(&w, "\"seq\":%lu,", (unsigned long)seq);
+ }
+ if (e == CMD_OK) {
+ cj_w(&w, "\"ok\":true%s", extra != NULL ? extra : "");
+ } else {
+ cj_w(&w, "\"ok\":false,\"err\":\"%s\"", cmd_err_str(e));
+ }
+ cj_w(&w, "%s}", dup ? ",\"dup\":true" : "");
+ return w.overflow ? 0 : w.pos;
+}
+
+size_t cmd_handle_line(cmd_link_t link, const char *line, char *out, size_t out_len)
+{
+ s_stats.lines++;
+
+ /* 两块大缓冲放静态区:处理器只在执行器任务里跑(cmd.h),不可重入,
+ 而 1.5 KB 放在那个任务的栈上就是把栈顶到头。 */
+ static cj_obj_t o;
+ static char extra[CMD_OUT_MAX];
+ if (!cj_parse(line, &o)) {
+ s_stats.rejects++;
+ return reply(out, out_len, false, 0, CMD_ERR_PARSE, false, NULL);
+ }
+
+ int64_t seq64 = 0;
+ const int gs = cj_get_int(&o, "seq", &seq64);
+ const bool has_seq = (gs == 1 && seq64 >= 1 && seq64 <= (int64_t)UINT32_MAX);
+ const uint32_t seq = has_seq ? (uint32_t)seq64 : 0u;
+
+ const char *name = NULL;
+ const cmd_id_t id = (cj_get_str(&o, "cmd", &name) == 1) ? lookup(name) : CMD_NONE;
+ if (id == CMD_NONE) {
+ s_stats.rejects++;
+ return reply(out, out_len, has_seq, seq, CMD_ERR_UNKNOWN, false, NULL);
+ }
+ if ((gs != 0 && !has_seq) || (mutates(id) && !has_seq)) {
+ s_stats.rejects++; /* 带了一个坏 seq,或者该带没带 */
+ return reply(out, out_len, false, 0, CMD_ERR_NO_SEQ, false, NULL);
+ }
+
+ if (mutates(id)) {
+ const seen_t *hit = seen_find(seq);
+ if (hit != NULL) {
+ /* 重传:回第一次的结论,**不再执行**(§10.8 规矩 3)。
+ 命令号对不上说明上位机把同一个 seq 用在了两条命令上 —— 那是它的 bug,
+ 照样不执行(执行了就是「一个 seq 两个动作」),但回的是 arg 让它看得见。 */
+ s_stats.dups++;
+ const cmd_err_t e = (hit->cmd == (uint8_t)id) ? (cmd_err_t)hit->err : CMD_ERR_ARG;
+ return reply(out, out_len, true, seq, e, true, NULL);
+ }
+ }
+
+ cj_writer_t w;
+ cj_w_init(&w, extra, sizeof(extra));
+ ctx_t c = { .o = &o, .link = link, .w = &w, .rail = 0, .bad = 0 };
+
+ cmd_err_t e = k_handlers[id](&c);
+ if (e == CMD_OK && w.overflow && !mutates(id)) {
+ e = CMD_ERR_OVERFLOW; /* 只读命令:什么都没做,如实报 */
+ }
+ if (e != CMD_OK) {
+ s_stats.rejects++;
+ log_reject(&c, id, e);
+ }
+ if (mutates(id)) {
+ seen_put(seq, id, e);
+ }
+ size_t n = reply(out, out_len, has_seq, seq, e, false, w.overflow ? NULL : extra);
+ if (n == 0) {
+ /* 附加字段装不进调用方的缓冲。会动东西的命令此时**已经执行了**:
+ 回一条不带附加字段的 ok,别让上位机以为没执行而换个 seq 再发一遍。 */
+ n = reply(out, out_len, has_seq, seq, mutates(id) ? e : CMD_ERR_OVERFLOW, false, NULL);
+ }
+ return n;
+}
diff --git a/firmware/main/cmd.h b/firmware/main/cmd.h
new file mode 100644
index 0000000..13918d9
--- /dev/null
+++ b/firmware/main/cmd.h
@@ -0,0 +1,135 @@
+/* 单一命令处理器(A12,需求 §10.8)。
+
+ §10.8 的第一条规矩:**命令处理器只有一个**。WiFi(A14)与 USB CDC(A13)只是传输,
+ 各自把「一行 JSON」交到这里、把「一行 JSON」带回去。鉴权之外的全部约束 ——
+ 限幅、空槽拒绝、序号判重、拒绝记事件 —— 都只写在本模块里,**绝不写进适配器**:
+ 以后加第三条链路时漏掉一处,功能测试是测不出来的。
+
+ ---- 在哪个任务里跑 ------------------------------------------------------
+ **只许在执行器任务里调 `cmd_handle_line()`**(app_main.c 的 `task_actuator`
+ 从命令队列里取行、调它、把回应交还给链路)。理由:`t_actuator_*` 的状态机没有锁,
+ 它们的请求函数与 `t_actuator_step()` 必须在同一个任务里;判重窗口同理。
+ 把处理器放进链路任务再给执行器加锁,等于为「两条链路」付出「全部状态机加锁」的代价。
+ 宿主机上没有任务,测试直接调。
+
+ ---- 协议(写回需求 §10.8「A12 定稿的线上格式」)------------------------
+ 请求:`{"seq":17,"cmd":"key","rail":3,"keys":"pwr","ms":10000}`
+ 回应:`{"seq":17,"ok":true}` / `{"seq":17,"ok":false,"err":"range"}`
+ 判重命中时回**第一次的结论**并加 `"dup":true`,不再执行。
+
+ ⚠️ **判重只对「会动东西的命令」**(`CMD_MUTATES`),而且这些命令**必须带 seq**:
+ `status` / `events` 本身幂等,重发一遍就该拿到新数据,不进窗口。
+ ⚠️ 窗口是**全局的、不分链路**(§10.8 规矩 3),按「值相等」判,不按「不大于最大值」判 ——
+ 后者在上位机重启、序号从头数的那一刻会把全部新命令判成重复。
+ 上位机因此要保证:**同一个 seq 只用于同一条命令的重传**;想「再试一次」就换新 seq。
+ ⚠️ 窗口在 RAM 里,板子重启就清空。「上位机重传的那一刻板子恰好重启」会让命令
+ 执行两遍 —— 但重启本身已经松开了所有键、关掉了所有输出(SR-6),第二遍落在的是
+ 一块刚启动的板子上,不是「第二遍按在刚开机的手机上」那种情形。
+ 回应里没有 boot 编号;上位机要识别重启就看 `status` 的 `up`(上电毫秒)有没有变小。 */
+#ifndef CMD_H
+#define CMD_H
+
+#include
+#include
+#include
+
+#include "t_safety.h"
+#include "t_thermal.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* ---- 线上编号:进 flash 的,冻结 ----------------------------------------
+ ⚠️ 这两张表的数字会写进事件日志(`LOG_EV_CMD_REJECT` 的 arg / current_ua、
+ `LOG_EV_REBOOT` 的 arg),和 `log_ev_t` 一样**只许在末尾追加**。
+ `test_cmd_numbers_are_frozen()` 把它们钉死了。 */
+typedef enum {
+ CMD_NONE = 0,
+ CMD_STATUS = 1,
+ CMD_SET_VOLTAGE = 2,
+ CMD_POWER_CYCLE = 3,
+ CMD_KEY = 4,
+ CMD_EDL = 5,
+ CMD_VBUS = 6,
+ CMD_FAN = 7,
+ CMD_EVENTS = 8,
+ CMD_POWER = 9, /* A12 新增,§10.8 原表没有:管理界面要能把一台手机停下来 */
+ CMD_WIFI = 10, /* A14/C1:写 WiFi 凭据(只收 USB) */
+ CMD_MQTT = 11, /* A14/C1:写 broker 地址 / 账号 / 遥测周期(只收 USB) */
+ CMD_NET = 12, /* A14:网络状态(只读,不回显密码) */
+ CMD_COUNT,
+} cmd_id_t;
+
+typedef enum {
+ CMD_OK = 0,
+ CMD_ERR_PARSE = 1, /* 不是一行合法的扁平 JSON */
+ CMD_ERR_UNKNOWN = 2, /* 没有这条命令 */
+ CMD_ERR_NO_SEQ = 3, /* 会动东西的命令没带 seq(或 seq 不在 1~2^32−1) */
+ CMD_ERR_ARG = 4, /* 缺参数 / 参数类型不对 / 键名写错 */
+ CMD_ERR_RANGE = 5, /* 参数越界(SR-13 就落在这一类) */
+ CMD_ERR_EMPTY = 6, /* 该路刀片不在位(`PRSNT#` 高)—— 一律拒 */
+ CMD_ERR_BUSY = 7, /* 这一路正在跑另一个序列(§10.6 不可重入) */
+ CMD_ERR_STATE = 8, /* 当前状态下不许(例:eFuse 关着时开 VBUS) */
+ CMD_ERR_IO = 9, /* 下发到器件失败(I²C) */
+ CMD_ERR_OVERFLOW = 10, /* 回应装不进缓冲 —— 本模块的 bug,不该出现 */
+ CMD_ERR_LINK = 11, /* 这条命令不许从这条链路来(经 WiFi 改 WiFi 配置) */
+ CMD_ERR_COUNT,
+} cmd_err_t;
+
+/* 哪条链路来的。进 `LOG_EV_REBOOT` 的 arg 高 4 位。§10.8:两条链路的命令语义完全一致,
+ **唯一的例外**是 `wifi` / `mqtt` 两条配置命令只收 USB(netcfg.h:经 WiFi 改 WiFi 配置,
+ 一次手误就把唯一的远程通道弄断了)。 */
+typedef enum {
+ CMD_LINK_TEST = 0,
+ CMD_LINK_USB = 1,
+ CMD_LINK_WIFI = 2,
+} cmd_link_t;
+
+/* `LOG_EV_REBOOT` 的 arg:bit0 = 1 硬(断电)/ 0 软(长按电源键),bit4~7 = 链路。 */
+#define CMD_REBOOT_ARG(hard, link) ((uint8_t)(((hard) ? 1u : 0u) | ((unsigned)(link) << 4)))
+
+/* 判重窗口条数。上位机的重传间隔是秒级,窗口 32 条对应的是「几十条命令之内的重传」,
+ 够用;再大只是多占 RAM,而且窗口越大,上位机序号回绕时撞车的概率越高。 */
+#define CMD_SEQ_WINDOW 32
+
+/* 一次 `events` 最多回几条。每条约 50 字节,8 条 ≈ 400 字节,和 status 一个量级。 */
+#define CMD_EVENTS_MAX 8
+
+/* 回应缓冲的建议大小。status 满配实测 ≈ 900 字节(`test_status_fits_in_out_max`)。 */
+#define CMD_OUT_MAX 1536
+
+/* 请求行的最大长度(含结尾 0)。超长的行由适配器整行丢弃(A13 的分帧规矩)。 */
+#define CMD_LINE_MAX 256
+
+typedef struct {
+ const safety_snap_t *safety; /* NULL = status 里不报这一块 */
+ const thermal_snap_t *thermal; /* NULL = 同上 */
+} cmd_env_t;
+
+/* 清判重窗口、记下两份快照的位置。幂等。
+ ⚠️ 快照属于别的任务(巡检 / 热管理),这里是**跨任务只读**:status 读到的可能是
+ 半圈新半圈旧的数,对遥测无害;本模块**从不拿快照里的数做判断**
+ (判断只用 `rail_efuse_is_on()` / `slot_present()` 这类单字节的影子)。 */
+void cmd_init(const cmd_env_t *env);
+
+/* 处理一行、写回一行(不带换行)。返回回应长度(不含结尾 0)。
+ `out` 至少 64 字节才保证装得下任何一条错误回应。 */
+size_t cmd_handle_line(cmd_link_t link, const char *line, char *out, size_t out_len);
+
+const char *cmd_name(cmd_id_t id); /* 线上用的名字,"set_voltage" 这种 */
+const char *cmd_err_str(cmd_err_t e);
+
+/* 观测:累计处理几行 / 判重命中几次 / 拒绝几次(含解析错)。遥测与测试用。 */
+typedef struct {
+ uint32_t lines;
+ uint32_t dups;
+ uint32_t rejects;
+} cmd_stats_t;
+void cmd_stats(cmd_stats_t *st);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* CMD_H */
diff --git a/firmware/main/cmd_exec.c b/firmware/main/cmd_exec.c
new file mode 100644
index 0000000..bdcc5c1
--- /dev/null
+++ b/firmware/main/cmd_exec.c
@@ -0,0 +1,239 @@
+/* 六条「会动东西的命令」的执行体 + 风扇下限(A12,需求 §10.8)。
+
+ 每一条都是同一个形状:**路号 → 在位 → 参数限幅 → 忙不忙 → 下发**。
+ 限幅与拒绝原因全在这里,适配器一个字都不碰(cmd.h 规矩 1)。
+ 判重、名字表、回应拼装在 cmd.c;status / events 的正文在 cmd_report.c。 */
+#include
+
+#include "cmd_priv.h"
+#include "evlog.h"
+#include "i2cbus.h"
+#include "rail.h"
+#include "rail_cfg.h"
+#include "slot.h"
+#include "t_actuator.h"
+
+/* ---- 共用的几步 ---------------------------------------------------------- */
+
+static cmd_err_t reject(ctx_t *c, cmd_err_t e, int64_t bad)
+{
+ c->bad = bad;
+ return e;
+}
+
+/* 路号 → 在位。**空槽一律拒**(计划 §5.8:空槽上电 = 金手指裸露带 12 V)。 */
+static cmd_err_t need_rail(ctx_t *c)
+{
+ int64_t r = 0;
+ if (cj_get_int(c->o, "rail", &r) != 1) {
+ return CMD_ERR_ARG;
+ }
+ if (r < 1 || r > PHONERAIL_RAIL_COUNT) {
+ return reject(c, CMD_ERR_RANGE, r);
+ }
+ c->rail = (int)r;
+ if (!slot_present(c->rail)) {
+ return reject(c, CMD_ERR_EMPTY, r);
+ }
+ return CMD_OK;
+}
+
+static cmd_err_t from_hal(hal_err_t e)
+{
+ switch (e) {
+ case HAL_OK: return CMD_OK;
+ case HAL_ERR_ARG: return CMD_ERR_RANGE; /* 前面已经限过幅,走到这里是两边的限值不一致 */
+ case HAL_ERR_STATE: return CMD_ERR_BUSY; /* 执行器:这一路正在跑别的序列 */
+ default: return CMD_ERR_IO;
+ }
+}
+
+/* ---- 各条命令 ------------------------------------------------------------ */
+
+typedef struct {
+ int rail;
+ int mv;
+} set_mv_arg_t;
+
+static hal_err_t set_mv_fn(void *arg)
+{
+ const set_mv_arg_t *a = arg;
+ return rail_set_mv(a->rail, a->mv);
+}
+
+cmd_err_t do_set_voltage(ctx_t *c)
+{
+ cmd_err_t e = need_rail(c);
+ if (e != CMD_OK) {
+ return e;
+ }
+ int64_t mv = 0;
+ if (cj_get_int(c->o, "mv", &mv) != 1) {
+ return CMD_ERR_ARG;
+ }
+ /* SR-13:没有任何命令能让输出超过 4.2 V。这里限的是 DAC 能到的 3524~4010,
+ 比 4.2 V 更紧;`rail_set_mv()` 自己还会再判一次(第四道之后的第五道)。 */
+ if (mv < RAIL_MV_MIN || mv > RAIL_MV_MAX) {
+ return reject(c, CMD_ERR_RANGE, mv);
+ }
+ /* ⚠️ DAC 在主干上,但 `rail_set_mv()` 自己**不拿锁**(它比 i2cbus 早)。
+ 不包这一层就是在巡检的事务中间插一笔 —— 总线上两个主机同时说话。 */
+ set_mv_arg_t a = { .rail = c->rail, .mv = (int)mv };
+ e = from_hal(i2cbus_xact(I2CBUS_CH_TRUNK, SLOT_LOCK_TIMEOUT_MS, set_mv_fn, &a));
+ if (e == CMD_OK) {
+ cj_w(c->w, ",\"set\":%d", rail_setpoint_mv(c->rail));
+ }
+ return e;
+}
+
+static int32_t now_mv(int rail)
+{
+ const safety_snap_t *s = cmd_env()->safety;
+ return (s != NULL && s->rail_ok[rail - 1]) ? s->rail[rail - 1].bus_mv : 0;
+}
+
+static int32_t now_ua(int rail)
+{
+ const safety_snap_t *s = cmd_env()->safety;
+ return (s != NULL && s->rail_ok[rail - 1]) ? s->rail[rail - 1].current_ua : 0;
+}
+
+cmd_err_t do_power_cycle(ctx_t *c)
+{
+ cmd_err_t e = need_rail(c);
+ if (e != CMD_OK) {
+ return e;
+ }
+ /* 断电时长不收参数:§7.4 的 3~5 s 窗口由 `ACT_HR_OFF_MS` 定死。
+ 让上位机传一个 1 s 进来,现象是「有的机型压根没重启」(t_actuator.h)。 */
+ e = from_hal(t_actuator_hard_reset(c->rail));
+ if (e == CMD_OK) {
+ evlog_put((uint8_t)c->rail, LOG_EV_REBOOT, CMD_REBOOT_ARG(true, c->link),
+ now_mv(c->rail), now_ua(c->rail));
+ }
+ return e;
+}
+
+/* "pwr+vdn" → 逻辑键位。未知的键名是参数错,不是「忽略掉那一个」。 */
+static bool parse_keys(const char *s, uint32_t *keys)
+{
+ static const struct { const char *name; uint32_t bit; } k[] = {
+ { "pwr", ACT_KEYS_PWR }, { "vup", ACT_KEYS_VUP }, { "vdn", ACT_KEYS_VDN },
+ };
+ uint32_t m = 0;
+ while (*s != '\0') {
+ const char *end = strchr(s, '+');
+ const size_t n = end ? (size_t)(end - s) : strlen(s);
+ bool hit = false;
+ for (size_t i = 0; i < sizeof(k) / sizeof(k[0]); i++) {
+ if (strlen(k[i].name) == n && strncmp(k[i].name, s, n) == 0) {
+ m |= k[i].bit;
+ hit = true;
+ }
+ }
+ if (!hit) {
+ return false;
+ }
+ s += n;
+ if (*s == '+') {
+ s++;
+ if (*s == '\0') {
+ return false; /* "pwr+" */
+ }
+ }
+ }
+ *keys = m;
+ return m != 0;
+}
+
+cmd_err_t do_key(ctx_t *c)
+{
+ cmd_err_t e = need_rail(c);
+ if (e != CMD_OK) {
+ return e;
+ }
+ const char *ks = NULL;
+ int64_t ms = 0;
+ uint32_t keys = 0;
+ if (cj_get_str(c->o, "keys", &ks) != 1 || !parse_keys(ks, &keys) ||
+ cj_get_int(c->o, "ms", &ms) != 1) {
+ return CMD_ERR_ARG;
+ }
+ if (ms < 1 || ms > ACT_KEY_MAX_MS) {
+ return reject(c, CMD_ERR_RANGE, ms); /* 拦「把电源键按死」的手误 */
+ }
+ e = from_hal(t_actuator_key(c->rail, keys, (uint32_t)ms));
+ if (e == CMD_OK && keys == ACT_KEYS_PWR && ms >= ACT_KEY_MS_FORCE) {
+ /* 长按电源键 ≥10 s = 两级重启的「一级(软)」(需求 §4.1),记一条重启事件。 */
+ evlog_put((uint8_t)c->rail, LOG_EV_REBOOT, CMD_REBOOT_ARG(false, c->link),
+ now_mv(c->rail), now_ua(c->rail));
+ }
+ return e;
+}
+
+cmd_err_t do_edl(ctx_t *c)
+{
+ const cmd_err_t e = need_rail(c);
+ return (e != CMD_OK) ? e : from_hal(t_actuator_edl(c->rail));
+}
+
+cmd_err_t do_vbus(ctx_t *c)
+{
+ cmd_err_t e = need_rail(c);
+ if (e != CMD_OK) {
+ return e;
+ }
+ bool on = false;
+ if (cj_get_bool(c->o, "on", &on) != 1) {
+ return CMD_ERR_ARG;
+ }
+ /* EDL 序列自己要动 VBUS,中途插手就把它的时序打乱了。 */
+ if (t_actuator_busy(c->rail)) {
+ return CMD_ERR_BUSY;
+ }
+ /* eFuse 关着时开 VBUS = 让手机只靠 USB 供着电 —— `rail_all_off()` 专门
+ 「先断 VBUS 再关 eFuse」就是为了不留这个窗口,命令也不许把它造出来。 */
+ if (on && !rail_efuse_is_on(c->rail)) {
+ return reject(c, CMD_ERR_STATE, 1);
+ }
+ return from_hal(slot_vbus_enable(c->rail, on));
+}
+
+cmd_err_t do_power(ctx_t *c)
+{
+ cmd_err_t e = need_rail(c);
+ if (e != CMD_OK) {
+ return e;
+ }
+ bool on = false;
+ if (cj_get_bool(c->o, "on", &on) != 1) {
+ return CMD_ERR_ARG;
+ }
+ if (t_actuator_busy(c->rail)) {
+ return CMD_ERR_BUSY; /* 硬重启走到一半再开关一次是自作主张(t_actuator_abort 同理) */
+ }
+ if (on) {
+ return from_hal(rail_efuse_enable(c->rail, true));
+ }
+ /* 关:与 `rail_all_off()` 同序 —— **先断 VBUS 再关 eFuse**。
+ VBUS 走 I²C 可能断不掉,eFuse 是 GPIO 永远关得掉:前者失败也照样关后者,
+ 回应报 io,上位机据此知道「直供断了,VBUS 可能还通着」。 */
+ const hal_err_t ev = slot_vbus_enable(c->rail, false);
+ const hal_err_t ee = rail_efuse_enable(c->rail, false);
+ return from_hal(ee != HAL_OK ? ee : ev);
+}
+
+cmd_err_t do_fan(ctx_t *c)
+{
+ int64_t duty = 0;
+ if (cj_get_int(c->o, "duty", &duty) != 1) {
+ return CMD_ERR_ARG;
+ }
+ if (duty < 0 || duty > 1000) {
+ return reject(c, CMD_ERR_RANGE, duty);
+ }
+ /* 手动值只是**下限**:温度曲线要得更高时照曲线来(t_thermal.h)。0 = 交回自动。 */
+ t_thermal_set_floor((uint16_t)duty);
+ cj_w(c->w, ",\"floor\":%u", (unsigned)t_thermal_floor());
+ return CMD_OK;
+}
diff --git a/firmware/main/cmd_json.c b/firmware/main/cmd_json.c
new file mode 100644
index 0000000..8da435d
--- /dev/null
+++ b/firmware/main/cmd_json.c
@@ -0,0 +1,213 @@
+/* 扁平 JSON 解析 + 定长写入器。契约与「刻意不支持」的清单在 cmd_json.h。 */
+#include "cmd_json.h"
+
+#include
+#include
+
+static const char *ws(const char *p)
+{
+ while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n') {
+ p++;
+ }
+ return p;
+}
+
+/* 读一个 `"..."`,写进 out(容量 cap,含结尾 0)。返回引号之后的位置,失败 NULL。
+ 只收可打印 ASCII 之外不做任何解释:反斜杠与控制字符一律失败。 */
+static const char *str(const char *p, char *out, size_t cap)
+{
+ if (*p != '"') {
+ return NULL;
+ }
+ p++;
+ size_t n = 0;
+ while (*p != '"') {
+ const unsigned char c = (unsigned char)*p;
+ if (c == '\0' || c == '\\' || c < 0x20) {
+ return NULL;
+ }
+ if (n + 1 >= cap) {
+ return NULL; /* 太长:截断会把 "set_voltage_x" 读成别的东西 */
+ }
+ out[n++] = (char)c;
+ p++;
+ }
+ out[n] = '\0';
+ return p + 1;
+}
+
+static const char *num(const char *p, int64_t *v)
+{
+ bool neg = false;
+ if (*p == '-') {
+ neg = true;
+ p++;
+ }
+ if (*p < '0' || *p > '9') {
+ return NULL;
+ }
+ if (*p == '0' && p[1] >= '0' && p[1] <= '9') {
+ return NULL; /* 前导零:JSON 本身就不许,也防 "010" 被当成八进制理解 */
+ }
+ int64_t acc = 0;
+ int digits = 0;
+ while (*p >= '0' && *p <= '9') {
+ if (++digits > 18) {
+ return NULL;
+ }
+ acc = acc * 10 + (*p - '0');
+ p++;
+ }
+ if (*p == '.' || *p == 'e' || *p == 'E') {
+ return NULL; /* 浮点:见头文件 */
+ }
+ *v = neg ? -acc : acc;
+ return p;
+}
+
+static bool word(const char **pp, const char *w)
+{
+ const size_t n = strlen(w);
+ if (strncmp(*pp, w, n) != 0) {
+ return false;
+ }
+ *pp += n;
+ return true;
+}
+
+static const cj_field_t *find(const cj_obj_t *o, const char *key)
+{
+ for (int i = 0; i < o->n; i++) {
+ if (strcmp(o->f[i].key, key) == 0) {
+ return &o->f[i];
+ }
+ }
+ return NULL;
+}
+
+bool cj_parse(const char *line, cj_obj_t *o)
+{
+ memset(o, 0, sizeof(*o));
+ if (line == NULL) {
+ return false;
+ }
+ const char *p = ws(line);
+ if (*p++ != '{') {
+ return false;
+ }
+ p = ws(p);
+ if (*p == '}') {
+ p++;
+ } else {
+ for (;;) {
+ if (o->n >= CMD_JSON_MAX_FIELDS) {
+ return false;
+ }
+ cj_field_t *f = &o->f[o->n];
+ p = str(p, f->key, sizeof(f->key));
+ if (p == NULL || f->key[0] == '\0' || find(o, f->key) != NULL) {
+ return false; /* 解析失败 / 空键 / 重复键 */
+ }
+ p = ws(p);
+ if (*p++ != ':') {
+ return false;
+ }
+ p = ws(p);
+ if (*p == '"') {
+ f->type = CJ_STR;
+ p = str(p, f->s, sizeof(f->s));
+ } else if (word(&p, "true")) {
+ f->type = CJ_BOOL;
+ f->i = 1;
+ } else if (word(&p, "false")) {
+ f->type = CJ_BOOL;
+ f->i = 0;
+ } else {
+ f->type = CJ_INT;
+ p = num(p, &f->i);
+ }
+ if (p == NULL) {
+ return false;
+ }
+ o->n++;
+ p = ws(p);
+ if (*p == ',') {
+ p = ws(p + 1);
+ continue;
+ }
+ if (*p == '}') {
+ p++;
+ break;
+ }
+ return false;
+ }
+ }
+ return *ws(p) == '\0'; /* 右括号之后只许有空白:两条命令粘在一行里是分帧的 bug */
+}
+
+int cj_get_int(const cj_obj_t *o, const char *key, int64_t *v)
+{
+ const cj_field_t *f = find(o, key);
+ if (f == NULL) {
+ return 0;
+ }
+ if (f->type != CJ_INT) {
+ return -1;
+ }
+ *v = f->i;
+ return 1;
+}
+
+int cj_get_str(const cj_obj_t *o, const char *key, const char **s)
+{
+ const cj_field_t *f = find(o, key);
+ if (f == NULL) {
+ return 0;
+ }
+ if (f->type != CJ_STR) {
+ return -1;
+ }
+ *s = f->s;
+ return 1;
+}
+
+int cj_get_bool(const cj_obj_t *o, const char *key, bool *v)
+{
+ const cj_field_t *f = find(o, key);
+ if (f == NULL) {
+ return 0;
+ }
+ if (f->type != CJ_BOOL) {
+ return -1; /* `"on":1` 不是布尔。收 1 的话 `"on":2` 该算什么就说不清了 */
+ }
+ *v = f->i != 0;
+ return 1;
+}
+
+void cj_w_init(cj_writer_t *w, char *buf, size_t cap)
+{
+ w->buf = buf;
+ w->cap = cap;
+ w->pos = 0;
+ w->overflow = (buf == NULL || cap == 0);
+ if (!w->overflow) {
+ buf[0] = '\0';
+ }
+}
+
+void cj_w(cj_writer_t *w, const char *fmt, ...)
+{
+ if (w->overflow) {
+ return;
+ }
+ va_list ap;
+ va_start(ap, fmt);
+ const int n = vsnprintf(w->buf + w->pos, w->cap - w->pos, fmt, ap);
+ va_end(ap);
+ if (n < 0 || (size_t)n >= w->cap - w->pos) {
+ w->overflow = true;
+ w->buf[w->pos] = '\0'; /* 把写了一半的那段撤掉 */
+ return;
+ }
+ w->pos += (size_t)n;
+}
diff --git a/firmware/main/cmd_json.h b/firmware/main/cmd_json.h
new file mode 100644
index 0000000..ca4654c
--- /dev/null
+++ b/firmware/main/cmd_json.h
@@ -0,0 +1,83 @@
+/* 命令行的 JSON 两头:**只认一层扁平对象**的解析器 + 往定长缓冲里拼的写入器(A12,需求 §10.8)。
+
+ §10.8 定的分帧是「一条命令 = 一行 JSON」。请求这一侧永远是一层扁平对象:
+
+ {"seq":17,"cmd":"key","rail":3,"keys":"pwr+vdn","ms":2000}
+
+ 所以这里**不引 cJSON**,自己写一个只认这个形状的小解析器,理由两条:
+ · cJSON 是 ESP-IDF 的组件,宿主机上要另外 vendor 一份;它还要 malloc,
+ 而命令处理器跑在执行器任务里(cmd.h),那里不该有堆分配;
+ · 请求形状越窄,越界输入的面就越小:嵌套、数组、转义、null、浮点**一律当解析错**,
+ 上位机本来就不该发这些。
+
+ ---- 刻意不支持的东西 ----------------------------------------------------
+ · **字符串转义**(`\"`、`\n`、`\u....`):命令里的字符串只有命令名和键名这类 ASCII 词,
+ 遇到反斜杠直接判解析错,而不是「尽量理解」—— 半懂不懂地解出来比拒绝危险。
+ · **浮点**:电压用整数 mV、时长用整数 ms。`3.9` 是解析错,不会被截成 3。
+ · **重复的键**:`{"mv":3900,"mv":4300}` 是解析错。按「后者覆盖」理解的实现不止一种,
+ 而这正是有人拿来绕过校验的那种写法。 */
+#ifndef CMD_JSON_H
+#define CMD_JSON_H
+
+#include
+#include
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define CMD_JSON_MAX_FIELDS 12
+#define CMD_JSON_KEY_MAX 16 /* 含结尾 0 */
+#define CMD_JSON_STR_MAX 65 /* 含结尾 0。64 = WPA2 口令上限 63 与 broker 地址(A14) */
+
+typedef enum {
+ CJ_INT = 1,
+ CJ_STR,
+ CJ_BOOL,
+} cj_type_t;
+
+typedef struct {
+ char key[CMD_JSON_KEY_MAX];
+ cj_type_t type;
+ int64_t i; /* CJ_INT / CJ_BOOL(0/1) */
+ char s[CMD_JSON_STR_MAX]; /* CJ_STR */
+} cj_field_t;
+
+typedef struct {
+ cj_field_t f[CMD_JSON_MAX_FIELDS];
+ int n;
+} cj_obj_t;
+
+/* 解析一行。`line` 可以带结尾的 `\r` / `\n`(当空白吞掉)。成功返回 true。
+ 整数只收 18 位以内(绝对值 < 10^18),超了是解析错 —— 不做回绕。 */
+bool cj_parse(const char *line, cj_obj_t *o);
+
+/* 三个取值器的返回值:1 = 有且类型对,0 = 没这个键,-1 = 有但类型不对。
+ 「类型不对」与「没有」要分开:`"mv":"3900"` 不是「没给电压」,是上位机的 bug。 */
+int cj_get_int(const cj_obj_t *o, const char *key, int64_t *v);
+int cj_get_str(const cj_obj_t *o, const char *key, const char **s);
+int cj_get_bool(const cj_obj_t *o, const char *key, bool *v);
+
+/* ---- 写入器:往定长缓冲里拼,写满了只置标志、不越界 ---------------------- */
+
+typedef struct {
+ char *buf;
+ size_t cap;
+ size_t pos;
+ bool overflow; /* 某一次没写下 ⇒ 整行作废(cmd.c 会换成一条 overflow 错误) */
+} cj_writer_t;
+
+void cj_w_init(cj_writer_t *w, char *buf, size_t cap);
+
+#if defined(__GNUC__)
+__attribute__((format(printf, 2, 3)))
+#endif
+void cj_w(cj_writer_t *w, const char *fmt, ...);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* CMD_JSON_H */
diff --git a/firmware/main/cmd_net.c b/firmware/main/cmd_net.c
new file mode 100644
index 0000000..ac438a7
--- /dev/null
+++ b/firmware/main/cmd_net.c
@@ -0,0 +1,107 @@
+/* 网络的三条命令(A14 / C1,需求 IF-19):`wifi` / `mqtt` 写配置(**只收 USB**),`net` 读状态。
+
+ `net` 的回应里**永远没有密码**,只有 `pass_set` / `mpass_set`(netcfg.h「密码永不外泄」)。 */
+#include
+
+#include "cmd_priv.h"
+#include "netcfg.h"
+
+/* 配置命令只收 USB(cmd.h 链路那一段)。宿主机测试用的 CMD_LINK_TEST 视同 USB。 */
+static bool usb_only_ok(const ctx_t *c) { return c->link != CMD_LINK_WIFI; }
+
+static cmd_err_t get_str(const ctx_t *c, const char *key, bool required, const char **out)
+{
+ static const char empty[] = "";
+ const int g = cj_get_str(c->o, key, out);
+ if (g == 1) {
+ return CMD_OK;
+ }
+ if (g == 0 && !required) {
+ *out = empty;
+ return CMD_OK;
+ }
+ return CMD_ERR_ARG;
+}
+
+cmd_err_t do_wifi(ctx_t *c)
+{
+ if (!usb_only_ok(c)) {
+ c->bad = CMD_WIFI;
+ return CMD_ERR_LINK;
+ }
+ const char *ssid = NULL;
+ const char *pass = NULL;
+ if (get_str(c, "ssid", true, &ssid) != CMD_OK || get_str(c, "pass", false, &pass) != CMD_OK) {
+ return CMD_ERR_ARG;
+ }
+ if (!netcfg_wifi_ok(ssid, pass)) {
+ c->bad = (int64_t)strlen(pass); /* 最常见的错是口令长度(<8),记长度不记内容 */
+ return CMD_ERR_RANGE;
+ }
+ return netcfg_set_wifi(ssid, pass) == HAL_OK ? CMD_OK : CMD_ERR_IO;
+}
+
+cmd_err_t do_mqtt(ctx_t *c)
+{
+ if (!usb_only_ok(c)) {
+ c->bad = CMD_MQTT;
+ return CMD_ERR_LINK;
+ }
+ const char *uri = NULL;
+ const char *user = NULL;
+ const char *mpass = NULL;
+ int64_t tele = 0;
+ const int gt = cj_get_int(c->o, "tele_ms", &tele);
+ if (get_str(c, "uri", true, &uri) != CMD_OK || get_str(c, "user", false, &user) != CMD_OK ||
+ get_str(c, "pass", false, &mpass) != CMD_OK || gt < 0) {
+ return CMD_ERR_ARG;
+ }
+ if (tele < 0 || tele > NETCFG_TELE_MAX_MS ||
+ !netcfg_mqtt_ok(uri, user, mpass, (uint32_t)tele)) {
+ c->bad = tele;
+ return CMD_ERR_RANGE;
+ }
+ return netcfg_set_mqtt(uri, user, mpass, (uint32_t)tele) == HAL_OK ? CMD_OK : CMD_ERR_IO;
+}
+
+/* 把一段来路不可信的字符串写成 JSON 字符串内容:引号、反斜杠、控制字符换成 `?`。
+ 经命令写进来的配置过了解析器,本来就没有这些;但 NVS 里读回来的可能是任何东西,
+ 一个裸引号就能让整条回应变成非法 JSON,或者更糟 —— 变成另一段合法 JSON。 */
+static void w_str(cj_writer_t *w, const char *s)
+{
+ char buf[NETCFG_URI_MAX + 1];
+ size_t n = 0;
+ for (; *s != '\0' && n < sizeof(buf) - 1; s++) {
+ const unsigned char ch = (unsigned char)*s;
+ buf[n++] = (ch < 0x20 || ch == 0x7F || ch == '"' || ch == '\\' || ch >= 0x80) ? '?' : (char)ch;
+ }
+ buf[n] = '\0';
+ cj_w(w, "\"%s\"", buf);
+}
+
+cmd_err_t do_net(ctx_t *c)
+{
+ netcfg_t cfg;
+ net_status_t st;
+ netcfg_get(&cfg);
+ net_status_get(&st);
+ cj_writer_t *w = c->w;
+
+ cj_w(w, ",\"id\":");
+ w_str(w, st.id);
+ cj_w(w, ",\"wifi\":\"%s\",\"ssid\":", net_state_str(st.wifi));
+ w_str(w, cfg.ssid);
+ cj_w(w, ",\"pass_set\":%s,\"ip\":", cfg.pass[0] ? "true" : "false");
+ w_str(w, st.ip);
+ cj_w(w, ",\"rssi\":%d,\"wifi_reason\":%u,\"mqtt\":\"%s\",\"uri\":", (int)st.rssi,
+ (unsigned)st.wifi_reason, net_state_str(st.mqtt));
+ w_str(w, cfg.uri);
+ cj_w(w, ",\"user\":");
+ w_str(w, cfg.user);
+ cj_w(w, ",\"mpass_set\":%s,\"tele_ms\":%lu,\"drops\":{\"wifi\":%lu,\"mqtt\":%lu},"
+ "\"tele\":{\"sent\":%lu,\"skipped\":%lu}",
+ cfg.mpass[0] ? "true" : "false", (unsigned long)cfg.tele_ms,
+ (unsigned long)st.wifi_drops, (unsigned long)st.mqtt_drops,
+ (unsigned long)st.tele_sent, (unsigned long)st.tele_skipped);
+ return CMD_OK;
+}
diff --git a/firmware/main/cmd_priv.h b/firmware/main/cmd_priv.h
new file mode 100644
index 0000000..ac0d603
--- /dev/null
+++ b/firmware/main/cmd_priv.h
@@ -0,0 +1,41 @@
+/* cmd.c / cmd_exec.c / cmd_report.c 之间的内部接口。别处不许 include。 */
+#ifndef CMD_PRIV_H
+#define CMD_PRIV_H
+
+#include "cmd.h"
+#include "cmd_json.h"
+
+/* 一条命令的上下文。 */
+typedef struct {
+ const cj_obj_t *o;
+ cmd_link_t link;
+ cj_writer_t *w; /* 成功时的附加字段写这里(以逗号开头) */
+ int rail; /* 已校验过的路号;0 = 还没有 / 整板命令 */
+ int64_t bad; /* 被拒时「越界的那个值」,进事件的 bus_mv */
+} ctx_t;
+
+/* 两份快照的位置(cmd_init 给的)。 */
+const cmd_env_t *cmd_env(void);
+
+/* cmd_exec.c:六条会动东西的命令 + 风扇下限。返回 CMD_OK 时可能往 c->w 写了附加字段;
+ 被拒时把越界的那个值留在 c->bad(进事件的 bus_mv)。 */
+cmd_err_t do_set_voltage(ctx_t *c);
+cmd_err_t do_power_cycle(ctx_t *c);
+cmd_err_t do_key(ctx_t *c);
+cmd_err_t do_edl(ctx_t *c);
+cmd_err_t do_vbus(ctx_t *c);
+cmd_err_t do_power(ctx_t *c);
+cmd_err_t do_fan(ctx_t *c);
+
+/* cmd_net.c:网络配置(只收 USB)与网络状态。 */
+cmd_err_t do_wifi(ctx_t *c);
+cmd_err_t do_mqtt(ctx_t *c);
+cmd_err_t do_net(ctx_t *c);
+
+/* status 的正文(不含外层的 `{"seq":..,"ok":true` 与结尾 `}`,以逗号开头)。 */
+void cmd_report_status(cj_writer_t *w, const cmd_env_t *env);
+
+/* events 的正文。先把 RAM 里的事件落盘(`evlog_read()` 只服务已落盘的部分)。 */
+void cmd_report_events(cj_writer_t *w, uint32_t from_seq);
+
+#endif /* CMD_PRIV_H */
diff --git a/firmware/main/cmd_queue.h b/firmware/main/cmd_queue.h
new file mode 100644
index 0000000..ebefbaa
--- /dev/null
+++ b/firmware/main/cmd_queue.h
@@ -0,0 +1,33 @@
+/* 传输适配器 → 命令处理器的入口(A12 定形状,A13 / A14 是调用方)。**只在 ESP 目标上有**。
+
+ §10.8:两条链路只是传输,命令处理器只有一个。适配器把切好的一行交到这里,
+ 由**执行器任务**取出来调 `cmd_handle_line()`(cmd.h「在哪个任务里跑」),
+ 回应经 `reply` 交还给适配器去发。
+
+ ⚠️ `reply` 是在**执行器任务**里被调的,不是在适配器自己的任务里:
+ 它必须线程安全、不许阻塞太久(执行器还要按时推状态机,松键靠它)。
+ USB-Serial-JTAG 的写与 MQTT publish 都满足;做不到的适配器就在回调里只做入队。 */
+#ifndef CMD_QUEUE_H
+#define CMD_QUEUE_H
+
+#include
+#include
+
+#include "cmd.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef void (*cmd_reply_fn)(void *ctx, const char *line, size_t len);
+
+/* 投递一行(会被拷贝,调用方的缓冲可以立刻复用)。超过 `CMD_LINE_MAX − 1` 的行直接拒收。
+ 队列满 / 超长 / 还没起来都返回 false —— 适配器应当回一条错误或者丢弃,
+ **不许**自己去调 `cmd_handle_line()`(那会绕开「只在执行器任务里跑」这条)。 */
+bool cmd_queue_submit(cmd_link_t link, const char *line, cmd_reply_fn reply, void *ctx);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* CMD_QUEUE_H */
diff --git a/firmware/main/cmd_report.c b/firmware/main/cmd_report.c
new file mode 100644
index 0000000..efd067b
--- /dev/null
+++ b/firmware/main/cmd_report.c
@@ -0,0 +1,132 @@
+/* `status` 与 `events` 两条只读命令的正文(A12,需求 §10.8)。
+
+ 两条都是**只读、幂等**的,所以不进判重窗口(cmd.h)。
+ 输出一律是整数(mV / µA / m°C / 千分比 / ms),不引浮点,和固件内部口径一致;
+ 读不到的量写 `null`,**不写 0** —— 0 mA 与「这一圈没读到」是两回事,
+ 上位机的卡死判据(电流曲线)拿 0 当真值会直接误判。 */
+#include
+
+#include "cmd_priv.h"
+#include "evlog.h"
+#include "hal_time.h"
+#include "pca9555.h"
+#include "phonerail.h"
+#include "rail.h"
+#include "rail_cfg.h"
+#include "slot.h"
+#include "t_actuator.h"
+
+static const char *b(bool v) { return v ? "true" : "false"; }
+
+static void rail_obj(cj_writer_t *w, int rail, const safety_snap_t *s)
+{
+ const int i = rail - 1;
+ cj_w(w, "{\"r\":%d,\"pr\":%s,\"en\":%s,\"vb\":%s,\"set\":%d", rail, b(slot_present(rail)),
+ b(rail_efuse_is_on(rail)), b(slot_vbus_is_on(rail)), rail_setpoint_mv(rail));
+ if (s != NULL) {
+ if (s->rail_ok[i]) {
+ cj_w(w, ",\"mv\":%ld,\"ua\":%ld", (long)s->rail[i].bus_mv,
+ (long)s->rail[i].current_ua);
+ } else {
+ cj_w(w, ",\"mv\":null,\"ua\":null");
+ }
+ if (s->io_ok[i]) {
+ cj_w(w, ",\"pg\":%s,\"flt\":%s", b(pca9555_efuse_pg(&s->io[i])),
+ b(pca9555_efuse_fault(&s->io[i])));
+ } else {
+ cj_w(w, ",\"pg\":null,\"flt\":null");
+ }
+ }
+ cj_w(w, ",\"st\":\"%s\",\"res\":\"%s\"}", t_actuator_state_str(t_actuator_state(rail)),
+ t_actuator_result_str(t_actuator_last_result(rail)));
+}
+
+static void thermal_obj(cj_writer_t *w, const thermal_snap_t *t)
+{
+ cj_w(w, ",\"temp\":[");
+ for (int i = 0; i < T_THERMAL_NTC_COUNT; i++) {
+ if (t->temp_ok[i]) {
+ cj_w(w, "%s%ld", i ? "," : "", (long)t->temp_mc[i]);
+ } else {
+ cj_w(w, "%snull", i ? "," : "");
+ }
+ }
+ cj_w(w, "],\"fan\":{\"duty\":%u,\"floor\":%u,\"en\":%s,\"rpm\":[", (unsigned)t->duty,
+ (unsigned)t_thermal_floor(), b(t->fan_en));
+ for (int i = 0; i < T_THERMAL_FAN_COUNT; i++) {
+ if (t->fan[i].rpm_valid) {
+ cj_w(w, "%s%lu", i ? "," : "", (unsigned long)t->fan[i].rpm);
+ } else {
+ cj_w(w, "%snull", i ? "," : "");
+ }
+ }
+ cj_w(w, "],\"stall\":[");
+ for (int i = 0; i < T_THERMAL_FAN_COUNT; i++) {
+ cj_w(w, "%s%s", i ? "," : "", b(t->fan[i].stalled));
+ }
+ cj_w(w, "]},\"derate\":%s", b(t->derate));
+}
+
+void cmd_report_status(cj_writer_t *w, const cmd_env_t *env)
+{
+ const safety_snap_t *s = env->safety;
+ cj_w(w, ",\"fw\":\"%s\",\"up\":%lu", phonerail_version(), (unsigned long)hal_time_now_ms());
+ if (s != NULL) {
+ if (s->input_ok) {
+ cj_w(w, ",\"in\":{\"mv\":%ld,\"ua\":%ld}", (long)s->input.bus_mv,
+ (long)s->input.current_ua);
+ } else {
+ cj_w(w, ",\"in\":null");
+ }
+ }
+ cj_w(w, ",\"rails\":[");
+ for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
+ if (rail > 1) {
+ cj_w(w, ",");
+ }
+ rail_obj(w, rail, s);
+ }
+ cj_w(w, "]");
+ if (env->thermal != NULL) {
+ thermal_obj(w, env->thermal);
+ }
+ if (s != NULL) {
+ cj_w(w, ",\"safety\":{\"cycles\":%lu,\"skipped\":%lu,\"i2c_err\":%lu,\"events\":%lu}",
+ (unsigned long)s->cycles, (unsigned long)s->skipped,
+ (unsigned long)s->i2c_errors, (unsigned long)s->events);
+ }
+ evlog_stats_t es;
+ evlog_stats(&es);
+ cmd_stats_t cs;
+ cmd_stats(&cs);
+ /* `ev_next` 是**已落盘**的下一个序号;还在 RAM 里的 `ev_pending` 条要等 events 触发落盘。
+ 上位机拿 `ev_next` 与自己的游标比,就知道要不要拉日志,不用每次都拉。 */
+ cj_w(w, ",\"ev_next\":%lu,\"ev_pending\":%lu,\"cmd\":{\"lines\":%lu,\"dups\":%lu,\"rej\":%lu}",
+ (unsigned long)es.next_seq, (unsigned long)es.pending, (unsigned long)cs.lines,
+ (unsigned long)cs.dups, (unsigned long)cs.rejects);
+}
+
+void cmd_report_events(cj_writer_t *w, uint32_t from_seq)
+{
+ (void)evlog_flush(0);
+ evlog_stats_t es;
+ evlog_stats(&es);
+
+ evlog_rec_t rec[CMD_EVENTS_MAX];
+ const uint32_t n = evlog_read(from_seq, rec, CMD_EVENTS_MAX);
+ /* `evlog_read()` 从 max(from, oldest) 开始读、序号连续(洞也占位),
+ 所以每条的序号可以这样算回来 —— 回应里逐条带上,上位机不用自己推。 */
+ const uint32_t start = (from_seq < es.oldest_seq) ? es.oldest_seq : from_seq;
+
+ /* `oldest` 大于上位机的游标 ⇒ 中间那一段已经被环形缓冲盖掉了(evlog.h)。 */
+ cj_w(w, ",\"oldest\":%lu,\"next\":%lu,\"ev\":[", (unsigned long)es.oldest_seq,
+ (unsigned long)es.next_seq);
+ for (uint32_t i = 0; i < n; i++) {
+ /* [序号, 上电毫秒, 路, 类型, arg, mV, µA]。类型是 `log_ev_t` 的冻结数字,
+ 名字表在上位机(中文名进不了 ASCII 行,也没必要每条都带)。 */
+ cj_w(w, "%s[%lu,%lu,%u,%u,%u,%ld,%ld]", i ? "," : "", (unsigned long)(start + i),
+ (unsigned long)rec[i].t_ms, (unsigned)rec[i].rail, (unsigned)rec[i].type,
+ (unsigned)rec[i].arg, (long)rec[i].bus_mv, (long)rec[i].current_ua);
+ }
+ cj_w(w, "]");
+}
diff --git a/firmware/main/mlink.c b/firmware/main/mlink.c
new file mode 100644
index 0000000..90c7800
--- /dev/null
+++ b/firmware/main/mlink.c
@@ -0,0 +1,84 @@
+/* MQTT 链路的平台无关部分。topic 表与各条规矩在 mlink.h。 */
+#include "mlink.h"
+
+#include
+#include
+
+#include "cmd.h"
+#include "cmd_json.h"
+
+static char s_id[13];
+static char s_topic[MLINK_T_COUNT][MLINK_TOPIC_MAX];
+
+static const char *const k_suffix[MLINK_T_COUNT] = {
+ [MLINK_T_CMD] = "cmd", [MLINK_T_RSP] = "rsp", [MLINK_T_TELE] = "tele", [MLINK_T_STATE] = "state",
+};
+
+void mlink_init(const uint8_t mac[6])
+{
+ snprintf(s_id, sizeof(s_id), "%02x%02x%02x%02x%02x%02x", mac[0], mac[1], mac[2], mac[3],
+ mac[4], mac[5]);
+ for (int t = 0; t < MLINK_T_COUNT; t++) {
+ snprintf(s_topic[t], sizeof(s_topic[t]), MLINK_TOPIC_ROOT "/%s/%s", s_id, k_suffix[t]);
+ }
+}
+
+const char *mlink_id(void) { return s_id; }
+
+const char *mlink_topic(mlink_topic_t t) { return (t < MLINK_T_COUNT) ? s_topic[t] : ""; }
+
+size_t mlink_state_online(const char *fw, const char *ip, char *out, size_t cap)
+{
+ cj_writer_t w;
+ cj_w_init(&w, out, cap);
+ cj_w(&w, "{\"online\":true,\"fw\":\"%s\",\"ip\":\"%s\"}", fw, ip);
+ return w.overflow ? 0 : w.pos;
+}
+
+const char *mlink_inbound(const char *data, int len, int total, char *line, size_t cap)
+{
+ /* 被切片的消息:命令最长 CMD_LINE_MAX,esp-mqtt 的缓冲 1 KB,切片只会出现在
+ 超长消息上 —— 直接拒,不做重组(重组就是一块要管生命周期的缓冲)。 */
+ if (data == NULL || len <= 0 || len != total || cap == 0 || (size_t)len >= cap ||
+ len >= CMD_LINE_MAX) {
+ return "too_long";
+ }
+ for (int i = 0; i < len; i++) {
+ const unsigned char c = (unsigned char)data[i];
+ /* 同 USB 的理由:NUL 会让 JSON 解析提前结束,后面的内容就被放过了。 */
+ if ((c < 0x20 && c != '\t') || c == 0x7F) {
+ return "bad_frame";
+ }
+ }
+ memcpy(line, data, (size_t)len);
+ line[len] = '\0';
+ return NULL;
+}
+
+uint32_t mlink_backoff_ms(uint32_t attempt)
+{
+ if (attempt >= 6) {
+ return MLINK_BACKOFF_MAX_MS; /* 1 s << 6 = 64 s,已经过顶 */
+ }
+ const uint32_t ms = 1000u << attempt;
+ return ms > MLINK_BACKOFF_MAX_MS ? MLINK_BACKOFF_MAX_MS : ms;
+}
+
+bool mlink_tele_due(mlink_tele_t *t, uint32_t now_ms, uint32_t period_ms)
+{
+ if (!t->started) {
+ t->started = true;
+ t->last_ms = now_ms;
+ return true; /* 一连上就先报一条:服务器立刻有数据 */
+ }
+ if ((uint32_t)(now_ms - t->last_ms) < period_ms) {
+ return false;
+ }
+ /* 推进一个整周期而不是「= now」:被挤晚了不累积漂移;落后超过一个周期就对齐到现在,
+ 不补发(补发的现象是网络一恢复连着冒出一串旧遥测)。 */
+ t->last_ms += period_ms;
+ if ((uint32_t)(now_ms - t->last_ms) >= period_ms) {
+ t->last_ms = now_ms;
+ }
+ return true;
+}
diff --git a/firmware/main/mlink.h b/firmware/main/mlink.h
new file mode 100644
index 0000000..14890aa
--- /dev/null
+++ b/firmware/main/mlink.h
@@ -0,0 +1,75 @@
+/* WiFi/MQTT 链路的平台无关部分(A14,需求 §10.8「A14 定稿的 MQTT topic」)。
+ ESP 胶水(WiFi 驱动、esp-mqtt、NVS)在 mlink_esp.c。
+
+ ---- topic(base = `phonerail/`,id = STA MAC 12 位小写十六进制)----------
+
+ | topic | 方向 | QoS | retain | 内容 |
+ |---|---|---|---|---|
+ | `/cmd` | 服务器 → 板 | 1 | 否 | 一条命令 JSON(与 USB 的 `@C` 负载完全相同,**不带前缀与 CRC**) |
+ | `/rsp` | 板 → 服务器 | 1 | 否 | 一条回应 JSON |
+ | `/tele` | 板 → 服务器 | 0 | 否 | 周期遥测 = 一条 `status` 回应(无 seq),默认 1 s |
+ | `/state` | 板 → 服务器 | 1 | **是** | `{"online":true,"fw":..,"ip":..}`;掉线由遗嘱改成 `{"online":false}` |
+
+ · MQTT 下面是 TCP,完整性有保证,所以不要 CRC;分帧就是「一条消息一条命令」。
+ · QoS 1 意味着 broker 可能重投 ⇒ 板子会收到同一条命令两次 —— 这正是 seq 判重
+ (cmd.h)存在的理由,这里不做第二层去重。
+ · 服务器订阅 `phonerail/+/rsp`、`phonerail/+/tele`、`phonerail/+/state` 就能管所有板子,
+ 新板上线时 retained 的 state 告诉它有这块板。 */
+#ifndef MLINK_H
+#define MLINK_H
+
+#include
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define MLINK_TOPIC_ROOT "phonerail"
+#define MLINK_TOPIC_MAX 40 /* "phonerail/" + 12 + "/state" + 0 */
+
+typedef enum {
+ MLINK_T_CMD = 0,
+ MLINK_T_RSP,
+ MLINK_T_TELE,
+ MLINK_T_STATE,
+ MLINK_T_COUNT,
+} mlink_topic_t;
+
+/* MAC → id(12 位小写十六进制)+ 四个 topic。 */
+void mlink_init(const uint8_t mac[6]);
+const char *mlink_id(void);
+const char *mlink_topic(mlink_topic_t t);
+
+/* 遗嘱消息(静态,建连时交给 broker)。 */
+#define MLINK_STATE_OFFLINE "{\"online\":false}"
+
+/* 上线消息。返回长度,装不下返回 0。 */
+size_t mlink_state_online(const char *fw, const char *ip, char *out, size_t cap);
+
+/* 一条入站 `cmd` 消息 → 可以投进 cmd_queue 的一行(带结尾 0)。
+ `len` 是本片长度,`total` 是整条消息长度(esp-mqtt 会把大消息切片送来)。
+ 返回 NULL = 可以投;否则返回链路层错误名(`too_long` / `bad_frame`),
+ 由胶水直接回到 rsp(不进命令处理器,同 USB 的链路层错误)。 */
+const char *mlink_inbound(const char *data, int len, int total, char *line, size_t cap);
+
+/* WiFi 重连退避(需求 §10.12:指数退避 + 上限):第 attempt 次失败之后等多久。
+ 1 s、2 s、4 s … 封顶 60 s。**不加随机**:一个机架五块板同时掉线再同时重连,
+ 对家用 AP 不构成压力,而确定性让测试与现场日志都好读。 */
+uint32_t mlink_backoff_ms(uint32_t attempt);
+#define MLINK_BACKOFF_MAX_MS 60000u
+
+/* 遥测节拍:到点了吗(并推进)。到点但上一条遥测还没发出去时,胶水跳过这一拍
+ (记 `tele_skipped`),免得网络堵的时候往命令队列里越塞越多。 */
+typedef struct {
+ uint32_t last_ms;
+ bool started;
+} mlink_tele_t;
+bool mlink_tele_due(mlink_tele_t *t, uint32_t now_ms, uint32_t period_ms);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* MLINK_H */
diff --git a/firmware/main/mlink_esp.c b/firmware/main/mlink_esp.c
new file mode 100644
index 0000000..16d6a55
--- /dev/null
+++ b/firmware/main/mlink_esp.c
@@ -0,0 +1,371 @@
+/* WiFi / MQTT 链路的 ESP 胶水(A14 + C1)。**只进 IDF 目标**;topic 与规矩在 mlink.h,
+ 配置与「密码永不外泄」在 netcfg.h。
+
+ ---- 谁在哪个任务里跑 ------------------------------------------------------
+ | 事 | 任务 |
+ |---|---|
+ | WiFi / IP 事件(掉线、拿到地址) | 系统事件循环 |
+ | MQTT 事件(连上、收到命令) | esp-mqtt 自己的任务,优先级压到与 `t_link` 相同(§10.3:链路最低) |
+ | **MQTT 客户端的创建 / 销毁、重配置、遥测节拍** | `t_link`(`mlink_esp_poll()`)—— 生命周期只在一个任务里管 |
+ | 回应 / 遥测的发布 | 执行器任务(cmd_queue 的回调)—— 只用 `esp_mqtt_client_enqueue()`,**不阻塞** |
+
+ `esp_mqtt_client_publish()` 在网络慢的时候会阻塞到超时,放在执行器任务里就是让
+ 「按键该松了」等 TCP。enqueue 只进发件箱,由 esp-mqtt 自己的任务发。
+
+ ---- 失联不动作(§10.2 / §10.8) -----------------------------------------
+ WiFi 断、broker 断,本文件只做一件事:**重连**(WiFi 指数退避,MQTT 由 esp-mqtt 每 10 s 重试)。
+ 没有任何「连不上就……」的分支会碰到输出 —— 五台手机照常供电。 */
+#include
+
+#include "cmd_queue.h"
+#include "esp_event.h"
+#include "esp_log.h"
+#include "esp_mac.h"
+#include "esp_netif.h"
+#include "esp_timer.h"
+#include "esp_wifi.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/semphr.h"
+#include "hal_time.h"
+#include "mlink.h"
+#include "mlink_esp.h"
+#include "mqtt_client.h"
+#include "netcfg.h"
+#include "nvs.h"
+#include "nvs_flash.h"
+#include "phonerail.h"
+#include "tasks.h"
+#include "ulink.h"
+
+static const char *TAG = "mlink";
+
+#define NVS_NS "phonerail"
+#define NVS_KEY "net"
+#define NVS_VERSION 1 /* netcfg_t 布局一变就加一:旧版本的 blob 直接当没配置 */
+
+/* evse-ca(home 服务器上那个 mosquitto 的自签 CA,公钥证书,不是机密)。
+ `main/CMakeLists.txt` 的 EMBED_TXTFILES 编进来,结尾带 0。只在 mqtts:// 时用。 */
+extern const char evse_ca_pem_start[] asm("_binary_evse_ca_pem_start");
+
+#define MQTT_KEEPALIVE_S 15 /* 掉线后 ≈1.5 × 15 s broker 发遗嘱;默认 120 s 要等 3 分钟 */
+
+typedef struct {
+ uint8_t ver;
+ netcfg_t cfg;
+} nvs_blob_t;
+
+static SemaphoreHandle_t s_mtx; /* 护 s_st 与 s_mqtt 句柄 */
+static net_status_t s_st;
+static esp_mqtt_client_handle_t s_mqtt;
+static netcfg_t s_cur; /* 当前生效的配置(只在 t_link 里读写) */
+static volatile bool s_dirty; /* 配置变了,等 t_link 来重配 */
+static esp_timer_handle_t s_retry;
+static uint32_t s_attempt;
+static mlink_tele_t s_tele;
+static volatile bool s_tele_inflight;
+static bool s_up;
+
+/* ---- 状态:改完立刻推给 netcfg,命令处理器随时读得到一份完整的 ------------ */
+
+/* 可变参数:语句里带逗号(snprintf 的实参)也能整个传进来。 */
+#define ST_EDIT(...) \
+ do { \
+ xSemaphoreTake(s_mtx, portMAX_DELAY); \
+ __VA_ARGS__; \
+ net_status_set(&s_st); \
+ xSemaphoreGive(s_mtx); \
+ } while (0)
+
+/* ---- NVS ----------------------------------------------------------------- */
+
+static hal_err_t nvs_save(void *ctx, const netcfg_t *cfg)
+{
+ (void)ctx;
+ /* ⚠️ 在执行器任务里被调(配置命令),flash 写可能要几十毫秒。配置是上架时一次性的事,
+ 这点停顿可以接受;换成异步写就得处理「回了 ok 但没存下」。 */
+ nvs_handle_t h;
+ if (nvs_open(NVS_NS, NVS_READWRITE, &h) != ESP_OK) {
+ return HAL_ERR_BUS;
+ }
+ const nvs_blob_t b = { .ver = NVS_VERSION, .cfg = *cfg };
+ esp_err_t e = nvs_set_blob(h, NVS_KEY, &b, sizeof(b));
+ if (e == ESP_OK) {
+ e = nvs_commit(h);
+ }
+ nvs_close(h);
+ return e == ESP_OK ? HAL_OK : HAL_ERR_BUS;
+}
+
+static bool nvs_load(netcfg_t *out)
+{
+ nvs_handle_t h;
+ if (nvs_open(NVS_NS, NVS_READONLY, &h) != ESP_OK) {
+ return false;
+ }
+ nvs_blob_t b;
+ size_t n = sizeof(b);
+ const bool ok = nvs_get_blob(h, NVS_KEY, &b, &n) == ESP_OK && n == sizeof(b) &&
+ b.ver == NVS_VERSION;
+ nvs_close(h);
+ if (ok) {
+ *out = b.cfg;
+ }
+ return ok;
+}
+
+static void on_cfg_changed(void *ctx)
+{
+ (void)ctx;
+ s_dirty = true; /* 执行器任务里只置标志;断开重连在 t_link 里做(esp_mqtt_client_stop 会阻塞) */
+}
+
+/* ---- 发布(执行器任务里调,只进发件箱) ----------------------------------- */
+
+static void enqueue(mlink_topic_t t, const char *data, size_t len, int qos, int retain)
+{
+ if (xSemaphoreTake(s_mtx, pdMS_TO_TICKS(10)) != pdTRUE) {
+ return;
+ }
+ if (s_mqtt != NULL && s_st.mqtt == NET_UP) {
+ (void)esp_mqtt_client_enqueue(s_mqtt, mlink_topic(t), data, (int)len, qos, retain, true);
+ }
+ xSemaphoreGive(s_mtx);
+}
+
+static void reply_mqtt(void *ctx, const char *line, size_t len)
+{
+ (void)ctx;
+ enqueue(MLINK_T_RSP, line, len, 1, 0);
+}
+
+static void reply_tele(void *ctx, const char *line, size_t len)
+{
+ (void)ctx;
+ enqueue(MLINK_T_TELE, line, len, 0, 0);
+ ST_EDIT(s_st.tele_sent++);
+ s_tele_inflight = false;
+}
+
+static void link_error(const char *payload, const char *err)
+{
+ char out[96];
+ const size_t n = ulink_link_error(payload, err, out, sizeof(out));
+ if (n > 0) {
+ enqueue(MLINK_T_RSP, out, n, 1, 0);
+ }
+}
+
+/* ---- MQTT 事件(esp-mqtt 任务) ------------------------------------------- */
+
+static void on_mqtt(void *arg, esp_event_base_t base, int32_t id, void *data)
+{
+ (void)arg;
+ (void)base;
+ esp_mqtt_event_handle_t ev = data;
+ switch ((esp_mqtt_event_id_t)id) {
+ case MQTT_EVENT_CONNECTED: {
+ esp_mqtt_client_subscribe_single(ev->client, mlink_topic(MLINK_T_CMD), 1);
+ char buf[96];
+ const size_t n = mlink_state_online(phonerail_version(), s_st.ip, buf, sizeof(buf));
+ esp_mqtt_client_enqueue(ev->client, mlink_topic(MLINK_T_STATE), buf, (int)n, 1, 1, true);
+ ST_EDIT(s_st.mqtt = NET_UP);
+ memset(&s_tele, 0, sizeof(s_tele)); /* 一连上先报一条遥测 */
+ ESP_LOGI(TAG, "MQTT 已连上,订阅 %s", mlink_topic(MLINK_T_CMD));
+ break;
+ }
+ case MQTT_EVENT_DISCONNECTED:
+ ST_EDIT(if (s_st.mqtt == NET_UP) { s_st.mqtt_drops++; } s_st.mqtt = NET_CONNECTING);
+ break;
+ case MQTT_EVENT_DATA: {
+ const char *cmd_t = mlink_topic(MLINK_T_CMD);
+ if (ev->topic_len != (int)strlen(cmd_t) || memcmp(ev->topic, cmd_t, (size_t)ev->topic_len)) {
+ break; /* 切片的后续片没有 topic —— 首片已经按 too_long 回过了 */
+ }
+ char line[CMD_LINE_MAX];
+ const char *err = mlink_inbound(ev->data, ev->data_len, ev->total_data_len, line, sizeof(line));
+ if (err != NULL) {
+ link_error(NULL, err);
+ } else if (!cmd_queue_submit(CMD_LINK_WIFI, line, reply_mqtt, NULL)) {
+ link_error(line, "queue_full");
+ }
+ break;
+ }
+ default:
+ break;
+ }
+}
+
+static void mqtt_start(void)
+{
+ static char client_id[24];
+ snprintf(client_id, sizeof(client_id), "phonerail-%s", mlink_id());
+ const esp_mqtt_client_config_t mc = {
+ .broker.address.uri = s_cur.uri,
+ .broker.verification.certificate =
+ (strncmp(s_cur.uri, "mqtts://", 8) == 0) ? evse_ca_pem_start : NULL,
+ .credentials.client_id = client_id,
+ .credentials.username = s_cur.user[0] ? s_cur.user : NULL,
+ .credentials.authentication.password = s_cur.mpass[0] ? s_cur.mpass : NULL,
+ .session.last_will = { .topic = mlink_topic(MLINK_T_STATE), .msg = MLINK_STATE_OFFLINE,
+ .qos = 1, .retain = 1 },
+ .session.keepalive = MQTT_KEEPALIVE_S,
+ .task.priority = task_desc(TASK_LINK)->prio,
+ .task.stack_size = 6144,
+ };
+ esp_mqtt_client_handle_t c = esp_mqtt_client_init(&mc);
+ if (c == NULL) {
+ ESP_LOGE(TAG, "MQTT 客户端建不起来");
+ return;
+ }
+ esp_mqtt_client_register_event(c, ESP_EVENT_ANY_ID, on_mqtt, NULL);
+ ST_EDIT(s_mqtt = c; s_st.mqtt = NET_CONNECTING);
+ esp_mqtt_client_start(c);
+}
+
+static void mqtt_stop(void)
+{
+ esp_mqtt_client_handle_t c;
+ ST_EDIT(c = s_mqtt; s_mqtt = NULL; s_st.mqtt = NET_OFF);
+ if (c != NULL) {
+ esp_mqtt_client_stop(c); /* 会阻塞到它的任务退出 —— 所以只在 t_link 里调 */
+ esp_mqtt_client_destroy(c);
+ }
+}
+
+/* ---- WiFi 事件(系统事件循环) ------------------------------------------- */
+
+static void retry_cb(void *arg)
+{
+ (void)arg;
+ if (s_st.wifi != NET_UP && s_cur.ssid[0] != '\0') {
+ esp_wifi_connect();
+ }
+}
+
+static void on_wifi(void *arg, esp_event_base_t base, int32_t id, void *data)
+{
+ (void)arg;
+ if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
+ const wifi_event_sta_disconnected_t *d = data;
+ ESP_LOGW(TAG, "WiFi 断开,原因 %u,%u ms 后重试", (unsigned)d->reason,
+ (unsigned)mlink_backoff_ms(s_attempt));
+ ST_EDIT(s_st.wifi_reason = d->reason;if (s_st.wifi == NET_UP) { s_st.wifi_drops++; } s_st.wifi =
+ s_cur.ssid[0] ? NET_CONNECTING : NET_OFF;
+ s_st.ip[0] = '\0'; s_st.rssi = 0);
+ if (s_cur.ssid[0] != '\0') {
+ const uint32_t ms = mlink_backoff_ms(s_attempt++);
+ esp_timer_stop(s_retry);
+ esp_timer_start_once(s_retry, (uint64_t)ms * 1000u);
+ }
+ } else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
+ const ip_event_got_ip_t *e = data;
+ s_attempt = 0;
+ ST_EDIT(s_st.wifi = NET_UP;
+ snprintf(s_st.ip, sizeof(s_st.ip), IPSTR, IP2STR(&e->ip_info.ip)));
+ ESP_LOGI(TAG, "WiFi 已连上,IP %s", s_st.ip);
+ }
+}
+
+/* 按 s_cur 重新连 WiFi(t_link 里调)。 */
+static void wifi_apply(void)
+{
+ esp_timer_stop(s_retry);
+ esp_wifi_disconnect();
+ s_attempt = 0;
+ if (s_cur.ssid[0] == '\0') {
+ ST_EDIT(s_st.wifi = NET_OFF);
+ return;
+ }
+ wifi_config_t wc = { 0 };
+ memcpy(wc.sta.ssid, s_cur.ssid, strlen(s_cur.ssid));
+ memcpy(wc.sta.password, s_cur.pass, strlen(s_cur.pass));
+ wc.sta.threshold.authmode = s_cur.pass[0] ? WIFI_AUTH_WPA2_PSK : WIFI_AUTH_OPEN;
+ esp_wifi_set_config(WIFI_IF_STA, &wc);
+ ST_EDIT(s_st.wifi = NET_CONNECTING);
+ esp_wifi_connect();
+ ESP_LOGI(TAG, "连 WiFi「%s」", s_cur.ssid); /* 只打 SSID,不打口令 */
+}
+
+/* ---- 对外 ---------------------------------------------------------------- */
+
+hal_err_t mlink_esp_init(void)
+{
+ s_mtx = xSemaphoreCreateMutex();
+ esp_err_t e = nvs_flash_init();
+ if (e == ESP_ERR_NVS_NO_FREE_PAGES || e == ESP_ERR_NVS_NEW_VERSION_FOUND) {
+ nvs_flash_erase(); /* NVS 分区格式不认:清掉重来(里面只有本模块的配置与 WiFi 校准) */
+ e = nvs_flash_init();
+ }
+ if (s_mtx == NULL || e != ESP_OK) {
+ ESP_LOGE(TAG, "NVS 起不来:%s —— WiFi 链路不可用", esp_err_to_name(e));
+ netcfg_init(NULL, NULL, NULL, NULL);
+ return HAL_ERR_STATE;
+ }
+ netcfg_t loaded;
+ const bool have = nvs_load(&loaded);
+ netcfg_init(have ? &loaded : NULL, nvs_save, on_cfg_changed, NULL);
+ netcfg_get(&s_cur);
+
+ uint8_t mac[6];
+ esp_read_mac(mac, ESP_MAC_WIFI_STA);
+ mlink_init(mac);
+ ST_EDIT(snprintf(s_st.id, sizeof(s_st.id), "%s", mlink_id()));
+
+ const esp_timer_create_args_t ta = { .callback = retry_cb, .name = "wifi_retry" };
+ if (esp_netif_init() != ESP_OK || esp_event_loop_create_default() != ESP_OK ||
+ esp_netif_create_default_wifi_sta() == NULL || esp_timer_create(&ta, &s_retry) != ESP_OK) {
+ ESP_LOGE(TAG, "网络栈起不来 —— WiFi 链路不可用");
+ return HAL_ERR_STATE;
+ }
+ const wifi_init_config_t wi = WIFI_INIT_CONFIG_DEFAULT();
+ if (esp_wifi_init(&wi) != ESP_OK) {
+ ESP_LOGE(TAG, "WiFi 驱动起不来 —— WiFi 链路不可用");
+ return HAL_ERR_STATE;
+ }
+ esp_event_handler_register(WIFI_EVENT, WIFI_EVENT_STA_DISCONNECTED, on_wifi, NULL);
+ esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, on_wifi, NULL);
+ esp_wifi_set_storage(WIFI_STORAGE_RAM); /* 凭据只存我们自己的那份,驱动别再存一份 */
+ esp_wifi_set_mode(WIFI_MODE_STA);
+ esp_wifi_start();
+ s_up = true;
+ ESP_LOGI(TAG, "板子 ID %s,topic %s/…", mlink_id(), mlink_topic(MLINK_T_CMD));
+ if (!have) {
+ ESP_LOGW(TAG, "还没配网:经 USB 发 wifi / mqtt 两条命令(tools/prlink.py)");
+ }
+ wifi_apply();
+ return HAL_OK;
+}
+
+void mlink_esp_poll(void)
+{
+ if (!s_up) {
+ return;
+ }
+ if (s_dirty) {
+ s_dirty = false;
+ mqtt_stop();
+ netcfg_get(&s_cur);
+ wifi_apply();
+ }
+ /* MQTT 的生命周期只在这里管:WiFi 有地址、配了 broker、还没建客户端 ⇒ 建。
+ 之后掉线重连由 esp-mqtt 自己做(network.reconnect_timeout_ms,默认 10 s)。 */
+ if (s_mqtt == NULL && s_st.wifi == NET_UP && s_cur.uri[0] != '\0') {
+ mqtt_start();
+ }
+ if (s_st.mqtt != NET_UP || !mlink_tele_due(&s_tele, hal_time_now_ms(), s_cur.tele_ms)) {
+ return;
+ }
+ wifi_ap_record_t ap;
+ if (esp_wifi_sta_get_ap_info(&ap) == ESP_OK) {
+ ST_EDIT(s_st.rssi = ap.rssi);
+ }
+ if (s_tele_inflight) {
+ ST_EDIT(s_st.tele_skipped++); /* 上一条还在队列里:网络或执行器堵了,不再往里塞 */
+ return;
+ }
+ s_tele_inflight = true;
+ if (!cmd_queue_submit(CMD_LINK_WIFI, "{\"cmd\":\"status\"}", reply_tele, NULL)) {
+ s_tele_inflight = false;
+ ST_EDIT(s_st.tele_skipped++);
+ }
+}
diff --git a/firmware/main/mlink_esp.h b/firmware/main/mlink_esp.h
new file mode 100644
index 0000000..ceb02b9
--- /dev/null
+++ b/firmware/main/mlink_esp.h
@@ -0,0 +1,15 @@
+/* WiFi / MQTT 链路的 ESP 胶水(A14 + C1)。只在 IDF 目标里有;说明在 mlink_esp.c 文件头。 */
+#ifndef MLINK_ESP_H
+#define MLINK_ESP_H
+
+#include "hal_err.h"
+
+/* NVS → 配置 → WiFi 驱动 → 按配置连网。要在命令队列建好之后、任务起来之前调。
+ 失败不 abort:WiFi 链路不可用,USB 链路与五路供电照常(§10.2)。
+ 没配过网时只起驱动、不连,等 USB 下发 `wifi` / `mqtt`。 */
+hal_err_t mlink_esp_init(void);
+
+/* 链路任务每圈调一次(≤100 ms 一次):应用配置变更、按需建 MQTT 客户端、推遥测。 */
+void mlink_esp_poll(void);
+
+#endif /* MLINK_ESP_H */
diff --git a/firmware/main/netcfg.c b/firmware/main/netcfg.c
new file mode 100644
index 0000000..401634d
--- /dev/null
+++ b/firmware/main/netcfg.c
@@ -0,0 +1,146 @@
+/* 网络配置与状态。契约(尤其「密码永不外泄」与跨任务那两段)在 netcfg.h。 */
+#include "netcfg.h"
+
+#include
+
+#include "hal_lock.h"
+
+static netcfg_t s_cfg;
+static netcfg_save_fn s_save;
+static netcfg_changed_fn s_changed;
+static void *s_ctx;
+static net_status_t s_st;
+
+void netcfg_init(const netcfg_t *loaded, netcfg_save_fn save, netcfg_changed_fn changed, void *ctx)
+{
+ hal_crit_enter();
+ memset(&s_cfg, 0, sizeof(s_cfg));
+ if (loaded != NULL) {
+ s_cfg = *loaded;
+ }
+ /* 从 flash 读回来的东西不可信:强制收尾,免得一个没带 0 的字段把后面的读穿。 */
+ s_cfg.ssid[NETCFG_SSID_MAX] = '\0';
+ s_cfg.pass[NETCFG_PASS_MAX] = '\0';
+ s_cfg.uri[NETCFG_URI_MAX] = '\0';
+ s_cfg.user[NETCFG_USER_MAX] = '\0';
+ s_cfg.mpass[NETCFG_MPASS_MAX] = '\0';
+ if (s_cfg.tele_ms < NETCFG_TELE_MIN_MS || s_cfg.tele_ms > NETCFG_TELE_MAX_MS) {
+ s_cfg.tele_ms = NETCFG_TELE_DEFAULT_MS;
+ }
+ memset(&s_st, 0, sizeof(s_st));
+ hal_crit_exit();
+ s_save = save;
+ s_changed = changed;
+ s_ctx = ctx;
+}
+
+void netcfg_get(netcfg_t *out)
+{
+ hal_crit_enter();
+ *out = s_cfg;
+ hal_crit_exit();
+}
+
+static bool len_in(const char *s, size_t lo, size_t hi)
+{
+ if (s == NULL) {
+ return false;
+ }
+ const size_t n = strlen(s);
+ return n >= lo && n <= hi;
+}
+
+bool netcfg_wifi_ok(const char *ssid, const char *pass)
+{
+ /* 口令:空(开放网络)或 8~63 —— WPA2 的规定。7 位的口令驱动会静默连不上,
+ 现象是「一直在连」,在这里拒掉比在那里猜强。 */
+ return len_in(ssid, 1, NETCFG_SSID_MAX) &&
+ (len_in(pass, 0, 0) || len_in(pass, 8, NETCFG_PASS_MAX));
+}
+
+bool netcfg_mqtt_ok(const char *uri, const char *user, const char *mpass, uint32_t tele_ms)
+{
+ if (!len_in(uri, 8, NETCFG_URI_MAX) || !len_in(user, 0, NETCFG_USER_MAX) ||
+ !len_in(mpass, 0, NETCFG_MPASS_MAX)) {
+ return false;
+ }
+ /* mqtts://(TLS,CA 编在固件里,mlink_esp.c)或明文 mqtt://(只该用在局域网里)。
+ 板子与服务器不在同一个局域网时只能走公网,那就必须是 mqtts。 */
+ const size_t head = (strncmp(uri, "mqtts://", 8) == 0) ? 8 : (strncmp(uri, "mqtt://", 7) == 0) ? 7 : 0;
+ if (head == 0 || uri[head] == '\0') {
+ return false;
+ }
+ return tele_ms == 0 || (tele_ms >= NETCFG_TELE_MIN_MS && tele_ms <= NETCFG_TELE_MAX_MS);
+}
+
+static hal_err_t commit(const netcfg_t *next)
+{
+ if (s_save != NULL) {
+ const hal_err_t e = s_save(s_ctx, next);
+ if (e != HAL_OK) {
+ return e; /* 没存下就不改内存:重启之后与现在一致,比「现在生效、重启回滚」好查 */
+ }
+ }
+ hal_crit_enter();
+ s_cfg = *next;
+ hal_crit_exit();
+ if (s_changed != NULL) {
+ s_changed(s_ctx);
+ }
+ return HAL_OK;
+}
+
+hal_err_t netcfg_set_wifi(const char *ssid, const char *pass)
+{
+ if (!netcfg_wifi_ok(ssid, pass)) {
+ return HAL_ERR_ARG;
+ }
+ netcfg_t next;
+ netcfg_get(&next);
+ memset(next.ssid, 0, sizeof(next.ssid));
+ memset(next.pass, 0, sizeof(next.pass));
+ strcpy(next.ssid, ssid);
+ strcpy(next.pass, pass);
+ return commit(&next);
+}
+
+hal_err_t netcfg_set_mqtt(const char *uri, const char *user, const char *mpass, uint32_t tele_ms)
+{
+ if (!netcfg_mqtt_ok(uri, user, mpass, tele_ms)) {
+ return HAL_ERR_ARG;
+ }
+ netcfg_t next;
+ netcfg_get(&next);
+ memset(next.uri, 0, sizeof(next.uri));
+ memset(next.user, 0, sizeof(next.user));
+ memset(next.mpass, 0, sizeof(next.mpass));
+ strcpy(next.uri, uri);
+ strcpy(next.user, user);
+ strcpy(next.mpass, mpass);
+ next.tele_ms = tele_ms ? tele_ms : NETCFG_TELE_DEFAULT_MS;
+ return commit(&next);
+}
+
+void net_status_set(const net_status_t *st)
+{
+ hal_crit_enter();
+ s_st = *st;
+ hal_crit_exit();
+}
+
+void net_status_get(net_status_t *st)
+{
+ hal_crit_enter();
+ *st = s_st;
+ hal_crit_exit();
+}
+
+const char *net_state_str(net_state_t s)
+{
+ switch (s) {
+ case NET_OFF: return "off";
+ case NET_CONNECTING: return "connecting";
+ case NET_UP: return "up";
+ }
+ return "?";
+}
diff --git a/firmware/main/netcfg.h b/firmware/main/netcfg.h
new file mode 100644
index 0000000..d22230e
--- /dev/null
+++ b/firmware/main/netcfg.h
@@ -0,0 +1,102 @@
+/* 网络配置与网络状态(A14 + C1,需求 IF-19 / §10.8)。
+
+ IF-19 原话:「凭据与服务端地址存 NVS,**不编译进固件** —— 新板上架时先用 IF-13 的
+ USB 链路配网」。所以:
+ · 配置由两条命令写入:`wifi`(SSID / 密码)与 `mqtt`(broker 地址 / 账号 / 遥测周期),
+ **只收 USB 链路来的**(cmd.c 回 `link`)—— 经 WiFi 改 WiFi 配置,一次手误就把
+ 唯一的远程通道弄断了,而那时只能拆机插 USB;
+ · 本模块只管「校验 + 在内存里持有一份 + 调存储后端 + 通知网络那一侧」,
+ NVS 与 WiFi/MQTT 都在 ESP 胶水里(mlink_esp.c),这一半宿主机可测。
+
+ ---- 密码永不外泄 --------------------------------------------------------
+ `net` 命令、日志、status 里**一律不出现两个密码**,只报 `*_set: true/false`。
+ 回显密码的接口一旦存在,USB 那头随便接一台电脑就能把 WiFi 密码拿走。
+
+ ---- 跨任务 --------------------------------------------------------------
+ 写者是执行器任务(命令处理器),读者是链路任务(网络胶水)。配置读写都在
+ `hal_crit` 里整块拷贝(≈260 B 的 memcpy,关中断几微秒),读者永远拿到一份完整的。
+ 网络状态反过来:链路任务写、命令处理器读,同样整块拷贝。 */
+#ifndef NETCFG_H
+#define NETCFG_H
+
+#include
+#include
+
+#include "hal_err.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define NETCFG_SSID_MAX 32 /* 802.11 上限 */
+#define NETCFG_PASS_MAX 63 /* WPA2-PSK 口令上限(64 位十六进制 PSK 不支持) */
+#define NETCFG_URI_MAX 64
+#define NETCFG_USER_MAX 32
+#define NETCFG_MPASS_MAX 64
+
+/* 遥测周期:上位机的卡死判据要看电流曲线(需求 §4.1),1 s 一个点够用。
+ 下限 200 ms:再密就是在拿执行器任务拼 JSON(一条 ≈1 KB)。 */
+#define NETCFG_TELE_DEFAULT_MS 1000
+#define NETCFG_TELE_MIN_MS 200
+#define NETCFG_TELE_MAX_MS 60000
+
+/* ⚠️ 这个结构体**原样存进 NVS**(mlink_esp.c 带版本号存)。改字段就升版本号。 */
+typedef struct {
+ char ssid[NETCFG_SSID_MAX + 1];
+ char pass[NETCFG_PASS_MAX + 1];
+ char uri[NETCFG_URI_MAX + 1];
+ char user[NETCFG_USER_MAX + 1];
+ char mpass[NETCFG_MPASS_MAX + 1];
+ uint32_t tele_ms;
+} netcfg_t;
+
+/* 存储后端:把整份配置持久化。返回非 HAL_OK = 没存下(内存里的也不改)。 */
+typedef hal_err_t (*netcfg_save_fn)(void *ctx, const netcfg_t *cfg);
+/* 配置变了。**在调用 set 的那个任务里**同步回调 —— 胶水应当只置个标志,
+ 真正的断开重连在它自己的任务里做(`esp_mqtt_client_stop()` 会阻塞)。 */
+typedef void (*netcfg_changed_fn)(void *ctx);
+
+/* `loaded` 为 NULL = 出厂空配置(不连网)。save / changed 可为 NULL。 */
+void netcfg_init(const netcfg_t *loaded, netcfg_save_fn save, netcfg_changed_fn changed, void *ctx);
+void netcfg_get(netcfg_t *out);
+
+/* 校验 → 存 → 改内存 → 通知。校验不过 HAL_ERR_ARG,存不下是 save 的错误码。
+ `mqtt` 的 tele_ms 传 0 = 用默认值。 */
+hal_err_t netcfg_set_wifi(const char *ssid, const char *pass);
+hal_err_t netcfg_set_mqtt(const char *uri, const char *user, const char *mpass, uint32_t tele_ms);
+
+/* 纯校验,给命令处理器先判一遍好回准确的错误。 */
+bool netcfg_wifi_ok(const char *ssid, const char *pass);
+bool netcfg_mqtt_ok(const char *uri, const char *user, const char *mpass, uint32_t tele_ms);
+
+/* ---- 网络状态(胶水写、命令处理器读) ------------------------------------ */
+
+typedef enum {
+ NET_OFF = 0, /* 没配置 */
+ NET_CONNECTING, /* 在连 / 在退避等待 */
+ NET_UP,
+} net_state_t;
+
+typedef struct {
+ net_state_t wifi;
+ net_state_t mqtt;
+ char ip[16];
+ int8_t rssi; /* dBm,WiFi 没连上时 0 */
+ uint32_t wifi_drops; /* 掉线次数(不含首次连) */
+ uint32_t mqtt_drops;
+ uint32_t tele_sent;
+ uint32_t tele_skipped; /* 该发遥测时命令队列满 / 上一条还没发完 */
+ uint8_t wifi_reason; /* 最近一次断开的原因码(wifi_err_reason_t;201 = 找不到 AP,
+ 202 = 认证失败,15 = 四次握手超时 ≈ 口令错),0 = 没断过 */
+ char id[13]; /* 板子 ID = STA MAC 的 12 位小写十六进制,topic 用它 */
+} net_status_t;
+
+void net_status_set(const net_status_t *st);
+void net_status_get(net_status_t *st);
+const char *net_state_str(net_state_t s);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* NETCFG_H */
diff --git a/firmware/main/phonerail.h b/firmware/main/phonerail.h
index 4e6ea10..18f1c85 100644
--- a/firmware/main/phonerail.h
+++ b/firmware/main/phonerail.h
@@ -3,7 +3,7 @@
#define PHONERAIL_H
/* 固件版本。规则:<主>.<次>.<修订>-<最后完成的任务包> */
-#define PHONERAIL_FW_VERSION "0.1.0-a11"
+#define PHONERAIL_FW_VERSION "0.1.0-a14"
/* 通道数。五路直供,见设计文档 §2。 */
#define PHONERAIL_RAIL_COUNT 5
diff --git a/firmware/main/rail.h b/firmware/main/rail.h
index 7ec1011..2eb9e60 100644
--- a/firmware/main/rail.h
+++ b/firmware/main/rail.h
@@ -64,7 +64,9 @@ hal_err_t rail_volt_init(void);
mv > 4010 → HAL_ERR_ARG(SR-13:越界即拒,让操作者早点知道自己写错了)。
mv < 3524 → HAL_ERR_ARG(DAC 到不了)。
⚠️ **不要用它做保护** —— 异常处理是 EFUSE_EN 的活(需求 §10.5 规矩 2)。
- TODO(A11):被拒的请求要记一条事件;事件环形缓冲还没有,先只返回错误码。 */
+ ⚠️ **本函数不拿 I²C 锁**(它比 `i2cbus` 早)。运行期调用必须包在
+ `i2cbus_xact(I2CBUS_CH_TRUNK, ...)` 里,否则就是在巡检的事务中间插一笔(A12 的 cmd_exec.c)。
+ 被拒的请求由调用方记事件(A12:`LOG_EV_CMD_REJECT`),本函数只返回错误码。 */
hal_err_t rail_set_mv(int rail, int mv);
/* 回读该路 DAC 里的码并换算成 mV。读的是**设定值**,不是实测输出 ——
diff --git a/firmware/main/t_thermal.c b/firmware/main/t_thermal.c
index eb8b2e5..d41abf8 100644
--- a/firmware/main/t_thermal.c
+++ b/firmware/main/t_thermal.c
@@ -22,6 +22,10 @@ static therm_tach_fn s_tach_fn;
static void *s_ctx;
static bool s_inited;
+/* 上位机 `fan` 命令给的下限(A12)。**跨任务**:命令处理器在执行器任务里写,
+ 本模块在热管理任务里读。一个对齐的 16 位量,Xtensa 上读写是原子的,不加锁。 */
+static volatile uint16_t s_floor;
+
/* 风扇侧占空比 → 真正写进 `hal_pwm` 的值。Q6 是低边管,栅极高 = 把风扇的
PWM 线拉低 ⇒ 反相(`t_thermal.h` 硬件那三条的第 2 条)。 */
static uint16_t gpio_duty_of(uint16_t fan_duty)
@@ -211,7 +215,12 @@ hal_err_t t_thermal_step(thermal_snap_t *s)
if (!s->temp_valid) {
s->temp_errors++;
}
- (void)apply_duty(s, t_thermal_duty_for(s->temp_max_mc, s->temp_valid));
+ uint16_t duty = t_thermal_duty_for(s->temp_max_mc, s->temp_valid);
+ const uint16_t floor_ = s_floor;
+ if (floor_ > duty) {
+ duty = floor_; /* 手动值只抬不压:曲线要得更高时照曲线来 */
+ }
+ (void)apply_duty(s, duty);
/* ---- 过温降载:本模块只**报**,关路是限功率那一包的事 ---- */
if (s->temp_valid) {
@@ -231,3 +240,10 @@ hal_err_t t_thermal_step(thermal_snap_t *s)
}
return HAL_OK;
}
+
+void t_thermal_set_floor(uint16_t permille)
+{
+ s_floor = (permille > 1000u) ? 1000u : permille;
+}
+
+uint16_t t_thermal_floor(void) { return s_floor; }
diff --git a/firmware/main/t_thermal.h b/firmware/main/t_thermal.h
index 86a4b9f..aec38c0 100644
--- a/firmware/main/t_thermal.h
+++ b/firmware/main/t_thermal.h
@@ -236,6 +236,13 @@ hal_err_t t_thermal_step(thermal_snap_t *s);
`valid == false` ⇒ 满速。单调**不降**是这条曲线唯一不能破的性质。 */
uint16_t t_thermal_duty_for(int32_t milli_c, bool valid);
+/* 上位机手动给的占空比**下限**(风扇侧千分比,A12 的 `fan` 命令)。0 = 全自动。
+ ⚠️ 只抬不压:实际占空比 = max(温度曲线, 下限)。「手动把风扇调小」这件事
+ 不提供 —— 那等于允许远程关掉散热,而温度读不到时满速(§10.10)的兜底也会被它盖掉。
+ 下一圈 `t_thermal_step()` 生效;不随 `t_thermal_init()` 清零(重启自然清零)。 */
+void t_thermal_set_floor(uint16_t permille);
+uint16_t t_thermal_floor(void);
+
#ifdef __cplusplus
}
#endif
diff --git a/firmware/main/ulink.c b/firmware/main/ulink.c
new file mode 100644
index 0000000..9026866
--- /dev/null
+++ b/firmware/main/ulink.c
@@ -0,0 +1,243 @@
+/* USB 链路分帧。格式与两条「为什么」在 ulink.h。 */
+#include "ulink.h"
+
+#include
+#include
+
+#include "cmd_json.h"
+
+uint16_t ulink_crc16(const char *data, size_t len)
+{
+ uint16_t crc = 0xFFFF;
+ for (size_t i = 0; i < len; i++) {
+ crc ^= (uint16_t)((uint8_t)data[i] << 8);
+ for (int b = 0; b < 8; b++) {
+ crc = (crc & 0x8000u) ? (uint16_t)((crc << 1) ^ 0x1021u) : (uint16_t)(crc << 1);
+ }
+ }
+ return crc;
+}
+
+static int hexval(char c)
+{
+ if (c >= '0' && c <= '9') {
+ return c - '0';
+ }
+ if (c >= 'A' && c <= 'F') {
+ return c - 'A' + 10;
+ }
+ if (c >= 'a' && c <= 'f') {
+ return c - 'a' + 10;
+ }
+ return -1;
+}
+
+/* s[0..len) 末尾是不是 "*XXXX"。是则返回 1 并给出 CRC 值。 */
+static int crc_suffix(const char *s, size_t len, uint16_t *crc)
+{
+ if (len < ULINK_CRC_LEN || s[len - ULINK_CRC_LEN] != '*') {
+ return 0;
+ }
+ uint16_t v = 0;
+ for (size_t i = len - 4; i < len; i++) {
+ const int h = hexval(s[i]);
+ if (h < 0) {
+ return 0;
+ }
+ v = (uint16_t)((v << 4) | (uint16_t)h);
+ }
+ *crc = v;
+ return 1;
+}
+
+/* ---- 接收 ---------------------------------------------------------------- */
+
+void ulink_rx_init(ulink_rx_t *rx) { memset(rx, 0, sizeof(*rx)); }
+
+static bool is_cmd_line(const ulink_rx_t *rx)
+{
+ return rx->n >= ULINK_PREFIX_LEN && memcmp(rx->buf, ULINK_CMD_PREFIX, ULINK_PREFIX_LEN) == 0;
+}
+
+static void end_of_line(ulink_rx_t *rx, ulink_rx_fn fn, void *ctx)
+{
+ if (rx->n > 0 && rx->buf[rx->n - 1] == '\r') {
+ rx->n--;
+ }
+ rx->buf[rx->n] = '\0';
+
+ if (!is_cmd_line(rx)) {
+ rx->ignored++;
+ } else if (rx->overflow) {
+ rx->too_long++;
+ fn(ctx, ULINK_RX_TOO_LONG, "");
+ } else {
+ char *json = rx->buf + ULINK_PREFIX_LEN;
+ size_t len = rx->n - ULINK_PREFIX_LEN;
+ uint16_t want = 0;
+ const int has_crc = crc_suffix(json, len, &want);
+ if (has_crc) {
+ len -= ULINK_CRC_LEN;
+ json[len] = '\0';
+ }
+ if (rx->bad) {
+ rx->bad_bytes++;
+ fn(ctx, ULINK_RX_BAD_BYTES, json);
+ } else if (has_crc && ulink_crc16(json, len) != want) {
+ rx->bad_crc++;
+ fn(ctx, ULINK_RX_BAD_CRC, json);
+ } else {
+ rx->cmds++;
+ fn(ctx, ULINK_RX_CMD, json);
+ }
+ }
+ rx->n = 0;
+ rx->overflow = false;
+ rx->bad = false;
+}
+
+void ulink_rx_feed(ulink_rx_t *rx, const uint8_t *data, size_t len, ulink_rx_fn fn, void *ctx)
+{
+ for (size_t i = 0; i < len; i++) {
+ const uint8_t c = data[i];
+ if (c == '\n') {
+ end_of_line(rx, fn, ctx);
+ continue;
+ }
+ /* NUL 与控制字符:记下来,行尾整行拒。⚠️ 不能只是跳过 —— NUL 会让后面的
+ JSON 解析在 NUL 处提前结束,`{"cmd":"status"}\0垃圾` 就被当成一条合法命令了。 */
+ if ((c < 0x20 && c != '\r' && c != '\t') || c == 0x7F) {
+ rx->bad = true;
+ }
+ if (rx->n >= ULINK_RX_LINE_MAX) {
+ rx->overflow = true; /* 前缀已经在 buf 里了,行尾还认得出它是不是命令 */
+ continue;
+ }
+ rx->buf[rx->n++] = (char)c;
+ }
+}
+
+/* ---- 发送 ---------------------------------------------------------------- */
+
+void ulink_tx_init(ulink_tx_t *tx, ulink_sink_fn sink, void *ctx)
+{
+ memset(tx, 0, sizeof(*tx));
+ tx->sink = sink;
+ tx->ctx = ctx;
+ tx->at_bol = true;
+}
+
+/* 写一段,按**真正写出去的**最后一个字节更新 at_bol。 */
+static bool put(ulink_tx_t *tx, const char *d, size_t n)
+{
+ if (n == 0) {
+ return true;
+ }
+ const size_t w = tx->sink(tx->ctx, d, n);
+ if (w > 0) {
+ tx->at_bol = (d[w - 1] == '\n');
+ }
+ if (w < n) {
+ tx->short_writes++;
+ return false;
+ }
+ return true;
+}
+
+void ulink_tx_log(ulink_tx_t *tx, const char *data, size_t len)
+{
+ size_t start = 0;
+ for (size_t i = 0; i <= len; i++) {
+ const bool eol = (i < len && data[i] == '\n');
+ if (i < len && !eol) {
+ continue;
+ }
+ const size_t seg = i - start + (eol ? 1u : 0u); /* 带上这个 \n */
+ if (seg > 0) {
+ if (tx->at_bol && data[start] == '@') {
+ if (!put(tx, " ", 1)) {
+ return;
+ }
+ }
+ if (!put(tx, data + start, seg)) {
+ return; /* 写不下就丢掉剩下的日志:日志 UART 上还有一份 */
+ }
+ }
+ start = i + 1;
+ }
+}
+
+bool ulink_tx_frame(ulink_tx_t *tx, const char *json, size_t len)
+{
+ if (!tx->at_bol && !put(tx, "\n", 1)) {
+ return false;
+ }
+ char tail[ULINK_CRC_LEN + 2];
+ snprintf(tail, sizeof(tail), "*%04X\n", (unsigned)ulink_crc16(json, len));
+ /* 三段分开写:前缀、JSON、CRC。中间被别的写者插进来的话 CRC 会对不上,
+ 上位机丢弃这一行 —— 这正是必带 CRC 的理由(ulink.h)。 */
+ const bool ok = put(tx, ULINK_RSP_PREFIX, ULINK_PREFIX_LEN) && put(tx, json, len) &&
+ put(tx, tail, ULINK_CRC_LEN + 1);
+ if (ok) {
+ tx->frames++;
+ }
+ return ok;
+}
+
+/* ---- 链路层错误 ---------------------------------------------------------- */
+
+const char *ulink_rx_err_str(ulink_rx_kind_t kind)
+{
+ switch (kind) {
+ case ULINK_RX_CMD: return NULL;
+ case ULINK_RX_TOO_LONG: return "too_long";
+ case ULINK_RX_BAD_CRC: return "bad_crc";
+ case ULINK_RX_BAD_BYTES: return "bad_frame";
+ }
+ return "bad_frame";
+}
+
+size_t ulink_link_error(const char *payload, const char *err, char *out, size_t cap)
+{
+ /* `cj_parse()` 是纯函数(不碰任何静态量),在链路任务里调是安全的 ——
+ 与只许在执行器任务里调的 `cmd_handle_line()` 不同。 */
+ cj_obj_t o;
+ int64_t seq = 0;
+ const bool has_seq = payload != NULL && cj_parse(payload, &o) &&
+ cj_get_int(&o, "seq", &seq) == 1 && seq >= 1 && seq <= (int64_t)UINT32_MAX;
+ cj_writer_t w;
+ cj_w_init(&w, out, cap);
+ if (has_seq) {
+ cj_w(&w, "{\"seq\":%lu,\"ok\":false,\"err\":\"%s\"}", (unsigned long)seq, err);
+ } else {
+ cj_w(&w, "{\"ok\":false,\"err\":\"%s\"}", err);
+ }
+ return w.overflow ? 0 : w.pos;
+}
+
+/* ---- 参考解帧器 ---------------------------------------------------------- */
+
+int ulink_deframe(const char *line, char *out, size_t cap)
+{
+ size_t len = strlen(line);
+ if (len > 0 && line[len - 1] == '\r') {
+ len--;
+ }
+ if (len < ULINK_PREFIX_LEN + ULINK_CRC_LEN ||
+ memcmp(line, ULINK_RSP_PREFIX, ULINK_PREFIX_LEN) != 0) {
+ return -1;
+ }
+ const char *json = line + ULINK_PREFIX_LEN;
+ size_t jlen = len - ULINK_PREFIX_LEN;
+ uint16_t want = 0;
+ if (!crc_suffix(json, jlen, &want)) {
+ return -1; /* 回应必带 CRC */
+ }
+ jlen -= ULINK_CRC_LEN;
+ if (ulink_crc16(json, jlen) != want || jlen + 1 > cap) {
+ return -1;
+ }
+ memcpy(out, json, jlen);
+ out[jlen] = '\0';
+ return (int)jlen;
+}
diff --git a/firmware/main/ulink.h b/firmware/main/ulink.h
new file mode 100644
index 0000000..d9ae4b7
--- /dev/null
+++ b/firmware/main/ulink.h
@@ -0,0 +1,123 @@
+/* USB-Serial-JTAG 链路的分帧(A13,需求 §10.8 规矩 4 / IF-13 / 计划 D13)。
+
+ ESP32-S3 的原生 USB-Serial-JTAG 是**一根线三用**:esptool 烧录、运行日志、命令链路。
+ 日志与命令回应在同一条串流上,所以必须分帧 —— 否则日志一刷就把回应冲散。
+ 本文件是与平台无关的那一半(宿主机可测),ESP 胶水在 `ulink_esp.c`。
+
+ ---- 线上格式 ------------------------------------------------------------
+
+ 上位机 → 板 @C {"seq":5,"cmd":"status"}*1A2B\n (*CRC 可省,给人手敲用)
+ 板 → 上位机 @R {"seq":5,"ok":true,...}*3C4D\n (*CRC 必带)
+ 其余任何一行 = 日志,上位机原样丢给日志窗口
+
+ · 前缀 `@C ` / `@R `(含一个空格)只在**行首**才算数。
+ · CRC 是 CRC-16/CCITT-FALSE(多项式 0x1021,初值 0xFFFF),算的是 JSON 那一段
+ (前缀与 `*` 都不算),4 位大写十六进制。「123456789」的校验值是 0x29B1。
+ · 行尾 `\n`,前面带一个 `\r` 也认(Windows 终端会发)。
+
+ ---- 为什么回应必须带 CRC ------------------------------------------------
+ 日志与回应都经 `ulink_tx_*` 串行写出(ESP 侧拿一把互斥锁),**正常情况下不会交错**。
+ 但总有绕开这把锁的写者:panic 处理、ROM 打印、别的组件直接往 USB 寄存器写。
+ 它们插进一条回应中间时,剩下的仍可能是一段「看起来合法」的 JSON ——
+ 比如 `"ok":false` 被截掉后半句。CRC 让这种回应在上位机那一侧**一定**被丢弃
+ (然后按超时重传同一个 seq,由判重兜住)。
+
+ ---- 为什么日志永远不可能被当成帧 ----------------------------------------
+ `ulink_tx_log()` 发现一行日志以 `@` 开头时,在前面补一个空格。
+ 日志内容是任意的(错误信息里可能引用了一条命令原文),这一条保证「行首的 `@R `」
+ 只可能来自 `ulink_tx_frame()`。 */
+#ifndef ULINK_H
+#define ULINK_H
+
+#include
+#include
+#include
+
+#include "cmd.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define ULINK_CMD_PREFIX "@C "
+#define ULINK_RSP_PREFIX "@R "
+#define ULINK_PREFIX_LEN 3
+#define ULINK_CRC_LEN 5 /* "*XXXX" */
+
+/* 一行请求最长多少字节(不含 `\r\n`):前缀 + JSON(≤ CMD_LINE_MAX − 1)+ CRC。 */
+#define ULINK_RX_LINE_MAX (ULINK_PREFIX_LEN + (CMD_LINE_MAX - 1) + ULINK_CRC_LEN)
+
+uint16_t ulink_crc16(const char *data, size_t len);
+
+/* ---- 接收:字节流 → 命令 ---------------------------------------------- */
+
+typedef enum {
+ ULINK_RX_CMD = 0, /* 一条完整的命令,payload 是去掉前缀与 CRC 的 JSON */
+ ULINK_RX_TOO_LONG, /* 以 `@C ` 开头但超长:整行丢弃,payload 为 "" */
+ ULINK_RX_BAD_CRC, /* 带了 CRC 但对不上:payload 是 JSON 部分(只用来回显 seq) */
+ ULINK_RX_BAD_BYTES, /* 行里有 NUL 或控制字符:payload 同上 */
+} ulink_rx_kind_t;
+
+typedef void (*ulink_rx_fn)(void *ctx, ulink_rx_kind_t kind, const char *payload);
+
+typedef struct {
+ char buf[ULINK_RX_LINE_MAX + 1];
+ size_t n;
+ bool overflow; /* 本行已超长:一直丢到 `\n` */
+ bool bad; /* 本行出现过 NUL / 控制字符 */
+ /* 统计 */
+ uint32_t cmds;
+ uint32_t ignored; /* 不是命令的行(日志回显、人手敲的回车……) */
+ uint32_t too_long;
+ uint32_t bad_crc;
+ uint32_t bad_bytes;
+} ulink_rx_t;
+
+void ulink_rx_init(ulink_rx_t *rx);
+
+/* 喂任意长度的一段字节(可以在任何位置切开,包括 `\r\n` 中间与 CRC 中间)。
+ 每凑齐一行就回调一次 —— 回调在本函数里同步发生。 */
+void ulink_rx_feed(ulink_rx_t *rx, const uint8_t *data, size_t len, ulink_rx_fn fn, void *ctx);
+
+/* ---- 发送:日志与帧交错写出 --------------------------------------------- */
+
+/* 往线上写字节。返回**真的写出去**几个(USB 缓冲满时可能少于 len)。 */
+typedef size_t (*ulink_sink_fn)(void *ctx, const char *data, size_t len);
+
+typedef struct {
+ ulink_sink_fn sink;
+ void *ctx;
+ bool at_bol; /* 最后真正写出去的字节是 `\n`(或者什么都还没写) */
+ uint32_t frames;
+ uint32_t short_writes; /* 没写全的次数:上位机那一侧会看到一条 CRC 错的行 */
+} ulink_tx_t;
+
+void ulink_tx_init(ulink_tx_t *tx, ulink_sink_fn sink, void *ctx);
+
+/* 写一段日志。可以是半行、一行或多行;行首 `@` 会被垫一个空格。 */
+void ulink_tx_log(ulink_tx_t *tx, const char *data, size_t len);
+
+/* 写一帧回应:必要时先补 `\n` 把半行日志收尾,再写 `@R *XXXX\n`。
+ `json` 里不许有 `\n`(cmd.h 的回应本来就没有)。返回是否整帧写出。 */
+bool ulink_tx_frame(ulink_tx_t *tx, const char *json, size_t len);
+
+/* ---- 链路层自己的错误回应 --------------------------------------------------
+ 这几种错在命令处理器**之前**就发生了(行太长 / CRC 错 / 坏字节 / 队列满),
+ 由链路任务直接回,不进处理器、不记事件(USB 线上的噪声不该刷掉事件缓冲)。
+ `payload` 里能解析出 seq 就回显,上位机据此知道是哪一条要重发。
+ err 名字:`too_long` / `bad_crc` / `bad_frame` / `queue_full`。返回 JSON 长度。 */
+size_t ulink_link_error(const char *payload, const char *err, char *out, size_t cap);
+
+const char *ulink_rx_err_str(ulink_rx_kind_t kind); /* CMD → NULL */
+
+/* ---- 参考解帧器(上位机那一侧的逻辑,放这里给测试和文档对账) --------------- */
+
+/* 一行输出(不含 `\n`)是不是一条合法回应帧。是则把 JSON 拷进 out 并返回其长度,
+ 不是(日志、CRC 错、没带 CRC)返回 −1。`tools/prlink.py` 是同一逻辑的 Python 版。 */
+int ulink_deframe(const char *line, char *out, size_t cap);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* ULINK_H */
diff --git a/firmware/main/ulink_esp.c b/firmware/main/ulink_esp.c
new file mode 100644
index 0000000..4ea3708
--- /dev/null
+++ b/firmware/main/ulink_esp.c
@@ -0,0 +1,155 @@
+/* USB-Serial-JTAG 链路的 ESP 胶水(A13)。**只进 IDF 目标**,分帧逻辑在 ulink.c(宿主机可测)。
+
+ ---- 这根线归谁 ----------------------------------------------------------
+ sdkconfig 里**关掉了副控制台**(`CONFIG_ESP_CONSOLE_SECONDARY_NONE`):
+ IDF 的副控制台会绕开驱动直接往 USB FIFO 写 stdout,和这里的写入互不同步,
+ 一条回应中间插进半行 printf 就被拆坏了(CRC 能兜住,但那是每条都可能坏)。
+ 现在这根线上的字节**只有两个来源**,都经同一把互斥锁、同一个 `ulink_tx_t`:
+ · `esp_log` 钩子:每条日志复制一份到 USB(UART0 / J33 照旧拿全量);
+ · 命令回应:执行器任务经 `cmd_queue` 的回调写。
+ `printf()` 与 panic 输出只去 UART0。代价:USB 上看不到 panic 回溯 ——
+ 复位原因会在下一次启动日志里报,回溯要接 J33。
+
+ ---- 两个写超时 ----------------------------------------------------------
+ 上位机没开串口时 USB 发不出去,`usb_serial_jtag_write_bytes()` 会等到超时。
+ · 日志:**0 等待**,写不下就丢(UART 上还有一份)。日志钩子在**任何任务**里被调,
+ 包括 20 ms 节拍的 `t_safety`,等一下就是把巡检拖慢一下;
+ · 回应:等 `ULINK_ESP_FRAME_WAIT_MS`。回应在执行器任务里写,等太久键会松得晚。
+ 拿锁同理:日志拿不到锁就不等(丢 USB 那份),回应等一小会儿。 */
+#include
+#include
+#include
+
+#include "cmd_queue.h"
+#include "driver/usb_serial_jtag.h"
+#include "esp_log.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/semphr.h"
+#include "ulink.h"
+#include "ulink_esp.h"
+
+static const char *TAG = "ulink";
+
+#define ULINK_ESP_TX_BUF 4096 /* 装得下两条满配 status(≈1 KB)+ 一堆日志 */
+#define ULINK_ESP_RX_BUF 1024
+#define ULINK_ESP_FRAME_WAIT_MS 20
+#define ULINK_ESP_LOG_LINE 256
+
+static SemaphoreHandle_t s_mtx;
+static ulink_tx_t s_tx;
+static ulink_rx_t s_rx;
+static vprintf_like_t s_prev_vprintf;
+static bool s_up;
+static TickType_t s_wait; /* 本次写允许等多久:由调用者在持锁时设 */
+
+static size_t usj_sink(void *ctx, const char *data, size_t len)
+{
+ (void)ctx;
+ if (!usb_serial_jtag_is_connected()) {
+ return 0; /* 没人听:一字节都不写(写进缓冲的会在下次连上时吐出一段陈年日志) */
+ }
+ const int n = usb_serial_jtag_write_bytes(data, len, s_wait);
+ return n > 0 ? (size_t)n : 0u;
+}
+
+static int log_vprintf(const char *fmt, va_list ap)
+{
+ if (s_up && xSemaphoreTake(s_mtx, 0) == pdTRUE) {
+ static char line[ULINK_ESP_LOG_LINE]; /* 锁内用,静态即可,不占调用者的栈 */
+ va_list ap2;
+ va_copy(ap2, ap);
+ const int n = vsnprintf(line, sizeof(line), fmt, ap2);
+ va_end(ap2);
+ if (n > 0) {
+ s_wait = 0;
+ ulink_tx_log(&s_tx, line, (size_t)n < sizeof(line) ? (size_t)n : sizeof(line) - 1);
+ }
+ xSemaphoreGive(s_mtx);
+ }
+ return s_prev_vprintf(fmt, ap); /* UART0 照旧 */
+}
+
+static void send_frame(const char *json, size_t len)
+{
+ if (!s_up || xSemaphoreTake(s_mtx, pdMS_TO_TICKS(ULINK_ESP_FRAME_WAIT_MS)) != pdTRUE) {
+ return; /* 上位机那一侧表现为超时,按同一个 seq 重发(判重兜住) */
+ }
+ s_wait = pdMS_TO_TICKS(ULINK_ESP_FRAME_WAIT_MS);
+ (void)ulink_tx_frame(&s_tx, json, len);
+ xSemaphoreGive(s_mtx);
+}
+
+/* cmd_queue 的回调:在**执行器任务**里被调(cmd_queue.h)。 */
+static void reply_usb(void *ctx, const char *line, size_t len)
+{
+ (void)ctx;
+ send_frame(line, len);
+}
+
+static void link_error(const char *payload, const char *err)
+{
+ char out[96];
+ const size_t n = ulink_link_error(payload, err, out, sizeof(out));
+ if (n > 0) {
+ send_frame(out, n);
+ }
+}
+
+/* ulink_rx_feed 的回调:在**链路任务**里被调。 */
+static void on_line(void *ctx, ulink_rx_kind_t kind, const char *payload)
+{
+ (void)ctx;
+ if (kind != ULINK_RX_CMD) {
+ link_error(payload, ulink_rx_err_str(kind));
+ return;
+ }
+ if (!cmd_queue_submit(CMD_LINK_USB, payload, reply_usb, NULL)) {
+ link_error(payload, "queue_full");
+ }
+}
+
+hal_err_t ulink_esp_init(void)
+{
+ s_mtx = xSemaphoreCreateMutex();
+ if (s_mtx == NULL) {
+ return HAL_ERR_STATE;
+ }
+ usb_serial_jtag_driver_config_t cfg = {
+ .tx_buffer_size = ULINK_ESP_TX_BUF,
+ .rx_buffer_size = ULINK_ESP_RX_BUF,
+ };
+ const esp_err_t e = usb_serial_jtag_driver_install(&cfg);
+ if (e != ESP_OK) {
+ ESP_LOGE(TAG, "USB-Serial-JTAG 驱动装不上:%s —— USB 命令链路不可用", esp_err_to_name(e));
+ return HAL_ERR_BUS;
+ }
+ ulink_tx_init(&s_tx, usj_sink, NULL);
+ ulink_rx_init(&s_rx);
+ s_up = true;
+ s_prev_vprintf = esp_log_set_vprintf(log_vprintf);
+ ESP_LOGI(TAG, "USB 命令链路就绪:请求 %s[*CRC],回应 %s*CRC", ULINK_CMD_PREFIX,
+ ULINK_RSP_PREFIX);
+ return HAL_OK;
+}
+
+void ulink_esp_poll(uint32_t wait_ms)
+{
+ if (!s_up) {
+ vTaskDelay(pdMS_TO_TICKS(wait_ms));
+ return;
+ }
+ uint8_t buf[64];
+ const int n = usb_serial_jtag_read_bytes(buf, sizeof(buf), pdMS_TO_TICKS(wait_ms));
+ if (n > 0) {
+ ulink_rx_feed(&s_rx, buf, (size_t)n, on_line, NULL);
+ }
+}
+
+void ulink_esp_stats(ulink_esp_stats_t *st)
+{
+ st->rx_cmds = s_rx.cmds;
+ st->rx_ignored = s_rx.ignored;
+ st->rx_errors = s_rx.too_long + s_rx.bad_crc + s_rx.bad_bytes;
+ st->tx_frames = s_tx.frames;
+ st->tx_short = s_tx.short_writes;
+}
diff --git a/firmware/main/ulink_esp.h b/firmware/main/ulink_esp.h
new file mode 100644
index 0000000..c078da4
--- /dev/null
+++ b/firmware/main/ulink_esp.h
@@ -0,0 +1,25 @@
+/* USB-Serial-JTAG 命令链路的 ESP 胶水(A13)。只在 IDF 目标里有;说明在 ulink_esp.c 文件头。 */
+#ifndef ULINK_ESP_H
+#define ULINK_ESP_H
+
+#include
+
+#include "hal_err.h"
+
+/* 装驱动、挂日志钩子。要在命令队列建好之后、任务起来之前调。
+ 失败不 abort:USB 命令链路不可用,WiFi 链路(A14)与五路供电照常(§10.2)。 */
+hal_err_t ulink_esp_init(void);
+
+/* 链路任务每圈调一次:最多等 wait_ms 收一包,切出的命令投进 cmd_queue。 */
+void ulink_esp_poll(uint32_t wait_ms);
+
+typedef struct {
+ uint32_t rx_cmds;
+ uint32_t rx_ignored;
+ uint32_t rx_errors; /* 超长 + CRC 错 + 坏字节 */
+ uint32_t tx_frames;
+ uint32_t tx_short; /* 没写全的帧(上位机那边是一条 CRC 错的行) */
+} ulink_esp_stats_t;
+void ulink_esp_stats(ulink_esp_stats_t *st);
+
+#endif /* ULINK_ESP_H */
diff --git a/firmware/sdkconfig.defaults b/firmware/sdkconfig.defaults
index 4c11aac..23d43cb 100644
--- a/firmware/sdkconfig.defaults
+++ b/firmware/sdkconfig.defaults
@@ -4,7 +4,10 @@ CONFIG_IDF_TARGET="esp32s3"
# 8 MB flash / 双分区 OTA 留到 C2,骨架期先用默认单 app 表。
CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y
-# 日志:默认 INFO,串口日志与命令混流的分帧问题在 A13 处理。
+# 日志:默认 INFO。
+# A13:关掉 USB-Serial-JTAG 副控制台 —— 它绕开驱动直接写 USB FIFO,会把命令回应拆坏。
+# 日志由 ulink_esp.c 的钩子复制到 USB(与回应同一把锁);printf / panic 只去 UART0(J33)。
+CONFIG_ESP_CONSOLE_SECONDARY_NONE=y
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
# 自定义分区表:默认那张里没有事件日志的地方(需求 §10.7)。见 partitions.csv。
diff --git a/firmware/test/test_cmd.c b/firmware/test/test_cmd.c
new file mode 100644
index 0000000..7fafe16
--- /dev/null
+++ b/firmware/test/test_cmd.c
@@ -0,0 +1,657 @@
+/* A12:单一命令处理器(需求 §10.8,计划 §6.3)。验收原话三条:
+ ① 同序号发两遍只执行一次;
+ ② 越界命令被拒且记事件;
+ ③ 空槽(`PRSNT#` 高)的命令一律拒。
+
+ 和 A9 的测试同一个口径:断言一律打在**线上真实发生的事**上 ——
+ 假 PCA9555 的锁存器与写事务计数、DAC 寄存器、`EFUSE_EN` 引脚电平、落盘的事件 ——
+ 不看处理器自己回了什么。「回了 ok」和「真的按了一次」是两件事,
+ 判重写错的现象恰恰是回应完全正常、手机被按了两遍。 */
+#include
+#include
+
+#include "board_pins.h"
+#include "cmd.h"
+#include "dac63204.h"
+#include "evlog.h"
+#include "hal_i2c.h"
+#include "hal_lock.h"
+#include "hal_time.h"
+#include "i2cbus.h"
+#include "pca9555.h"
+#include "phonerail.h"
+#include "rail.h"
+#include "rail_cfg.h"
+#include "sim_hal.h"
+#include "slot.h"
+#include "t_actuator.h"
+#include "t_thermal.h"
+#include "tca9548a.h"
+#include "unity.h"
+
+#define N PHONERAIL_RAIL_COUNT
+
+static char s_out[CMD_OUT_MAX];
+
+static const char *send_on(cmd_link_t link, const char *line)
+{
+ const size_t n = cmd_handle_line(link, line, s_out, sizeof(s_out));
+ TEST_ASSERT_EQUAL_size_t(strlen(s_out), n);
+ return s_out;
+}
+
+static const char *send(const char *line) { return send_on(CMD_LINK_TEST, line); }
+
+/* ⚠️ 写成函数不写成宏:宏会把 `send(...)` 展开两次(条件一次、失败消息一次),
+ 断言失败时打印出来的是**又发了一遍**的那条回应 —— 正好是判重命中的那一条,
+ 排查时会被它带偏。 */
+static void assert_has_at(const char *resp, const char *needle, int line)
+{
+ UNITY_TEST_ASSERT_NOT_NULL(strstr(resp, needle), line, resp);
+}
+
+static void assert_lacks_at(const char *resp, const char *needle, int line)
+{
+ UNITY_TEST_ASSERT_NULL(strstr(resp, needle), line, resp);
+}
+
+#define ASSERT_HAS(resp, needle) assert_has_at((resp), (needle), __LINE__)
+#define ASSERT_LACKS(resp, needle) assert_lacks_at((resp), (needle), __LINE__)
+
+/* ---- 假板子:mux + 五片刀片 PCA9555 + 主干两片 DAC ------------------------ */
+
+static sim_i2c_dev_t *blade(int rail)
+{
+ sim_i2c_dev_t *d = sim_i2c_dev_ch(rail_cfg(rail)->mux_ch, PCA9555_ADDR);
+ TEST_ASSERT_NOT_NULL(d);
+ return d;
+}
+
+static uint8_t latch(int rail) { return sim_pca9555_output(rail - 1, 0); }
+static uint32_t writes(int rail) { return blade(rail)->n_write; }
+static int en_pin(int rail) { return sim_gpio_level(rail_cfg(rail)->efuse_en); }
+
+static uint16_t dac_code(int rail)
+{
+ const uint16_t reg = sim_i2c_get_reg(dac63204_rail_addr(rail),
+ DAC63204_REG_DATA(dac63204_rail_ch(rail)));
+ return DAC63204_REG_TO_CODE(reg);
+}
+
+static void set_present(int rail, bool present)
+{
+ sim_gpio_drive(rail_cfg(rail)->prsnt_n, present ? 0 : 1);
+ slot_prsnt_poll();
+}
+
+void setUp(void)
+{
+ sim_hal_reset();
+ const hal_i2c_cfg_t cfg = { .sda_gpio = BOARD_PIN_I2C_SDA,
+ .scl_gpio = BOARD_PIN_I2C_SCL,
+ .freq_hz = 400000 };
+ TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_init(&cfg));
+ TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_init());
+ sim_i2c_add_mux(TCA9548A_ADDR, BOARD_PIN_MUX_RST_N);
+ for (int rail = 1; rail <= N; rail++) {
+ sim_pca9555_attach_ch(rail - 1, rail_cfg(rail)->mux_ch, PCA9555_ADDR);
+ }
+ for (int i = 0; i < DAC63204_COUNT; i++) {
+ sim_i2c_add(dac63204_all_addr[i], 2);
+ }
+ TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_init());
+ TEST_ASSERT_EQUAL_INT(HAL_OK, rail_en_init());
+ for (int rail = 1; rail <= N; rail++) {
+ TEST_ASSERT_EQUAL_INT(HAL_OK, slot_init(rail));
+ }
+ TEST_ASSERT_EQUAL_INT(HAL_OK, slot_prsnt_init());
+ for (int rail = 1; rail <= N; rail++) {
+ set_present(rail, true);
+ }
+ rail_setpoint_reset();
+ t_thermal_set_floor(0);
+ TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init());
+ t_actuator_init(NULL, NULL);
+ cmd_init(NULL);
+
+ /* 启动期独占,之后任何一次 I²C 事务没持锁就记违规 —— `set_voltage` 那一笔
+ 主干写就靠这一条抓(`rail_set_mv()` 自己不拿锁)。 */
+ sim_lock_require(HAL_LOCK_I2C, true);
+}
+
+void tearDown(void)
+{
+ TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, sim_lock_violations(HAL_LOCK_I2C), "有 I²C 事务没持锁");
+ TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous());
+}
+
+/* 虚拟时钟往前推,期间按执行器自己要的节奏叫它(同 test_t_actuator.c)。 */
+static void run_ms(uint32_t ms)
+{
+ const uint32_t target = hal_time_now_ms() + ms;
+ for (;;) {
+ uint32_t w = t_actuator_step();
+ const uint32_t now = hal_time_now_ms();
+ if ((int32_t)(now - target) >= 0) {
+ return;
+ }
+ const uint32_t left = target - now;
+ if (w == 0 || w > left) {
+ w = left;
+ }
+ sim_time_advance_ms(w);
+ }
+}
+
+/* 把 flash 里的事件全拉出来,找第 k 条类型为 type 的。 */
+static bool find_event(log_ev_t type, int k, evlog_rec_t *out)
+{
+ evlog_rec_t rec[64];
+ (void)evlog_flush(0);
+ const uint32_t n = evlog_read(0, rec, 64);
+ for (uint32_t i = 0; i < n; i++) {
+ if (rec[i].type == (uint8_t)type && k-- == 0) {
+ *out = rec[i];
+ return true;
+ }
+ }
+ return false;
+}
+
+static int count_events(log_ev_t type)
+{
+ evlog_rec_t r;
+ int k = 0;
+ while (find_event(type, k, &r)) {
+ k++;
+ }
+ return k;
+}
+
+/* ---- 冻结的编号 ---------------------------------------------------------- */
+
+/* 命令号与拒绝原因会写进 flash(CMD_REJECT 的 arg / current_ua),和 log_ev_t 一样冻结。 */
+static void test_cmd_numbers_are_frozen(void)
+{
+ TEST_ASSERT_EQUAL_INT(1, CMD_STATUS);
+ TEST_ASSERT_EQUAL_INT(2, CMD_SET_VOLTAGE);
+ TEST_ASSERT_EQUAL_INT(3, CMD_POWER_CYCLE);
+ TEST_ASSERT_EQUAL_INT(4, CMD_KEY);
+ TEST_ASSERT_EQUAL_INT(5, CMD_EDL);
+ TEST_ASSERT_EQUAL_INT(6, CMD_VBUS);
+ TEST_ASSERT_EQUAL_INT(7, CMD_FAN);
+ TEST_ASSERT_EQUAL_INT(8, CMD_EVENTS);
+ TEST_ASSERT_EQUAL_INT(9, CMD_POWER);
+ TEST_ASSERT_EQUAL_INT(10, CMD_WIFI);
+ TEST_ASSERT_EQUAL_INT(11, CMD_MQTT);
+ TEST_ASSERT_EQUAL_INT(12, CMD_NET);
+ TEST_ASSERT_EQUAL_INT(13, CMD_COUNT);
+
+ TEST_ASSERT_EQUAL_INT(0, CMD_OK);
+ TEST_ASSERT_EQUAL_INT(1, CMD_ERR_PARSE);
+ TEST_ASSERT_EQUAL_INT(2, CMD_ERR_UNKNOWN);
+ TEST_ASSERT_EQUAL_INT(3, CMD_ERR_NO_SEQ);
+ TEST_ASSERT_EQUAL_INT(4, CMD_ERR_ARG);
+ TEST_ASSERT_EQUAL_INT(5, CMD_ERR_RANGE);
+ TEST_ASSERT_EQUAL_INT(6, CMD_ERR_EMPTY);
+ TEST_ASSERT_EQUAL_INT(7, CMD_ERR_BUSY);
+ TEST_ASSERT_EQUAL_INT(8, CMD_ERR_STATE);
+ TEST_ASSERT_EQUAL_INT(9, CMD_ERR_IO);
+ TEST_ASSERT_EQUAL_INT(10, CMD_ERR_OVERFLOW);
+ TEST_ASSERT_EQUAL_INT(11, CMD_ERR_LINK);
+ TEST_ASSERT_EQUAL_INT(0x21, CMD_REBOOT_ARG(true, CMD_LINK_WIFI));
+ TEST_ASSERT_EQUAL_INT(0x10, CMD_REBOOT_ARG(false, CMD_LINK_USB));
+
+ for (int i = CMD_NONE + 1; i < CMD_COUNT; i++) {
+ TEST_ASSERT_TRUE(strlen(cmd_name((cmd_id_t)i)) > 0);
+ }
+ for (int i = 0; i < CMD_ERR_COUNT; i++) {
+ TEST_ASSERT_NOT_EQUAL(0, strcmp("?", cmd_err_str((cmd_err_t)i)));
+ }
+}
+
+/* ---- ① 判重 -------------------------------------------------------------- */
+
+/* ⭐ 验收主项一。同一个 seq 从 USB 再到 WiFi:第二遍必须回第一次的结论、
+ **一个字节都不许再发到刀片上**。 */
+static void test_same_seq_twice_executes_once_across_links(void)
+{
+ const char *line = "{\"seq\":41,\"cmd\":\"key\",\"rail\":2,\"keys\":\"pwr\",\"ms\":100}";
+ const uint32_t w0 = writes(2);
+ ASSERT_HAS(send_on(CMD_LINK_WIFI, line), "\"ok\":true");
+ const uint32_t w1 = writes(2);
+ TEST_ASSERT_EQUAL_UINT32(w0 + 1, w1); /* 按下去正好一次写 */
+ TEST_ASSERT_TRUE((latch(2) & (1u << pca9555_key_bit(PCA9555_KEY_PWR))) != 0);
+
+ const char *r = send_on(CMD_LINK_USB, line);
+ ASSERT_HAS(r, "\"seq\":41");
+ ASSERT_HAS(r, "\"ok\":true");
+ ASSERT_HAS(r, "\"dup\":true");
+ TEST_ASSERT_EQUAL_UINT32(w1, writes(2));
+
+ cmd_stats_t st;
+ cmd_stats(&st);
+ TEST_ASSERT_EQUAL_UINT32(1, st.dups);
+}
+
+/* ⭐ §10.8 原话的那个场景:「按 10 秒电源键」执行两遍,第二遍正好按在刚开机的手机上。
+ 第一遍**走完之后**重传到达,照样不许执行。 */
+static void test_retransmit_after_the_sequence_finished_is_still_dropped(void)
+{
+ const char *line = "{\"seq\":7,\"cmd\":\"key\",\"rail\":4,\"keys\":\"pwr\",\"ms\":10000}";
+ ASSERT_HAS(send(line), "\"ok\":true");
+ run_ms(10500);
+ TEST_ASSERT_FALSE(t_actuator_busy(4));
+ TEST_ASSERT_EQUAL_HEX8(0x00, latch(4));
+ const uint32_t w = writes(4);
+
+ ASSERT_HAS(send(line), "\"dup\":true");
+ TEST_ASSERT_FALSE(t_actuator_busy(4));
+ TEST_ASSERT_EQUAL_UINT32(w, writes(4));
+ TEST_ASSERT_EQUAL_INT(1, count_events(LOG_EV_REBOOT)); /* 软重启只记了一次 */
+}
+
+/* 被拒的命令也进窗口:重传拿到的是同一个拒绝,而不是「这次碰巧不忙了就执行」。 */
+static void test_a_rejected_command_stays_rejected_on_retransmit(void)
+{
+ ASSERT_HAS(send("{\"seq\":1,\"cmd\":\"key\",\"rail\":1,\"keys\":\"vup\",\"ms\":2000}"),
+ "\"ok\":true");
+ const char *busy = "{\"seq\":2,\"cmd\":\"edl\",\"rail\":1}";
+ ASSERT_HAS(send(busy), "\"err\":\"busy\"");
+ run_ms(2500);
+ TEST_ASSERT_FALSE(t_actuator_busy(1));
+ const char *r = send(busy);
+ ASSERT_HAS(r, "\"err\":\"busy\"");
+ ASSERT_HAS(r, "\"dup\":true");
+ TEST_ASSERT_EQUAL_UINT32(1, t_actuator_done(1)); /* 只走完过那一次按键,EDL 从没启动 */
+ TEST_ASSERT_FALSE(t_actuator_busy(1));
+}
+
+/* 上位机把同一个 seq 用在了两条命令上:不执行后一条,回 arg 让它看得见。 */
+static void test_same_seq_different_command_is_not_executed(void)
+{
+ ASSERT_HAS(send("{\"seq\":9,\"cmd\":\"fan\",\"duty\":500}"), "\"ok\":true");
+ const uint32_t w = writes(3);
+ const char *r = send("{\"seq\":9,\"cmd\":\"key\",\"rail\":3,\"keys\":\"pwr\",\"ms\":100}");
+ ASSERT_HAS(r, "\"err\":\"arg\"");
+ ASSERT_HAS(r, "\"dup\":true");
+ TEST_ASSERT_EQUAL_UINT32(w, writes(3));
+}
+
+/* 窗口按「值相等」判,不按「≤ 最大值」判:上位机重启后序号从头数,新命令照常执行。 */
+static void test_window_is_by_value_so_a_restarted_host_is_not_blocked(void)
+{
+ char line[96];
+ for (int s = 1000; s < 1000 + CMD_SEQ_WINDOW; s++) {
+ snprintf(line, sizeof(line), "{\"seq\":%d,\"cmd\":\"fan\",\"duty\":0}", s);
+ ASSERT_LACKS(send(line), "\"dup\":true");
+ }
+ ASSERT_LACKS(send("{\"seq\":3,\"cmd\":\"fan\",\"duty\":400}"), "\"dup\":true"); /* 比窗口里的都小 */
+ TEST_ASSERT_EQUAL_UINT16(400, t_thermal_floor());
+ /* 窗口满了之后最老的那条被挤出去:1000 这时已经不认识了。 */
+ ASSERT_LACKS(send("{\"seq\":1000,\"cmd\":\"fan\",\"duty\":0}"), "\"dup\":true");
+ ASSERT_HAS(send("{\"seq\":1031,\"cmd\":\"fan\",\"duty\":0}"), "\"dup\":true");
+}
+
+/* 会动东西的命令必须带 seq;status / events 幂等,带不带都行、也不判重。 */
+static void test_seq_rules(void)
+{
+ const uint32_t w = writes(1);
+ ASSERT_HAS(send("{\"cmd\":\"key\",\"rail\":1,\"keys\":\"pwr\",\"ms\":100}"), "\"err\":\"no_seq\"");
+ ASSERT_HAS(send("{\"seq\":0,\"cmd\":\"edl\",\"rail\":1}"), "\"err\":\"no_seq\"");
+ ASSERT_HAS(send("{\"seq\":4294967296,\"cmd\":\"edl\",\"rail\":1}"), "\"err\":\"no_seq\"");
+ ASSERT_HAS(send("{\"seq\":\"5\",\"cmd\":\"edl\",\"rail\":1}"), "\"err\":\"no_seq\"");
+ TEST_ASSERT_EQUAL_UINT32(w, writes(1));
+ TEST_ASSERT_FALSE(t_actuator_busy(1));
+
+ ASSERT_HAS(send("{\"cmd\":\"status\"}"), "\"ok\":true");
+ ASSERT_LACKS(send("{\"seq\":77,\"cmd\":\"status\"}"), "\"dup\":true");
+ const char *r = send("{\"seq\":77,\"cmd\":\"status\"}");
+ ASSERT_LACKS(r, "\"dup\":true");
+ ASSERT_HAS(r, "\"seq\":77");
+}
+
+/* ---- ② 越界即拒,而且记事件 ------------------------------------------------ */
+
+/* ⭐ 验收主项二。SR-13:4011 / 4300 / 3523 都拒,DAC 一个字节都不动,每一次都记一条
+ CMD_REJECT(arg = 命令号、bus_mv = 那个越界值、current_ua = 原因)。 */
+static void test_voltage_out_of_range_is_rejected_and_logged(void)
+{
+ const uint16_t before = dac_code(2);
+ const uint32_t dac_w = sim_i2c_dev(dac63204_rail_addr(2))->n_write;
+ static const int bad[] = { 4011, 4300, 3523, -1 };
+ char line[96];
+ for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) {
+ snprintf(line, sizeof(line), "{\"seq\":%d,\"cmd\":\"set_voltage\",\"rail\":2,\"mv\":%d}",
+ (int)i + 1, bad[i]);
+ ASSERT_HAS(send(line), "\"err\":\"range\"");
+ }
+ TEST_ASSERT_EQUAL_UINT16(before, dac_code(2));
+ TEST_ASSERT_EQUAL_UINT32(dac_w, sim_i2c_dev(dac63204_rail_addr(2))->n_write);
+ TEST_ASSERT_EQUAL_INT(RAIL_MV_SAFE, rail_setpoint_mv(2));
+
+ TEST_ASSERT_EQUAL_INT(4, count_events(LOG_EV_CMD_REJECT));
+ evlog_rec_t r;
+ TEST_ASSERT_TRUE(find_event(LOG_EV_CMD_REJECT, 1, &r));
+ TEST_ASSERT_EQUAL_UINT8(2, r.rail);
+ TEST_ASSERT_EQUAL_UINT8(CMD_SET_VOLTAGE, r.arg);
+ TEST_ASSERT_EQUAL_INT32(4300, r.bus_mv);
+ TEST_ASSERT_EQUAL_INT32(CMD_ERR_RANGE, r.current_ua);
+}
+
+/* 合法电压:主干上的 DAC 写**在锁里**(tearDown 查违规),码与 `rail_mv_to_code()` 一致。 */
+static void test_voltage_in_range_is_written_under_the_bus_lock(void)
+{
+ const char *r = send("{\"seq\":5,\"cmd\":\"set_voltage\",\"rail\":5,\"mv\":3800}");
+ ASSERT_HAS(r, "\"ok\":true");
+ ASSERT_HAS(r, "\"set\":3800");
+ TEST_ASSERT_EQUAL_UINT16(rail_mv_to_code(3800), dac_code(5));
+ TEST_ASSERT_EQUAL_INT(3800, rail_setpoint_mv(5));
+ ASSERT_HAS(send("{\"seq\":6,\"cmd\":\"set_voltage\",\"rail\":5,\"mv\":4010}"), "\"ok\":true");
+ TEST_ASSERT_EQUAL_INT(0, count_events(LOG_EV_CMD_REJECT));
+}
+
+static void test_other_range_checks(void)
+{
+ ASSERT_HAS(send("{\"seq\":1,\"cmd\":\"edl\",\"rail\":6}"), "\"err\":\"range\"");
+ ASSERT_HAS(send("{\"seq\":2,\"cmd\":\"edl\",\"rail\":0}"), "\"err\":\"range\"");
+ ASSERT_HAS(send("{\"seq\":3,\"cmd\":\"key\",\"rail\":1,\"keys\":\"pwr\",\"ms\":0}"),
+ "\"err\":\"range\"");
+ char line[96];
+ snprintf(line, sizeof(line), "{\"seq\":4,\"cmd\":\"key\",\"rail\":1,\"keys\":\"pwr\",\"ms\":%d}",
+ ACT_KEY_MAX_MS + 1);
+ ASSERT_HAS(send(line), "\"err\":\"range\"");
+ ASSERT_HAS(send("{\"seq\":5,\"cmd\":\"fan\",\"duty\":1001}"), "\"err\":\"range\"");
+ TEST_ASSERT_EQUAL_INT(5, count_events(LOG_EV_CMD_REJECT));
+
+ evlog_rec_t r;
+ TEST_ASSERT_TRUE(find_event(LOG_EV_CMD_REJECT, 0, &r));
+ TEST_ASSERT_EQUAL_UINT8(0, r.rail); /* 路号本身越界:记整板级,不记一个不存在的路 */
+ TEST_ASSERT_EQUAL_INT32(6, r.bus_mv);
+ for (int rail = 1; rail <= N; rail++) {
+ TEST_ASSERT_FALSE(t_actuator_busy(rail));
+ }
+}
+
+/* 缺参数 / 类型不对 / 键名写错:arg,**不记事件**(那是上位机的 bug,不是操作)。 */
+static void test_argument_errors_are_not_logged(void)
+{
+ static const char *const bad[] = {
+ "{\"seq\":1,\"cmd\":\"set_voltage\",\"rail\":1}",
+ "{\"seq\":2,\"cmd\":\"set_voltage\",\"rail\":1,\"mv\":\"3900\"}",
+ "{\"seq\":3,\"cmd\":\"key\",\"rail\":1,\"keys\":\"pwr+xyz\",\"ms\":100}",
+ "{\"seq\":4,\"cmd\":\"key\",\"rail\":1,\"keys\":\"pwr+\",\"ms\":100}",
+ "{\"seq\":5,\"cmd\":\"key\",\"rail\":1,\"keys\":\"\",\"ms\":100}",
+ "{\"seq\":6,\"cmd\":\"vbus\",\"rail\":1,\"on\":1}",
+ "{\"seq\":7,\"cmd\":\"power\",\"rail\":1}",
+ "{\"seq\":8,\"cmd\":\"edl\"}",
+ };
+ for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) {
+ ASSERT_HAS(send(bad[i]), "\"err\":\"arg\"");
+ }
+ ASSERT_HAS(send("{\"seq\":9,\"cmd\":\"reboot_everything\"}"), "\"err\":\"unknown_cmd\"");
+ ASSERT_HAS(send("not json at all"), "\"err\":\"parse\"");
+ TEST_ASSERT_EQUAL_INT(0, count_events(LOG_EV_CMD_REJECT));
+ TEST_ASSERT_FALSE(t_actuator_busy(1));
+ TEST_ASSERT_EQUAL_HEX8(0x00, latch(1));
+}
+
+/* ---- ③ 空槽一律拒 -------------------------------------------------------- */
+
+/* ⭐ 验收主项三。槽 3 空着:六条按路的命令全拒,刀片 3 一个字节不动、EN 不动,
+ 每条都记一条事件;隔壁槽照常。 */
+static void test_every_rail_command_is_refused_on_an_empty_slot(void)
+{
+ set_present(3, false);
+ TEST_ASSERT_FALSE(slot_present(3));
+ const uint32_t w = writes(3);
+ const uint16_t code = dac_code(3);
+
+ static const char *const cmds[] = {
+ "{\"seq\":1,\"cmd\":\"set_voltage\",\"rail\":3,\"mv\":3800}",
+ "{\"seq\":2,\"cmd\":\"power_cycle\",\"rail\":3}",
+ "{\"seq\":3,\"cmd\":\"key\",\"rail\":3,\"keys\":\"pwr\",\"ms\":100}",
+ "{\"seq\":4,\"cmd\":\"edl\",\"rail\":3}",
+ "{\"seq\":5,\"cmd\":\"vbus\",\"rail\":3,\"on\":false}",
+ "{\"seq\":6,\"cmd\":\"power\",\"rail\":3,\"on\":true}",
+ };
+ for (size_t i = 0; i < sizeof(cmds) / sizeof(cmds[0]); i++) {
+ ASSERT_HAS(send(cmds[i]), "\"err\":\"empty\"");
+ }
+ run_ms(100);
+ TEST_ASSERT_EQUAL_UINT32(w, writes(3));
+ TEST_ASSERT_EQUAL_UINT16(code, dac_code(3));
+ TEST_ASSERT_EQUAL_INT(0, en_pin(3));
+ TEST_ASSERT_FALSE(t_actuator_busy(3));
+
+ TEST_ASSERT_EQUAL_INT(6, count_events(LOG_EV_CMD_REJECT));
+ evlog_rec_t r;
+ TEST_ASSERT_TRUE(find_event(LOG_EV_CMD_REJECT, 5, &r));
+ TEST_ASSERT_EQUAL_UINT8(3, r.rail);
+ TEST_ASSERT_EQUAL_UINT8(CMD_POWER, r.arg);
+ TEST_ASSERT_EQUAL_INT32(CMD_ERR_EMPTY, r.current_ua);
+
+ ASSERT_HAS(send("{\"seq\":7,\"cmd\":\"power\",\"rail\":4,\"on\":true}"), "\"ok\":true");
+ TEST_ASSERT_EQUAL_INT(1, en_pin(4));
+}
+
+/* ---- 各条命令的语义 ------------------------------------------------------ */
+
+/* 组合键:一次写两位(§10.6),走的是命令里给的那一路。 */
+static void test_combo_keys_land_on_the_named_rail(void)
+{
+ uint32_t w[N];
+ for (int r = 1; r <= N; r++) {
+ w[r - 1] = writes(r);
+ }
+ ASSERT_HAS(send("{\"seq\":1,\"cmd\":\"key\",\"rail\":5,\"keys\":\"vdn+pwr\",\"ms\":2000}"),
+ "\"ok\":true");
+ const uint8_t want = (uint8_t)((1u << pca9555_key_bit(PCA9555_KEY_PWR)) |
+ (1u << pca9555_key_bit(PCA9555_KEY_VDN)));
+ TEST_ASSERT_EQUAL_HEX8(want, latch(5));
+ TEST_ASSERT_EQUAL_UINT32(w[4] + 1, writes(5));
+ for (int r = 1; r < 5; r++) {
+ TEST_ASSERT_EQUAL_UINT32(w[r - 1], writes(r));
+ }
+ run_ms(2100);
+ TEST_ASSERT_EQUAL_HEX8(0x00, latch(5));
+ TEST_ASSERT_EQUAL_INT(0, count_events(LOG_EV_REBOOT)); /* 组合键不算重启 */
+}
+
+/* 同一路正在跑序列:busy,不记事件(忙不是越界)。 */
+static void test_busy_rail_refuses_a_second_sequence(void)
+{
+ ASSERT_HAS(send("{\"seq\":1,\"cmd\":\"power_cycle\",\"rail\":1}"), "\"ok\":true");
+ ASSERT_HAS(send("{\"seq\":2,\"cmd\":\"key\",\"rail\":1,\"keys\":\"pwr\",\"ms\":100}"),
+ "\"err\":\"busy\"");
+ ASSERT_HAS(send("{\"seq\":3,\"cmd\":\"vbus\",\"rail\":1,\"on\":false}"), "\"err\":\"busy\"");
+ ASSERT_HAS(send("{\"seq\":4,\"cmd\":\"power\",\"rail\":1,\"on\":false}"), "\"err\":\"busy\"");
+ TEST_ASSERT_EQUAL_INT(ACT_SEQ_HARD_RESET, t_actuator_seq(1));
+ TEST_ASSERT_EQUAL_INT(0, count_events(LOG_EV_CMD_REJECT));
+}
+
+/* 两级重启的两条事件:硬 = power_cycle,软 = 单按电源键 ≥10 s。arg 带链路。 */
+static void test_reboots_are_logged_with_kind_and_link(void)
+{
+ ASSERT_HAS(send_on(CMD_LINK_WIFI, "{\"seq\":1,\"cmd\":\"power_cycle\",\"rail\":2}"),
+ "\"ok\":true");
+ ASSERT_HAS(send_on(CMD_LINK_USB,
+ "{\"seq\":2,\"cmd\":\"key\",\"rail\":3,\"keys\":\"pwr\",\"ms\":10000}"),
+ "\"ok\":true");
+ ASSERT_HAS(send("{\"seq\":3,\"cmd\":\"key\",\"rail\":4,\"keys\":\"pwr\",\"ms\":2000}"),
+ "\"ok\":true"); /* 2 s 是开机,不是重启 */
+
+ TEST_ASSERT_EQUAL_INT(2, count_events(LOG_EV_REBOOT));
+ evlog_rec_t r;
+ TEST_ASSERT_TRUE(find_event(LOG_EV_REBOOT, 0, &r));
+ TEST_ASSERT_EQUAL_UINT8(2, r.rail);
+ TEST_ASSERT_EQUAL_UINT8(CMD_REBOOT_ARG(true, CMD_LINK_WIFI), r.arg);
+ TEST_ASSERT_TRUE(find_event(LOG_EV_REBOOT, 1, &r));
+ TEST_ASSERT_EQUAL_UINT8(3, r.rail);
+ TEST_ASSERT_EQUAL_UINT8(CMD_REBOOT_ARG(false, CMD_LINK_USB), r.arg);
+}
+
+/* eFuse 关着时开 VBUS = 手机只靠 USB 供电:state,记事件。开了电之后才行。 */
+static void test_vbus_needs_the_rail_powered(void)
+{
+ const uint32_t vb = 1u << PCA9555_BIT_VBUS_EN;
+ ASSERT_HAS(send("{\"seq\":1,\"cmd\":\"vbus\",\"rail\":2,\"on\":true}"), "\"err\":\"state\"");
+ TEST_ASSERT_EQUAL_HEX8(0x00, latch(2) & vb);
+ TEST_ASSERT_EQUAL_INT(1, count_events(LOG_EV_CMD_REJECT));
+
+ ASSERT_HAS(send("{\"seq\":2,\"cmd\":\"power\",\"rail\":2,\"on\":true}"), "\"ok\":true");
+ TEST_ASSERT_EQUAL_INT(1, en_pin(2));
+ ASSERT_HAS(send("{\"seq\":3,\"cmd\":\"vbus\",\"rail\":2,\"on\":true}"), "\"ok\":true");
+ TEST_ASSERT_EQUAL_HEX8(vb, latch(2) & vb);
+ TEST_ASSERT_TRUE(slot_vbus_is_on(2));
+}
+
+/* 关电:先断 VBUS 再关 eFuse;VBUS 断不掉(刀片 NAK)也照样关 eFuse,回 io。 */
+static void test_power_off_drops_vbus_and_always_drops_the_efuse(void)
+{
+ const uint32_t vb = 1u << PCA9555_BIT_VBUS_EN;
+ ASSERT_HAS(send("{\"seq\":1,\"cmd\":\"power\",\"rail\":1,\"on\":true}"), "\"ok\":true");
+ ASSERT_HAS(send("{\"seq\":2,\"cmd\":\"vbus\",\"rail\":1,\"on\":true}"), "\"ok\":true");
+ ASSERT_HAS(send("{\"seq\":3,\"cmd\":\"power\",\"rail\":1,\"on\":false}"), "\"ok\":true");
+ TEST_ASSERT_EQUAL_HEX8(0x00, latch(1) & vb);
+ TEST_ASSERT_EQUAL_INT(0, en_pin(1));
+
+ ASSERT_HAS(send("{\"seq\":4,\"cmd\":\"power\",\"rail\":2,\"on\":true}"), "\"ok\":true");
+ ASSERT_HAS(send("{\"seq\":5,\"cmd\":\"vbus\",\"rail\":2,\"on\":true}"), "\"ok\":true");
+ sim_i2c_inject_nak_ch(rail_cfg(2)->mux_ch, PCA9555_ADDR, 100);
+ ASSERT_HAS(send("{\"seq\":6,\"cmd\":\"power\",\"rail\":2,\"on\":false}"), "\"err\":\"io\"");
+ TEST_ASSERT_EQUAL_INT(0, en_pin(2)); /* 直供照样断了 */
+ TEST_ASSERT_TRUE(slot_vbus_is_on(2)); /* 影子如实留在 on(slot.h:倒向安全的一侧) */
+}
+
+/* 风扇:手动值只是下限,0 交回自动。 */
+static void test_fan_floor(void)
+{
+ const char *r = send("{\"seq\":1,\"cmd\":\"fan\",\"duty\":700}");
+ ASSERT_HAS(r, "\"floor\":700");
+ 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());
+}
+
+/* ---- 只读命令 ------------------------------------------------------------ */
+
+/* events 分页:一次最多 CMD_EVENTS_MAX 条,逐条带序号,游标接得上;RAM 里的先落盘。 */
+static void test_events_pages_with_contiguous_seqs(void)
+{
+ for (int i = 0; i < 11; i++) {
+ evlog_put(1, LOG_EV_PG_CHANGE, (uint8_t)i, 3900 + i, 100 * i);
+ }
+ /* 这 11 条还在 RAM 里:events 自己会先落盘。 */
+ evlog_stats_t es;
+ evlog_stats(&es);
+ TEST_ASSERT_TRUE(es.pending > 0);
+
+ const char *r = send("{\"cmd\":\"events\"}");
+ ASSERT_HAS(r, "\"oldest\":0");
+ ASSERT_HAS(r, "\"next\":12"); /* BOOT + 11 */
+ ASSERT_HAS(r, "[0,"); /* 第一条是 BOOT,序号 0 */
+ ASSERT_HAS(r, "[7,");
+ ASSERT_LACKS(r, "[8,");
+
+ r = send("{\"cmd\":\"events\",\"from\":8}");
+ ASSERT_HAS(r, "[8,");
+ ASSERT_HAS(r, "[11,");
+ ASSERT_HAS(r, ",1,8,10,3910,1000]"); /* 第 10 条 PG_CHANGE:rail 1、type 8、arg 10 */
+
+ r = send("{\"cmd\":\"events\",\"from\":12}");
+ ASSERT_HAS(r, "\"ev\":[]");
+ ASSERT_HAS(send("{\"cmd\":\"events\",\"from\":-1}"), "\"err\":\"arg\"");
+}
+
+/* status:满配(两份快照都给)装得进 CMD_OUT_MAX;读不到的量是 null,不是 0。 */
+static void test_status_fits_and_reports_unknowns_as_null(void)
+{
+ static safety_snap_t s;
+ static thermal_snap_t t;
+ memset(&s, 0, sizeof(s));
+ memset(&t, 0, sizeof(t));
+ for (int i = 0; i < N; i++) {
+ s.rail_ok[i] = true;
+ s.io_ok[i] = true;
+ s.rail[i].bus_mv = -3999999; /* 故意取最长的数字,量上界 */
+ s.rail[i].current_ua = -3999999;
+ }
+ s.rail_ok[1] = false; /* 第 2 路这一圈没读到 */
+ s.io_ok[3] = false; /* 第 4 路 PCA9555 失联 */
+ s.input_ok = true;
+ s.input.bus_mv = 12030;
+ s.input.current_ua = 1234567;
+ s.cycles = 4000000000u;
+ t.temp_ok[0] = true;
+ t.temp_mc[0] = 41500;
+ t.fan[0].rpm_valid = true;
+ t.fan[0].rpm = 3300;
+ t.duty = 650;
+ t.fan_en = true;
+ const cmd_env_t env = { .safety = &s, .thermal = &t };
+ cmd_init(&env);
+
+ const char *r = send("{\"seq\":4000000000,\"cmd\":\"status\"}");
+ ASSERT_HAS(r, "{\"seq\":4000000000,\"ok\":true,\"fw\":\"" PHONERAIL_FW_VERSION "\"");
+ ASSERT_HAS(r, "\"in\":{\"mv\":12030,\"ua\":1234567}");
+ ASSERT_HAS(r, "{\"r\":2,\"pr\":true,\"en\":false,\"vb\":false,\"set\":3900,\"mv\":null,\"ua\":null");
+ ASSERT_HAS(r, "\"r\":4,");
+ ASSERT_HAS(r, "\"pg\":null,\"flt\":null");
+ ASSERT_HAS(r, "\"temp\":[41500,null]");
+ ASSERT_HAS(r, "\"rpm\":[3300,null]");
+ ASSERT_HAS(r, "\"st\":\"idle\"");
+ TEST_ASSERT_EQUAL_CHAR('}', r[strlen(r) - 1]);
+ printf("status 满配 %u 字节(上限 %u)\n", (unsigned)strlen(r), (unsigned)CMD_OUT_MAX);
+ TEST_ASSERT_TRUE(strlen(r) < CMD_OUT_MAX * 3 / 4); /* 留四分之一给以后加字段 */
+
+ /* 不给快照:只报影子,照样是一行合法回应。 */
+ cmd_init(NULL);
+ r = send("{\"cmd\":\"status\"}");
+ ASSERT_LACKS(r, "\"in\"");
+ ASSERT_LACKS(r, "\"temp\"");
+ ASSERT_HAS(r, "\"r\":5,");
+}
+
+/* 回应装不下:只读命令如实报 overflow;已经执行了的命令回一条不带附加字段的 ok。 */
+static void test_small_output_buffer(void)
+{
+ char small[64];
+ size_t n = cmd_handle_line(CMD_LINK_TEST, "{\"seq\":1,\"cmd\":\"status\"}", small,
+ sizeof(small));
+ TEST_ASSERT_EQUAL_size_t(strlen(small), n);
+ TEST_ASSERT_EQUAL_STRING("{\"seq\":1,\"ok\":false,\"err\":\"overflow\"}", small);
+
+ char tiny[24];
+ n = cmd_handle_line(CMD_LINK_TEST, "{\"seq\":2,\"cmd\":\"fan\",\"duty\":300}", tiny,
+ sizeof(tiny));
+ TEST_ASSERT_EQUAL_STRING("{\"seq\":2,\"ok\":true}", tiny);
+ TEST_ASSERT_EQUAL_UINT16(300, t_thermal_floor());
+}
+
+int main(void)
+{
+ UNITY_BEGIN();
+ RUN_TEST(test_cmd_numbers_are_frozen);
+ RUN_TEST(test_same_seq_twice_executes_once_across_links);
+ RUN_TEST(test_retransmit_after_the_sequence_finished_is_still_dropped);
+ RUN_TEST(test_a_rejected_command_stays_rejected_on_retransmit);
+ RUN_TEST(test_same_seq_different_command_is_not_executed);
+ RUN_TEST(test_window_is_by_value_so_a_restarted_host_is_not_blocked);
+ RUN_TEST(test_seq_rules);
+ RUN_TEST(test_voltage_out_of_range_is_rejected_and_logged);
+ RUN_TEST(test_voltage_in_range_is_written_under_the_bus_lock);
+ RUN_TEST(test_other_range_checks);
+ RUN_TEST(test_argument_errors_are_not_logged);
+ RUN_TEST(test_every_rail_command_is_refused_on_an_empty_slot);
+ RUN_TEST(test_combo_keys_land_on_the_named_rail);
+ RUN_TEST(test_busy_rail_refuses_a_second_sequence);
+ RUN_TEST(test_reboots_are_logged_with_kind_and_link);
+ RUN_TEST(test_vbus_needs_the_rail_powered);
+ RUN_TEST(test_power_off_drops_vbus_and_always_drops_the_efuse);
+ RUN_TEST(test_fan_floor);
+ RUN_TEST(test_events_pages_with_contiguous_seqs);
+ RUN_TEST(test_status_fits_and_reports_unknowns_as_null);
+ RUN_TEST(test_small_output_buffer);
+ return UNITY_END();
+}
diff --git a/firmware/test/test_cmd_json.c b/firmware/test/test_cmd_json.c
new file mode 100644
index 0000000..9965ec3
--- /dev/null
+++ b/firmware/test/test_cmd_json.c
@@ -0,0 +1,152 @@
+/* A12:命令行的扁平 JSON 解析器(cmd_json.h)。
+
+ 这一层的测试重点不是「合法的能解出来」,而是**不合法的一律拒**:
+ 请求形状越窄,越界输入的面就越小。每一条「刻意不支持」都在这里钉一条用例 ——
+ 哪天有人为了「兼容」放宽了其中一条,这里先红。 */
+#include
+#include
+
+#include "cmd_json.h"
+#include "unity.h"
+
+void setUp(void) {}
+void tearDown(void) {}
+
+static cj_obj_t o;
+
+static void test_parses_a_flat_command(void)
+{
+ TEST_ASSERT_TRUE(cj_parse("{\"seq\":17,\"cmd\":\"key\",\"rail\":3,\"keys\":\"pwr+vdn\","
+ "\"ms\":2000,\"on\":true,\"x\":false,\"neg\":-5}\r\n",
+ &o));
+ int64_t v = 0;
+ const char *s = NULL;
+ bool bv = false;
+ TEST_ASSERT_EQUAL_INT(1, cj_get_int(&o, "seq", &v));
+ TEST_ASSERT_EQUAL_INT64(17, v);
+ TEST_ASSERT_EQUAL_INT(1, cj_get_str(&o, "cmd", &s));
+ TEST_ASSERT_EQUAL_STRING("key", s);
+ TEST_ASSERT_EQUAL_INT(1, cj_get_str(&o, "keys", &s));
+ TEST_ASSERT_EQUAL_STRING("pwr+vdn", s);
+ TEST_ASSERT_EQUAL_INT(1, cj_get_bool(&o, "on", &bv));
+ TEST_ASSERT_TRUE(bv);
+ TEST_ASSERT_EQUAL_INT(1, cj_get_bool(&o, "x", &bv));
+ TEST_ASSERT_FALSE(bv);
+ TEST_ASSERT_EQUAL_INT(1, cj_get_int(&o, "neg", &v));
+ TEST_ASSERT_EQUAL_INT64(-5, v);
+ TEST_ASSERT_EQUAL_INT(0, cj_get_int(&o, "missing", &v));
+}
+
+static void test_whitespace_and_empty_object(void)
+{
+ TEST_ASSERT_TRUE(cj_parse(" { \"a\" : 1 ,\t\"b\":\"x\" } ", &o));
+ TEST_ASSERT_EQUAL_INT(2, o.n);
+ TEST_ASSERT_TRUE(cj_parse("{}", &o));
+ TEST_ASSERT_EQUAL_INT(0, o.n);
+}
+
+/* 「有但类型不对」与「没有」是两回事:`"mv":"3900"` 是上位机的 bug,不是「没给电压」。 */
+static void test_wrong_type_is_distinguished_from_missing(void)
+{
+ TEST_ASSERT_TRUE(cj_parse("{\"mv\":\"3900\",\"on\":1,\"cmd\":5}", &o));
+ int64_t v = 0;
+ const char *s = NULL;
+ bool b = false;
+ TEST_ASSERT_EQUAL_INT(-1, cj_get_int(&o, "mv", &v));
+ TEST_ASSERT_EQUAL_INT(-1, cj_get_bool(&o, "on", &b)); /* 1 不是布尔 */
+ TEST_ASSERT_EQUAL_INT(-1, cj_get_str(&o, "cmd", &s));
+}
+
+static void test_rejects_everything_outside_the_flat_shape(void)
+{
+ static const char *const bad[] = {
+ "", /* 空行 */
+ "[1,2]", /* 不是对象 */
+ "{\"a\":{\"b\":1}}", /* 嵌套 */
+ "{\"a\":[1]}", /* 数组 */
+ "{\"a\":null}", /* null */
+ "{\"a\":3.9}", /* 浮点:不许被截成 3 */
+ "{\"a\":1e3}", /* 指数 */
+ "{\"a\":010}", /* 前导零 */
+ "{\"a\":1234567890123456789}", /* 19 位:不回绕 */
+ "{\"a\":\"x\\\"y\"}", /* 转义:见头文件 */
+ "{\"a\":\"line\nbreak\"}", /* 控制字符 */
+ "{\"mv\":3900,\"mv\":4300}", /* 重复键:有人拿来绕校验的那种 */
+ "{\"a\":1}{\"b\":2}", /* 两条命令粘在一行 */
+ "{\"a\":1} x", /* 尾巴 */
+ "{\"a\":1,}", /* 尾逗号 */
+ "{\"a\" 1}", /* 缺冒号 */
+ "{\"\":1}", /* 空键 */
+ "{a:1}", /* 键没引号 */
+ "{\"a\":tru}", /* 半个 true */
+ "{\"a\":1", /* 没收尾 */
+ "{\"a\":-}", /* 光一个负号 */
+ };
+ for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) {
+ TEST_ASSERT_FALSE_MESSAGE(cj_parse(bad[i], &o), bad[i]);
+ }
+ TEST_ASSERT_FALSE(cj_parse(NULL, &o));
+}
+
+/* 超长的键 / 字符串不截断:截断会把 "set_voltage_x" 读成另一条命令。 */
+static void test_overlong_tokens_are_rejected_not_truncated(void)
+{
+ char line[128];
+ memset(line, 0, sizeof(line));
+ strcpy(line, "{\"cmd\":\"");
+ memset(line + strlen(line), 'a', CMD_JSON_STR_MAX); /* 32 个字符:装不下结尾 0 */
+ strcat(line, "\"}");
+ TEST_ASSERT_FALSE(cj_parse(line, &o));
+
+ memset(line, 0, sizeof(line));
+ strcpy(line, "{\"");
+ memset(line + 2, 'k', CMD_JSON_KEY_MAX); /* 16 个:装不下结尾 0 */
+ line[2 + CMD_JSON_KEY_MAX] = '\0';
+ strcat(line, "\":1}");
+ TEST_ASSERT_FALSE(cj_parse(line, &o));
+
+ memset(line, 0, sizeof(line));
+ strcpy(line, "{\"cmd\":\"");
+ memset(line + strlen(line), 'a', CMD_JSON_STR_MAX - 1); /* 31 个 + 结尾 0:刚好装得下 */
+ strcat(line, "\"}");
+ TEST_ASSERT_TRUE(cj_parse(line, &o));
+}
+
+static void test_field_count_is_bounded(void)
+{
+ char line[256] = "{";
+ for (int i = 0; i < CMD_JSON_MAX_FIELDS + 1; i++) {
+ char f[16];
+ snprintf(f, sizeof(f), "%s\"k%d\":%d", i ? "," : "", i, i);
+ strcat(line, f);
+ }
+ strcat(line, "}");
+ TEST_ASSERT_FALSE(cj_parse(line, &o));
+}
+
+static void test_writer_never_overruns(void)
+{
+ char buf[8];
+ cj_writer_t w;
+ cj_w_init(&w, buf, sizeof(buf));
+ cj_w(&w, "abc");
+ TEST_ASSERT_FALSE(w.overflow);
+ cj_w(&w, "defgh"); /* 3 + 5 = 8,装不下结尾 0 */
+ TEST_ASSERT_TRUE(w.overflow);
+ TEST_ASSERT_EQUAL_STRING("abc", buf); /* 写了一半的那段撤掉 */
+ cj_w(&w, "x");
+ TEST_ASSERT_EQUAL_STRING("abc", buf); /* 溢出之后一律不写 */
+}
+
+int main(void)
+{
+ UNITY_BEGIN();
+ RUN_TEST(test_parses_a_flat_command);
+ RUN_TEST(test_whitespace_and_empty_object);
+ RUN_TEST(test_wrong_type_is_distinguished_from_missing);
+ RUN_TEST(test_rejects_everything_outside_the_flat_shape);
+ RUN_TEST(test_overlong_tokens_are_rejected_not_truncated);
+ RUN_TEST(test_field_count_is_bounded);
+ RUN_TEST(test_writer_never_overruns);
+ return UNITY_END();
+}
diff --git a/firmware/test/test_net.c b/firmware/test/test_net.c
new file mode 100644
index 0000000..a01c4f0
--- /dev/null
+++ b/firmware/test/test_net.c
@@ -0,0 +1,295 @@
+/* A14 / C1:网络配置、MQTT 链路的平台无关部分、三条网络命令。
+
+ 计划里 A14 的验收原话是「协议层用 sim 传输测通;真连网留到 B 段」。
+ 这里测的是会写错而且上板查不出来的那几件事:
+ · 密码**永远**不出现在任何回应里;
+ · 配置命令**只收 USB**,经 WiFi 来的一律拒并记事件;
+ · 没存进 flash 的配置不生效(重启前后一致);
+ · MQTT 入站消息与 USB 同一套坏字节规矩;topic / 退避 / 遥测节拍的数。 */
+#include
+#include
+
+#include "cmd.h"
+#include "evlog.h"
+#include "mlink.h"
+#include "netcfg.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 s_save_err;
+static int s_changed;
+
+static hal_err_t fake_save(void *ctx, const netcfg_t *cfg)
+{
+ (void)ctx;
+ if (s_save_err != HAL_OK) {
+ return s_save_err;
+ }
+ s_saved = *cfg;
+ s_saves++;
+ return HAL_OK;
+}
+
+static void fake_changed(void *ctx)
+{
+ (void)ctx;
+ s_changed++;
+}
+
+void setUp(void)
+{
+ sim_hal_reset();
+ memset(&s_saved, 0, sizeof(s_saved));
+ s_saves = 0;
+ s_save_err = HAL_OK;
+ s_changed = 0;
+ netcfg_init(NULL, fake_save, fake_changed, NULL);
+ TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init());
+ cmd_init(NULL);
+}
+
+void tearDown(void) { TEST_ASSERT_EQUAL_INT(0, sim_crit_depth()); }
+
+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;
+}
+
+static void assert_has_at(const char *resp, const char *needle, int line)
+{
+ UNITY_TEST_ASSERT_NOT_NULL(strstr(resp, needle), line, resp);
+}
+static void assert_lacks_at(const char *resp, const char *needle, int line)
+{
+ UNITY_TEST_ASSERT_NULL(strstr(resp, needle), line, resp);
+}
+#define ASSERT_HAS(r, n) assert_has_at((r), (n), __LINE__)
+#define ASSERT_LACKS(r, n) assert_lacks_at((r), (n), __LINE__)
+
+static int count_rejects(void)
+{
+ evlog_rec_t rec[64];
+ (void)evlog_flush(0);
+ const uint32_t n = evlog_read(0, rec, 64);
+ int k = 0;
+ for (uint32_t i = 0; i < n; i++) {
+ k += rec[i].type == LOG_EV_CMD_REJECT;
+ }
+ return k;
+}
+
+/* ---- 配置命令 ------------------------------------------------------------ */
+
+/* ⭐ USB 来的 wifi / mqtt:存进后端、内存生效、通知网络那一侧重连。 */
+static void test_config_over_usb_is_saved_and_applied(void)
+{
+ ASSERT_HAS(send_on(CMD_LINK_USB,
+ "{\"seq\":1,\"cmd\":\"wifi\",\"ssid\":\"farm-2g\",\"pass\":\"s3cret-pass\"}"),
+ "\"ok\":true");
+ ASSERT_HAS(send_on(CMD_LINK_USB, "{\"seq\":2,\"cmd\":\"mqtt\",\"uri\":\"mqtt://10.5.5.13:1883\","
+ "\"user\":\"rack\",\"pass\":\"m-pass-1\",\"tele_ms\":2000}"),
+ "\"ok\":true");
+ TEST_ASSERT_EQUAL_INT(2, s_saves);
+ TEST_ASSERT_EQUAL_INT(2, s_changed);
+ TEST_ASSERT_EQUAL_STRING("farm-2g", s_saved.ssid);
+ TEST_ASSERT_EQUAL_STRING("s3cret-pass", s_saved.pass); /* 第二次存的是整份:WiFi 那部分还在 */
+ TEST_ASSERT_EQUAL_STRING("mqtt://10.5.5.13:1883", s_saved.uri);
+ TEST_ASSERT_EQUAL_UINT32(2000, s_saved.tele_ms);
+
+ netcfg_t c;
+ netcfg_get(&c);
+ TEST_ASSERT_EQUAL_MEMORY(&s_saved, &c, sizeof(c));
+}
+
+/* ⭐ 经 WiFi 改 WiFi / MQTT 配置:一律 `link`、记事件、后端一次都不碰。 */
+static void test_config_over_wifi_is_refused_and_logged(void)
+{
+ ASSERT_HAS(send_on(CMD_LINK_WIFI,
+ "{\"seq\":1,\"cmd\":\"wifi\",\"ssid\":\"evil\",\"pass\":\"12345678\"}"),
+ "\"err\":\"link\"");
+ ASSERT_HAS(send_on(CMD_LINK_WIFI, "{\"seq\":2,\"cmd\":\"mqtt\",\"uri\":\"mqtt://1.2.3.4\"}"),
+ "\"err\":\"link\"");
+ TEST_ASSERT_EQUAL_INT(0, s_saves);
+ TEST_ASSERT_EQUAL_INT(0, s_changed);
+ TEST_ASSERT_EQUAL_INT(2, count_rejects());
+ /* 只读的 net 经 WiFi 照常可用。 */
+ ASSERT_HAS(send_on(CMD_LINK_WIFI, "{\"cmd\":\"net\"}"), "\"ok\":true");
+}
+
+/* ⭐ 密码永不外泄:写进去之后 net 回应里只有 *_set,没有明文;换哪条链路问都一样。 */
+static void test_net_never_echoes_a_password(void)
+{
+ send_on(CMD_LINK_USB, "{\"seq\":1,\"cmd\":\"wifi\",\"ssid\":\"farm\",\"pass\":\"TopSecret99\"}");
+ send_on(CMD_LINK_USB, "{\"seq\":2,\"cmd\":\"mqtt\",\"uri\":\"mqtt://h:1883\",\"user\":\"u1\","
+ "\"pass\":\"BrokerPw77\"}");
+ for (int link = CMD_LINK_TEST; link <= CMD_LINK_WIFI; link++) {
+ const char *r = send_on((cmd_link_t)link, "{\"cmd\":\"net\"}");
+ ASSERT_LACKS(r, "TopSecret99");
+ ASSERT_LACKS(r, "BrokerPw77");
+ ASSERT_HAS(r, "\"ssid\":\"farm\",\"pass_set\":true");
+ ASSERT_HAS(r, "\"user\":\"u1\",\"mpass_set\":true");
+ ASSERT_HAS(r, "\"uri\":\"mqtt://h:1883\"");
+ ASSERT_HAS(r, "\"tele_ms\":1000"); /* 没给 → 默认 */
+ }
+ /* status 里也不许有。 */
+ ASSERT_LACKS(send_on(CMD_LINK_USB, "{\"cmd\":\"status\"}"), "TopSecret99");
+}
+
+static void test_config_validation(void)
+{
+ static const char *const bad[] = {
+ "{\"seq\":1,\"cmd\":\"wifi\",\"ssid\":\"\",\"pass\":\"12345678\"}", /* 空 SSID */
+ "{\"seq\":2,\"cmd\":\"wifi\",\"ssid\":\"x\",\"pass\":\"1234567\"}", /* 7 位口令 */
+ "{\"seq\":3,\"cmd\":\"mqtt\",\"uri\":\"http://10.5.5.13\"}", /* 不是 mqtt:// */
+ "{\"seq\":4,\"cmd\":\"mqtt\",\"uri\":\"ws://10.5.5.13\"}", /* 不认的 scheme */
+ "{\"seq\":5,\"cmd\":\"mqtt\",\"uri\":\"mqtt://h\",\"tele_ms\":100}", /* 太密 */
+ "{\"seq\":6,\"cmd\":\"mqtt\",\"uri\":\"mqtt://h\",\"tele_ms\":60001}",
+ "{\"seq\":7,\"cmd\":\"mqtt\",\"uri\":\"mqtt://h\",\"tele_ms\":-1}",
+ };
+ for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) {
+ ASSERT_HAS(send_on(CMD_LINK_USB, bad[i]), "\"err\":\"range\"");
+ }
+ ASSERT_HAS(send_on(CMD_LINK_USB, "{\"seq\":8,\"cmd\":\"wifi\",\"pass\":\"12345678\"}"),
+ "\"err\":\"arg\"");
+ ASSERT_HAS(send_on(CMD_LINK_USB, "{\"seq\":9,\"cmd\":\"wifi\",\"ssid\":\"x\",\"pass\":1}"),
+ "\"err\":\"arg\"");
+ ASSERT_HAS(send_on(CMD_LINK_USB, "{\"seq\":12,\"cmd\":\"mqtt\",\"uri\":\"mqtts://\"}"),
+ "\"err\":\"range\""); /* 只有 scheme 没有主机 */
+ ASSERT_HAS(send_on(CMD_LINK_USB, "{\"seq\":13,\"cmd\":\"mqtt\",\"uri\":\"mqtts://home.example:8883\"}"),
+ "\"ok\":true");
+ /* 开放网络(空口令)是合法的;63 位口令顶到上限也合法。 */
+ ASSERT_HAS(send_on(CMD_LINK_USB, "{\"seq\":10,\"cmd\":\"wifi\",\"ssid\":\"open\"}"),
+ "\"ok\":true");
+ char line[200];
+ char pass[64];
+ memset(pass, 'p', 63);
+ pass[63] = '\0';
+ snprintf(line, sizeof(line), "{\"seq\":11,\"cmd\":\"wifi\",\"ssid\":\"x\",\"pass\":\"%s\"}", pass);
+ ASSERT_HAS(send_on(CMD_LINK_USB, line), "\"ok\":true");
+ TEST_ASSERT_EQUAL_STRING(pass, s_saved.pass);
+ TEST_ASSERT_EQUAL_INT(3, s_saves);
+}
+
+/* 存不下:回 io,内存里的配置**不变**,也不通知重连(重启前后一致)。 */
+static void test_unsaved_config_does_not_take_effect(void)
+{
+ send_on(CMD_LINK_USB, "{\"seq\":1,\"cmd\":\"wifi\",\"ssid\":\"old\",\"pass\":\"12345678\"}");
+ s_save_err = HAL_ERR_BUS;
+ ASSERT_HAS(send_on(CMD_LINK_USB,
+ "{\"seq\":2,\"cmd\":\"wifi\",\"ssid\":\"new\",\"pass\":\"12345678\"}"),
+ "\"err\":\"io\"");
+ netcfg_t c;
+ netcfg_get(&c);
+ TEST_ASSERT_EQUAL_STRING("old", c.ssid);
+ TEST_ASSERT_EQUAL_INT(1, s_changed);
+}
+
+/* NVS 里读回来的东西不可信:没带结尾 0 的字段被强制收尾;遥测周期越界回默认;
+ 引号 / 反斜杠 / 控制字符在 net 回应里换成 `?`,回应仍是一行合法 JSON。 */
+static void test_loaded_config_is_sanitised(void)
+{
+ netcfg_t junk;
+ memset(&junk, 'A', sizeof(junk));
+ junk.tele_ms = 5;
+ memcpy(junk.ssid, "a\"b\\c\n", 6);
+ netcfg_init(&junk, NULL, NULL, NULL);
+ netcfg_t c;
+ netcfg_get(&c);
+ TEST_ASSERT_EQUAL_size_t(NETCFG_SSID_MAX, strlen(c.ssid));
+ TEST_ASSERT_EQUAL_size_t(NETCFG_URI_MAX, strlen(c.uri));
+ TEST_ASSERT_EQUAL_UINT32(NETCFG_TELE_DEFAULT_MS, c.tele_ms);
+
+ const char *r = send_on(CMD_LINK_USB, "{\"cmd\":\"net\"}");
+ ASSERT_HAS(r, "\"ssid\":\"a?b?c?AAAA");
+ TEST_ASSERT_EQUAL_CHAR('}', r[strlen(r) - 1]);
+}
+
+/* ---- MQTT 链路的平台无关部分 --------------------------------------------- */
+
+static void test_topics_follow_the_mac(void)
+{
+ const uint8_t mac[6] = { 0xB8, 0x1F, 0x3F, 0xC9, 0xC8, 0xBC };
+ mlink_init(mac);
+ TEST_ASSERT_EQUAL_STRING("b81f3fc9c8bc", mlink_id());
+ TEST_ASSERT_EQUAL_STRING("phonerail/b81f3fc9c8bc/cmd", mlink_topic(MLINK_T_CMD));
+ TEST_ASSERT_EQUAL_STRING("phonerail/b81f3fc9c8bc/rsp", mlink_topic(MLINK_T_RSP));
+ TEST_ASSERT_EQUAL_STRING("phonerail/b81f3fc9c8bc/tele", mlink_topic(MLINK_T_TELE));
+ TEST_ASSERT_EQUAL_STRING("phonerail/b81f3fc9c8bc/state", mlink_topic(MLINK_T_STATE));
+ TEST_ASSERT_TRUE(strlen(mlink_topic(MLINK_T_STATE)) < MLINK_TOPIC_MAX);
+
+ char buf[96];
+ TEST_ASSERT_TRUE(mlink_state_online("0.1.0-a14", "10.5.5.77", buf, sizeof(buf)) > 0);
+ TEST_ASSERT_EQUAL_STRING("{\"online\":true,\"fw\":\"0.1.0-a14\",\"ip\":\"10.5.5.77\"}", buf);
+}
+
+/* 入站消息:与 USB 同一套坏字节规矩;切片的 / 超长的直接拒。 */
+static void test_inbound_messages(void)
+{
+ char line[CMD_LINE_MAX];
+ const char *ok = "{\"cmd\":\"status\"}";
+ TEST_ASSERT_NULL(mlink_inbound(ok, (int)strlen(ok), (int)strlen(ok), line, sizeof(line)));
+ TEST_ASSERT_EQUAL_STRING(ok, line); /* 不带结尾 0 的负载也被正确收尾 */
+
+ const char nul[] = "{\"cmd\":\"status\"}\0junk";
+ TEST_ASSERT_EQUAL_STRING("bad_frame",
+ mlink_inbound(nul, sizeof(nul) - 1, sizeof(nul) - 1, line, sizeof(line)));
+ TEST_ASSERT_EQUAL_STRING("bad_frame", mlink_inbound("{\n}", 3, 3, line, sizeof(line)));
+ TEST_ASSERT_EQUAL_STRING("too_long", mlink_inbound(ok, 5, 100, line, sizeof(line))); /* 切片 */
+ TEST_ASSERT_EQUAL_STRING("too_long", mlink_inbound(ok, 0, 0, line, sizeof(line)));
+ static char big[CMD_LINE_MAX + 8];
+ memset(big, 'x', sizeof(big));
+ TEST_ASSERT_EQUAL_STRING("too_long", mlink_inbound(big, CMD_LINE_MAX, CMD_LINE_MAX, line,
+ sizeof(line)));
+ TEST_ASSERT_NULL(mlink_inbound(big, CMD_LINE_MAX - 1, CMD_LINE_MAX - 1, line, sizeof(line)));
+}
+
+static void test_backoff_doubles_and_caps(void)
+{
+ static const uint32_t want[] = { 1000, 2000, 4000, 8000, 16000, 32000, 60000, 60000 };
+ for (uint32_t i = 0; i < sizeof(want) / sizeof(want[0]); i++) {
+ TEST_ASSERT_EQUAL_UINT32(want[i], mlink_backoff_ms(i));
+ }
+ TEST_ASSERT_EQUAL_UINT32(MLINK_BACKOFF_MAX_MS, mlink_backoff_ms(0xFFFFFFFFu));
+}
+
+/* 遥测节拍:一连上先报一条;之后按整周期推进(被挤晚了不漂);落后一整周期以上
+ 就对齐到现在、不补发;32 位毫秒回绕照样对。 */
+static void test_telemetry_cadence(void)
+{
+ mlink_tele_t t = { 0 };
+ TEST_ASSERT_TRUE(mlink_tele_due(&t, 5000, 1000));
+ TEST_ASSERT_FALSE(mlink_tele_due(&t, 5999, 1000));
+ TEST_ASSERT_TRUE(mlink_tele_due(&t, 6030, 1000)); /* 晚 30 ms */
+ TEST_ASSERT_EQUAL_UINT32(6000, t.last_ms); /* 相位没漂 */
+ TEST_ASSERT_FALSE(mlink_tele_due(&t, 6999, 1000));
+ TEST_ASSERT_TRUE(mlink_tele_due(&t, 12000, 1000)); /* 卡了 5 s */
+ TEST_ASSERT_EQUAL_UINT32(12000, t.last_ms); /* 对齐到现在,不补发 */
+ TEST_ASSERT_FALSE(mlink_tele_due(&t, 12500, 1000));
+
+ mlink_tele_t w = { 0 };
+ TEST_ASSERT_TRUE(mlink_tele_due(&w, 0xFFFFFF00u, 1000));
+ TEST_ASSERT_FALSE(mlink_tele_due(&w, 0x00000100u, 1000)); /* 回绕后才过了 512 ms */
+ TEST_ASSERT_TRUE(mlink_tele_due(&w, 0x00000300u, 1000));
+}
+
+int main(void)
+{
+ UNITY_BEGIN();
+ RUN_TEST(test_config_over_usb_is_saved_and_applied);
+ RUN_TEST(test_config_over_wifi_is_refused_and_logged);
+ RUN_TEST(test_net_never_echoes_a_password);
+ RUN_TEST(test_config_validation);
+ RUN_TEST(test_unsaved_config_does_not_take_effect);
+ RUN_TEST(test_loaded_config_is_sanitised);
+ RUN_TEST(test_topics_follow_the_mac);
+ RUN_TEST(test_inbound_messages);
+ RUN_TEST(test_backoff_doubles_and_caps);
+ RUN_TEST(test_telemetry_cadence);
+ return UNITY_END();
+}
diff --git a/firmware/test/test_t_thermal.c b/firmware/test/test_t_thermal.c
index e757155..9423d5e 100644
--- a/firmware/test/test_t_thermal.c
+++ b/firmware/test/test_t_thermal.c
@@ -84,6 +84,7 @@ void setUp(void)
sim_hal_reset();
fans_up();
set_temp(25000); /* 25 °C:常温开机 */
+ t_thermal_set_floor(0); /* 下限是模块级静态量,不随 init 清零(t_thermal.h) */
}
void tearDown(void) {}
@@ -197,6 +198,34 @@ static void test_unreadable_temperature_means_full_speed(void)
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(25000);
+ 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(25000);
+ 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 —— 一个
哪儿都不存在、但看着很健康的数,而且平均值**永远不会**触发降载。 */
@@ -455,6 +484,7 @@ int main(void)
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);
diff --git a/firmware/test/test_ulink.c b/firmware/test/test_ulink.c
new file mode 100644
index 0000000..8991f83
--- /dev/null
+++ b/firmware/test/test_ulink.c
@@ -0,0 +1,410 @@
+/* A13:USB-Serial-JTAG 分帧(ulink.h)。计划里这一包的验收原话:
+ 「宿主机上用管道灌『命令 + 日志混流』,命令仍能被正确切分」。
+
+ 两个方向各有各的混流:
+ · 上位机 → 板:终端回显、人手敲的回车、别的工具的探测串,与 `@C` 命令混在一起,
+ 而且 USB 按包到达,**切在哪里都有可能**(`\r\n` 中间、CRC 中间、前缀中间);
+ · 板 → 上位机:日志(可能是半行)与 `@R` 回应交错写出,偶尔还有绕开锁的写者插进来。
+ 最后一条用例把两个方向接起来跑一遍真的命令处理器:这就是「管道」。 */
+#include
+#include
+
+#include "cmd.h"
+#include "sim_hal.h"
+#include "t_thermal.h"
+#include "ulink.h"
+#include "unity.h"
+
+void setUp(void) { sim_hal_reset(); }
+void tearDown(void) {}
+
+/* 确定性的伪随机(每次跑切块方式都一样,红了能复现)。 */
+static uint32_t s_rng;
+static uint32_t rnd(uint32_t n)
+{
+ s_rng = s_rng * 1103515245u + 12345u;
+ return (s_rng >> 16) % n;
+}
+
+/* ---- 接收侧的记录器 ------------------------------------------------------ */
+
+#define MAXGOT 32
+typedef struct {
+ int n;
+ ulink_rx_kind_t kind[MAXGOT];
+ char payload[MAXGOT][CMD_LINE_MAX];
+} got_t;
+
+static void collect(void *ctx, ulink_rx_kind_t kind, const char *payload)
+{
+ got_t *g = ctx;
+ TEST_ASSERT_TRUE(g->n < MAXGOT);
+ g->kind[g->n] = kind;
+ snprintf(g->payload[g->n], CMD_LINE_MAX, "%s", payload);
+ g->n++;
+}
+
+/* 把一段字节按随机大小(1~max 字节)切块喂进去。 */
+static void feed_chunked(ulink_rx_t *rx, const char *s, size_t len, uint32_t max, got_t *g)
+{
+ size_t i = 0;
+ while (i < len) {
+ size_t k = 1 + rnd(max);
+ if (k > len - i) {
+ k = len - i;
+ }
+ ulink_rx_feed(rx, (const uint8_t *)s + i, k, collect, g);
+ i += k;
+ }
+}
+
+/* 给一段 JSON 带上 CRC 拼成一行请求。 */
+static void cmd_line(char *out, size_t cap, const char *json)
+{
+ snprintf(out, cap, "@C %s*%04X\r\n", json, (unsigned)ulink_crc16(json, strlen(json)));
+}
+
+/* ---- CRC ----------------------------------------------------------------- */
+
+static void test_crc_is_ccitt_false(void)
+{
+ TEST_ASSERT_EQUAL_HEX16(0x29B1, ulink_crc16("123456789", 9)); /* 标准校验值 */
+ TEST_ASSERT_EQUAL_HEX16(0xFFFF, ulink_crc16("", 0));
+}
+
+/* ---- 接收 ---------------------------------------------------------------- */
+
+/* ⭐ 验收主项(接收方向)。混流里只有 `@C` 开头的行是命令;切块方式换 200 种,
+ 切出来的命令一条不多、一条不少、一个字节不差。 */
+static void test_commands_are_cut_out_of_a_mixed_stream_however_it_is_chunked(void)
+{
+ char a[96], b[96];
+ cmd_line(a, sizeof(a), "{\"seq\":1,\"cmd\":\"status\"}");
+ cmd_line(b, sizeof(b), "{\"seq\":2,\"cmd\":\"fan\",\"duty\":500}");
+ char stream[1024];
+ snprintf(stream, sizeof(stream),
+ "\r\n" /* 人手敲的回车 */
+ "I (1234) phonerail: boot\n" /* 终端回显的日志 */
+ "%s" /* 带 CRC、CRLF */
+ "AT\r\n" /* 别的工具的探测串 */
+ "x@C {\"seq\":9,\"cmd\":\"edl\",\"rail\":1}\n" /* @C 不在行首:不算 */
+ "@C {\"cmd\":\"status\"}\n" /* 不带 CRC:人手敲的,认 */
+ "@Cstatus\n" /* 前缀少了空格:不算 */
+ "%s",
+ a, b);
+ const size_t len = strlen(stream);
+
+ for (uint32_t trial = 0; trial < 200; trial++) {
+ s_rng = trial + 1;
+ ulink_rx_t rx;
+ ulink_rx_init(&rx);
+ got_t g = { 0 };
+ feed_chunked(&rx, stream, len, trial == 0 ? 1 : 1 + trial % 40, &g);
+
+ TEST_ASSERT_EQUAL_INT(3, g.n);
+ for (int i = 0; i < 3; i++) {
+ TEST_ASSERT_EQUAL_INT(ULINK_RX_CMD, g.kind[i]);
+ }
+ TEST_ASSERT_EQUAL_STRING("{\"seq\":1,\"cmd\":\"status\"}", g.payload[0]);
+ TEST_ASSERT_EQUAL_STRING("{\"cmd\":\"status\"}", g.payload[1]);
+ TEST_ASSERT_EQUAL_STRING("{\"seq\":2,\"cmd\":\"fan\",\"duty\":500}", g.payload[2]);
+ TEST_ASSERT_EQUAL_UINT32(5, rx.ignored);
+ }
+}
+
+static void test_bad_crc_is_refused_but_still_names_the_line(void)
+{
+ ulink_rx_t rx;
+ ulink_rx_init(&rx);
+ got_t g = { 0 };
+ const char *s = "@C {\"seq\":3,\"cmd\":\"power\",\"rail\":1,\"on\":true}*0000\n";
+ ulink_rx_feed(&rx, (const uint8_t *)s, strlen(s), collect, &g);
+ TEST_ASSERT_EQUAL_INT(1, g.n);
+ TEST_ASSERT_EQUAL_INT(ULINK_RX_BAD_CRC, g.kind[0]);
+ TEST_ASSERT_EQUAL_STRING("{\"seq\":3,\"cmd\":\"power\",\"rail\":1,\"on\":true}", g.payload[0]);
+
+ /* 小写十六进制也认。 */
+ char line[96];
+ const char *json = "{\"cmd\":\"status\"}";
+ snprintf(line, sizeof(line), "@C %s*%04x\n", json, (unsigned)ulink_crc16(json, strlen(json)));
+ ulink_rx_feed(&rx, (const uint8_t *)line, strlen(line), collect, &g);
+ TEST_ASSERT_EQUAL_INT(ULINK_RX_CMD, g.kind[1]);
+}
+
+/* NUL 不能只是跳过:它会让 JSON 解析在 NUL 处提前结束,后面的垃圾就被放过了。 */
+static void test_nul_and_control_bytes_poison_the_whole_line(void)
+{
+ ulink_rx_t rx;
+ ulink_rx_init(&rx);
+ got_t g = { 0 };
+ const char s[] = "@C {\"cmd\":\"status\"}\0garbage\n@C {\"cmd\":\"sta\x1btus\"}\n";
+ ulink_rx_feed(&rx, (const uint8_t *)s, sizeof(s) - 1, collect, &g);
+ TEST_ASSERT_EQUAL_INT(2, g.n);
+ TEST_ASSERT_EQUAL_INT(ULINK_RX_BAD_BYTES, g.kind[0]);
+ TEST_ASSERT_EQUAL_INT(ULINK_RX_BAD_BYTES, g.kind[1]);
+ TEST_ASSERT_EQUAL_UINT32(0, rx.cmds);
+}
+
+/* 超长的命令行整行丢、报一次;超长的日志行悄悄丢;之后的行不受影响。
+ 正好顶到上限的那一行照收(边界不许差一)。 */
+static void test_overlong_lines_are_dropped_and_the_stream_recovers(void)
+{
+ static char s[4 * ULINK_RX_LINE_MAX];
+ ulink_rx_t rx;
+ ulink_rx_init(&rx);
+ got_t g = { 0 };
+
+ /* 超长命令 */
+ strcpy(s, "@C {\"k\":\"");
+ memset(s + strlen(s), 'a', ULINK_RX_LINE_MAX);
+ strcpy(s + strlen(s), "\"}\n");
+ ulink_rx_feed(&rx, (const uint8_t *)s, strlen(s), collect, &g);
+ /* 超长日志 */
+ memset(s, 'L', 2 * ULINK_RX_LINE_MAX);
+ s[2 * ULINK_RX_LINE_MAX] = '\n';
+ ulink_rx_feed(&rx, (const uint8_t *)s, 2 * ULINK_RX_LINE_MAX + 1, collect, &g);
+ /* 正好上限:前缀 + JSON + CRC = ULINK_RX_LINE_MAX */
+ const size_t jlen = ULINK_RX_LINE_MAX - ULINK_PREFIX_LEN - ULINK_CRC_LEN;
+ char json[ULINK_RX_LINE_MAX];
+ strcpy(json, "{\"k\":\"");
+ memset(json + 6, 'b', jlen - 8);
+ strcpy(json + jlen - 2, "\"}");
+ TEST_ASSERT_EQUAL_size_t(jlen, strlen(json));
+ snprintf(s, sizeof(s), "@C %s*%04X\n", json, (unsigned)ulink_crc16(json, jlen));
+ ulink_rx_feed(&rx, (const uint8_t *)s, strlen(s), collect, &g);
+
+ TEST_ASSERT_EQUAL_INT(2, g.n);
+ TEST_ASSERT_EQUAL_INT(ULINK_RX_TOO_LONG, g.kind[0]);
+ TEST_ASSERT_EQUAL_INT(ULINK_RX_CMD, g.kind[1]);
+ TEST_ASSERT_EQUAL_STRING(json, g.payload[1]);
+ TEST_ASSERT_EQUAL_UINT32(1, rx.too_long);
+ TEST_ASSERT_EQUAL_UINT32(1, rx.ignored);
+}
+
+/* ---- 发送 ---------------------------------------------------------------- */
+
+/* 假 USB:把写出去的字节攒起来。`budget` 是还能写多少(模拟缓冲满)。 */
+typedef struct {
+ char buf[8192];
+ size_t n;
+ size_t budget;
+} wire_t;
+
+static size_t wire_sink(void *ctx, const char *d, size_t len)
+{
+ wire_t *w = ctx;
+ size_t k = len < w->budget ? len : w->budget;
+ TEST_ASSERT_TRUE(w->n + k < sizeof(w->buf));
+ memcpy(w->buf + w->n, d, k);
+ w->n += k;
+ w->budget -= k;
+ w->buf[w->n] = '\0';
+ return k;
+}
+
+/* 把线上的字节逐行过一遍参考解帧器,收集合法回应。 */
+static int deframe_all(wire_t *w, char out[][CMD_OUT_MAX], int max, int *log_lines)
+{
+ int n = 0;
+ char *line = w->buf;
+ *log_lines = 0;
+ for (;;) {
+ char *nl = strchr(line, '\n');
+ if (nl == NULL) {
+ break; /* 最后半行不算数 */
+ }
+ *nl = '\0';
+ if (n < max && ulink_deframe(line, out[n], CMD_OUT_MAX) >= 0) {
+ n++;
+ } else {
+ (*log_lines)++;
+ }
+ line = nl + 1;
+ }
+ return n;
+}
+
+static char s_frames[8][CMD_OUT_MAX];
+
+/* 长度一律取 strlen:手数字符数会把结尾的 NUL 也写上线(本文件第一版就这么错过)。 */
+#define LOG_S(tx, s) ulink_tx_log((tx), (s), strlen(s))
+#define FRAME_S(tx, s) ulink_tx_frame((tx), (s), strlen(s))
+
+/* 半行日志之后来一帧:先补换行,帧从行首开始;日志里自带的 `@R ` 被垫空格,
+ 解帧器绝不会把它当成回应。 */
+static void test_frames_always_start_a_line_and_logs_can_never_pose_as_frames(void)
+{
+ wire_t w = { .budget = sizeof(w.buf) };
+ ulink_tx_t tx;
+ ulink_tx_init(&tx, wire_sink, &w);
+
+ const char *fake = "@R {\"seq\":1,\"ok\":true}*0000";
+ char forged[96];
+ snprintf(forged, sizeof(forged), "@R {\"seq\":1,\"ok\":true}*%04X\n",
+ (unsigned)ulink_crc16("{\"seq\":1,\"ok\":true}", 19)); /* 连 CRC 都对的伪帧 */
+ LOG_S(&tx, "I (5) x: half a line");
+ FRAME_S(&tx, "{\"seq\":7,\"ok\":true}");
+ LOG_S(&tx, forged);
+ LOG_S(&tx, fake);
+ LOG_S(&tx, "\nW (6) x: two\nlines\n");
+ FRAME_S(&tx, "{\"ok\":false,\"err\":\"parse\"}");
+
+ /* 先看原始字节(deframe_all 会把 \n 就地改成 \0)。 */
+ TEST_ASSERT_NOT_NULL(strstr(w.buf, "half a line\n@R {\"seq\":7"));
+ TEST_ASSERT_NOT_NULL(strstr(w.buf, "\n @R {\"seq\":1")); /* 伪帧被垫了空格 */
+
+ int logs = 0;
+ TEST_ASSERT_EQUAL_INT(2, deframe_all(&w, s_frames, 8, &logs));
+ TEST_ASSERT_EQUAL_STRING("{\"seq\":7,\"ok\":true}", s_frames[0]);
+ TEST_ASSERT_EQUAL_STRING("{\"ok\":false,\"err\":\"parse\"}", s_frames[1]);
+ TEST_ASSERT_EQUAL_INT(5, logs);
+}
+
+/* USB 缓冲满、一帧只写出一半:下一帧先补换行,那半帧成了一行 CRC 错的垃圾被丢掉,
+ 下一帧完好。上位机那一侧的现象是「这条超时了」,按同一个 seq 重发即可。 */
+static void test_a_short_write_costs_one_frame_not_the_next(void)
+{
+ wire_t w = { .budget = 10 };
+ ulink_tx_t tx;
+ ulink_tx_init(&tx, wire_sink, &w);
+ TEST_ASSERT_FALSE(FRAME_S(&tx, "{\"seq\":1,\"ok\":true}"));
+ TEST_ASSERT_EQUAL_UINT32(1, tx.short_writes);
+ TEST_ASSERT_FALSE(tx.at_bol);
+
+ w.budget = sizeof(w.buf);
+ TEST_ASSERT_TRUE(FRAME_S(&tx, "{\"seq\":2,\"ok\":true}"));
+ int logs = 0;
+ TEST_ASSERT_EQUAL_INT(1, deframe_all(&w, s_frames, 8, &logs));
+ TEST_ASSERT_EQUAL_STRING("{\"seq\":2,\"ok\":true}", s_frames[0]);
+ TEST_ASSERT_EQUAL_INT(1, logs);
+}
+
+/* 绕开锁的写者(panic / ROM 打印)插进一帧中间:剩下的部分可能仍是合法 JSON,
+ 但 CRC 对不上 —— 必须丢。这就是回应必带 CRC 的理由。 */
+static void test_foreign_bytes_inside_a_frame_are_caught_by_the_crc(void)
+{
+ const char *json = "{\"seq\":4,\"ok\":false,\"err\":\"busy\"}";
+ char line[128];
+ snprintf(line, sizeof(line), "@R %s*%04X", json, (unsigned)ulink_crc16(json, strlen(json)));
+ char out[CMD_OUT_MAX];
+ TEST_ASSERT_EQUAL_INT((int)strlen(json), ulink_deframe(line, out, sizeof(out)));
+
+ /* 把 `"ok":false,"err":"busy"` 中间换掉几个字节,剩下的仍像一条回应。 */
+ char evil[128];
+ snprintf(evil, sizeof(evil), "@R {\"seq\":4,\"ok\":true} *%04X",
+ (unsigned)ulink_crc16(json, strlen(json)));
+ TEST_ASSERT_EQUAL_INT(-1, ulink_deframe(evil, out, sizeof(out)));
+ TEST_ASSERT_EQUAL_INT(-1, ulink_deframe("@R {\"seq\":4,\"ok\":true}", out, sizeof(out))); /* 没 CRC */
+ TEST_ASSERT_EQUAL_INT(-1, ulink_deframe("I (1) x: @R {}", out, sizeof(out)));
+ line[strlen(line) - 1] ^= 1; /* 改 CRC 的一位 */
+ TEST_ASSERT_EQUAL_INT(-1, ulink_deframe(line, out, sizeof(out)));
+}
+
+static void test_link_errors_echo_the_seq_when_they_can(void)
+{
+ char out[96];
+ ulink_link_error("{\"seq\":12,\"cmd\":\"edl\",\"rail\":1}", "bad_crc", out, sizeof(out));
+ TEST_ASSERT_EQUAL_STRING("{\"seq\":12,\"ok\":false,\"err\":\"bad_crc\"}", out);
+ ulink_link_error("", "too_long", out, sizeof(out));
+ TEST_ASSERT_EQUAL_STRING("{\"ok\":false,\"err\":\"too_long\"}", out);
+ ulink_link_error("{\"seq\":0}", "queue_full", out, sizeof(out));
+ TEST_ASSERT_EQUAL_STRING("{\"ok\":false,\"err\":\"queue_full\"}", out);
+ TEST_ASSERT_NULL(ulink_rx_err_str(ULINK_RX_CMD));
+ TEST_ASSERT_EQUAL_STRING("bad_frame", ulink_rx_err_str(ULINK_RX_BAD_BYTES));
+}
+
+/* ---- ⭐ 管道:两个方向接起来,过一遍真的命令处理器 ------------------------ */
+
+typedef struct {
+ ulink_tx_t *tx;
+ wire_t *w;
+ int lines;
+} pipe_t;
+
+/* 链路任务的回调:命令进处理器、回应成帧;链路层错误直接回。每回应一次,
+ 就在前后随机塞几段半行日志 —— 模拟别的任务在同一时刻打日志。 */
+static void pipe_rx(void *ctx, ulink_rx_kind_t kind, const char *payload)
+{
+ pipe_t *p = ctx;
+ static char out[CMD_OUT_MAX];
+ size_t n;
+ if (kind == ULINK_RX_CMD) {
+ n = cmd_handle_line(CMD_LINK_USB, payload, out, sizeof(out));
+ } else {
+ n = ulink_link_error(payload, ulink_rx_err_str(kind), out, sizeof(out));
+ }
+ static const char *const junk[] = { "I (9) t_safety: CH1 ", "3901 mV\n", "@R fake\n",
+ "E (9) x: oops\n", "" };
+ const char *before = junk[rnd(5)];
+ ulink_tx_log(p->tx, before, strlen(before));
+ ulink_tx_frame(p->tx, out, n);
+ const char *after = junk[rnd(5)];
+ ulink_tx_log(p->tx, after, strlen(after));
+ p->lines++;
+}
+
+static void test_pipe_mixed_stream_through_the_real_processor(void)
+{
+ cmd_init(NULL);
+ t_thermal_set_floor(0);
+
+ char in[2048] = "garbage line\r\n";
+ char l[128];
+ cmd_line(l, sizeof(l), "{\"seq\":100,\"cmd\":\"fan\",\"duty\":600}");
+ strcat(in, l);
+ strcat(in, "I (1) echo\n");
+ cmd_line(l, sizeof(l), "{\"seq\":100,\"cmd\":\"fan\",\"duty\":600}"); /* 重传 */
+ strcat(in, l);
+ strcat(in, "@C {\"seq\":101,\"cmd\":\"fan\",\"duty\":700}*BEEF\n"); /* CRC 错 */
+ cmd_line(l, sizeof(l), "{\"seq\":102,\"cmd\":\"status\"}");
+ strcat(in, l);
+ strcat(in, "@C not json\n");
+
+ for (uint32_t trial = 1; trial <= 50; trial++) {
+ s_rng = trial;
+ cmd_init(NULL);
+ static wire_t w;
+ memset(&w, 0, sizeof(w));
+ w.budget = sizeof(w.buf);
+ ulink_tx_t tx;
+ ulink_tx_init(&tx, wire_sink, &w);
+ pipe_t p = { .tx = &tx, .w = &w };
+ ulink_rx_t rx;
+ ulink_rx_init(&rx);
+ size_t i = 0;
+ const size_t len = strlen(in);
+ while (i < len) {
+ size_t k = 1 + rnd(1 + trial % 30);
+ if (k > len - i) {
+ k = len - i;
+ }
+ ulink_rx_feed(&rx, (const uint8_t *)in + i, k, pipe_rx, &p);
+ i += k;
+ }
+
+ int logs = 0;
+ TEST_ASSERT_EQUAL_INT(5, deframe_all(&w, s_frames, 8, &logs));
+ TEST_ASSERT_EQUAL_STRING("{\"seq\":100,\"ok\":true,\"floor\":600}", s_frames[0]);
+ TEST_ASSERT_EQUAL_STRING("{\"seq\":100,\"ok\":true,\"dup\":true}", s_frames[1]);
+ TEST_ASSERT_EQUAL_STRING("{\"seq\":101,\"ok\":false,\"err\":\"bad_crc\"}", s_frames[2]);
+ TEST_ASSERT_EQUAL_STRING_LEN("{\"seq\":102,\"ok\":true,\"fw\":", s_frames[3], 26);
+ TEST_ASSERT_EQUAL_STRING("{\"ok\":false,\"err\":\"parse\"}", s_frames[4]);
+ TEST_ASSERT_EQUAL_UINT16(600, t_thermal_floor()); /* CRC 错的那条 700 没生效 */
+ }
+}
+
+int main(void)
+{
+ UNITY_BEGIN();
+ RUN_TEST(test_crc_is_ccitt_false);
+ RUN_TEST(test_commands_are_cut_out_of_a_mixed_stream_however_it_is_chunked);
+ RUN_TEST(test_bad_crc_is_refused_but_still_names_the_line);
+ RUN_TEST(test_nul_and_control_bytes_poison_the_whole_line);
+ RUN_TEST(test_overlong_lines_are_dropped_and_the_stream_recovers);
+ RUN_TEST(test_frames_always_start_a_line_and_logs_can_never_pose_as_frames);
+ RUN_TEST(test_a_short_write_costs_one_frame_not_the_next);
+ RUN_TEST(test_foreign_bytes_inside_a_frame_are_caught_by_the_crc);
+ RUN_TEST(test_link_errors_echo_the_seq_when_they_can);
+ RUN_TEST(test_pipe_mixed_stream_through_the_real_processor);
+ return UNITY_END();
+}
diff --git a/firmware/tools/prlink.py b/firmware/tools/prlink.py
new file mode 100644
index 0000000..4e4b7da
--- /dev/null
+++ b/firmware/tools/prlink.py
@@ -0,0 +1,157 @@
+#!/usr/bin/env python3
+"""PhoneRail USB 命令链路的 PC 端客户端(A13,需求 §10.8 / firmware/main/ulink.h)。
+
+ python prlink.py COM5 status
+ python prlink.py COM5 '{"cmd":"key","rail":3,"keys":"pwr","ms":100}'
+ python prlink.py COM5 status --logs # 同时把板子日志打到 stderr
+ python prlink.py --selftest # 只跑本地的 CRC / 解帧自检,不碰串口
+
+线上格式(与 ulink.h 一致,改一边必须改另一边):
+ 发 @C *XXXX\\n XXXX = CRC-16/CCITT-FALSE(json),4 位大写十六进制
+ 收 @R *XXXX\\n CRC 必带;对不上的整行丢弃
+ 其余任何一行都是日志。
+
+两条上位机侧的规矩(cmd.h):
+ · seq 由这里生成,**重传用同一个 seq**:超时后原样再发,板子判重兜住,
+ 不会把「按 10 秒电源键」执行两遍;想「再试一次」就是一条新命令、新 seq。
+ · 打开串口时 **DTR / RTS 都保持低**。USB-Serial-JTAG 会把特定的 DTR/RTS 组合
+ 当成复位 / 进下载模式的信号(esptool 就是这么做的),默认打开方式可能把板子复位。
+"""
+import argparse
+import json
+import sys
+import time
+
+CMD_PREFIX = "@C "
+RSP_PREFIX = "@R "
+
+
+def crc16(data: bytes) -> int:
+ crc = 0xFFFF
+ for b in data:
+ crc ^= b << 8
+ for _ in range(8):
+ crc = ((crc << 1) ^ 0x1021) if (crc & 0x8000) else (crc << 1)
+ crc &= 0xFFFF
+ return crc
+
+
+def frame(obj: dict) -> bytes:
+ js = json.dumps(obj, separators=(",", ":"), ensure_ascii=True)
+ return f"{CMD_PREFIX}{js}*{crc16(js.encode()):04X}\n".encode()
+
+
+def deframe(line: str):
+ """一行输出 → 回应 dict;日志 / CRC 错 / 不带 CRC 都返回 None(同 ulink_deframe)。"""
+ line = line.rstrip("\r")
+ if not line.startswith(RSP_PREFIX) or len(line) < len(RSP_PREFIX) + 5:
+ return None
+ body = line[len(RSP_PREFIX):]
+ js, star, tail = body[:-5], body[-5], body[-4:]
+ if star != "*":
+ return None
+ try:
+ want = int(tail, 16)
+ except ValueError:
+ return None
+ if crc16(js.encode()) != want:
+ return None
+ try:
+ return json.loads(js)
+ except ValueError:
+ return None
+
+
+def new_seq() -> int:
+ """跨进程也不重复的 seq:0.1 s 分辨率的时间戳,落在 1~2^32−1。
+ 板子的判重窗口按「值相等」判(cmd.h),不要求单调,只要求不撞。"""
+ return int(time.time() * 10) % 0xFFFFFFFF + 1
+
+
+class Link:
+ def __init__(self, port: str, show_logs: bool = False):
+ import serial # 只有真要连串口时才需要 pyserial
+
+ self.ser = serial.Serial()
+ self.ser.port = port
+ self.ser.baudrate = 115200 # USB-Serial-JTAG 不看波特率,填什么都行
+ self.ser.timeout = 0.05
+ self.ser.dtr = False # 见文件头:先置低再 open
+ self.ser.rts = False
+ self.ser.open()
+ self.show_logs = show_logs
+ self.buf = b""
+
+ def close(self):
+ self.ser.close()
+
+ def _lines(self, deadline: float):
+ while time.monotonic() < deadline:
+ # 有多少读多少;什么都没有时读 1 字节、最多等 timeout。
+ # 写成 read(512) 的话,凑不满 512 字节每次都要白等满 timeout。
+ chunk = self.ser.read(self.ser.in_waiting or 1)
+ if chunk:
+ self.buf += chunk
+ while b"\n" in self.buf:
+ raw, self.buf = self.buf.split(b"\n", 1)
+ yield raw.decode("utf-8", errors="replace")
+
+ def call(self, obj: dict, timeout: float = 1.0, retries: int = 3) -> dict:
+ obj = dict(obj)
+ if "seq" not in obj:
+ obj["seq"] = new_seq()
+ data = frame(obj)
+ for _ in range(retries):
+ self.ser.write(data)
+ for line in self._lines(time.monotonic() + timeout):
+ rsp = deframe(line)
+ if rsp is None:
+ if self.show_logs and line.strip():
+ print(line, file=sys.stderr)
+ continue
+ if rsp.get("seq") == obj["seq"]:
+ return rsp
+ # 别的 seq 的回应(上一条命令迟到的那一份):丢掉接着等
+ raise TimeoutError(f"seq {obj['seq']} 等了 {retries} 次都没有回应")
+
+
+def selftest() -> int:
+ assert crc16(b"123456789") == 0x29B1
+ js = '{"seq":7,"ok":true}'
+ good = f"@R {js}*{crc16(js.encode()):04X}"
+ assert deframe(good) == {"seq": 7, "ok": True}
+ assert deframe(good + "\r") == {"seq": 7, "ok": True}
+ assert deframe(good[:-1] + ("0" if good[-1] != "0" else "1")) is None # CRC 错
+ assert deframe(f"@R {js}") is None # 没 CRC
+ assert deframe(f" {good}") is None # 不在行首(日志里垫过空格的伪帧)
+ assert deframe("I (1) x: hello") is None
+ f = frame({"cmd": "status", "seq": 1}).decode()
+ assert f.startswith('@C {"cmd":"status","seq":1}*') and f.endswith("\n")
+ print("prlink 自检通过")
+ return 0
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ ap.add_argument("port", nargs="?")
+ ap.add_argument("command", nargs="?", help='命令名(如 status)或一整段 JSON')
+ ap.add_argument("--logs", action="store_true", help="把板子日志打到 stderr")
+ ap.add_argument("--timeout", type=float, default=1.0)
+ ap.add_argument("--selftest", action="store_true")
+ a = ap.parse_args()
+ if a.selftest:
+ return selftest()
+ if not a.port or not a.command:
+ ap.error("需要 port 与 command")
+ obj = json.loads(a.command) if a.command.lstrip().startswith("{") else {"cmd": a.command}
+ link = Link(a.port, show_logs=a.logs)
+ try:
+ rsp = link.call(obj, timeout=a.timeout)
+ finally:
+ link.close()
+ print(json.dumps(rsp, ensure_ascii=False))
+ return 0 if rsp.get("ok") else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/固件开发计划.md b/固件开发计划.md
index 1c4d452..2c2fb9d 100644
--- a/固件开发计划.md
+++ b/固件开发计划.md
@@ -528,7 +528,7 @@ R-3 依赖 R-1+R-2;R-4 依赖 R-3;R-5 / R-6 随时可插。
| 里程碑 | 判据 | 依赖硬件 |
|---|---|---|
| **M1 逻辑跑通** | A1~A16 + R-0~R-6 全绿;模拟器上能完成一次完整的「崩溃 → 软重启 → 硬重启」 | ❌ 不需要 |
-| └ 进度 | **R-0~R-6 ✅ 完成**(2026-09-16,351 例);A1~A11 ✅;**还欠 A12~A16** | |
+| └ 进度 | **R-0~R-6 ✅ 完成**(2026-09-16,351 例);A1~A11 ✅;**A12 ✅**(2026-09-27,380 例);**A13 ✅**(同日,390 例,已上板);**A14 + C1 ✅**(同日,400 例,已上板真连网);**还欠 A15 / A16** | |
| **M2 板子活了** | §12.1 的 17 步 + B1b / B1c / B8b 全过,尤其 B3 / B4 | ✅ |
| **M3 交付** | 五台 24 h 无重启无告警;双链路验收通过 | ✅ |
@@ -559,7 +559,7 @@ R-3 依赖 R-1+R-2;R-4 依赖 R-3;R-5 / R-6 随时可插。
| **CH224A 的 I²C 从机地址** | R2 §36.2 只写了寄存器(`0x09` 状态 / `0x50` 档位电流 / `0x0A` 禁写),没写地址。**驱动做成地址可配**,B1 扫描时定死,然后回填本表与 §5.2 |
| **TCA9548A 失联的判定次数 N** | §5.8 的「连续失败 N 次」。N=1 太敏感(一次毛刺停五台),N=5 就是 100 ms。**B1c 实测总线误码率之后定**,先按 3 写、常量单列 |
| ⭐ **选通道「写下去不报错但没生效」要不要加周期性回读** | R-0 的测试写完之后发现的一个**模拟器造不出来的**失效:上拉太弱、某条支路被某个器件拉死时,`tca9548a_select()` 返回 `HAL_OK` 而通道其实没切过去。假器件的写永远生效,所以宿主机上一条都复现不了。真板上的表现是**「一直读到另一片」,而 `i2cbus_select_fail_streak()` 恒为 0 ⇒ §5.8 的 mux 失联判据完全失灵**。
两条路:① B1 上板时用逻辑分析仪实测一次,确认这种失效在这块板上是否可能(`R602`/`R603` 的上拉值、支路电容);② 给 `i2cbus` 加一条**低频回读校验**(比如每 100 圈回读一次控制字,与影子比对)。回读一次是 50 µs,摊到 100 圈可以忽略。**B1 之前不做**——现在加就是为一个还不知道存不存在的失效付复杂度 |
-| MQTT broker 与 topic 约定 | 依赖农场既有系统,A14 之前需要对方给出 |
+| ~~MQTT broker 与 topic 约定~~ | **2026-09-27 改由本项目自定**(需求 D7:broker 在 home 服务器,前后端也由本项目做)。A14 开工时一并定 topic,写回需求 §10.8 |
| `input_suspend` 节点路径 | 与需求 V-8 是同一件事,首台机上确认 |
| ~~ESP-IDF 版本~~ | **已定 v5.4**(`D:/Users/35138/esp/v5.4/esp-idf`)。构建走 `firmware/build_idf.ps1` |
| ~~单元测试框架~~ | **已定 Unity**(`firmware/third_party/unity/`)+ 宿主机 CMake/CTest,工具链 cygwin |
diff --git a/需求文档.md b/需求文档.md
index fa61fad..6a5b822 100644
--- a/需求文档.md
+++ b/需求文档.md
@@ -58,7 +58,8 @@ USB 通断」这些原本要人手做的动作全部电气化,交给板上 MCU
| D3 | 两板对插的板对板接口(两条 2×20 排针,共 80 针) |
| D4 | 手机侧线束与离板电容组(电池排线改造、尾插 USB 线、EDL 引线) |
| D5 | ESP32-S3 固件(遥测、两级重启、按键 / EDL 模拟、VBUS 通断、风扇闭环、告警) |
-| D5a | **双链路命令接口**(2026-09-06 用户拍板):**WiFi 为常态、USB CDC 为兜底**,两条链路共用同一套命令语义。上位机侧的调用方由农场既有系统实现(N7),本项目交付板侧协议与实现,见 §10.8 |
+| D5a | **双链路命令接口**(2026-09-06 用户拍板):**WiFi 为常态、USB CDC 为兜底**,两条链路共用同一套命令语义。本项目交付板侧协议与实现,见 §10.8;上位机见 D7 |
+| D7 | **机架管理前后端**(2026-09-27 用户拍板,取代原 N7):**架构 A 纯中心化** —— home 服务器(10.5.5.13)上跑 mosquitto + Python(FastAPI)后端 + 时序库 + Vue3 前端,走统一门户;板子经 WiFi MQTT 直连。首版功能:实时总览、手动操作、自动崩溃恢复(**服务器侧 adb over WiFi 探活 + 电流曲线确认**,先软后硬)、历史曲线与事件。**不做机架旁 agent,USB CDC 只用于上架配网与调试**(代价:WiFi 故障时没有远程救援通道,见 §10.8)。开发顺序:先补固件 A12/A13/A14,再写前后端。**2026-09-27 已上线**:代码 `rack/`(说明见 `rack/README.md`),服务 `phonerail-rack`(127.0.0.1:7764),门户 key `rack` |
| D6 | 完整原理图(11 页)+ 两块 PCB + BOM + 首板调试与验收记录 |
### 2.2 明确不做(Out of Scope)
@@ -71,7 +72,7 @@ USB 通断」这些原本要人手做的动作全部电气化,交给板上 MCU
| N4 | USB PD / BC1.2 协商,接快充充电器 | 决策 11:上游只接电脑 / Hub,VBUS 恒 5 V |
| N5 | 把过流保护做进 MCU 实时环路 | 那是 eFuse 的职责(SR-3) |
| N6 | 五路 Buck 错相同步 | §8.4 已否决:器件离散度远大于想制造的相位差 |
-| N7 | 上位机管理软件 / Web 界面 | 本期只交付固件与命令接口,上层由农场既有系统对接 |
+| ~~N7~~ | ~~上位机管理软件 / Web 界面~~ | **2026-09-27 撤销**,改为交付范围 D7 |
---
@@ -783,6 +784,70 @@ ESP32.KEY_SER → U21 → U22 → U33 (先移入的字节落在链尾 U
- **命令必须幂等或带序号**:链路重传时不能让「按 10 秒电源键」执行两遍(上面第 3 条)。
- **链路失联不触发任何动作**(10.2):没有心跳超时关输出这回事。
+#### A12 定稿的线上格式(2026-09-27,`firmware/main/cmd.h`)
+
+一行一条、单层扁平 JSON(嵌套 / 数组 / 转义 / 浮点 / null / 重复键一律 `parse`)。
+
+```
+→ {"seq":17,"cmd":"key","rail":3,"keys":"pwr","ms":10000}
+← {"seq":17,"ok":true}
+← {"seq":17,"ok":false,"err":"range"} 重传命中:{"seq":17,"ok":true,"dup":true}
+```
+
+| 命令 | 参数 | 与上表的差别 |
+|---|---|---|
+| `status` | — | 只读,seq 可省,不判重。读不到的量回 `null`(不是 0) |
+| `set_voltage` | `rail`, `mv` | 限 3524~4010(DAC 物理范围,比 SR-13 的 4.2 V 更紧) |
+| `power_cycle` | `rail` | **不收断电秒数**:固定 4 s(§7.4 的 3~5 s 窗口) |
+| `key` | `rail`, `keys`(`"pwr"` / `"vdn+pwr"`…), `ms`(1~15000) | 单按电源键 ≥10 s 记一条软重启事件 |
+| `edl` | `rail` | **不收 on/off**:跑一遍 SW-6 固定序列;退出 EDL 用 `power_cycle` |
+| `vbus` | `rail`, `on` | eFuse 关着时 `on:true` 拒(`state`);序列进行中拒(`busy`) |
+| `power` ⭐新增 | `rail`, `on` | 管理界面要能停一台。关 = 先断 VBUS 再关 eFuse;VBUS 断不掉也照样关 eFuse,回 `io` |
+| `fan` | `duty`(0~1000,0 = 自动) | **只是下限**:实际 = max(温度曲线, duty),远程不能把风扇调小 |
+| `events` | `from`(可省) | 一次最多 8 条 `[序号,上电ms,路,类型,arg,mV,µA]`,类型是 `log_ev_t` 的冻结数字 |
+
+- 会动东西的 7 条**必须带 seq(1~2³²−1)**;判重窗口 32 条、全局、按值相等判。
+ 上位机同一个 seq 只用于同一条命令的重传,「再试一次」要换新 seq。
+- 错误码:`parse / unknown_cmd / no_seq / arg / range / empty / busy / state / io / overflow`。
+ `range / empty / state` 记 `LOG_EV_CMD_REJECT`(arg = 命令号,bus_mv = 越界值,current_ua = 原因号),其余不记。
+- 空槽(`PRSNT#` 高)的按路命令一律 `empty`。
+- 板子重启会清空判重窗口;上位机看 `status.up` 变小识别重启。
+
+#### A13 定稿的 USB 分帧(2026-09-27,`firmware/main/ulink.h`)
+
+```
+上位机 → 板 @C [*XXXX]
+ CRC 可省(给人手敲);带了就必须对
+板 → 上位机 @R *XXXX
+ CRC 必带;XXXX = CRC-16/CCITT-FALSE(json),4 位大写十六进制
+其余任何一行 = 日志
+```
+
+- 前缀只在行首算数;日志行若以 `@` 开头,固件会垫一个空格 ⇒ 日志永远不会被当成帧。
+- 链路层自己的错误(不进命令处理器、不记事件):`too_long` / `bad_crc` / `bad_frame`(NUL 或控制字符)/ `queue_full`,能解析出 seq 就回显。
+- **USB 副控制台已关**(`CONFIG_ESP_CONSOLE_SECONDARY_NONE`):USB 上只有「日志钩子的副本 + 回应」两路,同一把锁;`printf` / panic 只去 UART0(J33)。
+- 上位机打开串口时 **DTR / RTS 保持低**,否则可能把板子复位。参考实现 `firmware/tools/prlink.py`。
+- 实测(板 B,2026-09-27):200 条 status 连发 0 坏帧,混进的 `t_safety` 日志全部正确识别;往返 status ≈3 ms、小命令 <1 ms。
+
+#### A14 定稿的 MQTT 链路(2026-09-27,`firmware/main/mlink.h` / `netcfg.h`)
+
+base = `phonerail/`,id = 板子 STA MAC 的 12 位小写十六进制(板 B = `b81f3fc9c8bc`)。
+
+| topic | 方向 | QoS | retain | 内容 |
+|---|---|---|---|---|
+| `/cmd` | 服务器 → 板 | 1 | 否 | 一条命令 JSON(与 USB `@C` 负载相同,无前缀无 CRC) |
+| `/rsp` | 板 → 服务器 | 1 | 否 | 一条回应 JSON |
+| `/tele` | 板 → 服务器 | 0 | 否 | 周期遥测 = 一条 status 回应(无 seq),默认 1 s(`tele_ms` 200~60000) |
+| `/state` | 板 → 服务器 | 1 | 是 | `{"online":true,"fw":..,"ip":..}`;掉线由遗嘱改成 `{"online":false}`(keepalive 15 s) |
+
+- **配网(C1)**:`wifi`(ssid / pass)与 `mqtt`(uri / user / pass / tele_ms)两条命令,存 NVS,**只收 USB**(经 WiFi 来回 `link` 并记事件)。
+ `net` 查网络状态,**永不回显密码**(只报 `pass_set` / `mpass_set`);含 `wifi_reason`(最近一次断开原因码)。
+- uri 收 `mqtts://`(TLS,CA 编在固件:`firmware/main/certs/evse_ca.pem`)或 `mqtt://`(只该用在局域网)。
+- **broker = home 服务器现有 mosquitto 的 8883(TLS)**,与 evse 共用:板子用户名 = id,ACL 用 `pattern … phonerail/%u/…`
+ 只许碰自己的四个 topic;后端用户 `phonerail-backend` 可读写 `phonerail/#`。口令在服务器 `/root/phonerail/mqtt.env`(仅 root)。
+- 失联不动作:WiFi 断按 1/2/4…60 s 退避重连,MQTT 由 esp-mqtt 每 10 s 重连;没有任何分支会碰输出。
+- 实测(板 B,2026-09-27):经 WiFi `CANBOX-5G` → 公网 → 8883 TLS 连上;判重 / `link` / `empty` / `parse` 回应与遥测(1 条/s)全部符合。
+
### 10.9 启动顺序
```
@@ -858,6 +923,7 @@ FR-12 是「中(可选)」,建议**首版先不做**,把接口留出来
### 11.2 假设(若不成立需重新评估)
| ID | 假设 |
+| **MQTT broker 与 topic** | 2026-09-27 起由本项目自定(D7,broker 在 home 服务器);topic 约定随 A14 写进 §10.8 |
|---|---|
| **A-1** | 上游 USB 恒为 5 V(只接电脑 / Hub,不接快充头) |
| **A-2** | 手机关机待机电流在 ≈50 µA 量级 |