Python for PS4 dualshock 4 controller

“selective focus photography of white Sony PS4 console with wireless controller” by Nikita Kachanovsky on Unsplash

PS4 dualshock 4 controller 是一個很成熟的遊戲控制器:除了最基本的類比搖桿、按鈕、及方向鍵以外,還多了六軸感應器 (accelerate and gyro) 和觸控面板 (touch pad)。雖然在大部分的遊戲裡面派不上用場,但六軸感應和觸控面板功能在動作控制上有很大的自由度,也提供使用者不一樣的操作體驗,因此也激發了我想藉由 dualshock 4 controller (DS4) 來控制自動化 xy 平台的想法。不過,第一步需要做的,就是想辦法讓 PC 能夠讀取控制器中的數據。我所使用的作業系統是 Windows 10,語言是 Python 3.6,套件管理為 Anaconda,控制器的連接方式則是 USB。

要藉由 Python 來讀取 DS4 的資料,主要有兩種方法:

  1. 使用 pygame 套件。這是一個遊戲控制器通用的套件,而且使用上非常方便,按鈕、搖桿、方向鍵等都分屬不同的 class,可以直接引用。但實際測試後,發現它無法取得 DS4 中六軸感應器的資料,也就是說,gyroscope 和 accelerometer 這兩個最有趣的動作感應功能,無法藉由 pygame 取得。所以我們必須使用第二種方法…
  2. 使用 pyUSB 套件,直接讀取 DS4 中的原始數據。這個方法對我這種生手來講就麻煩許多,花了兩天才把 pyUSB 和 DS4 的通訊搞定;再來就是如何解釋讀取到的數據,這些都得靠其他開發者流出的文件來摸索。不過至少目前來說,讀取 gyroscope 和 accelerometer 的數據是沒有問題的。

我所使用的作業系統是 Win10,套件管理為 Anaconda;先說明如何用 pygame 讀取資料:

如果沒有 pygame package 的話,第一步就是先安裝它。

pip install pygame

程式的部分,當然就是直接 import,然後 initiate 搖桿功能。

import pygame
import os
pygame.init()
pygame.joystick.init()
controller = pygame.joystick.Joystick(0)
controller.init()

在 pygame 的框架下,有三種控制模式,axis、button、hat:

# Three types of controls: axis, button, and hat
axis = {}
button = {}
hat = {}

接著給他們一些初始值 (通常設為 0 或 False):

# Assign initial data values# Axes are initialized to 0.0
for i in range(controller.get_numaxes()):
axis[i] = 0.0
# Buttons are initialized to False
for i in range(controller.get_numbuttons()):
button[i] = False
# Hats are initialized to 0
for i in range(controller.get_numhats()):
hat[i] = (0, 0)

再來就是將每個軸和按鈕等的標籤定義出來 (要實際測試過,才比較清楚每個按鍵和讀值的對應關係):

# Labels for DS4 controller axes
AXIS_LEFT_STICK_X = 0
AXIS_LEFT_STICK_Y = 1
AXIS_RIGHT_STICK_X = 2
AXIS_RIGHT_STICK_Y = 3
AXIS_R2 = 4
AXIS_L2 = 5
# Labels for DS4 controller buttons
# Note that there are 14 buttons (0 to 13 for pygame, 1 to 14 for Windows setup)
BUTTON_SQUARE = 0
BUTTON_CROSS = 1
BUTTON_CIRCLE = 2
BUTTON_TRIANGLE = 3
BUTTON_L1 = 4
BUTTON_R1 = 5
BUTTON_L2 = 6
BUTTON_R2 = 7
BUTTON_SHARE = 8
BUTTON_OPTIONS = 9
BUTTON_LEFT_STICK = 10
BUTTON_RIGHT_STICK = 11
BUTTON_PS = 12
BUTTON_PAD = 13
# Labels for DS4 controller hats (Only one hat control)
HAT_1 = 0

主要的迴圈,將持續列印出目前從 DS4 讀到的數據。按下 PS4 鍵可以跳出迴圈:

