import type { MasterDriver, ConnectionStatus, RobotCommand, CommandSequence, RemoteServerMasterConfig, CommandCallback, SequenceCallback, StatusChangeCallback, UnsubscribeFn } from "$lib/types/robotDriver"; import { getWebSocketConfig } from "$lib/configs/performanceConfig"; /** * Remote Server Master Driver * Connects to FastAPI WebSocket server for remote robot control */ export class RemoteServerMaster implements MasterDriver { readonly type = "master" as const; readonly id: string; readonly name: string; private _status: ConnectionStatus = { isConnected: false }; private config: RemoteServerMasterConfig; private robotId: string; // WebSocket connection private ws?: WebSocket; private reconnectAttempts = 0; private maxReconnectAttempts = getWebSocketConfig().MAX_RECONNECT_ATTEMPTS; private reconnectDelay = getWebSocketConfig().INITIAL_RECONNECT_DELAY_MS; private heartbeatInterval?: number; // Event callbacks private commandCallbacks: CommandCallback[] = []; private sequenceCallbacks: SequenceCallback[] = []; private statusCallbacks: StatusChangeCallback[] = []; constructor(config: RemoteServerMasterConfig, robotId: string) { this.config = config; this.robotId = robotId; this.id = `remote-master-${robotId}-${Date.now()}`; this.name = `Remote Server Master (${robotId})`; console.log(`Created RemoteServerMaster for robot ${robotId}`); } get status(): ConnectionStatus { return this._status; } async connect(): Promise { console.log(`Connecting ${this.name} to ${this.config.url}...`); try { // Build WebSocket URL const wsUrl = this.buildWebSocketUrl(); // Create WebSocket connection this.ws = new WebSocket(wsUrl); // Set up event handlers this.setupWebSocketHandlers(); // Wait for connection await this.waitForConnection(); // Start heartbeat this.startHeartbeat(); this._status = { isConnected: true, lastConnected: new Date(), error: undefined }; this.notifyStatusChange(); console.log(`${this.name} connected successfully`); } catch (error) { this._status = { isConnected: false, error: `Connection failed: ${error}` }; this.notifyStatusChange(); throw error; } } async disconnect(): Promise { console.log(`Disconnecting ${this.name}...`); this.stopHeartbeat(); if (this.ws) { this.ws.close(); this.ws = undefined; } this._status = { isConnected: false }; this.notifyStatusChange(); console.log(`${this.name} disconnected`); } async start(): Promise { if (!this._status.isConnected) { throw new Error("Cannot start: master not connected"); } console.log(`Starting remote control for robot ${this.robotId}`); // Send start command to server await this.sendMessage({ type: "start_control", timestamp: new Date().toISOString(), data: { robotId: this.robotId } }); } async stop(): Promise { console.log(`Stopping remote control for robot ${this.robotId}`); if (this._status.isConnected && this.ws) { await this.sendMessage({ type: "stop_control", timestamp: new Date().toISOString(), data: { robotId: this.robotId } }); } } async pause(): Promise { console.log(`Pausing remote control for robot ${this.robotId}`); if (this._status.isConnected && this.ws) { await this.sendMessage({ type: "pause_control", timestamp: new Date().toISOString(), data: { robotId: this.robotId } }); } } async resume(): Promise { console.log(`Resuming remote control for robot ${this.robotId}`); if (this._status.isConnected && this.ws) { await this.sendMessage({ type: "resume_control", timestamp: new Date().toISOString(), data: { robotId: this.robotId } }); } } // Event subscription methods onCommand(callback: CommandCallback): UnsubscribeFn { this.commandCallbacks.push(callback); return () => { const index = this.commandCallbacks.indexOf(callback); if (index >= 0) { this.commandCallbacks.splice(index, 1); } }; } onSequence(callback: SequenceCallback): UnsubscribeFn { this.sequenceCallbacks.push(callback); return () => { const index = this.sequenceCallbacks.indexOf(callback); if (index >= 0) { this.sequenceCallbacks.splice(index, 1); } }; } onStatusChange(callback: StatusChangeCallback): UnsubscribeFn { this.statusCallbacks.push(callback); return () => { const index = this.statusCallbacks.indexOf(callback); if (index >= 0) { this.statusCallbacks.splice(index, 1); } }; } // Private methods private buildWebSocketUrl(): string { const baseUrl = this.config.url.replace(/^http/, "ws"); return `${baseUrl}/ws/master/${this.robotId}`; } private setupWebSocketHandlers(): void { if (!this.ws) return; this.ws.onopen = () => { console.log(`WebSocket connected for robot ${this.robotId}`); this.reconnectAttempts = 0; // Reset on successful connection }; this.ws.onmessage = (event) => { try { const message = JSON.parse(event.data); this.handleServerMessage(message); } catch (error) { console.error("Failed to parse WebSocket message:", error); } }; this.ws.onclose = (event) => { console.log(`WebSocket closed for robot ${this.robotId}:`, event.code, event.reason); this.handleDisconnection(); }; this.ws.onerror = (error) => { console.error(`WebSocket error for robot ${this.robotId}:`, error); this._status = { isConnected: false, error: `WebSocket error: ${error}` }; this.notifyStatusChange(); }; } private async waitForConnection(): Promise { if (!this.ws) throw new Error("WebSocket not created"); return new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error("Connection timeout")); }, getWebSocketConfig().CONNECTION_TIMEOUT_MS); if (this.ws!.readyState === WebSocket.OPEN) { clearTimeout(timeout); resolve(); return; } this.ws!.onopen = () => { clearTimeout(timeout); resolve(); }; this.ws!.onerror = (error) => { clearTimeout(timeout); reject(error); }; }); } private async sendMessage(message: Record): Promise { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { throw new Error("WebSocket not connected"); } this.ws.send(JSON.stringify(message)); } private handleServerMessage(message: Record): void { const { type, data } = message; console.log(`🔍 RemoteServerMaster received: ${type}`, data); switch (type) { case "command": if (data) { this.notifyCommand([data as RobotCommand]); } break; case "sequence": if (data) { this.notifySequence(data as CommandSequence); } break; case "play_sequence": if (data) { console.log(`Playing sequence from server on robot ${this.robotId}`); this.notifySequence(data as CommandSequence); } break; case "robot_state": console.log(`Received robot state for ${this.robotId}:`, data); break; case "slave_status": console.log(`Slave status update for ${this.robotId}:`, data); // Status updates don't need to trigger robot movement break; case "joint_states": console.log(`Joint states update for ${this.robotId}:`, data); // Convert joint states from slave into robot commands to update local robot if (data && typeof data === "object" && "joints" in data) { const jointsData = data.joints as Array<{ name: string; virtual_value: number; real_value?: number; }>; if (Array.isArray(jointsData) && jointsData.length > 0) { const command: RobotCommand = { timestamp: Date.now(), joints: jointsData.map((joint) => ({ name: joint.name, value: joint.real_value !== undefined ? joint.real_value : joint.virtual_value })), metadata: { source: "remote_slave_joint_states" } }; console.log(`🔄 Converting joint states to command:`, command); this.notifyCommand([command]); } } break; case "slave_error": console.error(`Slave error for ${this.robotId}:`, data); break; case "heartbeat_ack": // Heartbeat acknowledged, connection is alive break; default: console.warn(`Unknown message type from server: ${type}`); } } private handleDisconnection(): void { this._status = { isConnected: false }; this.notifyStatusChange(); this.stopHeartbeat(); // Attempt reconnection if not manually disconnected if (this.reconnectAttempts < this.maxReconnectAttempts) { this.attemptReconnection(); } } private async attemptReconnection(): Promise { this.reconnectAttempts++; const maxDelay = getWebSocketConfig().MAX_RECONNECT_DELAY_MS; const delay = Math.min( this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1), maxDelay ); console.log( `Attempting master reconnection ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms...` ); setTimeout(async () => { try { await this.connect(); } catch (error) { console.error(`Master reconnection attempt ${this.reconnectAttempts} failed:`, error); } }, delay); } private startHeartbeat(): void { this.heartbeatInterval = setInterval(async () => { if (this._status.isConnected && this.ws) { try { await this.sendMessage({ type: "heartbeat", timestamp: new Date().toISOString() }); } catch (error) { console.error("Failed to send heartbeat:", error); } } }, getWebSocketConfig().HEARTBEAT_INTERVAL_MS); } private stopHeartbeat(): void { if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = undefined; } } private notifyCommand(commands: RobotCommand[]): void { this.commandCallbacks.forEach((callback) => { try { callback(commands); } catch (error) { console.error("Error in command callback:", error); } }); } private notifySequence(sequence: CommandSequence): void { this.sequenceCallbacks.forEach((callback) => { try { callback(sequence); } catch (error) { console.error("Error in sequence callback:", error); } }); } private notifyStatusChange(): void { this.statusCallbacks.forEach((callback) => { try { callback(this._status); } catch (error) { console.error("Error in status callback:", error); } }); } }