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