LeRobot-Arena / src /lib /robot /drivers /RemoteServerMaster.ts
blanchon's picture
Mostly UI Update
18b0fa5
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);
}
});
}
}