Arduino / PlatformIO driver for the Hi-Link HLK-LD2402, a 24GHz mmWave radar that detects moving, micro-moving (breathing-level stillness) and static human presence, with distance and per-gate signal strength.
Full control, not just an on/off pin: presence, distance, all 32 energy gates, max-distance and per-gate threshold configuration, auto-calibration, auto-gain, and saving settings to the sensor's own flash — everything the vendor's PC tool and the third-party Home Assistant/ESPHome components expose, as a plain library you can drop into any sketch.
Self-contained: the only dependency is Arduino's Stream interface, so it
works on hardware UART, a second hardware UART (ESP32's Serial1/Serial2),
or SoftwareSerial.
The sensor also has a plain presence-out pin (HIGH/LOW). If that's all you need, wire that pin to any GPIO and skip this library entirely. This library is for when you want the rest of what the sensor can do — distance, signal-strength-by-gate, remote calibration — which only exists over UART.
| Pin (J2) | Function |
|---|---|
| V | Power, 3.0–3.6V typical (check your board — see below) |
| IO | Presence out, HIGH/LOW (unused by this library — wire T/R instead) |
| G | Ground |
| T | UART TX (sensor → your board's RX) |
| R | UART RX (your board's TX → sensor) |
- Fixed baud rate: 115200, 8N1. Not configurable on the sensor side.
- Give it a supply that can source ~50mA average, more on peaks. A weak regulator (e.g. some USB-serial adapters' onboard 3.3V rail) can make an otherwise-fine module look dead.
- The datasheet lists an optional 4.5–5.5V input via an add-on LDO, but notes it as "default not posted" — i.e. not fitted at the factory. Assume 3.3V-only unless you've confirmed your specific board has that LDO.
- Download this repo as a ZIP: green Code button on the GitHub page → Download ZIP. (Don't unzip it — the IDE wants the .zip file itself.)
- In the Arduino IDE: Sketch → Include Library → Add .ZIP Library…, pick the file you just downloaded.
- That's it — the library (and its examples) now show up like any other installed library.
- Try it: File → Examples → LD2402 → BasicPresence, wire the sensor per the table above (T→your board's RX, R→your board's TX, plus power and ground), pick your board under Tools → Board, and upload.
- Open Tools → Serial Monitor (115200 baud) to see presence/distance printed live.
If you'd rather install it once for every sketch instead of per-project:
clone or download this repo into your Arduino libraries folder directly
(File → Preferences shows the "Sketchbook location" — the library goes in
a libraries subfolder there), then restart the IDE.
Add to platformio.ini:
lib_deps = https://github.com/g0urav2410/LD2402.git#include <LD2402.h>
LD2402 radar;
void setup() {
Serial.begin(115200);
radar.begin(Serial);
}
void loop() {
radar.loop();
if (radar.presence()) {
Serial.println(radar.distanceCm());
}
}See examples/BasicPresence for the full version, examples/FullControl for
engineering mode + calibration + all 32 energy gates, and
examples/NonBlockingUI for keeping a display or animation running during the
library's slow configuration calls.
radar.loop()— the sensor has the UART to itself. Call this everyloop()iteration and read the live getters below. Simplest option, and strongly recommended (see the warning).radar.feedByte(uint8_t)— feed the parser one byte at a time yourself.radar.midFrame()reports whether a binary engineering frame is in progress. Only reach for this if you have a specific reason to touch each byte first.
⚠️ Give the sensor its own UART. Don't share it with a log/debug console. Two hard-won lessons from real use, both worth avoiding:
- Your debug output on the shared TX goes into the sensor's RX and interferes with it accepting commands. This is why the ESPHome component tells you to set
baud_rate: 0(disable UART logging). Keep TX quiet.- The sensor's binary energy bytes will masquerade as console input. An engineering frame's body is arbitrary bytes — feed those to a command-interpreting console and a stray
0x78,'x', or a digit gets executed as a command. If you must share the wire, route by frame structure and treat every non-frame byte as noise to discard — never as a command. Simplest is to not share it at all.
| Call | Returns |
|---|---|
read() |
Reading{presence, moving, still, connected, distanceCm} — everything below, in one call |
presence() |
bool — someone detected (moving or still) |
isMoving() / isStill() |
bool — which kind of presence |
distanceCm() |
uint16_t — distance to the target |
haveEnergyGates() |
bool — true once an engineering frame has arrived |
triggerEnergyDb(gate) / motionlessEnergyDb(gate) |
float dB, gate 0–15, near → far |
connected() |
bool — data received in the last 2s |
lastUpdateMs() |
unsigned long — millis() of the last reading |
| Call | Effect |
|---|---|
setEngineeringMode(bool on) |
Preferred — one call, manages its own config session. false = plain "OFF"/"distance : NN" text (factory default). true = binary frames with distance + all 32 energy gates. |
setOutputMode(bool engineering) |
Raw version — you wrap enableConfig()/endConfig() yourself. Only reach for this inside a batch of other config calls. |
These set the value and commit it to the sensor's own flash, each managing its own config session. This is what you want almost all the time — a value set without saving reverts the moment the sensor loses power.
| Call | Sets |
|---|---|
setAndSaveMaxDistanceMeters(float) |
Max detection range, 0.7–10.0m |
setAndSaveDisappearDelaySec(uint16_t) |
How long presence is held after the target leaves |
setAndSaveTriggerThresholdDb(gate, db) |
One gate's trigger threshold, gate 0–15 |
setAndSaveMotionlessThresholdDb(gate, db) |
One gate's motionless/still threshold, gate 0–15 — automatically routes gate 15 through its known flash-persistence quirk, no special handling needed from you |
saveAllThresholds(motionDb[16], microDb[16]) |
All 32 thresholds at once, in one efficient session (pass nullptr for either array to skip it) — use this instead of 16 individual calls when applying a full set |
Reach for these only when you're batching several changes in one session for
efficiency (like saveAllThresholds() does internally), or reading a current
value. For a single change, the setAndSave* calls above are simpler.
| Call | Notes |
|---|---|
enableConfig() / endConfig() |
Required bracket around every call below. Retries internally on entry. |
readFirmwareVersion(String&) / readSerialNumber(String&) |
|
setMaxDistanceMeters(float) / readMaxDistanceMeters(float&) |
0.7–10.0m, live only |
setDisappearDelaySec(uint16_t) / readDisappearDelaySec(uint16_t&) |
Live only |
setTriggerThresholdDb(gate, db) / readTriggerThresholdDb(gate, db&) |
gate 0–15, live only |
setMotionlessThresholdDb(gate, db) / readMotionlessThresholdDb(gate, db&) |
gate 0–15, live only |
readPowerInterference(uint8_t&) |
0 not run, 1 clear, 2 interference detected |
startCalibration(trigger, hold, micro) |
Auto-generates thresholds for the room. Factors default 3. |
calibrationProgress(uint8_t&) |
0–100, poll until 100 |
startAutoGain() / autoGainDone(timeoutMs) |
Corrects a saturated front-end. autoGainDone waits for the sensor's own completion push — it isn't a normal ACK. |
saveParameters() |
Commits whatever's currently set to the sensor's own flash. Prefer setAndSave* above for a single value. |
readParameterRaw(id, value&) / setParameterRaw(id, value) |
Escape hatch for any parameter ID not wrapped above |
onIdle(fn) |
Runs fn while any of the above is waiting on the module, so your display/UI doesn't freeze — see below |
Every blocking call takes an optional timeoutMs (default 1000ms, longer for
autoGainDone).
Every configuration call above blocks: it sends a command and waits for the module to reply. On a single-threaded board — any AVR, an ESP8266, a single-task ESP32 sketch — nothing else in your sketch runs during that wait. A display freezes, an animation stops, a button goes unread.
onIdle() hands that time back. Give it a function, and the driver calls it
repeatedly wherever it would otherwise just be waiting.
void keepUiAlive() {
static unsigned long last = 0;
if (millis() - last < 1000) return; // <- the important line
last = millis();
redrawDisplay();
}
void setup() {
radar.begin(Serial);
radar.onIdle(keepUiAlive); // that's the whole integration
}That's it. One call, once, and every slow operation — reading settings, saving thresholds, calibration, auto-gain — stops freezing your sketch.
Run examples/NonBlockingUI to see it: it blinks the built-in LED while
reading all 32 thresholds, and n toggles the hook on and off so you can watch
the LED freeze and unfreeze.
It is called about 8,000 times a second. That is not an exaggeration — while waiting for a reply the driver spins in a tight loop, and your function runs on every pass.
So the first line must be a cheap test that usually returns immediately. The
if (millis() - last < interval) return; shape above is the standard way; in a
measured run of a bulk save the callback was entered 50,012 times and did real
work 12 times. Everything else was that early return.
Get this wrong and you make things worse: a callback costing 1ms, called 8,000 times, adds 8 seconds to a 6-second operation.
Two rules:
- Keep it cheap. Early-out first, work second.
- Never call
radar.*from inside it. A command/response exchange is in progress the entire time; re-entering the driver will corrupt it.
Safe things to do: refresh a display, toggle a pin, feed a hardware watchdog, step an animation. Unsafe: anything that talks to this sensor, anything that blocks, anything doing heavy I/O.
Worth knowing if you're wondering whether it covers the case you care about.
Measured on a real HLK-LD2402, a saveAllThresholds() totalling 6.2s:
| Phase | Time |
|---|---|
| The 31 threshold writes | ~0.6s (19ms each) |
| Flash commit, config mode opening and closing | ~5.6s |
The writes are under 10% of it. So the hook isn't called "between writes" — it
sits at the driver's actual wait points, including the fixed settle delays,
which is why a single onIdle() covers every blocking call in the library
rather than one of them.
onIdle() is entirely optional. Without it, the driver behaves exactly as it
always has — the internal wait is a plain yield(), unchanged. Nothing about
the protocol, the timing, or any other call is affected by adding or omitting
it, so upgrading is safe.
You can enter config mode (to read/change settings, calibrate) while the
sensor is mid engineering-stream — but the enable command's ACK is easily lost
in that ~775 byte/s flood. enableConfig() is therefore deadline-based:
give it a generous timeout (e.g. enableConfig(2500)) and it re-sends the
enable request until it breaks in — the moment the sensor accepts it, the
stream stops and the ACK comes back cleanly. endConfig() likewise retries,
so a missed exit can't strand the sensor silent in config mode. After you exit,
streaming resumes on its own. Verified reading/writing settings and running
calibration live, without dropping engineering data for more than the config
window.
The sensor divides range into fixed ~0.7 m distance slices called gates, gate 0 nearest. An engineering frame reports, per gate, the reflected signal strength — and each gate has two independent detection thresholds:
- motion gate/threshold — moving targets at that distance (walking)
- micro-motion gate/threshold — still targets (sitting, breathing)
Detection at a gate fires when its energy crosses its threshold. Tuning per-gate thresholds lets you, e.g., desensitise far gates so movement behind a wall stops triggering, or raise micro sensitivity at the one distance where someone actually sits.
Reverse-assembled from the official HLK-LD2402 user manual (v1.08); Hi-Link does not publish a separate protocol PDF. A few things the manual doesn't say outright, found by testing:
- Engineering data only streams after
endConfig()— enabling it while still in config mode has no visible effect. - Energy gates: the 128-byte body in an engineering frame is 32×4-byte
values — 16 motion gates followed by 16 micro-motion gates, matching the
16 threshold IDs of each (
0x0010–0x001Fand0x0030–0x003F). - dB conversion both ways:
dB = 10·log10(raw),raw = 10^(dB/10). - ACK frames echo the command word with
+0x0100set. Auto-gain's completion report is the one exception — it arrives unprompted, carrying the bare word0x00F0, not an ACK to something you sent. - Gate 15's micro threshold (
0x003F) doesn't persist via the normal set-then-saveParameters()sequence — confirmed on firmware v3.3.5, deterministic, 100% reproducible. The write itself succeeds and applies live; it's specifically the flash-commit step that silently fails whenever0x003Fwas touched, while every other parameter (including gate 15's own motion threshold,0x001F) saves and persists normally. Workaround that's been verified across a real power cycle:enableConfig()→ set the value → wait ~200ms → read it back → set it again →endConfig(), without callingsaveParameters()at all. SeeLD2402::persistGate15MotionlessThresholdDb()for the implementation. Hi-Link support confirmed this workaround but couldn't explain why it's needed — their explanation didn't match the documented protocol, so treat this as empirically verified rather than fully understood.
- Clockwise — the smart-clock project this library was built for; see its firmware for a real shared-UART integration, plus an HTTP API and Flutter app built on top of this driver's data.
MIT — see LICENSE.