python-can-flexible-serial 0.1.0__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.
- flexible_serial/__init__.py +5 -0
- flexible_serial/bus.py +780 -0
- python_can_flexible_serial-0.1.0.dist-info/METADATA +261 -0
- python_can_flexible_serial-0.1.0.dist-info/RECORD +8 -0
- python_can_flexible_serial-0.1.0.dist-info/WHEEL +5 -0
- python_can_flexible_serial-0.1.0.dist-info/entry_points.txt +2 -0
- python_can_flexible_serial-0.1.0.dist-info/licenses/LICENSE +21 -0
- python_can_flexible_serial-0.1.0.dist-info/top_level.txt +1 -0
flexible_serial/bus.py
ADDED
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A python-can interface plugin for a configurable, framed binary protocol
|
|
3
|
+
over a serial port. For example use over serial ports like "/dev/ttyS1" or
|
|
4
|
+
"/dev/ttyUSB0" on Linux machines or "COM1" on Windows.
|
|
5
|
+
|
|
6
|
+
Unlike python-can's fixed-layout ``serial``/``slcan`` interfaces, the frame
|
|
7
|
+
layout (SOF/EOF delimiters, ID width, optional CRC, optional flags byte,
|
|
8
|
+
optional timestamp) is configurable via constructor kwargs so it can be
|
|
9
|
+
matched to firmware on the other end of the link. See the README for the
|
|
10
|
+
full frame layout and kwarg reference, and
|
|
11
|
+
https://python-can.readthedocs.io/en/stable/plugin-interface.html for the
|
|
12
|
+
python-can plugin interface this implements.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import io
|
|
16
|
+
import logging
|
|
17
|
+
import struct
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from typing import TypedDict, Callable, Any, Optional, cast
|
|
20
|
+
import binascii
|
|
21
|
+
import time
|
|
22
|
+
from enum import IntEnum
|
|
23
|
+
|
|
24
|
+
from can import (
|
|
25
|
+
BusABC,
|
|
26
|
+
CanInitializationError,
|
|
27
|
+
CanInterfaceNotImplementedError,
|
|
28
|
+
CanOperationError,
|
|
29
|
+
CanProtocol,
|
|
30
|
+
CanTimeoutError,
|
|
31
|
+
Message,
|
|
32
|
+
)
|
|
33
|
+
from can.typechecking import AutoDetectedConfig
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger("can.flexible_serial")
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
import serial.tools.list_ports
|
|
39
|
+
except ImportError:
|
|
40
|
+
logger.warning(
|
|
41
|
+
"You won't be able to use the serial can backend without "
|
|
42
|
+
"the `pyserial` package installed!"
|
|
43
|
+
)
|
|
44
|
+
serial = None
|
|
45
|
+
|
|
46
|
+
# move these into the class
|
|
47
|
+
CAN_ERR_FLAG = 0x20000000
|
|
48
|
+
CAN_RTR_FLAG = 0x40000000
|
|
49
|
+
CAN_EFF_FLAG = 0x80000000
|
|
50
|
+
CAN_ID_MASK_EXT = 0x1FFFFFFF
|
|
51
|
+
CAN_ID_MASK_STD = 0x7FF
|
|
52
|
+
|
|
53
|
+
FLAG_BYTE_ERROR = 0x01
|
|
54
|
+
FLAG_BYTE_RTR = 0x02
|
|
55
|
+
FLAG_BYTE_EXTENDED = 0x04
|
|
56
|
+
FLAG_BYTE_FD = 0x08
|
|
57
|
+
FLAG_BYTE_FD_BRS = 0x10
|
|
58
|
+
FLAG_BYTE_IS_RX = 0x20
|
|
59
|
+
|
|
60
|
+
MAX_DATA_LEN_CLASSIC = 8
|
|
61
|
+
MAX_DATA_LEN_FD = 64
|
|
62
|
+
|
|
63
|
+
'''
|
|
64
|
+
Frame Layout (see README.md for the full reference):
|
|
65
|
+
- SOF -> 1 or more bytes
|
|
66
|
+
HEADER
|
|
67
|
+
- timestamp -> options: 0 or 4 bytes, uint32 milliseconds
|
|
68
|
+
- dlc -> 1 byte
|
|
69
|
+
- flags byte -> options 0 or 1 byte
|
|
70
|
+
- id -> options 2 or 4 bytes
|
|
71
|
+
DATA PAYLOAD -> 0 - 64 bytes
|
|
72
|
+
- CRC -> optional (0 or some num of bytes)
|
|
73
|
+
- EOF -> 1 or more bytes
|
|
74
|
+
'''
|
|
75
|
+
|
|
76
|
+
class TimeoutTimer():
|
|
77
|
+
def __init__(self, timeout: Optional[float]):
|
|
78
|
+
if timeout is None:
|
|
79
|
+
self._is_infinite = True
|
|
80
|
+
self._timeout = 0.0
|
|
81
|
+
else:
|
|
82
|
+
self._is_infinite = False
|
|
83
|
+
self._timeout = max(0.0, timeout)
|
|
84
|
+
self._start_time = time.monotonic()
|
|
85
|
+
|
|
86
|
+
def expired(self) -> bool:
|
|
87
|
+
if self._is_infinite:
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
return (time.monotonic() - self._start_time) >= self._timeout
|
|
91
|
+
|
|
92
|
+
def remaining_time(self) -> float | None:
|
|
93
|
+
if self._is_infinite:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
elapsed = time.monotonic() - self._start_time
|
|
97
|
+
return max(0.0, self._timeout - elapsed)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class FlexibleSerial(BusABC):
|
|
101
|
+
"""
|
|
102
|
+
Enable basic can communication over a serial device using a flexible binary protocol
|
|
103
|
+
|
|
104
|
+
.. note:: See :meth:`~_recv_internal` for some special semantics.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
class SerialError(IntEnum):
|
|
108
|
+
NONE = 0
|
|
109
|
+
DESYNC = 1
|
|
110
|
+
BAD_DLC = 2
|
|
111
|
+
BAD_EOF = 3
|
|
112
|
+
BAD_CRC = 4
|
|
113
|
+
|
|
114
|
+
ERROR_TEXT = {
|
|
115
|
+
SerialError.DESYNC: "Start-of-Frame desync",
|
|
116
|
+
SerialError.BAD_DLC: f"DataLength Invalid (>{MAX_DATA_LEN_FD} bytes)",
|
|
117
|
+
SerialError.BAD_EOF: "End-of-Frame mismatch",
|
|
118
|
+
SerialError.BAD_CRC: "CRC Error",
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
CRC_TYPES = frozenset({"none","crc16-ccitt"})
|
|
122
|
+
|
|
123
|
+
class FrameKwargs(TypedDict):
|
|
124
|
+
"""Constructor kwargs controlling the wire frame layout.
|
|
125
|
+
|
|
126
|
+
:ivar big_endian: Byte order for header/CRC fields.
|
|
127
|
+
:ivar sof: Start-of-frame delimiter (bytes, or hex string from CLI kwargs).
|
|
128
|
+
:ivar eof: End-of-frame delimiter (bytes, or hex string from CLI kwargs).
|
|
129
|
+
:ivar uint16_id: Use a 2-byte arbitration ID instead of 4-byte; rejects extended IDs.
|
|
130
|
+
:ivar crc_type: One of :attr:`CRC_TYPES` (``"none"``, ``"crc16-ccitt"``).
|
|
131
|
+
:ivar flags_byte: Carry extended/error/remote/FD metadata in a dedicated header byte.
|
|
132
|
+
:ivar no_timestamp: Omit the 4-byte (uint32 milliseconds) timestamp header field.
|
|
133
|
+
:ivar rx_bytes_callback: ``callback(raw_bytes, is_rx)`` invoked for every
|
|
134
|
+
frame written or chunk of buffer consumed while reading; useful for
|
|
135
|
+
logging/debugging raw wire traffic.
|
|
136
|
+
"""
|
|
137
|
+
big_endian: bool
|
|
138
|
+
sof: bytes
|
|
139
|
+
eof: bytes
|
|
140
|
+
uint16_id: bool
|
|
141
|
+
crc_type: str
|
|
142
|
+
flags_byte: bool
|
|
143
|
+
no_timestamp: bool
|
|
144
|
+
rx_bytes_callback: Callable | None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
DEFAULT_FRAME_KWARGS: FrameKwargs = {
|
|
148
|
+
"big_endian": False,
|
|
149
|
+
"sof": b"\xAA",
|
|
150
|
+
"eof": b"\xBB",
|
|
151
|
+
"uint16_id": False,
|
|
152
|
+
"crc_type": "none",
|
|
153
|
+
"flags_byte": False,
|
|
154
|
+
"no_timestamp": False,
|
|
155
|
+
"rx_bytes_callback": None
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
DataProcessedCallback = Callable[[bytes, bool], None]
|
|
159
|
+
''' Provides arguments [data bytes, is_rx] '''
|
|
160
|
+
|
|
161
|
+
def __init__(
|
|
162
|
+
self,
|
|
163
|
+
channel: str,
|
|
164
|
+
baudrate: int = 115200,
|
|
165
|
+
timeout: float = 0.1,
|
|
166
|
+
rtscts: bool = False,
|
|
167
|
+
**kwargs: Any,
|
|
168
|
+
) -> None:
|
|
169
|
+
"""
|
|
170
|
+
:param channel:
|
|
171
|
+
The serial device to open. For example "/dev/ttyS1" or
|
|
172
|
+
"/dev/ttyUSB0" on Linux or "COM1" on Windows systems.
|
|
173
|
+
|
|
174
|
+
:param baudrate:
|
|
175
|
+
Baud rate of the serial device in bit/s (default 115200).
|
|
176
|
+
|
|
177
|
+
.. warning::
|
|
178
|
+
Some serial port implementations don't care about the baudrate.
|
|
179
|
+
|
|
180
|
+
:param timeout:
|
|
181
|
+
Timeout for the serial device read calls in seconds (default 0.1).
|
|
182
|
+
|
|
183
|
+
:param rtscts:
|
|
184
|
+
Turn hardware handshake (RTS/CTS) on and off (default false)
|
|
185
|
+
|
|
186
|
+
:param kwargs:
|
|
187
|
+
Frame-layout options (see :class:`FrameKwargs` and
|
|
188
|
+
:attr:`DEFAULT_FRAME_KWARGS` for defaults), plus any remaining
|
|
189
|
+
kwargs passed to the base class constructor, see
|
|
190
|
+
:meth:`can.BusABC.__init__`. All frame-layout kwargs accept
|
|
191
|
+
hex strings (e.g. ``"0xAA"``) in addition to their native type,
|
|
192
|
+
since python-can CLI tools pass ``--bus-kwargs`` as strings.
|
|
193
|
+
|
|
194
|
+
:raises ~can.exceptions.CanInitializationError:
|
|
195
|
+
If the given parameters are invalid.
|
|
196
|
+
:raises TypeError: If channel is empty/None
|
|
197
|
+
:raises ~can.exceptions.CanInterfaceNotImplementedError:
|
|
198
|
+
If the serial module is not installed.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
if not serial:
|
|
202
|
+
raise CanInterfaceNotImplementedError("the serial module is not installed")
|
|
203
|
+
|
|
204
|
+
if not channel:
|
|
205
|
+
raise TypeError("Must specify a serial port.")
|
|
206
|
+
|
|
207
|
+
self.channel = channel
|
|
208
|
+
self.channel_info = f"Flexible serial interface: {channel}"
|
|
209
|
+
self._can_protocol = CanProtocol.CAN_20
|
|
210
|
+
|
|
211
|
+
self._rx_buffer = bytearray()
|
|
212
|
+
|
|
213
|
+
self._sof = self._coerce_bytes_kwarg(
|
|
214
|
+
"sof",
|
|
215
|
+
kwargs.pop("sof", self.DEFAULT_FRAME_KWARGS["sof"]),
|
|
216
|
+
)
|
|
217
|
+
self._eof = self._coerce_bytes_kwarg(
|
|
218
|
+
"eof",
|
|
219
|
+
kwargs.pop("eof", self.DEFAULT_FRAME_KWARGS["eof"]),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
big_endian = self._pop_bool_kwarg(kwargs, "big_endian")
|
|
223
|
+
no_timestamp = self._pop_bool_kwarg(kwargs, "no_timestamp")
|
|
224
|
+
uint16_id = self._pop_bool_kwarg(kwargs, "uint16_id")
|
|
225
|
+
flags_byte = self._pop_bool_kwarg(kwargs, "flags_byte")
|
|
226
|
+
|
|
227
|
+
self._configure_header(
|
|
228
|
+
big_endian=big_endian,
|
|
229
|
+
no_timestamp=no_timestamp,
|
|
230
|
+
uint16_id=uint16_id,
|
|
231
|
+
flags_byte=flags_byte,
|
|
232
|
+
)
|
|
233
|
+
self._configure_crc(big_endian=big_endian, kwargs=kwargs)
|
|
234
|
+
self._configure_frame_offsets(no_timestamp=no_timestamp)
|
|
235
|
+
|
|
236
|
+
# Register user callback for when serial bytes are received/transmitted
|
|
237
|
+
self._pop_rx_bytes_callback(kwargs)
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
self._ser = serial.serial_for_url(
|
|
241
|
+
channel, baudrate=baudrate, timeout=timeout, rtscts=rtscts
|
|
242
|
+
)
|
|
243
|
+
except ValueError as error:
|
|
244
|
+
raise CanInitializationError(
|
|
245
|
+
"Could not create the serial device"
|
|
246
|
+
) from error
|
|
247
|
+
|
|
248
|
+
logger.info(f"Opened Serial Port {channel} with frame settings:\r\n"
|
|
249
|
+
f"\tEndianness: {"big" if big_endian else "little"}\r\n"
|
|
250
|
+
f"\tSOF: {self._sof}\r\n"
|
|
251
|
+
f"\tEOF: {self._eof}\r\n"
|
|
252
|
+
f"\tTimestamp: {not no_timestamp}\r\n"
|
|
253
|
+
f"\tFlags Byte: {flags_byte}\r\n"
|
|
254
|
+
f"\tID Length: {2 if uint16_id else 4} bytes\r\n"
|
|
255
|
+
f"\tCRC: {self._crc_type}",
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
super().__init__(channel, **kwargs)
|
|
259
|
+
|
|
260
|
+
def shutdown(self) -> None:
|
|
261
|
+
"""
|
|
262
|
+
Close the serial interface.
|
|
263
|
+
"""
|
|
264
|
+
super().shutdown()
|
|
265
|
+
self._ser.close()
|
|
266
|
+
|
|
267
|
+
def send(self, msg: Message, timeout: Optional[float] = None) -> None:
|
|
268
|
+
"""
|
|
269
|
+
Send a message over the serial device.
|
|
270
|
+
|
|
271
|
+
:param msg:
|
|
272
|
+
Message to send.
|
|
273
|
+
|
|
274
|
+
.. note:: If the timestamp is a float value it will be converted
|
|
275
|
+
to an integer.
|
|
276
|
+
|
|
277
|
+
:param timeout:
|
|
278
|
+
This parameter will be ignored. The timeout value of the channel is
|
|
279
|
+
used instead.
|
|
280
|
+
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
# Reject extended frames if uint16 ID is selected
|
|
284
|
+
if self._id_is_uint16_t and msg.is_extended_id:
|
|
285
|
+
raise ValueError("uint16_id=True does not support extended CAN IDs")
|
|
286
|
+
|
|
287
|
+
# Mask ID, optionally placing error flags into ID field if its extended AND there is flags byte
|
|
288
|
+
arbitration_id = msg.arbitration_id & (CAN_ID_MASK_EXT if msg.is_extended_id else CAN_ID_MASK_STD)
|
|
289
|
+
if not self._has_flags_byte and not self._id_is_uint16_t:
|
|
290
|
+
if msg.is_extended_id:
|
|
291
|
+
arbitration_id |= CAN_EFF_FLAG
|
|
292
|
+
if msg.is_error_frame:
|
|
293
|
+
arbitration_id |= CAN_ERR_FLAG
|
|
294
|
+
if msg.is_remote_frame:
|
|
295
|
+
arbitration_id |= CAN_RTR_FLAG
|
|
296
|
+
|
|
297
|
+
# Generate timestamp even when frame format options omits it
|
|
298
|
+
timestamp_int = max(0, min(int(msg.timestamp * 1000), 0xFFFFFFFF))
|
|
299
|
+
|
|
300
|
+
# Construct frame
|
|
301
|
+
frame = bytearray(self._sof)
|
|
302
|
+
frame += self._header_pack(timestamp_int, msg, arbitration_id)
|
|
303
|
+
frame += msg.data
|
|
304
|
+
frame += self._crc_calculate(memoryview(frame)[self._crc_calc_start_offset:])
|
|
305
|
+
frame += self._eof
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
if timeout != self._ser.write_timeout:
|
|
309
|
+
self._ser.write_timeout = timeout
|
|
310
|
+
self._ser.write(frame)
|
|
311
|
+
if self._on_data_processed_callback:
|
|
312
|
+
self._on_data_processed_callback(frame, False)
|
|
313
|
+
except serial.PortNotOpenError as error:
|
|
314
|
+
raise CanOperationError("writing to closed port") from error
|
|
315
|
+
except serial.SerialTimeoutException as error:
|
|
316
|
+
raise CanTimeoutError() from error
|
|
317
|
+
|
|
318
|
+
@staticmethod
|
|
319
|
+
def decode_serial_can_error(msg: Message) -> str:
|
|
320
|
+
'''
|
|
321
|
+
Data Byte 0 | Data Byte 1
|
|
322
|
+
DESYNC | num bytes dropped
|
|
323
|
+
BAD_DLC | Received DLC
|
|
324
|
+
BAD_EOF | n/a
|
|
325
|
+
BAD_CRC | n/a
|
|
326
|
+
'''
|
|
327
|
+
if not msg.is_error_frame:
|
|
328
|
+
return ""
|
|
329
|
+
if len(msg.data) == 0:
|
|
330
|
+
return "Unknown, no data bytes to decode error type"
|
|
331
|
+
if msg.data[0] == FlexibleSerial.SerialError.DESYNC:
|
|
332
|
+
text = FlexibleSerial.ERROR_TEXT[FlexibleSerial.SerialError.DESYNC]
|
|
333
|
+
if len(msg.data) >= 2:
|
|
334
|
+
return text + f" -> {msg.data[1]} bytes dropped"
|
|
335
|
+
else:
|
|
336
|
+
return text
|
|
337
|
+
elif msg.data[0] == FlexibleSerial.SerialError.BAD_DLC:
|
|
338
|
+
text = FlexibleSerial.ERROR_TEXT[FlexibleSerial.SerialError.BAD_DLC]
|
|
339
|
+
if len(msg.data) >= 2:
|
|
340
|
+
return text + f" -> Rx data length = {msg.data[1]}"
|
|
341
|
+
else:
|
|
342
|
+
return text
|
|
343
|
+
elif msg.data[0] == FlexibleSerial.SerialError.BAD_EOF:
|
|
344
|
+
return FlexibleSerial.ERROR_TEXT[FlexibleSerial.SerialError.BAD_EOF]
|
|
345
|
+
elif msg.data[0] == FlexibleSerial.SerialError.BAD_CRC:
|
|
346
|
+
return FlexibleSerial.ERROR_TEXT[FlexibleSerial.SerialError.BAD_CRC]
|
|
347
|
+
else:
|
|
348
|
+
return f"Unknown data bytes error encoding: {msg.data.hex(' ')}"
|
|
349
|
+
|
|
350
|
+
def _recv_internal(
|
|
351
|
+
self, timeout: Optional[float]
|
|
352
|
+
) -> tuple[Optional[Message], bool]:
|
|
353
|
+
"""
|
|
354
|
+
Read a message from the serial device.
|
|
355
|
+
|
|
356
|
+
:param timeout: length to block until a message is received
|
|
357
|
+
|
|
358
|
+
:returns:
|
|
359
|
+
Received message and :obj:`False` (because no filtering as taken place).
|
|
360
|
+
"""
|
|
361
|
+
|
|
362
|
+
timer = TimeoutTimer(timeout)
|
|
363
|
+
try:
|
|
364
|
+
while (True):
|
|
365
|
+
msg = self._try_decode_message_from_buffer()
|
|
366
|
+
if msg is not None:
|
|
367
|
+
return (msg, False)
|
|
368
|
+
|
|
369
|
+
# The parser needs more bytes; allow one serial read before
|
|
370
|
+
# checking the deadline again so recv(0) still polls hardware.
|
|
371
|
+
# update timeout with remaining time
|
|
372
|
+
self._ser.timeout = timer.remaining_time()
|
|
373
|
+
# get bytes in hw serial rx buffer, up to an eof delimiter match
|
|
374
|
+
new_rx_bytes = self._ser.read_until(self._eof)
|
|
375
|
+
self._rx_buffer.extend(new_rx_bytes)
|
|
376
|
+
|
|
377
|
+
msg = self._try_decode_message_from_buffer()
|
|
378
|
+
if msg is not None:
|
|
379
|
+
return (msg, False)
|
|
380
|
+
|
|
381
|
+
if timer.expired():
|
|
382
|
+
return (None, False)
|
|
383
|
+
|
|
384
|
+
except serial.SerialException as error:
|
|
385
|
+
raise CanOperationError("could not read from serial") from error
|
|
386
|
+
|
|
387
|
+
def fileno(self) -> int:
|
|
388
|
+
try:
|
|
389
|
+
return cast("int", self._ser.fileno())
|
|
390
|
+
except io.UnsupportedOperation:
|
|
391
|
+
raise NotImplementedError(
|
|
392
|
+
"fileno is not implemented using current CAN bus on this platform"
|
|
393
|
+
) from None
|
|
394
|
+
except Exception as exception:
|
|
395
|
+
raise CanOperationError("Cannot fetch fileno") from exception
|
|
396
|
+
|
|
397
|
+
@staticmethod
|
|
398
|
+
def _detect_available_configs() -> Sequence[AutoDetectedConfig]:
|
|
399
|
+
configs: list[AutoDetectedConfig] = []
|
|
400
|
+
if serial is None:
|
|
401
|
+
return configs
|
|
402
|
+
|
|
403
|
+
for port in serial.tools.list_ports.comports():
|
|
404
|
+
configs.append({"interface": "flexible_serial", "channel": port.device})
|
|
405
|
+
return configs
|
|
406
|
+
|
|
407
|
+
@classmethod
|
|
408
|
+
def _pop_bool_kwarg(cls, kwargs: dict[str, Any], name: str) -> bool:
|
|
409
|
+
value = kwargs.pop(name, cls.DEFAULT_FRAME_KWARGS[name])
|
|
410
|
+
if not isinstance(value, bool):
|
|
411
|
+
raise TypeError(f"'{name}' must be bool, not {type(value).__name__}")
|
|
412
|
+
return value
|
|
413
|
+
|
|
414
|
+
def _pop_rx_bytes_callback(self, kwargs: dict[str, Any]):
|
|
415
|
+
value = kwargs.pop("rx_bytes_callback", None)
|
|
416
|
+
if value is None:
|
|
417
|
+
self._on_data_processed_callback = None
|
|
418
|
+
return
|
|
419
|
+
if not callable(value):
|
|
420
|
+
raise TypeError("'rx_bytes_callback' must be callable")
|
|
421
|
+
self._on_data_processed_callback = value
|
|
422
|
+
|
|
423
|
+
def _configure_header(
|
|
424
|
+
self,
|
|
425
|
+
*,
|
|
426
|
+
big_endian: bool,
|
|
427
|
+
no_timestamp: bool,
|
|
428
|
+
uint16_id: bool,
|
|
429
|
+
flags_byte: bool,
|
|
430
|
+
) -> None:
|
|
431
|
+
self._has_timestamp = not no_timestamp
|
|
432
|
+
self._id_is_uint16_t = uint16_id
|
|
433
|
+
self._has_flags_byte = flags_byte
|
|
434
|
+
|
|
435
|
+
header_format = self._build_header_format(
|
|
436
|
+
big_endian=big_endian,
|
|
437
|
+
no_timestamp=no_timestamp,
|
|
438
|
+
uint16_id=uint16_id,
|
|
439
|
+
flags_byte=flags_byte,
|
|
440
|
+
)
|
|
441
|
+
self._header = struct.Struct(header_format)
|
|
442
|
+
self._header_pack = self._select_header_pack()
|
|
443
|
+
|
|
444
|
+
@staticmethod
|
|
445
|
+
def _build_header_format(
|
|
446
|
+
*,
|
|
447
|
+
big_endian: bool,
|
|
448
|
+
no_timestamp: bool,
|
|
449
|
+
uint16_id: bool,
|
|
450
|
+
flags_byte: bool,
|
|
451
|
+
) -> str:
|
|
452
|
+
header_format = ">" if big_endian else "<" # Little/Big Endian
|
|
453
|
+
header_format += "" if no_timestamp else "I" # Timestamp -> 0 or 4 bytes
|
|
454
|
+
header_format += "B" # DLC -> 1 byte
|
|
455
|
+
header_format += "B" if flags_byte else "" # Flags -> 0 or 1 byte
|
|
456
|
+
header_format += "H" if uint16_id else "I" # ID -> 2 or 4 bytes
|
|
457
|
+
return header_format
|
|
458
|
+
|
|
459
|
+
def _select_header_pack(self) -> Callable[[int, Message, int], bytes]:
|
|
460
|
+
if (not self._has_timestamp and not self._has_flags_byte):
|
|
461
|
+
return self._pack_header_no_timestamp_no_flags
|
|
462
|
+
elif (not self._has_timestamp and self._has_flags_byte):
|
|
463
|
+
return self._pack_header_no_timestamp_yes_flags
|
|
464
|
+
elif (self._has_timestamp and not self._has_flags_byte):
|
|
465
|
+
return self._pack_header_yes_timestamp_no_flags
|
|
466
|
+
elif (self._has_timestamp and self._has_flags_byte):
|
|
467
|
+
return self._pack_header_yes_timestamp_yes_flags
|
|
468
|
+
|
|
469
|
+
raise RuntimeError("Unable to select header pack function")
|
|
470
|
+
|
|
471
|
+
def _configure_crc(self, *, big_endian: bool, kwargs: dict[str, Any]) -> None:
|
|
472
|
+
crc_type = kwargs.pop("crc_type", self.DEFAULT_FRAME_KWARGS["crc_type"])
|
|
473
|
+
if not isinstance(crc_type, str):
|
|
474
|
+
raise TypeError(f"'crc_type' must be str, not {type(crc_type).__name__}")
|
|
475
|
+
|
|
476
|
+
crc_type = crc_type.lower()
|
|
477
|
+
crc_type = "none" if crc_type == "" else crc_type
|
|
478
|
+
if crc_type not in self.CRC_TYPES:
|
|
479
|
+
raise ValueError(
|
|
480
|
+
f"'crc_type' {crc_type} not supported, must be one of {self.CRC_TYPES}"
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
self._crc_type = crc_type
|
|
484
|
+
if crc_type == "crc16-ccitt":
|
|
485
|
+
self._has_crc = True
|
|
486
|
+
self._crc_calculate = self._crc16_calculate_ccitt
|
|
487
|
+
self._crc_struct = struct.Struct((">" if big_endian else "<") + "H")
|
|
488
|
+
self._crc_size = self._crc_struct.size
|
|
489
|
+
else:
|
|
490
|
+
self._has_crc = False
|
|
491
|
+
self._crc_calculate = self._crc_calculate_none
|
|
492
|
+
self._crc_size = 0
|
|
493
|
+
|
|
494
|
+
def _configure_frame_offsets(self, *, no_timestamp: bool) -> None:
|
|
495
|
+
self._sof_len = len(self._sof)
|
|
496
|
+
self._eof_len = len(self._eof)
|
|
497
|
+
self._header_offset = self._sof_len
|
|
498
|
+
self._payload_offset = self._header_offset + self._header.size
|
|
499
|
+
|
|
500
|
+
self._crc_calc_start_offset = self._sof_len
|
|
501
|
+
self._crc_calc_start_offset += 0 if no_timestamp else 4
|
|
502
|
+
self._crc_calc_start_offset += 1
|
|
503
|
+
|
|
504
|
+
self._min_frame_length = self._payload_offset + self._crc_size + self._eof_len
|
|
505
|
+
|
|
506
|
+
@staticmethod
|
|
507
|
+
def _coerce_bytes_kwarg(name: str, value: Any) -> bytes:
|
|
508
|
+
if isinstance(value, (bytes, bytearray)):
|
|
509
|
+
byte_value = bytes(value)
|
|
510
|
+
elif isinstance(value, str):
|
|
511
|
+
byte_value = FlexibleSerial._parse_hex_bytes(name, value)
|
|
512
|
+
else:
|
|
513
|
+
raise TypeError(
|
|
514
|
+
f"'{name}' must be bytes or a hex string, "
|
|
515
|
+
f"not {type(value).__name__}"
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
if not byte_value:
|
|
519
|
+
raise ValueError(f"'{name}' must not be empty")
|
|
520
|
+
return byte_value
|
|
521
|
+
|
|
522
|
+
@staticmethod
|
|
523
|
+
def _parse_hex_bytes(name: str, value: str) -> bytes:
|
|
524
|
+
hex_value = value.strip()
|
|
525
|
+
hex_value = hex_value.replace("\\x", "").replace("\\X", "")
|
|
526
|
+
hex_value = hex_value.replace("0x", "").replace("0X", "")
|
|
527
|
+
|
|
528
|
+
for separator in (" ", "\t", "\r", "\n", "_", "-", ":", ","):
|
|
529
|
+
hex_value = hex_value.replace(separator, "")
|
|
530
|
+
|
|
531
|
+
if len(hex_value) % 2:
|
|
532
|
+
raise ValueError(
|
|
533
|
+
f"'{name}' hex string must contain an even number of digits"
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
try:
|
|
537
|
+
return bytes.fromhex(hex_value)
|
|
538
|
+
except ValueError as error:
|
|
539
|
+
raise ValueError(f"'{name}' must be bytes or a hex string") from error
|
|
540
|
+
|
|
541
|
+
# A different pack_header method for each possible header structure
|
|
542
|
+
# They all use the same arguments so the call to self._pack_header(...) in send() doesn't fail
|
|
543
|
+
def _pack_header_no_timestamp_no_flags(self, timestamp: int, msg: Message, arbitration_id: int) -> bytes:
|
|
544
|
+
return self._header.pack(msg.dlc, arbitration_id)
|
|
545
|
+
def _pack_header_no_timestamp_yes_flags(self, timestamp: int, msg: Message, arbitration_id: int) -> bytes:
|
|
546
|
+
return self._header.pack(msg.dlc, self._build_flags_byte(msg), arbitration_id)
|
|
547
|
+
def _pack_header_yes_timestamp_no_flags(self, timestamp: int, msg: Message, arbitration_id: int) -> bytes:
|
|
548
|
+
return self._header.pack(timestamp, msg.dlc, arbitration_id)
|
|
549
|
+
def _pack_header_yes_timestamp_yes_flags(self, timestamp: int, msg: Message, arbitration_id: int) -> bytes:
|
|
550
|
+
return self._header.pack(timestamp, msg.dlc, self._build_flags_byte(msg), arbitration_id)
|
|
551
|
+
|
|
552
|
+
def _crc_calculate_none(self, data: memoryview) -> bytes:
|
|
553
|
+
""" Return an empty bytes if CRC is not implemented """
|
|
554
|
+
return b""
|
|
555
|
+
|
|
556
|
+
def _crc16_calculate_ccitt(self, data: memoryview) -> bytes:
|
|
557
|
+
"""
|
|
558
|
+
CRC16-CCITT with 0xFFFF start seed using polynomial 0x1021
|
|
559
|
+
"""
|
|
560
|
+
crc = binascii.crc_hqx(data, 0xFFFF)
|
|
561
|
+
return self._crc_struct.pack(crc)
|
|
562
|
+
|
|
563
|
+
@staticmethod
|
|
564
|
+
def _build_flags_byte(msg: Message) -> int:
|
|
565
|
+
""" Build and return a packed byte filed with CAN message metadata"""
|
|
566
|
+
flags = 0
|
|
567
|
+
if msg.is_extended_id:
|
|
568
|
+
flags = FLAG_BYTE_EXTENDED
|
|
569
|
+
if msg.is_error_frame:
|
|
570
|
+
flags |= FLAG_BYTE_ERROR
|
|
571
|
+
if msg.is_remote_frame:
|
|
572
|
+
flags |= FLAG_BYTE_RTR
|
|
573
|
+
if msg.bitrate_switch:
|
|
574
|
+
flags |= FLAG_BYTE_FD_BRS
|
|
575
|
+
if msg.is_fd:
|
|
576
|
+
flags |= FLAG_BYTE_FD
|
|
577
|
+
if msg.is_rx:
|
|
578
|
+
flags |= FLAG_BYTE_IS_RX
|
|
579
|
+
return flags
|
|
580
|
+
|
|
581
|
+
def _build_rx_error_message(
|
|
582
|
+
self,
|
|
583
|
+
error: "FlexibleSerial.SerialError",
|
|
584
|
+
*,
|
|
585
|
+
timestamp: Optional[float] = None,
|
|
586
|
+
arbitration_id: int = 0,
|
|
587
|
+
is_extended_id: bool = False,
|
|
588
|
+
is_fd: bool = False,
|
|
589
|
+
is_rx: bool = True,
|
|
590
|
+
bitrate_switch: bool = False,
|
|
591
|
+
detail: Optional[int] = None,
|
|
592
|
+
) -> Message:
|
|
593
|
+
if detail is None:
|
|
594
|
+
data = bytes([int(error)])
|
|
595
|
+
else:
|
|
596
|
+
data = bytes([int(error), max(0, min(detail, 0xFF))])
|
|
597
|
+
|
|
598
|
+
return Message(
|
|
599
|
+
timestamp=time.time() if timestamp is None else timestamp,
|
|
600
|
+
arbitration_id=arbitration_id,
|
|
601
|
+
is_extended_id=is_extended_id,
|
|
602
|
+
is_remote_frame=False,
|
|
603
|
+
is_error_frame=True,
|
|
604
|
+
dlc=len(data),
|
|
605
|
+
data=data,
|
|
606
|
+
is_fd=is_fd,
|
|
607
|
+
is_rx=is_rx,
|
|
608
|
+
bitrate_switch=bitrate_switch,
|
|
609
|
+
channel=self._ser.port
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
def _try_decode_message_from_buffer(self) -> Optional[Message]:
|
|
613
|
+
"""Parse one complete message from the buffered serial bytes, if available."""
|
|
614
|
+
|
|
615
|
+
if not self._rx_buffer:
|
|
616
|
+
return None
|
|
617
|
+
|
|
618
|
+
# Search for SOF and preserve any partial SOF at the end of the buffer.
|
|
619
|
+
sof_index = self._rx_buffer.find(self._sof)
|
|
620
|
+
if sof_index < 0:
|
|
621
|
+
keep_len = 0
|
|
622
|
+
max_keep_len = min(len(self._rx_buffer), self._sof_len - 1)
|
|
623
|
+
for candidate_len in range(max_keep_len, 0, -1):
|
|
624
|
+
if self._sof.startswith(bytes(self._rx_buffer[-candidate_len:])):
|
|
625
|
+
keep_len = candidate_len
|
|
626
|
+
break
|
|
627
|
+
|
|
628
|
+
dropped_count = len(self._rx_buffer) - keep_len
|
|
629
|
+
if dropped_count <= 0:
|
|
630
|
+
return None
|
|
631
|
+
|
|
632
|
+
if self._on_data_processed_callback:
|
|
633
|
+
self._on_data_processed_callback(self._rx_buffer[:dropped_count], True)
|
|
634
|
+
del self._rx_buffer[:dropped_count]
|
|
635
|
+
return self._build_rx_error_message(
|
|
636
|
+
self.SerialError.DESYNC,
|
|
637
|
+
detail=dropped_count,
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
# Drop any junk before the next complete SOF.
|
|
641
|
+
if sof_index > 0:
|
|
642
|
+
if self._on_data_processed_callback:
|
|
643
|
+
self._on_data_processed_callback(self._rx_buffer[:sof_index], True)
|
|
644
|
+
del self._rx_buffer[:sof_index]
|
|
645
|
+
return self._build_rx_error_message(
|
|
646
|
+
self.SerialError.DESYNC,
|
|
647
|
+
detail=sof_index,
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
# Wait until the smallest valid frame can fit in the buffer.
|
|
651
|
+
if len(self._rx_buffer) < self._min_frame_length:
|
|
652
|
+
return None
|
|
653
|
+
|
|
654
|
+
# Unpack fixed header fields.
|
|
655
|
+
header = self._header.unpack_from(self._rx_buffer, self._header_offset)
|
|
656
|
+
header_idx = 0
|
|
657
|
+
if self._has_timestamp:
|
|
658
|
+
timestamp = header[header_idx] / 1000.0
|
|
659
|
+
header_idx += 1
|
|
660
|
+
else:
|
|
661
|
+
timestamp = time.time()
|
|
662
|
+
|
|
663
|
+
dlc = header[header_idx]
|
|
664
|
+
header_idx += 1
|
|
665
|
+
|
|
666
|
+
if self._has_flags_byte:
|
|
667
|
+
flags_byte = header[header_idx]
|
|
668
|
+
header_idx += 1
|
|
669
|
+
else:
|
|
670
|
+
flags_byte = None
|
|
671
|
+
|
|
672
|
+
arbitration_id = header[header_idx]
|
|
673
|
+
|
|
674
|
+
# Parse message metadata from flags byte, ID flags, or format defaults.
|
|
675
|
+
if self._has_flags_byte:
|
|
676
|
+
is_extended_id = bool(flags_byte & FLAG_BYTE_EXTENDED)
|
|
677
|
+
is_error_frame = bool(flags_byte & FLAG_BYTE_ERROR)
|
|
678
|
+
is_remote_frame = bool(flags_byte & FLAG_BYTE_RTR)
|
|
679
|
+
is_can_fd = bool(flags_byte & FLAG_BYTE_FD)
|
|
680
|
+
is_can_fd_brs = bool(flags_byte & FLAG_BYTE_FD_BRS)
|
|
681
|
+
is_rx = bool(flags_byte & FLAG_BYTE_IS_RX)
|
|
682
|
+
elif self._id_is_uint16_t:
|
|
683
|
+
is_extended_id = False
|
|
684
|
+
is_error_frame = False
|
|
685
|
+
is_remote_frame = False
|
|
686
|
+
is_can_fd = dlc > 8
|
|
687
|
+
is_can_fd_brs = False
|
|
688
|
+
is_rx = True
|
|
689
|
+
else:
|
|
690
|
+
is_extended_id = bool(arbitration_id & CAN_EFF_FLAG)
|
|
691
|
+
is_error_frame = bool(arbitration_id & CAN_ERR_FLAG)
|
|
692
|
+
is_remote_frame = bool(arbitration_id & CAN_RTR_FLAG)
|
|
693
|
+
is_can_fd = False
|
|
694
|
+
is_can_fd_brs = False
|
|
695
|
+
is_rx = True
|
|
696
|
+
|
|
697
|
+
# Mask protocol flag bits out of the CAN ID.
|
|
698
|
+
if is_extended_id:
|
|
699
|
+
arbitration_id = arbitration_id & CAN_ID_MASK_EXT
|
|
700
|
+
else:
|
|
701
|
+
arbitration_id = arbitration_id & CAN_ID_MASK_STD
|
|
702
|
+
|
|
703
|
+
# Validate DLC before trusting it as the payload length.
|
|
704
|
+
if dlc > (MAX_DATA_LEN_FD if is_can_fd else MAX_DATA_LEN_CLASSIC):
|
|
705
|
+
if self._on_data_processed_callback:
|
|
706
|
+
self._on_data_processed_callback(self._rx_buffer[0], True)
|
|
707
|
+
del self._rx_buffer[0]
|
|
708
|
+
return self._build_rx_error_message(
|
|
709
|
+
self.SerialError.BAD_DLC,
|
|
710
|
+
timestamp=timestamp,
|
|
711
|
+
arbitration_id=arbitration_id,
|
|
712
|
+
is_extended_id=is_extended_id,
|
|
713
|
+
is_fd=is_can_fd,
|
|
714
|
+
is_rx=is_rx,
|
|
715
|
+
bitrate_switch=is_can_fd_brs,
|
|
716
|
+
detail=dlc,
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
# Compute variable-length frame boundaries.
|
|
720
|
+
crc_offset = self._payload_offset + dlc
|
|
721
|
+
eof_offset = crc_offset + self._crc_size
|
|
722
|
+
frame_end = eof_offset + self._eof_len
|
|
723
|
+
|
|
724
|
+
# Wait until the complete frame is buffered.
|
|
725
|
+
if len(self._rx_buffer) < frame_end:
|
|
726
|
+
return None
|
|
727
|
+
|
|
728
|
+
# Validate EOF delimiter.
|
|
729
|
+
if self._rx_buffer[eof_offset:frame_end] != self._eof:
|
|
730
|
+
if self._on_data_processed_callback:
|
|
731
|
+
self._on_data_processed_callback(self._rx_buffer[0], True)
|
|
732
|
+
del self._rx_buffer[0]
|
|
733
|
+
return self._build_rx_error_message(
|
|
734
|
+
self.SerialError.BAD_EOF,
|
|
735
|
+
timestamp=timestamp,
|
|
736
|
+
arbitration_id=arbitration_id,
|
|
737
|
+
is_extended_id=is_extended_id,
|
|
738
|
+
is_fd=is_can_fd,
|
|
739
|
+
is_rx=is_rx,
|
|
740
|
+
bitrate_switch=is_can_fd_brs,
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
# Validate CRC when enabled.
|
|
744
|
+
if self._has_crc:
|
|
745
|
+
expected_crc = self._crc_calculate(
|
|
746
|
+
memoryview(self._rx_buffer)[self._crc_calc_start_offset:crc_offset]
|
|
747
|
+
)
|
|
748
|
+
received_crc = self._rx_buffer[crc_offset:eof_offset]
|
|
749
|
+
if expected_crc != received_crc:
|
|
750
|
+
if self._on_data_processed_callback:
|
|
751
|
+
self._on_data_processed_callback(self._rx_buffer[:frame_end], True)
|
|
752
|
+
del self._rx_buffer[:frame_end]
|
|
753
|
+
return self._build_rx_error_message(
|
|
754
|
+
self.SerialError.BAD_CRC,
|
|
755
|
+
timestamp=timestamp,
|
|
756
|
+
arbitration_id=arbitration_id,
|
|
757
|
+
is_extended_id=is_extended_id,
|
|
758
|
+
is_fd=is_can_fd,
|
|
759
|
+
is_rx=is_rx,
|
|
760
|
+
bitrate_switch=is_can_fd_brs,
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
# Build decoded CAN message and consume the frame bytes.
|
|
764
|
+
payload = bytearray(self._rx_buffer[self._payload_offset:crc_offset])
|
|
765
|
+
if self._on_data_processed_callback:
|
|
766
|
+
self._on_data_processed_callback(self._rx_buffer[:frame_end], True)
|
|
767
|
+
del self._rx_buffer[:frame_end]
|
|
768
|
+
return Message(
|
|
769
|
+
timestamp=timestamp,
|
|
770
|
+
arbitration_id=arbitration_id,
|
|
771
|
+
is_extended_id=is_extended_id,
|
|
772
|
+
is_remote_frame=is_remote_frame,
|
|
773
|
+
is_error_frame=is_error_frame,
|
|
774
|
+
dlc=dlc,
|
|
775
|
+
data=payload,
|
|
776
|
+
is_fd=is_can_fd,
|
|
777
|
+
is_rx=is_rx,
|
|
778
|
+
bitrate_switch=is_can_fd_brs,
|
|
779
|
+
channel=self._ser.port
|
|
780
|
+
)
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-can-flexible-serial
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: python-can interface plugin for a configurable binary serial CAN protocol
|
|
5
|
+
Project-URL: python-can plugin interface, https://python-can.readthedocs.io/en/stable/plugin-interface.html
|
|
6
|
+
Keywords: can,python-can,serial,plugin
|
|
7
|
+
Requires-Python: >=3.8
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: python-can<5,>=4.4
|
|
11
|
+
Requires-Dist: pyserial>=3.5
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# python-can-flexible-serial
|
|
17
|
+
|
|
18
|
+
A [python-can](https://python-can.readthedocs.io/) interface plugin that speaks a
|
|
19
|
+
configurable, framed binary protocol over a serial port. It follows the
|
|
20
|
+
[python-can plugin interface](https://python-can.readthedocs.io/en/stable/plugin-interface.html),
|
|
21
|
+
so once installed it is usable anywhere python-can accepts an `interface=`
|
|
22
|
+
name — `can.Bus`, `python -m can.viewer`, `python -m can.logger`,
|
|
23
|
+
`python -m can.bridge`, etc. — alongside the other community interface
|
|
24
|
+
plugins listed on that page (`python-can-canine`, `python-can-cvector`,
|
|
25
|
+
`python-can-remote`, `python-can-sontheim`, `python-can-cando`,
|
|
26
|
+
`python-can-candle`, ...).
|
|
27
|
+
|
|
28
|
+
Unlike the fixed-layout `slcan` and `serial` interfaces bundled with
|
|
29
|
+
python-can, the frame layout here is configurable via constructor kwargs
|
|
30
|
+
(SOF/EOF delimiters, ID width, optional CRC, optional flags byte, optional
|
|
31
|
+
timestamp) so it can be adapted to match firmware on the other end of the
|
|
32
|
+
serial link without changing code.
|
|
33
|
+
|
|
34
|
+
## Frame layout
|
|
35
|
+
|
|
36
|
+
| Section | Field | Size | Optional / Configurable |
|
|
37
|
+
|---|---|---|---|
|
|
38
|
+
| — | SOF | 1+ bytes | Configurable delimiter (default `0xAA`) |
|
|
39
|
+
| Header | timestamp | 0 or 4 bytes | Optional — omitted when `no_timestamp=True` |
|
|
40
|
+
| Header | dlc | 1 byte | Always present |
|
|
41
|
+
| Header | flags | 0 or 1 byte | Optional — only present when `flags_byte=True` |
|
|
42
|
+
| Header | id | 2 or 4 bytes | Sizeable — 2 bytes when `uint16_id=True`, else 4 |
|
|
43
|
+
| Payload | data | 0-64 bytes | Sizeable — length equals `dlc` |
|
|
44
|
+
| — | crc | 0 or N bytes | Optional — size/presence set by `crc_type` |
|
|
45
|
+
| — | EOF | 1+ bytes | Configurable delimiter (default `0xBB`) |
|
|
46
|
+
|
|
47
|
+
Details on each section:
|
|
48
|
+
|
|
49
|
+
- **SOF / EOF** — arbitrary-length byte sequences used purely as framing
|
|
50
|
+
delimiters. They are never covered by the CRC.
|
|
51
|
+
- **timestamp** *(optional, fixed 4 bytes when present)* — `uint32`
|
|
52
|
+
milliseconds. Omit entirely with `no_timestamp=True` to save 4 bytes per
|
|
53
|
+
frame if the far end doesn't supply/need one; python-can still assigns a
|
|
54
|
+
local receive timestamp in that case.
|
|
55
|
+
- **dlc** *(always present, fixed 1 byte)* — raw payload length in bytes;
|
|
56
|
+
also used to size the `data` section on receive.
|
|
57
|
+
- **flags** *(optional, fixed 1 byte when present)* — see "Flags byte" below
|
|
58
|
+
for the bit layout. When absent, the same metadata is instead packed into
|
|
59
|
+
spare bits of the `id` field (see below).
|
|
60
|
+
- **id** *(always present, sizeable: 2 or 4 bytes)* — arbitration ID.
|
|
61
|
+
`uint16_id=True` shrinks it to 2 bytes but rejects extended (29-bit) IDs;
|
|
62
|
+
the default 4-byte field supports both standard and extended IDs.
|
|
63
|
+
- **data** *(sizeable, 0-64 bytes)* — the CAN payload; length is whatever
|
|
64
|
+
`dlc` says, up to 64 bytes for CAN FD-sized frames or 8 bytes otherwise.
|
|
65
|
+
- **crc** *(optional, sizeable: 0 or N bytes)* — set by `crc_type`
|
|
66
|
+
(`"none"` or `"crc16-ccitt"`); covers the header and payload, but not
|
|
67
|
+
SOF/EOF.
|
|
68
|
+
|
|
69
|
+
Byte order for the header and CRC fields is little-endian by default, or
|
|
70
|
+
big-endian when `big_endian=True`.
|
|
71
|
+
|
|
72
|
+
### Flags byte
|
|
73
|
+
|
|
74
|
+
When `flags_byte=True`, extended/error/remote/FD metadata is carried in a
|
|
75
|
+
dedicated byte instead of being packed into spare bits of the arbitration ID:
|
|
76
|
+
|
|
77
|
+
| Bit | Mask | Meaning |
|
|
78
|
+
|------|------|---------|
|
|
79
|
+
| 0 | `0x01` | Error frame |
|
|
80
|
+
| 1 | `0x02` | Remote frame (RTR) |
|
|
81
|
+
| 2 | `0x04` | Extended (29-bit) ID |
|
|
82
|
+
| 3 | `0x08` | CAN FD frame |
|
|
83
|
+
| 4 | `0x10` | CAN FD bit-rate switch |
|
|
84
|
+
| 5 | `0x20` | Frame originated as RX (vs. TX) |
|
|
85
|
+
|
|
86
|
+
When `flags_byte=False`, extended/error/remote status is instead OR'd into
|
|
87
|
+
spare high bits of the 32-bit arbitration ID field (standard `python-can`
|
|
88
|
+
convention: `CAN_EFF_FLAG`, `CAN_ERR_FLAG`, `CAN_RTR_FLAG`). This only applies
|
|
89
|
+
when `uint16_id=False`, since a 16-bit ID field has no spare bits for flags.
|
|
90
|
+
|
|
91
|
+
### Error frames
|
|
92
|
+
|
|
93
|
+
Protocol-level errors (frame desync, bad DLC, EOF mismatch, bad CRC) are
|
|
94
|
+
surfaced as `Message` objects with `is_error_frame=True` rather than raising
|
|
95
|
+
exceptions, matching how other python-can interfaces report bus errors. Use
|
|
96
|
+
`FlexibleSerial.decode_serial_can_error(msg)` to get a human-readable
|
|
97
|
+
description of an error message's contents.
|
|
98
|
+
|
|
99
|
+
## Installation
|
|
100
|
+
|
|
101
|
+
How you install this depends on whether you're *using* it as a dependency
|
|
102
|
+
of some other project, or working on this repo itself.
|
|
103
|
+
|
|
104
|
+
### Using this plugin from another project
|
|
105
|
+
|
|
106
|
+
If that project is managed with [uv](https://docs.astral.sh/uv/), add this
|
|
107
|
+
repo as an editable path dependency, pointing at wherever you've cloned it:
|
|
108
|
+
|
|
109
|
+
```powershell
|
|
110
|
+
uv add --editable plugins/python-can-flexible-serial
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Run this from *that* project's directory, not from within this repo — `uv
|
|
114
|
+
add` adds a dependency to the project in your current directory, so running
|
|
115
|
+
it from inside `python-can-flexible-serial` itself is a self-dependency and
|
|
116
|
+
uv will refuse it.
|
|
117
|
+
|
|
118
|
+
With plain pip, from that project's environment:
|
|
119
|
+
|
|
120
|
+
```powershell
|
|
121
|
+
python -m pip install -e /path/to/python-can-flexible-serial
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Working on this repo itself
|
|
125
|
+
|
|
126
|
+
Use `uv sync` (a uv-managed project installs itself editable by default):
|
|
127
|
+
|
|
128
|
+
```powershell
|
|
129
|
+
uv sync
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Or with plain pip, from this repo's root:
|
|
133
|
+
|
|
134
|
+
```powershell
|
|
135
|
+
python -m pip install -e .
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Either way, this registers the plugin with python-can via the `can.interface`
|
|
139
|
+
entry point (`flexible_serial = "flexible_serial.bus:FlexibleSerial"`), so it
|
|
140
|
+
becomes available as `interface="flexible_serial"` anywhere python-can
|
|
141
|
+
resolves interfaces, without any further imports.
|
|
142
|
+
|
|
143
|
+
## Usage
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
import can
|
|
147
|
+
|
|
148
|
+
bus = can.interface.Bus(
|
|
149
|
+
interface="flexible_serial",
|
|
150
|
+
channel="COM3",
|
|
151
|
+
baudrate=115200,
|
|
152
|
+
)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Constructor arguments
|
|
156
|
+
|
|
157
|
+
| Argument | Type | Default | Description |
|
|
158
|
+
|---|---|---|---|
|
|
159
|
+
| `channel` | `str` | *(required)* | Serial device, e.g. `"COM3"` or `"/dev/ttyUSB0"` |
|
|
160
|
+
| `baudrate` | `int` | `115200` | Serial baud rate |
|
|
161
|
+
| `timeout` | `float` | `0.1` | Serial read timeout, in seconds |
|
|
162
|
+
| `rtscts` | `bool` | `False` | Enable RTS/CTS hardware handshake |
|
|
163
|
+
| `sof` | `bytes` \| hex `str` | `0xAA` | Start-of-frame delimiter |
|
|
164
|
+
| `eof` | `bytes` \| hex `str` | `0xBB` | End-of-frame delimiter |
|
|
165
|
+
| `big_endian` | `bool` | `False` | Byte order for header/CRC fields |
|
|
166
|
+
| `uint16_id` | `bool` | `False` | Use a 2-byte arbitration ID instead of 4-byte; rejects extended IDs |
|
|
167
|
+
| `flags_byte` | `bool` | `False` | Carry frame metadata in a dedicated byte (see "Flags byte" above) |
|
|
168
|
+
| `no_timestamp` | `bool` | `False` | Omit the 4-byte timestamp header field |
|
|
169
|
+
| `crc_type` | `str` | `"none"` | One of `"none"`, `"crc16-ccitt"` |
|
|
170
|
+
| `rx_bytes_callback` | `Callable[[bytes, bool], None] \| None` | `None` | Called with `(raw_bytes, is_rx)` whenever a frame is sent or a chunk of buffer is consumed on receive; useful for logging/debugging the raw wire traffic |
|
|
171
|
+
|
|
172
|
+
`sof` and `eof` accept either raw `bytes` (when constructing a `Bus` from
|
|
173
|
+
Python) or a hex string (when passed as a CLI `--bus-kwargs` value, since
|
|
174
|
+
python-can CLI tools pass kwargs as strings):
|
|
175
|
+
|
|
176
|
+
```powershell
|
|
177
|
+
python -m can.logger -i flexible_serial -c COM3 --bus-kwargs sof=0xAA eof=0x0D0A
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Prefer the `0x` prefix for CLI hex values, especially if the delimiter
|
|
181
|
+
contains only digits, since python-can auto-converts bare numeric kwargs.
|
|
182
|
+
|
|
183
|
+
### CLI examples
|
|
184
|
+
|
|
185
|
+
```powershell
|
|
186
|
+
python -m can.viewer -i flexible_serial -c COM6 --bus-kwargs sof=0x4040 eof=0x0A uint16_id=True crc_type=crc16-ccitt flags_byte=False no_timestamp=True
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Bridging to another interface:
|
|
190
|
+
|
|
191
|
+
```powershell
|
|
192
|
+
python -m can.bridge --bus1-interface flexible_serial --bus1-channel COM7 --bus1-bus-kwargs sof=0x4040 eof=0x0A uint16_id=True crc_type=crc16-ccitt flags_byte=False no_timestamp=True --bus2-interface kvaser --bus2-channel 0 --bus2-bitrate 125000
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Autodetection
|
|
196
|
+
|
|
197
|
+
Like the built-in `serial` interface, available serial ports are surfaced
|
|
198
|
+
through python-can's autodetection API:
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
import can
|
|
202
|
+
print(can.detect_available_configs(interfaces=["flexible_serial"]))
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## Layout
|
|
206
|
+
|
|
207
|
+
```text
|
|
208
|
+
python-can-flexible-serial/
|
|
209
|
+
pyproject.toml
|
|
210
|
+
README.md
|
|
211
|
+
src/
|
|
212
|
+
flexible_serial/
|
|
213
|
+
__init__.py
|
|
214
|
+
bus.py
|
|
215
|
+
tests/
|
|
216
|
+
test_hex_bytes.py
|
|
217
|
+
test_bus.py
|
|
218
|
+
message_helper.py
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## Development
|
|
222
|
+
|
|
223
|
+
Install with the `dev` extra to pull in test dependencies (`pytest`):
|
|
224
|
+
|
|
225
|
+
```powershell
|
|
226
|
+
uv sync --extra dev
|
|
227
|
+
```
|
|
228
|
+
or
|
|
229
|
+
```powershell
|
|
230
|
+
python -m pip install -e .[dev]
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Run the test suite with:
|
|
234
|
+
|
|
235
|
+
```
|
|
236
|
+
uv run -m pytest
|
|
237
|
+
```
|
|
238
|
+
or
|
|
239
|
+
```powershell
|
|
240
|
+
python -m pytest
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
`tests/test_bus.py` scaffolds round-trip tests against two backends — a fully
|
|
244
|
+
in-memory mocked serial port and pyserial's `loop://` — mirroring the
|
|
245
|
+
structure of python-can's own `serial` interface test suite. The test
|
|
246
|
+
bodies are currently stubs (`skipTest`); fill them in as coverage is added.
|
|
247
|
+
|
|
248
|
+
## Future improvement ideas
|
|
249
|
+
|
|
250
|
+
- **`can_filters` support** — already works today for free: `BusABC.recv()`
|
|
251
|
+
always calls `_matches_filters()` in software unless `_recv_internal`
|
|
252
|
+
reports the message as pre-filtered, and this interface never does. What's
|
|
253
|
+
still missing is a hardware-style optimization: implement the optional
|
|
254
|
+
`_apply_filters()` hook so frames can be dropped *before* the CRC check
|
|
255
|
+
runs, instead of always paying the full decode+CRC cost and filtering
|
|
256
|
+
afterward.
|
|
257
|
+
- Support the CAN FD protocol for the `BusABC` parent (`can_protocol`
|
|
258
|
+
currently reports `CAN_20` unconditionally).
|
|
259
|
+
- Add an option for arbitrary (non-CAN) data length up to 255 bytes, since
|
|
260
|
+
`dlc` is only 1 byte — useful for passing arbitrary serial payloads through
|
|
261
|
+
python-can tooling without being constrained to CAN/CAN FD payload sizes.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
flexible_serial/__init__.py,sha256=0XQ0axiWmTV75TIN2M0pM71QNvG_VbDm2wirLRALeRk,129
|
|
2
|
+
flexible_serial/bus.py,sha256=QXFIIey4GF-jp2sXYVjNElWBQeEvz12dbvZSqA2Ocho,29230
|
|
3
|
+
python_can_flexible_serial-0.1.0.dist-info/licenses/LICENSE,sha256=T9834WtL-nnhksAVXTn15FNr5fYyS1KzyLodhEVbKm4,1067
|
|
4
|
+
python_can_flexible_serial-0.1.0.dist-info/METADATA,sha256=7TlEeJcNW4_UCjzT-0y3JKzHHngHHk7TQ8dy4UOoX7s,10036
|
|
5
|
+
python_can_flexible_serial-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
6
|
+
python_can_flexible_serial-0.1.0.dist-info/entry_points.txt,sha256=FvRVfuDmEVWkQEeHq4CEczaXichOYQpbSLzelMhE8V0,69
|
|
7
|
+
python_can_flexible_serial-0.1.0.dist-info/top_level.txt,sha256=CBMqcoYnde__UVfBfwco7r1FFNAkA4MPV5evBl4Bsws,16
|
|
8
|
+
python_can_flexible_serial-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matt Bulow
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
flexible_serial
|