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.
- solixble-1.0.0/LICENSE.txt +21 -0
- solixble-1.0.0/PKG-INFO +121 -0
- solixble-1.0.0/README.md +75 -0
- solixble-1.0.0/SolixBLE.egg-info/PKG-INFO +121 -0
- solixble-1.0.0/SolixBLE.egg-info/SOURCES.txt +9 -0
- solixble-1.0.0/SolixBLE.egg-info/dependency_links.txt +1 -0
- solixble-1.0.0/SolixBLE.egg-info/requires.txt +2 -0
- solixble-1.0.0/SolixBLE.egg-info/top_level.txt +1 -0
- solixble-1.0.0/SolixBLE.py +758 -0
- solixble-1.0.0/pyproject.toml +34 -0
- solixble-1.0.0/setup.cfg +4 -0
|
@@ -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.
|
solixble-1.0.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://pypi.python.org/pypi/SolixBLE)
|
|
50
|
+
[](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
|
+
```
|
solixble-1.0.0/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# SolixBLE
|
|
2
|
+
|
|
3
|
+
[](https://pypi.python.org/pypi/SolixBLE)
|
|
4
|
+
[](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
|
+
[](https://pypi.python.org/pypi/SolixBLE)
|
|
50
|
+
[](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 @@
|
|
|
1
|
+
|
|
@@ -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"
|
solixble-1.0.0/setup.cfg
ADDED