pycyphal2 2.0.0.dev0__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.
@@ -0,0 +1,430 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import math
6
+ from dataclasses import dataclass, field
7
+
8
+ from ._api import DeliveryError, Instant, LivenessError, NackError, Priority, SendError
9
+ from ._api import Subscriber, Breadcrumb, Topic, Arrival
10
+ from ._header import SEQNO48_MASK, RspBeHeader, RspRelHeader
11
+ from ._node import (
12
+ ACK_BASELINE_DEFAULT_TIMEOUT,
13
+ REORDERING_CAPACITY,
14
+ SESSION_LIFETIME,
15
+ NodeImpl,
16
+ SubscriberRoot,
17
+ TopicImpl,
18
+ match_pattern,
19
+ )
20
+
21
+ _logger = logging.getLogger(__name__)
22
+ REORDERING_WINDOW_MAX = SESSION_LIFETIME / 2
23
+
24
+
25
+ # =====================================================================================================================
26
+ # Reordering
27
+ # =====================================================================================================================
28
+
29
+
30
+ @dataclass
31
+ class InternedMsg:
32
+ arrival: Arrival
33
+ tag: int
34
+ remote_id: int
35
+ lin_tag: int
36
+
37
+
38
+ @dataclass
39
+ class ReorderingState:
40
+ """Per (remote_id, topic_hash) reordering state for ordered subscriptions."""
41
+
42
+ tag_baseline: int = 0
43
+ last_ejected_lin_tag: int = 0
44
+ last_active_at: float = 0.0
45
+ interned: dict[int, InternedMsg] = field(default_factory=dict) # lin_tag -> msg
46
+ timeout_handle: asyncio.TimerHandle | None = None
47
+
48
+
49
+ class SubscriberImpl(Subscriber):
50
+ def __init__(
51
+ self,
52
+ node: NodeImpl,
53
+ root: SubscriberRoot,
54
+ pattern: str,
55
+ verbatim: bool,
56
+ reordering_window: float | None,
57
+ ) -> None:
58
+ self._node = node
59
+ self._root = root
60
+ self._pattern = pattern
61
+ self._verbatim = verbatim
62
+ self._timeout = float("inf")
63
+ self._reordering_window = self._normalize_reordering_window(reordering_window)
64
+ self.queue: asyncio.Queue[Arrival | BaseException] = asyncio.Queue()
65
+ self._reordering: dict[tuple[int, int], ReorderingState] = {} # (remote_id, topic_hash)
66
+ self.closed = False
67
+
68
+ @staticmethod
69
+ def _normalize_reordering_window(reordering_window: float | None) -> float | None:
70
+ if reordering_window is None:
71
+ return None
72
+ out = float(reordering_window)
73
+ if (out < 0.0) or (not math.isfinite(out)):
74
+ raise ValueError("Reordering window must be a finite non-negative duration")
75
+ if out > REORDERING_WINDOW_MAX:
76
+ raise ValueError(f"Reordering window is too large")
77
+ return out
78
+
79
+ @property
80
+ def pattern(self) -> str:
81
+ return self._pattern
82
+
83
+ @property
84
+ def verbatim(self) -> bool:
85
+ return self._verbatim
86
+
87
+ @property
88
+ def timeout(self) -> float:
89
+ return self._timeout
90
+
91
+ @timeout.setter
92
+ def timeout(self, duration: float) -> None:
93
+ self._timeout = duration
94
+
95
+ def substitutions(self, topic: Topic) -> list[tuple[str, int]] | None:
96
+ return match_pattern(self._pattern, topic.name)
97
+
98
+ def __aiter__(self) -> SubscriberImpl:
99
+ return self
100
+
101
+ async def __anext__(self) -> Arrival:
102
+ if self.closed:
103
+ raise StopAsyncIteration
104
+ timeout = self._timeout if self._timeout != float("inf") else None
105
+ try:
106
+ item = await asyncio.wait_for(self.queue.get(), timeout=timeout)
107
+ except asyncio.TimeoutError:
108
+ raise LivenessError("No message received within timeout")
109
+ if isinstance(item, StopAsyncIteration):
110
+ raise item
111
+ if isinstance(item, BaseException):
112
+ raise item
113
+ return item
114
+
115
+ def deliver(self, arrival: Arrival, tag: int, remote_id: int) -> bool:
116
+ """Called by the node to deliver a message to this subscriber."""
117
+ if self.closed:
118
+ return False
119
+ if self._reordering_window is None:
120
+ self.queue.put_nowait(arrival)
121
+ return True
122
+ # Reordering enabled.
123
+ self._drop_stale_reordering(arrival.timestamp.s)
124
+ topic_hash = arrival.breadcrumb.topic.hash
125
+ key = (remote_id, topic_hash)
126
+ state = self._reordering.get(key)
127
+ if state is None:
128
+ state = ReorderingState(
129
+ tag_baseline=tag - (REORDERING_CAPACITY // 2),
130
+ last_ejected_lin_tag=0,
131
+ last_active_at=arrival.timestamp.s,
132
+ )
133
+ self._reordering[key] = state
134
+ state.last_active_at = arrival.timestamp.s
135
+ lin_tag = (tag - state.tag_baseline) & ((1 << 64) - 1)
136
+
137
+ # Detect wraparound / very late messages.
138
+ if lin_tag > ((1 << 63) - 1):
139
+ _logger.debug("Reorder drop late tag=%d lin=%d", tag, lin_tag)
140
+ return False
141
+ if lin_tag <= state.last_ejected_lin_tag:
142
+ _logger.debug("Reorder drop dup/late tag=%d lin=%d last=%d", tag, lin_tag, state.last_ejected_lin_tag)
143
+ return False
144
+
145
+ while state.interned and lin_tag > (state.last_ejected_lin_tag + REORDERING_CAPACITY):
146
+ self._scan_reordering(state, force_first=True)
147
+
148
+ expected = state.last_ejected_lin_tag + 1
149
+ if lin_tag == expected:
150
+ # In-order: eject immediately and scan for consecutive.
151
+ self.queue.put_nowait(arrival)
152
+ state.last_ejected_lin_tag = lin_tag
153
+ self._scan_reordering(state, force_first=False)
154
+ return True
155
+
156
+ if lin_tag > (state.last_ejected_lin_tag + REORDERING_CAPACITY):
157
+ state.tag_baseline = tag - (REORDERING_CAPACITY // 2)
158
+ state.last_ejected_lin_tag = 0
159
+ lin_tag = (tag - state.tag_baseline) & ((1 << 64) - 1)
160
+ _logger.debug("Reorder resequence tag=%d lin=%d", tag, lin_tag)
161
+
162
+ # Out-of-order but within capacity: intern.
163
+ if lin_tag in state.interned:
164
+ return True
165
+ state.interned[lin_tag] = InternedMsg(arrival=arrival, tag=tag, remote_id=remote_id, lin_tag=lin_tag)
166
+ self._rearm_reorder_timeout(state)
167
+ return True
168
+
169
+ def _scan_reordering(self, state: ReorderingState, force_first: bool) -> None:
170
+ while True:
171
+ if not state.interned:
172
+ if state.timeout_handle is not None:
173
+ state.timeout_handle.cancel()
174
+ state.timeout_handle = None
175
+ break
176
+
177
+ lin_tag = min(state.interned)
178
+ if force_first or ((state.last_ejected_lin_tag + 1) == lin_tag):
179
+ force_first = False
180
+ interned = state.interned.pop(lin_tag)
181
+ self.queue.put_nowait(interned.arrival)
182
+ state.last_ejected_lin_tag = lin_tag
183
+ continue
184
+
185
+ self._rearm_reorder_timeout(state)
186
+ break
187
+
188
+ def _force_eject_all(self, state: ReorderingState, *, silenced: bool = False) -> None:
189
+ """Force-eject all interned messages in tag order."""
190
+ while state.interned:
191
+ lin_tag = min(state.interned)
192
+ interned = state.interned.pop(lin_tag)
193
+ state.last_ejected_lin_tag = lin_tag
194
+ if not silenced:
195
+ self.queue.put_nowait(interned.arrival)
196
+ if state.timeout_handle is not None:
197
+ state.timeout_handle.cancel()
198
+ state.timeout_handle = None
199
+
200
+ def _rearm_reorder_timeout(self, state: ReorderingState) -> None:
201
+ """Arm or rearm the reordering timeout against the current head-of-line slot."""
202
+ if self._reordering_window is None:
203
+ return
204
+ if not state.interned:
205
+ if state.timeout_handle is not None:
206
+ state.timeout_handle.cancel()
207
+ state.timeout_handle = None
208
+ return
209
+
210
+ lin_tag = min(state.interned)
211
+ delay = max(0.0, (state.interned[lin_tag].arrival.timestamp.s + self._reordering_window) - Instant.now().s)
212
+
213
+ loop = self._node.loop
214
+ if state.timeout_handle is not None:
215
+ state.timeout_handle.cancel()
216
+
217
+ def on_timeout() -> None:
218
+ state.timeout_handle = None
219
+ self._scan_reordering(state, force_first=True)
220
+
221
+ state.timeout_handle = loop.call_later(delay, on_timeout)
222
+
223
+ def _arm_reorder_timeout(self, state: ReorderingState) -> None:
224
+ self._rearm_reorder_timeout(state)
225
+
226
+ def _drop_stale_reordering(self, now: float) -> None:
227
+ stale = [key for key, state in self._reordering.items() if (state.last_active_at + SESSION_LIFETIME) < now]
228
+ for key in stale:
229
+ state = self._reordering.pop(key)
230
+ self._force_eject_all(state)
231
+
232
+ def forget_topic_reordering(self, topic_hash: int, *, silenced: bool = True) -> None:
233
+ keys = [key for key in self._reordering if key[1] == topic_hash]
234
+ for key in keys:
235
+ state = self._reordering.pop(key)
236
+ self._force_eject_all(state, silenced=silenced)
237
+
238
+ def close(self) -> None:
239
+ if self.closed:
240
+ return
241
+ self.closed = True
242
+ for state in self._reordering.values():
243
+ self._force_eject_all(state)
244
+ self._reordering.clear()
245
+ if self in self._root.subscribers:
246
+ self._root.subscribers.remove(self)
247
+ if not self._root.subscribers:
248
+ if self._root.scout_task is not None:
249
+ self._root.scout_task.cancel()
250
+ self._root.scout_task = None
251
+ if self._root.is_pattern:
252
+ self._node.sub_roots_pattern.pop(self._root.name, None)
253
+ else:
254
+ self._node.sub_roots_verbatim.pop(self._root.name, None)
255
+ for topic in list(self._node.topics_by_name.values()):
256
+ self._node.decouple_topic_root(topic, self._root)
257
+ self.queue.put_nowait(StopAsyncIteration())
258
+ _logger.info("Subscriber closed for '%s'", self._pattern)
259
+
260
+
261
+ # =====================================================================================================================
262
+ # Breadcrumb
263
+ # =====================================================================================================================
264
+
265
+
266
+ class BreadcrumbImpl(Breadcrumb):
267
+ def __init__(
268
+ self,
269
+ node: NodeImpl,
270
+ remote_id: int,
271
+ topic: TopicImpl,
272
+ message_tag: int,
273
+ initial_priority: Priority,
274
+ ) -> None:
275
+ self._node = node
276
+ self._remote_id = remote_id
277
+ self._topic = topic
278
+ self._message_tag = message_tag
279
+ self._priority = initial_priority
280
+ self._seqno = 0
281
+
282
+ @property
283
+ def remote_id(self) -> int:
284
+ return self._remote_id
285
+
286
+ @property
287
+ def topic(self) -> Topic:
288
+ return self._topic
289
+
290
+ @property
291
+ def tag(self) -> int:
292
+ return self._message_tag
293
+
294
+ async def __call__(
295
+ self,
296
+ deadline: Instant,
297
+ message: memoryview | bytes,
298
+ *,
299
+ reliable: bool = False,
300
+ ) -> None:
301
+ seqno = self._seqno & SEQNO48_MASK
302
+ self._seqno += 1
303
+
304
+ hdr: RspBeHeader | RspRelHeader
305
+ if not reliable:
306
+ hdr = RspBeHeader(
307
+ tag=0xFF,
308
+ seqno=seqno,
309
+ topic_hash=self._topic.hash,
310
+ message_tag=self._message_tag,
311
+ )
312
+ else:
313
+ rsp_tag = self._allocate_response_tag(seqno)
314
+ hdr = RspRelHeader(
315
+ tag=rsp_tag,
316
+ seqno=seqno,
317
+ topic_hash=self._topic.hash,
318
+ message_tag=self._message_tag,
319
+ )
320
+
321
+ data = hdr.serialize() + bytes(message)
322
+ if not reliable:
323
+ await self._node.transport.unicast(deadline, self._priority, self._remote_id, data)
324
+ _logger.debug("Response BE sent seqno=%d to %016x", seqno, self._remote_id)
325
+ return
326
+
327
+ # Reliable response with retransmission.
328
+ tracker = RespondTracker(
329
+ remote_id=self._remote_id,
330
+ message_tag=self._message_tag,
331
+ topic_hash=self._topic.hash,
332
+ seqno=seqno,
333
+ tag=hdr.tag,
334
+ )
335
+ key = tracker.key
336
+ self._node.respond_futures[key] = tracker
337
+
338
+ ack_timeout = ACK_BASELINE_DEFAULT_TIMEOUT * (1 << int(self._priority))
339
+ try:
340
+ initial_window = _ack_window(deadline.ns, ack_timeout)
341
+ if initial_window is None:
342
+ raise DeliveryError("Reliable response not acknowledged before deadline")
343
+
344
+ ack_deadline_ns, last_attempt = initial_window
345
+ tracker.ack_event.clear()
346
+ try:
347
+ await self._node.transport.unicast(Instant(ns=ack_deadline_ns), self._priority, self._remote_id, data)
348
+ except SendError:
349
+ raise
350
+ except OSError as ex:
351
+ raise SendError("Reliable response initial send failed") from ex
352
+
353
+ while True:
354
+ if tracker.done:
355
+ if tracker.nacked:
356
+ raise NackError("Response NACK'd by remote")
357
+ return
358
+
359
+ wait_until_ns = deadline.ns if last_attempt else ack_deadline_ns
360
+ wait_time = max(0.0, (wait_until_ns - Instant.now().ns) * 1e-9)
361
+ try:
362
+ await asyncio.wait_for(tracker.ack_event.wait(), timeout=wait_time)
363
+ except asyncio.TimeoutError:
364
+ pass
365
+
366
+ if tracker.done:
367
+ if tracker.nacked:
368
+ raise NackError("Response NACK'd by remote")
369
+ return
370
+
371
+ if last_attempt:
372
+ break
373
+ ack_timeout *= 2
374
+ next_window = _ack_window(deadline.ns, ack_timeout)
375
+ if next_window is None:
376
+ break
377
+ ack_deadline_ns, last_attempt = next_window
378
+ tracker.ack_event.clear()
379
+ try:
380
+ await self._node.transport.unicast(
381
+ Instant(ns=ack_deadline_ns), self._priority, self._remote_id, data
382
+ )
383
+ except (SendError, OSError):
384
+ pass
385
+
386
+ if not tracker.done:
387
+ raise DeliveryError("Reliable response not acknowledged before deadline")
388
+ finally:
389
+ self._node.respond_futures.pop(key, None)
390
+
391
+ def _allocate_response_tag(self, seqno: int) -> int:
392
+ for tag in range(256):
393
+ key = (self._remote_id, self._message_tag, self._topic.hash, seqno, tag)
394
+ if key not in self._node.respond_futures:
395
+ return tag
396
+ raise DeliveryError("Reliable response tag space exhausted")
397
+
398
+
399
+ class RespondTracker:
400
+ """Tracks a pending reliable response awaiting ACK."""
401
+
402
+ def __init__(self, remote_id: int, message_tag: int, topic_hash: int, seqno: int, tag: int) -> None:
403
+ self.remote_id = remote_id
404
+ self.message_tag = message_tag
405
+ self.topic_hash = topic_hash
406
+ self.seqno = seqno
407
+ self.tag = tag
408
+ self.key = (remote_id, message_tag, topic_hash, seqno, tag)
409
+ self.ack_event = asyncio.Event()
410
+ self.done = False
411
+ self.nacked = False
412
+
413
+ def on_ack(self, positive: bool) -> None:
414
+ self.done = True
415
+ self.nacked = not positive
416
+ 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)
@@ -0,0 +1,92 @@
1
+ """
2
+ The bottom-layer API that connects the session layer to the underlying transport layer.
3
+ Normally, applications don't care about this unless a custom transport is needed (very uncommon),
4
+ so it is moved into a separate module.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from abc import abstractmethod
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass
12
+
13
+ from ._api import Closable, Instant, Priority
14
+
15
+ SUBJECT_ID_MODULUS_16bit = 57203 # Suitable for all Cyphal transports
16
+ SUBJECT_ID_MODULUS_23bit = 8378431 # Incompatible with Cyphal/CAN
17
+ SUBJECT_ID_MODULUS_32bit = 4294954663 # Incompatible with Cyphal/CAN and Cyphal/UDPv4
18
+
19
+
20
+ class SubjectWriter(Closable):
21
+ @abstractmethod
22
+ async def __call__(self, deadline: Instant, priority: Priority, message: bytes | memoryview) -> None:
23
+ raise NotImplementedError
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class TransportArrival:
28
+ """
29
+ Arrival of a transfer from the underlying transport.
30
+ The session layer (this library) will parse the header and process the message.
31
+ """
32
+
33
+ timestamp: Instant
34
+ priority: Priority
35
+ remote_id: int
36
+ message: bytes
37
+
38
+
39
+ class Transport(Closable):
40
+ """
41
+ Serves the same purpose as cy_platform_t in Cy, with several Pythonic deviations documented below.
42
+ """
43
+
44
+ @property
45
+ @abstractmethod
46
+ def subject_id_modulus(self) -> int:
47
+ """
48
+ Constant, cannot be changed while the transport is in used because that would invalidate subject allocations.
49
+ """
50
+ raise NotImplementedError
51
+
52
+ @abstractmethod
53
+ def subject_listen(self, subject_id: int, handler: Callable[[TransportArrival], None]) -> Closable:
54
+ """
55
+ Subscribe to a subject to receive messages from it until the returned closable handle is closed.
56
+ The session layer may request at most one listener per subject at any given time, similar to the reference impl.
57
+ Duplicate requests for the same subject should raise ValueError.
58
+
59
+ REFERENCE PARITY: Unlike the reference implementation, our listeners do not have the extent setting --
60
+ the extent mostly matters for high-reliability/real-time applications; this Python implementation
61
+ assumes infinite extent.
62
+ """
63
+ raise NotImplementedError
64
+
65
+ @abstractmethod
66
+ def subject_advertise(self, subject_id: int) -> SubjectWriter:
67
+ """
68
+ Begin sending messages on a subject.
69
+ The session layer may request at most one writer per subject at any given time, similar to the reference impl.
70
+ Duplicate requests for the same subject should raise ValueError.
71
+ """
72
+ raise NotImplementedError
73
+
74
+ @abstractmethod
75
+ def unicast_listen(self, handler: Callable[[TransportArrival], None]) -> None:
76
+ """
77
+ The session layer will invoke this once to configure the handler that will process incoming unicast messages.
78
+ Normally it will happen very early in initialization so no messages are lost; if, however, it somehow comes
79
+ to pass that messages arrive while the handler is still not set, they may be silently dropped.
80
+ """
81
+ raise NotImplementedError
82
+
83
+ @abstractmethod
84
+ async def unicast(self, deadline: Instant, priority: Priority, remote_id: int, message: bytes | memoryview) -> None:
85
+ """
86
+ Send a unicast message to the specified remote node.
87
+ """
88
+ raise NotImplementedError
89
+
90
+ @abstractmethod
91
+ def __repr__(self) -> str:
92
+ raise NotImplementedError
@@ -0,0 +1,43 @@
1
+ """
2
+ Cyphal/CAN transport — real-time reliable pub/sub over Classic CAN and CAN FD.
3
+ Supports various backends such as SocketCAN and Python-CAN.
4
+
5
+ ```python
6
+ from pycyphal2.can import CANTransport
7
+ # Import the backend you need.
8
+ # Beware: optional dependencies may be needed, check pyproject.toml.
9
+ from pycyphal2.can.socketcan import SocketCANInterface
10
+
11
+ transport = CANTransport.new(SocketCANInterface("can0"))
12
+ ```
13
+
14
+ Python-CAN is useful when the application runs not on GNU/Linux or already uses `python-can` or needs
15
+ `one of its *many* hardware backends <https://python-can.readthedocs.io/en/stable/interfaces.html>`_
16
+ -- GS-USB, SLCAN, PCAN, etc:
17
+
18
+ ```python
19
+ import can
20
+ from pycyphal2.can import CANTransport
21
+ from pycyphal2.can.pythoncan import PythonCANInterface
22
+
23
+ bus = can.ThreadSafeBus(interface="socketcan", channel="can0")
24
+ transport = CANTransport.new(PythonCANInterface(bus))
25
+ ```
26
+
27
+ Pass the transport to `pycyphal2.Node.new()` to start a node.
28
+
29
+ For the available dependencies see the submodules such as `socketcan` et al.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from ._interface import Filter as Filter
35
+ from ._interface import Frame as Frame
36
+ from ._interface import Interface as Interface
37
+ from ._interface import TimestampedFrame as TimestampedFrame
38
+ from ._transport import CANTransport as CANTransport
39
+
40
+ # Backend submodules importable via pycyphal2.can.pythoncan / pycyphal2.can.socketcan;
41
+ # they are not eagerly imported here because they pull in optional dependencies.
42
+
43
+ __all__ = ["CANTransport", "Frame", "TimestampedFrame", "Filter", "Interface"]
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass
5
+ from typing import Iterable
6
+ import itertools
7
+
8
+ from .. import Closable, Instant
9
+
10
+ _CAN_EXT_ID_MASK = (1 << 29) - 1
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Frame:
15
+ """29-bit extended data frame."""
16
+
17
+ id: int
18
+ data: bytes
19
+
20
+ def __post_init__(self) -> None:
21
+ if not isinstance(self.id, int) or not (0 <= self.id <= _CAN_EXT_ID_MASK):
22
+ raise ValueError(f"Invalid CAN identifier: {self.id!r}")
23
+ data = bytes(self.data)
24
+ if len(data) > 64:
25
+ raise ValueError(f"Invalid CAN data length: {len(data)}")
26
+ object.__setattr__(self, "data", data)
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class TimestampedFrame(Frame):
31
+ timestamp: Instant
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Filter:
36
+ """29-bit extended identifier acceptance filter."""
37
+
38
+ id: int
39
+ mask: int
40
+
41
+ def __post_init__(self) -> None:
42
+ if not (0 <= self.id <= _CAN_EXT_ID_MASK):
43
+ raise ValueError(f"Invalid CAN identifier: {self.id!r}")
44
+ if not (0 <= self.mask <= _CAN_EXT_ID_MASK):
45
+ raise ValueError(f"Invalid CAN mask: {self.mask!r}")
46
+
47
+ @property
48
+ def rank(self) -> int:
49
+ return self.mask.bit_count()
50
+
51
+ def merge(self, other: Filter) -> Filter:
52
+ mask = self.mask & other.mask & ~(self.id ^ other.id)
53
+ return Filter(id=self.id & mask, mask=mask)
54
+
55
+ @staticmethod
56
+ def promiscuous() -> Filter:
57
+ return Filter(id=0, mask=0)
58
+
59
+ @staticmethod
60
+ def coalesce(filters: Iterable[Filter], count: int) -> list[Filter]:
61
+ if count < 1:
62
+ raise ValueError("The target number of filters must be positive")
63
+ filters = list(filters)
64
+ assert isinstance(filters, list)
65
+ # REFERENCE PARITY: Do not flag this as a divergence; this implementation is correct.
66
+ while len(filters) > count:
67
+ options = itertools.starmap(
68
+ lambda ia, ib: (ia[0], ib[0], ia[1].merge(ib[1])), itertools.permutations(enumerate(filters), 2)
69
+ )
70
+ index_replace, index_remove, merged = max(options, key=lambda x: int(x[2].rank))
71
+ filters[index_replace] = merged
72
+ del filters[index_remove] # Invalidates indexes
73
+ assert all(map(lambda x: isinstance(x, Filter), filters))
74
+ return filters
75
+
76
+
77
+ class Interface(Closable, ABC):
78
+ """
79
+ A local CAN controller interface.
80
+ Only extended-ID data frames are supported; everything else is silently dropped.
81
+ """
82
+
83
+ @property
84
+ @abstractmethod
85
+ def name(self) -> str:
86
+ raise NotImplementedError
87
+
88
+ @property
89
+ @abstractmethod
90
+ def fd(self) -> bool:
91
+ raise NotImplementedError
92
+
93
+ @abstractmethod
94
+ def filter(self, filters: Iterable[Filter]) -> None:
95
+ """
96
+ Request the hardware acceptance filter configuration.
97
+ Implementations with a smaller hardware capacity shall coalesce the list locally.
98
+ """
99
+ raise NotImplementedError
100
+
101
+ @abstractmethod
102
+ def enqueue(self, id: int, data: Iterable[memoryview], deadline: Instant) -> None:
103
+ """
104
+ Schedule one or more frames for transmission. All frames share the same extended identifier.
105
+ The frame order within the iterable shall be preserved. Implementations may prioritize queued
106
+ frames by CAN identifier to approximate bus arbitration, but the relative order of frames
107
+ belonging to one transfer shall remain unchanged.
108
+ """
109
+ # REFERENCE PARITY: TX queue ownership intentionally belongs to the interface rather than the transport.
110
+ # This differs from libcanard's internal queue placement but it is not a parity drift because it does not
111
+ # affect the wire-visible behavior by itself.
112
+ raise NotImplementedError
113
+
114
+ @abstractmethod
115
+ def purge(self) -> None:
116
+ """
117
+ Drop all queued but not yet transmitted frames.
118
+ Used when the local node-ID changes and queued continuations become invalid.
119
+ """
120
+ raise NotImplementedError
121
+
122
+ @abstractmethod
123
+ async def receive(self) -> TimestampedFrame:
124
+ """
125
+ Suspend until the next frame is received.
126
+ Raises an exception if the interface is closed or has failed.
127
+ """
128
+ raise NotImplementedError
129
+
130
+ def __repr__(self) -> str:
131
+ raise NotImplementedError