diff --git a/ui/raw.html b/ui/raw.html
index 514b04d9..9c47054f 100644
--- a/ui/raw.html
+++ b/ui/raw.html
@@ -589,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.
diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs
index 9170f316..7a6a3334 100644
--- a/v2/crates/wifi-densepose-sensing-server/src/main.rs
+++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs
@@ -5092,6 +5092,7 @@ async fn mmwave_latest() -> Json {
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!({
diff --git a/v2/crates/wifi-densepose-sensing-server/src/mmwave.rs b/v2/crates/wifi-densepose-sensing-server/src/mmwave.rs
index e620c6aa..596d5791 100644
--- a/v2/crates/wifi-densepose-sensing-server/src/mmwave.rs
+++ b/v2/crates/wifi-densepose-sensing-server/src/mmwave.rs
@@ -95,9 +95,18 @@ const GATE_HISTORY_LEN: usize = 180;
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,
}
@@ -437,6 +446,29 @@ fn parse_engineering_payload(payload: &[u8]) {
.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,
@@ -446,6 +478,7 @@ fn parse_engineering_payload(payload: &[u8]) {
});
*latest().lock().unwrap() = Some(MmwaveReading {
distance_cm,
+ near_field,
at: now,
});
@@ -630,10 +663,33 @@ fn fft_inplace(re: &mut [f64], im: &mut [f64]) {
}
}
+/// 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).
diff --git a/v2/crates/wifi-densepose-sensing-server/static/raw.html b/v2/crates/wifi-densepose-sensing-server/static/raw.html
index 514b04d9..9c47054f 100644
--- a/v2/crates/wifi-densepose-sensing-server/static/raw.html
+++ b/v2/crates/wifi-densepose-sensing-server/static/raw.html
@@ -589,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.