From 032804a59504ec870d8734f7d206174e36377988 Mon Sep 17 00:00:00 2001 From: cao-xiao-hao <127832643+cao-xiao-hao@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:38:44 +0800 Subject: [PATCH] fix: report a failed send with its cause and its type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GripperActions._emit()` folded every failed `send_mit_frame()` into `CommandError("MIT 帧下发失败(未连接或未使能)")`. On the bench the CAN interface went ENETDOWN mid-move, so that message named a cause nobody had checked: `is_connected` and `is_enabled` were both True at the instant it was raised, and the actual `OSError(100, 'Network is down')` existed only as a `log.warning` attached to no exception. The error table in README.md files a mid-session write failure under `CommError` and not connected / not enabled under `NotInitializedError`; neither is a `CommandError`. The reason now travels. `LiteGripCAN.control_mit()` and `control_mit_stream()` keep it in `last_send_error`, cleared on success. `LiteGrip.send_mit_frame()` passes it up and supplies its own `NotInitializedError` naming the step that was skipped. `_emit()` raises the type the table names, and when nothing recorded a reason — a coarse `send_mit_frame` stub, say — it says "原因未记录" rather than guessing. The trajectory replay abort and the teleop loop's first-failure warning name the same reason; neither changes what it does about it. Note for callers: catching `CommandError` around a motion call no longer catches a failed send. A caller that must keep catching one should catch `LiteGripError`, the base both types share. Closes #65 --- README.md | 7 + readme_zn.md | 6 + src/litegrip/actions.py | 38 +++- src/litegrip/gripper.py | 39 +++- src/litegrip/protocols/can_bus.py | 32 ++- src/litegrip/teleop.py | 14 +- src/litegrip/trajectory.py | 9 +- tests/fake_can.py | 23 ++- tests/test_send_failure_reports_its_cause.py | 207 +++++++++++++++++++ tests/test_teleop.py | 19 ++ 10 files changed, 375 insertions(+), 19 deletions(-) create mode 100644 tests/test_send_failure_reports_its_cause.py diff --git a/README.md b/README.md index 6e0fe6c..2799d11 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ LiteGrip(channel="can0", can_id=0x08, mst_id=None, canfd_mode=None, | `actions` | `GripperActions` — the engine the high-level methods forward to | | `mount` | `"normal"`, `"reverse"`, or `None` until calibrated (derived from the limits) | | `is_connected`, `is_enabled` | Session state | +| `last_send_error` | Why the last `send_mit_frame` did not send, or `None`. The exception the send reported — an `OSError` from the transport, or the `NotInitializedError` naming the step that was skipped. | | `session` | The CAN session, for advanced use | ### Connection and state @@ -489,6 +490,12 @@ text. The SDK logs through the standard `logging` module — the calibration it direction it derived, every guard that fired; attach a handler to the `litegrip` logger to see them, and note that warnings reach stderr even with no handler configured. +A frame that fails to go out follows the table rather than one fixed message. The send reports +`False` and keeps the reason in `gripper.last_send_error`: a transport failure raises `CommError` +carrying that reason, and a gripper that is not connected or not enabled raises +`NotInitializedError` naming the missing step. When nothing recorded a reason — a coarse +`send_mit_frame` stub, say — the message says so instead of guessing. + ### Advanced | Interface | What it is for | diff --git a/readme_zn.md b/readme_zn.md index 8f64ee8..1841075 100644 --- a/readme_zn.md +++ b/readme_zn.md @@ -98,6 +98,7 @@ LiteGrip(channel="can0", can_id=0x08, mst_id=None, canfd_mode=None, | `actions` | `GripperActions` —— 高层方法转发到的引擎 | | `mount` | `"normal"`、`"reverse"`,未标定时是 `None`(由限位数值推出) | | `is_connected`、`is_enabled` | 会话状态 | +| `last_send_error` | 上一次 `send_mit_frame` 为什么没发出去,没失败就是 `None`。下层报的那条异常 —— 传输层的 `OSError`,或者指出缺哪一步的 `NotInitializedError`。 | | `session` | CAN 会话,进阶用 | ### 连接与状态 @@ -450,6 +451,11 @@ python3 examples/trajectory.py --play pick --repeat 3 标准 `logging` 模块记录日志 —— 加载了哪份标定、推出了什么方向、哪条护栏触发了;给 `litegrip` logger 挂个 handler 就能看到,注意即使不挂 handler,告警也会到 stderr。 +一帧发不出去时,报的异常按上面那张表来,而不是一句写死的话。下发本身还是回 `False`,原因记在 +`gripper.last_send_error` 里:传输层失败抛 `CommError`,并把原因原样带在消息里;没连接或没使能 +抛 `NotInitializedError`,指出缺的是哪一步。下层没记原因时(比如一个只回 `False` 的替身),消息 +就说「原因未记录」,不替它猜。 + ### 进阶接口 | 接口 | 用来做什么 | diff --git a/src/litegrip/actions.py b/src/litegrip/actions.py index f03ffd5..378dccd 100644 --- a/src/litegrip/actions.py +++ b/src/litegrip/actions.py @@ -69,7 +69,12 @@ from typing import TYPE_CHECKING, Callable, Optional, Tuple from .constants import UnitConversion -from .exceptions import CommandError, LiteGripError +from .exceptions import ( + CommandError, + CommError, + LiteGripError, + NotInitializedError, +) from .models import CalibrationData, GripperConfig, GripperState if TYPE_CHECKING: # 避免运行时循环 import @@ -606,6 +611,27 @@ def _toward(value: float, target: float, step: float) -> float: return max(value - step, target) +def _raise_send_failure(g: "LiteGrip") -> None: + """抛出一个带原因的异常,说明为什么 ``send_mit_frame`` 回了 ``False``。 + + 原因在 :attr:`LiteGrip.last_send_error` 里,三种情况分开报: + + - 没连上 / 没使能 —— 下层记的就是 ``NotInitializedError``,原样抛回去: + 它的消息说的是该补哪一步,重新包一层只会把那个结论弄丢。 + - 总线写失败 —— 下层记的是它自己那条异常(``OSError`` 等)。这一层看不到 + 总线,只能照搬,按 README 的错误表归到 ``CommError``,并把原异常挂在 + ``__cause__`` 上。 + - 什么都没记 —— 替身只回 ``False``,或者失败在别处。照实说「原因未记录」, + 不替下层猜一个「未连接或未使能」:猜错了,诊断就去错了地方。 + """ + error = getattr(g, "last_send_error", None) + if isinstance(error, NotInitializedError): + raise error + if error is None: + raise CommError("MIT 帧下发失败(原因未记录)") + raise CommError(f"MIT 帧下发失败:{error!r}") from error + + class GripperActions: """夹爪的七个动作:``open`` / ``close`` / ``grasp`` / ``ramp_to`` / ``zero`` / ``enable`` / ``disable``。 @@ -942,6 +968,14 @@ def _emit(self, q: float, dq: float = 0.0, tau: float = 0.0, 真正下发的那个 ``q``(夹过之后的值)—— 调用方拿它填 ``MoveResult.final_cmd_rad`` / ``MoveProgress.cmd_rad``,报的是发出去 的指令,不是算出来又没发的。 + + Raises: + NotInitializedError: 夹爪没连上,或者没使能 —— 原样抛出 + :attr:`LiteGrip.last_send_error` 里记的那一条,它说的是该补哪 + 一步(``connect()`` / ``enable()``)。 + CommError: 帧没发出去,而下层给的原因是传输层失败(如 + ``OSError``),或者什么都没给。消息里带原样的原因 —— 这一层 + 看不到总线,替下层猜一个「未连接或未使能」就是谎报。 """ cfg = self.config g = self._g @@ -954,7 +988,7 @@ def _emit(self, q: float, dq: float = 0.0, tau: float = 0.0, tau=tau, ) if not sent: - raise CommandError("MIT 帧下发失败(未连接或未使能)") + _raise_send_failure(g) cfg.sleep_fn(cfg.frame_interval) return q diff --git a/src/litegrip/gripper.py b/src/litegrip/gripper.py index 922d1f6..403d708 100644 --- a/src/litegrip/gripper.py +++ b/src/litegrip/gripper.py @@ -319,6 +319,7 @@ def __init__( self._can: Optional[LiteGripCAN] = None self._enabled = False self._connected = False + self._last_send_error: Optional[Exception] = None self._status_flags = GripperStatus.NONE self._actions = GripperActions(self, motion_config) @@ -604,13 +605,34 @@ def send_mit_frame( tau: Feed-forward torque in Nm. Returns: - True if the frame was sent. + True if the frame was sent. A ``False`` return leaves the reason in + :attr:`last_send_error`. """ - if self._can is None or not self._enabled: + if self._can is None: + self._last_send_error = self._not_connected_error() + return False + if not self._enabled: + self._last_send_error = self._not_enabled_error() return False with self._io_lock: - return self._can.control_mit( + sent = self._can.control_mit( q_target=q, kp=kp, kd=kd, dq_target=dq, tau_feedforward=tau) + # 鸭子类型的替身可能没有这一项;真 CAN 层永远有。取不到就是 + # 「原因没记录」,上层据此照实报,而不是编一个。 + self._last_send_error = ( + None if sent else getattr(self._can, "last_send_error", None)) + return sent + + @property + def last_send_error(self) -> Optional[Exception]: + """Why the last :meth:`send_mit_frame` did not send, or ``None``. + + The exception the send itself reported — a ``CommError``-family cause + from the transport, or the ``NotInitializedError`` naming the step that + was skipped. The engine reads it to raise the right type instead of one + fixed message that guesses. + """ + return self._last_send_error def poll(self, timeout_s: float = 0.0) -> bool: """Poll for one CAN frame and update cached motor state. @@ -2552,13 +2574,20 @@ def trajectory_status(self) -> dict: # Internal # ═══════════════════════════════════════════════════════════════════ + def _not_connected_error(self) -> NotInitializedError: + return NotInitializedError( + "未连接 — 请先调用 connect() 或使用 with 上下文") + + def _not_enabled_error(self) -> NotInitializedError: + return NotInitializedError("未使能 — 请先调用 enable()") + def _check_connected(self) -> None: if not self._connected: - raise NotInitializedError("未连接 — 请先调用 connect() 或使用 with 上下文") + raise self._not_connected_error() def _check_enabled(self) -> None: if not self._enabled: - raise NotInitializedError("未使能 — 请先调用 enable()") + raise self._not_enabled_error() def _claim_session(self, owner: str, error: type) -> None: """Take the single long-running session slot, or raise *error*. diff --git a/src/litegrip/protocols/can_bus.py b/src/litegrip/protocols/can_bus.py index 11722d8..1a6ec53 100644 --- a/src/litegrip/protocols/can_bus.py +++ b/src/litegrip/protocols/can_bus.py @@ -54,6 +54,8 @@ def __init__( self._motor: Optional[MotorState] = None self._connected = False self._initialized = False + # 上一次 MIT 下发为什么没出去 —— 见 last_send_error。 + self._last_send_error: Optional[Exception] = None # ═══════════════════════════════════════════════════════════════════ # Connection @@ -366,26 +368,30 @@ def control_mit(self, q_target: float, kp: float, kd: float, Returns: True if the frame was sent, False when there is no connection or the - send failed. A failed send leaves a warning naming the reason — - ``False`` alone cannot tell "not enabled" from "the buffer was - full for 100 ms", and those need different fixes. + send failed. ``False`` alone cannot tell "not enabled" from + "the buffer was full for 100 ms", and those need different + fixes, so the reason is kept for :attr:`last_send_error` and + logged rather than raised. Raises: CommandError: a field is NaN/infinite, or the torque exceeds the frame's range. Those are caller bugs rather than transport - conditions, so they keep a diagnosable reason instead of being - folded into the ``False`` return. + conditions, so they are raised instead of being folded into the + ``False`` return. """ self._check_motion_command(q_target, kp, kd, dq_target, tau_feedforward) if self._controller is None or self._motor is None: + self._last_send_error = NotInitializedError("未连接或未注册") return False try: self._controller.control_mit( self._motor, kp=kp, kd=kd, q=q_target, dq=dq_target, tau=tau_feedforward) + self._last_send_error = None return True except Exception as e: + self._last_send_error = e log.warning("MIT 帧下发失败(%s):%r", self._channel, e) return False @@ -449,6 +455,7 @@ def control_mit_stream( if interval_s < 0.0: raise CommandError(f"interval_s 不能为负:{interval_s!r}。") if self._controller is None or self._motor is None: + self._last_send_error = NotInitializedError("未连接或未注册") return False ctrl = self._controller @@ -471,6 +478,9 @@ def control_mit_stream( time.sleep(interval_s) failed = attempts - sent + # 与 control_mit 同一口径:整段没发出去是失败,部分丢帧也是失败, + # 两种情况都把原因留给 last_send_error;全部发出去就清空。 + self._last_send_error = last_error if failed else None if failed: log.warning("%s:%d 帧里 %d 帧没发出去(最后一次:%r)", self._channel, attempts, failed, last_error) @@ -485,6 +495,18 @@ def control_mit_stream( # Status # ═══════════════════════════════════════════════════════════════════ + @property + def last_send_error(self) -> Optional[Exception]: + """Why the last :meth:`control_mit` / :meth:`control_mit_stream` did not + send, or ``None`` if it sent. + + The ``False`` return carries no reason, and the warning that does is + only as visible as the caller's logging setup. This keeps the exception + itself — type and all — so a layer above can report *why*, and can tell + "not registered" from "the write failed" without guessing. + """ + return self._last_send_error + def update_state(self, timeout_s: float = 0.05) -> bool: """Poll until at least one new status frame arrives.""" if self._controller is None or self._motor is None: diff --git a/src/litegrip/teleop.py b/src/litegrip/teleop.py index 7ddc022..df1189e 100644 --- a/src/litegrip/teleop.py +++ b/src/litegrip/teleop.py @@ -1362,13 +1362,21 @@ def _send(self, q: float, kp: float, kd: float, dq: float = 0.0, ``send_mit_frame`` returns ``False`` when the motor is not enabled or the CAN write fails — it does not raise, so ignoring the result means believing we are driving a gripper that is not moving. + + The first failure of a session is logged with the reason the send + recorded (``last_send_error``), not with both possibilities: a slave loop + must not abort on one lost frame, so the log line is the only report the + operator gets, and it should not make them check the 24 V supply when the + interface went down. """ if not self._g.send_mit_frame(q=q, kp=kp, kd=kd, dq=dq): self._send_failed += 1 if self._send_failed == 1 and not quiet: - log.warning("[%s] send_mit_frame returned False — the motor is " - "not enabled or the CAN write failed; the gripper may " - "not be moving at all", self._mode) + error = getattr(self._g, "last_send_error", None) + reason = "no reason recorded" if error is None else repr(error) + log.warning("[%s] send_mit_frame returned False (%s) — the " + "gripper may not be moving at all", + self._mode, reason) def _note_grip_fault(self, state: Any) -> None: """Consume the gripper's own ``error_code`` (§8 rule 10). diff --git a/src/litegrip/trajectory.py b/src/litegrip/trajectory.py index d10f731..73028f6 100644 --- a/src/litegrip/trajectory.py +++ b/src/litegrip/trajectory.py @@ -924,10 +924,17 @@ def _send_rad(self, q: float, dq: float = 0.0) -> None: Shared by the replay loop (``dq = 0``) and the align ramp, which feeds its travel speed forward as ``dq``. A failed send aborts: dropping frames silently would leave the jaws somewhere the status does not show. + + The abort keeps the type it always had — this is a replay that stopped, + not a transport error — but it now carries the send's own reason in the + message instead of asking "not connected or not enabled?" and leaving + the answer in a log the caller may never read. """ if not self._g.send_mit_frame(q=q, kp=self._kp, kd=self._kd, dq=dq): + error = getattr(self._g, "last_send_error", None) + reason = "原因未记录" if error is None else repr(error) raise TrajectoryError( - "MIT 帧下发失败 (未连接或未使能?) —— 回放中止, 不静默丢帧") + f"MIT 帧下发失败({reason})—— 回放中止, 不静默丢帧") from error self._frames += 1 def stop(self, timeout: float = 2.0) -> None: diff --git a/tests/fake_can.py b/tests/fake_can.py index 289a1f4..3f1733b 100644 --- a/tests/fake_can.py +++ b/tests/fake_can.py @@ -216,26 +216,43 @@ def __init__(self, pos: float = 0.0, block_rad: Optional[float] = None, #: 0xFE 是否真的改写偏置(False = 电机忽略了它,比如仍在使能态: #: set_zero() 照报 True,但读数不变)。 self.set_zero_applies = True + #: 下一次下发要报的失败原因 —— 非 None 时 ``control_mit`` 一帧都不发, + #: 直接回 False,并把它记进 ``last_send_error``。真 CAN 层就是这样把 + #: 「没连上」和「总线写失败」分开报的。 + self.send_error: Optional[Exception] = None + #: ``LiteGripCAN.last_send_error`` 的对应物:上一次下发失败的原因。 + self.last_send_error: Optional[Exception] = None # ── 运动 ─────────────────────────────────────────────────────────── def control_mit(self, q_target, kp, kd, dq_target=0.0, tau_feedforward=0.0) -> bool: + if self.send_error is not None: + self.last_send_error = self.send_error + return False self.motor.step(q_target, kp, dq_target, tau_feedforward, DT) self.frames.append(Frame( q=q_target, kp=kp, kd=kd, dq=dq_target, tau_ff=tau_feedforward, pos_after=self.motor.pos, tau_nm=self.motor.tau)) self.holding = tau_feedforward != 0.0 + self.last_send_error = None return True def control_mit_stream(self, q_target, kp, kd, duration_s=0.5, interval_s=0.005, dq_target=0.0, tau_feedforward=0.0) -> bool: - """把 ``control_mit`` 连发一“段平台”,和真 CAN 层同名同形。""" + """把 ``control_mit`` 连发一“段平台”,和真 CAN 层同名同形。 + + ``send_error`` 置上时逐帧失败,于是和真层一样回 False —— 真层在整段 + 一帧都没发出去时抛 ``CommError``,这里只回 False:替身不模拟那条 + 「一帧都没出去」的阈值。 + """ if interval_s <= 0: return True + ok = True for _ in range(max(1, int(duration_s / interval_s))): - self.control_mit(q_target, kp, kd, dq_target, tau_feedforward) - return True + ok = self.control_mit( + q_target, kp, kd, dq_target, tau_feedforward) and ok + return ok # ── 状态 ─────────────────────────────────────────────────────────── def poll(self, timeout_s: float = 0.0) -> bool: diff --git a/tests/test_send_failure_reports_its_cause.py b/tests/test_send_failure_reports_its_cause.py new file mode 100644 index 0000000..313e405 --- /dev/null +++ b/tests/test_send_failure_reports_its_cause.py @@ -0,0 +1,207 @@ +"""一次失败的下发,原因要跟着异常走到调用方,而不是只留在日志里。 + +旧契约下 ``_emit`` 把 ``send_mit_frame`` 的 ``False`` 变成一句写死的话: +``CommandError("MIT 帧下发失败(未连接或未使能)")``。这句话在真机上说过谎 —— 操作 +者看到它时 ``is_connected`` 和 ``is_enabled`` 都是 ``True``,而真正的原因 +(``OSError(100, 'Network is down')``,CAN 接口中途掉了)只作为一条 +``logging.warning`` 存在:没挂 handler 的调用方根本看不见。三种完全不同的失败 +(总线写不动 / 没连 / 没使能)也都报成同一个 ``CommandError``,而 README 的错误表 +里它们分别属于 ``CommError`` 和 ``NotInitializedError``。 + +这个文件钉住新契约,三段分开验: + +- ``LiteGripCAN.control_mit`` 把原因留在 ``last_send_error``(发成功就清空); +- ``LiteGrip.send_mit_frame`` 原样带上去,不连/没使能时自己给出那一条; +- ``GripperActions._emit`` 按错误表抛 ``NotInitializedError`` / ``CommError``, + 并且**下层没给原因时不替它猜一个**。 + +CAN 层用真 ``LiteGripCAN`` + 假 controller(与 ``test_can_stream_silence.py`` +同一套注入方式),上面两层用 ``fake_can.make_gripper``。 +""" + +from __future__ import annotations + +import unittest + +import _sdkpath # noqa: F401 +from fake_can import make_gripper +from litegrip import CommError, NotInitializedError +from litegrip.protocols.can_bus import LiteGripCAN + +#: 一帧完全合法的指令。 +GOOD = dict(q_target=0.5, kp=100.0, kd=2.0, dq_target=0.0, + tau_feedforward=0.0) + + +class FakeMotor: + """鸭子类型即可 —— 缺席 ``limits`` 时帧量程退回 DM4310(±10 Nm)。""" + + +class NetworkDownController: + """下发一律抛 ``ENETDOWN`` —— 真机上接口中途掉线就是这个。""" + + def __init__(self): + self.attempts = 0 + + def control_mit(self, motor, kp, kd, q, dq=0.0, tau=0.0): + self.attempts += 1 + raise OSError(100, "Network is down") + + def poll(self, timeout_s=0.0): + return None + + +class WorkingController: + def control_mit(self, motor, kp, kd, q, dq=0.0, tau=0.0): + pass + + def poll(self, timeout_s=0.0): + return None + + +def make_can(controller=None): + can = LiteGripCAN(channel="vcan0") + if controller is not None: + can._motor = FakeMotor() + can._controller = controller + can._connected = True + return can + + +class TestControlMitKeepsTheReason(unittest.TestCase): + + def test_a_transport_failure_reaches_last_send_error(self): + can = make_can(NetworkDownController()) + + with self.assertLogs("litegrip.can_bus", level="WARNING"): + ok = can.control_mit(**GOOD) + + self.assertFalse(ok) + error = can.last_send_error + self.assertIsInstance(error, OSError, + "原因只进了日志,没上异常 —— 调用方仍然只能读到" + "一个 False") + self.assertEqual(error.errno, 100) + self.assertIn("Network is down", str(error)) + + def test_a_good_frame_clears_the_previous_reason(self): + can = make_can(NetworkDownController()) + can.control_mit(**GOOD) + self.assertIsNotNone(can.last_send_error) + + can._controller = WorkingController() + self.assertTrue(can.control_mit(**GOOD)) + self.assertIsNone(can.last_send_error, + "上一次失败的原因留了下来 —— 下一次成功的下发会被" + "报成失败") + + def test_an_unregistered_can_says_it_is_not_registered(self): + can = make_can() # 既没 motor 也没 controller + + self.assertFalse(can.control_mit(**GOOD)) + self.assertIsInstance(can.last_send_error, NotInitializedError) + self.assertIn("未注册", str(can.last_send_error)) + + def test_nothing_is_recorded_before_the_first_send(self): + self.assertIsNone(make_can(WorkingController()).last_send_error) + + def test_a_stream_that_sends_nothing_keeps_the_reason(self): + can = make_can(NetworkDownController()) + + with self.assertRaises(CommError): + can.control_mit_stream(duration_s=0.01, interval_s=0.001, **GOOD) + + self.assertIsInstance(can.last_send_error, OSError) + + +class TestSendMitFrameCarriesTheReason(unittest.TestCase): + + def test_the_bus_reason_is_passed_through(self): + g, fake = make_gripper() + fake.send_error = OSError(100, "Network is down") + + self.assertFalse(g.send_mit_frame(0.5, 100.0, 2.0)) + self.assertIs(g.last_send_error, fake.send_error) + + def test_a_disabled_gripper_names_the_enable(self): + g, _ = make_gripper() + g._enabled = False + + self.assertFalse(g.send_mit_frame(0.5, 100.0, 2.0)) + self.assertIsInstance(g.last_send_error, NotInitializedError) + self.assertIn("未使能", str(g.last_send_error)) + + def test_an_unconnected_gripper_names_the_connect(self): + g, _ = make_gripper() + g._can = None + + self.assertFalse(g.send_mit_frame(0.5, 100.0, 2.0)) + self.assertIsInstance(g.last_send_error, NotInitializedError) + self.assertIn("未连接", str(g.last_send_error)) + + def test_a_good_frame_clears_the_previous_reason(self): + g, fake = make_gripper() + fake.send_error = OSError(100, "Network is down") + g.send_mit_frame(0.5, 100.0, 2.0) + + fake.send_error = None + self.assertTrue(g.send_mit_frame(0.5, 100.0, 2.0)) + self.assertIsNone(g.last_send_error) + + +class TestTheEngineRaisesTheTypeTheErrorTableNames(unittest.TestCase): + """``_emit`` 是引擎每一帧的必经之路 —— 它报什么,操作者就看到什么。""" + + def test_a_transport_failure_during_a_move_is_a_commerror(self): + """issue #65 的复现:连着、使能着,总线掉了。""" + g, fake = make_gripper() + fake.send_error = OSError(100, "Network is down") + + with self.assertRaises(CommError) as ctx: + g.close(20.0) + + message = str(ctx.exception) + self.assertIn("Network is down", message, + "真正的原因没跟着异常出来 —— 操作者只能看到一句" + "「未连接或未使能」") + self.assertNotIn( + "未连接或未使能", message, + "夹爪连着也使能着(is_connected/is_enabled 都是 True),这句话是" + "谎报,会把诊断指到 24V 供电和 enable() 上去") + self.assertIsInstance(ctx.exception.__cause__, OSError) + + def test_a_disabled_gripper_is_a_notinitializederror(self): + g, _ = make_gripper() + g._enabled = False + + # 走 _emit 本身:公开入口(close / goto_rad)在发帧之前就 _check_enabled() + # 了,用例会撞在那道检查上,测不到这一层对 False 的处理。 + with self.assertRaises(NotInitializedError) as ctx: + g.actions._emit(0.5) + + self.assertIn("未使能", str(ctx.exception)) + + def test_an_unconnected_gripper_is_a_notinitializederror(self): + g, _ = make_gripper() + g._can = None + + with self.assertRaises(NotInitializedError) as ctx: + g.actions._emit(0.5) + + self.assertIn("未连接", str(ctx.exception)) + + def test_an_unrecorded_reason_is_reported_as_unrecorded(self): + """替身只回 ``False`` —— 那就照实说,不替它编一个原因。""" + g, _ = make_gripper() + g.send_mit_frame = lambda *args, **kwargs: False + + with self.assertRaises(CommError) as ctx: + g.actions._emit(0.5) + + message = str(ctx.exception) + self.assertIn("原因未记录", message) + self.assertNotIn("未连接或未使能", message) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_teleop.py b/tests/test_teleop.py index b2fdb2b..52aad96 100644 --- a/tests/test_teleop.py +++ b/tests/test_teleop.py @@ -607,6 +607,25 @@ def test_counts_send_failures(self): finally: mgr.stop() + def test_the_first_failure_names_the_reason(self): + """循环不能因为丢一帧就中止 —— 那条日志就是操作者唯一的报告。""" + g, fake, transport, mgr = SlaveLoopTest()._slave( + align=False, watchdog_s=5.0) + fake.send_error = OSError(100, "Network is down") + with self.assertLogs("litegrip.teleop", level="WARNING") as caught: + mgr.start() + try: + self.assertTrue( + _wait_until(lambda: mgr.status()["send_failed"] > 0)) + finally: + mgr.stop() + + text = "\n".join(caught.output) + self.assertIn("Network is down", text, + "真正的原因没进日志 —— 只说了「没使能或写失败」两种可能," + "诊断还得自己去翻总线") + self.assertNotIn("not enabled or the CAN write failed", text) + def test_reports_the_gripper_fault_code(self): g, _ = make_gripper(start_rad=POS_CLOSED_RAD, err=0) transport = _PreSubTransport()