feat: ADR-073 multi-frequency mesh RF scanning

Live RF room scanner with ASCII spectrum visualization:
- rf-scan.js: single-channel scanner with null/dynamic/reflector classification,
  cross-node correlation, phase coherence, Unicode spectrum display
- rf-scan-multifreq.js: wideband view merging 6 channels, null diversity,
  per-channel penetration quality, frequency-dependent scatterer detection
- benchmark-rf-scan.js: null diversity gain, spectrum flatness, resolution estimate

Validated: 228 frames in 5s, 23 fps/node, 19% nulls detected,
0.993 cross-node correlation, line-of-sight confirmed

ADR-073: interleaved channel hopping (Node 1: ch 1/6/11, Node 2: ch 3/5/9)
targets 6x subcarrier diversity, <5% null gap, ~15cm resolution

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv 2026-04-03 00:18:29 -04:00
parent 8f2de7e9f2
commit b4c9e7743f
4 changed files with 2186 additions and 0 deletions

View file

@ -0,0 +1,187 @@
# ADR-073: Multi-Frequency Mesh Scanning
| Field | Value |
|-------------|--------------------------------------------|
| **Status** | Proposed |
| **Date** | 2026-04-02 |
| **Authors** | ruv |
| **Depends** | ADR-018 (binary frame), ADR-029 (channel hopping), ADR-039 (edge processing), ADR-060 (channel override) |
## Context
The current WiFi-DensePose deployment uses 2 ESP32-S3 nodes operating on a single WiFi channel (channel 5, 2432 MHz). A scan of the office environment reveals 9 WiFi networks across 6 distinct channels (1, 3, 5, 6, 9, 11), each broadcasting continuously. These neighbor networks are free RF illuminators whose signals pass through the room and interact with objects, people, and walls.
**Current single-channel limitations:**
1. **19% null subcarriers** — metal objects (desk, monitor frame, filing cabinet) create frequency-selective fading that blocks specific subcarriers on channel 5. These nulls are permanent blind spots in the RF map.
2. **No frequency diversity** — objects that are transparent at 2432 MHz may be opaque at 2412 MHz or 2462 MHz, and vice versa. A metal mesh that blocks one wavelength (122.5 mm at 2432 MHz) may pass another (124.0 mm at 2412 MHz) due to the mesh aperture-to-wavelength ratio.
3. **Single-perspective CSI** — both nodes see the same 52-64 subcarriers on the same channel. The subcarrier indices map to the same frequency bins, providing no spectral diversity.
4. **Neighbor illuminator waste** — 6 other APs broadcast continuously in the room. Their signals pass through walls, furniture, and people, creating CSI-measurable reflections that we currently ignore because we only listen on channel 5.
## Decision
Implement interleaved multi-frequency channel hopping across the 2 ESP32-S3 nodes, scanning 6 WiFi channels to build a wideband RF map of the room.
### Channel Allocation Strategy
The 2.4 GHz ISM band has 3 non-overlapping 20 MHz channels (1, 6, 11) and several partially-overlapping channels between them. We allocate channels to maximize both spectral coverage and illuminator exploitation:
```
Node 1: ch 1, 6, 11 (non-overlapping, full band coverage)
Node 2: ch 3, 5, 9 (interleaved, near neighbor APs)
```
**Rationale for this split:**
| Channel | Freq (MHz) | Node | Neighbor Illuminators | Purpose |
|---------|------------|------|----------------------------------------------|-----------------------------------|
| 1 | 2412 | 1 | (none visible, but lower freq = better penetration) | Low-frequency penetration |
| 3 | 2422 | 2 | conclusion mesh (signal 44) | Exploit neighbor AP as illuminator |
| 5 | 2432 | 2 | ruv.net (100), Cohen-Guest (100), HP LaserJet (94) | Primary channel, strongest illuminators |
| 6 | 2437 | 1 | Innanen (signal 19) | Center band, non-overlapping |
| 9 | 2452 | 2 | NETGEAR72 (42), NETGEAR72-Guest (42) | Exploit dual NETGEAR illuminators |
| 11 | 2462 | 1 | COGECO-21B20 (100), COGECO-4321 (30) | High-frequency, strong illuminators |
Each node dwells on a channel for 250 ms (configurable), collects 3-4 CSI frames, then hops to the next. The 3-channel rotation completes in 750 ms, giving ~1.3 full rotations per second.
### Physics Basis
At 2.4 GHz, WiFi wavelength ranges from 122.0 mm (ch 14, 2484 MHz) to 124.0 mm (ch 1, 2412 MHz). While this is a narrow range (~2%), the effect on multipath is significant:
1. **Frequency-selective fading**: multipath reflections create constructive/destructive interference patterns that vary with frequency. A 2 cm path length difference produces a null at 2432 MHz but constructive interference at 2412 MHz.
2. **Diffraction around objects**: Huygens-Fresnel diffraction depends on wavelength. Objects smaller than ~lambda/2 (61 mm) scatter differently across the band. Common office objects (monitor bezels, chair legs, cable bundles) are in this range.
3. **Material transparency**: some materials (wire mesh, perforated metal, PCB ground planes) have frequency-dependent transmission. A monitor's EMI shielding mesh with 5 mm apertures blocks 2.4 GHz signals but the exact attenuation varies with frequency due to slot antenna effects.
4. **Subcarrier orthogonality**: OFDM subcarriers on different channels are in different frequency bins. A null on subcarrier 15 of channel 5 does not imply a null on subcarrier 15 of channel 1, because they map to different absolute frequencies.
### Null Diversity Mechanism
```
Channel 5 subcarriers: ▅▆█▇▅▃▁_▁▃▅▆█▇▅▃▁_▁▃▅▆█▇▅▃
^ null (metal desk)
Channel 1 subcarriers: ▃▅▆█▇▅▃▅▆█▇▅▃▅▆█▇▅▃▅▆█▇▅▃▅▃
^ resolved! Different freq = different null pattern
Channel 11 subcarriers: ▅▃▁_▁▃▅▆█▇▅▃▅▆▅▃▁_▁▃▅▆█▇▅▃▅
^ null here instead (shifted by frequency offset)
```
By fusing subcarrier data across channels, nulls that exist on one channel are filled by non-null data from other channels. The remaining nulls (present on ALL channels) represent truly opaque objects — large metal surfaces that block all 2.4 GHz frequencies.
### Wideband View
Single channel: ~52-64 subcarriers (20 MHz bandwidth)
Multi-channel (6 channels): ~312-384 effective subcarrier observations (120 MHz coverage)
This is not simply 6x the resolution (the subcarrier spacing within each channel is the same), but it provides:
- 6x the spectral diversity for null mitigation
- 6x the illuminator variety (different APs = different signal paths)
- Frequency-dependent scattering signatures for material classification
## Integration
### Firmware (already supported)
The channel hopping infrastructure is already implemented in the ESP32 firmware (ADR-029):
```c
// csi_collector.h — already exists
void csi_collector_set_hop_table(const uint8_t *channels, uint8_t hop_count, uint32_t dwell_ms);
void csi_collector_start_hop_timer(void);
```
The ADR-018 binary frame header already includes the channel/frequency field at bytes [8..11], so the server-side parser can distinguish frames from different channels without any firmware changes.
### Provisioning Commands
```bash
# Node 1 (COM7): non-overlapping channels 1, 6, 11
python firmware/esp32-csi-node/provision.py --port COM7 \
--ssid "ruv.net" --password "..." --target-ip 192.168.1.20 \
--hop-channels 1,6,11 --hop-dwell-ms 250
# Node 2 (COM_): interleaved channels 3, 5, 9
python firmware/esp32-csi-node/provision.py --port COM_ \
--ssid "ruv.net" --password "..." --target-ip 192.168.1.20 \
--hop-channels 3,5,9 --hop-dwell-ms 250
```
Note: `--hop-channels` and `--hop-dwell-ms` require provision.py support for writing these values to NVS. If not yet implemented, the firmware's `csi_collector_set_hop_table()` can be called directly from the main init code with compile-time constants.
### Server-Side Processing
Three new Node.js scripts consume the multi-channel CSI data:
| Script | Purpose |
|--------|---------|
| `scripts/rf-scan.js` | Single-channel live RF room scanner with ASCII spectrum |
| `scripts/rf-scan-multifreq.js` | Multi-channel scanner with null diversity analysis |
| `scripts/benchmark-rf-scan.js` | Quantitative benchmark of multi-channel performance |
All scripts parse the ADR-018 binary UDP format and use the frequency field to separate frames by channel.
### Cognitum Seed Integration
The Cognitum Seed vector store (ADR-069) currently stores 1,605 vectors from single-channel CSI. With multi-frequency scanning:
1. **Per-channel feature vectors**: store separate 8-dim feature vectors for each channel, tagged with channel number. This increases the vector count to ~9,630 (6 channels x 1,605).
2. **Wideband feature vector**: concatenate or average per-channel features into a 48-dim wideband vector for richer kNN search. Objects that are ambiguous on one channel may be clearly distinguishable in the wideband representation.
3. **Null-aware embeddings**: encode null subcarrier patterns as part of the feature vector. The null pattern itself is informative — a consistent null at subcarrier 15 across all channels indicates a large metal object, while a null only on channel 5 indicates a frequency-dependent scatterer.
## Performance Targets
| Metric | Single-Channel Baseline | Multi-Channel Target | Method |
|--------|------------------------|---------------------|--------|
| Subcarrier count | ~52-64 | ~312-384 (6x) | 6 channels x 52-64 subcarriers |
| Null gap | 19% | <5% | Null diversity across channels |
| Position resolution | ~30 cm | ~15 cm | sqrt(6) improvement from independent observations |
| Per-channel FPS | 12 fps | ~4 fps | 250 ms dwell x 3 channels = 750 ms rotation |
| Total FPS (all channels) | 12 fps | ~12 fps per node (4 fps x 3 channels) |
| Wideband rotation | N/A | ~1.3 Hz | Full 3-channel rotation in 750 ms |
## Risks
### Per-Channel Sample Rate Reduction
Channel hopping reduces the per-channel sample rate from 12 fps (single channel) to approximately 4 fps per channel (250 ms dwell, 3 channels). This affects:
- **Vitals extraction**: breathing rate (0.1-0.5 Hz) requires at least 2 fps (Nyquist). At 4 fps per channel, this is met. Heart rate (0.8-2.0 Hz) requires at least 4 fps, which is marginal. Mitigation: keep one channel as "primary" with longer dwell for vitals, or fuse phase data across channels.
- **Motion tracking**: 4 fps is sufficient for walking speed (<2 m/s) but insufficient for fast gestures. If gesture recognition is needed, reduce to 2-channel hopping or increase dwell rate.
### Channel Hopping Latency
`esp_wifi_set_channel()` takes ~1-5 ms on ESP32-S3. During the transition, no CSI frames are captured. At 250 ms dwell, this is <2% overhead.
### AP Disconnection
Channel hopping may cause the ESP32 to lose connection to the home AP (ruv.net on channel 5) when dwelling on other channels. The STA reconnects automatically, but there may be brief UDP packet loss. Mitigation: the firmware already handles this gracefully — CSI collection works in promiscuous mode regardless of STA connection state.
### Increased Server Load
2 nodes x 3 channels x 4 fps = 24 frames/second total UDP traffic. Each frame is ~150-200 bytes (20-byte header + 64 subcarriers x 2 bytes I/Q). Total: ~4.8 KB/s — negligible.
## Alternatives Considered
1. **5 GHz channels**: ESP32-S3 supports 5 GHz CSI, and the shorter wavelength (60 mm) provides better spatial resolution. Rejected because: (a) no 5 GHz APs visible in the current environment, so no free illuminators; (b) 5 GHz has worse wall penetration, reducing the effective sensing volume.
2. **More nodes**: adding a 3rd or 4th ESP32 node would increase spatial diversity without channel hopping. Rejected for now due to cost, but this is complementary — more nodes + channel hopping would give both spatial and spectral diversity.
3. **Wider bandwidth (HT40)**: using 40 MHz channels doubles subcarrier count per channel. Rejected because: (a) HT40 requires a secondary channel, reducing available channels for hopping; (b) many neighbor APs use HT20, so their illumination only covers 20 MHz.
## References
- ADR-018: CSI binary frame format
- ADR-029: Channel hopping infrastructure
- ADR-039: Edge processing pipeline
- ADR-060: Channel override provisioning
- ADR-069: Cognitum Seed CSI pipeline
- IEEE 802.11-2020, Section 21 (OFDM PHY)
- ESP-IDF CSI Guide: https://docs.espressif.com/projects/esp-idf/en/v5.4/esp32s3/api-guides/wifi.html#wi-fi-channel-state-information

