riden 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.
- riden-0.1.0/LICENSE +21 -0
- riden-0.1.0/PKG-INFO +168 -0
- riden-0.1.0/README.md +143 -0
- riden-0.1.0/pyproject.toml +65 -0
- riden-0.1.0/pyproject.toml.orig +64 -0
- riden-0.1.0/src/riden/__init__.py +26 -0
- riden-0.1.0/src/riden/bt/__init__.py +5 -0
- riden-0.1.0/src/riden/bt/bt_serial.py +204 -0
- riden-0.1.0/src/riden/bt/protocol_bt.py +48 -0
- riden-0.1.0/src/riden/enums.py +11 -0
- riden-0.1.0/src/riden/field.py +204 -0
- riden-0.1.0/src/riden/model.py +32 -0
- riden-0.1.0/src/riden/py.typed +0 -0
- riden-0.1.0/src/riden/riden.py +252 -0
- riden-0.1.0/src/riden/transport/__init__.py +17 -0
- riden-0.1.0/src/riden/transport/delayed.py +46 -0
- riden-0.1.0/src/riden/transport/errors.py +44 -0
- riden-0.1.0/src/riden/transport/factory.py +54 -0
- riden-0.1.0/src/riden/transport/keepalive.py +160 -0
- riden-0.1.0/src/riden/transport/logging.py +93 -0
- riden-0.1.0/src/riden/transport/modbus.py +107 -0
- riden-0.1.0/src/riden/transport/reconnectable.py +114 -0
- riden-0.1.0/src/riden/transport/transport.py +57 -0
riden-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vyacheslav Mayorov
|
|
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.
|
riden-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: riden
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Synchronous Python client for Riden RD60xx power supplies (USB serial and Bluetooth)
|
|
5
|
+
Keywords: riden,rd6006,rd6012,rd6018,rk6006,rk6012,rk6018,power-supply,modbus,bluetooth,serial
|
|
6
|
+
Author: Vyacheslav Mayorov
|
|
7
|
+
Author-email: Vyacheslav Mayorov <vyacheslav.mayorov@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
16
|
+
Classifier: Topic :: System :: Hardware
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Dist: minimalmodbus>=2.1.1
|
|
19
|
+
Requires-Dist: pyserial>=3.5
|
|
20
|
+
Requires-Python: >=3.14
|
|
21
|
+
Project-URL: Homepage, https://github.com/acc15/riden
|
|
22
|
+
Project-URL: Repository, https://github.com/acc15/riden
|
|
23
|
+
Project-URL: Issues, https://github.com/acc15/riden/issues
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# riden
|
|
27
|
+
|
|
28
|
+
A simple synchronous, single-threaded client for Riden laboratory power
|
|
29
|
+
supplies (RD6006, RD6006P, RD6012, RD6012P, RD6018, RD6024, RK6006).
|
|
30
|
+
|
|
31
|
+
It speaks Modbus RTU over a regular serial port or Bluetooth SPP (RFCOMM) on
|
|
32
|
+
Linux.
|
|
33
|
+
|
|
34
|
+
## Features
|
|
35
|
+
|
|
36
|
+
- synchronous API with no background event loop:
|
|
37
|
+
`with Riden.connect(...) as rd: ...`;
|
|
38
|
+
- Bluetooth on Linux via `bt://` URLs (`AF_BLUETOOTH` + RFCOMM);
|
|
39
|
+
- batched I/O: fields are read and written in one Modbus transaction per
|
|
40
|
+
contiguous register block, not one transaction per field;
|
|
41
|
+
- automatic model and voltage/current scale detection on connect;
|
|
42
|
+
- ready-made transport stack: transaction pacing, keepalive, reconnect, and
|
|
43
|
+
logging.
|
|
44
|
+
|
|
45
|
+
## Requirements
|
|
46
|
+
|
|
47
|
+
- Python 3.14 or newer;
|
|
48
|
+
- Linux with BlueZ for Bluetooth SPP;
|
|
49
|
+
- any OS supported by pyserial for USB serial.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
uv add riden
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
or with pip:
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
pip install riden
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
From source:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
git clone https://github.com/acc15/riden
|
|
67
|
+
cd riden
|
|
68
|
+
uv sync
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Quick start
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from riden import Riden
|
|
75
|
+
|
|
76
|
+
with Riden.connect("bt://E2:F9:C6:24:71:C8?channel=1&timeout=0.5") as rd:
|
|
77
|
+
print(rd.status())
|
|
78
|
+
|
|
79
|
+
# Several fields — a single device transaction.
|
|
80
|
+
v, i, p = rd.read("v", "i", "p").values()
|
|
81
|
+
print(v, i, p)
|
|
82
|
+
|
|
83
|
+
# Several fields — a single write transaction.
|
|
84
|
+
rd.write(v_set=5.0, i_set=0.1, en=True)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
For USB serial, pass the device path:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
with Riden.connect("/dev/ttyUSB0") as rd:
|
|
91
|
+
...
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Field names are case-insensitive, so `"V"` and `"v"` refer to the same field.
|
|
95
|
+
|
|
96
|
+
## Example: voltage sweep + status
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
import time
|
|
100
|
+
|
|
101
|
+
from riden import Riden
|
|
102
|
+
|
|
103
|
+
with Riden.connect("bt://E2:F9:C6:24:71:C8?channel=1&timeout=0.5") as rd:
|
|
104
|
+
try:
|
|
105
|
+
rd.write(v_set=0.0, i_set=0.1, en=True)
|
|
106
|
+
|
|
107
|
+
for voltage in range(0, 60, 5):
|
|
108
|
+
rd.write(v_set=float(voltage))
|
|
109
|
+
time.sleep(2)
|
|
110
|
+
print(rd.status())
|
|
111
|
+
finally:
|
|
112
|
+
rd.write(v_set=0.0, en=False)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Bluetooth on Linux
|
|
116
|
+
|
|
117
|
+
Pair and trust the device first (`bluetoothctl pair/trust`); the RFCOMM
|
|
118
|
+
channel is usually `1`. URL format:
|
|
119
|
+
|
|
120
|
+
```text
|
|
121
|
+
bt://AA:BB:CC:DD:EE:FF?channel=1&timeout=0.5
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`timeout` and `connect_timeout` are specified in seconds as query parameters.
|
|
125
|
+
`BtSerial` is registered as a pyserial URL handler, so `bt://` works anywhere a
|
|
126
|
+
regular serial port is accepted.
|
|
127
|
+
|
|
128
|
+
## Performance
|
|
129
|
+
|
|
130
|
+
`read()` selects the requested fields, computes their overall address range,
|
|
131
|
+
and issues **one** `read_registers` call for the whole range, then decodes the
|
|
132
|
+
raw registers into fields. `write()` encodes and validates all values first,
|
|
133
|
+
merges adjacent fields into contiguous blocks, and sends one transaction per
|
|
134
|
+
block.
|
|
135
|
+
|
|
136
|
+
Therefore:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
# 1 transaction instead of 3
|
|
140
|
+
v, i, p = rd.read("v", "i", "p").values()
|
|
141
|
+
|
|
142
|
+
# 1 transaction instead of 3
|
|
143
|
+
rd.write(v_set=5.0, i_set=0.1, en=True)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Sparse fields are still read in one transaction spanning the whole range
|
|
147
|
+
between them, so grouping nearby fields is preferable.
|
|
148
|
+
|
|
149
|
+
## Development
|
|
150
|
+
|
|
151
|
+
```sh
|
|
152
|
+
uv sync
|
|
153
|
+
uv build # sdist + wheel in dist/
|
|
154
|
+
just check # ty + ruff + pytest
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Scripts in `scripts/` are run through `uv run`:
|
|
158
|
+
|
|
159
|
+
```sh
|
|
160
|
+
uv run scripts/v_sweep.py
|
|
161
|
+
uv run scripts/link_test.py "bt://E2:F9:C6:24:71:C8?channel=1&timeout=0.5"
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`just --list` shows the remaining tasks.
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
MIT — see [LICENSE](https://github.com/acc15/riden/blob/master/LICENSE).
|
riden-0.1.0/README.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# riden
|
|
2
|
+
|
|
3
|
+
A simple synchronous, single-threaded client for Riden laboratory power
|
|
4
|
+
supplies (RD6006, RD6006P, RD6012, RD6012P, RD6018, RD6024, RK6006).
|
|
5
|
+
|
|
6
|
+
It speaks Modbus RTU over a regular serial port or Bluetooth SPP (RFCOMM) on
|
|
7
|
+
Linux.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- synchronous API with no background event loop:
|
|
12
|
+
`with Riden.connect(...) as rd: ...`;
|
|
13
|
+
- Bluetooth on Linux via `bt://` URLs (`AF_BLUETOOTH` + RFCOMM);
|
|
14
|
+
- batched I/O: fields are read and written in one Modbus transaction per
|
|
15
|
+
contiguous register block, not one transaction per field;
|
|
16
|
+
- automatic model and voltage/current scale detection on connect;
|
|
17
|
+
- ready-made transport stack: transaction pacing, keepalive, reconnect, and
|
|
18
|
+
logging.
|
|
19
|
+
|
|
20
|
+
## Requirements
|
|
21
|
+
|
|
22
|
+
- Python 3.14 or newer;
|
|
23
|
+
- Linux with BlueZ for Bluetooth SPP;
|
|
24
|
+
- any OS supported by pyserial for USB serial.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
uv add riden
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
or with pip:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
pip install riden
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
From source:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
git clone https://github.com/acc15/riden
|
|
42
|
+
cd riden
|
|
43
|
+
uv sync
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Quick start
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from riden import Riden
|
|
50
|
+
|
|
51
|
+
with Riden.connect("bt://E2:F9:C6:24:71:C8?channel=1&timeout=0.5") as rd:
|
|
52
|
+
print(rd.status())
|
|
53
|
+
|
|
54
|
+
# Several fields — a single device transaction.
|
|
55
|
+
v, i, p = rd.read("v", "i", "p").values()
|
|
56
|
+
print(v, i, p)
|
|
57
|
+
|
|
58
|
+
# Several fields — a single write transaction.
|
|
59
|
+
rd.write(v_set=5.0, i_set=0.1, en=True)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
For USB serial, pass the device path:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
with Riden.connect("/dev/ttyUSB0") as rd:
|
|
66
|
+
...
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Field names are case-insensitive, so `"V"` and `"v"` refer to the same field.
|
|
70
|
+
|
|
71
|
+
## Example: voltage sweep + status
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
import time
|
|
75
|
+
|
|
76
|
+
from riden import Riden
|
|
77
|
+
|
|
78
|
+
with Riden.connect("bt://E2:F9:C6:24:71:C8?channel=1&timeout=0.5") as rd:
|
|
79
|
+
try:
|
|
80
|
+
rd.write(v_set=0.0, i_set=0.1, en=True)
|
|
81
|
+
|
|
82
|
+
for voltage in range(0, 60, 5):
|
|
83
|
+
rd.write(v_set=float(voltage))
|
|
84
|
+
time.sleep(2)
|
|
85
|
+
print(rd.status())
|
|
86
|
+
finally:
|
|
87
|
+
rd.write(v_set=0.0, en=False)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Bluetooth on Linux
|
|
91
|
+
|
|
92
|
+
Pair and trust the device first (`bluetoothctl pair/trust`); the RFCOMM
|
|
93
|
+
channel is usually `1`. URL format:
|
|
94
|
+
|
|
95
|
+
```text
|
|
96
|
+
bt://AA:BB:CC:DD:EE:FF?channel=1&timeout=0.5
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`timeout` and `connect_timeout` are specified in seconds as query parameters.
|
|
100
|
+
`BtSerial` is registered as a pyserial URL handler, so `bt://` works anywhere a
|
|
101
|
+
regular serial port is accepted.
|
|
102
|
+
|
|
103
|
+
## Performance
|
|
104
|
+
|
|
105
|
+
`read()` selects the requested fields, computes their overall address range,
|
|
106
|
+
and issues **one** `read_registers` call for the whole range, then decodes the
|
|
107
|
+
raw registers into fields. `write()` encodes and validates all values first,
|
|
108
|
+
merges adjacent fields into contiguous blocks, and sends one transaction per
|
|
109
|
+
block.
|
|
110
|
+
|
|
111
|
+
Therefore:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
# 1 transaction instead of 3
|
|
115
|
+
v, i, p = rd.read("v", "i", "p").values()
|
|
116
|
+
|
|
117
|
+
# 1 transaction instead of 3
|
|
118
|
+
rd.write(v_set=5.0, i_set=0.1, en=True)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Sparse fields are still read in one transaction spanning the whole range
|
|
122
|
+
between them, so grouping nearby fields is preferable.
|
|
123
|
+
|
|
124
|
+
## Development
|
|
125
|
+
|
|
126
|
+
```sh
|
|
127
|
+
uv sync
|
|
128
|
+
uv build # sdist + wheel in dist/
|
|
129
|
+
just check # ty + ruff + pytest
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Scripts in `scripts/` are run through `uv run`:
|
|
133
|
+
|
|
134
|
+
```sh
|
|
135
|
+
uv run scripts/v_sweep.py
|
|
136
|
+
uv run scripts/link_test.py "bt://E2:F9:C6:24:71:C8?channel=1&timeout=0.5"
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`just --list` shows the remaining tasks.
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT — see [LICENSE](https://github.com/acc15/riden/blob/master/LICENSE).
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "riden"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Synchronous Python client for Riden RD60xx power supplies (USB serial and Bluetooth)"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.14"
|
|
9
|
+
keywords = [
|
|
10
|
+
"riden",
|
|
11
|
+
"rd6006",
|
|
12
|
+
"rd6012",
|
|
13
|
+
"rd6018",
|
|
14
|
+
"rk6006",
|
|
15
|
+
"rk6012",
|
|
16
|
+
"rk6018",
|
|
17
|
+
"power-supply",
|
|
18
|
+
"modbus",
|
|
19
|
+
"bluetooth",
|
|
20
|
+
"serial",
|
|
21
|
+
]
|
|
22
|
+
classifiers = [
|
|
23
|
+
"Development Status :: 4 - Beta",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Programming Language :: Python :: 3.14",
|
|
28
|
+
"Programming Language :: Python :: 3.15",
|
|
29
|
+
"Topic :: System :: Hardware",
|
|
30
|
+
"Typing :: Typed",
|
|
31
|
+
]
|
|
32
|
+
dependencies = [
|
|
33
|
+
"minimalmodbus>=2.1.1",
|
|
34
|
+
"pyserial>=3.5",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[[project.authors]]
|
|
38
|
+
name = "Vyacheslav Mayorov"
|
|
39
|
+
email = "vyacheslav.mayorov@gmail.com"
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://github.com/acc15/riden"
|
|
43
|
+
Repository = "https://github.com/acc15/riden"
|
|
44
|
+
Issues = "https://github.com/acc15/riden/issues"
|
|
45
|
+
|
|
46
|
+
[tool.ruff]
|
|
47
|
+
src = ["src"]
|
|
48
|
+
target-version = "py314"
|
|
49
|
+
|
|
50
|
+
[tool.ty.rules]
|
|
51
|
+
all = "error"
|
|
52
|
+
|
|
53
|
+
[tool.pytest.ini_options]
|
|
54
|
+
testpaths = ["tests"]
|
|
55
|
+
|
|
56
|
+
[build-system]
|
|
57
|
+
requires = ["uv_build>=0.13.7,<0.14.0"]
|
|
58
|
+
build-backend = "uv_build"
|
|
59
|
+
|
|
60
|
+
[dependency-groups]
|
|
61
|
+
dev = [
|
|
62
|
+
"pytest>=9.2.0",
|
|
63
|
+
"ruff>=0.17.4",
|
|
64
|
+
"ty>=0.0.100",
|
|
65
|
+
]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "riden"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Synchronous Python client for Riden RD60xx power supplies (USB serial and Bluetooth)"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Vyacheslav Mayorov", email = "vyacheslav.mayorov@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
license = "MIT"
|
|
10
|
+
license-files = ["LICENSE"]
|
|
11
|
+
requires-python = ">=3.14"
|
|
12
|
+
keywords = [
|
|
13
|
+
"riden",
|
|
14
|
+
"rd6006",
|
|
15
|
+
"rd6012",
|
|
16
|
+
"rd6018",
|
|
17
|
+
"rk6006",
|
|
18
|
+
"rk6012",
|
|
19
|
+
"rk6018",
|
|
20
|
+
"power-supply",
|
|
21
|
+
"modbus",
|
|
22
|
+
"bluetooth",
|
|
23
|
+
"serial",
|
|
24
|
+
]
|
|
25
|
+
classifiers = [
|
|
26
|
+
"Development Status :: 4 - Beta",
|
|
27
|
+
"Intended Audience :: Developers",
|
|
28
|
+
"Operating System :: OS Independent",
|
|
29
|
+
"Programming Language :: Python :: 3",
|
|
30
|
+
"Programming Language :: Python :: 3.14",
|
|
31
|
+
"Programming Language :: Python :: 3.15",
|
|
32
|
+
"Topic :: System :: Hardware",
|
|
33
|
+
"Typing :: Typed",
|
|
34
|
+
]
|
|
35
|
+
dependencies = [
|
|
36
|
+
"minimalmodbus>=2.1.1",
|
|
37
|
+
"pyserial>=3.5",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://github.com/acc15/riden"
|
|
42
|
+
Repository = "https://github.com/acc15/riden"
|
|
43
|
+
Issues = "https://github.com/acc15/riden/issues"
|
|
44
|
+
|
|
45
|
+
[tool.ruff]
|
|
46
|
+
src = ["src"]
|
|
47
|
+
target-version = "py314"
|
|
48
|
+
|
|
49
|
+
[tool.ty.rules]
|
|
50
|
+
all = "error"
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
testpaths = ["tests"]
|
|
54
|
+
|
|
55
|
+
[build-system]
|
|
56
|
+
requires = ["uv_build>=0.13.7,<0.14.0"]
|
|
57
|
+
build-backend = "uv_build"
|
|
58
|
+
|
|
59
|
+
[dependency-groups]
|
|
60
|
+
dev = [
|
|
61
|
+
"pytest>=9.2.0",
|
|
62
|
+
"ruff>=0.17.4",
|
|
63
|
+
"ty>=0.0.100",
|
|
64
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import serial
|
|
2
|
+
|
|
3
|
+
from .bt import BtSerial
|
|
4
|
+
from .enums import Mode, Protection
|
|
5
|
+
from .field import Field
|
|
6
|
+
from .riden import Riden
|
|
7
|
+
from .transport import Delayed, KeepAlive, Logging, Modbus, Reconnectable, Transport
|
|
8
|
+
|
|
9
|
+
HANDLER_PACKAGE = "riden.bt"
|
|
10
|
+
|
|
11
|
+
if HANDLER_PACKAGE not in serial.protocol_handler_packages:
|
|
12
|
+
serial.protocol_handler_packages.append(HANDLER_PACKAGE)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"BtSerial",
|
|
16
|
+
"Delayed",
|
|
17
|
+
"Field",
|
|
18
|
+
"KeepAlive",
|
|
19
|
+
"Logging",
|
|
20
|
+
"Modbus",
|
|
21
|
+
"Mode",
|
|
22
|
+
"Protection",
|
|
23
|
+
"Reconnectable",
|
|
24
|
+
"Riden",
|
|
25
|
+
"Transport",
|
|
26
|
+
]
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Bluetooth SPP transport shaped like a pyserial port.
|
|
2
|
+
|
|
3
|
+
BtSerial.port is always a Bluetooth MAC, just as serial.Serial.port is a
|
|
4
|
+
device path. URL parsing lives only in serial_urlhandler/protocol_bt.py,
|
|
5
|
+
which pyserial uses via serial_for_url():
|
|
6
|
+
|
|
7
|
+
serial.serial_for_url("bt://E2:F9:C6:24:71:C8?channel=1&timeout=0.5")
|
|
8
|
+
|
|
9
|
+
Direct construction uses the MAC:
|
|
10
|
+
|
|
11
|
+
BtSerial("E2:F9:C6:24:71:C8", channel=1, timeout=0.5)
|
|
12
|
+
|
|
13
|
+
The connect timeout is an internal constant (CONNECT_TIMEOUT), not part of the
|
|
14
|
+
serial interface. For a configured but closed object use
|
|
15
|
+
serial_for_url(..., do_not_open=True), or BtSerial(None) and set .port before
|
|
16
|
+
open().
|
|
17
|
+
"""
|
|
18
|
+
import contextlib
|
|
19
|
+
import re
|
|
20
|
+
import select
|
|
21
|
+
import socket
|
|
22
|
+
import time
|
|
23
|
+
from typing import override
|
|
24
|
+
|
|
25
|
+
import serial
|
|
26
|
+
|
|
27
|
+
# Fixed for minimalmodbus; RFCOMM has no real baudrate.
|
|
28
|
+
BAUDRATE = 115200
|
|
29
|
+
|
|
30
|
+
_MAC_RE = re.compile(r"^(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$")
|
|
31
|
+
|
|
32
|
+
def parse_mac(value: str) -> str:
|
|
33
|
+
value = value.strip()
|
|
34
|
+
if not _MAC_RE.match(value):
|
|
35
|
+
raise ValueError(f"invalid Bluetooth MAC: {value!r}")
|
|
36
|
+
return value.replace("-", ":").upper()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BtSerial(contextlib.AbstractContextManager["BtSerial"]):
|
|
40
|
+
|
|
41
|
+
def __init__(self,
|
|
42
|
+
port: str | None = None,
|
|
43
|
+
channel: int = 1,
|
|
44
|
+
timeout: float | None = 0.5,
|
|
45
|
+
connect_timeout: float | None = 10
|
|
46
|
+
):
|
|
47
|
+
self.port = port
|
|
48
|
+
self.channel = channel
|
|
49
|
+
self.sock: socket.socket | None = None
|
|
50
|
+
self._buf = bytearray()
|
|
51
|
+
self.timeout = timeout
|
|
52
|
+
self.connect_timeout = connect_timeout
|
|
53
|
+
if port is not None:
|
|
54
|
+
self.open()
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def timeout(self) -> float | None:
|
|
58
|
+
return self._timeout
|
|
59
|
+
|
|
60
|
+
@timeout.setter
|
|
61
|
+
def timeout(self, value: float | None) -> None:
|
|
62
|
+
if value is not None:
|
|
63
|
+
try:
|
|
64
|
+
value + 1 # numeric check, like pyserial
|
|
65
|
+
except TypeError:
|
|
66
|
+
raise ValueError(f"Not a valid timeout: {value!r}") from None
|
|
67
|
+
if value < 0:
|
|
68
|
+
raise ValueError(f"Not a valid timeout: {value!r}")
|
|
69
|
+
self._timeout = value
|
|
70
|
+
if self.sock is not None:
|
|
71
|
+
self.sock.settimeout(value)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def baudrate(self) -> int:
|
|
75
|
+
# minimalmodbus reads this to calculate the 3.5-character silent
|
|
76
|
+
# period; RFCOMM itself has no baudrate setting.
|
|
77
|
+
return BAUDRATE
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def is_open(self) -> bool:
|
|
81
|
+
return self.sock is not None
|
|
82
|
+
|
|
83
|
+
# -- pyserial-compatible lifecycle ------------------------------------
|
|
84
|
+
def ensure_opened(self) -> socket.socket:
|
|
85
|
+
sock = self.sock
|
|
86
|
+
if sock is None:
|
|
87
|
+
raise serial.SerialException("Bluetooth socket is closed")
|
|
88
|
+
return sock
|
|
89
|
+
|
|
90
|
+
def open(self) -> None:
|
|
91
|
+
if self.is_open:
|
|
92
|
+
raise serial.SerialException("Port is already open.")
|
|
93
|
+
if self.port is None:
|
|
94
|
+
raise serial.SerialException("Port must be configured before it can be used.")
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
mac = parse_mac(str(self.port))
|
|
98
|
+
except ValueError as exc:
|
|
99
|
+
raise serial.SerialException(str(exc)) from exc
|
|
100
|
+
channel = int(self.channel)
|
|
101
|
+
if not 1 <= channel <= 30:
|
|
102
|
+
raise ValueError(f"Bluetooth channel must be 1..30, got {channel}")
|
|
103
|
+
|
|
104
|
+
sock = socket.socket(
|
|
105
|
+
socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM
|
|
106
|
+
)
|
|
107
|
+
try:
|
|
108
|
+
sock.settimeout(self.connect_timeout)
|
|
109
|
+
sock.connect((mac, channel))
|
|
110
|
+
sock.settimeout(self.timeout)
|
|
111
|
+
except Exception:
|
|
112
|
+
sock.close()
|
|
113
|
+
raise
|
|
114
|
+
|
|
115
|
+
self.sock = sock
|
|
116
|
+
self.mac = mac
|
|
117
|
+
self.channel = channel
|
|
118
|
+
self._buf.clear()
|
|
119
|
+
|
|
120
|
+
def close(self) -> None:
|
|
121
|
+
sock = self.sock
|
|
122
|
+
self.sock = None
|
|
123
|
+
if sock is not None:
|
|
124
|
+
sock.close()
|
|
125
|
+
|
|
126
|
+
def __del__(self):
|
|
127
|
+
with contextlib.suppress(Exception):
|
|
128
|
+
self.close()
|
|
129
|
+
|
|
130
|
+
@override
|
|
131
|
+
def __exit__(self, *_):
|
|
132
|
+
self.close()
|
|
133
|
+
|
|
134
|
+
# -- pyserial-like I/O ------------------------------------------------
|
|
135
|
+
def write(self, data: bytes) -> int:
|
|
136
|
+
sock = self.ensure_opened()
|
|
137
|
+
sock.sendall(bytes(data))
|
|
138
|
+
return len(data)
|
|
139
|
+
|
|
140
|
+
def read(self, size: int = 1) -> bytes:
|
|
141
|
+
sock = self.ensure_opened()
|
|
142
|
+
if size <= 0:
|
|
143
|
+
return b""
|
|
144
|
+
|
|
145
|
+
if self.timeout is None:
|
|
146
|
+
# pyserial semantics: None means blocking until size bytes or EOF.
|
|
147
|
+
while len(self._buf) < size:
|
|
148
|
+
chunk = sock.recv(size - len(self._buf))
|
|
149
|
+
if not chunk:
|
|
150
|
+
raise serial.SerialException("Bluetooth peer closed the link")
|
|
151
|
+
self._buf.extend(chunk)
|
|
152
|
+
out = bytes(self._buf[:size])
|
|
153
|
+
del self._buf[:size]
|
|
154
|
+
return out
|
|
155
|
+
|
|
156
|
+
if self.timeout == 0:
|
|
157
|
+
# pyserial semantics: return immediately with what is available.
|
|
158
|
+
try:
|
|
159
|
+
chunk = sock.recv(size - len(self._buf), socket.MSG_DONTWAIT)
|
|
160
|
+
if chunk:
|
|
161
|
+
self._buf.extend(chunk)
|
|
162
|
+
except BlockingIOError:
|
|
163
|
+
pass
|
|
164
|
+
except OSError as exc:
|
|
165
|
+
raise serial.SerialException(str(exc)) from exc
|
|
166
|
+
out = bytes(self._buf[:size])
|
|
167
|
+
del self._buf[:size]
|
|
168
|
+
return out
|
|
169
|
+
|
|
170
|
+
# timeout > 0: the timeout is for the whole call (pyserial semantics).
|
|
171
|
+
# select() waits for readability with the time left, so the socket
|
|
172
|
+
# timeout is never touched and recv() cannot block past the deadline.
|
|
173
|
+
deadline = time.monotonic() + self.timeout
|
|
174
|
+
while len(self._buf) < size:
|
|
175
|
+
remaining = deadline - time.monotonic()
|
|
176
|
+
if remaining <= 0:
|
|
177
|
+
break
|
|
178
|
+
if not select.select([sock], [], [], remaining)[0]:
|
|
179
|
+
break
|
|
180
|
+
chunk = sock.recv(size - len(self._buf))
|
|
181
|
+
if not chunk:
|
|
182
|
+
raise serial.SerialException("Bluetooth peer closed the link")
|
|
183
|
+
self._buf.extend(chunk)
|
|
184
|
+
|
|
185
|
+
out = bytes(self._buf[:size])
|
|
186
|
+
del self._buf[:size]
|
|
187
|
+
return out
|
|
188
|
+
|
|
189
|
+
def reset_input_buffer(self) -> None:
|
|
190
|
+
sock = self.ensure_opened()
|
|
191
|
+
self._buf.clear()
|
|
192
|
+
# Zero-timeout poll: recv() after a readable select() returns at once,
|
|
193
|
+
# so the socket timeout is never touched.
|
|
194
|
+
while select.select([sock], [], [], 0)[0]:
|
|
195
|
+
if not sock.recv(4096):
|
|
196
|
+
break
|
|
197
|
+
|
|
198
|
+
def reset_output_buffer(self) -> None:
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
def flush(self) -> None:
|
|
202
|
+
pass
|
|
203
|
+
|
|
204
|
+
|