关于 ros2-web-integration
ros2-web-integration 是面向 ROS2 开发者的技能,指导构建浏览器端机器人监控与控制接口。内容涵盖 rosbridge_suite,以及以 FastAPI 或 Flask 实现的桥接、视频流、双向通信和安全配置。
主要功能
- 01比较 rosbridge 与自定义桥接
- 02通过 REST 和 WebSocket 接入 ROS2
- 03传输 MJPEG、WebRTC 与压缩图像
- 04浏览器遥操作与状态广播
- 05配置身份验证、限流和 CORS
- 06并行运行 Web 服务与 rclpy
适用场景
- 01构建机器人状态监控仪表板
- 02在浏览器中查看机器人摄像头流
- 03通过网页控制机器人并调用 ROS2 服务
- 04为机器人 Web 接口添加访问控制
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ arpitg1304/ robotics-agent-skills --skill ros2-web-integration安装后在 Claude Code 中输入/ros2-web-integration确认已加载。
详细介绍
ROS2 Web 集成技能
何时使用此技能
- 构建 Web 仪表板,以监控或控制运行 ROS2 的机器人
- 将机器人上的摄像头画面(MJPEG、WebRTC、压缩 WebSocket)流式传输到浏览器
- 将 ROS2 服务和操作公开为 REST API 端点
- 实现 Web UI 与 ROS2 节点之间的双向 WebSocket 通信
- 设置 rosbridge_suite,以便快速原型开发或集成 foxglove
- 编写自定义 FastAPI 或 Flask 桥接程序,将 ROS2 用于生产部署
- 为机器人 Web 界面添加身份验证、速率限制或 CORS
- 在运行 rclpy 执行器的同时运行异步 Web 服务(uvicorn),避免死锁
- 从浏览器操纵杆向 cmd_vel 发布远程操控命令
- 通过 HTTP 提供 ROS2 参数配置页面或诊断仪表板
架构概述
对比表
| 特性 | rosbridge_suite | 自定义 FastAPI 桥接程序 | 自定义 Flask 桥接程序 |
|---|---|---|---|
| 延迟 | ~5-15ms(WebSocket) | ~2-5ms(WebSocket),~10-30ms(REST) | ~10-50ms(仅 REST,不含扩展) |
| 吞吐量 | 中(JSON 序列化开销) | 高(二进制 WebSocket、异步) | 低-中(同步、受 GIL 限制) |
| 身份验证 | 基本(rosauth,功能有限) | 完整(JWT、OAuth2、API 密钥) | 完整(Flask-Login、JWT) |
| 复杂度 | 低(启动并连接) | 中(必须管理两个事件循环) | 中(必须管理线程) |
| 视频流 | 需要单独的 web_video_server | 原生支持(MJPEG、WebSocket 二进制) | 通过生成器响应提供 MJPEG |
| 适用于生产环境 | 否(会暴露完整的主题图) | 是 | 是(搭配 gunicorn) |
| 适用场景 | 原型开发、foxglove、快速演示 | 生产 API、高性能流传输 | 简单的内部工具、旧版系统 |
何时使用 rosbridge,何时使用自定义桥接程序
在以下情况下使用 rosbridge_suite:
- 你需要在 10 分钟内搭建可用的桥接程序
- 客户端是 foxglove、webviz 或其他支持 rosbridge 的工具
- 无需考虑安全性(本地网络、演示环境)
- Web 与 ROS2 之间不需要自定义业务逻辑
在以下情况下使用自定义桥接程序(FastAPI/Flask):
- 你需要身份验证、授权或速率限制
- 你只想公开特定主题/服务,而非整个 ROS2 图
- 你需要在向客户端发送数据前对数据进行转换或聚合
- 你需要 REST 端点,以便与非 WebSocket 客户端集成
- 你正在传输视频流,需要控制编码和画质
- 系统部署在生产环境或公共网络上
模式 1:rosbridge_suite
安装和启动
# 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 客户端(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
}
局限性和性能
- JSON 序列化开销:所有消息都会序列化为 JSON,包括二进制数据(以 base64 编码)。一张 640x480 的 JPEG 压缩图像通过网络传输时会增大约 30%。
- 无法筛选主题:默认情况下,rosbridge 会公开 ROS2 图中的所有主题、服务和操作。任何已连接的客户端都可以向
/cmd_vel发布消息。 - 单线程事件循环:rosbridge_server 使用单个 Tornado 事件循环。多个客户端的高频订阅可能会导致该循环无法处理其他任务。
- 没有内置速率限制:客户端可以按任意频率订阅。行为异常的客户端若以 30Hz 订阅点云,将耗尽服务端资源。
- 身份验证功能有限:rosauth 使用基于 MAC 的令牌和共享密钥,不支持 JWT、OAuth2 或基于角色的访问控制。
模式 2:自定义 FastAPI 桥接程序
项目结构
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 节点
# 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)
集成 ROS2 的 FastAPI 应用
# 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 端点
# 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 < min_interval:
await asyncio.sleep(min_interval - elapsed)
continue
image_bytes = ros_node.get_latest_image()
if image_bytes is None or image_bytes is last_image_bytes:
# No new image available — avoid sending duplicates
await asyncio.sleep(0.01)
continue
last_image_bytes = image_bytes
last_send_time = time.monotonic()
# Send as base64 JSON for browser compatibility
b64_data = base64.b64encode(image_bytes).decode('ascii')
await websocket.send_json({
"type": "image",
"format": "jpeg",
"data": b64_data,
"timestamp": last_send_time,
})
except WebSocketDisconnect:
pass # Client disconnected — clean exit
except Exception as e:
ros_node.get_logger().warn(f'WebSocket error: {e}')
finally:
# Graceful disconnect — no cleanup needed for read-only stream
try:
await websocket.close()
except RuntimeError:
pass # Already closed
封装 ROS2 服务的 REST 端点
# Add to web_app.py — REST endpoints
@app.get("/api/robot/status")
async def get_robot_status():
"""Return current robot odometry and system status."""
ros_node: RobotBridgeNode = app.state.ros_node
odom = ros_node.get_latest_odom()
if odom is None:
raise HTTPException(status_code=503, detail="No odometry data available yet")
return {
"status": "active",
"odometry": odom,
"timestamp": time.time(),
}
@app.post("/api/robot/cmd_vel")
async def post_cmd_vel(cmd: CmdVelRequest):
"""Send a velocity command to the robot."""
ros_node: RobotBridgeNode = app.state.ros_node
ros_node.publish_cmd_vel(cmd.linear_x, cmd.angular_z)
return {"status": "ok", "linear_x": cmd.linear_x, "angular_z": cmd.angular_z}
@app.get("/api/robot/params/{param_name}")
async def get_parameter(param_name: str):
"""Read a ROS2 parameter from the bridge node."""
ros_node: RobotBridgeNode = app.state.ros_node
try:
param = ros_node.get_parameter(param_name)
return {"name": param_name, "value": param.value}
except rclpy.exceptions.ParameterNotDeclaredException:
raise HTTPException(status_code=404, detail=f"Parameter '{param_name}' not declared")
@app.put("/api/robot/params/{param_name}")
async def set_parameter(param_name: str, value: dict):
"""Set a ROS2 parameter on the bridge node.
Body: {"value": <new_value>}
"""
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}
同时运行 FastAPI 和 rclpy
这是关键的集成环节。Uvicorn 在主线程中运行,rclpy 在后台线程中执行 spin 操作,并通过信号协调关闭。
# 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()
模式 3:Flask 桥接程序
使用 rclpy 线程运行 Flask
Flask 是同步的。在与 Flask 相同的线程中运行 rclpy.spin() 会阻塞其中一个。正确做法是使用后台线程运行 ROS2 执行器。
# 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()
何时使用 Flask,何时需要 FastAPI
在以下情况下使用 Flask:
- 只需要简单的 REST 端点(不需要 WebSocket)
- Web 桥接是仅供内部使用、并发用户较少的工具
- 团队已经熟悉 Flask,还没准备好采用异步方式
- 不需要自动生成 OpenAPI/Swagger 文档
在以下情况下使用 FastAPI:
- 需要用于实时流传输的 WebSocket 端点
- 需要高并发(异步处理程序、多个客户端同时连接)
- 希望通过 Pydantic 模型自动验证请求
- 希望为机器人 API 自动生成 OpenAPI 文档
- 需要向多个客户端传输视频或传感器数据
视频流传输模式
MJPEG 流传输
MJPEG 流可直接在 <img> 标签中使用,无需 JavaScript。适用于简单的仪表板。
# mjpeg_stream.py — MJPEG streaming endpoint for FastAPI
import cv2
import time
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from .ros_node import RobotBridgeNode
def generate_mjpeg(ros_node: RobotBridgeNode, max_fps: int = 15):
"""Generator that yields MJPEG frames as multipart HTTP response chunks."""
min_interval = 1.0 / max_fps
last_send = 0.0
while True:
now = time.monotonic()
if now - last_send < min_interval:
time.sleep(min_interval - (now - last_send))
continue
image_bytes = ros_node.get_latest_image()
if image_bytes is None:
time.sleep(0.05)
continue
last_send = time.monotonic()
# Yield as multipart MJPEG frame
yield (
b'--frame\r\n'
b'Content-Type: image/jpeg\r\n'
b'Content-Length: ' + str(len(image_bytes)).encode() + b'\r\n'
b'\r\n' + image_bytes + b'\r\n'
)
@app.get("/video/mjpeg")
async def mjpeg_feed():
ros_node: RobotBridgeNode = app.state.ros_node
return StreamingResponse(
generate_mjpeg(ros_node, max_fps=15),
media_type="multipart/x-mixed-replace; boundary=frame"
)
浏览器用法——无需 JavaScript:
<img src="http://robot-host:8080/video/mjpeg" alt="Robot Camera" />
通过 webrtc_ros 使用 WebRTC
如需低延迟、高质量的视频流传输,请使用 webrtc_ros 软件包。
# webrtc_ros launch config
# webrtc_bridge.launch.py
from launch import LaunchDescription
from launch_ros.actions import Node
def generate_launch_description():
return LaunchDescription([
Node(
package='webrtc_ros',
executable='webrtc_ros_server_node',
name='webrtc_server',
parameters=[{
'port': 8443,
'image_transport': 'compressed',
# Bind to all interfaces for remote access
'address': '0.0.0.0',
}],
remappings=[
('image', '/camera/image_raw'),
],
),
])
通过 WebSocket 传输压缩话题
为兼顾简洁性与性能,可通过二进制 WebSocket 传输压缩图像话题。
# Binary WebSocket streaming — more efficient than base64 JSON
@app.websocket("/ws/camera/binary")
async def camera_stream_binary(websocket: WebSocket):
"""Stream JPEG frames as binary WebSocket messages.
~30% more bandwidth-efficient than base64 JSON encoding.
Client must handle raw binary blobs.
"""
await websocket.accept()
ros_node: RobotBridgeNode = websocket.app.state.ros_node
min_interval = 1.0 / 15 # 15 FPS max
try:
last_bytes = None
while True:
image_bytes = ros_node.get_latest_image()
if image_bytes is not None and image_bytes is not last_bytes:
last_bytes = image_bytes
await websocket.send_bytes(image_bytes)
await asyncio.sleep(min_interval)
except WebSocketDisconnect:
pass
客户端 JavaScript:
const ws = new WebSocket('ws://robot-host:8080/ws/camera/binary');
ws.binaryType = 'arraybuffer';
ws.onmessage = (event) => {
const blob = new Blob([event.data], { type: 'image/jpeg' });
const url = URL.createObjectURL(blob);
const img = document.getElementById('camera-feed');
// Revoke previous URL to prevent memory leaks
if (img.src.startsWith('blob:')) URL.revokeObjectURL(img.src);
img.src = url;
};
双向通信
从 Web UI 向机器人发送命令
# teleop_handler.py — WebSocket teleop with command timeout watchdog
import asyncio
import time
from fastapi import WebSocket, WebSocketDisconnect
from .ros_node import RobotBridgeNode
class TeleopHandler:
"""Handles joystick input from browser with safety watchdog.
If no command is received for 500ms, publishes zero velocity
to prevent the robot from running away on disconnect.
"""
COMMAND_TIMEOUT_S = 0.5 # Zero velocity after 500ms silence
def __init__(self, ros_node: RobotBridgeNode):
self.ros_node = ros_node
self.last_command_time = 0.0
async def handle(self, websocket: WebSocket):
await websocket.accept()
self.last_command_time = time.monotonic()
# Start watchdog task
watchdog_task = asyncio.create_task(self._watchdog())
try:
while True:
data = await websocket.receive_json()
# Expected: {"linear_x": 0.5, "angular_z": -0.3}
linear_x = float(data.get("linear_x", 0.0))
angular_z = float(data.get("angular_z", 0.0))
# Clamp values for safety
linear_x = max(-1.0, min(1.0, linear_x))
angular_z = max(-2.0, min(2.0, angular_z))
self.ros_node.publish_cmd_vel(linear_x, angular_z)
self.last_command_time = time.monotonic()
# Acknowledge to client
await websocket.send_json({"ack": True, "t": self.last_command_time})
except WebSocketDisconnect:
pass
finally:
watchdog_task.cancel()
# Always send zero on disconnect
self.ros_node.publish_cmd_vel(0.0, 0.0)
async def _watchdog(self):
"""Publish zero velocity if no command received within timeout."""
while True:
await asyncio.sleep(0.1)
if time.monotonic() - self.last_command_time > self.COMMAND_TIMEOUT_S:
self.ros_node.publish_cmd_vel(0.0, 0.0)
将机器人状态发送到 Web UI
# Status broadcast — push robot state to all connected WebSocket clients
class StatusBroadcaster:
"""Broadcasts robot status to all connected WebSocket clients."""
def __init__(self, ros_node: RobotBridgeNode):
self.ros_node = ros_node
self.clients: set[WebSocket] = set()
async def register(self, websocket: WebSocket):
await websocket.accept()
self.clients.add(websocket)
try:
# Keep connection alive — client sends pings
while True:
await websocket.receive_text()
except WebSocketDisconnect:
self.clients.discard(websocket)
async def broadcast_loop(self, interval: float = 0.1):
"""Call this as a background task on app startup."""
while True:
odom = self.ros_node.get_latest_odom()
if odom and self.clients:
dead_clients = set()
for client in self.clients.copy():
try:
await client.send_json({"type": "status", "odom": odom})
except Exception:
dead_clients.add(client)
self.clients -= dead_clients
await asyncio.sleep(interval)
命令确认模式
为确保命令可靠执行,可通过 WebSocket 使用带关联 ID 的请求-响应模式。
# Client sends: {"id": "cmd-001", "action": "navigate_to", "x": 1.0, "y": 2.0}
# Server responds: {"id": "cmd-001", "status": "accepted", "estimated_time": 12.5}
# Server updates: {"id": "cmd-001", "status": "in_progress", "progress": 0.45}
# Server completes: {"id": "cmd-001", "status": "completed", "result": "success"}
@app.websocket("/ws/commands")
async def command_channel(websocket: WebSocket):
await websocket.accept()
ros_node: RobotBridgeNode = websocket.app.state.ros_node
while True:
try:
data = await websocket.receive_json()
cmd_id = data.get("id", "unknown")
action = data.get("action")
if action == "navigate_to":
await websocket.send_json({
"id": cmd_id, "status": "accepted"
})
# Dispatch to ROS2 action client (non-blocking)
asyncio.create_task(
execute_navigation(ros_node, websocket, cmd_id,
data["x"], data["y"])
)
else:
await websocket.send_json({
"id": cmd_id, "status": "error",
"message": f"Unknown action: {action}"
})
except WebSocketDisconnect:
break
速率限制与背压
服务端速率限制
# rate_limiter.py — Token bucket rate limiter
import time
import threading
class TokenBucketRateLimiter:
"""Token bucket rate limiter for controlling message throughput.
Usage:
limiter = TokenBucketRateLimiter(tokens_per_second=10, burst_size=15)
if limiter.acquire():
send_message()
else:
drop_or_queue()
"""
def __init__(self, tokens_per_second: float, burst_size: int = 0):
self.rate = tokens_per_second
self.burst_size = burst_size or int(tokens_per_second)
self._tokens = float(self.burst_size)
self._last_refill = time.monotonic()
self._lock = threading.Lock()
def acquire(self) -> bool:
"""Try to acquire a token. Returns True if allowed, False if rate limited."""
with self._lock:
now = time.monotonic()
elapsed = now - self._last_refill
self._tokens = min(
self.burst_size,
self._tokens + elapsed * self.rate
)
self._last_refill = now
if self._tokens >= 1.0:
self._tokens -= 1.0
return True
return False
def reset(self):
"""Reset to full burst capacity."""
with self._lock:
self._tokens = float(self.burst_size)
self._last_refill = time.monotonic()
客户端驱动的背压
允许客户端自行请求速率,以匹配其处理能力。
@app.websocket("/ws/sensor/{topic_name}")
async def sensor_stream(websocket: WebSocket, topic_name: str):
await websocket.accept()
# Client sends desired rate on connect
config = await websocket.receive_json()
requested_hz = config.get("hz", 10)
requested_hz = max(1, min(requested_hz, 30)) # Clamp to 1-30 Hz
interval = 1.0 / requested_hz
ros_node: RobotBridgeNode = websocket.app.state.ros_node
try:
while True:
data = ros_node.get_latest_odom()
if data:
await websocket.send_json({"topic": topic_name, "data": data})
await asyncio.sleep(interval)
except WebSocketDisconnect:
pass
自适应降低画质
当带宽不足或客户端处理能力跟不上时,降低图像质量。
# Adaptive quality — reduce JPEG quality when send buffer backs up
import cv2
import numpy as np
async def adaptive_camera_stream(websocket: WebSocket, ros_node: RobotBridgeNode):
quality = 80 # Start at 80% JPEG quality
send_times = []
while True:
image_bytes = ros_node.get_latest_image()
if image_bytes is None:
await asyncio.sleep(0.05)
continue
# Re-encode with adaptive quality if needed
if quality < 80:
np_arr = np.frombuffer(image_bytes, np.uint8)
img = cv2.imdecode(np_arr, cv2.IMREAD_COLOR)
_, image_bytes = cv2.imencode(
'.jpg', img, [cv2.IMWRITE_JPEG_QUALITY, quality])
image_bytes = image_bytes.tobytes()
t0 = time.monotonic()
await websocket.send_bytes(image_bytes)
send_duration = time.monotonic() - t0
# Track send times to detect backpressure
send_times.append(send_duration)
if len(send_times) > 30:
send_times.pop(0)
avg_send = sum(send_times) / len(send_times)
# If average send time > 50ms, reduce quality
if avg_send > 0.05 and quality > 20:
quality -= 5
elif avg_send < 0.02 and quality < 80:
quality += 5
await asyncio.sleep(1.0 / 15)
安全性
使用 Nginx 反向代理配置 TLS/HTTPS
切勿将机器人 Web 桥接直接暴露在不受信任的网络中。使用 nginx 作为终止 TLS 的反向代理。
# /etc/nginx/sites-available/robot-bridge
server {
listen 443 ssl;
server_name robot.example.com;
ssl_certificate /etc/ssl/certs/robot.pem;
ssl_certificate_key /etc/ssl/private/robot.key;
ssl_protocols TLSv1.2 TLSv1.3;
# REST API
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# WebSocket endpoints
location /ws/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400; # Keep WebSocket alive for 24h
}
# MJPEG video stream
location /video/ {
proxy_pass http://127.0.0.1:8080;
proxy_buffering off; # Critical for streaming
proxy_cache off;
}
}
基于令牌的身份验证(JWT)
# auth.py — JWT authentication for FastAPI robot bridge
import time
from typing import Optional
from fastapi import Request, HTTPException, WebSocket
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import jwt
SECRET_KEY = "load-from-environment-variable" # Use os.environ in production
ALGORITHM = "HS256"
TOKEN_EXPIRY_S = 3600 # 1 hour
class RobotAPIAuth(HTTPBearer):
"""JWT bearer token authentication for robot API endpoints."""
def __init__(self, auto_error: bool = True):
super().__init__(auto_error=auto_error)
async def __call__(self, request: Request) -> dict:
credentials: HTTPAuthorizationCredentials = await super().__call__(request)
if not credentials:
raise HTTPException(status_code=403, detail="No credentials provided")
return self._verify_token(credentials.credentials)
@staticmethod
def _verify_token(token: str) -> dict:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
if payload.get("exp", 0) < time.time():
raise HTTPException(status_code=401, detail="Token expired")
return payload
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
auth_scheme = RobotAPIAuth()
# Protect REST endpoints
@app.get("/api/robot/status", dependencies=[Depends(auth_scheme)])
async def protected_status():
return {"status": "active"}
# Protect WebSocket endpoints — check token from query parameter
async def verify_ws_token(websocket: WebSocket) -> Optional[dict]:
"""WebSocket cannot use Authorization header — use query param."""
token = websocket.query_params.get("token")
if not token:
await websocket.close(code=4001, reason="Missing token")
return None
try:
return RobotAPIAuth._verify_token(token)
except HTTPException:
await websocket.close(code=4003, reason="Invalid token")
return None
CORS 配置
# BAD: Allow all origins — any website can control your robot
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Any website can send requests
allow_methods=["*"], # Including DELETE, PATCH
allow_headers=["*"],
)
# GOOD: Explicit origins — only your dashboard can access the API
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://dashboard.example.com",
"https://monitor.internal.example.com",
],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT"],
allow_headers=["Authorization", "Content-Type"],
)
网络分段
机器人应运行在隔离网络中。Web 桥接是唯一一个同时连接机器人网络和面向用户网络的组件。
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Browser / │ │ Web Bridge │ │ ROS2 Nodes │
│ Dashboard │◄───►│ (FastAPI) │◄───►│ (DDS network) │
│ (user network) │:443 │ eth0: user net │ │ (robot VLAN) │
│ │ │ eth1: robot net │ │ │
└─────────────────┘ └──────────────────┘ └─────────────────┘
- Web 桥接有两个网络接口:一个面向用户(nginx TLS),另一个面向机器人 DDS 网络。
- ROS2 DDS 发现通过
ROS_DOMAIN_ID和 DDS 网络接口配置限制在机器人 VLAN 内。 - Web 桥接负责转换和过滤,绝不转发原始 DDS 流量。
通过 REST 管理 ROS2 参数
# parameter_api.py — Full CRUD for ROS2 parameters via HTTP
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
from pydantic import BaseModel
from typing import Any
import asyncio
router = APIRouter(prefix="/api/params", tags=["parameters"])
class ParamUpdate(BaseModel):
value: Any
@router.get("/")
async def list_parameters():
"""List all declared parameters on the bridge node."""
ros_node = app.state.ros_node
param_names = ros_node.get_parameters_by_prefix("")
return {
"parameters": [
{"name": name, "value": param.value}
for name, param in param_names.items()
]
}
@router.get("/{name}")
async def get_param(name: str):
"""Get a single parameter value."""
ros_node = app.state.ros_node
try:
param = ros_node.get_parameter(name)
return {"name": name, "value": param.value, "type": str(param.type_)}
except Exception:
raise HTTPException(404, f"Parameter '{name}' not found")
@router.put("/{name}")
async def set_param(name: str, body: ParamUpdate):
"""Set a parameter value. Notifies WebSocket subscribers."""
ros_node = app.state.ros_node
try:
old_value = ros_node.get_parameter(name).value
ros_node.set_parameters([
rclpy.parameter.Parameter(name, value=body.value)
])
# Notify WebSocket subscribers of the change
await param_broadcaster.notify(name, old_value, body.value)
return {"name": name, "old_value": old_value, "new_value": body.value}
except Exception as e:
raise HTTPException(400, str(e))
# Parameter change notifications via WebSocket
class ParamBroadcaster:
def __init__(self):
self.subscribers: set[WebSocket] = set()
async def notify(self, name: str, old_value: Any, new_value: Any):
dead = set()
for ws in self.subscribers.copy():
try:
await ws.send_json({
"type": "param_change",
"name": name,
"old_value": old_value,
"new_value": new_value,
})
except Exception:
dead.add(ws)
self.subscribers -= dead
async def handle_ws(self, websocket: WebSocket):
await websocket.accept()
self.subscribers.add(websocket)
try:
while True:
await websocket.receive_text() # Keep alive
except WebSocketDisconnect:
self.subscribers.discard(websocket)
param_broadcaster = ParamBroadcaster()
@router.websocket("/ws")
async def param_ws(websocket: WebSocket):
"""WebSocket endpoint for real-time parameter change notifications."""
await param_broadcaster.handle_ws(websocket)
Web 集成反模式
1. 在 HTTP 处理程序中阻塞 ROS2 执行器
问题: 在 HTTP 处理程序中调用 rclpy.spin_once() 或 rclpy.spin_until_future_complete() 会阻塞 Web 服务线程;如果 ROS2 执行器已在另一个线程中运行,还可能导致死锁。
解决方法: 在专用后台线程中运行 ROS2 执行器。通过线程安全的共享状态(使用锁保护的属性)访问数据。切勿在请求处理程序中调用 spin 函数。
# BAD: Spinning inside a Flask route
@app.route('/api/scan')
def get_scan():
rclpy.spin_once(node, timeout_sec=1.0) # Blocks the web server thread
return jsonify(node.latest_scan)
# GOOD: Executor spins in background, handler reads shared state
@app.route('/api/scan')
def get_scan():
return jsonify(node.get_latest_scan()) # Lock-protected read, non-blocking
2. 通过 WebSocket 传输原始图像消息
问题: 通过 WebSocket 发送原始 sensor_msgs/Image 数据(未压缩的 BGR8 格式,640x480 = 每帧 921,600 字节)会浪费带宽,并增加客户端 CPU 负担。Base64 编码会将其膨胀到每帧 1.2MB。
解决方法: 订阅 CompressedImage 话题(JPEG/PNG),或在服务端压缩后再发送。使用二进制 WebSocket 帧,而不是 base64 JSON。
# BAD: Subscribing to raw image and base64-encoding it
self.create_subscription(Image, '/camera/image_raw', self._raw_cb, 10)
# Each frame: 921,600 bytes raw -> 1,228,800 bytes base64 -> JSON overhead
# GOOD: Subscribe to compressed topic, send as binary WebSocket frame
self.create_subscription(
CompressedImage, '/camera/image/compressed', self._compressed_cb,
QoSProfile(reliability=ReliabilityPolicy.BEST_EFFORT, depth=1))
# Each frame: ~30,000-80,000 bytes JPEG, sent as binary
await websocket.send_bytes(compressed_image_bytes)
3. 传感器订阅没有速率限制
问题: 以 20Hz 发布、每次扫描包含 100K 个点的 LiDAR 会产生约 8MB/s 的数据。将每条消息转发给每个 WebSocket 客户端会占满网络带宽,并使浏览器不堪重负。
解决方案: 在服务端按客户端进行速率限制。使用令牌桶或简单的基于时间的节流。允许客户端请求所需的速率。
# BAD: Forward every message to every client
def _scan_cb(self, msg):
for client in self.clients:
client.send(serialize(msg)) # 20 msgs/s * N clients
# GOOD: Rate-limit per client
def _scan_cb(self, msg):
with self._lock:
self._latest_scan = msg # Just store latest
async def stream_to_client(self, ws, max_hz=5):
interval = 1.0 / max_hz
while True:
scan = self.get_latest_scan()
if scan:
await ws.send_json(scan)
await asyncio.sleep(interval)
4. 在生产环境中运行 rosbridge 却没有身份验证
问题: 使用默认设置的 rosbridge_suite 会向任何 WebSocket 客户端开放所有主题、服务和参数。网络中的任何浏览器都可以向 /cmd_vel 发布消息,或调用 /emergency_stop。
解决方案: 在生产环境中,使用带身份验证的自定义桥接服务。如果必须使用 rosbridge,请启用 rosauth,通过过滤器限制主题,并在经过身份验证的反向代理后运行。
# BAD: Default rosbridge launch — full access to everything
ros2 launch rosbridge_server rosbridge_websocket_launch.xml
# GOOD: At minimum, enable authentication and use a reverse proxy
ros2 launch rosbridge_server rosbridge_websocket_launch.xml \
authenticate:=true \
topics_glob:="['/camera/image/compressed', '/odom', '/cmd_vel']"
5. 在异步处理程序中进行同步服务调用
问题: 在 FastAPI 的 async def 处理程序中调用 service_client.call(request)(同步调用),会阻塞事件循环,直到服务返回之前,所有其他请求都会冻结。
解决方案: 使用 call_async(),并通过 asyncio.get_event_loop().run_in_executor() 等待 future,或使用专用线程池。
# BAD: Synchronous service call blocks the async event loop
@app.post("/api/navigate")
async def navigate(goal: NavGoal):
response = nav_client.call(goal_request) # Blocks entire event loop
return {"result": response.result}
# GOOD: Async service call with executor bridge
@app.post("/api/navigate")
async def navigate(goal: NavGoal):
future = nav_client.call_async(goal_request)
response = await asyncio.get_event_loop().run_in_executor(
None, lambda: future.result(timeout=30.0)
)
return {"result": response.result}
6. Web 服务器与 ROS2 Spinner 之间的 GIL 争用
问题: 使用多个工作线程运行 uvicorn,并在另一个线程中运行 rclpy.spin,会导致 GIL 争用。在高负载下,Web 服务器和 ROS2 回调会相互阻塞,导致消息丢失并增加延迟。
解决方案: 使用线程数较少(2-4)的 MultiThreadedExecutor。对于高吞吐量系统,在单独的进程中运行 ROS2 节点,并通过共享内存、Redis 或 Unix 套接字进行通信。
# BAD: SingleThreadedExecutor competing with uvicorn for the GIL
executor = SingleThreadedExecutor()
executor.add_node(node)
threading.Thread(target=executor.spin).start()
uvicorn.run(app, workers=4) # 4 workers + 1 spinner = GIL contention
# GOOD: MultiThreadedExecutor with limited threads, single uvicorn worker
executor = MultiThreadedExecutor(num_threads=2)
executor.add_node(node)
threading.Thread(target=executor.spin, daemon=True).start()
uvicorn.run(app, workers=1, host="0.0.0.0", port=8080)
7. 未管理连接生命周期
问题: WebSocket 客户端可能在未发送关闭帧的情况下断开连接(例如关闭浏览器标签页或网络中断)。服务器仍会向失效连接发送数据,浪费 CPU 和内存。随着时间推移,失效客户端集合会无限增长。
解决方案: 使用集合跟踪已连接的客户端,在断开连接时将其移除,并定期通过心跳检查清理失效连接。
# BAD: No tracking of client lifecycle
clients = []
@app.websocket("/ws/data")
async def data_ws(ws: WebSocket):
await ws.accept()
clients.append(ws)
while True:
await ws.send_json(get_data()) # Throws on dead client, never removed
# GOOD: Proper lifecycle management with cleanup
clients: set[WebSocket] = set()
@app.websocket("/ws/data")
async def data_ws(ws: WebSocket):
await ws.accept()
clients.add(ws)
try:
while True:
# Wait for client messages (ping/pong keepalive)
await ws.receive_text()
except WebSocketDisconnect:
pass
finally:
clients.discard(ws)
async def broadcast(data: dict):
dead = set()
for client in clients.copy():
try:
await client.send_json(data)
except Exception:
dead.add(client)
clients -= dead
8. 无条件公开所有主题
问题: Web 桥接服务会订阅 ROS2 图中的每个主题,并向 Web 客户端开放所有主题。这会泄露内部系统细节(诊断信息、调试主题),浪费带宽并造成安全风险。
解决方案: 明确维护需要公开的主题允许列表。从配置中加载该列表。拒绝对列表中不存在的主题的请求。
# BAD: Dynamically subscribe to whatever the client requests
@app.websocket("/ws/topic/{topic_name}")
async def any_topic(ws: WebSocket, topic_name: str):
# Client can request /rosout, /parameter_events, /diagnostics, etc.
sub = node.create_subscription(String, topic_name, callback, 10)
# GOOD: Allowlist of exposed topics from configuration
ALLOWED_TOPICS = {
"/camera/image/compressed": CompressedImage,
"/odom": Odometry,
"/battery_state": BatteryState,
"/cmd_vel": Twist,
}
@app.websocket("/ws/topic/{topic_name:path}")
async def allowed_topic(ws: WebSocket, topic_name: str):
topic_path = "/" + topic_name
if topic_path not in ALLOWED_TOPICS:
await ws.close(code=4004, reason=f"Topic '{topic_path}' not in allowlist")
return
msg_type = ALLOWED_TOPICS[topic_path]
# Proceed with subscription
Web 集成检查清单
- 线程隔离:rclpy 执行器在专用后台线程中运行;Web 服务器在主线程或自己的线程中运行。它们绝不共用事件循环。
- 共享状态受锁保护:HTTP 处理程序读取、ROS2 回调写入的每项数据都由
threading.Lock()保护。跨线程访问时不得直接读取或写入属性。 - 协调实现平稳关停:信号处理程序设置关停事件,在调用
rclpy.shutdown()之前停止执行器,并在加入 spin 线程时设置超时。 - 强制执行主题允许列表:仅向 Web 客户端公开明确列出的主题。允许列表从配置文件加载,不得硬编码。
- 限制所有数据流的速率:每个 WebSocket 数据流都设有针对单个客户端的速率限制器(令牌桶或基于时间的限制器)。客户端可以请求更低的速率,但不能超过服务器的上限。
- 命令超时监控:任何接受速度或运动命令的端点,如果在 500ms 内未收到命令,都会发布零速度,以防机器人在断开连接后失控。
- 传输前压缩视频:摄像头画面使用
CompressedImage主题或由服务器进行 JPEG 编码。绝不向 Web 客户端转发原始Image消息。 - 在桥接服务前终止 TLS:使用 nginx 或 caddy 反向代理处理 TLS。桥接服务本身仅监听 localhost。正确代理 WebSocket 升级请求头。
- 对所有变更端点进行身份验证:POST、PUT、DELETE 端点和遥操作 WebSocket 连接都需要有效的 JWT 或 API 密钥。在专用网络上,只读状态端点可以不进行身份验证。
- CORS 限制来源:
allow_origins列出特定的仪表板 URL。生产环境中绝不使用通配符*。 - 管理连接生命周期:在一个集合中跟踪 WebSocket 客户端,断开连接时将其移除,并定期清理。绝不保留失效的客户端引用。
- 高带宽数据使用二进制 WebSocket:图像帧、点云和其他二进制数据使用
send_bytes(),而非经过 base64 编码的 JSON。这样可以为两端节省约 33% 的带宽和 CPU。