Skip to content

About

Alpha OTBR firmware for cod.m Zigbee Coordinator (CZC).

Resources

Stars

0 stars

Watchers

1 watching

Forks

Repository files navigation

CZC-unified ALPHA

This Firmware is in the ALPHA Phase and far away from being complete!

Known Issues

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:

  • Thread stack shutdown does not work reliably! This affects switching from Thread to any other protocol. Workaround: factory reset the ESP (debug options) the setup dialog will appear again and you can easily switch to any other mode.
  • ZigBee Router mode is not properly implemented.
  • The web interface is unreachable if the RCP is not configured correctly (Only Thread mode is affected - this is an IDF Issue).

Capturing Debug Output

When reporting an issue, serial log output is extremely helpful. You can enable debug output in the web interface debug section and capture it via USB.

  1. Install a serial terminal, e.g. CoolTerm (or any other serial terminal of your choice).

  2. 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.

  3. 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).

  4. Reproduce the issue you want to report.

  5. 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.

Setup

  1. Download the Binary from the Releases Tab. Make sure that you pick the firmware without the .ota suffix!

  2. Connect your CZC to your Network.

  3. Open ESP Webflasher. Pick the appropriate OTBR Firmware Version.

  4. Open http://codm-otbr.local. On first boot, the web interface will ask how you want to use your CZC. Select the desired mode — the matching RCP firmware is automatically downloaded and flashed. Progress is shown in the web interface.

  5. Once setup is complete you can start using your CZC!

WiFi / SoftAP

When no WiFi is configured and no Ethernet connection is available, the CZC opens an Access Point named otbr-codm with password codmcodm.

Connect to the AP and open http://192.168.4.1 — a dedicated WiFi setup page is served (see esp_ot_br_server below), where you can configure WiFi credentials or a static IP. Using Ethernet instead is recommended.

Once a connection is established, the device switches to normal operation mode automatically and the full web interface becomes available.

Home Assistant Integration (Thread Border Router & Matter)

This guide explains how to integrate your CZC border router into Home Assistant using Thread and Matter over Thread.


Prerequisites

  • 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

Step 1 — Install the Thread Integration

  1. In Home Assistant, navigate to Settings → Devices & Services.
  2. Click + Add Integration and search for Thread.
  3. Install the Thread integration.

Step 2 — Add a Thread Border Router

  1. In Settings → Devices & Services, click the gear icon (⚙) next to the Thread integration to open its settings.
  2. Click the three-dot menu (⋮) in the top-right corner.
  3. Select Add Thread Border Router and follow the on-screen instructions.
  4. You will be prompted fill in the URL of the OTBRs REST API: http://device-ip or http://codm-otbr.local.
  5. Once added, open the border router's details and select Make Preferred Network to designate it as the primary Thread network.

Step 3 — Install the Matter Integration (for Matter over Thread)

If your device uses Matter over Thread, you also need the Matter integration:

  1. Navigate to Settings → Devices & Services.
  2. Click + Add Integration, search for Matter, and install it.
  3. 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.

