arduino-esp32 实战:用 OpenThread SRP 服务发布 + UDP 打造 Thread 智能灯(ThreadDNSSD UDP Light 的 light 端)
【免费下载链接】arduino-esp32Arduino core for the ESP32 family of SoCs项目地址: https://gitcode.com/GitHub_Trending/ar/arduino-esp32
本文以 OpenThread 示例中的 light 草图为主体,讲解如何在 ESP32 Thread SoC(H2 / C6 / C5)上加入一个已有的 OTBR Thread 网络,通过 SRP 客户端把_otlight._udp服务发布到边界路由器,并用轻量 UDP 服务响应ON/OFF/TOGGLE/STATUS命令驱动板载 RGB LED。读完本文,你能完整理解该示例的入网流程、DNSSD 服务发布的事件驱动机制、UDP 命令协议,以及"SRP 发布失败/断网后从loop()自愈重发布"的工程化状态机设计,并能按文档完成烧录、联调与故障定位。
示例定位:ThreadDNSSD UDP Light 实验中的服务方
该示例属于ThreadDNSSD_UDP_Light端到端实验(见 组级 README),由三个草图协作完成:
| 草图 | 角色 |
|---|---|
| light(本文主角) | Thread 节点:SRP 发布_otlight._udp+ UDP 灯服务端(端口5051) |
| switch | Thread 节点:queryService发现灯,BOOT 按键发送TOGGLE/STATUS |
| web | 仅 Wi-Fi:通过http://otlight-ui.local,经 OTBR(Advertising Proxy + OMR 路由)访问同一盏灯 |
[Thread switch] --OThreadDNSSD+UDP--> [Thread light] --SRP--> [OTBR] | Adv Proxy mDNS [Browser] --HTTP--> [Wi-Fi web] --ESPmDNS+UDP-------------+与 UDP Light Switch 实验不同,这一组的板子以 Network Key 加入一个已存在的 OTBR 网络(草图内不启动 Commissioner / Joiner)。light 是唯一的服务方:它加入网络后向 OTBR 上的 SRP 服务器注册主机名与 UDP 服务,供网内的 switch 浏览发现和 Wi-Fi 侧经 OMR 路由访问。
端口 5051 是有意为之:应避开 5683 / 5684(CoAP)和 61631(Thread TMF CoAP),防止与栈内协议端口冲突。
支持平台与编译前提
支持目标
| SoC | Thread | RGB LED | 状态 |
|---|---|---|---|
| ESP32-H2 | yes | 必需 | Supported |
| ESP32-C6 | yes | 必需 | Supported |
| ESP32-C5 | yes | 必需 | Supported |
RGB LED 不是可选项——草图通过rgbLedWrite(RGB_BUILTIN, ...)直接点亮板载 RGB 灯珠作为灯的物理执行器(源码见 light.ino 的fadeTo/applyLamp),没有 RGB LED 的板子无法按预期运行。
必需 IDF 特性(sdkconfig)
| 特性 | 作用 |
|---|---|
CONFIG_OPENTHREAD_ENABLED=y | OpenThread 协议栈 |
CONFIG_SOC_IEEE802154_SUPPORTED=y | 802.15.4 射频(仅 H2/C6/C5 等带 15.4 的 SoC 满足) |
CONFIG_OPENTHREAD_SRP_CLIENT=y | OThreadDNSSD服务发布所依赖的 SRP 客户端 |
这组配置与 ci.yml 中声明的requires完全一致,意味着 CI 在缺少这些开关时会跳过编译该示例。从 OThreadDNSSD.h 头部的条件编译宏也能确认:整个 DNSSD API 只有在SOC_IEEE802154_SUPPORTED且CONFIG_OPENTHREAD_ENABLED且CONFIG_OPENTHREAD_SRP_CLIENT同时开启时才可见。
前提条件:OTBR 与擦除策略
运行前需要:
- 一个已开启 SRP 的 OTBR(OpenThread Border Router)挂在同一 Thread 网络上。SRP 服务器通常由 Border Router 承载;light 的
OThreadDNSSD发布请求最终要送达该 SRP 服务器才算成功。 - 把草图中的
OT_NETKEY改成 OTBR 的 Network Key。示例默认值为00112233445566778899aabbccddeeff(light.ino L31),若不匹配,waitAttached(60000)超时会打印FAIL: not attached并永久停住(见下文故障排查)。 - 烧录时优先选择 Tools → Erase Flash: Sketch Only。这样 NVS 中保存的 SRP ECDSA 密钥会被保留,避免重刷后 SRP 服务器认为同一密钥重新注册而报
OT_ERROR_DUPLICATED。
入网流程:只用 Network Key 加入 OTBR
setup()的入网序列(light.ino L167-L197):
OThread.begin(false); DataSet ds; ds.clear(); ds.setNetworkKey(OT_NETKEY); OThread.commitDataSet(ds); OThread.networkInterfaceUp(); OThread.start(); // waitAttached(): 轮询 otGetDeviceRole(), // 直到角色变为 Child / Router / Leader 之一(60 s 超时)要点:
OThread.begin(false)初始化 OpenThread 实例;commitDataSet把含 Network Key 的 Dataset 写入后,networkInterfaceUp()+start()开始组网状态机。waitAttached()(L82-L91)每 200 ms 轮询一次OThread.otGetDeviceRole(),只要角色属于Child / Router / Leader三种"已附着"角色即返回;60 秒超时则调用halt()死循环停止,串口输出FAIL: not attached (check Network Key vs OTBR)。这是示例唯一在入网阶段停住的失败点。- 附着成功后立即
OtUdp.begin(LIGHT_PORT)绑定 UDP。若绑定失败同样halt(FAIL: UDP begin)。
注意示例的失败分级策略(L93-L94 注释):只有入网和 UDP 绑定两类致命错误才停住;而 SRP 发布的begin()/addService()失败不 halt,而是置s_needReadvertise标志,交由loop()在冷却期后重试。这样即使 SRP 服务器还没就绪,UDP 服务也始终在线。
SRP 服务发布:OThreadDNSSD 事件驱动
服务发布的核心在startAdvertise()(light.ino L114-L139):
static bool startAdvertise(const char *reason) { s_ignoreLocalRemoved = true; OThreadDNSSD.end(); // 先注销旧主机/服务 s_ignoreLocalRemoved = false; s_announced = false; if (!OThreadDNSSD.begin(kHostName)) return false; // "ot-light" if (!OThreadDNSSD.addService("otlight", "udp", LIGHT_PORT)) return false; // _otlight._udp:5051 (void)OThreadDNSSD.addServiceTxt("otlight", "udp", "cmds", "on,off,toggle,status"); Serial.println("Waiting for OT_DNSSD_EVENT_ANNOUNCED..."); return true; }逐行对应到 OThreadDNSSD.h 的 API 语义:
begin(kHostName):配置本地 SRP 主机名并启用 SRP 自动启动。头文件明确"false 是本地/配置层面的失败,不代表 SRP 服务器未就绪",且要求OThread.begin()已调用、最好在已附着角色下使用。addService("otlight", "udp", 5051):以 ESPmDNS 风格发布服务。服务类型前后下划线可选("ot"/"_ot"均可,库会剥除前导下划线),最终编码为_otlight._udp。同一 service+proto 重复调用是幂等更新。addServiceTxt("otlight", "udp", "cmds", "on,off,toggle,status"):附加 TXT 记录,把支持命令集暴露在 DNS-SD 元数据里,便于发现方(switch / web)自省协议。TXT 槽位默认每服务最多 4 条(OT_DNSSD_MAX_TXT_ENTRIES),键长 16、值长 32 字节,超限会在 include 头文件前以#define覆盖。
事件回调:只置标志,不做事
setup()中注册OThreadDNSSD.onServiceEvent(onDnsEvent)。回调实现(L102-L111):
static void onDnsEvent(ot_dnssd_event_t event, otError error, void *context) { (void)context; // OpenThread task (or caller task for end()-generated REMOVED): flags only. if (event == OT_DNSSD_EVENT_REMOVED && s_ignoreLocalRemoved) { return; } s_event = event; s_err = error; s_gotEvent = true; }这里有两条值得注意的实现细节:
- 回调运行在 OpenThread 任务上(
end()触发的REMOVED除外,它在调用者任务即loop()/setup()上触发)——头文件 L181-L193 明确要求"不要在回调内调用其他 OThreadDNSSD 方法,只置标志/拷贝状态"。因此示例只在回调里更新三个volatile变量,真正的处理延迟到loop()。 s_ignoreLocalRemoved屏蔽自激 REMOVED:startAdvertise()开头调用OThreadDNSSD.end()会立即在本任务上派发一个REMOVED事件。若不加屏蔽,这个由自己主动触发的 REMOVED 会被loop()误判为"服务被服务器移除"从而多余地再排一次重发布。该窗口期标志正是为区分"被动失效"与"主动重建"。
三类事件在loop()中的处理逻辑(L212-L234):
| 事件 | 处理 |
|---|---|
OT_DNSSD_EVENT_ANNOUNCED | 置s_announced = true、清除s_needReadvertise,打印PASS: ANNOUNCED as ot-light _otlight._udp:5051 |
OT_DNSSD_EVENT_ERROR | 若错误是OT_ERROR_DUPLICATED或OT_ERROR_SECURITY:置s_nameConflict = true且不自动重试(同名冲突必须人工换主机名/清状态);其他错误:s_announced = false,等待冷却后重发布 |
OT_DNSSD_EVENT_REMOVED | 服务消失:s_announced = false,等待冷却后重发布 |
[OThreadDNSSD.h](https://link.gitcode.com/i/0fbb4a0f537540b0a6d483b95581d8fb#L59-L61)头部注释也印证了库的策略边界:"Name conflicts (OT_ERROR_DUPLICATED) are reported to the sketch; the library does not rename. Prefer unique hostnames and keep NVS across reflash."(库报告冲突但绝不自动改名,策略留给草图。)
UDP 服务端:绕开 lwIP 的最轻 Thread UDP
灯控通道使用OThreadUDP(OThreadUDP.h),它是直接构建在otUdpSocketAPI 上的 ArduinoUDP实现,不经过 lwIP,是 Thread 侧最轻量的 UDP 通路。两个可构建期覆盖的容量参数值得了解:
OT_UDP_MAX_PACKET_SIZE(默认 512 字节):单个入站数据报入队上限,超限截断;OT_UDP_RX_QUEUE_DEPTH(默认 4):parsePacket()之间可排队的入站数据报数量,队列满时丢旧包。
OtUdp.begin(LIGHT_PORT)会把套接字绑定到 IPv6 any 地址(OT_IN6ADDR_ANY,即::)的 5051 端口,覆盖所有接口。
Wire 协议
命令在serviceUdp()(L141-L165)中按整串文本精确匹配(strcmp),缓冲区 32 字节:
| 收到的数据报 | 动作 | 回复 |
|---|---|---|
ON | 灯开 | ACK ON |
OFF | 灯关 | ACK OFF |
TOGGLE | 反转灯状态 | ACK ON/ACK OFF |
STATUS | 不改变状态 | STATE ON/STATE OFF |
| 其他 | 忽略(串口打印Ignoring unknown command) | 无 |
回复通过reply()以单播发往OtUdp.remoteIP()/OtUdp.remotePort()(即来源地址/端口),不做广播。物理执行器侧,applyLamp(true)会把亮度从 0 以 2 ms 步长渐变到 248,关闭则渐隐到 0(fadeTo,L54-L65),用rgbLedWrite(RGB_BUILTIN, level, level, level)写白光。
UDP 与 SRP 的生命周期解耦
文档强调 "UDP starts before SRP completes",源码上也确实如此:setup()的顺序是先附着 → 先OtUdp.begin()→ 后startAdvertise("initial");整个loop()的重发布流程只操作OThreadDNSSD,从不stop()/重新begin()UDP 套接字。因此 OTBR 重启、SRPend()/begin()重建期间,UDP 服务始终在线——只要发现方手里有灯的 OMR 地址,控制通道不因 SRP 故障而中断。这也是该示例相对"阻塞式waitForAnnounce"写法的工程价值所在。
自愈状态机:loop() 里的重发布逻辑
loop()(L199-L259)每 20 ms 一轮,除了处理 UDP 和 DNSSD 事件外,还维护两个独立的健康信号:
- 附着丢失检测:比较上一轮与本轮的角色,若从"已附着"跌落(OTBR 重启/信号丢失),打印
Lost attach (role=...) — will re-advertise when attached again,清除s_announced并置s_needReadvertise; - Announce 完整性巡检:即使曾收到过
ANNOUNCED,若OThreadDNSSD.isAnnounceComplete()变 false(该函数每次调用实时读取otSrpClientGetHostInfo/otSrpClientGetServices,见 OThreadDNSSD.h L366-L373),打印Announce incomplete — scheduling re-advertise并重新排队。
重发布的触发门控(L242-L248):
if (s_needReadvertise && s_attached && !s_nameConflict) { if (now - s_lastAdvertiseMs >= kReadvertiseCooldownMs) { // 默认 15 s s_needReadvertise = false; (void)startAdvertise("recovery"); } }即必须同时满足"已附着 + 未处于命名冲突 + 距上次发布尝试 ≥ 15 s(kReadvertiseCooldownMs)"才执行startAdvertise("recovery")。这与 ThreadDNSSD_Advertise_Callback 示例是同一套模式:库负责报告,草图负责决策(重试、放弃、改名)。唯一例外是s_nameConflict(OT_ERROR_DUPLICATED/OT_ERROR_SECURITY),它会永久抑制自动重试,直到人工干预——因为用同一主机名无限重试只会反复撞车。
loop()末尾每 10 秒打印一次状态心跳:role=Child announce=1 needReadvertise=0 lamp=ON,是运行期最直观的"体检表"。
预期串口输出
正常启动路径(与 README 给出的样例一致):
ThreadDNSSD_UDP_Light / light Waiting to attach... Attached as Child UDP listening on port 5051 (MLEID fd..) Advertise (initial) as ot-light... Waiting for OT_DNSSD_EVENT_ANNOUNCED... PASS: ANNOUNCED as ot-light _otlight._udp:5051 RX [fd..]:xxxxx <- 'TOGGLE' role=Child announce=1 needReadvertise=0 lamp=ONOTBR 重启后的典型恢复序列是:先看到EVENT: ERROR或Announce incomplete,随后Advertise (recovery)...,再出现一个PASS。注意UDP listening ... (MLEID fd..)里打印的是OThread.getMeshLocalEid(),即 mesh-local EID;switch 经 SRP 发现解析到的实际是 OMR 地址(fd..前缀的 OMR 前缀),可把解析出的 OMR 粘贴给 web 端做LIGHT_IPV6_FALLBACK(见 switch README)。
自定义参数
| 常量 | 用途 |
|---|---|
OT_NETKEY | 必须与 OTBR 的 Network Key 一致 |
kHostName | SRP 主机名(默认ot-light) |
LIGHT_PORT | UDP 监听端口(默认 5051) |
kReadvertiseCooldownMs | 恢复发布尝试之间的等待(默认 15 s) |
如果修改了kHostName或LIGHT_PORT,必须在 switch 以及任何 web / PC 客户端镜像同样的服务类型与端口,否则发现端会查不到实例。另外按 OThreadDNSSD.h L252-L253 的建议,多台 light 共享同一 OTBR 时应为每台设备使用唯一主机名,从根上规避OT_ERROR_DUPLICATED。
故障排查
整合 README 的排障表与源码行为:
| 现象 | 可能原因 / 行为 |
|---|---|
FAIL: not attached | Network Key 与 OTBR 不匹配(草图halt停住) |
FAIL: UDP begin | 本地 UDP 绑定错误(草图halt停住) |
FAIL: OThreadDNSSD.begin/addService | 非致命:由loop()在 15 s 冷却后自动重试(不 halt) |
始终没有PASS: ANNOUNCED | 网络上还没有 SRP 服务器——UDP 仍在监听;等待或检查srp server service |
OT_ERROR_DUPLICATED | 命名冲突——换唯一主机名、Sketch Only 擦除、或清理 OTBR SRP 软状态(不会自动重试) |
| OTBR 重启后 switch 发现 0 个实例 | 等 light 打印Advertise (recovery)与新的PASS后再试 |
启动顺序建议(来自组级 README):先起 OTBR → 再烧 light(等 SRP announce)→ 最后 switch / web。
延伸阅读
- ThreadDNSSD UDP Light — 组级总览:light + switch + web 的完整实验拓扑、端口选择理由与启动顺序;
- switch:客户端侧如何用
OThreadDNSSD.queryService("otlight", "udp")浏览并单播控制灯; - ThreadDNSSD_Advertise_Callback:本示例重发布模式的原型(事件回调式 advertise);
- ThreadDNSSD 示例集:advertise / query / remove 各 API demo,以及 Flash 擦除与 SRP 命名冲突的处理说明;
- OThreadDNSSD.h / OThreadUDP.h:发布与查询 API 的完整头文件文档(池容量宏、事件语义、结果获取器稳定性约定)。
本示例代码采用 Apache License 2.0 授权。
【免费下载链接】arduino-esp32Arduino core for the ESP32 family of SoCs项目地址: https://gitcode.com/GitHub_Trending/ar/arduino-esp32
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考