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 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-03-27 23:49:08 -04:00
parent bcd19ad756
commit 75edd8339a
4 changed files with 429 additions and 9 deletions

View file

@ -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;
}
</style>
</head>
<body>
@ -326,6 +445,19 @@
<canvas id="timeseries-chart"></canvas>
</div>
<!-- Presence panel -->
<div id="presence-panel">
<div id="presence-header">
<h3>Presence</h3>
<span id="presence-status" class="clear">CLEAR</span>
</div>
<div id="presence-list">
<div class="empty-state">No links active</div>
</div>
<div id="deltarms-label">Delta RMS (10 s)</div>
<canvas id="deltarms-chart"></canvas>
</div>
<!-- Three.js from CDN -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<!-- OrbitControls from CDN -->

View file

@ -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 = '<div class="empty-state">No links active</div>';
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 += `
<div class="presence-row ${selected}" data-link-id="${linkID}">
<span class="presence-dot ${dotClass}"></span>
<span class="presence-link-id">${shortID}</span>
<span class="presence-rms">${rms.toFixed(4)}</span>
</div>
`;
}
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
// ============================================

View file

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

View file

@ -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
}