View file

@ -0,0 +1,533 @@
#!/usr/bin/env node
/**
* RuView RF Scan Benchmark
*
* Collects CSI frames from ESP32 nodes and computes quantitative metrics
* for single-channel and multi-channel scanning performance:
*
* - Frames per second per node per channel
* - Null subcarrier count per channel
* - Cross-channel null diversity (how many nulls are filled by other channels)
* - Subcarrier correlation across channels
* - Position accuracy improvement estimate
* - Spectrum flatness (lower = more objects)
*
* Usage:
* node scripts/benchmark-rf-scan.js --port 5006 --duration 30
* node scripts/benchmark-rf-scan.js --duration 60 --json
*
* ADR: docs/adr/ADR-073-multifrequency-mesh-scan.md
*/
'use strict';
const dgram = require('dgram');
const { parseArgs } = require('util');
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
const { values: args } = parseArgs({
options: {
port: { type: 'string', short: 'p', default: '5006' },
duration: { type: 'string', short: 'd', default: '30' },
json: { type: 'boolean', default: false },
},
strict: true,
});
const PORT = parseInt(args.port, 10);
const DURATION_S = parseInt(args.duration, 10);
const JSON_OUTPUT = args.json;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CSI_MAGIC = 0xC5110001;
const HEADER_SIZE = 20;
const NULL_THRESHOLD = 2.0;
// ---------------------------------------------------------------------------
// Data collection
// ---------------------------------------------------------------------------
/**
* Per-channel frame collector. Accumulates amplitude snapshots for analysis.
*/
class ChannelCollector {
constructor(channel) {
this.channel = channel;
this.freqMhz = 0;
this.frames = []; // array of { amplitudes, phases, rssi, timestamp }
this.nSubcarriers = 0;
}
add(amplitudes, phases, rssi, freqMhz) {
this.freqMhz = freqMhz;
this.nSubcarriers = amplitudes.length;
this.frames.push({
amplitudes: Float64Array.from(amplitudes),
phases: Float64Array.from(phases),
rssi,
timestamp: Date.now(),
});
}
}
class NodeCollector {
constructor(nodeId) {
this.nodeId = nodeId;
this.address = null;
this.channels = new Map(); // channel -> ChannelCollector
this.totalFrames = 0;
this.firstFrameMs = 0;
this.lastFrameMs = 0;
}
getOrCreate(channel) {
if (!this.channels.has(channel)) {
this.channels.set(channel, new ChannelCollector(channel));
}
return this.channels.get(channel);
}
}
const nodes = new Map();
let totalFrames = 0;
const startTime = Date.now();
// ---------------------------------------------------------------------------
// Packet parsing
// ---------------------------------------------------------------------------
function parseCSIFrame(buf) {
if (buf.length < HEADER_SIZE) return null;
if (buf.readUInt32LE(0) !== CSI_MAGIC) return null;
const nodeId = buf.readUInt8(4);
const nAntennas = buf.readUInt8(5) || 1;
const nSubcarriers = buf.readUInt16LE(6);
const freqMhz = buf.readUInt32LE(8);
const rssi = buf.readInt8(16);
const iqLen = nSubcarriers * nAntennas * 2;
if (buf.length < HEADER_SIZE + iqLen) return null;
const amplitudes = new Float64Array(nSubcarriers);
const phases = new Float64Array(nSubcarriers);
for (let sc = 0; sc < nSubcarriers; sc++) {
const offset = HEADER_SIZE + sc * 2;
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;
} else if (freqMhz >= 5180) {
channel = Math.round((freqMhz - 5000) / 5);
}
return { nodeId, nSubcarriers, freqMhz, rssi, amplitudes, phases, channel };
}
function handlePacket(buf, rinfo) {
if (buf.length < 4 || buf.readUInt32LE(0) !== CSI_MAGIC) return;
const frame = parseCSIFrame(buf);
if (!frame) return;
totalFrames++;
let node = nodes.get(frame.nodeId);
if (!node) {
node = new NodeCollector(frame.nodeId);
nodes.set(frame.nodeId, node);
}
node.address = rinfo.address;
node.totalFrames++;
const now = Date.now();
if (node.firstFrameMs === 0) node.firstFrameMs = now;
node.lastFrameMs = now;
const cc = node.getOrCreate(frame.channel);
cc.add(frame.amplitudes, frame.phases, frame.rssi, frame.freqMhz);
}
// ---------------------------------------------------------------------------
// Analysis
// ---------------------------------------------------------------------------
function computeMetrics() {
const results = {
duration_s: DURATION_S,
totalFrames,
nodes: [],
crossChannel: null,
summary: null,
};
for (const node of nodes.values()) {
const elapsed = (node.lastFrameMs - node.firstFrameMs) / 1000;
const nodeFps = elapsed > 0 ? node.totalFrames / elapsed : 0;
const channelMetrics = [];
for (const [ch, cc] of node.channels.entries()) {
if (cc.frames.length === 0) continue;
const n = cc.nSubcarriers;
const nFrames = cc.frames.length;
// FPS for this channel
let chFps = 0;
if (nFrames >= 2) {
const first = cc.frames[0].timestamp;
const last = cc.frames[nFrames - 1].timestamp;
const chElapsed = (last - first) / 1000;
chFps = chElapsed > 0 ? nFrames / chElapsed : 0;
}
// Average null count across frames
let totalNulls = 0;
for (const f of cc.frames) {
for (let i = 0; i < n; i++) {
if (f.amplitudes[i] < NULL_THRESHOLD) totalNulls++;
}
}
const avgNulls = totalNulls / nFrames;
const nullPct = n > 0 ? (avgNulls / n) * 100 : 0;
// Mean RSSI
const meanRssi = cc.frames.reduce((s, f) => s + f.rssi, 0) / nFrames;
// Spectrum flatness: geometric mean / arithmetic mean of last frame
const lastFrame = cc.frames[nFrames - 1];
let logSum = 0, ampSum = 0, count = 0;
for (let i = 0; i < n; i++) {
if (lastFrame.amplitudes[i] > 0) {
logSum += Math.log(lastFrame.amplitudes[i]);
count++;
}
ampSum += lastFrame.amplitudes[i];
}
const geoMean = count > 0 ? Math.exp(logSum / count) : 0;
const ariMean = n > 0 ? ampSum / n : 0;
const flatness = ariMean > 0 ? geoMean / ariMean : 0;
// Amplitude variance per subcarrier (average across subcarriers)
const means = new Float64Array(n);
const vars = new Float64Array(n);
for (const f of cc.frames) {
for (let i = 0; i < n; i++) means[i] += f.amplitudes[i];
}
for (let i = 0; i < n; i++) means[i] /= nFrames;
for (const f of cc.frames) {
for (let i = 0; i < n; i++) {
const d = f.amplitudes[i] - means[i];
vars[i] += d * d;
}
}
let avgVar = 0;
for (let i = 0; i < n; i++) {
vars[i] /= Math.max(1, nFrames - 1);
avgVar += vars[i];
}
avgVar /= Math.max(1, n);
// Null subcarrier indices (from last frame)
const nullIndices = [];
for (let i = 0; i < n; i++) {
if (lastFrame.amplitudes[i] < NULL_THRESHOLD) nullIndices.push(i);
}
channelMetrics.push({
channel: ch,
freqMhz: cc.freqMhz,
nSubcarriers: n,
frameCount: nFrames,
fps: parseFloat(chFps.toFixed(2)),
avgNullCount: parseFloat(avgNulls.toFixed(1)),
nullPercent: parseFloat(nullPct.toFixed(1)),
meanRssi: parseFloat(meanRssi.toFixed(1)),
spectrumFlatness: parseFloat(flatness.toFixed(4)),
avgAmplitudeVariance: parseFloat(avgVar.toFixed(4)),
nullIndices,
});
}
results.nodes.push({
nodeId: node.nodeId,
address: node.address,
totalFrames: node.totalFrames,
fps: parseFloat(nodeFps.toFixed(2)),
channels: channelMetrics,
});
}
// Cross-channel null diversity
const allChannelData = [];
for (const node of nodes.values()) {
for (const [ch, cc] of node.channels.entries()) {
if (cc.frames.length === 0) continue;
const n = cc.nSubcarriers;
const lastFrame = cc.frames[cc.frames.length - 1];
const nullSet = new Set();
for (let i = 0; i < n; i++) {
if (lastFrame.amplitudes[i] < NULL_THRESHOLD) nullSet.add(i);
}
allChannelData.push({ channel: ch, nodeId: node.nodeId, nullSet, n });
}
}
if (allChannelData.length >= 2) {
// Union and intersection of null sets
const allNullSets = allChannelData.map(d => d.nullSet);
const union = new Set();
for (const s of allNullSets) for (const idx of s) union.add(idx);
let intersectionCount = 0;
for (const idx of union) {
if (allNullSets.every(s => s.has(idx))) intersectionCount++;
}
const singleNulls = allNullSets[0].size;
const maxSub = Math.max(...allChannelData.map(d => d.n));
// Cross-channel correlation (pairwise)
const correlations = [];
for (let i = 0; i < allChannelData.length; i++) {
for (let j = i + 1; j < allChannelData.length; j++) {
const d1 = allChannelData[i];
const d2 = allChannelData[j];
const cc1 = [...nodes.values()].find(n => n.nodeId === d1.nodeId)?.channels.get(d1.channel);
const cc2 = [...nodes.values()].find(n => n.nodeId === d2.nodeId)?.channels.get(d2.channel);
if (!cc1 || !cc2) continue;
const f1 = cc1.frames[cc1.frames.length - 1];
const f2 = cc2.frames[cc2.frames.length - 1];
const len = Math.min(f1.amplitudes.length, f2.amplitudes.length);
let sumXY = 0, sumX = 0, sumY = 0, sumX2 = 0, sumY2 = 0;
for (let k = 0; k < len; k++) {
sumX += f1.amplitudes[k]; sumY += f2.amplitudes[k];
sumXY += f1.amplitudes[k] * f2.amplitudes[k];
sumX2 += f1.amplitudes[k] ** 2;
sumY2 += f2.amplitudes[k] ** 2;
}
const denom = Math.sqrt((len * sumX2 - sumX * sumX) * (len * sumY2 - sumY * sumY));
const corr = denom > 0 ? (len * sumXY - sumX * sumY) / denom : 0;
correlations.push({
node1: d1.nodeId, ch1: d1.channel,
node2: d2.nodeId, ch2: d2.channel,
correlation: parseFloat(corr.toFixed(4)),
});
}
}
results.crossChannel = {
totalChannels: allChannelData.length,
singleChannelNulls: singleNulls,
fusedNulls: intersectionCount,
unionNulls: union.size,
maxSubcarriers: maxSub,
singleNullPct: parseFloat(maxSub > 0 ? ((singleNulls / maxSub) * 100).toFixed(1) : '0'),
fusedNullPct: parseFloat(maxSub > 0 ? ((intersectionCount / maxSub) * 100).toFixed(1) : '0'),
diversityGainPct: parseFloat(singleNulls > 0
? ((1 - intersectionCount / singleNulls) * 100).toFixed(1)
: '0'),
correlations,
};
}
// Position accuracy estimate
// With N independent channel observations, accuracy improves by sqrt(N)
// Baseline: single channel ~30 cm resolution at 2.4 GHz
const nChannels = allChannelData.length;
const baselineResolutionCm = 30;
const estimatedResolutionCm = nChannels > 0
? baselineResolutionCm / Math.sqrt(nChannels)
: baselineResolutionCm;
results.summary = {
totalNodes: nodes.size,
totalChannels: nChannels,
totalFrames,
durationS: DURATION_S,
avgFps: parseFloat((totalFrames / DURATION_S).toFixed(1)),
baselineResolutionCm,
estimatedResolutionCm: parseFloat(estimatedResolutionCm.toFixed(1)),
resolutionImprovement: nChannels > 1 ? `${Math.sqrt(nChannels).toFixed(2)}x` : '1x (single channel)',
totalSubcarriers: allChannelData.reduce((s, d) => s + d.n, 0),
subcarrierMultiplier: nChannels > 0
? parseFloat((allChannelData.reduce((s, d) => s + d.n, 0) / Math.max(1, allChannelData[0]?.n || 1)).toFixed(1))
: 1,
};
return results;
}
// ---------------------------------------------------------------------------
// Reporting
// ---------------------------------------------------------------------------
function printReport(metrics) {
console.log('');
console.log('=== RUVIEW RF SCAN BENCHMARK ===');
console.log(`Duration: ${metrics.duration_s}s | Total frames: ${metrics.totalFrames}`);
console.log('');
// Per-node per-channel table
console.log('--- Frames Per Second ---');
console.log('Node Channel Freq FPS Frames Subcarriers RSSI');
for (const node of metrics.nodes) {
for (const ch of node.channels) {
console.log(` ${node.nodeId} ch${String(ch.channel).padStart(2)} ${ch.freqMhz} MHz ${String(ch.fps).padStart(5)} ${String(ch.frameCount).padStart(6)} ${String(ch.nSubcarriers).padStart(11)} ${ch.meanRssi} dBm`);
}
console.log(` ${node.nodeId} TOTAL ${String(node.fps).padStart(5)} ${String(node.totalFrames).padStart(6)}`);
}
console.log('');
// Null subcarriers
console.log('--- Null Subcarriers Per Channel ---');
console.log('Node Channel Nulls Null% Flatness AvgVariance');
for (const node of metrics.nodes) {
for (const ch of node.channels) {
console.log(` ${node.nodeId} ch${String(ch.channel).padStart(2)} ${String(ch.avgNullCount.toFixed(0)).padStart(5)} ${String(ch.nullPercent.toFixed(1)).padStart(5)}% ${String(ch.spectrumFlatness.toFixed(4)).padStart(8)} ${ch.avgAmplitudeVariance.toFixed(4)}`);
}
}
console.log('');
// Cross-channel diversity
if (metrics.crossChannel) {
const cc = metrics.crossChannel;
console.log('--- Cross-Channel Null Diversity ---');
console.log(` Channels scanned: ${cc.totalChannels}`);
console.log(` Single-channel nulls: ${cc.singleChannelNulls} (${cc.singleNullPct}%)`);
console.log(` Fused nulls (all ch): ${cc.fusedNulls} (${cc.fusedNullPct}%)`);
console.log(` Diversity gain: ${cc.diversityGainPct}%`);
console.log('');
if (cc.correlations.length > 0) {
console.log('--- Cross-Channel Correlation ---');
for (const c of cc.correlations) {
const label = c.node1 === c.node2
? `node${c.node1} ch${c.ch1}<->ch${c.ch2}`
: `node${c.node1}/ch${c.ch1}<->node${c.node2}/ch${c.ch2}`;
console.log(` ${label}: ${c.correlation.toFixed(4)}`);
}
console.log('');
}
}
// Summary
if (metrics.summary) {
const s = metrics.summary;
console.log('--- Summary ---');
console.log(` Nodes: ${s.totalNodes}`);
console.log(` Channels: ${s.totalChannels}`);
console.log(` Total subcarriers: ${s.totalSubcarriers} (${s.subcarrierMultiplier}x single-channel)`);
console.log(` Average FPS: ${s.avgFps}`);
console.log(` Baseline resolution: ${s.baselineResolutionCm} cm (single channel)`);
console.log(` Estimated resolution: ${s.estimatedResolutionCm} cm (${s.resolutionImprovement})`);
console.log('');
}
// Pass/fail targets (from ADR-073)
console.log('--- ADR-073 Targets ---');
const s = metrics.summary || {};
const cc = metrics.crossChannel || {};
const targets = [
{ name: 'Subcarrier multiplier >= 3x', pass: (s.subcarrierMultiplier || 0) >= 3,
actual: `${s.subcarrierMultiplier || 0}x` },
{ name: 'Null gap < 5%', pass: (cc.fusedNullPct || 100) < 5,
actual: `${cc.fusedNullPct || '?'}%` },
{ name: 'Resolution <= 15 cm', pass: (s.estimatedResolutionCm || 999) <= 15,
actual: `${s.estimatedResolutionCm || '?'} cm` },
];
for (const t of targets) {
const status = t.pass ? 'PASS' : 'FAIL';
console.log(` [${status}] ${t.name} (actual: ${t.actual})`);
}
console.log('');
console.log('Note: Targets require multi-channel hopping enabled on both ESP32 nodes.');
console.log('Single-channel mode will show FAIL for multi-channel targets.');
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
const server = dgram.createSocket('udp4');
server.on('error', (err) => {
console.error(`UDP error: ${err.message}`);
server.close();
process.exit(1);
});
server.on('message', (msg, rinfo) => {
handlePacket(msg, rinfo);
});
server.on('listening', () => {
const addr = server.address();
if (!JSON_OUTPUT) {
console.log(`RuView RF Scan Benchmark`);
console.log(`Listening on ${addr.address}:${addr.port} for ${DURATION_S}s...`);
console.log('Collecting CSI frames from ESP32 nodes...\n');
}
});
server.bind(PORT);
// Progress indicator (non-JSON mode)
let progressTimer;
if (!JSON_OUTPUT) {
let dots = 0;
progressTimer = setInterval(() => {
dots++;
const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
process.stdout.write(`\r ${elapsed}s / ${DURATION_S}s | ${totalFrames} frames | ${nodes.size} nodes ${'.' .repeat(dots % 4)} `);
}, 1000);
}
setTimeout(() => {
if (progressTimer) clearInterval(progressTimer);
if (!JSON_OUTPUT) process.stdout.write('\r' + ' '.repeat(60) + '\r');
const metrics = computeMetrics();
if (JSON_OUTPUT) {
process.stdout.write(JSON.stringify(metrics, null, 2) + '\n');
} else {
printReport(metrics);
}
server.close();
process.exit(0);
}, DURATION_S * 1000);
process.on('SIGINT', () => {
if (progressTimer) clearInterval(progressTimer);
if (!JSON_OUTPUT) console.log('\nInterrupted — computing metrics with collected data...\n');
const metrics = computeMetrics();
if (JSON_OUTPUT) {
process.stdout.write(JSON.stringify(metrics, null, 2) + '\n');
} else {
printReport(metrics);
}
server.close();
process.exit(0);
});
}
main();

