python-can-cansub 2026.6.1__tar.gz → 2026.7.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## 2026.07.02 (API 03.00)
6
+
7
+ ### Added
8
+ - `server_cert`: path to the server certificate (`.crt` file).
9
+ - `client_cert`: tuple of paths to the client certificate and key (`.crt`, `.key`), used for TLS mutual authentication.
10
+ - Warning when devices with unsupported API versions are found.
11
+
12
+ ### Changed
13
+ - Supported device API version changed from `02.00` to `03.00`.
14
+
15
+ ## 2026.06.02 (API 02.00)
16
+
17
+ ### Added
18
+ - `CANSUB_ROOT_CERT`: path to the CANsub root certificate.
19
+
20
+ ### Changed
21
+ - Transmit sequences (on the device) are deleted on bus open.
22
+
23
+ ## 2026.06.01 (API 02.00)
24
+
25
+ ### Fixed
26
+ - `CyclicSendTask` with `duration=None` now working.
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-can-cansub
3
- Version: 2026.6.1
3
+ Version: 2026.7.2
4
4
  Summary: CANsub python-can interface
5
5
  Project-URL: Homepage, https://csselectronics.com/
6
6
  Project-URL: Source, https://github.com/CSS-Electronics/python-can-cansub
7
+ Project-URL: Changelog, https://github.com/CSS-Electronics/python-can-cansub/blob/master/CHANGELOG.md
7
8
  Author: CSS Electronics
8
9
  Author-email: contact@csselectronics.com
9
10
  License: MIT
@@ -25,6 +26,17 @@ This package registers the CANsub as a standard python-can interface, making it
25
26
 
26
27
  > **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
 
29
+ ## Compatibility
30
+
31
+ The python-can-cansub package and the CANsub device communicate over a versioned API. They are compatible when the package supports the API version used by the device firmware.
32
+
33
+ - Each **python-can-cansub** release supports one API version. The supported API version for each release is listed in the [python-can-cansub changelog](https://github.com/CSS-Electronics/python-can-cansub/blob/master/CHANGELOG.md).
34
+ - Each **CANsub firmware** release uses one API version. The API version for each firmware release is listed in the CANsub changelog (provided with the device).
35
+
36
+ To check compatibility, look up the API version of the package release and of the device firmware release in their respective changelogs. If they match, the two are compatible.
37
+
38
+ If the package finds a connected device whose API version it does not support, it emits a warning. Update the package or the device firmware so their API versions align.
39
+
28
40
  ## python-can API
29
41
 
30
42
  ### Installation
@@ -81,6 +93,12 @@ In the above example two CANsub devices are detected, each with two channels. On
81
93
 
82
94
  ### Opening a Bus
83
95
 
96
+ > **Note:** `data_bitrate` is required even on a classic (non-FD) bus. On a non-FD bus, simply set it to e.g. `1_000_000` (1 Mbit/s).
97
+
98
+ > **Tip:** Pass `listen_only=True` to `can.Bus` to monitor a bus without transmitting or acknowledging frames.
99
+
100
+ > **Tip:** Pass `error_frames=True` to `can.Bus` to receive error frames.
101
+
84
102
  #### Single bus - hardcoded
85
103
 
86
104
  ```python
@@ -110,6 +128,22 @@ with (can.Bus(interface=configs[0]["interface"], channel=configs[0]["channel"],
110
128
  > pass
111
129
  > ```
112
130
 
131
+ ### TLS / Certificates
132
+
133
+ The data connection to the device is secured by TLS. When connecting via an IP address (where hostname verification will fail), `server_cert=None` can be used to disable certificate validation:
134
+
135
+ ```python
136
+ with can.Bus(interface="cansub", channel="192.168.1.10@1", server_cert=None, bitrate=250_000, data_bitrate=1_000_000) as bus:
137
+ pass
138
+ ```
139
+
140
+ If TLS mutual authentication is enabled, `client_cert` can be used to provide a tuple of paths to the client certificate (`.crt` file) and its unencrypted private key (`.key` file), i.e. `("cert", "key")`:
141
+
142
+ ```python
143
+ with can.Bus(**configs[0], client_cert=("/path/to/client.crt", "/path/to/client.key"), bitrate=250_000, data_bitrate=1_000_000) as bus:
144
+ pass
145
+ ```
146
+
113
147
  ### Receive and Transmit
114
148
 
115
149
  ```python
@@ -124,6 +158,20 @@ with can.Bus(**configs[0], bitrate=250_000, data_bitrate=1_000_000) as bus:
124
158
  print(msg_rx)
125
159
  ```
126
160
 
161
+ ### Error Frames
162
+
163
+ Error frame reporting is disabled by default; enable it by passing `error_frames=True` to `can.Bus`. Bus errors are then received as a `can.Message` with `is_error_frame` set. The error type is encoded in `arbitration_id`, which can be converted to a `CanSubErrorFrameType` enum:
164
+
165
+ ```python
166
+ from python_can_cansub import CanSubErrorFrameType
167
+
168
+ with can.Bus(**configs[0], bitrate=250_000, data_bitrate=1_000_000, error_frames=True) as bus:
169
+ msg = bus.recv(timeout=1.0)
170
+ if msg and msg.is_error_frame:
171
+ error_type = CanSubErrorFrameType(msg.arbitration_id)
172
+ print(f"Bus error: {error_type.name}") # e.g. "Bus error: ACK"
173
+ ```
174
+
127
175
  ### Filters
128
176
 
129
177
  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)`.
@@ -192,6 +240,28 @@ with can.Bus(**configs[0], bitrate=250_000, data_bitrate=1_000_000) as bus:
192
240
  bus.send(msg)
193
241
  ```
