mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-21 14:12:34 -05:00
With RUVIEW_API_TOKEN set on the server and no token in the browser, the
sensing socket was refused, closed with 1006, and retried forever while the
header said "Connecting...". A browser cannot see the 401 on a refused
upgrade, so the service could not tell this from an outage.
- ws-ticket.js: resolveWsUrl() reports a stored token the server refuses
(401/403) as 'rejected'. probeWsAuthRequired() repeats the ticket request
with no bearer after an unticketed socket fails to open; a 401 means the
server wants a token this browser does not have ('missing'). The probe
only runs on the failure path, so auth-off servers see no extra request.
withWsTicket() keeps its string contract for the other callers.
- sensing.service.js: new 'auth-required' state and dataSource. Entering it
clears the retry timer; reconnect() leaves it.
- The header widget shows "Token required", the global banner explains it,
and both open QuickSettings on API Access (once automatically per page
load, then on click). Tab banners and the dashboard indicator get labels.
- Tests: ws-ticket.test.mjs (+6) and a new sensing.service.test.mjs (5),
added to the CI UI test step.
Fixes #2099
Co-Authored-By: claude-flow <ruv@ruv.net>
406 lines
15 KiB
JavaScript
406 lines
15 KiB
JavaScript
/**
|
|
* SensingTab — Live WiFi Sensing Visualization
|
|
*
|
|
* Connects to the sensing WebSocket service and renders:
|
|
* 1. A 3D Gaussian-splat signal field (via gaussian-splats.js)
|
|
* 2. An overlay HUD with real-time metrics (RSSI, variance, bands, classification)
|
|
*/
|
|
|
|
import { sensingService } from '../services/sensing.service.js';
|
|
import { GaussianSplatRenderer } from './gaussian-splats.js';
|
|
|
|
export class SensingTab {
|
|
/** @param {HTMLElement} container - the #sensing section element */
|
|
constructor(container) {
|
|
this.container = container;
|
|
this.splatRenderer = null;
|
|
this._unsubData = null;
|
|
this._unsubState = null;
|
|
this._resizeObserver = null;
|
|
this._threeLoaded = false;
|
|
}
|
|
|
|
async init() {
|
|
this._buildDOM();
|
|
await this._loadThree();
|
|
this._initSplatRenderer();
|
|
this._connectService();
|
|
this._setupResize();
|
|
}
|
|
|
|
// ---- DOM construction --------------------------------------------------
|
|
|
|
_buildDOM() {
|
|
this.container.innerHTML = `
|
|
<h2>Live WiFi Sensing</h2>
|
|
|
|
<!-- Data-source status banner — updated by _onStateChange -->
|
|
<div id="sensingSourceBanner" class="sensing-source-banner sensing-source-reconnecting"
|
|
role="status" aria-live="polite">
|
|
RECONNECTING...
|
|
</div>
|
|
|
|
<div class="sensing-layout">
|
|
<!-- 3D viewport -->
|
|
<div class="sensing-viewport" id="sensingViewport">
|
|
<div class="sensing-loading">Loading 3D engine...</div>
|
|
</div>
|
|
|
|
<!-- Side panel -->
|
|
<div class="sensing-panel">
|
|
<!-- Connection -->
|
|
<div class="sensing-card">
|
|
<div class="sensing-card-title">Connection</div>
|
|
<div class="sensing-connection">
|
|
<span class="sensing-dot" id="sensingDot"></span>
|
|
<span id="sensingState">Connecting...</span>
|
|
<span class="sensing-source" id="sensingSource"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- RSSI -->
|
|
<div class="sensing-card">
|
|
<div class="sensing-card-title">RSSI</div>
|
|
<div class="sensing-big-value" id="sensingRssi">-- dBm</div>
|
|
<canvas id="sensingSparkline" width="200" height="40"></canvas>
|
|
</div>
|
|
|
|
<!-- Signal Features -->
|
|
<div class="sensing-card">
|
|
<div class="sensing-card-title">Signal Features</div>
|
|
<div class="sensing-meters">
|
|
<div class="sensing-meter">
|
|
<label>Variance</label>
|
|
<div class="sensing-bar"><div class="sensing-bar-fill" id="barVariance"></div></div>
|
|
<span class="sensing-meter-val" id="valVariance">0</span>
|
|
</div>
|
|
<div class="sensing-meter">
|
|
<label>Motion Band</label>
|
|
<div class="sensing-bar"><div class="sensing-bar-fill motion" id="barMotion"></div></div>
|
|
<span class="sensing-meter-val" id="valMotion">0</span>
|
|
</div>
|
|
<div class="sensing-meter">
|
|
<label>Breathing Band</label>
|
|
<div class="sensing-bar"><div class="sensing-bar-fill breath" id="barBreath"></div></div>
|
|
<span class="sensing-meter-val" id="valBreath">0</span>
|
|
</div>
|
|
<div class="sensing-meter">
|
|
<label>Spectral Power</label>
|
|
<div class="sensing-bar"><div class="sensing-bar-fill spectral" id="barSpectral"></div></div>
|
|
<span class="sensing-meter-val" id="valSpectral">0</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Classification -->
|
|
<div class="sensing-card">
|
|
<div class="sensing-card-title">Classification</div>
|
|
<div class="sensing-classification" id="sensingClassification">
|
|
<div class="sensing-class-label" id="classLabel">ABSENT</div>
|
|
<div class="sensing-confidence">
|
|
<label>Confidence</label>
|
|
<div class="sensing-bar"><div class="sensing-bar-fill confidence" id="barConfidence"></div></div>
|
|
<span class="sensing-meter-val" id="valConfidence">0%</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Setup info -->
|
|
<div class="sensing-card">
|
|
<div class="sensing-card-title">About This Data</div>
|
|
<p class="sensing-about-text">
|
|
Metrics are computed from WiFi Channel State Information (CSI).
|
|
With <strong><span id="sensingNodeCount">0</span> ESP32 node(s)</strong> you get presence detection, breathing
|
|
estimation, and gross motion. Add <strong>3-4+ ESP32 nodes</strong>
|
|
around the room for spatial resolution and limb-level tracking.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Node Status -->
|
|
<div class="sensing-card" id="sensingNodeCards">
|
|
<div class="sensing-card-title">NODE STATUS</div>
|
|
<div id="nodeStatusContainer"></div>
|
|
</div>
|
|
|
|
<!-- Extra info -->
|
|
<div class="sensing-card">
|
|
<div class="sensing-card-title">Details</div>
|
|
<div class="sensing-details">
|
|
<div class="sensing-detail-row">
|
|
<span>Dominant Freq</span><span id="valDomFreq">0 Hz</span>
|
|
</div>
|
|
<div class="sensing-detail-row">
|
|
<span>Change Points</span><span id="valChangePoints">0</span>
|
|
</div>
|
|
<div class="sensing-detail-row">
|
|
<span>Sample Rate</span><span id="valSampleRate">--</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ---- Three.js loading --------------------------------------------------
|
|
|
|
async _loadThree() {
|
|
if (window.THREE) {
|
|
this._threeLoaded = true;
|
|
return;
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const script = document.createElement('script');
|
|
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js';
|
|
script.onload = () => {
|
|
this._threeLoaded = true;
|
|
resolve();
|
|
};
|
|
script.onerror = () => reject(new Error('Failed to load Three.js'));
|
|
document.head.appendChild(script);
|
|
});
|
|
}
|
|
|
|
// ---- Splat renderer ----------------------------------------------------
|
|
|
|
_initSplatRenderer() {
|
|
const viewport = this.container.querySelector('#sensingViewport');
|
|
if (!viewport) return;
|
|
|
|
// Remove loading message
|
|
viewport.innerHTML = '';
|
|
|
|
try {
|
|
this.splatRenderer = new GaussianSplatRenderer(viewport, {
|
|
width: viewport.clientWidth,
|
|
height: viewport.clientHeight || 500,
|
|
});
|
|
} catch (e) {
|
|
console.error('[SensingTab] Failed to init splat renderer:', e);
|
|
viewport.innerHTML = '<div class="sensing-loading">3D rendering unavailable</div>';
|
|
}
|
|
}
|
|
|
|
// ---- Service connection ------------------------------------------------
|
|
|
|
_connectService() {
|
|
sensingService.start();
|
|
|
|
this._unsubData = sensingService.onData((data) => this._onSensingData(data));
|
|
this._unsubState = sensingService.onStateChange((state) => this._onStateChange(state));
|
|
}
|
|
|
|
_onSensingData(data) {
|
|
// Update 3D view
|
|
if (this.splatRenderer) {
|
|
this.splatRenderer.update(data);
|
|
}
|
|
|
|
// Update HUD
|
|
this._updateHUD(data);
|
|
|
|
// Update per-node panels
|
|
this._updateNodePanels(data);
|
|
}
|
|
|
|
_onStateChange(state) {
|
|
const dot = this.container.querySelector('#sensingDot');
|
|
const text = this.container.querySelector('#sensingState');
|
|
const banner = this.container.querySelector('#sensingSourceBanner');
|
|
|
|
if (dot && text) {
|
|
const stateLabels = {
|
|
disconnected: 'Disconnected',
|
|
connecting: 'Connecting...',
|
|
connected: 'Connected',
|
|
reconnecting: 'Reconnecting...',
|
|
simulated: 'Simulated',
|
|
'auth-required': 'API token required',
|
|
};
|
|
dot.className = 'sensing-dot ' + state;
|
|
text.textContent = stateLabels[state] || state;
|
|
}
|
|
|
|
if (banner) {
|
|
// Map the service's dataSource to banner text and CSS modifier class.
|
|
const dataSource = sensingService.dataSource;
|
|
const bannerConfig = {
|
|
'live': { text: 'LIVE \u2014 ESP32 HARDWARE', cls: 'sensing-source-live' },
|
|
'server-simulated': { text: 'SIMULATED \u2014 NO HARDWARE', cls: 'sensing-source-server-sim' },
|
|
'reconnecting': { text: 'RECONNECTING...', cls: 'sensing-source-reconnecting' },
|
|
'unreachable': { text: 'NO DATA \u2014 SERVER UNREACHABLE', cls: 'sensing-source-simulated' },
|
|
'simulated': { text: 'INVENTED DATA \u2014 NOT MEASURED', cls: 'sensing-source-simulated' },
|
|
'auth-required': { text: 'API TOKEN REQUIRED \u2014 SETTINGS \u2192 API ACCESS', cls: 'sensing-source-simulated' },
|
|
};
|
|
const cfg = bannerConfig[dataSource] || bannerConfig.reconnecting;
|
|
banner.textContent = cfg.text;
|
|
banner.className = 'sensing-source-banner ' + cfg.cls;
|
|
}
|
|
}
|
|
|
|
// ---- HUD update --------------------------------------------------------
|
|
|
|
_updateHUD(data) {
|
|
const f = data.features || {};
|
|
const c = data.classification || {};
|
|
|
|
// Node count
|
|
const nodeCount = (data.nodes || []).length;
|
|
const countEl = this.container.querySelector('#sensingNodeCount');
|
|
if (countEl) countEl.textContent = String(nodeCount);
|
|
|
|
// RSSI
|
|
this._setText('sensingRssi', `${(f.mean_rssi || -80).toFixed(1)} dBm`);
|
|
this._setText('sensingSource', data.source || '');
|
|
|
|
// Bars (scale to 0-100%)
|
|
this._setBar('barVariance', f.variance, 10, 'valVariance', f.variance);
|
|
this._setBar('barMotion', f.motion_band_power, 0.5, 'valMotion', f.motion_band_power);
|
|
this._setBar('barBreath', f.breathing_band_power, 0.3, 'valBreath', f.breathing_band_power);
|
|
this._setBar('barSpectral', f.spectral_power, 2.0, 'valSpectral', f.spectral_power);
|
|
|
|
// Classification
|
|
const label = this.container.querySelector('#classLabel');
|
|
if (label) {
|
|
const level = (c.motion_level || 'absent').toUpperCase();
|
|
label.textContent = level;
|
|
label.className = 'sensing-class-label ' + (c.motion_level || 'absent');
|
|
}
|
|
|
|
const confPct = ((c.confidence || 0) * 100).toFixed(0);
|
|
this._setBar('barConfidence', c.confidence, 1.0, 'valConfidence', confPct + '%');
|
|
|
|
// Details
|
|
this._setText('valDomFreq', (f.dominant_freq_hz || 0).toFixed(3) + ' Hz');
|
|
this._setText('valChangePoints', String(f.change_points || 0));
|
|
const srcLabel = (data.source === 'simulated' || data.source === 'simulate') ? 'sim' : data.source || 'live';
|
|
this._setText('valSampleRate', srcLabel);
|
|
|
|
// Sparkline
|
|
this._drawSparkline();
|
|
}
|
|
|
|
_setText(id, text) {
|
|
const el = this.container.querySelector('#' + id);
|
|
if (el) el.textContent = text;
|
|
}
|
|
|
|
_setBar(barId, value, maxVal, valId, displayVal) {
|
|
const bar = this.container.querySelector('#' + barId);
|
|
if (bar) {
|
|
const pct = Math.min(100, Math.max(0, ((value || 0) / maxVal) * 100));
|
|
bar.style.width = pct + '%';
|
|
}
|
|
if (valId && displayVal != null) {
|
|
const el = this.container.querySelector('#' + valId);
|
|
if (el) el.textContent = typeof displayVal === 'number' ? displayVal.toFixed(3) : displayVal;
|
|
}
|
|
}
|
|
|
|
_drawSparkline() {
|
|
const canvas = this.container.querySelector('#sensingSparkline');
|
|
if (!canvas) return;
|
|
const ctx = canvas.getContext('2d');
|
|
const history = sensingService.getRssiHistory();
|
|
if (history.length < 2) return;
|
|
|
|
const w = canvas.width;
|
|
const h = canvas.height;
|
|
ctx.clearRect(0, 0, w, h);
|
|
|
|
const min = Math.min(...history) - 2;
|
|
const max = Math.max(...history) + 2;
|
|
const range = max - min || 1;
|
|
|
|
ctx.beginPath();
|
|
ctx.strokeStyle = '#32b8c6';
|
|
ctx.lineWidth = 1.5;
|
|
|
|
for (let i = 0; i < history.length; i++) {
|
|
const x = (i / (history.length - 1)) * w;
|
|
const y = h - ((history[i] - min) / range) * h;
|
|
if (i === 0) ctx.moveTo(x, y);
|
|
else ctx.lineTo(x, y);
|
|
}
|
|
ctx.stroke();
|
|
}
|
|
|
|
// ---- Per-node panels ---------------------------------------------------
|
|
|
|
_updateNodePanels(data) {
|
|
const container = this.container.querySelector('#nodeStatusContainer');
|
|
if (!container) return;
|
|
const nodeFeatures = data.node_features || [];
|
|
if (nodeFeatures.length === 0) {
|
|
container.textContent = '';
|
|
const msg = document.createElement('div');
|
|
msg.style.cssText = 'color:#888;font-size:12px;padding:8px;';
|
|
msg.textContent = 'No nodes detected';
|
|
container.appendChild(msg);
|
|
return;
|
|
}
|
|
const NODE_COLORS = ['#00ccff', '#ff6600', '#00ff88', '#ff00cc', '#ffcc00', '#8800ff', '#00ffcc', '#ff0044'];
|
|
container.textContent = '';
|
|
for (const nf of nodeFeatures) {
|
|
const color = NODE_COLORS[nf.node_id % NODE_COLORS.length];
|
|
const statusColor = nf.stale ? '#888' : '#0f0';
|
|
|
|
const row = document.createElement('div');
|
|
row.style.cssText = `display:flex;align-items:center;gap:8px;padding:6px 8px;margin-bottom:4px;background:rgba(255,255,255,0.03);border-radius:6px;border-left:3px solid ${color};`;
|
|
|
|
const idCol = document.createElement('div');
|
|
idCol.style.minWidth = '50px';
|
|
const nameEl = document.createElement('div');
|
|
nameEl.style.cssText = `font-size:11px;font-weight:600;color:${color};`;
|
|
nameEl.textContent = 'Node ' + nf.node_id;
|
|
const statusEl = document.createElement('div');
|
|
statusEl.style.cssText = `font-size:9px;color:${statusColor};`;
|
|
statusEl.textContent = nf.stale ? 'STALE' : 'ACTIVE';
|
|
idCol.appendChild(nameEl);
|
|
idCol.appendChild(statusEl);
|
|
|
|
const metricsCol = document.createElement('div');
|
|
metricsCol.style.cssText = 'flex:1;font-size:10px;color:#aaa;';
|
|
metricsCol.textContent = (nf.rssi_dbm || -80).toFixed(0) + ' dBm · var ' + (nf.features?.variance || 0).toFixed(1);
|
|
|
|
const classCol = document.createElement('div');
|
|
classCol.style.cssText = 'font-size:10px;font-weight:600;color:#ccc;';
|
|
const motion = (nf.classification?.motion_level || 'absent').toUpperCase();
|
|
const conf = ((nf.classification?.confidence || 0) * 100).toFixed(0);
|
|
classCol.textContent = motion + ' ' + conf + '%';
|
|
|
|
row.appendChild(idCol);
|
|
row.appendChild(metricsCol);
|
|
row.appendChild(classCol);
|
|
container.appendChild(row);
|
|
}
|
|
}
|
|
|
|
// ---- Resize ------------------------------------------------------------
|
|
|
|
_setupResize() {
|
|
const viewport = this.container.querySelector('#sensingViewport');
|
|
if (!viewport || !window.ResizeObserver) return;
|
|
|
|
this._resizeObserver = new ResizeObserver((entries) => {
|
|
for (const entry of entries) {
|
|
if (this.splatRenderer) {
|
|
this.splatRenderer.resize(entry.contentRect.width, entry.contentRect.height);
|
|
}
|
|
}
|
|
});
|
|
this._resizeObserver.observe(viewport);
|
|
}
|
|
|
|
// ---- Cleanup -----------------------------------------------------------
|
|
|
|
dispose() {
|
|
if (this._unsubData) this._unsubData();
|
|
if (this._unsubState) this._unsubState();
|
|
if (this._resizeObserver) this._resizeObserver.disconnect();
|
|
if (this.splatRenderer) this.splatRenderer.dispose();
|
|
sensingService.stop();
|
|
}
|
|
}
|