固件: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]>
这个提交包含在:
zhou yang
2026-09-27 12:33:18 +08:00
共同撰写人 Claude Opus 5.5
父节点 cae567dfd8
当前提交 a32fa06c43
共修改 38 个文件,包含 4490 行新增和 85 行删除
+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` 分区时日志落不了盘,但五路照供电
+19
查看文件
@@ -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 */
+239
查看文件
@@ -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;
}
+213
查看文件
@@ -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;
}
+83
查看文件
@@ -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 */
+107
查看文件
@@ -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;
}
+41
查看文件
@@ -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 */
+33
查看文件
@@ -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 */
+132
查看文件
@@ -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, "]");
}
+84
查看文件
@@ -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;
}
+75
查看文件
@@ -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 */
+371
查看文件
@@ -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++);
}
}
+15
查看文件
@@ -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 */
+146
查看文件
@@ -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 "?";
}
+102
查看文件
@@ -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 */
+1 -1
查看文件
@@ -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; }
+7
查看文件
@@ -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
+243
查看文件
@@ -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;
}
+123
查看文件
@@ -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 */
+155
查看文件
@@ -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;
}
+25
查看文件
@@ -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 */