wifi-densepose/docs/adr/ADR-122-mmwave-engineering-...

6.0 KiB
Raw Blame History

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 510 mm, visible as cm-bin flicker), but no heart rate — cardiac chest displacement (~0.30.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:

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 = 00.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.82.0 Hz = 48120 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%      норма 1220
💓 📶 — BPM ·  | 📡 76 BPM · 63%        норма 60100

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.