diff --git a/src/software/thunderscope/robot_diagnostics/BUILD b/src/software/thunderscope/robot_diagnostics/BUILD index aee3a437d1..c909bb87d3 100644 --- a/src/software/thunderscope/robot_diagnostics/BUILD +++ b/src/software/thunderscope/robot_diagnostics/BUILD @@ -23,15 +23,33 @@ 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( name = "handheld_controller_widget", srcs = ["handheld_controller_widget.py"], deps = [ + "keyboard_controller", + ":controller_base", ":handheld_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 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..580858fb8a --- /dev/null +++ b/src/software/thunderscope/robot_diagnostics/keyboard_controller.py @@ -0,0 +1,90 @@ +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