SolixBLE 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Harvey Lelliott
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.4
2
+ Name: SolixBLE
3
+ Version: 1.0.0
4
+ Summary: Python module for monitoring Bluetooth Anker Solix devices
5
+ Author-email: Harvey Lelliott <harveylelliott@duck.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2025 Harvey Lelliott
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+ Project-URL: Homepage, https://github.com/flip-dots/SolixBLE
28
+ Project-URL: Repository, https://github.com/flip-dots/SolixBLE
29
+ Project-URL: Issues, https://github.com/flip-dots/SolixBLE/issues
30
+ Keywords: Anker,Solix,BLE,Home Assistant
31
+ Classifier: Development Status :: 3 - Alpha
32
+ Classifier: Intended Audience :: Developers
33
+ Classifier: Topic :: Home Automation
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Framework :: AsyncIO
36
+ Classifier: Operating System :: Microsoft :: Windows :: Windows 10
37
+ Classifier: Operating System :: POSIX :: Linux
38
+ Classifier: Operating System :: MacOS :: MacOS X
39
+ Classifier: Programming Language :: Python :: 3.11
40
+ Requires-Python: >=3.11
41
+ Description-Content-Type: text/markdown
42
+ License-File: LICENSE.txt
43
+ Requires-Dist: bleak>=0.19.0
44
+ Requires-Dist: bleak-retry-connector
45
+ Dynamic: license-file
46
+
47
+ # SolixBLE
48
+
49
+ [![PyPI Status](https://img.shields.io/pypi/v/SolixBLE.svg)](https://pypi.python.org/pypi/SolixBLE)
50
+ [![Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
51
+
52
+ Python module for monitoring Anker Solix power stations over Bluetooth.
53
+ - 👌 Free software: MIT license
54
+ - 🍝 Sauce: https://github.com/flip-dots/SolixBLE
55
+ - 📦 PIP: https://pypi.org/project/SolixBLE/
56
+
57
+
58
+ This Python module enables you to monitor Anker Solix devices directly
59
+ from your computer, without the need for any cloud services or Anker app.
60
+ It leverages the Bleak library to interact with Bluetooth Anker Solix power stations.
61
+ No pairing is required in order to receive telemetry data.
62
+
63
+
64
+ ## Features
65
+
66
+ - 🔋 Battery percentage
67
+ - ⚡ Total Power In/Out
68
+ - 🔌 AC Power In/Out
69
+ - 🚗 DC Power In/Out
70
+ - ⏰ AC/DC Timer value
71
+ - ⏲️ Time remaining to full/empty
72
+ - ☀️ Solar Power In
73
+ - 📱 USB Port Status
74
+ - 💡 Light bar status
75
+ - 🔂 Simple structure
76
+ - ✔️ More emojis than strictly necessary
77
+
78
+
79
+ ## Supported Devices
80
+
81
+ - C300X
82
+ - Maybe more? IDK
83
+
84
+
85
+ ## Requirements
86
+
87
+ - 🐍 Python 3.11+
88
+ - 📶 Bleak 0.19.0+
89
+ - 📶 bleak-retry-connector
90
+
91
+
92
+ ## Supported Operating Systems
93
+
94
+ - 🐧 Linux (BlueZ)
95
+ - Ubuntu Desktop
96
+ - Arch (HomeAssistant OS)
97
+ - 🏢 Windows
98
+ - Windows 10
99
+ - 💾 Mac OSX
100
+ - Maybe?
101
+
102
+
103
+ ## Installation
104
+
105
+
106
+ ### PIP
107
+
108
+ ```
109
+ pip install SolixBLE
110
+ ```
111
+
112
+
113
+ ### Manual
114
+
115
+ SolixBLE consists of a single file (SolixBLE.py) which you can simply put in the
116
+ same directory as your program. If you are using manual installation make sure
117
+ the dependencies are installed as well.
118
+
119
+ ```
120
+ pip install bleak bleak-retry-connector
121
+ ```
@@ -0,0 +1,75 @@
1
+ # SolixBLE
2
+
3
+ [![PyPI Status](https://img.shields.io/pypi/v/SolixBLE.svg)](https://pypi.python.org/pypi/SolixBLE)
4
+ [![Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
5
+
6
+ Python module for monitoring Anker Solix power stations over Bluetooth.
7
+ - 👌 Free software: MIT license
8
+ - 🍝 Sauce: https://github.com/flip-dots/SolixBLE
9
+ - 📦 PIP: https://pypi.org/project/SolixBLE/
10
+
11
+
12
+ This Python module enables you to monitor Anker Solix devices directly
13
+ from your computer, without the need for any cloud services or Anker app.
14
+ It leverages the Bleak library to interact with Bluetooth Anker Solix power stations.
15
+ No pairing is required in order to receive telemetry data.
16
+
17
+
18
+ ## Features
19
+
20
+ - 🔋 Battery percentage
21
+ - ⚡ Total Power In/Out
22
+ - 🔌 AC Power In/Out
23
+ - 🚗 DC Power In/Out
24
+ - ⏰ AC/DC Timer value
25
+ - ⏲️ Time remaining to full/empty
26
+ - ☀️ Solar Power In
27
+ - 📱 USB Port Status
28
+ - 💡 Light bar status
29
+ - 🔂 Simple structure
30
+ - ✔️ More emojis than strictly necessary
31
+
32
+
33
+ ## Supported Devices
34
+
35
+ - C300X
36
+ - Maybe more? IDK
37
+
38
+
39
+ ## Requirements
40
+
41
+ - 🐍 Python 3.11+
42
+ - 📶 Bleak 0.19.0+
43
+ - 📶 bleak-retry-connector
44
+
45
+
46
+ ## Supported Operating Systems
47
+
48
+ - 🐧 Linux (BlueZ)
49
+ - Ubuntu Desktop
50
+ - Arch (HomeAssistant OS)
51
+ - 🏢 Windows
52
+ - Windows 10
53
+ - 💾 Mac OSX
54
+ - Maybe?
55
+
56
+
57
+ ## Installation
58
+
59
+
60
+ ### PIP
61
+
62
+ ```
63
+ pip install SolixBLE
64
+ ```
65
+
66
+
67
+ ### Manual
68
+
69
+ SolixBLE consists of a single file (SolixBLE.py) which you can simply put in the
70
+ same directory as your program. If you are using manual installation make sure
71
+ the dependencies are installed as well.
72
+
73
+ ```
74
+ pip install bleak bleak-retry-connector
75
+ ```
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.4
2
+ Name: SolixBLE
3
+ Version: 1.0.0
4
+ Summary: Python module for monitoring Bluetooth Anker Solix devices
5
+ Author-email: Harvey Lelliott <harveylelliott@duck.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2025 Harvey Lelliott
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+ Project-URL: Homepage, https://github.com/flip-dots/SolixBLE
28
+ Project-URL: Repository, https://github.com/flip-dots/SolixBLE
29
+ Project-URL: Issues, https://github.com/flip-dots/SolixBLE/issues
30
+ Keywords: Anker,Solix,BLE,Home Assistant
31
+ Classifier: Development Status :: 3 - Alpha
32
+ Classifier: Intended Audience :: Developers
33
+ Classifier: Topic :: Home Automation
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Framework :: AsyncIO
36
+ Classifier: Operating System :: Microsoft :: Windows :: Windows 10
37
+ Classifier: Operating System :: POSIX :: Linux
38
+ Classifier: Operating System :: MacOS :: MacOS X
39
+ Classifier: Programming Language :: Python :: 3.11
40
+ Requires-Python: >=3.11
41
+ Description-Content-Type: text/markdown
42
+ License-File: LICENSE.txt
43
+ Requires-Dist: bleak>=0.19.0
44
+ Requires-Dist: bleak-retry-connector
45
+ Dynamic: license-file
46
+
47
+ # SolixBLE
48
+
49
+ [![PyPI Status](https://img.shields.io/pypi/v/SolixBLE.svg)](https://pypi.python.org/pypi/SolixBLE)
50
+ [![Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
51
+
52
+ Python module for monitoring Anker Solix power stations over Bluetooth.
53
+ - 👌 Free software: MIT license
54
+ - 🍝 Sauce: https://github.com/flip-dots/SolixBLE
55
+ - 📦 PIP: https://pypi.org/project/SolixBLE/
56
+
57
+
58
+ This Python module enables you to monitor Anker Solix devices directly
59
+ from your computer, without the need for any cloud services or Anker app.
60
+ It leverages the Bleak library to interact with Bluetooth Anker Solix power stations.
61
+ No pairing is required in order to receive telemetry data.
62
+
63
+
64
+ ## Features
65
+
66
+ - 🔋 Battery percentage
67
+ - ⚡ Total Power In/Out
68
+ - 🔌 AC Power In/Out
69
+ - 🚗 DC Power In/Out
70
+ - ⏰ AC/DC Timer value
71
+ - ⏲️ Time remaining to full/empty
72
+ - ☀️ Solar Power In
73
+ - 📱 USB Port Status
74
+ - 💡 Light bar status
75
+ - 🔂 Simple structure
76
+ - ✔️ More emojis than strictly necessary
77
+
78
+
79
+ ## Supported Devices
80
+
81
+ - C300X
82
+ - Maybe more? IDK
83
+
84
+
85
+ ## Requirements
86
+
87
+ - 🐍 Python 3.11+
88
+ - 📶 Bleak 0.19.0+
89
+ - 📶 bleak-retry-connector
90
+
91
+
92
+ ## Supported Operating Systems
93
+
94
+ - 🐧 Linux (BlueZ)
95
+ - Ubuntu Desktop
96
+ - Arch (HomeAssistant OS)
97
+ - 🏢 Windows
98
+ - Windows 10
99
+ - 💾 Mac OSX
100
+ - Maybe?
101
+
102
+
103
+ ## Installation
104
+
105
+
106
+ ### PIP
107
+
108
+ ```
109
+ pip install SolixBLE
110
+ ```
111
+
112
+
113
+ ### Manual
114
+
115
+ SolixBLE consists of a single file (SolixBLE.py) which you can simply put in the
116
+ same directory as your program. If you are using manual installation make sure
117
+ the dependencies are installed as well.
118
+
119
+ ```
120
+ pip install bleak bleak-retry-connector
121
+ ```
@@ -0,0 +1,9 @@
1
+ LICENSE.txt
2
+ README.md
3
+ SolixBLE.py
4
+ pyproject.toml
5
+ SolixBLE.egg-info/PKG-INFO
6
+ SolixBLE.egg-info/SOURCES.txt
7
+ SolixBLE.egg-info/dependency_links.txt
8
+ SolixBLE.egg-info/requires.txt
9
+ SolixBLE.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ bleak>=0.19.0
2
+ bleak-retry-connector
@@ -0,0 +1 @@
1
+ SolixBLE
@@ -0,0 +1,758 @@
1
+ """SolixBLE module.
2
+
3
+ .. moduleauthor:: Harvey Lelliott (flip-dots) <harveylelliott@duck.com>
4
+
5
+ """
6
+
7
+ # ruff: noqa: G004
8
+ import asyncio
9
+ from collections.abc import Callable
10
+ from datetime import datetime, timedelta
11
+ from enum import Enum
12
+ import logging
13
+
14
+ from bleak import BleakClient, BleakError, BleakScanner
15
+ from bleak.backends.client import BaseBleakClient
16
+ from bleak.backends.device import BLEDevice
17
+ from bleak_retry_connector import establish_connection
18
+
19
+ #: GATT Service UUID for device telemetry. Is subscribable. Handle 17.
20
+ UUID_TELEMETRY = "8c850003-0302-41c5-b46e-cf057c562025"
21
+
22
+ #: GATT Service UUID for identifying Solix devices (Tested on C300X).
23
+ UUID_IDENTIFIER = "0000ff09-0000-1000-8000-00805f9b34fb"
24
+
25
+ #: Time to wait before re-connecting on an unexpected disconnect.
26
+ RECONNECT_DELAY = 3
27
+
28
+ #: Maximum number of automatic re-connection attempts the program will make.
29
+ RECONNECT_ATTEMPTS_MAX = -1
30
+
31
+ #: Time to allow for a re-connect before considering the
32
+ #: device to be disconnected and running state changed callbacks.
33
+ DISCONNECT_TIMEOUT = 30
34
+
35
+ #: Size of expected telemetry packet in bytes.
36
+ EXPECTED_TELEMETRY_SIZE = 253
37
+
38
+ #: String value for unknown string attributes.
39
+ DEFAULT_METADATA_STRING = "Unknown"
40
+
41
+ #: Int value for unknown int attributes.
42
+ DEFAULT_METADATA_INT = -1
43
+
44
+ #: Float value for unknown float attributes.
45
+ DEFAULT_METADATA_FLOAT = -1.0
46
+
47
+
48
+ _LOGGER = logging.getLogger(__name__)
49
+
50
+
51
+ async def discover_devices(
52
+ scanner: BleakScanner | None = None, timeout: int = 5
53
+ ) -> list[BLEDevice]:
54
+ """Scan feature.
55
+
56
+ Scans the BLE neighborhood for Solix BLE device(s) and returns
57
+ a list of nearby devices based upon detection of a known UUID.
58
+
59
+ :param scanner: Scanner to use. Defaults to new scanner.
60
+ :param timeout: Time to scan for devices (default=5).
61
+ """
62
+
63
+ if scanner is None:
64
+ scanner = BleakScanner
65
+
66
+ devices = []
67
+
68
+ def callback(device, advertising_data):
69
+ if UUID_IDENTIFIER in advertising_data.service_uuids and device not in devices:
70
+ devices.append(device)
71
+
72
+ async with BleakScanner(callback) as scanner:
73
+ await asyncio.sleep(timeout)
74
+
75
+ return devices
76
+
77
+
78
+ class PortStatus(Enum):
79
+ """The status of a port on the device."""
80
+
81
+ #: The status of the port is unknown.
82
+ UNKNOWN = -1
83
+
84
+ #: The port is not connected.
85
+ NOT_CONNECTED = 0
86
+
87
+ #: The port is an output.
88
+ OUTPUT = 1
89
+
90
+ #: The port is an input.
91
+ INPUT = 2
92
+
93
+
94
+ class LightStatus(Enum):
95
+ """The status of the light on the device."""
96
+
97
+ #: The status of the light is unknown.
98
+ UNKNOWN = -1
99
+
100
+ #: The light is off.
101
+ OFF = 0
102
+
103
+ #: The light is on low.
104
+ LOW = 1
105
+
106
+ #: The light is on medium.
107
+ MEDIUM = 2
108
+
109
+ #: The light is on high.
110
+ HIGH = 3
111
+
112
+
113
+ class SolixBLEDevice:
114
+ """Solix BLE device object."""
115
+
116
+ def __init__(self, ble_device: BLEDevice) -> None:
117
+ """Initialise device object. Does not connect automatically."""
118
+
119
+ _LOGGER.debug(
120
+ f"Initializing Solix device '{ble_device.name}' with"
121
+ f"address '{ble_device.address}' and details '{ble_device.details}'"
122
+ )
123
+
124
+ self._ble_device: BLEDevice = ble_device
125
+ self._client: BleakClient | None = None
126
+ self._timer_ac: int | None = None
127
+ self._timer_dc: int | None = None
128
+ self._remain_hours: float | None = None
129
+ self._remain_days: int | None = None
130
+ self._power_ac_in: int | None = None
131
+ self._power_ac_out: int | None = None
132
+ self._power_usb_c1: int | None = None
133
+ self._power_usb_c2: int | None = None
134
+ self._power_usb_c3: int | None = None
135
+ self._power_usb_a1: int | None = None
136
+ self._power_dc_out: int | None = None
137
+ self._power_solar_in: int | None = None
138
+ self._power_in: int | None = None
139
+ self._power_out: int | None = None
140
+ self._status_solar: int | None = None
141
+ self._battery_percentage: int | None = None
142
+ self._status_usb_c1: int | None = None
143
+ self._status_usb_c2: int | None = None
144
+ self._status_usb_c3: int | None = None
145
+ self._status_usb_a1: int | None = None
146
+ self._status_dc_out: int | None = None
147
+ self._status_light: int | None = None
148
+ self._data: bytes | None = None
149
+ self._last_data_timestamp: datetime | None = None
150
+ self._supports_telemetry: bool = False
151
+ self._state_changed_callbacks: list[Callable[[], None]] = []
152
+ self._reconnect_task: asyncio.Task | None = None
153
+ self._expect_disconnect: bool = True
154
+ self._connection_attempts: int = 0
155
+
156
+ def add_callback(self, function: Callable[[], None]) -> None:
157
+ """Register a callback to be run on state updates.
158
+
159
+ Triggers include changes to pretty much anything, including,
160
+ battery percentage, output power, solar, connection status, etc.
161
+
162
+ :param function: Function to run on state changes.
163
+ """
164
+ self._state_changed_callbacks.append(function)
165
+
166
+ def remove_callback(self, function: Callable[[], None]) -> None:
167
+ """Remove a registered state change callback.
168
+
169
+ :param function: Function to remove from callbacks.
170
+ :raises ValueError: If callback does not exist.
171
+ """
172
+ self._state_changed_callbacks.remove(function)
173
+
174
+ async def connect(self, max_attempts: int = 3, run_callbacks: bool = True) -> bool:
175
+ """Connect to device.
176
+
177
+ This will connect to the device, determine if it is supported
178
+ and subscribe to status updates, returning True if successful.
179
+
180
+ :param max_attempts: Maximum number of attempts to try to connect (default=3).
181
+ :param run_callbacks: Execute registered callbacks on successful connection (default=True).
182
+ """
183
+
184
+ # If we are not connected then connect
185
+ if not self.connected:
186
+ self._connection_attempts += 1
187
+ _LOGGER.debug(
188
+ f"Connecting to '{self.name}' with address '{self.address}'..."
189
+ )
190
+
191
+ try:
192
+ # Make a new Bleak client and connect
193
+ self._client = await establish_connection(
194
+ BleakClient,
195
+ device=self._ble_device,
196
+ name=self.address,
197
+ max_attempts=max_attempts,
198
+ disconnected_callback=self._disconnect_callback,
199
+ )
200
+
201
+ except BleakError as e:
202
+ _LOGGER.error(f"Error connecting to '{self.name}'. E: '{e}'")
203
+
204
+ # If we are still not connected then we have failed
205
+ if not self.connected:
206
+ _LOGGER.error(
207
+ f"Failed to connect to '{self.name}' on attempt {self._connection_attempts}!"
208
+ )
209
+ return False
210
+
211
+ _LOGGER.debug(f"Connected to '{self.name}'")
212
+
213
+ # If we are not subscribed to telemetry then check that
214
+ # we can and then subscribe
215
+ if not self.available:
216
+ try:
217
+ await self._determine_services()
218
+ await self._subscribe_to_services()
219
+
220
+ except BleakError as e:
221
+ _LOGGER.error(f"Error subscribing to '{self.name}'. E: '{e}'")
222
+ return False
223
+
224
+ # If we are still not subscribed to telemetry then we have failed
225
+ if not self.available:
226
+ return False
227
+
228
+ # Else we have succeeded
229
+ self._expect_disconnect = False
230
+ self._connection_attempts = 0
231
+
232
+ # Execute callbacks if enabled
233
+ if run_callbacks:
234
+ self._run_state_changed_callbacks()
235
+
236
+ return True
237
+
238
+ async def disconnect(self) -> None:
239
+ """Disconnect from device.
240
+
241
+ Disconnects from device and does not execute callbacks.
242
+ """
243
+ self._expect_disconnect = True
244
+
245
+ # If there is a client disconnect and throw it away
246
+ if self._client:
247
+ self._client.disconnect()
248
+ self._client = None
249
+
250
+ @property
251
+ def connected(self) -> bool:
252
+ """Connected to device.
253
+
254
+ :returns: True/False if connected to device.
255
+ """
256
+ return self._client is not None and self._client.is_connected
257
+
258
+ @property
259
+ def available(self) -> bool:
260
+ """Connected to device and receiving data from it.
261
+
262
+ :returns: True/False if the device is connected and sending telemetry.
263
+ """
264
+ return self.connected and self.supports_telemetry
265
+
266
+ @property
267
+ def address(self) -> str:
268
+ """MAC address of device.
269
+
270
+ :returns: The Bluetooth MAC address of the device.
271
+ """
272
+ return self._ble_device.address
273
+
274
+ @property
275
+ def name(self) -> str:
276
+ """Bluetooth name of the device.
277
+
278
+ :returns: The name of the device or default string value.
279
+ """
280
+ return self._ble_device.name or DEFAULT_METADATA_STRING
281
+
282
+ @property
283
+ def supports_telemetry(self) -> bool:
284
+ """Device supports the libraries telemetry standard.
285
+
286
+ :returns: True/False if telemetry supported.
287
+ """
288
+ return self._supports_telemetry
289
+
290
+ @property
291
+ def last_update(self) -> datetime | None:
292
+ """Timestamp of last telemetry data update from device.
293
+
294
+ :returns: Timestamp of last update or None.
295
+ """
296
+ return self._last_data_timestamp
297
+
298
+ @property
299
+ def ac_timer_remaining(self) -> int:
300
+ """Time remaining on AC timer.
301
+
302
+ :returns: Seconds remaining or default int value.
303
+ """
304
+ return self._timer_ac if self._timer_ac is not None else DEFAULT_METADATA_INT
305
+
306
+ @property
307
+ def ac_timer(self) -> datetime | None:
308
+ """Timestamp of AC timer.
309
+
310
+ :returns: Timestamp of when AC timer expires or None.
311
+ """
312
+ if self._timer_ac is None or self._timer_ac == 0:
313
+ return None
314
+ return datetime.now() + timedelta(seconds=self._timer_ac)
315
+
316
+ @property
317
+ def dc_timer_remaining(self) -> int:
318
+ """Time remaining on DC timer.
319
+
320
+ :returns: Seconds remaining or default int value.
321
+ """
322
+ return self._timer_dc if self._timer_dc is not None else DEFAULT_METADATA_INT
323
+
324
+ @property
325
+ def dc_timer(self) -> datetime | None:
326
+ """Timestamp of DC timer.
327
+
328
+ :returns: Timestamp of when DC timer expires or None.
329
+ """
330
+ if self._timer_dc is None or self._timer_dc == 0:
331
+ return None
332
+ return datetime.now() + timedelta(seconds=self._timer_dc)
333
+
334
+ @property
335
+ def hours_remaining(self) -> float:
336
+ """Time remaining to full/empty.
337
+
338
+ Note that any hours over 24 are overflowed to the
339
+ days remaining. Use time_remaining if you want
340
+ days to be included.
341
+
342
+ :returns: Hours remaining or default float value.
343
+ """
344
+ return (
345
+ self._remain_hours
346
+ if self._remain_hours is not None
347
+ else DEFAULT_METADATA_INT
348
+ )
349
+
350
+ @property
351
+ def days_remaining(self) -> int:
352
+ """Time remaining to full/empty.
353
+
354
+ :returns: Days remaining or default int value.
355
+ """
356
+ return (
357
+ self._remain_days if self._remain_days is not None else DEFAULT_METADATA_INT
358
+ )
359
+
360
+ @property
361
+ def time_remaining(self) -> float:
362
+ """Time remaining to full/empty.
363
+
364
+ This includes any hours which were overflowed
365
+ into days.
366
+
367
+ :returns: Hours remaining or default float value.
368
+ """
369
+ if self._remain_hours is None or self._remain_days is None:
370
+ return DEFAULT_METADATA_FLOAT
371
+
372
+ return (self._remain_days * 24) + self._remain_hours
373
+
374
+ @property
375
+ def timestamp_remaining(self) -> datetime | None:
376
+ """Timestamp of when device will be full/empty.
377
+
378
+ :returns: Timestamp of when will be full/empty or None.
379
+ """
380
+ if self._remain_hours is None or self._remain_days is None:
381
+ return None
382
+ return datetime.now() + timedelta(
383
+ days=self._remain_days, hours=self._remain_hours
384
+ )
385
+
386
+ @property
387
+ def ac_power_in(self) -> int:
388
+ """AC Power In.
389
+
390
+ :returns: Total AC power in or default int value.
391
+ """
392
+ return (
393
+ self._power_ac_in if self._power_ac_in is not None else DEFAULT_METADATA_INT
394
+ )
395
+
396
+ @property
397
+ def ac_power_out(self) -> int:
398
+ """AC Power Out.
399
+
400
+ :returns: Total AC power out or default int value.
401
+ """
402
+ return (
403
+ self._power_ac_out
404
+ if self._power_ac_out is not None
405
+ else DEFAULT_METADATA_INT
406
+ )
407
+
408
+ @property
409
+ def usb_c1_power(self) -> int:
410
+ """USB C1 Power.
411
+
412
+ :returns: USB port C1 power or default int value.
413
+ """
414
+ return (
415
+ self._power_usb_c1
416
+ if self._power_usb_c1 is not None
417
+ else DEFAULT_METADATA_INT
418
+ )
419
+
420
+ @property
421
+ def usb_c2_power(self) -> int:
422
+ """USB C2 Power.
423
+
424
+ :returns: USB port C2 power or default int value.
425
+ """
426
+ return (
427
+ self._power_usb_c2
428
+ if self._power_usb_c2 is not None
429
+ else DEFAULT_METADATA_INT
430
+ )
431
+
432
+ @property
433
+ def usb_c3_power(self) -> int:
434
+ """USB C3 Power.
435
+
436
+ :returns: USB port C3 power or default int value.
437
+ """
438
+ return (
439
+ self._power_usb_c3
440
+ if self._power_usb_c3 is not None
441
+ else DEFAULT_METADATA_INT
442
+ )
443
+
444
+ @property
445
+ def usb_a1_power(self) -> int:
446
+ """USB A1 Power.
447
+
448
+ :returns: USB port A1 power or default int value.
449
+ """
450
+ return (
451
+ self._power_usb_a1
452
+ if self._power_usb_a1 is not None
453
+ else DEFAULT_METADATA_INT
454
+ )
455
+
456
+ @property
457
+ def dc_power_out(self) -> int:
458
+ """DC Power Out.
459
+
460
+ :returns: DC power out or default int value.
461
+ """
462
+ return (
463
+ self._power_dc_out
464
+ if self._power_ac_out is not None
465
+ else DEFAULT_METADATA_INT
466
+ )
467
+
468
+ @property
469
+ def solar_power_in(self) -> int:
470
+ """Solar Power In.
471
+
472
+ :returns: Total solar power in or default int value.
473
+ """
474
+ return (
475
+ self._power_solar_in
476
+ if self._power_solar_in is not None
477
+ else DEFAULT_METADATA_INT
478
+ )
479
+
480
+ @property
481
+ def power_in(self) -> int:
482
+ """Total Power In.
483
+
484
+ :returns: Total power in or default int value.
485
+ """
486
+ return self._power_in if self._power_in is not None else DEFAULT_METADATA_INT
487
+
488
+ @property
489
+ def power_out(self) -> int:
490
+ """Total Power Out.
491
+
492
+ :returns: Total power out or default int value.
493
+ """
494
+ return self._power_out if self._power_out is not None else DEFAULT_METADATA_INT
495
+
496
+ @property
497
+ def solar_port(self) -> PortStatus:
498
+ """Solar Port Status.
499
+
500
+ :returns: Status of the solar port.
501
+ """
502
+ return PortStatus(
503
+ self._status_solar
504
+ if self._status_solar is not None
505
+ else DEFAULT_METADATA_INT
506
+ )
507
+
508
+ @property
509
+ def battery_percentage(self) -> int:
510
+ """Battery Percentage.
511
+
512
+ :returns: Percentage charge of battery or default int value.
513
+ """
514
+ return (
515
+ self._battery_percentage
516
+ if self._battery_percentage is not None
517
+ else DEFAULT_METADATA_INT
518
+ )
519
+
520
+ @property
521
+ def usb_port_c1(self) -> PortStatus:
522
+ """USB C1 Port Status.
523
+
524
+ :returns: Status of the USB C1 port.
525
+ """
526
+ return PortStatus(
527
+ self._status_usb_c1
528
+ if self._status_usb_c1 is not None
529
+ else DEFAULT_METADATA_INT
530
+ )
531
+
532
+ @property
533
+ def usb_port_c2(self) -> PortStatus:
534
+ """USB C2 Port Status.
535
+
536
+ :returns: Status of the USB C2 port.
537
+ """
538
+ return PortStatus(
539
+ self._status_usb_c2
540
+ if self._status_usb_c2 is not None
541
+ else DEFAULT_METADATA_INT
542
+ )
543
+
544
+ @property
545
+ def usb_port_c3(self) -> PortStatus:
546
+ """USB C3 Port Status.
547
+
548
+ :returns: Status of the USB C3 port.
549
+ """
550
+ return PortStatus(
551
+ self._status_usb_c3
552
+ if self._status_usb_c3 is not None
553
+ else DEFAULT_METADATA_INT
554
+ )
555
+
556
+ @property
557
+ def usb_port_a1(self) -> PortStatus:
558
+ """USB A1 Port Status.
559
+
560
+ :returns: Status of the USB A1 port.
561
+ """
562
+ return PortStatus(
563
+ self._status_usb_a1
564
+ if self._status_usb_a1 is not None
565
+ else DEFAULT_METADATA_INT
566
+ )
567
+
568
+ @property
569
+ def dc_port(self) -> PortStatus:
570
+ """DC Port Status.
571
+
572
+ :returns: Status of the DC port.
573
+ """
574
+ return PortStatus(
575
+ self._status_dc_out
576
+ if self._status_dc_out is not None
577
+ else DEFAULT_METADATA_INT
578
+ )
579
+
580
+ @property
581
+ def light(self) -> LightStatus:
582
+ """Light Status.
583
+
584
+ :returns: Status of the light bar.
585
+ """
586
+ return LightStatus(
587
+ self._status_light
588
+ if self._status_light is not None
589
+ else DEFAULT_METADATA_INT
590
+ )
591
+
592
+ async def _determine_services(self) -> None:
593
+ """Determine GATT services available on the device."""
594
+
595
+ # Print services
596
+ services = self._client.services
597
+ for service_id, service in services.services.items():
598
+ _LOGGER.debug(
599
+ f"ID: {service_id} Service: {service}, description: {service.description}"
600
+ )
601
+
602
+ if service.characteristics is None:
603
+ continue
604
+
605
+ for char in service.characteristics:
606
+ _LOGGER.debug(
607
+ f"Characteristic: {char}, "
608
+ f"description: {char.description}, "
609
+ f"descriptors: {char.descriptors}"
610
+ )
611
+
612
+ # Populate supported services
613
+ self._supports_telemetry = bool(services.get_characteristic(UUID_TELEMETRY))
614
+ if not self._supports_telemetry:
615
+ _LOGGER.warning(
616
+ f"Device '{self.name}' does not support the telemetry characteristic!"
617
+ )
618
+
619
+ def _parse_int(self, index: int) -> int:
620
+ """Parse a 16-bit integer at the index in the telemetry bytes.
621
+
622
+ :param index: Index of 16-bit integer in array.
623
+ :returns: 16-bit integer.
624
+ :raises IndexError: If index is out of range.
625
+ """
626
+ return int.from_bytes(self._data[index : index + 2], byteorder="little")
627
+
628
+ def _parse_telemetry(self, data: bytearray) -> None:
629
+ """Update internal values using the telemetry data.
630
+
631
+ :param data: Bytes from status update message.
632
+ """
633
+
634
+ # If the size is wrong then it is not a telemetry message
635
+ if len(data) != EXPECTED_TELEMETRY_SIZE:
636
+ _LOGGER.debug(
637
+ f"Data is not telemetry data. The size is wrong ({len(data)} != {EXPECTED_TELEMETRY_SIZE})"
638
+ )
639
+ return
640
+
641
+ self._data = data
642
+ self._last_data_timestamp = datetime.now()
643
+ self._timer_ac = self._parse_int(16)
644
+ self._timer_dc = self._parse_int(23)
645
+ self._remain_hours = data[30] / 10.0
646
+ self._remain_days = data[31]
647
+ self._power_ac_in = self._parse_int(35)
648
+ self._power_ac_out = self._parse_int(40)
649
+ self._power_usb_c1 = data[45]
650
+ self._power_usb_c2 = data[50]
651
+ self._power_usb_c3 = data[55]
652
+ self._power_usb_a1 = data[60]
653
+ self._power_dc_out = data[65]
654
+ self._power_solar_in = self._parse_int(70)
655
+ self._power_in = self._parse_int(75)
656
+ self._power_out = self._parse_int(80)
657
+ self._status_solar = data[129]
658
+ self._battery_percentage = data[141]
659
+ self._status_usb_c1 = data[149]
660
+ self._status_usb_c2 = data[153]
661
+ self._status_usb_c3 = data[157]
662
+ self._status_usb_a1 = data[161]
663
+ self._status_dc_out = data[165]
664
+ self._status_light = data[241]
665
+
666
+ _LOGGER.debug(
667
+ f"\n===== STATUS UPDATE ({self.name}) =====\n"
668
+ f"TIMER AC: {self._timer_ac}\n"
669
+ f"TIMER DC: {self._timer_dc}\n"
670
+ f"REMAINING HOURS: {self._remain_hours}\n"
671
+ f"REMAINING DAYS: {self._remain_days}\n"
672
+ f"POWER AC IN: {self._power_ac_in}\n"
673
+ f"POWER AC OUT: {self._power_ac_out}\n"
674
+ f"POWER USB C1: {self._power_usb_c1}\n"
675
+ f"POWER USB C2: {self._power_usb_c2}\n"
676
+ f"POWER USB C3: {self._power_usb_c3}\n"
677
+ f"POWER USB A1: {self._power_usb_a1}\n"
678
+ f"POWER DC OUT: {self._power_dc_out}\n"
679
+ f"POWER SOLAR IN: {self._power_solar_in}\n"
680
+ f"POWER IN: {self._power_in}\n"
681
+ f"POWER OUT: {self._power_out}\n"
682
+ f"STATUS SOLAR: {self._status_solar}\n"
683
+ f"BATTERY PERCENTAGE: {self._battery_percentage}\n"
684
+ f"STATUS USB C1: {self._status_usb_c1}\n"
685
+ f"STATUS USB C2: {self._status_usb_c2}\n"
686
+ f"STATUS USB C3: {self._status_usb_c3}\n"
687
+ f"STATUS USB A1: {self._status_usb_a1}\n"
688
+ f"STATUS DC OUT: {self._status_dc_out}\n"
689
+ f"STATUS LIGHT: {self._status_light}"
690
+ )
691
+
692
+ def _run_state_changed_callbacks(self) -> None:
693
+ """Execute all registered callbacks for a state change."""
694
+ for function in self._state_changed_callbacks:
695
+ function()
696
+
697
+ async def _subscribe_to_services(self) -> None:
698
+ """Subscribe to state updates from device."""
699
+ if self._supports_telemetry:
700
+
701
+ def _telemetry_update(handle: int, data: bytearray) -> None:
702
+ """Update internal state and run callbacks."""
703
+ _LOGGER.debug(f"Received notification from '{self.name}'")
704
+ self._parse_telemetry(data)
705
+ self._run_state_changed_callbacks()
706
+
707
+ await self._client.start_notify(UUID_TELEMETRY, _telemetry_update)
708
+
709
+ async def _reconnect(self) -> None:
710
+ """Re-connect to device and run state change callbacks on timeout/failure."""
711
+ try:
712
+ async with asyncio.timeout(DISCONNECT_TIMEOUT):
713
+ await asyncio.sleep(RECONNECT_DELAY)
714
+ await self.connect(run_callbacks=False)
715
+ if self.available:
716
+ _LOGGER.debug(f"Successfully re-connected to '{self.name}'")
717
+
718
+ except TimeoutError as e:
719
+ _LOGGER.error(f"Failed to re-connect to '{self.name}'. E: '{e}'")
720
+ self._run_state_changed_callbacks()
721
+
722
+ def _disconnect_callback(self, client: BaseBleakClient) -> None:
723
+ """Re-connect on unexpected disconnect and run callbacks on failure.
724
+
725
+ This function will re-connect if this is not an expected
726
+ disconnect and if it fails to re-connect it will run
727
+ state changed callbacks. If the re-connect is successful then
728
+ no callbacks are executed.
729
+
730
+ :param client: Bleak client.
731
+ """
732
+
733
+ # Ignore disconnect callbacks from old clients
734
+ if client != self._client:
735
+ return
736
+
737
+ # Reset to false to ensure we
738
+ self._supports_telemetry = False
739
+
740
+ # If we expected the disconnect then we don't try to reconnect.
741
+ if self._expect_disconnect:
742
+ _LOGGER.info(f"Received expected disconnect from '{client}'.")
743
+ return
744
+
745
+ # Else we did not expect the disconnect and must re-connect if
746
+ # there are attempts remaining
747
+ _LOGGER.debug(f"Unexpected disconnect from '{client}'.")
748
+ if (
749
+ RECONNECT_ATTEMPTS_MAX == -1
750
+ or self._connection_attempts < RECONNECT_ATTEMPTS_MAX
751
+ ):
752
+ # Try and reconnect
753
+ self._reconnect_task = asyncio.create_task(self._reconnect())
754
+
755
+ else:
756
+ _LOGGER.warning(
757
+ f"Maximum re-connect attempts to '{client}' exceeded. Auto re-connect disabled!"
758
+ )
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "SolixBLE"
3
+ version = "1.0.0"
4
+ dependencies = [
5
+ "bleak>=0.19.0",
6
+ "bleak-retry-connector"
7
+ ]
8
+ requires-python = ">= 3.11"
9
+ authors = [
10
+ { name="Harvey Lelliott", email="harveylelliott@duck.com" },
11
+ ]
12
+ description = "Python module for monitoring Bluetooth Anker Solix devices"
13
+ readme = "README.md"
14
+ license = {file = "LICENSE.txt"}
15
+ keywords = ["Anker", "Solix", "BLE", "Home Assistant"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "Topic :: Home Automation",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Framework :: AsyncIO",
22
+ "Operating System :: Microsoft :: Windows :: Windows 10",
23
+ "Operating System :: POSIX :: Linux",
24
+ "Operating System :: MacOS :: MacOS X",
25
+ "Programming Language :: Python :: 3.11"
26
+ ]
27
+
28
+ [tool.setuptools]
29
+ py-modules = ['SolixBLE']
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/flip-dots/SolixBLE"
33
+ Repository = "https://github.com/flip-dots/SolixBLE"
34
+ Issues = "https://github.com/flip-dots/SolixBLE/issues"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+