Step 4 — Connect Your Device

  1. Open the Home Assistant Companion App on your smartphone.
  2. 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.
  3. Back in Home Assistant, go to Settings → Devices & Services → Devices and click + Add Device.
  4. Put your device into pairing mode (refer to your device's documentation for how to do this).
  5. Scan the QR code displayed on or shipped with your device to complete commissioning.

Troubleshooting

Pairing fails or device is not found

  • 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.

Pairing fails due to IPv6 not being enabled

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:

  1. Open the Terminal & SSH add-on (or any equivalent terminal app).

  2. Check the current setting:

    ha docker info
    

    If enable_ipv6 shows null or false, proceed to the next step.

  3. Enable IPv6 for the Docker environment Home Assistant uses:

    ha docker options --enable-ipv6=true
    
  4. 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.


Overview

Multi-mode coordinator firmware for the cod.m CZC device (ESP32 + CC2652P7 RCP). Supported modes:

Mode Description
Thread OTBR OpenThread Border Router — connects a Thread mesh to IP
Zigbee Coordinator USB Serial proxy over USB to a host application (e.g. Zigbee2MQTT)
Zigbee Coordinator Network TCP proxy to a network host application
Zigbee Router Standalone Zigbee router — RCP operates independently

The active mode is selected on first boot via the web interface, or changed later from the same UI. Any transition that needs new RCP firmware (i.e. involves Thread) flashes the CC2652 live: the active protocol controller is stopped to free the UART, the RCP is reflashed, and the new mode is started — no ESP reboot required.

The OpenThread stack is based on the Espressif OTBR example.

How to use Border Router

Hardware Required

Wi-Fi based Thread Border Router

ESP32 pin CC2652P7 pin
GND G
GPIO4 TX
GPIO36 RX
GPIO16 HW_RST

Firmware Baud: 921600

Configure the project

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

Setup Repository

  1. Clone the github repository

  2. 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.

  3. The required dependencies.lock file should get generated automatically while building the project.

    If you encounter issues regarding the webserver component try adding the following lines under dependencies:

       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_server to the direct_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-idle has to be deactivated: -> Component config → OpenThread → Thread Core Features → Disable OpenThread radio capability rx on when idle

  • more importantly: rx-timing is hardcoded in the espressif openthread sdk. In $IDF_PATH/esp-idf/components/openthread/src/port/esp_openthread_radio_spinel.cpp the constant OT_RADIO_CAPS_RECEIVE_TIMING part of the s_radio_caps object has do be deleted.

Now you can build and flash the project to your ESP and the border router should initialize properly.

Build, Flash, and Run

Build the project and flash it to the board, then run monitor tool to view serial output:

idf.py -p PORT build flash monitor

Project Documentation

This Chapter gives Architectural and specific Project coding Documentation

File Structure

czc-unified/
├── main/
│   └── main.cpp                  Boot sequence, wires all components
│
├── components/
│   ├── app_controller/           Composition root + boot orchestrator
│   │   ├── include/app_controller.h
│   │   ├── private_include/app_nvs.h
│   │   └── src/app_controller.cpp, app_nvs.cpp
│   │
│   ├── update_manager/           RCP (BSL) + ESP (OTA) firmware updates
│   │   ├── include/update_manager.h
│   │   ├── private_include/rcp_updater.h, ota_updater.h
│   │   └── src/
│   │
│   ├── cc_bsl/                   CC13xx/CC26xx ROM bootloader (BSL) protocol wrapper
│   │   ├── include/cc_bsl.h
│   │   └── src/cc_bsl.cpp
│   │
│   ├── zstack_mt/                Z-Stack Monitor and Test (MT) UART protocol wrapper
│   │   ├── include/zstack_mt.h
│   │   └── src/zstack_mt.cpp
│   │
│   ├── firmware_manager/         Protocol stack selection (Thread / Zigbee)
│   │   ├── include/firmware_manager.h, protocol_controller.h   ← DeviceMode enum
│   │   ├── private_include/thread_controller.h,
│   │   │                               zigbee_proxy_controller.h, proxy_transport.h
│   │   └── src/
│   │
│   ├── status_light/             Event-driven LED manager + LED overrides + night mode
│   │   ├── include/status_light_event.h, status_light_manager.h, status_light_hal.h
│   │   ├── include/status_light_callbacks.h  ← web_led_callbacks_t + night_mode_config_t (C)
│   │   ├── private_include/status_light_nvs.h
│   │   └── src/status_light_manager.cpp, status_light_hal.cpp, status_light_nvs.cpp
│   │
│   ├── board_config/             Hardware constants in Board:: namespace
│   │   └── include/board_config.h
│   │
│   ├── network/                  Ethernet + WiFi state machine
│   │   └── include/NetworkStateMachine.h, network_config.h, nvs_bind.h
│   │
│   ├── time_service/             SNTP time sync + timezone (NVS-backed defaults)
│   │   ├── include/time_service.h
│   │   ├── private_include/time_service_nvs.h
│   │   └── src/time_service.cpp, time_service_nvs.cpp
│   │
│   ├── cron/                     Wall-clock job scheduler (one task, minute resolution)
│   │   ├── include/cron.h        ← cron_timing_t + namespace Cron
│   │   └── src/cron.cpp
│   │
│   ├── esp_ot_br_server/         HTTP server + web frontend (C)
│   │   ├── include/esp_br_web.h  ← esp_br_web_start(fw, net, led callbacks)
│   │   └── frontend/             HTML/JS/CSS (gzip-compressed into SPIFFS)
│   │       ├── wifi_config.html  ← served instead of index.html while in Access Point mode
│   │       └── static/restful.js ← RCP flash dialog: Thread / Zigbee Coordinator / Zigbee Router tabs
│   │
│   └── sse_events/               SSE event broadcast (queue + sender task)
│       ├── include/sse_events.hpp      ← namespace sse_events: init() + post_* API + enums
│       ├── include/sse_events_init.h   ← C-compatible init, included by esp_br_web.c
│       └── src/sse_events.cpp, sse_events_init.cpp
│
└── partitions.csv                Flash partition table

Components

app_controller

Composition root. Runs the boot-decision-tree on every boot and routes web API requests to the appropriate sub-manager. Owns all NVS keys for boot state (device_setup, device_mode) via the private AppNvs namespace.

update_manager

Wraps two updaters, both non-blocking (spawn an internal FreeRTOS task and signal completion via a binary semaphore / reboot):

  • RcpUpdater — downloads TI binary into the OTA staging partition, then drives the CC2652 BSL over UART via CcBsl (cc_bsl component). UpdateManager::flashRcp() spawns the task; UpdateManager::waitForRcpFlash() blocks the caller until it's done and returns the result — used by AppController both at boot and for live re-flashes.
  • OtaUpdater — thin esp_https_ota wrapper for live ESP firmware updates; reboots on success.

Both use the same OTA partition; a busy flag prevents simultaneous access.

cc_bsl

Standalone, project-agnostic wrapper around the TI CC13xx/CC26xx ROM bootloader (BSL) protocol — framing (SIZE/CHECKSUM/CMD/DATA), ACK/NACK handling and the flash commands (PING, DOWNLOAD, SEND_DATA, GET_STATUS, BANK_ERASE). CcBsl owns the UART driver and the RST/BSL GPIO lines for the duration of a flash session. Split out of update_manager (formerly RcpHal) so the BSL transport can be reused outside this project without dragging in OTA/RCP-update orchestration.

zstack_mt

Standalone wrapper around TI's Z-Stack Monitor and Test (MT) UART protocol — the same command interface Zigbee host applications use to talk to a Z-Stack-based RCP (framing: SOF (0xFE) + length + cmd0/cmd1 + data + XOR checksum, see zstack_mt.h for the packet layout). ZstackMt owns the RCP UART plus the RST/BSL GPIO lines for the duration of a session, mirroring cc_bsl's ownership model but talking to the RCP's application firmware instead of its bootloader.

  • init() — configures the RST/BSL GPIOs (BSL held high, i.e. non-bootloader), toggles reset to boot the RCP into normal Z-Stack firmware, then blocks on the SYS_RESET_IND callback before returning.
  • eraseNvram() — sends NVRAMCLEARSTARTOPS (ZCD_STARTOPT_CLEAR_CONFIG | ZCD_STARTOPT_CLEAR_STATE) and reboots the RCP so it applies the clear on the next boot; blocks on rebootRcp() until the reset callback confirms it's back up.
  • close() — deletes the UART driver, freeing the port for another owner (CcBsl, ProtocolController).

Owned by ZigbeeProxyController, which drives it for the /device/rcp/reboot and /device/rcp/erasenvram debug actions: the proxy is stopped to free the UART, ZstackMt does the reset/erase, then the proxy is restarted. Only meaningful while the RCP runs Z-Stack firmware (ZIGBEE_USB / ZIGBEE_NET / ZIGBEE_ROUTER) — see firmware_manager below for the Thread-mode equivalents.

firmware_manager

Selects and starts the correct ProtocolController implementation based on DeviceMode:

  • ThreadController — OTBR stack (Spinel over UART).
  • ZigbeeProxyController — transparent serial proxy (USB or TCP). Pumps bytes between the RCP UART and an IProxyTransport implementation (UartTransport for USB, TcpTransport for TCP) using two FreeRTOS tasks. In ZIGBEE_ROUTER mode there is no transport — start()/stop() are no-ops and the RCP runs standalone; the ESP never touches the UART except for the debug actions below.

RCP debug actions (resetRcp() / factoryReset()). rcp_reboot and rcp_erase_nvram (AppController → FirmwareManager → active ProtocolController) are implemented per-protocol because the RCP speaks a different command set in each mode:

  • ZigbeeProxyController drives ZstackMt directly (Z-Stack MT protocol) — see zstack_mt above.
  • ThreadController::resetRcp() only pulses the RST GPIO — no Spinel/stack restart. The Spinel driver already handles unsolicited RCP resets (rcpFailureHandler, triggered on RCP failure), so it resynchronises on its own.
  • ThreadController::factoryReset() doesn't touch the RCP at all: in Thread mode the RCP holds no meaningful config of its own — the dataset lives in the ESP's NVS via the OpenThread settings API. It disables the Thread/IPv6 interface, calls otInstanceErasePersistentInfo() (erase-only, unlike otInstanceFactoryReset() which also triggers an immediate platform reboot — not what a "clear the RCP/network config" button should do), then does a full stop() + start() to come back up clean.

IProtocolController::resetRcp()/factoryReset() are pure virtual — both implementations must provide one, since there's no protocol-agnostic default that makes sense for either.

ThreadController::stop() drain barrier. Disabling the OpenThread stack triggers address-removal / multicast-leave-group notifications that are handled asynchronously — first as OpenThread tasklets, then as esp_event events on the default event loop task, which drive lwIP calls into the netif glue. esp_openthread_stop() frees the netif glue's packet queue, so if it runs before those events have been processed, the still in-flight leave-group send hits a freed (NULL) queue and crashes (xQueueGenericSend assert). stop() avoids this with a two-stage drain before calling esp_openthread_stop(): process OpenThread tasklets until idle (so the notifier's esp_event_post() calls are actually issued), then post a private barrier event and block until it comes back out the other end of the same FIFO queue — proof that everything posted before it has been handled.

