#!/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 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();