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.
@@ -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)