☰
ROS 2 小车网页按钮控制改造:从 Web 到 cmd_vel 的完整链路
2026/10/18 2:22:51 网站建设 项目流程

我桌面上这辆 ROS 2 小车已经陪我折腾了不少时间,过去控制它都得在终端里敲命令,键盘节点一启动,车倒是能跑,旁边看着的人却插不上手。最近我把它做了次改造,给车加了一个网页控制层——浏览器里放几个按钮,点一下“前进”,小车就真的往前动了。这个改造不算复杂,但涉及的链路挺完整:从 Web 前端到后端服务,再到 ROS 2 话题和电机驱动,每一步都有值得记录的细节,所以打算分几期把它完整写下来,这一期先说整体思路和第一个能跑的版本。

1. 为什么突然要做一个网页按钮控制

1.1 改造之前的痛点

小车原来跑的是 ROS 2,平时控制方式就是ros2 run teleop_twist_keyboard teleop_twist_keyboard,听起来很正常,但实际上非常局限。第一,键盘控制需要一台连着 SSH 的电脑,哪怕人在小车旁边,也得拿起笔记本才能动;第二,teleop 节点是持续性的,你按住 i 是前进,松开它并不会自动刹车,得手动按空格才能让它停下来;第三,这也是最实际的一点,这个操作逻辑对不懂 ROS 的人来说基本不可用。朋友过来想看车跑一圈,我只能递给他笔记本,告诉他按 i、j、l、, 这几个字母,结果就是他紧张地盯屏幕找键,车歪歪扭扭差点撞墙。

我当时的想法很简单:ROS 2 小车其实不缺“能动的能力”,缺的是一个让普通人也敢上手的交互层。把 Web 页面当成遥控器,最大的好处是零门槛。浏览器人人会开,按钮人人会点,“按下去动、松开停”的逻辑也符合直觉。改造完之后,任何人拿起手机或者平板,打开页面就能控制小车,这才是这辆车从“开发板+电机”变成“像个产品”的关键一步。

1.2 网页控制能带来什么

一旦 Web 控制层跑通,后续很多玩法都能在这条链路上扩展。比如在页面上叠加实时视频流,做成带摄像头画面的“远程驾驶舱”;比如把键盘控制替换成虚拟摇杆,让操作手感更接近 RC 遥控车;再比如把按钮按下的坐标记录下来,做路径复现演示。也就是说,这次做的不是一次性脚本,而是一个可以持续加功能的控制框架。第一期我先把最基础的部分做扎实:按钮、指令传输、话题发布、电机动作,以及整套链路的调试方法。

为了防止第一期就失控,我把目标收敛得很小:打开一个本地网页,页面上四个主要按钮(前进、后退、左转、右转),按下后小车按预设速度运动,松开后立即停止。先不玩花活,把“点到车动”这条链路的每一环都吃透。

2. 方案选型:先想清楚控制和通信两条链路

2.1 底盘与电机驱动的现实约束

这辆车用的是两轮差速底盘,两个直流减速电机,搭配一块电机驱动板,再加一组编码器接口。两轮差速最大优势是运动模型简单:左右轮速度相同就是直线走,速度不同就能画弧,方向相反就是原地旋转。和汽车常用的阿克曼转向相比,差速底盘做低速巡检、室内避障、原地掉头都非常方便,所以小型机器人项目里用的特别多。

驱动板通过串口接收指令,电机控制本质上就两路输出:左电机占空比、右电机占空比。我在底层协议里定义成一行字符串,比如P120,140这种格式,代表左轮 PWM 数值 120、右轮 PWM 数值 140。之所以不直接传最终的角速度和线速度,是为了让底层只做“执行”,把运动学解算放到上层。这样驱动板固件可以保持极简,以后换驱动板或者加主控,上层代码不受影响。

实际选型时需要考虑一个很现实的问题:电机驱动板对输入电压和电流很敏感,供电不足时会在启动瞬间压降,导致驱动板复位,表现就是车偶尔“抽搐”一下。我这次做改造时把供电做了拆分:电机电源和主控电源分开,共地但不同路,这样电机急加速时主控不会掉电重启。这是硬件侧最容易踩的坑,比代码问题隐蔽得多。

2.2 主控模块和 ROS 2 运行环境

主控模块选的是目前很常见的 ARM 开发板,运行 Ubuntu 系统,预装 ROS 2。为什么一定需要一个完整系统而不是单片机?因为 Web 控制需要跑 HTTP 服务、WebSocket 服务和 ROS 2 节点,这些都需要操作系统的网络栈和进程管理,裸单片机上做这些成本非常高,所以主控模块必须是一个能跑 Linux 的东西。

