python-can-cansub 2026.5.22__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.
- python_can_cansub/__init__.py +4 -0
- python_can_cansub/cansub.py +891 -0
- python_can_cansub/cansub_protocol.py +346 -0
- python_can_cansub/cansub_root_cert.crt +11 -0
- python_can_cansub-2026.5.22.dist-info/METADATA +339 -0
- python_can_cansub-2026.5.22.dist-info/RECORD +8 -0
- python_can_cansub-2026.5.22.dist-info/WHEEL +4 -0
- python_can_cansub-2026.5.22.dist-info/entry_points.txt +8 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import binascii
|
|
2
|
+
import can
|
|
3
|
+
from enum import IntEnum
|
|
4
|
+
from typing import Callable
|
|
5
|
+
from can.util import len2dlc, dlc2len
|
|
6
|
+
|
|
7
|
+
HDLC_BOUNDARY_BYTE = 0x7E
|
|
8
|
+
HDLC_ESCAPE_BYTE = 0x7D
|
|
9
|
+
|
|
10
|
+
MSG_ENCODE_ERR_HEADER_SIZE = 7
|
|
11
|
+
MSG_ENCODE_STD_HEADER_SIZE = 9 # 7 + 2
|
|
12
|
+
MSG_ENCODE_EXT_HEADER_SIZE = 11 # 7 + 4
|
|
13
|
+
MSG_ENCODE_HEADER_MIN_SIZE = MSG_ENCODE_ERR_HEADER_SIZE
|
|
14
|
+
MSG_ENCODE_HEADER_MAX_SIZE = MSG_ENCODE_EXT_HEADER_SIZE
|
|
15
|
+
|
|
16
|
+
# The encoded timestamp is offset by date 2025/01/01 00:00:00 UTC.
|
|
17
|
+
MSG_ENCODE_TIME_MIN = 1735689600
|
|
18
|
+
MSG_ENCODE_TIME_MAX = MSG_ENCODE_TIME_MIN + (0xFF_FF_FF_FF_FF_FF / 1_000_000)
|
|
19
|
+
|
|
20
|
+
class CanSubErrorFrameType(IntEnum):
|
|
21
|
+
BIT = 0
|
|
22
|
+
ACK = 1
|
|
23
|
+
FORM = 2
|
|
24
|
+
STUFF = 3
|
|
25
|
+
CRC = 4
|
|
26
|
+
OTHER = 0xFF
|
|
27
|
+
|
|
28
|
+
def cansub_protocol_encode(msg: can.Message, data: bytearray) -> bytes:
|
|
29
|
+
"""
|
|
30
|
+
Encodes msg into data. Can be called multiple times to encode multiple messages in the same data buffer. The
|
|
31
|
+
boundary bytes (start/stop), byte stuffing and, CRC32 will be updated to ensure that the frame in the data buffer
|
|
32
|
+
is always valid.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
# Encode the new message into message buffer
|
|
36
|
+
msg_buf = message_encode(msg)
|
|
37
|
+
|
|
38
|
+
# Default crc32 value
|
|
39
|
+
crc32 = 0
|
|
40
|
+
|
|
41
|
+
# Check if output buffer already contains a start boundary flag */
|
|
42
|
+
if len(data) == 0:
|
|
43
|
+
# No, add flag
|
|
44
|
+
data.append(HDLC_BOUNDARY_BYTE)
|
|
45
|
+
else:
|
|
46
|
+
# Remove the current end boundary flag (such that more data can be added)
|
|
47
|
+
boundary_flag_end = data.pop()
|
|
48
|
+
if boundary_flag_end != HDLC_BOUNDARY_BYTE:
|
|
49
|
+
raise ValueError("Unexpected boundary end flag")
|
|
50
|
+
|
|
51
|
+
# Get current CRC (and remove from the end of buffer)
|
|
52
|
+
# WARNING: The CRC bytes can be byte-stuffed and take up more than 4 bytes!
|
|
53
|
+
for i in range(0, 4):
|
|
54
|
+
|
|
55
|
+
if len(data) < 1:
|
|
56
|
+
raise ValueError("Missing CRC32 bytes")
|
|
57
|
+
|
|
58
|
+
# Get byte and check if byte before is escape byte
|
|
59
|
+
byte = data.pop()
|
|
60
|
+
if len(data) > 0 and (data[-1] == HDLC_ESCAPE_BYTE):
|
|
61
|
+
# Yes, byte before is escape byte, de-stuff crc byte and remove escape byte
|
|
62
|
+
byte ^= 0x20
|
|
63
|
+
data.pop()
|
|
64
|
+
|
|
65
|
+
# NOTE: Be aware that the CRC32 bytes are read from the end of the buffer!
|
|
66
|
+
crc32 |= byte << (i * 8)
|
|
67
|
+
|
|
68
|
+
# Update crc32 with new data
|
|
69
|
+
crc32 = binascii.crc32(msg_buf, crc32)
|
|
70
|
+
|
|
71
|
+
# Append CRC32 bytes to message buffer
|
|
72
|
+
msg_buf.extend(crc32.to_bytes(4, "big"))
|
|
73
|
+
|
|
74
|
+
# Add the new message to the persistent buffer while applying HDLC byte stuffing */
|
|
75
|
+
for byte in msg_buf:
|
|
76
|
+
|
|
77
|
+
# Check if bytestuffing in required
|
|
78
|
+
if byte == HDLC_BOUNDARY_BYTE or byte == HDLC_ESCAPE_BYTE:
|
|
79
|
+
# Insert escape byte
|
|
80
|
+
data.append(HDLC_ESCAPE_BYTE)
|
|
81
|
+
|
|
82
|
+
# Modify data byte with XOR 0x20
|
|
83
|
+
byte ^= 0x20
|
|
84
|
+
|
|
85
|
+
# Insert data byte
|
|
86
|
+
data.append(byte)
|
|
87
|
+
|
|
88
|
+
# Insert the boundary end flag
|
|
89
|
+
data.append(HDLC_BOUNDARY_BYTE)
|
|
90
|
+
|
|
91
|
+
return data
|
|
92
|
+
|
|
93
|
+
def cansub_protocol_decode(data: bytearray, msg_cb: Callable[[can.Message], None]) -> None:
|
|
94
|
+
frames_decode(data, msg_cb)
|
|
95
|
+
|
|
96
|
+
def frames_decode(data: bytearray, msg_cb: Callable[[can.Message], None]) -> None:
|
|
97
|
+
|
|
98
|
+
# Decode all frames in buffer
|
|
99
|
+
while len(data) > 0:
|
|
100
|
+
|
|
101
|
+
# Pre-decode length of buffer
|
|
102
|
+
data_len_pre = len(data)
|
|
103
|
+
|
|
104
|
+
# Decode single frame
|
|
105
|
+
try:
|
|
106
|
+
frame_decode(data, msg_cb)
|
|
107
|
+
|
|
108
|
+
# Check if bytes consumed
|
|
109
|
+
if len(data) == data_len_pre:
|
|
110
|
+
# No, this is not an error. Just that we need more bytes to parse frame.
|
|
111
|
+
break
|
|
112
|
+
except Exception as e:
|
|
113
|
+
# Some error. Bytes consumed?
|
|
114
|
+
print(f"Error decoding frame {e}")
|
|
115
|
+
if data_len_pre == len(data):
|
|
116
|
+
# No, likely deadlock, reset buffer
|
|
117
|
+
data = bytearray()
|
|
118
|
+
break
|
|
119
|
+
|
|
120
|
+
def frame_decode(data: bytearray, msg_cb: Callable[[can.Message], None]) -> None:
|
|
121
|
+
|
|
122
|
+
# Find boundary start
|
|
123
|
+
frame_start = data.find(HDLC_BOUNDARY_BYTE)
|
|
124
|
+
if frame_start < 0:
|
|
125
|
+
# Boundary start byte should during normal operation always be present. However, this is not a hard fault.
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
# Find boundary end (starting from boundary_start + 1)
|
|
129
|
+
frame_end = data.find(HDLC_BOUNDARY_BYTE, frame_start + 1)
|
|
130
|
+
if frame_end < 0:
|
|
131
|
+
# No boundary end, wait for more data. Zero bytes consumed
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
# Create frame containing byte-stuffed bytes without boundary flags
|
|
135
|
+
frame = data[frame_start + 1:frame_end]
|
|
136
|
+
|
|
137
|
+
# Consume frame bytes (including boundary flags)
|
|
138
|
+
del data[:frame_end + 1]
|
|
139
|
+
|
|
140
|
+
# Perform byte de-stuffing
|
|
141
|
+
i_in = 0
|
|
142
|
+
i_out = 0
|
|
143
|
+
while i_in < len(frame):
|
|
144
|
+
|
|
145
|
+
# Read one byte
|
|
146
|
+
byte = frame[i_in]
|
|
147
|
+
i_in += 1
|
|
148
|
+
|
|
149
|
+
# Check if de-stuffing is required
|
|
150
|
+
if byte == HDLC_ESCAPE_BYTE:
|
|
151
|
+
# Yes, check that we have one more byte
|
|
152
|
+
if i_in < len(frame):
|
|
153
|
+
# Yes, de-stuff next byte
|
|
154
|
+
byte = frame[i_in] ^ 0x20
|
|
155
|
+
i_in += 1
|
|
156
|
+
else:
|
|
157
|
+
# No, this is an error
|
|
158
|
+
raise ValueError("Frame stuffed byte missing")
|
|
159
|
+
|
|
160
|
+
# Add de-stuffed byte to the output
|
|
161
|
+
frame[i_out] = byte
|
|
162
|
+
i_out += 1
|
|
163
|
+
|
|
164
|
+
del frame[i_out:]
|
|
165
|
+
|
|
166
|
+
# Check frame crc32
|
|
167
|
+
if len(frame) < 4:
|
|
168
|
+
raise ValueError("Frame CRC32 missing")
|
|
169
|
+
|
|
170
|
+
crc32_expected = int.from_bytes(frame[-4:], "big")
|
|
171
|
+
del frame[-4:]
|
|
172
|
+
crc32_actual = binascii.crc32(frame)
|
|
173
|
+
|
|
174
|
+
if crc32_expected != crc32_actual:
|
|
175
|
+
raise ValueError("Frame CRC32 mismatch")
|
|
176
|
+
|
|
177
|
+
# Decode message(s) in frame
|
|
178
|
+
messages_decode(frame, msg_cb)
|
|
179
|
+
|
|
180
|
+
def messages_decode(data: bytearray, msg_cb: Callable[[can.Message], None]) -> None:
|
|
181
|
+
|
|
182
|
+
# Decode all messages in buffer
|
|
183
|
+
while len(data) > 0:
|
|
184
|
+
# Pre-decode length of buffer
|
|
185
|
+
data_len_pre = len(data)
|
|
186
|
+
|
|
187
|
+
# Decode a single message
|
|
188
|
+
try:
|
|
189
|
+
message_decode(data, msg_cb)
|
|
190
|
+
result = True
|
|
191
|
+
except Exception as e:
|
|
192
|
+
print(f"Error decoding message {e}")
|
|
193
|
+
result = False
|
|
194
|
+
break
|
|
195
|
+
|
|
196
|
+
# If decode error or if zero bytes consumed, break
|
|
197
|
+
if not result or data_len_pre == len(data):
|
|
198
|
+
raise ValueError("Message decode error")
|
|
199
|
+
|
|
200
|
+
def message_decode(data: bytearray, msg_cb: Callable[[can.Message], None]) -> None:
|
|
201
|
+
|
|
202
|
+
# Backup of len before bytes are removed
|
|
203
|
+
data_len_orig = len(data)
|
|
204
|
+
|
|
205
|
+
# Check minimum header
|
|
206
|
+
if data_len_orig < MSG_ENCODE_HEADER_MIN_SIZE:
|
|
207
|
+
raise ValueError("Not enough data to parse header")
|
|
208
|
+
|
|
209
|
+
# Microseconds (48 bit)
|
|
210
|
+
timestamp_us = int.from_bytes(data[:6], byteorder='big')
|
|
211
|
+
timestamp = float(timestamp_us) / 1_000_000.0 + MSG_ENCODE_TIME_MIN
|
|
212
|
+
del data[:6]
|
|
213
|
+
|
|
214
|
+
# Flags
|
|
215
|
+
flags = data[0]
|
|
216
|
+
del data[0]
|
|
217
|
+
|
|
218
|
+
# Error frame ?
|
|
219
|
+
if (flags & 0xE0) != 0x20:
|
|
220
|
+
|
|
221
|
+
# No, is data frame
|
|
222
|
+
if data_len_orig < MSG_ENCODE_STD_HEADER_SIZE:
|
|
223
|
+
raise ValueError("Not enough data to parse header")
|
|
224
|
+
|
|
225
|
+
# Frame format
|
|
226
|
+
is_fdf = bool(flags & 0x80)
|
|
227
|
+
|
|
228
|
+
if not is_fdf:
|
|
229
|
+
is_rtr = bool(flags & 0x40)
|
|
230
|
+
is_brs = False
|
|
231
|
+
is_esi = False
|
|
232
|
+
else:
|
|
233
|
+
is_rtr = False
|
|
234
|
+
is_brs = bool(flags & 0x40)
|
|
235
|
+
is_esi = bool(flags & 0x20)
|
|
236
|
+
|
|
237
|
+
# Get TX (note, logic reversed)
|
|
238
|
+
is_rx = not bool(flags & 0x10)
|
|
239
|
+
|
|
240
|
+
# DLC
|
|
241
|
+
dlc = flags & 0x0F
|
|
242
|
+
|
|
243
|
+
# Is extended ID?
|
|
244
|
+
is_extended_id = bool(data[0] & 0x80)
|
|
245
|
+
|
|
246
|
+
# Arbitration ID
|
|
247
|
+
if not is_extended_id:
|
|
248
|
+
# Regular ID
|
|
249
|
+
if len(data) < 2:
|
|
250
|
+
raise ValueError("Not enough data to parse STD ID")
|
|
251
|
+
arbitration_id = int.from_bytes(bytes(data[:2]), byteorder='big') & 0x7FF
|
|
252
|
+
del data[:2]
|
|
253
|
+
else:
|
|
254
|
+
# Extended ID
|
|
255
|
+
if len(data) < 4:
|
|
256
|
+
raise ValueError("Not enough data to parse EXT ID")
|
|
257
|
+
arbitration_id = int.from_bytes(bytes(data[:4]), byteorder='big') & 0x1FFFFFFF
|
|
258
|
+
del data[:4]
|
|
259
|
+
|
|
260
|
+
# Payload
|
|
261
|
+
payload = b""
|
|
262
|
+
if not is_rtr:
|
|
263
|
+
payload_len = dlc2len(dlc)
|
|
264
|
+
if len(data) >= payload_len:
|
|
265
|
+
payload = data[:payload_len]
|
|
266
|
+
del data[:payload_len]
|
|
267
|
+
else:
|
|
268
|
+
raise ValueError("Not enough data bytes for payload")
|
|
269
|
+
|
|
270
|
+
msg = can.Message(timestamp=timestamp,
|
|
271
|
+
channel=None,
|
|
272
|
+
is_rx=is_rx,
|
|
273
|
+
arbitration_id=arbitration_id,
|
|
274
|
+
is_extended_id=is_extended_id,
|
|
275
|
+
is_remote_frame=is_rtr,
|
|
276
|
+
is_fd=is_fdf,
|
|
277
|
+
bitrate_switch=is_brs,
|
|
278
|
+
error_state_indicator=is_esi,
|
|
279
|
+
# Note: python-can stores actual byte length in Message.dlc
|
|
280
|
+
dlc=dlc2len(dlc) if is_rtr else len(payload),
|
|
281
|
+
data=payload,
|
|
282
|
+
check=True,
|
|
283
|
+
)
|
|
284
|
+
# Invoke callback
|
|
285
|
+
msg_cb(msg)
|
|
286
|
+
|
|
287
|
+
else:
|
|
288
|
+
# Yes, is error frame. Store the error code in the arbitration ID (like the python-can socketcan implementation)
|
|
289
|
+
error_type_val = flags & 0x1F
|
|
290
|
+
|
|
291
|
+
# Map error type to enum value, default to OTHER if unknown
|
|
292
|
+
try:
|
|
293
|
+
error_type = CanSubErrorFrameType(error_type_val)
|
|
294
|
+
except ValueError:
|
|
295
|
+
error_type = CanSubErrorFrameType.OTHER
|
|
296
|
+
|
|
297
|
+
msg = can.Message(timestamp=timestamp,
|
|
298
|
+
channel=None,
|
|
299
|
+
arbitration_id=int(error_type),
|
|
300
|
+
is_error_frame=True,
|
|
301
|
+
check=True,
|
|
302
|
+
)
|
|
303
|
+
# Invoke callback
|
|
304
|
+
msg_cb(msg)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def message_encode(message: can.Message) -> bytearray:
|
|
308
|
+
|
|
309
|
+
if message.is_error_frame:
|
|
310
|
+
raise ValueError("Encoding of error frames not supported")
|
|
311
|
+
|
|
312
|
+
buffer = bytearray()
|
|
313
|
+
|
|
314
|
+
# Microseconds since 00:00:00 01/01/2025 UTC (48 bit)
|
|
315
|
+
timestamp_us = 0
|
|
316
|
+
if (message.timestamp is not None ) and (MSG_ENCODE_TIME_MAX >= message.timestamp >= MSG_ENCODE_TIME_MIN):
|
|
317
|
+
timestamp_us = int((message.timestamp - MSG_ENCODE_TIME_MIN) * 1_000_000)
|
|
318
|
+
buffer.extend(timestamp_us.to_bytes(6, byteorder='big'))
|
|
319
|
+
|
|
320
|
+
# Note: python-can stores actual byte length in Message.dlc
|
|
321
|
+
dlc = len2dlc(len(message.data)) if not message.is_remote_frame else len2dlc(message.dlc)
|
|
322
|
+
|
|
323
|
+
# fd (1 bit), rtr/brs (1 bit), 0/esi (1 bit), tx (1 bit), dlc (4 bit)
|
|
324
|
+
flags = 0
|
|
325
|
+
flags |= 0x80 if message.is_fd else 0x00
|
|
326
|
+
if not message.is_fd:
|
|
327
|
+
flags |= 0x40 if message.is_remote_frame else 0x00
|
|
328
|
+
else:
|
|
329
|
+
flags |= 0x40 if message.bitrate_switch else 0x00
|
|
330
|
+
flags |= 0x20 if message.error_state_indicator else 0x00
|
|
331
|
+
flags |= 0x10 if not message.is_rx else 0x00
|
|
332
|
+
flags |= dlc & 0x0F
|
|
333
|
+
buffer.append(flags)
|
|
334
|
+
|
|
335
|
+
# ID (11 or 29 bit). Note: MSb set if extended ID.
|
|
336
|
+
if not message.is_extended_id:
|
|
337
|
+
arbitration_id = bytearray((message.arbitration_id & 0x7FF).to_bytes(2, byteorder='big'))
|
|
338
|
+
else:
|
|
339
|
+
arbitration_id = bytearray(((message.arbitration_id & 0x1FFFFFFF) | 0x80000000).to_bytes(4, byteorder='big'))
|
|
340
|
+
buffer.extend(arbitration_id)
|
|
341
|
+
|
|
342
|
+
# Payload (omit for RTR)
|
|
343
|
+
if not message.is_remote_frame:
|
|
344
|
+
buffer.extend(message.data)
|
|
345
|
+
|
|
346
|
+
return buffer
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-----BEGIN CERTIFICATE-----
|
|
2
|
+
MIIBkDCCATegAwIBAgIUXNdJNtJWKeOF1jo//9d5b4ogt8MwCgYIKoZIzj0EAwIw
|
|
3
|
+
FjEUMBIGA1UEAwwLQ0FOc3ViIHJvb3QwHhcNMjYwNDIxMDQ1NDQwWhcNNDYwNDE2
|
|
4
|
+
MDQ1NDQwWjAWMRQwEgYDVQQDDAtDQU5zdWIgcm9vdDBZMBMGByqGSM49AgEGCCqG
|
|
5
|
+
SM49AwEHA0IABE/d9+b3ev4iv6R2K75IE+V+6Tiy7F0FZCOfh/XgcUDrHjGRFdG1
|
|
6
|
+
HM7g77ca+yBXebiZOLAhSaDgVghdF4nCLy6jYzBhMB0GA1UdDgQWBBQLrD8RSEnY
|
|
7
|
+
1TuXPi9/acQRBcMD9zAfBgNVHSMEGDAWgBQLrD8RSEnY1TuXPi9/acQRBcMD9zAP
|
|
8
|
+
BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwICBDAKBggqhkjOPQQDAgNHADBE
|
|
9
|
+
AiAknEW1MJy1PUO5I8T94ruNSqoFJ0aqrBXM8qBSEmWYhQIgc1wRS3jTucldvwKZ
|
|
10
|
+
yXf3o3Cmm+ebthFrKw7Bk5LtbkY=
|
|
11
|
+
-----END CERTIFICATE-----
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-can-cansub
|
|
3
|
+
Version: 2026.5.22
|
|
4
|
+
Summary: CANsub python-can interface
|
|
5
|
+
Project-URL: Homepage, https://csselectronics.com/
|
|
6
|
+
Project-URL: Source, https://github.com/CSS-Electronics/python-can-cansub
|
|
7
|
+
Author: CSS Electronics
|
|
8
|
+
Author-email: contact@csselectronics.com
|
|
9
|
+
License: MIT
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.12
|
|
14
|
+
Requires-Dist: python-can>=4.6.0
|
|
15
|
+
Requires-Dist: requests>=2.32.5
|
|
16
|
+
Requires-Dist: wsproto>=1.3.2
|
|
17
|
+
Requires-Dist: zeroconf>=0.131.0
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# python-can-cansub
|
|
21
|
+
|
|
22
|
+
A [python-can](https://python-can.readthedocs.io/) integration for the [CANsub](https://csselectronics.com/) CAN bus interface family by CSS Electronics. Source on [GitHub](https://github.com/CSS-Electronics/python-can-cansub).
|
|
23
|
+
|
|
24
|
+
This package registers the CANsub as a standard python-can interface, making it compatible with all python-can tools and workflows. It also adds a CSV logger compatible with the *webCAN* browser tool provided with the device.
|
|
25
|
+
|
|
26
|
+
> **Tip:** This README is optimized for LLMs. When using an AI coding assistant with this package, provide this file as context for accurate results.
|
|
27
|
+
|
|
28
|
+
## python-can API
|
|
29
|
+
|
|
30
|
+
### Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install python-can-cansub
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Import
|
|
37
|
+
|
|
38
|
+
When `python-can-cansub` is installed, the `cansub` interface is automatically registered with python-can. Import with:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import can
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Configuration
|
|
45
|
+
|
|
46
|
+
Python-can defines a hardware *configuration* by an `interface` and a `channel` (a single interface can have multiple channels).
|
|
47
|
+
|
|
48
|
+
The CANsub `interface` is fixed `"cansub"`. The `channel` is constructed from the device hostname (unique) and channel index.
|
|
49
|
+
|
|
50
|
+
| Connection | Hostname | python-can `channel` string |
|
|
51
|
+
|------------|-------------------------|-----------------------------------|
|
|
52
|
+
| USB | `[DEVICE-ID]-usb.local` | `[DEVICE-ID]-usb.local@[channel]` |
|
|
53
|
+
| Ethernet | `[DEVICE-ID]-eth.local` | `[DEVICE-ID]-eth.local@[channel]` |
|
|
54
|
+
|
|
55
|
+
The device-ID is printed on the device label. Channel indexing is **1-based** - the first channel is `1`.
|
|
56
|
+
|
|
57
|
+
A configuration is passed to `can.Bus` to open a bus.
|
|
58
|
+
|
|
59
|
+
#### Fixed
|
|
60
|
+
|
|
61
|
+
Example of a fixed configuration:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
configs = [{"interface": "cansub", "channel": "aabbccdd-usb.local@1"},
|
|
65
|
+
{"interface": "cansub", "channel": "aabbccdd-usb.local@2"}]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
#### Auto-detect
|
|
69
|
+
|
|
70
|
+
Example of using `detect_available_configs` to automatically discover (uses mDNS) all connected CANsub devices and channels:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
configs = can.detect_available_configs(interfaces=["cansub"])
|
|
74
|
+
# e.g. [{"interface": "cansub", "channel": "aabbccdd-usb.local@1"},
|
|
75
|
+
# {"interface": "cansub", "channel": "aabbccdd-usb.local@2"}
|
|
76
|
+
# {"interface": "cansub", "channel": "11223344-eth.local@1"}
|
|
77
|
+
# {"interface": "cansub", "channel": "11223344-eth.local@2"}]
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
In the above example two CANsub devices are detected, each with two channels. One device is connected via USB and the other via Ethernet.
|
|
81
|
+
|
|
82
|
+
### Opening a Bus
|
|
83
|
+
|
|
84
|
+
#### Single bus - hardcoded
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
with can.Bus(interface="cansub", channel="aabbccdd-usb.local@1", bitrate=250_000, data_bitrate=1_000_000) as bus:
|
|
88
|
+
pass
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
#### Single bus - from configs
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
with can.Bus(interface=configs[0]["interface"], channel=configs[0]["channel"], bitrate=250_000, data_bitrate=1_000_000) as bus:
|
|
95
|
+
pass
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
#### Multiple buses - from configs
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
with (can.Bus(interface=configs[0]["interface"], channel=configs[0]["channel"], bitrate=250_000, data_bitrate=1_000_000) as bus1,
|
|
102
|
+
can.Bus(interface=configs[1]["interface"], channel=configs[1]["channel"], bitrate=250_000, data_bitrate=1_000_000) as bus2):
|
|
103
|
+
pass
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
> **Tip:** `**config` unpacks a config dict directly into `can.Bus` keyword arguments:
|
|
107
|
+
>
|
|
108
|
+
> ```python
|
|
109
|
+
> with can.Bus(**configs[0], bitrate=250_000, data_bitrate=1_000_000) as bus:
|
|
110
|
+
> pass
|
|
111
|
+
> ```
|
|
112
|
+
|
|
113
|
+
### Receive and Transmit
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
with can.Bus(**configs[0], bitrate=250_000, data_bitrate=1_000_000) as bus:
|
|
117
|
+
|
|
118
|
+
# Transmit
|
|
119
|
+
msg_tx = can.Message(is_extended_id=False, arbitration_id=0x123, data=[0x01, 0x02, 0x03, 0x04])
|
|
120
|
+
bus.send(msg_tx)
|
|
121
|
+
|
|
122
|
+
# Receive with timeout
|
|
123
|
+
msg_rx = bus.recv(timeout=1.0)
|
|
124
|
+
print(msg_rx)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Filters
|
|
128
|
+
|
|
129
|
+
Apply hardware filters by passing `can_filters` to `can.Bus`. Each filter specifies a `can_id`, a `can_mask`, and whether to match standard (`extended=False`) or extended (`extended=True`) frames. A frame passes if `(frame_id & can_mask) == (can_id & can_mask)`.
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
filters = [
|
|
133
|
+
{"can_id": 0x123, "can_mask": 0x7FF, "extended": False}, # standard frames, exact ID match
|
|
134
|
+
{"can_id": 0x000, "can_mask": 0x000, "extended": True}, # all extended frames
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
with can.Bus(**configs[0], bitrate=250_000, data_bitrate=1_000_000, can_filters=filters) as bus:
|
|
138
|
+
msg = bus.recv(timeout=1.0)
|
|
139
|
+
print(msg)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
> **Tip:** Applying hardware filters reduces the network load between the CANsub and the connected client.
|
|
143
|
+
|
|
144
|
+
### Notifier and Listeners
|
|
145
|
+
|
|
146
|
+
`bus.recv()` blocks until a frame arrives. A `can.Notifier` runs a background thread that dispatches received frames to one or more *listeners*, allowing the main program to continue other work.
|
|
147
|
+
|
|
148
|
+
python-can provides built-in listeners including `can.Printer` (print to stdout) and `can.Logger` (log to file). The example below logs to a CSV file while the main program continues. Custom listeners can be implemented by subclassing `can.Listener`.
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from time import sleep
|
|
152
|
+
|
|
153
|
+
print_listener = can.Printer()
|
|
154
|
+
csv_listener = can.Logger("log.csv")
|
|
155
|
+
|
|
156
|
+
with can.Bus(**configs[0], bitrate=250_000, data_bitrate=1_000_000) as bus:
|
|
157
|
+
with can.Notifier([bus], listeners=[print_listener, csv_listener]):
|
|
158
|
+
|
|
159
|
+
# Perform other tasks here while frames are received in the background
|
|
160
|
+
sleep(10)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Broadcast Manager
|
|
164
|
+
|
|
165
|
+
Periodic transmission jobs can be started with `bus.send_periodic()`.
|
|
166
|
+
|
|
167
|
+
Most periodic transmission job types can be offloaded to the CANsub hardware, providing much better transmission time accuracy (compared to a host-scheduled transmission). A host-side background task is used only as a fallback when hardware transmission is not available.
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
from time import sleep
|
|
171
|
+
|
|
172
|
+
msgs = [
|
|
173
|
+
can.Message(is_extended_id=False, arbitration_id=0x123, data=[0x01, 0x02, 0x03, 0x04]),
|
|
174
|
+
can.Message(is_extended_id=False, arbitration_id=0x124, data=[0x05, 0x06, 0x07, 0x08]),
|
|
175
|
+
can.Message(is_extended_id=False, arbitration_id=0x125, data=[0x09, 0x0A, 0x0B, 0x0C]),
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
with can.Bus(**configs[0], bitrate=250_000, data_bitrate=1_000_000) as bus:
|
|
179
|
+
# period: time between individual frames (sequence repeats every len(msgs) * period)
|
|
180
|
+
# duration: total transmission time in seconds (None = transmit indefinitely)
|
|
181
|
+
task = bus.send_periodic(msgs, period=0.1, duration=5.0)
|
|
182
|
+
|
|
183
|
+
# Perform other tasks here while frames are transmitted in the background
|
|
184
|
+
sleep(6)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Replaying files
|
|
188
|
+
|
|
189
|
+
`can.MessageSync` can be used to replay messages from a log file.
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
with can.Bus(**configs[0], bitrate=250_000, data_bitrate=1_000_000) as bus:
|
|
193
|
+
with can.LogReader("log.csv") as reader:
|
|
194
|
+
for msg in can.MessageSync(messages=reader):
|
|
195
|
+
bus.send(msg)
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## python-can tools
|
|
199
|
+
|
|
200
|
+
python-can includes several command line tools. All tools accept `--interface` and `--channel` to select the bus, following the same configuration as the API.
|
|
201
|
+
|
|
202
|
+
The common argument pattern for the CANsub:
|
|
203
|
+
|
|
204
|
+
```
|
|
205
|
+
--interface cansub --channel aabbccdd-usb.local@1 --bitrate 250000 --data-bitrate 1000000
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### can_logger
|
|
209
|
+
|
|
210
|
+
Log received frames to a file (CSV by default; format inferred from file extension):
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
can_logger --interface cansub --channel aabbccdd-usb.local@1 --bitrate 250000 --data-bitrate 1000000 --output-file log.csv
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### can_player
|
|
217
|
+
|
|
218
|
+
Play back a previously recorded log file:
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
can_player --interface cansub --channel aabbccdd-usb.local@1 --bitrate 250000 --data-bitrate 1000000 log.csv
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### can_viewer
|
|
225
|
+
|
|
226
|
+
Live terminal viewer showing received frames, updated counts, timestamps, and byte-level changes:
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
can_viewer --interface cansub --channel aabbccdd-usb.local@1 --bitrate 250000 --data-bitrate 1000000
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### can_bridge
|
|
233
|
+
|
|
234
|
+
Forward all frames received on one bus to another (e.g. bridge two CANsub channels):
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
can_bridge --interface cansub --channel aabbccdd-usb.local@1 --bitrate 250000 --data-bitrate 1000000 \
|
|
238
|
+
--interface2 cansub --channel2 aabbccdd-usb.local@2 --bitrate2 250000 --data-bitrate2 1000000
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### can_logconvert
|
|
242
|
+
|
|
243
|
+
Convert a log file between formats; the format is inferred from the file extension:
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
can_logconvert log.csv log.asc
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## Related Packages
|
|
250
|
+
|
|
251
|
+
The following packages complement `python-can-cansub` and are included here as inspiration for working with CAN data in Python.
|
|
252
|
+
|
|
253
|
+
### cantools
|
|
254
|
+
|
|
255
|
+
[cantools](https://github.com/cantools/cantools) is a Python package for encoding and decoding CAN messages. Encoding/decoding rules can be created or loaded from DBC (and other) database files. It works directly with `can.Message` objects from python-can.
|
|
256
|
+
|
|
257
|
+
#### Installation
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
pip install cantools
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
#### Create database in code
|
|
264
|
+
|
|
265
|
+
A database can be constructed directly in Python without a database file:
|
|
266
|
+
|
|
267
|
+
```python
|
|
268
|
+
import cantools
|
|
269
|
+
|
|
270
|
+
db = cantools.database.Database()
|
|
271
|
+
|
|
272
|
+
msg_def = cantools.database.can.Message(
|
|
273
|
+
frame_id=0x123,
|
|
274
|
+
name="Message1",
|
|
275
|
+
length=8,
|
|
276
|
+
signals=[
|
|
277
|
+
cantools.database.can.Signal(name="Signal1", start=0, length=16, scale=0.1, offset=0.0, minimum=0.0, maximum=100.0),
|
|
278
|
+
cantools.database.can.Signal(name="Signal2", start=16, length=16, scale=0.1, offset=0.0, minimum=0.0, maximum=100.0),
|
|
279
|
+
]
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
db.add_message(msg_def)
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
#### Load database from DBC file
|
|
286
|
+
|
|
287
|
+
```python
|
|
288
|
+
import cantools
|
|
289
|
+
|
|
290
|
+
db = cantools.database.load_file("database.dbc")
|
|
291
|
+
msg_def = db.get_message_by_name("Message1")
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
#### Encode
|
|
295
|
+
|
|
296
|
+
Encode signal values into the byte payload of a `can.Message`:
|
|
297
|
+
|
|
298
|
+
```python
|
|
299
|
+
data = msg_def.encode({"Signal1": 1.0, "Signal2": 42.5})
|
|
300
|
+
msg_tx = can.Message(arbitration_id=msg_def.frame_id,
|
|
301
|
+
is_extended_id=msg_def.is_extended_frame,
|
|
302
|
+
data=data)
|
|
303
|
+
|
|
304
|
+
with can.Bus(**configs[0], bitrate=250_000, data_bitrate=1_000_000) as bus:
|
|
305
|
+
bus.send(msg_tx)
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
#### Decode
|
|
309
|
+
|
|
310
|
+
Decode the byte payload of a received `can.Message` back into signal values:
|
|
311
|
+
|
|
312
|
+
```python
|
|
313
|
+
with can.Bus(**configs[0], bitrate=250_000, data_bitrate=1_000_000) as bus:
|
|
314
|
+
msg_rx = bus.recv(timeout=1.0)
|
|
315
|
+
if msg_rx:
|
|
316
|
+
signals = db.decode_message(msg_rx.arbitration_id, msg_rx.data)
|
|
317
|
+
print(signals) # e.g. {'Signal1': 1.0, 'Signal2': 42.5}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
### asammdf
|
|
321
|
+
|
|
322
|
+
[asammdf](https://github.com/danielhrisca/asammdf) is a Python package for reading and writing MDF (Measurement Data Format) files.
|
|
323
|
+
|
|
324
|
+
When `asammdf` is installed, python-can automatically gains support for reading MDF log files via `can.LogReader`, allowing MDF recordings to be played back directly using `can.MessageSync`:
|
|
325
|
+
|
|
326
|
+
#### Installation
|
|
327
|
+
|
|
328
|
+
```bash
|
|
329
|
+
pip install asammdf
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
#### Playback of MDF log file
|
|
333
|
+
|
|
334
|
+
```python
|
|
335
|
+
with can.Bus(**configs[0], bitrate=250_000, data_bitrate=1_000_000) as bus:
|
|
336
|
+
with can.LogReader("recording.mf4") as reader:
|
|
337
|
+
for msg in can.MessageSync(messages=reader):
|
|
338
|
+
bus.send(msg)
|
|
339
|
+
```
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
python_can_cansub/__init__.py,sha256=d6h_Gcl355zHd6n60XpGI9VVfMf63GMXkVmt7fOqiaU,225
|
|
2
|
+
python_can_cansub/cansub.py,sha256=rUn9fFIZXgLPMwHD-kn4WiH3nrcBkwiVEtMJm77a7yE,36702
|
|
3
|
+
python_can_cansub/cansub_protocol.py,sha256=tPYurcznSyR6oG6xN5sG7sgy9QHtt3WJ8UyGNP3LdT0,11315
|
|
4
|
+
python_can_cansub/cansub_root_cert.crt,sha256=g4ds4CtMhoX3QWX_fGnXbaBhxLBHCNN2I4ci4gMdlGw,603
|
|
5
|
+
python_can_cansub-2026.5.22.dist-info/METADATA,sha256=sb8jyA07UxsIKBPCVlcdZTND1rPIV8hDj9-WPOQ5nO8,11342
|
|
6
|
+
python_can_cansub-2026.5.22.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
7
|
+
python_can_cansub-2026.5.22.dist-info/entry_points.txt,sha256=2NrO_BE31bhjk-Gg0oSfOA5eUSS358RcN8hdxoEsd44,201
|
|
8
|
+
python_can_cansub-2026.5.22.dist-info/RECORD,,
|