Files
Velocity-OS/webos/src/shared/hooks/useSentinelWebSocket.ts

129 lines
3.7 KiB
TypeScript

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<WebSocket | null>(null);
const sessionRef = useRef<LiveSession | null>(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 };
}