网关 MQTT 层

仓库: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 改动。

1. 任务分工与通道

应用(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。

2. 连接参数

3. 话题与服务质量

话题格式: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,云端会认为它最旧、推最新版,属正常测试行为。

4. 重连与缓冲

5. 命令与回执

命令 JSON:{"cmd_id":"...","action":"...","params":{...}}(字段顺序不限、容忍多余字段;解析器见 mqtt.rs 的 parse_cmd)。

6. firmware_update 端到端

6.1 任务层直接回执(不进 worker)

以下任一失败,任务层直接回执,不启动 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)

6.2 OTA 引擎(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 内出回执时也清)。

命令串行化分两层,不要混为一谈:

OTA 期间保活暂停(MQTT 任务在跑 OTA,不读 socket):OTA 小于 90 秒不丢 Broker 连接;更长则 Broker 断开,任务在 OTA 完成后重连再发回执,云端重推 + 排序检查幂等兜底回执丢失。

6.3 包下载(http_task,最小 GET)

6.4 验签与安装(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 板上测试法跑,主机测试通过不等于板上已验。

7. 遥测心跳(占位)

每 30 秒一条最小遥测样本({"unit":1,"v":1,"tick":N} 精简形状占位,未来点位遥测的替身),QoS 0;连上直接发(首条不等 30 秒),离线进缓冲、重连按序补发;发布失败时进缓冲不丢失。 状态不缓冲(离线时遗嘱已覆盖 online:false;重连时发 online:true 即覆盖)。

8. Broker 测试配置法(串口命令,仅测试)

串口: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(本批不做)。

9. 主机测试法

主机测试一律用模拟传输/模拟 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 零警告编过。

10. 板上 OTA 测试法

前置:板子按 docs/board-h743.md 带起并确认 LAN/IP;EMQX 测试账号已建;sign 端已出 GWPK v3 包(格式 3,型号 stm32h743vi、固件名 gateway);包下载地址须用明文 http + 局域网 IP(板上无 TLS、无 DNS;sign 端推送的 pkg_url 若为 https 域名,板上取不到)。

  1. 串口依次发(见第 8 节;下面是未写入开发板的流程;已写入的板子按 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(配置齐自动连)。
  2. 预期日志(defmt):LAN up、mqtt connected、上线状态发布、每 30 秒遥测心跳;MQTT STATUS 的 buffered 在离线时涨、重连后回落。
  3. sign 端推送 firmware_update(version = 目标版本、pkg_url = http://<局域网 IP>:<端口>/<包路径>、key_package = 该设备 160 hex 密钥包)。
  4. 预期:板子下载(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)验证,不在本节重复。
  5. 失败预期(逐项测):错签名/错密钥 → ok:false + 原因串,且零写盘不切换(active_version 仍为旧版本);版本相等 → ok:true、already up to date;版本更旧 → ok:false、downgrade rejected;未知动作 → ok:false、未知命令: <动作名>;下载超时/截断包 → 对应原因串。
  6. 未打戳 ELF(直接 probe-rs 运行)报 0.0.0 属正常(见第 3 节);云端会推最新版。
  7. 包上限 128 KiB:超限包直接拒(package too large),不下载剩余。

11. RAM 账

release 链接结果:text 188436 + data 80 + bss 473764(约 463KB 静态 RAM:环 160KB + OTA 包/明文各 128KB + 网络约 17KB + 任务栈等),在 AXI SRAM 512KB 内,余约 30–50KB(含主栈)。 若后续加静态量导致溢出,按序降档:环槽 160→128 字节,或 OTA 上限 128KB→96KB,再试;并在回报里写明调过哪一档。

12. 仍缺环节(未做,不要宣称端到端已通)