sequenceDiagram
    participant S as ThreadController::stop()
    participant OT as OpenThread (lock held)
    participant EvtLoop as Default event loop
    participant Glue as netif glue (packet queue)

    S->>OT: otThreadSetEnabled(false) / otIp6SetEnabled(false)
    OT-->>OT: notifier fires (address removed / multicast group left)
    S->>OT: otTaskletsProcess() until idle
    OT->>EvtLoop: esp_event_post(leave_ip6_multicast_group / remove_ip6_address)
    S->>EvtLoop: esp_event_post(DRAIN_BARRIER)
    EvtLoop->>Glue: handle leave/remove events (blocking lwIP calls)
    EvtLoop->>S: handle DRAIN_BARRIER → xSemaphoreGive(drainDone)
    S->>S: xSemaphoreTake(drainDone) returns
    S->>Glue: esp_openthread_stop() — safe to free the packet queue now
Loading
status_light

Event-driven LED manager. Other components set and clear LedStates via StatusLedMode::set() / clear() (or the RAII helper StatusLedModeScope), which post STATUS_LED_EVENT into the default event loop. The manager keeps every active state in an atomic bitmask and shows the highest one: operating modes (BOOTING, THREAD_ACTIVE, ZIGBEE_*) replace each other, faults (NETWORK_DOWN, ZIGBEE_ERROR, FLASHING, ERROR) are layered on top and cleared by whoever set them. The GPIO LEDs are driven via a 100 ms esp_timer tick. On top of that:

  • LED overrides — every LED (PWR, MODE, RCP) can be forced ON / OFF or handed back to AUTO (forceLed()), from the web UI via /device/led.
  • Night mode — a daily time range during which all LEDs are off. Implemented as two cron jobs that set / reset a mask flag (night_mode_active) on top of the overrides — the user's overrides stay untouched and apply again after the night. Configured via /device/nightmode.
  • Persistence — overrides and the night-mode range are stored in NVS (namespace status_lights, private StatusLightNvs namespace) and restored on boot; the night-mode cron jobs are re-registered in init().

Because init() re-registers the night-mode jobs, Cron::init() has to run before StatusLightManager::init() in main.cpp. The web server talks to it through the C callback struct web_led_callbacks_t (status_light_callbacks.h), filled by fillLedCallbacks(). See components/status_light/README.md for details, diagrams and the NVS layout.

network

Ethernet / WiFi / SoftAP state machine. Extended with getWifiConfig(), setWifiConfig(), getEthernetConfig(), setEthernetConfig(), getNetworkStatus(), scanWifi() and fillNetworkCallbacks(). NVS bindings in NvsBinding namespace support full IP configuration (DHCP / static IP / gateway / DNS). See components/network/README.md for the full state diagram and NVS layout.

time_service

SNTP time synchronisation and timezone handling, implemented as namespace TimeService (time_service.h) over static file-scope state (the currently configured server). Started from main.cpp after waitUntilInternetIsConnected() — SNTP needs a routed connection, so it is deliberately not part of the early boot sequence.

  • init(server = nullptr) — configures esp_netif_sntp with server_from_dhcp = false, applies the persisted defaults and blocks up to 10 s for the first sync (ESP_OK on success, ESP_FAIL on timeout). Passing nullptr uses the time server stored in NVS, falling back to pool.ntp.org. An explicitly passed server is used for this session only — it is not written to NVS (only setServer() persists). If SNTP was already initialised (ESP_ERR_INVALID_STATE), init() only waits for the sync again.
  • setServer(server) — validates and stores the new server, persists it to NVS, then restarts SNTP and waits for a sync. Used to verify a user-supplied server is actually reachable before accepting it.
  • setTimezone(tz) — sets the TZ environment variable + tzset() and persists the value. Takes a POSIX TZ string as found in posix_tz_db/zones.csv, e.g. CET-1CEST,M3.5.0,M10.5.0/3.
  • getCurrTime(out) — fills a struct tm via localtime_r(). Returns ESP_FAIL if the year is before 2024, which is the cheap "clock was never synced" check (an unsynced ESP starts at the 1970 epoch).

