mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-18 21:00:22 -05:00
Integrate reviewed source PRs #2201-#2223, #2225-#2231, #2235-#2240, #2242 and #2243 (firmware README hunks remain in #2247). Regenerate one package manifest after integration, validate native Windows test execution, bound MTK listener startup, support official C6 release bundles, and retain stronger release metadata gates.
481 lines
16 KiB
JavaScript
481 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* ADR-077: Room Environment Fingerprinting
|
|
*
|
|
* Clusters CSI feature vectors to identify distinct room states (empty,
|
|
* working, sleeping, etc.), tracks transitions, and detects anomalies.
|
|
*
|
|
* Usage:
|
|
* node scripts/room-fingerprint.js --replay data/recordings/overnight-1775217646.csi.jsonl
|
|
* node scripts/room-fingerprint.js --port 5006
|
|
* node scripts/room-fingerprint.js --replay FILE --json
|
|
*
|
|
* ADR: docs/adr/ADR-077-novel-rf-sensing-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' },
|
|
replay: { type: 'string', short: 'r' },
|
|
json: { type: 'boolean', default: false },
|
|
interval: { type: 'string', short: 'i', default: '10000' },
|
|
'k': { type: 'string', default: '5' },
|
|
'new-cluster-threshold': { type: 'string', default: '2.0' },
|
|
},
|
|
strict: true,
|
|
});
|
|
|
|
const PORT = parseInt(args.port, 10);
|
|
const JSON_OUTPUT = args.json;
|
|
const INTERVAL_MS = parseInt(args.interval, 10);
|
|
const K = parseInt(args.k, 10);
|
|
const NEW_CLUSTER_DIST = parseFloat(args['new-cluster-threshold']);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ADR-018 packet constants
|
|
// ---------------------------------------------------------------------------
|
|
const VITALS_MAGIC = 0xC5110002;
|
|
const FEATURE_MAGIC = 0xC5110003;
|
|
const FUSED_MAGIC = 0xC5110004;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Online k-means clustering
|
|
// ---------------------------------------------------------------------------
|
|
class OnlineKMeans {
|
|
constructor(maxK, featureDim, newClusterThreshold) {
|
|
this.maxK = maxK;
|
|
this.dim = featureDim;
|
|
this.threshold = newClusterThreshold;
|
|
|
|
this.centroids = []; // { center: Float64Array, count: number, label: string }
|
|
this.alpha = 0.01; // EMA update rate
|
|
}
|
|
|
|
_distance(a, b) {
|
|
let sum = 0;
|
|
const len = Math.min(a.length, b.length);
|
|
for (let i = 0; i < len; i++) {
|
|
sum += (a[i] - b[i]) ** 2;
|
|
}
|
|
return Math.sqrt(sum);
|
|
}
|
|
|
|
assign(features) {
|
|
if (this.centroids.length === 0) {
|
|
// First point creates first cluster
|
|
this.centroids.push({
|
|
center: Float64Array.from(features),
|
|
count: 1,
|
|
label: `State-0`,
|
|
});
|
|
return { clusterId: 0, distance: 0 };
|
|
}
|
|
|
|
// Find nearest centroid
|
|
let bestDist = Infinity;
|
|
let bestIdx = 0;
|
|
for (let i = 0; i < this.centroids.length; i++) {
|
|
const d = this._distance(features, this.centroids[i].center);
|
|
if (d < bestDist) {
|
|
bestDist = d;
|
|
bestIdx = i;
|
|
}
|
|
}
|
|
|
|
// If too far from any cluster, create new one (up to maxK)
|
|
if (bestDist > this.threshold && this.centroids.length < this.maxK) {
|
|
const newIdx = this.centroids.length;
|
|
this.centroids.push({
|
|
center: Float64Array.from(features),
|
|
count: 1,
|
|
label: `State-${newIdx}`,
|
|
});
|
|
return { clusterId: newIdx, distance: 0 };
|
|
}
|
|
|
|
// Update centroid via EMA
|
|
const c = this.centroids[bestIdx];
|
|
c.count++;
|
|
for (let i = 0; i < this.dim; i++) {
|
|
c.center[i] = c.center[i] * (1 - this.alpha) + features[i] * this.alpha;
|
|
}
|
|
|
|
return { clusterId: bestIdx, distance: bestDist };
|
|
}
|
|
|
|
labelClusters(clusterMotion) {
|
|
// Sort clusters by average motion to assign labels
|
|
const sorted = Object.entries(clusterMotion)
|
|
.sort((a, b) => a[1] - b[1]);
|
|
|
|
const labels = ['sleeping/empty', 'resting', 'working', 'active', 'highly active'];
|
|
for (let i = 0; i < sorted.length; i++) {
|
|
const clusterId = parseInt(sorted[i][0], 10);
|
|
if (clusterId < this.centroids.length) {
|
|
this.centroids[clusterId].label = labels[Math.min(i, labels.length - 1)];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Room state tracker
|
|
// ---------------------------------------------------------------------------
|
|
class RoomFingerprinter {
|
|
constructor(maxK, featureDim, newClusterThreshold) {
|
|
this.kmeans = new OnlineKMeans(maxK, featureDim, newClusterThreshold);
|
|
this.featureDim = featureDim;
|
|
|
|
// State tracking
|
|
this.currentState = null;
|
|
this.stateHistory = []; // { timestamp, clusterId, label, distance }
|
|
this.transitions = {}; // "from->to" -> count
|
|
|
|
// Vitals correlation
|
|
this.clusterMotionSum = {}; // clusterId -> sum
|
|
this.clusterMotionCount = {}; // clusterId -> count
|
|
|
|
// Feature buffer (latest per node)
|
|
this.latestFeatures = new Map(); // nodeId -> { timestamp, features }
|
|
this.latestVitals = new Map(); // nodeId -> { timestamp, motion, presence }
|
|
|
|
this.startTime = null;
|
|
}
|
|
|
|
pushFeature(timestamp, nodeId, features) {
|
|
if (!this.startTime) this.startTime = timestamp;
|
|
this.latestFeatures.set(nodeId, { timestamp, features });
|
|
}
|
|
|
|
pushVitals(timestamp, nodeId, motion, presence) {
|
|
this.latestVitals.set(nodeId, { timestamp, motion, presence });
|
|
}
|
|
|
|
analyze(timestamp) {
|
|
// Find latest feature vector (prefer most recent node)
|
|
let bestFeature = null;
|
|
let bestTs = 0;
|
|
for (const [, entry] of this.latestFeatures) {
|
|
if (entry.timestamp > bestTs) {
|
|
bestTs = entry.timestamp;
|
|
bestFeature = entry.features;
|
|
}
|
|
}
|
|
|
|
if (!bestFeature || bestFeature.length < this.featureDim) return null;
|
|
|
|
// Truncate or pad to featureDim
|
|
const features = new Float64Array(this.featureDim);
|
|
for (let i = 0; i < this.featureDim && i < bestFeature.length; i++) {
|
|
features[i] = bestFeature[i];
|
|
}
|
|
|
|
// Assign to cluster
|
|
const { clusterId, distance } = this.kmeans.assign(features);
|
|
|
|
// Track motion per cluster for labeling
|
|
let avgMotion = 0;
|
|
let motionCount = 0;
|
|
for (const [, v] of this.latestVitals) {
|
|
avgMotion += v.motion;
|
|
motionCount++;
|
|
}
|
|
avgMotion = motionCount > 0 ? avgMotion / motionCount : 0;
|
|
|
|
this.clusterMotionSum[clusterId] = (this.clusterMotionSum[clusterId] || 0) + avgMotion;
|
|
this.clusterMotionCount[clusterId] = (this.clusterMotionCount[clusterId] || 0) + 1;
|
|
|
|
// Update labels periodically
|
|
const clusterMotion = {};
|
|
for (const id of Object.keys(this.clusterMotionCount)) {
|
|
clusterMotion[id] = this.clusterMotionSum[id] / this.clusterMotionCount[id];
|
|
}
|
|
this.kmeans.labelClusters(clusterMotion);
|
|
|
|
const label = this.kmeans.centroids[clusterId]
|
|
? this.kmeans.centroids[clusterId].label
|
|
: `State-${clusterId}`;
|
|
|
|
// Track transitions
|
|
if (this.currentState !== null && this.currentState !== clusterId) {
|
|
const key = `${this.currentState}->${clusterId}`;
|
|
this.transitions[key] = (this.transitions[key] || 0) + 1;
|
|
}
|
|
const prevState = this.currentState;
|
|
this.currentState = clusterId;
|
|
|
|
const entry = {
|
|
timestamp,
|
|
clusterId,
|
|
label,
|
|
distance: +distance.toFixed(4),
|
|
motion: +avgMotion.toFixed(3),
|
|
transitioned: prevState !== null && prevState !== clusterId,
|
|
prevState: prevState !== null ? prevState : undefined,
|
|
totalClusters: this.kmeans.centroids.length,
|
|
};
|
|
|
|
this.stateHistory.push(entry);
|
|
return entry;
|
|
}
|
|
|
|
anomalyScore() {
|
|
// Anomaly = current state is rarely seen at this time-of-day
|
|
if (this.stateHistory.length < 10) return 0;
|
|
|
|
const currentCluster = this.currentState;
|
|
const recentCount = this.stateHistory.slice(-20).filter(e => e.clusterId === currentCluster).length;
|
|
return 1 - (recentCount / 20); // low count = high anomaly
|
|
}
|
|
|
|
renderTimeline(width) {
|
|
const w = width || 60;
|
|
if (this.stateHistory.length === 0) return 'No data yet.';
|
|
|
|
const step = Math.max(1, Math.floor(this.stateHistory.length / w));
|
|
const chars = '\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588';
|
|
|
|
let line = '';
|
|
for (let i = 0; i < this.stateHistory.length; i += step) {
|
|
const cid = this.stateHistory[i].clusterId;
|
|
line += chars[Math.min(cid, chars.length - 1)];
|
|
}
|
|
|
|
return `State timeline: ${line}`;
|
|
}
|
|
|
|
renderTransitionMatrix() {
|
|
if (Object.keys(this.transitions).length === 0) return 'No transitions yet.';
|
|
|
|
const lines = ['Transition matrix:'];
|
|
for (const [key, count] of Object.entries(this.transitions).sort((a, b) => b[1] - a[1])) {
|
|
const [from, to] = key.split('->');
|
|
const fromLabel = this.kmeans.centroids[parseInt(from, 10)]?.label || `State-${from}`;
|
|
const toLabel = this.kmeans.centroids[parseInt(to, 10)]?.label || `State-${to}`;
|
|
lines.push(` ${fromLabel} -> ${toLabel}: ${count}`);
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Packet parsing
|
|
// ---------------------------------------------------------------------------
|
|
function parseFeatureJsonl(record) {
|
|
if (record.type !== 'feature' || !record.features) return null;
|
|
return {
|
|
timestamp: record.timestamp,
|
|
nodeId: record.node_id,
|
|
features: record.features,
|
|
};
|
|
}
|
|
|
|
function parseVitalsJsonl(record) {
|
|
if (record.type !== 'vitals') return null;
|
|
return {
|
|
timestamp: record.timestamp,
|
|
nodeId: record.node_id,
|
|
motion: record.motion_energy || 0,
|
|
presence: record.presence_score || 0,
|
|
};
|
|
}
|
|
|
|
function parseFeatureUdp(buf) {
|
|
if (buf.length < 48) return null;
|
|
const magic = buf.readUInt32LE(0);
|
|
if (magic !== FEATURE_MAGIC) return null;
|
|
|
|
const nodeId = buf.readUInt8(4);
|
|
const features = [];
|
|
for (let i = 0; i < 8; i++) {
|
|
features.push(buf.readFloatLE(16 + i * 4));
|
|
}
|
|
return { timestamp: Date.now() / 1000, nodeId, features };
|
|
}
|
|
|
|
function parseVitalsUdp(buf) {
|
|
if (buf.length < 32) return null;
|
|
const magic = buf.readUInt32LE(0);
|
|
if (magic !== VITALS_MAGIC && magic !== FUSED_MAGIC) return null;
|
|
return {
|
|
timestamp: Date.now() / 1000,
|
|
nodeId: buf.readUInt8(4),
|
|
motion: buf.readFloatLE(16),
|
|
presence: buf.readFloatLE(20),
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Replay mode
|
|
// ---------------------------------------------------------------------------
|
|
async function startReplay(filePath) {
|
|
if (!fs.existsSync(filePath)) {
|
|
console.error(`File not found: ${filePath}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const fingerprinter = new RoomFingerprinter(K, 8, NEW_CLUSTER_DIST);
|
|
const rl = readline.createInterface({
|
|
input: fs.createReadStream(filePath),
|
|
crlfDelay: Infinity,
|
|
});
|
|
|
|
let featureCount = 0;
|
|
let vitalsCount = 0;
|
|
let lastAnalysisTs = 0;
|
|
|
|
for await (const line of rl) {
|
|
if (!line.trim()) continue;
|
|
let record;
|
|
try { record = JSON.parse(line); } catch { continue; }
|
|
|
|
const feat = parseFeatureJsonl(record);
|
|
if (feat) {
|
|
fingerprinter.pushFeature(feat.timestamp, feat.nodeId, feat.features);
|
|
featureCount++;
|
|
}
|
|
|
|
const vit = parseVitalsJsonl(record);
|
|
if (vit) {
|
|
fingerprinter.pushVitals(vit.timestamp, vit.nodeId, vit.motion, vit.presence);
|
|
vitalsCount++;
|
|
}
|
|
|
|
const ts = feat || vit;
|
|
if (!ts) continue;
|
|
|
|
const tsMs = ts.timestamp * 1000;
|
|
if (lastAnalysisTs === 0) lastAnalysisTs = tsMs;
|
|
|
|
if (tsMs - lastAnalysisTs >= INTERVAL_MS) {
|
|
const result = fingerprinter.analyze(ts.timestamp);
|
|
|
|
if (result) {
|
|
if (JSON_OUTPUT) {
|
|
console.log(JSON.stringify(result));
|
|
} else {
|
|
const tsStr = new Date(ts.timestamp * 1000).toISOString().slice(11, 19);
|
|
const transition = result.transitioned ? ` << TRANSITION from State-${result.prevState}` : '';
|
|
console.log(`[${tsStr}] Cluster ${result.clusterId} (${result.label}) | dist ${result.distance} | motion ${result.motion} | ${result.totalClusters} clusters${transition}`);
|
|
}
|
|
}
|
|
|
|
lastAnalysisTs = tsMs;
|
|
}
|
|
}
|
|
|
|
// Summary
|
|
if (!JSON_OUTPUT) {
|
|
console.log('\n' + '='.repeat(60));
|
|
console.log('ROOM FINGERPRINT SUMMARY');
|
|
console.log('='.repeat(60));
|
|
|
|
console.log(`\nClusters discovered: ${fingerprinter.kmeans.centroids.length}`);
|
|
for (let i = 0; i < fingerprinter.kmeans.centroids.length; i++) {
|
|
const c = fingerprinter.kmeans.centroids[i];
|
|
const stateCount = fingerprinter.stateHistory.filter(e => e.clusterId === i).length;
|
|
const pct = fingerprinter.stateHistory.length > 0
|
|
? ((stateCount / fingerprinter.stateHistory.length) * 100).toFixed(1)
|
|
: '0';
|
|
const avgMotion = fingerprinter.clusterMotionCount[i] > 0
|
|
? (fingerprinter.clusterMotionSum[i] / fingerprinter.clusterMotionCount[i]).toFixed(2)
|
|
: '?';
|
|
console.log(` Cluster ${i} (${c.label}): ${stateCount} windows (${pct}%) | avg motion ${avgMotion} | ${c.count} assignments`);
|
|
}
|
|
|
|
console.log('');
|
|
console.log(fingerprinter.renderTimeline(60));
|
|
console.log('');
|
|
console.log(fingerprinter.renderTransitionMatrix());
|
|
|
|
const anomaly = fingerprinter.anomalyScore();
|
|
console.log(`\nCurrent anomaly score: ${anomaly.toFixed(3)}`);
|
|
console.log(`Processed: ${featureCount} feature packets, ${vitalsCount} vitals packets`);
|
|
} else {
|
|
console.log(JSON.stringify({
|
|
type: 'summary',
|
|
clusters: fingerprinter.kmeans.centroids.length,
|
|
windows: fingerprinter.stateHistory.length,
|
|
transitions: Object.keys(fingerprinter.transitions).length,
|
|
anomaly: +fingerprinter.anomalyScore().toFixed(3),
|
|
}));
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Live UDP mode
|
|
// ---------------------------------------------------------------------------
|
|
function startLive() {
|
|
const fingerprinter = new RoomFingerprinter(K, 8, NEW_CLUSTER_DIST);
|
|
const server = dgram.createSocket('udp4');
|
|
|
|
server.on('message', (buf) => {
|
|
if (buf.length < 4) return;
|
|
const magic = buf.readUInt32LE(0);
|
|
|
|
if (magic === FEATURE_MAGIC) {
|
|
const feat = parseFeatureUdp(buf);
|
|
if (feat) fingerprinter.pushFeature(feat.timestamp, feat.nodeId, feat.features);
|
|
}
|
|
if (magic === VITALS_MAGIC || magic === FUSED_MAGIC) {
|
|
const vit = parseVitalsUdp(buf);
|
|
if (vit) fingerprinter.pushVitals(vit.timestamp, vit.nodeId, vit.motion, vit.presence);
|
|
}
|
|
});
|
|
|
|
setInterval(() => {
|
|
const result = fingerprinter.analyze(Date.now() / 1000);
|
|
|
|
if (JSON_OUTPUT) {
|
|
if (result) console.log(JSON.stringify(result));
|
|
} else {
|
|
process.stdout.write('\x1B[2J\x1B[H');
|
|
console.log('=== ROOM FINGERPRINT (ADR-077) ===\n');
|
|
|
|
if (result) {
|
|
console.log(`Current state: Cluster ${result.clusterId} (${result.label})`);
|
|
console.log(`Distance: ${result.distance} | Motion: ${result.motion}`);
|
|
console.log(`Clusters: ${result.totalClusters}`);
|
|
if (result.transitioned) {
|
|
console.log(`** STATE TRANSITION from State-${result.prevState} **`);
|
|
}
|
|
} else {
|
|
console.log('Collecting data...');
|
|
}
|
|
|
|
console.log('');
|
|
console.log(fingerprinter.renderTimeline(50));
|
|
console.log('');
|
|
console.log(fingerprinter.renderTransitionMatrix());
|
|
console.log(`\nAnomaly score: ${fingerprinter.anomalyScore().toFixed(3)}`);
|
|
}
|
|
}, INTERVAL_MS);
|
|
|
|
server.bind(PORT, () => {
|
|
if (!JSON_OUTPUT) {
|
|
console.log(`Room Fingerprint listening on UDP :${PORT} (k=${K})`);
|
|
}
|
|
});
|
|
|
|
process.on('SIGINT', () => { server.close(); process.exit(0); });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Entry
|
|
// ---------------------------------------------------------------------------
|
|
if (args.replay) {
|
|
startReplay(args.replay);
|
|
} else {
|
|
startLive();
|
|
}
|