ROS 2 小车网页按钮控制改造:从 Web 到 cmd_vel 的完整链路
发布时间:2026/10/18 2:23:20 锦皓数字建站

我桌面上这辆 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/sangular.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( packagerobot_driver, executablemotor_controller, namemotor_controller, parameters[{serial_port: /dev/ttyUSB0}] ), launch_ros.actions.Node( packagerobot_driver, executableweb_bridge, nameweb_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 fP{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(timeout0.1) node.publish_twist(linear, angular) except queue.Empty: pass rclpy.spin_once(node, timeout_sec0.05)rclpy.spin_once放在后台循环里每轮都顺手处理一下订阅回调这样不会丢掉其他话题的消息。队列里的指令如果连续来会逐条取出发布如果队列空了就相当于不发布新指令驱动板那边收不到新的串口指令电机也不会保持上一次的状态太久。4.2 前端按钮交互与事件处理前端部分一开始做很简单后来发现“按下按钮”这个交互在移动端有额外讲究。PC 上可以依赖mousedown和mouseup但手机和平板上触屏环境会触发touchstart、touchend、touchcancel如果只监听鼠标事件手机上就等于不能用。我后来用统一的事件绑定同时监听鼠标和触摸两类事件并且在 JS 里做了最小化防抖。button>
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。