pycyphal2 2.0.0.dev0__tar.gz → 2.0.0.dev1__tar.gz
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-2.0.0.dev0/src/pycyphal2.egg-info → pycyphal2-2.0.0.dev1}/PKG-INFO +6 -7
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/README.md +5 -6
- pycyphal2-2.0.0.dev1/src/pycyphal2/__init__.py +163 -0
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/src/pycyphal2/_api.py +15 -1
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/src/pycyphal2/_node.py +47 -19
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/src/pycyphal2/can/__init__.py +1 -1
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1/src/pycyphal2.egg-info}/PKG-INFO +6 -7
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/tests/test_gossip.py +3 -3
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/tests/test_integration.py +10 -2
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/tests/test_monitor.py +7 -1
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/tests/test_parity.py +7 -7
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/tests/test_reliable.py +15 -14
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/tests/test_topic.py +5 -5
- pycyphal2-2.0.0.dev0/src/pycyphal2/__init__.py +0 -89
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/LICENSE +0 -0
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/pyproject.toml +0 -0
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/setup.cfg +0 -0
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/src/pycyphal2/_hash.py +0 -0
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/src/pycyphal2/_header.py +0 -0
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/src/pycyphal2/_publisher.py +0 -0
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/src/pycyphal2/_subscriber.py +0 -0
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/src/pycyphal2/_transport.py +0 -0
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/src/pycyphal2/can/_interface.py +0 -0
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/src/pycyphal2/can/_reassembly.py +0 -0
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/src/pycyphal2/can/_transport.py +0 -0
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/src/pycyphal2/can/_wire.py +0 -0
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/src/pycyphal2/can/pythoncan.py +0 -0
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/src/pycyphal2/can/socketcan.py +0 -0
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/src/pycyphal2/py.typed +0 -0
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/src/pycyphal2/udp.py +0 -0
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/src/pycyphal2.egg-info/SOURCES.txt +0 -0
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/src/pycyphal2.egg-info/dependency_links.txt +0 -0
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/src/pycyphal2.egg-info/requires.txt +0 -0
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/src/pycyphal2.egg-info/top_level.txt +0 -0
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/tests/test_hash.py +0 -0
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/tests/test_header.py +0 -0
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/tests/test_names.py +0 -0
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/tests/test_parity_coverage.py +0 -0
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/tests/test_pubsub.py +0 -0
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/tests/test_reorder.py +0 -0
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/tests/test_rpc.py +0 -0
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/tests/test_scout.py +0 -0
- {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev1}/tests/test_udp.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pycyphal2
|
|
3
|
-
Version: 2.0.0.
|
|
3
|
+
Version: 2.0.0.dev1
|
|
4
4
|
Summary: Pure-Python implementation of Cyphal -- a simple and robust real-time publish/subscribe stack that runs anywhere.
|
|
5
5
|
Author-email: Pavel Kirienko and OpenCyphal team <pavel@opencyphal.org>
|
|
6
6
|
License: MIT
|
|
@@ -38,6 +38,7 @@ _pub/sub without steroids_
|
|
|
38
38
|
|
|
39
39
|
[](https://opencyphal.org/)
|
|
40
40
|
[](https://forum.opencyphal.org)
|
|
41
|
+
[](https://pypi.org/project/pycyphal2/)
|
|
41
42
|
[](https://opencyphal.github.io/pycyphal)
|
|
42
43
|
|
|
43
44
|
</div>
|
|
@@ -46,12 +47,10 @@ _pub/sub without steroids_
|
|
|
46
47
|
|
|
47
48
|
Python implementation of the [Cyphal](https://opencyphal.org) stack that runs on GNU/Linux, Windows, and macOS.
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
pip install pycyphal2[udp,pythoncan]
|
|
54
|
-
```
|
|
50
|
+
PyCyphal v2 is published on PyPI as `pycyphal2` to enable coexistence with v1 `pycyphal` in the same Python environment.
|
|
51
|
+
The two packages have radically different APIs but are wire-compatible on Cyphal/CAN.
|
|
52
|
+
The maintenance of the original `pycyphal` package will eventually cease;
|
|
53
|
+
existing applications leveraging `pycyphal` should upgrade to the new API of `pycyphal2`.
|
|
55
54
|
|
|
56
55
|
📚 **Read the docs** at <https://opencyphal.github.io/pycyphal>.
|
|
57
56
|
|
|
@@ -8,6 +8,7 @@ _pub/sub without steroids_
|
|
|
8
8
|
|
|
9
9
|
[](https://opencyphal.org/)
|
|
10
10
|
[](https://forum.opencyphal.org)
|
|
11
|
+
[](https://pypi.org/project/pycyphal2/)
|
|
11
12
|
[](https://opencyphal.github.io/pycyphal)
|
|
12
13
|
|
|
13
14
|
</div>
|
|
@@ -16,12 +17,10 @@ _pub/sub without steroids_
|
|
|
16
17
|
|
|
17
18
|
Python implementation of the [Cyphal](https://opencyphal.org) stack that runs on GNU/Linux, Windows, and macOS.
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
pip install pycyphal2[udp,pythoncan]
|
|
24
|
-
```
|
|
20
|
+
PyCyphal v2 is published on PyPI as `pycyphal2` to enable coexistence with v1 `pycyphal` in the same Python environment.
|
|
21
|
+
The two packages have radically different APIs but are wire-compatible on Cyphal/CAN.
|
|
22
|
+
The maintenance of the original `pycyphal` package will eventually cease;
|
|
23
|
+
existing applications leveraging `pycyphal` should upgrade to the new API of `pycyphal2`.
|
|
25
24
|
|
|
26
25
|
📚 **Read the docs** at <https://opencyphal.github.io/pycyphal>.
|
|
27
26
|
|
|
@@ -0,0 +1,163 @@
|
|
|
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, [including baremetal MCUs](https://github.com/OpenCyphal-Garage/cy).
|
|
5
|
+
|
|
6
|
+
Supports various transports such as Ethernet (UDP) and CAN FD with optional redundancy.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
Optional features inside the brackets can be removed if not needed; see `pyproject.toml` for the full list:
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
pip install pycyphal2[udp,pythoncan]
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
Set up a transport, make a node, publish and subscribe:
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from pycyphal2 import Node, Instant
|
|
22
|
+
from pycyphal2.udp import UDPTransport
|
|
23
|
+
|
|
24
|
+
async def main():
|
|
25
|
+
node = Node.new(UDPTransport.new(), "my_node")
|
|
26
|
+
|
|
27
|
+
pub = node.advertise("sensor/temperature")
|
|
28
|
+
await pub(Instant.now() + 1.0, b"21.5")
|
|
29
|
+
|
|
30
|
+
sub = node.subscribe("sensor/temperature")
|
|
31
|
+
async for arrival in sub:
|
|
32
|
+
print(arrival.message)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
All public symbols live at the top level — just `import pycyphal2`.
|
|
36
|
+
Transport modules (`pycyphal2.udp`, `pycyphal2.can`) are imported separately
|
|
37
|
+
so that only the needed dependencies are pulled in.
|
|
38
|
+
|
|
39
|
+
### Name resolution
|
|
40
|
+
|
|
41
|
+
The topic naming system shares many similarities with ROS.
|
|
42
|
+
A valid name contains printable ASCII characters except space (ASCII codes [33, 126]).
|
|
43
|
+
Normalized names do not have leading or trailing segment separators `/` and do not have consecutive separators.
|
|
44
|
+
Every node should have a unique name, which is called its *home*; home substitution is done via `~/`.
|
|
45
|
+
|
|
46
|
+
| Input name | Namespace | Home | Remap | Resolved name | Note |
|
|
47
|
+
| ----------------- | --------- | ---- | ------------------ | --------------------- | -------------------------------- |
|
|
48
|
+
| `foo/bar` | `ns` | `me` | | `ns/foo/bar` | Relative name |
|
|
49
|
+
| `/foo//bar/` | `ns` | `me` | | `foo/bar` | Absolute name; namespace ignored |
|
|
50
|
+
| `~/foo/bar` | `ns` | `me` | | `me/foo/bar` | Homeful name |
|
|
51
|
+
| `sensor/*/temp` | `diag` | `me` | | `diag/sensor/*/temp` | Pattern with `*` |
|
|
52
|
+
| `/sensor/>` | `diag` | `me` | | `sensor/>` | Pattern with trailing `>` |
|
|
53
|
+
| `foo/bar` | `ns` | `me` | `foo/bar=~/zoo` | `me/zoo` | Remap first, then resolve |
|
|
54
|
+
|
|
55
|
+
Only exact `~` or `~/...` is homeful; `~ns` is literal. A matching remap overrides pinning.
|
|
56
|
+
Pins are allowed only on verbatim names, not on patterns.
|
|
57
|
+
|
|
58
|
+
Environment variables that control name remapping:
|
|
59
|
+
|
|
60
|
+
- `CYPHAL_NAMESPACE` — default namespace prepended to relative topic names.
|
|
61
|
+
- `CYPHAL_REMAP` — topic name remappings (`from=to` pairs, whitespace-separated).
|
|
62
|
+
|
|
63
|
+
See also :meth:`Node.remap`.
|
|
64
|
+
|
|
65
|
+
### Publish
|
|
66
|
+
|
|
67
|
+
Publication is best-effort by default. Pass `reliable=True` when publishing to retry delivery until
|
|
68
|
+
acknowledged by every known subscriber or until the deadline; if the remote side does not acknowledge in time,
|
|
69
|
+
:class:`DeliveryError` is raised.
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
pub = node.advertise("sensor/temperature")
|
|
73
|
+
await pub(Instant.now() + 1.0, b"payload", reliable=True)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Subscribe
|
|
77
|
+
|
|
78
|
+
Subscriptions normally yield messages as soon as they arrive. Set `reordering_window` [seconds] on
|
|
79
|
+
:meth:`Node.subscribe` to allow delaying out-of-order messages to reconstruct the original publication order.
|
|
80
|
+
This is useful for sensor feeds and state estimators.
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
sub = node.subscribe("sensor/temperature", reordering_window=0.1)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Pattern matching is supported: use `*` to match one name segment (e.g., `sensor/*/temperature`)
|
|
87
|
+
and a trailing `>` to match zero or more trailing segments (e.g., `sensor/>`).
|
|
88
|
+
Pattern subscribers automatically join matching topics as they appear, and unsubscribe as they disappear.
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
sub = node.subscribe("sensor/*/temperature")
|
|
92
|
+
async for arrival in sub:
|
|
93
|
+
topic = arrival.breadcrumb.topic
|
|
94
|
+
captures = sub.substitutions(topic)
|
|
95
|
+
print(topic.name, captures) # [('engine', 1)], where 1 is the pattern segment index
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### RPC & streaming
|
|
99
|
+
|
|
100
|
+
RPC is layered directly on top of pub/sub. Use :meth:`Publisher.request` to publish a message that expects
|
|
101
|
+
responses, and use :attr:`Arrival.breadcrumb` on the subscriber side to send a unicast reply back to the requester.
|
|
102
|
+
One request may yield responses from multiple subscribers.
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
stream = await pub.request(Instant.now() + 1.0, 0.5, b"read")
|
|
106
|
+
async for response in stream:
|
|
107
|
+
print(response.message)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Streaming is just repeated replying on the same breadcrumb. The requester consumes such replies through
|
|
111
|
+
:class:`ResponseStream`; each responder numbers its own responses from zero upward.
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
await arrival.breadcrumb(Instant.now() + 1.0, b"chunk-1", reliable=True)
|
|
115
|
+
await arrival.breadcrumb(Instant.now() + 1.0, b"chunk-2", reliable=True)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Topic pinning
|
|
119
|
+
|
|
120
|
+
Topics may be pinned to a specific subject-ID using `name#1234` to bypass automatic assignment.
|
|
121
|
+
This is useful for applications where a high degree of determinism is required and for Cyphal/CAN v1.0 interoperability.
|
|
122
|
+
Pattern names (e.g., `sensor/*/temperature/>`) cannot be pinned.
|
|
123
|
+
|
|
124
|
+
To join a Cyphal/CAN v1.0 subject, use topic name of the form `subject_id#subject_id`; e.g., `7509#7509`.
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
pub = node.advertise("motor/status#1234")
|
|
128
|
+
sub = node.subscribe("1234#1234")
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Old Cyphal/CAN v1.0 nodes do not participate in the topic discovery protocol,
|
|
132
|
+
so topics joined only by such nodes are not discoverable by pattern subscribers.
|
|
133
|
+
|
|
134
|
+
## Remarks
|
|
135
|
+
|
|
136
|
+
Cyphal does not define a serialization format. Previous versions used to define the DSDL format but it has been
|
|
137
|
+
extracted into an independent project, and Cyphal was made serialization-agnostic in v1.1+.
|
|
138
|
+
|
|
139
|
+
PyCyphal v2 is published on PyPI as [`pycyphal2`](https://pypi.org/project/pycyphal2/)
|
|
140
|
+
to enable coexistence with the original [`pycyphal` v1](https://pypi.org/project/pycyphal/)
|
|
141
|
+
in the same Python environment.
|
|
142
|
+
The two packages have radically different APIs but are wire-compatible on Cyphal/CAN.
|
|
143
|
+
The maintenance of the original `pycyphal` package will eventually cease;
|
|
144
|
+
existing applications leveraging `pycyphal` should upgrade to the new API of `pycyphal2`.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
from __future__ import annotations
|
|
148
|
+
|
|
149
|
+
from ._api import *
|
|
150
|
+
from ._transport import Transport as Transport
|
|
151
|
+
from ._transport import TransportArrival as TransportArrival
|
|
152
|
+
from ._transport import SubjectWriter as SubjectWriter
|
|
153
|
+
|
|
154
|
+
__version__ = "2.0.0.dev1"
|
|
155
|
+
|
|
156
|
+
# pdoc needs __all__ to display re-exported members.
|
|
157
|
+
__all__ = [
|
|
158
|
+
_k
|
|
159
|
+
for _k, _v in vars().items()
|
|
160
|
+
if not _k.startswith("_")
|
|
161
|
+
and _k not in {"annotations", "TYPE_CHECKING"}
|
|
162
|
+
and (getattr(_v, "__module__", None) or "").startswith(__name__)
|
|
163
|
+
]
|
|
@@ -131,7 +131,6 @@ class Closable(ABC):
|
|
|
131
131
|
class Topic(ABC):
|
|
132
132
|
"""
|
|
133
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
134
|
"""
|
|
136
135
|
|
|
137
136
|
@property
|
|
@@ -144,6 +143,16 @@ class Topic(ABC):
|
|
|
144
143
|
def name(self) -> str:
|
|
145
144
|
raise NotImplementedError
|
|
146
145
|
|
|
146
|
+
@property
|
|
147
|
+
@abstractmethod
|
|
148
|
+
def evictions(self) -> int:
|
|
149
|
+
raise NotImplementedError
|
|
150
|
+
|
|
151
|
+
@abstractmethod
|
|
152
|
+
def subject_id(self, modulus: int) -> int:
|
|
153
|
+
"""The modulus can be obtained from :attr:`Transport.subject_id_modulus`."""
|
|
154
|
+
raise NotImplementedError
|
|
155
|
+
|
|
147
156
|
@abstractmethod
|
|
148
157
|
def match(self, pattern: str) -> list[tuple[str, int]] | None:
|
|
149
158
|
"""
|
|
@@ -475,6 +484,11 @@ class Node(Closable, ABC):
|
|
|
475
484
|
def namespace(self) -> str:
|
|
476
485
|
raise NotImplementedError
|
|
477
486
|
|
|
487
|
+
@property
|
|
488
|
+
@abstractmethod
|
|
489
|
+
def transport(self) -> Transport:
|
|
490
|
+
raise NotImplementedError
|
|
491
|
+
|
|
478
492
|
@abstractmethod
|
|
479
493
|
def remap(self, spec: str | dict[str, str]) -> None:
|
|
480
494
|
"""
|
|
@@ -306,6 +306,7 @@ class _TopicFlyweight(Topic):
|
|
|
306
306
|
|
|
307
307
|
_topic_hash: int
|
|
308
308
|
_name: str
|
|
309
|
+
_evictions: int
|
|
309
310
|
|
|
310
311
|
@property
|
|
311
312
|
def hash(self) -> int:
|
|
@@ -315,6 +316,13 @@ class _TopicFlyweight(Topic):
|
|
|
315
316
|
def name(self) -> str:
|
|
316
317
|
return self._name
|
|
317
318
|
|
|
319
|
+
@property
|
|
320
|
+
def evictions(self) -> int:
|
|
321
|
+
return self._evictions
|
|
322
|
+
|
|
323
|
+
def subject_id(self, modulus: int) -> int:
|
|
324
|
+
return compute_subject_id(self._topic_hash, self._evictions, modulus)
|
|
325
|
+
|
|
318
326
|
def match(self, pattern: str) -> list[tuple[str, int]] | None:
|
|
319
327
|
return match_pattern(pattern, self._name)
|
|
320
328
|
|
|
@@ -364,7 +372,7 @@ class TopicImpl(Topic):
|
|
|
364
372
|
self._node = node
|
|
365
373
|
self._name = name
|
|
366
374
|
self._topic_hash = rapidhash(name)
|
|
367
|
-
self.
|
|
375
|
+
self._evictions = evictions
|
|
368
376
|
self.ts_origin = now
|
|
369
377
|
self.ts_animated = now
|
|
370
378
|
self._pub_tag_baseline = int.from_bytes(os.urandom(8), "little")
|
|
@@ -392,14 +400,20 @@ class TopicImpl(Topic):
|
|
|
392
400
|
def name(self) -> str:
|
|
393
401
|
return self._name
|
|
394
402
|
|
|
403
|
+
@property
|
|
404
|
+
def evictions(self) -> int:
|
|
405
|
+
return self._evictions
|
|
406
|
+
|
|
407
|
+
def set_evictions(self, evictions: int) -> None:
|
|
408
|
+
self._evictions = evictions
|
|
409
|
+
|
|
410
|
+
def subject_id(self, modulus: int) -> int:
|
|
411
|
+
return compute_subject_id(self._topic_hash, self._evictions, modulus)
|
|
412
|
+
|
|
395
413
|
def match(self, pattern: str) -> list[tuple[str, int]] | None:
|
|
396
414
|
return match_pattern(pattern, self._name)
|
|
397
415
|
|
|
398
416
|
# -- Internal --
|
|
399
|
-
@property
|
|
400
|
-
def subject_id(self) -> int:
|
|
401
|
-
return compute_subject_id(self._topic_hash, self.evictions, self._node.transport.subject_id_modulus)
|
|
402
|
-
|
|
403
417
|
def lage(self, now: float) -> int:
|
|
404
418
|
return log_age(self.ts_origin, now)
|
|
405
419
|
|
|
@@ -426,14 +440,14 @@ class TopicImpl(Topic):
|
|
|
426
440
|
|
|
427
441
|
def ensure_writer(self) -> SubjectWriter:
|
|
428
442
|
if self.pub_writer is None:
|
|
429
|
-
sid = self.subject_id
|
|
443
|
+
sid = self.subject_id(self._node.transport.subject_id_modulus)
|
|
430
444
|
self.pub_writer = self._node.acquire_subject_writer(self, sid)
|
|
431
445
|
_logger.info("Writer acquired for '%s' sid=%d", self._name, sid)
|
|
432
446
|
return self.pub_writer
|
|
433
447
|
|
|
434
448
|
def ensure_listener(self) -> None:
|
|
435
449
|
if self.sub_listener is None and self.couplings:
|
|
436
|
-
sid = self.subject_id
|
|
450
|
+
sid = self.subject_id(self._node.transport.subject_id_modulus)
|
|
437
451
|
self.sub_listener = self._node.acquire_subject_listener(self, sid)
|
|
438
452
|
_logger.info("Listener acquired for '%s' sid=%d", self._name, sid)
|
|
439
453
|
|
|
@@ -441,12 +455,12 @@ class TopicImpl(Topic):
|
|
|
441
455
|
if self.couplings:
|
|
442
456
|
self.ensure_listener()
|
|
443
457
|
elif self.sub_listener is not None:
|
|
444
|
-
self._node.release_subject_listener(self, self.subject_id)
|
|
458
|
+
self._node.release_subject_listener(self, self.subject_id(self._node.transport.subject_id_modulus))
|
|
445
459
|
self.sub_listener = None
|
|
446
460
|
_logger.info("Listener released for '%s'", self._name)
|
|
447
461
|
|
|
448
462
|
def release_transport_handles(self) -> None:
|
|
449
|
-
sid = self.subject_id
|
|
463
|
+
sid = self.subject_id(self._node.transport.subject_id_modulus)
|
|
450
464
|
if self.pub_writer is not None:
|
|
451
465
|
self._node.release_subject_writer(self, sid)
|
|
452
466
|
self.pub_writer = None
|
|
@@ -487,7 +501,7 @@ def left_wins(l_lage: int, l_hash: int, r_lage: int, r_hash: int) -> bool:
|
|
|
487
501
|
|
|
488
502
|
class NodeImpl(Node):
|
|
489
503
|
def __init__(self, transport: Transport, *, home: str, namespace: str) -> None:
|
|
490
|
-
self.
|
|
504
|
+
self._transport = transport
|
|
491
505
|
self._home = home
|
|
492
506
|
self._namespace = namespace
|
|
493
507
|
self._remaps: dict[str, str] = {}
|
|
@@ -555,6 +569,10 @@ class NodeImpl(Node):
|
|
|
555
569
|
def namespace(self) -> str:
|
|
556
570
|
return self._namespace
|
|
557
571
|
|
|
572
|
+
@property
|
|
573
|
+
def transport(self) -> Transport:
|
|
574
|
+
return self._transport
|
|
575
|
+
|
|
558
576
|
def remap(self, spec: str | dict[str, str]) -> None:
|
|
559
577
|
if isinstance(spec, str):
|
|
560
578
|
spec = dict(x.split("=", 1) for x in spec.split() if "=" in x)
|
|
@@ -573,7 +591,12 @@ class NodeImpl(Node):
|
|
|
573
591
|
topic.pub_count += 1
|
|
574
592
|
topic.sync_implicit()
|
|
575
593
|
topic.ensure_writer()
|
|
576
|
-
_logger.info(
|
|
594
|
+
_logger.info(
|
|
595
|
+
"Advertise '%s' -> '%s' sid=%d",
|
|
596
|
+
name,
|
|
597
|
+
resolved,
|
|
598
|
+
topic.subject_id(self.transport.subject_id_modulus),
|
|
599
|
+
)
|
|
577
600
|
return PublisherImpl(self, topic)
|
|
578
601
|
|
|
579
602
|
def subscribe(self, name: str, *, reordering_window: float | None = None) -> Subscriber:
|
|
@@ -662,29 +685,34 @@ class NodeImpl(Node):
|
|
|
662
685
|
self.couple_topic_root(topic, root)
|
|
663
686
|
topic.sync_listener()
|
|
664
687
|
self.notify_implicit_gc()
|
|
665
|
-
_logger.info(
|
|
688
|
+
_logger.info(
|
|
689
|
+
"Topic created '%s' hash=%016x sid=%d",
|
|
690
|
+
name,
|
|
691
|
+
topic.hash,
|
|
692
|
+
topic.subject_id(self.transport.subject_id_modulus),
|
|
693
|
+
)
|
|
666
694
|
return topic
|
|
667
695
|
|
|
668
696
|
def topic_allocate(self, topic: TopicImpl, new_evictions: int, now: float) -> None:
|
|
669
697
|
"""Iterative subject-ID allocation with collision resolution. Mirrors topic_allocate() in cy.c."""
|
|
670
698
|
# Work queue: list of (topic, new_evictions) pairs to process.
|
|
699
|
+
modulus = self.transport.subject_id_modulus
|
|
671
700
|
work: list[tuple[TopicImpl, int]] = [(topic, new_evictions)]
|
|
672
701
|
while work:
|
|
673
702
|
t, ev = work.pop(0)
|
|
674
703
|
# Remove from subject-ID index first.
|
|
675
|
-
old_sid = t.subject_id
|
|
704
|
+
old_sid = t.subject_id(modulus)
|
|
676
705
|
if old_sid in self.topics_by_subject_id and self.topics_by_subject_id[old_sid] is t:
|
|
677
706
|
del self.topics_by_subject_id[old_sid]
|
|
678
707
|
|
|
679
708
|
if ev >= EVICTIONS_PINNED_MIN:
|
|
680
709
|
# Pinned topic: no collision detection, shared subject-IDs are fine.
|
|
681
710
|
t.release_transport_handles()
|
|
682
|
-
t.
|
|
711
|
+
t.set_evictions(ev)
|
|
683
712
|
t.sync_listener()
|
|
684
713
|
self.schedule_gossip_urgent(t)
|
|
685
714
|
continue
|
|
686
715
|
|
|
687
|
-
modulus = self.transport.subject_id_modulus
|
|
688
716
|
new_sid = compute_subject_id(t.hash, ev, modulus)
|
|
689
717
|
collider = self.topics_by_subject_id.get(new_sid)
|
|
690
718
|
|
|
@@ -694,14 +722,14 @@ class NodeImpl(Node):
|
|
|
694
722
|
if collider is None:
|
|
695
723
|
# No collision, install.
|
|
696
724
|
t.release_transport_handles()
|
|
697
|
-
t.
|
|
725
|
+
t.set_evictions(ev)
|
|
698
726
|
self.topics_by_subject_id[new_sid] = t
|
|
699
727
|
t.sync_listener()
|
|
700
728
|
self.schedule_gossip_urgent(t)
|
|
701
729
|
elif left_wins(t.lage(now), t.hash, collider.lage(now), collider.hash):
|
|
702
730
|
# Our topic wins: take the slot, evict the collider.
|
|
703
731
|
t.release_transport_handles()
|
|
704
|
-
t.
|
|
732
|
+
t.set_evictions(ev)
|
|
705
733
|
del self.topics_by_subject_id[new_sid]
|
|
706
734
|
self.topics_by_subject_id[new_sid] = t
|
|
707
735
|
if collider.pub_writer is not None:
|
|
@@ -1293,7 +1321,7 @@ class NodeImpl(Node):
|
|
|
1293
1321
|
self._notify_monitors(topic)
|
|
1294
1322
|
else:
|
|
1295
1323
|
self.on_gossip_unknown(hdr.topic_hash, hdr.topic_evictions, hdr.topic_log_age, ts)
|
|
1296
|
-
self._notify_monitors(_TopicFlyweight(hdr.topic_hash, name))
|
|
1324
|
+
self._notify_monitors(_TopicFlyweight(hdr.topic_hash, name, hdr.topic_evictions))
|
|
1297
1325
|
|
|
1298
1326
|
def on_gossip_known(
|
|
1299
1327
|
self,
|
|
@@ -1430,7 +1458,7 @@ class NodeImpl(Node):
|
|
|
1430
1458
|
self.decouple_topic_root(topic, topic.couplings[0].root, sync_lifecycle=False)
|
|
1431
1459
|
self.topics_by_name.pop(name, None)
|
|
1432
1460
|
self.topics_by_hash.pop(topic.hash, None)
|
|
1433
|
-
sid = topic.subject_id
|
|
1461
|
+
sid = topic.subject_id(self.transport.subject_id_modulus)
|
|
1434
1462
|
if self.topics_by_subject_id.get(sid) is topic:
|
|
1435
1463
|
del self.topics_by_subject_id[sid]
|
|
1436
1464
|
topic.associations.clear()
|
|
@@ -12,7 +12,7 @@ transport = CANTransport.new(SocketCANInterface("can0"))
|
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
Python-CAN is useful when the application runs not on GNU/Linux or already uses `python-can` or needs
|
|
15
|
-
|
|
15
|
+
[one of its *many* hardware backends](https://python-can.readthedocs.io/en/stable/interfaces.html)
|
|
16
16
|
-- GS-USB, SLCAN, PCAN, etc:
|
|
17
17
|
|
|
18
18
|
```python
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pycyphal2
|
|
3
|
-
Version: 2.0.0.
|
|
3
|
+
Version: 2.0.0.dev1
|
|
4
4
|
Summary: Pure-Python implementation of Cyphal -- a simple and robust real-time publish/subscribe stack that runs anywhere.
|
|
5
5
|
Author-email: Pavel Kirienko and OpenCyphal team <pavel@opencyphal.org>
|
|
6
6
|
License: MIT
|
|
@@ -38,6 +38,7 @@ _pub/sub without steroids_
|
|
|
38
38
|
|
|
39
39
|
[](https://opencyphal.org/)
|
|
40
40
|
[](https://forum.opencyphal.org)
|
|
41
|
+
[](https://pypi.org/project/pycyphal2/)
|
|
41
42
|
[](https://opencyphal.github.io/pycyphal)
|
|
42
43
|
|
|
43
44
|
</div>
|
|
@@ -46,12 +47,10 @@ _pub/sub without steroids_
|
|
|
46
47
|
|
|
47
48
|
Python implementation of the [Cyphal](https://opencyphal.org) stack that runs on GNU/Linux, Windows, and macOS.
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
pip install pycyphal2[udp,pythoncan]
|
|
54
|
-
```
|
|
50
|
+
PyCyphal v2 is published on PyPI as `pycyphal2` to enable coexistence with v1 `pycyphal` in the same Python environment.
|
|
51
|
+
The two packages have radically different APIs but are wire-compatible on Cyphal/CAN.
|
|
52
|
+
The maintenance of the original `pycyphal` package will eventually cease;
|
|
53
|
+
existing applications leveraging `pycyphal` should upgrade to the new API of `pycyphal2`.
|
|
55
54
|
|
|
56
55
|
📚 **Read the docs** at <https://opencyphal.github.io/pycyphal>.
|
|
57
56
|
|
|
@@ -266,7 +266,7 @@ async def test_topic_destroy():
|
|
|
266
266
|
topic = node.topics_by_name.get("to_destroy")
|
|
267
267
|
assert topic is not None
|
|
268
268
|
topic_hash = topic.hash
|
|
269
|
-
sid = topic.subject_id
|
|
269
|
+
sid = topic.subject_id(tr.subject_id_modulus)
|
|
270
270
|
|
|
271
271
|
pub.close() # Allow destroy.
|
|
272
272
|
node.destroy_topic("to_destroy")
|
|
@@ -380,7 +380,7 @@ async def test_topic_collision_during_allocate():
|
|
|
380
380
|
|
|
381
381
|
pub_a = node.advertise("/topic_alpha")
|
|
382
382
|
topic_a = node.topics_by_name["topic_alpha"]
|
|
383
|
-
sid_a = topic_a.subject_id
|
|
383
|
+
sid_a = topic_a.subject_id(tr.subject_id_modulus)
|
|
384
384
|
|
|
385
385
|
# Find a name that collides with topic_a's subject-ID.
|
|
386
386
|
from pycyphal2._hash import rapidhash
|
|
@@ -394,7 +394,7 @@ async def test_topic_collision_during_allocate():
|
|
|
394
394
|
pub_b = node.advertise(f"/{name}")
|
|
395
395
|
topic_b = node.topics_by_name[name]
|
|
396
396
|
# One of them should have been reallocated.
|
|
397
|
-
assert topic_a.subject_id != topic_b.subject_id
|
|
397
|
+
assert topic_a.subject_id(tr.subject_id_modulus) != topic_b.subject_id(tr.subject_id_modulus)
|
|
398
398
|
pub_b.close()
|
|
399
399
|
break
|
|
400
400
|
|
|
@@ -47,6 +47,14 @@ async def test_node_creation_and_home():
|
|
|
47
47
|
node.close()
|
|
48
48
|
|
|
49
49
|
|
|
50
|
+
async def test_node_exposes_transport_property():
|
|
51
|
+
net = MockNetwork()
|
|
52
|
+
tr = MockTransport(node_id=1, network=net)
|
|
53
|
+
node = new_node(tr, home="my_home")
|
|
54
|
+
assert node.transport is tr
|
|
55
|
+
node.close()
|
|
56
|
+
|
|
57
|
+
|
|
50
58
|
async def test_node_namespace():
|
|
51
59
|
"""Namespace should affect name resolution."""
|
|
52
60
|
net = MockNetwork()
|
|
@@ -121,7 +129,7 @@ async def test_pinned_topic():
|
|
|
121
129
|
|
|
122
130
|
pub = node.advertise("/my/topic#42")
|
|
123
131
|
topic = list(node.topics_by_name.values())[0]
|
|
124
|
-
assert topic.subject_id == 42
|
|
132
|
+
assert topic.subject_id(tr.subject_id_modulus) == 42
|
|
125
133
|
assert topic.evictions == 0xFFFFFFFF - 42
|
|
126
134
|
|
|
127
135
|
pub.close()
|
|
@@ -348,7 +356,7 @@ async def test_remap_advertise_pinned():
|
|
|
348
356
|
pub = node.advertise("my/topic")
|
|
349
357
|
topic = list(node.topics_by_name.values())[0]
|
|
350
358
|
assert topic.name == "ns/remapped"
|
|
351
|
-
assert topic.subject_id == 42
|
|
359
|
+
assert topic.subject_id(tr.subject_id_modulus) == 42
|
|
352
360
|
|
|
353
361
|
pub.close()
|
|
354
362
|
node.close()
|
|
@@ -115,6 +115,7 @@ async def test_monitor_implicit_topic_creation_reports_local_topic_instead_of_fl
|
|
|
115
115
|
|
|
116
116
|
async def test_monitor_unknown_topic_uses_flyweight_with_wire_identity() -> None:
|
|
117
117
|
node = new_node(MockTransport(node_id=1), home="n1")
|
|
118
|
+
modulus = node.transport.subject_id_modulus
|
|
118
119
|
|
|
119
120
|
received: list[pycyphal2.Topic] = []
|
|
120
121
|
node.monitor(received.append)
|
|
@@ -128,6 +129,8 @@ async def test_monitor_unknown_topic_uses_flyweight_with_wire_identity() -> None
|
|
|
128
129
|
assert not isinstance(received[0], TopicImpl)
|
|
129
130
|
assert received[0].hash == topic_hash
|
|
130
131
|
assert received[0].name == name
|
|
132
|
+
assert received[0].evictions == 0
|
|
133
|
+
assert received[0].subject_id(modulus) == pycyphal2.SUBJECT_ID_PINNED_MAX + 1 + (topic_hash % modulus)
|
|
131
134
|
assert received[0].match("sensor/*") == [("temp", 1)]
|
|
132
135
|
|
|
133
136
|
node.close()
|
|
@@ -142,6 +145,7 @@ async def test_monitor_unknown_topic_uses_flyweight_with_wire_identity() -> None
|
|
|
142
145
|
)
|
|
143
146
|
async def test_monitor_unknown_topic_preserves_decoded_wire_name(name_bytes: bytes, expected_name: str) -> None:
|
|
144
147
|
node = new_node(MockTransport(node_id=1), home="n1")
|
|
148
|
+
modulus = node.transport.subject_id_modulus
|
|
145
149
|
|
|
146
150
|
received: list[pycyphal2.Topic] = []
|
|
147
151
|
node.monitor(received.append)
|
|
@@ -154,6 +158,8 @@ async def test_monitor_unknown_topic_preserves_decoded_wire_name(name_bytes: byt
|
|
|
154
158
|
assert len(received) == 1
|
|
155
159
|
assert received[0].hash == 0xDEADBEEFCAFEBABE
|
|
156
160
|
assert received[0].name == expected_name
|
|
161
|
+
assert received[0].evictions == 3
|
|
162
|
+
assert received[0].subject_id(modulus) == pycyphal2.SUBJECT_ID_PINNED_MAX + 1 + ((0xDEADBEEFCAFEBABE + 9) % modulus)
|
|
157
163
|
|
|
158
164
|
node.close()
|
|
159
165
|
|
|
@@ -179,7 +185,7 @@ async def test_monitor_is_not_invoked_for_inline_gossip_on_message_reception() -
|
|
|
179
185
|
).serialize()
|
|
180
186
|
+ b"data",
|
|
181
187
|
)
|
|
182
|
-
node.on_subject_arrival(topic.subject_id, arrival)
|
|
188
|
+
node.on_subject_arrival(topic.subject_id(node.transport.subject_id_modulus), arrival)
|
|
183
189
|
|
|
184
190
|
assert received == []
|
|
185
191
|
|
|
@@ -44,7 +44,7 @@ async def test_crdt_collision_older_topic_wins():
|
|
|
44
44
|
# Create topic_a first (it will be older).
|
|
45
45
|
pub_a = node.advertise("/topic_a")
|
|
46
46
|
topic_a = node.topics_by_name["topic_a"]
|
|
47
|
-
sid_a = topic_a.subject_id
|
|
47
|
+
sid_a = topic_a.subject_id(tr.subject_id_modulus)
|
|
48
48
|
|
|
49
49
|
# Search for a colliding name.
|
|
50
50
|
modulus = tr.subject_id_modulus
|
|
@@ -66,7 +66,7 @@ async def test_crdt_collision_older_topic_wins():
|
|
|
66
66
|
topic_b = node.topics_by_name[colliding_name]
|
|
67
67
|
|
|
68
68
|
# topic_a should keep its subject-ID since it is older; topic_b should have been evicted.
|
|
69
|
-
assert topic_a.subject_id != topic_b.subject_id
|
|
69
|
+
assert topic_a.subject_id(tr.subject_id_modulus) != topic_b.subject_id(tr.subject_id_modulus)
|
|
70
70
|
assert topic_b.evictions > 0 # loser got bumped
|
|
71
71
|
assert topic_a.evictions == 0 # winner untouched
|
|
72
72
|
|
|
@@ -211,7 +211,7 @@ async def test_msg_header_merges_lage():
|
|
|
211
211
|
remote_id=99,
|
|
212
212
|
message=hdr.serialize() + b"payload",
|
|
213
213
|
)
|
|
214
|
-
node.on_subject_arrival(topic.subject_id, arrival)
|
|
214
|
+
node.on_subject_arrival(topic.subject_id(tr.subject_id_modulus), arrival)
|
|
215
215
|
|
|
216
216
|
# After merge, our lage should have increased to at least the remote's claim.
|
|
217
217
|
merged_lage = topic.lage(time.monotonic())
|
|
@@ -375,7 +375,7 @@ async def test_best_effort_full_pipeline():
|
|
|
375
375
|
await pub(pycyphal2.Instant.now() + 1.0, b"test_payload")
|
|
376
376
|
|
|
377
377
|
# Verify the transport writer was invoked.
|
|
378
|
-
writer = tr.writers.get(topic.subject_id)
|
|
378
|
+
writer = tr.writers.get(topic.subject_id(tr.subject_id_modulus))
|
|
379
379
|
assert writer is not None
|
|
380
380
|
assert writer.send_count >= 1
|
|
381
381
|
|
|
@@ -443,7 +443,7 @@ async def test_pinned_topic_formula():
|
|
|
443
443
|
for pin_val in [0, 1, 42, 100, SUBJECT_ID_PINNED_MAX]:
|
|
444
444
|
pub = node.advertise(f"/pin_{pin_val}#{pin_val}")
|
|
445
445
|
topic = node.topics_by_name[f"pin_{pin_val}"]
|
|
446
|
-
assert topic.subject_id == pin_val
|
|
446
|
+
assert topic.subject_id(tr.subject_id_modulus) == pin_val
|
|
447
447
|
assert topic.evictions == 0xFFFFFFFF - pin_val
|
|
448
448
|
pub.close()
|
|
449
449
|
|
|
@@ -462,8 +462,8 @@ async def test_multiple_pinned_topics_share_subject_id():
|
|
|
462
462
|
topic_b = node.topics_by_name["beta"]
|
|
463
463
|
|
|
464
464
|
# Both should have subject-ID 42.
|
|
465
|
-
assert topic_a.subject_id == 42
|
|
466
|
-
assert topic_b.subject_id == 42
|
|
465
|
+
assert topic_a.subject_id(tr.subject_id_modulus) == 42
|
|
466
|
+
assert topic_b.subject_id(tr.subject_id_modulus) == 42
|
|
467
467
|
assert topic_a.pub_writer is topic_b.pub_writer
|
|
468
468
|
assert tr.subject_writer_creations.get(42) == 1
|
|
469
469
|
|
|
@@ -160,7 +160,7 @@ async def test_reliable_publish_retry_rebuilds_writer_and_header_after_reallocat
|
|
|
160
160
|
pub.priority = pycyphal2.Priority.EXCEPTIONAL
|
|
161
161
|
pub.ack_timeout = 0.1
|
|
162
162
|
topic = node.topics_by_name["topic"]
|
|
163
|
-
old_sid = topic.subject_id
|
|
163
|
+
old_sid = topic.subject_id(tr.subject_id_modulus)
|
|
164
164
|
old_evictions = topic.evictions
|
|
165
165
|
old_messages: list[TransportArrival] = []
|
|
166
166
|
new_messages: list[TransportArrival] = []
|
|
@@ -189,7 +189,7 @@ async def test_reliable_publish_retry_rebuilds_writer_and_header_after_reallocat
|
|
|
189
189
|
)
|
|
190
190
|
node.on_subject_arrival(node.broadcast_subject_id, gossip_arrival)
|
|
191
191
|
|
|
192
|
-
new_sid = topic.subject_id
|
|
192
|
+
new_sid = topic.subject_id(tr.subject_id_modulus)
|
|
193
193
|
assert new_sid != old_sid
|
|
194
194
|
observer.subject_listen(new_sid, new_messages.append)
|
|
195
195
|
|
|
@@ -231,7 +231,7 @@ async def test_gossip_reallocation_to_occupied_subject_preserves_writer():
|
|
|
231
231
|
|
|
232
232
|
pub_b = node.advertise(colliding_name)
|
|
233
233
|
topic_b = node.topics_by_name[colliding_name.removeprefix("/")]
|
|
234
|
-
sid_b = topic_b.subject_id
|
|
234
|
+
sid_b = topic_b.subject_id(tr.subject_id_modulus)
|
|
235
235
|
writer_b = expect_mock_writer(topic_b.pub_writer)
|
|
236
236
|
|
|
237
237
|
now = pycyphal2.Instant.now().s
|
|
@@ -245,11 +245,11 @@ async def test_gossip_reallocation_to_occupied_subject_preserves_writer():
|
|
|
245
245
|
remote_lage = topic_a.lage(now) + 1
|
|
246
246
|
node.on_gossip_known(topic_a, remote_evictions, remote_lage, now, GossipScope.SHARDED)
|
|
247
247
|
|
|
248
|
-
assert topic_a.subject_id == sid_b
|
|
248
|
+
assert topic_a.subject_id(tr.subject_id_modulus) == sid_b
|
|
249
249
|
assert topic_a.pub_writer is writer_b
|
|
250
250
|
assert tr.subject_writer_creations.get(sid_b) == writer_creations_before == 1
|
|
251
251
|
assert topic_b.pub_writer is None
|
|
252
|
-
assert topic_b.subject_id != sid_b
|
|
252
|
+
assert topic_b.subject_id(tr.subject_id_modulus) != sid_b
|
|
253
253
|
|
|
254
254
|
send_count_before = writer_b.send_count
|
|
255
255
|
await pub_a(pycyphal2.Instant.now() + 1.0, b"payload")
|
|
@@ -439,7 +439,7 @@ async def test_gossip_unknown_topic_collision():
|
|
|
439
439
|
|
|
440
440
|
topic_a = node.topics_by_name.get("topic_a")
|
|
441
441
|
assert topic_a is not None
|
|
442
|
-
old_sid = topic_a.subject_id
|
|
442
|
+
old_sid = topic_a.subject_id(tr.subject_id_modulus)
|
|
443
443
|
|
|
444
444
|
# Craft a gossip from a different topic that happens to claim the same subject-ID.
|
|
445
445
|
# Use a fake hash that maps to the same subject-ID with evictions=0.
|
|
@@ -469,7 +469,7 @@ async def test_gossip_unknown_topic_collision():
|
|
|
469
469
|
node.on_subject_arrival(node.broadcast_subject_id, arrival)
|
|
470
470
|
await asyncio.sleep(0.02)
|
|
471
471
|
# Our topic should have been reallocated.
|
|
472
|
-
assert topic_a.subject_id != old_sid or topic_a.evictions > 0
|
|
472
|
+
assert topic_a.subject_id(tr.subject_id_modulus) != old_sid or topic_a.evictions > 0
|
|
473
473
|
|
|
474
474
|
pub.close()
|
|
475
475
|
node.close()
|
|
@@ -753,7 +753,7 @@ async def test_reliable_msg_sends_ack():
|
|
|
753
753
|
remote_id=99,
|
|
754
754
|
message=msg_data,
|
|
755
755
|
)
|
|
756
|
-
node.on_subject_arrival(topic.subject_id, arrival)
|
|
756
|
+
node.on_subject_arrival(topic.subject_id(tr.subject_id_modulus), arrival)
|
|
757
757
|
|
|
758
758
|
# Give ACK task time to run.
|
|
759
759
|
await asyncio.sleep(0.02)
|
|
@@ -781,7 +781,8 @@ async def test_reliable_msg_wrong_subject_dropped():
|
|
|
781
781
|
|
|
782
782
|
topic = list(node.topics_by_name.values())[0]
|
|
783
783
|
subject_id_max = pycyphal2.SUBJECT_ID_PINNED_MAX + tr.subject_id_modulus
|
|
784
|
-
|
|
784
|
+
topic_subject_id = topic.subject_id(tr.subject_id_modulus)
|
|
785
|
+
wrong_subject_id = topic_subject_id + 1 if topic_subject_id < subject_id_max else topic_subject_id - 1
|
|
785
786
|
hdr = MsgRelHeader(
|
|
786
787
|
topic_log_age=0,
|
|
787
788
|
topic_evictions=topic.evictions,
|
|
@@ -828,8 +829,8 @@ async def test_reliable_msg_dedup():
|
|
|
828
829
|
)
|
|
829
830
|
|
|
830
831
|
# Deliver twice.
|
|
831
|
-
node.on_subject_arrival(topic.subject_id, arrival)
|
|
832
|
-
node.on_subject_arrival(topic.subject_id, arrival)
|
|
832
|
+
node.on_subject_arrival(topic.subject_id(tr.subject_id_modulus), arrival)
|
|
833
|
+
node.on_subject_arrival(topic.subject_id(tr.subject_id_modulus), arrival)
|
|
833
834
|
|
|
834
835
|
# Should only get one message.
|
|
835
836
|
assert sub.queue.qsize() == 1
|
|
@@ -891,7 +892,7 @@ async def test_reliable_msg_ordered_late_drop_sends_no_ack_or_nack():
|
|
|
891
892
|
remote_id=99,
|
|
892
893
|
message=hdr.serialize() + f"m{tag}".encode(),
|
|
893
894
|
)
|
|
894
|
-
node.on_subject_arrival(topic.subject_id, arrival)
|
|
895
|
+
node.on_subject_arrival(topic.subject_id(tr.subject_id_modulus), arrival)
|
|
895
896
|
await asyncio.sleep(0.02)
|
|
896
897
|
tr.unicast_log.clear()
|
|
897
898
|
await sub.queue.get()
|
|
@@ -908,7 +909,7 @@ async def test_reliable_msg_ordered_late_drop_sends_no_ack_or_nack():
|
|
|
908
909
|
remote_id=99,
|
|
909
910
|
message=late_hdr.serialize() + b"late",
|
|
910
911
|
)
|
|
911
|
-
node.on_subject_arrival(topic.subject_id, late_arrival)
|
|
912
|
+
node.on_subject_arrival(topic.subject_id(tr.subject_id_modulus), late_arrival)
|
|
912
913
|
await asyncio.sleep(0.02)
|
|
913
914
|
|
|
914
915
|
assert tr.unicast_log == []
|
|
@@ -1011,7 +1012,7 @@ async def test_multicast_msg_ack_ignored():
|
|
|
1011
1012
|
remote_id=42,
|
|
1012
1013
|
message=MsgAckHeader(topic_hash=topic.hash, tag=tag).serialize(),
|
|
1013
1014
|
)
|
|
1014
|
-
node.on_subject_arrival(topic.subject_id, arrival)
|
|
1015
|
+
node.on_subject_arrival(topic.subject_id(tr.subject_id_modulus), arrival)
|
|
1015
1016
|
|
|
1016
1017
|
assert not tracker.acknowledged
|
|
1017
1018
|
assert tracker.remaining == {42}
|
|
@@ -109,7 +109,7 @@ async def test_advertise_assigns_subject_id():
|
|
|
109
109
|
resolved, _, _ = resolve_name("my/topic", "test_node", "")
|
|
110
110
|
topic = node.topics_by_name[resolved]
|
|
111
111
|
|
|
112
|
-
sid = topic.subject_id
|
|
112
|
+
sid = topic.subject_id(tr.subject_id_modulus)
|
|
113
113
|
assert sid == compute_subject_id(topic.hash, topic.evictions, DEFAULT_MODULUS)
|
|
114
114
|
assert node.topics_by_subject_id.get(sid) is topic
|
|
115
115
|
|
|
@@ -127,7 +127,7 @@ async def test_advertise_pinned_topic():
|
|
|
127
127
|
resolved, pin, _ = resolve_name("my/topic#42", "test_node", "")
|
|
128
128
|
assert pin == 42
|
|
129
129
|
topic = node.topics_by_name[resolved]
|
|
130
|
-
assert topic.subject_id == 42
|
|
130
|
+
assert topic.subject_id(tr.subject_id_modulus) == 42
|
|
131
131
|
|
|
132
132
|
pub.close()
|
|
133
133
|
node.close()
|
|
@@ -179,7 +179,7 @@ async def test_topic_collision_evicts_loser():
|
|
|
179
179
|
topic2 = node.topics_by_name[resolved2]
|
|
180
180
|
|
|
181
181
|
# Both topics should exist with non-colliding subject-IDs (the allocator resolved them).
|
|
182
|
-
assert topic1.subject_id != topic2.subject_id or topic1 is topic2
|
|
182
|
+
assert topic1.subject_id(tr.subject_id_modulus) != topic2.subject_id(tr.subject_id_modulus) or topic1 is topic2
|
|
183
183
|
assert topic1.name in node.topics_by_name
|
|
184
184
|
assert topic2.name in node.topics_by_name
|
|
185
185
|
|
|
@@ -217,7 +217,7 @@ async def test_collision_allocator_iterates():
|
|
|
217
217
|
# Collect all subject-IDs (non-pinned).
|
|
218
218
|
sids = set()
|
|
219
219
|
for name, topic in node.topics_by_name.items():
|
|
220
|
-
sid = topic.subject_id
|
|
220
|
+
sid = topic.subject_id(tr.subject_id_modulus)
|
|
221
221
|
if sid not in sids:
|
|
222
222
|
sids.add(sid)
|
|
223
223
|
else:
|
|
@@ -318,7 +318,7 @@ async def test_gossip_unknown_collision_we_win():
|
|
|
318
318
|
pub = node.advertise("my/topic")
|
|
319
319
|
resolved, _, _ = resolve_name("my/topic", "test_node", "")
|
|
320
320
|
topic = node.topics_by_name[resolved]
|
|
321
|
-
my_sid = topic.subject_id
|
|
321
|
+
my_sid = topic.subject_id(tr.subject_id_modulus)
|
|
322
322
|
|
|
323
323
|
# Make our topic very old so we win.
|
|
324
324
|
topic.ts_origin = time.monotonic() - 100000
|
|
@@ -1,89 +0,0 @@
|
|
|
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
|
-
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|