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.
- python_can_cansub-2026.7.2/CHANGELOG.md +26 -0
- {python_can_cansub-2026.6.1 → python_can_cansub-2026.7.2}/PKG-INFO +71 -1
- {python_can_cansub-2026.6.1 → python_can_cansub-2026.7.2}/README.md +69 -0
- python_can_cansub-2026.7.2/private_repo/README_REPO.md +43 -0
- {python_can_cansub-2026.6.1 → python_can_cansub-2026.7.2}/pyproject.toml +2 -1
- python_can_cansub-2026.7.2/src/python_can_cansub/__init__.py +4 -0
- {python_can_cansub-2026.6.1 → python_can_cansub-2026.7.2}/src/python_can_cansub/cansub.py +109 -20
- python_can_cansub-2026.6.1/CHANGELOG.md +0 -8
- python_can_cansub-2026.6.1/private_repo/README_REPO.md +0 -70
- python_can_cansub-2026.6.1/src/python_can_cansub/__init__.py +0 -4
- {python_can_cansub-2026.6.1 → python_can_cansub-2026.7.2}/.gitignore +0 -0
- {python_can_cansub-2026.6.1 → python_can_cansub-2026.7.2}/private_repo/README.md +0 -0
- {python_can_cansub-2026.6.1 → python_can_cansub-2026.7.2}/private_repo/README_BUILD.md +0 -0
- {python_can_cansub-2026.6.1 → python_can_cansub-2026.7.2}/private_repo/README_DEV.md +0 -0
- {python_can_cansub-2026.6.1 → python_can_cansub-2026.7.2}/private_repo/README_PYPI.md +0 -0
- {python_can_cansub-2026.6.1 → python_can_cansub-2026.7.2}/src/python_can_cansub/cansub_protocol.py +0 -0
- {python_can_cansub-2026.6.1 → python_can_cansub-2026.7.2}/src/python_can_cansub/cansub_root_cert.crt +0 -0
|
@@ -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.
|
|
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.
|
|
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 = [
|
|
@@ -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 = ["
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
665
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,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.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_can_cansub-2026.6.1 → python_can_cansub-2026.7.2}/src/python_can_cansub/cansub_protocol.py
RENAMED
|
File without changes
|
{python_can_cansub-2026.6.1 → python_can_cansub-2026.7.2}/src/python_can_cansub/cansub_root_cert.crt
RENAMED
|
File without changes
|