From cb6e24ed577c887989340e6216888bb8e528d50d Mon Sep 17 00:00:00 2001 From: arsen Date: Mon, 18 May 2026 11:27:28 +0700 Subject: [PATCH] feat(adr-121): HLK-LD2402 mmWave radar live readout in UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a dedicated blocking serial-reader thread that opens the HLK-LD2402 over a CP2102 USB-UART bridge (default 115200 8N1), parses ASCII `distance:\r\n` lines @ ~6 Hz, stores the latest reading in a static OnceLock>, and exposes it via: GET /api/v1/mmwave/latest → { "available": true, "distance_cm": 152, "age_ms": 90 } { "available": false } (port absent, stale > 2 s) UI (Sensing tab) polls the endpoint every visible WS tick and shows a new blue card "mmWave Radar (24 GHz)" with distance + age bar. Card hides when unavailable. CLI: --mmwave-port /dev/cu.usbserial-1140 --mmwave-baud 115200 (default) Both optional — server runs as before if the module is absent. Open failure: single WARN log, reader thread exits, server keeps serving WiFi sensing. Verified live: distance 149-153 cm at ~6 Hz, REST returns fresh readings with age_ms 55-127. Out of scope (logged in ADR-121): Engineering Mode binary frames, vitals cross-check vs ADR-021, W-MLP feature fusion, auto-reconnect. Co-Authored-By: Claude Opus 4.7 --- docs/adr/ADR-121-mmwave-hlk-ld2402.md | 159 ++++++++++++++++++ ui/components/SensingTab.js | 42 +++++ v2/Cargo.lock | 1 + .../wifi-densepose-sensing-server/Cargo.toml | 4 + .../wifi-densepose-sensing-server/src/lib.rs | 2 + .../wifi-densepose-sensing-server/src/main.rs | 37 ++++ .../src/mmwave.rs | 116 +++++++++++++ 7 files changed, 361 insertions(+) create mode 100644 docs/adr/ADR-121-mmwave-hlk-ld2402.md create mode 100644 v2/crates/wifi-densepose-sensing-server/src/mmwave.rs diff --git a/docs/adr/ADR-121-mmwave-hlk-ld2402.md b/docs/adr/ADR-121-mmwave-hlk-ld2402.md new file mode 100644 index 00000000..050e3dc0 --- /dev/null +++ b/docs/adr/ADR-121-mmwave-hlk-ld2402.md @@ -0,0 +1,159 @@ +# 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. diff --git a/ui/components/SensingTab.js b/ui/components/SensingTab.js index 33387eef..c2b5c015 100644 --- a/ui/components/SensingTab.js +++ b/ui/components/SensingTab.js @@ -105,6 +105,19 @@ export class SensingTab { + + +
About This Data
@@ -268,6 +281,35 @@ export class SensingTab { const confPct = ((c.confidence || 0) * 100).toFixed(0); this._setBar('barConfidence', c.confidence, 1.0, 'valConfidence', confPct + '%'); + // ADR-121: poll mmWave radar in parallel with the WS-driven update. + // Kick once per visible update; skip if already in flight. + if (!this._mmwaveBusy) { + this._mmwaveBusy = true; + fetch('/api/v1/mmwave/latest') + .then(r => r.json()) + .then(j => { + const card = this.container.querySelector('#mmwaveCard'); + if (!card) { this._mmwaveBusy = false; return; } + if (j && j.available) { + card.style.display = ''; + const lbl = this.container.querySelector('#mmwaveLabel'); + if (lbl) lbl.textContent = j.distance_cm + ' cm'; + const age = this.container.querySelector('#mmwaveAge'); + if (age) age.textContent = (j.age_ms || 0) + ' ms'; + const bar = this.container.querySelector('#mmwaveAgeBar'); + if (bar) { + // Age 0..2000 ms → 100..0% width (fresher = fuller bar). + const pct = Math.max(0, 100 - (j.age_ms || 0) / 20); + bar.style.width = pct + '%'; + } + } else { + card.style.display = 'none'; + } + }) + .catch(() => { /* server down or no port — silently hide */ }) + .finally(() => { this._mmwaveBusy = false; }); + } + // Details this._setText('valDomFreq', (f.dominant_freq_hz || 0).toFixed(3) + ' Hz'); this._setText('valChangePoints', String(f.change_points || 0)); diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 012f5d90..a94b3e2e 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -8550,6 +8550,7 @@ dependencies = [ "ruvector-mincut", "serde", "serde_json", + "serialport", "tempfile", "tokio", "tower 0.4.13", diff --git a/v2/crates/wifi-densepose-sensing-server/Cargo.toml b/v2/crates/wifi-densepose-sensing-server/Cargo.toml index 21a02c68..f0087cdf 100644 --- a/v2/crates/wifi-densepose-sensing-server/Cargo.toml +++ b/v2/crates/wifi-densepose-sensing-server/Cargo.toml @@ -56,6 +56,10 @@ wifi-densepose-signal = { version = "0.3.0", path = "../wifi-densepose-signal", midstreamer-temporal-compare = "0.2" # DTW / LCS / Edit-Distance pattern matching midstreamer-attractor = "0.2" # Lyapunov + regime classification +# ADR-121: HLK-LD2402 24 GHz mmWave radar over UART (auxiliary vitals/ +# range modality). Optional — server runs fine without the module attached. +serialport.workspace = true + [dev-dependencies] tempfile = "3.10" # `tower::ServiceExt::oneshot` for in-process Router tests (bearer_auth). diff --git a/v2/crates/wifi-densepose-sensing-server/src/lib.rs b/v2/crates/wifi-densepose-sensing-server/src/lib.rs index f8c2a8f9..d6884bab 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/lib.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/lib.rs @@ -21,3 +21,5 @@ pub mod sparse_inference; pub mod embedding; /// ADR-116: WiFlow-v1 supervised pose model loader + Rust forward pass. pub mod wiflow_v1; +/// ADR-121: HLK-LD2402 24 GHz mmWave radar reader (auxiliary modality). +pub mod mmwave; diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index a9d6baef..6601c48c 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -1174,6 +1174,20 @@ struct Args { /// Independent from `--model` (RVF container) and `--load-rvf`. #[arg(long, value_name = "PATH")] wiflow_model: Option, + + /// ADR-121: Path to HLK-LD2402 24 GHz mmWave radar UART (via + /// CP2102 USB bridge). Example: `/dev/cu.usbserial-1140` (macOS) + /// or `/dev/ttyUSB0` (Linux). When set, the server reads + /// `distance:` lines at 6 Hz and surfaces them on the + /// `mmwave` field of every SensingUpdate plus the + /// `/api/v1/mmwave/latest` REST endpoint. Missing port = no + /// mmWave, server still runs. + #[arg(long, value_name = "PATH")] + mmwave_port: Option, + + /// ADR-121: HLK-LD2402 baud rate. Factory default is 115200 8N1. + #[arg(long, default_value = "115200")] + mmwave_baud: u32, } /// ADR-116: globally-shared WiFlow-v1 model. Loaded once at startup if @@ -5069,6 +5083,23 @@ async fn adaptive_debug() -> Json { })) } +/// ADR-121: GET /api/v1/mmwave/latest — latest HLK-LD2402 reading or +/// `{ available: false }` when the reader thread isn't running OR the +/// most recent reading is stale (>2 seconds old). +async fn mmwave_latest() -> Json { + use wifi_densepose_sensing_server::mmwave; + match mmwave::current(std::time::Duration::from_secs(2)) { + Some(r) => Json(serde_json::json!({ + "available": true, + "distance_cm": r.distance_cm, + "age_ms": r.at.elapsed().as_millis() as u64, + })), + None => Json(serde_json::json!({ + "available": false, + })), + } +} + /// POST /api/v1/adaptive/unload — unload the adaptive model (revert to thresholds). async fn adaptive_unload(State(state): State) -> Json { let mut s = state.write().await; @@ -7364,6 +7395,11 @@ async fn main() { } }; + // ADR-121: spawn the HLK-LD2402 mmWave reader thread when --mmwave-port is set. + if let Some(port) = args.mmwave_port.clone() { + wifi_densepose_sensing_server::mmwave::spawn_reader(port, args.mmwave_baud); + } + // Load trained model via --model (uses progressive loading if --progressive set) let model_path = args.model.as_ref().or(args.load_rvf.as_ref()); let mut progressive_loader: Option = None; @@ -7648,6 +7684,7 @@ async fn main() { .route("/api/v1/adaptive/train", post(adaptive_train)) .route("/api/v1/adaptive/status", get(adaptive_status)) .route("/api/v1/adaptive/debug", get(adaptive_debug)) + .route("/api/v1/mmwave/latest", get(mmwave_latest)) .route("/api/v1/adaptive/unload", post(adaptive_unload)) // Field model calibration (eigenvalue-based person counting) .route("/api/v1/calibration/start", post(calibration_start)) diff --git a/v2/crates/wifi-densepose-sensing-server/src/mmwave.rs b/v2/crates/wifi-densepose-sensing-server/src/mmwave.rs new file mode 100644 index 00000000..4e1d614e --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/mmwave.rs @@ -0,0 +1,116 @@ +//! ADR-121: HLK-LD2402 24 GHz mmWave radar reader. +//! +//! Auxiliary range/vitals modality, attached over a CP2102 USB-UART +//! bridge. The module ships factory firmware that emits ASCII +//! `distance:\r\n` lines @ 115200 baud, ~6 Hz, in Normal Mode. +//! +//! This reader runs in a dedicated thread (blocking serial I/O is +//! awkward inside tokio) and pushes the latest reading + monotonic +//! timestamp into a global `OnceLock>` that the broadcast +//! tick task reads. +//! +//! Cold-start tolerance: if the port cannot be opened, the thread +//! logs once and exits cleanly — the server keeps running with WiFi +//! sensing only. No panics, no retries (operator can hot-plug; if +//! they want auto-reconnect we can add it later). + +use std::io::Read; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +/// Latest mmWave reading + when it landed. +#[derive(Debug, Clone, Copy)] +pub struct MmwaveReading { + pub distance_cm: u32, + pub at: Instant, +} + +static LATEST: OnceLock>> = OnceLock::new(); + +fn latest() -> &'static Mutex> { + LATEST.get_or_init(|| Mutex::new(None)) +} + +/// Returns the most recent reading if it landed within `staleness`. +pub fn current(staleness: Duration) -> Option { + let g = latest().lock().unwrap(); + let r = (*g)?; + if r.at.elapsed() <= staleness { Some(r) } else { None } +} + +/// Spawn the blocking serial reader thread. Returns immediately. +/// `port` example: `/dev/cu.usbserial-1140` (macOS) or `/dev/ttyUSB0` +/// (Linux). `baud` should be 115200 for HLK-LD2402 default firmware. +pub fn spawn_reader(port: String, baud: u32) { + std::thread::Builder::new() + .name("mmwave-reader".into()) + .spawn(move || run(port, baud)) + .expect("failed to spawn mmwave-reader thread"); +} + +fn run(port: String, baud: u32) { + let mut serial = match serialport::new(&port, baud) + .timeout(Duration::from_millis(500)) + .open() + { + Ok(s) => { + tracing::info!("ADR-121 mmWave reader: opened {port} @ {baud}"); + s + } + Err(e) => { + tracing::warn!("ADR-121 mmWave reader: cannot open {port} @ {baud}: {e}"); + return; + } + }; + + let mut buf = Vec::with_capacity(256); + let mut tmp = [0u8; 128]; + loop { + match serial.read(&mut tmp) { + Ok(0) => continue, + Ok(n) => buf.extend_from_slice(&tmp[..n]), + Err(e) => { + if e.kind() == std::io::ErrorKind::TimedOut { continue; } + tracing::warn!("ADR-121 mmWave reader: read error: {e}"); + return; + } + } + // Drain complete lines. + while let Some(pos) = buf.iter().position(|&b| b == b'\n') { + let raw_line: Vec = buf.drain(..=pos).collect(); + let line = String::from_utf8_lossy(&raw_line).trim().to_string(); + if let Some(cm) = parse_distance(&line) { + *latest().lock().unwrap() = Some(MmwaveReading { + distance_cm: cm, + at: Instant::now(), + }); + } else if !line.is_empty() { + tracing::trace!("mmwave non-distance line: {line:?}"); + } + } + // Guard against runaway buffer if module emits non-newline garbage. + if buf.len() > 1024 { buf.clear(); } + } +} + +/// Parse `distance:` (HLK-LD2402 Normal Mode line format). +pub fn parse_distance(line: &str) -> Option { + let lower = line.trim().to_ascii_lowercase(); + let rest = lower.strip_prefix("distance:")?; + rest.parse::().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_distance_lines() { + assert_eq!(parse_distance("distance:228"), Some(228)); + assert_eq!(parse_distance("Distance:0"), Some(0)); + assert_eq!(parse_distance(" distance:42 "), Some(42)); + assert_eq!(parse_distance("OFF"), None); + assert_eq!(parse_distance(""), None); + assert_eq!(parse_distance("distance:abc"), None); + } +}