# Main loop, one can press the PS button to break
quit = False
while quit == False:
# Get events
for event in pygame.event.get():
if event.type == pygame.JOYAXISMOTION:
axis[event.axis] = round(event.value,3)
elif event.type == pygame.JOYBUTTONDOWN:
button[event.button] = True
elif event.type == pygame.JOYBUTTONUP:
button[event.button] = False
elif event.type == pygame.JOYHATMOTION:
hat[event.hat] = event.value
quit = button[BUTTON_PS]# Print out results
os.system('cls') # Note that 'cls' is the command for Windows
# Axes
print("Left stick X:", axis[AXIS_LEFT_STICK_X])
print("Left stick Y:", axis[AXIS_LEFT_STICK_Y])
print("Right stick X:", axis[AXIS_RIGHT_STICK_X])
print("Right stick Y:", axis[AXIS_RIGHT_STICK_Y])
print("L2 strength:", axis[AXIS_L2])
print("R2 strength:", axis[AXIS_R2],"\n")
# Buttons
print("Square:", button[BUTTON_SQUARE])
print("Cross:", button[BUTTON_CROSS])
print("Circle:", button[BUTTON_CIRCLE])
print("Triangle:", button[BUTTON_TRIANGLE])
print("L1:", button[BUTTON_L1])
print("R1:", button[BUTTON_R1])
print("L2:", button[BUTTON_L2])
print("R2:", button[BUTTON_R2])
print("Share:", button[BUTTON_SHARE])
print("Options:", button[BUTTON_OPTIONS])
print("Left stick press:", button[BUTTON_LEFT_STICK])
print("Right stick press:", button[BUTTON_RIGHT_STICK])
print("PS:", button[BUTTON_PS])
print("Touch Pad:", button[BUTTON_PAD],"\n")
# Hats
print("Hat X:", hat[HAT_1][0])
print("Hat Y:", hat[HAT_1][1],"\n")
print("Press PS button to quit:", quit)# Limited to 30 frames per second to make the display not so flashy
clock = pygame.time.Clock()
clock.tick(30)

程式運作正常的情況下,你可以看到:

因此,如果需要利用 DS4 的輸出來控制機器的話,只要把數值對應的範圍抓出來就可以了 (例如搖桿的部分、 L2 和 R2 等類比輸出,全押全放的數值範圍是 -1 到 +1)。所以如果不需要用到六軸感應的數據的話,pygame 套件可以很快速地把想要的控制數據從 DS4 取出來。完整的 code 請參考 GitHub (https://github.com/anubisankh/dualshock4-python)。

再來就是六軸感應的部分了。

因為這邊使用的 DS4 是用 USB 連接到 PC,因此我們將使用 pyUSB:

pip install pyusb
pip install libusb

這邊須注意的是,只有安裝 pyUSB 還不夠,相關的 library 也是必要的,因此如果之前沒有 libusb 的話,須同時安裝。再來就是困擾我許久,但是最關鍵的部分了。如果我們按照官方的例子來寫:

import usb.coredev = usb.core.find()

接著就會出現 usb.core.NoBackendError: No backend available 的錯誤訊息。為了要讓 backend (*.dll) 能夠被找到,我們必須特別把它的位置找出來。如果 pip install libusb 成功的話,通常可以在 Anaconda3\Lib\site-packages\libusb\_platform\_windows\x64\libusb-1.0.dll 中找到。如果用 pip 安裝了 libusb 還是找不到 libusb-1.0.dll,可以在網路上找到適合該作業系統的 libusb-1.0.dll 下載下來,並把路徑複製到程式碼中:

import usb.core
import usb.util
import usb.backend.libusb1
# You have to change the path below to your libusb-1.0.dll
BACKEND = usb.backend.libusb1.get_backend(find_library=lambda x: "C:\\Users\\YourUserName\\Anaconda3\\Lib\\site-packages\\libusb\\_platform\\_windows\\x64\\libusb-1.0.dll")

再來我們就可以列出所有 USB 裝置的相關資訊:

dev = usb.core.find(find_all=True)if dev is None:
print('Device not found')
else:
for d in dev:
print('Decimal Vendor ID: ' + str(d.idVendor) + ' & ProductID: ' + str(d.idProduct)) # Print all connected USB devices in decimal
print('Hexadecimal Vendor ID: ' + hex(d.idVendor) + ' & ProductID: ' + hex(d.idProduct) + '\n') # Print all connected USB devices in hex

這邊會列出所有連接上的 USB 裝置的廠商 ID (vendor ID) 和產品 ID (product ID),範例輸出如下:

列印出的裝置資訊可以用十進位 (decimal) 或標準的十六進位 (hex) 來表示。把 DS4 拔出後跑上述程式碼,再重新裝回,可進一步發現 DS4 的 vendor ID: 0x54c / product id: 0x9cc。把這個訊息記錄下來後,我們開始只針對 DS4 來深入研究:

import usb.core
import usb.backend.libusb1
import os
import time
# DS4 controller ids
VENDOR_ID = 0x54c
PRODUCT_ID = 0x9cc
BACKEND = usb.backend.libusb1.get_backend(find_library=lambda x: "C:\\Users\\YourUserName\\Anaconda3\\Lib\\site-packages\\libusb\\_platform\\_windows\\x64\\libusb-1.0.dll")dev = usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID, backend=BACKEND)

這邊定義的 USB 裝置 (device, dev),就只有 DS4 controller。每一個 USB device 可能會有不同的 configuration(s),在之下又會有幾個不同的 interface(s),而 interface 下面又會有不同的 endpoint(s),如下圖所示:

USB 架構 (source)

因此,當我們取得 DS 的 configuration 時:

