fix: live demo static pose & inaccurate sensing data (issue #86)

- Docker default changed from --source simulated to --source auto
  (auto-detects ESP32 on UDP 5005, falls back to simulation)
- Pose derivation now driven by real sensing features: motion_band_power,
  breathing_band_power, variance, dominant_freq_hz, change_points
- Temporal feature extraction: 100-frame circular buffer, Goertzel
  breathing rate estimation (0.1-0.5 Hz), frame-to-frame L2 motion
  detection, SNR-based signal quality metric
- Signal field driven by subcarrier variance spatial mapping instead
  of fixed animation circle
- UI data source indicators: LIVE/RECONNECTING/SIMULATED banner on
  sensing tab, estimation mode badge on live demo tab
- Setup guide panel explaining ESP32 count requirements for each
  capability level (1x: presence, 3x: localization, 4x+: full pose)
- Tick rate improved from 500ms to 100ms (2fps to 10fps)
- Fixed Option<f64> division bug from PR #83
- ADR-035 documents all decisions

Closes #86

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv
2026-03-02 10:54:07 -05:00
parent fdc7142dfa
commit 8166d8d822
11 changed files with 1647 additions and 336 deletions
+50 -8
View File
@@ -4,8 +4,9 @@
* 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.
* 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
@@ -14,7 +15,10 @@ const _wsProto = (typeof window !== 'undefined' && window.location.protocol ===
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 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 {
@@ -26,7 +30,10 @@ class SensingService {
this._reconnectAttempt = 0;
this._reconnectTimer = null;
this._simTimer = null;
this._state = 'disconnected'; // disconnected | connecting | connected | simulated
// Connection state: disconnected | connecting | connected | reconnecting | simulated
this._state = 'disconnected';
// Data-source label exposed to the UI: "live" | "reconnecting" | "simulated"
this._dataSource = 'reconnecting';
this._lastMessage = null;
// Ring buffer of recent RSSI values for sparkline
@@ -76,6 +83,16 @@ class SensingService {
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() {
@@ -96,6 +113,7 @@ class SensingService {
this._reconnectAttempt = 0;
this._stopSimulation();
this._setState('connected');
this._setDataSource('live');
};
this._ws.onmessage = (evt) => {
@@ -118,28 +136,33 @@ class SensingService {
this._scheduleReconnect();
} else {
this._setState('disconnected');
this._setDataSource('reconnecting');
}
};
}
_scheduleReconnect() {
if (this._reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
console.warn('[Sensing] Max reconnect attempts reached, switching to simulation');
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)', delay, 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);
// Start simulation while waiting
if (this._state !== 'simulated') {
// 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();
}
}
@@ -148,6 +171,7 @@ class SensingService {
_fallbackToSimulation() {
this._setState('simulated');
this._setDataSource('simulated');
if (this._simTimer) return; // already running
console.info('[Sensing] Running in simulation mode');
@@ -196,6 +220,9 @@ class SensingService {
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,
@@ -262,6 +289,21 @@ class SensingService {
}
}
/**
* Update the dataSource label and notify state listeners so the UI can
* react without needing a separate subscription.
* @param {'live'|'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) {