Compare commits

...

5 Commits

Author SHA1 Message Date
arsen 5eac273630 fix(mmwave): override distance to "<70 cm" in radar near-field
HLK-LD2402's antenna near-zone (0–70 cm) is a dead spot for its
internal distance algorithm: gate-0 micromotion energy collapses to
zero, and the firmware falls back to a sidelobe pick that lands at
1.5–2 m. Operator sitting 40 cm away saw "180 cm" jumping ±10 cm.

Detect the near-field state from the gate snapshot:

  motion[0] > 5k AND motion[0] >= peak_motion_mid AND micro[0] < 3k

Debounce across the last 6 frames (≈1 s) so a single jittery frame
doesn't toggle the UI — gate energies swing 5–30k frame-to-frame
when the target is breathing right against the module.

When the flag is set, the distance pill renders "<70 cm" with a
tooltip explaining that vitals are unreliable at this range; the
recommended sweet-spot is 0.7–2 m.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 18:49:45 +07:00
arsen ee6d9dfa80 fix(mmwave): presence-gate vitals so empty beam doesn't show a number
The FFT will always find *some* peak in 0.8-2.0 Hz, even on pure
clutter, and the peak-to-mean ratio frequently lands at 0.5-0.7
"confidence" from noise alone. Net result: the HR pill showed 75-97
BPM with 60%+ confidence while the operator was across the room
with their back to the radar.

Add a presence gate based on the target gate's micromotion energy:

  empty room       peak_micro_mid  1k-3k
  person nearby    peak_micro_mid  10k-20k
  person in beam   peak_micro_mid  40k-80k

Threshold at 20k. Below it we null both BR and HR (the breathing
detector's internal buffer is still fed so it stays warm for instant
re-acquisition).

New diagnostic endpoint GET /api/v1/mmwave/gates dumps current
motion/micro arrays + the target gate so we can re-calibrate the
threshold on new firmware.

UI: pill now shows "· нет цели" (no target) when presence=false, so
the operator can tell "buffer warming up" from "nobody in beam"
from "module fell back to Normal Mode".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 18:39:03 +07:00
arsen 81e848ef2a feat(mmwave): ADR-122 — HLK-LD2402 Engineering Mode + heart rate
ADR-121 (Normal Mode) gave us distance and a passable breathing
estimate but couldn't see the heartbeat — cardiac chest displacement
(~0.5 mm) is well below the cm quantisation of `distance:NNN`.

Engineering Mode streams per-range-gate energy at the same 6 Hz
cadence (15 motion + 15 micromotion gates, u32 LE each). The
micromotion bin at the target's distance carries enough cardiac
modulation for FFT peak-detection in the 0.8-2.0 Hz band.

Live result, seated operator ~1.5 m from the radar:

  🫁 📡 13.0 BPM · 37%   норма 12-20
  💓 📡 76 BPM · 63%     норма 60-100

Implementation:
- Send enable-config → set-mode(0x04) → disable-config on startup;
  fall back to Normal-Mode ASCII parsing if the sequence fails.
- Binary frame parser: F4 F3 F2 F1 | len(2) | 0x01 | dist(2) | 8z |
  motion[15]×u32 LE | micro[15]×u32 LE | F8 F7 F6 F5. Gate the ASCII
  line-drain on the engineering_mode flag — first cut ran both
  unconditionally and destroyed 80% of partial frames mid-buffer.
- Target-gate selection: distance-bracketed gate first, mid-range
  micro-peak fallback, gate 1 default. Per-gate ring buffer of
  log-energies feeds a Hann + radix-2 FFT.
- /api/v1/mmwave/vitals now returns real `heart_rate_bpm`.
- raw.html: 💓 📡 pill now shows real values (no more "n/a"
  placeholder).
- New probe script v2/scripts/probe_ld2402_engineering.py used to
  reverse-engineer the wire format; kept in tree for next time.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:33:14 +07:00
arsen b9d1f6361e feat(mmwave): dual-source vital signs (WiFi-CSI 📶 vs mmWave 📡)
Previously only WiFi-CSI produced breathing/HR estimates. With the
HLK-LD2402 radar wired up we can compute a second, physically
independent breathing estimate from chest-induced cm flicker in the
distance time-series — a useful cross-check that catches the case
when one modality is blind (e.g. WiFi-CSI when nodes are offline,
or mmWave when nothing's in the radar's field of view).

mmwave.rs:
- Plumb a per-reading VitalSignDetector tuned for the module's 6 Hz
  Normal-Mode cadence (Nyquist 3 Hz comfortably covers the 0.1-0.5
  Hz breathing band).
- Distance (cm) feeds the detector as the "amplitude" channel;
  phase is empty so heartbeat falls back to amplitude residual.
- Gate `current_vitals()` on data freshness so a disconnected radar
  doesn't return stale cached BPMs.

main.rs:
- New GET /api/v1/mmwave/vitals returning the same shape as
  /api/v1/vital-signs plus buffer status for UI warm-up feedback.

ui/raw.html:
- Each vital pill now shows both 📶 (WiFi-CSI) and 📡 (mmWave)
  values side-by-side, separated by `|`. mmWave HR is labelled
  "n/a" — cm precision at 6 Hz puts heartbeat below the noise
  floor. Buffer fill (e.g. "120/180") shown while detector is
  warming up so the operator knows BPM is on the way.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:02:30 +07:00
arsen 26d47a9533 ui(raw): show adult-at-rest norms next to vital pills
The breathing/HR pills carried raw BPM with no context. An operator
glancing at "94 BPM" can't tell if that's normal or tachycardia
without external reference.

Add inline "норма 12–20" / "норма 60–100" hints (dimmed so they
don't compete with the live value), and tint the number amber when
it falls outside the adult-at-rest range. Tooltip carries the
medical terminology (bradypnea/tachypnea, bradycardia/tachycardia).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 12:52:17 +07:00
6 changed files with 1239 additions and 47 deletions

View File

@ -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
510 mm, visible as cm-bin flicker), but **no heart rate** — cardiac
chest displacement (~0.30.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 = 00.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.82.0 Hz = 48120 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% норма 1220
💓 📶 — BPM · | 📡 76 BPM · 63% норма 60100
```
## 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.

View File

@ -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">норма 1220</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">норма 60100</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.52 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.72 м для рабочего диапазона.';
} 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>

View File

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

View File

@ -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 = 00.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.10.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 010.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.82.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 1k5k 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 ~1k3k
/// * person nearby : peak_micro_mid ~10k20k
/// * person in beam: peak_micro_mid ~40k80k
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.52 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 070 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 5k30k 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 ~15k. 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 40180 BPM range
/// (0.6673.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.82.0 Hz = 48120 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);
}
}
// CooleyTukey
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
]
);
}
}

View File

@ -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">норма 1220</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">норма 60100</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.52 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.72 м для рабочего диапазона.';
} 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>

View File

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