import { SIMULATION_TICK_INTERVAL_MS } from '@/constants/simulation'; import { MAX_RECONNECT_ATTEMPTS, RECONNECT_DELAYS, WS_PATH } from '@/constants/websocket'; import { usePoseStore } from '@/stores/poseStore'; import { generateSimulatedData } from '@/services/simulation.service'; import type { ConnectionStatus, SensingFrame } from '@/types/sensing'; type FrameListener = (frame: SensingFrame) => void; class WsService { private ws: WebSocket | null = null; private listeners = new Set(); private reconnectAttempt = 0; private reconnectTimer: ReturnType | null = null; private simulationTimer: ReturnType | null = null; private targetUrl = ''; private active = false; private status: ConnectionStatus = 'disconnected'; connect(url: string): void { this.targetUrl = url; this.active = true; this.reconnectAttempt = 0; if (!url) { this.handleStatusChange('simulated'); this.startSimulation(); return; } if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) { return; } this.handleStatusChange('connecting'); try { const endpoint = this.buildWsUrl(url); const socket = new WebSocket(endpoint); this.ws = socket; socket.onopen = () => { this.reconnectAttempt = 0; this.stopSimulation(); this.handleStatusChange('connected'); }; socket.onmessage = (evt) => { try { const raw = typeof evt.data === 'string' ? evt.data : JSON.stringify(evt.data); const frame = JSON.parse(raw) as SensingFrame; this.listeners.forEach((listener) => listener(frame)); } catch { // ignore malformed frames } }; socket.onerror = () => { // handled by onclose }; socket.onclose = (evt) => { this.ws = null; if (!this.active) { this.handleStatusChange('disconnected'); return; } if (evt.code === 1000) { this.handleStatusChange('disconnected'); return; } this.scheduleReconnect(); }; } catch { this.scheduleReconnect(); } } disconnect(): void { this.active = false; this.clearReconnectTimer(); this.stopSimulation(); if (this.ws) { this.ws.close(1000, 'client disconnect'); this.ws = null; } this.handleStatusChange('disconnected'); } subscribe(listener: FrameListener): () => void { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; } getStatus(): ConnectionStatus { return this.status; } private buildWsUrl(rawUrl: string): string { const parsed = new URL(rawUrl); const proto = parsed.protocol === 'https:' || parsed.protocol === 'wss:' ? 'wss:' : 'ws:'; return `${proto}//${parsed.host}${WS_PATH}`; } private handleStatusChange(status: ConnectionStatus): void { if (status === this.status) { return; } this.status = status; usePoseStore.getState().setConnectionStatus(status); } private scheduleReconnect(): void { if (!this.active) { this.handleStatusChange('disconnected'); return; } if (this.reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) { this.handleStatusChange('simulated'); this.startSimulation(); return; } const delay = RECONNECT_DELAYS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS.length - 1)]; this.reconnectAttempt += 1; this.clearReconnectTimer(); this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.connect(this.targetUrl); }, delay); this.startSimulation(); } private startSimulation(): void { if (this.simulationTimer) { return; } this.simulationTimer = setInterval(() => { this.handleStatusChange('simulated'); const frame = generateSimulatedData(); this.listeners.forEach((listener) => { listener(frame); }); }, SIMULATION_TICK_INTERVAL_MS); } private stopSimulation(): void { if (this.simulationTimer) { clearInterval(this.simulationTimer); this.simulationTimer = null; } } private clearReconnectTimer(): void { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } } } export const wsService = new WsService();