Zigbee LED Controller
Native Zigbee LED strip controller firmware for two targets: ESP32-H2 (pure Zigbee mesh router) and ESP32-C6 (Zigbee end device with WiFi, web UI, and Wi-Fi OTA). Drives two physical LED strips through 8 independent virtual segments, each exposed as a separate Extended Color Light in Home Assistant. A ninth master endpoint controls all active segments simultaneously. Replaces WLED’s WiFi-based approach with native Zigbee mesh networking.
GitHub: ShaunPCcom/zigbee-LED-ESP32-controller
| H2 (Zigbee-only) | C6 (Zigbee + WiFi) | WLED | |
|---|---|---|---|
| Network | Native 802.15.4 mesh router | Zigbee end device + WiFi | WiFi, standalone |
| OTA transport | Zigbee block transfer (~5 min) | Wi-Fi HTTPS (~seconds) | Via WLED |
| Web UI | — | Segments, presets, config, WiFi, system | WLED dashboard |
| Config persistence | NVS, coordinator-independent | NVS, coordinator-independent | WiFi required |
| HA integration | Via Zigbee2MQTT | Via Zigbee2MQTT | Via WLED integration |
Problem
Commercial Zigbee LED controllers are typically closed systems with limited flexibility, making it difficult to customize behavior or integrate tightly with specific automation setups.
Solution
This project implements a custom ESP32-based Zigbee LED controller to provide full control over device behavior and integration within a Home Assistant environment.
By building the controller from scratch, the implementation avoids the limitations of closed commercial devices and enables predictable, consistent behavior within the Zigbee network.
What It Does
Two physical LED strips (SK6812 RGBW or WS2812B RGB, configured independently per strip) are divided into up to 8 virtual segments. Each segment is an independently controllable region with its own Zigbee endpoint — on/off, brightness, RGB color, color temperature, and power-on behavior. A ninth endpoint controls all active segments simultaneously.
The result is a single physical device that appears as up to 9 smart bulbs in Home Assistant, each automatable independently or as a group.
Hardware Constraint: SPI Instead of RMT
The ESP32-H2 RMT peripheral conflicts with the Zigbee radio when driving WS2812-style LEDs. Standard LED strip libraries that use RMT are incompatible with simultaneous Zigbee operation.
Solution
Both external strips use SPI2 with MOSI time-multiplexing. The MOSI GPIO is remapped between strip refreshes using gpio_matrix_out() — strip 1 uses GPIO4, strip 2 uses GPIO5, with MOSI switched at the driver level between each strip’s transmission. The onboard status LED retains the single safe RMT channel.
This approach works identically across ESP32-H2 and ESP32-C6, requiring no RMT availability assumptions from the LED driver layer.
Key Implementation Decisions
Virtual Segment Model
Segments are software divisions — they define a start index and LED count on a physical strip, with no hardware mapping at the strip level. Segments can overlap; the render order determines precedence (higher segment index wins).
9 Zigbee endpoints:
- EP 1–8: Individual segments, each an Extended Color Light (device ID 0x0210)
- EP 9: Master endpoint — commands propagate to all active segments simultaneously
EP 9 provides a single point of control for scenes like “all off” or “dim everything to 30%” without needing Zigbee groups or HA scripts. Each physical device presents all controls within itself.
Custom clusters on EP 1:
0xFC00: Device configuration — strip LED counts, strip types, power limits, global transition time, crash diagnostics (boot count, reset reason, last uptime, min heap), restart (0x00F0), factory reset (0x00F1)0xFC01: Segment geometry — start index, LED count, strip assignment per segment (24 attributes: 3 per segment × 8 segments)0xFC02: Preset management — save/recall/delete slots by index, slot names
Color Model
Each segment operates in either HS mode (drives RGB channels) or CT mode (drives the white channel on SK6812, approximates warm white on WS2812B). The modes are mutually exclusive — the same behavior as commercial RGBW bulbs.
WS2812B color temperature approximation: WS2812B strips have no physical white LED. CT mode on a WS2812B segment interpolates between cool white (equal RGB, 6500K) and desaturated orange (hue 30°, ~55% saturation, 2700K). It’s an approximation, but gives a convincing warm feeling and keeps presets portable across strip types.
Shortest-arc hue transitions: Color transitions follow the optimal path around the color wheel. A transition from 350° to 10° routes through 0° (20° arc) rather than backward through 340°. The wraparound calculation distinguishes between a small angular overshoot (value > 360 but < 32768) and a wrapped negative (value > 32768), which would otherwise both appear as large values in the same integer representation.
Transition Engine
A 200Hz firmware-side transition engine runs independently of the Zigbee stack’s interpolation. Each segment has four embedded transition_t structs (level, hue, saturation, color temperature). The engine interpolates linearly at 5ms intervals, feeding values directly to the LED driver.
The 200Hz engine serves as a smoothing filter over the Zigbee SDK’s internal Level Control interpolation, which produces discrete steps at lower resolution. With the firmware engine at 100ms default transition time, brightness and color fades are smooth and imperceptible even over long transition durations.
Transitions start immediately with their current value as the new start point if a new command arrives mid-transition, eliminating visual jumps on rapid commands.
Preset Management
8 named preset slots capture the complete state of all 8 segments (on/off, brightness, color, white temperature). Presets are stored in NVS flash and survive reboots and firmware updates.
Slot-number design: Preset slots use stable integer identifiers (0–7) rather than names. Names are metadata — they can change without breaking Home Assistant automations that reference slot numbers. This follows the pattern used by WLED and ensures automation reliability after preset renaming.
| |
Preset recall via Zigbee updates all segment state, then defers ZCL attribute synchronization to the Zigbee task context (100ms delay via esp_zb_scheduler_alarm). Direct ZCL calls from attribute handler context cause a FreeRTOS assertion; the scheduler alarm routes execution through the correct task.
Firmware Architecture
Modular C Design
The Zigbee application layer is split into 5 focused modules, refactored from a single 882-line zigbee_handlers.c:
| Module | Responsibility |
|---|---|
color_engine.c | HSV↔RGB conversion, shortest-arc hue calculation (~200 lines) |
led_renderer.c | 200Hz render loop, ZCL polling, state-to-LED rendering (~255 lines) |
preset_handler.c | Bridge between ZCL 0xFC02 cluster and preset_manager (~226 lines) |
zigbee_attr_handler.c | Attribute write dispatch — receives writes, routes to segment/preset/config handlers (~220 lines) |
zigbee_signal_handlers.c | Network lifecycle, steering retry, factory reset (~120 lines) |
The refactor eliminated a color_conversion.c file that handled XY conversion separately, merging all color math into color_engine.c. Binary size was unchanged (667KB). Each module can be read and modified independently of the others.
This same modular pattern was applied to the LD2450 project (4 modules from its 855-line god module), establishing a consistent architecture across both projects.
Shared Platform Components
Both projects share C++ components from esp32-zigbee-common:
BoardLed: WS2812 status LED state machine for connection state indication (amber = not joined, blue = pairing, green = joined, red = error). Handles pattern timing and color transitions internally.ButtonHandler: Boot button (GPIO9) with hold-time thresholds for two-level factory reset. Three-second hold leaves the Zigbee network (keeps NVS config); ten-second hold erases everything. A remote factory reset is also available via Zigbee2MQTT — a text input entity requires typingfactory-resetexactly, preventing accidental activation.
Shared components are published to GitHub and consumed via the ESP-IDF Component Manager. CI/CD builds on GitHub Actions pull components automatically with no local path dependencies.
OTA Update Pipeline
The dual-partition flash layout (two 1.5MB OTA partitions) enables over-the-air firmware updates with automatic rollback. The shared esp32-zigbee-ota component handles the Zigbee OTA cluster, image validation, and partition switching.
H2: Updates transfer block-by-block over Zigbee (~5 minutes). Z2M manages the transfer and shows progress in Home Assistant.
C6: When WiFi is connected, the C6 downloads firmware directly over HTTPS from the GitHub release URL — cutting update time to seconds. The web UI shows a “Check for Updates” banner when a newer version is available. If WiFi is unavailable, C6 falls back to Zigbee block transfer.
OTA restart uses esp_zb_scheduler_alarm for a deferred 3-second delay after the OTA FINISH callback. This allows Zigbee2MQTT to complete its post-update handshake (reading softwareBuildID to confirm version) before the device restarts. An immediate restart in the FINISH callback prevented Z2M from reading the firmware version, leaving devices showing as “unsupported” after OTA.
Automated release pipeline (GitHub Actions)
- Push version tag (
v1.2.0) - Workflow builds H2 and C6 firmware with ESP-IDF v5.5.2 on Ubuntu
- Packages each binary as a Zigbee OTA image (separate imageType per target)
- Creates GitHub release with both OTA files
- Commits updated OTA index, triggering aggregator to update the combined device index
Both the LED controller and LD2450 sensor use the same aggregated OTA index, served via GitHub Pages and configured in Zigbee2MQTT’s configuration.yaml.
Home Assistant Integration
Per-segment entities (8 segments × ~5 entities each, plus device controls):
- On/off, brightness, color (HS mode), color temperature (CT mode), power-on behavior per segment
- EP 9 (all-segments master) for unified control
- Strip configuration: LED counts, types, power limits
- Segment geometry: start index, LED count, strip assignment
- Global transition time
- Crash diagnostic sensors (boot count, reset reason, last uptime, min free heap)
- Restart button
- Factory reset text input (type
factory-resetto confirm) - Preset controls: slot selector, apply, save, delete, slot name display
- Firmware update entity
Serial CLI
Full configuration available over UART without coordinator dependency:
| |