DISLAIMER:
Building a DIY Zigbee sensor is not a simple task. It’s far more involved than just flashing an ESP32 with ESPHome, which is why custom Zigbee devices are relatively rare. While I did my best to ensure the sensor works properly and you can use it freely in your own smart home, there is always room for improvement and refinement.
Therefore, this project should not be considered a production-ready device. It is meant for tinkerers and enthusiasts as a learning resource, to be used as a base we can all build on and expand. SmartHomeScene’s content is free and always will be. If you want to support the site, you can always buy us a cup of coffee.
I welcome your feedback, suggestions, and experiences on how this project can be improved further. Please leave them in the comments below.
Intro and project requirements
A while back, Espressif released two mainstream IEEE 802.15.4 boards: ESP32-C6 and ESP32-H2. Which means, both of these support Zigbee and Thread, opening up the door to DIY Zigbee/Thread devices and making things a bit easier.
This DIY Zigbee presence sensor is based on the ESP32-C6-WROOM-1 and the Hi-Link HLK-LD2410C mmWave radar sensor. The firmware also works with the LD2410/LD2410B and can also be adapted to work with the latest HLK-LD2412 radar. Besides the board, this project requires the following things:
- HLK-LD2410C [AliExpress, Amazon]
- Dupont connectors [AliExpress, Amazon]
- Data cable (USB-C)
The firmware can also work with the ESP32-H2, although it requires changes and using different GPIO pins. Since I do not have one, I have not tested the sensor with the H2. Feel free to try and adapt it for the H2 and share your insights below.
Wiring and connection
The HLK-LD2410C radar communicates with the ESP32-C6 over UART. To connect it properly, you need power, ground and two data lines. This wiring ensures that the radar’s transmit line connects to the ESP32’s receive pin, and vice versa. The UART link runs at 256000 baud, which is supported by the ESP32-C6 hardware. These are the connections you will need to make:
- VCC (LD2410) → 5V (ESP32-C6)
- GND (LD2410) → GND (ESP32-C6)
- TX (LD2410) → GPIO4 (RX1) (ESP32-C6)
- RX (LD2410) → GPIO5 (TX1) (ESP32-C6)
The ESP32-C6 allows several pins to be assigned to UART, but GPIO4 and GPIO5 were selected because they provide the most stable and reliable option at the high baud rate of 256000. These two pins are hardware-mapped to UART1, so they handle continuous data streaming from the LD2410C without issues.
Unlike some other pins on the chip, they are not tied to bootstrapping functions, meaning the device always starts up cleanly even with the radar connected. They are also unused by other essential parts of the firmware, such as the Zigbee radio or the boot button on GPIO9, which further avoids conflicts. This makes GPIO4 and GPIO5 the safest and most robust choice for handling the radar’s data link.
Firmware overview and design logic
At its core, this project takes the LD2410C mmWave radar and makes it work as a simple, reliable Zigbee presence sensor. The radar itself can detect whether someone is moving or sitting still in a room, but the raw data it produces is messy and not very useful on its own. The firmware cleans this up and only sends the important states to Zigbee, so you get stable entities in Home Assistant that you can actually use for automations, without flooding your mesh network with useless payloads.
The way this is achieved is by combining a few building blocks inside the firmware: the radar data is read and smoothed out via UART, the information is mapped into Zigbee clusters so coordinators can understand it, and the boot button is given a smart role so you can reset everything easily if needed. That’s the simple view of things.
For those who want to dive deeper into the technical side, read below to learn how each part was implemented in more detail.
LD2410 data parsing and cooldown logic
The HLK-LD2410C radar streams detection frames over UART at 256000 baud. Each frame begins with the header bytes F4 F3 F2 F1
, followed by a length field and a series of data blocks. In total, the LD2410 publishes several types of information:
- A state byte that signals whether a moving target, a static target, or both are detected
- Bit 0 = moving
- Bit 1 = static
- Bit 0 or Bit 1 = occupancy
- Energy levels for each detection gate, covering distances from around 0.75 m up to 6 m depending on the configured range
- Distance values for the closest moving and closest static target
- Signal strength and quality metrics describing the radar return
- Configuration echoes when parameters are updated
The firmware intentionally keeps things simple and only parses the state byte. This provides the moving, static, and combined occupancy states needed for reliable and immediate presence sensing. For the time being, I decided to not parse the rest of the data for the following reasons:
- Distance reporting would result in noisy, constantly changing values. If these were pushed to Zigbee, the device would flood the network too much, making it unreliable for real-world use.
- Energy levels per gate and signal strength metrics are skipped for the same reason. They may be useful for debugging, but they add little value for daily automations.
- Configuration echoes are ignored since the firmware itself manages and stores all parameters in NVS, so there is no need to re-process what was just sent.
For configuration, this firmware applies sensitivity and range values globally across all gates. Individual gate configuration is planned for a future firmware update, which will allow fine-tuning of detection at different distances, similar implementation like the Apollo MSR-2 Multisensor.
Finally, the firmware adds a firmware-defined movement cooldown layer on top of the raw state values. When the radar stops reporting motion, the moving state is kept active for a user-defined time before clearing. This prevents rapid toggling caused by brief detection gaps and creates a smoother, more stable presence signal. Setting the cooldown to zero disables it completely, although I suggest you do not do that. The sensor will jump very frequently from detected to clear, flooding the network.
Endpoints, clusters and attributes implementation
Once the LD2410C data is parsed, obviously it needs to be represented in a Zigbee-compliant way so Home Assistant and other controllers can understand at least the basic occupancy cluster. For this firmware, I designed a two-endpoint structure that keeps things simple but powerful.
Endpoint 1: Light and configuration
Endpoint 1 is defined as a standard HA On/Off Light device. This is primarily used to drive the onboard light driver, giving a straightforward binary switch that can be controlled from Zigbee2MQTT. While simple, it is useful for feedback, debugging, or even integration into automations as a visual indicator. To keep things simple and focus on presence detection, I purposefully removed RGB controls from the light.
Alongside the On/Off cluster, Endpoint 1 also hosts a custom configuration cluster at ID 0xFDCD
. This cluster exposes several attributes that control how the radar operates:
- Movement cooldown (seconds)
- Occupancy clear duration (seconds)
- Moving sensitivity (0–10 proxy, internally 0–100)
- Static sensitivity (0–10 proxy, internally 0–100)
- Moving maximum gate (0–8, 0.75m step)
- Static maximum gate (2–8, 0.75m step)
The sensitivity sliders shown in Zigbee2MQTT range from 0–10 for convenience, but internally these values are mapped to the radar’s 0–100 scale. A value of 10 on the slider means maximum sensitivity (100), while 0 means the radar is effectively disabled for that detection type. This mapping keeps the interface simple while still allowing fine, granular control.

