SolixBLE 3.7.0__tar.gz → 3.8.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.8.0}/PKG-INFO +6 -1
- {solixble-3.7.0 → solixble-3.8.0}/README.md +5 -0
- {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/__init__.py +3 -1
- {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/device.py +71 -42
- {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/devices/__init__.py +2 -0
- {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/devices/c1000g2.py +102 -20
- solixble-3.8.0/SolixBLE/devices/prime_power_bank_20k.py +173 -0
- {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/prime_device.py +3 -1
- {solixble-3.7.0 → solixble-3.8.0}/SolixBLE.egg-info/PKG-INFO +6 -1
- {solixble-3.7.0 → solixble-3.8.0}/SolixBLE.egg-info/SOURCES.txt +1 -0
- {solixble-3.7.0 → solixble-3.8.0}/pyproject.toml +8 -1
- {solixble-3.7.0 → solixble-3.8.0}/tests/test_devices.py +299 -0
- {solixble-3.7.0 → solixble-3.8.0}/LICENSE.txt +0 -0
- {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/const.py +0 -0
- {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/devices/c1000.py +0 -0
- {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/devices/c300.py +0 -0
- {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/devices/c300dc.py +0 -0
- {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/devices/c800.py +0 -0
- {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/devices/f2000.py +0 -0
- {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/devices/f3800.py +0 -0
- {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/devices/generic.py +0 -0
- {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/devices/prime_charger_160w.py +0 -0
- {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/devices/prime_charger_250w.py +0 -0
- {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/devices/solarbank2.py +0 -0
- {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/devices/solarbank3.py +0 -0
- {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/states.py +0 -0
- {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/utilities.py +0 -0
- {solixble-3.7.0 → solixble-3.8.0}/SolixBLE.egg-info/dependency_links.txt +0 -0
- {solixble-3.7.0 → solixble-3.8.0}/SolixBLE.egg-info/requires.txt +0 -0
- {solixble-3.7.0 → solixble-3.8.0}/SolixBLE.egg-info/top_level.txt +0 -0
- {solixble-3.7.0 → solixble-3.8.0}/setup.cfg +0 -0
- {solixble-3.7.0 → solixble-3.8.0}/tests/test_connection.py +0 -0
- {solixble-3.7.0 → solixble-3.8.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.8.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
|
|
@@ -138,3 +138,8 @@ pip install SolixBLE
|
|
|
138
138
|
See the `Generic` class inside `SolixBLE/devices/generic.py` and the
|
|
139
139
|
[documentation](https://solixble.readthedocs.io/en/latest/new_devices.html)
|
|
140
140
|
for guidance on how to add support for new devices.
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
## Disclaimer
|
|
144
|
+
|
|
145
|
+
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.
|
|
@@ -89,3 +89,8 @@ pip install SolixBLE
|
|
|
89
89
|
See the `Generic` class inside `SolixBLE/devices/generic.py` and the
|
|
90
90
|
[documentation](https://solixble.readthedocs.io/en/latest/new_devices.html)
|
|
91
91
|
for guidance on how to add support for new devices.
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
## Disclaimer
|
|
95
|
+
|
|
96
|
+
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.
|
|
@@ -16,6 +16,7 @@ from .devices import (
|
|
|
16
16
|
Generic,
|
|
17
17
|
PrimeCharger160w,
|
|
18
18
|
PrimeCharger250w,
|
|
19
|
+
PrimePowerBank20k,
|
|
19
20
|
Solarbank2,
|
|
20
21
|
Solarbank3,
|
|
21
22
|
)
|
|
@@ -25,9 +26,9 @@ from .states import (
|
|
|
25
26
|
ChargingStatusF3800,
|
|
26
27
|
DisplayTimeout,
|
|
27
28
|
LightStatus,
|
|
29
|
+
PortOverload,
|
|
28
30
|
PortStatus,
|
|
29
31
|
TemperatureUnit,
|
|
30
|
-
PortOverload,
|
|
31
32
|
)
|
|
32
33
|
from .utilities import discover_devices
|
|
33
34
|
|
|
@@ -45,6 +46,7 @@ __all__ = [
|
|
|
45
46
|
"Solarbank3",
|
|
46
47
|
"PrimeCharger160w",
|
|
47
48
|
"PrimeCharger250w",
|
|
49
|
+
"PrimePowerBank20k",
|
|
48
50
|
"Generic",
|
|
49
51
|
"ChargingStatus",
|
|
50
52
|
"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
|
|
|
@@ -195,6 +200,15 @@ class SolixBLEDevice:
|
|
|
195
200
|
if self._disconnect_event.is_set():
|
|
196
201
|
self._disconnect_event.clear()
|
|
197
202
|
|
|
203
|
+
# Run any device-specific post-connect setup (e.g sending a subscribe
|
|
204
|
+
# command to start telemetry). This runs on every (re)connection. Errors
|
|
205
|
+
# are logged but do not abort the connection; the automatic reconnect
|
|
206
|
+
# task will retry.
|
|
207
|
+
try:
|
|
208
|
+
await self._post_connect()
|
|
209
|
+
except Exception:
|
|
210
|
+
_LOGGER.exception(f"Error running post-connect setup for '{self.name}'!")
|
|
211
|
+
|
|
198
212
|
# Start an automatic reconnect task if its not running already
|
|
199
213
|
if self._auto_reconnect_task is None:
|
|
200
214
|
self._auto_reconnect_task = asyncio.create_task(self._auto_reconnect())
|
|
@@ -205,6 +219,17 @@ class SolixBLEDevice:
|
|
|
205
219
|
|
|
206
220
|
return True
|
|
207
221
|
|
|
222
|
+
async def _post_connect(self) -> None:
|
|
223
|
+
"""Run device-specific setup after a negotiated connection is established.
|
|
224
|
+
|
|
225
|
+
Called by :meth:`connect` once the encrypted session has been negotiated
|
|
226
|
+
(so :meth:`_send_command` may be used) and on every automatic reconnect.
|
|
227
|
+
The default implementation does nothing; subclasses can override it to,
|
|
228
|
+
for example, send a subscribe command to start a telemetry stream (see
|
|
229
|
+
:class:`~SolixBLE.devices.c1000g2.C1000G2`).
|
|
230
|
+
"""
|
|
231
|
+
pass
|
|
232
|
+
|
|
208
233
|
async def disconnect(self) -> None:
|
|
209
234
|
"""Disconnect from device and reset internal state.
|
|
210
235
|
|
|
@@ -493,7 +518,9 @@ class SolixBLEDevice:
|
|
|
493
518
|
)
|
|
494
519
|
return cipher.encrypt(padded_data)
|
|
495
520
|
|
|
496
|
-
async def _process_telemetry_packet(
|
|
521
|
+
async def _process_telemetry_packet(
|
|
522
|
+
self, payload: bytes, cmd: bytes = None
|
|
523
|
+
) -> None:
|
|
497
524
|
"""Process a telemetry packet from the device.
|
|
498
525
|
|
|
499
526
|
This performs the default processing of telemetry packets in which
|
|
@@ -533,14 +560,12 @@ class SolixBLEDevice:
|
|
|
533
560
|
)
|
|
534
561
|
del self._fragment_buffers[cmd_key]
|
|
535
562
|
del self._fragment_totals[cmd_key]
|
|
536
|
-
_LOGGER.debug(
|
|
537
|
-
f"Reassembled payload: {len(payload)} bytes"
|
|
538
|
-
)
|
|
563
|
+
_LOGGER.debug(f"Reassembled payload: {len(payload)} bytes")
|
|
539
564
|
|
|
540
565
|
else:
|
|
541
566
|
# Strip fragment info
|
|
542
567
|
payload = payload[1:]
|
|
543
|
-
|
|
568
|
+
|
|
544
569
|
decrypted_payload = self._decrypt_payload(payload)
|
|
545
570
|
_LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}")
|
|
546
571
|
parameters = self._parse_payload(decrypted_payload)
|
|
@@ -615,51 +640,55 @@ class SolixBLEDevice:
|
|
|
615
640
|
# Match against common message types
|
|
616
641
|
match pattern.hex():
|
|
617
642
|
|
|
618
|
-
#
|
|
643
|
+
# Negotiation messages
|
|
619
644
|
case "030001":
|
|
620
|
-
_LOGGER.debug("Received
|
|
645
|
+
_LOGGER.debug("Received negotiation message!")
|
|
621
646
|
return await self._process_negotiation(cmd, payload)
|
|
622
647
|
|
|
623
|
-
#
|
|
648
|
+
# Session messages
|
|
624
649
|
case "03010f" | "030111":
|
|
625
650
|
|
|
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
|
|
651
|
+
# Non-encrypted telemetry messages
|
|
652
|
+
if cmd.hex() == "0300":
|
|
653
|
+
_LOGGER.debug("Received non-encrypted telemetry message!")
|
|
654
|
+
parameters = self._parse_payload(payload)
|
|
655
|
+
return await self._process_telemetry(parameters)
|
|
650
656
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
657
|
+
# Encrypted telemetry messages
|
|
658
|
+
elif cmd.hex() in self._TELEMETRY_COMMANDS:
|
|
659
|
+
_LOGGER.debug("Received encrypted telemetry message!")
|
|
660
|
+
return await self._process_telemetry_packet(payload, cmd)
|
|
661
|
+
|
|
662
|
+
# Unknown messages
|
|
663
|
+
else:
|
|
664
|
+
_LOGGER.debug(f"Received unknown message of type: {cmd.hex()}")
|
|
665
|
+
try:
|
|
666
|
+
|
|
667
|
+
# If the payload is one byte too short and we are
|
|
668
|
+
# using the default AES (CBC) then try putting the
|
|
669
|
+
# last byte of the cmd in front of it
|
|
670
|
+
if (
|
|
671
|
+
len(payload) % 16 == 15
|
|
672
|
+
and self._decrypt_payload
|
|
673
|
+
is SolixBLEDevice._decrypt_payload
|
|
674
|
+
):
|
|
656
675
|
_LOGGER.debug(
|
|
657
|
-
|
|
658
|
-
)
|
|
659
|
-
except Exception:
|
|
660
|
-
_LOGGER.exception(
|
|
661
|
-
"Exception decrypting unknown message type"
|
|
676
|
+
"Using special trick of embedded part of CMD in payload..."
|
|
662
677
|
)
|
|
678
|
+
payload = cmd[1].to_bytes() + payload
|
|
679
|
+
|
|
680
|
+
decrypted_payload = self._decrypt_payload(payload)
|
|
681
|
+
_LOGGER.debug(
|
|
682
|
+
f"Decrypted payload: {decrypted_payload.hex()}"
|
|
683
|
+
)
|
|
684
|
+
parameters = self._parse_payload(decrypted_payload)
|
|
685
|
+
_LOGGER.debug(
|
|
686
|
+
f"Parameters: {self._parameters_to_str(parameters, types=True)}"
|
|
687
|
+
)
|
|
688
|
+
except Exception:
|
|
689
|
+
_LOGGER.exception(
|
|
690
|
+
"Exception decrypting unknown message type"
|
|
691
|
+
)
|
|
663
692
|
|
|
664
693
|
case _:
|
|
665
694
|
_LOGGER.warning(
|
|
@@ -14,6 +14,7 @@ from .f3800 import F3800
|
|
|
14
14
|
from .generic import Generic
|
|
15
15
|
from .prime_charger_160w import PrimeCharger160w
|
|
16
16
|
from .prime_charger_250w import PrimeCharger250w
|
|
17
|
+
from .prime_power_bank_20k import PrimePowerBank20k
|
|
17
18
|
from .solarbank2 import Solarbank2
|
|
18
19
|
from .solarbank3 import Solarbank3
|
|
19
20
|
|
|
@@ -29,5 +30,6 @@ __all__ = [
|
|
|
29
30
|
"Solarbank3",
|
|
30
31
|
"PrimeCharger160w",
|
|
31
32
|
"PrimeCharger250w",
|
|
33
|
+
"PrimePowerBank20k",
|
|
32
34
|
"Generic",
|
|
33
35
|
]
|
|
@@ -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
|
"""
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Anker Prime Power Bank 20k (220w) model.
|
|
2
|
+
|
|
3
|
+
.. moduleauthor:: Harvey Lelliott (flip-dots) <harveylelliott@duck.com>
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ..const import DEFAULT_METADATA_FLOAT
|
|
8
|
+
from ..prime_device import PrimeDevice
|
|
9
|
+
from ..states import PortStatus
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PrimePowerBank20k(PrimeDevice):
|
|
13
|
+
"""
|
|
14
|
+
Anker Prime Power Bank 20k (220w) model.
|
|
15
|
+
|
|
16
|
+
Use this class to connect and monitor the 220w power bank.
|
|
17
|
+
This model is also known as the A110B.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def battery_percentage(self) -> int:
|
|
22
|
+
"""Battery Percentage.
|
|
23
|
+
|
|
24
|
+
:returns: Percentage charge of battery or default int value.
|
|
25
|
+
"""
|
|
26
|
+
return self._parse_int("a2", begin=1, end=2)
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def power_out(self) -> int:
|
|
30
|
+
"""Total Power Out.
|
|
31
|
+
|
|
32
|
+
:returns: Total power out or default int value.
|
|
33
|
+
"""
|
|
34
|
+
return self._parse_int("a6", begin=2, end=4) / 10.0
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def temperature(self) -> int:
|
|
38
|
+
"""Temperature of the unit (C).
|
|
39
|
+
|
|
40
|
+
:returns: Temperature of the unit in degrees C.
|
|
41
|
+
"""
|
|
42
|
+
return self._parse_int("af", begin=1, signed=True)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def usb_port_c1(self) -> PortStatus:
|
|
46
|
+
"""USB C1 Port Status.
|
|
47
|
+
|
|
48
|
+
:returns: Status of the USB C1 port.
|
|
49
|
+
"""
|
|
50
|
+
return PortStatus(self._parse_int("a8", begin=1, end=2))
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def usb_c1_voltage(self) -> float:
|
|
54
|
+
"""USB C1 Port voltage (V).
|
|
55
|
+
|
|
56
|
+
:returns: Voltage of the USB C1 port or default float value.
|
|
57
|
+
"""
|
|
58
|
+
if self._data is None:
|
|
59
|
+
return DEFAULT_METADATA_FLOAT
|
|
60
|
+
|
|
61
|
+
return self._parse_int("a8", begin=2, end=4) / 10.0
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def usb_c1_current(self) -> float:
|
|
65
|
+
"""USB C1 Port current (A).
|
|
66
|
+
|
|
67
|
+
:returns: Current of the USB C1 port or default float value.
|
|
68
|
+
"""
|
|
69
|
+
if self._data is None:
|
|
70
|
+
return DEFAULT_METADATA_FLOAT
|
|
71
|
+
|
|
72
|
+
return self._parse_int("a8", begin=4, end=6) / 10.0
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def usb_c1_power(self) -> float:
|
|
76
|
+
"""USB C1 Port power (W).
|
|
77
|
+
|
|
78
|
+
.. important::
|
|
79
|
+
|
|
80
|
+
There appears to be a firmware bug in the power bank which
|
|
81
|
+
causes the value of USB C1 power to latch to whatever its
|
|
82
|
+
last value was when unplugged, this does not happen with
|
|
83
|
+
USB C2 power for some reason. This has been observed on
|
|
84
|
+
version v1.6.0.5.
|
|
85
|
+
|
|
86
|
+
:returns: Power of the USB C1 port or default float value.
|
|
87
|
+
"""
|
|
88
|
+
if self._data is None:
|
|
89
|
+
return DEFAULT_METADATA_FLOAT
|
|
90
|
+
|
|
91
|
+
return self._parse_int("a8", begin=6, end=8) / 10.0
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def usb_port_c2(self) -> PortStatus:
|
|
95
|
+
"""USB C2 Port Status.
|
|
96
|
+
|
|
97
|
+
:returns: Status of the USB C2 port.
|
|
98
|
+
"""
|
|
99
|
+
return PortStatus(self._parse_int("a9", begin=1, end=2))
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def usb_c2_voltage(self) -> float:
|
|
103
|
+
"""USB C2 Port voltage (V).
|
|
104
|
+
|
|
105
|
+
:returns: Voltage of the USB C2 port or default float value.
|
|
106
|
+
"""
|
|
107
|
+
if self._data is None:
|
|
108
|
+
return DEFAULT_METADATA_FLOAT
|
|
109
|
+
|
|
110
|
+
return self._parse_int("a9", begin=2, end=4) / 10.0
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def usb_c2_current(self) -> float:
|
|
114
|
+
"""USB C2 Port current (A).
|
|
115
|
+
|
|
116
|
+
:returns: Current of the USB C2 port or default float value.
|
|
117
|
+
"""
|
|
118
|
+
if self._data is None:
|
|
119
|
+
return DEFAULT_METADATA_FLOAT
|
|
120
|
+
|
|
121
|
+
return self._parse_int("a9", begin=4, end=6) / 10.0
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def usb_c2_power(self) -> float:
|
|
125
|
+
"""USB C2 Port power (W).
|
|
126
|
+
|
|
127
|
+
:returns: Power of the USB C2 port or default float value.
|
|
128
|
+
"""
|
|
129
|
+
if self._data is None:
|
|
130
|
+
return DEFAULT_METADATA_FLOAT
|
|
131
|
+
|
|
132
|
+
return self._parse_int("a9", begin=6, end=8) / 10.0
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def usb_port_a1(self) -> PortStatus:
|
|
136
|
+
"""USB A1 Port Status.
|
|
137
|
+
|
|
138
|
+
:returns: Status of the USB A1 port.
|
|
139
|
+
"""
|
|
140
|
+
return PortStatus(self._parse_int("ac", begin=1, end=2))
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def usb_a1_voltage(self) -> float:
|
|
144
|
+
"""USB A1 Port voltage (V).
|
|
145
|
+
|
|
146
|
+
:returns: Voltage of the USB A1 port or default float value.
|
|
147
|
+
"""
|
|
148
|
+
if self._data is None:
|
|
149
|
+
return DEFAULT_METADATA_FLOAT
|
|
150
|
+
|
|
151
|
+
return self._parse_int("ac", begin=2, end=4) / 10.0
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def usb_a1_current(self) -> float:
|
|
155
|
+
"""USB A1 Port current (A).
|
|
156
|
+
|
|
157
|
+
:returns: Current of the USB A1 port or default float value.
|
|
158
|
+
"""
|
|
159
|
+
if self._data is None:
|
|
160
|
+
return DEFAULT_METADATA_FLOAT
|
|
161
|
+
|
|
162
|
+
return self._parse_int("ac", begin=4, end=6) / 10.0
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def usb_a1_power(self) -> float:
|
|
166
|
+
"""USB A1 Port power (W).
|
|
167
|
+
|
|
168
|
+
:returns: Power of the USB A1 port or default float value.
|
|
169
|
+
"""
|
|
170
|
+
if self._data is None:
|
|
171
|
+
return DEFAULT_METADATA_FLOAT
|
|
172
|
+
|
|
173
|
+
return self._parse_int("ac", begin=6, end=8) / 10.0
|
|
@@ -487,7 +487,9 @@ class PrimeDevice(SolixBLEDevice):
|
|
|
487
487
|
# Packet processing #
|
|
488
488
|
#####################
|
|
489
489
|
|
|
490
|
-
async def _process_telemetry_packet(
|
|
490
|
+
async def _process_telemetry_packet(
|
|
491
|
+
self, payload: bytes, cmd: bytes = None
|
|
492
|
+
) -> None:
|
|
491
493
|
"""
|
|
492
494
|
Process a telemetry packet from an Anker Prime device.
|
|
493
495
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: SolixBLE
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.8.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
|
|
@@ -138,3 +138,8 @@ pip install SolixBLE
|
|
|
138
138
|
See the `Generic` class inside `SolixBLE/devices/generic.py` and the
|
|
139
139
|
[documentation](https://solixble.readthedocs.io/en/latest/new_devices.html)
|
|
140
140
|
for guidance on how to add support for new devices.
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
## Disclaimer
|
|
144
|
+
|
|
145
|
+
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.
|
|
@@ -23,6 +23,7 @@ SolixBLE/devices/f3800.py
|
|
|
23
23
|
SolixBLE/devices/generic.py
|
|
24
24
|
SolixBLE/devices/prime_charger_160w.py
|
|
25
25
|
SolixBLE/devices/prime_charger_250w.py
|
|
26
|
+
SolixBLE/devices/prime_power_bank_20k.py
|
|
26
27
|
SolixBLE/devices/solarbank2.py
|
|
27
28
|
SolixBLE/devices/solarbank3.py
|
|
28
29
|
tests/test_connection.py
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "SolixBLE"
|
|
3
|
-
version = "3.
|
|
3
|
+
version = "3.8.0"
|
|
4
4
|
dependencies = [
|
|
5
5
|
"bleak>=0.19.0",
|
|
6
6
|
"cryptography",
|
|
@@ -27,6 +27,13 @@ classifiers = [
|
|
|
27
27
|
"Programming Language :: Python :: 3.11"
|
|
28
28
|
]
|
|
29
29
|
|
|
30
|
+
[tool.ruff.lint]
|
|
31
|
+
select = ["ALL"]
|
|
32
|
+
ignore = ["D202", "D212", "D213", "G004"]
|
|
33
|
+
|
|
34
|
+
[tool.ruff.lint.per-file-ignores]
|
|
35
|
+
"tests/*" = ["S101"]
|
|
36
|
+
|
|
30
37
|
[project.urls]
|
|
31
38
|
Homepage = "https://github.com/flip-dots/SolixBLE"
|
|
32
39
|
Documentation = "https://solixble.readthedocs.io/en/latest/"
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import asyncio
|
|
8
8
|
import logging
|
|
9
9
|
from typing import Any
|
|
10
|
+
from unittest import mock
|
|
10
11
|
|
|
11
12
|
import pytest
|
|
12
13
|
|
|
@@ -22,6 +23,7 @@ from SolixBLE import (
|
|
|
22
23
|
PortStatus,
|
|
23
24
|
PrimeCharger160w,
|
|
24
25
|
PrimeDevice,
|
|
26
|
+
PrimePowerBank20k,
|
|
25
27
|
Solarbank2,
|
|
26
28
|
SolixBLEDevice,
|
|
27
29
|
TemperatureUnit,
|
|
@@ -265,6 +267,71 @@ from tests.helpers import MockDevice
|
|
|
265
267
|
},
|
|
266
268
|
id="c1000g2",
|
|
267
269
|
),
|
|
270
|
+
# The two cases below are decrypted telemetry frames captured from a real
|
|
271
|
+
# C1000 Gen 2 (A1763) on 2026-06-21 with the AC output physically off then
|
|
272
|
+
# on (idle, no load). They are identical except for the "a7" param, which
|
|
273
|
+
# locks in the AC output decode: ac_output is "a7" byte 1 (00=off, 01=on,
|
|
274
|
+
# latched) -- the same per-port "04 <status> <watts LE>" shape used by the
|
|
275
|
+
# DC port (b2) and USB ports. ("a4" byte 22 is NOT the AC state: it stayed
|
|
276
|
+
# 01 with the port physically off, so an a4-based decode reports a false
|
|
277
|
+
# OUTPUT.)
|
|
278
|
+
pytest.param(
|
|
279
|
+
C1000G2,
|
|
280
|
+
"a10131a221062011415043444b39363047313631303033393000054131373633030401010100a30e0400000000b0040064cc00580200a41b0400000000580232010000000000f0003c00010000000100500a00a506042400396400a60a04000000000000ab2a39a70704000000000000a80404000000aa0404000000ab0404000000ac0404000000ae0404000000b20404000000d91a04000019500a0000000000000000000000000000000000000000da18040000000000000000000001e00164057f00000000000000dc06040000000000f91d0403040101060005000000000000000000090300010000000006090200fa15040101010100170300000000000000000000000000fd0e0031373832303439353930383637fe050364f4376a",
|
|
281
|
+
{
|
|
282
|
+
"serial_number": "APCDK960G16100390",
|
|
283
|
+
"part_number": "A1763",
|
|
284
|
+
"temperature": 36,
|
|
285
|
+
"battery_percentage": 57,
|
|
286
|
+
"battery_health": 100,
|
|
287
|
+
"ac_output": PortStatus.NOT_CONNECTED,
|
|
288
|
+
"ac_power_in": 0,
|
|
289
|
+
"ac_power_out": 0,
|
|
290
|
+
"power_out": 0,
|
|
291
|
+
"solar_port": PortStatus.NOT_CONNECTED,
|
|
292
|
+
"dc_output": PortStatus.NOT_CONNECTED,
|
|
293
|
+
"max_battery_percentage": 80,
|
|
294
|
+
"min_battery_percentage": 10,
|
|
295
|
+
},
|
|
296
|
+
id="c1000g2_ac_off",
|
|
297
|
+
),
|
|
298
|
+
pytest.param(
|
|
299
|
+
C1000G2,
|
|
300
|
+
"a10131a221062011415043444b39363047313631303033393000054131373633030401010100a30e0400000000b0040064cc00580200a41b0400000000580232010000000000f0003c00010000000100500a00a506042400396400a60a04000000000000ab2a39a70704010000000000a80404000000aa0404000000ab0404000000ac0404000000ae0404000000b20404000000d91a04000019500a0000000000000000000000000000000000000000da18040000000000000000000001e00164057f00000000000000dc06040000000000f91d0403040101060005000000000000000000090300010000000006090200fa15040101010100170300000000000000000000000000fd0e0031373832303439353930383637fe050369f4376a",
|
|
301
|
+
{
|
|
302
|
+
"serial_number": "APCDK960G16100390",
|
|
303
|
+
"part_number": "A1763",
|
|
304
|
+
"temperature": 36,
|
|
305
|
+
"battery_percentage": 57,
|
|
306
|
+
"battery_health": 100,
|
|
307
|
+
"ac_output": PortStatus.OUTPUT,
|
|
308
|
+
"ac_power_in": 0,
|
|
309
|
+
"ac_power_out": 0,
|
|
310
|
+
"power_out": 0,
|
|
311
|
+
"solar_port": PortStatus.NOT_CONNECTED,
|
|
312
|
+
"dc_output": PortStatus.NOT_CONNECTED,
|
|
313
|
+
"max_battery_percentage": 80,
|
|
314
|
+
"min_battery_percentage": 10,
|
|
315
|
+
},
|
|
316
|
+
id="c1000g2_ac_on",
|
|
317
|
+
),
|
|
318
|
+
# Derived from the idle "c1000g2" frame above with only the "b2" param
|
|
319
|
+
# changed from 04000000 to 04010600 -- the value observed live on a real
|
|
320
|
+
# C1000 Gen 2 with the DC output on and a ~6 W 12 V load. This locks in
|
|
321
|
+
# the DC decode: dc_output is "b2" byte 1 (01 = OUTPUT) and dc_power_out
|
|
322
|
+
# is "b2" [2:4] little-endian watts (0x0006 = 6 W).
|
|
323
|
+
pytest.param(
|
|
324
|
+
C1000G2,
|
|
325
|
+
"a10134a221062011415043444b39363146333734303032393000054131373633060201010100a30b0400000000b0040058dc00a41b0400000000b0043201000000000000001e00010000000000640103a506041700646400a60a04000000000000ab2a64a70704000000010000a80404000000aa0404000000ab0404000000ac0404000000ae0404000000b20404010600d91a0400001964010000000100000000000000000000000000000000da18040000000000000000000001e00164057f00000000000000dc06040000000000f91d0406020101050005000000000005000500050300010000000000020200fa150401010101001f0300000000000000000000000000fd0e0031373634363538323735393838fe0503638c2e69f0",
|
|
326
|
+
{
|
|
327
|
+
"serial_number": "APCDK961F37400290",
|
|
328
|
+
"battery_percentage": 100,
|
|
329
|
+
"ac_output": PortStatus.NOT_CONNECTED,
|
|
330
|
+
"dc_output": PortStatus.OUTPUT,
|
|
331
|
+
"dc_power_out": 6,
|
|
332
|
+
},
|
|
333
|
+
id="c1000g2_dc_on",
|
|
334
|
+
),
|
|
268
335
|
pytest.param(
|
|
269
336
|
C300,
|
|
270
337
|
"a10131a2050300000000a3050300000000a40302ffffa503020000a603025400a703020000a803020000a903020000aa03020100ab03020000ac03020000ad03020000ae03025500af03020000b003020100b103021b04b20302fc01b30302fc01b403021c00b503027b00b603021b04b7020101b8020100b9020124ba020100bb020164bc020164bd020100be020100bf020100c0020101c1020100c2020100c3020100c4020100c51100415a5653424a30453339323030303438c603024a01c70302a005c803022c01c903023c00ca03020000cb020101cc020100cd020102ce020132cf020100d0020100d1020101",
|
|
@@ -482,6 +549,94 @@ from tests.helpers import MockDevice
|
|
|
482
549
|
},
|
|
483
550
|
id="prime_160w_all_three_charging",
|
|
484
551
|
),
|
|
552
|
+
pytest.param(
|
|
553
|
+
PrimePowerBank20k,
|
|
554
|
+
"a10131a203044d60a30404010000a4020101a50404000000a60404000000a7080400000000000000a80f0400000000009600ff00ffffffff00a90f0400000000000000ff00ffffffff00ac09040000000000000000af02011db002011eb103020900fe050300000000",
|
|
555
|
+
{
|
|
556
|
+
"battery_percentage": 77,
|
|
557
|
+
"temperature": 29,
|
|
558
|
+
"power_out": 0.0,
|
|
559
|
+
"usb_port_c1": PortStatus.NOT_CONNECTED,
|
|
560
|
+
"usb_c1_current": 0.0,
|
|
561
|
+
"usb_c1_power": 15.0,
|
|
562
|
+
"usb_c1_voltage": 0.0,
|
|
563
|
+
"usb_port_c2": PortStatus.NOT_CONNECTED,
|
|
564
|
+
"usb_c2_current": 0.0,
|
|
565
|
+
"usb_c2_power": 0.0,
|
|
566
|
+
"usb_c2_voltage": 0.0,
|
|
567
|
+
"usb_port_a1": PortStatus.NOT_CONNECTED,
|
|
568
|
+
"usb_a1_current": 0.0,
|
|
569
|
+
"usb_a1_power": 0.0,
|
|
570
|
+
"usb_a1_voltage": 0.0,
|
|
571
|
+
},
|
|
572
|
+
id="prime_power_bank_20k_idle",
|
|
573
|
+
),
|
|
574
|
+
pytest.param(
|
|
575
|
+
PrimePowerBank20k,
|
|
576
|
+
"a10131a20304515ca30404010000a4020101a50404000000a60404013601a7080400000000000000a80f04019500140036010107ffffffff00a90f0400000000000000ff00ffffffff00ac09040000000000000000af02011ab002011bb103020900fe050300000000",
|
|
577
|
+
{
|
|
578
|
+
"battery_percentage": 81,
|
|
579
|
+
"temperature": 26,
|
|
580
|
+
"power_out": 31.0,
|
|
581
|
+
"usb_port_c1": PortStatus.OUTPUT,
|
|
582
|
+
"usb_c1_current": 2.0,
|
|
583
|
+
"usb_c1_power": 31.0,
|
|
584
|
+
"usb_c1_voltage": 14.9,
|
|
585
|
+
"usb_port_c2": PortStatus.NOT_CONNECTED,
|
|
586
|
+
"usb_c2_current": 0.0,
|
|
587
|
+
"usb_c2_power": 0.0,
|
|
588
|
+
"usb_c2_voltage": 0.0,
|
|
589
|
+
"usb_port_a1": PortStatus.NOT_CONNECTED,
|
|
590
|
+
"usb_a1_current": 0.0,
|
|
591
|
+
"usb_a1_power": 0.0,
|
|
592
|
+
"usb_a1_voltage": 0.0,
|
|
593
|
+
},
|
|
594
|
+
id="prime_power_bank_20k_discharge_c1",
|
|
595
|
+
),
|
|
596
|
+
pytest.param(
|
|
597
|
+
PrimePowerBank20k,
|
|
598
|
+
"a10131a20304505ca30404010000a4020101a50404000000a60404013a01a7080400000000000000a80f0400000000003d01ff00ffffffff00a90f0401950014002a010107ffffffff00ac09040133000300100000af02011bb002011cb103020900fe050300000000",
|
|
599
|
+
{
|
|
600
|
+
"battery_percentage": 80,
|
|
601
|
+
"temperature": 27,
|
|
602
|
+
"power_out": 31.4,
|
|
603
|
+
"usb_port_c1": PortStatus.NOT_CONNECTED,
|
|
604
|
+
"usb_c1_current": 0.0,
|
|
605
|
+
"usb_c1_power": 31.7,
|
|
606
|
+
"usb_c1_voltage": 0.0,
|
|
607
|
+
"usb_port_c2": PortStatus.OUTPUT,
|
|
608
|
+
"usb_c2_current": 2.0,
|
|
609
|
+
"usb_c2_power": 29.8,
|
|
610
|
+
"usb_c2_voltage": 14.9,
|
|
611
|
+
"usb_port_a1": PortStatus.OUTPUT,
|
|
612
|
+
"usb_a1_current": 0.3,
|
|
613
|
+
"usb_a1_power": 1.6,
|
|
614
|
+
"usb_a1_voltage": 5.1,
|
|
615
|
+
},
|
|
616
|
+
id="prime_power_bank_20k_discharge_c2_a1",
|
|
617
|
+
),
|
|
618
|
+
pytest.param(
|
|
619
|
+
PrimePowerBank20k,
|
|
620
|
+
"a10131a203044b5da30404010018a4020101a50404014102a6040401a300a7080400000000000000a80f04015900100096000107ffffffff00a90f0402c9001c004102ff07ffffffff00ac090401330002000d0000af02011cb002011db103020900fe050300000000",
|
|
621
|
+
{
|
|
622
|
+
"battery_percentage": 75,
|
|
623
|
+
"temperature": 28,
|
|
624
|
+
"power_out": 16.3,
|
|
625
|
+
"usb_port_c1": PortStatus.OUTPUT,
|
|
626
|
+
"usb_c1_current": 1.6,
|
|
627
|
+
"usb_c1_power": 15.0,
|
|
628
|
+
"usb_c1_voltage": 8.9,
|
|
629
|
+
"usb_port_c2": PortStatus.INPUT,
|
|
630
|
+
"usb_c2_current": 2.8,
|
|
631
|
+
"usb_c2_power": 57.7,
|
|
632
|
+
"usb_c2_voltage": 20.1,
|
|
633
|
+
"usb_port_a1": PortStatus.OUTPUT,
|
|
634
|
+
"usb_a1_current": 0.2,
|
|
635
|
+
"usb_a1_power": 1.3,
|
|
636
|
+
"usb_a1_voltage": 5.1,
|
|
637
|
+
},
|
|
638
|
+
id="prime_power_bank_20k_discharge_c1_a1_charge_c2",
|
|
639
|
+
),
|
|
485
640
|
pytest.param(
|
|
486
641
|
C300DC,
|
|
487
642
|
"a10131a2050300000000a303020000a403020000a503020000a603020000a703020000a803020000a903020000aa03020000ab03020000ac03020000ad03020000ae03020000af03020000b003020000b103020000b203020000b303020000b403020000b5020180b6020100b7020100b8020100b9020100ba020100bb020100bc020100bd020100be020100bf020100c0020100c1020100c2020100c3110020202020202020202020202020202020c403020000c503020000c603020000c7020100c8020100c9020100ca020100cb03020000cc020100cd020100f7050300000000f815040000000000000000000000000000000000000000",
|
|
@@ -684,6 +839,30 @@ async def test_values(
|
|
|
684
839
|
), f"Mismatch for property '{class_property}'!"
|
|
685
840
|
|
|
686
841
|
|
|
842
|
+
@pytest.mark.asyncio
|
|
843
|
+
async def test_c1000g2_dc_control() -> None:
|
|
844
|
+
"""C1000 Gen 2 DC output control dispatches command 4102.
|
|
845
|
+
|
|
846
|
+
Confirmed on real hardware (the 12 V port physically switched and acked).
|
|
847
|
+
Here we just lock in that turn_dc_on/off send command 4102 with the same
|
|
848
|
+
on/off payloads as the AC output, which is the only difference between the
|
|
849
|
+
two on the Gen 2.
|
|
850
|
+
"""
|
|
851
|
+
device = C1000G2(MOCK_BLE_DEVICE)
|
|
852
|
+
device._send_command = mock.AsyncMock()
|
|
853
|
+
|
|
854
|
+
await device.turn_dc_on()
|
|
855
|
+
device._send_command.assert_awaited_once_with(
|
|
856
|
+
cmd=bytes.fromhex("4102"), payload=bytes.fromhex("a10121a2020101")
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
device._send_command.reset_mock()
|
|
860
|
+
await device.turn_dc_off()
|
|
861
|
+
device._send_command.assert_awaited_once_with(
|
|
862
|
+
cmd=bytes.fromhex("4102"), payload=bytes.fromhex("a10121a2020100")
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
|
|
687
866
|
@pytest.mark.asyncio
|
|
688
867
|
@pytest.mark.parametrize(
|
|
689
868
|
"device_class,packets,secret",
|
|
@@ -753,6 +932,22 @@ async def test_values(
|
|
|
753
932
|
"6a2c89888de58cce1e15d98eb22669898ec29bcb1519ce19f950439aac9dbcb5",
|
|
754
933
|
id="solarbank2_1",
|
|
755
934
|
),
|
|
935
|
+
pytest.param(
|
|
936
|
+
PrimePowerBank20k,
|
|
937
|
+
[
|
|
938
|
+
"ff091e000300014801ab273ed3e27270c3f4d676ac7d69a00572793732a6",
|
|
939
|
+
"ff092b000300014803ab273ed0443800b35db54c6d4a6ec3d48171a04ea7ebce8bf749e5e48c5d991a5e67",
|
|
940
|
+
"ff0958000300014829ab273ed144326ada9fc66fa02508c5ddf549ade014d1eeb352fea11c0315b70b8aaa8a734ca5830f8d5827acbaa1224f05ad300b38d27bac9862a768d95c29daed0a89e92feb1d09163a094aa700ff",
|
|
941
|
+
"ff091b000300014805abab709a595a803dd04246b78a927453cf65",
|
|
942
|
+
"ff095d000300014821ab277f4e77c3b9e1f44367539f64f85d19969d0273c2c0ca93a06f3a010cf636e3b2df75d10791adf1e3c706a3238bcf0a858cd1e2d55d4cf1164a1b7db3b0058c47dfb24c71f11f8a96209d9f0924d420f03120",
|
|
943
|
+
"ff091b000300014822e520695552c2745a608fd21cf84bc6e3ccb9",
|
|
944
|
+
"ff091b000300014827e520695552c2745a608fd21cf84bc6e3ccbc",
|
|
945
|
+
"ff09df000301114a00e5a17fe3ebb89758b89ffb0e7d35a36ffeaeba3e991d79323680049a018c8e719bb706b6d00a142199a6cdc7f05bb5489f1ebb093fe3d134caf7ae5ad7b456867d9a58885cee8479bc10ea2d42d5b94d3b5a929cf4f4fd25f987e5a4922ae6fa744e22289080676583f390c1351a4b68ac5c1dabdcbf8e5e23416e47a0cea7a6062326dd8505464f821ba881f0f6f2c8ea050a7c978962980a539e90879aa1499b5be92fdceb53de533fc2bdd78b7998aec24493fdcfe3d2bc7e95b383744f92a4168819350e89d0d3142d1dbedcb779e45cfad12008",
|
|
946
|
+
"ff098300030111430044014f704abfd87d1d38fc0d7a35a36efdaf1f9f9f1c799493804dfaa6882d789fb7aeb4d117bd2330cd63c5f13f1e4a089ce80ac2442c66c85fa1f0dcb0d6867d9a58f7a3ee8479ec124724f6d7b84d8a58939c465ffb24e43754a1889be5f8c946d82d93806765835569e75bd67cbd3ac71071159c13a83bb9",
|
|
947
|
+
],
|
|
948
|
+
"5609bc39f79166da75139feb7c335fb7524b3bf0d730db96bf6ebf450d3e165b",
|
|
949
|
+
id="prime_power_bank_20k",
|
|
950
|
+
),
|
|
756
951
|
],
|
|
757
952
|
)
|
|
758
953
|
async def test_negotiation(
|
|
@@ -836,6 +1031,13 @@ async def test_negotiation(
|
|
|
836
1031
|
"a10131a20302e805a303020000a4020100a5080400000000000000a6080401d84e00000000a7080400000000000000a8020100a9020150aa020100ab090400001c50343b3b3bac0d0401002c0100002c0100000300ad0d0401002c0100002c0100000100ae0d0401002c0100002c0100000300af020101b0020101b1020100b2020101b30201ffb40d04fafffbff00000000fafffbffb50d04ffffffffffffffffffffffffe0050408000000e10b0400000000000000000000fe050300000000",
|
|
837
1032
|
id="prime_160w_telemetry_alt",
|
|
838
1033
|
),
|
|
1034
|
+
pytest.param(
|
|
1035
|
+
PrimePowerBank20k,
|
|
1036
|
+
"44014f704abfd87d1d38fc0d7a35a36efdaf1f9f9f1c799493804dfaa6882d789fb7aeb4d117bd2330cd63c5f13f1e4a089ce80ac2442c66c85fa1f0dcb0d6867d9a58f7a3ee8479ec124724f6d7b84d8a58939c465ffb24e43754a1889be5f8c946d82d93806765835569e75bd67cbd3ac71071159c13a83b",
|
|
1037
|
+
"5609bc39f79166da75139feb7c335fb7524b3bf0d730db96bf6ebf450d3e165b",
|
|
1038
|
+
"a10131a203044d60a30404010000a4020101a50404000000a60404000000a7080400000000000000a80f0400000000009600ff00ffffffff00a90f0400000000000000ff00ffffffff00ac09040000000000000000af02011db002011eb103020900fe050300000000",
|
|
1039
|
+
id="prime_power_bank_telemetry",
|
|
1040
|
+
),
|
|
839
1041
|
],
|
|
840
1042
|
)
|
|
841
1043
|
def test_payload_decryption(
|
|
@@ -989,6 +1191,28 @@ def test_payload_decryption(
|
|
|
989
1191
|
"""{'a1': '31', 'a2': '02e805', 'a3': '020000', 'a4': '0100', 'a5': '0401a824fe0b3f0b', 'a6': '0400000000000000', 'a7': '0400000000000000', 'a8': '0103', 'a9': '0150', 'aa': '0100', 'ab': '0400000f0f0f000000', 'ac': '0401002c0100002c0100000203', 'ad': '0401002c0100002c0100000300', 'ae': '0401002c0100002c0100000300', 'af': '0100', 'b0': '0100', 'b1': '0101', 'b2': '0101', 'b3': '0101', 'b4': '04e8040000fafffbfffafffbff', 'b5': '04ffffffffffffffffffffffff', 'e0': '0408000000', 'e1': '0480034b53000000000000', 'fe': '0300000000'}""",
|
|
990
1192
|
id="prime_telemetry_packet",
|
|
991
1193
|
),
|
|
1194
|
+
# Test an Anker Prime power bank (single payload device) with a single telemetry packet.
|
|
1195
|
+
pytest.param(
|
|
1196
|
+
PrimePowerBank20k,
|
|
1197
|
+
[
|
|
1198
|
+
"ff098300030111430044014f704abfd87d1d38fc0d7a35a36efdaf1f9f9f1c799493804dfaa6882d789fb7aeb4d117bd2330cd63c5f13f1e4a089ce80ac2442c66c85fa1f0dcb0d6867d9a58f7a3ee8479ec124724f6d7b84d8a58939c465ffb24e43754a1889be5f8c946d82d93806765835569e75bd67cbd3ac71071159c13a83bb9"
|
|
1199
|
+
],
|
|
1200
|
+
"5609bc39f79166da75139feb7c335fb7524b3bf0d730db96bf6ebf450d3e165b",
|
|
1201
|
+
"""{'a1': '31', 'a2': '044d60', 'a3': '04010000', 'a4': '0101', 'a5': '04000000', 'a6': '04000000', 'a7': '0400000000000000', 'a8': '0400000000009600ff00ffffffff00', 'a9': '0400000000000000ff00ffffffff00', 'ac': '040000000000000000', 'af': '011d', 'b0': '011e', 'b1': '020900', 'fe': '0300000000'}""",
|
|
1202
|
+
id="prime_power_bank_telemetry_packet",
|
|
1203
|
+
),
|
|
1204
|
+
# Test an Anker Prime device (single payload device) with a single telemetry packet
|
|
1205
|
+
# from the logs of someone elses unit which for some reason transmits telemetry
|
|
1206
|
+
# unencrypted
|
|
1207
|
+
pytest.param(
|
|
1208
|
+
PrimeCharger160w,
|
|
1209
|
+
[
|
|
1210
|
+
"ff09ca000301110300a10131a203024606a303020000a4020100a5080401d8459906bb0ba6080401e81300000000a7080400000000000000a8020103a9020150aa020100ab090400000000000b0b0bac0d0401002c0100002c0100000200ad0d0401002c0100002c0100000201ae0d0401002c0100002c0100000300af020100b0020100b1020100b2020101b30201ffb40d0400000000ac051573fafffbffb50d04ffffffffffffffffffffffffe0050448000000e10b0400000000000000000000fe0503000000006b"
|
|
1211
|
+
],
|
|
1212
|
+
"5609bc39f79166da75139feb7c335fb7524b3bf0d730db96bf6ebf450d3e165b",
|
|
1213
|
+
"""{'a1': '31', 'a2': '024606', 'a3': '020000', 'a4': '0100', 'a5': '0401d8459906bb0b', 'a6': '0401e81300000000', 'a7': '0400000000000000', 'a8': '0103', 'a9': '0150', 'aa': '0100', 'ab': '0400000000000b0b0b', 'ac': '0401002c0100002c0100000200', 'ad': '0401002c0100002c0100000201', 'ae': '0401002c0100002c0100000300', 'af': '0100', 'b0': '0100', 'b1': '0100', 'b2': '0101', 'b3': '01ff', 'b4': '0400000000ac051573fafffbff', 'b5': '04ffffffffffffffffffffffff', 'e0': '0448000000', 'e1': '0400000000000000000000', 'fe': '0300000000'}""",
|
|
1214
|
+
id="prime_telemetry_packet_plain_text",
|
|
1215
|
+
),
|
|
992
1216
|
],
|
|
993
1217
|
)
|
|
994
1218
|
async def test_telemetry_packet_processing(
|
|
@@ -1045,6 +1269,81 @@ async def test_telemetry_packet_processing(
|
|
|
1045
1269
|
assert parameters == device_parameters, "Parameters do not match expected!"
|
|
1046
1270
|
|
|
1047
1271
|
|
|
1272
|
+
@pytest.mark.asyncio
|
|
1273
|
+
@pytest.mark.parametrize(
|
|
1274
|
+
"device_class, packets, secret, expected_logs",
|
|
1275
|
+
[
|
|
1276
|
+
# Telemetry packet from logs of someone elses Prime 160w charger.
|
|
1277
|
+
# Interestingly this packet is not encrypted at all
|
|
1278
|
+
pytest.param(
|
|
1279
|
+
PrimeCharger160w,
|
|
1280
|
+
[
|
|
1281
|
+
"ff09ca000301110300a10131a203024606a303020000a4020100a5080401e042b105b209a6080401e81300000000a7080400000000000000a8020103a9020150aa020100ab090400000000000b0b0bac0d0401002c0100002c0100000200ad0d0401002c0100002c0100000201ae0d0401002c0100002c0100000300af020100b0020100b1020100b2020101b30201ffb40d0400000000ac051573fafffbffb50d04ffffffffffffffffffffffffe0050448000000e10b0400000000000000000000fe05030000000074"
|
|
1282
|
+
],
|
|
1283
|
+
"5609bc39f79166da75139feb7c335fb7524b3bf0d730db96bf6ebf450d3e165b",
|
|
1284
|
+
[
|
|
1285
|
+
"Received non-encrypted telemetry message",
|
|
1286
|
+
"Telemetry parameters: {'a1': '31', 'a2': '024606'",
|
|
1287
|
+
],
|
|
1288
|
+
id="prime_160w_other",
|
|
1289
|
+
),
|
|
1290
|
+
],
|
|
1291
|
+
)
|
|
1292
|
+
async def test_generic_packet_processing(
|
|
1293
|
+
caplog,
|
|
1294
|
+
fast_sleep,
|
|
1295
|
+
fast_timeouts,
|
|
1296
|
+
device_class: type[SolixBLEDevice],
|
|
1297
|
+
packets: list[str],
|
|
1298
|
+
secret: str,
|
|
1299
|
+
expected_logs: list[str],
|
|
1300
|
+
):
|
|
1301
|
+
"""
|
|
1302
|
+
Test the _process_notification function when processing arbitrary
|
|
1303
|
+
packets and check for expected log entries.
|
|
1304
|
+
|
|
1305
|
+
:param device_class: Class of device under test.
|
|
1306
|
+
:param packets: List of packets to send to device.
|
|
1307
|
+
:param secret: Shared secret used as AES key and IV.
|
|
1308
|
+
:param expected_logs: List of expected entries in the debug log.
|
|
1309
|
+
"""
|
|
1310
|
+
|
|
1311
|
+
device = device_class(MOCK_BLE_DEVICE)
|
|
1312
|
+
|
|
1313
|
+
negotiation_responses = (
|
|
1314
|
+
NEGOTIATION_RESPONSES_PRIME
|
|
1315
|
+
if issubclass(device_class, PrimeDevice)
|
|
1316
|
+
else NEGOTIATION_RESPONSES_SOLIX
|
|
1317
|
+
)
|
|
1318
|
+
|
|
1319
|
+
async with MockDevice() as mock_bluetooth:
|
|
1320
|
+
with caplog.at_level(logging.DEBUG):
|
|
1321
|
+
|
|
1322
|
+
# We first expect a negotiation
|
|
1323
|
+
for expected, response in negotiation_responses.items():
|
|
1324
|
+
mock_bluetooth.expect_ordered(
|
|
1325
|
+
bytes.fromhex(expected),
|
|
1326
|
+
[bytes.fromhex(x) for x in response],
|
|
1327
|
+
)
|
|
1328
|
+
|
|
1329
|
+
# We expect the negotiations to succeed
|
|
1330
|
+
assert await device.connect(), "Expected connect to return True"
|
|
1331
|
+
await asyncio.sleep(0.5)
|
|
1332
|
+
assert device.connected, "Expected connected to be True"
|
|
1333
|
+
assert device.negotiated, "Expected connected to be True"
|
|
1334
|
+
mock_bluetooth.check_assertions()
|
|
1335
|
+
|
|
1336
|
+
device._shared_secret = bytes.fromhex(secret)
|
|
1337
|
+
|
|
1338
|
+
for packet in packets:
|
|
1339
|
+
await mock_bluetooth.send_data([bytes.fromhex(packet)])
|
|
1340
|
+
|
|
1341
|
+
for expected_log_entry in expected_logs:
|
|
1342
|
+
assert (
|
|
1343
|
+
expected_log_entry in caplog.text
|
|
1344
|
+
), f"Expected to find '{expected_log_entry}' in logs but it was not found!"
|
|
1345
|
+
|
|
1346
|
+
|
|
1048
1347
|
@pytest.mark.asyncio
|
|
1049
1348
|
@pytest.mark.parametrize(
|
|
1050
1349
|
"device_class,payload,mapping,errors",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|