Skip to content

feat(sensing-server): per-node CSI separation for multi-node mesh#1

Closed
taylorjdawson wants to merge 2 commits intomainfrom
feat/per-node-csi-separation
Closed

feat(sensing-server): per-node CSI separation for multi-node mesh#1
taylorjdawson wants to merge 2 commits intomainfrom
feat/per-node-csi-separation

Conversation

@taylorjdawson
Copy link
Owner

Summary

  • Track each ESP32 node independently instead of merging all CSI frames into a single buffer
  • Compute per-node features (variance, motion, breathing, RSSI) using each node's own temporal history
  • Fuse per-node features into a backward-compatible aggregate for existing consumers
  • Add per-node status cards and colored markers to the Sensing UI
  • Fix RSSI sign bug (was showing positive values)
  • Add GET /api/v1/nodes endpoint for per-node health monitoring

Motivation

The sensing server merges CSI frames from all ESP32 nodes into one frame_history buffer, discarding node_id. This means:

  • Temporal features compare frames from different physical nodes
  • No spatial information — can't tell which node sees activity
  • UI shows "1 ESP32" despite multiple nodes connected
  • Classification accuracy is degraded by mixed-node data

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):

  • NodeState struct — per-node frame_history, RSSI history, features, classification, smoothing state
  • smooth_and_classify_node() — per-node motion classification with EMA/debounce
  • compute_fused_features() — weighted aggregation across active nodes (max-boosted for presence-sensitive features)
  • build_per_node_features() — builds sorted per-node feature list for WebSocket
  • nodes_endpoint()GET /api/v1/nodes returns per-node health
  • RSSI sign fix — negative values for WiFi RSSI
  • Node timeout — stale after 5s, removed after 30s
  • SensingUpdate.node_features — optional field (backward compatible)

Classifier (adaptive_classifier.rs):

  • Added 2-person classes: 2p_still, 2p_moving, 2p_active (7 total)

UI:

  • Dynamic node count (was hardcoded "1 ESP32")
  • Per-node status cards (RSSI, variance, classification, color-coded)
  • Multiple colored node markers in 3D gaussian splat view
  • Per-node RSSI history tracking

Backward Compatibility

  • SensingUpdate.features still populated with fused aggregate
  • SensingUpdate.nodes now contains ALL active nodes (was single node)
  • node_features field is Option with skip_serializing_if — old clients don't see it
  • Global frame_history still maintained alongside per-node histories

Test plan

  • cargo build -p wifi-densepose-sensing-server compiles clean
  • curl /api/v1/nodes returns per-node data for each connected ESP32
  • Sensing UI shows correct node count and per-node cards
  • RSSI values are negative (-30 to -90 dBm range)
  • Unplug a node → shows "stale" after 5s, disappears after 30s
  • Walk near one node → that node's variance spikes while others stay low
  • Existing WebSocket features field still populated (backward compat)

🤖 Generated with Claude Code

taylorjdawson and others added 2 commits March 21, 2026 13:12
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>
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 optional node_features alongside fused aggregate features/classification.
  • Introduce GET /api/v1/nodes for 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.

Comment on lines +380 to +407
// -- 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]);
}
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +1054 to +1056
/// 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.
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
/// 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.

Copilot uses AI. Check for mistakes.
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).
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// Fix RSSI sign: ensure it's always negative (dBm convention).
// Fix RSSI sign: ensure it is non-positive (dBm convention).

Copilot uses AI. Check for mistakes.
Comment on lines +1167 to +1176
// 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
};
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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;

Copilot uses AI. Check for mistakes.
Comment on lines +328 to +339
_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;
}
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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.

Copilot uses AI. Check for mistakes.
Comment on lines +2977 to +2978
"features": ns.latest_features,
"classification": ns.latest_classification,
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
"features": ns.latest_features,
"classification": ns.latest_classification,
"features": ns.latest_features.clone(),
"classification": ns.latest_classification.clone(),

Copilot uses AI. Check for mistakes.
Comment on lines +408 to +415
// Remove stale markers
for (const [id, marker] of this.nodeMarkers) {
if (!activeIds.has(id)) {
this.scene.remove(marker);
this.nodeMarkers.delete(id);
}
}
}
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +3081 to +3091
// 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);

Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +25
pub const CLASSES: &[&str] = &["absent", "present_still", "present_moving", "active", "2p_still", "2p_moving", "2p_active"];
const N_CLASSES: usize = 7;
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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();

Copilot uses AI. Check for mistakes.
@taylorjdawson
Copy link
Owner Author

Superseded by upstream PR ruvnet#289. Custom changes moved to homeview/custom branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants