Spaces:
Running
Running
| 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<void> { | |
| 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<void> { | |
| 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<void> { | |
| 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<void> { | |
| 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<void> { | |
| 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<void> { | |
| 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<void> { | |
| 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<string, unknown>): Promise<void> { | |
| if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { | |
| throw new Error("WebSocket not connected"); | |
| } | |
| this.ws.send(JSON.stringify(message)); | |
| } | |
| private handleServerMessage(message: Record<string, unknown>): 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<void> { | |
| 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); | |
| } | |
| }); | |
| } | |
| } | |