Persisted defaults. Both the timezone and the time server survive a reboot, so a device whose first sync times out still uses the user's configured timezone (the TZ is applied in init() before waiting for the sync). Note that init() itself is only reached once waitUntilInternetIsConnected() returns — until then TZ is unset and getCurrTime() reports ESP_FAIL. The private TimeServiceNvs namespace (time_service_nvs.h) owns the keys — same pattern as AppNvs / NvsBinding: every function opens and closes its own handle, and the values are read on init() and written on every setter.

NVS namespace Key Type Written by Default if absent
time_service_defs tz string setTimezone() CET-1CEST,M3.5.0,M10.5.0/3 (Berlin)
time_service_defs ts string setServer() pool.ntp.org
cron

Wall-clock job scheduler, implemented as namespace Cron (cron.h) over static file-scope state (the job list and the task handle). Lets other components run a callback at a given date/time instead of building their own timer task. Built on top of time_service — it compares against TimeService::getCurrTime(), so jobs only fire once SNTP has synced. The task itself can be started before the clock is synced: while getCurrTime() returns ESP_FAIL (year < 2024) every tick is skipped, so nothing matches against 1970. This is why Cron::init() runs at the very beginning of app_main() — components such as StatusLightManager register their jobs in their own init() long before the network (and therefore SNTP) is up.

  • init() — creates the job-list mutex and the Cron_Task FreeRTOS task (2 KB stack, priority 5). The task wakes once per minute, right after the minute boundary (+100 ms margin), and runs every job that matches the current time — i.e. the scheduling resolution is one minute. Must be called exactly once (a second call creates a second task and replaces the mutex).
  • scheduleJob(timing, funcptr, ctx) — appends a job to the list and returns its handle (int, monotonically increasing). The callback has the signature esp_err_t (*)(void* ctx) — like a FreeRTOS task function, ctx is handed back unchanged on every call, so a class can register a static member function and pass this (see StatusLightManager::allLedsOff). A non-ESP_OK return value is only logged.
  • removeJob(handle) — removes the job from the list again (ESP_ERR_INVALID_ARG if the handle is unknown).
  • close() — deletes the cron task and the mutex. The job list is not cleared.

Jobs run on the cron task (2 KB stack) while the job-list mutex is held — callbacks must be short, must not block for long and must not call scheduleJob() / removeJob() themselves (deadlock).

sequenceDiagram
    participant C as Component (e.g. StatusLightManager)
    participant Cron as Cron (job list + Cron_Task)
    participant TS as TimeService

    C->>Cron: scheduleJob({.minute=0, .hour=22}, allLedsOff, this)
    Cron-->>C: handle
    loop every minute (+100 ms)
        Cron->>TS: getCurrTime(now)
        alt clock not synced (ESP_FAIL)
            Cron-->>Cron: skip tick
        else synced, new minute
            Cron->>Cron: take mutex, match every job
            Cron->>C: funcptr(ctx) for each matching job
            Cron->>Cron: give mutex
        end
    end
    C->>Cron: removeJob(handle)
Loading

Timing struct. cron_timing_t holds the five classic cron fields, using the same value ranges as struct tm (see localtime_r), not the ones from a crontab line. Every field defaults to CRON_ANY (0xFF), the wildcard (*) — so a default-constructed cron_timing_t fires every minute, and you only set the fields you want to constrain:

Field Range struct tm counterpart
minute 0–59 tm_min
hour 0–23 tm_hour
dotm (day of the month) 1–31 tm_mday
month 0–11 (0 = January) tm_mon
weekday 0–6 (0 = Sunday) tm_wday

Matching semantics. A job runs when every field is either CRON_ANY or equal to the current time. {.minute = 0, .hour = 17} is "every day at 17:00", {.minute = 30, .weekday = 1} is "every Monday at every half hour".

Persistence — jobs are not stored in NVS. The job list lives in RAM only and is empty after every boot. This is deliberate: a job carries a function pointer, and a flash address is only valid for the firmware image it was built from — persisting it would break on the next OTA update. Instead, the component that owns the schedule owns its persistence:

  1. The component stores its configuration (e.g. "LEDs off at 17:00, on at 08:00") in its own NVS namespace, in whatever form is natural for it — times, an enabled flag, etc.
  2. In its init(), on every boot, it reads that configuration back and re-registers the matching jobs via Cron::scheduleJob() with its own callbacks.
  3. Setters that change the configuration write it to NVS and re-schedule (removeJob() + scheduleJob()), so the running schedule and the persisted one never drift apart.

Cron therefore stays stateless across reboots and never needs to know what a job means; the NVS layout for a schedule is documented with the component that owns it, next to its other keys (see the status_lights namespace in the status_light README for the first user of this pattern — the night mode). Components that register jobs have to be initialised after Cron::init(), which is why it is the first call after the default event loop in main.cpp.

esp_ot_br_server

HTTP server providing the OpenThread REST API and the web GUI. Receives three C callback structs (web_firmware_callbacks_t in firmware_callbacks.h, web_network_callbacks_t in network_config.h, web_led_callbacks_t in status_light_callbacks.h) that decouple it from the C++ application layer — hence the status_light entry in its REQUIRES. Calls sse_events_init() after the HTTP server is started to register the /events endpoint.

Access Point setup page. index_html_get_handler checks the current mode via get_network_status(); while the device is in Access Point mode, it serves frontend/wifi_config.html instead of index.html. That page is fully self-contained (inline <style>/<script>, no static/restful.js, no CDN-hosted jQuery/D3) because a client connected to the SoftAP has no internet access to fetch external resources with.

Frontend build process. All assets under frontend/ (index.html, wifi_config.html, static/restful.js, static/style.css) are compressed with gzip level 9 at build time via gzip_frontend.py, invoked from CMakeLists.txt both at configure time (so EMBED_FILES has the compressed assets available) and at build time (re-runs when a frontend/ source file changes, tracked via the build/frontend_gz/.stamp file). Typical compression ratio is ~20-23% of original size.

File(s) Mechanism Location in flash
favicon.ico EMBED_FILES → binary symbol App binary (.rodata)
index.html, wifi_config.html, restful.js, style.css (gzipped) spiffs_create_partition_image SPIFFS partition (0x3D2000, 184 KB)

