mirror of
https://github.com/ruvnet/RuView.git
synced 2026-04-28 14:09:33 +00:00
RF tomography (2D backprojection imaging), passive bistatic radar (neighbor APs as illuminators), frequency-selective material classification (metal/water/wood/glass), through-wall motion detection (per-channel penetration weighting), device fingerprinting (RF emission signatures per SSID) All impossible with single-channel WiFi — require 6-channel hopping. Co-Authored-By: claude-flow <ruv@ruv.net>
677 lines
22 KiB
JavaScript
677 lines
22 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Passive Bistatic Radar — Multi-Frequency Mesh Application
|
|
*
|
|
* Uses neighbor WiFi APs as illuminators of opportunity to build range-Doppler
|
|
* maps for moving target detection. Each neighbor AP is an uncontrolled
|
|
* transmitter whose signals pass through the room and are modulated by people
|
|
* and objects. The ESP32 nodes capture CSI from these transmissions across
|
|
* 6 channels.
|
|
*
|
|
* This is the same principle used by military passive radar (Kolchuga, VERA-NG)
|
|
* but with WiFi APs instead of broadcast towers.
|
|
*
|
|
* Requires multi-frequency mesh scanning (ADR-073): 2 ESP32 nodes hopping
|
|
* across channels 1, 3, 5, 6, 9, 11.
|
|
*
|
|
* Usage:
|
|
* node scripts/passive-radar.js
|
|
* node scripts/passive-radar.js --port 5006 --duration 60
|
|
* node scripts/passive-radar.js --replay data/recordings/overnight-1775217646.csi.jsonl
|
|
* node scripts/passive-radar.js --node-distance 3.0
|
|
*
|
|
* ADR: docs/adr/ADR-078-multifreq-mesh-applications.md
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const dgram = require('dgram');
|
|
const fs = require('fs');
|
|
const readline = require('readline');
|
|
const { parseArgs } = require('util');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CLI
|
|
// ---------------------------------------------------------------------------
|
|
const { values: args } = parseArgs({
|
|
options: {
|
|
port: { type: 'string', short: 'p', default: '5006' },
|
|
duration: { type: 'string', short: 'd' },
|
|
replay: { type: 'string', short: 'r' },
|
|
interval: { type: 'string', short: 'i', default: '3000' },
|
|
json: { type: 'boolean', default: false },
|
|
'node-distance': { type: 'string', default: '3.0' },
|
|
'doppler-bins': { type: 'string', default: '16' },
|
|
'range-bins': { type: 'string', default: '12' },
|
|
},
|
|
strict: true,
|
|
});
|
|
|
|
const PORT = parseInt(args.port, 10);
|
|
const DURATION_MS = args.duration ? parseInt(args.duration, 10) * 1000 : null;
|
|
const INTERVAL_MS = parseInt(args.interval, 10);
|
|
const JSON_OUTPUT = args.json;
|
|
const NODE_DISTANCE = parseFloat(args['node-distance']);
|
|
const DOPPLER_BINS = parseInt(args['doppler-bins'], 10);
|
|
const RANGE_BINS = parseInt(args['range-bins'], 10);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
const CSI_MAGIC = 0xC5110001;
|
|
const HEADER_SIZE = 20;
|
|
const SPEED_OF_LIGHT = 3e8; // m/s
|
|
|
|
const CHANNEL_FREQ = {};
|
|
for (let ch = 1; ch <= 13; ch++) CHANNEL_FREQ[ch] = 2412 + (ch - 1) * 5;
|
|
|
|
const NODE1_CHANNELS = [1, 6, 11];
|
|
const NODE2_CHANNELS = [3, 5, 9];
|
|
|
|
// Neighbor APs as illuminators with estimated positions
|
|
const ILLUMINATORS = [
|
|
{ ssid: 'ruv.net', channel: 5, signal: 100, pos: [1.5, 3.5], freq: 2432e6 },
|
|
{ ssid: 'Cohen-Guest', channel: 5, signal: 100, pos: [2.0, 3.8], freq: 2432e6 },
|
|
{ ssid: 'COGECO-21B20', channel: 11, signal: 100, pos: [4.0, 2.0], freq: 2462e6 },
|
|
{ ssid: 'HP M255', channel: 5, signal: 94, pos: [0.5, 1.5], freq: 2432e6 },
|
|
{ ssid: 'conclusion', channel: 3, signal: 44, pos: [3.5, 3.0], freq: 2422e6 },
|
|
{ ssid: 'NETGEAR72', channel: 9, signal: 42, pos: [4.5, 1.0], freq: 2452e6 },
|
|
{ ssid: 'COGECO-4321', channel: 11, signal: 30, pos: [4.0, 3.5], freq: 2462e6 },
|
|
{ ssid: 'Innanen', channel: 6, signal: 19, pos: [1.0, 4.0], freq: 2437e6 },
|
|
];
|
|
|
|
const NODE_POS = {
|
|
1: [0, 2.0],
|
|
2: [NODE_DISTANCE, 2.0],
|
|
};
|
|
|
|
// Range-Doppler plot characters
|
|
const RD_CHARS = [' ', '\u2581', '\u2582', '\u2583', '\u2584', '\u2585', '\u2586', '\u2587', '\u2588'];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Per-illuminator CSI history for Doppler processing
|
|
// ---------------------------------------------------------------------------
|
|
class IlluminatorTracker {
|
|
constructor(illuminator, nodeId) {
|
|
this.illuminator = illuminator;
|
|
this.nodeId = nodeId;
|
|
this.ssid = illuminator.ssid;
|
|
this.channel = illuminator.channel;
|
|
this.freqHz = illuminator.freq;
|
|
this.wavelength = SPEED_OF_LIGHT / this.freqHz;
|
|
|
|
// Phase history per subcarrier (ring buffer)
|
|
this.maxHistory = 64;
|
|
this.phaseHistory = []; // array of { timestamp, phases: Float64Array }
|
|
this.amplitudeHistory = [];
|
|
|
|
// Range-Doppler map
|
|
this.rangeDoppler = null;
|
|
this.lastUpdateMs = 0;
|
|
}
|
|
|
|
/** Ingest a new CSI frame */
|
|
ingest(timestamp, amplitudes, phases) {
|
|
this.phaseHistory.push({ timestamp, phases: new Float64Array(phases) });
|
|
this.amplitudeHistory.push({ timestamp, amplitudes: new Float64Array(amplitudes) });
|
|
|
|
if (this.phaseHistory.length > this.maxHistory) {
|
|
this.phaseHistory.shift();
|
|
this.amplitudeHistory.shift();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compute range-Doppler map from CSI phase history.
|
|
*
|
|
* Doppler: phase change rate across consecutive frames for each subcarrier.
|
|
* fd = d(phase)/dt / (2*pi) -> velocity = fd * wavelength / 2
|
|
*
|
|
* Range: phase slope across subcarriers within each frame.
|
|
* tau = d(phase)/d(subcarrier_freq) / (2*pi) -> range = c * tau
|
|
*/
|
|
computeRangeDoppler(dopplerBins, rangeBins) {
|
|
const n = this.phaseHistory.length;
|
|
if (n < 4) return null;
|
|
|
|
const nSub = this.phaseHistory[0].phases.length;
|
|
if (nSub < 4) return null;
|
|
|
|
// Initialize range-Doppler map
|
|
const rd = new Float64Array(rangeBins * dopplerBins);
|
|
|
|
// Doppler processing: compute phase change rate per subcarrier
|
|
const dopplerPerSub = new Float64Array(nSub);
|
|
const rangePerFrame = new Float64Array(n);
|
|
|
|
for (let sc = 0; sc < nSub; sc++) {
|
|
// Linear regression of phase vs time for this subcarrier
|
|
let sumT = 0, sumP = 0, sumTT = 0, sumTP = 0;
|
|
let prevPhase = this.phaseHistory[0].phases[sc];
|
|
|
|
for (let f = 0; f < n; f++) {
|
|
const t = this.phaseHistory[f].timestamp;
|
|
// Unwrap phase
|
|
let phase = this.phaseHistory[f].phases[sc];
|
|
while (phase - prevPhase > Math.PI) phase -= 2 * Math.PI;
|
|
while (phase - prevPhase < -Math.PI) phase += 2 * Math.PI;
|
|
prevPhase = phase;
|
|
|
|
sumT += t;
|
|
sumP += phase;
|
|
sumTT += t * t;
|
|
sumTP += t * phase;
|
|
}
|
|
|
|
const meanT = sumT / n;
|
|
const denom = sumTT - sumT * meanT;
|
|
if (Math.abs(denom) > 1e-10) {
|
|
const slope = (sumTP - sumT * (sumP / n)) / denom;
|
|
// Doppler frequency (Hz) = slope / (2*pi)
|
|
dopplerPerSub[sc] = slope / (2 * Math.PI);
|
|
}
|
|
}
|
|
|
|
// Range processing: phase slope across subcarriers per frame
|
|
const subcarrierSpacing = 312.5e3; // OFDM subcarrier spacing: 312.5 kHz
|
|
|
|
for (let f = 0; f < n; f++) {
|
|
const phases = this.phaseHistory[f].phases;
|
|
// Linear regression of phase vs subcarrier index
|
|
let sumI = 0, sumP = 0, sumII = 0, sumIP = 0;
|
|
let prevPhase = phases[0];
|
|
|
|
for (let sc = 0; sc < nSub; sc++) {
|
|
let phase = phases[sc];
|
|
// Unwrap
|
|
while (phase - prevPhase > Math.PI) phase -= 2 * Math.PI;
|
|
while (phase - prevPhase < -Math.PI) phase += 2 * Math.PI;
|
|
prevPhase = phase;
|
|
|
|
sumI += sc;
|
|
sumP += phase;
|
|
sumII += sc * sc;
|
|
sumIP += sc * phase;
|
|
}
|
|
|
|
const meanI = sumI / nSub;
|
|
const denom = sumII - sumI * meanI;
|
|
if (Math.abs(denom) > 1e-10) {
|
|
const slope = (sumIP - sumI * (sumP / nSub)) / denom;
|
|
// Time delay (seconds) = slope / (2*pi * subcarrier_spacing)
|
|
const tau = Math.abs(slope) / (2 * Math.PI * subcarrierSpacing);
|
|
rangePerFrame[f] = SPEED_OF_LIGHT * tau / 2; // bistatic range / 2
|
|
}
|
|
}
|
|
|
|
// Map to bins
|
|
const maxDoppler = 5.0; // Hz (corresponds to ~0.3 m/s at 2.4 GHz)
|
|
const maxRange = 10.0; // meters
|
|
|
|
for (let sc = 0; sc < nSub; sc++) {
|
|
const doppler = dopplerPerSub[sc];
|
|
const dBin = Math.floor(((doppler + maxDoppler) / (2 * maxDoppler)) * (dopplerBins - 1));
|
|
if (dBin < 0 || dBin >= dopplerBins) continue;
|
|
|
|
// Use mean amplitude as intensity
|
|
let meanAmp = 0;
|
|
for (let f = 0; f < n; f++) {
|
|
meanAmp += this.amplitudeHistory[f].amplitudes[sc];
|
|
}
|
|
meanAmp /= n;
|
|
|
|
// Average range across frames for this subcarrier's range bin
|
|
let meanRange = 0;
|
|
for (let f = 0; f < n; f++) meanRange += rangePerFrame[f];
|
|
meanRange /= n;
|
|
|
|
const rBin = Math.floor((meanRange / maxRange) * (rangeBins - 1));
|
|
if (rBin < 0 || rBin >= rangeBins) continue;
|
|
|
|
rd[rBin * dopplerBins + dBin] += meanAmp;
|
|
}
|
|
|
|
this.rangeDoppler = {
|
|
map: rd,
|
|
dopplerBins,
|
|
rangeBins,
|
|
maxDoppler,
|
|
maxRange,
|
|
nFrames: n,
|
|
};
|
|
|
|
return this.rangeDoppler;
|
|
}
|
|
|
|
/** Get dominant Doppler (strongest moving target) */
|
|
getDominantDoppler() {
|
|
if (!this.rangeDoppler) return null;
|
|
const { map, dopplerBins, rangeBins, maxDoppler } = this.rangeDoppler;
|
|
|
|
let maxVal = 0, maxD = 0, maxR = 0;
|
|
for (let r = 0; r < rangeBins; r++) {
|
|
for (let d = 0; d < dopplerBins; d++) {
|
|
const val = map[r * dopplerBins + d];
|
|
if (val > maxVal) {
|
|
maxVal = val;
|
|
maxD = d;
|
|
maxR = r;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (maxVal < 0.01) return null;
|
|
|
|
const doppler = (maxD / (dopplerBins - 1)) * 2 * maxDoppler - maxDoppler;
|
|
const velocity = doppler * this.wavelength / 2;
|
|
const range = (maxR / (rangeBins - 1)) * this.rangeDoppler.maxRange;
|
|
|
|
return { doppler: doppler.toFixed(2), velocity: velocity.toFixed(3), range: range.toFixed(1), intensity: maxVal.toFixed(1) };
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Multi-static fusion
|
|
// ---------------------------------------------------------------------------
|
|
class MultiStaticFusion {
|
|
constructor() {
|
|
this.trackers = new Map(); // key: `${ssid}-node${nodeId}` -> IlluminatorTracker
|
|
}
|
|
|
|
getOrCreateTracker(illuminator, nodeId) {
|
|
const key = `${illuminator.ssid}-node${nodeId}`;
|
|
if (!this.trackers.has(key)) {
|
|
this.trackers.set(key, new IlluminatorTracker(illuminator, nodeId));
|
|
}
|
|
return this.trackers.get(key);
|
|
}
|
|
|
|
ingestFrame(nodeId, channel, timestamp, amplitudes, phases) {
|
|
// Find illuminators on this channel
|
|
for (const il of ILLUMINATORS) {
|
|
if (il.channel === channel) {
|
|
const tracker = this.getOrCreateTracker(il, nodeId);
|
|
tracker.ingest(timestamp, amplitudes, phases);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Compute all range-Doppler maps */
|
|
computeAll(dopplerBins, rangeBins) {
|
|
const results = [];
|
|
for (const [key, tracker] of this.trackers) {
|
|
const rd = tracker.computeRangeDoppler(dopplerBins, rangeBins);
|
|
if (rd) {
|
|
results.push({ key, tracker, rd });
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Fuse multi-static detections.
|
|
* Each illuminator provides a range measurement to the target.
|
|
* The target lies on an ellipse with foci at TX (illuminator) and RX (ESP32 node).
|
|
* Intersection of multiple ellipses gives position.
|
|
*/
|
|
fuseDetections() {
|
|
const detections = [];
|
|
for (const [key, tracker] of this.trackers) {
|
|
const dom = tracker.getDominantDoppler();
|
|
if (dom && parseFloat(dom.intensity) > 1.0) {
|
|
detections.push({
|
|
key,
|
|
ssid: tracker.ssid,
|
|
channel: tracker.channel,
|
|
nodeId: tracker.nodeId,
|
|
txPos: tracker.illuminator.pos,
|
|
rxPos: NODE_POS[tracker.nodeId],
|
|
bistaticRange: parseFloat(dom.range),
|
|
velocity: parseFloat(dom.velocity),
|
|
intensity: parseFloat(dom.intensity),
|
|
});
|
|
}
|
|
}
|
|
|
|
if (detections.length < 2) {
|
|
return { detections, fusedPosition: null };
|
|
}
|
|
|
|
// Simple centroid-based fusion:
|
|
// For each detection, compute the midpoint of the TX-RX baseline
|
|
// weighted by intensity. This is a rough approximation.
|
|
// (Full ellipse intersection requires nonlinear optimization.)
|
|
let sumX = 0, sumY = 0, sumW = 0;
|
|
for (const det of detections) {
|
|
// Midpoint between TX and RX, offset by bistatic range
|
|
const mx = (det.txPos[0] + det.rxPos[0]) / 2;
|
|
const my = (det.txPos[1] + det.rxPos[1]) / 2;
|
|
const w = det.intensity;
|
|
sumX += mx * w;
|
|
sumY += my * w;
|
|
sumW += w;
|
|
}
|
|
|
|
const fusedPosition = sumW > 0
|
|
? { x: (sumX / sumW).toFixed(2), y: (sumY / sumW).toFixed(2), confidence: Math.min(1, detections.length / 4).toFixed(2) }
|
|
: null;
|
|
|
|
return { detections, fusedPosition };
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CSI parsing
|
|
// ---------------------------------------------------------------------------
|
|
function parseIqHex(iqHex, nSubcarriers) {
|
|
const bytes = Buffer.from(iqHex, 'hex');
|
|
const amplitudes = new Float64Array(nSubcarriers);
|
|
const phases = new Float64Array(nSubcarriers);
|
|
|
|
for (let sc = 0; sc < nSubcarriers; sc++) {
|
|
const offset = 2 + sc * 2;
|
|
if (offset + 1 >= bytes.length) break;
|
|
let I = bytes[offset];
|
|
let Q = bytes[offset + 1];
|
|
if (I > 127) I -= 256;
|
|
if (Q > 127) Q -= 256;
|
|
amplitudes[sc] = Math.sqrt(I * I + Q * Q);
|
|
phases[sc] = Math.atan2(Q, I);
|
|
}
|
|
|
|
return { amplitudes, phases };
|
|
}
|
|
|
|
function parseCSIFrame(buf) {
|
|
if (buf.length < HEADER_SIZE) return null;
|
|
const magic = buf.readUInt32LE(0);
|
|
if (magic !== CSI_MAGIC) return null;
|
|
|
|
const nodeId = buf.readUInt8(4);
|
|
const nSubcarriers = buf.readUInt16LE(6);
|
|
const freqMhz = buf.readUInt32LE(8);
|
|
const rssi = buf.readInt8(16);
|
|
|
|
const amplitudes = new Float64Array(nSubcarriers);
|
|
const phases = new Float64Array(nSubcarriers);
|
|
|
|
for (let sc = 0; sc < nSubcarriers; sc++) {
|
|
const offset = HEADER_SIZE + sc * 2;
|
|
if (offset + 1 >= buf.length) break;
|
|
const I = buf.readInt8(offset);
|
|
const Q = buf.readInt8(offset + 1);
|
|
amplitudes[sc] = Math.sqrt(I * I + Q * Q);
|
|
phases[sc] = Math.atan2(Q, I);
|
|
}
|
|
|
|
let channel = 0;
|
|
if (freqMhz >= 2412 && freqMhz <= 2484) {
|
|
channel = freqMhz === 2484 ? 14 : Math.round((freqMhz - 2412) / 5) + 1;
|
|
}
|
|
|
|
return { nodeId, nSubcarriers, freqMhz, rssi, amplitudes, phases, channel };
|
|
}
|
|
|
|
// Channel assignment for legacy JSONL
|
|
const nodeChannelIdx = { 1: 0, 2: 0 };
|
|
function assignChannel(nodeId) {
|
|
const channels = nodeId === 1 ? NODE1_CHANNELS : NODE2_CHANNELS;
|
|
const ch = channels[nodeChannelIdx[nodeId] % channels.length];
|
|
nodeChannelIdx[nodeId]++;
|
|
return ch;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Visualization
|
|
// ---------------------------------------------------------------------------
|
|
function renderRangeDoppler(tracker) {
|
|
const rd = tracker.rangeDoppler;
|
|
if (!rd) return ` ${tracker.ssid} (ch${tracker.channel}): insufficient data`;
|
|
|
|
const { map, dopplerBins, rangeBins, maxDoppler, maxRange, nFrames } = rd;
|
|
const lines = [];
|
|
|
|
lines.push(` ${tracker.ssid} (ch${tracker.channel}, node${tracker.nodeId}) | ${nFrames} frames`);
|
|
|
|
// Find max for normalization
|
|
let maxVal = 0;
|
|
for (let i = 0; i < map.length; i++) {
|
|
if (map[i] > maxVal) maxVal = map[i];
|
|
}
|
|
if (maxVal === 0) maxVal = 1;
|
|
|
|
// Render range (y-axis) vs Doppler (x-axis)
|
|
for (let r = rangeBins - 1; r >= 0; r--) {
|
|
const range = (r / (rangeBins - 1)) * maxRange;
|
|
let row = ` ${range.toFixed(1).padStart(5)}m |`;
|
|
for (let d = 0; d < dopplerBins; d++) {
|
|
const val = map[r * dopplerBins + d] / maxVal;
|
|
const level = Math.floor(val * 8.99);
|
|
row += RD_CHARS[Math.max(0, Math.min(8, level))];
|
|
}
|
|
row += '|';
|
|
lines.push(row);
|
|
}
|
|
|
|
// X-axis (Doppler)
|
|
lines.push(' ' + ' '.repeat(7) + '+' + '-'.repeat(dopplerBins) + '+');
|
|
const dLabel = ` ${' '.repeat(7)}-${maxDoppler}Hz${' '.repeat(Math.max(0, dopplerBins - 10))}+${maxDoppler}Hz`;
|
|
lines.push(dLabel);
|
|
|
|
// Dominant detection
|
|
const dom = tracker.getDominantDoppler();
|
|
if (dom) {
|
|
lines.push(` Peak: range=${dom.range}m doppler=${dom.doppler}Hz vel=${dom.velocity}m/s`);
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function renderFusion(fusion) {
|
|
const { detections, fusedPosition } = fusion;
|
|
const lines = [];
|
|
|
|
lines.push('');
|
|
lines.push(' MULTI-STATIC FUSION');
|
|
lines.push(' ' + '='.repeat(50));
|
|
|
|
if (detections.length === 0) {
|
|
lines.push(' No detections above threshold');
|
|
return lines.join('\n');
|
|
}
|
|
|
|
lines.push(` Active bistatic pairs: ${detections.length}`);
|
|
for (const det of detections) {
|
|
lines.push(` ${det.ssid.padEnd(16)} ch${det.channel} -> node${det.nodeId} | ` +
|
|
`range=${det.bistaticRange.toFixed(1)}m vel=${det.velocity.toFixed(3)}m/s`);
|
|
}
|
|
|
|
if (fusedPosition) {
|
|
lines.push(` Fused position: (${fusedPosition.x}, ${fusedPosition.y}) m confidence=${fusedPosition.confidence}`);
|
|
} else {
|
|
lines.push(' Insufficient detections for position fusion (need 2+)');
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Global state
|
|
// ---------------------------------------------------------------------------
|
|
const multiStatic = new MultiStaticFusion();
|
|
let lastDisplayMs = 0;
|
|
|
|
function processFrame(nodeId, channel, timestamp, amplitudes, phases) {
|
|
multiStatic.ingestFrame(nodeId, channel, timestamp, amplitudes, phases);
|
|
}
|
|
|
|
function displayUpdate() {
|
|
const results = multiStatic.computeAll(DOPPLER_BINS, RANGE_BINS);
|
|
const fusion = multiStatic.fuseDetections();
|
|
|
|
if (JSON_OUTPUT) {
|
|
const output = {
|
|
timestamp: Date.now() / 1000,
|
|
bistaticPairs: results.length,
|
|
detections: fusion.detections.map(d => ({
|
|
ssid: d.ssid, channel: d.channel, nodeId: d.nodeId,
|
|
bistaticRange: d.bistaticRange, velocity: d.velocity,
|
|
})),
|
|
fusedPosition: fusion.fusedPosition,
|
|
};
|
|
console.log(JSON.stringify(output));
|
|
} else {
|
|
process.stdout.write('\x1B[2J\x1B[H');
|
|
console.log(' PASSIVE BISTATIC RADAR');
|
|
console.log(' Using neighbor WiFi APs as illuminators of opportunity');
|
|
console.log(' ' + '-'.repeat(55));
|
|
console.log('');
|
|
|
|
// Show top 3 trackers by signal strength
|
|
const sorted = results.sort((a, b) => b.tracker.illuminator.signal - a.tracker.illuminator.signal);
|
|
for (const r of sorted.slice(0, 3)) {
|
|
console.log(renderRangeDoppler(r.tracker));
|
|
console.log('');
|
|
}
|
|
|
|
console.log(renderFusion(fusion));
|
|
console.log('');
|
|
console.log(` Total bistatic pairs: ${multiStatic.trackers.size}`);
|
|
console.log(' Press Ctrl+C to exit');
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Live mode
|
|
// ---------------------------------------------------------------------------
|
|
function startLive() {
|
|
const sock = dgram.createSocket('udp4');
|
|
|
|
sock.on('message', (buf, rinfo) => {
|
|
if (buf.length < 4) return;
|
|
const magic = buf.readUInt32LE(0);
|
|
if (magic !== CSI_MAGIC) return;
|
|
|
|
const frame = parseCSIFrame(buf);
|
|
if (!frame) return;
|
|
|
|
processFrame(frame.nodeId, frame.channel, Date.now() / 1000, frame.amplitudes, frame.phases);
|
|
|
|
const now = Date.now();
|
|
if (now - lastDisplayMs >= INTERVAL_MS) {
|
|
displayUpdate();
|
|
lastDisplayMs = now;
|
|
}
|
|
});
|
|
|
|
sock.bind(PORT, () => {
|
|
if (!JSON_OUTPUT) {
|
|
console.log(`Passive Bistatic Radar listening on UDP port ${PORT}`);
|
|
console.log(`Illuminators: ${ILLUMINATORS.length} neighbor APs`);
|
|
console.log(`Node distance: ${NODE_DISTANCE} m`);
|
|
console.log('Waiting for CSI frames...');
|
|
}
|
|
});
|
|
|
|
if (DURATION_MS) {
|
|
setTimeout(() => {
|
|
displayUpdate();
|
|
sock.close();
|
|
process.exit(0);
|
|
}, DURATION_MS);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Replay mode
|
|
// ---------------------------------------------------------------------------
|
|
async function startReplay(filePath) {
|
|
if (!fs.existsSync(filePath)) {
|
|
console.error(`File not found: ${filePath}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const rl = readline.createInterface({
|
|
input: fs.createReadStream(filePath),
|
|
crlfDelay: Infinity,
|
|
});
|
|
|
|
let frameCount = 0;
|
|
let lastAnalysisTs = 0;
|
|
let windowCount = 0;
|
|
|
|
for await (const line of rl) {
|
|
if (!line.trim()) continue;
|
|
|
|
let record;
|
|
try { record = JSON.parse(line); } catch { continue; }
|
|
if (record.type !== 'raw_csi' || !record.iq_hex) continue;
|
|
|
|
const { amplitudes, phases } = parseIqHex(record.iq_hex, record.subcarriers || 64);
|
|
const channel = record.channel || assignChannel(record.node_id);
|
|
|
|
processFrame(record.node_id, channel, record.timestamp, amplitudes, phases);
|
|
frameCount++;
|
|
|
|
const tsMs = record.timestamp * 1000;
|
|
if (lastAnalysisTs === 0) lastAnalysisTs = tsMs;
|
|
|
|
if (tsMs - lastAnalysisTs >= INTERVAL_MS) {
|
|
windowCount++;
|
|
const results = multiStatic.computeAll(DOPPLER_BINS, RANGE_BINS);
|
|
const fusion = multiStatic.fuseDetections();
|
|
|
|
if (JSON_OUTPUT) {
|
|
console.log(JSON.stringify({
|
|
window: windowCount,
|
|
timestamp: record.timestamp,
|
|
frames: frameCount,
|
|
detections: fusion.detections.length,
|
|
fusedPosition: fusion.fusedPosition,
|
|
}));
|
|
} else {
|
|
console.log(`\n${'='.repeat(60)}`);
|
|
console.log(`Window ${windowCount} | t=${record.timestamp.toFixed(1)}s | frames=${frameCount}`);
|
|
console.log('='.repeat(60));
|
|
|
|
const sorted = results.sort((a, b) => b.tracker.illuminator.signal - a.tracker.illuminator.signal);
|
|
for (const r of sorted.slice(0, 3)) {
|
|
console.log(renderRangeDoppler(r.tracker));
|
|
console.log('');
|
|
}
|
|
|
|
console.log(renderFusion(fusion));
|
|
}
|
|
lastAnalysisTs = tsMs;
|
|
}
|
|
}
|
|
|
|
// Final
|
|
if (!JSON_OUTPUT) {
|
|
const results = multiStatic.computeAll(DOPPLER_BINS, RANGE_BINS);
|
|
const fusion = multiStatic.fuseDetections();
|
|
|
|
console.log(`\n${'='.repeat(60)}`);
|
|
console.log('FINAL PASSIVE RADAR SUMMARY');
|
|
console.log('='.repeat(60));
|
|
|
|
for (const [key, tracker] of multiStatic.trackers) {
|
|
const dom = tracker.getDominantDoppler();
|
|
const domStr = dom ? `range=${dom.range}m vel=${dom.velocity}m/s` : 'no detection';
|
|
console.log(` ${key.padEnd(30)} ${domStr}`);
|
|
}
|
|
|
|
console.log(renderFusion(fusion));
|
|
console.log(`\nProcessed ${frameCount} frames in ${windowCount} windows`);
|
|
console.log(`Bistatic pairs tracked: ${multiStatic.trackers.size}`);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Entry point
|
|
// ---------------------------------------------------------------------------
|
|
if (args.replay) {
|
|
startReplay(args.replay);
|
|
} else {
|
|
startLive();
|
|
}
|