View file

@ -0,0 +1,844 @@
#!/usr/bin/env node
/**
* RuView Multi-Frequency RF Room Scanner
*
* Extended version of rf-scan.js that tracks CSI data per WiFi channel and
* merges multi-channel data into a wideband view. Works when channel hopping
* is enabled on ESP32 nodes via provision.py --hop-channels.
*
* Key capabilities:
* - Per-channel subcarrier tracking across 6 WiFi channels
* - Wideband merged spectrum (up to 6x subcarrier count)
* - Null diversity analysis (what one channel misses, another may see)
* - Frequency-dependent scattering identification
* - Neighbor network illuminator tracking
* - Per-channel penetration quality scoring
*
* Usage:
* node scripts/rf-scan-multifreq.js
* node scripts/rf-scan-multifreq.js --port 5006 --duration 60
* node scripts/rf-scan-multifreq.js --json
*
* ADR: docs/adr/ADR-073-multifrequency-mesh-scan.md
*/
'use strict';
const dgram = require('dgram');
const { parseArgs } = require('util');
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
const { values: args } = parseArgs({
options: {
port: { type: 'string', short: 'p', default: '5006' },
duration: { type: 'string', short: 'd' },
json: { type: 'boolean', default: false },
interval: { type: 'string', short: 'i', default: '2000' },
},
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;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CSI_MAGIC = 0xC5110001;
const VITALS_MAGIC = 0xC5110002;
const FEATURE_MAGIC = 0xC5110003;
const FUSED_MAGIC = 0xC5110004;
const HEADER_SIZE = 20;
const BARS = ['\u2581', '\u2582', '\u2583', '\u2584', '\u2585', '\u2586', '\u2587', '\u2588'];
const NULL_THRESHOLD = 2.0;
const DYNAMIC_VAR_THRESH = 0.15;
const STRONG_AMP_THRESH = 0.85;
// WiFi 2.4 GHz channel -> center frequency
const CHANNEL_FREQ = {};
for (let ch = 1; ch <= 13; ch++) CHANNEL_FREQ[ch] = 2412 + (ch - 1) * 5;
CHANNEL_FREQ[14] = 2484;
// Non-overlapping channel sets for 2-node mesh
const NODE1_CHANNELS = [1, 6, 11]; // non-overlapping
const NODE2_CHANNELS = [3, 5, 9]; // interleaved, near neighbor APs
// Known neighbor networks (from WiFi scan, used as illuminators)
const KNOWN_ILLUMINATORS = [
{ ssid: 'ruv.net', channel: 5, freq: 2432, signal: 100 },
{ ssid: 'Cohen-Guest', channel: 5, freq: 2432, signal: 100 },
{ ssid: 'COGECO-21B20', channel: 11, freq: 2462, signal: 100 },
{ ssid: 'DIRECT-fa-HP M255 LaserJet', channel: 5, freq: 2432, signal: 94 },
{ ssid: 'conclusion mesh', channel: 3, freq: 2422, signal: 44 },
{ ssid: 'NETGEAR72', channel: 9, freq: 2452, signal: 42 },
{ ssid: 'NETGEAR72-Guest', channel: 9, freq: 2452, signal: 42 },
{ ssid: 'COGECO-4321', channel: 11, freq: 2462, signal: 30 },
{ ssid: 'Innanen', channel: 6, freq: 2437, signal: 19 },
];
// ---------------------------------------------------------------------------
// Per-channel state within a node
// ---------------------------------------------------------------------------
class ChannelState {
constructor(channel) {
this.channel = channel;
this.freqMhz = CHANNEL_FREQ[channel] || 0;
this.nSubcarriers = 0;
this.frameCount = 0;
this.firstFrameMs = 0;
this.lastFrameMs = 0;
this.amplitudes = new Float64Array(256);
this.phases = new Float64Array(256);
// Welford variance per subcarrier
this.ampMean = new Float64Array(256);
this.ampM2 = new Float64Array(256);
this.ampCount = new Uint32Array(256);
// Illuminators active on this channel
this.illuminators = KNOWN_ILLUMINATORS.filter(n => n.channel === channel);
}
get fps() {
if (this.firstFrameMs === 0) return 0;
const elapsed = (this.lastFrameMs - this.firstFrameMs) / 1000;
return elapsed > 0 ? this.frameCount / elapsed : 0;
}
update(amplitudes, phases) {
const n = amplitudes.length;
this.nSubcarriers = n;
this.frameCount++;
const now = Date.now();
if (this.firstFrameMs === 0) this.firstFrameMs = now;
this.lastFrameMs = now;
for (let i = 0; i < n; i++) {
this.amplitudes[i] = amplitudes[i];
this.phases[i] = phases[i];
this.ampCount[i]++;
const delta = amplitudes[i] - this.ampMean[i];
this.ampMean[i] += delta / this.ampCount[i];
const delta2 = amplitudes[i] - this.ampMean[i];
this.ampM2[i] += delta * delta2;
}
}
getVariance(i) {
return this.ampCount[i] > 1 ? this.ampM2[i] / (this.ampCount[i] - 1) : 0;
}
getNulls() {
const nulls = [];
for (let i = 0; i < this.nSubcarriers; i++) {
if (this.amplitudes[i] < NULL_THRESHOLD) nulls.push(i);
}
return nulls;
}
getNullPercent() {
if (this.nSubcarriers === 0) return 0;
return (this.getNulls().length / this.nSubcarriers) * 100;
}
classify() {
const n = this.nSubcarriers;
if (n === 0) return { nulls: [], dynamic: [], reflectors: [], walls: [] };
let maxAmp = 0;
for (let i = 0; i < n; i++) {
if (this.amplitudes[i] > maxAmp) maxAmp = this.amplitudes[i];
}
if (maxAmp === 0) maxAmp = 1;
const nulls = [], dynamic = [], reflectors = [], walls = [];
for (let i = 0; i < n; i++) {
const normAmp = this.amplitudes[i] / maxAmp;
const variance = this.getVariance(i);
if (this.amplitudes[i] < NULL_THRESHOLD) nulls.push(i);
else if (variance > DYNAMIC_VAR_THRESH) dynamic.push(i);
else if (normAmp > STRONG_AMP_THRESH) reflectors.push(i);
else walls.push(i);
}
return { nulls, dynamic, reflectors, walls };
}
getSpectrumBar() {
const n = this.nSubcarriers;
if (n === 0) return '';
let maxAmp = 0;
for (let i = 0; i < n; i++) {
if (this.amplitudes[i] > maxAmp) maxAmp = this.amplitudes[i];
}
if (maxAmp === 0) maxAmp = 1;
let bar = '';
for (let i = 0; i < n; i++) {
const level = Math.floor((this.amplitudes[i] / maxAmp) * 7.99);
bar += BARS[Math.max(0, Math.min(7, level))];
}
return bar;
}
}
// ---------------------------------------------------------------------------
// Per-node state (multi-channel)
// ---------------------------------------------------------------------------
class NodeState {
constructor(nodeId) {
this.nodeId = nodeId;
this.address = null;
this.channels = new Map(); // channel number -> ChannelState
this.totalFrames = 0;
this.firstFrameMs = Date.now();
this.lastFrameMs = Date.now();
this.rssi = 0;
this.vitals = null;
this.features = null;
}
get fps() {
const elapsed = (this.lastFrameMs - this.firstFrameMs) / 1000;
return elapsed > 0 ? this.totalFrames / elapsed : 0;
}
getOrCreateChannel(channel) {
if (!this.channels.has(channel)) {
this.channels.set(channel, new ChannelState(channel));
}
return this.channels.get(channel);
}
getActiveChannels() {
return [...this.channels.values()]
.filter(cs => cs.frameCount > 0)
.sort((a, b) => a.channel - b.channel);
}
}
// ---------------------------------------------------------------------------
// Global state
// ---------------------------------------------------------------------------
const nodes = new Map();
const startTime = Date.now();
let totalFrames = 0;
// ---------------------------------------------------------------------------
// Packet parsing (same as rf-scan.js)
// ---------------------------------------------------------------------------
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 nAntennas = buf.readUInt8(5) || 1;
const nSubcarriers = buf.readUInt16LE(6);
const freqMhz = buf.readUInt32LE(8);
const seq = buf.readUInt32LE(12);
const rssi = buf.readInt8(16);
const noiseFloor = buf.readInt8(17);
const iqLen = nSubcarriers * nAntennas * 2;
if (buf.length < HEADER_SIZE + iqLen) return null;
const amplitudes = new Float64Array(nSubcarriers);
const phases = new Float64Array(nSubcarriers);
for (let sc = 0; sc < nSubcarriers; sc++) {
const offset = HEADER_SIZE + sc * 2;
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);
}
// Derive channel from frequency
let channel = 0;
if (freqMhz >= 2412 && freqMhz <= 2484) {
channel = freqMhz === 2484 ? 14 : Math.round((freqMhz - 2412) / 5) + 1;
} else if (freqMhz >= 5180) {
channel = Math.round((freqMhz - 5000) / 5);
}
return {
nodeId, nAntennas, nSubcarriers, freqMhz, seq, rssi, noiseFloor,
amplitudes, phases, channel,
};
}
function parseVitalsPacket(buf) {
if (buf.length < 32) return null;
const magic = buf.readUInt32LE(0);
if (magic !== VITALS_MAGIC && magic !== FUSED_MAGIC) return null;
return {
nodeId: buf.readUInt8(4),
flags: buf.readUInt8(5),
presence: !!(buf.readUInt8(5) & 0x01),
fall: !!(buf.readUInt8(5) & 0x02),
motion: !!(buf.readUInt8(5) & 0x04),
breathingRate: buf.readUInt16LE(6) / 100,
heartrate: buf.readUInt32LE(8) / 10000,
rssi: buf.readInt8(12),
nPersons: buf.readUInt8(13),
motionEnergy: buf.readFloatLE(16),
presenceScore: buf.readFloatLE(20),
timestampMs: buf.readUInt32LE(24),
};
}
function parseFeaturePacket(buf) {
if (buf.length < 48) return null;
const magic = buf.readUInt32LE(0);
if (magic !== FEATURE_MAGIC) return null;
const features = [];
for (let i = 0; i < 8; i++) features.push(buf.readFloatLE(12 + i * 4));
return { nodeId: buf.readUInt8(4), seq: buf.readUInt16LE(6), features };
}
function handlePacket(buf, rinfo) {
if (buf.length < 4) return;
const magic = buf.readUInt32LE(0);
if (magic === CSI_MAGIC) {
const frame = parseCSIFrame(buf);
if (!frame) return;
totalFrames++;
let node = nodes.get(frame.nodeId);
if (!node) {
node = new NodeState(frame.nodeId);
nodes.set(frame.nodeId, node);
}
node.address = rinfo.address;
node.rssi = frame.rssi;
node.totalFrames++;
node.lastFrameMs = Date.now();
const cs = node.getOrCreateChannel(frame.channel);
cs.update(frame.amplitudes, frame.phases);
return;
}
if (magic === VITALS_MAGIC || magic === FUSED_MAGIC) {
const vitals = parseVitalsPacket(buf);
if (!vitals) return;
let node = nodes.get(vitals.nodeId);
if (!node) { node = new NodeState(vitals.nodeId); nodes.set(vitals.nodeId, node); }
node.vitals = vitals;
return;
}
if (magic === FEATURE_MAGIC) {
const feat = parseFeaturePacket(buf);
if (!feat) return;
let node = nodes.get(feat.nodeId);
if (!node) { node = new NodeState(feat.nodeId); nodes.set(feat.nodeId, node); }
node.features = feat;
}
}
// ---------------------------------------------------------------------------
// Multi-frequency analysis
// ---------------------------------------------------------------------------
/**
* Compute null diversity: how many null subcarriers on one channel are
* resolved (non-null) on another channel. This is the core benefit of
* multi-frequency scanning.
*/
function computeNullDiversity() {
// Collect all channel states across all nodes
const allChannelStates = [];
for (const node of nodes.values()) {
for (const cs of node.channels.values()) {
if (cs.frameCount > 0) allChannelStates.push(cs);
}
}
if (allChannelStates.length < 2) return null;
// For each channel, get its null set
const channelNulls = new Map();
for (const cs of allChannelStates) {
const key = cs.channel;
if (!channelNulls.has(key)) {
channelNulls.set(key, { channel: key, nulls: new Set(cs.getNulls()), nSub: cs.nSubcarriers });
}
}
if (channelNulls.size < 2) return null;
const channels = [...channelNulls.keys()].sort((a, b) => a - b);
// Compute pairwise null diversity
const pairwise = [];
for (let i = 0; i < channels.length; i++) {
for (let j = i + 1; j < channels.length; j++) {
const c1 = channelNulls.get(channels[i]);
const c2 = channelNulls.get(channels[j]);
// Nulls on c1 that c2 resolves (non-null on c2)
let c1ResolvedByC2 = 0;
let c2ResolvedByC1 = 0;
let sharedNulls = 0;
for (const idx of c1.nulls) {
if (!c2.nulls.has(idx)) c1ResolvedByC2++;
else sharedNulls++;
}
for (const idx of c2.nulls) {
if (!c1.nulls.has(idx)) c2ResolvedByC1++;
}
pairwise.push({
ch1: channels[i], ch2: channels[j],
ch1Nulls: c1.nulls.size, ch2Nulls: c2.nulls.size,
sharedNulls,
ch1ResolvedByC2: c1ResolvedByC2,
ch2ResolvedByC1: c2ResolvedByC1,
});
}
}
// Global: union of all nulls vs intersection
const allNullSets = [...channelNulls.values()].map(c => c.nulls);
const unionNulls = new Set();
for (const s of allNullSets) for (const idx of s) unionNulls.add(idx);
let intersectionCount = 0;
for (const idx of unionNulls) {
if (allNullSets.every(s => s.has(idx))) intersectionCount++;
}
// Effective null rate after multi-channel fusion
const maxSub = Math.max(...[...channelNulls.values()].map(c => c.nSub));
const singleChannelNulls = allNullSets[0].size;
const fusedNulls = intersectionCount; // only nulls present on ALL channels
return {
channels,
pairwise,
singleChannelNulls,
fusedNulls,
unionNulls: unionNulls.size,
maxSubcarriers: maxSub,
singleNullPct: maxSub > 0 ? ((singleChannelNulls / maxSub) * 100).toFixed(1) : '0',
fusedNullPct: maxSub > 0 ? ((fusedNulls / maxSub) * 100).toFixed(1) : '0',
diversityGain: singleChannelNulls > 0
? ((1 - fusedNulls / singleChannelNulls) * 100).toFixed(1)
: '0',
};
}
/**
* Find objects visible on some channels but not others.
* These are frequency-dependent scatterers (interesting for material classification).
*/
function findFrequencyDependentObjects() {
const allChannelStates = [];
for (const node of nodes.values()) {
for (const cs of node.channels.values()) {
if (cs.frameCount > 0 && cs.nSubcarriers > 0) allChannelStates.push(cs);
}
}
if (allChannelStates.length < 2) return [];
const results = [];
const nSub = Math.min(...allChannelStates.map(cs => cs.nSubcarriers));
for (let i = 0; i < nSub; i++) {
const amps = allChannelStates.map(cs => cs.amplitudes[i]);
const vars = allChannelStates.map(cs => cs.getVariance(i));
const maxAmp = Math.max(...amps);
const minAmp = Math.min(...amps);
// Large amplitude spread across channels = frequency-dependent scatterer
if (maxAmp > 0 && (maxAmp - minAmp) / maxAmp > 0.5) {
const bestCh = allChannelStates[amps.indexOf(maxAmp)].channel;
const worstCh = allChannelStates[amps.indexOf(minAmp)].channel;
results.push({
subcarrier: i,
maxAmp: maxAmp.toFixed(1),
minAmp: minAmp.toFixed(1),
bestChannel: bestCh,
worstChannel: worstCh,
spread: ((maxAmp - minAmp) / maxAmp * 100).toFixed(0),
});
}
}
return results.slice(0, 20); // top 20
}
/**
* Compute per-channel penetration quality score.
* Lower frequency channels (ch 1 = 2412 MHz) have slightly longer wavelength
* and better penetration through some materials.
*/
function computePenetrationScores() {
const scores = [];
for (const node of nodes.values()) {
for (const cs of node.channels.values()) {
if (cs.frameCount === 0 || cs.nSubcarriers === 0) continue;
// Mean amplitude (higher = better penetration)
let sumAmp = 0;
for (let i = 0; i < cs.nSubcarriers; i++) sumAmp += cs.amplitudes[i];
const meanAmp = sumAmp / cs.nSubcarriers;
// Null rate (lower = better)
const nullPct = cs.getNullPercent();
// Spectrum flatness = geometric mean / arithmetic mean
// Flatter spectrum = more uniform penetration
let logSum = 0;
let count = 0;
for (let i = 0; i < cs.nSubcarriers; i++) {
if (cs.amplitudes[i] > 0) {
logSum += Math.log(cs.amplitudes[i]);
count++;
}
}
const geoMean = count > 0 ? Math.exp(logSum / count) : 0;
const flatness = sumAmp > 0 ? geoMean / meanAmp : 0;
// Quality score: weighted combination
const quality = (meanAmp / 20) * 0.4 + (1 - nullPct / 100) * 0.3 + flatness * 0.3;
scores.push({
nodeId: node.nodeId,
channel: cs.channel,
freqMhz: cs.freqMhz,
fps: cs.fps.toFixed(1),
meanAmp: meanAmp.toFixed(1),
nullPct: nullPct.toFixed(1),
flatness: flatness.toFixed(3),
quality: quality.toFixed(3),
illuminators: cs.illuminators.map(il => il.ssid),
});
}
}
return scores.sort((a, b) => parseFloat(b.quality) - parseFloat(a.quality));
}
// ---------------------------------------------------------------------------
// Wideband merged view
// ---------------------------------------------------------------------------
function buildWidebandSpectrum() {
// Collect all channel amplitudes into one wide view
const allChannels = [];
for (const node of nodes.values()) {
for (const cs of node.getActiveChannels()) {
allChannels.push(cs);
}
}
if (allChannels.length === 0) return { bar: '', channels: 0, totalSubcarriers: 0 };
// Sort by frequency
allChannels.sort((a, b) => a.freqMhz - b.freqMhz);
let totalSub = 0;
for (const cs of allChannels) totalSub += cs.nSubcarriers;
// Find global max amplitude for normalization
let globalMax = 0;
for (const cs of allChannels) {
for (let i = 0; i < cs.nSubcarriers; i++) {
if (cs.amplitudes[i] > globalMax) globalMax = cs.amplitudes[i];
}
}
if (globalMax === 0) globalMax = 1;
// Build wideband bar with channel separators
let bar = '';
let labels = '';
for (let c = 0; c < allChannels.length; c++) {
const cs = allChannels[c];
if (c > 0) {
bar += '|';
labels += '|';
}
const chLabel = `ch${cs.channel}`;
labels += chLabel + ' '.repeat(Math.max(0, cs.nSubcarriers - chLabel.length));
for (let i = 0; i < cs.nSubcarriers; i++) {
const level = Math.floor((cs.amplitudes[i] / globalMax) * 7.99);
bar += BARS[Math.max(0, Math.min(7, level))];
}
}
return { bar, labels, channels: allChannels.length, totalSubcarriers: totalSub };
}
// ---------------------------------------------------------------------------
// Display
// ---------------------------------------------------------------------------
function buildProgressBar(value, max, width) {
const filled = Math.round((value / max) * width);
return '\u2588'.repeat(Math.min(filled, width)) +
'\u2591'.repeat(Math.max(0, width - filled));
}
function renderASCII() {
const lines = [];
const nodeList = [...nodes.values()];
const activeNodes = nodeList.filter(n => n.totalFrames > 0);
if (activeNodes.length === 0) {
lines.push(`=== RUVIEW MULTI-FREQ RF SCAN === Listening on UDP :${PORT}`);
lines.push('Waiting for CSI frames from ESP32 nodes...');
lines.push('Enable channel hopping: python provision.py --port COMx --hop-channels 1,6,11');
lines.push(`Elapsed: ${((Date.now() - startTime) / 1000).toFixed(0)}s | Frames: ${totalFrames}`);
return lines.join('\n');
}
lines.push('=== RUVIEW MULTI-FREQUENCY RF SCAN ===');
lines.push('');
// Per-node, per-channel view
for (const node of activeNodes) {
lines.push(`--- Node ${node.nodeId} (${node.address || '?'}) | ${node.fps.toFixed(1)} fps total | RSSI ${node.rssi} dBm ---`);
const activeChannels = node.getActiveChannels();
if (activeChannels.length === 0) {
lines.push(' (no channel data yet)');
continue;
}
for (const cs of activeChannels) {
const cls = cs.classify();
const spectrum = cs.getSpectrumBar();
const nullPct = cs.getNullPercent().toFixed(0);
const ilNames = cs.illuminators.length > 0
? cs.illuminators.map(il => il.ssid).join(', ')
: 'none';
lines.push(` Ch ${String(cs.channel).padStart(2)} (${cs.freqMhz} MHz) | ${cs.fps.toFixed(1)} fps | nulls: ${nullPct}% | illuminators: ${ilNames}`);
if (spectrum.length > 0) {
// Truncate spectrum to terminal width (approx)
const maxWidth = 80;
const truncated = spectrum.length > maxWidth
? spectrum.slice(0, maxWidth) + '...'
: spectrum;
lines.push(` ${truncated}`);
}
lines.push(` ${cls.nulls.length} null | ${cls.dynamic.length} dynamic | ${cls.reflectors.length} reflector | ${cls.walls.length} static`);
}
// Vitals
if (node.vitals) {
const v = node.vitals;
lines.push(` Vitals: BR ${v.breathingRate.toFixed(0)} BPM | HR ${v.heartrate.toFixed(0)} BPM | presence ${v.presenceScore.toFixed(2)} | ${v.nPersons} person(s)`);
}
lines.push('');
}
// Wideband merged view
const wideband = buildWidebandSpectrum();
if (wideband.channels > 1) {
lines.push('--- Wideband Merged Spectrum ---');
const maxWidth = 100;
const truncBar = wideband.bar.length > maxWidth
? wideband.bar.slice(0, maxWidth) + '...'
: wideband.bar;
lines.push(` ${truncBar}`);
lines.push(` ${wideband.channels} channels | ${wideband.totalSubcarriers} total subcarriers`);
lines.push('');
}
// Null diversity analysis
const diversity = computeNullDiversity();
if (diversity) {
lines.push('--- Null Diversity Analysis ---');
lines.push(` Single-channel nulls: ${diversity.singleChannelNulls} (${diversity.singleNullPct}%)`);
lines.push(` Multi-channel fused: ${diversity.fusedNulls} (${diversity.fusedNullPct}%) -- only nulls on ALL channels`);
lines.push(` Diversity gain: ${diversity.diversityGain}% of nulls resolved by other channels`);
if (diversity.pairwise.length > 0) {
lines.push(' Pairwise:');
for (const p of diversity.pairwise) {
lines.push(` ch${p.ch1}<->ch${p.ch2}: ${p.sharedNulls} shared | ch${p.ch1} resolves ${p.ch2ResolvedByC1} of ch${p.ch2}'s nulls | ch${p.ch2} resolves ${p.ch1ResolvedByC2} of ch${p.ch1}'s nulls`);
}
}
lines.push('');
}
// Penetration scores
const penScores = computePenetrationScores();
if (penScores.length > 0) {
lines.push('--- Per-Channel Penetration Quality ---');
lines.push(' Ch Freq FPS MeanAmp Null% Flat Quality Illuminators');
for (const s of penScores) {
const ilStr = s.illuminators.length > 0 ? s.illuminators.slice(0, 2).join(', ') : '-';
lines.push(` ${String(s.channel).padStart(2)} ${s.freqMhz} MHz ${String(s.fps).padStart(5)} ${String(s.meanAmp).padStart(7)} ${String(s.nullPct).padStart(5)} ${s.flatness} ${s.quality} ${ilStr}`);
}
lines.push('');
}
// Frequency-dependent scatterers
const scatterers = findFrequencyDependentObjects();
if (scatterers.length > 0) {
lines.push(`--- Frequency-Dependent Scatterers (${scatterers.length} found) ---`);
lines.push(' Sub# Best Ch Worst Ch Spread MaxAmp MinAmp');
for (const s of scatterers.slice(0, 10)) {
lines.push(` ${String(s.subcarrier).padStart(4)} ch${String(s.bestChannel).padStart(2)} ch${String(s.worstChannel).padStart(2)} ${String(s.spread).padStart(3)}% ${String(s.maxAmp).padStart(6)} ${String(s.minAmp).padStart(6)}`);
}
lines.push(' (Objects visible on some frequencies but not others -- different materials)');
lines.push('');
}
// Summary
const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
lines.push(`Elapsed: ${elapsed}s | Total frames: ${totalFrames} | Nodes: ${activeNodes.length}`);
if (DURATION_MS) {
const remaining = Math.max(0, (DURATION_MS - (Date.now() - startTime)) / 1000).toFixed(0);
lines.push(`Remaining: ${remaining}s`);
}
return lines.join('\n');
}
function buildJsonOutput() {
const activeNodes = [...nodes.values()].filter(n => n.totalFrames > 0);
return {
timestamp: new Date().toISOString(),
elapsedMs: Date.now() - startTime,
totalFrames,
nodes: activeNodes.map(node => ({
nodeId: node.nodeId,
address: node.address,
fps: parseFloat(node.fps.toFixed(2)),
totalFrames: node.totalFrames,
channels: node.getActiveChannels().map(cs => {
const cls = cs.classify();
return {
channel: cs.channel,
freqMhz: cs.freqMhz,
fps: parseFloat(cs.fps.toFixed(2)),
nSubcarriers: cs.nSubcarriers,
frameCount: cs.frameCount,
classification: {
nullCount: cls.nulls.length,
dynamicCount: cls.dynamic.length,
reflectorCount: cls.reflectors.length,
staticCount: cls.walls.length,
nullPercent: parseFloat(cs.getNullPercent().toFixed(1)),
},
illuminators: cs.illuminators.map(il => il.ssid),
amplitudes: Array.from(cs.amplitudes.subarray(0, cs.nSubcarriers)),
phases: Array.from(cs.phases.subarray(0, cs.nSubcarriers)),
};
}),
vitals: node.vitals,
features: node.features ? node.features.features : null,
})),
nullDiversity: computeNullDiversity(),
penetrationScores: computePenetrationScores(),
frequencyDependentScatterers: findFrequencyDependentObjects(),
wideband: (() => {
const wb = buildWidebandSpectrum();
return { channels: wb.channels, totalSubcarriers: wb.totalSubcarriers };
})(),
};
}
function display() {
if (JSON_OUTPUT) {
process.stdout.write(JSON.stringify(buildJsonOutput()) + '\n');
} else {
process.stdout.write('\x1B[2J\x1B[H');
process.stdout.write(renderASCII() + '\n');
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
const server = dgram.createSocket('udp4');
server.on('error', (err) => {
console.error(`UDP error: ${err.message}`);
server.close();
process.exit(1);
});
server.on('message', (msg, rinfo) => {
handlePacket(msg, rinfo);
});
server.on('listening', () => {
const addr = server.address();
if (!JSON_OUTPUT) {
console.log(`RuView Multi-Frequency RF Scanner listening on ${addr.address}:${addr.port}`);
console.log('Waiting for CSI frames from ESP32 nodes...');
console.log('Tip: Enable channel hopping with provision.py --hop-channels 1,6,11\n');
}
});
server.bind(PORT);
const displayTimer = setInterval(display, INTERVAL_MS);
if (DURATION_MS) {
setTimeout(() => {
clearInterval(displayTimer);
if (JSON_OUTPUT) {
const summary = buildJsonOutput();
summary.final = true;
process.stdout.write(JSON.stringify(summary) + '\n');
} else {
display();
console.log('\n--- Multi-frequency scan complete ---');
const diversity = computeNullDiversity();
if (diversity) {
console.log(`Null diversity gain: ${diversity.diversityGain}% (${diversity.singleNullPct}% -> ${diversity.fusedNullPct}%)`);
}
console.log(`Total frames: ${totalFrames}`);
console.log(`Nodes: ${nodes.size}`);
for (const node of nodes.values()) {
const chList = node.getActiveChannels().map(cs => `ch${cs.channel}`).join(', ');
console.log(` Node ${node.nodeId}: ${node.totalFrames} frames, channels: [${chList}]`);
}
}
server.close();
process.exit(0);
}, DURATION_MS);
}
process.on('SIGINT', () => {
clearInterval(displayTimer);
if (!JSON_OUTPUT) console.log('\nShutting down...');
server.close();
process.exit(0);
});
}
main();

622
scripts/rf-scan.js Normal file
View file

@ -0,0 +1,622 @@
#!/usr/bin/env node
/**
* RuView RF Room Scanner Live CSI spectrum analyzer
*
* Listens on UDP for ADR-018 CSI frames from ESP32 nodes and builds a
* real-time RF map of the room showing null zones (metal), static reflectors,
* dynamic subcarriers (people), and cross-node correlation.
*
* Usage:
* node scripts/rf-scan.js
* node scripts/rf-scan.js --port 5006 --duration 30
* node scripts/rf-scan.js --json
*
* ADR: docs/adr/ADR-073-multifrequency-mesh-scan.md
*/
'use strict';
const dgram = require('dgram');
const { parseArgs } = require('util');
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
const { values: args } = parseArgs({
options: {
port: { type: 'string', short: 'p', default: '5006' },
duration: { type: 'string', short: 'd' },
json: { type: 'boolean', default: false },
interval: { type: 'string', short: 'i', default: '2000' },
},
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;
// ---------------------------------------------------------------------------
// ADR-018 packet constants
// ---------------------------------------------------------------------------
const CSI_MAGIC = 0xC5110001;
const VITALS_MAGIC = 0xC5110002;
const FEATURE_MAGIC = 0xC5110003;
const FUSED_MAGIC = 0xC5110004;
const HEADER_SIZE = 20;
// Spectrum visualization characters (8 levels)
const BARS = ['\u2581', '\u2582', '\u2583', '\u2584', '\u2585', '\u2586', '\u2587', '\u2588'];
// Subcarrier type markers
const TYPE_WALL = '.';
const TYPE_PERSON = '^';
const TYPE_REFLECTOR = '#';
const TYPE_NULL = '_';
const TYPE_UNKNOWN = ' ';
// Thresholds
const NULL_THRESHOLD = 2.0; // Amplitude below this = null subcarrier
const DYNAMIC_VAR_THRESH = 0.15; // Variance above this = dynamic (person/motion)
const STRONG_AMP_THRESH = 0.85; // Normalized amplitude above this = strong reflector
const COHERENCE_THRESH = 0.7; // Phase coherence above this = line-of-sight
// ---------------------------------------------------------------------------
// Per-node state
// ---------------------------------------------------------------------------
class NodeState {
constructor(nodeId) {
this.nodeId = nodeId;
this.address = null;
this.channel = 0;
this.freqMhz = 0;
this.rssi = 0;
this.noiseFloor = 0;
this.nSubcarriers = 0;
this.frameCount = 0;
this.firstFrameMs = Date.now();
this.lastFrameMs = Date.now();
// Per-subcarrier rolling state
this.amplitudes = new Float64Array(256);
this.phases = new Float64Array(256);
this.ampHistory = []; // circular buffer of amplitude snapshots
this.phaseHistory = []; // circular buffer of phase snapshots
this.historyMaxLen = 50; // ~10 seconds at 5 fps
// Welford variance per subcarrier
this.ampMean = new Float64Array(256);
this.ampM2 = new Float64Array(256);
this.ampCount = new Uint32Array(256);
// Latest vitals
this.vitals = null;
this.features = null;
}
get fps() {
const elapsed = (this.lastFrameMs - this.firstFrameMs) / 1000;
return elapsed > 0 ? this.frameCount / elapsed : 0;
}
channelFromFreq() {
if (this.freqMhz >= 2412 && this.freqMhz <= 2484) {
if (this.freqMhz === 2484) return 14;
return Math.round((this.freqMhz - 2412) / 5) + 1;
}
if (this.freqMhz >= 5180) {
return Math.round((this.freqMhz - 5000) / 5);
}
return 0;
}
updateAmplitudes(amplitudes, phases) {
const n = amplitudes.length;
this.nSubcarriers = n;
for (let i = 0; i < n; i++) {
this.amplitudes[i] = amplitudes[i];
this.phases[i] = phases[i];
// Welford online variance
this.ampCount[i]++;
const delta = amplitudes[i] - this.ampMean[i];
this.ampMean[i] += delta / this.ampCount[i];
const delta2 = amplitudes[i] - this.ampMean[i];
this.ampM2[i] += delta * delta2;
}
// Store history snapshot
this.ampHistory.push(Float64Array.from(amplitudes));
this.phaseHistory.push(Float64Array.from(phases));
if (this.ampHistory.length > this.historyMaxLen) {
this.ampHistory.shift();
this.phaseHistory.shift();
}
}
getVariance(i) {
return this.ampCount[i] > 1 ? this.ampM2[i] / (this.ampCount[i] - 1) : 0;
}
classify() {
const n = this.nSubcarriers;
if (n === 0) return { nulls: [], dynamic: [], reflectors: [], walls: [] };
// Find max amplitude for normalization
let maxAmp = 0;
for (let i = 0; i < n; i++) {
if (this.amplitudes[i] > maxAmp) maxAmp = this.amplitudes[i];
}
if (maxAmp === 0) maxAmp = 1;
const nulls = [];
const dynamic = [];
const reflectors = [];
const walls = [];
for (let i = 0; i < n; i++) {
const normAmp = this.amplitudes[i] / maxAmp;
const variance = this.getVariance(i);
if (this.amplitudes[i] < NULL_THRESHOLD) {
nulls.push(i);
} else if (variance > DYNAMIC_VAR_THRESH) {
dynamic.push(i);
} else if (normAmp > STRONG_AMP_THRESH) {
reflectors.push(i);
} else {
walls.push(i);
}
}
return { nulls, dynamic, reflectors, walls };
}
getTypeMap() {
const n = this.nSubcarriers;
const types = new Array(n).fill(TYPE_UNKNOWN);
const { nulls, dynamic, reflectors, walls } = this.classify();
for (const i of nulls) types[i] = TYPE_NULL;
for (const i of dynamic) types[i] = TYPE_PERSON;
for (const i of reflectors) types[i] = TYPE_REFLECTOR;
for (const i of walls) types[i] = TYPE_WALL;
return types;
}
getSpectrumBar() {
const n = this.nSubcarriers;
if (n === 0) return '';
let maxAmp = 0;
for (let i = 0; i < n; i++) {
if (this.amplitudes[i] > maxAmp) maxAmp = this.amplitudes[i];
}
if (maxAmp === 0) maxAmp = 1;
let bar = '';
for (let i = 0; i < n; i++) {
const level = Math.floor((this.amplitudes[i] / maxAmp) * 7.99);
bar += BARS[Math.max(0, Math.min(7, level))];
}
return bar;
}
}
// ---------------------------------------------------------------------------
// Global state
// ---------------------------------------------------------------------------
const nodes = new Map(); // nodeId -> NodeState
const startTime = Date.now();
let totalFrames = 0;
// ---------------------------------------------------------------------------
// Packet parsing
// ---------------------------------------------------------------------------
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 nAntennas = buf.readUInt8(5) || 1;
const nSubcarriers = buf.readUInt16LE(6);
const freqMhz = buf.readUInt32LE(8);
const seq = buf.readUInt32LE(12);
const rssi = buf.readInt8(16);
const noiseFloor = buf.readInt8(17);
const iqLen = nSubcarriers * nAntennas * 2;
if (buf.length < HEADER_SIZE + iqLen) return null;
// Extract amplitude and phase from I/Q pairs
const amplitudes = new Float64Array(nSubcarriers);
const phases = new Float64Array(nSubcarriers);
for (let sc = 0; sc < nSubcarriers; sc++) {
// Use first antenna for primary analysis
const offset = HEADER_SIZE + sc * 2;
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);
}
return {
nodeId, nAntennas, nSubcarriers, freqMhz, seq, rssi, noiseFloor,
amplitudes, phases,
};
}
function parseVitalsPacket(buf) {
if (buf.length < 32) return null;
const magic = buf.readUInt32LE(0);
if (magic !== VITALS_MAGIC && magic !== FUSED_MAGIC) return null;
const nodeId = buf.readUInt8(4);
const flags = buf.readUInt8(5);
const breathingRate = buf.readUInt16LE(6) / 100;
const heartrate = buf.readUInt32LE(8) / 10000;
const rssi = buf.readInt8(12);
const nPersons = buf.readUInt8(13);
const motionEnergy = buf.readFloatLE(16);
const presenceScore = buf.readFloatLE(20);
const timestampMs = buf.readUInt32LE(24);
return {
nodeId, flags,
presence: !!(flags & 0x01),
fall: !!(flags & 0x02),
motion: !!(flags & 0x04),
breathingRate, heartrate, rssi, nPersons,
motionEnergy, presenceScore, timestampMs,
isFused: magic === FUSED_MAGIC,
};
}
function parseFeaturePacket(buf) {
if (buf.length < 48) return null;
const magic = buf.readUInt32LE(0);
if (magic !== FEATURE_MAGIC) return null;
const nodeId = buf.readUInt8(4);
const seq = buf.readUInt16LE(6);
const features = [];
for (let i = 0; i < 8; i++) {
features.push(buf.readFloatLE(12 + i * 4));
}
return { nodeId, seq, features };
}
function handlePacket(buf, rinfo) {
// Try CSI frame first (most common)
if (buf.length >= 4) {
const magic = buf.readUInt32LE(0);
if (magic === CSI_MAGIC) {
const frame = parseCSIFrame(buf);
if (!frame) return;
totalFrames++;
let node = nodes.get(frame.nodeId);
if (!node) {
node = new NodeState(frame.nodeId);
nodes.set(frame.nodeId, node);
}
node.address = rinfo.address;
node.freqMhz = frame.freqMhz;
node.channel = node.channelFromFreq();
node.rssi = frame.rssi;
node.noiseFloor = frame.noiseFloor;
node.frameCount++;
node.lastFrameMs = Date.now();
node.updateAmplitudes(frame.amplitudes, frame.phases);
return;
}
if (magic === VITALS_MAGIC || magic === FUSED_MAGIC) {
const vitals = parseVitalsPacket(buf);
if (!vitals) return;
let node = nodes.get(vitals.nodeId);
if (!node) {
node = new NodeState(vitals.nodeId);
nodes.set(vitals.nodeId, node);
}
node.vitals = vitals;
return;
}
if (magic === FEATURE_MAGIC) {
const feat = parseFeaturePacket(buf);
if (!feat) return;
let node = nodes.get(feat.nodeId);
if (!node) {
node = new NodeState(feat.nodeId);
nodes.set(feat.nodeId, node);
}
node.features = feat;
return;
}
}
}
// ---------------------------------------------------------------------------
// Cross-node analysis
// ---------------------------------------------------------------------------
function computeCrossNodeCorrelation() {
const nodeList = [...nodes.values()].filter(n => n.nSubcarriers > 0);
if (nodeList.length < 2) return null;
const n0 = nodeList[0];
const n1 = nodeList[1];
const len = Math.min(n0.nSubcarriers, n1.nSubcarriers);
// Pearson correlation of amplitude vectors
let sumXY = 0, sumX = 0, sumY = 0, sumX2 = 0, sumY2 = 0;
for (let i = 0; i < len; i++) {
const x = n0.amplitudes[i];
const y = n1.amplitudes[i];
sumX += x; sumY += y;
sumXY += x * y;
sumX2 += x * x;
sumY2 += y * y;
}
const denom = Math.sqrt((len * sumX2 - sumX * sumX) * (len * sumY2 - sumY * sumY));
const correlation = denom > 0 ? (len * sumXY - sumX * sumY) / denom : 0;
// Phase coherence between nodes
let coherenceSum = 0;
for (let i = 0; i < len; i++) {
const phaseDiff = n0.phases[i] - n1.phases[i];
coherenceSum += Math.cos(phaseDiff);
}
const phaseCoherence = len > 0 ? coherenceSum / len : 0;
// Count matching nulls
const c0 = n0.classify();
const c1 = n1.classify();
const nullSet0 = new Set(c0.nulls);
const sharedNulls = c1.nulls.filter(i => nullSet0.has(i));
return {
correlation: correlation.toFixed(3),
phaseCoherence: phaseCoherence.toFixed(3),
los: phaseCoherence > COHERENCE_THRESH ? 'LINE-OF-SIGHT' : 'MULTIPATH',
sharedNulls: sharedNulls.length,
uniqueNulls0: c0.nulls.length - sharedNulls.length,
uniqueNulls1: c1.nulls.length - sharedNulls.length,
};
}
// ---------------------------------------------------------------------------
// Display
// ---------------------------------------------------------------------------
function buildProgressBar(value, max, width) {
const filled = Math.round((value / max) * width);
return '\u2588'.repeat(Math.min(filled, width)) +
'\u2591'.repeat(Math.max(0, width - filled));
}
function renderASCII() {
const lines = [];
const nodeList = [...nodes.values()].filter(n => n.nSubcarriers > 0);
if (nodeList.length === 0) {
lines.push(`=== RUVIEW RF SCAN === Listening on UDP :${PORT} ... no data yet`);
lines.push('Waiting for CSI frames from ESP32 nodes...');
lines.push(`Elapsed: ${((Date.now() - startTime) / 1000).toFixed(0)}s | Frames: ${totalFrames}`);
return lines.join('\n');
}
for (const node of nodeList) {
const ch = node.channel || '?';
const freq = node.freqMhz || '?';
lines.push(`=== RUVIEW RF SCAN -- Channel ${ch} (${freq} MHz) ===`);
lines.push(`Node ${node.nodeId} (${node.address || '?'}) | ${node.fps.toFixed(1)} fps | RSSI ${node.rssi} dBm | Noise ${node.noiseFloor} dBm`);
// Spectrum bar
const spectrum = node.getSpectrumBar();
if (spectrum.length > 0) {
lines.push(`Spectrum: ${spectrum}`);
// Type map
const types = node.getTypeMap();
lines.push(`Type: ${types.join('')}`);
lines.push(` ${TYPE_WALL} wall ${TYPE_PERSON} person ${TYPE_REFLECTOR} reflector ${TYPE_NULL} null(metal)`);
}
// Classification summary
const cls = node.classify();
lines.push('');
lines.push(`Objects: ${cls.nulls.length} null zones (metal) | ${cls.dynamic.length} dynamic (person/motion) | ${cls.reflectors.length} strong reflectors | ${cls.walls.length} static`);
const nullPct = node.nSubcarriers > 0
? ((cls.nulls.length / node.nSubcarriers) * 100).toFixed(0)
: '0';
lines.push(`Nulls: ${nullPct}% of subcarriers blocked`);
// Vitals
if (node.vitals) {
const v = node.vitals;
const presenceBar = buildProgressBar(v.presenceScore, 1, 10);
const motionBar = buildProgressBar(Math.min(v.motionEnergy, 1), 1, 10);
const position = v.presenceScore > 0.5 ? 'CENTERED' : v.presenceScore > 0.2 ? 'PERIPHERAL' : 'EMPTY';
lines.push(`Person: ${position} | BR ${v.breathingRate.toFixed(0)} BPM | HR ${v.heartrate.toFixed(0)} BPM | Motion ${v.motion ? 'HIGH' : 'LOW'}${v.fall ? ' | !! FALL !!' : ''}`);
lines.push(`Vitals: ${presenceBar} ${v.presenceScore.toFixed(2)} presence | ${motionBar} ${v.motionEnergy.toFixed(2)} motion | ${v.nPersons} person(s)`);
} else {
lines.push('Person: (awaiting vitals packet)');
}
// Feature vector
if (node.features) {
const fv = node.features.features.map(f => f.toFixed(3)).join(', ');
lines.push(`Feature: [${fv}]`);
}
lines.push('');
}
// Cross-node analysis
if (nodeList.length >= 2) {
const cross = computeCrossNodeCorrelation();
if (cross) {
lines.push('--- Cross-Node Analysis ---');
lines.push(`Correlation: ${cross.correlation} | Phase coherence: ${cross.phaseCoherence} | ${cross.los}`);
lines.push(`Nulls: ${cross.sharedNulls} shared | ${cross.uniqueNulls0} node-0-only | ${cross.uniqueNulls1} node-1-only`);
lines.push('');
}
}
// Summary line
const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
lines.push(`Elapsed: ${elapsed}s | Total frames: ${totalFrames} | Nodes: ${nodeList.length}`);
if (DURATION_MS) {
const remaining = Math.max(0, (DURATION_MS - (Date.now() - startTime)) / 1000).toFixed(0);
lines.push(`Remaining: ${remaining}s`);
}
return lines.join('\n');
}
function buildJsonOutput() {
const nodeList = [...nodes.values()].filter(n => n.nSubcarriers > 0);
const result = {
timestamp: new Date().toISOString(),
elapsedMs: Date.now() - startTime,
totalFrames,
nodes: nodeList.map(node => {
const cls = node.classify();
return {
nodeId: node.nodeId,
address: node.address,
channel: node.channel,
freqMhz: node.freqMhz,
rssi: node.rssi,
noiseFloor: node.noiseFloor,
fps: parseFloat(node.fps.toFixed(2)),
nSubcarriers: node.nSubcarriers,
frameCount: node.frameCount,
classification: {
nullCount: cls.nulls.length,
dynamicCount: cls.dynamic.length,
reflectorCount: cls.reflectors.length,
staticCount: cls.walls.length,
nullPercent: node.nSubcarriers > 0
? parseFloat(((cls.nulls.length / node.nSubcarriers) * 100).toFixed(1))
: 0,
},
vitals: node.vitals ? {
presence: node.vitals.presence,
presenceScore: node.vitals.presenceScore,
motionEnergy: node.vitals.motionEnergy,
breathingRate: node.vitals.breathingRate,
heartrate: node.vitals.heartrate,
nPersons: node.vitals.nPersons,
fall: node.vitals.fall,
} : null,
features: node.features ? node.features.features : null,
amplitudes: Array.from(node.amplitudes.subarray(0, node.nSubcarriers)),
phases: Array.from(node.phases.subarray(0, node.nSubcarriers)),
};
}),
crossNode: computeCrossNodeCorrelation(),
};
return result;
}
function display() {
if (JSON_OUTPUT) {
const data = buildJsonOutput();
process.stdout.write(JSON.stringify(data) + '\n');
} else {
// Clear screen and move cursor to top
process.stdout.write('\x1B[2J\x1B[H');
process.stdout.write(renderASCII() + '\n');
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
const server = dgram.createSocket('udp4');
server.on('error', (err) => {
console.error(`UDP error: ${err.message}`);
server.close();
process.exit(1);
});
server.on('message', (msg, rinfo) => {
handlePacket(msg, rinfo);
});
server.on('listening', () => {
const addr = server.address();
if (!JSON_OUTPUT) {
console.log(`RuView RF Scanner listening on ${addr.address}:${addr.port}`);
console.log('Waiting for CSI frames from ESP32 nodes...\n');
}
});
server.bind(PORT);
// Periodic display update
const displayTimer = setInterval(display, INTERVAL_MS);
// Duration timeout
if (DURATION_MS) {
setTimeout(() => {
clearInterval(displayTimer);
if (JSON_OUTPUT) {
// Final JSON summary
const summary = buildJsonOutput();
summary.final = true;
process.stdout.write(JSON.stringify(summary) + '\n');
} else {
display();
console.log('\n--- Scan complete ---');
const nodeList = [...nodes.values()].filter(n => n.nSubcarriers > 0);
console.log(`Duration: ${(DURATION_MS / 1000).toFixed(0)}s`);
console.log(`Total frames: ${totalFrames}`);
console.log(`Nodes detected: ${nodeList.length}`);
for (const node of nodeList) {
const cls = node.classify();
console.log(` Node ${node.nodeId}: ${node.frameCount} frames, ${node.fps.toFixed(1)} fps, ch ${node.channel}, ${cls.nulls.length} nulls (${((cls.nulls.length / Math.max(1, node.nSubcarriers)) * 100).toFixed(0)}%)`);
}
}
server.close();
process.exit(0);
}, DURATION_MS);
}
// Graceful shutdown
process.on('SIGINT', () => {
clearInterval(displayTimer);
if (!JSON_OUTPUT) {
console.log('\nShutting down...');
}
server.close();
process.exit(0);
});
}
main();