python-can-flexible-serial 0.1.0__py3-none-any.whl → 0.2.2__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/bus.py +219 -37
- python_can_flexible_serial-0.2.2.dist-info/METADATA +304 -0
- python_can_flexible_serial-0.2.2.dist-info/RECORD +8 -0
- {python_can_flexible_serial-0.1.0.dist-info → python_can_flexible_serial-0.2.2.dist-info}/WHEEL +1 -1
- {python_can_flexible_serial-0.1.0.dist-info → python_can_flexible_serial-0.2.2.dist-info}/licenses/LICENSE +1 -1
- python_can_flexible_serial-0.1.0.dist-info/METADATA +0 -261
- python_can_flexible_serial-0.1.0.dist-info/RECORD +0 -8
- {python_can_flexible_serial-0.1.0.dist-info → python_can_flexible_serial-0.2.2.dist-info}/entry_points.txt +0 -0
- {python_can_flexible_serial-0.1.0.dist-info → python_can_flexible_serial-0.2.2.dist-info}/top_level.txt +0 -0
flexible_serial/bus.py
CHANGED
|
@@ -12,9 +12,15 @@ https://python-can.readthedocs.io/en/stable/plugin-interface.html for the
|
|
|
12
12
|
python-can plugin interface this implements.
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import copy
|
|
15
18
|
import io
|
|
16
19
|
import logging
|
|
20
|
+
import os
|
|
17
21
|
import struct
|
|
22
|
+
import threading
|
|
23
|
+
from collections import deque
|
|
18
24
|
from collections.abc import Sequence
|
|
19
25
|
from typing import TypedDict, Callable, Any, Optional, cast
|
|
20
26
|
import binascii
|
|
@@ -113,7 +119,8 @@ class FlexibleSerial(BusABC):
|
|
|
113
119
|
|
|
114
120
|
ERROR_TEXT = {
|
|
115
121
|
SerialError.DESYNC: "Start-of-Frame desync",
|
|
116
|
-
|
|
122
|
+
# decode_serial_can_error adds the limit that applied: 8 bytes, or 64 for an FD frame
|
|
123
|
+
SerialError.BAD_DLC: "DataLength Invalid",
|
|
117
124
|
SerialError.BAD_EOF: "End-of-Frame mismatch",
|
|
118
125
|
SerialError.BAD_CRC: "CRC Error",
|
|
119
126
|
}
|
|
@@ -130,9 +137,15 @@ class FlexibleSerial(BusABC):
|
|
|
130
137
|
:ivar crc_type: One of :attr:`CRC_TYPES` (``"none"``, ``"crc16-ccitt"``).
|
|
131
138
|
:ivar flags_byte: Carry extended/error/remote/FD metadata in a dedicated header byte.
|
|
132
139
|
:ivar no_timestamp: Omit the 4-byte (uint32 milliseconds) timestamp header field.
|
|
133
|
-
:ivar
|
|
140
|
+
:ivar data_processed_callback: ``callback(raw_bytes, is_rx)`` invoked for every
|
|
134
141
|
frame written or chunk of buffer consumed while reading; useful for
|
|
135
142
|
logging/debugging raw wire traffic.
|
|
143
|
+
:ivar receive_own_messages: Hand every successfully sent message back through
|
|
144
|
+
:meth:`~_recv_internal`, so transmitted frames reach listeners as ordinary
|
|
145
|
+
receives and callers do not have to synthesize their own echo. Off by default.
|
|
146
|
+
The echo is the message as given, so set ``is_rx=False`` when sending if the
|
|
147
|
+
direction matters downstream. ``timestamp`` is passed through when set and
|
|
148
|
+
stamped at write time when not, matching how rx timestamps are handled.
|
|
136
149
|
"""
|
|
137
150
|
big_endian: bool
|
|
138
151
|
sof: bytes
|
|
@@ -141,7 +154,8 @@ class FlexibleSerial(BusABC):
|
|
|
141
154
|
crc_type: str
|
|
142
155
|
flags_byte: bool
|
|
143
156
|
no_timestamp: bool
|
|
144
|
-
|
|
157
|
+
data_processed_callback: Callable | None
|
|
158
|
+
receive_own_messages: bool
|
|
145
159
|
|
|
146
160
|
|
|
147
161
|
DEFAULT_FRAME_KWARGS: FrameKwargs = {
|
|
@@ -152,18 +166,36 @@ class FlexibleSerial(BusABC):
|
|
|
152
166
|
"crc_type": "none",
|
|
153
167
|
"flags_byte": False,
|
|
154
168
|
"no_timestamp": False,
|
|
155
|
-
"
|
|
169
|
+
"data_processed_callback": None,
|
|
170
|
+
"receive_own_messages": False
|
|
156
171
|
}
|
|
157
172
|
|
|
173
|
+
RX_QUEUE_MAX_FRAMES = 10_000
|
|
174
|
+
'''Cap on messages waiting to be handed out by _recv_internal.
|
|
175
|
+
|
|
176
|
+
High enough that it only ever catches a caller who opted into receive_own_messages and then
|
|
177
|
+
never drains - which is their bug, not ours, but should not be an unbounded leak. Received
|
|
178
|
+
frames cannot realistically reach it: what one read can queue is bounded by the driver's own
|
|
179
|
+
buffer, a few hundred frames at most, and _recv_internal hands them straight back out.
|
|
180
|
+
'''
|
|
181
|
+
|
|
158
182
|
DataProcessedCallback = Callable[[bytes, bool], None]
|
|
159
183
|
''' Provides arguments [data bytes, is_rx] '''
|
|
160
184
|
|
|
185
|
+
# Advantech PCIE-16xx serial cards (adv17v35x driver, Exar XR17V35x UART). The driver sets the RX FIFO
|
|
186
|
+
# trigger to 32 bytes whenever the port is first opened, so a frame shorter than that waits for the
|
|
187
|
+
# UART's ~4 character idle timeout. ADV_WRITE_REG (_IO('x', 2) in adv17v35x.c) writes one UART register.
|
|
188
|
+
ADVANTECH_PCI_DRIVER = "ADVserial"
|
|
189
|
+
ADVANTECH_WRITE_REG = 0x7802
|
|
190
|
+
ADVANTECH_RXTRG_REG = 0x0B
|
|
191
|
+
|
|
161
192
|
def __init__(
|
|
162
193
|
self,
|
|
163
194
|
channel: str,
|
|
164
195
|
baudrate: int = 115200,
|
|
165
196
|
timeout: float = 0.1,
|
|
166
197
|
rtscts: bool = False,
|
|
198
|
+
low_latency: bool = True,
|
|
167
199
|
**kwargs: Any,
|
|
168
200
|
) -> None:
|
|
169
201
|
"""
|
|
@@ -183,13 +215,19 @@ class FlexibleSerial(BusABC):
|
|
|
183
215
|
:param rtscts:
|
|
184
216
|
Turn hardware handshake (RTS/CTS) on and off (default false)
|
|
185
217
|
|
|
218
|
+
:param low_latency:
|
|
219
|
+
Ask the port to hand over received bytes as they arrive rather than in batches
|
|
220
|
+
(default true). Linux only, and best effort: a port that doesn't support it is
|
|
221
|
+
logged and opened anyway. See :meth:`_set_low_latency` for what it does per hardware.
|
|
222
|
+
Pass false to leave the port's own settings alone.
|
|
223
|
+
|
|
186
224
|
:param kwargs:
|
|
187
225
|
Frame-layout options (see :class:`FrameKwargs` and
|
|
188
226
|
:attr:`DEFAULT_FRAME_KWARGS` for defaults), plus any remaining
|
|
189
227
|
kwargs passed to the base class constructor, see
|
|
190
|
-
:meth:`can.BusABC.__init__`.
|
|
191
|
-
hex strings (e.g. ``"0xAA"``)
|
|
192
|
-
|
|
228
|
+
:meth:`can.BusABC.__init__`. ``sof`` and ``eof`` also accept
|
|
229
|
+
hex strings (e.g. ``"0xAA"``), since python-can CLI tools pass
|
|
230
|
+
``--bus-kwargs`` as strings.
|
|
193
231
|
|
|
194
232
|
:raises ~can.exceptions.CanInitializationError:
|
|
195
233
|
If the given parameters are invalid.
|
|
@@ -210,6 +248,28 @@ class FlexibleSerial(BusABC):
|
|
|
210
248
|
|
|
211
249
|
self._rx_buffer = bytearray()
|
|
212
250
|
|
|
251
|
+
self._receive_own_messages = self._pop_bool_kwarg(kwargs, "receive_own_messages")
|
|
252
|
+
|
|
253
|
+
# send() warns about the first out-of-range timestamp it clamps, not every one
|
|
254
|
+
self._timestamp_clamp_warned = False
|
|
255
|
+
|
|
256
|
+
# Everything waiting to be handed out by _recv_internal(), from both sources in the order
|
|
257
|
+
# it was queued: frames parsed off the wire, and - with receive_own_messages - echoes that
|
|
258
|
+
# send() queued as it was given them, so nothing is re-encoded going out or re-parsed
|
|
259
|
+
# coming back in.
|
|
260
|
+
#
|
|
261
|
+
# One queue rather than two because _recv_internal() returns one message per call, as
|
|
262
|
+
# python-can requires, while parsing every complete frame the buffer holds per trip to
|
|
263
|
+
# the hardware: a burst is timestamped in one pass instead of one frame per round trip
|
|
264
|
+
# through the caller's listeners, however slow those are. Both sources then just pop from
|
|
265
|
+
# here. deque.append and popleft are atomic, which matters because send() may be queueing
|
|
266
|
+
# an echo from another thread while _recv_internal() pops.
|
|
267
|
+
self._rx_queue: deque[Message] = deque(maxlen=self.RX_QUEUE_MAX_FRAMES)
|
|
268
|
+
|
|
269
|
+
# Serializes the write path. Backends over a socket or a vendor C library get this from underneath.
|
|
270
|
+
# Re-entrant so a data_processed_callback that sends cannot deadlock.
|
|
271
|
+
self._tx_lock = threading.RLock()
|
|
272
|
+
|
|
213
273
|
self._sof = self._coerce_bytes_kwarg(
|
|
214
274
|
"sof",
|
|
215
275
|
kwargs.pop("sof", self.DEFAULT_FRAME_KWARGS["sof"]),
|
|
@@ -234,7 +294,7 @@ class FlexibleSerial(BusABC):
|
|
|
234
294
|
self._configure_frame_offsets(no_timestamp=no_timestamp)
|
|
235
295
|
|
|
236
296
|
# Register user callback for when serial bytes are received/transmitted
|
|
237
|
-
self.
|
|
297
|
+
self._pop_data_processed_callback(kwargs)
|
|
238
298
|
|
|
239
299
|
try:
|
|
240
300
|
self._ser = serial.serial_for_url(
|
|
@@ -244,9 +304,12 @@ class FlexibleSerial(BusABC):
|
|
|
244
304
|
raise CanInitializationError(
|
|
245
305
|
"Could not create the serial device"
|
|
246
306
|
) from error
|
|
247
|
-
|
|
307
|
+
|
|
308
|
+
if low_latency:
|
|
309
|
+
self._set_low_latency()
|
|
310
|
+
|
|
248
311
|
logger.info(f"Opened Serial Port {channel} with frame settings:\r\n"
|
|
249
|
-
f"\tEndianness: {
|
|
312
|
+
f"\tEndianness: {'big' if big_endian else 'little'}\r\n"
|
|
250
313
|
f"\tSOF: {self._sof}\r\n"
|
|
251
314
|
f"\tEOF: {self._eof}\r\n"
|
|
252
315
|
f"\tTimestamp: {not no_timestamp}\r\n"
|
|
@@ -261,8 +324,10 @@ class FlexibleSerial(BusABC):
|
|
|
261
324
|
"""
|
|
262
325
|
Close the serial interface.
|
|
263
326
|
"""
|
|
327
|
+
# Tasks first (stop() only sets flags), then the lock, so an in-flight write finishes before the port closes under it.
|
|
264
328
|
super().shutdown()
|
|
265
|
-
self.
|
|
329
|
+
with self._tx_lock:
|
|
330
|
+
self._ser.close()
|
|
266
331
|
|
|
267
332
|
def send(self, msg: Message, timeout: Optional[float] = None) -> None:
|
|
268
333
|
"""
|
|
@@ -271,13 +336,17 @@ class FlexibleSerial(BusABC):
|
|
|
271
336
|
:param msg:
|
|
272
337
|
Message to send.
|
|
273
338
|
|
|
274
|
-
.. note::
|
|
275
|
-
|
|
339
|
+
.. note:: When the layout has a timestamp field, ``msg.timestamp``
|
|
340
|
+
(seconds) is sent as integer milliseconds, clamped to
|
|
341
|
+
0..0xFFFFFFFF rather than raising like python-can's
|
|
342
|
+
``serial`` interface. The first clamp logs a warning.
|
|
276
343
|
|
|
277
344
|
:param timeout:
|
|
278
|
-
|
|
279
|
-
|
|
345
|
+
Serial write timeout for this write, in seconds (None blocks until
|
|
346
|
+
written).
|
|
280
347
|
|
|
348
|
+
:raises ~can.exceptions.CanTimeoutError:
|
|
349
|
+
If the write doesn't finish within ``timeout``.
|
|
281
350
|
"""
|
|
282
351
|
|
|
283
352
|
# Reject extended frames if uint16 ID is selected
|
|
@@ -294,8 +363,17 @@ class FlexibleSerial(BusABC):
|
|
|
294
363
|
if msg.is_remote_frame:
|
|
295
364
|
arbitration_id |= CAN_RTR_FLAG
|
|
296
365
|
|
|
297
|
-
# Generate timestamp even when frame format options omits it
|
|
298
|
-
|
|
366
|
+
# Generate timestamp even when frame format options omits it. msg.timestamp is seconds and
|
|
367
|
+
# the wire field uint32 milliseconds, as in python-can's serial interface. Out of range is
|
|
368
|
+
# clamped rather than raised, so one odd clock doesn't stop a live trace - but said once, so
|
|
369
|
+
# it isn't silent either.
|
|
370
|
+
timestamp_ms = int(msg.timestamp * 1000)
|
|
371
|
+
timestamp_int = max(0, min(timestamp_ms, 0xFFFFFFFF))
|
|
372
|
+
if self._has_timestamp and not self._timestamp_clamp_warned and timestamp_int != timestamp_ms:
|
|
373
|
+
self._timestamp_clamp_warned = True
|
|
374
|
+
logger.warning(f"{self.channel}: timestamp {msg.timestamp} s doesn't fit the frame's uint32 "
|
|
375
|
+
f"millisecond field, sent as {timestamp_int} ms. Later out-of-range timestamps "
|
|
376
|
+
f"are clamped the same way without another warning.")
|
|
299
377
|
|
|
300
378
|
# Construct frame
|
|
301
379
|
frame = bytearray(self._sof)
|
|
@@ -304,16 +382,30 @@ class FlexibleSerial(BusABC):
|
|
|
304
382
|
frame += self._crc_calculate(memoryview(frame)[self._crc_calc_start_offset:])
|
|
305
383
|
frame += self._eof
|
|
306
384
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
385
|
+
# Building the frame above touches nothing shared. Everything below does: write_timeout
|
|
386
|
+
# is port-wide, and the echo and callback both report frame order.
|
|
387
|
+
with self._tx_lock:
|
|
388
|
+
try:
|
|
389
|
+
if timeout != self._ser.write_timeout:
|
|
390
|
+
self._ser.write_timeout = timeout
|
|
391
|
+
self._ser.write(frame)
|
|
392
|
+
|
|
393
|
+
if self._receive_own_messages:
|
|
394
|
+
echo = copy.copy(msg)
|
|
395
|
+
echo.data = bytearray(msg.data)
|
|
396
|
+
# Same rule _recv_internal uses when the wire carries no timestamp: pass
|
|
397
|
+
# through what we were given, otherwise stamp it. can.Message defaults
|
|
398
|
+
# timestamp to 0.0, so an unset one lands here
|
|
399
|
+
if not msg.timestamp:
|
|
400
|
+
echo.timestamp = time.time()
|
|
401
|
+
self._rx_queue.append(echo)
|
|
402
|
+
|
|
403
|
+
if self._on_data_processed_callback:
|
|
404
|
+
self._on_data_processed_callback(frame, False)
|
|
405
|
+
except serial.PortNotOpenError as error:
|
|
406
|
+
raise CanOperationError("writing to closed port") from error
|
|
407
|
+
except serial.SerialTimeoutException as error:
|
|
408
|
+
raise CanTimeoutError() from error
|
|
317
409
|
|
|
318
410
|
@staticmethod
|
|
319
411
|
def decode_serial_can_error(msg: Message) -> str:
|
|
@@ -335,7 +427,9 @@ class FlexibleSerial(BusABC):
|
|
|
335
427
|
else:
|
|
336
428
|
return text
|
|
337
429
|
elif msg.data[0] == FlexibleSerial.SerialError.BAD_DLC:
|
|
338
|
-
|
|
430
|
+
# The error frame keeps the bad frame's FD flag, which decided the limit it broke
|
|
431
|
+
limit = MAX_DATA_LEN_FD if msg.is_fd else MAX_DATA_LEN_CLASSIC
|
|
432
|
+
text = FlexibleSerial.ERROR_TEXT[FlexibleSerial.SerialError.BAD_DLC] + f" (>{limit} bytes)"
|
|
339
433
|
if len(msg.data) >= 2:
|
|
340
434
|
return text + f" -> Rx data length = {msg.data[1]}"
|
|
341
435
|
else:
|
|
@@ -359,12 +453,26 @@ class FlexibleSerial(BusABC):
|
|
|
359
453
|
Received message and :obj:`False` (because no filtering as taken place).
|
|
360
454
|
"""
|
|
361
455
|
|
|
456
|
+
# Queued on an earlier call - hand the next one over without touching the port at all.
|
|
457
|
+
if self._rx_queue:
|
|
458
|
+
return (self._rx_queue.popleft(), False)
|
|
459
|
+
|
|
362
460
|
timer = TimeoutTimer(timeout)
|
|
363
461
|
try:
|
|
364
462
|
while (True):
|
|
463
|
+
# Drain every complete frame the buffer holds rather than just the first. Each is
|
|
464
|
+
# timestamped as it is found, inside this loop, so their timestamps reflect when
|
|
465
|
+
# the bytes were read and not what happened between calls.
|
|
365
466
|
msg = self._try_decode_message_from_buffer()
|
|
366
467
|
if msg is not None:
|
|
367
|
-
|
|
468
|
+
self._rx_queue.append(msg)
|
|
469
|
+
continue
|
|
470
|
+
|
|
471
|
+
# Buffer holds no more complete frames. Anything queued - a frame from the pass
|
|
472
|
+
# above, or an echo send() left here - goes out now rather than waiting behind
|
|
473
|
+
# the blocking read below.
|
|
474
|
+
if self._rx_queue:
|
|
475
|
+
return (self._rx_queue.popleft(), False)
|
|
368
476
|
|
|
369
477
|
# The parser needs more bytes; allow one serial read before
|
|
370
478
|
# checking the deadline again so recv(0) still polls hardware.
|
|
@@ -372,11 +480,22 @@ class FlexibleSerial(BusABC):
|
|
|
372
480
|
self._ser.timeout = timer.remaining_time()
|
|
373
481
|
# get bytes in hw serial rx buffer, up to an eof delimiter match
|
|
374
482
|
new_rx_bytes = self._ser.read_until(self._eof)
|
|
483
|
+
# ...then take the rest of what the driver already had. read_until stops at the
|
|
484
|
+
# first EOF, so on its own it leaves a backlog behind and each frame in it would
|
|
485
|
+
# not be parsed - or timestamped - until its own recv() came round, after the
|
|
486
|
+
# previous frame had been through the caller's listeners. read_all does not
|
|
487
|
+
# block, so this costs nothing when there is no backlog.
|
|
488
|
+
new_rx_bytes += self._ser.read_all()
|
|
375
489
|
self._rx_buffer.extend(new_rx_bytes)
|
|
376
490
|
|
|
491
|
+
# Queue the first frame and go back round rather than returning it here, so the
|
|
492
|
+
# drain at the top of the loop parses and timestamps the rest of what the read
|
|
493
|
+
# above brought in, in the same pass. Returning straight from here would leave
|
|
494
|
+
# the backlog unparsed until the next call, which is the thing being fixed.
|
|
377
495
|
msg = self._try_decode_message_from_buffer()
|
|
378
496
|
if msg is not None:
|
|
379
|
-
|
|
497
|
+
self._rx_queue.append(msg)
|
|
498
|
+
continue
|
|
380
499
|
|
|
381
500
|
if timer.expired():
|
|
382
501
|
return (None, False)
|
|
@@ -394,14 +513,77 @@ class FlexibleSerial(BusABC):
|
|
|
394
513
|
except Exception as exception:
|
|
395
514
|
raise CanOperationError("Cannot fetch fileno") from exception
|
|
396
515
|
|
|
516
|
+
def _set_low_latency(self) -> None:
|
|
517
|
+
"""Make the open port deliver received bytes as they arrive, for the hardware that needs telling.
|
|
518
|
+
|
|
519
|
+
- Any Linux tty: pyserial's ``set_low_latency_mode`` (the ``ASYNC_LOW_LATENCY`` flag, no root
|
|
520
|
+
needed). FTDI's driver turns it into a 1 ms latency timer instead of the default 16 ms, and keeps
|
|
521
|
+
it until the adapter is replugged, so later opens by other programs get it too.
|
|
522
|
+
- Advantech PCIE-16xx: RX FIFO trigger set to 1 byte (see :attr:`ADVANTECH_PCI_DRIVER`). The driver
|
|
523
|
+
puts it back to 32 the next time the port is opened with nothing else holding it open.
|
|
524
|
+
|
|
525
|
+
Neither failing stops the bus opening; it only logs a warning.
|
|
526
|
+
"""
|
|
527
|
+
set_low_latency_mode = getattr(self._ser, "set_low_latency_mode", None)
|
|
528
|
+
if set_low_latency_mode is not None:
|
|
529
|
+
try:
|
|
530
|
+
set_low_latency_mode(True)
|
|
531
|
+
logger.info(f"{self.channel}: low latency mode on")
|
|
532
|
+
except (NotImplementedError, ValueError, OSError) as error:
|
|
533
|
+
logger.warning(f"{self.channel}: could not set low latency mode: {error}")
|
|
534
|
+
|
|
535
|
+
if self._is_advantech_port(self.channel):
|
|
536
|
+
import fcntl # POSIX only; _is_advantech_port is only ever True on Linux
|
|
537
|
+
try:
|
|
538
|
+
fcntl.ioctl(self._ser.fileno(), self.ADVANTECH_WRITE_REG, struct.pack("BB", self.ADVANTECH_RXTRG_REG, 1))
|
|
539
|
+
logger.info(f"{self.channel}: Advantech RX FIFO trigger set to 1 byte")
|
|
540
|
+
except OSError as error:
|
|
541
|
+
logger.warning(f"{self.channel}: could not set the Advantech RX FIFO trigger: {error}")
|
|
542
|
+
|
|
543
|
+
@classmethod
|
|
544
|
+
def _is_advantech_port(cls, channel: str) -> bool:
|
|
545
|
+
"""Whether a Linux tty belongs to an Advantech PCIE-16xx card, from the driver bound to its PCI device.
|
|
546
|
+
|
|
547
|
+
Follows symlinks such as ``/dev/hil-1622b-ch4`` or ``/dev/serial/by-id/...`` to the tty, then walks
|
|
548
|
+
up its sysfs device path: the tty itself sits on serial-core's own devices, below the PCI device the
|
|
549
|
+
Advantech driver binds to. False for anything else, including non-Linux ports and URLs.
|
|
550
|
+
"""
|
|
551
|
+
tty = os.path.basename(os.path.realpath(channel))
|
|
552
|
+
device = os.path.realpath(f"/sys/class/tty/{tty}/device")
|
|
553
|
+
while device.startswith("/sys/devices/"):
|
|
554
|
+
driver = os.path.join(device, "driver")
|
|
555
|
+
if os.path.islink(driver) and os.path.basename(os.path.realpath(driver)) == cls.ADVANTECH_PCI_DRIVER:
|
|
556
|
+
return True
|
|
557
|
+
device = os.path.dirname(device)
|
|
558
|
+
return False
|
|
559
|
+
|
|
397
560
|
@staticmethod
|
|
398
|
-
def
|
|
561
|
+
def _is_unpopulated_uart(channel: str) -> bool:
|
|
562
|
+
"""Whether a Linux tty is a serial-core placeholder with no UART behind it, eg. the 8250
|
|
563
|
+
driver's spare ttyS4-31.
|
|
564
|
+
|
|
565
|
+
pyserial hides those by their sysfs subsystem being "platform", which stopped working once
|
|
566
|
+
the kernel moved serial-core ttys onto its own serial-base bus (6.5): every ttyS0-31 is
|
|
567
|
+
listed. serial-core's ``type`` attribute is the UART type it probed, 0 (PORT_UNKNOWN) when
|
|
568
|
+
none answered, the same thing setserial reports as "unknown". False for anything without
|
|
569
|
+
that attribute, so non-serial-core ttys, non-Linux ports and URLs are always kept.
|
|
570
|
+
"""
|
|
571
|
+
tty = os.path.basename(os.path.realpath(channel))
|
|
572
|
+
try:
|
|
573
|
+
with open(f"/sys/class/tty/{tty}/type") as f:
|
|
574
|
+
return f.read().strip() == "0"
|
|
575
|
+
except OSError:
|
|
576
|
+
return False
|
|
577
|
+
|
|
578
|
+
@classmethod
|
|
579
|
+
def _detect_available_configs(cls) -> Sequence[AutoDetectedConfig]:
|
|
399
580
|
configs: list[AutoDetectedConfig] = []
|
|
400
581
|
if serial is None:
|
|
401
582
|
return configs
|
|
402
583
|
|
|
403
584
|
for port in serial.tools.list_ports.comports():
|
|
404
|
-
|
|
585
|
+
if not cls._is_unpopulated_uart(port.device):
|
|
586
|
+
configs.append({"interface": "flexible_serial", "channel": port.device})
|
|
405
587
|
return configs
|
|
406
588
|
|
|
407
589
|
@classmethod
|
|
@@ -411,13 +593,13 @@ class FlexibleSerial(BusABC):
|
|
|
411
593
|
raise TypeError(f"'{name}' must be bool, not {type(value).__name__}")
|
|
412
594
|
return value
|
|
413
595
|
|
|
414
|
-
def
|
|
415
|
-
value = kwargs.pop("
|
|
596
|
+
def _pop_data_processed_callback(self, kwargs: dict[str, Any]):
|
|
597
|
+
value = kwargs.pop("data_processed_callback", None)
|
|
416
598
|
if value is None:
|
|
417
599
|
self._on_data_processed_callback = None
|
|
418
600
|
return
|
|
419
601
|
if not callable(value):
|
|
420
|
-
raise TypeError("'
|
|
602
|
+
raise TypeError("'data_processed_callback' must be callable")
|
|
421
603
|
self._on_data_processed_callback = value
|
|
422
604
|
|
|
423
605
|
def _configure_header(
|
|
@@ -703,7 +885,7 @@ class FlexibleSerial(BusABC):
|
|
|
703
885
|
# Validate DLC before trusting it as the payload length.
|
|
704
886
|
if dlc > (MAX_DATA_LEN_FD if is_can_fd else MAX_DATA_LEN_CLASSIC):
|
|
705
887
|
if self._on_data_processed_callback:
|
|
706
|
-
self._on_data_processed_callback(self._rx_buffer[0], True)
|
|
888
|
+
self._on_data_processed_callback(self._rx_buffer[0:1], True)
|
|
707
889
|
del self._rx_buffer[0]
|
|
708
890
|
return self._build_rx_error_message(
|
|
709
891
|
self.SerialError.BAD_DLC,
|
|
@@ -728,7 +910,7 @@ class FlexibleSerial(BusABC):
|
|
|
728
910
|
# Validate EOF delimiter.
|
|
729
911
|
if self._rx_buffer[eof_offset:frame_end] != self._eof:
|
|
730
912
|
if self._on_data_processed_callback:
|
|
731
|
-
self._on_data_processed_callback(self._rx_buffer[0], True)
|
|
913
|
+
self._on_data_processed_callback(self._rx_buffer[0:1], True)
|
|
732
914
|
del self._rx_buffer[0]
|
|
733
915
|
return self._build_rx_error_message(
|
|
734
916
|
self.SerialError.BAD_EOF,
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-can-flexible-serial
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: python-can interface plugin for configurable, framed binary serial protocols
|
|
5
|
+
Author: Matt Bulow
|
|
6
|
+
Maintainer: Matt Bulow
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: python-can plugin interface, https://python-can.readthedocs.io/en/stable/plugin-interface.html
|
|
9
|
+
Keywords: python-can,CAN,CANbus,CAN FD,serial,UART,plugin
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Software Development :: Embedded Systems
|
|
21
|
+
Classifier: Topic :: Software Development :: Embedded Systems :: Controller Area Network (CAN)
|
|
22
|
+
Classifier: Topic :: System :: Hardware :: Hardware Drivers
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering :: Interface Engine/Protocol Translator
|
|
24
|
+
Classifier: Topic :: Terminals :: Serial
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: python-can<5,>=4.4
|
|
29
|
+
Requires-Dist: pyserial>=3.5
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# python-can-flexible-serial
|
|
33
|
+
|
|
34
|
+
A [python-can](https://python-can.readthedocs.io/) interface plugin that speaks a
|
|
35
|
+
configurable, framed binary protocol over a serial port. It follows the
|
|
36
|
+
[python-can plugin interface](https://python-can.readthedocs.io/en/stable/plugin-interface.html),
|
|
37
|
+
so once installed it is usable anywhere python-can accepts an `interface=`
|
|
38
|
+
name — `can.Bus`, `python -m can.viewer`, `python -m can.logger`,
|
|
39
|
+
`python -m can.bridge`, etc. — alongside the other community interface
|
|
40
|
+
plugins listed on that page (`python-can-canine`, `python-can-cvector`,
|
|
41
|
+
`python-can-remote`, `python-can-sontheim`, `python-can-cando`,
|
|
42
|
+
`python-can-candle`, ...).
|
|
43
|
+
|
|
44
|
+
Unlike the fixed-layout `slcan` and `serial` interfaces bundled with
|
|
45
|
+
python-can, the frame layout here is configurable via constructor kwargs
|
|
46
|
+
(SOF/EOF delimiters, ID width, optional CRC, optional flags byte, optional
|
|
47
|
+
timestamp) so it can be adapted to match firmware on the other end of the
|
|
48
|
+
serial link without changing code.
|
|
49
|
+
|
|
50
|
+
It's just as useful with no CAN anywhere. Frame a plain serial link's messages this way and python-can's tooling works on them without writing a parser: `can.logger` traces (`.blf`, `.asc`, `.mf4`, ...), `can.viewer`, `can.bridge`, message filters, periodic sends, and DBC decoding of the payloads with [cantools](https://cantools.readthedocs.io/).
|
|
51
|
+
|
|
52
|
+
## Frame layout
|
|
53
|
+
|
|
54
|
+
| Section | Field | Size | Optional / Configurable |
|
|
55
|
+
|---|---|---|---|
|
|
56
|
+
| — | SOF | 1+ bytes | Configurable delimiter (default `0xAA`) |
|
|
57
|
+
| Header | timestamp | 0 or 4 bytes | Optional — omitted when `no_timestamp=True` |
|
|
58
|
+
| Header | dlc | 1 byte | Always present |
|
|
59
|
+
| Header | flags | 0 or 1 byte | Optional — only present when `flags_byte=True` |
|
|
60
|
+
| Header | id | 2 or 4 bytes | Sizeable — 2 bytes when `uint16_id=True`, else 4 |
|
|
61
|
+
| Payload | data | 0-64 bytes | Sizeable — length equals `dlc` |
|
|
62
|
+
| — | crc | 0 or N bytes | Optional — size/presence set by `crc_type` |
|
|
63
|
+
| — | EOF | 1+ bytes | Configurable delimiter (default `0xBB`) |
|
|
64
|
+
|
|
65
|
+
Details on each section:
|
|
66
|
+
|
|
67
|
+
- **SOF / EOF** — arbitrary-length byte sequences used purely as framing delimiters. They are never covered by the CRC.
|
|
68
|
+
- **timestamp** *(optional, fixed 4 bytes when present)* — `uint32` milliseconds. It works the same way as in python-can's built-in `serial` interface: on send, `msg.timestamp` (seconds) is multiplied by 1000 and truncated; on receive, the field is divided by 1000 into `msg.timestamp`. It's whatever clock the sender uses (device uptime, say), not wall-clock time, and it wraps after about 49.7 days. If you use it, set `msg.timestamp` yourself: a message stamped with wall-clock time, like `time.time()`, doesn't fit. Out-of-range values are clamped to `0`–`0xFFFFFFFF` rather than raising as `serial` does, so one frame with an odd clock doesn't stop a live trace; the first one on each bus logs a warning on the `can.flexible_serial` logger. Omit the field entirely with `no_timestamp=True` to save 4 bytes per frame; python-can then stamps each frame when it's read.
|
|
69
|
+
- **dlc** *(always present, fixed 1 byte)* — the payload length in bytes (not a CAN DLC code), which also sizes the `data` section on receive.
|
|
70
|
+
- **flags** *(optional, fixed 1 byte when present)* — see "Flags byte" below for the bit layout. When absent, the same metadata is instead packed into spare bits of the `id` field (see below).
|
|
71
|
+
- **id** *(always present, sizeable: 2 or 4 bytes)* — arbitration ID. `uint16_id=True` shrinks it to 2 bytes but rejects extended (29-bit) IDs; the default 4-byte field supports both standard and extended IDs.
|
|
72
|
+
- **data** *(sizeable, 0-64 bytes)* — the CAN payload, `dlc` bytes long. How many bytes are allowed depends on how the layout says a frame is CAN FD:
|
|
73
|
+
|
|
74
|
+
| Layout | CAN FD frames | Max payload |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| `flags_byte=True` | the FD bit (and BRS bit) in the flags byte | 64 for FD frames, 8 otherwise |
|
|
77
|
+
| `uint16_id=True`, no flags byte | any frame longer than 8 bytes is taken as FD | 64 |
|
|
78
|
+
| 4-byte ID, no flags byte | not representable | 8 |
|
|
79
|
+
|
|
80
|
+
A longer payload than the layout allows is received as a `BAD_DLC` error frame (see "Error frames").
|
|
81
|
+
- **crc** *(optional, 0 or 2 bytes)* — set by `crc_type`: `"none"`, or `"crc16-ccitt"` (CRC-16/CCITT-FALSE: polynomial `0x1021`, initial value `0xFFFF`, no reflection, no final XOR — Python's `binascii.crc_hqx(data, 0xFFFF)`). It covers the **flags byte (if present), the ID and the data** — not the SOF, timestamp, length byte or EOF. That's by design: the length byte decides where the receiver reads the CRC and EOF from, so a corrupted length still fails the CRC or EOF check.
|
|
82
|
+
|
|
83
|
+
Byte order for the timestamp, ID and CRC fields is little-endian by default, or big-endian when `big_endian=True`.
|
|
84
|
+
|
|
85
|
+
### Flags byte
|
|
86
|
+
|
|
87
|
+
When `flags_byte=True`, extended/error/remote/FD metadata is carried in a dedicated byte instead of being packed into spare bits of the arbitration ID:
|
|
88
|
+
|
|
89
|
+
| Bit | Mask | Meaning | From / to `can.Message` |
|
|
90
|
+
|------|------|---------|---|
|
|
91
|
+
| 0 | `0x01` | Error frame | `is_error_frame` |
|
|
92
|
+
| 1 | `0x02` | Remote frame (RTR) | `is_remote_frame` |
|
|
93
|
+
| 2 | `0x04` | Extended (29-bit) ID | `is_extended_id` |
|
|
94
|
+
| 3 | `0x08` | CAN FD frame | `is_fd` |
|
|
95
|
+
| 4 | `0x10` | CAN FD bit-rate switch | `bitrate_switch` |
|
|
96
|
+
| 5 | `0x20` | Frame originated as RX (vs. TX) | `is_rx` |
|
|
97
|
+
|
|
98
|
+
Every bit is passed through as-is in both directions; the interface never rewrites them. That includes bit 5: `can.Message` defaults to `is_rx=True`, so a sent frame carries the RX bit unless you send it with `is_rx=False`. Forwarding a recorded trace keeps each frame's direction.
|
|
99
|
+
|
|
100
|
+
When `flags_byte=False`, extended/error/remote status is instead OR'd into spare high bits of the 32-bit arbitration ID field (standard `python-can` convention: `CAN_EFF_FLAG`, `CAN_ERR_FLAG`, `CAN_RTR_FLAG`). This only applies when `uint16_id=False`: a 16-bit ID field has no spare bits, so with `uint16_id=True` and no flags byte, error and remote frames can't be represented and every received frame is a data frame with `is_rx=True`.
|
|
101
|
+
|
|
102
|
+
### Error frames
|
|
103
|
+
|
|
104
|
+
Protocol-level errors are surfaced as `Message` objects with `is_error_frame=True` rather than raising exceptions, matching how other python-can interfaces report bus errors. Byte 0 of their data is the error code (`FlexibleSerial.SerialError`), and byte 1, when present, a detail:
|
|
105
|
+
|
|
106
|
+
| Code | Name | Byte 1 | What the receiver does next |
|
|
107
|
+
|---|---|---|---|
|
|
108
|
+
| 1 | `DESYNC` | number of bytes dropped | Skips the bytes before the next SOF |
|
|
109
|
+
| 2 | `BAD_DLC` | the length received | Drops one byte and resynchronizes; the rest of the bad frame then usually shows up as a `DESYNC` |
|
|
110
|
+
| 3 | `BAD_EOF` | — | Drops one byte and resynchronizes; the rest of the bad frame then usually shows up as a `DESYNC` |
|
|
111
|
+
| 4 | `BAD_CRC` | — | Drops the whole frame |
|
|
112
|
+
|
|
113
|
+
Use `FlexibleSerial.decode_serial_can_error(msg)` to get a human-readable description, e.g. `"Start-of-Frame desync -> 3 bytes dropped"`. `BAD_DLC`, `BAD_EOF` and `BAD_CRC` error frames carry the ID, timestamp and flags read from the bad frame's header; `DESYNC` ones have ID 0 and the time the junk was found.
|
|
114
|
+
|
|
115
|
+
## Installation
|
|
116
|
+
|
|
117
|
+
From PyPI:
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
pip install python-can-flexible-serial
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
or, in a project managed with [uv](https://docs.astral.sh/uv/), `uv add python-can-flexible-serial`.
|
|
124
|
+
|
|
125
|
+
### Using a local clone from another project
|
|
126
|
+
|
|
127
|
+
If that project is managed with [uv](https://docs.astral.sh/uv/), add this
|
|
128
|
+
repo as an editable path dependency, pointing at wherever you've cloned it:
|
|
129
|
+
|
|
130
|
+
```powershell
|
|
131
|
+
uv add --editable plugins/python-can-flexible-serial
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Run this from *that* project's directory, not from within this repo — `uv
|
|
135
|
+
add` adds a dependency to the project in your current directory, so running
|
|
136
|
+
it from inside `python-can-flexible-serial` itself is a self-dependency and
|
|
137
|
+
uv will refuse it.
|
|
138
|
+
|
|
139
|
+
With plain pip, from that project's environment:
|
|
140
|
+
|
|
141
|
+
```powershell
|
|
142
|
+
python -m pip install -e /path/to/python-can-flexible-serial
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Working on this repo itself
|
|
146
|
+
|
|
147
|
+
Use `uv sync` (a uv-managed project installs itself editable by default):
|
|
148
|
+
|
|
149
|
+
```powershell
|
|
150
|
+
uv sync
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Or with plain pip, from this repo's root:
|
|
154
|
+
|
|
155
|
+
```powershell
|
|
156
|
+
python -m pip install -e .
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Either way, this registers the plugin with python-can via the `can.interface`
|
|
160
|
+
entry point (`flexible_serial = "flexible_serial.bus:FlexibleSerial"`), so it
|
|
161
|
+
becomes available as `interface="flexible_serial"` anywhere python-can
|
|
162
|
+
resolves interfaces, without any further imports.
|
|
163
|
+
|
|
164
|
+
## Usage
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
import can
|
|
168
|
+
|
|
169
|
+
bus = can.interface.Bus(
|
|
170
|
+
interface="flexible_serial",
|
|
171
|
+
channel="COM3",
|
|
172
|
+
baudrate=115200,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
bus.send(can.Message(arbitration_id=0x123, data=[0x01, 0x02, 0x03], is_extended_id=False))
|
|
176
|
+
msg = bus.recv(timeout=1.0) # None if nothing arrived in time
|
|
177
|
+
|
|
178
|
+
bus.shutdown()
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Only opening the bus is specific to this plugin: the constructor arguments below pick the port and the frame layout. Everything after that is the standard [python-can bus API](https://python-can.readthedocs.io/en/stable/bus.html), the same as with any other interface: `send()` and `recv()`, iterating over the bus, `can.Notifier` with listeners and loggers, `send_periodic()`, `set_filters()`, and `with can.Bus(...) as bus:` or `shutdown()`. The few behaviors specific to this interface are under "Sending and receiving" below.
|
|
182
|
+
|
|
183
|
+
### Constructor arguments
|
|
184
|
+
|
|
185
|
+
| Argument | Type | Default | Description |
|
|
186
|
+
|---|---|---|---|
|
|
187
|
+
| `channel` | `str` | *(required)* | Serial device, e.g. `"COM3"` or `"/dev/ttyUSB0"`, or any [pyserial URL](https://pyserial.readthedocs.io/en/latest/url_handlers.html): `loop://` for testing, `socket://host:port`, `rfc2217://host:port`, ... |
|
|
188
|
+
| `baudrate` | `int` | `115200` | Serial baud rate |
|
|
189
|
+
| `timeout` | `float` | `0.1` | Serial read timeout, in seconds |
|
|
190
|
+
| `rtscts` | `bool` | `False` | Enable RTS/CTS hardware handshake |
|
|
191
|
+
| `low_latency` | `bool` | `True` | Linux: have the port hand over received bytes as they arrive (see "Low latency" below) |
|
|
192
|
+
| `sof` | `bytes` \| hex `str` | `0xAA` | Start-of-frame delimiter |
|
|
193
|
+
| `eof` | `bytes` \| hex `str` | `0xBB` | End-of-frame delimiter |
|
|
194
|
+
| `big_endian` | `bool` | `False` | Byte order for the timestamp, ID and CRC fields |
|
|
195
|
+
| `uint16_id` | `bool` | `False` | Use a 2-byte arbitration ID instead of 4-byte; rejects extended IDs |
|
|
196
|
+
| `flags_byte` | `bool` | `False` | Carry frame metadata in a dedicated byte (see "Flags byte" above) |
|
|
197
|
+
| `no_timestamp` | `bool` | `False` | Omit the 4-byte timestamp header field |
|
|
198
|
+
| `crc_type` | `str` | `"none"` | One of `"none"`, `"crc16-ccitt"` |
|
|
199
|
+
| `receive_own_messages` | `bool` | `False` | python-can's standard option: every message you send is also returned by `recv()` (see "Sending and receiving" below) |
|
|
200
|
+
| `data_processed_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, including bytes dropped while resynchronizing; useful for logging/debugging the raw wire traffic. It runs on the thread calling `send()` or `recv()`, so keep it quick. |
|
|
201
|
+
|
|
202
|
+
The `bool` arguments must be real booleans in Python code. Any other keyword arguments are passed on to python-can's `BusABC`, such as `can_filters`.
|
|
203
|
+
|
|
204
|
+
`sof` and `eof` accept either raw `bytes` (when constructing a `Bus` from Python) or a hex string (when passed as a CLI `--bus-kwargs` value, since python-can CLI tools pass kwargs as strings). A hex string can be written as `0x4040`, `4040`, `40 40` or `\x40\x40`, and may use `_`, `-`, `:` or `,` between bytes:
|
|
205
|
+
|
|
206
|
+
```powershell
|
|
207
|
+
python -m can.logger -i flexible_serial -c COM3 --bus-kwargs sof=0xAA eof=0x0D0A
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Prefer the `0x` prefix for CLI hex values, especially if the delimiter contains only digits, since python-can auto-converts bare numeric kwargs.
|
|
211
|
+
|
|
212
|
+
### Low latency
|
|
213
|
+
|
|
214
|
+
By default a serial port may hold received bytes back and deliver them in
|
|
215
|
+
batches, which delays received frames and their timestamps (with
|
|
216
|
+
`no_timestamp=True`, a frame is stamped when it is read). The bus turns that
|
|
217
|
+
off when it opens the port, for the hardware that needs telling. It is Linux
|
|
218
|
+
only and best effort: anything that can't be set may be logged as a warning and the
|
|
219
|
+
port opens anyway. Pass `low_latency=False` to leave the port's own settings
|
|
220
|
+
alone.
|
|
221
|
+
|
|
222
|
+
| Hardware | Port's own setting | With `low_latency=True` (the default) |
|
|
223
|
+
|---|---|---|
|
|
224
|
+
| FTDI USB serial (`ftdi_sio`) | 16 ms latency timer | 1 ms, via pyserial's `set_low_latency_mode`. The adapter keeps it until it's replugged, so later opens by other programs get it too. |
|
|
225
|
+
| Advantech PCIE-16xx cards (`adv17v35x` driver) | RX FIFO interrupt after 32 bytes, or after ~4 idle characters | Interrupt on every byte. The driver puts it back to 32 the next time the port is opened with nothing else holding it open. |
|
|
226
|
+
| Other Linux ttys | — | pyserial's `set_low_latency_mode`; what that does depends on the driver |
|
|
227
|
+
|
|
228
|
+
No root is needed for either. On Windows, set the FTDI latency timer in the
|
|
229
|
+
port's Device Manager properties instead.
|
|
230
|
+
|
|
231
|
+
### Sending and receiving
|
|
232
|
+
|
|
233
|
+
- **Threads:** `send()` is safe to call from several threads at once, including python-can's periodic send tasks (`bus.send_periodic()`); frames are never interleaved on the wire.
|
|
234
|
+
- **Send timeout:** `send(msg, timeout=...)` sets the serial write timeout for that write; a write that doesn't finish in time raises `can.CanTimeoutError`.
|
|
235
|
+
- **Bursts:** each read takes every complete frame the port has buffered and stamps them in the same pass, so with `no_timestamp=True` a burst gets timestamps from when its bytes were read, not from when your code got round to each `recv()`.
|
|
236
|
+
- **Own messages:** with `receive_own_messages=True`, each message you send is returned by `recv()` exactly as you passed it, after it's written: nothing is re-encoded or rewritten, including `is_rx`, so set `is_rx=False` when sending if the direction matters downstream. A message with no timestamp (`0.0`, the `can.Message` default) is stamped with the time it was written. Echoes are queued in order with received frames.
|
|
237
|
+
- **`fileno()`** returns the port's file descriptor where the platform has one, so the bus works with `select`-based tools and python-can's asyncio `Notifier`.
|
|
238
|
+
|
|
239
|
+
### CLI examples
|
|
240
|
+
|
|
241
|
+
```powershell
|
|
242
|
+
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
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Bridging to another interface:
|
|
246
|
+
|
|
247
|
+
```powershell
|
|
248
|
+
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
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### Autodetection
|
|
252
|
+
|
|
253
|
+
Like the built-in `serial` interface, available serial ports are surfaced
|
|
254
|
+
through python-can's autodetection API:
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
import can
|
|
258
|
+
print(can.detect_available_configs(interfaces=["flexible_serial"]))
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
It lists every port pyserial finds, except, on Linux, serial-core placeholder ttys with no UART behind them (such as the spare `ttyS4`–`ttyS31` the 8250 driver always creates). Newer kernels (6.5+) stopped pyserial from hiding those itself.
|
|
262
|
+
|
|
263
|
+
## Development
|
|
264
|
+
|
|
265
|
+
Test dependencies (`pytest`) are in the `dev` dependency group, which `uv sync` installs by default:
|
|
266
|
+
|
|
267
|
+
```powershell
|
|
268
|
+
uv sync
|
|
269
|
+
```
|
|
270
|
+
or, with pip 25.1 or newer:
|
|
271
|
+
```powershell
|
|
272
|
+
python -m pip install -e . --group dev
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Run the test suite with:
|
|
276
|
+
|
|
277
|
+
```
|
|
278
|
+
uv run pytest
|
|
279
|
+
```
|
|
280
|
+
or
|
|
281
|
+
```powershell
|
|
282
|
+
python -m pytest
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
To run it against a specific Python version, the way CI does: `uv run --python 3.9 pytest`.
|
|
286
|
+
|
|
287
|
+
`tests/test_bus.py` runs round-trip tests against two backends — a fully in-memory mocked serial port and pyserial's `loop://` — mirroring the structure of python-can's own `serial` interface test suite. A few tests exercise `fcntl` and are skipped on Windows; they run in CI.
|
|
288
|
+
|
|
289
|
+
CI and the release process, including publishing to PyPI, are described in `RELEASING.md` in the repository.
|
|
290
|
+
|
|
291
|
+
## Future improvement ideas
|
|
292
|
+
|
|
293
|
+
- **`can_filters` support** — already works today for free: `BusABC.recv()`
|
|
294
|
+
always calls `_matches_filters()` in software unless `_recv_internal`
|
|
295
|
+
reports the message as pre-filtered, and this interface never does. What's
|
|
296
|
+
still missing is a hardware-style optimization: implement the optional
|
|
297
|
+
`_apply_filters()` hook so frames can be dropped *before* the CRC check
|
|
298
|
+
runs, instead of always paying the full decode+CRC cost and filtering
|
|
299
|
+
afterward.
|
|
300
|
+
- Support the CAN FD protocol for the `BusABC` parent (`can_protocol`
|
|
301
|
+
currently reports `CAN_20` unconditionally).
|
|
302
|
+
- Add an option for arbitrary (non-CAN) data length up to 255 bytes, since
|
|
303
|
+
`dlc` is only 1 byte — useful for passing arbitrary serial payloads through
|
|
304
|
+
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=0ALHPKomevVitfn22fxroOJH9F4PIO1ckwPERKK1Lus,40282
|
|
3
|
+
python_can_flexible_serial-0.2.2.dist-info/licenses/LICENSE,sha256=gldqXfJjP9VXoXHVOw464BYthZBw6u3ZMNtPjDTZKe8,1076
|
|
4
|
+
python_can_flexible_serial-0.2.2.dist-info/METADATA,sha256=rwedELYmzTT92_xQZDv6aybHo7V8CuVQdAcHfYVbEnU,18155
|
|
5
|
+
python_can_flexible_serial-0.2.2.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
6
|
+
python_can_flexible_serial-0.2.2.dist-info/entry_points.txt,sha256=FvRVfuDmEVWkQEeHq4CEczaXichOYQpbSLzelMhE8V0,69
|
|
7
|
+
python_can_flexible_serial-0.2.2.dist-info/top_level.txt,sha256=CBMqcoYnde__UVfBfwco7r1FFNAkA4MPV5evBl4Bsws,16
|
|
8
|
+
python_can_flexible_serial-0.2.2.dist-info/RECORD,,
|
|
@@ -1,261 +0,0 @@
|
|
|
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.
|
|
@@ -1,8 +0,0 @@
|
|
|
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,,
|
|
File without changes
|
|
File without changes
|