# ADR-121 — HLK-LD2402 24 GHz mmWave Radar (auxiliary modality) **Status**: Accepted (single-modality readout). Fusion deferred. **Date**: 2026-05-18 **Scope**: `v2/crates/wifi-densepose-sensing-server/src/mmwave.rs` (new), `Cargo.toml` (serialport dep), `main.rs` (CLI flags `--mmwave-port` / `--mmwave-baud`, spawn reader, `mmwave_latest` REST handler, route), `ui/components/SensingTab.js` (new card, poll integration). ## Context The operator has an HLK-LD2402 24 GHz mmWave radar module attached via a CP2102 USB-to-UART bridge. Factory firmware emits ASCII `distance:\r\n` lines at 115200 8N1, ~6 Hz, in Normal Mode. This module is a useful **auxiliary modality**: sub-mm range to a moving target, very different physical principle than WiFi CSI, runs fully independent. Concrete uses: 1. **Live readout in the UI** — easiest. Operator sees the radar distance alongside the WiFi sensing. 2. **Vitals ground-truth** — at 6 Hz the data is too slow for HR but captures breathing rate (0.2-0.4 Hz). Compare against the WiFi-CSI vitals detector (ADR-021) for calibration. 3. **Multi-modal fusion** — feed the mmWave distance + WiFi features into a future classifier. Different physics, very different confusion set — high-value addition. This ADR ships #1 only. #2 and #3 are follow-ups. ## Decisions ### D1 — Dedicated blocking reader thread, not async `serialport` is a sync API. Wrapping it with `tokio::spawn_blocking` adds overhead for a single-port reader running indefinitely. A plain `std::thread` named `mmwave-reader` reads the port, parses lines, and writes the latest reading into a global `OnceLock>>`. ### D2 — Graceful absence `--mmwave-port` is **optional**. When unset, the server runs as before. When set but the port can't be opened, the reader thread logs a single warning and exits — server keeps running with WiFi sensing only. No retries, no panics. (Operator can hot-plug; if auto-reconnect is wanted we add it later.) ### D3 — Stale-after policy `mmwave::current(staleness)` returns `None` if the most recent reading is older than `staleness`. The REST endpoint uses 2 seconds — at the module's 6 Hz cadence, 2 s = ~12 missed frames, plenty of slack for a brief USB hiccup but tight enough to flag a dead module. ### D4 — Single new REST endpoint, no SensingUpdate change `GET /api/v1/mmwave/latest` returns: ```json { "available": true, "distance_cm": 152, "age_ms": 90 } ``` or ```json { "available": false } ``` Not embedded in `SensingUpdate` because: * The WS stream is already busy with per-tick CSI broadcasts; a separate poll lets the UI throttle the mmWave refresh independently (saves bandwidth if many clients connect). * Keeps the SensingUpdate schema stable — older WS consumers don't need a migration. UI polls the endpoint once per visible WS tick. ~5-10 Hz refresh. ### D5 — UI badge in `SensingTab`, hidden when unavailable New card "mmWave Radar (24 GHz)" with a blue badge showing ` cm` and an age bar (100 % at 0 ms → 0 % at 2 s). The whole card hides via `display: none` when the endpoint reports `available: false`, so deployments without the radar see no clutter. ### D6 — Parse only the `distance:` Normal Mode format HLK-LD2402 also has an "Engineering Mode" emitting binary frames with per-range-gate energy. Out of scope for v1 — Normal Mode covers the live-readout use case. Engineering Mode parsing is a separate ADR if/when we need per-gate data for vitals fusion. ## Files Touched ``` v2/crates/wifi-densepose-sensing-server/Cargo.toml + serialport.workspace = true v2/crates/wifi-densepose-sensing-server/src/mmwave.rs (new, ~130 LoC) + pub struct MmwaveReading { distance_cm: u32, at: Instant } + static LATEST: OnceLock>> + pub fn current(staleness) -> Option + pub fn spawn_reader(port, baud) + fn parse_distance(line: &str) -> Option + 1 unit test v2/crates/wifi-densepose-sensing-server/src/lib.rs + pub mod mmwave; v2/crates/wifi-densepose-sensing-server/src/main.rs + Args { mmwave_port, mmwave_baud } + spawn_reader call in main() + async fn mmwave_latest + route /api/v1/mmwave/latest ui/components/SensingTab.js + #mmwaveCard hidden-by-default card with #mmwaveLabel + age bar + fetch /api/v1/mmwave/latest each visible tick, show/hide card docs/adr/ADR-121-mmwave-hlk-ld2402.md (this) ``` ## Verified Acceptance Live with the module attached: ``` $ ./target/release/sensing-server --mmwave-port /dev/cu.usbserial-1140 … ADR-121 mmWave reader: opened /dev/cu.usbserial-1140 @ 115200 $ curl :8080/api/v1/mmwave/latest {"age_ms":55,"available":true,"distance_cm":149} {"age_ms":90,"available":true,"distance_cm":152} {"age_ms":127,"available":true,"distance_cm":153} ``` Live without module attached (port arg omitted): server starts cleanly, endpoint returns `{"available": false}`, Sensing tab card hidden. ## Out of Scope / Follow-ups * **Engineering Mode binary parser** — needed if we want per-gate energy for vitals (breathing band) or person-counting from per-gate occupancy. * **Vitals fusion (ADR-021 cross-check)** — log mmWave breathing rate side-by-side with WiFi-CSI vitals for 5 min, compute Pearson correlation, decide whether to weight one over the other in the final vitals output. * **W-MLP feature input** — once vitals fusion proves out, expose mmWave distance as a 23rd feature in the W-MLP and retrain. Would warrant ADR-122. * **Auto-reconnect** — current behaviour: open fails or read errors exit the reader thread. Add a retry loop with 2-second backoff if the operator wants USB hot-plug recovery. ## References * ADR-021 — WiFi-CSI vitals detector (the candidate cross-check partner for HLK-LD2402 breathing-rate output). * `assets/sensors/sensor_03.jpeg` / `_04.jpeg` / `_05.jpeg` — hardware photos and inventory entry for the module + CP2102 bridge.