pycyphal2 2.0.0.dev5__tar.gz → 2.0.0.dev6__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.dev5/src/pycyphal2.egg-info → pycyphal2-2.0.0.dev6}/PKG-INFO +1 -1
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/src/pycyphal2/__init__.py +1 -1
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/src/pycyphal2/_api.py +4 -5
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/src/pycyphal2/_hash.py +0 -13
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/src/pycyphal2/_header.py +0 -34
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/src/pycyphal2/_node.py +2 -82
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/src/pycyphal2/_publisher.py +0 -6
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/src/pycyphal2/_subscriber.py +0 -17
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/src/pycyphal2/_transport.py +5 -0
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/src/pycyphal2/can/_interface.py +19 -4
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/src/pycyphal2/can/_media_slcan.py +1 -2
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/src/pycyphal2/can/_transport.py +16 -7
- pycyphal2-2.0.0.dev6/src/pycyphal2/can/_tx.py +125 -0
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/src/pycyphal2/can/pythoncan.py +34 -33
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/src/pycyphal2/can/socketcan.py +28 -31
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/src/pycyphal2/can/webserial.py +36 -27
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/src/pycyphal2/udp.py +2 -68
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6/src/pycyphal2.egg-info}/PKG-INFO +1 -1
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/src/pycyphal2.egg-info/SOURCES.txt +1 -0
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/tests/test_gossip.py +84 -50
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/tests/test_hash.py +0 -2
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/tests/test_header.py +0 -34
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/tests/test_integration.py +7 -35
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/tests/test_monitor.py +3 -5
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/tests/test_names.py +11 -84
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/tests/test_parity.py +24 -115
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/tests/test_pubsub.py +16 -96
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/tests/test_reliable.py +3 -103
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/tests/test_reorder.py +1 -4
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/tests/test_rpc.py +3 -18
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/tests/test_scout.py +0 -2
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/tests/test_topic.py +18 -78
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/tests/test_udp.py +3 -100
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/LICENSE +0 -0
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/README.md +0 -0
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/pyproject.toml +0 -0
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/setup.cfg +0 -0
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/src/pycyphal2/can/__init__.py +0 -0
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/src/pycyphal2/can/_reassembly.py +0 -0
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/src/pycyphal2/can/_wire.py +0 -0
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/src/pycyphal2/py.typed +0 -0
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/src/pycyphal2.egg-info/dependency_links.txt +0 -0
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/src/pycyphal2.egg-info/requires.txt +0 -0
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/src/pycyphal2.egg-info/top_level.txt +0 -0
- {pycyphal2-2.0.0.dev5 → pycyphal2-2.0.0.dev6}/tests/test_parity_coverage.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.dev6
|
|
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
|
|
@@ -155,7 +155,7 @@ from ._transport import SubjectWriter as SubjectWriter
|
|
|
155
155
|
from ._transport import Transport as Transport
|
|
156
156
|
from ._transport import TransportArrival as TransportArrival
|
|
157
157
|
|
|
158
|
-
__version__ = "2.0.0.
|
|
158
|
+
__version__ = "2.0.0.dev6"
|
|
159
159
|
|
|
160
160
|
# pdoc needs __all__ to display re-exported members.
|
|
161
161
|
__all__ = [
|
|
@@ -219,7 +219,7 @@ class Publisher(Closable, ABC):
|
|
|
219
219
|
Represents the intent to send messages on a topic.
|
|
220
220
|
|
|
221
221
|
Calling the publisher sends one message.
|
|
222
|
-
By default this is best-effort publication: the message is sent once and
|
|
222
|
+
By default this is best-effort publication: the message is sent once and delivery is not confirmed.
|
|
223
223
|
With ``reliable=True``, the library retransmits until the deadline and waits for acknowledgments from remote
|
|
224
224
|
subscribers.
|
|
225
225
|
|
|
@@ -262,6 +262,9 @@ class Publisher(Closable, ABC):
|
|
|
262
262
|
Blocks at most until ``deadline``.
|
|
263
263
|
Raises :class:`SendError` if the message could not be sent before the deadline.
|
|
264
264
|
|
|
265
|
+
Returning means the message has been handed over to the network, not that anyone received it.
|
|
266
|
+
The call waits for that handover, so a congested network applies backpressure to the publisher.
|
|
267
|
+
|
|
265
268
|
If ``reliable`` is false, the message is sent once.
|
|
266
269
|
If ``reliable`` is true, the library retransmits until ``deadline`` leveraging :attr:`ack_timeout`.
|
|
267
270
|
"""
|
|
@@ -552,21 +555,17 @@ class Node(Closable, ABC):
|
|
|
552
555
|
"""
|
|
553
556
|
from ._node import NodeImpl
|
|
554
557
|
|
|
555
|
-
# Add random suffix if requested or generate pure random home.
|
|
556
558
|
# Leading/trailing separators will be normalized away.
|
|
557
559
|
home = home.strip() or "/"
|
|
558
560
|
if home.endswith("/"):
|
|
559
561
|
uid = transport.uid if hasattr(transport, "uid") else eui64()
|
|
560
562
|
home += f"{uid:016x}"
|
|
561
563
|
|
|
562
|
-
# Initialize the namespace: if not given explicitly, read it from the standard environment.
|
|
563
564
|
namespace = namespace.strip() or os.getenv("CYPHAL_NAMESPACE", "").strip()
|
|
564
565
|
|
|
565
|
-
# Construct the node.
|
|
566
566
|
node = NodeImpl(transport, home=home, namespace=namespace)
|
|
567
567
|
_logger.info("Constructed %s", node)
|
|
568
568
|
|
|
569
|
-
# Set up default name remapping.
|
|
570
569
|
try:
|
|
571
570
|
node.remap(os.getenv("CYPHAL_REMAP", ""))
|
|
572
571
|
except Exception as ex:
|
|
@@ -1,11 +1,5 @@
|
|
|
1
|
-
"""Hash and CRC utilities"""
|
|
2
|
-
|
|
3
1
|
from __future__ import annotations
|
|
4
2
|
|
|
5
|
-
# =====================================================================================================================
|
|
6
|
-
# CRC-32C (Castagnoli)
|
|
7
|
-
# =====================================================================================================================
|
|
8
|
-
|
|
9
3
|
CRC32C_INITIAL = 0xFFFFFFFF
|
|
10
4
|
CRC32C_OUTPUT_XOR = 0xFFFFFFFF
|
|
11
5
|
CRC32C_RESIDUE = 0x48674BC7
|
|
@@ -59,10 +53,6 @@ def crc32c_full(data: bytes | memoryview) -> int:
|
|
|
59
53
|
return crc32c_add(CRC32C_INITIAL, data) ^ CRC32C_OUTPUT_XOR
|
|
60
54
|
|
|
61
55
|
|
|
62
|
-
# =====================================================================================================================
|
|
63
|
-
# CRC-16/CCITT-FALSE
|
|
64
|
-
# =====================================================================================================================
|
|
65
|
-
|
|
66
56
|
CRC16CCITT_FALSE_INITIAL = 0xFFFF
|
|
67
57
|
CRC16CCITT_FALSE_RESIDUE = 0x0000
|
|
68
58
|
# fmt: off
|
|
@@ -103,10 +93,7 @@ def crc16ccitt_false_full(data: bytes | memoryview) -> int:
|
|
|
103
93
|
return crc16ccitt_false_add(CRC16CCITT_FALSE_INITIAL, data)
|
|
104
94
|
|
|
105
95
|
|
|
106
|
-
# =====================================================================================================================
|
|
107
96
|
# rapidhash V3
|
|
108
|
-
# =====================================================================================================================
|
|
109
|
-
|
|
110
97
|
_RAPID_MASK = 0xFFFFFFFFFFFFFFFF
|
|
111
98
|
_RAPID_SECRET = (
|
|
112
99
|
0x2D358DCCAA6C78A5,
|
|
@@ -11,11 +11,6 @@ LAGE_MIN = -1
|
|
|
11
11
|
LAGE_MAX = 35
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
# =====================================================================================================================
|
|
15
|
-
# MSG headers
|
|
16
|
-
# =====================================================================================================================
|
|
17
|
-
|
|
18
|
-
|
|
19
14
|
@dataclass(frozen=True)
|
|
20
15
|
class MsgBeHeader:
|
|
21
16
|
TYPE = 0
|
|
@@ -76,11 +71,6 @@ def _deserialize_msg(buf: bytes | memoryview) -> tuple[int, int, int, int] | Non
|
|
|
76
71
|
return (lage, evictions, topic_hash, tag)
|
|
77
72
|
|
|
78
73
|
|
|
79
|
-
# =====================================================================================================================
|
|
80
|
-
# MSG ACK/NACK headers
|
|
81
|
-
# =====================================================================================================================
|
|
82
|
-
|
|
83
|
-
|
|
84
74
|
@dataclass(frozen=True)
|
|
85
75
|
class MsgAckHeader:
|
|
86
76
|
TYPE = 2
|
|
@@ -131,11 +121,6 @@ def _deserialize_msg_ack(buf: bytes | memoryview) -> tuple[int, int] | None:
|
|
|
131
121
|
return (topic_hash, tag)
|
|
132
122
|
|
|
133
123
|
|
|
134
|
-
# =====================================================================================================================
|
|
135
|
-
# RSP headers (responses)
|
|
136
|
-
# =====================================================================================================================
|
|
137
|
-
|
|
138
|
-
|
|
139
124
|
@dataclass(frozen=True)
|
|
140
125
|
class RspBeHeader:
|
|
141
126
|
TYPE = 4
|
|
@@ -172,11 +157,6 @@ class RspRelHeader:
|
|
|
172
157
|
return RspRelHeader(*r) if r is not None else None
|
|
173
158
|
|
|
174
159
|
|
|
175
|
-
# =====================================================================================================================
|
|
176
|
-
# RSP ACK/NACK headers
|
|
177
|
-
# =====================================================================================================================
|
|
178
|
-
|
|
179
|
-
|
|
180
160
|
@dataclass(frozen=True)
|
|
181
161
|
class RspAckHeader:
|
|
182
162
|
TYPE = 6
|
|
@@ -237,11 +217,6 @@ def _deserialize_rsp(buf: bytes | memoryview) -> tuple[int, int, int, int] | Non
|
|
|
237
217
|
return (tag, seqno, topic_hash, message_tag)
|
|
238
218
|
|
|
239
219
|
|
|
240
|
-
# =====================================================================================================================
|
|
241
|
-
# GOSSIP header
|
|
242
|
-
# =====================================================================================================================
|
|
243
|
-
|
|
244
|
-
|
|
245
220
|
@dataclass(frozen=True)
|
|
246
221
|
class GossipHeader:
|
|
247
222
|
TYPE = 8
|
|
@@ -275,11 +250,6 @@ class GossipHeader:
|
|
|
275
250
|
return GossipHeader(lage, topic_hash, evictions, name_len)
|
|
276
251
|
|
|
277
252
|
|
|
278
|
-
# =====================================================================================================================
|
|
279
|
-
# SCOUT header
|
|
280
|
-
# =====================================================================================================================
|
|
281
|
-
|
|
282
|
-
|
|
283
253
|
@dataclass(frozen=True)
|
|
284
254
|
class ScoutHeader:
|
|
285
255
|
TYPE = 9
|
|
@@ -303,10 +273,6 @@ class ScoutHeader:
|
|
|
303
273
|
return ScoutHeader(buf[23])
|
|
304
274
|
|
|
305
275
|
|
|
306
|
-
# =====================================================================================================================
|
|
307
|
-
# Dispatcher
|
|
308
|
-
# =====================================================================================================================
|
|
309
|
-
|
|
310
276
|
HeaderType = (
|
|
311
277
|
MsgBeHeader
|
|
312
278
|
| MsgRelHeader
|
|
@@ -38,10 +38,6 @@ if TYPE_CHECKING:
|
|
|
38
38
|
|
|
39
39
|
_logger = logging.getLogger(__name__)
|
|
40
40
|
|
|
41
|
-
# =====================================================================================================================
|
|
42
|
-
# Constants
|
|
43
|
-
# =====================================================================================================================
|
|
44
|
-
|
|
45
41
|
TOPIC_NAME_MAX = 200
|
|
46
42
|
EVICTIONS_PINNED_MIN = 0xFFFFE000
|
|
47
43
|
GOSSIP_PERIOD = 5.0
|
|
@@ -82,11 +78,6 @@ class GossipScope(Enum):
|
|
|
82
78
|
INLINE = auto()
|
|
83
79
|
|
|
84
80
|
|
|
85
|
-
# =====================================================================================================================
|
|
86
|
-
# Name Resolution
|
|
87
|
-
# =====================================================================================================================
|
|
88
|
-
|
|
89
|
-
|
|
90
81
|
def _name_normalize(name: str) -> str:
|
|
91
82
|
"""Collapse separators, strip leading/trailing separators."""
|
|
92
83
|
parts: list[str] = []
|
|
@@ -120,7 +111,6 @@ def _name_consume_pin_suffix(name: str) -> tuple[str, int | None]:
|
|
|
120
111
|
|
|
121
112
|
|
|
122
113
|
def _name_join(left: str, right: str) -> str:
|
|
123
|
-
"""Join two name parts with separator, normalizing the result."""
|
|
124
114
|
left = _name_normalize(left)
|
|
125
115
|
right = _name_normalize(right)
|
|
126
116
|
if left and right:
|
|
@@ -163,7 +153,6 @@ def resolve_name(
|
|
|
163
153
|
if not name:
|
|
164
154
|
raise ValueError("Empty name")
|
|
165
155
|
|
|
166
|
-
# Strip pin suffix first.
|
|
167
156
|
name, pin = _name_consume_pin_suffix(name)
|
|
168
157
|
|
|
169
158
|
# Apply remapping: lookup on normalized pin-free name; matched rule replaces both name and pin.
|
|
@@ -173,7 +162,6 @@ def resolve_name(
|
|
|
173
162
|
name = remaps[lookup]
|
|
174
163
|
name, pin = _name_consume_pin_suffix(name)
|
|
175
164
|
|
|
176
|
-
# Classify and construct.
|
|
177
165
|
if name.startswith("/"):
|
|
178
166
|
resolved = _name_normalize(name)
|
|
179
167
|
elif _name_is_homeful(name):
|
|
@@ -191,7 +179,6 @@ def resolve_name(
|
|
|
191
179
|
raise ValueError("Name resolves to empty string")
|
|
192
180
|
if len(resolved) > TOPIC_NAME_MAX:
|
|
193
181
|
raise ValueError(f"Resolved name exceeds {TOPIC_NAME_MAX} characters")
|
|
194
|
-
# Validate characters: ASCII 33-126 and '/' only.
|
|
195
182
|
for ch in resolved:
|
|
196
183
|
o = ord(ch)
|
|
197
184
|
if o < 33 or o > 126:
|
|
@@ -203,11 +190,6 @@ def resolve_name(
|
|
|
203
190
|
return resolved, pin, verbatim
|
|
204
191
|
|
|
205
192
|
|
|
206
|
-
# =====================================================================================================================
|
|
207
|
-
# Pattern Matching
|
|
208
|
-
# =====================================================================================================================
|
|
209
|
-
|
|
210
|
-
|
|
211
193
|
def match_pattern(pattern: str, name: str) -> list[tuple[str, int]] | None:
|
|
212
194
|
"""
|
|
213
195
|
Match a pattern against a topic name.
|
|
@@ -237,13 +219,7 @@ def match_pattern(pattern: str, name: str) -> list[tuple[str, int]] | None:
|
|
|
237
219
|
return subs
|
|
238
220
|
|
|
239
221
|
|
|
240
|
-
# =====================================================================================================================
|
|
241
|
-
# Subject-ID Computation
|
|
242
|
-
# =====================================================================================================================
|
|
243
|
-
|
|
244
|
-
|
|
245
222
|
def compute_subject_id(topic_hash: int, evictions: int, modulus: int) -> int:
|
|
246
|
-
"""Compute the subject-ID for a topic given its hash, evictions, and subject-ID modulus."""
|
|
247
223
|
if evictions >= EVICTIONS_PINNED_MIN:
|
|
248
224
|
return 0xFFFFFFFF - evictions
|
|
249
225
|
h = topic_hash % modulus
|
|
@@ -251,11 +227,6 @@ def compute_subject_id(topic_hash: int, evictions: int, modulus: int) -> int:
|
|
|
251
227
|
return SUBJECT_ID_PINNED_MAX + 1 + ((h + ((e * e) % modulus)) % modulus)
|
|
252
228
|
|
|
253
229
|
|
|
254
|
-
# =====================================================================================================================
|
|
255
|
-
# Internal Data Structures
|
|
256
|
-
# =====================================================================================================================
|
|
257
|
-
|
|
258
|
-
|
|
259
230
|
@dataclass
|
|
260
231
|
class Association:
|
|
261
232
|
"""Tracks a known remote subscriber for reliable delivery ACK tracking."""
|
|
@@ -396,11 +367,6 @@ class PublishTracker:
|
|
|
396
367
|
self.ack_event.set()
|
|
397
368
|
|
|
398
369
|
|
|
399
|
-
# =====================================================================================================================
|
|
400
|
-
# Topic
|
|
401
|
-
# =====================================================================================================================
|
|
402
|
-
|
|
403
|
-
|
|
404
370
|
class TopicImpl(Topic):
|
|
405
371
|
|
|
406
372
|
def __init__(self, node: NodeImpl, name: str, evictions: int, now: float) -> None:
|
|
@@ -426,7 +392,6 @@ class TopicImpl(Topic):
|
|
|
426
392
|
self.gossip_task_is_periodic = False
|
|
427
393
|
self.gossip_counter = 0
|
|
428
394
|
|
|
429
|
-
# -- Topic ABC --
|
|
430
395
|
@property
|
|
431
396
|
def hash(self) -> int:
|
|
432
397
|
return self._topic_hash
|
|
@@ -448,7 +413,6 @@ class TopicImpl(Topic):
|
|
|
448
413
|
def match(self, pattern: str) -> list[tuple[str, int]] | None:
|
|
449
414
|
return match_pattern(pattern, self._name)
|
|
450
415
|
|
|
451
|
-
# -- Internal --
|
|
452
416
|
def lage(self, now: float) -> int:
|
|
453
417
|
return log_age(self.ts_origin, now)
|
|
454
418
|
|
|
@@ -529,11 +493,6 @@ def left_wins(l_lage: int, l_hash: int, r_lage: int, r_hash: int) -> bool:
|
|
|
529
493
|
return l_lage > r_lage if l_lage != r_lage else l_hash < r_hash
|
|
530
494
|
|
|
531
495
|
|
|
532
|
-
# =====================================================================================================================
|
|
533
|
-
# Node
|
|
534
|
-
# =====================================================================================================================
|
|
535
|
-
|
|
536
|
-
|
|
537
496
|
class NodeImpl(Node):
|
|
538
497
|
def __init__(self, transport: Transport, *, home: str, namespace: str) -> None:
|
|
539
498
|
self._transport = transport
|
|
@@ -545,26 +504,21 @@ class NodeImpl(Node):
|
|
|
545
504
|
self._monitor_callbacks: dict[int, Callable[[Topic], None]] = {}
|
|
546
505
|
self._next_monitor_callback_id = 0
|
|
547
506
|
|
|
548
|
-
# Topic indexes.
|
|
549
507
|
self.topics_by_name: dict[str, TopicImpl] = {}
|
|
550
508
|
self.topics_by_hash: dict[int, TopicImpl] = {}
|
|
551
509
|
self.topics_by_subject_id: dict[int, TopicImpl] = {} # non-pinned only
|
|
552
510
|
|
|
553
|
-
# Subscriber roots.
|
|
554
511
|
self.sub_roots_verbatim: dict[str, SubscriberRoot] = {}
|
|
555
512
|
self.sub_roots_pattern: dict[str, SubscriberRoot] = {}
|
|
556
513
|
|
|
557
|
-
# Respond futures for reliable responses.
|
|
558
514
|
self.respond_futures: dict[tuple[int, ...], RespondTracker] = {}
|
|
559
515
|
|
|
560
|
-
# Compute broadcast and gossip shard subject IDs.
|
|
561
516
|
modulus = transport.subject_id_modulus
|
|
562
517
|
sid_max = SUBJECT_ID_PINNED_MAX + modulus
|
|
563
518
|
self.broadcast_subject_id = (1 << (int(math.log2(sid_max)) + 1)) - 1
|
|
564
519
|
self.gossip_shard_count = self.broadcast_subject_id - (sid_max + 1)
|
|
565
520
|
assert self.gossip_shard_count > 0
|
|
566
521
|
|
|
567
|
-
# Set up broadcast writer and listener.
|
|
568
522
|
self.broadcast_writer = transport.subject_advertise(self.broadcast_subject_id)
|
|
569
523
|
|
|
570
524
|
def broadcast_handler(arrival: TransportArrival) -> None:
|
|
@@ -572,16 +526,13 @@ class NodeImpl(Node):
|
|
|
572
526
|
|
|
573
527
|
self.broadcast_listener = transport.subject_listen(self.broadcast_subject_id, broadcast_handler)
|
|
574
528
|
|
|
575
|
-
# Gossip shard state: lazily created per shard.
|
|
576
529
|
self.gossip_shard_writers: dict[int, SubjectWriter] = {}
|
|
577
530
|
self.gossip_shard_listeners: dict[int, Closable] = {}
|
|
578
531
|
self.shared_subject_writers: dict[int, SharedSubjectWriter] = {}
|
|
579
532
|
self.shared_subject_listeners: dict[int, SharedSubjectListener] = {}
|
|
580
533
|
|
|
581
|
-
# Register unicast handler.
|
|
582
534
|
transport.unicast_listen(self.on_unicast_arrival)
|
|
583
535
|
|
|
584
|
-
# Implicit topic GC task, driven by the earliest implicit-topic expiry.
|
|
585
536
|
self._implicit_topics: OrderedDict[TopicImpl, None] = OrderedDict()
|
|
586
537
|
self._implicit_gc_wakeup = asyncio.Event()
|
|
587
538
|
self._gc_task = self.loop.create_task(self.implicit_gc_loop())
|
|
@@ -594,7 +545,6 @@ class NodeImpl(Node):
|
|
|
594
545
|
self.gossip_shard_count,
|
|
595
546
|
)
|
|
596
547
|
|
|
597
|
-
# -- Node ABC --
|
|
598
548
|
@property
|
|
599
549
|
def home(self) -> str:
|
|
600
550
|
return self._home
|
|
@@ -666,7 +616,6 @@ class NodeImpl(Node):
|
|
|
666
616
|
if pin is not None and not verbatim:
|
|
667
617
|
raise ValueError("Pattern names cannot be pinned")
|
|
668
618
|
|
|
669
|
-
# Ensure subscriber root.
|
|
670
619
|
if verbatim:
|
|
671
620
|
root = self.sub_roots_verbatim.get(resolved)
|
|
672
621
|
if root is None:
|
|
@@ -682,12 +631,10 @@ class NodeImpl(Node):
|
|
|
682
631
|
root.subscribers.append(subscriber)
|
|
683
632
|
|
|
684
633
|
if verbatim:
|
|
685
|
-
# Ensure topic exists and couple.
|
|
686
634
|
topic = self.topic_ensure(resolved, pin)
|
|
687
635
|
self.couple_topic_root(topic, root)
|
|
688
636
|
topic.sync_implicit()
|
|
689
637
|
else:
|
|
690
|
-
# Pattern subscriber: couple with all existing matching topics and scout once per root.
|
|
691
638
|
for topic in list(self.topics_by_name.values()):
|
|
692
639
|
self.couple_topic_root(topic, root)
|
|
693
640
|
topic.sync_implicit()
|
|
@@ -725,10 +672,7 @@ class NodeImpl(Node):
|
|
|
725
672
|
except Exception as ex:
|
|
726
673
|
raise SendError(f"Scout send failed for '{resolved}'") from ex
|
|
727
674
|
|
|
728
|
-
# -- Topic Management --
|
|
729
|
-
|
|
730
675
|
def topic_ensure(self, name: str, pin: int | None) -> TopicImpl:
|
|
731
|
-
"""Get or create a topic by resolved name."""
|
|
732
676
|
topic = self.topics_by_name.get(name)
|
|
733
677
|
if topic is not None:
|
|
734
678
|
return topic
|
|
@@ -742,7 +686,6 @@ class NodeImpl(Node):
|
|
|
742
686
|
self.ensure_gossip_shard(self.gossip_shard_subject_id(topic.hash))
|
|
743
687
|
self.touch_implicit_topic(topic)
|
|
744
688
|
self.topic_allocate(topic, evictions, now)
|
|
745
|
-
# Couple with existing pattern subscriber roots.
|
|
746
689
|
for root in self.sub_roots_pattern.values():
|
|
747
690
|
self.couple_topic_root(topic, root)
|
|
748
691
|
topic.sync_listener()
|
|
@@ -757,12 +700,10 @@ class NodeImpl(Node):
|
|
|
757
700
|
|
|
758
701
|
def topic_allocate(self, topic: TopicImpl, new_evictions: int, now: float) -> None:
|
|
759
702
|
"""Iterative subject-ID allocation with collision resolution. Mirrors topic_allocate() in cy.c."""
|
|
760
|
-
# Work queue: list of (topic, new_evictions) pairs to process.
|
|
761
703
|
modulus = self.transport.subject_id_modulus
|
|
762
704
|
work: list[tuple[TopicImpl, int]] = [(topic, new_evictions)]
|
|
763
705
|
while work:
|
|
764
706
|
t, ev = work.pop(0)
|
|
765
|
-
# Remove from subject-ID index first.
|
|
766
707
|
old_sid = t.subject_id(modulus)
|
|
767
708
|
if old_sid in self.topics_by_subject_id and self.topics_by_subject_id[old_sid] is t:
|
|
768
709
|
del self.topics_by_subject_id[old_sid]
|
|
@@ -782,14 +723,12 @@ class NodeImpl(Node):
|
|
|
782
723
|
collider = None # same topic, no real collision
|
|
783
724
|
|
|
784
725
|
if collider is None:
|
|
785
|
-
# No collision, install.
|
|
786
726
|
t.release_transport_handles()
|
|
787
727
|
t.set_evictions(ev)
|
|
788
728
|
self.topics_by_subject_id[new_sid] = t
|
|
789
729
|
t.sync_listener()
|
|
790
730
|
self.schedule_gossip_urgent(t)
|
|
791
731
|
elif left_wins(t.lage(now), t.hash, collider.lage(now), collider.hash):
|
|
792
|
-
# Our topic wins: take the slot, evict the collider.
|
|
793
732
|
t.release_transport_handles()
|
|
794
733
|
t.set_evictions(ev)
|
|
795
734
|
del self.topics_by_subject_id[new_sid]
|
|
@@ -798,11 +737,9 @@ class NodeImpl(Node):
|
|
|
798
737
|
t.pub_writer = self.acquire_subject_writer(t, new_sid)
|
|
799
738
|
t.sync_listener()
|
|
800
739
|
self.schedule_gossip_urgent(t)
|
|
801
|
-
# Schedule collider for reallocation.
|
|
802
740
|
collider.release_transport_handles()
|
|
803
741
|
work.append((collider, collider.evictions + 1))
|
|
804
742
|
else:
|
|
805
|
-
# Our topic loses: increment evictions and retry.
|
|
806
743
|
work.append((t, ev + 1))
|
|
807
744
|
|
|
808
745
|
def sync_topic_lifecycle(self, topic: TopicImpl) -> None:
|
|
@@ -877,17 +814,14 @@ class NodeImpl(Node):
|
|
|
877
814
|
|
|
878
815
|
@staticmethod
|
|
879
816
|
def couple_topic_root(topic: TopicImpl, root: SubscriberRoot) -> None:
|
|
880
|
-
"""Create a coupling between a topic and a subscriber root if not already coupled."""
|
|
881
817
|
for c in topic.couplings:
|
|
882
818
|
if c.root is root:
|
|
883
|
-
return
|
|
819
|
+
return
|
|
884
820
|
subs = match_pattern(root.name, topic.name) if root.is_pattern else ([] if root.name == topic.name else None)
|
|
885
821
|
if subs is not None:
|
|
886
822
|
topic.couplings.append(Coupling(root=root, substitutions=subs))
|
|
887
823
|
_logger.debug("Coupled '%s' <-> root '%s'", topic.name, root.name)
|
|
888
824
|
|
|
889
|
-
# -- Gossip --
|
|
890
|
-
|
|
891
825
|
def gossip_shard_subject_id(self, topic_hash: int) -> int:
|
|
892
826
|
modulus = self.transport.subject_id_modulus
|
|
893
827
|
sid_max = SUBJECT_ID_PINNED_MAX + modulus
|
|
@@ -950,9 +884,8 @@ class NodeImpl(Node):
|
|
|
950
884
|
_logger.debug("Shared subject listener released sid=%d", subject_id)
|
|
951
885
|
|
|
952
886
|
def schedule_gossip(self, topic: TopicImpl) -> None:
|
|
953
|
-
"""Start periodic gossip for an explicit topic."""
|
|
954
887
|
if topic.gossip_task is not None:
|
|
955
|
-
return
|
|
888
|
+
return
|
|
956
889
|
self._reschedule_gossip_periodic(topic, suppressed=False)
|
|
957
890
|
|
|
958
891
|
@staticmethod
|
|
@@ -1066,8 +999,6 @@ class NodeImpl(Node):
|
|
|
1066
999
|
except (SendError, OSError) as e:
|
|
1067
1000
|
_logger.warning("Gossip unicast send failed for '%s': %s", topic.name, e)
|
|
1068
1001
|
|
|
1069
|
-
# -- Scout --
|
|
1070
|
-
|
|
1071
1002
|
async def _transmit_scout(self, pattern: str) -> None:
|
|
1072
1003
|
pattern_bytes = pattern.encode("utf-8")
|
|
1073
1004
|
hdr = ScoutHeader(pattern_len=len(pattern_bytes))
|
|
@@ -1096,14 +1027,10 @@ class NodeImpl(Node):
|
|
|
1096
1027
|
|
|
1097
1028
|
root.scout_task = self.loop.create_task(do_send())
|
|
1098
1029
|
|
|
1099
|
-
# -- Message Dispatch --
|
|
1100
|
-
|
|
1101
1030
|
def on_subject_arrival(self, subject_id: int, arrival: TransportArrival) -> None:
|
|
1102
|
-
"""Handle an arrival on a subject (multicast)."""
|
|
1103
1031
|
self.dispatch_arrival(arrival, subject_id=subject_id, unicast=False)
|
|
1104
1032
|
|
|
1105
1033
|
def on_unicast_arrival(self, arrival: TransportArrival) -> None:
|
|
1106
|
-
"""Handle an arrival via unicast."""
|
|
1107
1034
|
self.dispatch_arrival(arrival, subject_id=None, unicast=True)
|
|
1108
1035
|
|
|
1109
1036
|
def dispatch_arrival(self, arrival: TransportArrival, *, subject_id: int | None, unicast: bool) -> None:
|
|
@@ -1293,7 +1220,6 @@ class NodeImpl(Node):
|
|
|
1293
1220
|
tracker.on_ack(remote_id, positive)
|
|
1294
1221
|
|
|
1295
1222
|
def on_rsp(self, arrival: TransportArrival, hdr: RspBeHeader | RspRelHeader, payload: bytes) -> None:
|
|
1296
|
-
"""Handle a response message (for RPC)."""
|
|
1297
1223
|
ack = False
|
|
1298
1224
|
topic = self.topics_by_hash.get(hdr.topic_hash)
|
|
1299
1225
|
if topic is not None:
|
|
@@ -1317,7 +1243,6 @@ class NodeImpl(Node):
|
|
|
1317
1243
|
)
|
|
1318
1244
|
|
|
1319
1245
|
def on_rsp_ack(self, arrival: TransportArrival, hdr: RspAckHeader | RspNackHeader) -> None:
|
|
1320
|
-
"""Handle a response ACK/NACK."""
|
|
1321
1246
|
key = (arrival.remote_id, hdr.message_tag, hdr.topic_hash, hdr.seqno, hdr.tag)
|
|
1322
1247
|
future = self.respond_futures.get(key)
|
|
1323
1248
|
if future is not None:
|
|
@@ -1366,7 +1291,6 @@ class NodeImpl(Node):
|
|
|
1366
1291
|
|
|
1367
1292
|
topic = self.topics_by_hash.get(hdr.topic_hash)
|
|
1368
1293
|
|
|
1369
|
-
# If unknown topic with a name, check for pattern subscriber matches.
|
|
1370
1294
|
if topic is None and name:
|
|
1371
1295
|
if scope in {GossipScope.UNICAST, GossipScope.BROADCAST}:
|
|
1372
1296
|
topic = self.topic_subscribe_if_matching(
|
|
@@ -1472,8 +1396,6 @@ class NodeImpl(Node):
|
|
|
1472
1396
|
self.send_gossip_unicast(topic, arrival.remote_id, arrival.priority), "gossip unicast"
|
|
1473
1397
|
)
|
|
1474
1398
|
|
|
1475
|
-
# -- Implicit Topic GC --
|
|
1476
|
-
|
|
1477
1399
|
def notify_implicit_gc(self) -> None:
|
|
1478
1400
|
if not self._closed:
|
|
1479
1401
|
self._implicit_gc_wakeup.set()
|
|
@@ -1534,8 +1456,6 @@ class NodeImpl(Node):
|
|
|
1534
1456
|
self.notify_implicit_gc()
|
|
1535
1457
|
_logger.info("Topic destroyed '%s'", name)
|
|
1536
1458
|
|
|
1537
|
-
# -- Cleanup --
|
|
1538
|
-
|
|
1539
1459
|
def close(self) -> None:
|
|
1540
1460
|
if self._closed:
|
|
1541
1461
|
return
|
|
@@ -297,11 +297,6 @@ class PublisherImpl(Publisher):
|
|
|
297
297
|
_logger.info("Publisher closed for '%s'", self._topic.name)
|
|
298
298
|
|
|
299
299
|
|
|
300
|
-
# =====================================================================================================================
|
|
301
|
-
# Response Stream
|
|
302
|
-
# =====================================================================================================================
|
|
303
|
-
|
|
304
|
-
|
|
305
300
|
class ResponseStreamImpl(ResponseStream):
|
|
306
301
|
def __init__(
|
|
307
302
|
self,
|
|
@@ -367,7 +362,6 @@ class ResponseStreamImpl(ResponseStream):
|
|
|
367
362
|
hdr: RspBeHeader | RspRelHeader,
|
|
368
363
|
payload: bytes,
|
|
369
364
|
) -> bool:
|
|
370
|
-
"""Called by the node when a response arrives matching our message_tag."""
|
|
371
365
|
reliable = isinstance(hdr, RspRelHeader)
|
|
372
366
|
if self.closed:
|
|
373
367
|
if not reliable:
|
|
@@ -23,11 +23,6 @@ _logger = logging.getLogger(__name__)
|
|
|
23
23
|
REORDERING_WINDOW_MAX = SESSION_LIFETIME / 2
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
# =====================================================================================================================
|
|
27
|
-
# Reordering
|
|
28
|
-
# =====================================================================================================================
|
|
29
|
-
|
|
30
|
-
|
|
31
26
|
@dataclass
|
|
32
27
|
class InternedMsg:
|
|
33
28
|
arrival: Arrival
|
|
@@ -114,13 +109,11 @@ class SubscriberImpl(Subscriber):
|
|
|
114
109
|
return item
|
|
115
110
|
|
|
116
111
|
def deliver(self, arrival: Arrival, tag: int, remote_id: int) -> bool:
|
|
117
|
-
"""Called by the node to deliver a message to this subscriber."""
|
|
118
112
|
if self.closed:
|
|
119
113
|
return False
|
|
120
114
|
if self._reordering_window is None:
|
|
121
115
|
self.queue.put_nowait(arrival)
|
|
122
116
|
return True
|
|
123
|
-
# Reordering enabled.
|
|
124
117
|
self._drop_stale_reordering(arrival.timestamp.s)
|
|
125
118
|
topic_hash = arrival.breadcrumb.topic.hash
|
|
126
119
|
key = (remote_id, topic_hash)
|
|
@@ -148,7 +141,6 @@ class SubscriberImpl(Subscriber):
|
|
|
148
141
|
|
|
149
142
|
expected = state.last_ejected_lin_tag + 1
|
|
150
143
|
if lin_tag == expected:
|
|
151
|
-
# In-order: eject immediately and scan for consecutive.
|
|
152
144
|
self.queue.put_nowait(arrival)
|
|
153
145
|
state.last_ejected_lin_tag = lin_tag
|
|
154
146
|
self._scan_reordering(state, force_first=False)
|
|
@@ -160,7 +152,6 @@ class SubscriberImpl(Subscriber):
|
|
|
160
152
|
lin_tag = (tag - state.tag_baseline) & ((1 << 64) - 1)
|
|
161
153
|
_logger.debug("Reorder resequence tag=%d lin=%d", tag, lin_tag)
|
|
162
154
|
|
|
163
|
-
# Out-of-order but within capacity: intern.
|
|
164
155
|
if lin_tag in state.interned:
|
|
165
156
|
return True
|
|
166
157
|
state.interned[lin_tag] = InternedMsg(arrival=arrival, tag=tag, remote_id=remote_id, lin_tag=lin_tag)
|
|
@@ -187,7 +178,6 @@ class SubscriberImpl(Subscriber):
|
|
|
187
178
|
break
|
|
188
179
|
|
|
189
180
|
def _force_eject_all(self, state: ReorderingState, *, silenced: bool = False) -> None:
|
|
190
|
-
"""Force-eject all interned messages in tag order."""
|
|
191
181
|
while state.interned:
|
|
192
182
|
lin_tag = min(state.interned)
|
|
193
183
|
interned = state.interned.pop(lin_tag)
|
|
@@ -199,7 +189,6 @@ class SubscriberImpl(Subscriber):
|
|
|
199
189
|
state.timeout_handle = None
|
|
200
190
|
|
|
201
191
|
def _rearm_reorder_timeout(self, state: ReorderingState) -> None:
|
|
202
|
-
"""Arm or rearm the reordering timeout against the current head-of-line slot."""
|
|
203
192
|
if self._reordering_window is None:
|
|
204
193
|
return
|
|
205
194
|
if not state.interned:
|
|
@@ -256,11 +245,6 @@ class SubscriberImpl(Subscriber):
|
|
|
256
245
|
_logger.info("Subscriber closed for '%s'", self._pattern)
|
|
257
246
|
|
|
258
247
|
|
|
259
|
-
# =====================================================================================================================
|
|
260
|
-
# Breadcrumb
|
|
261
|
-
# =====================================================================================================================
|
|
262
|
-
|
|
263
|
-
|
|
264
248
|
class BreadcrumbImpl(Breadcrumb):
|
|
265
249
|
def __init__(
|
|
266
250
|
self,
|
|
@@ -322,7 +306,6 @@ class BreadcrumbImpl(Breadcrumb):
|
|
|
322
306
|
_logger.debug("Response BE sent seqno=%d to %016x", seqno, self._remote_id)
|
|
323
307
|
return
|
|
324
308
|
|
|
325
|
-
# Reliable response with retransmission.
|
|
326
309
|
tracker = RespondTracker(
|
|
327
310
|
remote_id=self._remote_id,
|
|
328
311
|
message_tag=self._message_tag,
|
|
@@ -20,6 +20,11 @@ SUBJECT_ID_MODULUS_32bit = 4294954663 # Incompatible with Cyphal/CAN and Cyphal
|
|
|
20
20
|
class SubjectWriter(Closable):
|
|
21
21
|
@abstractmethod
|
|
22
22
|
async def __call__(self, deadline: Instant, priority: Priority, message: bytes | memoryview) -> None:
|
|
23
|
+
"""
|
|
24
|
+
Send one message on the subject. Returning means every frame of the transfer has been handed over to the
|
|
25
|
+
OS or the driver on at least one interface; implementations that queue internally must await that handover.
|
|
26
|
+
Raises :class:`SendError` if the message could not be handed over before the deadline.
|
|
27
|
+
"""
|
|
23
28
|
raise NotImplementedError
|
|
24
29
|
|
|
25
30
|
|