feat: dynamic classifier classes, per-node UI, XSS fix, RSSI fix

Complements #326 (per-node state pipeline) with additional features:

- Dynamic adaptive classifier: discover activity classes from training
  data filenames instead of hardcoded array. Users add classes via
  filename convention (train_<class>_<desc>.jsonl), no code changes.
- Per-node UI cards: SensingTab shows individual node status with
  color-coded markers, RSSI, variance, and classification per node.
- Colored node markers in 3D gaussian splat view (8-color palette).
- Per-node RSSI history tracking in sensing service.
- XSS fix: UI uses createElement/textContent instead of innerHTML.
- RSSI sign fix: ensure dBm values are always negative.
- GET /api/v1/nodes endpoint for per-node health monitoring.
- node_features field in WebSocket SensingUpdate messages.
- Firmware watchdog fix: yield after every frame to prevent IDLE1 starvation.

Addresses #237, #276, #282

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Taylor Dawson
2026-03-27 21:21:15 -07:00
parent 3c02f6cfb0
commit d88994816f
6 changed files with 309 additions and 90 deletions
+66 -1
View File
@@ -110,12 +110,18 @@ export class SensingTab {
<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>1 ESP32</strong> you get presence detection, breathing
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>
@@ -193,6 +199,9 @@ export class SensingTab {
// Update HUD
this._updateHUD(data);
// Update per-node panels
this._updateNodePanels(data);
}
_onStateChange(state) {
@@ -233,6 +242,11 @@ export class SensingTab {
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 || '');
@@ -309,6 +323,57 @@ export class SensingTab {
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() {