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,158 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Iterable
4
+ from dataclasses import dataclass, field
5
+ import logging
6
+
7
+ from .._api import Instant, Priority
8
+ from ._wire import (
9
+ NODE_ID_ANONYMOUS,
10
+ PRIORITY_COUNT,
11
+ RX_SESSION_RETENTION_NS,
12
+ TRANSFER_ID_TIMEOUT_NS,
13
+ ParsedFrame,
14
+ TransferKind,
15
+ crc_add,
16
+ )
17
+
18
+ _logger = logging.getLogger(__name__)
19
+
20
+
21
+ @dataclass
22
+ class RxSlot:
23
+ start_ts_ns: int
24
+ transfer_id: int
25
+ iface_index: int
26
+ expected_toggle: bool
27
+ crc: int = 0xFFFF
28
+ data: bytearray = field(default_factory=bytearray)
29
+
30
+ def accept(self, payload: bytes) -> None:
31
+ self.data.extend(payload)
32
+ self.crc = crc_add(self.crc, payload)
33
+ self.expected_toggle = not self.expected_toggle
34
+
35
+
36
+ @dataclass
37
+ class RxSession:
38
+ last_admission_ts_ns: int
39
+ last_admitted_transfer_id: int
40
+ last_admitted_priority: int
41
+ iface_index: int
42
+ slots: list[RxSlot | None]
43
+
44
+ @staticmethod
45
+ def new(iface_index: int) -> RxSession:
46
+ return RxSession(
47
+ last_admission_ts_ns=-(1 << 62),
48
+ last_admitted_transfer_id=0,
49
+ last_admitted_priority=0,
50
+ iface_index=iface_index,
51
+ slots=[None] * PRIORITY_COUNT,
52
+ )
53
+
54
+
55
+ @dataclass
56
+ class Endpoint:
57
+ kind: TransferKind
58
+ port_id: int
59
+ on_transfer: Callable[[Instant, int, Priority, bytes], None]
60
+ sessions: dict[int, RxSession] = field(default_factory=dict)
61
+
62
+
63
+ class Reassembler:
64
+ @staticmethod
65
+ def cleanup_sessions(endpoints: Iterable[Endpoint], now_ns: int) -> None:
66
+ stale_deadline = now_ns - RX_SESSION_RETENTION_NS
67
+ for endpoint in endpoints:
68
+ for source_id, session in list(endpoint.sessions.items()):
69
+ for priority, slot in enumerate(session.slots):
70
+ if slot is not None and slot.start_ts_ns < stale_deadline:
71
+ session.slots[priority] = None
72
+ if all(slot is None for slot in session.slots) and session.last_admission_ts_ns < stale_deadline:
73
+ endpoint.sessions.pop(source_id, None)
74
+
75
+ @staticmethod
76
+ def ingest(endpoint: Endpoint, iface_index: int, timestamp: Instant, parsed: ParsedFrame) -> None:
77
+ if parsed.source_id == NODE_ID_ANONYMOUS:
78
+ if parsed.start_of_transfer and parsed.end_of_transfer:
79
+ endpoint.on_transfer(timestamp, parsed.source_id, Priority(parsed.priority), parsed.payload)
80
+ return
81
+
82
+ session = endpoint.sessions.get(parsed.source_id)
83
+ if session is None:
84
+ if not parsed.start_of_transfer:
85
+ return
86
+ session = RxSession.new(iface_index)
87
+ endpoint.sessions[parsed.source_id] = session
88
+ if not Reassembler._solve_admission(
89
+ session,
90
+ timestamp.ns,
91
+ parsed.priority,
92
+ parsed.start_of_transfer,
93
+ parsed.toggle,
94
+ parsed.transfer_id,
95
+ iface_index,
96
+ ):
97
+ return
98
+ if parsed.start_of_transfer:
99
+ if session.slots[parsed.priority] is not None:
100
+ session.slots[parsed.priority] = None
101
+ if not parsed.end_of_transfer:
102
+ Reassembler._cleanup_session_slots(session, timestamp.ns)
103
+ session.slots[parsed.priority] = RxSlot(
104
+ start_ts_ns=timestamp.ns,
105
+ transfer_id=parsed.transfer_id,
106
+ iface_index=iface_index,
107
+ expected_toggle=parsed.toggle,
108
+ )
109
+ session.last_admission_ts_ns = timestamp.ns
110
+ session.last_admitted_transfer_id = parsed.transfer_id
111
+ session.last_admitted_priority = parsed.priority
112
+ session.iface_index = iface_index
113
+
114
+ slot = session.slots[parsed.priority]
115
+ if slot is None:
116
+ endpoint.on_transfer(timestamp, parsed.source_id, Priority(parsed.priority), parsed.payload)
117
+ return
118
+ slot.accept(parsed.payload)
119
+ if parsed.end_of_transfer:
120
+ session.slots[parsed.priority] = None
121
+ if len(slot.data) >= 2 and slot.crc == 0:
122
+ endpoint.on_transfer(
123
+ Instant(ns=slot.start_ts_ns), parsed.source_id, Priority(parsed.priority), bytes(slot.data[:-2])
124
+ )
125
+ else:
126
+ _logger.debug(
127
+ "CAN drop bad CRC kind=%s port=%d src=%d", endpoint.kind.name, endpoint.port_id, parsed.source_id
128
+ )
129
+
130
+ @staticmethod
131
+ def _cleanup_session_slots(session: RxSession, now_ns: int) -> None:
132
+ deadline = now_ns - RX_SESSION_RETENTION_NS
133
+ for priority, slot in enumerate(session.slots):
134
+ if slot is not None and slot.start_ts_ns < deadline:
135
+ session.slots[priority] = None
136
+
137
+ @staticmethod
138
+ def _solve_admission(
139
+ session: RxSession,
140
+ timestamp_ns: int,
141
+ priority: int,
142
+ start_of_transfer: bool,
143
+ toggle: bool,
144
+ transfer_id: int,
145
+ iface_index: int,
146
+ ) -> bool:
147
+ if not start_of_transfer:
148
+ slot = session.slots[priority]
149
+ return (
150
+ slot is not None
151
+ and slot.transfer_id == transfer_id
152
+ and slot.iface_index == iface_index
153
+ and slot.expected_toggle == toggle
154
+ )
155
+ fresh = (transfer_id != session.last_admitted_transfer_id) or (priority != session.last_admitted_priority)
156
+ affine = session.iface_index == iface_index
157
+ stale = (timestamp_ns - TRANSFER_ID_TIMEOUT_NS) > session.last_admission_ts_ns
158
+ return (fresh and affine) or (affine and stale) or (stale and fresh)
@@ -0,0 +1,525 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ import asyncio
5
+ from collections.abc import Callable, Iterable
6
+ from dataclasses import dataclass
7
+ import logging
8
+ import os
9
+ import random
10
+
11
+ from .._api import ClosedError, Closable, Instant, Priority, SUBJECT_ID_PINNED_MAX, SendError
12
+ from .._hash import rapidhash
13
+ from .._header import HEADER_SIZE
14
+ from .._transport import SUBJECT_ID_MODULUS_16bit, SubjectWriter, Transport, TransportArrival
15
+ from ._interface import Filter, Interface, TimestampedFrame
16
+ from ._reassembly import Endpoint, Reassembler
17
+ from ._wire import (
18
+ MTU_CAN_CLASSIC,
19
+ MTU_CAN_FD,
20
+ NODE_ID_ANONYMOUS,
21
+ NODE_ID_CAPACITY,
22
+ NODE_ID_MAX,
23
+ SUBJECT_ID_MAX_16,
24
+ TRANSFER_ID_MODULO,
25
+ ParsedFrame,
26
+ TransferKind,
27
+ UNICAST_SERVICE_ID,
28
+ ensure_forced_filters,
29
+ make_filter,
30
+ pack_u32_le,
31
+ pack_u64_le,
32
+ parse_frames,
33
+ serialize_transfer,
34
+ )
35
+
36
+ _logger = logging.getLogger(__name__)
37
+
38
+
39
+ @dataclass
40
+ class _PinnedSubjectState:
41
+ subject_id: int
42
+ header_prefix: bytes
43
+ next_tag: int = 0
44
+
45
+ @staticmethod
46
+ def new(subject_id: int) -> _PinnedSubjectState:
47
+ buf = bytearray(HEADER_SIZE)
48
+ buf[3] = 0xFF
49
+ buf[4:8] = pack_u32_le(0xFFFFFFFF - subject_id)
50
+ buf[8:16] = pack_u64_le(rapidhash(str(subject_id)))
51
+ return _PinnedSubjectState(subject_id=subject_id, header_prefix=bytes(buf[:16]))
52
+
53
+ def wrap(self, payload: bytes) -> bytes:
54
+ self.next_tag += 1
55
+ return self.header_prefix + pack_u64_le(self.next_tag) + payload
56
+
57
+
58
+ class CANTransport(Transport, ABC):
59
+ @property
60
+ @abstractmethod
61
+ def id(self) -> int:
62
+ raise NotImplementedError
63
+
64
+ @property
65
+ @abstractmethod
66
+ def interfaces(self) -> list[Interface]:
67
+ raise NotImplementedError
68
+
69
+ @property
70
+ @abstractmethod
71
+ def closed(self) -> bool:
72
+ raise NotImplementedError
73
+
74
+ @property
75
+ @abstractmethod
76
+ def collision_count(self) -> int:
77
+ raise NotImplementedError
78
+
79
+ @staticmethod
80
+ def new(interfaces: Iterable[Interface] | Interface) -> CANTransport:
81
+ if isinstance(interfaces, Interface):
82
+ items = [interfaces]
83
+ else:
84
+ items = list(interfaces)
85
+ if not items or not all(isinstance(itf, Interface) for itf in items):
86
+ raise ValueError("interfaces must contain at least one Interface instance")
87
+ return _CANTransportImpl(items)
88
+
89
+
90
+ class _SubjectWriter(SubjectWriter):
91
+ def __init__(self, transport: _CANTransportImpl, subject_id: int) -> None:
92
+ self._transport = transport
93
+ self._subject_id = subject_id
94
+ self._closed = False
95
+ self._next_tid_13 = 0
96
+ self._next_tid_16 = 0
97
+
98
+ async def __call__(self, deadline: Instant, priority: Priority, message: bytes | memoryview) -> None:
99
+ if self._closed:
100
+ raise ClosedError("CAN subject writer closed")
101
+ if self._transport.closed:
102
+ raise ClosedError("CAN transport closed")
103
+ data = bytes(message)
104
+ pinned = self._subject_id <= SUBJECT_ID_PINNED_MAX
105
+ best_effort = len(data) >= HEADER_SIZE and data[0] == 0
106
+ use_13b = pinned and best_effort
107
+ if use_13b:
108
+ transfer_id = self._next_tid_13
109
+ self._next_tid_13 = (transfer_id + 1) % TRANSFER_ID_MODULO
110
+ payload = data[HEADER_SIZE:]
111
+ kind = TransferKind.MESSAGE_13
112
+ else:
113
+ transfer_id = self._next_tid_16
114
+ self._next_tid_16 = (transfer_id + 1) % TRANSFER_ID_MODULO
115
+ payload = data
116
+ kind = TransferKind.MESSAGE_16
117
+ await self._transport.send_transfer(
118
+ deadline=deadline,
119
+ priority=priority,
120
+ kind=kind,
121
+ port_id=self._subject_id,
122
+ payload=payload,
123
+ transfer_id=transfer_id,
124
+ )
125
+
126
+ def close(self) -> None:
127
+ if self._closed:
128
+ return
129
+ self._closed = True
130
+ self._transport.remove_subject_writer(self._subject_id, self)
131
+
132
+
133
+ class _SubjectListener(Closable):
134
+ def __init__(
135
+ self, transport: _CANTransportImpl, subject_id: int, handler: Callable[[TransportArrival], None]
136
+ ) -> None:
137
+ self._transport = transport
138
+ self._subject_id = subject_id
139
+ self._handler = handler
140
+ self._closed = False
141
+
142
+ def close(self) -> None:
143
+ if self._closed:
144
+ return
145
+ self._closed = True
146
+ self._transport.remove_subject_listener(self._subject_id, self._handler)
147
+
148
+
149
+ class _CANTransportImpl(CANTransport):
150
+ def __init__(self, interfaces: Iterable[Interface]) -> None:
151
+ self._loop = asyncio.get_running_loop()
152
+ self._closed = False
153
+ self._interfaces = list(interfaces)
154
+ if not self._interfaces:
155
+ raise ValueError("At least one CAN interface is required")
156
+ if len({itf.fd for itf in self._interfaces}) > 1:
157
+ raise ValueError("Mixed Classic-CAN and CAN FD interface sets are not supported")
158
+
159
+ self._fd = self._interfaces[0].fd
160
+ self._interface_index = {id(itf): i for i, itf in enumerate(self._interfaces)}
161
+ self._reader_tasks: dict[int, asyncio.Task[None]] = {}
162
+ self._filter_dirty: set[Interface] = set(self._interfaces)
163
+ self._filter_retry_event = asyncio.Event()
164
+ self._filter_failures: dict[Interface, int] = {}
165
+ self._rng = random.Random(int.from_bytes(os.urandom(8), "little"))
166
+ self._node_id_occupancy = 1
167
+ self._local_node_id = self._rng.randrange(1, NODE_ID_CAPACITY)
168
+ self._collision_count = 0
169
+ self._subject_handlers: dict[int, Callable[[TransportArrival], None]] = {}
170
+ self._subject_writers: dict[int, _SubjectWriter] = {}
171
+ self._pinned_subjects: dict[int, _PinnedSubjectState] = {}
172
+ self._endpoints: dict[tuple[TransferKind, int], Endpoint] = {}
173
+ self._unicast_handler: Callable[[TransportArrival], None] | None = None
174
+ self._unicast_tid = [0] * NODE_ID_CAPACITY
175
+ self._filter_retry_task = self._loop.create_task(self._filter_retry_loop())
176
+ self._cleanup_task = self._loop.create_task(self._cleanup_loop())
177
+
178
+ self._install_unicast_endpoint()
179
+ for itf in self._interfaces:
180
+ self._reader_tasks[id(itf)] = self._loop.create_task(self._reader_loop(itf))
181
+ self._refresh_filters()
182
+ _logger.info(
183
+ "CAN transport init ifaces=%s fd=%s nid=%d", [itf.name for itf in self._interfaces], self._fd, self.id
184
+ )
185
+
186
+ @property
187
+ def closed(self) -> bool:
188
+ return self._closed
189
+
190
+ @property
191
+ def id(self) -> int:
192
+ return self._local_node_id
193
+
194
+ @property
195
+ def interfaces(self) -> list[Interface]:
196
+ return list(self._interfaces)
197
+
198
+ @property
199
+ def collision_count(self) -> int:
200
+ return self._collision_count
201
+
202
+ @property
203
+ def subject_id_modulus(self) -> int:
204
+ return SUBJECT_ID_MODULUS_16bit
205
+
206
+ def __repr__(self) -> str:
207
+ return f"CANTransport(id={self.id}, fd={self._fd}, interfaces={[itf.name for itf in self._interfaces]!r})"
208
+
209
+ def subject_listen(self, subject_id: int, handler: Callable[[TransportArrival], None]) -> Closable:
210
+ if not (0 <= subject_id <= SUBJECT_ID_MAX_16):
211
+ raise ValueError(f"Invalid subject-ID: {subject_id}")
212
+ if subject_id in self._subject_handlers:
213
+ raise ValueError(f"Subject {subject_id} already has an active listener")
214
+ self._subject_handlers[subject_id] = handler
215
+
216
+ def on_transfer_16(timestamp: Instant, remote_id: int, priority: Priority, payload: bytes) -> None:
217
+ handler(TransportArrival(timestamp, priority, remote_id, payload))
218
+
219
+ self._endpoints[(TransferKind.MESSAGE_16, subject_id)] = Endpoint(
220
+ kind=TransferKind.MESSAGE_16,
221
+ port_id=subject_id,
222
+ on_transfer=on_transfer_16,
223
+ )
224
+ if subject_id <= SUBJECT_ID_PINNED_MAX:
225
+ pinned = self._pinned_subjects.setdefault(subject_id, _PinnedSubjectState.new(subject_id))
226
+
227
+ def on_transfer_13(timestamp: Instant, remote_id: int, priority: Priority, payload: bytes) -> None:
228
+ handler(TransportArrival(timestamp, priority, remote_id, pinned.wrap(payload)))
229
+
230
+ self._endpoints[(TransferKind.MESSAGE_13, subject_id)] = Endpoint(
231
+ kind=TransferKind.MESSAGE_13,
232
+ port_id=subject_id,
233
+ on_transfer=on_transfer_13,
234
+ )
235
+ self._refresh_filters()
236
+ return _SubjectListener(self, subject_id, handler)
237
+
238
+ def subject_advertise(self, subject_id: int) -> SubjectWriter:
239
+ if not (0 <= subject_id <= SUBJECT_ID_MAX_16):
240
+ raise ValueError(f"Invalid subject-ID: {subject_id}")
241
+ if subject_id in self._subject_writers:
242
+ raise ValueError(f"Subject {subject_id} already has an active writer")
243
+ writer = _SubjectWriter(self, subject_id)
244
+ self._subject_writers[subject_id] = writer
245
+ return writer
246
+
247
+ def unicast_listen(self, handler: Callable[[TransportArrival], None]) -> None:
248
+ self._unicast_handler = handler
249
+
250
+ async def unicast(self, deadline: Instant, priority: Priority, remote_id: int, message: bytes | memoryview) -> None:
251
+ if self._closed:
252
+ raise ClosedError("CAN transport closed")
253
+ if not (1 <= remote_id <= NODE_ID_MAX):
254
+ raise ValueError(f"Invalid remote node-ID: {remote_id}")
255
+ transfer_id = self._unicast_tid[remote_id]
256
+ self._unicast_tid[remote_id] = (transfer_id + 1) % TRANSFER_ID_MODULO
257
+ await self.send_transfer(
258
+ deadline=deadline,
259
+ priority=priority,
260
+ kind=TransferKind.REQUEST,
261
+ port_id=UNICAST_SERVICE_ID,
262
+ payload=bytes(message),
263
+ transfer_id=transfer_id,
264
+ destination_id=remote_id,
265
+ )
266
+
267
+ async def send_transfer(
268
+ self,
269
+ *,
270
+ deadline: Instant,
271
+ priority: Priority,
272
+ kind: TransferKind,
273
+ port_id: int,
274
+ payload: bytes | memoryview,
275
+ transfer_id: int,
276
+ destination_id: int | None = None,
277
+ ) -> None:
278
+ if self._closed:
279
+ raise ClosedError("CAN transport closed")
280
+ if Instant.now().ns >= deadline.ns:
281
+ raise SendError("Deadline exceeded")
282
+ identifier, frames = serialize_transfer(
283
+ kind=kind,
284
+ priority=int(priority),
285
+ port_id=port_id,
286
+ source_id=self._local_node_id,
287
+ destination_id=destination_id,
288
+ payload=payload,
289
+ transfer_id=transfer_id,
290
+ fd=self._fd,
291
+ )
292
+ views = tuple(memoryview(frm) for frm in frames)
293
+ accepted = 0
294
+ errors: list[BaseException] = []
295
+ for itf in tuple(self._interfaces):
296
+ try:
297
+ itf.enqueue(identifier, views, deadline)
298
+ except ClosedError as ex:
299
+ errors.append(ex)
300
+ self._drop_interface(itf, ex)
301
+ except Exception as ex: # pragma: no cover - exercised via tests with injected failures
302
+ errors.append(ex)
303
+ _logger.debug("CAN iface %s tx rejected: %s", itf.name, ex)
304
+ else:
305
+ accepted += 1
306
+ if accepted > 0:
307
+ return
308
+ first_error = errors[0] if errors else None
309
+ if self._closed:
310
+ raise ClosedError("CAN transport closed") from first_error
311
+ raise SendError("CAN transfer rejected by all interfaces") from first_error
312
+
313
+ def remove_subject_listener(self, subject_id: int, handler: Callable[[TransportArrival], None]) -> None:
314
+ if self._subject_handlers.get(subject_id) is not handler:
315
+ return
316
+ self._subject_handlers.pop(subject_id, None)
317
+ self._endpoints.pop((TransferKind.MESSAGE_16, subject_id), None)
318
+ self._endpoints.pop((TransferKind.MESSAGE_13, subject_id), None)
319
+ self._pinned_subjects.pop(subject_id, None)
320
+ self._refresh_filters()
321
+
322
+ def remove_subject_writer(self, subject_id: int, writer: _SubjectWriter) -> None:
323
+ if self._subject_writers.get(subject_id) is writer:
324
+ self._subject_writers.pop(subject_id, None)
325
+
326
+ def close(self) -> None:
327
+ if self._closed:
328
+ return
329
+ self._closed = True
330
+ self._filter_retry_task.cancel()
331
+ self._cleanup_task.cancel()
332
+ for task in self._reader_tasks.values():
333
+ task.cancel()
334
+ self._reader_tasks.clear()
335
+ for itf in self._interfaces:
336
+ itf.close()
337
+ self._interfaces.clear()
338
+ self._filter_dirty.clear()
339
+ self._filter_failures.clear()
340
+ self._subject_handlers.clear()
341
+ self._subject_writers.clear()
342
+ self._pinned_subjects.clear()
343
+ self._endpoints.clear()
344
+ self._unicast_handler = None
345
+
346
+ async def _reader_loop(self, itf: Interface) -> None:
347
+ while not self._closed:
348
+ try:
349
+ frame = await itf.receive()
350
+ except asyncio.CancelledError:
351
+ raise
352
+ except Exception as ex:
353
+ if not self._closed:
354
+ self._drop_interface(itf, ex)
355
+ return
356
+ iface_index = self._interface_index.get(id(itf))
357
+ if iface_index is None:
358
+ return
359
+ self._ingest_frame(iface_index, frame)
360
+
361
+ def _drop_interface(self, itf: Interface, ex: BaseException) -> None:
362
+ if itf not in self._interfaces:
363
+ return
364
+ _logger.error("CAN iface %s failed and is being removed: %s", itf.name, ex)
365
+ self._interfaces.remove(itf)
366
+ self._interface_index.pop(id(itf), None)
367
+ self._filter_dirty.discard(itf)
368
+ self._filter_failures.pop(itf, None)
369
+ task = self._reader_tasks.pop(id(itf), None)
370
+ if task is not None and task is not asyncio.current_task():
371
+ task.cancel()
372
+ try:
373
+ itf.close()
374
+ except Exception: # pragma: no cover - defensive
375
+ _logger.exception("CAN iface %s close failed", itf.name)
376
+ if not self._interfaces:
377
+ _logger.critical("CAN transport closed because no interfaces remain")
378
+ self.close()
379
+
380
+ def _install_unicast_endpoint(self) -> None:
381
+ self._endpoints[(TransferKind.REQUEST, UNICAST_SERVICE_ID)] = Endpoint(
382
+ kind=TransferKind.REQUEST,
383
+ port_id=UNICAST_SERVICE_ID,
384
+ on_transfer=self._on_unicast_transfer,
385
+ )
386
+
387
+ def _on_unicast_transfer(self, timestamp: Instant, remote_id: int, priority: Priority, payload: bytes) -> None:
388
+ handler = self._unicast_handler
389
+ if handler is not None:
390
+ handler(TransportArrival(timestamp, priority, remote_id, payload))
391
+
392
+ def _current_filters(self) -> list[Filter]:
393
+ filters = [make_filter(TransferKind.REQUEST, UNICAST_SERVICE_ID, self._local_node_id)]
394
+ for subject_id in self._subject_handlers:
395
+ filters.append(make_filter(TransferKind.MESSAGE_16, subject_id, self._local_node_id))
396
+ if subject_id <= SUBJECT_ID_PINNED_MAX:
397
+ filters.append(make_filter(TransferKind.MESSAGE_13, subject_id, self._local_node_id))
398
+ return ensure_forced_filters(filters, self._local_node_id)
399
+
400
+ def _mark_filters_dirty(self, interfaces: Iterable[Interface] | None = None) -> None:
401
+ if interfaces is None:
402
+ self._filter_dirty.update(self._interfaces)
403
+ else:
404
+ self._filter_dirty.update(itf for itf in interfaces if itf in self._interfaces)
405
+
406
+ def _refresh_filters(self) -> None:
407
+ self._mark_filters_dirty()
408
+ self._apply_dirty_filters()
409
+ if self._filter_dirty:
410
+ self._filter_retry_event.set()
411
+
412
+ def _apply_dirty_filters(self) -> None:
413
+ if self._closed:
414
+ return
415
+ filters = self._current_filters()
416
+ for itf in tuple(self._filter_dirty):
417
+ if itf not in self._interfaces:
418
+ self._filter_dirty.discard(itf)
419
+ self._filter_failures.pop(itf, None)
420
+ continue
421
+ try:
422
+ itf.filter(filters)
423
+ except Exception as ex:
424
+ failures = self._filter_failures.get(itf, 0) + 1
425
+ self._filter_failures[itf] = failures
426
+ if failures == 1:
427
+ _logger.critical("CAN iface %s filter apply failed: %s", itf.name, ex)
428
+ else:
429
+ _logger.debug("CAN iface %s filter retry failed #%d: %s", itf.name, failures, ex)
430
+ else:
431
+ if self._filter_failures.pop(itf, None) is not None:
432
+ _logger.info("CAN iface %s filter apply recovered", itf.name)
433
+ self._filter_dirty.discard(itf)
434
+
435
+ async def _filter_retry_loop(self) -> None:
436
+ try:
437
+ while not self._closed:
438
+ if not self._filter_dirty:
439
+ self._filter_retry_event.clear()
440
+ await self._filter_retry_event.wait()
441
+ continue
442
+ self._apply_dirty_filters()
443
+ if not self._filter_dirty:
444
+ continue
445
+ attempts = max(self._filter_failures.get(itf, 1) for itf in self._filter_dirty)
446
+ delay = min(1.0, 0.05 * (2 ** min(attempts - 1, 4)))
447
+ self._filter_retry_event.clear()
448
+ try:
449
+ await asyncio.wait_for(self._filter_retry_event.wait(), timeout=delay)
450
+ except asyncio.TimeoutError:
451
+ pass
452
+ except asyncio.CancelledError:
453
+ raise
454
+
455
+ async def _cleanup_loop(self) -> None:
456
+ try:
457
+ while not self._closed:
458
+ await asyncio.sleep(1.0)
459
+ Reassembler.cleanup_sessions(self._endpoints.values(), Instant.now().ns)
460
+ except asyncio.CancelledError:
461
+ raise
462
+
463
+ def _ingest_frame(self, iface_index: int, frame: TimestampedFrame) -> None:
464
+ parsed_items = parse_frames(frame.id, frame.data, mtu=MTU_CAN_FD if self._fd else MTU_CAN_CLASSIC)
465
+ if not parsed_items:
466
+ _logger.debug("CAN drop malformed id=%08x len=%d", frame.id, len(frame.data))
467
+ return
468
+ for parsed in parsed_items:
469
+ if parsed.start_of_transfer:
470
+ self._node_id_occupancy_update(parsed.source_id)
471
+ endpoint = self._route_endpoint(parsed)
472
+ if endpoint is not None:
473
+ Reassembler.ingest(endpoint, iface_index, frame.timestamp, parsed)
474
+
475
+ def _route_endpoint(self, parsed: ParsedFrame) -> Endpoint | None:
476
+ if parsed.kind is TransferKind.MESSAGE_16:
477
+ return self._endpoints.get((TransferKind.MESSAGE_16, parsed.port_id))
478
+ if parsed.kind is TransferKind.MESSAGE_13:
479
+ return self._endpoints.get((TransferKind.MESSAGE_13, parsed.port_id))
480
+ if (
481
+ parsed.kind is TransferKind.REQUEST
482
+ and parsed.port_id == UNICAST_SERVICE_ID
483
+ and parsed.destination_id == self._local_node_id
484
+ ):
485
+ return self._endpoints.get((TransferKind.REQUEST, UNICAST_SERVICE_ID))
486
+ return None
487
+
488
+ def _purge_interfaces(self) -> None:
489
+ # REFERENCE PARITY: Because TX queues are backend-owned in this design,
490
+ # a node-ID collision drops each backend queue wholesale instead of preserving unstarted transfers.
491
+ for itf in tuple(self._interfaces):
492
+ try:
493
+ itf.purge()
494
+ except Exception as ex: # pragma: no cover - defensive
495
+ _logger.error("CAN iface %s purge failed: %s", itf.name, ex)
496
+
497
+ def _node_id_occupancy_update(self, source_id: int) -> None:
498
+ if source_id == NODE_ID_ANONYMOUS:
499
+ return
500
+ mask = 1 << source_id
501
+ if (self._node_id_occupancy & mask) and (self._local_node_id != source_id):
502
+ return
503
+ self._node_id_occupancy |= mask
504
+ population = self._node_id_occupancy.bit_count()
505
+ free_count = NODE_ID_CAPACITY - population
506
+ purge = free_count > 0 and population > (NODE_ID_CAPACITY // 2) and (self._rng.randrange(free_count) == 0)
507
+ if self._local_node_id == source_id:
508
+ if free_count > 0:
509
+ free_index = self._rng.randrange(free_count)
510
+ new_node_id = 0
511
+ while True:
512
+ if (self._node_id_occupancy & (1 << new_node_id)) == 0:
513
+ if free_index == 0:
514
+ break
515
+ free_index -= 1
516
+ new_node_id += 1
517
+ self._local_node_id = new_node_id
518
+ self._collision_count += 1
519
+ self._purge_interfaces()
520
+ self._refresh_filters()
521
+ _logger.warning("CAN node-ID collision detected, switched to %d", self._local_node_id)
522
+ else:
523
+ _logger.warning("CAN node-ID collision detected on %d but no free slot remains", source_id)
524
+ if purge:
525
+ self._node_id_occupancy = 1 | mask