129 lines
3.7 KiB
TypeScript
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 };
|
|
}
|