/** * WebSocket zum Control Core (§17.9, §3.2). * * - Snapshot beim Connect, danach Deltas (parameter.update) plus Heartbeat. * - Reconnect mit Exponential-Backoff: 1 s, 2 s, 4 s … maximum 30 s. * - Browser-Neustart/-Reload stoppt den Output nicht (§3.2): der Client * sendet keinerlei Commands beim Connect und synchronisiert nur Zustand. * - URL: Same-Origin '/ws' (Dev über den Vite-Proxy → ws://localhost:8000/ws); * alternativ direkt über VITE_WS_URL konfigurierbar. */ import { useEffect, useState } from 'react'; import type { WsServerEvent } from '../types'; const RECONNECT_BASE_MS = 1000; const RECONNECT_MAX_MS = 30_000; function resolveWsUrl(): string { const override: unknown = import.meta.env.VITE_WS_URL; if (typeof override === 'string' && override.length > 0) return override; const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; return `${protocol}://${window.location.host}/ws`; } function isWsServerEvent(data: unknown): data is WsServerEvent { if (typeof data !== 'object' || data === null) return false; const type = (data as { type?: unknown }).type; return type === 'snapshot' || type === 'parameter.update' || type === 'heartbeat'; } export interface WsState { readonly connected: boolean; /** Engine-Revision aus Snapshot/Updates; null vor der ersten Serverantwort. */ readonly revision: number | null; readonly values: Readonly>; readonly lastEventMs: number | null; } export function useWebSocket(): WsState { const [connected, setConnected] = useState(false); const [revision, setRevision] = useState(null); const [values, setValues] = useState>({}); const [lastEventMs, setLastEventMs] = useState(null); useEffect(() => { let disposed = false; let socket: WebSocket | null = null; let timer: number | null = null; let attempt = 0; const clearTimer = (): void => { if (timer !== null) { window.clearTimeout(timer); timer = null; } }; const scheduleReconnect = (): void => { const delay = Math.min(RECONNECT_BASE_MS * 2 ** attempt, RECONNECT_MAX_MS); attempt += 1; timer = window.setTimeout(connect, delay); }; const connect = (): void => { if (disposed) return; socket = new WebSocket(resolveWsUrl()); socket.onopen = () => { if (disposed) return; attempt = 0; setConnected(true); }; socket.onmessage = (ev: MessageEvent) => { if (disposed) return; setLastEventMs(Date.now()); const parsed: unknown = (() => { try { return JSON.parse(ev.data) as unknown; } catch { return null; } })(); if (!isWsServerEvent(parsed)) return; switch (parsed.type) { case 'snapshot': setValues(parsed.values); setRevision(parsed.revision); break; case 'parameter.update': setValues(prev => ({ ...prev, [parsed.parameter_path]: parsed.value })); setRevision(parsed.revision); break; case 'heartbeat': break; } }; socket.onclose = () => { if (disposed) return; setConnected(false); scheduleReconnect(); }; socket.onerror = () => { socket?.close(); }; }; connect(); return () => { disposed = true; clearTimer(); socket?.close(1000, 'client-unmount'); socket = null; }; }, []); return { connected, revision, values, lastEventMs }; }