SolixBLE 3.7.0__tar.gz → 3.9.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-3.7.0 → solixble-3.9.0}/PKG-INFO +8 -1
- {solixble-3.7.0 → solixble-3.9.0}/README.md +7 -0
- {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/__init__.py +7 -1
- {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/device.py +133 -42
- {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/devices/__init__.py +6 -0
- {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/devices/c1000.py +19 -0
- {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/devices/c1000g2.py +102 -20
- {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/devices/c300dc.py +107 -1
- solixble-3.9.0/SolixBLE/devices/f2600.py +501 -0
- solixble-3.9.0/SolixBLE/devices/maggo_3in1.py +124 -0
- solixble-3.9.0/SolixBLE/devices/prime_charger_250w.py +477 -0
- solixble-3.9.0/SolixBLE/devices/prime_power_bank_20k.py +173 -0
- {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/prime_device.py +3 -1
- {solixble-3.7.0 → solixble-3.9.0}/SolixBLE.egg-info/PKG-INFO +8 -1
- {solixble-3.7.0 → solixble-3.9.0}/SolixBLE.egg-info/SOURCES.txt +3 -0
- {solixble-3.7.0 → solixble-3.9.0}/pyproject.toml +8 -1
- {solixble-3.7.0 → solixble-3.9.0}/tests/test_devices.py +807 -0
- solixble-3.7.0/SolixBLE/devices/prime_charger_250w.py +0 -278
- {solixble-3.7.0 → solixble-3.9.0}/LICENSE.txt +0 -0
- {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/const.py +0 -0
- {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/devices/c300.py +0 -0
- {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/devices/c800.py +0 -0
- {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/devices/f2000.py +0 -0
- {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/devices/f3800.py +0 -0
- {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/devices/generic.py +0 -0
- {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/devices/prime_charger_160w.py +0 -0
- {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/devices/solarbank2.py +0 -0
- {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/devices/solarbank3.py +0 -0
- {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/states.py +0 -0
- {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/utilities.py +0 -0
- {solixble-3.7.0 → solixble-3.9.0}/SolixBLE.egg-info/dependency_links.txt +0 -0
- {solixble-3.7.0 → solixble-3.9.0}/SolixBLE.egg-info/requires.txt +0 -0
- {solixble-3.7.0 → solixble-3.9.0}/SolixBLE.egg-info/top_level.txt +0 -0
- {solixble-3.7.0 → solixble-3.9.0}/setup.cfg +0 -0
- {solixble-3.7.0 → solixble-3.9.0}/tests/test_connection.py +0 -0
- {solixble-3.7.0 → solixble-3.9.0}/tests/test_prime.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: SolixBLE
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.9.0
|
|
4
4
|
Summary: Python module for monitoring & controlling Bluetooth Anker Solix devices
|
|
5
5
|
Author-email: Harvey Lelliott <harveylelliott@duck.com>
|
|
6
6
|
License: MIT License
|
|
@@ -97,11 +97,13 @@ See the [support table](https://solixble.readthedocs.io/en/latest) in the docume
|
|
|
97
97
|
- C1000(X)
|
|
98
98
|
- C1000 Gen 2
|
|
99
99
|
- F2000 (767 PowerHouse)
|
|
100
|
+
- F2600
|
|
100
101
|
- F3800
|
|
101
102
|
- Solarbank 2
|
|
102
103
|
- Solarbank 3
|
|
103
104
|
- Prime Charger 160w
|
|
104
105
|
- Prime Charger 250w
|
|
106
|
+
- Prime MagGo 3in1 (A25X7)
|
|
105
107
|
- Potentially more!
|
|
106
108
|
|
|
107
109
|
|
|
@@ -138,3 +140,8 @@ pip install SolixBLE
|
|
|
138
140
|
See the `Generic` class inside `SolixBLE/devices/generic.py` and the
|
|
139
141
|
[documentation](https://solixble.readthedocs.io/en/latest/new_devices.html)
|
|
140
142
|
for guidance on how to add support for new devices.
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
## Disclaimer
|
|
146
|
+
|
|
147
|
+
SolixBLE is a software library designed to work with Anker Solix/Prime devices. ANKER is a registered trademark of Anker Innovations Limited. This project is not affiliated with, endorsed by, or sponsored by Anker Innovations Limited (Though I wouldn't mind being sponsored 😉). All other trademarks cited herein are the property of their respective owners.
|
|
@@ -48,11 +48,13 @@ See the [support table](https://solixble.readthedocs.io/en/latest) in the docume
|
|
|
48
48
|
- C1000(X)
|
|
49
49
|
- C1000 Gen 2
|
|
50
50
|
- F2000 (767 PowerHouse)
|
|
51
|
+
- F2600
|
|
51
52
|
- F3800
|
|
52
53
|
- Solarbank 2
|
|
53
54
|
- Solarbank 3
|
|
54
55
|
- Prime Charger 160w
|
|
55
56
|
- Prime Charger 250w
|
|
57
|
+
- Prime MagGo 3in1 (A25X7)
|
|
56
58
|
- Potentially more!
|
|
57
59
|
|
|
58
60
|
|
|
@@ -89,3 +91,8 @@ pip install SolixBLE
|
|
|
89
91
|
See the `Generic` class inside `SolixBLE/devices/generic.py` and the
|
|
90
92
|
[documentation](https://solixble.readthedocs.io/en/latest/new_devices.html)
|
|
91
93
|
for guidance on how to add support for new devices.
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
## Disclaimer
|
|
97
|
+
|
|
98
|
+
SolixBLE is a software library designed to work with Anker Solix/Prime devices. ANKER is a registered trademark of Anker Innovations Limited. This project is not affiliated with, endorsed by, or sponsored by Anker Innovations Limited (Though I wouldn't mind being sponsored 😉). All other trademarks cited herein are the property of their respective owners.
|
|
@@ -12,10 +12,13 @@ from .devices import (
|
|
|
12
12
|
C1000,
|
|
13
13
|
C1000G2,
|
|
14
14
|
F2000,
|
|
15
|
+
F2600,
|
|
15
16
|
F3800,
|
|
16
17
|
Generic,
|
|
18
|
+
MagGo3in1,
|
|
17
19
|
PrimeCharger160w,
|
|
18
20
|
PrimeCharger250w,
|
|
21
|
+
PrimePowerBank20k,
|
|
19
22
|
Solarbank2,
|
|
20
23
|
Solarbank3,
|
|
21
24
|
)
|
|
@@ -25,9 +28,9 @@ from .states import (
|
|
|
25
28
|
ChargingStatusF3800,
|
|
26
29
|
DisplayTimeout,
|
|
27
30
|
LightStatus,
|
|
31
|
+
PortOverload,
|
|
28
32
|
PortStatus,
|
|
29
33
|
TemperatureUnit,
|
|
30
|
-
PortOverload,
|
|
31
34
|
)
|
|
32
35
|
from .utilities import discover_devices
|
|
33
36
|
|
|
@@ -40,11 +43,14 @@ __all__ = [
|
|
|
40
43
|
"C1000",
|
|
41
44
|
"C1000G2",
|
|
42
45
|
"F2000",
|
|
46
|
+
"F2600",
|
|
43
47
|
"F3800",
|
|
44
48
|
"Solarbank2",
|
|
45
49
|
"Solarbank3",
|
|
46
50
|
"PrimeCharger160w",
|
|
47
51
|
"PrimeCharger250w",
|
|
52
|
+
"PrimePowerBank20k",
|
|
53
|
+
"MagGo3in1",
|
|
48
54
|
"Generic",
|
|
49
55
|
"ChargingStatus",
|
|
50
56
|
"ChargingStatusF3800",
|
|
@@ -52,6 +52,11 @@ _LOGGER = logging.getLogger(__name__)
|
|
|
52
52
|
class SolixBLEDevice:
|
|
53
53
|
"""Solix BLE device object."""
|
|
54
54
|
|
|
55
|
+
#: Command codes (hex) that carry telemetry for this device. Subclasses can
|
|
56
|
+
#: override this if their model uses different telemetry command codes
|
|
57
|
+
#: (e.g the C1000 Gen 2 uses ``c421``/``c900`` instead of ``c402``/``c405``).
|
|
58
|
+
_TELEMETRY_COMMANDS: tuple[str, ...] = ("c402", "4300", "c405")
|
|
59
|
+
|
|
55
60
|
def __init__(self, ble_device: BLEDevice) -> None:
|
|
56
61
|
"""Initialise device object. Does not connect automatically."""
|
|
57
62
|
|
|
@@ -71,6 +76,7 @@ class SolixBLEDevice:
|
|
|
71
76
|
self._state_changed_callbacks: list[Callable[[], None]] = []
|
|
72
77
|
self._packet_futures: dict[bytes, list[asyncio.Future]] = {}
|
|
73
78
|
self._auto_reconnect_task: asyncio.Task | None = None
|
|
79
|
+
self._keep_alive_task: asyncio.Task | None = None
|
|
74
80
|
self._disconnect_event: asyncio.Event = asyncio.Event()
|
|
75
81
|
self._connection_attempts: int = 0
|
|
76
82
|
self._shared_secret: bytes | None = None
|
|
@@ -195,16 +201,60 @@ class SolixBLEDevice:
|
|
|
195
201
|
if self._disconnect_event.is_set():
|
|
196
202
|
self._disconnect_event.clear()
|
|
197
203
|
|
|
204
|
+
# Run any device-specific post-connect setup (e.g sending a subscribe
|
|
205
|
+
# command to start telemetry). This runs on every (re)connection. Errors
|
|
206
|
+
# are logged but do not abort the connection; the automatic reconnect
|
|
207
|
+
# task will retry.
|
|
208
|
+
try:
|
|
209
|
+
await self._post_connect()
|
|
210
|
+
except Exception:
|
|
211
|
+
_LOGGER.exception(f"Error running post-connect setup for '{self.name}'!")
|
|
212
|
+
|
|
198
213
|
# Start an automatic reconnect task if its not running already
|
|
199
214
|
if self._auto_reconnect_task is None:
|
|
200
215
|
self._auto_reconnect_task = asyncio.create_task(self._auto_reconnect())
|
|
201
216
|
|
|
217
|
+
# Start a keep-alive task if its not running already
|
|
218
|
+
if self._keep_alive_task is None:
|
|
219
|
+
self._keep_alive_task = asyncio.create_task(self._keep_alive_fn())
|
|
220
|
+
|
|
202
221
|
# Execute callbacks if enabled
|
|
203
222
|
if run_callbacks:
|
|
204
223
|
self._run_state_changed_callbacks()
|
|
205
224
|
|
|
206
225
|
return True
|
|
207
226
|
|
|
227
|
+
async def _post_connect(self) -> None:
|
|
228
|
+
"""Run device-specific setup after a negotiated connection is established.
|
|
229
|
+
|
|
230
|
+
Called by :meth:`connect` once the encrypted session has been negotiated
|
|
231
|
+
(so :meth:`_send_command` may be used) and on every automatic reconnect.
|
|
232
|
+
The default implementation does nothing; subclasses can override it to,
|
|
233
|
+
for example, send a subscribe command to start a telemetry stream (see
|
|
234
|
+
:class:`~SolixBLE.devices.c1000g2.C1000G2`).
|
|
235
|
+
"""
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
async def _keep_alive(self) -> int | None:
|
|
239
|
+
"""Execute designated keep-alive command periodically after good negotiation.
|
|
240
|
+
|
|
241
|
+
Use this to execute code periodically that is needed to keep the
|
|
242
|
+
connection. For example some devices need a special keep alive command
|
|
243
|
+
to keep receiving telemetry updates that must be sent every ~10 seconds.
|
|
244
|
+
|
|
245
|
+
This is automatically executed in a task created by :meth:`connect` once
|
|
246
|
+
the encrypted session has been negotiated (so :meth:`_send_command`
|
|
247
|
+
may be used) and on every automatic reconnect, with it not being
|
|
248
|
+
executed when not connected or negotiated.
|
|
249
|
+
|
|
250
|
+
The default implementation does nothing; subclasses can override it to,
|
|
251
|
+
for example, send a subscribe command to keep a telemetry stream (see
|
|
252
|
+
:class:`~SolixBLE.devices.prime_charger_250w.PrimeCharger250w`).
|
|
253
|
+
|
|
254
|
+
:returns: Seconds to wait before calling again or None for not implemented.
|
|
255
|
+
"""
|
|
256
|
+
return None
|
|
257
|
+
|
|
208
258
|
async def disconnect(self) -> None:
|
|
209
259
|
"""Disconnect from device and reset internal state.
|
|
210
260
|
|
|
@@ -217,6 +267,10 @@ class SolixBLEDevice:
|
|
|
217
267
|
if self._auto_reconnect_task is not None:
|
|
218
268
|
self._auto_reconnect_task.cancel()
|
|
219
269
|
|
|
270
|
+
# Cancel the keep-alive task
|
|
271
|
+
if self._keep_alive_task is not None:
|
|
272
|
+
self._keep_alive_task.cancel()
|
|
273
|
+
|
|
220
274
|
# If there is a client disconnect and throw it away
|
|
221
275
|
if self._client is not None:
|
|
222
276
|
await self._dispose_of_client()
|
|
@@ -493,7 +547,9 @@ class SolixBLEDevice:
|
|
|
493
547
|
)
|
|
494
548
|
return cipher.encrypt(padded_data)
|
|
495
549
|
|
|
496
|
-
async def _process_telemetry_packet(
|
|
550
|
+
async def _process_telemetry_packet(
|
|
551
|
+
self, payload: bytes, cmd: bytes = None
|
|
552
|
+
) -> None:
|
|
497
553
|
"""Process a telemetry packet from the device.
|
|
498
554
|
|
|
499
555
|
This performs the default processing of telemetry packets in which
|
|
@@ -533,14 +589,12 @@ class SolixBLEDevice:
|
|
|
533
589
|
)
|
|
534
590
|
del self._fragment_buffers[cmd_key]
|
|
535
591
|
del self._fragment_totals[cmd_key]
|
|
536
|
-
_LOGGER.debug(
|
|
537
|
-
f"Reassembled payload: {len(payload)} bytes"
|
|
538
|
-
)
|
|
592
|
+
_LOGGER.debug(f"Reassembled payload: {len(payload)} bytes")
|
|
539
593
|
|
|
540
594
|
else:
|
|
541
595
|
# Strip fragment info
|
|
542
596
|
payload = payload[1:]
|
|
543
|
-
|
|
597
|
+
|
|
544
598
|
decrypted_payload = self._decrypt_payload(payload)
|
|
545
599
|
_LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}")
|
|
546
600
|
parameters = self._parse_payload(decrypted_payload)
|
|
@@ -615,51 +669,55 @@ class SolixBLEDevice:
|
|
|
615
669
|
# Match against common message types
|
|
616
670
|
match pattern.hex():
|
|
617
671
|
|
|
618
|
-
#
|
|
672
|
+
# Negotiation messages
|
|
619
673
|
case "030001":
|
|
620
|
-
_LOGGER.debug("Received
|
|
674
|
+
_LOGGER.debug("Received negotiation message!")
|
|
621
675
|
return await self._process_negotiation(cmd, payload)
|
|
622
676
|
|
|
623
|
-
#
|
|
677
|
+
# Session messages
|
|
624
678
|
case "03010f" | "030111":
|
|
625
679
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
return await self._process_telemetry_packet(payload, cmd)
|
|
632
|
-
|
|
633
|
-
# Unknown messages
|
|
634
|
-
case _:
|
|
635
|
-
_LOGGER.debug(f"Received unknown message of type: {cmd.hex()}")
|
|
636
|
-
try:
|
|
637
|
-
|
|
638
|
-
# If the payload is one byte too short and we are
|
|
639
|
-
# using the default AES (CBC) then try putting the
|
|
640
|
-
# last byte of the cmd in front of it
|
|
641
|
-
if (
|
|
642
|
-
len(payload) % 16 == 15
|
|
643
|
-
and self._decrypt_payload
|
|
644
|
-
is SolixBLEDevice._decrypt_payload
|
|
645
|
-
):
|
|
646
|
-
_LOGGER.debug(
|
|
647
|
-
"Using special trick of embedded part of CMD in payload..."
|
|
648
|
-
)
|
|
649
|
-
payload = cmd[1].to_bytes() + payload
|
|
680
|
+
# Non-encrypted telemetry messages
|
|
681
|
+
if cmd.hex() == "0300":
|
|
682
|
+
_LOGGER.debug("Received non-encrypted telemetry message!")
|
|
683
|
+
parameters = self._parse_payload(payload)
|
|
684
|
+
return await self._process_telemetry(parameters)
|
|
650
685
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
686
|
+
# Encrypted telemetry messages
|
|
687
|
+
elif cmd.hex() in self._TELEMETRY_COMMANDS:
|
|
688
|
+
_LOGGER.debug("Received encrypted telemetry message!")
|
|
689
|
+
return await self._process_telemetry_packet(payload, cmd)
|
|
690
|
+
|
|
691
|
+
# Unknown messages
|
|
692
|
+
else:
|
|
693
|
+
_LOGGER.debug(f"Received unknown message of type: {cmd.hex()}")
|
|
694
|
+
try:
|
|
695
|
+
|
|
696
|
+
# If the payload is one byte too short and we are
|
|
697
|
+
# using the default AES (CBC) then try putting the
|
|
698
|
+
# last byte of the cmd in front of it
|
|
699
|
+
if (
|
|
700
|
+
len(payload) % 16 == 15
|
|
701
|
+
and self._decrypt_payload
|
|
702
|
+
is SolixBLEDevice._decrypt_payload
|
|
703
|
+
):
|
|
656
704
|
_LOGGER.debug(
|
|
657
|
-
|
|
658
|
-
)
|
|
659
|
-
except Exception:
|
|
660
|
-
_LOGGER.exception(
|
|
661
|
-
"Exception decrypting unknown message type"
|
|
705
|
+
"Using special trick of embedded part of CMD in payload..."
|
|
662
706
|
)
|
|
707
|
+
payload = cmd[1].to_bytes() + payload
|
|
708
|
+
|
|
709
|
+
decrypted_payload = self._decrypt_payload(payload)
|
|
710
|
+
_LOGGER.debug(
|
|
711
|
+
f"Decrypted payload: {decrypted_payload.hex()}"
|
|
712
|
+
)
|
|
713
|
+
parameters = self._parse_payload(decrypted_payload)
|
|
714
|
+
_LOGGER.debug(
|
|
715
|
+
f"Parameters: {self._parameters_to_str(parameters, types=True)}"
|
|
716
|
+
)
|
|
717
|
+
except Exception:
|
|
718
|
+
_LOGGER.exception(
|
|
719
|
+
"Exception decrypting unknown message type"
|
|
720
|
+
)
|
|
663
721
|
|
|
664
722
|
case _:
|
|
665
723
|
_LOGGER.warning(
|
|
@@ -996,6 +1054,39 @@ class SolixBLEDevice:
|
|
|
996
1054
|
except Exception:
|
|
997
1055
|
_LOGGER.exception("Unexpected exception in automatic reconnect task!")
|
|
998
1056
|
|
|
1057
|
+
|
|
1058
|
+
async def _keep_alive_fn(self) -> None:
|
|
1059
|
+
"""Task designed to be run in background to execute keep-alive.
|
|
1060
|
+
|
|
1061
|
+
This task is executed automatically when a successful connection
|
|
1062
|
+
is made and will periodically execute the keep alive function if
|
|
1063
|
+
one exists.
|
|
1064
|
+
|
|
1065
|
+
This background task is cancelled when the connection is lost.
|
|
1066
|
+
"""
|
|
1067
|
+
try:
|
|
1068
|
+
while self.negotiated:
|
|
1069
|
+
|
|
1070
|
+
try:
|
|
1071
|
+
_LOGGER.debug("Executing keep-alive...")
|
|
1072
|
+
result = await self._keep_alive()
|
|
1073
|
+
|
|
1074
|
+
if result is None:
|
|
1075
|
+
_LOGGER.debug("No keep-alive task registered, stopping task...")
|
|
1076
|
+
return
|
|
1077
|
+
|
|
1078
|
+
_LOGGER.debug(f"Executing keep-alive in {result}s")
|
|
1079
|
+
await asyncio.sleep(result)
|
|
1080
|
+
|
|
1081
|
+
except Exception:
|
|
1082
|
+
_LOGGER.exception("Exception raised executing keep-alive function!")
|
|
1083
|
+
|
|
1084
|
+
except asyncio.CancelledError:
|
|
1085
|
+
_LOGGER.debug("Keep-alive task has been canceled/stopped")
|
|
1086
|
+
|
|
1087
|
+
except Exception:
|
|
1088
|
+
_LOGGER.exception("Unexpected exception in keep-alive task!")
|
|
1089
|
+
|
|
999
1090
|
def _disconnect_callback(self, client: BaseBleakClient) -> None:
|
|
1000
1091
|
"""Callback executed by bleak when the connection is lost.
|
|
1001
1092
|
|
|
@@ -10,10 +10,13 @@ from .c800 import C800
|
|
|
10
10
|
from .c1000 import C1000
|
|
11
11
|
from .c1000g2 import C1000G2
|
|
12
12
|
from .f2000 import F2000
|
|
13
|
+
from .f2600 import F2600
|
|
13
14
|
from .f3800 import F3800
|
|
14
15
|
from .generic import Generic
|
|
16
|
+
from .maggo_3in1 import MagGo3in1
|
|
15
17
|
from .prime_charger_160w import PrimeCharger160w
|
|
16
18
|
from .prime_charger_250w import PrimeCharger250w
|
|
19
|
+
from .prime_power_bank_20k import PrimePowerBank20k
|
|
17
20
|
from .solarbank2 import Solarbank2
|
|
18
21
|
from .solarbank3 import Solarbank3
|
|
19
22
|
|
|
@@ -24,10 +27,13 @@ __all__ = [
|
|
|
24
27
|
"C1000",
|
|
25
28
|
"C1000G2",
|
|
26
29
|
"F2000",
|
|
30
|
+
"F2600",
|
|
27
31
|
"F3800",
|
|
28
32
|
"Solarbank2",
|
|
29
33
|
"Solarbank3",
|
|
30
34
|
"PrimeCharger160w",
|
|
31
35
|
"PrimeCharger250w",
|
|
36
|
+
"PrimePowerBank20k",
|
|
37
|
+
"MagGo3in1",
|
|
32
38
|
"Generic",
|
|
33
39
|
]
|
|
@@ -186,6 +186,25 @@ class C1000(SolixBLEDevice):
|
|
|
186
186
|
"""
|
|
187
187
|
return self._parse_int("b0", begin=1)
|
|
188
188
|
|
|
189
|
+
@property
|
|
190
|
+
def dc_output(self) -> PortStatus:
|
|
191
|
+
"""DC Port Status.
|
|
192
|
+
|
|
193
|
+
PortStatus.NOT_CONNECTED signifies off.
|
|
194
|
+
PortStatus.OUTPUT signifies on.
|
|
195
|
+
|
|
196
|
+
:returns: Status of the DC output port.
|
|
197
|
+
"""
|
|
198
|
+
return PortStatus(self._parse_int("b2", begin=1, end=2))
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def dc_power_out(self) -> int:
|
|
202
|
+
"""DC Power Out.
|
|
203
|
+
|
|
204
|
+
:returns: DC power out or default int value.
|
|
205
|
+
"""
|
|
206
|
+
return self._parse_int("b2", begin=2)
|
|
207
|
+
|
|
189
208
|
@property
|
|
190
209
|
def software_version(self) -> str:
|
|
191
210
|
"""Main software version.
|
|
@@ -4,33 +4,96 @@
|
|
|
4
4
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
from ..const import DEFAULT_METADATA_BOOL
|
|
8
7
|
from ..device import SolixBLEDevice
|
|
9
8
|
from ..states import PortStatus
|
|
10
9
|
|
|
10
|
+
#: Command sent after connecting to start the telemetry stream. Unlike the gen-1
|
|
11
|
+
#: models, the Gen 2 streams nothing until it receives this subscribe command.
|
|
12
|
+
CMD_SUBSCRIBE = "4100"
|
|
13
|
+
SUBSCRIBE_PAYLOAD = "a10121"
|
|
14
|
+
|
|
15
|
+
CMD_AC_OUTPUT = "4101"
|
|
16
|
+
CMD_DC_OUTPUT = "4102"
|
|
17
|
+
|
|
18
|
+
PAYLOAD_ON = "a10121a2020101"
|
|
19
|
+
PAYLOAD_OFF = "a10121a2020100"
|
|
20
|
+
|
|
11
21
|
|
|
12
22
|
class C1000G2(SolixBLEDevice):
|
|
13
23
|
"""
|
|
14
24
|
C1000(X) Gen 2 Power Station.
|
|
15
25
|
|
|
16
|
-
Use this class to connect and
|
|
17
|
-
This model is also known as the A1763.
|
|
18
|
-
|
|
19
|
-
.. note::
|
|
20
|
-
This model was added using data from anker-solix-api. It has not been
|
|
21
|
-
tested!
|
|
22
|
-
|
|
23
|
-
.. note::
|
|
24
|
-
It should be possible to add more sensors. I think devices with lots of
|
|
25
|
-
telemetry values split them up into multiple messages but I have not
|
|
26
|
-
played around with this yet. That and I am being a bit conservative with
|
|
27
|
-
these initial implementations, if you want more sensors and are willing
|
|
28
|
-
to help with testing feel free to raise a GitHub issue.
|
|
26
|
+
Use this class to connect, monitor and control a Gen 2 C1000(X) power
|
|
27
|
+
station. This model is also known as the A1763.
|
|
29
28
|
|
|
29
|
+
The Gen 2 uses the same encryption and telemetry framing as the gen-1
|
|
30
|
+
models but with different command codes: it must be sent a subscribe command
|
|
31
|
+
(``4100``) after connecting before it streams any telemetry, its telemetry
|
|
32
|
+
arrives on commands ``c421``/``c900``, its AC output is controlled with
|
|
33
|
+
command ``4101`` and its DC output with command ``4102``. Telemetry and
|
|
34
|
+
AC/DC on/off control have been confirmed on real hardware.
|
|
30
35
|
"""
|
|
31
36
|
|
|
32
37
|
_EXPECTED_TELEMETRY_LENGTH: int = 253
|
|
33
38
|
|
|
39
|
+
#: The Gen 2 pushes telemetry on different command codes to the gen-1 models.
|
|
40
|
+
_TELEMETRY_COMMANDS: tuple[str, ...] = ("c421", "c900")
|
|
41
|
+
|
|
42
|
+
async def _post_connect(self) -> None:
|
|
43
|
+
"""Subscribe to telemetry once connected.
|
|
44
|
+
|
|
45
|
+
The Gen 2 streams no telemetry until it receives this command, so we send
|
|
46
|
+
it after every (re)connection.
|
|
47
|
+
"""
|
|
48
|
+
await self._send_command(
|
|
49
|
+
cmd=bytes.fromhex(CMD_SUBSCRIBE),
|
|
50
|
+
payload=bytes.fromhex(SUBSCRIBE_PAYLOAD),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
async def turn_ac_on(self) -> None:
|
|
54
|
+
"""Turn the AC output on.
|
|
55
|
+
|
|
56
|
+
:raises ConnectionError: If not connected to device.
|
|
57
|
+
:raises BleakError: If command transmission fails.
|
|
58
|
+
"""
|
|
59
|
+
await self._send_command(
|
|
60
|
+
cmd=bytes.fromhex(CMD_AC_OUTPUT), payload=bytes.fromhex(PAYLOAD_ON)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
async def turn_ac_off(self) -> None:
|
|
64
|
+
"""Turn the AC output off.
|
|
65
|
+
|
|
66
|
+
:raises ConnectionError: If not connected to device.
|
|
67
|
+
:raises BleakError: If command transmission fails.
|
|
68
|
+
"""
|
|
69
|
+
await self._send_command(
|
|
70
|
+
cmd=bytes.fromhex(CMD_AC_OUTPUT), payload=bytes.fromhex(PAYLOAD_OFF)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
async def turn_dc_on(self) -> None:
|
|
74
|
+
"""Turn the DC (12 V) output on.
|
|
75
|
+
|
|
76
|
+
Confirmed on real hardware: the 12 V port physically switched and the
|
|
77
|
+
``b2`` status byte latched on. The Gen 2 reuses the AC on/off payload on
|
|
78
|
+
a different command code (``4102``).
|
|
79
|
+
|
|
80
|
+
:raises ConnectionError: If not connected to device.
|
|
81
|
+
:raises BleakError: If command transmission fails.
|
|
82
|
+
"""
|
|
83
|
+
await self._send_command(
|
|
84
|
+
cmd=bytes.fromhex(CMD_DC_OUTPUT), payload=bytes.fromhex(PAYLOAD_ON)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
async def turn_dc_off(self) -> None:
|
|
88
|
+
"""Turn the DC (12 V) output off.
|
|
89
|
+
|
|
90
|
+
:raises ConnectionError: If not connected to device.
|
|
91
|
+
:raises BleakError: If command transmission fails.
|
|
92
|
+
"""
|
|
93
|
+
await self._send_command(
|
|
94
|
+
cmd=bytes.fromhex(CMD_DC_OUTPUT), payload=bytes.fromhex(PAYLOAD_OFF)
|
|
95
|
+
)
|
|
96
|
+
|
|
34
97
|
@property
|
|
35
98
|
def serial_number(self) -> str:
|
|
36
99
|
"""Device serial number.
|
|
@@ -73,7 +136,7 @@ class C1000G2(SolixBLEDevice):
|
|
|
73
136
|
|
|
74
137
|
@property
|
|
75
138
|
def power_out(self) -> int:
|
|
76
|
-
"""Total Power Out.
|
|
139
|
+
"""Total Power Out (watts).
|
|
77
140
|
|
|
78
141
|
:returns: Total power out or default int value.
|
|
79
142
|
"""
|
|
@@ -81,7 +144,7 @@ class C1000G2(SolixBLEDevice):
|
|
|
81
144
|
|
|
82
145
|
@property
|
|
83
146
|
def ac_power_in(self) -> int:
|
|
84
|
-
"""AC Power In.
|
|
147
|
+
"""AC Power In (watts).
|
|
85
148
|
|
|
86
149
|
:returns: Total AC power in or default int value.
|
|
87
150
|
"""
|
|
@@ -94,13 +157,24 @@ class C1000G2(SolixBLEDevice):
|
|
|
94
157
|
PortStatus.NOT_CONNECTED signifies off.
|
|
95
158
|
PortStatus.OUTPUT signifies on.
|
|
96
159
|
|
|
160
|
+
.. note::
|
|
161
|
+
:collapsible: closed
|
|
162
|
+
|
|
163
|
+
The AC port status is the first byte of the ``a7`` parameter,
|
|
164
|
+
mirroring the ``04 <status> <watts LE>`` per-port shape used by the
|
|
165
|
+
DC port (``b2``) and the USB ports; ``ac_power_out`` reads the watts
|
|
166
|
+
from this same ``a7`` TLV. Confirmed on hardware: ``a7[1]`` latches
|
|
167
|
+
``01`` (OUTPUT) when AC is on and ``00`` when off, tracking the relay.
|
|
168
|
+
(The ``a4`` parameter is constant at the previously-used offset and
|
|
169
|
+
does NOT reflect the AC state.)
|
|
170
|
+
|
|
97
171
|
:returns: Status of the AC port.
|
|
98
172
|
"""
|
|
99
173
|
return PortStatus(self._parse_int("a7", begin=1, end=2))
|
|
100
174
|
|
|
101
175
|
@property
|
|
102
176
|
def ac_power_out(self) -> int:
|
|
103
|
-
"""AC Power Out.
|
|
177
|
+
"""AC Power Out (watts).
|
|
104
178
|
|
|
105
179
|
:returns: Total AC power out or default int value.
|
|
106
180
|
"""
|
|
@@ -116,7 +190,9 @@ class C1000G2(SolixBLEDevice):
|
|
|
116
190
|
|
|
117
191
|
@property
|
|
118
192
|
def solar_power_in(self) -> int:
|
|
119
|
-
"""Solar/DC Power In.
|
|
193
|
+
"""Solar/DC Power In (watts).
|
|
194
|
+
|
|
195
|
+
.. note:: Offset inferred, not yet confirmed on hardware (no solar/DC-in capture taken).
|
|
120
196
|
|
|
121
197
|
:returns: Solar/DC power in or default int value.
|
|
122
198
|
"""
|
|
@@ -164,7 +240,7 @@ class C1000G2(SolixBLEDevice):
|
|
|
164
240
|
|
|
165
241
|
@property
|
|
166
242
|
def usb_c3_power(self) -> int:
|
|
167
|
-
"""USB C3 Power.
|
|
243
|
+
"""USB C3 Power (watts).
|
|
168
244
|
|
|
169
245
|
:returns: USB port C3 power or default int value.
|
|
170
246
|
"""
|
|
@@ -190,13 +266,19 @@ class C1000G2(SolixBLEDevice):
|
|
|
190
266
|
def dc_output(self) -> PortStatus:
|
|
191
267
|
"""DC Port Status.
|
|
192
268
|
|
|
269
|
+
Confirmed on hardware: ``b2[1]`` latched ``01`` (OUTPUT) when the 12 V
|
|
270
|
+
port was switched on and ``00`` (NOT_CONNECTED) when off.
|
|
271
|
+
|
|
193
272
|
:returns: Status of the DC output port.
|
|
194
273
|
"""
|
|
195
274
|
return PortStatus(self._parse_int("b2", begin=1, end=2))
|
|
196
275
|
|
|
197
276
|
@property
|
|
198
277
|
def dc_power_out(self) -> int:
|
|
199
|
-
"""DC Power Out.
|
|
278
|
+
"""DC Power Out (watts).
|
|
279
|
+
|
|
280
|
+
Confirmed on hardware: ``b2`` [2:4] read 6 W with a 12 V load on the DC
|
|
281
|
+
output, matching the ``04 <status> <watts LE>`` per-port shape.
|
|
200
282
|
|
|
201
283
|
:returns: DC power out or default int value.
|
|
202
284
|
"""
|