点一下网页里的按钮,几公里之外的 ROS2 小车真的原地起步、转弯、停下来,这种“在浏览器里按一下键盘,轮子就跟着转”的体验,确实比在终端里敲一行命令发个话题要有感觉得多。
折腾这套东西的初衷很简单:ROS2 平时调试离不开命令行,但想让完全不懂 ROS 的人也能上手操作小车,或者自己躺沙发上遥控一下,网页控制就是最现实的方案。不需要装客户端,不需要学 Linux 和 DDS 概念,打开浏览器就能用。一个人改造 ROS2 小车,我把从零到能跑的真车链路完整走了一遍,从硬件选型、环境部署、节点编写到网页下发指令,每一步都踩了坑,也把最终能用的方案整理出来。这一期先讲整体改造思路和 Web 遥控链路的完整打通方式,后面再逐步拆解每个模块的细节。
如果你手里正好有一台能跑 ROS2 的小车,或者你正在纠结怎么给机器人加一个“别人也能轻松操控”的界面,这篇内容应该能帮你少走不少弯路。
1. 整体设计与思路拆解
1.1 为什么选 ROS2 而不是 ROS1 或者直接自写一套
先说结论:ROS2 的节点通信机制天然适合这种“网页桥接”场景。
ROS1 时代做 Web 控制,一般要靠 rosbridge_suite 那一套,把 ROS 的消息转成 JSON 通过 WebSocket 发出去。ROS2 时代也有 rosbridge,但节点发现机制、DDS 实现、QoS 策略都比 ROS1 复杂得多,配置不对很容易出现“网页连上了但消息死活不通”的诡异现象。
另一个选择是完全不用 ROS,自己在电机驱动板上写串口协议,再用 Python 起一个 WebSocket 服务直接控制电机。这种做法对单个小车来说确实最简单,但一旦你想后续加摄像头、加激光雷达、做自动导航,没有 ROS 的模块化通信体系,改造起来非常痛苦。
我最终选择 ROS2 的思路是:底层控制交给 ROS2 节点,让 wheel 控制、里程计、传感器各司其职;上层用 WebSocket 作为“外网入口”,把网页端的指令翻译成 ROS2 话题消息。这样做的好处是,网页控制只是 ROS2 小车的一个“客户端”,并不依赖网页端存在才能跑车——命令行、手柄、遥控器、乃至未来的自动驾驶节点都可以向同一个 cmd_vel 话题发指令,互不干扰。
1.2 一个人改造的边界:哪些自己造,哪些直接现成
一个人搞全栈,最忌讳的是什么都想从零手写。以 ROS2 小车为例,从机械底盘到电机驱动到 SLAM 导航,全打通工作量巨大。我更建议把改造目标限定在“用网页控制小车移动”这条主线上,在这条主线上尽量自己做,其余部分用成熟方案。
我自己这次改造的边界是这样划分的:
- 硬件和机械部分:直接用现有的差速驱动小车底盘,树莓派、电机驱动板、电池等基本都是现成组件。底盘改造不是本期重点,但电源分配和驱动板供电要自己重新理一遍。
- 底层驱动:电机驱动板通过 USB 串口和树莓派通信,这部分用厂商提供的 ROS2 驱动包先跑通,不自己写寄存器级控制。
- 核心改造点:自己写一个 ROS2 节点监听 WebSocket 下发的指令,转换成 cmd_vel 消息;自建网页前端;自己搭建 WebSocket 后端桥接服务。
- 网络链路:需要解决“树莓派在家里局域网,网页端可能在任意地方访问”这个核心痛点。
这个边界的核心思路是:底层驱动能跑就行,别花大量时间在造轮子(这里真的是轮子)上;把精力花在打通网页到 ROS2 话题这条“最后一公里”上,这样才能在有限精力内真正做出能显摆的效果。
1.3 网页控制链路的整体架构
整个链路从浏览器到轮子,拆开看是这样的:
浏览器网页按钮 -> WebSocket 服务 -> ROS2 桥接节点 -> cmd_vel 话题 -> 电机驱动节点 -> 电机 -> 轮子看起来链路不长,但每一步都有自己的坑。网页按钮响应的是 JavaScript 事件,WebSocket 服务端收到的是 JSON 字符串,ROS2 桥接节点需要把 JSON 里的线速度和角速度提取出来,封装成 Twist 消息发布到 /cmd_vel,电机驱动节点的控制频率和加速度限制又要单独调参。
我在设计时把这四层明确分开:
- 前端层:一个 Web 页面,包含前后左右停车按钮,以及一个速度滑块。这一层只负责“用户意图采集”。
- 中转层:WebSocket 服务,负责接收前端连接、转发 JSON 指令,同时把小车状态推送给前端显示。这一层我单独写成独立进程,不跟 ROS2 耦合。
- 桥接层:一个 ROS2 Python 节点,订阅 WebSocket 服务的消息,转换后发布 /cmd_vel。这一层是真正跟 ROS2 打交道的地方。
- 执行层:电机驱动节点,最终消费 cmd_vel,完成 PWM 控制。
分层的設計带来的直接好处是:WebSocket 服务可以单独测试不依赖 ROS2;桥接层可以做单元测试,模拟各种非法输入;前端出问题不会影响底层驱动。一个人改造的时候,这种模块化拆分能显著降低调试复杂度。
2. 核心细节解析与实操要点
2.1 前端按钮和实时控制之间隐藏的“手感”问题
很多人做网页遥控车,第一步想到的 HTML 按钮 onClick 事件,真正实测下来体验很差。原因在于:ROS2 的 /cmd_vel 话题是“持续指令”型消息,意味着电机驱动节点收到一条 Twist 消息后会持续以该速度运行,直到收到另一条 Twist 消息(比如全零的速度)才会停止。
如果前端只在按下按钮那一刻发一条速度指令,松开按钮后小车会一直跑,停不下来。这是网页遥控最典型的“幽灵车”问题。
我的解决方案是设计两套指令机制:
- 按钮按下时发送“开始运动”指令。
- 按钮松开时发送“停止”指令。
前端要同时监听按下和松开两个事件。一个简单的 HTML 按钮如果只绑定 touchstart / mousedown 而忘了 touchend / mouseup,就会看到小车自己往前冲的“无人驾驶”现场。
给按钮增加“松开急停”逻辑后,还需要处理一个更隐蔽的场景:用户在网页上按住前进键,然后鼠标滑出了按钮区域,此时 mouseup 事件会丢失,小车同样停不下来。我最终的做法是:按钮按下后开启一个定时器,每隔 200ms 重发当前的速度指令;按钮松开或鼠标离开页面时向服务端发送“停止”指令,同时清除定时器。这样做的好处是,即使偶尔丢一条消息,后续的重发也能保持小车运行状态;一旦检测到松开或失焦,立即停车,安全性大大提升。
2.2 WebSocket 消息格式的设计不要图省事
很多博客示例直接把速度值散装成多个参数发过去,收到后再拼凑。我一开始也是这么干的,后来发现要扩展功能——比如加入摄像头云台控制、音量控制、状态显示——时,散装参数只会让消息解析代码越来越乱。
我最终采用固定 JSON 格式:
{ "type": "cmd", "linear": 0.25, "angular": 0.0, "stamp": 1710000000.123 }- type 字段用来区分“控制指令”“查询请求”“状态反馈”等不同消息类型。
- linear 是线速度,单位 m/s。
- angular 是角速度,单位 rad/s。
- stamp 是前端生成的时间戳,用于调试指令延迟。
服务端收到消息后先做一个合法性校验:linear 的值必须在 [-1.0, 1.0] 范围内,angular 必须在 [-2.0, 2.0] 范围内,超出范围直接丢弃并记录日志。这个校验非常关键,因为它可以防止前端 bug 或恶意输入把电机驱动到异常状态。
格式“固定”还有一个好处:未来如果想让网页同时控制多台小车,只需要在 JSON 里增加一个 robot_id 字段,桥接层按 id 路由,前端不需要做大的改动。虽然本期没有做多车,但这个消息格式为后续扩展留好了余地。
2.3 ROS2 桥接节点:QoS 策略是最大的暗坑
如果你只是照葫芦画瓢写一个监听 WebSocket 的 ROS2 节点,把收到的消息 publish 到 /cmd_vel,大概率会遇到一个灵异事件:Node 端明明 publish 成功了,小车就是不动,而且没有任何报错。
这通常不是代码问题,而是 QoS(Quality of Service)匹配问题。ROS2 的话题通信要求发布者和订阅者的 QoS 策略彼此兼容。电机驱动节点往往是底层 C++ 实现的,它订阅 /cmd_vel 时使用的可能是 SensorDataQoS 或 SystemDefaultQoS;如果你在桥接节点创建 Publisher 时用了默认 QoS,两者就可能因为 Reliability 策略不匹配而静默断连——消息发出去如泥牛入海。
我在桥接节点里创建 Publisher 时做了这样的配置:
from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy cmd_vel_qos = QoSProfile( depth=1, reliability=ReliabilityPolicy.BEST_EFFORT, history=HistoryPolicy.KEEP_LAST, ) self.publisher = self.create_publisher(Twist, "/cmd_vel", cmd_vel_qos)实测下来,BEST_EFFORT 配合 depth=1 是兼容性最好的组合。不过这只是针对大多数底盘驱动有效,如果还是不行,就得用ros2 topic info -v命令去查看真正的订阅端 QoS 参数,再反推配置。这个排查过程我放到后面问题章节细讲。
2.4 cmd_vel 的线速度和角速度取值策略
网页端给的值到底多大合适?这需要结合底盘的真实能力来定。我用的是一款普通差速小车底盘,电机空载最大线速度大约 0.6 m/s。如果直接在网页里给 0.6 的初始速度,小车起步会非常冲,在有障碍物的室内很容易撞墙。
我做了三档速度映射:
- 低速档:0.15 m/s,适合在狭小空间精确调整位置。
- 中速档:0.30 m/s,日常巡视和演示常用。
- 高速档:0.45 m/s,空地跑起来有点意思,但已经接近底盘极限,不建议长时间运行。
角速度同理:低速 0.5 rad/s,中速 1.0 rad/s,高速 1.5 rad/s。别试图把角速度拉到 3.0 以上,差速底盘自转太快电机输出不够,会出现“直道可以、转弯打死”的怪异现象。
这三个档位通过网页下拉框或滑块切换。滑块的好处是连续可调,但我实测发现滑块的最大问题是不小心误触会给一个意外大速度,所以最终改成了分段选择(三个按钮加一个状态显示),安全优先。
3. 实操过程与核心环节实现
3.1 改造前的硬件与软件准备
开始之前建议先列个清单,免得做到一半发现缺东西。我的环境如下:
- 小车底盘:普通差速驱动底盘,两个驱动轮加一个万向轮。
- 主控板:树莓派 4B(8GB 版本),运行 Ubuntu 22.04 和 ROS2 Humble。
- 电机驱动板:支持 ROS2 驱动包的常见驱动板,通过 USB 串口连接到树莓派。
- 电池:11.1V 3S 锂电池,通过降压模块给树莓派和驱动板分别供电。
- 路由器:普通家用路由器,小车和操作电脑连同一个局域网。
- 电脑:平时写代码用的 MacBook,用于开发前端和调试。
软件方面要把 ROS2 环境先装好。这一步我只提醒一点:如果你跟我一样是在树莓派上跑 Ubuntu 22.04,建议用 deb 包方式安装 ROS2 Humble,不要从源码编译,否则在树莓派上编译的时间足够你灌三杯咖啡。
安装完成后,验证环境:
source /opt/ros/humble/setup.bash ros2 run demo_nodes_cpp talker如果能看到正常打印,说明 ROS2 基础环境没问题。接着验证底盘驱动节点能启动、能通过命令行控制:
ros2 topic pub /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.2}, angular: {z: 0.0}}" -r 10小车能跑起来,说明底层执行链路是通的。这一步如果都通不过,后面网页控制就是在沙滩上建楼。
3.2 搭建 WebSocket 服务:不掺和 ROS2 的独立进程
我选择用 Python 的 websockets 库来搭建中转层,代码很薄,核心就是“收到前端消息 -> 简单校验 -> 通过 Unix Socket 或 TCP 转发给 ROS2 桥接节点”。
为什么不用 rosbridge?因为 rosbridge 对 ROS2 的协议支持和 QoS 控制还是不够直观,遇到问题排查起来像是在猜谜。自己写一个独立的 WebSocket 进程反而更可控,代码量也就几十行。
WebSocket 服务的核心逻辑:
import asyncio import websockets import json connected_clients = set() async def handle_connection(websocket): connected_clients.add(websocket) try: async for message in websocket: data = json.loads(message) if data.get("type") == "cmd": linear = float(data.get("linear", 0.0)) angular = float(data.get("angular", 0.0)) if abs(linear) > 1.0 or abs(angular) > 2.0: continue # 转发给桥接层,这里简洁起见直接打印 print(f"cmd -> linear={linear}, angular={angular}") elif data.get("type") == "ping": await websocket.send(json.dumps({"type": "pong"})) except websockets.exceptions.ConnectionClosed: pass finally: connected_clients.remove(websocket) async def main(): async with websockets.serve(handle_connection, "0.0.0.0", 8765): await asyncio.Future() if __name__ == "__main__": asyncio.run(main())这个服务监听 8765 端口,把校验通过的指令打印出来。看起来简单,但要注意几个细节:
- 绑定 0.0.0.0 而不是 127.0.0.1,否则局域网内其他设备无法访问。
- 对非法范围的数据直接跳过,不给下游添乱。
- 每个新连接加入 connected_clients,方便以后做广播(比如把电池电压推送给所有在线页面)。
单个 TCP 连接能承载的标准 WebSocket 转发,延迟大约在几毫秒到十几毫秒,网页遥控完全够用。如果你想达到“指哪打哪”的手感,瓶颈其实不在 WebSocket,而在后端 QoS 参数和底盘自身的响应速度。
3.3 写 ROS2 桥接节点:把 JSON 翻译成 Twist
桥接节点是整条链路的核心。它需要同时跟 WebSocket 服务和 ROS2 通信。我的做法是在 ROS2 节点内部起一个 TCP Server,监听来自 WebSocket 服务的连接,收到 JSON 后解析成 Twist 发布。
为了不让这个节点过于复杂,我用线程来处理监听逻辑:
import rclpy import socket import json import threading from rclpy.node import Node from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy from geometry_msgs.msg import Twist class BridgeNode(Node): def __init__(self): super().__init__("web_bridge_node") qos = QoSProfile( depth=1, reliability=ReliabilityPolicy.BEST_EFFORT, history=HistoryPolicy.KEEP_LAST, ) self.publisher = self.create_publisher(Twist, "/cmd_vel", qos) self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.sock.bind(("127.0.0.1", 60000)) self.sock.listen(5) self.thread = threading.Thread(target=self._listen_tcp, daemon=True) self.thread.start() self.get_logger().info("Bridge node started, listening on 60000") def _listen_tcp(self): while rclpy.ok(): try: conn, addr = self.sock.accept() except Exception: break self._handle_conn(conn) def _handle_conn(self, conn): buffer = "" conn.settimeout(0.5) try: while rclpy.ok(): try: data = conn.recv(1024) except socket.timeout: continue if not data: break buffer += data.decode("utf-8", errors="ignore") while "\n" in buffer: line, buffer = buffer.split("\n", 1) self._process_line(line) finally: conn.close() def _process_line(self, line): try: msg_data = json.loads(line) except Exception: return twist = Twist() twist.linear.x = float(msg_data.get("linear", 0.0)) twist.angular.z = float(msg_data.get("angular", 0.0)) self.publisher.publish(twist) self.get_logger().info(f"Publish -> linear: {twist.linear.x}, angular: {twist.angular.z}") def main(args=None): rclpy.init(args=args) node = BridgeNode() try: rclpy.spin(node) except KeyboardInterrupt: pass finally: node.destroy_node() rclpy.shutdown()这段代码有几个值得说的点:
- 用 TCP 而不是直接在这个节点里连 WebSocket 前端,是为了解耦。WebSocket 服务可以重启、可以分流、可以做认证,桥接节点只关心“有人把合法指令送到了 60000 端口”。
- 每次从 socket 读到数据先攒进 buffer,遇到换行符才解析一次,避免半包问题。
- 如果一行 JSON 解析失败,直接丢弃,不影响后续消息。总线积压时,宁可丢一两条指令,也不能因为解析异常把节点搞挂。
在终端里启动这个节点时,用:
ros2 run my_bridge web_bridge_node正常启动后,可以用一个简单的 Python 脚本模拟 WebSocket 服务向 60000 端口发指令测试:
printf '{"type":"cmd","linear":0.2,"angular":0.0}\n' | nc 127.0.0.1 60000如果小车直线前进,说明链路已经通了百分之七十。
3.4 把 WebSocket 服务和桥接节点串起来
上面两个进程还彼此独立。我把它们用 TCP socket 串起来。WebSocket 服务收到前端指令后,不打印,而是通过网络发给桥接节点的 60000 端口:
import socket def forward_to_ros2(linear, angular): try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect(("127.0.0.1", 60000)) line = json.dumps({ "type": "cmd", "linear": linear, "angular": angular, }) + "\n" s.sendall(line.encode()) s.close() except Exception as e: print(f"forward error: {e}")这里用 TCP 而不是直接用 rclpy 的 Python API 去 publish,一个重要原因是:WebSocket 服务不需要依赖 ROS2 环境。这意味着它可以在一个完全不装 ROS2 的服务器上运行,以后把网页服务部署到公网服务器时,树莓派端只需要保留桥接节点的反向连接即可。
在树莓派上分别启动 WebSocket 服务和桥接节点后,用浏览器打开前端页面,任意按一个方向键,小车应该就能做出响应。我在这里实测下来的整体延迟(从按下按钮到轮子转动)大约 100~150ms,视觉上会有一点点“迟滞感”,但对于遥控小车来说属于正常范围。
3.5 网页前端的完整实现
前端页面不需要任何框架,一个 HTML 文件足够。关键是一个九宫格方向控制区域,外加速度档位切换按钮。方向控制用键盘和鼠标都支持:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>ROS2 小车控制器</title> <style> body { font-family: sans-serif; margin: 40px; } .control-pad { display: grid; grid-template-columns: repeat(3, 120px); gap: 10px; } .btn { padding: 20px; font-size: 24px; border: 2px solid #333; border-radius: 8px; cursor: pointer; user-select: none; } .btn:active { background: #4caf50; } .speed { margin-top: 20px; } .speed-btn { padding: 10px 20px; margin-right: 10px; } .status { margin-top: 20px; font-size: 14px; color: #666; } </style> </head> <body> <h2>ROS2 小车遥控器</h2> <div class="control-pad"> <div></div> <button class="btn" id="forward">ros2 topic pub /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.2}, angular: {z: 0.0}}" -r 5用 nc 向桥接节点发指令,确认桥接节点有输出日志。没有日志就看 TCP 是否连到了 60000 端口。
如果在桥接节点收到消息且有 publish 日志,但小车不动,90% 是 QoS 不匹配。运行:
ros2 topic info /cmd_vel -v这条命令会显示发布者和订阅者的 QoS 配置对比。如果发布者是 BEST_EFFORT,订阅者是 RELIABLE,它们之间其实是可以匹配的(因为兼容规则允许 BEST_EFFORT 发布者连接 RELIABLE 订阅者),但反过来 RELIABLE 发布者连 BEST_EFFORT 订阅者时不一定兼容。这一点ROS2 的匹配规则不是绝对的,要先确认两者的实际策略再改。
我在实际项目里遇到的情况是:电机驱动节点订阅 /cmd_vel 用的 QoS depth=10、RELIABLE,桥接节点发布用 depth=1、BEST_EFFORT,反而正常。这不是什么标准答案,只是经验之谈。用ros2 topic info -v看清楚再动手,比乱试快得多。
4.3 小车运动时一顿一顿的,像“抽风”
这不是机器人坏了,而是指令下发频率不够稳定。当你按住方向键时,前端每 200ms 发一次指令,这个频率对于 ROS2 的 cmd_vel 来说偏低。
底盘驱动节点通常期望以 10Hz~20Hz 持续收到速度指令,如果你 5Hz 发一次,中间空隙时间驱动板可能进入“收不到新指令就停车”的安全状态,于是小车一会走一会停。
解决办法有两个方向:
- 提高发送频率:把前端定时器从 200ms 改成 50ms,即 20Hz 下发。这个改动最简单,但要注意网页端脚本性能和网络开销。
- 更优雅的做法:在桥接节点里做速度保持,记录最近一次收到的速度值,并以 10Hz 的频率持续发布相同速度,直到收到新的不同速度指令或收到停止指令。这样即使前端偶尔丢包,小车也能保持平稳运行。
我最终选择了第二种方案,桥接节点里加了一个维持定时器和上次指令缓存。效果立竿见影,小车从“抽搐”变成了“丝滑”。
4.4 高速档下小车起步猛地“抬头”
这是电机响应太快、加速度没有限制的问题。底盘驱动节点一般最好支持加速度限制参数,如果不支持,需要在桥接节点做软件的加减速平滑。
我在桥接节点里加了一个简单的斜坡限制器:每次收到新目标速度时,不是直接发布,而是以固定步长逐步逼近目标值。比如目标线速度 0.45m/s,每 50ms 增加 0.05m/s,到达目标需要 450ms。这个“软启动”机制让起步和停车都变得柔和,省得小车一冲一停伤电机齿轮。
def _smooth_publish(self, target_lin, target_ang): current_lin = self.current_lin current_ang = self.current_ang step_lin = 0.05 if target_lin >= current_lin else -0.05 step_ang = 0.10 if target_ang >= current_ang else -0.10 while abs(target_lin - current_lin) > 0.001 or abs(target_ang - current_ang) > 0.001: current_lin = min(target_lin, current_lin + step_lin) if step_lin > 0 else max(target_lin, current_lin + step_lin) current_ang = min(target_ang, current_ang + step_ang) if step_ang > 0 else max(target_ang, current_ang + step_ang) twist = Twist() twist.linear.x = current_lin twist.angular.z = current_ang self.publisher.publish(twist) self.get_logger().info(f"Publish smoothed -> linear: {current_lin:.2f}, angular: {current_ang:.2f}") time.sleep(0.05)这个逻辑虽然简单,但在实际遥控里非常有用。第一次带着这个平滑逻辑去测试时,小车的运动质感一下子从“玩具级”提升到了“工程级”。
4.5 内网穿透后画面延迟高
网页控制本身需要传输的数据量很小(一个按钮指令才几个字节),延迟主要来自链路的中继节点和网络质量。如果用手机的 5G 公网访问,延迟 100ms 左右正常;如果你在同一个 wifi 下走公网域名访问,数据绕了一圈反而增加延迟,建议局域网内直接用 IP,出门再用穿透域名。
这里还有一个实际体验细节:页面上除了控制按钮,我还放了一个视频显示区域(通过另一个 WebRTC 流推摄像头画面)。控制指令走 WebSocket,视频流走 WebRTC,两者互不影响。把控制通道和媒体通道混在一起是新手常犯的错,会导致页面卡顿——按下按钮后,视频缓冲把控制指令挤到后面了。
4.6 树莓派供电不足导致重启
小车在跑高速档时机身电流需求大,如果树莓派和电机驱动板共用一个未经稳压的电池输出,瞬间压降可能让树莓派直接断电重启。
一个常见错误是把舵机/电机电源接到树莓派 5V 引脚上。电机启动时的大电流烧毁树莓派电源电路的风险极高,一定不要这么干。要单独给驱动板供电,树莓派用独立的 5V 降压稳压模块。如果电池电压是 11.1V,用一块支持持续 3A 输出的降压模块给树莓派供电。
我实测发现,树莓派 4B 满载时电流 1A 左右,加一个 USB 摄像头之后大约 1.5A,所以降压模块至少要留 50% 的余量,选择 3A 输出比较稳妥。
5. 安全提醒与遇到问题的速查表
网页遥控的另一个核心问题是安全。当你有了一定控制能力,危险也随之而来。
我在改造过程中给自己定了几条规则:
- 永远不要把高速档设为默认档位,默认中速甚至低速才是安全的。
- 网页上一定要有明确的急停按钮——不是“停”按钮,而是真的杀停逻辑:前端断线时、WebSocket 服务关闭时、桥接节点停止时,都要自动发布全零速度。
- 在桥接节点中保存“最后一条指令时间”,如果超过 1 秒没有收到任何指令,自动发布停止消息。这是一道最后防线。
- 不要在无人看管的场景下让小车长时间自动运行,尤其是远程模式下,意外情况可能超出你的预判。
这个最后防线我放在了桥接节点的回调里:
def _safety_check(self): if self.last_cmd_time is not None: elapsed = time.time() - self.last_cmd_time if elapsed > 1.0: stop = Twist() self.publisher.publish(stop) self.last_cmd_time = time.time()有了这套机制,哪怕前端浏览器突然崩溃、网络断开、WebSocket 服务宕机,小车也会在一秒内自动停下来,不会变成“脱缰野马”。
几个高频问题和我的最终处理方式汇总成表,方便你参考:
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 网页打不开 | WebSocket 服务没启动或端口错 | 检查 8765 端口监听日志 |
| 已连接但车不动 | QoS 不匹配或底盘驱动没起 | 查ros2 topic info -v确认配置 |
| 小车一顿一顿 | 指令下发的频率太低 | 增加速度保持机制到桥接节点 |
| 高速起步猛冲 | 缺少加速度限制 | 使用斜坡限制器平滑过渡 |
| 树莓派频繁重启 | 供电不足或压降过大 | 独立降压模块给树莓派供电 |
| 公网控制延迟大 | 数据走了较长的中继链路 | 局域网用 IP,公网再用穿透服务 |
| 网页掉线后车不停 | 断线没有安全逻辑 | 加超时急停机制 |
6. 后续还能做什么
这一期把“网页按钮 -> ROS2 -> 小车轮子”这条链路完全跑通了,从整体架构设计、消息格式定义,到桥接节点的 QoS 配置、速度平滑处理,都能直接应用到自己的小车上。
如果你也想自己动手改造,我个人的建议是从“底层能不能转”开始验证,不要一上来就写网页。先确保ros2 topic pub /cmd_vel能驱动小车,再逐步往上加 WebSocket 层,最后做页面美化,这样每一步的 bug 都隔离在一个小范围内,排错效率最高。
下一期我打算细拆这部分:如何把摄像头画面接到网页里,以及给前端加一个可视化里程计和电量监控,让遥控体验逼近一个“伪产品”。如果你有特别想先看的模块,也可以在评论区告诉我,我会按优先级调整踩坑顺序。