Skip to content

Commit 61d07b4

Browse files
authored
Keyboard joy (#652)
* feat(joy): add keyboard_joy node for keyboard-based teleoperation * chore(keyboard_joy_node): removed comments * refactor: migrate keyboard_joy to ament_python and add unit tests * chore(keyboard_joy): apply pre-commit changes * fix(keyboard_joy): use python3-pytest rosdep key instead of pytest * refactor(keyboard_joy): remove ament_python from buildtool_depend * Update mission/keyboard_joy/README.md * Update mission/keyboard_joy/config/key_mappings.yaml * docs(keyboard_joy): update naming in README to be consistent with code * refactor(keyboard_joy): rename timing params and improve documentation * refactor(keyboard_joy): replace axis tuples with dataclass and Enum * refactor(keyboard_joy): split into separate node and logic file and add unit tests * ci: undo temporary branch used for indistrual ci back to main * chore(keyboard_joy): remove ament_pytest from package.xml since it isnt a resolvable rosdep key * refactor(keyboard_joy): remove unnecessary lock from keyboard_joy_node.py * chore: remove COLCON_IGNORE files which were used for testing * chore: remove unnecessary file comments from keyboard_joy
1 parent 7482370 commit 61d07b4

12 files changed

Lines changed: 596 additions & 0 deletions

File tree

mission/keyboard_joy/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# keyboard_joy
2+
3+
`keyboard_joy` is a ROS 2 Python node that publishes `sensor_msgs/Joy` messages based on keyboard input, acting as a simple joystick replacement.
4+
Key-to-axis and key-to-button mappings are configurable via YAML and support both hold and sticky axis modes.
5+
6+
TODO: Currently **only works on Xorg** (not Wayland) due to limitations in global keyboard capture.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
axes:
2+
# Each mapping is [axis_index, value, mode]
3+
# axis_index is which Joy.axes[] index to control
4+
# value is the target value while the key is held (-1.0 to 1.0)
5+
# mode is 'hold' or 'sticky'
6+
7+
# Left stick (surge/sway)
8+
w: [1, 1.0, 'hold']
9+
s: [1, -1.0, 'hold']
10+
a: [0, 1.0, 'hold']
11+
d: [0, -1.0, 'hold']
12+
13+
# Heave
14+
Key.space: [2, 1.0, 'hold'] # Up (RT)
15+
Key.shift: [2, -1.0, 'hold'] # Down (LT)
16+
17+
# Rotation (pitch/yaw)
18+
Key.up: [4, 1.0, 'hold']
19+
Key.down: [4, -1.0, 'hold']
20+
Key.left: [3, 1.0, 'hold']
21+
Key.right: [3, -1.0, 'hold']
22+
23+
# Vertical (axis 7)
24+
Key.home: [7, 1.0, 'hold'] # Numpad 7 → D-pad up
25+
Key.end: [7, -1.0, 'hold'] # Numpad 1 → D-pad down
26+
27+
# Horizontal (axis 6)
28+
Key.page_up: [6, 1.0, 'hold'] # Numpad 9 → D-pad right
29+
Key.page_down: [6, -1.0, 'hold'] # Numpad 3 → D-pad left
30+
31+
parameters:
32+
axis_update_period: 0.02 # seconds per update
33+
publish_period: 0.02 # seconds per publish
34+
35+
# Motion tuning
36+
axis_increment_step: 0.1 # axis change per update (higher = faster movement)
37+
38+
39+
buttons:
40+
# Roll
41+
q: 4 # LB
42+
e: 5 # RB
43+
44+
# Modes
45+
'1': 0 # A - Xbox mode
46+
'2': 1 # B - Killswitch
47+
'3': 2 # X - Auto
48+
'4': 3 # Y - Reference

mission/keyboard_joy/keyboard_joy/__init__.py

Whitespace-only changes.
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
from __future__ import annotations
2+
3+
import threading
4+
from dataclasses import dataclass
5+
from enum import Enum
6+
7+
import yaml
8+
9+
10+
class AxisMode(Enum):
11+
HOLD = "hold"
12+
STICKY = "sticky"
13+
14+
15+
@dataclass(frozen=True)
16+
class AxisBinding:
17+
axis: int
18+
val: float
19+
mode: AxisMode
20+
21+
22+
@dataclass(frozen=True)
23+
class JoyState:
24+
axes: list[float]
25+
buttons: list[int]
26+
frame_id: str = "keyboard"
27+
28+
29+
class KeyboardJoyCore:
30+
def __init__(
31+
self,
32+
axis_mappings: dict[str, AxisBinding],
33+
button_mappings: dict[str, int],
34+
*,
35+
axis_update_period: float = 0.02,
36+
publish_period: float = 0.05,
37+
axis_increment_step: float = 0.05,
38+
frame_id: str = "keyboard",
39+
):
40+
self.axis_mappings = axis_mappings
41+
self.button_mappings = button_mappings
42+
43+
self.axis_update_period = float(axis_update_period)
44+
self.publish_period = float(publish_period)
45+
self.axis_increment_step = float(axis_increment_step)
46+
47+
self._frame_id = frame_id
48+
49+
self._active_axes: dict[int, float] = {}
50+
self._sticky_axes: dict[int, float] = {}
51+
52+
max_axis_index = max((b.axis for b in self.axis_mappings.values()), default=-1)
53+
max_button_index = max(self.button_mappings.values(), default=-1)
54+
55+
self._axes = [0.0] * (max_axis_index + 1)
56+
self._buttons = [0] * (max_button_index + 1)
57+
58+
self._lock = threading.Lock()
59+
60+
@classmethod
61+
def from_yaml_file(cls, config_file: str) -> KeyboardJoyCore:
62+
with open(config_file, encoding="utf-8") as f:
63+
keymap = yaml.safe_load(f) or {}
64+
65+
raw_axes = keymap.get("axes", {}) or {}
66+
axis_mappings: dict[str, AxisBinding] = {}
67+
for key, spec in raw_axes.items():
68+
# YAML format: [axis_index, value, "sticky"/"hold"]
69+
axis, val, mode = spec
70+
axis_mappings[key] = AxisBinding(
71+
axis=int(axis),
72+
val=float(val),
73+
mode=AxisMode(str(mode)),
74+
)
75+
76+
button_mappings = keymap.get("buttons", {}) or {}
77+
78+
params = keymap.get("parameters", {}) or {}
79+
axis_update_period = float(params.get("axis_update_period", 0.02))
80+
publish_period = float(params.get("publish_period", 0.05))
81+
axis_increment_step = float(params.get("axis_increment_step", 0.05))
82+
83+
return cls(
84+
axis_mappings=axis_mappings,
85+
button_mappings=button_mappings,
86+
axis_update_period=axis_update_period,
87+
publish_period=publish_period,
88+
axis_increment_step=axis_increment_step,
89+
)
90+
91+
def press(self, key_str: str) -> None:
92+
if not key_str:
93+
return
94+
95+
with self._lock:
96+
if key_str in self.axis_mappings:
97+
binding = self.axis_mappings[key_str]
98+
99+
if binding.mode == AxisMode.STICKY:
100+
axis = binding.axis
101+
new_val = (
102+
self._sticky_axes.get(axis, 0.0)
103+
+ binding.val * self.axis_increment_step
104+
)
105+
new_val = max(min(new_val, 1.0), -1.0)
106+
self._sticky_axes[axis] = new_val
107+
self._axes[axis] = round(new_val, 3)
108+
else:
109+
# HOLD mode: ramp toward binding.val while key is held
110+
self._active_axes[binding.axis] = binding.val
111+
112+
elif key_str in self.button_mappings:
113+
idx = int(self.button_mappings[key_str])
114+
if idx >= len(self._buttons):
115+
self._buttons.extend([0] * (idx + 1 - len(self._buttons)))
116+
self._buttons[idx] = 1
117+
118+
def release(self, key_str: str) -> None:
119+
if not key_str:
120+
return
121+
122+
with self._lock:
123+
if key_str in self.axis_mappings:
124+
binding = self.axis_mappings[key_str]
125+
self._active_axes.pop(binding.axis, None)
126+
if binding.mode != AxisMode.STICKY:
127+
self._axes[binding.axis] = 0.0
128+
129+
elif key_str in self.button_mappings:
130+
idx = int(self.button_mappings[key_str])
131+
if idx < len(self._buttons):
132+
self._buttons[idx] = 0
133+
134+
def update_active_axes(self) -> None:
135+
"""For HOLD axes: move axis value toward target at axis_increment_step per call."""
136+
with self._lock:
137+
for axis, target in list(self._active_axes.items()):
138+
if axis >= len(self._axes):
139+
self._axes.extend([0.0] * (axis + 1 - len(self._axes)))
140+
141+
current = self._axes[axis]
142+
delta = (
143+
self.axis_increment_step
144+
if target > 0
145+
else -self.axis_increment_step
146+
)
147+
next_val = current + delta
148+
149+
if (delta > 0 and next_val > target) or (
150+
delta < 0 and next_val < target
151+
):
152+
next_val = target
153+
154+
self._axes[axis] = round(next_val, 3)
155+
156+
def get_state(self) -> JoyState:
157+
with self._lock:
158+
return JoyState(
159+
axes=list(self._axes),
160+
buttons=list(self._buttons),
161+
frame_id=self._frame_id,
162+
)
163+
164+
# Helper for tests/debug
165+
def set_axis(self, axis: int, value: float) -> None:
166+
with self._lock:
167+
if axis >= len(self._axes):
168+
self._axes.extend([0.0] * (axis + 1 - len(self._axes)))
169+
self._axes[axis] = round(max(min(float(value), 1.0), -1.0), 3)
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
#!/usr/bin/env python3
2+
import rclpy
3+
from pynput import keyboard
4+
from rclpy.node import Node
5+
from rclpy.parameter import Parameter
6+
from sensor_msgs.msg import Joy
7+
8+
from keyboard_joy.keyboard_joy_core import KeyboardJoyCore
9+
10+
start_message = r"""
11+
██ ▄█▀▓█████▓██ ██▓ ▄▄▄▄ ▒█████ ▄▄▄ ██▀███ ▓█████▄ ▄▄▄██▀▀▀▒█████ ▓██ ██▓
12+
██▄█▒ ▓█ ▀ ▒██ ██▒▓█████▄ ▒██▒ ██▒▒████▄ ▓██ ▒ ██▒▒██▀ ██▌ ▒██ ▒██▒ ██▒▒██ ██▒
13+
▓███▄░ ▒███ ▒██ ██░▒██▒ ▄██▒██░ ██▒▒██ ▀█▄ ▓██ ░▄█ ▒░██ █▌ ░██ ▒██░ ██▒ ▒██ ██░
14+
▓██ █▄ ▒▓█ ▄ ░ ▐██▓░▒██░█▀ ▒██ ██░░██▄▄▄▄██ ▒██▀▀█▄ ░▓█▄ ▌▓██▄██▓ ▒██ ██░ ░ ▐██▓░
15+
▒██▒ █▄░▒████▒ ░ ██▒▓░░▓█ ▀█▓░ ████▓▒░ ▓█ ▓██▒░██▓ ▒██▒░▒████▓ ▓███▒ ░ ████▓▒░ ░ ██▒▓░
16+
▒ ▒▒ ▓▒░░ ▒░ ░ ██▒▒▒ ░▒▓███▀▒░ ▒░▒░▒░ ▒▒ ▓▒█░░ ▒▓ ░▒▓░ ▒▒▓ ▒ ▒▓▒▒░ ░ ▒░▒░▒░ ██▒▒▒
17+
░ ░▒ ▒░ ░ ░ ░▓██ ░▒░ ▒░▒ ░ ░ ▒ ▒░ ▒ ▒▒ ░ ░▒ ░ ▒░ ░ ▒ ▒ ▒ ░▒░ ░ ▒ ▒░ ▓██ ░▒░
18+
░ ░░ ░ ░ ▒ ▒ ░░ ░ ░ ░ ░ ░ ▒ ░ ▒ ░░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ▒ ▒ ▒ ░░
19+
░ ░ ░ ░░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░
20+
░ ░ ░ ░ ░ ░
21+
"""
22+
23+
24+
class KeyboardJoy(Node):
25+
def __init__(self):
26+
super().__init__("keyboard_joy")
27+
28+
self.declare_parameter("config", Parameter.Type.STRING)
29+
config_file = self.get_parameter("config").value
30+
if not config_file:
31+
raise RuntimeError("Parameter 'config' is required (pass it from launch).")
32+
33+
self.core = KeyboardJoyCore.from_yaml_file(config_file)
34+
35+
self.declare_parameter("topics.joy", Parameter.Type.STRING)
36+
joy_topic = self.get_parameter("topics.joy").value
37+
self.joy_pub = self.create_publisher(Joy, joy_topic, 10)
38+
39+
self.joy_msg = Joy()
40+
self.joy_msg.header.frame_id = "keyboard"
41+
42+
self.listener = keyboard.Listener(
43+
on_press=self.on_press,
44+
on_release=self.on_release,
45+
)
46+
self.listener.start()
47+
48+
self.create_timer(self.core.publish_period, self.publish_joy)
49+
self.create_timer(self.core.axis_update_period, self.update_active_axes)
50+
51+
self.get_logger().info(start_message)
52+
53+
def on_press(self, key):
54+
key_str = self.key_to_string(key)
55+
if not key_str:
56+
return
57+
self.core.press(key_str)
58+
59+
def on_release(self, key):
60+
key_str = self.key_to_string(key)
61+
if not key_str:
62+
return
63+
self.core.release(key_str)
64+
65+
@staticmethod
66+
def key_to_string(key):
67+
if hasattr(key, "char") and key.char:
68+
return key.char.lower()
69+
if hasattr(key, "name") and key.name:
70+
return f"Key.{key.name}"
71+
return None
72+
73+
def update_active_axes(self):
74+
self.core.update_active_axes()
75+
76+
def publish_joy(self):
77+
state = self.core.get_state()
78+
79+
self.joy_msg.header.stamp = self.get_clock().now().to_msg()
80+
self.joy_msg.header.frame_id = state.frame_id
81+
self.joy_msg.axes = state.axes
82+
self.joy_msg.buttons = state.buttons
83+
self.joy_pub.publish(self.joy_msg)
84+
85+
def destroy_node(self):
86+
if self.listener:
87+
self.listener.stop()
88+
super().destroy_node()
89+
90+
91+
def main(args=None):
92+
rclpy.init(args=args)
93+
node = KeyboardJoy()
94+
try:
95+
rclpy.spin(node)
96+
except KeyboardInterrupt:
97+
pass
98+
finally:
99+
node.destroy_node()
100+
rclpy.shutdown()
101+
102+
103+
if __name__ == "__main__":
104+
main()
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import os
2+
3+
from ament_index_python.packages import get_package_share_directory
4+
from launch import LaunchDescription
5+
from launch.actions import DeclareLaunchArgument
6+
from launch.substitutions import LaunchConfiguration
7+
from launch_ros.actions import Node
8+
9+
10+
def generate_launch_description():
11+
keyboard_config = os.path.join(
12+
get_package_share_directory('keyboard_joy'), 'config', 'key_mappings.yaml'
13+
)
14+
15+
orca_params = os.path.join(
16+
get_package_share_directory('auv_setup'), 'config', 'robots', 'orca.yaml'
17+
)
18+
19+
return LaunchDescription(
20+
[
21+
DeclareLaunchArgument(
22+
'config',
23+
default_value=keyboard_config,
24+
description='Path to key mappings YAML file',
25+
),
26+
Node(
27+
package='keyboard_joy',
28+
executable='keyboard_joy_node',
29+
name='keyboard_joy',
30+
namespace='orca',
31+
output='screen',
32+
parameters=[{'config': LaunchConfiguration('config')}, orca_params],
33+
),
34+
]
35+
)

mission/keyboard_joy/package.xml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?xml version="1.0"?>
2+
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
3+
<package format="3">
4+
<name>keyboard_joy</name>
5+
<version>0.0.0</version>
6+
<description>Keyboard teleop node that publishes sensor_msgs/Joy messages</description>
7+
<maintainer email="andreas.svendsrud@vortexntnu.no">kluge7</maintainer>
8+
<license>MIT</license>
9+
10+
<depend>rclpy</depend>
11+
<depend>sensor_msgs</depend>
12+
<depend>python3-pynput</depend>
13+
<depend>python3-yaml</depend>
14+
15+
<test_depend>python3-pytest</test_depend>
16+
17+
<export>
18+
<build_type>ament_python</build_type>
19+
</export>
20+
</package>

mission/keyboard_joy/pytest.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[pytest]
2+
testpaths = test

mission/keyboard_joy/resource/keyboard_joy

Whitespace-only changes.

mission/keyboard_joy/setup.cfg

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[develop]
2+
script_dir=$base/lib/keyboard_joy
3+
[install]
4+
install_scripts=$base/lib/keyboard_joy

0 commit comments

Comments
 (0)