Metrics are computed from WiFi Channel State Information (CSI).
- With 1 ESP32 you get presence detection, breathing
+ With 0 ESP32 node(s) you get presence detection, breathing
estimation, and gross motion. Add 3-4+ ESP32 nodes
around the room for spatial resolution and limb-level tracking.
+
+
Details
@@ -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() {
diff --git a/ui/components/gaussian-splats.js b/ui/components/gaussian-splats.js
index ecab6e481..5f7227fa3 100644
--- a/ui/components/gaussian-splats.js
+++ b/ui/components/gaussian-splats.js
@@ -66,6 +66,10 @@ function valueToColor(v) {
return [r, g, b];
}
+// ---- Node marker color palette -------------------------------------------
+
+const NODE_MARKER_COLORS = [0x00ccff, 0xff6600, 0x00ff88, 0xff00cc, 0xffcc00, 0x8800ff, 0x00ffcc, 0xff0044];
+
// ---- GaussianSplatRenderer -----------------------------------------------
export class GaussianSplatRenderer {
@@ -108,6 +112,10 @@ export class GaussianSplatRenderer {
// Node markers (ESP32 / router positions)
this._createNodeMarkers(THREE);
+ // Dynamic per-node markers (multi-node support)
+ this.nodeMarkers = new Map(); // nodeId -> THREE.Mesh
+ this._THREE = THREE;
+
// Body disruption blob
this._createBodyBlob(THREE);
@@ -369,11 +377,43 @@ export class GaussianSplatRenderer {
bGeo.attributes.splatSize.needsUpdate = true;
}
- // -- Update node positions ---------------------------------------------
+ // -- 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 (multi-node support) --------------
+ 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]);
+ }
+ // Remove stale markers
+ for (const [id, marker] of this.nodeMarkers) {
+ if (!activeIds.has(id)) {
+ this.scene.remove(marker);
+ this.nodeMarkers.delete(id);
+ }
+ }
+ }
+ }
}
// ---- Render loop -------------------------------------------------------
diff --git a/ui/services/sensing.service.js b/ui/services/sensing.service.js
index 4931e86e2..0992483bc 100644
--- a/ui/services/sensing.service.js
+++ b/ui/services/sensing.service.js
@@ -84,6 +84,11 @@ class SensingService {
return [...this._rssiHistory];
}
+ /** Get per-node RSSI history (object keyed by node_id). */
+ getPerNodeRssiHistory() {
+ return { ...(this._perNodeRssiHistory || {}) };
+ }
+
/** Current connection state. */
get state() {
return this._state;
@@ -327,6 +332,20 @@ class SensingService {
}
}
+ // Per-node RSSI tracking
+ if (!this._perNodeRssiHistory) this._perNodeRssiHistory = {};
+ if (data.node_features) {
+ for (const nf of data.node_features) {
+ if (!this._perNodeRssiHistory[nf.node_id]) {
+ this._perNodeRssiHistory[nf.node_id] = [];
+ }
+ this._perNodeRssiHistory[nf.node_id].push(nf.rssi_dbm);
+ if (this._perNodeRssiHistory[nf.node_id].length > this._maxHistory) {
+ this._perNodeRssiHistory[nf.node_id].shift();
+ }
+ }
+ }
+
// Notify all listeners
for (const cb of this._listeners) {
try {
diff --git a/wifi_densepose/__init__.py b/wifi_densepose/__init__.py
new file mode 100644
index 000000000..83d6e204f
--- /dev/null
+++ b/wifi_densepose/__init__.py
@@ -0,0 +1,137 @@
+"""
+WiFi-DensePose — WiFi-based human pose estimation using CSI data.
+
+Usage:
+ from wifi_densepose import WiFiDensePose
+
+ system = WiFiDensePose()
+ system.start()
+ poses = system.get_latest_poses()
+ system.stop()
+"""
+
+__version__ = "1.2.0"
+
+import sys
+import os
+import logging
+
+logger = logging.getLogger(__name__)
+
+# Allow importing the v1 src package when installed from the repo
+_v1_src = os.path.join(os.path.dirname(os.path.dirname(__file__)), "v1")
+if os.path.isdir(_v1_src) and _v1_src not in sys.path:
+ sys.path.insert(0, _v1_src)
+
+
+class WiFiDensePose:
+ """High-level facade for the WiFi-DensePose sensing system.
+
+ This is the primary entry point documented in the README Quick Start.
+ It wraps the underlying ServiceOrchestrator and exposes a simple
+ start / get_latest_poses / stop interface.
+ """
+
+ def __init__(self, host: str = "0.0.0.0", port: int = 3000, **kwargs):
+ self.host = host
+ self.port = port
+ self._config = kwargs
+ self._orchestrator = None
+ self._server_task = None
+ self._poses = []
+ self._running = False
+
+ # ------------------------------------------------------------------
+ # Public API (matches README Quick Start)
+ # ------------------------------------------------------------------
+
+ def start(self):
+ """Start the sensing system (blocking until ready)."""
+ import asyncio
+
+ loop = _get_or_create_event_loop()
+ loop.run_until_complete(self._async_start())
+
+ async def _async_start(self):
+ try:
+ from src.config.settings import get_settings
+ from src.services.orchestrator import ServiceOrchestrator
+
+ settings = get_settings()
+ self._orchestrator = ServiceOrchestrator(settings)
+ await self._orchestrator.initialize()
+ await self._orchestrator.start()
+ self._running = True
+ logger.info("WiFiDensePose system started on %s:%s", self.host, self.port)
+ except ImportError:
+ raise ImportError(
+ "Core dependencies not found. Make sure you installed "
+ "from the repository root:\n"
+ " cd wifi-densepose && pip install -e .\n"
+ "Or install the v1 package:\n"
+ " cd wifi-densepose/v1 && pip install -e ."
+ )
+
+ def stop(self):
+ """Stop the sensing system."""
+ import asyncio
+
+ if self._orchestrator is not None:
+ loop = _get_or_create_event_loop()
+ loop.run_until_complete(self._orchestrator.shutdown())
+ self._running = False
+ logger.info("WiFiDensePose system stopped")
+
+ def get_latest_poses(self):
+ """Return the most recent list of detected pose dicts."""
+ if self._orchestrator is None:
+ return []
+ try:
+ import asyncio
+
+ loop = _get_or_create_event_loop()
+ return loop.run_until_complete(self._fetch_poses())
+ except Exception:
+ return []
+
+ async def _fetch_poses(self):
+ try:
+ pose_svc = self._orchestrator.pose_service
+ if pose_svc and hasattr(pose_svc, "get_latest"):
+ return await pose_svc.get_latest()
+ except Exception:
+ pass
+ return []
+
+ # ------------------------------------------------------------------
+ # Context-manager support
+ # ------------------------------------------------------------------
+
+ def __enter__(self):
+ self.start()
+ return self
+
+ def __exit__(self, *exc):
+ self.stop()
+
+ # ------------------------------------------------------------------
+ # Convenience re-exports
+ # ------------------------------------------------------------------
+
+ @staticmethod
+ def version():
+ return __version__
+
+
+def _get_or_create_event_loop():
+ import asyncio
+
+ try:
+ return asyncio.get_event_loop()
+ except RuntimeError:
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ return loop
+
+
+__all__ = ["WiFiDensePose", "__version__"]