This Firmware is in the ALPHA Phase and far away from being complete!
This firmware is in an early alpha stage. Known issues are tracked as GitHub Issues. If you run into a problem that isn't listed there, please open a new issue with as much detail as possible (firmware version, steps to reproduce, log output).
Known Issues:
- Firmware downloads over a Wi-Fi connection are not yet possible (LAN only).
- Long startup time on first boot, caused by the RCP firmware download and flash process.
- No visual feedback on the CZC during setup or firmware updates.
- The web interface is unreachable if the RCP is not configured correctly.
- SoftAP network scanning does not work.
- SoftAP closes after invalid or non-existent network credentials are submitted.
- Feedback messages in the SoftAP configuration flow are unclear.
When reporting an issue, serial log output is extremely helpful. Each release provides a debug build with verbose logging enabled (see Setup) — please use this build when capturing logs.
-
Install a serial terminal, e.g. CoolTerm (or any other serial terminal of your choice).
-
Connect the CZC: plug it into your PC via USB (serial console) and into your network via a LAN cable.
Once booted, the web interface is reachable at http://codm-otbr.local.
-
Configure CoolTerm:
- Open "Options".
- Under "Port", select the USB port the CZC is connected to.
- Set the baud rate to 115200.
- Leave all other settings at their default values.
- Click "OK", then "Connect".
On Windows the COM port can be found in the Device Manager. On Linux it is usually listed under
/dev/ttyUSBx(FTDI). -
Reproduce the issue you want to report.
-
Save the serial output: CoolTerm shows all debug prints sent by the ESP. For longer captures, enable Connection → File Capture beforehand to log everything to a file.
Please attach the captured output to your issue report.
- Download the Binary from the Releases Tab. Make sure that you pick the firmware without the .ota suffix!
NOTE: Each release provides two firmware variants — a release build (default, minimal logging) and a debug build (verbose serial logging). Use the debug build if you want to troubleshoot an issue or need to capture logs for a bug report, see Capturing Debug Output.
- Connect your CZC to LAN
IMPORTANT NOTE: Currently ESP, RCP Firmware Updates are only supported via LAN.
-
Open ESP Webflasher. Pick the appropriate OTBR Firmware Version.
-
Wait a few minuets until your ESP32 has successfully setup and started its Webinterface (This can take a while depending on your internet connection speed).
NOTE: After the first flash the ESP automatically Downloads the RCP firmware and flashes the Radio-Co-Processor. This does not yet have a visual output besides Debug prints.
- Open http://codm-otbr.local and you can start setting up a Thread Network!
When no Wifi was previously configured and LAN is not connected the CZC opens up an Access Point named OTBR-Codm with the password: codmcodm.
After you connected your Device to the network you can open the wifi configuration interface on 192.168.4.1 and enter your Wifi credentials.
NOTE: Currently there is no fallback implemented if you enter wrong credentials. So make sure they are correct on your first try!
This guide explains how to integrate your CZC border router into Home Assistant using Thread and Matter over Thread.
- Home Assistant installed and running (on the same local network as your device)
- Home Assistant Companion App installed on your smartphone
- Bluetooth enabled on your smartphone (required for the pairing process)
- Your smartphone must be connected to the same local network as Home Assistant
- In Home Assistant, navigate to Settings → Devices & Services.
- Click + Add Integration and search for Thread.
- Install the Thread integration.
- In Settings → Devices & Services, click the gear icon (⚙) next to the Thread integration to open its settings.
- Click the three-dot menu (⋮) in the top-right corner.
- Select Add Thread Border Router and follow the on-screen instructions.
- You will be prompted fill in the URL of the OTBRs REST API:
http://device-iporhttp://codm-otbr.local. - Once added, open the border router's details and select Make Preferred Network to designate it as the primary Thread network.
If your device uses Matter over Thread, you also need the Matter integration:
- Navigate to Settings → Devices & Services.
- Click + Add Integration, search for Matter, and install it.
- During setup, you will also be prompted to install the Matter Server (the commissioner add-on). Install it and wait for it to start — this component handles all Matter commissioning and communication.
- Open the Home Assistant Companion App on your smartphone.
- Go to Settings → Companion App → Troubleshooting → Synchronize Thread Credentials. This ensures your phone shares the Thread network credentials with Home Assistant so it can act as a commissioning bridge.
- Back in Home Assistant, go to Settings → Devices & Services → Devices and click + Add Device.
- Put your device into pairing mode (refer to your device's documentation for how to do this).
- Scan the QR code displayed on or shipped with your device to complete commissioning.
- Make sure Bluetooth is enabled on your phone — it is required during the commissioning process even if the device ultimately connects via Thread.
- Confirm your phone is on the same local network as your Home Assistant instance.
- Try clearing the cache of the Home Assistant Companion App: on Android go to App Info → Storage → Clear Cache; on iOS, delete and reinstall the app. This resolves stale credential or state issues in the app.
Thread requires IPv6. If pairing fails immediately, the cause is often that IPv6 is not enabled inside the Docker container that runs the Thread integration within Home Assistant. Note that this is unrelated to whether Home Assistant itself is running in Docker — it specifically concerns the internal Thread container that Home Assistant manages.
There is currently no UI option for this — it must be changed from the command line.
For full details refer to the official documentation: https://www.home-assistant.io/integrations/thread/#home-assistant-operating-system
To check and enable IPv6:
-
Open the Terminal & SSH add-on (or any equivalent terminal app).
-
Check the current setting:
ha docker infoIf
enable_ipv6showsnullorfalse, proceed to the next step. -
Enable IPv6 for the Docker environment Home Assistant uses:
ha docker options --enable-ipv6=true -
Reboot Home Assistant for the change to take effect:
ha host reboot
Note: If Home Assistant OS is running inside a virtual machine, make sure the VM itself has IPv6 connectivity. If your hypervisor is routing traffic (rather than bridging), IPv6 forwarding may also need to be enabled on the hypervisor — refer to your hypervisor's documentation for instructions.
This Firmware implements an OpenThread Border Router based on a ESP32 and a CC2652P7 Radio-Co-Processor (RCP).
This Project uses parts of the Espressif example OTBR implementation.
| ESP32 pin | CC2652P7 pin |
|---|---|
| GND | G |
| GPIO4 | TX |
| GPIO36 | RX |
| GPIO16 | HW_RST |
Firmware Baud: 921600
idf.py menuconfig
OpenThread Command Line is enabled with UART as the default interface. Additionally, USB JTAG is also supported and can be activated through the menuconfig:
Component config → ESP System Settings → Channel for console output → USB Serial/JTAG Controller
-
Clone the github repository
-
You need a .vscode folder in your Repository normally generated from the espressif IDF
The files in this folder tell your development environment where your IDF etc. are installed on your device.
We suggest that you copy this folder from an example project created with your IDF if not automatically generated after clone.
-
The required
dependencies.lockfile should get generated automatically while building the project.If you encounter issues regarding the
webservercomponent try adding the following lines underdependencies:esp_ot_br_server: dependencies: - name: espressif/cjson rules: - if: idf_version >= 6.0 version: ^1.7.19 source: path: /$PROJ_PATH/ot_br/components/esp_ot_br_server type: local version: '*'
and add
esp_ot_br_serverto thedirect_dependencies:object.
After these changes you should be able to build the project.
Before building and flashing it to your ESP you have to make a few changes to your IDF or you will get these errors:
E(1550) OPENTHREAD:[C] P-RadioSpinel-: RCP is missing required capabilities:
E(1550) OPENTHREAD:[C] P-RadioSpinel-: rx-timing
E(1550) OPENTHREAD:[C] P-RadioSpinel-: rx-on-when-idle
-
rx-on-when-idlehas to be deactivated: -> Component config → OpenThread → Thread Core Features → Disable OpenThread radio capability rx on when idle -
more importantly:
rx-timingis hardcoded in the espressif openthread sdk. In$IDF_PATH/esp-idf/components/openthread/src/port/esp_openthread_radio_spinel.cppthe constantOT_RADIO_CAPS_RECEIVE_TIMINGpart of thes_radio_capsobject has do be deleted.
Now you can build and flash the project to your ESP and the border router should initialize properly.
Build the project and flash it to the board, then run monitor tool to view serial output:
idf.py -p PORT build flash monitor
This Chapter gives Architectural and specific Project coding Documentation
czc_ot_firmware/
├── main/ Application entry point
│ ├── main.cpp app_main(): boot sequence, network init,
│ │ RCP-flash handling, starts the OTBR stack
│ └── Kconfig.projbuild
│
├── components/
│ ├── system_manager/ Core orchestration layer (C++) — see below
│ ├── network/ Network connectivity state machine (C++) — see below
│ └── esp_ot_br_server/ REST API + Web GUI (C) — see below
│
├── partitions.csv Flash partition table of this firmware (OTA-enabled)
├── partition_table/
│ └── partitionTable.csv Reference: partition table of the CZC Zigbee firmware
├── sdkconfig* ESP-IDF build configuration (defaults + CI variants)
├── .devcontainer/ VSCode Dev Container setup for ESP-IDF
└── image/ Images used in this README
Owns the firmware/network lifecycle and bridges the C++ application core with the C webserver component.
| File | Description |
|---|---|
System_manager |
Top-level orchestrator: ESP OTA updates, RCP flash scheduling, device-setup state (NVS) |
Thread_controller |
Starts/stops the OpenThread stack and the embedded webserver, hardware RCP reset |
Rcp_interface |
Downloads RCP firmware and flashes the CC2652 over UART via BSL |
sys_nvs_bind |
NVS persistence for a pending RCP flash and the device-setup flag |
Implements the Ethernet / Wi-Fi / SoftAP connectivity state machine — see components/network/README.md for the full state diagram.
| File | Description |
|---|---|
NetworkStateMachine |
Switches between Ethernet, Wi-Fi, SoftAP and retry states |
EthernetAPI |
RMII Ethernet driver wrapper |
WirelessAPI |
Wi-Fi station + SoftAP wrapper |
nvs_bind |
NVS persistence for Wi-Fi credentials |
Embedded HTTP server providing the OpenThread REST API and the Web GUI, based on the Espressif OTBR example.
| File | Description |
|---|---|
src/esp_br_web.c |
httpd server setup, route table and request handlers (incl. /flash/esp, /flash/rcp) |
src/esp_br_web_api.c |
OpenThread REST resource implementations (node info, dataset, topology, ...) |
src/esp_br_web_base.c |
JSON ⇄ OpenThread struct (de)serialization helpers |
src/esp_br_wifi_config.c |
SoftAP captive portal for the initial Wi-Fi setup |
frontend/ |
Web GUI (HTML/CSS/JS), embedded into the SPIFFS image at build time |
include/, private_include/ |
Public and private headers |
The diagram shows the main classes, their relationships, and how the C++ application
core (system_manager, network) connects to the C-based webserver
(esp_ot_br_server) via the system_flash_callbacks_t bridge described under
API Endpoints.
classDiagram
direction LR
class main {
<<entry point>>
+app_main()
}
class System_manager {
-Rcp_interface rcp_interface
-Thread_controller thread_controller
-uart_port_t rcp_uart_num
-EventGroupHandle_t ota_event_group
+initThread() esp_err_t
+startThread() esp_err_t
+flashEspFirmware(url)
+flashRcpFirmware(url)
+initRcpFirmwareFlash(url)
+flashRcpFirmwareWhenConfigured()
+isRcpFlashPending() bool
+isDeviceSetup() bool
+writeDeviceSetup(bool) esp_err_t
}
class Thread_controller {
-bool thread_active
-esp_openthread_config_t config
+init(flash_cbs) esp_err_t
+start() esp_err_t
+stop() esp_err_t
+is_running() bool
}
class Rcp_interface {
-uart_port_t rcp_uart
-bool bsl_mode
+rcp_update_init(uart) esp_err_t
+rcp_update_start(url) esp_err_t
+get_update_event_group() EventGroupHandle_t
}
class system_flash_callbacks_t {
<<C struct>>
+flash_esp(ctx, url) esp_err_t
+flash_rcp(ctx, url) esp_err_t
+ctx void*
}
class esp_br_web {
<<C module>>
+esp_br_web_start(base_path, flash_cbs)
}
class NetworkStateMachine {
-NetworkState currentState
-EthernetAPI ethernetAPI
-WirelessAPI wirelessAPI
+initNetworkStateMachine()
+closeNetworkStateMachine()
+setState(NetworkState)
+getState() NetworkState
}
class EthernetAPI {
-ethernetConfig ethConfig
-bool ethIsConnected
+initEthernet()
+closeEthernet()
+getEthIsConnected() bool
}
class WirelessAPI {
-ActiveWirelessMode activeWirelessMode
-bool wifiIsConnected
+initWifi()
+closeWifi()
+initAccessPoint()
+closeAccessPoint()
+reconnect()
+getActiveWirelessMode() ActiveWirelessMode
}
class NetworkState {
<<enumeration>>
INIT
ETHERNET
WLAN
ACCESS_POINT
RETRY_ETHERNET
RETRY_WIFI
}
main --> System_manager : creates
main --> NetworkStateMachine : creates
main --> EthernetAPI : creates
main --> WirelessAPI : creates
System_manager *-- Rcp_interface
System_manager *-- Thread_controller
System_manager ..> system_flash_callbacks_t : builds (this = ctx)
Thread_controller ..> system_flash_callbacks_t : forwards
Thread_controller ..> esp_br_web : starts
esp_br_web ..> system_flash_callbacks_t : invokes
NetworkStateMachine o-- EthernetAPI
NetworkStateMachine o-- WirelessAPI
NetworkStateMachine ..> NetworkState : uses
Most REST endpoints are inherited from the
Espressif OTBR example
and documented in
components/esp_ot_br_server/src/openapi.yaml.
This project adds two custom endpoints for remote firmware updates:
| Endpoint | Method | Purpose | Request body | Response |
|---|---|---|---|---|
/flash/esp |
POST |
Starts an OTA update of the ESP32 itself | { "url": "<firmware.ota.bin>" } |
{ "status": "flashing", "message": "..." } |
/flash/rcp |
POST |
Schedules a firmware update of the CC2652 RCP (downloads now, flashes via BSL on next boot) | { "url": "<rcp_firmware.bin>" } |
{ "status": "started" } |
Testing: A browser address bar cannot send POST requests. Use e.g.
curl:curl -X POST http://<device-ip>/flash/rcp \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/rcp_firmware.bin"}'
The webserver (esp_br_web.c) is plain C, and all its handlers share the fixed signature
esp_err_t handler(httpd_req_t *req) — there is no room for extra parameters. The
System_manager, which actually performs the flashing, lives in C++ and is invisible to
the webserver. The two are connected through a small callback struct defined in
esp_br_web.h:
typedef struct {
esp_err_t (*flash_esp)(void *ctx, const char *url);
esp_err_t (*flash_rcp)(void *ctx, const char *url);
void *ctx; // opaque pointer, cast back to System_manager* in the adapters
} system_flash_callbacks_t;How the struct travels through the program:
System_manager::initThread()builds asystem_flash_callbacks_t, pointing the function pointers at twostaticadapter functions and settingctx = this.Thread_controller::init(flash_cbs)receives the struct and forwards it unchanged to the webserver.esp_br_web_start(base_path, flash_cbs)copies the struct by value into a static, file-scope variables_flash_cbs— a copy is required because the original struct lives oninitThread()'s stack and would be invalid once that function returns.- The HTTP handlers
esp_otbr_flash_esp_post_handler/esp_otbr_flash_rcp_post_handlerparse{"url": ...}from the request body and calls_flash_cbs.flash_esp(s_flash_cbs.ctx, url)/s_flash_cbs.flash_rcp(...). - The static adapter functions
flash_esp_cb/flash_rcp_cb(inSystem_manager.cpp)static_castctxback toSystem_manager*and callflashEspFirmware(url)/initRcpFirmwareFlash(url).
This keeps the webserver completely free of System_manager/C++ knowledge while still
letting it trigger application-level actions. The same pattern can be reused for future
endpoints by extending system_flash_callbacks_t (or adding a similar struct).
Both flash endpoints reboot the device once the update has been applied.
-
ESP firmware (
/flash/esp) — A standard ESP-IDF OTA update: the new image is streamed directly into the inactiveota_0/ota_1partition viaesp_https_ota_*, the boot partition is switched, and the device reboots into the new firmware. -
RCP firmware (
/flash/rcp) — The CC2652P7 cannot be updated over HTTP directly, so this happens in two steps:- The RCP firmware binary is downloaded over HTTPS into the ESP32's OTA partition (used purely as temporary storage — the ESP's own firmware is not changed).
- After a reboot, the OpenThread stack is stopped and
Rcp_interfaceflashes the downloaded image to the CC2652 over UART using TI's BSL (Boot Strap Loader) protocol (sync, erase, download, send-data, CRC, reset).
Downloading firmware from GitHub servers is more complex than just downloading a file from a local webserver hosted via Python.
You have to follow redirects and provide certificates for HTTPS.
It is also important to keep in mind that GitHub enforces a rate limit of 60 requests per hour. Because of this it is better to host the firmware yourself rather than downloading and fetching it from GitHub every time.
This OTBR firmware and the existing CZC Zigbee firmware currently use different partition tables:
- OTBR (this repository):
partitions.csv— OTA-enabled, withota_0/ota_1app partitions, anota_datapartition for the boot selector, and aspiffspartition for the web GUI. - Zigbee firmware: partition table is generated automatically by PlatformIO at build
time — no static
.csvis checked into its repository.
An OTA flash via /flash/esp only overwrites the application binary inside the
currently running app partition — it does not rewrite the partition table itself.
Because the two firmwares currently use incompatible partition layouts, a device cannot
simply be switched between the Zigbee and the OTBR firmware over the air; doing so still
requires a full reflash via USB/serial.
For a remote, OTA-only switch between the Zigbee and OTBR firmware (without USB/laptop),
both firmwares need to share the same partition table. This is work in progress —
the Zigbee firmware's partition table will be adapted to match partitions.csv in a
future release.