arduino-esp32 OpenThread sensor_collector:构建 Thread Leader + Commissioner 多传感器 UDP 汇聚服务
【免费下载链接】arduino-esp32Arduino core for the ESP32 family of SoCs项目地址: https://gitcode.com/GitHub_Trending/ar/arduino-esp32
本篇基于 arduino-esp32 仓库中 OpenThread 库的UDP_SensorNetwork示例,深入讲解服务端 sketchsensor_collector的完整实现:它如何组建或恢复一张 Thread 网络、以 Commissioner 角色为 Joiner 传感器开放入网窗口、在 UDP 5050 端口接收多节点遥测帧,并用"应用层序列号 + 权威 ACK + 节点表生命周期管理"构建一个可对大量传感节点提供服务的高节点 UDP 汇聚服务。读完本文,你可以直接复刻该 demo 的部署顺序与调参方法,并从源码层面理解 Commissioner 提权、Joiner 窗口、序列号去重与掉线驱逐等关键机制在sensor_collector.ino中的落地方式。
sensor_collector 在 UDP Sensor Network 中的定位
UDP_SensorNetwork是一个"多对一"(many-to-one)遥测示例,完全构建在 Arduino OpenThread Native API(OThread+OThreadUDP)之上。示例由两个 sketch 组成,架构如下:
sensor_node 1 ----\ sensor_node 2 -----+--> sensor_collector (Leader + Commissioner + UDP sink) sensor_node N ----/| Sketch | 角色 |
|---|---|
| sensor_collector (Leader + UDP sink) | 服务端:ThreadLeader + Commissioner,并在端口5050上提供高节点 UDP 汇聚。 |
| sensor_node (Joiner sensor client) | 客户端:ThreadJoiner(可选Sleepy End Device)+ 周期性 UDP 传感器上报。 |
sensor_collector是 sensor_node 的对应端(counterpart),一台 collector 可以在同一张网络上服务任意多个 sensor 节点。它做五件事:
- 组建或恢复名为
ESP_OT_SENSOR_NET的 Thread 网络(重启后从 NVS 恢复 DataSet); - 申请Commissioner角色,为 PSKd
J01NME打开 Joiner 入网窗口; - 在 UDP 端口5050上监听传感器遥测帧;
- 跟踪最多
MAX_SENSORS个节点 ID,把静默节点标记为离线、驱逐过期记录,并发送应用层序列号 ACK; - 一旦 collector 自身从网络掉线,自动重启 Thread 并重开 UDP 套接字。
完整的组网流程、线缆协议(wire protocol)与运行步骤见 UDP Sensor Network 总览 README。
支持的目标平台与 IDF 依赖
支持的 SoC
| SoC | Thread | 状态 |
|---|---|---|
| ESP32-H2 | 支持 | Supported |
| ESP32-C6 | 支持 | Supported |
| ESP32-C5 | 支持 | Supported |
必需的 sdkconfig 特性
sensor_collector的 ci.yml 中声明了该示例的编译前提,与下表一一对应:
| 特性 | 作用 |
|---|---|
CONFIG_OPENTHREAD_ENABLED=y | 构建 OpenThread 协议栈。 |
CONFIG_SOC_IEEE802154_SUPPORTED=y | 确认 SoC 具备 802.15.4 射频。 |
CONFIG_OPENTHREAD_COMMISSIONER=y | 启用 Commissioner API 用于 Joiner 准入。 |
其中CONFIG_OPENTHREAD_COMMISSIONER是 collector 独有依赖——在 OThread.cpp 中,startCommissioner()、addJoiner()、stopCommissioner()、getCommissionerState()等 Commissioner 相关接口全部被#if CONFIG_OPENTHREAD_COMMISSIONER包裹,未开启该开关时这些 API 根本不会被编译出来。而 sensor_node 侧对应的开关是CONFIG_OPENTHREAD_JOINER=y,两个开关不要混淆。
部署前提与启动顺序
- 必须先烧录本 collector,再烧录任何 sensor_node 开发板。
- 在烧录或复位传感器之前,先等待串口出现
Commissioner ACTIVE与Collector listening on UDP port 5050两条日志。 - 首次启动时,collector 会 provision 一个全新的 Active Dataset;此后每次启动都从 NVS 中恢复已存储的 dataset,因此复位 collector 后恢复的是同一张 Thread 网络。已被 commission 过的传感器同样会恢复 NVS 中的 dataset,正常掉电复位后不需要重新打开 joiner 窗口。
结合 UDP Sensor Network 总览 的 How to Run,完整操作是:
- 在一块开发板上烧录
sensor_collector,串口监视器波特率设为115200; - 等待其打印
Collector listening on UDP port 5050; - 在其余一块或多块开发板上烧录
sensor_node; - 确认每个传感器打印
status=ACKED、collector 打印到接收到的采样。
核心流程源码走读
以下以 sensor_collector.ino 为准展开。
1) Thread 网络:恢复 NVS dataset 或 provision 新网络
startThreadNetwork()的核心逻辑(sensor_collector.ino):
OThread.begin(false); if (OThread.hasActiveDataset()) { // 从 NVS 恢复已持久化的 Active Dataset } else { // 首次上电:用固定参数构建 DataSet 并提交 DataSet ds; ds.initNew(); ds.setNetworkName(OT_NETWORK_NAME); // "ESP_OT_SENSOR_NET" ds.setChannel(OT_CHANNEL); // 15 ds.setPanId(OT_PAN_ID); // 0xABCE ds.setExtendedPanId(OT_EXTPANID); ds.setNetworkKey(OT_NETKEY); // 128-bit OThread.commitDataSet(ds); } OThread.networkInterfaceUp(); OThread.start(); // 轮询等待设备角色达到 CHILD 以上(最多 24 * 2.5s = 60s) while (tries-- && OThread.otGetDeviceRole() < OT_ROLE_CHILD) { delay(2500); }要点:
- 网络参数是固定 DataSet——网络名
ESP_OT_SENSOR_NET、信道 15、PAN ID0xABCE、扩展 PAN ID 与 128 位网络密钥均写死在文件头部(常量定义)。collector 与传感器必须使用同一套 dataset 常量,PSKD也要一致(sensor_node 侧使用同样的"J01NME"与CHANNEL_HINT = 15)。 - 如果修改了
OT_*常量却不生效,是因为持久化在 NVS 中的 Active Dataset 优先级更高——需要擦除 NVS 或执行 OpenThreadfactoryreset后新常量才会生效。 - 等待 attach 的循环以角色判断为终止条件:
OT_ROLE_CHILD及更高级别(Router、Leader)都算成功。collector 因为没有父节点,最终会以 Leader 身份上线。
2) Commissioner 提权与 Joiner 窗口
// 2) Petition Commissioner and open the joiner window. OThread.startCommissioner(); OThread.addJoiner("J01NME", JOINER_WINDOW_SEC); // PSKd "J01NME",默认开 3600 sstartCommissioner()在 setup() 中执行;若首次失败,loop() 会在设备角色达到 CHILD 后每 5 秒重试一次,直到成功。
从源码实现看(OThread.cpp),这一过程比表面上复杂:
OpenThread::startCommissioner(uint32_t timeoutMs = 30000)(声明见 OThread.h)调用otCommissionerStart()发起petition,然后通过一个二进制信号量阻塞等待状态回调,直到状态变为OT_COMMISSIONER_STATE_ACTIVE(petition 被 Leader 接受)或OT_COMMISSIONER_STATE_DISABLED(被拒绝),超时默认 30 s;- 若返回
OT_ERROR_ALREADY(已经是 ACTIVE)会直接按成功处理;petition 被拒绝则返回OT_ERROR_REJECTED,示例中会打印startCommissioner failed: %d; addJoiner(pskd, timeoutSec = 120, eui64 = nullptr)(OThread.cpp)调用otCommissionerAddJoiner(),以 ASCII PSKd"J01NME"打开入网窗口,JOINER_WINDOW_SEC默认 3600 s;本例不传eui64,即窗口对任意 EUI-64的 Joiner 开放,任何持有正确 PSKd 的 sensor_node 都可以加入。
Commissioner 是 Thread 中唯一有权打开 Joiner 窗口的角色,因此这个"先当 Leader、再当 Commissioner"的组合恰好让 collector 成为入网准入的权威节点。同一组合但没有 UDP 流量的精简版本可参考 CommissionerNode 示例。
3) UDP 监听与序列号 ACK 协议
// 3) Bind UDP on port 5050 and process incoming frames in loop(). OtUdp.begin(COLLECTOR_PORT); // 5050OtUdp是 OThreadUDP(class OThreadUDP : public UDP),即绑定在 Thread 网络接口(MLD,Mesh-Local Address 域)上的 UDP 套接字,而非 WiFi/Ethernet 上的普通 UDP。
入站帧为 ASCII 格式(wire protocol):
id=<nodeId>,seq=<u32>,temp_centi=<i32>,batt_mv=<u16>示例帧:
id=3CAAB123,seq=42,temp_centi=2387,batt_mv=3810collector 的 ACK 格式:
OK,<nodeId>,<seq>processPacket()(sensor_collector.ino)是整个示例的协议核心,它用sscanf解析四个字段后执行一套完整的序列号状态机:
| 帧类型 | 判定条件 | collector 行为 |
|---|---|---|
| 首次帧 | packetCount == 0 | 接受读数 |
| 新帧 | seq > lastSeq | 接受读数,更新lastSeq |
| 重复帧 | seq == lastSeq | 记为(dup),不覆盖读数 |
| 节点重启 | seq == 1 && lastSeq > 1 | 视为新流重新开始,接受并清零重复计数 |
| 过期/乱序帧 | seq < lastSeq(且不满足重启条件) | 不覆盖最新读数,计为 stale |
关键设计是collector 是序列号的权威方:无论收到什么帧,ACK 里回的都是 collector自己存储的lastSeq,而不是收到的seq(sendAck() 与注释)。这样 sensor_node 端(见 sensor_node.ino 的 parseAckSeq 与 RESYNC 逻辑)可以用 collector 的存储值向前滚动(RESYNC)或保持原序列重试(NO_ACK),实现应用层重同步——传感器侧持有"已被确认的最后序列号"s_seq,每次发送s_seq + 1,只有收到 ACK 才前进。
端口注意事项:应用流量务必使用 UDP 端口5050,避开61631(Thread TMF CoAP)与5683/5684(CoAP)。若把应用套接字绑定到这些保留端口,可能收到二进制的 Thread 管理流量,在串口上表现为DROP malformed。
4) 节点表:离线判定、记录驱逐与统计
节点表是纯 RAM 结构:static SensorRecord s_records[MAX_SENSORS],每个记录包含used / online / nodeId / lastSeq / lastTempCenti / lastBatteryMv / packetCount / duplicateCount / lastSeenMs / lastIp(结构定义)。新节点到来时按nodeId线性查找,找不到则分配空槽;表满时直接丢弃并打印DROP table full (MAX_SENSORS=%u)。
auditNodes() 在每个报告周期(默认 30 s)执行两级老化:
age > NODE_OFFLINE_MS(默认 95 s,约 3 个丢失的 30 s 采样):节点online置 false,打印node %s -> OFFLINE (silent %lus);age > NODE_EVICT_MS(默认 30 min):整个记录被释放,槽位归还给新节点。
配套的 printCompactReport() 打印角色、已知/在线节点数与累计收包、丢包计数。注意节点表不持久化:collector 复位后表会清空,这是预期行为——传感器会在 collector 从 NVS 恢复网络后通过重传把表重建起来。
5) 角色看门狗:掉线自愈
loop() 中每 5 秒检查一次设备角色,若掉到OT_ROLE_CHILD以下(Detached/Disabled),触发 restartThreadNetwork():
停止 UDP 套接字 -> OThread.stop() -> OThread.start()(复用 NVS 中持久化的 dataset) -> 最多 20s 轮询等待重新 attach -> 重新 begin(5050)即"collector 掉线时自动重启 Thread 并重建 UDP socket",无需人工干预。对应地,sensor_node 侧也有自己的forceReattach()与 3 次连续NO_ACK后强制重挂逻辑(sensor_node.ino),两端各自自愈。
期望的串口输出
按 README 描述,正常运行时的典型输出为:
=== sensor_collector: Thread Leader + UDP sink === Attached as Leader. Commissioner ACTIVE. PSKd "J01NME" open for 3600 s. Collector listening on UDP port 5050 RX [fdde:ad00:beef:0:....]:5050 <- 'id=3CAAB123,seq=1,temp_centi=2387,batt_mv=3810' TX ACK -> 'OK,3CAAB123,1' Fleet: known=1 online=1 total_rx=1 dropped=0实际 sketch 打印的格式略有不同(如RX node=... seq=... temp=...C batt=...mV from [...]、[collector] role=... nodes=... online=... packets=... dropped=...),但判断部署成功的标志是稳定的:出现Commissioner ACTIVE与Collector listening on UDP port 5050后,传感器每 30 s 一帧、每帧都有OK,<nodeId>,<seq>应答。
可调参数一览
所有可调常量集中在 sensor_collector.ino 文件头部:
| 常量 | 作用 |
|---|---|
PSKD | Commissioner 接受的 Joiner 密钥,sensor_node 必须一致("J01NME")。 |
JOINER_WINDOW_SEC | addJoiner()窗口有效期(默认 3600 s)。 |
OT_CHANNEL | 802.15.4 信道(默认 15)。 |
OT_PAN_ID | 16 位 PAN ID(默认0xABCE)。 |
OT_EXTPANID | 扩展 PAN ID 字节序列(默认CE 01 00 00 DE AD BE EF)。 |
OT_NETKEY | 128 位网络密钥。 |
OT_NETWORK_NAME | Thread 网络名("ESP_OT_SENSOR_NET")。 |
COLLECTOR_PORT | UDP 监听端口(默认 5050)。 |
MAX_SENSORS | 内存节点表容量(默认 256)。 |
NODE_OFFLINE_MS | 节点被标记离线前的静默时长(默认 95 s)。 |
NODE_EVICT_MS | 节点记录被驱逐前的静默时长(默认 30 min)。 |
REPORT_PERIOD_MS | 舰队统计打印周期(默认 30 s)。 |
修改 dataset 相关常量(OT_*、PSKD)时记住:若 NVS 中已有旧 dataset,旧数据优先,需要擦除 NVS 或factoryreset才能让新常量生效;两侧(collector 与 node)的PSKD、信道等必须同步修改。
故障排查
启动顺序:先烧录本 collector,等到Collector listening on UDP port 5050再烧录 sensor_node 开发板;对任何在 collector 就绪前启动的传感器执行复位。
| 现象 | 可能原因 |
|---|---|
| 没有任何传感器能 attach(首次 commission) | Commissioner join 窗口未开——先等Commissioner ACTIVE再启动传感器。 |
| Collector 复位后节点表丢失 | 预期行为——表在 RAM 中;collector 从 NVS 重连后传感器会重建表。 |
来自 RLOC 地址的DROP malformed | 应用绑定到了 Thread 保留端口——用5050,不要用 61631/5683/5684。 |
修改OT_*常量不生效 | Dataset 已持久化在 NVS——先擦 flash 或factoryreset再期望新常量生效。 |
| Collector 掉线 | 角色看门狗会自动重启 Thread 并重开 UDP socket——检查射频环境与构建开关(CONFIG_OPENTHREAD_COMMISSIONER=y等)。 |
此外,sensor_node 侧 README/总览文档 还补充了几条客户端视角的现象:传感器NO_ACK(collector 未绑定、dataset/PSKd 不匹配或端口错误);collector 复位后传感器卡住(传感器在丢失 ACK 后会强制重挂,可等待或复位);传感器掉电重启后能工作但首次入网失败(预期——已 commission 的传感器走 NVS 恢复,joiner 窗口只用于首次 commission 或擦除 NVS 之后)。
相关资源
- UDP Sensor Network 总览——线缆协议与多节点架构;
- sensor_node(Joiner + 可选 SED 传感器客户端);
- CommissionerNode——同样的 Leader + Commissioner 组合,但不带 UDP 流量;
- Native UDP 示例总览——所有 Native UDP demo 与端口约定;
- API 实现:OThread.cpp(Commissioner 部分)、OThread.h、OThreadUDP.h。
本示例基于 Apache License 2.0 许可。
【免费下载链接】arduino-esp32Arduino core for the ESP32 family of SoCs项目地址: https://gitcode.com/GitHub_Trending/ar/arduino-esp32
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考