ROS 2 的通信模型是发布/订阅,所有节点围绕话题进行消息传递。控制小车的关键话题是/cmd_vel,消息类型是Twist,里面有两个核心字段:linear.x是线速度,单位 m/s;angular.z是角速度,单位 rad/s。linear.x为正表示前进,为负表示后退;angular.z为正表示逆时针转向,为负表示顺时针转向。差速底盘的运动学解算,就是把这两个量换算成左右轮各自的期望速度。

我在设计里把整个系统拆成三层:应用交互层(Web 页面)、桥接层(WebSocket 服务 + ROS 2 话题)和物理执行层(底盘驱动节点 + 电机驱动板)。这三层之间用话题和网络协议衔接,每一层都能独立测试。这也是我在这个项目中比较满意的地方,拆开之后,调试效率提升非常明显。

2.3 Web 交互路线:rosbridge 还是自建后端

聊到 Web 控制 ROS 2,绕不开rosbridge_server这个方案。它是机器人社区里相当成熟的 Web 到 ROS 的桥接工具,前端通过 WebSocket 直接往话题发 JSON 指令,看起来最“正统”。我一开始也试过,装依赖、开 rosbridge、前端用 roslibjs 连接,整个链路确实能通。但问题也很明显:rosbridge 本身是一个独立进程,又多了一层协议转换,一旦连接出现异常,排查时要同时看前端、rosbridge、ROS 2 节点三边的日志,对单人项目来说负担不小。

而且 rosbridge 的安全模型偏宽松,Web 页面上能访问的 ROS 2 API 范围很大,如果只是想让别人“按按钮开车”,没必要放开这么大权限。于是最终决定自建一个极薄的后端服务:Flask 提供 Web 页面和静态文件,WebSocket 专门负责接收前端的控制指令;后端把收到的按钮事件翻译成Twist消息,发布到/cmd_vel。这相当于把桥接层控制在自己手里,整个链路上每个环节出了什么问题,都能以比较直观的方式定位。

关于技术选型的建议:如果以后要在浏览器里做 RViz 那样的复杂可视化,rosbridge 值得考虑;如果只是想做一个能用的遥控器页面,自己写桥接服务更可控,代码量其实也就一二百行。

3. ROS 2 侧改造:把速度指令变成物理动作

3.1 功能包结构设计和启动配置

ROS 2 的代码组织方式以功能包为单位。我新建了一个功能包叫robot_driver,放在工作空间的src目录下面。创建命令很简单:

ros2 pkg create --build-type ament_python robot_driver

功能包里主要放两个节点:motor_controller负责订阅/cmd_vel并通过串口控制电机;web_bridge负责接收来自后端的 WebSocket 数据并发布/cmd_vel。我把两个节点分开,而不是写进一个大节点,是因为它们的生命周期不同:底盘驱动节点电机上电后就要运行,而 Web 桥接节点只有页面打开时才需要工作。分开之后,以后如果换成手柄控制,只需要换掉web_bridge节点就行,motor_controller不用动。

一个容易被忽略的工作是启动文件。手动开两个节点不是不行,但每次都要敲两遍ros2 run,而且串口设备名每次都可能变化,环境变量一多就容易乱。我写了一个launch.py启动文件,把参数集中管理起来:

import launch import launch_ros.actions def generate_launch_description(): return launch.LaunchDescription([ launch_ros.actions.Node( package='robot_driver', executable='motor_controller', name='motor_controller', parameters=[{'serial_port': '/dev/ttyUSB0'}] ), launch_ros.actions.Node( package='robot_driver', executable='web_bridge', name='web_bridge', parameters=[{'publish_topic': '/cmd_vel'}] ), ])

之后只需要跑ros2 launch robot_driver bringup.launch.py,两个节点一次性起来。这个习惯非常重要,项目越往后,节点数量越多,没有一个统一的启动入口迟早会走到“手工维护一堆终端窗口”的老路上。

3.2 订阅 cmd_vel 并完成差速解算

电机驱动节点的主要工作是订阅Twist消息,再做差速解算。差速解算公式并不复杂:

左轮期望速度 = 线速度 - 角速度 × 轮距 / 2
右轮期望速度 = 线速度 + 角速度 × 轮距 / 2

这个公式理解起来可以用一个生活化类比:站在地上原地转身时,你左脚和右脚移动方向是相反的;如果想要原地向右转,左脚需要往后走一点,右脚往前走一点,差值越大,转身越快。轮距就是左脚到右脚之间的距离,在公式里乘以角速度,核心原因是角速度是对角度变化的描述,必须乘以一个长度量才能换算成线速度。

不过这里有个关键点:ROS 2 的Twist消息描述的是车体坐标系下的速度,单位是米每秒和弧度每秒,而驱动板最终接收的是 PWM 占空比。所以必须在中间做一个“速度标定”映射。简单做法是,先给电机定一个固定 PWM 值,测出小车一秒内走过的距离,推算出 PWM 和速度的线性系数。这个系数在不同电池电量下会有偏差,但作为第一版已经够用了。

核心代码大概是这样的:

