import React, { useEffect, useState, useRef, useCallback } from "react"; import { useServer } from "../hooks/useServer"; import type { SensingUpdate, DataSource } from "../types"; // --------------------------------------------------------------------------- // Log entry model // --------------------------------------------------------------------------- type LogLevel = "INFO" | "WARN" | "ERROR"; interface LogEntry { id: number; timestamp: string; // HH:MM:SS.mmm level: LogLevel; source: string; message: string; } // --------------------------------------------------------------------------- // WebSocket message types from sensing server // --------------------------------------------------------------------------- interface WsNodeInfo { node_id: number; rssi_dbm: number; position: [number, number, number]; amplitude: number[]; subcarrier_count: number; } interface WsClassification { motion_level: string; presence: boolean; confidence: number; } interface WsFeatures { mean_rssi: number; variance: number; motion_band_power: number; breathing_band_power: number; dominant_freq_hz: number; change_points: number; spectral_power: number; } interface WsVitalSigns { breathing_rate_hz?: number; heart_rate_bpm?: number; confidence?: number; } interface WsSensingUpdate { type: string; timestamp: number; source: string; tick: number; nodes: WsNodeInfo[]; features: WsFeatures; classification: WsClassification; vital_signs?: WsVitalSigns; posture?: string; signal_quality_score?: number; quality_verdict?: string; bssid_count?: number; estimated_persons?: number; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function formatTimestamp(d: Date): string { const hh = String(d.getHours()).padStart(2, "0"); const mm = String(d.getMinutes()).padStart(2, "0"); const ss = String(d.getSeconds()).padStart(2, "0"); const ms = String(d.getMilliseconds()).padStart(3, "0"); return `${hh}:${mm}:${ss}.${ms}`; } let nextLogId = 1; function createLogFromWsUpdate(update: WsSensingUpdate): LogEntry[] { const entries: LogEntry[] = []; const ts = formatTimestamp(new Date(update.timestamp * 1000)); // Log each node's CSI data for (const node of update.nodes) { entries.push({ id: nextLogId++, timestamp: ts, level: "INFO", source: "csi_receiver", message: `Node ${node.node_id}: RSSI ${node.rssi_dbm.toFixed(1)} dBm, ${node.subcarrier_count} subcarriers`, }); } // Log classification if (update.classification) { const level: LogLevel = update.classification.confidence < 0.5 ? "WARN" : "INFO"; entries.push({ id: nextLogId++, timestamp: ts, level, source: "classifier", message: `Motion: ${update.classification.motion_level} (presence=${update.classification.presence}, conf=${(update.classification.confidence * 100).toFixed(0)}%)`, }); } // Log vital signs if present if (update.vital_signs) { const vs = update.vital_signs; const level: LogLevel = (vs.confidence ?? 0) < 0.5 ? "WARN" : "INFO"; entries.push({ id: nextLogId++, timestamp: ts, level, source: "vital_signs", message: `Breathing: ${vs.breathing_rate_hz?.toFixed(2) ?? "--"} Hz, HR: ${vs.heart_rate_bpm?.toFixed(0) ?? "--"} bpm`, }); } // Log quality verdict if present if (update.quality_verdict && update.quality_verdict !== "Permit") { entries.push({ id: nextLogId++, timestamp: ts, level: update.quality_verdict === "Deny" ? "ERROR" : "WARN", source: "quality_gate", message: `Signal quality: ${update.quality_verdict} (score=${(update.signal_quality_score ?? 0).toFixed(2)})`, }); } return entries; } function createActivityFromWsUpdate(update: WsSensingUpdate): SensingUpdate | null { if (!update.classification) return null; const node = update.nodes[0]; return { timestamp: new Date(update.timestamp * 1000).toISOString(), node_id: node?.node_id ?? 1, subcarrier_count: node?.subcarrier_count ?? 52, rssi: node?.rssi_dbm ?? -50, activity: update.posture ?? update.classification.motion_level, confidence: update.classification.confidence, }; } // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const MAX_LOG_ENTRIES = 200; const WS_RECONNECT_DELAY_MS = 3000; // --------------------------------------------------------------------------- // LogViewer component (ADR-053) // --------------------------------------------------------------------------- const LEVEL_COLOR: Record = { INFO: "var(--text-secondary)", WARN: "var(--status-warning)", ERROR: "var(--status-error)", }; function LogViewer({ entries, onClear, paused, onTogglePause, }: { entries: LogEntry[]; onClear: () => void; paused: boolean; onTogglePause: () => void; }) { const containerRef = useRef(null); useEffect(() => { // Scroll to bottom within the container only (not the page) if (!paused && containerRef.current) { containerRef.current.scrollTop = containerRef.current.scrollHeight; } }, [entries, paused]); return (
{/* Header bar */}
Server Log
{/* Log entries */}
{entries.length === 0 ? (
No log entries yet.
) : ( entries.map((entry) => (
{entry.timestamp}{" "} {entry.level} {" "} {entry.source}{" "} {entry.message}
)) )}
); } // --------------------------------------------------------------------------- // Sensing page // --------------------------------------------------------------------------- export const Sensing: React.FC = () => { const { status, isRunning, error, start, stop } = useServer({ pollInterval: 5000 }); const [starting, setStarting] = useState(false); const [stopping, setStopping] = useState(false); // Data source selection const [dataSource, setDataSource] = useState("simulate"); // Log viewer state const [logEntries, setLogEntries] = useState([]); const [paused, setPaused] = useState(false); const pausedRef = useRef(paused); pausedRef.current = paused; // Activity feed state const [activities, setActivities] = useState([]); // WebSocket connection state const [wsConnected, setWsConnected] = useState(false); const wsRef = useRef(null); const reconnectTimeoutRef = useRef(null); // Connect to real WebSocket when server is running useEffect(() => { if (!isRunning || !status?.ws_port) { // Server not running, disconnect if connected if (wsRef.current) { wsRef.current.close(); wsRef.current = null; setWsConnected(false); } return; } const connect = () => { const wsUrl = `ws://127.0.0.1:${status.ws_port}/ws/sensing`; const ws = new WebSocket(wsUrl); ws.onopen = () => { setWsConnected(true); setLogEntries((prev) => [ ...prev, { id: nextLogId++, timestamp: formatTimestamp(new Date()), level: "INFO", source: "desktop", message: `WebSocket connected to ${wsUrl}`, }, ]); }; ws.onmessage = (event) => { if (pausedRef.current) return; try { const update = JSON.parse(event.data) as WsSensingUpdate; // Create log entries from the update const entries = createLogFromWsUpdate(update); if (entries.length > 0) { setLogEntries((prev) => { const next = [...prev, ...entries]; return next.length > MAX_LOG_ENTRIES ? next.slice(next.length - MAX_LOG_ENTRIES) : next; }); } // Create activity update const activity = createActivityFromWsUpdate(update); if (activity) { setActivities((prev) => { const next = [activity, ...prev]; return next.slice(0, 5); }); } } catch (err) { console.error("Failed to parse WebSocket message:", err); } }; ws.onclose = () => { setWsConnected(false); wsRef.current = null; // Only add disconnect log if server is still supposed to be running if (isRunning) { setLogEntries((prev) => [ ...prev, { id: nextLogId++, timestamp: formatTimestamp(new Date()), level: "WARN", source: "desktop", message: "WebSocket disconnected, reconnecting...", }, ]); // Attempt reconnect reconnectTimeoutRef.current = window.setTimeout(connect, WS_RECONNECT_DELAY_MS); } }; ws.onerror = () => { setLogEntries((prev) => [ ...prev, { id: nextLogId++, timestamp: formatTimestamp(new Date()), level: "ERROR", source: "desktop", message: "WebSocket connection error", }, ]); }; wsRef.current = ws; }; connect(); return () => { if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); } if (wsRef.current) { wsRef.current.close(); wsRef.current = null; } }; }, [isRunning, status?.ws_port]); const handleClearLog = useCallback(() => setLogEntries([]), []); const handleTogglePause = useCallback(() => setPaused((p) => !p), []); const handleStart = async () => { setStarting(true); try { await start({ source: dataSource }); } finally { setStarting(false); } }; const handleStop = async () => { setStopping(true); try { await stop(); } finally { setStopping(false); } }; return (
{/* Page header */}

