Add GUID based selection

This commit is contained in:
Joey Yakimowich-Payne 2025-11-23 10:08:59 -07:00
commit 2835249614
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
2 changed files with 75 additions and 30 deletions

View file

@ -52,11 +52,14 @@ Options:
- `--all-ports` to include non-USB serial devices in discovery. - `--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). - `--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). - `--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 cant do 900K). - `--baud 921600` (default 921600; use `500000` if your adapter cant do 900K).
- `--frequency 1000` to send at 1 kHz. - `--frequency 1000` to send at 1 kHz.
- `--deadzone 0.08` to change stick deadzone (0.0-1.0). - `--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). - `--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`). - `--sdl-mapping path/to/gamecontrollerdb.txt` to load extra SDL mappings (defaults to `controller_db/gamecontrollerdb.txt`).
Hotplugging: controllers and UARTs can be plugged/unplugged while running; the bridge will auto reconnect when possible. Hotplugging: controllers and UARTs can be plugged/unplugged while running; the bridge will auto reconnect when possible.

View file

@ -370,7 +370,7 @@ class ControllerContext:
controller: sdl2.SDL_GameController controller: sdl2.SDL_GameController
instance_id: int instance_id: int
controller_index: int controller_index: int
stable_id: int stable_id: str
port: Optional[str] port: Optional[str]
uart: Optional[PicoUART] uart: Optional[PicoUART]
report: SwitchReport = field(default_factory=SwitchReport) 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()}") raise RuntimeError(f"Failed to open controller {index}: {sdl2.SDL_GetError().decode()}")
joystick = sdl2.SDL_GameControllerGetJoystick(controller) joystick = sdl2.SDL_GameControllerGetJoystick(controller)
instance_id = sdl2.SDL_JoystickInstanceID(joystick) instance_id = sdl2.SDL_JoystickInstanceID(joystick)
guid = sdl2.SDL_JoystickGetGUID(joystick) guid_str = guid_string_from_joystick(joystick)
buf = create_string_buffer(33)
sdl2.SDL_JoystickGetGUIDString(guid, buf, 33)
guid_str = buf.value.decode() if buf.value else ""
return controller, instance_id, guid_str return controller, instance_id, guid_str
@ -407,6 +404,22 @@ def try_open_uart(port: str, baud: int) -> Optional[PicoUART]:
return None 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]: def open_uart_or_warn(port: str, baud: int, console: Console) -> Optional[PicoUART]:
"""Open a UART and warn on failure.""" """Open a UART and warn on failure."""
try: try:
@ -470,6 +483,11 @@ def build_arg_parser() -> argparse.ArgumentParser:
default=[], default=[],
help="Only open controllers whose name contains this substring (case-insensitive). Repeatable.", 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( parser.add_argument(
"--swap-abxy", "--swap-abxy",
action="store_true", action="store_true",
@ -482,6 +500,12 @@ def build_arg_parser() -> argparse.ArgumentParser:
default=[], default=[],
help="Swap AB/XY mapping for specific controller indices (repeatable).", 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( parser.add_argument(
"--sdl-mapping", "--sdl-mapping",
action="append", action="append",
@ -526,6 +550,7 @@ class BridgeConfig:
button_map_default: Dict[int, int] button_map_default: Dict[int, int]
button_map_swapped: Dict[int, int] button_map_swapped: Dict[int, int]
swap_abxy_indices: set[int] swap_abxy_indices: set[int]
swap_abxy_ids: set[str]
@dataclass @dataclass
@ -540,19 +565,6 @@ class PairingState:
include_port_desc: List[str] = field(default_factory=list) 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]]: 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.""" """Load SDL controller mappings and return button map variants."""
default_mapping = Path(__file__).parent / "controller_db" / "gamecontrollerdb.txt" 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) 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) 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) 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( return BridgeConfig(
interval=interval, interval=interval,
deadzone_raw=deadzone_raw, 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_default=button_map_default,
button_map_swapped=button_map_swapped, button_map_swapped=button_map_swapped,
swap_abxy_indices=swap_abxy_indices, 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 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( def prepare_pairing_state(
args: argparse.Namespace, args: argparse.Namespace,
console: Console, console: Console,
@ -804,7 +840,7 @@ def open_initial_contexts(
pairing: PairingState, pairing: PairingState,
controller_indices: List[int], controller_indices: List[int],
console: Console, console: Console,
id_registry: ControllerIdRegistry, config: BridgeConfig,
) -> Tuple[Dict[int, ControllerContext], List[PicoUART]]: ) -> Tuple[Dict[int, ControllerContext], List[PicoUART]]:
"""Open initial controllers and UARTs for detected indices.""" """Open initial controllers and UARTs for detected indices."""
contexts: Dict[int, ControllerContext] = {} contexts: Dict[int, ControllerContext] = {}
@ -823,7 +859,9 @@ def open_initial_contexts(
except Exception as exc: except Exception as exc:
console.print(f"[red]Failed to open controller {index}: {exc}[/red]") console.print(f"[red]Failed to open controller {index}: {exc}[/red]")
continue 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 uart = open_uart_or_warn(port, args.baud, console) if port else None
if uart: if uart:
uarts.append(uart) uarts.append(uart)
@ -891,7 +929,7 @@ def handle_button_event(
return return
current_button_map = ( current_button_map = (
config.button_map_swapped 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 else config.button_map_default
) )
button = event.cbutton.button button = event.cbutton.button
@ -915,7 +953,7 @@ def handle_device_added(
contexts: Dict[int, ControllerContext], contexts: Dict[int, ControllerContext],
uarts: List[PicoUART], uarts: List[PicoUART],
console: Console, console: Console,
id_registry: ControllerIdRegistry, config: BridgeConfig,
) -> None: ) -> None:
"""Handle controller hotplug by opening and pairing UART if possible.""" """Handle controller hotplug by opening and pairing UART if possible."""
idx = event.cdevice.which idx = event.cdevice.which
@ -935,7 +973,10 @@ def handle_device_added(
except Exception as exc: 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 {idx}: {exc}[/red]")
return 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 uart = open_uart_or_warn(port, args.baud, console) if port else None
if uart: if uart:
uarts.append(uart) uarts.append(uart)
@ -991,7 +1032,7 @@ def service_contexts(
for ctx in list(contexts.values()): for ctx in list(contexts.values()):
current_button_map = ( current_button_map = (
config.button_map_swapped 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 else config.button_map_default
) )
poll_controller_buttons(ctx, current_button_map) poll_controller_buttons(ctx, current_button_map)
@ -1055,7 +1096,6 @@ def run_bridge_loop(
pairing: PairingState, pairing: PairingState,
contexts: Dict[int, ControllerContext], contexts: Dict[int, ControllerContext],
uarts: List[PicoUART], uarts: List[PicoUART],
id_registry: ControllerIdRegistry,
) -> None: ) -> None:
"""Main event loop for bridging controllers to UART and handling rumble.""" """Main event loop for bridging controllers to UART and handling rumble."""
event = sdl2.SDL_Event() event = sdl2.SDL_Event()
@ -1073,7 +1113,7 @@ def run_bridge_loop(
elif event.type in (sdl2.SDL_CONTROLLERBUTTONDOWN, sdl2.SDL_CONTROLLERBUTTONUP): elif event.type in (sdl2.SDL_CONTROLLERBUTTONDOWN, sdl2.SDL_CONTROLLERBUTTONUP):
handle_button_event(event, args, config, contexts) handle_button_event(event, args, config, contexts)
elif event.type == sdl2.SDL_CONTROLLERDEVICEADDED: 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: elif event.type == sdl2.SDL_CONTROLLERDEVICEREMOVED:
handle_device_removed(event, pairing, contexts, console) handle_device_removed(event, pairing, contexts, console)
@ -1105,16 +1145,18 @@ def main() -> None:
console = Console() console = Console()
config = build_bridge_config(console, args) config = build_bridge_config(console, args)
initialize_sdl(parser) initialize_sdl(parser)
id_registry = ControllerIdRegistry()
contexts: Dict[int, ControllerContext] = {} contexts: Dict[int, ControllerContext] = {}
uarts: List[PicoUART] = [] uarts: List[PicoUART] = []
try: try:
if args.list_controllers:
list_controllers_with_guids(console, parser)
return
controller_indices, controller_names = detect_controllers(console, args, parser) controller_indices, controller_names = detect_controllers(console, args, parser)
pairing = prepare_pairing_state(args, console, parser, controller_indices, controller_names) 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: if not contexts:
console.print("[yellow]No controllers opened; waiting for hotplug events...[/yellow]") 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: finally:
cleanup(contexts, uarts) cleanup(contexts, uarts)