Thread mesh: two nodes
Two ESP32-C6 boards running OpenThread on the shared air, and they mesh by themselves. Board 1 commits a complete dataset and forms the network as leader; board 2 carries only the network key, hears the leader and attaches as its child, exchanging MAC-secured MLE frames the whole way. Each serial tab narrates its board’s role changes and keeps the full OpenThread CLI (`state`, `parent`, `child table`); the Code tab holds both real sketches, and the 📡 Sniffer shows every frame.

How it works
Two C6 DevKits, both carrying the same complete Thread dataset in their Arduino sketches (both in the Code tab). Board 1 starts immediately, forms the network, and becomes leader; board 2 deliberately waits 8 seconds so the roles come out deterministic, then attaches as a child over MAC-secured MLE: commissioning by shared dataset, exactly how real deployments pre-provision devices. Both boards keep the full OpenThread CLI live on their serial tabs, so the mesh is completely inspectable from inside.
What's on the bench
- 2× Battery
- 2× ESP32-C6
How it's wired
- Battery · pos→ESP32-C6 · vin
- Battery · neg→ESP32-C6 · gnd
- Battery · pos→ESP32-C6 · vin
- Battery · neg→ESP32-C6 · 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.
Board 1 · leader
// OpenThread leader — auto-forms a Thread network on boot, no typing needed. // // setup() drives the real OpenThread CLI programmatically: it commits a // COMPLETE fixed operational dataset (timestamp, channel, PAN IDs, network // key, PSKc, mesh-local prefix, security policy) and starts Thread. With no // one to join, the node goes detached → leader in a few seconds. The CLI // stays fully interactive on the serial monitor — try `state`, // `dataset active`, `ipaddr`, `child table`. // // loop() pumps serial ↔ CLI itself instead of OThreadCLI.startConsole(): // the library's console worker busy-polls the stream and never lets the // CPU idle, while this pump sleeps between polls — much friendlier to // power (and to the emulator's idle-skip). // // A second board running the matching OpenThreadNode sketch (identical // dataset, delayed start) attaches to this network as a child. #include <Arduino.h> #include "OThread.h" #include "OThreadCLI.h" #include "OThreadCLI_Util.h" OpenThread node; // The Breadboard Thread network — every value fixed so each boot forms the // identical network, and any board with the same dataset can join it. static const char *DATASET[][2] = { {"dataset activetimestamp", "1"}, {"dataset channel", "15"}, // The channel mask is a required Active-Dataset TLV — the Joiner Entrust // (commissioning) can't be built without it. 0x07fff800 = channels 11-26. {"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"}, }; void setup() { Serial.begin(115200); OThread.begin(false); // fresh start — don't resume a dataset from NVS OThreadCLI.begin(); Serial.println("OpenThread starting (auto-forming): committing the Breadboard dataset."); 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`."); Serial.print("ot> "); } // Narrate role changes: disabled → detached → leader. static void narrateRole() { static ot_device_role_t lastRole = OT_ROLE_DISABLED; ot_device_role_t role = node.otGetDeviceRole(); if (role == lastRole) { return; } lastRole = role; Serial.printf("\r\n[thread] role: %s\r\n", node.otGetStringDeviceRole()); if (role == OT_ROLE_LEADER) { Serial.printf("[thread] network \"%s\" formed: channel %u, panid 0x%04x\r\n", node.getNetworkName().c_str(), node.getChannel(), node.getPanId()); Serial.printf("[thread] mesh-local EID: %s\r\n", node.getMeshLocalEid().toString().c_str()); Serial.printf("[thread] rloc16: 0x%04x - waiting for children\r\n", node.getRloc16()); } } // Serial ↔ CLI pump. Typed characters go to the CLI task (it executes on // newline); responses stream back, with a fresh prompt after each command // (the CLI ends every command with "Done" or "Error ..."). static void pumpCli() { while (Serial.available() > 0) { char ch = (char)Serial.read(); Serial.write(ch); // echo what you type 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 uint32_t lastRoleCheck = 0; if (millis() - lastRoleCheck >= 250) { lastRoleCheck = millis(); narrateRole(); } pumpCli(); delay(50); }
Board 2 · node
// OpenThread node — auto-attaches to the Breadboard Thread network on boot. // // Same COMPLETE fixed dataset as the OpenThreadLeader sketch, but `thread // start` is held back a few seconds so the leader forms first — by the time // this node starts MLE it hears the leader's network and attaches as a // child over MAC-secured MLE instead of forming its own partition. (If it // ever starts alone, the identical dataset means it simply forms the same // network itself and a later leader-image board merges with it.) // // The CLI stays fully interactive on the serial monitor — try `state`, // `parent`, `ipaddr`, `dataset active`. loop() pumps serial ↔ CLI itself // instead of OThreadCLI.startConsole(): the library's console worker // busy-polls the stream and never lets the CPU idle, while this pump // sleeps between polls — much friendlier to power (and to the emulator's // idle-skip). #include <Arduino.h> #include "OThread.h" #include "OThreadCLI.h" #include "OThreadCLI_Util.h" OpenThread node; // Must be identical to the OpenThreadLeader 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"}, }; void setup() { Serial.begin(115200); OThread.begin(false); // fresh start — don't resume a dataset from NVS OThreadCLI.begin(); Serial.println("OpenThread starting (auto-attaching): joining the Breadboard network once the leader is up."); delay(8000); // let board 1 form the network 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 - CLI ready, try `state` or `parent`."); Serial.print("ot> "); } // Narrate role changes: disabled → detached → child (→ router in a bigger // mesh). static void narrateRole() { static ot_device_role_t lastRole = OT_ROLE_DISABLED; ot_device_role_t role = node.otGetDeviceRole(); if (role == lastRole) { return; } lastRole = role; Serial.printf("\r\n[thread] role: %s\r\n", node.otGetStringDeviceRole()); if (role == OT_ROLE_CHILD) { Serial.printf("[thread] attached to \"%s\": channel %u, panid 0x%04x\r\n", node.getNetworkName().c_str(), node.getChannel(), node.getPanId()); Serial.printf("[thread] mesh-local EID: %s\r\n", node.getMeshLocalEid().toString().c_str()); Serial.printf("[thread] rloc16: 0x%04x\r\n", node.getRloc16()); } else if (role == OT_ROLE_ROUTER) { Serial.printf("[thread] promoted to router, rloc16: 0x%04x\r\n", node.getRloc16()); } else if (role == OT_ROLE_LEADER) { Serial.printf("[thread] no leader heard - formed \"%s\" myself\r\n", node.getNetworkName().c_str()); } } // Serial ↔ CLI pump. Typed characters go to the CLI task (it executes on // newline); responses stream back, with a fresh prompt after each command // (the CLI ends every command with "Done" or "Error ..."). static void pumpCli() { while (Serial.available() > 0) { char ch = (char)Serial.read(); Serial.write(ch); // echo what you type 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 uint32_t lastRoleCheck = 0; if (millis() - lastRoleCheck >= 250) { lastRoleCheck = millis(); narrateRole(); } pumpCli(); delay(50); }
Try this
- Type state on each board's serial tab: 'leader' on one, 'child' on the other.
- Type child table on the leader and find the node's entry.
- Open the 📡 Sniffer: MLE attach, then MAC-secured data frames.
- Power-cycle the node (unwire its battery, rewire it) and watch it re-attach on its own.



