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,891 @@
|
|
|
1
|
+
import socket
|
|
2
|
+
import ssl
|
|
3
|
+
import warnings
|
|
4
|
+
import can
|
|
5
|
+
import queue
|
|
6
|
+
import requests
|
|
7
|
+
import threading
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from time import time, sleep
|
|
11
|
+
from typing import Optional, Sequence, TypedDict, Union, Callable, Generator, Any
|
|
12
|
+
from can import LimitedDurationCyclicSendTaskABC, BusABC, RestartableCyclicTaskABC
|
|
13
|
+
from can.io.generic import TextIOMessageWriter, TextIOMessageReader
|
|
14
|
+
from wsproto import ConnectionType, WSConnection, ConnectionState, events
|
|
15
|
+
from python_can_cansub.cansub_protocol import cansub_protocol_decode, cansub_protocol_encode
|
|
16
|
+
|
|
17
|
+
class CanSubHwFilter(TypedDict):
|
|
18
|
+
is_extended: bool
|
|
19
|
+
is_range: bool
|
|
20
|
+
f1: int
|
|
21
|
+
f2: int
|
|
22
|
+
|
|
23
|
+
CanSubHwFilters = Sequence[CanSubHwFilter]
|
|
24
|
+
|
|
25
|
+
class CanSub(can.BusABC):
|
|
26
|
+
|
|
27
|
+
_supported_api_versions = ["02.00"]
|
|
28
|
+
_can_protocol = can.CanProtocol.CAN_FD_NON_ISO
|
|
29
|
+
_can_f_clock = 80_000_000
|
|
30
|
+
|
|
31
|
+
def __init__(self,
|
|
32
|
+
channel: can.typechecking.Channel,
|
|
33
|
+
can_filters: Optional[can.typechecking.CanFilters] = None,
|
|
34
|
+
address: str | None = None,
|
|
35
|
+
timing: Optional[can.BitTimingFd] = None,
|
|
36
|
+
bitrate: Optional[int] = None,
|
|
37
|
+
data_bitrate: Optional[int] = None,
|
|
38
|
+
listen_only: Optional[bool] = False,
|
|
39
|
+
auto_reset: Optional[bool] = True,
|
|
40
|
+
error_frames: Optional[bool] = False,
|
|
41
|
+
**kwargs: object
|
|
42
|
+
):
|
|
43
|
+
"""
|
|
44
|
+
Python-can compatible interface over a websocket connection. Functions bus.send and bus.recv are made
|
|
45
|
+
thread-safe by using queues.
|
|
46
|
+
|
|
47
|
+
:param timing:
|
|
48
|
+
CAN-bus bit-timing. Takes precedence over bitrate and data_bitrate.
|
|
49
|
+
|
|
50
|
+
:param int bitrate:
|
|
51
|
+
CAN-bus bit-rate.
|
|
52
|
+
|
|
53
|
+
:param int data_bitrate:
|
|
54
|
+
CAN-bus data (FD) bit-rate.
|
|
55
|
+
|
|
56
|
+
:param listen_only:
|
|
57
|
+
CAN-bus listen-only mode.
|
|
58
|
+
|
|
59
|
+
:param auto_reset:
|
|
60
|
+
Automatic recovery from bus-off.
|
|
61
|
+
|
|
62
|
+
:param error_frames:
|
|
63
|
+
CAN-bus error frame reporting.
|
|
64
|
+
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
# To be able to use python-can command line tool without custom arguments, support that channel can contain
|
|
68
|
+
# both address and channel, format e.g.: aabbccdd-usb.local@1
|
|
69
|
+
if isinstance(channel, str) and "@" in channel:
|
|
70
|
+
address, channel = channel.split("@", 1)
|
|
71
|
+
|
|
72
|
+
if address is None:
|
|
73
|
+
raise can.exceptions.CanInitializationError("Address not provided")
|
|
74
|
+
|
|
75
|
+
# Parse address and port
|
|
76
|
+
if ':' in address:
|
|
77
|
+
host, port_str = address.rsplit(':', 1)
|
|
78
|
+
try:
|
|
79
|
+
port = int(port_str)
|
|
80
|
+
except ValueError:
|
|
81
|
+
raise can.exceptions.CanInitializationError(f"Invalid port in address: {address}")
|
|
82
|
+
else:
|
|
83
|
+
host = address
|
|
84
|
+
port = 443
|
|
85
|
+
|
|
86
|
+
# Channel
|
|
87
|
+
self.channel = None
|
|
88
|
+
if isinstance(channel, can.typechecking.ChannelInt):
|
|
89
|
+
self.channel = channel
|
|
90
|
+
elif isinstance(channel, can.typechecking.ChannelStr):
|
|
91
|
+
self.channel = can.util.channel2int(channel)
|
|
92
|
+
elif isinstance(channel, Sequence) and len(channel) == 1:
|
|
93
|
+
self.channel = channel[0]
|
|
94
|
+
|
|
95
|
+
del channel
|
|
96
|
+
|
|
97
|
+
if not isinstance(self.channel, can.typechecking.ChannelInt):
|
|
98
|
+
raise can.exceptions.CanInitializationError("Invalid channel")
|
|
99
|
+
|
|
100
|
+
# Bitrate / bit-timing
|
|
101
|
+
if isinstance(timing, can.BitTimingFd):
|
|
102
|
+
# Bit-timing takes precedence over bitrate and data_bitrate
|
|
103
|
+
self.timing = timing
|
|
104
|
+
elif isinstance(bitrate, int) and isinstance(data_bitrate, int):
|
|
105
|
+
# Construct timing from bitrate/data_bitrate (using 80% sample point)
|
|
106
|
+
self.timing = can.BitTimingFd.from_sample_point(
|
|
107
|
+
f_clock=self._can_f_clock,
|
|
108
|
+
nom_bitrate=bitrate,
|
|
109
|
+
nom_sample_point=80.0,
|
|
110
|
+
data_bitrate=data_bitrate,
|
|
111
|
+
data_sample_point=80.0)
|
|
112
|
+
else:
|
|
113
|
+
raise can.exceptions.CanInitializationError("Bitrate / bit-timing not provided")
|
|
114
|
+
del timing, bitrate, data_bitrate
|
|
115
|
+
|
|
116
|
+
# listen-only
|
|
117
|
+
self.listen_only = listen_only
|
|
118
|
+
if not isinstance(self.listen_only, bool):
|
|
119
|
+
raise can.exceptions.CanInitializationError("Invalid listen_only type")
|
|
120
|
+
del listen_only
|
|
121
|
+
|
|
122
|
+
# auto-reset
|
|
123
|
+
self.auto_reset = auto_reset
|
|
124
|
+
if not isinstance(self.auto_reset, bool):
|
|
125
|
+
raise can.exceptions.CanInitializationError("Invalid auto_reset type")
|
|
126
|
+
del auto_reset
|
|
127
|
+
|
|
128
|
+
# error_frames
|
|
129
|
+
self.error_frames = error_frames
|
|
130
|
+
if not isinstance(self.error_frames, bool):
|
|
131
|
+
raise can.exceptions.CanInitializationError("Invalid error_frames type")
|
|
132
|
+
del error_frames
|
|
133
|
+
|
|
134
|
+
# Set channel info (required by python-can)
|
|
135
|
+
self.channel_info = f"CANSUB{self.channel}"
|
|
136
|
+
|
|
137
|
+
# Path to device root certificate
|
|
138
|
+
self.cansub_cert = str(Path(__file__).with_name("cansub_root_cert.crt"))
|
|
139
|
+
|
|
140
|
+
# Perform REST interaction with the device in a persistent session
|
|
141
|
+
self.api_url = f"https://{host}:{port}/api"
|
|
142
|
+
self._net_timeout = 3.0
|
|
143
|
+
|
|
144
|
+
# Create persistent session for REST API calls
|
|
145
|
+
self.session = requests.Session()
|
|
146
|
+
self.session.verify = self.cansub_cert
|
|
147
|
+
|
|
148
|
+
# Get api version (and test connection)
|
|
149
|
+
try:
|
|
150
|
+
response = self.session.get(f"{self.api_url}/version", timeout=self._net_timeout)
|
|
151
|
+
response.raise_for_status()
|
|
152
|
+
except Exception as e:
|
|
153
|
+
raise can.exceptions.CanInitializationError(f"Connection failed ({e})")
|
|
154
|
+
|
|
155
|
+
self.api_version = response.json()
|
|
156
|
+
if not (isinstance(self.api_version, str) and self.api_version in self._supported_api_versions):
|
|
157
|
+
raise can.exceptions.CanInitializationError(f"API version not supported ({self.api_version})")
|
|
158
|
+
|
|
159
|
+
# Get state of the channel
|
|
160
|
+
try:
|
|
161
|
+
response = self.session.get(f"{self.api_url}/can/{self.channel}", timeout=self._net_timeout)
|
|
162
|
+
response.raise_for_status()
|
|
163
|
+
except Exception as e:
|
|
164
|
+
raise can.exceptions.CanInitializationError(f"Failed to get channel state ({e})")
|
|
165
|
+
|
|
166
|
+
channel_state = response.json()
|
|
167
|
+
if not ("state" in channel_state and channel_state["state"] == "stopped"):
|
|
168
|
+
raise can.exceptions.CanInitializationError(f"Channel connect be opened (state: {channel_state['state']})")
|
|
169
|
+
|
|
170
|
+
# Set device time
|
|
171
|
+
time_string = datetime.now(timezone.utc).isoformat(timespec="milliseconds")[:-6] + "Z"
|
|
172
|
+
try:
|
|
173
|
+
response = self.session.put(url=f"{self.api_url}/time", json=time_string, timeout=self._net_timeout)
|
|
174
|
+
response.raise_for_status()
|
|
175
|
+
except Exception as e:
|
|
176
|
+
raise can.exceptions.CanInitializationError(f"Failed to set device time ({e})")
|
|
177
|
+
|
|
178
|
+
# Ensure that no hardware filters are set before the channel is opened
|
|
179
|
+
# (Ensures that no messages get through until provided filters are set)
|
|
180
|
+
self.set_hw_filters(None)
|
|
181
|
+
|
|
182
|
+
# Set channel configuration
|
|
183
|
+
phy_config = {
|
|
184
|
+
"listen_only": self.listen_only,
|
|
185
|
+
"auto_reset": self.auto_reset,
|
|
186
|
+
"error_frames": self.error_frames,
|
|
187
|
+
"timing": {
|
|
188
|
+
"brp": self.timing.nom_brp,
|
|
189
|
+
"seg1": self.timing.nom_tseg1,
|
|
190
|
+
"seg2": self.timing.nom_tseg2,
|
|
191
|
+
"sjw": self.timing.nom_sjw
|
|
192
|
+
},
|
|
193
|
+
"timing_data": {
|
|
194
|
+
"brp": self.timing.data_brp,
|
|
195
|
+
"seg1": self.timing.data_tseg1,
|
|
196
|
+
"seg2": self.timing.data_tseg2,
|
|
197
|
+
"sjw": self.timing.data_sjw
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
try:
|
|
201
|
+
response = self.session.put(f"{self.api_url}/can/{self.channel}/phy", json=phy_config,
|
|
202
|
+
timeout=self._net_timeout)
|
|
203
|
+
response.raise_for_status()
|
|
204
|
+
except Exception as e:
|
|
205
|
+
raise can.exceptions.CanInitializationError(f"Failed to configure channel ({e})")
|
|
206
|
+
|
|
207
|
+
# Socket TLS configuration
|
|
208
|
+
ssl_context = ssl.create_default_context(cafile=self.cansub_cert)
|
|
209
|
+
|
|
210
|
+
# Socket
|
|
211
|
+
self.ws_sock = ssl_context.wrap_socket(sock=socket.socket(socket.AF_INET, socket.SOCK_STREAM), server_hostname=host)
|
|
212
|
+
self.ws_sock.settimeout(self._net_timeout)
|
|
213
|
+
|
|
214
|
+
# Disable Nagles algorithm (lower latency for small writes)
|
|
215
|
+
self.ws_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
216
|
+
|
|
217
|
+
# Websocket client
|
|
218
|
+
self.ws_client = WSConnection(ConnectionType.CLIENT)
|
|
219
|
+
|
|
220
|
+
# Queue from websocket to python-can.recv
|
|
221
|
+
self.can_recv_queue: queue.Queue[can.Message] = queue.Queue()
|
|
222
|
+
|
|
223
|
+
# Queue from python-can.send to websocket
|
|
224
|
+
self.can_send_queue: queue.Queue[can.Message] = queue.Queue()
|
|
225
|
+
|
|
226
|
+
# Thread stop event
|
|
227
|
+
self.stop_event = threading.Event()
|
|
228
|
+
|
|
229
|
+
# ws close event. Stored here to allow the client (us) to correctly confirm the close
|
|
230
|
+
self.ws_close_event = None
|
|
231
|
+
|
|
232
|
+
# Connect to the regular socket with retry.
|
|
233
|
+
socket_connected = False
|
|
234
|
+
for i in range(5):
|
|
235
|
+
try:
|
|
236
|
+
self.ws_sock.connect((host, port))
|
|
237
|
+
socket_connected = True
|
|
238
|
+
break
|
|
239
|
+
except ConnectionRefusedError:
|
|
240
|
+
# Server is likely busy, wait a bit and try again
|
|
241
|
+
sleep(0.25 * (i + 1))
|
|
242
|
+
continue
|
|
243
|
+
except Exception:
|
|
244
|
+
break
|
|
245
|
+
|
|
246
|
+
if socket_connected is False:
|
|
247
|
+
raise can.exceptions.CanInitializationError("Connection failed")
|
|
248
|
+
|
|
249
|
+
# Upgrade to websocket
|
|
250
|
+
request = self.ws_client.send(events.Request(host=f"{host}:{port}", target=f"/api/can/{self.channel}/ws"))
|
|
251
|
+
self.ws_sock.sendall(request)
|
|
252
|
+
|
|
253
|
+
# Wait for upgrade confirmation with timeout
|
|
254
|
+
start_time = time()
|
|
255
|
+
while (self.ws_client.state != ConnectionState.OPEN) and (time() - start_time) < 5:
|
|
256
|
+
try:
|
|
257
|
+
self.ws_client.receive_data(self.ws_sock.recv(2048))
|
|
258
|
+
except socket.timeout:
|
|
259
|
+
continue
|
|
260
|
+
except Exception:
|
|
261
|
+
break
|
|
262
|
+
|
|
263
|
+
# Check that the upgrade was successful
|
|
264
|
+
if self.ws_client.state != ConnectionState.OPEN:
|
|
265
|
+
raise can.exceptions.CanInitializationError("Upgrade failed")
|
|
266
|
+
|
|
267
|
+
# Call init for parent class
|
|
268
|
+
# NOTE: This calls _apply_filters ensuring that can_filters are set
|
|
269
|
+
super().__init__(channel=self.channel, can_filters=can_filters)
|
|
270
|
+
|
|
271
|
+
def _ws_recv_loop():
|
|
272
|
+
"""
|
|
273
|
+
Receives data from (web)socket and adds parsed messages to python-can recv queue.
|
|
274
|
+
Warning: this thread is only allowed to receive from ws
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
data = bytearray()
|
|
278
|
+
|
|
279
|
+
# Receive data from the socket in loop. Ensure to periodically check if the stop event is set
|
|
280
|
+
while not self.stop_event.is_set():
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
ws_data = self.ws_sock.recv(2048)
|
|
284
|
+
except socket.timeout:
|
|
285
|
+
# Timeout to be able to check if the stop event is set
|
|
286
|
+
continue
|
|
287
|
+
except socket.error as e:
|
|
288
|
+
raise can.exceptions.CanOperationError(f"Socket receive error: {e}")
|
|
289
|
+
except Exception as e:
|
|
290
|
+
raise can.exceptions.CanOperationError(f"Socket receive error: {e}")
|
|
291
|
+
|
|
292
|
+
# Data received?
|
|
293
|
+
if not ws_data:
|
|
294
|
+
# No, socket closed by peer (NOTE, the regular socket was closed directly, not the websocket)
|
|
295
|
+
self.stop_event.set()
|
|
296
|
+
continue
|
|
297
|
+
else:
|
|
298
|
+
# Yes, parse websocket data
|
|
299
|
+
self.ws_client.receive_data(ws_data)
|
|
300
|
+
|
|
301
|
+
# Process websocket events
|
|
302
|
+
for event in self.ws_client.events():
|
|
303
|
+
|
|
304
|
+
# Websocket event type
|
|
305
|
+
if isinstance(event, events.BytesMessage):
|
|
306
|
+
|
|
307
|
+
# Append to data buffer
|
|
308
|
+
data.extend(event.data)
|
|
309
|
+
|
|
310
|
+
def msg_cb(msg: can.Message):
|
|
311
|
+
msg.channel = self.channel
|
|
312
|
+
self.can_recv_queue.put(msg)
|
|
313
|
+
|
|
314
|
+
cansub_protocol_decode(data, msg_cb)
|
|
315
|
+
|
|
316
|
+
# Websocket close event. NOTE: We need to store this event to allow the client (us) to
|
|
317
|
+
# correctly confirm the close later (we cannot send in this thread)
|
|
318
|
+
elif isinstance(event, events.CloseConnection):
|
|
319
|
+
self.ws_close_event = event
|
|
320
|
+
self.stop_event.set()
|
|
321
|
+
|
|
322
|
+
def _ws_send_loop():
|
|
323
|
+
"""Warning: this thread is only allowed to transmit to ws"""
|
|
324
|
+
|
|
325
|
+
# Send buffer
|
|
326
|
+
data = bytearray()
|
|
327
|
+
|
|
328
|
+
while not self.stop_event.is_set():
|
|
329
|
+
|
|
330
|
+
# Wait for a CAN TX frame in queue with timeout to allow checking stop_event
|
|
331
|
+
msg = None
|
|
332
|
+
try:
|
|
333
|
+
msg = self.can_send_queue.get(block=True, timeout=self.ws_sock.gettimeout())
|
|
334
|
+
except queue.Empty:
|
|
335
|
+
continue
|
|
336
|
+
|
|
337
|
+
# Encode the message in the buffer
|
|
338
|
+
cansub_protocol_encode(msg, data)
|
|
339
|
+
|
|
340
|
+
# Transmit if buffer "full" or no more messages in the queue
|
|
341
|
+
if len(data) >= 1200 or self.can_send_queue.qsize() == 0:
|
|
342
|
+
|
|
343
|
+
# Send network frame
|
|
344
|
+
if self.ws_sock:
|
|
345
|
+
self.ws_sock.sendall(self.ws_client.send(events.BytesMessage(data)))
|
|
346
|
+
else:
|
|
347
|
+
break
|
|
348
|
+
|
|
349
|
+
# Reset buffer
|
|
350
|
+
data.clear()
|
|
351
|
+
|
|
352
|
+
def _shutdown():
|
|
353
|
+
"""Thread to handle correct shutdown of the websocket connection, regardless of shutdown triggered by
|
|
354
|
+
used or due to error"""
|
|
355
|
+
|
|
356
|
+
# Wait ws threads to finish (on error or on shutdown event)
|
|
357
|
+
self._pythoncan_send_to_ws_send_thread.join()
|
|
358
|
+
self._ws_recv_to_python_can_recv_thread.join()
|
|
359
|
+
|
|
360
|
+
# Close websocket connection
|
|
361
|
+
if self.ws_client.state == ConnectionState.REMOTE_CLOSING and self.ws_close_event:
|
|
362
|
+
|
|
363
|
+
# Websocket already closed by peer, confirm close
|
|
364
|
+
self.ws_sock.sendall(self.ws_client.send(self.ws_close_event.response()))
|
|
365
|
+
|
|
366
|
+
#elif self.ws_client.state == ConnectionState.OPEN:
|
|
367
|
+
#
|
|
368
|
+
# # Websocket still open, send close request
|
|
369
|
+
# self.ws_sock.sendall(self.ws_client.send(CloseConnection(CloseReason.NORMAL_CLOSURE)))
|
|
370
|
+
#
|
|
371
|
+
# # Wait for peer to confirm close with timeout
|
|
372
|
+
# # TODO: Use below once cansub supports confirmation of close
|
|
373
|
+
# if False:
|
|
374
|
+
# start_time = time()
|
|
375
|
+
# while (self.ws_client.state != ConnectionState.CLOSED) and (time() - start_time) < 1:
|
|
376
|
+
# try:
|
|
377
|
+
# self.ws_client.receive_data(self.ws_sock.recv(2048))
|
|
378
|
+
# except socket.timeout:
|
|
379
|
+
# continue
|
|
380
|
+
# except Exception:
|
|
381
|
+
# break
|
|
382
|
+
#
|
|
383
|
+
## Confirm websocket close
|
|
384
|
+
## TODO: CANsub does currently not confirm to websocket close
|
|
385
|
+
#if self.ws_client.state != ConnectionState.CLOSED:
|
|
386
|
+
# #raise can.exceptions.CanOperationError("Websocket close failed")
|
|
387
|
+
# print("Websocket close failed")
|
|
388
|
+
# pass
|
|
389
|
+
|
|
390
|
+
# Gracefully close the TCP connection
|
|
391
|
+
try:
|
|
392
|
+
self.ws_sock.shutdown(socket.SHUT_WR)
|
|
393
|
+
except socket.error:
|
|
394
|
+
print("Socket shutdown failed")
|
|
395
|
+
# Socket already closed or other error
|
|
396
|
+
pass
|
|
397
|
+
|
|
398
|
+
# Wait for peer to confirm close of socket with timeout
|
|
399
|
+
try:
|
|
400
|
+
data = self.ws_sock.recv(1024)
|
|
401
|
+
if data:
|
|
402
|
+
print("Socket confirm close failed")
|
|
403
|
+
except socket.timeout:
|
|
404
|
+
# Peer dit not confirm in time
|
|
405
|
+
print("Socket shutdown timeout")
|
|
406
|
+
pass
|
|
407
|
+
except socket.error:
|
|
408
|
+
# Socket already closed or other error
|
|
409
|
+
print("Socket shutdown failed")
|
|
410
|
+
pass
|
|
411
|
+
finally:
|
|
412
|
+
# Close socket and release resources
|
|
413
|
+
self.ws_sock.close()
|
|
414
|
+
|
|
415
|
+
# ws thread, ws to python-can recv
|
|
416
|
+
self._ws_recv_to_python_can_recv_thread = threading.Thread(target=_ws_recv_loop)
|
|
417
|
+
|
|
418
|
+
# ws thread, python-can send to ws
|
|
419
|
+
self._pythoncan_send_to_ws_send_thread = threading.Thread(target=_ws_send_loop)
|
|
420
|
+
|
|
421
|
+
self._ws_recv_to_python_can_recv_thread.start()
|
|
422
|
+
self._pythoncan_send_to_ws_send_thread.start()
|
|
423
|
+
|
|
424
|
+
sleep(0.1)
|
|
425
|
+
|
|
426
|
+
# Create a thread to handle correct shutdown of the websocket connection, regardless of shutdown due to error
|
|
427
|
+
# or normal shutdown (by used calling bus.shutdown)
|
|
428
|
+
self.ws_shutdown_thread = threading.Thread(target=_shutdown)
|
|
429
|
+
self.ws_shutdown_thread.start()
|
|
430
|
+
|
|
431
|
+
# Initialize the parent class
|
|
432
|
+
super().__init__(channel=self.channel, can_filters=can_filters, **kwargs)
|
|
433
|
+
|
|
434
|
+
def shutdown(self):
|
|
435
|
+
|
|
436
|
+
# Signal websocket threads to stop
|
|
437
|
+
self.stop_event.set()
|
|
438
|
+
|
|
439
|
+
# Wait for shutdown thread to complete
|
|
440
|
+
self.ws_shutdown_thread.join()
|
|
441
|
+
|
|
442
|
+
# Close the persistent session
|
|
443
|
+
if hasattr(self, 'session'):
|
|
444
|
+
self.session.close()
|
|
445
|
+
|
|
446
|
+
super().shutdown()
|
|
447
|
+
|
|
448
|
+
def send(self, msg: can.Message, timeout: float = None):
|
|
449
|
+
"""
|
|
450
|
+
See "send" in can.BusABC. Note, waits for a slot in the queue to be available (not for messages to be ACK'ed)
|
|
451
|
+
"""
|
|
452
|
+
|
|
453
|
+
if timeout is not None and timeout < 0:
|
|
454
|
+
raise can.exceptions.CanOperationError("Invalid timeout value")
|
|
455
|
+
|
|
456
|
+
if not (msg.channel is None or msg.channel == self.channel):
|
|
457
|
+
raise can.exceptions.CanOperationError("Invalid channel")
|
|
458
|
+
|
|
459
|
+
try:
|
|
460
|
+
if timeout is None:
|
|
461
|
+
# Wait indefinitely for queue space
|
|
462
|
+
self.can_send_queue.put(msg, block=True, timeout=None)
|
|
463
|
+
elif timeout == 0:
|
|
464
|
+
# Return immediately
|
|
465
|
+
self.can_send_queue.put(msg, block=False)
|
|
466
|
+
else:
|
|
467
|
+
# Wait for timeout seconds for queue space
|
|
468
|
+
self.can_send_queue.put(msg, block=True, timeout=timeout)
|
|
469
|
+
except queue.Full:
|
|
470
|
+
# Queue full
|
|
471
|
+
can.exceptions.CanOperationError("TX queue full")
|
|
472
|
+
|
|
473
|
+
def _recv_internal(self, timeout: float = None) -> tuple[Optional[can.Message], bool]:
|
|
474
|
+
"""
|
|
475
|
+
See "recv" and "_recv_internal" in can.BusABC
|
|
476
|
+
|
|
477
|
+
Always return that message has already been filtered.
|
|
478
|
+
"""
|
|
479
|
+
if timeout is not None and timeout < 0:
|
|
480
|
+
raise can.exceptions.CanOperationError("Invalid timeout value")
|
|
481
|
+
|
|
482
|
+
msg = None
|
|
483
|
+
try:
|
|
484
|
+
if timeout is None:
|
|
485
|
+
# Wait indefinitely
|
|
486
|
+
msg = self.can_recv_queue.get(block=True, timeout=None)
|
|
487
|
+
elif timeout == 0:
|
|
488
|
+
# Return immediately
|
|
489
|
+
msg = self.can_recv_queue.get(block=False)
|
|
490
|
+
else:
|
|
491
|
+
# Wait for timeout seconds
|
|
492
|
+
msg = self.can_recv_queue.get(block=True, timeout=timeout)
|
|
493
|
+
except queue.Empty:
|
|
494
|
+
# Queue empty
|
|
495
|
+
pass
|
|
496
|
+
return msg, True
|
|
497
|
+
|
|
498
|
+
@property
|
|
499
|
+
def state(self) -> can.BusState:
|
|
500
|
+
"""
|
|
501
|
+
Return the current state of the hardware
|
|
502
|
+
"""
|
|
503
|
+
|
|
504
|
+
# Get channel state
|
|
505
|
+
try:
|
|
506
|
+
response = self.session.get(f"{self.api_url}/can/{self.channel}", timeout=self._net_timeout)
|
|
507
|
+
response.raise_for_status()
|
|
508
|
+
except Exception as e:
|
|
509
|
+
raise can.exceptions.CanOperationError(f"Failed to get state of channel {self.channel}")
|
|
510
|
+
|
|
511
|
+
# Parse response
|
|
512
|
+
response_dict = response.json()
|
|
513
|
+
if not (isinstance(response_dict, dict) and "state" in response_dict):
|
|
514
|
+
raise can.exceptions.CanOperationError(f"Failed to get state of channel {self.channel}")
|
|
515
|
+
|
|
516
|
+
response_state = response_dict["state"]
|
|
517
|
+
if response_state == "error_active" or response_state == "error_warning":
|
|
518
|
+
state = can.BusState.ACTIVE
|
|
519
|
+
elif response_state == "error_passive":
|
|
520
|
+
state = can.BusState.PASSIVE
|
|
521
|
+
elif response_state == "bus_off":
|
|
522
|
+
state = can.BusState.ERROR
|
|
523
|
+
else:
|
|
524
|
+
raise can.exceptions.CanOperationError(f"Unknown channel state {response_state}")
|
|
525
|
+
return state
|
|
526
|
+
|
|
527
|
+
def flush_tx_buffer(self):
|
|
528
|
+
"""
|
|
529
|
+
Flush to send queue
|
|
530
|
+
"""
|
|
531
|
+
self.can_send_queue.queue.clear()
|
|
532
|
+
|
|
533
|
+
def _apply_filters(self, filters: Optional[can.typechecking.CanFilters]) -> None:
|
|
534
|
+
"""
|
|
535
|
+
NOTE: This python-can function does not take channel information (applies provided filters to all channels)
|
|
536
|
+
"""
|
|
537
|
+
|
|
538
|
+
# Python-can specify that all messages should be accepted if filters are None or empty list
|
|
539
|
+
if filters is None or len(filters) == 0:
|
|
540
|
+
filters = [
|
|
541
|
+
can.typechecking.CanFilter(extended=False, can_id=0, can_mask=0), # Accept all std messages
|
|
542
|
+
can.typechecking.CanFilter(extended=True, can_id=0, can_mask=0) # Accept all ext messages
|
|
543
|
+
]
|
|
544
|
+
|
|
545
|
+
# Change to CanSubHwFilter type
|
|
546
|
+
can_hw_filters = [CanSubHwFilter(is_extended=x["extended"],
|
|
547
|
+
is_range=False,
|
|
548
|
+
f1=x["can_id"],
|
|
549
|
+
f2=x["can_mask"]) for x in filters]
|
|
550
|
+
|
|
551
|
+
# Apply filters to all channels
|
|
552
|
+
self.set_hw_filters(can_hw_filters)
|
|
553
|
+
|
|
554
|
+
def set_hw_filters(self, can_hw_filters: Optional[CanSubHwFilters]) -> None:
|
|
555
|
+
"""
|
|
556
|
+
Sets device hardware filters
|
|
557
|
+
|
|
558
|
+
NOTE: Contrary to the set_filters method, this method does not set any filters if can_hw_filters is None or empty.
|
|
559
|
+
"""
|
|
560
|
+
|
|
561
|
+
# Get existing filters for channel
|
|
562
|
+
try:
|
|
563
|
+
response = self.session.get(f"{self.api_url}/can/{self.channel}/filter", timeout=self._net_timeout)
|
|
564
|
+
response.raise_for_status()
|
|
565
|
+
except Exception as e:
|
|
566
|
+
raise can.exceptions.CanInitializationError(f"Failed to get existing filters ({e})")
|
|
567
|
+
|
|
568
|
+
filter_ids = response.json()
|
|
569
|
+
|
|
570
|
+
# Any existing filters?
|
|
571
|
+
if len(filter_ids) > 0:
|
|
572
|
+
|
|
573
|
+
# Yes, delete filters one by one
|
|
574
|
+
for filter_id in filter_ids:
|
|
575
|
+
try:
|
|
576
|
+
response = self.session.delete(f"{self.api_url}/can/{self.channel}/filter/{filter_id}",
|
|
577
|
+
timeout=self._net_timeout)
|
|
578
|
+
response.raise_for_status()
|
|
579
|
+
except Exception as e:
|
|
580
|
+
raise can.exceptions.CanInitializationError(f"Failed to delete existing filter ({e})")
|
|
581
|
+
|
|
582
|
+
# Confirm all filters deleted
|
|
583
|
+
try:
|
|
584
|
+
response = self.session.get(f"{self.api_url}/can/{self.channel}/filter",
|
|
585
|
+
timeout=self._net_timeout)
|
|
586
|
+
response.raise_for_status()
|
|
587
|
+
except Exception as e:
|
|
588
|
+
raise can.exceptions.CanInitializationError(f"Failed to get existing filters ({e})")
|
|
589
|
+
|
|
590
|
+
if len(response.json()) > 0:
|
|
591
|
+
raise can.exceptions.CanInitializationError("Failed to delete all existing filters")
|
|
592
|
+
|
|
593
|
+
# Any new filters provide?
|
|
594
|
+
if can_hw_filters is None or len(can_hw_filters) == 0:
|
|
595
|
+
# No, nothing to do
|
|
596
|
+
return
|
|
597
|
+
|
|
598
|
+
# Create new filters
|
|
599
|
+
for hw_filter in can_hw_filters:
|
|
600
|
+
|
|
601
|
+
if not isinstance(hw_filter, dict):
|
|
602
|
+
raise can.exceptions.CanOperationError("Invalid filter type")
|
|
603
|
+
|
|
604
|
+
try:
|
|
605
|
+
response = self.session.post(f"{self.api_url}/can/{self.channel}/filter", json=hw_filter,
|
|
606
|
+
timeout=self._net_timeout)
|
|
607
|
+
response.raise_for_status()
|
|
608
|
+
except Exception as e:
|
|
609
|
+
raise can.exceptions.CanOperationError(f"Failed to create new filter ({e})")
|
|
610
|
+
|
|
611
|
+
# Confirm all filters created
|
|
612
|
+
try:
|
|
613
|
+
response = self.session.get(f"{self.api_url}/can/{self.channel}/filter",
|
|
614
|
+
timeout=self._net_timeout)
|
|
615
|
+
response.raise_for_status()
|
|
616
|
+
except Exception as e:
|
|
617
|
+
raise can.exceptions.CanOperationError(f"Failed to get new filters ({e})")
|
|
618
|
+
|
|
619
|
+
if len(response.json()) != len(can_hw_filters):
|
|
620
|
+
raise can.exceptions.CanOperationError("Failed to create new filters")
|
|
621
|
+
|
|
622
|
+
def _send_periodic_internal(
|
|
623
|
+
self,
|
|
624
|
+
msgs: Union[Sequence[can.Message], can.Message],
|
|
625
|
+
period: float,
|
|
626
|
+
duration: Optional[float] = None,
|
|
627
|
+
autostart: bool = True,
|
|
628
|
+
modifier_callback: Optional[Callable[[can.Message], None]] = None,
|
|
629
|
+
) -> can.broadcastmanager.CyclicSendTaskABC:
|
|
630
|
+
|
|
631
|
+
if modifier_callback is None:
|
|
632
|
+
return CyclicSendTask(self, channel=self.channel, messages=msgs, period=period, duration=duration,
|
|
633
|
+
autostart=autostart)
|
|
634
|
+
else:
|
|
635
|
+
# modifier_callback not supported by hw, fallback to thread-based cyclic task
|
|
636
|
+
warnings.warn(
|
|
637
|
+
f"{self.__class__.__name__} falls back to a thread-based cyclic task, "
|
|
638
|
+
"when the `modifier_callback` argument is given.",
|
|
639
|
+
stacklevel=3,
|
|
640
|
+
)
|
|
641
|
+
return BusABC._send_periodic_internal(
|
|
642
|
+
self,
|
|
643
|
+
msgs=msgs,
|
|
644
|
+
period=period,
|
|
645
|
+
duration=duration,
|
|
646
|
+
autostart=autostart,
|
|
647
|
+
modifier_callback=modifier_callback,
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
@staticmethod
|
|
651
|
+
def _detect_available_configs() -> Sequence[can.typechecking.AutoDetectedConfig]:
|
|
652
|
+
from zeroconf import InterfaceChoice, ServiceBrowser, Zeroconf
|
|
653
|
+
|
|
654
|
+
# Collect DNS-SD service info objects keyed by service name.
|
|
655
|
+
# The device firmware advertises _cansub._tcp with TXT records:
|
|
656
|
+
# api=<version> - must match _supported_api_versions
|
|
657
|
+
# channels=<n> - number of CAN channels (0-based indices)
|
|
658
|
+
# The SRV hostname encodes the serial number and connection type,
|
|
659
|
+
# e.g. 1b5b9343-usb.local (USB) or 1b5b9343-eth.local (Ethernet).
|
|
660
|
+
discovered = {}
|
|
661
|
+
|
|
662
|
+
class _Listener:
|
|
663
|
+
def add_service(self, zc, type_, name):
|
|
664
|
+
info = zc.get_service_info(type_, name)
|
|
665
|
+
if info:
|
|
666
|
+
discovered[name] = info
|
|
667
|
+
def remove_service(self, zc, type_, name): pass
|
|
668
|
+
def update_service(self, zc, type_, name): pass
|
|
669
|
+
|
|
670
|
+
# Listen on all interfaces so devices on multiple USB/Ethernet links
|
|
671
|
+
# are all discovered (each CDC-NCM or Ethernet link is a separate interface).
|
|
672
|
+
zc = Zeroconf(interfaces=InterfaceChoice.All)
|
|
673
|
+
try:
|
|
674
|
+
ServiceBrowser(zc, "_cansub._tcp.local.", _Listener())
|
|
675
|
+
sleep(2.0)
|
|
676
|
+
finally:
|
|
677
|
+
zc.close()
|
|
678
|
+
|
|
679
|
+
configs = []
|
|
680
|
+
for info in discovered.values():
|
|
681
|
+
# TXT record keys/values arrive as bytes; decode to str for easy lookup.
|
|
682
|
+
txt = {
|
|
683
|
+
(k.decode() if isinstance(k, bytes) else k):
|
|
684
|
+
(v.decode() if isinstance(v, bytes) else v)
|
|
685
|
+
for k, v in info.properties.items()
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
# Skip devices running an unsupported API version.
|
|
689
|
+
if txt.get("api") not in CanSub._supported_api_versions:
|
|
690
|
+
continue
|
|
691
|
+
|
|
692
|
+
# info.server has a trailing dot (DNS convention); strip it.
|
|
693
|
+
hostname = info.server.rstrip(".")
|
|
694
|
+
|
|
695
|
+
try:
|
|
696
|
+
num_channels = int(txt.get("channels", 1))
|
|
697
|
+
except ValueError:
|
|
698
|
+
num_channels = 1
|
|
699
|
+
|
|
700
|
+
# Channel is encoded in the address string understood by __init__,
|
|
701
|
+
# e.g. "1b5b9343-usb.local@1". Channels are 1-based.
|
|
702
|
+
for channel in range(1, num_channels + 1):
|
|
703
|
+
configs.append({"interface": "cansub", "channel": f"{hostname}@{channel}"})
|
|
704
|
+
|
|
705
|
+
return configs
|
|
706
|
+
|
|
707
|
+
class CyclicSendTask(LimitedDurationCyclicSendTaskABC, RestartableCyclicTaskABC):
|
|
708
|
+
|
|
709
|
+
def __init__(
|
|
710
|
+
self,
|
|
711
|
+
cansub: CanSub,
|
|
712
|
+
channel: can.typechecking.ChannelInt,
|
|
713
|
+
messages: Union[Sequence[can.Message], can.Message],
|
|
714
|
+
period: float,
|
|
715
|
+
duration: Optional[float] = None,
|
|
716
|
+
autostart: bool = True
|
|
717
|
+
) -> None:
|
|
718
|
+
|
|
719
|
+
# Super classes perform various checks and place the parameters to use in self.
|
|
720
|
+
super().__init__(messages, period, duration)
|
|
721
|
+
|
|
722
|
+
self.transmit_api_url = f"{cansub.api_url}/can/{channel}/transmit"
|
|
723
|
+
self._net_timeout = cansub._net_timeout
|
|
724
|
+
self.cansub_cert = cansub.cansub_cert
|
|
725
|
+
self.session = cansub.session
|
|
726
|
+
|
|
727
|
+
period_ms = int(len(self.messages) * self.period_ns / 1_000_000)
|
|
728
|
+
spacing_ms = int(self.period_ns / 1_000_000)
|
|
729
|
+
|
|
730
|
+
if self.duration is None:
|
|
731
|
+
limit = 0xFFFFFFFF
|
|
732
|
+
else:
|
|
733
|
+
limit = int(duration * 1000 / period_ms)
|
|
734
|
+
limit = max(1, limit)
|
|
735
|
+
|
|
736
|
+
transmit_sequence = {
|
|
737
|
+
"limit": limit,
|
|
738
|
+
"period_ms": period_ms,
|
|
739
|
+
"spacing_ms": spacing_ms,
|
|
740
|
+
"frames": [
|
|
741
|
+
{
|
|
742
|
+
"is_extended": message.is_extended_id,
|
|
743
|
+
"is_fd": message.is_fd,
|
|
744
|
+
"is_rtr_brs": message.bitrate_switch if message.is_fd else message.is_remote_frame,
|
|
745
|
+
"id": message.arbitration_id,
|
|
746
|
+
"data": "".join([f"{x:02X}" for x in message.data])
|
|
747
|
+
} for message in self.messages
|
|
748
|
+
]
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
# Set up new transmit sequence
|
|
752
|
+
response = self.session.post(url=self.transmit_api_url, timeout=self._net_timeout, verify=self.cansub_cert,
|
|
753
|
+
json=transmit_sequence)
|
|
754
|
+
|
|
755
|
+
if response.status_code != 201:
|
|
756
|
+
raise can.exceptions.CanOperationError(f"Failed to create transmit sequence ({response.status_code})")
|
|
757
|
+
if "Location" not in response.headers or not isinstance(response.headers["Location"], str):
|
|
758
|
+
raise can.exceptions.CanOperationError(f"Failed get transmit sequence ID")
|
|
759
|
+
|
|
760
|
+
id_str = response.headers["Location"].rstrip('/').split('/')[-1]
|
|
761
|
+
if id_str.isdigit() is False:
|
|
762
|
+
raise can.exceptions.CanOperationError(f"Failed get transmit sequence ID")
|
|
763
|
+
self.transmit_id = int(id_str)
|
|
764
|
+
|
|
765
|
+
if autostart is True:
|
|
766
|
+
self.start()
|
|
767
|
+
|
|
768
|
+
def start(self) -> None:
|
|
769
|
+
"""Restart a stopped periodic task."""
|
|
770
|
+
response = self.session.put(url=f"{self.transmit_api_url}/{self.transmit_id}/count", timeout=self._net_timeout,
|
|
771
|
+
verify=self.cansub_cert, json=0)
|
|
772
|
+
if response.status_code != 200:
|
|
773
|
+
raise can.exceptions.CanOperationError(f"Failed to (re)start transmit sequence ({response.status_code})")
|
|
774
|
+
|
|
775
|
+
def stop(self) -> None:
|
|
776
|
+
"""Stop periodic task."""
|
|
777
|
+
response = self.session.delete(url=f"{self.transmit_api_url}/{self.transmit_id}", timeout=self._net_timeout,
|
|
778
|
+
verify=self.cansub_cert)
|
|
779
|
+
if response.status_code != 200:
|
|
780
|
+
raise can.exceptions.CanOperationError(f"Failed to stop transmit sequence ({response.status_code})")
|
|
781
|
+
return
|
|
782
|
+
|
|
783
|
+
class CanSubCSVWriter(TextIOMessageWriter):
|
|
784
|
+
"""
|
|
785
|
+
CanSub CSV writer.
|
|
786
|
+
"""
|
|
787
|
+
|
|
788
|
+
def __init__(self, file, append=False, **kwargs):
|
|
789
|
+
super().__init__(file, mode="a" if append else "w")
|
|
790
|
+
if not append:
|
|
791
|
+
self.file.write("TimestampEpoch;BusChannel;ID;IDE;DLC;DataLength;Dir;EDL;BRS;ESI;RTR;DataBytes\n")
|
|
792
|
+
|
|
793
|
+
def on_message_received(self, msg: can.Message) -> None:
|
|
794
|
+
|
|
795
|
+
# Determine BusChannel from msg.channel
|
|
796
|
+
bus_channel = str(msg.channel) if msg.channel is not None else ""
|
|
797
|
+
|
|
798
|
+
# ID formatting: 3 hex chars for standard ID, 8 for extended ID
|
|
799
|
+
id_fmt = f"{msg.arbitration_id:03X}" if not msg.is_extended_id else f"{msg.arbitration_id:08X}"
|
|
800
|
+
|
|
801
|
+
row = ";".join(
|
|
802
|
+
[
|
|
803
|
+
f"{msg.timestamp:10.6f}", # 0: TimestampEpoch
|
|
804
|
+
str(bus_channel), # 1: BusChannel
|
|
805
|
+
id_fmt, # 2: ID
|
|
806
|
+
"1" if msg.is_extended_id else "0", # 3: IDE
|
|
807
|
+
str(can.util.len2dlc(msg.dlc)), # 4: DLC (NOTE: python-can stores LEN in DLC)
|
|
808
|
+
str(msg.dlc), # 5: DataLength (NOTE: python-can stores LEN in DLC)
|
|
809
|
+
"0" if msg.is_rx else "1", # 6: Dir
|
|
810
|
+
"1" if msg.is_fd else "0", # 7: EDL
|
|
811
|
+
"1" if msg.bitrate_switch else "0", # 8: BRS
|
|
812
|
+
"1" if msg.error_state_indicator else "0", # 9: ESI
|
|
813
|
+
"1" if msg.is_remote_frame else "0", # 10: RTR
|
|
814
|
+
msg.data.hex().upper(), # 11: DataBytes
|
|
815
|
+
]
|
|
816
|
+
)
|
|
817
|
+
self.file.write(row + "\n")
|
|
818
|
+
|
|
819
|
+
class CanSubCSVReader(TextIOMessageReader):
|
|
820
|
+
"""
|
|
821
|
+
CanSub CSV reader.
|
|
822
|
+
"""
|
|
823
|
+
|
|
824
|
+
def __init__(self, file: Union[can.typechecking.StringPathLike, Any], channel_filter=None, **kwargs: Any) -> None:
|
|
825
|
+
"""
|
|
826
|
+
:param channel_filter: Only frames recorded on this channel are yielded (channel field is first set to None)
|
|
827
|
+
"""
|
|
828
|
+
super().__init__(file, mode="r", **kwargs)
|
|
829
|
+
self.channel_filter = str(channel_filter) if channel_filter is not None else None
|
|
830
|
+
|
|
831
|
+
def __iter__(self) -> Generator[can.Message, None, None]:
|
|
832
|
+
|
|
833
|
+
# Expected header
|
|
834
|
+
expected_header = "TimestampEpoch;BusChannel;ID;IDE;DLC;DataLength;Dir;EDL;BRS;ESI;RTR;DataBytes"
|
|
835
|
+
|
|
836
|
+
# Verify the header line
|
|
837
|
+
try:
|
|
838
|
+
header = next(self.file).strip()
|
|
839
|
+
if header.lower() != expected_header.lower():
|
|
840
|
+
warnings.warn(
|
|
841
|
+
f"CSV header mismatch. Expected: {expected_header}, Found: {header}",
|
|
842
|
+
UserWarning
|
|
843
|
+
)
|
|
844
|
+
except StopIteration:
|
|
845
|
+
return
|
|
846
|
+
|
|
847
|
+
for line in self.file:
|
|
848
|
+
line = line.strip()
|
|
849
|
+
if not line:
|
|
850
|
+
continue
|
|
851
|
+
|
|
852
|
+
columns = line.split(";")
|
|
853
|
+
if len(columns) < 12:
|
|
854
|
+
continue
|
|
855
|
+
|
|
856
|
+
try:
|
|
857
|
+
channel = int(columns[1]) if columns[1] else None
|
|
858
|
+
# Perform channel filtering if enabled
|
|
859
|
+
if channel is not None and self.channel_filter is not None:
|
|
860
|
+
if str(channel) != self.channel_filter:
|
|
861
|
+
continue
|
|
862
|
+
# Clear channel field (better compatibility with python-can commandline tools)
|
|
863
|
+
channel = None
|
|
864
|
+
yield can.Message(
|
|
865
|
+
timestamp=float(columns[0]), # 0: TimestampEpoch
|
|
866
|
+
channel=channel, # 1: BusChannel (None when channel_filter is set)
|
|
867
|
+
arbitration_id=int(columns[2], 16), # 2: ID
|
|
868
|
+
is_extended_id=columns[3] == "1", # 3: IDE
|
|
869
|
+
dlc=can.util.dlc2len(int(columns[4])), # 4: DLC (NOTE: python-can stores LEN in DLC)
|
|
870
|
+
# 5: DataLength not mapped
|
|
871
|
+
is_rx=columns[6] == "0", # 6: Dir
|
|
872
|
+
is_fd=columns[7] == "1", # 7: EDL
|
|
873
|
+
bitrate_switch=columns[8] == "1", # 8: BRS
|
|
874
|
+
error_state_indicator=columns[9] == "1", # 9: ESI
|
|
875
|
+
is_remote_frame=columns[10] == "1", # 10: RTR
|
|
876
|
+
data=bytes.fromhex(columns[11]) # 11: DataBytes
|
|
877
|
+
)
|
|
878
|
+
except (ValueError, IndexError):
|
|
879
|
+
continue
|
|
880
|
+
|
|
881
|
+
self.stop()
|
|
882
|
+
|
|
883
|
+
def register_cansub_csv_writer():
|
|
884
|
+
"""
|
|
885
|
+
Registers the CanSubCSVWriter and CanSubCSVReader for .csv files in python-can.
|
|
886
|
+
"""
|
|
887
|
+
from can.io.logger import MESSAGE_WRITERS
|
|
888
|
+
from can.io.player import MESSAGE_READERS
|
|
889
|
+
MESSAGE_WRITERS[".csv"] = CanSubCSVWriter
|
|
890
|
+
MESSAGE_READERS[".csv"] = CanSubCSVReader
|
|
891
|
+
|