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>
|
||||||
</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 -->
|
<!-- Setup info -->
|
||||||
<div class="sensing-card">
|
<div class="sensing-card">
|
||||||
<div class="sensing-card-title">About This Data</div>
|
<div class="sensing-card-title">About This Data</div>
|
||||||
|
|
@ -268,6 +281,35 @@ export class SensingTab {
|
||||||
const confPct = ((c.confidence || 0) * 100).toFixed(0);
|
const confPct = ((c.confidence || 0) * 100).toFixed(0);
|
||||||
this._setBar('barConfidence', c.confidence, 1.0, 'valConfidence', confPct + '%');
|
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
|
// Details
|
||||||
this._setText('valDomFreq', (f.dominant_freq_hz || 0).toFixed(3) + ' Hz');
|
this._setText('valDomFreq', (f.dominant_freq_hz || 0).toFixed(3) + ' Hz');
|
||||||
this._setText('valChangePoints', String(f.change_points || 0));
|
this._setText('valChangePoints', String(f.change_points || 0));
|
||||||
|
|
|
||||||
|
|
@ -8550,6 +8550,7 @@ dependencies = [
|
||||||
"ruvector-mincut",
|
"ruvector-mincut",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serialport",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower 0.4.13",
|
"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-temporal-compare = "0.2" # DTW / LCS / Edit-Distance pattern matching
|
||||||
midstreamer-attractor = "0.2" # Lyapunov + regime classification
|
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]
|
[dev-dependencies]
|
||||||
tempfile = "3.10"
|
tempfile = "3.10"
|
||||||
# `tower::ServiceExt::oneshot` for in-process Router tests (bearer_auth).
|
# `tower::ServiceExt::oneshot` for in-process Router tests (bearer_auth).
|
||||||
|
|
|
||||||
|
|
@ -21,3 +21,5 @@ pub mod sparse_inference;
|
||||||
pub mod embedding;
|
pub mod embedding;
|
||||||
/// ADR-116: WiFlow-v1 supervised pose model loader + Rust forward pass.
|
/// ADR-116: WiFlow-v1 supervised pose model loader + Rust forward pass.
|
||||||
pub mod wiflow_v1;
|
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`.
|
/// Independent from `--model` (RVF container) and `--load-rvf`.
|
||||||
#[arg(long, value_name = "PATH")]
|
#[arg(long, value_name = "PATH")]
|
||||||
wiflow_model: Option<PathBuf>,
|
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
|
/// 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).
|
/// POST /api/v1/adaptive/unload — unload the adaptive model (revert to thresholds).
|
||||||
async fn adaptive_unload(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
async fn adaptive_unload(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||||
let mut s = state.write().await;
|
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)
|
// 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 model_path = args.model.as_ref().or(args.load_rvf.as_ref());
|
||||||
let mut progressive_loader: Option<ProgressiveLoader> = None;
|
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/train", post(adaptive_train))
|
||||||
.route("/api/v1/adaptive/status", get(adaptive_status))
|
.route("/api/v1/adaptive/status", get(adaptive_status))
|
||||||
.route("/api/v1/adaptive/debug", get(adaptive_debug))
|
.route("/api/v1/adaptive/debug", get(adaptive_debug))
|
||||||
|
.route("/api/v1/mmwave/latest", get(mmwave_latest))
|
||||||
.route("/api/v1/adaptive/unload", post(adaptive_unload))
|
.route("/api/v1/adaptive/unload", post(adaptive_unload))
|
||||||
// Field model calibration (eigenvalue-based person counting)
|
// Field model calibration (eigenvalue-based person counting)
|
||||||
.route("/api/v1/calibration/start", post(calibration_start))
|
.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