固件: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]>
这个提交包含在:
+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 */
|
||||
在新工单中引用
屏蔽一个用户