修复平行夹爪串口通讯问题
今天三次实测都在"首轮咬合结束→张开到 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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 就永远不顺时针。
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user