From 9ff474b470a6c27b103a0840323f8fb2188c1afa Mon Sep 17 00:00:00 2001 From: Daniel Lindestad Date: Wed, 6 May 2026 18:27:06 +0200 Subject: [PATCH 1/2] fix linux controller input --- lib/controller.py | 231 +++++++++++++++++++++++++++++++++------ routes.py | 3 + static/css/styles.css | 2 + static/js/controller.js | 119 ++++++++++++++++++-- tests/test_controller.py | 171 +++++++++++++++++++++++++++++ 5 files changed, 485 insertions(+), 41 deletions(-) create mode 100644 tests/test_controller.py diff --git a/lib/controller.py b/lib/controller.py index d8c575a..7a9bcb7 100644 --- a/lib/controller.py +++ b/lib/controller.py @@ -9,6 +9,8 @@ import time import pygame +from pygame._sdl2 import controller as sdl_controller +from pygame._sdl2 import sdl2 from lib.bitmask import BitmaskClient @@ -22,13 +24,18 @@ class Controller: } DEADZONE = 0.05 # Axes within +/- this value are treated as 0 + CONTROLLER_AXIS_MAX = 32767.0 + CONTROLLER_AXIS_MIN = 32768.0 + VISUALIZER_BUTTON_COUNT = 16 def __init__(self, bitmask_client: BitmaskClient = None, rate_hz: float = 60.0): self.bm = bitmask_client # Use injected bitmask client from app.py self.delay_ms = int(1000 / rate_hz) if rate_hz > 0 else 16 # ~60 Hz default pygame.init() pygame.joystick.init() + sdl_controller.init() self.joystick = None + self.controller = None self.axis_offsets = {} # Calibration offsets for stuck axes self.light = 0 # Initial light value self._prev_dpad_up = False # For edge detection of light increase (D-pad up) @@ -39,6 +46,8 @@ def __init__(self, bitmask_client: BitmaskClient = None, rate_hz: float = 60.0): # Debug override state self._debug_override = None # None = no override; dict of axes when active self._debug_lock = threading.Lock() + self._input_status_lock = threading.Lock() + self._input_status = self._empty_input_status() # Gain settings (per-axis and master) self._gain_lock = threading.Lock() self._master_gain = 1.0 @@ -54,23 +63,76 @@ def __init__(self, bitmask_client: BitmaskClient = None, rate_hz: float = 60.0): def _try_connect(self): """Try to connect to first available joystick without reinitializing subsystem.""" - if pygame.joystick.get_count() > 0: + device_indices = range(pygame.joystick.get_count()) + for prefer_controller in (True, False): + if self._try_connect_matching(device_indices, prefer_controller): + return True + return False + + def _try_connect_matching(self, device_indices, prefer_controller): + for index in device_indices: try: - self.joystick = pygame.joystick.Joystick(0) - self.joystick.init() - print(f"Controller connected: {self.joystick.get_name()}") + is_controller = bool(sdl_controller.is_controller(index)) + if is_controller != prefer_controller: + continue + + if is_controller: + self.controller = sdl_controller.Controller(index) + self.joystick = self.controller.as_joystick() + print(f"Controller connected: {self.controller.name} (SDL game controller mapping)") + else: + self.controller = None + self.joystick = pygame.joystick.Joystick(index) + self.joystick.init() + print(f"Controller connected: {self.joystick.get_name()} (raw joystick mapping)") + print(f" Buttons: {self.joystick.get_numbuttons()}") print(f" Axes: {self.joystick.get_numaxes()}") print(f" Hats: {self.joystick.get_numhats()}") self.axis_offsets = {} # Reset calibration - self.calibrate_axes() + if not self.controller: + self.calibrate_axes() + self._update_input_status([0.0] * self.VISUALIZER_BUTTON_COUNT) return True - except pygame.error as e: - print(f"Failed to init joystick: {e}") + except (pygame.error, sdl2.error) as e: + print(f"Failed to init joystick {index}: {e}") + self.controller = None self.joystick = None - return False return False + def _disconnect_controller(self): + """Forget the active input device and stop movement.""" + if self.controller: + try: + self.controller.quit() + except (pygame.error, sdl2.error): + pass + self.controller = None + self.joystick = None + self._reset_command() + self._set_input_status(self._empty_input_status()) + + def _empty_input_status(self): + return { + "connected": False, + "source": "none", + "name": None, + "buttons": [0.0] * self.VISUALIZER_BUTTON_COUNT, + } + + def _set_input_status(self, status): + with self._input_status_lock: + self._input_status = status + + def get_input_status(self): + with self._input_status_lock: + return { + "connected": self._input_status["connected"], + "source": self._input_status["source"], + "name": self._input_status["name"], + "buttons": list(self._input_status["buttons"]), + } + def calibrate_axes(self): """Capture initial axis values to use as offsets (fixes stuck axes).""" pygame.event.pump() @@ -94,6 +156,94 @@ def get_calibrated_axis(self, axis_id): return 0.0 return calibrated + def _normalize_controller_axis(self, axis_id): + """Read an SDL GameController axis as a normalized -1.0..1.0 value.""" + raw = self.controller.get_axis(axis_id) + divisor = self.CONTROLLER_AXIS_MIN if raw < 0 else self.CONTROLLER_AXIS_MAX + value = max(-1.0, min(1.0, raw / divisor)) + if abs(value) < self.DEADZONE: + return 0.0 + return value + + def _normalize_controller_trigger(self, axis_id): + """Read an SDL GameController trigger as a normalized 0.0..1.0 value.""" + raw = self.controller.get_axis(axis_id) + return max(0.0, min(1.0, raw / self.CONTROLLER_AXIS_MAX)) + + def _read_axis(self, controller_axis, joystick_axis): + if self.controller: + return self._normalize_controller_axis(controller_axis) + return self.get_calibrated_axis(joystick_axis) + + def _read_trigger(self, controller_axis, joystick_axis): + if self.controller: + return self._normalize_controller_trigger(controller_axis) + return (self.joystick.get_axis(joystick_axis) + 1) / 2 + + def _read_button(self, controller_button, joystick_button): + if self.controller: + return bool(self.controller.get_button(controller_button)) + return bool(self.joystick.get_button(joystick_button)) + + def _read_dpad_up_down(self): + if self.controller: + return ( + bool(self.controller.get_button(pygame.CONTROLLER_BUTTON_DPAD_UP)), + bool(self.controller.get_button(pygame.CONTROLLER_BUTTON_DPAD_DOWN)), + ) + + hat = self.joystick.get_hat(0) if self.joystick.get_numhats() > 0 else (0, 0) + return hat[1] > 0, hat[1] < 0 + + def _read_visualizer_buttons(self, l2=0.0, r2=0.0): + buttons = [0.0] * self.VISUALIZER_BUTTON_COUNT + + if self.controller: + mapping = { + 0: pygame.CONTROLLER_BUTTON_A, + 1: pygame.CONTROLLER_BUTTON_B, + 2: pygame.CONTROLLER_BUTTON_X, + 3: pygame.CONTROLLER_BUTTON_Y, + 4: pygame.CONTROLLER_BUTTON_LEFTSHOULDER, + 5: pygame.CONTROLLER_BUTTON_RIGHTSHOULDER, + 8: pygame.CONTROLLER_BUTTON_BACK, + 9: pygame.CONTROLLER_BUTTON_START, + 10: pygame.CONTROLLER_BUTTON_LEFTSTICK, + 11: pygame.CONTROLLER_BUTTON_RIGHTSTICK, + 12: pygame.CONTROLLER_BUTTON_DPAD_UP, + 13: pygame.CONTROLLER_BUTTON_DPAD_DOWN, + 14: pygame.CONTROLLER_BUTTON_DPAD_LEFT, + 15: pygame.CONTROLLER_BUTTON_DPAD_RIGHT, + } + for visualizer_index, controller_button in mapping.items(): + buttons[visualizer_index] = 1.0 if self.controller.get_button(controller_button) else 0.0 + elif self.joystick: + for index in range(min(self.VISUALIZER_BUTTON_COUNT, self.joystick.get_numbuttons())): + buttons[index] = 1.0 if self.joystick.get_button(index) else 0.0 + + buttons[6] = max(buttons[6], l2) + buttons[7] = max(buttons[7], r2) + return buttons + + def _update_input_status(self, buttons): + name = None + source = "none" + if self.controller: + name = self.controller.name + source = "sdl_gamecontroller" + elif self.joystick: + name = self.joystick.get_name() + source = "raw_joystick" + + self._set_input_status( + { + "connected": self.joystick is not None, + "source": source, + "name": name, + "buttons": buttons, + } + ) + # --- Gain API --- def set_gains(self, master=None, **axis_gains): """Set master and/or per-axis gains. Values should be 0.0 – 1.0.""" @@ -157,6 +307,14 @@ def update(self): light=self.light, manip=0, ) + self._set_input_status( + { + "connected": self.joystick is not None, + "source": "debug_override", + "name": self.joystick.get_name() if self.joystick else None, + "buttons": [0.0] * self.VISUALIZER_BUTTON_COUNT, + } + ) return # Skip all joystick processing # Process pygame events (needed for hotplug detection) @@ -168,8 +326,14 @@ def update(self): self._try_connect() elif event.type == pygame.JOYDEVICEREMOVED: print("Joystick device removed!") - self.joystick = None - self._reset_command() # Stop movement if disconnected + self._disconnect_controller() + elif event.type == pygame.CONTROLLERDEVICEADDED: + print("Controller device added!") + if not self.joystick: + self._try_connect() + elif event.type == pygame.CONTROLLERDEVICEREMOVED: + print("Controller device removed!") + self._disconnect_controller() except SystemError: # pygame event system can error during hotplug, just continue pass @@ -180,45 +344,48 @@ def update(self): if self._reconnect_delay >= 60: # Try every ~1 second self._reconnect_delay = 0 self._try_connect() + self._set_input_status(self._empty_input_status()) return # Check if joystick is still connected try: - _ = self.joystick.get_axis(0) - except pygame.error: + if self.controller: + if not self.controller.attached(): + raise pygame.error("controller detached") + else: + _ = self.joystick.get_axis(0) + except (pygame.error, sdl2.error): print("Controller disconnected!") - self.joystick = None self._reconnect_delay = 0 - self._reset_command() # Stop movement if disconnected + self._disconnect_controller() return # --- BITMASK OUTPUT ---- # Read axes - heave = -self.get_calibrated_axis(3) # Right Y (inverted) - yaw = self.get_calibrated_axis(2) # Right X + heave = -self._read_axis(pygame.CONTROLLER_AXIS_RIGHTY, 3) # Right Y (inverted) + yaw = self._read_axis(pygame.CONTROLLER_AXIS_RIGHTX, 2) # Right X # manip is r2 axis minus l2 axis - # Triggers: convert from -1..1 to 0..1 - r2 = (self.joystick.get_axis(5) + 1) / 2 # R2 trigger - l2 = (self.joystick.get_axis(4) + 1) / 2 # L2 trigger + r2 = self._read_trigger(pygame.CONTROLLER_AXIS_TRIGGERRIGHT, 5) # R2 trigger + l2 = self._read_trigger(pygame.CONTROLLER_AXIS_TRIGGERLEFT, 4) # L2 trigger manip = r2 - l2 # This runs while button 9 is held down L1 to make # surge and sway controls toggleable to pitch and roll - if self.joystick.get_button(9): # Pitch and roll control - pitch = -self.get_calibrated_axis(1) # Left Y (inverted) - roll = self.get_calibrated_axis(0) # Left X + left_shoulder = self._read_button(pygame.CONTROLLER_BUTTON_LEFTSHOULDER, 9) + if left_shoulder: # Pitch and roll control + pitch = -self._read_axis(pygame.CONTROLLER_AXIS_LEFTY, 1) # Left Y (inverted) + roll = self._read_axis(pygame.CONTROLLER_AXIS_LEFTX, 0) # Left X surge = 0.0 sway = 0.0 else: # Surge and sway control - surge = -self.get_calibrated_axis(1) # Right Y (inverted) - sway = self.get_calibrated_axis(0) # Right X + surge = -self._read_axis(pygame.CONTROLLER_AXIS_LEFTY, 1) # Left Y (inverted) + sway = self._read_axis(pygame.CONTROLLER_AXIS_LEFTX, 0) # Left X pitch = 0.0 roll = 0.0 - # Light control with edge detection via D-pad hat (up/down) - hat = self.joystick.get_hat(0) if self.joystick.get_numhats() > 0 else (0, 0) - dpad_up = hat[1] > 0 - dpad_down = hat[1] < 0 + # Light control with edge detection via D-pad up/down + dpad_up, dpad_down = self._read_dpad_up_down() + buttons = self._read_visualizer_buttons(l2=l2, r2=r2) if dpad_up and not self._prev_dpad_up: # Just pressed self.light = min(1.0, self.light + 0.1) # +10% per press @@ -227,6 +394,7 @@ def update(self): self._prev_dpad_up = dpad_up self._prev_dpad_down = dpad_down + self._update_input_status(buttons) # Apply gain to each axis surge = self._apply_gain("surge", surge) @@ -237,9 +405,10 @@ def update(self): yaw = self._apply_gain("yaw", yaw) # Send to ROV! - self.bm.set_from_axes( - surge=surge, sway=sway, yaw=yaw, pitch=pitch, heave=heave, roll=roll, light=self.light, manip=manip - ) + if self.bm: + self.bm.set_from_axes( + surge=surge, sway=sway, yaw=yaw, pitch=pitch, heave=heave, roll=roll, light=self.light, manip=manip + ) def run_loop(self): """Blocking loop that polls controller at ~60 Hz.""" diff --git a/routes.py b/routes.py index 521c87c..d8530db 100644 --- a/routes.py +++ b/routes.py @@ -196,13 +196,16 @@ def get_command_status(): bm = current_app.config.get("BITMASK") resource = current_app.config.get("RESOURCE") override = current_app.config.get("SETPOINT_OVERRIDE") + controller = current_app.config.get("CONTROLLER") uplink = bm.get_uplink_status() if bm else {} udp_rx, udp_err = resource.get_udp_counters() if resource else (0, 0) state = override.get_state() if override else {} + controller_state = controller.get_input_status() if controller else {} return jsonify( { "ok": True, "uplink": uplink, + "controller": controller_state, "udp_rx_count": udp_rx, "udp_rx_errors": udp_err, "override": state, diff --git a/static/css/styles.css b/static/css/styles.css index 0a11766..5f7d7b7 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -358,6 +358,8 @@ body { #controller-b10.stick-active, #controller-b11.stick-active { fill: #dc3545 !important; +} + /* ========================================== Resource Monitor SVG Gauges ========================================== */ diff --git a/static/js/controller.js b/static/js/controller.js index bca338c..a97304e 100644 --- a/static/js/controller.js +++ b/static/js/controller.js @@ -1,4 +1,20 @@ let controllerIndex = null; +let backendCommand = null; +let backendButtons = null; +let backendCommandTime = 0; +let backendPollTimer = null; + +const RAW_COMMAND_SCALE = 127; + +function findConnectedGamepad() { + const gamepads = navigator.getGamepads ? navigator.getGamepads() : []; + for (const gamepad of gamepads) { + if (gamepad && gamepad.connected) { + return gamepad; + } + } + return null; +} window.addEventListener("gamepadconnected", (event) => { const gamepad = event.gamepad; @@ -26,6 +42,63 @@ function resetController() { resetStick("controller-b11"); } +function getBackendAxis(command, axis) { + return Math.max(-1, Math.min(1, (command[axis] || 0) / RAW_COMMAND_SCALE)); +} + +function updateControllerFromCommand(command) { + resetController(); + + const surge = getBackendAxis(command, "surge"); + const sway = getBackendAxis(command, "sway"); + const heave = getBackendAxis(command, "heave"); + const roll = getBackendAxis(command, "roll"); + const pitch = getBackendAxis(command, "pitch"); + const yaw = getBackendAxis(command, "yaw"); + const manip = getBackendAxis(command, "manip"); + + if (Math.abs(pitch) > 0.01 || Math.abs(roll) > 0.01) { + updateControllerButton(4, 1); + updateStick("controller-b10", roll, -pitch); + } else { + updateStick("controller-b10", sway, -surge); + } + + updateStick("controller-b11", yaw, -heave); + + if (manip < -0.01) { + updateControllerButton(6, Math.abs(manip)); + } else if (manip > 0.01) { + updateControllerButton(7, manip); + } + + if (backendButtons) { + updateControllerButtonsFromValues(backendButtons); + } +} + +function commandIsActive(command) { + return ["surge", "sway", "heave", "roll", "pitch", "yaw", "manip"].some((axis) => Math.abs(command[axis] || 0) > 1); +} + +async function fetchBackendCommand() { + try { + const response = await fetch("/api/command/status", { cache: "no-store" }); + if (!response.ok) return; + + const data = await response.json(); + const command = data && data.uplink && data.uplink.last_command; + const buttons = data && data.controller && data.controller.buttons; + if (command) { + backendCommand = command; + backendButtons = Array.isArray(buttons) ? buttons : null; + backendCommandTime = performance.now(); + } + } catch (error) { + console.debug("Controller command status unavailable:", error); + } +} + function resetStick(elementId) { const stick = document.getElementById(elementId); if (!stick) return; @@ -52,15 +125,20 @@ function updateControllerButton(index, value) { } function handleButtons(buttons) { + updateControllerButtonsFromValues(buttons); +} + +function updateControllerButtonsFromValues(buttons) { for (let i = 0; i < buttons.length; i++) { - const buttonValue = buttons[i].value; + const button = buttons[i]; + const buttonValue = typeof button === "number" ? button : button.value; updateControllerButton(i, buttonValue); } } function handleSticks(axes) { - updateStick("controller-b10", axes[0], axes[1]); - updateStick("controller-b11", axes[2], axes[3]); + updateStick("controller-b10", axes[0] || 0, axes[1] || 0); + updateStick("controller-b11", axes[2] || 0, axes[3] || 0); } function updateStick(elementId, leftRightAxis, upDownAxis) { @@ -87,14 +165,35 @@ function updateStick(elementId, leftRightAxis, upDownAxis) { } function gameLoop() { - if (controllerIndex !== null) { - const gamepad = navigator.getGamepads()[controllerIndex]; - if (gamepad) { - handleButtons(gamepad.buttons); - handleSticks(gamepad.axes); - } + let gamepad = null; + + if (controllerIndex !== null && navigator.getGamepads) { + gamepad = navigator.getGamepads()[controllerIndex]; } + + if (!gamepad) { + gamepad = findConnectedGamepad(); + controllerIndex = gamepad ? gamepad.index : null; + } + + if (backendCommand && performance.now() - backendCommandTime < 1000 && (!gamepad || commandIsActive(backendCommand))) { + updateControllerFromCommand(backendCommand); + } else if (gamepad) { + handleButtons(gamepad.buttons); + handleSticks(gamepad.axes); + } else { + resetController(); + } + requestAnimationFrame(gameLoop); } -gameLoop(); \ No newline at end of file +backendPollTimer = setInterval(fetchBackendCommand, 100); +fetchBackendCommand(); +gameLoop(); + +window.addEventListener("beforeunload", () => { + if (backendPollTimer) { + clearInterval(backendPollTimer); + } +}); diff --git a/tests/test_controller.py b/tests/test_controller.py new file mode 100644 index 0000000..221236e --- /dev/null +++ b/tests/test_controller.py @@ -0,0 +1,171 @@ +import os +import threading + +os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "1" + +import pygame +import pytest + +from lib.controller import Controller + + +class FakeBitmask: + def __init__(self): + self.calls = [] + + def set_from_axes(self, **kwargs): + self.calls.append(kwargs) + + +class FakeSdlController: + def __init__(self, axes=None, buttons=None, attached=True): + self.axes = axes or {} + self.buttons = buttons or set() + self._attached = attached + self.name = "fake SDL controller" + + def attached(self): + return self._attached + + def get_axis(self, axis): + return self.axes.get(axis, 0) + + def get_button(self, button): + return button in self.buttons + + +class FakeJoystick: + def __init__(self, axes=None, buttons=None, hat=(0, 0)): + self.axes = axes or {} + self.buttons = buttons or set() + self.hat = hat + + def get_axis(self, axis): + return self.axes.get(axis, 0.0) + + def get_button(self, button): + return button in self.buttons + + def get_numbuttons(self): + return max(self.buttons, default=-1) + 1 + + def get_name(self): + return "fake joystick" + + def get_numhats(self): + return 1 + + def get_hat(self, index): + assert index == 0 + return self.hat + + +def build_controller(controller=None, joystick=None): + ctrl = Controller.__new__(Controller) + ctrl.bm = FakeBitmask() + ctrl.controller = controller + ctrl.joystick = joystick or FakeJoystick() + ctrl.axis_offsets = {} + ctrl.light = 0.0 + ctrl._prev_dpad_up = False + ctrl._prev_dpad_down = False + ctrl._reconnect_delay = 0 + ctrl._debug_override = None + ctrl._debug_lock = threading.Lock() + ctrl._input_status_lock = threading.Lock() + ctrl._input_status = ctrl._empty_input_status() + ctrl._gain_lock = threading.Lock() + ctrl._master_gain = 1.0 + ctrl._axis_gains = { + "surge": 1.0, + "sway": 1.0, + "heave": 1.0, + "roll": 1.0, + "pitch": 1.0, + "yaw": 1.0, + } + return ctrl + + +def test_sdl_gamecontroller_mapping_normalizes_linux_playstation_layout(monkeypatch): + monkeypatch.setattr(pygame.event, "get", lambda: []) + sdl = FakeSdlController( + axes={ + pygame.CONTROLLER_AXIS_LEFTX: 8192, + pygame.CONTROLLER_AXIS_LEFTY: -16384, + pygame.CONTROLLER_AXIS_RIGHTX: 12288, + pygame.CONTROLLER_AXIS_RIGHTY: -4096, + pygame.CONTROLLER_AXIS_TRIGGERLEFT: 0, + pygame.CONTROLLER_AXIS_TRIGGERRIGHT: 32767, + }, + buttons={pygame.CONTROLLER_BUTTON_DPAD_UP}, + ) + ctrl = build_controller(controller=sdl) + + ctrl.update() + + command = ctrl.bm.calls[-1] + assert command["surge"] == pytest.approx(0.5, rel=1e-3) + assert command["sway"] == pytest.approx(0.25, rel=1e-3) + assert command["heave"] == pytest.approx(0.125, rel=1e-3) + assert command["yaw"] == pytest.approx(0.375, rel=1e-3) + assert command["pitch"] == 0.0 + assert command["roll"] == 0.0 + assert command["manip"] == 1.0 + assert command["light"] == pytest.approx(0.1, rel=1e-3) + status = ctrl.get_input_status() + assert status["connected"] is True + assert status["source"] == "sdl_gamecontroller" + assert status["buttons"][7] == 1.0 + assert status["buttons"][12] == 1.0 + + +def test_sdl_left_shoulder_switches_left_stick_to_pitch_and_roll(monkeypatch): + monkeypatch.setattr(pygame.event, "get", lambda: []) + sdl = FakeSdlController( + axes={ + pygame.CONTROLLER_AXIS_LEFTX: -32768, + pygame.CONTROLLER_AXIS_LEFTY: 32767, + }, + buttons={pygame.CONTROLLER_BUTTON_LEFTSHOULDER}, + ) + ctrl = build_controller(controller=sdl) + + ctrl.update() + + command = ctrl.bm.calls[-1] + assert command["surge"] == 0.0 + assert command["sway"] == 0.0 + assert command["pitch"] == -1.0 + assert command["roll"] == -1.0 + status = ctrl.get_input_status() + assert status["buttons"][4] == 1.0 + + +def test_raw_joystick_mapping_remains_available_for_unsupported_devices(monkeypatch): + monkeypatch.setattr(pygame.event, "get", lambda: []) + joystick = FakeJoystick( + axes={ + 0: 0.25, + 1: -0.5, + 2: 0.4, + 3: -0.2, + 4: -1.0, + 5: 1.0, + }, + hat=(0, 1), + ) + ctrl = build_controller(joystick=joystick) + + ctrl.update() + + command = ctrl.bm.calls[-1] + assert command["surge"] == 0.5 + assert command["sway"] == 0.25 + assert command["heave"] == 0.2 + assert command["yaw"] == 0.4 + assert command["manip"] == 1.0 + assert command["light"] == pytest.approx(0.1, rel=1e-3) + status = ctrl.get_input_status() + assert status["source"] == "raw_joystick" + assert status["buttons"][7] == 1.0 From 0d26dd06b3193f38b99e80e6a9c07bb749f01620 Mon Sep 17 00:00:00 2001 From: Daniel Lindestad Date: Wed, 6 May 2026 18:58:53 +0200 Subject: [PATCH 2/2] preserve windows controller mapping --- lib/controller.py | 35 +++++++++++++++++++++++++++-------- tests/test_controller.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/lib/controller.py b/lib/controller.py index 7a9bcb7..caf46b2 100644 --- a/lib/controller.py +++ b/lib/controller.py @@ -1,4 +1,5 @@ import os +import sys # fix for error 'NSInternalInconsistencyException', reason: 'nextEventMatchingMask should only be called from the Main Thread on posix systems if os.name == "posix": @@ -9,12 +10,28 @@ import time import pygame -from pygame._sdl2 import controller as sdl_controller -from pygame._sdl2 import sdl2 + +if sys.platform.startswith("linux"): + from pygame._sdl2 import controller as sdl_controller + from pygame._sdl2 import sdl2 +else: + sdl_controller = None + sdl2 = None from lib.bitmask import BitmaskClient +def _use_sdl_gamecontroller(): + return sys.platform.startswith("linux") and sdl_controller is not None + + +def _controller_errors(): + errors = [pygame.error] + if sdl2 is not None: + errors.append(sdl2.error) + return tuple(errors) + + class Controller: AXIS_THRESHOLDS = { "leftx": (0, 0.1), @@ -33,7 +50,8 @@ def __init__(self, bitmask_client: BitmaskClient = None, rate_hz: float = 60.0): self.delay_ms = int(1000 / rate_hz) if rate_hz > 0 else 16 # ~60 Hz default pygame.init() pygame.joystick.init() - sdl_controller.init() + if _use_sdl_gamecontroller(): + sdl_controller.init() self.joystick = None self.controller = None self.axis_offsets = {} # Calibration offsets for stuck axes @@ -64,7 +82,8 @@ def __init__(self, bitmask_client: BitmaskClient = None, rate_hz: float = 60.0): def _try_connect(self): """Try to connect to first available joystick without reinitializing subsystem.""" device_indices = range(pygame.joystick.get_count()) - for prefer_controller in (True, False): + mapping_preferences = (True, False) if _use_sdl_gamecontroller() else (False,) + for prefer_controller in mapping_preferences: if self._try_connect_matching(device_indices, prefer_controller): return True return False @@ -72,7 +91,7 @@ def _try_connect(self): def _try_connect_matching(self, device_indices, prefer_controller): for index in device_indices: try: - is_controller = bool(sdl_controller.is_controller(index)) + is_controller = bool(sdl_controller.is_controller(index)) if _use_sdl_gamecontroller() else False if is_controller != prefer_controller: continue @@ -94,7 +113,7 @@ def _try_connect_matching(self, device_indices, prefer_controller): self.calibrate_axes() self._update_input_status([0.0] * self.VISUALIZER_BUTTON_COUNT) return True - except (pygame.error, sdl2.error) as e: + except _controller_errors() as e: print(f"Failed to init joystick {index}: {e}") self.controller = None self.joystick = None @@ -105,7 +124,7 @@ def _disconnect_controller(self): if self.controller: try: self.controller.quit() - except (pygame.error, sdl2.error): + except _controller_errors(): pass self.controller = None self.joystick = None @@ -354,7 +373,7 @@ def update(self): raise pygame.error("controller detached") else: _ = self.joystick.get_axis(0) - except (pygame.error, sdl2.error): + except _controller_errors(): print("Controller disconnected!") self._reconnect_delay = 0 self._disconnect_controller() diff --git a/tests/test_controller.py b/tests/test_controller.py index 221236e..4f7e142 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -6,6 +6,7 @@ import pygame import pytest +import lib.controller as controller_module from lib.controller import Controller @@ -40,6 +41,9 @@ def __init__(self, axes=None, buttons=None, hat=(0, 0)): self.buttons = buttons or set() self.hat = hat + def init(self): + pass + def get_axis(self, axis): return self.axes.get(axis, 0.0) @@ -49,6 +53,9 @@ def get_button(self, button): def get_numbuttons(self): return max(self.buttons, default=-1) + 1 + def get_numaxes(self): + return max(self.axes, default=-1) + 1 + def get_name(self): return "fake joystick" @@ -169,3 +176,24 @@ def test_raw_joystick_mapping_remains_available_for_unsupported_devices(monkeypa status = ctrl.get_input_status() assert status["source"] == "raw_joystick" assert status["buttons"][7] == 1.0 + + +def test_non_linux_connection_uses_raw_joystick_without_sdl_probe(monkeypatch): + class FailingSdlController: + @staticmethod + def is_controller(index): + raise AssertionError(f"unexpected SDL controller probe for joystick {index}") + + joystick = FakeJoystick() + ctrl = build_controller(joystick=None) + + monkeypatch.setattr(controller_module.sys, "platform", "win32") + monkeypatch.setattr(controller_module, "sdl_controller", FailingSdlController) + monkeypatch.setattr(pygame.joystick, "get_count", lambda: 1) + monkeypatch.setattr(pygame.joystick, "Joystick", lambda index: joystick) + monkeypatch.setattr(pygame.event, "pump", lambda: None) + + assert ctrl._try_connect() is True + assert ctrl.controller is None + assert ctrl.joystick is joystick + assert ctrl.get_input_status()["source"] == "raw_joystick"