pycyphal2 2.0.0.dev2__tar.gz → 2.0.0.dev4__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.dev2/src/pycyphal2.egg-info → pycyphal2-2.0.0.dev4}/PKG-INFO +1 -1
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/__init__.py +1 -1
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/_node.py +89 -18
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/_publisher.py +7 -21
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/_subscriber.py +3 -19
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/can/_interface.py +5 -4
- pycyphal2-2.0.0.dev4/src/pycyphal2/can/_media_slcan.py +198 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/can/_wire.py +1 -2
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/can/pythoncan.py +21 -16
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/can/socketcan.py +30 -14
- pycyphal2-2.0.0.dev4/src/pycyphal2/can/webserial.py +287 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/udp.py +40 -16
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4/src/pycyphal2.egg-info}/PKG-INFO +1 -1
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2.egg-info/SOURCES.txt +2 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/tests/test_monitor.py +27 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/tests/test_parity.py +0 -2
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/tests/test_parity_coverage.py +3 -3
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/tests/test_pubsub.py +60 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/tests/test_reliable.py +0 -3
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/tests/test_udp.py +131 -10
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/LICENSE +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/README.md +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/pyproject.toml +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/setup.cfg +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/_api.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/_hash.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/_header.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/_transport.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/can/__init__.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/can/_reassembly.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/can/_transport.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/py.typed +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2.egg-info/dependency_links.txt +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2.egg-info/requires.txt +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2.egg-info/top_level.txt +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/tests/test_gossip.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/tests/test_hash.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/tests/test_header.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/tests/test_integration.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/tests/test_names.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/tests/test_reorder.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/tests/test_rpc.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/tests/test_scout.py +0 -0
- {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/tests/test_topic.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.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
|
|
@@ -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__ = [
|
|
@@ -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:
|
|
@@ -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)
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SLCAN text protocol for CAN media (frame codec + adapter handshake).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
from ._interface import CAN_EXT_ID_MASK, CAN_STD_ID_MASK, Frame
|
|
10
|
+
from ._wire import DLC_TO_LENGTH, MTU_CAN_CLASSIC
|
|
11
|
+
|
|
12
|
+
_logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
_CR = 0x0D # ACK / carriage return
|
|
15
|
+
_LF = 0x0A
|
|
16
|
+
_BEL = 0x07 # NACK / bell
|
|
17
|
+
_MAX_LINE_LENGTH = 256
|
|
18
|
+
_STRIP_CHARS = b" \t\r\n\x07\x03"
|
|
19
|
+
|
|
20
|
+
_CMD_TERMINATOR = bytes([_CR])
|
|
21
|
+
_CMD_CLOSE = b"C"
|
|
22
|
+
_CMD_OPEN = b"O"
|
|
23
|
+
_CMD_SET_BITRATE_PREFIX = b"S"
|
|
24
|
+
_BITRATE_TO_SPEED_CODE = {
|
|
25
|
+
1_000_000: 8,
|
|
26
|
+
800_000: 7,
|
|
27
|
+
500_000: 6,
|
|
28
|
+
250_000: 5,
|
|
29
|
+
125_000: 4,
|
|
30
|
+
100_000: 3,
|
|
31
|
+
50_000: 2,
|
|
32
|
+
20_000: 1,
|
|
33
|
+
10_000: 0,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def encode_frame(identifier: int, data: bytes | bytearray | memoryview) -> bytes:
|
|
38
|
+
"""
|
|
39
|
+
Encode an extended-ID Classic CAN data frame into one SLCAN ``T`` command line.
|
|
40
|
+
"""
|
|
41
|
+
if not isinstance(identifier, int) or not (0 <= identifier <= CAN_EXT_ID_MASK):
|
|
42
|
+
raise ValueError(f"Invalid CAN identifier: {identifier!r}")
|
|
43
|
+
payload = bytes(data)
|
|
44
|
+
if len(payload) > MTU_CAN_CLASSIC:
|
|
45
|
+
raise ValueError(f"Invalid CAN data length: {len(payload)}")
|
|
46
|
+
return f"T{identifier:08X}{len(payload):1d}{payload.hex().upper()}\r".encode()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def encode_deinit() -> bytes:
|
|
50
|
+
"""The close command line. Sent fire-and-forget before purging input to reset the adapter."""
|
|
51
|
+
return _CMD_CLOSE + _CMD_TERMINATOR
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def encode_init_sequence(bitrate: int | None) -> list[bytes]:
|
|
55
|
+
"""
|
|
56
|
+
Command lines to bring the adapter up after deinit+purge: optionally set the bitrate, then open.
|
|
57
|
+
If ``bitrate`` is None, the bitrate command is skipped, the old configured value (or adapter default) is kept.
|
|
58
|
+
A bitrate not in the standard speed-code table is sent as-is (some adapters accept raw bitrates, e.g. Zubax).
|
|
59
|
+
Each returned command line expects an ACK.
|
|
60
|
+
"""
|
|
61
|
+
out: list[bytes] = []
|
|
62
|
+
if bitrate is not None:
|
|
63
|
+
code = _BITRATE_TO_SPEED_CODE.get(bitrate, bitrate)
|
|
64
|
+
out.append(_CMD_SET_BITRATE_PREFIX + str(code).encode("ascii") + _CMD_TERMINATOR)
|
|
65
|
+
out.append(_CMD_OPEN + _CMD_TERMINATOR)
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def classify_init_response(chunk: bytes) -> bool | None:
|
|
70
|
+
"""Scan a chunk for the first ACK (True) or NACK (False); return None if neither appears."""
|
|
71
|
+
for byte in chunk:
|
|
72
|
+
if byte == _CR:
|
|
73
|
+
return True
|
|
74
|
+
if byte == _BEL:
|
|
75
|
+
return False
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class SLCANParser:
|
|
80
|
+
"""
|
|
81
|
+
Incremental SLCAN parser.
|
|
82
|
+
Only data frames are returned. Unsupported or malformed input is silently dropped with debug logging.
|
|
83
|
+
Adapter-specific suffixes after the payload, such as timestamps or flags, are ignored.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self) -> None:
|
|
87
|
+
self._buffer = bytearray()
|
|
88
|
+
self._discarding = False
|
|
89
|
+
|
|
90
|
+
def feed(self, chunk: bytes | bytearray | memoryview) -> list[Frame]:
|
|
91
|
+
out: list[Frame] = []
|
|
92
|
+
for byte in bytes(chunk):
|
|
93
|
+
if byte == _BEL:
|
|
94
|
+
if self._buffer or self._discarding:
|
|
95
|
+
_logger.debug("SLCAN drop adapter error len=%d", len(self._buffer))
|
|
96
|
+
self._buffer.clear()
|
|
97
|
+
self._discarding = False
|
|
98
|
+
continue
|
|
99
|
+
if byte in (_CR, _LF):
|
|
100
|
+
if self._discarding:
|
|
101
|
+
self._buffer.clear()
|
|
102
|
+
self._discarding = False
|
|
103
|
+
continue
|
|
104
|
+
if self._buffer:
|
|
105
|
+
frame = _parse_line(bytes(self._buffer))
|
|
106
|
+
if frame is not None:
|
|
107
|
+
out.append(frame)
|
|
108
|
+
self._buffer.clear()
|
|
109
|
+
continue
|
|
110
|
+
if self._discarding:
|
|
111
|
+
continue
|
|
112
|
+
if len(self._buffer) >= _MAX_LINE_LENGTH:
|
|
113
|
+
_logger.debug("SLCAN drop overlong line len>%d", _MAX_LINE_LENGTH)
|
|
114
|
+
self._buffer.clear()
|
|
115
|
+
self._discarding = True
|
|
116
|
+
continue
|
|
117
|
+
self._buffer.append(byte)
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _parse_line(line: bytes) -> Frame | None:
|
|
122
|
+
# Based on the original PyUAVCAN/PyDroneCAN implementation.
|
|
123
|
+
# Strips surrounding whitespace and control characters like BEL/ETX.
|
|
124
|
+
line = line.strip(_STRIP_CHARS)
|
|
125
|
+
if not line:
|
|
126
|
+
return None
|
|
127
|
+
command = line[:1]
|
|
128
|
+
if command in (b"T", b"x"):
|
|
129
|
+
return _parse_data_frame(line, id_length=8, max_payload_length=MTU_CAN_CLASSIC)
|
|
130
|
+
if command == b"t":
|
|
131
|
+
return _parse_data_frame(line, id_length=3, max_payload_length=MTU_CAN_CLASSIC)
|
|
132
|
+
if command == b"D":
|
|
133
|
+
return _parse_data_frame(line, id_length=8, max_payload_length=64)
|
|
134
|
+
if command in (b"r", b"R"):
|
|
135
|
+
_logger.debug("SLCAN drop unsupported frame type cmd=%r", command)
|
|
136
|
+
return None
|
|
137
|
+
_logger.debug("SLCAN drop unknown line=%r", line)
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _parse_data_frame(line: bytes, *, id_length: int, max_payload_length: int) -> Frame | None:
|
|
142
|
+
header_length = 2 + id_length
|
|
143
|
+
if len(line) < header_length:
|
|
144
|
+
_logger.debug("SLCAN drop short data line=%r", line)
|
|
145
|
+
return None
|
|
146
|
+
identifier = _parse_hex_int(line[1 : 1 + id_length])
|
|
147
|
+
dlc = _parse_dlc(line[1 + id_length])
|
|
148
|
+
if identifier is None or dlc is None:
|
|
149
|
+
_logger.debug("SLCAN drop malformed data header line=%r", line)
|
|
150
|
+
return None
|
|
151
|
+
payload_length = DLC_TO_LENGTH[dlc] # _parse_dlc guarantees dlc in [0, 15]
|
|
152
|
+
if payload_length > max_payload_length:
|
|
153
|
+
_logger.debug("SLCAN drop data dlc out of range dlc=%d max=%d line=%r", dlc, max_payload_length, line)
|
|
154
|
+
return None
|
|
155
|
+
expected = header_length + payload_length * 2
|
|
156
|
+
if len(line) < expected:
|
|
157
|
+
_logger.debug("SLCAN drop data dlc mismatch len=%d expected=%d", len(line), expected)
|
|
158
|
+
return None
|
|
159
|
+
if id_length == 3 and identifier > CAN_STD_ID_MASK:
|
|
160
|
+
_logger.debug("SLCAN drop invalid standard id=%x", identifier)
|
|
161
|
+
return None
|
|
162
|
+
data = _parse_hex_bytes(line[header_length:expected])
|
|
163
|
+
if data is None:
|
|
164
|
+
_logger.debug("SLCAN drop malformed data id=%08x", identifier)
|
|
165
|
+
return None
|
|
166
|
+
try:
|
|
167
|
+
return Frame(id=identifier, data=data)
|
|
168
|
+
except ValueError as ex:
|
|
169
|
+
_logger.debug("SLCAN drop invalid frame: %s", ex)
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _parse_hex_int(value: bytes) -> int | None:
|
|
174
|
+
if not value:
|
|
175
|
+
return None
|
|
176
|
+
try:
|
|
177
|
+
return int(value, 16)
|
|
178
|
+
except ValueError:
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _parse_hex_bytes(value: bytes) -> bytes | None:
|
|
183
|
+
if len(value) % 2 != 0:
|
|
184
|
+
return None
|
|
185
|
+
try:
|
|
186
|
+
return bytes.fromhex(value.decode("ascii"))
|
|
187
|
+
except (UnicodeDecodeError, ValueError):
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _parse_dlc(value: int) -> int | None:
|
|
192
|
+
if ord("0") <= value <= ord("9"):
|
|
193
|
+
return value - ord("0")
|
|
194
|
+
if ord("A") <= value <= ord("F"):
|
|
195
|
+
return 10 + value - ord("A")
|
|
196
|
+
if ord("a") <= value <= ord("f"):
|
|
197
|
+
return 10 + value - ord("a")
|
|
198
|
+
return None
|
|
@@ -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
|