joystick 输入生产者:100 Hz 发布与 0.2 秒失联门的缺口

作者:

在

结论:joystick_control 是一个输入生产者:它要么读终端键盘、要么读游戏手柄,然后由一个独立的守护线程以 100 Hz 目标节拍发布 testJoystick。三个事实决定它的边界:轴值是运行期估计(极值动态扩展,未标定);发布线程不检查输入年龄与 cancel,采集阻塞时旧轴值仍能被封装成新的 valid=True 消息;下游 joystickd 的约 0.2 秒帧差失联门只感知消息停发,识别不了「重复旧输入的新消息」。要注意这描述的是软件路径,不是实车会保持执行器动作的结论——selfdriveState、card 和 panda 安全策略仍独立约束输出。

两个实现:终端键盘与游戏手柄

入口带一个容易被误读的开关:直接执行脚本时会检查 IsOffroad,但设置 ZMQ 环境变量会跳过该入口检查,而 main() 本身直接构造游戏手柄。这是开发工具的启动约束,不是强制的进程级安全策略,ZMQ 的存在也不能视为网络身份认证。

组件 / 函数 具体行为 边界与含义
Keyboard.update() 阻塞读取一个字符;W/S 与 A/D 分别按 0.05 累积,R 归零,C 将本地 cancel 置真,下一次输入又先置假。 轴值不自动归中;输入停止不触发归零,长期保持的值由发布线程继续发送。
KBHit.__init__()、set_kbhit_terminal()、set_normal_term() 从 sys.stdin 取 fd,保存 termios,清除 ICANON/ECHO 并以 TCSAFLUSH 应用;注册进程正常退出时的恢复回调。 非 TTY 的 tcgetattr() 会失败;异常/信号强杀不保证 atexit 执行,终端状态不是按对象生命周期恢复。
KBHit.getch()、getarrow()、kbhit() 单字符阻塞读、三字符方向键读取、select(..., 0) 非阻塞就绪查询分别作为静态方法暴露。 Keyboard.update() 只调用 getch(),未先查询 kbhit();getarrow() 假设读满三字符,畸形序列可抛异常。
Joystick.update() get_gamepad()[0] 处理首个事件,按 PC/车载不同映射转换纵横轴;采集到的极值动态扩展区间,再插值、死区与三次曲线。 极值是运行期估计,不是经标定的设备物理量;只按首个事件更新。已处理的拔出异常会归零,其他异常或采集线程停止不进入该分支。
send_thread() 独立守护线程以 100 Hz 目标节拍发布 testJoystick,把外层 valid 置真,只复制 axes_order 对应的两轴值。 不检查采集线程是否阻塞、上次真实输入时间或 cancel。
Joystick schema 结构有 axes 和 buttons;服务表频率为 0。 当前生产者只填 axes;cancel 不是该生产者消息的一部分,下游也只取轴值。

发布线程不看输入:100 Hz 与约 0.2 秒失联门

send_thread() 周期性复制 axes_values。采集线程没有为每次输入维护时戳,发布线程也不检查 joystick.cancel。因此在主采集循环阻塞、设备静默但不抛已处理异常时,生产端仍会产生新的 valid=True 两轴消息。joystick_control_thread() 先同步写入 JoystickDebugMode=True,启动发布线程,主线程无限调用 update(),没有显式 stop/join/模式撤销协议。

下游 joystickd 的约 0.2 秒帧差失联门只感知消息停发,不会识别这类「重复旧输入的新消息」。探针也印证了这一点:send_thread() 在轴值不变且 cancel=True 时仍连续发送相同的两轴,每条 valid=True 且不填 buttons。这里要重复一次边界——这描述的是软件路径,selfdriveState、card 和 panda 安全策略仍独立约束输出,不能据此断言实车会保持执行器动作。

