pycyphal2 2.0.0.dev2__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 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.dev2"
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, 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:
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, 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)
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 = _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
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
@@ -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
- _RX_POLL_TIMEOUT = 0.1
31
- _CAN_EXT_ID_MASK = (1 << 29) - 1
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, _seq, deadline_ns, payload = await self._tx_queue.get()
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
- if Instant.now().ns >= deadline_ns:
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
- self._tx_queue.put_nowait((identifier, self._tx_seq, deadline_ns, payload))
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, self._tx_seq, deadline_ns, payload))
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 & _CAN_EXT_ID_MASK, data=bytes(msg.data), timestamp=Instant.now())
266
+ return TimestampedFrame(id=msg.arbitration_id & CAN_EXT_ID_MASK, data=bytes(msg.data), timestamp=Instant.now())
@@ -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() >= _CAN_FD_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 = _CAN_FD_MTU if self._fd else _CAN_CLASSIC_MTU
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
- if Instant.now().ns >= deadline_ns:
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 ClosedError(f"SocketCAN interface {self._name} is not CAN FD-capable")
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) < _CAN_CLASSIC_MTU:
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) >= _CAN_FD_MTU:
196
- can_id, length, _flags, _reserved0, _reserved1, data = _CANFD_FRAME_STRUCT.unpack(raw[:_CAN_FD_MTU])
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[:_CAN_CLASSIC_MTU])
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)
@@ -0,0 +1,287 @@
1
+ """
2
+ Browser-oriented SLCAN backend for WebSerial/Pyodide.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import logging
9
+ from abc import ABC, abstractmethod
10
+ from collections.abc import Iterable
11
+
12
+ from .._api import ClosedError, Instant
13
+ from ._interface import Filter, Interface, TimestampedFrame
14
+ from ._media_slcan import (
15
+ SLCANParser,
16
+ classify_init_response,
17
+ encode_deinit,
18
+ encode_frame,
19
+ encode_init_sequence,
20
+ )
21
+
22
+ _logger = logging.getLogger(__name__)
23
+
24
+ _ACK_TIMEOUT = 3.0
25
+ _DEINIT_SETTLE = 0.1
26
+ _PURGE_DRAIN_TIMEOUT = 0.05
27
+
28
+
29
+ class AsyncSerialPort(ABC):
30
+ """Minimal async byte stream expected from a WebSerial adapter."""
31
+
32
+ @abstractmethod
33
+ async def read(self) -> bytes:
34
+ raise NotImplementedError
35
+
36
+ @abstractmethod
37
+ async def write(self, data: bytes) -> None:
38
+ raise NotImplementedError
39
+
40
+ @abstractmethod
41
+ async def close(self) -> None:
42
+ raise NotImplementedError
43
+
44
+
45
+ class WebSerialSLCANInterface(Interface):
46
+ """
47
+ SLCAN CAN interface over an application-provided async serial byte stream.
48
+
49
+ The port is expected to be already opened by browser/Pyodide glue code.
50
+ On startup the adapter is reset: closed, left to settle, its pending input purged (so stale frames
51
+ from a previous configuration are dropped), then configured for the selected bitrate and reopened.
52
+ If no bitrate is given, the bitrate is left unconfigured (old/default).
53
+ """
54
+
55
+ def __init__(self, port: AsyncSerialPort, *, name: str = "webserial", bitrate: int | None = None) -> None:
56
+ self._port = port
57
+ self._name = str(name)
58
+ self._bitrate = None if bitrate is None else int(bitrate)
59
+ self._closed = False
60
+ self._failure: BaseException | None = None
61
+ self._parser = SLCANParser()
62
+ self._tx_seq = 0
63
+ self._tx_queue: asyncio.PriorityQueue[tuple[int, int, int, bytes]] = asyncio.PriorityQueue()
64
+ self._rx_queue: asyncio.Queue[TimestampedFrame | BaseException] = asyncio.Queue()
65
+ self._init_task: asyncio.Task[None] | None = None
66
+ self._tx_task: asyncio.Task[None] | None = None
67
+ self._rx_task: asyncio.Task[None] | None = None
68
+ self._close_task: asyncio.Task[None] | None = None
69
+ self._start_init() # Requires a running loop; this is an async interface, always built in one.
70
+ _logger.info("WebSerial SLCAN init iface=%s bitrate=%s", self._name, self._bitrate)
71
+
72
+ @property
73
+ def name(self) -> str:
74
+ return self._name
75
+
76
+ @property
77
+ def fd(self) -> bool:
78
+ return False
79
+
80
+ def filter(self, filters: Iterable[Filter]) -> None:
81
+ del filters
82
+ self._raise_if_closed()
83
+ # No-op: WebSerial adapters do not provide hardware acceptance filtering.
84
+
85
+ def enqueue(self, id: int, data: Iterable[memoryview], deadline: Instant) -> None:
86
+ self._raise_if_closed()
87
+ chunks = tuple(bytes(item) for item in data)
88
+ for chunk in chunks:
89
+ encode_frame(id, chunk) # Validate before mutating the queue.
90
+ if self._tx_task is None:
91
+ self._tx_task = asyncio.get_running_loop().create_task(self._tx_loop())
92
+ for chunk in chunks:
93
+ self._tx_seq += 1
94
+ self._tx_queue.put_nowait((id, self._tx_seq, deadline.ns, chunk))
95
+
96
+ def purge(self) -> None:
97
+ if self._closed:
98
+ return
99
+ dropped = 0
100
+ try:
101
+ while True:
102
+ self._tx_queue.get_nowait()
103
+ dropped += 1
104
+ except asyncio.QueueEmpty:
105
+ pass
106
+ if dropped > 0:
107
+ _logger.debug("WebSerial SLCAN purge iface=%s dropped=%d", self._name, dropped)
108
+
109
+ async def receive(self) -> TimestampedFrame:
110
+ self._raise_if_closed()
111
+ if self._rx_task is None:
112
+ self._rx_task = asyncio.get_running_loop().create_task(self._rx_loop())
113
+ item = await self._rx_queue.get()
114
+ if isinstance(item, BaseException):
115
+ if isinstance(item, ClosedError):
116
+ raise item
117
+ raise ClosedError(f"WebSerial SLCAN interface {self._name} receive failed") from item
118
+ return item
119
+
120
+ def close(self) -> None:
121
+ self._close(ClosedError(f"WebSerial SLCAN interface {self._name} closed"))
122
+
123
+ def __repr__(self) -> str:
124
+ return f"{type(self).__name__}({self._name!r}, fd={self.fd})"
125
+
126
+ async def _tx_loop(self) -> None:
127
+ try:
128
+ await self._ensure_initialized()
129
+ except Exception as ex:
130
+ self._fail(ex)
131
+ return
132
+ while not self._closed:
133
+ identifier, seq, deadline_ns, payload = await self._tx_queue.get()
134
+ if self._closed:
135
+ return
136
+ timeout = (deadline_ns - Instant.now().ns) * 1e-9
137
+ if timeout <= 0.0:
138
+ _logger.debug("WebSerial SLCAN tx drop expired iface=%s id=%08x", self._name, identifier)
139
+ continue
140
+ try:
141
+ await asyncio.wait_for(self._port.write(encode_frame(identifier, payload)), timeout=timeout)
142
+ except asyncio.TimeoutError:
143
+ self._tx_queue.put_nowait((identifier, seq, deadline_ns, payload))
144
+ await asyncio.sleep(0.001)
145
+ except Exception as ex:
146
+ self._fail(ex)
147
+ return
148
+
149
+ async def _rx_loop(self) -> None:
150
+ try:
151
+ await self._ensure_initialized()
152
+ except Exception as ex:
153
+ self._fail(ex)
154
+ return
155
+ while not self._closed:
156
+ try:
157
+ chunk = await self._port.read()
158
+ except Exception as ex:
159
+ self._fail(ex)
160
+ return
161
+ if not chunk:
162
+ self._fail(EOFError(f"WebSerial SLCAN interface {self._name} ended"))
163
+ return
164
+ for frame in self._parser.feed(chunk):
165
+ self._rx_queue.put_nowait(TimestampedFrame(id=frame.id, data=frame.data, timestamp=Instant.now()))
166
+
167
+ def _start_init(self) -> None:
168
+ if self._init_task is None:
169
+ self._init_task = asyncio.get_running_loop().create_task(self._init_adapter())
170
+ self._init_task.add_done_callback(self._on_init_done)
171
+
172
+ def _on_init_done(self, task: asyncio.Task[None]) -> None:
173
+ if task.cancelled():
174
+ return
175
+ try:
176
+ task.result()
177
+ except Exception as ex:
178
+ if not self._closed:
179
+ self._fail(ex)
180
+
181
+ async def _ensure_initialized(self) -> None:
182
+ self._raise_if_closed()
183
+ self._start_init()
184
+ assert self._init_task is not None
185
+ await asyncio.shield(self._init_task)
186
+ self._raise_if_closed()
187
+
188
+ async def _init_adapter(self) -> None:
189
+ _logger.info("WebSerial SLCAN setup iface=%s bitrate=%s", self._name, self._bitrate)
190
+ # Reset an adapter that may be in an unknown state: close, settle, discard whatever it was
191
+ # forwarding under the old config, then configure and open.
192
+ await self._port.write(encode_deinit())
193
+ await asyncio.sleep(_DEINIT_SETTLE)
194
+ await self._purge_input()
195
+ for command in encode_init_sequence(self._bitrate):
196
+ _logger.debug("WebSerial SLCAN setup cmd iface=%s cmd=%r", self._name, command)
197
+ await self._port.write(command)
198
+ await self._wait_for_init_ack()
199
+ _logger.info("WebSerial SLCAN setup done iface=%s", self._name)
200
+
201
+ async def _purge_input(self) -> None:
202
+ dropped = 0
203
+ while True:
204
+ try:
205
+ chunk = await asyncio.wait_for(self._port.read(), timeout=_PURGE_DRAIN_TIMEOUT)
206
+ except asyncio.TimeoutError:
207
+ break
208
+ if not chunk:
209
+ raise EOFError("SLCAN channel ended while purging input")
210
+ dropped += len(chunk)
211
+ if dropped > 0:
212
+ _logger.debug("WebSerial SLCAN purge stale input iface=%s dropped=%d", self._name, dropped)
213
+
214
+ async def _wait_for_init_ack(self) -> None:
215
+ loop = asyncio.get_running_loop()
216
+ deadline = loop.time() + _ACK_TIMEOUT
217
+ while True:
218
+ timeout = deadline - loop.time()
219
+ if timeout <= 0.0:
220
+ raise TimeoutError("SLCAN ACK timeout")
221
+ chunk = await asyncio.wait_for(self._port.read(), timeout=timeout)
222
+ if not chunk:
223
+ raise EOFError("SLCAN channel ended while waiting for ACK")
224
+ response = classify_init_response(chunk)
225
+ if response is True:
226
+ return
227
+ if response is False:
228
+ raise OSError("SLCAN NACK in response")
229
+ _logger.debug("WebSerial SLCAN setup ignored bytes iface=%s len=%d", self._name, len(chunk))
230
+
231
+ def _fail(self, ex: BaseException) -> None:
232
+ if self._failure is None:
233
+ self._failure = ex
234
+ _logger.error("WebSerial SLCAN interface %s failed: %s", self._name, ex)
235
+ self._close(ex)
236
+
237
+ def _close(self, unblock: BaseException) -> None:
238
+ if self._closed:
239
+ return
240
+ self._closed = True
241
+ self._cancel_worker_tasks()
242
+ self._drain_rx_queue()
243
+ self._rx_queue.put_nowait(unblock)
244
+ self._close_port()
245
+
246
+ def _cancel_worker_tasks(self) -> None:
247
+ current: asyncio.Task[object] | None
248
+ try:
249
+ current = asyncio.current_task()
250
+ except RuntimeError:
251
+ current = None
252
+ for task in (self._init_task, self._tx_task, self._rx_task):
253
+ if task is not None and task is not current:
254
+ task.cancel()
255
+ self._init_task = None
256
+ self._tx_task = None
257
+ self._rx_task = None
258
+
259
+ def _close_port(self) -> None:
260
+ try:
261
+ loop = asyncio.get_running_loop()
262
+ except RuntimeError:
263
+ try:
264
+ asyncio.run(self._close_port_async())
265
+ except Exception as ex:
266
+ _logger.debug("WebSerial SLCAN port close error on %s: %s", self._name, ex)
267
+ return
268
+ self._close_task = loop.create_task(self._close_port_async())
269
+
270
+ async def _close_port_async(self) -> None:
271
+ try:
272
+ await self._port.close()
273
+ except Exception as ex:
274
+ _logger.debug("WebSerial SLCAN port close error on %s: %s", self._name, ex)
275
+
276
+ def _raise_if_closed(self) -> None:
277
+ if self._closed:
278
+ if self._failure is not None:
279
+ raise ClosedError(f"WebSerial SLCAN interface {self._name} failed") from self._failure
280
+ raise ClosedError(f"WebSerial SLCAN interface {self._name} closed")
281
+
282
+ def _drain_rx_queue(self) -> None:
283
+ try:
284
+ while True:
285
+ self._rx_queue.get_nowait()
286
+ except asyncio.QueueEmpty:
287
+ pass
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, header.prefix_crc):
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, crc: int) -> bool:
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, crc=crc))
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
- eg = ExceptionGroup("send failed on some interfaces", errors)
521
- if success_count == 0:
522
- _logger.error("Send failed on all interfaces for subject %d", self._subject_id)
523
- raise SendError("send failed on all interfaces") from eg
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 failed on %d/%d interfaces for subject %d",
526
- len(errors),
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
- raise ExceptionGroup("unicast send failed on some interfaces", errors)
847
- _logger.debug("Unicast sent %d frames to remote_id=0x%016x", len(frames), remote_id)
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
- self._unicast_handler(arrival)
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
- handler(arrival)
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.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
@@ -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,22 +0,0 @@
1
- pycyphal2/__init__.py,sha256=PbQlgcHcmDlxhG_gA0yFJjARWz3tAvIQvhRZVV6QYCo,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/_reassembly.py,sha256=tDh0LubxANkOVIRCHG6zbABJEQD7WegxQS-UPqg__6E,5682
14
- pycyphal2/can/_transport.py,sha256=sWNoyUnVJnUuhQ8pEtzAkfJSuqvI0RzMVVDKA5j1NOk,21036
15
- pycyphal2/can/_wire.py,sha256=OSStXci7qzwnbz0YZ0H07rNU5bdi5fCLRNKVZmDOtdw,13696
16
- pycyphal2/can/pythoncan.py,sha256=-zgFhoKDvihlRpVZg3OV12ClBTsf5MNLf1eD9_HxDus,10845
17
- pycyphal2/can/socketcan.py,sha256=nBYzbAcoVXeGoRrovpJnCbTqs4MBabpt27QJw4JqUYc,8529
18
- pycyphal2-2.0.0.dev2.dist-info/licenses/LICENSE,sha256=ILoAsB6eavnHqYkHMZt5JIp4-2oIMglw7vQr2aizB8w,1101
19
- pycyphal2-2.0.0.dev2.dist-info/METADATA,sha256=6agEQ9TVYp3cEBjv4cy2MogON3a_PsJdD1jJlVg7SmE,2602
20
- pycyphal2-2.0.0.dev2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
21
- pycyphal2-2.0.0.dev2.dist-info/top_level.txt,sha256=m3-ZKFH4OwPKxa94jzEP4OCKx7B7qnONx3GYXgaw8BU,10
22
- pycyphal2-2.0.0.dev2.dist-info/RECORD,,