Breadboard
docs
Example project · Smart home

OpenThread: a real Thread node

A real emulated ESP32-C6 running the unmodified OpenThread stack. No typing needed: the sketch commits a complete operational dataset in setup() and starts Thread, so the node comes up as leader on its own; the radio encrypts its MLE traffic with the real hardware AES-CCM* path. The full OpenThread CLI stays on the serial monitor (`state`, `dataset active`, `ipaddr`), the Code tab holds the exact sketch the firmware was built from, and the 📡 Sniffer shows the 802.15.4 frames. Open the Thread mesh example to see a second board join it.

The OpenThread: a real Thread node circuit as rendered by the simulator

How it works

One sealed C6 board runs genuine OpenThread as compiled Arduino firmware. On boot it commits a complete operational dataset (channel 15, PAN ID 0x1234, network key, mesh-local prefix), starts Thread, and walks the real state machine: detached, then leader of its own one-node network, with the actual routing and MLE logic deciding that. The full OpenThread CLI stays live on serial at the ot> prompt: the same commands you'd type at a Nordic or Espressif devkit work here.

What's on the bench

  • Battery
  • ESP32-C6

How it's wired

  • 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.

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

Try this

  • Type state at the ot> prompt: 'leader', once formation settles.
  • Type dataset active to read back the committed network parameters.
  • Type ipaddr and meet the mesh-local and link-local addresses Thread derived.
  • Click the C6's 802.15.4 Inspector tab: PAN ID 0x1234, live, once the network forms.

Related projects