Skip to content
Closed
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
23 changes: 23 additions & 0 deletions src/litegrip/can/motor.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from __future__ import annotations

import time
from dataclasses import dataclass, field
from enum import IntEnum

Expand Down Expand Up @@ -133,6 +134,28 @@ def is_fault(self) -> bool:
def last_update(self) -> float:
return self._last_update

@property
def has_data(self) -> bool:
"""True once at least one status frame has been decoded.

A disabled DM motor does not stream status frames on its own, so
before the first enable (or after a disable, once the RX buffer
drains) there is nothing to read and every value here is still the
constructor default — position 0.0 with temperatures 0/0.
"""
return self.rx_count > 0

@property
def data_age_s(self) -> float:
"""Seconds since the last decoded status frame.

Returns ``inf`` when no frame has ever arrived, so comparisons like
``data_age_s > threshold`` work without a separate has-data check.
"""
if self.rx_count == 0:
return float("inf")
return max(0.0, time.monotonic() - self._last_update)

# ── control mode management ─────────────────────────────────────────

def set_mode(self, mode: ControlMode) -> None:
Expand Down
6 changes: 6 additions & 0 deletions src/litegrip/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,19 +72,25 @@ class ErrorCode:
"""Damiao motor error codes (extracted from status frame data[0] >> 4)."""
DISABLED: Final = 0
ENABLED: Final = 1
OV_FAULT: Final = 0x8
UV_FAULT: Final = 0x9
OC_FAULT: Final = 0xA
MOS_OT: Final = 0xB
COIL_OT: Final = 0xC
COMM_LOSS: Final = 0xD
OVERLOAD: Final = 0xE


ERROR_DESCRIPTIONS = {
0x0: "已失能",
0x1: "已使能",
0x8: "过压故障 (OV)",
0x9: "欠压故障 (UV)",
0xA: "过流故障 (OC)",
0xB: "MOS 过温故障",
0xC: "线圈过温故障",
0xD: "通讯丢失 (CAN 超时)",
0xE: "过载故障",
}


Expand Down
35 changes: 35 additions & 0 deletions src/litegrip/gripper.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
_os.path.join(_os.path.expanduser("~"), ".litegrip", "litegrip_calibration.json"),
)
from .models import (
STALE_AFTER_S,
GripperState,
GripperConfig,
GripperInfo,
Expand Down Expand Up @@ -1294,6 +1295,12 @@ def _find_limit(direction: str) -> float:
def get_state(self, wait: bool = True) -> GripperState:
"""Return the current gripper state.

A disabled motor does not stream status frames on its own, so when no
fresh frame arrives the returned snapshot is the last decoded one — or
constructor defaults (position 0.0, temperatures 0/0) if none ever
arrived. Check :attr:`GripperState.is_stale` / ``data_age_s`` before
trusting the readings, or send a refresh frame first.

Args:
wait: If True (default), waits up to 50 ms for a fresh status
frame. If False, returns immediately with the last cached
Expand All @@ -1311,6 +1318,16 @@ def get_state(self, wait: bool = True) -> GripperState:
else:
self._can.poll(timeout_s=0.0)

motor = self._can.motor
data_age_s = motor.data_age_s if motor is not None else float("inf")
if wait and data_age_s > STALE_AFTER_S:
log.warning(
"get_state(): 未收到新状态帧(%s)——返回的是缓存/默认值,"
"不是当前测量。失能状态的电机不主动发状态帧。",
"从未收到" if data_age_s == float("inf")
else f"最近一帧 {data_age_s:.2f}s 前",
)

position_rad = self._can.get_position()
velocity_rad_s = self._can.get_velocity()
torque_nm = self._can.get_torque()
Expand All @@ -1330,10 +1347,28 @@ def get_state(self, wait: bool = True) -> GripperState:
temperature_coil=t_coil,
error_code=error_code,
timestamp=time.time(),
data_age_s=data_age_s,
position_mm=position_mm,
force_n=force_n,
)

def refresh_status(self, timeout_s: float = 0.5) -> bool:
"""Request a status frame from the motor, even while disabled.

A disabled motor does not stream status frames, so :meth:`get_state`
keeps returning cached values (or zeros, before the first enable).
This sends the 0xCC refresh command — which the motor answers
regardless of enable state — and waits for the reply. Useful for
reading the position before enabling. No motion, no output change.

Returns:
True if a fresh status frame arrived.
"""
self._check_connected()
if self._can is None:
return False
return self._can.refresh_status(timeout_s=timeout_s)

def get_position(self) -> float:
"""Current position in mm."""
return self.get_state().position_mm
Expand Down
21 changes: 21 additions & 0 deletions src/litegrip/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
import time
from typing import Optional

# A status frame older than this is treated as no longer representing the
# present. DM motors emit status at ~10 Hz while enabled, so 0.5 s is ~5
# missed frames; the motor's own CAN-timeout fault trips at ~0.9 s.
STALE_AFTER_S = 0.5


class GripperMode(IntEnum):
"""Gripper control mode."""
Expand Down Expand Up @@ -41,10 +46,26 @@ class GripperState:
error_code: int = 0
timestamp: float = field(default_factory=time.time)

# Age of the status frame these values came from; inf = never received.
# A disabled motor does not stream status frames, so a state read before
# the first enable (or after a disable) holds constructor defaults, not
# measurements — check this before trusting position/force/temperature.
data_age_s: float = float("inf")

# Convenience — computed from raw values with unit conversion
position_mm: float = 0.0
force_n: float = 0.0

@property
def has_data(self) -> bool:
"""True if at least one status frame has been decoded."""
return self.data_age_s != float("inf")

@property
def is_stale(self) -> bool:
"""True if the snapshot is not backed by a recent status frame."""
return self.data_age_s > STALE_AFTER_S

@property
def is_enabled(self) -> bool:
"""True if the motor is enabled (error_code == 1)."""
Expand Down
Loading
Loading