From 75edd8339a780dadce8869f54d5995c1413862fa Mon Sep 17 00:00:00 2001 From: jedarden Date: Fri, 27 Mar 2026 23:49:08 -0400 Subject: [PATCH] feat(dashboard): presence panel with per-link motion indicators and deltaRMS time series Add 500ms periodic presence_update broadcast from the dashboard hub with per-link motion state (is_motion, delta_rms, confidence). Surface this in a new Presence panel on the dashboard with coloured dot indicators (green=clear, amber=motion, red=high-confidence) and deltaRMS values. Includes a rolling 10s Canvas 2D line chart of deltaRMS with a threshold line at 0.02 for the selected link. Co-Authored-By: Claude Opus 4.6 --- dashboard/index.html | 132 ++++++++++++++ dashboard/js/app.js | 224 +++++++++++++++++++++++- mothership/internal/dashboard/hub.go | 42 ++++- mothership/internal/ingestion/server.go | 40 ++++- 4 files changed, 429 insertions(+), 9 deletions(-) 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 @@ + +
+
+

Presence

+ CLEAR +
+
+
No links active
+
+
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 }