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/zenoh_link.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""Pure point-to-point zenoh link for gripper teleoperation.
|
|
2
|
+
|
|
3
|
+
This is a port of ``litearm-teleop-isomorphic/liteteleop/link.py`` — the transport
|
|
4
|
+
structure the arm teleoperation runs on in the field today. It is not a new
|
|
5
|
+
design; the function names, the config keys and the endpoint roles are kept
|
|
6
|
+
identical so the two can be compared line by line. What is new here is only the
|
|
7
|
+
:class:`ZenohTeleopTransport` adapter, which presents the same behaviour through
|
|
8
|
+
this SDK's :class:`~litegrip.TeleopTransport` interface.
|
|
9
|
+
|
|
10
|
+
Both ends run ``mode="peer"`` with **all discovery off** (no multicast scouting,
|
|
11
|
+
no gossip): the only way two peers find each other is the explicit
|
|
12
|
+
``listen``/``connect`` endpoints given here. The leader listens, the follower
|
|
13
|
+
connects.
|
|
14
|
+
|
|
15
|
+
Two rules worth losing a session over:
|
|
16
|
+
|
|
17
|
+
* **The process will not exit until ``close()`` is called.** This was measured,
|
|
18
|
+
not assumed — an un-closed session leaves the interpreter hanging and only
|
|
19
|
+
``timeout`` kills it.
|
|
20
|
+
* **The topic is shared with the litearm arm/gripper stack**
|
|
21
|
+
(``litearm/v4/{id}/gripper_teleop``). The frame *format* is byte-identical to
|
|
22
|
+
that stack, so the two interoperate — deliberately. Sharing a topic across
|
|
23
|
+
divergent formats would decode silently wrong, which is why the format is
|
|
24
|
+
pinned in :mod:`litegrip.teleop`.
|
|
25
|
+
|
|
26
|
+
The readiness channel (``litearm/v4/{id}/gripper_ready``, see
|
|
27
|
+
:func:`~litegrip.teleop.ready_topic`) is a local extension: it is a second key on
|
|
28
|
+
the same session, in the reverse direction, and it is **not** part of the litearm
|
|
29
|
+
link — a litearm peer simply never declares it. It is only wired when a
|
|
30
|
+
``ready_key`` is passed, so an endpoint built without one is one-way, exactly as
|
|
31
|
+
before.
|
|
32
|
+
|
|
33
|
+
zenoh is an optional dependency: install it with ``pip install litegrip-python[zenoh]``.
|
|
34
|
+
This module is imported lazily so the base SDK still works without it.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import logging
|
|
40
|
+
import threading
|
|
41
|
+
import time
|
|
42
|
+
from typing import Callable, Optional
|
|
43
|
+
|
|
44
|
+
import zenoh
|
|
45
|
+
|
|
46
|
+
from .teleop import (DEFAULT_GRIP_PORT, TeleopError, TeleopSubscription,
|
|
47
|
+
TeleopTransport)
|
|
48
|
+
|
|
49
|
+
log = logging.getLogger("litegrip.zenoh")
|
|
50
|
+
|
|
51
|
+
__all__ = ["DEFAULT_GRIP_PORT", "Listener", "Connector", "LatestSlot",
|
|
52
|
+
"ZenohTeleopTransport"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _base_config() -> zenoh.Config:
|
|
56
|
+
"""The shared point-to-point config: **every discovery mechanism off**."""
|
|
57
|
+
c = zenoh.Config()
|
|
58
|
+
c.insert_json5("scouting/multicast/enabled", "false")
|
|
59
|
+
c.insert_json5("scouting/gossip/enabled", "false")
|
|
60
|
+
c.insert_json5("mode", '"peer"')
|
|
61
|
+
return c
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _Endpoint:
|
|
65
|
+
"""Common lifecycle: an idempotent :meth:`close` that must be called."""
|
|
66
|
+
|
|
67
|
+
def __init__(self) -> None:
|
|
68
|
+
self._session: Optional[zenoh.Session] = None
|
|
69
|
+
self._closed = False
|
|
70
|
+
|
|
71
|
+
def close(self) -> None:
|
|
72
|
+
"""Release the session. Idempotent, but **must** be called — without it
|
|
73
|
+
the process never exits."""
|
|
74
|
+
if self._closed:
|
|
75
|
+
return
|
|
76
|
+
self._closed = True
|
|
77
|
+
s, self._session = self._session, None
|
|
78
|
+
if s is not None:
|
|
79
|
+
try:
|
|
80
|
+
s.close()
|
|
81
|
+
except Exception: # noqa: BLE001 - exit path never raises
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
def __enter__(self):
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
def __exit__(self, *exc) -> bool:
|
|
88
|
+
self.close()
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Listener(_Endpoint):
|
|
93
|
+
"""Leader side: listen on a port and wait for a follower to connect.
|
|
94
|
+
|
|
95
|
+
``ready_key`` is optional and adds the follower -> leader readiness channel:
|
|
96
|
+
a second declaration on the *same* session, so it needs no extra port. It
|
|
97
|
+
is an extension over ``liteteleop/link.py`` (which is one-way) and is only
|
|
98
|
+
declared when a key is given, so the one-way comparison still holds when it
|
|
99
|
+
is not.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(self, port: int, key: str,
|
|
103
|
+
ready_key: Optional[str] = None) -> None:
|
|
104
|
+
super().__init__()
|
|
105
|
+
cfg = _base_config()
|
|
106
|
+
cfg.insert_json5("listen/endpoints", f'["tcp/0.0.0.0:{int(port)}"]')
|
|
107
|
+
cfg.insert_json5("connect/endpoints", "[]")
|
|
108
|
+
self._session = zenoh.open(cfg)
|
|
109
|
+
self._pub = self._session.declare_publisher(key)
|
|
110
|
+
self._matching = False
|
|
111
|
+
|
|
112
|
+
def _on_match(status) -> None:
|
|
113
|
+
self._matching = bool(status.matching)
|
|
114
|
+
|
|
115
|
+
self._pub.declare_matching_listener(_on_match)
|
|
116
|
+
|
|
117
|
+
self._ready_slot: Optional[LatestSlot] = None
|
|
118
|
+
self._ready_sub = None
|
|
119
|
+
if ready_key:
|
|
120
|
+
self._ready_slot = LatestSlot()
|
|
121
|
+
|
|
122
|
+
def _on_ready(sample) -> None:
|
|
123
|
+
# zenoh thread ⇒ only the slot is touched.
|
|
124
|
+
self._ready_slot.put(bytes(sample.payload), time.monotonic())
|
|
125
|
+
|
|
126
|
+
self._ready_sub = self._session.declare_subscriber(ready_key, _on_ready)
|
|
127
|
+
|
|
128
|
+
def put(self, payload: bytes) -> None:
|
|
129
|
+
"""Publish one frame. Non-blocking."""
|
|
130
|
+
self._pub.put(payload)
|
|
131
|
+
|
|
132
|
+
def ready_subscription(self) -> "_SlotSubscription":
|
|
133
|
+
"""Subscribe-side handle for the readiness channel."""
|
|
134
|
+
return _SlotSubscription(self._ready_slot)
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def matching(self) -> bool:
|
|
138
|
+
"""Whether any subscriber is matched.
|
|
139
|
+
|
|
140
|
+
This is a **boolean, not a count** — ``zenoh.MatchingStatus`` exposes only
|
|
141
|
+
``.matching``. The UI shows "matched / not matched".
|
|
142
|
+
"""
|
|
143
|
+
return self._matching
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class Connector(_Endpoint):
|
|
147
|
+
"""Follower side: connect to the leader's host:port and subscribe to its stream.
|
|
148
|
+
|
|
149
|
+
``on_frame`` is called on **zenoh's own thread** ⇒ it must only write a
|
|
150
|
+
latest slot. Blocking work there (e.g. a ``goto_rad`` that waits for an ACK)
|
|
151
|
+
stalls the zenoh thread.
|
|
152
|
+
|
|
153
|
+
``ready_key`` is optional and adds the follower -> leader readiness channel
|
|
154
|
+
(a publisher on the same session); see :class:`Listener`.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
def __init__(self, host: str, port: int, key: str,
|
|
158
|
+
on_frame: Optional[Callable[[bytes], None]] = None,
|
|
159
|
+
ready_key: Optional[str] = None) -> None:
|
|
160
|
+
super().__init__()
|
|
161
|
+
cfg = _base_config()
|
|
162
|
+
cfg.insert_json5("listen/endpoints", "[]")
|
|
163
|
+
cfg.insert_json5("connect/endpoints", f'[\"tcp/{host}:{int(port)}\"]')
|
|
164
|
+
self._session = zenoh.open(cfg)
|
|
165
|
+
cb = on_frame or (lambda _b: None)
|
|
166
|
+
#: Read-only frame counter, for the status snapshot.
|
|
167
|
+
#: Its safety comes from *a single subscriber's callbacks running serially
|
|
168
|
+
#: on one zenoh thread*, not from ``+=`` being atomic under the GIL —
|
|
169
|
+
#: attribute ``+=`` is not an atomic bytecode sequence.
|
|
170
|
+
self.received = 0
|
|
171
|
+
|
|
172
|
+
def _handler(sample) -> None:
|
|
173
|
+
self.received += 1
|
|
174
|
+
cb(bytes(sample.payload))
|
|
175
|
+
|
|
176
|
+
self._sub = self._session.declare_subscriber(key, _handler)
|
|
177
|
+
self._ready_pub = (self._session.declare_publisher(ready_key)
|
|
178
|
+
if ready_key else None)
|
|
179
|
+
|
|
180
|
+
def put_ready(self, payload: bytes) -> None:
|
|
181
|
+
"""Publish one ready frame. Non-blocking."""
|
|
182
|
+
if self._ready_pub is not None:
|
|
183
|
+
self._ready_pub.put(payload)
|
|
184
|
+
|
|
185
|
+
def latest(self) -> int:
|
|
186
|
+
"""Frames received so far (used to display a rate)."""
|
|
187
|
+
return self.received
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class LatestSlot:
|
|
191
|
+
"""A latest-wins slot — the only hand-off between the zenoh callback and the
|
|
192
|
+
servo loop.
|
|
193
|
+
|
|
194
|
+
Only the **newest** frame is kept: a late frame overwrites, nothing queues.
|
|
195
|
+
Teleoperation wants the latest pose only; a backlog would make the follower
|
|
196
|
+
chase a stale trajectory.
|
|
197
|
+
|
|
198
|
+
⚠ **"Never received" and "just received" must be distinguishable.** An
|
|
199
|
+
earlier version used ``0.0`` as the "never received" sentinel, so once
|
|
200
|
+
``take()`` cleared the timestamp ``peek_age()`` reported "never received"
|
|
201
|
+
right after a frame was taken ⇒ a caller that takes a frame and then asks its
|
|
202
|
+
age is instantly judged dead and drops into HOLDING, triggering a blocking
|
|
203
|
+
realign — a single read order could stall the follower mid-motion. Now
|
|
204
|
+
``_last_recv_ts is None`` means "never received", and ``take()`` clears only
|
|
205
|
+
the payload, never the timestamp.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
def __init__(self) -> None:
|
|
209
|
+
self._lock = threading.Lock()
|
|
210
|
+
self._payload: Optional[bytes] = None
|
|
211
|
+
self._last_recv_ts: Optional[float] = None # None = never received
|
|
212
|
+
self.dropped = 0
|
|
213
|
+
|
|
214
|
+
def put(self, payload: bytes, now: float) -> None:
|
|
215
|
+
with self._lock:
|
|
216
|
+
if self._payload is not None:
|
|
217
|
+
self.dropped += 1
|
|
218
|
+
self._payload = payload
|
|
219
|
+
self._last_recv_ts = now
|
|
220
|
+
|
|
221
|
+
def take(self):
|
|
222
|
+
"""Take the newest payload, returning ``(payload_or_None, last_recv_ts_or_None)``.
|
|
223
|
+
|
|
224
|
+
⚠ **The timestamp is left alone** — it is the fact of "when we last
|
|
225
|
+
received", which taking the payload must not erase.
|
|
226
|
+
"""
|
|
227
|
+
with self._lock:
|
|
228
|
+
p = self._payload
|
|
229
|
+
self._payload = None
|
|
230
|
+
return p, self._last_recv_ts
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def ever_received(self) -> bool:
|
|
234
|
+
"""Whether *any* frame ever arrived.
|
|
235
|
+
|
|
236
|
+
The "no first frame within N seconds of start" diagnostic needs this, and
|
|
237
|
+
it is a **different question** from "the steady-state watchdog timed out"
|
|
238
|
+
(the latter only applies after a first frame).
|
|
239
|
+
"""
|
|
240
|
+
with self._lock:
|
|
241
|
+
return self._last_recv_ts is not None
|
|
242
|
+
|
|
243
|
+
def peek_age(self, now: float) -> Optional[float]:
|
|
244
|
+
"""Local seconds since the newest frame; **``None`` if never received**.
|
|
245
|
+
|
|
246
|
+
⚠ Returning ``None`` rather than ``0.0`` is deliberate: ``0.0`` is a
|
|
247
|
+
*legitimate* age ("just arrived"), so using it for "never received" would
|
|
248
|
+
void the diagnostic above.
|
|
249
|
+
⚠ The result is clamped to ``≥ 0``: the loop may read ``now`` and then a
|
|
250
|
+
frame carrying a larger ``now`` lands, making the difference negative —
|
|
251
|
+
which is a hair away from a spurious watchdog trip.
|
|
252
|
+
⚠ This is a **local** time difference, unrelated to the frame's own ``ts``
|
|
253
|
+
(the leader's clock); the two machines' clocks are not a common source.
|
|
254
|
+
"""
|
|
255
|
+
with self._lock:
|
|
256
|
+
t = self._last_recv_ts
|
|
257
|
+
if t is None:
|
|
258
|
+
return None
|
|
259
|
+
return max(0.0, now - t)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class _SlotSubscription(TeleopSubscription):
|
|
263
|
+
"""A :class:`LatestSlot` presented as a pull-style subscription."""
|
|
264
|
+
|
|
265
|
+
def __init__(self, slot: LatestSlot) -> None:
|
|
266
|
+
self._slot = slot
|
|
267
|
+
|
|
268
|
+
def try_recv(self) -> Optional[bytes]:
|
|
269
|
+
payload, _ts = self._slot.take()
|
|
270
|
+
return payload
|
|
271
|
+
|
|
272
|
+
@property
|
|
273
|
+
def ever_received(self) -> bool:
|
|
274
|
+
return self._slot.ever_received
|
|
275
|
+
|
|
276
|
+
def peek_age(self, now: float) -> Optional[float]:
|
|
277
|
+
return self._slot.peek_age(now)
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def dropped(self) -> int:
|
|
281
|
+
return self._slot.dropped
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class ZenohTeleopTransport(TeleopTransport):
|
|
285
|
+
"""A :class:`~litegrip.TeleopTransport` carried over the point-to-point zenoh link.
|
|
286
|
+
|
|
287
|
+
One instance is one endpoint: ``role="master"`` wraps a :class:`Listener`
|
|
288
|
+
(publish only), ``role="slave"`` wraps a :class:`Connector` (subscribe only).
|
|
289
|
+
The follower's frames land in a :class:`LatestSlot` and are drained by the
|
|
290
|
+
servo loop, so the zenoh callback never blocks.
|
|
291
|
+
|
|
292
|
+
⚠ The leader's :class:`Listener` must be **resident across teleoperation
|
|
293
|
+
sessions**: rebuilding it per session both leaves the port bound and makes
|
|
294
|
+
publisher↔subscriber matching fail intermittently (5 of 12 restarts when
|
|
295
|
+
rebuilt; 0 of 12 when resident). The follower's :class:`Connector` is
|
|
296
|
+
per-session and must be closed when the session ends — see
|
|
297
|
+
:meth:`~litegrip.LiteGrip.teleop_stop`.
|
|
298
|
+
|
|
299
|
+
⚠ The zenoh keys are fixed when the endpoint opens, so a resident listener
|
|
300
|
+
keeps the keys of the session that built it. Changing ``grip_id`` on a
|
|
301
|
+
gripper mid-life therefore needs a reconnect; the readiness subscription
|
|
302
|
+
reports the mismatch rather than silently listening on the old key.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
role: ``"master"`` (listen/publish) or ``"slave"`` (connect/subscribe).
|
|
306
|
+
key: The zenoh topic, normally ``teleop_topic(grip_id)``.
|
|
307
|
+
port: TCP port. The leader listens on it, the follower connects to it.
|
|
308
|
+
host: Follower only — the leader's address. Ignored by the master.
|
|
309
|
+
ready_key: The follower-ready topic, normally ``ready_topic(grip_id)``.
|
|
310
|
+
When set, the *reverse* direction is carried on the same session:
|
|
311
|
+
the master may ``sub`` it and the slave may ``pub`` it. ``None``
|
|
312
|
+
leaves the transport one-way, exactly as before.
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
def __init__(self, role: str, key: str, port: int = DEFAULT_GRIP_PORT,
|
|
316
|
+
host: Optional[str] = None,
|
|
317
|
+
ready_key: Optional[str] = None) -> None:
|
|
318
|
+
if role not in ("master", "slave"):
|
|
319
|
+
raise ValueError(f"role must be 'master' or 'slave', got {role!r}")
|
|
320
|
+
if role == "slave" and not host:
|
|
321
|
+
raise ValueError("host is required for the slave (connecting) end")
|
|
322
|
+
self._role = role
|
|
323
|
+
self._key = key
|
|
324
|
+
self._ready_key = ready_key
|
|
325
|
+
self._endpoint: Optional[_Endpoint] = None
|
|
326
|
+
self._slot: Optional[LatestSlot] = None
|
|
327
|
+
self._sub: Optional[_SlotSubscription] = None
|
|
328
|
+
self._ready_sub: Optional[_SlotSubscription] = None
|
|
329
|
+
|
|
330
|
+
if role == "master":
|
|
331
|
+
self._endpoint = Listener(int(port), key, ready_key=ready_key)
|
|
332
|
+
else:
|
|
333
|
+
self._slot = LatestSlot()
|
|
334
|
+
|
|
335
|
+
def _on_wire(payload: bytes) -> None:
|
|
336
|
+
# Wrapped, not ``slot.put`` directly: the callback has one
|
|
337
|
+
# parameter, ``put`` needs two. Runs on zenoh's thread ⇒ only
|
|
338
|
+
# the slot is touched.
|
|
339
|
+
self._slot.put(payload, time.monotonic())
|
|
340
|
+
|
|
341
|
+
self._endpoint = Connector(host, int(port), key, on_frame=_on_wire,
|
|
342
|
+
ready_key=ready_key)
|
|
343
|
+
|
|
344
|
+
@property
|
|
345
|
+
def matching(self) -> bool:
|
|
346
|
+
"""Master only — whether a subscriber is matched."""
|
|
347
|
+
if isinstance(self._endpoint, Listener):
|
|
348
|
+
return self._endpoint.matching
|
|
349
|
+
return False
|
|
350
|
+
|
|
351
|
+
@property
|
|
352
|
+
def received(self) -> int:
|
|
353
|
+
"""Slave only — frames received."""
|
|
354
|
+
if isinstance(self._endpoint, Connector):
|
|
355
|
+
return self._endpoint.received
|
|
356
|
+
return 0
|
|
357
|
+
|
|
358
|
+
def pub(self, topic: str, payload: bytes) -> None:
|
|
359
|
+
# Each role publishes on exactly one key: the master its teleop frames,
|
|
360
|
+
# the slave its ready frames.
|
|
361
|
+
if self._role == "master":
|
|
362
|
+
self._check_topic(topic, self._key)
|
|
363
|
+
self._endpoint.put(payload)
|
|
364
|
+
return
|
|
365
|
+
if self._ready_key is None:
|
|
366
|
+
raise TeleopError(
|
|
367
|
+
"this zenoh transport was built without a ready key; it cannot "
|
|
368
|
+
"publish the readiness channel")
|
|
369
|
+
self._check_topic(topic, self._ready_key)
|
|
370
|
+
self._endpoint.put_ready(payload)
|
|
371
|
+
|
|
372
|
+
def sub(self, topic: str) -> TeleopSubscription:
|
|
373
|
+
# Mirror of ``pub``: the slave subscribes to the teleop key, the master
|
|
374
|
+
# to the ready key.
|
|
375
|
+
if self._role == "slave":
|
|
376
|
+
self._check_topic(topic, self._key)
|
|
377
|
+
if self._sub is None:
|
|
378
|
+
self._sub = _SlotSubscription(self._slot)
|
|
379
|
+
return self._sub
|
|
380
|
+
if self._ready_key is None:
|
|
381
|
+
raise TeleopError(
|
|
382
|
+
"this zenoh transport was built without a ready key; it cannot "
|
|
383
|
+
"subscribe to the readiness channel")
|
|
384
|
+
self._check_topic(topic, self._ready_key)
|
|
385
|
+
if self._ready_sub is None:
|
|
386
|
+
self._ready_sub = self._endpoint.ready_subscription()
|
|
387
|
+
return self._ready_sub
|
|
388
|
+
|
|
389
|
+
def _check_topic(self, topic: str, expected: str) -> None:
|
|
390
|
+
# The zenoh keys are fixed when the endpoint is opened; a mismatched
|
|
391
|
+
# topic here means two ends were wired to different keys, which would
|
|
392
|
+
# otherwise look like "the other side just never publishes".
|
|
393
|
+
if topic != expected:
|
|
394
|
+
raise TeleopError(
|
|
395
|
+
f"topic {topic!r} does not match this transport's key {expected!r}")
|
|
396
|
+
|
|
397
|
+
def close(self) -> None:
|
|
398
|
+
endpoint, self._endpoint = self._endpoint, None
|
|
399
|
+
if endpoint is not None:
|
|
400
|
+
endpoint.close()
|