from geometry_msgs.msg import Twist def cmd_callback(self, msg: Twist): v = msg.linear.x w = msg.angular.z base_width = 0.18 # 两轮中心距,单位:米 left_speed = v - w * base_width / 2.0 right_speed = v + w * base_width / 2.0 pwm_left = self.speed_to_pwm(left_speed) pwm_right = self.speed_to_pwm(right_speed) self.send_to_motor_board(pwm_left, pwm_right)

speed_to_pwm函数里头放的是实测得到的线性映射关系,不是纯理论公式。这里我特别想提醒一点:不要一开始就指望让速度 PID 闭环,第一版先把“PWM 到速度的近似映射”跑通就很不错了,闭环控制留到编码器反馈稳定以后再上,否则调试变量太多,出了 bug 根本不知道问题在电机还是算法。

3.3 串口通信与底层执行细节

串口通信是整个底层驱动中最容易出问题的一环。表面上看就是打开串口、写字符串,实际操作中会遇到设备权限、波特率不匹配、数据粘包等一连串问题。我用的串口设备是/dev/ttyUSB0,如果权限不对,节点启动时会直接报错打不开设备。解决方法是把当前用户加入dialout组:

sudo usermod -aG dialout $USER

加完组之后必须注销重新登录才生效。这个坑我踩过一次,执行完usermod就继续跑,结果还是权限不足,折腾了半天才发现是要重新登录。

串口通信的数据格式我定义得非常简单:每行一条指令,类似P120,140\n,一条指令对应左右轮的 PWM 值。发送时必须保证每一条指令都是完整的一行,不能半截就 flush 出去。如果驱动板固件是按行解析的,半截数据会导致它等到超时才解析,整体响应速度瞬间变慢。为了保证原子性,我会在串口写方法里加一个线程锁:

def send_to_motor_board(self, left_pwm, right_pwm): payload = f"P{left_pwm:03d},{right_pwm:03d}\n" with self.serial_lock: self.ser.write(payload.encode())

还有一个细节是串口发送频率的控制。如果前端每 10 毫秒发一次指令,那么节点收到消息就立刻往串口写,一秒钟写 100 行,驱动板不一定会处理好如此密集的数据流。我在节点里做了速率限制,把串口的写入频率控制在 20Hz 到 30Hz 之间,也就是每 30 到 50 毫秒发一次。实测下来这个频率既能保证响应流畅,又能避免驱动板被数据淹没。

4. Web 端实现:让按钮事件变成话题消息

4.1 后端服务的线程模型设计

Web 端这块我做得比预想中更久,坑基本都出在线程模型上。最初很自然的想法是:Flask 收到 HTTP 请求后,直接在视图函数里调用 ROS 2 节点的 publish 方法。写起来很短,但跑起来就出问题。原因是 rclpy 的上下文是和初始化它的线程绑定的,Flask 的工作线程和初始化 rclpy 的线程不是同一个,调用 publish 时经常被告知只能在注册上下文的线程中执行操作。

后来我换成了标准的“后台线程 + 线程安全队列”模型。Web 后端只负责接收和解析前端的控制指令,把解析结果放进一个queue.Queue里;单独跑一个后台线程,里面初始化一个 ROS 2 节点并持续 spin,同时不断从队列中取出最新指令发布到/cmd_vel。这样的好处是,ROS 2 相关操作始终在自己专有的线程里执行,Web 侧谁也不碰 rclpy,完美避开跨线程问题。

import queue import threading import rclpy from rclpy.node import Node from geometry_msgs.msg import Twist command_queue = queue.Queue() class WebBridge(Node): def __init__(self): super().__init__('web_bridge') self.publisher = self.create_publisher(Twist, '/cmd_vel', 1) def publish_twist(self, linear, angular): msg = Twist() msg.linear.x = linear msg.angular.z = angular self.publisher.publish(msg) def ros_spin_thread(): rclpy.init() node = WebBridge() while True: try: linear, angular = command_queue.get(timeout=0.1) node.publish_twist(linear, angular) except queue.Empty: pass rclpy.spin_once(node, timeout_sec=0.05)

rclpy.spin_once放在后台循环里,每轮都顺手处理一下订阅回调,这样不会丢掉其他话题的消息。队列里的指令如果连续来,会逐条取出发布;如果队列空了,就相当于不发布新指令,驱动板那边收不到新的串口指令,电机也不会保持上一次的状态太久。

4.2 前端按钮交互与事件处理

前端部分一开始做很简单,后来发现“按下按钮”这个交互在移动端有额外讲究。PC 上可以依赖mousedown和mouseup,但手机和平板上触屏环境会触发touchstart、touchend、touchcancel,如果只监听鼠标事件,手机上就等于不能用。我后来用统一的事件绑定,同时监听鼠标和触摸两类事件,并且在 JS 里做了最小化防抖。

<button>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询