All file handlers in esp_br_web.c set the Content-Encoding: gzip response header and read with fopen("rb") + httpd_resp_send_chunk (binary-safe) rather than the string-based httpd_resp_sendstr_chunk, since gzip data contains null bytes that would otherwise truncate the response.

sse_events

Server-Sent Events broadcast component. Implemented as namespace sse_events (sse_events.hpp) backed by static file-scope state (s_clients, s_mutex, s_queue). sse_events_init(server) (sse_events_init.h, called once from esp_br_web.c) delegates to sse_events::init(), which creates the queue and mutex, registers the /events URI handler and spawns the sender task. The sender task drains the queue every second and sends a : ping\n\n keepalive every 20 seconds. Producers call sse_events::post_flash_progress(), post_flash_complete() or post_device_state() directly. See SSE Events for the event catalog.

Flash Partition Layout

The firmware uses a custom partition table (partitions.csv) that enables dual-bank OTA updates and reserves a small SPIFFS partition for the web GUI assets.

Name Type Offset Size (hex) Size (bytes) Size
nvs NVS data 0x009000 0x6000 24,576 24 KB
phy_init PHY calibration 0x00F000 0x1000 4,096 4 KB
ota_0 App (OTA slot 0) 0x010000 0x1E0000 1,966,080 1920 KB (~1.88 MB)
ota_1 App (OTA slot 1) 0x1F0000 0x1E0000 1,966,080 1920 KB (~1.88 MB)
ota_data OTA boot selector 0x3D0000 0x2000 8,192 8 KB
spiffs Web GUI assets 0x3D2000 0x2E000 188,416 184 KB
Total 4,194,304 4 MB

OTA update flow: The ESP-IDF OTA mechanism alternates between ota_0 and ota_1. The ota_data partition records which slot is active. A /flash/esp OTA update writes the new image into the inactive slot and switches the ota_data pointer — the previously running firmware remains intact in the other slot until the next update.

SPIFFS: Holds the gzip-compressed web GUI files (index.html, wifi_config.html, restful.js, style.css) — see esp_ot_br_server above for the build process.


Class Diagram

classDiagram
    direction TB

    class AppController {
        -UpdateManager& updateManager
        -FirmwareManager& firmwareManager
        +run() void
        +fillFirmwareCallbacks(cbs) void
        +requestRcpFlash(url, mode) esp_err_t
        +requestEspFlash(url) esp_err_t
        +requestModeChange(mode) esp_err_t
        +getCurrentMode() DeviceMode
        +rcpReboot() esp_err_t
        +rcpEraseNvram() esp_err_t
    }

    class UpdateManager {
        -bool busy
        +flashRcp(url) esp_err_t
        +waitForRcpFlash(timeout) esp_err_t
        +flashEsp(url) esp_err_t
    }
    class RcpUpdater {
        -CcBsl cc_bsl
        +flash(url) esp_err_t
    }
    class CcBsl {
        -uart_port_t uartPort
        -bool bslMode
        +init(uart) esp_err_t
        +enterBootloader() esp_err_t
        +eraseFlash() esp_err_t
        +beginFlash(addr, size) esp_err_t
        +sendData(data, len) esp_err_t
        +reset() esp_err_t
    }
    class OtaUpdater {
        +flash(url) esp_err_t
    }
    class ZstackMt {
        -uart_port_t uartPort
        +init(uart) esp_err_t
        +close() esp_err_t
        +eraseNvram() esp_err_t
        +rebootRcp(timeout) esp_err_t
    }

    class FirmwareManager {
        -DeviceMode activeMode
        +start(mode) esp_err_t
        +getActiveMode() DeviceMode
        +resetRcp() esp_err_t
        +factoryReset() esp_err_t
    }

    %% ── Interface Layer 1: Protocol lifecycle ──────────────────────────
    class ProtocolController {
        <<interface>>
        +start() esp_err_t
        +stop() esp_err_t
        +isRunning() bool
        +resetRcp() esp_err_t
        +factoryReset() esp_err_t
    }
    class ThreadController {
        +start() esp_err_t
        +stop() esp_err_t
        +isRunning() bool
        +resetRcp() esp_err_t
        +factoryReset() esp_err_t
    }
    class ZigbeeProxyController {
        -transport unique_ptr~IProxyTransport~
        -rcpToHostTask TaskHandle_t
        -hostToRcpTask TaskHandle_t
        -ZstackMt zstackMt
        +start() esp_err_t
        +stop() esp_err_t
        +resetRcp() esp_err_t
        +factoryReset() esp_err_t
        +isRunning() bool
    }

    %% ── Interface Layer 2: Byte-stream transport ────────────────────────
    class IProxyTransport {
        <<interface>>
        +open() esp_err_t
        +close() esp_err_t
        +write(buf, len) int
        +read(buf, len) int
    }
    class UartTransport {
        +open() esp_err_t
        +close() esp_err_t
        +write(buf, len) int
        +read(buf, len) int
    }
    class TcpTransport {
        -port uint16_t
        -serverFd int
        -clientFd int
        +open() esp_err_t
        +close() esp_err_t
        +write(buf, len) int
        +read(buf, len) int
    }

    class NetworkStateMachine {
        +getWifiConfig(out) esp_err_t
        +setWifiConfig(cfg) esp_err_t
        +getEthernetConfig(out) esp_err_t
        +setEthernetConfig(cfg) esp_err_t
        +getNetworkStatus(out) esp_err_t
        +fillNetworkCallbacks(cbs) void
    }
    class EthernetAPI {
        +initEthernet() void
        +closeEthernet() void
    }
    class WirelessAPI {
        +initWifi() void
        +closeWifi() void
        +initAccessPoint() void
        +closeAccessPoint() void
        +reconnect() void
        +scan(records, count) esp_err_t
    }

    class StatusLightManager {
        -atomic~uint32_t~ currentState
        -array~LedOverride~ overrides
        -int night_mode_start_handle
        -int night_mode_end_handle
        -night_mode_config_t night_mode_config
        -atomic~bool~ night_mode_active
        +init() esp_err_t
        +forceLed(led, mode) esp_err_t
        +getOverride(led) int
        +registerNightMode(config) esp_err_t
        +getNightMode(config) esp_err_t
        +clearNightMode() esp_err_t
        +fillLedCallbacks(cbs) void
        -allLedsOff(ctx)$ esp_err_t
        -restoreLedStates(ctx)$ esp_err_t
        -resolveCurrentState() LedState
    }
    class StatusLedMode {
        <<namespace>>
        +set(state) void
        +clear(state) void
    }
    class StatusLedModeScope {
        <<RAII>>
        +StatusLedModeScope(state)
        +~StatusLedModeScope()
    }
    class StatusLightHal {
        +init() esp_err_t
        +setPwr(enabled) void
        +setMode(enabled) void
        +setRcp(enabled) void
    }
    class StatusLightNvs {
        <<namespace>>
        +readOverrides(ovwrts, length) esp_err_t
        +writeOverrides(ovwrts, length) esp_err_t
        +readNightModeState(state, length) esp_err_t
        +writeNightModeState(state, length) esp_err_t
        +clearNightModeState() esp_err_t
    }

    class TimeService {
        <<namespace>>
        +init(server) esp_err_t
        +setServer(server) esp_err_t
        +setTimezone(tz) esp_err_t
        +getCurrTime(currTime) esp_err_t
    }
    class TimeServiceNvs {
        <<namespace>>
        +readDefaultTimezone(tz, length) esp_err_t
        +writeDefaultTimezone(tz) esp_err_t
        +readDefaultTimeServer(ts, length) esp_err_t
        +writeDefaultTimeServer(ts) esp_err_t
    }

    class Cron {
        <<namespace>>
        +init() esp_err_t
        +scheduleJob(timing, funcptr, ctx) int
        +removeJob(job_handle) esp_err_t
        +close() void
    }

    class sse_events {
        <<namespace>>
        +init(server) esp_err_t
        +post_flash_progress(target, phase, percent) esp_err_t
        +post_flash_complete(target, success, error) esp_err_t
        +post_device_state(mode, phase) esp_err_t
    }
    class WebServer {
        <<C component>>
        +esp_br_web_start() void
    }

    AppController o-- UpdateManager
    AppController o-- FirmwareManager
    UpdateManager *-- RcpUpdater
    UpdateManager *-- OtaUpdater
    RcpUpdater *-- CcBsl

    FirmwareManager o-- ProtocolController
    ProtocolController <|.. ThreadController
    ProtocolController <|.. ZigbeeProxyController

    ZigbeeProxyController *-- IProxyTransport
    ZigbeeProxyController *-- ZstackMt
    IProxyTransport <|.. UartTransport
    IProxyTransport <|.. TcpTransport

    NetworkStateMachine *-- EthernetAPI
    NetworkStateMachine *-- WirelessAPI

    StatusLightManager *-- StatusLightHal
    StatusLightManager ..> StatusLightNvs : overrides + night mode
    StatusLightManager ..> Cron : night-mode jobs
    StatusLedModeScope ..> StatusLedMode : set / clear
    StatusLedMode ..> StatusLightManager : STATUS_LED_EVENT
    StatusLightHal ..> FirmwareManager : RCP_LED_EVENT

    WebServer ..> sse_events : sse_events_init()
    WebServer ..> AppController : web_firmware_callbacks_t
    WebServer ..> NetworkStateMachine : web_network_callbacks_t
    WebServer ..> StatusLightManager : web_led_callbacks_t
    TimeService ..> TimeServiceNvs : persisted defaults
    Cron ..> TimeService : getCurrTime()
