Skip to content

Commit 6a34342

Browse files
committed
add initial implementation for deck controller
1 parent c69ed68 commit 6a34342

File tree

6 files changed

+333
-35
lines changed

6 files changed

+333
-35
lines changed

src/hhd/__main__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -820,9 +820,11 @@ def progress(idx, blockSize, total):
820820
logger.info("Closing cached controllers.")
821821
from hhd.controller.virtual.dualsense import Dualsense
822822
from hhd.controller.virtual.uinput import UInputDevice
823+
from hhd.controller.virtual.sd import SteamdeckController
823824

824825
UInputDevice.close_cached()
825826
Dualsense.close_cached()
827+
SteamdeckController.close_cached()
826828
except Exception as e:
827829
logger.error("Could not close cached controllers with error:\n{e}")
828830

Lines changed: 248 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,28 @@
11
import logging
22
import time
33
from collections import defaultdict
4-
from typing import Sequence
4+
from typing import Sequence, cast
55

66
from hhd.controller import Consumer, Event, Producer
77
from hhd.controller.lib.uhid import UhidDevice, BUS_USB
8+
from hhd.controller.lib.common import encode_axis, set_button
9+
from hhd.controller.lib.ccache import ControllerCache
810

911
from .const import (
1012
SDCONT_VENDOR,
11-
SDCONT_PRODUCT,
1213
SDCONT_VERSION,
1314
SDCONT_COUNTRY,
14-
SDCONT_NAME,
1515
SDCONT_DESCRIPTOR,
16+
SD_AXIS_MAP,
17+
SD_BTN_MAP,
1618
)
1719

20+
MAX_IMU_SYNC_DELAY = 2
21+
1822
logger = logging.getLogger(__name__)
1923

24+
_cache = ControllerCache()
25+
2026

2127
def trim(rep: bytes):
2228
if not rep:
@@ -31,42 +37,84 @@ def pad(rep):
3137
return bytes(rep) + bytes([0 for _ in range(64 - len(rep))])
3238

3339

34-
class SteamdeckOLEDController(Producer, Consumer):
40+
class SteamdeckController(Producer, Consumer):
41+
@staticmethod
42+
def close_cached():
43+
_cache.close()
44+
3545
def __init__(
3646
self,
47+
pid,
48+
name,
49+
touchpad: bool = False,
3750
) -> None:
3851
self.available = False
39-
self.report = None
4052
self.dev = None
4153
self.start = 0
54+
self.pid = pid
55+
self.name = name
56+
self.sync_gyro = False
57+
self.enable_touchpad = touchpad
58+
self.report = bytearray(64)
59+
self.i = 0
4260
self.last_rep = None
4361

