litegrip-python 1.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- litegrip/__init__.py +298 -0
- litegrip/actions.py +1444 -0
- litegrip/calibration_normal.json +9 -0
- litegrip/calibration_reverse.json +9 -0
- litegrip/can/__init__.py +41 -0
- litegrip/can/controller.py +413 -0
- litegrip/can/motor.py +188 -0
- litegrip/can/protocol.py +380 -0
- litegrip/can/transport.py +365 -0
- litegrip/constants.py +146 -0
- litegrip/exceptions.py +47 -0
- litegrip/factory_calibration.json +16 -0
- litegrip/gripper.py +2678 -0
- litegrip/models.py +210 -0
- litegrip/protocols/__init__.py +10 -0
- litegrip/protocols/can_bus.py +621 -0
- litegrip/py.typed +0 -0
- litegrip/teleop.py +1469 -0
- litegrip/trajectory.py +985 -0
- litegrip/zenoh_link.py +400 -0
- litegrip_python-1.1.2.dist-info/METADATA +636 -0
- litegrip_python-1.1.2.dist-info/RECORD +25 -0
- litegrip_python-1.1.2.dist-info/WHEEL +5 -0
- litegrip_python-1.1.2.dist-info/licenses/LICENSE +202 -0
- litegrip_python-1.1.2.dist-info/top_level.txt +1 -0
litegrip/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)
|