openpilot Joystick 调试工具详解:用 testJoystick 消息链路实时调试横纵向控制
2026/9/14 8:25:44 网站建设 项目流程

openpilot Joystick 调试工具详解:用 testJoystick 消息链路实时调试横纵向控制

【免费下载链接】openpilotopenpilot is an operating system for robotics. Currently, it upgrades the driver assistance system on 300+ supported cars.项目地址: https://gitcode.com/GitHub_Trending/op/openpilot

Joystick 调试模式是 openpilot 提供的控制链路调试通道:它在停车/Offroad 状态下启动,用一个虚拟的控制器进程joystickd取代正常的controlsd,将手柄或键盘输入转换为carControl消息,从而绕过感知与规划模型,直接验证执行器与 CAN 通信栈。读完本文,你可以掌握三种连接方式(键盘、comma 3 手柄、笔记本远程手柄)的完整操作步骤,并理解JoystickDebugMode参数如何切换进程、testJoystick消息如何以 100Hz 流经系统,以及纵向 4.0 m/s² 加减速与 3.0 m/s² 横向加速度上限等关键实现细节。

一、Joystick 调试模式的架构角色

Joystick 调试的核心思想是:joystickdcontrolsd二选一。openpilot 的进程管理器根据JoystickDebugMode参数决定启动哪一个,源码位于 process_config.py:

def joystick(started: bool, params: Params, CP: car.CarParams) -> bool: return started and params.get_bool("JoystickDebugMode") def not_joystick(started: bool, params: Params, CP: car.CarParams) -> bool: return started and not params.get_bool("JoystickDebugMode")

