简介:这是一份面向Python开发者的PS4手柄控制工具库,适用于游戏开发、交互设计、自动化测试及个人创意项目等场景。pyPS4Controller提供了简洁的事件监听接口,可获取手柄按键、摇杆状态、电池电量,并支持振动反馈与多手柄管理,帮助开发者绕过复杂的蓝牙底层通信,快速构建自定义控制逻辑,使设备交互开发变得更加高效。压缩包体积仅12KB,共13个文件,以Python源码为主,包含controller.py主控模块、cli.py命令行入口及__init__.py等,另有README说明文档与setup.py配置,便于安装、查阅与二次开发。通过该资源,读者可获得完整的库源码、基本用法的示例代码以及打包配置信息,从而快速掌握PS4手柄的接入与控制,为桌面应用、VR交互或机器人控制等项目添加硬件输入能力。已有152人浏览学习,适合需要低成本实现手柄输入的Python开发者。
1. 为什么要碰 pyPS4Controller 的 tar.gz 源码包
pyPS4Controller 1.2.1 这个 Python 库,把 Sony DualShock 4 手柄的输入抽象成一组可重写的回调方法,让按键、D-pad、摇杆、触摸板都能以事件形式进入 Python 程序。tar.gz 源码分发意味着你能直接看到 controller.py 里的实现,而不是像 USB HID 设备那样只能黑盒读写。它的典型场景是树莓派小车、机械臂、游戏手柄按键映射工具,以及那些不想用 ROS 又要用手柄控制算法的桌面程序。工程里凡是需要“人工介入”的环节,比如巡检机器人、视频云台、六轴机械臂,都会把它塞进 requirements。适不适合你:Python 基础越弱越值得用,因为它的回调接口把 pyPS4Controller 最麻烦的原始 HID 解析挡在外层,你要写的就是把事件变成动作。下文按解压安装、按键轴事件、蓝牙配对、排错和自定义映射的顺序讲完,不用再去找杂七杂八的 Python 教程。
2. 从 tar.gz 源码包安装 pyPS4Controller:解包、装包、事件模型
2.1 Linux 下解压 tar.gz 并完成 Python 安装
拿到pyPS4Controller-1.2.1.tar.gz后,第一件事不是双击解压,而是先看压缩包里有什么。Linux 下解压 tar.gz 最稳的命令是下面这组:
ls -l pyPS4Controller-1.2.1.tar.gz tar -tzf pyPS4Controller-1.2.1.tar.gz | head -30 tar -xzf pyPS4Controller-1.2.1.tar.gz cd pyPS4Controller-1.2.1 python3 -m pip install .tar -tzf里的-t表示只列出内容清单,-z告诉 tar 数据是 gzip 压缩的,-f指定后面跟着文件名。这一步看起来多余,但对排查问题非常有用:你能提前看到包内是否有setup.py、pyproject.toml、README.md,从而判断这个包是用 setuptools 还是新版构建后端。随后的-xzf才是真正解压,-x是 extract。解压后一定先进目录再执行pip install .,因为.指向当前目录下的构建配置,pip会在当前目录寻找setup.py或pyproject.toml,然后把库安装到当前 Python 解释器环境里。
很多人卡在这一步,报错是tar: pyPS4Controller-1.2.1.tar.gz: Cannot open: No such file or directory。这个提示的意思不是压缩包损坏,而是命令所在的当前目录里根本没有这个文件。常见原因有三个:一是在 VSCode 的终端里直接解压,但 VSCode 的终端工作目录默认是项目根目录,不是下载目录;二是浏览器下载时把文件名改成了pyPS4Controller-1.2.1(1).tar.gz;三是用sudo tar但文件本身在普通用户目录下,sudo 后的 shell 路径和处理权限变了。在 VSCode 终端里如果遇到这个错误,先pwd确认路径,再用绝对路径访问压缩包,例如tar -xzf ~/Downloads/pyPS4Controller-1.2.1.tar.gz。另外,如果系统里同时有 Python 2 和 Python 3,把安装命令写成python3 -m pip install .,这样能明确装进 Python 3 而不是默认的旧解释器。
提示:解压后的目录名自带版本号
pyPS4Controller-1.2.1,如果你手动改成pyPS4Controller,后续看源码定位行号时容易和 PyPI 上的安装路径混淆。建议保留原始目录名,pip装的是包名,不依赖目录名。
2.2 pyPS4Controller 的 Controller 类与事件定义
源码包解压后,真正需要关心的文件其实只有少数几个。下面这张表列的是 1.2.1 版本里最核心的产物:
| 文件 | 作用 |
|---|---|
pyPS4Controller/controller.py | 提供Controller主类,负责打开/dev/input/jsX、读取事件、分发回调 |
pyPS4Controller/event_definition.py | 定义按钮和摇杆轴的事件名,以及回调的触发方式 |
pyPS4Controller/__init__.py | 包入口,导出可供import的模块 |
setup.py/setup.cfg | 安装配置,声明依赖和元数据 |
pyPS4Controller 不是一个进程级别的后台服务,它更像一个“事件泵”。你在Controller子类里重写on_x_press、on_L3_up这类方法,然后调用listen()或listen_forever(),库就会从 Linux 的 joystick 设备节点读取原始输入,再把它翻译成你熟悉的语义化回调。这个过程不经过 X11 或 Wayland,所以它不会抢占你的桌面鼠标,但也意味着它默认只能跑在有/dev/input/jsX的 Linux 系统上。
Controller构造方法里最常改的三个参数是interface、connecting_using_ds4drv和event_definition。interface默认值是/dev/input/js0,如果你的电脑同时插了多个手柄,ls /dev/input/js*会列出多个节点,通常js0是第一个被内核注册的设备,不一定是 PS4 手柄。connecting_using_ds4drv这个参数在通过ds4drv把手柄转换成虚拟设备时使用,如果是直接连蓝牙或 USB,保持默认False即可。event_definition用于控制事件回调里的数值范围,默认的轴值是 0 到 32767,第 6 章会专门讲怎么缩放到 0 到 255。
3. 最小监听脚本能跑了:按键事件、D-pad 与状态保持
3.1 最小可跑的 pyPS4Controller 监听脚本
写完安装,先跑起来是最重要的。新建一个robot.py,内容直接照着抄:
import time from pyPS4Controller.controller import Controller class RobotController(Controller): def __init__(self, **kwargs): super().__init__(**kwargs) self._last_press = time.time() def on_x_press(self): print("cross button pressed") def on_circle_press(self): print("circle button pressed") def on_up_arrow_press(self): now = time.time() if now - self._last_press < 0.02: return self._last_press = now print("d-pad up pressed") if __name__ == "__main__": c = RobotController(interface="/dev/input/js0") c.listen(timeout=30)这个脚本的套路是:继承Controller并重写带on_前缀的方法;主程序创建实例,传入interface参数;然后调用listen(timeout=30)进入阻塞监听。timeout参数控制一次事件轮询的等待上限,测试时给 30 秒足够,避免 Ctrl+C 之后留下一个不死不活的进程。如果是生产服上持续监听,用listen_forever()更合适,它不会因为超时退出。
关于回调方法名,pyPS4Controller 有一套固定的命名约定,on_x_press只在按键按下的瞬间被调用一次,on_x_release在松手时调用一次。on_up_arrow_press对应 D-pad 的方向键上,注意 D-pad 在 Linux joystick 驱动里表现为一个复合轴,所以按键回调和普通按键一样,没有重复触发的特性。
注意:不要指望
on_x_press在按住期间被反复调用,它只是“按下这一下”的事件。如果你需要“按住持续前进”的效果,必须在回调里自己维护一个布尔状态,让控制循环去读这个状态。
3.2 按键映射表和状态保持
开发机器人控制逻辑时,我一般会把按键语义先列成一张表,再照表写回调。下面这张表是 pyPS4Controller 最常见的按键映射:
| 物理按键 | 按下回调 | 释放回调 | 常见用途 |
|---|---|---|---|
| ✕ 键 | on_x_press | on_x_release | 确认、电机正转 |
| ○ 键 | on_circle_press | on_circle_release | 取消、电机反转 |
| △ 键 | on_triangle_press | on_triangle_release | 切换挡位 |
| □ 键 | on_square_press | on_square_release | 自动/手动切换 |
| D-pad 上 | on_up_arrow_press | on_up_arrow_release | 前进 |
| L1 键 | on_L1_press | on_L1_release | 高速挡 |
| R1 键 | on_R1_press | on_R1_release | 低速挡 |
手柄的按键没有“当前键值”这种查询接口,它只有事件。所以程序里所有需要跨回调共享的状态,都建议放进self属性里。下面是一个典型的挡位切换写法:
class RobotController(Controller): def __init__(self, **kwargs): super().__init__(**kwargs) self.mode = "manual" def on_square_press(self): self.mode = "auto" print("switch to auto mode") def on_triangle_press(self): self.mode = "manual" print("switch to manual mode")这个例子里,mode不是局部变量,而是实例属性。按键回调只负责改状态,真正的电机控制循环在另一个线程或者主循环里周期性读取self.mode。这个“回调改状态、循环读状态”的模型,比直接在回调里跑电机逻辑更安全。因为回调执行频率受内核事件驱动,如果回调里做串口写入或延时操作,按键连按会让事件堆积,最终让控制周期抖动。
3.3 D-pad 去抖和误触处理
D-pad 在蓝牙链路下偶尔会出现一次物理抖动,导致同一方向连续触发两次。上面 3.1 里的_last_press时间戳就是用来做去抖的。实际的去抖阈值取决于你的使用场景:手柄按键手速快的人两次按下间隔不会低于 30ms,所以我通常取 20ms;如果是机械臂点动控制,想要更跟手的响应,可以降到 10ms 以下。这个值不是越小越好,太小会让去抖失效,太大则会吞掉快速连击。
如果 D-pad 的去抖已经做了,但还是出现方向错乱,先检查是不是手柄的轴映射在系统里发生了偏移。运行jstest /dev/input/js0,把 D-pad 四个方向依次按一遍,观察按钮 0 到 15 的变化。pyPS4Controller 是基于 Linux 标准 joystick 协议解析的,如果系统层面的轴序被打乱,回调里的on_up_arrow_press可能被映射到物理按键的左或右,这时候就不是调库能解决的问题,而是要校准系统层面的按键映射。
4. 摇杆轴原始值与归一化:L3 / R3 的压感怎么办
4.1 轴回调的参数设计
摇杆和按键不同,它产生的是连续模拟值。pyPS4Controller 在设计上把每个摇杆轴拆成了两个方向的回调,例如左摇杆的上下分别触发on_L3_up和on_L3_down,回调参数value是偏离中心点的绝对值。具体见下表:
| 回调 | 触发条件 | value 范围 |
|---|---|---|
on_L3_left | 左摇杆向左 | 0 ~ 32767 |
on_L3_right | 左摇杆向右 | 0 ~ 32767 |
on_L3_up | 左摇杆向上 | 0 ~ 32767 |
on_L3_down | 左摇杆向下 | 0 ~ 32767 |
on_L3_x_at_rest | 左摇杆回到水平中心 | 0 |
on_L3_y_at_rest | 左摇杆回到垂直中心 | 0 |
on_L3_press | 按下左摇杆 | 无参数 |
这里的value是 int 类型,而且取的是绝对值。假设摇杆推到最上边,on_L3_up收到的值是 32767;推到一半,收到约 16383;回到中心,调用的是on_L3_y_at_rest而不是on_L3_up(0)。这一点很多第一次用 pyPS4Controller 的开发者会踩坑,以为摇杆回中时on_L3_up会被调一次且值为 0,实际上回中事件走的是另一组回调。
如果你需要合成一个完整的 Y 轴坐标值,正确做法是四个回调合起来。以下代码把左摇杆的 Y 轴归一化成-1.0到1.0的浮点数,并带死区:
class RobotController(Controller): def __init__(self, interface="/dev/input/js0", deadzone=0.08, **kwargs): super().__init__(interface=interface, **kwargs) self.deadzone = deadzone self._lx = 0.0 self._ly = 0.0 def _normalize_axis(self, value, direction): magnitude = abs(value) / 32767.0 if magnitude < self.deadzone: return 0.0 magnitude = (magnitude - self.deadzone) / (1.0 - self.deadzone) return round(direction * magnitude, 3) def on_L3_up(self, value): self._ly = self._normalize_axis(value, 1) def on_L3_down(self, value): self._ly = self._normalize_axis(value, -1) def on_L3_y_at_rest(self): self._ly = 0.0这段代码里的direction参数决定了方向的正负号,1和-1只是约定,不代表物理意义上的上下。实际接入电机时,先单独测试on_L3_up打印出来的值是正是负,再看电机转向是否符合直觉,否则就交换两个方向的符号。deadzone用来屏蔽摇杆没有完全回中时残留的微小读数,常见取值在 0.05 到 0.1 之间,也就是 5% 到 10% 的摇杆行程。去掉死区的直接后果是小车静止时电机会轻微抖动,因为摇杆回中后的残留值通常有几百到两千。
4.2 类型转换和发送端对齐
value是 int,value / 32767.0在 Python 3 里会自动转成 float。别忽略这个细节:如果你把value直接塞进字符串拼接或 JSON 序列化,经常会遇到TypeError: unsupported operand type(s) for +: 'int' and 'str',这就是典型的 Python 类型转换问题。要对齐发送端的数据格式,我通常会先转float再处理:
def on_L3_right(self, value): x = float(value) / 32767.0 self._lx = round(x, 3)如果你的上位机协议只接受 0 到 255 的字节值,可以在赋值时做一次映射:int(x * 255)。但要注意,int()是直接截断小数部分,不是四舍五入,发送精度要求高时用round()先处理再转int。
另外一个容易忽略的点是摇杆回中回调。在 4.1 的代码里,on_L3_y_at_rest只在摇杆回到物理中心时触发一次。如果摇杆卡在中间位置而不是完全回中,这个回调不会触发,self._ly就停留在最后一次的非零值上。要避免这种情况,可以在被动控制循环里周期性归零,或者把 Y 轴的回中判断迁移到定时器里检查。简单做法是在主循环里每隔 100ms 读取一次self._ly,如果连续 5 次绝对值小于deadzone,强制把它清零。这把“事件驱动”和“周期检查”结合,能解决手柄使用久了摇杆弹簧疲劳导致的回中不彻底问题。
5. 接线与排错:蓝牙配对、/dev/input/js0 与权限
5.1 把 DualShock 4 配对到 Linux
pyPS4Controller 在 Linux 上读取的是/dev/input/jsX,所以第一步是让内核把手柄识别成一个 joystick。有线连接最简单,USB 线插上之后,运行dmesg | tail -20看到类似input: Wireless Controller as /devices/...就说明已经识别。蓝牙配对稍微麻烦一点,常见做法是用bluetoothctl走一遍流程:
sudo bluetoothctl power on agent on default-agent scan on # 等几秒,观察列表里出现 Wireless Controller pair 12:34:56:78:9A:BC trust 12:34:56:78:9A:BC connect 12:34:56:78:9A:BC配对完成后,DS4 在蓝牙设备列表里显示的名称是Wireless Controller,而不是DualShock 4或PS4 Controller。如果pair一直停在等待状态,多半是手柄处于“已连过其他设备”的状态,按住 SHARE 键加 PS 键约 5 秒,让手柄进入配对模式,指示灯开始快速双闪后再跑一次pair。trust的作用是让系统记住这个设备,以后开机不需要重新配对。
配对完成后先别急着跑 Python,先查内核事件节点:
ls -l /dev/input/js* sudo jstest /dev/input/js0如果ls输出为空,说明内核没有创建 joystick 接口。这时去查dmesg | grep -i sony,看手柄是否被识别,再查lsmod | grep hid_sony确认hid_sony模块是否加载。极少数系统上需要modprobe hid_sony手动加载。
5.2 udev 权限、interface 参数与快速验证
Python 进程读取/dev/input/js0需要设备节点有读权限。普通用户访问时会直接报PermissionError: [Errno 13] Permission denied。为了避免给每个用户都加 root 权限,更干净的做法是写一条 udev 规则:
sudo tee /etc/udev/rules.d/99-ps4-controller.rules <<'EOF' SUBSYSTEM=="input", ATTRS{name}=="Wireless Controller", MODE="0666" EOF sudo udevadm control --reload-rules sudo udevadm trigger规则里SUBSYSTEM=="input"限定在 input 子系统,ATTRS{name}=="Wireless Controller"匹配手柄设备名,MODE="0666"让所有用户都能读写该设备。写完后不需要重启,udevadm trigger会重新应用规则。如果设备名不同,先用udevadm info -a -n /dev/input/js0查看ATTRS{name}的实际值,把它替换到规则里。
权限解决后,接口参数也要对上。Controller的interface参数默认就是/dev/input/js0,但如果你用ds4drv把手柄转换成了虚拟 joystick,系统里可能出现多个js节点。常见参数组合如下:
| 使用场景 | interface | connecting_using_ds4drv |
|---|---|---|
| USB 直连手柄 | /dev/input/js0 | False |
| 蓝牙直连手柄 | /dev/input/js0 | False |
| 通过 ds4drv 虚拟手柄 | /dev/input/jsX | True |
| 多个手柄 | /dev/input/js1等 | 视驱动方式而定 |
验证整个链路是否通,不需要写复杂脚本。先跑jstest /dev/input/js0看轴和按键数值是否变化,再在同一个终端里用一行 Python 检查节点是否可读:
python3 -c "open('/dev/input/js0', 'rb').read(8)"没有异常就是节点可用。到这里再回去跑第 3 章的监听脚本,基本就不会卡在权限层。
5.3 手柄不触发回调的三个排查点
如果脚本跑起来了,按键按下去却没有任何print输出,按下面的顺序排查:
第一,确认事件节点选对了。电脑上如果有内置键盘或触摸板,/dev/input/js0不一定就是手柄。把所有节点列出来:
for f in /dev/input/js*; do echo "$f"; sudo jstest "$f" --event; done按下 DS4 的某个键,看到哪个节点有事件变化,把interface参数改成那个节点。
第二,确认connecting_using_ds4drv是否匹配。如果系统用了ds4drv把蓝牙手柄模拟成 Xbox 手柄,那么/dev/input/js0是虚拟设备,事件发送方变成系统级驱动,pyPS4Controller 默认参数下可能收不到带有 DS4 标识的事件。此时把connecting_using_ds4drv=True传进去:
c = RobotController(interface="/dev/input/js0", connecting_using_ds4drv=True)第三,检查回调签名和版本差异。从源码包解压出来的版本如果和 PyPI 上的最新版有差异,个别回调名可能不同。直接搜包里的源码确认:
grep -n "def on_x_press" pyPS4Controller/event_definition.py如果搜不到,说明这个版本里on_x_press不是独立函数,而是事件定义的一部分,去读整个event_definition.py,以实际代码为准。
6. 用自定义 event_definition 做轴缩放,再把回调日志压成一行
6.1 用 ScaleEventDefinition 省掉手写归一化
第 4 章的手写归一化适合需要精细控制死区的场景,但如果你只是想把摇杆值塞进一个只接受 0 到 255 字节的串口协议,pyPS4Controller 自带了缩放定义。在 1.2.1 这类版本里,event_definition.py通常同时提供EventDefinition和ScaleEventDefinition。后者会在进入回调之前把摇杆轴值从原始 0 到 32767 缩放到 0 到 255,这样回调函数里拿到的直接就是可发送的字节宽度。用法如下:
from pyPS4Controller.controller import Controller from pyPS4Controller.event_definition import ScaleEventDefinition class SerialBot(Controller): def __init__(self, **kwargs): super().__init__(**kwargs) self.servo_left = 0 self.servo_right = 0 def on_L3_up(self, value): # 此时 value 已被 ScaleEventDefinition 缩放为 0..255 self.servo_left = value print(f"left={value}") def on_L3_down(self, value): self.servo_left = 255 - value if __name__ == "__main__": bot = SerialBot( interface="/dev/input/js0", event_definition=ScaleEventDefinition() ) bot.listen_forever()这段代码的好处是回调里少一层除法,避免后续每一步都带着 32767 这个魔法数字。如果import ScaleEventDefinition失败,打开event_definition.py看这个版本里的导出名称,改成实际类名即可。同参数下如果摇杆推到底得到的值接近 255,说明缩放函数工作正常;如果回调直接被跳过,多半是摇杆的起始偏移被系统判定成了非零值,先重新校准手柄的 center 位置再测。
6.2 日志验证和最后一条技巧
验证轴缩放正确与否,我一般用双终端:一个跑jstest /dev/input/js0看原始数值,一个跑上面的脚本打印缩放值。摇杆推到右上极限,jstest 的 X 轴和 Y 轴应同时接近 32767,脚本打印的左右值接近 255。两者差值超过 5% 时,优先检查手柄是否在系统层面设置了响应曲线,pyPS4Controller 没有能力改变内核层的曲线,只能接受内核给的值。
确认无误后,把print替换成串口发送或 MQTT 发布,事件模型保持不变。这个阶段最实用的技巧是把日志压成一行,方便肉眼对比各个轴一致性:
python3 serial_bot.py 2>&1 | awk '{printf "%s\r", $0}'用awk的\r覆盖同一行输出,摇杆移动时各个轴的值能直接叠在一起看。等到所有通道数值都同步收敛,这套“解压 tar.gz → 装包 → 监听 → 缩放到 0 到 255”的链路就算真正打通了。
本文还有配套的精品资源,点击获取