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.
Files changed (44) hide show
  1. {pycyphal2-2.0.0.dev2/src/pycyphal2.egg-info → pycyphal2-2.0.0.dev4}/PKG-INFO +1 -1
  2. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/__init__.py +1 -1
  3. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/_node.py +89 -18
  4. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/_publisher.py +7 -21
  5. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/_subscriber.py +3 -19
  6. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/can/_interface.py +5 -4
  7. pycyphal2-2.0.0.dev4/src/pycyphal2/can/_media_slcan.py +198 -0
  8. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/can/_wire.py +1 -2
  9. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/can/pythoncan.py +21 -16
  10. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/can/socketcan.py +30 -14
  11. pycyphal2-2.0.0.dev4/src/pycyphal2/can/webserial.py +287 -0
  12. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/udp.py +40 -16
  13. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4/src/pycyphal2.egg-info}/PKG-INFO +1 -1
  14. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2.egg-info/SOURCES.txt +2 -0
  15. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/tests/test_monitor.py +27 -0
  16. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/tests/test_parity.py +0 -2
  17. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/tests/test_parity_coverage.py +3 -3
  18. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/tests/test_pubsub.py +60 -0
  19. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/tests/test_reliable.py +0 -3
  20. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/tests/test_udp.py +131 -10
  21. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/LICENSE +0 -0
  22. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/README.md +0 -0
  23. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/pyproject.toml +0 -0
  24. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/setup.cfg +0 -0
  25. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/_api.py +0 -0
  26. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/_hash.py +0 -0
  27. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/_header.py +0 -0
  28. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/_transport.py +0 -0
  29. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/can/__init__.py +0 -0
  30. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/can/_reassembly.py +0 -0
  31. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/can/_transport.py +0 -0
  32. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2/py.typed +0 -0
  33. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2.egg-info/dependency_links.txt +0 -0
  34. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2.egg-info/requires.txt +0 -0
  35. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/src/pycyphal2.egg-info/top_level.txt +0 -0
  36. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/tests/test_gossip.py +0 -0
  37. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/tests/test_hash.py +0 -0
  38. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/tests/test_header.py +0 -0
  39. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/tests/test_integration.py +0 -0
  40. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/tests/test_names.py +0 -0
  41. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/tests/test_reorder.py +0 -0
  42. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/tests/test_rpc.py +0 -0
  43. {pycyphal2-2.0.0.dev2 → pycyphal2-2.0.0.dev4}/tests/test_scout.py +0 -0
  44. {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.dev2
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.dev2"
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, deadline_ns: int, data: bytes) -> PublishTracker:
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.loop.create_task(do_send())
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.loop.create_task(do_send())
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.loop.create_task(self.send_gossip_unicast(topic, arrival.remote_id, arrival.priority))
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, delivery_deadline.ns, payload)
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
- @staticmethod
193
- def _reliable_publish_window(deadline_ns: int, ack_timeout: float) -> tuple[int, bool] | None:
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 = self._reliable_publish_window(deadline.ns, tracker.ack_timeout)
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 = self._reliable_publish_window(deadline.ns, tracker.ack_timeout)
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, deadline.ns, payload)
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 = _ack_window(deadline.ns, ack_timeout)
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 = _ack_window(deadline.ns, ack_timeout)
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
- _CAN_EXT_ID_MASK = (1 << 29) - 1
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 <= _CAN_EXT_ID_MASK):
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 <= _CAN_EXT_ID_MASK):
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 <= _CAN_EXT_ID_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