Thread (OpenThread) IPv6 mesh networking for the ArduinoNRF board package, running entirely on the nRF52840's own 2.4 GHz radio. The full OpenThread FTD stack (plus mbedtls) is vendored into the library and sits on a register-level IEEE 802.15.4 driver written for this core — no SoftDevice, no nrfx, no external radio module.
Four calls take a bare board into a self-healing mesh; after that every node
has real IPv6 and UDP across the network, and the complete official OpenThread
C API stays one Thread.instance() away.
- A mesh in four calls.
begin(),setNetwork(...),start(), andprocess()inloop(). The first board promotes itself to Leader; every further board with the same credentials attaches and gets promoted to Router as the mesh grows. - CoAP built in. Register text resources with
onCoapGet()/onCoapPost()and reach any node (or the whole mesh atff03::1) withcoapPost()/coapGet()— the same protocol Matter devices and Linuxcoap-clientspeak. - Survives reboots. The operational dataset, network key sequence and frame counters persist in two dedicated flash pages (OpenThread's two-swap settings store over raw NVMC); a rebooted node re-attaches by itself.
- Two API levels. The friendly
Threadobject for bring-up and role/state queries — and the official OpenThread C API (otXxx()from<openthread/...>) for everything else, viaThread.instance(). Nothing is hidden. - Own radio driver, hardware-verified. The 802.15.4 PHY/MAC driver
(
src/ot_radio_nrf52840.cpp) programs the RADIO peripheral directly: IRQ-driven RX, software address filter, immediate-acks with source-match frame-pending, single-shot hardware CCA. The OpenThread sub-MAC does CSMA backoff, retransmits, ack timeout and TX security in software. - Real interop evidence. Frames were cross-checked against a TI CC2530 sniffer during bring-up, and a two-node mesh (Leader + Child → Router, bidirectional UDP multicast) runs on real boards.
- Self-contained vendoring. OpenThread
fa3213ec+ mbedtls 3.6.5 live insrc/, pinned and patched in exactly three marked places (ARDUINONRF-PATCH) — see docs/VENDORING.md.
- An ArduinoNRF board (nRF52840) with the
ArduinoNRF board package
installed — the library uses its
NrfRtc/NrfPeripheralscore drivers. - Peripherals claimed: RADIO (exclusive with NimBLE / Zigbee / NrfRadio), RTC2 (millisecond alarm), TIMER3 (microsecond alarm + timestamps).
- Footprint with the UDP example: ~225 KB flash (28 %), ~35 KB RAM (14 %).
In the Arduino Library Manager it is published as NiusThread. The GitHub
repository keeps the ArduinoNRF-Thread name because it is developed alongside
the ArduinoNRF core.
Or compile an example straight from a checkout:
arduino-cli compile \
--fqbn arduinonrf:nrf52:promicro_nrf52840 \
--library <path-to>/ArduinoNRF-Thread \
<path-to>/ArduinoNRF-Thread/examples/FormNetworkBootloader layout note: on nice!nano-style bootloaders without a SoftDevice (
INFO_UF2.TXTsaysSoftDevice: not found), build with the no-SoftDevice bootloader option (e.g.:bootloader=promicroserialnosd) so the sketch links at0x1000— otherwise it uploads fine but never runs.
Existing sketches that did #include <Thread.h> keep working: the ArduinoNRF
package ships a compatibility shim that forwards to this library.
#include <NiusThread.h>
const uint8_t key[16] = {0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF};
void setup() {
Thread.begin();
Thread.setNetwork("MyMesh", 11, 0xBEEF, key); // name, channel, PAN id, key
Thread.start();
}
void loop() {
Thread.process(); // keep the stack running
if (Thread.isAttached()) {
// IPv6/UDP across the mesh - see the UdpBroadcast example
}
}Flash the same sketch to several boards: the first becomes leader, the rest join as child and get promoted to router.
| Example | What it shows |
|---|---|
FormNetwork |
The hello-world: form/join a mesh and watch the role change on the serial monitor |
NetworkInfo |
A live mesh dashboard — role, addresses, partition, neighbor table with RSSI — using the official otXxx() API |
UdpBroadcast |
Real data across the mesh: every node multicasts a numbered hello over UDP and prints what it hears |
CoapLamp |
A CoAP server on every node (GET/POST /led) — the boards toggle each other's LEDs over the mesh |
ThreadCli |
The full official OpenThread CLI on the serial monitor — state, dataset, ping, udp, everything ot-cli-ftd can do |
CommissionerNode |
Real Thread commissioning, admitting side: form a network and admit joiners by passphrase (PSKd) |
JoinerNode |
Real Thread commissioning, joining side: no credentials in the sketch — discover, EC-JPAKE with the PSKd, receive the network key over DTLS, attach |
Verified two-board output of UdpBroadcast (board 2's serial monitor):
UdpBroadcast: attaching...
role -> detached
role -> child
TX "hello #1 from 0xE401"
RX from fd00:db8:0:0:535d:5645:501c:5ef2 "hello #31 from 0xE400"
TX "hello #32 from 0xC000" <- promoted from child to router
RX from fd00:db8:0:0:535d:5645:501c:5ef2 "hello #62 from 0xE400"
Thread.begin(); // platform + OpenThread instance up
Thread.setNetwork(name, channel /*11..26*/, panId, key16 [, extPanId8]);
Thread.start(); Thread.stop();
Thread.process(); // call from loop() - alarms, radio, tasklets
Thread.setLinkMode(ThreadClass::LINK_SED, 1000); // sleepy end device,
// 1 s poll; LINK_MED = always-on end device,
// LINK_ROUTER = full device (default)
// Real commissioning (MeshCoP) - admit devices by passphrase instead of
// sharing the network key:
Thread.commissionerStart(); // on an attached node
Thread.commissionerAddJoiner("J01NME", 600); // open a 10-min joiner window
// ...and on the factory-fresh device (no setNetwork() needed):
Thread.joinNetwork("J01NME"); // poll Thread.joinResult()
Thread.eraseNetwork(); // forget stored credentials
Thread.role(); // ROLE_DISABLED/DETACHED/CHILD/ROUTER/LEADER
Thread.roleString(); // "leader", ...
Thread.isAttached(); // child, router or leader
Thread.rloc16(); // mesh-internal short address
Thread.getEui64(buf8); // factory-unique 64-bit id (FICR)
// CoAP, text payloads (full otCoap API available via instance()):
Thread.coapStart(); // server on :5683
Thread.onCoapGet("led", []() { return "on"; });
Thread.onCoapPost("led", [](const char *s) { /* act on s */ });
Thread.coapPost("ff03::1", "led", "toggle"); // POST to one node or the mesh
Thread.coapGet(addr, "led", [](const char *reply) { /* NULL on timeout */ });
otInstance *ot = Thread.instance(); // the official OpenThread C APINiusThread is an alias for Thread if another library in your sketch already
uses that name.
sketch -> Thread (Thread.h) friendly Arduino API
-> OpenThread core (vendored) MLE, mesh forwarding, MeshCoP, IPv6, UDP
-> platform_impl.cpp alarms (RTC2/TIMER3), entropy (TRNG),
RAM settings, logging -> Serial
-> ot_radio_nrf52840.cpp register-level 802.15.4 RADIO driver
Everything is polled from process() — interrupts only move bytes and set
flags, so the (non-ISR-safe) OpenThread core always runs in thread context.
The build is configured by src/arduino-ot-config.h (FTD role, software MAC,
no CSL/commissioner/joiner/border-router) since Arduino cannot pass per-library
compiler flags.
Persistent settings use OpenThread's two-swap flash store over raw NVMC
writes, in two 4 KB pages directly below the bootloader (default 0xF2000,
override with NIUSTHREAD_SETTINGS_FLASH_BASE) — clear of the application,
the core's EEPROM region, and the bootloader itself.
Current limitations, by design of the bring-up scope:
- No CSL receiver (sleepy-child long polling), commissioner, joiner or border router roles yet.
- One outstanding
coapGet()at a time; CoAP payloads are text up to ~120 bytes (use the fullotCoapAPI for more). - Logging defaults to
OT_LOG_LEVEL_NOTEonSerial.
Apache-2.0 for the library code — see LICENSE. Vendored components
keep their own licenses: OpenThread
(BSD-3-Clause) and Mbed TLS
(Apache-2.0), unmodified except for the marked ARDUINONRF-PATCH config hooks.