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/teleop.py ADDED
@@ -0,0 +1,1469 @@
1
+ """Leader/follower gripper teleoperation — single-DOF position mirroring.
2
+
3
+ Ported from ``litearm_device.gripper_teleop`` (the implementation that drove the
4
+ same feature inside the litearm server stack), minus the parts that only made
5
+ sense there. The algorithm is unchanged; what is new is that the transport is
6
+ pluggable and the module depends on nothing outside the standard library, so a
7
+ bare ``LiteGrip`` on a CAN bus is all it takes.
8
+
9
+ Topology::
10
+
11
+ leader (zero-gravity, hand-back-driven) --pub--> follower (MIT follow)
12
+ leader (holds under gain until ready) <--pub-- follower (at target)
13
+
14
+ ``master`` publishes its normalised opening at ``rate_hz``. ``slave``
15
+ subscribes, ramps once to the first frame's opening, then streams MIT position
16
+ frames toward the received opening. On a transport that carries the reverse
17
+ direction the follower also announces when it has arrived
18
+ (:func:`ready_topic`), and the master holds its own jaws under gain — **not**
19
+ hand-movable — until it hears that. A master that cannot subscribe to the ready
20
+ topic, or whose peer never announces, behaves as before: it goes slack
21
+ immediately, warning if it waited past :data:`DEFAULT_READY_TIMEOUT_S`.
22
+
23
+ Why the wire carries ``openness`` and not radians
24
+ -------------------------------------------------
25
+ Each gripper has its own zero, direction and calibration (one unit opens at
26
+ -1.42 rad, another at +1.14 rad), so a raw angle is meaningless on the far
27
+ side. ``openness`` is the opening normalised by the *local* travel and is
28
+ therefore dimensionless and direction-free; each side converts on its own.
29
+
30
+ Frame layout (big-endian, four doubles, 32 bytes) — byte-compatible with the
31
+ litearm implementation so the two can interoperate::
32
+
33
+ openness[0..1] | position_mm | force_n | timestamp
34
+
35
+ Safety notes
36
+ ------------
37
+ * ``openness`` is clamped to ``[0, 1]``, which keeps every commanded target
38
+ inside the calibrated travel. That clamp bounds *where* the follower may go;
39
+ ``lead_cap_mm`` bounds *how hard* the align may push, and ``torque_limit_nm``
40
+ is the backstop for the follow.
41
+ * The align move goes out through :meth:`GripperTeleop._ramp_to`, which caps how
42
+ far each frame's command may lead the measured position
43
+ (:meth:`GripperTeleop._cap_lead`). Torque is ``kp * (q_cmd - q_measured)``, so
44
+ bounding the lead bounds the commanded torque by construction — without it the
45
+ first align frame demands ``kp`` times the whole error, and at the shipped
46
+ ``kp`` of 100 Nm/rad anything past ~0.1 rad saturates the DM4310. The *follow*
47
+ loop is deliberately left uncapped so the follower stays responsive; the
48
+ ``torque_limit_nm`` guard is what protects it under load.
49
+ * When ``torque_limit_nm`` is set (``0``, the default, disables it) the follower
50
+ watches its own torque and releases when it stays over the limit for
51
+ :data:`TORQUE_TRIP_CYCLES` cycles — see
52
+ :meth:`GripperTeleop._update_torque_guard`. Position is deliberately not used
53
+ for this: a jam partway through the travel is indistinguishable from slow
54
+ motion, and the calibrated travel comes from two hand-measured limits, which
55
+ makes it the least trustworthy number in the loop.
56
+ * A **non-finite** frame (NaN / ±inf) is *dropped*, never clamped. ``_clamp01``
57
+ passes NaN through and ``min(hi, NaN)`` returns ``hi``, so clamping a NaN
58
+ target silently commands the follower to its closed stop. Bad readings are
59
+ rejected at the wire boundary on both ends; the follower then holds, which is
60
+ the safe side.
61
+ * A ``slave`` whose leader goes quiet **holds** its last target at the follow
62
+ gains (it does not relax to zero torque). The jaws therefore keep pressing
63
+ whatever is between them — the same behaviour as the litearm original. A
64
+ tripped torque guard overrides that and stays released.
65
+ * The leader's readiness gate is about **when it hands back**, never about
66
+ withholding frames: it publishes from the first cycle either way, so a
67
+ follower always has something to align to and neither end can block on the
68
+ other. ``require_ready=False`` disables the gate; ``ready_timeout_s=0`` waits
69
+ indefinitely. See :meth:`GripperTeleop._master_loop`.
70
+ * Teleoperation is exclusive: stop any motion you started elsewhere before
71
+ calling :meth:`~litegrip.LiteGrip.teleop_start`.
72
+ * :class:`UdpTeleopTransport` is plain, unauthenticated UDP. Use it only on a
73
+ trusted network. For real deployments use the point-to-point zenoh link in
74
+ :mod:`litegrip.zenoh_link` (``pip install litegrip-python[zenoh]``), which is what the
75
+ field-validated litearm teleoperation runs on.
76
+ """
77
+
78
+ from __future__ import annotations
79
+
80
+ import logging
81
+ import math
82
+ import socket
83
+ import struct
84
+ import threading
85
+ import time
86
+ from collections import deque
87
+ from typing import Any, Callable, Dict, Optional, Tuple, Union
88
+
89
+ from .exceptions import LiteGripError
90
+
91
+ log = logging.getLogger("litegrip.teleop")
92
+
93
+ # ── Frame codec ───────────────────────────────────────────────────────────
94
+
95
+ _FRAME = struct.Struct(">4d")
96
+
97
+ #: Size of one teleop frame in bytes (four doubles).
98
+ FRAME_SIZE = _FRAME.size
99
+
100
+ #: Default grip id and TCP port. The topic and the port come from the litearm
101
+ #: teleoperation namespace: the arm uses ``armA``/17447, the gripper
102
+ #: ``gripA``/17448, so one machine can run both without a collision.
103
+ DEFAULT_GRIP_ID = "gripA"
104
+ DEFAULT_GRIP_PORT = 17448
105
+
106
+ #: Default ceiling on the leader velocity the follower feeds forward, in rad/s.
107
+ #: The wire frame carries no velocity, so the follower recovers one by finite
108
+ #: difference (see :meth:`GripperTeleop._estimate_dq`); this bounds what a bad
109
+ #: estimate can demand. A hand sweep is well under 5 rad/s, and the DM4310's own
110
+ #: ``dq`` range is ±30, so this is generous for motion and tight for a glitch.
111
+ DEFAULT_DQ_MAX = 10.0
112
+
113
+ #: Longest interval a finite-difference velocity is trusted over, in seconds.
114
+ #: Past this the "velocity" would be an average across a dropout — refuse it and
115
+ #: fall back to position-only control for that cycle.
116
+ MAX_FRAME_GAP_S = 0.05
117
+
118
+ # ── [遥操对齐块] 常量 ──────────────────────────────────────────────────────
119
+ # 遥操从机启动对齐的「定速斜坡 + 每帧领先上限」。整块的作用、清单,以及
120
+ # 临时停用 / 整块移除的做法,见 GripperTeleop._ramp_to() 上方那段说明。
121
+ # 用 `grep -n '\[遥操对齐块\]' src/litegrip/teleop.py src/litegrip/gripper.py`
122
+ # 可定位本块的全部位置。
123
+ # ──────────────────────────────────────────────────────────────────────────
124
+ #: Speed the follower travels at when it aligns to the leader's opening, in mm/s
125
+ #: of jaw travel. It used to travel at whatever stiffness would get it there:
126
+ #: ``goto_rad(..., duration=1.0)`` sends ``q = q_target`` from the first frame,
127
+ #: so the whole error was commanded at once. Same number
128
+ #: ``actions.MotionConfig.speed_mm_s`` gives ``open``/``close``. Must be > 0.
129
+ DEFAULT_ALIGN_SPEED_MM_S = 50.0
130
+
131
+ #: Ceiling on how far the *align* command may lead the measured position, in mm
132
+ #: of jaw travel. Torque is ``kp * (q_cmd - q_measured)``, so bounding the lead
133
+ #: bounds the commanded torque by construction. 4 mm is 0.053 rad at the config
134
+ #: default ``rad_to_mm`` (``UnitConversion.RAD_TO_MM``, 75.44 mm/rad), i.e.
135
+ #: ~5.30 Nm at ``kp`` 100 — the bracket ``open``/``close`` already travel with
136
+ #: (``MotionConfig.max_lead_mm``). ``0`` disables the cap. The follow loop does
137
+ #: not use this: it commands the leader's opening outright so it stays
138
+ #: responsive.
139
+ DEFAULT_LEAD_CAP_MM = 4.0
140
+
141
+ #: Default ceiling on the follower's own torque, in Nm. ``0`` disables the guard,
142
+ #: which is the default: it changes the follower's behaviour under load, so a
143
+ #: caller opts in rather than inheriting it.
144
+ #:
145
+ #: Exceeding the limit means the jaws are pressing harder than a normal grasp,
146
+ #: which on a printed jaw or mount is how parts break. The status frame reports
147
+ #: torque (there is no raw milliamp field); the motor derives it from its coil
148
+ #: current, so that is the current signal to judge on. Pick the value from
149
+ #: observed torque on the machine: the 0.5 Nm ``grasp_torque_threshold`` and the
150
+ #: 2.0 Nm calibration probe ceiling bracket the useful range, and the follow
151
+ #: gains set how much position error that much torque corresponds to.
152
+ DEFAULT_TORQUE_LIMIT_NM = 0.0
153
+
154
+ #: Consecutive over-limit cycles before the guard trips. At the 50 Hz default
155
+ #: this is 60 ms, which rides out a single noisy sample without leaving a real
156
+ #: jam pressing for long.
157
+ TORQUE_TRIP_CYCLES = 3
158
+
159
+ #: Opening the leader must recover, in normalised openness, before a tripped
160
+ #: follower re-arms. Without it the release would re-engage on the next cycle
161
+ #: and press again — the guard would chatter instead of letting go. A trip
162
+ #: within this distance of the open stop has no travel left to reopen into, so
163
+ #: there the requirement is the open stop itself; see
164
+ #: :meth:`GripperTeleop._update_torque_guard`.
165
+ TORQUE_REARM_OPENNESS = 0.05
166
+
167
+ #: Follower arrival tolerance, in mm of the follower's own travel. The follower
168
+ #: announces itself ready once its measured position is this close to the target
169
+ #: it was last commanded, so the leader can hand back knowing the jaws are where
170
+ #: it thinks they are. Tight enough that the hand-off does not jump, loose
171
+ #: enough that a stiff position loop with a little following error still
172
+ #: qualifies.
173
+ DEFAULT_READY_TOLERANCE_MM = 2.0
174
+
175
+ #: How often the follower republishes its ready state, in seconds — ~10 Hz. The
176
+ #: state is also resent whenever it changes, so the leader sees a transition at
177
+ #: once; the periodic resend is for a leader that starts *after* the follower
178
+ #: arrived and would otherwise never learn it.
179
+ DEFAULT_READY_PERIOD_S = 0.1
180
+
181
+ #: How long the leader holds under gain for a ready signal before proceeding
182
+ #: anyway, in seconds. ``0`` waits indefinitely. A bounded wait is the
183
+ #: backward-compatibility answer: an older follower that never announces ready
184
+ #: must not leave a new leader holding forever, so the leader warns and relaxes
185
+ #: after this. See :meth:`GripperTeleop._relaxed`.
186
+ DEFAULT_READY_TIMEOUT_S = 10.0
187
+
188
+
189
+ def encode_frame(openness: float, position_mm: float, force_n: float,
190
+ timestamp: float) -> bytes:
191
+ """Pack one teleop frame.
192
+
193
+ Args:
194
+ openness: Normalised opening in ``[0, 1]`` (0 = closed, 1 = fully
195
+ open). The quantity that actually drives the follower.
196
+ position_mm: Leader opening in mm — diagnostic only.
197
+ force_n: Leader gripping force in N — diagnostic only.
198
+ timestamp: Sender clock in seconds — diagnostic only; the follower
199
+ judges liveness from its own receive time, not from this field.
200
+ """
201
+ return _FRAME.pack(float(openness), float(position_mm), float(force_n),
202
+ float(timestamp))
203
+
204
+
205
+ def decode_frame(payload: bytes) -> Tuple[float, float, float, float]:
206
+ """Unpack a teleop frame into ``(openness, position_mm, force_n, timestamp)``.
207
+
208
+ Raises:
209
+ ValueError: ``payload`` is not exactly :data:`FRAME_SIZE` bytes.
210
+ """
211
+ if len(payload) != FRAME_SIZE:
212
+ raise ValueError(
213
+ f"teleop frame must be {FRAME_SIZE} bytes, got {len(payload)}")
214
+ return _FRAME.unpack(payload)
215
+
216
+
217
+ # ── Ready codec ───────────────────────────────────────────────────────────
218
+
219
+ _READY_FRAME = struct.Struct(">B")
220
+
221
+ #: Size of one ready frame in bytes (a single state byte).
222
+ READY_FRAME_SIZE = _READY_FRAME.size
223
+
224
+
225
+ def encode_ready_frame(ready: bool) -> bytes:
226
+ """Pack one follower-ready frame — a single state byte.
227
+
228
+ Deliberately tiny and separate from the teleop frame: the teleop frame
229
+ carries the *leader's* pose and is byte-identical to the litearm stack's, so
230
+ a readiness bit cannot ride on it without diverging that format. This
231
+ channel is the follower -> leader direction only.
232
+ """
233
+ return _READY_FRAME.pack(1 if ready else 0)
234
+
235
+
236
+ def decode_ready_frame(payload: bytes) -> bool:
237
+ """Unpack a ready frame into ``True`` (ready) or ``False`` (not ready).
238
+
239
+ ⚠ Any non-zero byte reads as ready: a truncation or a stray value should not
240
+ silently look like "not ready". Both of those are caught at the wire
241
+ boundary instead — the leader treats an undecodable frame as no frame.
242
+
243
+ Raises:
244
+ ValueError: ``payload`` is not exactly :data:`READY_FRAME_SIZE` bytes.
245
+ """
246
+ if len(payload) != READY_FRAME_SIZE:
247
+ raise ValueError(
248
+ f"ready frame must be {READY_FRAME_SIZE} byte, got {len(payload)}")
249
+ return _READY_FRAME.unpack(payload)[0] != 0
250
+
251
+
252
+ def teleop_topic(grip_id: str = DEFAULT_GRIP_ID) -> str:
253
+ """Topic the leader publishes and the follower subscribes to.
254
+
255
+ ⚠ This is the **litearm teleoperation namespace**, shared with the arm and
256
+ gripper teleoperation stacks: ``litearm/v4/{grip_id}/gripper_teleop``. The
257
+ frame format is byte-identical, so the two interoperate — deliberately.
258
+ Both ends must agree on ``grip_id``; it defaults to ``"gripA"`` so a single
259
+ pair needs no configuration. Use a distinct id per pair when more than one
260
+ teleoperation runs on the same transport.
261
+ """
262
+ return f"litearm/v4/{grip_id}/gripper_teleop"
263
+
264
+
265
+ def ready_topic(grip_id: str = DEFAULT_GRIP_ID) -> str:
266
+ """Topic the follower publishes and the leader subscribes to.
267
+
268
+ A sibling of :func:`teleop_topic` on the same transport, in the reverse
269
+ direction, carrying the one-byte readiness state. It is *not* part of the
270
+ litearm namespace the teleop topic shares — that link is one-way and its
271
+ frame format is pinned — so a peer that does not know this topic simply
272
+ never matches it, which is the intended "old peer" behaviour.
273
+ """
274
+ return f"litearm/v4/{grip_id}/gripper_ready"
275
+
276
+
277
+ # ── Transport abstraction ─────────────────────────────────────────────────
278
+
279
+
280
+ class TeleopSubscription:
281
+ """A non-blocking subscription handle."""
282
+
283
+ def try_recv(self) -> Optional[bytes]:
284
+ """Return the next payload, or ``None`` if none is waiting."""
285
+ raise NotImplementedError
286
+
287
+ def drain_latest(self) -> Optional[bytes]:
288
+ """Discard all but the newest queued payload and return it.
289
+
290
+ Teleoperation only ever wants the latest sample, so a slow consumer
291
+ skips history instead of replaying it.
292
+ """
293
+ latest = None
294
+ while True:
295
+ msg = self.try_recv()
296
+ if msg is None:
297
+ return latest
298
+ latest = msg
299
+
300
+
301
+ class TeleopTransport:
302
+ """Publish/subscribe transport between a leader and a follower.
303
+
304
+ Implement this to carry teleop frames over anything (a different network
305
+ stack, an in-process bus, ...). Three implementations ship with the SDK:
306
+ :class:`UdpTeleopTransport`, :class:`InProcTeleopTransport`, and — for real
307
+ deployments — the point-to-point zenoh link in :mod:`litegrip.zenoh_link`.
308
+ """
309
+
310
+ def pub(self, topic: str, payload: bytes) -> None:
311
+ raise NotImplementedError
312
+
313
+ def sub(self, topic: str) -> TeleopSubscription:
314
+ raise NotImplementedError
315
+
316
+ def close(self) -> None:
317
+ """Release any resources. Idempotent."""
318
+
319
+
320
+ def _parse_addr(addr: Union[str, Tuple[str, int]]) -> Tuple[str, int]:
321
+ if isinstance(addr, str):
322
+ host, _, port = addr.rpartition(":")
323
+ if not host or not port:
324
+ raise ValueError(f"address must be 'host:port', got {addr!r}")
325
+ return host, int(port)
326
+ host, port = addr
327
+ return str(host), int(port)
328
+
329
+
330
+ class _UdpSubscription(TeleopSubscription):
331
+ def __init__(self, sock: socket.socket) -> None:
332
+ self._sock = sock
333
+
334
+ def try_recv(self) -> Optional[bytes]:
335
+ try:
336
+ data, _ = self._sock.recvfrom(2048)
337
+ return data
338
+ except (BlockingIOError, InterruptedError):
339
+ return None
340
+ except OSError as e:
341
+ log.debug("udp recv failed: %s", e)
342
+ return None
343
+
344
+
345
+ class UdpTeleopTransport(TeleopTransport):
346
+ """Plain UDP transport — the zero-dependency default.
347
+
348
+ The leader sends frames to ``pub_addr``; the follower receives on
349
+ ``bind_addr``. Either or both may be given, so one object can both send
350
+ and receive (not needed for a single leader/follower pair). Same machine:
351
+ ``"127.0.0.1:17448"``. Across machines: the peer's real address,
352
+ ``"0.0.0.0:<port>"`` to accept on every interface.
353
+
354
+ Unauthenticated and unencrypted — trusted networks only. A dropped
355
+ datagram is simply the next sample being late, which the follower's
356
+ watchdog already tolerates.
357
+ """
358
+
359
+ def __init__(self, pub_addr: Union[str, Tuple[str, int], None] = None,
360
+ bind_addr: Union[str, Tuple[str, int], None] = None) -> None:
361
+ self._pub_addr = _parse_addr(pub_addr) if pub_addr is not None else None
362
+ self._bind_addr = _parse_addr(bind_addr) if bind_addr is not None else None
363
+ self._sock: Optional[socket.socket] = None
364
+ self._sub: Optional[_UdpSubscription] = None
365
+
366
+ if self._bind_addr is not None:
367
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
368
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
369
+ sock.bind(self._bind_addr)
370
+ sock.setblocking(False)
371
+ self._sock = sock
372
+ elif self._pub_addr is not None:
373
+ self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
374
+
375
+ if self._pub_addr is None and self._bind_addr is None:
376
+ raise ValueError("UdpTeleopTransport needs pub_addr, bind_addr or both")
377
+
378
+ def pub(self, topic: str, payload: bytes) -> None:
379
+ if self._pub_addr is None or self._sock is None:
380
+ return
381
+ try:
382
+ self._sock.sendto(payload, self._pub_addr)
383
+ except OSError as e:
384
+ log.debug("udp send failed: %s", e)
385
+
386
+ def sub(self, topic: str) -> TeleopSubscription:
387
+ # Keyed on the *bind*, not on the socket: a pub-only transport still
388
+ # holds a socket, but it was never bound, is left in blocking mode, and
389
+ # a recv on it would block the caller forever rather than returning
390
+ # "nothing yet".
391
+ if self._bind_addr is None:
392
+ raise TeleopError(
393
+ "UdpTeleopTransport was built without bind_addr; it cannot subscribe")
394
+ if self._sub is None:
395
+ self._sub = _UdpSubscription(self._sock)
396
+ return self._sub
397
+
398
+ def close(self) -> None:
399
+ if self._sock is not None:
400
+ try:
401
+ self._sock.close()
402
+ except OSError:
403
+ pass
404
+ self._sock = None
405
+ self._sub = None
406
+
407
+
408
+ class _InProcSubscription(TeleopSubscription):
409
+ def __init__(self, queue: "deque[bytes]") -> None:
410
+ self._queue = queue
411
+
412
+ def try_recv(self) -> Optional[bytes]:
413
+ try:
414
+ return self._queue.popleft()
415
+ except IndexError:
416
+ return None
417
+
418
+
419
+ class InProcTeleopTransport(TeleopTransport):
420
+ """In-process bus — for tests and for two grippers in one program.
421
+
422
+ A single instance is shared by both ends; nothing crosses a process
423
+ boundary. Each topic keeps a bounded FIFO per subscriber, so a subscriber
424
+ that falls behind skips frames rather than growing without bound.
425
+ """
426
+
427
+ def __init__(self, fifo_depth: int = 16) -> None:
428
+ self._depth = fifo_depth
429
+ self._queues: Dict[str, list] = {}
430
+ self._lock = threading.Lock()
431
+
432
+ def pub(self, topic: str, payload: bytes) -> None:
433
+ with self._lock:
434
+ for queue in self._queues.get(topic, []):
435
+ queue.append(payload)
436
+ while len(queue) > self._depth:
437
+ queue.popleft()
438
+
439
+ def sub(self, topic: str) -> TeleopSubscription:
440
+ queue: "deque[bytes]" = deque()
441
+ with self._lock:
442
+ self._queues.setdefault(topic, []).append(queue)
443
+ return _InProcSubscription(queue)
444
+
445
+ def close(self) -> None:
446
+ with self._lock:
447
+ self._queues.clear()
448
+
449
+
450
+ # ── openness <-> radians conversion ───────────────────────────────────────
451
+
452
+
453
+ def travel_mm(cfg: Any) -> float:
454
+ """Full calibrated stroke in mm (``|open - closed| * rad_to_mm``)."""
455
+ return abs(cfg.pos_open_rad - cfg.pos_closed_rad) * cfg.rad_to_mm
456
+
457
+
458
+ def rad_to_openness(position_rad: float, cfg: Any) -> float:
459
+ """Motor angle -> normalised opening in ``[0, 1]``.
460
+
461
+ Uses the same sign convention as :meth:`LiteGrip.get_state`, so it is
462
+ correct for both mountings (``cfg.close_sign`` carries the direction).
463
+ """
464
+ stroke = travel_mm(cfg)
465
+ if stroke <= 0.0:
466
+ return 0.0
467
+ position_mm = ((cfg.pos_closed_rad - position_rad)
468
+ * cfg.close_sign * cfg.rad_to_mm)
469
+ return _clamp01(position_mm / stroke)
470
+
471
+
472
+ def openness_to_rad(openness: float, cfg: Any) -> float:
473
+ """Normalised opening in ``[0, 1]`` -> motor angle.
474
+
475
+ Mirrors :meth:`LiteGrip.goto_mm` including its ``close_sign`` factor, so a
476
+ reverse-mounted follower moves the correct way. ``openness`` is clamped
477
+ to ``[0, 1]`` first, which bounds the target to the calibrated travel.
478
+ """
479
+ if cfg.rad_to_mm <= 0.0:
480
+ return cfg.pos_closed_rad
481
+ openness = _clamp01(openness)
482
+ return (cfg.pos_closed_rad
483
+ - cfg.close_sign * openness * travel_mm(cfg) / cfg.rad_to_mm)
484
+
485
+
486
+ def clamp_to_calibrated(cfg: Any, q: float) -> float:
487
+ """Clamp a motor angle into the calibrated travel.
488
+
489
+ Either limit may be the larger one (a reverse mount swaps them), so this
490
+ takes ``min``/``max`` rather than assuming an order. Same source as the
491
+ SDK's own ``goto_rad`` clamp.
492
+ """
493
+ lo = min(float(cfg.pos_closed_rad), float(cfg.pos_open_rad))
494
+ hi = max(float(cfg.pos_closed_rad), float(cfg.pos_open_rad))
495
+ return max(lo, min(hi, float(q)))
496
+
497
+
498
+ def _clamp01(x: float) -> float:
499
+ """Clamp to ``[0, 1]``.
500
+
501
+ ⚠ **This does not sanitise NaN** — every comparison against NaN is false, so
502
+ NaN passes straight through, and a later ``min(hi, NaN)`` yields ``hi``.
503
+ Callers must reject non-finite values *before* clamping, not rely on the
504
+ clamp to bound them.
505
+ """
506
+ return 0.0 if x < 0.0 else (1.0 if x > 1.0 else x)
507
+
508
+
509
+ # ── exceptions ────────────────────────────────────────────────────────────
510
+
511
+
512
+ class TeleopError(LiteGripError):
513
+ """Base class for teleoperation errors."""
514
+
515
+
516
+ class TeleopBusyError(TeleopError):
517
+ """Raised when teleop is started while it is already running."""
518
+
519
+
520
+ class TeleopNotActiveError(TeleopError):
521
+ """Raised when an operation needs an active session but none is running."""
522
+
523
+
524
+ class TeleopNotReady(TeleopError):
525
+ """Raised when the gripper cannot safely be teleoperated yet.
526
+
527
+ ``send_mit_frame`` and ``goto_rad`` do **not** check ``calibrated`` — only
528
+ ``open``/``close``/``grasp`` do. Without this precheck an uncalibrated unit
529
+ is driven from placeholder limits of unknown direction.
530
+ """
531
+
532
+
533
+ def check_ready(cfg: Any) -> None:
534
+ """Verify the calibration teleoperation depends on. **Call before enable.**
535
+
536
+ Raises:
537
+ TeleopNotReady: uncalibrated, zero travel, or ``rad_to_mm == 0``.
538
+
539
+ The zero-travel test is written in **rad space** (matching the SDK); testing
540
+ ``travel_mm == 0`` in mm space is a strictly weaker condition.
541
+ """
542
+ if not bool(getattr(cfg, "calibrated", False)):
543
+ raise TeleopNotReady(
544
+ "gripper is not calibrated: pos_closed_rad / pos_open_rad are still "
545
+ "placeholder defaults and the direction is a guess. Run "
546
+ "load_calibration() / load_template(), or zero() first.")
547
+ if abs(float(cfg.pos_closed_rad) - float(cfg.pos_open_rad)) <= 1e-6:
548
+ raise TeleopNotReady(
549
+ "zero travel: pos_closed_rad equals pos_open_rad — recalibrate.")
550
+ if not float(cfg.rad_to_mm):
551
+ raise TeleopNotReady(
552
+ "rad_to_mm is 0: the openness<->radian conversion would divide by zero.")
553
+
554
+
555
+ # ── the algorithm ─────────────────────────────────────────────────────────
556
+
557
+
558
+ class GripperTeleop:
559
+ """One side of a gripper teleoperation, driven by a background thread.
560
+
561
+ ``mode="master"`` publishes its opening and, once the follower reports ready
562
+ (or ``ready_timeout_s`` passes), streams zero-torque frames so the jaws
563
+ can be moved by hand; until then it holds its own position under gain.
564
+ ``mode="slave"`` subscribes, ramps to the first frame (:meth:`_ramp_to`),
565
+ then follows every fresh sample with ``send_mit_frame``, feeding the leader's
566
+ finite-difference velocity forward as ``dq`` (:meth:`_estimate_dq`). The
567
+ ramp caps how far each frame's command may lead the measurement
568
+ (:meth:`_cap_lead`); the follow loop is uncapped so it tracks the leader
569
+ sample-for-sample. It announces ready on :func:`ready_topic` once it has
570
+ arrived.
571
+
572
+ One loop thread per side, sampling and sending in the same cycle — no
573
+ shared buffers and no contention, which is all a single-DOF gripper needs
574
+ (see the litearm original for the reasoning).
575
+
576
+ Args:
577
+ gripper: The ``LiteGrip`` this side drives.
578
+ transport: Transport used to publish (master) and, unless
579
+ ``sub_transport`` is given, to subscribe (slave).
580
+ mode: ``"master"`` or ``"slave"``.
581
+ topic: Topic to publish/subscribe.
582
+ rate_hz: Loop rate. ~50 Hz is plenty; the CAN frame stream and the
583
+ publish share the same cycle.
584
+ kp, kd: Follow gains (slave). ``None`` uses the calibration's ``kp`` /
585
+ ``kd``, falling back to ``100.0`` / ``2.0``.
586
+ align: Slave only — align to the first frame before following.
587
+ align_speed_mm_s: Slave only — speed of that align move, in mm/s of jaw
588
+ travel. Must be > 0. See :meth:`_ramp_to`.
589
+ watchdog_s: Slave only — seconds without a fresh frame before the
590
+ follower is considered stale and starts holding. Must be > 0: a
591
+ non-positive watchdog makes the follower permanently stale, which is
592
+ a session that starts and then silently does nothing.
593
+ dq_max: Slave only — ceiling in rad/s on the leader velocity fed forward
594
+ as the follower's ``dq`` target. ``0`` disables the feedforward, in
595
+ which case the follower biases on position error alone and trails a
596
+ moving leader. See :meth:`_estimate_dq`.
597
+ lead_cap_mm: Slave only — ceiling in mm on how far the *align* command
598
+ may lead the measured position, which bounds the align torque at
599
+ ``kp * lead_cap_mm / rad_to_mm``. The follow loop is not capped.
600
+ ``0`` disables the cap. See :meth:`_cap_lead`.
601
+ torque_limit_nm: Slave only — ceiling in Nm on the follower's own torque.
602
+ Held over the limit for :data:`TORQUE_TRIP_CYCLES` cycles it
603
+ releases in place and stays released until the leader reopens by
604
+ :data:`TORQUE_REARM_OPENNESS`. ``0`` disables the guard. See
605
+ :meth:`_update_torque_guard`.
606
+ ready_topic: The follower-ready channel, or ``None`` to run without a
607
+ handshake (the master then goes slack from its first cycle). When
608
+ set, the master holds under gain until a ready frame arrives and the
609
+ slave announces its arrival.
610
+ require_ready: Master only — gate the hand-back on a ready frame. Only
611
+ meaningful with ``ready_topic`` set; ``False`` restores the
612
+ immediate hand-back.
613
+ ready_timeout_s: Master only — seconds to hold for a ready signal before
614
+ proceeding anyway with a warning; ``0`` waits indefinitely. Ignored
615
+ when ``require_ready`` is ``False``.
616
+ ready_tolerance_mm: Slave only — announce ready once the measured
617
+ position is within this many mm of the commanded one.
618
+ ready_period_s: Slave only — republish the ready state at least this
619
+ often (it is also resent on every change).
620
+ sub_transport: Slave only — a separate transport to subscribe on when
621
+ the leader is remote (the master's transport is local-only).
622
+ sleep_fn, time_fn: Timing seams for tests. ``time_fn`` must be
623
+ monotonic.
624
+ """
625
+
626
+ def __init__(
627
+ self,
628
+ gripper: Any,
629
+ transport: TeleopTransport,
630
+ mode: str,
631
+ topic: str,
632
+ rate_hz: float = 50.0,
633
+ kp: Optional[float] = None,
634
+ kd: Optional[float] = None,
635
+ align: bool = True,
636
+ align_speed_mm_s: float = DEFAULT_ALIGN_SPEED_MM_S, # [遥操对齐块]
637
+ watchdog_s: float = 0.2,
638
+ dq_max: float = DEFAULT_DQ_MAX,
639
+ torque_limit_nm: float = DEFAULT_TORQUE_LIMIT_NM,
640
+ lead_cap_mm: float = DEFAULT_LEAD_CAP_MM, # [遥操对齐块]
641
+ ready_topic: Optional[str] = None,
642
+ require_ready: bool = True,
643
+ ready_timeout_s: float = DEFAULT_READY_TIMEOUT_S,
644
+ ready_tolerance_mm: float = DEFAULT_READY_TOLERANCE_MM,
645
+ ready_period_s: float = DEFAULT_READY_PERIOD_S,
646
+ sub_transport: Optional[TeleopTransport] = None,
647
+ sleep_fn: Callable[[float], None] = time.sleep,
648
+ time_fn: Callable[[], float] = time.monotonic,
649
+ ) -> None:
650
+ if mode not in ("master", "slave"):
651
+ raise ValueError(f"mode must be 'master' or 'slave', got {mode!r}")
652
+ if not rate_hz > 0.0:
653
+ raise ValueError("rate_hz must be > 0")
654
+ if not watchdog_s > 0.0:
655
+ raise ValueError("watchdog_s must be > 0")
656
+ if not float(align_speed_mm_s) > 0.0:
657
+ raise ValueError(
658
+ f"align_speed_mm_s must be > 0, got {align_speed_mm_s!r}")
659
+ if float(torque_limit_nm) < 0.0:
660
+ raise ValueError(
661
+ f"torque_limit_nm must be >= 0 (0 disables the guard), "
662
+ f"got {torque_limit_nm!r}")
663
+ if float(lead_cap_mm) < 0.0:
664
+ raise ValueError(
665
+ f"lead_cap_mm must be >= 0 (0 disables the cap), "
666
+ f"got {lead_cap_mm!r}")
667
+ if float(ready_timeout_s) < 0.0:
668
+ raise ValueError(
669
+ f"ready_timeout_s must be >= 0 (0 waits indefinitely), "
670
+ f"got {ready_timeout_s!r}")
671
+ if float(ready_tolerance_mm) < 0.0:
672
+ raise ValueError(
673
+ f"ready_tolerance_mm must be >= 0, got {ready_tolerance_mm!r}")
674
+ if not float(ready_period_s) > 0.0:
675
+ raise ValueError("ready_period_s must be > 0")
676
+ self._g = gripper
677
+ self._tp = transport
678
+ self._mode = mode
679
+ self._topic = topic
680
+ self._dt = 1.0 / rate_hz
681
+ self._kp = kp
682
+ self._kd = kd
683
+ self._align = align
684
+ self._align_speed_mm_s = float(align_speed_mm_s)
685
+ self._watchdog_s = watchdog_s
686
+ self._dq_max = float(dq_max)
687
+ self._torque_limit_nm = float(torque_limit_nm)
688
+ self._lead_cap_mm = float(lead_cap_mm)
689
+ self._ready_topic = ready_topic
690
+ self._require_ready = bool(require_ready)
691
+ self._ready_timeout_s = float(ready_timeout_s)
692
+ self._ready_tolerance_mm = float(ready_tolerance_mm)
693
+ self._ready_period_s = float(ready_period_s)
694
+ self._sub_tp = sub_transport
695
+ self._sleep_fn = sleep_fn
696
+ self._time_fn = time_fn
697
+
698
+ self._thread: Optional[threading.Thread] = None
699
+ self._running = False
700
+
701
+ # Diagnostics.
702
+ self._frames = 0
703
+ self._last_openness = 0.0
704
+ #: Last ``position_mm`` / ``force_n`` the session saw — the master reads
705
+ #: them off its own state, the slave takes them from the frame it
706
+ #: followed. Reported by :meth:`status` so a caller can show what the
707
+ #: jaws are doing without a second CAN reader (the teleop loop owns the
708
+ #: bus while it runs).
709
+ self._last_position_mm = 0.0
710
+ self._last_force_n = 0.0
711
+ self._last_frame_ts = 0.0
712
+ #: Explicit "have we ever received a frame" flag. The watchdog only
713
+ #: applies after the first frame, and this must not be inferred from a
714
+ #: ``0.0`` timestamp — see the ``LatestSlot`` note in the design spec.
715
+ self._ever_received = False
716
+ self._stale = False
717
+ self._rejected = 0
718
+ self._send_failed = 0
719
+ self._fault = ""
720
+ #: Torque guard (slave). ``_torque_nm`` is the last torque read;
721
+ #: ``_over_torque`` is latched once the limit is held for
722
+ #: ``TORQUE_TRIP_CYCLES`` cycles and clears on re-arm. See
723
+ #: :meth:`_update_torque_guard`.
724
+ self._torque_nm = 0.0
725
+ self._over_torque = False
726
+ self._torque_trips = 0
727
+ self._torque_over_cycles = 0
728
+ self._trip_openness = 0.0
729
+ #: Readiness handshake. Master side: ``_follower_ready`` is the last
730
+ #: state heard, ``_master_relaxed`` latches once the gate opens so a
731
+ #: later not-ready does not snatch control back from the operator, and
732
+ #: ``_master_t0``/``_ready_timed_out`` implement the bounded wait. Slave
733
+ #: side: ``_ready_state`` is the state it last published (``None`` =
734
+ #: nothing sent yet) and ``_ready_pub_ts`` paces the republish.
735
+ self._follower_ready = False
736
+ self._master_relaxed = False
737
+ self._master_t0: Optional[float] = None
738
+ self._ready_timed_out = False
739
+ self._ready_rx = 0
740
+ #: Master only — the live ready subscription, or ``None`` when the gate
741
+ #: is disabled or the transport cannot carry it. Set by
742
+ #: :meth:`_master_loop`.
743
+ self._ready_sub: Optional[TeleopSubscription] = None
744
+ self._ready_state: Optional[bool] = None
745
+ self._ready_pub_ts = 0.0
746
+ self._ready_pubs = 0
747
+ #: Slave only — the transport its ready frames go out on (the one that
748
+ #: reaches the leader), set by :meth:`_slave_loop`.
749
+ self._ready_tp: Optional[TeleopTransport] = None
750
+ #: Leader velocity fed forward as this cycle's ``dq`` target, and the
751
+ #: two samples it is differenced from. Seeded on the first good frame;
752
+ #: see :meth:`_estimate_dq`.
753
+ self._dq_cmd = 0.0
754
+ self._prev_q: Optional[float] = None
755
+ self._prev_rx_ts: Optional[float] = None
756
+ self._loops = 0
757
+ self._loop_hz = 0.0
758
+ self._hz_t0 = 0.0
759
+ self._hz_n0 = 0
760
+
761
+ # ── lifecycle ─────────────────────────────────────────────────────
762
+
763
+ @property
764
+ def is_running(self) -> bool:
765
+ return self._running
766
+
767
+ def start(self) -> None:
768
+ """Start the background loop. Raises :class:`TeleopBusyError` if it
769
+ is already running."""
770
+ if self._running:
771
+ raise TeleopBusyError("teleop already running")
772
+ self._running = True
773
+ self._thread = threading.Thread(
774
+ target=self._run, name=f"litegrip-teleop-{self._mode}", daemon=True)
775
+ self._thread.start()
776
+ log.info("teleop started: mode=%s topic=%s rate=%.0fHz",
777
+ self._mode, self._topic, 1.0 / self._dt)
778
+
779
+ def _run(self) -> None:
780
+ # Whatever ends the loop — a stop, a CAN error, a bad transport — the
781
+ # session is no longer active once the thread returns.
782
+ try:
783
+ if self._mode == "master":
784
+ self._master_loop()
785
+ else:
786
+ self._slave_loop()
787
+ finally:
788
+ self._running = False
789
+
790
+ def stop(self, timeout: float = 2.0) -> None:
791
+ """Stop the loop and leave the gripper holding its position.
792
+
793
+ Neither side disables the motor: the master leaves zero-gravity mode and
794
+ the slave sends one final frame at its current angle, so both hold under
795
+ gain and whatever is between the jaws stays there.
796
+ """
797
+ self._running = False
798
+ thread = self._thread
799
+ if thread is not None and thread.is_alive():
800
+ thread.join(timeout=timeout)
801
+ self._thread = None
802
+ self._handoff()
803
+ if self._mode == "slave":
804
+ # Withdraw the announcement: the next session aligns again, and a
805
+ # leader still up must not be told the follower is ready through a
806
+ # gap between sessions. Forced — the change might fall inside the
807
+ # republish period and be throttled away.
808
+ self._publish_ready(False, force=True)
809
+ log.info("teleop stopped: mode=%s frames=%d", self._mode, self._frames)
810
+
811
+ def _handoff(self) -> None:
812
+ """Leave the gripper holding position — never disable (§8 rule 4/6).
813
+
814
+ A tripped torque guard is the exception: re-applying the follow gains
815
+ would press the very thing the guard just let go of, so the final frame
816
+ stays at zero torque.
817
+ """
818
+ try:
819
+ if self._mode == "master":
820
+ # Internally one frame at the current angle under the config
821
+ # gains, which is exactly the hand-off we want.
822
+ self._g.exit_zero_gravity()
823
+ else:
824
+ q = clamp_to_calibrated(
825
+ self._g.config, self._g.get_state(wait=False).position_rad)
826
+ if self._over_torque:
827
+ self._send(q, 0.0, 0.0)
828
+ else:
829
+ self._send(q, self._resolve_kp(), self._resolve_kd())
830
+ except Exception as e: # noqa: BLE001
831
+ log.debug("hand-off on stop failed: %s", e)
832
+
833
+ def status(self) -> dict:
834
+ """A snapshot of the session, for logging and diagnostics."""
835
+ age_ms = None
836
+ if self._mode == "slave" and self._ever_received:
837
+ age_ms = (self._time_fn() - self._last_frame_ts) * 1000.0
838
+ matching = getattr(self._tp, "matching", None)
839
+ return {
840
+ "active": self._running,
841
+ "mode": self._mode,
842
+ "topic": self._topic,
843
+ "frames": self._frames,
844
+ "last_frame_age_ms": age_ms,
845
+ "stale": self._stale,
846
+ "openness": round(self._last_openness, 4),
847
+ # Position and force behind that opening — the master's own state,
848
+ # or (slave) the leader's values from the followed frame.
849
+ "position_mm": round(self._last_position_mm, 4),
850
+ "force_n": round(self._last_force_n, 4),
851
+ "loop_hz": round(self._loop_hz, 1),
852
+ # Slave only: the leader velocity last fed forward, in rad/s.
853
+ "dq_cmd": round(self._dq_cmd, 4),
854
+ # Frames dropped at the protocol boundary (non-finite values).
855
+ "rejected": self._rejected,
856
+ # ``send_mit_frame`` returned False — the motor is not following.
857
+ "send_failed": self._send_failed,
858
+ # The gripper's own error_code was not "enabled".
859
+ "fault": self._fault,
860
+ # Slave only: the follower's own torque (Nm), and whether the guard
861
+ # has released it. ``torque_trips`` counts trips this session.
862
+ "torque_nm": round(self._torque_nm, 4),
863
+ "over_torque": self._over_torque,
864
+ "torque_trips": self._torque_trips,
865
+ # Master only: whether a subscriber is matched.
866
+ "matching": matching if isinstance(matching, bool) else None,
867
+ # Readiness handshake. ``ready`` is this side's answer to "has the
868
+ # follower arrived?" — on the slave, what it last announced; on the
869
+ # master, the last state heard. ``ready_timed_out`` is the master
870
+ # proceeding without one (see :meth:`_master_loop`), and the two
871
+ # counters are the frames sent/received on the ready channel.
872
+ "ready": (bool(self._ready_state) if self._mode == "slave"
873
+ else self._follower_ready),
874
+ "ready_rx": self._ready_rx,
875
+ "ready_pubs": self._ready_pubs,
876
+ "ready_timed_out": self._ready_timed_out,
877
+ }
878
+
879
+ # ── master ────────────────────────────────────────────────────────
880
+
881
+ def _master_loop(self) -> None:
882
+ self._ready_sub = self._open_ready_sub()
883
+ if self._ready_sub is None:
884
+ log.info("[master] zero-gravity, publishing to %s", self._topic)
885
+ else:
886
+ wait = ("indefinitely" if self._ready_timeout_s <= 0.0
887
+ else f"{self._ready_timeout_s:.0f}s")
888
+ log.info("[master] holding under gain, publishing to %s; releasing "
889
+ "when %s reports ready (waiting %s)",
890
+ self._topic, self._ready_topic, wait)
891
+ self._master_t0 = self._time_fn()
892
+ try:
893
+ while self._running:
894
+ t0 = self._time_fn()
895
+ # Read before sending: a held master needs its own angle to
896
+ # hold, and a relaxed one still wants the state for the frame.
897
+ try:
898
+ state = self._g.get_state(wait=False)
899
+ except Exception: # noqa: BLE001
900
+ log.exception("[master] CAN error; loop exiting")
901
+ break
902
+ self._note_grip_fault(state)
903
+ if self._ready_sub is not None and not self._follower_ready:
904
+ self._read_ready(self._ready_sub)
905
+ # Keep the frame stream alive: the DM motor self-locks a
906
+ # "communication loss" fault ~100 ms after frames stop, so a
907
+ # frame goes out every cycle even though nothing is commanded.
908
+ # While the gate is shut it is a *hold* at the current angle —
909
+ # a zero-torque frame there would make the jaws hand-movable,
910
+ # which is the very thing the gate exists to prevent.
911
+ try:
912
+ if self._relaxed():
913
+ self._send(0.0, 0.0, 0.0, quiet=True)
914
+ else:
915
+ self._send(clamp_to_calibrated(
916
+ self._g.config, state.position_rad),
917
+ self._resolve_kp(), self._resolve_kd())
918
+ except Exception: # noqa: BLE001
919
+ log.exception("[master] CAN error; loop exiting")
920
+ break
921
+ openness = rad_to_openness(state.position_rad, self._g.config)
922
+ if not all(math.isfinite(v) for v in
923
+ (openness, state.position_mm, state.force_n)):
924
+ # Bad reading: publish nothing. The follower's watchdog then
925
+ # times out and **holds**, which is the safe side. Sending
926
+ # the frame would hand the follower a NaN target.
927
+ self._count_rejected(openness)
928
+ else:
929
+ self._last_openness = openness
930
+ self._last_position_mm = float(state.position_mm)
931
+ self._last_force_n = float(state.force_n)
932
+ try:
933
+ self._tp.pub(self._topic, encode_frame(
934
+ openness, state.position_mm, state.force_n,
935
+ self._time_fn()))
936
+ except Exception as e: # noqa: BLE001
937
+ log.debug("[master] publish failed: %s", e)
938
+ self._frames += 1
939
+ self._sleep_rest(t0)
940
+ finally:
941
+ log.info("[master] loop exited (%d frames sent)", self._frames)
942
+
943
+ # ── slave ─────────────────────────────────────────────────────────
944
+
945
+ def _slave_loop(self) -> None:
946
+ transport = self._sub_tp or self._tp
947
+ # The ready frames go back the same way the teleop frames came in, so
948
+ # they travel the link that is already proven to reach the leader.
949
+ self._ready_tp = transport
950
+ sub = transport.sub(self._topic)
951
+ cfg = self._g.config
952
+ log.info("[slave] subscribed %s (align=%s watchdog=%.0fms)",
953
+ self._topic, self._align, self._watchdog_s * 1000.0)
954
+
955
+ # Until a frame arrives, hold wherever the jaws already are. A ``0.0``
956
+ # target here would be a real position command — the open or the closed
957
+ # stop, depending on the mount.
958
+ here = self._g.get_state(wait=False)
959
+ q_cmd = clamp_to_calibrated(cfg, here.position_rad)
960
+ # Announce "not ready" up front: a leader already holding must not read
961
+ # a stale ready from the previous session as "this one is aligned".
962
+ self._publish_ready(False, force=True)
963
+
964
+ if self._align:
965
+ first = self._wait_first_frame(sub, timeout_s=5.0, hold_q=q_cmd)
966
+ if first is not None:
967
+ # ``q_cmd`` becomes the align target *before* the ramp so the
968
+ # follow loop keeps holding it if the leader then goes quiet;
969
+ # leaving it at the pre-align position would drive the jaws back
970
+ # the moment the ramp finished.
971
+ q_cmd = clamp_to_calibrated(
972
+ cfg, openness_to_rad(_clamp01(first[0]), cfg))
973
+ self._last_frame_ts = self._time_fn()
974
+ self._ever_received = True
975
+ self._last_openness = _clamp01(first[0])
976
+ self._last_position_mm = float(first[1])
977
+ self._last_force_n = float(first[2])
978
+ # [遥操对齐块] 定速斜坡 + 领先上限对齐(说明见 _ramp_to() 上方)。
979
+ # 退回旧写法就把下一行换成:
980
+ # self._g.goto_rad(q_cmd, kp=self._resolve_kp(),
981
+ # kd=self._resolve_kd(), duration=1.0)
982
+ # (goto_rad 现在走的是同一条有界斜坡 —— 那一行不再是「第一帧满
983
+ # 力矩」;但它不经过本块的 _update_torque_guard。)
984
+ self._ramp_to(q_cmd)
985
+ else:
986
+ log.warning("[slave] no frame within align timeout; "
987
+ "holding current position")
988
+
989
+ try:
990
+ while self._running:
991
+ t0 = self._time_fn()
992
+ # Only a fresh frame sets a velocity; a hold cycle is ``dq = 0``.
993
+ dq_cmd = 0.0
994
+ msg = sub.drain_latest()
995
+ if msg is not None:
996
+ try:
997
+ openness, _mm, _force, _ts = decode_frame(msg)
998
+ except ValueError as e:
999
+ log.debug("[slave] ignoring bad frame: %s", e)
1000
+ else:
1001
+ # Protocol boundary: reject non-finite values. Clamping
1002
+ # a NaN target folds it onto a hard stop, silently.
1003
+ if not all(math.isfinite(v) for v in (openness, _mm, _force)):
1004
+ self._count_rejected(openness)
1005
+ else:
1006
+ rx_ts = self._time_fn()
1007
+ q_cmd = openness_to_rad(_clamp01(openness), cfg)
1008
+ dq_cmd = self._estimate_dq(q_cmd, rx_ts)
1009
+ self._prev_q = q_cmd
1010
+ self._prev_rx_ts = rx_ts
1011
+ self._last_openness = _clamp01(openness)
1012
+ self._last_position_mm = float(_mm)
1013
+ self._last_force_n = float(_force)
1014
+ self._last_frame_ts = rx_ts
1015
+ self._ever_received = True
1016
+ self._frames += 1
1017
+ self._stale = False
1018
+ elif (self._ever_received
1019
+ and (self._time_fn() - self._last_frame_ts) > self._watchdog_s):
1020
+ if not self._stale:
1021
+ log.warning("[slave] frames stale (>%.0fms); holding position",
1022
+ self._watchdog_s * 1000.0)
1023
+ self._stale = True
1024
+
1025
+ # Always send — including while stale. The frame both holds
1026
+ # the position and keeps the motor from self-locking.
1027
+ q_cmd = clamp_to_calibrated(cfg, q_cmd)
1028
+ self._dq_cmd = dq_cmd
1029
+ try:
1030
+ # Read the state first: this cycle's send is decided from
1031
+ # this cycle's torque. The read is the same cached poll the
1032
+ # fault check always did, so the guard costs no extra traffic
1033
+ # and runs at the loop rate.
1034
+ state = self._g.get_state(wait=False)
1035
+ self._note_grip_fault(state)
1036
+ self._update_torque_guard(state)
1037
+ self._announce_ready(state, cfg, q_cmd)
1038
+ if self._over_torque:
1039
+ # Released in place: zero stiffness and damping, but the
1040
+ # frames keep flowing so the motor does not latch its
1041
+ # comm-loss fault.
1042
+ self._send(q_cmd, 0.0, 0.0)
1043
+ else:
1044
+ # Deliberately uncapped: the follower commands the
1045
+ # leader's opening outright so it stays responsive. Only
1046
+ # the align ramp caps its lead (:meth:`_ramp_to`); the
1047
+ # guard above is what protects the follow under load.
1048
+ self._send(q_cmd, self._resolve_kp(), self._resolve_kd(),
1049
+ dq=dq_cmd)
1050
+ except Exception: # noqa: BLE001
1051
+ log.exception("[slave] CAN error; loop exiting")
1052
+ break
1053
+ self._sleep_rest(t0)
1054
+ finally:
1055
+ log.info("[slave] loop exited (%d frames received)", self._frames)
1056
+
1057
+ # ── helpers ───────────────────────────────────────────────────────
1058
+
1059
+ def _wait_first_frame(self, sub: TeleopSubscription, timeout_s: float,
1060
+ hold_q: float) -> Optional[Tuple[float, float, float, float]]:
1061
+ """Wait for the first frame, holding position while we wait.
1062
+
1063
+ ⚠ A **non-finite** frame is skipped and the wait continues, never
1064
+ returned: this value feeds the align ramp, and a NaN folds onto an end
1065
+ stop — one bad frame would pull the follower to the closed limit, on the
1066
+ ``align=True`` default path, before the loop's own guard could see it.
1067
+ ⚠ The wait **keeps sending hold frames**: staying silent for up to 5 s
1068
+ contradicts this module's own "stop sending ⇒ lose force" rule, and the
1069
+ gripper may be holding something.
1070
+ """
1071
+ deadline = self._time_fn() + timeout_s
1072
+ while self._running and self._time_fn() < deadline:
1073
+ msg = sub.drain_latest()
1074
+ if msg is not None:
1075
+ try:
1076
+ values = decode_frame(msg)
1077
+ except ValueError:
1078
+ continue
1079
+ if all(math.isfinite(v) for v in values):
1080
+ return values
1081
+ self._count_rejected(values[0])
1082
+ try:
1083
+ self._send(hold_q, self._resolve_kp(), self._resolve_kd())
1084
+ self._note_grip_fault(self._g.get_state(wait=False))
1085
+ except Exception: # noqa: BLE001
1086
+ log.exception("[slave] CAN error during align; loop exiting")
1087
+ return None
1088
+ self._sleep_fn(0.01)
1089
+ return None
1090
+
1091
+ # ── readiness handshake ───────────────────────────────────────────
1092
+
1093
+ def _gate_enabled(self) -> bool:
1094
+ """Whether this session *wants* the leader-side gate."""
1095
+ return (self._mode == "master" and self._require_ready
1096
+ and self._ready_topic is not None)
1097
+
1098
+ def _open_ready_sub(self) -> Optional[TeleopSubscription]:
1099
+ """Subscribe to the ready channel, or ``None`` if the gate is off.
1100
+
1101
+ ⚠ A transport that cannot subscribe must not be fatal. The UDP master
1102
+ publishes without binding — there is no receive path on it at all — so
1103
+ the gate is simply disabled and the leader goes slack as it always did.
1104
+ Treating "no channel" as "not ready" would hold the leader under gain
1105
+ for the full timeout on a link that can never carry the answer.
1106
+ """
1107
+ if not self._gate_enabled():
1108
+ return None
1109
+ try:
1110
+ return (self._sub_tp or self._tp).sub(self._ready_topic)
1111
+ except Exception as e: # noqa: BLE001
1112
+ log.warning("[master] cannot subscribe to %s (%s); the readiness "
1113
+ "gate is off", self._ready_topic, e)
1114
+ return None
1115
+
1116
+ def _read_ready(self, sub: TeleopSubscription) -> None:
1117
+ """Drain the ready channel. The newest frame wins."""
1118
+ msg = sub.drain_latest()
1119
+ if msg is None:
1120
+ return
1121
+ try:
1122
+ ready = decode_ready_frame(msg)
1123
+ except ValueError as e:
1124
+ log.debug("[master] ignoring bad ready frame: %s", e)
1125
+ return
1126
+ self._ready_rx += 1
1127
+ if ready != self._follower_ready:
1128
+ log.info("[master] follower %s", "ready" if ready else "not ready")
1129
+ self._follower_ready = ready
1130
+
1131
+ def _relaxed(self) -> bool:
1132
+ """Whether the leader may hand back this cycle — **latched**.
1133
+
1134
+ The gate opens on the first ready frame, or, so that an older follower
1135
+ that never announces cannot deadlock a new leader, once
1136
+ ``ready_timeout_s`` has passed — with a warning. It then stays open:
1137
+ taking the jaws away from the operator mid-session because a frame went
1138
+ missing would be worse than the transient the gate guards against.
1139
+ """
1140
+ if self._master_relaxed:
1141
+ return True
1142
+ if self._ready_sub is None:
1143
+ return True
1144
+ if self._follower_ready:
1145
+ self._master_relaxed = True
1146
+ log.info("[master] follower ready — handing back zero-gravity")
1147
+ return True
1148
+ if self._ready_timeout_s <= 0.0:
1149
+ return False
1150
+ if (self._master_t0 is not None
1151
+ and (self._time_fn() - self._master_t0) >= self._ready_timeout_s):
1152
+ self._master_relaxed = True
1153
+ self._ready_timed_out = True
1154
+ log.warning("[master] no ready signal within %.0fs; releasing the "
1155
+ "hand-back anyway (an older follower may not announce)",
1156
+ self._ready_timeout_s)
1157
+ return True
1158
+ return False
1159
+
1160
+ def _announce_ready(self, state: Any, cfg: Any, q_cmd: float) -> None:
1161
+ """Publish the follower's readiness for this cycle (slave)."""
1162
+ if self._ready_topic is None:
1163
+ return
1164
+ measured = float(state.position_rad)
1165
+ at_target = (abs(float(q_cmd) - measured) * float(cfg.rad_to_mm)
1166
+ <= self._ready_tolerance_mm)
1167
+ self._publish_ready(self._ever_received and not self._stale
1168
+ and not self._over_torque and at_target)
1169
+
1170
+ def _publish_ready(self, ready: bool, force: bool = False) -> None:
1171
+ """Send one ready frame, throttled to a change or ``ready_period_s``.
1172
+
1173
+ Best-effort: the channel is advisory, so a transport that cannot carry
1174
+ it (or drops this one frame) must not affect the follow loop.
1175
+ """
1176
+ if self._ready_topic is None or self._ready_tp is None:
1177
+ return
1178
+ now = self._time_fn()
1179
+ if (not force and ready == self._ready_state
1180
+ and (now - self._ready_pub_ts) < self._ready_period_s):
1181
+ return
1182
+ try:
1183
+ self._ready_tp.pub(self._ready_topic, encode_ready_frame(ready))
1184
+ except Exception as e: # noqa: BLE001
1185
+ log.debug("[slave] ready publish failed: %s", e)
1186
+ return
1187
+ if ready != self._ready_state:
1188
+ log.info("[slave] %s", "ready — the leader may hand back" if ready
1189
+ else "not ready")
1190
+ self._ready_state = ready
1191
+ self._ready_pub_ts = now
1192
+ self._ready_pubs += 1
1193
+
1194
+ # ═══════════════════════════════════════════════════════════════════════
1195
+ # [遥操对齐块] 遥操从机启动对齐的安全措施 —— 可整块注释 / 删除
1196
+ #
1197
+ # 作用:把"对齐到 leader 第一帧"从「一帧 goto_rad 把整个误差一次发出去」
1198
+ # 改成「定速斜坡(align_speed_mm_s)+ 每帧把指令领先夹到 lead_cap_mm」。
1199
+ # 力矩 = kp × (q_cmd − q_measured),夹住领先就从构造上夹住了力矩 —— 开盘
1200
+ # 那一帧不再以满力矩顶过去(曾有从机被这样怼进硬限位、撞断打印限位)。
1201
+ # 顺带把对齐纳入 _update_torque_guard:卡住会像正常一样失力释放。
1202
+ #
1203
+ # 本块清单(grep -n '\[遥操对齐块\]' src/litegrip/teleop.py src/litegrip/gripper.py):
1204
+ # - 常量 DEFAULT_ALIGN_SPEED_MM_S / DEFAULT_LEAD_CAP_MM
1205
+ # - 形参 __init__ 的 align_speed_mm_s / lead_cap_mm(含校验与赋值)
1206
+ # - 方法 _lead_cap_rad / _cap_lead / _ramp_to(本段)
1207
+ # - 调用点 self._ramp_to(q_cmd)(slave 对齐段)
1208
+ # - 透传 gripper.py 的 teleop_start(align_speed_mm_s=, lead_cap_mm=)
1209
+ #
1210
+ # 临时停用(不动代码):teleop_start(lead_cap_mm=0) 关掉领先上限;
1211
+ # align_speed_mm_s 只调对齐速度。
1212
+ # 整块移除:删掉上面清单,并把 _ramp_to(q_cmd) 换成
1213
+ # self._g.goto_rad(q_cmd, kp=self._resolve_kp(),
1214
+ # kd=self._resolve_kd(), duration=1.0)
1215
+ # (写这一块时 duration 只是 deadline、不是斜坡,所以第一帧就是满误差 ——
1216
+ # 那正是本块要修的隐患。goto_rad 现在自己也走同一条有界斜坡,那一行不再
1217
+ # 是满力矩阶跃;但它不经过本块的 _update_torque_guard。)
1218
+ # ═══════════════════════════════════════════════════════════════════════
1219
+
1220
+ def _lead_cap_rad(self) -> float:
1221
+ """The lead cap in radians, or ``0.0`` when it is disabled.
1222
+
1223
+ ``rad_to_mm`` is the only conversion the cap needs and teleop already
1224
+ refuses to start without it (``check_ready``); treating a missing or zero
1225
+ one as "disabled" keeps this from dividing by zero on a caller that
1226
+ bypassed that check.
1227
+ """
1228
+ rad_to_mm = float(getattr(self._g.config, "rad_to_mm", 0.0) or 0.0)
1229
+ if self._lead_cap_mm <= 0.0 or rad_to_mm <= 0.0:
1230
+ return 0.0
1231
+ return self._lead_cap_mm / rad_to_mm
1232
+
1233
+ def _cap_lead(self, q_target: float, pos_rad: float) -> float:
1234
+ """Pull a position command back to within the lead cap of the measurement.
1235
+
1236
+ The follower is a position loop whose gain is in Nm/rad, so the torque it
1237
+ commands is ``kp * (q_target - pos_rad)``. Bounding that difference
1238
+ bounds the torque by construction, which is the point: the align's first
1239
+ frame hands over the leader's whole opening at once, and an uncapped one
1240
+ would demand ``kp`` times the full error — at the shipped ``kp`` of 100
1241
+ an error over ~0.1 rad saturates the DM4310 (10 Nm). Capping does not
1242
+ slow the move down for free: the jaws still travel, they just press with
1243
+ a bounded torque while they catch up. Only the align ramp calls this.
1244
+ """
1245
+ cap = self._lead_cap_rad()
1246
+ if cap <= 0.0:
1247
+ return q_target
1248
+ lead = q_target - pos_rad
1249
+ if lead > cap:
1250
+ return pos_rad + cap
1251
+ if lead < -cap:
1252
+ return pos_rad - cap
1253
+ return q_target
1254
+
1255
+ def _ramp_to(self, target_rad: float) -> None:
1256
+ """Travel to ``target_rad`` at :attr:`_align_speed_mm_s`, capped every frame.
1257
+
1258
+ The align used to be one ``goto_rad(..., duration=1.0)``. ``duration``
1259
+ reads like a ramp, but the CAN layer sends ``q = q_target`` from the
1260
+ first frame and merely holds it until the deadline
1261
+ (``protocols/can_bus.py``'s ``control_mit_stream``), so the first frame
1262
+ demanded ``kp`` times the whole error — a full-torque step wherever the
1263
+ jaws happened to start, which drove a follower into the closed hard stop
1264
+ hard enough to break its printed limit. Instead this is the profile
1265
+ ``open``/``close`` already travel with (``actions.MotionConfig``): a
1266
+ constant-speed schedule, the same speed fed forward as ``dq``, and
1267
+ :meth:`_cap_lead` on every frame.
1268
+
1269
+ Sending through this module's own path also puts the align under
1270
+ :meth:`_update_torque_guard`, which the blocking ``goto_rad`` was outside
1271
+ of — a jam during the align now releases like any other.
1272
+ """
1273
+ cfg = self._g.config
1274
+ try:
1275
+ state = self._g.get_state(wait=False)
1276
+ except Exception as e: # noqa: BLE001
1277
+ log.warning("[slave] align state read failed: %s", e)
1278
+ return
1279
+ start = clamp_to_calibrated(cfg, state.position_rad)
1280
+ dist_rad = target_rad - start
1281
+ rad_to_mm = float(getattr(cfg, "rad_to_mm", 0.0) or 0.0)
1282
+ speed_mm_s = self._align_speed_mm_s
1283
+ if rad_to_mm <= 0.0 or abs(dist_rad) < 1e-9:
1284
+ # Nothing to schedule against. One capped frame still bounds the
1285
+ # torque; an uncapped one would be the bug this method exists to fix.
1286
+ self._send(self._cap_lead(target_rad, state.position_rad),
1287
+ self._resolve_kp(), self._resolve_kd())
1288
+ return
1289
+ speed_rad_s = speed_mm_s / rad_to_mm
1290
+ sign = 1.0 if dist_rad >= 0.0 else -1.0
1291
+ steps = max(1, int(round(abs(dist_rad) / speed_rad_s / self._dt)))
1292
+ log.info("[slave] aligning: %+.4f -> %+.4f rad at %.1f mm/s "
1293
+ "(%d frames, cap %.4f rad)",
1294
+ start, target_rad, speed_mm_s, steps, self._lead_cap_rad())
1295
+ for i in range(1, steps + 1):
1296
+ if not self._running:
1297
+ return
1298
+ t0 = self._time_fn()
1299
+ try:
1300
+ state = self._g.get_state(wait=False)
1301
+ self._note_grip_fault(state)
1302
+ self._update_torque_guard(state)
1303
+ if self._over_torque:
1304
+ # Released in place, exactly as the follow loop would. Hold
1305
+ # where the jaws are; do not keep driving into whatever
1306
+ # tripped the guard.
1307
+ self._send(clamp_to_calibrated(cfg, state.position_rad),
1308
+ 0.0, 0.0)
1309
+ return
1310
+ q_sched = start + dist_rad * (i / steps)
1311
+ self._send(self._cap_lead(q_sched, state.position_rad),
1312
+ self._resolve_kp(), self._resolve_kd(),
1313
+ dq=sign * speed_rad_s)
1314
+ except Exception: # noqa: BLE001
1315
+ log.exception("[slave] CAN error during align; loop exiting")
1316
+ return
1317
+ rest = self._dt - (self._time_fn() - t0)
1318
+ if rest > 0.0:
1319
+ self._sleep_fn(rest)
1320
+
1321
+ def _estimate_dq(self, q: float, rx_ts: float) -> float:
1322
+ """Leader velocity in rad/s, differenced from the previous good frame.
1323
+
1324
+ The gripper wire frame carries no velocity field, so the follower
1325
+ recovers one here and feeds it forward as its own ``dq`` target — this
1326
+ stands in for the ``dq`` the arm teleoperation sends outright. Without
1327
+ it the follower biases on position error alone and visibly trails a
1328
+ moving leader (the lag scales with speed / ``kp``).
1329
+
1330
+ Deliberately conservative: nothing to difference against on the first
1331
+ frame, a degenerate or dropout-sized interval is refused, and the result
1332
+ is clamped to ``dq_max``. ``kd * (dq - dq_measured)`` is a real torque
1333
+ term, so an unbounded estimate could command a large one.
1334
+ """
1335
+ if (self._dq_max <= 0.0 or self._prev_q is None
1336
+ or self._prev_rx_ts is None):
1337
+ return 0.0
1338
+ dt = rx_ts - self._prev_rx_ts
1339
+ if not 1e-4 < dt < MAX_FRAME_GAP_S:
1340
+ return 0.0
1341
+ dq = (q - self._prev_q) / dt
1342
+ if not math.isfinite(dq):
1343
+ return 0.0
1344
+ return max(-self._dq_max, min(self._dq_max, dq))
1345
+
1346
+ def _count_rejected(self, got: Any) -> None:
1347
+ """Record a frame dropped at the protocol boundary (§8 rule 9)."""
1348
+ self._rejected += 1
1349
+ if self._rejected == 1:
1350
+ log.warning("[%s] dropped a non-finite frame (openness=%r) — "
1351
+ "holding position rather than folding onto a stop",
1352
+ self._mode, got)
1353
+
1354
+ def _send(self, q: float, kp: float, kd: float, dq: float = 0.0,
1355
+ quiet: bool = False) -> None:
1356
+ """Send one MIT frame and **check the return value** (§8 rule 10).
1357
+
1358
+ ``dq`` is the follower's velocity target: zero for a hold (and for the
1359
+ master's slack frames), the leader's finite-difference velocity for a
1360
+ follower cycle — see :meth:`_estimate_dq`.
1361
+
1362
+ ``send_mit_frame`` returns ``False`` when the motor is not enabled or the
1363
+ CAN write fails — it does not raise, so ignoring the result means
1364
+ believing we are driving a gripper that is not moving.
1365
+
1366
+ The first failure of a session is logged with the reason the send
1367
+ recorded (``last_send_error``), not with both possibilities: a slave loop
1368
+ must not abort on one lost frame, so the log line is the only report the
1369
+ operator gets, and it should not make them check the 24 V supply when the
1370
+ interface went down.
1371
+ """
1372
+ if not self._g.send_mit_frame(q=q, kp=kp, kd=kd, dq=dq):
1373
+ self._send_failed += 1
1374
+ if self._send_failed == 1 and not quiet:
1375
+ error = getattr(self._g, "last_send_error", None)
1376
+ reason = "no reason recorded" if error is None else repr(error)
1377
+ log.warning("[%s] send_mit_frame returned False (%s) — the "
1378
+ "gripper may not be moving at all",
1379
+ self._mode, reason)
1380
+
1381
+ def _note_grip_fault(self, state: Any) -> None:
1382
+ """Consume the gripper's own ``error_code`` (§8 rule 10).
1383
+
1384
+ Polling without reading ``error_code`` is the same as not polling: ``1``
1385
+ means enabled, ``0`` disabled, anything else is a real fault (over-temp,
1386
+ over-current). Only the first one is reported.
1387
+ """
1388
+ code = int(getattr(state, "error_code", 1))
1389
+ if code != 1 and not self._fault:
1390
+ self._fault = (f"gripper reports error_code={code}"
1391
+ + (" (disabled)" if code == 0 else " (fault)")
1392
+ + " — still streaming hold frames")
1393
+ log.warning("[%s] %s", self._mode, self._fault)
1394
+
1395
+ def _update_torque_guard(self, state: Any) -> None:
1396
+ """Release the follower when its torque stays over the limit.
1397
+
1398
+ Torque is the follower's own reading, not the leader's ``force_n`` on the
1399
+ wire: the jaws press whatever is between them, and the status frame's
1400
+ ``tau`` (derived from coil current) is the only signal that sees it.
1401
+ Position is not used — a jam partway through the travel looks like slow
1402
+ motion, and the calibrated limits are the loop's least trustworthy
1403
+ numbers.
1404
+
1405
+ Latching matters: releasing in place drops the torque to zero, so a
1406
+ non-latching guard would re-engage on the very next cycle and chatter.
1407
+ The follower re-arms only once the leader has reopened by
1408
+ :data:`TORQUE_REARM_OPENNESS`, i.e. once the operator has backed off.
1409
+
1410
+ That target is capped at the open stop. A trip within the margin of
1411
+ full open has no travel left to reopen into, and an uncapped target
1412
+ there is unreachable: the guard stayed off for the rest of the session
1413
+ while its own log line asked for a movement the stroke does not allow.
1414
+ Capped, such a trip re-arms when the leader reaches the open stop —
1415
+ the most back-off that exists at that end.
1416
+
1417
+ ``torque_limit_nm == 0`` disables the guard entirely.
1418
+ """
1419
+ self._torque_nm = float(getattr(state, "torque_nm", 0.0) or 0.0)
1420
+ if self._torque_limit_nm <= 0.0:
1421
+ return
1422
+ if self._over_torque:
1423
+ reopen_needed = min(self._trip_openness + TORQUE_REARM_OPENNESS, 1.0)
1424
+ if self._last_openness >= reopen_needed:
1425
+ self._over_torque = False
1426
+ self._torque_over_cycles = 0
1427
+ log.info("[%s] torque guard re-armed (leader reopened to %.3f; "
1428
+ "needed %.3f)", self._mode, self._last_openness,
1429
+ reopen_needed)
1430
+ return
1431
+ if abs(self._torque_nm) >= self._torque_limit_nm:
1432
+ self._torque_over_cycles += 1
1433
+ if self._torque_over_cycles >= TORQUE_TRIP_CYCLES:
1434
+ self._over_torque = True
1435
+ self._torque_trips += 1
1436
+ self._trip_openness = self._last_openness
1437
+ log.warning(
1438
+ "[%s] torque %.3f Nm >= limit %.3f Nm for %d cycles — "
1439
+ "releasing in place (reopen the leader to %.2f to re-arm)",
1440
+ self._mode, self._torque_nm, self._torque_limit_nm,
1441
+ TORQUE_TRIP_CYCLES,
1442
+ min(self._last_openness + TORQUE_REARM_OPENNESS, 1.0))
1443
+ else:
1444
+ self._torque_over_cycles = 0
1445
+
1446
+ def _resolve_kp(self) -> float:
1447
+ """Follow gain: the explicit one, else the calibration's ``kp``."""
1448
+ if self._kp is not None:
1449
+ return float(self._kp)
1450
+ return float(getattr(self._g.config, "kp", 100.0))
1451
+
1452
+ def _resolve_kd(self) -> float:
1453
+ if self._kd is not None:
1454
+ return float(self._kd)
1455
+ return float(getattr(self._g.config, "kd", 2.0))
1456
+
1457
+ def _sleep_rest(self, t0: float) -> None:
1458
+ self._loops += 1
1459
+ now = self._time_fn()
1460
+ if self._hz_t0 == 0.0:
1461
+ self._hz_t0 = now
1462
+ self._hz_n0 = self._loops
1463
+ elif now - self._hz_t0 >= 1.0:
1464
+ self._loop_hz = (self._loops - self._hz_n0) / (now - self._hz_t0)
1465
+ self._hz_t0 = now
1466
+ self._hz_n0 = self._loops
1467
+ rest = self._dt - (self._time_fn() - t0)
1468
+ if rest > 0.0:
1469
+ self._sleep_fn(rest)