6.0 KiB
ADR-122 — HLK-LD2402 Engineering Mode (per-gate energies, mmWave HR)
Status: Accepted.
Date: 2026-05-18
Scope: v2/crates/wifi-densepose-sensing-server/src/mmwave.rs
(extended), v2/scripts/probe_ld2402_engineering.py (new),
ui/raw.html (pills updated). Builds on ADR-121.
Context
ADR-121 wired up the HLK-LD2402 in Normal Mode — ASCII
distance:<cm>\r\n lines at ~6 Hz. That gave us a live distance pill
and a passable breathing estimate (chest movement modulates range by
5–10 mm, visible as cm-bin flicker), but no heart rate — cardiac
chest displacement (~0.3–0.5 mm) is well below the cm quantisation
step, so heartbeat is invisible in the distance time-series.
The module supports a richer Engineering Mode that streams per- range-gate amplitude at the same cadence. ESPHome's driver and the Hi-Link command spec gave enough information to reverse-engineer it.
Decisions
D1 — Switch into Engineering Mode at startup
On spawn_reader the host sends three commands in sequence:
0x00FF— enable config mode (payload01 00)0x0012— set work mode (payload00 00 04 00 00 00, mode = 0x04)0x00FE— disable config mode → data stream resumes (now binary)
Each command frame is FD FC FB FA <len LE> <cmd LE> <data...> 04 03 02 01.
If any step errors out we log a warning and fall back to the
ADR-121 ASCII Normal-Mode parser, so distance keeps working even when
the radar is in an inconsistent state.
D2 — Binary frame layout
Engineering frames at ~6 Hz, 141 bytes total:
F4 F3 F2 F1 data header
LL LL payload length (LE u16, = 131)
01 frame type (engineering)
DD DD distance in cm (LE u16)
00 × 8 reserved
<15 × u32 LE> motion-gate energies (gate 0 = 0–0.7 m, …)
<15 × u32 LE> micromotion-gate energies (same range bins)
F8 F7 F6 F5 footer
The ESPHome project documents 0x84 as the engineering type byte,
but our firmware emits 0x01 — likely a firmware revision difference.
The parser only accepts 0x01 for now.
D3 — Don't mix ASCII and binary drains in one loop
First-cut implementation ran both the binary frame parser and the
ASCII line drain unconditionally. Since the binary payload contains
arbitrary bytes (including 0x0A), the ASCII drain destroyed ~80 %
of partial frames mid-buffer, dropping the effective parse rate to
1.3 Hz. The fix is to track an engineering_mode flag set after the
enable sequence succeeds; ASCII drain only runs in the fallback path.
After this fix, frame parsing matches the raw byte rate exactly (~6.1 Hz observed live).
D4 — Target-gate selection for HR extraction
The micromotion-gate at the target's range is where the cardiac signal lives — that's the bin whose backscatter is modulated by chest-wall displacement. Three-tier selection:
- Distance-based — bracket
distance_cminto a 0.7 m gate; - Mid-range micro-peak — if that gate's micro energy is zero
(stale distance, wrong guess), pick the strongest micro gate in
[1, 14). Gate 0 is dominated by near-field clutter; the last gate is usually empty. - Default — gate 1 if all else fails (most common seated-operator torso distance).
D5 — HR via bandpass + FFT on micro-gate log-energy history
Per-gate micromotion energies are pushed into 30-s ring buffers
(180 samples at 6 Hz). We log-compress (ln(energy + 1)) to suppress
the dynamic range of the raw u32, then run a Hann-windowed bandpass
(0.8–2.0 Hz = 48–120 BPM) + radix-2 FFT peak search on the target-
gate buffer. Confidence is the peak-to-band-mean ratio, mapped to
[0,1] the same way as ADR-021's VitalSignDetector.
Breathing keeps using the distance time-series via the shared detector — that signal is strong enough not to need per-gate selection.
D6 — Diagnostic probe script kept in tree
v2/scripts/probe_ld2402_engineering.py does the same enable
sequence and dumps the first N frames as annotated hex. Useful for
verifying the wire format on new firmware revisions, and would have
saved an hour during this ADR's development if it had existed first.
Verified Acceptance
Live with the module attached, target ~1.5 m away (seated):
$ curl :8080/api/v1/mmwave/vitals
{"available":true,
"vital_signs": {
"breathing_rate_bpm": 13.06,
"breathing_confidence": 0.37,
"heart_rate_bpm": 75.93,
"heartbeat_confidence": 0.63
},
"buffer_status": {"breathing_capacity":180,"breathing_samples":180}}
Server logs:
ADR-121 mmWave reader: opened /dev/cu.usbserial-1140 @ 115200
ADR-122 mmWave: Engineering Mode enabled (per-gate energies @ 6 Hz)
In the UI, both pills now show two values:
🫁 📶 — BPM · | 📡 13.0 BPM · 37% норма 12–20
💓 📶 — BPM · | 📡 76 BPM · 63% норма 60–100
Out of Scope / Follow-ups
-
Calibration against ground-truth pulse. The 75 BPM value agrees with the seated operator's actual rest pulse to within ±5 BPM but hasn't been benchmarked against a chest-strap monitor across multiple subjects or activity levels.
-
Multi-target handling. The current target-gate selector picks one gate. With two people in the field the algorithm picks whichever has the stronger backscatter; the other person's HR is lost.
-
Engineering Mode is sticky. After this server runs, the module remains in engineering mode until something explicitly switches it back. The next ADR-121 ASCII consumer (an external tool) would receive binary garbage. Add a clean-shutdown step that issues
set_mode(0x64)if we ever wire up signal handling. -
Fusion with WiFi-CSI. Now we have HR from two independent modalities — weighted-average or disagreement-flag could improve reliability. Probably ADR-123.
References
- ADR-021 — WiFi-CSI vital signs detector (shared FFT/bandpass).
- ADR-121 — HLK-LD2402 Normal-Mode integration.
- ESPHome HLK-LD2402 driver (
Mc-Joung/hlk_ld2402_esphome) — main reference for the command opcodes and frame envelope.