237 lines
8.3 KiB
Python
237 lines
8.3 KiB
Python
"""
|
||
AGV 导航控制模块 - 通过 ROS2 控制 AGV 运动
|
||
使用 ros2 CLI 命令进行通信,避免 rclpy 导入问题
|
||
"""
|
||
import time
|
||
import subprocess
|
||
import json
|
||
import logging
|
||
import math
|
||
from typing import Tuple, Optional, List
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ROS2 环境设置
|
||
ROS2_SETUP_CMD = "export ROS_DOMAIN_ID=1 && source ~/agv_pro_ros2/install/setup.bash"
|
||
|
||
|
||
class AGVController:
|
||
"""AGV 运动控制 - ROS2 版本"""
|
||
|
||
def __init__(self, device: str = "/dev/agvpro_controller", baudrate: int = 1000000):
|
||
self.device = device
|
||
self.baudrate = baudrate
|
||
self._connected = False
|
||
self._position = [0.0, 0.0, 0.0] # [x, y, yaw]
|
||
self._voltage = 0.0
|
||
self._ros2_available = False
|
||
|
||
def _run_ros2_cmd(self, cmd: str, timeout: float = 5.0) -> tuple:
|
||
"""执行 ros2 命令"""
|
||
full_cmd = f"bash -c '{ROS2_SETUP_CMD} && {cmd}'"
|
||
try:
|
||
result = subprocess.run(
|
||
full_cmd,
|
||
shell=True,
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=timeout
|
||
)
|
||
return result.returncode, result.stdout.strip(), result.stderr.strip()
|
||
except subprocess.TimeoutExpired:
|
||
return -1, "", "Timeout"
|
||
except Exception as e:
|
||
return -1, "", str(e)
|
||
|
||
def connect(self) -> bool:
|
||
"""连接 AGV - 检查 ROS2 节点和 topic"""
|
||
try:
|
||
# 检查 agv_pro_node 是否运行
|
||
rc, out, err = self._run_ros2_cmd("ros2 node list")
|
||
if rc != 0:
|
||
logger.error(f"ROS2 节点列表获取失败: {err}")
|
||
return False
|
||
|
||
if "/agv_pro_node" not in out:
|
||
logger.error("agv_pro_node 未运行")
|
||
return False
|
||
|
||
# 检查 /odom topic
|
||
rc, out, err = self._run_ros2_cmd("ros2 topic list")
|
||
if "/odom" not in out:
|
||
logger.error("/odom topic 不存在")
|
||
return False
|
||
|
||
# 尝试获取一次位置数据
|
||
rc, out, err = self._run_ros2_cmd(
|
||
"timeout 5 ros2 topic echo /odom 2>timeout 10 ros2 topic echo /odom --once 2>/dev/null1 | head -1",
|
||
timeout=6
|
||
)
|
||
|
||
if rc == 0 and out:
|
||
self._connected = True
|
||
self._ros2_available = True
|
||
logger.info("AGV ROS2 连接成功")
|
||
return True
|
||
else:
|
||
# /odom 可能暂时没数据,但节点存在也算连接成功
|
||
self._connected = True
|
||
self._ros2_available = True
|
||
logger.info("AGV ROS2 连接成功 (节点存在,等待 odom 数据)")
|
||
return True
|
||
|
||
except Exception as e:
|
||
logger.error(f"AGV 连接失败: {e}")
|
||
return False
|
||
|
||
def is_connected(self) -> bool:
|
||
return self._connected
|
||
|
||
def _publish_cmd_vel(self, linear_x: float = 0.0, linear_y: float = 0.0, angular_z: float = 0.0):
|
||
"""发布速度命令到 /cmd_vel"""
|
||
# 直接执行,避免引号嵌套问题
|
||
msg = f'{{"linear": {{"x": {linear_x}, "y": {linear_y}, "z": 0.0}}, "angular": {{"x": 0.0, "y": 0.0, "z": {angular_z}}}}}'
|
||
full_cmd = f"bash -c '{ROS2_SETUP_CMD} && ros2 topic pub /cmd_vel geometry_msgs/msg/Twist \"{msg}\" --once'"
|
||
try:
|
||
result = subprocess.run(
|
||
full_cmd,
|
||
shell=True,
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=5
|
||
)
|
||
if result.returncode != 0:
|
||
logger.warning(f"发布 cmd_vel 失败: {result.stderr.strip()}")
|
||
except subprocess.TimeoutExpired:
|
||
logger.warning("发布 cmd_vel 超时")
|
||
except Exception as e:
|
||
logger.warning(f"发布 cmd_vel 失败: {e}")
|
||
|
||
def move_forward(self, speed: float = 0.5, duration: float = None):
|
||
"""前进"""
|
||
if not self.is_connected():
|
||
return
|
||
self._publish_cmd_vel(linear_x=speed)
|
||
if duration:
|
||
time.sleep(duration)
|
||
self.stop()
|
||
|
||
def move_backward(self, speed: float = 0.5, duration: float = None):
|
||
"""后退"""
|
||
if not self.is_connected():
|
||
return
|
||
self._publish_cmd_vel(linear_x=-speed)
|
||
if duration:
|
||
time.sleep(duration)
|
||
self.stop()
|
||
|
||
def turn_left(self, speed: float = 0.5, duration: float = None):
|
||
"""左转"""
|
||
if not self.is_connected():
|
||
return
|
||
self._publish_cmd_vel(angular_z=speed)
|
||
if duration:
|
||
time.sleep(duration)
|
||
self.stop()
|
||
|
||
def turn_right(self, speed: float = 0.5, duration: float = None):
|
||
"""右转"""
|
||
if not self.is_connected():
|
||
return
|
||
self._publish_cmd_vel(angular_z=-speed)
|
||
if duration:
|
||
time.sleep(duration)
|
||
self.stop()
|
||
|
||
def move_left_lateral(self, speed: float = 0.5, duration: float = None):
|
||
"""向左横向移动"""
|
||
if not self.is_connected():
|
||
return
|
||
self._publish_cmd_vel(linear_y=speed)
|
||
if duration:
|
||
time.sleep(duration)
|
||
self.stop()
|
||
|
||
def move_right_lateral(self, speed: float = 0.5, duration: float = None):
|
||
"""向右横向移动"""
|
||
if not self.is_connected():
|
||
return
|
||
self._publish_cmd_vel(linear_y=-speed)
|
||
if duration:
|
||
time.sleep(duration)
|
||
self.stop()
|
||
|
||
def stop(self):
|
||
"""停止"""
|
||
if self.is_connected():
|
||
self._publish_cmd_vel(0, 0, 0)
|
||
|
||
def get_position(self) -> Optional[List[float]]:
|
||
"""获取 AGV 当前位置 [x, y, yaw]"""
|
||
if not self.is_connected():
|
||
return None
|
||
try:
|
||
# 从 /odom topic 获取位置
|
||
rc, out, err = self._run_ros2_cmd(
|
||
"timeout 5 ros2 topic echo /odom 2>timeout 10 ros2 topic echo /odom --once 2>/dev/null1 | head -1",
|
||
timeout=6
|
||
)
|
||
if rc == 0 and out:
|
||
# 解析 odom 消息 (YAML 格式)
|
||
# ros2 topic echo 输出可能含多个 --- 分隔的文档,只取第一个
|
||
import yaml
|
||
yaml_str = out.split('---')[0]
|
||
data = yaml.safe_load(yaml_str)
|
||
if data:
|
||
pos = data.get("pose", {}).get("pose", {}).get("position", {})
|
||
x = pos.get("x", 0.0)
|
||
y = pos.get("y", 0.0)
|
||
# 从四元数计算 yaw
|
||
orient = data.get("pose", {}).get("pose", {}).get("orientation", {})
|
||
qz = orient.get("z", 0.0)
|
||
qw = orient.get("w", 1.0)
|
||
yaw = math.atan2(2.0 * qw * qz, 1.0 - 2.0 * qz * qz)
|
||
self._position = [x, y, yaw]
|
||
return self._position
|
||
except Exception as e:
|
||
logger.debug(f"获取位置失败: {e}")
|
||
return None
|
||
|
||
def go_to_point(self, x: float, y: float, rz: float = None, speed: float = 0.5) -> bool:
|
||
"""移动到目标点(需要 ROS2 导航栈)"""
|
||
logger.warning("go_to_point 需要 ROS2 Nav2 支持,当前仅记录目标")
|
||
return True
|
||
|
||
def get_battery(self) -> Optional[float]:
|
||
"""获取电池电压"""
|
||
if not self.is_connected():
|
||
return None
|
||
try:
|
||
# 从 /voltage topic 获取电压
|
||
rc, out, err = self._run_ros2_cmd(
|
||
"timeout 5 ros2 topic echo /voltage 2>timeout 10 ros2 topic echo /voltage --once 2>/dev/null1 | head -1",
|
||
timeout=6
|
||
)
|
||
if rc == 0 and out:
|
||
# 解析电压消息(ros2 topic echo 可能输出多文档 YAML)
|
||
import yaml
|
||
yaml_str = out.split('---')[0]
|
||
data = yaml.safe_load(yaml_str)
|
||
if data:
|
||
self._voltage = data.get("data", 0.0)
|
||
return self._voltage
|
||
except Exception as e:
|
||
logger.debug(f"获取电压失败: {e}")
|
||
return None
|
||
|
||
def disconnect(self):
|
||
self.stop()
|
||
self._connected = False
|
||
|
||
def __enter__(self):
|
||
self.connect()
|
||
return self
|
||
|
||
def __exit__(self, *args):
|
||
self.disconnect()
|