solarflow-ble 0.1.1__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.
- solarflow_ble-0.1.1/PKG-INFO +227 -0
- solarflow_ble-0.1.1/README.md +213 -0
- solarflow_ble-0.1.1/pyproject.toml +92 -0
- solarflow_ble-0.1.1/pyproject.toml.orig +83 -0
- solarflow_ble-0.1.1/src/solarflow_ble/__init__.py +18 -0
- solarflow_ble-0.1.1/src/solarflow_ble/client.py +333 -0
- solarflow_ble-0.1.1/src/solarflow_ble/const.py +9 -0
- solarflow_ble-0.1.1/src/solarflow_ble/exceptions.py +33 -0
- solarflow_ble-0.1.1/src/solarflow_ble/models.py +194 -0
- solarflow_ble-0.1.1/src/solarflow_ble/protocol.py +51 -0
- solarflow_ble-0.1.1/src/solarflow_ble/py.typed +0 -0
- solarflow_ble-0.1.1/src/solarflow_ble/transport.py +106 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: solarflow-ble
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: Daan Vervacke
|
|
6
|
+
Author-email: Daan Vervacke <daan.vervacke@proton.me>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Requires-Dist: bleak>=1.1.1
|
|
9
|
+
Requires-Dist: bleak-esphome>=4.1.0
|
|
10
|
+
Requires-Dist: bleak-retry-connector>=4.7.1
|
|
11
|
+
Requires-Dist: habluetooth>=7.0.0
|
|
12
|
+
Requires-Python: >=3.14
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# solarflow-ble
|
|
16
|
+
|
|
17
|
+
Unofficial asynchronous Python library for communicating with Zendure
|
|
18
|
+
SolarFlow controllers over Bluetooth Low Energy.
|
|
19
|
+
|
|
20
|
+
The protocol implementation is based on
|
|
21
|
+
[esphome-solarflow-ble](https://github.com/krumpholz/esphome-solarflow-ble).
|
|
22
|
+
The library has been tested with a SolarFlow 2400AC through a Home Assistant
|
|
23
|
+
Connect AUX-2 acting as a Bluetooth proxy. Traffic probing and debugging use
|
|
24
|
+
[`bleak-esphome`](https://github.com/Bluetooth-Devices/bleak-esphome).
|
|
25
|
+
|
|
26
|
+
All protocol credits and reverse-engineering efforts belong to the
|
|
27
|
+
[esphome-solarflow-ble project](https://github.com/krumpholz/esphome-solarflow-ble).
|
|
28
|
+
This library builds on that work in Python.
|
|
29
|
+
|
|
30
|
+
Requires Python >= 3.14.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uv add solarflow-ble
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
`SolarFlowClient` uses an injected transport, so applications can choose their
|
|
41
|
+
own Bluetooth adapter and tests can use a fake transport without Bluetooth
|
|
42
|
+
hardware.
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import asyncio
|
|
46
|
+
|
|
47
|
+
from bleak.backends.device import BLEDevice
|
|
48
|
+
|
|
49
|
+
from solarflow_ble import BleakTransport, SolarFlowClient
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def main() -> None:
|
|
53
|
+
# Replace these values with a device discovered by your BLE adapter.
|
|
54
|
+
device = BLEDevice("AA:BB:CC:DD:EE:FF", "SolarFlow", {})
|
|
55
|
+
client = SolarFlowClient(BleakTransport(device))
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
await client.connect()
|
|
59
|
+
print(client.state)
|
|
60
|
+
print(client.status)
|
|
61
|
+
finally:
|
|
62
|
+
await client.disconnect()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
asyncio.run(main())
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The client connects, completes the BLESPP handshake, reads the initial
|
|
69
|
+
`getAll` state, and keeps the session updated. Always call `disconnect()` when
|
|
70
|
+
the session ends.
|
|
71
|
+
|
|
72
|
+
Control methods are disabled by default. Enable them explicitly when the
|
|
73
|
+
application is intended to change device settings:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
client = SolarFlowClient(BleakTransport(device), allow_control=True)
|
|
77
|
+
try:
|
|
78
|
+
await client.connect()
|
|
79
|
+
await client.set_output_limit(800)
|
|
80
|
+
finally:
|
|
81
|
+
await client.disconnect()
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Probe
|
|
85
|
+
|
|
86
|
+
The developer-only probe captures SolarFlow GATT traffic through an ESPHome
|
|
87
|
+
Bluetooth proxy (e.g. the Home Assistant Connect AUX-2).
|
|
88
|
+
It does not send device-setting writes by default. It sends the BLESPP
|
|
89
|
+
handshake, `getInfo`, and `getAll` protocol requests.
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
# Discover a SolarFlow device and capture its traffic.
|
|
93
|
+
uv run scripts/probe_solarflow.py \
|
|
94
|
+
--proxy "192.168.1.157" \
|
|
95
|
+
--noise-psk "your-esphome-noise-psk" \
|
|
96
|
+
--output /tmp/solarflow.jsonl
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
List advertisements without connecting to a SolarFlow device:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
uv run scripts/probe_solarflow.py \
|
|
103
|
+
--proxy "192.168.1.157" \
|
|
104
|
+
--noise-psk "your-esphome-noise-psk" \
|
|
105
|
+
--list-advertisements \
|
|
106
|
+
--scan-seconds 30
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Advertisement addresses and parsed SolarFlow identifiers are redacted by
|
|
110
|
+
default. Add `--show-identities` when selecting values for `--address` or
|
|
111
|
+
`--identifier`; capture files remain redacted.
|
|
112
|
+
|
|
113
|
+
Target a specific SolarFlow device by Bluetooth address:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
uv run scripts/probe_solarflow.py \
|
|
117
|
+
--proxy "192.168.1.157" \
|
|
118
|
+
--noise-psk "your-esphome-noise-psk" \
|
|
119
|
+
--address "AA:BB:CC:DD:EE:FF" \
|
|
120
|
+
--scan-seconds 60 \
|
|
121
|
+
--capture-seconds 30 \
|
|
122
|
+
--output /tmp/solarflow-target.jsonl
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Alternatively, target a device by its SolarFlow manufacturer-advertisement
|
|
126
|
+
identifier:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
uv run scripts/probe_solarflow.py \
|
|
130
|
+
--proxy "192.168.1.157" \
|
|
131
|
+
--noise-psk "your-esphome-noise-psk" \
|
|
132
|
+
--identifier "DEVICE_IDENTIFIER" \
|
|
133
|
+
--scan-seconds 60 \
|
|
134
|
+
--capture-seconds 30 \
|
|
135
|
+
--output /tmp/solarflow-target.jsonl
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Connect to a device and capture notifications without sending the BLESPP
|
|
139
|
+
handshake or the initial `getInfo` and `getAll` requests:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
uv run scripts/probe_solarflow.py \
|
|
143
|
+
--proxy "192.168.1.157" \
|
|
144
|
+
--noise-psk "your-esphome-noise-psk" \
|
|
145
|
+
--address "AA:BB:CC:DD:EE:FF" \
|
|
146
|
+
--no-handshake \
|
|
147
|
+
--capture-seconds 30 \
|
|
148
|
+
--output /tmp/solarflow-passive.jsonl
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Development
|
|
152
|
+
|
|
153
|
+
This project uses [uv](https://docs.astral.sh/uv/) and targets Python 3.14+.
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
uv sync
|
|
157
|
+
uv run python -m scripts.check
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The development gate stops at the first failure in this order: format check,
|
|
161
|
+
Ruff lint, mypy, branch-covered tests, coverage report, then package build.
|
|
162
|
+
|
|
163
|
+
Run one test file or test:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
uv run pytest tests/test_solarflow.py
|
|
167
|
+
uv run pytest tests/test_solarflow.py -k connect_handshake
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Standalone library client test
|
|
171
|
+
|
|
172
|
+
Use the standalone diagnostic to exercise `SolarFlowClient` through an
|
|
173
|
+
ESPHome Bluetooth proxy. It discovers exactly one target by address or
|
|
174
|
+
SolarFlow advertisement identifier and is read-only unless controls are
|
|
175
|
+
explicitly confirmed.
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
uv run scripts/test_solarflow_client.py \
|
|
179
|
+
--proxy "192.168.1.157" \
|
|
180
|
+
--noise-psk "your-esphome-noise-psk" \
|
|
181
|
+
--identifier "DEVICE_IDENTIFIER" \
|
|
182
|
+
--duration 30 \
|
|
183
|
+
--output /tmp/solarflow-library-test.jsonl
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
The script prints device identities and decoded state to stdout for local
|
|
187
|
+
diagnostics. The optional JSONL file is always recursively redacted. Controls
|
|
188
|
+
require both `--controls` and `--confirm-controls`; they also require explicit
|
|
189
|
+
`--min-soc` and `--soc` values because those original wire values are not
|
|
190
|
+
available safely for restoration.
|
|
191
|
+
|
|
192
|
+
The connection settings can be kept in the ignored local config file
|
|
193
|
+
`scripts/test_solarflow_client.local.json`. The file may contain `proxy`,
|
|
194
|
+
`noise_psk`, and exactly one of `address` or `identifier`:
|
|
195
|
+
|
|
196
|
+
```json
|
|
197
|
+
{
|
|
198
|
+
"proxy": "192.168.1.157",
|
|
199
|
+
"noise_psk": "your-esphome-noise-psk",
|
|
200
|
+
"identifier": "DEVICE_IDENTIFIER"
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Run the diagnostic with the default local file:
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
uv run scripts/test_solarflow_client.py --duration 30
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Use `--config path/to/config.json` for another local file. Command-line values
|
|
211
|
+
override values from the config file, so individual settings can be replaced
|
|
212
|
+
without editing it:
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
uv run scripts/test_solarflow_client.py \
|
|
216
|
+
--config scripts/test_solarflow_client.local.json \
|
|
217
|
+
--identifier "OTHER_DEVICE_IDENTIFIER"
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Do not commit this file or paste a real `noise_psk` into documentation,
|
|
221
|
+
fixtures, logs, or shell history. The default local filename is ignored by
|
|
222
|
+
Git; use a file with equivalent local-only handling when choosing another
|
|
223
|
+
config path. The script never prints or writes `noise_psk`.
|
|
224
|
+
|
|
225
|
+
## License
|
|
226
|
+
|
|
227
|
+
This project is licensed under the MIT License. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# solarflow-ble
|
|
2
|
+
|
|
3
|
+
Unofficial asynchronous Python library for communicating with Zendure
|
|
4
|
+
SolarFlow controllers over Bluetooth Low Energy.
|
|
5
|
+
|
|
6
|
+
The protocol implementation is based on
|
|
7
|
+
[esphome-solarflow-ble](https://github.com/krumpholz/esphome-solarflow-ble).
|
|
8
|
+
The library has been tested with a SolarFlow 2400AC through a Home Assistant
|
|
9
|
+
Connect AUX-2 acting as a Bluetooth proxy. Traffic probing and debugging use
|
|
10
|
+
[`bleak-esphome`](https://github.com/Bluetooth-Devices/bleak-esphome).
|
|
11
|
+
|
|
12
|
+
All protocol credits and reverse-engineering efforts belong to the
|
|
13
|
+
[esphome-solarflow-ble project](https://github.com/krumpholz/esphome-solarflow-ble).
|
|
14
|
+
This library builds on that work in Python.
|
|
15
|
+
|
|
16
|
+
Requires Python >= 3.14.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
uv add solarflow-ble
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
`SolarFlowClient` uses an injected transport, so applications can choose their
|
|
27
|
+
own Bluetooth adapter and tests can use a fake transport without Bluetooth
|
|
28
|
+
hardware.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import asyncio
|
|
32
|
+
|
|
33
|
+
from bleak.backends.device import BLEDevice
|
|
34
|
+
|
|
35
|
+
from solarflow_ble import BleakTransport, SolarFlowClient
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def main() -> None:
|
|
39
|
+
# Replace these values with a device discovered by your BLE adapter.
|
|
40
|
+
device = BLEDevice("AA:BB:CC:DD:EE:FF", "SolarFlow", {})
|
|
41
|
+
client = SolarFlowClient(BleakTransport(device))
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
await client.connect()
|
|
45
|
+
print(client.state)
|
|
46
|
+
print(client.status)
|
|
47
|
+
finally:
|
|
48
|
+
await client.disconnect()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
asyncio.run(main())
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The client connects, completes the BLESPP handshake, reads the initial
|
|
55
|
+
`getAll` state, and keeps the session updated. Always call `disconnect()` when
|
|
56
|
+
the session ends.
|
|
57
|
+
|
|
58
|
+
Control methods are disabled by default. Enable them explicitly when the
|
|
59
|
+
application is intended to change device settings:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
client = SolarFlowClient(BleakTransport(device), allow_control=True)
|
|
63
|
+
try:
|
|
64
|
+
await client.connect()
|
|
65
|
+
await client.set_output_limit(800)
|
|
66
|
+
finally:
|
|
67
|
+
await client.disconnect()
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Probe
|
|
71
|
+
|
|
72
|
+
The developer-only probe captures SolarFlow GATT traffic through an ESPHome
|
|
73
|
+
Bluetooth proxy (e.g. the Home Assistant Connect AUX-2).
|
|
74
|
+
It does not send device-setting writes by default. It sends the BLESPP
|
|
75
|
+
handshake, `getInfo`, and `getAll` protocol requests.
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# Discover a SolarFlow device and capture its traffic.
|
|
79
|
+
uv run scripts/probe_solarflow.py \
|
|
80
|
+
--proxy "192.168.1.157" \
|
|
81
|
+
--noise-psk "your-esphome-noise-psk" \
|
|
82
|
+
--output /tmp/solarflow.jsonl
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
List advertisements without connecting to a SolarFlow device:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
uv run scripts/probe_solarflow.py \
|
|
89
|
+
--proxy "192.168.1.157" \
|
|
90
|
+
--noise-psk "your-esphome-noise-psk" \
|
|
91
|
+
--list-advertisements \
|
|
92
|
+
--scan-seconds 30
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Advertisement addresses and parsed SolarFlow identifiers are redacted by
|
|
96
|
+
default. Add `--show-identities` when selecting values for `--address` or
|
|
97
|
+
`--identifier`; capture files remain redacted.
|
|
98
|
+
|
|
99
|
+
Target a specific SolarFlow device by Bluetooth address:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
uv run scripts/probe_solarflow.py \
|
|
103
|
+
--proxy "192.168.1.157" \
|
|
104
|
+
--noise-psk "your-esphome-noise-psk" \
|
|
105
|
+
--address "AA:BB:CC:DD:EE:FF" \
|
|
106
|
+
--scan-seconds 60 \
|
|
107
|
+
--capture-seconds 30 \
|
|
108
|
+
--output /tmp/solarflow-target.jsonl
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Alternatively, target a device by its SolarFlow manufacturer-advertisement
|
|
112
|
+
identifier:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
uv run scripts/probe_solarflow.py \
|
|
116
|
+
--proxy "192.168.1.157" \
|
|
117
|
+
--noise-psk "your-esphome-noise-psk" \
|
|
118
|
+
--identifier "DEVICE_IDENTIFIER" \
|
|
119
|
+
--scan-seconds 60 \
|
|
120
|
+
--capture-seconds 30 \
|
|
121
|
+
--output /tmp/solarflow-target.jsonl
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Connect to a device and capture notifications without sending the BLESPP
|
|
125
|
+
handshake or the initial `getInfo` and `getAll` requests:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
uv run scripts/probe_solarflow.py \
|
|
129
|
+
--proxy "192.168.1.157" \
|
|
130
|
+
--noise-psk "your-esphome-noise-psk" \
|
|
131
|
+
--address "AA:BB:CC:DD:EE:FF" \
|
|
132
|
+
--no-handshake \
|
|
133
|
+
--capture-seconds 30 \
|
|
134
|
+
--output /tmp/solarflow-passive.jsonl
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Development
|
|
138
|
+
|
|
139
|
+
This project uses [uv](https://docs.astral.sh/uv/) and targets Python 3.14+.
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
uv sync
|
|
143
|
+
uv run python -m scripts.check
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The development gate stops at the first failure in this order: format check,
|
|
147
|
+
Ruff lint, mypy, branch-covered tests, coverage report, then package build.
|
|
148
|
+
|
|
149
|
+
Run one test file or test:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
uv run pytest tests/test_solarflow.py
|
|
153
|
+
uv run pytest tests/test_solarflow.py -k connect_handshake
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Standalone library client test
|
|
157
|
+
|
|
158
|
+
Use the standalone diagnostic to exercise `SolarFlowClient` through an
|
|
159
|
+
ESPHome Bluetooth proxy. It discovers exactly one target by address or
|
|
160
|
+
SolarFlow advertisement identifier and is read-only unless controls are
|
|
161
|
+
explicitly confirmed.
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
uv run scripts/test_solarflow_client.py \
|
|
165
|
+
--proxy "192.168.1.157" \
|
|
166
|
+
--noise-psk "your-esphome-noise-psk" \
|
|
167
|
+
--identifier "DEVICE_IDENTIFIER" \
|
|
168
|
+
--duration 30 \
|
|
169
|
+
--output /tmp/solarflow-library-test.jsonl
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The script prints device identities and decoded state to stdout for local
|
|
173
|
+
diagnostics. The optional JSONL file is always recursively redacted. Controls
|
|
174
|
+
require both `--controls` and `--confirm-controls`; they also require explicit
|
|
175
|
+
`--min-soc` and `--soc` values because those original wire values are not
|
|
176
|
+
available safely for restoration.
|
|
177
|
+
|
|
178
|
+
The connection settings can be kept in the ignored local config file
|
|
179
|
+
`scripts/test_solarflow_client.local.json`. The file may contain `proxy`,
|
|
180
|
+
`noise_psk`, and exactly one of `address` or `identifier`:
|
|
181
|
+
|
|
182
|
+
```json
|
|
183
|
+
{
|
|
184
|
+
"proxy": "192.168.1.157",
|
|
185
|
+
"noise_psk": "your-esphome-noise-psk",
|
|
186
|
+
"identifier": "DEVICE_IDENTIFIER"
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Run the diagnostic with the default local file:
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
uv run scripts/test_solarflow_client.py --duration 30
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Use `--config path/to/config.json` for another local file. Command-line values
|
|
197
|
+
override values from the config file, so individual settings can be replaced
|
|
198
|
+
without editing it:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
uv run scripts/test_solarflow_client.py \
|
|
202
|
+
--config scripts/test_solarflow_client.local.json \
|
|
203
|
+
--identifier "OTHER_DEVICE_IDENTIFIER"
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Do not commit this file or paste a real `noise_psk` into documentation,
|
|
207
|
+
fixtures, logs, or shell history. The default local filename is ignored by
|
|
208
|
+
Git; use a file with equivalent local-only handling when choosing another
|
|
209
|
+
config path. The script never prints or writes `noise_psk`.
|
|
210
|
+
|
|
211
|
+
## License
|
|
212
|
+
|
|
213
|
+
This project is licensed under the MIT License. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "solarflow-ble"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.14"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"bleak>=1.1.1",
|
|
10
|
+
"bleak-esphome>=4.1.0",
|
|
11
|
+
"bleak-retry-connector>=4.7.1",
|
|
12
|
+
"habluetooth>=7.0.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[[project.authors]]
|
|
16
|
+
name = "Daan Vervacke"
|
|
17
|
+
email = "daan.vervacke@proton.me"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["uv_build>=0.12.5,<0.13.0"]
|
|
21
|
+
build-backend = "uv_build"
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = [
|
|
25
|
+
"coverage[toml]>=7.15.2",
|
|
26
|
+
"mypy>=2.3.1",
|
|
27
|
+
"pytest>=9.1.1",
|
|
28
|
+
"pytest-asyncio>=1.4.0",
|
|
29
|
+
"ruff>=0.16.8",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
asyncio_mode = "auto"
|
|
34
|
+
|
|
35
|
+
[tool.ruff]
|
|
36
|
+
target-version = "py314"
|
|
37
|
+
line-length = 88
|
|
38
|
+
src = [
|
|
39
|
+
"src/solarflow_ble",
|
|
40
|
+
"tests",
|
|
41
|
+
"scripts",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[tool.ruff.lint]
|
|
45
|
+
select = ["ALL"]
|
|
46
|
+
ignore = [
|
|
47
|
+
"ANN401",
|
|
48
|
+
"CPY001",
|
|
49
|
+
"COM812",
|
|
50
|
+
"C901",
|
|
51
|
+
"EM101",
|
|
52
|
+
"EM102",
|
|
53
|
+
"FBT001",
|
|
54
|
+
"FBT002",
|
|
55
|
+
"FBT003",
|
|
56
|
+
"FURB110",
|
|
57
|
+
"D",
|
|
58
|
+
"ISC001",
|
|
59
|
+
"PLR0913",
|
|
60
|
+
"PLR0917",
|
|
61
|
+
"PLR2004",
|
|
62
|
+
"RUF036",
|
|
63
|
+
"RUF006",
|
|
64
|
+
"TRY003",
|
|
65
|
+
"TC001",
|
|
66
|
+
"TC002",
|
|
67
|
+
"TC003",
|
|
68
|
+
"TC006",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
[tool.ruff.lint.per-file-ignores]
|
|
72
|
+
"tests/**" = [
|
|
73
|
+
"ANN",
|
|
74
|
+
"ARG",
|
|
75
|
+
"CPY001",
|
|
76
|
+
"FBT",
|
|
77
|
+
"INP001",
|
|
78
|
+
"PLC0415",
|
|
79
|
+
"PLR2004",
|
|
80
|
+
"S101",
|
|
81
|
+
"S105",
|
|
82
|
+
"S106",
|
|
83
|
+
"SLF001",
|
|
84
|
+
]
|
|
85
|
+
"scripts/check.py" = [
|
|
86
|
+
"S603",
|
|
87
|
+
"T201",
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
[tool.mypy]
|
|
91
|
+
python_version = "3.14"
|
|
92
|
+
strict = true
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "solarflow-ble"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [{ name = "Daan Vervacke", email = "daan.vervacke@proton.me" }]
|
|
8
|
+
requires-python = ">=3.14"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"bleak>=1.1.1",
|
|
11
|
+
"bleak-esphome>=4.1.0",
|
|
12
|
+
"bleak-retry-connector>=4.7.1",
|
|
13
|
+
"habluetooth>=7.0.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["uv_build>=0.12.5,<0.13.0"]
|
|
18
|
+
build-backend = "uv_build"
|
|
19
|
+
|
|
20
|
+
[dependency-groups]
|
|
21
|
+
dev = [
|
|
22
|
+
"coverage[toml]>=7.15.2",
|
|
23
|
+
"mypy>=2.3.1",
|
|
24
|
+
"pytest>=9.1.1",
|
|
25
|
+
"pytest-asyncio>=1.4.0",
|
|
26
|
+
"ruff>=0.16.8",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
asyncio_mode = "auto"
|
|
31
|
+
|
|
32
|
+
[tool.ruff]
|
|
33
|
+
target-version = "py314"
|
|
34
|
+
line-length = 88
|
|
35
|
+
src = ["src/solarflow_ble", "tests", "scripts"]
|
|
36
|
+
|
|
37
|
+
[tool.ruff.lint]
|
|
38
|
+
select = ["ALL"]
|
|
39
|
+
ignore = [
|
|
40
|
+
"ANN401",
|
|
41
|
+
"CPY001",
|
|
42
|
+
"COM812",
|
|
43
|
+
"C901",
|
|
44
|
+
"EM101",
|
|
45
|
+
"EM102",
|
|
46
|
+
"FBT001",
|
|
47
|
+
"FBT002",
|
|
48
|
+
"FBT003",
|
|
49
|
+
"FURB110",
|
|
50
|
+
"D",
|
|
51
|
+
"ISC001",
|
|
52
|
+
"PLR0913",
|
|
53
|
+
"PLR0917",
|
|
54
|
+
"PLR2004",
|
|
55
|
+
"RUF036",
|
|
56
|
+
"RUF006",
|
|
57
|
+
"TRY003",
|
|
58
|
+
"TC001",
|
|
59
|
+
"TC002",
|
|
60
|
+
"TC003",
|
|
61
|
+
"TC006",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
[tool.ruff.lint.per-file-ignores]
|
|
65
|
+
"tests/**" = [
|
|
66
|
+
"ANN",
|
|
67
|
+
"ARG",
|
|
68
|
+
"CPY001",
|
|
69
|
+
"FBT",
|
|
70
|
+
"INP001",
|
|
71
|
+
"PLC0415",
|
|
72
|
+
"PLR2004",
|
|
73
|
+
"S101",
|
|
74
|
+
"S105",
|
|
75
|
+
"S106",
|
|
76
|
+
"SLF001",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
"scripts/check.py" = ["S603", "T201"]
|
|
80
|
+
|
|
81
|
+
[tool.mypy]
|
|
82
|
+
python_version = "3.14"
|
|
83
|
+
strict = true
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Python library for Zendure SolarFlow BLE devices."""
|
|
2
|
+
|
|
3
|
+
from .client import BleTransport, SolarFlowClient
|
|
4
|
+
from .models import Advertisement, ConnectionStatus, SolarFlowState, SolarFlowUpdate
|
|
5
|
+
from .protocol import parse_advertisement
|
|
6
|
+
from .transport import BleakTransport
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Advertisement",
|
|
11
|
+
"BleTransport",
|
|
12
|
+
"BleakTransport",
|
|
13
|
+
"ConnectionStatus",
|
|
14
|
+
"SolarFlowClient",
|
|
15
|
+
"SolarFlowState",
|
|
16
|
+
"SolarFlowUpdate",
|
|
17
|
+
"parse_advertisement",
|
|
18
|
+
]
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Typed SolarFlow BLE client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
|
+
from contextlib import suppress
|
|
10
|
+
from typing import Any, Protocol
|
|
11
|
+
|
|
12
|
+
from .const import (
|
|
13
|
+
DEFAULT_BLE_SPP_DELAY,
|
|
14
|
+
DEFAULT_KEEPALIVE_SECONDS,
|
|
15
|
+
DEFAULT_RESPONSE_TIMEOUT,
|
|
16
|
+
NOTIFY_CHARACTERISTIC_UUID,
|
|
17
|
+
WRITE_CHARACTERISTIC_UUID,
|
|
18
|
+
)
|
|
19
|
+
from .exceptions import (
|
|
20
|
+
SolarFlowCommandError,
|
|
21
|
+
SolarFlowDeviceError,
|
|
22
|
+
SolarFlowNotReadyError,
|
|
23
|
+
SolarFlowProtocolError,
|
|
24
|
+
SolarFlowTimeoutError,
|
|
25
|
+
SolarFlowValidationError,
|
|
26
|
+
)
|
|
27
|
+
from .models import ConnectionStatus, SolarFlowState, SolarFlowUpdate
|
|
28
|
+
from .protocol import decode_json, encode_json
|
|
29
|
+
|
|
30
|
+
NotificationCallback = Callable[[str, bytes], Awaitable[None] | None]
|
|
31
|
+
UpdateCallback = Callable[[SolarFlowUpdate], Awaitable[None]]
|
|
32
|
+
_LOGGER = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BleTransport(Protocol):
|
|
36
|
+
"""Minimal BLE transport supplied by the caller."""
|
|
37
|
+
|
|
38
|
+
async def connect(self) -> None: ...
|
|
39
|
+
async def disconnect(self) -> None: ...
|
|
40
|
+
async def start_notify(
|
|
41
|
+
self, characteristic: str, callback: NotificationCallback
|
|
42
|
+
) -> None: ...
|
|
43
|
+
async def stop_notify(self, characteristic: str) -> None: ...
|
|
44
|
+
async def write_gatt_char(
|
|
45
|
+
self, characteristic: str, data: bytes, response: bool = False
|
|
46
|
+
) -> None: ...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SolarFlowClient:
|
|
50
|
+
"""Communicate with one SolarFlow controller."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
transport: BleTransport,
|
|
55
|
+
*,
|
|
56
|
+
device_id: str | None = None,
|
|
57
|
+
response_timeout: float = DEFAULT_RESPONSE_TIMEOUT,
|
|
58
|
+
keepalive_seconds: float = DEFAULT_KEEPALIVE_SECONDS,
|
|
59
|
+
ble_spp_delay: float = DEFAULT_BLE_SPP_DELAY,
|
|
60
|
+
initial_read_delay: float = 0.3,
|
|
61
|
+
update_callback: UpdateCallback | None = None,
|
|
62
|
+
allow_control: bool = False,
|
|
63
|
+
) -> None:
|
|
64
|
+
self.transport = transport
|
|
65
|
+
self.device_id = device_id
|
|
66
|
+
self.response_timeout = response_timeout
|
|
67
|
+
self.keepalive_seconds = keepalive_seconds
|
|
68
|
+
self.ble_spp_delay = ble_spp_delay
|
|
69
|
+
self.initial_read_delay = initial_read_delay
|
|
70
|
+
self.update_callback = update_callback
|
|
71
|
+
self.allow_control = allow_control
|
|
72
|
+
self.state = SolarFlowState()
|
|
73
|
+
self.status = ConnectionStatus.DISCONNECTED
|
|
74
|
+
self._lock = asyncio.Lock()
|
|
75
|
+
self._reports: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
76
|
+
self._write_results: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
77
|
+
self._keepalive_task: asyncio.Task[None] | None = None
|
|
78
|
+
self._message_id = 1
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def connected(self) -> bool:
|
|
82
|
+
return self.status is not ConnectionStatus.DISCONNECTED
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def protocol_ready(self) -> bool:
|
|
86
|
+
return self.status in (ConnectionStatus.PROTOCOL_READY, ConnectionStatus.READY)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def ready(self) -> bool:
|
|
90
|
+
return self.status is ConnectionStatus.READY
|
|
91
|
+
|
|
92
|
+
async def connect(self) -> None:
|
|
93
|
+
try:
|
|
94
|
+
await self.transport.connect()
|
|
95
|
+
self.status = ConnectionStatus.CONNECTED
|
|
96
|
+
await self.transport.start_notify(
|
|
97
|
+
NOTIFY_CHARACTERISTIC_UUID, self._notification
|
|
98
|
+
)
|
|
99
|
+
ble_spp = await self._wait_for_method("BLESPP")
|
|
100
|
+
self._establish_identity(ble_spp)
|
|
101
|
+
await self._write({"messageId": 1009, "method": "BLESPP_OK"})
|
|
102
|
+
await asyncio.sleep(self.ble_spp_delay)
|
|
103
|
+
timestamp = int(time.time() * 1000)
|
|
104
|
+
await self._write(
|
|
105
|
+
{
|
|
106
|
+
"deviceId": self._require_device_id(),
|
|
107
|
+
"messageId": self._next_message_id(),
|
|
108
|
+
"method": "getInfo",
|
|
109
|
+
"timestamp": timestamp,
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
await self._wait_for_method("getInfo-rsp")
|
|
113
|
+
self.status = ConnectionStatus.PROTOCOL_READY
|
|
114
|
+
await asyncio.sleep(self.initial_read_delay)
|
|
115
|
+
await self._write(
|
|
116
|
+
{
|
|
117
|
+
"deviceId": self._require_device_id(),
|
|
118
|
+
"messageId": self._next_message_id(),
|
|
119
|
+
"timestamp": int(time.time() * 1000),
|
|
120
|
+
"properties": ["getAll"],
|
|
121
|
+
"method": "read",
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
await self._wait_for_initial_reports()
|
|
125
|
+
self._refresh_status()
|
|
126
|
+
self._keepalive_task = asyncio.create_task(self._keepalive())
|
|
127
|
+
except BaseException:
|
|
128
|
+
await self.disconnect()
|
|
129
|
+
raise
|
|
130
|
+
|
|
131
|
+
async def disconnect(self) -> None:
|
|
132
|
+
if self._keepalive_task:
|
|
133
|
+
self._keepalive_task.cancel()
|
|
134
|
+
await asyncio.gather(self._keepalive_task, return_exceptions=True)
|
|
135
|
+
self._keepalive_task = None
|
|
136
|
+
with suppress(Exception):
|
|
137
|
+
await self.transport.stop_notify(NOTIFY_CHARACTERISTIC_UUID)
|
|
138
|
+
with suppress(Exception):
|
|
139
|
+
await self.transport.disconnect()
|
|
140
|
+
self.status = ConnectionStatus.DISCONNECTED
|
|
141
|
+
|
|
142
|
+
async def _notification(self, _characteristic: str, payload: bytes) -> None:
|
|
143
|
+
message = decode_json(payload)
|
|
144
|
+
method = message.get("method")
|
|
145
|
+
self._validate_message_identity(message)
|
|
146
|
+
if method == "BLESPP":
|
|
147
|
+
await self._reports.put(message)
|
|
148
|
+
return
|
|
149
|
+
if method in {"getInfo-rsp", "read_reply"}:
|
|
150
|
+
await self._reports.put(message)
|
|
151
|
+
elif method == "report":
|
|
152
|
+
properties = message.get("properties")
|
|
153
|
+
if isinstance(properties, dict):
|
|
154
|
+
self.state = self.state.update(properties)
|
|
155
|
+
self.state = self.state.with_identity(message)
|
|
156
|
+
if "writeRsp" in properties:
|
|
157
|
+
await self._write_results.put(message)
|
|
158
|
+
self.state = self.state.with_packs(message)
|
|
159
|
+
await self._reports.put(message)
|
|
160
|
+
elif method == "error":
|
|
161
|
+
await self._reports.put(message)
|
|
162
|
+
self._refresh_status()
|
|
163
|
+
callback = self.update_callback
|
|
164
|
+
if callback:
|
|
165
|
+
try:
|
|
166
|
+
await callback(SolarFlowUpdate(self.state, self.status, message))
|
|
167
|
+
except Exception:
|
|
168
|
+
_LOGGER.exception("SolarFlow update callback failed")
|
|
169
|
+
|
|
170
|
+
async def _write(self, message: dict[str, Any]) -> None:
|
|
171
|
+
await self.transport.write_gatt_char(
|
|
172
|
+
WRITE_CHARACTERISTIC_UUID, encode_json(message), response=False
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
async def _wait_for_method(self, method: str) -> dict[str, Any]:
|
|
176
|
+
deadline = asyncio.get_running_loop().time() + self.response_timeout
|
|
177
|
+
while True:
|
|
178
|
+
remaining = deadline - asyncio.get_running_loop().time()
|
|
179
|
+
if remaining <= 0:
|
|
180
|
+
raise SolarFlowTimeoutError(f"Timed out waiting for {method}")
|
|
181
|
+
try:
|
|
182
|
+
message = await asyncio.wait_for(self._reports.get(), remaining)
|
|
183
|
+
except TimeoutError as err:
|
|
184
|
+
raise SolarFlowTimeoutError(f"Timed out waiting for {method}") from err
|
|
185
|
+
if message.get("method") == method:
|
|
186
|
+
return message
|
|
187
|
+
|
|
188
|
+
def _establish_identity(self, message: dict[str, Any]) -> None:
|
|
189
|
+
device_id = message.get("deviceId")
|
|
190
|
+
if not isinstance(device_id, str) or not device_id:
|
|
191
|
+
raise SolarFlowProtocolError("BLESPP did not include deviceId")
|
|
192
|
+
if self.device_id is not None and self.device_id != device_id:
|
|
193
|
+
raise SolarFlowProtocolError(
|
|
194
|
+
"BLESPP deviceId does not match the requested device"
|
|
195
|
+
)
|
|
196
|
+
self.device_id = device_id
|
|
197
|
+
self.state = self.state.with_identity({"deviceId": device_id})
|
|
198
|
+
|
|
199
|
+
def _validate_message_identity(self, message: dict[str, Any]) -> None:
|
|
200
|
+
message_device_id = message.get("deviceId")
|
|
201
|
+
if message_device_id is None or self.device_id is None:
|
|
202
|
+
return
|
|
203
|
+
if message_device_id != self.device_id:
|
|
204
|
+
raise SolarFlowProtocolError(
|
|
205
|
+
"SolarFlow message deviceId does not match the connected device"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def _require_device_id(self) -> str:
|
|
209
|
+
if self.device_id is None:
|
|
210
|
+
raise SolarFlowProtocolError("SolarFlow device identity is not established")
|
|
211
|
+
return self.device_id
|
|
212
|
+
|
|
213
|
+
def _next_message_id(self) -> int:
|
|
214
|
+
while self._message_id == 1009:
|
|
215
|
+
self._message_id += 1
|
|
216
|
+
message_id = self._message_id
|
|
217
|
+
self._message_id += 1
|
|
218
|
+
return message_id
|
|
219
|
+
|
|
220
|
+
async def _wait_for_initial_reports(self) -> None:
|
|
221
|
+
deadline = asyncio.get_running_loop().time() + self.response_timeout
|
|
222
|
+
while self.state.smart_mode is None:
|
|
223
|
+
remaining = deadline - asyncio.get_running_loop().time()
|
|
224
|
+
if remaining <= 0:
|
|
225
|
+
raise SolarFlowTimeoutError(
|
|
226
|
+
"Timed out waiting for initial report state"
|
|
227
|
+
)
|
|
228
|
+
try:
|
|
229
|
+
message = await asyncio.wait_for(self._reports.get(), remaining)
|
|
230
|
+
except TimeoutError as err:
|
|
231
|
+
raise SolarFlowTimeoutError(
|
|
232
|
+
"Timed out waiting for initial report state"
|
|
233
|
+
) from err
|
|
234
|
+
if message.get("method") == "error":
|
|
235
|
+
raise SolarFlowDeviceError(
|
|
236
|
+
f"SolarFlow reported error: {message.get('data')}"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def _refresh_status(self) -> None:
|
|
240
|
+
if self.status is ConnectionStatus.DISCONNECTED or not self.protocol_ready:
|
|
241
|
+
return
|
|
242
|
+
self.status = (
|
|
243
|
+
ConnectionStatus.READY
|
|
244
|
+
if self.state.smart_mode == 1
|
|
245
|
+
else ConnectionStatus.PROTOCOL_READY
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
async def _request_write(self, property_name: str, value: int) -> None:
|
|
249
|
+
if not self.allow_control:
|
|
250
|
+
raise SolarFlowNotReadyError(
|
|
251
|
+
"SolarFlow controls are disabled for this session"
|
|
252
|
+
)
|
|
253
|
+
if not self.ready:
|
|
254
|
+
raise SolarFlowNotReadyError("SolarFlow controls are not ready")
|
|
255
|
+
async with self._lock:
|
|
256
|
+
timestamp = int(time.time() * 1000)
|
|
257
|
+
await self._write(
|
|
258
|
+
{
|
|
259
|
+
"method": "write",
|
|
260
|
+
"timestamp": timestamp,
|
|
261
|
+
"deviceId": self._require_device_id(),
|
|
262
|
+
"messageId": self._next_message_id(),
|
|
263
|
+
"properties": {property_name: value},
|
|
264
|
+
}
|
|
265
|
+
)
|
|
266
|
+
deadline = asyncio.get_running_loop().time() + self.response_timeout
|
|
267
|
+
while True:
|
|
268
|
+
remaining = deadline - asyncio.get_running_loop().time()
|
|
269
|
+
if remaining <= 0:
|
|
270
|
+
raise SolarFlowTimeoutError(
|
|
271
|
+
f"Timed out waiting for {property_name} acknowledgement"
|
|
272
|
+
)
|
|
273
|
+
try:
|
|
274
|
+
response = await asyncio.wait_for(
|
|
275
|
+
self._write_results.get(), remaining
|
|
276
|
+
)
|
|
277
|
+
except TimeoutError as err:
|
|
278
|
+
raise SolarFlowTimeoutError(
|
|
279
|
+
f"Timed out waiting for {property_name} acknowledgement"
|
|
280
|
+
) from err
|
|
281
|
+
properties = response.get("properties")
|
|
282
|
+
if isinstance(properties, dict) and "writeRsp" in properties:
|
|
283
|
+
if properties["writeRsp"] != 0:
|
|
284
|
+
raise SolarFlowCommandError(
|
|
285
|
+
f"SolarFlow rejected {property_name}"
|
|
286
|
+
)
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
async def set_input_limit(self, value: int) -> None:
|
|
290
|
+
self._validate_limit(value)
|
|
291
|
+
await self._request_write("inputLimit", value)
|
|
292
|
+
|
|
293
|
+
async def set_output_limit(self, value: int) -> None:
|
|
294
|
+
self._validate_limit(value)
|
|
295
|
+
await self._request_write("outputLimit", value)
|
|
296
|
+
|
|
297
|
+
async def set_min_soc(self, value: int) -> None:
|
|
298
|
+
if not 0 <= value <= 50:
|
|
299
|
+
raise SolarFlowValidationError(
|
|
300
|
+
"Minimum SOC must be between 0 and 50 percent"
|
|
301
|
+
)
|
|
302
|
+
await self._request_write("minSoc", value * 10)
|
|
303
|
+
|
|
304
|
+
async def set_soc(self, value: int) -> None:
|
|
305
|
+
if not 70 <= value <= 100:
|
|
306
|
+
raise SolarFlowValidationError(
|
|
307
|
+
"Maximum SOC must be between 70 and 100 percent"
|
|
308
|
+
)
|
|
309
|
+
await self._request_write("socSet", value * 10)
|
|
310
|
+
|
|
311
|
+
async def set_ac_mode(self, value: int) -> None:
|
|
312
|
+
if value not in (1, 2):
|
|
313
|
+
raise SolarFlowValidationError("AC mode must be 1 or 2")
|
|
314
|
+
await self._request_write("acMode", value)
|
|
315
|
+
|
|
316
|
+
async def _keepalive(self) -> None:
|
|
317
|
+
while True:
|
|
318
|
+
await asyncio.sleep(self.keepalive_seconds)
|
|
319
|
+
async with self._lock:
|
|
320
|
+
await self._write(
|
|
321
|
+
{
|
|
322
|
+
"deviceId": self._require_device_id(),
|
|
323
|
+
"messageId": self._next_message_id(),
|
|
324
|
+
"timestamp": int(time.time() * 1000),
|
|
325
|
+
"properties": ["getAll"],
|
|
326
|
+
"method": "read",
|
|
327
|
+
}
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
@staticmethod
|
|
331
|
+
def _validate_limit(value: int) -> None:
|
|
332
|
+
if not 0 <= value <= 2400:
|
|
333
|
+
raise SolarFlowValidationError("Power limit must be between 0 and 2400 W")
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""SolarFlow BLE constants."""
|
|
2
|
+
|
|
3
|
+
SERVICE_UUID = "0000a002-0000-1000-8000-00805f9b34fb"
|
|
4
|
+
WRITE_CHARACTERISTIC_UUID = "0000c304-0000-1000-8000-00805f9b34fb"
|
|
5
|
+
NOTIFY_CHARACTERISTIC_UUID = "0000c305-0000-1000-8000-00805f9b34fb"
|
|
6
|
+
MANUFACTURER_ID = 0x4F48
|
|
7
|
+
DEFAULT_KEEPALIVE_SECONDS = 30.0
|
|
8
|
+
DEFAULT_RESPONSE_TIMEOUT = 10.0
|
|
9
|
+
DEFAULT_BLE_SPP_DELAY = 0.3
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""SolarFlow exceptions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SolarFlowError(Exception):
|
|
5
|
+
"""Base SolarFlow error."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SolarFlowConnectionError(SolarFlowError):
|
|
9
|
+
"""The BLE transport could not connect or disconnected."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SolarFlowTimeoutError(SolarFlowError):
|
|
13
|
+
"""The device did not answer before the timeout."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SolarFlowProtocolError(SolarFlowError):
|
|
17
|
+
"""The device sent invalid or unexpected protocol data."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SolarFlowCommandError(SolarFlowError):
|
|
21
|
+
"""The device rejected a command."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SolarFlowValidationError(SolarFlowError, ValueError):
|
|
25
|
+
"""A command argument is outside the supported range."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SolarFlowNotReadyError(SolarFlowError):
|
|
29
|
+
"""The session is connected but controls are not currently ready."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SolarFlowDeviceError(SolarFlowError):
|
|
33
|
+
"""The device reported a protocol error event."""
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Typed SolarFlow models."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, replace
|
|
4
|
+
from enum import IntEnum, StrEnum
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
_REPORT_FIELDS = {
|
|
8
|
+
"packInputPower": "pack_input_power",
|
|
9
|
+
"outputPackPower": "output_pack_power",
|
|
10
|
+
"outputHomePower": "output_home_power",
|
|
11
|
+
"remainOutTime": "remain_out_time",
|
|
12
|
+
"dataReady": "data_ready",
|
|
13
|
+
"acMode": "ac_mode",
|
|
14
|
+
"inputLimit": "input_limit",
|
|
15
|
+
"outputLimit": "output_limit",
|
|
16
|
+
"packState": "pack_state",
|
|
17
|
+
"acStatus": "ac_status",
|
|
18
|
+
"electricLevel": "electric_level",
|
|
19
|
+
"gridState": "grid_state",
|
|
20
|
+
"faultLevel": "fault_level",
|
|
21
|
+
"smartMode": "smart_mode",
|
|
22
|
+
"chargeMaxLimit": "charge_max_limit",
|
|
23
|
+
"socLimit": "soc_limit",
|
|
24
|
+
"gridInputPower": "grid_input_power",
|
|
25
|
+
"solarInputPower": "solar_input_power",
|
|
26
|
+
"solarPower1": "solar_power_1",
|
|
27
|
+
"solarPower2": "solar_power_2",
|
|
28
|
+
"solarPower3": "solar_power_3",
|
|
29
|
+
"solarPower4": "solar_power_4",
|
|
30
|
+
"solarPower5": "solar_power_5",
|
|
31
|
+
"solarPower6": "solar_power_6",
|
|
32
|
+
"gridOffPower": "grid_off_power",
|
|
33
|
+
"socStatus": "soc_status",
|
|
34
|
+
"hyperTmp": "hyper_temperature",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_PACK_FIELDS = {
|
|
38
|
+
"packType": "pack_type",
|
|
39
|
+
"socLevel": "soc_level",
|
|
40
|
+
"state": "state",
|
|
41
|
+
"power": "power",
|
|
42
|
+
"maxTemp": "max_temp",
|
|
43
|
+
"totalVol": "total_voltage",
|
|
44
|
+
"batcur": "battery_current",
|
|
45
|
+
"maxVol": "max_voltage",
|
|
46
|
+
"minVol": "min_voltage",
|
|
47
|
+
"softVersion": "software_version",
|
|
48
|
+
"heatState": "heat_state",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AcMode(IntEnum):
|
|
53
|
+
"""Known AC modes."""
|
|
54
|
+
|
|
55
|
+
CHARGING = 1
|
|
56
|
+
DISCHARGING = 2
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PackState(IntEnum):
|
|
60
|
+
"""Known pack states."""
|
|
61
|
+
|
|
62
|
+
STANDBY = 0
|
|
63
|
+
CHARGING = 1
|
|
64
|
+
DISCHARGING = 2
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ConnectionStatus(StrEnum):
|
|
68
|
+
"""Protocol session status."""
|
|
69
|
+
|
|
70
|
+
DISCONNECTED = "disconnected"
|
|
71
|
+
CONNECTED = "connected"
|
|
72
|
+
PROTOCOL_READY = "protocol_ready"
|
|
73
|
+
READY = "ready"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True, slots=True)
|
|
77
|
+
class BatteryPack:
|
|
78
|
+
"""Latest state reported for one battery pack."""
|
|
79
|
+
|
|
80
|
+
serial_number: str
|
|
81
|
+
pack_type: int | None = None
|
|
82
|
+
soc_level: int | None = None
|
|
83
|
+
state: int | None = None
|
|
84
|
+
power: int | None = None
|
|
85
|
+
max_temp: int | None = None
|
|
86
|
+
total_voltage: int | None = None
|
|
87
|
+
battery_current: int | None = None
|
|
88
|
+
max_voltage: int | None = None
|
|
89
|
+
min_voltage: int | None = None
|
|
90
|
+
software_version: int | None = None
|
|
91
|
+
heat_state: int | None = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True, slots=True)
|
|
95
|
+
class Advertisement:
|
|
96
|
+
"""A SolarFlow BLE advertisement."""
|
|
97
|
+
|
|
98
|
+
address: str
|
|
99
|
+
identifier: str
|
|
100
|
+
rssi: int | None = None
|
|
101
|
+
connectable: bool = True
|
|
102
|
+
address_type: int | None = None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True, slots=True)
|
|
106
|
+
class SolarFlowState:
|
|
107
|
+
"""Latest decoded controller state."""
|
|
108
|
+
|
|
109
|
+
pack_input_power: int | None = None
|
|
110
|
+
output_pack_power: int | None = None
|
|
111
|
+
battery_power: int | None = None
|
|
112
|
+
ac_mode: int | None = None
|
|
113
|
+
input_limit: int | None = None
|
|
114
|
+
output_limit: int | None = None
|
|
115
|
+
pack_state: int | None = None
|
|
116
|
+
ac_status: int | None = None
|
|
117
|
+
electric_level: int | None = None
|
|
118
|
+
grid_state: int | None = None
|
|
119
|
+
fault_level: int | None = None
|
|
120
|
+
smart_mode: int | None = None
|
|
121
|
+
charge_max_limit: int | None = None
|
|
122
|
+
soc_limit: int | None = None
|
|
123
|
+
output_home_power: int | None = None
|
|
124
|
+
remain_out_time: int | None = None
|
|
125
|
+
data_ready: int | None = None
|
|
126
|
+
grid_input_power: int | None = None
|
|
127
|
+
solar_input_power: int | None = None
|
|
128
|
+
solar_power_1: int | None = None
|
|
129
|
+
solar_power_2: int | None = None
|
|
130
|
+
solar_power_3: int | None = None
|
|
131
|
+
solar_power_4: int | None = None
|
|
132
|
+
solar_power_5: int | None = None
|
|
133
|
+
solar_power_6: int | None = None
|
|
134
|
+
grid_off_power: int | None = None
|
|
135
|
+
soc_status: int | None = None
|
|
136
|
+
hyper_temperature: int | None = None
|
|
137
|
+
device_id: str | None = None
|
|
138
|
+
product_key: str | None = None
|
|
139
|
+
packs: tuple[BatteryPack, ...] = ()
|
|
140
|
+
raw: dict[str, object] | None = None
|
|
141
|
+
|
|
142
|
+
def update(self, values: dict[str, object]) -> SolarFlowState:
|
|
143
|
+
changes = {
|
|
144
|
+
_REPORT_FIELDS[key]: value
|
|
145
|
+
for key, value in values.items()
|
|
146
|
+
if key in _REPORT_FIELDS and isinstance(value, int)
|
|
147
|
+
}
|
|
148
|
+
current = replace(self, raw={**(self.raw or {}), **values})
|
|
149
|
+
current = replace(current, **cast(Any, changes))
|
|
150
|
+
if (
|
|
151
|
+
current.pack_input_power is not None
|
|
152
|
+
and current.output_pack_power is not None
|
|
153
|
+
):
|
|
154
|
+
current = replace(
|
|
155
|
+
current,
|
|
156
|
+
battery_power=current.output_pack_power - current.pack_input_power,
|
|
157
|
+
)
|
|
158
|
+
return current
|
|
159
|
+
|
|
160
|
+
def with_identity(self, message: dict[str, object]) -> SolarFlowState:
|
|
161
|
+
device_id = message.get("deviceId")
|
|
162
|
+
product_key = message.get("productKey")
|
|
163
|
+
return replace(
|
|
164
|
+
self,
|
|
165
|
+
device_id=device_id if isinstance(device_id, str) else self.device_id,
|
|
166
|
+
product_key=product_key
|
|
167
|
+
if isinstance(product_key, str)
|
|
168
|
+
else self.product_key,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def with_packs(self, message: dict[str, object]) -> SolarFlowState:
|
|
172
|
+
raw_packs = message.get("packData")
|
|
173
|
+
if not isinstance(raw_packs, list):
|
|
174
|
+
return self
|
|
175
|
+
known = {pack.serial_number: pack for pack in self.packs}
|
|
176
|
+
for raw in raw_packs:
|
|
177
|
+
if not isinstance(raw, dict) or not isinstance(raw.get("sn"), str):
|
|
178
|
+
continue
|
|
179
|
+
serial_number = raw["sn"]
|
|
180
|
+
current = known.get(serial_number, BatteryPack(serial_number=serial_number))
|
|
181
|
+
changes = {
|
|
182
|
+
field: raw[key] for key, field in _PACK_FIELDS.items() if key in raw
|
|
183
|
+
}
|
|
184
|
+
known[serial_number] = replace(current, **cast(Any, changes))
|
|
185
|
+
return replace(self, packs=tuple(known.values()))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass(frozen=True, slots=True)
|
|
189
|
+
class SolarFlowUpdate:
|
|
190
|
+
"""Typed state update delivered to an optional callback."""
|
|
191
|
+
|
|
192
|
+
state: SolarFlowState
|
|
193
|
+
status: ConnectionStatus
|
|
194
|
+
raw_message: dict[str, object]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""SolarFlow protocol helpers."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .const import MANUFACTURER_ID
|
|
7
|
+
from .exceptions import SolarFlowProtocolError
|
|
8
|
+
from .models import Advertisement
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_advertisement(
|
|
12
|
+
address: str,
|
|
13
|
+
manufacturer_data: dict[int, bytes],
|
|
14
|
+
*,
|
|
15
|
+
rssi: int | None = None,
|
|
16
|
+
connectable: bool = True,
|
|
17
|
+
address_type: int | None = None,
|
|
18
|
+
) -> Advertisement | None:
|
|
19
|
+
"""Parse the SolarFlow manufacturer advertisement."""
|
|
20
|
+
payload = manufacturer_data.get(MANUFACTURER_ID)
|
|
21
|
+
if payload is None:
|
|
22
|
+
return None
|
|
23
|
+
try:
|
|
24
|
+
identifier = (
|
|
25
|
+
payload[:-1].decode("ascii")
|
|
26
|
+
if payload.endswith(b"\x16")
|
|
27
|
+
else payload.decode("ascii")
|
|
28
|
+
)
|
|
29
|
+
except UnicodeDecodeError:
|
|
30
|
+
return None
|
|
31
|
+
return (
|
|
32
|
+
Advertisement(address, identifier, rssi, connectable, address_type)
|
|
33
|
+
if identifier
|
|
34
|
+
else None
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def decode_json(payload: bytes | bytearray) -> dict[str, Any]:
|
|
39
|
+
"""Decode one JSON notification."""
|
|
40
|
+
try:
|
|
41
|
+
value = json.loads(bytes(payload))
|
|
42
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as err:
|
|
43
|
+
raise SolarFlowProtocolError("Invalid SolarFlow JSON payload") from err
|
|
44
|
+
if not isinstance(value, dict):
|
|
45
|
+
raise SolarFlowProtocolError("SolarFlow payload is not an object")
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def encode_json(message: dict[str, Any]) -> bytes:
|
|
50
|
+
"""Encode compact JSON for C304."""
|
|
51
|
+
return json.dumps(message, separators=(",", ":"), ensure_ascii=False).encode()
|
|
File without changes
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""BLE transport adapters for SolarFlow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from contextlib import suppress
|
|
9
|
+
from typing import cast
|
|
10
|
+
|
|
11
|
+
import bleak
|
|
12
|
+
from bleak.backends.characteristic import BleakGATTCharacteristic
|
|
13
|
+
from bleak.backends.device import BLEDevice
|
|
14
|
+
from bleak_retry_connector import establish_connection
|
|
15
|
+
|
|
16
|
+
from .client import BleTransport, NotificationCallback
|
|
17
|
+
|
|
18
|
+
_LOGGER = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BleakTransport(BleTransport):
|
|
22
|
+
"""Adapt a Bleak client to the SolarFlow transport protocol."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
device: BLEDevice,
|
|
27
|
+
*,
|
|
28
|
+
timeout: float = 30.0,
|
|
29
|
+
client_factory: Callable[..., bleak.BleakClient] | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.device = device
|
|
32
|
+
self.timeout = timeout
|
|
33
|
+
self._client_factory = (
|
|
34
|
+
client_factory if client_factory is not None else bleak.BleakClient
|
|
35
|
+
)
|
|
36
|
+
self._client: bleak.BleakClient | None = None
|
|
37
|
+
self._notification_tasks: set[asyncio.Task[None]] = set()
|
|
38
|
+
self._accept_notifications = True
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def client(self) -> bleak.BleakClient:
|
|
42
|
+
if self._client is None:
|
|
43
|
+
raise RuntimeError("SolarFlow BLE transport is not connected")
|
|
44
|
+
return self._client
|
|
45
|
+
|
|
46
|
+
async def connect(self) -> None:
|
|
47
|
+
self._accept_notifications = True
|
|
48
|
+
if self._client is None or not self._client.is_connected:
|
|
49
|
+
self._client = await establish_connection(
|
|
50
|
+
cast(type[bleak.BleakClient], self._client_factory),
|
|
51
|
+
self.device,
|
|
52
|
+
self.device.name or self.device.address,
|
|
53
|
+
timeout=self.timeout,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
async def disconnect(self) -> None:
|
|
57
|
+
self._accept_notifications = False
|
|
58
|
+
await self._cancel_notification_tasks()
|
|
59
|
+
client = self._client
|
|
60
|
+
try:
|
|
61
|
+
if client is not None:
|
|
62
|
+
await client.disconnect()
|
|
63
|
+
finally:
|
|
64
|
+
self._client = None
|
|
65
|
+
|
|
66
|
+
async def start_notify(
|
|
67
|
+
self, characteristic: str, callback: NotificationCallback
|
|
68
|
+
) -> None:
|
|
69
|
+
def on_notification(
|
|
70
|
+
gatt_characteristic: BleakGATTCharacteristic, payload: bytearray
|
|
71
|
+
) -> None:
|
|
72
|
+
if not self._accept_notifications:
|
|
73
|
+
return
|
|
74
|
+
result = callback(gatt_characteristic.uuid, bytes(payload))
|
|
75
|
+
if isinstance(result, Awaitable):
|
|
76
|
+
task = asyncio.ensure_future(result)
|
|
77
|
+
self._notification_tasks.add(task)
|
|
78
|
+
task.add_done_callback(self._notification_task_done)
|
|
79
|
+
|
|
80
|
+
await self.client.start_notify(characteristic, on_notification)
|
|
81
|
+
|
|
82
|
+
async def stop_notify(self, characteristic: str) -> None:
|
|
83
|
+
if self._client is not None and self._client.is_connected:
|
|
84
|
+
await self._client.stop_notify(characteristic)
|
|
85
|
+
|
|
86
|
+
async def write_gatt_char(
|
|
87
|
+
self, characteristic: str, data: bytes, response: bool = False
|
|
88
|
+
) -> None:
|
|
89
|
+
await self.client.write_gatt_char(characteristic, data, response=response)
|
|
90
|
+
|
|
91
|
+
def _notification_task_done(self, task: asyncio.Task[None]) -> None:
|
|
92
|
+
self._notification_tasks.discard(task)
|
|
93
|
+
if task.cancelled():
|
|
94
|
+
return
|
|
95
|
+
with suppress(asyncio.CancelledError):
|
|
96
|
+
error = task.exception()
|
|
97
|
+
if error is not None:
|
|
98
|
+
_LOGGER.error("SolarFlow notification callback failed", exc_info=error)
|
|
99
|
+
|
|
100
|
+
async def _cancel_notification_tasks(self) -> None:
|
|
101
|
+
tasks = tuple(self._notification_tasks)
|
|
102
|
+
if not tasks:
|
|
103
|
+
return
|
|
104
|
+
for task in tasks:
|
|
105
|
+
task.cancel()
|
|
106
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|