仓库:https://github.com/ffffxxxjjj/gateway
本文档描述网关应用的 MQTT 传输层与 firmware_update 端到端流水线(为板上 OTA 测试准备)。
范围:MQTT 连接/订阅/发布、离线环形缓冲、指数退避重连、最小 HTTP 包下载、OTA 接线(bootloader 的 OtaWorker + 真实 Flash 存储,见 docs/flash-boot.md)、OTP 身份读取(见 docs/otp-read.md)。
不做:W5500、Modbus 点位、完整遥测点位、客户端下发存 Flash 通道、写一次性可编程存储区(读已做,见 docs/otp-read.md)/动选项字节/开读出保护、SG 改动。
应用(app/src/main.rs)在 embassy 执行器上跑以下任务,互不阻塞主循环心跳:
| 任务 | 职责 |
|---|---|
serial_task(异步 USART1,DMA) |
读串口行命令(见第 8 节),写回 OK/ERR/MQTT STATUS |
mqtt_task(embassy-net 栈) |
CONNECT/订阅/发布/重连/命令处理/OTA 引擎调用 |
http_task(embassy-net 栈) |
最小 HTTP GET,分块喂给 OTA(见第 6 节) |
net_task(板级库) |
网络栈 Runner(DHCP、链路) |
| 主循环 | 链路/IP 变化日志 + 每 10 秒心跳;LAN 失败时 5 秒一次 alive, no network,不死机 |
静态通道(embassy-sync,无堆,固定容量):
| 通道 | 方向 | 容量 | 内容 |
|---|---|---|---|
CONFIG_CH |
串口 → MQTT | 4 | MqttCommand(设 Broker/身份/密钥/连接/断开) |
REQ_CH |
MQTT → HTTP | 1 | DownloadRequest(host/port/path,一次只跑一个下载) |
CHUNK_CH |
HTTP → MQTT | 4 | ChunkMsg(1024 字节块 / Done / Failed) |
STATUS |
各任务 → 串口 | 1(Mutex) | MqttStatus(状态、缓冲数、计数、OTA 阶段、最近错误) |
串口命令满了回 ERR mqtt busy,不阻塞串口;MQTT 任务未启动(无网)时通道满 4 条即 ERR。
boards/h743/net.rs 的 embassy-net 栈(LAN8720 单网口,见 docs/board-h743.md),MQTT 与 HTTP 共用(栈资源 4 路,现用 2 路)。client_id = 序列号。OTP 已写入时取自 OTP 并锁定(见 docs/otp-read.md);未写入时串口 MQTT IDENTITY 注入(见第 8 节)。username = 租户/序列号,password = 登记签发的一机一证(串口注入,仅测试;绝不写死在 MQTT 模块里;产品路走客户端下发存 Flash,以后再做)。OTP 已写入时 IDENTITY 命令的序列号被忽略、只应用租户/口令;KEY/CLOUDKEY 在已写入时双双拒收(见 docs/otp-read.md 第 5 节)。MQTT BROKER,仅测试);只接 IP,不接主机名(板上无 DNS,主机名直接失败并给出明确原因)。https 包地址直接拒绝(见第 6 节)。CONNECT;DISCONNECT 后须再发 CONNECT 才重连。ConfigChanged),退避后用新配置重连;改密钥只更新本地,不重连。话题格式:v1/tenants/{tenant}/devices/{device}/{kind},段字符仅字母数字、-、_(见 mqtt.rs 的 build_topic)。
| 话题 kind | 方向 | 服务质量 | retained | 说明 |
|---|---|---|---|---|
telemetry |
设备 → 云端 | 0 | 否 | 遥测发布通道(本批只做 30 秒一条最小样本占位,见第 7 节;点位遥测是下一批) |
status |
设备 → 云端 | 1 | 是 | 上线 {"device_id":"...","online":true,"version":"..."};遗嘱 online:false;干净断开先发 online:false 再发 DISCONNECT(遗嘱不发) |
cmd |
云端 → 设备 | 1 | 否 | 订阅话题;命令 JSON 见第 5 节 |
cmd_ack |
设备 → 云端 | 1 | 否 | 回执 JSON,cmd_id 原样回显;不进缓冲(命令没收到就无 ack 可补,靠云端重推 + 排序检查幂等) |
状态版本取自版本块(0x08101000 处 228 字节格式 2,见 docs/firmware-version-block.md),不用 Cargo.toml 常量,避免漂移。
未打戳 ELF(直接 probe-rs 运行)版本块全零,状态报 0.0.0,云端会认为它最旧、推最新版,属正常测试行为。
MQTT STATUS 查状态(disconnected/connecting/connected/backoff)、缓冲数、dropped/too_large/malformed 计数、OTA 阶段(idle/downloading/verifying/installing/failed-pending 的粗粒度 ota/idle)、最近错误(64 字节截断);关键转折记 defmt 日志。dropped);超槽载荷拒绝并计数(too_large),不静默丢弃;未来点位遥测(1000 点)载荷更大时须重审槽设计(下一批的事)。cmd_ack 发布失败直接断开(云端重推兜底)。命令 JSON:{"cmd_id":"...","action":"...","params":{...}}(字段顺序不限、容忍多余字段;解析器见 mqtt.rs 的 parse_cmd)。
firmware_update:params 含 version(x.y.z,无 v 前缀)、pkg_url、key_package(160 hex = 80 字节密钥包)。处理见第 6 节。ok:false,消息按契约中文 未知命令: <动作名>;动作名含引号/反斜杠/控制字符时回退为通用消息 未知命令(不带动作名,避免破坏 JSON 结构)。绝不静默丢弃。malformed,不回执(无 cmd_id 可配对)。{"cmd_id":"...","ok":true/false,"message":"...","data":{"version":"...","active_version":"..."}};失败时 data.version 为空字符串(非 null),以便消费方始终按字符串处理;成功时 message 为 installed,失败时为原因串。以下任一失败,任务层直接回执,不启动 worker(worker 内仍会再验一次,纵深防御,规则一致):
| 检查 | 失败回执 |
|---|---|
| 密钥包 hex 解码失败 | bad key package hex |
| hex 解码后长度非 80 | key package is not 80 bytes |
| 版本串解析失败 | bad version |
| 版本等于当前 | ok:true、already up to date(不重装) |
| 版本更旧 | downgrade rejected |
| 密钥未注入 | keys not set (use MQTT KEY/CLOUDKEY) |
| 包地址解析失败 | bad pkg_url: ...(https not supported / bad scheme / bad host / bad port / bad path) |
run_ota,不碰 MQTT socket)步骤:排空 CHUNK_CH 旧消息(防上一轮超时下载的残留块污染下一轮)→ 复位真实 Flash 存储(写偏移从 0 起、清掉上一轮待切记录;待切版本由切换时从暂存镜像版本块读出,不再由调用方挂起)→ 经 REQ_CH 发下载请求(15 秒超时,超时合成 download request timeout)→ 建 OtaWorker(下载/安装各 60 秒、上限 128 KiB)→ 10ms 间隔 poll,另加 180 秒安全网超时(合成 ota timeout)→ 返回 worker outcome 或合成 outcome;返回前用 zeroize 清掉任务侧解码出的 80 字节密钥包副本(worker 内出回执时也清)。
命令串行化分两层,不要混为一谈:
worker busy,第二命令不进入队列;跑完后恢复空闲)。OTA 期间保活暂停(MQTT 任务在跑 OTA,不读 socket):OTA 小于 90 秒不丢 Broker 连接;更长则 Broker 断开,任务在 OTA 完成后重连再发回执,云端重推 + 排序检查幂等兜底回执丢失。
http_task,最小 GET)http(板上无 TLS,https 直接拒);仅 IP(主机名直接失败 http host must be IP)。200 拒;含 transfer-encoding: chunked 明拒(会 corrupt 包体,必须明拒,不静默接受)。CHUNK_CH 发送,结束发 Done,读错发 Failed("http read failed");每轮结束 socket drop,下一请求重建。OtaWorker 的 max_package_size):超上限/外来包/长度不符提前拒绝,不下载剩余;312 字节包头到齐即解析(格式 2 直接拒绝,格式 3 才继续)。bootloader 的 OtaWorker,先验后装)app 直接路径依赖 bootloader 的 lib(bootloader 是 lib + bin 结构,app 不开 board-test 特性,故不编板上测试 bin;不需要复制 ota.rs、不需要改 bootloader 结构)。
流水线:下载(每轮最多一 chunk)→ 验证(整包 verify:Ed25519 验签 + X25519 解包密钥 + AES-256-GCM 解密,验签不过零写盘)→ 安装(逐 chunk 写 + 读回 + 切换)。
失败不切换启动、备用区作废(仅写开始后的安装失败作废;下载与验证失败不碰存储)。
版本规则与 ota.rs 一致:恰好三段、每段 1–5 位 ASCII 数字、每段 ≤ u16;命令版本必须与包头版本一致,否则 version mismatch。
真实 Flash 存储(FlashImageStore,暂存区 + 启动信息)已落地:安装成功表示“已暂存 + 待复位拷贝”,回执 data.active_version 仍为旧版本(见 docs/flash-boot.md 回执语义变化);上线状态发布成功后写健康确认(仅待确认时写盘)。板上验证(复位拷贝 + 确认 + 回滚)须用户审过启动程序改动后按 docs/flash-boot.md 板上测试法跑,主机测试通过不等于板上已验。
每 30 秒一条最小遥测样本({"unit":1,"v":1,"tick":N} 精简形状占位,未来点位遥测的替身),QoS 0;连上直接发(首条不等 30 秒),离线进缓冲、重连按序补发;发布失败时进缓冲不丢失。
状态不缓冲(离线时遗嘱已覆盖 online:false;重连时发 online:true 即覆盖)。
串口:USART1,115200 8N1(PA9 TX / PA10 RX)。命令关键字大小写不限,值大小写敏感;非 MQTT 行静默忽略(留给 PROV,不回 ERR 刷屏);MQTT 行格式错回 ERR。
MQTT BROKER <host> <port> # 如 MQTT BROKER 192.168.1.10 1883(仅 IP)
MQTT IDENTITY <serial> <tenant> <password>
MQTT KEY <64hex> # 设备私钥(仅测试;生产密钥绝不走串口;OTP 已写入时拒收)
MQTT CLOUDKEY <64hex> # 云端公钥(仅测试;生产密钥绝不走串口;OTP 已写入时拒收)
MQTT STATUS # 查状态/缓冲/计数/OTA 阶段/最近错误
MQTT CONNECT # 手动连接(配置齐时自动连,不必另发)
MQTT DISCONNECT # 干净断开(发 online:false + DISCONNECT,关使能)
MQTT HELP # 帮助文本(含仅测试警告)
行缓冲 256 字节(一行最多 255 字符 + 换行;溢出行进入丢弃态直至换行,不把旧行尾巴粘到新行)。
OTP 门控(见 docs/otp-read.md 第 5 节):已写入的板子发 KEY/CLOUDKEY 回 ERR otp written, key injection rejected;IDENTITY 回 OK identity set (serial locked to otp)(序列号被忽略,只应用租户/口令)。未写入的开发板才按下面第 10 节四条注入。
EMQX 现无任何 MQTT 用户,设备账号待登记环节建(未排期);板上测试须先有测试账号与口令。Broker 地址等产品路走客户端下发存 Flash(本批不做)。
主机测试一律用模拟传输/模拟 Broker,不连真实网络。
# app lib(含 MQTT 编解码、命令解析、环形缓冲、退避、OTA 接线)
cd app && cargo test --lib --no-default-features --target x86_64-unknown-linux-gnu
# bootloader 回归(必须全绿)
cd bootloader && cargo test
--no-default-features + --target 的原因:app 默认目标是芯片(thumbv7em-none-eabihf,无 std),且默认特性 chip 含 embassy-executor 的 arch-cortex-m(其 sev 汇编编不进 host);关掉默认特性后 lib 只剩 bootloader + sha2,才能在 host 上编过。
芯片构建(要求零警告):
cd app && cargo build && cargo build --release
cd boards/h743 && cargo build --target thumbv7em-none-eabihf
注意:板级 crate 故意不设默认目标(见其 .cargo/config.toml 注释),裸 cargo build 走宿主 x86 会在 sev 上挂掉,属预期行为,必须带 --target。
另注意:app 的 sha2 必须与 bootloader 一致用 default-features = false;若 app 侧开默认特性,Cargo 特性统一会把 bootloader 的 no_std sha2 也带上 std,导致芯片构建在 crypto-common 上挂掉(见 app/Cargo.toml 注释)。
覆盖(缺一不可):剩余长度往返/畸形/截断、UTF-8、CONNECT/CONNACK/PUBLISH/PUBACK/SUBACK/PING/DISCONNECT 字节、decode_one 分发与未知类型、话题/用户名、状态/cmd_ack JSON 精确串(含中文未知命令消息与含引号动作名的回退)、命令解析(字段顺序打乱 + 多余字段 + 嵌套对象 + 字符串值内假键名 + 未知动作 + 缺字段 + 空/超长 cmd_id + 转义错 + 非 ASCII \u)、hex 解码、HTTP 地址解析、环形缓冲(1000 条填满 + 丢最旧 + 顺序 + 超槽拒绝)、退避序列、状态截断、OTA 接线(成功端到端 + 回执 JSON、错签名零写盘不切换不作废、错设备密钥解包失败零写盘、版本不匹配 + 坏版本、截断包、下载超时、忙时第二命令拒绝不排队且跑完后恢复空闲、包超上限、密钥包 79 字节、安装超时(写开始后清待切记录不切换)、50 次连续 OTA soak 无状态泄漏、版本解析/格式化往返)、OTP 记录(布局常量、编解往返、擦除与错 magic → 未写入、错 format 与序列号与校验 → 损坏、短输入 → 损坏、读失败 → 错误、非法序列号编不出、Debug 不漏密钥、清零归零)、启动身份决策(仅有效记录算已写入、损坏算未写入、注入仅未写入时允许、序列号锁跟已写入走、OTP 序列号优先于注入、无身份时无有效序列号、决策不碰密钥材料)。
当前结果:app lib 49 项(原 42 + 启动身份决策 7)、bootloader 87 项(原 78 + OTP 记录 9),全绿;芯片 debug/release 零警告编过。
前置:板子按 docs/board-h743.md 带起并确认 LAN/IP;EMQX 测试账号已建;sign 端已出 GWPK v3 包(格式 3,型号 stm32h743vi、固件名 gateway);包下载地址须用明文 http + 局域网 IP(板上无 TLS、无 DNS;sign 端推送的 pkg_url 若为 https 域名,板上取不到)。
docs/otp-read.md 第 6 节路径 B,KEY/CLOUDKEY 会拒收、IDENTITY 序列号被忽略):
MQTT BROKER <EMQX 局域网 IP> 1883
MQTT IDENTITY <序列号> <租户> <测试口令>
MQTT KEY <设备私钥 64hex> # 仅测试
MQTT CLOUDKEY <云端公钥 64hex> # 仅测试
预期:每条回 OK;MQTT STATUS 显示 state=connected(配置齐自动连)。LAN up、mqtt connected、上线状态发布、每 30 秒遥测心跳;MQTT STATUS 的 buffered 在离线时涨、重连后回落。firmware_update(version = 目标版本、pkg_url = http://<局域网 IP>:<端口>/<包路径>、key_package = 该设备 160 hex 密钥包)。http_task 日志)→ 验签 → 真实 Flash 存储写暂存区 + 待切记录 → 回 cmd_ack(ok:true、message: installed、data.version = 目标版本、data.active_version = 旧版本,安装成功时运行版本仍是旧版本);MQTT STATUS 的 ota 回 idle。复位后的拷贝、确认与回滚按 docs/flash-boot.md 板上测试法(c/d/e)验证,不在本节重复。ok:false + 原因串,且零写盘不切换(active_version 仍为旧版本);版本相等 → ok:true、already up to date;版本更旧 → ok:false、downgrade rejected;未知动作 → ok:false、未知命令: <动作名>;下载超时/截断包 → 对应原因串。0.0.0 属正常(见第 3 节);云端会推最新版。package too large),不下载剩余。release 链接结果:text 188436 + data 80 + bss 473764(约 463KB 静态 RAM:环 160KB + OTA 包/明文各 128KB + 网络约 17KB + 任务栈等),在 AXI SRAM 512KB 内,余约 30–50KB(含主栈)。
若后续加静态量导致溢出,按序降档:环槽 160→128 字节,或 OTA 上限 128KB→96KB,再试;并在回报里写明调过哪一档。
provision.rs;读已做,见 docs/otp-read.md,其路径 B 暂不可跑);云端验证密钥的生产来源(已写入的板子 CLOUDKEY 被拒收,生产来源待定;不影响 OTP 身份建连,只影响 OTA)。docs/flash-boot.md),但板上复位拷贝、确认与回滚尚未跑(须用户审过启动程序改动后跑),主机测试通过不等于板上已验。.github/workflows/build.yml 的 host-test job 已含 app lib 步骤),本节旧表述已过期,同步修正。