固件:A12 命令处理器 + A13 USB 分帧 + A14 WiFi/MQTT(含 C1 配网)
- A12 单一命令处理器(cmd*.c):一行 JSON 进出、seq 全局判重(32 条按值)、
限幅 / 空槽一律拒 / 拒绝记事件;命令号与错误码冻结(进 flash)。新增 power 命令,
fan 改为只抬不压的下限(t_thermal_set_floor)。rail_set_mv 注明不拿锁,调用方包 xact。
- A13 USB-Serial-JTAG 分帧(ulink*.c):@C / @R 前缀 + CRC-16,日志行首 @ 垫空格,
任意切块可切;关掉 USB 副控制台(它绕开驱动写 FIFO)。PC 端客户端 tools/prlink.py。
- A14 + C1(netcfg / mlink / cmd_net):topic phonerail/<mac>/{cmd,rsp,tele,state},
mqtts(evse-ca 编进固件)、1 s 遥测、遗嘱离线;wifi / mqtt 配置只收 USB、存 NVS,
net 永不回显密码。
- 协议定稿写进需求文档 §10.8(A12 / A13 / A14 三节);N7 撤销改 D7(机架管理前后端)。
宿主机 23 个 ctest / 400 例全绿;板 B 已烧 0.1.0-a14,经 WiFi → 公网 → 8883 TLS 真连网验过。
固件 0xE2220 / 1 MB 分区只剩 12%,C2 前要重排分区表。
Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
+31
-66
@@ -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`,经 `<base>/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)。
|
||||
|
||||
@@ -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 目标编)。
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
+14
-2
@@ -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)
|
||||
|
||||
+73
-8
@@ -1,11 +1,15 @@
|
||||
/* ESP32-S3 入口。只在 ESP-IDF 目标里编译,宿主机构建不包含本文件。 */
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#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` 分区时日志落不了盘,但五路照供电
|
||||
|
||||
@@ -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-----
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
/* 单一命令处理器:解析 → 序号判重 → 限幅 / 空槽 → 执行 → 回应。契约在 cmd.h。 */
|
||||
#include "cmd.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
+135
@@ -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 <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#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 */
|
||||
@@ -0,0 +1,239 @@
|
||||
/* 六条「会动东西的命令」的执行体 + 风扇下限(A12,需求 §10.8)。
|
||||
|
||||
每一条都是同一个形状:**路号 → 在位 → 参数限幅 → 忙不忙 → 下发**。
|
||||
限幅与拒绝原因全在这里,适配器一个字都不碰(cmd.h 规矩 1)。
|
||||
判重、名字表、回应拼装在 cmd.c;status / events 的正文在 cmd_report.c。 */
|
||||
#include <string.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/* 扁平 JSON 解析 + 定长写入器。契约与「刻意不支持」的清单在 cmd_json.h。 */
|
||||
#include "cmd_json.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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 <stdarg.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#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 */
|
||||
@@ -0,0 +1,107 @@
|
||||
/* 网络的三条命令(A14 / C1,需求 IF-19):`wifi` / `mqtt` 写配置(**只收 USB**),`net` 读状态。
|
||||
|
||||
`net` 的回应里**永远没有密码**,只有 `pass_set` / `mpass_set`(netcfg.h「密码永不外泄」)。 */
|
||||
#include <string.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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 <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#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 */
|
||||
@@ -0,0 +1,132 @@
|
||||
/* `status` 与 `events` 两条只读命令的正文(A12,需求 §10.8)。
|
||||
|
||||
两条都是**只读、幂等**的,所以不进判重窗口(cmd.h)。
|
||||
输出一律是整数(mV / µA / m°C / 千分比 / ms),不引浮点,和固件内部口径一致;
|
||||
读不到的量写 `null`,**不写 0** —— 0 mA 与「这一圈没读到」是两回事,
|
||||
上位机的卡死判据(电流曲线)拿 0 当真值会直接误判。 */
|
||||
#include <stdio.h>
|
||||
|
||||
#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, "]");
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/* MQTT 链路的平台无关部分。topic 表与各条规矩在 mlink.h。 */
|
||||
#include "mlink.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/* WiFi/MQTT 链路的平台无关部分(A14,需求 §10.8「A14 定稿的 MQTT topic」)。
|
||||
ESP 胶水(WiFi 驱动、esp-mqtt、NVS)在 mlink_esp.c。
|
||||
|
||||
---- topic(base = `phonerail/<id>`,id = STA MAC 12 位小写十六进制)----------
|
||||
|
||||
| topic | 方向 | QoS | retain | 内容 |
|
||||
|---|---|---|---|---|
|
||||
| `<base>/cmd` | 服务器 → 板 | 1 | 否 | 一条命令 JSON(与 USB 的 `@C` 负载完全相同,**不带前缀与 CRC**) |
|
||||
| `<base>/rsp` | 板 → 服务器 | 1 | 否 | 一条回应 JSON |
|
||||
| `<base>/tele` | 板 → 服务器 | 0 | 否 | 周期遥测 = 一条 `status` 回应(无 seq),默认 1 s |
|
||||
| `<base>/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 <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#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 */
|
||||
@@ -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 <string.h>
|
||||
|
||||
#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++);
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -0,0 +1,146 @@
|
||||
/* 网络配置与状态。契约(尤其「密码永不外泄」与跨任务那两段)在 netcfg.h。 */
|
||||
#include "netcfg.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#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 "?";
|
||||
}
|
||||
@@ -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 <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#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 */
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -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。读的是**设定值**,不是实测输出 ——
|
||||
|
||||
+17
-1
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/* USB 链路分帧。格式与两条「为什么」在 ulink.h。 */
|
||||
#include "ulink.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
@@ -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 <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#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 <json>*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 */
|
||||
@@ -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 <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#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<json>[*CRC],回应 %s<json>*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;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/* USB-Serial-JTAG 命令链路的 ESP 胶水(A13)。只在 IDF 目标里有;说明在 ulink_esp.c 文件头。 */
|
||||
#ifndef ULINK_ESP_H
|
||||
#define ULINK_ESP_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#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 */
|
||||
@@ -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。
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
/* A12:单一命令处理器(需求 §10.8,计划 §6.3)。验收原话三条:
|
||||
① 同序号发两遍只执行一次;
|
||||
② 越界命令被拒且记事件;
|
||||
③ 空槽(`PRSNT#` 高)的命令一律拒。
|
||||
|
||||
和 A9 的测试同一个口径:断言一律打在**线上真实发生的事**上 ——
|
||||
假 PCA9555 的锁存器与写事务计数、DAC 寄存器、`EFUSE_EN` 引脚电平、落盘的事件 ——
|
||||
不看处理器自己回了什么。「回了 ok」和「真的按了一次」是两件事,
|
||||
判重写错的现象恰恰是回应完全正常、手机被按了两遍。 */
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#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();
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/* A12:命令行的扁平 JSON 解析器(cmd_json.h)。
|
||||
|
||||
这一层的测试重点不是「合法的能解出来」,而是**不合法的一律拒**:
|
||||
请求形状越窄,越界输入的面就越小。每一条「刻意不支持」都在这里钉一条用例 ——
|
||||
哪天有人为了「兼容」放宽了其中一条,这里先红。 */
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#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();
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
/* A14 / C1:网络配置、MQTT 链路的平台无关部分、三条网络命令。
|
||||
|
||||
计划里 A14 的验收原话是「协议层用 sim 传输测通;真连网留到 B 段」。
|
||||
这里测的是会写错而且上板查不出来的那几件事:
|
||||
· 密码**永远**不出现在任何回应里;
|
||||
· 配置命令**只收 USB**,经 WiFi 来的一律拒并记事件;
|
||||
· 没存进 flash 的配置不生效(重启前后一致);
|
||||
· MQTT 入站消息与 USB 同一套坏字节规矩;topic / 退避 / 遥测节拍的数。 */
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#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();
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
/* A13:USB-Serial-JTAG 分帧(ulink.h)。计划里这一包的验收原话:
|
||||
「宿主机上用管道灌『命令 + 日志混流』,命令仍能被正确切分」。
|
||||
|
||||
两个方向各有各的混流:
|
||||
· 上位机 → 板:终端回显、人手敲的回车、别的工具的探测串,与 `@C` 命令混在一起,
|
||||
而且 USB 按包到达,**切在哪里都有可能**(`\r\n` 中间、CRC 中间、前缀中间);
|
||||
· 板 → 上位机:日志(可能是半行)与 `@R` 回应交错写出,偶尔还有绕开锁的写者插进来。
|
||||
最后一条用例把两个方向接起来跑一遍真的命令处理器:这就是「管道」。 */
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#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();
|
||||
}
|
||||
@@ -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 <json>*XXXX\\n XXXX = CRC-16/CCITT-FALSE(json),4 位大写十六进制
|
||||
收 @R <json>*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())
|
||||
@@ -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 失联判据完全失灵**。<br>两条路:① 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 |
|
||||
|
||||
@@ -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 <json>[*XXXX]
|
||||
CRC 可省(给人手敲);带了就必须对
|
||||
板 → 上位机 @R <json>*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>`,id = 板子 STA MAC 的 12 位小写十六进制(板 B = `b81f3fc9c8bc`)。
|
||||
|
||||
| topic | 方向 | QoS | retain | 内容 |
|
||||
|---|---|---|---|---|
|
||||
| `<base>/cmd` | 服务器 → 板 | 1 | 否 | 一条命令 JSON(与 USB `@C` 负载相同,无前缀无 CRC) |
|
||||
| `<base>/rsp` | 板 → 服务器 | 1 | 否 | 一条回应 JSON |
|
||||
| `<base>/tele` | 板 → 服务器 | 0 | 否 | 周期遥测 = 一条 status 回应(无 seq),默认 1 s(`tele_ms` 200~60000) |
|
||||
| `<base>/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 量级 |
|
||||
|
||||
在新工单中引用
屏蔽一个用户