Breadboard
docs
Example project · ESP32

Thread sensor network (hub + node)

Thread made visible: the hub DevKit forms the network as leader and listens on UDP, and the sealed sensor node attaches as its child and reports its temperature every 5 seconds — each report a MAC-secured 802.15.4 frame you can follow in the 📡 Sniffer. Watch the hub’s serial tab print every datagram; the full OpenThread CLI stays available there (`child table`, `ipaddr`). Both sketches are in the Code tab.

The Thread sensor network (hub + node) circuit as rendered by the simulator

How it works

Thread's whole story in two boards: the hub DevKit commits a complete operational dataset and forms the network as leader, then opens a UDP socket on port 12345. The sealed sensor node carries the same dataset, waits a few seconds so the leader forms first, attaches as a child (MAC-secured MLE the whole way), and then reports its temperature every 5 seconds — a UDP datagram to the mesh-local multicast ff03::1 that the hub prints as it arrives. The full OpenThread CLI stays live on both serial tabs.

What's on the bench

  • 2× Battery
  • ESP32-C6
  • Thread Sensor Node

How it's wired

  • Battery · posESP32-C6 · vin
  • Battery · negESP32-C6 · gnd
  • Battery · posThread Sensor Node · vcc
  • Battery · negThread Sensor Node · 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.

Thread hub (leader)

// Thread hub leader — forms the Breadboard Thread network and listens on
// UDP :12345 for the sensor node's reports, printing each one. Same
// complete fixed dataset as the sensor sketch; this board starts
// immediately so it becomes the leader.

#include <Arduino.h>
#include "OThread.h"
#include "OThreadCLI.h"
#include "OThreadCLI_Util.h"

OpenThread node;

// Must be identical to the ThreadSensorNode 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"},
};

static bool udpBound = false;

void setup() {
  Serial.begin(115200);
  OThread.begin(false);  // fresh start — don't resume a dataset from NVS
  OThreadCLI.begin();
  Serial.println("Thread hub starting: forming the Breadboard network.");
  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 `child table`.");
  Serial.print("ot> ");
}

// Serial ↔ CLI pump (received UDP datagrams print through the CLI stream).
static void pumpCli() {
  while (Serial.available() > 0) {
    char ch = (char)Serial.read();
    Serial.write(ch);
    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 ot_device_role_t lastRole = OT_ROLE_DISABLED;
  ot_device_role_t role = node.otGetDeviceRole();
  if (role != lastRole) {
    lastRole = role;
    Serial.printf("\r\n[thread] role: %s\r\n", node.otGetStringDeviceRole());
    if (role == OT_ROLE_LEADER) {
      Serial.printf("[thread] network \"%s\" formed, rloc16 0x%04x\r\n", node.getNetworkName().c_str(), node.getRloc16());
    }
  }

  // Once the network is up, listen for the sensor's reports.
  if (!udpBound && role == OT_ROLE_LEADER) {
    if (otExecCommand("udp", "open") && otExecCommand("udp", "bind :: 12345")) {
      udpBound = true;
      Serial.println("[hub] listening on udp :12345 - sensor reports will print below.");
      Serial.print("ot> ");
    }
  }

  pumpCli();
  delay(50);
}

Sensor node

// Thread sensor node — a battery-style sensor that attaches to the
// Breadboard Thread network as a child and reports its temperature over
// UDP to the mesh-local all-nodes multicast (ff03::1), where the hub
// leader is listening. Same complete fixed dataset as the leader; `thread
// start` held back so the leader forms first.

#include <Arduino.h>
#include "OThread.h"
#include "OThreadCLI.h"
#include "OThreadCLI_Util.h"

OpenThread node;

// Must be identical to the ThreadHubLeader 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"},
};

static bool udpOpen = false;

void setup() {
  Serial.begin(115200);
  OThread.begin(false);  // fresh start — don't resume a dataset from NVS
  OThreadCLI.begin();
  Serial.println("Thread sensor starting: attaching to the Breadboard network once the hub is up.");
  delay(8000);  // let the hub form 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 - reports begin once attached.");
}

// Synthetic temperature: a slow triangle wave 18..28 C, deterministic.
static float sensorTempC() {
  uint32_t phase = (millis() / 1000) % 40;              // 40 s period
  float ramp = phase < 20 ? phase : 40 - phase;         // 0..20..0
  return 18.0f + ramp * 0.5f;                           // 18..28 C
}

static void pumpCli() {
  while (Serial.available() > 0) {
    char ch = (char)Serial.read();
    Serial.write(ch);
    OThreadCLI.write((uint8_t)ch);
  }
  while (OThreadCLI.available() > 0) {
    Serial.write((char)OThreadCLI.read());
  }
}

void loop() {
  static ot_device_role_t lastRole = OT_ROLE_DISABLED;
  ot_device_role_t role = node.otGetDeviceRole();
  if (role != lastRole) {
    lastRole = role;
    Serial.printf("\r\n[thread] role: %s\r\n", node.otGetStringDeviceRole());
    if (role == OT_ROLE_CHILD || role == OT_ROLE_ROUTER) {
      Serial.printf("[thread] attached, rloc16 0x%04x\r\n", node.getRloc16());
    }
  }

  bool attached = role == OT_ROLE_CHILD || role == OT_ROLE_ROUTER || role == OT_ROLE_LEADER;
  if (attached && !udpOpen) {
    udpOpen = otExecCommand("udp", "open");
    if (udpOpen) {
      Serial.println("[sensor] udp open - reporting every 5 s to ff03::1:12345");
    }
  }

  static uint32_t lastReport = 0;
  if (attached && udpOpen && millis() - lastReport >= 5000) {
    lastReport = millis();
    char args[64];
    float t = sensorTempC();
    // OT CLI: udp send <ip> <port> <ascii-payload>
    snprintf(args, sizeof(args), "send ff03::1 12345 temp=%.1fC", t);
    if (otExecCommand("udp", args)) {
      Serial.printf("[sensor] sent temp=%.1fC\r\n", t);
    }
  }

  pumpCli();
  delay(100);
}

Try this

  • Watch the hub's serial: role leader, then 'listening on udp :12345', then temp=…C datagrams every 5 s.
  • Type `child table` on the hub's serial to see the sensor attached as its child.
  • Open the 📡 Sniffer: each report is a MAC-secured 802.15.4 data frame crossing the air.
  • Power-cycle the sensor node (unwire/rewire its battery) and watch it re-attach and resume reporting.

Related projects