Sensing

{/* ----------------------------------------------------------------- */} {/* Section 1: Server Control */} {/* ----------------------------------------------------------------- */}
{/* Left: status info */}
{/* Status dot */}
Sensing Server
{isRunning ? "Running" : "Stopped"}
{/* Running details */} {isRunning && status && (
{status.pid != null && PID {status.pid}} {status.http_port != null && HTTP :{status.http_port}} {status.ws_port != null && WS :{status.ws_port}} {wsConnected ? "Live" : "Connecting..."}
)}
{/* Right: data source + action button */}
{/* Data source selector */}
{/* Action button */}
{/* Error display */} {error && (
{error}
)}
{/* ----------------------------------------------------------------- */} {/* Section 2: Log Viewer (ADR-053) */} {/* ----------------------------------------------------------------- */}
{/* ----------------------------------------------------------------- */} {/* Section 3: Activity Feed */} {/* ----------------------------------------------------------------- */}

Activity Feed

{activities.length === 0 ? (
Waiting for sensing data...
) : (
{activities.map((update, i) => { const ts = new Date(update.timestamp); const conf = update.confidence ?? 0; return (
{/* Timestamp */} {formatTimestamp(ts)} {/* Node ID */} Node {update.node_id} {/* Activity */} {update.activity ?? "unknown"} {/* Confidence bar */}
= 0.8 ? "var(--status-online)" : conf >= 0.6 ? "var(--status-warning)" : "var(--status-error)", borderRadius: 3, transition: "width 0.3s ease", }} />
{/* Confidence value */} {Math.round(conf * 100)}%
); })}
)}
); }; export default Sensing;