Make your own Thread Border Router for just $5

Hey, thanks for the input. Why do you think this change is needed?

Looks like Espressif changed the structure of esp_openthread_config_t

Yeah, it seems they changed the rcp example, but that is on master. The v6.0-beta1 branch which we use in the guide does not include that change.

My distrobox, running in WSL2 on Windows 10, does not have USB pass-through and I cannot flash from within distrobox. But I have copied the file out to windows and I have connected to my ESP32C6 with ESPConnect. Now I need to flash the bin file, but I am not sure which partition I should flash it to. I think I have to use the factory partition, but does anyone know?

These are the partitions I see:

Label Type Subtype Offset Size
Bootloader Reserved Reserved 0x0 32 KB
Partition Table Reserved Reserved 0x8000 4 KB
nvs Data (0x01) NVS (0x02) 0x9000 24 KB
phy_init Data (0x01) PHY Init Data (0x01) 0xF000 4 KB
factory Application (0x00) Factory App (0x00) 0x10000 1 MB

I flashed it to the factory partition, but it seems to disconnect constantly. Maybe the flashing is required to executed from the distrobox…

Try this
esptool.py -p COM10 --baud 921600 write_flash --flash_mode dio --flash_freq 40m --flash_size detect 0x0 bootloader.bin 0x8000 partitions.bin 0x9000 nvs.bin 0xF000 phy_init.bin 0x10000 firmware.bin

Btw can someone please share a pre-compiled bin ?

I’ve flashed an ESP32-H2 with the RCP firmware (configured with USB transport for RCP), but HA isn’t automatically discovering it, even though it’s mapped through to the docker container.

services:
  homeassistant:
    privileged: true
    container_name: home-assistant
    image: homeassistant/home-assistant
    volumes:
      - ./home_assistant/config:/config
      - /etc/localtime:/etc/localtime:ro
      - ./home_assistant/media:/media
    restart: unless-stopped
    devices:
      - /dev/serial/by-id/usb-Espressif_USB_JTAG_serial_debug_unit_10:51:DB:63:BA:11-if00:/dev/ot-rcp-usb-H2
    network_mode: host
    depends_on:
      - mariadb
      - mosquitto

When I try manually install the OTBR add-on it only asks for a URL.
I’m on HA 2026.1.1.

As you are on HA docker 2 more services needed, otbr and matter, both in docker.
See here:

2 Likes

Thanks for the suggestion, but again, I do not have USB access in my distrobox. I use EspConnect homepage.

I updated to guide to provide an option to use a pre-compiled (by myself) binary. Afaict you can use web.esphome.io to flash it too.

1 Like

Thanks for the guide.
I’m having problems with Package hass-otbr-docker · GitHub starting up the otbr-agent.

2026-02-02T11:47:27.657635573Z [NOTE]-AGENT---: Running 0.3.0-b067e5ac-dirty
2026-02-02T11:47:27.657657106Z [NOTE]-AGENT---: Thread version: 1.3.0
2026-02-02T11:47:27.657663903Z [NOTE]-AGENT---: Thread interface: wpan0
2026-02-02T11:47:27.657670108Z [NOTE]-AGENT---: Radio URL: spinel+hdlc+uart:///dev/ttyRadio?uart-baudrate=460800&uart-init-deassert
2026-02-02T11:47:27.657676804Z [NOTE]-AGENT---: Radio URL: trel://ens18
2026-02-02T11:47:27.657686321Z [NOTE]-ILS-----: Infra link selected: ens18
2026-02-02T11:47:27.657795638Z [INFO]-RCP_HOS-: OpenThread log level changed to 5
2026-02-02T11:47:27.657834741Z 55d.03:49:33.827 [C] Platform------: Init() at hdlc_interface.cpp:153: No such file or directory
2026-02-02T11:47:27.661500082Z **WARNING**: otbr-agent exited with code 5 (by signal 0).
<firewall stuff cut>
2026-02-02T11:47:27.820097997Z s6-svlisten1: **fatal**: /run/s6-rc/servicedirs/otbr-agent failed permanently or its supervisor died
2026-02-02T11:47:27.820448444Z s6-rc: **warning**: unable to start service otbr-agent: command exited 1
2026-02-02T11:47:27.821516796Z s6-rc: info: service legacy-cont-init: stopping
2026-02-02T11:47:27.821723869Z s6-rc: info: service universal-silabs-flasher: stopping
2026-02-02T11:47:27.821912290Z /run/s6/basedir/scripts/rc.init: warning: s6-rc failed to properly bring all the services up! Check your logs (in /run/uncaught-logs/current if you have in-container logging) for more information.
  otbr:
    container_name: otbr
    image: ghcr.io/ownbee/hass-otbr-docker
    restart: unless-stopped
    privileged: true
    network_mode: host
    cap_add:
      - SYS_ADMIN
      - NET_ADMIN
    environment:
      DEVICE: "/dev/ttyRadio"
      FLOW_CONTROL: 1
      FIREWALL: 1
      NAT64: 1
      BAUDRATE: 460800
      OTBR_REST_PORT: 8081
      OTBR_WEB_PORT: 7586
      AUTOFLASH_FIRMWARE: 0
      BACKBONE_IF: "ens18"  # Replace with your network interface found in Step 3
      #PUID: 1000
      #PGID: 1000
      OTBR_LOG_LEVEL: debug
      OTBR_ENABLE: 1
    devices:
    #  - /dev/serial/by-id/usb-Espressif_USB_JTAG_serial_debug_unit_10\:51\:DB\:63\:BA\:11-if00:/dev/ttyRadio
      - /dev/net/tun:/dev/net/tun
      - /dev/ttyRadio:/dev/ttyRadio
    volumes:
      - ./otbr_data:/data/thread:rw