Loading

Boot-Decision-Tree

flowchart TD
    boot(["Boot\nnvs_flash_init + default event loop"])
    cron["Cron::init()\n(task skips ticks until time is synced)"]
    led["StatusLightManager ctor + init()\nrestore overrides (NVS 'ov')\nrestore night mode (NVS 'nm') → Cron jobs\nstarts in BOOTING"]
    net["Network up\n(NetworkStateMachine)"]
    web["Web server start\n(fw / net / led callbacks)"]
    conn["Wait for routed connection\n(waitUntilInternetIsConnected)"]
    time["TimeService::init()\nSNTP sync (max 10 s, non-fatal)"]
    q2{"device_setup?"}
    wait["Block — wait for mode\nselection via web UI"]
    flash["requestRcpFlash():\nstop() active controller (if any)\n→ flashRcp() + waitForRcpFlash()\n→ write device_mode/device_setup\n→ firmware_manager.start(mode)"]
    q3["Read DeviceMode from NVS"]
    start["firmware_manager.start(mode)"]
    run(["Normal operation"])

    boot --> cron --> led --> net --> web --> conn --> time --> q2
    q2 -->|"no (first boot)"| wait -->|"POST /flash/rcp\n(newest fw url + mode)"| flash --> run
    q2 -->|yes| q3 --> start --> run
Loading

requestRcpFlash() is the same live-flash path used both for first-boot setup and for any later re-flash (e.g. switching into/out of Thread mode) — there is no NVS-intent / reboot mechanism anymore; see Firmware Flash.

Night-mode jobs are registered during StatusLightManager::init() — long before TimeService::init() — but only start firing once the clock is synced (see cron).

API Endpoints

