5.9 KiB
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:<cm>\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:
- Live readout in the UI — easiest. Operator sees the radar distance alongside the WiFi sensing.
- 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.
- 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<Mutex<Option<MmwaveReading>>>.
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:
{ "available": true, "distance_cm": 152, "age_ms": 90 }
or
{ "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
<distance> 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:<n> 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<Mutex<Option<MmwaveReading>>>
+ pub fn current(staleness) -> Option<MmwaveReading>
+ pub fn spawn_reader(port, baud)
+ fn parse_distance(line: &str) -> Option<u32>
+ 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.