diff --git a/src/hhd/__main__.py b/src/hhd/__main__.py index 1a728217..0bc179d3 100644 --- a/src/hhd/__main__.py +++ b/src/hhd/__main__.py @@ -823,10 +823,12 @@ def progress(idx, blockSize, total): from hhd.controller.virtual.dualsense import Dualsense from hhd.controller.virtual.uinput import UInputDevice from hhd.controller.virtual.sd import SteamdeckController + from hhd.controller.virtual.sinput import SInputController UInputDevice.close_cached() Dualsense.close_cached() SteamdeckController.close_cached() + SInputController.close_cached() except Exception as e: logger.error("Could not close cached controllers with error:\n{e}") diff --git a/src/hhd/controller/virtual/sinput/__init__.py b/src/hhd/controller/virtual/sinput/__init__.py new file mode 100644 index 00000000..0d186739 --- /dev/null +++ b/src/hhd/controller/virtual/sinput/__init__.py @@ -0,0 +1,457 @@ +import logging +import time +from collections import defaultdict +from typing import Sequence, cast, Literal + +from hhd.controller import ( + Consumer, + Event, + Producer, +) +from hhd.controller.lib.common import encode_axis, set_button +from hhd.controller.lib.uhid import BUS_BLUETOOTH, BUS_USB, UhidDevice +from hhd.controller.lib.ccache import ControllerCache + +from .const import ( + SINPUT_HID_REPORT, + SINPUT_BTN_MAP, + SINPUT_AXIS_MAP_V1, + SINPUT_AXIS_MAP_V2, + GYRO_SCALE_V2, + ACCEL_SCALE_V2, + get_button_mask, + GYRO_MAX_DPS, + ACCEL_MAX_G, + SINPUT_AVAILABLE_BUTTONS, + SDL_SUBTYPE_XINPUT_SHARE_NONE, + SDL_SUBTYPE_XINPUT_SHARE_DUAL, + SDL_SUBTYPE_XINPUT_SHARE_QUAD, + SDL_SUBTYPE_XINPUT_SHARE_NONE_CLICK, + SDL_SUBTYPE_XINPUT_SHARE_DUAL_CLICK, + SDL_SUBTYPE_XINPUT_SHARE_QUAD_CLICK, +) + +SINPUT_NANE = "S-Input (HHD)" +MAX_IMU_SYNC_DELAY = 2 + +logger = logging.getLogger(__name__) + +_cache = ControllerCache() + + +def prefill_report(axis) -> bytearray: + """Prefill the report with zeros.""" + report = bytearray(64) + report[0] = 0x01 # Report type + encode_axis(report, axis["lt"], 0) + encode_axis(report, axis["rt"], 0) + return report + + +class SInputController(Producer, Consumer): + @staticmethod + def close_cached(): + _cache.close() + + def __init__( + self, + enable_touchpad: bool = True, + touchpad_click: bool = False, + enable_rgb: bool = True, + enable_gyro: bool = True, + sync_gyro: bool = False, + controller_id: int = 0, + glyphs: Literal["standard", "xbox", "sony", "nintendo"] = "standard", + paddles: Literal["none", "dual", "quad"] = "none", + cache: bool = False, + ) -> None: + self.enable_touchpad = enable_touchpad + self.enable_rgb = enable_rgb + self.enable_gyro = enable_gyro + self.sync_gyro = sync_gyro + self.controller_id = controller_id + self.glyphs = glyphs + self.paddles = paddles + self.touchpad_click = touchpad_click + self.axis = {} + self.btns = {} + + self.settings = ( + enable_touchpad, + enable_rgb, + enable_gyro, + sync_gyro, + controller_id, + glyphs, + paddles, + ) + + self.version = 1 + if self.version == 2: + self.axis = SINPUT_AXIS_MAP_V2 + else: + self.axis = SINPUT_AXIS_MAP_V1 + + self.cache = cache + self.report = prefill_report(self.axis) + self.dev: UhidDevice | None = None + self.fd: int | None = None + self.available = False + + def open(self) -> Sequence[int]: + self.available = False + self.report = prefill_report(self.axis) + + cached = cast(SInputController | None, _cache.get()) + + # Use cached controller to avoid disconnects + self.dev = None + if cached: + if self.settings == cached.settings: + logger.warning(f"Using cached controller node for SInput.") + self.dev = cached.dev + if self.dev and self.dev.fd: + self.fd = self.dev.fd + else: + logger.warning(f"Throwing away cached Sinput controller.") + cached.close(True, in_cache=True) + if not self.dev: + self.dev = UhidDevice( + vid=0x16D0, + pid=0x145B, + bus=BUS_USB, + version=256, + country=0, + name=SINPUT_NANE.encode(), + report_descriptor=(SINPUT_HID_REPORT), + ) + self.fd = self.dev.open() + + self.state: dict = defaultdict(lambda: 0) + self.rumble = False + self.touchpad_touch = False + curr = time.perf_counter() + self.touchpad_down = curr + self.last_imu = curr + self.imu_failed = False + self.start = time.perf_counter() + self._prepare_features(False) + + logger.info( + f"Starting S-Input controller with RGB={self.enable_rgb}, touchpad={self.enable_touchpad}." + ) + assert self.fd + return [self.fd] + + def close(self, exit: bool, in_cache: bool = False) -> bool: + if not in_cache and self.cache and time.perf_counter() - self.start: + logger.warning(f"Caching SInput device to avoid reconnection.") + _cache.add(self) + elif self.dev: + self.dev.send_destroy() + self.dev.close() + self.dev = None + self.fd = None + + return True + + def _prepare_features(self, feat: bool) -> bytearray: + # Feature report + feats = bytearray(64) + feats[0] = 0x02 + feats[1] = 0x02 + + if feat: + feats[2:4] = b"SI" + ofs = 4 + else: + ofs = 2 + + # + # Features + # + + # Set SDL types based on available buttons + gtype = 0x02 + if self.touchpad_click: + match self.paddles: + case "none": + gtype = SDL_SUBTYPE_XINPUT_SHARE_NONE_CLICK + case "dual": + gtype = SDL_SUBTYPE_XINPUT_SHARE_DUAL_CLICK + case "quad": + gtype = SDL_SUBTYPE_XINPUT_SHARE_QUAD_CLICK + else: + match self.paddles: + case "none": + gtype = SDL_SUBTYPE_XINPUT_SHARE_NONE + case "dual": + gtype = SDL_SUBTYPE_XINPUT_SHARE_DUAL + case "quad": + gtype = SDL_SUBTYPE_XINPUT_SHARE_QUAD + + match self.glyphs: + case "standard": + sdl_type = 0x01 + sdl_subtype = (1 << 5) | gtype + case "xbox": + sdl_type = 0x03 + sdl_subtype = (1 << 5) | gtype + case "sony": + sdl_type = 0x06 + sdl_subtype = (4 << 5) | gtype + case "nintendo": + sdl_type = 0x07 + sdl_subtype = (3 << 5) | gtype + + # Set values depending on mask + match self.version: + case 2: + # Protocol version + feats[ofs : ofs + 2] = (0x02, 0x00) + + sdl_type = 12 # handheld type, supported by V2 + feats[ofs + 2] = sdl_type + feats[ofs + 3] = sdl_subtype + serial_ofs = ofs + 4 + + feats[ofs + 10 : ofs + 12] = int.to_bytes( + 1000, 2, "little" + ) # todo: make this dynamic + feats[ofs + 12 : ofs + 14] = int.to_bytes(ACCEL_SCALE_V2, 2, "little") + feats[ofs + 14 : ofs + 16] = int.to_bytes(GYRO_SCALE_V2, 2, "little") + + # Enable all features except player led + bmask = get_button_mask(ofs + 18) + feats[ofs + 22] = ( + 0x01 + self.enable_gyro * (0x04 + 0x08) + 0x10 + 0x20 + 0x40 + 0x80 + ) + # We are a handheld, with touchpad, and rgb + feats[ofs + 23] = ( + self.enable_touchpad * 0x01 + 0x10 + self.enable_rgb * 0x20 + ) + case 0 | 1: + # Protocol version + feats[ofs : ofs + 2] = (0x01, 0x00) + + feats[ofs + 4] = sdl_type + feats[ofs + 5] = sdl_subtype + + # Enable all features except player led + feats[ofs + 2] = ( + 0x01 + self.enable_gyro * (0x04 + 0x08) + 0x10 + 0x20 + 0x40 + 0x80 + ) + # We are a handheld, with touchpad, and rgb + feats[ofs + 3] = ( + self.enable_touchpad * 0x01 + self.enable_rgb * 0x02 + 0x04 + ) + + feats[ofs + 6] = 5 + # Accelerometer scale + feats[ofs + 8 : ofs + 10] = int.to_bytes(ACCEL_MAX_G, 2, "little") + feats[ofs + 10 : ofs + 12] = int.to_bytes(GYRO_MAX_DPS, 2, "little") + + bmask = get_button_mask(ofs + 12) + serial_ofs = ofs + 18 + case _: + raise ValueError(f"Unsupported SInput version {self.version}.") + + # Set button mask + self.btns = SINPUT_AVAILABLE_BUTTONS[gtype] + for key in self.btns: + set_button(feats, bmask[key], True) + + # + # Serial + # + feats[serial_ofs] = 0x53 + feats[serial_ofs + 1] = 0x35 + feats[serial_ofs + 5] = self.controller_id + + return feats + + def produce(self, fds: Sequence[int]) -> Sequence[Event]: + if self.fd not in fds: + return [] + + # Process queued events + out: Sequence[Event] = [] + assert self.dev + while ev := self.dev.read_event(): + match ev["type"]: + case "open": + self.available = True + case "close": + self.available = False + case "output": + rep = ev["data"] + if rep[0] != 0x03: + logger.warning( + f"Received unexpected report type {rep[0]:02x}: {rep.hex()}" + ) + continue + + match rep[1]: + case 0x02: + self.dev.send_input_report(self._prepare_features(False)) + case 1: + if self.version < 2: + if rep[2] != 0x02: + continue + out.append( + { + "type": "rumble", + "code": "main", + "strong_magnitude": rep[3] / 255, + "weak_magnitude": rep[5] / 255, + } + ) + else: + out.append( + { + "type": "rumble", + "code": "main", + "strong_magnitude": int.from_bytes( + rep[2:4], "little" + ) + / 2**16, + "weak_magnitude": int.from_bytes( + rep[6:8], "little" + ) + / 2**16, + } + ) + case 4: + red, green, blue = rep[2:5] + + # Crunch lower values since steam is bugged + if red < 3 and green < 3 and blue < 3: + red = 0 + green = 0 + blue = 0 + + logger.info(f"Changing leds to RGB: {red} {green} {blue}") + + out.append( + { + "type": "led", + "code": "main", + "mode": "solid", + # "brightness": led_brightness / 63 + # if led_brightness + # else 1, + "initialize": False, + "direction": "left", + "speed": 0, + "brightness": 1, + "speedd": "high", + "brightnessd": "high", + "red": red, + "blue": blue, + "green": green, + "red2": 0, # disable for OXP + "blue2": 0, + "green2": 0, + "oxp": None, + } + ) + case _: + logger.info(rep.hex()) + case "set_report": + d = ev["data"] + rid = d[2] + if rid != 0x02: + logger.info( + f"Received set report with unexpected ID {rid:02x}.\n{d.hex()}" + ) + self.dev.send_set_report_reply(ev["id"], 1) + + cmd = d[3] + if cmd == 0x02: + self.dev.send_set_report_reply( + ev["id"], 0 if d[4:10] == b"SINPUT" else 1 + ) + continue + + logger.info( + f"Received set report with command {cmd:02x}.\n{d.hex()}" + ) + + case "get_report": + self.dev.send_get_report_reply( + ev["id"], 0, self._prepare_features(True) + ) + case _: + logger.info(f"Received unhandled report:\n{ev}") + return out + + def consume(self, events: Sequence[Event]): + assert self.dev and self.report + # To fix gyro to mouse in latest steam + # only send updates when gyro sends a timestamp + send = not self.sync_gyro + curr = time.perf_counter() + + new_rep = bytearray(self.report) + for ev in events: + code = ev["code"] + match ev["type"]: + case "axis": + if not self.enable_touchpad and code.startswith("touchpad"): + continue + if code in self.axis: + try: + encode_axis(new_rep, self.axis[code], ev["value"]) + except Exception: + logger.warning( + f"Encoding '{ev['code']}' with {ev['value']} overflowed." + ) + # DPAD is weird + match code: + case "gyro_ts" | "accel_ts" | "imu_ts": + send = True + self.last_imu = time.perf_counter() + self.last_imu_ts = ev["value"] + new_rep[19:23] = int(ev["value"] / 1000).to_bytes( + 8, byteorder="little", signed=False + )[:4] + case "button": + if not self.enable_touchpad and code.startswith("touchpad"): + continue + + if code in self.btns and code in SINPUT_BTN_MAP: + set_button(new_rep, SINPUT_BTN_MAP[code], ev["value"]) + + # Fix touchpad click requiring touch + if code == "touchpad_touch": + self.touchpad_touch = ev["value"] + if code == "touchpad_left": + set_button( + new_rep, + SINPUT_BTN_MAP["touchpad_pressure"], + ev["value"] or self.touchpad_touch, + ) + + # Cache + # Caching can cause issues since receivers expect reports + # at least a couple of times per second + # if new_rep == self.report and not self.fake_timestamps: + # return + self.report = new_rep + + # If the IMU breaks, smoothly re-enable the controller + failover = self.last_imu + MAX_IMU_SYNC_DELAY < curr + if self.sync_gyro and failover and not self.imu_failed: + self.imu_failed = True + logger.error( + f"IMU Did not send information for {MAX_IMU_SYNC_DELAY}s. Disabling Gyro Sync." + ) + + if failover: + new_rep[19:23] = int(time.perf_counter_ns() // 1000).to_bytes( + 8, byteorder="little", signed=False + )[:4] + + if send or failover: + # logger.info(self.report.hex()) + self.dev.send_input_report(self.report) diff --git a/src/hhd/controller/virtual/sinput/const.py b/src/hhd/controller/virtual/sinput/const.py new file mode 100644 index 00000000..43dbb5c1 --- /dev/null +++ b/src/hhd/controller/virtual/sinput/const.py @@ -0,0 +1,242 @@ +from hhd.controller.lib.common import AM, BM + +SINPUT_HID_REPORT = bytes([ + 0x05, 0x01, # Usage Page (Generic Desktop Ctrls) + 0x09, 0x05, # Usage (Gamepad) + 0xA1, 0x01, # Collection (Application) + + # INPUT REPORT ID 0x01 - Main gamepad data + 0x85, 0x01, # Report ID (1) + + # Padding bytes (bytes 2-3) - Plug status and Charge Percent (0-100) + 0x06, 0x00, 0xFF, # Usage Page (Vendor Defined) + 0x09, 0x01, # Usage (Vendor Usage 1) + 0x15, 0x00, # Logical Minimum (0) + 0x25, 0xFF, # Logical Maximum (255) + 0x75, 0x08, # Report Size (8) + 0x95, 0x02, # Report Count (2) + 0x81, 0x02, # Input (Data,Var,Abs) + + # --- 32 buttons --- + 0x05, 0x09, # Usage Page (Button) + 0x19, 0x01, # Usage Minimum (Button 1) + 0x29, 0x20, # Usage Maximum (Button 32) + 0x15, 0x00, # Logical Min (0) + 0x25, 0x01, # Logical Max (1) + 0x75, 0x01, # Report Size (1) + 0x95, 0x20, # Report Count (32) + 0x81, 0x02, # Input (Data,Var,Abs) + + # Analog Sticks and Triggers + 0x05, 0x01, # Usage Page (Generic Desktop) + # Left Stick X (bytes 8-9) + 0x09, 0x30, # Usage (X) + # Left Stick Y (bytes 10-11) + 0x09, 0x31, # Usage (Y) + # Right Stick X (bytes 12-13) + 0x09, 0x32, # Usage (Z) + # Right Stick Y (bytes 14-15) + 0x09, 0x35, # Usage (Rz) + # Right Trigger (bytes 18-19) + 0x09, 0x33, # Usage (Rx) + # Left Trigger (bytes 16-17) + 0x09, 0x34, # Usage (Ry) + 0x16, 0x00, 0x80, # Logical Minimum (-32768) + 0x26, 0xFF, 0x7F, # Logical Maximum (32767) + 0x75, 0x10, # Report Size (16) + 0x95, 0x06, # Report Count (6) + 0x81, 0x02, # Input (Data,Var,Abs) + + # Motion data and Reserved data (bytes 20-63) - 44 bytes + # This includes gyro/accel data that apps can use if supported + 0x06, 0x00, 0xFF, # Usage Page (Vendor Defined) + + # Motion Input Timestamp (Microseconds) + 0x09, 0x20, # Usage (Vendor Usage 0x20) + 0x15, 0x00, # Logical Minimum (0) + 0x26, 0xFF, 0xFF, # Logical Maximum (655535) + 0x75, 0x20, # Report Size (32) + 0x95, 0x01, # Report Count (1) + 0x81, 0x02, # Input (Data,Var,Abs) + + # Motion Input Accelerometer XYZ (Gs) and Gyroscope XYZ (Degrees Per Second) + 0x09, 0x21, # Usage (Vendor Usage 0x21) + 0x16, 0x00, 0x80, # Logical Minimum (-32768) + 0x26, 0xFF, 0x7F, # Logical Maximum (32767) + 0x75, 0x10, # Report Size (16) + 0x95, 0x06, # Report Count (6) + 0x81, 0x02, # Input (Data,Var,Abs) + + # Reserved padding + 0x09, 0x22, # Usage (Vendor Usage 0x22) + 0x15, 0x00, # Logical Minimum (0) + 0x26, 0xFF, 0x00, # Logical Maximum (255) + 0x75, 0x08, # Report Size (8) + 0x95, 0x1D, # Report Count (29) + 0x81, 0x02, # Input (Data,Var,Abs) + + # INPUT REPORT ID 0x02 - Vendor COMMAND data + 0x85, 0x02, # Report ID (2) + 0x09, 0x23, # Usage (Vendor Usage 0x23) + 0x15, 0x00, # Logical Minimum (0) + 0x26, 0xFF, 0x00, # Logical Maximum (255) + 0x75, 0x08, # Report Size (8 bits) + 0x95, 0x3F, # Report Count (63) - 64 bytes minus report ID + 0x81, 0x02, # Input (Data,Var,Abs) + + # FEATURE REPORT ID 0x02 - Vendor Feature data + 0x09, 0x24, # Usage (Vendor Usage 0x24) + 0x19, 0x00, # Usage Minimum (0) + 0x2a, 0xff, 0x00, # Usage Maximum (255) + 0x15, 0x00, # Logical Minimum (0) + 0x26, 0xff, 0x00, # Logical Maximum (255) + 0x75, 0x08, # Report Size (8) + 0x95, 0x3f, # Report Count (63) + 0xb1, 0x00, # Feature (Data,Arr,Abs) + + # OUTPUT REPORT ID 0x03 - Vendor COMMAND data + 0x85, 0x03, # Report ID (3) + 0x09, 0x24, # Usage (Vendor Usage 0x24) + 0x15, 0x00, # Logical Minimum (0) + 0x26, 0xFF, 0x00, # Logical Maximum (255) + 0x75, 0x08, # Report Size (8 bits) + 0x95, 0x2F, # Report Count (47) - 48 bytes minus report ID + 0x91, 0x02, # Output (Data,Var,Abs) + + 0xC0 # End Collection +]) + +ACCEL_MAX_G = 3 +ACCEL_SCALE = (2**15 - 1) / ACCEL_MAX_G / 9.80665 +GYRO_MAX_DPS = 1600 +GYRO_SCALE = -(2**15 - 1) * 180 / 3.14 / GYRO_MAX_DPS + +SINPUT_AXIS_MAP_V1 = { + "ls_x": AM((7 << 3), "i16"), + "ls_y": AM((9 << 3), "i16"), + "rs_x": AM((11 << 3), "i16"), + "rs_y": AM((13 << 3), "i16"), + "rt": AM((15 << 3), "i16", scale=2**16 - 2, offset=-(2**15 - 1)), + "lt": AM((17 << 3), "i16", scale=2**16 - 2, offset=-(2**15 - 1)), + "accel_x": AM( + (23 << 3), "i16", scale=ACCEL_SCALE, bounds=(-(2**15) + 2, 2**15 - 1) + ), + "accel_y": AM( + (25 << 3), "i16", scale=ACCEL_SCALE, bounds=(-(2**15) + 2, 2**15 - 1) + ), + "accel_z": AM( + (27 << 3), "i16", scale=ACCEL_SCALE, bounds=(-(2**15) + 2, 2**15 - 1) + ), + "gyro_x": AM((29 << 3), "i16", scale=GYRO_SCALE), + "gyro_y": AM((31 << 3), "i16", scale=GYRO_SCALE), + "gyro_z": AM((33 << 3), "i16", scale=GYRO_SCALE), +} + +ACCEL_SCALE_V2 = 10197 +GYRO_SCALE_V2 = 11465 + +SINPUT_AXIS_MAP_V2 = { + "ls_x": AM((7 << 3), "i16"), + "ls_y": AM((9 << 3), "i16"), + "rs_x": AM((11 << 3), "i16"), + "rs_y": AM((13 << 3), "i16"), + "rt": AM((15 << 3), "i16", scale=2**16 - 2, offset=-(2**15 - 1)), + "lt": AM((17 << 3), "i16", scale=2**16 - 2, offset=-(2**15 - 1)), + "accel_x": AM( + (23 << 3), "i16", scale=ACCEL_SCALE_V2 / 10, bounds=(-(2**15) + 2, 2**15 - 1) + ), + "accel_y": AM( + (25 << 3), "i16", scale=ACCEL_SCALE_V2 / 10, bounds=(-(2**15) + 2, 2**15 - 1) + ), + "accel_z": AM( + (27 << 3), "i16", scale=ACCEL_SCALE_V2 / 10, bounds=(-(2**15) + 2, 2**15 - 1) + ), + "gyro_x": AM((29 << 3), "i16", scale=-GYRO_SCALE_V2 / 10), + "gyro_y": AM((31 << 3), "i16", scale=-GYRO_SCALE_V2 / 10), + "gyro_z": AM((33 << 3), "i16", scale=-GYRO_SCALE_V2 / 10), +} + +get_button_mask = lambda ofs: { + # Byte 0 + "b": BM((ofs << 3) + 7), + "a": BM((ofs << 3) + 6), + "y": BM((ofs << 3) + 5), + "x": BM((ofs << 3) + 4), + "dpad_up": BM((ofs << 3) + 3), + "dpad_down": BM((ofs << 3) + 2), + "dpad_left": BM((ofs << 3) + 1), + "dpad_right": BM((ofs << 3)), + # Byte 1 + "ls": BM(((ofs + 1) << 3) + 7), + "rs": BM(((ofs + 1) << 3) + 6), + "lb": BM(((ofs + 1) << 3) + 5), + "rb": BM(((ofs + 1) << 3) + 4), + # "lt": BM(((ofs + 1) << 3) + 3), + # "rt": BM(((ofs + 1) << 3) + 2), + "extra_l1": BM(((ofs + 1) << 3) + 1), + "extra_r1": BM(((ofs + 1) << 3)), + # Byte 2 + "start": BM(((ofs + 2) << 3) + 7), + "select": BM(((ofs + 2) << 3) + 6), + "mode": BM(((ofs + 2) << 3) + 5), + "share": BM(((ofs + 2) << 3) + 4), + "extra_r2": BM(((ofs + 2) << 3) + 3), + "extra_l2": BM(((ofs + 2) << 3) + 2), + "touchpad_left": BM(((ofs + 2) << 3) + 1), +} + +SINPUT_BTN_MAP = get_button_mask(3) + +XINPUT = [ + "b", + "a", + "y", + "x", + "dpad_up", + "dpad_down", + "dpad_left", + "dpad_right", + "ls", + "rs", + "lb", + "rb", + "start", + "select", + "mode", +] + +STANDARD_BUTTONS = XINPUT + [ + "share", +] + +DUAL_PADDLES = [ + "extra_l1", + "extra_r1", +] + STANDARD_BUTTONS + +QUAD_PADDLES = [ + "extra_l1", + "extra_r1", + "extra_l2", + "extra_r2", +] + STANDARD_BUTTONS + +SDL_SUBTYPE_FULL_MAPPING = 0x00 +SDL_SUBTYPE_XINPUT_ONLY = 0x01 +SDL_SUBTYPE_XINPUT_SHARE_NONE = 0x02 +SDL_SUBTYPE_XINPUT_SHARE_DUAL = 0x03 +SDL_SUBTYPE_XINPUT_SHARE_QUAD = 0x04 +SDL_SUBTYPE_XINPUT_SHARE_NONE_CLICK = 0x05 +SDL_SUBTYPE_XINPUT_SHARE_DUAL_CLICK = 0x06 +SDL_SUBTYPE_XINPUT_SHARE_QUAD_CLICK = 0x07 +SDL_SUBTYPE_LOAD_FIRMWARE = 0xFF + +SINPUT_AVAILABLE_BUTTONS = { + SDL_SUBTYPE_XINPUT_ONLY: STANDARD_BUTTONS, + SDL_SUBTYPE_XINPUT_SHARE_NONE: STANDARD_BUTTONS, + SDL_SUBTYPE_XINPUT_SHARE_DUAL: DUAL_PADDLES, + SDL_SUBTYPE_XINPUT_SHARE_QUAD: QUAD_PADDLES, + SDL_SUBTYPE_XINPUT_SHARE_NONE_CLICK: STANDARD_BUTTONS + ["touchpad_left"], + SDL_SUBTYPE_XINPUT_SHARE_DUAL_CLICK: DUAL_PADDLES + ["touchpad_left"], + SDL_SUBTYPE_XINPUT_SHARE_QUAD_CLICK: QUAD_PADDLES + ["touchpad_left"], +} diff --git a/src/hhd/device/claw/base.py b/src/hhd/device/claw/base.py index 7aeb3086..cd590b71 100644 --- a/src/hhd/device/claw/base.py +++ b/src/hhd/device/claw/base.py @@ -369,6 +369,7 @@ def controller_loop( None, emit=emit, rgb_modes={"disabled": [], "solid": ["color"]}, + extra_buttons=dconf.get("extra_buttons", "dual"), ) # Inputs diff --git a/src/hhd/device/generic/base.py b/src/hhd/device/generic/base.py index 77eb6ec3..f89544ef 100644 --- a/src/hhd/device/generic/base.py +++ b/src/hhd/device/generic/base.py @@ -125,6 +125,7 @@ def controller_loop( emit=emit, rgb_modes={"disabled": [], "solid": ["color"]} if is_led_supported() else None, rgb_resets_on_ac=is_led_supported(), + extra_buttons=dconf.get("extra_buttons", "dual"), ) motion = d_params.get("uses_motion", True) diff --git a/src/hhd/device/legion_go/slim/base.py b/src/hhd/device/legion_go/slim/base.py index 6fba55c2..b2ea2e99 100644 --- a/src/hhd/device/legion_go/slim/base.py +++ b/src/hhd/device/legion_go/slim/base.py @@ -241,6 +241,7 @@ def controller_loop_xinput( "rainbow": ["brightness", "speed"], "spiral": ["brightness", "speed"], }, + extra_buttons="dual", ) swap_legion = conf["swap_legion"].to(bool) diff --git a/src/hhd/device/legion_go/tablet/base.py b/src/hhd/device/legion_go/tablet/base.py index 3f9eb873..c922042e 100644 --- a/src/hhd/device/legion_go/tablet/base.py +++ b/src/hhd/device/legion_go/tablet/base.py @@ -247,6 +247,7 @@ def controller_loop_xinput( "rainbow": ["brightness", "speed"], "spiral": ["brightness", "speed"], }, + extra_buttons="quad", ) motion = d_params.get("uses_motion", True) dual_motion = d_params.get("uses_dual_motion", True) diff --git a/src/hhd/device/oxp/base.py b/src/hhd/device/oxp/base.py index d31ee936..6d90025d 100644 --- a/src/hhd/device/oxp/base.py +++ b/src/hhd/device/oxp/base.py @@ -288,6 +288,7 @@ def turbo_loop( emit=emit, rgb_modes=rgb_modes, # type: ignore controller_disabled=True, + extra_buttons=dconf.get("extra_buttons", "dual"), ) d_kbd_1 = GenericGamepadEvdev( diff --git a/src/hhd/plugins/outputs.py b/src/hhd/plugins/outputs.py index 11ae4aec..7adeb705 100644 --- a/src/hhd/plugins/outputs.py +++ b/src/hhd/plugins/outputs.py @@ -5,6 +5,7 @@ from ..controller.base import Consumer, Producer, RgbMode, RgbSettings, RgbZones from ..controller.virtual.dualsense import Dualsense, TouchpadCorrectionType from ..controller.virtual.sd import SteamdeckController +from ..controller.virtual.sinput import SInputController from ..controller.virtual.uinput import ( CONTROLLER_THEMES, GAMEPAD_BUTTON_MAP, @@ -41,6 +42,7 @@ def get_outputs( rgb_resets_on_ac: bool = False, controller_disabled: bool = False, touchpad_enable: Literal["disabled", "gamemode", "always"] | None = None, + extra_buttons: Literal["none", "dual", "quad"] = "dual", ) -> tuple[Sequence[Producer], Sequence[Consumer], Mapping[str, Any]]: producers = [] consumers = [] @@ -58,7 +60,7 @@ def get_outputs( correction = "legos" # todo: make generic desktop_disable = touchpad_enable == "gamemode" else: - touchpad = "controller" + touchpad = "disabled" correction = "stretch" # Run steam check for touchpad @@ -89,11 +91,36 @@ def get_outputs( UInputDevice.close_cached() Dualsense.close_cached() SteamdeckController.close_cached() + SInputController.close_cached() motion = False noob_mode = conf.get("hidden.noob_mode", False) + case "sinput": + UInputDevice.close_cached() + Dualsense.close_cached() + SteamdeckController.close_cached() + uses_touch = touchpad == "controller" and steam_check is not False + uses_leds = conf.get("sinput.led_support", False) + paddles_as = conf.get("sinput.paddles_as", "noob") + noob_mode = paddles_as == "noob" + glyphs = conf.get("sinput.glyphs", "standard") + has_qam = True + + d = SInputController( + enable_touchpad=uses_touch, + enable_rgb=uses_leds, + enable_gyro=motion, + sync_gyro=conf["sinput.sync_gyro"].to(bool) and motion, + paddles=extra_buttons if paddles_as == "steam_input" else "none", + glyphs=glyphs, + controller_id=controller_id, + cache=True, + ) + producers.append(d) + consumers.append(d) case "dualsense": UInputDevice.close_cached() SteamdeckController.close_cached() + SInputController.close_cached() flip_z = conf["dualsense.flip_z"].to(bool) uses_touch = touchpad == "controller" and steam_check is not False uses_leds = conf.get("dualsense.led_support", False) @@ -129,6 +156,7 @@ def get_outputs( case "sd": UInputDevice.close_cached() Dualsense.close_cached() + SInputController.close_cached() uses_touch = touchpad == "controller" and steam_check is not False d = SteamdeckController( name="Steam Controller (HHD)", @@ -143,6 +171,7 @@ def get_outputs( case "uinput" | "xbox_elite" | "joycon_pair" | "hori_steam": Dualsense.close_cached() SteamdeckController.close_cached() + SInputController.close_cached() version = 1 sync_gyro = False paddles_as = conf.get("uinput.paddles_as", "noob") @@ -288,6 +317,7 @@ def get_outputs_config( del s["modes"]["disabled"] if not has_leds: del s["modes"]["dualsense"]["children"]["led_support"] + del s["modes"]["sinput"]["children"]["led_support"] if extra_buttons == "none": del s["modes"]["dualsense"]["children"]["paddles_as"] diff --git a/src/hhd/plugins/outputs.yml b/src/hhd/plugins/outputs.yml index 34722ffe..08ea2b65 100644 --- a/src/hhd/plugins/outputs.yml +++ b/src/hhd/plugins/outputs.yml @@ -209,6 +209,64 @@ modes: Test and report back! default: True + sinput: + type: container + tags: [sinput, non-essential] + title: SInput + hint: >- + Allows for gyro, paddles, and has a proper QAM button. + children: + info: + type: display + # title: Capabilities + tags: [non-essential] + default: | + Allows for gyro, paddles, and has a proper QAM button. + paddles_as: + type: multiple + title: Extra buttons as + hint: >- + Changes the behavior of the extra buttons. + Left button is Keyboard, right button is Overlay. + Or they can be left/right touchpad clicks. + For the legion go, top buttons are shortcuts, bottom are + touchpad clicks. + options: + steam_input: Steam Input + noob: Keyboard/Overlay + disabled: Disabled + default: noob + glyphs: + type: multiple + title: Glyphs + options: + standard: Standard + xbox: Xbox + sony: Sony + nintendo: Nintendo + default: standard + + led_support: + type: bool + title: LED Support + hint: >- + Passes through the LEDs to the controller, which allows games + to control them. + default: False + sync_gyro: + type: bool + title: Gyro Output Sync + tags: [non-essential] + hint: >- + Steam relies on the IMU timestamp for the touchpad as Mouse and `Gyro to Mouse [BETA]`. + If the same timestamp is sent in 2 reports, this causes a division by 0 and instability. + This option makes it so reports are sent only when there is a new + IMU timestamp, effectively limiting the responsiveness of the + controller to that of the IMU. + This only makes a difference for the Legion Go (125hz), as all the other + handhelds are using 400hz by default. + default: True + disabled: type: container tags: [lgc_emulation_disabled, expert, non-essential] diff --git a/src/hhd/plugins/overlay/controllers.py b/src/hhd/plugins/overlay/controllers.py index f7502435..012e3d8a 100644 --- a/src/hhd/plugins/overlay/controllers.py +++ b/src/hhd/plugins/overlay/controllers.py @@ -266,6 +266,11 @@ def find_devices( if dev.get("vendor", 0) == 0x28DE and dev.get("product", 0) == 0x11FF: continue + # Skip SInput devices + # Vendor=28de Product=11ff + if dev.get("vendor", 0) == 0x2E8A and dev.get("product", 0) == 0x10C6: + continue + abs = dev.get("byte", {}).get("abs", bytes()) keys = dev.get("byte", {}).get("key", bytes()) diff --git a/usr/lib/udev/rules.d/83-hhd.rules b/usr/lib/udev/rules.d/83-hhd.rules index ff015011..886b9d26 100644 --- a/usr/lib/udev/rules.d/83-hhd.rules +++ b/usr/lib/udev/rules.d/83-hhd.rules @@ -41,4 +41,23 @@ ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="e310", RUN+="/sbin/modprobe xpad" RU # Banish Ally HID devices to oblivion since they crash SDL/Proton controller handlers SUBSYSTEMS=="usb|hidraw", ATTRS{idVendor}=="0b05", ATTRS{idProduct}=="1b4c", MODE="000", GROUP="root", TAG-="uaccess", RUN+="/bin/chmod 000 /dev/%k" -SUBSYSTEMS=="usb|hidraw", ATTRS{idVendor}=="0b05", ATTRS{idProduct}=="1abe", MODE="000", GROUP="root", TAG-="uaccess", RUN+="/bin/chmod 000 /dev/%k" \ No newline at end of file +SUBSYSTEMS=="usb|hidraw", ATTRS{idVendor}=="0b05", ATTRS{idProduct}=="1abe", MODE="000", GROUP="root", TAG-="uaccess", RUN+="/bin/chmod 000 /dev/%k" + +# +# SInput +# + +# Generic SInput Device; Bluetooth; USB +KERNEL=="hidraw*", KERNELS=="*2E8A:10C6*", MODE="0660", TAG+="uaccess" +KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="2e8a", ATTRS{idProduct}=="10c6", MODE="0660", TAG+="uaccess" + +# ProGCC in SInput Mode; Bluetooth; USB +KERNEL=="hidraw*", KERNELS=="*2E8A:10DF*", MODE="0660", TAG+="uaccess" +KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="2e8a", ATTRS{idProduct}=="10df", MODE="0660", TAG+="uaccess" + +# GC Ultimate in SInput Mode; Bluetooth; USB +KERNEL=="hidraw*", KERNELS=="*2E8A:10DD*", MODE="0660", TAG+="uaccess" +KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="2e8a", ATTRS{idProduct}=="10dd", MODE="0660", TAG+="uaccess" + +# Firebird in SInput Mode; USB +KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="2e8a", ATTRS{idProduct}=="10e0", MODE="0660", TAG+="uaccess"