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.
pycyphal2/can/_wire.py ADDED
@@ -0,0 +1,376 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum, auto
5
+ import struct
6
+ from typing import Iterable, Sequence
7
+
8
+ from .._hash import (
9
+ CRC16CCITT_FALSE_INITIAL,
10
+ CRC16CCITT_FALSE_RESIDUE,
11
+ crc16ccitt_false_add,
12
+ )
13
+ from ._interface import Filter
14
+
15
+ CAN_EXT_ID_MASK = (1 << 29) - 1
16
+ NODE_ID_MAX = 127
17
+ NODE_ID_ANONYMOUS = 0xFF
18
+ NODE_ID_CAPACITY = NODE_ID_MAX + 1
19
+ SUBJECT_ID_MAX_13 = 8191
20
+ SUBJECT_ID_MAX_16 = 0xFFFF
21
+ SERVICE_ID_MAX = 511
22
+ SERVICE_ID_MAX_V0 = 0xFF
23
+ PRIORITY_COUNT = 8
24
+ TRANSFER_ID_MODULO = 32
25
+ TRANSFER_ID_MAX = TRANSFER_ID_MODULO - 1
26
+ MTU_CAN_CLASSIC = 8
27
+ MTU_CAN_FD = 64
28
+ UNICAST_SERVICE_ID = 511
29
+ HEARTBEAT_SUBJECT_ID = 7509
30
+ LEGACY_NODE_STATUS_SUBJECT_ID = 341
31
+ TRANSFER_ID_TIMEOUT_NS = 2_000_000_000
32
+ RX_SESSION_TIMEOUT_NS = 30_000_000_000
33
+ RX_SESSION_RETENTION_NS = max(RX_SESSION_TIMEOUT_NS, TRANSFER_ID_TIMEOUT_NS)
34
+ CRC_INITIAL = CRC16CCITT_FALSE_INITIAL
35
+ CRC_RESIDUE = CRC16CCITT_FALSE_RESIDUE
36
+ CRC_BYTES = 2
37
+ TAIL_SOT = 0x80
38
+ TAIL_EOT = 0x40
39
+ TAIL_TOGGLE = 0x20
40
+ PRIO_SHIFT = 26
41
+ PADDING_BYTE = 0x00
42
+
43
+ DLC_TO_LENGTH: tuple[int, ...] = (0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 20, 24, 32, 48, 64)
44
+
45
+
46
+ def _make_length_to_dlc() -> tuple[int, ...]:
47
+ out = [0] * (MTU_CAN_FD + 1)
48
+ dlc = 0
49
+ for length in range(MTU_CAN_FD + 1):
50
+ while DLC_TO_LENGTH[dlc] < length:
51
+ dlc += 1
52
+ out[length] = dlc
53
+ return tuple(out)
54
+
55
+
56
+ LENGTH_TO_DLC = _make_length_to_dlc()
57
+
58
+
59
+ class TransferKind(Enum):
60
+ MESSAGE_16 = auto()
61
+ MESSAGE_13 = auto()
62
+ REQUEST = auto()
63
+ RESPONSE = auto()
64
+ V0_MESSAGE = auto()
65
+ V0_REQUEST = auto()
66
+ V0_RESPONSE = auto()
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class ParsedFrame:
71
+ kind: TransferKind
72
+ priority: int
73
+ port_id: int
74
+ source_id: int
75
+ destination_id: int | None
76
+ transfer_id: int
77
+ start_of_transfer: bool
78
+ end_of_transfer: bool
79
+ toggle: bool
80
+ payload: bytes
81
+
82
+
83
+ def crc_add_byte(crc: int, value: int) -> int:
84
+ return crc16ccitt_false_add(crc, bytes((value & 0xFF,)))
85
+
86
+
87
+ def crc_add(crc: int, data: bytes | bytearray | memoryview) -> int:
88
+ return crc16ccitt_false_add(crc, memoryview(data))
89
+
90
+
91
+ def make_tail_byte(start_of_transfer: bool, end_of_transfer: bool, toggle: bool, transfer_id: int) -> int:
92
+ return (
93
+ (TAIL_SOT if start_of_transfer else 0)
94
+ | (TAIL_EOT if end_of_transfer else 0)
95
+ | (TAIL_TOGGLE if toggle else 0)
96
+ | (transfer_id & TRANSFER_ID_MAX)
97
+ )
98
+
99
+
100
+ def ceil_frame_payload_size(size: int) -> int:
101
+ if not (0 <= size <= MTU_CAN_FD):
102
+ raise ValueError(f"Invalid frame payload size: {size}")
103
+ return DLC_TO_LENGTH[LENGTH_TO_DLC[size]]
104
+
105
+
106
+ def serialize_transfer(
107
+ kind: TransferKind,
108
+ priority: int,
109
+ port_id: int,
110
+ source_id: int,
111
+ payload: bytes | memoryview,
112
+ transfer_id: int,
113
+ *,
114
+ destination_id: int | None = None,
115
+ fd: bool = False,
116
+ ) -> tuple[int, list[bytes]]:
117
+ payload_bytes = bytes(payload)
118
+ mtu = MTU_CAN_FD if fd else MTU_CAN_CLASSIC
119
+ can_id = make_can_id(kind, priority, port_id, source_id, destination_id=destination_id)
120
+ toggle = True
121
+ if len(payload_bytes) < mtu:
122
+ frame_size = ceil_frame_payload_size(len(payload_bytes) + 1)
123
+ tail = bytes((make_tail_byte(True, True, toggle, transfer_id),))
124
+ return can_id, [payload_bytes + (bytes(frame_size - len(payload_bytes) - 1)) + tail]
125
+
126
+ size_with_crc = len(payload_bytes) + CRC_BYTES
127
+ crc = CRC_INITIAL
128
+ offset = 0
129
+ frames: list[bytes] = []
130
+ while offset < size_with_crc:
131
+ if (size_with_crc - offset) < (mtu - 1):
132
+ frame_size_with_tail = ceil_frame_payload_size((size_with_crc - offset) + 1)
133
+ else:
134
+ frame_size_with_tail = mtu
135
+ frame_size = frame_size_with_tail - 1
136
+ buf = bytearray(frame_size_with_tail)
137
+ frame_offset = 0
138
+ if offset < len(payload_bytes):
139
+ move_size = min(len(payload_bytes) - offset, frame_size)
140
+ buf[0:move_size] = payload_bytes[offset : offset + move_size]
141
+ crc = crc_add(crc, memoryview(buf)[:move_size])
142
+ frame_offset += move_size
143
+ offset += move_size
144
+ if offset >= len(payload_bytes):
145
+ while (frame_offset + CRC_BYTES) < frame_size:
146
+ buf[frame_offset] = PADDING_BYTE
147
+ crc = crc_add_byte(crc, PADDING_BYTE)
148
+ frame_offset += 1
149
+ if frame_offset < frame_size and offset == len(payload_bytes):
150
+ buf[frame_offset] = (crc >> 8) & 0xFF
151
+ frame_offset += 1
152
+ offset += 1
153
+ if frame_offset < frame_size and offset > len(payload_bytes):
154
+ buf[frame_offset] = crc & 0xFF
155
+ frame_offset += 1
156
+ offset += 1
157
+ assert frame_offset + 1 == frame_size_with_tail
158
+ buf[frame_offset] = make_tail_byte(len(frames) == 0, offset >= size_with_crc, toggle, transfer_id)
159
+ frames.append(bytes(buf))
160
+ toggle = not toggle
161
+ return can_id, frames
162
+
163
+
164
+ def parse_frame(identifier: int, data: bytes | memoryview, *, mtu: int = MTU_CAN_CLASSIC) -> ParsedFrame | None:
165
+ parsed = parse_frames(identifier, data, mtu=mtu)
166
+ for item in parsed:
167
+ if item.kind in (
168
+ TransferKind.MESSAGE_16,
169
+ TransferKind.MESSAGE_13,
170
+ TransferKind.REQUEST,
171
+ TransferKind.RESPONSE,
172
+ ):
173
+ return item
174
+ return parsed[0] if parsed else None
175
+
176
+
177
+ def parse_frames(identifier: int, data: bytes | memoryview, *, mtu: int = MTU_CAN_CLASSIC) -> tuple[ParsedFrame, ...]:
178
+ payload_raw = bytes(data)
179
+ if not (1 <= mtu <= MTU_CAN_FD):
180
+ raise ValueError(f"Invalid MTU: {mtu}")
181
+ if not (0 <= identifier <= CAN_EXT_ID_MASK):
182
+ return ()
183
+ if len(payload_raw) < 1:
184
+ return ()
185
+ tail = payload_raw[-1]
186
+ start = (tail & TAIL_SOT) != 0
187
+ end = (tail & TAIL_EOT) != 0
188
+ toggle = (tail & TAIL_TOGGLE) != 0
189
+ transfer_id = tail & TRANSFER_ID_MAX
190
+ payload = payload_raw[:-1]
191
+ payload_ok = (end or (len(payload_raw) >= MTU_CAN_CLASSIC)) and ((start and end) or (len(payload) > 0))
192
+ if not payload_ok:
193
+ return ()
194
+ priority = (identifier >> PRIO_SHIFT) & 0x07
195
+ source_id = identifier & NODE_ID_MAX
196
+ out: list[ParsedFrame] = []
197
+
198
+ if not (start and toggle):
199
+ service_v0 = (identifier & (1 << 7)) != 0
200
+ if service_v0:
201
+ destination_id = (identifier >> 8) & NODE_ID_MAX
202
+ port_id = (identifier >> 16) & SERVICE_ID_MAX_V0
203
+ request = (identifier & (1 << 15)) != 0
204
+ if destination_id != 0 and source_id != 0 and source_id != destination_id:
205
+ out.append(
206
+ ParsedFrame(
207
+ kind=TransferKind.V0_REQUEST if request else TransferKind.V0_RESPONSE,
208
+ priority=priority,
209
+ port_id=port_id,
210
+ source_id=source_id,
211
+ destination_id=destination_id,
212
+ transfer_id=transfer_id,
213
+ start_of_transfer=start,
214
+ end_of_transfer=end,
215
+ toggle=toggle,
216
+ payload=payload,
217
+ )
218
+ )
219
+ else:
220
+ source_id_v0 = NODE_ID_ANONYMOUS if source_id == 0 else source_id
221
+ if source_id_v0 != NODE_ID_ANONYMOUS or (start and end):
222
+ out.append(
223
+ ParsedFrame(
224
+ kind=TransferKind.V0_MESSAGE,
225
+ priority=priority,
226
+ port_id=(identifier >> 8) & SUBJECT_ID_MAX_16,
227
+ source_id=source_id_v0,
228
+ destination_id=None,
229
+ transfer_id=transfer_id,
230
+ start_of_transfer=start,
231
+ end_of_transfer=end,
232
+ toggle=toggle,
233
+ payload=payload,
234
+ )
235
+ )
236
+
237
+ if start and not toggle:
238
+ return tuple(out)
239
+ service = (identifier & (1 << 25)) != 0
240
+ bit_23 = (identifier & (1 << 23)) != 0
241
+ if service:
242
+ destination_id = (identifier >> 7) & NODE_ID_MAX
243
+ port_id = (identifier >> 14) & SERVICE_ID_MAX
244
+ request = (identifier & (1 << 24)) != 0
245
+ if not (bit_23 or (source_id == destination_id)):
246
+ out.append(
247
+ ParsedFrame(
248
+ kind=TransferKind.REQUEST if request else TransferKind.RESPONSE,
249
+ priority=priority,
250
+ port_id=port_id,
251
+ source_id=source_id,
252
+ destination_id=destination_id,
253
+ transfer_id=transfer_id,
254
+ start_of_transfer=start,
255
+ end_of_transfer=end,
256
+ toggle=toggle,
257
+ payload=payload,
258
+ )
259
+ )
260
+ return tuple(out)
261
+ destination_id_msg: int | None = None
262
+ if (identifier & (1 << 7)) != 0:
263
+ if (identifier & (1 << 24)) == 0:
264
+ out.append(
265
+ ParsedFrame(
266
+ kind=TransferKind.MESSAGE_16,
267
+ priority=priority,
268
+ port_id=(identifier >> 8) & SUBJECT_ID_MAX_16,
269
+ source_id=source_id,
270
+ destination_id=destination_id_msg,
271
+ transfer_id=transfer_id,
272
+ start_of_transfer=start,
273
+ end_of_transfer=end,
274
+ toggle=toggle,
275
+ payload=payload,
276
+ )
277
+ )
278
+ return tuple(out)
279
+ if bit_23:
280
+ return tuple(out)
281
+ anonymous = (identifier & (1 << 24)) != 0
282
+ if anonymous:
283
+ if not (start and end):
284
+ return tuple(out)
285
+ source_id = NODE_ID_ANONYMOUS
286
+ out.append(
287
+ ParsedFrame(
288
+ kind=TransferKind.MESSAGE_13,
289
+ priority=priority,
290
+ port_id=(identifier >> 8) & SUBJECT_ID_MAX_13,
291
+ source_id=source_id,
292
+ destination_id=destination_id_msg,
293
+ transfer_id=transfer_id,
294
+ start_of_transfer=start,
295
+ end_of_transfer=end,
296
+ toggle=toggle,
297
+ payload=payload,
298
+ )
299
+ )
300
+ return tuple(out)
301
+
302
+
303
+ def make_can_id(
304
+ kind: TransferKind, priority: int, port_id: int, source_id: int, destination_id: int | None = None
305
+ ) -> int:
306
+ if not (0 <= priority < PRIORITY_COUNT):
307
+ raise ValueError(f"Invalid priority: {priority}")
308
+ if not (0 <= source_id <= NODE_ID_MAX):
309
+ raise ValueError(f"Invalid source node-ID: {source_id}")
310
+ if kind is TransferKind.MESSAGE_16:
311
+ if not (0 <= port_id <= SUBJECT_ID_MAX_16):
312
+ raise ValueError(f"Invalid 16-bit subject-ID: {port_id}")
313
+ return (priority << PRIO_SHIFT) | (port_id << 8) | (1 << 7) | source_id
314
+ if kind is TransferKind.MESSAGE_13:
315
+ if not (0 <= port_id <= SUBJECT_ID_MAX_13):
316
+ raise ValueError(f"Invalid 13-bit subject-ID: {port_id}")
317
+ return (priority << PRIO_SHIFT) | (3 << 21) | (port_id << 8) | source_id
318
+ if kind in (TransferKind.V0_MESSAGE, TransferKind.V0_REQUEST, TransferKind.V0_RESPONSE):
319
+ raise ValueError(f"Legacy v0 TX is not supported: {kind}")
320
+ if destination_id is None or not (0 <= destination_id <= NODE_ID_MAX):
321
+ raise ValueError(f"Invalid destination node-ID: {destination_id}")
322
+ if not (0 <= port_id <= SERVICE_ID_MAX):
323
+ raise ValueError(f"Invalid service-ID: {port_id}")
324
+ request_not_response = 1 if kind is TransferKind.REQUEST else 0
325
+ if kind not in (TransferKind.REQUEST, TransferKind.RESPONSE):
326
+ raise ValueError(f"Unsupported transfer kind for service frame: {kind}")
327
+ return (
328
+ (priority << PRIO_SHIFT)
329
+ | (1 << 25)
330
+ | (request_not_response << 24)
331
+ | (port_id << 14)
332
+ | (destination_id << 7)
333
+ | source_id
334
+ )
335
+
336
+
337
+ def make_filter(kind: TransferKind, port_id: int, local_node_id: int) -> Filter:
338
+ if not (0 <= local_node_id <= NODE_ID_MAX):
339
+ raise ValueError(f"Invalid local node-ID: {local_node_id}")
340
+ if kind is TransferKind.MESSAGE_16:
341
+ return Filter(id=(port_id << 8) | (1 << 7), mask=0x03FFFF80)
342
+ if kind is TransferKind.MESSAGE_13:
343
+ return Filter(id=port_id << 8, mask=0x029FFF80)
344
+ if kind is TransferKind.V0_MESSAGE:
345
+ return Filter(id=port_id << 8, mask=0x00FFFF80)
346
+ if kind in (TransferKind.REQUEST, TransferKind.RESPONSE):
347
+ request_bit = 1 << 24 if kind is TransferKind.REQUEST else 0
348
+ return Filter(id=(1 << 25) | request_bit | (port_id << 14) | (local_node_id << 7), mask=0x03FFFF80)
349
+ if kind in (TransferKind.V0_REQUEST, TransferKind.V0_RESPONSE):
350
+ request_bit = 1 << 15 if kind is TransferKind.V0_REQUEST else 0
351
+ return Filter(id=((port_id & 0xFF) << 16) | request_bit | (local_node_id << 8) | (1 << 7), mask=0x00FFFF80)
352
+ raise ValueError(f"Unsupported transfer kind: {kind}")
353
+
354
+
355
+ def match_filters(filters: Sequence[Filter], identifier: int) -> bool:
356
+ return any((identifier & flt.mask) == (flt.id & flt.mask) for flt in filters)
357
+
358
+
359
+ def ensure_forced_filters(filters: Iterable[Filter], local_node_id: int) -> list[Filter]:
360
+ out = list(filters)
361
+ forced = (
362
+ make_filter(TransferKind.MESSAGE_13, HEARTBEAT_SUBJECT_ID, local_node_id),
363
+ make_filter(TransferKind.V0_MESSAGE, LEGACY_NODE_STATUS_SUBJECT_ID, local_node_id),
364
+ )
365
+ for flt in forced:
366
+ if not match_filters(out, flt.id):
367
+ out.append(flt)
368
+ return out
369
+
370
+
371
+ def pack_u32_le(value: int) -> bytes:
372
+ return struct.pack("<I", value & 0xFFFFFFFF)
373
+
374
+
375
+ def pack_u64_le(value: int) -> bytes:
376
+ return struct.pack("<Q", value & 0xFFFFFFFFFFFFFFFF)
@@ -0,0 +1,261 @@
1
+ """
2
+ Cross-platform CAN backend using `python-can <https://python-can.readthedocs.io/>`_.
3
+
4
+ This module exposes :class:`PythonCANInterface`, which adapts an existing :class:`can.BusABC`
5
+ instance to :mod:`pycyphal2.can`. Install the optional dependency with ``pycyphal2[pythoncan]``.
6
+
7
+ The application is responsible for creating and configuring the underlying python-can bus
8
+ (backend, channel, bitrate, FD mode, vendor-specific options, etc.) before wrapping it here.
9
+ This backend is a good fit when the application already uses python-can directly or needs
10
+ one of its cross-platform hardware integrations.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ from collections.abc import Iterable
17
+ import logging
18
+ import threading
19
+
20
+ from .._api import ClosedError, Instant
21
+ from ._interface import Filter, Interface, TimestampedFrame
22
+
23
+ try:
24
+ import can
25
+ except ImportError:
26
+ raise ImportError("PythonCAN backend requires python-can: pip install 'pycyphal2[pythoncan]'") from None
27
+
28
+ _logger = logging.getLogger(__name__)
29
+
30
+ _RX_POLL_TIMEOUT = 0.1
31
+ _CAN_EXT_ID_MASK = (1 << 29) - 1
32
+
33
+
34
+ class PythonCANInterface(Interface):
35
+ """
36
+ Wraps a `python-can <https://python-can.readthedocs.io/>`_ bus as a :class:`pycyphal2.can.Interface`.
37
+
38
+ The caller is responsible for constructing and configuring the :class:`can.BusABC` instance
39
+ (bitrate, interface type, channel, FD mode, etc.) and passing it in.
40
+ Use :class:`can.ThreadSafeBus` for safe concurrent access from the RX thread and TX executor.
41
+
42
+ The ``fd`` flag may be left as ``None``; in that case, FD capability is detected
43
+ from ``bus.protocol`` (see :class:`can.CanProtocol`), defaulting to Classic CAN
44
+ if the bus does not report FD support.
45
+ """
46
+
47
+ def __init__(self, bus: can.BusABC, *, fd: bool | None = None) -> None:
48
+ self._bus = bus
49
+ self._name = getattr(bus, "channel_info", repr(bus))
50
+ if fd is None:
51
+ fd = bus.protocol in (can.CanProtocol.CAN_FD, can.CanProtocol.CAN_FD_NON_ISO)
52
+ self._fd = fd
53
+ self._closed = False
54
+ self._failure: BaseException | None = None
55
+ self._tx_seq = 0
56
+ self._tx_queue: asyncio.PriorityQueue[tuple[int, int, int, bytes]] = asyncio.PriorityQueue()
57
+ self._tx_task: asyncio.Task[None] | None = None
58
+ self._rx_queue: asyncio.Queue[TimestampedFrame | BaseException] = asyncio.Queue()
59
+ self._loop = asyncio.get_running_loop()
60
+ self._admin_lock = threading.Lock()
61
+ self._rx_gate = threading.Condition()
62
+ self._rx_pause_requested = False
63
+ self._rx_paused = False
64
+ self._rx_thread = threading.Thread(target=self._rx_thread_func, daemon=True, name=f"pythoncan-rx-{self._name}")
65
+ self._rx_thread.start()
66
+ _logger.info("PythonCAN init iface=%s fd=%s", self._name, self._fd)
67
+
68
+ @property
69
+ def name(self) -> str:
70
+ return self._name
71
+
72
+ @property
73
+ def fd(self) -> bool:
74
+ return self._fd
75
+
76
+ def filter(self, filters: Iterable[Filter]) -> None:
77
+ self._raise_if_closed()
78
+ can_filters: list[can.typechecking.CanFilter] = []
79
+ for item in filters:
80
+ can_filters.append(can.typechecking.CanFilter(can_id=item.id, can_mask=item.mask, extended=True))
81
+ try:
82
+ with self._admin_lock:
83
+ self._raise_if_closed()
84
+ self._pause_rx_for_admin()
85
+ try:
86
+ # ThreadSafeBus serializes recv() and set_filters() on the same receive lock,
87
+ # so the RX loop must be quiesced before reconfiguring filters.
88
+ self._bus.set_filters(can_filters)
89
+ finally:
90
+ self._resume_rx_for_admin()
91
+ except can.CanError as ex:
92
+ raise OSError(f"PythonCAN filter configuration failed on {self._name}: {ex}") from ex
93
+ _logger.debug("PythonCAN filters set iface=%s n=%d", self._name, len(can_filters))
94
+
95
+ def enqueue(self, id: int, data: Iterable[memoryview], deadline: Instant) -> None:
96
+ self._raise_if_closed()
97
+ if self._tx_task is None:
98
+ self._tx_task = self._loop.create_task(self._tx_loop())
99
+ for chunk in data:
100
+ self._tx_seq += 1
101
+ self._tx_queue.put_nowait((id, self._tx_seq, deadline.ns, bytes(chunk)))
102
+
103
+ def purge(self) -> None:
104
+ if self._closed:
105
+ return
106
+ dropped = 0
107
+ try:
108
+ while True:
109
+ self._tx_queue.get_nowait()
110
+ dropped += 1
111
+ except asyncio.QueueEmpty:
112
+ pass
113
+ if dropped > 0:
114
+ _logger.debug("PythonCAN purge iface=%s dropped=%d", self._name, dropped)
115
+
116
+ async def receive(self) -> TimestampedFrame:
117
+ self._raise_if_closed()
118
+ while True:
119
+ item = await self._rx_queue.get()
120
+ if isinstance(item, BaseException):
121
+ self._fail(item)
122
+ raise ClosedError(f"PythonCAN interface {self._name} receive failed") from item
123
+ return item
124
+
125
+ def close(self) -> None:
126
+ with self._admin_lock:
127
+ if self._closed:
128
+ return
129
+ self._pause_rx_for_admin()
130
+ self._closed = True
131
+ if self._tx_task is not None:
132
+ self._tx_task.cancel()
133
+ self._tx_task = None
134
+ try:
135
+ self._rx_queue.put_nowait(ClosedError(f"PythonCAN interface {self._name} closed"))
136
+ except Exception:
137
+ pass
138
+ try:
139
+ self._bus.shutdown()
140
+ except Exception as ex:
141
+ _logger.debug("PythonCAN bus shutdown error on %s: %s", self._name, ex)
142
+ finally:
143
+ self._resume_rx_for_admin()
144
+
145
+ def __repr__(self) -> str:
146
+ return f"{type(self).__name__}({self._name!r}, fd={self._fd})"
147
+
148
+ async def _tx_loop(self) -> None:
149
+ # Deadlines are enforced when popping from the queue. Once a frame is handed to bus.send(),
150
+ # the deadline is passed as the blocking timeout but cannot be enforced further by us.
151
+ loop = asyncio.get_running_loop()
152
+ while not self._closed:
153
+ try:
154
+ identifier, _seq, deadline_ns, payload = await self._tx_queue.get()
155
+ except asyncio.CancelledError:
156
+ raise
157
+ if self._closed:
158
+ 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
+ if timeout <= 0.0:
164
+ _logger.debug("PythonCAN tx drop expired iface=%s id=%08x", self._name, identifier)
165
+ continue
166
+ msg = can.Message(
167
+ arbitration_id=identifier,
168
+ is_extended_id=True,
169
+ data=payload,
170
+ is_fd=self._fd and len(payload) > 8,
171
+ bitrate_switch=self._fd and len(payload) > 8,
172
+ )
173
+ try:
174
+ 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:
180
+ _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
183
+ await asyncio.sleep(0.001)
184
+ except OSError as ex:
185
+ self._fail(ex)
186
+ return
187
+
188
+ def _rx_thread_func(self) -> None:
189
+ try:
190
+ while True:
191
+ with self._rx_gate:
192
+ if self._rx_pause_requested:
193
+ self._rx_paused = True
194
+ self._rx_gate.notify_all()
195
+ self._rx_gate.wait_for(lambda: not self._rx_pause_requested or self._closed)
196
+ self._rx_paused = False
197
+ self._rx_gate.notify_all()
198
+ if self._closed:
199
+ return
200
+ try:
201
+ msg = self._bus.recv(timeout=_RX_POLL_TIMEOUT)
202
+ except Exception as ex:
203
+ if not self._closed:
204
+ try:
205
+ self._loop.call_soon_threadsafe(self._rx_queue.put_nowait, ex)
206
+ except RuntimeError:
207
+ pass
208
+ return
209
+ if msg is None:
210
+ continue
211
+ try:
212
+ frame = _parse_message(msg)
213
+ except Exception as ex:
214
+ _logger.debug("PythonCAN rx drop malformed: %s", ex)
215
+ continue
216
+ if frame is not None:
217
+ try:
218
+ self._loop.call_soon_threadsafe(self._rx_queue.put_nowait, frame)
219
+ except RuntimeError:
220
+ return
221
+ finally:
222
+ with self._rx_gate:
223
+ self._rx_paused = False
224
+ self._rx_gate.notify_all()
225
+
226
+ def _fail(self, ex: BaseException) -> None:
227
+ if self._failure is None:
228
+ self._failure = ex
229
+ _logger.error("PythonCAN interface %s failed: %s", self._name, ex)
230
+ self.close()
231
+
232
+ def _raise_if_closed(self) -> None:
233
+ if self._closed:
234
+ if self._failure is not None:
235
+ raise ClosedError(f"PythonCAN interface {self._name} failed") from self._failure
236
+ raise ClosedError(f"PythonCAN interface {self._name} closed")
237
+
238
+ def _pause_rx_for_admin(self) -> None:
239
+ with self._rx_gate:
240
+ self._rx_pause_requested = True
241
+ self._rx_gate.notify_all()
242
+ self._rx_gate.wait_for(lambda: self._rx_paused or not self._rx_thread.is_alive())
243
+
244
+ def _resume_rx_for_admin(self) -> None:
245
+ with self._rx_gate:
246
+ self._rx_pause_requested = False
247
+ self._rx_gate.notify_all()
248
+ self._rx_gate.wait_for(lambda: not self._rx_paused or not self._rx_thread.is_alive())
249
+
250
+
251
+ def _parse_message(msg: can.Message) -> TimestampedFrame | None:
252
+ if msg.is_error_frame:
253
+ _logger.debug("PythonCAN drop error frame id=%08x", msg.arbitration_id)
254
+ return None
255
+ if not msg.is_extended_id:
256
+ _logger.debug("PythonCAN drop non-extended id=%08x", msg.arbitration_id)
257
+ return None
258
+ if msg.is_remote_frame:
259
+ _logger.debug("PythonCAN drop remote frame id=%08x", msg.arbitration_id)
260
+ return None
261
+ return TimestampedFrame(id=msg.arbitration_id & _CAN_EXT_ID_MASK, data=bytes(msg.data), timestamp=Instant.now())