Setup and notes for running a standalone OpenThread Border Router on an ESP32-C6. The ESP32-C6 hosts both Wi-Fi (station) and the 802.15.4 radio, so a single chip bridges a Thread network to the Wi-Fi LAN — no separate RCP module needed.
Note on terminology. In Espressif's reference designs, the "border router" usually runs on an ESP32-S3/C5/P4 connected to an external H2/C6 acting as a Radio Co-Processor (RCP). Here the C6 runs the whole stack itself. The reference example in
esp-thread-br/examples/basic_thread_border_routeris primarily tailored to the S3/C5/P4 + RCP layout — we keep using it because it adds a web UI and HA-friendly defaults on top of the upstreamesp-idf/examples/openthread/ot_br, but several of its defaults (UART RCP transport, RCP auto-update) need to be overridden for the single-chip layout — see the menuconfig table below.
.
├── esp-idf/ # ESP-IDF SDK (submodule, pinned to v5.5.4)
├── esp-thread-br/ # Espressif Thread Border Router components & examples (submodule)
│ └── examples/
│ └── basic_thread_border_router/ # reference implementation we build from
└── patches/ # local patches applied on top of the submodules — see "Patches"
| Submodule | Repo | Pin |
|---|---|---|
esp-idf |
https://github.com/espressif/esp-idf | v5.5.4 |
esp-thread-br |
https://github.com/espressif/esp-thread-br | tracks default branch |
After a fresh clone:
git clone <this-repo>
cd esp32-c6-border-router
git submodule update --init --recursive--recursive is required because esp-idf itself pulls in many nested submodules (mbedTLS, OpenThread, etc.).
Then apply the local patches (see Patches for what they do):
(cd esp-thread-br && git apply ../patches/basic_thread_border_router-native-radio.patch)patches/ holds local diffs applied on top of the unmodified submodule checkout. They live
outside the submodule so re-cloning, bumping, or git submodule update --remote doesn't
silently lose them — re-apply with git apply after any submodule reset.
| Patch | Target submodule | What it does |
|---|---|---|
basic_thread_border_router-native-radio.patch |
esp-thread-br |
Adds a CONFIG_OPENTHREAD_RADIO_NATIVE branch to examples/basic_thread_border_router/main/esp_ot_config.h. The upstream file only handles OPENTHREAD_RADIO_SPINEL_UART / _SPI and falls through to the SPI macro when native radio is selected, which references CONFIG_PIN_TO_RCP_MOSI/MISO/SCLK/CS symbols that don't exist outside SPI mode. Without this patch, the single-chip C6 build fails with 'CONFIG_PIN_TO_RCP_MOSI' undeclared. |
To check what's currently applied:
(cd esp-thread-br && git diff)To regenerate a patch after editing files inside a submodule:
(cd esp-thread-br && git diff <path> > ../patches/<name>.patch)ESP-IDF ships its own installer that fetches the RISC-V toolchain and Python deps into ~/.espressif/.
cd esp-idf
./install.sh esp32c6
. ./export.sh # exports IDF_PATH and PATH; run in every new shell
cd ..You must source esp-idf/export.sh (or wire it into your shell rc) before any idf.py command.
The example is built out-of-tree from esp-thread-br/examples/basic_thread_border_router.
cd esp-thread-br/examples/basic_thread_border_router
idf.py set-target esp32c6
idf.py menuconfig # see "menuconfig knobs" below
idf.py -p /dev/cu.usbmodem* build flash monitorThe example's sdkconfig.defaults is tuned for the ESP-Thread-Border-Router devkit (S3 host + external H2 RCP over UART). For a single-chip ESP32-C6 you need to override several of these.
Locations below are the path you navigate to inside idf.py menuconfig.
All four of these must be set or the build will fail / produce a broken binary:
| Setting | menuconfig path | Why |
|---|---|---|
OPENTHREAD_RADIO_NATIVE=y |
Component config → OpenThread → Thread Core Features → Thread 15.4 Radio Link → Config the Thread radio type with 15.4 link → Native 15.4 radio |
Use the C6's built-in 802.15.4 radio. The default in sdkconfig.defaults is OPENTHREAD_RADIO_SPINEL_UART, which expects an external RCP — switch it. |
OPENTHREAD_BORDER_ROUTER=y |
Component config → OpenThread → Thread Core Features → Enable Border Router |
Border-router features. Already on via sdkconfig.defaults. |
ESP_BR_BOARD_STANDALONE=y |
ESP Thread Border Router Example → Border router board type → Standalone dev kits |
Tells the example we are not on the integrated devkit. Overrides the default ESP_BR_BOARD_DEV_KIT. |
ESP_CONSOLE_USB_SERIAL_JTAG=y |
Component config → ESP System Settings → Channel for console output → USB Serial/JTAG Controller |
C6 devkits expose console over native USB. Pick Default: UART0 instead if you're wired up to the UART pins. |
The defaults try to pack an external RCP firmware into the BR binary, which makes no sense
on a single-chip C6 and will fail the build with FileNotFoundError: …/ot_rcp/build/rcp_version
if left on. Turn both off:
| Setting | menuconfig path |
|---|---|
AUTO_UPDATE_RCP=n |
Component config → OpenThread RCP Update → Update RCP automatically |
CREATE_OTA_IMAGE_WITH_RCP_FW=n |
Component config → OpenThread RCP Update → Create the OTA image with rcp for border router |
| Setting | menuconfig path |
|---|---|
OPENTHREAD_BR_AUTO_START=y |
ESP Thread Border Router Example → Enable the automatic start mode in Thread Border Router. |
EXAMPLE_WIFI_SSID |
Example Connection Configuration → WiFi SSID |
EXAMPLE_WIFI_PASSWORD |
Example Connection Configuration → WiFi Password |
With OPENTHREAD_BR_AUTO_START off, you configure Wi-Fi at runtime with the wifi connect -s … -p … CLI.
| Setting | menuconfig path | Notes |
|---|---|---|
OPENTHREAD_BR_START_WEB=y |
ESP Thread Border Router Example → Enable the web server in Thread Border Router. |
Web GUI for Thread network config. Pulls in commissioner/joiner. |
OPENTHREAD_BR_SOFTAP_SETUP=y |
ESP Thread Border Router Example → Enable SoftAP Wi-Fi configuration mode |
Falls back to a SoftAP at http://192.168.4.1 when Wi-Fi creds are missing. |
After saving, run idf.py build flash monitor (add erase-flash on the first flash to clear stale NVS).
CMake auto-initialises ESP-IDF's nested submodules at configure time. The download is large
(~1 GB: Wi-Fi, OpenThread, lwIP, mbedTLS, BT libs, …) and if it's interrupted you'll get an
exit code of -2 (SIGINT) and a half-populated esp-idf/components/*. Easiest fix is to
fetch them yourself with visible progress, then retry:
cd esp-idf
git submodule update --init --recursive --progress
cd ../esp-thread-br/examples/basic_thread_border_router
rm -rf build # clear the half-finished cmake state
idf.py set-target esp32c6Right after the first flash, ot state returns disabled — the firmware is running but the
Thread interface has never been initialised. The example doesn't auto-form a network unless
OPENTHREAD_BR_AUTO_START=y is set, so on a default build you do it once from the console.
Open idf.py monitor and run:
esp32c6> ot dataset init new
esp32c6> ot dataset commit active
esp32c6> ot ifconfig up
esp32c6> ot thread start
esp32c6> ot state
detached
... # within ~10 s, transitions to "leader"
dataset init new generates a fresh Thread network (random network key, PANID, channel,
network name). dataset commit active writes it to NVS, so it survives reboots — you only
need to do this once per chip. After this, every reboot brings Thread up automatically if
auto-start is enabled, otherwise you need ot ifconfig up; ot thread start again.
To make it fully headless (BR boots → joins Wi-Fi → starts Thread, no console interaction):
set OPENTHREAD_BR_AUTO_START=y in menuconfig (ESP Thread Border Router Example → Enable the automatic start mode in Thread Border Router.) and reflash. With auto-start on, the
dataset created above is reused; you don't need to run the one-shot again.
Save the dataset somewhere you can recover it (printer, password manager) — if you wipe NVS or re-flash without backing it up, you'll be forming a new Thread network and any device already joined to the old one stops talking to the BR.
esp32c6> ot dataset active -x
0e08000000000001000035060004001fffe00208dead00beef00cafe...
Done
Home Assistant's Thread integration auto-discovers any OpenThread Border Router that
advertises _meshcop._udp on the LAN via mDNS. The Espressif OTBR does this automatically
once it has joined Wi-Fi and formed a Thread network — no HA add-on is needed.
On top of the single-chip C6 essentials above:
| Setting | menuconfig path | Why |
|---|---|---|
OPENTHREAD_BR_AUTO_START=y |
ESP Thread Border Router Example → Enable the automatic start mode in Thread Border Router. |
Headless boot: connects to Wi-Fi and forms a Thread network on power-up. |
EXAMPLE_WIFI_SSID / EXAMPLE_WIFI_PASSWORD |
Example Connection Configuration → WiFi SSID / WiFi Password |
2.4 GHz Wi-Fi credentials. |
OPENTHREAD_BR_START_WEB=y |
ESP Thread Border Router Example → Enable the web server in Thread Border Router. |
Selects commissioner + joiner. HA needs commissioner support to push its Thread credentials to the BR. Also exposes a web UI at http://<br-ip>/ for inspection. |
Optional — pre-seed a deterministic Thread network under
Component config → OpenThread → Thread Core Features → Thread Operational Dataset (OPENTHREAD_NETWORK_NAME,
_CHANNEL, _PANID, _EXTPANID, _MASTERKEY, _PSKC). Otherwise the BR auto-generates
one on first boot; HA will then adopt those credentials when it marks this BR as preferred.
From any host on the same L2 segment as the BR and HA:
dns-sd -B _meshcop._udp # macOS
avahi-browse -r _meshcop._udp -t # LinuxOne entry for the C6 means mDNS discovery is working.
The BR advertises an on-mesh IPv6 prefix via ICMPv6 router advertisements on Wi-Fi. For Thread devices to be reachable from HA you need:
- Your Wi-Fi router must forward RAs (most consumer routers do; some "AP isolation" modes drop them).
- HA OS / Supervised handles RA acceptance automatically. For HA Container on a plain Linux host,
set on the Wi-Fi interface:
sudo sysctl -w net/ipv6/conf/<wlan-iface>/accept_ra=2 sudo sysctl -w net/ipv6/conf/<wlan-iface>/accept_ra_rt_info_max_plen=128
- Settings → Devices & Services — the Thread integration should auto-discover the BR. Accept it.
- Open the Thread integration, find the BR entry, and mark it preferred border router if you want HA to push its Thread credentials to it (so Matter/Thread devices commissioned via HA join this BR's mesh).
If you have other border routers on the LAN (HomePod, Nest Hub, SkyConnect with OTBR add-on), they merge networks only when they share credentials — HA's "preferred" flag controls which BR receives credential pushes.
Connect over USB and run idf.py monitor (or any serial monitor at 115200 baud). The prompt is
esp32c6>. Commands are case-sensitive.
OpenThread CLI commands are namespaced behind an ot prefix on the ESP console. Typing
state returns Unrecognized command; the working form is ot state. The Thread tables below
all need that prefix. The prefix is configurable under Component config → OpenThread → Thread Console → The prefix of the openthread CLI command registered on the esp console
(default "ot"; set to empty to drop it).
Reference: OpenThread CLI.
Run help for the full list of top-level commands actually registered on this firmware.
| Command | What it shows |
|---|---|
state |
This node's Thread role: leader / router / child / detached / disabled. |
version |
OpenThread version string. |
dataset active |
Active operational dataset (channel, PANID, network name, network key, …). |
dataset active -x |
Same dataset as a single hex blob — paste into HA to share credentials. |
partitionid |
Thread partition ID. Two BRs on different partitions = split network. |
leaderdata |
Leader RLOC16, partition ID, weighting. |
channel, panid, extpanid, networkname, extaddr |
Quick single-parameter reads. |
| Command | What it shows |
|---|---|
router table |
All routers in the partition: RLOC16, next-hop, link quality in/out, age. |
child table |
End-devices directly attached to this BR: RLOC, RSSI, mode, last contact. |
child ipaddr |
IPv6 addresses of each child. |
neighbor table |
Direct radio neighbors with average RSSI and frame error rate — best signal-quality view. |
ipaddr |
This BR's IPv6 addresses on the Thread interface (link-local, mesh-local, OMR, …). |
ipmaddr |
Multicast groups this BR has joined. |
| Command | What it shows |
|---|---|
br state |
Border-routing state (running / disabled). |
br omrprefix |
Off-mesh-routable IPv6 prefix advertised to the Thread side. |
br onlinkprefix |
On-link prefix advertised over Wi-Fi via RA. |
br nat64prefix |
NAT64 prefix (if NAT64 is enabled). |
br routers |
Other BRs the routing manager sees on Wi-Fi. |
bbr state / bbr config |
Backbone-router (primary BBR election, sequence number). |
ba state / ba port |
Border Agent — i.e. the _meshcop._udp mDNS responder HA discovers. |
srp server state |
SRP server (Service Registration Protocol — what Thread devices use to publish services). |
srp server host / srp server service |
Devices and services currently registered via SRP — gives you a list of everything advertising itself on the mesh. |
| Command | What it shows |
|---|---|
ping <ipv6> |
ICMPv6 echo to a Thread or off-mesh address. Works for any reachable IPv6 host. |
scan |
Active scan for Thread networks on all channels. |
scan energy |
Energy detection per 802.15.4 channel — use this to find a quieter channel if Thread perf is bad. |
discover |
Discovery scan: nearby Thread networks plus their network name, ExtPANID, joiner UDP port. |
factoryreset |
Wipe NVS and reboot (will re-form a fresh Thread network). |
This example only exposes a minimal wifi command set:
| Command | What it does |
|---|---|
wifi state |
disconnected / connected / reconnecting. |
wifi connect -s <ssid> -p <psk> |
Manual join (also persisted to NVS). |
wifi disconnect |
Drop the current association. |
There is no wifi rssi command. To see Wi-Fi signal strength, watch the wifi: log lines on
the monitor (look for bcn_count / rssi= in the periodic Wi-Fi log) or enable iperf and
read the AP info from there.
| Command | What it does |
|---|---|
free |
Free heap (bytes). |
heap |
Heap stats incl. largest free block — useful to spot fragmentation. |
tasks |
FreeRTOS task list with stack high-watermarks. |
restart |
Soft-reboot the chip. |
| Command | What it does |
|---|---|
ota download <https-url> |
Fetch and apply a firmware image from an HTTPS server (cert in server_certs/ca_cert.pem). |
Map the entire Thread mesh from this BR's vantage point:
esp32c6> ot state
esp32c6> ot router table
esp32c6> ot child table
esp32c6> ot neighbor table
esp32c6> ot srp server host
esp32c6> ot srp server service
Copy the Thread credentials to HA (paste the output as the active dataset under HA's Thread integration):
esp32c6> ot dataset active -x
Sanity-check IPv6 to a Thread device once you have its address from ot child ipaddr:
esp32c6> ot ping fd11:1111:1122:0:abcd:abcd:abcd:abcd
The example currently recommends ESP-IDF v5.5.4, which is the tag this repo pins. To bump,
inside esp-idf/:
git fetch --tags
git checkout vX.Y.Z
cd ..
git add esp-idf
git commit -m "Bump esp-idf to vX.Y.Z"Re-run ./install.sh esp32c6 after switching IDF versions.