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/__init__.py +89 -0
- pycyphal2/_api.py +604 -0
- pycyphal2/_hash.py +204 -0
- pycyphal2/_header.py +349 -0
- pycyphal2/_node.py +1472 -0
- pycyphal2/_publisher.py +427 -0
- pycyphal2/_subscriber.py +430 -0
- pycyphal2/_transport.py +92 -0
- pycyphal2/can/__init__.py +43 -0
- pycyphal2/can/_interface.py +131 -0
- pycyphal2/can/_reassembly.py +158 -0
- pycyphal2/can/_transport.py +525 -0
- pycyphal2/can/_wire.py +376 -0
- pycyphal2/can/pythoncan.py +261 -0
- pycyphal2/can/socketcan.py +225 -0
- pycyphal2/py.typed +0 -0
- pycyphal2/udp.py +1000 -0
- pycyphal2-2.0.0.dev0.dist-info/METADATA +58 -0
- pycyphal2-2.0.0.dev0.dist-info/RECORD +22 -0
- pycyphal2-2.0.0.dev0.dist-info/WHEEL +5 -0
- pycyphal2-2.0.0.dev0.dist-info/licenses/LICENSE +20 -0
- pycyphal2-2.0.0.dev0.dist-info/top_level.txt +1 -0
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())
|