生命周期缺口 源码事实 后果
输入年龄门 采集线程无每次输入时戳,发布线程不查上次输入时间 采集阻塞仍发布旧轴为「新消息」
取消传播 C 只改本地 cancel,且下次按键即清除;消息不含取消字段 取消不会作为消息传给下游
发布时钟 发布按 100 Hz 目标节拍,与采样时钟无关 发布时钟不能代表采样新鲜度
关闭握手 无显式 stop/join/模式撤销协议 进程退出才停;不能推断采集卡住时会停止发布

模式参数与下游 joystickd:两种进程切换方式

JoystickDebugMode 在 manager 启动/下线时切换清理:车载调试模式用它把控制权在 controlsd 与 joystickd 之间切换。这里要区分两件事——参数清理不等于 testJoystick 发布生命周期监测,模式选择与输入有效性需要分开验证。下游 joystickd 只取轴值并设置帧数超时门,而 card 与 panda 各自还有独立的安全门。

游戏手柄一侧的映射也不是只有一套:Joystick.update() 按 PC 与车载两套映射转换纵横轴,采集到的极值动态扩展区间后,再经插值、3 百分比死区与三次曲线。探针确认 PC 扳机翻转后会再归一化,已处理的拔出异常会清零轴值。源文档还列出若干待验证项:不同游戏手柄 inputs 事件顺序与极值、真实拔出/静默行为、GIL 调度下两个线程的读写时序、消息停发后的实际时间,以及车辆安全钩子的响应;当前归档目录没有针对这些边界的专用原仓库单元测试,本篇的隔离探针不能替代真实设备验证。

输入契约:采样时钟不能由发布时钟代替

源文档对插件化的要求很明确:输入插件输出应包含设备身份、采样单调时戳、序号、校准版本、轴语义/单位以及明确的取消事件,发布时钟不能代替采样时钟;宿主按真实输入年龄与设备健康授予短期租约,过期、拔出、采集线程失活或异常时撤销租约,并且发布无效/零值不能伪装成新鲜的真实操纵;测试控制与生产控制分属隔离命名空间,即使测试输入有效,也只形成受监督的候选指令,必须继续通过唯一控制仲裁和独立硬件安全门。

还要留意终端资源这块的实测边界:Keyboard() 构造时才创建 KBHit,但 joystick_control_thread() 没有 finally 或显式调用 set_normal_term();正常 Python 退出时 atexit 尝试恢复,被强制终止则不能保证。此外该类注释里「Windows no-op」的说法与模块顶层 import termios 不一致,不能把它当跨平台终端插件接口。

常见问题

为什么「消息还在发」不代表输入还新鲜?

因为 send_thread() 按 100 Hz 目标节拍复制 axes_values,而采集线程没有为每次输入维护时戳,发布线程也不检查上次真实输入时间或 cancel。采集阻塞但进程仍在时,旧轴值仍会被封装成新的 valid=True 消息。

终端里按 C「取消」会传给下游吗?

不会。C 只是把本地 cancel 置真,且下一次输入又会先置假;cancel 并不是该生产者消息的一部分,下游 joystickd 也只取轴值。因此不能把终端说明中的取消键理解为已传播的取消命令。

游戏手柄拔出后轴值会归零吗?

对已处理的 OSError/UnpluggedError 会归零并返回假;但源文档指出其他异常或采集线程停止并不进入该归零分支,而且极值是运行期估计,不是经标定的设备物理量。

设置 ZMQ 环境变量是否等于更安全?

不是。设置 ZMQ 只是跳过 CLI 入口的 IsOffroad 检查,改变的是本地检查,不能被当作网络身份认证或进程级安全策略。

延伸阅读

参考资料

  • 项目 02 —— openpilot-research/JOYSTICK_INPUT_PRODUCER_DEEP_DIVE.md(取用了键盘与游戏手柄两种输入实现、100 Hz 发布与约 0.2 秒失联门缺口、六项隔离探针结果与插件化输入契约)。

说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

更新记录

首次发布 2026-10-11 最近核对:2026-10-11

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注