The UI had hardcoded localhost:8080 for HTTP and localhost:8765 for WebSocket, causing "Backend unavailable" when served from Docker (port 3000) or any non-default port. Changes: - api.config.js: BASE_URL now uses window.location.origin instead of hardcoded localhost:8080 - api.config.js: buildWsUrl() uses window.location.host instead of hardcoded localhost:8080 - sensing.service.js: WebSocket URL derived from page origin instead of hardcoded localhost:8765 - main.rs: Added /ws/sensing route to the HTTP server so WebSocket and REST are reachable on a single port Fixes #55 Co-Authored-By: claude-flow <ruv@ruv.net>
276 lines
8.0 KiB
JavaScript
276 lines
8.0 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 if the server is unreachable so the UI
|
|
* always shows something.
|
|
*/
|
|
|
|
// Derive WebSocket URL from the page origin so it works on any port
|
|
// (Docker :3000, native :8080, etc.)
|
|
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 = 10;
|
|
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;
|
|
this._state = 'disconnected'; // disconnected | connecting | connected | simulated
|
|
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;
|
|
}
|
|
|
|
// ---- 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');
|
|
};
|
|
|
|
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');
|
|
}
|
|
};
|
|
}
|
|
|
|
_scheduleReconnect() {
|
|
if (this._reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
|
|
console.warn('[Sensing] Max reconnect attempts reached, switching to simulation');
|
|
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)', delay, this._reconnectAttempt);
|
|
|
|
this._reconnectTimer = setTimeout(() => {
|
|
this._reconnectTimer = null;
|
|
this._connect();
|
|
}, delay);
|
|
|
|
// Start simulation while waiting
|
|
if (this._state !== 'simulated') {
|
|
this._fallbackToSimulation();
|
|
}
|
|
}
|
|
|
|
// ---- Simulation fallback -----------------------------------------------
|
|
|
|
_fallbackToSimulation() {
|
|
this._setState('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',
|
|
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,
|
|
},
|
|
};
|
|
}
|
|
|
|
// ---- Data handling -----------------------------------------------------
|
|
|
|
_handleData(data) {
|
|
this._lastMessage = data;
|
|
|
|
// 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 */ }
|
|
}
|
|
}
|
|
|
|
_clearTimers() {
|
|
this._stopSimulation();
|
|
if (this._reconnectTimer) {
|
|
clearTimeout(this._reconnectTimer);
|
|
this._reconnectTimer = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Singleton
|
|
export const sensingService = new SensingService();
|