Проигрываем звук на DualShock4 с компьютера +29



Выбирая геймпад для своего компьютера, я остановился на DualShock4, так как мне понравилась идея, что можно будет слушать аудио через подключаемые к нему наушники. Но после покупки я узнал, что, оказывается, никто не знает, как передать звук на геймпад через Bluetooth. Поэтому я решил разобраться с данным вопросом. Если вам интересно узнать, как DualShock4 общается с игровой консолью, жду под катом.

К сожалению, у меня нет PlayStation 4, поэтому пришлось довольствоваться только выложенными в Интернете дампами, а также уже известными фрагментами обмена.
В процессе изучения темы мне очень помогла вот эта страница. В ней описаны основные моменты передачи данных между консолью и геймпадом, а также выложен дамп этих данных. Нас интересует файл дампа с именем ds4_uart_hci_cap_playroom_needs_sorting.pcap.gz. Открываем его в Wireshark и начинаем изучать. Отсортируем пакеты по времени, так как, видимо, дамп записывался отдельно на приём и передачу. Дамп снимался напрямую с UART геймпада, после чего был сконвертирован в pcap.

В начале идёт настройка самого модуля Bluetooth. Далее, с №49-го по №163-й пакет, идёт установка соединения и настройка канала передачи. Очень хорошо этот процесс описан в статье Беспроводной звук. Часть 1. Препарируем Bluetooth.
Но для нашей задачи это неособо важно.

После всех «подготовительных работ» геймпад начинает отправлять HID Report. Формат сообщения описан на вики странице. Первый пакет с данными от консоли — это пакет №70181. Давайте разберём его, пользуясь данными с вики страницы.
Нас интересуют только данные, которые передаются через HID Profile.
Вот его содержание.


Номер байта bit 7 bit 6 bit 5 bit 4 bit 3 bit 2 bit 1 bit 0
[0] 0x0a – Тип Data 0x00 — Зарезервировано 0x02 — Направление передачи
[1] 0x11 – Код операции
[2 — 3] Неизвестно
[4] 0xf0 Запрещает изменение данных у геймпада, 0xf3 Разрешает изменение
[5 — 6] Неизвестно
[7] Rumble (right / weak)
[8] Rumble (left / strong)
[9] RGB color (Red)
[10] RGB color (Green)
[11] RGB color (Blue)
[12-24] Неизвестно
[25] Громкость звучания в %
[26 — 74] Неизвестно
[75 — 78] CRC-32 от предыдущих данных

Хотя 26 байт помечен на упомянутой выше странице как неизвестный, во время моих экспериментов удалось выяснить, что он отвечает за громкость звучания и выставляется в процентах. Также хотя поле crc присутствует, но геймпад его не проверяет и можно просто отправлять нулевое значение.

Так как нам интересно, какие данные передаёт консоль, давайте отфильтруем их по 0-му байту HID Profile, который поможет нам определить направление пакета. Данные от гемпада имеют значение 0xa1, от консоли 0xa2. Фильтр для Wireshark получится таким: bthid[0] == 0xa2.

Если прокрутить пакеты, то, начиная с пакета №98516, сильно увеличился размер данных. Если судить по данным с вики страницы, то начало у пакетов с кодом операции 0x15 и 0x19 такое же, как и у 0x11, только без CRC, которая находится в конце.

Всё есть HID


Вот мы и подошли к самому интересному — как передать звук на геймпад. Вот как выглядит пакет с аудиоданными.


Если внимательно посмотреть на пакеты с кодами операции 0x14, 0x15, 0x17, 0x19, то заметно некое постоянство, а именно идущие подряд байты 0x9c, 0x75, 0x19. Это очень похоже на Bluetooth SBC header ( SBC — это один из стандартных кодеков для передачи аудио по Bluetooth). И хотя для передачи SBC по Bluetooth есть стандарт A2DP, создатели PS4 решили пойти по своему пути и передавать звук прямо в HID сообщениях. Также если посмотреть пакеты дальше то видно, что также меняются два байта перед Bluetooth SBC header, это счётчик фреймов. Давайте проверим наше предположение, что это стандартный SBC кодек. Для этого воспользуемся следующим скриптом на Python.
#!/usr/bin/env python3

from pcapfile import savefile
import collections
import struct

class bluetooth(object):

	def __init__(self, packet, number):
		self.direction = packet.raw()[3]
		self.payload = packet.raw()[4:]
		self.time = ((packet.timestamp_ms-444738)/1000000)+(packet.timestamp-3)
		self.number = number


pcap = savefile.load_savefile(open('ds4_uart_hci_cap_playroom_needs_sorting.pcap', 'rb'))


bluetooth_packet = []
number=1
for pkt in pcap.packets:
	bluetooth_packet.append(bluetooth(pkt, number))
	number+=1

sbc = open('test.sbc', 'wb')
bluetooth_packet.sort(key=lambda pkt: pkt.time)
count = 0
for bt in bluetooth_packet:
	count+=1
	if(bt.payload[0]==2):
		l2cap_len = struct.unpack("<H",bt.payload[5:7])[0]
		if(l2cap_len>5):
			sony_opcode = bt.payload[10]
			if(sony_opcode == 0x19):
				sbc.write(bt.payload[0x5b:-0x12])

			if(sony_opcode == 0x17):
				sbc.write(bt.payload[0x10:-0x8])

			if(sony_opcode == 0x15):
				sbc.write(bt.payload[0x5b:-0x1D])

			if(sony_opcode == 0x14):
				sbc.write(bt.payload[0x10:-0x28])

Скрипт работает следующим образом: открываем дамп, кладем все пакеты в список, после чего сортируем по времени. Затем проходим по порядку все пакеты, доставая аудиоданные из сообщений с кодом операции 0x19,0x17,0x15 и 0x14 и записывая их в файл.

