mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
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:
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user