194
242
 
243
+ ### CSV Logger
244
+
245
+ On import, this package overrides the default python-can `.csv` reader and writer with a format compatible with the *webCAN* browser tool provided with the device. This applies automatically wherever `.csv` files are read or written, including `can.Logger`, `can.LogReader`, and the command-line tools.
246
+
247
+ The writer (`CanSubCSVWriter`) and reader (`CanSubCSVReader`) can also be used directly:
248
+
249
+ ```python
250
+ from python_can_cansub import CanSubCSVWriter, CanSubCSVReader
251
+
252
+ # Write received messages to a webCAN-compatible CSV file
253
+ with can.Bus(**configs[0], bitrate=250_000, data_bitrate=1_000_000) as bus:
254
+ with CanSubCSVWriter("log.csv") as writer:
255
+ msg = bus.recv(timeout=1.0)
256
+ if msg:
257
+ writer.on_message_received(msg)
258
+
259
+ # Read messages back from the CSV file
260
+ with CanSubCSVReader("log.csv") as reader:
261
+ for msg in reader:
262
+ print(msg)
263
+ ```
264
+
195
265
  ## python-can tools
196
266
 
197
267
  python-can includes several command line tools. All tools accept `--interface` and `--channel` to select the bus, following the same configuration as the API.
@@ -6,6 +6,17 @@ This package registers the CANsub as a standard python-can interface, making it
6
6
 
7
7
  > **Tip:** This README is optimized for LLMs. When using an AI coding assistant with this package, provide this file as context for accurate results.
8
8
 
