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