python-can-flexible-serial 0.1.1__tar.gz → 0.2.2__tar.gz

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.
Files changed (22) hide show
  1. {python_can_flexible_serial-0.1.1 → python_can_flexible_serial-0.2.2}/LICENSE +1 -1
  2. python_can_flexible_serial-0.2.2/MANIFEST.in +3 -0
  3. python_can_flexible_serial-0.2.2/PKG-INFO +304 -0
  4. python_can_flexible_serial-0.2.2/README.md +273 -0
  5. python_can_flexible_serial-0.2.2/pyproject.toml +61 -0
  6. {python_can_flexible_serial-0.1.1 → python_can_flexible_serial-0.2.2}/src/flexible_serial/bus.py +219 -37
  7. python_can_flexible_serial-0.2.2/src/python_can_flexible_serial.egg-info/PKG-INFO +304 -0
  8. {python_can_flexible_serial-0.1.1 → python_can_flexible_serial-0.2.2}/src/python_can_flexible_serial.egg-info/SOURCES.txt +2 -0
  9. {python_can_flexible_serial-0.1.1 → python_can_flexible_serial-0.2.2}/src/python_can_flexible_serial.egg-info/requires.txt +0 -3
  10. python_can_flexible_serial-0.2.2/tests/message_helper.py +49 -0
  11. python_can_flexible_serial-0.2.2/tests/test_bus.py +702 -0
  12. python_can_flexible_serial-0.1.1/PKG-INFO +0 -261
  13. python_can_flexible_serial-0.1.1/README.md +0 -246
  14. python_can_flexible_serial-0.1.1/pyproject.toml +0 -32
  15. python_can_flexible_serial-0.1.1/src/python_can_flexible_serial.egg-info/PKG-INFO +0 -261
  16. python_can_flexible_serial-0.1.1/tests/test_bus.py +0 -145
  17. {python_can_flexible_serial-0.1.1 → python_can_flexible_serial-0.2.2}/setup.cfg +0 -0
  18. {python_can_flexible_serial-0.1.1 → python_can_flexible_serial-0.2.2}/src/flexible_serial/__init__.py +0 -0
  19. {python_can_flexible_serial-0.1.1 → python_can_flexible_serial-0.2.2}/src/python_can_flexible_serial.egg-info/dependency_links.txt +0 -0
  20. {python_can_flexible_serial-0.1.1 → python_can_flexible_serial-0.2.2}/src/python_can_flexible_serial.egg-info/entry_points.txt +0 -0
  21. {python_can_flexible_serial-0.1.1 → python_can_flexible_serial-0.2.2}/src/python_can_flexible_serial.egg-info/top_level.txt +0 -0
  22. {python_can_flexible_serial-0.1.1 → python_can_flexible_serial-0.2.2}/tests/test_hex_bytes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Matt Bulow
