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); }
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.



