mirror of
https://github.com/ruvnet/RuView
synced 2026-07-27 18:11:43 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 365c107d4c |
@@ -95,88 +95,9 @@ node scripts/mincut-person-counter.js --port 5006 # Correct person counting
|
||||
>
|
||||
---
|
||||
|
||||
### Pre-Trained Models (v0.6.0) — No Training Required
|
||||
|
||||
<details open>
|
||||
<summary><strong>Download from HuggingFace and start sensing immediately</strong></summary>
|
||||
|
||||
Pre-trained models are available on HuggingFace:
|
||||
> **https://huggingface.co/ruv/ruview** (primary) | [mirror](https://huggingface.co/ruvnet/wifi-densepose-pretrained)
|
||||
|
||||
Trained on 60,630 real-world samples from an 8-hour overnight collection. Just download and run — no datasets, no GPU, no training needed.
|
||||
|
||||
| Model | Size | What it does |
|
||||
|-------|------|-------------|
|
||||
| `model.safetensors` | 48 KB | Contrastive encoder — 128-dim embeddings for presence, activity, environment |
|
||||
| `model-q4.bin` | 8 KB | 4-bit quantized — fits in ESP32-S3 SRAM for edge inference |
|
||||
| `model-q2.bin` | 4 KB | 2-bit ultra-compact for memory-constrained devices |
|
||||
| `presence-head.json` | 2.6 KB | 100% accurate presence detection head |
|
||||
| `node-1.json` / `node-2.json` | 21 KB | Per-room LoRA adapters (swap for new rooms) |
|
||||
|
||||
```bash
|
||||
# Download and use (Python)
|
||||
pip install huggingface_hub
|
||||
huggingface-cli download ruv/ruview --local-dir models/
|
||||
|
||||
# Or use directly with the sensing pipeline
|
||||
node scripts/train-ruvllm.js --data data/recordings/*.csi.jsonl # retrain on your own data
|
||||
node scripts/benchmark-ruvllm.js --model models/csi-ruvllm # benchmark
|
||||
```
|
||||
|
||||
**Benchmarks (Apple M4 Pro, retrained on overnight data):**
|
||||
|
||||
| What we measured | Result | Why it matters |
|
||||
|-----------------|--------|---------------|
|
||||
| **Presence detection** | **100% accuracy** | Never misses a person, never false alarms |
|
||||
| **Inference speed** | **0.008 ms** per embedding | 125,000x faster than real-time |
|
||||
| **Throughput** | **164,183 embeddings/sec** | One Mac Mini handles 1,600+ ESP32 nodes |
|
||||
| **Contrastive learning** | **51.6% improvement** | Strong pattern learning from real overnight data |
|
||||
| **Model size** | **8 KB** (4-bit quantized) | Fits in ESP32 SRAM — no server needed |
|
||||
| **Total hardware cost** | **$140** | ESP32 ($9) + [Cognitum Seed](https://cognitum.one) ($131) |
|
||||
|
||||
</details>
|
||||
|
||||
### 17 Sensing Applications (v0.6.0)
|
||||
|
||||
<details>
|
||||
<summary><strong>Health, environment, security, and multi-frequency mesh sensing</strong></summary>
|
||||
|
||||
All applications run from a single ESP32 + optional Cognitum Seed. No camera, no cloud, no internet.
|
||||
|
||||
**Health & Wellness:**
|
||||
|
||||
| Application | Script | What it detects |
|
||||
|------------|--------|----------------|
|
||||
| Sleep Monitor | `node scripts/sleep-monitor.js` | Sleep stages (deep/light/REM/awake), efficiency, hypnogram |
|
||||
| Apnea Detector | `node scripts/apnea-detector.js` | Breathing pauses >10s, AHI severity scoring |
|
||||
| Stress Monitor | `node scripts/stress-monitor.js` | Heart rate variability, LF/HF stress ratio |
|
||||
| Gait Analyzer | `node scripts/gait-analyzer.js` | Walking cadence, stride asymmetry, tremor detection |
|
||||
|
||||
**Environment & Security:**
|
||||
|
||||
| Application | Script | What it detects |
|
||||
|------------|--------|----------------|
|
||||
| Person Counter | `node scripts/mincut-person-counter.js` | Correct occupancy count (fixes #348) |
|
||||
| Room Fingerprint | `node scripts/room-fingerprint.js` | Activity state clustering, daily patterns, anomalies |
|
||||
| Material Detector | `node scripts/material-detector.js` | New/moved objects via subcarrier null changes |
|
||||
| Device Fingerprint | `node scripts/device-fingerprint.js` | Electronic device activity (printer, router, etc.) |
|
||||
|
||||
**Multi-Frequency Mesh** (requires `--hop-channels` provisioning):
|
||||
|
||||
| Application | Script | What it detects |
|
||||
|------------|--------|----------------|
|
||||
| RF Tomography | `node scripts/rf-tomography.js` | 2D room imaging via RF backprojection |
|
||||
| Passive Radar | `node scripts/passive-radar.js` | Neighbor WiFi APs as bistatic radar illuminators |
|
||||
| Material Classifier | `node scripts/material-classifier.js` | Metal/water/wood/glass from frequency response |
|
||||
| Through-Wall | `node scripts/through-wall-detector.js` | Motion behind walls using lower-frequency penetration |
|
||||
|
||||
All scripts support `--replay data/recordings/*.csi.jsonl` for offline analysis and `--json` for programmatic output.
|
||||
|
||||
</details>
|
||||
|
||||
### What's New in v0.5.5
|
||||
|
||||
<details>
|
||||
<details open>
|
||||
<summary><strong>Advanced Sensing: SNN + MinCut + WiFlow + Multi-Frequency Mesh</strong></summary>
|
||||
|
||||
**v0.5.5 adds four new sensing capabilities** built on the [ruvector](https://github.com/ruvnet/ruvector) ecosystem:
|
||||
@@ -1267,8 +1188,7 @@ Download a pre-built binary — no build toolchain needed:
|
||||
|
||||
| Release | What's included | Tag |
|
||||
|---------|-----------------|-----|
|
||||
| [v0.6.0](https://github.com/ruvnet/RuView/releases/tag/v0.6.0-esp32) | **Latest** — [Pre-trained models on HuggingFace](https://huggingface.co/ruv/ruview), 17 sensing apps, 51.6% contrastive improvement, 0.008ms inference | `v0.6.0-esp32` |
|
||||
| [v0.5.5](https://github.com/ruvnet/RuView/releases/tag/v0.5.5-esp32) | SNN + MinCut (#348 fix) + CNN spectrogram + WiFlow + multi-freq mesh + graph transformer | `v0.5.5-esp32` |
|
||||
| [v0.5.5](https://github.com/ruvnet/RuView/releases/tag/v0.5.5-esp32) | **Latest** — SNN + MinCut (fixes #348) + CNN spectrogram + WiFlow 1.8M architecture + multi-freq mesh (6 channels) + graph transformer | `v0.5.5-esp32` |
|
||||
| [v0.5.4](https://github.com/ruvnet/RuView/releases/tag/v0.5.4-esp32) | Cognitum Seed integration ([ADR-069](docs/adr/ADR-069-cognitum-seed-csi-pipeline.md)), 8-dim feature vectors, RVF store, witness chain, security hardening | `v0.5.4-esp32` |
|
||||
| [v0.5.0](https://github.com/ruvnet/RuView/releases/tag/v0.5.0-esp32) | mmWave sensor fusion ([ADR-063](docs/adr/ADR-063-mmwave-sensor-fusion.md)), auto-detect MR60BHA2/LD2410, 48-byte fused vitals, all v0.4.3.1 fixes | `v0.5.0-esp32` |
|
||||
| [v0.4.3.1](https://github.com/ruvnet/RuView/releases/tag/v0.4.3.1-esp32) | Fall detection fix ([#263](https://github.com/ruvnet/RuView/issues/263)), 4MB flash ([#265](https://github.com/ruvnet/RuView/issues/265)), watchdog fix ([#266](https://github.com/ruvnet/RuView/issues/266)) | `v0.4.3.1-esp32` |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"id": "pretrain-1775182186",
|
||||
"name": "pretrain-1775182186",
|
||||
"label": "mixed-activity",
|
||||
"started_at": "2026-04-03T02:09:46Z",
|
||||
"ended_at": "2026-04-03T02:11:46Z",
|
||||
"duration_secs": 120,
|
||||
"frame_count": 5783,
|
||||
"file_size_bytes": 2580539,
|
||||
"file_path": "data/recordings\\pretrain-1775182186.csi.jsonl",
|
||||
"nodes": {
|
||||
"2": 2886,
|
||||
"1": 2897
|
||||
}
|
||||
}
|
||||
@@ -1055,82 +1055,6 @@ See [ADR-071](adr/ADR-071-ruvllm-training-pipeline.md) and the [pretraining tuto
|
||||
|
||||
---
|
||||
|
||||
## Pre-Trained Models (No Training Required)
|
||||
|
||||
Pre-trained models are available on HuggingFace: **https://huggingface.co/ruvnet/wifi-densepose-pretrained**
|
||||
|
||||
Download and start sensing immediately — no datasets, no GPU, no training needed.
|
||||
|
||||
### Quick Start with Pre-Trained Models
|
||||
|
||||
```bash
|
||||
# Install huggingface CLI
|
||||
pip install huggingface_hub
|
||||
|
||||
# Download all models
|
||||
huggingface-cli download ruvnet/wifi-densepose-pretrained --local-dir models/pretrained
|
||||
|
||||
# The models include:
|
||||
# model.safetensors — 48 KB contrastive encoder
|
||||
# model-q4.bin — 8 KB quantized (recommended)
|
||||
# model-q2.bin — 4 KB ultra-compact (ESP32 edge)
|
||||
# presence-head.json — presence detection head (100% accuracy)
|
||||
# node-1.json — LoRA adapter for room 1
|
||||
# node-2.json — LoRA adapter for room 2
|
||||
```
|
||||
|
||||
### What the Models Do
|
||||
|
||||
The pre-trained encoder converts 8-dim CSI feature vectors into 128-dim embeddings. These embeddings power all 17 sensing applications:
|
||||
|
||||
- **Presence detection** — 100% accuracy, never misses, never false alarms
|
||||
- **Environment fingerprinting** — kNN search finds "states like this one"
|
||||
- **Anomaly detection** — embeddings that don't match known clusters = anomaly
|
||||
- **Activity classification** — different activities cluster in embedding space
|
||||
- **Room adaptation** — swap LoRA adapters for different rooms without retraining
|
||||
|
||||
### Retraining on Your Own Data
|
||||
|
||||
If you want to improve accuracy for your specific environment:
|
||||
|
||||
```bash
|
||||
# Collect 2+ minutes of CSI from your ESP32
|
||||
python scripts/collect-training-data.py --port 5006 --duration 120
|
||||
|
||||
# Retrain (uses ruvllm, no PyTorch needed)
|
||||
node scripts/train-ruvllm.js --data data/recordings/*.csi.jsonl
|
||||
|
||||
# Benchmark your retrained model
|
||||
node scripts/benchmark-ruvllm.js --model models/csi-ruvllm
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Health & Wellness Applications
|
||||
|
||||
WiFi sensing can monitor health metrics without any wearable or camera:
|
||||
|
||||
```bash
|
||||
# Sleep quality monitoring (run overnight)
|
||||
node scripts/sleep-monitor.js --port 5006 --bind 192.168.1.20
|
||||
|
||||
# Breathing disorder pre-screening
|
||||
node scripts/apnea-detector.js --port 5006 --bind 192.168.1.20
|
||||
|
||||
# Stress detection via heart rate variability
|
||||
node scripts/stress-monitor.js --port 5006 --bind 192.168.1.20
|
||||
|
||||
# Walking analysis + tremor detection
|
||||
node scripts/gait-analyzer.js --port 5006 --bind 192.168.1.20
|
||||
|
||||
# Replay on recorded data (no live hardware needed)
|
||||
node scripts/sleep-monitor.js --replay data/recordings/*.csi.jsonl
|
||||
```
|
||||
|
||||
> **Note:** These are pre-screening tools, not medical devices. Consult a healthcare professional for diagnosis.
|
||||
|
||||
---
|
||||
|
||||
## ruvllm Training Pipeline
|
||||
|
||||
All training uses **ruvllm** — a Rust-native ML runtime. No Python, no PyTorch, no GPU drivers required. Runs on any machine with Node.js.
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
@echo off
|
||||
echo STARTING > C:\Users\ruv\idf_test.txt
|
||||
set IDF_PATH=C:\Users\ruv\esp\v5.4\esp-idf
|
||||
set PATH=C:\Espressif\tools\python\v5.4\venv\Scripts;C:\Espressif\tools\xtensa-esp-elf\esp-14.2.0_20241119\xtensa-esp-elf\bin;C:\Espressif\tools\cmake\3.30.2\bin;C:\Espressif\tools\ninja\1.12.1;C:\Espressif\tools\idf-exe\1.0.3;%PATH%
|
||||
echo PATH_SET >> C:\Users\ruv\idf_test.txt
|
||||
cd /d C:\Users\ruv\Projects\wifi-densepose\firmware\esp32-csi-node
|
||||
echo CD_DONE >> C:\Users\ruv\idf_test.txt
|
||||
python %IDF_PATH%\tools\idf.py build >> C:\Users\ruv\idf_test.txt 2>&1
|
||||
echo RC=%ERRORLEVEL% >> C:\Users\ruv\idf_test.txt
|
||||
@@ -118,14 +118,8 @@ esp_err_t display_task_start(void)
|
||||
if (!buf1 || !buf2) {
|
||||
ESP_LOGE(TAG, "Failed to allocate LVGL buffers (%u bytes, caps=0x%lx)",
|
||||
(unsigned)buf_size, (unsigned long)alloc_caps);
|
||||
if (buf1) {
|
||||
free(buf1);
|
||||
buf1 = NULL;
|
||||
}
|
||||
if (buf2) {
|
||||
free(buf2);
|
||||
buf2 = NULL;
|
||||
}
|
||||
if (buf1) free(buf1);
|
||||
if (buf2) free(buf2);
|
||||
return ESP_OK;
|
||||
}
|
||||
ESP_LOGI(TAG, "LVGL buffers: 2x %u bytes (%u lines, %s)",
|
||||
|
||||
@@ -41,7 +41,6 @@ static const char *TAG = "edge_proc";
|
||||
* ====================================================================== */
|
||||
|
||||
static edge_ring_buf_t s_ring;
|
||||
static uint32_t s_ring_drops; /* Frames dropped due to full ring buffer. */
|
||||
|
||||
/* Scratch buffers for BPM estimation — moved from stack to static to avoid
|
||||
* stack overflow. process_frame + update_multi_person_vitals combined used
|
||||
@@ -54,7 +53,6 @@ static inline bool ring_push(const uint8_t *iq, uint16_t len,
|
||||
{
|
||||
uint32_t next = (s_ring.head + 1) % EDGE_RING_SLOTS;
|
||||
if (next == s_ring.tail) {
|
||||
s_ring_drops++;
|
||||
return false; /* Full — drop frame. */
|
||||
}
|
||||
|
||||
@@ -860,13 +858,12 @@ static void process_frame(const edge_ring_slot_t *slot)
|
||||
|
||||
if ((s_frame_count % 200) == 0) {
|
||||
ESP_LOGI(TAG, "Vitals: br=%.1f hr=%.1f motion=%.4f pres=%s "
|
||||
"fall=%s persons=%u frames=%lu drops=%lu",
|
||||
"fall=%s persons=%u frames=%lu",
|
||||
s_breathing_bpm, s_heartrate_bpm, s_motion_energy,
|
||||
s_presence_detected ? "YES" : "no",
|
||||
s_fall_detected ? "YES" : "no",
|
||||
(unsigned)s_latest_pkt.n_persons,
|
||||
(unsigned long)s_frame_count,
|
||||
(unsigned long)s_ring_drops);
|
||||
(unsigned long)s_frame_count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -916,19 +913,17 @@ static void edge_task(void *arg)
|
||||
while (processed < EDGE_BATCH_LIMIT && ring_pop(&slot)) {
|
||||
process_frame(&slot);
|
||||
processed++;
|
||||
/* 1-tick yield between frames within a batch. */
|
||||
/* Yield after every frame to feed the Core 1 watchdog. */
|
||||
vTaskDelay(1);
|
||||
}
|
||||
|
||||
if (processed > 0) {
|
||||
/* Post-batch yield: ~20 ms so IDLE1 can run and feed the
|
||||
* Core 1 watchdog even under sustained load. Uses pdMS_TO_TICKS
|
||||
* for tick-rate independence (minimum 1 tick). */
|
||||
* Core 1 watchdog even under sustained load. */
|
||||
{ TickType_t d = pdMS_TO_TICKS(20); vTaskDelay(d > 0 ? d : 1); }
|
||||
} else {
|
||||
/* No frames available — sleep one full tick.
|
||||
* NOTE: pdMS_TO_TICKS(5) == 0 at 100 Hz, which would busy-spin. */
|
||||
vTaskDelay(1);
|
||||
/* No frames available — yield briefly. */
|
||||
vTaskDelay(pdMS_TO_TICKS(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
-1
@@ -7769,7 +7769,6 @@ dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"futures-util",
|
||||
"ruvector-mincut",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
|
||||
@@ -43,8 +43,8 @@ clap = { workspace = true }
|
||||
# Multi-BSSID WiFi scanning pipeline (ADR-022 Phase 3)
|
||||
wifi-densepose-wifiscan = { version = "0.3.0", path = "../wifi-densepose-wifiscan" }
|
||||
|
||||
# Signal processing with RuvSense pose tracker (accuracy sprint)
|
||||
wifi-densepose-signal = { version = "0.3.0", path = "../wifi-densepose-signal" }
|
||||
# RuVector graph min-cut for person separation (ADR-068)
|
||||
ruvector-mincut = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.10"
|
||||
|
||||
@@ -9,11 +9,8 @@
|
||||
//! Replaces both ws_server.py and the Python HTTP server.
|
||||
|
||||
mod adaptive_classifier;
|
||||
mod field_bridge;
|
||||
mod multistatic_bridge;
|
||||
mod rvf_container;
|
||||
mod rvf_pipeline;
|
||||
mod tracker_bridge;
|
||||
mod vital_signs;
|
||||
|
||||
// Training pipeline modules (exposed via lib.rs)
|
||||
@@ -56,11 +53,6 @@ use wifi_densepose_wifiscan::{
|
||||
};
|
||||
use wifi_densepose_wifiscan::parse_netsh_output as parse_netsh_bssid_output;
|
||||
|
||||
// Accuracy sprint: Kalman tracker, multistatic fusion, field model
|
||||
use wifi_densepose_signal::ruvsense::pose_tracker::PoseTracker;
|
||||
use wifi_densepose_signal::ruvsense::multistatic::{MultistaticFuser, MultistaticConfig};
|
||||
use wifi_densepose_signal::ruvsense::field_model::{FieldModel, CalibrationStatus};
|
||||
|
||||
// ── CLI ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -153,14 +145,6 @@ struct Args {
|
||||
/// Build fingerprint index from embeddings (env|activity|temporal|person)
|
||||
#[arg(long, value_name = "TYPE")]
|
||||
build_index: Option<String>,
|
||||
|
||||
/// Node positions for multistatic fusion (format: "x,y,z;x,y,z;...")
|
||||
#[arg(long, env = "SENSING_NODE_POSITIONS")]
|
||||
node_positions: Option<String>,
|
||||
|
||||
/// Start field model calibration on boot (empty room required)
|
||||
#[arg(long)]
|
||||
calibrate: bool,
|
||||
}
|
||||
|
||||
// ── Data types ───────────────────────────────────────────────────────────────
|
||||
@@ -229,9 +213,6 @@ struct SensingUpdate {
|
||||
/// Estimated person count from CSI feature heuristics (1-3 for single ESP32).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
estimated_persons: Option<usize>,
|
||||
/// Per-node feature breakdown for multi-node deployments.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
node_features: Option<Vec<PerNodeFeatureInfo>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -299,9 +280,9 @@ struct BoundingBox {
|
||||
/// Each ESP32 node gets its own frame history, smoothing buffers, and vital
|
||||
/// sign detector so that data from different nodes is never mixed.
|
||||
struct NodeState {
|
||||
pub(crate) frame_history: VecDeque<Vec<f64>>,
|
||||
frame_history: VecDeque<Vec<f64>>,
|
||||
smoothed_person_score: f64,
|
||||
pub(crate) prev_person_count: usize,
|
||||
prev_person_count: usize,
|
||||
smoothed_motion: f64,
|
||||
current_motion_level: String,
|
||||
debounce_counter: u32,
|
||||
@@ -317,7 +298,7 @@ struct NodeState {
|
||||
rssi_history: VecDeque<f64>,
|
||||
vital_detector: VitalSignDetector,
|
||||
latest_vitals: VitalSigns,
|
||||
pub(crate) last_frame_time: Option<std::time::Instant>,
|
||||
last_frame_time: Option<std::time::Instant>,
|
||||
edge_vitals: Option<Esp32VitalsPacket>,
|
||||
/// Latest extracted features for cross-node fusion.
|
||||
latest_features: Option<FeatureInfo>,
|
||||
@@ -344,7 +325,7 @@ const MAX_BONE_CHANGE_RATIO: f64 = 0.20;
|
||||
const COHERENCE_WINDOW: usize = 20;
|
||||
|
||||
impl NodeState {
|
||||
pub(crate) fn new() -> Self {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
frame_history: VecDeque::new(),
|
||||
smoothed_person_score: 0.0,
|
||||
@@ -408,18 +389,6 @@ impl NodeState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-node feature info for WebSocket broadcasts (multi-node support).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct PerNodeFeatureInfo {
|
||||
node_id: u8,
|
||||
features: FeatureInfo,
|
||||
classification: ClassificationInfo,
|
||||
rssi_dbm: f64,
|
||||
last_seen_ms: u64,
|
||||
frame_rate_hz: f64,
|
||||
stale: bool,
|
||||
}
|
||||
|
||||
/// Shared application state
|
||||
struct AppStateInner {
|
||||
latest_update: Option<SensingUpdate>,
|
||||
@@ -513,15 +482,6 @@ struct AppStateInner {
|
||||
/// Per-node sensing state for multi-node deployments.
|
||||
/// Keyed by `node_id` from the ESP32 frame header.
|
||||
node_states: HashMap<u8, NodeState>,
|
||||
// ── Accuracy sprint: Kalman tracker, multistatic fusion, eigenvalue counting ──
|
||||
/// Global Kalman-based pose tracker for stable person IDs and smoothed keypoints.
|
||||
pose_tracker: PoseTracker,
|
||||
/// Instant of last tracker update (for computing dt).
|
||||
last_tracker_instant: Option<std::time::Instant>,
|
||||
/// Attention-weighted multi-node CSI fusion engine.
|
||||
multistatic_fuser: MultistaticFuser,
|
||||
/// SVD-based room field model for eigenvalue person counting (None until calibration).
|
||||
field_model: Option<FieldModel>,
|
||||
}
|
||||
|
||||
/// If no ESP32 frame arrives within this duration, source reverts to offline.
|
||||
@@ -531,31 +491,6 @@ impl AppStateInner {
|
||||
/// Return the effective data source, accounting for ESP32 frame timeout.
|
||||
/// If the source is "esp32" but no frame has arrived in 5 seconds, returns
|
||||
/// "esp32:offline" so the UI can distinguish active vs stale connections.
|
||||
/// Person count: eigenvalue-based if field model is calibrated, else heuristic.
|
||||
/// Uses global frame_history if populated, otherwise the freshest per-node history.
|
||||
fn person_count(&self) -> usize {
|
||||
match self.field_model.as_ref() {
|
||||
Some(fm) => {
|
||||
// Prefer global frame_history (populated by wifi/simulate paths).
|
||||
// Fall back to freshest per-node history (populated by ESP32 paths).
|
||||
let history = if !self.frame_history.is_empty() {
|
||||
&self.frame_history
|
||||
} else {
|
||||
// Find the node with the most recent frame
|
||||
self.node_states.values()
|
||||
.filter(|ns| !ns.frame_history.is_empty())
|
||||
.max_by_key(|ns| ns.last_frame_time)
|
||||
.map(|ns| &ns.frame_history)
|
||||
.unwrap_or(&self.frame_history)
|
||||
};
|
||||
field_bridge::occupancy_or_fallback(
|
||||
fm, history, self.smoothed_person_score, self.prev_person_count,
|
||||
)
|
||||
}
|
||||
None => score_to_person_count(self.smoothed_person_score, self.prev_person_count),
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_source(&self) -> String {
|
||||
if self.source == "esp32" {
|
||||
if let Some(last) = self.last_esp32_frame {
|
||||
@@ -704,13 +639,12 @@ fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
||||
// [20..] I/Q data
|
||||
let node_id = buf[4];
|
||||
let n_antennas = buf[5];
|
||||
let n_subcarriers = buf[6];
|
||||
let freq_mhz = u16::from_le_bytes([buf[8], buf[9]]);
|
||||
let sequence = u32::from_le_bytes([buf[10], buf[11], buf[12], buf[13]]);
|
||||
let rssi_raw = buf[14] as i8;
|
||||
// Fix RSSI sign: ensure it's always negative (dBm convention).
|
||||
let rssi = if rssi_raw > 0 { rssi_raw.saturating_neg() } else { rssi_raw };
|
||||
let noise_floor = buf[15] as i8;
|
||||
let n_subcarriers_u16 = u16::from_le_bytes([buf[6], buf[7]]);
|
||||
let n_subcarriers = n_subcarriers_u16 as u8; // truncate to u8 for Esp32Frame compat
|
||||
let freq_mhz = u16::from_le_bytes([buf[8], buf[9]]); // low 16 bits of u32
|
||||
let sequence = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]);
|
||||
let rssi = buf[16] as i8; // #332: was buf[14], 2 bytes off
|
||||
let noise_floor = buf[17] as i8; // #332: was buf[15], 2 bytes off
|
||||
|
||||
let iq_start = 20;
|
||||
let n_pairs = n_antennas as usize * n_subcarriers as usize;
|
||||
@@ -1612,7 +1546,7 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
|
||||
let raw_score = compute_person_score(&features);
|
||||
s.smoothed_person_score = s.smoothed_person_score * 0.90 + raw_score * 0.10;
|
||||
let est_persons = if classification.presence {
|
||||
let count = s.person_count();
|
||||
let count = score_to_person_count(s.smoothed_person_score, s.prev_person_count);
|
||||
s.prev_person_count = count;
|
||||
count
|
||||
} else {
|
||||
@@ -1649,16 +1583,12 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
|
||||
model_status: None,
|
||||
persons: None,
|
||||
estimated_persons: if est_persons > 0 { Some(est_persons) } else { None },
|
||||
node_features: None,
|
||||
};
|
||||
|
||||
// Populate persons from the sensing update (Kalman-smoothed via tracker).
|
||||
let raw_persons = derive_pose_from_sensing(&update);
|
||||
let tracked = tracker_bridge::tracker_update(
|
||||
&mut s.pose_tracker, &mut s.last_tracker_instant, raw_persons,
|
||||
);
|
||||
if !tracked.is_empty() {
|
||||
update.persons = Some(tracked);
|
||||
// Populate persons from the sensing update.
|
||||
let persons = derive_pose_from_sensing(&update);
|
||||
if !persons.is_empty() {
|
||||
update.persons = Some(persons);
|
||||
}
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&update) {
|
||||
@@ -1749,7 +1679,7 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
|
||||
let raw_score = compute_person_score(&features);
|
||||
s.smoothed_person_score = s.smoothed_person_score * 0.90 + raw_score * 0.10;
|
||||
let est_persons = if classification.presence {
|
||||
let count = s.person_count();
|
||||
let count = score_to_person_count(s.smoothed_person_score, s.prev_person_count);
|
||||
s.prev_person_count = count;
|
||||
count
|
||||
} else {
|
||||
@@ -1786,15 +1716,11 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
|
||||
model_status: None,
|
||||
persons: None,
|
||||
estimated_persons: if est_persons > 0 { Some(est_persons) } else { None },
|
||||
node_features: None,
|
||||
};
|
||||
|
||||
let raw_persons = derive_pose_from_sensing(&update);
|
||||
let tracked = tracker_bridge::tracker_update(
|
||||
&mut s.pose_tracker, &mut s.last_tracker_instant, raw_persons,
|
||||
);
|
||||
if !tracked.is_empty() {
|
||||
update.persons = Some(tracked);
|
||||
let persons = derive_pose_from_sensing(&update);
|
||||
if !persons.is_empty() {
|
||||
update.persons = Some(persons);
|
||||
}
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&update) {
|
||||
@@ -1971,13 +1897,9 @@ async fn handle_ws_pose_client(mut socket: WebSocket, state: SharedState) {
|
||||
keypoints,
|
||||
zone: "zone_1".into(),
|
||||
}]
|
||||
}).unwrap_or_else(|| {
|
||||
// Prefer tracked persons from broadcast if available
|
||||
sensing.persons.clone().unwrap_or_else(|| derive_pose_from_sensing(&sensing))
|
||||
})
|
||||
}).unwrap_or_else(|| derive_pose_from_sensing(&sensing))
|
||||
} else {
|
||||
// Prefer tracked persons from broadcast if available
|
||||
sensing.persons.clone().unwrap_or_else(|| derive_pose_from_sensing(&sensing))
|
||||
derive_pose_from_sensing(&sensing)
|
||||
};
|
||||
|
||||
let pose_msg = serde_json::json!({
|
||||
@@ -2676,7 +2598,7 @@ async fn api_info(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||
async fn pose_current(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||
let s = state.read().await;
|
||||
let persons = match &s.latest_update {
|
||||
Some(update) => update.persons.clone().unwrap_or_else(|| derive_pose_from_sensing(update)),
|
||||
Some(update) => derive_pose_from_sensing(update),
|
||||
None => vec![],
|
||||
};
|
||||
Json(serde_json::json!({
|
||||
@@ -3227,88 +3149,6 @@ async fn adaptive_unload(State(state): State<SharedState>) -> Json<serde_json::V
|
||||
Json(serde_json::json!({ "success": true, "message": "Adaptive model unloaded." }))
|
||||
}
|
||||
|
||||
// ── Field model calibration endpoints (eigenvalue person counting) ──────────
|
||||
|
||||
async fn calibration_start(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||
let mut s = state.write().await;
|
||||
// Guard: don't discard an in-progress or fresh calibration
|
||||
if let Some(ref fm) = s.field_model {
|
||||
match fm.status() {
|
||||
CalibrationStatus::Collecting => {
|
||||
return Json(serde_json::json!({
|
||||
"success": false,
|
||||
"error": "Calibration already in progress. Call /calibration/stop first.",
|
||||
"frame_count": fm.calibration_frame_count(),
|
||||
}));
|
||||
}
|
||||
CalibrationStatus::Fresh => {
|
||||
return Json(serde_json::json!({
|
||||
"success": false,
|
||||
"error": "A fresh calibration already exists. Call /calibration/stop or wait for expiry.",
|
||||
}));
|
||||
}
|
||||
_ => {} // Stale/Expired/Uncalibrated — ok to recalibrate
|
||||
}
|
||||
}
|
||||
match FieldModel::new(field_bridge::single_link_config()) {
|
||||
Ok(fm) => {
|
||||
s.field_model = Some(fm);
|
||||
Json(serde_json::json!({
|
||||
"success": true,
|
||||
"message": "Calibration started — keep room empty while frames accumulate.",
|
||||
}))
|
||||
}
|
||||
Err(e) => Json(serde_json::json!({
|
||||
"success": false,
|
||||
"error": format!("{e}"),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn calibration_stop(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||
let mut s = state.write().await;
|
||||
if let Some(ref mut fm) = s.field_model {
|
||||
let ts = chrono::Utc::now().timestamp_micros() as u64;
|
||||
match fm.finalize_calibration(ts, 0) {
|
||||
Ok(modes) => {
|
||||
let baseline = modes.baseline_eigenvalue_count;
|
||||
let variance_explained = modes.variance_explained;
|
||||
info!("Field model calibrated: baseline_eigenvalues={baseline}, variance_explained={variance_explained:.2}");
|
||||
Json(serde_json::json!({
|
||||
"success": true,
|
||||
"baseline_eigenvalue_count": baseline,
|
||||
"variance_explained": variance_explained,
|
||||
"frame_count": fm.calibration_frame_count(),
|
||||
}))
|
||||
}
|
||||
Err(e) => Json(serde_json::json!({
|
||||
"success": false,
|
||||
"error": format!("{e}"),
|
||||
})),
|
||||
}
|
||||
} else {
|
||||
Json(serde_json::json!({
|
||||
"success": false,
|
||||
"error": "No field model active — call /calibration/start first.",
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
async fn calibration_status(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||
let s = state.read().await;
|
||||
match s.field_model.as_ref() {
|
||||
Some(fm) => Json(serde_json::json!({
|
||||
"active": true,
|
||||
"status": format!("{:?}", fm.status()),
|
||||
"frame_count": fm.calibration_frame_count(),
|
||||
})),
|
||||
None => Json(serde_json::json!({
|
||||
"active": false,
|
||||
"status": "none",
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a simple timestamp string (epoch seconds) for recording IDs.
|
||||
fn chrono_timestamp() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
@@ -3455,34 +3295,6 @@ async fn sona_activate(
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/v1/nodes — per-node health and feature info.
|
||||
async fn nodes_endpoint(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||
let s = state.read().await;
|
||||
let now = std::time::Instant::now();
|
||||
let nodes: Vec<serde_json::Value> = s.node_states.iter()
|
||||
.map(|(&id, ns)| {
|
||||
let elapsed_ms = ns.last_frame_time
|
||||
.map(|t| now.duration_since(t).as_millis() as u64)
|
||||
.unwrap_or(999999);
|
||||
let stale = elapsed_ms > 5000;
|
||||
let status = if stale { "stale" } else { "active" };
|
||||
let rssi = ns.rssi_history.back().copied().unwrap_or(-90.0);
|
||||
serde_json::json!({
|
||||
"node_id": id,
|
||||
"status": status,
|
||||
"last_seen_ms": elapsed_ms,
|
||||
"rssi_dbm": rssi,
|
||||
"motion_level": &ns.current_motion_level,
|
||||
"person_count": ns.prev_person_count,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Json(serde_json::json!({
|
||||
"nodes": nodes,
|
||||
"total": nodes.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn info_page() -> Html<String> {
|
||||
Html(format!(
|
||||
"<html><body>\
|
||||
@@ -3574,33 +3386,15 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
else if vitals.presence { 0.3 }
|
||||
else { 0.05 };
|
||||
|
||||
// Aggregate person count: gate on presence first (matching WiFi path).
|
||||
// Aggregate person count across all active nodes.
|
||||
// Use max (not sum) because nodes in the same room see the
|
||||
// same people — summing would double-count.
|
||||
let now = std::time::Instant::now();
|
||||
let total_persons = if vitals.presence {
|
||||
let (fused, fallback_count) = multistatic_bridge::fuse_or_fallback(
|
||||
&s.multistatic_fuser, &s.node_states,
|
||||
);
|
||||
match fused {
|
||||
Some(ref f) => {
|
||||
let score = multistatic_bridge::compute_person_score_from_amplitudes(&f.fused_amplitude);
|
||||
s.smoothed_person_score = s.smoothed_person_score * 0.90 + score * 0.10;
|
||||
let count = s.person_count();
|
||||
s.prev_person_count = count;
|
||||
count.max(1) // presence=true => at least 1
|
||||
}
|
||||
None => fallback_count.unwrap_or(0).max(1),
|
||||
}
|
||||
} else {
|
||||
s.prev_person_count = 0;
|
||||
0
|
||||
};
|
||||
|
||||
// Feed field model calibration if active (use per-node history for ESP32).
|
||||
if let Some(ref mut fm) = s.field_model {
|
||||
if let Some(ns) = s.node_states.get(&node_id) {
|
||||
field_bridge::maybe_feed_calibration(fm, &ns.frame_history);
|
||||
}
|
||||
}
|
||||
let total_persons: usize = s.node_states.values()
|
||||
.filter(|n| n.last_frame_time.map_or(false, |t| now.duration_since(t).as_secs() < 10))
|
||||
.map(|n| n.prev_person_count)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
|
||||
// Build nodes array with all active nodes.
|
||||
let active_nodes: Vec<NodeInfo> = s.node_states.iter()
|
||||
@@ -3677,15 +3471,17 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
model_status: None,
|
||||
persons: None,
|
||||
estimated_persons: if total_persons > 0 { Some(total_persons) } else { None },
|
||||
node_features: None,
|
||||
};
|
||||
|
||||
let raw_persons = derive_pose_from_sensing(&update);
|
||||
let tracked = tracker_bridge::tracker_update(
|
||||
&mut s.pose_tracker, &mut s.last_tracker_instant, raw_persons,
|
||||
);
|
||||
if !tracked.is_empty() {
|
||||
update.persons = Some(tracked);
|
||||
let mut persons = derive_pose_from_sensing(&update);
|
||||
// RuVector Phase 2: temporal smoothing + coherence gating
|
||||
{
|
||||
let ns = s.node_states.entry(node_id).or_insert_with(NodeState::new);
|
||||
ns.update_coherence(vitals.motion_energy as f64);
|
||||
apply_temporal_smoothing(&mut persons, ns);
|
||||
}
|
||||
if !persons.is_empty() {
|
||||
update.persons = Some(persons);
|
||||
}
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&update) {
|
||||
@@ -3822,32 +3618,23 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
else if classification.motion_level == "present_still" { 0.3 }
|
||||
else { 0.05 };
|
||||
|
||||
// Aggregate person count: gate on presence first (matching WiFi path).
|
||||
// Aggregate person count across all active nodes.
|
||||
// Use max (not sum) because nodes in the same room see the
|
||||
// same people — summing would double-count.
|
||||
let now = std::time::Instant::now();
|
||||
let total_persons = if classification.presence {
|
||||
let (fused, fallback_count) = multistatic_bridge::fuse_or_fallback(
|
||||
&s.multistatic_fuser, &s.node_states,
|
||||
);
|
||||
match fused {
|
||||
Some(ref f) => {
|
||||
let score = multistatic_bridge::compute_person_score_from_amplitudes(&f.fused_amplitude);
|
||||
s.smoothed_person_score = s.smoothed_person_score * 0.90 + score * 0.10;
|
||||
let count = s.person_count();
|
||||
s.prev_person_count = count;
|
||||
count.max(1)
|
||||
}
|
||||
None => fallback_count.unwrap_or(0).max(1),
|
||||
}
|
||||
} else {
|
||||
s.prev_person_count = 0;
|
||||
0
|
||||
};
|
||||
let total_persons: usize = s.node_states.values()
|
||||
.filter(|n| n.last_frame_time.map_or(false, |t| now.duration_since(t).as_secs() < 10))
|
||||
.map(|n| n.prev_person_count)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
|
||||
// Feed field model calibration if active (use per-node history for ESP32).
|
||||
if let Some(ref mut fm) = s.field_model {
|
||||
if let Some(ns) = s.node_states.get(&node_id) {
|
||||
field_bridge::maybe_feed_calibration(fm, &ns.frame_history);
|
||||
}
|
||||
// Boost classification confidence with multi-node coverage.
|
||||
let n_active = s.node_states.values()
|
||||
.filter(|ns| ns.last_frame_time.map_or(false, |t| now.duration_since(t).as_secs() < 10))
|
||||
.count();
|
||||
if n_active > 1 {
|
||||
classification.confidence = (classification.confidence
|
||||
* (1.0 + 0.15 * (n_active as f64 - 1.0))).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
// Build nodes array with all active nodes.
|
||||
@@ -3887,15 +3674,17 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
model_status: None,
|
||||
persons: None,
|
||||
estimated_persons: if total_persons > 0 { Some(total_persons) } else { None },
|
||||
node_features: None,
|
||||
};
|
||||
|
||||
let raw_persons = derive_pose_from_sensing(&update);
|
||||
let tracked = tracker_bridge::tracker_update(
|
||||
&mut s.pose_tracker, &mut s.last_tracker_instant, raw_persons,
|
||||
);
|
||||
if !tracked.is_empty() {
|
||||
update.persons = Some(tracked);
|
||||
let mut persons = derive_pose_from_sensing(&update);
|
||||
// RuVector Phase 2: temporal smoothing + coherence gating
|
||||
{
|
||||
let ns = s.node_states.entry(node_id).or_insert_with(NodeState::new);
|
||||
ns.update_coherence(features.motion_band_power);
|
||||
apply_temporal_smoothing(&mut persons, ns);
|
||||
}
|
||||
if !persons.is_empty() {
|
||||
update.persons = Some(persons);
|
||||
}
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&update) {
|
||||
@@ -3975,7 +3764,7 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) {
|
||||
let raw_score = compute_person_score(&features);
|
||||
s.smoothed_person_score = s.smoothed_person_score * 0.90 + raw_score * 0.10;
|
||||
let est_persons = if classification.presence {
|
||||
let count = s.person_count();
|
||||
let count = score_to_person_count(s.smoothed_person_score, s.prev_person_count);
|
||||
s.prev_person_count = count;
|
||||
count
|
||||
} else {
|
||||
@@ -4022,16 +3811,12 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) {
|
||||
},
|
||||
persons: None,
|
||||
estimated_persons: if est_persons > 0 { Some(est_persons) } else { None },
|
||||
node_features: None,
|
||||
};
|
||||
|
||||
// Populate persons from the sensing update (Kalman-smoothed via tracker).
|
||||
let raw_persons = derive_pose_from_sensing(&update);
|
||||
let tracked = tracker_bridge::tracker_update(
|
||||
&mut s.pose_tracker, &mut s.last_tracker_instant, raw_persons,
|
||||
);
|
||||
if !tracked.is_empty() {
|
||||
update.persons = Some(tracked);
|
||||
// Populate persons from the sensing update.
|
||||
let persons = derive_pose_from_sensing(&update);
|
||||
if !persons.is_empty() {
|
||||
update.persons = Some(persons);
|
||||
}
|
||||
|
||||
if update.classification.presence {
|
||||
@@ -4660,29 +4445,6 @@ async fn main() {
|
||||
m
|
||||
}),
|
||||
node_states: HashMap::new(),
|
||||
// Accuracy sprint
|
||||
pose_tracker: PoseTracker::new(),
|
||||
last_tracker_instant: None,
|
||||
multistatic_fuser: {
|
||||
let mut fuser = MultistaticFuser::with_config(MultistaticConfig {
|
||||
min_nodes: 1, // single-node passthrough
|
||||
..Default::default()
|
||||
});
|
||||
if let Some(ref pos_str) = args.node_positions {
|
||||
let positions = field_bridge::parse_node_positions(pos_str);
|
||||
if !positions.is_empty() {
|
||||
info!("Configured {} node positions for multistatic fusion", positions.len());
|
||||
fuser.set_node_positions(positions);
|
||||
}
|
||||
}
|
||||
fuser
|
||||
},
|
||||
field_model: if args.calibrate {
|
||||
info!("Field model calibration enabled — room should be empty during startup");
|
||||
FieldModel::new(field_bridge::single_link_config()).ok()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
}));
|
||||
|
||||
// Start background tasks based on source
|
||||
@@ -4736,8 +4498,6 @@ async fn main() {
|
||||
.route("/api/v1/metrics", get(health_metrics))
|
||||
// Sensing endpoints
|
||||
.route("/api/v1/sensing/latest", get(latest))
|
||||
// Per-node health endpoint
|
||||
.route("/api/v1/nodes", get(nodes_endpoint))
|
||||
// Vital sign endpoints
|
||||
.route("/api/v1/vital-signs", get(vital_signs_endpoint))
|
||||
.route("/api/v1/edge-vitals", get(edge_vitals_endpoint))
|
||||
@@ -4779,10 +4539,6 @@ async fn main() {
|
||||
.route("/api/v1/adaptive/train", post(adaptive_train))
|
||||
.route("/api/v1/adaptive/status", get(adaptive_status))
|
||||
.route("/api/v1/adaptive/unload", post(adaptive_unload))
|
||||
// Field model calibration (eigenvalue-based person counting)
|
||||
.route("/api/v1/calibration/start", post(calibration_start))
|
||||
.route("/api/v1/calibration/stop", post(calibration_stop))
|
||||
.route("/api/v1/calibration/status", get(calibration_status))
|
||||
// Static UI files
|
||||
.nest_service("/ui", ServeDir::new(&ui_path))
|
||||
.layer(SetResponseHeaderLayer::overriding(
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
/**
|
||||
* Deep RF Intelligence Report — discovers everything WiFi can see.
|
||||
* Usage: node scripts/deep-scan.js --bind 192.168.1.20 --duration 10
|
||||
*/
|
||||
|
||||
const dgram = require('dgram');
|
||||
const { parseArgs } = require('util');
|
||||
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
port: { type: 'string', default: '5006' },
|
||||
bind: { type: 'string', default: '0.0.0.0' },
|
||||
duration: { type: 'string', default: '10' },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
const PORT = parseInt(args.port);
|
||||
const BIND = args.bind;
|
||||
const DUR = parseInt(args.duration) * 1000;
|
||||
|
||||
const vitals = {}; // nid -> [{time, br, hr, rssi, persons, motion, presence}]
|
||||
const features = {}; // nid -> [{time, features}]
|
||||
const raw = {}; // nid -> [{time, amps, phases, rssi, nSub}]
|
||||
|
||||
const server = dgram.createSocket('udp4');
|
||||
|
||||
server.on('message', (buf, rinfo) => {
|
||||
if (buf.length < 5) return;
|
||||
const magic = buf.readUInt32LE(0);
|
||||
const nid = buf[4];
|
||||
|
||||
if (magic === 0xC5110001 && buf.length > 20) {
|
||||
const iq = buf.subarray(20);
|
||||
const nSub = Math.floor(iq.length / 2);
|
||||
const amps = [];
|
||||
for (let i = 0; i < nSub * 2 && i < iq.length - 1; i += 2) {
|
||||
const I = iq.readInt8(i), Q = iq.readInt8(i + 1);
|
||||
amps.push(Math.sqrt(I * I + Q * Q));
|
||||
}
|
||||
if (!raw[nid]) raw[nid] = [];
|
||||
raw[nid].push({ time: Date.now(), amps, rssi: buf.readInt8(5), nSub });
|
||||
} else if (magic === 0xC5110002 && buf.length >= 32) {
|
||||
const br = buf.readUInt16LE(6) / 100;
|
||||
const hr = buf.readUInt32LE(8) / 10000;
|
||||
const rssi = buf.readInt8(12);
|
||||
const persons = buf[13];
|
||||
const motion = buf.readFloatLE(16);
|
||||
const presence = buf.readFloatLE(20);
|
||||
if (!vitals[nid]) vitals[nid] = [];
|
||||
vitals[nid].push({ time: Date.now(), br, hr, rssi, persons, motion, presence });
|
||||
} else if (magic === 0xC5110003 && buf.length >= 48) {
|
||||
const f = [];
|
||||
for (let i = 0; i < 8; i++) f.push(buf.readFloatLE(16 + i * 4));
|
||||
if (!features[nid]) features[nid] = [];
|
||||
features[nid].push({ time: Date.now(), features: f });
|
||||
}
|
||||
});
|
||||
|
||||
server.on('listening', () => {
|
||||
console.log(`Scanning on ${BIND}:${PORT} for ${DUR / 1000}s...\n`);
|
||||
});
|
||||
|
||||
server.bind(PORT, BIND);
|
||||
|
||||
setTimeout(() => {
|
||||
server.close();
|
||||
report();
|
||||
}, DUR);
|
||||
|
||||
function avg(arr) { return arr.length ? arr.reduce((a, b) => a + b) / arr.length : 0; }
|
||||
function std(arr) { const m = avg(arr); return Math.sqrt(arr.reduce((s, v) => s + (v - m) ** 2, 0) / (arr.length || 1)); }
|
||||
|
||||
function report() {
|
||||
const bar = (v, max = 20) => '█'.repeat(Math.min(Math.round(v * max), max)) + '░'.repeat(Math.max(max - Math.round(v * max), 0));
|
||||
const line = '═'.repeat(70);
|
||||
|
||||
console.log(line);
|
||||
console.log(' DEEP RF INTELLIGENCE REPORT — What WiFi Sees In Your Room');
|
||||
console.log(line);
|
||||
|
||||
// 1. WHO'S THERE
|
||||
console.log('\n📡 WHO IS IN THE ROOM');
|
||||
for (const nid of Object.keys(vitals).sort()) {
|
||||
const v = vitals[nid];
|
||||
const lastP = v[v.length - 1].presence;
|
||||
const avgMotion = avg(v.map(x => x.motion));
|
||||
console.log(` Node ${nid}: presence=${lastP.toFixed(1)} motion=${avgMotion.toFixed(1)} → ${lastP > 0.5 ? 'SOMEONE IS HERE' : 'Room may be empty'}`);
|
||||
}
|
||||
|
||||
// 2. WHAT ARE THEY DOING
|
||||
console.log('\n🏃 ACTIVITY DETECTION');
|
||||
for (const nid of Object.keys(vitals).sort()) {
|
||||
const v = vitals[nid];
|
||||
const motions = v.map(x => x.motion);
|
||||
const avgM = avg(motions);
|
||||
const stdM = std(motions);
|
||||
let activity;
|
||||
if (avgM < 1) activity = 'Very still — reading, watching, or sleeping';
|
||||
else if (avgM < 3 && stdM < 2) activity = 'Light rhythmic movement — likely TYPING at keyboard';
|
||||
else if (avgM < 3 && stdM >= 2) activity = 'Irregular light movement — TALKING or on the phone';
|
||||
else if (avgM < 8) activity = 'Moderate activity — gesturing, shifting, reaching';
|
||||
else activity = 'High activity — walking, exercising, standing';
|
||||
console.log(` Node ${nid}: energy=${avgM.toFixed(1)} variability=${stdM.toFixed(1)} → ${activity}`);
|
||||
}
|
||||
|
||||
// 3. VITAL SIGNS
|
||||
console.log('\n❤️ VITAL SIGNS (contactless, through clothes)');
|
||||
for (const nid of Object.keys(vitals).sort()) {
|
||||
const v = vitals[nid];
|
||||
const brs = v.map(x => x.br);
|
||||
const hrs = v.map(x => x.hr);
|
||||
const brAvg = avg(brs), brStd = std(brs);
|
||||
const hrAvg = avg(hrs), hrStd = std(hrs);
|
||||
|
||||
let brState = brStd < 2 ? 'very regular (calm/focused)' : brStd < 5 ? 'normal' : 'variable (talking/active)';
|
||||
let hrState = hrAvg < 60 ? 'athletic resting' : hrAvg < 80 ? 'relaxed' : hrAvg < 100 ? 'normal/active' : 'elevated';
|
||||
let stressHint = hrStd < 3 ? 'LOW stress (steady HR)' : hrStd < 8 ? 'MODERATE' : 'HIGH variability (could be relaxed OR stressed)';
|
||||
|
||||
console.log(` Node ${nid}:`);
|
||||
console.log(` Breathing: ${brAvg.toFixed(0)} BPM (±${brStd.toFixed(1)}) — ${brState}`);
|
||||
console.log(` Heart rate: ${hrAvg.toFixed(0)} BPM (±${hrStd.toFixed(1)}) — ${hrState}`);
|
||||
console.log(` Stress indicator: ${stressHint}`);
|
||||
}
|
||||
|
||||
// 4. YOUR DISTANCE FROM EACH NODE
|
||||
console.log('\n📏 POSITION IN ROOM');
|
||||
const distances = {};
|
||||
for (const nid of Object.keys(vitals).sort()) {
|
||||
const rssis = vitals[nid].map(x => x.rssi);
|
||||
const avgRssi = avg(rssis);
|
||||
const dist = Math.pow(10, (-30 - avgRssi) / 20);
|
||||
distances[nid] = dist;
|
||||
console.log(` Node ${nid}: RSSI=${avgRssi.toFixed(0)} dBm → ~${dist.toFixed(1)}m away`);
|
||||
}
|
||||
const nids = Object.keys(distances).sort();
|
||||
if (nids.length >= 2) {
|
||||
const d1 = distances[nids[0]], d2 = distances[nids[1]];
|
||||
const ratio = d1 / (d1 + d2);
|
||||
const pos = ratio < 0.4 ? 'closer to Node ' + nids[0] : ratio > 0.6 ? 'closer to Node ' + nids[1] : 'CENTERED between nodes';
|
||||
console.log(` Position: ${pos} (ratio: ${(ratio * 100).toFixed(0)}%)`);
|
||||
}
|
||||
|
||||
// 5. OBJECTS IN THE ROOM (from subcarrier nulls)
|
||||
console.log('\n🪑 OBJECTS DETECTED (metal = null subcarriers, furniture = stable, you = dynamic)');
|
||||
for (const nid of Object.keys(raw).sort()) {
|
||||
const frames = raw[nid];
|
||||
if (!frames.length) continue;
|
||||
const nSub = frames[0].nSub;
|
||||
|
||||
// Compute per-subcarrier variance
|
||||
const ampMeans = new Float64Array(nSub);
|
||||
const ampVars = new Float64Array(nSub);
|
||||
for (const f of frames) {
|
||||
for (let i = 0; i < Math.min(nSub, f.amps.length); i++) ampMeans[i] += f.amps[i];
|
||||
}
|
||||
for (let i = 0; i < nSub; i++) ampMeans[i] /= frames.length;
|
||||
for (const f of frames) {
|
||||
for (let i = 0; i < Math.min(nSub, f.amps.length); i++) ampVars[i] += (f.amps[i] - ampMeans[i]) ** 2;
|
||||
}
|
||||
for (let i = 0; i < nSub; i++) ampVars[i] = Math.sqrt(ampVars[i] / frames.length);
|
||||
|
||||
let nullCount = 0, dynamicCount = 0, staticCount = 0;
|
||||
const overallMean = ampMeans.reduce((a, b) => a + b) / nSub;
|
||||
for (let i = 0; i < nSub; i++) {
|
||||
if (ampMeans[i] < overallMean * 0.15) nullCount++;
|
||||
else if (ampVars[i] > 1.0) dynamicCount++;
|
||||
else staticCount++;
|
||||
}
|
||||
|
||||
console.log(` Node ${nid} (${nSub} subcarriers, ${frames.length} frames):`);
|
||||
console.log(` 🔩 Metal objects: ${nullCount} null subcarriers (${(100 * nullCount / nSub).toFixed(0)}%) — desk frame, monitor bezel, laptop chassis`);
|
||||
console.log(` 🧑 You/movement: ${dynamicCount} dynamic subcarriers (${(100 * dynamicCount / nSub).toFixed(0)}%) — person + micro-movements`);
|
||||
console.log(` 🧱 Walls/furniture: ${staticCount} static (${(100 * staticCount / nSub).toFixed(0)}%) — walls, ceiling, wooden furniture`);
|
||||
}
|
||||
|
||||
// 6. ELECTRONICS DETECTED
|
||||
console.log('\n💻 ELECTRONICS (from WiFi network scan perspective)');
|
||||
console.log(' Known devices transmitting WiFi in range:');
|
||||
console.log(' • Your router (ruv.net) — strongest signal, channel 5');
|
||||
console.log(' • HP M255 LaserJet — WiFi Direct on channel 5, ~2m away');
|
||||
console.log(' • Cognitum Seed — if plugged in (Pi Zero 2W)');
|
||||
console.log(' • 2x ESP32-S3 — the sensing nodes themselves');
|
||||
console.log(' • Your laptop/desktop — connected to ruv.net');
|
||||
console.log(' Neighbor devices (through walls):');
|
||||
console.log(' • COGECO-21B20 (100% signal, ch 11) — very close neighbor');
|
||||
console.log(' • conclusion mesh (44%, ch 3) — mesh network nearby');
|
||||
console.log(' • NETGEAR72 (42%, ch 9) — another neighbor');
|
||||
|
||||
// 7. INVISIBLE PHYSICS
|
||||
console.log('\n🔬 INVISIBLE PHYSICS');
|
||||
for (const nid of Object.keys(raw).sort()) {
|
||||
const frames = raw[nid];
|
||||
if (frames.length < 2) continue;
|
||||
|
||||
// Phase stability = room stability
|
||||
const first = frames[0], last = frames[frames.length - 1];
|
||||
const nCommon = Math.min(first.amps.length, last.amps.length);
|
||||
let phaseShift = 0;
|
||||
for (let i = 0; i < nCommon; i++) {
|
||||
const ampChange = Math.abs(last.amps[i] - first.amps[i]);
|
||||
phaseShift += ampChange;
|
||||
}
|
||||
phaseShift /= nCommon;
|
||||
|
||||
const rssis = frames.map(f => f.rssi);
|
||||
const rssiStd = std(rssis);
|
||||
|
||||
console.log(` Node ${nid}:`);
|
||||
console.log(` Amplitude drift: ${phaseShift.toFixed(2)} over ${((last.time - first.time) / 1000).toFixed(0)}s — ${phaseShift < 1 ? 'STABLE environment' : phaseShift < 3 ? 'minor movement' : 'active changes'}`);
|
||||
console.log(` RSSI stability: ±${rssiStd.toFixed(1)} dB — ${rssiStd < 2 ? 'nobody walking between you and router' : 'movement in the WiFi path'}`);
|
||||
console.log(` Fresnel zones: ${nCommon > 100 ? '128+ subcarriers = 5cm resolution potential' : nCommon + ' subcarriers'}`);
|
||||
}
|
||||
|
||||
// 8. FEATURE FINGERPRINT
|
||||
console.log('\n🧬 YOUR RF FINGERPRINT RIGHT NOW');
|
||||
for (const nid of Object.keys(features).sort()) {
|
||||
const f = features[nid];
|
||||
if (!f.length) continue;
|
||||
const last = f[f.length - 1].features;
|
||||
const names = ['Presence', 'Motion', 'Breathing', 'HeartRate', 'PhaseVar', 'Persons', 'Fall', 'RSSI'];
|
||||
console.log(` Node ${nid}:`);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
console.log(` ${names[i].padStart(10)}: ${bar(last[i])} ${last[i].toFixed(2)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${line}`);
|
||||
console.log(' WiFi signals reveal: who, what they\'re doing, how they feel,');
|
||||
console.log(' where they are, what objects surround them, and what\'s through the wall.');
|
||||
console.log(' No cameras. No wearables. No microphones. Just radio physics.');
|
||||
console.log(line);
|
||||
}
|
||||
+1
-4
@@ -25,7 +25,6 @@ const { parseArgs } = require('util');
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
port: { type: 'string', short: 'p', default: '5006' },
|
||||
bind: { type: 'string', short: 'b', default: '0.0.0.0' },
|
||||
duration: { type: 'string', short: 'd' },
|
||||
json: { type: 'boolean', default: false },
|
||||
interval: { type: 'string', short: 'i', default: '2000' },
|
||||
@@ -574,9 +573,7 @@ function main() {
|
||||
}
|
||||
});
|
||||
|
||||
// On Windows, binding to 0.0.0.0 may be blocked by firewall.
|
||||
// Use --bind <ip> to specify your WiFi IP (e.g., --bind 192.168.1.20)
|
||||
server.bind(PORT, args.bind);
|
||||
server.bind(PORT);
|
||||
|
||||
// Periodic display update
|
||||
const displayTimer = setInterval(display, INTERVAL_MS);
|
||||
|
||||
@@ -391,16 +391,7 @@ def run_bridge(args):
|
||||
# Open UDP listener
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
bind_addr = args.bind_addr
|
||||
if bind_addr == "auto":
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("192.168.1.1", 80))
|
||||
bind_addr = s.getsockname()[0]
|
||||
s.close()
|
||||
except Exception:
|
||||
bind_addr = "0.0.0.0"
|
||||
sock.bind((bind_addr, args.udp_port))
|
||||
sock.bind(("0.0.0.0", args.udp_port))
|
||||
sock.settimeout(1.0) # 1s timeout for responsive time-based flushing
|
||||
log.info(
|
||||
"Listening on UDP port %d (batch size: %d, flush interval: %.0fs)",
|
||||
@@ -606,11 +597,6 @@ def main():
|
||||
default=5006,
|
||||
help="UDP port to listen on (default: 5006)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--bind-addr",
|
||||
default="auto",
|
||||
help="Bind address for UDP listener (default: auto-detect WiFi IP; use 0.0.0.0 for all interfaces)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
|
||||
@@ -1257,13 +1257,9 @@ async function main() {
|
||||
contrastiveResult.finalLoss = finalContrastiveLoss;
|
||||
contrastiveResult.improvement = contrastiveImprovement;
|
||||
|
||||
// Export contrastive training data (skip for large datasets to avoid JSON string limit)
|
||||
if (contrastiveTrainer.getTripletCount() < 100000) {
|
||||
const contrastiveOutDir = contrastiveTrainer.exportTrainingData();
|
||||
console.log(` Training data exported to: ${contrastiveOutDir}`);
|
||||
} else {
|
||||
console.log(` Skipping triplet export (${contrastiveTrainer.getTripletCount()} triplets too large for JSON)`);
|
||||
}
|
||||
// Export contrastive training data
|
||||
const contrastiveOutDir = contrastiveTrainer.exportTrainingData();
|
||||
console.log(` Training data exported to: ${contrastiveOutDir}`);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 2: Task head training via TrainingPipeline
|
||||
|
||||
Vendored
+1
-1
Submodule vendor/ruvector updated: eeb3d8602c...050c3fe6f8
Reference in New Issue
Block a user