File size: 10,851 Bytes
15b8951 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 | """Chế độ lệnh chuyển động rời rạc (NaVILA-style mid-level action).
2 cấp:
- parse_motion(): "não/parser" — tách câu lệnh -> MotionCmd (quãng đường / góc).
- MotionController: "tủy sống" — chạy vòng kín bằng /odom, publish TwistStamped vào
/cmd_vel_joy (priority cao, đè Nav2), tự dừng đúng quãng đường/góc; chặn /scan +
timeout + cancel để an toàn.
parse_motion thuần (chỉ re/math) -> unit-test được. ROS import nằm trong MotionController
(lazy) nên module import được khi không có ROS.
"""
import os
import re
import math
from dataclasses import dataclass
_NUM = r"([-+]?\d*\.?\d+)"
_MOVE_DIR = re.compile(r"\b(forward|ahead|backward|back|tiến|lùi|tien|lui)\b")
_MOVE_VERB = re.compile(r"\b(move|go|drive|forward|backward|back|ahead|tiến|lùi|tien|lui)\b")
_MOVE_UNIT = re.compile(_NUM + r"\s*(cm|centimet\w*|mm|m|met\w*|meter\w*)\b")
_TURN_VERB = re.compile(r"\b(turn|rotate|spin|xoay|quay)\b")
_TURN_DIR = re.compile(r"\b(left|right|trái|phải|trai|phai)\b")
_TURN_UNIT = re.compile(_NUM + r"\s*(deg|degree\w*|°|rad|radian\w*)")
@dataclass
class MotionCmd:
kind: str # "move" | "turn"
value: float # mét (move; + tiến, - lùi) | radian (turn; + trái/CCW, - phải/CW)
raw: str
def parse_motion(command):
"""Trả MotionCmd nếu câu lệnh là lệnh chuyển động; None nếu không phải."""
t = (command or "").lower().strip()
# TURN
if _TURN_VERB.search(t) or _TURN_DIR.search(t):
mu = _TURN_UNIT.search(t)
if mu and (_TURN_DIR.search(t) or _TURN_VERB.search(t)):
val = float(mu.group(1))
unit = mu.group(2)
rad = val if unit.startswith("rad") else math.radians(val)
if re.search(r"\b(right|phải|phai)\b", t):
rad = -abs(rad)
else:
rad = abs(rad)
return MotionCmd("turn", rad, command)
# MOVE
mu = _MOVE_UNIT.search(t)
if mu and _MOVE_VERB.search(t):
val = float(mu.group(1))
unit = mu.group(2)
if unit == "cm" or unit.startswith("centi"):
meters = val / 100.0
elif unit == "mm":
meters = val / 1000.0
else:
meters = val
if re.search(r"\b(back|backward|lùi|lui)\b", t):
meters = -abs(meters)
else:
meters = abs(meters)
return MotionCmd("move", meters, command)
return None
def _yaw(q):
return math.atan2(2 * (q.w * q.z + q.x * q.y),
1 - 2 * (q.y * q.y + q.z * q.z))
class MotionController:
def __init__(self, node, cmd_topic="/cmd_vel_joy", lin_speed=None, ang_speed=None,
rate_hz=20.0, scan_topic="/scan", front_stop=None, front_fov_deg=20.0,
timeout_s=40.0):
# Đi bằng env MOTION_LIN_SPEED; xoay bằng MOTION_ANG_SPEED (mặc định ~20°/s để
# xoay dứt khoát), có TRẦN an toàn MOTION_ANG_MAX_DEG (mặc định 30°/s) để xoay
# không quá nhanh gây mất cam. Hạ trần xuống nếu xoay mà mất camera.
if lin_speed is None:
lin_speed = float(os.getenv("MOTION_LIN_SPEED", "0.3"))
if ang_speed is None:
ang_speed = float(os.getenv("MOTION_ANG_SPEED", "0.35")) # ~20°/s
ang_max = math.radians(float(os.getenv("MOTION_ANG_MAX_DEG", "30")))
ang_speed = min(abs(ang_speed), ang_max)
# Khoảng cách (m) lidar coi là vật cản phía trước -> dừng. HẠ xuống để robot
# được lại GẦN mục tiêu hơn (đáy box xuống sát mép dưới ảnh). Mặc định 0.30m.
if front_stop is None:
front_stop = float(os.getenv("MOTION_FRONT_STOP", "0.30"))
from geometry_msgs.msg import TwistStamped
from nav_msgs.msg import Odometry
from sensor_msgs.msg import LaserScan
from rclpy.qos import qos_profile_sensor_data
self.node = node
self.lin_speed = lin_speed
self.ang_speed = ang_speed
self.rate_hz = rate_hz
self.front_stop = front_stop
self.front_fov = math.radians(front_fov_deg)
self.timeout_s = timeout_s
self._TwistStamped = TwistStamped
self._odom = None
self._scan = None
self.pub = node.create_publisher(TwistStamped, cmd_topic, 10)
node.create_subscription(Odometry, "/odom", self._on_odom, 10)
node.create_subscription(LaserScan, scan_topic, self._on_scan,
qos_profile_sensor_data)
def _on_odom(self, msg):
self._odom = msg.pose.pose
def _on_scan(self, msg):
self._scan = msg
def _front_clear(self):
s = self._scan
if s is None:
return True # không có scan -> không chặn được (cẩn thận: đi chậm)
vals = []
for i, r in enumerate(s.ranges):
a = s.angle_min + i * s.angle_increment
if -self.front_fov <= a <= self.front_fov and r > 0.05 and r == r:
vals.append(r)
return (min(vals) > self.front_stop) if vals else True
def front_distance(self):
"""Khoảng cách vật gần nhất trong nón phía trước (m), hoặc None nếu chưa có scan."""
s = self._scan
if s is None:
return None
vals = []
for i, r in enumerate(s.ranges):
a = s.angle_min + i * s.angle_increment
if -self.front_fov <= a <= self.front_fov and r > 0.05 and r == r:
vals.append(r)
return min(vals) if vals else None
def _publish(self, vx, wz):
msg = self._TwistStamped()
msg.header.stamp = self.node.get_clock().now().to_msg()
msg.header.frame_id = "base_link"
msg.twist.linear.x = float(vx)
msg.twist.angular.z = float(wz)
self.pub.publish(msg)
def _stop(self):
for _ in range(3):
self._publish(0.0, 0.0)
def estop(self):
"""Phanh khẩn cấp: spam vận tốc 0 lên cmd_vel_joy (đè mọi nguồn)."""
for _ in range(8):
self._publish(0.0, 0.0)
def _move(self, distance, cancel):
import time
if self._odom is None:
yield {"kind": "nodom"}
return
x0, y0 = self._odom.position.x, self._odom.position.y
target = abs(distance)
vx = self.lin_speed if distance >= 0 else -self.lin_speed
t0 = time.time()
while True:
if cancel is not None and cancel.is_set():
yield {"kind": "cancel"}
return
if distance > 0 and not self._front_clear():
yield {"kind": "obstacle"}
return
od = self._odom
cur = math.hypot(od.position.x - x0, od.position.y - y0)
rem = target - cur
if rem <= 0.03:
yield {"kind": "done"}
return
if time.time() - t0 > self.timeout_s:
yield {"kind": "timeout"}
return
self._publish(vx, 0.0)
yield {"kind": "progress", "remaining": rem}
time.sleep(1.0 / self.rate_hz)
def _turn(self, angle, cancel):
import time
if self._odom is None:
yield {"kind": "nodom"}
return
prev = _yaw(self._odom.orientation)
acc = 0.0
target = abs(angle)
# Dung sai HOÀN THÀNH lệnh xoay (deadband chống vọt/rung ở 20Hz). KHÔNG phải ngưỡng
# căn giữa 5° (cái đó ở prompt VLM). Hạ xuống nếu muốn xoay tới sát góc lệnh hơn.
turn_tol = math.radians(float(os.getenv("MOTION_TURN_TOL_DEG", "5")))
# MOTION_TURN_SIGN=-1 nếu robot xoay NGƯỢC chiều mong muốn (lệch mounting/sign).
sign = float(os.getenv("MOTION_TURN_SIGN", "1"))
wz = (self.ang_speed if angle >= 0 else -self.ang_speed) * sign
t0 = time.time()
while True:
if cancel is not None and cancel.is_set():
yield {"kind": "cancel"}
return
cur = _yaw(self._odom.orientation)
d = (cur - prev + math.pi) % (2 * math.pi) - math.pi
acc += abs(d)
prev = cur
rem = target - acc
if rem <= turn_tol:
yield {"kind": "done"}
return
if time.time() - t0 > self.timeout_s:
yield {"kind": "timeout"}
return
self._publish(0.0, wz)
yield {"kind": "progress", "remaining": rem}
time.sleep(1.0 / self.rate_hz)
def run(self, cmd, cancel=None):
"""Generator phát step-events cho GUI (tái dùng schema demo1)."""
if cmd.kind == "move":
label = "forward" if cmd.value >= 0 else "backward"
title = f"Moving {label} {abs(cmd.value):.2f} m"
steps = self._move(cmd.value, cancel)
else:
label = "left" if cmd.value >= 0 else "right"
title = f"Turning {label} {math.degrees(abs(cmd.value)):.0f}°"
steps = self._turn(cmd.value, cancel)
yield {"type": "step", "id": "motion", "status": "running", "title": title}
result = {"kind": "fail"}
for ev in steps:
if ev["kind"] == "progress":
if cmd.kind == "move":
yield {"type": "nav", "distance_remaining": ev["remaining"]}
else:
yield {"type": "step", "id": "motion", "status": "running",
"title": f"Turning {label} — {math.degrees(ev['remaining']):.0f}° left"}
else:
result = ev
self._stop()
kind = result["kind"]
if kind == "done":
yield {"type": "step", "id": "motion", "status": "done"}
yield {"type": "answer", "text": f"Done — {title.lower()}.",
"state": "UNKNOWN"}
elif kind == "obstacle":
yield {"type": "step", "id": "motion", "status": "error"}
yield {"type": "error", "message": "⛔ Vật cản phía trước — đã dừng an toàn."}
elif kind == "cancel":
yield {"type": "step", "id": "motion", "status": "error"}
yield {"type": "error", "message": "⏹ Đã dừng theo yêu cầu."}
elif kind == "nodom":
yield {"type": "step", "id": "motion", "status": "error"}
yield {"type": "error", "message": "Không có /odom — robot chưa kết nối?"}
else: # timeout / fail
yield {"type": "step", "id": "motion", "status": "error"}
yield {"type": "error", "message": "Quá thời gian chuyển động."}
|