mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 14:44:44 -05:00
26 KiB
Executable File
26 KiB
Executable File
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
- rosbridge_suite: Provides WebSocket interface to ROS2
- roslib.js: JavaScript library for ROS communication
- HRIStudio Plugin System: Abstracts robot-specific implementations
- Message Type Definitions: TypeScript interfaces for ROS2 messages
ROS2 Bridge Setup
Robot-Side Configuration
The robot or a companion computer must run rosbridge:
# 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:
<launch>
<arg name="port" default="9090"/>
<arg name="address" default="0.0.0.0"/>
<arg name="ssl" default="false"/>
<arg name="certfile" default=""/>
<arg name="keyfile" default=""/>
<node pkg="rosbridge_server" exec="rosbridge_websocket" name="rosbridge_websocket">
<param name="port" value="$(var port)"/>
<param name="address" value="$(var address)"/>
<param name="ssl" value="$(var ssl)"/>
<param name="certfile" value="$(var certfile)"/>
<param name="keyfile" value="$(var keyfile)"/>
<!-- Limit message sizes for security -->
<param name="max_message_size" value="10000000"/>
<param name="unregister_timeout" value="10.0"/>
<!-- Enable specific services only -->
<param name="services_glob" value="['/hristudio/*']"/>
<param name="topics_glob" value="['/hristudio/*', '/tf', '/tf_static']"/>
</node>
</launch>
Client-Side Implementation
ROS Connection Manager
src/lib/ros/connection.ts:
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<string, Set<(data: any) => void>> = new Map();
constructor(url: string = process.env.NEXT_PUBLIC_ROSBRIDGE_URL || 'ws://localhost:9090') {
this.url = url;
}
connect(): Promise<void> {
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<T>(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<TRequest, TResponse>(
serviceName: string,
serviceType: string,
request: TRequest
): Promise<TResponse> {
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:
// 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<string, any>;
timeout: number;
}
export interface HRIResponse {
action_id: string;
success: boolean;
message: string;
data: Record<string, any>;
duration_ms: number;
}
export interface HRIState {
robot_id: string;
connected: boolean;
battery: BatteryState;
pose: Pose;
joint_states: JointState;
custom_data: Record<string, any>;
}
ROS2 Robot Plugin Implementation
Base ROS2 Plugin
src/lib/plugins/ros2/base-plugin.ts:
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<string, (response: HRIResponse) => void> = new Map();
abstract configSchema: z.ZodSchema;
abstract defaultConfig: Record<string, any>;
abstract actions: ActionDefinition[];
async initialize(config: any): Promise<void> {
// Validate config
this.configSchema.parse(config);
// Set namespace if provided
if (config.namespace) {
this.namespace = config.namespace;
}
}
async connect(): Promise<boolean> {
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<void> {
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<ActionResult> {
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<RobotState> {
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<HRIResponse> {
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<void> {
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<ActionResult> {
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:
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<void> {
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<ActionResult> {
// 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<ActionResult> {
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<ActionResult> {
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:
#!/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:
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
- Message Throttling: Limit frequency of state updates
- Compression: Enable PNG compression for image topics
- Selective Subscriptions: Only subscribe to needed topics
- Connection Pooling: Reuse WebSocket connections
- Client-Side Caching: Cache robot capabilities
Troubleshooting
Common Issues
-
Connection Refused
- Check rosbridge is running
- Verify firewall rules
- Check WebSocket URL
-
Message Type Errors
- Ensure message types match between client and robot
- Verify ROS2 workspace is sourced
-
High Latency
- Check network conditions
- Consider local rosbridge proxy
- Optimize message sizes
-
Authentication Failures
- Verify SSL certificates
- Check authentication tokens
- Review rosbridge configuration