160 lines
6.0 KiB
Markdown
160 lines
6.0 KiB
Markdown
# 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:
|
||
|
||
1. `0x00FF` — enable config mode (payload `01 00`)
|
||
2. `0x0012` — set work mode (payload `00 00 04 00 00 00`, mode = 0x04)
|
||
3. `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:
|
||
|
||
```text
|
||
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:
|
||
|
||
1. **Distance-based** — bracket `distance_cm` into a 0.7 m gate;
|
||
2. **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.
|
||
3. **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.
|