diff --git a/README.md b/README.md index c4fd775..a61aa02 100644 --- a/README.md +++ b/README.md @@ -52,11 +52,14 @@ Options: - `--all-ports` to include non-USB serial devices in discovery. - `--ignore-port-desc SUBSTR` / `--include-port-desc SUBSTR` to filter serial ports by description (repeatable). - `--include-controller-name SUBSTR` to only open controllers whose name matches (repeatable). +- `--list-controllers` to print detected controllers and their GUIDs, then exit (useful for GUID-based options). - `--baud 921600` (default 921600; use `500000` if your adapter can’t do 900K). - `--frequency 1000` to send at 1 kHz. - `--deadzone 0.08` to change stick deadzone (0.0-1.0). - `--trigger-threshold 0.35` to change analog trigger press threshold (0.0-1.0). -- `--swap-abxy` or `--swap-abxy-index N` to flip AB/XY globally or for specific controller indices (repeatable). +- `--swap-abxy` to flip AB/XY globally. +- `--swap-abxy-index N` (repeatable) to flip AB/XY for controllers first seen at index N (auto-converts to a stable GUID). +- `--swap-abxy-guid GUID` (repeatable) to flip AB/XY for a specific physical controller (GUID is stable across runs). - `--sdl-mapping path/to/gamecontrollerdb.txt` to load extra SDL mappings (defaults to `controller_db/gamecontrollerdb.txt`). Hot‑plugging: controllers and UARTs can be plugged/unplugged while running; the bridge will auto reconnect when possible. diff --git a/controller_uart_bridge.py b/controller_uart_bridge.py index 1a7f9b7..453e9cb 100644 --- a/controller_uart_bridge.py +++ b/controller_uart_bridge.py @@ -370,7 +370,7 @@ class ControllerContext: controller: sdl2.SDL_GameController instance_id: int controller_index: int - stable_id: int + stable_id: str port: Optional[str] uart: Optional[PicoUART] report: SwitchReport = field(default_factory=SwitchReport) @@ -392,10 +392,7 @@ def open_controller(index: int) -> Tuple[sdl2.SDL_GameController, int, str]: raise RuntimeError(f"Failed to open controller {index}: {sdl2.SDL_GetError().decode()}") joystick = sdl2.SDL_GameControllerGetJoystick(controller) instance_id = sdl2.SDL_JoystickInstanceID(joystick) - guid = sdl2.SDL_JoystickGetGUID(joystick) - buf = create_string_buffer(33) - sdl2.SDL_JoystickGetGUIDString(guid, buf, 33) - guid_str = buf.value.decode() if buf.value else "" + guid_str = guid_string_from_joystick(joystick) return controller, instance_id, guid_str @@ -407,6 +404,22 @@ def try_open_uart(port: str, baud: int) -> Optional[PicoUART]: return None +def guid_string_from_joystick(joystick: sdl2.SDL_Joystick) -> str: + """Return a GUID string for an already-open joystick.""" + guid = sdl2.SDL_JoystickGetGUID(joystick) + buf = create_string_buffer(33) + sdl2.SDL_JoystickGetGUIDString(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) + buf = create_string_buffer(33) + sdl2.SDL_JoystickGetGUIDString(guid, buf, 33) + return buf.value.decode().lower() if buf.value else "" + + def open_uart_or_warn(port: str, baud: int, console: Console) -> Optional[PicoUART]: """Open a UART and warn on failure.""" try: @@ -470,6 +483,11 @@ def build_arg_parser() -> argparse.ArgumentParser: default=[], help="Only open controllers whose name contains this substring (case-insensitive). Repeatable.", ) + parser.add_argument( + "--list-controllers", + action="store_true", + help="List detected controllers with GUIDs and exit.", + ) parser.add_argument( "--swap-abxy", action="store_true", @@ -482,6 +500,12 @@ def build_arg_parser() -> argparse.ArgumentParser: default=[], help="Swap AB/XY mapping for specific controller indices (repeatable).", ) + parser.add_argument( + "--swap-abxy-guid", + action="append", + default=[], + help="Swap AB/XY mapping for specific controller GUIDs (see --list-controllers). Repeatable.", + ) parser.add_argument( "--sdl-mapping", action="append", @@ -526,6 +550,7 @@ class BridgeConfig: button_map_default: Dict[int, int] button_map_swapped: Dict[int, int] swap_abxy_indices: set[int] + swap_abxy_ids: set[str] @dataclass @@ -540,19 +565,6 @@ class PairingState: include_port_desc: List[str] = field(default_factory=list) -@dataclass -class ControllerIdRegistry: - """Assign stable IDs to controllers based on their GUID.""" - guid_to_id: Dict[str, int] = field(default_factory=dict) - next_id: int = 0 - - def stable_id_for_guid(self, guid: str) -> int: - if guid not in self.guid_to_id: - self.guid_to_id[guid] = self.next_id - self.next_id += 1 - return self.guid_to_id[guid] - - def load_button_maps(console: Console, args: argparse.Namespace) -> Tuple[Dict[int, int], Dict[int, int], set[int]]: """Load SDL controller mappings and return button map variants.""" default_mapping = Path(__file__).parent / "controller_db" / "gamecontrollerdb.txt" @@ -582,6 +594,7 @@ def build_bridge_config(console: Console, args: argparse.Namespace) -> BridgeCon deadzone_raw = int(max(0.0, min(args.deadzone, 1.0)) * 32767) trigger_threshold = int(max(0.0, min(args.trigger_threshold, 1.0)) * 32767) button_map_default, button_map_swapped, swap_abxy_indices = load_button_maps(console, args) + swap_abxy_guids = {g.lower() for g in args.swap_abxy_guid} return BridgeConfig( interval=interval, deadzone_raw=deadzone_raw, @@ -589,6 +602,7 @@ def build_bridge_config(console: Console, args: argparse.Namespace) -> BridgeCon button_map_default=button_map_default, button_map_swapped=button_map_swapped, swap_abxy_indices=swap_abxy_indices, + swap_abxy_ids=set(swap_abxy_guids), # filled later once stable IDs are known ) @@ -633,6 +647,28 @@ def detect_controllers( return controller_indices, 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: + console.print("[yellow]No controllers detected.[/yellow]") + return + table = Table(title="Detected Controllers (GUIDs)") + table.add_column("Index", justify="center") + table.add_column("Type") + table.add_column("Name") + table.add_column("GUID") + for idx in range(count): + is_gc = sdl2.SDL_IsGameController(idx) + name = sdl2.SDL_GameControllerNameForIndex(idx) if is_gc else sdl2.SDL_JoystickNameForIndex(idx) + name_str = name.decode() if isinstance(name, bytes) else str(name) + guid_str = guid_string_for_device_index(idx) + table.add_row(str(idx), "GameController" if is_gc else "Joystick", name_str, guid_str) + console.print(table) + + def prepare_pairing_state( args: argparse.Namespace, console: Console, @@ -804,7 +840,7 @@ def open_initial_contexts( pairing: PairingState, controller_indices: List[int], console: Console, - id_registry: ControllerIdRegistry, + config: BridgeConfig, ) -> Tuple[Dict[int, ControllerContext], List[PicoUART]]: """Open initial controllers and UARTs for detected indices.""" contexts: Dict[int, ControllerContext] = {} @@ -823,7 +859,9 @@ def open_initial_contexts( except Exception as exc: console.print(f"[red]Failed to open controller {index}: {exc}[/red]") continue - stable_id = id_registry.stable_id_for_guid(guid) + stable_id = guid + if index in config.swap_abxy_indices: + config.swap_abxy_ids.add(stable_id) uart = open_uart_or_warn(port, args.baud, console) if port else None if uart: uarts.append(uart) @@ -891,7 +929,7 @@ def handle_button_event( return current_button_map = ( config.button_map_swapped - if (args.swap_abxy or ctx.controller_index in config.swap_abxy_indices) + if (args.swap_abxy or ctx.stable_id in config.swap_abxy_ids) else config.button_map_default ) button = event.cbutton.button @@ -915,7 +953,7 @@ def handle_device_added( contexts: Dict[int, ControllerContext], uarts: List[PicoUART], console: Console, - id_registry: ControllerIdRegistry, + config: BridgeConfig, ) -> None: """Handle controller hotplug by opening and pairing UART if possible.""" idx = event.cdevice.which @@ -935,7 +973,10 @@ def handle_device_added( except Exception as exc: console.print(f"[red]Hotplug open failed for controller {idx}: {exc}[/red]") return - stable_id = id_registry.stable_id_for_guid(guid) + stable_id = guid + # Promote any index-based swap flags to stable IDs on first sight. + if idx in config.swap_abxy_indices: + config.swap_abxy_ids.add(stable_id) uart = open_uart_or_warn(port, args.baud, console) if port else None if uart: uarts.append(uart) @@ -991,7 +1032,7 @@ def service_contexts( for ctx in list(contexts.values()): current_button_map = ( config.button_map_swapped - if (args.swap_abxy or ctx.controller_index in config.swap_abxy_indices) + if (args.swap_abxy or ctx.stable_id in config.swap_abxy_ids) else config.button_map_default ) poll_controller_buttons(ctx, current_button_map) @@ -1055,7 +1096,6 @@ def run_bridge_loop( pairing: PairingState, contexts: Dict[int, ControllerContext], uarts: List[PicoUART], - id_registry: ControllerIdRegistry, ) -> None: """Main event loop for bridging controllers to UART and handling rumble.""" event = sdl2.SDL_Event() @@ -1073,7 +1113,7 @@ def run_bridge_loop( elif event.type in (sdl2.SDL_CONTROLLERBUTTONDOWN, sdl2.SDL_CONTROLLERBUTTONUP): handle_button_event(event, args, config, contexts) elif event.type == sdl2.SDL_CONTROLLERDEVICEADDED: - handle_device_added(event, args, pairing, contexts, uarts, console, id_registry) + handle_device_added(event, args, pairing, contexts, uarts, console, config) elif event.type == sdl2.SDL_CONTROLLERDEVICEREMOVED: handle_device_removed(event, pairing, contexts, console) @@ -1105,16 +1145,18 @@ def main() -> None: console = Console() config = build_bridge_config(console, args) initialize_sdl(parser) - id_registry = ControllerIdRegistry() contexts: Dict[int, ControllerContext] = {} uarts: List[PicoUART] = [] try: + if args.list_controllers: + list_controllers_with_guids(console, parser) + return controller_indices, controller_names = detect_controllers(console, args, parser) pairing = prepare_pairing_state(args, console, parser, controller_indices, controller_names) - contexts, uarts = open_initial_contexts(args, pairing, controller_indices, console, id_registry) + contexts, uarts = open_initial_contexts(args, pairing, controller_indices, console, config) if not contexts: console.print("[yellow]No controllers opened; waiting for hotplug events...[/yellow]") - run_bridge_loop(args, console, config, pairing, contexts, uarts, id_registry) + run_bridge_loop(args, console, config, pairing, contexts, uarts) finally: cleanup(contexts, uarts)