From fcfa3851b26301ba0e11694cf98e2d8e1b3a591f Mon Sep 17 00:00:00 2001 From: Andreas Kluge Svendsrud <89779148+kluge7@users.noreply.github.com> Date: Tue, 4 Nov 2025 02:35:41 +0100 Subject: [PATCH 01/19] feat(joy): add keyboard_joy node for keyboard-based teleoperation --- mission/keyboard_joy/CMakeLists.txt | 20 +++ mission/keyboard_joy/config/key_mappings.yaml | 32 ++++ mission/keyboard_joy/keyboard_joy/__init__.py | 0 .../keyboard_joy/keyboard_joy_node.py | 167 ++++++++++++++++++ .../launch/keyboard_joy_node.launch.py | 35 ++++ mission/keyboard_joy/package.xml | 19 ++ 6 files changed, 273 insertions(+) create mode 100644 mission/keyboard_joy/CMakeLists.txt create mode 100644 mission/keyboard_joy/config/key_mappings.yaml create mode 100644 mission/keyboard_joy/keyboard_joy/__init__.py create mode 100755 mission/keyboard_joy/keyboard_joy/keyboard_joy_node.py create mode 100644 mission/keyboard_joy/launch/keyboard_joy_node.launch.py create mode 100644 mission/keyboard_joy/package.xml diff --git a/mission/keyboard_joy/CMakeLists.txt b/mission/keyboard_joy/CMakeLists.txt new file mode 100644 index 000000000..2ec1c84aa --- /dev/null +++ b/mission/keyboard_joy/CMakeLists.txt @@ -0,0 +1,20 @@ +cmake_minimum_required(VERSION 3.8) +project(keyboard_joy) + +find_package(ament_cmake REQUIRED) +find_package(rclpy REQUIRED) + +ament_python_install_package(${PROJECT_NAME}) + +install(DIRECTORY + config + launch + DESTINATION share/${PROJECT_NAME} +) + +install(PROGRAMS + ${PROJECT_NAME}/${PROJECT_NAME}_node.py + DESTINATION lib/${PROJECT_NAME} +) + +ament_package() diff --git a/mission/keyboard_joy/config/key_mappings.yaml b/mission/keyboard_joy/config/key_mappings.yaml new file mode 100644 index 000000000..e02a6222c --- /dev/null +++ b/mission/keyboard_joy/config/key_mappings.yaml @@ -0,0 +1,32 @@ +axes: + # Left stick (surge/sway) + w: [1, 1.0, 'normal'] + s: [1, -1.0, 'normal'] + a: [0, 1.0, 'normal'] + d: [0, -1.0, 'normal'] + + # Heave + Key.space: [2, 1.0, 'normal'] # Up (RT) + Key.shift: [2, -1.0, 'normal'] # Down (LT) + + # Rotation (pitch/yaw) + Key.up: [4, 1.0, 'normal'] + Key.down: [4, -1.0, 'normal'] + Key.left: [3, 1.0, 'normal'] + Key.right: [3, -1.0, 'normal'] + +parameters: + axis_increment_rate: 0.02 # update interval (higher = slower response) + axis_increment_step: 0.1 # axis change per update (higher = faster movement) + + +buttons: + # Roll + q: 4 # LB + e: 5 # RB + + # Modes + '1': 0 # A - Xbox mode + '2': 1 # B - Killswitch + '3': 2 # X - Auto + '4': 3 # Y - Reference diff --git a/mission/keyboard_joy/keyboard_joy/__init__.py b/mission/keyboard_joy/keyboard_joy/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/mission/keyboard_joy/keyboard_joy/keyboard_joy_node.py b/mission/keyboard_joy/keyboard_joy/keyboard_joy_node.py new file mode 100755 index 000000000..9743978b0 --- /dev/null +++ b/mission/keyboard_joy/keyboard_joy/keyboard_joy_node.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +import os +import threading + +import rclpy +import yaml +from ament_index_python.packages import get_package_share_directory +from pynput import keyboard +from rclpy.node import Node +from sensor_msgs.msg import Joy + +start_message = r""" + ██ ▄█▀▓█████▓██ ██▓ ▄▄▄▄ ▒█████ ▄▄▄ ██▀███ ▓█████▄ ▄▄▄██▀▀▀▒█████ ▓██ ██▓ + ██▄█▒ ▓█ ▀ ▒██ ██▒▓█████▄ ▒██▒ ██▒▒████▄ ▓██ ▒ ██▒▒██▀ ██▌ ▒██ ▒██▒ ██▒▒██ ██▒ +▓███▄░ ▒███ ▒██ ██░▒██▒ ▄██▒██░ ██▒▒██ ▀█▄ ▓██ ░▄█ ▒░██ █▌ ░██ ▒██░ ██▒ ▒██ ██░ +▓██ █▄ ▒▓█ ▄ ░ ▐██▓░▒██░█▀ ▒██ ██░░██▄▄▄▄██ ▒██▀▀█▄ ░▓█▄ ▌▓██▄██▓ ▒██ ██░ ░ ▐██▓░ +▒██▒ █▄░▒████▒ ░ ██▒▓░░▓█ ▀█▓░ ████▓▒░ ▓█ ▓██▒░██▓ ▒██▒░▒████▓ ▓███▒ ░ ████▓▒░ ░ ██▒▓░ +▒ ▒▒ ▓▒░░ ▒░ ░ ██▒▒▒ ░▒▓███▀▒░ ▒░▒░▒░ ▒▒ ▓▒█░░ ▒▓ ░▒▓░ ▒▒▓ ▒ ▒▓▒▒░ ░ ▒░▒░▒░ ██▒▒▒ +░ ░▒ ▒░ ░ ░ ░▓██ ░▒░ ▒░▒ ░ ░ ▒ ▒░ ▒ ▒▒ ░ ░▒ ░ ▒░ ░ ▒ ▒ ▒ ░▒░ ░ ▒ ▒░ ▓██ ░▒░ +░ ░░ ░ ░ ▒ ▒ ░░ ░ ░ ░ ░ ░ ▒ ░ ▒ ░░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ▒ ▒ ▒ ░░ +░ ░ ░ ░░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ + ░ ░ ░ ░ ░ ░ +""" + + +class KeyboardJoy(Node): + def __init__(self): + super().__init__('keyboard_joy') + + # Load configuration + self.declare_parameter('config', '') + self.load_key_mappings() + + # Prepare publisher + self.joy_pub = self.create_publisher(Joy, 'joy', 10) + + # Precompute max indices for axes/buttons + max_axis_index = max((v[0] for v in self.axis_mappings.values()), default=-1) + max_button_index = max(self.button_mappings.values(), default=-1) + + self.joy_msg = Joy() + self.joy_msg.axes = [0.0] * (max_axis_index + 1) + self.joy_msg.buttons = [0] * (max_button_index + 1) + + self.active_axes = {} + self.sticky_axes = {} + + self.lock = threading.Lock() + + # Start keyboard listener + self.listener = keyboard.Listener( + on_press=self.on_press, on_release=self.on_release + ) + self.listener.start() + + self.create_timer(0.05, self.publish_joy) + self.create_timer(self.axis_increment_rate, self.update_active_axes) + + self.get_logger().info(start_message) + + def load_key_mappings(self): + config_file = self.get_parameter( + 'config' + ).get_parameter_value().string_value or os.path.join( + get_package_share_directory('keyboard_joy'), 'config', 'key_mappings.yaml' + ) + + try: + with open(config_file) as f: + keymap = yaml.safe_load(f) or {} + except Exception as e: + self.get_logger().error(f"Failed to load config '{config_file}': {e}") + keymap = {} + + self.axis_mappings = keymap.get('axes', {}) + self.button_mappings = keymap.get('buttons', {}) + params = keymap.get('parameters', {}) + + self.axis_increment_rate = float(params.get('axis_increment_rate', 0.02)) + self.axis_increment_step = float(params.get('axis_increment_step', 0.05)) + + def on_press(self, key): + key_str = self.key_to_string(key) + if not key_str: + return + with self.lock: + if key_str in self.axis_mappings: + axis, val, mode = self.axis_mappings[key_str] + if mode == 'sticky': + self.sticky_axes[axis] = max( + min( + self.sticky_axes.get(axis, 0.0) + + val * self.axis_increment_step, + 1.0, + ), + -1.0, + ) + self.joy_msg.axes[axis] = round(self.sticky_axes[axis], 3) + else: + self.active_axes[axis] = val + elif key_str in self.button_mappings: + self.joy_msg.buttons[self.button_mappings[key_str]] = 1 + + def on_release(self, key): + key_str = self.key_to_string(key) + if not key_str: + return + with self.lock: + if key_str in self.axis_mappings: + axis, _, mode = self.axis_mappings[key_str] + self.active_axes.pop(axis, None) + if mode != 'sticky': + self.joy_msg.axes[axis] = 0.0 + elif key_str in self.button_mappings: + self.joy_msg.buttons[self.button_mappings[key_str]] = 0 + + @staticmethod + def key_to_string(key): + """Convert pynput key event to a normalized string.""" + if hasattr(key, 'char') and key.char: + return key.char.lower() + if hasattr(key, 'name') and key.name: + return f'Key.{key.name}' + return None + + def update_active_axes(self): + """Gradually update active axes towards their target values.""" + with self.lock: + for axis, target in self.active_axes.items(): + current = self.joy_msg.axes[axis] + delta = ( + self.axis_increment_step + if target > 0 + else -self.axis_increment_step + ) + next_val = current + delta + if (delta > 0 and next_val > target) or ( + delta < 0 and next_val < target + ): + next_val = target + self.joy_msg.axes[axis] = round(next_val, 3) + + def publish_joy(self): + with self.lock: + self.joy_msg.header.stamp = self.get_clock().now().to_msg() + self.joy_pub.publish(self.joy_msg) + + def destroy_node(self): + if self.listener: + self.listener.stop() + super().destroy_node() + + +def main(args=None): + rclpy.init(args=args) + node = KeyboardJoy() + try: + rclpy.spin(node) + except KeyboardInterrupt: + pass + finally: + node.destroy_node() + rclpy.shutdown() + + +if __name__ == '__main__': + main() diff --git a/mission/keyboard_joy/launch/keyboard_joy_node.launch.py b/mission/keyboard_joy/launch/keyboard_joy_node.launch.py new file mode 100644 index 000000000..d3ffeb33e --- /dev/null +++ b/mission/keyboard_joy/launch/keyboard_joy_node.launch.py @@ -0,0 +1,35 @@ +import os + +from ament_index_python.packages import get_package_share_directory +from launch import LaunchDescription +from launch.actions import DeclareLaunchArgument +from launch.substitutions import LaunchConfiguration +from launch_ros.actions import Node + + +def generate_launch_description(): + keyboard_config = os.path.join( + get_package_share_directory('keyboard_joy'), 'config', 'key_mappings.yaml' + ) + + orca_params = os.path.join( + get_package_share_directory('auv_setup'), 'config', 'robots', 'orca.yaml' + ) + + return LaunchDescription( + [ + DeclareLaunchArgument( + 'config', + default_value=keyboard_config, + description='Path to key mappings YAML file', + ), + Node( + package='keyboard_joy', + executable='keyboard_joy_node.py', + name='keyboard_joy', + namespace='orca', + output='screen', + parameters=[{'config': LaunchConfiguration('config')}, orca_params], + ), + ] + ) diff --git a/mission/keyboard_joy/package.xml b/mission/keyboard_joy/package.xml new file mode 100644 index 000000000..5bdc666d5 --- /dev/null +++ b/mission/keyboard_joy/package.xml @@ -0,0 +1,19 @@ + + + + keyboard_joy + 0.0.0 + Keyboard teleop node that publishes sensor_msgs/Joy messages + kluge7 + MIT + + ament_cmake + + rclpy + sensor_msgs + python3-pynput + + + ament_cmake + + From eec9cb369023be8536b4785d321f3e3f5f4b4f67 Mon Sep 17 00:00:00 2001 From: Andreas Kluge Svendsrud <89779148+kluge7@users.noreply.github.com> Date: Tue, 4 Nov 2025 06:01:00 +0100 Subject: [PATCH 02/19] chore(keyboard_joy_node): removed comments --- mission/keyboard_joy/keyboard_joy/keyboard_joy_node.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/mission/keyboard_joy/keyboard_joy/keyboard_joy_node.py b/mission/keyboard_joy/keyboard_joy/keyboard_joy_node.py index 9743978b0..8ba68265e 100755 --- a/mission/keyboard_joy/keyboard_joy/keyboard_joy_node.py +++ b/mission/keyboard_joy/keyboard_joy/keyboard_joy_node.py @@ -27,14 +27,11 @@ class KeyboardJoy(Node): def __init__(self): super().__init__('keyboard_joy') - # Load configuration self.declare_parameter('config', '') self.load_key_mappings() - # Prepare publisher self.joy_pub = self.create_publisher(Joy, 'joy', 10) - # Precompute max indices for axes/buttons max_axis_index = max((v[0] for v in self.axis_mappings.values()), default=-1) max_button_index = max(self.button_mappings.values(), default=-1) @@ -47,7 +44,6 @@ def __init__(self): self.lock = threading.Lock() - # Start keyboard listener self.listener = keyboard.Listener( on_press=self.on_press, on_release=self.on_release ) From 2d999ba193f274c7c13507668a6e027ac551b02f Mon Sep 17 00:00:00 2001 From: Andreas Kluge Svendsrud <89779148+kluge7@users.noreply.github.com> Date: Sun, 4 Jan 2026 13:21:49 +0100 Subject: [PATCH 03/19] refactor: migrate keyboard_joy to ament_python and add unit tests --- mission/keyboard_joy/CMakeLists.txt | 20 --- mission/keyboard_joy/README.md | 6 + .../launch/keyboard_joy_node.launch.py | 2 +- mission/keyboard_joy/package.xml | 7 +- mission/keyboard_joy/resource/keyboard_joy | 0 mission/keyboard_joy/setup.cfg | 4 + mission/keyboard_joy/setup.py | 29 ++++ .../test/test_keyboard_joy_node.py | 133 ++++++++++++++++++ 8 files changed, 178 insertions(+), 23 deletions(-) delete mode 100644 mission/keyboard_joy/CMakeLists.txt create mode 100644 mission/keyboard_joy/README.md create mode 100644 mission/keyboard_joy/resource/keyboard_joy create mode 100644 mission/keyboard_joy/setup.cfg create mode 100644 mission/keyboard_joy/setup.py create mode 100644 mission/keyboard_joy/test/test_keyboard_joy_node.py diff --git a/mission/keyboard_joy/CMakeLists.txt b/mission/keyboard_joy/CMakeLists.txt deleted file mode 100644 index 2ec1c84aa..000000000 --- a/mission/keyboard_joy/CMakeLists.txt +++ /dev/null @@ -1,20 +0,0 @@ -cmake_minimum_required(VERSION 3.8) -project(keyboard_joy) - -find_package(ament_cmake REQUIRED) -find_package(rclpy REQUIRED) - -ament_python_install_package(${PROJECT_NAME}) - -install(DIRECTORY - config - launch - DESTINATION share/${PROJECT_NAME} -) - -install(PROGRAMS - ${PROJECT_NAME}/${PROJECT_NAME}_node.py - DESTINATION lib/${PROJECT_NAME} -) - -ament_package() diff --git a/mission/keyboard_joy/README.md b/mission/keyboard_joy/README.md new file mode 100644 index 000000000..f462fa845 --- /dev/null +++ b/mission/keyboard_joy/README.md @@ -0,0 +1,6 @@ +# keyboard_joy + +`keyboard_joy` is a ROS 2 Python node that publishes `sensor_msgs/Joy` messages based on keyboard input, acting as a simple joystick replacement. +Key-to-axis and key-to-button mappings are configurable via YAML and support both hold and sticky axis modes. + +TODO: Currently **only works on Xorg** (not Wayland) due to limitations in global keyboard capture. diff --git a/mission/keyboard_joy/launch/keyboard_joy_node.launch.py b/mission/keyboard_joy/launch/keyboard_joy_node.launch.py index d3ffeb33e..80500f790 100644 --- a/mission/keyboard_joy/launch/keyboard_joy_node.launch.py +++ b/mission/keyboard_joy/launch/keyboard_joy_node.launch.py @@ -25,7 +25,7 @@ def generate_launch_description(): ), Node( package='keyboard_joy', - executable='keyboard_joy_node.py', + executable='keyboard_joy_node', name='keyboard_joy', namespace='orca', output='screen', diff --git a/mission/keyboard_joy/package.xml b/mission/keyboard_joy/package.xml index 5bdc666d5..e4ce73b69 100644 --- a/mission/keyboard_joy/package.xml +++ b/mission/keyboard_joy/package.xml @@ -7,13 +7,16 @@ kluge7 MIT - ament_cmake + ament_python rclpy sensor_msgs python3-pynput + ament_pytest + pytest + - ament_cmake + ament_python diff --git a/mission/keyboard_joy/resource/keyboard_joy b/mission/keyboard_joy/resource/keyboard_joy new file mode 100644 index 000000000..e69de29bb diff --git a/mission/keyboard_joy/setup.cfg b/mission/keyboard_joy/setup.cfg new file mode 100644 index 000000000..628e9fc33 --- /dev/null +++ b/mission/keyboard_joy/setup.cfg @@ -0,0 +1,4 @@ +[develop] +script_dir=$base/lib/keyboard_joy +[install] +install_scripts=$base/lib/keyboard_joy diff --git a/mission/keyboard_joy/setup.py b/mission/keyboard_joy/setup.py new file mode 100644 index 000000000..4e417ce5d --- /dev/null +++ b/mission/keyboard_joy/setup.py @@ -0,0 +1,29 @@ +from setuptools import setup +import os +from glob import glob + +package_name = 'keyboard_joy' + +setup( + name=package_name, + version='0.0.0', + packages=[package_name], + data_files=[ + ('share/ament_index/resource_index/packages', ['resource/' + package_name]), + ('share/' + package_name, ['package.xml']), + (os.path.join('share', package_name, 'launch'), glob('launch/*.launch.py')), + (os.path.join('share', package_name, 'config'), glob('config/*.yaml')), + ], + install_requires=['setuptools'], + tests_require=['pytest'], + zip_safe=True, + maintainer='kluge7', + maintainer_email='andreas.svendsrud@vortexntnu.no', + description='Keyboard teleop node that publishes sensor_msgs/Joy messages', + license='MIT', + entry_points={ + 'console_scripts': [ + 'keyboard_joy_node = keyboard_joy.keyboard_joy_node:main', + ], + }, +) diff --git a/mission/keyboard_joy/test/test_keyboard_joy_node.py b/mission/keyboard_joy/test/test_keyboard_joy_node.py new file mode 100644 index 000000000..31f28032e --- /dev/null +++ b/mission/keyboard_joy/test/test_keyboard_joy_node.py @@ -0,0 +1,133 @@ +import pytest +import rclpy + +from keyboard_joy.keyboard_joy_node import KeyboardJoy + + +class DummyListener: + """Stand-in for pynput.keyboard.Listener that does nothing.""" + def __init__(self, on_press=None, on_release=None): + self.on_press = on_press + self.on_release = on_release + self.started = False + self.stopped = False + + def start(self): + self.started = True + + def stop(self): + self.stopped = True + + +class KeyChar: + """Mimics pynput key with .char""" + def __init__(self, char): + self.char = char + + +class KeyName: + """Mimics pynput special key with .name""" + def __init__(self, name): + self.name = name + + +@pytest.fixture(scope="function") +def rclpy_ctx(): + rclpy.init() + yield + rclpy.shutdown() + + +@pytest.fixture(scope="function") +def node(monkeypatch, rclpy_ctx): + # Prevent real keyboard listener from starting + import keyboard_joy.keyboard_joy_node as mod + monkeypatch.setattr(mod.keyboard, "Listener", DummyListener) + + # Inject deterministic mappings (avoid filesystem / YAML / ament index) + def fake_load_key_mappings(self): + self.axis_mappings = { + "w": [0, 1.0, "hold"], + "s": [0, -1.0, "hold"], + "e": [1, 1.0, "sticky"], + "q": [1, -1.0, "sticky"], + } + self.button_mappings = { + " ": 0, + "Key.enter": 1, + } + self.axis_increment_rate = 0.01 + self.axis_increment_step = 0.2 + + monkeypatch.setattr(KeyboardJoy, "load_key_mappings", fake_load_key_mappings) + + n = KeyboardJoy() + yield n + n.destroy_node() + + +def test_key_to_string_char_lowercases(): + assert KeyboardJoy.key_to_string(KeyChar("W")) == "w" + assert KeyboardJoy.key_to_string(KeyChar("w")) == "w" + + +def test_key_to_string_special_key(): + assert KeyboardJoy.key_to_string(KeyName("enter")) == "Key.enter" + + +def test_button_press_and_release(node): + node.on_press(KeyChar(" ")) + assert node.joy_msg.buttons[0] == 1 + + node.on_release(KeyChar(" ")) + assert node.joy_msg.buttons[0] == 0 + + node.on_press(KeyName("enter")) + assert node.joy_msg.buttons[1] == 1 + + node.on_release(KeyName("enter")) + assert node.joy_msg.buttons[1] == 0 + + +def test_hold_axis_moves_towards_target(node): + node.on_press(KeyChar("w")) + + node.update_active_axes() + assert node.joy_msg.axes[0] == pytest.approx(0.2, abs=1e-6) + + node.update_active_axes() + assert node.joy_msg.axes[0] == pytest.approx(0.4, abs=1e-6) + + node.on_release(KeyChar("w")) + assert node.joy_msg.axes[0] == pytest.approx(0.0, abs=1e-6) + + +def test_hold_axis_clamps_at_target(node): + node.on_press(KeyChar("w")) + + for _ in range(10): + node.update_active_axes() + + assert node.joy_msg.axes[0] == pytest.approx(1.0, abs=1e-6) + + +def test_sticky_axis_accumulates_and_clamps(node): + for _ in range(10): + node.on_press(KeyChar("e")) + node.on_release(KeyChar("e")) + + assert node.joy_msg.axes[1] == pytest.approx(1.0, abs=1e-6) + + for _ in range(20): + node.on_press(KeyChar("q")) + node.on_release(KeyChar("q")) + + assert node.joy_msg.axes[1] == pytest.approx(-1.0, abs=1e-6) + + +def test_listener_is_stopped_on_destroy(node): + assert isinstance(node.listener, DummyListener) + assert node.listener.started is True + + node.destroy_node() + assert node.listener.stopped is True From 9f73fc5d9ac336310af19cfce1a2d070ceb1676b Mon Sep 17 00:00:00 2001 From: Andreas Kluge Svendsrud <89779148+kluge7@users.noreply.github.com> Date: Sun, 4 Jan 2026 13:28:19 +0100 Subject: [PATCH 04/19] chore(keyboard_joy): apply pre-commit changes --- mission/keyboard_joy/README.md | 4 ++-- mission/keyboard_joy/setup.py | 3 ++- mission/keyboard_joy/test/test_keyboard_joy_node.py | 13 ++++++++----- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/mission/keyboard_joy/README.md b/mission/keyboard_joy/README.md index f462fa845..a74b988f5 100644 --- a/mission/keyboard_joy/README.md +++ b/mission/keyboard_joy/README.md @@ -1,6 +1,6 @@ # keyboard_joy -`keyboard_joy` is a ROS 2 Python node that publishes `sensor_msgs/Joy` messages based on keyboard input, acting as a simple joystick replacement. -Key-to-axis and key-to-button mappings are configurable via YAML and support both hold and sticky axis modes. +`keyboard_joy` is a ROS 2 Python node that publishes `sensor_msgs/Joy` messages based on keyboard input, acting as a simple joystick replacement. +Key-to-axis and key-to-button mappings are configurable via YAML and support both hold and sticky axis modes. TODO: Currently **only works on Xorg** (not Wayland) due to limitations in global keyboard capture. diff --git a/mission/keyboard_joy/setup.py b/mission/keyboard_joy/setup.py index 4e417ce5d..8bf70451e 100644 --- a/mission/keyboard_joy/setup.py +++ b/mission/keyboard_joy/setup.py @@ -1,7 +1,8 @@ -from setuptools import setup import os from glob import glob +from setuptools import setup + package_name = 'keyboard_joy' setup( diff --git a/mission/keyboard_joy/test/test_keyboard_joy_node.py b/mission/keyboard_joy/test/test_keyboard_joy_node.py index 31f28032e..c2609a193 100644 --- a/mission/keyboard_joy/test/test_keyboard_joy_node.py +++ b/mission/keyboard_joy/test/test_keyboard_joy_node.py @@ -1,11 +1,11 @@ import pytest import rclpy - from keyboard_joy.keyboard_joy_node import KeyboardJoy class DummyListener: """Stand-in for pynput.keyboard.Listener that does nothing.""" + def __init__(self, on_press=None, on_release=None): self.on_press = on_press self.on_release = on_release @@ -20,28 +20,31 @@ def stop(self): class KeyChar: - """Mimics pynput key with .char""" + """Mimics pynput key with .char.""" + def __init__(self, char): self.char = char class KeyName: - """Mimics pynput special key with .name""" + """Mimics pynput special key with .name.""" + def __init__(self, name): self.name = name -@pytest.fixture(scope="function") +@pytest.fixture def rclpy_ctx(): rclpy.init() yield rclpy.shutdown() -@pytest.fixture(scope="function") +@pytest.fixture def node(monkeypatch, rclpy_ctx): # Prevent real keyboard listener from starting import keyboard_joy.keyboard_joy_node as mod + monkeypatch.setattr(mod.keyboard, "Listener", DummyListener) # Inject deterministic mappings (avoid filesystem / YAML / ament index) From 631feb9f8dcf8ebd4bb6bd4ceaa74656c0ec8048 Mon Sep 17 00:00:00 2001 From: Andreas Kluge Svendsrud <89779148+kluge7@users.noreply.github.com> Date: Sun, 4 Jan 2026 13:30:03 +0100 Subject: [PATCH 05/19] fix(keyboard_joy): use python3-pytest rosdep key instead of pytest --- mission/keyboard_joy/package.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mission/keyboard_joy/package.xml b/mission/keyboard_joy/package.xml index e4ce73b69..3db7c41c9 100644 --- a/mission/keyboard_joy/package.xml +++ b/mission/keyboard_joy/package.xml @@ -14,7 +14,7 @@ python3-pynput ament_pytest - pytest + python3-pytest ament_python From 8c0770cf361e128378fe4e59c1b80747eebeae19 Mon Sep 17 00:00:00 2001 From: Andreas Kluge Svendsrud <89779148+kluge7@users.noreply.github.com> Date: Sun, 4 Jan 2026 13:55:42 +0100 Subject: [PATCH 06/19] refactor(keyboard_joy): remove ament_python from buildtool_depend --- mission/keyboard_joy/package.xml | 2 -- 1 file changed, 2 deletions(-) diff --git a/mission/keyboard_joy/package.xml b/mission/keyboard_joy/package.xml index 3db7c41c9..3d2a4f17a 100644 --- a/mission/keyboard_joy/package.xml +++ b/mission/keyboard_joy/package.xml @@ -7,8 +7,6 @@ kluge7 MIT - ament_python - rclpy sensor_msgs python3-pynput From f42e1d70dedbde9757c40a07e306b492f71ed443 Mon Sep 17 00:00:00 2001 From: Andreas Kluge Svendsrud <89779148+kluge7@users.noreply.github.com> Date: Sun, 4 Jan 2026 13:57:59 +0100 Subject: [PATCH 07/19] Update mission/keyboard_joy/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- mission/keyboard_joy/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mission/keyboard_joy/README.md b/mission/keyboard_joy/README.md index a74b988f5..d91ca8e82 100644 --- a/mission/keyboard_joy/README.md +++ b/mission/keyboard_joy/README.md @@ -1,6 +1,6 @@ # keyboard_joy `keyboard_joy` is a ROS 2 Python node that publishes `sensor_msgs/Joy` messages based on keyboard input, acting as a simple joystick replacement. -Key-to-axis and key-to-button mappings are configurable via YAML and support both hold and sticky axis modes. +Key-to-axis and key-to-button mappings are configurable via YAML and support both normal and sticky axis modes. TODO: Currently **only works on Xorg** (not Wayland) due to limitations in global keyboard capture. From 93807e8b1d60429ffeff28cce932413751901266 Mon Sep 17 00:00:00 2001 From: Andreas Kluge Svendsrud <89779148+kluge7@users.noreply.github.com> Date: Sun, 4 Jan 2026 13:58:50 +0100 Subject: [PATCH 08/19] Update mission/keyboard_joy/config/key_mappings.yaml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- mission/keyboard_joy/config/key_mappings.yaml | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/mission/keyboard_joy/config/key_mappings.yaml b/mission/keyboard_joy/config/key_mappings.yaml index e02a6222c..189caf33d 100644 --- a/mission/keyboard_joy/config/key_mappings.yaml +++ b/mission/keyboard_joy/config/key_mappings.yaml @@ -1,19 +1,19 @@ axes: # Left stick (surge/sway) - w: [1, 1.0, 'normal'] - s: [1, -1.0, 'normal'] - a: [0, 1.0, 'normal'] - d: [0, -1.0, 'normal'] + w: [1, 1.0, 'hold'] + s: [1, -1.0, 'hold'] + a: [0, 1.0, 'hold'] + d: [0, -1.0, 'hold'] # Heave - Key.space: [2, 1.0, 'normal'] # Up (RT) - Key.shift: [2, -1.0, 'normal'] # Down (LT) + Key.space: [2, 1.0, 'hold'] # Up (RT) + Key.shift: [2, -1.0, 'hold'] # Down (LT) # Rotation (pitch/yaw) - Key.up: [4, 1.0, 'normal'] - Key.down: [4, -1.0, 'normal'] - Key.left: [3, 1.0, 'normal'] - Key.right: [3, -1.0, 'normal'] + Key.up: [4, 1.0, 'hold'] + Key.down: [4, -1.0, 'hold'] + Key.left: [3, 1.0, 'hold'] + Key.right: [3, -1.0, 'hold'] parameters: axis_increment_rate: 0.02 # update interval (higher = slower response) From c1f8b438388a4f70ab810d3e4eb1ee56841337b2 Mon Sep 17 00:00:00 2001 From: Andreas Kluge Svendsrud <89779148+kluge7@users.noreply.github.com> Date: Sun, 4 Jan 2026 14:01:45 +0100 Subject: [PATCH 09/19] docs(keyboard_joy): update naming in README to be consistent with code --- mission/keyboard_joy/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mission/keyboard_joy/README.md b/mission/keyboard_joy/README.md index d91ca8e82..a74b988f5 100644 --- a/mission/keyboard_joy/README.md +++ b/mission/keyboard_joy/README.md @@ -1,6 +1,6 @@ # keyboard_joy `keyboard_joy` is a ROS 2 Python node that publishes `sensor_msgs/Joy` messages based on keyboard input, acting as a simple joystick replacement. -Key-to-axis and key-to-button mappings are configurable via YAML and support both normal and sticky axis modes. +Key-to-axis and key-to-button mappings are configurable via YAML and support both hold and sticky axis modes. TODO: Currently **only works on Xorg** (not Wayland) due to limitations in global keyboard capture. From 5d8c813894a8dc29efbf98f3e449345da8b7ba04 Mon Sep 17 00:00:00 2001 From: Andreas Kluge Svendsrud <89779148+kluge7@users.noreply.github.com> Date: Sun, 4 Jan 2026 14:20:11 +0100 Subject: [PATCH 10/19] test --- .github/workflows/industrial-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/industrial-ci.yml b/.github/workflows/industrial-ci.yml index d2c0e782f..02987c990 100644 --- a/.github/workflows/industrial-ci.yml +++ b/.github/workflows/industrial-ci.yml @@ -11,7 +11,7 @@ on: - cron: '0 1 * * *' # Runs daily to check for dependency issues or flaking tests jobs: call_reusable_workflow: - uses: vortexntnu/vortex-ci/.github/workflows/reusable-industrial-ci.yml@main + uses: vortexntnu/vortex-ci/.github/workflows/reusable-industrial-ci.yml@debs/ament-pytest with: upstream_workspace: './dependencies.repos' before_install_upstream_dependencies: './scripts/ci_install_dependencies.sh' From 8893e9c80c582bef0f3985dae3301b2319357411 Mon Sep 17 00:00:00 2001 From: Andreas Kluge Svendsrud <89779148+kluge7@users.noreply.github.com> Date: Sun, 4 Jan 2026 15:30:44 +0100 Subject: [PATCH 11/19] test: remove keyboard_joy tests since they are unsupported in CI --- filtering/COLCON_IGNORE | 0 mission/FSM/COLCON_IGNORE | 0 .../test/test_keyboard_joy_node.py | 136 ------------------ 3 files changed, 136 deletions(-) create mode 100644 filtering/COLCON_IGNORE create mode 100644 mission/FSM/COLCON_IGNORE delete mode 100644 mission/keyboard_joy/test/test_keyboard_joy_node.py diff --git a/filtering/COLCON_IGNORE b/filtering/COLCON_IGNORE new file mode 100644 index 000000000..e69de29bb diff --git a/mission/FSM/COLCON_IGNORE b/mission/FSM/COLCON_IGNORE new file mode 100644 index 000000000..e69de29bb diff --git a/mission/keyboard_joy/test/test_keyboard_joy_node.py b/mission/keyboard_joy/test/test_keyboard_joy_node.py deleted file mode 100644 index c2609a193..000000000 --- a/mission/keyboard_joy/test/test_keyboard_joy_node.py +++ /dev/null @@ -1,136 +0,0 @@ -import pytest -import rclpy -from keyboard_joy.keyboard_joy_node import KeyboardJoy - - -class DummyListener: - """Stand-in for pynput.keyboard.Listener that does nothing.""" - - def __init__(self, on_press=None, on_release=None): - self.on_press = on_press - self.on_release = on_release - self.started = False - self.stopped = False - - def start(self): - self.started = True - - def stop(self): - self.stopped = True - - -class KeyChar: - """Mimics pynput key with .char.""" - - def __init__(self, char): - self.char = char - - -class KeyName: - """Mimics pynput special key with .name.""" - - def __init__(self, name): - self.name = name - - -@pytest.fixture -def rclpy_ctx(): - rclpy.init() - yield - rclpy.shutdown() - - -@pytest.fixture -def node(monkeypatch, rclpy_ctx): - # Prevent real keyboard listener from starting - import keyboard_joy.keyboard_joy_node as mod - - monkeypatch.setattr(mod.keyboard, "Listener", DummyListener) - - # Inject deterministic mappings (avoid filesystem / YAML / ament index) - def fake_load_key_mappings(self): - self.axis_mappings = { - "w": [0, 1.0, "hold"], - "s": [0, -1.0, "hold"], - "e": [1, 1.0, "sticky"], - "q": [1, -1.0, "sticky"], - } - self.button_mappings = { - " ": 0, - "Key.enter": 1, - } - self.axis_increment_rate = 0.01 - self.axis_increment_step = 0.2 - - monkeypatch.setattr(KeyboardJoy, "load_key_mappings", fake_load_key_mappings) - - n = KeyboardJoy() - yield n - n.destroy_node() - - -def test_key_to_string_char_lowercases(): - assert KeyboardJoy.key_to_string(KeyChar("W")) == "w" - assert KeyboardJoy.key_to_string(KeyChar("w")) == "w" - - -def test_key_to_string_special_key(): - assert KeyboardJoy.key_to_string(KeyName("enter")) == "Key.enter" - - -def test_button_press_and_release(node): - node.on_press(KeyChar(" ")) - assert node.joy_msg.buttons[0] == 1 - - node.on_release(KeyChar(" ")) - assert node.joy_msg.buttons[0] == 0 - - node.on_press(KeyName("enter")) - assert node.joy_msg.buttons[1] == 1 - - node.on_release(KeyName("enter")) - assert node.joy_msg.buttons[1] == 0 - - -def test_hold_axis_moves_towards_target(node): - node.on_press(KeyChar("w")) - - node.update_active_axes() - assert node.joy_msg.axes[0] == pytest.approx(0.2, abs=1e-6) - - node.update_active_axes() - assert node.joy_msg.axes[0] == pytest.approx(0.4, abs=1e-6) - - node.on_release(KeyChar("w")) - assert node.joy_msg.axes[0] == pytest.approx(0.0, abs=1e-6) - - -def test_hold_axis_clamps_at_target(node): - node.on_press(KeyChar("w")) - - for _ in range(10): - node.update_active_axes() - - assert node.joy_msg.axes[0] == pytest.approx(1.0, abs=1e-6) - - -def test_sticky_axis_accumulates_and_clamps(node): - for _ in range(10): - node.on_press(KeyChar("e")) - node.on_release(KeyChar("e")) - - assert node.joy_msg.axes[1] == pytest.approx(1.0, abs=1e-6) - - for _ in range(20): - node.on_press(KeyChar("q")) - node.on_release(KeyChar("q")) - - assert node.joy_msg.axes[1] == pytest.approx(-1.0, abs=1e-6) - - -def test_listener_is_stopped_on_destroy(node): - assert isinstance(node.listener, DummyListener) - assert node.listener.started is True - - node.destroy_node() - assert node.listener.stopped is True From f38ab63188edf8323a4ddd2dad8fb3cfce389da1 Mon Sep 17 00:00:00 2001 From: Andreas Kluge Svendsrud <89779148+kluge7@users.noreply.github.com> Date: Sat, 17 Jan 2026 20:13:53 +0100 Subject: [PATCH 12/19] refactor(keyboard_joy): rename timing params and improve documentation --- mission/keyboard_joy/config/key_mappings.yaml | 12 +++- .../keyboard_joy/keyboard_joy_node.py | 61 ++++++++++--------- 2 files changed, 43 insertions(+), 30 deletions(-) diff --git a/mission/keyboard_joy/config/key_mappings.yaml b/mission/keyboard_joy/config/key_mappings.yaml index 189caf33d..c302a66cd 100644 --- a/mission/keyboard_joy/config/key_mappings.yaml +++ b/mission/keyboard_joy/config/key_mappings.yaml @@ -1,4 +1,9 @@ axes: + # Each mapping is [axis_index, value, mode] + # axis_index is which Joy.axes[] index to control + # value is the target value while the key is held (-1.0 to 1.0) + # mode is 'hold' or 'sticky' + # Left stick (surge/sway) w: [1, 1.0, 'hold'] s: [1, -1.0, 'hold'] @@ -16,8 +21,11 @@ axes: Key.right: [3, -1.0, 'hold'] parameters: - axis_increment_rate: 0.02 # update interval (higher = slower response) - axis_increment_step: 0.1 # axis change per update (higher = faster movement) + axis_update_period: 0.02 # seconds per update + publish_period: 0.02 # seconds per publish + + # Motion tuning + axis_increment_step: 0.1 # axis change per update (higher = faster movement) buttons: diff --git a/mission/keyboard_joy/keyboard_joy/keyboard_joy_node.py b/mission/keyboard_joy/keyboard_joy/keyboard_joy_node.py index 8ba68265e..738c46445 100755 --- a/mission/keyboard_joy/keyboard_joy/keyboard_joy_node.py +++ b/mission/keyboard_joy/keyboard_joy/keyboard_joy_node.py @@ -8,6 +8,7 @@ from pynput import keyboard from rclpy.node import Node from sensor_msgs.msg import Joy +from rclpy.parameter import Parameter start_message = r""" ██ ▄█▀▓█████▓██ ██▓ ▄▄▄▄ ▒█████ ▄▄▄ ██▀███ ▓█████▄ ▄▄▄██▀▀▀▒█████ ▓██ ██▓ @@ -27,17 +28,14 @@ class KeyboardJoy(Node): def __init__(self): super().__init__('keyboard_joy') - self.declare_parameter('config', '') - self.load_key_mappings() + self.declare_parameter('config', Parameter.Type.STRING) + self.load_key_mappings() - self.joy_pub = self.create_publisher(Joy, 'joy', 10) + self.declare_parameter('topics.joy', Parameter.Type.STRING) + joy_topic = self.get_parameter('topics.joy').value + self.joy_pub = self.create_publisher(Joy, joy_topic, 10) - max_axis_index = max((v[0] for v in self.axis_mappings.values()), default=-1) - max_button_index = max(self.button_mappings.values(), default=-1) - - self.joy_msg = Joy() - self.joy_msg.axes = [0.0] * (max_axis_index + 1) - self.joy_msg.buttons = [0] * (max_button_index + 1) + self._init_joy_message() self.active_axes = {} self.sticky_axes = {} @@ -49,18 +47,16 @@ def __init__(self): ) self.listener.start() - self.create_timer(0.05, self.publish_joy) - self.create_timer(self.axis_increment_rate, self.update_active_axes) + self.create_timer(self.publish_period, self.publish_joy) + self.create_timer(self.axis_update_period, self.update_active_axes) self.get_logger().info(start_message) def load_key_mappings(self): - config_file = self.get_parameter( - 'config' - ).get_parameter_value().string_value or os.path.join( - get_package_share_directory('keyboard_joy'), 'config', 'key_mappings.yaml' - ) - + config_file = self.get_parameter('config').value + if not config_file: + raise RuntimeError("Parameter 'config' is required (pass it from launch).") + try: with open(config_file) as f: keymap = yaml.safe_load(f) or {} @@ -72,9 +68,25 @@ def load_key_mappings(self): self.button_mappings = keymap.get('buttons', {}) params = keymap.get('parameters', {}) - self.axis_increment_rate = float(params.get('axis_increment_rate', 0.02)) + self.axis_update_period = float(params.get('axis_update_period', 0.02)) + self.publish_period = float(params.get('publish_period', 0.05)) self.axis_increment_step = float(params.get('axis_increment_step', 0.05)) + + def _init_joy_message(self): + # Find the highest axis index used in the key mappings (0 based) + max_axis_index = max((v[0] for v in self.axis_mappings.values()), default=-1) + + # Find the highest button index used in the key mappings (0 based) + max_button_index = max(self.button_mappings.values(), default=-1) + + self.joy_msg = Joy() + self.joy_msg.header.frame_id = "keyboard" + + # Allocate arrays large enough to cover the highest index (+1 because 0 based) + self.joy_msg.axes = [0.0] * (max_axis_index + 1) + self.joy_msg.buttons = [0] * (max_button_index + 1) + def on_press(self, key): key_str = self.key_to_string(key) if not key_str: @@ -112,7 +124,6 @@ def on_release(self, key): @staticmethod def key_to_string(key): - """Convert pynput key event to a normalized string.""" if hasattr(key, 'char') and key.char: return key.char.lower() if hasattr(key, 'name') and key.name: @@ -120,19 +131,12 @@ def key_to_string(key): return None def update_active_axes(self): - """Gradually update active axes towards their target values.""" with self.lock: for axis, target in self.active_axes.items(): current = self.joy_msg.axes[axis] - delta = ( - self.axis_increment_step - if target > 0 - else -self.axis_increment_step - ) + delta = self.axis_increment_step if target > 0 else -self.axis_increment_step next_val = current + delta - if (delta > 0 and next_val > target) or ( - delta < 0 and next_val < target - ): + if (delta > 0 and next_val > target) or (delta < 0 and next_val < target): next_val = target self.joy_msg.axes[axis] = round(next_val, 3) @@ -161,3 +165,4 @@ def main(args=None): if __name__ == '__main__': main() + From 6599bda0f94366dac7f70478dc12c3b8bce62cae Mon Sep 17 00:00:00 2001 From: Andreas Kluge Svendsrud <89779148+kluge7@users.noreply.github.com> Date: Sat, 17 Jan 2026 20:24:43 +0100 Subject: [PATCH 13/19] refactor(keyboard_joy): replace axis tuples with dataclass and Enum --- .../keyboard_joy/keyboard_joy_node.py | 102 ++++++++++++------ 1 file changed, 67 insertions(+), 35 deletions(-) diff --git a/mission/keyboard_joy/keyboard_joy/keyboard_joy_node.py b/mission/keyboard_joy/keyboard_joy/keyboard_joy_node.py index 738c46445..ebe982d1b 100755 --- a/mission/keyboard_joy/keyboard_joy/keyboard_joy_node.py +++ b/mission/keyboard_joy/keyboard_joy/keyboard_joy_node.py @@ -1,14 +1,14 @@ #!/usr/bin/env python3 -import os import threading +from dataclasses import dataclass +from enum import Enum import rclpy import yaml -from ament_index_python.packages import get_package_share_directory from pynput import keyboard from rclpy.node import Node -from sensor_msgs.msg import Joy from rclpy.parameter import Parameter +from sensor_msgs.msg import Joy start_message = r""" ██ ▄█▀▓█████▓██ ██▓ ▄▄▄▄ ▒█████ ▄▄▄ ██▀███ ▓█████▄ ▄▄▄██▀▀▀▒█████ ▓██ ██▓ @@ -24,15 +24,27 @@ """ +class AxisMode(Enum): + HOLD = "hold" + STICKY = "sticky" + + +@dataclass(frozen=True) +class AxisBinding: + axis: int + val: float + mode: AxisMode + + class KeyboardJoy(Node): def __init__(self): - super().__init__('keyboard_joy') + super().__init__("keyboard_joy") - self.declare_parameter('config', Parameter.Type.STRING) - self.load_key_mappings() + self.declare_parameter("config", Parameter.Type.STRING) + self.load_key_mappings() - self.declare_parameter('topics.joy', Parameter.Type.STRING) - joy_topic = self.get_parameter('topics.joy').value + self.declare_parameter("topics.joy", Parameter.Type.STRING) + joy_topic = self.get_parameter("topics.joy").value self.joy_pub = self.create_publisher(Joy, joy_topic, 10) self._init_joy_message() @@ -53,10 +65,10 @@ def __init__(self): self.get_logger().info(start_message) def load_key_mappings(self): - config_file = self.get_parameter('config').value + config_file = self.get_parameter("config").value if not config_file: raise RuntimeError("Parameter 'config' is required (pass it from launch).") - + try: with open(config_file) as f: keymap = yaml.safe_load(f) or {} @@ -64,26 +76,35 @@ def load_key_mappings(self): self.get_logger().error(f"Failed to load config '{config_file}': {e}") keymap = {} - self.axis_mappings = keymap.get('axes', {}) - self.button_mappings = keymap.get('buttons', {}) - params = keymap.get('parameters', {}) + raw_axes = keymap.get("axes", {}) or {} + self.axis_mappings = {} + for key, spec in raw_axes.items(): + # YAML format: [axis_index, value, "sticky"/"hold"] + axis, val, mode = spec + self.axis_mappings[key] = AxisBinding( + axis=int(axis), + val=float(val), + mode=AxisMode(mode), + ) - self.axis_update_period = float(params.get('axis_update_period', 0.02)) - self.publish_period = float(params.get('publish_period', 0.05)) - self.axis_increment_step = float(params.get('axis_increment_step', 0.05)) + self.button_mappings = keymap.get("buttons", {}) or {} + params = keymap.get("parameters", {}) or {} + self.axis_update_period = float(params.get("axis_update_period", 0.02)) + self.publish_period = float(params.get("publish_period", 0.05)) + self.axis_increment_step = float(params.get("axis_increment_step", 0.05)) def _init_joy_message(self): - # Find the highest axis index used in the key mappings (0 based) - max_axis_index = max((v[0] for v in self.axis_mappings.values()), default=-1) + # Find the highest axis index used in the key mappings (0-based) + max_axis_index = max((b.axis for b in self.axis_mappings.values()), default=-1) - # Find the highest button index used in the key mappings (0 based) + # Find the highest button index used in the key mappings (0-based) max_button_index = max(self.button_mappings.values(), default=-1) self.joy_msg = Joy() self.joy_msg.header.frame_id = "keyboard" - # Allocate arrays large enough to cover the highest index (+1 because 0 based) + # Allocate arrays large enough to cover the highest index (+1 because 0-based) self.joy_msg.axes = [0.0] * (max_axis_index + 1) self.joy_msg.buttons = [0] * (max_button_index + 1) @@ -91,21 +112,25 @@ def on_press(self, key): key_str = self.key_to_string(key) if not key_str: return + with self.lock: if key_str in self.axis_mappings: - axis, val, mode = self.axis_mappings[key_str] - if mode == 'sticky': + binding = self.axis_mappings[key_str] + + if binding.mode == AxisMode.STICKY: + axis = binding.axis self.sticky_axes[axis] = max( min( self.sticky_axes.get(axis, 0.0) - + val * self.axis_increment_step, + + binding.val * self.axis_increment_step, 1.0, ), -1.0, ) self.joy_msg.axes[axis] = round(self.sticky_axes[axis], 3) else: - self.active_axes[axis] = val + self.active_axes[binding.axis] = binding.val + elif key_str in self.button_mappings: self.joy_msg.buttons[self.button_mappings[key_str]] = 1 @@ -113,30 +138,38 @@ def on_release(self, key): key_str = self.key_to_string(key) if not key_str: return + with self.lock: if key_str in self.axis_mappings: - axis, _, mode = self.axis_mappings[key_str] - self.active_axes.pop(axis, None) - if mode != 'sticky': - self.joy_msg.axes[axis] = 0.0 + binding = self.axis_mappings[key_str] + self.active_axes.pop(binding.axis, None) + if binding.mode != AxisMode.STICKY: + self.joy_msg.axes[binding.axis] = 0.0 + elif key_str in self.button_mappings: self.joy_msg.buttons[self.button_mappings[key_str]] = 0 @staticmethod def key_to_string(key): - if hasattr(key, 'char') and key.char: + if hasattr(key, "char") and key.char: return key.char.lower() - if hasattr(key, 'name') and key.name: - return f'Key.{key.name}' + if hasattr(key, "name") and key.name: + return f"Key.{key.name}" return None def update_active_axes(self): with self.lock: for axis, target in self.active_axes.items(): current = self.joy_msg.axes[axis] - delta = self.axis_increment_step if target > 0 else -self.axis_increment_step + delta = ( + self.axis_increment_step + if target > 0 + else -self.axis_increment_step + ) next_val = current + delta - if (delta > 0 and next_val > target) or (delta < 0 and next_val < target): + if (delta > 0 and next_val > target) or ( + delta < 0 and next_val < target + ): next_val = target self.joy_msg.axes[axis] = round(next_val, 3) @@ -163,6 +196,5 @@ def main(args=None): rclpy.shutdown() -if __name__ == '__main__': +if __name__ == "__main__": main() - From 43e9c10cba16ada0644043beb1d5fb96f3272653 Mon Sep 17 00:00:00 2001 From: Andreas Kluge Svendsrud <89779148+kluge7@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:00:16 +0100 Subject: [PATCH 14/19] refactor(keyboard_joy): split into separate node and logic file and add unit tests --- mission/keyboard_joy/config/key_mappings.yaml | 8 + .../keyboard_joy/keyboard_joy_core.py | 170 +++++++++++++++++ .../keyboard_joy/keyboard_joy_node.py | 135 +++---------- mission/keyboard_joy/package.xml | 1 + mission/keyboard_joy/pytest.ini | 2 + mission/keyboard_joy/setup.py | 2 +- .../test/test_keyboard_joy_core.py | 180 ++++++++++++++++++ 7 files changed, 386 insertions(+), 112 deletions(-) create mode 100644 mission/keyboard_joy/keyboard_joy/keyboard_joy_core.py create mode 100644 mission/keyboard_joy/pytest.ini create mode 100644 mission/keyboard_joy/test/test_keyboard_joy_core.py diff --git a/mission/keyboard_joy/config/key_mappings.yaml b/mission/keyboard_joy/config/key_mappings.yaml index c302a66cd..50654591d 100644 --- a/mission/keyboard_joy/config/key_mappings.yaml +++ b/mission/keyboard_joy/config/key_mappings.yaml @@ -20,6 +20,14 @@ axes: Key.left: [3, 1.0, 'hold'] Key.right: [3, -1.0, 'hold'] + # Vertical (axis 7) + Key.home: [7, 1.0, 'hold'] # Numpad 7 → D-pad up + Key.end: [7, -1.0, 'hold'] # Numpad 1 → D-pad down + + # Horizontal (axis 6) + Key.page_up: [6, 1.0, 'hold'] # Numpad 9 → D-pad right + Key.page_down: [6, -1.0, 'hold'] # Numpad 3 → D-pad left + parameters: axis_update_period: 0.02 # seconds per update publish_period: 0.02 # seconds per publish diff --git a/mission/keyboard_joy/keyboard_joy/keyboard_joy_core.py b/mission/keyboard_joy/keyboard_joy/keyboard_joy_core.py new file mode 100644 index 000000000..7f94d8957 --- /dev/null +++ b/mission/keyboard_joy/keyboard_joy/keyboard_joy_core.py @@ -0,0 +1,170 @@ +# keyboard_joy_core.py +from __future__ import annotations + +import threading +from dataclasses import dataclass +from enum import Enum + +import yaml + + +class AxisMode(Enum): + HOLD = "hold" + STICKY = "sticky" + + +@dataclass(frozen=True) +class AxisBinding: + axis: int + val: float + mode: AxisMode + + +@dataclass(frozen=True) +class JoyState: + axes: list[float] + buttons: list[int] + frame_id: str = "keyboard" + + +class KeyboardJoyCore: + def __init__( + self, + axis_mappings: dict[str, AxisBinding], + button_mappings: dict[str, int], + *, + axis_update_period: float = 0.02, + publish_period: float = 0.05, + axis_increment_step: float = 0.05, + frame_id: str = "keyboard", + ): + self.axis_mappings = axis_mappings + self.button_mappings = button_mappings + + self.axis_update_period = float(axis_update_period) + self.publish_period = float(publish_period) + self.axis_increment_step = float(axis_increment_step) + + self._frame_id = frame_id + + self._active_axes: dict[int, float] = {} + self._sticky_axes: dict[int, float] = {} + + max_axis_index = max((b.axis for b in self.axis_mappings.values()), default=-1) + max_button_index = max(self.button_mappings.values(), default=-1) + + self._axes = [0.0] * (max_axis_index + 1) + self._buttons = [0] * (max_button_index + 1) + + self._lock = threading.Lock() + + @classmethod + def from_yaml_file(cls, config_file: str) -> KeyboardJoyCore: + with open(config_file, encoding="utf-8") as f: + keymap = yaml.safe_load(f) or {} + + raw_axes = keymap.get("axes", {}) or {} + axis_mappings: dict[str, AxisBinding] = {} + for key, spec in raw_axes.items(): + # YAML format: [axis_index, value, "sticky"/"hold"] + axis, val, mode = spec + axis_mappings[key] = AxisBinding( + axis=int(axis), + val=float(val), + mode=AxisMode(str(mode)), + ) + + button_mappings = keymap.get("buttons", {}) or {} + + params = keymap.get("parameters", {}) or {} + axis_update_period = float(params.get("axis_update_period", 0.02)) + publish_period = float(params.get("publish_period", 0.05)) + axis_increment_step = float(params.get("axis_increment_step", 0.05)) + + return cls( + axis_mappings=axis_mappings, + button_mappings=button_mappings, + axis_update_period=axis_update_period, + publish_period=publish_period, + axis_increment_step=axis_increment_step, + ) + + def press(self, key_str: str) -> None: + if not key_str: + return + + with self._lock: + if key_str in self.axis_mappings: + binding = self.axis_mappings[key_str] + + if binding.mode == AxisMode.STICKY: + axis = binding.axis + new_val = ( + self._sticky_axes.get(axis, 0.0) + + binding.val * self.axis_increment_step + ) + new_val = max(min(new_val, 1.0), -1.0) + self._sticky_axes[axis] = new_val + self._axes[axis] = round(new_val, 3) + else: + # HOLD mode: ramp toward binding.val while key is held + self._active_axes[binding.axis] = binding.val + + elif key_str in self.button_mappings: + idx = int(self.button_mappings[key_str]) + if idx >= len(self._buttons): + self._buttons.extend([0] * (idx + 1 - len(self._buttons))) + self._buttons[idx] = 1 + + def release(self, key_str: str) -> None: + if not key_str: + return + + with self._lock: + if key_str in self.axis_mappings: + binding = self.axis_mappings[key_str] + self._active_axes.pop(binding.axis, None) + if binding.mode != AxisMode.STICKY: + self._axes[binding.axis] = 0.0 + + elif key_str in self.button_mappings: + idx = int(self.button_mappings[key_str]) + if idx < len(self._buttons): + self._buttons[idx] = 0 + + def update_active_axes(self) -> None: + """For HOLD axes: move axis value toward target at axis_increment_step per call.""" + with self._lock: + for axis, target in list(self._active_axes.items()): + if axis >= len(self._axes): + self._axes.extend([0.0] * (axis + 1 - len(self._axes))) + + current = self._axes[axis] + delta = ( + self.axis_increment_step + if target > 0 + else -self.axis_increment_step + ) + next_val = current + delta + + if (delta > 0 and next_val > target) or ( + delta < 0 and next_val < target + ): + next_val = target + + self._axes[axis] = round(next_val, 3) + + def get_state(self) -> JoyState: + with self._lock: + return JoyState( + axes=list(self._axes), + buttons=list(self._buttons), + frame_id=self._frame_id, + ) + + # Helper for tests/debug + def set_axis(self, axis: int, value: float) -> None: + with self._lock: + if axis >= len(self._axes): + self._axes.extend([0.0] * (axis + 1 - len(self._axes))) + self._axes[axis] = round(max(min(float(value), 1.0), -1.0), 3) diff --git a/mission/keyboard_joy/keyboard_joy/keyboard_joy_node.py b/mission/keyboard_joy/keyboard_joy/keyboard_joy_node.py index ebe982d1b..c1675e393 100755 --- a/mission/keyboard_joy/keyboard_joy/keyboard_joy_node.py +++ b/mission/keyboard_joy/keyboard_joy/keyboard_joy_node.py @@ -1,15 +1,14 @@ #!/usr/bin/env python3 import threading -from dataclasses import dataclass -from enum import Enum import rclpy -import yaml from pynput import keyboard from rclpy.node import Node from rclpy.parameter import Parameter from sensor_msgs.msg import Joy +from keyboard_joy.keyboard_joy_core import KeyboardJoyCore + start_message = r""" ██ ▄█▀▓█████▓██ ██▓ ▄▄▄▄ ▒█████ ▄▄▄ ██▀███ ▓█████▄ ▄▄▄██▀▀▀▒█████ ▓██ ██▓ ██▄█▒ ▓█ ▀ ▒██ ██▒▓█████▄ ▒██▒ ██▒▒████▄ ▓██ ▒ ██▒▒██▀ ██▌ ▒██ ▒██▒ ██▒▒██ ██▒ @@ -24,130 +23,50 @@ """ -class AxisMode(Enum): - HOLD = "hold" - STICKY = "sticky" - - -@dataclass(frozen=True) -class AxisBinding: - axis: int - val: float - mode: AxisMode - - class KeyboardJoy(Node): def __init__(self): super().__init__("keyboard_joy") self.declare_parameter("config", Parameter.Type.STRING) - self.load_key_mappings() + config_file = self.get_parameter("config").value + if not config_file: + raise RuntimeError("Parameter 'config' is required (pass it from launch).") + + self.core = KeyboardJoyCore.from_yaml_file(config_file) self.declare_parameter("topics.joy", Parameter.Type.STRING) joy_topic = self.get_parameter("topics.joy").value self.joy_pub = self.create_publisher(Joy, joy_topic, 10) - self._init_joy_message() - - self.active_axes = {} - self.sticky_axes = {} + self.joy_msg = Joy() + self.joy_msg.header.frame_id = "keyboard" self.lock = threading.Lock() self.listener = keyboard.Listener( - on_press=self.on_press, on_release=self.on_release + on_press=self.on_press, + on_release=self.on_release, ) self.listener.start() - self.create_timer(self.publish_period, self.publish_joy) - self.create_timer(self.axis_update_period, self.update_active_axes) + self.create_timer(self.core.publish_period, self.publish_joy) + self.create_timer(self.core.axis_update_period, self.update_active_axes) self.get_logger().info(start_message) - def load_key_mappings(self): - config_file = self.get_parameter("config").value - if not config_file: - raise RuntimeError("Parameter 'config' is required (pass it from launch).") - - try: - with open(config_file) as f: - keymap = yaml.safe_load(f) or {} - except Exception as e: - self.get_logger().error(f"Failed to load config '{config_file}': {e}") - keymap = {} - - raw_axes = keymap.get("axes", {}) or {} - self.axis_mappings = {} - for key, spec in raw_axes.items(): - # YAML format: [axis_index, value, "sticky"/"hold"] - axis, val, mode = spec - self.axis_mappings[key] = AxisBinding( - axis=int(axis), - val=float(val), - mode=AxisMode(mode), - ) - - self.button_mappings = keymap.get("buttons", {}) or {} - - params = keymap.get("parameters", {}) or {} - self.axis_update_period = float(params.get("axis_update_period", 0.02)) - self.publish_period = float(params.get("publish_period", 0.05)) - self.axis_increment_step = float(params.get("axis_increment_step", 0.05)) - - def _init_joy_message(self): - # Find the highest axis index used in the key mappings (0-based) - max_axis_index = max((b.axis for b in self.axis_mappings.values()), default=-1) - - # Find the highest button index used in the key mappings (0-based) - max_button_index = max(self.button_mappings.values(), default=-1) - - self.joy_msg = Joy() - self.joy_msg.header.frame_id = "keyboard" - - # Allocate arrays large enough to cover the highest index (+1 because 0-based) - self.joy_msg.axes = [0.0] * (max_axis_index + 1) - self.joy_msg.buttons = [0] * (max_button_index + 1) - def on_press(self, key): key_str = self.key_to_string(key) if not key_str: return - with self.lock: - if key_str in self.axis_mappings: - binding = self.axis_mappings[key_str] - - if binding.mode == AxisMode.STICKY: - axis = binding.axis - self.sticky_axes[axis] = max( - min( - self.sticky_axes.get(axis, 0.0) - + binding.val * self.axis_increment_step, - 1.0, - ), - -1.0, - ) - self.joy_msg.axes[axis] = round(self.sticky_axes[axis], 3) - else: - self.active_axes[binding.axis] = binding.val - - elif key_str in self.button_mappings: - self.joy_msg.buttons[self.button_mappings[key_str]] = 1 + self.core.press(key_str) def on_release(self, key): key_str = self.key_to_string(key) if not key_str: return - with self.lock: - if key_str in self.axis_mappings: - binding = self.axis_mappings[key_str] - self.active_axes.pop(binding.axis, None) - if binding.mode != AxisMode.STICKY: - self.joy_msg.axes[binding.axis] = 0.0 - - elif key_str in self.button_mappings: - self.joy_msg.buttons[self.button_mappings[key_str]] = 0 + self.core.release(key_str) @staticmethod def key_to_string(key): @@ -159,24 +78,18 @@ def key_to_string(key): def update_active_axes(self): with self.lock: - for axis, target in self.active_axes.items(): - current = self.joy_msg.axes[axis] - delta = ( - self.axis_increment_step - if target > 0 - else -self.axis_increment_step - ) - next_val = current + delta - if (delta > 0 and next_val > target) or ( - delta < 0 and next_val < target - ): - next_val = target - self.joy_msg.axes[axis] = round(next_val, 3) + self.core.update_active_axes() def publish_joy(self): with self.lock: - self.joy_msg.header.stamp = self.get_clock().now().to_msg() - self.joy_pub.publish(self.joy_msg) + state = self.core.get_state() + + # Convert core state -> ROS Joy + self.joy_msg.header.stamp = self.get_clock().now().to_msg() + self.joy_msg.header.frame_id = state.frame_id + self.joy_msg.axes = state.axes + self.joy_msg.buttons = state.buttons + self.joy_pub.publish(self.joy_msg) def destroy_node(self): if self.listener: diff --git a/mission/keyboard_joy/package.xml b/mission/keyboard_joy/package.xml index 3d2a4f17a..9ac20dfe4 100644 --- a/mission/keyboard_joy/package.xml +++ b/mission/keyboard_joy/package.xml @@ -10,6 +10,7 @@ rclpy sensor_msgs python3-pynput + python3-yaml ament_pytest python3-pytest diff --git a/mission/keyboard_joy/pytest.ini b/mission/keyboard_joy/pytest.ini new file mode 100644 index 000000000..226641919 --- /dev/null +++ b/mission/keyboard_joy/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = test diff --git a/mission/keyboard_joy/setup.py b/mission/keyboard_joy/setup.py index 8bf70451e..a045633a5 100644 --- a/mission/keyboard_joy/setup.py +++ b/mission/keyboard_joy/setup.py @@ -15,7 +15,7 @@ (os.path.join('share', package_name, 'launch'), glob('launch/*.launch.py')), (os.path.join('share', package_name, 'config'), glob('config/*.yaml')), ], - install_requires=['setuptools'], + install_requires=['setuptools', 'pyyaml'], tests_require=['pytest'], zip_safe=True, maintainer='kluge7', diff --git a/mission/keyboard_joy/test/test_keyboard_joy_core.py b/mission/keyboard_joy/test/test_keyboard_joy_core.py new file mode 100644 index 000000000..388552c1b --- /dev/null +++ b/mission/keyboard_joy/test/test_keyboard_joy_core.py @@ -0,0 +1,180 @@ +# test/test_keyboard_joy_core.py + +from keyboard_joy.keyboard_joy_core import AxisBinding, AxisMode, KeyboardJoyCore + + +def test_initializes_axes_and_buttons_to_max_index(): + core = KeyboardJoyCore( + axis_mappings={ + "w": AxisBinding(axis=1, val=1.0, mode=AxisMode.HOLD), + "a": AxisBinding(axis=5, val=-1.0, mode=AxisMode.HOLD), + }, + button_mappings={ + "1": 0, + "q": 7, + }, + ) + + state = core.get_state() + assert len(state.axes) == 6 + assert len(state.buttons) == 8 + assert state.axes == [0.0] * 6 + assert state.buttons == [0] * 8 + + +def test_hold_axis_ramps_toward_target_and_stops_at_target(): + core = KeyboardJoyCore( + axis_mappings={"w": AxisBinding(axis=1, val=1.0, mode=AxisMode.HOLD)}, + button_mappings={}, + axis_increment_step=0.3, + ) + + core.press("w") + core.update_active_axes() + assert core.get_state().axes[1] == 0.3 + + core.update_active_axes() + assert core.get_state().axes[1] == 0.6 + + core.update_active_axes() + assert core.get_state().axes[1] == 0.9 + + core.update_active_axes() + assert core.get_state().axes[1] == 1.0 + + core.update_active_axes() + assert core.get_state().axes[1] == 1.0 + + +def test_hold_axis_negative_ramps_down_and_stops_at_target(): + core = KeyboardJoyCore( + axis_mappings={"s": AxisBinding(axis=0, val=-1.0, mode=AxisMode.HOLD)}, + button_mappings={}, + axis_increment_step=0.4, + ) + + core.press("s") + core.update_active_axes() + assert core.get_state().axes[0] == -0.4 + + core.update_active_axes() + assert core.get_state().axes[0] == -0.8 + + core.update_active_axes() + assert core.get_state().axes[0] == -1.0 + + +def test_hold_axis_release_resets_axis_to_zero(): + core = KeyboardJoyCore( + axis_mappings={"w": AxisBinding(axis=1, val=1.0, mode=AxisMode.HOLD)}, + button_mappings={}, + axis_increment_step=0.5, + ) + + core.press("w") + core.update_active_axes() + assert core.get_state().axes[1] == 0.5 + + core.release("w") + assert core.get_state().axes[1] == 0.0 + + core.update_active_axes() + assert core.get_state().axes[1] == 0.0 + + +def test_sticky_axis_accumulates_in_steps_and_clamps(): + core = KeyboardJoyCore( + axis_mappings={ + "i": AxisBinding(axis=2, val=1.0, mode=AxisMode.STICKY), + "k": AxisBinding(axis=2, val=-1.0, mode=AxisMode.STICKY), + }, + button_mappings={}, + axis_increment_step=0.25, + ) + + core.press("i") + assert core.get_state().axes[2] == 0.25 + core.press("i") + assert core.get_state().axes[2] == 0.5 + + core.press("k") + assert core.get_state().axes[2] == 0.25 + + for _ in range(10): + core.press("i") + assert core.get_state().axes[2] == 1.0 + + for _ in range(20): + core.press("k") + assert core.get_state().axes[2] == -1.0 + + +def test_sticky_axis_release_does_not_reset(): + core = KeyboardJoyCore( + axis_mappings={"i": AxisBinding(axis=0, val=1.0, mode=AxisMode.STICKY)}, + button_mappings={}, + axis_increment_step=0.5, + ) + + core.press("i") + assert core.get_state().axes[0] == 0.5 + + core.release("i") + assert core.get_state().axes[0] == 0.5 + + +def test_button_press_and_release_sets_button_state(): + core = KeyboardJoyCore( + axis_mappings={}, + button_mappings={" ": 0, "q": 4}, + ) + + core.press(" ") + assert core.get_state().buttons[0] == 1 + core.release(" ") + assert core.get_state().buttons[0] == 0 + + core.press("q") + assert core.get_state().buttons[4] == 1 + core.release("q") + assert core.get_state().buttons[4] == 0 + + +def test_button_press_extends_buttons_array_if_needed(): + core = KeyboardJoyCore(axis_mappings={}, button_mappings={"x": 12}) + assert len(core.get_state().buttons) == 13 + + core2 = KeyboardJoyCore(axis_mappings={}, button_mappings={}) + assert len(core2.get_state().buttons) == 0 + core2.button_mappings["x"] = 12 + core2.press("x") + assert len(core2.get_state().buttons) == 13 + assert core2.get_state().buttons[12] == 1 + + +def test_unknown_keys_do_not_change_state(): + core = KeyboardJoyCore( + axis_mappings={"w": AxisBinding(axis=0, val=1.0, mode=AxisMode.HOLD)}, + button_mappings={" ": 0}, + ) + before = core.get_state() + + core.press("does-not-exist") + core.release("does-not-exist") + core.update_active_axes() + + after = core.get_state() + assert after.axes == before.axes + assert after.buttons == before.buttons + + +def test_set_axis_clamps_and_rounds(): + core = KeyboardJoyCore(axis_mappings={}, button_mappings={}) + core.set_axis(3, 1.23456) + assert core.get_state().axes[3] == 1.0 + + core.set_axis(3, -1.23456) + assert core.get_state().axes[3] == -1.0 + + core.set_axis(3, 0.33333) + assert core.get_state().axes[3] == 0.333 From e45438485c5c06f00e38e3442fa9d5fe203c8f68 Mon Sep 17 00:00:00 2001 From: Andreas Kluge Svendsrud <89779148+kluge7@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:02:04 +0100 Subject: [PATCH 15/19] ci: undo temporary branch used for indistrual ci back to main --- .github/workflows/industrial-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/industrial-ci.yml b/.github/workflows/industrial-ci.yml index 02987c990..d2c0e782f 100644 --- a/.github/workflows/industrial-ci.yml +++ b/.github/workflows/industrial-ci.yml @@ -11,7 +11,7 @@ on: - cron: '0 1 * * *' # Runs daily to check for dependency issues or flaking tests jobs: call_reusable_workflow: - uses: vortexntnu/vortex-ci/.github/workflows/reusable-industrial-ci.yml@debs/ament-pytest + uses: vortexntnu/vortex-ci/.github/workflows/reusable-industrial-ci.yml@main with: upstream_workspace: './dependencies.repos' before_install_upstream_dependencies: './scripts/ci_install_dependencies.sh' From b707ca93b39a1d9bd8fb341d1f580427c49d2543 Mon Sep 17 00:00:00 2001 From: Andreas Kluge Svendsrud <89779148+kluge7@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:38:44 +0100 Subject: [PATCH 16/19] chore(keyboard_joy): remove ament_pytest from package.xml since it isnt a resolvable rosdep key --- mission/keyboard_joy/package.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/mission/keyboard_joy/package.xml b/mission/keyboard_joy/package.xml index 9ac20dfe4..19171abdb 100644 --- a/mission/keyboard_joy/package.xml +++ b/mission/keyboard_joy/package.xml @@ -12,7 +12,6 @@ python3-pynput python3-yaml - ament_pytest python3-pytest From 36ca3c0454607be06550585d5613dddffc81ed11 Mon Sep 17 00:00:00 2001 From: Andreas Kluge Svendsrud <89779148+kluge7@users.noreply.github.com> Date: Thu, 29 Jan 2026 14:02:10 +0100 Subject: [PATCH 17/19] refactor(keyboard_joy): remove unnecessary lock from keyboard_joy_node.py --- .../keyboard_joy/keyboard_joy_node.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/mission/keyboard_joy/keyboard_joy/keyboard_joy_node.py b/mission/keyboard_joy/keyboard_joy/keyboard_joy_node.py index c1675e393..b3a500d4f 100755 --- a/mission/keyboard_joy/keyboard_joy/keyboard_joy_node.py +++ b/mission/keyboard_joy/keyboard_joy/keyboard_joy_node.py @@ -1,6 +1,4 @@ #!/usr/bin/env python3 -import threading - import rclpy from pynput import keyboard from rclpy.node import Node @@ -41,8 +39,6 @@ def __init__(self): self.joy_msg = Joy() self.joy_msg.header.frame_id = "keyboard" - self.lock = threading.Lock() - self.listener = keyboard.Listener( on_press=self.on_press, on_release=self.on_release, @@ -58,15 +54,13 @@ def on_press(self, key): key_str = self.key_to_string(key) if not key_str: return - with self.lock: - self.core.press(key_str) + self.core.press(key_str) def on_release(self, key): key_str = self.key_to_string(key) if not key_str: return - with self.lock: - self.core.release(key_str) + self.core.release(key_str) @staticmethod def key_to_string(key): @@ -77,14 +71,11 @@ def key_to_string(key): return None def update_active_axes(self): - with self.lock: - self.core.update_active_axes() + self.core.update_active_axes() def publish_joy(self): - with self.lock: - state = self.core.get_state() + state = self.core.get_state() - # Convert core state -> ROS Joy self.joy_msg.header.stamp = self.get_clock().now().to_msg() self.joy_msg.header.frame_id = state.frame_id self.joy_msg.axes = state.axes From 71488e6edaf49cefffdedf4969e628bcd2ee8518 Mon Sep 17 00:00:00 2001 From: Andreas Kluge Svendsrud <89779148+kluge7@users.noreply.github.com> Date: Thu, 29 Jan 2026 19:14:13 +0100 Subject: [PATCH 18/19] chore: remove COLCON_IGNORE files which were used for testing --- filtering/COLCON_IGNORE | 0 mission/FSM/COLCON_IGNORE | 0 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 filtering/COLCON_IGNORE delete mode 100644 mission/FSM/COLCON_IGNORE diff --git a/filtering/COLCON_IGNORE b/filtering/COLCON_IGNORE deleted file mode 100644 index e69de29bb..000000000 diff --git a/mission/FSM/COLCON_IGNORE b/mission/FSM/COLCON_IGNORE deleted file mode 100644 index e69de29bb..000000000 From d5344fb0dfebc30ffc1974568421eafcb788b97e Mon Sep 17 00:00:00 2001 From: Andreas Kluge Svendsrud <89779148+kluge7@users.noreply.github.com> Date: Thu, 29 Jan 2026 19:39:06 +0100 Subject: [PATCH 19/19] chore: remove unnecessary file comments from keyboard_joy --- mission/keyboard_joy/keyboard_joy/keyboard_joy_core.py | 1 - mission/keyboard_joy/test/test_keyboard_joy_core.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/mission/keyboard_joy/keyboard_joy/keyboard_joy_core.py b/mission/keyboard_joy/keyboard_joy/keyboard_joy_core.py index 7f94d8957..182f148f5 100644 --- a/mission/keyboard_joy/keyboard_joy/keyboard_joy_core.py +++ b/mission/keyboard_joy/keyboard_joy/keyboard_joy_core.py @@ -1,4 +1,3 @@ -# keyboard_joy_core.py from __future__ import annotations import threading diff --git a/mission/keyboard_joy/test/test_keyboard_joy_core.py b/mission/keyboard_joy/test/test_keyboard_joy_core.py index 388552c1b..210cfcc66 100644 --- a/mission/keyboard_joy/test/test_keyboard_joy_core.py +++ b/mission/keyboard_joy/test/test_keyboard_joy_core.py @@ -1,5 +1,3 @@ -# test/test_keyboard_joy_core.py - from keyboard_joy.keyboard_joy_core import AxisBinding, AxisMode, KeyboardJoyCore