mirror of
https://github.com/ruvnet/RuView.git
synced 2026-08-26 18:16:03 +00:00
Reviews and fixes RuView's 10 most recent substantive issues, plus closes the wiring gap flagged in the perception-substrate review (docs/adr, gist, release notes from the PR #1579 review). ## Fixed - #1526: UI falsely claimed "LIVE — ESP32 Hardware Connected" whenever the unauthenticated /api/v1/status probe 401'd. Now sends the bearer token on the probe (the fallback direction was already fixed by ADR-295). - #1554: top-level `classification` (GET /api/v1/sensing/latest) was the last UDP packet's single node, not the fused room aggregate, so it flapped at packet rate with 2+ disagreeing nodes. Now derived from `RoomInference` (ADR-297's `fuse_room`) at both call sites. - #1541: per-node MQTT `presence`/`presence_score` read a `classification` JSON key that does not exist on `NodeInfo` (the real field is `node_inference`), silently falling back to the room aggregate for every node — reproducing the exact "lockstep publish" the field report measured. Also fixed the *existing* regression test for this bug, which used the same wrong key in its own fixture and so never caught it. - #1557: pose-fusion's `wsPortMap` only knew port 3000, so a remapped host port fell through to `localhost:8765` (nothing there for a remote viewer). WS port is now derived from `location.port` instead of a 2-entry lookup table (the "never render simulated as live" half was already fixed by ADR-295's `onVerifiedFrame` gate). - #1525: the no-model pose path already clamps keypoint confidence to a 0.1 floor, but the renderer's own threshold is also 0.1 compared with `<=`/`>` — a keypoint at exactly the floor was still invisible. Floor raised to 0.15 to clear the client's gate. - #1556: `--mqtt-ca-file`/`--mqtt-client-cert`/`--mqtt-client-key` were parsed and stored but never applied — TLS always used the system trust store, so a self-signed broker always failed UnknownIssuer. Now builds `rumqttc::TlsConfiguration::Simple` from the real PEM files (no new TLS dependency needed). A file that can't be read logs why and falls back to system trust instead of failing opaquely later. - #1555: the MQTT availability heartbeat asserted "online" for every known node on a fixed 30s timer regardless of whether that node's data was still arriving. Now tracks each node's last-seen broadcast snapshot and only reports "online" within a 10s freshness window, otherwise "offline" — a frozen sensor can no longer look available. (The other half — restarting a publisher that goes permanently silent — needs a reproduction the reporter themselves weren't certain of; left for a follow-up rather than guessing at the trigger.) - #1540: already fixed on main (node-keyed RateLimiter, ADR-297). - #1521/#1522: not fixable in this repo (published HF model artifact); replied with the ADR-298 gate status and the exact byte-level fix for the safetensors header, and corrected the README row that claimed the file loads with the reference loader. - #1542, #1527: replied — #1542 is a real, larger firmware+server feature left open for follow-up; #1527's suggested fixes were already applied, the one residual sample is an inherent first-frame paint gap. ## Certificate spine wiring (closes the gap flagged in the PR #1579 review) `ruview-certify` and `ruview-policy` now depend on `ruview-ood` and provide real `From<ruview_ood::DomainState>` adapters plus a composed entry point, `ruview_policy::authorize_from_certificate`, matching the adapter contract `ruview-policy`'s own doc comment already described but that no code actually implemented. A new cross-crate integration test (`acceptance_test_b_real_integration`) mints a real signed `CapabilityCertificate` and proves a real post-drift `ruview_ood::Unknown` denies a `SafetyCritical` action through the composed pipeline — not two disconnected unit tests hand-setting the same enum value. Also: - Wires `evaluate_linear_head` (ADR-298 model-release gate) into a new CI job so the checker itself can't silently regress; documents that gating an actual model publish is still a manual step (no HF automation here). - Wires `SourceState::export_watermark()` into `start_recording`: a recording captured while the source is synthetic is now stamped in its metadata (not the filename or per-line JSON, to avoid breaking `delete_recording`'s path reconstruction or the training dataset loader's schema). - Updates docs/user-guide.md's "Developer Preview" section to describe what's now genuinely wired vs. still not (no live continuous calibration/OOD loop in the running server yet). ## Validation - cargo test --workspace --no-default-features: 4391 passed, 0 failed - cargo build --release -p wifi-densepose-sensing-server --features mqtt: clean - Server smoke-tested end-to-end against the simulator (real startup, UDP/WS/HTTP listeners, /api/v1/sensing/latest stable across calls) - Real ESP32-S3 hardware was NOT reachable this session (no COM port present, zero UDP frames received after 40s bound to 0.0.0.0:5005) — the multi-node fixes are validated by the new unit/integration tests and full-workspace regression, not by live hardware. Co-Authored-By: claude-flow <ruv@ruv.net>
445 lines
15 KiB
JavaScript
445 lines
15 KiB
JavaScript
import { withWsTicket } from './ws-ticket.js';
|
|
import { apiService } from './api.service.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-295 (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.
|
|
//
|
|
// Send the bearer token via `apiService.getHeaders()` so the probe can
|
|
// actually succeed under the documented secure posture (API auth
|
|
// enabled) instead of always 401ing and relying solely on the
|
|
// conservative fallback below (issue #1526, suggested fix #1).
|
|
try {
|
|
const resp = await fetch('/api/v1/status', { headers: apiService.getHeaders() });
|
|
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-295
|
|
* `source_state`) to the UI data-source label.
|
|
*/
|
|
_applyServerSource(rawSource, sourceState) {
|
|
this._serverSource = rawSource;
|
|
// ADR-295: 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();
|