4462
def open(self) -> Sequence[int]:
4563
self.available = False
46-
self.report = bytearray([64] + [0 for _ in range(64)])
47-
self.dev = UhidDevice(
48-
vid=SDCONT_VENDOR,
49-
pid=SDCONT_PRODUCT,
50-
bus=BUS_USB,
51-
version=SDCONT_VERSION,
52-
country=SDCONT_COUNTRY,
53-
name=SDCONT_NAME,
54-
report_descriptor=SDCONT_DESCRIPTOR,
64+
self.report[0] = 0x01
65+
self.report[2] = 0x09
66+
self.report[3] = 0x40
67+
self.i = 0
68+
69+
# Use cached controller to avoid disconnects
70+
cached = cast(
71+
SteamdeckController | None,
72+
_cache.get(),
5573
)
74+
self.dev = None
75+
if cached:
76+
if self.pid == cached.pid and self.name == cached.name:
77+
logger.warning(
78+
f"Using cached controller node for Steamdeck Controller."
79+
)
80+
self.dev = cached.dev
81+
if self.dev and self.dev.fd:
82+
self.fd = self.dev.fd
83+
else:
84+
logger.warning(f"Throwing away cached Steamdeck Controller.")
85+
cached.close(True, in_cache=True)
86+
if not self.dev:
87+
self.dev = UhidDevice(
88+
vid=SDCONT_VENDOR,
89+
pid=self.pid,
90+
bus=BUS_USB,
91+
version=SDCONT_VERSION,
92+
country=SDCONT_COUNTRY,
93+
name=bytes(self.name, "utf-8"),
94+
report_descriptor=SDCONT_DESCRIPTOR,
95+
unique_name=b"",
96+
physical_name=b"",
97+
)
98+
self.fd = self.dev.open()
5699

57100
self.state: dict = defaultdict(lambda: 0)
58101
self.rumble = False
59102
self.touchpad_touch = False
60-
self.start = time.perf_counter_ns()
61-
self.fd = self.dev.open()
62-
return [self.fd]
103+
curr = time.perf_counter()
104+
self.start = curr
105+
self.touchpad_down = curr
106+
self.last_imu = curr
107+
self.imu_failed = False
63108

64-
def close(self, exit: bool) -> bool:
65-
if not exit:
66-
"""This is a consumer, so we would deadlock if it was disabled."""
67-
return False
109+
logger.info(f"Starting '{self.name}'.")
110+
assert self.fd
111+
return [self.fd]
68112

69-
if self.dev:
113+
def close(self, exit: bool, in_cache: bool = False) -> bool:
114+
if not in_cache and time.perf_counter() - self.start:
115+
logger.warning(f"Caching Steam Controller to avoid reconnection.")
116+
_cache.add(self)
117+
elif self.dev:
70118
self.dev.send_destroy()
71119
self.dev.close()
72120
self.dev = None
@@ -83,12 +131,67 @@ def produce(self, fds: Sequence[int]) -> Sequence[Event]:
83131
assert self.dev
84132
while ev := self.dev.read_event():
85133
match ev["type"]:
86-
case "open":
87-
logger.info(f"OPENED")
88-
case "close":
89-
logger.info(f"CLOSED")
134+
# case "open":
135+
# logger.info(f"SD OPENED")
136+
# case "close":
137+
# logger.info(f"SD CLOSED")
90138
case "get_report":
91139
match self.last_rep:
140+
case 0x83:
141+
rep = bytes(
142+
[
143+
0x83,
144+
0x2D, # 45/5=9 attrs
145+
# https://github.com/libsdl-org/SDL/blob/eed94cb0345cbf6dc9088c7bfc3d10828cb19f9d/src/joystick/hidapi/steam/controller_constants.h#L363
146+
0x01, # ATTRIB_PRODUCT_ID
147+
0x05,
148+
0x12,
149+
0x00,
150+
0x00,
151+
0x02, # ATTRIB_PRODUCT_REVISON
152+
0x00,
153+
0x00,
154+
0x00,
155+
0x00,
156+
0x0A, # ATTRIB_BOOTLOADER_BUILD_TIME
157+
0x2B,
158+
0x12,
159+
0xA9,
160+
0x62,
161+
0x04, # ATTRIB_FIRMWARE_BUILD_TIME
162+
0xB7,
163+
0x61,
164+
0x7C,
165+
0x67,
166+
0x09, # ATTRIB_BOARD_REVISION
167+
0x2E,
168+
0x00,
169+
0x00,
170+
0x00,
171+
0x0B, # ATTRIB_CONNECTION_INTERVAL_IN_US
172+
0xA0,
173+
0x0F,
174+
0x00,
175+
0x00,
176+
0x0D, # attr
177+
0x00,
178+
0x00,
179+
0x00,
180+
0x00,
181+
0x0C, # attr
182+
0x00,
183+
0x00,
184+
0x00,
185+
0x00,
186+
0x0E, # attr
187+
0x00,
188+
0x00,
189+
0x00,
190+
0x00,
191+
]
192+
)
193+
if not ev["rnum"]:
194+
rep = bytes([0]) + rep
92195
case 0xAE:
93196
rep = bytes(
94197
[
@@ -107,16 +210,129 @@ def produce(self, fds: Sequence[int]) -> Sequence[Event]:
107210
)
108211
case "set_report":
109212
self.dev.send_set_report_reply(ev["id"], 0)
110-
logger.info(
111-
f"SET_REPORT({ev['rnum']:02x}:{ev['rtype']:02x}): {trim(ev['data']).hex()}"
112-
)
113213
self.last_rep = ev["data"][3]
214+
215+
match self.last_rep:
216+
case 0xEB:
217+
left = int.from_bytes(
218+
ev["data"][8:10], byteorder="little", signed=True
219+
)
220+
right = int.from_bytes(
221+
ev["data"][10:12], byteorder="little", signed=True
222+
)
223+
out.append(
224+
{
225+
"type": "rumble",
226+
"code": "main",
227+
# For some reason goes to 127
228+
"strong_magnitude": left / (2**16 - 1),
229+
"weak_magnitude": right / (2**16 - 1),
230+
}
231+
)
232+
case 0x8f:
233+
pass
234+
case _:
235+
logger.info(
236+
f"SD SET_REPORT({ev['rnum']:02x}:{ev['rtype']:02x}): {trim(ev['data']).hex()}"
237+
)
238+
239+
# 410000eb 0901401f 0000 0000 fbfb
240+
# 410000eb 0901401f ff7f ff7f fbfb
114241
case "output":
115-
logger.info(f"OUTPUT")
242+
logger.info(f"SD OUTPUT")
116243
case _:
117-
logger.warning(f"UKN_EVENT: {ev}")
244+
logger.warning(f"SD UKN_EVENT: {ev}")
118245

119246
return out
120247

121248
def consume(self, events: Sequence[Event]):
122-
pass
249+
if not self.dev:
250+
return
251+
252+
assert self.report
253+
254+
# To fix gyro to mouse in latest steam
255+
# only send updates when gyro sends a timestamp
256+
send = not self.sync_gyro
257+
curr = time.perf_counter()
258+
259+
new_rep = bytearray(self.report)
260+
for ev in events:
261+
code = ev["code"]
262+
match ev["type"]:
263+
case "axis":
264+
if not self.enable_touchpad and code.startswith("touchpad"):
265+
continue
266+
if code in SD_AXIS_MAP:
267+
try:
268+
encode_axis(new_rep, SD_AXIS_MAP[code], ev["value"])
269+
except Exception:
270+
logger.warning(
271+
f"Encoding '{ev['code']}' with {ev['value']} overflowed."
272+
)
273+
# DPAD is weird
274+
match code:
275+
case "hat_x":
276+
self.state["hat_x"] = ev["value"]
277+
# patch_dpad_val(
278+
# new_rep,
279+
# self.ofs,
280+
# self.state["hat_x"],
281+
# self.state["hat_y"],
282+
# )
283+
case "hat_y":
284+
self.state["hat_y"] = ev["value"]
285+
# patch_dpad_val(
286+
# new_rep,
287+
# self.ofs,
288+
# self.state["hat_x"],
289+
# self.state["hat_y"],
290+
# )
291+
case "gyro_ts" | "accel_ts" | "imu_ts":
292+
send = True
293+
self.last_imu = time.perf_counter()
294+
self.last_imu_ts = ev["value"]
295+
# new_rep[self.ofs + 27 : self.ofs + 31] = int(
296+
# ev["value"] / DS5_EDGE_DELTA_TIME_NS
297+
# ).to_bytes(8, byteorder="little", signed=False)[:4]
298+
case "button":
299+
if code in SD_BTN_MAP:
300+
set_button(new_rep, SD_BTN_MAP[code], ev["value"])
301+
302+
# # Fix touchpad click requiring touch
303+
# if code == "touchpad_touch":
304+
# self.touchpad_touch = ev["value"]
305+
# if code == "touchpad_left":
306+
# set_button(
307+
# new_rep,
308+
# SD_BTN_MAP["touchpad_touch"],
309+
# ev["value"] or self.touchpad_touch,
310+
# )
311+
# # Also add right click
312+
# if code == "touchpad_right":
313+
# set_button(
314+
# new_rep,
315+
# SD_BTN_MAP["touchpad_touch"],
316+
# ev["value"] or self.touchpad_touch,
317+
# )
318+
# set_button(
319+
# new_rep,
320+
# SD_BTN_MAP["touchpad_touch2"],
321+
# ev["value"],
322+
# )
323+
324+
# If the IMU breaks, smoothly re-enable the controller
325+
failover = self.last_imu + MAX_IMU_SYNC_DELAY < curr
326+
if self.sync_gyro and failover and not self.imu_failed:
327+
self.imu_failed = True
328+
logger.error(
329+
f"IMU Did not send information for {MAX_IMU_SYNC_DELAY}s. Disabling Gyro Sync."
330+
)
331+
332+
self.report = new_rep
333+
# sign_crc32_inplace(self.report, DS5_INPUT_CRC32_SEED)
334+
if send or failover:
335+
new_rep[4:8] = self.i.to_bytes(4, byteorder="little", signed=False)
336+
self.i = self.i + 1 if self.i < 0xFFFF else 0
337+
self.dev.send_input_report(self.report)
338+
# logger.info(self.report.hex())

0 commit comments

Comments
 (0)