Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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
2 changes: 1 addition & 1 deletion .github/workflows/industrial-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Empty file added filtering/COLCON_IGNORE
Empty file.
Empty file added mission/FSM/COLCON_IGNORE
Empty file.
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.
40 changes: 40 additions & 0 deletions mission/keyboard_joy/config/key_mappings.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
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']

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.
200 changes: 200 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,200 @@
#!/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

start_message = r"""
██ ▄█▀▓█████▓██ ██▓ ▄▄▄▄ ▒█████ ▄▄▄ ██▀███ ▓█████▄ ▄▄▄██▀▀▀▒█████ ▓██ ██▓
██▄█▒ ▓█ ▀ ▒██ ██▒▓█████▄ ▒██▒ ██▒▒████▄ ▓██ ▒ ██▒▒██▀ ██▌ ▒██ ▒██▒ ██▒▒██ ██▒
▓███▄░ ▒███ ▒██ ██░▒██▒ ▄██▒██░ ██▒▒██ ▀█▄ ▓██ ░▄█ ▒░██ █▌ ░██ ▒██░ ██▒ ▒██ ██░
▓██ █▄ ▒▓█ ▄ ░ ▐██▓░▒██░█▀ ▒██ ██░░██▄▄▄▄██ ▒██▀▀█▄ ░▓█▄ ▌▓██▄██▓ ▒██ ██░ ░ ▐██▓░
▒██▒ █▄░▒████▒ ░ ██▒▓░░▓█ ▀█▓░ ████▓▒░ ▓█ ▓██▒░██▓ ▒██▒░▒████▓ ▓███▒ ░ ████▓▒░ ░ ██▒▓░
▒ ▒▒ ▓▒░░ ▒░ ░ ██▒▒▒ ░▒▓███▀▒░ ▒░▒░▒░ ▒▒ ▓▒█░░ ▒▓ ░▒▓░ ▒▒▓ ▒ ▒▓▒▒░ ░ ▒░▒░▒░ ██▒▒▒
░ ░▒ ▒░ ░ ░ ░▓██ ░▒░ ▒░▒ ░ ░ ▒ ▒░ ▒ ▒▒ ░ ░▒ ░ ▒░ ░ ▒ ▒ ▒ ░▒░ ░ ▒ ▒░ ▓██ ░▒░
░ ░░ ░ ░ ▒ ▒ ░░ ░ ░ ░ ░ ░ ▒ ░ ▒ ░░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ▒ ▒ ▒ ░░
░ ░ ░ ░░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░
░ ░ ░ ░ ░ ░
"""
Comment on lines +10 to +21
Copy link
Contributor

Choose a reason for hiding this comment

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

yes



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()

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.lock = threading.Lock()

self.listener = keyboard.Listener(
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.get_logger().info(start_message)

Comment on lines +46 to +52
Copy link

Copilot AI Jan 29, 2026

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 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

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

@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

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)

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()
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'
)

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',
output='screen',
parameters=[{'config': LaunchConfiguration('config')}, orca_params],
),
]
)
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>

<test_depend>ament_pytest</test_depend>
<test_depend>python3-pytest</test_depend>

<export>
<build_type>ament_python</build_type>
</export>
</package>
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
30 changes: 30 additions & 0 deletions mission/keyboard_joy/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import os
from glob import glob

from setuptools import setup

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',
],
},
)