ESP border router¶
an Espressif board is my second border router. this page covers its parts, building and flashing it, getting it onto the network, its web UI and REST API, and firmware updates.
| hardware | Espressif ESP Thread Border Router/Zigbee Gateway board: ESP32-S3 host, ESP32-H2 radio co-processor, UART between them |
| backbone | Ethernet, on Espressif's Sub-Ethernet daughter board (W5500, 10/100), built with CONFIG_EXAMPLE_CONNECT_ETHERNET. the main board alone is Wi-Fi only. i wanted this border router as reliable as i could make it, so no Wi-Fi |
| power | PoE, through an external splitter. Espressif lists 5 V over either USB-C port as the board's only power input, and neither board has PoE |
| firmware | esp-thread-br basic_thread_border_router, built from main at 46d36d3 on ESP-IDF v5.5.4 |
what you need¶
| part | what for |
|---|---|
| ESP Thread Border Router / Zigbee Gateway board | the border router: ESP32-S3 host, ESP32-H2 radio. main's default partitions need its 8 MB of flash |
| Sub-Ethernet daughter board | the wired backbone. it stacks on the main board's headers |
| 802.3af PoE splitter with 5 V out | one cable to the board: Ethernet for the daughter board, 5 V for the board |
| a barrel to USB-C lead | the splitter's DC output into a USB-C port |
| a USB-C data cable | flashing, from your computer. charge-only cables will not enumerate |
| a printed case | optional. fits the two boards stacked, with the Ethernet socket out the side |
the two USB-C ports are USB1 (the H2) and USB2 (the S3). both feed the board through diodes, so power on one and your computer on the other at the same time is fine. mine keeps power on USB1 and leaves USB2 for flashing.
build and flash it¶
the board arrives blank: it does nothing until you flash Espressif's
basic_thread_border_router example onto the S3. the H2 gets its radio firmware
from the S3 on first boot, so you only flash one of them.
-
install ESP-IDF's prerequisites, from Espressif's setup guide for your OS. run their commands, not a summary of them:
-
Linux, Debian and Ubuntu:
sudo apt-get install git wget flex bison gperf python3 python3-pip python3-venv cmake ninja-build ccache libffi-dev libssl-dev dfu-util libusb-1.0-0also add yourself to the
dialoutgroup (uucpon some distributions), or flashing fails with a permission error on the port -
macOS:
brew install cmake ninja dfu-util ccache. ESP-IDF uses the system python3 -
Windows: the ESP-IDF Windows installer, which is the supported path rather than WSL. its ESP-IDF PowerShell shortcut sets the environment up for you, but only for the tree the installer manages. so either:
- pick v5.5.4 in the installer, use that shortcut, and skip the
esp-idfclone in the next step, or - clone as below and activate that clone yourself, in PowerShell:
.\esp-idf\install.ps1 esp32s3,esp32h2then. .\esp-idf\export.ps1
get this wrong and
idf.pyruns against a different ESP-IDF from the one you built the radio firmware in - pick v5.5.4 in the installer, use that shortcut, and skip the
-
-
get both trees. on Windows that is
install.ps1in PowerShell, orinstall.batin the Command Prompt. everyidf.pybelow runs in a shell where you have sourced the matchingexportscript for this tree, unless you let the installer manage it -
put your settings in a second defaults file rather than editing anything. mine is
sdkconfig.esp-ot-brinesp-thread-br/examples/basic_thread_border_router:CONFIG_EXAMPLE_CONNECT_ETHERNET=y # CONFIG_EXAMPLE_CONNECT_WIFI is not set CONFIG_OPENTHREAD_BR_AUTO_START=y CONFIG_OPENTHREAD_BR_START_WEB=y CONFIG_OPENTHREAD_COMMISSIONER=y CONFIG_OPENTHREAD_JOINER=y CONFIG_OPENTHREAD_RADIO_STATS_ENABLE=y CONFIG_OPENTHREAD_TIME_SYNC=y CONFIG_OPENTHREAD_PACKAGE_NAME="openthread-unit1"you do not need to set the radio link. the board's defaults already have it:
CONFIG_PIN_TO_RCP_TX=17 CONFIG_PIN_TO_RCP_RX=18 CONFIG_PIN_TO_RCP_RESET=7 CONFIG_PIN_TO_RCP_BOOT=8 CONFIG_AUTO_UPDATE_RCP=ywith
AUTO_UPDATE_RCPthe S3 flashes the H2 with the radio firmware it was built with, over the reset and boot pins -
give the board a name of its own. Espressif's code sets the same name,
esp-ot-br, on every board, so a second one answers to the sameesp-ot-br.localas the first. one line inmain/esp_ot_br.cnames it after its own MAC instead, the way the firmware already names the network it forms:uint8_t mac[6]; char hostname[32]; ESP_ERROR_CHECK(esp_read_mac(mac, ESP_MAC_BASE)); snprintf(hostname, sizeof(hostname), "esp-ot-br-%02x%02x", mac[4], mac[5]); ESP_ERROR_CHECK(mdns_hostname_set(hostname));add
#include "esp_mac.h"at the top, and replace the line that readsmdns_hostname_set("esp-ot-br"). this renames the web UI, the REST API and the name Home Assistant shows. it is a change to Espressif's code, so re-apply it at each firmware updatemine came out as
esp-ot-br-33f0.local, and every example on this page uses that name. yours ends in your own four characters, which the board prints on the console at boot and shows in the web UI. skip this step and it staysesp-ot-br.local -
build the radio firmware first, because the border router build packs it into its
rcp_fwpartition. on Linux and macOS:cd "$IDF_PATH/examples/openthread/ot_rcp" && idf.py set-target esp32h2 && idf.py build cd ~/esp-thread-br/examples/basic_thread_border_router && export SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.esp-ot-br" && idf.py set-target esp32s3 && idf.py buildin the ESP-IDF PowerShell,
exportdoes not exist and&&only works in PowerShell 7, so the commands there are separate:cd "$env:IDF_PATH\examples\openthread\ot_rcp" idf.py set-target esp32h2 idf.py build cd $HOME\esp-thread-br\examples\basic_thread_border_router $env:SDKCONFIG_DEFAULTS = "sdkconfig.defaults;sdkconfig.esp-ot-br" idf.py set-target esp32s3 idf.py buildIDF_PATHis set by theexportscript, or by the installer's shortcut on Windows, and points at whichever ESP-IDF you activated. using it means these commands work whether that tree sits besideesp-thread-bror wherever the installer put it- the second
cdis wherever you clonedesp-thread-br - the commands are chained because
idf.py set-targetclears the build directory: after acdthat failed, the next command wipes the radio firmware build. on Windows, check each command worked before the next
-
plug your computer into USB2 and flash. the S3 appears as a USB serial device:
/dev/ttyACM0on Linux,/dev/cu.usbmodemXXXXon macOS,COM4or similar on Windows. it needs no driver, because it is the chip's own USB -
first boot takes about 15 seconds: the S3 flashes the H2 with the radio firmware, restarts, brings up Ethernet, and serves its web UI at
http://esp-ot-br-33f0.local.idf.py -p <port> monitorshows it, and restarts the board when it connects
the board does not join anything by itself. with no dataset stored it forms a
network of its own on a random channel and becomes its leader. that network is
named ESP-BR-xxxx after the board's MAC. this is expected, and the next
section moves the board onto yours.
get it onto your network¶
Home Assistant can push your network onto the board, which is much better than copying dataset TLVs around by hand. the panel only offers it for a border router it manages, so add it to the integration first.
- Settings > Devices & services > Add integration > OpenThread
Border Router, URL
http://esp-ot-br-33f0.local. the ESP is on port 80. a standard border router uses 8081 - rename both entries, ⋮ > Rename. the one you add by URL is always called
Open Thread Border Router, and the app's entry takes the app's name,
OpenThread Border Router: the two differ by one space. mine say
(ESP32)and(add-on + ser2net). to tell them apart, use ⋮ > Download diagnostics: the URL is in the JSON - Settings > Devices & services > Thread. the board shows under its
own
ESP-BR-xxxxnetwork. on its row, ⋮ > Add to preferred network - the dialog says Home Assistant will join an existing Thread network. Any devices that are currently joined on this Home Assistant Thread network will need to be re-joined. it reads backwards: it pushes your preferred dataset onto the board. the devices it means are the ones on the network the board is leaving, which is its own empty one. press OK
-
it attaches as a child and is a router a minute or two later
-
the empty
ESP-BR-xxxxnetwork is left behind in the panel. delete it with the bin icon on its card
- do not press Make preferred network on the
ESP-BR-xxxxcard. that promotes the board's own network instead of moving the board - adding the integration only reads: Home Assistant fetches the active dataset and stores it, and writes nothing to the board until you ask it to
- Create network on a border router factory resets it first. never use it on a working one
- entries are matched on the border agent ID. a firmware update keeps that ID, so the entry survives one, but an erase changes it and you delete and re-add
- if Home Assistant cannot resolve the name, use the address, and give it a DHCP reservation first
web UI and REST API¶
- web UI:
http://esp-ot-br-33f0.local/ http://esp-ot-br-33f0.local/.well-known/thread/esp-br-restlists the API-
the REST API has the same shape as OpenThread's own border router's:
curl -s http://esp-ot-br-33f0.local/node/state; echo curl -s http://esp-ot-br-33f0.local/node curl -s http://esp-ot-br-33f0.local/diagnostics curl -s http://esp-ot-br-33f0.local/topology/node/stateanswers"disabled","detached","child","router"or"leader". in/nodethe same thing is a number, 0 to 4 in that order -
the answers are not shaped alike:
/topologyand/ipaddrwrap theirs in aresultfield, while/diagnosticsis a bare array and/nodea bare object. sojqneeds.result[]for the first two and.[]for/diagnostics - a path it does not know returns HTTP 200 with a 404 error in the body, so read the body, not the status code
- the OpenThread console is on the S3's USB-C port, not on the network. its
commands need an
otprefix (ot state). plainidf.py -p /dev/cu.usbmodemXXXX monitorrestarts the S3 when it connects.--no-resetis meant to stop that. i have not tried it
there is no authentication. anyone on the LAN can read the dataset, network key
included, from /node/dataset/active, read the PSKc from the web UI's
/get_properties, stop Thread, or make it join or form another network. i accept
that on my LAN. if you do not, build with CONFIG_OPENTHREAD_BR_START_WEB off and
use the USB console.