Теперь попробуем воспроизвести получившийся файл, для чего воспользуемся gstreamer'ом:

gst-launch-1.0 filesrc location=test.sbc ! sbcparse ! sbcdec ! autoaudiosink

В начале файла будет тишина (это видно и по сохраненным данным). Для удобства преобразуем данные в wav:

gst-launch-1.0 filesrc location=test.sbc ! sbcparse ! sbcdec ! audioconvert ! wavenc ! filesink location=output.wav

Еесли перемотать на 41 секунду получившийся wav, мы услышим звук.
Таким образом, мы удостоверились, что DualShock4 использует обычное SBC кодирование для передачи звука.

Теперь интересно попробовать самим сгенерировать данные для воспроизведения на геймпаде.
Воспользуемся для этого всё теми же инструментами. Gstreamer будет кодировать, а Python будет будет передавать данные на DualShock4.
В Linux можно очень просто работать с геймпадом благодаря тому, что в нём всё (включая устройства) является файлами.
Узнать, какой файл соответствует геймпаду, можно после сопряжения DualShock4 с компьютером. В результате удачного сопряжения в выводе dmesg появится строка
sony 0005:054C:05C4.0007: input,hidraw5: BLUETOOTH HID v1.00 Gamepad [Wireless Controller]
Значит, наш контроллер присутствует в системе в виде файла с именем /dev/hidraw5, и мы можем передавать данные на геймпад, просто записывая необходимые данные в этот файл.
Вот скрипт, с помощью которого это можно делать:
#!/usr/bin/env python3
import struct
from sys import stdin
import os
from io import FileIO

hiddev = os.open("/dev/hidraw5", os.O_RDWR | os.O_NONBLOCK)
pf = FileIO(hiddev, "wb+", closefd=False)
#pf=open("ds_my.bin", "wb+")

rumble_l = 0
rumble_r = 0
r = 0
g = 0
b = 50
crc = 0
volume = 50
flash_bright = 150
flash_dark = 150


def frame_number(inc):
	res = struct.pack("<H", frame_number.n)
	frame_number.n += inc
	if frame_number.n > 0xffff:
		frame_number.n = 0
	return res
frame_number.n = 0

def joy_data():
	data = [0xf3,0x4,0x00]
	data.extend([rumble_l,rumble_r,r,g,b,flash_bright,flash_dark])
	data.extend([0]*8)
	data.extend([0x43,0x43,0x00,volume,0x85])
	return data

def _11_report():
	data = joy_data()
	data.extend([0]*(48))
	data.append(crc)
	return bytearray(data)

def _14_report(audo_data):
	return b'\x14\x40\xA0'+ frame_number(2) + b'\x02'+ audo_data + bytearray(40)

def _15_report(audo_data):
	data = joy_data();
	data.extend([0]*(52))
	return b'\x15\xC0\xA0' + bytearray(data)+ frame_number(2) + b'\x02' + audo_data + bytearray(29)

def _17_report(audo_data):
	return b'\x17\x40\xA0' + frame_number(4) + b'\x02' + audo_data + bytearray(8)

stdin = stdin.detach()
data = bytearray()
count = 1
while True:
#	if count % 200:
	if True:
		data = _14_report(stdin.read(224)) if count % 3 else _15_report(stdin.read(224))
	else:
		data = _17_report(stdin.read(448))
		print('big')
	count+=1

	pf.write(data)


Скрипт читает из стандартного потока закодированные в SBC аудиоданные и формирует два типа пакетов 0x14 и 0x15 (также комментированием/раскомментированием строк можно включить формирование увеличенного в два раза пакета с опкодом 0x17) и отправляет их на геймпад путем записи в hidraw девайс.
Попробуем использовать этот скрипт, чтобы проиграть тестовый звуковой сигнал.
Данный сигнал будет генерироваться при помощи gstreamer и отправляться на стандартный поток вывода, откуда его будет забирать скрипт.

gst-launch-1.0 -q audiotestsrc is-live=true ! sbcenc ! 'audio/x-sbc,channels=2,rate=32000,channel-mode=dual,blocks=16,subbands=8,bitpool=25' ! queue ! fdsink | ./play.py

И у нас получилось (почти). Звук идет, но периодически слышны небольшие заикания. С чем они связаны, я понять так и не смог. Возможно, я не совсем правильно работаю с hid устройством в linux — если кто-нибудь сможет подсказать, как сделать правильнее, я буду благодарен. Попытка испопользования Bluetooth сокета успехом также не увенчалась — через полсекунды проигрывания звука всё заканчивалось(Смотри UPD).

Заключение


Хотелось бы выразить благодарность таким проектам, как DS4Windows и ds4drv.
Данные проекты позволяют использовать геймпад на компьютере. Надеюсь, эта статья поможет добавить также и поддержку передачи звука в эти проекты.

Спасибо за внимание.

UPD:
Небольшие дополнение.
Если добавить is-live=true к audiotestsrc то звук идет почти без заиканий.
Вот полезный pipeline для gstreamer который позволяет захватывать все, что идет на аудио выход и отправлять на DualShock4.

gst-launch-1.0 -q pulsesrc device="alsa_output.pci-0000_00_1b.0.analog-stereo.monitor" ! queue ! audioresample ! 'audio/x-raw,rate=32000' ! audioconvert ! sbcenc ! 'audio/x-sbc,channels=2,rate=32000,channel-mode=dual,blocks=16,subbands=8,bitpool=25' ! queue ! fdsink | ./play.py

Получить имя девайса можно следующей командой.
pacmd list-sources | grep -e device.string -e 'name:'
-->


К сожалению, не доступен сервер mySQL