3
+ Copyright (c) 2026 CisLunar Industries
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -0,0 +1,3 @@
1
+ # setuptools only picks up tests/test_*.py on its own; ship the helpers they
2
+ # import too, so the test suite runs from an unpacked sdist
3
+ recursive-include tests *.py
@@ -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,273 @@
1
+ # python-can-flexible-serial
2
+
3
+ A [python-can](https://python-can.readthedocs.io/) interface plugin that speaks a
4
+ configurable, framed binary protocol over a serial port. It follows the
5
+ [python-can plugin interface](https://python-can.readthedocs.io/en/stable/plugin-interface.html),
6
+ so once installed it is usable anywhere python-can accepts an `interface=`
7
+ name — `can.Bus`, `python -m can.viewer`, `python -m can.logger`,
8
+ `python -m can.bridge`, etc. — alongside the other community interface
9
+ plugins listed on that page (`python-can-canine`, `python-can-cvector`,
10
+ `python-can-remote`, `python-can-sontheim`, `python-can-cando`,
11
+ `python-can-candle`, ...).
12
+
13
+ Unlike the fixed-layout `slcan` and `serial` interfaces bundled with
14
+ python-can, the frame layout here is configurable via constructor kwargs
15
+ (SOF/EOF delimiters, ID width, optional CRC, optional flags byte, optional
16
+ timestamp) so it can be adapted to match firmware on the other end of the
17
+ serial link without changing code.
18
+
19
+ 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/).
20
+
21
+ ## Frame layout
22
+
23
+ | Section | Field | Size | Optional / Configurable |
24
+ |---|---|---|---|
25
+ | — | SOF | 1+ bytes | Configurable delimiter (default `0xAA`) |
26
+ | Header | timestamp | 0 or 4 bytes | Optional — omitted when `no_timestamp=True` |
27
+ | Header | dlc | 1 byte | Always present |
28
+ | Header | flags | 0 or 1 byte | Optional — only present when `flags_byte=True` |
29
+ | Header | id | 2 or 4 bytes | Sizeable — 2 bytes when `uint16_id=True`, else 4 |
30
+ | Payload | data | 0-64 bytes | Sizeable — length equals `dlc` |
31
+ | — | crc | 0 or N bytes | Optional — size/presence set by `crc_type` |
32
+ | — | EOF | 1+ bytes | Configurable delimiter (default `0xBB`) |
33
+
34
+ Details on each section:
35
+
36
+ - **SOF / EOF** — arbitrary-length byte sequences used purely as framing delimiters. They are never covered by the CRC.
37
+ - **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.
38
+ - **dlc** *(always present, fixed 1 byte)* — the payload length in bytes (not a CAN DLC code), which also sizes the `data` section on receive.
39
+ - **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).
40
+ - **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.
41
+ - **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:
42
+
43
+ | Layout | CAN FD frames | Max payload |
44
+ |---|---|---|
45
+ | `flags_byte=True` | the FD bit (and BRS bit) in the flags byte | 64 for FD frames, 8 otherwise |
46
+ | `uint16_id=True`, no flags byte | any frame longer than 8 bytes is taken as FD | 64 |
47
+ | 4-byte ID, no flags byte | not representable | 8 |
48
+
49
+ A longer payload than the layout allows is received as a `BAD_DLC` error frame (see "Error frames").
50
+ - **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.
51
+
52
+ Byte order for the timestamp, ID and CRC fields is little-endian by default, or big-endian when `big_endian=True`.
53
+
54
+ ### Flags byte
55
+
56
+ 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:
57
+
58
+ | Bit | Mask | Meaning | From / to `can.Message` |
59
+ |------|------|---------|---|
60
+ | 0 | `0x01` | Error frame | `is_error_frame` |
61
+ | 1 | `0x02` | Remote frame (RTR) | `is_remote_frame` |
62
+ | 2 | `0x04` | Extended (29-bit) ID | `is_extended_id` |
63
+ | 3 | `0x08` | CAN FD frame | `is_fd` |
64
+ | 4 | `0x10` | CAN FD bit-rate switch | `bitrate_switch` |
65
+ | 5 | `0x20` | Frame originated as RX (vs. TX) | `is_rx` |
66
+
67
+ 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.
68
+
69
+ 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`.
70
+
71
+ ### Error frames
72
+
73
+ 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:
74
+
75
+ | Code | Name | Byte 1 | What the receiver does next |
76
+ |---|---|---|---|
77
+ | 1 | `DESYNC` | number of bytes dropped | Skips the bytes before the next SOF |
78
+ | 2 | `BAD_DLC` | the length received | Drops one byte and resynchronizes; the rest of the bad frame then usually shows up as a `DESYNC` |
79
+ | 3 | `BAD_EOF` | — | Drops one byte and resynchronizes; the rest of the bad frame then usually shows up as a `DESYNC` |
80
+ | 4 | `BAD_CRC` | — | Drops the whole frame |
81
+
82
+ 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.
83
+
84
+ ## Installation
85
+
86
+ From PyPI:
87
+
88
+ ```
89
+ pip install python-can-flexible-serial
90
+ ```
91
+
92
+ or, in a project managed with [uv](https://docs.astral.sh/uv/), `uv add python-can-flexible-serial`.
93
+
94
+ ### Using a local clone from another project
95
+
96
+ If that project is managed with [uv](https://docs.astral.sh/uv/), add this
97
+ repo as an editable path dependency, pointing at wherever you've cloned it:
98
+
99
+ ```powershell
100
+ uv add --editable plugins/python-can-flexible-serial
101
+ ```
102
+
103
+ Run this from *that* project's directory, not from within this repo — `uv
104
+ add` adds a dependency to the project in your current directory, so running
105
+ it from inside `python-can-flexible-serial` itself is a self-dependency and
106
+ uv will refuse it.
107
+
108
+ With plain pip, from that project's environment:
109
+
110
+ ```powershell
111
+ python -m pip install -e /path/to/python-can-flexible-serial
112
+ ```
113
+
114
+ ### Working on this repo itself
115
+
116
+ Use `uv sync` (a uv-managed project installs itself editable by default):
117
+
118
+ ```powershell
119
+ uv sync
120
+ ```
121
+
122
+ Or with plain pip, from this repo's root:
123
+
124
+ ```powershell
125
+ python -m pip install -e .
126
+ ```
127
+
128
+ Either way, this registers the plugin with python-can via the `can.interface`
129
+ entry point (`flexible_serial = "flexible_serial.bus:FlexibleSerial"`), so it
130
+ becomes available as `interface="flexible_serial"` anywhere python-can
131
+ resolves interfaces, without any further imports.
132
+
133
+ ## Usage
134
+
135
+ ```python
136
+ import can
137
+
138
+ bus = can.interface.Bus(
139
+ interface="flexible_serial",
140
+ channel="COM3",
141
+ baudrate=115200,
142
+ )
143
+
144
+ bus.send(can.Message(arbitration_id=0x123, data=[0x01, 0x02, 0x03], is_extended_id=False))
145
+ msg = bus.recv(timeout=1.0) # None if nothing arrived in time
146
+
147
+ bus.shutdown()
148
+ ```
149
+
150
+ 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.
151
+
152
+ ### Constructor arguments
153
+
154
+ | Argument | Type | Default | Description |
155
+ |---|---|---|---|
156
+ | `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`, ... |
157
+ | `baudrate` | `int` | `115200` | Serial baud rate |
158
+ | `timeout` | `float` | `0.1` | Serial read timeout, in seconds |
159
+ | `rtscts` | `bool` | `False` | Enable RTS/CTS hardware handshake |
160
+ | `low_latency` | `bool` | `True` | Linux: have the port hand over received bytes as they arrive (see "Low latency" below) |
161
+ | `sof` | `bytes` \| hex `str` | `0xAA` | Start-of-frame delimiter |
162
+ | `eof` | `bytes` \| hex `str` | `0xBB` | End-of-frame delimiter |
163
+ | `big_endian` | `bool` | `False` | Byte order for the timestamp, ID and CRC fields |
164
+ | `uint16_id` | `bool` | `False` | Use a 2-byte arbitration ID instead of 4-byte; rejects extended IDs |
165
+ | `flags_byte` | `bool` | `False` | Carry frame metadata in a dedicated byte (see "Flags byte" above) |
166
+ | `no_timestamp` | `bool` | `False` | Omit the 4-byte timestamp header field |
167
+ | `crc_type` | `str` | `"none"` | One of `"none"`, `"crc16-ccitt"` |
168
+ | `receive_own_messages` | `bool` | `False` | python-can's standard option: every message you send is also returned by `recv()` (see "Sending and receiving" below) |
169
+ | `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. |
170
+
171
+ 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`.
172
+
173
+ `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:
174
+
175
+ ```powershell
176
+ python -m can.logger -i flexible_serial -c COM3 --bus-kwargs sof=0xAA eof=0x0D0A
177
+ ```
178
+
179
+ Prefer the `0x` prefix for CLI hex values, especially if the delimiter contains only digits, since python-can auto-converts bare numeric kwargs.
180
+
181
+ ### Low latency
182
+
183
+ By default a serial port may hold received bytes back and deliver them in
184
+ batches, which delays received frames and their timestamps (with
185
+ `no_timestamp=True`, a frame is stamped when it is read). The bus turns that
186
+ off when it opens the port, for the hardware that needs telling. It is Linux
187
+ only and best effort: anything that can't be set may be logged as a warning and the
188
+ port opens anyway. Pass `low_latency=False` to leave the port's own settings
189
+ alone.
190
+
191
+ | Hardware | Port's own setting | With `low_latency=True` (the default) |
192
+ |---|---|---|
193
+ | 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. |
194
+ | 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. |
195
+ | Other Linux ttys | — | pyserial's `set_low_latency_mode`; what that does depends on the driver |
196
+
197
+ No root is needed for either. On Windows, set the FTDI latency timer in the
198
+ port's Device Manager properties instead.
199
+
200
+ ### Sending and receiving
201
+
202
+ - **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.
203
+ - **Send timeout:** `send(msg, timeout=...)` sets the serial write timeout for that write; a write that doesn't finish in time raises `can.CanTimeoutError`.
204
+ - **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()`.
205
+ - **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.
206
+ - **`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`.
207
+
208
+ ### CLI examples
209
+
210
+ ```powershell
211
+ 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
212
+ ```
213
+
214
+ Bridging to another interface:
215
+
216
+ ```powershell
217
+ 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
218
+ ```
219
+
220
+ ### Autodetection
221
+
222
+ Like the built-in `serial` interface, available serial ports are surfaced
223
+ through python-can's autodetection API:
224
+
225
+ ```python
226
+ import can
227
+ print(can.detect_available_configs(interfaces=["flexible_serial"]))
228
+ ```
229
+
230
+ 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.
231
+
232
+ ## Development
233
+
234
+ Test dependencies (`pytest`) are in the `dev` dependency group, which `uv sync` installs by default:
235
+
236
+ ```powershell
237
+ uv sync
238
+ ```
239
+ or, with pip 25.1 or newer:
240
+ ```powershell
241
+ python -m pip install -e . --group dev
242
+ ```
243
+
244
+ Run the test suite with:
245
+
246
+ ```
247
+ uv run pytest
248
+ ```
249
+ or
250
+ ```powershell
251
+ python -m pytest
252
+ ```
253
+
254
+ To run it against a specific Python version, the way CI does: `uv run --python 3.9 pytest`.
255
+
256
+ `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.
257
+
258
+ CI and the release process, including publishing to PyPI, are described in `RELEASING.md` in the repository.
259
+
260
+ ## Future improvement ideas
261
+
262
+ - **`can_filters` support** — already works today for free: `BusABC.recv()`
263
+ always calls `_matches_filters()` in software unless `_recv_internal`
264
+ reports the message as pre-filtered, and this interface never does. What's
265
+ still missing is a hardware-style optimization: implement the optional
266
+ `_apply_filters()` hook so frames can be dropped *before* the CRC check
267
+ runs, instead of always paying the full decode+CRC cost and filtering
268
+ afterward.
269
+ - Support the CAN FD protocol for the `BusABC` parent (`can_protocol`
270
+ currently reports `CAN_20` unconditionally).
271
+ - Add an option for arbitrary (non-CAN) data length up to 255 bytes, since
272
+ `dlc` is only 1 byte — useful for passing arbitrary serial payloads through
273
+ python-can tooling without being constrained to CAN/CAN FD payload sizes.
@@ -0,0 +1,61 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "python-can-flexible-serial"
7
+ version = "0.2.2"
8
+ description = "python-can interface plugin for configurable, framed binary serial protocols"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Matt Bulow" }]
13
+ maintainers = [{ name = "Matt Bulow" }]
14
+ # Shown as "Tags" on PyPI; same style as python-can's other interface plugins
15
+ keywords = ["python-can", "CAN", "CANbus", "CAN FD", "serial", "UART", "plugin"]
16
+ # No "License ::" classifier: `license` above is the SPDX expression, and setuptools refuses both
17
+ classifiers = [
18
+ "Development Status :: 4 - Beta",
19
+ "Intended Audience :: Developers",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ # One per version the CI test matrix runs; keep them in step with it and requires-python
23
+ "Programming Language :: Python :: 3.9",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Programming Language :: Python :: 3.14",
29
+ # The CAN ones python-can itself uses, so this shows up next to it when browsing PyPI
30
+ "Topic :: Software Development :: Embedded Systems",
31
+ "Topic :: Software Development :: Embedded Systems :: Controller Area Network (CAN)",
32
+ "Topic :: System :: Hardware :: Hardware Drivers",
33
+ # Plain serial too: it's as much for parsing framed serial data with python-can's tools as for CAN
34
+ "Topic :: Scientific/Engineering :: Interface Engine/Protocol Translator",
35
+ "Topic :: Terminals :: Serial",
36
+ ]
37
+ requires-python = ">=3.9"
38
+ dependencies = [
39
+ "python-can>=4.4,<5",
40
+ "pyserial>=3.5",
41
+ ]
42
+
43
+ [dependency-groups]
44
+ dev = [
45
+ "pytest>=8",
46
+ ]
47
+
48
+ [project.urls]
49
+ "python-can plugin interface" = "https://python-can.readthedocs.io/en/stable/plugin-interface.html"
50
+
51
+ [project.entry-points."can.interface"]
52
+ flexible_serial = "flexible_serial.bus:FlexibleSerial"
53
+
54
+ [tool.setuptools]
55
+ package-dir = {"" = "src"}
56
+
57
+ [tool.setuptools.packages.find]
58
+ where = ["src"]
59
+
60
+ [tool.pytest.ini_options]
61
+ testpaths = ["tests"]