chore: add ros2 udp_teleop package and tools
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -11,9 +11,9 @@
|
|||||||
# ====================
|
# ====================
|
||||||
# ROS 2
|
# ROS 2
|
||||||
# ====================
|
# ====================
|
||||||
ros2/build/
|
ros2/**/build/
|
||||||
ros2/install/
|
ros2/**/install/
|
||||||
ros2/log/
|
ros2/**/log/
|
||||||
|
|
||||||
# ====================
|
# ====================
|
||||||
# Python
|
# Python
|
||||||
|
|||||||
165
ros2/src/udp_teleop/README.md
Normal file
165
ros2/src/udp_teleop/README.md
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# udp_teleop — ROS 2 底盘 + 机械臂键盘 UDP 遥控
|
||||||
|
|
||||||
|
通过键盘实时控制底盘(差速驱动)和机械臂(6 电机),指令通过 **单一 UDP socket** 发送到设备端。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
ros2/
|
||||||
|
├── build/ # colcon 构建产物(自动生成)
|
||||||
|
├── install/ # colcon 安装产物(自动生成)
|
||||||
|
├── log/ # 构建日志(自动生成)
|
||||||
|
└── src/
|
||||||
|
└── udp_teleop/ # ROS 2 包
|
||||||
|
├── config/
|
||||||
|
│ └── params.yaml # 可配置参数
|
||||||
|
├── launch/ # launch 文件(预留)
|
||||||
|
├── udp_teleop/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ └── keyboard_control.py # 键盘遥控节点
|
||||||
|
├── resource/
|
||||||
|
├── test/
|
||||||
|
├── package.xml
|
||||||
|
├── setup.cfg
|
||||||
|
└── setup.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境搭建
|
||||||
|
|
||||||
|
### 1. 安装 Conda
|
||||||
|
|
||||||
|
使用 Miniconda 或 Anaconda。推荐 Miniconda:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 下载并安装 Miniconda
|
||||||
|
wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh
|
||||||
|
bash Miniconda3-latest-Linux-x86_64.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 创建 ROS 2 Humble 环境
|
||||||
|
|
||||||
|
使用 robostack 频道安装 ROS 2 Humble Desktop 完整版:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda create -n ros2_humble -c robostack-staging -c conda-forge ros-humble-desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 安装构建工具
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda activate ros2_humble
|
||||||
|
conda install -c robostack-staging -c conda-forge \
|
||||||
|
colcon-common-extensions \
|
||||||
|
ros-humble-ament-cmake \
|
||||||
|
python3-pip
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 安装 Python 依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install pynput
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 激活环境
|
||||||
|
|
||||||
|
每次使用前:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda activate ros2_humble
|
||||||
|
source /path/to/ros2/install/setup.bash # 首次构建后执行
|
||||||
|
```
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ros2
|
||||||
|
colcon build --symlink-install --packages-select udp_teleop
|
||||||
|
source install/setup.bash
|
||||||
|
```
|
||||||
|
|
||||||
|
> `--symlink-install`:修改 Python 源文件后无需重新构建,直接生效。
|
||||||
|
|
||||||
|
## 运行
|
||||||
|
|
||||||
|
### 使用参数文件(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 run udp_teleop keyboard_control \
|
||||||
|
--ros-args --params-file src/udp_teleop/config/params.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 命令行覆盖参数
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 run udp_teleop keyboard_control \
|
||||||
|
--ros-args -p udp_ip:=192.168.1.100 -p udp_port:=9999
|
||||||
|
```
|
||||||
|
|
||||||
|
## 按键映射
|
||||||
|
|
||||||
|
### 底盘控制
|
||||||
|
|
||||||
|
| 按键 | 功能 |
|
||||||
|
|------|------|
|
||||||
|
| `W` / `S` | 前进 / 后退 |
|
||||||
|
| `A` / `D` | 左移 / 右移 |
|
||||||
|
| `Q` / `E` | 左转 / 右转 |
|
||||||
|
|
||||||
|
### 机械臂控制
|
||||||
|
|
||||||
|
| 按键 | 功能 |
|
||||||
|
|------|------|
|
||||||
|
| `↑` / `↓` | 升降高度(↑ 升高,↓ 降低) |
|
||||||
|
| `2` ~ `6` | 选择关节 J2 ~ J6 |
|
||||||
|
| `←` / `→` | 减小 / 增大当前关节角度 |
|
||||||
|
|
||||||
|
> 底盘和机械臂可以**同时操控**。机械臂指令仅在按下机械臂相关按键后发送。
|
||||||
|
|
||||||
|
### 其他
|
||||||
|
|
||||||
|
| 按键 | 功能 |
|
||||||
|
|------|------|
|
||||||
|
| `Ctrl+C` | 退出并发送底盘停止指令 |
|
||||||
|
|
||||||
|
## UDP 协议
|
||||||
|
|
||||||
|
### 底盘指令
|
||||||
|
|
||||||
|
```
|
||||||
|
XYW:<X速度>:<Y速度>:<W角速度>:XZHY\n
|
||||||
|
```
|
||||||
|
|
||||||
|
### 机械臂指令
|
||||||
|
|
||||||
|
```
|
||||||
|
JXB:<高度>:<J2>:<J3>:<J4>:<J5>:<J6>:0:0:EZHY\n
|
||||||
|
```
|
||||||
|
|
||||||
|
- 6 个电机:电机 1 控制高度,电机 2~6 对应关节 J2~J6
|
||||||
|
- 末尾补零至 8 个值
|
||||||
|
|
||||||
|
## 参数配置
|
||||||
|
|
||||||
|
| 参数 | 默认值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `udp_ip` | `127.0.0.1` | UDP 目标 IP 地址 |
|
||||||
|
| `udp_port` | `8888` | UDP 目标端口 |
|
||||||
|
| `chassis_linear_speed` | `100` | 底盘线速度 |
|
||||||
|
| `chassis_angular_speed` | `45` | 底盘角速度 |
|
||||||
|
| `arm_height_step` | `5` | 高度每步变化量 |
|
||||||
|
| `arm_joint_step` | `5` | 关节角度每步变化量 |
|
||||||
|
| `update_rate` | `0.05` | 控制循环周期(秒) |
|
||||||
|
| `stdin_hold_time` | `0.04` | 按键持续时间(秒),修复箭头键时序问题 |
|
||||||
|
| `debug_keys` | `false` | 是否在状态行显示当前按键 |
|
||||||
|
| `keyboard_backend` | `auto` | 键盘后端:`auto` / `stdin` / `pynput` / `win_poll` |
|
||||||
|
|
||||||
|
## 键盘后端
|
||||||
|
|
||||||
|
| 后端 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `auto` | 自动选择:Linux/macOS 用 `stdin`,Windows 用 `win_poll` |
|
||||||
|
| `stdin` | 基于终端原始输入,无需额外依赖,**需要交互终端** |
|
||||||
|
| `pynput` | 基于 pynput 库,跨平台,需要 `pip install pynput` |
|
||||||
|
| `win_poll` | Windows 专用,通过 Win32 API 轮询按键状态 |
|
||||||
|
|
||||||
|
> `ros2 launch` 启动的子进程**没有交互终端**,使用 `stdin` 后端会报错。必须通过 `ros2 run` 在终端直接运行。
|
||||||
12
ros2/src/udp_teleop/config/params.yaml
Normal file
12
ros2/src/udp_teleop/config/params.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
keyboard_udp_control:
|
||||||
|
ros__parameters:
|
||||||
|
udp_ip: "127.0.0.1"
|
||||||
|
udp_port: 8888
|
||||||
|
chassis_linear_speed: 100
|
||||||
|
chassis_angular_speed: 45
|
||||||
|
arm_height_step: 5
|
||||||
|
arm_joint_step: 5
|
||||||
|
update_rate: 0.05
|
||||||
|
debug_keys: false
|
||||||
|
keyboard_backend: "auto"
|
||||||
|
stdin_hold_time: 0.04
|
||||||
18
ros2/src/udp_teleop/package.xml
Normal file
18
ros2/src/udp_teleop/package.xml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?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>udp_teleop</name>
|
||||||
|
<version>0.0.0</version>
|
||||||
|
<description>TODO: Package description</description>
|
||||||
|
<maintainer email="fallensigh@gmail.com">fallensigh</maintainer>
|
||||||
|
<license>TODO: License declaration</license>
|
||||||
|
|
||||||
|
<test_depend>ament_copyright</test_depend>
|
||||||
|
<test_depend>ament_flake8</test_depend>
|
||||||
|
<test_depend>ament_pep257</test_depend>
|
||||||
|
<test_depend>python3-pytest</test_depend>
|
||||||
|
|
||||||
|
<export>
|
||||||
|
<build_type>ament_python</build_type>
|
||||||
|
</export>
|
||||||
|
</package>
|
||||||
0
ros2/src/udp_teleop/resource/udp_teleop
Normal file
0
ros2/src/udp_teleop/resource/udp_teleop
Normal file
4
ros2/src/udp_teleop/setup.cfg
Normal file
4
ros2/src/udp_teleop/setup.cfg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[develop]
|
||||||
|
script_dir=$base/lib/udp_teleop
|
||||||
|
[install]
|
||||||
|
install_scripts=$base/lib/udp_teleop
|
||||||
35
ros2/src/udp_teleop/setup.py
Normal file
35
ros2/src/udp_teleop/setup.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import os
|
||||||
|
from glob import glob
|
||||||
|
|
||||||
|
from setuptools import find_packages, setup
|
||||||
|
|
||||||
|
package_name = 'udp_teleop'
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name=package_name,
|
||||||
|
version='0.0.0',
|
||||||
|
packages=find_packages(exclude=['test']),
|
||||||
|
data_files=[
|
||||||
|
('share/ament_index/resource_index/packages',
|
||||||
|
['resource/' + package_name]),
|
||||||
|
('share/' + package_name, ['package.xml']),
|
||||||
|
(os.path.join('share', package_name, 'config'),
|
||||||
|
glob('config/*.yaml')),
|
||||||
|
],
|
||||||
|
install_requires=['setuptools'],
|
||||||
|
zip_safe=True,
|
||||||
|
maintainer='fallensigh',
|
||||||
|
maintainer_email='fallensigh@gmail.com',
|
||||||
|
description='TODO: Package description',
|
||||||
|
license='TODO: License declaration',
|
||||||
|
extras_require={
|
||||||
|
'test': [
|
||||||
|
'pytest',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
entry_points={
|
||||||
|
'console_scripts': [
|
||||||
|
'keyboard_control = udp_teleop.keyboard_control:main'
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
25
ros2/src/udp_teleop/test/test_copyright.py
Normal file
25
ros2/src/udp_teleop/test/test_copyright.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Copyright 2015 Open Source Robotics Foundation, Inc.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
from ament_copyright.main import main
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# Remove the `skip` decorator once the source file(s) have a copyright header
|
||||||
|
@pytest.mark.skip(reason='No copyright header has been placed in the generated source file.')
|
||||||
|
@pytest.mark.copyright
|
||||||
|
@pytest.mark.linter
|
||||||
|
def test_copyright():
|
||||||
|
rc = main(argv=['.', 'test'])
|
||||||
|
assert rc == 0, 'Found errors'
|
||||||
25
ros2/src/udp_teleop/test/test_flake8.py
Normal file
25
ros2/src/udp_teleop/test/test_flake8.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Copyright 2017 Open Source Robotics Foundation, Inc.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
from ament_flake8.main import main_with_errors
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.flake8
|
||||||
|
@pytest.mark.linter
|
||||||
|
def test_flake8():
|
||||||
|
rc, errors = main_with_errors(argv=[])
|
||||||
|
assert rc == 0, \
|
||||||
|
'Found %d code style errors / warnings:\n' % len(errors) + \
|
||||||
|
'\n'.join(errors)
|
||||||
23
ros2/src/udp_teleop/test/test_pep257.py
Normal file
23
ros2/src/udp_teleop/test/test_pep257.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Copyright 2015 Open Source Robotics Foundation, Inc.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
from ament_pep257.main import main
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.linter
|
||||||
|
@pytest.mark.pep257
|
||||||
|
def test_pep257():
|
||||||
|
rc = main(argv=['.', 'test'])
|
||||||
|
assert rc == 0, 'Found code style errors / warnings'
|
||||||
0
ros2/src/udp_teleop/udp_teleop/__init__.py
Normal file
0
ros2/src/udp_teleop/udp_teleop/__init__.py
Normal file
411
ros2/src/udp_teleop/udp_teleop/keyboard_control.py
Normal file
411
ros2/src/udp_teleop/udp_teleop/keyboard_control.py
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import rclpy
|
||||||
|
from rclpy.node import Node
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pynput import keyboard
|
||||||
|
except ImportError:
|
||||||
|
keyboard = None
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
user32 = ctypes.WinDLL("user32", use_last_error=True)
|
||||||
|
else:
|
||||||
|
user32 = None
|
||||||
|
|
||||||
|
if sys.platform != "win32":
|
||||||
|
import atexit
|
||||||
|
import select
|
||||||
|
import termios
|
||||||
|
import tty
|
||||||
|
else:
|
||||||
|
atexit = None
|
||||||
|
select = None
|
||||||
|
termios = None
|
||||||
|
tty = None
|
||||||
|
|
||||||
|
|
||||||
|
KEY_UP = "up"
|
||||||
|
KEY_DOWN = "down"
|
||||||
|
KEY_LEFT = "left"
|
||||||
|
KEY_RIGHT = "right"
|
||||||
|
|
||||||
|
WIN_KEY_CODES = {
|
||||||
|
"w": 0x57,
|
||||||
|
"a": 0x41,
|
||||||
|
"s": 0x53,
|
||||||
|
"d": 0x44,
|
||||||
|
"q": 0x51,
|
||||||
|
"e": 0x45,
|
||||||
|
"2": 0x32,
|
||||||
|
"3": 0x33,
|
||||||
|
"4": 0x34,
|
||||||
|
"5": 0x35,
|
||||||
|
"6": 0x36,
|
||||||
|
"up": 0x26,
|
||||||
|
"down": 0x28,
|
||||||
|
"left": 0x25,
|
||||||
|
"right": 0x27,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class KeyboardUdpControlNode(Node):
|
||||||
|
"""ROS 2 node that sends chassis and arm UDP commands while keys are held."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__("keyboard_udp_control")
|
||||||
|
|
||||||
|
self.declare_parameter("udp_ip", "192.168.233.67")
|
||||||
|
self.declare_parameter("udp_port", 8888)
|
||||||
|
self.declare_parameter("chassis_linear_speed", 100)
|
||||||
|
self.declare_parameter("chassis_angular_speed", 45)
|
||||||
|
self.declare_parameter("arm_height_step", 5)
|
||||||
|
self.declare_parameter("arm_joint_step", 5)
|
||||||
|
self.declare_parameter("update_rate", 0.05)
|
||||||
|
self.declare_parameter("debug_keys", False)
|
||||||
|
self.declare_parameter("keyboard_backend", "auto")
|
||||||
|
self.declare_parameter("stdin_hold_time", 0.04)
|
||||||
|
|
||||||
|
self.udp_ip = self.get_parameter("udp_ip").value
|
||||||
|
self.udp_port = int(self.get_parameter("udp_port").value)
|
||||||
|
self.chassis_linear_speed = int(
|
||||||
|
self.get_parameter("chassis_linear_speed").value
|
||||||
|
)
|
||||||
|
self.chassis_angular_speed = int(
|
||||||
|
self.get_parameter("chassis_angular_speed").value
|
||||||
|
)
|
||||||
|
self.arm_height_step = int(self.get_parameter("arm_height_step").value)
|
||||||
|
self.arm_joint_step = int(self.get_parameter("arm_joint_step").value)
|
||||||
|
self.update_rate = float(self.get_parameter("update_rate").value)
|
||||||
|
self.debug_keys = bool(self.get_parameter("debug_keys").value)
|
||||||
|
self.keyboard_backend = str(self.get_parameter("keyboard_backend").value)
|
||||||
|
self.stdin_hold_time = float(self.get_parameter("stdin_hold_time").value)
|
||||||
|
self.use_windows_polling = self.should_use_windows_polling()
|
||||||
|
self.use_stdin_keyboard = self.should_use_stdin_keyboard()
|
||||||
|
|
||||||
|
self.chassis_x = 0
|
||||||
|
self.chassis_y = 0
|
||||||
|
self.chassis_w = 0
|
||||||
|
|
||||||
|
self.arm_height = -10
|
||||||
|
self.arm_joints = [0] * 5
|
||||||
|
self.arm_selected_joint = 0
|
||||||
|
self.arm_active = False
|
||||||
|
|
||||||
|
self.pressed_keys = set()
|
||||||
|
self.stdin_key_times = {}
|
||||||
|
self.key_lock = threading.Lock()
|
||||||
|
self.old_terminal_settings = None
|
||||||
|
|
||||||
|
self._stdin_buf = ""
|
||||||
|
|
||||||
|
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
|
||||||
|
self.listener = None
|
||||||
|
if self.use_windows_polling:
|
||||||
|
self.get_logger().info("Using Windows keyboard polling backend.")
|
||||||
|
elif self.use_stdin_keyboard:
|
||||||
|
self.enable_stdin_keyboard()
|
||||||
|
self.get_logger().info("Using stdin keyboard backend.")
|
||||||
|
else:
|
||||||
|
if keyboard is None:
|
||||||
|
raise RuntimeError("pynput is required for keyboard_backend:=pynput.")
|
||||||
|
self.listener = keyboard.Listener(
|
||||||
|
on_press=self.on_press,
|
||||||
|
on_release=self.on_release,
|
||||||
|
)
|
||||||
|
self.listener.start()
|
||||||
|
self.get_logger().info("Using pynput keyboard listener backend.")
|
||||||
|
|
||||||
|
self.timer = self.create_timer(self.update_rate, self.control_tick)
|
||||||
|
|
||||||
|
self.print_help()
|
||||||
|
self.get_logger().info(
|
||||||
|
f"UDP target: {self.udp_ip}:{self.udp_port}"
|
||||||
|
)
|
||||||
|
self.get_logger().info(
|
||||||
|
"Keyboard UDP control node started. Press Ctrl+C to exit."
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_press(self, key):
|
||||||
|
try:
|
||||||
|
value = key.char.lower() if key.char else key.char
|
||||||
|
except AttributeError:
|
||||||
|
value = key
|
||||||
|
|
||||||
|
with self.key_lock:
|
||||||
|
self.pressed_keys.add(value)
|
||||||
|
|
||||||
|
def on_release(self, key):
|
||||||
|
try:
|
||||||
|
value = key.char.lower() if key.char else key.char
|
||||||
|
except AttributeError:
|
||||||
|
value = key
|
||||||
|
|
||||||
|
with self.key_lock:
|
||||||
|
self.pressed_keys.discard(value)
|
||||||
|
|
||||||
|
def should_use_windows_polling(self):
|
||||||
|
if self.keyboard_backend == "win_poll":
|
||||||
|
return True
|
||||||
|
if self.keyboard_backend in ("pynput", "stdin"):
|
||||||
|
return False
|
||||||
|
return sys.platform == "win32" and user32 is not None
|
||||||
|
|
||||||
|
def should_use_stdin_keyboard(self):
|
||||||
|
if self.keyboard_backend == "stdin":
|
||||||
|
return True
|
||||||
|
if self.keyboard_backend in ("pynput", "win_poll"):
|
||||||
|
return False
|
||||||
|
return sys.platform != "win32"
|
||||||
|
|
||||||
|
def enable_stdin_keyboard(self):
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
raise RuntimeError("stdin keyboard backend requires an interactive terminal.")
|
||||||
|
|
||||||
|
self.old_terminal_settings = termios.tcgetattr(sys.stdin)
|
||||||
|
tty.setcbreak(sys.stdin.fileno())
|
||||||
|
atexit.register(self.restore_terminal)
|
||||||
|
|
||||||
|
def get_pressed_keys(self):
|
||||||
|
if self.use_windows_polling:
|
||||||
|
return self.get_windows_pressed_keys()
|
||||||
|
if self.use_stdin_keyboard:
|
||||||
|
return self.get_stdin_pressed_keys()
|
||||||
|
|
||||||
|
with self.key_lock:
|
||||||
|
return set(self.pressed_keys)
|
||||||
|
|
||||||
|
def get_windows_pressed_keys(self):
|
||||||
|
keys = set()
|
||||||
|
for key_name, key_code in WIN_KEY_CODES.items():
|
||||||
|
if user32.GetAsyncKeyState(key_code) & 0x8000:
|
||||||
|
keys.add(key_name)
|
||||||
|
|
||||||
|
return keys
|
||||||
|
|
||||||
|
_ALLOWED_SINGLE_KEYS = frozenset(
|
||||||
|
{"w", "a", "s", "d", "q", "e", "2", "3", "4", "5", "6"}
|
||||||
|
)
|
||||||
|
_ARROW_MAP = {"A": KEY_UP, "B": KEY_DOWN, "C": KEY_RIGHT, "D": KEY_LEFT}
|
||||||
|
|
||||||
|
def get_stdin_pressed_keys(self):
|
||||||
|
now = time.monotonic()
|
||||||
|
fd = sys.stdin.fileno()
|
||||||
|
|
||||||
|
raw = b""
|
||||||
|
while select.select([sys.stdin], [], [], 0)[0]:
|
||||||
|
chunk = os.read(fd, 4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
raw += chunk
|
||||||
|
|
||||||
|
buf = self._stdin_buf + raw.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
n = len(buf)
|
||||||
|
while i < n:
|
||||||
|
ch = buf[i]
|
||||||
|
|
||||||
|
if ch == "\x03":
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
|
||||||
|
if ch == "\x1b":
|
||||||
|
# Only handle when we have the full 3-byte escape.
|
||||||
|
if i + 2 < n and buf[i + 1] in ("[", "O"):
|
||||||
|
arrow = buf[i + 2]
|
||||||
|
key = self._ARROW_MAP.get(arrow)
|
||||||
|
if key is not None:
|
||||||
|
self.stdin_key_times[key] = now
|
||||||
|
i += 3
|
||||||
|
continue
|
||||||
|
# Incomplete / unknown escape — leave remainder for next tick.
|
||||||
|
break
|
||||||
|
|
||||||
|
ch_lower = ch.lower()
|
||||||
|
if ch_lower in self._ALLOWED_SINGLE_KEYS:
|
||||||
|
self.stdin_key_times[ch_lower] = now
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
self._stdin_buf = buf[i:]
|
||||||
|
|
||||||
|
expired_keys = [
|
||||||
|
key
|
||||||
|
for key, last_time in self.stdin_key_times.items()
|
||||||
|
if now - last_time > self.stdin_hold_time
|
||||||
|
]
|
||||||
|
for key in expired_keys:
|
||||||
|
del self.stdin_key_times[key]
|
||||||
|
|
||||||
|
return set(self.stdin_key_times.keys())
|
||||||
|
|
||||||
|
def is_arm_key_pressed(self, keys):
|
||||||
|
return (
|
||||||
|
KEY_UP in keys
|
||||||
|
or KEY_DOWN in keys
|
||||||
|
or KEY_LEFT in keys
|
||||||
|
or KEY_RIGHT in keys
|
||||||
|
or any(str(i) in keys for i in range(2, 7))
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_state(self, keys):
|
||||||
|
self.chassis_x = 0
|
||||||
|
self.chassis_y = 0
|
||||||
|
self.chassis_w = 0
|
||||||
|
|
||||||
|
if "w" in keys:
|
||||||
|
self.chassis_y = -self.chassis_linear_speed
|
||||||
|
if "s" in keys:
|
||||||
|
self.chassis_y = self.chassis_linear_speed
|
||||||
|
if "d" in keys:
|
||||||
|
self.chassis_x = self.chassis_linear_speed
|
||||||
|
if "a" in keys:
|
||||||
|
self.chassis_x = -self.chassis_linear_speed
|
||||||
|
if "e" in keys:
|
||||||
|
self.chassis_w = self.chassis_angular_speed
|
||||||
|
if "q" in keys:
|
||||||
|
self.chassis_w = -self.chassis_angular_speed
|
||||||
|
|
||||||
|
arm_changed = False
|
||||||
|
|
||||||
|
for i in range(2, 7):
|
||||||
|
if str(i) in keys:
|
||||||
|
self.arm_selected_joint = i - 2
|
||||||
|
arm_changed = True
|
||||||
|
|
||||||
|
if KEY_UP in keys:
|
||||||
|
self.arm_height = min(self.arm_height + self.arm_height_step, -10)
|
||||||
|
arm_changed = True
|
||||||
|
if KEY_DOWN in keys:
|
||||||
|
self.arm_height = max(self.arm_height - self.arm_height_step, -280)
|
||||||
|
arm_changed = True
|
||||||
|
|
||||||
|
joint_index = self.arm_selected_joint
|
||||||
|
if KEY_RIGHT in keys:
|
||||||
|
self.arm_joints[joint_index] = min(
|
||||||
|
self.arm_joints[joint_index] + self.arm_joint_step,
|
||||||
|
180,
|
||||||
|
)
|
||||||
|
arm_changed = True
|
||||||
|
if KEY_LEFT in keys:
|
||||||
|
self.arm_joints[joint_index] = max(
|
||||||
|
self.arm_joints[joint_index] - self.arm_joint_step,
|
||||||
|
-180,
|
||||||
|
)
|
||||||
|
arm_changed = True
|
||||||
|
|
||||||
|
if arm_changed:
|
||||||
|
self.arm_active = True
|
||||||
|
|
||||||
|
def build_chassis_cmd(self):
|
||||||
|
return f"XYW:{self.chassis_x}:{self.chassis_y}:{self.chassis_w}:XZHY\n".encode()
|
||||||
|
|
||||||
|
def build_arm_cmd(self):
|
||||||
|
joints = self.arm_joints
|
||||||
|
return (
|
||||||
|
f"JXB:{self.arm_height}:"
|
||||||
|
f"{joints[0]}:{joints[1]}:{joints[2]}:"
|
||||||
|
f"{joints[3]}:{joints[4]}:0:0:EZHY\n"
|
||||||
|
).encode()
|
||||||
|
|
||||||
|
def control_tick(self):
|
||||||
|
keys = self.get_pressed_keys()
|
||||||
|
self.update_state(keys)
|
||||||
|
|
||||||
|
self.sock.sendto(
|
||||||
|
self.build_chassis_cmd(),
|
||||||
|
(self.udp_ip, self.udp_port),
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.arm_active or self.is_arm_key_pressed(keys):
|
||||||
|
self.sock.sendto(self.build_arm_cmd(), (self.udp_ip, self.udp_port))
|
||||||
|
|
||||||
|
self.print_status(keys)
|
||||||
|
|
||||||
|
def print_status(self, keys):
|
||||||
|
selected_joint = self.arm_selected_joint + 2
|
||||||
|
joints = self.arm_joints
|
||||||
|
key_text = ""
|
||||||
|
if self.debug_keys:
|
||||||
|
key_text = " Keys:" + ",".join(self.format_key(key) for key in keys)
|
||||||
|
|
||||||
|
status = (
|
||||||
|
f"\rChassis X:{self.chassis_x:>4} Y:{self.chassis_y:>4} "
|
||||||
|
f"W:{self.chassis_w:>4} | "
|
||||||
|
f"Arm H:{self.arm_height:>4}mm J{selected_joint}:"
|
||||||
|
f"[{joints[0]:>4} {joints[1]:>4} {joints[2]:>4} "
|
||||||
|
f"{joints[3]:>4} {joints[4]:>4}] "
|
||||||
|
f"{'[ARM]' if self.arm_active else '[STANDBY]'}"
|
||||||
|
f"{key_text}"
|
||||||
|
)
|
||||||
|
print(status, end="", flush=True)
|
||||||
|
|
||||||
|
def format_key(self, key):
|
||||||
|
if isinstance(key, str):
|
||||||
|
return key
|
||||||
|
return str(key).replace("Key.", "")
|
||||||
|
|
||||||
|
def send_stop(self):
|
||||||
|
self.sock.sendto(
|
||||||
|
b"XYW:0:0:0:XZHY\n",
|
||||||
|
(self.udp_ip, self.udp_port),
|
||||||
|
)
|
||||||
|
|
||||||
|
def destroy_node(self):
|
||||||
|
self.send_stop()
|
||||||
|
|
||||||
|
if self.listener is not None and self.listener.running:
|
||||||
|
self.listener.stop()
|
||||||
|
self.restore_terminal()
|
||||||
|
|
||||||
|
self.sock.close()
|
||||||
|
|
||||||
|
print("\nSent chassis stop command.")
|
||||||
|
super().destroy_node()
|
||||||
|
|
||||||
|
def restore_terminal(self):
|
||||||
|
if self.old_terminal_settings is not None:
|
||||||
|
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, self.old_terminal_settings)
|
||||||
|
self.old_terminal_settings = None
|
||||||
|
|
||||||
|
def print_help(self):
|
||||||
|
print("=" * 62)
|
||||||
|
print(" ROS 2 chassis + arm keyboard UDP control")
|
||||||
|
print("=" * 62)
|
||||||
|
print(" Chassis:")
|
||||||
|
print(" W/S forward / backward")
|
||||||
|
print(" A/D left / right")
|
||||||
|
print(" Q/E turn left / turn right")
|
||||||
|
print(" Arm:")
|
||||||
|
print(" Up/Down lift up / down")
|
||||||
|
print(" 2 ~ 6 select joint J2 ~ J6")
|
||||||
|
print(" Lt/Rt decrease / increase selected joint angle")
|
||||||
|
print(" Other:")
|
||||||
|
print(" Ctrl+C exit and send chassis stop command")
|
||||||
|
print("=" * 62)
|
||||||
|
print(" Arm commands are sent only after pressing an arm key.")
|
||||||
|
print(" Make sure the arm pose matches the initial values.")
|
||||||
|
print("=" * 62)
|
||||||
|
|
||||||
|
|
||||||
|
def main(args=None):
|
||||||
|
rclpy.init(args=args)
|
||||||
|
node = KeyboardUdpControlNode()
|
||||||
|
|
||||||
|
try:
|
||||||
|
rclpy.spin(node)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
node.destroy_node()
|
||||||
|
rclpy.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
22
tools/udp_server.py
Normal file
22
tools/udp_server.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import socket
|
||||||
|
|
||||||
|
# 创建 UDP socket
|
||||||
|
server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
|
||||||
|
# 绑定地址和端口
|
||||||
|
HOST = "0.0.0.0"
|
||||||
|
PORT = 8888
|
||||||
|
|
||||||
|
server.bind((HOST, PORT))
|
||||||
|
|
||||||
|
print(f"UDP服务器正在监听 {HOST}:{PORT}")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# 接收数据
|
||||||
|
data, addr = server.recvfrom(1024)
|
||||||
|
|
||||||
|
print(f"收到来自 {addr} 的消息: {data.decode()}")
|
||||||
|
|
||||||
|
# 回复客户端
|
||||||
|
reply = "服务器已收到"
|
||||||
|
server.sendto(reply.encode(), addr)
|
||||||
Reference in New Issue
Block a user