The gates define how the LD2410C splits its detection range into distance zones. Each gate represents a step of 0.75 meters.
Each gate also has a default energy threshold for detection. The firmware applies separate thresholds for moving and static targets, but controls them globally through the sensitivity sliders.
For moving detection, the maximum gate can be set from 0 to 8, covering the entire range. For static detection, the maximum gate starts at 2. This is because gates 0 and 1 are too close to the radar and often dominated by noise and reflections, making static presence unreliable. By starting from gate 2, static detection begins at 0.75 m and extends outward, where the radar can better distinguish a still target from background interference.
All of these attributes are read/write and are persisted in NVS so the device always restores the same behavior after a reboot. To avoid wearing out flash memory, updates are debounced, cooldown values are saved immediately, while sensitivities and gate changes are queued and written after 500 ms.
Endpoint 2: Occupancy sensing
Endpoint 2 uses the standard Occupancy Sensing cluster at ID 0x0406
. Here, the device publishes three states as manufacturer-specific attributes:
- Moving target detected
- Static target detected
- Occupancy (either moving OR static)
These appear in Zigbee2MQTT and Home Assistant as clean binary entities. Because they use a standard cluster, the integration is seamless, while the custom attributes provide the extra detail not available in stock Zigbee presence sensors.
Why this design
This two-endpoint layout was chosen after experimenting with a five-endpoint version that separated moving, static, occupancy, light, and config. That design technically worked but cluttered the Zigbee2MQTT interface and created unnecessary complexity. I feel that this two-endpoint model strikes a balance between standards compliance and flexibility.
If you better a better suggestion on how to handle the endpoints, I’d love to hear it!
Zigbee device name and metadata
Besides the endpoint and cluster layout, the firmware also makes sure the device identifies itself correctly on the Zigbee network. This helps both Zigbee2MQTT and Home Assistant recognize it as a valid router and display useful information.
- Manufacturer name:
SmartHomeScene
- Model identifier:
SHS01
- Date code: build date of the firmware (e.g.
2025-08-29
) - Software build ID: semantic version of the firmware (e.g.
SHS01-1.0.0
) - Power source: always reported as mains powered
- Network topology: Zigbee Router
The device name and model identifier can be changed in the shs01.h
firmware file by adjusting the SHS_MANUFACTURER_NAME
and SHS_MODEL_IDENTIFIER
lines. These strings are defined as length-prefixed ASCII values (the first byte defines the number of characters that follow):
/* Manufacturer / Model strings for Basic cluster (length-prefixed ASCII) */
#define SHS_MANUFACTURER_NAME "\x0E""SmartHomeScene" // 0x0E = 14 characters
#define SHS_MODEL_IDENTIFIER "\x05""SHS01" // 0x05 = 5 characters
You would also need to change the model and vendor ID identifiers in the external converter, so the device is recognized properly in Zigbee2MQTT. These are found at line 126, under module.exports
.
Boot button and factory reset behavior
A critical part of any Zigbee device is the ability to recover from misconfiguration or network issues without re-flashing firmware. For this reason, the boot button on the ESP32-C6 board was repurposed to handle reset/pairing logic.
When the button is pressed and held for six seconds, the device enters its factory reset routine:

