diff --git a/dashboard/index.html b/dashboard/index.html
index 9d4f6b0..dab847d 100644
--- a/dashboard/index.html
+++ b/dashboard/index.html
@@ -269,6 +269,125 @@
transition: background 0.2s;
}
#floorplan-btn:hover { background: rgba(255,255,255,0.12); color: #ccc; }
+
+ /* Presence panel */
+ #presence-panel {
+ position: fixed;
+ top: 60px;
+ right: 20px;
+ width: 300px;
+ background: rgba(0, 0, 0, 0.8);
+ border-radius: 8px;
+ padding: 12px;
+ z-index: 100;
+ }
+
+ #presence-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 10px;
+ }
+
+ #presence-header h3 {
+ font-size: 14px;
+ color: #888;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ margin: 0;
+ }
+
+ #presence-status {
+ font-size: 11px;
+ font-weight: 600;
+ padding: 2px 8px;
+ border-radius: 4px;
+ transition: background 0.3s, color 0.3s;
+ }
+
+ #presence-status.motion {
+ background: rgba(244, 67, 54, 0.3);
+ color: #ef5350;
+ }
+
+ #presence-status.clear {
+ background: rgba(76, 175, 80, 0.15);
+ color: #66bb6a;
+ }
+
+ #presence-list {
+ max-height: 200px;
+ overflow-y: auto;
+ }
+
+ .presence-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 5px 8px;
+ margin-bottom: 3px;
+ background: rgba(255, 255, 255, 0.03);
+ border-radius: 4px;
+ font-size: 12px;
+ cursor: pointer;
+ transition: background 0.2s;
+ }
+
+ .presence-row:hover {
+ background: rgba(255, 255, 255, 0.08);
+ }
+
+ .presence-row.selected {
+ background: rgba(79, 195, 247, 0.2);
+ }
+
+ .presence-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ transition: background 0.3s;
+ }
+
+ .presence-dot.clear {
+ background: #66bb6a;
+ box-shadow: 0 0 6px rgba(102, 187, 106, 0.5);
+ }
+
+ .presence-dot.motion {
+ background: #ffa726;
+ box-shadow: 0 0 6px rgba(255, 167, 38, 0.5);
+ }
+
+ .presence-dot.high-confidence {
+ background: #ef5350;
+ box-shadow: 0 0 6px rgba(239, 83, 80, 0.5);
+ }
+
+ .presence-link-id {
+ font-family: monospace;
+ color: #aaa;
+ flex: 1;
+ }
+
+ .presence-rms {
+ font-family: monospace;
+ color: #888;
+ font-size: 11px;
+ }
+
+ #deltarms-label {
+ font-size: 10px;
+ color: #555;
+ margin-top: 8px;
+ margin-bottom: 4px;
+ }
+
+ #deltarms-chart {
+ width: 100%;
+ height: 80px;
+ display: block;
+ }
@@ -326,6 +445,19 @@
+
+
+
+
+
Delta RMS (10 s)
+
+
+
diff --git a/dashboard/js/app.js b/dashboard/js/app.js
index 3d2f1ad..631366b 100644
--- a/dashboard/js/app.js
+++ b/dashboard/js/app.js
@@ -23,7 +23,10 @@
chartBars: 64, // number of subcarriers
chartUpdateMs: 100, // update chart at 10 Hz max
tsMaxPoints: 360, // max time series samples per link (~60s at 6Hz)
- tsMinIntervalMs: 100 // min ms between time series samples
+ tsMinIntervalMs: 100, // min ms between time series samples
+ drTsWindowMs: 10000, // deltaRMS time series window: 10 seconds
+ drTsMaxPoints: 100, // max deltaRMS samples per link
+ drThreshold: 0.02 // DefaultDeltaRMSThreshold
};
// ============================================
@@ -35,6 +38,8 @@
nodes: new Map(), // MAC -> { mac, firmware, chip, lastSeen }
links: new Map(), // linkID -> { nodeMAC, peerMAC, lastFrame, lastCSI, motionDetected, deltaRMS, ampHistory, lastAmpSample }
selectedLinkID: null,
+ presenceSelectedLinkID: null,
+ drHistory: new Map(), // linkID -> [{ t: number, rms: number }]
lastChartUpdate: 0,
frameCount: 0,
lastFpsTime: performance.now()
@@ -299,6 +304,10 @@
}
break;
+ case 'presence_update':
+ handlePresenceUpdate(msg);
+ break;
+
case 'registry_state':
Viz3D.handleRegistryState(msg);
break;
@@ -484,6 +493,116 @@
}
}
+ // ============================================
+ // Presence Panel
+ // ============================================
+ function handlePresenceUpdate(msg) {
+ if (!msg.links) return;
+
+ const now = performance.now();
+ let anyMotion = false;
+
+ for (const [linkID, info] of Object.entries(msg.links)) {
+ // Update link state if link exists
+ const link = state.links.get(linkID);
+ if (link) {
+ link.motionDetected = info.is_motion || info.motion_detected || false;
+ link.deltaRMS = info.delta_rms || 0;
+ }
+
+ if (info.is_motion || info.motion_detected) anyMotion = true;
+
+ // Append to deltaRMS history
+ let history = state.drHistory.get(linkID);
+ if (!history) {
+ history = [];
+ state.drHistory.set(linkID, history);
+ }
+ history.push({ t: now, rms: info.delta_rms || 0 });
+
+ // Trim to window
+ const cutoff = now - CONFIG.drTsWindowMs;
+ while (history.length > 0 && history[0].t < cutoff) {
+ history.shift();
+ }
+ if (history.length > CONFIG.drTsMaxPoints) {
+ history.splice(0, history.length - CONFIG.drTsMaxPoints);
+ }
+ }
+
+ updatePresencePanel(msg.links, anyMotion);
+ updateLinkList();
+ drawDeltaRMSTimeSeries();
+ }
+
+ function updatePresencePanel(links, anyMotion) {
+ const container = document.getElementById('presence-list');
+ const statusEl = document.getElementById('presence-status');
+
+ if (anyMotion) {
+ statusEl.className = 'motion';
+ statusEl.textContent = 'MOTION';
+ } else {
+ statusEl.className = 'clear';
+ statusEl.textContent = 'CLEAR';
+ }
+
+ const entries = Object.entries(links);
+ if (entries.length === 0) {
+ container.innerHTML = 'No links active
';
+ return;
+ }
+
+ let html = '';
+ for (const [linkID, info] of entries) {
+ const isMotion = info.is_motion || info.motion_detected || false;
+ const confidence = info.confidence || 0;
+ const rms = info.delta_rms || 0;
+ const shortID = abbreviateLinkID(linkID);
+ const selected = state.presenceSelectedLinkID === linkID ? 'selected' : '';
+
+ let dotClass = 'clear';
+ if (isMotion && confidence > 0.7) {
+ dotClass = 'high-confidence';
+ } else if (isMotion) {
+ dotClass = 'motion';
+ }
+
+ html += `
+
+
+ ${shortID}
+ ${rms.toFixed(4)}
+
+ `;
+ }
+ container.innerHTML = html;
+
+ container.querySelectorAll('.presence-row').forEach(el => {
+ el.addEventListener('click', () => {
+ state.presenceSelectedLinkID = el.dataset.linkId;
+ updatePresencePanel(links, anyMotion);
+ });
+ });
+ }
+
+ function abbreviateLinkID(linkID) {
+ const parts = linkID.split(':');
+ if (parts.length >= 12) {
+ // Full MAC:MAC format: AA:BB:CC:DD:EE:FF:AA:BB:CC:DD:EE:FF
+ const nodeShort = parts.slice(3, 6).join(':');
+ const peerShort = parts.slice(9, 12).join(':');
+ return nodeShort + '\u2192' + peerShort;
+ }
+ // Fallback: last segment of each side
+ const halves = linkID.split(':').reduce((acc, p, i, arr) => {
+ if (i < 6) acc[0].push(p);
+ else acc[1].push(p);
+ return acc;
+ }, [[], []]);
+ return halves[0].slice(-2).join(':') + '\u2192' + halves[1].slice(-2).join(':');
+ }
+
function updateLinkList() {
const container = document.getElementById('link-list');
document.getElementById('link-count').textContent = state.links.size;
@@ -536,6 +655,7 @@
// ============================================
let chartCanvas, chartCtx;
let tsCanvas, tsCtx;
+ let drCanvas, drCtx;
function initChart() {
chartCanvas = document.getElementById('amplitude-chart');
@@ -557,6 +677,16 @@
tsCtx.scale(window.devicePixelRatio, window.devicePixelRatio);
drawTimeSeries([]);
+
+ drCanvas = document.getElementById('deltarms-chart');
+ drCtx = drCanvas.getContext('2d');
+
+ const drRect = drCanvas.getBoundingClientRect();
+ drCanvas.width = drRect.width * window.devicePixelRatio;
+ drCanvas.height = drRect.height * window.devicePixelRatio;
+ drCtx.scale(window.devicePixelRatio, window.devicePixelRatio);
+
+ drawDeltaRMSTimeSeries();
}
function drawEmptyChart() {
@@ -677,11 +807,101 @@
tsCtx.fillStyle = '#555';
tsCtx.font = '9px monospace';
tsCtx.textAlign = 'left';
- tsCtx.fillText(`−${spanS}s`, 2, height - 2);
+ tsCtx.fillText(`-${spanS}s`, 2, height - 2);
tsCtx.textAlign = 'right';
tsCtx.fillText('now', width - 2, height - 2);
}
+ function drawDeltaRMSTimeSeries() {
+ if (!drCanvas) return;
+ const width = drCanvas.width / window.devicePixelRatio;
+ const height = drCanvas.height / window.devicePixelRatio;
+
+ drCtx.fillStyle = '#1a1a2e';
+ drCtx.fillRect(0, 0, width, height);
+
+ const linkID = state.presenceSelectedLinkID;
+ const history = linkID ? state.drHistory.get(linkID) : null;
+
+ if (!history || history.length < 2) {
+ drCtx.fillStyle = '#444';
+ drCtx.font = '10px sans-serif';
+ drCtx.textAlign = 'center';
+ drCtx.fillText('Select a link', width / 2, height / 2 + 4);
+ return;
+ }
+
+ const padTop = 4;
+ const padBottom = 14;
+ const plotH = height - padTop - padBottom;
+
+ // Y-axis range: 0 to 0.1 (typical deltaRMS range)
+ const yMax = 0.1;
+
+ // Draw threshold line at 0.02
+ const threshY = padTop + plotH - (CONFIG.drThreshold / yMax) * plotH;
+ drCtx.strokeStyle = 'rgba(255, 167, 38, 0.5)';
+ drCtx.lineWidth = 1;
+ drCtx.setLineDash([4, 3]);
+ drCtx.beginPath();
+ drCtx.moveTo(0, threshY);
+ drCtx.lineTo(width, threshY);
+ drCtx.stroke();
+ drCtx.setLineDash([]);
+
+ // Threshold label
+ drCtx.fillStyle = 'rgba(255, 167, 38, 0.7)';
+ drCtx.font = '9px monospace';
+ drCtx.textAlign = 'left';
+ drCtx.fillText('0.02', 2, threshY - 2);
+
+ // Time range
+ const tEnd = history[history.length - 1].t;
+ const tStart = tEnd - CONFIG.drTsWindowMs;
+
+ // Draw deltaRMS line
+ drCtx.lineWidth = 1.5;
+ drCtx.strokeStyle = 'rgba(79, 195, 247, 0.8)';
+ drCtx.beginPath();
+ let started = false;
+
+ for (let i = 0; i < history.length; i++) {
+ const x = ((history[i].t - tStart) / CONFIG.drTsWindowMs) * width;
+ const y = padTop + plotH - (Math.min(history[i].rms, yMax) / yMax) * plotH;
+
+ if (!started) {
+ drCtx.moveTo(x, y);
+ started = true;
+ } else {
+ drCtx.lineTo(x, y);
+ }
+ }
+ drCtx.stroke();
+
+ // Fill area under curve
+ if (history.length >= 2) {
+ const lastX = ((history[history.length - 1].t - tStart) / CONFIG.drTsWindowMs) * width;
+ const firstX = ((history[0].t - tStart) / CONFIG.drTsWindowMs) * width;
+ drCtx.lineTo(lastX, padTop + plotH);
+ drCtx.lineTo(firstX, padTop + plotH);
+ drCtx.closePath();
+ drCtx.fillStyle = 'rgba(79, 195, 247, 0.08)';
+ drCtx.fill();
+ }
+
+ // Time labels
+ drCtx.fillStyle = '#555';
+ drCtx.font = '9px monospace';
+ drCtx.textAlign = 'left';
+ drCtx.fillText('-10s', 2, height - 2);
+ drCtx.textAlign = 'right';
+ drCtx.fillText('now', width - 2, height - 2);
+
+ // Y-axis labels
+ drCtx.textAlign = 'right';
+ drCtx.fillText('0.1', width - 2, padTop + 10);
+ }
+
// ============================================
// Initialization
// ============================================
diff --git a/mothership/internal/dashboard/hub.go b/mothership/internal/dashboard/hub.go
index 6600a57..592fe9b 100644
--- a/mothership/internal/dashboard/hub.go
+++ b/mothership/internal/dashboard/hub.go
@@ -57,8 +57,11 @@ func (h *Hub) SetIngestionState(state IngestionState) {
// Run starts the hub's main loop
func (h *Hub) Run() {
- ticker := time.NewTicker(5 * time.Second)
- defer ticker.Stop()
+ stateTicker := time.NewTicker(5 * time.Second)
+ defer stateTicker.Stop()
+
+ presenceTicker := time.NewTicker(500 * time.Millisecond)
+ defer presenceTicker.Stop()
for {
select {
@@ -89,8 +92,11 @@ func (h *Hub) Run() {
}
h.mu.RUnlock()
- case <-ticker.C:
+ case <-stateTicker.C:
h.broadcastState()
+
+ case <-presenceTicker.C:
+ h.broadcastPresence()
}
}
}
@@ -174,6 +180,36 @@ func (h *Hub) BroadcastMotionState(states []ingestion.MotionStateItem) {
h.Broadcast(data)
}
+// BroadcastPresenceUpdate sends periodic presence state for all links.
+// Broadcasts every 500ms with {type: "presence_update", links: {linkID: {...}}}.
+func (h *Hub) broadcastPresence() {
+ h.mu.RLock()
+ state := h.ingestionState
+ clientCount := len(h.clients)
+ h.mu.RUnlock()
+
+ if state == nil || clientCount == 0 {
+ return
+ }
+
+ items := state.GetAllMotionStates()
+ if len(items) == 0 {
+ return
+ }
+
+ links := make(map[string]ingestion.MotionStateItem, len(items))
+ for _, item := range items {
+ links[item.LinkID] = item
+ }
+
+ msg := map[string]interface{}{
+ "type": "presence_update",
+ "links": links,
+ }
+ data, _ := json.Marshal(msg)
+ h.Broadcast(data)
+}
+
// ─── Phase 3 Broadcasts ─────────────────────────────────────────────────────
// nodeJSON is the wire format for a fleet node sent to the dashboard.
diff --git a/mothership/internal/ingestion/server.go b/mothership/internal/ingestion/server.go
index df86c63..f6dccb6 100644
--- a/mothership/internal/ingestion/server.go
+++ b/mothership/internal/ingestion/server.go
@@ -36,6 +36,7 @@ type MotionStateItem struct {
LinkID string `json:"link_id"`
MotionDetected bool `json:"motion_detected"`
DeltaRMS float64 `json:"delta_rms"`
+ Confidence float64 `json:"confidence"`
}
// ReplayAppender appends raw CSI frames to a persistent store.
@@ -155,6 +156,18 @@ func (s *Server) SetRateController(rc *RateController) {
s.mu.Unlock()
}
+// SetFleetNotifier sets the fleet manager for node lifecycle callbacks.
+func (s *Server) SetFleetNotifier(fn FleetNotifier) {
+ s.mu.Lock()
+ s.fleetNotifier = fn
+ s.mu.Unlock()
+}
+
+// GetConnectedMACs returns the MACs of currently-connected nodes.
+func (s *Server) GetConnectedMACs() []string {
+ return s.GetConnectedNodes()
+}
+
// SendConfigToMAC sends a rate config command to a connected node by MAC.
// varianceThreshold > 0 enables on-device amplitude variance monitoring.
func (s *Server) SendConfigToMAC(mac string, rateHz int, varianceThreshold float64) {
@@ -242,6 +255,7 @@ func (s *Server) HandleNodeWS(w http.ResponseWriter, r *http.Request) {
s.connections[hello.MAC] = nc
s.malformedCounts[hello.MAC] = &malformedCounter{}
broadcaster := s.dashboardBroadcaster
+ fleetFn := s.fleetNotifier
s.mu.Unlock()
log.Printf("[INFO] Node connected: MAC=%s firmware=%s chip=%s",
@@ -251,8 +265,12 @@ func (s *Server) HandleNodeWS(w http.ResponseWriter, r *http.Request) {
broadcaster.BroadcastNodeConnected(hello.MAC, hello.FirmwareVersion, hello.Chip)
}
- s.sendRole(nc, "rx", "")
- s.sendConfig(nc, RateIdle, 0, DefaultVarianceThreshold)
+ if fleetFn != nil {
+ fleetFn.OnNodeConnected(hello.MAC, hello.FirmwareVersion, hello.Chip)
+ } else {
+ s.sendRole(nc, "rx", "")
+ s.sendConfig(nc, RateIdle, 0, DefaultVarianceThreshold)
+ }
go s.pingLoop(nc)
s.handleMessages(nc)
@@ -267,6 +285,7 @@ func (s *Server) handleMessages(nc *NodeConnection) {
delete(s.malformedCounts, nc.MAC)
broadcaster := s.dashboardBroadcaster
rateCtrl := s.rateCtrl
+ fleetFn := s.fleetNotifier
s.mu.Unlock()
log.Printf("[INFO] Node disconnected: MAC=%s", nc.MAC)
@@ -277,6 +296,9 @@ func (s *Server) handleMessages(nc *NodeConnection) {
if rateCtrl != nil {
rateCtrl.OnNodeDisconnected(nc.MAC)
}
+ if fleetFn != nil {
+ fleetFn.OnNodeDisconnected(nc.MAC)
+ }
}()
for {
@@ -580,16 +602,26 @@ func (s *Server) GetAllLinksInfo() []LinkInfo {
// GetAllMotionStates returns current motion state for all known links.
func (s *Server) GetAllMotionStates() []MotionStateItem {
+ s.mu.RLock()
+ pm := s.processorMgr
+ s.mu.RUnlock()
+
s.mu.RLock()
defer s.mu.RUnlock()
states := make([]MotionStateItem, 0, len(s.linkMotionState))
for linkID, detected := range s.linkMotionState {
- states = append(states, MotionStateItem{
+ item := MotionStateItem{
LinkID: linkID,
MotionDetected: detected,
DeltaRMS: s.linkDeltaRMS[linkID],
- })
+ }
+ if pm != nil {
+ if proc := pm.GetProcessor(linkID); proc != nil {
+ item.Confidence = proc.GetBaseline().GetConfidence()
+ }
+ }
+ states = append(states, item)
}
return states
}