pycyphal2 2.0.0.dev3__py3-none-any.whl → 2.0.0.dev4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pycyphal2/__init__.py 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.dev3"
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
@@ -6,12 +6,11 @@ from __future__ import annotations
6
6
 
7
7
  import logging
8
8
 
9
- from ._interface import Frame
10
- from ._wire import CAN_EXT_ID_MASK, DLC_TO_LENGTH, MTU_CAN_CLASSIC
9
+ from ._interface import CAN_EXT_ID_MASK, CAN_STD_ID_MASK, Frame
10
+ from ._wire import DLC_TO_LENGTH, MTU_CAN_CLASSIC
11
11
 
12
12
  _logger = logging.getLogger(__name__)
13
13
 
14
- _CAN_STD_ID_MASK = (1 << 11) - 1
15
14
  _CR = 0x0D # ACK / carriage return
16
15
  _LF = 0x0A
17
16
  _BEL = 0x07 # NACK / bell
@@ -157,7 +156,7 @@ def _parse_data_frame(line: bytes, *, id_length: int, max_payload_length: int) -
157
156
  if len(line) < expected:
158
157
  _logger.debug("SLCAN drop data dlc mismatch len=%d expected=%d", len(line), expected)
159
158
  return None
160
- if id_length == 3 and identifier > _CAN_STD_ID_MASK:
159
+ if id_length == 3 and identifier > CAN_STD_ID_MASK:
161
160
  _logger.debug("SLCAN drop invalid standard id=%x", identifier)
162
161
  return None
163
162
  data = _parse_hex_bytes(line[header_length:expected])
pycyphal2/can/_wire.py CHANGED
@@ -10,9 +10,8 @@ from .._hash import (
10
10
  CRC16CCITT_FALSE_RESIDUE,
11
11
  crc16ccitt_false_add,
12
12
  )
13
- from ._interface import Filter
13
+ from ._interface import CAN_EXT_ID_MASK, Filter
14
14
 
15
- CAN_EXT_ID_MASK = (1 << 29) - 1
16
15
  NODE_ID_MAX = 127
17
16
  NODE_ID_ANONYMOUS = 0xFF
18
17
  NODE_ID_CAPACITY = NODE_ID_MAX + 1
@@ -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)
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.dev3
3
+ Version: 2.0.0.dev4
4
4
  Summary: Pure-Python implementation of Cyphal -- a simple and robust real-time publish/subscribe stack that runs anywhere.
5
5
  Author-email: Pavel Kirienko and OpenCyphal team <pavel@opencyphal.org>
6
6
  License: MIT
