# ROS2 Integration Guide for HRIStudio ## Overview HRIStudio integrates with ROS2-based robots through the rosbridge protocol, enabling web-based control and monitoring of robots without requiring ROS2 installation on the server. This approach provides flexibility and simplifies deployment, especially on platforms like Vercel. ## Architecture ### Communication Flow ``` HRIStudio Web App → WebSocket → rosbridge_server → ROS2 Robot ↓ ROS2 Topics/Services ``` ### Key Components 1. **rosbridge_suite**: Provides WebSocket interface to ROS2 2. **roslib.js**: JavaScript library for ROS communication 3. **HRIStudio Plugin System**: Abstracts robot-specific implementations 4. **Message Type Definitions**: TypeScript interfaces for ROS2 messages ## ROS2 Bridge Setup ### Robot-Side Configuration The robot or a companion computer must run rosbridge: ```bash # Install rosbridge suite sudo apt update sudo apt install ros-humble-rosbridge-suite # Launch rosbridge with WebSocket server ros2 launch rosbridge_server rosbridge_websocket_launch.xml ``` ### Custom Launch File Create `hristudio_bridge.launch.xml`: ```xml ``` ## Client-Side Implementation ### ROS Connection Manager `src/lib/ros/connection.ts`: ```typescript import ROSLIB from 'roslib'; export class RosConnection { private ros: ROSLIB.Ros | null = null; private url: string; private reconnectInterval: number = 5000; private reconnectTimer: NodeJS.Timeout | null = null; private listeners: Map void>> = new Map(); constructor(url: string = process.env.NEXT_PUBLIC_ROSBRIDGE_URL || 'ws://localhost:9090') { this.url = url; } connect(): Promise { return new Promise((resolve, reject) => { if (this.ros?.isConnected) { resolve(); return; } this.ros = new ROSLIB.Ros({ url: this.url, options: { // Enable compression for better performance compression: 'png', // Throttle rate for topic subscriptions throttle_rate: 100, } }); this.ros.on('connection', () => { console.log('Connected to ROS bridge'); this.clearReconnectTimer(); resolve(); }); this.ros.on('error', (error) => { console.error('ROS connection error:', error); reject(error); }); this.ros.on('close', () => { console.log('ROS connection closed'); this.scheduleReconnect(); }); }); } private scheduleReconnect() { if (this.reconnectTimer) return; this.reconnectTimer = setTimeout(() => { console.log('Attempting to reconnect to ROS...'); this.connect().catch(console.error); }, this.reconnectInterval); } private clearReconnectTimer() { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } } disconnect() { this.clearReconnectTimer(); if (this.ros) { this.ros.close(); this.ros = null; } } isConnected(): boolean { return this.ros?.isConnected || false; } getRos(): ROSLIB.Ros | null { return this.ros; } // Topic subscription helper subscribe(topicName: string, messageType: string, callback: (message: T) => void): () => void { if (!this.ros) throw new Error('Not connected to ROS'); const topic = new ROSLIB.Topic({ ros: this.ros, name: topicName, messageType: messageType, compression: 'png', throttle_rate: 100 }); topic.subscribe(callback); // Return unsubscribe function return () => { topic.unsubscribe(callback); }; } // Service call helper async callService( serviceName: string, serviceType: string, request: TRequest ): Promise { if (!this.ros) throw new Error('Not connected to ROS'); const service = new ROSLIB.Service({ ros: this.ros, name: serviceName, serviceType: serviceType }); return new Promise((resolve, reject) => { service.callService( new ROSLIB.ServiceRequest(request), (response: TResponse) => resolve(response), (error: string) => reject(new Error(error)) ); }); } // Action client helper createActionClient(actionName: string, actionType: string): ROSLIB.ActionClient { if (!this.ros) throw new Error('Not connected to ROS'); return new ROSLIB.ActionClient({ ros: this.ros, serverName: actionName, actionName: actionType }); } } // Singleton instance export const rosConnection = new RosConnection(); ``` ### ROS2 Message Types `src/lib/ros/types.ts`: ```typescript // Common ROS2 message types export interface Header { stamp: { sec: number; nanosec: number; }; frame_id: string; } export interface Twist { linear: { x: number; y: number; z: number; }; angular: { x: number; y: number; z: number; }; } export interface Pose { position: { x: number; y: number; z: number; }; orientation: { x: number; y: number; z: number; w: number; }; } export interface JointState { header: Header; name: string[]; position: number[]; velocity: number[]; effort: number[]; } export interface BatteryState { header: Header; voltage: number; temperature: number; current: number; charge: number; capacity: number; percentage: number; power_supply_status: number; power_supply_health: number; power_supply_technology: number; present: boolean; } // HRIStudio specific messages export interface HRICommand { action_id: string; action_type: string; parameters: Record; timeout: number; } export interface HRIResponse { action_id: string; success: boolean; message: string; data: Record; duration_ms: number; } export interface HRIState { robot_id: string; connected: boolean; battery: BatteryState; pose: Pose; joint_states: JointState; custom_data: Record; } ``` ## ROS2 Robot Plugin Implementation ### Base ROS2 Plugin `src/lib/plugins/ros2/base-plugin.ts`: ```typescript import { RobotPlugin, ActionDefinition, ActionResult, RobotState } from '@/lib/plugins/types'; import { rosConnection } from '@/lib/ros/connection'; import { HRICommand, HRIResponse, HRIState, Twist } from '@/lib/ros/types'; import ROSLIB from 'roslib'; import { z } from 'zod'; export abstract class BaseROS2Plugin implements RobotPlugin { abstract id: string; abstract name: string; abstract version: string; abstract robotId: string; protected namespace: string = '/hristudio'; protected commandTopic: ROSLIB.Topic | null = null; protected stateTopic: ROSLIB.Topic | null = null; protected currentState: HRIState | null = null; protected pendingCommands: Map void> = new Map(); abstract configSchema: z.ZodSchema; abstract defaultConfig: Record; abstract actions: ActionDefinition[]; async initialize(config: any): Promise { // Validate config this.configSchema.parse(config); // Set namespace if provided if (config.namespace) { this.namespace = config.namespace; } } async connect(): Promise { try { await rosConnection.connect(); const ros = rosConnection.getRos(); if (!ros) return false; // Subscribe to robot state this.stateTopic = new ROSLIB.Topic({ ros, name: `${this.namespace}/robot_state`, messageType: 'hristudio_msgs/HRIState' }); this.stateTopic.subscribe((state: HRIState) => { this.currentState = state; }); // Create command publisher this.commandTopic = new ROSLIB.Topic({ ros, name: `${this.namespace}/commands`, messageType: 'hristudio_msgs/HRICommand' }); // Subscribe to responses const responseTopic = new ROSLIB.Topic({ ros, name: `${this.namespace}/responses`, messageType: 'hristudio_msgs/HRIResponse' }); responseTopic.subscribe((response: HRIResponse) => { const handler = this.pendingCommands.get(response.action_id); if (handler) { handler(response); this.pendingCommands.delete(response.action_id); } }); // Wait for initial state await this.waitForState(5000); return true; } catch (error) { console.error('Failed to connect to ROS2:', error); return false; } } async disconnect(): Promise { if (this.stateTopic) { this.stateTopic.unsubscribe(); this.stateTopic = null; } if (this.commandTopic) { this.commandTopic = null; } this.currentState = null; this.pendingCommands.clear(); } async executeAction(action: ActionDefinition, params: any): Promise { if (!this.commandTopic) { return { success: false, error: 'Not connected to robot', duration: 0 }; } const startTime = Date.now(); const actionId = `${action.id}_${Date.now()}`; try { // Validate parameters const validatedParams = action.parameterSchema.parse(params); // Create command const command: HRICommand = { action_id: actionId, action_type: action.id, parameters: validatedParams, timeout: action.timeout || 30000 }; // Send command and wait for response const response = await this.sendCommand(command); return { success: response.success, data: response.data, error: response.success ? undefined : response.message, duration: Date.now() - startTime }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Unknown error', duration: Date.now() - startTime }; } } async getState(): Promise { if (!this.currentState) { return { connected: false }; } return { connected: this.currentState.connected, battery: this.currentState.battery.percentage, position: { x: this.currentState.pose.position.x, y: this.currentState.pose.position.y, z: this.currentState.pose.position.z }, sensors: { jointStates: this.currentState.joint_states, ...this.currentState.custom_data } }; } protected sendCommand(command: HRICommand): Promise { return new Promise((resolve, reject) => { if (!this.commandTopic) { reject(new Error('Command topic not initialized')); return; } // Set timeout const timeout = setTimeout(() => { this.pendingCommands.delete(command.action_id); reject(new Error('Command timeout')); }, command.timeout); // Store handler this.pendingCommands.set(command.action_id, (response) => { clearTimeout(timeout); resolve(response); }); // Publish command this.commandTopic.publish(command); }); } protected async waitForState(timeoutMs: number): Promise { const startTime = Date.now(); while (!this.currentState && Date.now() - startTime < timeoutMs) { await new Promise(resolve => setTimeout(resolve, 100)); } if (!this.currentState) { throw new Error('Timeout waiting for robot state'); } } // Helper methods for common robot controls protected async moveBase(linear: number, angular: number): Promise { const ros = rosConnection.getRos(); if (!ros) { return { success: false, error: 'Not connected', duration: 0 }; } const cmdVelTopic = new ROSLIB.Topic({ ros, name: '/cmd_vel', messageType: 'geometry_msgs/Twist' }); const twist: Twist = { linear: { x: linear, y: 0, z: 0 }, angular: { x: 0, y: 0, z: angular } }; cmdVelTopic.publish(twist); return { success: true, duration: 0 }; } } ``` ### TurtleBot3 Plugin Example `src/lib/plugins/robots/turtlebot3.ts`: ```typescript import { BaseROS2Plugin } from '../ros2/base-plugin'; import { ActionDefinition } from '@/lib/plugins/types'; import { z } from 'zod'; export class TurtleBot3Plugin extends BaseROS2Plugin { id = 'turtlebot3-burger'; name = 'TurtleBot3 Burger'; version = '1.0.0'; robotId = 'turtlebot3'; configSchema = z.object({ namespace: z.string().default('/tb3'), maxLinearVelocity: z.number().default(0.22), maxAngularVelocity: z.number().default(2.84), rosbridge_url: z.string().url().optional(), }); defaultConfig = { namespace: '/tb3', maxLinearVelocity: 0.22, maxAngularVelocity: 2.84, }; actions: ActionDefinition[] = [ { id: 'move_forward', name: 'Move Forward', description: 'Move the robot forward', category: 'movement', icon: 'arrow-up', parameterSchema: z.object({ distance: z.number().min(0).max(5).describe('Distance in meters'), speed: z.number().min(0).max(0.22).default(0.1).describe('Speed in m/s'), }), timeout: 30000, retryable: true, }, { id: 'turn', name: 'Turn', description: 'Turn the robot', category: 'movement', icon: 'rotate-cw', parameterSchema: z.object({ angle: z.number().min(-180).max(180).describe('Angle in degrees'), speed: z.number().min(0).max(2.84).default(0.5).describe('Angular speed in rad/s'), }), timeout: 20000, retryable: true, }, { id: 'speak', name: 'Speak', description: 'Make the robot speak using TTS', category: 'interaction', icon: 'volume-2', parameterSchema: z.object({ text: z.string().max(500).describe('Text to speak'), voice: z.enum(['male', 'female']).default('female'), speed: z.number().min(0.5).max(2).default(1), }), timeout: 60000, retryable: false, }, { id: 'led_color', name: 'Set LED Color', description: 'Change the robot LED color', category: 'feedback', icon: 'lightbulb', parameterSchema: z.object({ color: z.enum(['red', 'green', 'blue', 'yellow', 'white', 'off']), duration: z.number().min(0).max(60).default(0).describe('Duration in seconds (0 = permanent)'), }), timeout: 5000, retryable: true, }, ]; async initialize(config: any): Promise { await super.initialize(config); // TurtleBot3 specific initialization if (config.rosbridge_url) { // Override default rosbridge URL if provided process.env.NEXT_PUBLIC_ROSBRIDGE_URL = config.rosbridge_url; } } // Override executeAction for robot-specific implementations async executeAction(action: ActionDefinition, params: any): Promise { // For movement actions, we can use direct topic publishing // for better real-time control if (action.id === 'move_forward') { return this.moveForward(params.distance, params.speed); } else if (action.id === 'turn') { return this.turn(params.angle, params.speed); } // For other actions, use the base implementation return super.executeAction(action, params); } private async moveForward(distance: number, speed: number): Promise { const startTime = Date.now(); const duration = (distance / speed) * 1000; // Convert to milliseconds // Start moving await this.moveBase(speed, 0); // Wait for movement to complete await new Promise(resolve => setTimeout(resolve, duration)); // Stop await this.moveBase(0, 0); return { success: true, data: { distance, speed }, duration: Date.now() - startTime }; } private async turn(angleDegrees: number, speed: number): Promise { const startTime = Date.now(); const angleRad = (angleDegrees * Math.PI) / 180; const duration = Math.abs(angleRad / speed) * 1000; // Start turning (negative for clockwise) const angularVel = angleDegrees > 0 ? speed : -speed; await this.moveBase(0, angularVel); // Wait for turn to complete await new Promise(resolve => setTimeout(resolve, duration)); // Stop await this.moveBase(0, 0); return { success: true, data: { angle: angleDegrees, speed }, duration: Date.now() - startTime }; } } ``` ## ROS2 Node for HRIStudio For robots to work with HRIStudio, they need a ROS2 node that implements the HRIStudio protocol: `hristudio_robot_node.py`: ```python #!/usr/bin/env python3 import rclpy from rclpy.node import Node from std_msgs.msg import String from geometry_msgs.msg import Twist from sensor_msgs.msg import JointState, BatteryState from hristudio_msgs.msg import HRICommand, HRIResponse, HRIState import json import time from threading import Thread class HRIStudioRobotNode(Node): def __init__(self): super().__init__('hristudio_robot_node') # Publishers self.state_pub = self.create_publisher(HRIState, '/hristudio/robot_state', 10) self.response_pub = self.create_publisher(HRIResponse, '/hristudio/responses', 10) # Subscribers self.command_sub = self.create_subscription( HRICommand, '/hristudio/commands', self.command_callback, 10 ) # Robot control publishers self.cmd_vel_pub = self.create_publisher(Twist, '/cmd_vel', 10) # State update timer self.state_timer = self.create_timer(0.1, self.publish_state) # Action handlers self.action_handlers = { 'move_forward': self.handle_move_forward, 'turn': self.handle_turn, 'speak': self.handle_speak, 'led_color': self.handle_led_color, } self.get_logger().info('HRIStudio Robot Node started') def command_callback(self, msg): """Handle incoming commands from HRIStudio""" self.get_logger().info(f'Received command: {msg.action_type}') # Execute action in separate thread to avoid blocking thread = Thread(target=self.execute_command, args=(msg,)) thread.start() def execute_command(self, command): """Execute a command and send response""" start_time = time.time() response = HRIResponse() response.action_id = command.action_id try: # Parse parameters params = json.loads(command.parameters) if isinstance(command.parameters, str) else command.parameters # Execute action if command.action_type in self.action_handlers: result = self.action_handlers[command.action_type](params) response.success = result['success'] response.message = result.get('message', '') response.data = json.dumps(result.get('data', {})) else: response.success = False response.message = f'Unknown action type: {command.action_type}' except Exception as e: response.success = False response.message = str(e) self.get_logger().error(f'Error executing command: {e}') response.duration_ms = int((time.time() - start_time) * 1000) self.response_pub.publish(response) def publish_state(self): """Publish current robot state""" state = HRIState() state.robot_id = 'turtlebot3' state.connected = True # Add current sensor data # This would come from actual robot sensors state.battery.percentage = 85.0 state.pose.position.x = 0.0 state.pose.position.y = 0.0 state.pose.orientation.w = 1.0 self.state_pub.publish(state) def handle_move_forward(self, params): """Move robot forward""" distance = params.get('distance', 0) speed = params.get('speed', 0.1) # Calculate duration duration = distance / speed # Publish velocity command twist = Twist() twist.linear.x = speed self.cmd_vel_pub.publish(twist) # Wait for movement to complete time.sleep(duration) # Stop twist.linear.x = 0.0 self.cmd_vel_pub.publish(twist) return { 'success': True, 'data': {'distance': distance, 'speed': speed} } def handle_turn(self, params): """Turn robot""" angle = params.get('angle', 0) speed = params.get('speed', 0.5) # Convert to radians angle_rad = angle * 3.14159 / 180 duration = abs(angle_rad) / speed # Publish velocity command twist = Twist() twist.angular.z = speed if angle > 0 else -speed self.cmd_vel_pub.publish(twist) # Wait for turn to complete time.sleep(duration) # Stop twist.angular.z = 0.0 self.cmd_vel_pub.publish(twist) return { 'success': True, 'data': {'angle': angle, 'speed': speed} } def handle_speak(self, params): """Text to speech""" text = params.get('text', '') # Implement TTS here self.get_logger().info(f'Speaking: {text}') return {'success': True, 'data': {'text': text}} def handle_led_color(self, params): """Set LED color""" color = params.get('color', 'off') # Implement LED control here self.get_logger().info(f'Setting LED to: {color}') return {'success': True, 'data': {'color': color}} def main(args=None): rclpy.init(args=args) node = HRIStudioRobotNode() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` ## Security Considerations ### 1. Authentication - Use SSL/TLS for rosbridge connections in production - Implement token-based authentication for rosbridge - Restrict topic/service access patterns ### 2. Network Security - Use VPN or SSH tunnels for remote robot connections - Implement firewall rules to restrict rosbridge access - Use separate network segments for robot communication ### 3. Message Validation - Validate all incoming messages on the robot side - Implement rate limiting to prevent DoS attacks - Sanitize string inputs to prevent injection attacks ## Deployment Considerations ### Local Development - Robot and development machine on same network - Direct WebSocket connection to rosbridge - No SSL required ### Production (Vercel) - Robot behind NAT/firewall - Use reverse proxy or tunnel (e.g., ngrok, Cloudflare Tunnel) - SSL/TLS required for secure communication - Consider latency for real-time control ### Hybrid Approach - Local "robot companion" server near robot - Companion server connects to both robot and Vercel app - Reduces latency for critical operations - Maintains security boundaries ## Testing ROS2 Integration ### Unit Tests `src/lib/ros/__tests__/connection.test.ts`: ```typescript import { RosConnection } from '../connection'; import { vi, describe, it, expect, beforeEach } from 'vitest'; vi.mock('roslib', () => ({ default: { Ros: vi.fn().mockImplementation(() => ({ on: vi.fn(), connect: vi.fn(), close: vi.fn(), isConnected: true, })), Topic: vi.fn(), Service: vi.fn(), }, })); describe('RosConnection', () => { let connection: RosConnection; beforeEach(() => { connection = new RosConnection('ws://test:9090'); }); it('should connect successfully', async () => { await expect(connection.connect()).resolves.toBeUndefined(); expect(connection.isConnected()).toBe(true); }); // Add more tests... }); ``` ### Integration Tests - Use rosbridge_server in test mode - Mock robot responses - Test error scenarios - Verify message formats ## Performance Optimization 1. **Message Throttling**: Limit frequency of state updates 2. **Compression**: Enable PNG compression for image topics 3. **Selective Subscriptions**: Only subscribe to needed topics 4. **Connection Pooling**: Reuse WebSocket connections 5. **Client-Side Caching**: Cache robot capabilities ## Troubleshooting ### Common Issues 1. **Connection Refused** - Check rosbridge is running - Verify firewall rules - Check WebSocket URL 2. **Message Type Errors** - Ensure message types match between client and robot - Verify ROS2 workspace is sourced 3. **High Latency** - Check network conditions - Consider local rosbridge proxy - Optimize message sizes 4. **Authentication Failures** - Verify SSL certificates - Check authentication tokens - Review rosbridge configuration