Method Path Description
POST /flash/rcp Flash RCP firmware live and activate the given mode — no reboot
POST /flash/esp Start live ESP OTA update
GET /device/mode Returns {"mode": <int>, "device_setup": <bool>}
POST /device/mode Set device mode — {"mode": <int>}
POST /device/esp/reboot Reboot the ESP32
POST /device/esp/erasenvs Erase the ESP32's own NVS
POST /device/rcp/reboot Hardware-reset the RCP — Zigbee via ZstackMt, Thread via a direct RST pulse
POST /device/rcp/erasenvram Erase the RCP's persisted network config — Zigbee via ZstackMt NVRAM clear, Thread via the OpenThread settings API (RCP itself holds no state in Thread mode)
POST /device/loglevel Set the ESP32 runtime log level — {"mode": <int>}, where 0=None, 1=Error, 2=Warning, 3=Info, 4=Debug, 5=Verbose (matches esp_log_level_t)
GET /device/led Current LED overrides — {"pwr": <int>, "mode": <int>, "rcp": <int>}, 0=Auto, 1=On, 2=Off (matches LedOverride)
POST /device/led Set one LED override — {"led": "pwr|mode|rcp", "mode": <int>}; persisted to NVS
GET /device/nightmode Current night-mode range — {"enabled": true, "start": "HH:MM", "end": "HH:MM"}, or {"enabled": false} if none is registered
POST /device/nightmode Set night-mode range — {"start": "HH:MM", "end": "HH:MM"} (crossing midnight allowed); replaces the previous range, persisted to NVS
POST /device/nightmode/clear Remove the night-mode cron jobs and the NVS entry; ends a currently running night immediately
GET /network/wifi Read WiFi config from NVS
POST /network/wifi Write WiFi config + trigger reconnect
GET /network/ethernet Read Ethernet config from NVS
POST /network/ethernet Write Ethernet config + trigger reinit
POST /network/mdns Set mDNS hostname — {"hostname": "<str>"}
GET /network/status Returns {"mode": <int>, "ip": "<str>", "connected": <bool>}
GET /network/wifi/scan Scan for nearby WiFi networks — returns [{"ssid", "rssi", "authmode", "channel"}, ...]
GET /get_properties OpenThread network properties
GET/DELETE /node OpenThread node info
GET/PUT /node/dataset/active Active Thread dataset
GET /available_network Scan for Thread networks
POST /join_network Join a Thread network
POST /form_network Form a new Thread network
GET /topology Thread network topology

Bridging the C web server and the C++ application

The web server (esp_br_web.c) is plain C. It receives three C callback structs at startup that forward web API requests to the C++ application layer without knowing anything about it:

// Filled by AppController::fillFirmwareCallbacks() — ctx = AppController*
typedef struct { ...; void *ctx; } web_firmware_callbacks_t;  // firmware_callbacks.h

// Filled by NetworkStateMachine::fillNetworkCallbacks() — ctx = NetworkStateMachine*
typedef struct { ...; void *ctx; } web_network_callbacks_t;   // network_config.h

// Filled by StatusLightManager::fillLedCallbacks() — ctx = StatusLightManager*
typedef struct {
    esp_err_t (*set_override)        (void *ctx, int led, int mode);
    esp_err_t (*get_override)        (void *ctx, int led, int *mode_out);
    esp_err_t (*set_night_mode)      (void *ctx, const night_mode_config_t *cfg);
    esp_err_t (*get_night_mode)      (void *ctx, night_mode_config_t *out);
    esp_err_t (*set_clear_night_mode)(void *ctx);
    void *ctx;
} web_led_callbacks_t;                                         // status_light_callbacks.h

main.cpp wires all three before starting the web server:

web_firmware_callbacks_t fwCbs{};
appController.fillFirmwareCallbacks(&fwCbs);

web_network_callbacks_t netCbs{};
network.fillNetworkCallbacks(&netCbs);

web_led_callbacks_t ledCbs{};
statusLight.fillLedCallbacks(&ledCbs);

esp_br_web_start("/spiffs", &fwCbs, &netCbs, &ledCbs);

HTTP handlers parse the JSON body and call the matching function pointer. AppController, NetworkStateMachine and StatusLightManager remain invisible to the web server. Any pointer may be NULL — the handlers then answer with ESP_ERR_NOT_SUPPORTED / default values instead of crashing.

LED / night-mode example. The frontend identifies LEDs by name ("pwr", "mode", "rcp"); esp_br_web.c maps the name to its index in s_led_names[], which equals the LedId value, and passes it on as a plain int. Night-mode times travel as "HH:MM" strings (the native value format of <input type="time">) and are parsed by parse_time_hhmm() into the uint8_t fields of night_mode_config_t (hour ≤ 23, minute ≤ 59, otherwise 400 Bad Request).

sequenceDiagram
    participant UI as restful.js
    participant Web as esp_br_web.c
    participant SLM as StatusLightManager
    participant Cron as Cron
    participant NVS as StatusLightNvs

    UI->>Web: POST /device/nightmode {"start":"22:00","end":"06:00"}
    Web->>Web: parse_time_hhmm() → night_mode_config_t
    Web->>SLM: set_night_mode(ctx, &cfg) → registerNightMode(cfg)
    SLM->>Cron: removeJob(old start/end) (if any)
    SLM->>NVS: clearNightModeState() (if old jobs existed)
    SLM->>Cron: scheduleJob(22:00, allLedsOff, this)
    SLM->>Cron: scheduleJob(06:00, restoreLedStates, this)
    SLM->>NVS: writeNightModeState(cfg)
    SLM-->>Web: ESP_OK
    Web-->>UI: pack_response(0, "successful", "Night mode saved.")
Loading

WiFi scan example. scan_shortend_record_t (network_config.h) is a slimmed-down projection of ESP-IDF's wifi_ap_record_t — only the fields the frontend needs (ssid, rssi, authmode, primary_channel) — kept separate so the web layer never has to include esp_wifi.h. WirelessAPI::scan() allocates the result array with malloc() and hands ownership to the caller through an out-parameter (scan_shortend_record_t **scan_records) plus a uint16_t *count; the call chain is network_wifi_scan_get_handler (esp_br_web.c) → get_wifi_scan_results callback → NetworkStateMachine::scanWifi() → WirelessAPI::scan(). The HTTP handler is responsible for free()-ing the array once the JSON response has been built.

Response pattern: pack_response → Log Box

What happens: almost every REST endpoint replies with the same small JSON envelope, and the frontend has one central place — not one per form — that turns that envelope into visible user feedback (the log_window modal, referred to here as the Log Box).

Backend. pack_response(error, result, message) in esp_br_web.c builds the envelope, httpd_send_packet() sends it, HTTP status is always 200:

{ "error": 0, "result": "successful", "message": "WiFi config saved" }

error (0 = ok, non-zero = failed) is the only field that decides success/failure; message is a human-readable string for display. httpd_resp_send_err() (real HTTP error codes, plain HTML body) is only used for hard transport failures that never reach this stage (e.g. request body too large) — everything that's a normal request/response cycle goes through pack_response, including validation failures like a missing field.

