修复平行夹爪串口通讯问题

今天三次实测都在"首轮咬合结束→张开到 400"之后停住,微抬 Z 3mm 那条
厂家 MoveL 从未下发。日志只写"夹爪 pos 后 8s 未到位(没有 /gripper/states)",
照着去查夹爪开合,方向是错的。

真实原因是串口会话坏了:旧节点从昨天 18:25 一直跑,写命令(FC06)有回显
——ctrl pos=400 有记录、手指也确实张开——读 0x0201 却连续 264 次无应答
(0x0200 和写都有应答)。_on_timer 读失败就 return,一帧状态都不发,
流水线拿不到状态,就在 _shift_flange_z 之前退出了。

改法(都按"读不到要如实说读不到"):

- dh_pgea:串口 EIO/termios 错误从 DhPgeaError 里分出来,单独抛
  DhPgeaBusError。以前 termios.error(5) 从 timer 回调冒出去,整个节点带
  traceback 退出。
- dh_pgea_node:读失败也发 /gripper/states,data 增到 6 个元素,最后一位
  online(1=刚读回,0=离线、前 5 个是旧值);5 元素老消费者不受影响。
  离线时不发 /gripper/joint_states(关节状态没有离线位,不能拿旧开口冒充
  现在的)。EIO 立刻重开串口,连续 30 拍读不到也重开一次,1 秒最多一次。
- pipeline_node:超时不再一律说"未到位",三种情况分开——真没到位 /
  只有离线帧(总线无应答,不是没张开)/ 一帧都没收到(节点没在发)。
- gauge_gripper、operator_gripper_state:离线帧不当有效状态,夹爪页显示
  "总线无应答"。
- gauge_config:登记 unwind_lift_mm 和 unwind_open_permille。这两个键
  上一次只写进了 site.yaml,加载器按"不认识的键"直接拒绝启动
  gauge_assembly 段(test_shipped_site_yaml_s07_step_cannot_deadlock 当时
  是红的)。
- site_io:恢复 7af9e3d 误删的 listed_arms / absolutize_site_paths 等
  (operator_gripper_state 还在导入 listed_arms,操作台夹爪页一导入就
  ImportError)。按 76c9b9c 恢复,只增不减。

实测验收:重启节点后 --cli probe 读回
data=[1.0, 2.0, 9.0, 0.0, 20.0, 1.0],online=1、grip=2(咬住)、pos=9,
0x0201 不再无应答。

