From 5c18c75d33c6726b89565d075d63522d89abd618 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Mon, 16 Mar 2026 16:54:58 -0600 Subject: [PATCH] feat(bridge): migrate to SDL3 and fix controller hotplug issues - Migrate all SDL2 GameController API calls to SDL3 Gamepad equivalents - Fix PermissionError on controller reconnect by closing UART handle before releasing port back to pool (Windows holds COM ports exclusively) - Add DisplayIndexAllocator so controllers get stable display indices (0, 0, 0) instead of incrementing SDL instance IDs (1, 2, 3) on each reconnect - Button mapping now uses SDL3 positional constants (SOUTH/EAST/WEST/NORTH) which works correctly for all controller types without USE_BUTTON_LABELS hint Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../controller_uart_bridge.py | 399 ++++++++++-------- src/switch_pico_bridge/switch_pico_uart.py | 6 +- 2 files changed, 223 insertions(+), 182 deletions(-) diff --git a/src/switch_pico_bridge/controller_uart_bridge.py b/src/switch_pico_bridge/controller_uart_bridge.py index 7b280d3..a5a22af 100644 --- a/src/switch_pico_bridge/controller_uart_bridge.py +++ b/src/switch_pico_bridge/controller_uart_bridge.py @@ -16,6 +16,7 @@ Features inspired by ``host/controller_bridge.py``: from __future__ import annotations import argparse +import ctypes import os import sys import time @@ -26,8 +27,7 @@ from pathlib import Path from typing import Dict, List, Optional, Tuple from serial import SerialException -import sdl2 -import sdl2.ext +import sdl3 from rich.console import Console from rich.prompt import Prompt from rich.table import Table @@ -58,8 +58,8 @@ RUMBLE_STUCK_TIMEOUT = 0.60 # continuous same-energy rumble will be stopped aft RUMBLE_MIN_ACTIVE = 0.40 # below this, rumble is treated as off/noise RUMBLE_SCALE = 1.0 CONTROLLER_DB_URL_DEFAULT = "https://raw.githubusercontent.com/mdqinc/SDL_GameControllerDB/refs/heads/master/gamecontrollerdb.txt" -SDL_TRUE = getattr(sdl2, "SDL_TRUE", 1) -SDL_CONTROLLERSENSORUPDATE = getattr(sdl2, "SDL_CONTROLLERSENSORUPDATE", 0x658) +SDL_TRUE = True +SDL_EVENT_GAMEPAD_SENSOR_UPDATE = getattr(sdl3, "SDL_EVENT_GAMEPAD_SENSOR_UPDATE", 0x658) GYRO_BIAS_SAMPLES = 200 IMU_BUFFER_SIZE = 32 @@ -121,41 +121,41 @@ def parse_hotkey(value: str) -> str: def set_hint(name: str, value: str) -> None: - """Set an SDL hint safely even if the constant is missing in PySDL2.""" + """Set an SDL hint safely even if the constant is missing.""" try: - sdl2.SDL_SetHint(name.encode(), value.encode()) + sdl3.SDL_SetHint(name.encode(), value.encode()) except Exception: pass BUTTON_MAP = { # Direct mapping to the Switch PRO mask definitions in switch_pro_descriptors.h - sdl2.SDL_CONTROLLER_BUTTON_A: SwitchButton.A, - sdl2.SDL_CONTROLLER_BUTTON_B: SwitchButton.B, - sdl2.SDL_CONTROLLER_BUTTON_X: SwitchButton.X, - sdl2.SDL_CONTROLLER_BUTTON_Y: SwitchButton.Y, - sdl2.SDL_CONTROLLER_BUTTON_LEFTSHOULDER: SwitchButton.L, - sdl2.SDL_CONTROLLER_BUTTON_RIGHTSHOULDER: SwitchButton.R, - sdl2.SDL_CONTROLLER_BUTTON_BACK: SwitchButton.MINUS, - sdl2.SDL_CONTROLLER_BUTTON_START: SwitchButton.PLUS, - sdl2.SDL_CONTROLLER_BUTTON_GUIDE: SwitchButton.HOME, - sdl2.SDL_CONTROLLER_BUTTON_MISC1: SwitchButton.CAPTURE, - sdl2.SDL_CONTROLLER_BUTTON_LEFTSTICK: SwitchButton.LCLICK, - sdl2.SDL_CONTROLLER_BUTTON_RIGHTSTICK: SwitchButton.RCLICK, + sdl3.SDL_GAMEPAD_BUTTON_SOUTH: SwitchButton.B, + sdl3.SDL_GAMEPAD_BUTTON_EAST: SwitchButton.A, + sdl3.SDL_GAMEPAD_BUTTON_WEST: SwitchButton.Y, + sdl3.SDL_GAMEPAD_BUTTON_NORTH: SwitchButton.X, + sdl3.SDL_GAMEPAD_BUTTON_LEFT_SHOULDER: SwitchButton.L, + sdl3.SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER: SwitchButton.R, + sdl3.SDL_GAMEPAD_BUTTON_BACK: SwitchButton.MINUS, + sdl3.SDL_GAMEPAD_BUTTON_START: SwitchButton.PLUS, + sdl3.SDL_GAMEPAD_BUTTON_GUIDE: SwitchButton.HOME, + sdl3.SDL_GAMEPAD_BUTTON_MISC1: SwitchButton.CAPTURE, + sdl3.SDL_GAMEPAD_BUTTON_LEFT_STICK: SwitchButton.LCLICK, + sdl3.SDL_GAMEPAD_BUTTON_RIGHT_STICK: SwitchButton.RCLICK, } DPAD_BUTTONS = { - sdl2.SDL_CONTROLLER_BUTTON_DPAD_UP: "up", - sdl2.SDL_CONTROLLER_BUTTON_DPAD_DOWN: "down", - sdl2.SDL_CONTROLLER_BUTTON_DPAD_LEFT: "left", - sdl2.SDL_CONTROLLER_BUTTON_DPAD_RIGHT: "right", + sdl3.SDL_GAMEPAD_BUTTON_DPAD_UP: "up", + sdl3.SDL_GAMEPAD_BUTTON_DPAD_DOWN: "down", + sdl3.SDL_GAMEPAD_BUTTON_DPAD_LEFT: "left", + sdl3.SDL_GAMEPAD_BUTTON_DPAD_RIGHT: "right", } STICK_AXIS_LABELS = ( - (sdl2.SDL_CONTROLLER_AXIS_LEFTX, "LX"), - (sdl2.SDL_CONTROLLER_AXIS_LEFTY, "LY"), - (sdl2.SDL_CONTROLLER_AXIS_RIGHTX, "RX"), - (sdl2.SDL_CONTROLLER_AXIS_RIGHTY, "RY"), + (sdl3.SDL_GAMEPAD_AXIS_LEFTX, "LX"), + (sdl3.SDL_GAMEPAD_AXIS_LEFTY, "LY"), + (sdl3.SDL_GAMEPAD_AXIS_RIGHTX, "RX"), + (sdl3.SDL_GAMEPAD_AXIS_RIGHTY, "RY"), ) STICK_AXES = tuple(axis for axis, _ in STICK_AXIS_LABELS) @@ -200,26 +200,26 @@ def interactive_pairing( return mappings -def apply_rumble(controller: sdl2.SDL_GameController, payload: bytes) -> float: +def apply_rumble(controller: sdl3.SDL_Gamepad, payload: bytes) -> float: """Apply rumble payload to SDL controller and return max normalized energy.""" left_norm, right_norm = decode_rumble(payload) max_norm = max(left_norm, right_norm) # Treat small rumble as "off" to avoid idle buzz. if max_norm < RUMBLE_MIN_ACTIVE: - sdl2.SDL_GameControllerRumble(controller, 0, 0, 0) + sdl3.SDL_RumbleGamepad(controller, 0, 0, 0) return 0.0 # Attenuate to feel closer to a real controller; cap at ~25% strength. scale = RUMBLE_SCALE low = int(min(1.0, left_norm * scale) * 0xFFFF) # SDL: low_frequency_rumble high = int(min(1.0, right_norm * scale) * 0xFFFF) # SDL: high_frequency_rumble duration = 10 - sdl2.SDL_GameControllerRumble(controller, low, high, duration) + sdl3.SDL_RumbleGamepad(controller, low, high, duration) return max_norm @dataclass class ControllerContext: - controller: sdl2.SDL_GameController + controller: sdl3.SDL_Gamepad instance_id: int controller_index: int stable_id: str @@ -258,11 +258,11 @@ class ControllerContext: last_debug_imu_print: float = 0.0 -def capture_stick_offsets(controller: sdl2.SDL_GameController) -> Dict[int, int]: +def capture_stick_offsets(controller: sdl3.SDL_Gamepad) -> Dict[int, int]: """Sample the current stick axes so they can be treated as the neutral center.""" offsets: Dict[int, int] = {} for axis in STICK_AXES: - offsets[axis] = int(sdl2.SDL_GameControllerGetAxis(controller, axis)) + offsets[axis] = int(sdl3.SDL_GetGamepadAxis(controller, axis)) return offsets @@ -298,11 +298,11 @@ def convert_gyro_to_raw(rad_s: float, scale: float = 1.0) -> int: def initialize_controller_sensors(ctx: ControllerContext, console: Console) -> None: """Enable SDL accelerometer and gyroscope on the controller if available.""" if not all( - hasattr(sdl2, name) + hasattr(sdl3, name) for name in ( - "SDL_GameControllerHasSensor", - "SDL_GameControllerSetSensorEnabled", - "SDL_GameControllerGetSensorData", + "SDL_GamepadHasSensor", + "SDL_SetGamepadSensorEnabled", + "SDL_GetGamepadSensorData", ) ): ctx.sensors_supported = False @@ -311,22 +311,18 @@ def initialize_controller_sensors(ctx: ControllerContext, console: Console) -> N if SENSOR_ACCEL is None or SENSOR_GYRO is None: ctx.sensors_supported = False return - accel_ok = bool(sdl2.SDL_GameControllerHasSensor(ctx.controller, SENSOR_ACCEL)) - gyro_ok = bool(sdl2.SDL_GameControllerHasSensor(ctx.controller, SENSOR_GYRO)) + accel_ok = bool(sdl3.SDL_GamepadHasSensor(ctx.controller, SENSOR_ACCEL)) + gyro_ok = bool(sdl3.SDL_GamepadHasSensor(ctx.controller, SENSOR_GYRO)) ctx.sensors_supported = accel_ok and gyro_ok if not ctx.sensors_supported: console.print( f"[yellow]Controller {ctx.controller_index} has no accelerometer/gyro sensors[/yellow]" ) return - accel_enabled = ( - sdl2.SDL_GameControllerSetSensorEnabled(ctx.controller, SENSOR_ACCEL, SDL_TRUE) - == 0 - ) - gyro_enabled = ( - sdl2.SDL_GameControllerSetSensorEnabled(ctx.controller, SENSOR_GYRO, SDL_TRUE) - == 0 + accel_enabled = sdl3.SDL_SetGamepadSensorEnabled( + ctx.controller, SENSOR_ACCEL, SDL_TRUE ) + gyro_enabled = sdl3.SDL_SetGamepadSensorEnabled(ctx.controller, SENSOR_GYRO, SDL_TRUE) ctx.sensors_enabled = accel_enabled and gyro_enabled if not ctx.sensors_enabled: console.print( @@ -502,7 +498,7 @@ def zero_all_context_sticks( def controller_display_name(ctx: ControllerContext) -> str: """Return a human-readable controller name.""" - name = sdl2.SDL_GameControllerName(ctx.controller) + name = sdl3.SDL_GetGamepadName(ctx.controller) if not name: return "Unknown" if isinstance(name, bytes): @@ -582,15 +578,15 @@ def prompt_swap_abxy_controller( toggle_abxy_for_context(ctx, config, console) -def open_controller(index: int) -> Tuple[sdl2.SDL_GameController, int, str]: +def open_controller(instance_id: int) -> Tuple[sdl3.SDL_Gamepad, int, str]: """Open an SDL GameController by index and return it with instance ID and GUID string.""" - controller = sdl2.SDL_GameControllerOpen(index) + controller = sdl3.SDL_OpenGamepad(instance_id) if not controller: raise RuntimeError( - f"Failed to open controller {index}: {sdl2.SDL_GetError().decode()}" + f"Failed to open controller {instance_id}: {sdl3.SDL_GetError().decode()}" ) - joystick = sdl2.SDL_GameControllerGetJoystick(controller) - instance_id = sdl2.SDL_JoystickInstanceID(joystick) + joystick = sdl3.SDL_GetGamepadJoystick(controller) + instance_id = sdl3.SDL_GetJoystickID(joystick) guid_str = guid_string_from_joystick(joystick) return controller, instance_id, guid_str @@ -603,19 +599,19 @@ def try_open_uart(port: str, baud: int) -> Optional[PicoUART]: return None -def guid_string_from_joystick(joystick: sdl2.SDL_Joystick) -> str: +def guid_string_from_joystick(joystick: sdl3.SDL_Joystick) -> str: """Return a GUID string for an already-open joystick.""" - guid = sdl2.SDL_JoystickGetGUID(joystick) + guid = sdl3.SDL_GetJoystickGUID(joystick) buf = create_string_buffer(33) - sdl2.SDL_JoystickGetGUIDString(guid, buf, 33) + sdl3.SDL_GUIDToString(guid, buf, 33) return buf.value.decode().lower() if buf.value else "" -def guid_string_for_device_index(index: int) -> str: - """Return a GUID string for a joystick device index without opening it.""" - guid = sdl2.SDL_JoystickGetDeviceGUID(index) +def guid_string_for_instance_id(instance_id: int) -> str: + """Return a GUID string for a joystick instance ID without opening it.""" + guid = sdl3.SDL_GetJoystickGUIDForID(instance_id) buf = create_string_buffer(33) - sdl2.SDL_JoystickGetGUIDString(guid, buf, 33) + sdl3.SDL_GUIDToString(guid, buf, 33) return buf.value.decode().lower() if buf.value else "" @@ -786,7 +782,7 @@ def poll_controller_buttons( """Update button/hat state based on current SDL controller readings.""" changed = False for sdl_button, switch_bit in button_map.items(): - pressed = bool(sdl2.SDL_GameControllerGetButton(ctx.controller, sdl_button)) + pressed = bool(sdl3.SDL_GetGamepadButton(ctx.controller, sdl_button)) previous = ctx.button_state.get(sdl_button) if previous == pressed: continue @@ -799,7 +795,7 @@ def poll_controller_buttons( dpad_changed = False for sdl_button, name in DPAD_BUTTONS.items(): - pressed = bool(sdl2.SDL_GameControllerGetButton(ctx.controller, sdl_button)) + pressed = bool(sdl3.SDL_GetGamepadButton(ctx.controller, sdl_button)) if ctx.dpad[name] == pressed: continue ctx.dpad[name] = pressed @@ -827,6 +823,25 @@ class BridgeConfig: gyro_scale: float = 1.0 +class DisplayIndexAllocator: + """Assigns stable 0-based display indices to controllers, recycling freed slots.""" + + def __init__(self) -> None: + self._next: int = 0 + self._free: List[int] = [] + + def allocate(self) -> int: + if self._free: + return self._free.pop(0) + idx = self._next + self._next += 1 + return idx + + def release(self, idx: int) -> None: + self._free.append(idx) + self._free.sort() + + @dataclass class PairingState: mapping_by_index: Dict[int, str] @@ -838,6 +853,7 @@ class PairingState: ignore_port_desc: List[str] = field(default_factory=list) include_port_desc: List[str] = field(default_factory=list) include_port_mfr: List[str] = field(default_factory=list) + display_index_alloc: DisplayIndexAllocator = field(default_factory=DisplayIndexAllocator) def load_button_maps( @@ -853,16 +869,16 @@ def load_button_maps( mappings_to_load.extend(args.sdl_mapping) button_map_default = dict(BUTTON_MAP) button_map_swapped = dict(BUTTON_MAP) - button_map_swapped[sdl2.SDL_CONTROLLER_BUTTON_A] = SwitchButton.B - button_map_swapped[sdl2.SDL_CONTROLLER_BUTTON_B] = SwitchButton.A - button_map_swapped[sdl2.SDL_CONTROLLER_BUTTON_X] = SwitchButton.Y - button_map_swapped[sdl2.SDL_CONTROLLER_BUTTON_Y] = SwitchButton.X + button_map_swapped[sdl3.SDL_GAMEPAD_BUTTON_SOUTH] = SwitchButton.A + button_map_swapped[sdl3.SDL_GAMEPAD_BUTTON_EAST] = SwitchButton.B + button_map_swapped[sdl3.SDL_GAMEPAD_BUTTON_WEST] = SwitchButton.X + button_map_swapped[sdl3.SDL_GAMEPAD_BUTTON_NORTH] = SwitchButton.Y swap_abxy_indices = { idx for idx in args.swap_abxy_index if idx is not None and idx >= 0 } for mapping_path in mappings_to_load: try: - loaded = sdl2.SDL_GameControllerAddMappingsFromFile(mapping_path.encode()) + loaded = sdl3.SDL_AddGamepadMappingsFromFile(mapping_path.encode()) console.print( f"[green]Loaded {loaded} SDL mapping(s) from {mapping_path}[/green]" ) @@ -902,70 +918,76 @@ def build_bridge_config(console: Console, args: argparse.Namespace) -> BridgeCon def initialize_sdl(parser: argparse.ArgumentParser) -> None: """Set SDL hints and initialize subsystems needed for controllers.""" - sdl2.SDL_SetHint(sdl2.SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, b"1") + sdl3.SDL_SetHint(sdl3.SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, b"1") set_hint("SDL_JOYSTICK_HIDAPI", "1") set_hint("SDL_JOYSTICK_HIDAPI_SWITCH", "1") - # Use controller button labels so Nintendo layouts (ABXY) map correctly on Linux. - set_hint("SDL_GAMECONTROLLER_USE_BUTTON_LABELS", "1") - if ( - sdl2.SDL_Init( - sdl2.SDL_INIT_GAMECONTROLLER - | sdl2.SDL_INIT_JOYSTICK - | sdl2.SDL_INIT_EVERYTHING - ) - != 0 - ): - parser.error(f"SDL init failed: {sdl2.SDL_GetError().decode(errors='ignore')}") + if not sdl3.SDL_Init(sdl3.SDL_INIT_GAMEPAD | sdl3.SDL_INIT_JOYSTICK): + parser.error(f"SDL init failed: {sdl3.SDL_GetError().decode(errors='ignore')}") def detect_controllers( console: Console, args: argparse.Namespace, parser: argparse.ArgumentParser ) -> Tuple[List[int], Dict[int, str]]: """Detect available controllers and return usable indices and names.""" - controller_indices: List[int] = [] + controller_ids: List[int] = [] controller_names: Dict[int, str] = {} - controller_count = sdl2.SDL_NumJoysticks() - if controller_count < 0: - parser.error(f"SDL error: {sdl2.SDL_GetError().decode()}") + count = ctypes.c_int(0) + joystick_ids = sdl3.SDL_GetJoysticks(ctypes.byref(count)) + if not joystick_ids: + if count.value < 0: + parser.error(f"SDL error: {sdl3.SDL_GetError().decode()}") + return controller_ids, controller_names include_controller_name = [n.lower() for n in args.include_controller_name] - for index in range(controller_count): - if sdl2.SDL_IsGameController(index): - name = sdl2.SDL_GameControllerNameForIndex(index) - name_str = name.decode() if isinstance(name, bytes) else str(name) + display_counter = 0 + for i in range(count.value): + instance_id = joystick_ids[i] + if sdl3.SDL_IsGamepad(instance_id): + name = sdl3.SDL_GetGamepadNameForID(instance_id) + name_str = ( + name.decode() if isinstance(name, bytes) else str(name) if name else "Unknown" + ) if include_controller_name and all( substr not in name_str.lower() for substr in include_controller_name ): console.print( - f"[yellow]Skipping controller {index} ({name_str}) due to name filter[/yellow]" + f"[yellow]Skipping controller ({name_str}) due to name filter[/yellow]" ) continue - console.print(f"[cyan]Detected controller {index}: {name_str}[/cyan]") - controller_indices.append(index) - controller_names[index] = name_str + console.print(f"[cyan]Detected controller {display_counter}: ({name_str})[/cyan]") + display_counter += 1 + controller_ids.append(instance_id) + controller_names[instance_id] = name_str else: - name = sdl2.SDL_JoystickNameForIndex(index) - name_str = name.decode() if isinstance(name, bytes) else str(name) + name = sdl3.SDL_GetJoystickNameForID(instance_id) + name_str = ( + name.decode() if isinstance(name, bytes) else str(name) if name else "Unknown" + ) if include_controller_name and all( substr not in name_str.lower() for substr in include_controller_name ): console.print( - f"[yellow]Skipping joystick {index} ({name_str}) due to name filter[/yellow]" + f"[yellow]Skipping joystick {instance_id} ({name_str}) due to name filter[/yellow]" ) continue console.print( - f"[yellow]Found joystick {index} (not a GameController): {name_str}[/yellow]" + f"[yellow]Found joystick {instance_id} (not a GameController): {name_str}[/yellow]" ) - return controller_indices, controller_names + sdl3.SDL_free(joystick_ids) + return controller_ids, controller_names def list_controllers_with_guids( console: Console, parser: argparse.ArgumentParser ) -> None: """Print detected controllers with their GUID strings and exit.""" - count = sdl2.SDL_NumJoysticks() - if count < 0: - parser.error(f"SDL error: {sdl2.SDL_GetError().decode()}") - if count == 0: + count = ctypes.c_int(0) + joystick_ids = sdl3.SDL_GetJoysticks(ctypes.byref(count)) + if not joystick_ids: + if count.value < 0: + parser.error(f"SDL error: {sdl3.SDL_GetError().decode()}") + console.print("[yellow]No controllers detected.[/yellow]") + return + if count.value == 0: console.print("[yellow]No controllers detected.[/yellow]") return table = Table(title="Detected Controllers (GUIDs)") @@ -973,18 +995,20 @@ def list_controllers_with_guids( table.add_column("Type") table.add_column("Name") table.add_column("GUID") - for idx in range(count): - is_gc = sdl2.SDL_IsGameController(idx) + for i in range(count.value): + instance_id = joystick_ids[i] + is_gc = sdl3.SDL_IsGamepad(instance_id) name = ( - sdl2.SDL_GameControllerNameForIndex(idx) + sdl3.SDL_GetGamepadNameForID(instance_id) if is_gc - else sdl2.SDL_JoystickNameForIndex(idx) + else sdl3.SDL_GetJoystickNameForID(instance_id) ) - name_str = name.decode() if isinstance(name, bytes) else str(name) - guid_str = guid_string_for_device_index(idx) + name_str = name.decode() if isinstance(name, bytes) else str(name) if name else "Unknown" + guid_str = guid_string_for_instance_id(instance_id) table.add_row( - str(idx), "GameController" if is_gc else "Joystick", name_str, guid_str + str(instance_id), "GameController" if is_gc else "Joystick", name_str, guid_str ) + sdl3.SDL_free(joystick_ids) console.print(table) @@ -1074,7 +1098,7 @@ def assign_port_for_index( return port_choice -def ports_in_use(pairing: PairingState, contexts: Dict[int, ControllerContext]) -> set: +def ports_in_use(pairing: PairingState, contexts: Dict[int, ControllerContext]) -> set[str]: """Return a set of UART paths currently reserved or mapped.""" used = set(pairing.mapping_by_index.values()) used.update(ctx.port for ctx in contexts.values() if ctx.port) @@ -1107,7 +1131,7 @@ def handle_removed_port( ctx.uart.close() except Exception: pass - sdl2.SDL_GameControllerRumble(ctx.controller, 0, 0, 0) + sdl3.SDL_RumbleGamepad(ctx.controller, 0, 0, 0) ctx.uart = None ctx.port = None ctx.rumble_active = False @@ -1190,44 +1214,47 @@ def open_initial_contexts( """Open initial controllers and UARTs for detected indices.""" contexts: Dict[int, ControllerContext] = {} uarts: List[PicoUART] = [] - for index in controller_indices: - if index >= sdl2.SDL_NumJoysticks() or not sdl2.SDL_IsGameController(index): - name = sdl2.SDL_JoystickNameForIndex(index) - name_str = name.decode() if isinstance(name, bytes) else str(name) + for instance_id in controller_indices: + if not sdl3.SDL_IsGamepad(instance_id): + name = sdl3.SDL_GetJoystickNameForID(instance_id) + name_str = name.decode() if isinstance(name, bytes) else str(name) if name else "Unknown" console.print( - f"[yellow]Index {index} is not a GameController ({name_str}). Trying raw open failed.[/yellow]" + f"[yellow]ID {instance_id} is not a GameController ({name_str}). Trying raw open failed.[/yellow]" ) continue - port = assign_port_for_index(pairing, index, console) + display_idx = pairing.display_index_alloc.allocate() + port = assign_port_for_index(pairing, display_idx, console) if port is None and not pairing.auto_pairing_enabled: + pairing.display_index_alloc.release(display_idx) continue try: - controller, instance_id, guid = open_controller(index) + controller, opened_instance_id, guid = open_controller(instance_id) except Exception as exc: - console.print(f"[red]Failed to open controller {index}: {exc}[/red]") + console.print(f"[red]Failed to open controller {display_idx}: {exc}[/red]") + pairing.display_index_alloc.release(display_idx) continue stable_id = guid should_swap = ( - index in config.swap_abxy_indices or stable_id in config.swap_abxy_ids + display_idx in config.swap_abxy_indices or stable_id in config.swap_abxy_ids ) uart = open_uart_or_warn(port, args.baud, console) if port else None if uart: uarts.append(uart) console.print( - f"[green]Controller {index} (id {stable_id}, inst {instance_id}) paired to {port}[/green]" + f"[green]Controller {display_idx} (id {stable_id}, inst {opened_instance_id}) paired to {port}[/green]" ) elif port: console.print( - f"[yellow]Controller {index} (id {stable_id}, inst {instance_id}) waiting for UART {port}[/yellow]" + f"[yellow]Controller {display_idx} (id {stable_id}, inst {opened_instance_id}) waiting for UART {port}[/yellow]" ) else: console.print( - f"[yellow]Controller {index} (id {stable_id}, inst {instance_id}) connected; waiting for an available UART[/yellow]" + f"[yellow]Controller {display_idx} (id {stable_id}, inst {opened_instance_id}) connected; waiting for an available UART[/yellow]" ) ctx = ControllerContext( controller=controller, - instance_id=instance_id, - controller_index=index, + instance_id=opened_instance_id, + controller_index=display_idx, stable_id=stable_id, port=port, uart=uart, @@ -1237,28 +1264,28 @@ def open_initial_contexts( initialize_controller_sensors(ctx, console) if config.zero_sticks: zero_context_sticks(ctx, console) - contexts[instance_id] = ctx + contexts[opened_instance_id] = ctx return contexts, uarts def handle_axis_motion( - event: sdl2.SDL_Event, contexts: Dict[int, ControllerContext], config: BridgeConfig + event: sdl3.SDL_Event, contexts: Dict[int, ControllerContext], config: BridgeConfig ) -> None: """Process axis motion event into stick/trigger state.""" - ctx = contexts.get(event.caxis.which) + ctx = contexts.get(event.gaxis.which) if not ctx: return - axis = event.caxis.axis - value = calibrate_axis_value(event.caxis.value, axis, ctx) - if axis == sdl2.SDL_CONTROLLER_AXIS_LEFTX: + axis = event.gaxis.axis + value = calibrate_axis_value(event.gaxis.value, axis, ctx) + if axis == sdl3.SDL_GAMEPAD_AXIS_LEFTX: ctx.report.lx = axis_to_stick(value, config.deadzone_raw) - elif axis == sdl2.SDL_CONTROLLER_AXIS_LEFTY: + elif axis == sdl3.SDL_GAMEPAD_AXIS_LEFTY: ctx.report.ly = axis_to_stick(value, config.deadzone_raw) - elif axis == sdl2.SDL_CONTROLLER_AXIS_RIGHTX: + elif axis == sdl3.SDL_GAMEPAD_AXIS_RIGHTX: ctx.report.rx = axis_to_stick(value, config.deadzone_raw) - elif axis == sdl2.SDL_CONTROLLER_AXIS_RIGHTY: + elif axis == sdl3.SDL_GAMEPAD_AXIS_RIGHTY: ctx.report.ry = axis_to_stick(value, config.deadzone_raw) - elif axis == sdl2.SDL_CONTROLLER_AXIS_TRIGGERLEFT: + elif axis == sdl3.SDL_GAMEPAD_AXIS_LEFT_TRIGGER: pressed = trigger_to_button(value, config.trigger_threshold) if pressed != ctx.last_trigger_state["left"]: if pressed: @@ -1266,7 +1293,7 @@ def handle_axis_motion( else: ctx.report.buttons &= ~SwitchButton.ZL ctx.last_trigger_state["left"] = pressed - elif axis == sdl2.SDL_CONTROLLER_AXIS_TRIGGERRIGHT: + elif axis == sdl3.SDL_GAMEPAD_AXIS_RIGHT_TRIGGER: pressed = trigger_to_button(value, config.trigger_threshold) if pressed != ctx.last_trigger_state["right"]: if pressed: @@ -1277,17 +1304,17 @@ def handle_axis_motion( def handle_sensor_update( - event: sdl2.SDL_Event, + event: sdl3.SDL_Event, contexts: Dict[int, ControllerContext], config: BridgeConfig, ) -> None: """Process SDL sensor update event into IMU samples.""" - ctx = contexts.get(event.csensor.which) + ctx = contexts.get(event.gsensor.which) if ctx is None or not ctx.sensors_enabled or config.no_imu: return - sensor_type = event.csensor.sensor - data = event.csensor.data + sensor_type = event.gsensor.sensor + data = event.gsensor.data if sensor_type == SENSOR_ACCEL: ctx.last_accel = (float(data[0]), float(data[1]), float(data[2])) @@ -1359,12 +1386,12 @@ def handle_sensor_update( def handle_button_event( - event: sdl2.SDL_Event, + event: sdl3.SDL_Event, config: BridgeConfig, contexts: Dict[int, ControllerContext], ) -> None: """Process button events into report/dpad state.""" - ctx = contexts.get(event.cbutton.which) + ctx = contexts.get(event.gbutton.which) if not ctx: return current_button_map = ( @@ -1372,8 +1399,8 @@ def handle_button_event( if (config.swap_abxy_global or ctx.swap_abxy) else config.button_map_default ) - button = event.cbutton.button - pressed = event.type == sdl2.SDL_CONTROLLERBUTTONDOWN + button = event.gbutton.button + pressed = event.type == sdl3.SDL_EVENT_GAMEPAD_BUTTON_DOWN if button in current_button_map: bit = current_button_map[button] if pressed: @@ -1387,7 +1414,7 @@ def handle_button_event( def handle_device_added( - event: sdl2.SDL_Event, + event: sdl3.SDL_Event, args: argparse.Namespace, pairing: PairingState, contexts: Dict[int, ControllerContext], @@ -1396,46 +1423,47 @@ def handle_device_added( config: BridgeConfig, ) -> None: """Handle controller hotplug by opening and pairing UART if possible.""" - idx = event.cdevice.which - # If we already have a context for this logical index, ignore the duplicate event. - if any(c.controller_index == idx for c in contexts.values()): + sdl_id = event.gdevice.which + if sdl_id in contexts: return - port = assign_port_for_index(pairing, idx, console) - if port is None and not pairing.auto_pairing_enabled: - return - if idx >= sdl2.SDL_NumJoysticks() or not sdl2.SDL_IsGameController(idx): - name = sdl2.SDL_JoystickNameForIndex(idx) - name_str = name.decode() if isinstance(name, bytes) else str(name) + if not sdl3.SDL_IsGamepad(sdl_id): + name = sdl3.SDL_GetJoystickNameForID(sdl_id) + name_str = name.decode() if isinstance(name, bytes) else str(name) if name else "Unknown" console.print( - f"[yellow]Index {idx} is not a GameController ({name_str}). Trying raw open failed.[/yellow]" + f"[yellow]Device {sdl_id} is not a GameController ({name_str}).[/yellow]" ) return + display_idx = pairing.display_index_alloc.allocate() + port = assign_port_for_index(pairing, display_idx, console) + if port is None and not pairing.auto_pairing_enabled: + pairing.display_index_alloc.release(display_idx) + return try: - controller, instance_id, guid = open_controller(idx) + controller, instance_id, guid = open_controller(sdl_id) except Exception as exc: - console.print(f"[red]Hotplug open failed for controller {idx}: {exc}[/red]") + console.print(f"[red]Hotplug open failed for controller {display_idx}: {exc}[/red]") + pairing.display_index_alloc.release(display_idx) return stable_id = guid - # Determine if this controller should have swapped ABXY based on index or GUID - should_swap = idx in config.swap_abxy_indices or stable_id in config.swap_abxy_ids + should_swap = display_idx in config.swap_abxy_indices or stable_id in config.swap_abxy_ids uart = open_uart_or_warn(port, args.baud, console) if port else None if uart: uarts.append(uart) console.print( - f"[green]Controller {idx} (id {stable_id}, inst {instance_id}) paired to {port}[/green]" + f"[green]Controller {display_idx} (id {stable_id}, inst {instance_id}) paired to {port}[/green]" ) elif port: console.print( - f"[yellow]Controller {idx} (id {stable_id}, inst {instance_id}) waiting for UART {port}[/yellow]" + f"[yellow]Controller {display_idx} (id {stable_id}, inst {instance_id}) waiting for UART {port}[/yellow]" ) else: console.print( - f"[yellow]Controller {idx} (id {stable_id}, inst {instance_id}) connected; waiting for an available UART[/yellow]" + f"[yellow]Controller {display_idx} (id {stable_id}, inst {instance_id}) connected; waiting for an available UART[/yellow]" ) ctx = ControllerContext( controller=controller, instance_id=instance_id, - controller_index=idx, + controller_index=display_idx, stable_id=stable_id, port=port, uart=uart, @@ -1449,19 +1477,31 @@ def handle_device_added( def handle_device_removed( - event: sdl2.SDL_Event, + event: sdl3.SDL_Event, pairing: PairingState, contexts: Dict[int, ControllerContext], + uarts: List[PicoUART], console: Console, ) -> None: """Handle controller removal and release any auto-assigned UART.""" - instance_id = event.cdevice.which + instance_id = event.gdevice.which ctx = contexts.pop(instance_id, None) if not ctx: return console.print( - f"[yellow]Controller {instance_id} (id {ctx.stable_id}) removed[/yellow]" + f"[yellow]Controller {ctx.controller_index} (id {ctx.stable_id}) removed[/yellow]" ) + # Close the UART handle *before* returning the port to the pool so the + # next consumer can actually open it (Windows holds the port exclusively). + if ctx.uart: + try: + ctx.uart.close() + except Exception: + pass + if ctx.uart in uarts: + uarts.remove(ctx.uart) + ctx.uart = None + sdl3.SDL_RumbleGamepad(ctx.controller, 0, 0, 0) if ctx.controller_index in pairing.auto_assigned_indices: # Return auto-paired UART back to the pool so a future device can use it. freed = pairing.mapping_by_index.pop(ctx.controller_index, None) @@ -1469,7 +1509,8 @@ def handle_device_removed( if freed and freed not in pairing.available_ports: pairing.available_ports.append(freed) console.print(f"[cyan]Released UART {freed} back to pool[/cyan]") - sdl2.SDL_GameControllerClose(ctx.controller) + pairing.display_index_alloc.release(ctx.controller_index) + sdl3.SDL_CloseGamepad(ctx.controller) def service_contexts( @@ -1530,14 +1571,14 @@ def service_contexts( ctx.last_rumble_energy = energy ctx.last_rumble = now elif ctx.rumble_active and (now - ctx.last_rumble) > RUMBLE_IDLE_TIMEOUT: - sdl2.SDL_GameControllerRumble(ctx.controller, 0, 0, 0) + sdl3.SDL_RumbleGamepad(ctx.controller, 0, 0, 0) ctx.rumble_active = False ctx.last_rumble_energy = 0.0 elif ( ctx.rumble_active and (now - ctx.last_rumble_change) > RUMBLE_STUCK_TIMEOUT ): - sdl2.SDL_GameControllerRumble(ctx.controller, 0, 0, 0) + sdl3.SDL_RumbleGamepad(ctx.controller, 0, 0, 0) ctx.rumble_active = False ctx.last_rumble_energy = 0.0 except SerialException as exc: @@ -1546,7 +1587,7 @@ def service_contexts( ctx.uart.close() except Exception: pass - sdl2.SDL_GameControllerRumble(ctx.controller, 0, 0, 0) + sdl3.SDL_RumbleGamepad(ctx.controller, 0, 0, 0) ctx.uart = None ctx.rumble_active = False ctx.last_rumble_energy = 0.0 @@ -1565,31 +1606,31 @@ def run_bridge_loop( hotkey: Optional[HotkeyMonitor] = None, ) -> None: """Main event loop for bridging controllers to UART and handling rumble.""" - event = sdl2.SDL_Event() + event = sdl3.SDL_Event() port_scan_interval = 2.0 last_port_scan = time.monotonic() running = True while running: - while sdl2.SDL_PollEvent(event): - if event.type == sdl2.SDL_QUIT: + while sdl3.SDL_PollEvent(ctypes.byref(event)): + if event.type == sdl3.SDL_EVENT_QUIT: running = False break - if event.type == sdl2.SDL_CONTROLLERAXISMOTION: + if event.type == sdl3.SDL_EVENT_GAMEPAD_AXIS_MOTION: handle_axis_motion(event, contexts, config) elif event.type in ( - sdl2.SDL_CONTROLLERBUTTONDOWN, - sdl2.SDL_CONTROLLERBUTTONUP, + sdl3.SDL_EVENT_GAMEPAD_BUTTON_DOWN, + sdl3.SDL_EVENT_GAMEPAD_BUTTON_UP, ): handle_button_event(event, config, contexts) - elif event.type == SDL_CONTROLLERSENSORUPDATE: + elif event.type == SDL_EVENT_GAMEPAD_SENSOR_UPDATE: handle_sensor_update(event, contexts, config) - elif event.type == sdl2.SDL_CONTROLLERDEVICEADDED: + elif event.type == sdl3.SDL_EVENT_GAMEPAD_ADDED: handle_device_added( event, args, pairing, contexts, uarts, console, config ) - elif event.type == sdl2.SDL_CONTROLLERDEVICEREMOVED: - handle_device_removed(event, pairing, contexts, console) + elif event.type == sdl3.SDL_EVENT_GAMEPAD_REMOVED: + handle_device_removed(event, pairing, contexts, uarts, console) now = time.monotonic() if now - last_port_scan > port_scan_interval: @@ -1606,16 +1647,16 @@ def run_bridge_loop( zero_all_context_sticks(contexts, console) elif key == config.swap_hotkey: prompt_swap_abxy_controller(contexts, config, console, hotkey) - sdl2.SDL_Delay(1) + sdl3.SDL_Delay(1) def cleanup(contexts: Dict[int, ControllerContext], uarts: List[PicoUART]) -> None: """Gracefully close controllers, UARTs, and SDL subsystems.""" for ctx in contexts.values(): - sdl2.SDL_GameControllerClose(ctx.controller) + sdl3.SDL_CloseGamepad(ctx.controller) for uart in uarts: uart.close() - sdl2.SDL_Quit() + sdl3.SDL_Quit() def main() -> None: diff --git a/src/switch_pico_bridge/switch_pico_uart.py b/src/switch_pico_bridge/switch_pico_uart.py index 2fdab53..81b2f54 100644 --- a/src/switch_pico_bridge/switch_pico_uart.py +++ b/src/switch_pico_bridge/switch_pico_uart.py @@ -36,10 +36,10 @@ ACCEL_LSB_PER_G = 4096.0 GYRO_LSB_PER_RAD_S = 818.5 try: - import sdl2 as _sdl2 # type: ignore[import-not-found] + import sdl3 as _sdl3 # type: ignore[import-not-found] - _sensor_accel = getattr(_sdl2, "SDL_SENSOR_ACCEL", 1) - _sensor_gyro = getattr(_sdl2, "SDL_SENSOR_GYRO", 2) + _sensor_accel = getattr(_sdl3, "SDL_SENSOR_ACCEL", 1) + _sensor_gyro = getattr(_sdl3, "SDL_SENSOR_GYRO", 2) except ImportError: _sensor_accel = 1 _sensor_gyro = 2