9
+ ## Compatibility
10
+
11
+ The python-can-cansub package and the CANsub device communicate over a versioned API. They are compatible when the package supports the API version used by the device firmware.
12
+
13
+ - Each **python-can-cansub** release supports one API version. The supported API version for each release is listed in the [python-can-cansub changelog](https://github.com/CSS-Electronics/python-can-cansub/blob/master/CHANGELOG.md).
14
+ - Each **CANsub firmware** release uses one API version. The API version for each firmware release is listed in the CANsub changelog (provided with the device).
15
+
16
+ To check compatibility, look up the API version of the package release and of the device firmware release in their respective changelogs. If they match, the two are compatible.
17
+
18
+ If the package finds a connected device whose API version it does not support, it emits a warning. Update the package or the device firmware so their API versions align.
19
+
9
20
  ## python-can API
10
21
 
11
22
  ### Installation
@@ -62,6 +73,12 @@ In the above example two CANsub devices are detected, each with two channels. On
62
73
 
63
74
  ### Opening a Bus
64
75
 
76
+ > **Note:** `data_bitrate` is required even on a classic (non-FD) bus. On a non-FD bus, simply set it to e.g. `1_000_000` (1 Mbit/s).
77
+
78
+ > **Tip:** Pass `listen_only=True` to `can.Bus` to monitor a bus without transmitting or acknowledging frames.
79
+
80
+ > **Tip:** Pass `error_frames=True` to `can.Bus` to receive error frames.
81
+
65
82
  #### Single bus - hardcoded
66
83
 
67
84
  ```python
@@ -91,6 +108,22 @@ with (can.Bus(interface=configs[0]["interface"], channel=configs[0]["channel"],
91
108
  > pass
92
109
  > ```
93
110
 
111
+ ### TLS / Certificates
112
+
113
+ The data connection to the device is secured by TLS. When connecting via an IP address (where hostname verification will fail), `server_cert=None` can be used to disable certificate validation:
114
+
115
+ ```python
116
+ with can.Bus(interface="cansub", channel="192.168.1.10@1", server_cert=None, bitrate=250_000, data_bitrate=1_000_000) as bus:
117
+ pass
118
+ ```
119
+
120
+ If TLS mutual authentication is enabled, `client_cert` can be used to provide a tuple of paths to the client certificate (`.crt` file) and its unencrypted private key (`.key` file), i.e. `("cert", "key")`:
121
+
122
+ ```python
123
+ with can.Bus(**configs[0], client_cert=("/path/to/client.crt", "/path/to/client.key"), bitrate=250_000, data_bitrate=1_000_000) as bus:
124
+ pass
125
+ ```
126
+
94
127
  ### Receive and Transmit
95
128
 
96
129
  ```python
@@ -105,6 +138,20 @@ with can.Bus(**configs[0], bitrate=250_000, data_bitrate=1_000_000) as bus:
105
138
  print(msg_rx)
106
139
  ```
107
140
 
141
+ ### Error Frames
142
+
143
+ Error frame reporting is disabled by default; enable it by passing `error_frames=True` to `can.Bus`. Bus errors are then received as a `can.Message` with `is_error_frame` set. The error type is encoded in `arbitration_id`, which can be converted to a `CanSubErrorFrameType` enum:
144
+
145
+ ```python
146
+ from python_can_cansub import CanSubErrorFrameType
147
+
148
+ with can.Bus(**configs[0], bitrate=250_000, data_bitrate=1_000_000, error_frames=True) as bus:
149
+ msg = bus.recv(timeout=1.0)
150
+ if msg and msg.is_error_frame:
151
+ error_type = CanSubErrorFrameType(msg.arbitration_id)
152
+ print(f"Bus error: {error_type.name}") # e.g. "Bus error: ACK"
153
+ ```
154
+
108
155
  ### Filters
109
156
 
110
157
  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)`.
@@ -173,6 +220,28 @@ with can.Bus(**configs[0], bitrate=250_000, data_bitrate=1_000_000) as bus:
173
220
  bus.send(msg)
174
221
  ```
175
222
 
223
+ ### CSV Logger
224
+
225
+ On import, this package overrides the default python-can `.csv` reader and writer with a format compatible with the *webCAN* browser tool provided with the device. This applies automatically wherever `.csv` files are read or written, including `can.Logger`, `can.LogReader`, and the command-line tools.
226
+
227
+ The writer (`CanSubCSVWriter`) and reader (`CanSubCSVReader`) can also be used directly:
228
+
229
+ ```python
230
+ from python_can_cansub import CanSubCSVWriter, CanSubCSVReader
231
+
232
+ # Write received messages to a webCAN-compatible CSV file
233
+ with can.Bus(**configs[0], bitrate=250_000, data_bitrate=1_000_000) as bus:
234
+ with CanSubCSVWriter("log.csv") as writer:
235
+ msg = bus.recv(timeout=1.0)
236
+ if msg:
237
+ writer.on_message_received(msg)
238
+
239
+ # Read messages back from the CSV file
240
+ with CanSubCSVReader("log.csv") as reader:
241
+ for msg in reader:
242
+ print(msg)
243
+ ```
244
+
176
245
  ## python-can tools