This sequence performs three actions in order:
- Erase Zigbee network data so the device is returned to a factory-new state and can be paired again.
- Clear all configuration values in NVS, including cooldowns, sensitivities, and gate settings.
- Restart the device, so it immediately begins normal operation with default parameters.
During the long-press, logs clearly announce that the reset is armed, and after the hold time expires, confirmation is shown that the reset is complete. This helps avoid accidental wipes when the button is tapped briefly. Always enter idf.py monitor when pairing the device and keep an eye on the logs:
Preparing the environment
There are many ways to build and flash firmware onto an ESP32-C6. You can use the command line, ESP-IDF directly, or even Espressif’s official flasher tools. For this project, I use Visual Studio Code (VS Code) with the ESP-IDF extension because it provides an integrated environment that is easier to manage. It allows you to install the SDK, handle dependencies, build the firmware, and monitor logs all in one place.

NOTE: Even though some of these instructions may seem obsolete or obvious, I am sharing them anyway so that less tech-savvy people can try it out as well.
Installing VS Code and ESP-IDF
- Install Visual Studio Code from the official website.
- Open VS Code and select Extensions from the left menu
- Find and install the ESP-IDF extension.
- When prompted, run the setup wizard and choose EXPRESS Installation
- You can also start the wizard by pressing Ctrl+Shift+P and searching ESP-IDF: Configure ESP-IDF extension
- You can also choose ADVANCED and change the directories

