Compare commits
5 Commits
f6adcb2014
...
5eac273630
| Author | SHA1 | Date |
|---|---|---|
|
|
5eac273630 | |
|
|
ee6d9dfa80 | |
|
|
81e848ef2a | |
|
|
b9d1f6361e | |
|
|
26d47a9533 |
|
|
@ -0,0 +1,159 @@
|
|||
# ADR-122 — HLK-LD2402 Engineering Mode (per-gate energies, mmWave HR)
|
||||
|
||||
**Status**: Accepted.
|
||||
**Date**: 2026-05-18
|
||||
**Scope**: `v2/crates/wifi-densepose-sensing-server/src/mmwave.rs`
|
||||
(extended), `v2/scripts/probe_ld2402_engineering.py` (new),
|
||||
`ui/raw.html` (pills updated). Builds on ADR-121.
|
||||
|
||||
## Context
|
||||
|
||||
ADR-121 wired up the HLK-LD2402 in **Normal Mode** — ASCII
|
||||
`distance:<cm>\r\n` lines at ~6 Hz. That gave us a live distance pill
|
||||
and a passable breathing estimate (chest movement modulates range by
|
||||
5–10 mm, visible as cm-bin flicker), but **no heart rate** — cardiac
|
||||
chest displacement (~0.3–0.5 mm) is well below the cm quantisation
|
||||
step, so heartbeat is invisible in the distance time-series.
|
||||
|
||||
The module supports a richer **Engineering Mode** that streams per-
|
||||
range-gate amplitude at the same cadence. ESPHome's driver and the
|
||||
Hi-Link command spec gave enough information to reverse-engineer it.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1 — Switch into Engineering Mode at startup
|
||||
|
||||
On `spawn_reader` the host sends three commands in sequence:
|
||||
|
||||
1. `0x00FF` — enable config mode (payload `01 00`)
|
||||
2. `0x0012` — set work mode (payload `00 00 04 00 00 00`, mode = 0x04)
|
||||
3. `0x00FE` — disable config mode → data stream resumes (now binary)
|
||||
|
||||
Each command frame is `FD FC FB FA <len LE> <cmd LE> <data...> 04 03 02 01`.
|
||||
If any step errors out we log a warning and fall back to the
|
||||
ADR-121 ASCII Normal-Mode parser, so distance keeps working even when
|
||||
the radar is in an inconsistent state.
|
||||
|
||||
### D2 — Binary frame layout
|
||||
|
||||
Engineering frames at ~6 Hz, 141 bytes total:
|
||||
|
||||
```text
|
||||
F4 F3 F2 F1 data header
|
||||
LL LL payload length (LE u16, = 131)
|
||||
01 frame type (engineering)
|
||||
DD DD distance in cm (LE u16)
|
||||
00 × 8 reserved
|
||||
<15 × u32 LE> motion-gate energies (gate 0 = 0–0.7 m, …)
|
||||
<15 × u32 LE> micromotion-gate energies (same range bins)
|
||||
F8 F7 F6 F5 footer
|
||||
```
|
||||
|
||||
The ESPHome project documents `0x84` as the engineering type byte,
|
||||
but our firmware emits `0x01` — likely a firmware revision difference.
|
||||
The parser only accepts `0x01` for now.
|
||||
|
||||
### D3 — Don't mix ASCII and binary drains in one loop
|
||||
|
||||
First-cut implementation ran both the binary frame parser and the
|
||||
ASCII line drain unconditionally. Since the binary payload contains
|
||||
arbitrary bytes (including `0x0A`), the ASCII drain destroyed ~80 %
|
||||
of partial frames mid-buffer, dropping the effective parse rate to
|
||||
1.3 Hz. The fix is to track an `engineering_mode` flag set after the
|
||||
enable sequence succeeds; ASCII drain only runs in the fallback path.
|
||||
|
||||
After this fix, frame parsing matches the raw byte rate exactly
|
||||
(~6.1 Hz observed live).
|
||||
|
||||
### D4 — Target-gate selection for HR extraction
|
||||
|
||||
The micromotion-gate at the target's range is where the cardiac
|
||||
signal lives — that's the bin whose backscatter is modulated by
|
||||
chest-wall displacement. Three-tier selection:
|
||||
|
||||
1. **Distance-based** — bracket `distance_cm` into a 0.7 m gate;
|
||||
2. **Mid-range micro-peak** — if that gate's micro energy is zero
|
||||
(stale distance, wrong guess), pick the strongest micro gate in
|
||||
`[1, 14)`. Gate 0 is dominated by near-field clutter; the last
|
||||
gate is usually empty.
|
||||
3. **Default** — gate 1 if all else fails (most common seated-operator
|
||||
torso distance).
|
||||
|
||||
### D5 — HR via bandpass + FFT on micro-gate log-energy history
|
||||
|
||||
Per-gate micromotion energies are pushed into 30-s ring buffers
|
||||
(180 samples at 6 Hz). We log-compress (`ln(energy + 1)`) to suppress
|
||||
the dynamic range of the raw u32, then run a Hann-windowed bandpass
|
||||
(0.8–2.0 Hz = 48–120 BPM) + radix-2 FFT peak search on the target-
|
||||
gate buffer. Confidence is the peak-to-band-mean ratio, mapped to
|
||||
[0,1] the same way as ADR-021's VitalSignDetector.
|
||||
|
||||
Breathing keeps using the distance time-series via the shared
|
||||
detector — that signal is strong enough not to need per-gate
|
||||
selection.
|
||||
|
||||
### D6 — Diagnostic probe script kept in tree
|
||||
|
||||
`v2/scripts/probe_ld2402_engineering.py` does the same enable
|
||||
sequence and dumps the first N frames as annotated hex. Useful for
|
||||
verifying the wire format on new firmware revisions, and would have
|
||||
saved an hour during this ADR's development if it had existed first.
|
||||
|
||||
## Verified Acceptance
|
||||
|
||||
Live with the module attached, target ~1.5 m away (seated):
|
||||
|
||||
```
|
||||
$ curl :8080/api/v1/mmwave/vitals
|
||||
{"available":true,
|
||||
"vital_signs": {
|
||||
"breathing_rate_bpm": 13.06,
|
||||
"breathing_confidence": 0.37,
|
||||
"heart_rate_bpm": 75.93,
|
||||
"heartbeat_confidence": 0.63
|
||||
},
|
||||
"buffer_status": {"breathing_capacity":180,"breathing_samples":180}}
|
||||
```
|
||||
|
||||
Server logs:
|
||||
|
||||
```
|
||||
ADR-121 mmWave reader: opened /dev/cu.usbserial-1140 @ 115200
|
||||
ADR-122 mmWave: Engineering Mode enabled (per-gate energies @ 6 Hz)
|
||||
```
|
||||
|
||||
In the UI, both pills now show two values:
|
||||
|
||||
```
|
||||
🫁 📶 — BPM · | 📡 13.0 BPM · 37% норма 12–20
|
||||
💓 📶 — BPM · | 📡 76 BPM · 63% норма 60–100
|
||||
```
|
||||
|
||||
## Out of Scope / Follow-ups
|
||||
|
||||
* **Calibration against ground-truth pulse**. The 75 BPM value
|
||||
agrees with the seated operator's actual rest pulse to within
|
||||
±5 BPM but hasn't been benchmarked against a chest-strap monitor
|
||||
across multiple subjects or activity levels.
|
||||
|
||||
* **Multi-target handling**. The current target-gate selector picks
|
||||
one gate. With two people in the field the algorithm picks
|
||||
whichever has the stronger backscatter; the other person's HR is
|
||||
lost.
|
||||
|
||||
* **Engineering Mode is sticky**. After this server runs, the module
|
||||
remains in engineering mode until something explicitly switches it
|
||||
back. The next ADR-121 ASCII consumer (an external tool) would
|
||||
receive binary garbage. Add a clean-shutdown step that issues
|
||||
`set_mode(0x64)` if we ever wire up signal handling.
|
||||
|
||||
* **Fusion with WiFi-CSI**. Now we have HR from two independent
|
||||
modalities — weighted-average or disagreement-flag could improve
|
||||
reliability. Probably ADR-123.
|
||||
|
||||
## References
|
||||
|
||||
* ADR-021 — WiFi-CSI vital signs detector (shared FFT/bandpass).
|
||||
* ADR-121 — HLK-LD2402 Normal-Mode integration.
|
||||
* ESPHome HLK-LD2402 driver (`Mc-Joung/hlk_ld2402_esphome`) — main
|
||||
reference for the command opcodes and frame envelope.
|
||||
120
ui/raw.html
120
ui/raw.html
|
|
@ -48,16 +48,25 @@
|
|||
<span class="pill" id="lastTs">last: --</span>
|
||||
<span class="badge absent" id="globalBadge" style="font-size:13px;padding:4px 12px;">absent</span>
|
||||
<span class="pill" id="globalCV">CV 0%</span>
|
||||
<!-- ADR-021: WiFi-CSI vital signs — breathing + heart rate (computed server-side). -->
|
||||
<!-- ADR-021 + ADR-121: dual-source vital signs.
|
||||
Each pill shows WiFi-CSI (📶) and mmWave (📡) side-by-side so
|
||||
the operator can spot disagreement between modalities. Norms
|
||||
are adult-at-rest ranges. -->
|
||||
<span class="pill" id="brPill"
|
||||
style="background:rgba(63,185,80,0.18); color:rgb(63,185,80); border:1px solid rgb(63,185,80);"
|
||||
title="WiFi-CSI breathing rate from bandpass 0.1-0.5 Hz on broadband amplitude (ADR-021)">
|
||||
🫁 <b id="brBpm">— BPM</b> <span id="brConf" style="opacity:0.7;font-size:11px">·</span>
|
||||
title="Breathing rate, two sources: 📶 = WiFi-CSI bandpass 0.1-0.5 Hz (ADR-021). 📡 = HLK-LD2402 mmWave distance bandpass (ADR-121). Adult-at-rest norm: 12-20 BPM. Below 12 = bradypnea, above 20 = tachypnea.">
|
||||
🫁 <span style="opacity:0.6;font-size:11px">📶</span><b id="brBpm">— BPM</b><span id="brConf" style="opacity:0.7;font-size:11px">·</span>
|
||||
<span style="opacity:0.4">|</span>
|
||||
<span style="opacity:0.6;font-size:11px">📡</span><b id="brBpmMm">— BPM</b><span id="brConfMm" style="opacity:0.7;font-size:11px">·</span>
|
||||
<span style="opacity:0.55;font-size:11px;margin-left:6px">норма 12–20</span>
|
||||
</span>
|
||||
<span class="pill" id="hrPill"
|
||||
style="background:rgba(248,81,73,0.18); color:rgb(248,81,73); border:1px solid rgb(248,81,73);"
|
||||
title="WiFi-CSI heart rate from bandpass 0.8-2.0 Hz on broadband amplitude (ADR-021)">
|
||||
💓 <b id="hrBpm">— BPM</b> <span id="hrConf" style="opacity:0.7;font-size:11px">·</span>
|
||||
title="Heart rate, two sources: 📶 = WiFi-CSI bandpass 0.8-2.0 Hz (ADR-021). 📡 = HLK-LD2402 mmWave Engineering Mode — bandpass on the micromotion-gate energy time-series at the target's range bin (ADR-122). Adult-at-rest norm: 60-100 BPM.">
|
||||
💓 <span style="opacity:0.6;font-size:11px">📶</span><b id="hrBpm">— BPM</b><span id="hrConf" style="opacity:0.7;font-size:11px">·</span>
|
||||
<span style="opacity:0.4">|</span>
|
||||
<span style="opacity:0.6;font-size:11px">📡</span><b id="hrBpmMm">— BPM</b><span id="hrConfMm" style="opacity:0.7;font-size:11px">·</span>
|
||||
<span style="opacity:0.55;font-size:11px;margin-left:6px">норма 60–100</span>
|
||||
</span>
|
||||
<!-- ADR-121: HLK-LD2402 24 GHz mmWave radar pill — hidden until first reading. -->
|
||||
<span class="pill" id="mmwavePill" style="display:none; background:rgba(33,150,243,0.18);
|
||||
|
|
@ -379,21 +388,36 @@ function handleSensingUpdate(d) {
|
|||
const hrConf = document.getElementById('hrConf');
|
||||
const brPill = document.getElementById('brPill');
|
||||
const hrPill = document.getElementById('hrPill');
|
||||
// Adult-at-rest norms (resting). Out-of-norm values get a warning tint
|
||||
// so the operator immediately sees brady/tachy without reading numbers.
|
||||
// Tooltip on the pill carries the brady/tachy terminology.
|
||||
const BR_MIN = 12, BR_MAX = 20; // breaths per minute
|
||||
const HR_MIN = 60, HR_MAX = 100; // heartbeats per minute
|
||||
if (vs && typeof vs.breathing_rate_bpm === 'number' && Number.isFinite(vs.breathing_rate_bpm) && vs.breathing_rate_bpm > 0) {
|
||||
if (brBpm) brBpm.textContent = vs.breathing_rate_bpm.toFixed(1) + ' BPM';
|
||||
const v = vs.breathing_rate_bpm;
|
||||
const out = v < BR_MIN || v > BR_MAX;
|
||||
if (brBpm) {
|
||||
brBpm.textContent = v.toFixed(1) + ' BPM';
|
||||
brBpm.style.color = out ? '#f0a020' : '';
|
||||
}
|
||||
if (brConf) brConf.textContent = '· ' + ((vs.breathing_confidence || 0) * 100).toFixed(0) + '%';
|
||||
if (brPill) brPill.style.opacity = (vs.breathing_confidence || 0) < 0.2 ? '0.5' : '1.0';
|
||||
} else {
|
||||
if (brBpm) brBpm.textContent = '— BPM';
|
||||
if (brBpm) { brBpm.textContent = '— BPM'; brBpm.style.color = ''; }
|
||||
if (brConf) brConf.textContent = '·';
|
||||
if (brPill) brPill.style.opacity = '0.5';
|
||||
}
|
||||
if (vs && typeof vs.heart_rate_bpm === 'number' && Number.isFinite(vs.heart_rate_bpm) && vs.heart_rate_bpm > 0) {
|
||||
if (hrBpm) hrBpm.textContent = vs.heart_rate_bpm.toFixed(0) + ' BPM';
|
||||
const v = vs.heart_rate_bpm;
|
||||
const out = v < HR_MIN || v > HR_MAX;
|
||||
if (hrBpm) {
|
||||
hrBpm.textContent = v.toFixed(0) + ' BPM';
|
||||
hrBpm.style.color = out ? '#f0a020' : '';
|
||||
}
|
||||
if (hrConf) hrConf.textContent = '· ' + ((vs.heartbeat_confidence || 0) * 100).toFixed(0) + '%';
|
||||
if (hrPill) hrPill.style.opacity = (vs.heartbeat_confidence || 0) < 0.2 ? '0.5' : '1.0';
|
||||
} else {
|
||||
if (hrBpm) hrBpm.textContent = '— BPM';
|
||||
if (hrBpm) { hrBpm.textContent = '— BPM'; hrBpm.style.color = ''; }
|
||||
if (hrConf) hrConf.textContent = '·';
|
||||
if (hrPill) hrPill.style.opacity = '0.5';
|
||||
}
|
||||
|
|
@ -565,7 +589,18 @@ async function pollMmwave() {
|
|||
const j = await r.json();
|
||||
if (j && j.available) {
|
||||
mmwavePill.style.display = '';
|
||||
mmwaveDist.textContent = j.distance_cm + ' cm';
|
||||
// ADR-122 hardware quirk: at <70 cm the radar's gate-0 micro is
|
||||
// dead and its distance algorithm returns a sidelobe pick
|
||||
// (~1.5–2 m). The server flags this as `near_field`; we
|
||||
// override the display so the operator doesn't read the bogus
|
||||
// 161 cm reading as truth.
|
||||
if (j.near_field) {
|
||||
mmwaveDist.textContent = '<70 cm';
|
||||
mmwavePill.title = 'Цель в ближней зоне антенны (<70 cm). Радар тебя видит, но точная дистанция и пульс/дыхание ненадёжны на таком расстоянии. Отодвинься на 0.7–2 м для рабочего диапазона.';
|
||||
} else {
|
||||
mmwaveDist.textContent = j.distance_cm + ' cm';
|
||||
mmwavePill.title = 'HLK-LD2402 24 GHz radar — distance to closest target';
|
||||
}
|
||||
const age = Math.round(j.age_ms || 0);
|
||||
mmwaveAge.textContent = '· ' + age + ' ms';
|
||||
// Fade pill if stale (>1.5 s) before server hides at 2 s.
|
||||
|
|
@ -579,5 +614,70 @@ async function pollMmwave() {
|
|||
}
|
||||
pollMmwave();
|
||||
setInterval(pollMmwave, 200);
|
||||
|
||||
// ── ADR-121 follow-up: poll mmWave-derived vital signs @ 1 Hz ────────
|
||||
// Slower than the distance poll (5 Hz) — BPM only meaningfully updates
|
||||
// once per breath cycle, and the FFT runs on a 30-s buffer so faster
|
||||
// polling just wastes cycles.
|
||||
const brBpmMm = document.getElementById('brBpmMm');
|
||||
const brConfMm = document.getElementById('brConfMm');
|
||||
const hrBpmMm = document.getElementById('hrBpmMm');
|
||||
const hrConfMm = document.getElementById('hrConfMm');
|
||||
let mmVitalsBusy = false;
|
||||
async function pollMmwaveVitals() {
|
||||
if (mmVitalsBusy) return; mmVitalsBusy = true;
|
||||
try {
|
||||
const r = await fetch('/api/v1/mmwave/vitals', { cache: 'no-store' });
|
||||
if (!r.ok) throw new Error('http ' + r.status);
|
||||
const j = await r.json();
|
||||
const vs = (j && j.available) ? (j.vital_signs || {}) : {};
|
||||
const BR_MIN = 12, BR_MAX = 20, HR_MIN = 60, HR_MAX = 100;
|
||||
if (typeof vs.breathing_rate_bpm === 'number' && Number.isFinite(vs.breathing_rate_bpm) && vs.breathing_rate_bpm > 0) {
|
||||
const v = vs.breathing_rate_bpm;
|
||||
const out = v < BR_MIN || v > BR_MAX;
|
||||
brBpmMm.textContent = ' ' + v.toFixed(1) + ' BPM ';
|
||||
brBpmMm.style.color = out ? '#f0a020' : '';
|
||||
brConfMm.textContent = '· ' + ((vs.breathing_confidence || 0) * 100).toFixed(0) + '%';
|
||||
} else {
|
||||
brBpmMm.textContent = ' — BPM ';
|
||||
brBpmMm.style.color = '';
|
||||
// Three reasons to show "—":
|
||||
// 1. no presence (radar sees no body in beam) → "нет цели"
|
||||
// 2. buffer warming up (first 30 s) → "NN/180"
|
||||
// 3. presence ok but FFT failed (rare) → "·"
|
||||
if (j && j.available && j.presence === false) {
|
||||
brConfMm.textContent = '· нет цели';
|
||||
} else {
|
||||
const fill = j && j.buffer_status ? j.buffer_status.breathing_samples : 0;
|
||||
const cap = j && j.buffer_status ? j.buffer_status.breathing_capacity : 180;
|
||||
brConfMm.textContent = cap > 0 && fill < cap ? '· ' + fill + '/' + cap : '·';
|
||||
}
|
||||
}
|
||||
if (typeof vs.heart_rate_bpm === 'number' && Number.isFinite(vs.heart_rate_bpm) && vs.heart_rate_bpm > 0) {
|
||||
const v = vs.heart_rate_bpm;
|
||||
const out = v < HR_MIN || v > HR_MAX;
|
||||
hrBpmMm.textContent = ' ' + v.toFixed(0) + ' BPM ';
|
||||
hrBpmMm.style.color = out ? '#f0a020' : '';
|
||||
hrConfMm.textContent = '· ' + ((vs.heartbeat_confidence || 0) * 100).toFixed(0) + '%';
|
||||
} else {
|
||||
// ADR-122: HR only published when presence-gate passes (target
|
||||
// micromotion energy above clutter threshold). Show *why* the
|
||||
// value is missing so the operator can tell "warming up" from
|
||||
// "nobody in beam" from "module fell back to Normal Mode".
|
||||
hrBpmMm.textContent = ' — BPM ';
|
||||
hrBpmMm.style.color = '';
|
||||
if (j && j.available && j.presence === false) {
|
||||
hrConfMm.textContent = '· нет цели';
|
||||
} else {
|
||||
hrConfMm.textContent = '·';
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
brBpmMm.textContent = ' — BPM '; brConfMm.textContent = '·';
|
||||
hrBpmMm.textContent = ' — BPM '; hrConfMm.textContent = '·';
|
||||
} finally { mmVitalsBusy = false; }
|
||||
}
|
||||
pollMmwaveVitals();
|
||||
setInterval(pollMmwaveVitals, 1000);
|
||||
</script>
|
||||
</body></html>
|
||||
|
|
|
|||
|
|
@ -5092,6 +5092,7 @@ async fn mmwave_latest() -> Json<serde_json::Value> {
|
|||
Some(r) => Json(serde_json::json!({
|
||||
"available": true,
|
||||
"distance_cm": r.distance_cm,
|
||||
"near_field": r.near_field,
|
||||
"age_ms": r.at.elapsed().as_millis() as u64,
|
||||
})),
|
||||
None => Json(serde_json::json!({
|
||||
|
|
@ -5100,6 +5101,62 @@ async fn mmwave_latest() -> Json<serde_json::Value> {
|
|||
}
|
||||
}
|
||||
|
||||
/// ADR-122 diagnostic: GET /api/v1/mmwave/gates — current per-gate
|
||||
/// energies + target-gate selection. Lets the operator see whether
|
||||
/// the radar is actually picking up a body or just background noise.
|
||||
async fn mmwave_gates() -> Json<serde_json::Value> {
|
||||
use wifi_densepose_sensing_server::mmwave;
|
||||
let stale = std::time::Duration::from_secs(2);
|
||||
match mmwave::current_gates(stale) {
|
||||
Some(snap) => {
|
||||
let peak_motion_mid = snap.motion[1..14].iter().copied().max().unwrap_or(0);
|
||||
let peak_micro_mid = snap.micro[1..14].iter().copied().max().unwrap_or(0);
|
||||
Json(serde_json::json!({
|
||||
"available": true,
|
||||
"motion": snap.motion,
|
||||
"micro": snap.micro,
|
||||
"target_gate": snap.target_gate,
|
||||
"peak_motion_mid": peak_motion_mid,
|
||||
"peak_micro_mid": peak_micro_mid,
|
||||
"age_ms": snap.at.elapsed().as_millis() as u64,
|
||||
}))
|
||||
}
|
||||
None => Json(serde_json::json!({"available": false})),
|
||||
}
|
||||
}
|
||||
|
||||
/// ADR-121 follow-up: GET /api/v1/mmwave/vitals — breathing + (best-
|
||||
/// effort) heart-rate computed from the mmWave distance time-series.
|
||||
/// Returns `{ available: false }` if no recent reading or if the
|
||||
/// detector buffer is still warming up.
|
||||
///
|
||||
/// Same shape as `/api/v1/vital-signs` so the UI can render both
|
||||
/// sources with a single code path.
|
||||
async fn mmwave_vitals() -> Json<serde_json::Value> {
|
||||
use wifi_densepose_sensing_server::mmwave;
|
||||
let stale = std::time::Duration::from_secs(2);
|
||||
match mmwave::current_vitals(stale) {
|
||||
Some(vs) => {
|
||||
let (br_samples, br_cap) = mmwave::buffer_status();
|
||||
let presence = mmwave::current_presence(stale).unwrap_or(false);
|
||||
Json(serde_json::json!({
|
||||
"available": true,
|
||||
"source": "mmwave:hlk-ld2402",
|
||||
"presence": presence,
|
||||
"vital_signs": vs,
|
||||
"buffer_status": {
|
||||
"breathing_samples": br_samples,
|
||||
"breathing_capacity": br_cap,
|
||||
},
|
||||
}))
|
||||
}
|
||||
None => Json(serde_json::json!({
|
||||
"available": false,
|
||||
"source": "mmwave:offline",
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
|
@ -7685,6 +7742,8 @@ async fn main() {
|
|||
.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/mmwave/vitals", get(mmwave_vitals))
|
||||
.route("/api/v1/mmwave/gates", get(mmwave_gates))
|
||||
.route("/api/v1/adaptive/unload", post(adaptive_unload))
|
||||
// Field model calibration (eigenvalue-based person counting)
|
||||
.route("/api/v1/calibration/start", post(calibration_start))
|
||||
|
|
|
|||
|
|
@ -1,43 +1,207 @@
|
|||
//! ADR-121: HLK-LD2402 24 GHz mmWave radar reader.
|
||||
//! ADR-121 + ADR-122: 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.
|
||||
//! Auxiliary range + per-range-gate vitals modality, attached over a
|
||||
//! CP2102 USB-UART bridge.
|
||||
//!
|
||||
//! Two operating modes are supported on the wire:
|
||||
//!
|
||||
//! * **Normal Mode** (factory default) — emits ASCII
|
||||
//! `distance:<cm>\r\n` lines @ 115200 baud, ~6 Hz. Implemented by
|
||||
//! `parse_distance` as a fallback when Engineering Mode setup fails
|
||||
//! or no enable-config ACK is received.
|
||||
//!
|
||||
//! * **Engineering Mode** (ADR-122) — after the host issues
|
||||
//! `enable-config → set-mode(0x04) → disable-config` the module
|
||||
//! emits binary frames at the same ~6 Hz cadence:
|
||||
//!
|
||||
//! ```text
|
||||
//! F4 F3 F2 F1 data header
|
||||
//! LL LL u16 LE length of payload (typically 131)
|
||||
//! 01 frame type = engineering
|
||||
//! DD DD u16 LE distance (cm)
|
||||
//! 00 00 00 00 00 00 00 00 8 reserved bytes
|
||||
//! <15 × u32 LE> 15 motion-gate energies (gate 0 = 0–0.7 m, etc.)
|
||||
//! <15 × u32 LE> 15 micromotion-gate energies (same range bins)
|
||||
//! F8 F7 F6 F5 data footer
|
||||
//! ```
|
||||
//!
|
||||
//! The micromotion gate at the target's range is the input we feed
|
||||
//! into the vital-sign detector for heart-rate extraction — at 6 Hz
|
||||
//! the energy time-series carries cardiac modulation that the
|
||||
//! integer-cm distance reading cannot resolve.
|
||||
//!
|
||||
//! 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.
|
||||
//! awkward inside tokio) and pushes the latest distance, the per-gate
|
||||
//! energy snapshot, and the computed VitalSigns into global
|
||||
//! `OnceLock<Mutex<…>>` slots 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).
|
||||
//! sensing only. If config commands fail, we fall back to the ASCII
|
||||
//! Normal Mode parser so distance keeps working.
|
||||
|
||||
use std::io::Read;
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::vital_signs::{VitalSignDetector, VitalSigns};
|
||||
|
||||
/// HLK-LD2402 Normal-Mode cadence: factory firmware emits ~6 distance
|
||||
/// lines/sec. We pick 6.0 Hz as the detector's nominal sample_rate so
|
||||
/// the breathing band (0.1–0.5 Hz) sits comfortably inside Nyquist.
|
||||
const MMWAVE_SAMPLE_RATE_HZ: f64 = 6.0;
|
||||
|
||||
/// Gate count emitted in each Engineering Mode frame (each for
|
||||
/// motion + micromotion). The module's firmware reports 15 gates of
|
||||
/// 0.7 m each, covering 0–10.5 m.
|
||||
const GATE_COUNT: usize = 15;
|
||||
|
||||
/// Bytes per gate energy (u32 LE).
|
||||
const GATE_BYTES: usize = 4;
|
||||
|
||||
/// Engineering frame fixed-payload prelude: type(1) + distance(2) + 8 reserved.
|
||||
const ENG_PRELUDE_BYTES: usize = 1 + 2 + 8;
|
||||
|
||||
/// Expected payload length for an engineering frame:
|
||||
/// prelude(11) + 2 * 15 gates * 4 bytes = 131.
|
||||
const ENG_PAYLOAD_LEN: u16 = (ENG_PRELUDE_BYTES + 2 * GATE_COUNT * GATE_BYTES) as u16;
|
||||
|
||||
/// Engineering data frame markers.
|
||||
const DATA_HEAD: [u8; 4] = [0xF4, 0xF3, 0xF2, 0xF1];
|
||||
const DATA_FOOT: [u8; 4] = [0xF8, 0xF7, 0xF6, 0xF5];
|
||||
|
||||
/// Engineering frame type byte (observed on LD2402 firmware; ESPHome
|
||||
/// docs mention 0x84 but the actual device emits 0x01).
|
||||
const FRAME_TYPE_ENGINEERING: u8 = 0x01;
|
||||
|
||||
/// How many recent micromotion-energy samples to keep per gate. At
|
||||
/// 6 Hz sampling, 30 s = 180 samples — enough for the FFT bin in the
|
||||
/// heartbeat band (0.8–2.0 Hz) to resolve cleanly.
|
||||
const GATE_HISTORY_LEN: usize = 180;
|
||||
|
||||
/// Presence threshold on the target gate's *micromotion* energy. The
|
||||
/// micro-channel responds to small chest-wall displacement; when no
|
||||
/// body is in the radar's main beam this stays in the 1k–5k clutter
|
||||
/// range. When a torso is on-axis it climbs above 30k. We use 20k as
|
||||
/// a conservative gate — below this we refuse to publish HR (and
|
||||
/// flag breathing as low-confidence) so the operator doesn't read a
|
||||
/// random FFT peak as a vital sign.
|
||||
///
|
||||
/// Empirically observed on this exact module:
|
||||
/// * empty room : peak_micro_mid ~1k–3k
|
||||
/// * person nearby : peak_micro_mid ~10k–20k
|
||||
/// * person in beam: peak_micro_mid ~40k–80k
|
||||
const PRESENCE_MICRO_THRESHOLD: u32 = 20_000;
|
||||
|
||||
/// Latest mmWave reading + when it landed.
|
||||
///
|
||||
/// `near_field` is set when the gate-0 motion energy dominates the
|
||||
/// mid-range gates (operator is closer than ~70 cm). In that regime
|
||||
/// the radar's internal distance algorithm returns nonsense
|
||||
/// (typically 1.5–2 m), because gate-0 micromotion is in the
|
||||
/// antenna's dead zone and the algorithm falls back to a sidelobe
|
||||
/// pick. We surface the flag so the UI can render "<70 cm" instead
|
||||
/// of the bogus number.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct MmwaveReading {
|
||||
pub distance_cm: u32,
|
||||
pub near_field: bool,
|
||||
pub at: Instant,
|
||||
}
|
||||
|
||||
/// Latest per-gate energy snapshot.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GateSnapshot {
|
||||
/// Motion gate energies (raw u32 from frame).
|
||||
pub motion: [u32; GATE_COUNT],
|
||||
/// Micromotion gate energies (raw u32 from frame).
|
||||
pub micro: [u32; GATE_COUNT],
|
||||
/// Index of the dominant gate (highest motion energy) — used as
|
||||
/// the target-gate hint for HR extraction.
|
||||
pub target_gate: usize,
|
||||
pub at: Instant,
|
||||
}
|
||||
|
||||
static LATEST: OnceLock<Mutex<Option<MmwaveReading>>> = OnceLock::new();
|
||||
static GATES: OnceLock<Mutex<Option<GateSnapshot>>> = OnceLock::new();
|
||||
static VITALS: OnceLock<Mutex<Option<VitalSigns>>> = OnceLock::new();
|
||||
static BR_DETECTOR: OnceLock<Mutex<VitalSignDetector>> = OnceLock::new();
|
||||
static HR_HISTORY: OnceLock<Mutex<Vec<VecDeque<f64>>>> = 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`.
|
||||
fn gates_slot() -> &'static Mutex<Option<GateSnapshot>> {
|
||||
GATES.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
fn vitals_slot() -> &'static Mutex<Option<VitalSigns>> {
|
||||
VITALS.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
fn br_detector() -> &'static Mutex<VitalSignDetector> {
|
||||
BR_DETECTOR.get_or_init(|| Mutex::new(VitalSignDetector::new(MMWAVE_SAMPLE_RATE_HZ)))
|
||||
}
|
||||
|
||||
fn hr_history() -> &'static Mutex<Vec<VecDeque<f64>>> {
|
||||
HR_HISTORY.get_or_init(|| {
|
||||
Mutex::new(
|
||||
(0..GATE_COUNT)
|
||||
.map(|_| VecDeque::with_capacity(GATE_HISTORY_LEN))
|
||||
.collect(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the most recent distance 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 }
|
||||
}
|
||||
|
||||
/// Returns the latest per-gate energy snapshot, gated on freshness.
|
||||
pub fn current_gates(staleness: Duration) -> Option<GateSnapshot> {
|
||||
let g = gates_slot().lock().unwrap();
|
||||
let s = (*g).clone()?;
|
||||
if s.at.elapsed() <= staleness { Some(s) } else { None }
|
||||
}
|
||||
|
||||
/// Returns the latest mmWave-derived VitalSigns. Breathing is computed
|
||||
/// from the distance time-series; heart rate (when present) is
|
||||
/// computed from the micromotion-gate energy time-series at the
|
||||
/// detected target gate.
|
||||
///
|
||||
/// Returns `None` if the most recent distance reading is older than
|
||||
/// `staleness`.
|
||||
pub fn current_vitals(staleness: Duration) -> Option<VitalSigns> {
|
||||
current(staleness)?;
|
||||
vitals_slot().lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Buffer fill stats for UI ("breathing: 120/180 samples").
|
||||
pub fn buffer_status() -> (usize, usize) {
|
||||
let det = br_detector().lock().unwrap();
|
||||
let (br_samples, br_cap, _hr_samples, _hr_cap) = det.buffer_status();
|
||||
(br_samples, br_cap)
|
||||
}
|
||||
|
||||
/// True if the latest gate snapshot has enough on-axis micromotion
|
||||
/// energy to plausibly be a body — i.e. vitals are meaningful right
|
||||
/// now, not a noise hallucination.
|
||||
pub fn current_presence(staleness: Duration) -> Option<bool> {
|
||||
let snap = current_gates(staleness)?;
|
||||
let peak_micro_mid = snap.micro[1..GATE_COUNT.saturating_sub(1)]
|
||||
.iter()
|
||||
.copied()
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let target_micro = snap.micro.get(snap.target_gate).copied().unwrap_or(0);
|
||||
Some(peak_micro_mid >= PRESENCE_MICRO_THRESHOLD || target_micro >= PRESENCE_MICRO_THRESHOLD)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
|
@ -48,6 +212,52 @@ pub fn spawn_reader(port: String, baud: u32) {
|
|||
.expect("failed to spawn mmwave-reader thread");
|
||||
}
|
||||
|
||||
// ── Command frame builders (host → module) ──────────────────────────
|
||||
|
||||
const CMD_HEAD: [u8; 4] = [0xFD, 0xFC, 0xFB, 0xFA];
|
||||
const CMD_FOOT: [u8; 4] = [0x04, 0x03, 0x02, 0x01];
|
||||
|
||||
fn build_cmd(cmd: u16, data: &[u8]) -> Vec<u8> {
|
||||
let body_len: u16 = (2 + data.len()) as u16;
|
||||
let mut out = Vec::with_capacity(4 + 2 + 2 + data.len() + 4);
|
||||
out.extend_from_slice(&CMD_HEAD);
|
||||
out.extend_from_slice(&body_len.to_le_bytes());
|
||||
out.extend_from_slice(&cmd.to_le_bytes());
|
||||
out.extend_from_slice(data);
|
||||
out.extend_from_slice(&CMD_FOOT);
|
||||
out
|
||||
}
|
||||
|
||||
fn enable_engineering_mode(serial: &mut dyn serialport::SerialPort) -> std::io::Result<()> {
|
||||
// 1) enable config: cmd 0x00FF, data = 01 00 (protocol-version request)
|
||||
serial.write_all(&build_cmd(0x00FF, &[0x01, 0x00]))?;
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
let _ = drain(serial);
|
||||
// 2) set work mode = engineering (0x04). Payload is 2 reserved bytes
|
||||
// followed by the u32 mode value.
|
||||
let mut mode_data = [0u8; 6];
|
||||
mode_data[2..].copy_from_slice(&0x0000_0004u32.to_le_bytes());
|
||||
serial.write_all(&build_cmd(0x0012, &mode_data))?;
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
let _ = drain(serial);
|
||||
// 3) disable config — data flow resumes (now binary).
|
||||
serial.write_all(&build_cmd(0x00FE, &[]))?;
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
let _ = drain(serial);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn drain(serial: &mut dyn serialport::SerialPort) -> std::io::Result<()> {
|
||||
let mut buf = [0u8; 256];
|
||||
for _ in 0..4 {
|
||||
match serial.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => continue,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run(port: String, baud: u32) {
|
||||
let mut serial = match serialport::new(&port, baud)
|
||||
.timeout(Duration::from_millis(500))
|
||||
|
|
@ -63,36 +273,431 @@ fn run(port: String, baud: u32) {
|
|||
}
|
||||
};
|
||||
|
||||
let mut buf = Vec::with_capacity(256);
|
||||
let mut tmp = [0u8; 128];
|
||||
// Try to switch into Engineering Mode. If it fails the loop below
|
||||
// still parses ASCII `distance:NNN\r\n` Normal-Mode lines, so the
|
||||
// distance pill keeps working even when commands can't get through.
|
||||
let engineering_mode = match enable_engineering_mode(serial.as_mut()) {
|
||||
Ok(()) => {
|
||||
tracing::info!(
|
||||
"ADR-122 mmWave: Engineering Mode enabled (per-gate energies @ {} Hz)",
|
||||
MMWAVE_SAMPLE_RATE_HZ
|
||||
);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"ADR-122 mmWave: engineering-mode setup failed ({e}); falling back to ASCII Normal Mode"
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
let mut buf: Vec<u8> = Vec::with_capacity(2048);
|
||||
let mut tmp = [0u8; 1024];
|
||||
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; }
|
||||
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:?}");
|
||||
|
||||
if engineering_mode {
|
||||
// Binary frames only. The engineering payload contains
|
||||
// arbitrary bytes (including 0x0A), so we must NOT run the
|
||||
// ASCII line-drain or it will chop partial frames in half
|
||||
// mid-buffer. Lesson learned: at 6 Hz × 141 bytes the
|
||||
// tail of every read is usually a partial frame, and the
|
||||
// ASCII drain destroyed ~80 % of them in our first cut.
|
||||
consume_binary_frames(&mut buf);
|
||||
} else {
|
||||
// Normal Mode fallback: ASCII `distance:NNN\r\n` 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) {
|
||||
ingest_distance(cm);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Guard against runaway buffer if module emits non-newline garbage.
|
||||
if buf.len() > 1024 { buf.clear(); }
|
||||
|
||||
if buf.len() > 8192 {
|
||||
// Runaway buffer guard.
|
||||
buf.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Locate and consume any complete binary engineering frames inside
|
||||
/// `buf`. Anything between frames is kept (ASCII lines are processed
|
||||
/// separately by the caller).
|
||||
fn consume_binary_frames(buf: &mut Vec<u8>) {
|
||||
loop {
|
||||
let Some(start) = find_subseq(buf, &DATA_HEAD) else {
|
||||
// Trim head if buffer is huge and no header is in sight.
|
||||
if buf.len() > 4096 {
|
||||
let keep = std::cmp::min(8, buf.len());
|
||||
let new_buf = buf.split_off(buf.len() - keep);
|
||||
*buf = new_buf;
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
// Need head(4) + len(2) + at least 1 byte of payload to know size.
|
||||
if buf.len() < start + 4 + 2 + 1 {
|
||||
// Wait for more data.
|
||||
// Drop the prefix in front of the header to avoid scanning it forever.
|
||||
if start > 0 {
|
||||
buf.drain(..start);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let len_bytes = &buf[start + 4..start + 6];
|
||||
let payload_len = u16::from_le_bytes([len_bytes[0], len_bytes[1]]) as usize;
|
||||
let end = start + 4 + 2 + payload_len + 4; // head + len + payload + foot
|
||||
if buf.len() < end {
|
||||
if start > 0 {
|
||||
buf.drain(..start);
|
||||
}
|
||||
return; // wait for more
|
||||
}
|
||||
|
||||
// Footer check; if missing, drop just the header and keep scanning.
|
||||
if buf[end - 4..end] != DATA_FOOT {
|
||||
buf.drain(..start + 4);
|
||||
continue;
|
||||
}
|
||||
|
||||
let payload = &buf[start + 6..start + 6 + payload_len];
|
||||
parse_engineering_payload(payload);
|
||||
buf.drain(..end);
|
||||
}
|
||||
}
|
||||
|
||||
fn find_subseq(hay: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
hay.windows(needle.len()).position(|w| w == needle)
|
||||
}
|
||||
|
||||
/// Parse the payload (between length field and footer) of an
|
||||
/// engineering data frame and feed it into the global state.
|
||||
fn parse_engineering_payload(payload: &[u8]) {
|
||||
if payload.len() < ENG_PRELUDE_BYTES {
|
||||
return;
|
||||
}
|
||||
if payload[0] != FRAME_TYPE_ENGINEERING {
|
||||
return;
|
||||
}
|
||||
if payload.len() < ENG_PAYLOAD_LEN as usize {
|
||||
return;
|
||||
}
|
||||
let distance_cm = u16::from_le_bytes([payload[1], payload[2]]) as u32;
|
||||
|
||||
// Gates start after the 11-byte prelude.
|
||||
let mut motion = [0u32; GATE_COUNT];
|
||||
let mut micro = [0u32; GATE_COUNT];
|
||||
let motion_start = ENG_PRELUDE_BYTES;
|
||||
let micro_start = motion_start + GATE_COUNT * GATE_BYTES;
|
||||
for (i, slot) in motion.iter_mut().enumerate() {
|
||||
let off = motion_start + i * GATE_BYTES;
|
||||
*slot = u32::from_le_bytes([
|
||||
payload[off], payload[off + 1], payload[off + 2], payload[off + 3],
|
||||
]);
|
||||
}
|
||||
for (i, slot) in micro.iter_mut().enumerate() {
|
||||
let off = micro_start + i * GATE_BYTES;
|
||||
*slot = u32::from_le_bytes([
|
||||
payload[off], payload[off + 1], payload[off + 2], payload[off + 3],
|
||||
]);
|
||||
}
|
||||
|
||||
// Target gate selection — three layered heuristics, in priority:
|
||||
//
|
||||
// 1. The gate that brackets the reported `distance_cm` value
|
||||
// (each gate is 0.7 m wide). This is the gate where the body
|
||||
// is physically located, and where chest-induced micromotion
|
||||
// should be most pronounced.
|
||||
//
|
||||
// 2. If that gate's micromotion energy is too low (e.g. the
|
||||
// distance reading is stale or the radar guessed wrong), fall
|
||||
// back to the gate with the highest micromotion energy among
|
||||
// the mid-range gates 1..GATE_COUNT-2 (gate 0 is dominated by
|
||||
// near-field clutter; the last gate is usually empty).
|
||||
//
|
||||
// 3. Final fallback: gate 1, which is the most common torso
|
||||
// distance for a seated operator.
|
||||
let dist_gate = ((distance_cm as f64) / 70.0).floor() as usize;
|
||||
let target_gate = if dist_gate < GATE_COUNT && micro[dist_gate] > 0 {
|
||||
dist_gate
|
||||
} else {
|
||||
// Pick the micro-peak from mid-range gates only.
|
||||
let lo = 1usize;
|
||||
let hi = GATE_COUNT.saturating_sub(1).max(lo + 1);
|
||||
micro[lo..hi]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by_key(|(_, &e)| e)
|
||||
.map(|(i, _)| i + lo)
|
||||
.unwrap_or(1)
|
||||
};
|
||||
|
||||
// Near-field detection: motion[0] is in the antenna's near-zone.
|
||||
// When the operator is closer than ~70 cm, two telltale things
|
||||
// happen in the gate snapshot:
|
||||
//
|
||||
// 1. motion[0] dominates gates 1..14 (the body is at 0–70 cm
|
||||
// so the strongest reflector is in the first bin).
|
||||
// 2. micro[0] is ~0 because the antenna's near-zone phase
|
||||
// pattern can't resolve sub-cm chest movement.
|
||||
//
|
||||
// We require *both* conditions, then debounce across 6 frames
|
||||
// (≈1 s @ 6 Hz) so a single jitter doesn't toggle the flag — the
|
||||
// gate energies are noisy and motion[0] swings 5k–30k frame to
|
||||
// frame when the operator is breathing right against the module.
|
||||
let peak_motion_mid = motion[1..GATE_COUNT.saturating_sub(1)]
|
||||
.iter()
|
||||
.copied()
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let near_field_now = motion[0] > 5_000
|
||||
&& motion[0] >= peak_motion_mid
|
||||
&& micro[0] < 3_000;
|
||||
let near_field = update_near_field_state(near_field_now);
|
||||
|
||||
let now = Instant::now();
|
||||
*gates_slot().lock().unwrap() = Some(GateSnapshot {
|
||||
motion,
|
||||
micro,
|
||||
target_gate,
|
||||
at: now,
|
||||
});
|
||||
*latest().lock().unwrap() = Some(MmwaveReading {
|
||||
distance_cm,
|
||||
near_field,
|
||||
at: now,
|
||||
});
|
||||
|
||||
// Push each gate's micromotion energy into its rolling history. We
|
||||
// keep all gates rather than only the current target — the target
|
||||
// can drift between samples and using a fixed gate over the FFT
|
||||
// window avoids discontinuity in the time-series.
|
||||
{
|
||||
let mut hist = hr_history().lock().unwrap();
|
||||
for (i, &e) in micro.iter().enumerate() {
|
||||
let q = &mut hist[i];
|
||||
// log-compress to suppress dynamic range and keep cardiac
|
||||
// ripple visible against breathing baseline.
|
||||
let v = if e > 0 { (e as f64).ln() } else { 0.0 };
|
||||
q.push_back(v);
|
||||
while q.len() > GATE_HISTORY_LEN {
|
||||
q.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Presence gate: peak micromotion in mid-range gates. If nobody
|
||||
// is in the beam this is dominated by clutter at ~1–5k. We refuse
|
||||
// to publish vitals below the threshold so the operator stops
|
||||
// seeing a confident-looking HR number from pure noise.
|
||||
let peak_micro_mid = micro[1..GATE_COUNT.saturating_sub(1)]
|
||||
.iter()
|
||||
.copied()
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let target_micro = micro.get(target_gate).copied().unwrap_or(0);
|
||||
let present = peak_micro_mid >= PRESENCE_MICRO_THRESHOLD
|
||||
|| target_micro >= PRESENCE_MICRO_THRESHOLD;
|
||||
|
||||
// ── breathing: distance time-series via existing detector ──────
|
||||
let cm_f = distance_cm as f64;
|
||||
let mut vs = br_detector().lock().unwrap().process_frame(&[cm_f], &[]);
|
||||
|
||||
if !present {
|
||||
// Keep the breathing detector's buffer warm so we don't lose
|
||||
// 30 s of warm-up the moment a target reappears, but null the
|
||||
// *output* so the UI shows "—" instead of a confident-looking
|
||||
// hallucination of vitals.
|
||||
vs.breathing_rate_bpm = None;
|
||||
vs.breathing_confidence = 0.0;
|
||||
vs.heart_rate_bpm = None;
|
||||
vs.heartbeat_confidence = 0.0;
|
||||
} else {
|
||||
// ── heart rate: per-gate micromotion FFT in HR band ────────
|
||||
let (hr_bpm, hr_conf) = compute_heart_rate(target_gate);
|
||||
vs.heart_rate_bpm = hr_bpm;
|
||||
vs.heartbeat_confidence = hr_conf;
|
||||
}
|
||||
|
||||
*vitals_slot().lock().unwrap() = Some(vs);
|
||||
}
|
||||
|
||||
/// Run a bandpass + FFT-peak search on the target gate's micromotion
|
||||
/// energy history, returning (BPM, confidence) in the 40–180 BPM range
|
||||
/// (0.667–3.0 Hz). Returns (None, 0.0) until the buffer is full.
|
||||
///
|
||||
/// We reuse the same bandpass+FFT code path used by the WiFi-CSI
|
||||
/// detector by spinning up a one-shot VitalSignDetector and feeding it
|
||||
/// the recent history — that gives us identical confidence semantics
|
||||
/// and avoids re-implementing peak detection.
|
||||
fn compute_heart_rate(target_gate: usize) -> (Option<f64>, f64) {
|
||||
use crate::vital_signs::bandpass_filter;
|
||||
|
||||
let hist = hr_history().lock().unwrap();
|
||||
let q = match hist.get(target_gate) {
|
||||
Some(q) => q,
|
||||
None => return (None, 0.0),
|
||||
};
|
||||
if q.len() < GATE_HISTORY_LEN / 2 {
|
||||
return (None, 0.0); // buffer still warming up (≥90 samples)
|
||||
}
|
||||
let samples: Vec<f64> = q.iter().copied().collect();
|
||||
drop(hist);
|
||||
|
||||
// Heart rate band: 0.8–2.0 Hz = 48–120 BPM at adult resting.
|
||||
let filtered = bandpass_filter(&samples, 0.8, 2.0, MMWAVE_SAMPLE_RATE_HZ);
|
||||
let (peak_freq, peak_mag, band_mean) = fft_peak_in_band(&filtered, 0.8, 2.0, MMWAVE_SAMPLE_RATE_HZ);
|
||||
|
||||
if peak_freq <= 0.0 || band_mean <= f64::EPSILON {
|
||||
return (None, 0.0);
|
||||
}
|
||||
let bpm = peak_freq * 60.0;
|
||||
let ratio = peak_mag / band_mean;
|
||||
// Same confidence shape as VitalSignDetector (threshold ~ 1.5):
|
||||
// ratio≥1.5 → high confidence, 1.0..1.5 → low.
|
||||
let confidence = if ratio >= 1.5 {
|
||||
((ratio - 1.0) / 2.0).clamp(0.0, 1.0)
|
||||
} else {
|
||||
((ratio - 1.0) / 0.5 * 0.5).clamp(0.0, 0.5)
|
||||
};
|
||||
(Some(bpm), confidence)
|
||||
}
|
||||
|
||||
/// Naive radix-2 FFT magnitude peak search inside `[min_hz, max_hz]`.
|
||||
/// Returns (peak_freq_hz, peak_mag, band_mean). Used only by the HR
|
||||
/// path; the breathing path goes through the shared detector.
|
||||
fn fft_peak_in_band(signal: &[f64], min_hz: f64, max_hz: f64, sample_rate: f64) -> (f64, f64, f64) {
|
||||
let n = signal.len().next_power_of_two().max(64);
|
||||
let mut re = vec![0.0f64; n];
|
||||
let mut im = vec![0.0f64; n];
|
||||
re[..signal.len()].copy_from_slice(signal);
|
||||
// Hann window so leakage doesn't kill the peak-to-mean ratio.
|
||||
for i in 0..signal.len() {
|
||||
let w = 0.5 - 0.5 * ((2.0 * std::f64::consts::PI * i as f64) / (signal.len() as f64 - 1.0)).cos();
|
||||
re[i] *= w;
|
||||
}
|
||||
fft_inplace(&mut re, &mut im);
|
||||
let half = n / 2;
|
||||
let bin_hz = sample_rate / n as f64;
|
||||
let min_bin = (min_hz / bin_hz).floor() as usize;
|
||||
let max_bin = ((max_hz / bin_hz).ceil() as usize).min(half - 1);
|
||||
if max_bin <= min_bin {
|
||||
return (0.0, 0.0, 0.0);
|
||||
}
|
||||
let mut peak_mag = 0.0;
|
||||
let mut peak_bin = min_bin;
|
||||
let mut band_sum = 0.0;
|
||||
let mut band_count = 0usize;
|
||||
for bin in min_bin..=max_bin {
|
||||
let m = (re[bin] * re[bin] + im[bin] * im[bin]).sqrt();
|
||||
band_sum += m;
|
||||
band_count += 1;
|
||||
if m > peak_mag {
|
||||
peak_mag = m;
|
||||
peak_bin = bin;
|
||||
}
|
||||
}
|
||||
let band_mean = if band_count > 0 { band_sum / band_count as f64 } else { 0.0 };
|
||||
// Parabolic interpolation for sub-bin frequency.
|
||||
let freq = if peak_bin > 0 && peak_bin + 1 < half {
|
||||
let am = (re[peak_bin - 1] * re[peak_bin - 1] + im[peak_bin - 1] * im[peak_bin - 1]).sqrt();
|
||||
let ap = (re[peak_bin + 1] * re[peak_bin + 1] + im[peak_bin + 1] * im[peak_bin + 1]).sqrt();
|
||||
let denom = am - 2.0 * peak_mag + ap;
|
||||
let shift = if denom.abs() > f64::EPSILON { 0.5 * (am - ap) / denom } else { 0.0 };
|
||||
(peak_bin as f64 + shift) * bin_hz
|
||||
} else {
|
||||
peak_bin as f64 * bin_hz
|
||||
};
|
||||
(freq, peak_mag, band_mean)
|
||||
}
|
||||
|
||||
fn fft_inplace(re: &mut [f64], im: &mut [f64]) {
|
||||
let n = re.len();
|
||||
debug_assert!(n.is_power_of_two() && im.len() == n);
|
||||
// Bit-reverse permutation
|
||||
let mut j = 0usize;
|
||||
for i in 1..n {
|
||||
let mut bit = n >> 1;
|
||||
while j & bit != 0 {
|
||||
j ^= bit;
|
||||
bit >>= 1;
|
||||
}
|
||||
j ^= bit;
|
||||
if i < j {
|
||||
re.swap(i, j);
|
||||
im.swap(i, j);
|
||||
}
|
||||
}
|
||||
// Cooley–Tukey
|
||||
let mut size = 2usize;
|
||||
while size <= n {
|
||||
let half = size / 2;
|
||||
let table_step = std::f64::consts::PI / half as f64;
|
||||
for chunk in (0..n).step_by(size) {
|
||||
for k in 0..half {
|
||||
let angle = -table_step * k as f64;
|
||||
let (wr, wi) = (angle.cos(), angle.sin());
|
||||
let tr = re[chunk + k + half] * wr - im[chunk + k + half] * wi;
|
||||
let ti = re[chunk + k + half] * wi + im[chunk + k + half] * wr;
|
||||
re[chunk + k + half] = re[chunk + k] - tr;
|
||||
im[chunk + k + half] = im[chunk + k] - ti;
|
||||
re[chunk + k] += tr;
|
||||
im[chunk + k] += ti;
|
||||
}
|
||||
}
|
||||
size *= 2;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sliding-window debounce for the near-field flag. Tracks how many
|
||||
/// of the last 6 frames met the raw condition; toggles to true once
|
||||
/// ≥4/6 agree, falls back to false once ≤1/6 do. This prevents UI
|
||||
/// flicker when motion[0] briefly dips during a breath cycle.
|
||||
fn update_near_field_state(near_now: bool) -> bool {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
// Bit 0..6 = last 6 frames (1 = near), bit 7 = current debounced state.
|
||||
static STATE: AtomicU32 = AtomicU32::new(0);
|
||||
let mut s = STATE.load(Ordering::Relaxed);
|
||||
let history = ((s & 0x3F) << 1) & 0x3F | (near_now as u32);
|
||||
let mut latched = (s >> 7) & 1 == 1;
|
||||
let count = history.count_ones();
|
||||
if !latched && count >= 4 {
|
||||
latched = true;
|
||||
} else if latched && count <= 1 {
|
||||
latched = false;
|
||||
}
|
||||
s = history | ((latched as u32) << 7);
|
||||
STATE.store(s, Ordering::Relaxed);
|
||||
latched
|
||||
}
|
||||
|
||||
fn ingest_distance(cm: u32) {
|
||||
let now = Instant::now();
|
||||
*latest().lock().unwrap() = Some(MmwaveReading {
|
||||
distance_cm: cm,
|
||||
near_field: false, // ASCII fallback has no gate info
|
||||
at: now,
|
||||
});
|
||||
// Breathing-only update on ASCII fallback (no per-gate data).
|
||||
let cm_f = cm as f64;
|
||||
let vs = br_detector().lock().unwrap().process_frame(&[cm_f], &[]);
|
||||
*vitals_slot().lock().unwrap() = Some(vs);
|
||||
}
|
||||
|
||||
/// Parse `distance:<digits>` (HLK-LD2402 Normal Mode line format).
|
||||
pub fn parse_distance(line: &str) -> Option<u32> {
|
||||
let lower = line.trim().to_ascii_lowercase();
|
||||
|
|
@ -113,4 +718,40 @@ mod tests {
|
|||
assert_eq!(parse_distance(""), None);
|
||||
assert_eq!(parse_distance("distance:abc"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engineering_frame_round_trip() {
|
||||
// Build a synthetic engineering frame and feed it through the
|
||||
// payload parser. Expected: distance + per-gate energies stored.
|
||||
let mut payload = Vec::new();
|
||||
payload.push(FRAME_TYPE_ENGINEERING);
|
||||
payload.extend_from_slice(&151u16.to_le_bytes());
|
||||
payload.extend_from_slice(&[0; 8]);
|
||||
for i in 0..GATE_COUNT {
|
||||
payload.extend_from_slice(&((1000 + i as u32) * 10).to_le_bytes());
|
||||
}
|
||||
for i in 0..GATE_COUNT {
|
||||
payload.extend_from_slice(&((500 + i as u32) * 5).to_le_bytes());
|
||||
}
|
||||
assert_eq!(payload.len(), ENG_PAYLOAD_LEN as usize);
|
||||
parse_engineering_payload(&payload);
|
||||
let snap = gates_slot().lock().unwrap().clone().expect("snap recorded");
|
||||
assert_eq!(snap.motion[0], 10000);
|
||||
assert_eq!(snap.micro[14], (500 + 14) * 5);
|
||||
let dist = latest().lock().unwrap().expect("dist recorded");
|
||||
assert_eq!(dist.distance_cm, 151);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_cmd_layout() {
|
||||
// Enable-config command per probe:
|
||||
// FD FC FB FA 04 00 FF 00 01 00 04 03 02 01
|
||||
let bytes = build_cmd(0x00FF, &[0x01, 0x00]);
|
||||
assert_eq!(
|
||||
bytes,
|
||||
vec![
|
||||
0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x01, 0x00, 0x04, 0x03, 0x02, 0x01
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,16 +48,25 @@
|
|||
<span class="pill" id="lastTs">last: --</span>
|
||||
<span class="badge absent" id="globalBadge" style="font-size:13px;padding:4px 12px;">absent</span>
|
||||
<span class="pill" id="globalCV">CV 0%</span>
|
||||
<!-- ADR-021: WiFi-CSI vital signs — breathing + heart rate (computed server-side). -->
|
||||
<!-- ADR-021 + ADR-121: dual-source vital signs.
|
||||
Each pill shows WiFi-CSI (📶) and mmWave (📡) side-by-side so
|
||||
the operator can spot disagreement between modalities. Norms
|
||||
are adult-at-rest ranges. -->
|
||||
<span class="pill" id="brPill"
|
||||
style="background:rgba(63,185,80,0.18); color:rgb(63,185,80); border:1px solid rgb(63,185,80);"
|
||||
title="WiFi-CSI breathing rate from bandpass 0.1-0.5 Hz on broadband amplitude (ADR-021)">
|
||||
🫁 <b id="brBpm">— BPM</b> <span id="brConf" style="opacity:0.7;font-size:11px">·</span>
|
||||
title="Breathing rate, two sources: 📶 = WiFi-CSI bandpass 0.1-0.5 Hz (ADR-021). 📡 = HLK-LD2402 mmWave distance bandpass (ADR-121). Adult-at-rest norm: 12-20 BPM. Below 12 = bradypnea, above 20 = tachypnea.">
|
||||
🫁 <span style="opacity:0.6;font-size:11px">📶</span><b id="brBpm">— BPM</b><span id="brConf" style="opacity:0.7;font-size:11px">·</span>
|
||||
<span style="opacity:0.4">|</span>
|
||||
<span style="opacity:0.6;font-size:11px">📡</span><b id="brBpmMm">— BPM</b><span id="brConfMm" style="opacity:0.7;font-size:11px">·</span>
|
||||
<span style="opacity:0.55;font-size:11px;margin-left:6px">норма 12–20</span>
|
||||
</span>
|
||||
<span class="pill" id="hrPill"
|
||||
style="background:rgba(248,81,73,0.18); color:rgb(248,81,73); border:1px solid rgb(248,81,73);"
|
||||
title="WiFi-CSI heart rate from bandpass 0.8-2.0 Hz on broadband amplitude (ADR-021)">
|
||||
💓 <b id="hrBpm">— BPM</b> <span id="hrConf" style="opacity:0.7;font-size:11px">·</span>
|
||||
title="Heart rate, two sources: 📶 = WiFi-CSI bandpass 0.8-2.0 Hz (ADR-021). 📡 = HLK-LD2402 mmWave Engineering Mode — bandpass on the micromotion-gate energy time-series at the target's range bin (ADR-122). Adult-at-rest norm: 60-100 BPM.">
|
||||
💓 <span style="opacity:0.6;font-size:11px">📶</span><b id="hrBpm">— BPM</b><span id="hrConf" style="opacity:0.7;font-size:11px">·</span>
|
||||
<span style="opacity:0.4">|</span>
|
||||
<span style="opacity:0.6;font-size:11px">📡</span><b id="hrBpmMm">— BPM</b><span id="hrConfMm" style="opacity:0.7;font-size:11px">·</span>
|
||||
<span style="opacity:0.55;font-size:11px;margin-left:6px">норма 60–100</span>
|
||||
</span>
|
||||
<!-- ADR-121: HLK-LD2402 24 GHz mmWave radar pill — hidden until first reading. -->
|
||||
<span class="pill" id="mmwavePill" style="display:none; background:rgba(33,150,243,0.18);
|
||||
|
|
@ -379,21 +388,36 @@ function handleSensingUpdate(d) {
|
|||
const hrConf = document.getElementById('hrConf');
|
||||
const brPill = document.getElementById('brPill');
|
||||
const hrPill = document.getElementById('hrPill');
|
||||
// Adult-at-rest norms (resting). Out-of-norm values get a warning tint
|
||||
// so the operator immediately sees brady/tachy without reading numbers.
|
||||
// Tooltip on the pill carries the brady/tachy terminology.
|
||||
const BR_MIN = 12, BR_MAX = 20; // breaths per minute
|
||||
const HR_MIN = 60, HR_MAX = 100; // heartbeats per minute
|
||||
if (vs && typeof vs.breathing_rate_bpm === 'number' && Number.isFinite(vs.breathing_rate_bpm) && vs.breathing_rate_bpm > 0) {
|
||||
if (brBpm) brBpm.textContent = vs.breathing_rate_bpm.toFixed(1) + ' BPM';
|
||||
const v = vs.breathing_rate_bpm;
|
||||
const out = v < BR_MIN || v > BR_MAX;
|
||||
if (brBpm) {
|
||||
brBpm.textContent = v.toFixed(1) + ' BPM';
|
||||
brBpm.style.color = out ? '#f0a020' : '';
|
||||
}
|
||||
if (brConf) brConf.textContent = '· ' + ((vs.breathing_confidence || 0) * 100).toFixed(0) + '%';
|
||||
if (brPill) brPill.style.opacity = (vs.breathing_confidence || 0) < 0.2 ? '0.5' : '1.0';
|
||||
} else {
|
||||
if (brBpm) brBpm.textContent = '— BPM';
|
||||
if (brBpm) { brBpm.textContent = '— BPM'; brBpm.style.color = ''; }
|
||||
if (brConf) brConf.textContent = '·';
|
||||
if (brPill) brPill.style.opacity = '0.5';
|
||||
}
|
||||
if (vs && typeof vs.heart_rate_bpm === 'number' && Number.isFinite(vs.heart_rate_bpm) && vs.heart_rate_bpm > 0) {
|
||||
if (hrBpm) hrBpm.textContent = vs.heart_rate_bpm.toFixed(0) + ' BPM';
|
||||
const v = vs.heart_rate_bpm;
|
||||
const out = v < HR_MIN || v > HR_MAX;
|
||||
if (hrBpm) {
|
||||
hrBpm.textContent = v.toFixed(0) + ' BPM';
|
||||
hrBpm.style.color = out ? '#f0a020' : '';
|
||||
}
|
||||
if (hrConf) hrConf.textContent = '· ' + ((vs.heartbeat_confidence || 0) * 100).toFixed(0) + '%';
|
||||
if (hrPill) hrPill.style.opacity = (vs.heartbeat_confidence || 0) < 0.2 ? '0.5' : '1.0';
|
||||
} else {
|
||||
if (hrBpm) hrBpm.textContent = '— BPM';
|
||||
if (hrBpm) { hrBpm.textContent = '— BPM'; hrBpm.style.color = ''; }
|
||||
if (hrConf) hrConf.textContent = '·';
|
||||
if (hrPill) hrPill.style.opacity = '0.5';
|
||||
}
|
||||
|
|
@ -565,7 +589,18 @@ async function pollMmwave() {
|
|||
const j = await r.json();
|
||||
if (j && j.available) {
|
||||
mmwavePill.style.display = '';
|
||||
mmwaveDist.textContent = j.distance_cm + ' cm';
|
||||
// ADR-122 hardware quirk: at <70 cm the radar's gate-0 micro is
|
||||
// dead and its distance algorithm returns a sidelobe pick
|
||||
// (~1.5–2 m). The server flags this as `near_field`; we
|
||||
// override the display so the operator doesn't read the bogus
|
||||
// 161 cm reading as truth.
|
||||
if (j.near_field) {
|
||||
mmwaveDist.textContent = '<70 cm';
|
||||
mmwavePill.title = 'Цель в ближней зоне антенны (<70 cm). Радар тебя видит, но точная дистанция и пульс/дыхание ненадёжны на таком расстоянии. Отодвинься на 0.7–2 м для рабочего диапазона.';
|
||||
} else {
|
||||
mmwaveDist.textContent = j.distance_cm + ' cm';
|
||||
mmwavePill.title = 'HLK-LD2402 24 GHz radar — distance to closest target';
|
||||
}
|
||||
const age = Math.round(j.age_ms || 0);
|
||||
mmwaveAge.textContent = '· ' + age + ' ms';
|
||||
// Fade pill if stale (>1.5 s) before server hides at 2 s.
|
||||
|
|
@ -579,5 +614,70 @@ async function pollMmwave() {
|
|||
}
|
||||
pollMmwave();
|
||||
setInterval(pollMmwave, 200);
|
||||
|
||||
// ── ADR-121 follow-up: poll mmWave-derived vital signs @ 1 Hz ────────
|
||||
// Slower than the distance poll (5 Hz) — BPM only meaningfully updates
|
||||
// once per breath cycle, and the FFT runs on a 30-s buffer so faster
|
||||
// polling just wastes cycles.
|
||||
const brBpmMm = document.getElementById('brBpmMm');
|
||||
const brConfMm = document.getElementById('brConfMm');
|
||||
const hrBpmMm = document.getElementById('hrBpmMm');
|
||||
const hrConfMm = document.getElementById('hrConfMm');
|
||||
let mmVitalsBusy = false;
|
||||
async function pollMmwaveVitals() {
|
||||
if (mmVitalsBusy) return; mmVitalsBusy = true;
|
||||
try {
|
||||
const r = await fetch('/api/v1/mmwave/vitals', { cache: 'no-store' });
|
||||
if (!r.ok) throw new Error('http ' + r.status);
|
||||
const j = await r.json();
|
||||
const vs = (j && j.available) ? (j.vital_signs || {}) : {};
|
||||
const BR_MIN = 12, BR_MAX = 20, HR_MIN = 60, HR_MAX = 100;
|
||||
if (typeof vs.breathing_rate_bpm === 'number' && Number.isFinite(vs.breathing_rate_bpm) && vs.breathing_rate_bpm > 0) {
|
||||
const v = vs.breathing_rate_bpm;
|
||||
const out = v < BR_MIN || v > BR_MAX;
|
||||
brBpmMm.textContent = ' ' + v.toFixed(1) + ' BPM ';
|
||||
brBpmMm.style.color = out ? '#f0a020' : '';
|
||||
brConfMm.textContent = '· ' + ((vs.breathing_confidence || 0) * 100).toFixed(0) + '%';
|
||||
} else {
|
||||
brBpmMm.textContent = ' — BPM ';
|
||||
brBpmMm.style.color = '';
|
||||
// Three reasons to show "—":
|
||||
// 1. no presence (radar sees no body in beam) → "нет цели"
|
||||
// 2. buffer warming up (first 30 s) → "NN/180"
|
||||
// 3. presence ok but FFT failed (rare) → "·"
|
||||
if (j && j.available && j.presence === false) {
|
||||
brConfMm.textContent = '· нет цели';
|
||||
} else {
|
||||
const fill = j && j.buffer_status ? j.buffer_status.breathing_samples : 0;
|
||||
const cap = j && j.buffer_status ? j.buffer_status.breathing_capacity : 180;
|
||||
brConfMm.textContent = cap > 0 && fill < cap ? '· ' + fill + '/' + cap : '·';
|
||||
}
|
||||
}
|
||||
if (typeof vs.heart_rate_bpm === 'number' && Number.isFinite(vs.heart_rate_bpm) && vs.heart_rate_bpm > 0) {
|
||||
const v = vs.heart_rate_bpm;
|
||||
const out = v < HR_MIN || v > HR_MAX;
|
||||
hrBpmMm.textContent = ' ' + v.toFixed(0) + ' BPM ';
|
||||
hrBpmMm.style.color = out ? '#f0a020' : '';
|
||||
hrConfMm.textContent = '· ' + ((vs.heartbeat_confidence || 0) * 100).toFixed(0) + '%';
|
||||
} else {
|
||||
// ADR-122: HR only published when presence-gate passes (target
|
||||
// micromotion energy above clutter threshold). Show *why* the
|
||||
// value is missing so the operator can tell "warming up" from
|
||||
// "nobody in beam" from "module fell back to Normal Mode".
|
||||
hrBpmMm.textContent = ' — BPM ';
|
||||
hrBpmMm.style.color = '';
|
||||
if (j && j.available && j.presence === false) {
|
||||
hrConfMm.textContent = '· нет цели';
|
||||
} else {
|
||||
hrConfMm.textContent = '·';
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
brBpmMm.textContent = ' — BPM '; brConfMm.textContent = '·';
|
||||
hrBpmMm.textContent = ' — BPM '; hrConfMm.textContent = '·';
|
||||
} finally { mmVitalsBusy = false; }
|
||||
}
|
||||
pollMmwaveVitals();
|
||||
setInterval(pollMmwaveVitals, 1000);
|
||||
</script>
|
||||
</body></html>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
ADR-122 prep probe — switch HLK-LD2402 into Engineering Mode and
|
||||
dump the first few binary frames so we can verify per-gate layout
|
||||
before committing to a Rust parser.
|
||||
|
||||
Protocol (Hi-Link, reverse-engineered from ESPHome driver):
|
||||
|
||||
Command frame (host -> module):
|
||||
FD FC FB FA (header)
|
||||
len_lo len_hi (LE u16; counts cmd+data bytes)
|
||||
cmd_lo cmd_hi (LE u16)
|
||||
[data...]
|
||||
04 03 02 01 (footer)
|
||||
|
||||
Sequence to enable engineering mode:
|
||||
1. CMD 0x00FF (enable config), data = 01 00
|
||||
2. CMD 0x0012 (set work mode), data = 00 00 04 00 00 00 (mode=0x04)
|
||||
3. CMD 0x00FE (disable config), data = ()
|
||||
|
||||
Data frame (module -> host) in Engineering Mode:
|
||||
F4 F3 F2 F1 (data header)
|
||||
LL LL (length, LE u16, payload bytes)
|
||||
84 (frame type = engineering)
|
||||
... (per-gate energy, u32 LE per gate)
|
||||
F8 F7 F6 F5 (data footer)
|
||||
"""
|
||||
import argparse, struct, sys, time
|
||||
|
||||
import serial # pyserial
|
||||
|
||||
HEAD = b"\xFD\xFC\xFB\xFA"
|
||||
FOOT = b"\x04\x03\x02\x01"
|
||||
DATA_HEAD = b"\xF4\xF3\xF2\xF1"
|
||||
DATA_FOOT = b"\xF8\xF7\xF6\xF5"
|
||||
|
||||
def frame(cmd: int, data: bytes = b"") -> bytes:
|
||||
body = struct.pack("<H", cmd) + data
|
||||
return HEAD + struct.pack("<H", len(body)) + body + FOOT
|
||||
|
||||
def hex_dump(b: bytes, n: int = 64) -> str:
|
||||
return " ".join(f"{x:02X}" for x in b[:n]) + (" ..." if len(b) > n else "")
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--port", default="/dev/cu.usbserial-1140")
|
||||
ap.add_argument("--baud", type=int, default=115200)
|
||||
ap.add_argument("--frames", type=int, default=20, help="how many engineering frames to capture")
|
||||
ap.add_argument("--no-restore", action="store_true", help="leave module in engineering mode")
|
||||
args = ap.parse_args()
|
||||
|
||||
print(f"[probe] open {args.port} @ {args.baud}", flush=True)
|
||||
ser = serial.Serial(args.port, args.baud, timeout=0.5)
|
||||
time.sleep(0.2)
|
||||
ser.reset_input_buffer()
|
||||
|
||||
# 1. enable config mode
|
||||
pkt = frame(0x00FF, b"\x01\x00")
|
||||
print(f"[probe] -> ENABLE_CONFIG: {hex_dump(pkt)}", flush=True)
|
||||
ser.write(pkt); time.sleep(0.3)
|
||||
print(f"[probe] <- {hex_dump(ser.read(64))}", flush=True)
|
||||
|
||||
# 2. set work mode = engineering (0x04)
|
||||
mode_data = b"\x00\x00" + struct.pack("<I", 0x04)
|
||||
pkt = frame(0x0012, mode_data)
|
||||
print(f"[probe] -> SET_MODE engineering: {hex_dump(pkt)}", flush=True)
|
||||
ser.write(pkt); time.sleep(0.5)
|
||||
print(f"[probe] <- {hex_dump(ser.read(64))}", flush=True)
|
||||
|
||||
# 3. disable config mode (data starts flowing)
|
||||
pkt = frame(0x00FE)
|
||||
print(f"[probe] -> DISABLE_CONFIG: {hex_dump(pkt)}", flush=True)
|
||||
ser.write(pkt); time.sleep(0.3)
|
||||
print(f"[probe] <- {hex_dump(ser.read(64))}", flush=True)
|
||||
|
||||
# 4. capture engineering frames
|
||||
print(f"\n[probe] capturing {args.frames} engineering frames...", flush=True)
|
||||
buf = bytearray()
|
||||
frames_seen = 0
|
||||
t0 = time.time()
|
||||
while frames_seen < args.frames and (time.time() - t0) < 15:
|
||||
chunk = ser.read(512)
|
||||
if chunk:
|
||||
buf.extend(chunk)
|
||||
while True:
|
||||
i = buf.find(DATA_HEAD)
|
||||
if i < 0:
|
||||
if len(buf) > 4096:
|
||||
buf = buf[-4:]
|
||||
break
|
||||
# need at least header(4) + len(2) + type(1) = 7 bytes after match
|
||||
if len(buf) < i + 7:
|
||||
break
|
||||
length = buf[i+4] | (buf[i+5] << 8)
|
||||
# full frame = head(4) + len(2) + payload(length) + foot(4)
|
||||
end = i + 4 + 2 + length + 4
|
||||
if len(buf) < end:
|
||||
break
|
||||
frm = bytes(buf[i:end])
|
||||
buf = buf[end:]
|
||||
ftype = frm[6]
|
||||
print(f"\n[frame #{frames_seen}] len={length} type=0x{ftype:02X} total={len(frm)} footer={frm[-4:].hex(' ').upper()}", flush=True)
|
||||
print(f" hex: {hex_dump(frm, 160)}", flush=True)
|
||||
if ftype == 0x84 and length >= 4:
|
||||
# payload (after type byte) starts at frm[7:7+length-1]
|
||||
# ESPHome read showed gates start at offset 10 within frame_data
|
||||
# which (their frame_data = bytes from data header) means
|
||||
# offset 10 = after 4(head)+2(len)+1(type)+3 prelude bytes
|
||||
payload = frm[7:7+length-1]
|
||||
prelude = payload[:3]
|
||||
gate_bytes = payload[3:]
|
||||
n_gates = len(gate_bytes) // 4
|
||||
energies = struct.unpack(f"<{n_gates}I", gate_bytes[:n_gates*4])
|
||||
print(f" prelude({len(prelude)}): {hex_dump(prelude)}", flush=True)
|
||||
print(f" gates({n_gates}): {energies}", flush=True)
|
||||
frames_seen += 1
|
||||
if frames_seen >= args.frames:
|
||||
break
|
||||
|
||||
print(f"\n[probe] captured {frames_seen} frame(s) in {time.time()-t0:.1f}s", flush=True)
|
||||
|
||||
if not args.no_restore:
|
||||
# restore normal mode so we don't break the existing server next start
|
||||
pkt = frame(0x00FF, b"\x01\x00"); ser.write(pkt); time.sleep(0.3); ser.read(64)
|
||||
mode_data = b"\x00\x00" + struct.pack("<I", 0x64) # production
|
||||
pkt = frame(0x0012, mode_data); ser.write(pkt); time.sleep(0.3); ser.read(64)
|
||||
pkt = frame(0x00FE); ser.write(pkt); time.sleep(0.3); ser.read(64)
|
||||
print("[probe] restored normal mode", flush=True)
|
||||
|
||||
ser.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in New Issue