feat(sensing-server): per-node CSI separation for multi-node mesh#1
feat(sensing-server): per-node CSI separation for multi-node mesh#1taylorjdawson wants to merge 2 commits intomainfrom
Conversation
Track each ESP32 node independently instead of merging all CSI frames into a single buffer. This enables per-node feature computation, spatial awareness, and proper multi-node visualization. Server changes: - Add NodeState struct with per-node frame_history, RSSI history, features, classification, and smoothing state - Compute features per-node using each node's own temporal history - Add compute_fused_features() for backward-compatible aggregate - Add smooth_and_classify_node() for per-node motion classification - Add GET /api/v1/nodes endpoint for per-node health/status - Add PerNodeFeatureInfo to WebSocket SensingUpdate messages - Fix RSSI sign (negative values for WiFi RSSI) - Add node timeout cleanup (stale after 5s, removed after 30s) - Add 2-person activity classes to adaptive classifier (7 total) UI changes: - Dynamic node count display (was hardcoded "1 ESP32") - Per-node status cards showing RSSI, variance, classification - Color-coded node markers in 3D gaussian splat view - Per-node RSSI history tracking in sensing service Addresses ruvnet#237, ruvnet#276, ruvnet#51 — multi-node sensing now shows independent per-node features instead of merged/corrupted single-stream data. Implements server-side integration for ADR-029 (RuvSense multistatic sensing mode) per-node tracking. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix RSSI negation overflow: use saturating_neg() to handle i8::MIN - Fix signal_field using fused features instead of single-node features - Fix XSS: replace innerHTML with DOM element creation in node panels Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR implements Phase 3A multi-node support for the sensing stack by tracking CSI history per ESP32 node, computing per-node features/classification, and exposing/fusing them for backward-compatible consumers while updating the UI to visualize per-node status and markers.
Changes:
- Add per-node CSI state (
NodeState) and emit optionalnode_featuresalongside fused aggregate features/classification. - Introduce
GET /api/v1/nodesfor per-node health/status monitoring. - Update UI to show dynamic node count, per-node status cards, and per-node colored markers in the splat view.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| ui/services/sensing.service.js | Tracks per-node RSSI histories from node_features messages. |
| ui/components/gaussian-splats.js | Adds dynamic colored node markers rendered per node_id. |
| ui/components/SensingTab.js | Displays node count and per-node status panels based on node_features. |
| rust-port/.../src/main.rs | Implements per-node state/history, fused feature computation, node_features WS field, and /api/v1/nodes. |
| rust-port/.../src/adaptive_classifier.rs | Expands adaptive classifier label set to include 2-person activity classes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // -- Update node positions (legacy single-node) ------------------------ | ||
| if (nodes.length > 0 && nodes[0].position) { | ||
| const pos = nodes[0].position; | ||
| this.nodeMarker.position.set(pos[0], 0.5, pos[2]); | ||
| } | ||
|
|
||
| // -- Update dynamic per-node markers (Phase 3A) ----------------------- | ||
| if (nodes && nodes.length > 0 && this.scene) { | ||
| const THREE = this._THREE || window.THREE; | ||
| if (THREE) { | ||
| const activeIds = new Set(); | ||
| for (const node of nodes) { | ||
| activeIds.add(node.node_id); | ||
| if (!this.nodeMarkers.has(node.node_id)) { | ||
| const geo = new THREE.SphereGeometry(0.25, 16, 16); | ||
| const mat = new THREE.MeshBasicMaterial({ | ||
| color: NODE_MARKER_COLORS[node.node_id % NODE_MARKER_COLORS.length], | ||
| transparent: true, | ||
| opacity: 0.8, | ||
| }); | ||
| const marker = new THREE.Mesh(geo, mat); | ||
| this.scene.add(marker); | ||
| this.nodeMarkers.set(node.node_id, marker); | ||
| } | ||
| const marker = this.nodeMarkers.get(node.node_id); | ||
| const pos = node.position || [0, 0, 0]; | ||
| marker.position.set(pos[0], 0.5, pos[2]); | ||
| } |
There was a problem hiding this comment.
The renderer now keeps the legacy this.nodeMarker and creates a dynamic marker for every entry in nodes, so the first node can be rendered twice (once as the legacy cyan marker and once as the per-node colored marker). Consider disabling/hiding the legacy marker when multi-node data is present, or reusing it for a specific node_id instead of adding an extra mesh.
| /// Uses weighted mean for most features, with max-boosted presence-sensitive | ||
| /// fields (variance, motion_band_power) so that a single strongly-activated | ||
| /// node is not diluted by quiet nodes. |
There was a problem hiding this comment.
The doc comment says this uses a "weighted mean" for most features, but the implementation is currently an unweighted mean (simple sum / n) with some max-boosting for a couple fields. Update the comment to match the behavior, or implement the intended weighting.
| /// Uses weighted mean for most features, with max-boosted presence-sensitive | |
| /// fields (variance, motion_band_power) so that a single strongly-activated | |
| /// node is not diluted by quiet nodes. | |
| /// Uses an unweighted mean (simple average across active nodes) for most | |
| /// features, with max-boosted presence-sensitive fields (variance, | |
| /// motion_band_power) so that a single strongly-activated node is not | |
| /// diluted by quiet nodes. |
| let sequence = u32::from_le_bytes([buf[10], buf[11], buf[12], buf[13]]); | ||
| let rssi = buf[14] as i8; | ||
| let rssi_raw = buf[14] as i8; | ||
| // Fix RSSI sign: ensure it's always negative (dBm convention). |
There was a problem hiding this comment.
The RSSI "always negative" comment doesn't match the logic for rssi_raw == 0 (it will remain 0). Either relax the comment ("ensure non-positive") or make the condition >= 0 if you truly want strictly negative values.
| // Fix RSSI sign: ensure it's always negative (dBm convention). | |
| // Fix RSSI sign: ensure it is non-positive (dBm convention). |
| // Estimate frame rate from frame_count and last_seen. | ||
| // Use a rough estimate: frames / elapsed time (cap at 100 Hz). | ||
| let elapsed_secs = now.duration_since(ns.last_seen).as_secs_f64(); | ||
| let frame_rate_hz = if ns.frame_count > 1 && elapsed_secs < 60.0 { | ||
| // Simple: use 10 Hz default unless we have better info. | ||
| // A proper rate estimator would track inter-frame intervals. | ||
| 10.0 | ||
| } else { | ||
| 0.0 | ||
| }; |
There was a problem hiding this comment.
frame_rate_hz is exposed in PerNodeFeatureInfo, but the current computation is effectively hardcoded to 10.0 once a node has >1 frame (and elapsed_secs is computed but not used). This makes the field misleading for monitoring. Either compute a real rate (track inter-frame timestamps / a short EMA) or omit the field until it's accurate.
| // Estimate frame rate from frame_count and last_seen. | |
| // Use a rough estimate: frames / elapsed time (cap at 100 Hz). | |
| let elapsed_secs = now.duration_since(ns.last_seen).as_secs_f64(); | |
| let frame_rate_hz = if ns.frame_count > 1 && elapsed_secs < 60.0 { | |
| // Simple: use 10 Hz default unless we have better info. | |
| // A proper rate estimator would track inter-frame intervals. | |
| 10.0 | |
| } else { | |
| 0.0 | |
| }; | |
| // NOTE: A proper frame rate estimator should track inter-frame intervals | |
| // (e.g., using timestamps or a short EMA). Until that is implemented, | |
| // report 0.0 to avoid exposing a misleading hardcoded value. | |
| let frame_rate_hz = 0.0; |
| _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; | ||
| } |
There was a problem hiding this comment.
_updateNodePanels treats missing/empty data.node_features as "No nodes detected", but the rest of the UI (and the server) can still populate data.nodes (e.g., simulated or wifi sources set node_features: None). This leads to contradictory UI where node count > 0 but status says no nodes. Consider falling back to data.nodes for the panel, or show a message like "Per-node features unavailable" when node_features is absent.
| "features": ns.latest_features, | ||
| "classification": ns.latest_classification, |
There was a problem hiding this comment.
nodes_endpoint builds JSON using ns.latest_features / ns.latest_classification, which attempts to move these Option<T> values out of a &NodeState and should fail to compile. Clone (or serialize by reference) these fields instead (e.g., use .clone() on the options) to keep the borrow valid.
| "features": ns.latest_features, | |
| "classification": ns.latest_classification, | |
| "features": ns.latest_features.clone(), | |
| "classification": ns.latest_classification.clone(), |
| // Remove stale markers | ||
| for (const [id, marker] of this.nodeMarkers) { | ||
| if (!activeIds.has(id)) { | ||
| this.scene.remove(marker); | ||
| this.nodeMarkers.delete(id); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Dynamic node markers are removed from the scene but their geometries/materials are never disposed, and dispose() doesn't clear this.nodeMarkers. Over time (or when nodes flap / tab is re-mounted) this can leak GPU resources. Dispose the marker's geometry/material when removing, and ensure dispose() iterates through this.nodeMarkers to remove+dispose all meshes.
| // Push amplitudes to the NODE's frame_history. | ||
| node.frame_history.push_back(frame.amplitudes.clone()); | ||
| if node.frame_history.len() > FRAME_HISTORY_CAPACITY { | ||
| node.frame_history.pop_front(); | ||
| } | ||
|
|
||
| // Extract features using the NODE's frame_history. | ||
| let sample_rate_hz = 1000.0 / 500.0_f64; | ||
| let (node_features, mut node_classification, _node_breathing_hz, _node_sub_vars, node_raw_motion) = | ||
| extract_features_from_frame(&frame, &node.frame_history, sample_rate_hz); | ||
|
|
There was a problem hiding this comment.
Per-node frame_history is updated with the current frame before calling extract_features_from_frame(). Inside that function, temporal motion compares the current frame against frame_history.back(), which will now be the same frame, producing a zero diff and weakening the temporal-motion feature for nodes. Either compute features before pushing the current frame, or adjust the extractor to compare against the previous entry (e.g., second-to-last) when the current frame is already stored.
| pub const CLASSES: &[&str] = &["absent", "present_still", "present_moving", "active", "2p_still", "2p_moving", "2p_active"]; | ||
| const N_CLASSES: usize = 7; |
There was a problem hiding this comment.
Adding 2-person classes changes N_CLASSES/CLASSES, but existing saved adaptive models (e.g., data/adaptive_model.json) may still contain weights/stats for 4 classes. With the current classify() implementation, the extra class logits default to 0 and can dominate the softmax, causing an old 4-class model to incorrectly predict a 2p_* label. Add compatibility handling: validate class count on load (reject/ignore mismatched models) or pad missing logits with a large negative value so new classes can't be selected unless trained.
| pub const CLASSES: &[&str] = &["absent", "present_still", "present_moving", "active", "2p_still", "2p_moving", "2p_active"]; | |
| const N_CLASSES: usize = 7; | |
| pub const CLASSES: &[&str] = &[ | |
| "absent", | |
| "present_still", | |
| "present_moving", | |
| "active", | |
| "2p_still", | |
| "2p_moving", | |
| "2p_active", | |
| ]; | |
| pub const N_CLASSES: usize = CLASSES.len(); |
|
Superseded by upstream PR ruvnet#289. Custom changes moved to homeview/custom branch. |
Summary
GET /api/v1/nodesendpoint for per-node health monitoringMotivation
The sensing server merges CSI frames from all ESP32 nodes into one
frame_historybuffer, discardingnode_id. This means:With 4 nodes in a 900 sqft apartment, per-node separation enables room-level localization and dramatically better motion/presence detection.
Addresses upstream issues ruvnet#237, ruvnet#276, ruvnet#51.
Implements server-side integration for ADR-029 (RuvSense multistatic sensing mode).
Changes
Server (
sensing-server/src/main.rs):NodeStatestruct — per-node frame_history, RSSI history, features, classification, smoothing statesmooth_and_classify_node()— per-node motion classification with EMA/debouncecompute_fused_features()— weighted aggregation across active nodes (max-boosted for presence-sensitive features)build_per_node_features()— builds sorted per-node feature list for WebSocketnodes_endpoint()—GET /api/v1/nodesreturns per-node healthSensingUpdate.node_features— optional field (backward compatible)Classifier (
adaptive_classifier.rs):2p_still,2p_moving,2p_active(7 total)UI:
Backward Compatibility
SensingUpdate.featuresstill populated with fused aggregateSensingUpdate.nodesnow contains ALL active nodes (was single node)node_featuresfield isOptionwithskip_serializing_if— old clients don't see itframe_historystill maintained alongside per-node historiesTest plan
cargo build -p wifi-densepose-sensing-servercompiles cleancurl /api/v1/nodesreturns per-node data for each connected ESP32featuresfield still populated (backward compat)🤖 Generated with Claude Code