Frontend. Two global jQuery hooks in restful.js — not per-$.ajax-call code — inspect every response that passes through:

  • ajaxSuccess shows the Log Box for any response that has an error field and a non-empty message.
  • ajaxError shows a generic Log Box ("Request failed (HTTP …)") for any failed request that didn't register its own local error handler.

The Log Box title is derived from the URL (/network/mdns → "Mdns"). A $.ajax call only needs its own success/error handler for actual side effects — table refresh, redirect, polling — not to display anything; the two global hooks own all the logging.

What gets logged, and what doesn't:

Endpoint kind Example Success Failure
Action (POST, changes state) /network/wifi, /join_network, /add_prefix Logged — real message from the backend Logged — real message from the backend
Read-only diagnostic (GET) /topology, /get_properties, /available_network Not logged — backend sends message: "" on success, since error == 0 already says it worked Logged — backend still sends a real failure message
LED override (POST) /device/led Not logged — send_result_response() with an empty success message; the frontend only moves the tri-state highlight once error == 0 Logged — esp_err_to_name() of the callback result
Night mode (POST) /device/nightmode, /device/nightmode/clear Logged — "Night mode saved." / "Night-Mode successfully cleared" Logged — esp_err_to_name() / "Night-Mode already cleared!"
Settings read (GET) /device/led, /device/nightmode Not logged — plain JSON object, no envelope; only used to fill the UI on page load (led_load_settings()). Without an active night mode start/end are omitted, so the time inputs keep their HTML defaults (22:00 / 06:00) Not logged
Flash (/flash/esp, /flash/rcp) — Not auto-logged — different envelope ({status, message}, no error field), handler shows its own Log Box manually same
Calls with a local error handler saveNetworkConfig() (WiFi/Ethernet) n/a Not auto-logged — the global ajaxError fallback skips it so the caller's own handling (e.g. the WiFi reconnect case below) isn't overwritten

WiFi reconnect exception. After a WiFi save the AP may shut down mid-response (jqXHR.status === 0). saveNetworkConfig() handles that itself instead of relying on the global hooks: it shows a "reconnecting" Log Box and hands off to a separate inline element (#wifi-save-status) that polls /network/status and reports Connecting… (n) / Timeout / Connected! — a multi-step client-side process, not a single backend response.

mDNS redirect. network_mdns_post_handler uses the same pack_response envelope as any other action endpoint; the frontend's only extra step is redirecting the browser to http://<hostname>.local/ once the Log Box has shown a successful save.

SSE Events

The /events endpoint (sse_events component) streams server-push events to the web frontend (EventSource in JS) as event: <name>\ndata: <json>\n\n frames, plus a : ping\n\n keepalive every 20 s. Producers call the typed helpers in namespace sse_events (sse_events.hpp) — post_flash_progress(), post_flash_complete(), post_device_state() — which serialise the payload and push an SseEvent onto the internal static queue. The sender task drains that queue once per second and forwards each event to all connected clients. This section is the catalog of event names/payloads; keep it up to date as new events are added.

Pattern: state-change event → frontend re-fetches

The general shape for new events: a component posts a small/empty "something changed" event, and the frontend reacts by calling the existing REST GET endpoint for the full, current state — the SSE event is a signal, not the payload. This avoids duplicating response shapes between SSE and REST, and keeps the event data field (256 bytes) from becoming a bottleneck for larger payloads.

Event Payload Producer Frontend reaction
device_state {"mode": "<normal|setup|flashing>", "phase": "<str, optional>"} app_controller / firmware_manager (boot + mode transitions) Update device status banner
flash_progress {"percent": <int>, "phase": "<downloading|writing|rebooting|verifying>", "target": "<esp|rcp>"} update_manager (OtaUpdater / RcpUpdater) during /flash/esp, /flash/rcp Update flash progress bar
flash_complete {"target": "<esp|rcp>", "success": <bool>, "error": "<str, optional>"} update_manager, end of flash Close flash dialog / show error
network_changed (planned) {} NetworkStateMachine, on state transition / IP change / config write Re-fetch /network/status (and /network/wifi or /network/ethernet if that config view is open)

SseFlashTarget, SseFlashPhase, and SseDeviceMode in sse_events.hpp are the typed parameters of the post_* helpers — they map directly to the string values in the payloads above.

Planned: network_changed. The frontend currently polls a network status endpoint on an interval. The plan is to remove that polling loop and instead have NetworkStateMachine push a network_changed event whenever connectivity state, IP, or stored config changes; the frontend listens on /events and re-fetches the relevant /network/* endpoint only when notified. The event payload itself stays minimal (or empty) — the point is cutting polling traffic, not duplicating NetworkStatus over SSE.

Firmware Flash

  • ESP firmware (/flash/esp) — A standard ESP-IDF OTA update: the new image is streamed directly into the inactive ota_0/ota_1 partition via esp_https_ota_*, the boot partition is switched, and the device reboots into the new firmware. Handled by OtaUpdater (in update_manager).

  • RCP firmware (/flash/rcp) — The CC2652P7 cannot be updated over HTTP directly. The request body is { "url": "<rcp_firmware.bin>", "type": <DeviceMode int> }. AppController::requestRcpFlash(url, mode) flashes live — no ESP reboot:

    1. If a protocol controller is already running (i.e. device_setup is already true — this isn't the first-boot setup case), it's stopped first via FirmwareManager::stop() to free the RCP UART.
    2. UpdateManager::flashRcp() spawns a background task that downloads the TI binary over HTTPS into the OTA staging partition (RcpUpdater) and drives the CC2652 BSL over UART via CcBsl (cc_bsl component): sync → bank-erase → download → send-data → reset. The UART driver is released via CcBsl::close() after the flash.
    3. requestRcpFlash() blocks on UpdateManager::waitForRcpFlash(), which waits on a binary semaphore signaled by that task — no polling, no fixed delay.
    4. On success, writes mode to device_mode and sets device_setup, then calls firmware_manager.start(mode) directly, in the same boot session.

    The OTA partition is used as raw byte storage for the RCP binary — the ESP's own firmware is never modified during an RCP flash.

Github Download

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.

Additional resources

NOTE: The documentation is partly generated with AI. We ensure the factual integrety with regular checks by our engineers.

About

Alpha OTBR firmware for cod.m Zigbee Coordinator (CZC).

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages