Thread sensor network (hub + node)
Thread made visible: the hub DevKit forms the network as leader and listens on UDP, and the sealed sensor node attaches as its child and reports its temperature every 5 seconds, each report a MAC-secured 802.15.4 frame you can follow in the 📡 Sniffer. Watch the hub’s serial tab print every datagram; the full OpenThread CLI stays available there (`child table`, `ipaddr`). Both sketches are in the Code tab.

How it works
Thread's whole story in two boards: the hub DevKit commits a complete operational dataset and forms the network as leader, then opens a UDP socket on port 12345. The sealed sensor node carries the same dataset, waits a few seconds so the leader forms first, attaches as a child (MAC-secured MLE the whole way), and then reports its temperature every 5 seconds — a UDP datagram to the mesh-local multicast ff03::1 that the hub prints as it arrives. The full OpenThread CLI stays live on both serial tabs.
What's on the bench
- 2× Battery
- ESP32-C6
- Thread Sensor Node
How it's wired
- Battery · pos→ESP32-C6 · vin
- Battery · neg→ESP32-C6 · gnd
- Battery · pos→Thread Sensor Node · vcc
- Battery · neg→Thread Sensor Node · gnd
The code
The exact Arduino C++ sketch(es) the bundled firmware was compiled from — shown in the simulator’s Code tab, where you can edit them and press Compile & upload to rebuild the board’s firmware.
Thread hub (leader)
// Thread hub leader — forms the Breadboard Thread network and listens on // UDP :12345 for the sensor node's reports, printing each one. Same // complete fixed dataset as the sensor sketch; this board starts // immediately so it becomes the leader. #include <Arduino.h> #include "OThread.h" #include "OThreadCLI.h" #include "OThreadCLI_Util.h" OpenThread node; // Must be identical to the ThreadSensorNode sketch's dataset. static const char *DATASET[][2] = { {"dataset activetimestamp", "1"}, {"dataset channel", "15"}, {"dataset channelmask", "0x07fff800"}, {"dataset panid", "0x1234"}, {"dataset extpanid", "1122334455667788"}, {"dataset networkname", "Breadboard"}, {"dataset networkkey", "00112233445566778899aabbccddeeff"}, {"dataset pskc", "00112233445566778899aabbccddeeff"}, {"dataset meshlocalprefix", "fd11:2233:4455:6677::"}, {"dataset securitypolicy", "672 onrc"}, {"dataset commit", "active"}, {"ifconfig", "up"}, {"thread", "start"}, }; static bool udpBound = false; void setup() { Serial.begin(115200); OThread.begin(false); // fresh start — don't resume a dataset from NVS OThreadCLI.begin(); Serial.println("Thread hub starting: forming the Breadboard network."); for (auto &cmd : DATASET) { if (!otExecCommand(cmd[0], cmd[1])) { Serial.printf("[thread] '%s %s' failed\r\n", cmd[0], cmd[1]); } } Serial.println("Thread started - CLI ready, try `state` or `child table`."); Serial.print("ot> "); } // Serial ↔ CLI pump (received UDP datagrams print through the CLI stream). static void pumpCli() { while (Serial.available() > 0) { char ch = (char)Serial.read(); Serial.write(ch); OThreadCLI.write((uint8_t)ch); } static String lineBuf; while (OThreadCLI.available() > 0) { char ch = (char)OThreadCLI.read(); Serial.write(ch); if (ch == '\n') { if (lineBuf.startsWith("Done") || lineBuf.startsWith("Error")) { Serial.print("ot> "); } lineBuf = ""; } else if (ch != '\r') { lineBuf += ch; } } } void loop() { static ot_device_role_t lastRole = OT_ROLE_DISABLED; ot_device_role_t role = node.otGetDeviceRole(); if (role != lastRole) { lastRole = role; Serial.printf("\r\n[thread] role: %s\r\n", node.otGetStringDeviceRole()); if (role == OT_ROLE_LEADER) { Serial.printf("[thread] network \"%s\" formed, rloc16 0x%04x\r\n", node.getNetworkName().c_str(), node.getRloc16()); } } // Once the network is up, listen for the sensor's reports. if (!udpBound && role == OT_ROLE_LEADER) { if (otExecCommand("udp", "open") && otExecCommand("udp", "bind :: 12345")) { udpBound = true; Serial.println("[hub] listening on udp :12345 - sensor reports will print below."); Serial.print("ot> "); } } pumpCli(); delay(50); }
Sensor node
// Thread sensor node — a battery-style sensor that attaches to the // Breadboard Thread network as a child and reports its temperature over // UDP to the mesh-local all-nodes multicast (ff03::1), where the hub // leader is listening. Same complete fixed dataset as the leader; `thread // start` held back so the leader forms first. #include <Arduino.h> #include "OThread.h" #include "OThreadCLI.h" #include "OThreadCLI_Util.h" OpenThread node; // Must be identical to the ThreadHubLeader sketch's dataset. static const char *DATASET[][2] = { {"dataset activetimestamp", "1"}, {"dataset channel", "15"}, {"dataset channelmask", "0x07fff800"}, {"dataset panid", "0x1234"}, {"dataset extpanid", "1122334455667788"}, {"dataset networkname", "Breadboard"}, {"dataset networkkey", "00112233445566778899aabbccddeeff"}, {"dataset pskc", "00112233445566778899aabbccddeeff"}, {"dataset meshlocalprefix", "fd11:2233:4455:6677::"}, {"dataset securitypolicy", "672 onrc"}, {"dataset commit", "active"}, {"ifconfig", "up"}, {"thread", "start"}, }; static bool udpOpen = false; void setup() { Serial.begin(115200); OThread.begin(false); // fresh start — don't resume a dataset from NVS OThreadCLI.begin(); Serial.println("Thread sensor starting: attaching to the Breadboard network once the hub is up."); delay(8000); // let the hub form first → deterministic roles for (auto &cmd : DATASET) { if (!otExecCommand(cmd[0], cmd[1])) { Serial.printf("[thread] '%s %s' failed\r\n", cmd[0], cmd[1]); } } Serial.println("Thread started - reports begin once attached."); } // Synthetic temperature: a slow triangle wave 18..28 C, deterministic. static float sensorTempC() { uint32_t phase = (millis() / 1000) % 40; // 40 s period float ramp = phase < 20 ? phase : 40 - phase; // 0..20..0 return 18.0f + ramp * 0.5f; // 18..28 C } static void pumpCli() { while (Serial.available() > 0) { char ch = (char)Serial.read(); Serial.write(ch); OThreadCLI.write((uint8_t)ch); } while (OThreadCLI.available() > 0) { Serial.write((char)OThreadCLI.read()); } } void loop() { static ot_device_role_t lastRole = OT_ROLE_DISABLED; ot_device_role_t role = node.otGetDeviceRole(); if (role != lastRole) { lastRole = role; Serial.printf("\r\n[thread] role: %s\r\n", node.otGetStringDeviceRole()); if (role == OT_ROLE_CHILD || role == OT_ROLE_ROUTER) { Serial.printf("[thread] attached, rloc16 0x%04x\r\n", node.getRloc16()); } } bool attached = role == OT_ROLE_CHILD || role == OT_ROLE_ROUTER || role == OT_ROLE_LEADER; if (attached && !udpOpen) { udpOpen = otExecCommand("udp", "open"); if (udpOpen) { Serial.println("[sensor] udp open - reporting every 5 s to ff03::1:12345"); } } static uint32_t lastReport = 0; if (attached && udpOpen && millis() - lastReport >= 5000) { lastReport = millis(); char args[64]; float t = sensorTempC(); // OT CLI: udp send <ip> <port> <ascii-payload> snprintf(args, sizeof(args), "send ff03::1 12345 temp=%.1fC", t); if (otExecCommand("udp", args)) { Serial.printf("[sensor] sent temp=%.1fC\r\n", t); } } pumpCli(); delay(100); }
Firmware licence
Notice: this firmware uses the LGPL-licensed Arduino core library
arduino-thread-hub-c6.bin is statically linked with Arduino-ESP32 3.3.10. Parts of this library are licensed under the GNU Lesser General Public License, version 2.1 or (at your option) any later version. You may modify this work for your own use and reverse engineer it to debug those modifications. Download the relink package and corresponding source to rebuild it with a changed library.
- Relink package: thread-hub-c6-relink.zip (0.9 MB)
- Required shared inputs: arduino-esp32-3.3.10-esp32c6-sdk.zip (10.0 MB), arduino-esp32-3.3.10-notices.zip (0.1 MB)
- Corresponding source: arduino-esp32-3.3.10-core-source.zip (1.2 MB), arduino-esp32-3.3.10-esp32c6-headers-source.zip (2.1 MB)
- Licence texts and relink instructions
Notice: this firmware uses the LGPL-licensed Arduino core library
arduino-thread-sensor-c6.bin is statically linked with Arduino-ESP32 3.3.10. Parts of this library are licensed under the GNU Lesser General Public License, version 2.1 or (at your option) any later version. You may modify this work for your own use and reverse engineer it to debug those modifications. Download the relink package and corresponding source to rebuild it with a changed library.
- Relink package: thread-sensor-c6-relink.zip (0.9 MB)
- Required shared inputs: arduino-esp32-3.3.10-esp32c6-sdk.zip (10.0 MB), arduino-esp32-3.3.10-notices.zip (0.1 MB)
- Corresponding source: arduino-esp32-3.3.10-core-source.zip (1.2 MB), arduino-esp32-3.3.10-esp32c6-headers-source.zip (2.1 MB)
- Licence texts and relink instructions
Try this
- Watch the hub's serial: role leader, then 'listening on udp :12345', then temp=…C datagrams every 5 s.
- Type `child table` on the hub's serial to see the sensor attached as its child.
- Open the 📡 Sniffer: each report is a MAC-secured 802.15.4 data frame crossing the air.
- Power-cycle the sensor node (unwire/rewire its battery) and watch it re-attach and resume reporting.



