feat(adr-121): HLK-LD2402 mmWave radar live readout in UI

Adds a dedicated blocking serial-reader thread that opens the
HLK-LD2402 over a CP2102 USB-UART bridge (default 115200 8N1),
parses ASCII `distance:<cm>\r\n` lines @ ~6 Hz, stores the latest
reading in a static OnceLock<Mutex<…>>, 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 <noreply@anthropic.com>
This commit is contained in:
arsen 2026-05-18 11:27:28 +07:00
parent 831602b584
commit cb6e24ed57
7 changed files with 361 additions and 0 deletions

View File

@ -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:<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:
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<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:
```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
`<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.

View File

@ -105,6 +105,19 @@ export class SensingTab {
</div>
</div>
<!-- ADR-121: mmWave radar (HLK-LD2402) auxiliary range modality -->
<div class="sensing-card" id="mmwaveCard" style="display:none;">
<div class="sensing-card-title">mmWave Radar (24 GHz)</div>
<div class="sensing-classification">
<div class="sensing-class-label" id="mmwaveLabel" style="background:rgba(33,150,243,0.15);color:rgb(33,150,243);"> cm</div>
<div class="sensing-confidence">
<label>Age</label>
<div class="sensing-bar"><div class="sensing-bar-fill confidence" id="mmwaveAgeBar" style="background:rgb(33,150,243);"></div></div>
<span class="sensing-meter-val" id="mmwaveAge"> ms</span>
</div>
</div>
</div>
<!-- Setup info -->
<div class="sensing-card">
<div class="sensing-card-title">About This Data</div>
@ -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));

1
v2/Cargo.lock generated
View File

@ -8550,6 +8550,7 @@ dependencies = [
"ruvector-mincut",
"serde",
"serde_json",
"serialport",
"tempfile",
"tokio",
"tower 0.4.13",

View File

@ -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).

View File

@ -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;

View File

@ -1174,6 +1174,20 @@ struct Args {
/// Independent from `--model` (RVF container) and `--load-rvf`.
#[arg(long, value_name = "PATH")]
wiflow_model: Option<PathBuf>,
/// 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:<cm>` 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<String>,
/// 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<serde_json::Value> {
}))
}
/// 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<serde_json::Value> {
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<SharedState>) -> Json<serde_json::Value> {
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<ProgressiveLoader> = 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))

View File

@ -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:<cm>\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<Mutex<…>>` 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<Mutex<Option<MmwaveReading>>> = OnceLock::new();
fn latest() -> &'static Mutex<Option<MmwaveReading>> {
LATEST.get_or_init(|| Mutex::new(None))
}
/// Returns the most recent reading if it landed within `staleness`.
pub fn current(staleness: Duration) -> Option<MmwaveReading> {
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<u8> = 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:<digits>` (HLK-LD2402 Normal Mode line format).
pub fn parse_distance(line: &str) -> Option<u32> {
let lower = line.trim().to_ascii_lowercase();
let rest = lower.strip_prefix("distance:")?;
rest.parse::<u32>().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);
}
}