pycyphal2 2.0.0.dev3__py3-none-any.whl → 2.0.0.dev4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pycyphal2/__init__.py +1 -1
- pycyphal2/_node.py +89 -18
- pycyphal2/_publisher.py +7 -21
- pycyphal2/_subscriber.py +3 -19
- pycyphal2/can/_interface.py +5 -4
- pycyphal2/can/_media_slcan.py +3 -4
- pycyphal2/can/_wire.py +1 -2
- pycyphal2/can/pythoncan.py +21 -16
- pycyphal2/can/socketcan.py +30 -14
- pycyphal2/udp.py +40 -16
- {pycyphal2-2.0.0.dev3.dist-info → pycyphal2-2.0.0.dev4.dist-info}/METADATA +1 -1
- pycyphal2-2.0.0.dev4.dist-info/RECORD +24 -0
- pycyphal2-2.0.0.dev3.dist-info/RECORD +0 -24
- {pycyphal2-2.0.0.dev3.dist-info → pycyphal2-2.0.0.dev4.dist-info}/WHEEL +0 -0
- {pycyphal2-2.0.0.dev3.dist-info → pycyphal2-2.0.0.dev4.dist-info}/licenses/LICENSE +0 -0
- {pycyphal2-2.0.0.dev3.dist-info → pycyphal2-2.0.0.dev4.dist-info}/top_level.txt +0 -0
pycyphal2/__init__.py
CHANGED
|
@@ -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.dev4"
|
|
159
159
|
|
|
160
160
|
# pdoc needs __all__ to display re-exported members.
|
|
161
161
|
__all__ = [
|
pycyphal2/_node.py
CHANGED
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
from collections import OrderedDict
|
|
5
|
+
from collections.abc import Coroutine
|
|
5
6
|
import logging
|
|
6
7
|
import math
|
|
7
8
|
import os
|
|
@@ -28,7 +29,7 @@ from ._header import (
|
|
|
28
29
|
deserialize_header,
|
|
29
30
|
)
|
|
30
31
|
from ._transport import SubjectWriter, Transport, TransportArrival
|
|
31
|
-
from ._api import Topic, Node, Publisher, Subscriber, Breadcrumb, Closable, Instant, Priority, SendError
|
|
32
|
+
from ._api import Topic, Node, Publisher, Subscriber, Breadcrumb, Closable, ClosedError, Instant, Priority, SendError
|
|
32
33
|
from ._api import SUBJECT_ID_PINNED_MAX
|
|
33
34
|
|
|
34
35
|
if TYPE_CHECKING:
|
|
@@ -58,6 +59,22 @@ ACK_SEQNO_MAX_LAG = 100000
|
|
|
58
59
|
U64_MASK = (1 << 64) - 1
|
|
59
60
|
|
|
60
61
|
|
|
62
|
+
def ack_is_last_attempt(current_ack_deadline_ns: int, current_ack_timeout: float, total_deadline_ns: int) -> bool:
|
|
63
|
+
"""True if doubling the ACK timeout would overrun the total deadline, so this is the last retry."""
|
|
64
|
+
next_ack_timeout_ns = round(current_ack_timeout * 2 * 1e9)
|
|
65
|
+
remaining_budget_ns = total_deadline_ns - current_ack_deadline_ns
|
|
66
|
+
return remaining_budget_ns < next_ack_timeout_ns
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def ack_window(deadline_ns: int, ack_timeout: float) -> tuple[int, bool] | None:
|
|
70
|
+
"""Next reliable-delivery ACK window: (ack_deadline_ns, is_last_attempt), or None if past the deadline."""
|
|
71
|
+
now_ns = Instant.now().ns
|
|
72
|
+
if now_ns >= deadline_ns:
|
|
73
|
+
return None
|
|
74
|
+
ack_deadline_ns = min(deadline_ns, now_ns + round(ack_timeout * 1e9))
|
|
75
|
+
return ack_deadline_ns, ack_is_last_attempt(ack_deadline_ns, ack_timeout, deadline_ns)
|
|
76
|
+
|
|
77
|
+
|
|
61
78
|
class GossipScope(Enum):
|
|
62
79
|
UNICAST = auto()
|
|
63
80
|
BROADCAST = auto()
|
|
@@ -115,6 +132,24 @@ def _name_is_homeful(name: str) -> bool:
|
|
|
115
132
|
return name == "~" or name.startswith("~/")
|
|
116
133
|
|
|
117
134
|
|
|
135
|
+
def _is_valid_wire_name(name: str) -> bool:
|
|
136
|
+
"""True if `name` is a well-formed *resolved* wire topic name, as required of names received in gossip:
|
|
137
|
+
nonempty, length-bounded, printable ASCII (33-126), already normalized (no leading/trailing/duplicate
|
|
138
|
+
'/'), verbatim (no '*'/'>' pattern tokens), not homeful ('~'/'~/...'), and pin-free (no '#<id>' suffix).
|
|
139
|
+
The last two are stripped/expanded by resolve_name before a name reaches the wire, so their presence
|
|
140
|
+
means the gossip is unresolved/non-canonical and must not create a local topic."""
|
|
141
|
+
return (
|
|
142
|
+
bool(name)
|
|
143
|
+
and len(name) <= TOPIC_NAME_MAX
|
|
144
|
+
and "*" not in name
|
|
145
|
+
and ">" not in name
|
|
146
|
+
and not _name_is_homeful(name)
|
|
147
|
+
and _name_consume_pin_suffix(name)[1] is None
|
|
148
|
+
and all(33 <= ord(ch) <= 126 for ch in name)
|
|
149
|
+
and _name_normalize(name) == name
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
118
153
|
def resolve_name(
|
|
119
154
|
name: str, home: str, namespace: str, remaps: dict[str, str] | None = None
|
|
120
155
|
) -> tuple[str, int | None, bool]:
|
|
@@ -345,10 +380,8 @@ class PublishTracker:
|
|
|
345
380
|
"""Tracks a pending reliable publication awaiting ACKs."""
|
|
346
381
|
|
|
347
382
|
tag: int
|
|
348
|
-
deadline_ns: int
|
|
349
383
|
ack_event: asyncio.Event
|
|
350
384
|
acknowledged: bool = False
|
|
351
|
-
data: bytes | None = None
|
|
352
385
|
ack_timeout: float = ACK_BASELINE_DEFAULT_TIMEOUT
|
|
353
386
|
compromised: bool = False
|
|
354
387
|
remaining: set[int] = field(default_factory=set)
|
|
@@ -507,7 +540,6 @@ class NodeImpl(Node):
|
|
|
507
540
|
self._remaps: dict[str, str] = {}
|
|
508
541
|
self._closed = False
|
|
509
542
|
self.loop = asyncio.get_running_loop()
|
|
510
|
-
self._now_mono = time.monotonic()
|
|
511
543
|
self._monitor_callbacks: dict[int, Callable[[Topic], None]] = {}
|
|
512
544
|
self._next_monitor_callback_id = 0
|
|
513
545
|
|
|
@@ -573,7 +605,31 @@ class NodeImpl(Node):
|
|
|
573
605
|
def transport(self) -> Transport:
|
|
574
606
|
return self._transport
|
|
575
607
|
|
|
608
|
+
def _raise_if_closed(self) -> None:
|
|
609
|
+
if self._closed:
|
|
610
|
+
raise ClosedError(f"Node '{self._home}' is closed")
|
|
611
|
+
|
|
612
|
+
def _spawn_detached(self, coro: Coroutine[Any, Any, None], what: str) -> None:
|
|
613
|
+
"""Fire-and-forget a short-lived send: skip when closed, and never let its exception go unobserved."""
|
|
614
|
+
if self._closed:
|
|
615
|
+
coro.close()
|
|
616
|
+
return
|
|
617
|
+
|
|
618
|
+
def _done(task: asyncio.Task[None]) -> None:
|
|
619
|
+
if task.cancelled():
|
|
620
|
+
return
|
|
621
|
+
ex = task.exception()
|
|
622
|
+
if ex is None:
|
|
623
|
+
return
|
|
624
|
+
if isinstance(ex, (SendError, OSError)):
|
|
625
|
+
_logger.debug("%s send failed: %s", what, ex)
|
|
626
|
+
else:
|
|
627
|
+
_logger.error("%s task crashed: %s", what, ex, exc_info=ex)
|
|
628
|
+
|
|
629
|
+
self.loop.create_task(coro).add_done_callback(_done)
|
|
630
|
+
|
|
576
631
|
def remap(self, spec: str | dict[str, str]) -> None:
|
|
632
|
+
self._raise_if_closed()
|
|
577
633
|
if isinstance(spec, str):
|
|
578
634
|
spec = dict(x.split("=", 1) for x in spec.split() if "=" in x)
|
|
579
635
|
assert isinstance(spec, dict)
|
|
@@ -584,6 +640,7 @@ class NodeImpl(Node):
|
|
|
584
640
|
def advertise(self, name: str) -> Publisher:
|
|
585
641
|
from ._publisher import PublisherImpl
|
|
586
642
|
|
|
643
|
+
self._raise_if_closed()
|
|
587
644
|
resolved, pin, verbatim = resolve_name(name, self._home, self._namespace, self._remaps)
|
|
588
645
|
if not verbatim:
|
|
589
646
|
raise ValueError("Cannot advertise on a pattern name")
|
|
@@ -602,6 +659,7 @@ class NodeImpl(Node):
|
|
|
602
659
|
def subscribe(self, name: str, *, reordering_window: float | None = None) -> Subscriber:
|
|
603
660
|
from ._subscriber import SubscriberImpl
|
|
604
661
|
|
|
662
|
+
self._raise_if_closed()
|
|
605
663
|
resolved, pin, verbatim = resolve_name(name, self._home, self._namespace, self._remaps)
|
|
606
664
|
if pin is not None and not verbatim:
|
|
607
665
|
raise ValueError("Pattern names cannot be pinned")
|
|
@@ -637,6 +695,7 @@ class NodeImpl(Node):
|
|
|
637
695
|
return subscriber
|
|
638
696
|
|
|
639
697
|
def monitor(self, callback: Callable[[Topic], None]) -> Closable:
|
|
698
|
+
self._raise_if_closed()
|
|
640
699
|
callback_id = self._next_monitor_callback_id
|
|
641
700
|
self._next_monitor_callback_id += 1
|
|
642
701
|
self._monitor_callbacks[callback_id] = callback
|
|
@@ -653,6 +712,7 @@ class NodeImpl(Node):
|
|
|
653
712
|
_logger.exception("monitor() callback failed for %s", topic)
|
|
654
713
|
|
|
655
714
|
async def scout(self, pattern: str) -> None:
|
|
715
|
+
self._raise_if_closed()
|
|
656
716
|
resolved, pin, _ = resolve_name(pattern, self._home, self._namespace, self._remaps)
|
|
657
717
|
if pin is not None:
|
|
658
718
|
raise ValueError("Cannot scout a pinned name/pattern")
|
|
@@ -800,12 +860,10 @@ class NodeImpl(Node):
|
|
|
800
860
|
tracker.remaining.clear()
|
|
801
861
|
|
|
802
862
|
@staticmethod
|
|
803
|
-
def prepare_publish_tracker(topic: TopicImpl, tag: int
|
|
863
|
+
def prepare_publish_tracker(topic: TopicImpl, tag: int) -> PublishTracker:
|
|
804
864
|
tracker = PublishTracker(
|
|
805
865
|
tag=tag,
|
|
806
|
-
deadline_ns=deadline_ns,
|
|
807
866
|
ack_event=asyncio.Event(),
|
|
808
|
-
data=data,
|
|
809
867
|
)
|
|
810
868
|
tracker.ack_timeout = ACK_BASELINE_DEFAULT_TIMEOUT
|
|
811
869
|
for assoc in sorted(topic.associations.values(), key=lambda x: x.remote_id):
|
|
@@ -1036,14 +1094,6 @@ class NodeImpl(Node):
|
|
|
1036
1094
|
|
|
1037
1095
|
root.scout_task = self.loop.create_task(do_send())
|
|
1038
1096
|
|
|
1039
|
-
def send_scout(self, pattern: str) -> None:
|
|
1040
|
-
"""Send a scout message to discover topics matching a pattern."""
|
|
1041
|
-
|
|
1042
|
-
async def do_send() -> None:
|
|
1043
|
-
await self._send_scout_once(pattern)
|
|
1044
|
-
|
|
1045
|
-
self.loop.create_task(do_send())
|
|
1046
|
-
|
|
1047
1097
|
# -- Message Dispatch --
|
|
1048
1098
|
|
|
1049
1099
|
def on_subject_arrival(self, subject_id: int, arrival: TransportArrival) -> None:
|
|
@@ -1055,6 +1105,8 @@ class NodeImpl(Node):
|
|
|
1055
1105
|
self.dispatch_arrival(arrival, subject_id=None, unicast=True)
|
|
1056
1106
|
|
|
1057
1107
|
def dispatch_arrival(self, arrival: TransportArrival, *, subject_id: int | None, unicast: bool) -> None:
|
|
1108
|
+
if self._closed:
|
|
1109
|
+
return # Drop late arrivals after close instead of mutating state / spawning sends.
|
|
1058
1110
|
msg = arrival.message
|
|
1059
1111
|
if len(msg) < HEADER_SIZE:
|
|
1060
1112
|
_logger.debug("Drop short msg len=%d", len(msg))
|
|
@@ -1207,7 +1259,7 @@ class NodeImpl(Node):
|
|
|
1207
1259
|
except (SendError, OSError) as e:
|
|
1208
1260
|
_logger.debug("ACK send failed: %s", e)
|
|
1209
1261
|
|
|
1210
|
-
self.
|
|
1262
|
+
self._spawn_detached(do_send(), "ACK")
|
|
1211
1263
|
|
|
1212
1264
|
def on_msg_ack(self, arrival: TransportArrival, hdr: MsgAckHeader | MsgNackHeader) -> None:
|
|
1213
1265
|
topic = self.topics_by_hash.get(hdr.topic_hash)
|
|
@@ -1295,7 +1347,7 @@ class NodeImpl(Node):
|
|
|
1295
1347
|
except (SendError, OSError) as e:
|
|
1296
1348
|
_logger.debug("RSP ACK send failed: %s", e)
|
|
1297
1349
|
|
|
1298
|
-
self.
|
|
1350
|
+
self._spawn_detached(do_send(), "RSP ACK")
|
|
1299
1351
|
|
|
1300
1352
|
def on_gossip(
|
|
1301
1353
|
self,
|
|
@@ -1306,6 +1358,8 @@ class NodeImpl(Node):
|
|
|
1306
1358
|
) -> None:
|
|
1307
1359
|
name = ""
|
|
1308
1360
|
if hdr.name_len > 0:
|
|
1361
|
+
# Best-effort decode for diagnostics/monitoring; an invalid name cannot create a topic because
|
|
1362
|
+
# topic_subscribe_if_matching validates the character set before creating one.
|
|
1309
1363
|
name = payload[: hdr.name_len].decode("utf-8", errors="replace")
|
|
1310
1364
|
|
|
1311
1365
|
topic = self.topics_by_hash.get(hdr.topic_hash)
|
|
@@ -1374,6 +1428,14 @@ class NodeImpl(Node):
|
|
|
1374
1428
|
now: float,
|
|
1375
1429
|
) -> TopicImpl | None:
|
|
1376
1430
|
"""Create an implicit topic if any pattern subscriber matches the name."""
|
|
1431
|
+
# REFERENCE PARITY: the reference does not (yet) validate gossip-name characters here -- it trusts
|
|
1432
|
+
# the hash. We additionally reject names that are not valid resolved wire names (non-normalized,
|
|
1433
|
+
# non-verbatim by this implementation's rule, homeful, or pinned) so untrusted wire input cannot
|
|
1434
|
+
# create a local topic with such a name. Consistent with the documented whitespace-strip deviation;
|
|
1435
|
+
# the reference may adopt the same validation later.
|
|
1436
|
+
if not _is_valid_wire_name(name):
|
|
1437
|
+
_logger.debug("Gossip drop invalid wire name hash=%016x", topic_hash)
|
|
1438
|
+
return None
|
|
1377
1439
|
# Validate that the hash matches the name to prevent corrupt gossip from creating inconsistencies.
|
|
1378
1440
|
if rapidhash(name) != topic_hash:
|
|
1379
1441
|
_logger.debug("Gossip hash mismatch for '%s': got %016x, expected %016x", name, topic_hash, rapidhash(name))
|
|
@@ -1398,12 +1460,15 @@ class NodeImpl(Node):
|
|
|
1398
1460
|
def on_scout(self, arrival: TransportArrival, hdr: ScoutHeader, payload: bytes) -> None:
|
|
1399
1461
|
if hdr.pattern_len == 0 or hdr.pattern_len > TOPIC_NAME_MAX or len(payload) < hdr.pattern_len:
|
|
1400
1462
|
return
|
|
1463
|
+
# Best-effort decode; an invalid pattern simply matches no local topic names.
|
|
1401
1464
|
pattern = payload[: hdr.pattern_len].decode("utf-8", errors="replace")
|
|
1402
1465
|
_logger.debug("Scout received pattern='%s' from %016x", pattern, arrival.remote_id)
|
|
1403
1466
|
for topic in list(self.topics_by_name.values()):
|
|
1404
1467
|
subs = match_pattern(pattern, topic.name)
|
|
1405
1468
|
if subs is not None:
|
|
1406
|
-
self.
|
|
1469
|
+
self._spawn_detached(
|
|
1470
|
+
self.send_gossip_unicast(topic, arrival.remote_id, arrival.priority), "gossip unicast"
|
|
1471
|
+
)
|
|
1407
1472
|
|
|
1408
1473
|
# -- Implicit Topic GC --
|
|
1409
1474
|
|
|
@@ -1474,6 +1539,12 @@ class NodeImpl(Node):
|
|
|
1474
1539
|
return
|
|
1475
1540
|
self._closed = True
|
|
1476
1541
|
_logger.info("Node closing home='%s'", self._home)
|
|
1542
|
+
# Unblock anything awaiting on a subscriber (`async for`): closing each enqueues StopAsyncIteration,
|
|
1543
|
+
# otherwise a default (no-liveness-timeout) subscriber would wait on its queue forever. (Reliable
|
|
1544
|
+
# publishes / response streams are deadline-bounded and resolve on their own.)
|
|
1545
|
+
for root in list(self.sub_roots_verbatim.values()) + list(self.sub_roots_pattern.values()):
|
|
1546
|
+
for sub in list(root.subscribers):
|
|
1547
|
+
sub.close()
|
|
1477
1548
|
self._gc_task.cancel()
|
|
1478
1549
|
for root in list(self.sub_roots_pattern.values()):
|
|
1479
1550
|
if root.scout_task is not None:
|
pycyphal2/_publisher.py
CHANGED
|
@@ -8,7 +8,7 @@ from dataclasses import dataclass
|
|
|
8
8
|
from ._api import DeliveryError, Instant, LivenessError, Priority, SendError
|
|
9
9
|
from ._api import Publisher, Topic, ResponseStream, Response
|
|
10
10
|
from ._header import MsgBeHeader, MsgRelHeader, RspBeHeader, RspRelHeader
|
|
11
|
-
from ._node import ACK_BASELINE_DEFAULT_TIMEOUT, NodeImpl, PublishTracker, SESSION_LIFETIME, TopicImpl
|
|
11
|
+
from ._node import ACK_BASELINE_DEFAULT_TIMEOUT, NodeImpl, PublishTracker, SESSION_LIFETIME, TopicImpl, ack_window
|
|
12
12
|
from ._transport import TransportArrival
|
|
13
13
|
|
|
14
14
|
_logger = logging.getLogger(__name__)
|
|
@@ -124,7 +124,7 @@ class PublisherImpl(Publisher):
|
|
|
124
124
|
)
|
|
125
125
|
self._topic.request_futures[tag] = stream
|
|
126
126
|
|
|
127
|
-
tracker = self._prepare_reliable_publish_tracker(tag
|
|
127
|
+
tracker = self._prepare_reliable_publish_tracker(tag)
|
|
128
128
|
try:
|
|
129
129
|
initial_window = await self._reliable_publish_start(delivery_deadline, tag, payload, tracker)
|
|
130
130
|
except asyncio.CancelledError:
|
|
@@ -169,12 +169,6 @@ class PublisherImpl(Publisher):
|
|
|
169
169
|
finally:
|
|
170
170
|
self._release_reliable_publish_tracker(tag, tracker)
|
|
171
171
|
|
|
172
|
-
@staticmethod
|
|
173
|
-
def _ack_is_last_attempt(current_ack_deadline_ns: int, current_ack_timeout: float, total_deadline_ns: int) -> bool:
|
|
174
|
-
next_ack_timeout_ns = round(current_ack_timeout * 2 * 1e9)
|
|
175
|
-
remaining_budget_ns = total_deadline_ns - current_ack_deadline_ns
|
|
176
|
-
return remaining_budget_ns < next_ack_timeout_ns
|
|
177
|
-
|
|
178
172
|
@staticmethod
|
|
179
173
|
def _ack_window_is_compromised(deadline_ns: int, current_ack_timeout: float) -> bool:
|
|
180
174
|
return Instant.now().ns >= (deadline_ns - round(current_ack_timeout * 1e9))
|
|
@@ -189,16 +183,8 @@ class PublisherImpl(Publisher):
|
|
|
189
183
|
)
|
|
190
184
|
return hdr.serialize() + payload
|
|
191
185
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
now_ns = Instant.now().ns
|
|
195
|
-
if now_ns >= deadline_ns:
|
|
196
|
-
return None
|
|
197
|
-
ack_deadline_ns = min(deadline_ns, now_ns + round(ack_timeout * 1e9))
|
|
198
|
-
return ack_deadline_ns, PublisherImpl._ack_is_last_attempt(ack_deadline_ns, ack_timeout, deadline_ns)
|
|
199
|
-
|
|
200
|
-
def _prepare_reliable_publish_tracker(self, tag: int, deadline_ns: int, payload: bytes) -> PublishTracker:
|
|
201
|
-
tracker = self._node.prepare_publish_tracker(self._topic, tag, deadline_ns, payload)
|
|
186
|
+
def _prepare_reliable_publish_tracker(self, tag: int) -> PublishTracker:
|
|
187
|
+
tracker = self._node.prepare_publish_tracker(self._topic, tag)
|
|
202
188
|
tracker.ack_timeout = self.ack_timeout
|
|
203
189
|
self._topic.publish_futures[tag] = tracker
|
|
204
190
|
return tracker
|
|
@@ -231,7 +217,7 @@ class PublisherImpl(Publisher):
|
|
|
231
217
|
payload: bytes,
|
|
232
218
|
tracker: PublishTracker,
|
|
233
219
|
) -> tuple[int, bool]:
|
|
234
|
-
initial_window =
|
|
220
|
+
initial_window = ack_window(deadline.ns, tracker.ack_timeout)
|
|
235
221
|
if initial_window is None:
|
|
236
222
|
raise DeliveryError("Reliable publish not acknowledged before deadline")
|
|
237
223
|
ack_deadline_ns, _ = initial_window
|
|
@@ -277,7 +263,7 @@ class PublisherImpl(Publisher):
|
|
|
277
263
|
if last_attempt:
|
|
278
264
|
break
|
|
279
265
|
tracker.ack_timeout *= 2
|
|
280
|
-
next_window =
|
|
266
|
+
next_window = ack_window(deadline.ns, tracker.ack_timeout)
|
|
281
267
|
if next_window is None:
|
|
282
268
|
break
|
|
283
269
|
ack_deadline_ns, last_attempt = next_window
|
|
@@ -292,7 +278,7 @@ class PublisherImpl(Publisher):
|
|
|
292
278
|
raise DeliveryError("Reliable publish not acknowledged before deadline")
|
|
293
279
|
|
|
294
280
|
async def _reliable_publish(self, deadline: Instant, tag: int, payload: bytes) -> None:
|
|
295
|
-
tracker = self._prepare_reliable_publish_tracker(tag
|
|
281
|
+
tracker = self._prepare_reliable_publish_tracker(tag)
|
|
296
282
|
try:
|
|
297
283
|
initial_window = await self._reliable_publish_start(deadline, tag, payload, tracker)
|
|
298
284
|
await self._reliable_publish_continue(deadline, tag, payload, tracker, initial_window)
|
pycyphal2/_subscriber.py
CHANGED
|
@@ -15,6 +15,7 @@ from ._node import (
|
|
|
15
15
|
NodeImpl,
|
|
16
16
|
SubscriberRoot,
|
|
17
17
|
TopicImpl,
|
|
18
|
+
ack_window,
|
|
18
19
|
match_pattern,
|
|
19
20
|
)
|
|
20
21
|
|
|
@@ -220,9 +221,6 @@ class SubscriberImpl(Subscriber):
|
|
|
220
221
|
|
|
221
222
|
state.timeout_handle = loop.call_later(delay, on_timeout)
|
|
222
223
|
|
|
223
|
-
def _arm_reorder_timeout(self, state: ReorderingState) -> None:
|
|
224
|
-
self._rearm_reorder_timeout(state)
|
|
225
|
-
|
|
226
224
|
def _drop_stale_reordering(self, now: float) -> None:
|
|
227
225
|
stale = [key for key, state in self._reordering.items() if (state.last_active_at + SESSION_LIFETIME) < now]
|
|
228
226
|
for key in stale:
|
|
@@ -337,7 +335,7 @@ class BreadcrumbImpl(Breadcrumb):
|
|
|
337
335
|
|
|
338
336
|
ack_timeout = ACK_BASELINE_DEFAULT_TIMEOUT * (1 << int(self._priority))
|
|
339
337
|
try:
|
|
340
|
-
initial_window =
|
|
338
|
+
initial_window = ack_window(deadline.ns, ack_timeout)
|
|
341
339
|
if initial_window is None:
|
|
342
340
|
raise DeliveryError("Reliable response not acknowledged before deadline")
|
|
343
341
|
|
|
@@ -371,7 +369,7 @@ class BreadcrumbImpl(Breadcrumb):
|
|
|
371
369
|
if last_attempt:
|
|
372
370
|
break
|
|
373
371
|
ack_timeout *= 2
|
|
374
|
-
next_window =
|
|
372
|
+
next_window = ack_window(deadline.ns, ack_timeout)
|
|
375
373
|
if next_window is None:
|
|
376
374
|
break
|
|
377
375
|
ack_deadline_ns, last_attempt = next_window
|
|
@@ -414,17 +412,3 @@ class RespondTracker:
|
|
|
414
412
|
self.done = True
|
|
415
413
|
self.nacked = not positive
|
|
416
414
|
self.ack_event.set()
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
def _ack_is_last_attempt(current_ack_deadline_ns: int, current_ack_timeout: float, total_deadline_ns: int) -> bool:
|
|
420
|
-
next_ack_timeout_ns = round(current_ack_timeout * 2 * 1e9)
|
|
421
|
-
remaining_budget_ns = total_deadline_ns - current_ack_deadline_ns
|
|
422
|
-
return remaining_budget_ns < next_ack_timeout_ns
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
def _ack_window(deadline_ns: int, ack_timeout: float) -> tuple[int, bool] | None:
|
|
426
|
-
now_ns = Instant.now().ns
|
|
427
|
-
if now_ns >= deadline_ns:
|
|
428
|
-
return None
|
|
429
|
-
ack_deadline_ns = min(deadline_ns, now_ns + round(ack_timeout * 1e9))
|
|
430
|
-
return ack_deadline_ns, _ack_is_last_attempt(ack_deadline_ns, ack_timeout, deadline_ns)
|
pycyphal2/can/_interface.py
CHANGED
|
@@ -7,7 +7,8 @@ import itertools
|
|
|
7
7
|
|
|
8
8
|
from .. import Closable, Instant
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
CAN_EXT_ID_MASK = (1 << 29) - 1
|
|
11
|
+
CAN_STD_ID_MASK = (1 << 11) - 1
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
@dataclass(frozen=True)
|
|
@@ -18,7 +19,7 @@ class Frame:
|
|
|
18
19
|
data: bytes
|
|
19
20
|
|
|
20
21
|
def __post_init__(self) -> None:
|
|
21
|
-
if not isinstance(self.id, int) or not (0 <= self.id <=
|
|
22
|
+
if not isinstance(self.id, int) or not (0 <= self.id <= CAN_EXT_ID_MASK):
|
|
22
23
|
raise ValueError(f"Invalid CAN identifier: {self.id!r}")
|
|
23
24
|
data = bytes(self.data)
|
|
24
25
|
if len(data) > 64:
|
|
@@ -39,9 +40,9 @@ class Filter:
|
|
|
39
40
|
mask: int
|
|
40
41
|
|
|
41
42
|
def __post_init__(self) -> None:
|
|
42
|
-
if not (0 <= self.id <=
|
|
43
|
+
if not (0 <= self.id <= CAN_EXT_ID_MASK):
|
|
43
44
|
raise ValueError(f"Invalid CAN identifier: {self.id!r}")
|
|
44
|
-
if not (0 <= self.mask <=
|
|
45
|
+
if not (0 <= self.mask <= CAN_EXT_ID_MASK):
|
|
45
46
|
raise ValueError(f"Invalid CAN mask: {self.mask!r}")
|
|
46
47
|
|
|
47
48
|
@property
|
pycyphal2/can/_media_slcan.py
CHANGED
|
@@ -6,12 +6,11 @@ from __future__ import annotations
|
|
|
6
6
|
|
|
7
7
|
import logging
|
|
8
8
|
|
|
9
|
-
from ._interface import Frame
|
|
10
|
-
from ._wire import
|
|
9
|
+
from ._interface import CAN_EXT_ID_MASK, CAN_STD_ID_MASK, Frame
|
|
10
|
+
from ._wire import DLC_TO_LENGTH, MTU_CAN_CLASSIC
|
|
11
11
|
|
|
12
12
|
_logger = logging.getLogger(__name__)
|
|
13
13
|
|
|
14
|
-
_CAN_STD_ID_MASK = (1 << 11) - 1
|
|
15
14
|
_CR = 0x0D # ACK / carriage return
|
|
16
15
|
_LF = 0x0A
|
|
17
16
|
_BEL = 0x07 # NACK / bell
|
|
@@ -157,7 +156,7 @@ def _parse_data_frame(line: bytes, *, id_length: int, max_payload_length: int) -
|
|
|
157
156
|
if len(line) < expected:
|
|
158
157
|
_logger.debug("SLCAN drop data dlc mismatch len=%d expected=%d", len(line), expected)
|
|
159
158
|
return None
|
|
160
|
-
if id_length == 3 and identifier >
|
|
159
|
+
if id_length == 3 and identifier > CAN_STD_ID_MASK:
|
|
161
160
|
_logger.debug("SLCAN drop invalid standard id=%x", identifier)
|
|
162
161
|
return None
|
|
163
162
|
data = _parse_hex_bytes(line[header_length:expected])
|
pycyphal2/can/_wire.py
CHANGED
|
@@ -10,9 +10,8 @@ from .._hash import (
|
|
|
10
10
|
CRC16CCITT_FALSE_RESIDUE,
|
|
11
11
|
crc16ccitt_false_add,
|
|
12
12
|
)
|
|
13
|
-
from ._interface import Filter
|
|
13
|
+
from ._interface import CAN_EXT_ID_MASK, Filter
|
|
14
14
|
|
|
15
|
-
CAN_EXT_ID_MASK = (1 << 29) - 1
|
|
16
15
|
NODE_ID_MAX = 127
|
|
17
16
|
NODE_ID_ANONYMOUS = 0xFF
|
|
18
17
|
NODE_ID_CAPACITY = NODE_ID_MAX + 1
|
pycyphal2/can/pythoncan.py
CHANGED
|
@@ -18,7 +18,7 @@ import logging
|
|
|
18
18
|
import threading
|
|
19
19
|
|
|
20
20
|
from .._api import ClosedError, Instant
|
|
21
|
-
from ._interface import Filter, Interface, TimestampedFrame
|
|
21
|
+
from ._interface import CAN_EXT_ID_MASK, Filter, Interface, TimestampedFrame
|
|
22
22
|
|
|
23
23
|
try:
|
|
24
24
|
import can
|
|
@@ -27,8 +27,11 @@ except ImportError:
|
|
|
27
27
|
|
|
28
28
|
_logger = logging.getLogger(__name__)
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
# RX thread poll cadence. This also bounds how long filter()/close() may block the event loop while
|
|
31
|
+
# they quiesce the RX thread (it can only acknowledge a pause between recv() calls). Kept short so that
|
|
32
|
+
# admin stall stays small. ponytail: to remove the stall entirely, hand pending filters to the RX
|
|
33
|
+
# thread to apply between recv() calls instead of pausing it from the loop thread.
|
|
34
|
+
_RX_POLL_TIMEOUT = 0.02
|
|
32
35
|
|
|
33
36
|
|
|
34
37
|
class PythonCANInterface(Interface):
|
|
@@ -96,6 +99,7 @@ class PythonCANInterface(Interface):
|
|
|
96
99
|
self._raise_if_closed()
|
|
97
100
|
if self._tx_task is None:
|
|
98
101
|
self._tx_task = self._loop.create_task(self._tx_loop())
|
|
102
|
+
self._tx_task.add_done_callback(self._on_task_done)
|
|
99
103
|
for chunk in data:
|
|
100
104
|
self._tx_seq += 1
|
|
101
105
|
self._tx_queue.put_nowait((id, self._tx_seq, deadline.ns, bytes(chunk)))
|
|
@@ -151,15 +155,12 @@ class PythonCANInterface(Interface):
|
|
|
151
155
|
loop = asyncio.get_running_loop()
|
|
152
156
|
while not self._closed:
|
|
153
157
|
try:
|
|
154
|
-
identifier,
|
|
158
|
+
identifier, seq, deadline_ns, payload = await self._tx_queue.get()
|
|
155
159
|
except asyncio.CancelledError:
|
|
156
160
|
raise
|
|
157
161
|
if self._closed:
|
|
158
162
|
return
|
|
159
|
-
|
|
160
|
-
_logger.debug("PythonCAN tx drop expired iface=%s id=%08x", self._name, identifier)
|
|
161
|
-
continue
|
|
162
|
-
timeout = max(0.0, (deadline_ns - Instant.now().ns) * 1e-9)
|
|
163
|
+
timeout = (deadline_ns - Instant.now().ns) * 1e-9
|
|
163
164
|
if timeout <= 0.0:
|
|
164
165
|
_logger.debug("PythonCAN tx drop expired iface=%s id=%08x", self._name, identifier)
|
|
165
166
|
continue
|
|
@@ -172,14 +173,10 @@ class PythonCANInterface(Interface):
|
|
|
172
173
|
)
|
|
173
174
|
try:
|
|
174
175
|
await asyncio.wait_for(loop.run_in_executor(None, self._bus.send, msg, timeout), timeout=timeout)
|
|
175
|
-
except asyncio.TimeoutError:
|
|
176
|
-
|
|
177
|
-
self._tx_seq += 1
|
|
178
|
-
await asyncio.sleep(0.001)
|
|
179
|
-
except can.CanError as ex:
|
|
176
|
+
except (asyncio.TimeoutError, can.CanError) as ex:
|
|
177
|
+
# Re-queue with the original seq so the frame keeps its place within its transfer.
|
|
180
178
|
_logger.debug("PythonCAN tx retry iface=%s err=%s", self._name, ex)
|
|
181
|
-
self._tx_queue.put_nowait((identifier,
|
|
182
|
-
self._tx_seq += 1
|
|
179
|
+
self._tx_queue.put_nowait((identifier, seq, deadline_ns, payload))
|
|
183
180
|
await asyncio.sleep(0.001)
|
|
184
181
|
except OSError as ex:
|
|
185
182
|
self._fail(ex)
|
|
@@ -229,6 +226,14 @@ class PythonCANInterface(Interface):
|
|
|
229
226
|
_logger.error("PythonCAN interface %s failed: %s", self._name, ex)
|
|
230
227
|
self.close()
|
|
231
228
|
|
|
229
|
+
def _on_task_done(self, task: asyncio.Task[None]) -> None:
|
|
230
|
+
# Surface an unexpected TX-task crash as an interface failure instead of swallowing it.
|
|
231
|
+
if task.cancelled() or self._closed:
|
|
232
|
+
return
|
|
233
|
+
ex = task.exception()
|
|
234
|
+
if ex is not None:
|
|
235
|
+
self._fail(ex)
|
|
236
|
+
|
|
232
237
|
def _raise_if_closed(self) -> None:
|
|
233
238
|
if self._closed:
|
|
234
239
|
if self._failure is not None:
|
|
@@ -258,4 +263,4 @@ def _parse_message(msg: can.Message) -> TimestampedFrame | None:
|
|
|
258
263
|
if msg.is_remote_frame:
|
|
259
264
|
_logger.debug("PythonCAN drop remote frame id=%08x", msg.arbitration_id)
|
|
260
265
|
return None
|
|
261
|
-
return TimestampedFrame(id=msg.arbitration_id &
|
|
266
|
+
return TimestampedFrame(id=msg.arbitration_id & CAN_EXT_ID_MASK, data=bytes(msg.data), timestamp=Instant.now())
|
pycyphal2/can/socketcan.py
CHANGED
|
@@ -21,12 +21,14 @@ _logger = logging.getLogger(__name__)
|
|
|
21
21
|
|
|
22
22
|
_CAN_FILTER_CAPACITY = 64
|
|
23
23
|
_CAN_INTERFACE_TYPE = 280
|
|
24
|
-
_CAN_CLASSIC_MTU = 16
|
|
25
|
-
_CAN_FD_MTU = 72
|
|
26
24
|
_CANFD_FDF = getattr(socket, "CANFD_FDF", 0)
|
|
27
25
|
_CAN_FRAME_STRUCT = struct.Struct("=IB3x8s")
|
|
28
26
|
_CANFD_FRAME_STRUCT = struct.Struct("=IBBBB64s")
|
|
29
27
|
_CAN_FILTER_STRUCT = struct.Struct("=II")
|
|
28
|
+
# SocketCAN frame sizes — sizeof(struct can_frame)=16, sizeof(struct canfd_frame)=72 (NOT the 8/64 payload MTU).
|
|
29
|
+
# The kernel also reports these as the CAN netdev MTU, so they double as the FD-capability threshold.
|
|
30
|
+
_CLASSIC_FRAME_SIZE = _CAN_FRAME_STRUCT.size
|
|
31
|
+
_FD_FRAME_SIZE = _CANFD_FRAME_STRUCT.size
|
|
30
32
|
_TRANSIENT_TX_ERRNO = {errno.EAGAIN, errno.EWOULDBLOCK, errno.ENOBUFS, errno.ENOMEM, errno.EBUSY}
|
|
31
33
|
|
|
32
34
|
|
|
@@ -37,7 +39,7 @@ class SocketCANInterface(Interface):
|
|
|
37
39
|
self._sock.setblocking(False)
|
|
38
40
|
self._sock.setsockopt(socket.SOL_CAN_RAW, socket.CAN_RAW_LOOPBACK, 1)
|
|
39
41
|
self._sock.bind((self._name,))
|
|
40
|
-
self._fd = self._read_iface_mtu() >=
|
|
42
|
+
self._fd = self._read_iface_mtu() >= _FD_FRAME_SIZE
|
|
41
43
|
if self._fd:
|
|
42
44
|
self._sock.setsockopt(socket.SOL_CAN_RAW, socket.CAN_RAW_FD_FRAMES, 1)
|
|
43
45
|
self._closed = False
|
|
@@ -74,6 +76,7 @@ class SocketCANInterface(Interface):
|
|
|
74
76
|
self._raise_if_closed()
|
|
75
77
|
if self._tx_task is None:
|
|
76
78
|
self._tx_task = asyncio.get_running_loop().create_task(self._tx_loop())
|
|
79
|
+
self._tx_task.add_done_callback(self._on_task_done)
|
|
77
80
|
for chunk in data:
|
|
78
81
|
self._tx_seq += 1
|
|
79
82
|
self._tx_queue.put_nowait((id, self._tx_seq, deadline.ns, bytes(chunk)))
|
|
@@ -94,7 +97,7 @@ class SocketCANInterface(Interface):
|
|
|
94
97
|
async def receive(self) -> TimestampedFrame:
|
|
95
98
|
self._raise_if_closed()
|
|
96
99
|
loop = asyncio.get_running_loop()
|
|
97
|
-
recv_size =
|
|
100
|
+
recv_size = _FD_FRAME_SIZE if self._fd else _CLASSIC_FRAME_SIZE
|
|
98
101
|
while True:
|
|
99
102
|
try:
|
|
100
103
|
raw = await loop.sock_recv(self._sock, recv_size)
|
|
@@ -128,14 +131,17 @@ class SocketCANInterface(Interface):
|
|
|
128
131
|
raise
|
|
129
132
|
if self._closed:
|
|
130
133
|
return
|
|
131
|
-
|
|
132
|
-
_logger.debug("SocketCAN tx drop expired iface=%s id=%08x", self._name, identifier)
|
|
133
|
-
continue
|
|
134
|
-
frame = self._encode(identifier, payload)
|
|
135
|
-
timeout = max(0.0, (deadline_ns - Instant.now().ns) * 1e-9)
|
|
134
|
+
timeout = (deadline_ns - Instant.now().ns) * 1e-9
|
|
136
135
|
if timeout <= 0.0:
|
|
137
136
|
_logger.debug("SocketCAN tx drop expired iface=%s id=%08x", self._name, identifier)
|
|
138
137
|
continue
|
|
138
|
+
try:
|
|
139
|
+
frame = self._encode(identifier, payload)
|
|
140
|
+
except ValueError as ex:
|
|
141
|
+
# An unencodable frame (e.g. oversized on a Classic-only interface) is a single bad
|
|
142
|
+
# frame, not an interface failure: drop it instead of letting it kill the TX task.
|
|
143
|
+
_logger.warning("SocketCAN tx drop unencodable iface=%s id=%08x: %s", self._name, identifier, ex)
|
|
144
|
+
continue
|
|
139
145
|
try:
|
|
140
146
|
await asyncio.wait_for(loop.sock_sendall(self._sock, frame), timeout=timeout)
|
|
141
147
|
except asyncio.TimeoutError:
|
|
@@ -159,6 +165,14 @@ class SocketCANInterface(Interface):
|
|
|
159
165
|
_logger.error("SocketCAN interface %s failed: %s", self._name, ex)
|
|
160
166
|
self.close()
|
|
161
167
|
|
|
168
|
+
def _on_task_done(self, task: asyncio.Task[None]) -> None:
|
|
169
|
+
# Surface an unexpected TX-task crash as an interface failure instead of swallowing it.
|
|
170
|
+
if task.cancelled() or self._closed:
|
|
171
|
+
return
|
|
172
|
+
ex = task.exception()
|
|
173
|
+
if ex is not None:
|
|
174
|
+
self._fail(ex)
|
|
175
|
+
|
|
162
176
|
def _raise_if_closed(self) -> None:
|
|
163
177
|
if self._closed:
|
|
164
178
|
if self._failure is not None:
|
|
@@ -172,7 +186,9 @@ class SocketCANInterface(Interface):
|
|
|
172
186
|
def _encode(self, identifier: int, data: bytes) -> bytes:
|
|
173
187
|
if len(data) > 8:
|
|
174
188
|
if not self._fd:
|
|
175
|
-
raise
|
|
189
|
+
raise ValueError(
|
|
190
|
+
f"SocketCAN interface {self._name} cannot send a {len(data)}-byte frame on Classic CAN"
|
|
191
|
+
)
|
|
176
192
|
return _CANFD_FRAME_STRUCT.pack(
|
|
177
193
|
socket.CAN_EFF_FLAG | (identifier & socket.CAN_EFF_MASK),
|
|
178
194
|
len(data),
|
|
@@ -189,14 +205,14 @@ class SocketCANInterface(Interface):
|
|
|
189
205
|
|
|
190
206
|
@staticmethod
|
|
191
207
|
def _decode(raw: bytes) -> TimestampedFrame | None:
|
|
192
|
-
if len(raw) <
|
|
208
|
+
if len(raw) < _CLASSIC_FRAME_SIZE:
|
|
193
209
|
_logger.debug("SocketCAN drop short len=%d", len(raw))
|
|
194
210
|
return None
|
|
195
|
-
if len(raw) >=
|
|
196
|
-
can_id, length, _flags, _reserved0, _reserved1, data = _CANFD_FRAME_STRUCT.unpack(raw[:
|
|
211
|
+
if len(raw) >= _FD_FRAME_SIZE:
|
|
212
|
+
can_id, length, _flags, _reserved0, _reserved1, data = _CANFD_FRAME_STRUCT.unpack(raw[:_FD_FRAME_SIZE])
|
|
197
213
|
payload = data[: min(length, 64)]
|
|
198
214
|
else:
|
|
199
|
-
can_id, length, data = _CAN_FRAME_STRUCT.unpack(raw[:
|
|
215
|
+
can_id, length, data = _CAN_FRAME_STRUCT.unpack(raw[:_CLASSIC_FRAME_SIZE])
|
|
200
216
|
payload = data[: min(length, 8)]
|
|
201
217
|
if (can_id & socket.CAN_EFF_FLAG) == 0 or (can_id & (socket.CAN_RTR_FLAG | socket.CAN_ERR_FLAG)) != 0:
|
|
202
218
|
_logger.debug("SocketCAN drop non-extended or non-data id=%08x", can_id)
|
pycyphal2/udp.py
CHANGED
|
@@ -172,7 +172,6 @@ def _frame_is_valid(header: _FrameHeader, payload_chunk: bytes | memoryview) ->
|
|
|
172
172
|
class _Fragment:
|
|
173
173
|
offset: int
|
|
174
174
|
data: bytes
|
|
175
|
-
crc: int
|
|
176
175
|
|
|
177
176
|
@property
|
|
178
177
|
def end(self) -> int:
|
|
@@ -210,7 +209,7 @@ class _TransferSlot:
|
|
|
210
209
|
)
|
|
211
210
|
|
|
212
211
|
def update(self, timestamp_ns: int, header: _FrameHeader, payload_chunk: bytes) -> bytes | None:
|
|
213
|
-
if self._accept_fragment(header.frame_payload_offset, payload_chunk
|
|
212
|
+
if self._accept_fragment(header.frame_payload_offset, payload_chunk):
|
|
214
213
|
self.ts_max_ns = max(self.ts_max_ns, timestamp_ns)
|
|
215
214
|
self.ts_min_ns = min(self.ts_min_ns, timestamp_ns)
|
|
216
215
|
crc_end = header.frame_payload_offset + len(payload_chunk)
|
|
@@ -221,7 +220,7 @@ class _TransferSlot:
|
|
|
221
220
|
return None
|
|
222
221
|
return self._finalize_payload()
|
|
223
222
|
|
|
224
|
-
def _accept_fragment(self, offset: int, data: bytes
|
|
223
|
+
def _accept_fragment(self, offset: int, data: bytes) -> bool:
|
|
225
224
|
left = offset
|
|
226
225
|
right = offset + len(data)
|
|
227
226
|
for frag in self.fragments:
|
|
@@ -244,7 +243,7 @@ class _TransferSlot:
|
|
|
244
243
|
v_left = min(left, left_neighbor.offset + 1) if left_neighbor is not None else left
|
|
245
244
|
v_right = max(right, max(right_neighbor.end, 1) - 1) if right_neighbor is not None else right
|
|
246
245
|
self.fragments = [frag for frag in self.fragments if not (frag.offset >= v_left and frag.end <= v_right)]
|
|
247
|
-
self.fragments.append(_Fragment(offset=offset, data=data
|
|
246
|
+
self.fragments.append(_Fragment(offset=offset, data=data))
|
|
248
247
|
self.fragments.sort(key=lambda frag: frag.offset)
|
|
249
248
|
self.covered_prefix = self._compute_covered_prefix()
|
|
250
249
|
return True
|
|
@@ -474,10 +473,15 @@ class Interface:
|
|
|
474
473
|
mtu_link: int
|
|
475
474
|
"""Link-layer MTU. E.g., 1500 for Ethernet, ~64K for loopback."""
|
|
476
475
|
|
|
476
|
+
def __post_init__(self) -> None:
|
|
477
|
+
# Validate at construction (not via assert, which `python -O` strips) so a too-small MTU can
|
|
478
|
+
# never produce a negative mtu_cyphal that would make segmentation loop forever.
|
|
479
|
+
if self.mtu_link < _CYPHAL_MTU_LINK_MIN:
|
|
480
|
+
raise ValueError(f"mtu_link must be >= {_CYPHAL_MTU_LINK_MIN}, got {self.mtu_link}")
|
|
481
|
+
|
|
477
482
|
@property
|
|
478
483
|
def mtu_cyphal(self) -> int:
|
|
479
484
|
"""Max Cyphal frame payload: mtu_link - 60 (IPv4 max) - 8 (UDP) - 32 (Cyphal header)."""
|
|
480
|
-
assert self.mtu_link >= _CYPHAL_MTU_LINK_MIN
|
|
481
485
|
return self.mtu_link - _CYPHAL_OVERHEAD_MAX
|
|
482
486
|
|
|
483
487
|
|
|
@@ -516,18 +520,22 @@ class _UDPSubjectWriter(SubjectWriter):
|
|
|
516
520
|
except (OSError, SendError) as e:
|
|
517
521
|
errors.append(e)
|
|
518
522
|
|
|
523
|
+
if errors and success_count == 0:
|
|
524
|
+
_logger.error("Send failed on all interfaces for subject %d", self._subject_id)
|
|
525
|
+
raise SendError("send failed on all interfaces") from ExceptionGroup(
|
|
526
|
+
"send failed on all interfaces", errors
|
|
527
|
+
)
|
|
519
528
|
if errors:
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
529
|
+
# Redundant transport: delivery via at least one interface is a success. Warn but do not
|
|
530
|
+
# raise, otherwise the caller would treat a delivered transfer as failed and retry it,
|
|
531
|
+
# duplicating it on the interfaces that already succeeded. This mirrors the CAN transport
|
|
532
|
+
# (see _CANTransportImpl.send_transfer) and the reference cy_udp_posix push semantics.
|
|
524
533
|
_logger.warning(
|
|
525
|
-
"Send
|
|
526
|
-
|
|
534
|
+
"Send succeeded on %d/%d interfaces for subject %d",
|
|
535
|
+
success_count,
|
|
527
536
|
len(errors) + success_count,
|
|
528
537
|
self._subject_id,
|
|
529
538
|
)
|
|
530
|
-
raise eg
|
|
531
539
|
|
|
532
540
|
_logger.debug("Subject tx done sid=%d tid=%d", self._subject_id, transfer_id)
|
|
533
541
|
|
|
@@ -843,8 +851,16 @@ class _UDPTransportImpl(UDPTransport):
|
|
|
843
851
|
_logger.warning("No endpoint known for remote_id=0x%016x", remote_id)
|
|
844
852
|
raise SendError("No endpoint known for remote_id")
|
|
845
853
|
if errors:
|
|
846
|
-
|
|
847
|
-
|
|
854
|
+
# Redundant transport: delivery via at least one interface is a success. Warn but do not
|
|
855
|
+
# raise, otherwise a delivered transfer would be reported as failed and retried (mirrors
|
|
856
|
+
# _UDPSubjectWriter.__call__ and the reference cy_udp_posix unicast push semantics).
|
|
857
|
+
_logger.warning(
|
|
858
|
+
"Unicast succeeded on %d/%d interfaces for remote_id=0x%016x",
|
|
859
|
+
success_count,
|
|
860
|
+
len(errors) + success_count,
|
|
861
|
+
remote_id,
|
|
862
|
+
)
|
|
863
|
+
_logger.debug("Unicast sent to remote_id=0x%016x", remote_id)
|
|
848
864
|
|
|
849
865
|
def close(self) -> None:
|
|
850
866
|
if self._closed:
|
|
@@ -947,7 +963,11 @@ class _UDPTransportImpl(UDPTransport):
|
|
|
947
963
|
return
|
|
948
964
|
if arrival is not None and self._unicast_handler is not None:
|
|
949
965
|
_logger.debug("Unicast transfer complete from sender_uid=0x%016x", arrival.remote_id)
|
|
950
|
-
|
|
966
|
+
try:
|
|
967
|
+
self._unicast_handler(arrival)
|
|
968
|
+
except Exception:
|
|
969
|
+
# A raising handler must not kill the receive loop; drop the arrival and keep serving.
|
|
970
|
+
_logger.exception("Unicast handler raised iface=%d", iface_idx)
|
|
951
971
|
|
|
952
972
|
def _process_subject_datagram(
|
|
953
973
|
self,
|
|
@@ -997,4 +1017,8 @@ class _UDPTransportImpl(UDPTransport):
|
|
|
997
1017
|
if arrival is not None:
|
|
998
1018
|
_logger.debug("Subject %d transfer complete from sender_uid=0x%016x", subject_id, arrival.remote_id)
|
|
999
1019
|
if handler is not None:
|
|
1000
|
-
|
|
1020
|
+
try:
|
|
1021
|
+
handler(arrival)
|
|
1022
|
+
except Exception:
|
|
1023
|
+
# A raising handler must not kill the receive loop; drop the arrival and keep serving.
|
|
1024
|
+
_logger.exception("Subject %d handler raised", subject_id)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pycyphal2
|
|
3
|
-
Version: 2.0.0.
|
|
3
|
+
Version: 2.0.0.dev4
|
|
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
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
pycyphal2/__init__.py,sha256=I3q42Aaf6RF1-wwqRDuIwfI384Qar3SdH5pNqrx7TCc,6849
|
|
2
|
+
pycyphal2/_api.py,sha256=fYtM9J0RmaEp30iSzebi6a0kLeINPRM0UvvAjevEV6E,22688
|
|
3
|
+
pycyphal2/_hash.py,sha256=qdQ3A35oFy5mxPNO873OlLntVsCuOak6ckJf8r5j254,10621
|
|
4
|
+
pycyphal2/_header.py,sha256=12r_jQQ1t3rrNG6si5C3-vNcUSkvYQCBo8YhG16g19Q,10448
|
|
5
|
+
pycyphal2/_node.py,sha256=t4Tv-EkxZDSaOl4S8p5uahUs1cVYA_lVO415XmiyJFs,61537
|
|
6
|
+
pycyphal2/_publisher.py,sha256=-5T-WVa9MZin1MQ7ELckOiC31AxU1SBs8qFnM7E21g0,14998
|
|
7
|
+
pycyphal2/_subscriber.py,sha256=nHFj_TdqK3y_HwvoZ2mbiQZ6WJZkATQqp7zXgIqpDDk,14989
|
|
8
|
+
pycyphal2/_transport.py,sha256=C0-hMukqJ_wUbNkXMBpMSp78Rf9FE7rcBfZpLDMkNF4,3492
|
|
9
|
+
pycyphal2/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
pycyphal2/udp.py,sha256=JMWjVcht0E7j3POyvsu0Gm0sJXsMXiI4QlP99SbqO7g,43949
|
|
11
|
+
pycyphal2/can/__init__.py,sha256=db9Miv0bKV591WiajAVijef2x7J5Y9-gu0G2mm7hHU4,1553
|
|
12
|
+
pycyphal2/can/_interface.py,sha256=YScBZ90oJ1AdDIFNt4tiNWqpGufvTOGot3Oh3FW0CNI,4397
|
|
13
|
+
pycyphal2/can/_media_slcan.py,sha256=B7e4bscH9klu8Ik0nMI7X1aemVccuI2McQfXLIR6VVw,6878
|
|
14
|
+
pycyphal2/can/_reassembly.py,sha256=tDh0LubxANkOVIRCHG6zbABJEQD7WegxQS-UPqg__6E,5682
|
|
15
|
+
pycyphal2/can/_transport.py,sha256=sWNoyUnVJnUuhQ8pEtzAkfJSuqvI0RzMVVDKA5j1NOk,21036
|
|
16
|
+
pycyphal2/can/_wire.py,sha256=f7OgeuyAUHSLhmRiBq8ukQ7NTTfNu0zXQW7J5gRIGQ0,13681
|
|
17
|
+
pycyphal2/can/pythoncan.py,sha256=jxnlKhuXcH4nxU27e-PtOmtJX0ZLgbpk7_Sds6B7PTM,11272
|
|
18
|
+
pycyphal2/can/socketcan.py,sha256=lmfYzP3ih6xMayHyxW83K9KR352GF5_DVgxF0NqrLRc,9467
|
|
19
|
+
pycyphal2/can/webserial.py,sha256=HwZ2HrRDCsOyLsOPCHunP_Nje1TQa9VsRNEeXQbu-Dg,10723
|
|
20
|
+
pycyphal2-2.0.0.dev4.dist-info/licenses/LICENSE,sha256=ILoAsB6eavnHqYkHMZt5JIp4-2oIMglw7vQr2aizB8w,1101
|
|
21
|
+
pycyphal2-2.0.0.dev4.dist-info/METADATA,sha256=gCpSfgEpNXp6dS5KVSpV47Frxorq2h2_hNhDJNM-wig,2602
|
|
22
|
+
pycyphal2-2.0.0.dev4.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
23
|
+
pycyphal2-2.0.0.dev4.dist-info/top_level.txt,sha256=m3-ZKFH4OwPKxa94jzEP4OCKx7B7qnONx3GYXgaw8BU,10
|
|
24
|
+
pycyphal2-2.0.0.dev4.dist-info/RECORD,,
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
pycyphal2/__init__.py,sha256=oQzpEogvwPAgYiY01NHR5LfUj9HycRldI6gZpOifXB4,6849
|
|
2
|
-
pycyphal2/_api.py,sha256=fYtM9J0RmaEp30iSzebi6a0kLeINPRM0UvvAjevEV6E,22688
|
|
3
|
-
pycyphal2/_hash.py,sha256=qdQ3A35oFy5mxPNO873OlLntVsCuOak6ckJf8r5j254,10621
|
|
4
|
-
pycyphal2/_header.py,sha256=12r_jQQ1t3rrNG6si5C3-vNcUSkvYQCBo8YhG16g19Q,10448
|
|
5
|
-
pycyphal2/_node.py,sha256=8MOV6GoROC55bczN8kcYi19IrdD4UF-U7mUaTAaPCJQ,57586
|
|
6
|
-
pycyphal2/_publisher.py,sha256=e8ydodDeLTqrgeNnBj4hjz2gWqkGgODmUGER9YgyY10,15868
|
|
7
|
-
pycyphal2/_subscriber.py,sha256=_BOeyjzSJel58GYRlfud0qpilFuaZEgBzBCGRFf4NtI,15721
|
|
8
|
-
pycyphal2/_transport.py,sha256=C0-hMukqJ_wUbNkXMBpMSp78Rf9FE7rcBfZpLDMkNF4,3492
|
|
9
|
-
pycyphal2/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
-
pycyphal2/udp.py,sha256=xGrXQZQf-Tqo6em2rBxUTn373HYhaqb0B5nsf0b6SLQ,42389
|
|
11
|
-
pycyphal2/can/__init__.py,sha256=db9Miv0bKV591WiajAVijef2x7J5Y9-gu0G2mm7hHU4,1553
|
|
12
|
-
pycyphal2/can/_interface.py,sha256=u2YmS-fUkThE75IRKmF_VoA_YCTMM4ADkfMAi25cOQY,4369
|
|
13
|
-
pycyphal2/can/_media_slcan.py,sha256=drDcftRfgIeJKFyShYQP8-G8pO8PucfSrg8ou1VdgU4,6895
|
|
14
|
-
pycyphal2/can/_reassembly.py,sha256=tDh0LubxANkOVIRCHG6zbABJEQD7WegxQS-UPqg__6E,5682
|
|
15
|
-
pycyphal2/can/_transport.py,sha256=sWNoyUnVJnUuhQ8pEtzAkfJSuqvI0RzMVVDKA5j1NOk,21036
|
|
16
|
-
pycyphal2/can/_wire.py,sha256=OSStXci7qzwnbz0YZ0H07rNU5bdi5fCLRNKVZmDOtdw,13696
|
|
17
|
-
pycyphal2/can/pythoncan.py,sha256=-zgFhoKDvihlRpVZg3OV12ClBTsf5MNLf1eD9_HxDus,10845
|
|
18
|
-
pycyphal2/can/socketcan.py,sha256=nBYzbAcoVXeGoRrovpJnCbTqs4MBabpt27QJw4JqUYc,8529
|
|
19
|
-
pycyphal2/can/webserial.py,sha256=HwZ2HrRDCsOyLsOPCHunP_Nje1TQa9VsRNEeXQbu-Dg,10723
|
|
20
|
-
pycyphal2-2.0.0.dev3.dist-info/licenses/LICENSE,sha256=ILoAsB6eavnHqYkHMZt5JIp4-2oIMglw7vQr2aizB8w,1101
|
|
21
|
-
pycyphal2-2.0.0.dev3.dist-info/METADATA,sha256=9xo_xBrpoL5Fn_LD8_ReHL0JBzGbAwaydvjo0LPyuRI,2602
|
|
22
|
-
pycyphal2-2.0.0.dev3.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
23
|
-
pycyphal2-2.0.0.dev3.dist-info/top_level.txt,sha256=m3-ZKFH4OwPKxa94jzEP4OCKx7B7qnONx3GYXgaw8BU,10
|
|
24
|
-
pycyphal2-2.0.0.dev3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|