Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
fcfa385
feat(joy): add keyboard_joy node for keyboard-based teleoperation
kluge7 Nov 4, 2025
eec9cb3
chore(keyboard_joy_node): removed comments
kluge7 Nov 4, 2025
2d999ba
refactor: migrate keyboard_joy to ament_python and add unit tests
kluge7 Jan 4, 2026
9f73fc5
chore(keyboard_joy): apply pre-commit changes
kluge7 Jan 4, 2026
631feb9
fix(keyboard_joy): use python3-pytest rosdep key instead of pytest
kluge7 Jan 4, 2026
ec5f5ee
Merge branch 'main' into feat/keyboard-joy
kluge7 Jan 4, 2026
8c0770c
refactor(keyboard_joy): remove ament_python from buildtool_depend
kluge7 Jan 4, 2026
f42e1d7
Update mission/keyboard_joy/README.md
kluge7 Jan 4, 2026
93807e8
Update mission/keyboard_joy/config/key_mappings.yaml
kluge7 Jan 4, 2026
c1f8b43
docs(keyboard_joy): update naming in README to be consistent with code
kluge7 Jan 4, 2026
5d8c813
test
kluge7 Jan 4, 2026
8893e9c
test: remove keyboard_joy tests since they are unsupported in CI
kluge7 Jan 4, 2026
f38ab63
refactor(keyboard_joy): rename timing params and improve documentation
kluge7 Jan 17, 2026
5a4b55b
Merge branch 'main' into feat/keyboard-joy
kluge7 Jan 17, 2026
6599bda
refactor(keyboard_joy): replace axis tuples with dataclass and Enum
kluge7 Jan 17, 2026
43e9c10
refactor(keyboard_joy): split into separate node and logic file and a…
kluge7 Jan 29, 2026
e454384
ci: undo temporary branch used for indistrual ci back to main
kluge7 Jan 29, 2026
efbde0e
Merge branch 'main' into feat/keyboard-joy
kluge7 Jan 29, 2026
b707ca9
chore(keyboard_joy): remove ament_pytest from package.xml since it is…
kluge7 Jan 29, 2026
36ca3c0
refactor(keyboard_joy): remove unnecessary lock from keyboard_joy_nod…
kluge7 Jan 29, 2026
71488e6
chore: remove COLCON_IGNORE files which were used for testing
kluge7 Jan 29, 2026
d5344fb
chore: remove unnecessary file comments from keyboard_joy
kluge7 Jan 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions mission/keyboard_joy/README.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 48 additions & 0 deletions mission/keyboard_joy/config/key_mappings.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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']
a: [0, 1.0, 'hold']
d: [0, -1.0, 'hold']

# Heave
Key.space: [2, 1.0, 'hold'] # Up (RT)
Key.shift: [2, -1.0, 'hold'] # Down (LT)

# Rotation (pitch/yaw)
Key.up: [4, 1.0, 'hold']
Key.down: [4, -1.0, 'hold']
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

# Motion tuning
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
Empty file.
169 changes: 169 additions & 0 deletions mission/keyboard_joy/keyboard_joy/keyboard_joy_core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
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 {}
Comment thread
kluge7 marked this conversation as resolved.

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)),
)
Comment thread
kluge7 marked this conversation as resolved.

button_mappings = keymap.get("buttons", {}) or {}

Copilot AI Jan 29, 2026

Copy link

Choose a reason for hiding this comment

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

Button mappings loaded from YAML are not type-validated. The button_mappings dictionary values should be integers, but there's no conversion or validation when loading from YAML (line 77). This could lead to runtime errors later when the values are used with int() conversions in the press and release methods.

Consider adding validation or explicit type conversion similar to how axis mappings are handled.

Suggested change
button_mappings = keymap.get("buttons", {}) or {}
raw_buttons = keymap.get("buttons", {}) or {}
button_mappings: dict[str, int] = {}
for key, button in raw_buttons.items():
try:
button_mappings[key] = int(button)
except (TypeError, ValueError) as exc:
raise ValueError(
f"Invalid button mapping for key {key!r}: {button!r}"
) from exc

