Add swapping hotkey

This commit is contained in:
jojomawswan 2025-11-29 21:03:34 -07:00
commit c0c97aa70f
2 changed files with 179 additions and 34 deletions

View file

@ -64,6 +64,7 @@ Options:
- `--swap-abxy` to flip AB/XY globally. - `--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-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). - `--swap-abxy-guid GUID` (repeatable) to flip AB/XY for a specific physical controller (GUID is stable across runs).
- `--swap-hotkey x` to pick the runtime hotkey that prompts you to toggle ABXY layout for a specific connected controller (default `x`; empty string disables).
- `--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`).
Hot‑plugging: controllers and UARTs can be plugged/unplugged while running; the bridge will auto reconnect when possible. Hot‑plugging: controllers and UARTs can be plugged/unplugged while running; the bridge will auto reconnect when possible.

View file

@ -275,65 +275,108 @@ def calibrate_axis_value(value: int, axis: int, ctx: ControllerContext) -> int:
return max(-32768, min(32767, value - offset)) return max(-32768, min(32767, value - offset))
class ZeroHotkeyMonitor: class HotkeyMonitor:
"""Platform-aware helper that watches for a single keypress without blocking the main loop.""" """Platform-aware helper that watches for configured hotkeys without blocking the main loop."""
def __init__(self, key: str, console: Console) -> None: def __init__(self, console: Console, key_messages: Optional[Dict[str, str]] = None) -> None:
self.key = (key or "").lower()
self.console = console self.console = console
self._active = False
self._platform = os.name self._platform = os.name
self._fd: Optional[int] = None self._fd: Optional[int] = None
self._old_termios = None self._orig_termios = None
self._msvcrt = None self._msvcrt = None
self._active = False
self._started = False
self._keys: Dict[str, str] = {}
if key_messages:
for key, message in key_messages.items():
self.register_key(key, message)
def register_key(self, key: str, message: str) -> None:
key = (key or "").lower()
if not key:
return
self._keys[key] = message
def has_keys(self) -> bool:
return bool(self._keys)
def start(self) -> bool: def start(self) -> bool:
if not self.key: if not self._keys or self._started:
return False return False
if self._platform == "nt": if self._platform == "nt":
try: try:
import msvcrt # type: ignore import msvcrt # type: ignore
except ImportError: except ImportError:
self.console.print("[yellow]Zero hotkey disabled: msvcrt unavailable.[/yellow]") self.console.print("[yellow]Hotkeys disabled: msvcrt unavailable.[/yellow]")
return False return False
self._msvcrt = msvcrt self._msvcrt = msvcrt
self._active = True self._active = True
self.console.print( self._started = True
f"[magenta]Press '{self.key.upper()}' in this terminal to re-zero controller sticks.[/magenta]" self._print_instructions()
)
return True return True
if not sys.stdin.isatty(): if not sys.stdin.isatty():
self.console.print("[yellow]Zero hotkey disabled: stdin is not a TTY.[/yellow]") self.console.print("[yellow]Hotkeys disabled: stdin is not a TTY.[/yellow]")
return False return False
import termios import termios
import tty import tty
self._fd = sys.stdin.fileno() self._fd = sys.stdin.fileno()
self._old_termios = termios.tcgetattr(self._fd) self._orig_termios = termios.tcgetattr(self._fd)
tty.setcbreak(self._fd) tty.setcbreak(self._fd)
self._active = True self._active = True
self.console.print( self._started = True
f"[magenta]Press '{self.key.upper()}' in this terminal to re-zero controller sticks.[/magenta]" self._print_instructions()
)
return True return True
def stop(self) -> None: def suspend(self) -> None:
if not self._active: if not self._active:
return return
if self._platform != "nt" and self._fd is not None and self._old_termios is not None: if self._platform != "nt" and self._fd is not None and self._orig_termios is not None:
import termios import termios
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old_termios) termios.tcsetattr(self._fd, termios.TCSADRAIN, self._orig_termios)
self._active = False self._active = False
def poll_trigger(self) -> bool: def resume(self) -> None:
if not self._started or self._active:
return
if self._platform == "nt":
self._active = True
return
if self._fd is None:
return
import tty
tty.setcbreak(self._fd)
self._active = True
def stop(self) -> None:
if self._platform != "nt" and self._fd is not None and self._orig_termios is not None:
import termios
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._orig_termios)
self._active = False
self._started = False
def poll_keys(self) -> List[str]:
if not self._active: if not self._active:
return False return []
key = self._read_key() pressed: List[str] = []
if key and key.lower() == self.key: while True:
return True key = self._read_key()
return False if not key:
break
lowered = key.lower()
if lowered in self._keys:
pressed.append(lowered)
return pressed
def _print_instructions(self) -> None:
if not self._keys:
return
instructions = " | ".join(f"'{key.upper()}' to {message}" for key, message in self._keys.items())
self.console.print(f"[magenta]Hotkeys active: {instructions}[/magenta]")
def _read_key(self) -> Optional[str]: def _read_key(self) -> Optional[str]:
if self._platform == "nt": if self._platform == "nt":
@ -373,6 +416,83 @@ def zero_all_context_sticks(contexts: Dict[int, ControllerContext], console: Con
zero_context_sticks(ctx, console, reason="Re-zeroed stick centers") zero_context_sticks(ctx, console, reason="Re-zeroed stick centers")
def controller_display_name(ctx: ControllerContext) -> str:
"""Return a human-readable controller name."""
name = sdl2.SDL_GameControllerName(ctx.controller)
if not name:
return "Unknown"
if isinstance(name, bytes):
return name.decode(errors="ignore")
return str(name)
def toggle_abxy_for_context(ctx: ControllerContext, config: BridgeConfig, console: Console) -> None:
"""Toggle the ABXY layout for a single controller."""
if config.swap_abxy_global:
console.print("[yellow]Global --swap-abxy is enabled; disable it to use per-controller toggles.[/yellow]")
return
swapped = ctx.stable_id in config.swap_abxy_ids
action = "standard" if swapped else "swapped"
if swapped:
config.swap_abxy_ids.discard(ctx.stable_id)
else:
config.swap_abxy_ids.add(ctx.stable_id)
console.print(
f"[cyan]Controller {ctx.controller_index} ({controller_display_name(ctx)}, inst {ctx.instance_id}) now using {action} ABXY layout.[/cyan]"
)
def prompt_swap_abxy_controller(
contexts: Dict[int, ControllerContext],
config: BridgeConfig,
console: Console,
hotkey: Optional[HotkeyMonitor] = None,
) -> None:
"""Prompt the user to choose a controller whose ABXY layout should be toggled."""
if not contexts:
console.print("[yellow]No controllers connected to toggle ABXY layout.[/yellow]")
return
controllers = sorted(contexts.values(), key=lambda ctx: (ctx.controller_index, ctx.instance_id))
table = Table(title="Toggle ABXY layout for a controller")
table.add_column("Choice", justify="center")
table.add_column("SDL Index", justify="center")
table.add_column("Instance", justify="center")
table.add_column("Name")
table.add_column("GUID")
table.add_column("Layout", justify="center")
for idx, ctx in enumerate(controllers):
swapped = config.swap_abxy_global or (ctx.stable_id in config.swap_abxy_ids)
state = "Swapped" if swapped else "Standard"
if config.swap_abxy_global:
state += " (global)"
table.add_row(
str(idx),
str(ctx.controller_index),
str(ctx.instance_id),
controller_display_name(ctx),
ctx.stable_id or "unknown",
state,
)
console.print(table)
choices = [str(i) for i in range(len(controllers))] + ["q"]
if hotkey:
hotkey.suspend()
try:
selection = Prompt.ask(
"Select controller index to toggle ABXY (or 'q' to cancel)",
choices=choices,
default="q",
)
finally:
if hotkey:
hotkey.resume()
if selection == "q":
console.print("[yellow]ABXY toggle canceled.[/yellow]")
return
ctx = controllers[int(selection)]
toggle_abxy_for_context(ctx, config, console)
def open_controller(index: int) -> Tuple[sdl2.SDL_GameController, int, str]: def open_controller(index: int) -> Tuple[sdl2.SDL_GameController, int, str]:
"""Open an SDL GameController by index and return it with instance ID and GUID string.""" """Open an SDL GameController by index and return it with instance ID and GUID string."""
controller = sdl2.SDL_GameControllerOpen(index) controller = sdl2.SDL_GameControllerOpen(index)
@ -458,6 +578,13 @@ def build_arg_parser() -> argparse.ArgumentParser:
metavar="KEY", metavar="KEY",
help="Press this key in the terminal to re-zero sticks at runtime (default: 'z', empty string disables).", help="Press this key in the terminal to re-zero sticks at runtime (default: 'z', empty string disables).",
) )
parser.add_argument(
"--swap-hotkey",
type=parse_hotkey,
default="x",
metavar="KEY",
help="Press this key in the terminal to toggle ABXY layout for a connected controller (default: 'x'; empty string disables).",
)
parser.add_argument( parser.add_argument(
"--trigger-threshold", "--trigger-threshold",
type=float, type=float,
@ -554,10 +681,12 @@ class BridgeConfig:
trigger_threshold: int trigger_threshold: int
zero_sticks: bool zero_sticks: bool
zero_hotkey: str zero_hotkey: str
swap_hotkey: str
button_map_default: Dict[int, SwitchButton] button_map_default: Dict[int, SwitchButton]
button_map_swapped: Dict[int, SwitchButton] button_map_swapped: Dict[int, SwitchButton]
swap_abxy_indices: set[int] swap_abxy_indices: set[int]
swap_abxy_ids: set[str] swap_abxy_ids: set[str]
swap_abxy_global: bool
@dataclass @dataclass
@ -608,10 +737,12 @@ def build_bridge_config(console: Console, args: argparse.Namespace) -> BridgeCon
trigger_threshold=trigger_threshold, trigger_threshold=trigger_threshold,
zero_sticks=bool(args.zero_sticks), zero_sticks=bool(args.zero_sticks),
zero_hotkey=args.zero_hotkey or "", zero_hotkey=args.zero_hotkey or "",
swap_hotkey=args.swap_hotkey or "",
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 swap_abxy_ids=set(swap_abxy_guids), # filled later once stable IDs are known
swap_abxy_global=bool(args.swap_abxy),
) )
@ -930,7 +1061,6 @@ def handle_axis_motion(event: sdl2.SDL_Event, contexts: Dict[int, ControllerCont
def handle_button_event( def handle_button_event(
event: sdl2.SDL_Event, event: sdl2.SDL_Event,
args: argparse.Namespace,
config: BridgeConfig, config: BridgeConfig,
contexts: Dict[int, ControllerContext], contexts: Dict[int, ControllerContext],
) -> None: ) -> None:
@ -940,7 +1070,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.stable_id in config.swap_abxy_ids) if (config.swap_abxy_global 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
@ -1045,7 +1175,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.stable_id in config.swap_abxy_ids) if (config.swap_abxy_global 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)
@ -1109,7 +1239,7 @@ def run_bridge_loop(
pairing: PairingState, pairing: PairingState,
contexts: Dict[int, ControllerContext], contexts: Dict[int, ControllerContext],
uarts: List[PicoUART], uarts: List[PicoUART],
hotkey: Optional[ZeroHotkeyMonitor] = None, hotkey: Optional[HotkeyMonitor] = None,
) -> 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()
@ -1125,7 +1255,7 @@ def run_bridge_loop(
if event.type == sdl2.SDL_CONTROLLERAXISMOTION: if event.type == sdl2.SDL_CONTROLLERAXISMOTION:
handle_axis_motion(event, contexts, config) handle_axis_motion(event, contexts, config)
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, config, contexts)
elif event.type == sdl2.SDL_CONTROLLERDEVICEADDED: elif event.type == sdl2.SDL_CONTROLLERDEVICEADDED:
handle_device_added(event, args, pairing, contexts, uarts, console, config) handle_device_added(event, args, pairing, contexts, uarts, console, config)
elif event.type == sdl2.SDL_CONTROLLERDEVICEREMOVED: elif event.type == sdl2.SDL_CONTROLLERDEVICEREMOVED:
@ -1140,8 +1270,12 @@ def run_bridge_loop(
else: else:
pair_waiting_contexts(args, pairing, contexts, uarts, console) pair_waiting_contexts(args, pairing, contexts, uarts, console)
service_contexts(now, args, config, contexts, uarts, console) service_contexts(now, args, config, contexts, uarts, console)
if hotkey and hotkey.poll_trigger(): if hotkey:
zero_all_context_sticks(contexts, console) for key in hotkey.poll_keys():
if key == config.zero_hotkey:
zero_all_context_sticks(contexts, console)
elif key == config.swap_hotkey:
prompt_swap_abxy_controller(contexts, config, console, hotkey)
sdl2.SDL_Delay(1) sdl2.SDL_Delay(1)
@ -1163,15 +1297,25 @@ def main() -> None:
initialize_sdl(parser) initialize_sdl(parser)
contexts: Dict[int, ControllerContext] = {} contexts: Dict[int, ControllerContext] = {}
uarts: List[PicoUART] = [] uarts: List[PicoUART] = []
hotkey_monitor: Optional[ZeroHotkeyMonitor] = None hotkey_monitor: Optional[HotkeyMonitor] = None
try: try:
if args.list_controllers: if args.list_controllers:
list_controllers_with_guids(console, parser) list_controllers_with_guids(console, parser)
return 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)
hotkey_messages: Dict[str, str] = {}
if config.zero_hotkey: if config.zero_hotkey:
candidate = ZeroHotkeyMonitor(config.zero_hotkey, console) hotkey_messages[config.zero_hotkey] = "re-zero controller sticks"
if config.swap_hotkey:
if config.swap_hotkey in hotkey_messages:
hotkey_messages[config.swap_hotkey] = (
hotkey_messages[config.swap_hotkey] + "; toggle ABXY layout"
)
else:
hotkey_messages[config.swap_hotkey] = "toggle ABXY layout for a controller"
if hotkey_messages:
candidate = HotkeyMonitor(console, hotkey_messages)
if candidate.start(): if candidate.start():
hotkey_monitor = candidate hotkey_monitor = candidate
contexts, uarts = open_initial_contexts(args, pairing, controller_indices, console, config) contexts, uarts = open_initial_contexts(args, pairing, controller_indices, console, config)