Googling isn’t throwing me any bones and I have included a bunch of fixes (such as the PUID/PGID and cap_add) which I found around the net.
Is that agent crash-out indicative of a comms issue with the serial device, can anyone tell me?

Having a quick look here, I can point out an obvious one:

devices:
    #  - /dev/serial/by-id/usb-Espressif_USB_JTAG_serial_debug_unit_10\:51\:DB\:63\:BA\:11-if00:/dev/ttyRadio
      - /dev/net/tun:/dev/net/tun
      - /dev/ttyRadio:/dev/ttyRadio

You are supposed to use the /dev/serial/by-id/... device to connect to the ESP32 from the OTBR container. Not /dev/ttyRadio.

Your logs seem to have other issues too (like hdlc_interface.cpp:153: No such file or directory) but maybe try this and report back?

Thanks for looking. /dev/ttyRadio is a udev remapped device, mapped to
dev/serial/by-id/usb-Espressif_USB_JTAG_serial_debug_unit_10:51:DB:63:BA:11-if00 in the underlying host OS. Would that still be a concern?
I’m not sure what to do about the cpp error… I’ll have a further Google.

Remapped device sounds OK to me (but not an expert). Also, I noticed in your yaml:

      FLOW_CONTROL: 1

The ESP32-C6 doesn’t support hardware flow control, it should be disabled (0).

ChatGPT/Gemini can be of much more help here than google.

1 Like

Just some feedback to say that I got it working eventually.
The problem was with my nesting of OTBR within docker which was running on a Ubuntu server VM which is running on Proxmox.

The USB device (ESP32-H2 in my case) isn’t picked-up by OTBR and gives the error “Platform------: Init() at hdlc_interface.cpp:153: No such file or directory”. It’s not a very descriptive error, that’s what threw me - plus the web portal won’t load, so it leaves you in limbo.

I moved over to an LXC and installed docker (Proxmox VE Helper-Scripts) and I udev remapped the raw USB device at the Proxmox host level as follows:

root@proxmox:~# cat /etc/udev/rules.d/99-otbr.rules
SUBSYSTEM=="tty", ATTRS{idVendor}=="303a", ATTRS{idProduct}=="1001", SYMLINK+="ttyRadio"

Then reload the device rules in Proxmox to create the new /dev/ttyRadio device:

root@proxmox:~# udevadm control --reload-rules
root@proxmox:~# udevadm trigger

My functional docker-compose is as follows:

services:
  otbr:
    container_name: otbr
    image: ghcr.io/ownbee/hass-otbr-docker
    restart: unless-stopped
    privileged: true
    network_mode: host
    cap_add:
      - SYS_ADMIN
    #  - NET_ADMIN
    environment:
      DEVICE: "/dev/ttyRadio"
      FLOW_CONTROL: 0
      FIREWALL: 0
      NAT64: 1
      BAUDRATE: 460800
      OTBR_REST_PORT: 8081
      OTBR_WEB_PORT: 7586
      AUTOFLASH_FIRMWARE: 0
      BACKBONE_IF: "eth0"  # Replace with your network interface found in Step 3
      #PUID: 1000
      #PGID: 1000
      OTBR_LOG_LEVEL: debug
      OTBR_ENABLE: 1
    devices:
      - /dev/net/tun:/dev/net/tun
      - /dev/ttyRadio:/dev/ttyRadio
    volumes:
      - ./otbr_data:/data/thread:rw

Maybe this will help somebody.

1 Like

Thanks for sharing!

Thank you for posting this! I worked on doing this for hours probably just a few days before you posted it without success. Using this, I now have a border router running on an esp32-h2. Also thanks to alinelena for posting info on setting up OTBR in a container when not running HAOS.

1 Like

Thanks for the instructions. I have the new border router showing up in the Thread integration, however I can’t set it as my preferred network. I clicked “Send credentials to HA” on my iPhone, but it does not seem to sync them with HA

Solved it… the problem was some old nonexisting Thread network being listed as Preferred in HA. To fix:

  • Remove the Thread credentials from the iPhone. This requires a Mac, thankfully I had one lying around.
  • Send Credentials to HA from the Companion app
  • Restart the HA instance

Now the old network had disappeared, and my OpenThread network was listed as the Preferred network… under the name of that old network? Odd.

  • Select “Used for Android/iPhone credentials” on the network.
  • On the Companion app, Send Credentials to HA

After that I was able to add a Thread sensor using the app.

Seems the precompiled firmware (C6, internal antenna) contains the freezing bug? Happy that I got border router on C6 to work, now it freezed.

Can someone help with fresh compiled firmware on v6.0 beta 1 or newer?