feat: ADR-078 — 5 multi-frequency mesh applications

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>
This commit is contained in:
ruv
2026-04-03 08:52:50 -04:00
parent 4f6780f884
commit 4fc491dea5
6 changed files with 3535 additions and 0 deletions
+715
View File
@@ -0,0 +1,715 @@
#!/usr/bin/env node
/**
* Device Fingerprinting via RF Emissions — Multi-Frequency Mesh Application
*
* Identifies electronic devices by their unique RF characteristics across
* multiple WiFi channels. Each device creates distinctive subcarrier patterns:
*
* - WiFi APs: unique transmit power, phase noise, clock drift
* - Printers: motor EMI creates specific subcarrier modulation
* - Microwaves: 2.45 GHz magnetron radiates across channels 8-11
* - Bluetooth: frequency-hopping creates transient spikes
*
* Correlates WiFi scan SSID/signal with CSI patterns to build per-device
* fingerprints, then detects when devices become active or inactive.
*
* Requires multi-frequency mesh scanning (ADR-073): 2 ESP32 nodes hopping
* across channels 1, 3, 5, 6, 9, 11.
*
* Usage:
* node scripts/device-fingerprint.js
* node scripts/device-fingerprint.js --port 5006 --duration 120
* node scripts/device-fingerprint.js --replay data/recordings/overnight-1775217646.csi.jsonl
* node scripts/device-fingerprint.js --learn 30
*
* 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: '5000' },
learn: { type: 'string', short: 'l', default: '20' },
json: { type: 'boolean', default: false },
'save-fingerprints': { type: 'string' },
'load-fingerprints': { type: 'string' },
},
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 LEARN_DURATION = parseInt(args.learn, 10);
const JSON_OUTPUT = args.json;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CSI_MAGIC = 0xC5110001;
const HEADER_SIZE = 20;
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];
// Known devices from WiFi scan (these are the devices we can fingerprint)
const KNOWN_DEVICES = [
{ id: 'ruv-net', ssid: 'ruv.net', channel: 5, signal: 100, type: 'router' },
{ id: 'cohen-guest', ssid: 'Cohen-Guest', channel: 5, signal: 100, type: 'router' },
{ id: 'cogeco-21b20', ssid: 'COGECO-21B20', channel: 11, signal: 100, type: 'router' },
{ id: 'hp-printer', ssid: 'DIRECT-fa-HP M255 LaserJet', channel: 5, signal: 94, type: 'printer' },
{ id: 'conclusion', ssid: 'conclusion mesh', channel: 3, signal: 44, type: 'mesh-node' },
{ id: 'netgear72', ssid: 'NETGEAR72', channel: 9, signal: 42, type: 'router' },
{ id: 'cogeco-4321', ssid: 'COGECO-4321', channel: 11, signal: 30, type: 'router' },
{ id: 'innanen', ssid: 'Innanen', channel: 6, signal: 19, type: 'router' },
];
// Activity states
const ACTIVITY = {
UNKNOWN: 'unknown',
ACTIVE: 'active',
IDLE: 'idle',
CHANGED: 'changed',
};
// ---------------------------------------------------------------------------
// Device fingerprint
// ---------------------------------------------------------------------------
class DeviceFingerprint {
constructor(device) {
this.device = device;
this.id = device.id;
this.channel = device.channel;
// Per-subcarrier signature (learned during training)
this.baselineMean = null; // Float64Array
this.baselineStd = null; // Float64Array
this.varianceProfile = null; // Float64Array - characteristic variance pattern
this.nSub = 0;
this.trainCount = 0;
// Welford accumulators for training
this._sum = null;
this._sumSq = null;
this._varSum = null;
this._varSumSq = null;
this._frameAmps = []; // store recent frames for variance computation
// Runtime state
this.activity = ACTIVITY.UNKNOWN;
this.lastScore = 0;
this.lastSeen = 0;
this.activityHistory = [];
this.maxHistory = 30;
}
/** Ingest a training frame */
train(amplitudes) {
const n = amplitudes.length;
if (!this._sum) {
this.nSub = n;
this._sum = new Float64Array(n);
this._sumSq = new Float64Array(n);
}
this.trainCount++;
for (let i = 0; i < n && i < this.nSub; i++) {
this._sum[i] += amplitudes[i];
this._sumSq[i] += amplitudes[i] * amplitudes[i];
}
// Keep last 10 frames for variance profile
this._frameAmps.push(new Float64Array(amplitudes));
if (this._frameAmps.length > 10) this._frameAmps.shift();
}
/** Finalize training */
finalizeTrain() {
if (this.trainCount < 3 || !this._sum) return false;
this.baselineMean = new Float64Array(this.nSub);
this.baselineStd = new Float64Array(this.nSub);
for (let i = 0; i < this.nSub; i++) {
this.baselineMean[i] = this._sum[i] / this.trainCount;
const variance = (this._sumSq[i] / this.trainCount) - (this.baselineMean[i] ** 2);
this.baselineStd[i] = Math.sqrt(Math.max(0, variance));
if (this.baselineStd[i] < 0.1) this.baselineStd[i] = 0.1;
}
// Compute variance profile from stored frames
if (this._frameAmps.length >= 3) {
this.varianceProfile = new Float64Array(this.nSub);
for (let i = 0; i < this.nSub; i++) {
let sum = 0, sumSq = 0;
for (const frame of this._frameAmps) {
sum += frame[i];
sumSq += frame[i] * frame[i];
}
const n = this._frameAmps.length;
const mean = sum / n;
this.varianceProfile[i] = (sumSq / n) - (mean * mean);
}
}
// Clean up training data
this._sum = null;
this._sumSq = null;
this._frameAmps = [];
return true;
}
/**
* Score a new frame against this device's fingerprint.
* Returns a similarity score (0 = no match, 1 = perfect match).
*/
score(amplitudes) {
if (!this.baselineMean) return 0;
const n = Math.min(amplitudes.length, this.nSub);
let matchScore = 0;
let count = 0;
for (let i = 0; i < n; i++) {
// Normalized difference from baseline
const diff = Math.abs(amplitudes[i] - this.baselineMean[i]);
const normalizedDiff = diff / this.baselineStd[i];
// Score: 1.0 if within 1 std, decreasing beyond
const subScore = Math.exp(-0.5 * normalizedDiff * normalizedDiff);
matchScore += subScore;
count++;
}
return count > 0 ? matchScore / count : 0;
}
/**
* Detect activity change.
* Compare current frame's variance against baseline variance profile.
*/
detectActivity(amplitudes, timestamp) {
const similarity = this.score(amplitudes);
this.lastScore = similarity;
this.lastSeen = timestamp;
// Activity thresholds
const prevActivity = this.activity;
if (similarity > 0.7) {
this.activity = ACTIVITY.ACTIVE;
} else if (similarity > 0.4) {
this.activity = ACTIVITY.CHANGED;
} else {
this.activity = ACTIVITY.IDLE;
}
// Record transitions
if (prevActivity !== this.activity && prevActivity !== ACTIVITY.UNKNOWN) {
this.activityHistory.push({
timestamp,
from: prevActivity,
to: this.activity,
score: similarity.toFixed(3),
});
if (this.activityHistory.length > this.maxHistory) this.activityHistory.shift();
}
return {
id: this.id,
ssid: this.device.ssid,
type: this.device.type,
channel: this.channel,
activity: this.activity,
similarity: similarity.toFixed(3),
changed: prevActivity !== this.activity && prevActivity !== ACTIVITY.UNKNOWN,
};
}
/** Export fingerprint for persistence */
exportFingerprint() {
return {
id: this.id,
device: this.device,
nSub: this.nSub,
trainCount: this.trainCount,
baselineMean: this.baselineMean ? Array.from(this.baselineMean) : null,
baselineStd: this.baselineStd ? Array.from(this.baselineStd) : null,
varianceProfile: this.varianceProfile ? Array.from(this.varianceProfile) : null,
};
}
/** Import fingerprint from saved data */
importFingerprint(data) {
this.nSub = data.nSub;
this.trainCount = data.trainCount;
this.baselineMean = data.baselineMean ? new Float64Array(data.baselineMean) : null;
this.baselineStd = data.baselineStd ? new Float64Array(data.baselineStd) : null;
this.varianceProfile = data.varianceProfile ? new Float64Array(data.varianceProfile) : null;
}
}
// ---------------------------------------------------------------------------
// Device fingerprint manager
// ---------------------------------------------------------------------------
class FingerprintManager {
constructor(learnDuration) {
this.learnDuration = learnDuration;
this.fingerprints = new Map(); // id -> DeviceFingerprint
this.learning = true;
this.startTime = null;
this.totalFrames = 0;
// Initialize fingerprints for known devices
for (const device of KNOWN_DEVICES) {
this.fingerprints.set(device.id, new DeviceFingerprint(device));
}
}
ingestFrame(channel, amplitudes, timestamp) {
this.totalFrames++;
if (!this.startTime) this.startTime = timestamp;
// Learning phase: train fingerprints for devices on this channel
if (this.learning) {
for (const fp of this.fingerprints.values()) {
if (fp.channel === channel) {
fp.train(amplitudes);
}
}
if (timestamp - this.startTime >= this.learnDuration) {
// Finalize all fingerprints
let trained = 0;
for (const fp of this.fingerprints.values()) {
if (fp.finalizeTrain()) trained++;
}
this.learning = false;
return { event: 'learn_complete', trained, total: this.fingerprints.size };
}
return { event: 'learning', elapsed: timestamp - this.startTime, duration: this.learnDuration };
}
// Detection phase: score all devices on this channel
const results = [];
for (const fp of this.fingerprints.values()) {
if (fp.channel === channel) {
const result = fp.detectActivity(amplitudes, timestamp);
results.push(result);
}
}
return { event: 'detect', results };
}
/** Get current device activity summary */
getSummary() {
const devices = [];
for (const fp of this.fingerprints.values()) {
devices.push({
id: fp.id,
ssid: fp.device.ssid,
type: fp.device.type,
channel: fp.channel,
activity: fp.activity,
similarity: fp.lastScore.toFixed(3),
trained: fp.baselineMean !== null,
trainFrames: fp.trainCount,
transitions: fp.activityHistory.length,
});
}
return {
learning: this.learning,
totalFrames: this.totalFrames,
devices: devices.sort((a, b) => parseFloat(b.similarity) - parseFloat(a.similarity)),
};
}
/** Save fingerprints to file */
saveFingerprints(filePath) {
const data = {};
for (const [id, fp] of this.fingerprints) {
if (fp.baselineMean) {
data[id] = fp.exportFingerprint();
}
}
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
return Object.keys(data).length;
}
/** Load fingerprints from file */
loadFingerprints(filePath) {
if (!fs.existsSync(filePath)) return 0;
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
let loaded = 0;
for (const [id, fpData] of Object.entries(data)) {
if (this.fingerprints.has(id)) {
this.fingerprints.get(id).importFingerprint(fpData);
loaded++;
}
}
if (loaded > 0) this.learning = false;
return loaded;
}
}
// ---------------------------------------------------------------------------
// CSI parsing
// ---------------------------------------------------------------------------
function parseIqHex(iqHex, nSubcarriers) {
const bytes = Buffer.from(iqHex, 'hex');
const amplitudes = 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);
}
return amplitudes;
}
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 amplitudes = 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);
}
let channel = 0;
if (freqMhz >= 2412 && freqMhz <= 2484) {
channel = freqMhz === 2484 ? 14 : Math.round((freqMhz - 2412) / 5) + 1;
}
return { nodeId, nSubcarriers, freqMhz, amplitudes, channel };
}
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 renderDeviceTable(manager) {
const summary = manager.getSummary();
const lines = [];
lines.push('');
lines.push(' DEVICE FINGERPRINTING — RF EMISSIONS ANALYSIS');
lines.push(' ' + '='.repeat(60));
lines.push('');
if (summary.learning) {
const elapsed = manager.startTime ? Date.now() / 1000 - manager.startTime : 0;
const progress = Math.min(100, (elapsed / manager.learnDuration) * 100);
const barLen = Math.floor(progress / 2);
const bar = '\u2588'.repeat(barLen) + '\u2591'.repeat(50 - barLen);
lines.push(` Learning device signatures: [${bar}] ${progress.toFixed(0)}%`);
lines.push(` Frames: ${summary.totalFrames}`);
lines.push('');
}
// Device activity table
const activitySymbol = {
[ACTIVITY.ACTIVE]: '[ON] ',
[ACTIVITY.IDLE]: '[off]',
[ACTIVITY.CHANGED]: '[CHG]',
[ACTIVITY.UNKNOWN]: '[ ? ]',
};
lines.push(' Device Type Ch Similarity Status');
lines.push(' ' + '-'.repeat(65));
for (const dev of summary.devices) {
const status = activitySymbol[dev.activity] || '[ ? ]';
const trained = dev.trained ? '' : ' (untrained)';
lines.push(
` ${dev.ssid.substring(0, 28).padEnd(30)} ${dev.type.padEnd(10)} ${String(dev.channel).padStart(2)} ` +
`${dev.similarity.padStart(7)} ${status}${trained}`
);
}
return lines.join('\n');
}
function renderTimeline(manager) {
const summary = manager.getSummary();
const lines = [];
lines.push('');
lines.push(' Activity Transitions:');
lines.push(' ' + '-'.repeat(50));
let hasTransitions = false;
for (const dev of summary.devices) {
const fp = manager.fingerprints.get(dev.id);
if (fp && fp.activityHistory.length > 0) {
hasTransitions = true;
const recent = fp.activityHistory.slice(-3);
for (const t of recent) {
const time = new Date(t.timestamp * 1000).toISOString().substring(11, 19);
lines.push(` ${time} ${dev.ssid.substring(0, 20).padEnd(20)} ${t.from} -> ${t.to} (score=${t.score})`);
}
}
}
if (!hasTransitions) {
lines.push(' (no transitions detected yet)');
}
return lines.join('\n');
}
function renderChannelActivity(manager) {
const summary = manager.getSummary();
const lines = [];
lines.push('');
lines.push(' Per-Channel Device Activity:');
const channels = [...new Set(summary.devices.map(d => d.channel))].sort((a, b) => a - b);
for (const ch of channels) {
const devs = summary.devices.filter(d => d.channel === ch);
const activeCount = devs.filter(d => d.activity === ACTIVITY.ACTIVE).length;
lines.push(` ch${ch} (${CHANNEL_FREQ[ch]} MHz): ${activeCount}/${devs.length} devices active`);
for (const dev of devs) {
const bar = '\u2588'.repeat(Math.floor(parseFloat(dev.similarity) * 20));
lines.push(` ${dev.ssid.substring(0, 18).padEnd(18)} ${bar} ${dev.similarity}`);
}
}
return lines.join('\n');
}
// ---------------------------------------------------------------------------
// Global state
// ---------------------------------------------------------------------------
const manager = new FingerprintManager(LEARN_DURATION);
let lastDisplayMs = 0;
// Load saved fingerprints if specified
if (args['load-fingerprints']) {
const loaded = manager.loadFingerprints(args['load-fingerprints']);
if (!JSON_OUTPUT) console.log(`Loaded ${loaded} fingerprints from ${args['load-fingerprints']}`);
}
function displayUpdate() {
if (JSON_OUTPUT) {
const summary = manager.getSummary();
console.log(JSON.stringify({
timestamp: Date.now() / 1000,
learning: summary.learning,
totalFrames: summary.totalFrames,
devices: summary.devices.map(d => ({
id: d.id, ssid: d.ssid, activity: d.activity,
similarity: d.similarity, channel: d.channel,
})),
}));
} else {
process.stdout.write('\x1B[2J\x1B[H');
console.log(renderDeviceTable(manager));
console.log(renderTimeline(manager));
console.log(renderChannelActivity(manager));
console.log('');
console.log(` Total frames: ${manager.totalFrames}`);
console.log(' Press Ctrl+C to exit');
}
}
// ---------------------------------------------------------------------------
// Live mode
// ---------------------------------------------------------------------------
function startLive() {
const sock = dgram.createSocket('udp4');
sock.on('message', (buf) => {
if (buf.length < 4) return;
const magic = buf.readUInt32LE(0);
if (magic !== CSI_MAGIC) return;
const frame = parseCSIFrame(buf);
if (!frame) return;
const result = manager.ingestFrame(frame.channel, frame.amplitudes, Date.now() / 1000);
// Announce learning completion
if (result && result.event === 'learn_complete' && !JSON_OUTPUT) {
console.log(`\nLearning complete! Trained ${result.trained}/${result.total} device fingerprints`);
}
const now = Date.now();
if (now - lastDisplayMs >= INTERVAL_MS) {
displayUpdate();
lastDisplayMs = now;
}
});
sock.bind(PORT, () => {
if (!JSON_OUTPUT) {
console.log(`Device Fingerprinter listening on UDP port ${PORT}`);
console.log(`Learning duration: ${LEARN_DURATION}s`);
console.log(`Known devices: ${KNOWN_DEVICES.length}`);
console.log('Waiting for CSI frames...');
}
});
if (DURATION_MS) {
setTimeout(() => {
displayUpdate();
if (args['save-fingerprints']) {
const saved = manager.saveFingerprints(args['save-fingerprints']);
if (!JSON_OUTPUT) console.log(`Saved ${saved} fingerprints to ${args['save-fingerprints']}`);
}
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;
let learnComplete = false;
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 = parseIqHex(record.iq_hex, record.subcarriers || 64);
const channel = record.channel || assignChannel(record.node_id);
const result = manager.ingestFrame(channel, amplitudes, record.timestamp);
frameCount++;
if (result && result.event === 'learn_complete' && !learnComplete) {
learnComplete = true;
if (!JSON_OUTPUT) {
console.log(`\nLearning complete at t=${record.timestamp.toFixed(1)}s`);
console.log(`Trained ${result.trained}/${result.total} device fingerprints`);
console.log('');
}
}
const tsMs = record.timestamp * 1000;
if (lastAnalysisTs === 0) lastAnalysisTs = tsMs;
if (tsMs - lastAnalysisTs >= INTERVAL_MS) {
windowCount++;
const summary = manager.getSummary();
if (JSON_OUTPUT) {
console.log(JSON.stringify({
window: windowCount,
timestamp: record.timestamp,
learning: summary.learning,
devices: summary.devices.map(d => ({
id: d.id, activity: d.activity, similarity: d.similarity,
})),
}));
} else if (!summary.learning) {
// Compact per-window output
const active = summary.devices.filter(d => d.activity === ACTIVITY.ACTIVE);
const changed = summary.devices.filter(d => d.activity === ACTIVITY.CHANGED);
let line = ` [${String(windowCount).padStart(4)}] t=${record.timestamp.toFixed(1)}s active: `;
line += active.length > 0
? active.map(d => `${d.ssid.substring(0, 15)}(${d.similarity})`).join(', ')
: '(none)';
if (changed.length > 0) {
line += ' changed: ' + changed.map(d => d.ssid.substring(0, 12)).join(', ');
}
console.log(line);
}
lastAnalysisTs = tsMs;
}
}
// Save fingerprints if requested
if (args['save-fingerprints']) {
const saved = manager.saveFingerprints(args['save-fingerprints']);
if (!JSON_OUTPUT) console.log(`\nSaved ${saved} fingerprints to ${args['save-fingerprints']}`);
}
// Final summary
if (!JSON_OUTPUT) {
const summary = manager.getSummary();
console.log('');
console.log('='.repeat(60));
console.log('DEVICE FINGERPRINT SUMMARY');
console.log('='.repeat(60));
console.log(renderDeviceTable(manager));
console.log(renderTimeline(manager));
// Statistics
const trained = summary.devices.filter(d => d.trained).length;
const active = summary.devices.filter(d => d.activity === ACTIVITY.ACTIVE).length;
console.log('');
console.log(` Trained fingerprints: ${trained}/${summary.devices.length}`);
console.log(` Currently active: ${active}/${summary.devices.length}`);
console.log(` Total frames: ${frameCount}`);
console.log(` Analysis windows: ${windowCount}`);
}
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
if (args.replay) {
startReplay(args.replay);
} else {
startLive();
}
+613
View File
@@ -0,0 +1,613 @@
#!/usr/bin/env node
/**
* Frequency-Selective Material Classification — Multi-Frequency Mesh Application
*
* Compares CSI null/attenuation patterns across 6 WiFi channels to classify
* materials in the room. Different materials absorb WiFi at different rates
* depending on frequency:
*
* Metal: blocks all frequencies equally (frequency-flat null)
* Water: absorbs strongly, increasing with frequency (dielectric loss)
* Wood: mild attenuation, increases with frequency (moisture)
* Glass: low attenuation, nearly frequency-flat
* Human: 60-70% water, strong frequency-dependent absorption
*
* Requires multi-frequency mesh scanning (ADR-073): 2 ESP32 nodes hopping
* across channels 1, 3, 5, 6, 9, 11.
*
* Usage:
* node scripts/material-classifier.js
* node scripts/material-classifier.js --port 5006 --duration 60
* node scripts/material-classifier.js --replay data/recordings/overnight-1775217646.csi.jsonl
*
* 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: '5000' },
json: { type: 'boolean', default: false },
window: { type: 'string', short: 'w', default: '20' },
},
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 WINDOW_FRAMES = parseInt(args.window, 10);
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CSI_MAGIC = 0xC5110001;
const HEADER_SIZE = 20;
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];
// Material classification thresholds
const NULL_THRESHOLD = 2.0;
// Material types
const MATERIAL = {
METAL: { name: 'Metal', char: '#', desc: 'Total block, frequency-flat' },
WATER: { name: 'Water', char: '~', desc: 'Strong absorption, freq-dependent' },
HUMAN: { name: 'Human', char: '@', desc: '60-70% water, strong freq-dependent' },
WOOD: { name: 'Wood', char: '|', desc: 'Mild attenuation, freq-increasing' },
GLASS: { name: 'Glass', char: ':', desc: 'Low attenuation, frequency-flat' },
AIR: { name: 'Air', char: '.', desc: 'Minimal attenuation' },
COMPLEX: { name: 'Complex', char: '?', desc: 'Mixed/unclassifiable' },
};
// ---------------------------------------------------------------------------
// Per-channel amplitude accumulator
// ---------------------------------------------------------------------------
class ChannelAccumulator {
constructor() {
// channel -> { amplitudes: Float64Array[], count: number }
this.channels = new Map();
}
ingest(channel, amplitudes) {
if (!this.channels.has(channel)) {
this.channels.set(channel, {
sum: new Float64Array(amplitudes.length),
sumSq: new Float64Array(amplitudes.length),
count: 0,
nSub: amplitudes.length,
});
}
const ch = this.channels.get(channel);
ch.count++;
for (let i = 0; i < amplitudes.length && i < ch.nSub; i++) {
ch.sum[i] += amplitudes[i];
ch.sumSq[i] += amplitudes[i] * amplitudes[i];
}
}
/** Get mean amplitude per subcarrier per channel */
getMeans() {
const means = new Map();
for (const [channel, ch] of this.channels) {
if (ch.count === 0) continue;
const mean = new Float64Array(ch.nSub);
for (let i = 0; i < ch.nSub; i++) {
mean[i] = ch.sum[i] / ch.count;
}
means.set(channel, { mean, count: ch.count, nSub: ch.nSub });
}
return means;
}
/** Get variance per subcarrier per channel */
getVariances() {
const variances = new Map();
for (const [channel, ch] of this.channels) {
if (ch.count < 2) continue;
const variance = new Float64Array(ch.nSub);
for (let i = 0; i < ch.nSub; i++) {
const mean = ch.sum[i] / ch.count;
variance[i] = (ch.sumSq[i] / ch.count) - (mean * mean);
}
variances.set(channel, variance);
}
return variances;
}
/** Get active channel list sorted by frequency */
getActiveChannels() {
return [...this.channels.keys()]
.filter(ch => this.channels.get(ch).count > 0)
.sort((a, b) => a - b);
}
reset() {
this.channels.clear();
}
}
// ---------------------------------------------------------------------------
// Material classifier
// ---------------------------------------------------------------------------
class MaterialClassifier {
constructor() {
this.accumulator = new ChannelAccumulator();
this.frameCount = 0;
this.classifications = [];
}
ingestFrame(channel, amplitudes) {
this.accumulator.ingest(channel, amplitudes);
this.frameCount++;
}
/**
* Classify each subcarrier group by comparing attenuation across channels.
*
* For each subcarrier index:
* 1. Collect mean amplitude on each channel
* 2. Compute frequency selectivity metrics:
* - Flat ratio = std / mean (low = frequency-flat)
* - Slope = linear regression of amplitude vs frequency
* - Mean level = overall attenuation (high = strong absorber)
* 3. Decision tree:
* - All channels null -> Metal (frequency-flat total block)
* - Flat ratio < 0.15 AND mean < 3.0 -> Metal
* - Flat ratio < 0.15 AND mean > 8.0 -> Glass/Air
* - Negative slope (amp decreases with freq) AND mean < 6.0 -> Water/Human
* - Negative slope AND mean 6.0-8.0 -> Wood
* - High variance across channels -> Complex
*/
classify() {
const means = this.accumulator.getMeans();
const channels = this.accumulator.getActiveChannels();
if (channels.length < 2) {
return { error: 'Need at least 2 channels for material classification', channels: channels.length };
}
const nSub = Math.min(...[...means.values()].map(m => m.nSub));
const freqs = channels.map(ch => CHANNEL_FREQ[ch] || 2432);
const results = [];
const materialCounts = {};
for (const m of Object.values(MATERIAL)) materialCounts[m.name] = 0;
for (let sc = 0; sc < nSub; sc++) {
// Collect amplitudes across channels for this subcarrier
const amps = channels.map(ch => means.get(ch).mean[sc]);
// Is this a null on all channels?
const allNull = amps.every(a => a < NULL_THRESHOLD);
const anyNull = amps.some(a => a < NULL_THRESHOLD);
// Mean amplitude
const meanAmp = amps.reduce((a, b) => a + b, 0) / amps.length;
// Standard deviation
const variance = amps.reduce((a, b) => a + (b - meanAmp) ** 2, 0) / amps.length;
const stdAmp = Math.sqrt(variance);
// Flat ratio (coefficient of variation)
const flatRatio = meanAmp > 0.01 ? stdAmp / meanAmp : 0;
// Frequency slope: linear regression of amplitude vs frequency
let sumF = 0, sumA = 0, sumFF = 0, sumFA = 0;
for (let i = 0; i < channels.length; i++) {
sumF += freqs[i];
sumA += amps[i];
sumFF += freqs[i] * freqs[i];
sumFA += freqs[i] * amps[i];
}
const nCh = channels.length;
const meanF = sumF / nCh;
const denomF = sumFF - sumF * meanF;
const slope = Math.abs(denomF) > 1e-6
? (sumFA - sumF * (sumA / nCh)) / denomF
: 0;
// Normalized slope (per MHz)
const slopePerMHz = slope;
// Classification decision tree
let material;
if (allNull) {
material = MATERIAL.METAL;
} else if (flatRatio < 0.15 && meanAmp < 3.0) {
material = MATERIAL.METAL;
} else if (flatRatio < 0.15 && meanAmp > 10.0) {
material = MATERIAL.AIR;
} else if (flatRatio < 0.15 && meanAmp > 6.0) {
material = MATERIAL.GLASS;
} else if (slopePerMHz < -0.005 && meanAmp < 5.0) {
// Amplitude decreases with frequency = frequency-dependent absorption
material = MATERIAL.HUMAN;
} else if (slopePerMHz < -0.003 && meanAmp < 8.0) {
material = MATERIAL.WATER;
} else if (slopePerMHz < -0.001 && meanAmp >= 5.0) {
material = MATERIAL.WOOD;
} else if (flatRatio > 0.5) {
material = MATERIAL.COMPLEX;
} else {
material = MATERIAL.AIR;
}
materialCounts[material.name]++;
results.push({
subcarrier: sc,
material: material.name,
char: material.char,
meanAmp: meanAmp.toFixed(1),
flatRatio: flatRatio.toFixed(3),
slopePerMHz: slopePerMHz.toFixed(5),
amps: amps.map(a => a.toFixed(1)),
});
}
this.classifications = results;
return {
channels,
nSubcarriers: nSub,
frameCount: this.frameCount,
materialCounts,
classifications: results,
};
}
reset() {
this.accumulator.reset();
this.frameCount = 0;
this.classifications = [];
}
}
// ---------------------------------------------------------------------------
// CSI parsing
// ---------------------------------------------------------------------------
function parseIqHex(iqHex, nSubcarriers) {
const bytes = Buffer.from(iqHex, 'hex');
const amplitudes = 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);
}
return amplitudes;
}
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 amplitudes = 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);
}
let channel = 0;
if (freqMhz >= 2412 && freqMhz <= 2484) {
channel = freqMhz === 2484 ? 14 : Math.round((freqMhz - 2412) / 5) + 1;
}
return { nodeId, nSubcarriers, freqMhz, amplitudes, channel };
}
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 renderMaterialMap(result) {
const { classifications, channels, nSubcarriers, materialCounts } = result;
if (!classifications || classifications.length === 0) return ' No classifications available';
const lines = [];
lines.push('');
lines.push(' FREQUENCY-SELECTIVE MATERIAL CLASSIFICATION');
lines.push(' ' + '='.repeat(55));
lines.push('');
// Material map: one char per subcarrier
lines.push(' Subcarrier Material Map (1 char = 1 subcarrier):');
let mapRow = ' ';
for (let i = 0; i < classifications.length; i++) {
mapRow += classifications[i].char;
if ((i + 1) % 64 === 0) {
lines.push(mapRow);
mapRow = ' ';
}
}
if (mapRow.trim()) lines.push(mapRow);
lines.push('');
lines.push(' Legend:');
for (const m of Object.values(MATERIAL)) {
const count = materialCounts[m.name] || 0;
const pct = nSubcarriers > 0 ? (count / nSubcarriers * 100).toFixed(1) : '0.0';
lines.push(` ${m.char} = ${m.name.padEnd(8)} (${pct}%) ${m.desc}`);
}
return lines.join('\n');
}
function renderFrequencyProfile(result) {
const { classifications, channels } = result;
if (!classifications || channels.length < 2) return '';
const lines = [];
lines.push('');
lines.push(' Frequency Profile (mean amplitude per channel):');
lines.push(' ' + '-'.repeat(50));
// Compute mean per channel across all subcarriers
const channelMeans = {};
for (const ch of channels) channelMeans[ch] = { sum: 0, count: 0 };
for (const cls of classifications) {
for (let i = 0; i < channels.length && i < cls.amps.length; i++) {
channelMeans[channels[i]].sum += parseFloat(cls.amps[i]);
channelMeans[channels[i]].count++;
}
}
const BARS = '\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588';
let maxMean = 0;
for (const ch of channels) {
const m = channelMeans[ch].count > 0 ? channelMeans[ch].sum / channelMeans[ch].count : 0;
if (m > maxMean) maxMean = m;
}
if (maxMean === 0) maxMean = 1;
for (const ch of channels) {
const mean = channelMeans[ch].count > 0 ? channelMeans[ch].sum / channelMeans[ch].count : 0;
const freq = CHANNEL_FREQ[ch] || 0;
const barLen = Math.floor((mean / maxMean) * 30);
const bar = BARS[7].repeat(barLen);
lines.push(` ch${String(ch).padStart(2)} (${freq} MHz): ${bar} ${mean.toFixed(1)}`);
}
// Slope analysis
const freqs = channels.map(ch => CHANNEL_FREQ[ch]);
const means = channels.map(ch => {
const c = channelMeans[ch];
return c.count > 0 ? c.sum / c.count : 0;
});
let sumF = 0, sumA = 0, sumFF = 0, sumFA = 0;
for (let i = 0; i < channels.length; i++) {
sumF += freqs[i]; sumA += means[i];
sumFF += freqs[i] * freqs[i]; sumFA += freqs[i] * means[i];
}
const nCh = channels.length;
const meanF = sumF / nCh;
const denomF = sumFF - sumF * meanF;
const slope = Math.abs(denomF) > 1e-6 ? (sumFA - sumF * (sumA / nCh)) / denomF : 0;
lines.push('');
if (slope < -0.003) {
lines.push(' Overall trend: DECREASING with frequency (water/organic absorption)');
} else if (slope > 0.003) {
lines.push(' Overall trend: INCREASING with frequency (unusual, possible reflection)');
} else {
lines.push(' Overall trend: FLAT across frequency (metal or air dominant)');
}
lines.push(` Slope: ${(slope * 1000).toFixed(3)} amplitude/GHz`);
return lines.join('\n');
}
function renderDetailedSubcarriers(result) {
const { classifications, channels } = result;
if (!classifications) return '';
const lines = [];
lines.push('');
lines.push(' Notable Subcarriers (high frequency selectivity):');
lines.push(' ' + '-'.repeat(60));
lines.push(' SC# Material Mean Flat Slope/MHz Per-channel amps');
// Find most interesting subcarriers (high flat ratio or steep slope)
const interesting = classifications
.filter(c => parseFloat(c.flatRatio) > 0.3 || Math.abs(parseFloat(c.slopePerMHz)) > 0.005)
.sort((a, b) => parseFloat(b.flatRatio) - parseFloat(a.flatRatio))
.slice(0, 15);
for (const cls of interesting) {
const amps = cls.amps.join(' ');
lines.push(` ${String(cls.subcarrier).padStart(3)} ${cls.material.padEnd(8)} ` +
`${cls.meanAmp.padStart(5)} ${cls.flatRatio} ${cls.slopePerMHz.padStart(9)} [${amps}]`);
}
if (interesting.length === 0) {
lines.push(' (no highly frequency-selective subcarriers detected)');
}
return lines.join('\n');
}
// ---------------------------------------------------------------------------
// Global state
// ---------------------------------------------------------------------------
const classifier = new MaterialClassifier();
let lastDisplayMs = 0;
function processFrame(channel, amplitudes) {
classifier.ingestFrame(channel, amplitudes);
}
function displayUpdate() {
const result = classifier.classify();
if (JSON_OUTPUT) {
console.log(JSON.stringify({
timestamp: Date.now() / 1000,
channels: result.channels,
frameCount: result.frameCount,
materialCounts: result.materialCounts,
topClassifications: (result.classifications || [])
.filter(c => c.material !== 'Air')
.slice(0, 20)
.map(c => ({ sc: c.subcarrier, material: c.material, meanAmp: c.meanAmp })),
}));
} else {
process.stdout.write('\x1B[2J\x1B[H');
console.log(renderMaterialMap(result));
console.log(renderFrequencyProfile(result));
console.log(renderDetailedSubcarriers(result));
console.log('');
console.log(` Frames: ${result.frameCount} | Channels: ${(result.channels || []).length}`);
console.log(' Press Ctrl+C to exit');
}
}
// ---------------------------------------------------------------------------
// Live mode
// ---------------------------------------------------------------------------
function startLive() {
const sock = dgram.createSocket('udp4');
sock.on('message', (buf) => {
if (buf.length < 4) return;
const magic = buf.readUInt32LE(0);
if (magic !== CSI_MAGIC) return;
const frame = parseCSIFrame(buf);
if (!frame) return;
processFrame(frame.channel, frame.amplitudes);
const now = Date.now();
if (now - lastDisplayMs >= INTERVAL_MS) {
displayUpdate();
lastDisplayMs = now;
}
});
sock.bind(PORT, () => {
if (!JSON_OUTPUT) {
console.log(`Material Classifier listening on UDP port ${PORT}`);
console.log('Waiting for multi-channel 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 = parseIqHex(record.iq_hex, record.subcarriers || 64);
const channel = record.channel || assignChannel(record.node_id);
processFrame(channel, amplitudes);
frameCount++;
const tsMs = record.timestamp * 1000;
if (lastAnalysisTs === 0) lastAnalysisTs = tsMs;
if (tsMs - lastAnalysisTs >= INTERVAL_MS) {
windowCount++;
const result = classifier.classify();
if (JSON_OUTPUT) {
console.log(JSON.stringify({
window: windowCount, timestamp: record.timestamp,
materialCounts: result.materialCounts,
}));
} else {
console.log(`\n${'='.repeat(60)}`);
console.log(`Window ${windowCount} | t=${record.timestamp.toFixed(1)}s | frames=${frameCount}`);
console.log('='.repeat(60));
console.log(renderMaterialMap(result));
console.log(renderFrequencyProfile(result));
}
lastAnalysisTs = tsMs;
}
}
// Final
if (!JSON_OUTPUT) {
const result = classifier.classify();
console.log(`\n${'='.repeat(60)}`);
console.log('FINAL MATERIAL CLASSIFICATION');
console.log('='.repeat(60));
console.log(renderMaterialMap(result));
console.log(renderFrequencyProfile(result));
console.log(renderDetailedSubcarriers(result));
console.log(`\nProcessed ${frameCount} frames in ${windowCount} windows`);
}
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
if (args.replay) {
startReplay(args.replay);
} else {
startLive();
}
+677
View File
@@ -0,0 +1,677 @@
#!/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();
}
+581
View File
@@ -0,0 +1,581 @@
#!/usr/bin/env node
/**
* RF Tomographic Imaging — Multi-Frequency Mesh Application
*
* Back-projects CSI attenuation along each TX->RX path across 6 WiFi channels
* to build a 2D heatmap of RF absorption in the room. Areas with high absorption
* correspond to people, furniture, or walls.
*
* Requires multi-frequency mesh scanning (ADR-073): 2 ESP32 nodes hopping
* across channels 1, 3, 5, 6, 9, 11.
*
* Usage:
* node scripts/rf-tomography.js
* node scripts/rf-tomography.js --port 5006 --duration 60
* node scripts/rf-tomography.js --replay data/recordings/overnight-1775217646.csi.jsonl
* node scripts/rf-tomography.js --grid 15 --node-distance 4.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: '2000' },
grid: { type: 'string', short: 'g', default: '10' },
json: { type: 'boolean', default: false },
'node-distance': { type: 'string', default: '3.0' },
'room-width': { type: 'string', default: '5.0' },
'room-height': { type: 'string', default: '4.0' },
},
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 GRID_SIZE = parseInt(args.grid, 10);
const JSON_OUTPUT = args.json;
const NODE_DISTANCE = parseFloat(args['node-distance']);
const ROOM_WIDTH = parseFloat(args['room-width']);
const ROOM_HEIGHT = parseFloat(args['room-height']);
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CSI_MAGIC = 0xC5110001;
const HEADER_SIZE = 20;
const CHANNEL_FREQ = {};
for (let ch = 1; ch <= 13; ch++) CHANNEL_FREQ[ch] = 2412 + (ch - 1) * 5;
CHANNEL_FREQ[14] = 2484;
const NODE1_CHANNELS = [1, 6, 11];
const NODE2_CHANNELS = [3, 5, 9];
// Known neighbor APs as additional illuminators (TX positions estimated)
const ILLUMINATORS = [
{ ssid: 'ruv.net', channel: 5, signal: 100, pos: [1.5, 3.5] },
{ ssid: 'Cohen-Guest', channel: 5, signal: 100, pos: [2.0, 3.8] },
{ ssid: 'COGECO-21B20', channel: 11, signal: 100, pos: [4.0, 2.0] },
{ ssid: 'HP M255', channel: 5, signal: 94, pos: [0.5, 1.5] },
{ ssid: 'conclusion', channel: 3, signal: 44, pos: [3.5, 3.0] },
{ ssid: 'NETGEAR72', channel: 9, signal: 42, pos: [4.5, 1.0] },
{ ssid: 'COGECO-4321', channel: 11, signal: 30, pos: [4.0, 3.5] },
{ ssid: 'Innanen', channel: 6, signal: 19, pos: [1.0, 4.0] },
];
// Node positions (meters)
const NODE_POS = {
1: [0, ROOM_HEIGHT / 2],
2: [NODE_DISTANCE, ROOM_HEIGHT / 2],
};
// Heatmap characters (8 levels: transparent -> opaque)
const HEAT = [' ', '\u2591', '\u2591', '\u2592', '\u2592', '\u2593', '\u2593', '\u2588'];
const HEAT_LABELS = ['air', 'low', 'low', 'med', 'med', 'high', 'high', 'solid'];
// ---------------------------------------------------------------------------
// Tomographic grid
// ---------------------------------------------------------------------------
class TomographyGrid {
constructor(gridSize, roomWidth, roomHeight) {
this.gridSize = gridSize;
this.roomWidth = roomWidth;
this.roomHeight = roomHeight;
this.cellWidth = roomWidth / gridSize;
this.cellHeight = roomHeight / gridSize;
// Accumulated attenuation per cell
this.attenuation = new Float64Array(gridSize * gridSize);
// Number of paths passing through each cell (for normalization)
this.pathCount = new Float64Array(gridSize * gridSize);
// Per-channel attenuation (for frequency analysis)
this.channelAttenuation = new Map(); // channel -> Float64Array
this.frameCount = 0;
this.channelFrames = new Map();
}
/** Get center position of grid cell (row, col) in meters */
cellCenter(row, col) {
return [
(col + 0.5) * this.cellWidth,
(row + 0.5) * this.cellHeight,
];
}
/**
* Perpendicular distance from point P to line segment AB.
* Returns minimum distance to the infinite line through A and B.
*/
pointToLineDistance(px, py, ax, ay, bx, by) {
const dx = bx - ax;
const dy = by - ay;
const len = Math.sqrt(dx * dx + dy * dy);
if (len < 1e-6) return Math.sqrt((px - ax) ** 2 + (py - ay) ** 2);
// Signed distance using cross product
return Math.abs((dy * px - dx * py + bx * ay - by * ax)) / len;
}
/**
* Back-project attenuation along a TX->RX path.
* Each cell near the path receives a weighted contribution.
*
* @param {number[]} txPos - Transmitter position [x, y]
* @param {number[]} rxPos - Receiver position [x, y]
* @param {number} atten - Measured attenuation (dB or normalized)
* @param {number} channel - WiFi channel number
*/
backProject(txPos, rxPos, atten, channel) {
const [ax, ay] = txPos;
const [bx, by] = rxPos;
const pathLen = Math.sqrt((bx - ax) ** 2 + (by - ay) ** 2);
if (pathLen < 0.01) return;
// Kernel width: how far from the path the contribution extends
// Approximately lambda/2 at 2.4 GHz = ~6 cm, but we use wider for stability
const kernelWidth = Math.max(this.cellWidth, this.cellHeight) * 1.5;
if (!this.channelAttenuation.has(channel)) {
this.channelAttenuation.set(channel, new Float64Array(this.gridSize * this.gridSize));
}
const chAtten = this.channelAttenuation.get(channel);
for (let r = 0; r < this.gridSize; r++) {
for (let c = 0; c < this.gridSize; c++) {
const [cx, cy] = this.cellCenter(r, c);
const dist = this.pointToLineDistance(cx, cy, ax, ay, bx, by);
if (dist < kernelWidth) {
// Weight by proximity to path (Gaussian-like)
const weight = Math.exp(-0.5 * (dist / (kernelWidth * 0.4)) ** 2);
const idx = r * this.gridSize + c;
this.attenuation[idx] += atten * weight;
this.pathCount[idx] += weight;
chAtten[idx] += atten * weight;
}
}
}
this.frameCount++;
this.channelFrames.set(channel, (this.channelFrames.get(channel) || 0) + 1);
}
/** Get normalized attenuation image */
getImage() {
const img = new Float64Array(this.gridSize * this.gridSize);
let maxVal = 0;
for (let i = 0; i < img.length; i++) {
img[i] = this.pathCount[i] > 0 ? this.attenuation[i] / this.pathCount[i] : 0;
if (img[i] > maxVal) maxVal = img[i];
}
// Normalize to 0-1
if (maxVal > 0) {
for (let i = 0; i < img.length; i++) img[i] /= maxVal;
}
return img;
}
/** Get per-channel images for frequency analysis */
getChannelImages() {
const images = {};
for (const [ch, chAtten] of this.channelAttenuation) {
const img = new Float64Array(this.gridSize * this.gridSize);
let maxVal = 0;
for (let i = 0; i < img.length; i++) {
img[i] = this.pathCount[i] > 0 ? chAtten[i] / this.pathCount[i] : 0;
if (img[i] > maxVal) maxVal = img[i];
}
if (maxVal > 0) for (let i = 0; i < img.length; i++) img[i] /= maxVal;
images[ch] = img;
}
return images;
}
/** Detect high-attenuation regions (potential person locations) */
detectObjects(threshold = 0.6) {
const img = this.getImage();
const objects = [];
for (let r = 0; r < this.gridSize; r++) {
for (let c = 0; c < this.gridSize; c++) {
const val = img[r * this.gridSize + c];
if (val >= threshold) {
const [x, y] = this.cellCenter(r, c);
objects.push({
row: r, col: c,
x: x.toFixed(2), y: y.toFixed(2),
attenuation: val.toFixed(3),
});
}
}
}
return objects;
}
/** Reset accumulator for next window */
reset() {
this.attenuation.fill(0);
this.pathCount.fill(0);
this.channelAttenuation.clear();
this.frameCount = 0;
this.channelFrames.clear();
}
}
// ---------------------------------------------------------------------------
// CSI parsing (shared with other scripts)
// ---------------------------------------------------------------------------
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 };
}
/**
* Compute mean amplitude as a proxy for path attenuation.
* Higher amplitude = less attenuation. We invert for the tomography grid.
*/
function computeAttenuation(amplitudes) {
let sum = 0;
for (let i = 0; i < amplitudes.length; i++) sum += amplitudes[i];
const mean = sum / amplitudes.length;
// Free-space reference (approximate, empirically calibrated)
const freeSpaceRef = 15.0;
// Attenuation: how much below free-space reference
return Math.max(0, freeSpaceRef - mean);
}
// ---------------------------------------------------------------------------
// Channel assignment for legacy JSONL (no freq field)
// ---------------------------------------------------------------------------
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 renderHeatmap(grid) {
const img = grid.getImage();
const gs = grid.gridSize;
const lines = [];
lines.push('');
lines.push(' RF Tomographic Image');
lines.push(' ' + '='.repeat(gs * 2 + 2));
// Y-axis label
for (let r = 0; r < gs; r++) {
const y = ((gs - r - 0.5) / gs * grid.roomHeight).toFixed(1);
let row = `${y.padStart(4)}m |`;
for (let c = 0; c < gs; c++) {
const val = img[r * gs + c];
const level = Math.floor(val * 7.99);
row += HEAT[Math.max(0, Math.min(7, level))] + ' ';
}
row += '|';
lines.push(' ' + row);
}
// X-axis
lines.push(' ' + ' '.repeat(6) + '+' + '-'.repeat(gs * 2) + '+');
let xLabels = ' '.repeat(7);
for (let c = 0; c < gs; c += Math.max(1, Math.floor(gs / 5))) {
const x = (c / gs * grid.roomWidth).toFixed(1);
xLabels += x.padEnd(Math.floor(gs / 5) * 2 || 2);
}
lines.push(' ' + xLabels + ' (m)');
// Legend
lines.push('');
lines.push(' Legend: ' + HEAT.map((ch, i) =>
`${ch}=${HEAT_LABELS[i]}`
).join(' '));
// Node positions
const n1c = Math.floor(NODE_POS[1][0] / grid.roomWidth * gs);
const n1r = gs - 1 - Math.floor(NODE_POS[1][1] / grid.roomHeight * gs);
const n2c = Math.floor(NODE_POS[2][0] / grid.roomWidth * gs);
const n2r = gs - 1 - Math.floor(NODE_POS[2][1] / grid.roomHeight * gs);
lines.push(` Node 1: (${NODE_POS[1][0]}, ${NODE_POS[1][1]}) m [grid ${n1r},${n1c}]`);
lines.push(` Node 2: (${NODE_POS[2][0]}, ${NODE_POS[2][1]}) m [grid ${n2r},${n2c}]`);
return lines.join('\n');
}
function renderStats(grid) {
const lines = [];
lines.push(` Frames: ${grid.frameCount}`);
const chFrames = [...grid.channelFrames.entries()].sort((a, b) => a[0] - b[0]);
if (chFrames.length > 0) {
lines.push(' Per-channel frames: ' + chFrames.map(([ch, n]) =>
`ch${ch}=${n}`
).join(' '));
}
const objects = grid.detectObjects(0.6);
if (objects.length > 0) {
lines.push(` Detected ${objects.length} high-attenuation region(s):`);
for (const obj of objects.slice(0, 5)) {
lines.push(` (${obj.x}, ${obj.y}) m attenuation=${obj.attenuation}`);
}
} else {
lines.push(' No high-attenuation regions detected');
}
return lines.join('\n');
}
function renderChannelComparison(grid) {
const images = grid.getChannelImages();
const channels = Object.keys(images).map(Number).sort((a, b) => a - b);
if (channels.length < 2) return '';
const gs = grid.gridSize;
const lines = [];
lines.push('');
lines.push(' Per-Channel Attenuation (middle row):');
const midRow = Math.floor(gs / 2);
for (const ch of channels) {
const img = images[ch];
let bar = ` ch${String(ch).padStart(2)}: `;
for (let c = 0; c < gs; c++) {
const val = img[midRow * gs + c];
const level = Math.floor(val * 7.99);
bar += HEAT[Math.max(0, Math.min(7, level))] + ' ';
}
lines.push(bar);
}
return lines.join('\n');
}
// ---------------------------------------------------------------------------
// Process a single CSI record
// ---------------------------------------------------------------------------
const grid = new TomographyGrid(GRID_SIZE, ROOM_WIDTH, ROOM_HEIGHT);
let lastDisplayMs = 0;
function processFrame(nodeId, amplitudes, channel, timestamp) {
const atten = computeAttenuation(amplitudes);
// Back-project along node-to-node path
const txPos = NODE_POS[nodeId] || [0, 0];
const otherNode = nodeId === 1 ? 2 : 1;
const rxPos = NODE_POS[otherNode] || [NODE_DISTANCE, ROOM_HEIGHT / 2];
grid.backProject(txPos, rxPos, atten, channel);
// Also back-project along paths to known illuminators on this channel
for (const il of ILLUMINATORS) {
if (il.channel === channel) {
grid.backProject(il.pos, txPos, atten * (il.signal / 100), channel);
}
}
}
function displayUpdate() {
if (JSON_OUTPUT) {
const img = grid.getImage();
const objects = grid.detectObjects(0.6);
console.log(JSON.stringify({
timestamp: Date.now() / 1000,
frames: grid.frameCount,
channels: [...grid.channelFrames.keys()].sort(),
image: Array.from(img).map(v => +v.toFixed(3)),
gridSize: GRID_SIZE,
roomWidth: ROOM_WIDTH,
roomHeight: ROOM_HEIGHT,
objects,
}));
} else {
process.stdout.write('\x1B[2J\x1B[H'); // clear screen
console.log(renderHeatmap(grid));
console.log(renderStats(grid));
console.log(renderChannelComparison(grid));
console.log('');
console.log(' Press Ctrl+C to exit');
}
}
// ---------------------------------------------------------------------------
// Live mode (UDP)
// ---------------------------------------------------------------------------
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.amplitudes, frame.channel, Date.now() / 1000);
const now = Date.now();
if (now - lastDisplayMs >= INTERVAL_MS) {
displayUpdate();
lastDisplayMs = now;
}
});
sock.bind(PORT, () => {
if (!JSON_OUTPUT) {
console.log(`RF Tomography listening on UDP port ${PORT}`);
console.log(`Grid: ${GRID_SIZE}x${GRID_SIZE}, Room: ${ROOM_WIDTH}x${ROOM_HEIGHT} m`);
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 (JSONL)
// ---------------------------------------------------------------------------
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, amplitudes, channel, record.timestamp);
frameCount++;
const tsMs = record.timestamp * 1000;
if (lastAnalysisTs === 0) lastAnalysisTs = tsMs;
if (tsMs - lastAnalysisTs >= INTERVAL_MS) {
windowCount++;
if (JSON_OUTPUT) {
displayUpdate();
} else {
console.log(`\n${'='.repeat(60)}`);
console.log(`Window ${windowCount} | t=${record.timestamp.toFixed(1)}s | frames=${frameCount}`);
console.log('='.repeat(60));
console.log(renderHeatmap(grid));
console.log(renderStats(grid));
console.log(renderChannelComparison(grid));
}
lastAnalysisTs = tsMs;
}
}
// Final output
if (!JSON_OUTPUT) {
console.log(`\n${'='.repeat(60)}`);
console.log('FINAL RF TOMOGRAPHIC IMAGE');
console.log('='.repeat(60));
console.log(renderHeatmap(grid));
console.log(renderStats(grid));
console.log(renderChannelComparison(grid));
console.log(`\nProcessed ${frameCount} frames in ${windowCount} windows`);
} else {
displayUpdate();
}
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
if (args.replay) {
startReplay(args.replay);
} else {
startLive();
}
+595
View File
@@ -0,0 +1,595 @@
#!/usr/bin/env node
/**
* Through-Wall Motion Detection — Multi-Frequency Mesh Application
*
* Detects motion behind walls by exploiting the fact that lower WiFi frequencies
* penetrate walls better than higher frequencies. With 6 channels spanning
* 2412-2462 MHz, we can:
*
* 1. Baseline each channel's attenuation through the wall (calibration phase)
* 2. Detect changes above baseline = motion behind wall
* 3. Weight lower channels more heavily (better through-wall SNR)
* 4. Cross-validate across channels (real motion is coherent; noise is not)
*
* Requires multi-frequency mesh scanning (ADR-073): 2 ESP32 nodes hopping
* across channels 1, 3, 5, 6, 9, 11.
*
* Usage:
* node scripts/through-wall-detector.js --calibrate 60
* node scripts/through-wall-detector.js --port 5006 --duration 300
* node scripts/through-wall-detector.js --replay data/recordings/overnight-1775217646.csi.jsonl
* node scripts/through-wall-detector.js --threshold 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: '1000' },
calibrate: { type: 'string', short: 'c', default: '30' },
threshold: { type: 'string', short: 't', default: '2.5' },
json: { type: 'boolean', default: false },
'consecutive-frames': { type: 'string', default: '3' },
},
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 CALIBRATE_S = parseInt(args.calibrate, 10);
const ALERT_THRESHOLD = parseFloat(args.threshold);
const CONSECUTIVE_FRAMES = parseInt(args['consecutive-frames'], 10);
const JSON_OUTPUT = args.json;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CSI_MAGIC = 0xC5110001;
const HEADER_SIZE = 20;
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];
// Channel penetration weights: lower freq = better wall penetration
// Approximate wall loss at each channel for drywall+stud:
// ch1 (2412 MHz) = 2.5 dB, ch11 (2462 MHz) = 2.7 dB
// Weight inversely proportional to loss
const PENETRATION_WEIGHT = {
1: 1.00, // 2412 MHz - best penetration
3: 0.96,
5: 0.92,
6: 0.90,
9: 0.85,
11: 0.80, // 2462 MHz - worst penetration
};
// Status display
const STATUS = {
CALIBRATING: 'CALIBRATING',
MONITORING: 'MONITORING',
ALERT: 'ALERT',
};
// ---------------------------------------------------------------------------
// Per-channel baseline
// ---------------------------------------------------------------------------
class ChannelBaseline {
constructor(channel) {
this.channel = channel;
this.freqMhz = CHANNEL_FREQ[channel] || 2432;
this.weight = PENETRATION_WEIGHT[channel] || 0.9;
// Welford online mean/variance
this.nSub = 0;
this.count = 0;
this.mean = null; // Float64Array
this.m2 = null; // Float64Array
this.calibrated = false;
}
/** Ingest a frame during calibration */
calibrate(amplitudes) {
const n = amplitudes.length;
if (!this.mean) {
this.nSub = n;
this.mean = new Float64Array(n);
this.m2 = new Float64Array(n);
}
this.count++;
for (let i = 0; i < n && i < this.nSub; i++) {
const delta = amplitudes[i] - this.mean[i];
this.mean[i] += delta / this.count;
const delta2 = amplitudes[i] - this.mean[i];
this.m2[i] += delta * delta2;
}
}
/** Finalize calibration */
finalize() {
if (this.count < 5) return;
this.calibrated = true;
}
/** Get standard deviation per subcarrier */
getStd() {
if (!this.mean || this.count < 2) return null;
const std = new Float64Array(this.nSub);
for (let i = 0; i < this.nSub; i++) {
std[i] = Math.sqrt(this.m2[i] / (this.count - 1));
// Minimum std to avoid division by zero
if (std[i] < 0.1) std[i] = 0.1;
}
return std;
}
/**
* Compute deviation score for a new frame.
* Score = mean(|amplitude - baseline_mean| / baseline_std) across subcarriers
*/
computeDeviation(amplitudes) {
if (!this.calibrated || !this.mean) return 0;
const std = this.getStd();
if (!std) return 0;
let sumDeviation = 0;
let count = 0;
for (let i = 0; i < amplitudes.length && i < this.nSub; i++) {
const z = Math.abs(amplitudes[i] - this.mean[i]) / std[i];
sumDeviation += z;
count++;
}
return count > 0 ? sumDeviation / count : 0;
}
}
// ---------------------------------------------------------------------------
// Through-wall detector
// ---------------------------------------------------------------------------
class ThroughWallDetector {
constructor(calibrateDuration, alertThreshold, consecutiveFrames) {
this.calibrateDuration = calibrateDuration;
this.alertThreshold = alertThreshold;
this.consecutiveFrames = consecutiveFrames;
this.baselines = new Map(); // channel -> ChannelBaseline
this.status = STATUS.CALIBRATING;
this.startTime = null;
// Detection state
this.perChannelScores = new Map();
this.fusedScore = 0;
this.alertStreak = 0;
this.alertActive = false;
this.alerts = [];
// History for display
this.scoreHistory = []; // { timestamp, fusedScore, perChannel }
this.maxHistory = 60;
this.totalFrames = 0;
}
ingestFrame(channel, amplitudes, timestamp) {
this.totalFrames++;
if (!this.startTime) this.startTime = timestamp;
// Get or create baseline
if (!this.baselines.has(channel)) {
this.baselines.set(channel, new ChannelBaseline(channel));
}
const baseline = this.baselines.get(channel);
// Calibration phase
if (this.status === STATUS.CALIBRATING) {
baseline.calibrate(amplitudes);
if (timestamp - this.startTime >= this.calibrateDuration) {
// Finalize all baselines
for (const bl of this.baselines.values()) bl.finalize();
this.status = STATUS.MONITORING;
}
return;
}
// Detection phase
const deviation = baseline.computeDeviation(amplitudes);
const weight = PENETRATION_WEIGHT[channel] || 0.9;
const weightedScore = deviation * weight;
this.perChannelScores.set(channel, {
deviation: deviation,
weighted: weightedScore,
channel,
freqMhz: CHANNEL_FREQ[channel],
});
// Fused score: weighted average across all channels
let sumWeighted = 0, sumWeights = 0;
for (const [ch, score] of this.perChannelScores) {
sumWeighted += score.weighted;
sumWeights += PENETRATION_WEIGHT[ch] || 0.9;
}
this.fusedScore = sumWeights > 0 ? sumWeighted / sumWeights : 0;
// Cross-channel coherence: how many channels agree on motion?
let agreeCount = 0;
for (const score of this.perChannelScores.values()) {
if (score.deviation > this.alertThreshold * 0.5) agreeCount++;
}
const coherence = this.perChannelScores.size > 0
? agreeCount / this.perChannelScores.size
: 0;
// Alert logic
if (this.fusedScore > this.alertThreshold && coherence > 0.4) {
this.alertStreak++;
} else {
this.alertStreak = Math.max(0, this.alertStreak - 1);
}
const wasAlert = this.alertActive;
this.alertActive = this.alertStreak >= this.consecutiveFrames;
if (this.alertActive && !wasAlert) {
this.status = STATUS.ALERT;
this.alerts.push({
timestamp,
fusedScore: this.fusedScore,
coherence,
channels: [...this.perChannelScores.values()].map(s => ({
ch: s.channel, dev: s.deviation.toFixed(2),
})),
});
} else if (!this.alertActive && wasAlert) {
this.status = STATUS.MONITORING;
}
// Store history
this.scoreHistory.push({
timestamp,
fusedScore: this.fusedScore,
coherence,
perChannel: [...this.perChannelScores.entries()].map(([ch, s]) => ({
ch, dev: s.deviation.toFixed(2), weight: (PENETRATION_WEIGHT[ch] || 0.9).toFixed(2),
})),
});
if (this.scoreHistory.length > this.maxHistory) this.scoreHistory.shift();
}
getState() {
return {
status: this.status,
fusedScore: this.fusedScore,
alertActive: this.alertActive,
alertStreak: this.alertStreak,
totalFrames: this.totalFrames,
calibratedChannels: [...this.baselines.values()]
.filter(b => b.calibrated)
.map(b => b.channel)
.sort((a, b) => a - b),
perChannelScores: [...this.perChannelScores.entries()]
.sort((a, b) => a[0] - b[0])
.map(([ch, s]) => ({ ch, deviation: s.deviation.toFixed(2), weighted: s.weighted.toFixed(2) })),
alertCount: this.alerts.length,
scoreHistory: this.scoreHistory,
};
}
}
// ---------------------------------------------------------------------------
// CSI parsing
// ---------------------------------------------------------------------------
function parseIqHex(iqHex, nSubcarriers) {
const bytes = Buffer.from(iqHex, 'hex');
const amplitudes = 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);
}
return amplitudes;
}
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 amplitudes = 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);
}
let channel = 0;
if (freqMhz >= 2412 && freqMhz <= 2484) {
channel = freqMhz === 2484 ? 14 : Math.round((freqMhz - 2412) / 5) + 1;
}
return { nodeId, nSubcarriers, freqMhz, amplitudes, channel };
}
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 renderStatus(detector) {
const state = detector.getState();
const lines = [];
lines.push('');
lines.push(' THROUGH-WALL MOTION DETECTOR');
lines.push(' ' + '='.repeat(55));
lines.push('');
// Status banner
const statusBanner = {
[STATUS.CALIBRATING]: ' [ CALIBRATING ] Establishing wall baseline...',
[STATUS.MONITORING]: ' [ MONITORING ] Watching for through-wall motion',
[STATUS.ALERT]: ' [ ** ALERT ** ] Motion detected behind wall!',
};
lines.push(statusBanner[state.status] || ` [ ${state.status} ]`);
lines.push('');
if (state.status === STATUS.CALIBRATING) {
const progress = Math.min(100, (state.totalFrames / (CALIBRATE_S * 12)) * 100);
const barLen = Math.floor(progress / 2);
const bar = '\u2588'.repeat(barLen) + '\u2591'.repeat(50 - barLen);
lines.push(` Calibration progress: [${bar}] ${progress.toFixed(0)}%`);
lines.push(` Frames collected: ${state.totalFrames}`);
lines.push(` Channels: ${state.calibratedChannels.length > 0 ? state.calibratedChannels.join(', ') : 'accumulating...'}`);
return lines.join('\n');
}
// Fused score meter
const maxMeter = 40;
const meterFill = Math.min(maxMeter, Math.floor((state.fusedScore / (ALERT_THRESHOLD * 2)) * maxMeter));
const meterChar = state.alertActive ? '\u2588' : '\u2593';
const meterEmpty = '\u2591';
const meter = meterChar.repeat(meterFill) + meterEmpty.repeat(maxMeter - meterFill);
const threshMark = Math.floor((ALERT_THRESHOLD / (ALERT_THRESHOLD * 2)) * maxMeter);
lines.push(` Fused score: [${meter}] ${state.fusedScore.toFixed(2)}`);
lines.push(` ${''.padStart(15 + threshMark)}^ threshold=${ALERT_THRESHOLD}`);
// Per-channel breakdown
lines.push('');
lines.push(' Per-Channel Deviation (weighted by penetration quality):');
lines.push(' ' + '-'.repeat(55));
lines.push(' Ch Freq(MHz) Weight Deviation Weighted Status');
for (const score of state.perChannelScores) {
const ch = score.ch;
const freq = CHANNEL_FREQ[ch] || 0;
const wt = (PENETRATION_WEIGHT[ch] || 0.9).toFixed(2);
const dev = score.deviation;
const wtd = score.weighted;
const above = parseFloat(dev) > ALERT_THRESHOLD * 0.5;
const marker = above ? ' <--' : '';
lines.push(` ${String(ch).padStart(2)} ${freq} ${wt} ${dev.padStart(6)} ${wtd.padStart(6)} ${marker}`);
}
// Score timeline (last 30 readings)
const history = state.scoreHistory.slice(-30);
if (history.length > 0) {
lines.push('');
lines.push(' Score Timeline (last 30 readings):');
const SPARK = '\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588';
let timeline = ' ';
for (const h of history) {
const level = Math.min(7, Math.floor((h.fusedScore / (ALERT_THRESHOLD * 2)) * 7.99));
timeline += SPARK[level];
}
lines.push(timeline);
lines.push(` ${''.padStart(2)}${'oldest'.padEnd(15)}${''.padEnd(Math.max(0, history.length - 21))}newest`);
}
// Alert summary
lines.push('');
lines.push(` Alert history: ${state.alertCount} alert(s)`);
lines.push(` Consecutive frames above threshold: ${state.alertStreak}/${CONSECUTIVE_FRAMES}`);
lines.push(` Calibrated channels: ${state.calibratedChannels.join(', ')}`);
lines.push(` Total frames: ${state.totalFrames}`);
return lines.join('\n');
}
// ---------------------------------------------------------------------------
// Global state
// ---------------------------------------------------------------------------
const detector = new ThroughWallDetector(CALIBRATE_S, ALERT_THRESHOLD, CONSECUTIVE_FRAMES);
let lastDisplayMs = 0;
function displayUpdate() {
const state = detector.getState();
if (JSON_OUTPUT) {
console.log(JSON.stringify({
timestamp: Date.now() / 1000,
status: state.status,
fusedScore: +state.fusedScore.toFixed(3),
alertActive: state.alertActive,
perChannel: state.perChannelScores,
alertCount: state.alertCount,
}));
} else {
process.stdout.write('\x1B[2J\x1B[H');
console.log(renderStatus(detector));
console.log('');
console.log(' Press Ctrl+C to exit');
}
}
// ---------------------------------------------------------------------------
// Live mode
// ---------------------------------------------------------------------------
function startLive() {
const sock = dgram.createSocket('udp4');
sock.on('message', (buf) => {
if (buf.length < 4) return;
const magic = buf.readUInt32LE(0);
if (magic !== CSI_MAGIC) return;
const frame = parseCSIFrame(buf);
if (!frame) return;
detector.ingestFrame(frame.channel, frame.amplitudes, Date.now() / 1000);
const now = Date.now();
if (now - lastDisplayMs >= INTERVAL_MS) {
displayUpdate();
lastDisplayMs = now;
}
});
sock.bind(PORT, () => {
if (!JSON_OUTPUT) {
console.log(`Through-Wall Detector listening on UDP port ${PORT}`);
console.log(`Calibration period: ${CALIBRATE_S}s`);
console.log(`Alert threshold: ${ALERT_THRESHOLD}`);
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;
let firstAlertTs = null;
let totalAlertWindows = 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 = parseIqHex(record.iq_hex, record.subcarriers || 64);
const channel = record.channel || assignChannel(record.node_id);
detector.ingestFrame(channel, amplitudes, record.timestamp);
frameCount++;
const tsMs = record.timestamp * 1000;
if (lastAnalysisTs === 0) lastAnalysisTs = tsMs;
if (tsMs - lastAnalysisTs >= INTERVAL_MS) {
windowCount++;
const state = detector.getState();
if (state.alertActive) {
totalAlertWindows++;
if (!firstAlertTs) firstAlertTs = record.timestamp;
}
if (JSON_OUTPUT) {
console.log(JSON.stringify({
window: windowCount,
timestamp: record.timestamp,
status: state.status,
fusedScore: +state.fusedScore.toFixed(3),
alertActive: state.alertActive,
}));
} else {
const statusTag = state.status === STATUS.ALERT ? ' ** ALERT **' :
state.status === STATUS.CALIBRATING ? ' calibrating' : '';
console.log(
` [${windowCount.toString().padStart(4)}] t=${record.timestamp.toFixed(1)}s` +
` score=${state.fusedScore.toFixed(2).padStart(5)}` +
` channels=${state.calibratedChannels.length}` +
` streak=${state.alertStreak}/${CONSECUTIVE_FRAMES}` +
statusTag
);
}
lastAnalysisTs = tsMs;
}
}
// Final summary
if (!JSON_OUTPUT) {
const state = detector.getState();
console.log('');
console.log('='.repeat(60));
console.log('THROUGH-WALL DETECTION SUMMARY');
console.log('='.repeat(60));
console.log(` Total frames: ${frameCount}`);
console.log(` Analysis windows: ${windowCount}`);
console.log(` Calibrated channels: ${state.calibratedChannels.join(', ')}`);
console.log(` Alert windows: ${totalAlertWindows} / ${windowCount} (${windowCount > 0 ? (totalAlertWindows / windowCount * 100).toFixed(1) : 0}%)`);
console.log(` Total alerts: ${state.alertCount}`);
if (firstAlertTs) {
console.log(` First alert at: t=${firstAlertTs.toFixed(1)}s`);
}
console.log(` Threshold: ${ALERT_THRESHOLD}, Consecutive frames: ${CONSECUTIVE_FRAMES}`);
}
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
if (args.replay) {
startReplay(args.replay);
} else {
startLive();
}