mirror of
https://github.com/ruvnet/RuView.git
synced 2026-04-28 05:59:32 +00:00
Windows firewall blocks UDP on 0.0.0.0 — must bind to specific WiFi IP. - seed_csi_bridge.py: --bind-addr auto (auto-detects WiFi IP) - rf-scan.js: --bind <ip> option (default 0.0.0.0, use 192.168.1.x on Windows) Confirmed: 195 frames received from both ESP32 nodes with --bind 192.168.1.20 Co-Authored-By: claude-flow <ruv@ruv.net>
625 lines
20 KiB
JavaScript
625 lines
20 KiB
JavaScript
#!/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' },
|
|
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' },
|
|
},
|
|
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');
|
|
}
|
|
});
|
|
|
|
// 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);
|
|
|
|
// 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();
|