nuotec-sent2usb 0.1.0__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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nuotec GmbH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,188 @@
1
+ Metadata-Version: 2.4
2
+ Name: nuotec-sent2usb
3
+ Version: 0.1.0
4
+ Summary: Driver for Nuotec SENT2USB Converter
5
+ Author: Nuotec GmbH
6
+ License-Expression: MIT
7
+ License-File: LICENSE.txt
8
+ Requires-Dist: cobs>=1.2.2,<2
9
+ Requires-Dist: pyserial>=3.5,<4
10
+ Requires-Python: >=3.8
11
+ Description-Content-Type: text/markdown
12
+
13
+ # Nuotec SENT2USB Python Driver
14
+
15
+ This is an alternative Python driver for the
16
+ [SENT (SAE J2716) to USB converter from uCANDevices](https://ucandevices.github.io/sentusb.html).
17
+
18
+ > [!IMPORTANT]
19
+ > This driver requires the firmware from
20
+ > [nuotec-sent2usb-firmware](https://gitlab.com/nuotec/sent2usb/nuotec-sent2usb-firmware)
21
+ > to be running on the device. See the instructions below to replace the
22
+ > original firmware with this firmware.
23
+
24
+ > [!NOTE]
25
+ > For more information about the advantages and disadvantages of this
26
+ > alternative firmware and Python driver compared to the original product, see
27
+ > details at
28
+ > [nuotec-sent2usb-firmware](https://gitlab.com/nuotec/sent2usb/nuotec-sent2usb-firmware).
29
+
30
+ ## Installation
31
+
32
+ Install the package from PyPI:
33
+
34
+ ```bash
35
+ pip install nuotec-sent2usb
36
+ ```
37
+
38
+ ## CLI Usage
39
+
40
+ The easiest way to use this driver is through its command-line interface:
41
+
42
+ ```bash
43
+ $ nuotec-sent2usb --help
44
+
45
+ usage: nuotec-sent2usb [-h] [-l] [-a] [-b] [-o] [-f] [-u] [-p PORT] [-s SEND] [-i INTERVAL] [-m] [-t TICK_TIME] [-w WIDTH]
46
+
47
+ options:
48
+ -h, --help show this help message and exit
49
+ -l, --list List connected devices
50
+ -a, --all List all available serial ports
51
+ -b, --bootloader Enter DFU bootloader
52
+ -o, --flash-option-bytes
53
+ Flash option bytes with dfu-util
54
+ -f, --flash Flash firmware with dfu-util
55
+ -u, --update Check & update firmware on device
56
+ -p, --port PORT Serial port
57
+ -s, --send SEND Send nibbles (e.g. '0abc')
58
+ -i, --interval INTERVAL
59
+ Send data periodically in this interval [s]
60
+ -m, --monitor Monitor and print received SENT frames
61
+ -t, --tick-time TICK_TIME
62
+ Clock tick time in microseconds
63
+ -w, --width WIDTH Low pulse length in ticks
64
+ ```
65
+
66
+ ### Flash Firmware
67
+
68
+ After you have purchased an original
69
+ [SENTToUSB device from uCANDevices](https://ucandevices.github.io/sentusb.html),
70
+ you need to replace its firmware to make it work with this Python driver.
71
+
72
+ > [!CAUTION]
73
+ > This step may be risky. After flashing our firmware, your device won't be
74
+ > compatible with the tools from uCANDevices anymore. It is recommended to
75
+ > back up the original firmware on the device first, to allow restoring it
76
+ > at any time.
77
+
78
+ First, instruct the original firmware to enter the bootloader (adjust
79
+ `/dev/ttyACM0` to match your serial port):
80
+
81
+ ```bash
82
+ python -c "import serial,time; s=serial.Serial('/dev/ttyACM0',115200,timeout=1); s.write(b'boot\r'); time.sleep(0.3); s.close()"
83
+ ```
84
+
85
+ Then flash the new firmware to it. This will require
86
+ [dfu-util](https://dfu-util.sourceforge.net/) to be available in `PATH`:
87
+
88
+ ```bash
89
+ nuotec-sent2usb -f
90
+ ```
91
+
92
+ ### Update Firmware
93
+
94
+ If the device is already running our custom firmware, it can be updated with
95
+ a single command (the firmware binary is contained in the Python driver):
96
+
97
+ ```bash
98
+ nuotec-sent2usb -u
99
+ ```
100
+
101
+ ### Example: List Devices
102
+
103
+ ```bash
104
+ $ nuotec-sent2usb -l
105
+ /dev/ttyACM0 Nuotec SENT2USB - CDC Interface 002E002F393043022033344E
106
+ ```
107
+
108
+ ### Example: Send Frame (90us Tick Time, Data=123456)
109
+
110
+ ```bash
111
+ $ nuotec-sent2usb -t 90 -s 123456
112
+ Firmware: 1.0.0 (9b287c72ce4c48e7c11475cbb7acde5df17e562a)
113
+ Tick Time: 90 μs
114
+ TX: status=0x1 data=[0x2, 0x3, 0x4, 0x5, 0x6] crc=0x0
115
+ ```
116
+
117
+ ### Example: Monitor Received Frames (90us Tick Time)
118
+
119
+ ```bash
120
+ $ nuotec-sent2usb -t 90 -m
121
+ Firmware: 1.0.0 (9b287c72ce4c48e7c11475cbb7acde5df17e562a)
122
+ Tick Time: 90 μs
123
+ RX: status=0x0 data=[0x1, 0x0, 0xC, 0x3, 0xC, 0x0] crc=0xB
124
+ RX: status=0x0 data=[0x1, 0x0, 0xC, 0x3, 0xC, 0x0] crc=0xB
125
+ RX: status=0x0 data=[0x1, 0x0, 0xC, 0x3, 0xC, 0x0] crc=0xB
126
+ RX: status=0x0 data=[0x1, 0x0, 0xC, 0x3, 0xC, 0x0] crc=0xB
127
+ ```
128
+
129
+ Press Ctrl+C to cancel.
130
+
131
+ ## API Usage
132
+
133
+ The driver can also be used from Python directly:
134
+
135
+ ```python
136
+ import time
137
+ from nuotec_sent2usb import Sent2UsbDevice, SentFrame, SentDecoder
138
+
139
+ with Sent2UsbDevice() as device:
140
+ print(f"Firmware: {device.get_version()}")
141
+
142
+ # Send frame
143
+ frame = SentFrame(status=0x0, data=[0x1, 0x2, 0x3, 0x4, 0x5, 0x6])
144
+ device.send_pulses(frame.to_pulses(5, 90)) # width [ticks] & tick time [us]
145
+ print(f"TX: {frame}")
146
+
147
+ # Receive frames
148
+ decoder = SentDecoder(90) # tick time [us]
149
+ while True:
150
+ for period, width in device.fetch_pulses():
151
+ frame = decoder.process(period, width)
152
+ if frame is not None:
153
+ print(f"RX: {frame}")
154
+ time.sleep(0.05)
155
+ ```
156
+
157
+ For more details, see the Python files in this package.
158
+
159
+ ## Disclaimer
160
+
161
+ This project is provided as-is without any guarantee for correct behavior.
162
+ It was a side project, without the goal of creating a highly reliable
163
+ production device. So far it seems to work fine, but the code is not perfect
164
+ at all and would need some deeper review, rework and more testing to get more
165
+ confidence about the quality and reliability of this product.
166
+
167
+ ## Contributions
168
+
169
+ Generally, this project has been published just in case it could be useful for
170
+ other people. However, it may or may not fit your use-case. In case you are
171
+ interested in adding more functionality, bugfixing, etc., we are happy to
172
+ receive contributions (though without any guarantee that we find the time to
173
+ review & merge it - it's recommended to contact us in advance about your
174
+ intentions).
175
+
176
+ ## Credits
177
+
178
+ The development of this project is kindly sponsored by
179
+ [Nuotec GmbH](https://nuotec.ch) and
180
+ [Sensirion Automotive Solutions AG](https://sensirion-automotive.com/).
181
+
182
+ Many thanks to [uCANDevices](https://ucandevices.github.io/) for developing
183
+ the hardware this firmware runs on, which is available for purchase through
184
+ their website.
185
+
186
+ ## License
187
+
188
+ Released under MIT, see [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,176 @@
1
+ # Nuotec SENT2USB Python Driver
2
+
3
+ This is an alternative Python driver for the
4
+ [SENT (SAE J2716) to USB converter from uCANDevices](https://ucandevices.github.io/sentusb.html).
5
+
6
+ > [!IMPORTANT]
7
+ > This driver requires the firmware from
8
+ > [nuotec-sent2usb-firmware](https://gitlab.com/nuotec/sent2usb/nuotec-sent2usb-firmware)
9
+ > to be running on the device. See the instructions below to replace the
10
+ > original firmware with this firmware.
11
+
12
+ > [!NOTE]
13
+ > For more information about the advantages and disadvantages of this
14
+ > alternative firmware and Python driver compared to the original product, see
15
+ > details at
16
+ > [nuotec-sent2usb-firmware](https://gitlab.com/nuotec/sent2usb/nuotec-sent2usb-firmware).
17
+
18
+ ## Installation
19
+
20
+ Install the package from PyPI:
21
+
22
+ ```bash
23
+ pip install nuotec-sent2usb
24
+ ```
25
+
26
+ ## CLI Usage
27
+
28
+ The easiest way to use this driver is through its command-line interface:
29
+
30
+ ```bash
31
+ $ nuotec-sent2usb --help
32
+
33
+ usage: nuotec-sent2usb [-h] [-l] [-a] [-b] [-o] [-f] [-u] [-p PORT] [-s SEND] [-i INTERVAL] [-m] [-t TICK_TIME] [-w WIDTH]
34
+
35
+ options:
36
+ -h, --help show this help message and exit
37
+ -l, --list List connected devices
38
+ -a, --all List all available serial ports
39
+ -b, --bootloader Enter DFU bootloader
40
+ -o, --flash-option-bytes
41
+ Flash option bytes with dfu-util
42
+ -f, --flash Flash firmware with dfu-util
43
+ -u, --update Check & update firmware on device
44
+ -p, --port PORT Serial port
45
+ -s, --send SEND Send nibbles (e.g. '0abc')
46
+ -i, --interval INTERVAL
47
+ Send data periodically in this interval [s]
48
+ -m, --monitor Monitor and print received SENT frames
49
+ -t, --tick-time TICK_TIME
50
+ Clock tick time in microseconds
51
+ -w, --width WIDTH Low pulse length in ticks
52
+ ```
53
+
54
+ ### Flash Firmware
55
+
56
+ After you have purchased an original
57
+ [SENTToUSB device from uCANDevices](https://ucandevices.github.io/sentusb.html),
58
+ you need to replace its firmware to make it work with this Python driver.
59
+
60
+ > [!CAUTION]
61
+ > This step may be risky. After flashing our firmware, your device won't be
62
+ > compatible with the tools from uCANDevices anymore. It is recommended to
63
+ > back up the original firmware on the device first, to allow restoring it
64
+ > at any time.
65
+
66
+ First, instruct the original firmware to enter the bootloader (adjust
67
+ `/dev/ttyACM0` to match your serial port):
68
+
69
+ ```bash
70
+ python -c "import serial,time; s=serial.Serial('/dev/ttyACM0',115200,timeout=1); s.write(b'boot\r'); time.sleep(0.3); s.close()"
71
+ ```
72
+
73
+ Then flash the new firmware to it. This will require
74
+ [dfu-util](https://dfu-util.sourceforge.net/) to be available in `PATH`:
75
+
76
+ ```bash
77
+ nuotec-sent2usb -f
78
+ ```
79
+
80
+ ### Update Firmware
81
+
82
+ If the device is already running our custom firmware, it can be updated with
83
+ a single command (the firmware binary is contained in the Python driver):
84
+
85
+ ```bash
86
+ nuotec-sent2usb -u
87
+ ```
88
+
89
+ ### Example: List Devices
90
+
91
+ ```bash
92
+ $ nuotec-sent2usb -l
93
+ /dev/ttyACM0 Nuotec SENT2USB - CDC Interface 002E002F393043022033344E
94
+ ```
95
+
96
+ ### Example: Send Frame (90us Tick Time, Data=123456)
97
+
98
+ ```bash
99
+ $ nuotec-sent2usb -t 90 -s 123456
100
+ Firmware: 1.0.0 (9b287c72ce4c48e7c11475cbb7acde5df17e562a)
101
+ Tick Time: 90 μs
102
+ TX: status=0x1 data=[0x2, 0x3, 0x4, 0x5, 0x6] crc=0x0
103
+ ```
104
+
105
+ ### Example: Monitor Received Frames (90us Tick Time)
106
+
107
+ ```bash
108
+ $ nuotec-sent2usb -t 90 -m
109
+ Firmware: 1.0.0 (9b287c72ce4c48e7c11475cbb7acde5df17e562a)
110
+ Tick Time: 90 μs
111
+ RX: status=0x0 data=[0x1, 0x0, 0xC, 0x3, 0xC, 0x0] crc=0xB
112
+ RX: status=0x0 data=[0x1, 0x0, 0xC, 0x3, 0xC, 0x0] crc=0xB
113
+ RX: status=0x0 data=[0x1, 0x0, 0xC, 0x3, 0xC, 0x0] crc=0xB
114
+ RX: status=0x0 data=[0x1, 0x0, 0xC, 0x3, 0xC, 0x0] crc=0xB
115
+ ```
116
+
117
+ Press Ctrl+C to cancel.
118
+
119
+ ## API Usage
120
+
121
+ The driver can also be used from Python directly:
122
+
123
+ ```python
124
+ import time
125
+ from nuotec_sent2usb import Sent2UsbDevice, SentFrame, SentDecoder
126
+
127
+ with Sent2UsbDevice() as device:
128
+ print(f"Firmware: {device.get_version()}")
129
+
130
+ # Send frame
131
+ frame = SentFrame(status=0x0, data=[0x1, 0x2, 0x3, 0x4, 0x5, 0x6])
132
+ device.send_pulses(frame.to_pulses(5, 90)) # width [ticks] & tick time [us]
133
+ print(f"TX: {frame}")
134
+
135
+ # Receive frames
136
+ decoder = SentDecoder(90) # tick time [us]
137
+ while True:
138
+ for period, width in device.fetch_pulses():
139
+ frame = decoder.process(period, width)
140
+ if frame is not None:
141
+ print(f"RX: {frame}")
142
+ time.sleep(0.05)
143
+ ```
144
+
145
+ For more details, see the Python files in this package.
146
+
147
+ ## Disclaimer
148
+
149
+ This project is provided as-is without any guarantee for correct behavior.
150
+ It was a side project, without the goal of creating a highly reliable
151
+ production device. So far it seems to work fine, but the code is not perfect
152
+ at all and would need some deeper review, rework and more testing to get more
153
+ confidence about the quality and reliability of this product.
154
+
155
+ ## Contributions
156
+
157
+ Generally, this project has been published just in case it could be useful for
158
+ other people. However, it may or may not fit your use-case. In case you are
159
+ interested in adding more functionality, bugfixing, etc., we are happy to
160
+ receive contributions (though without any guarantee that we find the time to
161
+ review & merge it - it's recommended to contact us in advance about your
162
+ intentions).
163
+
164
+ ## Credits
165
+
166
+ The development of this project is kindly sponsored by
167
+ [Nuotec GmbH](https://nuotec.ch) and
168
+ [Sensirion Automotive Solutions AG](https://sensirion-automotive.com/).
169
+
170
+ Many thanks to [uCANDevices](https://ucandevices.github.io/) for developing
171
+ the hardware this firmware runs on, which is available for purchase through
172
+ their website.
173
+
174
+ ## License
175
+
176
+ Released under MIT, see [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,29 @@
1
+ [project]
2
+ name = "nuotec-sent2usb"
3
+ version = "0.1.0"
4
+ description = "Driver for Nuotec SENT2USB Converter"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE.txt"]
8
+ requires-python = ">=3.8"
9
+ dependencies = [
10
+ "cobs>=1.2.2,<2",
11
+ "pyserial>=3.5,<4",
12
+ ]
13
+
14
+ [[project.authors]]
15
+ name = "Nuotec GmbH"
16
+
17
+ [project.scripts]
18
+ nuotec-sent2usb = "nuotec_sent2usb.cli:main"
19
+ sent2usb = "nuotec_sent2usb.cli:main"
20
+
21
+ [dependency-groups]
22
+ dev = [
23
+ "pytest>=8.0,<8.4",
24
+ "ruff>=0.16.2,<0.17",
25
+ ]
26
+
27
+ [build-system]
28
+ requires = ["uv_build>=0.12.3,<0.13"]
29
+ build-backend = "uv_build"
@@ -0,0 +1,29 @@
1
+ [project]
2
+ name = "nuotec-sent2usb"
3
+ version = "0.1.0"
4
+ description = "Driver for Nuotec SENT2USB Converter"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE.txt"]
8
+ authors = [
9
+ { name = "Nuotec GmbH" },
10
+ ]
11
+ requires-python = ">=3.8"
12
+ dependencies = [
13
+ "cobs>=1.2.2,<2",
14
+ "pyserial>=3.5,<4",
15
+ ]
16
+
17
+ [dependency-groups]
18
+ dev = [
19
+ "pytest>=8.0,<8.4",
20
+ "ruff>=0.16.2,<0.17",
21
+ ]
22
+
23
+ [project.scripts]
24
+ nuotec-sent2usb = "nuotec_sent2usb.cli:main"
25
+ sent2usb = "nuotec_sent2usb.cli:main"
26
+
27
+ [build-system]
28
+ requires = ["uv_build>=0.12.3,<0.13"]
29
+ build-backend = "uv_build"
@@ -0,0 +1,19 @@
1
+ from .decoder import SentDecoder
2
+ from .device import Sent2UsbDevice
3
+ from .device_version import Sent2UsbDeviceVersion
4
+ from .exceptions import (
5
+ Sent2UsbCommunicationError,
6
+ Sent2UsbDeviceError,
7
+ Sent2UsbError,
8
+ )
9
+ from .frame import SentFrame
10
+
11
+ __all__ = [
12
+ "Sent2UsbCommunicationError",
13
+ "Sent2UsbDevice",
14
+ "Sent2UsbDeviceError",
15
+ "Sent2UsbDeviceVersion",
16
+ "Sent2UsbError",
17
+ "SentDecoder",
18
+ "SentFrame",
19
+ ]
@@ -0,0 +1,191 @@
1
+ import argparse
2
+ import subprocess
3
+ import sys
4
+ import time
5
+ from importlib import resources
6
+ from struct import unpack
7
+
8
+ from .decoder import SentDecoder
9
+ from .device import Sent2UsbDevice
10
+ from .device_version import Sent2UsbDeviceVersion
11
+ from .frame import SentFrame
12
+
13
+ FIRMWARE_RESOURCE = ("nuotec_sent2usb", "nuotec-sent2usb.bin")
14
+ OPTIONBYTES_RESOURCE = ("nuotec_sent2usb", "optionbytes.bin")
15
+
16
+
17
+ def main():
18
+ parser = argparse.ArgumentParser()
19
+ parser.add_argument(
20
+ "-l", "--list", action="store_true", help="List connected devices"
21
+ )
22
+ parser.add_argument(
23
+ "-a", "--all", action="store_true", help="List all available serial ports"
24
+ )
25
+ parser.add_argument(
26
+ "-b", "--bootloader", action="store_true", help="Enter DFU bootloader"
27
+ )
28
+ parser.add_argument(
29
+ "-o",
30
+ "--flash-option-bytes",
31
+ action="store_true",
32
+ help="Flash option bytes with dfu-util",
33
+ )
34
+ parser.add_argument(
35
+ "-f", "--flash", action="store_true", help="Flash firmware with dfu-util"
36
+ )
37
+ parser.add_argument(
38
+ "-u", "--update", action="store_true", help="Check & update firmware on device"
39
+ )
40
+ parser.add_argument("-p", "--port", type=str, help="Serial port")
41
+ parser.add_argument(
42
+ "-s",
43
+ "--send",
44
+ type=str,
45
+ help="Send nibbles (e.g. '0abc')",
46
+ )
47
+ parser.add_argument(
48
+ "-i",
49
+ "--interval",
50
+ type=float,
51
+ help="Send data periodically in this interval [s]",
52
+ )
53
+ parser.add_argument(
54
+ "-m",
55
+ "--monitor",
56
+ action="store_true",
57
+ help="Monitor and print received SENT frames",
58
+ )
59
+ parser.add_argument(
60
+ "-t", "--tick-time", type=int, default=3, help="Clock tick time in microseconds"
61
+ )
62
+ parser.add_argument(
63
+ "-w", "--width", type=int, default=5, help="Low pulse length in ticks"
64
+ )
65
+ args = parser.parse_args()
66
+
67
+ # --list
68
+ if args.list:
69
+ try:
70
+ ports = Sent2UsbDevice.list_ports(list_all=args.all, as_objects=True)
71
+ for p in ports:
72
+ print(f"{p.device} {p.manufacturer} {p.description} {p.serial_number}")
73
+ if len(ports) == 0:
74
+ print(
75
+ "No device found. Make sure the nuotec-sent2usb firmware is flashed and running."
76
+ )
77
+ except Exception as e: # noqa: BLE001
78
+ print(f"ERROR: {e!s}")
79
+ sys.exit(1)
80
+
81
+ # --update
82
+ if args.update:
83
+ try:
84
+ with resources.path(*FIRMWARE_RESOURCE) as p, open(p, "rb") as f:
85
+ content = f.read()
86
+ pattern, major, minor, patch, candidate, name_len, desc_len = unpack(
87
+ "<IBBBBII", content[0x100:0x110]
88
+ )
89
+ if pattern != 0x6E758001:
90
+ raise ValueError("Invalid firmware binary (magic pattern not found).")
91
+ latest_version = Sent2UsbDeviceVersion(
92
+ major,
93
+ minor,
94
+ patch,
95
+ candidate,
96
+ content[0x114 + name_len : 0x114 + name_len + desc_len],
97
+ )
98
+ print(f"Latest version: {latest_version}")
99
+ with Sent2UsbDevice(args.port) as device:
100
+ version = device.get_version()
101
+ print(f"Device version: {version}")
102
+ if latest_version.build_id == version.build_id:
103
+ print("Firmware is up-to-date.")
104
+ else:
105
+ args.bootloader = True
106
+ args.flash = True
107
+ except Exception as e: # noqa: BLE001
108
+ print(f"ERROR: {e!s}")
109
+ sys.exit(1)
110
+
111
+ # --bootloader
112
+ if args.bootloader:
113
+ try:
114
+ print("Enter bootloader...")
115
+ with Sent2UsbDevice(args.port) as device:
116
+ device.enter_bootloader()
117
+ print("Waiting for reset...")
118
+ time.sleep(4.0)
119
+ except Exception as e: # noqa: BLE001
120
+ print(f"ERROR: {e!s}")
121
+ sys.exit(1)
122
+
123
+ # --flash-option-bytes
124
+ if args.flash_option_bytes:
125
+ try:
126
+ with resources.path(*OPTIONBYTES_RESOURCE) as p:
127
+ command = [
128
+ "dfu-util",
129
+ "-a1",
130
+ "-s0x1ffff800",
131
+ f"-D{p}",
132
+ ]
133
+ print(f"Run command: {' '.join(command)}")
134
+ subprocess.check_call(command, stdout=sys.stdout, stderr=sys.stderr)
135
+ time.sleep(2.0)
136
+ except Exception as e: # noqa: BLE001
137
+ print(f"ERROR: {e!s}")
138
+ sys.exit(1)
139
+
140
+ # --flash
141
+ if args.flash:
142
+ try:
143
+ with resources.path(*FIRMWARE_RESOURCE) as p:
144
+ command = [
145
+ "dfu-util",
146
+ "-a0",
147
+ "-s0x08000000:leave",
148
+ f"-D{p}",
149
+ ]
150
+ print(f"Run command: {' '.join(command)}")
151
+ subprocess.check_call(command, stdout=sys.stdout, stderr=sys.stderr)
152
+ time.sleep(2.0)
153
+ except Exception as e: # noqa: BLE001
154
+ print(f"ERROR: {e!s}")
155
+ sys.exit(1)
156
+
157
+ if args.send or args.monitor:
158
+ with Sent2UsbDevice(args.port) as device:
159
+ print(f"Firmware: {device.get_version()}")
160
+ print(f"Tick Time: {args.tick_time} μs")
161
+
162
+ last_tx_time = None
163
+ decoder = SentDecoder(args.tick_time)
164
+ while True:
165
+ if args.send and (
166
+ last_tx_time is None
167
+ or (
168
+ args.interval is not None
169
+ and (time.time() - last_tx_time > args.interval)
170
+ )
171
+ ):
172
+ last_tx_time = time.time()
173
+ nibbles = [int(c, 16) for c in args.send]
174
+ frame = SentFrame(status=nibbles[0], data=nibbles[1:])
175
+ print(f"TX: {frame}")
176
+ device.send_pulses(frame.to_pulses(args.width, args.tick_time))
177
+
178
+ if args.monitor:
179
+ for period, width in device.fetch_pulses():
180
+ frame = decoder.process(period, width)
181
+ if frame is not None:
182
+ print(f"RX: {frame}")
183
+
184
+ if not args.monitor and args.interval is None:
185
+ break
186
+
187
+ time.sleep(0.05)
188
+
189
+
190
+ if __name__ == "__main__":
191
+ main()
@@ -0,0 +1,61 @@
1
+ from .frame import SentFrame
2
+
3
+
4
+ class SentDecoder:
5
+ """Decodes SENT pulse pairs into frames."""
6
+
7
+ def __init__(self, tick_time_us: int, max_deviation: float = 0.25):
8
+ """Initialize the decoder.
9
+
10
+ Args:
11
+ tick_time_us: SENT clock tick time [microseconds].
12
+ max_deviation: Allowed calibration pulse deviation [1].
13
+ """
14
+ self._time_base_ticks = 48 * tick_time_us
15
+ self._min_calib_ticks = 56 * (1 - max_deviation)
16
+ self._max_calib_ticks = 56 * (1 + max_deviation)
17
+ self._calib_factor = None
18
+ self._received_nibbles = []
19
+
20
+ def process(self, period: int, width: int):
21
+ """Process a received pulse pair.
22
+
23
+ Args:
24
+ period: Pulse period in device ticks.
25
+ width: Pulse low time in device ticks.
26
+
27
+ Returns:
28
+ A decoded frame, or `None` until one is complete.
29
+ """
30
+ period_ticks = period / self._time_base_ticks
31
+
32
+ is_complete = width != 0
33
+ is_calib_pulse = (
34
+ is_complete
35
+ and (period_ticks >= self._min_calib_ticks)
36
+ and (period_ticks <= self._max_calib_ticks)
37
+ )
38
+ is_timeout = period_ticks > self._max_calib_ticks
39
+
40
+ result = None
41
+ if (
42
+ self._calib_factor is not None
43
+ and len(self._received_nibbles) > 2
44
+ and (is_calib_pulse or is_timeout)
45
+ ):
46
+ result = SentFrame(
47
+ self._received_nibbles[0],
48
+ self._received_nibbles[1:-1],
49
+ self._received_nibbles[-1],
50
+ )
51
+ self._calib_factor = None
52
+ if is_calib_pulse:
53
+ self._calib_factor = period_ticks / 56
54
+ self._received_nibbles = []
55
+ elif is_complete and self._calib_factor is not None:
56
+ value = round((period_ticks / self._calib_factor) - 12)
57
+ if (value >= 0) and (value <= 15):
58
+ self._received_nibbles.append(value)
59
+ else:
60
+ self._calib_factor = None
61
+ return result
@@ -0,0 +1,192 @@
1
+ from struct import pack, unpack
2
+
3
+ from cobs import cobs
4
+ from serial import Serial
5
+ from serial.tools import list_ports
6
+
7
+ from .device_version import Sent2UsbDeviceVersion
8
+ from .exceptions import Sent2UsbCommunicationError, Sent2UsbDeviceError, Sent2UsbError
9
+
10
+
11
+ class Sent2UsbDevice:
12
+ """Represents a SENT2USB device."""
13
+
14
+ USB_VID = 0x0483
15
+ USB_PID = 0x5740
16
+
17
+ def __init__(self, port=None):
18
+ """Initialize the device (not connecting yet).
19
+
20
+ Args:
21
+ port: Serial port. Detected automatically when omitted.
22
+ """
23
+ self._port = port or Sent2UsbDevice.find_port()
24
+ self._serial = None
25
+
26
+ def __enter__(self):
27
+ """Connect to and return the device."""
28
+ self.connect()
29
+ return self
30
+
31
+ def __exit__(self, exc_type, exc_val, exc_tb):
32
+ """Disconnect from the device."""
33
+ self.disconnect()
34
+
35
+ def connect(self):
36
+ """Open the serial connection."""
37
+ self._serial = Serial(port=self._port, timeout=1)
38
+
39
+ def disconnect(self):
40
+ """Close the serial connection."""
41
+ if self._serial and self._serial.is_open:
42
+ self._serial.close()
43
+ self._serial = None
44
+
45
+ def get_version(self) -> Sent2UsbDeviceVersion:
46
+ """Read the firmware version.
47
+
48
+ Returns:
49
+ The device firmware version.
50
+ """
51
+ rx_data = self._transceive(0x00, min_len=4, max_len=None)
52
+ return Sent2UsbDeviceVersion.from_raw(rx_data)
53
+
54
+ def send_pulses(self, pattern):
55
+ """Send SENT pulse pairs.
56
+
57
+ Args:
58
+ pattern: `(period, low_time)` pairs ending with period `0xFFFF`.
59
+
60
+ Raises:
61
+ ValueError: If the pattern is invalid.
62
+ """
63
+ if len(pattern) < 1:
64
+ raise ValueError("Pattern must contain minimum 1 pulse.")
65
+ if any(x[0] < 2 or x[0] > 0xFFFF for x in pattern):
66
+ raise ValueError("Period must be in range 2..0xFFFF.")
67
+ if any(x[1] < 1 or x[1] >= x[0] for x in pattern):
68
+ raise ValueError("Low-time must be in range 1..period-1.")
69
+ if pattern[-1][0] != 0xFFFF:
70
+ raise ValueError("The last period must be 0xFFFF.")
71
+ data = pack(f">{len(pattern) * 2}H", *[v for pair in pattern for v in pair])
72
+ self._transceive(0x10, data)
73
+
74
+ def fetch_pulses(self):
75
+ """Fetch received SENT pulse pairs.
76
+
77
+ Returns:
78
+ A list of `(period, low_time)` pairs.
79
+ """
80
+ data = self._transceive(0x20, max_len=None)
81
+ values = unpack(f">{len(data) // 4}I", data)
82
+ return [(values[i * 2], values[i * 2 + 1]) for i in range(len(values) // 2)]
83
+
84
+ def enter_bootloader(self):
85
+ """Instruct the device to enter its bootloader."""
86
+ self._transceive(0xFF)
87
+
88
+ def _transceive(self, cmd, data=None, min_len=0, max_len=-1):
89
+ if max_len is not None and max_len < 0:
90
+ max_len = min_len
91
+ self._transmit(cmd, data)
92
+ rx_cmd, rx_result, rx_data = self._receive()
93
+ if rx_cmd != cmd:
94
+ raise Sent2UsbCommunicationError(
95
+ f"Received command '0x{rx_cmd:02X}', expected '0x{cmd:02X}'."
96
+ )
97
+ if rx_result != 0x00:
98
+ raise Sent2UsbDeviceError(
99
+ f"Command '0x{cmd:02X}' failed with error '0x{rx_result:02X}'."
100
+ )
101
+ rx_len = len(rx_data)
102
+ if rx_len < min_len or (max_len is not None and rx_len > max_len):
103
+ raise Sent2UsbCommunicationError(
104
+ f"Received {rx_len} bytes, expected {min_len}..{max_len}."
105
+ )
106
+ return rx_data
107
+
108
+ def _transmit(self, cmd, data=None):
109
+ frame = bytearray()
110
+ frame.append(cmd)
111
+ if data is not None:
112
+ frame.extend(data)
113
+ frame.extend(pack(">H", self._calc_crc(frame)))
114
+ self._serial.reset_input_buffer()
115
+ self._serial.write(cobs.encode(frame) + b"\x00")
116
+
117
+ def _receive(self):
118
+ frame = self._serial.read_until(expected=b"\x00")
119
+ if not frame.endswith(b"\x00"):
120
+ raise TimeoutError()
121
+ frame = cobs.decode(frame[:-1])
122
+ if len(frame) < 4:
123
+ raise Sent2UsbCommunicationError(
124
+ f"Received too short response frame: {frame}"
125
+ )
126
+ cmd = frame[0]
127
+ result = frame[1]
128
+ payload = frame[2:-2]
129
+ crc = unpack(">H", frame[-2:])[0]
130
+ if crc != self._calc_crc(frame[:-2]):
131
+ raise Sent2UsbCommunicationError(f"Received invalid CRC: {frame}")
132
+ return cmd, result, payload
133
+
134
+ def _calc_crc(self, data: bytes) -> int:
135
+ crc = 0xFFFF
136
+ for byte in data:
137
+ crc ^= byte << 8
138
+ for _ in range(8):
139
+ if crc & 0x8000:
140
+ crc = ((crc << 1) ^ 0x1021) & 0xFFFF
141
+ else:
142
+ crc = (crc << 1) & 0xFFFF
143
+ return crc
144
+
145
+ @staticmethod
146
+ def list_ports(list_all: bool = False, as_objects: bool = False):
147
+ """List serial ports.
148
+
149
+ Args:
150
+ list_all: Include ports not matching the SENT2USB identifiers.
151
+ as_objects: Return pyserial port objects instead of device names.
152
+
153
+ Returns:
154
+ Available serial ports.
155
+ """
156
+ ports = list_ports.comports()
157
+ if not list_all:
158
+ # Filter by VID/PID
159
+ ports = [
160
+ p
161
+ for p in ports
162
+ if p.vid == Sent2UsbDevice.USB_VID and p.pid == Sent2UsbDevice.USB_PID
163
+ ]
164
+ # Try to find only devices which match the manufacturer & description.
165
+ # However, this does not seem to work on Windows so this is optional.
166
+ ports = [
167
+ p
168
+ for p in ports
169
+ if "nuotec" in (p.manufacturer or "").lower()
170
+ and "sent2usb" in (p.description or "").lower()
171
+ ] or ports
172
+ return ports if as_objects else [p.device for p in ports]
173
+
174
+ @staticmethod
175
+ def find_port():
176
+ """Find the only connected SENT2USB device.
177
+
178
+ Returns:
179
+ The device serial port.
180
+
181
+ Raises:
182
+ Sent2UsbError: If no device or multiple devices are found.
183
+ """
184
+ ports = Sent2UsbDevice.list_ports()
185
+ if len(ports) == 0:
186
+ raise Sent2UsbError("No device found, check USB connection.")
187
+ elif len(ports) > 1:
188
+ raise Sent2UsbError(
189
+ f"Multiple devices found, specify serial port manually: {ports}"
190
+ )
191
+ else:
192
+ return ports[0]
@@ -0,0 +1,40 @@
1
+ class Sent2UsbDeviceVersion:
2
+ """Represents a SENT2USB firmware version."""
3
+
4
+ def __init__(
5
+ self, major: int, minor: int, patch: int, candidate: int, build_id: str
6
+ ):
7
+ """Initialize a firmware version.
8
+
9
+ Args:
10
+ major: Major version number.
11
+ minor: Minor version number.
12
+ patch: Patch version number.
13
+ candidate: Release candidate number.
14
+ build_id: Firmware build identifier.
15
+ """
16
+ self.major = major
17
+ self.minor = minor
18
+ self.patch = patch
19
+ self.candidate = candidate
20
+ self.build_id = build_id
21
+
22
+ @staticmethod
23
+ def from_raw(data: bytes) -> "Sent2UsbDeviceVersion":
24
+ """Parse a raw version response.
25
+
26
+ Args:
27
+ data: Raw version response bytes.
28
+
29
+ Returns:
30
+ The parsed firmware version.
31
+ """
32
+ return Sent2UsbDeviceVersion(data[0], data[1], data[2], data[3], data[4:])
33
+
34
+ def __str__(self) -> str:
35
+ s = f"{self.major}.{self.minor}.{self.patch}"
36
+ if self.candidate != 0:
37
+ s += f"-rc{self.candidate}"
38
+ if len(self.build_id) > 0:
39
+ s += f" ({self.build_id.hex()})"
40
+ return s
@@ -0,0 +1,10 @@
1
+ class Sent2UsbError(Exception):
2
+ """Base class for errors raised by this driver."""
3
+
4
+
5
+ class Sent2UsbCommunicationError(Sent2UsbError):
6
+ """An invalid or unexpected response received from the device."""
7
+
8
+
9
+ class Sent2UsbDeviceError(Sent2UsbError):
10
+ """A non-success result returned from the device."""
@@ -0,0 +1,65 @@
1
+ class SentFrame:
2
+ """Represents a SENT frame."""
3
+
4
+ def __init__(self, status: int, data: list, crc=None):
5
+ """Initialize a SENT frame.
6
+
7
+ Args:
8
+ status: Status nibble.
9
+ data: Frame data nibbles.
10
+ crc: CRC nibble. Calculated from data when omitted.
11
+ """
12
+ self.status = status
13
+ self.data = data
14
+ self.crc = SentFrame.calc_crc(self.data) if crc is None else crc
15
+
16
+ @property
17
+ def valid(self) -> bool:
18
+ """Whether the frame CRC is valid."""
19
+ return self.crc == SentFrame.calc_crc(self.data)
20
+
21
+ def to_pulses(self, low_time: int, tick_time_us: int):
22
+ """Convert the frame to SENT pulse pairs.
23
+
24
+ Args:
25
+ low_time: Pulse low time [clock ticks].
26
+ tick_time_us: Clock tick time [microseconds].
27
+
28
+ Returns:
29
+ A list of `(period, low_time)` pairs.
30
+ """
31
+ pulses = []
32
+ pulses.append(56) # calibration pulse
33
+ pulses.append(self.status + 12)
34
+ pulses.extend([data + 12 for data in self.data])
35
+ pulses.append(self.crc + 12)
36
+ return [(p * tick_time_us, low_time * tick_time_us) for p in pulses] + [
37
+ (0xFFFF, low_time * tick_time_us)
38
+ ]
39
+
40
+ def __str__(self) -> str:
41
+ data = ", ".join([f"0x{d:X}" for d in self.data])
42
+ s = f"status=0x{self.status:X} data=[{data}] crc=0x{self.crc:X}"
43
+ if not self.valid:
44
+ s += " [CRC ERROR]"
45
+ return s
46
+
47
+ @staticmethod
48
+ def calc_crc(data: bytes) -> int:
49
+ """Calculate the SENT CRC nibble.
50
+
51
+ Args:
52
+ data: Frame data nibbles.
53
+
54
+ Returns:
55
+ The CRC nibble.
56
+ """
57
+ crc = 0x03
58
+ for byte in data:
59
+ crc ^= byte
60
+ for _ in range(4):
61
+ if crc & 0x08:
62
+ crc = ((crc << 1) ^ 0x0D) & 0x0F
63
+ else:
64
+ crc = (crc << 1) & 0x0F
65
+ return crc
@@ -0,0 +1 @@
1
+ �U�������������