litegrip-python 1.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- litegrip/__init__.py +298 -0
- litegrip/actions.py +1444 -0
- litegrip/calibration_normal.json +9 -0
- litegrip/calibration_reverse.json +9 -0
- litegrip/can/__init__.py +41 -0
- litegrip/can/controller.py +413 -0
- litegrip/can/motor.py +188 -0
- litegrip/can/protocol.py +380 -0
- litegrip/can/transport.py +365 -0
- litegrip/constants.py +146 -0
- litegrip/exceptions.py +47 -0
- litegrip/factory_calibration.json +16 -0
- litegrip/gripper.py +2678 -0
- litegrip/models.py +210 -0
- litegrip/protocols/__init__.py +10 -0
- litegrip/protocols/can_bus.py +621 -0
- litegrip/py.typed +0 -0
- litegrip/teleop.py +1469 -0
- litegrip/trajectory.py +985 -0
- litegrip/zenoh_link.py +400 -0
- litegrip_python-1.1.2.dist-info/METADATA +636 -0
- litegrip_python-1.1.2.dist-info/RECORD +25 -0
- litegrip_python-1.1.2.dist-info/WHEEL +5 -0
- litegrip_python-1.1.2.dist-info/licenses/LICENSE +202 -0
- litegrip_python-1.1.2.dist-info/top_level.txt +1 -0
litegrip/gripper.py
ADDED
|
@@ -0,0 +1,2678 @@
|
|
|
1
|
+
"""LiteGrip SDK — high-level gripper API.
|
|
2
|
+
|
|
3
|
+
The LiteGrip class is the primary entry point. It manages a single
|
|
4
|
+
gripper motor (DM4310 by default) over CAN, exposing an intuitive
|
|
5
|
+
open/close/grasp interface with automatic unit conversion.
|
|
6
|
+
|
|
7
|
+
Example::
|
|
8
|
+
|
|
9
|
+
from litegrip import LiteGrip
|
|
10
|
+
|
|
11
|
+
with LiteGrip(channel="can0", can_id=0x08) as gripper:
|
|
12
|
+
gripper.load_calibration()
|
|
13
|
+
gripper.enable() # 反复重试直到状态帧确认 err == 1
|
|
14
|
+
gripper.open()
|
|
15
|
+
gripper.grasp(force_n=20.0, hold_s=3.0)
|
|
16
|
+
state = gripper.get_state()
|
|
17
|
+
print(f"Position: {state.position_mm:.1f} mm, Force: {state.force_n:.1f} N")
|
|
18
|
+
|
|
19
|
+
``enable()`` / ``disable()`` / ``open()`` / ``close()`` / ``grasp()`` /
|
|
20
|
+
``zero()`` 都转发到 :class:`~litegrip.actions.GripperActions`(见
|
|
21
|
+
:attr:`LiteGrip.actions`),运动参数在 :attr:`LiteGrip.motion_config` 上。
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
import math
|
|
28
|
+
import os as _os
|
|
29
|
+
import select as _select_mod
|
|
30
|
+
import sys
|
|
31
|
+
import threading
|
|
32
|
+
import time
|
|
33
|
+
from datetime import datetime
|
|
34
|
+
from typing import Callable, List, Optional
|
|
35
|
+
|
|
36
|
+
from .can.motor import MotorType
|
|
37
|
+
from .protocols.can_bus import LiteGripCAN
|
|
38
|
+
from .actions import (
|
|
39
|
+
EnableResult,
|
|
40
|
+
GraspResult,
|
|
41
|
+
GripperActions,
|
|
42
|
+
MotionConfig,
|
|
43
|
+
MoveProgress,
|
|
44
|
+
MoveResult,
|
|
45
|
+
WriteZeroResult,
|
|
46
|
+
_check_calibrated,
|
|
47
|
+
clamp_to_travel,
|
|
48
|
+
)
|
|
49
|
+
from .teleop import (DEFAULT_ALIGN_SPEED_MM_S, DEFAULT_DQ_MAX, DEFAULT_GRIP_ID,
|
|
50
|
+
DEFAULT_GRIP_PORT, DEFAULT_LEAD_CAP_MM,
|
|
51
|
+
DEFAULT_READY_PERIOD_S, DEFAULT_READY_TIMEOUT_S,
|
|
52
|
+
DEFAULT_READY_TOLERANCE_MM, DEFAULT_TORQUE_LIMIT_NM)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _zenoh_transport(role: str, key: str, port: int, host: Optional[str],
|
|
56
|
+
ready_key: Optional[str] = None) -> "TeleopTransport":
|
|
57
|
+
"""Build a point-to-point zenoh transport, or explain how to get one.
|
|
58
|
+
|
|
59
|
+
zenoh is an optional dependency, so a bare ``import`` failure is turned into
|
|
60
|
+
an actionable message rather than a ModuleNotFoundError naming a module the
|
|
61
|
+
user never asked for.
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
from .zenoh_link import ZenohTeleopTransport
|
|
65
|
+
except ImportError as e:
|
|
66
|
+
raise ImportError(
|
|
67
|
+
"the zenoh teleoperation link needs the optional zenoh dependency — "
|
|
68
|
+
"install it with `pip install litegrip-python[zenoh]`") from e
|
|
69
|
+
return ZenohTeleopTransport(role, key, port=port, host=host,
|
|
70
|
+
ready_key=ready_key)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# The calibration the SDK ships for the unit it ships with. This is the
|
|
74
|
+
# *standard* value: a no-argument load_calibration() reads it and nothing else.
|
|
75
|
+
# A calibration measured on the buyer's machine is never picked up by accident
|
|
76
|
+
# — name it with ``path=``, ``template=`` or ``LITEGRIP_CALIB``.
|
|
77
|
+
_FACTORY_CALIB = _os.path.join(_os.path.dirname(__file__), "factory_calibration.json")
|
|
78
|
+
|
|
79
|
+
# Pre-made calibration templates shipped with the package. Each is a pair of
|
|
80
|
+
# nominal limits whose *ordering* declares the mounting direction: "normal"
|
|
81
|
+
# closes at the larger rad value, "reverse" at the smaller one. They are a
|
|
82
|
+
# labelling seed, not a per-unit calibration — the limits are nominal, so run
|
|
83
|
+
# zero() to measure the real travel. Pick one by eye after watching which way
|
|
84
|
+
# the jaws move::
|
|
85
|
+
#
|
|
86
|
+
# gripper.load_calibration(CALIB_TEMPLATES["reverse"]) # by path
|
|
87
|
+
# gripper.load_template("reverse") # or by name
|
|
88
|
+
#
|
|
89
|
+
# They deliberately carry no `channel`, `can_id`, `mst_id` or gain values: a
|
|
90
|
+
# template declares a *direction*, and adopting a device identity or a tuned
|
|
91
|
+
# kp/kd from it would silently rewrite what the caller set. Use
|
|
92
|
+
# :func:`list_templates` to enumerate the names for a UI.
|
|
93
|
+
CALIB_TEMPLATES = {
|
|
94
|
+
"normal": _os.path.join(_os.path.dirname(__file__), "calibration_normal.json"),
|
|
95
|
+
"reverse": _os.path.join(_os.path.dirname(__file__), "calibration_reverse.json"),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
# The pre-per-channel location. A calibration saved by an older version is
|
|
99
|
+
# still on disk and still loadable — but only by naming it, like any other
|
|
100
|
+
# file on the machine.
|
|
101
|
+
_LEGACY_CALIB_PATH = _os.path.join(
|
|
102
|
+
_os.path.expanduser("~"), ".litegrip", "litegrip_calibration.json")
|
|
103
|
+
|
|
104
|
+
# The historical default calibration path, kept exported so existing imports
|
|
105
|
+
# keep working. It is NOT read by load_calibration() any more: a no-argument
|
|
106
|
+
# load reads the factory file alone, and everything on this machine has to be
|
|
107
|
+
# asked for by name (``path=`` or ``LITEGRIP_CALIB``).
|
|
108
|
+
DEFAULT_CALIB = _os.environ.get("LITEGRIP_CALIB", _LEGACY_CALIB_PATH)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
from .models import (
|
|
112
|
+
GripperState,
|
|
113
|
+
GripperConfig,
|
|
114
|
+
GripperInfo,
|
|
115
|
+
GripperStatus,
|
|
116
|
+
CalibrationData,
|
|
117
|
+
)
|
|
118
|
+
from .constants import (
|
|
119
|
+
GripperGeometry,
|
|
120
|
+
GripperParams,
|
|
121
|
+
UnitConversion,
|
|
122
|
+
DefaultParams,
|
|
123
|
+
describe_error,
|
|
124
|
+
)
|
|
125
|
+
from .exceptions import (
|
|
126
|
+
CommandError,
|
|
127
|
+
ConnectError,
|
|
128
|
+
HardwareError,
|
|
129
|
+
NotInitializedError,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _scale_from_travel(config: GripperConfig, travel_rad: float) -> float:
|
|
134
|
+
"""A probed travel in radians → millimetres per radian.
|
|
135
|
+
|
|
136
|
+
The numerator is ``max_stroke_mm + STOP_INSET_MM`` rather than
|
|
137
|
+
``max_stroke_mm``: the probe presses *into* the open stop to find it, so the
|
|
138
|
+
two recorded extremes span about a millimetre more than the jaws can
|
|
139
|
+
actually travel. See :class:`~litegrip.constants.GripperGeometry`.
|
|
140
|
+
|
|
141
|
+
Deriving the scale from the jaw travel alone is a 1.2% short scale — 1 mm
|
|
142
|
+
lost per full stroke — and it is what this used to do, on top of a nominal
|
|
143
|
+
stroke (120 mm) that belonged to no gripper in this family.
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
RuntimeError: the two stops coincide, so no scale can be derived. A
|
|
147
|
+
calibration that cannot produce a scale must not invent one.
|
|
148
|
+
"""
|
|
149
|
+
if not travel_rad > 0:
|
|
150
|
+
raise RuntimeError(
|
|
151
|
+
f"标定失败:两端止点重合成 {travel_rad:.6f} rad 的行程,推不出 "
|
|
152
|
+
"mm/rad。确认夹爪真的走完了全程,再重新标定。")
|
|
153
|
+
return ((config.max_stroke_mm + GripperGeometry.STOP_INSET_MM)
|
|
154
|
+
/ travel_rad)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def default_calib_path(channel: str = DefaultParams.CAN_CHANNEL) -> str:
|
|
158
|
+
"""Where a calibration for *channel* is saved, and how to name it back.
|
|
159
|
+
|
|
160
|
+
One gripper per channel, one file per channel —
|
|
161
|
+
``~/.litegrip/<channel>_calibration.json``. A single shared file let two
|
|
162
|
+
grippers on one machine (``can0`` / ``can1``, both at CAN ID 0x08)
|
|
163
|
+
overwrite each other's direction and travel.
|
|
164
|
+
|
|
165
|
+
This is :meth:`LiteGrip.save_calibration`'s destination. It is *not* what
|
|
166
|
+
a no-argument :meth:`LiteGrip.load_calibration` reads: that reads the
|
|
167
|
+
factory file alone. Feed the saved path back explicitly, or set
|
|
168
|
+
``LITEGRIP_CALIB`` to it.
|
|
169
|
+
|
|
170
|
+
``LITEGRIP_CALIB``, when set, overrides this with one explicit path for
|
|
171
|
+
every channel. ``HOME`` and the env var are read per call, so a caller
|
|
172
|
+
(or a test) can redirect them.
|
|
173
|
+
"""
|
|
174
|
+
env = _os.environ.get("LITEGRIP_CALIB")
|
|
175
|
+
if env:
|
|
176
|
+
return env
|
|
177
|
+
return _os.path.join(_os.path.expanduser("~"), ".litegrip",
|
|
178
|
+
f"{channel}_calibration.json")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def list_templates() -> List[str]:
|
|
182
|
+
"""The calibration template names, in declaration order (normal first).
|
|
183
|
+
|
|
184
|
+
For a UI that has to offer the mount as a choice.
|
|
185
|
+
"""
|
|
186
|
+
return list(CALIB_TEMPLATES)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _resolve_template(name: str) -> str:
|
|
190
|
+
"""Template *name* → path. Unknown names are a hard error."""
|
|
191
|
+
try:
|
|
192
|
+
return CALIB_TEMPLATES[name]
|
|
193
|
+
except KeyError:
|
|
194
|
+
raise CommandError(
|
|
195
|
+
f"未知的标定模板 {name!r};可用的是 "
|
|
196
|
+
f"{', '.join(repr(n) for n in CALIB_TEMPLATES)}"
|
|
197
|
+
f"(也可以直接给文件路径)。") from None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _dedupe(paths: List[str]) -> List[str]:
|
|
201
|
+
"""Drop repeats, keep order — ``LITEGRIP_CALIB`` can alias several entries."""
|
|
202
|
+
seen = set()
|
|
203
|
+
out: List[str] = []
|
|
204
|
+
for p in paths:
|
|
205
|
+
if p not in seen:
|
|
206
|
+
seen.add(p)
|
|
207
|
+
out.append(p)
|
|
208
|
+
return out
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _ignored_calib_files(channel: str) -> List[str]:
|
|
212
|
+
"""Calibrations on this machine that a no-argument load does not read.
|
|
213
|
+
|
|
214
|
+
A file written by :meth:`LiteGrip.save_calibration`, or the pre-per-channel
|
|
215
|
+
``litegrip_calibration.json``, sits in ``~/.litegrip`` and no longer takes
|
|
216
|
+
part in a no-argument load. Reporting it is the point: a machine that
|
|
217
|
+
quietly keeps running the factory calibration with a measured one next to
|
|
218
|
+
it is the failure this whole change exists to stop.
|
|
219
|
+
|
|
220
|
+
Only names that could belong to *channel* are listed — ``<channel>_...json``
|
|
221
|
+
and the legacy name — so a second gripper's file does not show up in the
|
|
222
|
+
first one's log. Anything else in that directory is not this SDK's to
|
|
223
|
+
judge (see the README for which names are recognized at all).
|
|
224
|
+
"""
|
|
225
|
+
directory = _os.path.join(_os.path.expanduser("~"), ".litegrip")
|
|
226
|
+
if not _os.path.isdir(directory):
|
|
227
|
+
return []
|
|
228
|
+
ignored = []
|
|
229
|
+
for name in sorted(_os.listdir(directory)):
|
|
230
|
+
if not name.endswith(".json"):
|
|
231
|
+
continue
|
|
232
|
+
if name == _os.path.basename(_LEGACY_CALIB_PATH) or name.startswith(f"{channel}_"):
|
|
233
|
+
ignored.append(_os.path.join(directory, name))
|
|
234
|
+
return ignored
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _warn_ignored_local_calibrations(channel: str) -> None:
|
|
238
|
+
"""Name every machine calibration a no-argument load walked past.
|
|
239
|
+
|
|
240
|
+
Silent fallback is the bug this reports on: without it the gripper keeps
|
|
241
|
+
running one calibration while a freshly measured one sits unused on disk.
|
|
242
|
+
"""
|
|
243
|
+
ignored = _ignored_calib_files(channel)
|
|
244
|
+
if not ignored:
|
|
245
|
+
return
|
|
246
|
+
log.warning(
|
|
247
|
+
"Local calibration not read: %s. A no-argument load_calibration() "
|
|
248
|
+
"reads the shipped factory calibration only, so this run does not use "
|
|
249
|
+
"them. To use one, name it: load_calibration(path=%r), or set "
|
|
250
|
+
"LITEGRIP_CALIB=%s.",
|
|
251
|
+
", ".join(ignored), ignored[0], ignored[0])
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
log = logging.getLogger("litegrip")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class LiteGrip:
|
|
258
|
+
"""LiteGrip adaptive two-finger gripper.
|
|
259
|
+
|
|
260
|
+
Parameters
|
|
261
|
+
----------
|
|
262
|
+
channel:
|
|
263
|
+
CAN interface name (``"can0"``, ``"vcan0"``, etc.).
|
|
264
|
+
can_id:
|
|
265
|
+
Motor CAN ID (default 0x08).
|
|
266
|
+
mst_id:
|
|
267
|
+
Motor master ID for status frames. ``None`` = auto-detect.
|
|
268
|
+
canfd_mode:
|
|
269
|
+
``True`` to prefer CAN FD. ``None`` = auto-detect from interface MTU.
|
|
270
|
+
motor_type:
|
|
271
|
+
Damiao motor model (default DM4310).
|
|
272
|
+
config:
|
|
273
|
+
Full GripperConfig for advanced tuning.
|
|
274
|
+
motion_config:
|
|
275
|
+
Motion parameters for the high-level moves (see
|
|
276
|
+
:class:`~litegrip.actions.MotionConfig`). Also settable at runtime
|
|
277
|
+
via the :attr:`motion_config` property.
|
|
278
|
+
disable_on_disconnect:
|
|
279
|
+
``True`` (default) → :meth:`disconnect` sends a disable command first,
|
|
280
|
+
so the gripper goes limp when the object is released. Set ``False``
|
|
281
|
+
to leave the motor enabled after disconnecting.
|
|
282
|
+
mount:
|
|
283
|
+
Template name declaring which way this unit is mounted —
|
|
284
|
+
``"normal"`` or ``"reverse"`` (see :data:`CALIB_TEMPLATES` and
|
|
285
|
+
:func:`list_templates`). Loads that template immediately, so
|
|
286
|
+
``LiteGrip("can1", mount="reverse")`` is all a two-gripper setup
|
|
287
|
+
needs. ``None`` (default) loads nothing; call
|
|
288
|
+
:meth:`load_calibration` yourself. This *declares* the mount; read
|
|
289
|
+
the resulting direction back from :attr:`GripperConfig.mount`.
|
|
290
|
+
|
|
291
|
+
The gripper works as a context manager::
|
|
292
|
+
|
|
293
|
+
with LiteGrip("can0") as gripper:
|
|
294
|
+
gripper.enable()
|
|
295
|
+
gripper.open()
|
|
296
|
+
"""
|
|
297
|
+
|
|
298
|
+
def __init__(
|
|
299
|
+
self,
|
|
300
|
+
channel: str = DefaultParams.CAN_CHANNEL,
|
|
301
|
+
can_id: int = GripperParams.CAN_ID,
|
|
302
|
+
mst_id: Optional[int] = None,
|
|
303
|
+
canfd_mode: Optional[bool] = None,
|
|
304
|
+
motor_type: MotorType = MotorType.DM4310,
|
|
305
|
+
config: Optional[GripperConfig] = None,
|
|
306
|
+
motion_config: Optional[MotionConfig] = None,
|
|
307
|
+
disable_on_disconnect: bool = True,
|
|
308
|
+
mount: Optional[str] = None,
|
|
309
|
+
):
|
|
310
|
+
cfg = config or GripperConfig()
|
|
311
|
+
self._channel = channel if channel != DefaultParams.CAN_CHANNEL or config is None else cfg.can_channel
|
|
312
|
+
self._can_id = can_id if can_id != GripperParams.CAN_ID or config is None else cfg.can_id
|
|
313
|
+
self._mst_id = mst_id if mst_id is not None else cfg.mst_id
|
|
314
|
+
self._canfd_mode = canfd_mode if canfd_mode is not None else cfg.canfd_mode
|
|
315
|
+
self._motor_type = motor_type
|
|
316
|
+
self._config = cfg
|
|
317
|
+
self._disable_on_disconnect = disable_on_disconnect
|
|
318
|
+
|
|
319
|
+
self._can: Optional[LiteGripCAN] = None
|
|
320
|
+
self._enabled = False
|
|
321
|
+
self._connected = False
|
|
322
|
+
self._last_send_error: Optional[Exception] = None
|
|
323
|
+
self._status_flags = GripperStatus.NONE
|
|
324
|
+
self._actions = GripperActions(self, motion_config)
|
|
325
|
+
|
|
326
|
+
# Reentrant lock around the low-level CAN I/O that a teleop background
|
|
327
|
+
# thread shares with the caller (send/poll/get_state). A no-op for the
|
|
328
|
+
# single-threaded use this SDK assumed before teleop existed.
|
|
329
|
+
self._io_lock = threading.RLock()
|
|
330
|
+
self._teleop: Optional["GripperTeleop"] = None
|
|
331
|
+
# Transport teleop_start built itself (as opposed to one the caller
|
|
332
|
+
# injected), so teleop_stop knows what it is allowed to close.
|
|
333
|
+
self._teleop_transport: Optional["TeleopTransport"] = None
|
|
334
|
+
# The leader's zenoh publisher endpoint, kept for the life of this
|
|
335
|
+
# gripper rather than per session — rebuilding it per session leaves the
|
|
336
|
+
# port bound and makes matching fail intermittently.
|
|
337
|
+
self._teleop_pub: Optional["TeleopTransport"] = None
|
|
338
|
+
|
|
339
|
+
# One long-running session at a time. Teleoperation and a trajectory
|
|
340
|
+
# both own the CAN I/O for their whole duration and both run in their
|
|
341
|
+
# own thread, so "is teleop running? no → start recording" is a
|
|
342
|
+
# check-then-act race that lets two loops interleave frames on one
|
|
343
|
+
# motor. Claiming and releasing under one lock closes it. The owner
|
|
344
|
+
# string names the session, so the refusal says who is in the way.
|
|
345
|
+
self._session_lock = threading.Lock()
|
|
346
|
+
self._session_owner: Optional[str] = None
|
|
347
|
+
self._trajectory_recorder: Optional["TrajectoryRecorder"] = None
|
|
348
|
+
self._trajectory_player: Optional["TrajectoryPlayer"] = None
|
|
349
|
+
|
|
350
|
+
# Declaring the mount is just loading the matching template, so it
|
|
351
|
+
# costs no CAN traffic and is safe this early. The template carries
|
|
352
|
+
# only a direction and geometry, so it cannot clobber the identity
|
|
353
|
+
# arguments above or a tuned config.
|
|
354
|
+
if mount is not None:
|
|
355
|
+
self.load_template(mount)
|
|
356
|
+
|
|
357
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
358
|
+
# Properties
|
|
359
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
360
|
+
|
|
361
|
+
@property
|
|
362
|
+
def channel(self) -> str:
|
|
363
|
+
return self._channel
|
|
364
|
+
|
|
365
|
+
@property
|
|
366
|
+
def can_id(self) -> int:
|
|
367
|
+
return self._can_id
|
|
368
|
+
|
|
369
|
+
@property
|
|
370
|
+
def mst_id(self) -> Optional[int]:
|
|
371
|
+
return self._mst_id
|
|
372
|
+
|
|
373
|
+
@property
|
|
374
|
+
def is_connected(self) -> bool:
|
|
375
|
+
return self._connected
|
|
376
|
+
|
|
377
|
+
@property
|
|
378
|
+
def is_enabled(self) -> bool:
|
|
379
|
+
return self._enabled
|
|
380
|
+
|
|
381
|
+
@property
|
|
382
|
+
def config(self) -> GripperConfig:
|
|
383
|
+
return self._config
|
|
384
|
+
|
|
385
|
+
@property
|
|
386
|
+
def mount(self) -> Optional[str]:
|
|
387
|
+
"""``"normal"`` / ``"reverse"``, or ``None`` while uncalibrated.
|
|
388
|
+
|
|
389
|
+
Read back from the loaded limits (see
|
|
390
|
+
:attr:`~litegrip.GripperConfig.mount`) — not necessarily the name
|
|
391
|
+
that was *declared*, since a calibration run keeps the direction but
|
|
392
|
+
rewrites the travel.
|
|
393
|
+
"""
|
|
394
|
+
return self._config.mount
|
|
395
|
+
|
|
396
|
+
@property
|
|
397
|
+
def actions(self) -> GripperActions:
|
|
398
|
+
"""High-level motion API: ``open`` / ``close`` / ``grasp`` / ``zero`` /
|
|
399
|
+
``enable`` / ``disable``."""
|
|
400
|
+
return self._actions
|
|
401
|
+
|
|
402
|
+
@property
|
|
403
|
+
def motion_config(self) -> MotionConfig:
|
|
404
|
+
"""Motion parameters used by the high-level moves. Settable."""
|
|
405
|
+
return self._actions.config
|
|
406
|
+
|
|
407
|
+
@motion_config.setter
|
|
408
|
+
def motion_config(self, value: MotionConfig) -> None:
|
|
409
|
+
self._actions.config = value
|
|
410
|
+
|
|
411
|
+
@property
|
|
412
|
+
def disable_on_disconnect(self) -> bool:
|
|
413
|
+
return self._disable_on_disconnect
|
|
414
|
+
|
|
415
|
+
@disable_on_disconnect.setter
|
|
416
|
+
def disable_on_disconnect(self, value: bool) -> None:
|
|
417
|
+
self._disable_on_disconnect = value
|
|
418
|
+
|
|
419
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
420
|
+
# Connection
|
|
421
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
422
|
+
|
|
423
|
+
def connect(self) -> bool:
|
|
424
|
+
"""Open CAN bus and register the gripper motor.
|
|
425
|
+
|
|
426
|
+
If *mst_id* was ``None`` at construction, it is auto-detected here.
|
|
427
|
+
"""
|
|
428
|
+
if self._connected:
|
|
429
|
+
return True
|
|
430
|
+
|
|
431
|
+
try:
|
|
432
|
+
self._can = LiteGripCAN(
|
|
433
|
+
channel=self._channel,
|
|
434
|
+
canfd_mode=self._canfd_mode, # None → auto-detect in transport
|
|
435
|
+
)
|
|
436
|
+
self._can.connect()
|
|
437
|
+
|
|
438
|
+
# Register motor; mst_id=None triggers auto-detect
|
|
439
|
+
actual_mst = self._can.register_gripper(
|
|
440
|
+
can_id=self._can_id,
|
|
441
|
+
mst_id=self._mst_id,
|
|
442
|
+
motor_type=self._motor_type,
|
|
443
|
+
)
|
|
444
|
+
if self._mst_id is None:
|
|
445
|
+
self._mst_id = actual_mst
|
|
446
|
+
|
|
447
|
+
self._connected = True
|
|
448
|
+
self._status_flags = GripperStatus.NONE
|
|
449
|
+
log.info("LiteGrip connected: %s", self)
|
|
450
|
+
return True
|
|
451
|
+
except ConnectError:
|
|
452
|
+
raise
|
|
453
|
+
except Exception as e:
|
|
454
|
+
raise ConnectError(f"连接失败: {e}")
|
|
455
|
+
|
|
456
|
+
def disconnect(self) -> None:
|
|
457
|
+
"""Close the CAN transport.
|
|
458
|
+
|
|
459
|
+
Sends a disable command first unless :attr:`disable_on_disconnect`
|
|
460
|
+
is ``False`` (in which case the motor stays enabled).
|
|
461
|
+
"""
|
|
462
|
+
if not self._connected:
|
|
463
|
+
return
|
|
464
|
+
|
|
465
|
+
if self._teleop is not None:
|
|
466
|
+
self.teleop_stop()
|
|
467
|
+
self._close_teleop_pub()
|
|
468
|
+
# A recording left running would keep sampling a bus that is about to
|
|
469
|
+
# close, and a replay would keep commanding one. stop() also leaves the
|
|
470
|
+
# gripper holding rather than slack.
|
|
471
|
+
if self._trajectory_recorder is not None:
|
|
472
|
+
self._trajectory_recorder.stop()
|
|
473
|
+
self._release_session("record")
|
|
474
|
+
self._trajectory_recorder = None
|
|
475
|
+
if self._trajectory_player is not None:
|
|
476
|
+
self._trajectory_player.stop()
|
|
477
|
+
self._release_session("play")
|
|
478
|
+
self._trajectory_player = None
|
|
479
|
+
|
|
480
|
+
if self._can:
|
|
481
|
+
self._can.disconnect(disable=self._disable_on_disconnect)
|
|
482
|
+
self._can = None
|
|
483
|
+
|
|
484
|
+
self._connected = False
|
|
485
|
+
self._enabled = False
|
|
486
|
+
self._status_flags = GripperStatus.NONE
|
|
487
|
+
|
|
488
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
489
|
+
# Enable / disable / fault
|
|
490
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
491
|
+
|
|
492
|
+
def enable(self, retries: Optional[int] = None) -> EnableResult:
|
|
493
|
+
"""Enable the gripper motor and verify it actually took.
|
|
494
|
+
|
|
495
|
+
Retries ``enable`` until a status frame reports ``err == 1`` (真使能)
|
|
496
|
+
— the frame must be read back, because ``enable`` is a one-way CAN
|
|
497
|
+
command and a dropped frame would otherwise go unnoticed. Real
|
|
498
|
+
faults (err ∉ {0, 1}) are cleared before retrying.
|
|
499
|
+
|
|
500
|
+
Args:
|
|
501
|
+
retries: Attempts. ``None`` = ``MotionConfig.enable_retries``.
|
|
502
|
+
|
|
503
|
+
Returns:
|
|
504
|
+
:class:`~litegrip.actions.EnableResult` — truthy when enabled.
|
|
505
|
+
"""
|
|
506
|
+
result = self._actions.enable(retries)
|
|
507
|
+
# 以「回读到的状态帧」为准,而不是以某一次 initialize() 的返回值为准
|
|
508
|
+
self._enabled = result.ok
|
|
509
|
+
if result.ok:
|
|
510
|
+
self._status_flags |= GripperStatus.ENABLED
|
|
511
|
+
else:
|
|
512
|
+
self._status_flags &= ~GripperStatus.ENABLED
|
|
513
|
+
return result
|
|
514
|
+
|
|
515
|
+
def _enable_once(self) -> bool:
|
|
516
|
+
"""One ``enable`` attempt: full init (disable → MIT → enable →
|
|
517
|
+
feedback). No retry, no state-frame verification — that is
|
|
518
|
+
:meth:`actions.enable` / :meth:`enable`'s job.
|
|
519
|
+
|
|
520
|
+
Raises:
|
|
521
|
+
HardwareError: the motor did not end up enabled.
|
|
522
|
+
"""
|
|
523
|
+
self._check_connected()
|
|
524
|
+
|
|
525
|
+
err = self.get_error()
|
|
526
|
+
if err not in (0, 1):
|
|
527
|
+
self.clear_fault()
|
|
528
|
+
|
|
529
|
+
try:
|
|
530
|
+
self._enabled = self._can.initialize()
|
|
531
|
+
if self._enabled:
|
|
532
|
+
self._status_flags |= GripperStatus.ENABLED
|
|
533
|
+
return self._enabled
|
|
534
|
+
except HardwareError:
|
|
535
|
+
raise
|
|
536
|
+
except Exception as e:
|
|
537
|
+
raise HardwareError(f"使能失败: {e}")
|
|
538
|
+
|
|
539
|
+
def disable(self) -> bool:
|
|
540
|
+
"""Disable the gripper motor."""
|
|
541
|
+
return self._actions.disable()
|
|
542
|
+
|
|
543
|
+
def _disable_once(self) -> bool:
|
|
544
|
+
"""Send a single disable command and clear the enabled flags."""
|
|
545
|
+
self._check_connected()
|
|
546
|
+
try:
|
|
547
|
+
result = self._can.disable()
|
|
548
|
+
self._enabled = False
|
|
549
|
+
self._status_flags &= ~GripperStatus.ENABLED
|
|
550
|
+
return result
|
|
551
|
+
except Exception:
|
|
552
|
+
self._enabled = False
|
|
553
|
+
return False
|
|
554
|
+
|
|
555
|
+
def clear_fault(self) -> bool:
|
|
556
|
+
"""Clear latched faults (UV / OC / OT).
|
|
557
|
+
|
|
558
|
+
Sequence: disable → clear(0xFB) → enable → verify.
|
|
559
|
+
Retries up to *FAULT_CLEAR_RETRIES* times.
|
|
560
|
+
"""
|
|
561
|
+
self._check_connected()
|
|
562
|
+
if self._can is None:
|
|
563
|
+
return False
|
|
564
|
+
|
|
565
|
+
cleared = self._can.clear_fault()
|
|
566
|
+
if not cleared:
|
|
567
|
+
err = self._can.get_error()
|
|
568
|
+
raise HardwareError(
|
|
569
|
+
f"故障清除失败: {describe_error(err)} (错误码 0x{err:X})",
|
|
570
|
+
error_code=err,
|
|
571
|
+
)
|
|
572
|
+
self._enabled = True
|
|
573
|
+
self._status_flags |= GripperStatus.ENABLED
|
|
574
|
+
return True
|
|
575
|
+
|
|
576
|
+
def stop(self) -> None:
|
|
577
|
+
"""Emergency stop — send zero-torque MIT frame.
|
|
578
|
+
|
|
579
|
+
Does NOT disable the motor; the motor stays enabled but exerts
|
|
580
|
+
zero torque so it can be back-driven.
|
|
581
|
+
"""
|
|
582
|
+
if self._can is not None and self._enabled:
|
|
583
|
+
self._can.control_mit(q_target=0, kp=0, kd=0)
|
|
584
|
+
self._can.update_state(timeout_s=0.02)
|
|
585
|
+
|
|
586
|
+
def send_mit_frame(
|
|
587
|
+
self,
|
|
588
|
+
q: float,
|
|
589
|
+
kp: float,
|
|
590
|
+
kd: float,
|
|
591
|
+
dq: float = 0.0,
|
|
592
|
+
tau: float = 0.0,
|
|
593
|
+
) -> bool:
|
|
594
|
+
"""Send a single MIT control frame — expert/low-level use.
|
|
595
|
+
|
|
596
|
+
For sustained motion use :meth:`goto_rad` or :meth:`move_at_speed`
|
|
597
|
+
instead. This method is exposed for custom control loops that
|
|
598
|
+
manage their own timing (e.g. force-feedback monitors).
|
|
599
|
+
|
|
600
|
+
Args:
|
|
601
|
+
q: Target position in rad.
|
|
602
|
+
kp: Position stiffness.
|
|
603
|
+
kd: Velocity damping.
|
|
604
|
+
dq: Target velocity in rad/s.
|
|
605
|
+
tau: Feed-forward torque in Nm.
|
|
606
|
+
|
|
607
|
+
Returns:
|
|
608
|
+
True if the frame was sent. A ``False`` return leaves the reason in
|
|
609
|
+
:attr:`last_send_error`.
|
|
610
|
+
"""
|
|
611
|
+
if self._can is None:
|
|
612
|
+
self._last_send_error = self._not_connected_error()
|
|
613
|
+
return False
|
|
614
|
+
if not self._enabled:
|
|
615
|
+
self._last_send_error = self._not_enabled_error()
|
|
616
|
+
return False
|
|
617
|
+
with self._io_lock:
|
|
618
|
+
sent = self._can.control_mit(
|
|
619
|
+
q_target=q, kp=kp, kd=kd, dq_target=dq, tau_feedforward=tau)
|
|
620
|
+
# 鸭子类型的替身可能没有这一项;真 CAN 层永远有。取不到就是
|
|
621
|
+
# 「原因没记录」,上层据此照实报,而不是编一个。
|
|
622
|
+
self._last_send_error = (
|
|
623
|
+
None if sent else getattr(self._can, "last_send_error", None))
|
|
624
|
+
return sent
|
|
625
|
+
|
|
626
|
+
@property
|
|
627
|
+
def last_send_error(self) -> Optional[Exception]:
|
|
628
|
+
"""Why the last :meth:`send_mit_frame` did not send, or ``None``.
|
|
629
|
+
|
|
630
|
+
The exception the send itself reported — a ``CommError``-family cause
|
|
631
|
+
from the transport, or the ``NotInitializedError`` naming the step that
|
|
632
|
+
was skipped. The engine reads it to raise the right type instead of one
|
|
633
|
+
fixed message that guesses.
|
|
634
|
+
"""
|
|
635
|
+
return self._last_send_error
|
|
636
|
+
|
|
637
|
+
def poll(self, timeout_s: float = 0.0) -> bool:
|
|
638
|
+
"""Poll for one CAN frame and update cached motor state.
|
|
639
|
+
|
|
640
|
+
Args:
|
|
641
|
+
timeout_s: Max wait time in seconds (0 = non-blocking).
|
|
642
|
+
|
|
643
|
+
Returns:
|
|
644
|
+
True if a status frame for this gripper was received.
|
|
645
|
+
"""
|
|
646
|
+
if self._can is None:
|
|
647
|
+
return False
|
|
648
|
+
with self._io_lock:
|
|
649
|
+
return self._can.poll(timeout_s=timeout_s)
|
|
650
|
+
|
|
651
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
652
|
+
# Zero-gravity mode (manual back-driving)
|
|
653
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
654
|
+
|
|
655
|
+
def enter_zero_gravity(self, duration: float = 0.0) -> None:
|
|
656
|
+
"""Enter zero-gravity mode — motor stays enabled but exerts no torque.
|
|
657
|
+
|
|
658
|
+
The gripper can be freely moved by hand. Call :meth:`exit_zero_gravity`
|
|
659
|
+
or any motion command to resume normal control.
|
|
660
|
+
|
|
661
|
+
Args:
|
|
662
|
+
duration: Seconds to sustain zero-gravity. If > 0, blocks for
|
|
663
|
+
that duration while continuously streaming kp=0, kd=0
|
|
664
|
+
frames. If 0 (default), the caller must call
|
|
665
|
+
:meth:`update_state` or poll manually to sustain the
|
|
666
|
+
mode (single frame sent as bootstrap).
|
|
667
|
+
"""
|
|
668
|
+
self._check_connected()
|
|
669
|
+
self._check_enabled()
|
|
670
|
+
if self._can is None:
|
|
671
|
+
return
|
|
672
|
+
|
|
673
|
+
if duration > 0:
|
|
674
|
+
deadline = time.monotonic() + duration
|
|
675
|
+
while time.monotonic() < deadline:
|
|
676
|
+
self._can.control_mit(q_target=0, kp=0, kd=0, tau_feedforward=0)
|
|
677
|
+
self._can.poll(timeout_s=0.0)
|
|
678
|
+
time.sleep(0.005)
|
|
679
|
+
log.info("Zero-gravity mode ended (duration=%.1fs)", duration)
|
|
680
|
+
else:
|
|
681
|
+
self._can.control_mit(q_target=0, kp=0, kd=0, tau_feedforward=0)
|
|
682
|
+
log.info("Zero-gravity mode: gripper is freely back-drivable "
|
|
683
|
+
"(caller must poll/sustain)")
|
|
684
|
+
|
|
685
|
+
def exit_zero_gravity(self) -> None:
|
|
686
|
+
"""Exit zero-gravity mode and hold current position."""
|
|
687
|
+
if self._can is not None and self._enabled:
|
|
688
|
+
self._hold_position()
|
|
689
|
+
log.info("Zero-gravity mode exited; holding position")
|
|
690
|
+
|
|
691
|
+
def _hold_position(self) -> None:
|
|
692
|
+
"""Send one MIT frame holding the current position under the configured gains.
|
|
693
|
+
|
|
694
|
+
The one "hold, do not go slack" primitive in this SDK: the teleop
|
|
695
|
+
master and a stopping trajectory replay both need it, and neither wants
|
|
696
|
+
zero-gravity mode's log line. It is one frame, not a sustained hold —
|
|
697
|
+
the motor self-locks a communication-loss fault about 100 ms after the
|
|
698
|
+
frames stop, so a caller that needs the jaws held longer has to keep
|
|
699
|
+
sending (or call a motion action).
|
|
700
|
+
"""
|
|
701
|
+
if self._can is None or not self._enabled:
|
|
702
|
+
return
|
|
703
|
+
# Under the same lock as every other CAN operation: a caller normally
|
|
704
|
+
# joins its loop before holding, but a join that times out would
|
|
705
|
+
# otherwise let this frame overtake one still being written.
|
|
706
|
+
with self._io_lock:
|
|
707
|
+
current_pos = self._can.get_position()
|
|
708
|
+
self._can.control_mit(q_target=current_pos, kp=self._config.kp,
|
|
709
|
+
kd=self._config.kd, tau_feedforward=0)
|
|
710
|
+
|
|
711
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
712
|
+
# Manual calibration (zero-gravity assisted)
|
|
713
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
714
|
+
|
|
715
|
+
def calibrate_manual(
|
|
716
|
+
self,
|
|
717
|
+
duration: float = 30.0,
|
|
718
|
+
settle_time: float = 2.0,
|
|
719
|
+
sample_interval: float = 0.01,
|
|
720
|
+
) -> CalibrationData:
|
|
721
|
+
"""Calibrate by manually moving the gripper in zero-gravity mode.
|
|
722
|
+
|
|
723
|
+
The motor enters zero-torque mode so you can freely push/pull the
|
|
724
|
+
gripper jaws through their full range. The SDK records the two
|
|
725
|
+
extremes reached — which one is "closed" depends on the mounting
|
|
726
|
+
direction declared by the current configuration (see
|
|
727
|
+
:attr:`GripperConfig.close_sign`), not on which is numerically larger.
|
|
728
|
+
|
|
729
|
+
Usage::
|
|
730
|
+
|
|
731
|
+
with LiteGrip("can0") as gripper:
|
|
732
|
+
gripper.enable()
|
|
733
|
+
result = gripper.calibrate_manual(duration=30.0)
|
|
734
|
+
print(f"行程: {result.travel_mm:.1f} mm")
|
|
735
|
+
|
|
736
|
+
**Procedure:**
|
|
737
|
+
|
|
738
|
+
1. Call this method — the gripper goes limp (zero torque).
|
|
739
|
+
2. Manually push the jaws fully closed, then fully open.
|
|
740
|
+
3. Repeat a few times to ensure limits are captured.
|
|
741
|
+
4. The method returns after *duration* seconds, or press Ctrl+C
|
|
742
|
+
to stop early and keep the best readings so far.
|
|
743
|
+
|
|
744
|
+
Args:
|
|
745
|
+
duration: Max recording time in seconds.
|
|
746
|
+
settle_time: Extra seconds at the end to hold position before
|
|
747
|
+
exiting zero-gravity.
|
|
748
|
+
sample_interval: Polling interval in seconds (~100 Hz default).
|
|
749
|
+
|
|
750
|
+
Returns:
|
|
751
|
+
CalibrationData with zero_position, max_position, travel_range,
|
|
752
|
+
and updated rad_to_mm. The internal :attr:`config` is also updated.
|
|
753
|
+
"""
|
|
754
|
+
self._check_connected()
|
|
755
|
+
self._check_enabled()
|
|
756
|
+
if self._can is None:
|
|
757
|
+
raise NotInitializedError("未连接")
|
|
758
|
+
|
|
759
|
+
print("=" * 60)
|
|
760
|
+
print(" 零重力手动标定")
|
|
761
|
+
print("=" * 60)
|
|
762
|
+
print()
|
|
763
|
+
print(" 夹爪已进入零重力模式,可以自由用手掰动。")
|
|
764
|
+
print()
|
|
765
|
+
print(" 操作步骤:")
|
|
766
|
+
print(" 1. 将夹爪推到完全闭合位置")
|
|
767
|
+
print(" 2. 将夹爪拉到完全张开位置")
|
|
768
|
+
print(" 3. 反复推拉几次确保极限被抓到")
|
|
769
|
+
print()
|
|
770
|
+
print(f" 标定将持续 {duration:.0f} 秒(可按 Ctrl+C 提前结束)")
|
|
771
|
+
print("=" * 60)
|
|
772
|
+
print()
|
|
773
|
+
|
|
774
|
+
# Track the two extremes; the mounting direction (close_sign) decides
|
|
775
|
+
# which of them is the closed limit. A normal mount closes at the
|
|
776
|
+
# larger rad value, a reverse mount at the smaller one.
|
|
777
|
+
s = self._config.close_sign
|
|
778
|
+
lo_rad = float("+inf") # most negative rad seen
|
|
779
|
+
hi_rad = float("-inf") # most positive rad seen
|
|
780
|
+
sample_count = 0
|
|
781
|
+
|
|
782
|
+
def _label(rad: float) -> str:
|
|
783
|
+
at_lo = rad <= lo_rad
|
|
784
|
+
if s > 0:
|
|
785
|
+
return "张开极限" if at_lo else "闭合极限"
|
|
786
|
+
return "闭合极限" if at_lo else "张开极限"
|
|
787
|
+
|
|
788
|
+
deadline = time.monotonic() + duration
|
|
789
|
+
|
|
790
|
+
try:
|
|
791
|
+
while time.monotonic() < deadline:
|
|
792
|
+
# Stream zero-torque MIT frame
|
|
793
|
+
self._can.control_mit(q_target=0, kp=0, kd=0, tau_feedforward=0)
|
|
794
|
+
|
|
795
|
+
# Poll for latest position
|
|
796
|
+
self._can.poll(timeout_s=0.0)
|
|
797
|
+
|
|
798
|
+
pos = self._can.get_position()
|
|
799
|
+
# Only count positions that look like real feedback
|
|
800
|
+
if abs(pos) < 50.0:
|
|
801
|
+
sample_count += 1
|
|
802
|
+
if pos < lo_rad:
|
|
803
|
+
lo_rad = pos
|
|
804
|
+
print(f" ★ 新{_label(pos)}: {pos:.6f} rad")
|
|
805
|
+
elif pos > hi_rad:
|
|
806
|
+
hi_rad = pos
|
|
807
|
+
print(f" ★ 新{_label(pos)}: {pos:.6f} rad")
|
|
808
|
+
|
|
809
|
+
# Progress indicator (every ~1 second)
|
|
810
|
+
remaining = deadline - time.monotonic()
|
|
811
|
+
if sample_count % 100 == 0 and sample_count > 0:
|
|
812
|
+
print(f" ... 剩余 {remaining:.0f}s | 当前 pos={pos:.4f} rad "
|
|
813
|
+
f"| lo={lo_rad:.4f} hi={hi_rad:.4f}")
|
|
814
|
+
|
|
815
|
+
time.sleep(sample_interval)
|
|
816
|
+
|
|
817
|
+
except KeyboardInterrupt:
|
|
818
|
+
print("\n ⏎ 用户提前结束标定")
|
|
819
|
+
|
|
820
|
+
# ── Settle ──────────────────────────────────────────────────────
|
|
821
|
+
print(f"\n 保持零重力 {settle_time:.0f} 秒(稳定位置)...")
|
|
822
|
+
settle_deadline = time.monotonic() + settle_time
|
|
823
|
+
while time.monotonic() < settle_deadline:
|
|
824
|
+
self._can.control_mit(q_target=0, kp=0, kd=0, tau_feedforward=0)
|
|
825
|
+
self._can.poll(timeout_s=0.0)
|
|
826
|
+
pos = self._can.get_position()
|
|
827
|
+
lo_rad = min(lo_rad, pos)
|
|
828
|
+
hi_rad = max(hi_rad, pos)
|
|
829
|
+
time.sleep(sample_interval)
|
|
830
|
+
|
|
831
|
+
# ── Exit zero-gravity ───────────────────────────────────────────
|
|
832
|
+
self.exit_zero_gravity()
|
|
833
|
+
time.sleep(0.1)
|
|
834
|
+
|
|
835
|
+
# ── Validate ────────────────────────────────────────────────────
|
|
836
|
+
if lo_rad == float("+inf"):
|
|
837
|
+
raise RuntimeError(
|
|
838
|
+
"标定失败:未能捕获有效的位置范围。"
|
|
839
|
+
"请确保夹爪使能正常且有反馈。"
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
close_rad, open_rad = (hi_rad, lo_rad) if s > 0 else (lo_rad, hi_rad)
|
|
843
|
+
|
|
844
|
+
travel = abs(close_rad - open_rad)
|
|
845
|
+
if travel <= 0:
|
|
846
|
+
raise RuntimeError(
|
|
847
|
+
f"标定失败:行程异常 ({travel:.6f} rad)。"
|
|
848
|
+
"请重新标定。"
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
rad_to_mm = _scale_from_travel(self._config, travel)
|
|
852
|
+
|
|
853
|
+
result = CalibrationData(
|
|
854
|
+
zero_position=round(close_rad, 6), # closed → 0 mm
|
|
855
|
+
max_position=round(open_rad, 6), # open → max mm
|
|
856
|
+
travel_range=round(travel, 6),
|
|
857
|
+
rad_to_mm=round(rad_to_mm, 2),
|
|
858
|
+
motor_type=self._motor_type.name,
|
|
859
|
+
can_id=self._can_id,
|
|
860
|
+
mst_id=self._mst_id or 0,
|
|
861
|
+
calibration_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
# Update config. The ordering the two values end up in is the
|
|
865
|
+
# direction declaration: close on the close_sign side, open on the
|
|
866
|
+
# other, whatever the mount.
|
|
867
|
+
self._config.pos_closed_rad = result.zero_position
|
|
868
|
+
self._config.pos_open_rad = result.max_position
|
|
869
|
+
self._config.rad_to_mm = result.rad_to_mm
|
|
870
|
+
self._config.calibrated = True
|
|
871
|
+
|
|
872
|
+
print(f"\n{'=' * 60}")
|
|
873
|
+
print(f" 标定完成")
|
|
874
|
+
print(f"{'=' * 60}")
|
|
875
|
+
print(f" 样本数: {sample_count}")
|
|
876
|
+
print(f" 闭合极限: {result.zero_position:.6f} rad (0.0 mm)")
|
|
877
|
+
print(f" 张开极限: {result.max_position:.6f} rad "
|
|
878
|
+
f"({result.travel_mm:.1f} mm)")
|
|
879
|
+
print(f" 行程: {result.travel_range:.6f} rad "
|
|
880
|
+
f"({result.travel_mm:.1f} mm)")
|
|
881
|
+
print(f" 转换系数: {result.rad_to_mm:.1f} mm/rad")
|
|
882
|
+
print(f"{'=' * 60}")
|
|
883
|
+
|
|
884
|
+
return result
|
|
885
|
+
|
|
886
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
887
|
+
# Guided calibration (step-by-step, user confirms each limit)
|
|
888
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
889
|
+
|
|
890
|
+
def calibrate_guided(
|
|
891
|
+
self,
|
|
892
|
+
kp: float = 20.0,
|
|
893
|
+
kd: float = 2.0,
|
|
894
|
+
step_rad: float = 0.05,
|
|
895
|
+
stall_delta: float = 0.0015,
|
|
896
|
+
stall_cycles: int = 5,
|
|
897
|
+
max_iter: int = 200,
|
|
898
|
+
tau_limit: Optional[float] = 2.0,
|
|
899
|
+
) -> CalibrationData:
|
|
900
|
+
"""Guided two-step calibration with user confirmation at each limit.
|
|
901
|
+
|
|
902
|
+
The gripper moves itself (low stiffness) toward each limit. You
|
|
903
|
+
press Enter when the limit is reached.
|
|
904
|
+
|
|
905
|
+
**Procedure:**
|
|
906
|
+
|
|
907
|
+
1. Gripper steps toward the OPEN direction. Watch the position.
|
|
908
|
+
Press Enter when fully open (or when it stalls at the hard stop).
|
|
909
|
+
2. Gripper steps toward the CLOSE direction. Press Enter when
|
|
910
|
+
fully closed.
|
|
911
|
+
3. Calibration is saved to :attr:`config` automatically.
|
|
912
|
+
|
|
913
|
+
Args:
|
|
914
|
+
kp: Probing stiffness (low = gentle).
|
|
915
|
+
kd: Probing damping.
|
|
916
|
+
step_rad: Step size per iteration (rad).
|
|
917
|
+
stall_delta: Position delta for auto-stall detection (rad).
|
|
918
|
+
stall_cycles: Consecutive stalls to auto-confirm limit.
|
|
919
|
+
max_iter: Max steps per direction.
|
|
920
|
+
tau_limit: Torque ceiling in Nm; the probe stops when ``|tau|``
|
|
921
|
+
reaches it (``None`` disables the ceiling). The command lead is
|
|
922
|
+
already bounded to ``step_rad`` here, so this is the second,
|
|
923
|
+
independent guard.
|
|
924
|
+
|
|
925
|
+
Returns:
|
|
926
|
+
CalibrationData; :attr:`config` is updated in-place.
|
|
927
|
+
"""
|
|
928
|
+
self._check_connected()
|
|
929
|
+
self._check_enabled()
|
|
930
|
+
if self._can is None:
|
|
931
|
+
raise NotInitializedError("未连接")
|
|
932
|
+
|
|
933
|
+
print("=" * 60)
|
|
934
|
+
print(" 引导式标定")
|
|
935
|
+
print("=" * 60)
|
|
936
|
+
print()
|
|
937
|
+
print(" 夹爪将自动缓慢移动。到达极限时按 Enter 确认。")
|
|
938
|
+
|
|
939
|
+
s = self._config.close_sign
|
|
940
|
+
print(f" 方向:{'正向' if s > 0 else '反装'}(由当前配置的限位顺序推出)")
|
|
941
|
+
print()
|
|
942
|
+
|
|
943
|
+
def _step_to_limit(direction: str, sign: float, label: str) -> float:
|
|
944
|
+
"""Step in *direction* until user presses Enter or stall."""
|
|
945
|
+
print(f" [{direction}] 正在{label}...")
|
|
946
|
+
print(f" 按 Enter 确认到达{label},或等待自动检测堵转")
|
|
947
|
+
print()
|
|
948
|
+
|
|
949
|
+
self._can.update_state(timeout_s=0.05)
|
|
950
|
+
current = self._can.get_position()
|
|
951
|
+
stall = 0
|
|
952
|
+
|
|
953
|
+
for i in range(max_iter):
|
|
954
|
+
target = current + sign * step_rad
|
|
955
|
+
self._can.control_mit_stream(
|
|
956
|
+
target, kp, kd, duration_s=0.3, interval_s=0.005)
|
|
957
|
+
self._can.update_state(timeout_s=0.1)
|
|
958
|
+
|
|
959
|
+
new_pos = self._can.get_position()
|
|
960
|
+
delta = abs(new_pos - current)
|
|
961
|
+
tau = self._can.get_torque()
|
|
962
|
+
|
|
963
|
+
# Non-blocking keyboard check
|
|
964
|
+
hit_enter = False
|
|
965
|
+
if sys.stdin.isatty():
|
|
966
|
+
r, _, _ = _select_mod.select([sys.stdin], [], [], 0)
|
|
967
|
+
if r:
|
|
968
|
+
sys.stdin.readline()
|
|
969
|
+
hit_enter = True
|
|
970
|
+
|
|
971
|
+
print(f" [{i}] pos={new_pos:.4f} rad d={delta:.5f} "
|
|
972
|
+
f"tau={tau:+.3f} stall={stall}", end="")
|
|
973
|
+
if hit_enter:
|
|
974
|
+
print(" ← 用户确认")
|
|
975
|
+
return new_pos
|
|
976
|
+
|
|
977
|
+
if tau_limit is not None and abs(tau) >= tau_limit:
|
|
978
|
+
print(f" ← 力矩达到上限 {tau_limit:.2f} Nm({tau:+.3f})")
|
|
979
|
+
print(f" → 停止推进并保持: {new_pos:.6f} rad")
|
|
980
|
+
return new_pos
|
|
981
|
+
|
|
982
|
+
if delta < stall_delta:
|
|
983
|
+
stall += 1
|
|
984
|
+
print(f" (堵转检测中)")
|
|
985
|
+
if stall >= stall_cycles:
|
|
986
|
+
print(f" → 自动检测到{label}: {new_pos:.6f} rad")
|
|
987
|
+
return new_pos
|
|
988
|
+
else:
|
|
989
|
+
stall = 0
|
|
990
|
+
print()
|
|
991
|
+
|
|
992
|
+
current = new_pos
|
|
993
|
+
|
|
994
|
+
print(f" → 安全停止(达到最大步数 {max_iter}): {current:.4f} rad")
|
|
995
|
+
return current
|
|
996
|
+
|
|
997
|
+
# ── Step 1: Open ─────────────────────────────────────────────────
|
|
998
|
+
print("━" * 60)
|
|
999
|
+
print(" 第 1 步:张开")
|
|
1000
|
+
print("━" * 60)
|
|
1001
|
+
open_rad = _step_to_limit("open", sign=-s, label="张开极限")
|
|
1002
|
+
|
|
1003
|
+
# Small back-off, away from the open stop toward the close side
|
|
1004
|
+
print(f"\n 回退一小段...")
|
|
1005
|
+
self._can.control_mit_stream(open_rad + s * 0.15, kp=80, kd=kd,
|
|
1006
|
+
duration_s=0.5, interval_s=0.005)
|
|
1007
|
+
time.sleep(0.1)
|
|
1008
|
+
|
|
1009
|
+
# ── Step 2: Close ────────────────────────────────────────────────
|
|
1010
|
+
print(f"\n{'━' * 60}")
|
|
1011
|
+
print(" 第 2 步:闭合")
|
|
1012
|
+
print("━" * 60)
|
|
1013
|
+
close_rad = _step_to_limit("close", sign=+s, label="闭合极限")
|
|
1014
|
+
|
|
1015
|
+
# ── Compute ──────────────────────────────────────────────────────
|
|
1016
|
+
travel = abs(close_rad - open_rad)
|
|
1017
|
+
if travel <= 0:
|
|
1018
|
+
raise RuntimeError(f"行程异常: close={close_rad:.4f} == open={open_rad:.4f}")
|
|
1019
|
+
|
|
1020
|
+
rad_to_mm = _scale_from_travel(self._config, travel)
|
|
1021
|
+
|
|
1022
|
+
result = CalibrationData(
|
|
1023
|
+
zero_position=round(close_rad, 6),
|
|
1024
|
+
max_position=round(open_rad, 6),
|
|
1025
|
+
travel_range=round(travel, 6),
|
|
1026
|
+
rad_to_mm=round(rad_to_mm, 2),
|
|
1027
|
+
motor_type=self._motor_type.name,
|
|
1028
|
+
can_id=self._can_id,
|
|
1029
|
+
mst_id=self._mst_id or 0,
|
|
1030
|
+
calibration_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
1031
|
+
)
|
|
1032
|
+
|
|
1033
|
+
# Update config
|
|
1034
|
+
self._config.pos_closed_rad = result.zero_position
|
|
1035
|
+
self._config.pos_open_rad = result.max_position
|
|
1036
|
+
self._config.rad_to_mm = result.rad_to_mm
|
|
1037
|
+
self._config.calibrated = True
|
|
1038
|
+
|
|
1039
|
+
print(f"\n{'=' * 60}")
|
|
1040
|
+
print(f" 标定完成")
|
|
1041
|
+
print(f"{'=' * 60}")
|
|
1042
|
+
print(f" 闭合(0mm): {result.zero_position:.6f} rad")
|
|
1043
|
+
print(f" 张开({self._config.max_stroke_mm:.0f}mm): "
|
|
1044
|
+
f"{result.max_position:.6f} rad")
|
|
1045
|
+
print(f" 行程: {result.travel_range:.6f} rad "
|
|
1046
|
+
f"({result.travel_mm:.1f} mm)")
|
|
1047
|
+
print(f" 转换系数: {result.rad_to_mm:.1f} mm/rad")
|
|
1048
|
+
print(f"{'=' * 60}")
|
|
1049
|
+
|
|
1050
|
+
return result
|
|
1051
|
+
|
|
1052
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
1053
|
+
# Calibration persistence
|
|
1054
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
1055
|
+
|
|
1056
|
+
def save_calibration(self, path: Optional[str] = None) -> str:
|
|
1057
|
+
"""Save current calibration and settings to a JSON file.
|
|
1058
|
+
|
|
1059
|
+
Args:
|
|
1060
|
+
path: Destination file. When omitted, saves to this channel's own
|
|
1061
|
+
path (:func:`default_calib_path`, i.e.
|
|
1062
|
+
``~/.litegrip/<channel>_calibration.json``). One file per
|
|
1063
|
+
channel is what keeps two grippers on one machine from
|
|
1064
|
+
overwriting each other. The factory calibration shipped
|
|
1065
|
+
with the SDK is never overwritten; back it up separately if
|
|
1066
|
+
needed.
|
|
1067
|
+
|
|
1068
|
+
A no-argument :meth:`load_calibration` reads the factory calibration
|
|
1069
|
+
only, so what this writes is *not* read back on its own: name it
|
|
1070
|
+
(``load_calibration(path=<returned>)``) or point ``LITEGRIP_CALIB`` at
|
|
1071
|
+
it. A warning says so at save time.
|
|
1072
|
+
|
|
1073
|
+
Returns:
|
|
1074
|
+
The absolute path the calibration was written to.
|
|
1075
|
+
"""
|
|
1076
|
+
import json
|
|
1077
|
+
if path is None:
|
|
1078
|
+
path = default_calib_path(self._channel)
|
|
1079
|
+
data = {
|
|
1080
|
+
"channel": self._channel,
|
|
1081
|
+
"can_id": self._can_id,
|
|
1082
|
+
# Only stamp mst_id when it is actually known. Writing a falsy 0
|
|
1083
|
+
# would pin auto-detection: load_calibration would then install a
|
|
1084
|
+
# RX filter of 0x000, every reply from the motor would be dropped,
|
|
1085
|
+
# and enable() would fail after a long retry loop. Omitting it
|
|
1086
|
+
# keeps the value "unknown", which connect() reads as auto-detect.
|
|
1087
|
+
"canfd_mode": self._canfd_mode or False,
|
|
1088
|
+
"calibrated": True,
|
|
1089
|
+
# Which of zero/max is numerically larger is what carries the
|
|
1090
|
+
# mounting direction; there is no separate field for it.
|
|
1091
|
+
"zero_position_rad": self._config.pos_closed_rad,
|
|
1092
|
+
"max_position_rad": self._config.pos_open_rad,
|
|
1093
|
+
"travel_range_rad": abs(self._config.pos_open_rad - self._config.pos_closed_rad),
|
|
1094
|
+
"rad_to_mm": self._config.rad_to_mm,
|
|
1095
|
+
"work_stroke_mm": self._config.work_stroke_mm,
|
|
1096
|
+
"motor_type": self._motor_type.name,
|
|
1097
|
+
"kp": self._config.kp,
|
|
1098
|
+
"kd": self._config.kd,
|
|
1099
|
+
"grasp_torque_threshold": self._config.grasp_torque_threshold,
|
|
1100
|
+
}
|
|
1101
|
+
if self._mst_id:
|
|
1102
|
+
data["mst_id"] = self._mst_id
|
|
1103
|
+
parent = _os.path.dirname(path)
|
|
1104
|
+
if parent:
|
|
1105
|
+
_os.makedirs(parent, exist_ok=True)
|
|
1106
|
+
with open(path, "w") as f:
|
|
1107
|
+
json.dump(data, f, indent=2)
|
|
1108
|
+
log.info("Calibration saved to %s", path)
|
|
1109
|
+
log.warning(
|
|
1110
|
+
"The factory calibration stays the standard value, so this file is "
|
|
1111
|
+
"not read back on its own. To use it: load_calibration(path=%r), or "
|
|
1112
|
+
"LITEGRIP_CALIB=%s.", path, path)
|
|
1113
|
+
return path
|
|
1114
|
+
|
|
1115
|
+
def load_calibration(self, path: Optional[str] = None,
|
|
1116
|
+
template: Optional[str] = None) -> bool:
|
|
1117
|
+
"""Load calibration from a JSON file into :attr:`config`.
|
|
1118
|
+
|
|
1119
|
+
Three ways to say which file, and they are mutually exclusive:
|
|
1120
|
+
|
|
1121
|
+
* ``template="reverse"`` — a name from :data:`CALIB_TEMPLATES`
|
|
1122
|
+
(:func:`list_templates`). Strict: if that template cannot be read
|
|
1123
|
+
this raises instead of falling back, because the fallback would be
|
|
1124
|
+
the factory file, and that file is a *normal* mount — answering a
|
|
1125
|
+
request for reverse with normal is the one failure the name exists
|
|
1126
|
+
to prevent.
|
|
1127
|
+
* ``path=...`` — an explicit file, which may be anywhere (including a
|
|
1128
|
+
template's path). Falls back to the built-in factory calibration
|
|
1129
|
+
if it cannot be read.
|
|
1130
|
+
* neither — the factory calibration the SDK ships, and nothing else.
|
|
1131
|
+
A calibration measured on this machine is never picked up by
|
|
1132
|
+
accident: name it with ``path=``, or point ``LITEGRIP_CALIB`` at it
|
|
1133
|
+
(the env var is read here, ahead of the factory file).
|
|
1134
|
+
|
|
1135
|
+
The factory file declares ``channel: can0``, so a ``can1`` unit gets
|
|
1136
|
+
nothing from a no-argument load — it fails loudly rather than
|
|
1137
|
+
silently adopting ``can0``'s direction. Give it a calibration with
|
|
1138
|
+
``mount=``, ``path=`` or ``LITEGRIP_CALIB``.
|
|
1139
|
+
|
|
1140
|
+
Any file in ``~/.litegrip`` that this load passed over is named in a
|
|
1141
|
+
warning, with the call that would use it.
|
|
1142
|
+
|
|
1143
|
+
Call this after :meth:`connect` but before :meth:`enable`.
|
|
1144
|
+
|
|
1145
|
+
The limits in the file decide the direction: whichever of the two is
|
|
1146
|
+
numerically larger is the closed side. Loading a template is how a
|
|
1147
|
+
reverse-mounted gripper is declared, and the direction a file implies
|
|
1148
|
+
is logged.
|
|
1149
|
+
|
|
1150
|
+
Args:
|
|
1151
|
+
path: JSON file path.
|
|
1152
|
+
template: Template name (``"normal"`` / ``"reverse"``).
|
|
1153
|
+
|
|
1154
|
+
Returns:
|
|
1155
|
+
True if loaded successfully.
|
|
1156
|
+
|
|
1157
|
+
Raises:
|
|
1158
|
+
CommandError: both *path* and *template* given, an unknown
|
|
1159
|
+
template name, or a readable-but-missing template file.
|
|
1160
|
+
"""
|
|
1161
|
+
import json
|
|
1162
|
+
|
|
1163
|
+
if path is not None and template is not None:
|
|
1164
|
+
raise CommandError(
|
|
1165
|
+
"load_calibration() 的 path 与 template 只能给一个:path 是显式"
|
|
1166
|
+
"文件,template 是 CALIB_TEMPLATES 里的名字。")
|
|
1167
|
+
|
|
1168
|
+
if template is not None:
|
|
1169
|
+
sources = [_resolve_template(template)]
|
|
1170
|
+
strict = True
|
|
1171
|
+
skip_other_channels = False
|
|
1172
|
+
report_ignored_local = False
|
|
1173
|
+
elif path is not None:
|
|
1174
|
+
sources = [path, _FACTORY_CALIB]
|
|
1175
|
+
strict = False
|
|
1176
|
+
skip_other_channels = False
|
|
1177
|
+
report_ignored_local = False
|
|
1178
|
+
else:
|
|
1179
|
+
# 出厂标定是这台夹爪的标准值。本机文件一律要显式点名才读 ——
|
|
1180
|
+
# 一个来路不明的遗留文件不该悄悄盖过它。
|
|
1181
|
+
env = _os.environ.get("LITEGRIP_CALIB")
|
|
1182
|
+
sources = _dedupe([p for p in (env, _FACTORY_CALIB) if p])
|
|
1183
|
+
strict = False
|
|
1184
|
+
skip_other_channels = True
|
|
1185
|
+
report_ignored_local = env is None
|
|
1186
|
+
|
|
1187
|
+
data = None
|
|
1188
|
+
loaded_from: str = ""
|
|
1189
|
+
for src in sources:
|
|
1190
|
+
try:
|
|
1191
|
+
with open(src, "r") as f:
|
|
1192
|
+
candidate = json.load(f)
|
|
1193
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
1194
|
+
continue
|
|
1195
|
+
if skip_other_channels:
|
|
1196
|
+
file_channel = candidate.get("channel")
|
|
1197
|
+
if file_channel and self._channel and file_channel != self._channel:
|
|
1198
|
+
log.info("跳过 %s:它声明的 channel=%s 不是本实例的 %s",
|
|
1199
|
+
src, file_channel, self._channel)
|
|
1200
|
+
continue
|
|
1201
|
+
data = candidate
|
|
1202
|
+
loaded_from = src
|
|
1203
|
+
break
|
|
1204
|
+
|
|
1205
|
+
if report_ignored_local:
|
|
1206
|
+
_warn_ignored_local_calibrations(self._channel)
|
|
1207
|
+
|
|
1208
|
+
if data is None:
|
|
1209
|
+
if strict:
|
|
1210
|
+
raise CommandError(
|
|
1211
|
+
f"标定模板 {template!r} 读不出来({sources[0]})。不作出厂"
|
|
1212
|
+
f"回退 —— 出厂文件是正装,用它会静默把方向换成正装。")
|
|
1213
|
+
log.warning("No calibration found (tried: %s). "
|
|
1214
|
+
"Run zero() first, or load a template.",
|
|
1215
|
+
", ".join(sources))
|
|
1216
|
+
return False
|
|
1217
|
+
|
|
1218
|
+
log.info("Calibration loaded from %s", loaded_from)
|
|
1219
|
+
|
|
1220
|
+
self._config.pos_closed_rad = float(data["zero_position_rad"])
|
|
1221
|
+
self._config.pos_open_rad = float(data["max_position_rad"])
|
|
1222
|
+
self._config.rad_to_mm = float(data["rad_to_mm"])
|
|
1223
|
+
|
|
1224
|
+
# Optional fields (present in newer calibration files)
|
|
1225
|
+
for key, attr in [
|
|
1226
|
+
("can_id", "can_id"),
|
|
1227
|
+
("mst_id", "mst_id"),
|
|
1228
|
+
("channel", "can_channel"),
|
|
1229
|
+
("canfd_mode", "canfd_mode"),
|
|
1230
|
+
("kp", "kp"),
|
|
1231
|
+
("kd", "kd"),
|
|
1232
|
+
("grasp_torque_threshold", "grasp_torque_threshold"),
|
|
1233
|
+
("work_stroke_mm", "work_stroke_mm"),
|
|
1234
|
+
]:
|
|
1235
|
+
if key in data:
|
|
1236
|
+
setattr(self._config, attr, data[key])
|
|
1237
|
+
|
|
1238
|
+
# Older calibration files predate the flag and always came from a real
|
|
1239
|
+
# calibration run, so absence means calibrated.
|
|
1240
|
+
self._config.calibrated = bool(data.get("calibrated", True))
|
|
1241
|
+
|
|
1242
|
+
# The channel is the only thing that tells two grippers apart when both
|
|
1243
|
+
# sit at CAN ID 0x08, so a file that names another channel is worth
|
|
1244
|
+
# flagging — but it is not fatal, since older files may omit the key.
|
|
1245
|
+
file_channel = data.get("channel")
|
|
1246
|
+
if file_channel and self._channel and file_channel != self._channel:
|
|
1247
|
+
log.warning("标定文件的 channel=%s 与本实例的 %s 不一致 —— "
|
|
1248
|
+
"同一台电脑上多台夹爪共用 CAN ID 时,通道是唯一身份键,"
|
|
1249
|
+
"确认没有指错文件。", file_channel, self._channel)
|
|
1250
|
+
|
|
1251
|
+
# Also update instance-level IDs if present. A falsy value counts as
|
|
1252
|
+
# "unknown", not as ID 0: files written before this SDK started omitting
|
|
1253
|
+
# an unset mst_id (and hand-edited ones) may carry ``"mst_id": 0``, which
|
|
1254
|
+
# would otherwise pin the CAN RX filter to 0x000 and drop every reply.
|
|
1255
|
+
if data.get("can_id"):
|
|
1256
|
+
self._can_id = int(data["can_id"])
|
|
1257
|
+
if data.get("mst_id"):
|
|
1258
|
+
self._mst_id = int(data["mst_id"])
|
|
1259
|
+
|
|
1260
|
+
s = self._config.close_sign
|
|
1261
|
+
log.info("Calibration loaded from %s: range=%.1f mm,方向=%s",
|
|
1262
|
+
loaded_from,
|
|
1263
|
+
abs(self._config.pos_open_rad - self._config.pos_closed_rad)
|
|
1264
|
+
* self._config.rad_to_mm,
|
|
1265
|
+
"正向(rad 增大 = 闭合)" if s > 0 else "反装(rad 减小 = 闭合)")
|
|
1266
|
+
return True
|
|
1267
|
+
|
|
1268
|
+
def load_template(self, name: str) -> bool:
|
|
1269
|
+
"""Load a pre-made calibration template by name, declaring the mount.
|
|
1270
|
+
|
|
1271
|
+
Names come from :func:`list_templates` (``"normal"`` / ``"reverse"``).
|
|
1272
|
+
Thin wrapper over ``load_calibration(template=name)`` so the name is
|
|
1273
|
+
resolved in one place; see that method for why a template never falls
|
|
1274
|
+
back to the factory file.
|
|
1275
|
+
|
|
1276
|
+
Args:
|
|
1277
|
+
name: Template name.
|
|
1278
|
+
|
|
1279
|
+
Returns:
|
|
1280
|
+
True once loaded.
|
|
1281
|
+
|
|
1282
|
+
Raises:
|
|
1283
|
+
CommandError: unknown name, or the template file is unreadable.
|
|
1284
|
+
"""
|
|
1285
|
+
return self.load_calibration(template=name)
|
|
1286
|
+
|
|
1287
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
1288
|
+
# Motion — high-level
|
|
1289
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
1290
|
+
|
|
1291
|
+
def home(self) -> MoveResult:
|
|
1292
|
+
"""Ramp to the closed (zero) position and stop there.
|
|
1293
|
+
|
|
1294
|
+
Uses this instance's calibrated closed limit, so a reverse-mounted
|
|
1295
|
+
gripper homes to the correct end. The stop is the closed end of the
|
|
1296
|
+
travel, not a squeeze: the ramp's command lead is capped at
|
|
1297
|
+
:attr:`MotionConfig.max_lead_mm`, so the jaws arrive without pressing.
|
|
1298
|
+
|
|
1299
|
+
Raises:
|
|
1300
|
+
CommandError: the configuration is uncalibrated — the "calibrated
|
|
1301
|
+
closed limit" is a placeholder default in that state. See
|
|
1302
|
+
:meth:`goto_rad`.
|
|
1303
|
+
"""
|
|
1304
|
+
self._check_connected()
|
|
1305
|
+
self._check_enabled()
|
|
1306
|
+
return self.move_to(self._config.pos_closed_rad, duration=1.0)
|
|
1307
|
+
|
|
1308
|
+
def open(
|
|
1309
|
+
self,
|
|
1310
|
+
speed_mm_s: Optional[float] = None,
|
|
1311
|
+
*,
|
|
1312
|
+
progress: Optional[Callable[[MoveProgress], None]] = None,
|
|
1313
|
+
) -> MoveResult:
|
|
1314
|
+
"""Open the gripper.
|
|
1315
|
+
|
|
1316
|
+
By default a continuous ramp (velocity feed-forward, one frame per
|
|
1317
|
+
:attr:`MotionConfig.frame_interval`) toward the open end. The command
|
|
1318
|
+
lead is narrowed to :attr:`MotionConfig.stop_lead_mm` inside
|
|
1319
|
+
:attr:`MotionConfig.press_zone_mm` of the limit, and every emitted
|
|
1320
|
+
command position is capped at the calibrated travel, so the jaw stops
|
|
1321
|
+
on the calibrated open limit and is never driven past it. That cap is
|
|
1322
|
+
what makes a stale calibration, or a mechanical stop that is not where
|
|
1323
|
+
the calibration says, bound the travel instead of letting the jaw run
|
|
1324
|
+
on. The move ends on stall detection.
|
|
1325
|
+
|
|
1326
|
+
When the config carries a **work stroke** (:attr:`GripperConfig.work_stroke_mm`
|
|
1327
|
+
set and smaller than the mechanical travel) it stops there instead — a
|
|
1328
|
+
plain move to that position, leaving a margin at the open end.
|
|
1329
|
+
``MoveResult.ok`` is then ``reached and not stalled``.
|
|
1330
|
+
|
|
1331
|
+
Args:
|
|
1332
|
+
speed_mm_s: Opening speed; ``None`` = ``MotionConfig.speed_mm_s``.
|
|
1333
|
+
progress: Optional callback, called with a
|
|
1334
|
+
:class:`~litegrip.actions.MoveProgress` per sample.
|
|
1335
|
+
|
|
1336
|
+
Returns:
|
|
1337
|
+
:class:`~litegrip.actions.MoveResult` — with no work stroke, truthy
|
|
1338
|
+
when it stalled and parked within :attr:`MotionConfig.stop_tol` of
|
|
1339
|
+
the calibrated limit; stalling far from the limit means something
|
|
1340
|
+
blocked the travel, and is falsy. With a work stroke, truthy when
|
|
1341
|
+
it reached the work position.
|
|
1342
|
+
"""
|
|
1343
|
+
self._check_connected()
|
|
1344
|
+
return self._actions.open(speed_mm_s, progress=progress)
|
|
1345
|
+
|
|
1346
|
+
def close(
|
|
1347
|
+
self,
|
|
1348
|
+
speed_mm_s: Optional[float] = None,
|
|
1349
|
+
*,
|
|
1350
|
+
progress: Optional[Callable[[MoveProgress], None]] = None,
|
|
1351
|
+
) -> MoveResult:
|
|
1352
|
+
"""Close the gripper.
|
|
1353
|
+
|
|
1354
|
+
Same ramp as :meth:`open`, toward the closed side; the emitted command
|
|
1355
|
+
is capped at the calibrated travel, so the jaw stops on the calibrated
|
|
1356
|
+
closed limit and is never driven past it. Use :meth:`grasp` for a power
|
|
1357
|
+
grasp (closing onto an object and squeezing) — that one stops on the
|
|
1358
|
+
object, not on the empty travel end.
|
|
1359
|
+
|
|
1360
|
+
Args:
|
|
1361
|
+
speed_mm_s: Closing speed; ``None`` = ``MotionConfig.speed_mm_s``.
|
|
1362
|
+
progress: Optional progress callback.
|
|
1363
|
+
|
|
1364
|
+
Returns:
|
|
1365
|
+
:class:`~litegrip.actions.MoveResult`.
|
|
1366
|
+
"""
|
|
1367
|
+
self._check_connected()
|
|
1368
|
+
return self._actions.close(speed_mm_s, progress=progress)
|
|
1369
|
+
|
|
1370
|
+
def grasp(
|
|
1371
|
+
self,
|
|
1372
|
+
force_n: Optional[float] = None,
|
|
1373
|
+
hold_s: float = 0.0,
|
|
1374
|
+
*,
|
|
1375
|
+
progress: Optional[Callable[[MoveProgress], None]] = None,
|
|
1376
|
+
) -> GraspResult:
|
|
1377
|
+
"""Adaptive grasp — close until stall, then hold with a set force.
|
|
1378
|
+
|
|
1379
|
+
Blocking. Closes on a ramp (stall detection catches the object),
|
|
1380
|
+
then streams MIT frames carrying a feed-forward torque of
|
|
1381
|
+
``force_n × 0.1`` Nm and no position or velocity gain, so the grip
|
|
1382
|
+
force is the same whether the object yields or holds firm. The
|
|
1383
|
+
status is re-read every ``MotionConfig.hold_interval`` to catch a
|
|
1384
|
+
fault.
|
|
1385
|
+
|
|
1386
|
+
Args:
|
|
1387
|
+
force_n: Gripping force in N; ``None`` = ``MotionConfig.force_n``.
|
|
1388
|
+
hold_s: Hold time in seconds; ``0`` = hold until a fault or
|
|
1389
|
+
Ctrl+C.
|
|
1390
|
+
progress: Optional progress callback.
|
|
1391
|
+
|
|
1392
|
+
Returns:
|
|
1393
|
+
:class:`~litegrip.actions.GraspResult` — truthy when the hold
|
|
1394
|
+
ended normally. ``stalled=True`` with ``reached=False`` means
|
|
1395
|
+
it closed onto an object, which is the expected outcome.
|
|
1396
|
+
"""
|
|
1397
|
+
self._check_connected()
|
|
1398
|
+
return self._actions.grasp(force_n, hold_s, progress=progress)
|
|
1399
|
+
|
|
1400
|
+
def zero(self) -> CalibrationData:
|
|
1401
|
+
"""Full calibration: probe both mechanical limits and save.
|
|
1402
|
+
|
|
1403
|
+
The gripper is driven against each end stop with low stiffness.
|
|
1404
|
+
Make sure the travel is clear. Writes the result to the default
|
|
1405
|
+
user calibration path (see :meth:`save_calibration`).
|
|
1406
|
+
|
|
1407
|
+
Returns:
|
|
1408
|
+
:class:`CalibrationData`.
|
|
1409
|
+
"""
|
|
1410
|
+
self._check_connected()
|
|
1411
|
+
self._check_enabled()
|
|
1412
|
+
return self._actions.zero()
|
|
1413
|
+
|
|
1414
|
+
def write_zero(self) -> WriteZeroResult:
|
|
1415
|
+
"""Make the current encoder angle the motor's zero, in place (CMD 0xFE).
|
|
1416
|
+
|
|
1417
|
+
The jaws are left limp at whatever angle they are at, that angle is
|
|
1418
|
+
declared zero, and the angle is read back to confirm (~0). Unlike
|
|
1419
|
+
:meth:`zero`, this does **not** probe the end stops or touch the
|
|
1420
|
+
calibration file: it only rewrites the encoder offset, so the span
|
|
1421
|
+
already measured stays valid. ⚠ Irreversible — it writes the offset
|
|
1422
|
+
into the motor's flash.
|
|
1423
|
+
|
|
1424
|
+
A Damiao motor ignores 0xFE while it is enabled, so the motor is
|
|
1425
|
+
disabled first and re-enabled after (the order ``examples/zero_closed.py``
|
|
1426
|
+
uses).
|
|
1427
|
+
|
|
1428
|
+
Returns:
|
|
1429
|
+
:class:`~litegrip.actions.WriteZeroResult` — truthy when the
|
|
1430
|
+
read-back came back ~0.
|
|
1431
|
+
"""
|
|
1432
|
+
self._check_connected()
|
|
1433
|
+
self._check_enabled()
|
|
1434
|
+
if self._can is None:
|
|
1435
|
+
raise HardwareError("未连接")
|
|
1436
|
+
|
|
1437
|
+
# 1. Limp at the current angle, so the jaws stay exactly where the
|
|
1438
|
+
# operator put them, then read the pose we are about to zero.
|
|
1439
|
+
before = self._can.get_position()
|
|
1440
|
+
self._can.control_mit_stream(q_target=before, kp=0.0, kd=0.0,
|
|
1441
|
+
duration_s=0.3)
|
|
1442
|
+
self._can.update_state(timeout_s=0.3)
|
|
1443
|
+
before = self._can.get_position()
|
|
1444
|
+
|
|
1445
|
+
# 2. A DM motor ignores 0xFE while enabled — disable first.
|
|
1446
|
+
self.disable()
|
|
1447
|
+
time.sleep(0.02)
|
|
1448
|
+
if not self._can.set_zero():
|
|
1449
|
+
raise HardwareError("0xFE 未能发送(未连接或未注册)")
|
|
1450
|
+
time.sleep(0.05)
|
|
1451
|
+
|
|
1452
|
+
# 3. Re-enable, force fresh frames, read the new zero back.
|
|
1453
|
+
self.enable()
|
|
1454
|
+
self._can.control_mit_stream(q_target=0.0, kp=0.0, kd=0.0,
|
|
1455
|
+
duration_s=0.2)
|
|
1456
|
+
self._can.update_state(timeout_s=0.3)
|
|
1457
|
+
after = self._can.get_position()
|
|
1458
|
+
return WriteZeroResult(before_rad=before, after_rad=after)
|
|
1459
|
+
|
|
1460
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
1461
|
+
# Motion — mid-level
|
|
1462
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
1463
|
+
|
|
1464
|
+
def goto(
|
|
1465
|
+
self,
|
|
1466
|
+
position_mm: float,
|
|
1467
|
+
kp: Optional[float] = None,
|
|
1468
|
+
kd: Optional[float] = None,
|
|
1469
|
+
duration: float = 0.5,
|
|
1470
|
+
) -> MoveResult:
|
|
1471
|
+
"""Ramp to an absolute position in millimetres.
|
|
1472
|
+
|
|
1473
|
+
The target is one point on the closed-to-open travel; the move itself is
|
|
1474
|
+
the bounded ramp described in :meth:`goto_rad`.
|
|
1475
|
+
|
|
1476
|
+
Raises:
|
|
1477
|
+
CommandError: ``position_mm`` is not a finite number, or the
|
|
1478
|
+
configuration is uncalibrated — see :meth:`goto_rad`. A NaN has
|
|
1479
|
+
no position on the travel; letting it through would press the
|
|
1480
|
+
jaws onto one of the stops (the clamp below turns it into an end
|
|
1481
|
+
of the calibrated range), which is a move nobody asked for.
|
|
1482
|
+
"""
|
|
1483
|
+
self._check_connected()
|
|
1484
|
+
self._check_enabled()
|
|
1485
|
+
if not math.isfinite(position_mm):
|
|
1486
|
+
raise CommandError(
|
|
1487
|
+
f"目标位置不是有限数:position_mm={position_mm!r}。"
|
|
1488
|
+
"拒绝下发:NaN 落在下面的限位 clamp 里会变成量程的一端,"
|
|
1489
|
+
"也就是一个调用方没要过的真实位置。")
|
|
1490
|
+
# Opening may increase or decrease the motor rad count depending on the
|
|
1491
|
+
# mount; close_sign carries that, so this works for both.
|
|
1492
|
+
position_rad = (self._config.pos_closed_rad
|
|
1493
|
+
- self._config.close_sign * position_mm
|
|
1494
|
+
/ self._config.rad_to_mm)
|
|
1495
|
+
return self.goto_rad(position_rad, kp=kp, kd=kd, duration=duration)
|
|
1496
|
+
|
|
1497
|
+
def goto_rad(
|
|
1498
|
+
self,
|
|
1499
|
+
position_rad: float,
|
|
1500
|
+
kp: Optional[float] = None,
|
|
1501
|
+
kd: Optional[float] = None,
|
|
1502
|
+
dq_target: float = 0.0,
|
|
1503
|
+
tau_feedforward: float = 0.0,
|
|
1504
|
+
duration: float = 0.5,
|
|
1505
|
+
) -> MoveResult:
|
|
1506
|
+
"""Ramp to an absolute position in radians.
|
|
1507
|
+
|
|
1508
|
+
One linear ramp over ``duration`` seconds: the speed is the measured
|
|
1509
|
+
distance divided by it, every frame carries the matching velocity
|
|
1510
|
+
feed-forward, and each frame's command leads the measured position by at
|
|
1511
|
+
most :attr:`MotionConfig.max_lead_mm`. The first frame is one ramp step,
|
|
1512
|
+
never a step command to the target — at the shipped ``kp`` of 100 Nm/rad
|
|
1513
|
+
any error past ~0.1 rad saturates the DM4310's ±10 Nm frame range, and a
|
|
1514
|
+
saturated frame overshoots the target instead of stopping on it.
|
|
1515
|
+
|
|
1516
|
+
The target is clamped into the calibrated range. Arrival is decided
|
|
1517
|
+
after the ramp, while the jaws hold the target for
|
|
1518
|
+
:attr:`MotionConfig.settle_s`, so a call takes about ``duration`` plus
|
|
1519
|
+
that hold.
|
|
1520
|
+
|
|
1521
|
+
Returns:
|
|
1522
|
+
:class:`MoveResult`. ``ok`` means the jaws arrived inside
|
|
1523
|
+
:attr:`MotionConfig.reach_tol` without stalling. A call whose frames
|
|
1524
|
+
never went out, or that was blocked on the way, comes back ``False``:
|
|
1525
|
+
running the command and moving +0.000 mm is not success, whatever the
|
|
1526
|
+
screen said before (measured on 2026-10-12).
|
|
1527
|
+
|
|
1528
|
+
Raises:
|
|
1529
|
+
CommandError: ``position_rad`` is not a finite number, ``duration``
|
|
1530
|
+
is not a positive finite number, ``dq_target`` or
|
|
1531
|
+
``tau_feedforward`` is not 0, or the configuration is
|
|
1532
|
+
uncalibrated. The finiteness check is deliberately *above* the
|
|
1533
|
+
clamp: ``min`` / ``max`` compare false against a NaN, so the
|
|
1534
|
+
clamp would answer it with one of the calibrated limits — a real
|
|
1535
|
+
position that was never asked for.
|
|
1536
|
+
|
|
1537
|
+
The calibration check is there because the clamp below is not a
|
|
1538
|
+
safety net while ``pos_closed_rad`` / ``pos_open_rad`` are still
|
|
1539
|
+
the placeholder defaults — it is arithmetic on numbers that
|
|
1540
|
+
describe no real travel, and it produces a real target. On the
|
|
1541
|
+
defaults ``goto(40.0)`` lands at ``+0.61 rad``, past this unit's
|
|
1542
|
+
closed stop at ``+0.05 rad``, so the jaws are driven into it and
|
|
1543
|
+
press there. Every other motion already refuses through
|
|
1544
|
+
``_check_calibrated``; this was the way around it.
|
|
1545
|
+
|
|
1546
|
+
``dq_target`` / ``tau_feedforward`` are refused rather than
|
|
1547
|
+
ignored: the ramp derives its velocity feed-forward from the
|
|
1548
|
+
schedule, and silently dropping a value the caller passed is
|
|
1549
|
+
worse than saying no.
|
|
1550
|
+
"""
|
|
1551
|
+
if dq_target != 0.0:
|
|
1552
|
+
raise CommandError(
|
|
1553
|
+
f"goto_rad 不再接受 dq_target={dq_target!r}:定位走的是按帧排程"
|
|
1554
|
+
"的斜坡,速度前馈由斜坡自己按「距离 ÷ 时长」给,再给一个会和它"
|
|
1555
|
+
"打架。")
|
|
1556
|
+
if tau_feedforward != 0.0:
|
|
1557
|
+
raise CommandError(
|
|
1558
|
+
f"goto_rad 不再接受 tau_feedforward={tau_feedforward!r}:斜坡"
|
|
1559
|
+
"不带前馈力矩。要出力请用 grasp()。")
|
|
1560
|
+
if not math.isfinite(duration) or duration <= 0.0:
|
|
1561
|
+
raise CommandError(
|
|
1562
|
+
f"duration 必须是正的有限数:{duration!r}。整段斜坡的时长就是"
|
|
1563
|
+
"它,速度由实测距离除以它反推 —— 0 或负数没有对应的斜坡(旧实现"
|
|
1564
|
+
"在这个时长里一帧都不发,却返回成功)。")
|
|
1565
|
+
return self._ramp_to_rad(position_rad, duration_s=duration,
|
|
1566
|
+
kp=kp, kd=kd)
|
|
1567
|
+
|
|
1568
|
+
def move_to(
|
|
1569
|
+
self,
|
|
1570
|
+
target_rad: float,
|
|
1571
|
+
kp: Optional[float] = None,
|
|
1572
|
+
kd: Optional[float] = None,
|
|
1573
|
+
tau_feedforward: float = 0.0,
|
|
1574
|
+
duration: float = 1.0,
|
|
1575
|
+
) -> MoveResult:
|
|
1576
|
+
"""Ramp to a target position (longer default duration).
|
|
1577
|
+
|
|
1578
|
+
The same move as :meth:`goto_rad`; only the default duration differs.
|
|
1579
|
+
|
|
1580
|
+
Raises:
|
|
1581
|
+
CommandError: the configuration is uncalibrated, or a field is
|
|
1582
|
+
refused — see :meth:`goto_rad`.
|
|
1583
|
+
"""
|
|
1584
|
+
return self.goto_rad(target_rad, kp=kp, kd=kd,
|
|
1585
|
+
tau_feedforward=tau_feedforward,
|
|
1586
|
+
duration=duration)
|
|
1587
|
+
|
|
1588
|
+
def _ramp_to_rad(
|
|
1589
|
+
self,
|
|
1590
|
+
target_rad: float,
|
|
1591
|
+
*,
|
|
1592
|
+
speed_mm_s: float = 0.0,
|
|
1593
|
+
duration_s: Optional[float] = None,
|
|
1594
|
+
kp: Optional[float] = None,
|
|
1595
|
+
kd: Optional[float] = None,
|
|
1596
|
+
) -> MoveResult:
|
|
1597
|
+
"""定位的共用入口:闸门 + clamp + 交给引擎。
|
|
1598
|
+
|
|
1599
|
+
:meth:`goto` / :meth:`goto_rad` / :meth:`move_to` / :meth:`home` /
|
|
1600
|
+
:meth:`move_at_speed` / :meth:`move_at_speed_rad` 六条路径都走这里。它们
|
|
1601
|
+
本来就只差三件事:目标怎么给(mm 还是 rad)、给速度还是给时长、以及各自
|
|
1602
|
+
的输入校验;运动本身必须是同一条,见
|
|
1603
|
+
:meth:`GripperActions.ramp_to`。
|
|
1604
|
+
"""
|
|
1605
|
+
self._check_connected()
|
|
1606
|
+
self._check_enabled()
|
|
1607
|
+
_check_calibrated(self._config)
|
|
1608
|
+
# 非有限值必须在 clamp 之前拦下:min/max 与 NaN 的比较全为假,clamp 会
|
|
1609
|
+
# 把它折成量程的一端 —— 一个没人要过的真实位置。
|
|
1610
|
+
if not math.isfinite(target_rad):
|
|
1611
|
+
raise CommandError(
|
|
1612
|
+
f"目标位置不是有限数:target_rad={target_rad!r}。"
|
|
1613
|
+
"拒绝下发:NaN 落在下面的限位 clamp 里会变成量程的一端,"
|
|
1614
|
+
"也就是一个调用方没要过的真实位置。")
|
|
1615
|
+
if self._can is None:
|
|
1616
|
+
raise NotInitializedError("没有 CAN 通道 — 请先 connect()")
|
|
1617
|
+
# clamp 的目标直接经 clamp_to_travel 夹进标定行程(两端谁大谁小由安装
|
|
1618
|
+
# 方向决定,它用 min/max,不假设顺序)。
|
|
1619
|
+
return self._actions.ramp_to(
|
|
1620
|
+
clamp_to_travel(self._config, target_rad),
|
|
1621
|
+
speed_mm_s, duration_s=duration_s, kp=kp, kd=kd)
|
|
1622
|
+
|
|
1623
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
1624
|
+
# Moves specified by speed (the same ramp as the goto family)
|
|
1625
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
1626
|
+
|
|
1627
|
+
def move_at_speed(
|
|
1628
|
+
self,
|
|
1629
|
+
target_mm: float,
|
|
1630
|
+
speed_mm_s: float = 30.0,
|
|
1631
|
+
kp: Optional[float] = None,
|
|
1632
|
+
kd: Optional[float] = None,
|
|
1633
|
+
) -> MoveResult:
|
|
1634
|
+
"""Ramp to a target position at a constant linear speed (mm/s).
|
|
1635
|
+
|
|
1636
|
+
The same bounded ramp as :meth:`goto_rad`; only the way the speed is
|
|
1637
|
+
given differs — here it is mm/s, which fixes the ramp duration from the
|
|
1638
|
+
measured distance. There is no separate velocity-controlled
|
|
1639
|
+
implementation any more: the old one stepped a fixed 200 Hz schedule
|
|
1640
|
+
(ignoring :attr:`MotionConfig.frame_interval`), held the target for 20
|
|
1641
|
+
frames regardless of where the jaws were, and dropped every frame's send
|
|
1642
|
+
result.
|
|
1643
|
+
|
|
1644
|
+
Args:
|
|
1645
|
+
target_mm: Target position in mm, measured from the closed stop
|
|
1646
|
+
(``0`` = closed; ``config.max_stroke_mm``, 85 mm by default,
|
|
1647
|
+
= fully open).
|
|
1648
|
+
speed_mm_s: Travel speed in mm/s (default 30).
|
|
1649
|
+
kp: Position stiffness (default from config).
|
|
1650
|
+
kd: Velocity damping.
|
|
1651
|
+
|
|
1652
|
+
Returns:
|
|
1653
|
+
:class:`MoveResult` — the same arrival check as :meth:`goto_rad`,
|
|
1654
|
+
which the old implementation had none of (it returned ``True`` after
|
|
1655
|
+
sending, whatever the jaws did).
|
|
1656
|
+
|
|
1657
|
+
Raises:
|
|
1658
|
+
CommandError: ``target_mm`` or ``speed_mm_s`` is not a finite
|
|
1659
|
+
number, ``speed_mm_s`` is not positive, or the configuration is
|
|
1660
|
+
uncalibrated — see :meth:`goto_rad`. Both of the shortcuts this
|
|
1661
|
+
method used to take compare false against a NaN, so it would slip
|
|
1662
|
+
past them and reach the ramp arithmetic; and ``speed_mm_s <= 0``
|
|
1663
|
+
used to return ``True`` — "move at 0 mm/s" reported as success.
|
|
1664
|
+
"""
|
|
1665
|
+
self._check_connected()
|
|
1666
|
+
self._check_enabled()
|
|
1667
|
+
# Before the shortcuts below: on a placeholder config even a "nothing to
|
|
1668
|
+
# do" comparison is arithmetic on numbers that describe no real travel.
|
|
1669
|
+
_check_calibrated(self._config)
|
|
1670
|
+
for name, value in (("target_mm", target_mm),
|
|
1671
|
+
("speed_mm_s", speed_mm_s)):
|
|
1672
|
+
if not math.isfinite(value):
|
|
1673
|
+
raise CommandError(
|
|
1674
|
+
f"{name} 不是有限数:{value!r}。拒绝下发。")
|
|
1675
|
+
if speed_mm_s <= 0.0:
|
|
1676
|
+
raise CommandError(
|
|
1677
|
+
f"速度必须为正:speed_mm_s={speed_mm_s!r}。速度为 0 或负的"
|
|
1678
|
+
"移动没有对应的斜坡(旧实现把它当成功返回 True)。")
|
|
1679
|
+
|
|
1680
|
+
# close_sign handles the reverse mount, as in ``goto``.
|
|
1681
|
+
target_rad = (self._config.pos_closed_rad
|
|
1682
|
+
- self._config.close_sign * target_mm
|
|
1683
|
+
/ self._config.rad_to_mm)
|
|
1684
|
+
return self._ramp_to_rad(target_rad, speed_mm_s=speed_mm_s,
|
|
1685
|
+
kp=kp, kd=kd)
|
|
1686
|
+
|
|
1687
|
+
def move_at_speed_rad(
|
|
1688
|
+
self,
|
|
1689
|
+
target_rad: float,
|
|
1690
|
+
speed_rad_s: float = 0.5,
|
|
1691
|
+
kp: Optional[float] = None,
|
|
1692
|
+
kd: Optional[float] = None,
|
|
1693
|
+
) -> MoveResult:
|
|
1694
|
+
"""Ramp to a target position at a constant motor speed (rad/s).
|
|
1695
|
+
|
|
1696
|
+
The rad/s spelling of :meth:`move_at_speed`; the ramp is the same one
|
|
1697
|
+
(see :meth:`goto_rad`).
|
|
1698
|
+
|
|
1699
|
+
Args:
|
|
1700
|
+
target_rad: Target position in rad.
|
|
1701
|
+
speed_rad_s: Motor speed in rad/s (default 0.5).
|
|
1702
|
+
kp: Position stiffness (default from config).
|
|
1703
|
+
kd: Velocity damping.
|
|
1704
|
+
|
|
1705
|
+
Returns:
|
|
1706
|
+
:class:`MoveResult`.
|
|
1707
|
+
|
|
1708
|
+
Raises:
|
|
1709
|
+
CommandError: ``target_rad`` or ``speed_rad_s`` is not a finite
|
|
1710
|
+
number, ``speed_rad_s`` is not positive, or the configuration is
|
|
1711
|
+
uncalibrated — see :meth:`goto_rad`. The clamp would otherwise
|
|
1712
|
+
answer a NaN target with a calibrated limit.
|
|
1713
|
+
"""
|
|
1714
|
+
self._check_connected()
|
|
1715
|
+
self._check_enabled()
|
|
1716
|
+
# Same as ``move_at_speed``: the check runs before the ramp arithmetic,
|
|
1717
|
+
# where a NaN would quietly pick a real speed.
|
|
1718
|
+
_check_calibrated(self._config)
|
|
1719
|
+
for name, value in (("target_rad", target_rad),
|
|
1720
|
+
("speed_rad_s", speed_rad_s)):
|
|
1721
|
+
if not math.isfinite(value):
|
|
1722
|
+
raise CommandError(
|
|
1723
|
+
f"{name} 不是有限数:{value!r}。拒绝下发。")
|
|
1724
|
+
if speed_rad_s <= 0.0:
|
|
1725
|
+
raise CommandError(
|
|
1726
|
+
f"速度必须为正:speed_rad_s={speed_rad_s!r}。速度为 0 或负的"
|
|
1727
|
+
"移动没有对应的斜坡(旧实现把它当成功返回 True)。")
|
|
1728
|
+
|
|
1729
|
+
return self._ramp_to_rad(
|
|
1730
|
+
target_rad,
|
|
1731
|
+
speed_mm_s=speed_rad_s * self._config.rad_to_mm,
|
|
1732
|
+
kp=kp, kd=kd)
|
|
1733
|
+
|
|
1734
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
1735
|
+
# Calibration
|
|
1736
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
1737
|
+
|
|
1738
|
+
def calibrate(
|
|
1739
|
+
self,
|
|
1740
|
+
kp: float = 20.0,
|
|
1741
|
+
kd: float = 2.0,
|
|
1742
|
+
step_rad: float = 0.05,
|
|
1743
|
+
stall_delta: float = 0.0015,
|
|
1744
|
+
stall_cycles: int = 5,
|
|
1745
|
+
max_iter: int = 200,
|
|
1746
|
+
tau_limit: Optional[float] = 2.0,
|
|
1747
|
+
) -> CalibrationData:
|
|
1748
|
+
"""Calibrate the gripper: find close and open mechanical limits.
|
|
1749
|
+
|
|
1750
|
+
Process:
|
|
1751
|
+
1. Back off a small amount from current position
|
|
1752
|
+
2. Step toward close direction until stall → zero_position
|
|
1753
|
+
3. Back off
|
|
1754
|
+
4. Step toward open direction until stall → max_position
|
|
1755
|
+
5. Compute travel_range and update internal conversion factors
|
|
1756
|
+
|
|
1757
|
+
A stall only says "something stopped me", never *which* end was hit —
|
|
1758
|
+
both ends are hard stops. So the direction is not discovered here: it
|
|
1759
|
+
comes from :attr:`GripperConfig.close_sign` (load a calibration
|
|
1760
|
+
template first for a reverse mount), and this routine preserves it.
|
|
1761
|
+
|
|
1762
|
+
Safety: at a hard stop the encoder keeps creeping (backlash, elastic
|
|
1763
|
+
deformation, micro-slip), so the position-based stall test alone can
|
|
1764
|
+
never fire — the command would keep advancing and ``kp × error`` would
|
|
1765
|
+
keep growing until the structure breaks. Two independent guards are
|
|
1766
|
+
therefore in place: the command lead is re-derived from the *measured*
|
|
1767
|
+
position every cycle (bounded to ``step_rad``, so pressing torque is at
|
|
1768
|
+
most ``kp × step_rad``), and the probe aborts the moment ``|tau|``
|
|
1769
|
+
reaches ``tau_limit``.
|
|
1770
|
+
|
|
1771
|
+
Args:
|
|
1772
|
+
kp: Probing stiffness (low = gentle).
|
|
1773
|
+
kd: Probing damping.
|
|
1774
|
+
step_rad: Step size per iteration (rad); also the command lead cap.
|
|
1775
|
+
stall_delta: Position change threshold for stall detection (rad).
|
|
1776
|
+
stall_cycles: Consecutive stalls to confirm limit.
|
|
1777
|
+
max_iter: Max steps per direction (safety cap).
|
|
1778
|
+
tau_limit: Torque ceiling in Nm. The probe stops as soon as
|
|
1779
|
+
``|tau|`` reaches this; ``None`` disables the ceiling.
|
|
1780
|
+
|
|
1781
|
+
Returns:
|
|
1782
|
+
CalibrationData with zero/max position, travel range, and
|
|
1783
|
+
rad-to-mm conversion.
|
|
1784
|
+
"""
|
|
1785
|
+
self._check_connected()
|
|
1786
|
+
self._check_enabled()
|
|
1787
|
+
if self._can is None:
|
|
1788
|
+
raise NotInitializedError("未连接")
|
|
1789
|
+
|
|
1790
|
+
s = self._config.close_sign
|
|
1791
|
+
init_pos = self._can.get_position()
|
|
1792
|
+
self._can.update_state(timeout_s=0.1)
|
|
1793
|
+
init_pos = self._can.get_position()
|
|
1794
|
+
print(f"标定开始 初始位置: {init_pos:.4f} rad "
|
|
1795
|
+
f"(方向:{'正向' if s > 0 else '反装'})")
|
|
1796
|
+
|
|
1797
|
+
def _find_limit(direction: str) -> float:
|
|
1798
|
+
sign = s if direction == "close" else -s
|
|
1799
|
+
label = "闭合限位" if direction == "close" else "张开限位"
|
|
1800
|
+
print(f" 寻找{label}...")
|
|
1801
|
+
|
|
1802
|
+
self._can.update_state(timeout_s=0.05)
|
|
1803
|
+
current = self._can.get_position()
|
|
1804
|
+
stall = 0
|
|
1805
|
+
|
|
1806
|
+
for i in range(max_iter):
|
|
1807
|
+
# Re-derive the command from the *measured* position each cycle.
|
|
1808
|
+
# Accumulating a running target (``target += sign * step_rad``)
|
|
1809
|
+
# lets the command lead grow without bound once the stop is
|
|
1810
|
+
# reached, and the pressing torque (kp × lead) grows with it.
|
|
1811
|
+
# Bounding the lead to one step caps it at kp × step_rad.
|
|
1812
|
+
target = current + sign * step_rad
|
|
1813
|
+
self._can.control_mit_stream(target, kp, kd, duration_s=0.3, interval_s=0.005)
|
|
1814
|
+
self._can.update_state(timeout_s=0.1)
|
|
1815
|
+
|
|
1816
|
+
new_pos = self._can.get_position()
|
|
1817
|
+
delta = abs(new_pos - current)
|
|
1818
|
+
tau = self._can.get_torque()
|
|
1819
|
+
|
|
1820
|
+
print(f" [{i}] tgt={target:+.3f} pos={new_pos:.4f} "
|
|
1821
|
+
f"d={delta:.5f} tau={tau:+.3f} st={stall}")
|
|
1822
|
+
|
|
1823
|
+
if tau_limit is not None and abs(tau) >= tau_limit:
|
|
1824
|
+
print(f" → 力矩达到上限 {tau_limit:.2f} Nm({tau:+.3f}),"
|
|
1825
|
+
f"停止推进并保持: {new_pos:.6f} rad")
|
|
1826
|
+
return new_pos
|
|
1827
|
+
|
|
1828
|
+
if delta < stall_delta:
|
|
1829
|
+
stall += 1
|
|
1830
|
+
if stall >= stall_cycles:
|
|
1831
|
+
print(f" → 到达{label}: {new_pos:.6f} rad")
|
|
1832
|
+
return new_pos
|
|
1833
|
+
else:
|
|
1834
|
+
stall = 0
|
|
1835
|
+
current = new_pos
|
|
1836
|
+
|
|
1837
|
+
print(f" → 安全停止(达到最大步数 {max_iter}): {current:.4f} rad")
|
|
1838
|
+
return current
|
|
1839
|
+
|
|
1840
|
+
def _bounded_move(target: float, label: str) -> None:
|
|
1841
|
+
"""Move toward *target*, guarded the same way as the probe.
|
|
1842
|
+
|
|
1843
|
+
The plain ``goto_rad`` streams one constant command for half a
|
|
1844
|
+
second with no ceiling. If the jaws already sit at the stop that
|
|
1845
|
+
command is heading for, the pressing torque is ``kp × 0.2`` (16 Nm at
|
|
1846
|
+
``kp=80``) held for the whole duration — the same kind of unguarded
|
|
1847
|
+
push that broke a jaw. Leading by one step at a time and watching
|
|
1848
|
+
``|tau|`` bounds it to the probe's ceiling instead.
|
|
1849
|
+
"""
|
|
1850
|
+
current = self._can.get_position()
|
|
1851
|
+
sign = 1.0 if target >= current else -1.0
|
|
1852
|
+
for _ in range(max_iter): # same cap as the probe
|
|
1853
|
+
if abs(target - current) <= step_rad:
|
|
1854
|
+
return
|
|
1855
|
+
self._can.control_mit_stream(
|
|
1856
|
+
current + sign * step_rad, kp, kd,
|
|
1857
|
+
duration_s=0.1, interval_s=0.005)
|
|
1858
|
+
self._can.update_state(timeout_s=0.1)
|
|
1859
|
+
new_pos = self._can.get_position()
|
|
1860
|
+
tau = self._can.get_torque()
|
|
1861
|
+
if tau_limit is not None and abs(tau) >= tau_limit:
|
|
1862
|
+
print(f" → {label}:力矩达到上限 {tau_limit:.2f} Nm"
|
|
1863
|
+
f"({tau:+.3f}),停止")
|
|
1864
|
+
return
|
|
1865
|
+
if abs(new_pos - current) < stall_delta:
|
|
1866
|
+
print(f" → {label}:位置不再变化,停止")
|
|
1867
|
+
return
|
|
1868
|
+
current = new_pos
|
|
1869
|
+
|
|
1870
|
+
# 1. Safe back-off (a nudge toward the close side, as before, flipped
|
|
1871
|
+
# with the mount)
|
|
1872
|
+
print(" 安全回退...")
|
|
1873
|
+
_bounded_move(init_pos + s * 0.2, "安全回退")
|
|
1874
|
+
|
|
1875
|
+
# 2. Find zero (close direction)
|
|
1876
|
+
zero_pos = _find_limit("close")
|
|
1877
|
+
|
|
1878
|
+
# 3. Back off, away from the close stop toward the open side
|
|
1879
|
+
print(" 回退...")
|
|
1880
|
+
_bounded_move(zero_pos - s * 0.3, "回退")
|
|
1881
|
+
|
|
1882
|
+
# 4. Find max (open direction)
|
|
1883
|
+
max_pos = _find_limit("open")
|
|
1884
|
+
|
|
1885
|
+
# 5. Compute results
|
|
1886
|
+
# zero_pos = close limit, max_pos = open limit; which is numerically
|
|
1887
|
+
# larger depends on the mount, so take the magnitude.
|
|
1888
|
+
travel = abs(zero_pos - max_pos)
|
|
1889
|
+
rad_to_mm = _scale_from_travel(self._config, travel)
|
|
1890
|
+
|
|
1891
|
+
result = CalibrationData(
|
|
1892
|
+
zero_position=round(zero_pos, 6), # closed → 0 mm
|
|
1893
|
+
max_position=round(max_pos, 6), # open → max mm
|
|
1894
|
+
travel_range=round(travel, 6),
|
|
1895
|
+
rad_to_mm=round(rad_to_mm, 2),
|
|
1896
|
+
motor_type=self._motor_type.name,
|
|
1897
|
+
can_id=self._can_id,
|
|
1898
|
+
mst_id=self._mst_id or 0,
|
|
1899
|
+
calibration_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
1900
|
+
)
|
|
1901
|
+
|
|
1902
|
+
# Update config
|
|
1903
|
+
self._config.pos_closed_rad = result.zero_position
|
|
1904
|
+
self._config.pos_open_rad = result.max_position
|
|
1905
|
+
self._config.rad_to_mm = result.rad_to_mm
|
|
1906
|
+
self._config.calibrated = True
|
|
1907
|
+
|
|
1908
|
+
print(f"\n 标定结果:")
|
|
1909
|
+
print(f" 闭合(0mm): {result.zero_position:.6f} rad")
|
|
1910
|
+
print(f" 张开({self._config.max_stroke_mm:.0f}mm): "
|
|
1911
|
+
f"{result.max_position:.6f} rad")
|
|
1912
|
+
print(f" 行程: {result.travel_range:.6f} rad "
|
|
1913
|
+
f"({result.travel_mm:.1f} mm)")
|
|
1914
|
+
print(f" 转换系数: {result.rad_to_mm:.1f} mm/rad")
|
|
1915
|
+
|
|
1916
|
+
return result
|
|
1917
|
+
|
|
1918
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
1919
|
+
# State
|
|
1920
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
1921
|
+
|
|
1922
|
+
def get_state(self, wait: bool = True) -> GripperState:
|
|
1923
|
+
"""Return the current gripper state, saying how fresh it is.
|
|
1924
|
+
|
|
1925
|
+
Args:
|
|
1926
|
+
wait: If True (default), waits up to 50 ms for a status frame.
|
|
1927
|
+
If False, returns immediately with the last cached state
|
|
1928
|
+
(suitable for high-frequency control loops).
|
|
1929
|
+
|
|
1930
|
+
``wait=True`` asks for a frame; it does not guarantee one. A motor
|
|
1931
|
+
answers a *control* frame, so an idle bus — one holding a pose, or
|
|
1932
|
+
disabled — says nothing, and the wait ends on the cached readings. The
|
|
1933
|
+
returned snapshot says which happened: ``fresh`` is False when no frame
|
|
1934
|
+
arrived, ``data_age_s`` is the age of the frame the readings did come
|
|
1935
|
+
from, and ``timestamp`` is that frame's arrival time, not this call's.
|
|
1936
|
+
Call :meth:`refresh_status` first when the bus has been idle.
|
|
1937
|
+
|
|
1938
|
+
Returns:
|
|
1939
|
+
GripperState snapshot.
|
|
1940
|
+
"""
|
|
1941
|
+
self._check_connected()
|
|
1942
|
+
if self._can is None:
|
|
1943
|
+
return GripperState()
|
|
1944
|
+
|
|
1945
|
+
with self._io_lock:
|
|
1946
|
+
if wait:
|
|
1947
|
+
answered = self._can.update_state(timeout_s=0.05)
|
|
1948
|
+
else:
|
|
1949
|
+
answered = self._can.poll(timeout_s=0.0)
|
|
1950
|
+
|
|
1951
|
+
position_rad = self._can.get_position()
|
|
1952
|
+
velocity_rad_s = self._can.get_velocity()
|
|
1953
|
+
torque_nm = self._can.get_torque()
|
|
1954
|
+
error_code = self._can.get_error()
|
|
1955
|
+
t_mos, t_coil = self._can.get_temperature()
|
|
1956
|
+
|
|
1957
|
+
# Both stamps come from the frame, not from this call. A duck-typed
|
|
1958
|
+
# transport need not carry them, and then there is nothing to say:
|
|
1959
|
+
# None age means "no frame to date the readings by".
|
|
1960
|
+
last_update = getattr(self._can, "last_update", None)
|
|
1961
|
+
frame_time = getattr(self._can, "last_frame_time", None)
|
|
1962
|
+
# last_update is the monotonic stamp the receive path put on the
|
|
1963
|
+
# frame, so the age has to be measured on that clock.
|
|
1964
|
+
data_age_s = (None if last_update is None
|
|
1965
|
+
else time.monotonic() - last_update)
|
|
1966
|
+
|
|
1967
|
+
# pos_closed_rad = closed (0 mm), pos_open_rad = open (max mm).
|
|
1968
|
+
# Which way the rad count runs depends on the mount, so close_sign
|
|
1969
|
+
# sets the sign; the result is 0 mm at the closed limit and +stroke
|
|
1970
|
+
# at the open limit for both mountings.
|
|
1971
|
+
s = self._config.close_sign
|
|
1972
|
+
position_mm = ((self._config.pos_closed_rad - position_rad)
|
|
1973
|
+
* s * self._config.rad_to_mm)
|
|
1974
|
+
# Squeeze is positive force: the sign flips with the mount too.
|
|
1975
|
+
force_n = s * torque_nm * UnitConversion.NM_TO_N
|
|
1976
|
+
|
|
1977
|
+
return GripperState(
|
|
1978
|
+
position_rad=position_rad,
|
|
1979
|
+
velocity_rad_s=velocity_rad_s,
|
|
1980
|
+
torque_nm=torque_nm,
|
|
1981
|
+
temperature_mos=t_mos,
|
|
1982
|
+
temperature_coil=t_coil,
|
|
1983
|
+
error_code=error_code,
|
|
1984
|
+
# 0.0 = no frame ever arrived (see GripperState.timestamp).
|
|
1985
|
+
timestamp=0.0 if frame_time is None else frame_time,
|
|
1986
|
+
position_mm=position_mm,
|
|
1987
|
+
force_n=force_n,
|
|
1988
|
+
fresh=bool(answered),
|
|
1989
|
+
data_age_s=data_age_s,
|
|
1990
|
+
)
|
|
1991
|
+
|
|
1992
|
+
def refresh_status(self) -> bool:
|
|
1993
|
+
"""Ask the motor for a status frame (0xCC). Does not move the jaws.
|
|
1994
|
+
|
|
1995
|
+
Use it before :meth:`get_state` on an idle bus. A motor answers the
|
|
1996
|
+
control frames it receives, so a gripper holding a pose sends nothing on
|
|
1997
|
+
its own and the status link goes quiet; this request makes it speak.
|
|
1998
|
+
|
|
1999
|
+
Returns:
|
|
2000
|
+
True if the request went out. False when there is no connection or
|
|
2001
|
+
the write failed — the same reporting as ``enable()``, since this
|
|
2002
|
+
asks rather than commands.
|
|
2003
|
+
"""
|
|
2004
|
+
self._check_connected()
|
|
2005
|
+
if self._can is None:
|
|
2006
|
+
return False
|
|
2007
|
+
with self._io_lock:
|
|
2008
|
+
return self._can.refresh_status()
|
|
2009
|
+
|
|
2010
|
+
def get_position(self) -> float:
|
|
2011
|
+
"""Current position in mm."""
|
|
2012
|
+
return self.get_state().position_mm
|
|
2013
|
+
|
|
2014
|
+
def get_position_rad(self) -> float:
|
|
2015
|
+
"""Current position in rad."""
|
|
2016
|
+
self._check_connected()
|
|
2017
|
+
if self._can is None:
|
|
2018
|
+
return 0.0
|
|
2019
|
+
self._can.update_state(timeout_s=0.05)
|
|
2020
|
+
return self._can.get_position()
|
|
2021
|
+
|
|
2022
|
+
def get_force(self) -> float:
|
|
2023
|
+
"""Estimated gripping force in N."""
|
|
2024
|
+
return self.get_state().force_n
|
|
2025
|
+
|
|
2026
|
+
def get_torque(self) -> float:
|
|
2027
|
+
"""Current motor torque in Nm."""
|
|
2028
|
+
self._check_connected()
|
|
2029
|
+
if self._can is None:
|
|
2030
|
+
return 0.0
|
|
2031
|
+
self._can.update_state(timeout_s=0.05)
|
|
2032
|
+
return self._can.get_torque()
|
|
2033
|
+
|
|
2034
|
+
def get_error(self) -> int:
|
|
2035
|
+
"""Motor error code (0=disabled, 1=enabled, 0x9=UV, ...)."""
|
|
2036
|
+
self._check_connected()
|
|
2037
|
+
if self._can is None:
|
|
2038
|
+
return -1
|
|
2039
|
+
self._can.update_state(timeout_s=0.05)
|
|
2040
|
+
return self._can.get_error()
|
|
2041
|
+
|
|
2042
|
+
def get_temperature(self) -> tuple[int, int]:
|
|
2043
|
+
"""Return (MOS_temp, Coil_temp) in °C."""
|
|
2044
|
+
self._check_connected()
|
|
2045
|
+
if self._can is None:
|
|
2046
|
+
return 0, 0
|
|
2047
|
+
self._can.update_state(timeout_s=0.05)
|
|
2048
|
+
return self._can.get_temperature()
|
|
2049
|
+
|
|
2050
|
+
def get_info(self) -> GripperInfo:
|
|
2051
|
+
"""Return device metadata."""
|
|
2052
|
+
return GripperInfo(
|
|
2053
|
+
motor_type=self._motor_type.name,
|
|
2054
|
+
can_id=self._can_id,
|
|
2055
|
+
mst_id=self._mst_id or 0,
|
|
2056
|
+
)
|
|
2057
|
+
|
|
2058
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
2059
|
+
# State predicates
|
|
2060
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
2061
|
+
|
|
2062
|
+
def is_moving(self) -> bool:
|
|
2063
|
+
"""True if the gripper is currently in motion."""
|
|
2064
|
+
state = self.get_state()
|
|
2065
|
+
return state.is_moving
|
|
2066
|
+
|
|
2067
|
+
def is_grasped(self) -> bool:
|
|
2068
|
+
"""True if torque exceeds the grasp-detection threshold."""
|
|
2069
|
+
state = self.get_state()
|
|
2070
|
+
return abs(state.torque_nm) > self._config.grasp_torque_threshold
|
|
2071
|
+
|
|
2072
|
+
def wait_for_ready(self, timeout: float = 5.0) -> bool:
|
|
2073
|
+
"""Block until the motor is enabled and not moving."""
|
|
2074
|
+
if self._can is None:
|
|
2075
|
+
return False
|
|
2076
|
+
start = time.time()
|
|
2077
|
+
while time.time() - start < timeout:
|
|
2078
|
+
self._can.update_state(timeout_s=0.05)
|
|
2079
|
+
err = self._can.get_error()
|
|
2080
|
+
if err == 1 and not self.is_moving():
|
|
2081
|
+
return True
|
|
2082
|
+
time.sleep(0.05)
|
|
2083
|
+
return False
|
|
2084
|
+
|
|
2085
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
2086
|
+
# Parameter access (expert)
|
|
2087
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
2088
|
+
|
|
2089
|
+
def read_param(self, rid: int, timeout_s: float = 0.5) -> float:
|
|
2090
|
+
"""Read a motor register by its RID (expert use).
|
|
2091
|
+
|
|
2092
|
+
See :class:`litegrip.can.protocol.DM_REG` for available registers.
|
|
2093
|
+
"""
|
|
2094
|
+
self._check_connected()
|
|
2095
|
+
if self._can is None:
|
|
2096
|
+
raise NotInitializedError("未连接")
|
|
2097
|
+
return self._can.read_param(rid, timeout_s=timeout_s)
|
|
2098
|
+
|
|
2099
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
2100
|
+
# Teleoperation (leader / follower)
|
|
2101
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
2102
|
+
|
|
2103
|
+
def teleop_start(
|
|
2104
|
+
self,
|
|
2105
|
+
mode: str,
|
|
2106
|
+
*,
|
|
2107
|
+
transport: Optional["TeleopTransport"] = None,
|
|
2108
|
+
link: str = "zenoh",
|
|
2109
|
+
host: Optional[str] = None,
|
|
2110
|
+
port: int = DEFAULT_GRIP_PORT,
|
|
2111
|
+
grip_id: str = DEFAULT_GRIP_ID,
|
|
2112
|
+
kp: Optional[float] = None,
|
|
2113
|
+
kd: Optional[float] = None,
|
|
2114
|
+
align: bool = True,
|
|
2115
|
+
align_speed_mm_s: float = DEFAULT_ALIGN_SPEED_MM_S, # [遥操对齐块]
|
|
2116
|
+
watchdog_s: float = 0.2,
|
|
2117
|
+
dq_max: float = DEFAULT_DQ_MAX,
|
|
2118
|
+
torque_limit_nm: float = DEFAULT_TORQUE_LIMIT_NM,
|
|
2119
|
+
lead_cap_mm: float = DEFAULT_LEAD_CAP_MM, # [遥操对齐块]
|
|
2120
|
+
require_ready: bool = True,
|
|
2121
|
+
ready_timeout_s: float = DEFAULT_READY_TIMEOUT_S,
|
|
2122
|
+
ready_tolerance_mm: float = DEFAULT_READY_TOLERANCE_MM,
|
|
2123
|
+
ready_period_s: float = DEFAULT_READY_PERIOD_S,
|
|
2124
|
+
rate_hz: float = 50.0,
|
|
2125
|
+
) -> dict:
|
|
2126
|
+
"""Start leader/follower teleoperation on this gripper.
|
|
2127
|
+
|
|
2128
|
+
``mode="master"`` (leader) publishes the opening and, once the follower
|
|
2129
|
+
reports ready, makes the motor slack — the jaws can be pushed by hand.
|
|
2130
|
+
``mode="slave"`` (follower) receives the opening, follows it, and
|
|
2131
|
+
announces when it has arrived.
|
|
2132
|
+
|
|
2133
|
+
Both ends must agree on ``grip_id``. Teleoperation is exclusive: the
|
|
2134
|
+
background loop owns the CAN I/O until :meth:`teleop_stop`, so do not
|
|
2135
|
+
drive this gripper from the caller while it runs.
|
|
2136
|
+
|
|
2137
|
+
Args:
|
|
2138
|
+
mode: ``"master"`` or ``"slave"``.
|
|
2139
|
+
transport: A :class:`~litegrip.TeleopTransport`. When omitted, one
|
|
2140
|
+
is built from ``link``. An injected transport is never closed
|
|
2141
|
+
by this class.
|
|
2142
|
+
link: ``"zenoh"`` (default) builds the point-to-point zenoh link
|
|
2143
|
+
used by the field teleoperation — see
|
|
2144
|
+
:mod:`litegrip.zenoh_link`, needs ``pip install litegrip-python[zenoh]``.
|
|
2145
|
+
``"udp"`` builds the plain-UDP transport, for a trusted LAN.
|
|
2146
|
+
host: The peer's address. Required for a ``slave``; the master only
|
|
2147
|
+
listens, so it needs no host.
|
|
2148
|
+
port: TCP (zenoh) or UDP port.
|
|
2149
|
+
grip_id: Topic id shared by both ends.
|
|
2150
|
+
kp, kd: Follow gains (slave). ``None`` uses the calibration's own.
|
|
2151
|
+
align: Slave only — align to the first received frame before
|
|
2152
|
+
following.
|
|
2153
|
+
align_speed_mm_s: Slave only — speed of that align move, in mm/s of
|
|
2154
|
+
jaw travel. Must be > 0.
|
|
2155
|
+
watchdog_s: Slave only — hold position after this long without a
|
|
2156
|
+
fresh frame.
|
|
2157
|
+
dq_max: Slave only — ceiling in rad/s on the leader velocity fed
|
|
2158
|
+
forward to the follower. ``0`` disables the feedforward.
|
|
2159
|
+
torque_limit_nm: Slave only — ceiling in Nm on the follower's own
|
|
2160
|
+
torque; held over it the follower releases in place. ``0``
|
|
2161
|
+
disables the guard. See :class:`~litegrip.GripperTeleop`.
|
|
2162
|
+
lead_cap_mm: Slave only — ceiling in mm on how far the *align*
|
|
2163
|
+
command may lead the measured position, which bounds the align
|
|
2164
|
+
torque. The follow loop is not capped. ``0`` disables the cap.
|
|
2165
|
+
require_ready: Master only — hold the jaws under gain (not
|
|
2166
|
+
hand-movable) until the follower announces it has arrived,
|
|
2167
|
+
rather than going slack from the first cycle.
|
|
2168
|
+
ready_timeout_s: Master only — seconds to wait for that
|
|
2169
|
+
announcement before releasing the hand-back anyway with a
|
|
2170
|
+
warning; ``0`` waits indefinitely. A bounded default keeps an
|
|
2171
|
+
older follower that never announces from stalling a new leader.
|
|
2172
|
+
ready_tolerance_mm: Slave only — arrival tolerance for announcing
|
|
2173
|
+
ready, in mm of travel.
|
|
2174
|
+
ready_period_s: Slave only — how often to republish the ready state.
|
|
2175
|
+
rate_hz: Loop rate.
|
|
2176
|
+
|
|
2177
|
+
Returns:
|
|
2178
|
+
The initial :meth:`teleop_status` snapshot.
|
|
2179
|
+
|
|
2180
|
+
Raises:
|
|
2181
|
+
TeleopNotReady: uncalibrated, zero travel, or ``rad_to_mm == 0``.
|
|
2182
|
+
TeleopBusyError: teleoperation is already running.
|
|
2183
|
+
NotInitializedError: not connected or not enabled.
|
|
2184
|
+
ValueError: a negative ``torque_limit_nm`` / ``lead_cap_mm`` /
|
|
2185
|
+
``ready_timeout_s`` / ``ready_tolerance_mm``, a non-positive
|
|
2186
|
+
``ready_period_s``, or an ``align_speed_mm_s`` that is not
|
|
2187
|
+
positive.
|
|
2188
|
+
"""
|
|
2189
|
+
from .teleop import (GripperTeleop, TeleopBusyError, check_ready,
|
|
2190
|
+
ready_topic, teleop_topic)
|
|
2191
|
+
from .teleop import UdpTeleopTransport
|
|
2192
|
+
|
|
2193
|
+
self._check_connected()
|
|
2194
|
+
self._check_enabled()
|
|
2195
|
+
if mode not in ("master", "slave"):
|
|
2196
|
+
raise ValueError(f"mode must be 'master' or 'slave', got {mode!r}")
|
|
2197
|
+
# Before anything is enabled or driven: ``send_mit_frame`` does not
|
|
2198
|
+
# check ``calibrated`` itself (``goto_rad`` now does).
|
|
2199
|
+
check_ready(self.config)
|
|
2200
|
+
if self._teleop is not None and self._teleop.is_running:
|
|
2201
|
+
raise TeleopBusyError("teleop is already running")
|
|
2202
|
+
self._claim_session("teleop", TeleopBusyError)
|
|
2203
|
+
|
|
2204
|
+
# Everything from here to the running loop is inside one try: the
|
|
2205
|
+
# session was claimed above, and a transport that refuses (a bad link,
|
|
2206
|
+
# a missing host, a port already bound) would otherwise leave the slot
|
|
2207
|
+
# claimed for the life of the process, refusing every later teleop,
|
|
2208
|
+
# record and replay in turn.
|
|
2209
|
+
try:
|
|
2210
|
+
key = teleop_topic(grip_id)
|
|
2211
|
+
ready_key = ready_topic(grip_id)
|
|
2212
|
+
created_transport = None
|
|
2213
|
+
if transport is None:
|
|
2214
|
+
if link == "zenoh":
|
|
2215
|
+
if mode == "master":
|
|
2216
|
+
# ⚠ 常驻 publisher **不登记**成 per-session transport:登记了
|
|
2217
|
+
# `teleop_stop` 就会 close 它,而 `_teleop_pub` 仍指向这个
|
|
2218
|
+
# 已死的端点 ⇒ 此后每一轮都在往死会话里 put。(真机实测:
|
|
2219
|
+
# 第一次配对正常,之后每次从端 0 帧。)常驻端点的生命周期
|
|
2220
|
+
# 只归 `disconnect()` → `_close_teleop_pub()`。
|
|
2221
|
+
transport = self._open_teleop_pub(port, key, ready_key)
|
|
2222
|
+
else:
|
|
2223
|
+
transport = _zenoh_transport("slave", key, port, host,
|
|
2224
|
+
ready_key)
|
|
2225
|
+
created_transport = transport
|
|
2226
|
+
elif link == "udp":
|
|
2227
|
+
if host is None:
|
|
2228
|
+
raise ValueError("host is required for the udp link")
|
|
2229
|
+
addr = f"{host}:{port}"
|
|
2230
|
+
if mode == "master":
|
|
2231
|
+
# Publish-only: with no bind there is no receive path, so
|
|
2232
|
+
# the readiness gate disables itself (see
|
|
2233
|
+
# ``GripperTeleop._open_ready_sub``) and the leader goes
|
|
2234
|
+
# slack as it always did. Carrying ready over UDP would
|
|
2235
|
+
# need a second port for the reverse direction.
|
|
2236
|
+
transport = UdpTeleopTransport(pub_addr=addr)
|
|
2237
|
+
else:
|
|
2238
|
+
transport = UdpTeleopTransport(bind_addr=addr)
|
|
2239
|
+
created_transport = transport
|
|
2240
|
+
else:
|
|
2241
|
+
raise ValueError(
|
|
2242
|
+
f"link must be 'zenoh' or 'udp', got {link!r}")
|
|
2243
|
+
|
|
2244
|
+
manager = GripperTeleop(
|
|
2245
|
+
self, transport, mode, key,
|
|
2246
|
+
rate_hz=rate_hz, kp=kp, kd=kd, align=align,
|
|
2247
|
+
align_speed_mm_s=align_speed_mm_s, # [遥操对齐块]
|
|
2248
|
+
watchdog_s=watchdog_s, dq_max=dq_max,
|
|
2249
|
+
torque_limit_nm=torque_limit_nm, lead_cap_mm=lead_cap_mm, # [遥操对齐块]
|
|
2250
|
+
ready_topic=ready_key, require_ready=require_ready,
|
|
2251
|
+
ready_timeout_s=ready_timeout_s,
|
|
2252
|
+
ready_tolerance_mm=ready_tolerance_mm,
|
|
2253
|
+
ready_period_s=ready_period_s)
|
|
2254
|
+
manager.start()
|
|
2255
|
+
except BaseException:
|
|
2256
|
+
self._release_session("teleop")
|
|
2257
|
+
raise
|
|
2258
|
+
self._teleop = manager
|
|
2259
|
+
self._teleop_transport = created_transport
|
|
2260
|
+
return manager.status()
|
|
2261
|
+
|
|
2262
|
+
def _open_teleop_pub(self, port: int, key: str,
|
|
2263
|
+
ready_key: Optional[str] = None) -> "TeleopTransport":
|
|
2264
|
+
"""Return the leader's resident publisher, building it on first use.
|
|
2265
|
+
|
|
2266
|
+
⚠ **Resident, not per session.** Rebuilding the zenoh listener on every
|
|
2267
|
+
session leaves the port bound and makes publisher↔subscriber matching
|
|
2268
|
+
fail intermittently; keeping one for the life of the gripper removes
|
|
2269
|
+
both. The consequence is that the zenoh keys are fixed by the first
|
|
2270
|
+
session — a later session with a different ``grip_id`` would mismatch
|
|
2271
|
+
them, which the transport reports rather than silently ignoring.
|
|
2272
|
+
"""
|
|
2273
|
+
if self._teleop_pub is None:
|
|
2274
|
+
self._teleop_pub = _zenoh_transport("master", key, port, None,
|
|
2275
|
+
ready_key)
|
|
2276
|
+
return self._teleop_pub
|
|
2277
|
+
|
|
2278
|
+
def _close_teleop_pub(self) -> None:
|
|
2279
|
+
pub, self._teleop_pub = self._teleop_pub, None
|
|
2280
|
+
if pub is not None:
|
|
2281
|
+
try:
|
|
2282
|
+
pub.close()
|
|
2283
|
+
except Exception as e: # noqa: BLE001
|
|
2284
|
+
log.debug("teleop publisher close failed: %s", e)
|
|
2285
|
+
|
|
2286
|
+
def teleop_stop(self, timeout: float = 2.0) -> dict:
|
|
2287
|
+
"""Stop teleoperation and leave the gripper holding its position.
|
|
2288
|
+
|
|
2289
|
+
Neither side disables: the master leaves zero-gravity mode and the slave
|
|
2290
|
+
sends one final frame at its current angle, so the jaws hold under gain.
|
|
2291
|
+
The leader's resident publisher is **not** closed here — only the
|
|
2292
|
+
per-session subscriber is.
|
|
2293
|
+
"""
|
|
2294
|
+
manager = self._teleop
|
|
2295
|
+
if manager is None:
|
|
2296
|
+
return {"active": False, "mode": None}
|
|
2297
|
+
manager.stop(timeout=timeout)
|
|
2298
|
+
self._teleop = None
|
|
2299
|
+
self._release_session("teleop")
|
|
2300
|
+
if self._teleop_transport is not None:
|
|
2301
|
+
try:
|
|
2302
|
+
self._teleop_transport.close()
|
|
2303
|
+
except Exception as e: # noqa: BLE001
|
|
2304
|
+
log.debug("teleop transport close failed: %s", e)
|
|
2305
|
+
self._teleop_transport = None
|
|
2306
|
+
return manager.status()
|
|
2307
|
+
|
|
2308
|
+
def teleop_status(self) -> dict:
|
|
2309
|
+
"""Snapshot of the running teleoperation, or ``{"active": False}``."""
|
|
2310
|
+
if self._teleop is None:
|
|
2311
|
+
return {"active": False, "mode": None}
|
|
2312
|
+
return self._teleop.status()
|
|
2313
|
+
|
|
2314
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
2315
|
+
# Trajectory record and replay
|
|
2316
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
2317
|
+
#
|
|
2318
|
+
# Teach a motion once, repeat it later. See litegrip.trajectory for what
|
|
2319
|
+
# the recording stores and, importantly, what a replay does *not*
|
|
2320
|
+
# reproduce (force).
|
|
2321
|
+
|
|
2322
|
+
def record_start(
|
|
2323
|
+
self,
|
|
2324
|
+
rate_hz: float = 100.0,
|
|
2325
|
+
zero_gravity: bool = True,
|
|
2326
|
+
max_samples: Optional[int] = None,
|
|
2327
|
+
) -> dict:
|
|
2328
|
+
"""Begin recording this gripper's motion, in the background.
|
|
2329
|
+
|
|
2330
|
+
With ``zero_gravity=True`` — the hand-teaching mode — the recorder
|
|
2331
|
+
itself streams zero-torque frames so the jaws can be pushed by hand.
|
|
2332
|
+
Do not drive the gripper from the caller while that runs. With
|
|
2333
|
+
``zero_gravity=False`` the recorder only *reads* state, so the caller is
|
|
2334
|
+
free to drive the gripper from another thread and capture a
|
|
2335
|
+
programmatic move; only the sampling touches the bus.
|
|
2336
|
+
|
|
2337
|
+
Recording is exclusive with teleoperation and replay.
|
|
2338
|
+
|
|
2339
|
+
Args:
|
|
2340
|
+
rate_hz: Samples per second (default 100).
|
|
2341
|
+
zero_gravity: Stream zero-torque frames, leaving the jaws
|
|
2342
|
+
back-drivable by hand.
|
|
2343
|
+
max_samples: Stop by itself after this many samples; ``None``
|
|
2344
|
+
records until :meth:`record_stop`.
|
|
2345
|
+
|
|
2346
|
+
Returns:
|
|
2347
|
+
The initial :meth:`trajectory_status` snapshot.
|
|
2348
|
+
|
|
2349
|
+
Raises:
|
|
2350
|
+
TrajectoryBusyError: another session (teleop, record, play) is running.
|
|
2351
|
+
NotInitializedError: not connected or not enabled.
|
|
2352
|
+
TrajectoryError: the unit is not calibrated, so the normalised
|
|
2353
|
+
opening a sample stores would be a guess.
|
|
2354
|
+
"""
|
|
2355
|
+
from .trajectory import (TrajectoryBusyError, TrajectoryError,
|
|
2356
|
+
TrajectoryRecorder)
|
|
2357
|
+
|
|
2358
|
+
self._check_connected()
|
|
2359
|
+
self._check_enabled()
|
|
2360
|
+
if not self._config.calibrated:
|
|
2361
|
+
raise TrajectoryError(
|
|
2362
|
+
"未标定 —— 轨迹记录的是按行程归一化的张开度, 没有标定就算不出来; "
|
|
2363
|
+
"先 load_calibration() 或 zero()")
|
|
2364
|
+
self._claim_session("record", TrajectoryBusyError)
|
|
2365
|
+
try:
|
|
2366
|
+
recorder = TrajectoryRecorder(
|
|
2367
|
+
self, rate_hz=rate_hz, zero_gravity=zero_gravity,
|
|
2368
|
+
max_samples=max_samples)
|
|
2369
|
+
recorder.start()
|
|
2370
|
+
except BaseException:
|
|
2371
|
+
self._release_session("record")
|
|
2372
|
+
raise
|
|
2373
|
+
self._trajectory_recorder = recorder
|
|
2374
|
+
return recorder.status()
|
|
2375
|
+
|
|
2376
|
+
def record_stop(self, allow_empty: bool = False) -> "Trajectory":
|
|
2377
|
+
"""Stop recording and return the captured trajectory.
|
|
2378
|
+
|
|
2379
|
+
Args:
|
|
2380
|
+
allow_empty: Return an empty trajectory instead of raising when
|
|
2381
|
+
nothing was captured. For a deliberate start-then-immediately-
|
|
2382
|
+
stop; a capture that was *meant* to contain motion should be
|
|
2383
|
+
allowed to raise.
|
|
2384
|
+
|
|
2385
|
+
Returns:
|
|
2386
|
+
The recorded :class:`~litegrip.Trajectory`.
|
|
2387
|
+
|
|
2388
|
+
Raises:
|
|
2389
|
+
TrajectoryNotActiveError: nothing is being recorded.
|
|
2390
|
+
TrajectoryRecordingError: the sampling loop died — a partial
|
|
2391
|
+
capture is never returned as if it were whole.
|
|
2392
|
+
TrajectoryEmptyError: no samples were captured.
|
|
2393
|
+
"""
|
|
2394
|
+
from .trajectory import TrajectoryNotActiveError
|
|
2395
|
+
|
|
2396
|
+
recorder = self._trajectory_recorder
|
|
2397
|
+
if recorder is None:
|
|
2398
|
+
raise TrajectoryNotActiveError("没有正在进行的录制")
|
|
2399
|
+
recorder.stop()
|
|
2400
|
+
self._trajectory_recorder = None
|
|
2401
|
+
self._release_session("record")
|
|
2402
|
+
return recorder.result(allow_empty=allow_empty)
|
|
2403
|
+
|
|
2404
|
+
def record(
|
|
2405
|
+
self,
|
|
2406
|
+
duration_s: float,
|
|
2407
|
+
rate_hz: float = 100.0,
|
|
2408
|
+
zero_gravity: bool = True,
|
|
2409
|
+
) -> "Trajectory":
|
|
2410
|
+
"""Hand-teach a motion: record for *duration_s* seconds, return it.
|
|
2411
|
+
|
|
2412
|
+
Blocking. Zero-gravity is on, so the jaws go slack and you push them
|
|
2413
|
+
through the motion by hand while the samples are taken. The call
|
|
2414
|
+
returns once the capture is complete; if it did not fill, it raises and
|
|
2415
|
+
says how many samples it got rather than returning a short recording.
|
|
2416
|
+
|
|
2417
|
+
Args:
|
|
2418
|
+
duration_s: How many seconds to record. The gripper is slack for
|
|
2419
|
+
that long — keep a hand on it, and be aware that the jaws hold
|
|
2420
|
+
nothing while it is slack.
|
|
2421
|
+
rate_hz: Samples per second (default 100).
|
|
2422
|
+
zero_gravity: ``False`` only if something else drives the gripper
|
|
2423
|
+
during the recording — see :meth:`record_start`.
|
|
2424
|
+
|
|
2425
|
+
Raises:
|
|
2426
|
+
ValueError: ``duration_s <= 0``.
|
|
2427
|
+
TrajectoryBusyError: another session is running.
|
|
2428
|
+
NotInitializedError: not connected or not enabled.
|
|
2429
|
+
TrajectoryError: the unit is not calibrated.
|
|
2430
|
+
TrajectoryRecordingError: the capture did not fill.
|
|
2431
|
+
|
|
2432
|
+
Example::
|
|
2433
|
+
|
|
2434
|
+
with LiteGrip("can0") as gripper:
|
|
2435
|
+
gripper.load_calibration()
|
|
2436
|
+
gripper.enable()
|
|
2437
|
+
taught = gripper.record(5.0) # push the jaws by hand
|
|
2438
|
+
taught.save("pick") # ~/.litegrip/trajectories/pick.lgt
|
|
2439
|
+
gripper.play(taught) # repeat it
|
|
2440
|
+
"""
|
|
2441
|
+
duration_s = float(duration_s)
|
|
2442
|
+
if duration_s <= 0.0:
|
|
2443
|
+
raise ValueError(f"duration_s 需 > 0 (给的是 {duration_s})")
|
|
2444
|
+
target = max(1, int(round(duration_s * float(rate_hz))))
|
|
2445
|
+
|
|
2446
|
+
self.record_start(rate_hz=rate_hz, zero_gravity=zero_gravity,
|
|
2447
|
+
max_samples=target)
|
|
2448
|
+
recorder = self._trajectory_recorder
|
|
2449
|
+
try:
|
|
2450
|
+
recorder.wait_for(target, timeout=duration_s * 1.5 + 3.0)
|
|
2451
|
+
except BaseException:
|
|
2452
|
+
# Never leave the jaws slack and the session claimed because the
|
|
2453
|
+
# capture went wrong — clean up, then let the error through.
|
|
2454
|
+
recorder.stop()
|
|
2455
|
+
self._trajectory_recorder = None
|
|
2456
|
+
self._release_session("record")
|
|
2457
|
+
raise
|
|
2458
|
+
return self.record_stop()
|
|
2459
|
+
|
|
2460
|
+
def play_start(
|
|
2461
|
+
self,
|
|
2462
|
+
trajectory: "Trajectory",
|
|
2463
|
+
speed: float = 1.0,
|
|
2464
|
+
kp: Optional[float] = None,
|
|
2465
|
+
kd: Optional[float] = None,
|
|
2466
|
+
loop: bool = False,
|
|
2467
|
+
align: bool = True,
|
|
2468
|
+
) -> dict:
|
|
2469
|
+
"""Replay a trajectory in the background.
|
|
2470
|
+
|
|
2471
|
+
Only position is replayed: the recorded torque and velocity are
|
|
2472
|
+
diagnostics, never feed-forward, so a motion recorded while gripping an
|
|
2473
|
+
object replays as a position path and *not* as the same gripping force.
|
|
2474
|
+
Follow it with :meth:`grasp` if the force matters.
|
|
2475
|
+
|
|
2476
|
+
Args:
|
|
2477
|
+
trajectory: A :class:`~litegrip.Trajectory`, from :meth:`record_stop`
|
|
2478
|
+
or :meth:`~litegrip.Trajectory.load`.
|
|
2479
|
+
speed: Timing multiplier; ``0.5`` plays at half speed.
|
|
2480
|
+
kp, kd: Gains for the replay frames; ``None`` uses the configured ones.
|
|
2481
|
+
loop: Restart at the end instead of stopping. A one-sample
|
|
2482
|
+
trajectory is a pose, so looping it holds that opening.
|
|
2483
|
+
align: Walk to the trajectory's first opening before following, at
|
|
2484
|
+
``motion_config.speed_mm_s`` and under its lead cap, so the first
|
|
2485
|
+
frame is not a full-torque step from wherever the jaws are.
|
|
2486
|
+
|
|
2487
|
+
Returns:
|
|
2488
|
+
The initial :meth:`trajectory_status` snapshot.
|
|
2489
|
+
|
|
2490
|
+
Raises:
|
|
2491
|
+
TrajectoryBusyError: another session is running.
|
|
2492
|
+
TrajectoryEmptyError: the trajectory has no samples.
|
|
2493
|
+
ValueError: ``speed <= 0``.
|
|
2494
|
+
NotInitializedError: not connected or not enabled.
|
|
2495
|
+
TrajectoryError: the unit is not calibrated — the opening has to be
|
|
2496
|
+
converted back through *this* gripper's travel, and with the
|
|
2497
|
+
placeholder limits that conversion is a guess.
|
|
2498
|
+
"""
|
|
2499
|
+
from .trajectory import (TrajectoryBusyError, TrajectoryError,
|
|
2500
|
+
TrajectoryPlayer)
|
|
2501
|
+
|
|
2502
|
+
self._check_connected()
|
|
2503
|
+
self._check_enabled()
|
|
2504
|
+
if not self._config.calibrated:
|
|
2505
|
+
raise TrajectoryError(
|
|
2506
|
+
"未标定 —— 回放要把张开度按本机行程换算回角度, 没有标定就是瞎走; "
|
|
2507
|
+
"先 load_calibration() 或 zero()")
|
|
2508
|
+
self._claim_session("play", TrajectoryBusyError)
|
|
2509
|
+
try:
|
|
2510
|
+
player = TrajectoryPlayer(
|
|
2511
|
+
self, trajectory, speed=speed, kp=kp, kd=kd, loop=loop,
|
|
2512
|
+
align=align)
|
|
2513
|
+
player.start()
|
|
2514
|
+
except BaseException:
|
|
2515
|
+
self._release_session("play")
|
|
2516
|
+
raise
|
|
2517
|
+
self._trajectory_player = player
|
|
2518
|
+
return player.status()
|
|
2519
|
+
|
|
2520
|
+
def play(
|
|
2521
|
+
self,
|
|
2522
|
+
trajectory: "Trajectory",
|
|
2523
|
+
speed: float = 1.0,
|
|
2524
|
+
kp: Optional[float] = None,
|
|
2525
|
+
kd: Optional[float] = None,
|
|
2526
|
+
loop: bool = False,
|
|
2527
|
+
align: bool = True,
|
|
2528
|
+
) -> dict:
|
|
2529
|
+
"""Replay a trajectory once, blocking until it finishes.
|
|
2530
|
+
|
|
2531
|
+
The last frame holds the final position under the configured gains, but
|
|
2532
|
+
it is *one* frame: the motor self-locks a communication-loss fault about
|
|
2533
|
+
100 ms after the frames stop. Call the next action promptly, or use
|
|
2534
|
+
:meth:`play_start` with ``loop=True`` for a hold that lasts until
|
|
2535
|
+
:meth:`play_stop`.
|
|
2536
|
+
|
|
2537
|
+
Args:
|
|
2538
|
+
loop: Must be ``False``. A blocking replay of a looping trajectory
|
|
2539
|
+
never returns; use :meth:`play_start` for that.
|
|
2540
|
+
|
|
2541
|
+
Returns:
|
|
2542
|
+
The final :meth:`trajectory_status` snapshot.
|
|
2543
|
+
|
|
2544
|
+
Raises:
|
|
2545
|
+
ValueError: ``loop`` is true, or ``speed <= 0``.
|
|
2546
|
+
TrajectoryBusyError: another session is running.
|
|
2547
|
+
TrajectoryEmptyError: the trajectory has no samples.
|
|
2548
|
+
TrajectoryError: the unit is not calibrated, or the replay stopped
|
|
2549
|
+
early — a send failed, or the sampling clock stalled.
|
|
2550
|
+
NotInitializedError: not connected or not enabled.
|
|
2551
|
+
"""
|
|
2552
|
+
from .teleop import travel_mm
|
|
2553
|
+
from .trajectory import TrajectoryError
|
|
2554
|
+
|
|
2555
|
+
if loop:
|
|
2556
|
+
raise ValueError(
|
|
2557
|
+
"loop=True 的阻塞回放永远不会返回; 要循环播放用 "
|
|
2558
|
+
"play_start(loop=True), 再用 play_stop() 停")
|
|
2559
|
+
self.play_start(trajectory, speed=speed, kp=kp, kd=kd, loop=False,
|
|
2560
|
+
align=align)
|
|
2561
|
+
player = self._trajectory_player
|
|
2562
|
+
# Wall-clock pacing, plus the align walk and a margin for a slow first
|
|
2563
|
+
# frame. The align is a bounded-speed move over up to the whole travel,
|
|
2564
|
+
# so budget its worst case rather than a flat second. A stall guard of
|
|
2565
|
+
# its own, so a stopped clock cannot hang here.
|
|
2566
|
+
align_s = 0.0
|
|
2567
|
+
align_speed_mm_s = float(getattr(self.motion_config, "speed_mm_s", 0.0) or 0.0)
|
|
2568
|
+
if align and align_speed_mm_s > 0.0:
|
|
2569
|
+
align_s = travel_mm(self._config) / align_speed_mm_s
|
|
2570
|
+
budget = (abs(float(trajectory.duration)) / float(speed) * 1.5
|
|
2571
|
+
+ align_s + 4.0)
|
|
2572
|
+
try:
|
|
2573
|
+
finished = player.wait(budget)
|
|
2574
|
+
except BaseException:
|
|
2575
|
+
# Ctrl+C during a long replay is the ordinary way out of this call.
|
|
2576
|
+
# Without the stop the player keeps commanding the motor and the
|
|
2577
|
+
# session stays claimed, so every later record/play is refused as
|
|
2578
|
+
# busy until the process ends.
|
|
2579
|
+
self.play_stop()
|
|
2580
|
+
raise
|
|
2581
|
+
status = self.play_stop()
|
|
2582
|
+
if not finished:
|
|
2583
|
+
raise TrajectoryError(
|
|
2584
|
+
f"回放未在 {budget:.1f}s 内结束 (已发 {status.get('frames', 0)} 帧) "
|
|
2585
|
+
f"—— 采样时钟可能停住了")
|
|
2586
|
+
if status.get("error") is not None:
|
|
2587
|
+
raise TrajectoryError(f"回放中止: {status['error']}")
|
|
2588
|
+
return status
|
|
2589
|
+
|
|
2590
|
+
def play_stop(self, timeout: float = 2.0) -> dict:
|
|
2591
|
+
"""Stop a replay and leave the gripper holding its last target."""
|
|
2592
|
+
player = self._trajectory_player
|
|
2593
|
+
if player is None:
|
|
2594
|
+
return {"active": False, "kind": None}
|
|
2595
|
+
player.stop(timeout=timeout)
|
|
2596
|
+
self._trajectory_player = None
|
|
2597
|
+
self._release_session("play")
|
|
2598
|
+
return player.status()
|
|
2599
|
+
|
|
2600
|
+
def trajectory_status(self) -> dict:
|
|
2601
|
+
"""Snapshot of the running recording or replay, or ``{"active": False}``.
|
|
2602
|
+
|
|
2603
|
+
One call for both directions: the ``kind`` key says which
|
|
2604
|
+
(``"record"`` / ``"play"``), and only one of them can be running.
|
|
2605
|
+
"""
|
|
2606
|
+
if self._trajectory_recorder is not None:
|
|
2607
|
+
return self._trajectory_recorder.status()
|
|
2608
|
+
if self._trajectory_player is not None:
|
|
2609
|
+
return self._trajectory_player.status()
|
|
2610
|
+
return {"active": False, "kind": None}
|
|
2611
|
+
|
|
2612
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
2613
|
+
# Internal
|
|
2614
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
2615
|
+
|
|
2616
|
+
def _not_connected_error(self) -> NotInitializedError:
|
|
2617
|
+
return NotInitializedError(
|
|
2618
|
+
"未连接 — 请先调用 connect() 或使用 with 上下文")
|
|
2619
|
+
|
|
2620
|
+
def _not_enabled_error(self) -> NotInitializedError:
|
|
2621
|
+
return NotInitializedError("未使能 — 请先调用 enable()")
|
|
2622
|
+
|
|
2623
|
+
def _check_connected(self) -> None:
|
|
2624
|
+
if not self._connected:
|
|
2625
|
+
raise self._not_connected_error()
|
|
2626
|
+
|
|
2627
|
+
def _check_enabled(self) -> None:
|
|
2628
|
+
if not self._enabled:
|
|
2629
|
+
raise self._not_enabled_error()
|
|
2630
|
+
|
|
2631
|
+
def _claim_session(self, owner: str, error: type) -> None:
|
|
2632
|
+
"""Take the single long-running session slot, or raise *error*.
|
|
2633
|
+
|
|
2634
|
+
Teleoperation, trajectory recording and trajectory replay all own the
|
|
2635
|
+
CAN I/O for their duration. Claiming happens under one lock, so two
|
|
2636
|
+
threads starting different sessions at the same instant cannot both
|
|
2637
|
+
pass the check and then both start.
|
|
2638
|
+
"""
|
|
2639
|
+
with self._session_lock:
|
|
2640
|
+
if self._session_owner is not None:
|
|
2641
|
+
raise error(
|
|
2642
|
+
f"已有会话在运行 ({self._session_owner}) —— "
|
|
2643
|
+
f"teleop、录制、回放同一时刻只能有一个")
|
|
2644
|
+
self._session_owner = owner
|
|
2645
|
+
|
|
2646
|
+
def _release_session(self, owner: str) -> None:
|
|
2647
|
+
"""Give the session slot back. Harmless if *owner* no longer holds it."""
|
|
2648
|
+
with self._session_lock:
|
|
2649
|
+
if self._session_owner == owner:
|
|
2650
|
+
self._session_owner = None
|
|
2651
|
+
|
|
2652
|
+
@property
|
|
2653
|
+
def session(self) -> Optional[str]:
|
|
2654
|
+
"""Which long-running session is active: ``None``, ``"teleop"``,
|
|
2655
|
+
``"record"`` or ``"play"``. For diagnostics and for a UI that has to
|
|
2656
|
+
disable the controls that cannot run at the same time."""
|
|
2657
|
+
return self._session_owner
|
|
2658
|
+
|
|
2659
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
2660
|
+
# Context manager
|
|
2661
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
2662
|
+
|
|
2663
|
+
def __enter__(self) -> "LiteGrip":
|
|
2664
|
+
self.connect()
|
|
2665
|
+
return self
|
|
2666
|
+
|
|
2667
|
+
def __exit__(self, *args) -> None:
|
|
2668
|
+
self.disconnect()
|
|
2669
|
+
|
|
2670
|
+
def __repr__(self) -> str:
|
|
2671
|
+
status = "已连接" if self._connected else "未连接"
|
|
2672
|
+
enabled = "已使能" if self._enabled else "未使能"
|
|
2673
|
+
mst = f"0x{self._mst_id:02X}" if self._mst_id else "auto"
|
|
2674
|
+
return (f"LiteGrip(ch={self._channel}, CAN_ID=0x{self._can_id:02X}, "
|
|
2675
|
+
f"MST_ID={mst}, {status}, {enabled})")
|
|
2676
|
+
|
|
2677
|
+
def __str__(self) -> str:
|
|
2678
|
+
return self.__repr__()
|