Breadboard
docs
Example project · Smart home

OpenThread: H2 node + C6 leader

The Wi-Fi-free ESP32-H2 joins a real Thread mesh with an ESP32-C6. The C6 auto-forms the fixed Breadboard network as leader; after eight seconds the H2 commits the matching dataset and attaches as a child over MAC-secured MLE. No typing needed. Both serial tabs narrate their roles and keep a full OpenThread CLI, the Code tab shows both exact Arduino sketches, and the 📡 Sniffer shows their bidirectional 802.15.4 traffic.

The OpenThread: H2 node + C6 leader circuit as rendered by the simulator

What's on the bench

  • 2× Battery
  • ESP32-C6
  • ESP32-H2 DevKitM-1

How it's wired

  • Battery · posESP32-H2 DevKitM-1 · 5v
  • Battery · negESP32-H2 DevKitM-1 · 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.

ESP32-H2 · attaching 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);
}

ESP32-C6 · 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);
}

Related projects