pycyphal2 2.0.0.dev0__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.
- pycyphal2/__init__.py +89 -0
- pycyphal2/_api.py +604 -0
- pycyphal2/_hash.py +204 -0
- pycyphal2/_header.py +349 -0
- pycyphal2/_node.py +1472 -0
- pycyphal2/_publisher.py +427 -0
- pycyphal2/_subscriber.py +430 -0
- pycyphal2/_transport.py +92 -0
- pycyphal2/can/__init__.py +43 -0
- pycyphal2/can/_interface.py +131 -0
- pycyphal2/can/_reassembly.py +158 -0
- pycyphal2/can/_transport.py +525 -0
- pycyphal2/can/_wire.py +376 -0
- pycyphal2/can/pythoncan.py +261 -0
- pycyphal2/can/socketcan.py +225 -0
- pycyphal2/py.typed +0 -0
- pycyphal2/udp.py +1000 -0
- pycyphal2-2.0.0.dev0.dist-info/METADATA +58 -0
- pycyphal2-2.0.0.dev0.dist-info/RECORD +22 -0
- pycyphal2-2.0.0.dev0.dist-info/WHEEL +5 -0
- pycyphal2-2.0.0.dev0.dist-info/licenses/LICENSE +20 -0
- pycyphal2-2.0.0.dev0.dist-info/top_level.txt +1 -0
pycyphal2/__init__.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`Cyphal <https://opencyphal.org>`_ in Python —
|
|
3
|
+
decentralized real-time pub/sub with tunable reliability, service discovery, and zero configuration.
|
|
4
|
+
Works anywhere, `even baremetal MCUs <https://github.com/OpenCyphal-Garage/cy>`_.
|
|
5
|
+
|
|
6
|
+
Supports various transports such as Ethernet (UDP) and CAN FD with optional redundancy.
|
|
7
|
+
Set up a transport, make a node, publish and subscribe:
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from pycyphal2 import Node, Instant
|
|
11
|
+
from pycyphal2.udp import UDPTransport
|
|
12
|
+
|
|
13
|
+
async def main():
|
|
14
|
+
node = Node.new(UDPTransport.new(), "my_node")
|
|
15
|
+
|
|
16
|
+
pub = node.advertise("sensor/temperature")
|
|
17
|
+
await pub(Instant.now() + 1.0, b"21.5")
|
|
18
|
+
|
|
19
|
+
sub = node.subscribe("sensor/temperature")
|
|
20
|
+
async for arrival in sub:
|
|
21
|
+
print(arrival.message)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
All public symbols live at the top level — just `import pycyphal2`.
|
|
25
|
+
Transport modules (`pycyphal2.udp`, `pycyphal2.can`) are imported separately
|
|
26
|
+
so that only the needed dependencies are pulled in.
|
|
27
|
+
|
|
28
|
+
The source repository contains a collection of runnable examples.
|
|
29
|
+
|
|
30
|
+
Environment variables control name remapping similar to ROS:
|
|
31
|
+
|
|
32
|
+
- `CYPHAL_NAMESPACE` — default namespace prepended to relative topic names.
|
|
33
|
+
- `CYPHAL_REMAP` — topic name remappings (`from=to` pairs, whitespace-separated).
|
|
34
|
+
|
|
35
|
+
Publication is best-effort by default. Pass ``reliable=True`` when publishing to retry delivery until
|
|
36
|
+
acknowledged by every known subscriber or until the deadline; if the remote side does not acknowledge in time,
|
|
37
|
+
:class:`DeliveryError` is raised.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
await pub(Instant.now() + 1.0, b"payload", reliable=True)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Subscriptions normally yield messages as soon as they arrive. Set ``reordering_window`` [seconds] on
|
|
44
|
+
:meth:`Node.subscribe` to allow delaying out-of-order messages to reconstruct the original publication order.
|
|
45
|
+
This is useful for sensor feeds and state estimators.
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
sub = node.subscribe("sensor/temperature", reordering_window=0.1)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
RPC is layered directly on top of pub/sub. Use :meth:`Publisher.request` to publish a message that expects
|
|
52
|
+
responses, and use :attr:`Arrival.breadcrumb` on the subscriber side to send a unicast reply back to the requester.
|
|
53
|
+
One request may yield responses from multiple subscribers.
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
stream = await pub.request(Instant.now() + 1.0, 0.5, b"read")
|
|
57
|
+
async for response in stream:
|
|
58
|
+
print(response.message)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Streaming is just repeated replying on the same breadcrumb. The requester consumes such replies through
|
|
62
|
+
:class:`ResponseStream`; each responder numbers its own responses from zero upward.
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
await arrival.breadcrumb(Instant.now() + 1.0, b"chunk-1", reliable=True)
|
|
66
|
+
await arrival.breadcrumb(Instant.now() + 1.0, b"chunk-2", reliable=True)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Cyphal does not define a serialization format. Previous versions used to define the DSDL format but it has been
|
|
70
|
+
extracted into an independent project, and Cyphal was made serialization-agnostic in v1.1+.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
from __future__ import annotations
|
|
74
|
+
|
|
75
|
+
from ._api import *
|
|
76
|
+
from ._transport import Transport as Transport
|
|
77
|
+
from ._transport import TransportArrival as TransportArrival
|
|
78
|
+
from ._transport import SubjectWriter as SubjectWriter
|
|
79
|
+
|
|
80
|
+
__version__ = "2.0.0.dev0"
|
|
81
|
+
|
|
82
|
+
# pdoc needs __all__ to display re-exported members.
|
|
83
|
+
__all__ = [
|
|
84
|
+
_k
|
|
85
|
+
for _k, _v in vars().items()
|
|
86
|
+
if not _k.startswith("_")
|
|
87
|
+
and _k not in {"annotations", "TYPE_CHECKING"}
|
|
88
|
+
and (getattr(_v, "__module__", None) or "").startswith(__name__)
|
|
89
|
+
]
|
pycyphal2/_api.py
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This is the main public contract. The rest of the codebase is hidden behind it and can be morphed ad-hoc.
|
|
3
|
+
There is also the downward-facing contract for the transport layer in the adjacent interface module.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
# Top-level exported API entities. Keep pristine! The rest of the library can be noisy but not this!
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import inspect
|
|
12
|
+
import logging
|
|
13
|
+
import time
|
|
14
|
+
import os
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from enum import IntEnum
|
|
18
|
+
import random
|
|
19
|
+
import platform
|
|
20
|
+
from typing import Any, Awaitable, Callable, TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from ._transport import Transport as Transport
|
|
24
|
+
|
|
25
|
+
_logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
SUBJECT_ID_PINNED_MAX = 0x1FFF
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Error(Exception):
|
|
31
|
+
"""The base type for all application-specific errors."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SendError(Error):
|
|
35
|
+
"""Message could not be sent before the deadline."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ClosedError(SendError):
|
|
39
|
+
"""The operation cannot proceed because the object has been closed permanently."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DeliveryError(Error):
|
|
43
|
+
"""Message was sent, but the remote did not acknowledge. The remote might be unreachable or dysfunctional."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class LivenessError(Error):
|
|
47
|
+
"""A message was expected, but it did not arrive."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class NackError(Error):
|
|
51
|
+
"""The remote node was reached, but it explicitly rejected the message."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Instant:
|
|
56
|
+
"""
|
|
57
|
+
Monotonic time elapsed from an unspecified origin instant; used to represent a point in time.
|
|
58
|
+
Durations use plain float seconds instead.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
ns: int
|
|
62
|
+
|
|
63
|
+
def __init__(self, *, ns: int) -> None:
|
|
64
|
+
object.__setattr__(self, "ns", int(ns))
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def us(self) -> float:
|
|
68
|
+
return self.ns * 1e-3
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def ms(self) -> float:
|
|
72
|
+
return self.ns * 1e-6
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def s(self) -> float:
|
|
76
|
+
return self.ns * 1e-9
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def now() -> Instant:
|
|
80
|
+
return Instant(ns=time.monotonic_ns())
|
|
81
|
+
|
|
82
|
+
def __add__(self, other: Any) -> Instant:
|
|
83
|
+
if isinstance(other, (float, int)):
|
|
84
|
+
return Instant(ns=self.ns + round(other * 1e9))
|
|
85
|
+
return NotImplemented
|
|
86
|
+
|
|
87
|
+
def __radd__(self, other: Any) -> Instant:
|
|
88
|
+
return self.__add__(other)
|
|
89
|
+
|
|
90
|
+
def __sub__(self, other: Any) -> Instant | float:
|
|
91
|
+
if isinstance(other, Instant):
|
|
92
|
+
return (self.ns - other.ns) * 1e-9
|
|
93
|
+
if isinstance(other, (float, int)):
|
|
94
|
+
return Instant(ns=self.ns - round(other * 1e9))
|
|
95
|
+
return NotImplemented
|
|
96
|
+
|
|
97
|
+
def __mul__(self, other: Any) -> Instant:
|
|
98
|
+
if isinstance(other, (float, int)):
|
|
99
|
+
return Instant(ns=round(self.ns * other))
|
|
100
|
+
return NotImplemented
|
|
101
|
+
|
|
102
|
+
def __rmul__(self, other: Any) -> Instant:
|
|
103
|
+
return self.__mul__(other)
|
|
104
|
+
|
|
105
|
+
def __truediv__(self, other: Any) -> Instant:
|
|
106
|
+
if isinstance(other, (float, int)):
|
|
107
|
+
return Instant(ns=round(self.ns / other))
|
|
108
|
+
return NotImplemented
|
|
109
|
+
|
|
110
|
+
def __str__(self) -> str:
|
|
111
|
+
return f"{self.s:.3f}s"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class Priority(IntEnum):
|
|
115
|
+
EXCEPTIONAL = 0
|
|
116
|
+
IMMEDIATE = 1
|
|
117
|
+
FAST = 2
|
|
118
|
+
HIGH = 3
|
|
119
|
+
NOMINAL = 4
|
|
120
|
+
LOW = 5
|
|
121
|
+
SLOW = 6
|
|
122
|
+
OPTIONAL = 7
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Closable(ABC):
|
|
126
|
+
@abstractmethod
|
|
127
|
+
def close(self) -> None:
|
|
128
|
+
raise NotImplementedError
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Topic(ABC):
|
|
132
|
+
"""
|
|
133
|
+
Topics are managed automatically by the library, created and destroyed as necessary.
|
|
134
|
+
This is just a compact view to expose some auxiliary information.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
@abstractmethod
|
|
139
|
+
def hash(self) -> int:
|
|
140
|
+
raise NotImplementedError
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
@abstractmethod
|
|
144
|
+
def name(self) -> str:
|
|
145
|
+
raise NotImplementedError
|
|
146
|
+
|
|
147
|
+
@abstractmethod
|
|
148
|
+
def match(self, pattern: str) -> list[tuple[str, int]] | None:
|
|
149
|
+
"""
|
|
150
|
+
If the pattern matches the topic name, returns the name segment substitutions needed to achieve the match.
|
|
151
|
+
None if there is no match. Empty list for verbatim subscribers (match only one topic), where pattern==name.
|
|
152
|
+
Each substitution is the segment and the index of the substitution character in the pattern.
|
|
153
|
+
"""
|
|
154
|
+
raise NotImplementedError
|
|
155
|
+
|
|
156
|
+
def __str__(self) -> str:
|
|
157
|
+
return f"T{self.hash:016x}{self.name!r}"
|
|
158
|
+
|
|
159
|
+
def __repr__(self) -> str:
|
|
160
|
+
return f"Topic({self.name!r}, hash=0x{self.hash:016x})"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass(frozen=True)
|
|
164
|
+
class Response:
|
|
165
|
+
"""
|
|
166
|
+
One response yielded by :class:`ResponseStream`.
|
|
167
|
+
|
|
168
|
+
A single request may elicit responses from multiple remote subscribers; ``remote_id`` identifies which one sent
|
|
169
|
+
this item. ``seqno`` is scoped to that remote responder: the first response is zero, then it increments by one
|
|
170
|
+
for each subsequent streamed response.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
timestamp: Instant
|
|
174
|
+
remote_id: int
|
|
175
|
+
seqno: int
|
|
176
|
+
message: bytes
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class ResponseStream(Closable, ABC):
|
|
180
|
+
"""
|
|
181
|
+
Async iterator of responses produced by :meth:`Publisher.request`.
|
|
182
|
+
|
|
183
|
+
One request may yield zero, one, or many responses, possibly from different remotes.
|
|
184
|
+
Keeping the stream open enables streaming: later responses to the same request are yielded as they arrive.
|
|
185
|
+
If the remote uses reliable delivery for streaming (usually the case), then it will be notified if the client
|
|
186
|
+
stream is closed (explicit NACK) or if the client becomes unreachable (absence of ACK).
|
|
187
|
+
|
|
188
|
+
Library-level errors are reported through iteration and do not automatically close the stream.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
def __aiter__(self) -> ResponseStream:
|
|
192
|
+
return self
|
|
193
|
+
|
|
194
|
+
async def __anext__(self) -> Response:
|
|
195
|
+
"""
|
|
196
|
+
Wait for the next response or the next library-level failure.
|
|
197
|
+
|
|
198
|
+
Raises :class:`LivenessError` if no response arrives for longer than the configured response timeout; the
|
|
199
|
+
timeout restarts after every accepted response, so it also bounds the gaps inside a stream.
|
|
200
|
+
|
|
201
|
+
Raises :class:`DeliveryError` or :class:`SendError` if the request publication itself fails.
|
|
202
|
+
Such errors do not close the stream automatically; later iterations may still yield more responses until
|
|
203
|
+
:meth:`close`d.
|
|
204
|
+
"""
|
|
205
|
+
raise NotImplementedError
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class Publisher(Closable, ABC):
|
|
209
|
+
"""
|
|
210
|
+
Represents the intent to send messages on a topic.
|
|
211
|
+
|
|
212
|
+
Calling the publisher sends one message.
|
|
213
|
+
By default this is best-effort publication: the message is sent once and only immediate send failures are reported.
|
|
214
|
+
With ``reliable=True``, the library retransmits until the deadline and waits for acknowledgments from remote
|
|
215
|
+
subscribers.
|
|
216
|
+
|
|
217
|
+
For publications that expect responses, use :meth:`request`, which returns a :class:`ResponseStream`.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
@abstractmethod
|
|
222
|
+
def topic(self) -> Topic:
|
|
223
|
+
raise NotImplementedError
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
@abstractmethod
|
|
227
|
+
def priority(self) -> Priority:
|
|
228
|
+
raise NotImplementedError
|
|
229
|
+
|
|
230
|
+
@priority.setter
|
|
231
|
+
@abstractmethod
|
|
232
|
+
def priority(self, priority: Priority) -> None:
|
|
233
|
+
raise NotImplementedError
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
@abstractmethod
|
|
237
|
+
def ack_timeout(self) -> float:
|
|
238
|
+
"""
|
|
239
|
+
The effective initial ACK timeout at the current priority; retries back off exponentially.
|
|
240
|
+
The deadline limits the entire reliable publication, not just one attempt.
|
|
241
|
+
"""
|
|
242
|
+
raise NotImplementedError
|
|
243
|
+
|
|
244
|
+
@ack_timeout.setter
|
|
245
|
+
@abstractmethod
|
|
246
|
+
def ack_timeout(self, duration: float) -> None:
|
|
247
|
+
raise NotImplementedError
|
|
248
|
+
|
|
249
|
+
@abstractmethod
|
|
250
|
+
async def __call__(self, deadline: Instant, message: memoryview | bytes, *, reliable: bool = False) -> None:
|
|
251
|
+
"""
|
|
252
|
+
Send one message.
|
|
253
|
+
Blocks at most until ``deadline``.
|
|
254
|
+
Raises :class:`SendError` if the message could not be sent before the deadline.
|
|
255
|
+
|
|
256
|
+
If ``reliable`` is false, the message is sent once.
|
|
257
|
+
If ``reliable`` is true, the library retransmits until ``deadline`` leveraging :attr:`ack_timeout`.
|
|
258
|
+
"""
|
|
259
|
+
raise NotImplementedError
|
|
260
|
+
|
|
261
|
+
@abstractmethod
|
|
262
|
+
async def request(
|
|
263
|
+
self, delivery_deadline: Instant, response_timeout: float, message: memoryview | bytes
|
|
264
|
+
) -> ResponseStream:
|
|
265
|
+
"""
|
|
266
|
+
Publish a request and return a stream of responses.
|
|
267
|
+
|
|
268
|
+
The request publication uses reliable delivery governed by ``delivery_deadline`` and :attr:`ack_timeout`.
|
|
269
|
+
Once the request is in flight, the returned :class:`ResponseStream` yields unicast responses
|
|
270
|
+
from any subscriber that chooses to answer.
|
|
271
|
+
|
|
272
|
+
``response_timeout`` is the maximum idle gap (liveness timeout) between accepted responses,
|
|
273
|
+
so it applies both to one-off RPC and to streaming.
|
|
274
|
+
"""
|
|
275
|
+
raise NotImplementedError
|
|
276
|
+
|
|
277
|
+
def __repr__(self) -> str:
|
|
278
|
+
return f"Publisher(topic={self.topic}, priority={self.priority}, ack_timeout={self.ack_timeout})"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class Breadcrumb(ABC):
|
|
282
|
+
"""
|
|
283
|
+
Response handle attached to a received message.
|
|
284
|
+
|
|
285
|
+
It can be used, optionally, to send one or more unicast responses back to the original publisher,
|
|
286
|
+
enabling RPC and streaming alongside pub/sub.
|
|
287
|
+
Instances may be retained after message reception for as long as necessary.
|
|
288
|
+
One instance is shared across all subscribers receiving the same message, ensuring contiguous sequence numbers
|
|
289
|
+
across all responses emitted for that arrival.
|
|
290
|
+
|
|
291
|
+
Responses are always sent at the same priority as that of the request.
|
|
292
|
+
Internally, the library tracks the seqno that starts at zero and is incremented with every response.
|
|
293
|
+
|
|
294
|
+
The set of (remote-ID, topic hash, message tag) forms a globally unique stream identification triplet,
|
|
295
|
+
which can be hashed down to a single number for convenience.
|
|
296
|
+
"""
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
@abstractmethod
|
|
300
|
+
def remote_id(self) -> int:
|
|
301
|
+
raise NotImplementedError
|
|
302
|
+
|
|
303
|
+
@property
|
|
304
|
+
@abstractmethod
|
|
305
|
+
def topic(self) -> Topic:
|
|
306
|
+
raise NotImplementedError
|
|
307
|
+
|
|
308
|
+
@property
|
|
309
|
+
@abstractmethod
|
|
310
|
+
def tag(self) -> int:
|
|
311
|
+
raise NotImplementedError
|
|
312
|
+
|
|
313
|
+
@abstractmethod
|
|
314
|
+
async def __call__(self, deadline: Instant, message: memoryview | bytes, *, reliable: bool = False) -> None:
|
|
315
|
+
"""
|
|
316
|
+
Send one response to the original publisher.
|
|
317
|
+
|
|
318
|
+
Invoke multiple times on the same breadcrumb to stream multiple responses. Blocks at most until ``deadline``.
|
|
319
|
+
Raises :class:`SendError` if the response could not be sent before the deadline.
|
|
320
|
+
|
|
321
|
+
If ``reliable`` is true, the response is retransmitted until acknowledged or until ``deadline`` expires.
|
|
322
|
+
:class:`DeliveryError` means the requester could not be reached in time; :class:`NackError` means the
|
|
323
|
+
requester is reachable but is no longer accepting responses for this stream (stream closed).
|
|
324
|
+
"""
|
|
325
|
+
raise NotImplementedError
|
|
326
|
+
|
|
327
|
+
def __repr__(self) -> str:
|
|
328
|
+
return f"Breadcrumb(remote_id={self.remote_id:016x}, tag={self.tag:016x}, topic={self.topic})"
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@dataclass(frozen=True)
|
|
332
|
+
class Arrival:
|
|
333
|
+
"""
|
|
334
|
+
Represents one message received from a topic.
|
|
335
|
+
``breadcrumb`` captures the responder context for this arrival.
|
|
336
|
+
Calling it sends a unicast response back to the original publisher, enabling RPC and streaming.
|
|
337
|
+
"""
|
|
338
|
+
|
|
339
|
+
timestamp: Instant
|
|
340
|
+
breadcrumb: Breadcrumb
|
|
341
|
+
message: bytes
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class Subscriber(Closable, ABC):
|
|
345
|
+
"""
|
|
346
|
+
Async source of :class:`Arrival` objects produced by :meth:`Node.subscribe`.
|
|
347
|
+
|
|
348
|
+
Without reordering, arrivals are yielded as soon as they are accepted.
|
|
349
|
+
With a reordering window, each ``(remote_id, topic)`` stream may be delayed to reconstruct monotonically
|
|
350
|
+
increasing publication tags. In-order arrivals are not delayed.
|
|
351
|
+
"""
|
|
352
|
+
|
|
353
|
+
@property
|
|
354
|
+
@abstractmethod
|
|
355
|
+
def pattern(self) -> str:
|
|
356
|
+
"""
|
|
357
|
+
The topic name used when creating the subscriber.
|
|
358
|
+
"""
|
|
359
|
+
raise NotImplementedError
|
|
360
|
+
|
|
361
|
+
@property
|
|
362
|
+
@abstractmethod
|
|
363
|
+
def verbatim(self) -> bool:
|
|
364
|
+
"""
|
|
365
|
+
True if the pattern does not contain substitution segments named `*` and `>`.
|
|
366
|
+
"""
|
|
367
|
+
raise NotImplementedError
|
|
368
|
+
|
|
369
|
+
@property
|
|
370
|
+
@abstractmethod
|
|
371
|
+
def timeout(self) -> float:
|
|
372
|
+
"""
|
|
373
|
+
By default, the timeout is infinite, meaning that LivenessError will never be returned.
|
|
374
|
+
The user can override this as needed. Setting a non-finite timeout disables this feature.
|
|
375
|
+
"""
|
|
376
|
+
raise NotImplementedError
|
|
377
|
+
|
|
378
|
+
@timeout.setter
|
|
379
|
+
@abstractmethod
|
|
380
|
+
def timeout(self, duration: float) -> None:
|
|
381
|
+
raise NotImplementedError
|
|
382
|
+
|
|
383
|
+
@abstractmethod
|
|
384
|
+
def substitutions(self, topic: Topic) -> list[tuple[str, int]] | None:
|
|
385
|
+
"""
|
|
386
|
+
Pattern name segment substitutions needed to match the name of this subscriber to the name of the
|
|
387
|
+
specified topic. None if no match. Empty list for verbatim subscribers (match only one topic).
|
|
388
|
+
"""
|
|
389
|
+
raise NotImplementedError
|
|
390
|
+
|
|
391
|
+
def __aiter__(self) -> Subscriber:
|
|
392
|
+
return self
|
|
393
|
+
|
|
394
|
+
@abstractmethod
|
|
395
|
+
async def __anext__(self) -> Arrival:
|
|
396
|
+
"""
|
|
397
|
+
Wait for the next deliverable arrival.
|
|
398
|
+
|
|
399
|
+
Raises :class:`LivenessError` if messages cease arriving for longer than :attr:`timeout`, unless the timeout
|
|
400
|
+
is non-finite (default).
|
|
401
|
+
For ordered subscriptions, out-of-order messages may be withheld until the gap closes or the reordering
|
|
402
|
+
window expires.
|
|
403
|
+
"""
|
|
404
|
+
raise NotImplementedError
|
|
405
|
+
|
|
406
|
+
def listen(
|
|
407
|
+
self,
|
|
408
|
+
callback: Callable[[Arrival | Error], Awaitable[None] | None],
|
|
409
|
+
) -> asyncio.Task[None]:
|
|
410
|
+
"""
|
|
411
|
+
Launch a background task that forwards every received message to ``callback``.
|
|
412
|
+
The callback may be sync or async and is invoked with either an :class:`Arrival` or a library-level
|
|
413
|
+
:class:`Error` raised by the receive side (e.g. :class:`LivenessError`).
|
|
414
|
+
Such errors are delivered as values and the loop keeps running; the callback decides how to react.
|
|
415
|
+
|
|
416
|
+
The task terminates cleanly when the subscriber is closed or when the caller cancels the task.
|
|
417
|
+
Any non-:class:`Error` exception from ``__anext__``, or any exception raised by the callback itself,
|
|
418
|
+
fails the task and is logged.
|
|
419
|
+
|
|
420
|
+
The caller must retain a reference to the returned task; otherwise the event loop may garbage-collect it.
|
|
421
|
+
"""
|
|
422
|
+
|
|
423
|
+
async def loop() -> None:
|
|
424
|
+
while True:
|
|
425
|
+
item: Arrival | Error
|
|
426
|
+
try:
|
|
427
|
+
item = await self.__anext__()
|
|
428
|
+
except StopAsyncIteration:
|
|
429
|
+
return
|
|
430
|
+
except Error as exc: # Library-level errors are delivered as values.
|
|
431
|
+
item = exc
|
|
432
|
+
result = callback(item)
|
|
433
|
+
if inspect.isawaitable(result):
|
|
434
|
+
await result
|
|
435
|
+
|
|
436
|
+
task = asyncio.create_task(loop(), name=f"pycyphal2.listen:{self.pattern}")
|
|
437
|
+
|
|
438
|
+
def on_done(t: asyncio.Task[None]) -> None:
|
|
439
|
+
if t.cancelled():
|
|
440
|
+
return
|
|
441
|
+
exc = t.exception()
|
|
442
|
+
if exc is not None:
|
|
443
|
+
_logger.error("listen() task for %r terminated with %r", self.pattern, exc)
|
|
444
|
+
|
|
445
|
+
task.add_done_callback(on_done)
|
|
446
|
+
return task
|
|
447
|
+
|
|
448
|
+
def __repr__(self) -> str:
|
|
449
|
+
return f"Subscriber(pattern={self.pattern!r}, verbatim={self.verbatim}, timeout={self.timeout})"
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
class Node(Closable, ABC):
|
|
453
|
+
"""
|
|
454
|
+
The top-level entity that represents a node in the network.
|
|
455
|
+
|
|
456
|
+
Conventionally, topic names are hardcoded in the application.
|
|
457
|
+
Integration of a node into a network requires some way of altering such hardcoded names to match the actual network
|
|
458
|
+
configuration. Several facilities are provided to that end (readers familiar with ROS will feel right at home):
|
|
459
|
+
|
|
460
|
+
- Namespacing. When a node is created, the namespace is specified; if not given explicitly, it defaults to the
|
|
461
|
+
``CYPHAL_NAMESPACE`` environment variable. This name is added to all relative topic names.
|
|
462
|
+
- Home, aka node name. Topic names starting with `~/` are updated to replace `~` with the home.
|
|
463
|
+
- Remapping. A set of replacements is provided that matches hardcoded names and replaces them with arbitrary
|
|
464
|
+
target names. These are configured via a dedicated method after the node is created; the initial remapping
|
|
465
|
+
configuration is seeded from the ``CYPHAL_REMAP`` environment variable (whitespace-separated pairs of `from=to`).
|
|
466
|
+
"""
|
|
467
|
+
|
|
468
|
+
@property
|
|
469
|
+
@abstractmethod
|
|
470
|
+
def home(self) -> str:
|
|
471
|
+
raise NotImplementedError
|
|
472
|
+
|
|
473
|
+
@property
|
|
474
|
+
@abstractmethod
|
|
475
|
+
def namespace(self) -> str:
|
|
476
|
+
raise NotImplementedError
|
|
477
|
+
|
|
478
|
+
@abstractmethod
|
|
479
|
+
def remap(self, spec: str | dict[str, str]) -> None:
|
|
480
|
+
"""
|
|
481
|
+
Accepts either a string containing ASCII whitespace-separated remapping pairs, where each pair is formed like
|
|
482
|
+
`from=to`, or a dict where keys match hardcoded names and the values are their replacements.
|
|
483
|
+
If invoked multiple times, the effect is incremental. Newer entries override older ones in case of conflict.
|
|
484
|
+
|
|
485
|
+
When the node is constructed, the default remapping set is configured immediately as
|
|
486
|
+
``self.remap(os.getenv("CYPHAL_REMAP", ""))`` (no need to do it manually).
|
|
487
|
+
|
|
488
|
+
Remapping examples:
|
|
489
|
+
|
|
490
|
+
NAME FROM TO NAMESPACE HOME RESOLVED PINNING REMARK
|
|
491
|
+
foo/bar foo/bar zoo ns me ns/zoo - relative remap
|
|
492
|
+
foo/bar foo/bar zoo#123 ns me ns/zoo 123 pinned relative remap
|
|
493
|
+
foo/bar#456 foo/bar zoo ns me ns/zoo - matched rule discards user pin
|
|
494
|
+
foo/bar foo/bar /zoo ns me zoo - absolute remap (ns ignored)
|
|
495
|
+
foo/bar foo/bar ~/zoo ns me me/zoo - homeful remap (home expanded)
|
|
496
|
+
"""
|
|
497
|
+
raise NotImplementedError
|
|
498
|
+
|
|
499
|
+
@abstractmethod
|
|
500
|
+
def advertise(self, name: str) -> Publisher:
|
|
501
|
+
"""
|
|
502
|
+
Begin publishing on a topic.
|
|
503
|
+
|
|
504
|
+
The returned :class:`Publisher` is used for ordinary publication and for RPC-style requests sent with
|
|
505
|
+
:meth:`Publisher.request`.
|
|
506
|
+
"""
|
|
507
|
+
raise NotImplementedError
|
|
508
|
+
|
|
509
|
+
@abstractmethod
|
|
510
|
+
def subscribe(self, name: str, *, reordering_window: float | None = None) -> Subscriber:
|
|
511
|
+
"""
|
|
512
|
+
Receive messages from one topic or from several if ``name`` is a pattern.
|
|
513
|
+
|
|
514
|
+
If ``reordering_window`` is ``None``, messages are yielded in arrival order.
|
|
515
|
+
Otherwise, each ``(remote_id, topic)`` stream is reordered independently to ensure that the application
|
|
516
|
+
sees a monotonically increasing tag sequence; this is useful for sensor feeds, state estimators, etc.
|
|
517
|
+
"""
|
|
518
|
+
raise NotImplementedError
|
|
519
|
+
|
|
520
|
+
def __repr__(self) -> str:
|
|
521
|
+
return f"Node(home={self.home!r}, namespace={self.namespace!r})"
|
|
522
|
+
|
|
523
|
+
@staticmethod
|
|
524
|
+
def new(transport: Transport, home: str = "", namespace: str = "") -> Node:
|
|
525
|
+
"""
|
|
526
|
+
Construct a new node using the specified transport. This is the main entry point of the library.
|
|
527
|
+
|
|
528
|
+
The transport is constructed using one of the stock transport implementations like ``pycyphal2.udp``,
|
|
529
|
+
depending on the needs of the application, or it could be custom.
|
|
530
|
+
|
|
531
|
+
Every node needs a unique nonempty home. If the home string is not provided, a random home will be generated.
|
|
532
|
+
If home ends with a `/`, a unique string will be automatically appended to generate a prefixed unique home;
|
|
533
|
+
e.g., `my_node` stays as-is; `my_node/` becomes like `my_node/abcdef0123456789`,
|
|
534
|
+
an empty string becomes a random string.
|
|
535
|
+
|
|
536
|
+
If the namespace is not set, it is read from the CYPHAL_NAMESPACE environment variable,
|
|
537
|
+
which is the main intended use case. Direct assignment might be considered an anti-pattern in most cases.
|
|
538
|
+
"""
|
|
539
|
+
from ._node import NodeImpl
|
|
540
|
+
|
|
541
|
+
# Add random suffix if requested or generate pure random home.
|
|
542
|
+
# Leading/trailing separators will be normalized away.
|
|
543
|
+
home = home.strip() or "/"
|
|
544
|
+
if home.endswith("/"):
|
|
545
|
+
uid = transport.uid if hasattr(transport, "uid") else eui64()
|
|
546
|
+
home += f"{uid:016x}"
|
|
547
|
+
|
|
548
|
+
# Initialize the namespace: if not given explicitly, read it from the standard environment.
|
|
549
|
+
namespace = namespace.strip() or os.getenv("CYPHAL_NAMESPACE", "").strip()
|
|
550
|
+
|
|
551
|
+
# Construct the node.
|
|
552
|
+
node = NodeImpl(transport, home=home, namespace=namespace)
|
|
553
|
+
_logger.info("Constructed %s", node)
|
|
554
|
+
|
|
555
|
+
# Set up default name remapping.
|
|
556
|
+
try:
|
|
557
|
+
node.remap(os.getenv("CYPHAL_REMAP", ""))
|
|
558
|
+
except Exception as ex:
|
|
559
|
+
_logger.exception("Failed to set up default remapping from CYPHAL_REMAP: %s", ex)
|
|
560
|
+
return node
|
|
561
|
+
|
|
562
|
+
@abstractmethod
|
|
563
|
+
def monitor(self, callback: Callable[[Topic], None]) -> Closable:
|
|
564
|
+
"""
|
|
565
|
+
*Advanced diagnostic utility.*
|
|
566
|
+
|
|
567
|
+
Install a listener callback invoked whenever the local node receives a non-inline gossip message.
|
|
568
|
+
This can be used to discover the full set of topics in the network for diagnostic purposes.
|
|
569
|
+
|
|
570
|
+
The :class:`Topic` instance is the actual local topic instance for locally known topics;
|
|
571
|
+
for topics not known locally it is a short-lived flyweight object.
|
|
572
|
+
|
|
573
|
+
The returned :class:`Closable` can be closed to remove the callback.
|
|
574
|
+
"""
|
|
575
|
+
raise NotImplementedError
|
|
576
|
+
|
|
577
|
+
@abstractmethod
|
|
578
|
+
async def scout(self, pattern: str) -> None:
|
|
579
|
+
"""
|
|
580
|
+
*Advanced diagnostic utility.*
|
|
581
|
+
|
|
582
|
+
Query the network for topics matching the pattern.
|
|
583
|
+
The :meth:`monitor` should be installed beforehand to process the responses.
|
|
584
|
+
"""
|
|
585
|
+
raise NotImplementedError
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def eui64() -> int:
|
|
589
|
+
"""
|
|
590
|
+
Generate a globally unique random EUI-64 identifier where:
|
|
591
|
+
- 20 most significant bits (5 hexadecimals) are a function of the host machine identity.
|
|
592
|
+
- 44 least significant bits (11 hexadecimals) are random.
|
|
593
|
+
|
|
594
|
+
The EIU-64 format is: The I/G bit is cleared (unicast). The U/L bit is set (locally administered).
|
|
595
|
+
The protocol doesn't care about this structure, it is just an optional default convention for better diagnostics.
|
|
596
|
+
"""
|
|
597
|
+
from ._hash import rapidhash
|
|
598
|
+
|
|
599
|
+
host_20 = rapidhash(platform.node().encode()) & 0xFFFFF
|
|
600
|
+
rand_44 = random.getrandbits(44)
|
|
601
|
+
out = (host_20 << 44) | rand_44
|
|
602
|
+
out &= ~(1 << 56) # clear I/G bit (unicast)
|
|
603
|
+
out |= 1 << 57 # set U/L bit (locally administered)
|
|
604
|
+
return out
|