chore: add ros2 udp_teleop package and tools

This commit is contained in:
2026-05-27 02:17:35 +08:00
parent 13fdba5fff
commit 6f6577d983
13 changed files with 743 additions and 3 deletions

6
.gitignore vendored
View File

@@ -11,9 +11,9 @@
# ====================
# ROS 2
# ====================
ros2/build/
ros2/install/
ros2/log/
ros2/**/build/
ros2/**/install/
ros2/**/log/
# ====================
# Python

View 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` 在终端直接运行。

View 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

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

View File

View File

@@ -0,0 +1,4 @@
[develop]
script_dir=$base/lib/udp_teleop
[install]
install_scripts=$base/lib/udp_teleop

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

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

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

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

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