362e089be0
Der Nutzer hat recht: Der Ordner war voller Entwicklungs-Muell. Jetzt ist sauber getrennt: ROOT (was der Nutzer sieht und braucht): - run.py = das Programm - hms_app/ = der Anwendungscode - HMS MediaEngine.app = macOS Doppelklick-Starter - HMS-Start.vbs = Windows Doppelklick-Starter - HMS-Install.vbs = Windows Erst-Installation - HMS-Mac-Install.command = macOS Homebrew-Installation - HMS-Portable-Install.command = macOS Portable-Installation (16GB-Fix) - installer_gui.py = grafischer Installer - launcher.pyw + launcher_core.py = interne Start-Logik - LIESMICH.txt = 10-Zeilen-Kurzanleitung - .gitignore _entwicklung/ (alles andere, NICHT benoetigt): - packages/ apps/ native/ plugins/ tools/ schemas/ tests/ docs/ build/ fixture_profiles/ - PLAN.md STATUS.md ERRORS.md TEST_REPORT.md CHANGELOG.md README.md - pyproject.toml uv.lock setup_*.sh/ps1 make_mac_app.py Diese Trennung gilt ab sofort fuer alle Commits. Der Nutzer kann _entwicklung/ loeschen wenn er Platz braucht - die App laeuft ohne. Verifiziert: App startet nach Aufraeumen unveraendert (Health 200).
118 lines
3.6 KiB
TypeScript
118 lines
3.6 KiB
TypeScript
/**
|
|
* 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<Record<string, number>>;
|
|
readonly lastEventMs: number | null;
|
|
}
|
|
|
|
export function useWebSocket(): WsState {
|
|
const [connected, setConnected] = useState(false);
|
|
const [revision, setRevision] = useState<number | null>(null);
|
|
const [values, setValues] = useState<Record<string, number>>({});
|
|
const [lastEventMs, setLastEventMs] = useState<number | null>(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<string>) => {
|
|
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 };
|
|
}
|