Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Build

on:
push:
pull_request:
schedule:
# Weekly, to notice when a new ESPHome release breaks the config
- cron: "17 4 * * 1"
workflow_dispatch:

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install ESPHome
run: pip install esphome

# Dummy secrets: a made-up test dataset and a random key.
# The resulting firmware is only built, never published.
- name: Create dummy secrets
run: |
cat > secrets.yaml <<SECRETS
thread_tlv: "0e080000000000010000000300000f35060004001fffe0020811111111222222220708fd8f5ea21f1d9e4a0510aabbccddeeff00112233445566778899aa030f4f70656e5468726561642d31323334010212340410aabbccddeeff00112233445566778899aa0c0402a0f7f8"
api_encryption_key: "$(openssl rand -base64 32)"
SECRETS

- name: Validate config
run: esphome config esp32c6-thread-router.yaml

- name: Cache PlatformIO
uses: actions/cache@v4
with:
path: ~/.platformio
key: platformio-${{ runner.os }}-${{ hashFiles('esp32c6-thread-router.yaml') }}
restore-keys: platformio-${{ runner.os }}-

- name: Compile firmware
run: esphome compile esp32c6-thread-router.yaml
184 changes: 134 additions & 50 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<img src="./thread-router-image.jpg" alt="ESP32-C6 Thread Router" width="600">

