I bought the Wi-Fi version of the Apple TV 4K thinking it would act as a Thread Border Router for my Eve Energy plugs. It does not. The Wi-Fi model has no Thread radio โ only the Ethernet model does. By the time I figured this out I had already bought the plugs, so I decided to build my own border router instead.
This guide covers the full stack: flashing an ESP32-C6 as a Thread radio, building OpenThread Border Router on a Raspberry Pi 5, and wiring it all into Home Assistant via Matter. There's very little documentation that covers this end-to-end, and I hit several non-obvious pitfalls along the way โ so I'm writing it up in the hope that it saves someone else the debugging time.
The end result: Eve Energy plugs fully controllable from Home Assistant, including automations, with no dependency on Apple Home, Google Home, or any commercial Thread Border Router hub. Everything runs locally on the Pi.
The entire process โ working through the setup step by step, debugging failures, and figuring out the non-obvious issues like the Ethernet routing conflict and the two-step commissioning flow โ was facilitated by Claude Code. This guide is the write-up of that session.
What you need
Hardware
- Raspberry Pi 5 (4 GB recommended โ the build steps are RAM-heavy)
- ESP32-C6 development board โ this provides the Thread radio as an RCP (Radio Co-Processor)
- USB-A to USB-C cable to connect the ESP32-C6 to the Pi
- One or more Eve Energy smart plugs
- Wi-Fi network (see the critical note on Ethernet below)
Software (on the Pi)
- Raspberry Pi OS 64-bit (Bookworm)
- Docker
- Python 3.11+
- Git, cmake, ninja-build
Architecture overview
The key insight is that matter-server (running in Docker) cannot do Bluetooth commissioning because Docker has no Bluetooth passthrough on Linux. Eve Energy plugs require BLE for initial commissioning. The solution is a two-step process:
- chip-tool (compiled natively on the Pi) commissions the plug over BLE+Thread and establishes a Matter fabric
- matter-server joins that same fabric via a multi-admin commissioning window over the Thread network, which registers the device with Home Assistant
This means you need both chip-tool and matter-server, and they cannot run simultaneously (both implement the CHIP stack and interfere with each other during commissioning).
Part 1 โ Flash the ESP32-C6 as an RCP
The ESP32-C6 needs to run OpenThread RCP firmware, which makes it a Thread radio slave to the Pi.
Install esptool
pip install esptool
Get the RCP firmware
Espressif provides pre-built RCP firmware in the ESP Thread Border Router SDK releases:
Download the ot-rcp image for ESP-IDF v5.x.
Flash
Connect the ESP32-C6 via its CP210x USB-C port (the non-JTAG port) and flash:
esptool.py --chip esp32c6 --port /dev/ttyUSB0 --baud 460800 \
write_flash 0x0 ot-rcp.bin
After reconnecting, verify:
ls /dev/ttyUSB0 # CP210x โ this is the RCP port
ls /dev/ttyACM0 # JTAG debug โ ignore
Part 2 โ Build and install OTBR
OTBR (OpenThread Border Router) must be built from source. This takes 20โ40 minutes on a Pi 5.
Clone and bootstrap
git clone https://github.com/openthread/ot-br-posix.git ~/ot-br-posix
cd ~/ot-br-posix
./script/bootstrap
Build
./script/setup -DOT_POSIX_CONFIG_RCP_BUS=UART \
-DOTBR_BORDER_ROUTING=ON \
-DOTBR_BACKBONE_ROUTER=ON \
-DOTBR_REST=ON \
-DOTBR_SRP_ADVERTISING_PROXY=ON \
-DOTBR_TREL=ON
The important flags:
OTBR_REST=ONโ enables the REST API on port 8081 that Home Assistant usesDOTBR_SRP_ADVERTISING_PROXY=ONโ required for Matter CASE session establishment (device address resolution)DOTBR_TREL=ONโ Thread Radio Encapsulation Link, needed for Thread over Wi-Fi backbone
Install the binary and service
sudo cp ~/ot-br-posix/build/otbr-agent /usr/sbin/otbr-agent
sudo chmod +x /usr/sbin/otbr-agent
Create /etc/systemd/system/otbr-agent.service:
[Unit]
Description=Border Router Agent
After=network.target
[Service]
Type=simple
ExecStart=/usr/sbin/otbr-agent -I wpan0 -B wlan0 spinel+hdlc+uart:///dev/ttyUSB0 trel://wlan0
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target
Enable and start:
sudo systemctl daemon-reload
sudo systemctl enable --now otbr-agent
Verify:
sudo systemctl status otbr-agent
curl http://localhost:8081/node/state
Part 3 โ Form the Thread network
sudo ot-ctl dataset init new
sudo ot-ctl dataset commit active
sudo ot-ctl ifconfig up
sudo ot-ctl thread start
After ~30 seconds, the node should become a Leader:
sudo ot-ctl state # should show "leader"
Save your dataset hex โ you will need it for commissioning and for disaster recovery:
sudo ot-ctl dataset active -x
Verify the Thread prefix is routed via wpan0, not eth0 (see Critical Note below):
ip -6 route show | grep wpan0
Part 4 โ Build chip-tool
chip-tool is the Matter commissioning CLI from the connectedhomeip SDK. It handles the BLE commissioning step.
Install build dependencies
sudo apt-get install -y git gcc g++ pkg-config libssl-dev libdbus-1-dev \
libglib2.0-dev libavahi-client-dev ninja-build python3-venv python3-dev \
python3-pip unzip libgirepository1.0-dev libcairo2-dev libreadline-dev
Set up a 4 GB SSD swapfile
Do not use zRAM โ it is RAM-backed and useless here. The compiler peaks at ~3 GB RAM and will OOM without a real swapfile on SSD:
sudo fallocate -l 4G /mnt/swapfile # /mnt should be on SSD, not the SD card
sudo chmod 600 /mnt/swapfile
sudo mkswap /mnt/swapfile
sudo swapon /mnt/swapfile
Clone connectedhomeip
git clone --depth=1 --branch v1.4.2.0 \
https://github.com/project-chip/connectedhomeip.git ~/connectedhomeip
cd ~/connectedhomeip
git submodule update --init --recursive --depth=1
Build
cd ~/connectedhomeip
source scripts/activate.sh
gn gen out/chip-tool --args='chip_enable_wifi=false is_debug=false treat_warnings_as_errors=false'
ninja -j1 -C out/chip-tool chip-tool
Use -j1 (single thread) to avoid OOM. This takes 1โ2 hours.
Install a wrapper script
chip-tool requires --storage-directory to come after the subcommand, which is easy to forget. A wrapper handles this:
mkdir -p ~/.local/bin ~/.local/share/chip-tool
cat > ~/.local/bin/chip-tool <<'EOF'
#!/bin/bash
exec ~/connectedhomeip/out/chip-tool/chip-tool "$@" --storage-directory ~/.local/share/chip-tool
EOF
chmod +x ~/.local/bin/chip-tool
Verify:
chip-tool --version
Part 5 โ Start Docker containers
matter-server
mkdir -p ~/matter-server
docker run -d --name matter-server --restart unless-stopped \
--network host --privileged \
-v ~/matter-server:/data \
-v /run/dbus:/run/dbus:ro \
ghcr.io/home-assistant-libs/python-matter-server:stable \
matter-server --storage-path /data --port 5580
Home Assistant
mkdir -p ~/homeassistant
docker run -d --name home-assistant --privileged --restart unless-stopped \
--network host \
-v ~/homeassistant:/config \
ghcr.io/home-assistant/home-assistant:stable
Pi 5 note: Use
ghcr.io/home-assistant/home-assistant:stable. Pi 4-specific images crash on Pi 5 due to jemalloc incompatibility with the Pi 5's 16 KB memory page size.
Part 6 โ Configure Home Assistant
Open http://<pi-ip>:8123 and complete the onboarding wizard.
Add OTBR integration
Settings โ Devices & Services โ Add Integration โ OpenThread Border Router
- URL:
http://localhost:8081
HA will detect the active Thread dataset automatically.
Add Matter integration
Settings โ Devices & Services โ Add Integration โ Matter (BETA)
- Select "Use the Python Matter Server running on this host"
- WebSocket URL:
ws://localhost:5580/ws
Part 7 โ Commission the Eve Energy plugs
This is the most involved part. As explained above, it's a two-step process: BLE via chip-tool, then Thread via matter-server.
Step 1: Factory reset the plug
Hold the button for ~15 seconds until the LED flashes red repeatedly (around 30 rapid flashes). The plug is now in pairing mode with BLE advertising active for approximately 3 minutes.
Step 2: Commission with chip-tool over BLE
Stop matter-server โ chip-tool and matter-server cannot both run the CHIP stack at the same time:
docker stop matter-server
Commission the plug. The passcode and discriminator are on the label (or encoded in the QR code):
chip-tool pairing ble-thread 1 hex:<your-dataset-hex> <passcode> <discriminator> \
--bypass-attestation-verifier true
--bypass-attestation-verifier trueis required for Eve plugs because they use a development PAA (Product Attestation Authority) certificate that is not in the standard trust store.
On success: CHIP:TOO: Commissioning success
Step 3: Open a multi-admin commissioning window
chip-tool administratorcommissioning open-basic-commissioning-window 600 1 0 \
--timedInteractionTimeoutMs 10000
This gives matter-server 600 seconds to join the same fabric.
Step 4: Commission via matter-server
Start matter-server and wait a few seconds:
docker start matter-server
sleep 5
Connect to the matter-server WebSocket and send the commission command (replace the code value with the QR code string from your plug's label):
pip install websockets # if not already installed
python3 - <<'EOF'
import asyncio, json, websockets
async def commission():
async with websockets.connect("ws://localhost:5580/ws") as ws:
await ws.recv() # server info message
await ws.send(json.dumps({
"message_id": "1",
"command": "commission_with_code",
"args": {
"code": "MT:REPLACE-WITH-YOUR-QR-CODE",
"network_only": True
}
}))
print(json.loads(await ws.recv()))
asyncio.run(commission())
EOF
Within ~30 seconds, Home Assistant will discover the plug as a new Matter device and create a switch entity for it.
Repeat for each additional plug
Use a different node ID (the first argument after ble-thread) for each chip-tool commissioning.
Critical: if you have Ethernet connected, unplug it
This cost me a lot of debugging time. If eth0 and wlan0 are both connected to the same LAN, all Matter devices will time out.
Here is why: OTBR sends Router Advertisements on wlan0 announcing the Thread mesh prefix. If eth0 is on the same LAN, it also receives these RAs and installs the Thread prefix as a route on eth0 with metric 100. The wpan0 route has metric 256. So all Matter CASE (Certificate-Authenticated Session Establishment) packets exit via Ethernet and never reach the Thread network โ every device times out indefinitely.
Fix: keep eth0 unplugged if you are using Wi-Fi for the backbone.
Verify routing is correct:
ip -6 route show | grep <your-thread-prefix>
# Should show ONLY: <prefix>/64 dev wpan0
# If eth0 appears, unplug Ethernet.
Troubleshooting
All devices unavailable after Pi reboot or OTBR restart
- Check routing (see above). Unplug eth0.
- Clear stale SRP entries โ devices re-register after a reboot but the SRP server may still hold the old entry and reject re-registration, which silently breaks CASE:
Wait 30 seconds, then retry.sudo ot-ctl srp server disable sudo ot-ctl srp server enable - If still failing: factory reset and recommission.
BLE scan times out during chip-tool commissioning
The plug is not in pairing mode. Factory reset it and retry within 3 minutes.
SRP name conflict on retry
If a commissioning attempt was interrupted and you retry, the SRP name from the first attempt is still cached. Clear it:
sudo ot-ctl srp server disable && sudo ot-ctl srp server enable
chip-tool keeps recreating the same old fabric
Deleting chip_tool_kvs alone is not enough. The fabric root CA key lives in chip_tool_config.ini. Delete everything in the storage directory for a fresh start:
rm -f ~/.local/share/chip-tool/*
HA shows a Matter device as unavailable after recommissioning
The stale node entry needs to be removed via the matter-server WebSocket:
{"message_id": "1", "command": "remove_node", "args": {"node_id": <old_node_id>}}
Then restart HA.
I hope this can help anyone in the same situation as me.