pycyphal2 2.0.0.dev4__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.dev4/src/pycyphal2.egg-info → pycyphal2-2.0.0.dev6}/PKG-INFO +1 -1
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/src/pycyphal2/__init__.py +1 -1
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/src/pycyphal2/_api.py +4 -5
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/src/pycyphal2/_hash.py +0 -13
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/src/pycyphal2/_header.py +0 -34
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/src/pycyphal2/_node.py +5 -83
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/src/pycyphal2/_publisher.py +0 -6
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/src/pycyphal2/_subscriber.py +0 -17
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/src/pycyphal2/_transport.py +5 -0
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/src/pycyphal2/can/_interface.py +19 -4
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/src/pycyphal2/can/_media_slcan.py +1 -2
- {pycyphal2-2.0.0.dev4 → 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.dev4 → pycyphal2-2.0.0.dev6}/src/pycyphal2/can/pythoncan.py +34 -33
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/src/pycyphal2/can/socketcan.py +28 -31
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/src/pycyphal2/can/webserial.py +36 -27
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/src/pycyphal2/udp.py +2 -68
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6/src/pycyphal2.egg-info}/PKG-INFO +1 -1
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/src/pycyphal2.egg-info/SOURCES.txt +1 -0
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/tests/test_gossip.py +84 -50
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/tests/test_hash.py +0 -2
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/tests/test_header.py +0 -34
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/tests/test_integration.py +9 -37
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/tests/test_monitor.py +3 -5
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/tests/test_names.py +11 -84
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/tests/test_parity.py +24 -115
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/tests/test_pubsub.py +16 -96
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/tests/test_reliable.py +3 -103
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/tests/test_reorder.py +1 -4
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/tests/test_rpc.py +3 -18
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/tests/test_scout.py +0 -2
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/tests/test_topic.py +55 -88
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/tests/test_udp.py +3 -100
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/LICENSE +0 -0
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/README.md +0 -0
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/pyproject.toml +0 -0
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/setup.cfg +0 -0
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/src/pycyphal2/can/__init__.py +0 -0
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/src/pycyphal2/can/_reassembly.py +0 -0
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/src/pycyphal2/can/_wire.py +0 -0
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/src/pycyphal2/py.typed +0 -0
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/src/pycyphal2.egg-info/dependency_links.txt +0 -0
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/src/pycyphal2.egg-info/requires.txt +0 -0
- {pycyphal2-2.0.0.dev4 → pycyphal2-2.0.0.dev6}/src/pycyphal2.egg-info/top_level.txt +0 -0
- {pycyphal2-2.0.0.dev4 → 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,21 +219,12 @@ 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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
# =====================================================================================================================
|
|
253
|
-
# Internal Data Structures
|
|
254
|
-
# =====================================================================================================================
|
|
225
|
+
h = topic_hash % modulus
|
|
226
|
+
e = evictions % modulus
|
|
227
|
+
return SUBJECT_ID_PINNED_MAX + 1 + ((h + ((e * e) % modulus)) % modulus)
|
|
255
228
|
|
|
256
229
|
|
|
257
230
|
@dataclass
|
|
@@ -394,11 +367,6 @@ class PublishTracker:
|
|
|
394
367
|
self.ack_event.set()
|
|
395
368
|
|
|
396
369
|
|
|
397
|
-
# =====================================================================================================================
|
|
398
|
-
# Topic
|
|
399
|
-
# =====================================================================================================================
|
|
400
|
-
|
|
401
|
-
|
|
402
370
|
class TopicImpl(Topic):
|
|
403
371
|
|
|
404
372
|
def __init__(self, node: NodeImpl, name: str, evictions: int, now: float) -> None:
|
|
@@ -424,7 +392,6 @@ class TopicImpl(Topic):
|
|
|
424
392
|
self.gossip_task_is_periodic = False
|
|
425
393
|
self.gossip_counter = 0
|
|
426
394
|
|
|
427
|
-
# -- Topic ABC --
|
|
428
395
|
@property
|
|
429
396
|
def hash(self) -> int:
|
|
430
397
|
return self._topic_hash
|
|
@@ -446,7 +413,6 @@ class TopicImpl(Topic):
|
|
|
446
413
|
def match(self, pattern: str) -> list[tuple[str, int]] | None:
|
|
447
414
|
return match_pattern(pattern, self._name)
|
|
448
415
|
|
|
449
|
-
# -- Internal --
|
|
450
416
|
def lage(self, now: float) -> int:
|
|
451
417
|
return log_age(self.ts_origin, now)
|
|
452
418
|
|
|
@@ -527,11 +493,6 @@ def left_wins(l_lage: int, l_hash: int, r_lage: int, r_hash: int) -> bool:
|
|
|
527
493
|
return l_lage > r_lage if l_lage != r_lage else l_hash < r_hash
|
|
528
494
|
|
|
529
495
|
|
|
530
|
-
# =====================================================================================================================
|
|
531
|
-
# Node
|
|
532
|
-
# =====================================================================================================================
|
|
533
|
-
|
|
534
|
-
|
|
535
496
|
class NodeImpl(Node):
|
|
536
497
|
def __init__(self, transport: Transport, *, home: str, namespace: str) -> None:
|
|
537
498
|
self._transport = transport
|
|
@@ -543,26 +504,21 @@ class NodeImpl(Node):
|
|
|
543
504
|
self._monitor_callbacks: dict[int, Callable[[Topic], None]] = {}
|
|
544
505
|
self._next_monitor_callback_id = 0
|
|
545
506
|
|
|
546
|
-
# Topic indexes.
|
|
547
507
|
self.topics_by_name: dict[str, TopicImpl] = {}
|
|
548
508
|
self.topics_by_hash: dict[int, TopicImpl] = {}
|
|
549
509
|
self.topics_by_subject_id: dict[int, TopicImpl] = {} # non-pinned only
|
|
550
510
|
|
|
551
|
-
# Subscriber roots.
|
|
552
511
|
self.sub_roots_verbatim: dict[str, SubscriberRoot] = {}
|
|
553
512
|
self.sub_roots_pattern: dict[str, SubscriberRoot] = {}
|
|
554
513
|
|
|
555
|
-
# Respond futures for reliable responses.
|
|
556
514
|
self.respond_futures: dict[tuple[int, ...], RespondTracker] = {}
|
|
557
515
|
|
|
558
|
-
# Compute broadcast and gossip shard subject IDs.
|
|
559
516
|
modulus = transport.subject_id_modulus
|
|
560
517
|
sid_max = SUBJECT_ID_PINNED_MAX + modulus
|
|
561
518
|
self.broadcast_subject_id = (1 << (int(math.log2(sid_max)) + 1)) - 1
|
|
562
519
|
self.gossip_shard_count = self.broadcast_subject_id - (sid_max + 1)
|
|
563
520
|
assert self.gossip_shard_count > 0
|
|
564
521
|
|
|
565
|
-
# Set up broadcast writer and listener.
|
|
566
522
|
self.broadcast_writer = transport.subject_advertise(self.broadcast_subject_id)
|
|
567
523
|
|
|
568
524
|
def broadcast_handler(arrival: TransportArrival) -> None:
|
|
@@ -570,16 +526,13 @@ class NodeImpl(Node):
|
|
|
570
526
|
|
|
571
527
|
self.broadcast_listener = transport.subject_listen(self.broadcast_subject_id, broadcast_handler)
|
|
572
528
|
|
|
573
|
-
# Gossip shard state: lazily created per shard.
|
|
574
529
|
self.gossip_shard_writers: dict[int, SubjectWriter] = {}
|
|
575
530
|
self.gossip_shard_listeners: dict[int, Closable] = {}
|
|
576
531
|
self.shared_subject_writers: dict[int, SharedSubjectWriter] = {}
|
|
577
532
|
self.shared_subject_listeners: dict[int, SharedSubjectListener] = {}
|
|
578
533
|
|
|
579
|
-
# Register unicast handler.
|
|
580
534
|
transport.unicast_listen(self.on_unicast_arrival)
|
|
581
535
|
|
|
582
|
-
# Implicit topic GC task, driven by the earliest implicit-topic expiry.
|
|
583
536
|
self._implicit_topics: OrderedDict[TopicImpl, None] = OrderedDict()
|
|
584
537
|
self._implicit_gc_wakeup = asyncio.Event()
|
|
585
538
|
self._gc_task = self.loop.create_task(self.implicit_gc_loop())
|
|
@@ -592,7 +545,6 @@ class NodeImpl(Node):
|
|
|
592
545
|
self.gossip_shard_count,
|
|
593
546
|
)
|
|
594
547
|
|
|
595
|
-
# -- Node ABC --
|
|
596
548
|
@property
|
|
597
549
|
def home(self) -> str:
|
|
598
550
|
return self._home
|
|
@@ -664,7 +616,6 @@ class NodeImpl(Node):
|
|
|
664
616
|
if pin is not None and not verbatim:
|
|
665
617
|
raise ValueError("Pattern names cannot be pinned")
|
|
666
618
|
|
|
667
|
-
# Ensure subscriber root.
|
|
668
619
|
if verbatim:
|
|
669
620
|
root = self.sub_roots_verbatim.get(resolved)
|
|
670
621
|
if root is None:
|
|
@@ -680,12 +631,10 @@ class NodeImpl(Node):
|
|
|
680
631
|
root.subscribers.append(subscriber)
|
|
681
632
|
|
|
682
633
|
if verbatim:
|
|
683
|
-
# Ensure topic exists and couple.
|
|
684
634
|
topic = self.topic_ensure(resolved, pin)
|
|
685
635
|
self.couple_topic_root(topic, root)
|
|
686
636
|
topic.sync_implicit()
|
|
687
637
|
else:
|
|
688
|
-
# Pattern subscriber: couple with all existing matching topics and scout once per root.
|
|
689
638
|
for topic in list(self.topics_by_name.values()):
|
|
690
639
|
self.couple_topic_root(topic, root)
|
|
691
640
|
topic.sync_implicit()
|
|
@@ -723,10 +672,7 @@ class NodeImpl(Node):
|
|
|
723
672
|
except Exception as ex:
|
|
724
673
|
raise SendError(f"Scout send failed for '{resolved}'") from ex
|
|
725
674
|
|
|
726
|
-
# -- Topic Management --
|
|
727
|
-
|
|
728
675
|
def topic_ensure(self, name: str, pin: int | None) -> TopicImpl:
|
|
729
|
-
"""Get or create a topic by resolved name."""
|
|
730
676
|
topic = self.topics_by_name.get(name)
|
|
731
677
|
if topic is not None:
|
|
732
678
|
return topic
|
|
@@ -740,7 +686,6 @@ class NodeImpl(Node):
|
|
|
740
686
|
self.ensure_gossip_shard(self.gossip_shard_subject_id(topic.hash))
|
|
741
687
|
self.touch_implicit_topic(topic)
|
|
742
688
|
self.topic_allocate(topic, evictions, now)
|
|
743
|
-
# Couple with existing pattern subscriber roots.
|
|
744
689
|
for root in self.sub_roots_pattern.values():
|
|
745
690
|
self.couple_topic_root(topic, root)
|
|
746
691
|
topic.sync_listener()
|
|
@@ -755,12 +700,10 @@ class NodeImpl(Node):
|
|
|
755
700
|
|
|
756
701
|
def topic_allocate(self, topic: TopicImpl, new_evictions: int, now: float) -> None:
|
|
757
702
|
"""Iterative subject-ID allocation with collision resolution. Mirrors topic_allocate() in cy.c."""
|
|
758
|
-
# Work queue: list of (topic, new_evictions) pairs to process.
|
|
759
703
|
modulus = self.transport.subject_id_modulus
|
|
760
704
|
work: list[tuple[TopicImpl, int]] = [(topic, new_evictions)]
|
|
761
705
|
while work:
|
|
762
706
|
t, ev = work.pop(0)
|
|
763
|
-
# Remove from subject-ID index first.
|
|
764
707
|
old_sid = t.subject_id(modulus)
|
|
765
708
|
if old_sid in self.topics_by_subject_id and self.topics_by_subject_id[old_sid] is t:
|
|
766
709
|
del self.topics_by_subject_id[old_sid]
|
|
@@ -780,14 +723,12 @@ class NodeImpl(Node):
|
|
|
780
723
|
collider = None # same topic, no real collision
|
|
781
724
|
|
|
782
725
|
if collider is None:
|
|
783
|
-
# No collision, install.
|
|
784
726
|
t.release_transport_handles()
|
|
785
727
|
t.set_evictions(ev)
|
|
786
728
|
self.topics_by_subject_id[new_sid] = t
|
|
787
729
|
t.sync_listener()
|
|
788
730
|
self.schedule_gossip_urgent(t)
|
|
789
731
|
elif left_wins(t.lage(now), t.hash, collider.lage(now), collider.hash):
|
|
790
|
-
# Our topic wins: take the slot, evict the collider.
|
|
791
732
|
t.release_transport_handles()
|
|
792
733
|
t.set_evictions(ev)
|
|
793
734
|
del self.topics_by_subject_id[new_sid]
|
|
@@ -796,11 +737,9 @@ class NodeImpl(Node):
|
|
|
796
737
|
t.pub_writer = self.acquire_subject_writer(t, new_sid)
|
|
797
738
|
t.sync_listener()
|
|
798
739
|
self.schedule_gossip_urgent(t)
|
|
799
|
-
# Schedule collider for reallocation.
|
|
800
740
|
collider.release_transport_handles()
|
|
801
741
|
work.append((collider, collider.evictions + 1))
|
|
802
742
|
else:
|
|
803
|
-
# Our topic loses: increment evictions and retry.
|
|
804
743
|
work.append((t, ev + 1))
|
|
805
744
|
|
|
806
745
|
def sync_topic_lifecycle(self, topic: TopicImpl) -> None:
|
|
@@ -875,17 +814,14 @@ class NodeImpl(Node):
|
|
|
875
814
|
|
|
876
815
|
@staticmethod
|
|
877
816
|
def couple_topic_root(topic: TopicImpl, root: SubscriberRoot) -> None:
|
|
878
|
-
"""Create a coupling between a topic and a subscriber root if not already coupled."""
|
|
879
817
|
for c in topic.couplings:
|
|
880
818
|
if c.root is root:
|
|
881
|
-
return
|
|
819
|
+
return
|
|
882
820
|
subs = match_pattern(root.name, topic.name) if root.is_pattern else ([] if root.name == topic.name else None)
|
|
883
821
|
if subs is not None:
|
|
884
822
|
topic.couplings.append(Coupling(root=root, substitutions=subs))
|
|
885
823
|
_logger.debug("Coupled '%s' <-> root '%s'", topic.name, root.name)
|
|
886
824
|
|
|
887
|
-
# -- Gossip --
|
|
888
|
-
|
|
889
825
|
def gossip_shard_subject_id(self, topic_hash: int) -> int:
|
|
890
826
|
modulus = self.transport.subject_id_modulus
|
|
891
827
|
sid_max = SUBJECT_ID_PINNED_MAX + modulus
|
|
@@ -948,9 +884,8 @@ class NodeImpl(Node):
|
|
|
948
884
|
_logger.debug("Shared subject listener released sid=%d", subject_id)
|
|
949
885
|
|
|
950
886
|
def schedule_gossip(self, topic: TopicImpl) -> None:
|
|
951
|
-
"""Start periodic gossip for an explicit topic."""
|
|
952
887
|
if topic.gossip_task is not None:
|
|
953
|
-
return
|
|
888
|
+
return
|
|
954
889
|
self._reschedule_gossip_periodic(topic, suppressed=False)
|
|
955
890
|
|
|
956
891
|
@staticmethod
|
|
@@ -1064,8 +999,6 @@ class NodeImpl(Node):
|
|
|
1064
999
|
except (SendError, OSError) as e:
|
|
1065
1000
|
_logger.warning("Gossip unicast send failed for '%s': %s", topic.name, e)
|
|
1066
1001
|
|
|
1067
|
-
# -- Scout --
|
|
1068
|
-
|
|
1069
1002
|
async def _transmit_scout(self, pattern: str) -> None:
|
|
1070
1003
|
pattern_bytes = pattern.encode("utf-8")
|
|
1071
1004
|
hdr = ScoutHeader(pattern_len=len(pattern_bytes))
|
|
@@ -1094,14 +1027,10 @@ class NodeImpl(Node):
|
|
|
1094
1027
|
|
|
1095
1028
|
root.scout_task = self.loop.create_task(do_send())
|
|
1096
1029
|
|
|
1097
|
-
# -- Message Dispatch --
|
|
1098
|
-
|
|
1099
1030
|
def on_subject_arrival(self, subject_id: int, arrival: TransportArrival) -> None:
|
|
1100
|
-
"""Handle an arrival on a subject (multicast)."""
|
|
1101
1031
|
self.dispatch_arrival(arrival, subject_id=subject_id, unicast=False)
|
|
1102
1032
|
|
|
1103
1033
|
def on_unicast_arrival(self, arrival: TransportArrival) -> None:
|
|
1104
|
-
"""Handle an arrival via unicast."""
|
|
1105
1034
|
self.dispatch_arrival(arrival, subject_id=None, unicast=True)
|
|
1106
1035
|
|
|
1107
1036
|
def dispatch_arrival(self, arrival: TransportArrival, *, subject_id: int | None, unicast: bool) -> None:
|
|
@@ -1291,7 +1220,6 @@ class NodeImpl(Node):
|
|
|
1291
1220
|
tracker.on_ack(remote_id, positive)
|
|
1292
1221
|
|
|
1293
1222
|
def on_rsp(self, arrival: TransportArrival, hdr: RspBeHeader | RspRelHeader, payload: bytes) -> None:
|
|
1294
|
-
"""Handle a response message (for RPC)."""
|
|
1295
1223
|
ack = False
|
|
1296
1224
|
topic = self.topics_by_hash.get(hdr.topic_hash)
|
|
1297
1225
|
if topic is not None:
|
|
@@ -1315,7 +1243,6 @@ class NodeImpl(Node):
|
|
|
1315
1243
|
)
|
|
1316
1244
|
|
|
1317
1245
|
def on_rsp_ack(self, arrival: TransportArrival, hdr: RspAckHeader | RspNackHeader) -> None:
|
|
1318
|
-
"""Handle a response ACK/NACK."""
|
|
1319
1246
|
key = (arrival.remote_id, hdr.message_tag, hdr.topic_hash, hdr.seqno, hdr.tag)
|
|
1320
1247
|
future = self.respond_futures.get(key)
|
|
1321
1248
|
if future is not None:
|
|
@@ -1364,7 +1291,6 @@ class NodeImpl(Node):
|
|
|
1364
1291
|
|
|
1365
1292
|
topic = self.topics_by_hash.get(hdr.topic_hash)
|
|
1366
1293
|
|
|
1367
|
-
# If unknown topic with a name, check for pattern subscriber matches.
|
|
1368
1294
|
if topic is None and name:
|
|
1369
1295
|
if scope in {GossipScope.UNICAST, GossipScope.BROADCAST}:
|
|
1370
1296
|
topic = self.topic_subscribe_if_matching(
|
|
@@ -1470,8 +1396,6 @@ class NodeImpl(Node):
|
|
|
1470
1396
|
self.send_gossip_unicast(topic, arrival.remote_id, arrival.priority), "gossip unicast"
|
|
1471
1397
|
)
|
|
1472
1398
|
|
|
1473
|
-
# -- Implicit Topic GC --
|
|
1474
|
-
|
|
1475
1399
|
def notify_implicit_gc(self) -> None:
|
|
1476
1400
|
if not self._closed:
|
|
1477
1401
|
self._implicit_gc_wakeup.set()
|
|
@@ -1532,8 +1456,6 @@ class NodeImpl(Node):
|
|
|
1532
1456
|
self.notify_implicit_gc()
|
|
1533
1457
|
_logger.info("Topic destroyed '%s'", name)
|
|
1534
1458
|
|
|
1535
|
-
# -- Cleanup --
|
|
1536
|
-
|
|
1537
1459
|
def close(self) -> None:
|
|
1538
1460
|
if self._closed:
|
|
1539
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
|
|