- From the dropdown, select the latest version (v.5.5.1 as of Sept, 2025)
- Press Install which will download and install:
- ESP-IDF SDK (v5.x or newer for ESP32-C6)
- The toolchain for RISC-V targets
- Python with pip for dependencies
- CMake, Ninja, and Git
- The process takes about 20 minutes, so be patient
- Done!
Connecting the board and finding the port
The next step is to connect the board to your PC. Use a USB-C data cable plugged into the left USB-C port marked “CH343” (this is the onboard USB-to-UART chip). Do not use the right USB-C port, which is reserved for native ESP32-C6 functions. Once attached, find the correct port and remember it:
- Windows: Device Manager > Ports (COM & LPT) > USB-Serial CH343 (COMx)
- Mac:
ls /dev/tty.*
→ look for /dev/tty.usbserial-xxxx - Linux:
dmesg | grep tty
→ check for /dev/ttyUSBx
Building and flashing the firmware
Once you have the port number (e.g. COM4), you are ready to build and flash.
- Download the .zip file containing the project
- Extract the .zip on your computer
- Open VS code, click File > Open Folder and select the
SHS01
folder - Press CTRL + Shift + P and Search for ESP-IDF: Open ESP-IDF Terminal
- Execute the following commands, in order to build and flash the firmware
Set target to esp32c6. Alternatively, select it manually in the bottom left corner (CPU icon):
idf.py set-target esp32c6
Build the project
idf.py build
Flash the firmware (replace port number)
idf.py -p COM4 flash
Monitor the output
idf.py monitor
If you get no errors, the logs should output the current state of the sensor. As the default moving target cooldown is zero, the moving target will jump up and down while you are being detected. Exit the logs by pressing Ctrl + ]
. If the device fails to enter network steering mode after a successful flash, or it produces write errors, erase the flash before re-building and re-flashing with the following command:
Erase flash
idf.py -p COM4 erase_flash
Zigbee2MQTT Integration
For this DIY sensor to work properly in Zigbee2MQTT, an external converter is required. This converter acts as a translator between the custom firmware running on the ESP32-C6 and the way Zigbee2MQTT expects to handle devices. It defines the device model, explains which clusters and attributes are exposed, and maps them into usable entities such as binary sensors, switches, and sliders inside Home Assistant.
Here’s how to add it:
- Stop the Zigbee2MQTT add-on
- Using a file explorer, open the
zigbee2mqtt
folder - Create a new folder and name it
external_converters
- Get the
shs01.js
file found in theshs01.zip
file you downloaded- Location:
shs01/zigbee2mqtt/external_converters/shs01.js
- Location:
- Upload the
shs01.js
file and place it inside Zigbee2MQTT’s external converters folder- Location:
zigbee2mqtt/external_converters/shs01.js
- Location:
- Start Zigbee2MQTT add-on
- Enable
Permit Join (ALL)
- Press and hold the
BOOT
button for 6 seconds - Done!
IMPORTANT: Recently, Zigbee2MQTT changed the way it handles external converters. If you have an external_coverters
line inside Zigbee2MQTT’s configuration.yaml
file, remove it completely. It will auto-detect any external converters placed in the external_converters
folder on boot.


What about ZHA?
At this point, I’m still making small changes and tweaks to the firmware. I use Zigbee2MQTT as a testing platform, making sure everything works properly and configuration parameters are respected. I’d rather not bother with a ZHA quirk until I’m confident the device works 100% like it should. If anyone wants to develop a custom quirk, feel free to do so.
Why not a Github repo?
A proper GitHub repository is in the works, but I want to finish initial testing and stabilization before publishing it. This ensures that the first public repo will contain a clean, well-structured baseline without half-finished features or constant breaking changes.
Final Thoughts
This project shows how an ESP32-C6 combined with the HLK-LD2410C radar can be turned into a reliable Zigbee presence sensor. The firmware strips away noisy raw data and focuses on stable moving, static, and occupancy states that are actually useful in a smart home. With the addition of configurable cooldowns, sensitivities, and detection ranges, it grants a certain level of flexibility without overwhelming the Zigbee network.
While not a production-ready device, the sensor provides a solid foundation for DIY enthusiasts. Future improvements such as per-gate tuning, additional attributes, and broader integration support will make it even more capable. For now, it offers a stable baseline that can be built upon, tested, and refined further by the community.
If you have any feedback, please share it in the comments below.
Nice work, thanks for sharing! Just wondering, any reason you didn’t go with the C6 mini?
No reason, that’s what I had on hand from another project.
It’s a recycled ESP32-C6 🙂