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/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__()