文件
phonerail/firmware/test/test_ulink.c
T
zhou yang和Claude Opus 5.5 a32fa06c43 固件: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]>
2026-09-27 12:33:18 +08:00

411 行
16 KiB
C
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
/* A13:USB-Serial-JTAG 分帧(ulink.h)。计划里这一包的验收原话:
「宿主机上用管道灌『命令 + 日志混流』,命令仍能被正确切分」。
两个方向各有各的混流:
· 上位机 → 板:终端回显、人手敲的回车、别的工具的探测串,与 `@C` 命令混在一起,
而且 USB 按包到达,**切在哪里都有可能**(`\r\n` 中间、CRC 中间、前缀中间);
· 板 → 上位机:日志(可能是半行)与 `@R` 回应交错写出,偶尔还有绕开锁的写者插进来。
最后一条用例把两个方向接起来跑一遍真的命令处理器:这就是「管道」。 */
#include <stdio.h>
#include <string.h>
#include "cmd.h"
#include "sim_hal.h"
#include "t_thermal.h"
#include "ulink.h"
#include "unity.h"
void setUp(void) { sim_hal_reset(); }
void tearDown(void) {}
/* 确定性的伪随机(每次跑切块方式都一样,红了能复现)。 */
static uint32_t s_rng;
static uint32_t rnd(uint32_t n)
{
s_rng = s_rng * 1103515245u + 12345u;
return (s_rng >> 16) % n;
}
/* ---- 接收侧的记录器 ------------------------------------------------------ */
#define MAXGOT 32
typedef struct {
int n;
ulink_rx_kind_t kind[MAXGOT];
char payload[MAXGOT][CMD_LINE_MAX];
} got_t;
static void collect(void *ctx, ulink_rx_kind_t kind, const char *payload)
{
got_t *g = ctx;
TEST_ASSERT_TRUE(g->n < MAXGOT);
g->kind[g->n] = kind;
snprintf(g->payload[g->n], CMD_LINE_MAX, "%s", payload);
g->n++;
}
/* 把一段字节按随机大小(1~max 字节)切块喂进去。 */
static void feed_chunked(ulink_rx_t *rx, const char *s, size_t len, uint32_t max, got_t *g)
{
size_t i = 0;
while (i < len) {
size_t k = 1 + rnd(max);
if (k > len - i) {
k = len - i;
}
ulink_rx_feed(rx, (const uint8_t *)s + i, k, collect, g);
i += k;
}
}
/* 给一段 JSON 带上 CRC 拼成一行请求。 */
static void cmd_line(char *out, size_t cap, const char *json)
{
snprintf(out, cap, "@C %s*%04X\r\n", json, (unsigned)ulink_crc16(json, strlen(json)));
}
/* ---- CRC ----------------------------------------------------------------- */
static void test_crc_is_ccitt_false(void)
{
TEST_ASSERT_EQUAL_HEX16(0x29B1, ulink_crc16("123456789", 9)); /* 标准校验值 */
TEST_ASSERT_EQUAL_HEX16(0xFFFF, ulink_crc16("", 0));
}
/* ---- 接收 ---------------------------------------------------------------- */
/* ⭐ 验收主项(接收方向)。混流里只有 `@C` 开头的行是命令;切块方式换 200 种,
切出来的命令一条不多、一条不少、一个字节不差。 */
static void test_commands_are_cut_out_of_a_mixed_stream_however_it_is_chunked(void)
{
char a[96], b[96];
cmd_line(a, sizeof(a), "{\"seq\":1,\"cmd\":\"status\"}");
cmd_line(b, sizeof(b), "{\"seq\":2,\"cmd\":\"fan\",\"duty\":500}");
char stream[1024];
snprintf(stream, sizeof(stream),
"\r\n" /* 人手敲的回车 */
"I (1234) phonerail: boot\n" /* 终端回显的日志 */
"%s" /* 带 CRC、CRLF */
"AT\r\n" /* 别的工具的探测串 */
"x@C {\"seq\":9,\"cmd\":\"edl\",\"rail\":1}\n" /* @C 不在行首:不算 */
"@C {\"cmd\":\"status\"}\n" /* 不带 CRC:人手敲的,认 */
"@Cstatus\n" /* 前缀少了空格:不算 */
"%s",
a, b);
const size_t len = strlen(stream);
for (uint32_t trial = 0; trial < 200; trial++) {
s_rng = trial + 1;
ulink_rx_t rx;
ulink_rx_init(&rx);
got_t g = { 0 };
feed_chunked(&rx, stream, len, trial == 0 ? 1 : 1 + trial % 40, &g);
TEST_ASSERT_EQUAL_INT(3, g.n);
for (int i = 0; i < 3; i++) {
TEST_ASSERT_EQUAL_INT(ULINK_RX_CMD, g.kind[i]);
}
TEST_ASSERT_EQUAL_STRING("{\"seq\":1,\"cmd\":\"status\"}", g.payload[0]);
TEST_ASSERT_EQUAL_STRING("{\"cmd\":\"status\"}", g.payload[1]);
TEST_ASSERT_EQUAL_STRING("{\"seq\":2,\"cmd\":\"fan\",\"duty\":500}", g.payload[2]);
TEST_ASSERT_EQUAL_UINT32(5, rx.ignored);
}
}
static void test_bad_crc_is_refused_but_still_names_the_line(void)
{
ulink_rx_t rx;
ulink_rx_init(&rx);
got_t g = { 0 };
const char *s = "@C {\"seq\":3,\"cmd\":\"power\",\"rail\":1,\"on\":true}*0000\n";
ulink_rx_feed(&rx, (const uint8_t *)s, strlen(s), collect, &g);
TEST_ASSERT_EQUAL_INT(1, g.n);
TEST_ASSERT_EQUAL_INT(ULINK_RX_BAD_CRC, g.kind[0]);
TEST_ASSERT_EQUAL_STRING("{\"seq\":3,\"cmd\":\"power\",\"rail\":1,\"on\":true}", g.payload[0]);
/* 小写十六进制也认。 */
char line[96];
const char *json = "{\"cmd\":\"status\"}";
snprintf(line, sizeof(line), "@C %s*%04x\n", json, (unsigned)ulink_crc16(json, strlen(json)));
ulink_rx_feed(&rx, (const uint8_t *)line, strlen(line), collect, &g);
TEST_ASSERT_EQUAL_INT(ULINK_RX_CMD, g.kind[1]);
}
/* NUL 不能只是跳过:它会让 JSON 解析在 NUL 处提前结束,后面的垃圾就被放过了。 */
static void test_nul_and_control_bytes_poison_the_whole_line(void)
{
ulink_rx_t rx;
ulink_rx_init(&rx);
got_t g = { 0 };
const char s[] = "@C {\"cmd\":\"status\"}\0garbage\n@C {\"cmd\":\"sta\x1btus\"}\n";
ulink_rx_feed(&rx, (const uint8_t *)s, sizeof(s) - 1, collect, &g);
TEST_ASSERT_EQUAL_INT(2, g.n);
TEST_ASSERT_EQUAL_INT(ULINK_RX_BAD_BYTES, g.kind[0]);
TEST_ASSERT_EQUAL_INT(ULINK_RX_BAD_BYTES, g.kind[1]);
TEST_ASSERT_EQUAL_UINT32(0, rx.cmds);
}
/* 超长的命令行整行丢、报一次;超长的日志行悄悄丢;之后的行不受影响。
正好顶到上限的那一行照收(边界不许差一)。 */
static void test_overlong_lines_are_dropped_and_the_stream_recovers(void)
{
static char s[4 * ULINK_RX_LINE_MAX];
ulink_rx_t rx;
ulink_rx_init(&rx);
got_t g = { 0 };
/* 超长命令 */
strcpy(s, "@C {\"k\":\"");
memset(s + strlen(s), 'a', ULINK_RX_LINE_MAX);
strcpy(s + strlen(s), "\"}\n");
ulink_rx_feed(&rx, (const uint8_t *)s, strlen(s), collect, &g);
/* 超长日志 */
memset(s, 'L', 2 * ULINK_RX_LINE_MAX);
s[2 * ULINK_RX_LINE_MAX] = '\n';
ulink_rx_feed(&rx, (const uint8_t *)s, 2 * ULINK_RX_LINE_MAX + 1, collect, &g);
/* 正好上限:前缀 + JSON + CRC = ULINK_RX_LINE_MAX */
const size_t jlen = ULINK_RX_LINE_MAX - ULINK_PREFIX_LEN - ULINK_CRC_LEN;
char json[ULINK_RX_LINE_MAX];
strcpy(json, "{\"k\":\"");
memset(json + 6, 'b', jlen - 8);
strcpy(json + jlen - 2, "\"}");
TEST_ASSERT_EQUAL_size_t(jlen, strlen(json));
snprintf(s, sizeof(s), "@C %s*%04X\n", json, (unsigned)ulink_crc16(json, jlen));
ulink_rx_feed(&rx, (const uint8_t *)s, strlen(s), collect, &g);
TEST_ASSERT_EQUAL_INT(2, g.n);
TEST_ASSERT_EQUAL_INT(ULINK_RX_TOO_LONG, g.kind[0]);
TEST_ASSERT_EQUAL_INT(ULINK_RX_CMD, g.kind[1]);
TEST_ASSERT_EQUAL_STRING(json, g.payload[1]);
TEST_ASSERT_EQUAL_UINT32(1, rx.too_long);
TEST_ASSERT_EQUAL_UINT32(1, rx.ignored);
}
/* ---- 发送 ---------------------------------------------------------------- */
/* 假 USB:把写出去的字节攒起来。`budget` 是还能写多少(模拟缓冲满)。 */
typedef struct {
char buf[8192];
size_t n;
size_t budget;
} wire_t;
static size_t wire_sink(void *ctx, const char *d, size_t len)
{
wire_t *w = ctx;
size_t k = len < w->budget ? len : w->budget;
TEST_ASSERT_TRUE(w->n + k < sizeof(w->buf));
memcpy(w->buf + w->n, d, k);
w->n += k;
w->budget -= k;
w->buf[w->n] = '\0';
return k;
}
/* 把线上的字节逐行过一遍参考解帧器,收集合法回应。 */
static int deframe_all(wire_t *w, char out[][CMD_OUT_MAX], int max, int *log_lines)
{
int n = 0;
char *line = w->buf;
*log_lines = 0;
for (;;) {
char *nl = strchr(line, '\n');
if (nl == NULL) {
break; /* 最后半行不算数 */
}
*nl = '\0';
if (n < max && ulink_deframe(line, out[n], CMD_OUT_MAX) >= 0) {
n++;
} else {
(*log_lines)++;
}
line = nl + 1;
}
return n;
}
static char s_frames[8][CMD_OUT_MAX];
/* 长度一律取 strlen:手数字符数会把结尾的 NUL 也写上线(本文件第一版就这么错过)。 */
#define LOG_S(tx, s) ulink_tx_log((tx), (s), strlen(s))
#define FRAME_S(tx, s) ulink_tx_frame((tx), (s), strlen(s))
/* 半行日志之后来一帧:先补换行,帧从行首开始;日志里自带的 `@R ` 被垫空格,
解帧器绝不会把它当成回应。 */
static void test_frames_always_start_a_line_and_logs_can_never_pose_as_frames(void)
{
wire_t w = { .budget = sizeof(w.buf) };
ulink_tx_t tx;
ulink_tx_init(&tx, wire_sink, &w);
const char *fake = "@R {\"seq\":1,\"ok\":true}*0000";
char forged[96];
snprintf(forged, sizeof(forged), "@R {\"seq\":1,\"ok\":true}*%04X\n",
(unsigned)ulink_crc16("{\"seq\":1,\"ok\":true}", 19)); /* 连 CRC 都对的伪帧 */
LOG_S(&tx, "I (5) x: half a line");
FRAME_S(&tx, "{\"seq\":7,\"ok\":true}");
LOG_S(&tx, forged);
LOG_S(&tx, fake);
LOG_S(&tx, "\nW (6) x: two\nlines\n");
FRAME_S(&tx, "{\"ok\":false,\"err\":\"parse\"}");
/* 先看原始字节(deframe_all 会把 \n 就地改成 \0)。 */
TEST_ASSERT_NOT_NULL(strstr(w.buf, "half a line\n@R {\"seq\":7"));
TEST_ASSERT_NOT_NULL(strstr(w.buf, "\n @R {\"seq\":1")); /* 伪帧被垫了空格 */
int logs = 0;
TEST_ASSERT_EQUAL_INT(2, deframe_all(&w, s_frames, 8, &logs));
TEST_ASSERT_EQUAL_STRING("{\"seq\":7,\"ok\":true}", s_frames[0]);
TEST_ASSERT_EQUAL_STRING("{\"ok\":false,\"err\":\"parse\"}", s_frames[1]);
TEST_ASSERT_EQUAL_INT(5, logs);
}
/* USB 缓冲满、一帧只写出一半:下一帧先补换行,那半帧成了一行 CRC 错的垃圾被丢掉,
下一帧完好。上位机那一侧的现象是「这条超时了」,按同一个 seq 重发即可。 */
static void test_a_short_write_costs_one_frame_not_the_next(void)
{
wire_t w = { .budget = 10 };
ulink_tx_t tx;
ulink_tx_init(&tx, wire_sink, &w);
TEST_ASSERT_FALSE(FRAME_S(&tx, "{\"seq\":1,\"ok\":true}"));
TEST_ASSERT_EQUAL_UINT32(1, tx.short_writes);
TEST_ASSERT_FALSE(tx.at_bol);
w.budget = sizeof(w.buf);
TEST_ASSERT_TRUE(FRAME_S(&tx, "{\"seq\":2,\"ok\":true}"));
int logs = 0;
TEST_ASSERT_EQUAL_INT(1, deframe_all(&w, s_frames, 8, &logs));
TEST_ASSERT_EQUAL_STRING("{\"seq\":2,\"ok\":true}", s_frames[0]);
TEST_ASSERT_EQUAL_INT(1, logs);
}
/* 绕开锁的写者(panic / ROM 打印)插进一帧中间:剩下的部分可能仍是合法 JSON,
但 CRC 对不上 —— 必须丢。这就是回应必带 CRC 的理由。 */
static void test_foreign_bytes_inside_a_frame_are_caught_by_the_crc(void)
{
const char *json = "{\"seq\":4,\"ok\":false,\"err\":\"busy\"}";
char line[128];
snprintf(line, sizeof(line), "@R %s*%04X", json, (unsigned)ulink_crc16(json, strlen(json)));
char out[CMD_OUT_MAX];
TEST_ASSERT_EQUAL_INT((int)strlen(json), ulink_deframe(line, out, sizeof(out)));
/* 把 `"ok":false,"err":"busy"` 中间换掉几个字节,剩下的仍像一条回应。 */
char evil[128];
snprintf(evil, sizeof(evil), "@R {\"seq\":4,\"ok\":true} *%04X",
(unsigned)ulink_crc16(json, strlen(json)));
TEST_ASSERT_EQUAL_INT(-1, ulink_deframe(evil, out, sizeof(out)));
TEST_ASSERT_EQUAL_INT(-1, ulink_deframe("@R {\"seq\":4,\"ok\":true}", out, sizeof(out))); /* 没 CRC */
TEST_ASSERT_EQUAL_INT(-1, ulink_deframe("I (1) x: @R {}", out, sizeof(out)));
line[strlen(line) - 1] ^= 1; /* 改 CRC 的一位 */
TEST_ASSERT_EQUAL_INT(-1, ulink_deframe(line, out, sizeof(out)));
}
static void test_link_errors_echo_the_seq_when_they_can(void)
{
char out[96];
ulink_link_error("{\"seq\":12,\"cmd\":\"edl\",\"rail\":1}", "bad_crc", out, sizeof(out));
TEST_ASSERT_EQUAL_STRING("{\"seq\":12,\"ok\":false,\"err\":\"bad_crc\"}", out);
ulink_link_error("", "too_long", out, sizeof(out));
TEST_ASSERT_EQUAL_STRING("{\"ok\":false,\"err\":\"too_long\"}", out);
ulink_link_error("{\"seq\":0}", "queue_full", out, sizeof(out));
TEST_ASSERT_EQUAL_STRING("{\"ok\":false,\"err\":\"queue_full\"}", out);
TEST_ASSERT_NULL(ulink_rx_err_str(ULINK_RX_CMD));
TEST_ASSERT_EQUAL_STRING("bad_frame", ulink_rx_err_str(ULINK_RX_BAD_BYTES));
}
/* ---- ⭐ 管道:两个方向接起来,过一遍真的命令处理器 ------------------------ */
typedef struct {
ulink_tx_t *tx;
wire_t *w;
int lines;
} pipe_t;
/* 链路任务的回调:命令进处理器、回应成帧;链路层错误直接回。每回应一次,
就在前后随机塞几段半行日志 —— 模拟别的任务在同一时刻打日志。 */
static void pipe_rx(void *ctx, ulink_rx_kind_t kind, const char *payload)
{
pipe_t *p = ctx;
static char out[CMD_OUT_MAX];
size_t n;
if (kind == ULINK_RX_CMD) {
n = cmd_handle_line(CMD_LINK_USB, payload, out, sizeof(out));
} else {
n = ulink_link_error(payload, ulink_rx_err_str(kind), out, sizeof(out));
}
static const char *const junk[] = { "I (9) t_safety: CH1 ", "3901 mV\n", "@R fake\n",
"E (9) x: oops\n", "" };
const char *before = junk[rnd(5)];
ulink_tx_log(p->tx, before, strlen(before));
ulink_tx_frame(p->tx, out, n);
const char *after = junk[rnd(5)];
ulink_tx_log(p->tx, after, strlen(after));
p->lines++;
}
static void test_pipe_mixed_stream_through_the_real_processor(void)
{
cmd_init(NULL);
t_thermal_set_floor(0);
char in[2048] = "garbage line\r\n";
char l[128];
cmd_line(l, sizeof(l), "{\"seq\":100,\"cmd\":\"fan\",\"duty\":600}");
strcat(in, l);
strcat(in, "I (1) echo\n");
cmd_line(l, sizeof(l), "{\"seq\":100,\"cmd\":\"fan\",\"duty\":600}"); /* 重传 */
strcat(in, l);
strcat(in, "@C {\"seq\":101,\"cmd\":\"fan\",\"duty\":700}*BEEF\n"); /* CRC 错 */
cmd_line(l, sizeof(l), "{\"seq\":102,\"cmd\":\"status\"}");
strcat(in, l);
strcat(in, "@C not json\n");
for (uint32_t trial = 1; trial <= 50; trial++) {
s_rng = trial;
cmd_init(NULL);
static wire_t w;
memset(&w, 0, sizeof(w));
w.budget = sizeof(w.buf);
ulink_tx_t tx;
ulink_tx_init(&tx, wire_sink, &w);
pipe_t p = { .tx = &tx, .w = &w };
ulink_rx_t rx;
ulink_rx_init(&rx);
size_t i = 0;
const size_t len = strlen(in);
while (i < len) {
size_t k = 1 + rnd(1 + trial % 30);
if (k > len - i) {
k = len - i;
}
ulink_rx_feed(&rx, (const uint8_t *)in + i, k, pipe_rx, &p);
i += k;
}
int logs = 0;
TEST_ASSERT_EQUAL_INT(5, deframe_all(&w, s_frames, 8, &logs));
TEST_ASSERT_EQUAL_STRING("{\"seq\":100,\"ok\":true,\"floor\":600}", s_frames[0]);
TEST_ASSERT_EQUAL_STRING("{\"seq\":100,\"ok\":true,\"dup\":true}", s_frames[1]);
TEST_ASSERT_EQUAL_STRING("{\"seq\":101,\"ok\":false,\"err\":\"bad_crc\"}", s_frames[2]);
TEST_ASSERT_EQUAL_STRING_LEN("{\"seq\":102,\"ok\":true,\"fw\":", s_frames[3], 26);
TEST_ASSERT_EQUAL_STRING("{\"ok\":false,\"err\":\"parse\"}", s_frames[4]);
TEST_ASSERT_EQUAL_UINT16(600, t_thermal_floor()); /* CRC 错的那条 700 没生效 */
}
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_crc_is_ccitt_false);
RUN_TEST(test_commands_are_cut_out_of_a_mixed_stream_however_it_is_chunked);
RUN_TEST(test_bad_crc_is_refused_but_still_names_the_line);
RUN_TEST(test_nul_and_control_bytes_poison_the_whole_line);
RUN_TEST(test_overlong_lines_are_dropped_and_the_stream_recovers);
RUN_TEST(test_frames_always_start_a_line_and_logs_can_never_pose_as_frames);
RUN_TEST(test_a_short_write_costs_one_frame_not_the_next);
RUN_TEST(test_foreign_bytes_inside_a_frame_are_caught_by_the_crc);
RUN_TEST(test_link_errors_echo_the_seq_when_they_can);
RUN_TEST(test_pipe_mixed_stream_through_the_real_processor);
return UNITY_END();
}