LD2450 Presence Sensor
Native Zigbee firmware for the HLK-LD2450 24GHz mmWave radar sensor. Builds for two targets: ESP32-H2 (pure Zigbee mesh router, no WiFi) and ESP32-C6 (Zigbee end device with WiFi, web UI, and Wi-Fi OTA). A Zigbee alternative to ESPHome-based implementations, adding configurable polygon zones and full Home Assistant integration.
The system is validated in a live Home Assistant environment alongside commercial Zigbee devices, exposing real-world issues such as missed reports, network latency, and coordination delays.
GitHub: ShaunPCcom/esp32-ld2450-zigbee
Problem
Presence detection systems often rely on binary presence detection and centralized automation logic. In real-world environments, this leads to unreliable behavior due to missed updates, network latency, and dependence on a coordinator such as Home Assistant.
When the coordinator is delayed or unavailable, automations fail, resulting in inconsistent or non-functional behavior.
Solution
This project implements a custom ESP32-based Zigbee presence sensor using a 24GHz mmWave radar, designed to maintain reliable behavior under real-world conditions.
Instead of relying on centralized automation, the system moves key logic onto the device itself, reducing dependency on the coordinator.
It introduces:
- Polygon-based zones mapped to physical space, replacing binary presence detection
- Coordinator fallback to maintain autonomous operation when Home Assistant is delayed or unavailable
- A two-tier watchdog to distinguish transient jitter from genuine outages, preventing false automation triggers
What It Does
The HLK-LD2450 is a 24GHz millimeter-wave radar that tracks up to 3 targets simultaneously, reporting X/Y coordinates in millimeters at 10Hz. Raw coordinate data is useful for debugging but not for automation — you need spatial zones.
This firmware adds 10 configurable polygon zones on top of the sensor’s tracking output. Each zone defines a physical area in the room (desk, couch, doorway) using 3–10 vertices (triangle, rectangle, or any irregular shape) and exposes an independent occupancy sensor in Home Assistant. The entire configuration — zones, detection limits, tracking mode — persists to NVS flash and survives reboots without coordinator dependency.
Why Zigbee Over ESPHome
The existing ESPHome component for LD2450 is mature and flexible. This firmware makes different trade-offs:
| H2 (Zigbee-only) | C6 (Zigbee + WiFi) | ESPHome | |
|---|---|---|---|
| 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 ESPHome |
| Web UI | — | Radar, zones, settings, OTA | ESPHome dashboard |
| Zone count | Up to 10 polygon zones | Up to 10 polygon zones | Unlimited polygons |
| Config persistence | NVS, coordinator-independent | NVS, coordinator-independent | WiFi required |
| HA integration | Via Zigbee2MQTT | Via Zigbee2MQTT | Via ESPHome integration |
The Zigbee version fits a Zigbee-first environment where mesh networking and coordinator-independent config are priorities over maximum flexibility.
Key Implementation Decisions
Multi-Endpoint Architecture
The device registers 11 Zigbee endpoints:
- EP 1: Main device — overall occupancy, target data, sensor config, occupancy tuning, coordinator fallback (including per-zone fallback cooldowns), diagnostics, controls
- EP 2–11: Zones 1–10 — per-zone occupancy + per-zone config (vertex count, polygon coordinates, cooldown, delay)
Each zone is a separate Zigbee endpoint with its own Occupancy Sensing cluster and its own config cluster. This maps cleanly to Home Assistant entities and allows per-zone automations without filtering logic. Zone config writes arrive on each zone’s own endpoint — EP 2 for zone 1, EP 3 for zone 2, and so on.
Custom cluster 0xFC00:
- EP 1: Target count, coordinates, max distance, angle limits, tracking mode, coordinate publishing, occupancy cooldown/delay, coordinator fallback (mode, heartbeat, timeouts, per-zone fallback cooldowns), crash diagnostics, restart, factory reset, boot count reset, heartbeat ping
- EP 2–11: Per-zone config — vertex count, polygon coordinates (CSV), occupancy cooldown, occupancy delay
A two-phase NVS protocol prevents partial zone data persisting to flash: the vertex count is cached in memory until valid coordinates arrive, then the complete zone is written to NVS atomically.
Zone System Design
Zones support 3–10 vertices each (triangles, rectangles, pentagons, or any irregular polygon). Vertices are defined in metres in the sensor’s coordinate system (origin at sensor, Y-axis pointing forward, X-axis left/right). The firmware evaluates each tracked target against all 10 zones every poll cycle using a ray-casting point-in-polygon test.
Zone coordinates are stored and transmitted as a flat CSV string (e.g. 0.5,1.0,-0.5,2.0,0.0,3.0 for a triangle). This keeps the Zigbee attribute model to a single writable string per zone rather than N×2 number attributes. Coordinate validation enforces physical bounds: x ∈ [±7 m], y ∈ [0, 7 m], and rejects all-zero polygons as placeholders.
Disabling a zone (vertex count = 0) clears the coordinates in both NVS and the Z2M state cache immediately — no stale data in Home Assistant.
Occupancy Cooldown
A configurable cooldown per endpoint prevents false “clear” reports when someone briefly leaves a zone. When occupancy goes clear, a timer starts before the clear state is reported. If occupancy returns during the window, the clear is cancelled.
Each of the 11 endpoints (main + 10 zones) has an independent cooldown value (0–300 seconds). Default is 1 second — enough to prevent transition flicker between adjacent zones without meaningfully delaying real clear events.
Crash Diagnostics
Four attributes on EP 1 expose runtime health data: boot_count, reset_reason, last_uptime_sec, and min_free_heap. These persist across software resets using RTC_NOINIT_ATTR (.rtc_noinit section, preserved across esp_restart() and watchdog resets, cleared only by power loss).
last_uptime_sec updates every 100ms during operation. After a software reset (OTA reboot, CLI restart, watchdog), it shows how long the device ran before going down — useful for diagnosing stability issues remotely without physical access.
Factory Reset Options
The BOOT button (GPIO9) supports two distinct reset levels based on hold duration:
- 3 seconds: Zigbee network reset only — leaves the mesh and re-enters pairing mode, keeps all zone configuration
- 10 seconds: Full factory reset — erases both Zigbee state and all NVS configuration
LED feedback distinguishes the levels during the hold: fast red blink (0–3s), slow red blink (3–10s), solid red (>10s, full reset armed).
A remote factory reset is also available via Zigbee2MQTT: a text input entity in Home Assistant requires typing factory-reset exactly before sending. The confirmation gate prevents accidental activation; the firmware only acts on the magic byte 0xFE written to attribute 0x00F1, which the Z2M converter only sends after string validation.
Firmware Architecture
Modular C Design
The Zigbee application layer is split into 4 focused modules, refactored from a single 855-line zigbee_app.c that had accumulated all responsibilities:
| Module | Responsibility |
|---|---|
zigbee_init.c | Stack setup, endpoint and cluster registration (~320 lines) |
zigbee_attr_handler.c | Attribute write dispatch — receives writes, routes to NVS/sensor (~200 lines) |
sensor_bridge.c | Sensor polling, state tracking, occupancy reporting (~270 lines) |
zigbee_signal_handlers.c | Network lifecycle, steering retry, factory reset (~140 lines) |
Each module has a single clear responsibility. Changes to how attributes are written don’t touch the stack initialization code; changes to sensor polling don’t touch the network lifecycle handlers.
The refactor produced no functional changes. Binary size was unchanged at 701KB. The improvement is in maintainability: any module can be read, understood, and modified in isolation.
Shared Platform Components
Both this project and the LED controller share C++ components from esp32-common:
BoardLed: WS2812 status LED state machine. Connection states (not joined, pairing, joined, error) expressed as class methods; color/pattern sequences managed internally. One call to change state from anywhere in the application.ButtonHandler: GPIO boot button with configurable hold-time thresholds. Debouncing, repeat suppression, and multi-level reset detection in one component.
Shared components mean bug fixes and improvements propagate to both projects. The LED project’s ButtonHandler refinements apply to the LD2450 on the next integration.
OTA Update Pipeline
Firmware updates use a shared OTA component (esp32-zigbee-ota). The dual-partition flash layout provides automatic rollback: if the new firmware fails to boot, the bootloader reverts to the previous working partition.
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” button and an amber banner when a newer version is available. If WiFi is unavailable, C6 falls back to Zigbee block transfer.
Automated release pipeline (GitHub Actions)
- Push a version tag (e.g.
v2.4.1) - Workflow builds H2 and C6 firmware on Ubuntu with ESP-IDF v5.5.2
- Packages each binary as a Zigbee OTA image (separate imageType per target)
- Creates GitHub release with both OTA files attached
- Commits updated
z2m/ota_index.jsonback to master
Zigbee2MQTT checks the OTA index every 24 hours. Devices receive update notifications in Home Assistant automatically.
Home Assistant Integration
89 entities exposed via Zigbee2MQTT external converter (z2m/ld2450_zb_h2.js):
- 11 occupancy binary sensors (main + 10 zones)
- 1 target count sensor
- 6 coordinate sensors (X/Y position for each of 3 tracked targets)
- 4 crash diagnostic sensors (boot count, reset reason, last uptime, min free heap)
- 1 reset boot count action, 1 restart control, 1 factory reset text input, 1 heartbeat ping action
- 7 global configuration controls (max distance, angle limits, tracking mode, coordinate publishing, main cooldown/delay)
- 7 coordinator fallback controls (enable, mode, cooldown, heartbeat enable/interval, hard timeout, ACK timeout)
- 50 zone configuration controls (vertex count select + coords text + cooldown + delay + fallback cooldown per zone × 10)
- 1 firmware update entity
Zone setup workflow: on C6, use the web UI radar view directly. On H2, enable coordinate publishing, watch target positions on a Plotly graph card (included in examples/home-assistant/), set vertex count, then enter polygon coordinates as a CSV string in metres.
Coordinator Fallback
When the Zigbee coordinator or Home Assistant goes offline, the sensor can keep controlling lights autonomously via direct Zigbee bindings. A two-tier model separates transient network jitter from real outages:
- Soft fallback: Single ACK timeout — sensor sends On/Off directly to its bound light. Clears automatically when the coordinator responds.
- Hard fallback: No coordinator response for
hard_timeout_secseconds — sensor takes full control, turning lights on and off based on presence. Sticky, NVS-backed, survives reboots. - Heartbeat watchdog: HA blueprint sends periodic pings. If pings stop (HA crashed, Z2M stopped, server rebooted), the sensor enters hard fallback — catching the common case where the Zigbee radio is alive but the software stack is dead.
An HA blueprint automation (ha/blueprints/ld2450_fallback_watchdog.yaml) handles recovery: it clears hard fallback automatically when Z2M reconnects, on the next heartbeat cycle, or after HA restarts.
This approach ensures presence detection remains reliable even when the central automation system is degraded or unavailable.
Serial CLI
For direct configuration without coordinator dependency, a UART CLI is available at 115200 baud:
| |
All CLI changes persist to NVS immediately.