Copilot uses AI. Check for mistakes.

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,
)
Comment on lines +60 to +89

Copilot AI Jan 29, 2026

Copy link

Choose a reason for hiding this comment

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

Missing test coverage for from_yaml_file method. The from_yaml_file classmethod is a critical piece of functionality that parses YAML configuration and creates KeyboardJoyCore instances, but it has no corresponding tests in test_keyboard_joy_core.py. All existing tests create KeyboardJoyCore instances directly with constructor arguments.

Tests should cover valid YAML parsing, malformed YAML handling, missing sections, invalid mode values, and parameter defaults to ensure the configuration loading is robust.

Copilot uses AI. Check for mistakes.

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
Comment thread
kluge7 marked this conversation as resolved.
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
Comment on lines +142 to +152

Copilot AI Jan 29, 2026

Copy link

Choose a reason for hiding this comment

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

Logic assumes target value is never zero, but this is not validated. The update_active_axes method calculates delta based on whether target > 0 (lines 143-147), which doesn't handle the case where target == 0. If a user creates an AxisBinding with val=0.0, the axis would increment toward negative infinity instead of staying at zero.

Consider adding validation in the AxisBinding or init to ensure axis values are non-zero, or fix the delta calculation to handle zero targets correctly.

Suggested change
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
diff = target - current
if diff == 0.0:
continue
step = self.axis_increment_step
if abs(diff) <= step:
next_val = target
elif diff > 0:
next_val = current + step
else:
next_val = current - step

Copilot uses AI. Check for mistakes.

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)
104 changes: 104 additions & 0 deletions mission/keyboard_joy/keyboard_joy/keyboard_joy_node.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
import rclpy
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"""
██ ▄█▀▓█████▓██ ██▓ ▄▄▄▄ ▒█████ ▄▄▄ ██▀███ ▓█████▄ ▄▄▄██▀▀▀▒█████ ▓██ ██▓
██▄█▒ ▓█ ▀ ▒██ ██▒▓█████▄ ▒██▒ ██▒▒████▄ ▓██ ▒ ██▒▒██▀ ██▌ ▒██ ▒██▒ ██▒▒██ ██▒
▓███▄░ ▒███ ▒██ ██░▒██▒ ▄██▒██░ ██▒▒██ ▀█▄ ▓██ ░▄█ ▒░██ █▌ ░██ ▒██░ ██▒ ▒██ ██░
▓██ █▄ ▒▓█ ▄ ░ ▐██▓░▒██░█▀ ▒██ ██░░██▄▄▄▄██ ▒██▀▀█▄ ░▓█▄ ▌▓██▄██▓ ▒██ ██░ ░ ▐██▓░
▒██▒ █▄░▒████▒ ░ ██▒▓░░▓█ ▀█▓░ ████▓▒░ ▓█ ▓██▒░██▓ ▒██▒░▒████▓ ▓███▒ ░ ████▓▒░ ░ ██▒▓░
▒ ▒▒ ▓▒░░ ▒░ ░ ██▒▒▒ ░▒▓███▀▒░ ▒░▒░▒░ ▒▒ ▓▒█░░ ▒▓ ░▒▓░ ▒▒▓ ▒ ▒▓▒▒░ ░ ▒░▒░▒░ ██▒▒▒
░ ░▒ ▒░ ░ ░ ░▓██ ░▒░ ▒░▒ ░ ░ ▒ ▒░ ▒ ▒▒ ░ ░▒ ░ ▒░ ░ ▒ ▒ ▒ ░▒░ ░ ▒ ▒░ ▓██ ░▒░
░ ░░ ░ ░ ▒ ▒ ░░ ░ ░ ░ ░ ░ ▒ ░ ▒ ░░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ▒ ▒ ▒ ░░
░ ░ ░ ░░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░
░ ░ ░ ░ ░ ░
"""
Comment on lines +10 to +21

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