单测 931 passed。新增负向用例:离线帧不能被当成有效状态、EIO 必须抛
DhPgeaBusError、夹爪超时不能报成"未到位"。剩余 6 个失败与本次无关
(伺服限速、回调遮蔽、operator_camera_view 直接继承 Node、
site.yaml 缺 screw_stage_depth_mm、臂控制器未激活,以及 parallel_cli
用例因当前有真实夹爪节点占着串口而走了另一条分支)。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
arm_dev
2026-10-20 10:10:37 +08:00
parent e80edd7b5e
commit 04137fb6e5
12 changed files with 460 additions and 34 deletions
+13
View File
@@ -67,4 +67,17 @@ ros2 service call /dual_gripper_driver/right/execute arm_msgs/srv/GripperExecute
USB 未插齐时节点仍可起来(`allow_offline:=true`),对应侧 `status.error_msg` 会写连接失败。 USB 未插齐时节点仍可起来(`allow_offline:=true`),对应侧 `status.error_msg` 会写连接失败。
## `/gripper/states` 的离线帧
`data` 是 6 个元素:
`[initialized, grip_state, position, target_pos, target_force, online]`。
`online=0` 表示这一帧**没读到总线**,前 5 个是上一次读到的值,不是现在的状态。老节点只发 5 个元素(那时它只在读成功时才发),5 元素帧按有效帧处理。
读 `0x0201` 无应答时节点照样发离线帧,`/gripper/joint_states` **不发**:关节状态没有离线位,宁可不发,也不要把上一次的开口当成现在的。
流水线 `_wait_gripper` 只认有效帧;超时没等到有效帧时报「读不到状态(只有离线帧,Modbus 无应答),不是夹爪没张开」,不能再报成「夹爪没到位」——2026-10-20 读 `0x0201` 一连 264 次无应答,实测在张开后停住、微抬没有下发,日志却只写「未到位」。
串口 `EIO`(USB 掉线)抛 `DhPgeaBusError`,节点先关串口再重开(1 秒最多一次),不再整进程带 traceback 退出。没有应答(`DhPgeaError`)先继续重试;连续 `BUS_REOPEN_AFTER_FAILURES=30` 拍都读不到也重开一次——2026-10-20 那次就是写有回显、读一直无应答,重启节点立刻恢复,说明坏的是串口会话。
用户需在 `dialout`:`sudo usermod -aG dialout $USER`。 用户需在 `dialout`:`sudo usermod -aG dialout $USER`。
@@ -204,6 +204,22 @@ class DhPgeaError(RuntimeError):
pass pass
class DhPgeaBusError(DhPgeaError):
"""串口本身坏了(EIO / 设备被拔 / USB 掉线),不是夹爪没应答。
2026-10-20 之前 ``_transact`` 让 ``termios.error: (5, 'Input/output error')``
直接冒到 timer 回调外,整个夹爪节点带着 traceback 退出。这两种失败必须分开:
没应答可以继续重试;串口坏了要先关再重开。
"""
#: 连续这么多拍读不到状态就重开串口。
#: 2026-10-20 实测:写命令有回显(``ctrl pos=400`` 有记录、手指也动了),
#: 读 ``0x0201`` 却连续 264 次无应答;重启节点立刻恢复。说明串口会话坏了,
#: 光在同一个 fd 上重试没用。按失败拍数(不是秒)计数,是因为每拍耗时随重试变长。
BUS_REOPEN_AFTER_FAILURES = 30
WIRING_HINT = ( WIRING_HINT = (
"PC 已发出 Modbus,夹爪 0 字节返回。不是力/速度参数问题。按顺序查:\n" "PC 已发出 Modbus,夹爪 0 字节返回。不是力/速度参数问题。按顺序查:\n"
" 1) 夹爪 24 V DC 已上电(指示灯亮)\n" " 1) 夹爪 24 V DC 已上电(指示灯亮)\n"
@@ -362,6 +378,7 @@ class DhPgeaGripper:
if self._ser is None or not self._ser.is_open: if self._ser is None or not self._ser.is_open:
raise DhPgeaError("串口未打开") raise DhPgeaError("串口未打开")
bit_s = 10.0 / max(1, self.baudrate) bit_s = 10.0 / max(1, self.baudrate)
try:
self._set_tx(True) self._set_tx(True)
if self.rs485_rts: if self.rs485_rts:
time.sleep(2.0 * bit_s) time.sleep(2.0 * bit_s)
@@ -377,6 +394,11 @@ class DhPgeaGripper:
chunk = self._ser.read(expect - len(buf)) chunk = self._ser.read(expect - len(buf))
if chunk: if chunk:
buf.extend(chunk) buf.extend(chunk)
except OSError as exc:
raise DhPgeaBusError(
f"{self.port} 串口读写失败({exc})。"
"设备被拔掉或 USB 掉线时要先关串口再重开,不是夹爪没应答。"
) from exc
return bytes(buf) if buf else None return bytes(buf) if buf else None
def initialization(self) -> bool: def initialization(self) -> bool:
@@ -6,8 +6,9 @@
data: [initialize, position, force, speed] data: [initialize, position, force, speed]
initialize>0.5 → 写 0xA5;否则设置力/速度/位置 initialize>0.5 → 写 0xA5;否则设置力/速度/位置
/gripper/states std_msgs/Float32MultiArray /gripper/states std_msgs/Float32MultiArray
data: [initialized, grip_state, position, target_pos, target_force] data: [initialized, grip_state, position, target_pos, target_force, online]
/gripper/joint_states sensor_msgs/JointState online=1 这一帧刚从总线读回来;0=读不到(前 5 个是上一次的值)
/gripper/joint_states sensor_msgs/JointState(只在读到状态时发)
服务(std_srvs/Trigger): 服务(std_srvs/Trigger):
/gripper/initialize /gripper/open /gripper/close /gripper/initialize /gripper/open /gripper/close
@@ -18,6 +19,7 @@
from __future__ import annotations from __future__ import annotations
import threading import threading
import time
from typing import Optional from typing import Optional
import rclpy import rclpy
@@ -28,11 +30,14 @@ from std_msgs.msg import Float32, Float32MultiArray, MultiArrayDimension, MultiA
from std_srvs.srv import Trigger from std_srvs.srv import Trigger
from arm_application.dh_pgea import ( from arm_application.dh_pgea import (
BUS_REOPEN_AFTER_FAILURES,
DhPgeaBusError,
DhPgeaError, DhPgeaError,
DhPgeaGripper, DhPgeaGripper,
GripperState, GripperState,
WIRING_HINT, WIRING_HINT,
) )
from arm_application.gauge_gripper import STATES_ONLINE_INDEX
from arm_application.ros_node import TolerantNode, param_as_str from arm_application.ros_node import TolerantNode, param_as_str
@@ -84,6 +89,8 @@ class DhPgeaNode(TolerantNode):
) )
self._state = GripperState(stroke_mm=self._stroke) self._state = GripperState(stroke_mm=self._stroke)
self._cmd_lock = threading.Lock() self._cmd_lock = threading.Lock()
self._last_reopen = 0.0
self._fail_streak = 0
self._pub_state = self.create_publisher(Float32MultiArray, "/gripper/states", 10) self._pub_state = self.create_publisher(Float32MultiArray, "/gripper/states", 10)
self._pub_js = self.create_publisher(JointState, "/gripper/joint_states", 10) self._pub_js = self.create_publisher(JointState, "/gripper/joint_states", 10)
@@ -157,14 +164,14 @@ class DhPgeaNode(TolerantNode):
info = self._apply_move(int(position), f, s) info = self._apply_move(int(position), f, s)
self.get_logger().info(f"ctrl {info}") self.get_logger().info(f"ctrl {info}")
except DhPgeaError as exc: except DhPgeaError as exc:
self.get_logger().error(str(exc)) self._note_bus_error(exc)
def _on_set_pos(self, msg: Float32) -> None: def _on_set_pos(self, msg: Float32) -> None:
try: try:
info = self._apply_move(int(msg.data)) info = self._apply_move(int(msg.data))
self.get_logger().info(f"set_position {info}") self.get_logger().info(f"set_position {info}")
except DhPgeaError as exc: except DhPgeaError as exc:
self.get_logger().error(str(exc)) self._note_bus_error(exc)
def _srv_init(self, _req, resp): def _srv_init(self, _req, resp):
try: try:
@@ -172,6 +179,7 @@ class DhPgeaNode(TolerantNode):
resp.success = True resp.success = True
resp.message = self._grip.get_run_states().summary() resp.message = self._grip.get_run_states().summary()
except DhPgeaError as exc: except DhPgeaError as exc:
self._note_bus_error(exc)
resp.success = False resp.success = False
resp.message = str(exc) resp.message = str(exc)
return resp return resp
@@ -182,6 +190,7 @@ class DhPgeaNode(TolerantNode):
resp.success = True resp.success = True
resp.message = info resp.message = info
except DhPgeaError as exc: except DhPgeaError as exc:
self._note_bus_error(exc)
resp.success = False resp.success = False
resp.message = str(exc) resp.message = str(exc)
return resp return resp
@@ -192,30 +201,87 @@ class DhPgeaNode(TolerantNode):
resp.success = True resp.success = True
resp.message = info resp.message = info
except DhPgeaError as exc: except DhPgeaError as exc:
self._note_bus_error(exc)
resp.success = False resp.success = False
resp.message = str(exc) resp.message = str(exc)
return resp return resp
def _reopen_serial(self) -> None:
"""串口 EIO 后先关再重开。1 秒最多一次,避免把 USB 打爆。"""
now = time.monotonic()
if now - self._last_reopen < 1.0:
return
self._last_reopen = now
try:
self._grip.close()
except Exception:
pass
try:
self._grip.open()
self._grip.probe()
except (DhPgeaError, OSError) as exc:
self.get_logger().error(
f"夹爪串口重开失败:{exc}", throttle_duration_sec=10.0
)
return
self._online = True
self.get_logger().info("夹爪串口已重开,总线恢复应答")
def _note_bus_error(self, exc: Exception) -> None:
"""命令回调里的失败也走同一套:标离线,串口坏了就重开。"""
self._online = False
self.get_logger().error(str(exc))
if isinstance(exc, DhPgeaBusError):
self._reopen_serial()
def _on_timer(self) -> None: def _on_timer(self) -> None:
online = True
try: try:
st = self._grip.get_run_states(retries=2 if self._online else 1) st = self._grip.get_run_states(retries=2 if self._online else 1)
self._state = st self._state = st
self._fail_streak = 0
if not self._online: if not self._online:
self._online = True self._online = True
self.get_logger().info("夹爪总线已恢复") self.get_logger().info("夹爪总线已恢复")
except DhPgeaBusError as exc:
self._online = False
self._fail_streak = 0
self.get_logger().warn(str(exc), throttle_duration_sec=5.0)
self._reopen_serial()
online = False
except DhPgeaError as exc: except DhPgeaError as exc:
self._online = False self._online = False
self._fail_streak += 1
self.get_logger().warn(str(exc), throttle_duration_sec=5.0) self.get_logger().warn(str(exc), throttle_duration_sec=5.0)
return if self._fail_streak >= BUS_REOPEN_AFTER_FAILURES:
# 写有回显、读一直无应答时,重试同一个串口会话没用,必须重开。
self.get_logger().warn(
f"连续 {self._fail_streak} 拍读不到状态,先重开串口再继续重试"
)
self._fail_streak = 0
self._reopen_serial()
online = False
self._publish_state(online)
def _publish_state(self, online: bool) -> None:
"""离线也要发一帧,前 5 个是上一次的值、最后一位是 0。
不发的话上层只看到"没有 /gripper/states",会把"总线读不到"当成
"夹爪没到位"。只有 5 个元素的老消费者照旧能用。
"""
st = self._state
n = STATES_ONLINE_INDEX + 1
arr = Float32MultiArray() arr = Float32MultiArray()
arr.layout = MultiArrayLayout( arr.layout = MultiArrayLayout(
dim=[MultiArrayDimension(label="dh_pgea", size=5, stride=5)], dim=[MultiArrayDimension(label="dh_pgea", size=n, stride=n)],
data_offset=0, data_offset=0,
) )
arr.data = list(st.as_tuple()) arr.data = list(st.as_tuple()) + [1.0 if online else 0.0]
self._pub_state.publish(arr) self._pub_state.publish(arr)
# 关节状态没有"离线"位:宁可不发,也不要把上一次的开口当成现在的。
if not online:
return
js = JointState() js = JointState()
js.header.stamp = self.get_clock().now().to_msg() js.header.stamp = self.get_clock().now().to_msg()
js.name = ["gripper_finger1_joint"] js.name = ["gripper_finger1_joint"]
@@ -52,6 +52,7 @@ _UNIT_SUFFIX: Dict[str, str] = {
"m": "_m", "m": "_m",
"px": "_px", "px": "_px",
"pct": "_pct", "pct": "_pct",
"permille": "_permille",
"au": "_delta", "au": "_delta",
# 腕部 FT 的**真牛顿**。与 au 分开一个后缀是刻意的:两个口径混用不会报错, # 腕部 FT 的**真牛顿**。与 au 分开一个后缀是刻意的:两个口径混用不会报错,
# 只会"判据不触发"或"一碰就炸",看起来像算法问题(见 site.yaml 的注释)。 # 只会"判据不触发"或"一碰就炸",看起来像算法问题(见 site.yaml 的注释)。
@@ -119,6 +120,9 @@ _TYPES: Dict[str, type] = {
"holes_total": int, "holes_total": int,
"dry_run": bool, "dry_run": bool,
"reload_timeout_s": float, "reload_timeout_s": float,
# 首次咬合之后的空回:松爪开度 + 微抬量(见 pipeline_node._run_a7_seat)
"unwind_open_permille": int,
"unwind_lift_mm": float,
} }
#: 每个键的单位。``""`` 表示无量纲(须同时登记在 :data:`_UNITLESS`)。 #: 每个键的单位。``""`` 表示无量纲(须同时登记在 :data:`_UNITLESS`)。
@@ -168,6 +172,8 @@ _UNITS: Dict[str, str] = {
"holes_total": "", "holes_total": "",
"dry_run": "", "dry_run": "",
"reload_timeout_s": "s", "reload_timeout_s": "s",
"unwind_open_permille": "permille",
"unwind_lift_mm": "mm",
} }
#: 键名后缀与单位必须一致(防"填毫米当米用")。``contact_speed_m_s`` 这类 #: 键名后缀与单位必须一致(防"填毫米当米用")。``contact_speed_m_s`` 这类
@@ -273,6 +279,10 @@ DEFAULTS: Dict[str, Any] = {
# E1(掉件)重放:等操作员放好并确认 /gauge/reload_ok 的最长时间。 # E1(掉件)重放:等操作员放好并确认 /gauge/reload_ok 的最长时间。
# 超时 = 中止(臂在 home,人不在旁边时"继续等"比"停"更危险)。 # 超时 = 中止(臂在 home,人不在旁边时"继续等"比"停"更危险)。
"reload_timeout_s": 180.0, "reload_timeout_s": 180.0,
# 首轮咬合之后的空回:只张开到这个开度(约 8mm),微抬这么多 Z 再转回去。
# 实际下发在 pipeline_node._run_a7_seat(0–5mm、1–1000‰ 由那边也会再挡一次)。
"unwind_open_permille": 400,
"unwind_lift_mm": 3.0,
} }
@@ -321,6 +331,8 @@ class GaugeConfig:
holes_total: int holes_total: int
dry_run: bool dry_run: bool
reload_timeout_s: float reload_timeout_s: float
unwind_open_permille: int
unwind_lift_mm: float
# -- 派生量(不占 YAML 键,免得两处各写一份,规则 M2)------------------- # -- 派生量(不占 YAML 键,免得两处各写一份,规则 M2)-------------------
@property @property
@@ -378,6 +390,8 @@ class GaugeConfig:
f"{self.total_screw_deg:.0f}°;停止判据「切向 >+{self.screw_shear_delta:.0f} 且 " f"{self.total_screw_deg:.0f}°;停止判据「切向 >+{self.screw_shear_delta:.0f} 且 "
f"法向 >+{self.screw_normal_delta:.0f}」", f"法向 >+{self.screw_normal_delta:.0f}」",
f" S09 拧出 {self.unscrew_cycles} 轮(比拧入多 {self.unscrew_extra_cycles} 轮)", f" S09 拧出 {self.unscrew_cycles} 轮(比拧入多 {self.unscrew_extra_cycles} 轮)",
f" 首轮咬合后空回:松爪到 {self.unwind_open_permille}‰、微抬 Z "
f"{self.unwind_lift_mm:.1f}mm,转回后按实测降回",
] ]
@@ -511,6 +525,16 @@ def _sanity(cfg: GaugeConfig) -> None:
) )
if cfg.snap_tol_px <= 0.0: if cfg.snap_tol_px <= 0.0:
raise GaugeConfigError(f"gauge_assembly.snap_tol_px 必须为正,收到 {cfg.snap_tol_px}") raise GaugeConfigError(f"gauge_assembly.snap_tol_px 必须为正,收到 {cfg.snap_tol_px}")
if not 0.0 <= cfg.unwind_lift_mm <= 5.0:
raise GaugeConfigError(
f"gauge_assembly.unwind_lift_mm 只接受 0–5mm 的微抬,"
f"收到 {cfg.unwind_lift_mm}。空回只需要让规离开孔口,抬多了会撞上方"
)
if not 1 <= cfg.unwind_open_permille <= 1000:
raise GaugeConfigError(
f"gauge_assembly.unwind_open_permille 只接受 1–1000,"
f"收到 {cfg.unwind_open_permille}(0=合,1000=全开)"
)
if cfg.contact_relief_mm < 0.0: if cfg.contact_relief_mm < 0.0:
raise GaugeConfigError( raise GaugeConfigError(
f"gauge_assembly.contact_relief_mm 不能为负(0 = 接触到之后不卸载)," f"gauge_assembly.contact_relief_mm 不能为负(0 = 接触到之后不卸载),"
@@ -45,6 +45,13 @@ CONTRACT_CLOSE = "/gripper/close"
CONTRACT_STATUS_TOPIC = "/gripper/status" CONTRACT_STATUS_TOPIC = "/gripper/status"
LEGACY_STATUS_TOPIC = "/gripper/states" LEGACY_STATUS_TOPIC = "/gripper/states"
#: ``/gripper/states`` 第 6 个元素:1=这一帧刚从总线读回来;0=总线无应答,
#: 前 5 个是上一次读到的值。老节点只发 5 个元素 —— 那时它只在读成功才发,
#: 所以 5 元素帧按有效帧处理。
#: 为什么要这一位:2026-10-20 读 0x0201 一连 264 次无应答,节点干脆不发状态,
#: 流水线只看到「没有 /gripper/states」,把「总线读不到」显示成「夹爪没到位」。
STATES_ONLINE_INDEX = 5
#: DH 状态字(与 ``arm_msgs/GripperStatus.msg`` 里的注释一致)。 #: DH 状态字(与 ``arm_msgs/GripperStatus.msg`` 里的注释一致)。
GRIP_STATE_MOVING = 0 GRIP_STATE_MOVING = 0
GRIP_STATE_REACHED = 1 GRIP_STATE_REACHED = 1
@@ -199,14 +206,30 @@ def is_dropped_from_state(grip_state: int, was_gripped: bool) -> bool:
return was_gripped and grip_state == GRIP_STATE_DROPPED return was_gripped and grip_state == GRIP_STATE_DROPPED
def legacy_states_to_fields(data: Sequence[float]) -> Dict[str, float]: def states_frame_online(data: Sequence[float]) -> bool:
"""``/gripper/states`` 的 5 元素数组 → 具名字段。 """这一帧是不是刚从总线读回来的。
超出长度或为空时返回空字典(而不是抛异常):这是**状态话题**, 5 元素帧(老节点)视为有效:老节点只在读成功时发。6 元素帧看最后一位。
收到畸形数据时应该让上层走"不知道"分支,而不是让整个节点崩掉。 """
if len(data) <= STATES_ONLINE_INDEX:
return True
try:
return float(data[STATES_ONLINE_INDEX]) >= 0.5
except (TypeError, ValueError):
return False
def legacy_states_to_fields(data: Sequence[float]) -> Dict[str, float]:
"""``/gripper/states`` 的 6 元素数组 → 具名字段。
超出长度、为空、或这一帧是离线帧时返回空字典(而不是抛异常):这是
**状态话题**,收到畸形数据时应该让上层走"不知道"分支,而不是让整个
节点崩掉。离线帧里的前 5 个是上一次的值,不能当现在的状态用。
""" """
if not data or len(data) < 5: if not data or len(data) < 5:
return {} return {}
if not states_frame_online(data):
return {}
return { return {
"initialized": float(data[0]), "initialized": float(data[0]),
"grip_state": float(data[1]), "grip_state": float(data[1]),
@@ -392,8 +392,15 @@ def _parse_states_echo(text: str) -> dict | None:
init, grip, pos, _target, force = (float(v) for v in raw[:5]) init, grip, pos, _target, force = (float(v) for v in raw[:5])
except (TypeError, ValueError): except (TypeError, ValueError):
continue continue
# 第 6 位是"这一帧刚从总线读回来吗"。老 5 元素帧只在读成功时发,按在线算。
online = True
if len(raw) > 5:
try:
online = float(raw[5]) >= 0.5
except (TypeError, ValueError):
online = False
return { return {
"connected": True, "connected": online,
"initialized": init >= 0.5, "initialized": init >= 0.5,
"permille": pos, "permille": pos,
"rotation_deg": 0.0, "rotation_deg": 0.0,
@@ -401,9 +408,9 @@ def _parse_states_echo(text: str) -> dict | None:
"speed": None, "speed": None,
"grip_state": int(grip), "grip_state": int(grip),
"rotation_state": None, "rotation_state": None,
"moving": int(grip) == 0, "moving": online and int(grip) == 0,
"gripped": int(grip) == 2, "gripped": online and int(grip) == 2,
"error_msg": "", "error_msg": "" if online else "夹爪总线无应答:读不到状态,这里是上一次的值",
} }
return None return None
@@ -26,6 +26,7 @@ from rclpy.qos import DurabilityPolicy, HistoryPolicy, QoSProfile, ReliabilityPo
from std_msgs.msg import Bool, Float32MultiArray, String from std_msgs.msg import Bool, Float32MultiArray, String
from trajectory_msgs.msg import JointTrajectory from trajectory_msgs.msg import JointTrajectory
from arm_application.gauge_gripper import states_frame_online
from arm_application.gauge_kin import ( from arm_application.gauge_kin import (
a7_limit_target, a7_limit_target,
match_limits, match_limits,
@@ -89,6 +90,40 @@ def close_ready(grip: int, pos: float, tol: float = CLOSE_POS_TOL) -> bool:
return g == 1 and float(pos) <= float(tol) return g == 1 and float(pos) <= float(tol)
def gripper_wait_timeout_reason(
action: str,
timeout_s: float,
last: Optional[Tuple[int, float]],
*,
saw_any: bool,
saw_offline: bool,
) -> str:
"""等夹爪状态超时后,到底该报哪一句。
2026-10-20 三连实测都卡在这里:读 ``0x0201`` 一连 264 次无应答,节点
不再发状态,流水线把「总线读不到」显示成「夹爪没到位」,而臂停在原地
没做微抬。三种情况必须分开说,否则查的方向完全相反:
* ``last`` 有值 → 真收到过有效帧,就是没到位;
* 只有离线帧 → 总线读不回来,不是没张开;
* 一帧都没有 → 节点没在发(崩了 / 没起 / 话题名不对)。
"""
if last is not None:
return (
f"夹爪 {action} 后 {timeout_s:.0f}s 未到位"
f"(grip={last[0]} pos={last[1]:.0f}),A7 不再转"
)
if saw_offline:
return (
f"夹爪 {action} 后 {timeout_s:.0f}s 读不到状态(/gripper/states "
"只有离线帧,Modbus 无应答),不是夹爪没张开。A7 不再转"
)
return (
f"夹爪 {action} 后 {timeout_s:.0f}s 一帧 /gripper/states 都没收到"
f"(节点没在发,saw_any={saw_any}),A7 不再转"
)
def init_z_refusal( def init_z_refusal(
z_m: float, z_m: float,
tool_len_m: float, tool_len_m: float,
@@ -446,7 +481,11 @@ class AssemblyPipeline(Stage3Search):
return self._wait_gripper(action, value) return self._wait_gripper(action, value)
def _wait_gripper(self, action: str, value: str = "", timeout_s: float = 8.0) -> bool: def _wait_gripper(self, action: str, value: str = "", timeout_s: float = 8.0) -> bool:
"""``/gripper/states``:``pos`` 要停在目标附近;合爪见 :func:`close_ready`。""" """``/gripper/states``:``pos`` 要停在目标附近;合爪见 :func:`close_ready`。
离线帧(第 6 位为 0,前 5 个是上一次的值)不算数:读不到总线时
必须报"读不到状态",不能报成"夹爪没到位"。
"""
box: dict = {"data": None} box: dict = {"data": None}
def _on_state(msg: Float32MultiArray) -> None: def _on_state(msg: Float32MultiArray) -> None:
@@ -455,12 +494,18 @@ class AssemblyPipeline(Stage3Search):
sub = self.create_subscription(Float32MultiArray, "/gripper/states", _on_state, 10) sub = self.create_subscription(Float32MultiArray, "/gripper/states", _on_state, 10)
t0 = time.monotonic() t0 = time.monotonic()
last = None last = None
saw_any = False
saw_offline = False
try: try:
while time.monotonic() - t0 < timeout_s: while time.monotonic() - t0 < timeout_s:
self._spin(0.05) self._spin(0.05)
data = box["data"] data = box["data"]
if not data or len(data) < 3: if not data or len(data) < 3:
continue continue
saw_any = True
if not states_frame_online(data):
saw_offline = True
continue
grip = int(data[1]) grip = int(data[1])
pos = float(data[2]) pos = float(data[2])
last = (grip, pos) last = (grip, pos)
@@ -479,9 +524,10 @@ class AssemblyPipeline(Stage3Search):
return True return True
finally: finally:
self.destroy_subscription(sub) self.destroy_subscription(sub)
shown = f"grip={last[0]} pos={last[1]:.0f}" if last else "没有 /gripper/states"
self.get_logger().error( self.get_logger().error(
f"夹爪 {action} 后 {timeout_s:.0f}s 未到位({shown}),A7 不再转" gripper_wait_timeout_reason(
action, timeout_s, last, saw_any=saw_any, saw_offline=saw_offline
)
) )
return False return False
+149 -1
View File
@@ -10,13 +10,83 @@ import yaml
def ros2_root() -> Path: def ros2_root() -> Path:
"""仓库根目录。先看环境变量,再从本文件向上找 ``config/site.yaml``。
不能默认 ``~/ros2``:别人把仓库克隆到别的目录时,写死的家目录路径是空的,
启动会报文件不存在。
"""
for key in ("SITE_ROS2_ROOT", "ROS2_WS"): for key in ("SITE_ROS2_ROOT", "ROS2_WS"):
v = os.environ.get(key, "").strip() v = os.environ.get(key, "").strip()
if v: if v:
return Path(v).expanduser() return Path(v).expanduser()
here = Path(__file__).resolve()
for parent in here.parents:
if (parent / "config" / "site.yaml").is_file() and (parent / "scripts" / "env.sh").is_file():
return parent
return Path.home() / "ros2" return Path.home() / "ros2"
_PATH_KEYS = frozenset({
"named_poses_file",
"handeye_file",
"handeye_waypoints",
"handeye_workdir",
"handeye_calibrate",
"yolo_weights",
"hole_weights",
"weights",
"workpiece_weights",
"config_file",
"append_poses_file",
})
def resolve_repo_path(value: str, root: Path | None = None) -> str:
"""仓库内的相对路径展开成绝对路径。已经是绝对路径的原样保留。"""
text = str(value or "").strip()
if not text:
return ""
path = Path(text).expanduser()
if path.is_absolute():
return str(path)
base = root or ros2_root()
return str((base / path).resolve())
def _is_path_key(key: str) -> bool:
return (
key in _PATH_KEYS
or key.startswith("handeye_file")
or key.startswith("handeye_waypoints")
or key.endswith("_weights")
or key.endswith("_file")
or key.endswith("_workdir")
)
def absolutize_site_paths(data: Any, root: Path | None = None) -> Any:
"""把 site 里的仓库相对路径展开。只动路径字段,不动别的字符串。"""
base = root or ros2_root()
if isinstance(data, dict):
out: dict[Any, Any] = {}
for key, val in data.items():
name = str(key)
if isinstance(val, str) and _is_path_key(name):
out[key] = resolve_repo_path(val, base)
elif (
name == "config"
and isinstance(val, str)
and (val.endswith((".yaml", ".yml", ".json")) or "/" in val)
):
out[key] = resolve_repo_path(val, base)
else:
out[key] = absolutize_site_paths(val, base)
return out
if isinstance(data, list):
return [absolutize_site_paths(item, base) for item in data]
return data
def site_path() -> Path: def site_path() -> Path:
env = os.environ.get("SITE_CONFIG", "").strip() env = os.environ.get("SITE_CONFIG", "").strip()
if env: if env:
@@ -24,12 +94,90 @@ def site_path() -> Path:
return ros2_root() / "config" / "site.yaml" return ros2_root() / "config" / "site.yaml"
def arm_short_id(sn: str) -> str:
"""序列号末两位就是臂号(``Rizon4-063989`` → ``89``)。
和 ``scripts/lib/export_site_env.arm_short_id`` 同一条规则。换一台臂
只改 ``site.yaml`` 的 SN,不要在界面里再写一张本机对照表。
"""
tail = str(sn or "").strip().rsplit("-", 1)[-1]
return tail[-2:] if tail.isdigit() and len(tail) >= 2 else ""
def listed_arms(data: dict[str, Any] | None) -> list[dict[str, Any]]:
"""``robots[]`` 里的真机臂。``sn: sim`` 不进控制。
旋转爪只看夹爪型号里有没有 ``RGI``。右臂也可以是平行爪,不能靠臂号猜。
"""
robots = ((data or {}).get("robot") or {}).get("robots") or []
out: list[dict[str, Any]] = []
seen: set[str] = set()
for robot in robots:
if not isinstance(robot, dict):
continue
sn = str(robot.get("sn") or "").strip()
if not sn or sn.lower() == "sim":
continue
arm_no = arm_short_id(sn)
if not arm_no:
continue
if arm_no in seen:
raise ValueError(f"两台臂的序列号末两位都是 {arm_no},--arm 分不清")
seen.add(arm_no)
side = str(robot.get("arm") or "").strip().lower()
grip = robot.get("gripper") if isinstance(robot.get("gripper"), dict) else {}
model = str(grip.get("model") or "")
shared = (data or {}).get("gripper") if isinstance((data or {}).get("gripper"), dict) else {}
stroke = grip.get("stroke_mm")
if stroke is None:
stroke = shared.get("stroke_mm") or 20.0
out.append({
"id": arm_no,
"label": str(robot.get("label") or "").strip() or f"臂{arm_no}",
"sn": sn,
"side": side if side in ("left", "right") else "",
"tactile": bool(robot.get("tactile")),
"camera": str((robot.get("camera") or {}).get("name") or "") if isinstance(robot.get("camera"), dict) else "",
"gripper": model,
"rotary": "RGI" in model.upper(),
"iface": str(robot.get("iface") or ""),
"pc_ip": str(robot.get("pc_ip") or ""),
"port": str(grip.get("port") or ""),
"stroke_mm": float(stroke),
})
return out
def usb_expectations(data: dict[str, Any] | None) -> dict[str, int]:
"""设备页上「应有几台」跟 ``site.yaml`` 走,不写死本机的 2+1+2。"""
cameras = (data or {}).get("cameras") or {}
d405 = 0
d435 = 0
if isinstance(cameras, dict):
for key, val in cameras.items():
model = str((val or {}).get("model") or "") if isinstance(val, dict) else ""
token = f"{key} {model}".lower()
if "d405" in token:
d405 += 1
elif "d435" in token:
d435 += 1
tactile = (data or {}).get("tactile") or {}
orisys = 0
if isinstance(tactile, dict):
orisys = sum(1 for key in ("left_video", "right_video") if tactile.get(key) is not None)
return {"d405": d405, "d435": d435, "orisys": orisys}
def load_site_dict(path: str | Path | None = None) -> dict[str, Any]: def load_site_dict(path: str | Path | None = None) -> dict[str, Any]:
p = Path(path).expanduser() if path else site_path() p = Path(path).expanduser() if path else site_path()
if not p.is_file(): if not p.is_file():
return {} return {}
data = yaml.safe_load(p.read_text(encoding="utf-8")) or {} data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
return data if isinstance(data, dict) else {} if not isinstance(data, dict):
return {}
resolved = p.resolve()
root = resolved.parent.parent if resolved.parent.name == "config" else ros2_root()
return absolutize_site_paths(data, root)
def deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]: def deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
@@ -16,11 +16,13 @@ from pathlib import Path
from arm_application.dh_pgea import ( from arm_application.dh_pgea import (
ctrl_values, ctrl_values,
open_failure_text, open_failure_text,
REG_INIT_STATE,
REG_POSITION, REG_POSITION,
S_GRIP_ARRIVED, S_GRIP_ARRIVED,
S_GRIP_CAUGHT, S_GRIP_CAUGHT,
S_INIT_DOING, S_INIT_DOING,
S_INIT_FINISHED, S_INIT_FINISHED,
DhPgeaBusError,
DhPgeaGripper, DhPgeaGripper,
grip_motion_settled, grip_motion_settled,
latched_position_break, latched_position_break,
@@ -110,6 +112,31 @@ def test_node_owned_port_uses_ctrl_topic_not_a_second_open() -> None:
raise AssertionError("超范围位置应当失败") raise AssertionError("超范围位置应当失败")
class _DeadSerial:
"""模拟 USB 掉线:读写抛 termios EIO,而不是"没有应答"。"""
is_open = True
def reset_input_buffer(self) -> None:
raise OSError(5, "Input/output error")
def test_serial_eio_is_a_bus_error_not_a_node_crash():
"""2026-10-20:EIO 从 timer 回调冒出去,整个夹爪节点带 traceback 退出。
没应答(DhPgeaError)可以继续重试;串口坏了(DhPgeaBusError)必须先关了
重开。两者混在一起时,节点只会崩,流水线只看到"没有 /gripper/states"。
"""
grip = DhPgeaGripper(port="/dev/null")
grip._ser = _DeadSerial() # type: ignore[assignment]
try:
grip.read_register(REG_INIT_STATE, retries=1)
except DhPgeaBusError as exc:
assert "串口" in str(exc)
else:
raise AssertionError("EIO 必须变成 DhPgeaBusError")
def test_exclusive_lock_is_not_called_a_power_failure(): def test_exclusive_lock_is_not_called_a_power_failure():
exc = OSError(11, "Could not exclusively lock port /dev/ttyUSB0: Resource temporarily unavailable") exc = OSError(11, "Could not exclusively lock port /dev/ttyUSB0: Resource temporarily unavailable")
text = open_failure_text("/dev/ttyUSB0", exc) text = open_failure_text("/dev/ttyUSB0", exc)
@@ -27,6 +27,7 @@ from arm_application.gauge_gripper import (
is_gripped_from_state, is_gripped_from_state,
legacy_states_to_fields, legacy_states_to_fields,
pick_backend, pick_backend,
states_frame_online,
) )
@@ -166,6 +167,19 @@ def test_legacy_states_parsing_is_field_named_and_defensive():
assert legacy_states_to_fields([]) == {} assert legacy_states_to_fields([]) == {}
assert legacy_states_to_fields([1.0, 2.0]) == {} assert legacy_states_to_fields([1.0, 2.0]) == {}
# 离线帧(第 6 位 0)里的前 5 个是上一次的值,不能当现在的状态用。
# 2026-10-20 读 0x0201 连 264 次无应答,这就是流水线把"读不到"说成
# "夹爪没到位"的入口,必须有负向测试。
assert legacy_states_to_fields([1.0, 2.0, 500.0, 0.0, 50.0, 0.0]) == {}
def test_states_online_marks_only_fresh_frames():
assert states_frame_online([1.0, 2.0, 500.0, 0.0, 50.0]) is True
assert states_frame_online([1.0, 2.0, 500.0, 0.0, 50.0, 1.0]) is True
assert states_frame_online([1.0, 2.0, 500.0, 0.0, 50.0, 0.0]) is False
# 第 6 位是垃圾 → 当离线,不许当有效帧
assert states_frame_online([1.0, 2.0, 500.0, 0.0, 50.0, "x"]) is False
def test_describe_mentions_the_unit_trap_for_legacy(): def test_describe_mentions_the_unit_trap_for_legacy():
"""legacy 的"开口"是千分比不是毫米,日志里必须写明。 """legacy 的"开口"是千分比不是毫米,日志里必须写明。
@@ -104,6 +104,21 @@ def test_parallel_topic_array() -> None:
assert parsed["permille"] == 250.0 assert parsed["permille"] == 250.0
assert parsed["gripped"] is True assert parsed["gripped"] is True
assert parsed["initialized"] is True assert parsed["initialized"] is True
# 老 5 元素帧:节点只在读成功时发,按在线算
assert parsed["connected"] is True
def test_offline_topic_frame_is_not_shown_as_live_state() -> None:
"""第 6 位是 0 时前 5 个是上一次的值,页面上不能显示成"夹住"。"""
text = "data:\n- 1.0\n- 2.0\n- 250.0\n- 250.0\n- 40.0\n- 0.0\n---\n"
parsed = _parse_states_echo(text)
assert parsed is not None
assert parsed["connected"] is False
assert "总线无应答" in parsed["error_msg"]
row = present("89", rotary=False, stroke_mm=20.0, reading=parsed)
assert row["connected"] is False
assert row["gripped"] is False
assert "总线无应答" in row["summary"]
def test_rotary_topic_yaml() -> None: def test_rotary_topic_yaml() -> None:
@@ -19,6 +19,7 @@ import pytest
from arm_application.overhead_oneshot import D435_MOVEL_GAVE_UP from arm_application.overhead_oneshot import D435_MOVEL_GAVE_UP
from arm_application.pipeline_node import ( from arm_application.pipeline_node import (
close_ready, close_ready,
gripper_wait_timeout_reason,
hold_at_insert_z, hold_at_insert_z,
init_z_refusal, init_z_refusal,
stage1_log_fatal, stage1_log_fatal,
@@ -27,6 +28,26 @@ from arm_application.pipeline_node import (
) )
def test_gripper_timeout_does_not_call_a_dead_bus_not_in_position():
"""2026-10-20:读 0x0201 无应答时只发离线帧,不能报成"夹爪没到位"。
三种情况说法必须不同,否则会去查夹爪开合,而真正的问题是总线读不回来。
"""
no_frame = gripper_wait_timeout_reason(
"pos", 8.0, None, saw_any=False, saw_offline=False
)
assert "都没收到" in no_frame and "节点没在发" in no_frame
offline = gripper_wait_timeout_reason(
"pos", 8.0, None, saw_any=True, saw_offline=True
)
assert "读不到状态" in offline and "不是夹爪没张开" in offline
assert "未到位" not in offline
late = gripper_wait_timeout_reason(
"pos", 8.0, (0, 120.0), saw_any=True, saw_offline=False
)
assert "未到位" in late and "grip=0 pos=120" in late
def test_close_at_zero_continues_even_when_firmware_says_arrived(): def test_close_at_zero_continues_even_when_firmware_says_arrived():
"""2026-10-19:合到 0 时规还在,固件写 grip=1。只认 grip=2 就永远不顺时针。 """2026-10-19:合到 0 时规还在,固件写 grip=1。只认 grip=2 就永远不顺时针。