cfg = dev.get_active_configuration()print('Configuration data:\n\n', cfg,'\n')

會得到一長串的資料:

其中可以看到有些 interface 是給 Audio,再往下拉的話,可以看到其中一個 interface 是 Human Interface Device (HID),這就是我們 DS4 控制器主要輸入的介面。在該介面下,還包含了 endpoint 0x84 和 endpoint 0x3 兩個端點。Endpoint 0x84: Interrupt IN 則是我們想要取得 access 的端點。

設定 endpoint 資訊如下:

INTERFACE_DS4 = 3 # HID interface
SETTING_DS4 = 0
interface = cfg[(INTERFACE_DS4, SETTING_DS4)]
ENDPOINT_DS4_OUT = 0 # Input endpoint
endpoint = interface[ENDPOINT_DS4_OUT]
print(endpoint.read(0x40)) # wMaxPacketSize 0x40

執行後,列印出 endpoint 讀取資料的結果為一個陣列:array(‘B’, [1, 131, 128, 129, 132, 8, 0, 16, 0, 0, 189, 178, 11, 4, 0, 3, 0, 255, 255, 103, 1, 177, 31, 113, 6, 0, 0, 0, 0, 0, 27, 0, 0, 1, 135, 134, 187, 180, 32, 132, 29, 55, 12, 0, 128, 0, 0, 0, 128, 0, 0, 0, 0, 128, 0, 0, 0, 128, 0, 0, 0, 0, 128, 0])。[]中的數字便是我們想要的 DS4 各個按鈕、搖桿、還有六軸感應器的讀值了。

接下來,最麻煩的地方就在於如何解讀這些數字。這部分可以參考 PS4 開發者已經試出來的資訊。這些輸出當中,Readout [1] ~ [9] 是和搖桿及按鈕相關的資訊,而我們有興趣的 gyroscope 和 accelerometer 則是在 [14] ~ [25]。

所以搖桿及按鈕的部分 (同時列出 hex 和 binary 的結果):

# Axesprint('Readout L stick  X:', format(endpoint.read(0x40)[1],'#04X'), format(endpoint.read(0x40)[1],'08b'), endpoint.read(0x40)[1])print('Readout L stick  Y:', format(endpoint.read(0x40)[2],'#04X'), format(endpoint.read(0x40)[2],'08b'), endpoint.read(0x40)[2])print('Readout R stick  X:', format(endpoint.read(0x40)[3],'#04X'), format(endpoint.read(0x40)[3],'08b'), endpoint.read(0x40)[3])print('Readout R stick  Y:', format(endpoint.read(0x40)[4],'#04X'), format(endpoint.read(0x40)[4],'08b'), endpoint.read(0x40)[4])print('Readout L2 trigger:', format(endpoint.read(0x40)[8],'#04X'), format(endpoint.read(0x40)[8],'08b'), endpoint.read(0x40)[8])print('Readout R2 trigger:', format(endpoint.read(0x40)[9],'#04X'), format(endpoint.read(0x40)[9],'08b'), endpoint.read(0x40)[9],'\n')

Gyroscope 和 accelerometer 的六軸部分,一個軸的輸出需要兩個 byte 的數據,所以:

# Accelerometers (2 bytes to signed integer)
data = endpoint.read(0x40)
print('Accelerometer X:', format(256*data[18]+data[19],'016b'), 256*data[18]+data[19]-(65536 if data[18] > 127 else 0))
print('Accelerometer Y:', format(256*data[16]+data[17],'016b'), 256*data[16]+data[17]-(65536 if data[16] > 127 else 0)print('Accelerometer Z:', format(256*data[14]+data[15],'016b'), 256*data[14]+data[15]-(65536 if data[14] > 127 else 0),'\n')# Gyroscopes
data = endpoint.read(0x40)
print('Gyroscope X (Roll) :', format(256*data[20]+data[21],'016b'), 256*data[20]+data[21]-(65536 if data[20] > 127 else 0))
print('Gyroscope Y (Yaw) :', format(256*data[22]+data[23],'016b'), 256*data[22]+data[23]-(65536 if data[22] > 127 else 0))print('Gyroscope Z (Pitch):', format(256*data[24]+data[25],'016b'), 256*data[24]+data[25]-(65536 if data[24] > 127 else 0),'\n')

加上其他按鈕和功能的數據,範例輸出如下:

如此一來,便可以藉由 pyUSB 讀取 DS 4 六軸感測器的數據來操控想控制的機器了。另外,DS4 還有 touch pad,甚至支援兩點觸控,若想使用 touch pad 功能還可以用 Readout[34] ~ [51] 的輸出來設計自己想要的操控模式。相關原始碼及參考資料可參考:https://github.com/anubisankh/dualshock4-python

Another Brick in the Wall

Written by

別認真,只不過是牆上的另一塊磚

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade