-
Notifications
You must be signed in to change notification settings - Fork 24
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
Merged
Merged
Keyboard joy #652
Changes from 4 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 eec9cb3
chore(keyboard_joy_node): removed comments
kluge7 2d999ba
refactor: migrate keyboard_joy to ament_python and add unit tests
kluge7 9f73fc5
chore(keyboard_joy): apply pre-commit changes
kluge7 631feb9
fix(keyboard_joy): use python3-pytest rosdep key instead of pytest
kluge7 ec5f5ee
Merge branch 'main' into feat/keyboard-joy
kluge7 8c0770c
refactor(keyboard_joy): remove ament_python from buildtool_depend
kluge7 f42e1d7
Update mission/keyboard_joy/README.md
kluge7 93807e8
Update mission/keyboard_joy/config/key_mappings.yaml
kluge7 c1f8b43
docs(keyboard_joy): update naming in README to be consistent with code
kluge7 5d8c813
test
kluge7 8893e9c
test: remove keyboard_joy tests since they are unsupported in CI
kluge7 f38ab63
refactor(keyboard_joy): rename timing params and improve documentation
kluge7 5a4b55b
Merge branch 'main' into feat/keyboard-joy
kluge7 6599bda
refactor(keyboard_joy): replace axis tuples with dataclass and Enum
kluge7 43e9c10
refactor(keyboard_joy): split into separate node and logic file and a…
kluge7 e454384
ci: undo temporary branch used for indistrual ci back to main
kluge7 efbde0e
Merge branch 'main' into feat/keyboard-joy
kluge7 b707ca9
chore(keyboard_joy): remove ament_pytest from package.xml since it is…
kluge7 36ca3c0
refactor(keyboard_joy): remove unnecessary lock from keyboard_joy_nod…
kluge7 71488e6
chore: remove COLCON_IGNORE files which were used for testing
kluge7 d5344fb
chore: remove unnecessary file comments from keyboard_joy
kluge7 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'] | ||
kluge7 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| parameters: | ||
| axis_increment_rate: 0.02 # update interval (higher = slower response) | ||
Q3rkses marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
kluge7 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,163 @@ | ||
| #!/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""" | ||
| ██ ▄█▀▓█████▓██ ██▓ ▄▄▄▄ ▒█████ ▄▄▄ ██▀███ ▓█████▄ ▄▄▄██▀▀▀▒█████ ▓██ ██▓ | ||
| ██▄█▒ ▓█ ▀ ▒██ ██▒▓█████▄ ▒██▒ ██▒▒████▄ ▓██ ▒ ██▒▒██▀ ██▌ ▒██ ▒██▒ ██▒▒██ ██▒ | ||
| ▓███▄░ ▒███ ▒██ ██░▒██▒ ▄██▒██░ ██▒▒██ ▀█▄ ▓██ ░▄█ ▒░██ █▌ ░██ ▒██░ ██▒ ▒██ ██░ | ||
| ▓██ █▄ ▒▓█ ▄ ░ ▐██▓░▒██░█▀ ▒██ ██░░██▄▄▄▄██ ▒██▀▀█▄ ░▓█▄ ▌▓██▄██▓ ▒██ ██░ ░ ▐██▓░ | ||
| ▒██▒ █▄░▒████▒ ░ ██▒▓░░▓█ ▀█▓░ ████▓▒░ ▓█ ▓██▒░██▓ ▒██▒░▒████▓ ▓███▒ ░ ████▓▒░ ░ ██▒▓░ | ||
| ▒ ▒▒ ▓▒░░ ▒░ ░ ██▒▒▒ ░▒▓███▀▒░ ▒░▒░▒░ ▒▒ ▓▒█░░ ▒▓ ░▒▓░ ▒▒▓ ▒ ▒▓▒▒░ ░ ▒░▒░▒░ ██▒▒▒ | ||
| ░ ░▒ ▒░ ░ ░ ░▓██ ░▒░ ▒░▒ ░ ░ ▒ ▒░ ▒ ▒▒ ░ ░▒ ░ ▒░ ░ ▒ ▒ ▒ ░▒░ ░ ▒ ▒░ ▓██ ░▒░ | ||
| ░ ░░ ░ ░ ▒ ▒ ░░ ░ ░ ░ ░ ░ ▒ ░ ▒ ░░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ▒ ▒ ▒ ░░ | ||
| ░ ░ ░ ░░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ | ||
| ░ ░ ░ ░ ░ ░ | ||
| """ | ||
|
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', '') | ||
kluge7 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| self.load_key_mappings() | ||
|
|
||
| self.joy_pub = self.create_publisher(Joy, 'joy', 10) | ||
kluge7 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| 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) | ||
|
|
||
Q3rkses marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| self.joy_msg = Joy() | ||
| self.joy_msg.axes = [0.0] * (max_axis_index + 1) | ||
| self.joy_msg.buttons = [0] * (max_button_index + 1) | ||
Q3rkses marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
kluge7 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
kluge7 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| 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(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' | ||
| ) | ||
kluge7 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| 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}") | ||
Q3rkses marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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)) | ||
Q3rkses marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
Q3rkses marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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 | ||
Q3rkses marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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: | ||
Q3rkses marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| return key.char.lower() | ||
| if hasattr(key, 'name') and key.name: | ||
| return f'Key.{key.name}' | ||
| return None | ||
Q3rkses marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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) | ||
Q3rkses marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| def destroy_node(self): | ||
| if self.listener: | ||
| self.listener.stop() | ||
| super().destroy_node() | ||
Q3rkses marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
|
|
||
| def main(args=None): | ||
| rclpy.init(args=args) | ||
| node = KeyboardJoy() | ||
| try: | ||
| rclpy.spin(node) | ||
| except KeyboardInterrupt: | ||
Q3rkses marked this conversation as resolved.
Show resolved
Hide resolved
kluge7 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| pass | ||
| finally: | ||
| node.destroy_node() | ||
| rclpy.shutdown() | ||
|
|
||
|
|
||
| if __name__ == '__main__': | ||
| main() | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.
Show resolved
Hide 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.
Show resolved
Hide resolved
|
||
| output='screen', | ||
| parameters=[{'config': LaunchConfiguration('config')}, orca_params], | ||
kluge7 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ), | ||
| ] | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| <?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="[email protected]">kluge7</maintainer> | ||
| <license>MIT</license> | ||
|
|
||
| <buildtool_depend>ament_python</buildtool_depend> | ||
|
|
||
| <depend>rclpy</depend> | ||
| <depend>sensor_msgs</depend> | ||
| <depend>python3-pynput</depend> | ||
|
|
||
| <test_depend>ament_pytest</test_depend> | ||
| <test_depend>pytest</test_depend> | ||
|
|
||
| <export> | ||
| <build_type>ament_python</build_type> | ||
| </export> | ||
| </package> |
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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='[email protected]', | ||
| 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', | ||
| ], | ||
| }, | ||
| ) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.