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:
parent
831602b584
commit
cb6e24ed57
|
|
@ -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.
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -8550,6 +8550,7 @@ dependencies = [
|
|||
"ruvector-mincut",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serialport",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tower 0.4.13",
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue