Files
ruvnet--RuView/ui/services/sensing.service.js
ruv 7485ebed57 fix: implement 14 missing API endpoints, fix WebSocket connectivity, and replace 25 placeholder mobile tests
The web UI had persistent 404 errors on model, recording, and training
endpoints, and the sensing WebSocket never connected on Dashboard/Live
Demo tabs because sensingService.start() was only called lazily on
Sensing tab visit.

Server (main.rs):
- Add 14 fully-functional Axum handlers: model CRUD (7), recording
  lifecycle (4), training control (3)
- Scan data/models/ and data/recordings/ at startup
- Recording writes CSI frames to .jsonl via tokio background task
- Model load/unload lifecycle with state tracking

Web UI (app.js):
- Import and start sensingService early in initializeServices() so
  Dashboard and Live Demo tabs connect to /ws/sensing immediately

Mobile (ws.service.ts):
- Fix WebSocket URL builder to use same-origin port instead of
  hardcoded port 3001

Mobile (jest.config.js):
- Fix testPathIgnorePatterns that was ignoring the entire test directory

Mobile (25 test files):
- Replace all it.todo() placeholder tests with real implementations
  covering components, services, stores, hooks, screens, and utils

ADR-043 documents all changes.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-03 13:24:03 -05:00

376 lines
12 KiB
JavaScript

/**
* 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.
*/
// Derive WebSocket URL from the page origin so it works on any port.
// The /ws/sensing endpoint is available on the same HTTP port (3000).
const _wsProto = (typeof window !== 'undefined' && window.location.protocol === 'https:') ? 'wss:' : 'ws:';
const _wsHost = (typeof window !== 'undefined' && window.location.host) ? window.location.host : 'localhost:3000';
const SENSING_WS_URL = `${_wsProto}//${_wsHost}/ws/sensing`;
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() {
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];
}
/** 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 --------------------------------------------------------
_connect() {
if (this._ws && this._ws.readyState <= WebSocket.OPEN) return;
this._setState('connecting');
try {
this._ws = new WebSocket(SENSING_WS_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;
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() {
try {
const resp = await fetch('/api/v1/status');
if (resp.ok) {
const json = await resp.json();
this._applyServerSource(json.source);
} else {
// Can't reach status endpoint — assume live until first frame tells us
this._setDataSource('live');
}
} catch {
this._setDataSource('live');
}
}
/**
* Map a raw server source string to the UI data-source label.
*/
_applyServerSource(rawSource) {
this._serverSource = rawSource;
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();
}
}
// 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();