Install
$ agentstack add skill-arpitg1304-robotics-agent-skills-ros2-web-integration ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ● Network access Used
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ● Environment & secrets Used
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
ROS2 Web Integration Skill
When to Use This Skill
- Building a web dashboard to monitor or control a robot running ROS2
- Streaming camera feeds (MJPEG, WebRTC, compressed WebSocket) from a robot to a browser
- Exposing ROS2 services and actions as REST API endpoints
- Implementing bidirectional WebSocket communication between a web UI and ROS2 nodes
- Setting up rosbridge_suite for quick prototyping or foxglove integration
- Writing a custom FastAPI or Flask bridge to ROS2 for production deployments
- Adding authentication, rate limiting, or CORS to robot web interfaces
- Running an async web server (uvicorn) alongside the rclpy executor without deadlocks
- Publishing teleop commands from a browser joystick to cmd_vel
- Serving ROS2 parameter configuration pages or diagnostic dashboards over HTTP
Architecture Overview
Comparison Table
| Feature | rosbridgesuite | Custom FastAPI Bridge | Custom Flask Bridge | |---|---|---|---| | Latency | ~5-15ms (WebSocket) | ~2-5ms (WebSocket), ~10-30ms (REST) | ~10-50ms (REST only without extensions) | | Throughput | Medium (JSON serialization overhead) | High (binary WebSocket, async) | Low-Medium (sync, GIL-bound) | | Auth | Basic (rosauth, limited) | Full (JWT, OAuth2, API keys) | Full (Flask-Login, JWT) | | Complexity | Low (launch and connect) | Medium (must manage two event loops) | Medium (must manage threading) | | Video Streaming | Requires separate webvideo_server | Native (MJPEG, WebSocket binary) | MJPEG via generator responses | | Production Ready | No (exposes full topic graph) | Yes | Yes (with gunicorn) | | When to Use | Prototyping, foxglove, quick demos | Production APIs, high-perf streaming | Simple internal tools, legacy systems |
When to Use rosbridge vs Custom Bridge
Use rosbridge_suite when:
- You need a working bridge in under 10 minutes
- The client is foxglove, webviz, or another rosbridge-aware tool
- Security is not a concern (local network, demo environment)
- You do not need custom business logic between web and ROS2
Use a custom bridge (FastAPI/Flask) when:
- You need authentication, authorization, or rate limiting
- You want to expose only specific topics/services (not the entire ROS2 graph)
- You need to transform or aggregate data before sending to the client
- You need REST endpoints for integration with non-WebSocket clients
- You are streaming video and need control over encoding and quality
- The system is deployed in production or on a public network
Pattern 1: rosbridge_suite
Installation and Launch
# Install rosbridge_suite
sudo apt install ros-${ROS_DISTRO}-rosbridge-suite
# Launch with default settings (port 9090)
ros2 launch rosbridge_server rosbridge_websocket_launch.xml
# Launch with custom port and SSL
ros2 launch rosbridge_server rosbridge_websocket_launch.xml \
port:=9091 \
ssl:=true \
certfile:=/etc/ssl/certs/robot.pem \
keyfile:=/etc/ssl/private/robot.key
# Launch with authentication (rosauth)
ros2 launch rosbridge_server rosbridge_websocket_launch.xml \
authenticate:=true
JavaScript Client (roslibjs)
// Connect to rosbridge WebSocket
const ros = new ROSLIB.Ros({ url: 'ws://robot-host:9090' });
ros.on('connection', () => console.log('Connected to rosbridge'));
ros.on('error', (err) => console.error('Connection error:', err));
ros.on('close', () => console.log('Connection closed'));
// Subscribe to compressed camera images
const imageTopic = new ROSLIB.Topic({
ros: ros,
name: '/camera/image/compressed',
messageType: 'sensor_msgs/msg/CompressedImage',
// Throttle to 10 Hz to avoid flooding the browser
throttle_rate: 100,
// Queue size of 1 — drop stale frames
queue_size: 1
});
imageTopic.subscribe((msg) => {
// msg.data is base64-encoded JPEG
const imgElement = document.getElementById('camera-feed');
imgElement.src = 'data:image/jpeg;base64,' + msg.data;
});
// Call a ROS2 service
const getMapSrv = new ROSLIB.Service({
ros: ros,
name: '/map_server/map',
serviceType: 'nav_msgs/srv/GetMap'
});
getMapSrv.callService(new ROSLIB.ServiceRequest({}), (result) => {
console.log('Map received:', result.map.info.width, 'x', result.map.info.height);
}, (error) => {
console.error('Service call failed:', error);
});
// Publish velocity commands from a virtual joystick
const cmdVelTopic = new ROSLIB.Topic({
ros: ros,
name: '/cmd_vel',
messageType: 'geometry_msgs/msg/Twist'
});
function sendVelocity(linearX, angularZ) {
const twist = new ROSLIB.Message({
linear: { x: linearX, y: 0.0, z: 0.0 },
angular: { x: 0.0, y: 0.0, z: angularZ }
});
cmdVelTopic.publish(twist);
}
// Publish at 10 Hz while joystick is active; stop on release
let joystickInterval = null;
function onJoystickMove(lx, az) {
if (!joystickInterval) {
joystickInterval = setInterval(() => sendVelocity(lx, az), 100);
}
}
function onJoystickRelease() {
clearInterval(joystickInterval);
joystickInterval = null;
sendVelocity(0.0, 0.0); // Always send zero on release
}
Limitations and Performance
- JSON serialization overhead: All messages are serialized to JSON, including binary data (base64-encoded). A 640x480 JPEG compressed image becomes ~30% larger over the wire.
- No topic filtering: By default rosbridge exposes every topic, service, and action on the ROS2 graph. Any connected client can publish to
/cmd_vel. - Single-threaded event loop: rosbridge_server uses a single Tornado event loop. High-frequency subscriptions from multiple clients can starve the loop.
- No built-in rate limiting: Clients can subscribe at any rate. A misbehaving client subscribing to a 30Hz point cloud will consume the server.
- Authentication is minimal: rosauth uses MAC-based tokens with shared secrets. It does not support JWT, OAuth2, or role-based access.
Pattern 2: Custom FastAPI Bridge
Project Structure
robot_web_bridge/
├── robot_web_bridge/
│ ├── __init__.py
│ ├── ros_node.py # ROS2 node with shared state
│ ├── web_app.py # FastAPI application
│ ├── main.py # Entry point: starts both rclpy and uvicorn
│ ├── auth.py # JWT authentication middleware
│ └── rate_limiter.py # Token bucket rate limiter
├── config/
│ └── bridge_config.yaml # Allowed topics, rate limits, auth keys
├── launch/
│ └── web_bridge.launch.py
├── package.xml
├── setup.py
└── setup.cfg
ROS2 Node with Async Executor
# ros_node.py
import threading
import time
from typing import Optional
import rclpy
from rclpy.node import Node
from rclpy.executors import MultiThreadedExecutor
from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy
from sensor_msgs.msg import CompressedImage
from geometry_msgs.msg import Twist
from nav_msgs.msg import Odometry
from std_srvs.srv import Trigger
class RobotBridgeNode(Node):
"""ROS2 node that exposes topic data via thread-safe shared state."""
def __init__(self):
super().__init__('web_bridge_node')
# Thread-safe shared state for latest messages
self._lock = threading.Lock()
self._latest_image: Optional[bytes] = None
self._latest_odom: Optional[dict] = None
self._image_timestamp: float = 0.0
# QoS for sensor data — best effort, keep last 1
sensor_qos = QoSProfile(
reliability=ReliabilityPolicy.BEST_EFFORT,
history=HistoryPolicy.KEEP_LAST,
depth=1
)
# Subscribers
self.create_subscription(
CompressedImage, '/camera/image/compressed',
self._image_cb, sensor_qos)
self.create_subscription(
Odometry, '/odom', self._odom_cb, sensor_qos)
# Publisher for velocity commands
self.cmd_vel_pub = self.create_publisher(Twist, '/cmd_vel', 10)
# Service client for emergency stop
self.estop_client = self.create_client(Trigger, '/emergency_stop')
self.get_logger().info('Web bridge node initialized')
def _image_cb(self, msg: CompressedImage):
with self._lock:
self._latest_image = bytes(msg.data)
self._image_timestamp = time.monotonic()
def _odom_cb(self, msg: Odometry):
with self._lock:
self._latest_odom = {
'x': msg.pose.pose.position.x,
'y': msg.pose.pose.position.y,
'theta': 2.0 * __import__('math').atan2(
msg.pose.pose.orientation.z,
msg.pose.pose.orientation.w),
'linear_vel': msg.twist.twist.linear.x,
'angular_vel': msg.twist.twist.angular.z,
}
def get_latest_image(self) -> Optional[bytes]:
with self._lock:
return self._latest_image
def get_latest_odom(self) -> Optional[dict]:
with self._lock:
return self._latest_odom.copy() if self._latest_odom else None
def publish_cmd_vel(self, linear_x: float, angular_z: float):
msg = Twist()
msg.linear.x = float(linear_x)
msg.angular.z = float(angular_z)
self.cmd_vel_pub.publish(msg)
FastAPI App with ROS2 Integration
# web_app.py
import base64
import asyncio
import time
from typing import Optional
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Depends
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from .ros_node import RobotBridgeNode
class CmdVelRequest(BaseModel):
linear_x: float = Field(ge=-1.0, le=1.0, description="Linear velocity m/s")
angular_z: float = Field(ge=-2.0, le=2.0, description="Angular velocity rad/s")
def create_app(ros_node: RobotBridgeNode) -> FastAPI:
app = FastAPI(title="Robot Web Bridge", version="1.0.0")
# CORS — restrict to known origins in production
app.add_middleware(
CORSMiddleware,
allow_origins=["https://dashboard.example.com"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT"],
allow_headers=["Authorization", "Content-Type"],
)
# Store ros_node in app state so endpoints can access it
app.state.ros_node = ros_node
return app
WebSocket Endpoint for Streaming
# Add to web_app.py — WebSocket camera streaming endpoint
@app.websocket("/ws/camera")
async def camera_stream(websocket: WebSocket):
"""Stream compressed camera images as base64 over WebSocket.
Supports per-client rate limiting via query parameter:
ws://host/ws/camera?max_fps=10
"""
await websocket.accept()
ros_node: RobotBridgeNode = websocket.app.state.ros_node
# Per-client rate limiting
max_fps = int(websocket.query_params.get("max_fps", "15"))
min_interval = 1.0 / max(1, min(max_fps, 30)) # Clamp 1-30 FPS
last_send_time = 0.0
last_image_bytes: Optional[bytes] = None
try:
while True:
now = time.monotonic()
elapsed = now - last_send_time
if elapsed }
"""
ros_node: RobotBridgeNode = app.state.ros_node
try:
param_value = value.get("value")
if param_value is None:
raise HTTPException(status_code=400, detail="Missing 'value' field")
ros_node.set_parameters([rclpy.Parameter(param_name, value=param_value)])
return {"name": param_name, "value": param_value, "status": "updated"}
except rclpy.exceptions.ParameterNotDeclaredException:
raise HTTPException(status_code=404, detail=f"Parameter '{param_name}' not declared")
@app.post("/api/robot/emergency_stop")
async def emergency_stop():
"""Call the emergency stop service."""
ros_node: RobotBridgeNode = app.state.ros_node
if not ros_node.estop_client.service_is_ready():
raise HTTPException(status_code=503, detail="Emergency stop service not available")
future = ros_node.estop_client.call_async(Trigger.Request())
# Wait for result with timeout — run in executor to avoid blocking
result = await asyncio.get_event_loop().run_in_executor(
None, lambda: future.result(timeout=5.0)
)
return {"success": result.success, "message": result.message}
Running FastAPI + rclpy Together
This is the critical integration point. Uvicorn runs in the main thread, rclpy spins in a background thread, and shutdown is coordinated via signals.
# main.py
import signal
import sys
import threading
import rclpy
from rclpy.executors import MultiThreadedExecutor
import uvicorn
from .ros_node import RobotBridgeNode
from .web_app import create_app
def main():
rclpy.init()
ros_node = RobotBridgeNode()
app = create_app(ros_node)
# Spin rclpy in a background thread with a multi-threaded executor
executor = MultiThreadedExecutor(num_threads=2)
executor.add_node(ros_node)
spin_thread = threading.Thread(target=executor.spin, daemon=True)
spin_thread.start()
# Shutdown coordination
shutdown_event = threading.Event()
def shutdown_handler(signum, frame):
ros_node.get_logger().info('Shutdown signal received')
shutdown_event.set()
# Stop uvicorn by raising KeyboardInterrupt in main thread
raise KeyboardInterrupt
signal.signal(signal.SIGINT, shutdown_handler)
signal.signal(signal.SIGTERM, shutdown_handler)
try:
# Run uvicorn in the main thread
uvicorn.run(
app,
host="0.0.0.0",
port=8080,
log_level="info",
# Do NOT use reload in production with rclpy
reload=False,
)
except KeyboardInterrupt:
pass
finally:
ros_node.get_logger().info('Shutting down web bridge...')
executor.shutdown()
ros_node.destroy_node()
rclpy.shutdown()
spin_thread.join(timeout=5.0)
if __name__ == '__main__':
main()
Pattern 3: Flask Bridge
Flask with rclpy Threading
Flask is synchronous. Running rclpy.spin() on the same thread as Flask will block one or the other. The correct approach uses a background thread for the ROS2 executor.
# BAD: Blocking — rclpy.spin() never returns, Flask never starts
import rclpy
from flask import Flask, jsonify
app = Flask(__name__)
def bad_main():
rclpy.init()
node = rclpy.create_node('flask_bridge')
rclpy.spin(node) # Blocks forever — Flask never starts
app.run(host='0.0.0.0', port=8080)
# GOOD: Threaded executor — rclpy spins in background, Flask serves in main thread
import threading
import rclpy
from rclpy.executors import MultiThreadedExecutor
from flask import Flask, jsonify
app = Flask(__name__)
ros_node = None
class SimpleRosNode(rclpy.node.Node):
def __init__(self):
super().__init__('flask_bridge')
self._lock = threading.Lock()
self._data = {}
self.create_subscription(
Odometry, '/odom', self._odom_cb,
QoSProfile(reliability=ReliabilityPolicy.BEST_EFFORT, depth=1))
def _odom_cb(self, msg):
with self._lock:
self._data['x'] = msg.pose.pose.position.x
self._data['y'] = msg.pose.pose.position.y
def get_data(self):
with self._lock:
return self._data.copy()
@app.route('/api/status')
def status():
return jsonify(ros_node.get_data())
def main():
global ros_node
rclpy.init()
ros_node = SimpleRosNode()
executor = MultiThreadedExecutor()
executor.add_node(ros_node)
spin_thread = threading.Thread(target=executor.spin, daemon=True)
spin_thread.start()
try:
app.run(host='0.0.0.0', port=8080, threaded=True)
finally:
executor.shutdown()
ros_node.destroy_node()
rclpy.shutdown()
When Flask Is Enough vs When You Need FastAPI
Use Flask when:
- You only need simple REST endpoints (no WebSocket)
- The web bridge is an internal tool with few concur
…
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: arpitg1304
- Source: arpitg1304/robotics-agent-skills
- License: Apache-2.0
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.