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.
@@ -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
+