-
Notifications
You must be signed in to change notification settings - Fork 26
Keyboard joy #652
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Keyboard joy #652
Changes from all commits
fcfa385
eec9cb3
2d999ba
9f73fc5
631feb9
ec5f5ee
8c0770c
f42e1d7
93807e8
c1f8b43
5d8c813
8893e9c
f38ab63
5a4b55b
6599bda
43e9c10
e454384
efbde0e
b707ca9
36ca3c0
71488e6
d5344fb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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 |
| 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 {} | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| 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)), | ||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||
|
kluge7 marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| button_mappings = keymap.get("buttons", {}) or {} | ||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||
| 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
AI
Jan 29, 2026
There was a problem hiding this comment.
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
AI
Jan 29, 2026
There was a problem hiding this comment.
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.
| 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 |
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||||||||||||||||||||||||||||
|
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
|
||||||||||||||||||||||||||||
| 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() |
| 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' | ||
| ) | ||
|
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', | ||
|
Q3rkses marked this conversation as resolved.
|
||
| output='screen', | ||
| parameters=[{'config': LaunchConfiguration('config')}, orca_params], | ||
|
kluge7 marked this conversation as resolved.
|
||
| ), | ||
| ] | ||
| ) | ||
| 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> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| [pytest] | ||
| testpaths = test |
| 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 |
Uh oh!
There was an error while loading. Please reload this page.