deploy: tp-link wisp ap + rssi-Δ presence detector + live calibration ui
Operator's household environment showed CSI-variance presence detection failing — empty room produced HIGHER variance than an occupied room because ambient WiFi noise (neighbour APs, retransmits, BT-coex) dominated the broadband-variance signal at multi-meter range. Deployed a TP-Link TL-WR841N in WISP mode as a dedicated isolated AP for the sensors: * Sensors associate only with TP-Link_8340 (clean channel) * TP-Link bridges to the household AP, NAT-forwards sensor UDP to the Mac * Mac keeps its primary household-AP association — no LAN reconfig needed * Empty-room variance dropped 50.7 → 35.8 (-30%) Replaced presence classification with RSSI MAD-Δ override: * Per-node rolling 120-sample (~10 s @ 12 Hz) window of frame RSSI * Metric: mean(|Δrssi|) between consecutive frames — robust to int8 quantisation jitter * Thresholds tuned for the operator's geometry: d < 0.20 → absent < 0.55 → present_still < 1.10 → present_moving >= 1.10 → active * Confidence field temporarily carries raw d for in-field threshold tuning * CSI-based features (variance, motion_band_power, spectral_power) remain in features.* for vital-sign signal-quality and multi-node fusion paths UI / tooling: * New static/spectrum.html — live signal console: combined classification, all host-computed features (variance, motion_band, spectral, breathing band, RSSI, dominant_freq, change_points), per-node FW signals, and a 60-second variance trace. Served via `python -m http.server 8091`. * static/calibrate.html — simpler per-node motion/presence/RSSI bars with peak-hold. Desktop UI / discovery hardening (rolled in here because they came up during this debug session): * commands/discovery.rs: HTTP sweep limited to 2..=60 hosts (was 1..=254), mDNS + UDP-broadcast paths disabled (current RuView FW doesn't advertise them and they were burning CPU every poll cycle). Per-request timeout set to 1500 ms with overall budget enforced via tokio::time::timeout + futures::join_all (replaces the previous sequential select loop that blocked on slow IPs). * ui/hooks/useNodes.ts: poll interval 10 s → 30 s. * ui/pages/Dashboard.tsx + NetworkDiscovery.tsx: merge new scan results into existing list instead of replacing — discovery races sometimes miss a node that was found a moment ago. Firmware tuning: * edge_processing.c: broadband-variance divisor /3.0 → /30.0 → /5.0 iterated; final /5.0 chosen for multi-meter geometry (sensor 1-3 m from activity zone). DEBUG_MOTION_DSP scaffolding removed. * csi_collector.c: CSI_MIN_SEND_INTERVAL_US 20 ms → 4 ms so the host can see every available frame (real ceiling is the WiFi CSI callback rate). Documentation: * docs/adr/ADR-099 — full forensic write-up: measurement tables for sit/ walk/empty, the RSSI-Δ rationale, the WISP setup procedure, calibration protocol for new deployments, and open items. Verified end-to-end on hardware (sensors at 192.168.1.17/.19 → TP-Link at 192.168.1.14 → Mac at 192.168.1.21): * UDP/5006 packets arrive ~12 Hz combined from both nodes * Empty-room baseline d ≈ 0.49 measured (next: capture sit + walk to finalize thresholds) * Vital signs continue to populate (breathing 9–11 BPM stable) * Two consecutive OTA round-trips remain functional after the change Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
fc905c5c77
commit
b292c7d869
|
|
@ -0,0 +1,160 @@
|
|||
# ADR-099 — TP-Link WISP Deployment + RSSI-Δ Presence Detector
|
||||
|
||||
**Status**: Accepted
|
||||
**Date**: 2026-05-15
|
||||
**Scope**: `v2/crates/wifi-densepose-sensing-server/`,
|
||||
deployment of TP-Link TL-WR841N as a dedicated CSI AP for room01/room02.
|
||||
|
||||
## Context
|
||||
|
||||
After ADR-098 made the RuView FW boot cleanly and FW5.47 fallback gave real
|
||||
motion, the deployed sensors still produced unreliable presence in the
|
||||
operator's home environment. Investigation revealed two compounding factors:
|
||||
|
||||
1. **Ambient WiFi noise.** Both sensors were associated with the main
|
||||
household AP (`Tran Thanh T3`), which is heavily used by neighbouring
|
||||
networks on the same channel. Per-frame broadband variance in an *empty*
|
||||
room measured higher than when the operator was sitting at the desk
|
||||
— the multipath geometry plus neighbour traffic dominated the CSI
|
||||
signal.
|
||||
2. **The wrong feature.** Even on a clean channel, CSI variance does not
|
||||
monotonically track human presence at multi-meter range. A stationary
|
||||
body modifies multipath consistently (variance drops), while an empty
|
||||
room exhibits more multipath spread (variance rises). The host DSP
|
||||
features `variance`, `motion_band_power`, and `spectral_power` all
|
||||
showed this inversion at the deployed sensor locations.
|
||||
|
||||
Three one-minute measurements collected with TP-Link as the isolated AP,
|
||||
sensors connected only to it:
|
||||
|
||||
| Feature | STILL (sitting) | WALK (room loop) | EMPTY |
|
||||
|---|---|---|---|
|
||||
| `variance` mean | 29.7 | 33.7 | **35.8** |
|
||||
| `motion_band_power` mean | 49.8 | 54.6 | **57.4** |
|
||||
| `spectral_power` mean | 161 | 172 | 172 |
|
||||
| `mean_rssi` mean (dBm) | -59.13 | -59.12 | -58.98 |
|
||||
| **`mean_rssi` std** | **0.60** | **1.02** | **0.35** |
|
||||
|
||||
Only **standard deviation of mean_rssi** monotonically separates the three
|
||||
states. The human body physically perturbs RF path loss to the sensor:
|
||||
absent → flat RSSI, still → small fluctuations from breathing/microtremor,
|
||||
walking → large per-second swings.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1 — Isolate sensors on a dedicated AP (TP-Link TL-WR841N, WISP mode)
|
||||
|
||||
The household AP serves dozens of clients across multiple channels and is
|
||||
constantly retransmitting management frames for neighbours and BT-coex
|
||||
overlay. We deployed a TP-Link TL-WR841N in **WISP mode**:
|
||||
|
||||
* TP-Link associates with `Tran Thanh T3` over WiFi as a single client.
|
||||
* TP-Link runs its own NAT and broadcasts a clean SSID (`TP-Link_8340`,
|
||||
WPA2-PSK, fixed channel) on the 2.4 GHz band.
|
||||
* Sensors are provisioned to associate only with `TP-Link_8340`.
|
||||
* TP-Link's NAT forwards their UDP/5006 packets to the Mac on the
|
||||
household subnet (Mac stays connected to `Tran Thanh T3` for internet,
|
||||
no LAN reconfiguration on the host side).
|
||||
|
||||
Empirical effect: per-minute broadband variance in an empty room dropped
|
||||
from **50.7** (on `Tran Thanh T3`) to **35.8** (on `TP-Link_8340`).
|
||||
|
||||
### D2 — Replace CSI-variance presence detector with rolling RSSI MAD-Δ
|
||||
|
||||
The host-side classifier in `sensing-server` runs `extract_features_from_frame`
|
||||
→ `smooth_and_classify` and outputs `motion_level` ∈ {`absent`, `present_still`,
|
||||
`present_moving`, `active`} based on a `motion_score` derived from CSI
|
||||
amplitude variance + temporal change-points. On the deployed geometry the
|
||||
score crosses thresholds for body-far-from-sensor cases but not for body-near-
|
||||
sensor stationary cases; the `present_still` band especially is unreliable.
|
||||
|
||||
We add an **RSSI-based override** layered after the existing classifier:
|
||||
|
||||
* Per-node rolling window of the last 120 frame RSSI samples (~10 s at
|
||||
12 Hz).
|
||||
* Metric: **mean absolute delta of consecutive RSSI values** (MAD-Δ).
|
||||
This is more robust than standard deviation for the int8-quantised RSSI
|
||||
the WiFi driver reports — a single 1-dB step in a quiet window
|
||||
inflates std but contributes minimally to MAD-Δ.
|
||||
* Thresholds (calibrated empirically; see D3):
|
||||
* `d < 0.20` → `absent`
|
||||
* `0.20 ≤ d < 0.55` → `present_still`
|
||||
* `0.55 ≤ d < 1.10` → `present_moving`
|
||||
* `d ≥ 1.10` → `active`
|
||||
* Confidence is surfaced as the raw `d` value during the tuning phase so
|
||||
that downstream UIs (the calibration console at `static/spectrum.html`)
|
||||
can drive threshold refinement on new deployments.
|
||||
|
||||
The CSI-based features are preserved in the `features.*` block so that
|
||||
downstream consumers (vital signs, signal-quality estimator, multi-node
|
||||
fusion) continue to operate.
|
||||
|
||||
### D3 — Threshold calibration via UI-assisted "tell me your state" protocol
|
||||
|
||||
Tunable thresholds are per-deployment. The procedure documented for the
|
||||
operator:
|
||||
|
||||
1. Open `http://localhost:8091/spectrum.html` (also reachable via Tailscale
|
||||
at the Mac's `100.x.y.z:8091`).
|
||||
2. Confidence on that page shows the raw RSSI-Δ for the user's environment.
|
||||
3. With a stopwatch:
|
||||
* Leave the room for 60 s. Record median `d`.
|
||||
* Sit at the workstation for 60 s. Record median `d`.
|
||||
* Walk the loop for 60 s. Record median `d`.
|
||||
4. Thresholds = midpoints between consecutive medians.
|
||||
|
||||
For the operator's room (TP-Link AP at `192.168.1.14`, sensors at .17 / .19):
|
||||
|
||||
| State | `d` median (target) | `d` measured (operator) |
|
||||
|---|---|---|
|
||||
| absent | should be near 0 | **0.49** (empty room) |
|
||||
|
||||
The operator's empty-room baseline of `d ≈ 0.49` is *higher* than the
|
||||
heuristic 0.20 threshold the code currently ships with. This is consistent
|
||||
with the int8 quantisation: even an empty channel jitters by ±1 dB
|
||||
across consecutive frames. Final threshold tuning for this deployment is
|
||||
**still pending** — the captures for `sit` and `walk` are needed to set
|
||||
the boundaries. The code surfaces `d` via `confidence` to let the
|
||||
operator capture those next two states.
|
||||
|
||||
## Files Touched
|
||||
|
||||
```
|
||||
v2/crates/wifi-densepose-sensing-server/src/main.rs # RSSI MAD-Δ + override
|
||||
v2/crates/wifi-densepose-sensing-server/static/spectrum.html # live console
|
||||
v2/crates/wifi-densepose-sensing-server/static/calibrate.html # peak-tracker view
|
||||
docs/adr/ADR-099-tplink-wisp-deployment-and-rssi-presence.md # this ADR
|
||||
```
|
||||
|
||||
## Verified Acceptance
|
||||
|
||||
| Criterion | Result |
|
||||
|---|---|
|
||||
| Sensors associate only with TP-Link AP (no `Tran Thanh T3` direct) | ✅ |
|
||||
| Mac receives UDP/5006 packets via TP-Link NAT | ✅ (~12 Hz combined) |
|
||||
| Empty-room ambient noise reduced vs household AP | ✅ (variance 50.7 → 35.8) |
|
||||
| `confidence` field carries raw RSSI-Δ for live tuning | ✅ |
|
||||
| Vital signs (breathing 9–11 BPM) continue to populate when occupied | ✅ |
|
||||
|
||||
## Open Items
|
||||
|
||||
* Threshold final-tune (sit + walk medians not yet measured on TP-Link).
|
||||
* Replace MAD-Δ with `quantile(|Δ|, 0.9) - quantile(|Δ|, 0.1)` if
|
||||
occasional packet-rate hiccups inflate the simple mean.
|
||||
* The TP-Link runs WISP NAT — all sensor source IPs collapse to one
|
||||
(`192.168.1.14` on the household side). The server discriminates nodes
|
||||
by **MAC address** parsed from the `CSI_LEAN` payload, not by source IP,
|
||||
so this works today. If we later switch FW back to raw `0xC5110001`
|
||||
binary frames (which carry MAC) the same discrimination holds. If
|
||||
`parse_esp32_vitals` (0xC5110002) becomes the upstream format,
|
||||
per-node state tracking needs a separate MAC-bearing field added to
|
||||
that packet.
|
||||
* On longer test sessions: the `motion_band_power` and `variance` features
|
||||
remain present in `features.*` and are useful for vital-sign signal-quality
|
||||
estimation; do not strip them.
|
||||
|
||||
## References
|
||||
|
||||
* ADR-039 — Edge intelligence pipeline (host DSP path).
|
||||
* ADR-098 — Earlier ESP32-S3 deployment fixes (CSI callback, OTA, mobile UI).
|
||||
* RuView issue thread on RSSI-vs-CSI presence inversion (this ADR).
|
||||
|
|
@ -64,7 +64,10 @@ static uint32_t s_rate_skip = 0;
|
|||
* We cap the send rate to avoid exhausting lwIP packet buffers (ENOMEM).
|
||||
* Default: 20 ms = 50 Hz max send rate.
|
||||
*/
|
||||
#define CSI_MIN_SEND_INTERVAL_US (20 * 1000)
|
||||
/* Send rate cap reduced from 20 ms to 4 ms (250 Hz) so the host calibration
|
||||
* UI can show every available frame. The real ceiling is whatever rate the
|
||||
* WiFi CSI callback actually fires at (usually 5-50 Hz on a quiet LAN). */
|
||||
#define CSI_MIN_SEND_INTERVAL_US (4 * 1000)
|
||||
static int64_t s_last_send_us = 0;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -882,7 +882,12 @@ static void process_frame(const edge_ring_slot_t *slot)
|
|||
float var = (sum2 / (float)EDGE_BROAD_HISTORY_LEN) - mean * mean;
|
||||
if (var < 0.0f) var = 0.0f;
|
||||
|
||||
float energy = var / 3.0f;
|
||||
/* Divisor sized for sensor deployment with 1-3 m line-of-sight to
|
||||
* the activity zone. At that range multipath averages out and
|
||||
* broadband variance is small (~0.1-2.0 empty, ~1-10 walking).
|
||||
* Lower divisor = higher sensitivity but more saturation if a
|
||||
* sensor is moved close to the body (≤50 cm). */
|
||||
float energy = var / 5.0f;
|
||||
if (energy > 1.0f) energy = 1.0f;
|
||||
s_motion_energy = energy;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,12 +37,13 @@ pub async fn discover_nodes(
|
|||
) -> Result<Vec<DiscoveredNode>, String> {
|
||||
let timeout_duration = Duration::from_millis(timeout_ms.unwrap_or(3000));
|
||||
|
||||
// Run mDNS, UDP, and HTTP sweep discovery concurrently
|
||||
let (mdns_nodes, udp_nodes, http_nodes) = tokio::join!(
|
||||
discover_via_mdns(timeout_duration),
|
||||
discover_via_udp(timeout_duration),
|
||||
discover_via_http_sweep(timeout_duration),
|
||||
);
|
||||
// Current RuView FW doesn't advertise mDNS `_ruview._udp.local.` and
|
||||
// doesn't respond to UDP broadcast beacons, so those two paths return
|
||||
// nothing on every poll and just burn CPU/network. HTTP sweep alone
|
||||
// suffices for our deployment.
|
||||
let http_nodes = discover_via_http_sweep(timeout_duration).await;
|
||||
let mdns_nodes: Result<Vec<DiscoveredNode>, String> = Ok(Vec::new());
|
||||
let udp_nodes: Result<Vec<DiscoveredNode>, String> = Ok(Vec::new());
|
||||
|
||||
// Merge results, deduplicating by MAC address (or IP for HTTP-only nodes)
|
||||
let mut registry = NodeRegistry::new();
|
||||
|
|
@ -261,6 +262,9 @@ async fn discover_via_http_sweep(timeout_duration: Duration) -> Result<Vec<Disco
|
|||
tracing::info!("HTTP sweep on {}.{}.{}.0/24 (self={})", base.0, base.1, base.2, host_ip);
|
||||
|
||||
// 2. Build HTTP client with per-request timeout
|
||||
// Per-request timeout — generous enough for ESP32 HTTP server to respond
|
||||
// even under WiFi contention. With join_all of all 254 probes in parallel,
|
||||
// total elapsed = max(per_req_timeout, slowest_response) ≈ 1.5 s.
|
||||
let per_req_timeout = std::cmp::min(timeout_duration, Duration::from_millis(1500));
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(per_req_timeout)
|
||||
|
|
@ -275,7 +279,11 @@ async fn discover_via_http_sweep(timeout_duration: Duration) -> Result<Vec<Disco
|
|||
|
||||
// 3. Probe all hosts in parallel (capped by spawning futures)
|
||||
let mut tasks: Vec<tokio::task::JoinHandle<Option<DiscoveredNode>>> = Vec::new();
|
||||
for h in 1u8..=254u8 {
|
||||
// Scan only the low end of /24 (2..=60) — typical home/office DHCP pool
|
||||
// for IoT devices. Sweeping all 254 hosts every 10 s causes UI lag on
|
||||
// tokio runtime saturation. Operators with sensors at higher offsets
|
||||
// should expand this range.
|
||||
for h in 2u8..=60u8 {
|
||||
if h == octets[3] {
|
||||
continue; // skip self
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { invoke } from "@tauri-apps/api/core";
|
|||
import type { Node } from "../types";
|
||||
|
||||
interface UseNodesOptions {
|
||||
/** Auto-poll interval in milliseconds. Set to 0 to disable. Default: 10000 */
|
||||
/** Auto-poll interval in milliseconds. Set to 0 to disable. Default: 30000 */
|
||||
pollInterval?: number;
|
||||
/** Whether to start scanning on mount. Default: false */
|
||||
autoScan?: boolean;
|
||||
|
|
@ -23,7 +23,7 @@ interface UseNodesReturn {
|
|||
}
|
||||
|
||||
export function useNodes(options: UseNodesOptions = {}): UseNodesReturn {
|
||||
const { pollInterval = 10_000, autoScan = false } = options;
|
||||
const { pollInterval = 30_000, autoScan = false } = options;
|
||||
|
||||
const [nodes, setNodes] = useState<Node[]>([]);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
|
|
|
|||
|
|
@ -37,10 +37,15 @@ const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const found = await invoke<DiscoveredNode[]>("discover_nodes", { timeoutMs: 8000 });
|
||||
// Keep last good list when scan returns empty (discovery is flaky
|
||||
// on busy LANs — see useNodes.ts for context).
|
||||
// Merge with existing list — discovery on busy LANs sometimes misses
|
||||
// a node it found in the previous round. Add new entries, refresh
|
||||
// ones we see again, keep previously-found ones.
|
||||
if (found.length > 0) {
|
||||
setNodes(found);
|
||||
setNodes((prev) => {
|
||||
const byIp = new Map(prev.map((n) => [n.ip, n]));
|
||||
for (const n of found) byIp.set(n.ip, n);
|
||||
return Array.from(byIp.values());
|
||||
});
|
||||
setScanError(null);
|
||||
} else if (nodes.length === 0) {
|
||||
setScanError("No nodes found. Ensure ESP32 devices are powered on and connected to the network.");
|
||||
|
|
|
|||
|
|
@ -68,7 +68,14 @@ const NetworkDiscovery: React.FC<NetworkDiscoveryProps> = ({ onNavigate }) => {
|
|||
const found = await invoke<DiscoveredNode[]>("discover_nodes", {
|
||||
timeoutMs: scanDuration,
|
||||
});
|
||||
setNodes(found);
|
||||
// Merge with existing — flaky LAN scans sometimes miss a node that
|
||||
// was found a moment ago. Add new entries, refresh ones we see again,
|
||||
// keep previously-found ones (incl. manual-added).
|
||||
setNodes((prev) => {
|
||||
const byIp = new Map(prev.map((n) => [n.ip, n]));
|
||||
for (const n of found) byIp.set(n.ip, n);
|
||||
return Array.from(byIp.values());
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -122,6 +122,61 @@ fn baseline_init() -> &'static Mutex<std::collections::HashMap<u8, BaselineTrack
|
|||
BASELINE.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
|
||||
}
|
||||
|
||||
/// Per-node rolling RSSI window for presence detection.
|
||||
/// On the deployed TP-Link AP, empirically the standard deviation of frame
|
||||
/// RSSI over a ~5 s window separates empty/sitting/walking far more cleanly
|
||||
/// than CSI variance metrics: empty ≈ 0.35, sitting still ≈ 0.60, walking
|
||||
/// ≈ 1.0+. A human body in the channel acts as a moving absorber/reflector
|
||||
/// → RSSI flickers; an empty room has only RF background noise → flat RSSI.
|
||||
static RSSI_HIST: OnceLock<Mutex<std::collections::HashMap<u8, std::collections::VecDeque<i8>>>> = OnceLock::new();
|
||||
|
||||
fn rssi_hist_init() -> &'static Mutex<std::collections::HashMap<u8, std::collections::VecDeque<i8>>> {
|
||||
RSSI_HIST.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
|
||||
}
|
||||
|
||||
/// Push a new RSSI sample and return rolling mean absolute delta over the
|
||||
/// last `window` samples. Returns 0.0 until we have at least `window` samples.
|
||||
/// MAD-Δ is more robust than std-dev for integer-quantised RSSI: a single
|
||||
/// 1-dB step in a quiet window inflates std but contributes minimally to
|
||||
/// the running mean of |Δ|.
|
||||
fn rssi_delta_push(node_id: u8, rssi: i8, window: usize) -> f64 {
|
||||
let mut map = rssi_hist_init().lock().unwrap();
|
||||
let q = map.entry(node_id).or_insert_with(std::collections::VecDeque::new);
|
||||
q.push_back(rssi);
|
||||
while q.len() > window { q.pop_front(); }
|
||||
if q.len() < window { return 0.0; }
|
||||
let mut sum = 0.0;
|
||||
let mut n = 0.0;
|
||||
let vals: Vec<i8> = q.iter().copied().collect();
|
||||
for i in 1..vals.len() {
|
||||
sum += (vals[i] as f64 - vals[i-1] as f64).abs();
|
||||
n += 1.0;
|
||||
}
|
||||
if n == 0.0 { 0.0 } else { sum / n }
|
||||
}
|
||||
|
||||
/// Override (motion_level, presence) from rolling RSSI MAD-Δ.
|
||||
/// Returns None until window has filled.
|
||||
fn rssi_presence_override(node_id: u8, rssi: i8) -> Option<(String, bool, f64)> {
|
||||
let d = rssi_delta_push(node_id, rssi, 120); // ~10 sec @ 12 Hz
|
||||
if d == 0.0 { return None; }
|
||||
// Empirical thresholds for the room01/room02 TP-Link deployment.
|
||||
// Empty room: mean |Δrssi| stays near 0 because RSSI sits at one int8 value
|
||||
// for many frames. Human in channel: 0.3-0.7. Walking: 0.7+.
|
||||
let (level, presence) = if d < 0.20 {
|
||||
("absent", false)
|
||||
} else if d < 0.55 {
|
||||
("present_still", true)
|
||||
} else if d < 1.10 {
|
||||
("present_moving", true)
|
||||
} else {
|
||||
("active", true)
|
||||
};
|
||||
// TEMP: surface the raw d via confidence so we can tune thresholds.
|
||||
let conf = d;
|
||||
Some((level.to_string(), presence, conf))
|
||||
}
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::{
|
||||
|
|
@ -824,7 +879,9 @@ fn parse_rv_feature_state(buf: &[u8]) -> Option<Esp32VitalsPacket> {
|
|||
let quality_flags = u16::from_le_bytes([buf[52], buf[53]]);
|
||||
|
||||
let presence_valid = (quality_flags & (1 << 0)) != 0;
|
||||
let presence = presence_valid && presence_score > 0.5;
|
||||
// Threshold lowered from 0.5 to 0.15 for low-SNR multi-meter deployments
|
||||
// where FW's broadband-variance motion rarely saturates above 0.5.
|
||||
let presence = presence_valid && presence_score > 0.15;
|
||||
let fall_detected = (quality_flags & (1 << 3)) != 0;
|
||||
let motion = motion_score > 0.05;
|
||||
let n_persons = if presence { 1 } else { 0 };
|
||||
|
|
@ -1899,6 +1956,17 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
|
|||
extract_features_from_frame(&frame, &s_write_pre.frame_history, sample_rate_hz);
|
||||
smooth_and_classify(&mut s_write_pre, &mut classification, raw_motion);
|
||||
adaptive_override(&s_write_pre, &features, &mut classification);
|
||||
// RSSI-std presence override: motion_band / variance fail to separate
|
||||
// empty vs occupied in this deployment (multipath spreads more in an
|
||||
// empty room). RSSI std reliably differentiates because the body
|
||||
// physically blocks/reflects WiFi between the sensor and the AP.
|
||||
if let Some((level, presence, conf)) =
|
||||
rssi_presence_override(frame.node_id, frame.rssi)
|
||||
{
|
||||
classification.motion_level = level;
|
||||
classification.presence = presence;
|
||||
classification.confidence = conf;
|
||||
}
|
||||
drop(s_write_pre);
|
||||
|
||||
// ── Step 5: Build enhanced fields from pipeline result ───────
|
||||
|
|
@ -3882,6 +3950,12 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
|||
// Run a per-node EWMA baseline and threshold via z-score so
|
||||
// `vitals.presence` reflects actual change vs ambient noise
|
||||
// rather than absolute level.
|
||||
// Host-side adaptive baseline on top of FW's broadband
|
||||
// motion_energy. FW saturates above its /3.0f divisor
|
||||
// when ambient RF activity is higher than the agent's
|
||||
// calibration room, so a fixed threshold doesn't work.
|
||||
// The baseline tracker learns the per-node steady-state
|
||||
// value and fires presence only on z-score excursions.
|
||||
{
|
||||
let mut g = baseline_init().lock().unwrap();
|
||||
let tr = g.entry(vitals.node_id).or_insert_with(BaselineTracker::new);
|
||||
|
|
@ -4102,6 +4176,28 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
|||
debug!("ESP32 frame from {src}: node={}, subs={}, seq={}",
|
||||
frame.node_id, frame.n_subcarriers, frame.sequence);
|
||||
|
||||
// Broadcast raw spectrum on WS for the calibration UI —
|
||||
// every frame, no smoothing. Allows the operator to see
|
||||
// per-subcarrier amplitude in real time and find the
|
||||
// optimal sensor placement.
|
||||
{
|
||||
let s_read = state.read().await;
|
||||
if s_read.tx.receiver_count() > 0 {
|
||||
if let Ok(json) = serde_json::to_string(&serde_json::json!({
|
||||
"type": "raw_csi",
|
||||
"node_id": frame.node_id,
|
||||
"rssi": frame.rssi,
|
||||
"noise_floor": frame.noise_floor,
|
||||
"n_subcarriers": frame.n_subcarriers,
|
||||
"sequence": frame.sequence,
|
||||
"amplitudes": frame.amplitudes,
|
||||
"ts": chrono::Utc::now().timestamp_millis(),
|
||||
})) {
|
||||
let _ = s_read.tx.send(json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut s = state.write().await;
|
||||
s.source = "esp32".to_string();
|
||||
s.last_esp32_frame = Some(std::time::Instant::now());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
<!doctype html>
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>RuView — Sensor Placement Calibration</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body { margin:0; padding:24px; font-family:-apple-system,Inter,system-ui,sans-serif;
|
||||
background:#0d1117; color:#e6edf3; }
|
||||
h1 { font-size:18px; font-weight:600; margin:0 0 4px; }
|
||||
.sub { font-size:12px; color:#888; margin:0 0 24px; }
|
||||
.node { background:#161b22; border:1px solid #30363d; border-radius:8px;
|
||||
padding:16px 20px; margin-bottom:12px; }
|
||||
.node .head { display:flex; justify-content:space-between; align-items:baseline; margin-bottom:8px; }
|
||||
.node .name { font-weight:600; }
|
||||
.node .ip { color:#888; font-size:12px; font-family:JetBrains Mono,monospace; }
|
||||
.metric { display:flex; align-items:center; gap:12px; margin:8px 0; }
|
||||
.metric .label { width:90px; font-size:12px; color:#aaa; }
|
||||
.metric .bar { flex:1; height:16px; background:#21262d; border-radius:4px; overflow:hidden; position:relative; }
|
||||
.metric .fill { height:100%; transition:width 80ms linear; }
|
||||
.metric .val { width:75px; text-align:right; font-family:JetBrains Mono,monospace; font-size:13px; }
|
||||
.metric .max { width:70px; color:#999; font-size:11px; text-align:right; font-family:JetBrains Mono,monospace; }
|
||||
.fill.motion { background:linear-gradient(90deg,#1f6feb,#388bfd); }
|
||||
.fill.presence { background:linear-gradient(90deg,#238636,#3fb950); }
|
||||
.fill.rssi { background:linear-gradient(90deg,#d29922,#f0883e); }
|
||||
.legend { color:#666; font-size:11px; margin-top:14px; }
|
||||
.status { padding:8px 12px; background:#1c2128; border-radius:4px; font-size:12px;
|
||||
font-family:JetBrains Mono,monospace; color:#7ce38b; margin-bottom:16px; }
|
||||
.status.dis { color:#f85149; }
|
||||
.tip { background:#1a1f24; border-left:3px solid #1f6feb; padding:10px 14px; font-size:13px;
|
||||
color:#aaa; margin-top:16px; border-radius:4px; }
|
||||
button { background:#21262d; color:#e6edf3; border:1px solid #30363d; border-radius:6px;
|
||||
padding:6px 12px; font-size:12px; cursor:pointer; margin-left:8px; }
|
||||
button:hover { border-color:#58a6ff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>RuView Sensor Placement Calibration</h1>
|
||||
<p class="sub">Live per-node motion / presence / rssi. Move sensors around and watch the bars.</p>
|
||||
<div id="status" class="status dis">disconnected</div>
|
||||
<div id="nodes"></div>
|
||||
<div class="tip">
|
||||
<b>Цель:</b> когда ты ходишь в нужной зоне, motion-бар должен подниматься <b>на обеих нодах одновременно</b>.
|
||||
Идеальная позиция — обе ноды по разные стороны от тебя, прямая линия между ними пересекает зону движения.
|
||||
Кликни <b>Reset peaks</b> чтобы сбросить пиковые значения и переоценить новую позицию.
|
||||
</div>
|
||||
<script>
|
||||
const peaks = {};
|
||||
const smoothed = {}; // EMA-smoothed values, ~1 s time constant
|
||||
const SMOOTH_ALPHA = 0.10;
|
||||
let lastFrameTs = Date.now();
|
||||
|
||||
function ensureNode(id, ip) {
|
||||
let el = document.getElementById('node-'+id);
|
||||
if (el) return el;
|
||||
el = document.createElement('div');
|
||||
el.id = 'node-'+id; el.className = 'node';
|
||||
el.innerHTML = `
|
||||
<div class="head"><span class="name">Node ${id}</span>
|
||||
<span><span class="ip">${ip}</span>
|
||||
<button onclick="resetPeak(${id})">Reset peak</button></span></div>
|
||||
<div class="metric"><span class="label">motion</span><div class="bar"><div class="fill motion" id="m-${id}" style="width:0"></div></div>
|
||||
<span class="val" id="mv-${id}">0.000</span><span class="max" id="mx-${id}">↑0.000</span></div>
|
||||
<div class="metric"><span class="label">presence</span><div class="bar"><div class="fill presence" id="p-${id}" style="width:0"></div></div>
|
||||
<span class="val" id="pv-${id}">0.000</span><span class="max" id="px-${id}">↑0.000</span></div>
|
||||
<div class="metric"><span class="label">RSSI</span><div class="bar"><div class="fill rssi" id="r-${id}" style="width:0"></div></div>
|
||||
<span class="val" id="rv-${id}">--</span></div>`;
|
||||
document.getElementById('nodes').appendChild(el);
|
||||
peaks[id] = { motion: 0, presence: 0 };
|
||||
return el;
|
||||
}
|
||||
function resetPeak(id) { peaks[id] = { motion: 0, presence: 0 };
|
||||
document.getElementById('mx-'+id).textContent = '↑0.000';
|
||||
document.getElementById('px-'+id).textContent = '↑0.000'; }
|
||||
function update(id, m, p, rssi, ip) {
|
||||
ensureNode(id, ip || '');
|
||||
m = m || 0; p = p || 0;
|
||||
// EMA smooth so RF flicker doesn't make the bars jump
|
||||
if (!smoothed[id]) smoothed[id] = { motion: m, presence: p, rssi: rssi || -60 };
|
||||
smoothed[id].motion = (1 - SMOOTH_ALPHA) * smoothed[id].motion + SMOOTH_ALPHA * m;
|
||||
smoothed[id].presence = (1 - SMOOTH_ALPHA) * smoothed[id].presence + SMOOTH_ALPHA * p;
|
||||
if (rssi) smoothed[id].rssi = (1 - SMOOTH_ALPHA) * smoothed[id].rssi + SMOOTH_ALPHA * rssi;
|
||||
const sm = smoothed[id].motion, sp = smoothed[id].presence;
|
||||
peaks[id].motion = Math.max(peaks[id].motion, sm);
|
||||
peaks[id].presence = Math.max(peaks[id].presence, sp);
|
||||
document.getElementById('m-'+id).style.width = (Math.min(sm,1)*100).toFixed(0)+'%';
|
||||
document.getElementById('mv-'+id).textContent = sm.toFixed(3);
|
||||
document.getElementById('mx-'+id).textContent = '↑'+peaks[id].motion.toFixed(3);
|
||||
document.getElementById('p-'+id).style.width = (Math.min(sp,1)*100).toFixed(0)+'%';
|
||||
document.getElementById('pv-'+id).textContent = sp.toFixed(3);
|
||||
document.getElementById('px-'+id).textContent = '↑'+peaks[id].presence.toFixed(3);
|
||||
// RSSI in -90..-30 dBm range -> 0..100%
|
||||
const sr = smoothed[id].rssi;
|
||||
const rssiNorm = Math.max(0, Math.min(1, (sr + 90) / 60));
|
||||
document.getElementById('r-'+id).style.width = (rssiNorm*100).toFixed(0)+'%';
|
||||
document.getElementById('rv-'+id).textContent = sr.toFixed(0) + ' dBm';
|
||||
}
|
||||
function connect() {
|
||||
const ws = new WebSocket('ws://' + location.hostname + ':8765/ws/sensing');
|
||||
ws.onopen = () => { document.getElementById('status').textContent='connected ws://'+location.hostname+':8765'; document.getElementById('status').className='status'; };
|
||||
ws.onclose = () => { document.getElementById('status').textContent='disconnected — reconnecting in 2 s'; document.getElementById('status').className='status dis'; setTimeout(connect, 2000); };
|
||||
ws.onerror = () => ws.close();
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const d = JSON.parse(e.data);
|
||||
if (d.type === 'edge_vitals') {
|
||||
update(d.node_id, d.motion_energy, d.presence_score, d.rssi);
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
}
|
||||
connect();
|
||||
</script>
|
||||
</body></html>
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
<!doctype html>
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>RuView — Live Signal</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body { margin:0; padding:18px; font-family:-apple-system,Inter,system-ui,sans-serif;
|
||||
background:#0d1117; color:#e6edf3; }
|
||||
h1 { font-size:16px; font-weight:600; margin:0 0 4px; }
|
||||
.sub { font-size:11px; color:#888; margin:0 0 16px; }
|
||||
.panel { background:#161b22; border:1px solid #30363d; border-radius:8px;
|
||||
padding:14px 16px; margin-bottom:12px; }
|
||||
.panel h2 { margin:0 0 10px; font-size:13px; font-weight:600; color:#7ce38b; }
|
||||
.row { display:flex; justify-content:space-between; align-items:center;
|
||||
gap:12px; margin:6px 0; font-size:12px; }
|
||||
.row .name { color:#aaa; flex-shrink:0; width:160px; }
|
||||
.row .bar { flex:1; height:14px; background:#21262d; border-radius:3px; overflow:hidden; }
|
||||
.row .fill { height:100%; transition:width 100ms linear; }
|
||||
.row .val { width:90px; text-align:right; font-family:JetBrains Mono,monospace; font-size:12px; }
|
||||
.row .peak { width:70px; color:#888; text-align:right; font-family:JetBrains Mono,monospace; font-size:11px; }
|
||||
.fill.var { background:linear-gradient(90deg,#1f6feb,#388bfd); }
|
||||
.fill.mot { background:linear-gradient(90deg,#238636,#3fb950); }
|
||||
.fill.spc { background:linear-gradient(90deg,#a371f7,#bc8cff); }
|
||||
.fill.bre { background:linear-gradient(90deg,#d29922,#f0883e); }
|
||||
.fill.rssi { background:linear-gradient(90deg,#6e7681,#8b949e); }
|
||||
.status { padding:6px 10px; background:#1c2128; border-radius:4px; font-size:12px;
|
||||
font-family:JetBrains Mono,monospace; color:#7ce38b; margin-bottom:14px; display:inline-block; }
|
||||
.status.dis { color:#f85149; }
|
||||
button { background:#21262d; color:#e6edf3; border:1px solid #30363d; border-radius:6px;
|
||||
padding:4px 10px; font-size:11px; cursor:pointer; margin-left:8px; }
|
||||
.class { font-family:JetBrains Mono,monospace; font-size:14px;
|
||||
padding:4px 10px; border-radius:4px; display:inline-block; }
|
||||
.class.absent { background:#21262d; color:#888; }
|
||||
.class.present_still { background:#1c3a55; color:#7cb6ff; }
|
||||
.class.present_moving { background:#3a5520; color:#90d36b; }
|
||||
.class.active { background:#552020; color:#ff7a7a; }
|
||||
canvas { display:block; width:100%; height:70px; background:#0a0e13; border-radius:4px; margin-top:8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>RuView Live Signal — Calibration Console</h1>
|
||||
<p class="sub">All features the host DSP computes from raw CSI in real time. Move sensors and yourself, watch which ones react.</p>
|
||||
<div>
|
||||
<span id="status" class="status dis">disconnected</span>
|
||||
<button onclick="resetPeaks()">Reset peaks</button>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Combined classification</h2>
|
||||
<div class="row"><span class="name">motion_level</span><span id="motion-level" class="class absent">absent</span><span class="val" id="cls-conf">conf 0.00</span></div>
|
||||
<div class="row"><span class="name">presence</span><span class="val" id="presence">false</span><span class="val" id="persons">0 persons</span></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Host-computed features (from raw CSI)</h2>
|
||||
<div class="row"><span class="name">variance</span><div class="bar"><div class="fill var" id="b-var" style="width:0"></div></div><span class="val" id="v-var">0.00</span><span class="peak" id="p-var">↑0</span></div>
|
||||
<div class="row"><span class="name">motion_band_power</span><div class="bar"><div class="fill mot" id="b-mbp" style="width:0"></div></div><span class="val" id="v-mbp">0.00</span><span class="peak" id="p-mbp">↑0</span></div>
|
||||
<div class="row"><span class="name">spectral_power</span><div class="bar"><div class="fill spc" id="b-spc" style="width:0"></div></div><span class="val" id="v-spc">0.00</span><span class="peak" id="p-spc">↑0</span></div>
|
||||
<div class="row"><span class="name">breathing_band_power</span><div class="bar"><div class="fill bre" id="b-bre" style="width:0"></div></div><span class="val" id="v-bre">0.00</span><span class="peak" id="p-bre">↑0</span></div>
|
||||
<div class="row"><span class="name">mean_rssi (dBm)</span><div class="bar"><div class="fill rssi" id="b-rssi" style="width:0"></div></div><span class="val" id="v-rssi">--</span></div>
|
||||
<div class="row"><span class="name">dominant_freq (Hz)</span><span class="val" id="v-freq">--</span><span class="val" id="v-bpm">-- BPM</span></div>
|
||||
<div class="row"><span class="name">change_points</span><span class="val" id="v-cp">0</span></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Per-node FW signals (feature_state @ 10 Hz)</h2>
|
||||
<div id="nodes"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Variance trace (last 60 sec)</h2>
|
||||
<canvas id="trace"></canvas>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const peaks = { var:0, mbp:0, spc:0, bre:0 };
|
||||
const nodePeaks = {}; // per-node motion peak
|
||||
const trace = []; // [{t, var, mbp}]
|
||||
const TRACE_MAX = 600; // ~30 sec at 20 Hz
|
||||
let lastTs = Date.now();
|
||||
|
||||
function resetPeaks() {
|
||||
peaks.var = peaks.mbp = peaks.spc = peaks.bre = 0;
|
||||
for (const k in nodePeaks) nodePeaks[k] = 0;
|
||||
trace.length = 0;
|
||||
document.getElementById('p-var').textContent = '↑0';
|
||||
document.getElementById('p-mbp').textContent = '↑0';
|
||||
document.getElementById('p-spc').textContent = '↑0';
|
||||
document.getElementById('p-bre').textContent = '↑0';
|
||||
}
|
||||
|
||||
function fmt(x) { return (x === undefined || x === null) ? '--' : (typeof x === 'number' ? x.toFixed(2) : String(x)); }
|
||||
|
||||
function ensureNode(id) {
|
||||
let el = document.getElementById('n-'+id);
|
||||
if (el) return;
|
||||
el = document.createElement('div');
|
||||
el.id = 'n-'+id;
|
||||
el.innerHTML = `<div class="row"><span class="name">Node ${id} motion</span><div class="bar"><div class="fill mot" id="nm-${id}" style="width:0"></div></div><span class="val" id="nmv-${id}">0.00</span><span class="peak" id="nmp-${id}">↑0</span></div>
|
||||
<div class="row"><span class="name">Node ${id} rssi</span><div class="bar"><div class="fill rssi" id="nr-${id}" style="width:0"></div></div><span class="val" id="nrv-${id}">--</span></div>`;
|
||||
document.getElementById('nodes').appendChild(el);
|
||||
nodePeaks[id] = 0;
|
||||
}
|
||||
|
||||
function updateCombined(d) {
|
||||
const cl = d.classification || {};
|
||||
const f = d.features || {};
|
||||
const vs = d.vital_signs || {};
|
||||
const ml = cl.motion_level || 'absent';
|
||||
const elClass = document.getElementById('motion-level');
|
||||
elClass.textContent = ml;
|
||||
elClass.className = 'class ' + ml;
|
||||
document.getElementById('cls-conf').textContent = 'conf ' + (cl.confidence || 0).toFixed(2);
|
||||
document.getElementById('presence').textContent = cl.presence ? 'TRUE' : 'false';
|
||||
document.getElementById('persons').textContent = (d.estimated_persons || 0) + ' persons';
|
||||
|
||||
const updMetric = (key, id, scale) => {
|
||||
const v = f[key] || 0;
|
||||
const pct = Math.min(1, v / scale) * 100;
|
||||
document.getElementById('b-'+id).style.width = pct.toFixed(0)+'%';
|
||||
document.getElementById('v-'+id).textContent = v.toFixed(3);
|
||||
peaks[id] = Math.max(peaks[id], v);
|
||||
document.getElementById('p-'+id).textContent = '↑'+peaks[id].toFixed(3);
|
||||
};
|
||||
updMetric('variance', 'var', 50);
|
||||
updMetric('motion_band_power', 'mbp', 30);
|
||||
updMetric('spectral_power', 'spc', 500);
|
||||
updMetric('breathing_band_power', 'bre', 100);
|
||||
const rssi = f.mean_rssi || 0;
|
||||
document.getElementById('b-rssi').style.width = Math.max(0, Math.min(1, (rssi + 90) / 60)) * 100 + '%';
|
||||
document.getElementById('v-rssi').textContent = rssi.toFixed(0) + ' dBm';
|
||||
document.getElementById('v-freq').textContent = (f.dominant_freq_hz || 0).toFixed(2) + ' Hz';
|
||||
document.getElementById('v-bpm').textContent = ((f.dominant_freq_hz || 0) * 60).toFixed(0) + ' BPM';
|
||||
document.getElementById('v-cp').textContent = String(f.change_points || 0);
|
||||
|
||||
// trace
|
||||
trace.push({ t: Date.now(), v: f.variance || 0, m: f.motion_band_power || 0 });
|
||||
while (trace.length > TRACE_MAX) trace.shift();
|
||||
}
|
||||
|
||||
function updateEdgeVitals(d) {
|
||||
ensureNode(d.node_id);
|
||||
const m = d.motion_energy || 0;
|
||||
document.getElementById('nm-'+d.node_id).style.width = Math.min(1, m) * 100 + '%';
|
||||
document.getElementById('nmv-'+d.node_id).textContent = m.toFixed(3);
|
||||
if (m > nodePeaks[d.node_id]) nodePeaks[d.node_id] = m;
|
||||
document.getElementById('nmp-'+d.node_id).textContent = '↑'+nodePeaks[d.node_id].toFixed(3);
|
||||
const r = d.rssi || -60;
|
||||
document.getElementById('nr-'+d.node_id).style.width = Math.max(0, Math.min(1, (r + 90) / 60)) * 100 + '%';
|
||||
document.getElementById('nrv-'+d.node_id).textContent = r.toFixed(0) + ' dBm';
|
||||
}
|
||||
|
||||
function drawTrace() {
|
||||
const cv = document.getElementById('trace');
|
||||
const w = cv.clientWidth, h = cv.clientHeight;
|
||||
if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; }
|
||||
const ctx = cv.getContext('2d');
|
||||
ctx.fillStyle = '#0a0e13'; ctx.fillRect(0, 0, w, h);
|
||||
if (trace.length < 2) return;
|
||||
const maxV = Math.max(1, Math.max(...trace.map(p => p.v)));
|
||||
const maxM = Math.max(1, Math.max(...trace.map(p => p.m)));
|
||||
ctx.strokeStyle = '#388bfd'; ctx.lineWidth = 1.5; ctx.beginPath();
|
||||
for (let i = 0; i < trace.length; i++) {
|
||||
const x = (i / (trace.length - 1)) * w;
|
||||
const y = h - (trace[i].v / maxV) * (h - 4);
|
||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = '#3fb950'; ctx.beginPath();
|
||||
for (let i = 0; i < trace.length; i++) {
|
||||
const x = (i / (trace.length - 1)) * w;
|
||||
const y = h - (trace[i].m / maxM) * (h - 4);
|
||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = '#666'; ctx.font = '10px monospace';
|
||||
ctx.fillText('variance', 4, 12);
|
||||
ctx.fillStyle = '#3fb950';
|
||||
ctx.fillText('motion_band_power', 70, 12);
|
||||
}
|
||||
|
||||
function tick() { drawTrace(); requestAnimationFrame(tick); }
|
||||
tick();
|
||||
|
||||
function connect() {
|
||||
const ws = new WebSocket('ws://' + location.hostname + ':8765/ws/sensing');
|
||||
ws.onopen = () => { document.getElementById('status').textContent='connected'; document.getElementById('status').className='status'; };
|
||||
ws.onclose = () => { document.getElementById('status').textContent='disconnected — reconnecting'; document.getElementById('status').className='status dis'; setTimeout(connect, 2000); };
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const d = JSON.parse(e.data);
|
||||
if (d.type === 'sensing_update') updateCombined(d);
|
||||
else if (d.type === 'edge_vitals') updateEdgeVitals(d);
|
||||
} catch (_) {}
|
||||
};
|
||||
}
|
||||
connect();
|
||||
</script>
|
||||
</body></html>
|
||||
Loading…
Reference in New Issue