177
246
 
178
247
  python-can includes several command line tools. All tools accept `--interface` and `--channel` to select the bus, following the same configuration as the API.
@@ -0,0 +1,43 @@
1
+ # Repository Workflow Reminder
2
+
3
+ This project uses a **private repository for development** and a **public repository for cleaned releases**.
4
+
5
+ The private repository is hosted on Bitbucket while the public repository is hosted on GitHub (https://github.com/CSS-Electronics/python-can-cansub).
6
+
7
+ ## Branches
8
+
9
+ - `master`: private development master. Full history. **Never pushed to the public repository.**
10
+ - `public-master`: temporary local branch used only to build and push a public release.
11
+
12
+ ## Publish a Release
13
+
14
+ 1. Fetch the public master into a local `public-master`. No GitHub remote is added, which avoids accidental pushes:
15
+
16
+ ```bash
17
+ git fetch git@github.com:CSS-Electronics/python-can-cansub.git master:public-master
18
+ git checkout public-master
19
+ ```
20
+
21
+ 2. Bring the release changes over from the private branch **manually**. In CLion, open the private branch's **"Show Diff with Working Tree"** and pick the individual changes to include. Leave out anything private (e.g. the `private_repo/` folder).
22
+
23
+ > Do not use `git merge` / `git cherry-pick` — hand-picking keeps the public history clean and prevents private content leaking.
24
+
25
+ 3. Commit on `public-master`, then verify the diff against the private branch is exactly what should be public (versions in `CHANGELOG.md` and `pyproject.toml` match, no private files).
26
+
27
+ 4. Push straight to the public GitHub master:
28
+
29
+ ```bash
30
+ git push git@github.com:CSS-Electronics/python-can-cansub.git public-master:master
31
+ ```
32
+
33
+ 5. Delete the local `public-master` when done:
34
+
35
+ ```bash
36
+ git branch -D public-master
37
+ ```
38
+
39
+ ## Rule of Thumb
40
+
41
+ - `master` = private full history.
42
+ - `public-master` = throwaway branch, hand-curated per release.
43
+ - Never push private `master` to the public repository.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-can-cansub"
7
- version = "2026.06.01"
7
+ version = "2026.07.02"
8
8
  authors = [
9
9
  { name="CSS Electronics" },
10
10
  { email="contact@csselectronics.com" },
@@ -37,6 +37,7 @@ csv = "python_can_cansub.cansub:CanSubCSVReader"
37
37
  [project.urls]
38
38
  "Homepage" = "https://csselectronics.com/"
39
39
  "Source" = "https://github.com/CSS-Electronics/python-can-cansub"
40
+ "Changelog" = "https://github.com/CSS-Electronics/python-can-cansub/blob/master/CHANGELOG.md"
40
41
 
41
42
  [tool.pytest.ini_options]
42
43
  pythonpath = [
@@ -0,0 +1,4 @@
1
+ from .cansub_protocol import CanSubErrorFrameType
2
+ from .cansub import CANSUB_ROOT_CERT, CanSub, CanSubHwFilter, CanSubCSVWriter, CanSubCSVReader, register_cansub_csv_writer
3
+ # Override the default CSV writer/reader
4
+ register_cansub_csv_writer()
@@ -8,12 +8,16 @@ import threading
8
8
  from pathlib import Path
9
9
  from datetime import datetime, timezone
10
10
  from time import time, sleep
11
- from typing import Optional, Sequence, TypedDict, Union, Callable, Generator, Any
11
+ from typing import Optional, Sequence, Tuple, TypedDict, Union, Callable, Generator, Any
12
12
  from can import LimitedDurationCyclicSendTaskABC, BusABC, RestartableCyclicTaskABC
13
13
  from can.io.generic import TextIOMessageWriter, TextIOMessageReader
14
14
  from wsproto import ConnectionType, WSConnection, ConnectionState, events
15
+ from zeroconf import InterfacesType, ServiceListener, Zeroconf, ServiceBrowser, InterfaceChoice, IPVersion
16
+
15
17
  from python_can_cansub.cansub_protocol import cansub_protocol_decode, cansub_protocol_encode
16
18
 
19
+ CANSUB_ROOT_CERT: Path = Path(__file__).with_name("cansub_root_cert.crt")
20
+
17
21
  class CanSubHwFilter(TypedDict):
18
22
  is_extended: bool
19
23
  is_range: bool
@@ -24,7 +28,7 @@ CanSubHwFilters = Sequence[CanSubHwFilter]
24
28
 
25
29
  class CanSub(can.BusABC):
26
30
 
27
- _supported_api_versions = ["02.00"]
31
+ _supported_api_versions = ["03.00"]
28
32
  _can_protocol = can.CanProtocol.CAN_FD_NON_ISO
29
33
  _can_f_clock = 80_000_000
30
34
 
@@ -38,12 +42,24 @@ class CanSub(can.BusABC):
38
42
  listen_only: Optional[bool] = False,
39
43
  auto_reset: Optional[bool] = True,
40
44
  error_frames: Optional[bool] = False,
45
+ server_cert: Optional[Union[str, Path]] = CANSUB_ROOT_CERT,
46
+ client_cert: Optional[Tuple[Union[str, Path], Union[str, Path]]] = None,
41
47
  **kwargs: object
42
48
  ):
43
49
  """
44
50
  Python-can compatible interface over a websocket connection. Functions bus.send and bus.recv are made
45
51
  thread-safe by using queues.
46
52
 
53
+ :param channel:
54
+ The CAN channel to use. Can be an integer or a string. If a string contains '@',
55
+ it can specify both the device address and the channel (e.g., 'aabbccdd-usb.local@1').
56
+
57
+ :param can_filters:
58
+ Optional hardware filters to apply.
59
+
60
+ :param address:
61
+ The hostname or IP address of the device. If not provided, it must be part of the `channel` string.
62
+
47
63
  :param timing:
48
64
  CAN-bus bit-timing. Takes precedence over bitrate and data_bitrate.
49
65
 
@@ -62,6 +78,18 @@ class CanSub(can.BusABC):
62
78
  :param error_frames:
63
79
  CAN-bus error frame reporting.
64
80
 
81
+ :param server_cert:
82
+ Path to the server (device) public certificate (.crt file). If None, server verification is turned off.
83
+ Defaults to the built-in CANsub root certificate.
84
+
85
+ :param client_cert:
86
+ Tuple of paths to the client certificate (.crt file) and the unencrypted private key (.key file)
87
+ (i.e. ('cert', 'key')). Password protection is not supported.
88
+ Required when TLS mutual authentication is enabled.
89
+
90
+ :param kwargs:
91
+ Extra arguments passed to the parent `can.BusABC` class.
92
+
65
93
  """
66
94
 
67
95
  # To be able to use python-can command line tool without custom arguments, support that channel can contain
@@ -83,6 +111,41 @@ class CanSub(can.BusABC):
83
111
  host = address
84
112
  port = 443
85
113
 
114
+ # Server (device) certificate
115
+ if not isinstance(server_cert, (type(None), str, Path)):
116
+ raise can.exceptions.CanInitializationError("Invalid server_cert")
117
+
118
+ self.server_cert = Path(server_cert) if server_cert else None
119
+ del server_cert
120
+
121
+ if self.server_cert is not None:
122
+ if not self.server_cert.is_file():
123
+ raise can.exceptions.CanInitializationError(f"Server certificate not found: {self.server_cert}")
124
+ if self.server_cert.suffix.lower() != ".crt":
125
+ raise can.exceptions.CanInitializationError(f"Server certificate must be a .crt file: {self.server_cert}")
126
+
127
+ # Client certificate (mTLS)
128
+ if client_cert is not None:
129
+ if not (isinstance(client_cert, (tuple, list)) and len(client_cert) == 2
130
+ and all(isinstance(p, (str, Path)) for p in client_cert)):
131
+ raise can.exceptions.CanInitializationError("Invalid client_cert")
132
+
133
+ self.client_cert = (Path(client_cert[0]), Path(client_cert[1]))
134
+ else:
135
+ self.client_cert = None
136
+ del client_cert
137
+
138
+ if self.client_cert is not None:
139
+ client_crt, client_key = self.client_cert
140
+ if not client_crt.is_file():
141
+ raise can.exceptions.CanInitializationError(f"Client certificate not found: {client_crt}")
142
+ if client_crt.suffix.lower() != ".crt":
143
+ raise can.exceptions.CanInitializationError(f"Client certificate must be a .crt file: {client_crt}")
144
+ if not client_key.is_file():
145
+ raise can.exceptions.CanInitializationError(f"Client key not found: {client_key}")
146
+ if client_key.suffix.lower() != ".key":
147
+ raise can.exceptions.CanInitializationError(f"Client key must be a .key file: {client_key}")
148
+
86
149
  # Channel
87
150
  self.channel = None
88
151
  if isinstance(channel, can.typechecking.ChannelInt):
@@ -134,16 +197,14 @@ class CanSub(can.BusABC):
134
197
  # Set channel info (required by python-can)
135
198
  self.channel_info = f"{address}@{self.channel}"
136
199
 
137
- # Path to device root certificate
138
- self.cansub_cert = str(Path(__file__).with_name("cansub_root_cert.crt"))
139
-
140
200
  # Perform REST interaction with the device in a persistent session
141
201
  self.api_url = f"https://{host}:{port}/api"
142
202
  self._net_timeout = 3.0
143
203
 
144
204
  # Create persistent session for REST API calls
145
205
  self.session = requests.Session()
146
- self.session.verify = self.cansub_cert
206
+ self.session.verify = str(self.server_cert) if self.server_cert else False
207
+ self.session.cert = (str(self.client_cert[0]), str(self.client_cert[1])) if self.client_cert else None
147
208
 
148
209
  # Get api version (and test connection)
149
210
  try:
@@ -179,6 +240,16 @@ class CanSub(can.BusABC):
179
240
  # (Ensures that no messages get through until provided filters are set)
180
241
  self.set_hw_filters(None)
181
242
 
243
+ # Ensure that no transmit-sequences are configured before the channel is opened
244
+ try:
245
+ response = self.session.get(f"{self.api_url}/can/{self.channel}/transmit", timeout=self._net_timeout)
246
+ response.raise_for_status()
247
+ for transmit_id in response.json():
248
+ response = self.session.delete(f"{self.api_url}/can/{self.channel}/transmit/{transmit_id}", timeout=self._net_timeout)
249
+ response.raise_for_status()
250
+ except Exception as e:
251
+ raise can.exceptions.CanInitializationError(f"Failed to clear transmit sequences ({e})")
252
+
182
253
  # Set channel configuration
183
254
  phy_config = {
184
255
  "listen_only": self.listen_only,
@@ -205,7 +276,15 @@ class CanSub(can.BusABC):
205
276
  raise can.exceptions.CanInitializationError(f"Failed to configure channel ({e})")
206
277
 
207
278
  # Socket TLS configuration
208
- ssl_context = ssl.create_default_context(cafile=self.cansub_cert)
279
+ if self.server_cert:
280
+ ssl_context = ssl.create_default_context(cafile=self.server_cert)
281
+ else:
282
+ ssl_context = ssl.create_default_context()
283
+ ssl_context.check_hostname = False
284
+ ssl_context.verify_mode = ssl.CERT_NONE
285
+
286
+ if self.client_cert:
287
+ ssl_context.load_cert_chain(certfile=self.client_cert[0], keyfile=self.client_cert[1])
209
288
 
210
289
  # Socket
211
290
  self.ws_sock = ssl_context.wrap_socket(sock=socket.socket(socket.AF_INET, socket.SOCK_STREAM), server_hostname=host)
@@ -643,8 +722,16 @@ class CanSub(can.BusABC):
643
722
 
644
723
  @staticmethod
645
724
  def _detect_available_configs() -> Sequence[can.typechecking.AutoDetectedConfig]:
646
- from zeroconf import InterfaceChoice, ServiceBrowser, Zeroconf
725
+ return CanSub.mdns_discover(interfaces=InterfaceChoice.All)
647
726
 
727
+ @staticmethod
728
+ def mdns_discover(interfaces: InterfacesType = InterfaceChoice.All, discovery_time: float = 2.0) -> Sequence[can.typechecking.AutoDetectedConfig]:
729
+ """Discover available CANsub devices by performing a mDNS lookup.
730
+
731
+ @param interfaces: The interfaces to listen on. These must be either interface addresses or one of the zeroconf
732
+ InterfaceChoice enum values.
733
+ @return: A sequence of auto-detected CANbus configurations.
734
+ """
648
735
  # Collect DNS-SD service info objects keyed by service name.
649
736
  # The device firmware advertises _cansub._tcp with TXT records:
650
737
  # api=<version> - must match _supported_api_versions
@@ -653,7 +740,7 @@ class CanSub(can.BusABC):
653
740
  # e.g. 1b5b9343-usb.local (USB) or 1b5b9343-eth.local (Ethernet).
654
741
  discovered = {}
655
742
 
656
- class _Listener:
743
+ class _Listener(ServiceListener):
657
744
  def add_service(self, zc, type_, name):
658
745
  info = zc.get_service_info(type_, name)
659
746
  if info:
@@ -661,12 +748,11 @@ class CanSub(can.BusABC):
661
748
  def remove_service(self, zc, type_, name): pass
662
749
  def update_service(self, zc, type_, name): pass
663
750
 
664
- # Listen on all interfaces so devices on multiple USB/Ethernet links
665
- # are all discovered (each CDC-NCM or Ethernet link is a separate interface).
666
- zc = Zeroconf(interfaces=InterfaceChoice.All)
751
+ # Listen on all specified interfaces.
752
+ zc = Zeroconf(interfaces=interfaces, ip_version=IPVersion.V4Only, use_asyncio=False)
667
753
  try:
668
754
  ServiceBrowser(zc, "_cansub._tcp.local.", _Listener())
669
- sleep(2.0)
755
+ sleep(discovery_time)
670
756
  finally:
671
757
  zc.close()
672
758
 
@@ -680,7 +766,13 @@ class CanSub(can.BusABC):
680
766
  }
681
767
 
682
768
  # Skip devices running an unsupported API version.
683
- if txt.get("api") not in CanSub._supported_api_versions:
769
+ api_version = txt.get("api")
770
+ if api_version not in CanSub._supported_api_versions:
771
+ warnings.warn(
772
+ f"Detected CANsub device {info.server.rstrip('.')} has an unsupported API version ({api_version}). "
773
+ f"Supported versions: [{', '.join(CanSub._supported_api_versions)}]",
774
+ UserWarning
775
+ )
684
776
  continue
685
777
 
686
778
  # info.server has a trailing dot (DNS convention); strip it.
@@ -715,7 +807,6 @@ class CyclicSendTask(LimitedDurationCyclicSendTaskABC, RestartableCyclicTaskABC)
715
807
 
716
808
  self.transmit_api_url = f"{cansub.api_url}/can/{channel}/transmit"
717
809
  self._net_timeout = cansub._net_timeout
718
- self.cansub_cert = cansub.cansub_cert
719
810
  self.session = cansub.session
720
811
 
721
812
  period_ms = int(len(self.messages) * self.period_ns / 1_000_000)
@@ -744,7 +835,7 @@ class CyclicSendTask(LimitedDurationCyclicSendTaskABC, RestartableCyclicTaskABC)
744
835
  }
745
836
 
746
837
  # Set up new transmit sequence
747
- response = self.session.post(url=self.transmit_api_url, timeout=self._net_timeout, verify=self.cansub_cert,
838
+ response = self.session.post(url=self.transmit_api_url, timeout=self._net_timeout,
748
839
  json=transmit_sequence)
749
840
 
750
841
  if response.status_code != 201:
@@ -763,14 +854,13 @@ class CyclicSendTask(LimitedDurationCyclicSendTaskABC, RestartableCyclicTaskABC)
763
854
  def start(self) -> None:
764
855
  """Restart a stopped periodic task."""
765
856
  response = self.session.put(url=f"{self.transmit_api_url}/{self.transmit_id}/count", timeout=self._net_timeout,
766
- verify=self.cansub_cert, json=0)
857
+ json=0)
767
858
  if response.status_code != 200:
768
859
  raise can.exceptions.CanOperationError(f"Failed to (re)start transmit sequence ({response.status_code})")
769
860
 
770
861
  def stop(self) -> None:
771
862
  """Stop periodic task."""
772
- response = self.session.delete(url=f"{self.transmit_api_url}/{self.transmit_id}", timeout=self._net_timeout,
773
- verify=self.cansub_cert)
863
+ response = self.session.delete(url=f"{self.transmit_api_url}/{self.transmit_id}", timeout=self._net_timeout)
774
864
  if response.status_code != 200:
775
865
  raise can.exceptions.CanOperationError(f"Failed to stop transmit sequence ({response.status_code})")
776
866
  return
@@ -883,4 +973,3 @@ def register_cansub_csv_writer():
883
973
  from can.io.player import MESSAGE_READERS
884
974
  MESSAGE_WRITERS[".csv"] = CanSubCSVWriter
885
975
  MESSAGE_READERS[".csv"] = CanSubCSVReader
886
-
@@ -1,8 +0,0 @@
1
- # Changelog
2
-
3
- All notable changes to this project will be documented in this file.
4
-
5
- ## 2026.06.01
6
-
7
- ### Fixed
8
- - `CyclicSendTask` with `duration=None` now working.
@@ -1,70 +0,0 @@
1
- # Repository Workflow Reminder
2
-
3
- This project uses a **private repository for development** and a **public repository for cleaned releases**.
4
-
5
- The private repository is hosted on Bitbucket while the public repository is hosted on GitHub (https://github.com/CSS-Electronics/python-can-cansub).
6
-
7
- ## Branches
8
-
9
- ### `master`
10
-
11
- Private development master.
12
-
13
- - Lives in the private repository.
14
- - Contains the full commit history.
15
- - Feature branches are reviewed and fast-forward merged here.
16
- - This branch is **not pushed to the public repository**.
17
-
18
- ### `public-master`
19
-
20
- Clean public-ready master.
21
-
22
- - Lives in the private repository too.
23
- - Contains squashed/clean commits only.
24
- - This is the branch that gets pushed to the public repository's `master`.
25
-
26
- ## Normal Development
27
-
28
- Work normally in the IDE using the private repository.
29
-
30
- ## Prepare Public Version
31
-
32
- When a feature is ready for public release, squash it into `public-master`:
33
-
34
- ```bash
35
- git checkout public-master
36
- git merge --squash feature/my-feature
37
- git commit -m "Add my feature"
38
- ```
39
-
40
- Validate that `public-master` content and history is correct.
41
-
42
- Push the cleaned branch to the private repo:
43
-
44
- ```bash
45
- git push origin public-master
46
- ```
47
-
48
- ## Push to Public Repository
49
-
50
- The public repository remote is not kept configured permanently.
51
-
52
- Push only when intentionally publishing:
53
-
54
- ```bash
55
- git push git@github.com:CSS-Electronics/python-can-cansub.git public-master:master
56
- ```
57
-
58
- This pushes:
59
-
60
- ```text
61
- local/private public-master -> public repo master
62
- ```
63
-
64
- ## Rule of Thumb
65
-
66
- - `master` = private full history
67
- - `public-master` = clean squashed history
68
- - public repo `master` = copy of `public-master`
69
-
70
- Never push private `master` to the public repository.
@@ -1,4 +0,0 @@
1
- from .cansub_protocol import CanSubErrorFrameType
2
- from .cansub import CanSub, CanSubHwFilter, CanSubCSVWriter, CanSubCSVReader, register_cansub_csv_writer
3
- # Override the default CSV writer/reader
4
- register_cansub_csv_writer()