Breadboard
docs
Example project · Smart home

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.

The Thread mesh: two nodes circuit as rendered by the simulator

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 · posESP32-C6 · vin
  • Battery · negESP32-C6 · gnd
  • Battery · posESP32-C6 · vin
  • Battery · negESP32-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.

Related projects