arduino-esp32 实战:用 OpenThread SRP 服务发布 + UDP 打造 Thread 智能灯(ThreadDNSSD UDP Light 的 light 端)
2026/9/14 19:04:14 网站建设 项目流程

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
switchThread 节点: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),防止与栈内协议端口冲突。

支持平台与编译前提

支持目标

SoCThreadRGB LED状态
ESP32-H2yes必需Supported
ESP32-C6yes必需Supported
ESP32-C5yes必需Supported

RGB LED 不是可选项——草图通过rgbLedWrite(RGB_BUILTIN, ...)直接点亮板载 RGB 灯珠作为灯的物理执行器(源码见 light.ino 的fadeTo/applyLamp),没有 RGB LED 的板子无法按预期运行。

必需 IDF 特性(sdkconfig)

特性作用
CONFIG_OPENTHREAD_ENABLED=yOpenThread 协议栈
CONFIG_SOC_IEEE802154_SUPPORTED=y802.15.4 射频(仅 H2/C6/C5 等带 15.4 的 SoC 满足)
CONFIG_OPENTHREAD_SRP_CLIENT=yOThreadDNSSD服务发布所依赖的 SRP 客户端

这组配置与 ci.yml 中声明的requires完全一致,意味着 CI 在缺少这些开关时会跳过编译该示例。从 OThreadDNSSD.h 头部的条件编译宏也能确认:整个 DNSSD API 只有在SOC_IEEE802154_SUPPORTEDCONFIG_OPENTHREAD_ENABLEDCONFIG_OPENTHREAD_SRP_CLIENT同时开启时才可见。

前提条件:OTBR 与擦除策略

运行前需要:

  1. 一个已开启 SRP 的 OTBR(OpenThread Border Router)挂在同一 Thread 网络上。SRP 服务器通常由 Border Router 承载;light 的OThreadDNSSD发布请求最终要送达该 SRP 服务器才算成功。
  2. 把草图中的OT_NETKEY改成 OTBR 的 Network Key。示例默认值为00112233445566778899aabbccddeeff(light.ino L31),若不匹配,waitAttached(60000)超时会打印FAIL: not attached永久停住(见下文故障排查)。
  3. 烧录时优先选择 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。若绑定失败同样haltFAIL: 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; }

这里有两条值得注意的实现细节:

  1. 回调运行在 OpenThread 任务上end()触发的REMOVED除外,它在调用者任务即loop()/setup()上触发)——头文件 L181-L193 明确要求"不要在回调内调用其他 OThreadDNSSD 方法,只置标志/拷贝状态"。因此示例只在回调里更新三个volatile变量,真正的处理延迟到loop()
  2. s_ignoreLocalRemoved屏蔽自激 REMOVEDstartAdvertise()开头调用OThreadDNSSD.end()会立即在本任务上派发一个REMOVED事件。若不加屏蔽,这个由自己主动触发的 REMOVED 会被loop()误判为"服务被服务器移除"从而多余地再排一次重发布。该窗口期标志正是为区分"被动失效"与"主动重建"。

三类事件在loop()中的处理逻辑(L212-L234):

事件处理
OT_DNSSD_EVENT_ANNOUNCEDs_announced = true、清除s_needReadvertise,打印PASS: ANNOUNCED as ot-light _otlight._udp:5051
OT_DNSSD_EVENT_ERROR若错误是OT_ERROR_DUPLICATEDOT_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 事件外,还维护两个独立的健康信号:

  1. 附着丢失检测:比较上一轮与本轮的角色,若从"已附着"跌落(OTBR 重启/信号丢失),打印Lost attach (role=...) — will re-advertise when attached again,清除s_announced并置s_needReadvertise
  2. 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_nameConflictOT_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=ON

OTBR 重启后的典型恢复序列是:先看到EVENT: ERRORAnnounce 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 一致
kHostNameSRP 主机名(默认ot-light
LIGHT_PORTUDP 监听端口(默认 5051)
kReadvertiseCooldownMs恢复发布尝试之间的等待(默认 15 s)

如果修改了kHostNameLIGHT_PORT,必须在 switch 以及任何 web / PC 客户端镜像同样的服务类型与端口,否则发现端会查不到实例。另外按 OThreadDNSSD.h L252-L253 的建议,多台 light 共享同一 OTBR 时应为每台设备使用唯一主机名,从根上规避OT_ERROR_DUPLICATED

故障排查

整合 README 的排障表与源码行为:

现象可能原因 / 行为
FAIL: not attachedNetwork 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询