joining it by hand¶
the panel does this for you. by hand is for when it cannot: no Home Assistant, or a border router it does not manage.
some things do not work, or not the way you would expect:
- the menuconfig dataset (Component config > OpenThread > Thread
Operational Dataset) is ignored. with nothing stored the board forms its own
ESP-BR-xxxxnetwork instead - Join in the web UI once started from a random new network and copied in only the channel, PAN ID and key, leaving the name, extended PAN ID and mesh-local prefix random. this build has the fix, but i have not used it
- Form in the web UI makes a new network
a board that attaches but stays a child has a dataset that differs from the network's, usually in the mesh-local prefix. with the same active timestamp on both, it never takes the network's copy, and pushing the dataset is what fixes it.
check what it has. to keep the key off the screen, jq picks out four fields:
curl -s http://esp-ot-br-33f0.local/node/dataset/active | jq '{NetworkName, Channel, MeshLocalPrefix, ActiveTimestamp}'
in Home Assistant's Active dataset TLVs, the mesh-local prefix is the 16 hex
characters after 0708 (type 7, length 8). if they differ, push the dataset.
going through the clipboard keeps the network key out of shell history and
files. steps 1, 2 and 4 differ per platform and i only run the macOS ones. the
installer's shortcut opens Windows PowerShell 5.1, where curl is an alias for
Invoke-WebRequest rather than curl itself and does not understand -s, -o,
-w or -X. call curl.exe for the reads, and use the Invoke-RestMethod
lines below for the writes. PowerShell 7 dropped that alias, so there curl is
the real thing.
-
stop Thread. it only takes a new active dataset while stopped. expect
200curl -s -o /dev/null -w '%{http_code}\n' -X PUT -H 'Content-Type: application/json' -d '"disable"' http://esp-ot-br-33f0.local/node/statethe PowerShell version throws on anything but a 2xx rather than printing a code:
-
copy Active dataset TLVs from Home Assistant, then send it from the clipboard and overwrite the clipboard. expect
200. macOS:pbpaste | tr -d '[:space:]' | curl -s -o /dev/null -w '%{http_code}\n' -X PUT -H 'Content-Type: text/plain' --data-binary @- http://esp-ot-br-33f0.local/node/dataset/active pbcopy < /dev/nullon Linux over ssh there is no desktop, so no clipboard.
read -rstakes the paste without echoing it.readis a shell builtin, so the key stays out of the history, files and the process list:read -rs TLV printf '%s' "$TLV" | tr -d '[:space:]' | curl -s -o /dev/null -w '%{http_code}\n' -X PUT -H 'Content-Type: text/plain' --data-binary @- http://esp-ot-br-33f0.local/node/dataset/active unset TLVpaste at the blank line and press Enter.
-sis bash and zsh. on a shell without it the paste is echoed, which is only a shoulder-surfing problemPowerShell has no
trorpbcopy. without-Raw,Get-Clipboardhands back one string per line, and the PUT then carries an array rather than the dataset.Set-Clipboardwill not take an empty string, so the last line overwrites the clipboard with a space:$tlv = (Get-Clipboard -Raw) -replace '\s', '' Invoke-RestMethod -Method Put -ContentType 'text/plain' -Body $tlv -Uri http://esp-ot-br-33f0.local/node/dataset/active Set-Clipboard -Value ' 'the values in the TLVs replace the stored ones
-
run the check above. the mesh-local prefix should now match
-
start Thread
curl -s -o /dev/null -w '%{http_code}\n' -X PUT -H 'Content-Type: application/json' -d '"enable"' http://esp-ot-br-33f0.local/node/statePowerShell:
-
after two or three minutes
/node/stateshould say"router". if it sits at"detached", power cycle the board. it starts from the stored dataset
firmware updates¶
an update is the same build as above, with three differences.
- keep the old tree. clone the new ESP-IDF and esp-thread-br into new directories, so the build you are running now is still there
-
back up the flash before writing anything
- the
&&stops the flash if the backup fails. in PowerShell run the two separately and check the backup exists first - the backup takes about 12 minutes and holds the network key, so keep it
out of git. the way back is
esptool.py write_flash 0with that backup file. i have not needed it
- the
-
never
idf.py erase-flash. a normal flash keeps the dataset, the border agent ID and the board's place in the network. an erase loses all three, and you start again at get it onto your network
then /node/state should say "router" within a couple of minutes and the
dataset check should still match Home Assistant.
/diagnostics and /topology do not return NetworkData, Connectivity,
MACCounters or ChannelPages, so network data comes from the USB console, see
what each border router publishes.