yes



class KeyboardJoy(Node):
def __init__(self):
super().__init__("keyboard_joy")

self.declare_parameter("config", Parameter.Type.STRING)
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
Comment thread
kluge7 marked this conversation as resolved.
self.joy_pub = self.create_publisher(Joy, joy_topic, 10)

self.joy_msg = Joy()
self.joy_msg.header.frame_id = "keyboard"

self.listener = keyboard.Listener(
on_press=self.on_press,
on_release=self.on_release,
)
self.listener.start()

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)

Comment on lines +46 to +52

Copilot AI Jan 29, 2026

Copy link

Choose a reason for hiding this comment

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

Potential resource leak if node initialization fails after starting listener. The keyboard listener is started on line 50, but if any subsequent initialization steps fail (e.g., create_timer on lines 52-53), the listener will not be stopped. The listener thread will continue running even though the node failed to initialize.

Consider wrapping the initialization in a try-except block that ensures the listener is stopped if initialization fails after it's started, or defer starting the listener until after all other initialization is complete.

Suggested change
self.listener.start()
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)
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)
self.listener.start()

Copilot uses AI. Check for mistakes.
def on_press(self, key):
key_str = self.key_to_string(key)
if not key_str:
return
self.core.press(key_str)

def on_release(self, key):
key_str = self.key_to_string(key)
if not key_str:
return
self.core.release(key_str)

@staticmethod
def key_to_string(key):
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
Comment thread
Q3rkses marked this conversation as resolved.

def update_active_axes(self):
self.core.update_active_axes()

def publish_joy(self):
state = self.core.get_state()

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:
self.listener.stop()
super().destroy_node()
Comment thread
Q3rkses marked this conversation as resolved.


def main(args=None):
rclpy.init(args=args)
node = KeyboardJoy()
try:
rclpy.spin(node)
except KeyboardInterrupt:
Comment thread
Q3rkses marked this conversation as resolved.
Comment thread
kluge7 marked this conversation as resolved.
pass
finally:
node.destroy_node()
rclpy.shutdown()


if __name__ == "__main__":
main()
35 changes: 35 additions & 0 deletions mission/keyboard_joy/launch/keyboard_joy_node.launch.py
Original file line number Diff line number Diff line change
@@ -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'
)
Comment thread
Q3rkses marked this conversation as resolved.

return LaunchDescription(
[
DeclareLaunchArgument(
'config',
default_value=keyboard_config,
description='Path to key mappings YAML file',
),
Node(
package='keyboard_joy',
executable='keyboard_joy_node',
name='keyboard_joy',
namespace='orca',
Comment thread
Q3rkses marked this conversation as resolved.
output='screen',
parameters=[{'config': LaunchConfiguration('config')}, orca_params],
Comment thread
kluge7 marked this conversation as resolved.
),
]
)
20 changes: 20 additions & 0 deletions mission/keyboard_joy/package.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?xml version="1.0"?>
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
<name>keyboard_joy</name>
<version>0.0.0</version>
<description>Keyboard teleop node that publishes sensor_msgs/Joy messages</description>
<maintainer email="andreas.svendsrud@vortexntnu.no">kluge7</maintainer>
<license>MIT</license>

<depend>rclpy</depend>
<depend>sensor_msgs</depend>
<depend>python3-pynput</depend>
<depend>python3-yaml</depend>

<test_depend>python3-pytest</test_depend>

<export>
<build_type>ament_python</build_type>
</export>
</package>
2 changes: 2 additions & 0 deletions mission/keyboard_joy/pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[pytest]
testpaths = test
Empty file.
4 changes: 4 additions & 0 deletions mission/keyboard_joy/setup.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[develop]
script_dir=$base/lib/keyboard_joy
[install]
install_scripts=$base/lib/keyboard_joy
Loading