{"id":"d9a3db82-5c65-422e-8bba-0976c2fa1199","shortId":"JePNn6","kind":"skill","title":"ros2-web-integration","tagline":"Patterns and best practices for integrating ROS2 systems with web technologies including REST APIs, WebSocket bridges, and browser-based robot interfaces. Use this skill when building web dashboards for robots, streaming camera feeds to browsers, exposing ROS2 services as REST en","description":"# ROS2 Web Integration Skill\n\n## When to Use This Skill\n- Building a web dashboard to monitor or control a robot running ROS2\n- Streaming camera feeds (MJPEG, WebRTC, compressed WebSocket) from a robot to a browser\n- Exposing ROS2 services and actions as REST API endpoints\n- Implementing bidirectional WebSocket communication between a web UI and ROS2 nodes\n- Setting up rosbridge_suite for quick prototyping or foxglove integration\n- Writing a custom FastAPI or Flask bridge to ROS2 for production deployments\n- Adding authentication, rate limiting, or CORS to robot web interfaces\n- Running an async web server (uvicorn) alongside the rclpy executor without deadlocks\n- Publishing teleop commands from a browser joystick to cmd_vel\n- Serving ROS2 parameter configuration pages or diagnostic dashboards over HTTP\n\n## Architecture Overview\n\n### Comparison Table\n\n| Feature | rosbridge_suite | Custom FastAPI Bridge | Custom Flask Bridge |\n|---|---|---|---|\n| Latency | ~5-15ms (WebSocket) | ~2-5ms (WebSocket), ~10-30ms (REST) | ~10-50ms (REST only without extensions) |\n| Throughput | Medium (JSON serialization overhead) | High (binary WebSocket, async) | Low-Medium (sync, GIL-bound) |\n| Auth | Basic (rosauth, limited) | Full (JWT, OAuth2, API keys) | Full (Flask-Login, JWT) |\n| Complexity | Low (launch and connect) | Medium (must manage two event loops) | Medium (must manage threading) |\n| Video Streaming | Requires separate web_video_server | Native (MJPEG, WebSocket binary) | MJPEG via generator responses |\n| Production Ready | No (exposes full topic graph) | Yes | Yes (with gunicorn) |\n| When to Use | Prototyping, foxglove, quick demos | Production APIs, high-perf streaming | Simple internal tools, legacy systems |\n\n### When to Use rosbridge vs Custom Bridge\n\nUse **rosbridge_suite** when:\n- You need a working bridge in under 10 minutes\n- The client is foxglove, webviz, or another rosbridge-aware tool\n- Security is not a concern (local network, demo environment)\n- You do not need custom business logic between web and ROS2\n\nUse a **custom bridge** (FastAPI/Flask) when:\n- You need authentication, authorization, or rate limiting\n- You want to expose only specific topics/services (not the entire ROS2 graph)\n- You need to transform or aggregate data before sending to the client\n- You need REST endpoints for integration with non-WebSocket clients\n- You are streaming video and need control over encoding and quality\n- The system is deployed in production or on a public network\n\n## Pattern 1: rosbridge_suite\n\n### Installation and Launch\n\n```bash\n# Install rosbridge_suite\nsudo apt install ros-${ROS_DISTRO}-rosbridge-suite\n\n# Launch with default settings (port 9090)\nros2 launch rosbridge_server rosbridge_websocket_launch.xml\n\n# Launch with custom port and SSL\nros2 launch rosbridge_server rosbridge_websocket_launch.xml \\\n    port:=9091 \\\n    ssl:=true \\\n    certfile:=/etc/ssl/certs/robot.pem \\\n    keyfile:=/etc/ssl/private/robot.key\n\n# Launch with authentication (rosauth)\nros2 launch rosbridge_server rosbridge_websocket_launch.xml \\\n    authenticate:=true\n```\n\n### JavaScript Client (roslibjs)\n\n```javascript\n// Connect to rosbridge WebSocket\nconst ros = new ROSLIB.Ros({ url: 'ws://robot-host:9090' });\n\nros.on('connection', () => console.log('Connected to rosbridge'));\nros.on('error', (err) => console.error('Connection error:', err));\nros.on('close', () => console.log('Connection closed'));\n\n// Subscribe to compressed camera images\nconst imageTopic = new ROSLIB.Topic({\n  ros: ros,\n  name: '/camera/image/compressed',\n  messageType: 'sensor_msgs/msg/CompressedImage',\n  // Throttle to 10 Hz to avoid flooding the browser\n  throttle_rate: 100,\n  // Queue size of 1 — drop stale frames\n  queue_size: 1\n});\n\nimageTopic.subscribe((msg) => {\n  // msg.data is base64-encoded JPEG\n  const imgElement = document.getElementById('camera-feed');\n  imgElement.src = 'data:image/jpeg;base64,' + msg.data;\n});\n\n// Call a ROS2 service\nconst getMapSrv = new ROSLIB.Service({\n  ros: ros,\n  name: '/map_server/map',\n  serviceType: 'nav_msgs/srv/GetMap'\n});\n\ngetMapSrv.callService(new ROSLIB.ServiceRequest({}), (result) => {\n  console.log('Map received:', result.map.info.width, 'x', result.map.info.height);\n}, (error) => {\n  console.error('Service call failed:', error);\n});\n\n// Publish velocity commands from a virtual joystick\nconst cmdVelTopic = new ROSLIB.Topic({\n  ros: ros,\n  name: '/cmd_vel',\n  messageType: 'geometry_msgs/msg/Twist'\n});\n\nfunction sendVelocity(linearX, angularZ) {\n  const twist = new ROSLIB.Message({\n    linear: { x: linearX, y: 0.0, z: 0.0 },\n    angular: { x: 0.0, y: 0.0, z: angularZ }\n  });\n  cmdVelTopic.publish(twist);\n}\n\n// Publish at 10 Hz while joystick is active; stop on release\nlet joystickInterval = null;\nfunction onJoystickMove(lx, az) {\n  if (!joystickInterval) {\n    joystickInterval = setInterval(() => sendVelocity(lx, az), 100);\n  }\n}\nfunction onJoystickRelease() {\n  clearInterval(joystickInterval);\n  joystickInterval = null;\n  sendVelocity(0.0, 0.0);  // Always send zero on release\n}\n```\n\n### Limitations and Performance\n- **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.\n- **No topic filtering**: By default rosbridge exposes every topic, service, and action on the ROS2 graph. Any connected client can publish to `/cmd_vel`.\n- **Single-threaded event loop**: rosbridge_server uses a single Tornado event loop. High-frequency subscriptions from multiple clients can starve the loop.\n- **No built-in rate limiting**: Clients can subscribe at any rate. A misbehaving client subscribing to a 30Hz point cloud will consume the server.\n- **Authentication is minimal**: rosauth uses MAC-based tokens with shared secrets. It does not support JWT, OAuth2, or role-based access.\n\n## Pattern 2: Custom FastAPI Bridge\n\n### Project Structure\n\n```\nrobot_web_bridge/\n├── robot_web_bridge/\n│   ├── __init__.py\n│   ├── ros_node.py          # ROS2 node with shared state\n│   ├── web_app.py           # FastAPI application\n│   ├── main.py              # Entry point: starts both rclpy and uvicorn\n│   ├── auth.py              # JWT authentication middleware\n│   └── rate_limiter.py      # Token bucket rate limiter\n├── config/\n│   └── bridge_config.yaml   # Allowed topics, rate limits, auth keys\n├── launch/\n│   └── web_bridge.launch.py\n├── package.xml\n├── setup.py\n└── setup.cfg\n```\n\n### ROS2 Node with Async Executor\n\n```python\n# ros_node.py\nimport threading\nimport time\nfrom typing import Optional\n\nimport rclpy\nfrom rclpy.node import Node\nfrom rclpy.executors import MultiThreadedExecutor\nfrom rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy\nfrom sensor_msgs.msg import CompressedImage\nfrom geometry_msgs.msg import Twist\nfrom nav_msgs.msg import Odometry\nfrom std_srvs.srv import Trigger\n\n\nclass RobotBridgeNode(Node):\n    \"\"\"ROS2 node that exposes topic data via thread-safe shared state.\"\"\"\n\n    def __init__(self):\n        super().__init__('web_bridge_node')\n\n        # Thread-safe shared state for latest messages\n        self._lock = threading.Lock()\n        self._latest_image: Optional[bytes] = None\n        self._latest_odom: Optional[dict] = None\n        self._image_timestamp: float = 0.0\n\n        # QoS for sensor data — best effort, keep last 1\n        sensor_qos = QoSProfile(\n            reliability=ReliabilityPolicy.BEST_EFFORT,\n            history=HistoryPolicy.KEEP_LAST,\n            depth=1\n        )\n\n        # Subscribers\n        self.create_subscription(\n            CompressedImage, '/camera/image/compressed',\n            self._image_cb, sensor_qos)\n        self.create_subscription(\n            Odometry, '/odom', self._odom_cb, sensor_qos)\n\n        # Publisher for velocity commands\n        self.cmd_vel_pub = self.create_publisher(Twist, '/cmd_vel', 10)\n\n        # Service client for emergency stop\n        self.estop_client = self.create_client(Trigger, '/emergency_stop')\n\n        self.get_logger().info('Web bridge node initialized')\n\n    def _image_cb(self, msg: CompressedImage):\n        with self._lock:\n            self._latest_image = bytes(msg.data)\n            self._image_timestamp = time.monotonic()\n\n    def _odom_cb(self, msg: Odometry):\n        with self._lock:\n            self._latest_odom = {\n                'x': msg.pose.pose.position.x,\n                'y': msg.pose.pose.position.y,\n                'theta': 2.0 * __import__('math').atan2(\n                    msg.pose.pose.orientation.z,\n                    msg.pose.pose.orientation.w),\n                'linear_vel': msg.twist.twist.linear.x,\n                'angular_vel': msg.twist.twist.angular.z,\n            }\n\n    def get_latest_image(self) -> Optional[bytes]:\n        with self._lock:\n            return self._latest_image\n\n    def get_latest_odom(self) -> Optional[dict]:\n        with self._lock:\n            return self._latest_odom.copy() if self._latest_odom else None\n\n    def publish_cmd_vel(self, linear_x: float, angular_z: float):\n        msg = Twist()\n        msg.linear.x = float(linear_x)\n        msg.angular.z = float(angular_z)\n        self.cmd_vel_pub.publish(msg)\n```\n\n### FastAPI App with ROS2 Integration\n\n```python\n# web_app.py\nimport base64\nimport asyncio\nimport time\nfrom typing import Optional\n\nfrom fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Depends\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom pydantic import BaseModel, Field\n\nfrom .ros_node import RobotBridgeNode\n\n\nclass CmdVelRequest(BaseModel):\n    linear_x: float = Field(ge=-1.0, le=1.0, description=\"Linear velocity m/s\")\n    angular_z: float = Field(ge=-2.0, le=2.0, description=\"Angular velocity rad/s\")\n\n\ndef create_app(ros_node: RobotBridgeNode) -> FastAPI:\n    app = FastAPI(title=\"Robot Web Bridge\", version=\"1.0.0\")\n\n    # CORS — restrict to known origins in production\n    app.add_middleware(\n        CORSMiddleware,\n        allow_origins=[\"https://dashboard.example.com\"],\n        allow_credentials=True,\n        allow_methods=[\"GET\", \"POST\", \"PUT\"],\n        allow_headers=[\"Authorization\", \"Content-Type\"],\n    )\n\n    # Store ros_node in app state so endpoints can access it\n    app.state.ros_node = ros_node\n\n    return app\n```\n\n### WebSocket Endpoint for Streaming\n\n```python\n# Add to web_app.py — WebSocket camera streaming endpoint\n\n@app.websocket(\"/ws/camera\")\nasync def camera_stream(websocket: WebSocket):\n    \"\"\"Stream compressed camera images as base64 over WebSocket.\n\n    Supports per-client rate limiting via query parameter:\n        ws://host/ws/camera?max_fps=10\n    \"\"\"\n    await websocket.accept()\n    ros_node: RobotBridgeNode = websocket.app.state.ros_node\n\n    # Per-client rate limiting\n    max_fps = int(websocket.query_params.get(\"max_fps\", \"15\"))\n    min_interval = 1.0 / max(1, min(max_fps, 30))  # Clamp 1-30 FPS\n    last_send_time = 0.0\n    last_image_bytes: Optional[bytes] = None\n\n    try:\n        while True:\n            now = time.monotonic()\n            elapsed = now - last_send_time\n\n            if elapsed < min_interval:\n                await asyncio.sleep(min_interval - elapsed)\n                continue\n\n            image_bytes = ros_node.get_latest_image()\n            if image_bytes is None or image_bytes is last_image_bytes:\n                # No new image available — avoid sending duplicates\n                await asyncio.sleep(0.01)\n                continue\n\n            last_image_bytes = image_bytes\n            last_send_time = time.monotonic()\n\n            # Send as base64 JSON for browser compatibility\n            b64_data = base64.b64encode(image_bytes).decode('ascii')\n            await websocket.send_json({\n                \"type\": \"image\",\n                \"format\": \"jpeg\",\n                \"data\": b64_data,\n                \"timestamp\": last_send_time,\n            })\n    except WebSocketDisconnect:\n        pass  # Client disconnected — clean exit\n    except Exception as e:\n        ros_node.get_logger().warn(f'WebSocket error: {e}')\n    finally:\n        # Graceful disconnect — no cleanup needed for read-only stream\n        try:\n            await websocket.close()\n        except RuntimeError:\n            pass  # Already closed\n```\n\n### REST Endpoints Wrapping ROS2 Services\n\n```python\n# Add to web_app.py — REST endpoints\n\n@app.get(\"/api/robot/status\")\nasync def get_robot_status():\n    \"\"\"Return current robot odometry and system status.\"\"\"\n    ros_node: RobotBridgeNode = app.state.ros_node\n    odom = ros_node.get_latest_odom()\n    if odom is None:\n        raise HTTPException(status_code=503, detail=\"No odometry data available yet\")\n    return {\n        \"status\": \"active\",\n        \"odometry\": odom,\n        \"timestamp\": time.time(),\n    }\n\n\n@app.post(\"/api/robot/cmd_vel\")\nasync def post_cmd_vel(cmd: CmdVelRequest):\n    \"\"\"Send a velocity command to the robot.\"\"\"\n    ros_node: RobotBridgeNode = app.state.ros_node\n    ros_node.publish_cmd_vel(cmd.linear_x, cmd.angular_z)\n    return {\"status\": \"ok\", \"linear_x\": cmd.linear_x, \"angular_z\": cmd.angular_z}\n\n\n@app.get(\"/api/robot/params/{param_name}\")\nasync def get_parameter(param_name: str):\n    \"\"\"Read a ROS2 parameter from the bridge node.\"\"\"\n    ros_node: RobotBridgeNode = app.state.ros_node\n    try:\n        param = ros_node.get_parameter(param_name)\n        return {\"name\": param_name, \"value\": param.value}\n    except rclpy.exceptions.ParameterNotDeclaredException:\n        raise HTTPException(status_code=404, detail=f\"Parameter '{param_name}' not declared\")\n\n\n@app.put(\"/api/robot/params/{param_name}\")\nasync def set_parameter(param_name: str, value: dict):\n    \"\"\"Set a ROS2 parameter on the bridge node.\n\n    Body: {\"value\": <new_value>}\n    \"\"\"\n    ros_node: RobotBridgeNode = app.state.ros_node\n    try:\n        param_value = value.get(\"value\")\n        if param_value is None:\n            raise HTTPException(status_code=400, detail=\"Missing 'value' field\")\n        ros_node.set_parameters([rclpy.Parameter(param_name, value=param_value)])\n        return {\"name\": param_name, \"value\": param_value, \"status\": \"updated\"}\n    except rclpy.exceptions.ParameterNotDeclaredException:\n        raise HTTPException(status_code=404, detail=f\"Parameter '{param_name}' not declared\")\n\n\n@app.post(\"/api/robot/emergency_stop\")\nasync def emergency_stop():\n    \"\"\"Call the emergency stop service.\"\"\"\n    ros_node: RobotBridgeNode = app.state.ros_node\n    if not ros_node.estop_client.service_is_ready():\n        raise HTTPException(status_code=503, detail=\"Emergency stop service not available\")\n    future = ros_node.estop_client.call_async(Trigger.Request())\n    # Wait for result with timeout — run in executor to avoid blocking\n    result = await asyncio.get_event_loop().run_in_executor(\n        None, lambda: future.result(timeout=5.0)\n    )\n    return {\"success\": result.success, \"message\": result.message}\n```\n\n### Running FastAPI + rclpy Together\n\nThis is the critical integration point. Uvicorn runs in the main thread, rclpy spins in a background thread, and shutdown is coordinated via signals.\n\n```python\n# main.py\nimport signal\nimport sys\nimport threading\n\nimport rclpy\nfrom rclpy.executors import MultiThreadedExecutor\nimport uvicorn\n\nfrom .ros_node import RobotBridgeNode\nfrom .web_app import create_app\n\n\ndef main():\n    rclpy.init()\n    ros_node = RobotBridgeNode()\n    app = create_app(ros_node)\n\n    # Spin rclpy in a background thread with a multi-threaded executor\n    executor = MultiThreadedExecutor(num_threads=2)\n    executor.add_node(ros_node)\n    spin_thread = threading.Thread(target=executor.spin, daemon=True)\n    spin_thread.start()\n\n    # Shutdown coordination\n    shutdown_event = threading.Event()\n\n    def shutdown_handler(signum, frame):\n        ros_node.get_logger().info('Shutdown signal received')\n        shutdown_event.set()\n        # Stop uvicorn by raising KeyboardInterrupt in main thread\n        raise KeyboardInterrupt\n\n    signal.signal(signal.SIGINT, shutdown_handler)\n    signal.signal(signal.SIGTERM, shutdown_handler)\n\n    try:\n        # Run uvicorn in the main thread\n        uvicorn.run(\n            app,\n            host=\"0.0.0.0\",\n            port=8080,\n            log_level=\"info\",\n            # Do NOT use reload in production with rclpy\n            reload=False,\n        )\n    except KeyboardInterrupt:\n        pass\n    finally:\n        ros_node.get_logger().info('Shutting down web bridge...')\n        executor.shutdown()\n        ros_node.destroy_node()\n        rclpy.shutdown()\n        spin_thread.join(timeout=5.0)\n\n\nif __name__ == '__main__':\n    main()\n```\n\n## Pattern 3: Flask Bridge\n\n### Flask with rclpy Threading\n\nFlask 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.\n\n```python\n# BAD: Blocking — rclpy.spin() never returns, Flask never starts\nimport rclpy\nfrom flask import Flask, jsonify\n\napp = Flask(__name__)\n\ndef bad_main():\n    rclpy.init()\n    node = rclpy.create_node('flask_bridge')\n    rclpy.spin(node)  # Blocks forever — Flask never starts\n    app.run(host='0.0.0.0', port=8080)\n```\n\n```python\n# GOOD: Threaded executor — rclpy spins in background, Flask serves in main thread\nimport threading\nimport rclpy\nfrom rclpy.executors import MultiThreadedExecutor\nfrom flask import Flask, jsonify\n\napp = Flask(__name__)\nros_node = None\n\nclass SimpleRosNode(rclpy.node.Node):\n    def __init__(self):\n        super().__init__('flask_bridge')\n        self._lock = threading.Lock()\n        self._data = {}\n        self.create_subscription(\n            Odometry, '/odom', self._odom_cb,\n            QoSProfile(reliability=ReliabilityPolicy.BEST_EFFORT, depth=1))\n\n    def _odom_cb(self, msg):\n        with self._lock:\n            self._data['x'] = msg.pose.pose.position.x\n            self._data['y'] = msg.pose.pose.position.y\n\n    def get_data(self):\n        with self._lock:\n            return self._data.copy()\n\n@app.route('/api/status')\ndef status():\n    return jsonify(ros_node.get_data())\n\ndef main():\n    global ros_node\n    rclpy.init()\n    ros_node = SimpleRosNode()\n\n    executor = MultiThreadedExecutor()\n    executor.add_node(ros_node)\n    spin_thread = threading.Thread(target=executor.spin, daemon=True)\n    spin_thread.start()\n\n    try:\n        app.run(host='0.0.0.0', port=8080, threaded=True)\n    finally:\n        executor.shutdown()\n        ros_node.destroy_node()\n        rclpy.shutdown()\n```\n\n### When Flask Is Enough vs When You Need FastAPI\n\nUse **Flask** when:\n- You only need simple REST endpoints (no WebSocket)\n- The web bridge is an internal tool with few concurrent users\n- Your team is already familiar with Flask and not ready to adopt async\n- You do not need OpenAPI/Swagger documentation auto-generation\n\nUse **FastAPI** when:\n- You need WebSocket endpoints for real-time streaming\n- You need high concurrency (async handlers, many simultaneous clients)\n- You want automatic request validation via Pydantic models\n- You want auto-generated OpenAPI docs for the robot API\n- You are streaming video or sensor data to multiple clients\n\n## Video Streaming Patterns\n\n### MJPEG Streaming\n\nMJPEG streams work in `<img>` tags natively with no JavaScript needed. Useful for simple dashboards.\n\n```python\n# mjpeg_stream.py — MJPEG streaming endpoint for FastAPI\nimport cv2\nimport time\nfrom fastapi import FastAPI\nfrom fastapi.responses import StreamingResponse\nfrom .ros_node import RobotBridgeNode\n\n\ndef generate_mjpeg(ros_node: RobotBridgeNode, max_fps: int = 15):\n    \"\"\"Generator that yields MJPEG frames as multipart HTTP response chunks.\"\"\"\n    min_interval = 1.0 / max_fps\n    last_send = 0.0\n\n    while True:\n        now = time.monotonic()\n        if now - last_send < min_interval:\n            time.sleep(min_interval - (now - last_send))\n            continue\n\n        image_bytes = ros_node.get_latest_image()\n        if image_bytes is None:\n            time.sleep(0.05)\n            continue\n\n        last_send = time.monotonic()\n        # Yield as multipart MJPEG frame\n        yield (\n            b'--frame\\r\\n'\n            b'Content-Type: image/jpeg\\r\\n'\n            b'Content-Length: ' + str(len(image_bytes)).encode() + b'\\r\\n'\n            b'\\r\\n' + image_bytes + b'\\r\\n'\n        )\n\n\n@app.get(\"/video/mjpeg\")\nasync def mjpeg_feed():\n    ros_node: RobotBridgeNode = app.state.ros_node\n    return StreamingResponse(\n        generate_mjpeg(ros_node, max_fps=15),\n        media_type=\"multipart/x-mixed-replace; boundary=frame\"\n    )\n```\n\nBrowser usage — no JavaScript required:\n\n```html\n<img src=\"http://robot-host:8080/video/mjpeg\" alt=\"Robot Camera\" />\n```\n\n### WebRTC via webrtc_ros\n\nFor low-latency, high-quality video streaming, use the `webrtc_ros` package.\n\n```yaml\n# webrtc_ros launch config\n# webrtc_bridge.launch.py\nfrom launch import LaunchDescription\nfrom launch_ros.actions import Node\n\ndef generate_launch_description():\n    return LaunchDescription([\n        Node(\n            package='webrtc_ros',\n            executable='webrtc_ros_server_node',\n            name='webrtc_server',\n            parameters=[{\n                'port': 8443,\n                'image_transport': 'compressed',\n                # Bind to all interfaces for remote access\n                'address': '0.0.0.0',\n            }],\n            remappings=[\n                ('image', '/camera/image_raw'),\n            ],\n        ),\n    ])\n```\n\n### Compressed Topic Streaming via WebSocket\n\nFor a balance between simplicity and performance, stream compressed image topics over a binary WebSocket.\n\n```python\n# Binary WebSocket streaming — more efficient than base64 JSON\n@app.websocket(\"/ws/camera/binary\")\nasync def camera_stream_binary(websocket: WebSocket):\n    \"\"\"Stream JPEG frames as binary WebSocket messages.\n\n    ~30% more bandwidth-efficient than base64 JSON encoding.\n    Client must handle raw binary blobs.\n    \"\"\"\n    await websocket.accept()\n    ros_node: RobotBridgeNode = websocket.app.state.ros_node\n    min_interval = 1.0 / 15  # 15 FPS max\n\n    try:\n        last_bytes = None\n        while True:\n            image_bytes = ros_node.get_latest_image()\n            if image_bytes is not None and image_bytes is not last_bytes:\n                last_bytes = image_bytes\n                await websocket.send_bytes(image_bytes)\n            await asyncio.sleep(min_interval)\n    except WebSocketDisconnect:\n        pass\n```\n\nClient-side JavaScript:\n\n```javascript\nconst ws = new WebSocket('ws://robot-host:8080/ws/camera/binary');\nws.binaryType = 'arraybuffer';\n\nws.onmessage = (event) => {\n  const blob = new Blob([event.data], { type: 'image/jpeg' });\n  const url = URL.createObjectURL(blob);\n  const img = document.getElementById('camera-feed');\n  // Revoke previous URL to prevent memory leaks\n  if (img.src.startsWith('blob:')) URL.revokeObjectURL(img.src);\n  img.src = url;\n};\n```\n\n## Bidirectional Communication\n\n### Web UI to Robot Commands\n\n```python\n# teleop_handler.py — WebSocket teleop with command timeout watchdog\nimport asyncio\nimport time\nfrom fastapi import WebSocket, WebSocketDisconnect\n\nfrom .ros_node import RobotBridgeNode\n\n\nclass TeleopHandler:\n    \"\"\"Handles joystick input from browser with safety watchdog.\n\n    If no command is received for 500ms, publishes zero velocity\n    to prevent the robot from running away on disconnect.\n    \"\"\"\n\n    COMMAND_TIMEOUT_S = 0.5  # Zero velocity after 500ms silence\n\n    def __init__(self, ros_node: RobotBridgeNode):\n        self.ros_node = ros_node\n        self.last_command_time = 0.0\n\n    async def handle(self, websocket: WebSocket):\n        await websocket.accept()\n        self.last_command_time = time.monotonic()\n\n        # Start watchdog task\n        watchdog_task = asyncio.create_task(self._watchdog())\n\n        try:\n            while True:\n                data = await websocket.receive_json()\n                # Expected: {\"linear_x\": 0.5, \"angular_z\": -0.3}\n                linear_x = float(data.get(\"linear_x\", 0.0))\n                angular_z = float(data.get(\"angular_z\", 0.0))\n\n                # Clamp values for safety\n                linear_x = max(-1.0, min(1.0, linear_x))\n                angular_z = max(-2.0, min(2.0, angular_z))\n\n                self.ros_node.publish_cmd_vel(linear_x, angular_z)\n                self.last_command_time = time.monotonic()\n\n                # Acknowledge to client\n                await websocket.send_json({\"ack\": True, \"t\": self.last_command_time})\n        except WebSocketDisconnect:\n            pass\n        finally:\n            watchdog_task.cancel()\n            # Always send zero on disconnect\n            self.ros_node.publish_cmd_vel(0.0, 0.0)\n\n    async def _watchdog(self):\n        \"\"\"Publish zero velocity if no command received within timeout.\"\"\"\n        while True:\n            await asyncio.sleep(0.1)\n            if time.monotonic() - self.last_command_time > self.COMMAND_TIMEOUT_S:\n                self.ros_node.publish_cmd_vel(0.0, 0.0)\n```\n\n### Robot Status to Web UI\n\n```python\n# Status broadcast — push robot state to all connected WebSocket clients\nclass StatusBroadcaster:\n    \"\"\"Broadcasts robot status to all connected WebSocket clients.\"\"\"\n\n    def __init__(self, ros_node: RobotBridgeNode):\n        self.ros_node = ros_node\n        self.clients: set[WebSocket] = set()\n\n    async def register(self, websocket: WebSocket):\n        await websocket.accept()\n        self.clients.add(websocket)\n        try:\n            # Keep connection alive — client sends pings\n            while True:\n                await websocket.receive_text()\n        except WebSocketDisconnect:\n            self.clients.discard(websocket)\n\n    async def broadcast_loop(self, interval: float = 0.1):\n        \"\"\"Call this as a background task on app startup.\"\"\"\n        while True:\n            odom = self.ros_node.get_latest_odom()\n            if odom and self.clients:\n                dead_clients = set()\n                for client in self.clients.copy():\n                    try:\n                        await client.send_json({\"type\": \"status\", \"odom\": odom})\n                    except Exception:\n                        dead_clients.add(client)\n                self.clients -= dead_clients\n            await asyncio.sleep(interval)\n```\n\n### Command Acknowledgment Pattern\n\nFor reliable command execution, use a request-response pattern over WebSocket with correlation IDs.\n\n```python\n# Client sends:  {\"id\": \"cmd-001\", \"action\": \"navigate_to\", \"x\": 1.0, \"y\": 2.0}\n# Server responds: {\"id\": \"cmd-001\", \"status\": \"accepted\", \"estimated_time\": 12.5}\n# Server updates: {\"id\": \"cmd-001\", \"status\": \"in_progress\", \"progress\": 0.45}\n# Server completes: {\"id\": \"cmd-001\", \"status\": \"completed\", \"result\": \"success\"}\n\n@app.websocket(\"/ws/commands\")\nasync def command_channel(websocket: WebSocket):\n    await websocket.accept()\n    ros_node: RobotBridgeNode = websocket.app.state.ros_node\n\n    while True:\n        try:\n            data = await websocket.receive_json()\n            cmd_id = data.get(\"id\", \"unknown\")\n            action = data.get(\"action\")\n\n            if action == \"navigate_to\":\n                await websocket.send_json({\n                    \"id\": cmd_id, \"status\": \"accepted\"\n                })\n                # Dispatch to ROS2 action client (non-blocking)\n                asyncio.create_task(\n                    execute_navigation(ros_node, websocket, cmd_id,\n                                       data[\"x\"], data[\"y\"])\n                )\n            else:\n                await websocket.send_json({\n                    \"id\": cmd_id, \"status\": \"error\",\n                    \"message\": f\"Unknown action: {action}\"\n                })\n        except WebSocketDisconnect:\n            break\n```\n\n## Rate Limiting and Backpressure\n\n### Server-Side Rate Limiting\n\n```python\n# rate_limiter.py — Token bucket rate limiter\nimport time\nimport threading\n\n\nclass TokenBucketRateLimiter:\n    \"\"\"Token bucket rate limiter for controlling message throughput.\n\n    Usage:\n        limiter = TokenBucketRateLimiter(tokens_per_second=10, burst_size=15)\n        if limiter.acquire():\n            send_message()\n        else:\n            drop_or_queue()\n    \"\"\"\n\n    def __init__(self, tokens_per_second: float, burst_size: int = 0):\n        self.rate = tokens_per_second\n        self.burst_size = burst_size or int(tokens_per_second)\n        self._tokens = float(self.burst_size)\n        self._last_refill = time.monotonic()\n        self._lock = threading.Lock()\n\n    def acquire(self) -> bool:\n        \"\"\"Try to acquire a token. Returns True if allowed, False if rate limited.\"\"\"\n        with self._lock:\n            now = time.monotonic()\n            elapsed = now - self._last_refill\n            self._tokens = min(\n                self.burst_size,\n                self._tokens + elapsed * self.rate\n            )\n            self._last_refill = now\n\n            if self._tokens >= 1.0:\n                self._tokens -= 1.0\n                return True\n            return False\n\n    def reset(self):\n        \"\"\"Reset to full burst capacity.\"\"\"\n        with self._lock:\n            self._tokens = float(self.burst_size)\n            self._last_refill = time.monotonic()\n```\n\n### Client-Driven Backpressure\n\nLet clients request their own rate to match their processing capability.\n\n```python\n@app.websocket(\"/ws/sensor/{topic_name}\")\nasync def sensor_stream(websocket: WebSocket, topic_name: str):\n    await websocket.accept()\n\n    # Client sends desired rate on connect\n    config = await websocket.receive_json()\n    requested_hz = config.get(\"hz\", 10)\n    requested_hz = max(1, min(requested_hz, 30))  # Clamp to 1-30 Hz\n    interval = 1.0 / requested_hz\n\n    ros_node: RobotBridgeNode = websocket.app.state.ros_node\n\n    try:\n        while True:\n            data = ros_node.get_latest_odom()\n            if data:\n                await websocket.send_json({\"topic\": topic_name, \"data\": data})\n            await asyncio.sleep(interval)\n    except WebSocketDisconnect:\n        pass\n```\n\n### Adaptive Quality Reduction\n\nReduce image quality when bandwidth or client processing cannot keep up.\n\n```python\n# Adaptive quality — reduce JPEG quality when send buffer backs up\nimport cv2\nimport numpy as np\n\n\nasync def adaptive_camera_stream(websocket: WebSocket, ros_node: RobotBridgeNode):\n    quality = 80  # Start at 80% JPEG quality\n    send_times = []\n\n    while True:\n        image_bytes = ros_node.get_latest_image()\n        if image_bytes is None:\n            await asyncio.sleep(0.05)\n            continue\n\n        # Re-encode with adaptive quality if needed\n        if quality < 80:\n            np_arr = np.frombuffer(image_bytes, np.uint8)\n            img = cv2.imdecode(np_arr, cv2.IMREAD_COLOR)\n            _, image_bytes = cv2.imencode(\n                '.jpg', img, [cv2.IMWRITE_JPEG_QUALITY, quality])\n            image_bytes = image_bytes.tobytes()\n\n        t0 = time.monotonic()\n        await websocket.send_bytes(image_bytes)\n        send_duration = time.monotonic() - t0\n\n        # Track send times to detect backpressure\n        send_times.append(send_duration)\n        if len(send_times) > 30:\n            send_times.pop(0)\n\n        avg_send = sum(send_times) / len(send_times)\n        # If average send time > 50ms, reduce quality\n        if avg_send > 0.05 and quality > 20:\n            quality -= 5\n        elif avg_send < 0.02 and quality < 80:\n            quality += 5\n\n        await asyncio.sleep(1.0 / 15)\n```\n\n## Security\n\n### TLS/HTTPS with Nginx Reverse Proxy\n\nNever expose the robot web bridge directly to untrusted networks. Use nginx as a TLS-terminating reverse proxy.\n\n```nginx\n# /etc/nginx/sites-available/robot-bridge\nserver {\n    listen 443 ssl;\n    server_name robot.example.com;\n\n    ssl_certificate /etc/ssl/certs/robot.pem;\n    ssl_certificate_key /etc/ssl/private/robot.key;\n    ssl_protocols TLSv1.2 TLSv1.3;\n\n    # REST API\n    location /api/ {\n        proxy_pass http://127.0.0.1:8080;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n    }\n\n    # WebSocket endpoints\n    location /ws/ {\n        proxy_pass http://127.0.0.1:8080;\n        proxy_http_version 1.1;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";\n        proxy_read_timeout 86400;  # Keep WebSocket alive for 24h\n    }\n\n    # MJPEG video stream\n    location /video/ {\n        proxy_pass http://127.0.0.1:8080;\n        proxy_buffering off;  # Critical for streaming\n        proxy_cache off;\n    }\n}\n```\n\n### Token-Based Auth (JWT)\n\n```python\n# auth.py — JWT authentication for FastAPI robot bridge\nimport time\nfrom typing import Optional\n\nfrom fastapi import Request, HTTPException, WebSocket\nfrom fastapi.security import HTTPBearer, HTTPAuthorizationCredentials\nimport jwt\n\nSECRET_KEY = \"load-from-environment-variable\"  # Use os.environ in production\nALGORITHM = \"HS256\"\nTOKEN_EXPIRY_S = 3600  # 1 hour\n\n\nclass RobotAPIAuth(HTTPBearer):\n    \"\"\"JWT bearer token authentication for robot API endpoints.\"\"\"\n\n    def __init__(self, auto_error: bool = True):\n        super().__init__(auto_error=auto_error)\n\n    async def __call__(self, request: Request) -> dict:\n        credentials: HTTPAuthorizationCredentials = await super().__call__(request)\n        if not credentials:\n            raise HTTPException(status_code=403, detail=\"No credentials provided\")\n        return self._verify_token(credentials.credentials)\n\n    @staticmethod\n    def _verify_token(token: str) -> dict:\n        try:\n            payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])\n            if payload.get(\"exp\", 0) < time.time():\n                raise HTTPException(status_code=401, detail=\"Token expired\")\n            return payload\n        except jwt.InvalidTokenError:\n            raise HTTPException(status_code=401, detail=\"Invalid token\")\n\n\nauth_scheme = RobotAPIAuth()\n\n\n# Protect REST endpoints\n@app.get(\"/api/robot/status\", dependencies=[Depends(auth_scheme)])\nasync def protected_status():\n    return {\"status\": \"active\"}\n\n\n# Protect WebSocket endpoints — check token from query parameter\nasync def verify_ws_token(websocket: WebSocket) -> Optional[dict]:\n    \"\"\"WebSocket cannot use Authorization header — use query param.\"\"\"\n    token = websocket.query_params.get(\"token\")\n    if not token:\n        await websocket.close(code=4001, reason=\"Missing token\")\n        return None\n    try:\n        return RobotAPIAuth._verify_token(token)\n    except HTTPException:\n        await websocket.close(code=4003, reason=\"Invalid token\")\n        return None\n```\n\n### CORS Configuration\n\n```python\n# BAD: Allow all origins — any website can control your robot\napp.add_middleware(\n    CORSMiddleware,\n    allow_origins=[\"*\"],        # Any website can send requests\n    allow_methods=[\"*\"],        # Including DELETE, PATCH\n    allow_headers=[\"*\"],\n)\n\n# GOOD: Explicit origins — only your dashboard can access the API\napp.add_middleware(\n    CORSMiddleware,\n    allow_origins=[\n        \"https://dashboard.example.com\",\n        \"https://monitor.internal.example.com\",\n    ],\n    allow_credentials=True,\n    allow_methods=[\"GET\", \"POST\", \"PUT\"],\n    allow_headers=[\"Authorization\", \"Content-Type\"],\n)\n```\n\n### Network Segmentation\n\nRobots should run on isolated networks. The web bridge is the only component with interfaces on both the robot network and the user-facing network.\n\n```\n┌─────────────────┐     ┌──────────────────┐     ┌─────────────────┐\n│   Browser /      │     │   Web Bridge      │     │   ROS2 Nodes    │\n│   Dashboard      │◄───►│   (FastAPI)        │◄───►│   (DDS network) │\n│   (user network) │:443 │   eth0: user net   │     │   (robot VLAN)  │\n│                  │     │   eth1: robot net  │     │                 │\n└─────────────────┘     └──────────────────┘     └─────────────────┘\n```\n\n- The web bridge has two network interfaces: one facing users (nginx TLS), one facing the robot DDS network.\n- ROS2 DDS discovery is confined to the robot VLAN via `ROS_DOMAIN_ID` and DDS network interface configuration.\n- The web bridge translates and filters — never forwards raw DDS traffic.\n\n## ROS2 Parameter Management via REST\n\n```python\n# parameter_api.py — Full CRUD for ROS2 parameters via HTTP\n\nfrom fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect\nfrom pydantic import BaseModel\nfrom typing import Any\nimport asyncio\n\nrouter = APIRouter(prefix=\"/api/params\", tags=[\"parameters\"])\n\n\nclass ParamUpdate(BaseModel):\n    value: Any\n\n\n@router.get(\"/\")\nasync def list_parameters():\n    \"\"\"List all declared parameters on the bridge node.\"\"\"\n    ros_node = app.state.ros_node\n    param_names = ros_node.get_parameters_by_prefix(\"\")\n    return {\n        \"parameters\": [\n            {\"name\": name, \"value\": param.value}\n            for name, param in param_names.items()\n        ]\n    }\n\n\n@router.get(\"/{name}\")\nasync def get_param(name: str):\n    \"\"\"Get a single parameter value.\"\"\"\n    ros_node = app.state.ros_node\n    try:\n        param = ros_node.get_parameter(name)\n        return {\"name\": name, \"value\": param.value, \"type\": str(param.type_)}\n    except Exception:\n        raise HTTPException(404, f\"Parameter '{name}' not found\")\n\n\n@router.put(\"/{name}\")\nasync def set_param(name: str, body: ParamUpdate):\n    \"\"\"Set a parameter value. Notifies WebSocket subscribers.\"\"\"\n    ros_node = app.state.ros_node\n    try:\n        old_value = ros_node.get_parameter(name).value\n        ros_node.set_parameters([\n            rclpy.parameter.Parameter(name, value=body.value)\n        ])\n        # Notify WebSocket subscribers of the change\n        await param_broadcaster.notify(name, old_value, body.value)\n        return {\"name\": name, \"old_value\": old_value, \"new_value\": body.value}\n    except Exception as e:\n        raise HTTPException(400, str(e))\n\n\n# Parameter change notifications via WebSocket\nclass ParamBroadcaster:\n    def __init__(self):\n        self.subscribers: set[WebSocket] = set()\n\n    async def notify(self, name: str, old_value: Any, new_value: Any):\n        dead = set()\n        for ws in self.subscribers.copy():\n            try:\n                await ws.send_json({\n                    \"type\": \"param_change\",\n                    \"name\": name,\n                    \"old_value\": old_value,\n                    \"new_value\": new_value,\n                })\n            except Exception:\n                dead.add(ws)\n        self.subscribers -= dead\n\n    async def handle_ws(self, websocket: WebSocket):\n        await websocket.accept()\n        self.subscribers.add(websocket)\n        try:\n            while True:\n                await websocket.receive_text()  # Keep alive\n        except WebSocketDisconnect:\n            self.subscribers.discard(websocket)\n\n\nparam_broadcaster = ParamBroadcaster()\n\n\n@router.websocket(\"/ws\")\nasync def param_ws(websocket: WebSocket):\n    \"\"\"WebSocket endpoint for real-time parameter change notifications.\"\"\"\n    await param_broadcaster.handle_ws(websocket)\n```\n\n## Web Integration Anti-Patterns\n\n### 1. Blocking the ROS2 Executor from HTTP Handlers\n\n**Problem:** Calling `rclpy.spin_once()` or `rclpy.spin_until_future_complete()` inside an HTTP handler blocks the web server thread and can deadlock if the ROS2 executor is already spinning in another thread.\n\n**Fix:** Spin the ROS2 executor in a dedicated background thread. Access data via thread-safe shared state (lock-protected attributes). Never call spin functions from request handlers.\n\n```python\n# BAD: Spinning inside a Flask route\n@app.route('/api/scan')\ndef get_scan():\n    rclpy.spin_once(node, timeout_sec=1.0)  # Blocks the web server thread\n    return jsonify(node.latest_scan)\n\n# GOOD: Executor spins in background, handler reads shared state\n@app.route('/api/scan')\ndef get_scan():\n    return jsonify(node.get_latest_scan())  # Lock-protected read, non-blocking\n```\n\n### 2. Streaming Raw Image Messages Over WebSocket\n\n**Problem:** Sending raw `sensor_msgs/Image` data (uncompressed BGR8, 640x480 = 921,600 bytes per frame) over WebSocket wastes bandwidth and CPU on the client. Base64 encoding inflates it to 1.2MB per frame.\n\n**Fix:** Subscribe to `CompressedImage` topics (JPEG/PNG) or compress on the server side before sending. Use binary WebSocket frames instead of base64 JSON.\n\n```python\n# BAD: Subscribing to raw image and base64-encoding it\nself.create_subscription(Image, '/camera/image_raw', self._raw_cb, 10)\n# Each frame: 921,600 bytes raw -> 1,228,800 bytes base64 -> JSON overhead\n\n# GOOD: Subscribe to compressed topic, send as binary WebSocket frame\nself.create_subscription(\n    CompressedImage, '/camera/image/compressed', self._compressed_cb,\n    QoSProfile(reliability=ReliabilityPolicy.BEST_EFFORT, depth=1))\n# Each frame: ~30,000-80,000 bytes JPEG, sent as binary\nawait websocket.send_bytes(compressed_image_bytes)\n```\n\n### 3. No Rate Limiting on Sensor Subscriptions\n\n**Problem:** A LiDAR publishing at 20Hz with 100K points per scan generates ~8MB/s of data. Forwarding every message to every WebSocket client saturates the network and browser.\n\n**Fix:** Apply server-side rate limiting per client. Use a token bucket or simple time-based throttle. Let clients request their desired rate.\n\n```python\n# BAD: Forward every message to every client\ndef _scan_cb(self, msg):\n    for client in self.clients:\n        client.send(serialize(msg))  # 20 msgs/s * N clients\n\n# GOOD: Rate-limit per client\ndef _scan_cb(self, msg):\n    with self._lock:\n        self._latest_scan = msg  # Just store latest\n\nasync def stream_to_client(self, ws, max_hz=5):\n    interval = 1.0 / max_hz\n    while True:\n        scan = self.get_latest_scan()\n        if scan:\n            await ws.send_json(scan)\n        await asyncio.sleep(interval)\n```\n\n### 4. Running rosbridge in Production Without Auth\n\n**Problem:** rosbridge_suite with default settings exposes every topic, service, and parameter to any WebSocket client. Any browser on the network can publish to `/cmd_vel` or call `/emergency_stop`.\n\n**Fix:** For production, use a custom bridge with authentication. If you must use rosbridge, enable rosauth, restrict topics via a filter, and run behind an authenticated reverse proxy.\n\n```yaml\n# BAD: Default rosbridge launch — full access to everything\nros2 launch rosbridge_server rosbridge_websocket_launch.xml\n\n# GOOD: At minimum, enable authentication and use a reverse proxy\nros2 launch rosbridge_server rosbridge_websocket_launch.xml \\\n    authenticate:=true \\\n    topics_glob:=\"['/camera/image/compressed', '/odom', '/cmd_vel']\"\n```\n\n### 5. Synchronous Service Calls in Async Handlers\n\n**Problem:** Calling `service_client.call(request)` (synchronous) inside an `async def` FastAPI handler blocks the event loop, freezing all other requests until the service responds.\n\n**Fix:** Use `call_async()` and await the future via `asyncio.get_event_loop().run_in_executor()`, or use a dedicated thread pool.\n\n```python\n# BAD: Synchronous service call blocks the async event loop\n@app.post(\"/api/navigate\")\nasync def navigate(goal: NavGoal):\n    response = nav_client.call(goal_request)  # Blocks entire event loop\n    return {\"result\": response.result}\n\n# GOOD: Async service call with executor bridge\n@app.post(\"/api/navigate\")\nasync def navigate(goal: NavGoal):\n    future = nav_client.call_async(goal_request)\n    response = await asyncio.get_event_loop().run_in_executor(\n        None, lambda: future.result(timeout=30.0)\n    )\n    return {\"result\": response.result}\n```\n\n### 6. GIL Contention Between Web Server and ROS2 Spinner\n\n**Problem:** Running uvicorn with multiple worker threads and rclpy.spin in another thread causes GIL contention. Under high load, both the web server and ROS2 callbacks stall each other, leading to dropped messages and increased latency.\n\n**Fix:** Use `MultiThreadedExecutor` with a small thread count (2-4). For high-throughput systems, run the ROS2 node in a separate process and communicate via shared memory, Redis, or a Unix socket.\n\n```python\n# BAD: SingleThreadedExecutor competing with uvicorn for the GIL\nexecutor = SingleThreadedExecutor()\nexecutor.add_node(node)\nthreading.Thread(target=executor.spin).start()\nuvicorn.run(app, workers=4)  # 4 workers + 1 spinner = GIL contention\n\n# GOOD: MultiThreadedExecutor with limited threads, single uvicorn worker\nexecutor = MultiThreadedExecutor(num_threads=2)\nexecutor.add_node(node)\nthreading.Thread(target=executor.spin, daemon=True).start()\nuvicorn.run(app, workers=1, host=\"0.0.0.0\", port=8080)\n```\n\n### 7. No Connection Lifecycle Management\n\n**Problem:** WebSocket clients disconnect without sending a close frame (browser tab closed, network drop). The server keeps sending data to dead connections, wasting CPU and memory. Over time, the dead client set grows unbounded.\n\n**Fix:** Track connected clients in a set, remove on disconnect, and periodically prune stale connections with a heartbeat check.\n\n```python\n# BAD: No tracking of client lifecycle\nclients = []\n\n@app.websocket(\"/ws/data\")\nasync def data_ws(ws: WebSocket):\n    await ws.accept()\n    clients.append(ws)\n    while True:\n        await ws.send_json(get_data())  # Throws on dead client, never removed\n\n# GOOD: Proper lifecycle management with cleanup\nclients: set[WebSocket] = set()\n\n@app.websocket(\"/ws/data\")\nasync def data_ws(ws: WebSocket):\n    await ws.accept()\n    clients.add(ws)\n    try:\n        while True:\n            # Wait for client messages (ping/pong keepalive)\n            await ws.receive_text()\n    except WebSocketDisconnect:\n        pass\n    finally:\n        clients.discard(ws)\n\nasync def broadcast(data: dict):\n    dead = set()\n    for client in clients.copy():\n        try:\n            await client.send_json(data)\n        except Exception:\n            dead.add(client)\n    clients -= dead\n```\n\n### 8. Exposing All Topics Unconditionally\n\n**Problem:** The web bridge subscribes to every topic on the ROS2 graph and makes all of them available to web clients. This leaks internal system details (diagnostics, debug topics), wastes bandwidth, and creates a security risk.\n\n**Fix:** Maintain an explicit allowlist of topics that should be exposed. Load it from configuration. Reject requests for topics not on the list.\n\n```python\n# BAD: Dynamically subscribe to whatever the client requests\n@app.websocket(\"/ws/topic/{topic_name}\")\nasync def any_topic(ws: WebSocket, topic_name: str):\n    # Client can request /rosout, /parameter_events, /diagnostics, etc.\n    sub = node.create_subscription(String, topic_name, callback, 10)\n\n# GOOD: Allowlist of exposed topics from configuration\nALLOWED_TOPICS = {\n    \"/camera/image/compressed\": CompressedImage,\n    \"/odom\": Odometry,\n    \"/battery_state\": BatteryState,\n    \"/cmd_vel\": Twist,\n}\n\n@app.websocket(\"/ws/topic/{topic_name:path}\")\nasync def allowed_topic(ws: WebSocket, topic_name: str):\n    topic_path = \"/\" + topic_name\n    if topic_path not in ALLOWED_TOPICS:\n        await ws.close(code=4004, reason=f\"Topic '{topic_path}' not in allowlist\")\n        return\n    msg_type = ALLOWED_TOPICS[topic_path]\n    # Proceed with subscription\n```\n\n## Web Integration Checklist\n\n1. **Thread separation**: rclpy executor runs in a dedicated background thread; the web server runs in the main thread or its own thread. They never share an event loop.\n2. **Shared state is lock-protected**: Every piece of data read by HTTP handlers and written by ROS2 callbacks is guarded by `threading.Lock()`. No bare attribute access across threads.\n3. **Graceful shutdown coordination**: Signal handlers set a shutdown event, the executor is stopped before `rclpy.shutdown()`, and the spin thread is joined with a timeout.\n4. **Topic allowlist enforced**: Only explicitly listed topics are exposed to web clients. The allowlist is loaded from a configuration file, not hardcoded.\n5. **Rate limiting on all streams**: Every WebSocket stream has a per-client rate limiter (token bucket or time-based). Clients can request a lower rate but not exceed the server maximum.\n6. **Command timeout watchdog**: Any endpoint that accepts velocity or motion commands publishes zero velocity if no command is received within 500ms, preventing runaway robots on disconnect.\n7. **Video is compressed before transmission**: Camera feeds use `CompressedImage` topics or server-side JPEG encoding. Raw `Image` messages are never forwarded to web clients.\n8. **TLS termination in front of the bridge**: An nginx or caddy reverse proxy handles TLS. The bridge itself listens on localhost only. WebSocket upgrade headers are properly proxied.\n9. **Authentication on all mutation endpoints**: POST, PUT, DELETE endpoints and teleop WebSocket connections require a valid JWT or API key. Read-only status endpoints may be unauthenticated on private networks.\n10. **CORS restricts origins**: `allow_origins` lists specific dashboard URLs. Wildcard `*` is never used in production.\n11. **Connection lifecycle management**: WebSocket clients are tracked in a set, removed on disconnect, and periodically pruned. Dead client references are never retained.\n12. **Binary WebSocket for high-bandwidth data**: Image frames, point clouds, and other binary data use `send_bytes()`, not base64-encoded JSON. This saves ~33% bandwidth and CPU on both sides.","tags":["ros2","web","integration","robotics","agent","skills","arpitg1304","agent-skills","ai-coding-assistant","claude-skills"],"capabilities":["skill","source-arpitg1304","skill-ros2-web-integration","topic-agent-skills","topic-ai-coding-assistant","topic-claude-skills","topic-robotics"],"categories":["robotics-agent-skills"],"synonyms":[],"warnings":[],"endpointUrl":"https://skills.sh/arpitg1304/robotics-agent-skills/ros2-web-integration","protocol":"skill","transport":"skills-sh","auth":{"type":"none","details":{"cli":"npx skills add arpitg1304/robotics-agent-skills","source_repo":"https://github.com/arpitg1304/robotics-agent-skills","install_from":"skills.sh"}},"qualityScore":"0.544","qualityRationale":"deterministic score 0.54 from registry signals: · indexed on github topic:agent-skills · 189 github stars · SKILL.md body (49,274 chars)","verified":false,"liveness":"unknown","lastLivenessCheck":null,"agentReviews":{"count":0,"score_avg":null,"cost_usd_avg":null,"success_rate":null,"latency_p50_ms":null,"narrative_summary":null,"summary_updated_at":null},"enrichmentModel":"deterministic:skill-github:v1","enrichmentVersion":1,"enrichedAt":"2026-05-02T18:54:21.281Z","embedding":null,"createdAt":"2026-04-18T22:05:39.139Z","updatedAt":"2026-05-02T18:54:21.281Z","lastSeenAt":"2026-05-02T18:54:21.281Z","tsv":"'-0.3':2718 '-001':2963,2975,2985,2995 '-1.0':1151,2740 '-15':180 '-2.0':1163,2748 '-30':188,1300,3274 '-4':4917 '-5':184 '-50':192 '-80':4508 '/api':3521 '/api/navigate':4812,4837 '/api/params':4017 '/api/robot/cmd_vel':1491 '/api/robot/emergency_stop':1658 '/api/robot/params':1530,1580 '/api/robot/status':1446,3760 '/api/scan':4347,4376 '/api/status':2056 '/battery_state':5266 '/camera/image/compressed':516,967,4496,4747,5262 '/camera/image_raw':2441,4467 '/cmd_vel':606,725,988,4682,4749,5268 '/diagnostics':5243 '/emergency_stop':1000,4685 '/etc/nginx/sites-available/robot-bridge':3499 '/etc/ssl/certs/robot.pem':455,3509 '/etc/ssl/private/robot.key':457,3513 '/map_server/map':572 '/odom':974,2024,4748,5264 '/parameter_events':5242 '/rosout':5241 '/video':3596 '/video/mjpeg':2344 '/ws':3563,4246 '/ws/camera':1242 '/ws/camera/binary':2472 '/ws/commands':3001 '/ws/data':5066,5101 '/ws/sensor':3234 '/ws/topic':5226,5271 '0':3137,3435,3731 '0.0':622,624,627,629,667,668,942,1305,2272,2684,2725,2732,2789,2790,2820,2821 '0.0.0.0':1862,1973,2089,2438,4996 '0.01':1358 '0.02':3463 '0.05':2301,3372,3454 '0.1':2808,2895 '0.45':2990 '0.5':2665,2715 '000':4507,4509 '1':409,535,541,951,962,1293,1299,2031,3266,3273,3659,4271,4476,4503,4965,4994,5320 '1.0':1153,1291,2267,2511,2742,2968,3194,3196,3277,3471,4356,4633 '1.0.0':1184 '1.1':3571 '1.2':4427 '10':187,191,305,522,636,989,1269,3115,3262,4469,5252,5575 '100':531,659 '100k':4535 '11':5591 '12':5614 '12.5':2980 '127.0.0.1':3524,3566,3599 '15':1288,2254,2362,2512,2513,3118,3472 '2':183,799,1804,4392,4916,4981,5349 '2.0':1037,1165,2750,2970 '20':3457,4600 '20hz':4533 '228':4477 '24h':3591 '3':1901,4521,5379 '30':698,1297,2487,3270,3433,4506 '30.0':4860 '30hz':768 '33':5640 '3600':3658 '4':4651,4962,4963,5404 '400':1621,4161 '4001':3806 '4003':3821 '4004':5298 '401':3737,3749 '403':3705 '404':1571,1649,4093 '443':3502,3927 '5':179,3459,3468,4631,4750,5427 '5.0':1716,1895 '500ms':2649,2669,5482 '503':1476,1682 '50ms':3448 '6':4864,5461 '600':4409,4473 '640x480':693,4407 '7':4999,5488 '8':5152,5514 '80':3350,3353,3384,3466 '800':4478 '8080':1864,1975,2091,3525,3567,3600,4998 '8080/ws/camera/binary':2568 '8443':2426 '86400':3586 '8mb/s':4540 '9':5543 '9090':433,485 '9091':451 '921':4408,4472 'accept':2977,3041,5468 'access':797,1221,2436,3864,4320,4720,5376 'ack':2770 'acknowledg':2764,2941 'acquir':3160,3165 'across':5377 'action':85,714,2964,3027,3029,3031,3045,3075,3076 'activ':641,1485,3771 'ad':123 'adapt':3308,3323,3341,3378 'add':1234,1440,3548 'addr':3539 'address':2437 'adopt':2141 'aggreg':368 'algorithm':3653,3726,3727 'aliv':2875,3589,4237 'allow':841,1195,1198,1201,1206,3171,3831,3843,3850,3855,3870,3874,3877,3882,5260,5277,5293,5310,5579 'allowlist':5197,5254,5306,5406,5418 'alongsid':139 'alreadi':1432,2133,4305 'alway':669,2781 'angular':625,1049,1087,1100,1158,1167,1525,2716,2726,2730,2745,2751,2758 'angularz':613,631 'anoth':313,4308,4883 'anti':4269 'anti-pattern':4268 'api':18,88,221,277,2191,3519,3670,3866,5562 'apirout':4000,4015 'app':1105,1172,1177,1216,1228,1773,1776,1783,1785,1860,1952,2002,2903,4960,4992 'app.add':1192,3840,3867 'app.get':1445,1529,2343,3759 'app.post':1490,1657,4811,4836 'app.put':1579 'app.route':2055,4346,4375 'app.run':1971,2087 'app.state.ros':1223,1462,1509,1551,1605,1671,2352,4040,4074,4118 'app.websocket':1241,2471,3000,3233,5065,5100,5225,5270 'appli':4556 'applic':821 'approach':1927 'apt':420 'architectur':165 'arr':3386,3394 'arraybuff':2570 'ascii':1382 'async':135,206,855,1243,1447,1492,1533,1583,1659,1691,2142,2168,2345,2473,2685,2791,2862,2888,3002,3237,3339,3685,3765,3780,4026,4061,4101,4178,4219,4247,4622,4755,4764,4783,4808,4813,4830,4838,4845,5067,5102,5130,5229,5275 'asyncio':1114,2620,4013 'asyncio.create':2702,3050 'asyncio.get':1706,4789,4850 'asyncio.sleep':1327,1357,2550,2807,2938,3303,3371,3470,4649 'atan2':1040 'attribut':4331,5375 'auth':214,845,3613,3753,3763,4657 'auth.py':830,3616 'authent':124,346,460,467,775,832,3618,3667,4694,4711,4732,4743,5544 'author':347,1208,3792,3884 'auto':2150,2184,3675,3681,3683 'auto-gener':2149,2183 'automat':2175 'avail':1352,1481,1688,5174 'averag':3445 'avg':3436,3452,3461 'avoid':525,1353,1702 'await':1270,1326,1356,1383,1427,1705,2502,2544,2549,2691,2709,2767,2806,2868,2881,2923,2937,3008,3019,3034,3064,3246,3255,3294,3302,3370,3411,3469,3694,3803,3818,4139,4197,4226,4233,4262,4515,4644,4648,4785,4849,5073,5079,5108,5121,5142,5295 'awar':316 'away':2659 'az':651,658 'b':2312,2316,2323,2332,2335,2340 'b64':1376,1391 'back':3331 'background':1742,1792,1930,1983,2900,4318,4370,5329 'backpressur':3083,3220,3425 'bad':1937,1956,3830,4340,4454,4581,4715,4802,4942,5058,5217 'balanc':2449 'bandwidth':2490,3315,4416,5187,5620,5641 'bandwidth-effici':2489 'bare':5374 'base':24,782,796,3612,4572,5448 'base64':547,559,690,1112,1254,1371,2469,2493,4422,4451,4461,4480,5635 'base64-encoded':546,689,5634 'base64-encoding':4460 'base64.b64encode':1378 'basemodel':1136,1145,4007,4022 'bash':415 'basic':215 'batteryst':5267 'bearer':3665 'becom':697 'behind':4709 'best':7,947 'bgr8':4406 'bidirect':91,2604 'binari':204,253,687,2460,2463,2477,2484,2500,4446,4490,4514,5615,5628 'bind':2430 'blob':2501,2574,2576,2583,2599 'block':1703,1920,1938,1966,3049,4272,4292,4357,4391,4768,4806,4822 'bodi':1600,4107 'body.value':4132,4144,4154 'bool':3162,3677 'bound':213 'boundari':2366 'break':3079 'bridg':20,117,174,177,293,302,341,802,807,810,920,1005,1182,1546,1598,1888,1903,1963,2017,2121,3484,3622,3898,3918,3938,3974,4036,4692,4835,5160,5521,5531 'bridge_config.yaml':840 'broadcast':2829,2840,2890,4243,5132 'browser':23,40,80,150,528,1374,2368,2639,3916,4554,4675,5013 'browser-bas':22 'bucket':836,3092,3102,4567,5444 'buffer':3330,3602 'build':31,56 'built':752 'built-in':751 'burst':3116,3134,3144,3207 'busi':332 'byte':934,1017,1059,1308,1310,1333,1339,1344,1348,1362,1364,1380,2291,2297,2330,2339,2518,2523,2529,2535,2539,2541,2543,2546,2548,3361,3367,3389,3398,3407,3413,3415,4410,4474,4479,4510,4517,4520,5632 'cach':3608 'caddi':5525 'call':561,589,1663,2896,3687,3696,4280,4333,4684,4753,4758,4782,4805,4832 'callback':4897,5251,5368 'camera':37,69,507,554,1238,1245,1251,2475,2588,3342,5494 'camera-fe':553,2587 'cannot':3319,3790 'capabl':3231 'capac':3208 'caus':4885 'cb':1010,1023,2034,4590,4612 'certfil':454 'certif':3508,3511 'chang':4138,4165,4202,4260 'channel':3005 'check':3775,5056 'checklist':5319 'chunk':2264 'clamp':1298,2733,3271 'class':899,1143,2008,2633,2838,3099,3661,4020,4169 'clean':1402 'cleanup':1419,5095 'clearinterv':662 'client':308,374,385,470,721,745,756,764,991,996,998,1260,1279,1400,2172,2201,2496,2557,2766,2837,2847,2876,2916,2919,2933,2936,2959,3046,3218,3222,3248,3317,4421,4549,4563,4575,4587,4594,4603,4609,4626,4673,5006,5034,5041,5062,5064,5087,5096,5117,5138,5149,5150,5177,5223,5238,5416,5440,5449,5513,5596,5609 'client-driven':3217 'client-sid':2556 'client.send':2924,4597,5143 'clients.add':5110 'clients.append':5075 'clients.copy':5140 'clients.discard':5128 'close':500,503,1433,5011,5015 'cloud':770,5625 'cmd':153,1081,1495,1497,1512,2754,2787,2818,2962,2974,2984,2994,3022,3038,3057,3068 'cmd.angular':1516,1527 'cmd.linear':1514,1523 'cmdvelrequest':1144,1498 'cmdveltop':600 'cmdveltopic.publish':632 'code':1475,1570,1620,1648,1681,3704,3736,3748,3805,3820,5297 'color':3396 'command':147,594,981,1502,2610,2616,2645,2662,2682,2694,2761,2774,2800,2812,2940,2945,3004,5462,5472,5478 'communic':93,2605,4932 'comparison':167 'compat':1375 'compet':4944 'complet':2992,2997,4287 'complex':228 'compon':3902 'compress':73,506,695,1250,2429,2442,2455,4438,4486,4518,5491 'compressedimag':886,966,1013,4434,4495,5263,5497 'concern':322 'concurr':2128,2167 'config':839,2396,3254 'config.get':3260 'configur':158,3828,3971,5207,5259,5423 'confin':3958 'connect':232,473,487,489,496,502,720,2835,2845,2874,3253,3581,5001,5025,5040,5052,5556,5592 'console.error':495,587 'console.log':488,501,580 'const':477,509,550,565,599,614,2561,2573,2580,2584 'consum':772 'content':1210,2318,2325,3886,4866,4887,4968 'content-length':2324 'content-typ':1209,2317,3885 'continu':1331,1359,2289,2302,3373 'control':63,392,3106,3837 'coordin':1747,1818,5382 'cor':128,1185,3827,5576 'correct':1926 'correl':2956 'corsmiddlewar':1132,1194,3842,3869 'count':4915 'cpu':4418,5027,5643 'creat':1171,1775,1784,5189 'credenti':1199,3692,3700,3708,3875 'credentials.credentials':3712 'critic':1729,3604 'crud':3991 'current':1453 'custom':113,172,175,292,331,340,441,800,4691 'cv2':2229,3334 'cv2.imdecode':3392 'cv2.imencode':3399 'cv2.imread':3395 'cv2.imwrite':3402 'daemon':1814,2083,4988 'dashboard':33,59,162,2220,3862,3921,5583 'dashboard.example.com':1197,3872 'data':369,557,688,907,946,1377,1390,1392,1480,2049,2062,2198,2708,3018,3059,3061,3288,3293,3300,3301,4321,4404,4542,5022,5069,5083,5104,5133,5145,5359,5621,5629 'data.get':2722,2729,3024,3028 'dds':3923,3952,3955,3968,3981 'dead':2915,2935,4190,4218,5024,5033,5086,5135,5151,5608 'dead.add':4215,5148 'dead_clients.add':2932 'deadlock':144,4299 'debug':5184 'declar':1578,1656,4032 'decod':1381 'dedic':4317,4798,5328 'def':914,1008,1021,1053,1064,1079,1170,1244,1448,1493,1534,1584,1660,1777,1822,1955,2011,2032,2047,2057,2063,2245,2346,2406,2474,2671,2686,2792,2848,2863,2889,3003,3127,3159,3201,3238,3340,3672,3686,3714,3766,3781,4027,4062,4102,4171,4179,4220,4248,4348,4377,4588,4610,4623,4765,4814,4839,5068,5103,5131,5230,5276 'default':430,707,4662,4716 'delet':3853,5551 'demo':275,325 'depend':1128,3761,3762 'deploy':122,400 'depth':961,2030,4502 'descript':1154,1166,2409 'desir':3250,4578 'detail':1477,1572,1622,1650,1683,3706,3738,3750,5182 'detect':3424 'diagnost':161,5183 'dict':938,1070,1591,3691,3719,3788,5134 'direct':3485 'disconnect':1401,1417,2661,2785,5007,5047,5487,5604 'discoveri':3956 'dispatch':3042 'distro':424 'doc':2187 'document':2148 'document.getelementbyid':552,2586 'domain':3965 'driven':3219 'drop':536,3124,4903,5017 'duplic':1355 'durat':3417,3428 'dynam':5218 'e':1407,1414,4158,4163 'effici':2467,2491 'effort':948,957,2029,4501 'elaps':1317,1323,1330,3180,3188 'elif':3460 'els':1077,3063,3123 'emerg':993,1661,1665,1684 'en':46 'enabl':4700,4731 'encod':394,548,691,2331,2495,3376,4423,4462,5504,5636 'endpoint':89,378,1219,1230,1240,1435,1444,2116,2158,2225,3561,3671,3758,3774,4254,5466,5548,5552,5568 'enforc':5407 'enough':2102 'entir':360,4823 'entri':823 'environ':326,3647 'err':494,498 'error':493,497,586,591,1413,3071,3676,3682,3684 'estim':2978 'etc':5244 'eth0':3928 'eth1':3933 'event':237,729,737,1707,1820,2572,4770,4790,4809,4824,4851,5347,5388 'event.data':2577 'everi':710,4544,4547,4583,4586,4665,5163,5356,5433 'everyth':4722 'exceed':5457 'except':1397,1404,1405,1429,1565,1643,1878,2553,2776,2884,2930,2931,3077,3305,3743,3816,4089,4090,4155,4156,4213,4214,4238,5124,5146,5147 'execut':2416,2946,3052 'executor':142,856,1700,1711,1799,1800,1935,1979,2072,4275,4303,4314,4367,4794,4834,4855,4950,4977,5324,5390 'executor.add':1805,2074,4952,4982 'executor.shutdown':1889,2095 'executor.spin':1813,2082,4957,4987 'exit':1403 'exp':3730 'expect':2712 'expir':3740 'expiri':3656 'explicit':3858,5196,5409 'expos':41,81,261,354,709,905,3480,4664,5153,5203,5256,5413 'extens':197 'f':1411,1573,1651,3073,4094,5300 'face':3914,3944,3949 'fail':590 'fals':1877,3172,3200 'familiar':2134 'fastapi':114,173,801,820,1104,1122,1124,1176,1178,1723,2107,2153,2227,2233,2235,2624,3620,3630,3922,3998,4766 'fastapi.middleware.cors':1130 'fastapi.responses':2237 'fastapi.security':3636 'fastapi/flask':342 'featur':169 'feed':38,70,555,2348,2589,5495 'field':1137,1149,1161,1625 'file':5424 'filter':705,3977,4706 'final':1415,1881,2094,2779,5127 'fix':4310,4431,4555,4686,4780,4908,5038,5193 'flask':116,176,225,1902,1904,1908,1918,1942,1948,1950,1953,1962,1968,1984,1998,2000,2003,2016,2100,2109,2136,4344 'flask-login':224 'float':941,1086,1089,1094,1099,1148,1160,2721,2728,2894,3133,3152,3212 'flood':526 'forev':1967 'format':1388 'forward':3545,3550,3557,3979,4543,4582,5510 'found':4098 'foxglov':109,273,310 'fps':1268,1283,1287,1296,1301,2252,2269,2361,2514 'frame':538,1826,2259,2310,2313,2367,2482,4412,4430,4448,4471,4492,4505,5012,5623 'freez':4772 'frequenc':741 'front':5518 'full':218,223,262,3206,3990,4719 'function':610,648,660,4335 'futur':1689,4286,4787,4843 'future.result':1714,4858 'ge':1150,1162 'generat':256,2151,2185,2246,2255,2356,2407,4539 'geometri':608 'geometry_msgs.msg':888 'get':1054,1065,1203,1449,1535,2048,3879,4063,4067,4349,4378,5082 'getmapsrv':566 'getmapsrv.callservice':576 'gil':212,4865,4886,4949,4967 'gil-bound':211 'glob':4746 'global':2065 'goal':4816,4820,4841,4846 'good':1977,3857,4366,4483,4604,4728,4829,4969,5090,5253 'grace':1416,5380 'graph':264,362,718,5168 'grow':5036 'guard':5370 'gunicorn':268 'handl':2498,2635,2687,4221,5528 'handler':1824,1847,1851,2169,4278,4291,4338,4371,4756,4767,5363,5384 'hardcod':5426 'header':1207,3528,3533,3542,3554,3574,3580,3793,3856,3883,5539 'heartbeat':5055 'high':203,279,740,2166,2383,4889,4920,5619 'high-bandwidth':5618 'high-frequ':739 'high-perf':278 'high-qual':2382 'high-throughput':4919 'histori':958 'historypolici':882 'historypolicy.keep':959 'host':484,1861,1972,2088,2567,3529,3530,4995 'host/ws/camera':1266 'hour':3660 'hs256':3654 'html':2373 'http':164,2262,3569,3576,3996,4277,4290,5362 'httpauthorizationcredenti':3639,3693 'httpbearer':3638,3663 'httpexcept':1127,1473,1568,1618,1646,1679,3633,3702,3734,3746,3817,4001,4092,4160 'hz':523,637,3259,3261,3264,3269,3275,3279,4630,4635 'id':2957,2961,2973,2983,2993,3023,3025,3037,3039,3058,3067,3069,3966 'imag':508,696,1009,1056,1252,1307,1332,1336,1338,1343,1347,1351,1361,1363,1379,1387,2290,2294,2296,2329,2338,2427,2440,2456,2522,2526,2528,2534,2542,2547,3312,3360,3364,3366,3388,3397,3406,3414,4395,4458,4466,4519,5506,5622 'image/jpeg':558,2320,2579 'image_bytes.tobytes':3408 'imagetop':510 'imagetopic.subscribe':542 'img':2585,3391,3401 'img.src':2601,2602 'img.src.startswith':2598 'imgel':551 'imgelement.src':556 'implement':90 'import':859,861,865,867,871,875,879,885,889,893,897,1038,1111,1113,1115,1119,1123,1131,1135,1141,1752,1754,1756,1758,1762,1764,1769,1774,1945,1949,1989,1991,1995,1999,2228,2230,2234,2238,2243,2400,2404,2619,2621,2625,2631,3095,3097,3333,3335,3623,3627,3631,3637,3640,3999,4006,4010,4012 'includ':16,686,3852 'increas':4906 'inflat':4424 'info':1003,1829,1867,1884 'init':811,915,918,2012,2015,2672,2849,3128,3673,3680,4172 'initi':1007 'input':2637 'insid':4288,4342,4762 'instal':412,416,421 'instead':4449 'int':1284,2253,3136,3147 'integr':4,10,49,110,380,1108,1730,4267,5318 'interfac':26,132,2433,3904,3942,3970 'intern':283,2124,5180 'interv':1290,1325,1329,2266,2282,2285,2510,2552,2893,2939,3276,3304,4632,4650 'invalid':3751,3823 'ip':3537 'isol':3894 'javascript':469,472,2215,2371,2559,2560 'join':5400 'joystick':151,598,639,2636 'joystickinterv':646,653,654,663,664 'jpeg':549,694,1389,2481,3326,3354,3403,4511,5503 'jpeg/png':4436 'jpg':3400 'json':200,677,685,1372,1385,2470,2494,2711,2769,2925,3021,3036,3066,3257,3296,4199,4452,4481,4646,5081,5144,5637 'jsonifi':1951,2001,2060,4363,4381 'jwt':219,227,791,831,3614,3617,3641,3664,5560 'jwt.decode':3722 'jwt.invalidtokenerror':3744 'keep':949,2873,3320,3587,4236,5020 'keepal':5120 'key':222,846,3512,3643,3725,5563 'keyboardinterrupt':1838,1843,1879 'keyfil':456 'known':1188 'lambda':1713,4857 'larger':699 'last':950,960,1302,1306,1319,1346,1360,1365,1394,2270,2279,2287,2303,2517,2538,2540 'latenc':178,2381,4907 'latest':928,1055,1066,1335,1466,2293,2525,2909,3290,3363,4383,4621,4640 'launch':230,414,428,435,439,446,458,463,847,2395,2399,2408,4718,4724,4739 'launch_ros.actions':2403 'launchdescript':2401,2411 'le':1152,1164 'lead':4901 'leak':2596,5179 'legaci':285 'len':2328,3430,3441 'length':2326 'let':645,3221,4574 'level':1866 'lidar':4530 'lifecycl':5002,5063,5092,5593 'limit':126,217,350,674,755,838,844,1262,1281,3081,3088,3094,3104,3110,3175,4524,4561,4607,4972,5429,5442 'limiter.acquire':3120 'linear':618,1045,1084,1095,1146,1155,1521,2713,2719,2723,2737,2743,2756 'linearx':612,620 'list':4028,4030,5215,5410,5581 'listen':3501,5533 'load':3645,4890,5204,5420 'load-from-environment-vari':3644 'local':323 'localhost':5535 'locat':3520,3562,3595 'lock':4329,4386,5354 'lock-protect':4328,4385,5353 'log':1865 'logger':1002,1409,1828,1883 'logic':333 'login':226 'loop':238,730,738,749,1708,2891,4771,4791,4810,4825,4852,5348 'low':208,229,2380 'low-lat':2379 'low-medium':207 'lower':5453 'lx':650,657 'm/s':1157 'mac':781 'mac-bas':780 'main':1736,1778,1840,1857,1898,1899,1957,1987,2064,5337 'main.py':822,1751 'maintain':5194 'make':5170 'manag':235,241,3985,5003,5093,5594 'mani':2170 'map':581 'match':3228 'math':1039 'max':1267,1282,1286,1292,1295,2251,2268,2360,2515,2739,2747,3265,4629,4634 'maximum':5460 'may':5569 'mb':4428 'media':2363 'medium':199,209,233,239 'memori':2595,4935,5029 'messag':681,929,1720,2486,3072,3107,3122,4396,4545,4584,4904,5118,5507 'messagetyp':517,607 'method':1202,3851,3878 'middlewar':833,1193,3841,3868 'min':1289,1294,1324,1328,2265,2281,2284,2509,2551,2741,2749,3184,3267 'minim':777 'minimum':4730 'minut':306 'misbehav':763 'miss':1623,3808 'mjpeg':71,251,254,2205,2207,2223,2247,2258,2309,2347,2357,3592 'mjpeg_stream.py':2222 'model':2180 'monitor':61 'monitor.internal.example.com':3873 'motion':5471 'ms':181,185,189,193 'msg':543,1012,1025,1090,1103,2036,4592,4599,4614,4618,5308 'msg.angular':1097 'msg.data':544,560,1018 'msg.linear':1092 'msg.pose.pose.orientation':1041,1043 'msg.pose.pose.position':1031,1034,2041,2045 'msg.twist.twist.angular':1051 'msg.twist.twist.linear':1047 'msgs/image':4403 'msgs/msg/compressedimage':519 'msgs/msg/twist':609 'msgs/s':4601 'msgs/srv/getmap':575 'multi':1797 'multi-thread':1796 'multipart':2261,2308 'multipart/x-mixed-replace':2365 'multipl':744,2200,4877 'multithreadedexecutor':876,1763,1801,1996,2073,4910,4970,4978 'must':234,240,2497,4697 'mutat':5547 'n':2315,2322,2334,2337,2342,4602 'name':515,571,605,1532,1538,1558,1560,1562,1576,1582,1588,1630,1635,1637,1654,1897,1954,2004,2421,3236,3244,3299,3505,4043,4050,4051,4055,4060,4065,4080,4082,4083,4096,4100,4105,4125,4130,4141,4146,4147,4182,4203,4204,5228,5236,5250,5273,5282,5287 'nativ':250,2212 'nav':574 'nav_client.call':4819,4844 'nav_msgs.msg':892 'navgoal':4817,4842 'navig':2965,3032,3053,4815,4840 'need':299,330,345,364,376,391,1420,2106,2113,2146,2156,2165,2216,3381 'net':3930,3935 'network':324,407,3488,3888,3895,3909,3915,3924,3926,3941,3953,3969,4552,4678,5016,5574 'never':1940,1943,1969,3479,3978,4332,5088,5344,5509,5587,5612 'new':479,511,567,577,601,616,1350,2563,2575,4152,4187,4209,4211 'nginx':3476,3490,3498,3946,5523 'node':100,815,853,872,901,903,921,1006,1140,1174,1214,1224,1226,1273,1276,1460,1463,1507,1510,1547,1549,1552,1599,1603,1606,1669,1672,1768,1781,1787,1806,1808,1891,1959,1961,1965,2006,2067,2070,2075,2077,2097,2242,2249,2350,2353,2359,2405,2412,2420,2505,2508,2630,2675,2678,2680,2852,2855,2857,3011,3014,3055,3281,3284,3347,3920,4037,4039,4041,4073,4075,4117,4119,4353,4926,4953,4954,4983,4984 'node.create':5246 'node.get':4382 'node.latest':4364 'non':383,3048,4390 'non-block':3047,4389 'non-websocket':382 'none':935,939,1078,1311,1341,1471,1616,1712,2007,2299,2519,2532,3369,3811,3826,4856 'notif':4166,4261 'notifi':4113,4133,4180 'np':3338,3385,3393 'np.frombuffer':3387 'np.uint8':3390 'null':647,665 'num':1802,4979 'numpi':3336 'oauth2':220,792 'odom':1022,1067,1464,1467,1469,1487,2033,2907,2910,2912,2928,2929,3291 'odometri':894,973,1026,1455,1479,1486,2023,5265 'ok':1520 'old':4121,4142,4148,4150,4184,4205,4207 'one':1921,3943,3948 'onjoystickmov':649 'onjoystickreleas':661 'openapi':2186 'openapi/swagger':2147 'option':866,933,937,1058,1069,1120,1309,3628,3787 'origin':1189,1196,3833,3844,3859,3871,5578,5580 'os.environ':3650 'overhead':202,679,4482 'overview':166 'packag':2391,2413 'package.xml':849 'page':159 'param':1531,1537,1554,1557,1561,1575,1581,1587,1608,1613,1629,1632,1636,1639,1653,3796,4042,4056,4064,4077,4104,4201,4242,4249 'param.type':4088 'param.value':1564,4053,4085 'param_broadcaster.handle':4263 'param_broadcaster.notify':4140 'param_names.items':4058 'parambroadcast':4170,4244 'paramet':157,1265,1536,1543,1556,1574,1586,1595,1627,1652,2424,3779,3984,3994,4019,4029,4033,4045,4049,4070,4079,4095,4111,4124,4128,4164,4259,4669 'parameter_api.py':3989 'paramupd':4021,4108 'pass':1399,1431,1880,2555,2778,3307,3523,3565,3598,5126 'patch':3854 'path':5274,5285,5290,5303,5313 'pattern':5,408,798,1900,2204,2942,2952,4270 'payload':3721,3742 'payload.get':3729 'per':1259,1278,3113,3131,3140,3149,4411,4429,4537,4562,4608,5439 'per-client':1258,1277,5438 'perf':280 'perform':676,2453 'period':5049,5606 'piec':5357 'ping':2878 'ping/pong':5119 'point':769,824,1731,4536,5624 'pool':4800 'port':432,442,450,1863,1974,2090,2425,4997 'post':1204,1494,3880,5549 'practic':8 'prefix':4016,4047 'prevent':2594,2654,5483 'previous':2591 'privat':5573 'problem':4279,4399,4528,4658,4757,4873,5004,5157 'proceed':5314 'process':3230,3318,4930 'product':121,258,276,402,1191,1873,3652,4655,4688,5590 'progress':2988,2989 'project':803 'proper':5091,5541 'protect':3756,3767,3772,4330,4387,5355 'proto':3558 'protocol':3515 'prototyp':107,272 'provid':3709 'proxi':3478,3497,3522,3526,3531,3540,3547,3552,3564,3568,3572,3578,3583,3597,3601,3607,4713,4737,5527,5542 'prune':5050,5607 'pub':984 'public':406 'publish':145,592,634,723,978,986,1080,2650,2795,4531,4680,5473 'push':2830 'put':1205,3881,5550 'py':812 'pydant':1134,2179,4005 'python':857,1109,1233,1439,1750,1936,1976,2221,2462,2611,2827,2958,3089,3232,3322,3615,3829,3988,4339,4453,4580,4801,4941,5057,5216 'qos':943,953,970,977 'qosprofil':880,954,2026,4498 'qualiti':396,2384,3309,3313,3324,3327,3349,3355,3379,3383,3404,3405,3450,3456,3458,3465,3467 'queri':1264,3778,3795 'queue':532,539,3126 'quick':106,274 'r':2314,2321,2333,2336,2341 'rad/s':1169 'rais':1472,1567,1617,1645,1678,1837,1842,3701,3733,3745,4091,4159 'rate':125,349,530,754,761,837,843,1261,1280,3080,3087,3093,3103,3174,3226,3251,4523,4560,4579,4606,5428,5441,5454 'rate-limit':4605 'rate_limiter.py':834,3090 'raw':2499,3980,4394,4401,4457,4475,5505 'rclpi':141,827,868,1724,1738,1759,1789,1875,1906,1946,1980,1992,5323 'rclpy.create':1960 'rclpy.exceptions.parameternotdeclaredexception':1566,1644 'rclpy.executors':874,1761,1994 'rclpy.init':1779,1958,2068 'rclpy.node':870 'rclpy.node.node':2010 'rclpy.parameter':1628 'rclpy.parameter.parameter':4129 'rclpy.qos':878 'rclpy.shutdown':1892,2098,5394 'rclpy.spin':1912,1939,1964,4281,4284,4351,4881 're':3375 're-encod':3374 'read':1423,1540,3584,4372,4388,5360,5565 'read-on':1422,5564 'readi':259,1677,2139 'real':2161,3536,4257 'real-tim':2160,4256 'reason':3807,3822,5299 'receiv':582,1832,2647,2801,5480 'redi':4936 'reduc':3311,3325,3449 'reduct':3310 'refer':5610 'regist':2864 'reject':5208 'releas':644,673 'reliabilitypolici':881 'reliabilitypolicy.best':956,2028,4500 'reliabl':955,2027,2944,4499 'reload':1871,1876 'remap':2439 'remot':2435,3538 'remov':5045,5089,5602 'request':2176,2950,3223,3258,3263,3268,3278,3632,3689,3690,3697,3849,4337,4576,4760,4775,4821,4847,5209,5224,5240,5451 'request-respons':2949 'requir':245,2372,5557 'reset':3202,3204 'respond':2972,4779 'respons':257,2263,2951,4818,4848 'response.result':4828,4863 'rest':17,45,87,190,194,377,1434,1443,2115,3518,3757,3987 'restrict':1186,4702,5577 'result':579,1695,1704,2998,4827,4862 'result.map.info.height':585 'result.map.info.width':583 'result.message':1721 'result.success':1719 'retain':5613 'return':1062,1073,1227,1452,1483,1518,1559,1634,1717,1941,2053,2059,2354,2410,3168,3197,3199,3710,3741,3769,3810,3813,3825,4048,4081,4145,4362,4380,4826,4861,5307 'revers':3477,3496,4712,4736,5526 'revok':2590 'risk':5192 'robot':25,35,65,77,130,483,805,808,1180,1450,1454,1505,2190,2566,2609,2656,2822,2831,2841,3482,3621,3669,3839,3890,3908,3931,3934,3951,3961,5485 'robot-host':482,2565 'robot.example.com':3506 'robotapiauth':3662,3755 'robotapiauth._verify_token':3814 'robotbridgenod':900,1142,1175,1274,1461,1508,1550,1604,1670,1770,1782,2244,2250,2351,2506,2632,2676,2853,3012,3282,3348 'role':795 'role-bas':794 'ros':422,423,478,513,514,569,570,603,604,1139,1173,1213,1225,1272,1459,1506,1548,1602,1668,1767,1780,1786,1807,2005,2066,2069,2076,2241,2248,2349,2358,2377,2390,2394,2415,2418,2504,2629,2674,2679,2851,2856,3010,3054,3280,3346,3964,4038,4072,4116 'ros.on':486,492,499 'ros2':2,11,42,47,67,82,99,119,156,337,361,434,445,462,563,717,814,852,902,1107,1437,1542,1594,1934,3044,3919,3954,3983,3993,4274,4302,4313,4723,4738,4871,4896,4925,5167,5367 'ros2-web-integration':1 'ros_node.destroy':1890,2096 'ros_node.estop_client.call':1690 'ros_node.estop_client.service':1675 'ros_node.get':1334,1408,1465,1555,1827,1882,2061,2292,2524,3289,3362,4044,4078,4123 'ros_node.publish':1511 'ros_node.py':813,858 'ros_node.set':1626,4127 'rosauth':216,461,778,4701 'rosbridg':103,170,290,295,315,410,417,426,436,447,464,475,491,708,731,4653,4659,4699,4717,4725,4740 'rosbridge-awar':314 'rosbridge-suit':425 'rosbridge_websocket_launch.xml':438,449,466,4727,4742 'roslib.message':617 'roslib.ros':480 'roslib.service':568 'roslib.servicerequest':578 'roslib.topic':512,602 'roslibj':471 'rout':4345 'router':4014 'router.get':4025,4059 'router.put':4099 'router.websocket':4245 'run':66,133,1698,1709,1722,1733,1853,1911,2658,3892,4652,4708,4792,4853,4874,4923,5325,5334 'runaway':5484 'runtimeerror':1430 'safe':911,924,4325 'safeti':2641,2736 'satur':4550 'save':5639 'scan':4350,4365,4379,4384,4538,4589,4611,4638,4641,4643,4647 'scheme':3559,3754,3764 'sec':4355 'second':3114,3132,3141,3150 'secret':786,3642,3724 'secur':318,3473,5191 'segment':3889 'self':916,1011,1024,1057,1068,1083,2013,2035,2050,2673,2688,2794,2850,2865,2892,3129,3161,3203,3674,3688,4173,4181,4223,4591,4613,4627 'self._compressed_cb':4497 'self._data':2020,2039,2043 'self._data.copy':2054 'self._image_cb':968 'self._image_timestamp':940,1019 'self._last_refill':3155,3182,3190,3215 'self._latest_image':932,1016,1063 'self._latest_odom':936,1029,1076 'self._latest_odom.copy':1074 'self._latest_scan':4617 'self._lock':930,1015,1028,1061,1072,2018,2038,2052,3157,3177,3210,4616 'self._odom_cb':975,2025 'self._raw_cb':4468 'self._tokens':3151,3183,3187,3193,3195,3211 'self._verify_token':3711 'self._watchdog':2704 'self.burst':3142,3153,3185,3213 'self.clients':2858,2914,2934,4596 'self.clients.add':2870 'self.clients.copy':2921 'self.clients.discard':2886 'self.cmd':982 'self.cmd_vel_pub.publish':1102 'self.command':2814 'self.create':964,971,985,997,2021,4464,4493 'self.estop':995 'self.get':1001,4639 'self.last':2681,2693,2760,2773,2811 'self.rate':3138,3189 'self.ros':2677,2854 'self.ros_node.get':2908 'self.ros_node.publish':2753,2786,2817 'self.subscribers':4174,4217 'self.subscribers.add':4228 'self.subscribers.copy':4195 'self.subscribers.discard':4240 'send':371,670,1303,1320,1354,1366,1369,1395,1499,2271,2280,2288,2304,2782,2877,2960,3121,3249,3329,3356,3416,3421,3427,3431,3437,3439,3442,3446,3453,3462,3848,4400,4444,4488,5009,5021,5631 'send_times.append':3426 'send_times.pop':3434 'sendveloc':611,656,666 'sensor':518,945,952,969,976,2197,3239,4402,4526 'sensor_msgs.msg':884 'sent':4512 'separ':246,4929,5322 'serial':201,678,683,4598 'serv':155,1985 'server':137,249,437,448,465,732,774,2419,2423,2971,2981,2991,3085,3500,3504,4295,4360,4441,4558,4726,4741,4869,4894,5019,5333,5459,5501 'server-sid':3084,4557,5500 'servic':43,83,564,588,712,990,1438,1667,1686,4667,4752,4778,4804,4831 'service_client.call':4759 'servicetyp':573 'set':101,431,1585,1592,2859,2861,2917,3527,3532,3541,3553,3573,3579,4103,4109,4175,4177,4191,4663,5035,5044,5097,5099,5136,5385,5601 'setinterv':655 'setup.cfg':851 'setup.py':850 'share':785,817,912,925,4326,4373,4934,5345,5350 'shut':1885 'shutdown':1745,1817,1819,1823,1830,1846,1850,5381,5387 'shutdown_event.set':1833 'side':2558,3086,4442,4559,5502,5646 'signal':1749,1753,1831,5383 'signal.sigint':1845 'signal.signal':1844,1848 'signal.sigterm':1849 'signum':1825 'silenc':2670 'simpl':282,2114,2219,4569 'simplerosnod':2009,2071 'simplic':2451 'simultan':2171 'singl':727,735,4069,4974 'single-thread':726 'singlethreadedexecutor':4943,4951 'size':533,540,3117,3135,3143,3145,3154,3186,3214 'skill':29,50,55 'skill-ros2-web-integration' 'small':4913 'socket':4940 'source-arpitg1304' 'specif':356,5582 'spin':1739,1788,1809,1981,2078,4306,4311,4334,4341,4368,5397 'spin_thread.join':1893 'spin_thread.start':1816,2085 'spinner':4872,4966 'ssl':444,452,3503,3507,3510,3514 'stale':537,5051 'stall':4898 'start':825,1944,1970,2697,3351,4958,4990 'startup':2904 'starv':747 'state':818,913,926,1217,2832,4327,4374,5351 'staticmethod':3713 'status':1451,1458,1474,1484,1519,1569,1619,1641,1647,1680,2058,2823,2828,2842,2927,2976,2986,2996,3040,3070,3703,3735,3747,3768,3770,5567 'statusbroadcast':2839 'std_srvs.srv':896 'stop':642,994,1662,1666,1685,1834,5392 'store':1212,4620 'str':1539,1589,2327,3245,3718,4066,4087,4106,4162,4183,5237,5283 'stream':36,68,244,281,388,1232,1239,1246,1249,1425,2163,2194,2203,2206,2208,2224,2386,2444,2454,2465,2476,2480,3240,3343,3594,3606,4393,4624,5432,5435 'streamingrespons':2239,2355 'string':5248 'structur':804 'sub':5245 'subscrib':504,758,765,963,4115,4135,4432,4455,4484,5161,5219 'subscript':742,965,972,2022,4465,4494,4527,5247,5316 'success':1718,2999 'sudo':419 'suit':104,171,296,411,418,427,4660 'sum':3438 'super':917,2014,3679,3695 'support':790,1257 'sync':210 'synchron':1910,4751,4761,4803 'sys':1755 'system':12,286,398,1457,4922,5181 't0':3409,3419 'tab':5014 'tabl':168 'tag':2211,4018 'target':1812,2081,4956,4986 'task':2699,2701,2703,2901,3051 'team':2131 'technolog':15 'teleop':146,2614,5554 'teleop_handler.py':2612 'teleophandl':2634 'termin':3495,5516 'text':2883,4235,5123 'theta':1036 'thread':242,728,860,910,923,1737,1743,1757,1793,1798,1803,1810,1841,1858,1907,1916,1931,1978,1988,1990,2079,2092,3098,4296,4309,4319,4324,4361,4799,4879,4884,4914,4973,4980,5321,5330,5338,5342,5378,5398 'thread-saf':909,922,4323 'threading.event':1821 'threading.lock':931,2019,3158,5372 'threading.thread':1811,2080,4955,4985 'throttl':520,529,4573 'throughput':198,3108,4921 'throw':5084 'time':862,1116,1304,1321,1367,1396,2162,2231,2622,2683,2695,2762,2775,2813,2979,3096,3357,3422,3432,3440,3443,3447,3624,4258,4571,5031,5447 'time-bas':4570,5446 'time.monotonic':1020,1316,1368,2276,2305,2696,2763,2810,3156,3179,3216,3410,3418 'time.sleep':2283,2300 'time.time':1489,3732 'timeout':1697,1715,1894,2617,2663,2803,2815,3585,4354,4859,5403,5463 'timestamp':1393,1488 'titl':1179 'tls':3494,3947,5515,5529 'tls-termin':3493 'tls/https':3474 'tlsv1.2':3516 'tlsv1.3':3517 'togeth':1725 'token':783,835,3091,3101,3112,3130,3139,3148,3167,3611,3655,3666,3716,3717,3723,3739,3752,3776,3784,3797,3799,3802,3809,3815,3824,4566,5443 'token-bas':3610 'tokenbucketratelimit':3100,3111 'tool':284,317,2125 'topic':263,704,711,842,906,2443,2457,3235,3243,3297,3298,4435,4487,4666,4703,4745,5155,5164,5185,5199,5211,5227,5232,5235,5249,5257,5261,5272,5278,5281,5284,5286,5289,5294,5301,5302,5311,5312,5405,5411,5498 'topic-agent-skills' 'topic-ai-coding-assistant' 'topic-claude-skills' 'topic-robotics' 'topics/services':357 'tornado':736 'track':3420,5039,5060,5598 'traffic':3982 'transform':366 'translat':3975 'transmiss':5493 'transport':2428 'tri':1312,1426,1553,1607,1852,2086,2516,2705,2872,2922,3017,3163,3285,3720,3812,4076,4120,4196,4230,5112,5141 'trigger':898,999 'trigger.request':1692 'true':453,468,1200,1314,1815,2084,2093,2274,2521,2707,2771,2805,2880,2906,3016,3169,3198,3287,3359,3678,3876,4232,4637,4744,4989,5078,5114 'twist':615,633,890,987,1091,5269 'two':236,3940 'type':864,1118,1211,1386,2319,2364,2578,2926,3626,3887,4009,4086,4200,5309 'ui':97,2607,2826 'unauthent':5571 'unbound':5037 'uncompress':4405 'uncondit':5156 'unix':4939 'unknown':3026,3074 'untrust':3487 'updat':1642,2982 'upgrad':3575,3577,3582,5538 'url':481,2581,2592,2603,5584 'url.createobjecturl':2582 'url.revokeobjecturl':2600 'usag':2369,3109 'use':27,53,271,289,294,338,733,779,1870,1928,2108,2152,2217,2387,2947,3489,3649,3791,3794,4445,4564,4689,4698,4734,4781,4796,4909,5496,5588,5630 'user':2129,3913,3925,3929,3945 'user-fac':3912 'uvicorn':138,829,1732,1765,1835,1854,4875,4946,4975 'uvicorn.run':1859,4959,4991 'valid':2177,5559 'valu':1563,1590,1601,1609,1611,1614,1624,1631,1633,1638,1640,2734,4023,4052,4071,4084,4112,4122,4126,4131,4143,4149,4151,4153,4185,4188,4206,4208,4210,4212 'value.get':1610 'variabl':3648 'vel':154,983,1046,1050,1082,1496,1513,2755,2788,2819 'veloc':593,980,1156,1168,1501,2652,2667,2797,5469,5475 'verifi':3715,3782 'version':1183,3570 'via':255,908,1263,1748,2178,2375,2445,3963,3986,3995,4167,4322,4704,4788,4933 'video':243,248,389,2195,2202,2385,3593,5489 'virtual':597 'vlan':3932,3962 'vs':291,2103 'w':1044 'wait':1693,5115 'want':352,2174,2182 'warn':1410 'wast':4415,5026,5186 'watchdog':2618,2642,2698,2700,2793,5464 'watchdog_task.cancel':2780 'web':3,14,32,48,58,96,131,136,247,335,806,809,919,1004,1181,1772,1887,2120,2606,2825,3483,3897,3917,3937,3973,4266,4294,4359,4868,4893,5159,5176,5317,5332,5415,5512 'web_app.py':819,1110,1236,1442 'web_bridge.launch.py':848 'webrtc':72,2374,2376,2389,2393,2414,2417,2422 'webrtc_bridge.launch.py':2397 'websit':3835,3846 'websocket':19,74,92,182,186,205,252,384,476,1125,1229,1237,1247,1248,1256,1412,2118,2157,2446,2461,2464,2478,2479,2485,2564,2613,2626,2689,2690,2836,2846,2860,2866,2867,2871,2887,2954,3006,3007,3056,3241,3242,3344,3345,3560,3588,3634,3773,3785,3786,3789,4002,4114,4134,4168,4176,4224,4225,4229,4241,4251,4252,4253,4265,4398,4414,4447,4491,4548,4672,5005,5072,5098,5107,5234,5280,5434,5537,5555,5595,5616 'websocket.accept':1271,2503,2692,2869,3009,3247,4227 'websocket.app.state.ros':1275,2507,3013,3283 'websocket.close':1428,3804,3819 'websocket.query_params.get':1285,3798 'websocket.receive':2710,2882,3020,3256,4234 'websocket.send':1384,2545,2768,3035,3065,3295,3412,4516 'websocketdisconnect':1126,1398,2554,2627,2777,2885,3078,3306,4003,4239,5125 'webviz':311 'whatev':5221 'wildcard':5585 'wire':702 'within':2802,5481 'without':143,196,4656,5008 'work':301,2209 'worker':4878,4961,4964,4976,4993 'wrap':1436 'write':111 'written':5365 'ws':2562,3783,4193,4216,4222,4250,4264,4628,5070,5071,5076,5105,5106,5111,5129,5233,5279 'ws.accept':5074,5109 'ws.binarytype':2569 'ws.close':5296 'ws.onmessage':2571 'ws.receive':5122 'ws.send':4198,4645,5080 'x':584,619,626,1030,1032,1048,1085,1093,1096,1147,1515,1522,1524,2040,2042,2714,2720,2724,2738,2744,2757,2967,3060,3535,3544,3549,3556 'x-forwarded-for':3543 'x-forwarded-proto':3555 'x-real-ip':3534 'y':621,628,1033,1035,2044,2046,2969,3062 'yaml':2392,4714 'yes':265,266 'yet':1482 'yield':2257,2306,2311 'z':623,630,1042,1052,1088,1098,1101,1159,1517,1526,1528,2717,2727,2731,2746,2752,2759 'zero':671,2651,2666,2783,2796,5474","prices":[{"id":"929dacf3-7f3f-4b2f-8013-9fe84b8c1e9f","listingId":"d9a3db82-5c65-422e-8bba-0976c2fa1199","amountUsd":"0","unit":"free","nativeCurrency":null,"nativeAmount":null,"chain":null,"payTo":null,"paymentMethod":"skill-free","isPrimary":true,"details":{"org":"arpitg1304","category":"robotics-agent-skills","install_from":"skills.sh"},"createdAt":"2026-04-18T22:05:39.139Z"}],"sources":[{"listingId":"d9a3db82-5c65-422e-8bba-0976c2fa1199","source":"github","sourceId":"arpitg1304/robotics-agent-skills/ros2-web-integration","sourceUrl":"https://github.com/arpitg1304/robotics-agent-skills/tree/main/skills/ros2-web-integration","isPrimary":false,"firstSeenAt":"2026-04-18T22:05:39.139Z","lastSeenAt":"2026-05-02T18:54:21.281Z"}],"details":{"listingId":"d9a3db82-5c65-422e-8bba-0976c2fa1199","quickStartSnippet":null,"exampleRequest":null,"exampleResponse":null,"schema":null,"openapiUrl":null,"agentsTxtUrl":null,"citations":[],"useCases":[],"bestFor":[],"notFor":[],"kindDetails":{"org":"arpitg1304","slug":"ros2-web-integration","github":{"repo":"arpitg1304/robotics-agent-skills","stars":189,"topics":["agent-skills","ai-coding-assistant","claude-skills","robotics"],"license":"apache-2.0","html_url":"https://github.com/arpitg1304/robotics-agent-skills","pushed_at":"2026-03-25T03:44:12Z","description":"Agent skills that make AI coding assistants write production-grade robotics software. ROS1, ROS2, design patterns, SOLID principles, and testing — for Claude Code, Cursor, Copilot, and any SKILL.md-compatible agent.","skill_md_sha":"14ae3d42ad612691d4d006d9c0ac7d760fd89511","skill_md_path":"skills/ros2-web-integration/SKILL.md","default_branch":"main","skill_tree_url":"https://github.com/arpitg1304/robotics-agent-skills/tree/main/skills/ros2-web-integration"},"layout":"multi","source":"github","category":"robotics-agent-skills","frontmatter":{"name":"ros2-web-integration","description":"Patterns and best practices for integrating ROS2 systems with web technologies including REST APIs, WebSocket bridges, and browser-based robot interfaces. Use this skill when building web dashboards for robots, streaming camera feeds to browsers, exposing ROS2 services as REST endpoints, or implementing bidirectional WebSocket communication between web UIs and ROS2 nodes. Trigger whenever the user mentions rosbridge, rosbridge_suite, roslibjs, FastAPI with ROS2, Flask with rclpy, WebSocket for robot telemetry, MJPEG streaming, WebRTC for robots, REST API wrapping ROS2 services, web-based robot control, browser robot interface, robot dashboard, CORS configuration for robots, or any web-to-ROS2 bridge pattern. Also trigger for authentication on robot web interfaces, rate limiting sensor streams, video streaming from robot cameras to browsers, or running async web frameworks alongside the ROS2 executor. Covers rosbridge_suite, FastAPI, Flask, WebSocket, and WebRTC approaches."},"skills_sh_url":"https://skills.sh/arpitg1304/robotics-agent-skills/ros2-web-integration"},"updatedAt":"2026-05-02T18:54:21.281Z"}}