mirror of
https://github.com/ruvnet/RuView
synced 2026-06-13 10:53:20 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 85417b84a6 | |||
| 4fc491dea5 | |||
| 4f6780f884 | |||
| 085af0c2be | |||
| f4e636aaa2 | |||
| 582d51aed6 | |||
| b31efe5e92 | |||
| f03b484dd1 | |||
| 7a75277d58 | |||
| 73ce72d39c |
+2
-1
@@ -248,4 +248,5 @@ v1/src/sensing/mac_wifi
|
||||
**/node_modules/
|
||||
|
||||
# Local build scripts
|
||||
firmware/esp32-csi-node/build_firmware.bat
|
||||
firmware/esp32-csi-node/build_firmware.batdata/
|
||||
models/
|
||||
|
||||
@@ -7,32 +7,32 @@
|
||||
</p>
|
||||
|
||||
> **Beta Software** — Under active development. APIs and firmware may change. Known limitations:
|
||||
> - No pre-trained model weights are provided; training from scratch is required
|
||||
> - ESP32-C3 and original ESP32 are not supported (single-core, insufficient for CSI DSP)
|
||||
> - Single ESP32 deployments have limited spatial resolution — use 2+ nodes or add a [Cognitum Seed](https://cognitum.one) for best results
|
||||
> - Multi-person counting (n_persons) may overcount in single-occupancy scenarios ([#348](https://github.com/ruvnet/RuView/issues/348))
|
||||
> - Camera-free pose accuracy is limited (2.5% PCK@20) — camera-labeled data significantly improves accuracy
|
||||
>
|
||||
> Contributions and bug reports welcome at [Issues](https://github.com/ruvnet/RuView/issues).
|
||||
|
||||
## **See through walls with WiFi + Ai** ##
|
||||
## **See through walls with WiFi** ##
|
||||
|
||||
**Perceive the world through signals.** No cameras. No wearables. No Internet. Just physics.
|
||||
**Turn ordinary WiFi into a sensing system.** Detect people, measure breathing and heart rate, track movement, and monitor rooms — through walls, in the dark, with no cameras or wearables. Just physics.
|
||||
|
||||
### π RuView is an edge AI perception system that learns directly from the environment around it.
|
||||
### π RuView is a WiFi sensing platform that turns radio signals into spatial intelligence.
|
||||
|
||||
Instead of relying on cameras or cloud models, it observes whatever signals exist in a space such as WiFi, radio waves across the spectrum, motion patterns, vibration, sound, or other sensory inputs and builds an understanding of what is happening locally.
|
||||
Every WiFi router already fills your space with radio waves. When people move, breathe, or even sit still, they disturb those waves in measurable ways. RuView captures these disturbances using Channel State Information (CSI) from low-cost ESP32 sensors and turns them into actionable data: who's there, what they're doing, and whether they're okay.
|
||||
|
||||
Built on top of [RuVector](https://github.com/ruvnet/ruvector/) Self Learning Vector Memory system and [Cognitum.One](https://Cognitum.One) , the project became widely known for its implementation of WiFi DensePose — a sensing technique first explored in academic research such as Carnegie Mellon University's *DensePose From WiFi* work. That research demonstrated that WiFi signals can be used to reconstruct human pose.
|
||||
**What it senses:**
|
||||
- **Presence and occupancy** — detect people through walls, count them, track entries and exits
|
||||
- **Vital signs** — breathing rate and heart rate, contactless, while sleeping or sitting
|
||||
- **Activity recognition** — walking, sitting, gestures, falls — from temporal CSI patterns
|
||||
- **Environment mapping** — RF fingerprinting identifies rooms, detects moved furniture, spots new objects
|
||||
- **Sleep quality** — overnight monitoring with sleep stage classification and apnea screening
|
||||
|
||||
RuView extends that concept into a practical edge system. By analyzing Channel State Information (CSI) disturbances caused by human movement, RuView reconstructs body position, breathing rate, heart rate, and presence in real time using physics-based signal processing and machine learning.
|
||||
Built on [RuVector](https://github.com/ruvnet/ruvector/) and [Cognitum Seed](https://cognitum.one), RuView runs entirely on edge hardware — an ESP32 mesh (as low as $9 per node) paired with a Cognitum Seed for persistent memory, cryptographic attestation, and AI integration. No cloud, no cameras, no internet required.
|
||||
|
||||
Unlike research systems that rely on synchronized cameras for training, RuView is designed to operate entirely from radio signals and self-learned embeddings at the edge.
|
||||
The system learns each environment locally using spiking neural networks that adapt in under 30 seconds, with multi-frequency mesh scanning across 6 WiFi channels that uses your neighbors' routers as free radar illuminators. Every measurement is cryptographically attested via an Ed25519 witness chain.
|
||||
|
||||
The system runs entirely on inexpensive hardware such as an ESP32 sensor mesh (as low as ~$1 per node). Small programmable edge modules analyze signals locally and learn the RF signature of a room over time, allowing the system to separate the environment from the activity happening inside it.
|
||||
|
||||
Because RuView learns in proximity to the signals it observes, it improves as it operates. Each deployment develops a local model of its surroundings and continuously adapts without requiring cameras, labeled data, or cloud infrastructure.
|
||||
|
||||
In practice this means ordinary environments gain a new kind of spatial awareness. Rooms, buildings, and devices begin to sense presence, movement, and vital activity using the signals that already fill the space.
|
||||
RuView also supports pose estimation (17 COCO keypoints via the WiFlow architecture), trained entirely without cameras using 10 sensor signals — a technique pioneered from the original *DensePose From WiFi* research at Carnegie Mellon University.
|
||||
|
||||
### Built for low-power edge applications
|
||||
|
||||
@@ -54,25 +54,39 @@ In practice this means ordinary environments gain a new kind of spatial awarenes
|
||||
> | **Heart rate** | Bandpass 0.8-2.0 Hz → zero-crossing BPM | 40-120 BPM |
|
||||
> | **Presence sensing** | Trained model + PIR fusion — 100% accuracy | 0.012 ms latency |
|
||||
> | **Through-wall** | Fresnel zone geometry + multipath modeling | Up to 5m depth |
|
||||
> | **Edge intelligence** | 8-dim feature vectors + RVF store on Cognitum Seed | $27 total BOM |
|
||||
> | **Edge intelligence** | 8-dim feature vectors + RVF store on Cognitum Seed | $140 total BOM |
|
||||
> | **Camera-free training** | 10 sensor signals, no labels needed | 84s on M4 Pro |
|
||||
> | **Multi-frequency mesh** | Channel hopping across 6 bands, neighbor APs as illuminators | 3x sensing bandwidth |
|
||||
|
||||
```bash
|
||||
# 30 seconds to live sensing — no toolchain required
|
||||
# Option 1: Docker (simulated data, no hardware needed)
|
||||
docker pull ruvnet/wifi-densepose:latest
|
||||
docker run -p 3000:3000 ruvnet/wifi-densepose:latest
|
||||
# Open http://localhost:3000
|
||||
|
||||
# Option 2: Live sensing with ESP32-S3 hardware ($9)
|
||||
# Flash firmware, provision WiFi, and start sensing:
|
||||
python -m esptool --chip esp32s3 --port COM9 --baud 460800 \
|
||||
write_flash 0x0 bootloader.bin 0x8000 partition-table.bin \
|
||||
0xf000 ota_data_initial.bin 0x20000 esp32-csi-node.bin
|
||||
python firmware/esp32-csi-node/provision.py --port COM9 \
|
||||
--ssid "YourWiFi" --password "secret" --target-ip 192.168.1.20
|
||||
|
||||
# Option 3: Full system with Cognitum Seed ($140)
|
||||
# ESP32 streams CSI → bridge forwards to Seed for persistent storage + kNN + witness chain
|
||||
node scripts/rf-scan.js --port 5006 # Live RF room scan
|
||||
node scripts/snn-csi-processor.js --port 5006 # SNN real-time learning
|
||||
node scripts/mincut-person-counter.js --port 5006 # Correct person counting
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> **CSI-capable hardware required.** Pose estimation, vital signs, and through-wall sensing rely on Channel State Information (CSI) — per-subcarrier amplitude and phase data that standard consumer WiFi does not expose. You need CSI-capable hardware (ESP32-S3 or a research NIC) for full functionality. Consumer WiFi laptops can only provide RSSI-based presence detection, which is significantly less capable.
|
||||
> **CSI-capable hardware recommended.** Presence, vital signs, through-wall sensing, and all advanced capabilities require Channel State Information (CSI) from an ESP32-S3 ($9) or research NIC. The Docker image runs with simulated data for evaluation. Consumer WiFi laptops provide RSSI-only presence detection.
|
||||
|
||||
> **Hardware options** for live CSI capture:
|
||||
>
|
||||
> | Option | Hardware | Cost | Full CSI | Capabilities |
|
||||
> |--------|----------|------|----------|-------------|
|
||||
> | **ESP32 + Cognitum Seed** (recommended) | ESP32-S3 + Cognitum Seed (Pi Zero 2 W) | ~$27 | Yes | Pose, breathing, heartbeat, motion, presence + persistent vector store, kNN search, witness chain, MCP proxy |
|
||||
> | **ESP32 + Cognitum Seed** (recommended) | ESP32-S3 + [Cognitum Seed](https://cognitum.one) | ~$140 | Yes | Pose, breathing, heartbeat, motion, presence + persistent vector store, kNN search, witness chain, MCP proxy |
|
||||
> | **ESP32 Mesh** | 3-6x ESP32-S3 + WiFi router | ~$54 | Yes | Pose, breathing, heartbeat, motion, presence |
|
||||
> | **Research NIC** | Intel 5300 / Atheros AR9580 | ~$50-100 | Yes | Full CSI with 3x3 MIMO |
|
||||
> | **Any WiFi** | Windows, macOS, or Linux laptop | $0 | No | RSSI-only: coarse presence and motion |
|
||||
@@ -81,16 +95,67 @@ docker run -p 3000:3000 ruvnet/wifi-densepose:latest
|
||||
>
|
||||
---
|
||||
|
||||
### What's New in v0.5.4
|
||||
### What's New in v0.5.5
|
||||
|
||||
<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:
|
||||
|
||||
| Capability | What it does | ADR |
|
||||
|-----------|-------------|-----|
|
||||
| **Spiking Neural Network** | Adapts to your room in <30s with STDP online learning — no labels, no batches, 16-160x less compute | [ADR-074](docs/adr/ADR-074-spiking-neural-csi-sensing.md) |
|
||||
| **MinCut Person Counting** | Stoer-Wagner min-cut on subcarrier correlation graph — **fixes #348** (was always 4, now correct) | [ADR-075](docs/adr/ADR-075-mincut-person-separation.md) |
|
||||
| **CNN Spectrogram Embeddings** | Treat CSI as a 64×20 image → 128-dim embedding for environment fingerprinting (0.95+ similarity) | [ADR-076](docs/adr/ADR-076-csi-spectrogram-embeddings.md) |
|
||||
| **WiFlow SOTA Architecture** | TCN + axial attention + pose decoder → 17 COCO keypoints, 1.8M params (881 KB at 4-bit) | [ADR-072](docs/adr/ADR-072-wiflow-architecture.md) |
|
||||
| **Multi-Frequency Mesh** | Channel hopping across 6 bands, neighbor WiFi as passive radar illuminators | [ADR-073](docs/adr/ADR-073-multifrequency-mesh-scan.md) |
|
||||
|
||||
```bash
|
||||
# Live RF room scan (spectrum visualization)
|
||||
node scripts/rf-scan.js --port 5006 --duration 30
|
||||
|
||||
# Correct person counting (fixes #348)
|
||||
node scripts/mincut-person-counter.js --port 5006
|
||||
|
||||
# SNN real-time adaptation
|
||||
node scripts/snn-csi-processor.js --port 5006
|
||||
|
||||
# CNN spectrogram embeddings
|
||||
node scripts/csi-spectrogram.js --replay data/recordings/*.csi.jsonl
|
||||
|
||||
# WiFlow 17-keypoint pose training
|
||||
node scripts/train-wiflow.js --data data/recordings/*.csi.jsonl
|
||||
|
||||
# Enable channel hopping on ESP32
|
||||
python firmware/esp32-csi-node/provision.py --port COM9 --hop-channels "1,6,11"
|
||||
```
|
||||
|
||||
**Validated benchmarks:**
|
||||
|
||||
| Metric | v0.5.4 | v0.5.5 |
|
||||
|--------|--------|--------|
|
||||
| Person counting | Broken (always 4) | **Correct** (MinCut, 24/24) |
|
||||
| WiFi channels | 1 | **6** (multi-freq hopping) |
|
||||
| Null subcarriers | 19% blocked | **16%** (frequency diversity) |
|
||||
| Pose model | 16K params (FC only) | **1.8M params** (WiFlow) |
|
||||
| Online adaptation | None | **<30s** (SNN STDP) |
|
||||
| Fingerprint dims | 8 | **128** (CNN spectrogram) |
|
||||
| Multi-node fusion | Average | **GATv2 attention** |
|
||||
| New scripts | 0 | **15+** |
|
||||
| New ADRs | 3 | **8** (069-076) |
|
||||
|
||||
</details>
|
||||
|
||||
### What's New in v0.5.4
|
||||
|
||||
<details>
|
||||
<summary><strong>Cognitum Seed Integration + Camera-Free Pose Training</strong></summary>
|
||||
|
||||
**v0.5.4 transforms RuView from a real-time sensing tool into a persistent edge AI system.** Your ESP32 now remembers what it senses, learns without cameras, and proves its data cryptographically.
|
||||
|
||||
| Capability | Details | Hardware |
|
||||
|-----------|---------|----------|
|
||||
| **Persistent vector store** | Every sensing event stored as searchable 8-dim vector in RVF format | ESP32 + [Cognitum Seed](https://cognitum.one) ($27) |
|
||||
| **Persistent vector store** | Every sensing event stored as searchable 8-dim vector in RVF format | ESP32 + [Cognitum Seed](https://cognitum.one) ($140) |
|
||||
| **kNN similarity search** | "Find the 10 most similar states to right now" — anomaly detection, fingerprinting | Cognitum Seed |
|
||||
| **Witness chain** | SHA-256 tamper-evident audit trail for every measurement (1,747 entries validated) | Cognitum Seed |
|
||||
| **Camera-free pose training** | 17 COCO keypoints from 10 sensor signals — PIR, RSSI triangulation, subcarrier asymmetry, vibration, BME280 | 2x ESP32 + Seed |
|
||||
@@ -119,18 +184,24 @@ node scripts/train-camera-free.js --data data/recordings/pretrain-*.csi.jsonl
|
||||
node scripts/benchmark-ruvllm.js --model models/csi-ruvllm
|
||||
```
|
||||
|
||||
**Validated benchmarks (M4 Pro):**
|
||||
**Benchmarks — validated on real hardware (Apple M4 Pro + ESP32-S3 + Cognitum Seed):**
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Training time | 84.4s (2,360 augmented samples) |
|
||||
| Contrastive improvement | 33.9% |
|
||||
| Presence accuracy | 100% |
|
||||
| Inference latency | 0.012 ms |
|
||||
| Throughput | 171,472 emb/s |
|
||||
| Model size (4-bit) | 8 KB |
|
||||
| Skeleton violations | 0 / 100 frames |
|
||||
| Rust tests | 1,463 passed |
|
||||
| What we measured | Result | Why it matters |
|
||||
|-----------------|--------|---------------|
|
||||
| **Presence detection** | **100% accuracy** | Never misses a person, never false alarms |
|
||||
| **Person counting** | **24/24 correct** (MinCut) | Fixed the #1 user-reported issue |
|
||||
| **Inference speed** | **0.012 ms** per embedding | 83,000x faster than real-time |
|
||||
| **Throughput** | **171,472 embeddings/sec** | One Mac Mini handles 1,700+ ESP32 nodes |
|
||||
| **Training time** | **84 seconds** | From zero to trained model in under 2 minutes |
|
||||
| **Contrastive learning** | **33.9% improvement** | Model learns meaningful patterns from CSI |
|
||||
| **Model size** | **8 KB** (4-bit quantized) | Fits in ESP32 SRAM — no server needed |
|
||||
| **Skeleton physics** | **0 violations** in 100 frames | Every pose is anatomically valid |
|
||||
| **Pose keypoints** | **17 COCO keypoints** | Full body pose, no camera required |
|
||||
| **WiFi channels** | **6 simultaneous** | 3x more sensing data than single-channel |
|
||||
| **Online adaptation** | **<30 seconds** (SNN) | Learns a new room without retraining |
|
||||
| **Witness chain** | **2,547 entries** verified | Cryptographic proof every measurement is real |
|
||||
| **Test suite** | **1,463 tests passed** | Rock-solid foundation |
|
||||
| **Total hardware cost** | **$140** | ESP32 ($9) + [Cognitum Seed](https://cognitum.one) ($131) |
|
||||
|
||||
See [ADR-069](docs/adr/ADR-069-cognitum-seed-csi-pipeline.md), [ADR-071](docs/adr/ADR-071-ruvllm-training-pipeline.md), and the [Cognitum Seed tutorial](docs/tutorials/cognitum-seed-pretraining.md) for full details.
|
||||
|
||||
@@ -1117,7 +1188,8 @@ Download a pre-built binary — no build toolchain needed:
|
||||
|
||||
| Release | What's included | Tag |
|
||||
|---------|-----------------|-----|
|
||||
| [v0.5.4](https://github.com/ruvnet/RuView/releases/tag/v0.5.4-esp32) | **Latest** — Cognitum Seed integration ([ADR-069](docs/adr/ADR-069-cognitum-seed-csi-pipeline.md)), 8-dim feature vectors at 1 Hz, RVF vector store ingest, witness chain attestation, security hardening | `v0.5.4-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` |
|
||||
| [v0.4.1](https://github.com/ruvnet/RuView/releases/tag/v0.4.1-esp32) | CSI build fix, compile guard, AMOLED display, edge intelligence ([ADR-057](docs/adr/ADR-057-firmware-csi-build-guard.md)) | `v0.4.1-esp32` |
|
||||
@@ -1166,7 +1238,7 @@ Nodes can also hop across WiFi channels (1, 6, 11) to increase sensing bandwidth
|
||||
|
||||
### Cognitum Seed integration (ADR-069)
|
||||
|
||||
Connect an ESP32 to a [Cognitum Seed](https://cognitum.one) (Pi Zero 2 W, ~$15) for persistent vector storage, kNN search, cryptographic witness chain, and AI-accessible MCP proxy:
|
||||
Connect an ESP32 to a [Cognitum Seed](https://cognitum.one) ($131) for persistent vector storage, kNN search, cryptographic witness chain, and AI-accessible MCP proxy:
|
||||
|
||||
```
|
||||
ESP32-S3 ($9) ──UDP──> Host bridge ──HTTPS──> Cognitum Seed ($15)
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
# ADR-077: Novel RF Sensing Applications
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-04-02
|
||||
**Authors:** ruv
|
||||
**Depends on:** ADR-018 (CSI binary protocol), ADR-073 (multifrequency mesh scan), ADR-075 (MinCut person separation), ADR-076 (CSI spectrogram embeddings)
|
||||
|
||||
## Context
|
||||
|
||||
The existing ESP32 CSI + Cognitum Seed infrastructure collects rich multi-modal data:
|
||||
- 2 ESP32-S3 nodes streaming CSI at ~22 fps each (64-128 subcarriers, channel hopping ch 1/3/5/6/9/11)
|
||||
- Vitals extraction: breathing rate, heart rate, motion energy, presence score (1 Hz per node)
|
||||
- 8-dimensional feature vectors per frame
|
||||
- Cognitum Seed with BME280 (temp/humidity/pressure), PIR, reed switch, vibration sensor
|
||||
|
||||
No new hardware is required. All 6 applications below derive novel insights from data already being collected via the ADR-018 binary protocol over UDP port 5006.
|
||||
|
||||
## Decision
|
||||
|
||||
Implement 6 novel RF sensing applications as standalone Node.js scripts that process live UDP or replayed `.csi.jsonl` recordings.
|
||||
|
||||
---
|
||||
|
||||
## Application 1: Sleep Quality Monitoring
|
||||
|
||||
### Input
|
||||
Breathing rate (BR) and heart rate (HR) time series from vitals packets (0xC5110002), sampled at ~1 Hz per node over 6-8 hours.
|
||||
|
||||
### Algorithm
|
||||
Sliding window analysis (5-minute windows, 1-minute stride) classifying sleep stages:
|
||||
|
||||
| Stage | BR (BPM) | BR Variance | HR Pattern | Motion |
|
||||
|-------|----------|-------------|------------|--------|
|
||||
| **Deep (N3)** | 6-12 | Very low (<2.0) | Slow, regular | None |
|
||||
| **Light (N1/N2)** | 12-18 | Moderate (2.0-8.0) | Normal | Minimal |
|
||||
| **REM** | 15-25 | High (>8.0), irregular | Elevated | Eyes only (low CSI motion) |
|
||||
| **Awake** | >18 or <6 | Any | Variable | Moderate-high |
|
||||
|
||||
Each 5-minute window is scored by:
|
||||
1. Compute BR mean and variance within the window
|
||||
2. Compute HR mean and coefficient of variation (CV)
|
||||
3. Compute motion energy mean (from vitals `motion_energy` field)
|
||||
4. Classify stage using threshold hierarchy: Awake > REM > Light > Deep
|
||||
|
||||
### Output
|
||||
- Real-time sleep stage classification
|
||||
- ASCII hypnogram (time vs. stage)
|
||||
- Summary: total sleep time, sleep efficiency (TST / time in bed), time per stage
|
||||
- Optional JSON for health app integration
|
||||
|
||||
### Validation
|
||||
Overnight recording (`overnight-1775217646.csi.jsonl`, 113k frames, ~40 min) should show:
|
||||
- Transition from active (awake) to resting states
|
||||
- Decreased motion energy over time
|
||||
- BR stabilization in sleeping segments
|
||||
|
||||
### Clinical Relevance
|
||||
Consumer-grade sleep tracking without wearables. RF-based sensing avoids compliance issues (forgotten wristbands, dead batteries). Not diagnostic; informational only.
|
||||
|
||||
---
|
||||
|
||||
## Application 2: Breathing Disorder Screening (Apnea Detection)
|
||||
|
||||
### Input
|
||||
Breathing rate time series from vitals packets at ~1 Hz.
|
||||
|
||||
### Algorithm
|
||||
Detect respiratory events in the BR time series:
|
||||
|
||||
| Event | Definition | Duration |
|
||||
|-------|-----------|----------|
|
||||
| **Apnea** | BR drops below 3 BPM (effective cessation) | >= 10 seconds |
|
||||
| **Hypopnea** | BR drops > 50% from 5-min rolling baseline | >= 10 seconds |
|
||||
|
||||
Scoring:
|
||||
1. Maintain 5-minute rolling baseline BR (exponential moving average)
|
||||
2. Flag apnea when BR < 3 BPM for >= 10 consecutive seconds
|
||||
3. Flag hypopnea when BR < 50% of baseline for >= 10 consecutive seconds
|
||||
4. Compute AHI (Apnea-Hypopnea Index) = total events / hours monitored
|
||||
|
||||
| AHI | Severity |
|
||||
|-----|----------|
|
||||
| < 5 | Normal |
|
||||
| 5-15 | Mild |
|
||||
| 15-30 | Moderate |
|
||||
| > 30 | Severe |
|
||||
|
||||
### Output
|
||||
- Per-event log: type (apnea/hypopnea), start time, duration, BR during event
|
||||
- Hourly AHI and overall AHI
|
||||
- Severity classification
|
||||
- Alert on severe events (consecutive apneas > 30s)
|
||||
|
||||
### Clinical Relevance
|
||||
Pre-screening tool for obstructive sleep apnea (OSA). Provides motivation for clinical polysomnography referral. Not a diagnostic device; informational pre-screen only.
|
||||
|
||||
---
|
||||
|
||||
## Application 3: Emotional State / Stress Detection
|
||||
|
||||
### Input
|
||||
Heart rate time series from vitals packets at ~1 Hz.
|
||||
|
||||
### Algorithm
|
||||
Heart Rate Variability (HRV) analysis:
|
||||
|
||||
1. **RMSSD** (Root Mean Square of Successive Differences):
|
||||
- Compute successive HR differences within 5-minute windows
|
||||
- RMSSD = sqrt(mean(diff^2))
|
||||
- High RMSSD = high vagal tone = relaxed
|
||||
- Low RMSSD = sympathetic dominance = stressed
|
||||
|
||||
2. **LF/HF Ratio** (via FFT on 5-minute HR windows):
|
||||
- LF band: 0.04-0.15 Hz (sympathetic + parasympathetic)
|
||||
- HF band: 0.15-0.40 Hz (parasympathetic)
|
||||
- High LF/HF (> 2.0) = stressed
|
||||
- Low LF/HF (< 1.0) = relaxed
|
||||
|
||||
3. **Stress Score** (0-100):
|
||||
- `score = 50 * (1 - RMSSD_norm) + 50 * LF_HF_norm`
|
||||
- Where `RMSSD_norm` = RMSSD / max_expected_RMSSD (capped at 1.0)
|
||||
- And `LF_HF_norm` = min(LF_HF / 4.0, 1.0)
|
||||
|
||||
### Output
|
||||
- Real-time stress score (0-100)
|
||||
- RMSSD and LF/HF ratio per window
|
||||
- ASCII trend chart over hours
|
||||
- Activity context correlation (motion level vs. stress)
|
||||
|
||||
### Validation
|
||||
- Periods of activity (walking, working) should correlate with higher stress scores
|
||||
- Quiet rest should show lower scores
|
||||
- Sleeping should show lowest scores (high HRV, low LF/HF)
|
||||
|
||||
---
|
||||
|
||||
## Application 4: Gait Analysis / Movement Disorder Detection
|
||||
|
||||
### Input
|
||||
- Motion energy time series from vitals packets
|
||||
- CSI phase variance from raw CSI frames (0xC5110001)
|
||||
- Cross-node RSSI from vitals packets
|
||||
|
||||
### Algorithm
|
||||
|
||||
1. **Cadence Extraction**: FFT on motion_energy within 5-second sliding windows
|
||||
- Walking cadence: dominant frequency 0.8-2.0 Hz (normal: ~1.0 Hz = 120 steps/min)
|
||||
- Running: > 2.0 Hz
|
||||
- Stationary: no dominant peak
|
||||
|
||||
2. **Stride Regularity**: Autocorrelation of motion_energy
|
||||
- Regular walking: strong autocorrelation peak at step period
|
||||
- Irregularity score = 1 - (peak_height / baseline)
|
||||
|
||||
3. **Asymmetry Detection**: Compare motion energy oscillation between two ESP32 nodes
|
||||
- Symmetric gait: both nodes see similar oscillation period and amplitude
|
||||
- Asymmetry index = |period_node1 - period_node2| / mean_period
|
||||
|
||||
4. **Tremor Detection**: High-frequency phase variance analysis
|
||||
- Compute phase variance per subcarrier in 2-second windows
|
||||
- Tremor band: 3-8 Hz component in phase variance time series
|
||||
- Parkinsonian tremor: 4-6 Hz, resting
|
||||
- Essential tremor: 5-8 Hz, action
|
||||
|
||||
### Output
|
||||
- Cadence (steps/min)
|
||||
- Stride regularity score (0-1)
|
||||
- Asymmetry index (0 = symmetric, 1 = highly asymmetric)
|
||||
- Tremor score and dominant frequency
|
||||
- Walking vs. stationary classification
|
||||
|
||||
### Validation
|
||||
Overnight data should show clear stationary periods with no cadence detected. Any walking segments should show cadence in the 0.8-2.0 Hz range.
|
||||
|
||||
---
|
||||
|
||||
## Application 5: Material/Object Change Detection
|
||||
|
||||
### Input
|
||||
Per-subcarrier amplitude from raw CSI frames (0xC5110001).
|
||||
|
||||
### Algorithm
|
||||
|
||||
1. **Baseline Establishment** (first 10 minutes or configurable):
|
||||
- Record mean amplitude per subcarrier (Welford online mean)
|
||||
- Record null pattern: which subcarriers are below null threshold (amplitude < 2.0)
|
||||
|
||||
2. **Change Detection** (sliding 30-second windows):
|
||||
- Compare current null pattern to baseline
|
||||
- New nulls appearing = new metal object blocking RF path
|
||||
- Existing nulls disappearing = metal object removed
|
||||
- Null position shifted = object moved
|
||||
- Amplitude change without null change = non-metal material (wood, water, glass)
|
||||
|
||||
3. **Material Classification** heuristic:
|
||||
- Metal: sharp null (amplitude drops to near 0 on specific subcarriers)
|
||||
- Water/human: broad amplitude reduction across many subcarriers
|
||||
- Wood/plastic: minimal amplitude change, mostly phase shift
|
||||
- Glass: frequency-selective (affects higher subcarriers more)
|
||||
|
||||
### Output
|
||||
- Change events with timestamp, type (add/remove/move), affected subcarrier range
|
||||
- Estimated material category
|
||||
- Null pattern delta visualization (ASCII)
|
||||
- Event timeline for monitoring
|
||||
|
||||
### Validation
|
||||
Overnight data has 19% null baseline. Changes in null pattern over the recording period indicate environment changes (doors opening/closing, person entering/leaving).
|
||||
|
||||
---
|
||||
|
||||
## Application 6: Room Environment Fingerprinting
|
||||
|
||||
### Input
|
||||
- 8-dimensional feature vectors from feature packets (0xC5110003)
|
||||
- Motion energy and presence score from vitals packets
|
||||
|
||||
### Algorithm
|
||||
|
||||
1. **Online Clustering** using running k-means (k=5, updateable centroids):
|
||||
- Each incoming 8-dim feature vector is assigned to nearest centroid
|
||||
- Centroid updated via exponential moving average (alpha=0.01)
|
||||
- New cluster created if distance to all centroids exceeds threshold
|
||||
|
||||
2. **State Labeling** (heuristic from vitals correlation):
|
||||
- Cluster with lowest motion_energy = "empty/sleeping"
|
||||
- Cluster with highest motion_energy = "active/walking"
|
||||
- Intermediate clusters = "resting", "working", "transitional"
|
||||
|
||||
3. **Transition Tracking**:
|
||||
- Build state transition matrix (from_state -> to_state counts)
|
||||
- Detect anomalous transitions (rare in historical data)
|
||||
|
||||
4. **Daily Profile**:
|
||||
- Aggregate state durations per hour
|
||||
- Compare across days for routine detection
|
||||
|
||||
### Output
|
||||
- Current room state and confidence
|
||||
- State timeline (ASCII)
|
||||
- Transition matrix
|
||||
- Daily pattern profile
|
||||
- Anomaly score (deviation from established daily pattern)
|
||||
|
||||
### Validation
|
||||
Overnight recording should show 2-3 stable clusters corresponding to activity periods at different times. Transitions should be infrequent and correspond to real behavioral changes.
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
All scripts share common infrastructure:
|
||||
- ADR-018 binary packet parsing (same as rf-scan.js, mincut-person-counter.js)
|
||||
- JSONL replay via readline interface
|
||||
- Live UDP via dgram
|
||||
- Pure Node.js, no external dependencies
|
||||
- CLI: `--replay <file>` for offline, `--port <N>` for live, `--json` for programmatic output
|
||||
|
||||
| Script | Primary Packets | Key Algorithm |
|
||||
|--------|----------------|---------------|
|
||||
| `sleep-monitor.js` | vitals (0xC5110002) | BR/HR window classification |
|
||||
| `apnea-detector.js` | vitals (0xC5110002) | BR pause detection, AHI scoring |
|
||||
| `stress-monitor.js` | vitals (0xC5110002) | HRV RMSSD + FFT LF/HF |
|
||||
| `gait-analyzer.js` | vitals + raw CSI | FFT cadence + phase tremor |
|
||||
| `material-detector.js` | raw CSI (0xC5110001) | Null pattern baseline + delta |
|
||||
| `room-fingerprint.js` | feature (0xC5110003) + vitals | Online k-means clustering |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- 6 new sensing applications from existing hardware (zero additional cost)
|
||||
- All offline-capable via JSONL replay (no live hardware needed for development)
|
||||
- Pure JS, no native dependencies, runs on any platform with Node.js
|
||||
- Each script is standalone and composable
|
||||
|
||||
### Negative
|
||||
- Vitals accuracy depends on ESP32 CSI quality (RSSI, multipath)
|
||||
- HRV analysis at 1 Hz HR sampling is coarse compared to ECG
|
||||
- Material classification is heuristic, not definitive
|
||||
- Sleep staging without EEG is approximate (consumer-grade accuracy)
|
||||
|
||||
### Risks
|
||||
- Users may misinterpret health-related outputs as clinical diagnoses
|
||||
- Mitigation: all scripts include disclaimers in output headers
|
||||
@@ -0,0 +1,354 @@
|
||||
# ADR-078: Multi-Frequency Mesh Sensing Applications
|
||||
|
||||
| Field | Value |
|
||||
|-------------|--------------------------------------------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-04-02 |
|
||||
| **Authors** | ruv |
|
||||
| **Depends** | ADR-018 (binary frame), ADR-029 (channel hopping), ADR-073 (multi-frequency mesh scan) |
|
||||
|
||||
## Context
|
||||
|
||||
ADR-073 established multi-frequency mesh scanning: 2 ESP32-S3 nodes hopping across 6 WiFi channels (1, 3, 5, 6, 9, 11) with 9 neighbor WiFi networks as passive illuminators. This ADR defines 5 sensing applications that are **unique to multi-frequency mesh scanning** and impossible with single-channel WiFi sensing.
|
||||
|
||||
### Why Multi-Frequency is Required
|
||||
|
||||
Single-channel WiFi sensing captures CSI on one frequency (e.g., channel 5 at 2432 MHz). This provides amplitude and phase across ~52-64 OFDM subcarriers within a 20 MHz bandwidth. Multi-frequency mesh scanning extends this to 6 channels spanning 2412-2462 MHz (50 MHz total), with each channel providing independent multipath observations. The applications below exploit the frequency dimension that single-channel sensing cannot access.
|
||||
|
||||
### Available Infrastructure
|
||||
|
||||
| Resource | Detail |
|
||||
|----------|--------|
|
||||
| Node 1 (COM7) | ESP32-S3, channels 1, 6, 11 (non-overlapping), 200ms dwell |
|
||||
| Node 2 | ESP32-S3, channels 3, 5, 9 (interleaved, near neighbor APs), 200ms dwell |
|
||||
| Neighbor APs | 9 networks across channels 3, 5, 6, 9, 11 |
|
||||
| Data transport | UDP port 5006, ADR-018 binary format |
|
||||
| Recorded data | `data/recordings/overnight-*.csi.jsonl` |
|
||||
|
||||
### Neighbor AP Illuminator Table
|
||||
|
||||
| SSID | Channel | Freq (MHz) | Signal (%) | Role |
|
||||
|------|---------|------------|------------|------|
|
||||
| ruv.net | 5 | 2432 | 100 | Primary illuminator |
|
||||
| Cohen-Guest | 5 | 2432 | 100 | Co-channel illuminator |
|
||||
| COGECO-21B20 | 11 | 2462 | 100 | High-freq illuminator |
|
||||
| HP M255 LaserJet | 5 | 2432 | 94 | Device fingerprinting target |
|
||||
| conclusion mesh | 3 | 2422 | 44 | Low-freq illuminator |
|
||||
| NETGEAR72 | 9 | 2452 | 42 | Mid-high illuminator |
|
||||
| NETGEAR72-Guest | 9 | 2452 | 42 | Co-channel illuminator |
|
||||
| COGECO-4321 | 11 | 2462 | 30 | Weak high-freq illuminator |
|
||||
| Innanen | 6 | 2437 | 19 | Weak center-band illuminator |
|
||||
|
||||
## Decision
|
||||
|
||||
Implement 5 multi-frequency-specific sensing applications, each as a standalone Node.js script in `scripts/`.
|
||||
|
||||
---
|
||||
|
||||
## Application 1: RF Tomographic Imaging
|
||||
|
||||
### Principle
|
||||
|
||||
Each WiFi channel "sees" through the room differently because multipath interference patterns are frequency-dependent. A 2 cm path length difference produces a null at 2432 MHz but constructive interference at 2412 MHz. With 6 channels x 2 nodes, we have 12 independent RF path observations through the room.
|
||||
|
||||
RF tomography back-projects attenuation along each transmitter-receiver path. Where paths overlap with high attenuation, there is an absorbing object (person, furniture, wall). Where paths show low attenuation, the space is clear.
|
||||
|
||||
### Algorithm
|
||||
|
||||
```
|
||||
For each CSI frame:
|
||||
1. Compute path attenuation = RSSI_free_space - RSSI_measured
|
||||
2. For each cell in a 10x10 room grid:
|
||||
a. Compute the cell's distance to the TX->RX line (perpendicular distance)
|
||||
b. Weight contribution by 1/distance (cells near the path contribute more)
|
||||
3. Accumulate weighted attenuation across all frames, channels, and node pairs
|
||||
4. Normalize: cells with high accumulated attenuation = absorbers (people/objects)
|
||||
```
|
||||
|
||||
Uses the Algebraic Reconstruction Technique (ART) for iterative refinement, or simple backprojection for real-time display.
|
||||
|
||||
### Resolution
|
||||
|
||||
- Theoretical: ~lambda/2 = 6 cm (at 2.4 GHz)
|
||||
- Practical with 2 nodes: ~20 cm (limited by node geometry)
|
||||
- Frequency diversity gain: sqrt(6) improvement over single-channel = ~2.4x
|
||||
|
||||
### Why Single-Channel Cannot Do This
|
||||
|
||||
Single-channel provides only 1 frequency observation per path. Frequency-selective fading means a single channel may show zero attenuation through a person (if the path happens to be at a constructive interference point). Multiple channels provide independent attenuation measurements through the same spatial path, enabling reliable detection.
|
||||
|
||||
### Script
|
||||
|
||||
`scripts/rf-tomography.js`
|
||||
|
||||
---
|
||||
|
||||
## Application 2: Passive Bistatic Radar
|
||||
|
||||
### Principle
|
||||
|
||||
Neighbor WiFi APs transmit continuously and uncontrollably. The ESP32 nodes capture CSI from these transmissions, which includes phase and amplitude modulated by objects in the room. Each neighbor AP acts as a free "illuminator of opportunity" at a known position and frequency.
|
||||
|
||||
This is the same principle used by military passive radar systems (e.g., the Ukrainian Kolchuga, Czech VERA-NG) that use FM radio and TV transmitters to detect aircraft without emitting any signals themselves. Here we use WiFi APs instead of broadcast towers, and detect people instead of aircraft.
|
||||
|
||||
### Algorithm
|
||||
|
||||
```
|
||||
For each neighbor AP (identified by BSSID/channel):
|
||||
1. Track CSI phase progression across consecutive frames
|
||||
2. Compute Doppler shift: fd = d(phase)/dt / (2*pi)
|
||||
- Positive Doppler = target moving toward the AP
|
||||
- Negative Doppler = target moving away
|
||||
3. Compute range from subcarrier phase slope:
|
||||
- tau = d(phase)/d(subcarrier_freq) / (2*pi)
|
||||
- range = c * tau (where c = speed of light)
|
||||
4. Build range-Doppler map per AP
|
||||
5. Fuse multi-static detections:
|
||||
- Each AP provides a range ellipse (locus of constant TX->target->RX delay)
|
||||
- Intersection of 3+ ellipses = target position
|
||||
```
|
||||
|
||||
### Multi-Static Geometry
|
||||
|
||||
With 3+ neighbor APs as transmitters and 2 ESP32 receivers, we have 6+ bistatic pairs. Each pair constrains the target to an ellipse. The intersection provides 2D position.
|
||||
|
||||
```
|
||||
AP1 (ch5) AP2 (ch11)
|
||||
\ /
|
||||
\ TARGET /
|
||||
\ /|\ /
|
||||
\ / | \ /
|
||||
ESP32_1 ---*--+--*--- ESP32_2
|
||||
/ \ | / \
|
||||
/ \|/ \
|
||||
/ TARGET \
|
||||
/ \
|
||||
AP3 (ch3) AP4 (ch9)
|
||||
```
|
||||
|
||||
### Why Single-Channel Cannot Do This
|
||||
|
||||
Single-channel only captures CSI from APs on that one channel. With channel 5, you see ruv.net and Cohen-Guest, but miss COGECO-21B20 (ch11), conclusion mesh (ch3), NETGEAR72 (ch9). Multi-frequency scanning captures illumination from all 9 APs across 6 channels, providing the geometric diversity needed for position triangulation.
|
||||
|
||||
### Script
|
||||
|
||||
`scripts/passive-radar.js`
|
||||
|
||||
---
|
||||
|
||||
## Application 3: Frequency-Selective Material Classification
|
||||
|
||||
### Principle
|
||||
|
||||
Different materials interact with 2.4 GHz WiFi signals differently, and critically, their absorption/reflection varies with frequency:
|
||||
|
||||
| Material | Attenuation Pattern | Frequency Dependence |
|
||||
|----------|--------------------|--------------------|
|
||||
| Metal | Total reflection, deep null | Frequency-flat (blocks all equally) |
|
||||
| Water/Human body | Strong absorption | Increases with frequency (dielectric loss ~ f^2) |
|
||||
| Wood | Mild attenuation | Increases with frequency (moisture content) |
|
||||
| Glass | Low attenuation | Nearly frequency-flat |
|
||||
| Drywall | Low-moderate attenuation | Slight frequency dependence |
|
||||
| Concrete | Moderate-high attenuation | Increases with frequency |
|
||||
|
||||
### Algorithm
|
||||
|
||||
```
|
||||
For each subcarrier index i across all channels:
|
||||
1. Measure attenuation A(i, ch) on each channel
|
||||
2. Compute frequency selectivity:
|
||||
- Flat ratio = std(A across channels) / mean(A across channels)
|
||||
- Slope = linear regression of A vs frequency
|
||||
3. Classify:
|
||||
- Flat ratio < 0.1 AND high attenuation -> Metal
|
||||
- Flat ratio < 0.1 AND low attenuation -> Glass/Air
|
||||
- Positive slope (A increases with freq) AND high A -> Water/Human
|
||||
- Positive slope AND moderate A -> Wood
|
||||
- High variance across channels -> Complex scatterer
|
||||
```
|
||||
|
||||
### Physics Basis
|
||||
|
||||
At 2.4 GHz, water's complex permittivity is epsilon_r = 77 - j10. The imaginary component (loss) increases with frequency within the WiFi band. Metal is a perfect conductor regardless of frequency. Glass (epsilon_r ~ 6 - j0.1) has negligible loss at all WiFi frequencies.
|
||||
|
||||
The 50 MHz span (2412-2462 MHz) is only ~2% of the carrier frequency, but this is sufficient to detect the frequency-dependent absorption signature of water-bearing materials (human body, wet wood, potted plants) versus frequency-flat materials (metal, glass).
|
||||
|
||||
### Why Single-Channel Cannot Do This
|
||||
|
||||
Material classification requires measuring how attenuation varies with frequency. A single channel provides only one frequency point -- there is no frequency axis to measure against. Multi-frequency scanning provides 6 frequency points spanning 50 MHz, enabling slope and variance computation.
|
||||
|
||||
### Script
|
||||
|
||||
`scripts/material-classifier.js`
|
||||
|
||||
---
|
||||
|
||||
## Application 4: Through-Wall Motion Detection
|
||||
|
||||
### Principle
|
||||
|
||||
Lower WiFi frequencies penetrate walls better than higher frequencies. At 2.4 GHz, wall attenuation for a standard drywall+stud partition is approximately:
|
||||
|
||||
| Channel | Freq (MHz) | Drywall Loss (dB) | Concrete Loss (dB) |
|
||||
|---------|------------|-------------------|-------------------|
|
||||
| 1 | 2412 | 2.5 | 8.0 |
|
||||
| 6 | 2437 | 2.6 | 8.3 |
|
||||
| 11 | 2462 | 2.7 | 8.6 |
|
||||
|
||||
The absolute differences are small (~0.2 dB), but with 6 channels we can:
|
||||
|
||||
1. **Baseline the wall's frequency-dependent attenuation profile** during a calibration period (no one behind the wall)
|
||||
2. **Detect changes above baseline** that indicate motion behind the wall
|
||||
3. **Weight lower channels more heavily** since they have better through-wall SNR
|
||||
4. **Cross-validate** across channels: real through-wall motion appears on all channels (with frequency-dependent amplitude), while interference/noise typically appears on only one channel
|
||||
|
||||
### Algorithm
|
||||
|
||||
```
|
||||
Calibration phase (60 seconds, no motion behind wall):
|
||||
For each channel ch:
|
||||
baseline_mean[ch] = mean(CSI amplitude over calibration)
|
||||
baseline_std[ch] = std(CSI amplitude over calibration)
|
||||
|
||||
Detection phase:
|
||||
For each frame on channel ch:
|
||||
1. Compute deviation = |current_amplitude - baseline_mean[ch]| / baseline_std[ch]
|
||||
2. Channel weight = f(penetration_quality[ch])
|
||||
3. Per-channel score = deviation * weight
|
||||
|
||||
Fused score = weighted sum across channels
|
||||
Alert if fused_score > threshold for N consecutive frames
|
||||
```
|
||||
|
||||
### Why Single-Channel Cannot Do This
|
||||
|
||||
Single-channel through-wall detection suffers from high false-positive rates because it cannot distinguish wall effects from motion. With multi-frequency, we can:
|
||||
|
||||
1. Characterize the wall's frequency response during calibration
|
||||
2. Subtract the wall effect per channel
|
||||
3. Cross-validate detections across channels (real motion is coherent across frequencies; noise is not)
|
||||
|
||||
The frequency diversity provides a ~2.4x improvement in detection SNR (sqrt(6) independent observations).
|
||||
|
||||
### Script
|
||||
|
||||
`scripts/through-wall-detector.js`
|
||||
|
||||
---
|
||||
|
||||
## Application 5: Device Fingerprinting via RF Emissions
|
||||
|
||||
### Principle
|
||||
|
||||
Every electronic device has unique RF characteristics visible in the WiFi spectrum. When a device transmits (or even when its internal oscillators radiate EMI), it modulates nearby WiFi signals in device-specific ways:
|
||||
|
||||
- **WiFi APs**: each AP has unique transmit power, phase noise, and clock drift characteristics
|
||||
- **Printers**: the HP M255 LaserJet creates specific subcarrier patterns when printing (motor EMI)
|
||||
- **Microwave ovens**: 2.45 GHz magnetron radiates across channels 8-11, creating distinctive wideband interference
|
||||
- **Bluetooth devices**: 2.4 GHz frequency-hopping creates transient spikes across channels
|
||||
|
||||
### Algorithm
|
||||
|
||||
```
|
||||
Learning phase:
|
||||
For each known device (from WiFi scan SSID/BSSID correlation):
|
||||
1. Record CSI patterns when device is active vs inactive
|
||||
2. Compute per-channel signature:
|
||||
- Mean amplitude profile across subcarriers
|
||||
- Variance profile (active devices increase variance on specific subcarriers)
|
||||
- Phase noise characteristics
|
||||
3. Store signature as device fingerprint
|
||||
|
||||
Detection phase:
|
||||
For each analysis window:
|
||||
1. Compute current CSI profile per channel
|
||||
2. Correlate against stored fingerprints
|
||||
3. Report device activity: "HP printer active (confidence 0.87)"
|
||||
```
|
||||
|
||||
### Multi-Frequency Advantage
|
||||
|
||||
Different devices affect different channels:
|
||||
|
||||
- HP printer (ch5): affects subcarriers 20-40 on channel 5 during print jobs
|
||||
- NETGEAR72 router (ch9): creates clock-drift correlated phase patterns on channel 9
|
||||
- Microwave: broadband interference strongest on channels 9-11
|
||||
|
||||
Single-channel sensing only sees devices that affect that one channel. Multi-frequency scanning observes the full 2412-2462 MHz band, detecting device activity regardless of which channel the device operates on.
|
||||
|
||||
### Script
|
||||
|
||||
`scripts/device-fingerprint.js`
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### Shared Infrastructure
|
||||
|
||||
All 5 scripts share common infrastructure:
|
||||
|
||||
| Component | Detail |
|
||||
|-----------|--------|
|
||||
| Packet format | ADR-018 binary (UDP) or .csi.jsonl (replay) |
|
||||
| IQ parsing | `parseIqHex()` for JSONL, `parseCSIFrame()` for binary UDP |
|
||||
| Channel assignment | From binary freq field, or simulated round-robin for legacy JSONL |
|
||||
| Node positions | Configurable, default: Node 1 at (0,0), Node 2 at (3,0) meters |
|
||||
| Visualization | ASCII Unicode block characters and box drawing |
|
||||
|
||||
### Scripts
|
||||
|
||||
| Script | Application | Lines | Key Algorithm |
|
||||
|--------|------------|-------|---------------|
|
||||
| `scripts/rf-tomography.js` | RF Tomographic Imaging | ~500 | ART backprojection |
|
||||
| `scripts/passive-radar.js` | Passive Bistatic Radar | ~500 | Range-Doppler + multi-static fusion |
|
||||
| `scripts/material-classifier.js` | Material Classification | ~450 | Frequency-selective attenuation analysis |
|
||||
| `scripts/through-wall-detector.js` | Through-Wall Detection | ~400 | Baselined multi-channel anomaly detection |
|
||||
| `scripts/device-fingerprint.js` | Device Fingerprinting | ~450 | Per-channel signature correlation |
|
||||
|
||||
### Data Requirements
|
||||
|
||||
- **Live mode**: UDP port 5006, 2 ESP32 nodes channel-hopping per ADR-073
|
||||
- **Replay mode**: `--replay <file.csi.jsonl>` with overnight recordings
|
||||
- **Calibration**: through-wall detector requires 60s calibration with `--calibrate`
|
||||
|
||||
## Performance Targets
|
||||
|
||||
| Application | Latency | Update Rate | Accuracy Target |
|
||||
|-------------|---------|-------------|-----------------|
|
||||
| RF Tomography | <100ms per frame | 1 Hz image update | 20 cm spatial resolution |
|
||||
| Passive Radar | <200ms per frame | 2 Hz range-Doppler | 1 m range, 0.1 m/s velocity |
|
||||
| Material Classification | <500ms per window | 0.5 Hz classification | 70% correct material ID |
|
||||
| Through-Wall Detection | <100ms per frame | 2 Hz detection | 90% true positive, <10% false positive |
|
||||
| Device Fingerprinting | <1s per window | 0.2 Hz activity update | 80% correct device ID |
|
||||
|
||||
## Risks
|
||||
|
||||
### Limited Frequency Span
|
||||
|
||||
The 50 MHz span (2412-2462 MHz) is only 2% of the carrier frequency. Material classification accuracy depends on the attenuation slope being measurable within this narrow range. Mitigation: use long averaging windows (5-10 seconds) to improve SNR of frequency-dependent measurements.
|
||||
|
||||
### Node Geometry
|
||||
|
||||
2 nodes provide limited spatial diversity for tomographic imaging. The backprojection is essentially 1D along the node-to-node axis, with poor resolution perpendicular to it. Mitigation: neighbor APs provide additional geometric diversity for passive radar mode.
|
||||
|
||||
### Legacy Data Compatibility
|
||||
|
||||
Overnight recordings (`data/recordings/overnight-*.csi.jsonl`) were captured before multi-frequency scanning was deployed and lack channel/frequency fields. Scripts simulate channel assignment for replay. Full multi-frequency data requires re-recording with channel hopping enabled.
|
||||
|
||||
### Phase Calibration
|
||||
|
||||
Passive radar requires accurate phase tracking across consecutive frames. ESP32 CSI phase includes a random offset per channel hop that must be removed. Mitigation: use phase-difference between consecutive frames rather than absolute phase.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
1. **5 GHz multi-frequency**: rejected -- no 5 GHz APs visible in environment, no free illuminators.
|
||||
2. **UWB (ultra-wideband)**: rejected -- ESP32-S3 does not support UWB. Would require additional hardware (DW1000/DW3000 modules).
|
||||
3. **Dedicated radar hardware**: rejected -- multi-frequency WiFi sensing achieves similar capabilities using existing infrastructure at zero additional cost.
|
||||
|
||||
## References
|
||||
|
||||
- Wilson, J. & Patwari, N. (2010). "Radio Tomographic Imaging with Wireless Networks." IEEE Trans. Mobile Computing.
|
||||
- Colone, F. et al. (2012). "WiFi-Based Passive Bistatic Radar: Data Processing Schemes and Experimental Results." IEEE Trans. Aerospace and Electronic Systems.
|
||||
- Adib, F. & Katabi, D. (2013). "See Through Walls with WiFi!" ACM SIGCOMM.
|
||||
- Banerjee, A. et al. (2014). "RF-based material identification using WiFi signals." ACM MobiCom.
|
||||
@@ -0,0 +1,410 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ADR-077: Breathing Disorder Screening — Apnea/Hypopnea Detection
|
||||
*
|
||||
* Monitors breathing rate time series for respiratory events (pauses > 10s)
|
||||
* and computes AHI (Apnea-Hypopnea Index) for pre-screening.
|
||||
*
|
||||
* DISCLAIMER: This is a pre-screening tool, NOT a clinical diagnostic device.
|
||||
* Consult a physician and pursue polysomnography for diagnosis.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/apnea-detector.js --replay data/recordings/overnight-1775217646.csi.jsonl
|
||||
* node scripts/apnea-detector.js --port 5006
|
||||
* node scripts/apnea-detector.js --replay FILE --json
|
||||
*
|
||||
* ADR: docs/adr/ADR-077-novel-rf-sensing-applications.md
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const dgram = require('dgram');
|
||||
const fs = require('fs');
|
||||
const readline = require('readline');
|
||||
const { parseArgs } = require('util');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
port: { type: 'string', short: 'p', default: '5006' },
|
||||
replay: { type: 'string', short: 'r' },
|
||||
json: { type: 'boolean', default: false },
|
||||
interval: { type: 'string', short: 'i', default: '5000' },
|
||||
'apnea-threshold': { type: 'string', default: '3.0' },
|
||||
'hypopnea-drop': { type: 'string', default: '0.5' },
|
||||
'min-duration': { type: 'string', default: '10' },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
const PORT = parseInt(args.port, 10);
|
||||
const JSON_OUTPUT = args.json;
|
||||
const INTERVAL_MS = parseInt(args.interval, 10);
|
||||
const APNEA_THRESH = parseFloat(args['apnea-threshold']); // BR below this = apnea
|
||||
const HYPOPNEA_DROP = parseFloat(args['hypopnea-drop']); // 50% drop from baseline
|
||||
const MIN_DURATION_SEC = parseInt(args['min-duration'], 10); // min event duration
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ADR-018 packet constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const VITALS_MAGIC = 0xC5110002;
|
||||
const FUSED_MAGIC = 0xC5110004;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Apnea detector engine
|
||||
// ---------------------------------------------------------------------------
|
||||
class ApneaDetector {
|
||||
constructor(opts) {
|
||||
this.apneaThresh = opts.apneaThresh;
|
||||
this.hypopneaDrop = opts.hypopneaDrop;
|
||||
this.minDurationSec = opts.minDurationSec;
|
||||
|
||||
// Rolling baseline (exponential moving average, 5-min window)
|
||||
this.baselineBR = null;
|
||||
this.baselineAlpha = 0.005; // slow adaptation
|
||||
|
||||
// Event tracking
|
||||
this.events = []; // { type, startTs, endTs, durationSec, avgBR }
|
||||
this.currentEvent = null; // in-progress event
|
||||
this.eventSamples = []; // BR samples during current event
|
||||
|
||||
// Time tracking
|
||||
this.startTime = null;
|
||||
this.lastTime = null;
|
||||
this.totalSamples = 0;
|
||||
|
||||
// Per-hour tracking
|
||||
this.hourlyEvents = new Map(); // hour_index -> count
|
||||
}
|
||||
|
||||
ingest(timestamp, br) {
|
||||
if (!this.startTime) this.startTime = timestamp;
|
||||
this.lastTime = timestamp;
|
||||
this.totalSamples++;
|
||||
|
||||
// Update baseline (only with "normal" breathing)
|
||||
if (br > this.apneaThresh * 2 && (!this.baselineBR || br < this.baselineBR * 2)) {
|
||||
if (this.baselineBR === null) {
|
||||
this.baselineBR = br;
|
||||
} else {
|
||||
this.baselineBR = this.baselineBR * (1 - this.baselineAlpha) + br * this.baselineAlpha;
|
||||
}
|
||||
}
|
||||
|
||||
// Detect events
|
||||
const isApnea = br < this.apneaThresh;
|
||||
const isHypopnea = this.baselineBR && br < this.baselineBR * (1 - this.hypopneaDrop) && !isApnea;
|
||||
const inEvent = isApnea || isHypopnea;
|
||||
|
||||
if (inEvent) {
|
||||
if (!this.currentEvent) {
|
||||
// Start new event
|
||||
this.currentEvent = {
|
||||
type: isApnea ? 'apnea' : 'hypopnea',
|
||||
startTs: timestamp,
|
||||
};
|
||||
this.eventSamples = [br];
|
||||
} else {
|
||||
this.eventSamples.push(br);
|
||||
// Upgrade hypopnea to apnea if BR drops further
|
||||
if (isApnea && this.currentEvent.type === 'hypopnea') {
|
||||
this.currentEvent.type = 'apnea';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Event ended
|
||||
if (this.currentEvent) {
|
||||
const duration = timestamp - this.currentEvent.startTs;
|
||||
if (duration >= this.minDurationSec) {
|
||||
const avgBR = this.eventSamples.reduce((a, b) => a + b, 0) / this.eventSamples.length;
|
||||
const event = {
|
||||
type: this.currentEvent.type,
|
||||
startTs: this.currentEvent.startTs,
|
||||
endTs: timestamp,
|
||||
durationSec: duration,
|
||||
avgBR,
|
||||
};
|
||||
this.events.push(event);
|
||||
|
||||
// Track hourly
|
||||
const hourIdx = Math.floor((this.currentEvent.startTs - this.startTime) / 3600);
|
||||
this.hourlyEvents.set(hourIdx, (this.hourlyEvents.get(hourIdx) || 0) + 1);
|
||||
}
|
||||
this.currentEvent = null;
|
||||
this.eventSamples = [];
|
||||
}
|
||||
}
|
||||
|
||||
return { isApnea, isHypopnea, baseline: this.baselineBR, br };
|
||||
}
|
||||
|
||||
getAHI() {
|
||||
const hours = this.lastTime && this.startTime
|
||||
? (this.lastTime - this.startTime) / 3600
|
||||
: 0;
|
||||
if (hours < 0.01) return { ahi: 0, hours, events: 0, severity: 'Insufficient data' };
|
||||
|
||||
const totalEvents = this.events.length;
|
||||
const ahi = totalEvents / hours;
|
||||
|
||||
let severity;
|
||||
if (ahi < 5) severity = 'Normal';
|
||||
else if (ahi < 15) severity = 'Mild';
|
||||
else if (ahi < 30) severity = 'Moderate';
|
||||
else severity = 'Severe';
|
||||
|
||||
return { ahi, hours, events: totalEvents, severity };
|
||||
}
|
||||
|
||||
getHourlyAHI() {
|
||||
const result = [];
|
||||
for (const [hour, count] of [...this.hourlyEvents.entries()].sort((a, b) => a[0] - b[0])) {
|
||||
result.push({ hour, events: count, ahi: count }); // events per 1 hour
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
getEventSummary() {
|
||||
const apneas = this.events.filter(e => e.type === 'apnea');
|
||||
const hypopneas = this.events.filter(e => e.type === 'hypopnea');
|
||||
|
||||
return {
|
||||
totalEvents: this.events.length,
|
||||
apneas: apneas.length,
|
||||
hypopneas: hypopneas.length,
|
||||
avgApneaDuration: apneas.length > 0
|
||||
? apneas.reduce((s, e) => s + e.durationSec, 0) / apneas.length : 0,
|
||||
avgHypopneaDuration: hypopneas.length > 0
|
||||
? hypopneas.reduce((s, e) => s + e.durationSec, 0) / hypopneas.length : 0,
|
||||
maxDuration: this.events.length > 0
|
||||
? Math.max(...this.events.map(e => e.durationSec)) : 0,
|
||||
baselineBR: this.baselineBR || 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Packet parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseVitalsJsonl(record) {
|
||||
if (record.type !== 'vitals') return null;
|
||||
return { timestamp: record.timestamp, nodeId: record.node_id, br: record.breathing_bpm || 0 };
|
||||
}
|
||||
|
||||
function parseVitalsUdp(buf) {
|
||||
if (buf.length < 32) return null;
|
||||
const magic = buf.readUInt32LE(0);
|
||||
if (magic !== VITALS_MAGIC && magic !== FUSED_MAGIC) return null;
|
||||
return {
|
||||
timestamp: Date.now() / 1000,
|
||||
nodeId: buf.readUInt8(4),
|
||||
br: buf.readUInt16LE(6) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Replay mode
|
||||
// ---------------------------------------------------------------------------
|
||||
async function startReplay(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`File not found: ${filePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const detector = new ApneaDetector({
|
||||
apneaThresh: APNEA_THRESH,
|
||||
hypopneaDrop: HYPOPNEA_DROP,
|
||||
minDurationSec: MIN_DURATION_SEC,
|
||||
});
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: fs.createReadStream(filePath),
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
let vitalsCount = 0;
|
||||
let lastPrintTs = 0;
|
||||
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) continue;
|
||||
let record;
|
||||
try { record = JSON.parse(line); } catch { continue; }
|
||||
|
||||
const v = parseVitalsJsonl(record);
|
||||
if (!v) continue;
|
||||
|
||||
const state = detector.ingest(v.timestamp, v.br);
|
||||
vitalsCount++;
|
||||
|
||||
// Print new events immediately
|
||||
const lastEvent = detector.events.length > 0 ? detector.events[detector.events.length - 1] : null;
|
||||
if (lastEvent && lastEvent.endTs === v.timestamp) {
|
||||
if (JSON_OUTPUT) {
|
||||
console.log(JSON.stringify({
|
||||
type: 'event',
|
||||
event_type: lastEvent.type,
|
||||
start: lastEvent.startTs,
|
||||
end: lastEvent.endTs,
|
||||
duration_sec: +lastEvent.durationSec.toFixed(1),
|
||||
avg_br: +lastEvent.avgBR.toFixed(2),
|
||||
}));
|
||||
} else {
|
||||
const ts = new Date(lastEvent.startTs * 1000).toISOString().slice(11, 19);
|
||||
const tag = lastEvent.type === 'apnea' ? '!! APNEA ' : '~ HYPOPNEA';
|
||||
console.log(`[${ts}] ${tag} | ${lastEvent.durationSec.toFixed(1)}s | avg BR ${lastEvent.avgBR.toFixed(1)} BPM`);
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic status
|
||||
const tsMs = v.timestamp * 1000;
|
||||
if (tsMs - lastPrintTs >= INTERVAL_MS * 2) {
|
||||
if (!JSON_OUTPUT) {
|
||||
const ahi = detector.getAHI();
|
||||
const ts = new Date(v.timestamp * 1000).toISOString().slice(11, 19);
|
||||
console.log(`[${ts}] BR ${v.br.toFixed(1)} | baseline ${(state.baseline || 0).toFixed(1)} | AHI ${ahi.ahi.toFixed(1)} (${ahi.severity}) | ${ahi.events} events / ${ahi.hours.toFixed(2)} hrs`);
|
||||
}
|
||||
lastPrintTs = tsMs;
|
||||
}
|
||||
}
|
||||
|
||||
// Final summary
|
||||
const ahi = detector.getAHI();
|
||||
const summary = detector.getEventSummary();
|
||||
|
||||
if (JSON_OUTPUT) {
|
||||
console.log(JSON.stringify({
|
||||
type: 'summary',
|
||||
ahi: +ahi.ahi.toFixed(2),
|
||||
severity: ahi.severity,
|
||||
hours: +ahi.hours.toFixed(3),
|
||||
...summary,
|
||||
hourly: detector.getHourlyAHI(),
|
||||
}));
|
||||
} else {
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('APNEA SCREENING SUMMARY');
|
||||
console.log('DISCLAIMER: Pre-screening only. Consult a physician.');
|
||||
console.log('='.repeat(60));
|
||||
console.log(`Monitored: ${ahi.hours.toFixed(2)} hours (${vitalsCount} samples)`);
|
||||
console.log(`AHI: ${ahi.ahi.toFixed(1)} events/hour`);
|
||||
console.log(`Severity: ${ahi.severity}`);
|
||||
console.log(`Total events: ${summary.totalEvents}`);
|
||||
console.log(` Apneas: ${summary.apneas} (avg ${summary.avgApneaDuration.toFixed(1)}s)`);
|
||||
console.log(` Hypopneas: ${summary.hypopneas} (avg ${summary.avgHypopneaDuration.toFixed(1)}s)`);
|
||||
console.log(` Longest event: ${summary.maxDuration.toFixed(1)}s`);
|
||||
console.log(`Baseline BR: ${summary.baselineBR.toFixed(1)} BPM`);
|
||||
|
||||
const hourly = detector.getHourlyAHI();
|
||||
if (hourly.length > 0) {
|
||||
console.log('\nHourly breakdown:');
|
||||
for (const h of hourly) {
|
||||
const bar = '\u2588'.repeat(Math.min(h.events, 40));
|
||||
console.log(` Hour ${h.hour}: ${bar} ${h.events} events (AHI ${h.ahi})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Event timeline
|
||||
if (detector.events.length > 0 && detector.events.length <= 50) {
|
||||
console.log('\nEvent timeline:');
|
||||
for (const e of detector.events) {
|
||||
const ts = new Date(e.startTs * 1000).toISOString().slice(11, 19);
|
||||
const tag = e.type === 'apnea' ? 'APNEA ' : 'HYPOPNEA';
|
||||
console.log(` [${ts}] ${tag} ${e.durationSec.toFixed(1)}s (BR ${e.avgBR.toFixed(1)})`);
|
||||
}
|
||||
} else if (detector.events.length > 50) {
|
||||
console.log(`\n(${detector.events.length} events total, showing first/last 5)`);
|
||||
for (const e of detector.events.slice(0, 5)) {
|
||||
const ts = new Date(e.startTs * 1000).toISOString().slice(11, 19);
|
||||
console.log(` [${ts}] ${e.type.padEnd(8)} ${e.durationSec.toFixed(1)}s`);
|
||||
}
|
||||
console.log(' ...');
|
||||
for (const e of detector.events.slice(-5)) {
|
||||
const ts = new Date(e.startTs * 1000).toISOString().slice(11, 19);
|
||||
console.log(` [${ts}] ${e.type.padEnd(8)} ${e.durationSec.toFixed(1)}s`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live UDP mode
|
||||
// ---------------------------------------------------------------------------
|
||||
function startLive() {
|
||||
const detector = new ApneaDetector({
|
||||
apneaThresh: APNEA_THRESH,
|
||||
hypopneaDrop: HYPOPNEA_DROP,
|
||||
minDurationSec: MIN_DURATION_SEC,
|
||||
});
|
||||
|
||||
const server = dgram.createSocket('udp4');
|
||||
|
||||
server.on('message', (buf) => {
|
||||
const v = parseVitalsUdp(buf);
|
||||
if (!v) return;
|
||||
|
||||
const state = detector.ingest(v.timestamp, v.br);
|
||||
|
||||
// Alert on new events
|
||||
const lastEvent = detector.events.length > 0 ? detector.events[detector.events.length - 1] : null;
|
||||
if (lastEvent && Math.abs(lastEvent.endTs - v.timestamp) < 2) {
|
||||
if (JSON_OUTPUT) {
|
||||
console.log(JSON.stringify({
|
||||
type: 'event', event_type: lastEvent.type,
|
||||
duration_sec: +lastEvent.durationSec.toFixed(1),
|
||||
avg_br: +lastEvent.avgBR.toFixed(2),
|
||||
}));
|
||||
} else {
|
||||
const tag = lastEvent.type === 'apnea' ? '!! APNEA' : '~ HYPOPNEA';
|
||||
console.log(`${tag} | ${lastEvent.durationSec.toFixed(1)}s | avg BR ${lastEvent.avgBR.toFixed(1)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
if (!JSON_OUTPUT) {
|
||||
const ahi = detector.getAHI();
|
||||
process.stdout.write('\x1B[2J\x1B[H');
|
||||
console.log('=== APNEA SCREENING (ADR-077) ===');
|
||||
console.log('DISCLAIMER: Pre-screening only. Not a diagnostic device.');
|
||||
console.log('');
|
||||
console.log(`AHI: ${ahi.ahi.toFixed(1)} events/hour | Severity: ${ahi.severity}`);
|
||||
console.log(`Events: ${ahi.events} in ${ahi.hours.toFixed(2)} hours`);
|
||||
console.log(`Baseline BR: ${(detector.baselineBR || 0).toFixed(1)} BPM`);
|
||||
|
||||
if (detector.events.length > 0) {
|
||||
console.log('\nRecent events:');
|
||||
for (const e of detector.events.slice(-5)) {
|
||||
const ts = new Date(e.startTs * 1000).toISOString().slice(11, 19);
|
||||
console.log(` [${ts}] ${e.type.padEnd(8)} ${e.durationSec.toFixed(1)}s (BR ${e.avgBR.toFixed(1)})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, INTERVAL_MS);
|
||||
|
||||
server.bind(PORT, () => {
|
||||
if (!JSON_OUTPUT) {
|
||||
console.log(`Apnea Detector listening on UDP :${PORT}`);
|
||||
console.log('DISCLAIMER: Pre-screening only. Consult a physician.\n');
|
||||
}
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
const ahi = detector.getAHI();
|
||||
if (!JSON_OUTPUT) {
|
||||
console.log(`\nSession AHI: ${ahi.ahi.toFixed(1)} (${ahi.severity}) | ${ahi.events} events / ${ahi.hours.toFixed(2)} hrs`);
|
||||
}
|
||||
server.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry
|
||||
// ---------------------------------------------------------------------------
|
||||
if (args.replay) {
|
||||
startReplay(args.replay);
|
||||
} else {
|
||||
startLive();
|
||||
}
|
||||
@@ -0,0 +1,715 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Device Fingerprinting via RF Emissions — Multi-Frequency Mesh Application
|
||||
*
|
||||
* Identifies electronic devices by their unique RF characteristics across
|
||||
* multiple WiFi channels. Each device creates distinctive subcarrier patterns:
|
||||
*
|
||||
* - WiFi APs: unique transmit power, phase noise, clock drift
|
||||
* - Printers: motor EMI creates specific subcarrier modulation
|
||||
* - Microwaves: 2.45 GHz magnetron radiates across channels 8-11
|
||||
* - Bluetooth: frequency-hopping creates transient spikes
|
||||
*
|
||||
* Correlates WiFi scan SSID/signal with CSI patterns to build per-device
|
||||
* fingerprints, then detects when devices become active or inactive.
|
||||
*
|
||||
* Requires multi-frequency mesh scanning (ADR-073): 2 ESP32 nodes hopping
|
||||
* across channels 1, 3, 5, 6, 9, 11.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/device-fingerprint.js
|
||||
* node scripts/device-fingerprint.js --port 5006 --duration 120
|
||||
* node scripts/device-fingerprint.js --replay data/recordings/overnight-1775217646.csi.jsonl
|
||||
* node scripts/device-fingerprint.js --learn 30
|
||||
*
|
||||
* ADR: docs/adr/ADR-078-multifreq-mesh-applications.md
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const dgram = require('dgram');
|
||||
const fs = require('fs');
|
||||
const readline = require('readline');
|
||||
const { parseArgs } = require('util');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
port: { type: 'string', short: 'p', default: '5006' },
|
||||
duration: { type: 'string', short: 'd' },
|
||||
replay: { type: 'string', short: 'r' },
|
||||
interval: { type: 'string', short: 'i', default: '5000' },
|
||||
learn: { type: 'string', short: 'l', default: '20' },
|
||||
json: { type: 'boolean', default: false },
|
||||
'save-fingerprints': { type: 'string' },
|
||||
'load-fingerprints': { type: 'string' },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
const PORT = parseInt(args.port, 10);
|
||||
const DURATION_MS = args.duration ? parseInt(args.duration, 10) * 1000 : null;
|
||||
const INTERVAL_MS = parseInt(args.interval, 10);
|
||||
const LEARN_DURATION = parseInt(args.learn, 10);
|
||||
const JSON_OUTPUT = args.json;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const CSI_MAGIC = 0xC5110001;
|
||||
const HEADER_SIZE = 20;
|
||||
|
||||
const CHANNEL_FREQ = {};
|
||||
for (let ch = 1; ch <= 13; ch++) CHANNEL_FREQ[ch] = 2412 + (ch - 1) * 5;
|
||||
|
||||
const NODE1_CHANNELS = [1, 6, 11];
|
||||
const NODE2_CHANNELS = [3, 5, 9];
|
||||
|
||||
// Known devices from WiFi scan (these are the devices we can fingerprint)
|
||||
const KNOWN_DEVICES = [
|
||||
{ id: 'ruv-net', ssid: 'ruv.net', channel: 5, signal: 100, type: 'router' },
|
||||
{ id: 'cohen-guest', ssid: 'Cohen-Guest', channel: 5, signal: 100, type: 'router' },
|
||||
{ id: 'cogeco-21b20', ssid: 'COGECO-21B20', channel: 11, signal: 100, type: 'router' },
|
||||
{ id: 'hp-printer', ssid: 'DIRECT-fa-HP M255 LaserJet', channel: 5, signal: 94, type: 'printer' },
|
||||
{ id: 'conclusion', ssid: 'conclusion mesh', channel: 3, signal: 44, type: 'mesh-node' },
|
||||
{ id: 'netgear72', ssid: 'NETGEAR72', channel: 9, signal: 42, type: 'router' },
|
||||
{ id: 'cogeco-4321', ssid: 'COGECO-4321', channel: 11, signal: 30, type: 'router' },
|
||||
{ id: 'innanen', ssid: 'Innanen', channel: 6, signal: 19, type: 'router' },
|
||||
];
|
||||
|
||||
// Activity states
|
||||
const ACTIVITY = {
|
||||
UNKNOWN: 'unknown',
|
||||
ACTIVE: 'active',
|
||||
IDLE: 'idle',
|
||||
CHANGED: 'changed',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Device fingerprint
|
||||
// ---------------------------------------------------------------------------
|
||||
class DeviceFingerprint {
|
||||
constructor(device) {
|
||||
this.device = device;
|
||||
this.id = device.id;
|
||||
this.channel = device.channel;
|
||||
|
||||
// Per-subcarrier signature (learned during training)
|
||||
this.baselineMean = null; // Float64Array
|
||||
this.baselineStd = null; // Float64Array
|
||||
this.varianceProfile = null; // Float64Array - characteristic variance pattern
|
||||
this.nSub = 0;
|
||||
this.trainCount = 0;
|
||||
|
||||
// Welford accumulators for training
|
||||
this._sum = null;
|
||||
this._sumSq = null;
|
||||
this._varSum = null;
|
||||
this._varSumSq = null;
|
||||
this._frameAmps = []; // store recent frames for variance computation
|
||||
|
||||
// Runtime state
|
||||
this.activity = ACTIVITY.UNKNOWN;
|
||||
this.lastScore = 0;
|
||||
this.lastSeen = 0;
|
||||
this.activityHistory = [];
|
||||
this.maxHistory = 30;
|
||||
}
|
||||
|
||||
/** Ingest a training frame */
|
||||
train(amplitudes) {
|
||||
const n = amplitudes.length;
|
||||
if (!this._sum) {
|
||||
this.nSub = n;
|
||||
this._sum = new Float64Array(n);
|
||||
this._sumSq = new Float64Array(n);
|
||||
}
|
||||
|
||||
this.trainCount++;
|
||||
for (let i = 0; i < n && i < this.nSub; i++) {
|
||||
this._sum[i] += amplitudes[i];
|
||||
this._sumSq[i] += amplitudes[i] * amplitudes[i];
|
||||
}
|
||||
|
||||
// Keep last 10 frames for variance profile
|
||||
this._frameAmps.push(new Float64Array(amplitudes));
|
||||
if (this._frameAmps.length > 10) this._frameAmps.shift();
|
||||
}
|
||||
|
||||
/** Finalize training */
|
||||
finalizeTrain() {
|
||||
if (this.trainCount < 3 || !this._sum) return false;
|
||||
|
||||
this.baselineMean = new Float64Array(this.nSub);
|
||||
this.baselineStd = new Float64Array(this.nSub);
|
||||
|
||||
for (let i = 0; i < this.nSub; i++) {
|
||||
this.baselineMean[i] = this._sum[i] / this.trainCount;
|
||||
const variance = (this._sumSq[i] / this.trainCount) - (this.baselineMean[i] ** 2);
|
||||
this.baselineStd[i] = Math.sqrt(Math.max(0, variance));
|
||||
if (this.baselineStd[i] < 0.1) this.baselineStd[i] = 0.1;
|
||||
}
|
||||
|
||||
// Compute variance profile from stored frames
|
||||
if (this._frameAmps.length >= 3) {
|
||||
this.varianceProfile = new Float64Array(this.nSub);
|
||||
for (let i = 0; i < this.nSub; i++) {
|
||||
let sum = 0, sumSq = 0;
|
||||
for (const frame of this._frameAmps) {
|
||||
sum += frame[i];
|
||||
sumSq += frame[i] * frame[i];
|
||||
}
|
||||
const n = this._frameAmps.length;
|
||||
const mean = sum / n;
|
||||
this.varianceProfile[i] = (sumSq / n) - (mean * mean);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up training data
|
||||
this._sum = null;
|
||||
this._sumSq = null;
|
||||
this._frameAmps = [];
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score a new frame against this device's fingerprint.
|
||||
* Returns a similarity score (0 = no match, 1 = perfect match).
|
||||
*/
|
||||
score(amplitudes) {
|
||||
if (!this.baselineMean) return 0;
|
||||
|
||||
const n = Math.min(amplitudes.length, this.nSub);
|
||||
let matchScore = 0;
|
||||
let count = 0;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
// Normalized difference from baseline
|
||||
const diff = Math.abs(amplitudes[i] - this.baselineMean[i]);
|
||||
const normalizedDiff = diff / this.baselineStd[i];
|
||||
|
||||
// Score: 1.0 if within 1 std, decreasing beyond
|
||||
const subScore = Math.exp(-0.5 * normalizedDiff * normalizedDiff);
|
||||
matchScore += subScore;
|
||||
count++;
|
||||
}
|
||||
|
||||
return count > 0 ? matchScore / count : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect activity change.
|
||||
* Compare current frame's variance against baseline variance profile.
|
||||
*/
|
||||
detectActivity(amplitudes, timestamp) {
|
||||
const similarity = this.score(amplitudes);
|
||||
this.lastScore = similarity;
|
||||
this.lastSeen = timestamp;
|
||||
|
||||
// Activity thresholds
|
||||
const prevActivity = this.activity;
|
||||
if (similarity > 0.7) {
|
||||
this.activity = ACTIVITY.ACTIVE;
|
||||
} else if (similarity > 0.4) {
|
||||
this.activity = ACTIVITY.CHANGED;
|
||||
} else {
|
||||
this.activity = ACTIVITY.IDLE;
|
||||
}
|
||||
|
||||
// Record transitions
|
||||
if (prevActivity !== this.activity && prevActivity !== ACTIVITY.UNKNOWN) {
|
||||
this.activityHistory.push({
|
||||
timestamp,
|
||||
from: prevActivity,
|
||||
to: this.activity,
|
||||
score: similarity.toFixed(3),
|
||||
});
|
||||
if (this.activityHistory.length > this.maxHistory) this.activityHistory.shift();
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
ssid: this.device.ssid,
|
||||
type: this.device.type,
|
||||
channel: this.channel,
|
||||
activity: this.activity,
|
||||
similarity: similarity.toFixed(3),
|
||||
changed: prevActivity !== this.activity && prevActivity !== ACTIVITY.UNKNOWN,
|
||||
};
|
||||
}
|
||||
|
||||
/** Export fingerprint for persistence */
|
||||
exportFingerprint() {
|
||||
return {
|
||||
id: this.id,
|
||||
device: this.device,
|
||||
nSub: this.nSub,
|
||||
trainCount: this.trainCount,
|
||||
baselineMean: this.baselineMean ? Array.from(this.baselineMean) : null,
|
||||
baselineStd: this.baselineStd ? Array.from(this.baselineStd) : null,
|
||||
varianceProfile: this.varianceProfile ? Array.from(this.varianceProfile) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Import fingerprint from saved data */
|
||||
importFingerprint(data) {
|
||||
this.nSub = data.nSub;
|
||||
this.trainCount = data.trainCount;
|
||||
this.baselineMean = data.baselineMean ? new Float64Array(data.baselineMean) : null;
|
||||
this.baselineStd = data.baselineStd ? new Float64Array(data.baselineStd) : null;
|
||||
this.varianceProfile = data.varianceProfile ? new Float64Array(data.varianceProfile) : null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Device fingerprint manager
|
||||
// ---------------------------------------------------------------------------
|
||||
class FingerprintManager {
|
||||
constructor(learnDuration) {
|
||||
this.learnDuration = learnDuration;
|
||||
this.fingerprints = new Map(); // id -> DeviceFingerprint
|
||||
this.learning = true;
|
||||
this.startTime = null;
|
||||
this.totalFrames = 0;
|
||||
|
||||
// Initialize fingerprints for known devices
|
||||
for (const device of KNOWN_DEVICES) {
|
||||
this.fingerprints.set(device.id, new DeviceFingerprint(device));
|
||||
}
|
||||
}
|
||||
|
||||
ingestFrame(channel, amplitudes, timestamp) {
|
||||
this.totalFrames++;
|
||||
if (!this.startTime) this.startTime = timestamp;
|
||||
|
||||
// Learning phase: train fingerprints for devices on this channel
|
||||
if (this.learning) {
|
||||
for (const fp of this.fingerprints.values()) {
|
||||
if (fp.channel === channel) {
|
||||
fp.train(amplitudes);
|
||||
}
|
||||
}
|
||||
|
||||
if (timestamp - this.startTime >= this.learnDuration) {
|
||||
// Finalize all fingerprints
|
||||
let trained = 0;
|
||||
for (const fp of this.fingerprints.values()) {
|
||||
if (fp.finalizeTrain()) trained++;
|
||||
}
|
||||
this.learning = false;
|
||||
return { event: 'learn_complete', trained, total: this.fingerprints.size };
|
||||
}
|
||||
|
||||
return { event: 'learning', elapsed: timestamp - this.startTime, duration: this.learnDuration };
|
||||
}
|
||||
|
||||
// Detection phase: score all devices on this channel
|
||||
const results = [];
|
||||
for (const fp of this.fingerprints.values()) {
|
||||
if (fp.channel === channel) {
|
||||
const result = fp.detectActivity(amplitudes, timestamp);
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
return { event: 'detect', results };
|
||||
}
|
||||
|
||||
/** Get current device activity summary */
|
||||
getSummary() {
|
||||
const devices = [];
|
||||
for (const fp of this.fingerprints.values()) {
|
||||
devices.push({
|
||||
id: fp.id,
|
||||
ssid: fp.device.ssid,
|
||||
type: fp.device.type,
|
||||
channel: fp.channel,
|
||||
activity: fp.activity,
|
||||
similarity: fp.lastScore.toFixed(3),
|
||||
trained: fp.baselineMean !== null,
|
||||
trainFrames: fp.trainCount,
|
||||
transitions: fp.activityHistory.length,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
learning: this.learning,
|
||||
totalFrames: this.totalFrames,
|
||||
devices: devices.sort((a, b) => parseFloat(b.similarity) - parseFloat(a.similarity)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Save fingerprints to file */
|
||||
saveFingerprints(filePath) {
|
||||
const data = {};
|
||||
for (const [id, fp] of this.fingerprints) {
|
||||
if (fp.baselineMean) {
|
||||
data[id] = fp.exportFingerprint();
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
return Object.keys(data).length;
|
||||
}
|
||||
|
||||
/** Load fingerprints from file */
|
||||
loadFingerprints(filePath) {
|
||||
if (!fs.existsSync(filePath)) return 0;
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
let loaded = 0;
|
||||
for (const [id, fpData] of Object.entries(data)) {
|
||||
if (this.fingerprints.has(id)) {
|
||||
this.fingerprints.get(id).importFingerprint(fpData);
|
||||
loaded++;
|
||||
}
|
||||
}
|
||||
if (loaded > 0) this.learning = false;
|
||||
return loaded;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSI parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseIqHex(iqHex, nSubcarriers) {
|
||||
const bytes = Buffer.from(iqHex, 'hex');
|
||||
const amplitudes = new Float64Array(nSubcarriers);
|
||||
|
||||
for (let sc = 0; sc < nSubcarriers; sc++) {
|
||||
const offset = 2 + sc * 2;
|
||||
if (offset + 1 >= bytes.length) break;
|
||||
let I = bytes[offset];
|
||||
let Q = bytes[offset + 1];
|
||||
if (I > 127) I -= 256;
|
||||
if (Q > 127) Q -= 256;
|
||||
amplitudes[sc] = Math.sqrt(I * I + Q * Q);
|
||||
}
|
||||
|
||||
return amplitudes;
|
||||
}
|
||||
|
||||
function parseCSIFrame(buf) {
|
||||
if (buf.length < HEADER_SIZE) return null;
|
||||
const magic = buf.readUInt32LE(0);
|
||||
if (magic !== CSI_MAGIC) return null;
|
||||
|
||||
const nodeId = buf.readUInt8(4);
|
||||
const nSubcarriers = buf.readUInt16LE(6);
|
||||
const freqMhz = buf.readUInt32LE(8);
|
||||
|
||||
const amplitudes = new Float64Array(nSubcarriers);
|
||||
for (let sc = 0; sc < nSubcarriers; sc++) {
|
||||
const offset = HEADER_SIZE + sc * 2;
|
||||
if (offset + 1 >= buf.length) break;
|
||||
const I = buf.readInt8(offset);
|
||||
const Q = buf.readInt8(offset + 1);
|
||||
amplitudes[sc] = Math.sqrt(I * I + Q * Q);
|
||||
}
|
||||
|
||||
let channel = 0;
|
||||
if (freqMhz >= 2412 && freqMhz <= 2484) {
|
||||
channel = freqMhz === 2484 ? 14 : Math.round((freqMhz - 2412) / 5) + 1;
|
||||
}
|
||||
|
||||
return { nodeId, nSubcarriers, freqMhz, amplitudes, channel };
|
||||
}
|
||||
|
||||
const nodeChannelIdx = { 1: 0, 2: 0 };
|
||||
function assignChannel(nodeId) {
|
||||
const channels = nodeId === 1 ? NODE1_CHANNELS : NODE2_CHANNELS;
|
||||
const ch = channels[nodeChannelIdx[nodeId] % channels.length];
|
||||
nodeChannelIdx[nodeId]++;
|
||||
return ch;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Visualization
|
||||
// ---------------------------------------------------------------------------
|
||||
function renderDeviceTable(manager) {
|
||||
const summary = manager.getSummary();
|
||||
const lines = [];
|
||||
|
||||
lines.push('');
|
||||
lines.push(' DEVICE FINGERPRINTING — RF EMISSIONS ANALYSIS');
|
||||
lines.push(' ' + '='.repeat(60));
|
||||
lines.push('');
|
||||
|
||||
if (summary.learning) {
|
||||
const elapsed = manager.startTime ? Date.now() / 1000 - manager.startTime : 0;
|
||||
const progress = Math.min(100, (elapsed / manager.learnDuration) * 100);
|
||||
const barLen = Math.floor(progress / 2);
|
||||
const bar = '\u2588'.repeat(barLen) + '\u2591'.repeat(50 - barLen);
|
||||
lines.push(` Learning device signatures: [${bar}] ${progress.toFixed(0)}%`);
|
||||
lines.push(` Frames: ${summary.totalFrames}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Device activity table
|
||||
const activitySymbol = {
|
||||
[ACTIVITY.ACTIVE]: '[ON] ',
|
||||
[ACTIVITY.IDLE]: '[off]',
|
||||
[ACTIVITY.CHANGED]: '[CHG]',
|
||||
[ACTIVITY.UNKNOWN]: '[ ? ]',
|
||||
};
|
||||
|
||||
lines.push(' Device Type Ch Similarity Status');
|
||||
lines.push(' ' + '-'.repeat(65));
|
||||
|
||||
for (const dev of summary.devices) {
|
||||
const status = activitySymbol[dev.activity] || '[ ? ]';
|
||||
const trained = dev.trained ? '' : ' (untrained)';
|
||||
lines.push(
|
||||
` ${dev.ssid.substring(0, 28).padEnd(30)} ${dev.type.padEnd(10)} ${String(dev.channel).padStart(2)} ` +
|
||||
`${dev.similarity.padStart(7)} ${status}${trained}`
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderTimeline(manager) {
|
||||
const summary = manager.getSummary();
|
||||
const lines = [];
|
||||
|
||||
lines.push('');
|
||||
lines.push(' Activity Transitions:');
|
||||
lines.push(' ' + '-'.repeat(50));
|
||||
|
||||
let hasTransitions = false;
|
||||
for (const dev of summary.devices) {
|
||||
const fp = manager.fingerprints.get(dev.id);
|
||||
if (fp && fp.activityHistory.length > 0) {
|
||||
hasTransitions = true;
|
||||
const recent = fp.activityHistory.slice(-3);
|
||||
for (const t of recent) {
|
||||
const time = new Date(t.timestamp * 1000).toISOString().substring(11, 19);
|
||||
lines.push(` ${time} ${dev.ssid.substring(0, 20).padEnd(20)} ${t.from} -> ${t.to} (score=${t.score})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasTransitions) {
|
||||
lines.push(' (no transitions detected yet)');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderChannelActivity(manager) {
|
||||
const summary = manager.getSummary();
|
||||
const lines = [];
|
||||
|
||||
lines.push('');
|
||||
lines.push(' Per-Channel Device Activity:');
|
||||
|
||||
const channels = [...new Set(summary.devices.map(d => d.channel))].sort((a, b) => a - b);
|
||||
for (const ch of channels) {
|
||||
const devs = summary.devices.filter(d => d.channel === ch);
|
||||
const activeCount = devs.filter(d => d.activity === ACTIVITY.ACTIVE).length;
|
||||
lines.push(` ch${ch} (${CHANNEL_FREQ[ch]} MHz): ${activeCount}/${devs.length} devices active`);
|
||||
for (const dev of devs) {
|
||||
const bar = '\u2588'.repeat(Math.floor(parseFloat(dev.similarity) * 20));
|
||||
lines.push(` ${dev.ssid.substring(0, 18).padEnd(18)} ${bar} ${dev.similarity}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global state
|
||||
// ---------------------------------------------------------------------------
|
||||
const manager = new FingerprintManager(LEARN_DURATION);
|
||||
let lastDisplayMs = 0;
|
||||
|
||||
// Load saved fingerprints if specified
|
||||
if (args['load-fingerprints']) {
|
||||
const loaded = manager.loadFingerprints(args['load-fingerprints']);
|
||||
if (!JSON_OUTPUT) console.log(`Loaded ${loaded} fingerprints from ${args['load-fingerprints']}`);
|
||||
}
|
||||
|
||||
function displayUpdate() {
|
||||
if (JSON_OUTPUT) {
|
||||
const summary = manager.getSummary();
|
||||
console.log(JSON.stringify({
|
||||
timestamp: Date.now() / 1000,
|
||||
learning: summary.learning,
|
||||
totalFrames: summary.totalFrames,
|
||||
devices: summary.devices.map(d => ({
|
||||
id: d.id, ssid: d.ssid, activity: d.activity,
|
||||
similarity: d.similarity, channel: d.channel,
|
||||
})),
|
||||
}));
|
||||
} else {
|
||||
process.stdout.write('\x1B[2J\x1B[H');
|
||||
console.log(renderDeviceTable(manager));
|
||||
console.log(renderTimeline(manager));
|
||||
console.log(renderChannelActivity(manager));
|
||||
console.log('');
|
||||
console.log(` Total frames: ${manager.totalFrames}`);
|
||||
console.log(' Press Ctrl+C to exit');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live mode
|
||||
// ---------------------------------------------------------------------------
|
||||
function startLive() {
|
||||
const sock = dgram.createSocket('udp4');
|
||||
|
||||
sock.on('message', (buf) => {
|
||||
if (buf.length < 4) return;
|
||||
const magic = buf.readUInt32LE(0);
|
||||
if (magic !== CSI_MAGIC) return;
|
||||
|
||||
const frame = parseCSIFrame(buf);
|
||||
if (!frame) return;
|
||||
|
||||
const result = manager.ingestFrame(frame.channel, frame.amplitudes, Date.now() / 1000);
|
||||
|
||||
// Announce learning completion
|
||||
if (result && result.event === 'learn_complete' && !JSON_OUTPUT) {
|
||||
console.log(`\nLearning complete! Trained ${result.trained}/${result.total} device fingerprints`);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastDisplayMs >= INTERVAL_MS) {
|
||||
displayUpdate();
|
||||
lastDisplayMs = now;
|
||||
}
|
||||
});
|
||||
|
||||
sock.bind(PORT, () => {
|
||||
if (!JSON_OUTPUT) {
|
||||
console.log(`Device Fingerprinter listening on UDP port ${PORT}`);
|
||||
console.log(`Learning duration: ${LEARN_DURATION}s`);
|
||||
console.log(`Known devices: ${KNOWN_DEVICES.length}`);
|
||||
console.log('Waiting for CSI frames...');
|
||||
}
|
||||
});
|
||||
|
||||
if (DURATION_MS) {
|
||||
setTimeout(() => {
|
||||
displayUpdate();
|
||||
if (args['save-fingerprints']) {
|
||||
const saved = manager.saveFingerprints(args['save-fingerprints']);
|
||||
if (!JSON_OUTPUT) console.log(`Saved ${saved} fingerprints to ${args['save-fingerprints']}`);
|
||||
}
|
||||
sock.close();
|
||||
process.exit(0);
|
||||
}, DURATION_MS);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Replay mode
|
||||
// ---------------------------------------------------------------------------
|
||||
async function startReplay(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`File not found: ${filePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: fs.createReadStream(filePath),
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
let frameCount = 0;
|
||||
let lastAnalysisTs = 0;
|
||||
let windowCount = 0;
|
||||
let learnComplete = false;
|
||||
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
let record;
|
||||
try { record = JSON.parse(line); } catch { continue; }
|
||||
if (record.type !== 'raw_csi' || !record.iq_hex) continue;
|
||||
|
||||
const amplitudes = parseIqHex(record.iq_hex, record.subcarriers || 64);
|
||||
const channel = record.channel || assignChannel(record.node_id);
|
||||
|
||||
const result = manager.ingestFrame(channel, amplitudes, record.timestamp);
|
||||
frameCount++;
|
||||
|
||||
if (result && result.event === 'learn_complete' && !learnComplete) {
|
||||
learnComplete = true;
|
||||
if (!JSON_OUTPUT) {
|
||||
console.log(`\nLearning complete at t=${record.timestamp.toFixed(1)}s`);
|
||||
console.log(`Trained ${result.trained}/${result.total} device fingerprints`);
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
const tsMs = record.timestamp * 1000;
|
||||
if (lastAnalysisTs === 0) lastAnalysisTs = tsMs;
|
||||
|
||||
if (tsMs - lastAnalysisTs >= INTERVAL_MS) {
|
||||
windowCount++;
|
||||
const summary = manager.getSummary();
|
||||
|
||||
if (JSON_OUTPUT) {
|
||||
console.log(JSON.stringify({
|
||||
window: windowCount,
|
||||
timestamp: record.timestamp,
|
||||
learning: summary.learning,
|
||||
devices: summary.devices.map(d => ({
|
||||
id: d.id, activity: d.activity, similarity: d.similarity,
|
||||
})),
|
||||
}));
|
||||
} else if (!summary.learning) {
|
||||
// Compact per-window output
|
||||
const active = summary.devices.filter(d => d.activity === ACTIVITY.ACTIVE);
|
||||
const changed = summary.devices.filter(d => d.activity === ACTIVITY.CHANGED);
|
||||
let line = ` [${String(windowCount).padStart(4)}] t=${record.timestamp.toFixed(1)}s active: `;
|
||||
line += active.length > 0
|
||||
? active.map(d => `${d.ssid.substring(0, 15)}(${d.similarity})`).join(', ')
|
||||
: '(none)';
|
||||
if (changed.length > 0) {
|
||||
line += ' changed: ' + changed.map(d => d.ssid.substring(0, 12)).join(', ');
|
||||
}
|
||||
console.log(line);
|
||||
}
|
||||
|
||||
lastAnalysisTs = tsMs;
|
||||
}
|
||||
}
|
||||
|
||||
// Save fingerprints if requested
|
||||
if (args['save-fingerprints']) {
|
||||
const saved = manager.saveFingerprints(args['save-fingerprints']);
|
||||
if (!JSON_OUTPUT) console.log(`\nSaved ${saved} fingerprints to ${args['save-fingerprints']}`);
|
||||
}
|
||||
|
||||
// Final summary
|
||||
if (!JSON_OUTPUT) {
|
||||
const summary = manager.getSummary();
|
||||
console.log('');
|
||||
console.log('='.repeat(60));
|
||||
console.log('DEVICE FINGERPRINT SUMMARY');
|
||||
console.log('='.repeat(60));
|
||||
console.log(renderDeviceTable(manager));
|
||||
console.log(renderTimeline(manager));
|
||||
|
||||
// Statistics
|
||||
const trained = summary.devices.filter(d => d.trained).length;
|
||||
const active = summary.devices.filter(d => d.activity === ACTIVITY.ACTIVE).length;
|
||||
console.log('');
|
||||
console.log(` Trained fingerprints: ${trained}/${summary.devices.length}`);
|
||||
console.log(` Currently active: ${active}/${summary.devices.length}`);
|
||||
console.log(` Total frames: ${frameCount}`);
|
||||
console.log(` Analysis windows: ${windowCount}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
if (args.replay) {
|
||||
startReplay(args.replay);
|
||||
} else {
|
||||
startLive();
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ADR-077: Gait Analysis / Movement Disorder Detection
|
||||
*
|
||||
* Extracts walking cadence, stride regularity, asymmetry, and tremor indicators
|
||||
* from CSI motion energy and phase variance time series.
|
||||
*
|
||||
* DISCLAIMER: This is an informational tool, NOT a medical device.
|
||||
* Do not use for clinical diagnosis of movement disorders.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/gait-analyzer.js --replay data/recordings/overnight-1775217646.csi.jsonl
|
||||
* node scripts/gait-analyzer.js --port 5006
|
||||
* node scripts/gait-analyzer.js --replay FILE --json
|
||||
*
|
||||
* ADR: docs/adr/ADR-077-novel-rf-sensing-applications.md
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const dgram = require('dgram');
|
||||
const fs = require('fs');
|
||||
const readline = require('readline');
|
||||
const { parseArgs } = require('util');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
port: { type: 'string', short: 'p', default: '5006' },
|
||||
replay: { type: 'string', short: 'r' },
|
||||
json: { type: 'boolean', default: false },
|
||||
interval: { type: 'string', short: 'i', default: '5000' },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
const PORT = parseInt(args.port, 10);
|
||||
const JSON_OUTPUT = args.json;
|
||||
const INTERVAL_MS = parseInt(args.interval, 10);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ADR-018 packet constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const CSI_MAGIC = 0xC5110001;
|
||||
const VITALS_MAGIC = 0xC5110002;
|
||||
const FUSED_MAGIC = 0xC5110004;
|
||||
const HEADER_SIZE = 20;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simple FFT (radix-2 DIT)
|
||||
// ---------------------------------------------------------------------------
|
||||
function fft(re, im) {
|
||||
const n = re.length;
|
||||
if (n <= 1) return;
|
||||
|
||||
for (let i = 1, j = 0; i < n; i++) {
|
||||
let bit = n >> 1;
|
||||
for (; j & bit; bit >>= 1) j ^= bit;
|
||||
j ^= bit;
|
||||
if (i < j) {
|
||||
[re[i], re[j]] = [re[j], re[i]];
|
||||
[im[i], im[j]] = [im[j], im[i]];
|
||||
}
|
||||
}
|
||||
|
||||
for (let len = 2; len <= n; len *= 2) {
|
||||
const half = len / 2;
|
||||
const angle = -2 * Math.PI / len;
|
||||
const wRe = Math.cos(angle);
|
||||
const wIm = Math.sin(angle);
|
||||
|
||||
for (let i = 0; i < n; i += len) {
|
||||
let curRe = 1, curIm = 0;
|
||||
for (let j = 0; j < half; j++) {
|
||||
const tRe = curRe * re[i + j + half] - curIm * im[i + j + half];
|
||||
const tIm = curRe * im[i + j + half] + curIm * re[i + j + half];
|
||||
re[i + j + half] = re[i + j] - tRe;
|
||||
im[i + j + half] = im[i + j] - tIm;
|
||||
re[i + j] += tRe;
|
||||
im[i + j] += tIm;
|
||||
const newCurRe = curRe * wRe - curIm * wIm;
|
||||
curIm = curRe * wIm + curIm * wRe;
|
||||
curRe = newCurRe;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function nextPow2(n) {
|
||||
let p = 1;
|
||||
while (p < n) p *= 2;
|
||||
return p;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gait analysis engine
|
||||
// ---------------------------------------------------------------------------
|
||||
class GaitAnalyzer {
|
||||
constructor() {
|
||||
// Per-node time series buffers (5-second windows at ~22 fps or ~1 Hz vitals)
|
||||
this.motionBuffers = new Map(); // nodeId -> [{ timestamp, motion }]
|
||||
this.phaseVarBuffers = new Map(); // nodeId -> [{ timestamp, phaseVar }]
|
||||
this.maxAge = 5.0; // seconds
|
||||
this.results = [];
|
||||
}
|
||||
|
||||
pushMotion(nodeId, timestamp, motion) {
|
||||
if (!this.motionBuffers.has(nodeId)) this.motionBuffers.set(nodeId, []);
|
||||
const buf = this.motionBuffers.get(nodeId);
|
||||
buf.push({ timestamp, motion });
|
||||
const cutoff = timestamp - this.maxAge;
|
||||
while (buf.length > 0 && buf[0].timestamp < cutoff) buf.shift();
|
||||
}
|
||||
|
||||
pushPhaseVar(nodeId, timestamp, phaseVar) {
|
||||
if (!this.phaseVarBuffers.has(nodeId)) this.phaseVarBuffers.set(nodeId, []);
|
||||
const buf = this.phaseVarBuffers.get(nodeId);
|
||||
buf.push({ timestamp, phaseVar });
|
||||
const cutoff = timestamp - this.maxAge;
|
||||
while (buf.length > 0 && buf[0].timestamp < cutoff) buf.shift();
|
||||
}
|
||||
|
||||
analyze(timestamp) {
|
||||
const perNode = {};
|
||||
let bestCadence = 0;
|
||||
let bestRegularity = 0;
|
||||
const cadences = [];
|
||||
|
||||
for (const [nodeId, buf] of this.motionBuffers) {
|
||||
if (buf.length < 5) {
|
||||
perNode[nodeId] = { cadence: 0, regularity: 0, state: 'insufficient data' };
|
||||
continue;
|
||||
}
|
||||
|
||||
const motionValues = buf.map(b => b.motion);
|
||||
|
||||
// Estimate sampling rate
|
||||
const duration = buf[buf.length - 1].timestamp - buf[0].timestamp;
|
||||
const fs = duration > 0 ? buf.length / duration : 1;
|
||||
|
||||
// FFT for cadence
|
||||
const nfft = nextPow2(Math.max(motionValues.length, 32));
|
||||
const re = new Float64Array(nfft);
|
||||
const im = new Float64Array(nfft);
|
||||
|
||||
const mean = motionValues.reduce((a, b) => a + b, 0) / motionValues.length;
|
||||
for (let i = 0; i < motionValues.length; i++) {
|
||||
const hann = 0.5 * (1 - Math.cos(2 * Math.PI * i / (motionValues.length - 1)));
|
||||
re[i] = (motionValues[i] - mean) * hann;
|
||||
}
|
||||
|
||||
fft(re, im);
|
||||
|
||||
// Find dominant frequency in walking range (0.8 - 2.5 Hz)
|
||||
const freqRes = fs / nfft;
|
||||
let peakPower = 0, peakFreq = 0;
|
||||
let totalPower = 0;
|
||||
|
||||
for (let k = 1; k < nfft / 2; k++) {
|
||||
const freq = k * freqRes;
|
||||
const power = re[k] * re[k] + im[k] * im[k];
|
||||
totalPower += power;
|
||||
|
||||
if (freq >= 0.8 && freq <= 2.5 && power > peakPower) {
|
||||
peakPower = power;
|
||||
peakFreq = freq;
|
||||
}
|
||||
}
|
||||
|
||||
const cadence = peakFreq * 60; // steps per minute (each leg cycle)
|
||||
const regularity = totalPower > 0 ? peakPower / totalPower : 0;
|
||||
|
||||
// Autocorrelation for stride regularity
|
||||
const autoCorr = this._autocorrelation(motionValues);
|
||||
const strideRegularity = autoCorr > 0 ? autoCorr : 0;
|
||||
|
||||
// State classification
|
||||
let state;
|
||||
if (mean < 1.0) state = 'stationary';
|
||||
else if (peakFreq >= 0.8 && peakFreq <= 2.0 && regularity > 0.1) state = 'walking';
|
||||
else if (peakFreq > 2.0 && regularity > 0.1) state = 'running';
|
||||
else state = 'moving (irregular)';
|
||||
|
||||
perNode[nodeId] = {
|
||||
cadence: +cadence.toFixed(1),
|
||||
cadenceHz: +peakFreq.toFixed(3),
|
||||
regularity: +regularity.toFixed(3),
|
||||
strideRegularity: +strideRegularity.toFixed(3),
|
||||
meanMotion: +mean.toFixed(3),
|
||||
state,
|
||||
samples: buf.length,
|
||||
fps: +fs.toFixed(1),
|
||||
};
|
||||
|
||||
if (cadence > bestCadence) bestCadence = cadence;
|
||||
if (regularity > bestRegularity) bestRegularity = regularity;
|
||||
if (peakFreq > 0) cadences.push(cadence);
|
||||
}
|
||||
|
||||
// Cross-node asymmetry (if 2+ nodes)
|
||||
let asymmetry = 0;
|
||||
const nodeKeys = Object.keys(perNode);
|
||||
if (nodeKeys.length >= 2) {
|
||||
const c0 = perNode[nodeKeys[0]].cadenceHz;
|
||||
const c1 = perNode[nodeKeys[1]].cadenceHz;
|
||||
const meanC = (c0 + c1) / 2;
|
||||
asymmetry = meanC > 0 ? Math.abs(c0 - c1) / meanC : 0;
|
||||
}
|
||||
|
||||
// Tremor detection from phase variance
|
||||
let tremorScore = 0;
|
||||
let tremorFreq = 0;
|
||||
for (const [, buf] of this.phaseVarBuffers) {
|
||||
if (buf.length < 10) continue;
|
||||
|
||||
const values = buf.map(b => b.phaseVar);
|
||||
const duration = buf[buf.length - 1].timestamp - buf[0].timestamp;
|
||||
const fs = duration > 0 ? buf.length / duration : 1;
|
||||
|
||||
const nfft = nextPow2(Math.max(values.length, 32));
|
||||
const re = new Float64Array(nfft);
|
||||
const im = new Float64Array(nfft);
|
||||
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
||||
for (let i = 0; i < values.length; i++) re[i] = values[i] - mean;
|
||||
fft(re, im);
|
||||
|
||||
const freqRes = fs / nfft;
|
||||
let tPeak = 0, tFreq = 0;
|
||||
for (let k = 1; k < nfft / 2; k++) {
|
||||
const freq = k * freqRes;
|
||||
const power = re[k] * re[k] + im[k] * im[k];
|
||||
if (freq >= 3.0 && freq <= 8.0 && power > tPeak) {
|
||||
tPeak = power;
|
||||
tFreq = freq;
|
||||
}
|
||||
}
|
||||
if (tPeak > tremorScore) {
|
||||
tremorScore = tPeak;
|
||||
tremorFreq = tFreq;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize tremor score to 0-1 range (heuristic)
|
||||
const tremorNorm = Math.min(tremorScore / 100, 1.0);
|
||||
|
||||
const result = {
|
||||
timestamp,
|
||||
cadence: +bestCadence.toFixed(1),
|
||||
regularity: +bestRegularity.toFixed(3),
|
||||
asymmetry: +asymmetry.toFixed(3),
|
||||
tremorScore: +tremorNorm.toFixed(3),
|
||||
tremorFreqHz: +tremorFreq.toFixed(2),
|
||||
perNode,
|
||||
overallState: this._overallState(perNode),
|
||||
};
|
||||
|
||||
this.results.push(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
_autocorrelation(values) {
|
||||
const n = values.length;
|
||||
if (n < 4) return 0;
|
||||
|
||||
const mean = values.reduce((a, b) => a + b, 0) / n;
|
||||
let denom = 0;
|
||||
for (let i = 0; i < n; i++) denom += (values[i] - mean) ** 2;
|
||||
if (denom < 0.001) return 0;
|
||||
|
||||
// Check autocorrelation at lag = n/4 to n/2 (typical stride period range)
|
||||
let bestCorr = 0;
|
||||
const minLag = Math.max(2, Math.floor(n / 4));
|
||||
const maxLag = Math.floor(n / 2);
|
||||
|
||||
for (let lag = minLag; lag <= maxLag; lag++) {
|
||||
let num = 0;
|
||||
for (let i = 0; i < n - lag; i++) {
|
||||
num += (values[i] - mean) * (values[i + lag] - mean);
|
||||
}
|
||||
const corr = num / denom;
|
||||
if (corr > bestCorr) bestCorr = corr;
|
||||
}
|
||||
|
||||
return bestCorr;
|
||||
}
|
||||
|
||||
_overallState(perNode) {
|
||||
const states = Object.values(perNode).map(n => n.state);
|
||||
if (states.includes('walking')) return 'walking';
|
||||
if (states.includes('running')) return 'running';
|
||||
if (states.includes('moving (irregular)')) return 'moving';
|
||||
return 'stationary';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Packet parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseVitalsJsonl(record) {
|
||||
if (record.type !== 'vitals') return null;
|
||||
return {
|
||||
timestamp: record.timestamp,
|
||||
nodeId: record.node_id,
|
||||
motion: record.motion_energy || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function parseCsiJsonl(record) {
|
||||
if (record.type !== 'raw_csi' || !record.iq_hex) return null;
|
||||
const nSc = record.subcarriers || 64;
|
||||
const bytes = Buffer.from(record.iq_hex, 'hex');
|
||||
|
||||
// Compute phase variance across subcarriers
|
||||
let phaseSum = 0, phaseSqSum = 0, count = 0;
|
||||
for (let sc = 0; sc < nSc; sc++) {
|
||||
const offset = 2 + sc * 2;
|
||||
if (offset + 1 >= bytes.length) break;
|
||||
let I = bytes[offset]; if (I > 127) I -= 256;
|
||||
let Q = bytes[offset + 1]; if (Q > 127) Q -= 256;
|
||||
const phase = Math.atan2(Q, I);
|
||||
phaseSum += phase;
|
||||
phaseSqSum += phase * phase;
|
||||
count++;
|
||||
}
|
||||
|
||||
const phaseMean = count > 0 ? phaseSum / count : 0;
|
||||
const phaseVar = count > 1 ? (phaseSqSum / count - phaseMean * phaseMean) : 0;
|
||||
|
||||
return {
|
||||
timestamp: record.timestamp,
|
||||
nodeId: record.node_id,
|
||||
phaseVar: Math.abs(phaseVar),
|
||||
};
|
||||
}
|
||||
|
||||
function parseVitalsUdp(buf) {
|
||||
if (buf.length < 32) return null;
|
||||
const magic = buf.readUInt32LE(0);
|
||||
if (magic !== VITALS_MAGIC && magic !== FUSED_MAGIC) return null;
|
||||
return {
|
||||
timestamp: Date.now() / 1000,
|
||||
nodeId: buf.readUInt8(4),
|
||||
motion: buf.readFloatLE(16),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCsiUdp(buf) {
|
||||
if (buf.length < HEADER_SIZE) return null;
|
||||
const magic = buf.readUInt32LE(0);
|
||||
if (magic !== CSI_MAGIC) return null;
|
||||
|
||||
const nodeId = buf.readUInt8(4);
|
||||
const nSc = buf.readUInt16LE(6);
|
||||
|
||||
let phaseSum = 0, phaseSqSum = 0, count = 0;
|
||||
for (let sc = 0; sc < nSc; sc++) {
|
||||
const offset = HEADER_SIZE + sc * 2;
|
||||
if (offset + 1 >= buf.length) break;
|
||||
const I = buf.readInt8(offset);
|
||||
const Q = buf.readInt8(offset + 1);
|
||||
const phase = Math.atan2(Q, I);
|
||||
phaseSum += phase;
|
||||
phaseSqSum += phase * phase;
|
||||
count++;
|
||||
}
|
||||
|
||||
const phaseMean = count > 0 ? phaseSum / count : 0;
|
||||
const phaseVar = count > 1 ? (phaseSqSum / count - phaseMean * phaseMean) : 0;
|
||||
|
||||
return { timestamp: Date.now() / 1000, nodeId, phaseVar: Math.abs(phaseVar) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display
|
||||
// ---------------------------------------------------------------------------
|
||||
function formatResult(result) {
|
||||
const lines = [];
|
||||
const ts = new Date(result.timestamp * 1000).toISOString().slice(11, 19);
|
||||
lines.push(`[${ts}] ${result.overallState.toUpperCase()}`);
|
||||
lines.push(` Cadence: ${result.cadence} steps/min`);
|
||||
lines.push(` Regularity: ${result.regularity}`);
|
||||
lines.push(` Asymmetry: ${result.asymmetry}`);
|
||||
lines.push(` Tremor: ${result.tremorScore} (${result.tremorFreqHz} Hz)`);
|
||||
|
||||
for (const [nodeId, node] of Object.entries(result.perNode)) {
|
||||
lines.push(` Node ${nodeId}: ${node.state} | ${node.cadence} spm | regularity ${node.regularity} | ${node.samples} samples @ ${node.fps} fps`);
|
||||
}
|
||||
|
||||
// Flags
|
||||
const flags = [];
|
||||
if (result.asymmetry > 0.3) flags.push('HIGH ASYMMETRY');
|
||||
if (result.tremorScore > 0.3) flags.push(`TREMOR DETECTED (${result.tremorFreqHz} Hz)`);
|
||||
if (result.cadence > 0 && result.cadence < 50) flags.push('SLOW CADENCE');
|
||||
if (flags.length > 0) lines.push(` ** ${flags.join(' | ')} **`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Replay mode
|
||||
// ---------------------------------------------------------------------------
|
||||
async function startReplay(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`File not found: ${filePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const analyzer = new GaitAnalyzer();
|
||||
const rl = readline.createInterface({
|
||||
input: fs.createReadStream(filePath),
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
let frameCount = 0;
|
||||
let lastAnalysisTs = 0;
|
||||
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) continue;
|
||||
let record;
|
||||
try { record = JSON.parse(line); } catch { continue; }
|
||||
|
||||
const v = parseVitalsJsonl(record);
|
||||
if (v) {
|
||||
analyzer.pushMotion(v.nodeId, v.timestamp, v.motion);
|
||||
frameCount++;
|
||||
}
|
||||
|
||||
const csi = parseCsiJsonl(record);
|
||||
if (csi) {
|
||||
analyzer.pushPhaseVar(csi.nodeId, csi.timestamp, csi.phaseVar);
|
||||
}
|
||||
|
||||
const ts = (v || csi);
|
||||
if (!ts) continue;
|
||||
|
||||
const tsMs = ts.timestamp * 1000;
|
||||
if (lastAnalysisTs === 0) lastAnalysisTs = tsMs;
|
||||
|
||||
if (tsMs - lastAnalysisTs >= INTERVAL_MS) {
|
||||
const result = analyzer.analyze(ts.timestamp);
|
||||
|
||||
if (JSON_OUTPUT) {
|
||||
console.log(JSON.stringify(result));
|
||||
} else {
|
||||
console.log(formatResult(result));
|
||||
}
|
||||
|
||||
lastAnalysisTs = tsMs;
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
if (!JSON_OUTPUT && analyzer.results.length > 0) {
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('GAIT ANALYSIS SUMMARY');
|
||||
console.log('DISCLAIMER: Informational only. Not a medical device.');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const states = {};
|
||||
let totalCadence = 0, cadenceCount = 0;
|
||||
let maxTremor = 0;
|
||||
|
||||
for (const r of analyzer.results) {
|
||||
states[r.overallState] = (states[r.overallState] || 0) + 1;
|
||||
if (r.cadence > 0) {
|
||||
totalCadence += r.cadence;
|
||||
cadenceCount++;
|
||||
}
|
||||
if (r.tremorScore > maxTremor) maxTremor = r.tremorScore;
|
||||
}
|
||||
|
||||
console.log('Activity distribution:');
|
||||
for (const [state, count] of Object.entries(states)) {
|
||||
const pct = ((count / analyzer.results.length) * 100).toFixed(1);
|
||||
const bar = '\u2588'.repeat(Math.round(pct / 2));
|
||||
console.log(` ${state.padEnd(15)} ${bar.padEnd(50)} ${pct}%`);
|
||||
}
|
||||
|
||||
if (cadenceCount > 0) {
|
||||
console.log(`\nAverage walking cadence: ${(totalCadence / cadenceCount).toFixed(1)} steps/min`);
|
||||
}
|
||||
console.log(`Max tremor score: ${maxTremor.toFixed(3)}`);
|
||||
console.log(`Analysis windows: ${analyzer.results.length}`);
|
||||
console.log(`Processed ${frameCount} vitals packets`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live UDP mode
|
||||
// ---------------------------------------------------------------------------
|
||||
function startLive() {
|
||||
const analyzer = new GaitAnalyzer();
|
||||
const server = dgram.createSocket('udp4');
|
||||
|
||||
server.on('message', (buf) => {
|
||||
const v = parseVitalsUdp(buf);
|
||||
if (v) analyzer.pushMotion(v.nodeId, v.timestamp, v.motion);
|
||||
|
||||
const csi = parseCsiUdp(buf);
|
||||
if (csi) analyzer.pushPhaseVar(csi.nodeId, csi.timestamp, csi.phaseVar);
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
const result = analyzer.analyze(Date.now() / 1000);
|
||||
|
||||
if (JSON_OUTPUT) {
|
||||
console.log(JSON.stringify(result));
|
||||
} else {
|
||||
process.stdout.write('\x1B[2J\x1B[H');
|
||||
console.log('=== GAIT ANALYZER (ADR-077) ===');
|
||||
console.log('DISCLAIMER: Informational only. Not a medical device.\n');
|
||||
console.log(formatResult(result));
|
||||
}
|
||||
}, INTERVAL_MS);
|
||||
|
||||
server.bind(PORT, () => {
|
||||
if (!JSON_OUTPUT) {
|
||||
console.log(`Gait Analyzer listening on UDP :${PORT}`);
|
||||
}
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => { server.close(); process.exit(0); });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry
|
||||
// ---------------------------------------------------------------------------
|
||||
if (args.replay) {
|
||||
startReplay(args.replay);
|
||||
} else {
|
||||
startLive();
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Frequency-Selective Material Classification — Multi-Frequency Mesh Application
|
||||
*
|
||||
* Compares CSI null/attenuation patterns across 6 WiFi channels to classify
|
||||
* materials in the room. Different materials absorb WiFi at different rates
|
||||
* depending on frequency:
|
||||
*
|
||||
* Metal: blocks all frequencies equally (frequency-flat null)
|
||||
* Water: absorbs strongly, increasing with frequency (dielectric loss)
|
||||
* Wood: mild attenuation, increases with frequency (moisture)
|
||||
* Glass: low attenuation, nearly frequency-flat
|
||||
* Human: 60-70% water, strong frequency-dependent absorption
|
||||
*
|
||||
* Requires multi-frequency mesh scanning (ADR-073): 2 ESP32 nodes hopping
|
||||
* across channels 1, 3, 5, 6, 9, 11.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/material-classifier.js
|
||||
* node scripts/material-classifier.js --port 5006 --duration 60
|
||||
* node scripts/material-classifier.js --replay data/recordings/overnight-1775217646.csi.jsonl
|
||||
*
|
||||
* ADR: docs/adr/ADR-078-multifreq-mesh-applications.md
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const dgram = require('dgram');
|
||||
const fs = require('fs');
|
||||
const readline = require('readline');
|
||||
const { parseArgs } = require('util');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
port: { type: 'string', short: 'p', default: '5006' },
|
||||
duration: { type: 'string', short: 'd' },
|
||||
replay: { type: 'string', short: 'r' },
|
||||
interval: { type: 'string', short: 'i', default: '5000' },
|
||||
json: { type: 'boolean', default: false },
|
||||
window: { type: 'string', short: 'w', default: '20' },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
const PORT = parseInt(args.port, 10);
|
||||
const DURATION_MS = args.duration ? parseInt(args.duration, 10) * 1000 : null;
|
||||
const INTERVAL_MS = parseInt(args.interval, 10);
|
||||
const JSON_OUTPUT = args.json;
|
||||
const WINDOW_FRAMES = parseInt(args.window, 10);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const CSI_MAGIC = 0xC5110001;
|
||||
const HEADER_SIZE = 20;
|
||||
|
||||
const CHANNEL_FREQ = {};
|
||||
for (let ch = 1; ch <= 13; ch++) CHANNEL_FREQ[ch] = 2412 + (ch - 1) * 5;
|
||||
|
||||
const NODE1_CHANNELS = [1, 6, 11];
|
||||
const NODE2_CHANNELS = [3, 5, 9];
|
||||
|
||||
// Material classification thresholds
|
||||
const NULL_THRESHOLD = 2.0;
|
||||
|
||||
// Material types
|
||||
const MATERIAL = {
|
||||
METAL: { name: 'Metal', char: '#', desc: 'Total block, frequency-flat' },
|
||||
WATER: { name: 'Water', char: '~', desc: 'Strong absorption, freq-dependent' },
|
||||
HUMAN: { name: 'Human', char: '@', desc: '60-70% water, strong freq-dependent' },
|
||||
WOOD: { name: 'Wood', char: '|', desc: 'Mild attenuation, freq-increasing' },
|
||||
GLASS: { name: 'Glass', char: ':', desc: 'Low attenuation, frequency-flat' },
|
||||
AIR: { name: 'Air', char: '.', desc: 'Minimal attenuation' },
|
||||
COMPLEX: { name: 'Complex', char: '?', desc: 'Mixed/unclassifiable' },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-channel amplitude accumulator
|
||||
// ---------------------------------------------------------------------------
|
||||
class ChannelAccumulator {
|
||||
constructor() {
|
||||
// channel -> { amplitudes: Float64Array[], count: number }
|
||||
this.channels = new Map();
|
||||
}
|
||||
|
||||
ingest(channel, amplitudes) {
|
||||
if (!this.channels.has(channel)) {
|
||||
this.channels.set(channel, {
|
||||
sum: new Float64Array(amplitudes.length),
|
||||
sumSq: new Float64Array(amplitudes.length),
|
||||
count: 0,
|
||||
nSub: amplitudes.length,
|
||||
});
|
||||
}
|
||||
|
||||
const ch = this.channels.get(channel);
|
||||
ch.count++;
|
||||
for (let i = 0; i < amplitudes.length && i < ch.nSub; i++) {
|
||||
ch.sum[i] += amplitudes[i];
|
||||
ch.sumSq[i] += amplitudes[i] * amplitudes[i];
|
||||
}
|
||||
}
|
||||
|
||||
/** Get mean amplitude per subcarrier per channel */
|
||||
getMeans() {
|
||||
const means = new Map();
|
||||
for (const [channel, ch] of this.channels) {
|
||||
if (ch.count === 0) continue;
|
||||
const mean = new Float64Array(ch.nSub);
|
||||
for (let i = 0; i < ch.nSub; i++) {
|
||||
mean[i] = ch.sum[i] / ch.count;
|
||||
}
|
||||
means.set(channel, { mean, count: ch.count, nSub: ch.nSub });
|
||||
}
|
||||
return means;
|
||||
}
|
||||
|
||||
/** Get variance per subcarrier per channel */
|
||||
getVariances() {
|
||||
const variances = new Map();
|
||||
for (const [channel, ch] of this.channels) {
|
||||
if (ch.count < 2) continue;
|
||||
const variance = new Float64Array(ch.nSub);
|
||||
for (let i = 0; i < ch.nSub; i++) {
|
||||
const mean = ch.sum[i] / ch.count;
|
||||
variance[i] = (ch.sumSq[i] / ch.count) - (mean * mean);
|
||||
}
|
||||
variances.set(channel, variance);
|
||||
}
|
||||
return variances;
|
||||
}
|
||||
|
||||
/** Get active channel list sorted by frequency */
|
||||
getActiveChannels() {
|
||||
return [...this.channels.keys()]
|
||||
.filter(ch => this.channels.get(ch).count > 0)
|
||||
.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.channels.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Material classifier
|
||||
// ---------------------------------------------------------------------------
|
||||
class MaterialClassifier {
|
||||
constructor() {
|
||||
this.accumulator = new ChannelAccumulator();
|
||||
this.frameCount = 0;
|
||||
this.classifications = [];
|
||||
}
|
||||
|
||||
ingestFrame(channel, amplitudes) {
|
||||
this.accumulator.ingest(channel, amplitudes);
|
||||
this.frameCount++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify each subcarrier group by comparing attenuation across channels.
|
||||
*
|
||||
* For each subcarrier index:
|
||||
* 1. Collect mean amplitude on each channel
|
||||
* 2. Compute frequency selectivity metrics:
|
||||
* - Flat ratio = std / mean (low = frequency-flat)
|
||||
* - Slope = linear regression of amplitude vs frequency
|
||||
* - Mean level = overall attenuation (high = strong absorber)
|
||||
* 3. Decision tree:
|
||||
* - All channels null -> Metal (frequency-flat total block)
|
||||
* - Flat ratio < 0.15 AND mean < 3.0 -> Metal
|
||||
* - Flat ratio < 0.15 AND mean > 8.0 -> Glass/Air
|
||||
* - Negative slope (amp decreases with freq) AND mean < 6.0 -> Water/Human
|
||||
* - Negative slope AND mean 6.0-8.0 -> Wood
|
||||
* - High variance across channels -> Complex
|
||||
*/
|
||||
classify() {
|
||||
const means = this.accumulator.getMeans();
|
||||
const channels = this.accumulator.getActiveChannels();
|
||||
|
||||
if (channels.length < 2) {
|
||||
return { error: 'Need at least 2 channels for material classification', channels: channels.length };
|
||||
}
|
||||
|
||||
const nSub = Math.min(...[...means.values()].map(m => m.nSub));
|
||||
const freqs = channels.map(ch => CHANNEL_FREQ[ch] || 2432);
|
||||
|
||||
const results = [];
|
||||
const materialCounts = {};
|
||||
for (const m of Object.values(MATERIAL)) materialCounts[m.name] = 0;
|
||||
|
||||
for (let sc = 0; sc < nSub; sc++) {
|
||||
// Collect amplitudes across channels for this subcarrier
|
||||
const amps = channels.map(ch => means.get(ch).mean[sc]);
|
||||
|
||||
// Is this a null on all channels?
|
||||
const allNull = amps.every(a => a < NULL_THRESHOLD);
|
||||
const anyNull = amps.some(a => a < NULL_THRESHOLD);
|
||||
|
||||
// Mean amplitude
|
||||
const meanAmp = amps.reduce((a, b) => a + b, 0) / amps.length;
|
||||
|
||||
// Standard deviation
|
||||
const variance = amps.reduce((a, b) => a + (b - meanAmp) ** 2, 0) / amps.length;
|
||||
const stdAmp = Math.sqrt(variance);
|
||||
|
||||
// Flat ratio (coefficient of variation)
|
||||
const flatRatio = meanAmp > 0.01 ? stdAmp / meanAmp : 0;
|
||||
|
||||
// Frequency slope: linear regression of amplitude vs frequency
|
||||
let sumF = 0, sumA = 0, sumFF = 0, sumFA = 0;
|
||||
for (let i = 0; i < channels.length; i++) {
|
||||
sumF += freqs[i];
|
||||
sumA += amps[i];
|
||||
sumFF += freqs[i] * freqs[i];
|
||||
sumFA += freqs[i] * amps[i];
|
||||
}
|
||||
const nCh = channels.length;
|
||||
const meanF = sumF / nCh;
|
||||
const denomF = sumFF - sumF * meanF;
|
||||
const slope = Math.abs(denomF) > 1e-6
|
||||
? (sumFA - sumF * (sumA / nCh)) / denomF
|
||||
: 0;
|
||||
|
||||
// Normalized slope (per MHz)
|
||||
const slopePerMHz = slope;
|
||||
|
||||
// Classification decision tree
|
||||
let material;
|
||||
if (allNull) {
|
||||
material = MATERIAL.METAL;
|
||||
} else if (flatRatio < 0.15 && meanAmp < 3.0) {
|
||||
material = MATERIAL.METAL;
|
||||
} else if (flatRatio < 0.15 && meanAmp > 10.0) {
|
||||
material = MATERIAL.AIR;
|
||||
} else if (flatRatio < 0.15 && meanAmp > 6.0) {
|
||||
material = MATERIAL.GLASS;
|
||||
} else if (slopePerMHz < -0.005 && meanAmp < 5.0) {
|
||||
// Amplitude decreases with frequency = frequency-dependent absorption
|
||||
material = MATERIAL.HUMAN;
|
||||
} else if (slopePerMHz < -0.003 && meanAmp < 8.0) {
|
||||
material = MATERIAL.WATER;
|
||||
} else if (slopePerMHz < -0.001 && meanAmp >= 5.0) {
|
||||
material = MATERIAL.WOOD;
|
||||
} else if (flatRatio > 0.5) {
|
||||
material = MATERIAL.COMPLEX;
|
||||
} else {
|
||||
material = MATERIAL.AIR;
|
||||
}
|
||||
|
||||
materialCounts[material.name]++;
|
||||
results.push({
|
||||
subcarrier: sc,
|
||||
material: material.name,
|
||||
char: material.char,
|
||||
meanAmp: meanAmp.toFixed(1),
|
||||
flatRatio: flatRatio.toFixed(3),
|
||||
slopePerMHz: slopePerMHz.toFixed(5),
|
||||
amps: amps.map(a => a.toFixed(1)),
|
||||
});
|
||||
}
|
||||
|
||||
this.classifications = results;
|
||||
|
||||
return {
|
||||
channels,
|
||||
nSubcarriers: nSub,
|
||||
frameCount: this.frameCount,
|
||||
materialCounts,
|
||||
classifications: results,
|
||||
};
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.accumulator.reset();
|
||||
this.frameCount = 0;
|
||||
this.classifications = [];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSI parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseIqHex(iqHex, nSubcarriers) {
|
||||
const bytes = Buffer.from(iqHex, 'hex');
|
||||
const amplitudes = new Float64Array(nSubcarriers);
|
||||
|
||||
for (let sc = 0; sc < nSubcarriers; sc++) {
|
||||
const offset = 2 + sc * 2;
|
||||
if (offset + 1 >= bytes.length) break;
|
||||
let I = bytes[offset];
|
||||
let Q = bytes[offset + 1];
|
||||
if (I > 127) I -= 256;
|
||||
if (Q > 127) Q -= 256;
|
||||
amplitudes[sc] = Math.sqrt(I * I + Q * Q);
|
||||
}
|
||||
|
||||
return amplitudes;
|
||||
}
|
||||
|
||||
function parseCSIFrame(buf) {
|
||||
if (buf.length < HEADER_SIZE) return null;
|
||||
const magic = buf.readUInt32LE(0);
|
||||
if (magic !== CSI_MAGIC) return null;
|
||||
|
||||
const nodeId = buf.readUInt8(4);
|
||||
const nSubcarriers = buf.readUInt16LE(6);
|
||||
const freqMhz = buf.readUInt32LE(8);
|
||||
|
||||
const amplitudes = new Float64Array(nSubcarriers);
|
||||
for (let sc = 0; sc < nSubcarriers; sc++) {
|
||||
const offset = HEADER_SIZE + sc * 2;
|
||||
if (offset + 1 >= buf.length) break;
|
||||
const I = buf.readInt8(offset);
|
||||
const Q = buf.readInt8(offset + 1);
|
||||
amplitudes[sc] = Math.sqrt(I * I + Q * Q);
|
||||
}
|
||||
|
||||
let channel = 0;
|
||||
if (freqMhz >= 2412 && freqMhz <= 2484) {
|
||||
channel = freqMhz === 2484 ? 14 : Math.round((freqMhz - 2412) / 5) + 1;
|
||||
}
|
||||
|
||||
return { nodeId, nSubcarriers, freqMhz, amplitudes, channel };
|
||||
}
|
||||
|
||||
const nodeChannelIdx = { 1: 0, 2: 0 };
|
||||
function assignChannel(nodeId) {
|
||||
const channels = nodeId === 1 ? NODE1_CHANNELS : NODE2_CHANNELS;
|
||||
const ch = channels[nodeChannelIdx[nodeId] % channels.length];
|
||||
nodeChannelIdx[nodeId]++;
|
||||
return ch;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Visualization
|
||||
// ---------------------------------------------------------------------------
|
||||
function renderMaterialMap(result) {
|
||||
const { classifications, channels, nSubcarriers, materialCounts } = result;
|
||||
if (!classifications || classifications.length === 0) return ' No classifications available';
|
||||
|
||||
const lines = [];
|
||||
lines.push('');
|
||||
lines.push(' FREQUENCY-SELECTIVE MATERIAL CLASSIFICATION');
|
||||
lines.push(' ' + '='.repeat(55));
|
||||
lines.push('');
|
||||
|
||||
// Material map: one char per subcarrier
|
||||
lines.push(' Subcarrier Material Map (1 char = 1 subcarrier):');
|
||||
let mapRow = ' ';
|
||||
for (let i = 0; i < classifications.length; i++) {
|
||||
mapRow += classifications[i].char;
|
||||
if ((i + 1) % 64 === 0) {
|
||||
lines.push(mapRow);
|
||||
mapRow = ' ';
|
||||
}
|
||||
}
|
||||
if (mapRow.trim()) lines.push(mapRow);
|
||||
|
||||
lines.push('');
|
||||
lines.push(' Legend:');
|
||||
for (const m of Object.values(MATERIAL)) {
|
||||
const count = materialCounts[m.name] || 0;
|
||||
const pct = nSubcarriers > 0 ? (count / nSubcarriers * 100).toFixed(1) : '0.0';
|
||||
lines.push(` ${m.char} = ${m.name.padEnd(8)} (${pct}%) ${m.desc}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderFrequencyProfile(result) {
|
||||
const { classifications, channels } = result;
|
||||
if (!classifications || channels.length < 2) return '';
|
||||
|
||||
const lines = [];
|
||||
lines.push('');
|
||||
lines.push(' Frequency Profile (mean amplitude per channel):');
|
||||
lines.push(' ' + '-'.repeat(50));
|
||||
|
||||
// Compute mean per channel across all subcarriers
|
||||
const channelMeans = {};
|
||||
for (const ch of channels) channelMeans[ch] = { sum: 0, count: 0 };
|
||||
|
||||
for (const cls of classifications) {
|
||||
for (let i = 0; i < channels.length && i < cls.amps.length; i++) {
|
||||
channelMeans[channels[i]].sum += parseFloat(cls.amps[i]);
|
||||
channelMeans[channels[i]].count++;
|
||||
}
|
||||
}
|
||||
|
||||
const BARS = '\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588';
|
||||
let maxMean = 0;
|
||||
for (const ch of channels) {
|
||||
const m = channelMeans[ch].count > 0 ? channelMeans[ch].sum / channelMeans[ch].count : 0;
|
||||
if (m > maxMean) maxMean = m;
|
||||
}
|
||||
if (maxMean === 0) maxMean = 1;
|
||||
|
||||
for (const ch of channels) {
|
||||
const mean = channelMeans[ch].count > 0 ? channelMeans[ch].sum / channelMeans[ch].count : 0;
|
||||
const freq = CHANNEL_FREQ[ch] || 0;
|
||||
const barLen = Math.floor((mean / maxMean) * 30);
|
||||
const bar = BARS[7].repeat(barLen);
|
||||
lines.push(` ch${String(ch).padStart(2)} (${freq} MHz): ${bar} ${mean.toFixed(1)}`);
|
||||
}
|
||||
|
||||
// Slope analysis
|
||||
const freqs = channels.map(ch => CHANNEL_FREQ[ch]);
|
||||
const means = channels.map(ch => {
|
||||
const c = channelMeans[ch];
|
||||
return c.count > 0 ? c.sum / c.count : 0;
|
||||
});
|
||||
|
||||
let sumF = 0, sumA = 0, sumFF = 0, sumFA = 0;
|
||||
for (let i = 0; i < channels.length; i++) {
|
||||
sumF += freqs[i]; sumA += means[i];
|
||||
sumFF += freqs[i] * freqs[i]; sumFA += freqs[i] * means[i];
|
||||
}
|
||||
const nCh = channels.length;
|
||||
const meanF = sumF / nCh;
|
||||
const denomF = sumFF - sumF * meanF;
|
||||
const slope = Math.abs(denomF) > 1e-6 ? (sumFA - sumF * (sumA / nCh)) / denomF : 0;
|
||||
|
||||
lines.push('');
|
||||
if (slope < -0.003) {
|
||||
lines.push(' Overall trend: DECREASING with frequency (water/organic absorption)');
|
||||
} else if (slope > 0.003) {
|
||||
lines.push(' Overall trend: INCREASING with frequency (unusual, possible reflection)');
|
||||
} else {
|
||||
lines.push(' Overall trend: FLAT across frequency (metal or air dominant)');
|
||||
}
|
||||
lines.push(` Slope: ${(slope * 1000).toFixed(3)} amplitude/GHz`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderDetailedSubcarriers(result) {
|
||||
const { classifications, channels } = result;
|
||||
if (!classifications) return '';
|
||||
|
||||
const lines = [];
|
||||
lines.push('');
|
||||
lines.push(' Notable Subcarriers (high frequency selectivity):');
|
||||
lines.push(' ' + '-'.repeat(60));
|
||||
lines.push(' SC# Material Mean Flat Slope/MHz Per-channel amps');
|
||||
|
||||
// Find most interesting subcarriers (high flat ratio or steep slope)
|
||||
const interesting = classifications
|
||||
.filter(c => parseFloat(c.flatRatio) > 0.3 || Math.abs(parseFloat(c.slopePerMHz)) > 0.005)
|
||||
.sort((a, b) => parseFloat(b.flatRatio) - parseFloat(a.flatRatio))
|
||||
.slice(0, 15);
|
||||
|
||||
for (const cls of interesting) {
|
||||
const amps = cls.amps.join(' ');
|
||||
lines.push(` ${String(cls.subcarrier).padStart(3)} ${cls.material.padEnd(8)} ` +
|
||||
`${cls.meanAmp.padStart(5)} ${cls.flatRatio} ${cls.slopePerMHz.padStart(9)} [${amps}]`);
|
||||
}
|
||||
|
||||
if (interesting.length === 0) {
|
||||
lines.push(' (no highly frequency-selective subcarriers detected)');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global state
|
||||
// ---------------------------------------------------------------------------
|
||||
const classifier = new MaterialClassifier();
|
||||
let lastDisplayMs = 0;
|
||||
|
||||
function processFrame(channel, amplitudes) {
|
||||
classifier.ingestFrame(channel, amplitudes);
|
||||
}
|
||||
|
||||
function displayUpdate() {
|
||||
const result = classifier.classify();
|
||||
|
||||
if (JSON_OUTPUT) {
|
||||
console.log(JSON.stringify({
|
||||
timestamp: Date.now() / 1000,
|
||||
channels: result.channels,
|
||||
frameCount: result.frameCount,
|
||||
materialCounts: result.materialCounts,
|
||||
topClassifications: (result.classifications || [])
|
||||
.filter(c => c.material !== 'Air')
|
||||
.slice(0, 20)
|
||||
.map(c => ({ sc: c.subcarrier, material: c.material, meanAmp: c.meanAmp })),
|
||||
}));
|
||||
} else {
|
||||
process.stdout.write('\x1B[2J\x1B[H');
|
||||
console.log(renderMaterialMap(result));
|
||||
console.log(renderFrequencyProfile(result));
|
||||
console.log(renderDetailedSubcarriers(result));
|
||||
console.log('');
|
||||
console.log(` Frames: ${result.frameCount} | Channels: ${(result.channels || []).length}`);
|
||||
console.log(' Press Ctrl+C to exit');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live mode
|
||||
// ---------------------------------------------------------------------------
|
||||
function startLive() {
|
||||
const sock = dgram.createSocket('udp4');
|
||||
|
||||
sock.on('message', (buf) => {
|
||||
if (buf.length < 4) return;
|
||||
const magic = buf.readUInt32LE(0);
|
||||
if (magic !== CSI_MAGIC) return;
|
||||
|
||||
const frame = parseCSIFrame(buf);
|
||||
if (!frame) return;
|
||||
|
||||
processFrame(frame.channel, frame.amplitudes);
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastDisplayMs >= INTERVAL_MS) {
|
||||
displayUpdate();
|
||||
lastDisplayMs = now;
|
||||
}
|
||||
});
|
||||
|
||||
sock.bind(PORT, () => {
|
||||
if (!JSON_OUTPUT) {
|
||||
console.log(`Material Classifier listening on UDP port ${PORT}`);
|
||||
console.log('Waiting for multi-channel CSI frames...');
|
||||
}
|
||||
});
|
||||
|
||||
if (DURATION_MS) {
|
||||
setTimeout(() => { displayUpdate(); sock.close(); process.exit(0); }, DURATION_MS);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Replay mode
|
||||
// ---------------------------------------------------------------------------
|
||||
async function startReplay(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`File not found: ${filePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: fs.createReadStream(filePath),
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
let frameCount = 0;
|
||||
let lastAnalysisTs = 0;
|
||||
let windowCount = 0;
|
||||
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
let record;
|
||||
try { record = JSON.parse(line); } catch { continue; }
|
||||
if (record.type !== 'raw_csi' || !record.iq_hex) continue;
|
||||
|
||||
const amplitudes = parseIqHex(record.iq_hex, record.subcarriers || 64);
|
||||
const channel = record.channel || assignChannel(record.node_id);
|
||||
|
||||
processFrame(channel, amplitudes);
|
||||
frameCount++;
|
||||
|
||||
const tsMs = record.timestamp * 1000;
|
||||
if (lastAnalysisTs === 0) lastAnalysisTs = tsMs;
|
||||
|
||||
if (tsMs - lastAnalysisTs >= INTERVAL_MS) {
|
||||
windowCount++;
|
||||
const result = classifier.classify();
|
||||
|
||||
if (JSON_OUTPUT) {
|
||||
console.log(JSON.stringify({
|
||||
window: windowCount, timestamp: record.timestamp,
|
||||
materialCounts: result.materialCounts,
|
||||
}));
|
||||
} else {
|
||||
console.log(`\n${'='.repeat(60)}`);
|
||||
console.log(`Window ${windowCount} | t=${record.timestamp.toFixed(1)}s | frames=${frameCount}`);
|
||||
console.log('='.repeat(60));
|
||||
console.log(renderMaterialMap(result));
|
||||
console.log(renderFrequencyProfile(result));
|
||||
}
|
||||
lastAnalysisTs = tsMs;
|
||||
}
|
||||
}
|
||||
|
||||
// Final
|
||||
if (!JSON_OUTPUT) {
|
||||
const result = classifier.classify();
|
||||
console.log(`\n${'='.repeat(60)}`);
|
||||
console.log('FINAL MATERIAL CLASSIFICATION');
|
||||
console.log('='.repeat(60));
|
||||
console.log(renderMaterialMap(result));
|
||||
console.log(renderFrequencyProfile(result));
|
||||
console.log(renderDetailedSubcarriers(result));
|
||||
console.log(`\nProcessed ${frameCount} frames in ${windowCount} windows`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
if (args.replay) {
|
||||
startReplay(args.replay);
|
||||
} else {
|
||||
startLive();
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ADR-077: Material/Object Change Detection
|
||||
*
|
||||
* Monitors CSI subcarrier null patterns to detect when objects (metal, water,
|
||||
* wood, glass) are introduced, removed, or moved in the sensing area.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/material-detector.js --replay data/recordings/overnight-1775217646.csi.jsonl
|
||||
* node scripts/material-detector.js --port 5006
|
||||
* node scripts/material-detector.js --replay FILE --json
|
||||
* node scripts/material-detector.js --replay FILE --baseline-time 120
|
||||
*
|
||||
* ADR: docs/adr/ADR-077-novel-rf-sensing-applications.md
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const dgram = require('dgram');
|
||||
const fs = require('fs');
|
||||
const readline = require('readline');
|
||||
const { parseArgs } = require('util');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
port: { type: 'string', short: 'p', default: '5006' },
|
||||
replay: { type: 'string', short: 'r' },
|
||||
json: { type: 'boolean', default: false },
|
||||
interval: { type: 'string', short: 'i', default: '5000' },
|
||||
'baseline-time': { type: 'string', default: '60' },
|
||||
'null-threshold': { type: 'string', default: '2.0' },
|
||||
'change-threshold': { type: 'string', default: '3' },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
const PORT = parseInt(args.port, 10);
|
||||
const JSON_OUTPUT = args.json;
|
||||
const INTERVAL_MS = parseInt(args.interval, 10);
|
||||
const BASELINE_SEC = parseInt(args['baseline-time'], 10);
|
||||
const NULL_THRESHOLD = parseFloat(args['null-threshold']);
|
||||
const CHANGE_THRESHOLD = parseInt(args['change-threshold'], 10); // min subcarriers changed
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ADR-018 packet constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const CSI_MAGIC = 0xC5110001;
|
||||
const HEADER_SIZE = 20;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subcarrier null pattern tracker
|
||||
// ---------------------------------------------------------------------------
|
||||
class NullPatternTracker {
|
||||
constructor(nSubcarriers) {
|
||||
this.nSc = nSubcarriers || 64;
|
||||
|
||||
// Baseline (Welford mean per subcarrier)
|
||||
this.baselineMean = new Float64Array(256);
|
||||
this.baselineCount = new Uint32Array(256);
|
||||
this.baselineEstablished = false;
|
||||
this.baselineNulls = new Set();
|
||||
|
||||
// Current window state
|
||||
this.currentAmps = new Float64Array(256);
|
||||
this.currentCount = 0;
|
||||
|
||||
// Events
|
||||
this.events = [];
|
||||
this.startTime = null;
|
||||
this.lastTime = null;
|
||||
}
|
||||
|
||||
updateBaseline(amplitudes) {
|
||||
const n = amplitudes.length;
|
||||
this.nSc = n;
|
||||
for (let i = 0; i < n; i++) {
|
||||
this.baselineCount[i]++;
|
||||
const delta = amplitudes[i] - this.baselineMean[i];
|
||||
this.baselineMean[i] += delta / this.baselineCount[i];
|
||||
}
|
||||
}
|
||||
|
||||
finalizeBaseline() {
|
||||
this.baselineNulls = new Set();
|
||||
for (let i = 0; i < this.nSc; i++) {
|
||||
if (this.baselineMean[i] < NULL_THRESHOLD) {
|
||||
this.baselineNulls.add(i);
|
||||
}
|
||||
}
|
||||
this.baselineEstablished = true;
|
||||
}
|
||||
|
||||
updateCurrent(amplitudes) {
|
||||
const n = amplitudes.length;
|
||||
// Exponential moving average for current window
|
||||
const alpha = 0.1;
|
||||
if (this.currentCount === 0) {
|
||||
for (let i = 0; i < n; i++) this.currentAmps[i] = amplitudes[i];
|
||||
} else {
|
||||
for (let i = 0; i < n; i++) {
|
||||
this.currentAmps[i] = this.currentAmps[i] * (1 - alpha) + amplitudes[i] * alpha;
|
||||
}
|
||||
}
|
||||
this.currentCount++;
|
||||
}
|
||||
|
||||
detectChanges(timestamp) {
|
||||
if (!this.baselineEstablished || this.currentCount < 5) return null;
|
||||
|
||||
const currentNulls = new Set();
|
||||
for (let i = 0; i < this.nSc; i++) {
|
||||
if (this.currentAmps[i] < NULL_THRESHOLD) {
|
||||
currentNulls.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Find differences
|
||||
const newNulls = []; // appeared (something blocking)
|
||||
const removedNulls = []; // disappeared (object removed)
|
||||
const shiftedNulls = []; // nearby shifts
|
||||
|
||||
for (const sc of currentNulls) {
|
||||
if (!this.baselineNulls.has(sc)) newNulls.push(sc);
|
||||
}
|
||||
for (const sc of this.baselineNulls) {
|
||||
if (!currentNulls.has(sc)) removedNulls.push(sc);
|
||||
}
|
||||
|
||||
// Detect shifts (null moved by 1-3 subcarriers)
|
||||
for (const newSc of newNulls) {
|
||||
for (const rmSc of removedNulls) {
|
||||
if (Math.abs(newSc - rmSc) <= 3) {
|
||||
shiftedNulls.push({ from: rmSc, to: newSc });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Amplitude changes (non-null subcarriers with significant amplitude shift)
|
||||
const ampChanges = [];
|
||||
for (let i = 0; i < this.nSc; i++) {
|
||||
if (this.baselineMean[i] > NULL_THRESHOLD && this.currentAmps[i] > NULL_THRESHOLD) {
|
||||
const ratio = this.currentAmps[i] / this.baselineMean[i];
|
||||
if (ratio < 0.5 || ratio > 2.0) {
|
||||
ampChanges.push({ sc: i, baseline: this.baselineMean[i], current: this.currentAmps[i], ratio });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Material classification
|
||||
let material = 'unknown';
|
||||
if (newNulls.length > 0) {
|
||||
// Null pattern indicates metal
|
||||
if (newNulls.length <= 5) material = 'metal (small object)';
|
||||
else if (newNulls.length <= 15) material = 'metal (medium)';
|
||||
else material = 'metal (large)';
|
||||
} else if (ampChanges.length > this.nSc * 0.3) {
|
||||
// Broad amplitude change = water or human
|
||||
const avgRatio = ampChanges.reduce((s, c) => s + c.ratio, 0) / ampChanges.length;
|
||||
material = avgRatio < 1 ? 'water/human (absorption)' : 'reflective surface';
|
||||
} else if (ampChanges.length > 0 && ampChanges.length <= this.nSc * 0.1) {
|
||||
material = 'wood/plastic (minimal)';
|
||||
}
|
||||
|
||||
const totalChanges = newNulls.length + removedNulls.length + ampChanges.length;
|
||||
|
||||
// Only report if significant changes
|
||||
if (totalChanges < CHANGE_THRESHOLD) {
|
||||
return {
|
||||
timestamp,
|
||||
changeDetected: false,
|
||||
currentNullCount: currentNulls.size,
|
||||
baselineNullCount: this.baselineNulls.size,
|
||||
};
|
||||
}
|
||||
|
||||
// Determine event type
|
||||
let eventType;
|
||||
if (shiftedNulls.length > 0) eventType = 'moved';
|
||||
else if (newNulls.length > removedNulls.length) eventType = 'added';
|
||||
else if (removedNulls.length > newNulls.length) eventType = 'removed';
|
||||
else eventType = 'changed';
|
||||
|
||||
const event = {
|
||||
timestamp,
|
||||
changeDetected: true,
|
||||
eventType,
|
||||
material,
|
||||
newNulls: newNulls.length,
|
||||
removedNulls: removedNulls.length,
|
||||
shiftedNulls: shiftedNulls.length,
|
||||
ampChanges: ampChanges.length,
|
||||
newNullRange: newNulls.length > 0 ? [Math.min(...newNulls), Math.max(...newNulls)] : null,
|
||||
removedNullRange: removedNulls.length > 0 ? [Math.min(...removedNulls), Math.max(...removedNulls)] : null,
|
||||
currentNullCount: currentNulls.size,
|
||||
baselineNullCount: this.baselineNulls.size,
|
||||
nullDelta: currentNulls.size - this.baselineNulls.size,
|
||||
};
|
||||
|
||||
this.events.push(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
renderNullMap() {
|
||||
const chars = [];
|
||||
for (let i = 0; i < this.nSc; i++) {
|
||||
if (this.currentAmps[i] < NULL_THRESHOLD) {
|
||||
if (this.baselineNulls.has(i)) chars.push('_'); // baseline null
|
||||
else chars.push('X'); // new null
|
||||
} else if (this.baselineNulls.has(i)) {
|
||||
chars.push('O'); // removed null
|
||||
} else {
|
||||
chars.push('\u2581'); // normal
|
||||
}
|
||||
}
|
||||
return chars.join('');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-node manager
|
||||
// ---------------------------------------------------------------------------
|
||||
class MaterialDetector {
|
||||
constructor() {
|
||||
this.trackers = new Map(); // nodeId -> NullPatternTracker
|
||||
this.startTime = null;
|
||||
this.allEvents = [];
|
||||
}
|
||||
|
||||
ingestCSI(nodeId, timestamp, amplitudes) {
|
||||
if (!this.startTime) this.startTime = timestamp;
|
||||
|
||||
if (!this.trackers.has(nodeId)) {
|
||||
this.trackers.set(nodeId, new NullPatternTracker(amplitudes.length));
|
||||
}
|
||||
const tracker = this.trackers.get(nodeId);
|
||||
tracker.lastTime = timestamp;
|
||||
if (!tracker.startTime) tracker.startTime = timestamp;
|
||||
|
||||
// Baseline phase
|
||||
const elapsed = timestamp - tracker.startTime;
|
||||
if (elapsed < BASELINE_SEC) {
|
||||
tracker.updateBaseline(amplitudes);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Finalize baseline on transition
|
||||
if (!tracker.baselineEstablished) {
|
||||
tracker.finalizeBaseline();
|
||||
}
|
||||
|
||||
tracker.updateCurrent(amplitudes);
|
||||
return null; // actual detection happens on analyze() call
|
||||
}
|
||||
|
||||
analyze(timestamp) {
|
||||
const results = {};
|
||||
for (const [nodeId, tracker] of this.trackers) {
|
||||
const result = tracker.detectChanges(timestamp);
|
||||
if (result) {
|
||||
result.nodeId = nodeId;
|
||||
results[nodeId] = result;
|
||||
if (result.changeDetected) {
|
||||
this.allEvents.push(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Packet parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseCsiJsonl(record) {
|
||||
if (record.type !== 'raw_csi' || !record.iq_hex) return null;
|
||||
const nSc = record.subcarriers || 64;
|
||||
const bytes = Buffer.from(record.iq_hex, 'hex');
|
||||
const amplitudes = new Float64Array(nSc);
|
||||
|
||||
for (let sc = 0; sc < nSc; sc++) {
|
||||
const offset = 2 + sc * 2;
|
||||
if (offset + 1 >= bytes.length) break;
|
||||
let I = bytes[offset]; if (I > 127) I -= 256;
|
||||
let Q = bytes[offset + 1]; if (Q > 127) Q -= 256;
|
||||
amplitudes[sc] = Math.sqrt(I * I + Q * Q);
|
||||
}
|
||||
|
||||
return { timestamp: record.timestamp, nodeId: record.node_id, amplitudes };
|
||||
}
|
||||
|
||||
function parseCsiUdp(buf) {
|
||||
if (buf.length < HEADER_SIZE) return null;
|
||||
const magic = buf.readUInt32LE(0);
|
||||
if (magic !== CSI_MAGIC) return null;
|
||||
|
||||
const nodeId = buf.readUInt8(4);
|
||||
const nSc = buf.readUInt16LE(6);
|
||||
const amplitudes = new Float64Array(nSc);
|
||||
|
||||
for (let sc = 0; sc < nSc; sc++) {
|
||||
const offset = HEADER_SIZE + sc * 2;
|
||||
if (offset + 1 >= buf.length) break;
|
||||
const I = buf.readInt8(offset);
|
||||
const Q = buf.readInt8(offset + 1);
|
||||
amplitudes[sc] = Math.sqrt(I * I + Q * Q);
|
||||
}
|
||||
|
||||
return { timestamp: Date.now() / 1000, nodeId, amplitudes };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Replay mode
|
||||
// ---------------------------------------------------------------------------
|
||||
async function startReplay(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`File not found: ${filePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const detector = new MaterialDetector();
|
||||
const rl = readline.createInterface({
|
||||
input: fs.createReadStream(filePath),
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
let frameCount = 0;
|
||||
let lastAnalysisTs = 0;
|
||||
let baselineReported = new Set();
|
||||
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) continue;
|
||||
let record;
|
||||
try { record = JSON.parse(line); } catch { continue; }
|
||||
|
||||
const csi = parseCsiJsonl(record);
|
||||
if (!csi) continue;
|
||||
|
||||
detector.ingestCSI(csi.nodeId, csi.timestamp, csi.amplitudes);
|
||||
frameCount++;
|
||||
|
||||
// Report baseline completion
|
||||
for (const [nodeId, tracker] of detector.trackers) {
|
||||
if (tracker.baselineEstablished && !baselineReported.has(nodeId)) {
|
||||
baselineReported.add(nodeId);
|
||||
if (!JSON_OUTPUT) {
|
||||
console.log(`Node ${nodeId}: baseline established (${tracker.baselineNulls.size} nulls, ${((tracker.baselineNulls.size / tracker.nSc) * 100).toFixed(0)}%)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tsMs = csi.timestamp * 1000;
|
||||
if (lastAnalysisTs === 0) lastAnalysisTs = tsMs;
|
||||
|
||||
if (tsMs - lastAnalysisTs >= INTERVAL_MS) {
|
||||
const results = detector.analyze(csi.timestamp);
|
||||
|
||||
for (const [nodeId, result] of Object.entries(results)) {
|
||||
if (JSON_OUTPUT) {
|
||||
console.log(JSON.stringify(result));
|
||||
} else if (result.changeDetected) {
|
||||
const ts = new Date(csi.timestamp * 1000).toISOString().slice(11, 19);
|
||||
console.log(`[${ts}] Node ${nodeId}: ${result.eventType.toUpperCase()} | ${result.material} | nulls ${result.baselineNullCount} -> ${result.currentNullCount} (delta ${result.nullDelta > 0 ? '+' : ''}${result.nullDelta})`);
|
||||
if (result.newNullRange) console.log(` New nulls: sc ${result.newNullRange[0]}-${result.newNullRange[1]} (${result.newNulls} subcarriers)`);
|
||||
if (result.removedNullRange) console.log(` Removed nulls: sc ${result.removedNullRange[0]}-${result.removedNullRange[1]} (${result.removedNulls} subcarriers)`);
|
||||
if (result.ampChanges > 0) console.log(` Amplitude changes: ${result.ampChanges} subcarriers`);
|
||||
}
|
||||
}
|
||||
|
||||
lastAnalysisTs = tsMs;
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
if (!JSON_OUTPUT) {
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('MATERIAL/OBJECT CHANGE DETECTION SUMMARY');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
for (const [nodeId, tracker] of detector.trackers) {
|
||||
console.log(`\nNode ${nodeId}:`);
|
||||
console.log(` Baseline nulls: ${tracker.baselineNulls.size} / ${tracker.nSc} (${((tracker.baselineNulls.size / tracker.nSc) * 100).toFixed(0)}%)`);
|
||||
console.log(` Current map: ${tracker.renderNullMap()}`);
|
||||
console.log(` Legend: _ = baseline null, X = new null, O = removed null, \u2581 = normal`);
|
||||
}
|
||||
|
||||
console.log(`\nTotal change events: ${detector.allEvents.length}`);
|
||||
if (detector.allEvents.length > 0) {
|
||||
const types = {};
|
||||
const materials = {};
|
||||
for (const e of detector.allEvents) {
|
||||
types[e.eventType] = (types[e.eventType] || 0) + 1;
|
||||
materials[e.material] = (materials[e.material] || 0) + 1;
|
||||
}
|
||||
console.log('Event types:');
|
||||
for (const [t, c] of Object.entries(types)) console.log(` ${t}: ${c}`);
|
||||
console.log('Materials:');
|
||||
for (const [m, c] of Object.entries(materials)) console.log(` ${m}: ${c}`);
|
||||
}
|
||||
|
||||
console.log(`\nProcessed ${frameCount} CSI frames`);
|
||||
} else {
|
||||
console.log(JSON.stringify({
|
||||
type: 'summary',
|
||||
events: detector.allEvents.length,
|
||||
frames: frameCount,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live UDP mode
|
||||
// ---------------------------------------------------------------------------
|
||||
function startLive() {
|
||||
const detector = new MaterialDetector();
|
||||
const server = dgram.createSocket('udp4');
|
||||
|
||||
server.on('message', (buf) => {
|
||||
const csi = parseCsiUdp(buf);
|
||||
if (csi) detector.ingestCSI(csi.nodeId, csi.timestamp, csi.amplitudes);
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
const results = detector.analyze(Date.now() / 1000);
|
||||
|
||||
if (JSON_OUTPUT) {
|
||||
for (const result of Object.values(results)) {
|
||||
console.log(JSON.stringify(result));
|
||||
}
|
||||
} else {
|
||||
process.stdout.write('\x1B[2J\x1B[H');
|
||||
console.log('=== MATERIAL/OBJECT DETECTOR (ADR-077) ===\n');
|
||||
|
||||
for (const [nodeId, tracker] of detector.trackers) {
|
||||
if (!tracker.baselineEstablished) {
|
||||
const elapsed = tracker.lastTime ? tracker.lastTime - tracker.startTime : 0;
|
||||
console.log(`Node ${nodeId}: establishing baseline... ${elapsed.toFixed(0)}/${BASELINE_SEC}s`);
|
||||
} else {
|
||||
console.log(`Node ${nodeId}: ${tracker.renderNullMap()}`);
|
||||
console.log(` Baseline: ${tracker.baselineNulls.size} nulls | Current: ${[...Array(tracker.nSc)].filter((_, i) => tracker.currentAmps[i] < NULL_THRESHOLD).length} nulls`);
|
||||
}
|
||||
}
|
||||
|
||||
if (detector.allEvents.length > 0) {
|
||||
console.log('\nRecent events:');
|
||||
for (const e of detector.allEvents.slice(-5)) {
|
||||
const ts = new Date(e.timestamp * 1000).toISOString().slice(11, 19);
|
||||
console.log(` [${ts}] ${e.eventType} | ${e.material} | delta ${e.nullDelta}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nTotal events: ${detector.allEvents.length}`);
|
||||
}
|
||||
}, INTERVAL_MS);
|
||||
|
||||
server.bind(PORT, () => {
|
||||
if (!JSON_OUTPUT) {
|
||||
console.log(`Material Detector listening on UDP :${PORT} (baseline: ${BASELINE_SEC}s)`);
|
||||
}
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => { server.close(); process.exit(0); });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry
|
||||
// ---------------------------------------------------------------------------
|
||||
if (args.replay) {
|
||||
startReplay(args.replay);
|
||||
} else {
|
||||
startLive();
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Passive Bistatic Radar — Multi-Frequency Mesh Application
|
||||
*
|
||||
* Uses neighbor WiFi APs as illuminators of opportunity to build range-Doppler
|
||||
* maps for moving target detection. Each neighbor AP is an uncontrolled
|
||||
* transmitter whose signals pass through the room and are modulated by people
|
||||
* and objects. The ESP32 nodes capture CSI from these transmissions across
|
||||
* 6 channels.
|
||||
*
|
||||
* This is the same principle used by military passive radar (Kolchuga, VERA-NG)
|
||||
* but with WiFi APs instead of broadcast towers.
|
||||
*
|
||||
* Requires multi-frequency mesh scanning (ADR-073): 2 ESP32 nodes hopping
|
||||
* across channels 1, 3, 5, 6, 9, 11.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/passive-radar.js
|
||||
* node scripts/passive-radar.js --port 5006 --duration 60
|
||||
* node scripts/passive-radar.js --replay data/recordings/overnight-1775217646.csi.jsonl
|
||||
* node scripts/passive-radar.js --node-distance 3.0
|
||||
*
|
||||
* ADR: docs/adr/ADR-078-multifreq-mesh-applications.md
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const dgram = require('dgram');
|
||||
const fs = require('fs');
|
||||
const readline = require('readline');
|
||||
const { parseArgs } = require('util');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
port: { type: 'string', short: 'p', default: '5006' },
|
||||
duration: { type: 'string', short: 'd' },
|
||||
replay: { type: 'string', short: 'r' },
|
||||
interval: { type: 'string', short: 'i', default: '3000' },
|
||||
json: { type: 'boolean', default: false },
|
||||
'node-distance': { type: 'string', default: '3.0' },
|
||||
'doppler-bins': { type: 'string', default: '16' },
|
||||
'range-bins': { type: 'string', default: '12' },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
const PORT = parseInt(args.port, 10);
|
||||
const DURATION_MS = args.duration ? parseInt(args.duration, 10) * 1000 : null;
|
||||
const INTERVAL_MS = parseInt(args.interval, 10);
|
||||
const JSON_OUTPUT = args.json;
|
||||
const NODE_DISTANCE = parseFloat(args['node-distance']);
|
||||
const DOPPLER_BINS = parseInt(args['doppler-bins'], 10);
|
||||
const RANGE_BINS = parseInt(args['range-bins'], 10);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const CSI_MAGIC = 0xC5110001;
|
||||
const HEADER_SIZE = 20;
|
||||
const SPEED_OF_LIGHT = 3e8; // m/s
|
||||
|
||||
const CHANNEL_FREQ = {};
|
||||
for (let ch = 1; ch <= 13; ch++) CHANNEL_FREQ[ch] = 2412 + (ch - 1) * 5;
|
||||
|
||||
const NODE1_CHANNELS = [1, 6, 11];
|
||||
const NODE2_CHANNELS = [3, 5, 9];
|
||||
|
||||
// Neighbor APs as illuminators with estimated positions
|
||||
const ILLUMINATORS = [
|
||||
{ ssid: 'ruv.net', channel: 5, signal: 100, pos: [1.5, 3.5], freq: 2432e6 },
|
||||
{ ssid: 'Cohen-Guest', channel: 5, signal: 100, pos: [2.0, 3.8], freq: 2432e6 },
|
||||
{ ssid: 'COGECO-21B20', channel: 11, signal: 100, pos: [4.0, 2.0], freq: 2462e6 },
|
||||
{ ssid: 'HP M255', channel: 5, signal: 94, pos: [0.5, 1.5], freq: 2432e6 },
|
||||
{ ssid: 'conclusion', channel: 3, signal: 44, pos: [3.5, 3.0], freq: 2422e6 },
|
||||
{ ssid: 'NETGEAR72', channel: 9, signal: 42, pos: [4.5, 1.0], freq: 2452e6 },
|
||||
{ ssid: 'COGECO-4321', channel: 11, signal: 30, pos: [4.0, 3.5], freq: 2462e6 },
|
||||
{ ssid: 'Innanen', channel: 6, signal: 19, pos: [1.0, 4.0], freq: 2437e6 },
|
||||
];
|
||||
|
||||
const NODE_POS = {
|
||||
1: [0, 2.0],
|
||||
2: [NODE_DISTANCE, 2.0],
|
||||
};
|
||||
|
||||
// Range-Doppler plot characters
|
||||
const RD_CHARS = [' ', '\u2581', '\u2582', '\u2583', '\u2584', '\u2585', '\u2586', '\u2587', '\u2588'];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-illuminator CSI history for Doppler processing
|
||||
// ---------------------------------------------------------------------------
|
||||
class IlluminatorTracker {
|
||||
constructor(illuminator, nodeId) {
|
||||
this.illuminator = illuminator;
|
||||
this.nodeId = nodeId;
|
||||
this.ssid = illuminator.ssid;
|
||||
this.channel = illuminator.channel;
|
||||
this.freqHz = illuminator.freq;
|
||||
this.wavelength = SPEED_OF_LIGHT / this.freqHz;
|
||||
|
||||
// Phase history per subcarrier (ring buffer)
|
||||
this.maxHistory = 64;
|
||||
this.phaseHistory = []; // array of { timestamp, phases: Float64Array }
|
||||
this.amplitudeHistory = [];
|
||||
|
||||
// Range-Doppler map
|
||||
this.rangeDoppler = null;
|
||||
this.lastUpdateMs = 0;
|
||||
}
|
||||
|
||||
/** Ingest a new CSI frame */
|
||||
ingest(timestamp, amplitudes, phases) {
|
||||
this.phaseHistory.push({ timestamp, phases: new Float64Array(phases) });
|
||||
this.amplitudeHistory.push({ timestamp, amplitudes: new Float64Array(amplitudes) });
|
||||
|
||||
if (this.phaseHistory.length > this.maxHistory) {
|
||||
this.phaseHistory.shift();
|
||||
this.amplitudeHistory.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute range-Doppler map from CSI phase history.
|
||||
*
|
||||
* Doppler: phase change rate across consecutive frames for each subcarrier.
|
||||
* fd = d(phase)/dt / (2*pi) -> velocity = fd * wavelength / 2
|
||||
*
|
||||
* Range: phase slope across subcarriers within each frame.
|
||||
* tau = d(phase)/d(subcarrier_freq) / (2*pi) -> range = c * tau
|
||||
*/
|
||||
computeRangeDoppler(dopplerBins, rangeBins) {
|
||||
const n = this.phaseHistory.length;
|
||||
if (n < 4) return null;
|
||||
|
||||
const nSub = this.phaseHistory[0].phases.length;
|
||||
if (nSub < 4) return null;
|
||||
|
||||
// Initialize range-Doppler map
|
||||
const rd = new Float64Array(rangeBins * dopplerBins);
|
||||
|
||||
// Doppler processing: compute phase change rate per subcarrier
|
||||
const dopplerPerSub = new Float64Array(nSub);
|
||||
const rangePerFrame = new Float64Array(n);
|
||||
|
||||
for (let sc = 0; sc < nSub; sc++) {
|
||||
// Linear regression of phase vs time for this subcarrier
|
||||
let sumT = 0, sumP = 0, sumTT = 0, sumTP = 0;
|
||||
let prevPhase = this.phaseHistory[0].phases[sc];
|
||||
|
||||
for (let f = 0; f < n; f++) {
|
||||
const t = this.phaseHistory[f].timestamp;
|
||||
// Unwrap phase
|
||||
let phase = this.phaseHistory[f].phases[sc];
|
||||
while (phase - prevPhase > Math.PI) phase -= 2 * Math.PI;
|
||||
while (phase - prevPhase < -Math.PI) phase += 2 * Math.PI;
|
||||
prevPhase = phase;
|
||||
|
||||
sumT += t;
|
||||
sumP += phase;
|
||||
sumTT += t * t;
|
||||
sumTP += t * phase;
|
||||
}
|
||||
|
||||
const meanT = sumT / n;
|
||||
const denom = sumTT - sumT * meanT;
|
||||
if (Math.abs(denom) > 1e-10) {
|
||||
const slope = (sumTP - sumT * (sumP / n)) / denom;
|
||||
// Doppler frequency (Hz) = slope / (2*pi)
|
||||
dopplerPerSub[sc] = slope / (2 * Math.PI);
|
||||
}
|
||||
}
|
||||
|
||||
// Range processing: phase slope across subcarriers per frame
|
||||
const subcarrierSpacing = 312.5e3; // OFDM subcarrier spacing: 312.5 kHz
|
||||
|
||||
for (let f = 0; f < n; f++) {
|
||||
const phases = this.phaseHistory[f].phases;
|
||||
// Linear regression of phase vs subcarrier index
|
||||
let sumI = 0, sumP = 0, sumII = 0, sumIP = 0;
|
||||
let prevPhase = phases[0];
|
||||
|
||||
for (let sc = 0; sc < nSub; sc++) {
|
||||
let phase = phases[sc];
|
||||
// Unwrap
|
||||
while (phase - prevPhase > Math.PI) phase -= 2 * Math.PI;
|
||||
while (phase - prevPhase < -Math.PI) phase += 2 * Math.PI;
|
||||
prevPhase = phase;
|
||||
|
||||
sumI += sc;
|
||||
sumP += phase;
|
||||
sumII += sc * sc;
|
||||
sumIP += sc * phase;
|
||||
}
|
||||
|
||||
const meanI = sumI / nSub;
|
||||
const denom = sumII - sumI * meanI;
|
||||
if (Math.abs(denom) > 1e-10) {
|
||||
const slope = (sumIP - sumI * (sumP / nSub)) / denom;
|
||||
// Time delay (seconds) = slope / (2*pi * subcarrier_spacing)
|
||||
const tau = Math.abs(slope) / (2 * Math.PI * subcarrierSpacing);
|
||||
rangePerFrame[f] = SPEED_OF_LIGHT * tau / 2; // bistatic range / 2
|
||||
}
|
||||
}
|
||||
|
||||
// Map to bins
|
||||
const maxDoppler = 5.0; // Hz (corresponds to ~0.3 m/s at 2.4 GHz)
|
||||
const maxRange = 10.0; // meters
|
||||
|
||||
for (let sc = 0; sc < nSub; sc++) {
|
||||
const doppler = dopplerPerSub[sc];
|
||||
const dBin = Math.floor(((doppler + maxDoppler) / (2 * maxDoppler)) * (dopplerBins - 1));
|
||||
if (dBin < 0 || dBin >= dopplerBins) continue;
|
||||
|
||||
// Use mean amplitude as intensity
|
||||
let meanAmp = 0;
|
||||
for (let f = 0; f < n; f++) {
|
||||
meanAmp += this.amplitudeHistory[f].amplitudes[sc];
|
||||
}
|
||||
meanAmp /= n;
|
||||
|
||||
// Average range across frames for this subcarrier's range bin
|
||||
let meanRange = 0;
|
||||
for (let f = 0; f < n; f++) meanRange += rangePerFrame[f];
|
||||
meanRange /= n;
|
||||
|
||||
const rBin = Math.floor((meanRange / maxRange) * (rangeBins - 1));
|
||||
if (rBin < 0 || rBin >= rangeBins) continue;
|
||||
|
||||
rd[rBin * dopplerBins + dBin] += meanAmp;
|
||||
}
|
||||
|
||||
this.rangeDoppler = {
|
||||
map: rd,
|
||||
dopplerBins,
|
||||
rangeBins,
|
||||
maxDoppler,
|
||||
maxRange,
|
||||
nFrames: n,
|
||||
};
|
||||
|
||||
return this.rangeDoppler;
|
||||
}
|
||||
|
||||
/** Get dominant Doppler (strongest moving target) */
|
||||
getDominantDoppler() {
|
||||
if (!this.rangeDoppler) return null;
|
||||
const { map, dopplerBins, rangeBins, maxDoppler } = this.rangeDoppler;
|
||||
|
||||
let maxVal = 0, maxD = 0, maxR = 0;
|
||||
for (let r = 0; r < rangeBins; r++) {
|
||||
for (let d = 0; d < dopplerBins; d++) {
|
||||
const val = map[r * dopplerBins + d];
|
||||
if (val > maxVal) {
|
||||
maxVal = val;
|
||||
maxD = d;
|
||||
maxR = r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (maxVal < 0.01) return null;
|
||||
|
||||
const doppler = (maxD / (dopplerBins - 1)) * 2 * maxDoppler - maxDoppler;
|
||||
const velocity = doppler * this.wavelength / 2;
|
||||
const range = (maxR / (rangeBins - 1)) * this.rangeDoppler.maxRange;
|
||||
|
||||
return { doppler: doppler.toFixed(2), velocity: velocity.toFixed(3), range: range.toFixed(1), intensity: maxVal.toFixed(1) };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-static fusion
|
||||
// ---------------------------------------------------------------------------
|
||||
class MultiStaticFusion {
|
||||
constructor() {
|
||||
this.trackers = new Map(); // key: `${ssid}-node${nodeId}` -> IlluminatorTracker
|
||||
}
|
||||
|
||||
getOrCreateTracker(illuminator, nodeId) {
|
||||
const key = `${illuminator.ssid}-node${nodeId}`;
|
||||
if (!this.trackers.has(key)) {
|
||||
this.trackers.set(key, new IlluminatorTracker(illuminator, nodeId));
|
||||
}
|
||||
return this.trackers.get(key);
|
||||
}
|
||||
|
||||
ingestFrame(nodeId, channel, timestamp, amplitudes, phases) {
|
||||
// Find illuminators on this channel
|
||||
for (const il of ILLUMINATORS) {
|
||||
if (il.channel === channel) {
|
||||
const tracker = this.getOrCreateTracker(il, nodeId);
|
||||
tracker.ingest(timestamp, amplitudes, phases);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute all range-Doppler maps */
|
||||
computeAll(dopplerBins, rangeBins) {
|
||||
const results = [];
|
||||
for (const [key, tracker] of this.trackers) {
|
||||
const rd = tracker.computeRangeDoppler(dopplerBins, rangeBins);
|
||||
if (rd) {
|
||||
results.push({ key, tracker, rd });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuse multi-static detections.
|
||||
* Each illuminator provides a range measurement to the target.
|
||||
* The target lies on an ellipse with foci at TX (illuminator) and RX (ESP32 node).
|
||||
* Intersection of multiple ellipses gives position.
|
||||
*/
|
||||
fuseDetections() {
|
||||
const detections = [];
|
||||
for (const [key, tracker] of this.trackers) {
|
||||
const dom = tracker.getDominantDoppler();
|
||||
if (dom && parseFloat(dom.intensity) > 1.0) {
|
||||
detections.push({
|
||||
key,
|
||||
ssid: tracker.ssid,
|
||||
channel: tracker.channel,
|
||||
nodeId: tracker.nodeId,
|
||||
txPos: tracker.illuminator.pos,
|
||||
rxPos: NODE_POS[tracker.nodeId],
|
||||
bistaticRange: parseFloat(dom.range),
|
||||
velocity: parseFloat(dom.velocity),
|
||||
intensity: parseFloat(dom.intensity),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (detections.length < 2) {
|
||||
return { detections, fusedPosition: null };
|
||||
}
|
||||
|
||||
// Simple centroid-based fusion:
|
||||
// For each detection, compute the midpoint of the TX-RX baseline
|
||||
// weighted by intensity. This is a rough approximation.
|
||||
// (Full ellipse intersection requires nonlinear optimization.)
|
||||
let sumX = 0, sumY = 0, sumW = 0;
|
||||
for (const det of detections) {
|
||||
// Midpoint between TX and RX, offset by bistatic range
|
||||
const mx = (det.txPos[0] + det.rxPos[0]) / 2;
|
||||
const my = (det.txPos[1] + det.rxPos[1]) / 2;
|
||||
const w = det.intensity;
|
||||
sumX += mx * w;
|
||||
sumY += my * w;
|
||||
sumW += w;
|
||||
}
|
||||
|
||||
const fusedPosition = sumW > 0
|
||||
? { x: (sumX / sumW).toFixed(2), y: (sumY / sumW).toFixed(2), confidence: Math.min(1, detections.length / 4).toFixed(2) }
|
||||
: null;
|
||||
|
||||
return { detections, fusedPosition };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSI parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseIqHex(iqHex, nSubcarriers) {
|
||||
const bytes = Buffer.from(iqHex, 'hex');
|
||||
const amplitudes = new Float64Array(nSubcarriers);
|
||||
const phases = new Float64Array(nSubcarriers);
|
||||
|
||||
for (let sc = 0; sc < nSubcarriers; sc++) {
|
||||
const offset = 2 + sc * 2;
|
||||
if (offset + 1 >= bytes.length) break;
|
||||
let I = bytes[offset];
|
||||
let Q = bytes[offset + 1];
|
||||
if (I > 127) I -= 256;
|
||||
if (Q > 127) Q -= 256;
|
||||
amplitudes[sc] = Math.sqrt(I * I + Q * Q);
|
||||
phases[sc] = Math.atan2(Q, I);
|
||||
}
|
||||
|
||||
return { amplitudes, phases };
|
||||
}
|
||||
|
||||
function parseCSIFrame(buf) {
|
||||
if (buf.length < HEADER_SIZE) return null;
|
||||
const magic = buf.readUInt32LE(0);
|
||||
if (magic !== CSI_MAGIC) return null;
|
||||
|
||||
const nodeId = buf.readUInt8(4);
|
||||
const nSubcarriers = buf.readUInt16LE(6);
|
||||
const freqMhz = buf.readUInt32LE(8);
|
||||
const rssi = buf.readInt8(16);
|
||||
|
||||
const amplitudes = new Float64Array(nSubcarriers);
|
||||
const phases = new Float64Array(nSubcarriers);
|
||||
|
||||
for (let sc = 0; sc < nSubcarriers; sc++) {
|
||||
const offset = HEADER_SIZE + sc * 2;
|
||||
if (offset + 1 >= buf.length) break;
|
||||
const I = buf.readInt8(offset);
|
||||
const Q = buf.readInt8(offset + 1);
|
||||
amplitudes[sc] = Math.sqrt(I * I + Q * Q);
|
||||
phases[sc] = Math.atan2(Q, I);
|
||||
}
|
||||
|
||||
let channel = 0;
|
||||
if (freqMhz >= 2412 && freqMhz <= 2484) {
|
||||
channel = freqMhz === 2484 ? 14 : Math.round((freqMhz - 2412) / 5) + 1;
|
||||
}
|
||||
|
||||
return { nodeId, nSubcarriers, freqMhz, rssi, amplitudes, phases, channel };
|
||||
}
|
||||
|
||||
// Channel assignment for legacy JSONL
|
||||
const nodeChannelIdx = { 1: 0, 2: 0 };
|
||||
function assignChannel(nodeId) {
|
||||
const channels = nodeId === 1 ? NODE1_CHANNELS : NODE2_CHANNELS;
|
||||
const ch = channels[nodeChannelIdx[nodeId] % channels.length];
|
||||
nodeChannelIdx[nodeId]++;
|
||||
return ch;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Visualization
|
||||
// ---------------------------------------------------------------------------
|
||||
function renderRangeDoppler(tracker) {
|
||||
const rd = tracker.rangeDoppler;
|
||||
if (!rd) return ` ${tracker.ssid} (ch${tracker.channel}): insufficient data`;
|
||||
|
||||
const { map, dopplerBins, rangeBins, maxDoppler, maxRange, nFrames } = rd;
|
||||
const lines = [];
|
||||
|
||||
lines.push(` ${tracker.ssid} (ch${tracker.channel}, node${tracker.nodeId}) | ${nFrames} frames`);
|
||||
|
||||
// Find max for normalization
|
||||
let maxVal = 0;
|
||||
for (let i = 0; i < map.length; i++) {
|
||||
if (map[i] > maxVal) maxVal = map[i];
|
||||
}
|
||||
if (maxVal === 0) maxVal = 1;
|
||||
|
||||
// Render range (y-axis) vs Doppler (x-axis)
|
||||
for (let r = rangeBins - 1; r >= 0; r--) {
|
||||
const range = (r / (rangeBins - 1)) * maxRange;
|
||||
let row = ` ${range.toFixed(1).padStart(5)}m |`;
|
||||
for (let d = 0; d < dopplerBins; d++) {
|
||||
const val = map[r * dopplerBins + d] / maxVal;
|
||||
const level = Math.floor(val * 8.99);
|
||||
row += RD_CHARS[Math.max(0, Math.min(8, level))];
|
||||
}
|
||||
row += '|';
|
||||
lines.push(row);
|
||||
}
|
||||
|
||||
// X-axis (Doppler)
|
||||
lines.push(' ' + ' '.repeat(7) + '+' + '-'.repeat(dopplerBins) + '+');
|
||||
const dLabel = ` ${' '.repeat(7)}-${maxDoppler}Hz${' '.repeat(Math.max(0, dopplerBins - 10))}+${maxDoppler}Hz`;
|
||||
lines.push(dLabel);
|
||||
|
||||
// Dominant detection
|
||||
const dom = tracker.getDominantDoppler();
|
||||
if (dom) {
|
||||
lines.push(` Peak: range=${dom.range}m doppler=${dom.doppler}Hz vel=${dom.velocity}m/s`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderFusion(fusion) {
|
||||
const { detections, fusedPosition } = fusion;
|
||||
const lines = [];
|
||||
|
||||
lines.push('');
|
||||
lines.push(' MULTI-STATIC FUSION');
|
||||
lines.push(' ' + '='.repeat(50));
|
||||
|
||||
if (detections.length === 0) {
|
||||
lines.push(' No detections above threshold');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
lines.push(` Active bistatic pairs: ${detections.length}`);
|
||||
for (const det of detections) {
|
||||
lines.push(` ${det.ssid.padEnd(16)} ch${det.channel} -> node${det.nodeId} | ` +
|
||||
`range=${det.bistaticRange.toFixed(1)}m vel=${det.velocity.toFixed(3)}m/s`);
|
||||
}
|
||||
|
||||
if (fusedPosition) {
|
||||
lines.push(` Fused position: (${fusedPosition.x}, ${fusedPosition.y}) m confidence=${fusedPosition.confidence}`);
|
||||
} else {
|
||||
lines.push(' Insufficient detections for position fusion (need 2+)');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global state
|
||||
// ---------------------------------------------------------------------------
|
||||
const multiStatic = new MultiStaticFusion();
|
||||
let lastDisplayMs = 0;
|
||||
|
||||
function processFrame(nodeId, channel, timestamp, amplitudes, phases) {
|
||||
multiStatic.ingestFrame(nodeId, channel, timestamp, amplitudes, phases);
|
||||
}
|
||||
|
||||
function displayUpdate() {
|
||||
const results = multiStatic.computeAll(DOPPLER_BINS, RANGE_BINS);
|
||||
const fusion = multiStatic.fuseDetections();
|
||||
|
||||
if (JSON_OUTPUT) {
|
||||
const output = {
|
||||
timestamp: Date.now() / 1000,
|
||||
bistaticPairs: results.length,
|
||||
detections: fusion.detections.map(d => ({
|
||||
ssid: d.ssid, channel: d.channel, nodeId: d.nodeId,
|
||||
bistaticRange: d.bistaticRange, velocity: d.velocity,
|
||||
})),
|
||||
fusedPosition: fusion.fusedPosition,
|
||||
};
|
||||
console.log(JSON.stringify(output));
|
||||
} else {
|
||||
process.stdout.write('\x1B[2J\x1B[H');
|
||||
console.log(' PASSIVE BISTATIC RADAR');
|
||||
console.log(' Using neighbor WiFi APs as illuminators of opportunity');
|
||||
console.log(' ' + '-'.repeat(55));
|
||||
console.log('');
|
||||
|
||||
// Show top 3 trackers by signal strength
|
||||
const sorted = results.sort((a, b) => b.tracker.illuminator.signal - a.tracker.illuminator.signal);
|
||||
for (const r of sorted.slice(0, 3)) {
|
||||
console.log(renderRangeDoppler(r.tracker));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log(renderFusion(fusion));
|
||||
console.log('');
|
||||
console.log(` Total bistatic pairs: ${multiStatic.trackers.size}`);
|
||||
console.log(' Press Ctrl+C to exit');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live mode
|
||||
// ---------------------------------------------------------------------------
|
||||
function startLive() {
|
||||
const sock = dgram.createSocket('udp4');
|
||||
|
||||
sock.on('message', (buf, rinfo) => {
|
||||
if (buf.length < 4) return;
|
||||
const magic = buf.readUInt32LE(0);
|
||||
if (magic !== CSI_MAGIC) return;
|
||||
|
||||
const frame = parseCSIFrame(buf);
|
||||
if (!frame) return;
|
||||
|
||||
processFrame(frame.nodeId, frame.channel, Date.now() / 1000, frame.amplitudes, frame.phases);
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastDisplayMs >= INTERVAL_MS) {
|
||||
displayUpdate();
|
||||
lastDisplayMs = now;
|
||||
}
|
||||
});
|
||||
|
||||
sock.bind(PORT, () => {
|
||||
if (!JSON_OUTPUT) {
|
||||
console.log(`Passive Bistatic Radar listening on UDP port ${PORT}`);
|
||||
console.log(`Illuminators: ${ILLUMINATORS.length} neighbor APs`);
|
||||
console.log(`Node distance: ${NODE_DISTANCE} m`);
|
||||
console.log('Waiting for CSI frames...');
|
||||
}
|
||||
});
|
||||
|
||||
if (DURATION_MS) {
|
||||
setTimeout(() => {
|
||||
displayUpdate();
|
||||
sock.close();
|
||||
process.exit(0);
|
||||
}, DURATION_MS);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Replay mode
|
||||
// ---------------------------------------------------------------------------
|
||||
async function startReplay(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`File not found: ${filePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: fs.createReadStream(filePath),
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
let frameCount = 0;
|
||||
let lastAnalysisTs = 0;
|
||||
let windowCount = 0;
|
||||
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
let record;
|
||||
try { record = JSON.parse(line); } catch { continue; }
|
||||
if (record.type !== 'raw_csi' || !record.iq_hex) continue;
|
||||
|
||||
const { amplitudes, phases } = parseIqHex(record.iq_hex, record.subcarriers || 64);
|
||||
const channel = record.channel || assignChannel(record.node_id);
|
||||
|
||||
processFrame(record.node_id, channel, record.timestamp, amplitudes, phases);
|
||||
frameCount++;
|
||||
|
||||
const tsMs = record.timestamp * 1000;
|
||||
if (lastAnalysisTs === 0) lastAnalysisTs = tsMs;
|
||||
|
||||
if (tsMs - lastAnalysisTs >= INTERVAL_MS) {
|
||||
windowCount++;
|
||||
const results = multiStatic.computeAll(DOPPLER_BINS, RANGE_BINS);
|
||||
const fusion = multiStatic.fuseDetections();
|
||||
|
||||
if (JSON_OUTPUT) {
|
||||
console.log(JSON.stringify({
|
||||
window: windowCount,
|
||||
timestamp: record.timestamp,
|
||||
frames: frameCount,
|
||||
detections: fusion.detections.length,
|
||||
fusedPosition: fusion.fusedPosition,
|
||||
}));
|
||||
} else {
|
||||
console.log(`\n${'='.repeat(60)}`);
|
||||
console.log(`Window ${windowCount} | t=${record.timestamp.toFixed(1)}s | frames=${frameCount}`);
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const sorted = results.sort((a, b) => b.tracker.illuminator.signal - a.tracker.illuminator.signal);
|
||||
for (const r of sorted.slice(0, 3)) {
|
||||
console.log(renderRangeDoppler(r.tracker));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log(renderFusion(fusion));
|
||||
}
|
||||
lastAnalysisTs = tsMs;
|
||||
}
|
||||
}
|
||||
|
||||
// Final
|
||||
if (!JSON_OUTPUT) {
|
||||
const results = multiStatic.computeAll(DOPPLER_BINS, RANGE_BINS);
|
||||
const fusion = multiStatic.fuseDetections();
|
||||
|
||||
console.log(`\n${'='.repeat(60)}`);
|
||||
console.log('FINAL PASSIVE RADAR SUMMARY');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
for (const [key, tracker] of multiStatic.trackers) {
|
||||
const dom = tracker.getDominantDoppler();
|
||||
const domStr = dom ? `range=${dom.range}m vel=${dom.velocity}m/s` : 'no detection';
|
||||
console.log(` ${key.padEnd(30)} ${domStr}`);
|
||||
}
|
||||
|
||||
console.log(renderFusion(fusion));
|
||||
console.log(`\nProcessed ${frameCount} frames in ${windowCount} windows`);
|
||||
console.log(`Bistatic pairs tracked: ${multiStatic.trackers.size}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
if (args.replay) {
|
||||
startReplay(args.replay);
|
||||
} else {
|
||||
startLive();
|
||||
}
|
||||
+4
-1
@@ -25,6 +25,7 @@ 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' },
|
||||
@@ -573,7 +574,9 @@ function main() {
|
||||
}
|
||||
});
|
||||
|
||||
server.bind(PORT);
|
||||
// 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);
|
||||
|
||||
// Periodic display update
|
||||
const displayTimer = setInterval(display, INTERVAL_MS);
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* RF Tomographic Imaging — Multi-Frequency Mesh Application
|
||||
*
|
||||
* Back-projects CSI attenuation along each TX->RX path across 6 WiFi channels
|
||||
* to build a 2D heatmap of RF absorption in the room. Areas with high absorption
|
||||
* correspond to people, furniture, or walls.
|
||||
*
|
||||
* Requires multi-frequency mesh scanning (ADR-073): 2 ESP32 nodes hopping
|
||||
* across channels 1, 3, 5, 6, 9, 11.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/rf-tomography.js
|
||||
* node scripts/rf-tomography.js --port 5006 --duration 60
|
||||
* node scripts/rf-tomography.js --replay data/recordings/overnight-1775217646.csi.jsonl
|
||||
* node scripts/rf-tomography.js --grid 15 --node-distance 4.0
|
||||
*
|
||||
* ADR: docs/adr/ADR-078-multifreq-mesh-applications.md
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const dgram = require('dgram');
|
||||
const fs = require('fs');
|
||||
const readline = require('readline');
|
||||
const { parseArgs } = require('util');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
port: { type: 'string', short: 'p', default: '5006' },
|
||||
duration: { type: 'string', short: 'd' },
|
||||
replay: { type: 'string', short: 'r' },
|
||||
interval: { type: 'string', short: 'i', default: '2000' },
|
||||
grid: { type: 'string', short: 'g', default: '10' },
|
||||
json: { type: 'boolean', default: false },
|
||||
'node-distance': { type: 'string', default: '3.0' },
|
||||
'room-width': { type: 'string', default: '5.0' },
|
||||
'room-height': { type: 'string', default: '4.0' },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
const PORT = parseInt(args.port, 10);
|
||||
const DURATION_MS = args.duration ? parseInt(args.duration, 10) * 1000 : null;
|
||||
const INTERVAL_MS = parseInt(args.interval, 10);
|
||||
const GRID_SIZE = parseInt(args.grid, 10);
|
||||
const JSON_OUTPUT = args.json;
|
||||
const NODE_DISTANCE = parseFloat(args['node-distance']);
|
||||
const ROOM_WIDTH = parseFloat(args['room-width']);
|
||||
const ROOM_HEIGHT = parseFloat(args['room-height']);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const CSI_MAGIC = 0xC5110001;
|
||||
const HEADER_SIZE = 20;
|
||||
|
||||
const CHANNEL_FREQ = {};
|
||||
for (let ch = 1; ch <= 13; ch++) CHANNEL_FREQ[ch] = 2412 + (ch - 1) * 5;
|
||||
CHANNEL_FREQ[14] = 2484;
|
||||
|
||||
const NODE1_CHANNELS = [1, 6, 11];
|
||||
const NODE2_CHANNELS = [3, 5, 9];
|
||||
|
||||
// Known neighbor APs as additional illuminators (TX positions estimated)
|
||||
const ILLUMINATORS = [
|
||||
{ ssid: 'ruv.net', channel: 5, signal: 100, pos: [1.5, 3.5] },
|
||||
{ ssid: 'Cohen-Guest', channel: 5, signal: 100, pos: [2.0, 3.8] },
|
||||
{ ssid: 'COGECO-21B20', channel: 11, signal: 100, pos: [4.0, 2.0] },
|
||||
{ ssid: 'HP M255', channel: 5, signal: 94, pos: [0.5, 1.5] },
|
||||
{ ssid: 'conclusion', channel: 3, signal: 44, pos: [3.5, 3.0] },
|
||||
{ ssid: 'NETGEAR72', channel: 9, signal: 42, pos: [4.5, 1.0] },
|
||||
{ ssid: 'COGECO-4321', channel: 11, signal: 30, pos: [4.0, 3.5] },
|
||||
{ ssid: 'Innanen', channel: 6, signal: 19, pos: [1.0, 4.0] },
|
||||
];
|
||||
|
||||
// Node positions (meters)
|
||||
const NODE_POS = {
|
||||
1: [0, ROOM_HEIGHT / 2],
|
||||
2: [NODE_DISTANCE, ROOM_HEIGHT / 2],
|
||||
};
|
||||
|
||||
// Heatmap characters (8 levels: transparent -> opaque)
|
||||
const HEAT = [' ', '\u2591', '\u2591', '\u2592', '\u2592', '\u2593', '\u2593', '\u2588'];
|
||||
const HEAT_LABELS = ['air', 'low', 'low', 'med', 'med', 'high', 'high', 'solid'];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tomographic grid
|
||||
// ---------------------------------------------------------------------------
|
||||
class TomographyGrid {
|
||||
constructor(gridSize, roomWidth, roomHeight) {
|
||||
this.gridSize = gridSize;
|
||||
this.roomWidth = roomWidth;
|
||||
this.roomHeight = roomHeight;
|
||||
this.cellWidth = roomWidth / gridSize;
|
||||
this.cellHeight = roomHeight / gridSize;
|
||||
|
||||
// Accumulated attenuation per cell
|
||||
this.attenuation = new Float64Array(gridSize * gridSize);
|
||||
// Number of paths passing through each cell (for normalization)
|
||||
this.pathCount = new Float64Array(gridSize * gridSize);
|
||||
// Per-channel attenuation (for frequency analysis)
|
||||
this.channelAttenuation = new Map(); // channel -> Float64Array
|
||||
|
||||
this.frameCount = 0;
|
||||
this.channelFrames = new Map();
|
||||
}
|
||||
|
||||
/** Get center position of grid cell (row, col) in meters */
|
||||
cellCenter(row, col) {
|
||||
return [
|
||||
(col + 0.5) * this.cellWidth,
|
||||
(row + 0.5) * this.cellHeight,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Perpendicular distance from point P to line segment AB.
|
||||
* Returns minimum distance to the infinite line through A and B.
|
||||
*/
|
||||
pointToLineDistance(px, py, ax, ay, bx, by) {
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const len = Math.sqrt(dx * dx + dy * dy);
|
||||
if (len < 1e-6) return Math.sqrt((px - ax) ** 2 + (py - ay) ** 2);
|
||||
// Signed distance using cross product
|
||||
return Math.abs((dy * px - dx * py + bx * ay - by * ax)) / len;
|
||||
}
|
||||
|
||||
/**
|
||||
* Back-project attenuation along a TX->RX path.
|
||||
* Each cell near the path receives a weighted contribution.
|
||||
*
|
||||
* @param {number[]} txPos - Transmitter position [x, y]
|
||||
* @param {number[]} rxPos - Receiver position [x, y]
|
||||
* @param {number} atten - Measured attenuation (dB or normalized)
|
||||
* @param {number} channel - WiFi channel number
|
||||
*/
|
||||
backProject(txPos, rxPos, atten, channel) {
|
||||
const [ax, ay] = txPos;
|
||||
const [bx, by] = rxPos;
|
||||
const pathLen = Math.sqrt((bx - ax) ** 2 + (by - ay) ** 2);
|
||||
if (pathLen < 0.01) return;
|
||||
|
||||
// Kernel width: how far from the path the contribution extends
|
||||
// Approximately lambda/2 at 2.4 GHz = ~6 cm, but we use wider for stability
|
||||
const kernelWidth = Math.max(this.cellWidth, this.cellHeight) * 1.5;
|
||||
|
||||
if (!this.channelAttenuation.has(channel)) {
|
||||
this.channelAttenuation.set(channel, new Float64Array(this.gridSize * this.gridSize));
|
||||
}
|
||||
const chAtten = this.channelAttenuation.get(channel);
|
||||
|
||||
for (let r = 0; r < this.gridSize; r++) {
|
||||
for (let c = 0; c < this.gridSize; c++) {
|
||||
const [cx, cy] = this.cellCenter(r, c);
|
||||
const dist = this.pointToLineDistance(cx, cy, ax, ay, bx, by);
|
||||
|
||||
if (dist < kernelWidth) {
|
||||
// Weight by proximity to path (Gaussian-like)
|
||||
const weight = Math.exp(-0.5 * (dist / (kernelWidth * 0.4)) ** 2);
|
||||
const idx = r * this.gridSize + c;
|
||||
this.attenuation[idx] += atten * weight;
|
||||
this.pathCount[idx] += weight;
|
||||
chAtten[idx] += atten * weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.frameCount++;
|
||||
this.channelFrames.set(channel, (this.channelFrames.get(channel) || 0) + 1);
|
||||
}
|
||||
|
||||
/** Get normalized attenuation image */
|
||||
getImage() {
|
||||
const img = new Float64Array(this.gridSize * this.gridSize);
|
||||
let maxVal = 0;
|
||||
|
||||
for (let i = 0; i < img.length; i++) {
|
||||
img[i] = this.pathCount[i] > 0 ? this.attenuation[i] / this.pathCount[i] : 0;
|
||||
if (img[i] > maxVal) maxVal = img[i];
|
||||
}
|
||||
|
||||
// Normalize to 0-1
|
||||
if (maxVal > 0) {
|
||||
for (let i = 0; i < img.length; i++) img[i] /= maxVal;
|
||||
}
|
||||
|
||||
return img;
|
||||
}
|
||||
|
||||
/** Get per-channel images for frequency analysis */
|
||||
getChannelImages() {
|
||||
const images = {};
|
||||
for (const [ch, chAtten] of this.channelAttenuation) {
|
||||
const img = new Float64Array(this.gridSize * this.gridSize);
|
||||
let maxVal = 0;
|
||||
for (let i = 0; i < img.length; i++) {
|
||||
img[i] = this.pathCount[i] > 0 ? chAtten[i] / this.pathCount[i] : 0;
|
||||
if (img[i] > maxVal) maxVal = img[i];
|
||||
}
|
||||
if (maxVal > 0) for (let i = 0; i < img.length; i++) img[i] /= maxVal;
|
||||
images[ch] = img;
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
/** Detect high-attenuation regions (potential person locations) */
|
||||
detectObjects(threshold = 0.6) {
|
||||
const img = this.getImage();
|
||||
const objects = [];
|
||||
|
||||
for (let r = 0; r < this.gridSize; r++) {
|
||||
for (let c = 0; c < this.gridSize; c++) {
|
||||
const val = img[r * this.gridSize + c];
|
||||
if (val >= threshold) {
|
||||
const [x, y] = this.cellCenter(r, c);
|
||||
objects.push({
|
||||
row: r, col: c,
|
||||
x: x.toFixed(2), y: y.toFixed(2),
|
||||
attenuation: val.toFixed(3),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return objects;
|
||||
}
|
||||
|
||||
/** Reset accumulator for next window */
|
||||
reset() {
|
||||
this.attenuation.fill(0);
|
||||
this.pathCount.fill(0);
|
||||
this.channelAttenuation.clear();
|
||||
this.frameCount = 0;
|
||||
this.channelFrames.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSI parsing (shared with other scripts)
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseIqHex(iqHex, nSubcarriers) {
|
||||
const bytes = Buffer.from(iqHex, 'hex');
|
||||
const amplitudes = new Float64Array(nSubcarriers);
|
||||
const phases = new Float64Array(nSubcarriers);
|
||||
|
||||
for (let sc = 0; sc < nSubcarriers; sc++) {
|
||||
const offset = 2 + sc * 2;
|
||||
if (offset + 1 >= bytes.length) break;
|
||||
let I = bytes[offset];
|
||||
let Q = bytes[offset + 1];
|
||||
if (I > 127) I -= 256;
|
||||
if (Q > 127) Q -= 256;
|
||||
amplitudes[sc] = Math.sqrt(I * I + Q * Q);
|
||||
phases[sc] = Math.atan2(Q, I);
|
||||
}
|
||||
|
||||
return { amplitudes, phases };
|
||||
}
|
||||
|
||||
function parseCSIFrame(buf) {
|
||||
if (buf.length < HEADER_SIZE) return null;
|
||||
const magic = buf.readUInt32LE(0);
|
||||
if (magic !== CSI_MAGIC) return null;
|
||||
|
||||
const nodeId = buf.readUInt8(4);
|
||||
const nSubcarriers = buf.readUInt16LE(6);
|
||||
const freqMhz = buf.readUInt32LE(8);
|
||||
const rssi = buf.readInt8(16);
|
||||
|
||||
const amplitudes = new Float64Array(nSubcarriers);
|
||||
const phases = new Float64Array(nSubcarriers);
|
||||
|
||||
for (let sc = 0; sc < nSubcarriers; sc++) {
|
||||
const offset = HEADER_SIZE + sc * 2;
|
||||
if (offset + 1 >= buf.length) break;
|
||||
const I = buf.readInt8(offset);
|
||||
const Q = buf.readInt8(offset + 1);
|
||||
amplitudes[sc] = Math.sqrt(I * I + Q * Q);
|
||||
phases[sc] = Math.atan2(Q, I);
|
||||
}
|
||||
|
||||
let channel = 0;
|
||||
if (freqMhz >= 2412 && freqMhz <= 2484) {
|
||||
channel = freqMhz === 2484 ? 14 : Math.round((freqMhz - 2412) / 5) + 1;
|
||||
}
|
||||
|
||||
return { nodeId, nSubcarriers, freqMhz, rssi, amplitudes, phases, channel };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute mean amplitude as a proxy for path attenuation.
|
||||
* Higher amplitude = less attenuation. We invert for the tomography grid.
|
||||
*/
|
||||
function computeAttenuation(amplitudes) {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < amplitudes.length; i++) sum += amplitudes[i];
|
||||
const mean = sum / amplitudes.length;
|
||||
// Free-space reference (approximate, empirically calibrated)
|
||||
const freeSpaceRef = 15.0;
|
||||
// Attenuation: how much below free-space reference
|
||||
return Math.max(0, freeSpaceRef - mean);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Channel assignment for legacy JSONL (no freq field)
|
||||
// ---------------------------------------------------------------------------
|
||||
const nodeChannelIdx = { 1: 0, 2: 0 };
|
||||
|
||||
function assignChannel(nodeId) {
|
||||
const channels = nodeId === 1 ? NODE1_CHANNELS : NODE2_CHANNELS;
|
||||
const ch = channels[nodeChannelIdx[nodeId] % channels.length];
|
||||
nodeChannelIdx[nodeId]++;
|
||||
return ch;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Visualization
|
||||
// ---------------------------------------------------------------------------
|
||||
function renderHeatmap(grid) {
|
||||
const img = grid.getImage();
|
||||
const gs = grid.gridSize;
|
||||
|
||||
const lines = [];
|
||||
lines.push('');
|
||||
lines.push(' RF Tomographic Image');
|
||||
lines.push(' ' + '='.repeat(gs * 2 + 2));
|
||||
|
||||
// Y-axis label
|
||||
for (let r = 0; r < gs; r++) {
|
||||
const y = ((gs - r - 0.5) / gs * grid.roomHeight).toFixed(1);
|
||||
let row = `${y.padStart(4)}m |`;
|
||||
for (let c = 0; c < gs; c++) {
|
||||
const val = img[r * gs + c];
|
||||
const level = Math.floor(val * 7.99);
|
||||
row += HEAT[Math.max(0, Math.min(7, level))] + ' ';
|
||||
}
|
||||
row += '|';
|
||||
lines.push(' ' + row);
|
||||
}
|
||||
|
||||
// X-axis
|
||||
lines.push(' ' + ' '.repeat(6) + '+' + '-'.repeat(gs * 2) + '+');
|
||||
let xLabels = ' '.repeat(7);
|
||||
for (let c = 0; c < gs; c += Math.max(1, Math.floor(gs / 5))) {
|
||||
const x = (c / gs * grid.roomWidth).toFixed(1);
|
||||
xLabels += x.padEnd(Math.floor(gs / 5) * 2 || 2);
|
||||
}
|
||||
lines.push(' ' + xLabels + ' (m)');
|
||||
|
||||
// Legend
|
||||
lines.push('');
|
||||
lines.push(' Legend: ' + HEAT.map((ch, i) =>
|
||||
`${ch}=${HEAT_LABELS[i]}`
|
||||
).join(' '));
|
||||
|
||||
// Node positions
|
||||
const n1c = Math.floor(NODE_POS[1][0] / grid.roomWidth * gs);
|
||||
const n1r = gs - 1 - Math.floor(NODE_POS[1][1] / grid.roomHeight * gs);
|
||||
const n2c = Math.floor(NODE_POS[2][0] / grid.roomWidth * gs);
|
||||
const n2r = gs - 1 - Math.floor(NODE_POS[2][1] / grid.roomHeight * gs);
|
||||
lines.push(` Node 1: (${NODE_POS[1][0]}, ${NODE_POS[1][1]}) m [grid ${n1r},${n1c}]`);
|
||||
lines.push(` Node 2: (${NODE_POS[2][0]}, ${NODE_POS[2][1]}) m [grid ${n2r},${n2c}]`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderStats(grid) {
|
||||
const lines = [];
|
||||
lines.push(` Frames: ${grid.frameCount}`);
|
||||
|
||||
const chFrames = [...grid.channelFrames.entries()].sort((a, b) => a[0] - b[0]);
|
||||
if (chFrames.length > 0) {
|
||||
lines.push(' Per-channel frames: ' + chFrames.map(([ch, n]) =>
|
||||
`ch${ch}=${n}`
|
||||
).join(' '));
|
||||
}
|
||||
|
||||
const objects = grid.detectObjects(0.6);
|
||||
if (objects.length > 0) {
|
||||
lines.push(` Detected ${objects.length} high-attenuation region(s):`);
|
||||
for (const obj of objects.slice(0, 5)) {
|
||||
lines.push(` (${obj.x}, ${obj.y}) m attenuation=${obj.attenuation}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(' No high-attenuation regions detected');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderChannelComparison(grid) {
|
||||
const images = grid.getChannelImages();
|
||||
const channels = Object.keys(images).map(Number).sort((a, b) => a - b);
|
||||
if (channels.length < 2) return '';
|
||||
|
||||
const gs = grid.gridSize;
|
||||
const lines = [];
|
||||
lines.push('');
|
||||
lines.push(' Per-Channel Attenuation (middle row):');
|
||||
|
||||
const midRow = Math.floor(gs / 2);
|
||||
for (const ch of channels) {
|
||||
const img = images[ch];
|
||||
let bar = ` ch${String(ch).padStart(2)}: `;
|
||||
for (let c = 0; c < gs; c++) {
|
||||
const val = img[midRow * gs + c];
|
||||
const level = Math.floor(val * 7.99);
|
||||
bar += HEAT[Math.max(0, Math.min(7, level))] + ' ';
|
||||
}
|
||||
lines.push(bar);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Process a single CSI record
|
||||
// ---------------------------------------------------------------------------
|
||||
const grid = new TomographyGrid(GRID_SIZE, ROOM_WIDTH, ROOM_HEIGHT);
|
||||
let lastDisplayMs = 0;
|
||||
|
||||
function processFrame(nodeId, amplitudes, channel, timestamp) {
|
||||
const atten = computeAttenuation(amplitudes);
|
||||
|
||||
// Back-project along node-to-node path
|
||||
const txPos = NODE_POS[nodeId] || [0, 0];
|
||||
const otherNode = nodeId === 1 ? 2 : 1;
|
||||
const rxPos = NODE_POS[otherNode] || [NODE_DISTANCE, ROOM_HEIGHT / 2];
|
||||
|
||||
grid.backProject(txPos, rxPos, atten, channel);
|
||||
|
||||
// Also back-project along paths to known illuminators on this channel
|
||||
for (const il of ILLUMINATORS) {
|
||||
if (il.channel === channel) {
|
||||
grid.backProject(il.pos, txPos, atten * (il.signal / 100), channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function displayUpdate() {
|
||||
if (JSON_OUTPUT) {
|
||||
const img = grid.getImage();
|
||||
const objects = grid.detectObjects(0.6);
|
||||
console.log(JSON.stringify({
|
||||
timestamp: Date.now() / 1000,
|
||||
frames: grid.frameCount,
|
||||
channels: [...grid.channelFrames.keys()].sort(),
|
||||
image: Array.from(img).map(v => +v.toFixed(3)),
|
||||
gridSize: GRID_SIZE,
|
||||
roomWidth: ROOM_WIDTH,
|
||||
roomHeight: ROOM_HEIGHT,
|
||||
objects,
|
||||
}));
|
||||
} else {
|
||||
process.stdout.write('\x1B[2J\x1B[H'); // clear screen
|
||||
console.log(renderHeatmap(grid));
|
||||
console.log(renderStats(grid));
|
||||
console.log(renderChannelComparison(grid));
|
||||
console.log('');
|
||||
console.log(' Press Ctrl+C to exit');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live mode (UDP)
|
||||
// ---------------------------------------------------------------------------
|
||||
function startLive() {
|
||||
const sock = dgram.createSocket('udp4');
|
||||
|
||||
sock.on('message', (buf, rinfo) => {
|
||||
if (buf.length < 4) return;
|
||||
const magic = buf.readUInt32LE(0);
|
||||
if (magic !== CSI_MAGIC) return;
|
||||
|
||||
const frame = parseCSIFrame(buf);
|
||||
if (!frame) return;
|
||||
|
||||
processFrame(frame.nodeId, frame.amplitudes, frame.channel, Date.now() / 1000);
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastDisplayMs >= INTERVAL_MS) {
|
||||
displayUpdate();
|
||||
lastDisplayMs = now;
|
||||
}
|
||||
});
|
||||
|
||||
sock.bind(PORT, () => {
|
||||
if (!JSON_OUTPUT) {
|
||||
console.log(`RF Tomography listening on UDP port ${PORT}`);
|
||||
console.log(`Grid: ${GRID_SIZE}x${GRID_SIZE}, Room: ${ROOM_WIDTH}x${ROOM_HEIGHT} m`);
|
||||
console.log(`Node distance: ${NODE_DISTANCE} m`);
|
||||
console.log('Waiting for CSI frames...');
|
||||
}
|
||||
});
|
||||
|
||||
if (DURATION_MS) {
|
||||
setTimeout(() => {
|
||||
displayUpdate();
|
||||
sock.close();
|
||||
process.exit(0);
|
||||
}, DURATION_MS);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Replay mode (JSONL)
|
||||
// ---------------------------------------------------------------------------
|
||||
async function startReplay(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`File not found: ${filePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: fs.createReadStream(filePath),
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
let frameCount = 0;
|
||||
let lastAnalysisTs = 0;
|
||||
let windowCount = 0;
|
||||
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
let record;
|
||||
try { record = JSON.parse(line); } catch { continue; }
|
||||
if (record.type !== 'raw_csi' || !record.iq_hex) continue;
|
||||
|
||||
const { amplitudes, phases } = parseIqHex(record.iq_hex, record.subcarriers || 64);
|
||||
const channel = record.channel || assignChannel(record.node_id);
|
||||
|
||||
processFrame(record.node_id, amplitudes, channel, record.timestamp);
|
||||
frameCount++;
|
||||
|
||||
const tsMs = record.timestamp * 1000;
|
||||
if (lastAnalysisTs === 0) lastAnalysisTs = tsMs;
|
||||
|
||||
if (tsMs - lastAnalysisTs >= INTERVAL_MS) {
|
||||
windowCount++;
|
||||
if (JSON_OUTPUT) {
|
||||
displayUpdate();
|
||||
} else {
|
||||
console.log(`\n${'='.repeat(60)}`);
|
||||
console.log(`Window ${windowCount} | t=${record.timestamp.toFixed(1)}s | frames=${frameCount}`);
|
||||
console.log('='.repeat(60));
|
||||
console.log(renderHeatmap(grid));
|
||||
console.log(renderStats(grid));
|
||||
console.log(renderChannelComparison(grid));
|
||||
}
|
||||
lastAnalysisTs = tsMs;
|
||||
}
|
||||
}
|
||||
|
||||
// Final output
|
||||
if (!JSON_OUTPUT) {
|
||||
console.log(`\n${'='.repeat(60)}`);
|
||||
console.log('FINAL RF TOMOGRAPHIC IMAGE');
|
||||
console.log('='.repeat(60));
|
||||
console.log(renderHeatmap(grid));
|
||||
console.log(renderStats(grid));
|
||||
console.log(renderChannelComparison(grid));
|
||||
console.log(`\nProcessed ${frameCount} frames in ${windowCount} windows`);
|
||||
} else {
|
||||
displayUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
if (args.replay) {
|
||||
startReplay(args.replay);
|
||||
} else {
|
||||
startLive();
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ADR-077: Room Environment Fingerprinting
|
||||
*
|
||||
* Clusters CSI feature vectors to identify distinct room states (empty,
|
||||
* working, sleeping, etc.), tracks transitions, and detects anomalies.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/room-fingerprint.js --replay data/recordings/overnight-1775217646.csi.jsonl
|
||||
* node scripts/room-fingerprint.js --port 5006
|
||||
* node scripts/room-fingerprint.js --replay FILE --json
|
||||
*
|
||||
* ADR: docs/adr/ADR-077-novel-rf-sensing-applications.md
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const dgram = require('dgram');
|
||||
const fs = require('fs');
|
||||
const readline = require('readline');
|
||||
const { parseArgs } = require('util');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
port: { type: 'string', short: 'p', default: '5006' },
|
||||
replay: { type: 'string', short: 'r' },
|
||||
json: { type: 'boolean', default: false },
|
||||
interval: { type: 'string', short: 'i', default: '10000' },
|
||||
'k': { type: 'string', default: '5' },
|
||||
'new-cluster-threshold': { type: 'string', default: '2.0' },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
const PORT = parseInt(args.port, 10);
|
||||
const JSON_OUTPUT = args.json;
|
||||
const INTERVAL_MS = parseInt(args.interval, 10);
|
||||
const K = parseInt(args.k, 10);
|
||||
const NEW_CLUSTER_DIST = parseFloat(args['new-cluster-threshold']);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ADR-018 packet constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const VITALS_MAGIC = 0xC5110002;
|
||||
const FEATURE_MAGIC = 0xC5110003;
|
||||
const FUSED_MAGIC = 0xC5110004;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Online k-means clustering
|
||||
// ---------------------------------------------------------------------------
|
||||
class OnlineKMeans {
|
||||
constructor(maxK, featureDim, newClusterThreshold) {
|
||||
this.maxK = maxK;
|
||||
this.dim = featureDim;
|
||||
this.threshold = newClusterThreshold;
|
||||
|
||||
this.centroids = []; // { center: Float64Array, count: number, label: string }
|
||||
this.alpha = 0.01; // EMA update rate
|
||||
}
|
||||
|
||||
_distance(a, b) {
|
||||
let sum = 0;
|
||||
const len = Math.min(a.length, b.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
sum += (a[i] - b[i]) ** 2;
|
||||
}
|
||||
return Math.sqrt(sum);
|
||||
}
|
||||
|
||||
assign(features) {
|
||||
if (this.centroids.length === 0) {
|
||||
// First point creates first cluster
|
||||
this.centroids.push({
|
||||
center: Float64Array.from(features),
|
||||
count: 1,
|
||||
label: `State-0`,
|
||||
});
|
||||
return { clusterId: 0, distance: 0 };
|
||||
}
|
||||
|
||||
// Find nearest centroid
|
||||
let bestDist = Infinity;
|
||||
let bestIdx = 0;
|
||||
for (let i = 0; i < this.centroids.length; i++) {
|
||||
const d = this._distance(features, this.centroids[i].center);
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
// If too far from any cluster, create new one (up to maxK)
|
||||
if (bestDist > this.threshold && this.centroids.length < this.maxK) {
|
||||
const newIdx = this.centroids.length;
|
||||
this.centroids.push({
|
||||
center: Float64Array.from(features),
|
||||
count: 1,
|
||||
label: `State-${newIdx}`,
|
||||
});
|
||||
return { clusterId: newIdx, distance: 0 };
|
||||
}
|
||||
|
||||
// Update centroid via EMA
|
||||
const c = this.centroids[bestIdx];
|
||||
c.count++;
|
||||
for (let i = 0; i < this.dim; i++) {
|
||||
c.center[i] = c.center[i] * (1 - this.alpha) + features[i] * this.alpha;
|
||||
}
|
||||
|
||||
return { clusterId: bestIdx, distance: bestDist };
|
||||
}
|
||||
|
||||
labelClusters(clusterMotion) {
|
||||
// Sort clusters by average motion to assign labels
|
||||
const sorted = Object.entries(clusterMotion)
|
||||
.sort((a, b) => a[1] - b[1]);
|
||||
|
||||
const labels = ['sleeping/empty', 'resting', 'working', 'active', 'highly active'];
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const clusterId = parseInt(sorted[i][0], 10);
|
||||
if (clusterId < this.centroids.length) {
|
||||
this.centroids[clusterId].label = labels[Math.min(i, labels.length - 1)];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Room state tracker
|
||||
// ---------------------------------------------------------------------------
|
||||
class RoomFingerprinter {
|
||||
constructor(maxK, featureDim, newClusterThreshold) {
|
||||
this.kmeans = new OnlineKMeans(maxK, featureDim, newClusterThreshold);
|
||||
this.featureDim = featureDim;
|
||||
|
||||
// State tracking
|
||||
this.currentState = null;
|
||||
this.stateHistory = []; // { timestamp, clusterId, label, distance }
|
||||
this.transitions = {}; // "from->to" -> count
|
||||
|
||||
// Vitals correlation
|
||||
this.clusterMotionSum = {}; // clusterId -> sum
|
||||
this.clusterMotionCount = {}; // clusterId -> count
|
||||
|
||||
// Feature buffer (latest per node)
|
||||
this.latestFeatures = new Map(); // nodeId -> { timestamp, features }
|
||||
this.latestVitals = new Map(); // nodeId -> { timestamp, motion, presence }
|
||||
|
||||
this.startTime = null;
|
||||
}
|
||||
|
||||
pushFeature(timestamp, nodeId, features) {
|
||||
if (!this.startTime) this.startTime = timestamp;
|
||||
this.latestFeatures.set(nodeId, { timestamp, features });
|
||||
}
|
||||
|
||||
pushVitals(timestamp, nodeId, motion, presence) {
|
||||
this.latestVitals.set(nodeId, { timestamp, motion, presence });
|
||||
}
|
||||
|
||||
analyze(timestamp) {
|
||||
// Find latest feature vector (prefer most recent node)
|
||||
let bestFeature = null;
|
||||
let bestTs = 0;
|
||||
for (const [, entry] of this.latestFeatures) {
|
||||
if (entry.timestamp > bestTs) {
|
||||
bestTs = entry.timestamp;
|
||||
bestFeature = entry.features;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestFeature || bestFeature.length < this.featureDim) return null;
|
||||
|
||||
// Truncate or pad to featureDim
|
||||
const features = new Float64Array(this.featureDim);
|
||||
for (let i = 0; i < this.featureDim && i < bestFeature.length; i++) {
|
||||
features[i] = bestFeature[i];
|
||||
}
|
||||
|
||||
// Assign to cluster
|
||||
const { clusterId, distance } = this.kmeans.assign(features);
|
||||
|
||||
// Track motion per cluster for labeling
|
||||
let avgMotion = 0;
|
||||
let motionCount = 0;
|
||||
for (const [, v] of this.latestVitals) {
|
||||
avgMotion += v.motion;
|
||||
motionCount++;
|
||||
}
|
||||
avgMotion = motionCount > 0 ? avgMotion / motionCount : 0;
|
||||
|
||||
this.clusterMotionSum[clusterId] = (this.clusterMotionSum[clusterId] || 0) + avgMotion;
|
||||
this.clusterMotionCount[clusterId] = (this.clusterMotionCount[clusterId] || 0) + 1;
|
||||
|
||||
// Update labels periodically
|
||||
const clusterMotion = {};
|
||||
for (const id of Object.keys(this.clusterMotionCount)) {
|
||||
clusterMotion[id] = this.clusterMotionSum[id] / this.clusterMotionCount[id];
|
||||
}
|
||||
this.kmeans.labelClusters(clusterMotion);
|
||||
|
||||
const label = this.kmeans.centroids[clusterId]
|
||||
? this.kmeans.centroids[clusterId].label
|
||||
: `State-${clusterId}`;
|
||||
|
||||
// Track transitions
|
||||
if (this.currentState !== null && this.currentState !== clusterId) {
|
||||
const key = `${this.currentState}->${clusterId}`;
|
||||
this.transitions[key] = (this.transitions[key] || 0) + 1;
|
||||
}
|
||||
const prevState = this.currentState;
|
||||
this.currentState = clusterId;
|
||||
|
||||
const entry = {
|
||||
timestamp,
|
||||
clusterId,
|
||||
label,
|
||||
distance: +distance.toFixed(4),
|
||||
motion: +avgMotion.toFixed(3),
|
||||
transitioned: prevState !== null && prevState !== clusterId,
|
||||
prevState: prevState !== null ? prevState : undefined,
|
||||
totalClusters: this.kmeans.centroids.length,
|
||||
};
|
||||
|
||||
this.stateHistory.push(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
anomalyScore() {
|
||||
// Anomaly = current state is rarely seen at this time-of-day
|
||||
if (this.stateHistory.length < 10) return 0;
|
||||
|
||||
const currentCluster = this.currentState;
|
||||
const recentCount = this.stateHistory.slice(-20).filter(e => e.clusterId === currentCluster).length;
|
||||
return 1 - (recentCount / 20); // low count = high anomaly
|
||||
}
|
||||
|
||||
renderTimeline(width) {
|
||||
const w = width || 60;
|
||||
if (this.stateHistory.length === 0) return 'No data yet.';
|
||||
|
||||
const step = Math.max(1, Math.floor(this.stateHistory.length / w));
|
||||
const chars = '\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588';
|
||||
|
||||
let line = '';
|
||||
for (let i = 0; i < this.stateHistory.length; i += step) {
|
||||
const cid = this.stateHistory[i].clusterId;
|
||||
line += chars[Math.min(cid, chars.length - 1)];
|
||||
}
|
||||
|
||||
return `State timeline: ${line}`;
|
||||
}
|
||||
|
||||
renderTransitionMatrix() {
|
||||
if (Object.keys(this.transitions).length === 0) return 'No transitions yet.';
|
||||
|
||||
const lines = ['Transition matrix:'];
|
||||
for (const [key, count] of Object.entries(this.transitions).sort((a, b) => b[1] - a[1])) {
|
||||
const [from, to] = key.split('->');
|
||||
const fromLabel = this.kmeans.centroids[parseInt(from, 10)]?.label || `State-${from}`;
|
||||
const toLabel = this.kmeans.centroids[parseInt(to, 10)]?.label || `State-${to}`;
|
||||
lines.push(` ${fromLabel} -> ${toLabel}: ${count}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Packet parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseFeatureJsonl(record) {
|
||||
if (record.type !== 'feature' || !record.features) return null;
|
||||
return {
|
||||
timestamp: record.timestamp,
|
||||
nodeId: record.node_id,
|
||||
features: record.features,
|
||||
};
|
||||
}
|
||||
|
||||
function parseVitalsJsonl(record) {
|
||||
if (record.type !== 'vitals') return null;
|
||||
return {
|
||||
timestamp: record.timestamp,
|
||||
nodeId: record.node_id,
|
||||
motion: record.motion_energy || 0,
|
||||
presence: record.presence_score || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function parseFeatureUdp(buf) {
|
||||
if (buf.length < 48) return null;
|
||||
const magic = buf.readUInt32LE(0);
|
||||
if (magic !== FEATURE_MAGIC) return null;
|
||||
|
||||
const nodeId = buf.readUInt8(4);
|
||||
const features = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
features.push(buf.readFloatLE(12 + i * 4));
|
||||
}
|
||||
return { timestamp: Date.now() / 1000, nodeId, features };
|
||||
}
|
||||
|
||||
function parseVitalsUdp(buf) {
|
||||
if (buf.length < 32) return null;
|
||||
const magic = buf.readUInt32LE(0);
|
||||
if (magic !== VITALS_MAGIC && magic !== FUSED_MAGIC) return null;
|
||||
return {
|
||||
timestamp: Date.now() / 1000,
|
||||
nodeId: buf.readUInt8(4),
|
||||
motion: buf.readFloatLE(16),
|
||||
presence: buf.readFloatLE(20),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Replay mode
|
||||
// ---------------------------------------------------------------------------
|
||||
async function startReplay(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`File not found: ${filePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fingerprinter = new RoomFingerprinter(K, 8, NEW_CLUSTER_DIST);
|
||||
const rl = readline.createInterface({
|
||||
input: fs.createReadStream(filePath),
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
let featureCount = 0;
|
||||
let vitalsCount = 0;
|
||||
let lastAnalysisTs = 0;
|
||||
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) continue;
|
||||
let record;
|
||||
try { record = JSON.parse(line); } catch { continue; }
|
||||
|
||||
const feat = parseFeatureJsonl(record);
|
||||
if (feat) {
|
||||
fingerprinter.pushFeature(feat.timestamp, feat.nodeId, feat.features);
|
||||
featureCount++;
|
||||
}
|
||||
|
||||
const vit = parseVitalsJsonl(record);
|
||||
if (vit) {
|
||||
fingerprinter.pushVitals(vit.timestamp, vit.nodeId, vit.motion, vit.presence);
|
||||
vitalsCount++;
|
||||
}
|
||||
|
||||
const ts = feat || vit;
|
||||
if (!ts) continue;
|
||||
|
||||
const tsMs = ts.timestamp * 1000;
|
||||
if (lastAnalysisTs === 0) lastAnalysisTs = tsMs;
|
||||
|
||||
if (tsMs - lastAnalysisTs >= INTERVAL_MS) {
|
||||
const result = fingerprinter.analyze(ts.timestamp);
|
||||
|
||||
if (result) {
|
||||
if (JSON_OUTPUT) {
|
||||
console.log(JSON.stringify(result));
|
||||
} else {
|
||||
const tsStr = new Date(ts.timestamp * 1000).toISOString().slice(11, 19);
|
||||
const transition = result.transitioned ? ` << TRANSITION from State-${result.prevState}` : '';
|
||||
console.log(`[${tsStr}] Cluster ${result.clusterId} (${result.label}) | dist ${result.distance} | motion ${result.motion} | ${result.totalClusters} clusters${transition}`);
|
||||
}
|
||||
}
|
||||
|
||||
lastAnalysisTs = tsMs;
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
if (!JSON_OUTPUT) {
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('ROOM FINGERPRINT SUMMARY');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
console.log(`\nClusters discovered: ${fingerprinter.kmeans.centroids.length}`);
|
||||
for (let i = 0; i < fingerprinter.kmeans.centroids.length; i++) {
|
||||
const c = fingerprinter.kmeans.centroids[i];
|
||||
const stateCount = fingerprinter.stateHistory.filter(e => e.clusterId === i).length;
|
||||
const pct = fingerprinter.stateHistory.length > 0
|
||||
? ((stateCount / fingerprinter.stateHistory.length) * 100).toFixed(1)
|
||||
: '0';
|
||||
const avgMotion = fingerprinter.clusterMotionCount[i] > 0
|
||||
? (fingerprinter.clusterMotionSum[i] / fingerprinter.clusterMotionCount[i]).toFixed(2)
|
||||
: '?';
|
||||
console.log(` Cluster ${i} (${c.label}): ${stateCount} windows (${pct}%) | avg motion ${avgMotion} | ${c.count} assignments`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(fingerprinter.renderTimeline(60));
|
||||
console.log('');
|
||||
console.log(fingerprinter.renderTransitionMatrix());
|
||||
|
||||
const anomaly = fingerprinter.anomalyScore();
|
||||
console.log(`\nCurrent anomaly score: ${anomaly.toFixed(3)}`);
|
||||
console.log(`Processed: ${featureCount} feature packets, ${vitalsCount} vitals packets`);
|
||||
} else {
|
||||
console.log(JSON.stringify({
|
||||
type: 'summary',
|
||||
clusters: fingerprinter.kmeans.centroids.length,
|
||||
windows: fingerprinter.stateHistory.length,
|
||||
transitions: Object.keys(fingerprinter.transitions).length,
|
||||
anomaly: +fingerprinter.anomalyScore().toFixed(3),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live UDP mode
|
||||
// ---------------------------------------------------------------------------
|
||||
function startLive() {
|
||||
const fingerprinter = new RoomFingerprinter(K, 8, NEW_CLUSTER_DIST);
|
||||
const server = dgram.createSocket('udp4');
|
||||
|
||||
server.on('message', (buf) => {
|
||||
if (buf.length < 4) return;
|
||||
const magic = buf.readUInt32LE(0);
|
||||
|
||||
if (magic === FEATURE_MAGIC) {
|
||||
const feat = parseFeatureUdp(buf);
|
||||
if (feat) fingerprinter.pushFeature(feat.timestamp, feat.nodeId, feat.features);
|
||||
}
|
||||
if (magic === VITALS_MAGIC || magic === FUSED_MAGIC) {
|
||||
const vit = parseVitalsUdp(buf);
|
||||
if (vit) fingerprinter.pushVitals(vit.timestamp, vit.nodeId, vit.motion, vit.presence);
|
||||
}
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
const result = fingerprinter.analyze(Date.now() / 1000);
|
||||
|
||||
if (JSON_OUTPUT) {
|
||||
if (result) console.log(JSON.stringify(result));
|
||||
} else {
|
||||
process.stdout.write('\x1B[2J\x1B[H');
|
||||
console.log('=== ROOM FINGERPRINT (ADR-077) ===\n');
|
||||
|
||||
if (result) {
|
||||
console.log(`Current state: Cluster ${result.clusterId} (${result.label})`);
|
||||
console.log(`Distance: ${result.distance} | Motion: ${result.motion}`);
|
||||
console.log(`Clusters: ${result.totalClusters}`);
|
||||
if (result.transitioned) {
|
||||
console.log(`** STATE TRANSITION from State-${result.prevState} **`);
|
||||
}
|
||||
} else {
|
||||
console.log('Collecting data...');
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(fingerprinter.renderTimeline(50));
|
||||
console.log('');
|
||||
console.log(fingerprinter.renderTransitionMatrix());
|
||||
console.log(`\nAnomaly score: ${fingerprinter.anomalyScore().toFixed(3)}`);
|
||||
}
|
||||
}, INTERVAL_MS);
|
||||
|
||||
server.bind(PORT, () => {
|
||||
if (!JSON_OUTPUT) {
|
||||
console.log(`Room Fingerprint listening on UDP :${PORT} (k=${K})`);
|
||||
}
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => { server.close(); process.exit(0); });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry
|
||||
// ---------------------------------------------------------------------------
|
||||
if (args.replay) {
|
||||
startReplay(args.replay);
|
||||
} else {
|
||||
startLive();
|
||||
}
|
||||
@@ -391,7 +391,16 @@ 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)
|
||||
sock.bind(("0.0.0.0", args.udp_port))
|
||||
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.settimeout(1.0) # 1s timeout for responsive time-based flushing
|
||||
log.info(
|
||||
"Listening on UDP port %d (batch size: %d, flush interval: %.0fs)",
|
||||
@@ -597,6 +606,11 @@ 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,
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ADR-077: Sleep Quality Monitor — CSI-based sleep staging
|
||||
*
|
||||
* Classifies sleep stages from breathing rate + heart rate + motion energy
|
||||
* using 5-minute sliding windows. Produces a hypnogram and summary stats.
|
||||
*
|
||||
* DISCLAIMER: This is a consumer-grade informational tool, NOT a medical device.
|
||||
* Do not use for clinical diagnosis. Consult a physician for sleep concerns.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/sleep-monitor.js --replay data/recordings/overnight-1775217646.csi.jsonl
|
||||
* node scripts/sleep-monitor.js --port 5006
|
||||
* node scripts/sleep-monitor.js --replay FILE --json
|
||||
*
|
||||
* ADR: docs/adr/ADR-077-novel-rf-sensing-applications.md
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const dgram = require('dgram');
|
||||
const fs = require('fs');
|
||||
const readline = require('readline');
|
||||
const { parseArgs } = require('util');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
port: { type: 'string', short: 'p', default: '5006' },
|
||||
replay: { type: 'string', short: 'r' },
|
||||
json: { type: 'boolean', default: false },
|
||||
interval: { type: 'string', short: 'i', default: '5000' },
|
||||
window: { type: 'string', short: 'w', default: '300' },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
const PORT = parseInt(args.port, 10);
|
||||
const JSON_OUTPUT = args.json;
|
||||
const INTERVAL_MS = parseInt(args.interval, 10);
|
||||
const WINDOW_SEC = parseInt(args.window, 10); // default 5 min = 300s
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ADR-018 packet constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const VITALS_MAGIC = 0xC5110002;
|
||||
const FUSED_MAGIC = 0xC5110004;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sleep stage thresholds
|
||||
// ---------------------------------------------------------------------------
|
||||
const STAGES = { AWAKE: 'Awake', LIGHT: 'Light', REM: 'REM', DEEP: 'Deep' };
|
||||
const STAGE_CHARS = { Awake: 'W', Light: 'L', REM: 'R', Deep: 'D' };
|
||||
const STAGE_BARS = { Awake: '\u2581', Light: '\u2583', REM: '\u2585', Deep: '\u2588' };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vitals buffer
|
||||
// ---------------------------------------------------------------------------
|
||||
class VitalsBuffer {
|
||||
constructor(maxAgeSec) {
|
||||
this.maxAgeSec = maxAgeSec;
|
||||
this.samples = []; // { timestamp, br, hr, motion }
|
||||
}
|
||||
|
||||
push(timestamp, br, hr, motion) {
|
||||
this.samples.push({ timestamp, br, hr, motion });
|
||||
this._prune(timestamp);
|
||||
}
|
||||
|
||||
_prune(now) {
|
||||
const cutoff = now - this.maxAgeSec;
|
||||
while (this.samples.length > 0 && this.samples[0].timestamp < cutoff) {
|
||||
this.samples.shift();
|
||||
}
|
||||
}
|
||||
|
||||
get length() { return this.samples.length; }
|
||||
|
||||
stats() {
|
||||
const n = this.samples.length;
|
||||
if (n < 3) return null;
|
||||
|
||||
let brSum = 0, hrSum = 0, motionSum = 0;
|
||||
for (const s of this.samples) {
|
||||
brSum += s.br;
|
||||
hrSum += s.hr;
|
||||
motionSum += s.motion;
|
||||
}
|
||||
const brMean = brSum / n;
|
||||
const hrMean = hrSum / n;
|
||||
const motionMean = motionSum / n;
|
||||
|
||||
// BR variance
|
||||
let brVar = 0;
|
||||
for (const s of this.samples) {
|
||||
brVar += (s.br - brMean) ** 2;
|
||||
}
|
||||
brVar /= (n - 1);
|
||||
|
||||
// HR coefficient of variation
|
||||
let hrVar = 0;
|
||||
for (const s of this.samples) {
|
||||
hrVar += (s.hr - hrMean) ** 2;
|
||||
}
|
||||
hrVar /= (n - 1);
|
||||
const hrCV = hrMean > 0 ? Math.sqrt(hrVar) / hrMean : 0;
|
||||
|
||||
return { brMean, brVar, hrMean, hrCV, motionMean, n };
|
||||
}
|
||||
|
||||
classify() {
|
||||
const s = this.stats();
|
||||
if (!s) return null;
|
||||
|
||||
// High motion => Awake
|
||||
if (s.motionMean > 5.0 || s.brMean > 25 || s.brMean < 3) {
|
||||
return { stage: STAGES.AWAKE, ...s };
|
||||
}
|
||||
|
||||
// REM: irregular breathing (high variance), HR elevated
|
||||
if (s.brVar > 8.0 && s.brMean >= 15 && s.brMean <= 25) {
|
||||
return { stage: STAGES.REM, ...s };
|
||||
}
|
||||
|
||||
// Deep: low BR, very regular
|
||||
if (s.brMean >= 6 && s.brMean <= 14 && s.brVar < 2.0 && s.motionMean < 2.0) {
|
||||
return { stage: STAGES.DEEP, ...s };
|
||||
}
|
||||
|
||||
// Light: moderate BR and variance
|
||||
if (s.brMean >= 10 && s.brMean <= 20 && s.motionMean < 4.0) {
|
||||
return { stage: STAGES.LIGHT, ...s };
|
||||
}
|
||||
|
||||
// Default to Awake
|
||||
return { stage: STAGES.AWAKE, ...s };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sleep session tracker
|
||||
// ---------------------------------------------------------------------------
|
||||
class SleepSession {
|
||||
constructor(windowSec) {
|
||||
this.windowSec = windowSec;
|
||||
this.buffers = new Map(); // nodeId -> VitalsBuffer
|
||||
this.hypnogram = []; // { timestamp, stage, stats }
|
||||
this.startTime = null;
|
||||
this.lastTime = null;
|
||||
}
|
||||
|
||||
ingest(timestamp, nodeId, br, hr, motion) {
|
||||
if (!this.startTime) this.startTime = timestamp;
|
||||
this.lastTime = timestamp;
|
||||
|
||||
if (!this.buffers.has(nodeId)) {
|
||||
this.buffers.set(nodeId, new VitalsBuffer(this.windowSec));
|
||||
}
|
||||
this.buffers.get(nodeId).push(timestamp, br, hr, motion);
|
||||
}
|
||||
|
||||
analyze(timestamp) {
|
||||
// Merge stats from all nodes (take the one with most samples)
|
||||
let bestResult = null;
|
||||
let bestCount = 0;
|
||||
for (const [, buf] of this.buffers) {
|
||||
const result = buf.classify();
|
||||
if (result && result.n > bestCount) {
|
||||
bestResult = result;
|
||||
bestCount = result.n;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestResult) {
|
||||
this.hypnogram.push({ timestamp, ...bestResult });
|
||||
}
|
||||
return bestResult;
|
||||
}
|
||||
|
||||
summary() {
|
||||
if (this.hypnogram.length === 0) return null;
|
||||
|
||||
const counts = { Awake: 0, Light: 0, REM: 0, Deep: 0 };
|
||||
for (const entry of this.hypnogram) {
|
||||
counts[entry.stage]++;
|
||||
}
|
||||
const total = this.hypnogram.length;
|
||||
const sleepEntries = total - counts.Awake;
|
||||
const durationSec = this.lastTime - this.startTime;
|
||||
const durationMin = durationSec / 60;
|
||||
|
||||
return {
|
||||
totalRecordedMin: durationMin,
|
||||
totalSleepMin: (sleepEntries / total) * durationMin,
|
||||
sleepEfficiency: total > 0 ? ((sleepEntries / total) * 100) : 0,
|
||||
stageMinutes: {
|
||||
Awake: (counts.Awake / total) * durationMin,
|
||||
Light: (counts.Light / total) * durationMin,
|
||||
REM: (counts.REM / total) * durationMin,
|
||||
Deep: (counts.Deep / total) * durationMin,
|
||||
},
|
||||
stagePercent: {
|
||||
Awake: total > 0 ? ((counts.Awake / total) * 100) : 0,
|
||||
Light: total > 0 ? ((counts.Light / total) * 100) : 0,
|
||||
REM: total > 0 ? ((counts.REM / total) * 100) : 0,
|
||||
Deep: total > 0 ? ((counts.Deep / total) * 100) : 0,
|
||||
},
|
||||
entries: total,
|
||||
};
|
||||
}
|
||||
|
||||
renderHypnogram(width) {
|
||||
if (this.hypnogram.length === 0) return 'No data yet.';
|
||||
|
||||
const w = width || 60;
|
||||
const step = Math.max(1, Math.floor(this.hypnogram.length / w));
|
||||
let bars = '';
|
||||
let labels = '';
|
||||
for (let i = 0; i < this.hypnogram.length; i += step) {
|
||||
const entry = this.hypnogram[i];
|
||||
bars += STAGE_BARS[entry.stage] || ' ';
|
||||
labels += STAGE_CHARS[entry.stage] || '?';
|
||||
}
|
||||
|
||||
const lines = [];
|
||||
lines.push('Hypnogram:');
|
||||
lines.push(` ${bars}`);
|
||||
lines.push(` ${labels}`);
|
||||
lines.push(' W=Awake L=Light R=REM D=Deep');
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Packet parsing (from JSONL or UDP)
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseVitalsJsonl(record) {
|
||||
if (record.type !== 'vitals') return null;
|
||||
return {
|
||||
timestamp: record.timestamp,
|
||||
nodeId: record.node_id,
|
||||
br: record.breathing_bpm || 0,
|
||||
hr: record.heartrate_bpm || 0,
|
||||
motion: record.motion_energy || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function parseVitalsUdp(buf) {
|
||||
if (buf.length < 32) return null;
|
||||
const magic = buf.readUInt32LE(0);
|
||||
if (magic !== VITALS_MAGIC && magic !== FUSED_MAGIC) return null;
|
||||
|
||||
return {
|
||||
timestamp: Date.now() / 1000,
|
||||
nodeId: buf.readUInt8(4),
|
||||
br: buf.readUInt16LE(6) / 100,
|
||||
hr: buf.readUInt32LE(8) / 10000,
|
||||
motion: buf.readFloatLE(16),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display
|
||||
// ---------------------------------------------------------------------------
|
||||
function renderLive(session, latest) {
|
||||
const lines = [];
|
||||
lines.push('=== SLEEP QUALITY MONITOR (ADR-077) ===');
|
||||
lines.push('DISCLAIMER: Informational only. Not a medical device.');
|
||||
lines.push('');
|
||||
|
||||
if (latest) {
|
||||
lines.push(`Current stage: ${latest.stage}`);
|
||||
lines.push(` BR: ${latest.brMean.toFixed(1)} BPM (var ${latest.brVar.toFixed(2)})`);
|
||||
lines.push(` HR: ${latest.hrMean.toFixed(1)} BPM (CV ${(latest.hrCV * 100).toFixed(1)}%)`);
|
||||
lines.push(` Motion: ${latest.motionMean.toFixed(2)}`);
|
||||
lines.push(` Window: ${latest.n} samples`);
|
||||
} else {
|
||||
lines.push('Collecting data...');
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(session.renderHypnogram(60));
|
||||
|
||||
const sum = session.summary();
|
||||
if (sum) {
|
||||
lines.push('');
|
||||
lines.push(`Duration: ${sum.totalRecordedMin.toFixed(1)} min | Sleep: ${sum.totalSleepMin.toFixed(1)} min | Efficiency: ${sum.sleepEfficiency.toFixed(1)}%`);
|
||||
lines.push(` Deep: ${sum.stagePercent.Deep.toFixed(1)}% | Light: ${sum.stagePercent.Light.toFixed(1)}% | REM: ${sum.stagePercent.REM.toFixed(1)}% | Awake: ${sum.stagePercent.Awake.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Replay mode
|
||||
// ---------------------------------------------------------------------------
|
||||
async function startReplay(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`File not found: ${filePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const session = new SleepSession(WINDOW_SEC);
|
||||
const rl = readline.createInterface({
|
||||
input: fs.createReadStream(filePath),
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
let vitalsCount = 0;
|
||||
let lastAnalysisTs = 0;
|
||||
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) continue;
|
||||
let record;
|
||||
try { record = JSON.parse(line); } catch { continue; }
|
||||
|
||||
const v = parseVitalsJsonl(record);
|
||||
if (!v) continue;
|
||||
|
||||
session.ingest(v.timestamp, v.nodeId, v.br, v.hr, v.motion);
|
||||
vitalsCount++;
|
||||
|
||||
// Analyze every INTERVAL_MS worth of time
|
||||
const tsMs = v.timestamp * 1000;
|
||||
if (lastAnalysisTs === 0) lastAnalysisTs = tsMs;
|
||||
|
||||
if (tsMs - lastAnalysisTs >= INTERVAL_MS) {
|
||||
const result = session.analyze(v.timestamp);
|
||||
|
||||
if (JSON_OUTPUT) {
|
||||
if (result) {
|
||||
console.log(JSON.stringify({
|
||||
timestamp: v.timestamp,
|
||||
stage: result.stage,
|
||||
br_mean: +result.brMean.toFixed(2),
|
||||
br_var: +result.brVar.toFixed(3),
|
||||
hr_mean: +result.hrMean.toFixed(2),
|
||||
hr_cv: +result.hrCV.toFixed(4),
|
||||
motion_mean: +result.motionMean.toFixed(3),
|
||||
}));
|
||||
}
|
||||
} else if (result) {
|
||||
const ts = new Date(v.timestamp * 1000).toISOString().slice(11, 19);
|
||||
console.log(`[${ts}] ${result.stage.padEnd(5)} | BR ${result.brMean.toFixed(1)} (var ${result.brVar.toFixed(2)}) | HR ${result.hrMean.toFixed(1)} | Motion ${result.motionMean.toFixed(2)}`);
|
||||
}
|
||||
|
||||
lastAnalysisTs = tsMs;
|
||||
}
|
||||
}
|
||||
|
||||
// Final summary
|
||||
if (!JSON_OUTPUT) {
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('SLEEP SESSION SUMMARY');
|
||||
console.log('='.repeat(60));
|
||||
console.log(session.renderHypnogram(60));
|
||||
|
||||
const sum = session.summary();
|
||||
if (sum) {
|
||||
console.log('');
|
||||
console.log(`Total recorded: ${sum.totalRecordedMin.toFixed(1)} min`);
|
||||
console.log(`Total sleep: ${sum.totalSleepMin.toFixed(1)} min`);
|
||||
console.log(`Efficiency: ${sum.sleepEfficiency.toFixed(1)}%`);
|
||||
console.log(`Entries: ${sum.entries} analysis windows`);
|
||||
console.log('');
|
||||
console.log('Stage breakdown:');
|
||||
for (const stage of ['Deep', 'Light', 'REM', 'Awake']) {
|
||||
const pct = sum.stagePercent[stage].toFixed(1);
|
||||
const min = sum.stageMinutes[stage].toFixed(1);
|
||||
const bar = '\u2588'.repeat(Math.round(sum.stagePercent[stage] / 2));
|
||||
console.log(` ${stage.padEnd(6)} ${bar.padEnd(50)} ${pct}% (${min} min)`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nProcessed ${vitalsCount} vitals packets`);
|
||||
} else {
|
||||
const sum = session.summary();
|
||||
if (sum) {
|
||||
console.log(JSON.stringify({ type: 'summary', ...sum }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live UDP mode
|
||||
// ---------------------------------------------------------------------------
|
||||
function startLive() {
|
||||
const session = new SleepSession(WINDOW_SEC);
|
||||
const server = dgram.createSocket('udp4');
|
||||
|
||||
server.on('message', (buf) => {
|
||||
const v = parseVitalsUdp(buf);
|
||||
if (v) {
|
||||
session.ingest(v.timestamp, v.nodeId, v.br, v.hr, v.motion);
|
||||
}
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
const result = session.analyze(Date.now() / 1000);
|
||||
|
||||
if (JSON_OUTPUT) {
|
||||
if (result) {
|
||||
console.log(JSON.stringify({
|
||||
timestamp: Date.now() / 1000,
|
||||
stage: result.stage,
|
||||
br_mean: +result.brMean.toFixed(2),
|
||||
br_var: +result.brVar.toFixed(3),
|
||||
hr_mean: +result.hrMean.toFixed(2),
|
||||
motion_mean: +result.motionMean.toFixed(3),
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
process.stdout.write('\x1B[2J\x1B[H');
|
||||
process.stdout.write(renderLive(session, result) + '\n');
|
||||
}
|
||||
}, INTERVAL_MS);
|
||||
|
||||
server.bind(PORT, () => {
|
||||
if (!JSON_OUTPUT) {
|
||||
console.log(`Sleep Monitor listening on UDP :${PORT} (window ${WINDOW_SEC}s)`);
|
||||
console.log('DISCLAIMER: Informational only. Not a medical device.\n');
|
||||
}
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
if (!JSON_OUTPUT) {
|
||||
console.log('\n' + '='.repeat(60));
|
||||
const sum = session.summary();
|
||||
if (sum) {
|
||||
console.log(`Session: ${sum.totalRecordedMin.toFixed(1)} min | Sleep: ${sum.totalSleepMin.toFixed(1)} min | Efficiency: ${sum.sleepEfficiency.toFixed(1)}%`);
|
||||
}
|
||||
}
|
||||
server.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry
|
||||
// ---------------------------------------------------------------------------
|
||||
if (args.replay) {
|
||||
startReplay(args.replay);
|
||||
} else {
|
||||
startLive();
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ADR-077: Stress Monitor — HRV-based emotional state detection
|
||||
*
|
||||
* Computes RMSSD and LF/HF ratio from heart rate time series to produce
|
||||
* a stress score (0-100). Uses 5-minute sliding windows with FFT analysis.
|
||||
*
|
||||
* DISCLAIMER: This is an informational wellness tool, NOT a medical device.
|
||||
* Do not use for clinical diagnosis.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/stress-monitor.js --replay data/recordings/overnight-1775217646.csi.jsonl
|
||||
* node scripts/stress-monitor.js --port 5006
|
||||
* node scripts/stress-monitor.js --replay FILE --json
|
||||
*
|
||||
* ADR: docs/adr/ADR-077-novel-rf-sensing-applications.md
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const dgram = require('dgram');
|
||||
const fs = require('fs');
|
||||
const readline = require('readline');
|
||||
const { parseArgs } = require('util');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
port: { type: 'string', short: 'p', default: '5006' },
|
||||
replay: { type: 'string', short: 'r' },
|
||||
json: { type: 'boolean', default: false },
|
||||
interval: { type: 'string', short: 'i', default: '5000' },
|
||||
window: { type: 'string', short: 'w', default: '300' },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
const PORT = parseInt(args.port, 10);
|
||||
const JSON_OUTPUT = args.json;
|
||||
const INTERVAL_MS = parseInt(args.interval, 10);
|
||||
const WINDOW_SEC = parseInt(args.window, 10);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ADR-018 packet constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const VITALS_MAGIC = 0xC5110002;
|
||||
const FUSED_MAGIC = 0xC5110004;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simple FFT (radix-2 DIT, power-of-2 only)
|
||||
// ---------------------------------------------------------------------------
|
||||
function fft(re, im) {
|
||||
const n = re.length;
|
||||
if (n <= 1) return;
|
||||
|
||||
// Bit-reversal permutation
|
||||
for (let i = 1, j = 0; i < n; i++) {
|
||||
let bit = n >> 1;
|
||||
for (; j & bit; bit >>= 1) {
|
||||
j ^= bit;
|
||||
}
|
||||
j ^= bit;
|
||||
if (i < j) {
|
||||
[re[i], re[j]] = [re[j], re[i]];
|
||||
[im[i], im[j]] = [im[j], im[i]];
|
||||
}
|
||||
}
|
||||
|
||||
// Cooley-Tukey
|
||||
for (let len = 2; len <= n; len *= 2) {
|
||||
const half = len / 2;
|
||||
const angle = -2 * Math.PI / len;
|
||||
const wRe = Math.cos(angle);
|
||||
const wIm = Math.sin(angle);
|
||||
|
||||
for (let i = 0; i < n; i += len) {
|
||||
let curRe = 1, curIm = 0;
|
||||
for (let j = 0; j < half; j++) {
|
||||
const tRe = curRe * re[i + j + half] - curIm * im[i + j + half];
|
||||
const tIm = curRe * im[i + j + half] + curIm * re[i + j + half];
|
||||
|
||||
re[i + j + half] = re[i + j] - tRe;
|
||||
im[i + j + half] = im[i + j] - tIm;
|
||||
re[i + j] += tRe;
|
||||
im[i + j] += tIm;
|
||||
|
||||
const newCurRe = curRe * wRe - curIm * wIm;
|
||||
curIm = curRe * wIm + curIm * wRe;
|
||||
curRe = newCurRe;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function nextPow2(n) {
|
||||
let p = 1;
|
||||
while (p < n) p *= 2;
|
||||
return p;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HRV analysis engine
|
||||
// ---------------------------------------------------------------------------
|
||||
class HRVAnalyzer {
|
||||
constructor(windowSec) {
|
||||
this.windowSec = windowSec;
|
||||
this.hrSamples = []; // { timestamp, hr }
|
||||
this.history = []; // { timestamp, rmssd, lfhf, stress, motionMean }
|
||||
this.maxHistory = 500;
|
||||
}
|
||||
|
||||
push(timestamp, hr, motion) {
|
||||
this.hrSamples.push({ timestamp, hr, motion: motion || 0 });
|
||||
// Prune old samples
|
||||
const cutoff = timestamp - this.windowSec;
|
||||
while (this.hrSamples.length > 0 && this.hrSamples[0].timestamp < cutoff) {
|
||||
this.hrSamples.shift();
|
||||
}
|
||||
}
|
||||
|
||||
analyze(timestamp) {
|
||||
const samples = this.hrSamples;
|
||||
const n = samples.length;
|
||||
if (n < 10) return null;
|
||||
|
||||
// Compute RR intervals (from HR in BPM -> interval in ms)
|
||||
// HR = 60000 / RR_ms, so RR_ms = 60000 / HR
|
||||
const rr = [];
|
||||
for (const s of samples) {
|
||||
if (s.hr > 20 && s.hr < 200) {
|
||||
rr.push(60000 / s.hr);
|
||||
}
|
||||
}
|
||||
if (rr.length < 5) return null;
|
||||
|
||||
// RMSSD: root mean square of successive differences
|
||||
let sumSqDiff = 0;
|
||||
let diffCount = 0;
|
||||
for (let i = 1; i < rr.length; i++) {
|
||||
const diff = rr[i] - rr[i - 1];
|
||||
sumSqDiff += diff * diff;
|
||||
diffCount++;
|
||||
}
|
||||
const rmssd = diffCount > 0 ? Math.sqrt(sumSqDiff / diffCount) : 0;
|
||||
|
||||
// FFT-based LF/HF ratio
|
||||
// Resample RR series to uniform ~1 Hz for FFT
|
||||
const fs = 1.0; // 1 Hz sampling (approximate, given ~1 Hz vitals)
|
||||
const nfft = nextPow2(Math.max(rr.length, 64));
|
||||
const re = new Float64Array(nfft);
|
||||
const im = new Float64Array(nfft);
|
||||
|
||||
// De-mean and window (Hann)
|
||||
const mean = rr.reduce((a, b) => a + b, 0) / rr.length;
|
||||
for (let i = 0; i < rr.length; i++) {
|
||||
const hann = 0.5 * (1 - Math.cos(2 * Math.PI * i / (rr.length - 1)));
|
||||
re[i] = (rr[i] - mean) * hann;
|
||||
}
|
||||
|
||||
fft(re, im);
|
||||
|
||||
// Compute power spectral density
|
||||
const freqRes = fs / nfft;
|
||||
let lfPower = 0, hfPower = 0;
|
||||
for (let k = 0; k < nfft / 2; k++) {
|
||||
const freq = k * freqRes;
|
||||
const power = re[k] * re[k] + im[k] * im[k];
|
||||
|
||||
if (freq >= 0.04 && freq <= 0.15) lfPower += power;
|
||||
if (freq >= 0.15 && freq <= 0.40) hfPower += power;
|
||||
}
|
||||
|
||||
const lfhf = hfPower > 0.001 ? lfPower / hfPower : 0;
|
||||
|
||||
// Stress score (0-100)
|
||||
// High RMSSD = relaxed (low stress), high LF/HF = stressed
|
||||
const maxRmssd = 100; // typical max RMSSD for WiFi-derived HR
|
||||
const rmssdNorm = Math.min(rmssd / maxRmssd, 1.0);
|
||||
const lfhfNorm = Math.min(lfhf / 4.0, 1.0);
|
||||
const stress = Math.round(50 * (1 - rmssdNorm) + 50 * lfhfNorm);
|
||||
|
||||
// Average motion in window
|
||||
let motionSum = 0;
|
||||
for (const s of samples) motionSum += s.motion;
|
||||
const motionMean = motionSum / n;
|
||||
|
||||
// HR stats
|
||||
const hrValues = samples.map(s => s.hr).filter(h => h > 20 && h < 200);
|
||||
const hrMean = hrValues.reduce((a, b) => a + b, 0) / hrValues.length;
|
||||
|
||||
const result = {
|
||||
timestamp,
|
||||
rmssd: +rmssd.toFixed(2),
|
||||
lfPower: +lfPower.toFixed(2),
|
||||
hfPower: +hfPower.toFixed(2),
|
||||
lfhf: +lfhf.toFixed(3),
|
||||
stress,
|
||||
hrMean: +hrMean.toFixed(1),
|
||||
motionMean: +motionMean.toFixed(3),
|
||||
samples: n,
|
||||
};
|
||||
|
||||
this.history.push(result);
|
||||
if (this.history.length > this.maxHistory) this.history.shift();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
stressLabel(score) {
|
||||
if (score < 20) return 'Very relaxed';
|
||||
if (score < 40) return 'Relaxed';
|
||||
if (score < 60) return 'Moderate';
|
||||
if (score < 80) return 'Stressed';
|
||||
return 'Very stressed';
|
||||
}
|
||||
|
||||
renderTrend(width) {
|
||||
const w = width || 50;
|
||||
if (this.history.length === 0) return 'No data yet.';
|
||||
|
||||
const step = Math.max(1, Math.floor(this.history.length / w));
|
||||
const bars = ['\u2581', '\u2582', '\u2583', '\u2584', '\u2585', '\u2586', '\u2587', '\u2588'];
|
||||
|
||||
let line = '';
|
||||
for (let i = 0; i < this.history.length; i += step) {
|
||||
const s = this.history[i].stress;
|
||||
const idx = Math.min(7, Math.floor(s / 12.5));
|
||||
line += bars[idx];
|
||||
}
|
||||
return `Stress trend: ${line} (low)\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588(high)`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Packet parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseVitalsJsonl(record) {
|
||||
if (record.type !== 'vitals') return null;
|
||||
return {
|
||||
timestamp: record.timestamp,
|
||||
nodeId: record.node_id,
|
||||
hr: record.heartrate_bpm || 0,
|
||||
motion: record.motion_energy || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function parseVitalsUdp(buf) {
|
||||
if (buf.length < 32) return null;
|
||||
const magic = buf.readUInt32LE(0);
|
||||
if (magic !== VITALS_MAGIC && magic !== FUSED_MAGIC) return null;
|
||||
return {
|
||||
timestamp: Date.now() / 1000,
|
||||
nodeId: buf.readUInt8(4),
|
||||
hr: buf.readUInt32LE(8) / 10000,
|
||||
motion: buf.readFloatLE(16),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Replay mode
|
||||
// ---------------------------------------------------------------------------
|
||||
async function startReplay(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`File not found: ${filePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const analyzer = new HRVAnalyzer(WINDOW_SEC);
|
||||
const rl = readline.createInterface({
|
||||
input: fs.createReadStream(filePath),
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
let vitalsCount = 0;
|
||||
let lastAnalysisTs = 0;
|
||||
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) continue;
|
||||
let record;
|
||||
try { record = JSON.parse(line); } catch { continue; }
|
||||
|
||||
const v = parseVitalsJsonl(record);
|
||||
if (!v) continue;
|
||||
|
||||
analyzer.push(v.timestamp, v.hr, v.motion);
|
||||
vitalsCount++;
|
||||
|
||||
const tsMs = v.timestamp * 1000;
|
||||
if (lastAnalysisTs === 0) lastAnalysisTs = tsMs;
|
||||
|
||||
if (tsMs - lastAnalysisTs >= INTERVAL_MS) {
|
||||
const result = analyzer.analyze(v.timestamp);
|
||||
|
||||
if (result) {
|
||||
if (JSON_OUTPUT) {
|
||||
console.log(JSON.stringify(result));
|
||||
} else {
|
||||
const ts = new Date(v.timestamp * 1000).toISOString().slice(11, 19);
|
||||
const label = analyzer.stressLabel(result.stress);
|
||||
const bar = '\u2588'.repeat(Math.round(result.stress / 5));
|
||||
console.log(`[${ts}] Stress: ${String(result.stress).padStart(3)}/100 ${bar.padEnd(20)} ${label} | RMSSD ${result.rmssd} | LF/HF ${result.lfhf} | HR ${result.hrMean} | Motion ${result.motionMean}`);
|
||||
}
|
||||
}
|
||||
|
||||
lastAnalysisTs = tsMs;
|
||||
}
|
||||
}
|
||||
|
||||
// Final summary
|
||||
if (!JSON_OUTPUT) {
|
||||
console.log('\n' + '='.repeat(70));
|
||||
console.log('STRESS ANALYSIS SUMMARY');
|
||||
console.log('DISCLAIMER: Informational only. Not a medical device.');
|
||||
console.log('='.repeat(70));
|
||||
|
||||
if (analyzer.history.length > 0) {
|
||||
const scores = analyzer.history.map(h => h.stress);
|
||||
const avg = scores.reduce((a, b) => a + b, 0) / scores.length;
|
||||
const min = Math.min(...scores);
|
||||
const max = Math.max(...scores);
|
||||
|
||||
console.log(`Average stress: ${avg.toFixed(0)}/100 (${analyzer.stressLabel(avg)})`);
|
||||
console.log(`Range: ${min} - ${max}`);
|
||||
console.log(`Windows: ${analyzer.history.length}`);
|
||||
console.log('');
|
||||
console.log(analyzer.renderTrend(60));
|
||||
|
||||
// Activity correlation
|
||||
const highMotion = analyzer.history.filter(h => h.motionMean > 3.0);
|
||||
const lowMotion = analyzer.history.filter(h => h.motionMean < 1.0);
|
||||
if (highMotion.length > 0 && lowMotion.length > 0) {
|
||||
const avgHigh = highMotion.reduce((s, h) => s + h.stress, 0) / highMotion.length;
|
||||
const avgLow = lowMotion.reduce((s, h) => s + h.stress, 0) / lowMotion.length;
|
||||
console.log('');
|
||||
console.log(`Activity correlation:`);
|
||||
console.log(` Active periods (motion > 3): avg stress ${avgHigh.toFixed(0)} (${highMotion.length} windows)`);
|
||||
console.log(` Rest periods (motion < 1): avg stress ${avgLow.toFixed(0)} (${lowMotion.length} windows)`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nProcessed ${vitalsCount} vitals packets`);
|
||||
} else {
|
||||
if (analyzer.history.length > 0) {
|
||||
const scores = analyzer.history.map(h => h.stress);
|
||||
console.log(JSON.stringify({
|
||||
type: 'summary',
|
||||
avg_stress: +(scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(1),
|
||||
min_stress: Math.min(...scores),
|
||||
max_stress: Math.max(...scores),
|
||||
windows: analyzer.history.length,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live UDP mode
|
||||
// ---------------------------------------------------------------------------
|
||||
function startLive() {
|
||||
const analyzer = new HRVAnalyzer(WINDOW_SEC);
|
||||
const server = dgram.createSocket('udp4');
|
||||
|
||||
server.on('message', (buf) => {
|
||||
const v = parseVitalsUdp(buf);
|
||||
if (v) {
|
||||
analyzer.push(v.timestamp, v.hr, v.motion);
|
||||
}
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
const result = analyzer.analyze(Date.now() / 1000);
|
||||
|
||||
if (JSON_OUTPUT) {
|
||||
if (result) console.log(JSON.stringify(result));
|
||||
} else {
|
||||
process.stdout.write('\x1B[2J\x1B[H');
|
||||
console.log('=== STRESS MONITOR (ADR-077) ===');
|
||||
console.log('DISCLAIMER: Informational only. Not a medical device.');
|
||||
console.log('');
|
||||
|
||||
if (result) {
|
||||
const label = analyzer.stressLabel(result.stress);
|
||||
const bar = '\u2588'.repeat(Math.round(result.stress / 5));
|
||||
console.log(`Stress: ${result.stress}/100 ${bar} ${label}`);
|
||||
console.log(`RMSSD: ${result.rmssd} ms | LF/HF: ${result.lfhf}`);
|
||||
console.log(`HR: ${result.hrMean} BPM | Motion: ${result.motionMean}`);
|
||||
console.log(`Window: ${result.samples} samples`);
|
||||
console.log('');
|
||||
console.log(analyzer.renderTrend(50));
|
||||
} else {
|
||||
console.log('Collecting data...');
|
||||
}
|
||||
}
|
||||
}, INTERVAL_MS);
|
||||
|
||||
server.bind(PORT, () => {
|
||||
if (!JSON_OUTPUT) {
|
||||
console.log(`Stress Monitor listening on UDP :${PORT} (window ${WINDOW_SEC}s)`);
|
||||
}
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => { server.close(); process.exit(0); });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry
|
||||
// ---------------------------------------------------------------------------
|
||||
if (args.replay) {
|
||||
startReplay(args.replay);
|
||||
} else {
|
||||
startLive();
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Through-Wall Motion Detection — Multi-Frequency Mesh Application
|
||||
*
|
||||
* Detects motion behind walls by exploiting the fact that lower WiFi frequencies
|
||||
* penetrate walls better than higher frequencies. With 6 channels spanning
|
||||
* 2412-2462 MHz, we can:
|
||||
*
|
||||
* 1. Baseline each channel's attenuation through the wall (calibration phase)
|
||||
* 2. Detect changes above baseline = motion behind wall
|
||||
* 3. Weight lower channels more heavily (better through-wall SNR)
|
||||
* 4. Cross-validate across channels (real motion is coherent; noise is not)
|
||||
*
|
||||
* Requires multi-frequency mesh scanning (ADR-073): 2 ESP32 nodes hopping
|
||||
* across channels 1, 3, 5, 6, 9, 11.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/through-wall-detector.js --calibrate 60
|
||||
* node scripts/through-wall-detector.js --port 5006 --duration 300
|
||||
* node scripts/through-wall-detector.js --replay data/recordings/overnight-1775217646.csi.jsonl
|
||||
* node scripts/through-wall-detector.js --threshold 3.0
|
||||
*
|
||||
* ADR: docs/adr/ADR-078-multifreq-mesh-applications.md
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const dgram = require('dgram');
|
||||
const fs = require('fs');
|
||||
const readline = require('readline');
|
||||
const { parseArgs } = require('util');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
port: { type: 'string', short: 'p', default: '5006' },
|
||||
duration: { type: 'string', short: 'd' },
|
||||
replay: { type: 'string', short: 'r' },
|
||||
interval: { type: 'string', short: 'i', default: '1000' },
|
||||
calibrate: { type: 'string', short: 'c', default: '30' },
|
||||
threshold: { type: 'string', short: 't', default: '2.5' },
|
||||
json: { type: 'boolean', default: false },
|
||||
'consecutive-frames': { type: 'string', default: '3' },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
const PORT = parseInt(args.port, 10);
|
||||
const DURATION_MS = args.duration ? parseInt(args.duration, 10) * 1000 : null;
|
||||
const INTERVAL_MS = parseInt(args.interval, 10);
|
||||
const CALIBRATE_S = parseInt(args.calibrate, 10);
|
||||
const ALERT_THRESHOLD = parseFloat(args.threshold);
|
||||
const CONSECUTIVE_FRAMES = parseInt(args['consecutive-frames'], 10);
|
||||
const JSON_OUTPUT = args.json;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const CSI_MAGIC = 0xC5110001;
|
||||
const HEADER_SIZE = 20;
|
||||
|
||||
const CHANNEL_FREQ = {};
|
||||
for (let ch = 1; ch <= 13; ch++) CHANNEL_FREQ[ch] = 2412 + (ch - 1) * 5;
|
||||
|
||||
const NODE1_CHANNELS = [1, 6, 11];
|
||||
const NODE2_CHANNELS = [3, 5, 9];
|
||||
|
||||
// Channel penetration weights: lower freq = better wall penetration
|
||||
// Approximate wall loss at each channel for drywall+stud:
|
||||
// ch1 (2412 MHz) = 2.5 dB, ch11 (2462 MHz) = 2.7 dB
|
||||
// Weight inversely proportional to loss
|
||||
const PENETRATION_WEIGHT = {
|
||||
1: 1.00, // 2412 MHz - best penetration
|
||||
3: 0.96,
|
||||
5: 0.92,
|
||||
6: 0.90,
|
||||
9: 0.85,
|
||||
11: 0.80, // 2462 MHz - worst penetration
|
||||
};
|
||||
|
||||
// Status display
|
||||
const STATUS = {
|
||||
CALIBRATING: 'CALIBRATING',
|
||||
MONITORING: 'MONITORING',
|
||||
ALERT: 'ALERT',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-channel baseline
|
||||
// ---------------------------------------------------------------------------
|
||||
class ChannelBaseline {
|
||||
constructor(channel) {
|
||||
this.channel = channel;
|
||||
this.freqMhz = CHANNEL_FREQ[channel] || 2432;
|
||||
this.weight = PENETRATION_WEIGHT[channel] || 0.9;
|
||||
|
||||
// Welford online mean/variance
|
||||
this.nSub = 0;
|
||||
this.count = 0;
|
||||
this.mean = null; // Float64Array
|
||||
this.m2 = null; // Float64Array
|
||||
this.calibrated = false;
|
||||
}
|
||||
|
||||
/** Ingest a frame during calibration */
|
||||
calibrate(amplitudes) {
|
||||
const n = amplitudes.length;
|
||||
if (!this.mean) {
|
||||
this.nSub = n;
|
||||
this.mean = new Float64Array(n);
|
||||
this.m2 = new Float64Array(n);
|
||||
}
|
||||
|
||||
this.count++;
|
||||
for (let i = 0; i < n && i < this.nSub; i++) {
|
||||
const delta = amplitudes[i] - this.mean[i];
|
||||
this.mean[i] += delta / this.count;
|
||||
const delta2 = amplitudes[i] - this.mean[i];
|
||||
this.m2[i] += delta * delta2;
|
||||
}
|
||||
}
|
||||
|
||||
/** Finalize calibration */
|
||||
finalize() {
|
||||
if (this.count < 5) return;
|
||||
this.calibrated = true;
|
||||
}
|
||||
|
||||
/** Get standard deviation per subcarrier */
|
||||
getStd() {
|
||||
if (!this.mean || this.count < 2) return null;
|
||||
const std = new Float64Array(this.nSub);
|
||||
for (let i = 0; i < this.nSub; i++) {
|
||||
std[i] = Math.sqrt(this.m2[i] / (this.count - 1));
|
||||
// Minimum std to avoid division by zero
|
||||
if (std[i] < 0.1) std[i] = 0.1;
|
||||
}
|
||||
return std;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute deviation score for a new frame.
|
||||
* Score = mean(|amplitude - baseline_mean| / baseline_std) across subcarriers
|
||||
*/
|
||||
computeDeviation(amplitudes) {
|
||||
if (!this.calibrated || !this.mean) return 0;
|
||||
|
||||
const std = this.getStd();
|
||||
if (!std) return 0;
|
||||
|
||||
let sumDeviation = 0;
|
||||
let count = 0;
|
||||
for (let i = 0; i < amplitudes.length && i < this.nSub; i++) {
|
||||
const z = Math.abs(amplitudes[i] - this.mean[i]) / std[i];
|
||||
sumDeviation += z;
|
||||
count++;
|
||||
}
|
||||
|
||||
return count > 0 ? sumDeviation / count : 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Through-wall detector
|
||||
// ---------------------------------------------------------------------------
|
||||
class ThroughWallDetector {
|
||||
constructor(calibrateDuration, alertThreshold, consecutiveFrames) {
|
||||
this.calibrateDuration = calibrateDuration;
|
||||
this.alertThreshold = alertThreshold;
|
||||
this.consecutiveFrames = consecutiveFrames;
|
||||
|
||||
this.baselines = new Map(); // channel -> ChannelBaseline
|
||||
this.status = STATUS.CALIBRATING;
|
||||
this.startTime = null;
|
||||
|
||||
// Detection state
|
||||
this.perChannelScores = new Map();
|
||||
this.fusedScore = 0;
|
||||
this.alertStreak = 0;
|
||||
this.alertActive = false;
|
||||
this.alerts = [];
|
||||
|
||||
// History for display
|
||||
this.scoreHistory = []; // { timestamp, fusedScore, perChannel }
|
||||
this.maxHistory = 60;
|
||||
|
||||
this.totalFrames = 0;
|
||||
}
|
||||
|
||||
ingestFrame(channel, amplitudes, timestamp) {
|
||||
this.totalFrames++;
|
||||
|
||||
if (!this.startTime) this.startTime = timestamp;
|
||||
|
||||
// Get or create baseline
|
||||
if (!this.baselines.has(channel)) {
|
||||
this.baselines.set(channel, new ChannelBaseline(channel));
|
||||
}
|
||||
const baseline = this.baselines.get(channel);
|
||||
|
||||
// Calibration phase
|
||||
if (this.status === STATUS.CALIBRATING) {
|
||||
baseline.calibrate(amplitudes);
|
||||
|
||||
if (timestamp - this.startTime >= this.calibrateDuration) {
|
||||
// Finalize all baselines
|
||||
for (const bl of this.baselines.values()) bl.finalize();
|
||||
this.status = STATUS.MONITORING;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Detection phase
|
||||
const deviation = baseline.computeDeviation(amplitudes);
|
||||
const weight = PENETRATION_WEIGHT[channel] || 0.9;
|
||||
const weightedScore = deviation * weight;
|
||||
|
||||
this.perChannelScores.set(channel, {
|
||||
deviation: deviation,
|
||||
weighted: weightedScore,
|
||||
channel,
|
||||
freqMhz: CHANNEL_FREQ[channel],
|
||||
});
|
||||
|
||||
// Fused score: weighted average across all channels
|
||||
let sumWeighted = 0, sumWeights = 0;
|
||||
for (const [ch, score] of this.perChannelScores) {
|
||||
sumWeighted += score.weighted;
|
||||
sumWeights += PENETRATION_WEIGHT[ch] || 0.9;
|
||||
}
|
||||
this.fusedScore = sumWeights > 0 ? sumWeighted / sumWeights : 0;
|
||||
|
||||
// Cross-channel coherence: how many channels agree on motion?
|
||||
let agreeCount = 0;
|
||||
for (const score of this.perChannelScores.values()) {
|
||||
if (score.deviation > this.alertThreshold * 0.5) agreeCount++;
|
||||
}
|
||||
const coherence = this.perChannelScores.size > 0
|
||||
? agreeCount / this.perChannelScores.size
|
||||
: 0;
|
||||
|
||||
// Alert logic
|
||||
if (this.fusedScore > this.alertThreshold && coherence > 0.4) {
|
||||
this.alertStreak++;
|
||||
} else {
|
||||
this.alertStreak = Math.max(0, this.alertStreak - 1);
|
||||
}
|
||||
|
||||
const wasAlert = this.alertActive;
|
||||
this.alertActive = this.alertStreak >= this.consecutiveFrames;
|
||||
|
||||
if (this.alertActive && !wasAlert) {
|
||||
this.status = STATUS.ALERT;
|
||||
this.alerts.push({
|
||||
timestamp,
|
||||
fusedScore: this.fusedScore,
|
||||
coherence,
|
||||
channels: [...this.perChannelScores.values()].map(s => ({
|
||||
ch: s.channel, dev: s.deviation.toFixed(2),
|
||||
})),
|
||||
});
|
||||
} else if (!this.alertActive && wasAlert) {
|
||||
this.status = STATUS.MONITORING;
|
||||
}
|
||||
|
||||
// Store history
|
||||
this.scoreHistory.push({
|
||||
timestamp,
|
||||
fusedScore: this.fusedScore,
|
||||
coherence,
|
||||
perChannel: [...this.perChannelScores.entries()].map(([ch, s]) => ({
|
||||
ch, dev: s.deviation.toFixed(2), weight: (PENETRATION_WEIGHT[ch] || 0.9).toFixed(2),
|
||||
})),
|
||||
});
|
||||
if (this.scoreHistory.length > this.maxHistory) this.scoreHistory.shift();
|
||||
}
|
||||
|
||||
getState() {
|
||||
return {
|
||||
status: this.status,
|
||||
fusedScore: this.fusedScore,
|
||||
alertActive: this.alertActive,
|
||||
alertStreak: this.alertStreak,
|
||||
totalFrames: this.totalFrames,
|
||||
calibratedChannels: [...this.baselines.values()]
|
||||
.filter(b => b.calibrated)
|
||||
.map(b => b.channel)
|
||||
.sort((a, b) => a - b),
|
||||
perChannelScores: [...this.perChannelScores.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([ch, s]) => ({ ch, deviation: s.deviation.toFixed(2), weighted: s.weighted.toFixed(2) })),
|
||||
alertCount: this.alerts.length,
|
||||
scoreHistory: this.scoreHistory,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSI parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseIqHex(iqHex, nSubcarriers) {
|
||||
const bytes = Buffer.from(iqHex, 'hex');
|
||||
const amplitudes = new Float64Array(nSubcarriers);
|
||||
|
||||
for (let sc = 0; sc < nSubcarriers; sc++) {
|
||||
const offset = 2 + sc * 2;
|
||||
if (offset + 1 >= bytes.length) break;
|
||||
let I = bytes[offset];
|
||||
let Q = bytes[offset + 1];
|
||||
if (I > 127) I -= 256;
|
||||
if (Q > 127) Q -= 256;
|
||||
amplitudes[sc] = Math.sqrt(I * I + Q * Q);
|
||||
}
|
||||
|
||||
return amplitudes;
|
||||
}
|
||||
|
||||
function parseCSIFrame(buf) {
|
||||
if (buf.length < HEADER_SIZE) return null;
|
||||
const magic = buf.readUInt32LE(0);
|
||||
if (magic !== CSI_MAGIC) return null;
|
||||
|
||||
const nodeId = buf.readUInt8(4);
|
||||
const nSubcarriers = buf.readUInt16LE(6);
|
||||
const freqMhz = buf.readUInt32LE(8);
|
||||
|
||||
const amplitudes = new Float64Array(nSubcarriers);
|
||||
for (let sc = 0; sc < nSubcarriers; sc++) {
|
||||
const offset = HEADER_SIZE + sc * 2;
|
||||
if (offset + 1 >= buf.length) break;
|
||||
const I = buf.readInt8(offset);
|
||||
const Q = buf.readInt8(offset + 1);
|
||||
amplitudes[sc] = Math.sqrt(I * I + Q * Q);
|
||||
}
|
||||
|
||||
let channel = 0;
|
||||
if (freqMhz >= 2412 && freqMhz <= 2484) {
|
||||
channel = freqMhz === 2484 ? 14 : Math.round((freqMhz - 2412) / 5) + 1;
|
||||
}
|
||||
|
||||
return { nodeId, nSubcarriers, freqMhz, amplitudes, channel };
|
||||
}
|
||||
|
||||
const nodeChannelIdx = { 1: 0, 2: 0 };
|
||||
function assignChannel(nodeId) {
|
||||
const channels = nodeId === 1 ? NODE1_CHANNELS : NODE2_CHANNELS;
|
||||
const ch = channels[nodeChannelIdx[nodeId] % channels.length];
|
||||
nodeChannelIdx[nodeId]++;
|
||||
return ch;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Visualization
|
||||
// ---------------------------------------------------------------------------
|
||||
function renderStatus(detector) {
|
||||
const state = detector.getState();
|
||||
const lines = [];
|
||||
|
||||
lines.push('');
|
||||
lines.push(' THROUGH-WALL MOTION DETECTOR');
|
||||
lines.push(' ' + '='.repeat(55));
|
||||
lines.push('');
|
||||
|
||||
// Status banner
|
||||
const statusBanner = {
|
||||
[STATUS.CALIBRATING]: ' [ CALIBRATING ] Establishing wall baseline...',
|
||||
[STATUS.MONITORING]: ' [ MONITORING ] Watching for through-wall motion',
|
||||
[STATUS.ALERT]: ' [ ** ALERT ** ] Motion detected behind wall!',
|
||||
};
|
||||
lines.push(statusBanner[state.status] || ` [ ${state.status} ]`);
|
||||
lines.push('');
|
||||
|
||||
if (state.status === STATUS.CALIBRATING) {
|
||||
const progress = Math.min(100, (state.totalFrames / (CALIBRATE_S * 12)) * 100);
|
||||
const barLen = Math.floor(progress / 2);
|
||||
const bar = '\u2588'.repeat(barLen) + '\u2591'.repeat(50 - barLen);
|
||||
lines.push(` Calibration progress: [${bar}] ${progress.toFixed(0)}%`);
|
||||
lines.push(` Frames collected: ${state.totalFrames}`);
|
||||
lines.push(` Channels: ${state.calibratedChannels.length > 0 ? state.calibratedChannels.join(', ') : 'accumulating...'}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Fused score meter
|
||||
const maxMeter = 40;
|
||||
const meterFill = Math.min(maxMeter, Math.floor((state.fusedScore / (ALERT_THRESHOLD * 2)) * maxMeter));
|
||||
const meterChar = state.alertActive ? '\u2588' : '\u2593';
|
||||
const meterEmpty = '\u2591';
|
||||
const meter = meterChar.repeat(meterFill) + meterEmpty.repeat(maxMeter - meterFill);
|
||||
const threshMark = Math.floor((ALERT_THRESHOLD / (ALERT_THRESHOLD * 2)) * maxMeter);
|
||||
lines.push(` Fused score: [${meter}] ${state.fusedScore.toFixed(2)}`);
|
||||
lines.push(` ${''.padStart(15 + threshMark)}^ threshold=${ALERT_THRESHOLD}`);
|
||||
|
||||
// Per-channel breakdown
|
||||
lines.push('');
|
||||
lines.push(' Per-Channel Deviation (weighted by penetration quality):');
|
||||
lines.push(' ' + '-'.repeat(55));
|
||||
lines.push(' Ch Freq(MHz) Weight Deviation Weighted Status');
|
||||
|
||||
for (const score of state.perChannelScores) {
|
||||
const ch = score.ch;
|
||||
const freq = CHANNEL_FREQ[ch] || 0;
|
||||
const wt = (PENETRATION_WEIGHT[ch] || 0.9).toFixed(2);
|
||||
const dev = score.deviation;
|
||||
const wtd = score.weighted;
|
||||
const above = parseFloat(dev) > ALERT_THRESHOLD * 0.5;
|
||||
const marker = above ? ' <--' : '';
|
||||
lines.push(` ${String(ch).padStart(2)} ${freq} ${wt} ${dev.padStart(6)} ${wtd.padStart(6)} ${marker}`);
|
||||
}
|
||||
|
||||
// Score timeline (last 30 readings)
|
||||
const history = state.scoreHistory.slice(-30);
|
||||
if (history.length > 0) {
|
||||
lines.push('');
|
||||
lines.push(' Score Timeline (last 30 readings):');
|
||||
const SPARK = '\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588';
|
||||
let timeline = ' ';
|
||||
for (const h of history) {
|
||||
const level = Math.min(7, Math.floor((h.fusedScore / (ALERT_THRESHOLD * 2)) * 7.99));
|
||||
timeline += SPARK[level];
|
||||
}
|
||||
lines.push(timeline);
|
||||
lines.push(` ${''.padStart(2)}${'oldest'.padEnd(15)}${''.padEnd(Math.max(0, history.length - 21))}newest`);
|
||||
}
|
||||
|
||||
// Alert summary
|
||||
lines.push('');
|
||||
lines.push(` Alert history: ${state.alertCount} alert(s)`);
|
||||
lines.push(` Consecutive frames above threshold: ${state.alertStreak}/${CONSECUTIVE_FRAMES}`);
|
||||
lines.push(` Calibrated channels: ${state.calibratedChannels.join(', ')}`);
|
||||
lines.push(` Total frames: ${state.totalFrames}`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global state
|
||||
// ---------------------------------------------------------------------------
|
||||
const detector = new ThroughWallDetector(CALIBRATE_S, ALERT_THRESHOLD, CONSECUTIVE_FRAMES);
|
||||
let lastDisplayMs = 0;
|
||||
|
||||
function displayUpdate() {
|
||||
const state = detector.getState();
|
||||
|
||||
if (JSON_OUTPUT) {
|
||||
console.log(JSON.stringify({
|
||||
timestamp: Date.now() / 1000,
|
||||
status: state.status,
|
||||
fusedScore: +state.fusedScore.toFixed(3),
|
||||
alertActive: state.alertActive,
|
||||
perChannel: state.perChannelScores,
|
||||
alertCount: state.alertCount,
|
||||
}));
|
||||
} else {
|
||||
process.stdout.write('\x1B[2J\x1B[H');
|
||||
console.log(renderStatus(detector));
|
||||
console.log('');
|
||||
console.log(' Press Ctrl+C to exit');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live mode
|
||||
// ---------------------------------------------------------------------------
|
||||
function startLive() {
|
||||
const sock = dgram.createSocket('udp4');
|
||||
|
||||
sock.on('message', (buf) => {
|
||||
if (buf.length < 4) return;
|
||||
const magic = buf.readUInt32LE(0);
|
||||
if (magic !== CSI_MAGIC) return;
|
||||
|
||||
const frame = parseCSIFrame(buf);
|
||||
if (!frame) return;
|
||||
|
||||
detector.ingestFrame(frame.channel, frame.amplitudes, Date.now() / 1000);
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastDisplayMs >= INTERVAL_MS) {
|
||||
displayUpdate();
|
||||
lastDisplayMs = now;
|
||||
}
|
||||
});
|
||||
|
||||
sock.bind(PORT, () => {
|
||||
if (!JSON_OUTPUT) {
|
||||
console.log(`Through-Wall Detector listening on UDP port ${PORT}`);
|
||||
console.log(`Calibration period: ${CALIBRATE_S}s`);
|
||||
console.log(`Alert threshold: ${ALERT_THRESHOLD}`);
|
||||
console.log('Waiting for CSI frames...');
|
||||
}
|
||||
});
|
||||
|
||||
if (DURATION_MS) {
|
||||
setTimeout(() => { displayUpdate(); sock.close(); process.exit(0); }, DURATION_MS);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Replay mode
|
||||
// ---------------------------------------------------------------------------
|
||||
async function startReplay(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`File not found: ${filePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: fs.createReadStream(filePath),
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
let frameCount = 0;
|
||||
let lastAnalysisTs = 0;
|
||||
let windowCount = 0;
|
||||
let firstAlertTs = null;
|
||||
let totalAlertWindows = 0;
|
||||
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
let record;
|
||||
try { record = JSON.parse(line); } catch { continue; }
|
||||
if (record.type !== 'raw_csi' || !record.iq_hex) continue;
|
||||
|
||||
const amplitudes = parseIqHex(record.iq_hex, record.subcarriers || 64);
|
||||
const channel = record.channel || assignChannel(record.node_id);
|
||||
|
||||
detector.ingestFrame(channel, amplitudes, record.timestamp);
|
||||
frameCount++;
|
||||
|
||||
const tsMs = record.timestamp * 1000;
|
||||
if (lastAnalysisTs === 0) lastAnalysisTs = tsMs;
|
||||
|
||||
if (tsMs - lastAnalysisTs >= INTERVAL_MS) {
|
||||
windowCount++;
|
||||
const state = detector.getState();
|
||||
|
||||
if (state.alertActive) {
|
||||
totalAlertWindows++;
|
||||
if (!firstAlertTs) firstAlertTs = record.timestamp;
|
||||
}
|
||||
|
||||
if (JSON_OUTPUT) {
|
||||
console.log(JSON.stringify({
|
||||
window: windowCount,
|
||||
timestamp: record.timestamp,
|
||||
status: state.status,
|
||||
fusedScore: +state.fusedScore.toFixed(3),
|
||||
alertActive: state.alertActive,
|
||||
}));
|
||||
} else {
|
||||
const statusTag = state.status === STATUS.ALERT ? ' ** ALERT **' :
|
||||
state.status === STATUS.CALIBRATING ? ' calibrating' : '';
|
||||
console.log(
|
||||
` [${windowCount.toString().padStart(4)}] t=${record.timestamp.toFixed(1)}s` +
|
||||
` score=${state.fusedScore.toFixed(2).padStart(5)}` +
|
||||
` channels=${state.calibratedChannels.length}` +
|
||||
` streak=${state.alertStreak}/${CONSECUTIVE_FRAMES}` +
|
||||
statusTag
|
||||
);
|
||||
}
|
||||
|
||||
lastAnalysisTs = tsMs;
|
||||
}
|
||||
}
|
||||
|
||||
// Final summary
|
||||
if (!JSON_OUTPUT) {
|
||||
const state = detector.getState();
|
||||
console.log('');
|
||||
console.log('='.repeat(60));
|
||||
console.log('THROUGH-WALL DETECTION SUMMARY');
|
||||
console.log('='.repeat(60));
|
||||
console.log(` Total frames: ${frameCount}`);
|
||||
console.log(` Analysis windows: ${windowCount}`);
|
||||
console.log(` Calibrated channels: ${state.calibratedChannels.join(', ')}`);
|
||||
console.log(` Alert windows: ${totalAlertWindows} / ${windowCount} (${windowCount > 0 ? (totalAlertWindows / windowCount * 100).toFixed(1) : 0}%)`);
|
||||
console.log(` Total alerts: ${state.alertCount}`);
|
||||
if (firstAlertTs) {
|
||||
console.log(` First alert at: t=${firstAlertTs.toFixed(1)}s`);
|
||||
}
|
||||
console.log(` Threshold: ${ALERT_THRESHOLD}, Consecutive frames: ${CONSECUTIVE_FRAMES}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
if (args.replay) {
|
||||
startReplay(args.replay);
|
||||
} else {
|
||||
startLive();
|
||||
}
|
||||
Reference in New Issue
Block a user