Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/software/thunderscope/robot_diagnostics/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

semi colon

":controller_base",
":handheld_controller",
"//software/thunderscope:constants",
requirement("pyqtgraph"),
Expand Down
30 changes: 30 additions & 0 deletions src/software/thunderscope/robot_diagnostics/controller_base.py
Original file line number Diff line number Diff line change
@@ -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."""
...
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand All @@ -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():
Expand All @@ -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.

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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:
Copy link
Copy Markdown
Contributor

@StarrryNight StarrryNight May 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add time-based speed ramping for movement. Or else robot will either move in full speed or zero speed.

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
Loading