269 lines
7.1 KiB
TypeScript
269 lines
7.1 KiB
TypeScript
/**
|
|
* 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';
|
|
|
|
export interface RVSMessage {
|
|
type: MessageType;
|
|
payload: Record<string, unknown>;
|
|
timestamp: number;
|
|
}
|
|
|
|
export interface ConnectionConfig {
|
|
host: string;
|
|
port: number;
|
|
token: string;
|
|
useTLS: boolean;
|
|
}
|
|
|
|
type MessageCallback = (message: RVSMessage) => void;
|
|
type StateCallback = (state: ConnectionState) => 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;
|
|
|
|
// --- RVS-Klasse ---
|
|
|
|
class RVSConnection {
|
|
private ws: WebSocket | null = null;
|
|
private config: ConnectionConfig | null = null;
|
|
private state: ConnectionState = 'disconnected';
|
|
|
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
private reconnectDelay: number = INITIAL_RECONNECT_DELAY_MS;
|
|
private shouldReconnect: boolean = false;
|
|
|
|
private messageListeners: MessageCallback[] = [];
|
|
private stateListeners: StateCallback[] = [];
|
|
|
|
// --- 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) {
|
|
console.warn('[RVS] Keine Verbindungskonfiguration vorhanden');
|
|
return;
|
|
}
|
|
|
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
console.log('[RVS] Bereits verbunden');
|
|
return;
|
|
}
|
|
|
|
this.shouldReconnect = true;
|
|
this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
|
|
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.setState('disconnected');
|
|
}
|
|
|
|
/** Nachricht an den RVS senden */
|
|
send(type: MessageType, payload: Record<string, unknown>): 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);
|
|
};
|
|
}
|
|
|
|
// --- Interne Methoden ---
|
|
|
|
private establishConnection(): void {
|
|
if (!this.config) return;
|
|
|
|
this.setState('connecting');
|
|
|
|
const protocol = this.config.useTLS ? 'wss' : 'ws';
|
|
const url = `${protocol}://${this.config.host}:${this.config.port}?token=${this.config.token}`;
|
|
|
|
try {
|
|
this.ws = new WebSocket(url);
|
|
|
|
this.ws.onopen = () => {
|
|
console.log('[RVS] Verbunden');
|
|
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) {
|
|
console.error('[RVS] Fehler beim Parsen der Nachricht:', err);
|
|
}
|
|
};
|
|
|
|
this.ws.onclose = (event) => {
|
|
console.log(`[RVS] Verbindung geschlossen (Code: ${event.code})`);
|
|
this.clearTimers();
|
|
this.ws = null;
|
|
this.setState('disconnected');
|
|
|
|
if (this.shouldReconnect) {
|
|
this.scheduleReconnect();
|
|
}
|
|
};
|
|
|
|
this.ws.onerror = (error) => {
|
|
console.error('[RVS] WebSocket-Fehler:', error);
|
|
};
|
|
} catch (err) {
|
|
console.error('[RVS] Verbindungsfehler:', err);
|
|
this.setState('disconnected');
|
|
|
|
if (this.shouldReconnect) {
|
|
this.scheduleReconnect();
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Reconnect mit exponentiellem Backoff planen */
|
|
private scheduleReconnect(): void {
|
|
console.log(`[RVS] 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<void> {
|
|
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<ConnectionConfig | null> {
|
|
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;
|