AUFGERAUMT: Root auf 10 sichtbare Elemente reduziert
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).
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user