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 量级 |