Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
6 changes: 6 additions & 0 deletions readme_zn.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 会话,进阶用 |

### 连接与状态
Expand Down Expand Up @@ -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` 的替身),消息
就说「原因未记录」,不替它猜。

### 进阶接口

| 接口 | 用来做什么 |
Expand Down
38 changes: 36 additions & 2 deletions src/litegrip/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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``。
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
39 changes: 34 additions & 5 deletions src/litegrip/gripper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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*.
Expand Down
32 changes: 27 additions & 5 deletions src/litegrip/protocols/can_bus.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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:
Expand Down
14 changes: 11 additions & 3 deletions src/litegrip/teleop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
9 changes: 8 additions & 1 deletion src/litegrip/trajectory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
23 changes: 20 additions & 3 deletions tests/fake_can.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading