Files
RuView/ui/services/sensing.service.js
Claude 34c9804002 feat: implement ADR-292/293/294/295/296 — provenance, UDP hardening, multi-node, model gates, CSI policy
ADR-292 (sensing-server): SourceState enum + pure transition (provenance.rs);
auth-error/unknown can never resolve to LiveVerified; synthetic exports
watermarked. Pose-fusion simulator starts SYNTHETIC and only shows LIVE on a
real decoded frame (#1557); sensing client no longer labels an unauthorized
status endpoint as live (#1526).
ADR-294 (sensing-server): NodeInference distinct from RoomInference (inference.rs);
deterministic freshness-weighted fuse_room; RateLimiter re-keyed to
(NodeId,EntityKind) so nodes do not starve each other (#1541); stale nodes go
unavailable not frozen-online (#1555).
ADR-293 (sensing-server): --udp-bind (default 127.0.0.1) + --udp-allow allowlist
+ fail-closed refusal of routable bind without allowlist unless --udp-insecure-lan
(udp_bind.rs); crate SECURITY.md documents the threat model and the deferred
per-device-auth step two.
ADR-295 (train): model_gates.rs — constant-output, unreachable-boundary (the
issue-1521 degenerate presence head), class-balance, baseline, and
metric-name-provenance gates.
ADR-296 (ci): scripts/csi-data-policy-check.sh (+ allowlist) and a workflow that
fails on tracked CSI-format/oversized-JSONL files; 6/6 self-tests pass.

Per-crate suites reported green by the swarm; CSI policy self-test 6/6 and JS
syntax verified here. Full workspace re-verification deferred until the
concurrent phase-1 spine build frees the target dir (disk pressure).

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015TcKegTS7QqhWPC2L2SzaS
2026-08-11 00:47:29 +00:00

439 lines
14 KiB
JavaScript

import { withWsTicket } from './ws-ticket.js';
/**
* Sensing WebSocket Service
*
* Manages the connection to the Python sensing WebSocket server
* (ws://localhost:8765) and provides a callback-based API for the UI.
*
* Falls back to simulated data only after MAX_RECONNECT_ATTEMPTS exhausted.
* While reconnecting the service stays in "reconnecting" state and does NOT
* emit simulated frames so the UI can clearly distinguish live vs. fallback data.
*/
const SENSING_WS_PORT_BY_HTTP_PORT = {
// Docker image: HTTP UI/API on 3000, sensing stream on 3001.
'3000': '3001',
// Python sensing stack: UI on 8080, sensing stream on 8765.
'8080': '8765',
};
export function buildSensingWsUrl(locationLike = (typeof window !== 'undefined' ? window.location : null)) {
const protocol = locationLike && locationLike.protocol === 'https:' ? 'wss:' : 'ws:';
const host = locationLike && locationLike.host ? locationLike.host : 'localhost:3001';
const hostname = locationLike && locationLike.hostname ? locationLike.hostname : host.split(':')[0];
const port = locationLike && locationLike.port ? locationLike.port : '';
const wsPort = SENSING_WS_PORT_BY_HTTP_PORT[port];
const wsHost = wsPort ? `${hostname}:${wsPort}` : host;
return `${protocol}//${wsHost}/ws/sensing`;
}
const SENSING_WS_URL = buildSensingWsUrl();
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000];
const MAX_RECONNECT_ATTEMPTS = 20;
// Number of failed attempts that must occur before simulation starts.
// This prevents the UI from flashing "SIMULATED" on a brief hiccup.
const SIM_FALLBACK_AFTER_ATTEMPTS = 5;
const SIMULATION_INTERVAL = 500; // ms
class SensingService {
constructor() {
/** @type {WebSocket|null} */
this._ws = null;
this._listeners = new Set();
this._stateListeners = new Set();
this._reconnectAttempt = 0;
this._reconnectTimer = null;
this._simTimer = null;
// Connection state: disconnected | connecting | connected | reconnecting | simulated
this._state = 'disconnected';
// Data-source label exposed to the UI:
// "live" — real ESP32 hardware connected
// "server-simulated" — server is running but using synthetic data (no hardware)
// "reconnecting" — WebSocket disconnected, retrying
// "simulated" — client-side fallback simulation (server unreachable)
this._dataSource = 'reconnecting';
// The raw source string from the server (e.g. "esp32", "simulated", "simulate")
this._serverSource = null;
this._lastMessage = null;
// Ring buffer of recent RSSI values for sparkline
this._rssiHistory = [];
this._maxHistory = 60;
}
// ---- Public API --------------------------------------------------------
/** Start the service (connect or simulate). */
start() {
void this._connect();
}
/** Stop the service entirely. */
stop() {
this._clearTimers();
if (this._ws) {
this._ws.close(1000, 'client stop');
this._ws = null;
}
this._setState('disconnected');
}
/** Register a callback for sensing data updates. Returns unsubscribe fn. */
onData(callback) {
this._listeners.add(callback);
// Immediately push last known data if available
if (this._lastMessage) callback(this._lastMessage);
return () => this._listeners.delete(callback);
}
/** Register a callback for connection state changes. Returns unsubscribe fn. */
onStateChange(callback) {
this._stateListeners.add(callback);
callback(this._state);
return () => this._stateListeners.delete(callback);
}
/** Get the RSSI sparkline history (array of floats). */
getRssiHistory() {
return [...this._rssiHistory];
}
/** Get per-node RSSI history (object keyed by node_id). */
getPerNodeRssiHistory() {
return { ...(this._perNodeRssiHistory || {}) };
}
/** Current connection state. */
get state() {
return this._state;
}
/**
* Current data source label.
* "live" — frames are arriving from the real ESP32 over WebSocket
* "reconnecting" — WebSocket disconnected; actively retrying, no frames emitted
* "simulated" — max reconnect attempts exhausted; emitting synthetic frames
*/
get dataSource() {
return this._dataSource;
}
// ---- Connection --------------------------------------------------------
// async because the server gates `/ws/sensing` (ADR-272) and a browser
// cannot set an Authorization header on an upgrade — so we mint a
// single-use ticket first. Minted per connect attempt, never cached: a
// ticket is valid once and expires in seconds, so reusing one across
// reconnects would fail on the second attempt.
async _connect() {
if (this._ws && this._ws.readyState <= WebSocket.OPEN) return;
this._setState('connecting');
let url = SENSING_WS_URL;
try {
url = await withWsTicket(SENSING_WS_URL);
} catch {
// Ticket minting is best-effort: against a server with auth off, or one
// predating ADR-272, connecting without a ticket is correct.
}
try {
this._ws = new WebSocket(url);
} catch (err) {
console.warn('[Sensing] WebSocket constructor failed:', err.message);
this._fallbackToSimulation();
return;
}
this._ws.onopen = () => {
console.info('[Sensing] Connected to', SENSING_WS_URL);
this._reconnectAttempt = 0;
this._stopSimulation();
this._setState('connected');
// Don't assume "live" yet — wait for first frame's source field.
// Fetch server status to determine actual data source immediately.
this._detectServerSource();
};
this._ws.onmessage = (evt) => {
try {
const data = JSON.parse(evt.data);
this._handleData(data);
} catch (e) {
console.warn('[Sensing] Invalid message:', e.message);
}
};
this._ws.onerror = () => {
// onerror is always followed by onclose, so we handle reconnect there
};
this._ws.onclose = (evt) => {
console.info('[Sensing] Connection closed (code=%d)', evt.code);
this._ws = null;
if (evt.code !== 1000) {
this._scheduleReconnect();
} else {
this._setState('disconnected');
this._setDataSource('reconnecting');
}
};
}
_scheduleReconnect() {
if (this._reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
console.warn('[Sensing] Max reconnect attempts (%d) reached, switching to simulation', MAX_RECONNECT_ATTEMPTS);
this._fallbackToSimulation();
return;
}
const delay = RECONNECT_DELAYS[Math.min(this._reconnectAttempt, RECONNECT_DELAYS.length - 1)];
this._reconnectAttempt++;
console.info('[Sensing] Reconnecting in %dms (attempt %d/%d)', delay, this._reconnectAttempt, MAX_RECONNECT_ATTEMPTS);
this._setState('reconnecting');
this._setDataSource('reconnecting');
this._reconnectTimer = setTimeout(() => {
this._reconnectTimer = null;
void this._connect();
}, delay);
// Only start simulation after several failed attempts so a brief hiccup
// does not immediately switch the UI to "SIMULATED DATA".
if (this._reconnectAttempt >= SIM_FALLBACK_AFTER_ATTEMPTS && this._state !== 'simulated') {
this._fallbackToSimulation();
}
}
// ---- Simulation fallback -----------------------------------------------
_fallbackToSimulation() {
this._setState('simulated');
this._setDataSource('simulated');
if (this._simTimer) return; // already running
console.info('[Sensing] Running in simulation mode');
this._simTimer = setInterval(() => {
const data = this._generateSimulatedData();
this._handleData(data);
}, SIMULATION_INTERVAL);
}
_stopSimulation() {
if (this._simTimer) {
clearInterval(this._simTimer);
this._simTimer = null;
}
}
_generateSimulatedData() {
const t = Date.now() / 1000;
const baseRssi = -45;
const variance = 1.5 + Math.sin(t * 0.1) * 1.0;
const motionBand = 0.05 + Math.abs(Math.sin(t * 0.3)) * 0.15;
const breathBand = 0.03 + Math.abs(Math.sin(t * 0.05)) * 0.08;
const isPresent = variance > 0.8;
const isActive = motionBand > 0.12;
// Generate signal field
const gridSize = 20;
const values = [];
for (let iz = 0; iz < gridSize; iz++) {
for (let ix = 0; ix < gridSize; ix++) {
const cx = gridSize / 2, cy = gridSize / 2;
const dist = Math.sqrt((ix - cx) ** 2 + (iz - cy) ** 2);
let v = Math.max(0, 1 - dist / (gridSize * 0.7)) * 0.3;
// Body blob
const bx = cx + 3 * Math.sin(t * 0.2);
const by = cy + 2 * Math.cos(t * 0.15);
const bodyDist = Math.sqrt((ix - bx) ** 2 + (iz - by) ** 2);
if (isPresent) {
v += Math.exp(-bodyDist * bodyDist / 8) * (0.3 + motionBand * 3);
}
values.push(Math.min(1, Math.max(0, v + Math.random() * 0.05)));
}
}
return {
type: 'sensing_update',
timestamp: t,
source: 'simulated',
// Explicit machine-readable marker so the UI can always detect simulated
// frames regardless of which code path produced them.
_simulated: true,
nodes: [{
node_id: 1,
rssi_dbm: baseRssi + Math.sin(t * 0.5) * 3,
position: [2, 0, 1.5],
amplitude: [],
subcarrier_count: 0,
}],
features: {
mean_rssi: baseRssi + Math.sin(t * 0.5) * 3,
variance,
std: Math.sqrt(variance),
motion_band_power: motionBand,
breathing_band_power: breathBand,
dominant_freq_hz: 0.3 + Math.sin(t * 0.02) * 0.1,
change_points: Math.floor(Math.random() * 3),
spectral_power: motionBand + breathBand + Math.random() * 0.1,
range: variance * 3,
iqr: variance * 1.5,
skewness: (Math.random() - 0.5) * 0.5,
kurtosis: Math.random() * 2,
},
classification: {
motion_level: isActive ? 'active' : (isPresent ? 'present_still' : 'absent'),
presence: isPresent,
confidence: isPresent ? 0.75 + Math.random() * 0.2 : 0.5 + Math.random() * 0.3,
},
signal_field: {
grid_size: [gridSize, 1, gridSize],
values,
},
};
}
// ---- Server source detection -------------------------------------------
/**
* Fetch `/api/v1/status` to find out if the server is using real
* hardware or simulation. Called once on WebSocket open.
*/
async _detectServerSource() {
// ADR-292 (issue #1526): an unreachable or unauthorized status endpoint is
// an *unknown* state — it must NOT collapse to "live". Prefer the canonical
// `source_state` the server now returns; on any error stay conservative
// (server-simulated) until a real frame's `source` field promotes us.
try {
const resp = await fetch('/api/v1/status');
if (resp.ok) {
const json = await resp.json();
this._applyServerSource(json.source, json.source_state);
} else {
this._setDataSource('server-simulated');
}
} catch {
this._setDataSource('server-simulated');
}
}
/**
* Map a raw server source string (and optional canonical ADR-292
* `source_state`) to the UI data-source label.
*/
_applyServerSource(rawSource, sourceState) {
this._serverSource = rawSource;
// ADR-292: only the verified/unverified live states may show "live"; any
// synthetic/stale/disconnected state must not.
if (sourceState) {
if (sourceState === 'live_verified' || sourceState === 'live_unverified') {
this._setDataSource('live');
} else if (sourceState === 'synthetic') {
this._setDataSource('server-simulated');
} else {
this._setDataSource('server-simulated');
}
return;
}
if (rawSource === 'esp32' || rawSource === 'wifi' || rawSource === 'live') {
this._setDataSource('live');
} else if (rawSource === 'simulated' || rawSource === 'simulate') {
this._setDataSource('server-simulated');
} else {
// Unknown source — show as server-simulated to be safe
this._setDataSource('server-simulated');
}
}
/** @return {string|null} Raw server source (e.g. "esp32", "simulated") */
get serverSource() {
return this._serverSource;
}
// ---- Data handling -----------------------------------------------------
_handleData(data) {
this._lastMessage = data;
// Track the server's source field from each frame so the UI
// can react if the server switches between esp32 ↔ simulated at runtime.
if (data.source && this._state === 'connected') {
const raw = data.source;
if (raw !== this._serverSource) {
this._applyServerSource(raw);
}
}
// Update RSSI history for sparkline
if (data.features && data.features.mean_rssi != null) {
this._rssiHistory.push(data.features.mean_rssi);
if (this._rssiHistory.length > this._maxHistory) {
this._rssiHistory.shift();
}
}
// Per-node RSSI tracking
if (!this._perNodeRssiHistory) this._perNodeRssiHistory = {};
if (data.node_features) {
for (const nf of data.node_features) {
if (!this._perNodeRssiHistory[nf.node_id]) {
this._perNodeRssiHistory[nf.node_id] = [];
}
this._perNodeRssiHistory[nf.node_id].push(nf.rssi_dbm);
if (this._perNodeRssiHistory[nf.node_id].length > this._maxHistory) {
this._perNodeRssiHistory[nf.node_id].shift();
}
}
}
// Notify all listeners
for (const cb of this._listeners) {
try {
cb(data);
} catch (e) {
console.error('[Sensing] Listener error:', e);
}
}
}
// ---- State management --------------------------------------------------
_setState(newState) {
if (newState === this._state) return;
this._state = newState;
for (const cb of this._stateListeners) {
try { cb(newState); } catch (e) { /* ignore */ }
}
}
/**
* Update the dataSource label and notify state listeners so the UI can
* react without needing a separate subscription.
* @param {'live'|'server-simulated'|'reconnecting'|'simulated'} source
*/
_setDataSource(source) {
if (source === this._dataSource) return;
this._dataSource = source;
// Re-use the same state-listener channel — listeners receive the
// connection state but can read dataSource via service.dataSource.
for (const cb of this._stateListeners) {
try { cb(this._state); } catch (e) { /* ignore */ }
}
}
_clearTimers() {
this._stopSimulation();
if (this._reconnectTimer) {
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
}
}
}
// Singleton
export const sensingService = new SensingService();