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