/** * RVS (Rendezvous Server) - WebSocket-Verbindungsmanager * * Verwaltet die persistente WebSocket-Verbindung zwischen der ARIA Cockpit App * und dem Rendezvous Server. Unterstützt Auto-Reconnect, Heartbeat und * typisierte Nachrichten. */ import AsyncStorage from '@react-native-async-storage/async-storage'; // --- Typen --- export type ConnectionState = 'connecting' | 'connected' | 'disconnected'; export type MessageType = 'chat' | 'audio' | 'file' | 'location' | 'mode' | 'log' | 'event' | 'update_available' | string; export interface RVSMessage { type: MessageType; payload: Record; timestamp: number; } export interface ConnectionConfig { host: string; port: number; token: string; useTLS: boolean; } type MessageCallback = (message: RVSMessage) => void; type StateCallback = (state: ConnectionState) => void; /** Einzelner Eintrag im Verbindungslog */ export interface ConnectionLogEntry { timestamp: number; level: 'info' | 'warn' | 'error'; message: string; } type LogCallback = (entry: ConnectionLogEntry) => void; // --- Konstanten --- const HEARTBEAT_INTERVAL_MS = 25_000; const INITIAL_RECONNECT_DELAY_MS = 1_000; const MAX_RECONNECT_DELAY_MS = 30_000; const RECONNECT_BACKOFF_FACTOR = 2; const MAX_LOG_ENTRIES = 100; // --- RVS-Klasse --- class RVSConnection { private ws: WebSocket | null = null; private config: ConnectionConfig | null = null; private state: ConnectionState = 'disconnected'; private heartbeatTimer: ReturnType | null = null; private reconnectTimer: ReturnType | null = null; private reconnectDelay: number = INITIAL_RECONNECT_DELAY_MS; private shouldReconnect: boolean = false; private messageListeners: MessageCallback[] = []; private stateListeners: StateCallback[] = []; private logListeners: LogCallback[] = []; private connectionLog: ConnectionLogEntry[] = []; private usingTLSFallback: boolean = false; // --- Konfiguration --- /** Verbindungsdaten setzen (z.B. nach QR-Scan) */ setConfig(config: ConnectionConfig): void { this.config = config; this.saveConfig(config); } getConfig(): ConnectionConfig | null { return this.config; } getState(): ConnectionState { return this.state; } // --- Verbindung --- /** Verbindung zum RVS aufbauen */ connect(): void { if (!this.config) { this.log('warn', 'Keine Verbindungskonfiguration vorhanden'); return; } if (this.ws?.readyState === WebSocket.OPEN) { this.log('info', 'Bereits verbunden'); return; } this.shouldReconnect = true; this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS; this.usingTLSFallback = false; this.log('info', `Verbindungsaufbau zu ${this.config.host}:${this.config.port} (TLS: ${this.config.useTLS ? 'ja' : 'nein'})`); this.establishConnection(); } /** Verbindung trennen (kein Auto-Reconnect) */ disconnect(): void { this.shouldReconnect = false; this.clearTimers(); if (this.ws) { this.ws.close(1000, 'Benutzer hat getrennt'); this.ws = null; } this.log('info', 'Verbindung getrennt (manuell)'); this.setState('disconnected'); } /** Nachricht an den RVS senden */ send(type: MessageType, payload: Record): void { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { console.warn('[RVS] Kann nicht senden - nicht verbunden'); return; } const message: RVSMessage = { type, payload, timestamp: Date.now(), }; this.ws.send(JSON.stringify(message)); } // --- Event-Listener --- /** Callback fuer eingehende Nachrichten registrieren */ onMessage(callback: MessageCallback): () => void { this.messageListeners.push(callback); // Gibt Unsubscribe-Funktion zurueck return () => { this.messageListeners = this.messageListeners.filter(cb => cb !== callback); }; } /** Callback fuer Verbindungsstatus-Aenderungen registrieren */ onStateChange(callback: StateCallback): () => void { this.stateListeners.push(callback); return () => { this.stateListeners = this.stateListeners.filter(cb => cb !== callback); }; } /** Callback fuer Verbindungslog-Eintraege registrieren */ onLog(callback: LogCallback): () => void { this.logListeners.push(callback); return () => { this.logListeners = this.logListeners.filter(cb => cb !== callback); }; } /** Gesamten Verbindungslog abrufen */ getConnectionLog(): ConnectionLogEntry[] { return [...this.connectionLog]; } // --- Interne Methoden --- /** Eintrag ins Verbindungslog schreiben */ private log(level: ConnectionLogEntry['level'], message: string): void { const entry: ConnectionLogEntry = { timestamp: Date.now(), level, message }; this.connectionLog = [...this.connectionLog.slice(-(MAX_LOG_ENTRIES - 1)), entry]; this.logListeners.forEach(cb => cb(entry)); const prefix = level === 'error' ? 'ERROR' : level === 'warn' ? 'WARN' : 'INFO'; console.log(`[RVS] [${prefix}] ${message}`); } private establishConnection(): void { if (!this.config) return; this.setState('connecting'); const useTLS = this.config.useTLS && !this.usingTLSFallback; const protocol = useTLS ? 'wss' : 'ws'; const url = `${protocol}://${this.config.host}:${this.config.port}?token=${this.config.token}`; this.log('info', `Verbinde: ${protocol}://${this.config.host}:${this.config.port}`); try { this.ws = new WebSocket(url); this.ws.onopen = () => { const tlsInfo = this.usingTLSFallback ? ' (TLS-Fallback: ws://)' : ''; this.log('info', `Verbunden${tlsInfo}`); this.setState('connected'); this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS; this.startHeartbeat(); }; this.ws.onmessage = (event: WebSocketMessageEvent) => { try { const message: RVSMessage = JSON.parse(event.data as string); this.notifyMessageListeners(message); } catch (err) { this.log('error', `Nachricht parsen fehlgeschlagen: ${err}`); } }; this.ws.onclose = (event) => { this.log('info', `Verbindung geschlossen (Code: ${event.code}, Reason: ${event.reason || '-'})`); this.clearTimers(); this.ws = null; this.setState('disconnected'); if (this.shouldReconnect) { this.scheduleReconnect(); } }; this.ws.onerror = (error) => { const errorMsg = (error as any)?.message || 'Unbekannter Fehler'; this.log('error', `WebSocket-Fehler: ${errorMsg}`); // TLS-Fallback: Wenn wss:// fehlschlaegt, auf ws:// wechseln if (this.config?.useTLS && !this.usingTLSFallback) { this.usingTLSFallback = true; // shouldReconnect kurz deaktivieren damit onclose keinen // parallelen Reconnect ausloest — wir machen das selbst this.shouldReconnect = false; this.log('warn', 'TLS fehlgeschlagen — Fallback auf ws:// (ohne TLS)'); this.clearTimers(); if (this.ws) { this.ws.onclose = null; // onclose-Handler entfernen um Doppel-Reconnect zu verhindern try { this.ws.close(); } catch (_) {} } this.ws = null; this.shouldReconnect = true; this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS; this.establishConnection(); return; } }; } catch (err) { this.log('error', `Verbindungsfehler: ${err}`); this.setState('disconnected'); if (this.shouldReconnect) { this.scheduleReconnect(); } } } /** Reconnect mit exponentiellem Backoff planen */ private scheduleReconnect(): void { this.log('info', `Reconnect in ${this.reconnectDelay / 1000}s...`); this.reconnectTimer = setTimeout(() => { this.establishConnection(); }, this.reconnectDelay); // Exponentieller Backoff: 1s -> 2s -> 4s -> 8s -> ... -> max 30s this.reconnectDelay = Math.min( this.reconnectDelay * RECONNECT_BACKOFF_FACTOR, MAX_RECONNECT_DELAY_MS, ); } /** Heartbeat starten (alle 25 Sekunden) */ private startHeartbeat(): void { this.heartbeatTimer = setInterval(() => { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: 'heartbeat', timestamp: Date.now() })); } }, HEARTBEAT_INTERVAL_MS); } private clearTimers(): void { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; } if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } } private setState(state: ConnectionState): void { if (this.state !== state) { this.state = state; this.stateListeners.forEach(cb => cb(state)); } } private notifyMessageListeners(message: RVSMessage): void { this.messageListeners.forEach(cb => cb(message)); } // --- Persistenz --- private static readonly STORAGE_KEY = 'rvs_config'; private async saveConfig(config: ConnectionConfig): Promise { try { await AsyncStorage.setItem(RVSConnection.STORAGE_KEY, JSON.stringify(config)); console.log('[RVS] Konfiguration gespeichert'); } catch (err) { console.error('[RVS] Fehler beim Speichern:', err); } } async loadConfig(): Promise { try { const data = await AsyncStorage.getItem(RVSConnection.STORAGE_KEY); if (data) { this.config = JSON.parse(data); console.log('[RVS] Konfiguration geladen'); return this.config; } return null; } catch (err) { console.error('[RVS] Fehler beim Laden:', err); return null; } } } // Singleton-Instanz const rvs = new RVSConnection(); export default rvs;