[![ESPHome](https://img.shields.io/badge/ESPHome-Compatible-blue?logo=esphome)](https://esphome.io/)
[![ESPHome](https://img.shields.io/badge/ESPHome-2026.8%2B-blue?logo=esphome)](https://esphome.io/)
[![Thread](https://img.shields.io/badge/Thread-1.3-green)](https://www.threadgroup.org/)
[![Home Assistant](https://img.shields.io/badge/Home%20Assistant-Integration-41BDF5?logo=homeassistant)](https://www.home-assistant.io/)

Expand All @@ -16,16 +16,27 @@ This ESPHome configuration turns an ESP32-C6 board into a Thread FTD (Full Threa

</div>

## ✨ Features

- πŸ“Ά **Thread router (FTD)**: extends your Thread mesh, no WiFi needed
- πŸ” **Encrypted API and OTA**: Home Assistant connection and firmware updates use one encryption key
- πŸ“‘ **Antenna switch** (Seeed XIAO ESP32C6): choose the built-in or external antenna from Home Assistant
- πŸ“Š **Diagnostics in Home Assistant**: Thread role, RLOC16, channel, IP address, parent RSSI, TX retries, CCA errors, partition changes, uptime
- πŸ’‘ **Status LED**: blinks when something is wrong
- πŸ” **Restart button** in Home Assistant

## πŸ“‹ Prerequisites

| Requirement | Description |
|-------------|-------------|
| **πŸ”§ Hardware** | ESP32-C6 board (tested with [Seeed Studio XIAO ESP32C6](https://www.seeedstudio.com/Seeed-Studio-XIAO-ESP32C6-p-5884.html)) |
| **🌐 Network** | Thread Border Router in network (e.g., Home Assistant with Thread integration) |
| **πŸ’» Software** | ESPHome installed |
| **πŸ’» Software** | ESPHome **2026.8 or newer** (required for the Thread diagnostic sensors) |
| **πŸ”Œ Cable** | USB cable for initial flashing |
| **πŸ“¦ Optional** | 3D-printed case - [XIAO ESP32-C6 Case](https://www.printables.com/model/1543275-xiao-esp32-c6-zigbee-router-case-split-lid-sma-ext) |

> **πŸ’‘ Other boards:** The config also works on other ESP32-C6 boards. Remove the block marked `Seeed XIAO ESP32C6 specific` in `esp32c6-thread-router.yaml`, since it drives GPIO3, GPIO14 and GPIO15. ESPHome also supports Thread on ESP32-C5, ESP32-H2 and (since 2026.7) nRF52 boards, but this config is only tested on the ESP32-C6.

## πŸ”‘ Required Information

### 🌐 1. Thread Network Dataset (TLV)
Expand All @@ -38,7 +49,7 @@ This ESPHome configuration turns an ESP32-C6 board into a Thread FTD (Full Threa

**Step 1:** SSH to your Server

**Step 2:** Retrieve TLV data:
**Step 2:** Retrieve TLV data (use `podman` instead of `docker` if you run Podman):
```bash
docker exec otbr ot-ctl dataset active -x
```
Expand All @@ -54,29 +65,57 @@ thread_tlv: "YOUR_TLV_HEX_HERE"

<table>
<tr><td>βœ… Channel</td><td>βœ… Network Name</td><td>βœ… PAN ID & Extended PAN ID</td></tr>
<tr><td>βœ… Network Key (encrypted)</td><td>βœ… PSKc (encrypted)</td><td>βœ… Mesh-Local Prefix</td></tr>
<tr><td>βœ… Network Key</td><td>βœ… PSKc</td><td>βœ… Mesh-Local Prefix</td></tr>
</table>

### πŸ” 2. ESPHome Secrets
> ⚠️ **Keep the TLV secret!** The network key is stored in the TLV **in plain text**. Anyone with your TLV can join your Thread network. Never commit `secrets.yaml` or post the TLV anywhere. The firmware image also contains the TLV, so don't share compiled `.bin` files.

### πŸ” 2. API Encryption Key

The Home Assistant API and OTA updates are encrypted with a shared key. Generate one:

```bash
openssl rand -base64 32
```

Or copy a freshly generated key from the [ESPHome API docs](https://esphome.io/components/api/).

### πŸ“„ 3. ESPHome Secrets

In `secrets.yaml` you need:

| Key | Description | Required |
|-----|-------------|----------|
| **thread_tlv** | Your Thread network commissioning data | βœ… Required |
| **thread_tlv** | Your Thread network dataset (hex) | βœ… Required |
| **api_encryption_key** | 32-byte base64 key for API and OTA encryption | βœ… Required |

## πŸš€ Installation

### πŸ“ Step 1: Configure Secrets

Edit `secrets.yaml` with your Thread TLV:
Create `secrets.yaml` next to `esp32c6-thread-router.yaml`:
```yaml
thread_tlv: "YOUR_TLV_HEX_HERE"
api_encryption_key: "YOUR_BASE64_KEY_HERE"
```

That's all you need - no WiFi credentials required!

### ⚑ Step 2: Compile and Flash Firmware
### πŸ“‘ Step 2: Choose the Antenna (Seeed XIAO ESP32C6 only)

The XIAO ESP32C6 has a built-in ceramic antenna and a U.FL connector for an external antenna (e.g. the SMA antenna of the recommended case). The antenna is selected by an RF switch (GPIO3 = switch power, GPIO14 = antenna select).

If you use an **external antenna**, change this substitution before flashing:
```yaml
substitutions:
antenna_default: RESTORE_DEFAULT_ON # external antenna
```

You can also toggle the **External Antenna** switch in Home Assistant later. The setting is kept across reboots.

> ⚠️ Without an external antenna connected, keep the built-in antenna selected. Otherwise the range will be very poor.

### ⚑ Step 3: Compile and Flash Firmware

Connect your ESP32-C6 via USB and flash the firmware using local ESPHome:

Expand All @@ -94,7 +133,7 @@ esphome run esp32c6-thread-router.yaml --device=/dev/ttyACM0
| OS | Typical Paths |
|----|--------------|
| 🐧 Linux | `/dev/ttyUSB0`, `/dev/ttyACM0`, `/dev/ttyUSB1` |
| 🍎 macOS | `/dev/cu.usbserial-*`, `/dev/cu.wchusbserial*` |
| 🍎 macOS | `/dev/cu.usbserial-*`, `/dev/cu.usbmodem*` |
| πŸͺŸ Windows | `COM3`, `COM4`, etc. |

**Check available ports:**
Expand Down Expand Up @@ -122,7 +161,7 @@ ls -la /dev/ttyACM0
sudo chmod 666 /dev/ttyACM0

# If using Docker/Podman, restart the container
docker-compose restart
docker compose restart
```

> ⚠️ **Note:** You need to run `sudo chmod 666` each time you reconnect the USB device.
Expand All @@ -134,52 +173,49 @@ docker-compose restart
<details>
<summary><b>🐳 Alternative: Using Docker/Podman</b></summary>

> ⚠️ **Important:** When using Docker/Podman (rootless), you need to fix USB permissions before flashing.
The included `docker-compose.yml` mounts this repository as `/config` and passes the USB device into the container.

> ⚠️ **Important:** The USB device must be plugged in **before** the container starts. When using Docker/Podman (rootless), fix USB permissions first (see above).

```bash
# 1. Start container
docker-compose up -d
# 1. Start container (default device: /dev/ttyACM0)
docker compose up -d
# or with another device:
ESPHOME_DEVICE=/dev/ttyUSB0 docker compose up -d

# 2. Flash the firmware
docker-compose exec esphome esphome run /config/Thread/esp32c6-thread-router.yaml --device=/dev/ttyACM0
docker compose exec esphome esphome run /config/esp32c6-thread-router.yaml --device=/dev/ttyACM0
```

> **πŸ“Œ Note:** The docker-compose.yml mounts the parent `config/` directory to `/config` in the container. That's why you need `/config/Thread/` in the path above. This is necessary so ESPHome can find the build files in `.esphome/`.

</details>

---

<details>
<summary><b>🌐 Alternative: Web Dashboard (GUI)</b></summary>

Choose between local or hosted dashboard:

**Docker Dashboard:**
```bash
# Start container if not already running
docker-compose up -d

# Start the dashboard
docker-compose exec esphome esphome dashboard /config
docker compose up -d
```
Then open **http://localhost:6052** in your browser and navigate to the Thread folder.
The ESPHome container starts the dashboard automatically. Open **http://localhost:6052** in your browser.

> ⚠️ The dashboard has no password and listens on all interfaces (host networking). Only run it on a trusted network, or stop the container after flashing.

**Local Dashboard** (requires local ESPHome):
```bash
esphome dashboard .
```
Then open **http://localhost:6052** in your browser and use the web interface.

**Hosted Dashboard** (no installation needed):
> **πŸŽ‰ No installation needed!** Flash directly from your browser.
**Browser Flashing via [web.esphome.io](https://web.esphome.io/):**

1. Visit **https://web.esphome.io/**
2. Click "Connect" and select your ESP32-C6 device
3. Upload your `esp32c6-thread-router.yaml` configuration file
4. Click "Install" to compile and flash
web.esphome.io can only **flash** a finished firmware file. It cannot compile your YAML. Because the TLV is compiled into the firmware, you have to build it yourself first:

**Perfect for:** Users who prefer GUI over command line, or quick flashing without local ESPHome installation.
1. Compile locally: `esphome compile esp32c6-thread-router.yaml`
2. The firmware is written to `.esphome/build/esp32c6-thread-router/.pioenvs/esp32c6-thread-router/firmware.factory.bin`
3. Open **https://web.esphome.io/** in Chrome or Edge, click "Connect", choose your ESP32-C6 and select "Install"
4. Upload the `firmware.factory.bin` file

</details>

Expand Down Expand Up @@ -212,51 +248,98 @@ Choose "Over The Air" option when prompted. You should see:

#### 🌐 Method 2: Check Thread Network
```bash
# List all Thread devices in network
podman exec otbr ot-ctl router table
# List all Thread routers in the network
docker exec otbr ot-ctl router table
# Your ESP32-C6 should appear with Extended MAC and good Link Quality

# Show network state
podman exec otbr ot-ctl state
docker exec otbr ot-ctl state
```

> **πŸ’‘ Note:** A new FTD first joins as a child (*REED*, Router Eligible End Device). Thread only promotes it to *router* when the mesh needs another router, which can take a few minutes, or not happen at all in a small network. It only shows up in the router table after that. You can see the current role in the **Thread Role** sensor in Home Assistant.

---

#### 🏠 Method 3: Home Assistant Integration
- Go to **Settings β†’ Devices & Services**
- The ESP32-C6 should appear as a discovered device
- Add it to Home Assistant (use `esp32c6-thread-router.local` or IPv6 address)
- Enter the `api_encryption_key` from your `secrets.yaml` when asked
- Check device status - should show "Online"

## Multiple Thread Routers
## πŸ“Š Diagnostics in Home Assistant

To flash multiple ESP32-C6 devices and use them as separate routers in the same Thread network:
The device exposes these diagnostic entities:

### 1. Create Device-Specific Configuration Files
| Entity | Meaning |
|--------|---------|
| **Thread Role** | `router`, `child`, `leader`, `detached` or `disabled` |
| **Thread RLOC16** | Short address in the mesh. Routers end in `00` (e.g. `0x3400`) |
| **Thread Channel** / **Thread IP Address** | Current channel and IPv6 address |
| **Thread Parent RSSI** | Signal strength to the parent. Only meaningful while the device is a child, not as router |
| **Thread TX Retries** / **Thread TX CCA Errors** | Rising fast = poor link or busy channel (e.g. WiFi on the same frequencies) |
| **Thread Partition Changes** | Rising = the mesh keeps splitting, check placement/range |
| **Uptime** | Detects unexpected reboots |
| **External Antenna** (XIAO only) | Switch between built-in and external antenna |
| **Restart** | Restart the device |

For each additional device, create a new YAML file (e.g., `esp32c6-thread-router-2.yaml`):
More sensors (link quality, RX/TX totals, attach attempts, ...) are available in the [openthread_info](https://esphome.io/components/text_sensor/openthread_info/) component and can be added to the `sensor:` section.

```yaml
esphome:
name: esp32c6-thread-router-2 # Must be unique!
friendly_name: ESP32-C6 Thread Router 2 # Optional but helpful
## βš™οΈ Advanced Options

### πŸ“Ά Transmit Power

Since ESPHome 2026.3 the transmit power can be set. The ESP32-C6 supports -15 to 20 dBm:

```yaml
openthread:
device_type: FTD
tlv: !secret thread_tlv # Same TLV = same Thread network
output_power: 10dBm
```
### 2. Flash Additional Devices

Each device will join the same Thread network and act as an independent router, extending your mesh coverage.
> ⚠️ Respect the regulatory limits for 2.4 GHz in your country. More power only helps if the other devices can also reach the router, since Thread links are bidirectional.

### πŸ”„ Thread Network Was Recreated / TLV Changed

The router stores the Thread dataset in flash after the first start and then **ignores** the `tlv` from the config. If you recreate your Thread network or change the TLV, the router stays in the old network.

To apply a new TLV:

1. Update `thread_tlv` in `secrets.yaml`
2. Uncomment `force_dataset: true` in the `openthread:` section and flash
3. Once the router has joined, comment it out again and flash once more

> **πŸ’‘ Why not keep `force_dataset: true` permanently?** The Thread network can change its dataset at runtime, e.g. when Home Assistant moves it to another channel. With `force_dataset: true` the router would fall back to the old dataset from the config after every reboot.

## πŸ”€ Multiple Thread Routers

To flash multiple ESP32-C6 devices and use them as separate routers in the same Thread network, create a small file per additional device (e.g. `esp32c6-thread-router-2.yaml`) that reuses the main config:

```yaml
substitutions:
name: esp32c6-thread-router-2 # Must be unique!
friendly_name: ESP32-C6 Thread Router 2
# antenna_default: RESTORE_DEFAULT_ON # Optional: external antenna

packages:
base: !include esp32c6-thread-router.yaml
```

Then flash it:
```bash
esphome run esp32c6-thread-router-2.yaml --device=/dev/ttyACM0
```

All devices share the same `secrets.yaml` (same TLV = same Thread network). Changes to the main config apply to all devices on the next flash. Each device will join the same Thread network and act as an independent router, extending your mesh coverage.

## πŸ“‚ File Structure

```
.
β”œβ”€β”€ esp32c6-thread-router.yaml # Main ESPHome configuration
β”œβ”€β”€ esp32c6-thread-router-2.yaml # Optional: Second router
β”œβ”€β”€ esp32c6-thread-router-3.yaml # Optional: Third router
β”œβ”€β”€ secrets.yaml # Shared credentials (not in git)
β”œβ”€β”€ esp32c6-thread-router-2.yaml # Optional: second router (includes the main config)
β”œβ”€β”€ secrets.yaml # Shared secrets (not in git)
β”œβ”€β”€ docker-compose.yml # Optional: ESPHome in Docker/Podman
β”œβ”€β”€ .github/workflows/ # CI: validates and compiles the config
β”œβ”€β”€ .gitignore # Excludes secrets and build artifacts
└── README.md # This file
```
Expand All @@ -266,6 +349,8 @@ Each device will join the same Thread network and act as an independent router,
| Resource | Description |
|----------|-------------|
| πŸ“– [ESPHome OpenThread Documentation](https://esphome.io/components/openthread/) | Official ESPHome Thread component docs |
| πŸ“Š [ESPHome OpenThread Info](https://esphome.io/components/text_sensor/openthread_info/) | Thread diagnostic sensors |
| πŸ“‘ [Seeed XIAO ESP32C6 Wiki](https://wiki.seeedstudio.com/xiao_esp32c6_getting_started/) | Pinout and RF switch of the XIAO ESP32C6 |
| πŸ”— [OpenThread Primer](https://openthread.io/guides/thread-primer/) | Learn the basics of Thread networking |
| 🏠 [Home Assistant Thread Integration](https://www.home-assistant.io/integrations/thread/) | How Thread works in Home Assistant |

Expand All @@ -278,4 +363,3 @@ Each device will join the same Thread network and act as an independent router,
⭐ Star us on [GitHub](https://github.com/firsttris/esp32c6-thread-router) β€’ πŸ› [Report a Bug](https://github.com/firsttris/esp32c6-thread-router/issues) β€’ πŸ’‘ [Request a Feature](https://github.com/firsttris/esp32c6-thread-router/issues)

</div>

17 changes: 9 additions & 8 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
version: '3'
services:
esphome:
container_name: esphome
image: ghcr.io/esphome/esphome:latest
image: ghcr.io/esphome/esphome:stable
volumes:
- /home/tristan/Projects/esphome/config:/config
# This repository is mounted as /config
- ./:/config
- /etc/localtime:/etc/localtime:ro
restart: always
privileged: true
# USB device used for the first flash. Override with ESPHOME_DEVICE=/dev/ttyUSB0 docker compose up -d
# The device must be plugged in when the container starts.
devices:
- ${ESPHOME_DEVICE:-/dev/ttyACM0}:${ESPHOME_DEVICE:-/dev/ttyACM0}
# Host networking is needed for mDNS discovery and the dashboard on port 6052
network_mode: host
environment:
- USERNAME=test
- PASSWORD=test
restart: unless-stopped
Loading
Loading