import { useEffect, useRef, useCallback } from 'react'; import { useSentinelStore } from '@/store/sentinelStore'; import { useAuthStore } from '@/store/authStore'; /** * useSentinelWebSocket * Connects to the Sentinel WebSocket endpoint on core-api. * Translates raw CCTV events into store actions. * The broker-facing UI NEVER sees: WebSocket status, connection state, * raw event types, MediaPipe processing info, or frame IDs. * * Events handled: * visitor_detected → setPendingAlert (triggers SentinelAlertBanner) * session_start → setShowroomActive(true) * qd_update → update session QD score * session_end → setShowroomActive(false) * ai_observation → update session AI observation text */ interface RawSentinelEvent { type: 'visitor_detected' | 'session_start' | 'qd_update' | 'session_end' | 'ai_observation'; session_id?: string; qd_score?: number; qd_trend?: number; zone?: string; matched_person_id?: string; matched_name?: string; face_confidence?: number; ai_observation?: string; peak_qd?: number; } interface LiveSession { sessionId: string; qdScore: number; qdTrend: number; currentZone?: string; aiObservation?: string; peakQd?: number; } export function useSentinelWebSocket() { const wsRef = useRef(null); const sessionRef = useRef(null); const { token } = useAuthStore.getState(); const { setPendingAlert, setShowroomActive, setSessionDuration, setHasInsights, } = useSentinelStore(); const connect = useCallback(() => { // wss:// on velocity.local, proxied by Traefik → core-api const wsUrl = `wss://${window.location.host}/ws/sentinel?token=${token}`; const ws = new WebSocket(wsUrl); wsRef.current = ws; ws.onmessage = (evt) => { let event: RawSentinelEvent; try { event = JSON.parse(evt.data); } catch { return; } switch (event.type) { case 'visitor_detected': setPendingAlert({ id: String(Date.now()), matchedName: event.matched_name, matchedPersonId:event.matched_person_id, confidence: event.face_confidence, zone: event.zone ?? 'Entrance', timestamp: new Date(), }); break; case 'session_start': sessionRef.current = { sessionId: event.session_id!, qdScore: 0, qdTrend: 0, }; setShowroomActive(true, event.session_id); break; case 'qd_update': if (sessionRef.current) { sessionRef.current = { ...sessionRef.current, qdScore: event.qd_score ?? 0, qdTrend: event.qd_trend ?? 0, currentZone: event.zone, peakQd: Math.max(sessionRef.current.peakQd ?? 0, event.qd_score ?? 0), }; } break; case 'ai_observation': if (sessionRef.current) { sessionRef.current = { ...sessionRef.current, aiObservation: event.ai_observation, }; setHasInsights(true); } break; case 'session_end': setShowroomActive(false); break; } }; ws.onerror = () => { /* silent — broker never sees connection errors */ }; ws.onclose = (e) => { if (e.code !== 1000) { // Reconnect after 3s on unexpected disconnect setTimeout(connect, 3000); } }; }, [token, setPendingAlert, setShowroomActive, setHasInsights]); useEffect(() => { connect(); return () => { wsRef.current?.close(1000, 'component unmount'); }; }, [connect]); return { session: sessionRef.current }; }