breadboard
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 · pos→ESP32-H2 DevKitM-1 · 5v
  • Battery · neg→ESP32-H2 DevKitM-1 · 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.

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

Firmware licence

Notice: this firmware uses the LGPL-licensed Arduino core library

arduino-openthread-node-h2.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.

Notice: this firmware uses the LGPL-licensed Arduino core library

arduino-openthread-leader-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.

Related projects