进程注册表中两者互斥(process_config.py#L95-L96):

PythonProcess("controlsd", "openpilot.selfdrive.controls.controlsd", and_(not_joystick, iscar)), PythonProcess("joystickd", "openpilot.tools.joystick.joystickd", or_(joystick, notcar)),

从源码结构看,joystickd的启动条件是“上车后且开启 JoystickDebugMode”或“非车辆环境(notcar,如 PC 模式)”之一。这意味着在真正的 comma 设备上,该参数必须在 offroad 时写好,上车后 manager 才会拉起joystickd而不是controlsd

消息链路

整条链路只依赖一个 capnp 服务testJoystick,其结构体定义在 log.capnp#L2118-L2122:

struct Joystick { # convenient for debug and live tuning axes @0: List(Float32); buttons @1: List(Bool); }

它在事件表中注册为testJoystick @52 :Joystick;(log.capnp#L2613),服务配置在 services.py#L86 中为(True, 0.)——即始终启用、频率为 0(按需触发,不做频率存活检查)。数据流为:

  1. 输入端joystick_control.py读取手柄/键盘,以 100Hz 发布testJoystick
  2. (可选)网络桥接:laptop 上运行时,comma 设备上的bridge二进制把 ZMQ 消息转发回 msgq;
  3. 控制端joystickd.py以 100Hz 订阅testJoystick等 5 路服务,计算后发布carControlcontrolsState

二、准备工作与三种使用方式

按 README 的要求,硬件需求为:运行 openpilot 的设备、一台笔记本、手柄(可选)。关键前提:车辆必须熄火(off),且 openpilot 必须处于 offroad 状态才能启动joystick_control——这一点也由源码强制校验(见下文 4.3 节)。

2.1 键盘模式

SSH 到 comma 设备后执行:

openpilot/tools/joystick/joystick_control.py --keyboard

启动后会打印按键映射。按键语义完整继承自源码 joystick_control.py#L17-L40:

按键作用说明
W/S加速 / 刹车映射到gb轴,每次按键 ±0.05(满行程的 5%)
A/D向左 / 向右转向映射到steer轴,同样 5% 步进
R复位两个轴清零
C取消巡航cancel = True,最终体现在CC.cruiseControl.cancel

键盘值会被np.clip裁剪到 [-1, 1] 区间(joystick_control.py#L37)。键盘读取基于 tools/lib/kbhit.py 的KBHit类,它通过termios将终端设为无回显、无缓冲的原始模式(ICANON | ECHO置反),并用atexit注册退出时恢复终端设置。

2.2 手柄直连 comma 3

将手柄插入 comma 3 的aux USB-C 口,然后 SSH 到设备运行:

openpilot/tools/joystick/joystick_control.py

README 明确该场景下源码注释标注了官方适配目标:“This class supports a PlayStation 5 DualSense controller on the comma 3X”(joystick_control.py#L45-L46)。

2.3 笔记本手柄(网络模式,四步)

由于joystick_control运行在笔记本上,它无法直接访问 comma 设备内部的 msgq 队列,需要一条“笔记本 → ZMQ → bridge → msgq”的路径:

  1. 手柄接入笔记本;
  2. 笔记本连接 comma 设备的热点,SSH 打开新终端。因为joystick_control在笔记本上运行(不会自己写参数),需要手动写参数让controlsd体系知道进入 joystick 调试模式:
    # on your comma device echo -n "1" > /data/params/d/JoystickDebugMode
  3. 在 comma 设备上运行 bridge,以笔记本 IP 为源、以testJoystick为服务白名单,把笔记本发出的包重新发布到本地 msgq:
    # on your comma device openpilot/cereal/messaging/bridge {LAPTOP_IP} testJoystick

    bridge 的二进制入口在 bridge.cc#L60-L63:argv[1]即来源 IP,argv[2]即服务白名单,与上述命令的占位符一一对应。

  4. 在笔记本上以 ZMQ 模式启动:
    # on your laptop export ZMQ=1 openpilot/tools/joystick/joystick_control.py

    ZMQ=1会让 messaging 库通过 ZeroMQ 而非本地共享内存发送,对应 cereal/messaging 下的bridge_zmq.cc实现。

2.4 上车验证

完成上述任一路径后,启动车辆,openpilot 上车时应进入 joystick 模式并在启动时弹出告警:告警上显示两个轴的数值,按钮状态则打印在运行joystick_control的 shell 中(源码中每 20 帧打印一次,即约每 0.2s,见 joystick_control.py#L100-L101)。

还需注意 panda 侧的门控:必须满足 panda 允许控制的条件(例如开启巡航),否则carControl里的执行器不会生效。README 提到也可以修改 panda 代码让其恒放行,但这属于危险操作,仅建议在封闭场地内进行。

三、输入端实现:joystick_control.py 源码解析

joystick_control.py 约 147 行,由输入抽象(Keyboard/Joystick两个类)、发布线程与主线程三部分组成。

3.1 键盘抽象

class Keyboard: def __init__(self): self.kb = KBHit() self.axis_increment = 0.05 # 5% of full actuation each key press self.axes_map = {'w': 'gb', 's': 'gb', 'a': 'steer', 'd': 'steer'}

update()每次取一个按键:raxes_values全部清零;c置 cancel;w/a取正增量、s/d取负增量并裁剪到 [-1, 1]。

3.2 手柄抽象:动态归一化 + 指数响应曲线

Joystick类基于inputs库(支持多种常见手柄/游戏杆),按硬件类型区分轴映射(joystick_control.py#L48-L56):

环境加速轴转向轴触发器翻转
PC(HARDWARE.get_device_type() == 'pc'ABS_ZABS_RXABS_RZ→ 加速轴取负
comma 设备ABS_RXABS_ZABS_RY→ 加速轴取负

“触发器翻转”指左手柄(brake trigger)的原始事件取反后合并进加速轴,实现“右推加速、左推刹车”的单轴双功能。取消按钮固定为BTN_NORTH(DualSense 的三角/X 键),按下边沿置cancel = True、释放边沿复位。

归一化与手感曲线是这段实现的核心(joystick_control.py#L83-L88):

self.max_axis_value[event[0]] = max(event[1], self.max_axis_value[event[0]]) self.min_axis_value[event[0]] = min(event[1], self.min_axis_value[event[0]]) norm = -float(np.interp(event[1], [self.min_axis_value[event[0]], self.max_axis_value[event[0]]], [-1., 1.])) norm = norm if abs(norm) > 0.03 else 0. # center can be noisy, deadzone of 3% self.axes_values[event[0]] = EXPO * norm ** 3 + (1 - EXPO) * norm # less action near center for fine control
  • 运行时自适应标定:min/max 不预先写死,而是随使用过程不断扩展,再用np.interp线性映射到 [-1, 1] 并取反(手柄原点在中间);
  • 3% 死区:中心区域噪声直接归零;
  • EXPO 曲线EXPO = 0.4,joystick_control.py#L14):0.4·x³ + 0.6·x,让靠近中心的小幅度输入产生更小的输出,便于精细控制——这是游戏行业常用的指数响应(expo)手法。

若手柄被拔出(UnpluggedError/OSError),所有轴立即清零并返回False,是一种输入级保护。

3.3 发布线程与 offroad 校验

def send_thread(joystick): pm = messaging.PubMaster(['testJoystick']) rk = Ratekeeper(100, print_delay_threshold=None) while True: ... joystick_msg.testJoystick.axes = [joystick.axes_values[ax] for ax in joystick.axes_order] pm.send('testJoystick', joystick_msg) rk.keep_time()

Ratekeeper(100)保证严格 100Hz 发布节奏,与joystickd的 100Hz 消费节奏对齐。joystick_control_thread在启动时执行Params().put_bool('JoystickDebugMode', True, block=True)(joystick_control.py#L113)——这就是“设备本地运行时无需手动 echo 参数”的原因;而笔记本模式下该参数由 SSH 步骤手动写入,两者殊途同归。

main 中的防护逻辑(joystick_control.py#L131-L133):

if not Params().get_bool("IsOffroad") and "ZMQ" not in os.environ: print("The car must be off before running joystick_control.") exit()

即:本地模式必须 offroad 且车辆熄火;而ZMQ模式(笔记本)读不到设备侧参数,故豁免该校验,这正是 2.3 节流程中export ZMQ=1的双重作用之一。

四、控制端实现:joystickd.py 源码解析

joystickd.py 是替换controlsd的“虚拟大脑”,其输出接口与controlsd完全一致(carControl+controlsState),因此 panda、UI 等下游无需任何改动。

4.1 输入订阅与启动条件

CP = messaging.log_from_bytes(params.get("CarParams", block=True), car.CarParams) VM = VehicleModel(CP) sm = messaging.SubMaster(['carState', 'onroadEvents', 'vehicleParameters', 'selfdriveState', 'testJoystick'], frequency=1. / DT_CTRL) pm = messaging.PubMaster(['carControl', 'controlsState']) rk = Ratekeeper(100, print_delay_threshold=None)

启动时阻塞等待指纹写入的CarParams,据此构建VehicleModel(用于转角-曲率换算)。latActive/longActive的判定复用了 normal 控制的路径(joystickd.py#L34-L37):横向还需steerFault无故障;纵向要求CP.openpilotLongitudinalControl且无overrideLongitudinal事件。

4.2 安全机制:0.2 秒无输入即归零

should_reset_joystick = sm.recv_frame['testJoystick'] == 0 or (sm.frame - sm.recv_frame['testJoystick'])*DT_CTRL > 0.2 if not should_reset_joystick: joystick_axes = sm['testJoystick'].axes else: joystick_axes = [0.0, 0.0]

若从未收到testJoystick,或距上次收到超过 0.2 秒(例如joystick_control崩溃、网线断开),两轴强制置零——纵向意味着目标加速度归零(趋向停车逻辑),横向意味着目标曲率归零(回正)。这是该工具最重要的兜底防线。

4.3 纵向控制:±4.0 m/s² 的加减速映射

if CC.longActive: actuators.accel = 4.0 * float(np.clip(joystick_axes[0], -1, 1)) actuators.longControlState = LongCtrlState.stopping if should_stop(sm['carState'].vEgo, actuators.accel) else LongCtrlState.pid CC.cruiseControl.resume = actuators.accel > 0.0
  • 轴 0(gb)乘以4.0 m/s²得到目标纵向加速度,正为加速、负为刹车;
  • 当车速与目标加速度满足should_stop条件时(引自 drive_helpers),控制器状态切到stopping,即进入停车流程;
  • 只要目标加速度为正,就下发cruiseControl.resume,用于低速/停车后的恢复。

4.4 横向控制:曲率上限随车速自适应

MAX_LAT_ACCEL = 3.0 if CC.latActive: max_curvature = MAX_LAT_ACCEL / max(sm['carState'].vEgo ** 2, 5) max_angle = math.degrees(VM.get_steer_from_curvature(max_curvature, sm['carState'].vEgo, sm['vehicleParameters'].roll)) actuators.torque = float(np.clip(joystick_axes[1], -1, 1)) actuators.steeringAngleDeg, actuators.curvature = actuators.torque * max_angle, actuators.torque * -max_curvature

这是与真实controlsd中角度/曲率限制同构的安全限幅:把横向加速度限制在MAX_LAT_ACCEL = 3.0 m/s²(joystickd.py#L15),换算成当前车速下的最大曲率3.0 / max(vEgo², 5)(低速时max(..., 5)兜底防止除零级发散),再经VehicleModel换算为对应转向角。轴 1(steer)满偏对应torque = ±1,即打满上述限幅;小幅输入则按比例平滑过渡。controlsState中的lateralControlState初始化为debugState(joystickd.py#L68),UI 侧据此渲染调试状态而非真实横向控制器状态。

五、JoystickDebugMode 的开启与互斥关系

除命令行写参数外,设备 UI 也提供开关:设置 → Developer 下的 “Joystick Debug Mode” 拨动项(developer.py#L55-L61,mici 界面见 mici/layouts/settings/developer.py)。两点源码约束值得注意:

  1. 仅 offroad 可改:拨动项enabled=ui_state.is_offroad,与“上车前必须写好参数”的进程判定逻辑一致;
  2. release 构建隐藏_update_toggles中 joystick、纵向/横向 maneuver 等非发布功能在 release 包中不可见(developer.py#L120-L121);
  3. 三者互斥:开启 Joystick 模式会自动关闭LongitudinalManeuverModeLateralManeuverMode;反向开启 maneuver 模式也会清掉JoystickDebugMode(developer.py#L164-L184)。

另外从源码结构看,testJoystick还出现在 athenad.py#L802 与 webrtcd.py#L246 的bridge_services_in列表中,即该服务也参与远程会话的服务桥接体系;本文主线仍以本地/局域网两种路径为准。

六、实战注意事项小结

  • 顺序:熄火 → offroad → 写参数(或本地运行joystick_control自动写)→ 上车 →joystickd取代controlsd接管;
  • 参数文件路径/data/params/d/JoystickDebugModeecho -n "1"写单字符1(Params 的 bool 编码),对应源码put_bool('JoystickDebugMode', True)
  • 反馈通道:设备屏幕告警显示轴值(UI 侧从testJoystick.axes取值渲染,见 ui_state.py#L70 与 onroad.py#L69),shell 显示按钮/轴打印;
  • 安全底线:0.2s 输入超时归零、3% 死区、±4.0 m/s² 纵向限幅、3.0 m/s² 横向加速度限幅,以及 panda 侧的车控门控(建议先开巡航)共同构成该工具的安全边界;
  • 文件索引:工具入口 joystick_control.py、替身控制器 joystickd.py、消息定义 log.capnp、进程编排 process_config.py。

【免费下载链接】openpilotopenpilot is an operating system for robotics. Currently, it upgrades the driver assistance system on 300+ supported cars.项目地址: https://gitcode.com/GitHub_Trending/op/openpilot

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询