From 8667bd66379db3dee432e443ba56e4dad8407a2e Mon Sep 17 00:00:00 2001 From: Grayson Date: Sat, 23 May 2026 15:35:41 -0700 Subject: [PATCH 01/14] keyboard support --- .../thunderscope/robot_diagnostics/BUILD | 18 ++++ .../robot_diagnostics/controller_base.py | 30 +++++++ .../robot_diagnostics/handheld_controller.py | 4 +- .../handheld_controller_widget.py | 23 ++++- .../robot_diagnostics/keyboard_controller.py | 88 +++++++++++++++++++ 5 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 src/software/thunderscope/robot_diagnostics/controller_base.py create mode 100644 src/software/thunderscope/robot_diagnostics/keyboard_controller.py diff --git a/src/software/thunderscope/robot_diagnostics/BUILD b/src/software/thunderscope/robot_diagnostics/BUILD index aee3a437d1..e3c5ef0370 100644 --- a/src/software/thunderscope/robot_diagnostics/BUILD +++ b/src/software/thunderscope/robot_diagnostics/BUILD @@ -23,9 +23,25 @@ py_library( ], ) +py_library( + name = "keyboard_controller", + srcs = ["keyboard_controller.py"], + deps = [ + ":controller_base", + ] +) + py_library( name = "handheld_controller", srcs = ["handheld_controller.py"], + deps = [ + ":controller_base", + ] +) + +py_library( + name = "controller_base", + srcs = ["controller_base.py"], ) py_library( @@ -33,6 +49,8 @@ py_library( srcs = ["handheld_controller_widget.py"], deps = [ ":handheld_controller", + ":controller_base", + "keyboard_controller", "//software/thunderscope:constants", requirement("pyqtgraph"), ] + select({ diff --git a/src/software/thunderscope/robot_diagnostics/controller_base.py b/src/software/thunderscope/robot_diagnostics/controller_base.py new file mode 100644 index 0000000000..ae438271cf --- /dev/null +++ b/src/software/thunderscope/robot_diagnostics/controller_base.py @@ -0,0 +1,30 @@ +from abc import ABC, abstractmethod + + +class ControllerBase(ABC): + """Abstract base class for controller input sources.""" + + @abstractmethod + def name(self) -> str: + """Get the display name of the input source.""" + ... + + @abstractmethod + def connected(self) -> bool: + """Return true if the input source is active and available.""" + ... + + @abstractmethod + def key_down(self, key_code: int) -> bool: + """Return true if the given key/button code is currently pressed.""" + ... + + @abstractmethod + def abs_value(self, abs_code: int) -> float: + """Return the current value of an axis, normalized to [-1, 1].""" + ... + + @abstractmethod + def close(self) -> None: + """Release any resources held by the input source.""" + ... diff --git a/src/software/thunderscope/robot_diagnostics/handheld_controller.py b/src/software/thunderscope/robot_diagnostics/handheld_controller.py index d3a39a3ab0..e78c4d7f89 100644 --- a/src/software/thunderscope/robot_diagnostics/handheld_controller.py +++ b/src/software/thunderscope/robot_diagnostics/handheld_controller.py @@ -8,8 +8,10 @@ from threading import Thread +from software.thunderscope.robot_diagnostics.controller_base import ControllerBase -class HandheldController: + +class HandheldController(ControllerBase): """Represents a handheld game controller or input device that can be used to manually control our robots. """ diff --git a/src/software/thunderscope/robot_diagnostics/handheld_controller_widget.py b/src/software/thunderscope/robot_diagnostics/handheld_controller_widget.py index 64d244da4c..2885a272ba 100644 --- a/src/software/thunderscope/robot_diagnostics/handheld_controller_widget.py +++ b/src/software/thunderscope/robot_diagnostics/handheld_controller_widget.py @@ -16,9 +16,13 @@ from software.py_constants import * from software.thunderscope.constants import DiagnosticsConstants +from software.thunderscope.robot_diagnostics.controller_base import ControllerBase from software.thunderscope.robot_diagnostics.handheld_controller import ( HandheldController, ) +from software.thunderscope.robot_diagnostics.keyboard_controller import ( + KeyboardController, +) class HandheldControllerWidget(QWidget): @@ -39,7 +43,7 @@ def __init__(self) -> None: self.constants = tbots_cpp.createRobotConstants() - self.handheld_controller: HandheldController | None = None + self.handheld_controller: ControllerBase | None = None self.last_d_pad_axis_x_value = 0 self.last_d_pad_axis_y_value = 0 @@ -62,6 +66,8 @@ def detect_controller(self) -> None: handheld controller and, if one is found, set it as the device to accept controller inputs from. """ + if self.handheld_controller is not None: + self.handheld_controller.close() self.handheld_controller = None for path in evdev.list_devices(): @@ -72,6 +78,13 @@ def detect_controller(self) -> None: self.__update_controller_status() + def use_keyboard_controller(self) -> None: + """Switch to keyboard input, closing any active controller first.""" + if self.handheld_controller is not None: + self.handheld_controller.close() + self.handheld_controller = KeyboardController() + self.__update_controller_status() + def controller_input_enabled(self) -> bool: """Check whether controller input is enabled. @@ -105,15 +118,19 @@ def __create_widgets(self) -> QGroupBox: self.detect_controller_button = QPushButton("Detect Controller") self.detect_controller_button.clicked.connect(self.detect_controller) + self.use_keyboard_button = QPushButton("Use Keyboard") + self.use_keyboard_button.clicked.connect(self.use_keyboard_controller) + self.enable_input_checkbox = QCheckBox("Enable Input") self.enable_input_checkbox.setChecked(False) self.enable_input_checkbox.setEnabled(True) grid_layout = QGridLayout() - grid_layout.addWidget(self.controller_status_label, 0, 0, 2, 1) + grid_layout.addWidget(self.controller_status_label, 0, 0, 3, 1) grid_layout.addWidget(self.detect_controller_button, 0, 1) + grid_layout.addWidget(self.use_keyboard_button, 1, 1) grid_layout.addWidget( - self.enable_input_checkbox, 1, 1, QtCore.Qt.AlignmentFlag.AlignHCenter + self.enable_input_checkbox, 2, 1, QtCore.Qt.AlignmentFlag.AlignHCenter ) grid_layout.setColumnStretch(0, 4) grid_layout.setColumnStretch(1, 1) diff --git a/src/software/thunderscope/robot_diagnostics/keyboard_controller.py b/src/software/thunderscope/robot_diagnostics/keyboard_controller.py new file mode 100644 index 0000000000..7414f77edf --- /dev/null +++ b/src/software/thunderscope/robot_diagnostics/keyboard_controller.py @@ -0,0 +1,88 @@ +from abc import ABCMeta + +from pyqtgraph.Qt.QtCore import Qt, QObject, QEvent +from pyqtgraph.Qt.QtWidgets import QApplication + +from software.thunderscope.robot_diagnostics.controller_base import ControllerBase + +# evdev-independent copies of the ecodes values used by HandheldControllerWidget +_ABS_X = 0 +_ABS_Y = 1 +_ABS_Z = 2 +_ABS_RX = 3 +_ABS_RZ = 5 +_ABS_HAT0X = 16 +_ABS_HAT0Y = 17 +_BTN_A = 304 +_BTN_B = 305 + +# Maps abs_code -> (negative_key, positive_key). +# A held negative key returns -1.0; a held positive key returns +1.0. +_ABS_KEY_MAP: dict[int, tuple[Qt.Key | None, Qt.Key | None]] = { + _ABS_Y: (Qt.Key.Key_W, Qt.Key.Key_S), # forward / back + _ABS_X: (Qt.Key.Key_A, Qt.Key.Key_D), # strafe left / right + _ABS_RX: (Qt.Key.Key_Q, Qt.Key.Key_E), # rotate CCW / CW + _ABS_Z: (None, Qt.Key.Key_Shift), # slowdown (left trigger) + _ABS_HAT0X: (Qt.Key.Key_Left, Qt.Key.Key_Right), # step kick power + _ABS_HAT0Y: (Qt.Key.Key_Up, Qt.Key.Key_Down), # step dribbler RPM + _ABS_RZ: (None, Qt.Key.Key_R), # dribbler hold (right trigger) +} + +# Maps key_code (ecodes int) -> Qt key for digital button inputs +_BTN_KEY_MAP: dict[int, Qt.Key] = { + _BTN_A: Qt.Key.Key_X, # kick + _BTN_B: Qt.Key.Key_C, # chip +} + +class _QABCMeta(type(QObject), ABCMeta): + pass + +class KeyboardController(QObject, ControllerBase, metaclass=_QABCMeta): + """Keyboard input source. + + Installs a QApplication-level event filter so key events are captured + regardless of which widget currently has focus. + """ + + def __init__(self) -> None: + super().__init__() + self._held_keys: set[Qt.Key] = set() + self._active = True + QApplication.instance().installEventFilter(self) + + def name(self) -> str: + return "Keyboard" + + def connected(self) -> bool: + return self._active + + def key_down(self, key_code: int) -> bool: + qt_key = _BTN_KEY_MAP.get(key_code) + if qt_key is None: + return False + return qt_key in self._held_keys + + def abs_value(self, abs_code: int) -> float: + key_pair = _ABS_KEY_MAP.get(abs_code) + if key_pair is None: + return 0.0 + neg_key, pos_key = key_pair + if neg_key is not None and neg_key in self._held_keys: + return -1.0 + if pos_key is not None and pos_key in self._held_keys: + return 1.0 + return 0.0 + + def close(self) -> None: + self._active = False + self._held_keys.clear() + app = QApplication.instance() + if app is not None: + app.removeEventFilter(self) + + def eventFilter(self, obj: QObject, event: QEvent) -> bool: + if event.type() == QEvent.Type.KeyPress and not event.isAutoRepeat(): + self._held_keys.add(Qt.Key(event.key())) + elif event.type() == QEvent.Type.KeyRelease and not event.isAutoRepeat(): + self._held_keys.discard(Qt.Key(event.key())) + return False From 538aadc4dd4d370b5450ad53c50952c3be05f418 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 22:42:45 +0000 Subject: [PATCH 02/14] [pre-commit.ci lite] apply automatic fixes --- .../thunderscope/robot_diagnostics/BUILD | 8 ++++---- .../robot_diagnostics/keyboard_controller.py | 16 +++++++++------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/software/thunderscope/robot_diagnostics/BUILD b/src/software/thunderscope/robot_diagnostics/BUILD index e3c5ef0370..c909bb87d3 100644 --- a/src/software/thunderscope/robot_diagnostics/BUILD +++ b/src/software/thunderscope/robot_diagnostics/BUILD @@ -28,7 +28,7 @@ py_library( srcs = ["keyboard_controller.py"], deps = [ ":controller_base", - ] + ], ) py_library( @@ -36,7 +36,7 @@ py_library( srcs = ["handheld_controller.py"], deps = [ ":controller_base", - ] + ], ) py_library( @@ -48,9 +48,9 @@ py_library( name = "handheld_controller_widget", srcs = ["handheld_controller_widget.py"], deps = [ - ":handheld_controller", - ":controller_base", "keyboard_controller", + ":controller_base", + ":handheld_controller", "//software/thunderscope:constants", requirement("pyqtgraph"), ] + select({ diff --git a/src/software/thunderscope/robot_diagnostics/keyboard_controller.py b/src/software/thunderscope/robot_diagnostics/keyboard_controller.py index 7414f77edf..580858fb8a 100644 --- a/src/software/thunderscope/robot_diagnostics/keyboard_controller.py +++ b/src/software/thunderscope/robot_diagnostics/keyboard_controller.py @@ -19,24 +19,26 @@ # Maps abs_code -> (negative_key, positive_key). # A held negative key returns -1.0; a held positive key returns +1.0. _ABS_KEY_MAP: dict[int, tuple[Qt.Key | None, Qt.Key | None]] = { - _ABS_Y: (Qt.Key.Key_W, Qt.Key.Key_S), # forward / back - _ABS_X: (Qt.Key.Key_A, Qt.Key.Key_D), # strafe left / right - _ABS_RX: (Qt.Key.Key_Q, Qt.Key.Key_E), # rotate CCW / CW - _ABS_Z: (None, Qt.Key.Key_Shift), # slowdown (left trigger) + _ABS_Y: (Qt.Key.Key_W, Qt.Key.Key_S), # forward / back + _ABS_X: (Qt.Key.Key_A, Qt.Key.Key_D), # strafe left / right + _ABS_RX: (Qt.Key.Key_Q, Qt.Key.Key_E), # rotate CCW / CW + _ABS_Z: (None, Qt.Key.Key_Shift), # slowdown (left trigger) _ABS_HAT0X: (Qt.Key.Key_Left, Qt.Key.Key_Right), # step kick power - _ABS_HAT0Y: (Qt.Key.Key_Up, Qt.Key.Key_Down), # step dribbler RPM - _ABS_RZ: (None, Qt.Key.Key_R), # dribbler hold (right trigger) + _ABS_HAT0Y: (Qt.Key.Key_Up, Qt.Key.Key_Down), # step dribbler RPM + _ABS_RZ: (None, Qt.Key.Key_R), # dribbler hold (right trigger) } # Maps key_code (ecodes int) -> Qt key for digital button inputs _BTN_KEY_MAP: dict[int, Qt.Key] = { _BTN_A: Qt.Key.Key_X, # kick - _BTN_B: Qt.Key.Key_C, # chip + _BTN_B: Qt.Key.Key_C, # chip } + class _QABCMeta(type(QObject), ABCMeta): pass + class KeyboardController(QObject, ControllerBase, metaclass=_QABCMeta): """Keyboard input source. From 444654d90f9d72af333de85ac713ba36326eea08 Mon Sep 17 00:00:00 2001 From: Grayson Date: Mon, 29 Jun 2026 19:53:37 -0700 Subject: [PATCH 03/14] refactor --- .../thunderscope/robot_diagnostics/BUILD | 8 +- .../robot_diagnostics/controller_base.py | 40 +++++- .../robot_diagnostics/handheld_controller.py | 116 +++++++++++++--- .../handheld_controller_widget.py | 72 +++------- .../robot_diagnostics/keyboard_controller.py | 131 +++++++++++------- 5 files changed, 239 insertions(+), 128 deletions(-) diff --git a/src/software/thunderscope/robot_diagnostics/BUILD b/src/software/thunderscope/robot_diagnostics/BUILD index c909bb87d3..2fa0d949c6 100644 --- a/src/software/thunderscope/robot_diagnostics/BUILD +++ b/src/software/thunderscope/robot_diagnostics/BUILD @@ -28,6 +28,8 @@ py_library( srcs = ["keyboard_controller.py"], deps = [ ":controller_base", + "//software/thunderscope:constants", + requirement("pyqtgraph"), ], ) @@ -36,7 +38,11 @@ py_library( srcs = ["handheld_controller.py"], deps = [ ":controller_base", - ], + "//software/thunderscope:constants", + ] + select({ + "@platforms//os:linux": [requirement("evdev"), requirement("numpy")], + "//conditions:default": [requirement("numpy")], + }), ) py_library( diff --git a/src/software/thunderscope/robot_diagnostics/controller_base.py b/src/software/thunderscope/robot_diagnostics/controller_base.py index ae438271cf..96ee529f1f 100644 --- a/src/software/thunderscope/robot_diagnostics/controller_base.py +++ b/src/software/thunderscope/robot_diagnostics/controller_base.py @@ -15,16 +15,44 @@ def connected(self) -> bool: ... @abstractmethod - def key_down(self, key_code: int) -> bool: - """Return true if the given key/button code is currently pressed.""" + def close(self) -> None: + """Release any resources held by the input source.""" ... @abstractmethod - def abs_value(self, abs_code: int) -> float: - """Return the current value of an axis, normalized to [-1, 1].""" + def get_move_velocity(self) -> tuple[float, float, float]: + """Return (x, y, angular) velocity, each normalized to [-1, 1] with deadzone applied. + Positive x = forward, positive y = strafe left, positive angular = CCW.""" ... @abstractmethod - def close(self) -> None: - """Release any resources held by the input source.""" + def get_speed_factor(self) -> float: + """Return 1.0 normally, or SPEED_SLOWDOWN_FACTOR when slowdown input is active.""" + ... + + @abstractmethod + def is_dribbler_held(self) -> bool: + """Return True if the dribbler engage input is active.""" + ... + + @abstractmethod + def get_kick_power_step(self) -> int: + """Return -1, 0, or +1 for kick/chip power step direction. + Non-zero only once per new input (edge-detected).""" + ... + + @abstractmethod + def get_dribbler_step(self) -> int: + """Return -1, 0, or +1 for dribbler RPM step direction. + Non-zero only once per new input (edge-detected).""" + ... + + @abstractmethod + def is_kick_fired(self) -> bool: + """Return True once per kick button press (rising edge only).""" + ... + + @abstractmethod + def is_chip_fired(self) -> bool: + """Return True once per chip button press (rising edge only).""" ... diff --git a/src/software/thunderscope/robot_diagnostics/handheld_controller.py b/src/software/thunderscope/robot_diagnostics/handheld_controller.py index e78c4d7f89..6e815ca3a4 100644 --- a/src/software/thunderscope/robot_diagnostics/handheld_controller.py +++ b/src/software/thunderscope/robot_diagnostics/handheld_controller.py @@ -8,6 +8,7 @@ from threading import Thread +from software.thunderscope.constants import DiagnosticsConstants from software.thunderscope.robot_diagnostics.controller_base import ControllerBase @@ -29,6 +30,11 @@ def __init__(self, path: str): self.supported_keys = set(capabilities[ecodes.EV_KEY]) self.abs_info = dict(capabilities[ecodes.EV_ABS]) + self._last_hat0x = 0 + self._last_hat0y = 0 + self._last_btn_a = False + self._last_btn_b = False + self.thread = Thread(target=self.__read_input, daemon=True) self.thread.start() @@ -46,26 +52,103 @@ def connected(self) -> bool: """ return self.input_device is not None - def key_down(self, key_code: int) -> bool: - """Check whether the given key on the controller is currently pressed down. + def close(self) -> None: + """Close the connection to the controller.""" + if self.input_device is not None: + self.input_values.clear() + self.input_device.close() + self.input_device = None + + def get_move_velocity(self) -> tuple[float, float, float]: + """Return (x, y, angular) velocity normalized to [-1, 1] with deadzone applied. + + :return: (vx, vy, vrot) where positive x = forward, positive y = strafe left, + positive angular = CCW + """ + + def with_deadzone(v: float) -> float: + return v if abs(v) >= DiagnosticsConstants.DEADZONE_PERCENTAGE else 0.0 + + # Negate raw axis values: on most controllers, stick-up is negative ABS_Y, + # but semantic "forward" should be positive. + vx = -with_deadzone(self._get_abs_normalized(ecodes.ABS_Y)) + vy = -with_deadzone(self._get_abs_normalized(ecodes.ABS_X)) + vrot = -with_deadzone(self._get_abs_normalized(ecodes.ABS_RX)) + return vx, vy, vrot + + def get_speed_factor(self) -> float: + """Return 1.0 normally, or SPEED_SLOWDOWN_FACTOR when the left trigger is held. + + :return: the speed scaling factor + """ + trigger = self._get_abs_normalized(ecodes.ABS_Z) + return ( + DiagnosticsConstants.SPEED_SLOWDOWN_FACTOR + if trigger > DiagnosticsConstants.BUTTON_PRESSED_THRESHOLD + else 1.0 + ) + + def is_dribbler_held(self) -> bool: + """Return True if the right trigger is held past the button threshold. + + :return: true if the dribbler engage input is active + """ + return ( + self._get_abs_normalized(ecodes.ABS_RZ) + > DiagnosticsConstants.BUTTON_PRESSED_THRESHOLD + ) + + def get_kick_power_step(self) -> int: + """Return the kick/chip power step direction from the d-pad x axis. + Non-zero only on a new press (edge-detected). - :param key_code: the EV_KEY code of the key to check - :return: true if the key is currently pressed down, false otherwise + :return: -1, 0, or +1 """ - if key_code not in self.supported_keys: - return False + current = round(self._get_abs_normalized(ecodes.ABS_HAT0X)) + step = current if current != self._last_hat0x else 0 + self._last_hat0x = current + return step - return bool(self.input_values.get(key_code, 0)) + def get_dribbler_step(self) -> int: + """Return the dribbler RPM step direction from the d-pad y axis. + Non-zero only on a new press (edge-detected). Positive = increase RPM. - def abs_value(self, abs_code: int) -> float: - """Get the current value of an absolute axis input on the controller, - normalized to a range of [-1, 1]. + :return: -1, 0, or +1 + """ + current = round(self._get_abs_normalized(ecodes.ABS_HAT0Y)) + # Negate: d-pad up is HAT0Y=-1, but "increase dribbler" should be +1. + step = -current if current != self._last_hat0y else 0 + self._last_hat0y = current + return step - :param abs_code: the EV_ABS code of the axis to check - :return: the value of the axis input, normalized to [-1, 1] + def is_kick_fired(self) -> bool: + """Return True once on the rising edge of the kick button (BTN_A). + + :return: true if the kick button was just pressed + """ + current = bool(self.input_values.get(ecodes.BTN_A, 0)) + fired = current and not self._last_btn_a + self._last_btn_a = current + return fired + + def is_chip_fired(self) -> bool: + """Return True once on the rising edge of the chip button (BTN_B). + + :return: true if the chip button was just pressed + """ + current = bool(self.input_values.get(ecodes.BTN_B, 0)) + fired = current and not self._last_btn_b + self._last_btn_b = current + return fired + + def _get_abs_normalized(self, abs_code: int) -> float: + """Return the current value of an absolute axis, normalized to [-1, 1]. + + :param abs_code: the EV_ABS code of the axis to read + :return: normalized axis value in [-1, 1] """ if abs_code not in self.abs_info: - return 0 + return 0.0 value_min = self.abs_info[abs_code].min value_max = self.abs_info[abs_code].max @@ -76,13 +159,6 @@ def abs_value(self, abs_code: int) -> float: else: return numpy.interp(value, (value_min, 0), (-1, 0)) - def close(self) -> None: - """Close the connection to the controller.""" - if self.input_device is not None: - self.input_values.clear() - self.input_device.close() - self.input_device = None - def __read_input(self) -> None: """Endless loop that reads input events from the controller.""" try: diff --git a/src/software/thunderscope/robot_diagnostics/handheld_controller_widget.py b/src/software/thunderscope/robot_diagnostics/handheld_controller_widget.py index 2885a272ba..6c707143ea 100644 --- a/src/software/thunderscope/robot_diagnostics/handheld_controller_widget.py +++ b/src/software/thunderscope/robot_diagnostics/handheld_controller_widget.py @@ -3,7 +3,6 @@ # TODO: remove the try-catch when we rewrite this with macOS-compatible lib try: import evdev - from evdev import ecodes except ImportError: pass @@ -45,11 +44,6 @@ def __init__(self) -> None: self.handheld_controller: ControllerBase | None = None - self.last_d_pad_axis_x_value = 0 - self.last_d_pad_axis_y_value = 0 - self.last_btn_a_value = False - self.last_btn_b_value = False - self.motor_control = MotorControl() self.dribbler_speed = 0 self.kick_power = DiagnosticsConstants.MIN_KICK_POWER @@ -142,84 +136,54 @@ def __create_widgets(self) -> QGroupBox: return box def __read_controller_inputs(self) -> None: - """Read and interpret the current controller input values.""" - - def with_deadzone(abs_value: float) -> float: - return ( - abs_value - if abs(abs_value) >= DiagnosticsConstants.DEADZONE_PERCENTAGE - else 0 - ) - - move_axis_x = with_deadzone(self.handheld_controller.abs_value(ecodes.ABS_Y)) - move_axis_y = with_deadzone(self.handheld_controller.abs_value(ecodes.ABS_X)) - move_axis_rot = with_deadzone(self.handheld_controller.abs_value(ecodes.ABS_RX)) - - left_trigger_value = self.handheld_controller.abs_value(ecodes.ABS_Z) - speed_factor = ( - DiagnosticsConstants.SPEED_SLOWDOWN_FACTOR - if left_trigger_value > DiagnosticsConstants.BUTTON_PRESSED_THRESHOLD - else 1 - ) + """Read controller state and update motor control proto.""" + vx, vy, vrot = self.handheld_controller.get_move_velocity() + speed = self.handheld_controller.get_speed_factor() self.motor_control.direct_velocity_control.velocity.x_component_meters = ( - -move_axis_x * self.constants.robot_max_speed_m_per_s * speed_factor + vx * self.constants.robot_max_speed_m_per_s * speed ) self.motor_control.direct_velocity_control.velocity.y_component_meters = ( - -move_axis_y * self.constants.robot_max_speed_m_per_s * speed_factor + vy * self.constants.robot_max_speed_m_per_s * speed ) self.motor_control.direct_velocity_control.angular_velocity.radians_per_second = ( - -move_axis_rot * self.constants.robot_max_ang_speed_rad_per_s * speed_factor + vrot * self.constants.robot_max_ang_speed_rad_per_s * speed ) - d_pad_axis_x = self.handheld_controller.abs_value(ecodes.ABS_HAT0X) - d_pad_axis_y = self.handheld_controller.abs_value(ecodes.ABS_HAT0Y) - - if d_pad_axis_x != self.last_d_pad_axis_x_value: - self.last_d_pad_axis_x_value = d_pad_axis_x + kick_step = self.handheld_controller.get_kick_power_step() + if kick_step: self.kick_power = numpy.clip( - a=self.kick_power - + int(d_pad_axis_x) * DiagnosticsConstants.KICK_POWER_STEPPER, + a=self.kick_power + kick_step * DiagnosticsConstants.KICK_POWER_STEPPER, a_min=DiagnosticsConstants.MIN_KICK_POWER, a_max=DiagnosticsConstants.MAX_KICK_POWER, ) self.chip_distance = numpy.clip( a=self.chip_distance - + d_pad_axis_x * DiagnosticsConstants.CHIP_DISTANCE_STEPPER, + + kick_step * DiagnosticsConstants.CHIP_DISTANCE_STEPPER, a_min=DiagnosticsConstants.MIN_CHIP_POWER, a_max=DiagnosticsConstants.MAX_CHIP_POWER, ) - if d_pad_axis_y != self.last_d_pad_axis_y_value: - self.last_d_pad_axis_y_value = d_pad_axis_y + dribbler_step = self.handheld_controller.get_dribbler_step() + if dribbler_step: self.dribbler_speed = int( numpy.clip( a=self.dribbler_speed - - d_pad_axis_y * DiagnosticsConstants.DRIBBLER_RPM_STEPPER, + + dribbler_step * DiagnosticsConstants.DRIBBLER_RPM_STEPPER, a_min=self.constants.indefinite_dribbler_speed_rpm, a_max=-self.constants.indefinite_dribbler_speed_rpm, ) ) - right_trigger_value = self.handheld_controller.abs_value(ecodes.ABS_RZ) self.motor_control.dribbler_speed_rpm = ( - self.dribbler_speed - if right_trigger_value > DiagnosticsConstants.BUTTON_PRESSED_THRESHOLD - else 0 + self.dribbler_speed if self.handheld_controller.is_dribbler_held() else 0 ) - btn_a = self.handheld_controller.key_down(ecodes.BTN_A) - btn_b = self.handheld_controller.key_down(ecodes.BTN_B) - - if btn_a != self.last_btn_a_value: - self.last_btn_a_value = btn_a - if btn_a: - self.kick_button_pressed.emit() + if self.handheld_controller.is_kick_fired(): + self.kick_button_pressed.emit() - if btn_b != self.last_btn_b_value: - self.last_btn_b_value = btn_b - if btn_b: - self.chip_button_pressed.emit() + if self.handheld_controller.is_chip_fired(): + self.chip_button_pressed.emit() def __update_controller_status(self) -> None: """Update the widget to display the current controller connection status.""" diff --git a/src/software/thunderscope/robot_diagnostics/keyboard_controller.py b/src/software/thunderscope/robot_diagnostics/keyboard_controller.py index 580858fb8a..31be08f1d8 100644 --- a/src/software/thunderscope/robot_diagnostics/keyboard_controller.py +++ b/src/software/thunderscope/robot_diagnostics/keyboard_controller.py @@ -3,37 +3,9 @@ from pyqtgraph.Qt.QtCore import Qt, QObject, QEvent from pyqtgraph.Qt.QtWidgets import QApplication +from software.thunderscope.constants import DiagnosticsConstants from software.thunderscope.robot_diagnostics.controller_base import ControllerBase -# evdev-independent copies of the ecodes values used by HandheldControllerWidget -_ABS_X = 0 -_ABS_Y = 1 -_ABS_Z = 2 -_ABS_RX = 3 -_ABS_RZ = 5 -_ABS_HAT0X = 16 -_ABS_HAT0Y = 17 -_BTN_A = 304 -_BTN_B = 305 - -# Maps abs_code -> (negative_key, positive_key). -# A held negative key returns -1.0; a held positive key returns +1.0. -_ABS_KEY_MAP: dict[int, tuple[Qt.Key | None, Qt.Key | None]] = { - _ABS_Y: (Qt.Key.Key_W, Qt.Key.Key_S), # forward / back - _ABS_X: (Qt.Key.Key_A, Qt.Key.Key_D), # strafe left / right - _ABS_RX: (Qt.Key.Key_Q, Qt.Key.Key_E), # rotate CCW / CW - _ABS_Z: (None, Qt.Key.Key_Shift), # slowdown (left trigger) - _ABS_HAT0X: (Qt.Key.Key_Left, Qt.Key.Key_Right), # step kick power - _ABS_HAT0Y: (Qt.Key.Key_Up, Qt.Key.Key_Down), # step dribbler RPM - _ABS_RZ: (None, Qt.Key.Key_R), # dribbler hold (right trigger) -} - -# Maps key_code (ecodes int) -> Qt key for digital button inputs -_BTN_KEY_MAP: dict[int, Qt.Key] = { - _BTN_A: Qt.Key.Key_X, # kick - _BTN_B: Qt.Key.Key_C, # chip -} - class _QABCMeta(type(QObject), ABCMeta): pass @@ -44,12 +16,21 @@ class KeyboardController(QObject, ControllerBase, metaclass=_QABCMeta): Installs a QApplication-level event filter so key events are captured regardless of which widget currently has focus. + + Analog axes (move velocity, speed factor, dribbler hold) are driven by + held keys. Stepped inputs (kick/chip power, dribbler RPM) and one-shot + actions (kick, chip) are set as pending flags in the event filter and + consumed on the first read. """ def __init__(self) -> None: super().__init__() self._held_keys: set[Qt.Key] = set() self._active = True + self._pending_kick_power_step = 0 + self._pending_dribbler_step = 0 + self._pending_kick = False + self._pending_chip = False QApplication.instance().installEventFilter(self) def name(self) -> str: @@ -58,23 +39,6 @@ def name(self) -> str: def connected(self) -> bool: return self._active - def key_down(self, key_code: int) -> bool: - qt_key = _BTN_KEY_MAP.get(key_code) - if qt_key is None: - return False - return qt_key in self._held_keys - - def abs_value(self, abs_code: int) -> float: - key_pair = _ABS_KEY_MAP.get(abs_code) - if key_pair is None: - return 0.0 - neg_key, pos_key = key_pair - if neg_key is not None and neg_key in self._held_keys: - return -1.0 - if pos_key is not None and pos_key in self._held_keys: - return 1.0 - return 0.0 - def close(self) -> None: self._active = False self._held_keys.clear() @@ -82,9 +46,82 @@ def close(self) -> None: if app is not None: app.removeEventFilter(self) + def get_move_velocity(self) -> tuple[float, float, float]: + """Return (vx, vy, vrot) from held movement keys. + + :return: (vx, vy, vrot) where positive x = forward (W), positive y = strafe left (A), + positive angular = CCW (Q) + """ + + def axis(neg: Qt.Key, pos: Qt.Key) -> float: + if neg in self._held_keys: + return -1.0 + if pos in self._held_keys: + return 1.0 + return 0.0 + + return ( + axis(Qt.Key.Key_S, Qt.Key.Key_W), + axis(Qt.Key.Key_D, Qt.Key.Key_A), + axis(Qt.Key.Key_E, Qt.Key.Key_Q), + ) + + def get_speed_factor(self) -> float: + """Return SPEED_SLOWDOWN_FACTOR if Shift is held, otherwise 1.0.""" + return ( + DiagnosticsConstants.SPEED_SLOWDOWN_FACTOR + if Qt.Key.Key_Shift in self._held_keys + else 0.75 + ) + + def is_dribbler_held(self) -> bool: + """Return True if R is held.""" + return Qt.Key.Key_R in self._held_keys + + def get_kick_power_step(self) -> int: + """Consume and return the pending kick/chip power step (-1, 0, or +1). + Left arrow = -1, Right arrow = +1. + """ + step = self._pending_kick_power_step + self._pending_kick_power_step = 0 + return step + + def get_dribbler_step(self) -> int: + """Consume and return the pending dribbler RPM step (-1, 0, or +1). + Up arrow = +1 (increase), Down arrow = -1 (decrease). + """ + step = self._pending_dribbler_step + self._pending_dribbler_step = 0 + return step + + def is_kick_fired(self) -> bool: + """Consume and return whether a kick was pending (X key).""" + fired = self._pending_kick + self._pending_kick = False + return fired + + def is_chip_fired(self) -> bool: + """Consume and return whether a chip was pending (C key).""" + fired = self._pending_chip + self._pending_chip = False + return fired + def eventFilter(self, obj: QObject, event: QEvent) -> bool: if event.type() == QEvent.Type.KeyPress and not event.isAutoRepeat(): - self._held_keys.add(Qt.Key(event.key())) + key = Qt.Key(event.key()) + self._held_keys.add(key) + if key == Qt.Key.Key_Left: + self._pending_kick_power_step = -1 + elif key == Qt.Key.Key_Right: + self._pending_kick_power_step = 1 + elif key == Qt.Key.Key_Up: + self._pending_dribbler_step = 1 + elif key == Qt.Key.Key_Down: + self._pending_dribbler_step = -1 + elif key == Qt.Key.Key_X: + self._pending_kick = True + elif key == Qt.Key.Key_C: + self._pending_chip = True elif event.type() == QEvent.Type.KeyRelease and not event.isAutoRepeat(): self._held_keys.discard(Qt.Key(event.key())) return False From 037a706c44de57439ba71ce8d15ac7ec3aebdd0d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 03:12:36 +0000 Subject: [PATCH 04/14] [pre-commit.ci lite] apply automatic fixes --- src/software/thunderscope/robot_diagnostics/BUILD | 5 ++++- .../thunderscope/robot_diagnostics/controller_base.py | 9 ++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/software/thunderscope/robot_diagnostics/BUILD b/src/software/thunderscope/robot_diagnostics/BUILD index 2fa0d949c6..a2c93d290b 100644 --- a/src/software/thunderscope/robot_diagnostics/BUILD +++ b/src/software/thunderscope/robot_diagnostics/BUILD @@ -40,7 +40,10 @@ py_library( ":controller_base", "//software/thunderscope:constants", ] + select({ - "@platforms//os:linux": [requirement("evdev"), requirement("numpy")], + "@platforms//os:linux": [ + requirement("evdev"), + requirement("numpy"), + ], "//conditions:default": [requirement("numpy")], }), ) diff --git a/src/software/thunderscope/robot_diagnostics/controller_base.py b/src/software/thunderscope/robot_diagnostics/controller_base.py index 96ee529f1f..a759fbf33a 100644 --- a/src/software/thunderscope/robot_diagnostics/controller_base.py +++ b/src/software/thunderscope/robot_diagnostics/controller_base.py @@ -22,7 +22,8 @@ def close(self) -> None: @abstractmethod def get_move_velocity(self) -> tuple[float, float, float]: """Return (x, y, angular) velocity, each normalized to [-1, 1] with deadzone applied. - Positive x = forward, positive y = strafe left, positive angular = CCW.""" + Positive x = forward, positive y = strafe left, positive angular = CCW. + """ ... @abstractmethod @@ -38,13 +39,15 @@ def is_dribbler_held(self) -> bool: @abstractmethod def get_kick_power_step(self) -> int: """Return -1, 0, or +1 for kick/chip power step direction. - Non-zero only once per new input (edge-detected).""" + Non-zero only once per new input (edge-detected). + """ ... @abstractmethod def get_dribbler_step(self) -> int: """Return -1, 0, or +1 for dribbler RPM step direction. - Non-zero only once per new input (edge-detected).""" + Non-zero only once per new input (edge-detected). + """ ... @abstractmethod From eadc24d04f952e3a37182970d1115665d864ab82 Mon Sep 17 00:00:00 2001 From: Grayson Date: Mon, 29 Jun 2026 23:06:59 -0700 Subject: [PATCH 05/14] pygame --- src/software/thunderscope/BUILD | 1 - src/software/thunderscope/constants.py | 9 -- src/software/thunderscope/requirements.in | 2 +- .../thunderscope/requirements_lock.darwin.txt | 132 ---------------- .../thunderscope/robot_diagnostics/BUILD | 15 +- .../robot_diagnostics/controller_base.py | 5 + .../robot_diagnostics/handheld_controller.py | 149 +++++++++--------- .../handheld_controller_widget.py | 22 +-- 8 files changed, 93 insertions(+), 242 deletions(-) delete mode 100644 src/software/thunderscope/requirements_lock.darwin.txt diff --git a/src/software/thunderscope/BUILD b/src/software/thunderscope/BUILD index 769f9781f2..5d8fb1b7e0 100644 --- a/src/software/thunderscope/BUILD +++ b/src/software/thunderscope/BUILD @@ -6,7 +6,6 @@ package(default_visibility = ["//visibility:public"]) compile_pip_requirements( name = "requirements", src = "requirements.in", - requirements_darwin = "requirements_lock.darwin.txt", requirements_txt = "requirements_lock.txt", ) diff --git a/src/software/thunderscope/constants.py b/src/software/thunderscope/constants.py index 073dd2a95b..fb06901fbf 100644 --- a/src/software/thunderscope/constants.py +++ b/src/software/thunderscope/constants.py @@ -327,15 +327,6 @@ class TrailValues: class DiagnosticsConstants: """Constants for Robot Diagnostics""" - # Device names of the controllers supported for controlling robots - SUPPORTED_CONTROLLERS = { - "Microsoft Xbox One X pad", - "Microsoft X-Box One S pad", - "Microsoft X-Box 360 pad", - "Microsoft Xbox 360 pad", - "Generic X-Box pad", - } - BUTTON_PRESSED_THRESHOLD = 0.5 DEADZONE_PERCENTAGE = 0.20 diff --git a/src/software/thunderscope/requirements.in b/src/software/thunderscope/requirements.in index 18c98bc70f..f7813b2d66 100644 --- a/src/software/thunderscope/requirements.in +++ b/src/software/thunderscope/requirements.in @@ -1,8 +1,8 @@ colorama==0.4.6 netifaces==0.11.0 -evdev==1.7.0; sys_platform == "linux" numpy==1.26.4 protobuf==6.31.1 +pygame==2.6.1 pyqtgraph==0.13.7 pyqtdarktheme-fork==2.3.2 PyQt6-Qt6==6.8.1 diff --git a/src/software/thunderscope/requirements_lock.darwin.txt b/src/software/thunderscope/requirements_lock.darwin.txt deleted file mode 100644 index 55045c79c5..0000000000 --- a/src/software/thunderscope/requirements_lock.darwin.txt +++ /dev/null @@ -1,132 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.12 -# by the following command: -# -# bazel run //software/thunderscope:requirements.update -# -colorama==0.4.6 \ - --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ - --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 - # via -r software/thunderscope/requirements.in -darkdetect==0.7.1 \ - --hash=sha256:3efe69f8ecd5f1b7f4fbb0d1d93f656b0e493c45cc49222380ffe2a529cbc866 \ - --hash=sha256:47be3cf5134432ddb616bbffc927237718407914993c82809983e7ccebf49013 - # via pyqtdarktheme-fork -netifaces==0.11.0 \ - --hash=sha256:043a79146eb2907edf439899f262b3dfe41717d34124298ed281139a8b93ca32 \ - --hash=sha256:08e3f102a59f9eaef70948340aeb6c89bd09734e0dca0f3b82720305729f63ea \ - --hash=sha256:0f6133ac02521270d9f7c490f0c8c60638ff4aec8338efeff10a1b51506abe85 \ - --hash=sha256:18917fbbdcb2d4f897153c5ddbb56b31fa6dd7c3fa9608b7e3c3a663df8206b5 \ - --hash=sha256:2479bb4bb50968089a7c045f24d120f37026d7e802ec134c4490eae994c729b5 \ - --hash=sha256:2650beee182fed66617e18474b943e72e52f10a24dc8cac1db36c41ee9c041b7 \ - --hash=sha256:28f4bf3a1361ab3ed93c5ef360c8b7d4a4ae060176a3529e72e5e4ffc4afd8b0 \ - --hash=sha256:3ecb3f37c31d5d51d2a4d935cfa81c9bc956687c6f5237021b36d6fdc2815b2c \ - --hash=sha256:469fc61034f3daf095e02f9f1bbac07927b826c76b745207287bc594884cfd05 \ - --hash=sha256:48324183af7f1bc44f5f197f3dad54a809ad1ef0c78baee2c88f16a5de02c4c9 \ - --hash=sha256:50721858c935a76b83dd0dd1ab472cad0a3ef540a1408057624604002fcfb45b \ - --hash=sha256:54ff6624eb95b8a07e79aa8817288659af174e954cca24cdb0daeeddfc03c4ff \ - --hash=sha256:5be83986100ed1fdfa78f11ccff9e4757297735ac17391b95e17e74335c2047d \ - --hash=sha256:5f9ca13babe4d845e400921973f6165a4c2f9f3379c7abfc7478160e25d196a4 \ - --hash=sha256:73ff21559675150d31deea8f1f8d7e9a9a7e4688732a94d71327082f517fc6b4 \ - --hash=sha256:7dbb71ea26d304e78ccccf6faccef71bb27ea35e259fb883cfd7fd7b4f17ecb1 \ - --hash=sha256:815eafdf8b8f2e61370afc6add6194bd5a7252ae44c667e96c4c1ecf418811e4 \ - --hash=sha256:841aa21110a20dc1621e3dd9f922c64ca64dd1eb213c47267a2c324d823f6c8f \ - --hash=sha256:84e4d2e6973eccc52778735befc01638498781ce0e39aa2044ccfd2385c03246 \ - --hash=sha256:8f7da24eab0d4184715d96208b38d373fd15c37b0dafb74756c638bd619ba150 \ - --hash=sha256:96c0fe9696398253f93482c84814f0e7290eee0bfec11563bd07d80d701280c3 \ - --hash=sha256:aab1dbfdc55086c789f0eb37affccf47b895b98d490738b81f3b2360100426be \ - --hash=sha256:c03fb2d4ef4e393f2e6ffc6376410a22a3544f164b336b3a355226653e5efd89 \ - --hash=sha256:c37a1ca83825bc6f54dddf5277e9c65dec2f1b4d0ba44b8fd42bc30c91aa6ea1 \ - --hash=sha256:c92ff9ac7c2282009fe0dcb67ee3cd17978cffbe0c8f4b471c00fe4325c9b4d4 \ - --hash=sha256:c9a3a47cd3aaeb71e93e681d9816c56406ed755b9442e981b07e3618fb71d2ac \ - --hash=sha256:cb925e1ca024d6f9b4f9b01d83215fd00fe69d095d0255ff3f64bffda74025c8 \ - --hash=sha256:d07b01c51b0b6ceb0f09fc48ec58debd99d2c8430b09e56651addeaf5de48048 \ - --hash=sha256:e76c7f351e0444721e85f975ae92718e21c1f361bda946d60a214061de1f00a1 \ - --hash=sha256:eb4813b77d5df99903af4757ce980a98c4d702bbcb81f32a0b305a1537bdf0b1 - # via -r software/thunderscope/requirements.in -numpy==1.26.4 \ - --hash=sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b \ - --hash=sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818 \ - --hash=sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20 \ - --hash=sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0 \ - --hash=sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010 \ - --hash=sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a \ - --hash=sha256:3373d5d70a5fe74a2c1bb6d2cfd9609ecf686d47a2d7b1d37a8f3b6bf6003aea \ - --hash=sha256:47711010ad8555514b434df65f7d7b076bb8261df1ca9bb78f53d3b2db02e95c \ - --hash=sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71 \ - --hash=sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110 \ - --hash=sha256:52b8b60467cd7dd1e9ed082188b4e6bb35aa5cdd01777621a1658910745b90be \ - --hash=sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a \ - --hash=sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a \ - --hash=sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5 \ - --hash=sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed \ - --hash=sha256:679b0076f67ecc0138fd2ede3a8fd196dddc2ad3254069bcb9faf9a79b1cebcd \ - --hash=sha256:7349ab0fa0c429c82442a27a9673fc802ffdb7c7775fad780226cb234965e53c \ - --hash=sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e \ - --hash=sha256:7e50d0a0cc3189f9cb0aeb3a6a6af18c16f59f004b866cd2be1c14b36134a4a0 \ - --hash=sha256:95a7476c59002f2f6c590b9b7b998306fba6a5aa646b1e22ddfeaf8f78c3a29c \ - --hash=sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a \ - --hash=sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b \ - --hash=sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0 \ - --hash=sha256:a354325ee03388678242a4d7ebcd08b5c727033fcff3b2f536aea978e15ee9e6 \ - --hash=sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2 \ - --hash=sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a \ - --hash=sha256:afedb719a9dcfc7eaf2287b839d8198e06dcd4cb5d276a3df279231138e83d30 \ - --hash=sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218 \ - --hash=sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5 \ - --hash=sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07 \ - --hash=sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2 \ - --hash=sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4 \ - --hash=sha256:d5241e0a80d808d70546c697135da2c613f30e28251ff8307eb72ba696945764 \ - --hash=sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef \ - --hash=sha256:f870204a840a60da0b12273ef34f7051e98c3b5961b61b0c2c1be6dfd64fbcd3 \ - --hash=sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f - # via - # -r software/thunderscope/requirements.in - # pyqtgraph -packaging==24.2 \ - --hash=sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759 \ - --hash=sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f - # via qtpy -protobuf==6.31.1 \ - --hash=sha256:0414e3aa5a5f3ff423828e1e6a6e907d6c65c1d5b7e6e975793d5590bdeecc16 \ - --hash=sha256:426f59d2964864a1a366254fa703b8632dcec0790d8862d30034d8245e1cd447 \ - --hash=sha256:4ee898bf66f7a8b0bd21bce523814e6fbd8c6add948045ce958b73af7e8878c6 \ - --hash=sha256:6f1227473dc43d44ed644425268eb7c2e488ae245d51c6866d19fe158e207402 \ - --hash=sha256:720a6c7e6b77288b85063569baae8536671b39f15cc22037ec7045658d80489e \ - --hash=sha256:7fa17d5a29c2e04b7d90e5e32388b8bfd0e7107cd8e616feef7ed3fa6bdab5c9 \ - --hash=sha256:8764cf4587791e7564051b35524b72844f845ad0bb011704c3736cce762d8fe9 \ - --hash=sha256:a40fc12b84c154884d7d4c4ebd675d5b3b5283e155f324049ae396b95ddebc39 \ - --hash=sha256:d8cac4c982f0b957a4dc73a80e2ea24fab08e679c0de9deb835f4a12d69aca9a - # via -r software/thunderscope/requirements.in -pyqt-toast-notification==1.3.2 \ - --hash=sha256:135736ec0f16bff41104dee3c60ac318e5d55ae3378bf26892c6d08c36088ae6 \ - --hash=sha256:82688101202737736d51ab6c74a573b32266ecb7c8b0002f913407bd369737d9 - # via -r software/thunderscope/requirements.in -pyqt6-qt6==6.8.1 \ - --hash=sha256:006d786693d0511fbcf184a862edbd339c6ed1bb3bd9de363d73a19ed4b23dff \ - --hash=sha256:08065d595f1e6fc2dde9f4450eeff89082f4bad26f600a8e9b9cc5966716bfcf \ - --hash=sha256:1eb8460a1fdb38d0b2458c2974c01d471c1e59e4eb19ea63fc447aaba3ad530e \ - --hash=sha256:20843cb86bd94942d1cd99e39bf1aeabb875b241a35a8ab273e4bbbfa63776db \ - --hash=sha256:9f3790c4ce4dc576e48b8718d55fb8743057e6cbd53a6ca1dd253ffbac9b7287 \ - --hash=sha256:a8bc2ed4ee5e7c6ff4dd1c7db0b27705d151fee5dc232bbd1bf17618f937f515 \ - --hash=sha256:d6ca5d2b9d2ec0ee4a814b2175f641a5c4299cb80b45e0f5f8356632663f89b3 - # via -r software/thunderscope/requirements.in -pyqtdarktheme-fork==2.3.2 \ - --hash=sha256:3ea94fed5df262d960378409357c63032639f749794d766f41a45ad8558b2523 \ - --hash=sha256:d96ee64f0884678fad9b6bc352d5e37d84ca786fa60ed32ffaa7e6c6bc67e964 - # via -r software/thunderscope/requirements.in -pyqtgraph==0.13.7 \ - --hash=sha256:64f84f1935c6996d0e09b1ee66fe478a7771e3ca6f3aaa05f00f6e068321d9e3 \ - --hash=sha256:7754edbefb6c367fa0dfb176e2d0610da3ada20aa7a5318516c74af5fb72bf7a - # via -r software/thunderscope/requirements.in -qtawesome==1.4.0 \ - --hash=sha256:783e414d1317f3e978bf67ea8e8a1b1498bad9dbd305dec814027e3b50521be6 \ - --hash=sha256:a4d689fa071c595aa6184171ce1f0f847677cb8d2db45382c43129f1d72a3d93 - # via -r software/thunderscope/requirements.in -qtpy==2.4.2 \ - --hash=sha256:5a696b1dd7a354cb330657da1d17c20c2190c72d4888ba923f8461da67aa1a1c \ - --hash=sha256:9d6ec91a587cc1495eaebd23130f7619afa5cdd34a277acb87735b4ad7c65156 - # via - # pyqt-toast-notification - # qtawesome diff --git a/src/software/thunderscope/robot_diagnostics/BUILD b/src/software/thunderscope/robot_diagnostics/BUILD index 2fa0d949c6..79937cdd63 100644 --- a/src/software/thunderscope/robot_diagnostics/BUILD +++ b/src/software/thunderscope/robot_diagnostics/BUILD @@ -39,10 +39,8 @@ py_library( deps = [ ":controller_base", "//software/thunderscope:constants", - ] + select({ - "@platforms//os:linux": [requirement("evdev"), requirement("numpy")], - "//conditions:default": [requirement("numpy")], - }), + requirement("pygame"), + ], ) py_library( @@ -54,17 +52,12 @@ py_library( name = "handheld_controller_widget", srcs = ["handheld_controller_widget.py"], deps = [ - "keyboard_controller", + ":keyboard_controller", ":controller_base", ":handheld_controller", "//software/thunderscope:constants", requirement("pyqtgraph"), - ] + select({ - # TODO: remove this selection when we replace evdev to - # other macos supported libs. - "@platforms//os:linux": [requirement("evdev")], - "//conditions:default": [], - }), + ], ) py_library( diff --git a/src/software/thunderscope/robot_diagnostics/controller_base.py b/src/software/thunderscope/robot_diagnostics/controller_base.py index 96ee529f1f..a8cbf304a3 100644 --- a/src/software/thunderscope/robot_diagnostics/controller_base.py +++ b/src/software/thunderscope/robot_diagnostics/controller_base.py @@ -19,6 +19,11 @@ def close(self) -> None: """Release any resources held by the input source.""" ... + def update(self) -> None: + """Refresh controller input state. Called once per frame before reading inputs. + Override for polled backends; push-based controllers can leave this as a no-op.""" + pass + @abstractmethod def get_move_velocity(self) -> tuple[float, float, float]: """Return (x, y, angular) velocity, each normalized to [-1, 1] with deadzone applied. diff --git a/src/software/thunderscope/robot_diagnostics/handheld_controller.py b/src/software/thunderscope/robot_diagnostics/handheld_controller.py index 6e815ca3a4..1409934c71 100644 --- a/src/software/thunderscope/robot_diagnostics/handheld_controller.py +++ b/src/software/thunderscope/robot_diagnostics/handheld_controller.py @@ -1,63 +1,82 @@ -import numpy +import os -# TODO: remove the try-catch when we rewrite this with macOS-compatible lib -try: - from evdev import InputDevice, ecodes -except ImportError: - pass +# Suppress the pygame "Hello from the pygame community" banner on import +os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "1" -from threading import Thread +import pygame +from pygame._sdl2 import controller from software.thunderscope.constants import DiagnosticsConstants from software.thunderscope.robot_diagnostics.controller_base import ControllerBase class HandheldController(ControllerBase): - """Represents a handheld game controller or input device that can be used - to manually control our robots. + """Represents a handheld game controller (e.g. an Xbox gamepad) that can be + used to manually control our robots. + + Backed by SDL's game-controller API (via pygame), which recognizes controllers + through its controller database and exposes a standardized button/axis layout. + Controllers are read in poll mode — call update() each frame before reading inputs. """ - def __init__(self, path: str): - """Initialize a HandheldController that reads input events from - the specified device path. + # Raw SDL axis values span the signed 16-bit range. + _AXIS_MAX_VALUE = 32767 - :param path: the device input path - """ - self.input_device = InputDevice(path) - self.input_values: dict[int, float] = {} + def __init__(self, controller_index: int): + """Open the game controller at the given SDL device index. - capabilities = self.input_device.capabilities() - self.supported_keys = set(capabilities[ecodes.EV_KEY]) - self.abs_info = dict(capabilities[ecodes.EV_ABS]) + :param controller_index: the SDL device index of the controller + """ + self.controller = controller.Controller(controller_index) - self._last_hat0x = 0 - self._last_hat0y = 0 + self._last_dpad_x = 0 + self._last_dpad_y = 0 self._last_btn_a = False self._last_btn_b = False - self.thread = Thread(target=self.__read_input, daemon=True) - self.thread.start() + @classmethod + def detect(cls) -> "HandheldController | None": + """Scan the currently connected devices for a supported game controller. + + :return: a HandheldController for the first recognized controller, or + None if none is connected + """ + controller.init() + # Poll mode: refresh state via update() instead of the SDL event queue, + # so no display/event subsystem is required. + controller.set_eventstate(False) + controller.update() + + for index in range(controller.get_count()): + if controller.is_controller(index): + return cls(index) + + return None def name(self) -> str: """Get the device name of the controller. :return: the device name """ - return self.input_device.name + return self.controller.name def connected(self) -> bool: """Check whether the controller is currently connected. :return: true if the controller is connected, false otherwise """ - return self.input_device is not None + return self.controller.attached() + + def update(self) -> None: + """Refresh the controller's input state from SDL. + + Must be called once per frame before reading any input values. + """ + controller.update() def close(self) -> None: """Close the connection to the controller.""" - if self.input_device is not None: - self.input_values.clear() - self.input_device.close() - self.input_device = None + self.controller.quit() def get_move_velocity(self) -> tuple[float, float, float]: """Return (x, y, angular) velocity normalized to [-1, 1] with deadzone applied. @@ -69,11 +88,10 @@ def get_move_velocity(self) -> tuple[float, float, float]: def with_deadzone(v: float) -> float: return v if abs(v) >= DiagnosticsConstants.DEADZONE_PERCENTAGE else 0.0 - # Negate raw axis values: on most controllers, stick-up is negative ABS_Y, - # but semantic "forward" should be positive. - vx = -with_deadzone(self._get_abs_normalized(ecodes.ABS_Y)) - vy = -with_deadzone(self._get_abs_normalized(ecodes.ABS_X)) - vrot = -with_deadzone(self._get_abs_normalized(ecodes.ABS_RX)) + # Negate: SDL stick-up is negative LEFTY, but semantic "forward" = +vx. + vx = -with_deadzone(self._get_abs_normalized(pygame.CONTROLLER_AXIS_LEFTY)) + vy = -with_deadzone(self._get_abs_normalized(pygame.CONTROLLER_AXIS_LEFTX)) + vrot = -with_deadzone(self._get_abs_normalized(pygame.CONTROLLER_AXIS_RIGHTX)) return vx, vy, vrot def get_speed_factor(self) -> float: @@ -81,7 +99,7 @@ def get_speed_factor(self) -> float: :return: the speed scaling factor """ - trigger = self._get_abs_normalized(ecodes.ABS_Z) + trigger = self._get_abs_normalized(pygame.CONTROLLER_AXIS_TRIGGERLEFT) return ( DiagnosticsConstants.SPEED_SLOWDOWN_FACTOR if trigger > DiagnosticsConstants.BUTTON_PRESSED_THRESHOLD @@ -94,76 +112,63 @@ def is_dribbler_held(self) -> bool: :return: true if the dribbler engage input is active """ return ( - self._get_abs_normalized(ecodes.ABS_RZ) + self._get_abs_normalized(pygame.CONTROLLER_AXIS_TRIGGERRIGHT) > DiagnosticsConstants.BUTTON_PRESSED_THRESHOLD ) def get_kick_power_step(self) -> int: - """Return the kick/chip power step direction from the d-pad x axis. + """Return the kick/chip power step direction from the d-pad x buttons. Non-zero only on a new press (edge-detected). :return: -1, 0, or +1 """ - current = round(self._get_abs_normalized(ecodes.ABS_HAT0X)) - step = current if current != self._last_hat0x else 0 - self._last_hat0x = current + current = int( + self.controller.get_button(pygame.CONTROLLER_BUTTON_DPAD_RIGHT) + ) - int(self.controller.get_button(pygame.CONTROLLER_BUTTON_DPAD_LEFT)) + step = current if current != self._last_dpad_x else 0 + self._last_dpad_x = current return step def get_dribbler_step(self) -> int: - """Return the dribbler RPM step direction from the d-pad y axis. + """Return the dribbler RPM step direction from the d-pad y buttons. Non-zero only on a new press (edge-detected). Positive = increase RPM. :return: -1, 0, or +1 """ - current = round(self._get_abs_normalized(ecodes.ABS_HAT0Y)) - # Negate: d-pad up is HAT0Y=-1, but "increase dribbler" should be +1. - step = -current if current != self._last_hat0y else 0 - self._last_hat0y = current + current = int( + self.controller.get_button(pygame.CONTROLLER_BUTTON_DPAD_DOWN) + ) - int(self.controller.get_button(pygame.CONTROLLER_BUTTON_DPAD_UP)) + # Negate: down = +1 raw → decrease dribbler → -1; up = -1 raw → increase → +1. + step = -current if current != self._last_dpad_y else 0 + self._last_dpad_y = current return step def is_kick_fired(self) -> bool: - """Return True once on the rising edge of the kick button (BTN_A). + """Return True once on the rising edge of the kick button (A). :return: true if the kick button was just pressed """ - current = bool(self.input_values.get(ecodes.BTN_A, 0)) + current = bool(self.controller.get_button(pygame.CONTROLLER_BUTTON_A)) fired = current and not self._last_btn_a self._last_btn_a = current return fired def is_chip_fired(self) -> bool: - """Return True once on the rising edge of the chip button (BTN_B). + """Return True once on the rising edge of the chip button (B). :return: true if the chip button was just pressed """ - current = bool(self.input_values.get(ecodes.BTN_B, 0)) + current = bool(self.controller.get_button(pygame.CONTROLLER_BUTTON_B)) fired = current and not self._last_btn_b self._last_btn_b = current return fired - def _get_abs_normalized(self, abs_code: int) -> float: - """Return the current value of an absolute axis, normalized to [-1, 1]. + def _get_abs_normalized(self, axis: int) -> float: + """Return the current value of an axis, normalized to [-1, 1]. - :param abs_code: the EV_ABS code of the axis to read + :param axis: the pygame.CONTROLLER_AXIS_* code of the axis to read :return: normalized axis value in [-1, 1] """ - if abs_code not in self.abs_info: - return 0.0 - - value_min = self.abs_info[abs_code].min - value_max = self.abs_info[abs_code].max - value = self.input_values.get(abs_code, 0) - - if value >= 0: - return numpy.interp(value, (0, value_max), (0, 1)) - else: - return numpy.interp(value, (value_min, 0), (-1, 0)) - - def __read_input(self) -> None: - """Endless loop that reads input events from the controller.""" - try: - for event in self.input_device.read_loop(): - if event.type == ecodes.EV_KEY or event.type == ecodes.EV_ABS: - self.input_values[event.code] = event.value - except: - self.close() + return max( + -1.0, min(1.0, self.controller.get_axis(axis) / self._AXIS_MAX_VALUE) + ) diff --git a/src/software/thunderscope/robot_diagnostics/handheld_controller_widget.py b/src/software/thunderscope/robot_diagnostics/handheld_controller_widget.py index 6c707143ea..5eb8cbc99d 100644 --- a/src/software/thunderscope/robot_diagnostics/handheld_controller_widget.py +++ b/src/software/thunderscope/robot_diagnostics/handheld_controller_widget.py @@ -1,11 +1,5 @@ import numpy -# TODO: remove the try-catch when we rewrite this with macOS-compatible lib -try: - import evdev -except ImportError: - pass - from proto.import_all_protos import * from pyqtgraph.Qt.QtWidgets import * from pyqtgraph.Qt import QtCore @@ -56,20 +50,13 @@ def __init__(self) -> None: self.detect_controller() def detect_controller(self) -> None: - """Scan through the list of currently connected devices for a supported - handheld controller and, if one is found, set it as the device to accept + """Scan the currently connected devices for a supported handheld + controller and, if one is found, set it as the device to accept controller inputs from. """ if self.handheld_controller is not None: self.handheld_controller.close() - self.handheld_controller = None - - for path in evdev.list_devices(): - device = evdev.InputDevice(path) - if device.name in DiagnosticsConstants.SUPPORTED_CONTROLLERS: - self.handheld_controller = HandheldController(path) - break - + self.handheld_controller = HandheldController.detect() self.__update_controller_status() def use_keyboard_controller(self) -> None: @@ -90,7 +77,10 @@ def refresh(self) -> None: if self.handheld_controller is None: return + self.handheld_controller.update() + if not self.handheld_controller.connected(): + self.handheld_controller.close() self.handheld_controller = None self.__update_controller_status() return From 733c4567503f2450c9d2f4b443d9061d65603b4e Mon Sep 17 00:00:00 2001 From: Grayson Date: Tue, 30 Jun 2026 21:25:24 -0700 Subject: [PATCH 06/14] requirementslock --- .../thunderscope/requirements_lock.txt | 65 ++++++++++++++++++- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/src/software/thunderscope/requirements_lock.txt b/src/software/thunderscope/requirements_lock.txt index 1b4d0632ee..38a3b05b3c 100644 --- a/src/software/thunderscope/requirements_lock.txt +++ b/src/software/thunderscope/requirements_lock.txt @@ -12,9 +12,6 @@ darkdetect==0.7.1 \ --hash=sha256:3efe69f8ecd5f1b7f4fbb0d1d93f656b0e493c45cc49222380ffe2a529cbc866 \ --hash=sha256:47be3cf5134432ddb616bbffc927237718407914993c82809983e7ccebf49013 # via pyqtdarktheme-fork -evdev==1.7.0 ; sys_platform == "linux" \ - --hash=sha256:95bd2a1e0c6ce2cd7a2ecc6e6cd9736ff794b3ad5cb54d81d8cbc2e414d0b870 - # via -r software/thunderscope/requirements.in netifaces==0.11.0 \ --hash=sha256:043a79146eb2907edf439899f262b3dfe41717d34124298ed281139a8b93ca32 \ --hash=sha256:08e3f102a59f9eaef70948340aeb6c89bd09734e0dca0f3b82720305729f63ea \ @@ -102,6 +99,68 @@ protobuf==6.31.1 \ --hash=sha256:a40fc12b84c154884d7d4c4ebd675d5b3b5283e155f324049ae396b95ddebc39 \ --hash=sha256:d8cac4c982f0b957a4dc73a80e2ea24fab08e679c0de9deb835f4a12d69aca9a # via -r software/thunderscope/requirements.in +pygame==2.6.1 \ + --hash=sha256:00827aba089355925902d533f9c41e79a799641f03746c50a374dc5c3362e43d \ + --hash=sha256:10e3d2a55f001f6c0a6eb44aa79ea7607091c9352b946692acedb2ac1482f1c9 \ + --hash=sha256:1206125f14cae22c44565c9d333607f1d9f59487b1f1432945dfc809aeaa3e88 \ + --hash=sha256:14f9dda45469b254c0f15edaaeaa85d2cc072ff6a83584a265f5d684c7f7efd8 \ + --hash=sha256:15efaa11a80a65dd589a95bebe812fa5bfc7e14946b638a424c5bd9ac6cca1a4 \ + --hash=sha256:163e66de169bd5670c86e27d0b74aad0d2d745e3b63cf4e7eb5b2bff1231ca8d \ + --hash=sha256:173badf82fa198e6888017bea40f511cb28e69ecdd5a72b214e81e4dcd66c3b1 \ + --hash=sha256:17498a2b043bc0e795faedef1b081199c688890200aef34991c1941caa2d2c89 \ + --hash=sha256:20349195326a5e82a16e351ed93465a7845a7e2a9af55b7bc1b2110ea3e344e1 \ + --hash=sha256:21160d9093533eb831f1b708e630706e5ac16b30750571ec27bc3b8364814f38 \ + --hash=sha256:27eb17e3dc9640e4b4683074f1890e2e879827447770470c2aba9f125f74510b \ + --hash=sha256:28b43190436037e428a5be28fc80cf6615304fd528009f2c688cc828f4ff104b \ + --hash=sha256:2a3a1288e2e9b1e5834e425bedd5ba01a3cd4902b5c2bff8ed4a740ccfe98171 \ + --hash=sha256:2a615d78b2364e86f541458ff41c2a46181b9a1e9eabd97b389282fdf04efbb3 \ + --hash=sha256:325a84d072d52e3c2921eff02f87c6a74b7e77d71db3bdf53801c6c975f1b6c4 \ + --hash=sha256:33006f784e1c7d7e466fcb61d5489da59cc5f7eb098712f792a225df1d4e229d \ + --hash=sha256:3a9e7396be0d9633831c3f8d5d82dd63ba373ad65599628294b7a4f8a5a01a65 \ + --hash=sha256:3acd8c009317190c2bfd81db681ecef47d5eb108c2151d09596d9c7ea9df5c0e \ + --hash=sha256:3bede70ec708057e305815d6546012669226d1d80566785feca9b044216062e7 \ + --hash=sha256:481cfe1bdbb7fe00acc5950c494c26f00240888619bdc396fc8c39a734797432 \ + --hash=sha256:4a8ea113b1bf627322a025a1a5a87e3818a7f55ab3a4077ff1ae5c8c60576614 \ + --hash=sha256:4c1623180e70a03c4a734deb9bac50fc9c82942ae84a3a220779062128e75f3b \ + --hash=sha256:4ee7f2771f588c966fa2fa8b829be26698c9b4836f82ede5e4edc1a68594942e \ + --hash=sha256:56fb02ead529cee00d415c3e007f75e0780c655909aaa8e8bf616ee09c9feb1f \ + --hash=sha256:56ffca6059b165bbf64f4b4be23b8068f6a0e220780e4f96ec0bb5ac3c63ec39 \ + --hash=sha256:5d09fd950725d187aa5207c0cb8eb9ab0d2f8ce9ab8d189c30eeb470e71b617e \ + --hash=sha256:6582aa71a681e02e55d43150a9ab41394e6bf4d783d2962a10aea58f424be060 \ + --hash=sha256:7103c60939bbc1e05cfc7ba3f1d2ad3bbf103b7828b82a7166a9ab6f51950146 \ + --hash=sha256:7bffdd3eaf394d9645331d1c3a5df9d782ebcc3c5a78f3b657c7879a828dd111 \ + --hash=sha256:811e7b925146d8149d79193652cbb83e0eca0aae66476b1cb310f0f4226b8b5c \ + --hash=sha256:813af4fba5d0b2cb8e58f5d95f7910295c34067dcc290d34f1be59c48bd1ea6a \ + --hash=sha256:816e85000c5d8b02a42b9834f761a5925ef3377d2924e3a7c4c143d2990ce5b8 \ + --hash=sha256:818b4eaec9c4acb6ac64805d4ca8edd4062bebca77bd815c18739fe2842c97e9 \ + --hash=sha256:84fc4054e25262140d09d39e094f6880d730199710829902f0d8ceae0213379e \ + --hash=sha256:8a78fd030d98faab4a8e27878536fdff7518d3e062a72761c552f624ebba5a5f \ + --hash=sha256:91476902426facd4bb0dad4dc3b2573bc82c95c71b135e0daaea072ed528d299 \ + --hash=sha256:94afd1177680d92f9214c54966ad3517d18210c4fbc5d84a0192d218e93647e0 \ + --hash=sha256:97ac4e13847b6b293ecaffa5ffce9886c98d09c03309406931cc592f0cea6366 \ + --hash=sha256:9beeb647e555afb5657111fa83acb74b99ad88761108eaea66472e8b8547b55b \ + --hash=sha256:9dd5c054d4bd875a8caf978b82672f02bec332f52a833a76899220c460bb4b58 \ + --hash=sha256:a1bf7ab5311bbced70320f1a56701650b4c18231343ae5af42111eea91e0949a \ + --hash=sha256:a4b8f04fceddd9a3ac30778d11f0254f59efcd1c382d5801271113cea8b4f2f3 \ + --hash=sha256:a620883d589926f157b8f1d1f543183ac52e5c30507dea445e3927ae0bee1c54 \ + --hash=sha256:ac3f033d2be4a9e23660a96afe2986df3a6916227538a6a0061bc218c5088507 \ + --hash=sha256:ae6039f3a55d800db80e8010f387557b528d34d534435e0871326804df2a62f2 \ + --hash=sha256:b46e68cd168f44d0224c670bb72186688fc692d7079715f79d04096757d703d0 \ + --hash=sha256:b7f9f8e6f76de36f4725175d686601214af362a4f30614b4dae2240198e72e6f \ + --hash=sha256:bbb7167c92103a2091366e9af26d4914ba3776666e8677d3c93551353fffa626 \ + --hash=sha256:c0b11356ac96261162d54a2c2b41a41978f00525631b01ec9c4fe26b01c66595 \ + --hash=sha256:c31dbdb5d0217f32764797d21c2752e258e5fb7e895326538d82b5f75a0cd856 \ + --hash=sha256:c47a6938de93fa610accd4969e638c2aebcb29b2fca518a84c3a39d91ab47116 \ + --hash=sha256:c8040ea2ab18c6b255af706ec01355c8a6b08dc48d77fd4ee783f8fc46a843bf \ + --hash=sha256:ce8cc108b92de9b149b344ad2e25eedbe773af0dc41dfb24d1f07f679b558c60 \ + --hash=sha256:d1a7f2b66ac2e4c9583b6d4c6d6f346fb10a3392c04163f537061f86a448ed5c \ + --hash=sha256:d29eb9a93f12aa3d997b6e3c447ac85b2a4b142ab2548441523a8fcf5e216042 \ + --hash=sha256:da3ad64d685f84a34ebe5daacb39fff14f1251acb34c098d760d63fee768f50c \ + --hash=sha256:ef07c0103d79492c21fced9ad68c11c32efa6801ca1920ebfd0f15fb46c78b1c \ + --hash=sha256:f3935459109da4bb0b3901da9904f0a3e52028a3332a355d298b1673a334cf21 \ + --hash=sha256:f84f15d146d6aa93254008a626c56ef96fed276006202881a47b29757f0cd65a \ + --hash=sha256:fb6e8d0547f30ddc845f4fd1e33070ef548233ad0dbf21f7ecea768883d1bbdc + # via -r software/thunderscope/requirements.in pyqt-toast-notification==1.3.2 \ --hash=sha256:135736ec0f16bff41104dee3c60ac318e5d55ae3378bf26892c6d08c36088ae6 \ --hash=sha256:82688101202737736d51ab6c74a573b32266ecb7c8b0002f913407bd369737d9 From 4d750e7fc936bda60f8643e768d6ea26d9b9d76d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 06:35:39 +0000 Subject: [PATCH 07/14] [pre-commit.ci lite] apply automatic fixes --- src/software/thunderscope/requirements_lock.txt | 4 ---- src/software/thunderscope/robot_diagnostics/BUILD | 2 +- .../thunderscope/robot_diagnostics/controller_base.py | 3 ++- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/software/thunderscope/requirements_lock.txt b/src/software/thunderscope/requirements_lock.txt index b13d47dc07..38a3b05b3c 100644 --- a/src/software/thunderscope/requirements_lock.txt +++ b/src/software/thunderscope/requirements_lock.txt @@ -182,10 +182,6 @@ pyqtgraph==0.13.7 \ --hash=sha256:64f84f1935c6996d0e09b1ee66fe478a7771e3ca6f3aaa05f00f6e068321d9e3 \ --hash=sha256:7754edbefb6c367fa0dfb176e2d0610da3ada20aa7a5318516c74af5fb72bf7a # via -r software/thunderscope/requirements.in -pyserial==3.5 \ - --hash=sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb \ - --hash=sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0 - # via -r software/thunderscope/requirements.in qtawesome==1.4.0 \ --hash=sha256:783e414d1317f3e978bf67ea8e8a1b1498bad9dbd305dec814027e3b50521be6 \ --hash=sha256:a4d689fa071c595aa6184171ce1f0f847677cb8d2db45382c43129f1d72a3d93 diff --git a/src/software/thunderscope/robot_diagnostics/BUILD b/src/software/thunderscope/robot_diagnostics/BUILD index 79937cdd63..d8d6510aec 100644 --- a/src/software/thunderscope/robot_diagnostics/BUILD +++ b/src/software/thunderscope/robot_diagnostics/BUILD @@ -52,9 +52,9 @@ py_library( name = "handheld_controller_widget", srcs = ["handheld_controller_widget.py"], deps = [ - ":keyboard_controller", ":controller_base", ":handheld_controller", + ":keyboard_controller", "//software/thunderscope:constants", requirement("pyqtgraph"), ], diff --git a/src/software/thunderscope/robot_diagnostics/controller_base.py b/src/software/thunderscope/robot_diagnostics/controller_base.py index a62997b5d4..8a5f5fb5f1 100644 --- a/src/software/thunderscope/robot_diagnostics/controller_base.py +++ b/src/software/thunderscope/robot_diagnostics/controller_base.py @@ -21,7 +21,8 @@ def close(self) -> None: def update(self) -> None: """Refresh controller input state. Called once per frame before reading inputs. - Override for polled backends; push-based controllers can leave this as a no-op.""" + Override for polled backends; push-based controllers can leave this as a no-op. + """ pass @abstractmethod From 8421b5b666cdcd29cc431fbf904e1aaa677e953a Mon Sep 17 00:00:00 2001 From: Avah Xiao Date: Sat, 15 Aug 2026 11:27:09 -0700 Subject: [PATCH 08/14] Remove outdated pyserial reference --- src/software/thunderscope/BUILD | 1 - 1 file changed, 1 deletion(-) diff --git a/src/software/thunderscope/BUILD b/src/software/thunderscope/BUILD index e0a7391396..2a0e71a543 100644 --- a/src/software/thunderscope/BUILD +++ b/src/software/thunderscope/BUILD @@ -192,6 +192,5 @@ py_library( data = ["//software:py_constants.so"], deps = [ "//software/thunderscope:constants", - requirement("pyserial"), ], ) From 1f8e8e8a630680837fb2596ff0698379e86afe8a Mon Sep 17 00:00:00 2001 From: Avah Xiao Date: Tue, 18 Aug 2026 20:20:53 -0700 Subject: [PATCH 09/14] Revert "Remove outdated pyserial reference" This reverts commit 8421b5b666cdcd29cc431fbf904e1aaa677e953a. --- src/software/thunderscope/BUILD | 1 + 1 file changed, 1 insertion(+) diff --git a/src/software/thunderscope/BUILD b/src/software/thunderscope/BUILD index 2a0e71a543..e0a7391396 100644 --- a/src/software/thunderscope/BUILD +++ b/src/software/thunderscope/BUILD @@ -192,5 +192,6 @@ py_library( data = ["//software:py_constants.so"], deps = [ "//software/thunderscope:constants", + requirement("pyserial"), ], ) From 38b8e91d47f9cf309864cf66954d8b738a1b2e35 Mon Sep 17 00:00:00 2001 From: Avah Xiao Date: Tue, 18 Aug 2026 20:21:02 -0700 Subject: [PATCH 10/14] Add back pyserial requirement for estop helper --- src/software/thunderscope/requirements.in | 1 + 1 file changed, 1 insertion(+) diff --git a/src/software/thunderscope/requirements.in b/src/software/thunderscope/requirements.in index f7813b2d66..ae5dea9fc3 100644 --- a/src/software/thunderscope/requirements.in +++ b/src/software/thunderscope/requirements.in @@ -2,6 +2,7 @@ colorama==0.4.6 netifaces==0.11.0 numpy==1.26.4 protobuf==6.31.1 +pyserial==3.5 pygame==2.6.1 pyqtgraph==0.13.7 pyqtdarktheme-fork==2.3.2 From d4d1c838452f0e6acb71f8870fba7ce3d07c6175 Mon Sep 17 00:00:00 2001 From: Avah Xiao Date: Tue, 18 Aug 2026 20:24:03 -0700 Subject: [PATCH 11/14] Regenerate requirements lock --- src/software/thunderscope/requirements_lock.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/software/thunderscope/requirements_lock.txt b/src/software/thunderscope/requirements_lock.txt index 38a3b05b3c..b13d47dc07 100644 --- a/src/software/thunderscope/requirements_lock.txt +++ b/src/software/thunderscope/requirements_lock.txt @@ -182,6 +182,10 @@ pyqtgraph==0.13.7 \ --hash=sha256:64f84f1935c6996d0e09b1ee66fe478a7771e3ca6f3aaa05f00f6e068321d9e3 \ --hash=sha256:7754edbefb6c367fa0dfb176e2d0610da3ada20aa7a5318516c74af5fb72bf7a # via -r software/thunderscope/requirements.in +pyserial==3.5 \ + --hash=sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb \ + --hash=sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0 + # via -r software/thunderscope/requirements.in qtawesome==1.4.0 \ --hash=sha256:783e414d1317f3e978bf67ea8e8a1b1498bad9dbd305dec814027e3b50521be6 \ --hash=sha256:a4d689fa071c595aa6184171ce1f0f847677cb8d2db45382c43129f1d72a3d93 From d59caa4ebd50b98768de2adbcb3433f3a47d8790 Mon Sep 17 00:00:00 2001 From: Avah Xiao Date: Sat, 29 Aug 2026 15:37:02 -0700 Subject: [PATCH 12/14] Rename to IControllerBase --- .../thunderscope/robot_diagnostics/controller_base.py | 2 +- .../thunderscope/robot_diagnostics/handheld_controller.py | 4 ++-- .../robot_diagnostics/handheld_controller_widget.py | 4 ++-- .../thunderscope/robot_diagnostics/keyboard_controller.py | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/software/thunderscope/robot_diagnostics/controller_base.py b/src/software/thunderscope/robot_diagnostics/controller_base.py index 8a5f5fb5f1..1b5d1686ae 100644 --- a/src/software/thunderscope/robot_diagnostics/controller_base.py +++ b/src/software/thunderscope/robot_diagnostics/controller_base.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod -class ControllerBase(ABC): +class IControllerBase(ABC): """Abstract base class for controller input sources.""" @abstractmethod diff --git a/src/software/thunderscope/robot_diagnostics/handheld_controller.py b/src/software/thunderscope/robot_diagnostics/handheld_controller.py index 1409934c71..f760467a13 100644 --- a/src/software/thunderscope/robot_diagnostics/handheld_controller.py +++ b/src/software/thunderscope/robot_diagnostics/handheld_controller.py @@ -7,10 +7,10 @@ from pygame._sdl2 import controller from software.thunderscope.constants import DiagnosticsConstants -from software.thunderscope.robot_diagnostics.controller_base import ControllerBase +from software.thunderscope.robot_diagnostics.controller_base import IControllerBase -class HandheldController(ControllerBase): +class HandheldController(IControllerBase): """Represents a handheld game controller (e.g. an Xbox gamepad) that can be used to manually control our robots. diff --git a/src/software/thunderscope/robot_diagnostics/handheld_controller_widget.py b/src/software/thunderscope/robot_diagnostics/handheld_controller_widget.py index ea9391130b..41b1f658d1 100644 --- a/src/software/thunderscope/robot_diagnostics/handheld_controller_widget.py +++ b/src/software/thunderscope/robot_diagnostics/handheld_controller_widget.py @@ -7,7 +7,7 @@ import software.python_bindings as tbots_cpp from software.thunderscope.constants import DiagnosticsConstants -from software.thunderscope.robot_diagnostics.controller_base import ControllerBase +from software.thunderscope.robot_diagnostics.controller_base import IControllerBase from software.thunderscope.robot_diagnostics.handheld_controller import ( HandheldController, ) @@ -34,7 +34,7 @@ def __init__(self) -> None: self.constants = tbots_cpp.createRobotConstants() - self.handheld_controller: ControllerBase | None = None + self.handheld_controller: IControllerBase | None = None self.motor_control = protos.MotorControl() self.dribbler_speed = 0 diff --git a/src/software/thunderscope/robot_diagnostics/keyboard_controller.py b/src/software/thunderscope/robot_diagnostics/keyboard_controller.py index 31be08f1d8..5d3e09b7ab 100644 --- a/src/software/thunderscope/robot_diagnostics/keyboard_controller.py +++ b/src/software/thunderscope/robot_diagnostics/keyboard_controller.py @@ -4,14 +4,14 @@ from pyqtgraph.Qt.QtWidgets import QApplication from software.thunderscope.constants import DiagnosticsConstants -from software.thunderscope.robot_diagnostics.controller_base import ControllerBase +from software.thunderscope.robot_diagnostics.controller_base import IControllerBase class _QABCMeta(type(QObject), ABCMeta): pass -class KeyboardController(QObject, ControllerBase, metaclass=_QABCMeta): +class KeyboardController(QObject, IControllerBase, metaclass=_QABCMeta): """Keyboard input source. Installs a QApplication-level event filter so key events are captured From 954c7f30857ef82cab2b9697f3f5a2312d8a7bdc Mon Sep 17 00:00:00 2001 From: Avah Xiao Date: Sat, 29 Aug 2026 16:35:07 -0700 Subject: [PATCH 13/14] Make code consistent with docs --- .../thunderscope/robot_diagnostics/keyboard_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/software/thunderscope/robot_diagnostics/keyboard_controller.py b/src/software/thunderscope/robot_diagnostics/keyboard_controller.py index 5d3e09b7ab..7fc622ab0e 100644 --- a/src/software/thunderscope/robot_diagnostics/keyboard_controller.py +++ b/src/software/thunderscope/robot_diagnostics/keyboard_controller.py @@ -71,7 +71,7 @@ def get_speed_factor(self) -> float: return ( DiagnosticsConstants.SPEED_SLOWDOWN_FACTOR if Qt.Key.Key_Shift in self._held_keys - else 0.75 + else 1.0 ) def is_dribbler_held(self) -> bool: From 67b9937cb7bef338f933aa8b8e65848abb501c57 Mon Sep 17 00:00:00 2001 From: Avah Xiao Date: Sat, 29 Aug 2026 16:37:35 -0700 Subject: [PATCH 14/14] Get rid of unnecessary placeholders --- .../thunderscope/robot_diagnostics/controller_base.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/software/thunderscope/robot_diagnostics/controller_base.py b/src/software/thunderscope/robot_diagnostics/controller_base.py index 1b5d1686ae..e2b64ba7a7 100644 --- a/src/software/thunderscope/robot_diagnostics/controller_base.py +++ b/src/software/thunderscope/robot_diagnostics/controller_base.py @@ -7,61 +7,50 @@ class IControllerBase(ABC): @abstractmethod def name(self) -> str: """Get the display name of the input source.""" - ... @abstractmethod def connected(self) -> bool: """Return true if the input source is active and available.""" - ... @abstractmethod def close(self) -> None: """Release any resources held by the input source.""" - ... def update(self) -> None: """Refresh controller input state. Called once per frame before reading inputs. Override for polled backends; push-based controllers can leave this as a no-op. """ - pass @abstractmethod def get_move_velocity(self) -> tuple[float, float, float]: """Return (x, y, angular) velocity, each normalized to [-1, 1] with deadzone applied. Positive x = forward, positive y = strafe left, positive angular = CCW. """ - ... @abstractmethod def get_speed_factor(self) -> float: """Return 1.0 normally, or SPEED_SLOWDOWN_FACTOR when slowdown input is active.""" - ... @abstractmethod def is_dribbler_held(self) -> bool: """Return True if the dribbler engage input is active.""" - ... @abstractmethod def get_kick_power_step(self) -> int: """Return -1, 0, or +1 for kick/chip power step direction. Non-zero only once per new input (edge-detected). """ - ... @abstractmethod def get_dribbler_step(self) -> int: """Return -1, 0, or +1 for dribbler RPM step direction. Non-zero only once per new input (edge-detected). """ - ... @abstractmethod def is_kick_fired(self) -> bool: """Return True once per kick button press (rising edge only).""" - ... @abstractmethod def is_chip_fired(self) -> bool: """Return True once per chip button press (rising edge only).""" - ...