Skip to content

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
NetworkNative 802.15.4 mesh routerZigbee end device + WiFiWiFi, standalone
OTA transportZigbee block transfer (~5 min)Wi-Fi HTTPS (~seconds)Via WLED
Web UISegments, presets, config, WiFi, systemWLED dashboard
Config persistenceNVS, coordinator-independentNVS, coordinator-independentWiFi required
HA integrationVia Zigbee2MQTTVia Zigbee2MQTTVia 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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Home Assistant automation using stable slot number
- service: number.set_value
  target:
    entity_id: number.zb_led_ctrl_preset_slot
  data:
    value: 2  # "Movie Mode" — name can change, slot 2 is always slot 2
- service: select.select_option
  target:
    entity_id: select.zb_led_ctrl_apply_preset
  data:
    option: "Apply"

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:

ModuleResponsibility
color_engine.cHSV↔RGB conversion, shortest-arc hue calculation (~200 lines)
led_renderer.c200Hz render loop, ZCL polling, state-to-LED rendering (~255 lines)
preset_handler.cBridge between ZCL 0xFC02 cluster and preset_manager (~226 lines)
zigbee_attr_handler.cAttribute write dispatch — receives writes, routes to segment/preset/config handlers (~220 lines)
zigbee_signal_handlers.cNetwork 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 typing factory-reset exactly, 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)

  1. Push version tag (v1.2.0)
  2. Workflow builds H2 and C6 firmware with ESP-IDF v5.5.2 on Ubuntu
  3. Packages each binary as a Zigbee OTA image (separate imageType per target)
  4. Creates GitHub release with both OTA files
  5. 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-reset to confirm)
  • Preset controls: slot selector, apply, save, delete, slot name display
  • Firmware update entity

Serial CLI

Full configuration available over UART without coordinator dependency:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
led config                      # Strip configuration summary
led seg 1                       # Show segment 1 geometry
led seg 2 start 60              # Set segment 2 start to LED 60
led seg 2 count 30              # Set segment 2 to 30 LEDs
led seg 2 strip 2               # Assign segment 2 to strip 2
led type 1 sk6812               # Set strip 1 type to SK6812
led maxcurrent 1 5000           # Limit strip 1 to 5A
led transition 200              # Set global transition to 200ms
led preset                      # List all preset slots
led preset save 0 Evening       # Save current state as "Evening"
led preset apply 0              # Recall preset slot 0