@@ -0,0 +1,24 @@
1
+ pycyphal2/__init__.py,sha256=I3q42Aaf6RF1-wwqRDuIwfI384Qar3SdH5pNqrx7TCc,6849
2
+ pycyphal2/_api.py,sha256=fYtM9J0RmaEp30iSzebi6a0kLeINPRM0UvvAjevEV6E,22688
3
+ pycyphal2/_hash.py,sha256=qdQ3A35oFy5mxPNO873OlLntVsCuOak6ckJf8r5j254,10621
4
+ pycyphal2/_header.py,sha256=12r_jQQ1t3rrNG6si5C3-vNcUSkvYQCBo8YhG16g19Q,10448
5
+ pycyphal2/_node.py,sha256=t4Tv-EkxZDSaOl4S8p5uahUs1cVYA_lVO415XmiyJFs,61537
6
+ pycyphal2/_publisher.py,sha256=-5T-WVa9MZin1MQ7ELckOiC31AxU1SBs8qFnM7E21g0,14998
7
+ pycyphal2/_subscriber.py,sha256=nHFj_TdqK3y_HwvoZ2mbiQZ6WJZkATQqp7zXgIqpDDk,14989
8
+ pycyphal2/_transport.py,sha256=C0-hMukqJ_wUbNkXMBpMSp78Rf9FE7rcBfZpLDMkNF4,3492
9
+ pycyphal2/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ pycyphal2/udp.py,sha256=JMWjVcht0E7j3POyvsu0Gm0sJXsMXiI4QlP99SbqO7g,43949
11
+ pycyphal2/can/__init__.py,sha256=db9Miv0bKV591WiajAVijef2x7J5Y9-gu0G2mm7hHU4,1553
12
+ pycyphal2/can/_interface.py,sha256=YScBZ90oJ1AdDIFNt4tiNWqpGufvTOGot3Oh3FW0CNI,4397
13
+ pycyphal2/can/_media_slcan.py,sha256=B7e4bscH9klu8Ik0nMI7X1aemVccuI2McQfXLIR6VVw,6878
14
+ pycyphal2/can/_reassembly.py,sha256=tDh0LubxANkOVIRCHG6zbABJEQD7WegxQS-UPqg__6E,5682
15
+ pycyphal2/can/_transport.py,sha256=sWNoyUnVJnUuhQ8pEtzAkfJSuqvI0RzMVVDKA5j1NOk,21036
16
+ pycyphal2/can/_wire.py,sha256=f7OgeuyAUHSLhmRiBq8ukQ7NTTfNu0zXQW7J5gRIGQ0,13681
17
+ pycyphal2/can/pythoncan.py,sha256=jxnlKhuXcH4nxU27e-PtOmtJX0ZLgbpk7_Sds6B7PTM,11272
18
+ pycyphal2/can/socketcan.py,sha256=lmfYzP3ih6xMayHyxW83K9KR352GF5_DVgxF0NqrLRc,9467
19
+ pycyphal2/can/webserial.py,sha256=HwZ2HrRDCsOyLsOPCHunP_Nje1TQa9VsRNEeXQbu-Dg,10723
20
+ pycyphal2-2.0.0.dev4.dist-info/licenses/LICENSE,sha256=ILoAsB6eavnHqYkHMZt5JIp4-2oIMglw7vQr2aizB8w,1101
21
+ pycyphal2-2.0.0.dev4.dist-info/METADATA,sha256=gCpSfgEpNXp6dS5KVSpV47Frxorq2h2_hNhDJNM-wig,2602
22
+ pycyphal2-2.0.0.dev4.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
23
+ pycyphal2-2.0.0.dev4.dist-info/top_level.txt,sha256=m3-ZKFH4OwPKxa94jzEP4OCKx7B7qnONx3GYXgaw8BU,10
24
+ pycyphal2-2.0.0.dev4.dist-info/RECORD,,
@@ -1,24 +0,0 @@
1
- pycyphal2/__init__.py,sha256=oQzpEogvwPAgYiY01NHR5LfUj9HycRldI6gZpOifXB4,6849
2
- pycyphal2/_api.py,sha256=fYtM9J0RmaEp30iSzebi6a0kLeINPRM0UvvAjevEV6E,22688
3
- pycyphal2/_hash.py,sha256=qdQ3A35oFy5mxPNO873OlLntVsCuOak6ckJf8r5j254,10621
4
- pycyphal2/_header.py,sha256=12r_jQQ1t3rrNG6si5C3-vNcUSkvYQCBo8YhG16g19Q,10448
5
- pycyphal2/_node.py,sha256=8MOV6GoROC55bczN8kcYi19IrdD4UF-U7mUaTAaPCJQ,57586
6
- pycyphal2/_publisher.py,sha256=e8ydodDeLTqrgeNnBj4hjz2gWqkGgODmUGER9YgyY10,15868
7
- pycyphal2/_subscriber.py,sha256=_BOeyjzSJel58GYRlfud0qpilFuaZEgBzBCGRFf4NtI,15721
8
- pycyphal2/_transport.py,sha256=C0-hMukqJ_wUbNkXMBpMSp78Rf9FE7rcBfZpLDMkNF4,3492
9
- pycyphal2/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- pycyphal2/udp.py,sha256=xGrXQZQf-Tqo6em2rBxUTn373HYhaqb0B5nsf0b6SLQ,42389
11
- pycyphal2/can/__init__.py,sha256=db9Miv0bKV591WiajAVijef2x7J5Y9-gu0G2mm7hHU4,1553
12
- pycyphal2/can/_interface.py,sha256=u2YmS-fUkThE75IRKmF_VoA_YCTMM4ADkfMAi25cOQY,4369
13
- pycyphal2/can/_media_slcan.py,sha256=drDcftRfgIeJKFyShYQP8-G8pO8PucfSrg8ou1VdgU4,6895
14
- pycyphal2/can/_reassembly.py,sha256=tDh0LubxANkOVIRCHG6zbABJEQD7WegxQS-UPqg__6E,5682
15
- pycyphal2/can/_transport.py,sha256=sWNoyUnVJnUuhQ8pEtzAkfJSuqvI0RzMVVDKA5j1NOk,21036
16
- pycyphal2/can/_wire.py,sha256=OSStXci7qzwnbz0YZ0H07rNU5bdi5fCLRNKVZmDOtdw,13696
17
- pycyphal2/can/pythoncan.py,sha256=-zgFhoKDvihlRpVZg3OV12ClBTsf5MNLf1eD9_HxDus,10845
18
- pycyphal2/can/socketcan.py,sha256=nBYzbAcoVXeGoRrovpJnCbTqs4MBabpt27QJw4JqUYc,8529
19
- pycyphal2/can/webserial.py,sha256=HwZ2HrRDCsOyLsOPCHunP_Nje1TQa9VsRNEeXQbu-Dg,10723
20
- pycyphal2-2.0.0.dev3.dist-info/licenses/LICENSE,sha256=ILoAsB6eavnHqYkHMZt5JIp4-2oIMglw7vQr2aizB8w,1101
21
- pycyphal2-2.0.0.dev3.dist-info/METADATA,sha256=9xo_xBrpoL5Fn_LD8_ReHL0JBzGbAwaydvjo0LPyuRI,2602
22
- pycyphal2-2.0.0.dev3.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
23
- pycyphal2-2.0.0.dev3.dist-info/top_level.txt,sha256=m3-ZKFH4OwPKxa94jzEP4OCKx7B7qnONx3GYXgaw8BU,10
24
- pycyphal2-2.0.0.dev3.dist-info/RECORD,,