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.
Files changed (33) hide show
  1. {solixble-3.7.0 → solixble-3.8.0}/PKG-INFO +6 -1
  2. {solixble-3.7.0 → solixble-3.8.0}/README.md +5 -0
  3. {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/__init__.py +3 -1
  4. {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/device.py +71 -42
  5. {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/devices/__init__.py +2 -0
  6. {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/devices/c1000g2.py +102 -20
  7. solixble-3.8.0/SolixBLE/devices/prime_power_bank_20k.py +173 -0
  8. {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/prime_device.py +3 -1
  9. {solixble-3.7.0 → solixble-3.8.0}/SolixBLE.egg-info/PKG-INFO +6 -1
  10. {solixble-3.7.0 → solixble-3.8.0}/SolixBLE.egg-info/SOURCES.txt +1 -0
  11. {solixble-3.7.0 → solixble-3.8.0}/pyproject.toml +8 -1
  12. {solixble-3.7.0 → solixble-3.8.0}/tests/test_devices.py +299 -0
  13. {solixble-3.7.0 → solixble-3.8.0}/LICENSE.txt +0 -0
  14. {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/const.py +0 -0
  15. {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/devices/c1000.py +0 -0
  16. {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/devices/c300.py +0 -0
  17. {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/devices/c300dc.py +0 -0
  18. {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/devices/c800.py +0 -0
  19. {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/devices/f2000.py +0 -0
  20. {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/devices/f3800.py +0 -0
  21. {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/devices/generic.py +0 -0
  22. {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/devices/prime_charger_160w.py +0 -0
  23. {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/devices/prime_charger_250w.py +0 -0
  24. {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/devices/solarbank2.py +0 -0
  25. {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/devices/solarbank3.py +0 -0
  26. {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/states.py +0 -0
  27. {solixble-3.7.0 → solixble-3.8.0}/SolixBLE/utilities.py +0 -0
  28. {solixble-3.7.0 → solixble-3.8.0}/SolixBLE.egg-info/dependency_links.txt +0 -0
  29. {solixble-3.7.0 → solixble-3.8.0}/SolixBLE.egg-info/requires.txt +0 -0
  30. {solixble-3.7.0 → solixble-3.8.0}/SolixBLE.egg-info/top_level.txt +0 -0
  31. {solixble-3.7.0 → solixble-3.8.0}/setup.cfg +0 -0
  32. {solixble-3.7.0 → solixble-3.8.0}/tests/test_connection.py +0 -0
  33. {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.7.0
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(self, payload: bytes, cmd: bytes = None) -> None:
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
- # Encryption negotiation
643
+ # Negotiation messages
619
644
  case "030001":
620
- _LOGGER.debug("Received encryption negotiation message!")
645
+ _LOGGER.debug("Received negotiation message!")
621
646
  return await self._process_negotiation(cmd, payload)
622
647
 
623
- # Encrypted messages
648
+ # Session messages
624
649
  case "03010f" | "030111":
625
650
 
626
- match cmd.hex():
627
-
628
- # Telemetry messages
629
- case "c402" | "4300" | "c405":
630
- _LOGGER.debug("Received telemetry message!")
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
- decrypted_payload = self._decrypt_payload(payload)
652
- _LOGGER.debug(
653
- f"Decrypted payload: {decrypted_payload.hex()}"
654
- )
655
- parameters = self._parse_payload(decrypted_payload)
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
- f"Parameters: {self._parameters_to_str(parameters, types=True)}"
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 monitor a Gen 2 C1000(X) power station.
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(self, payload: bytes, cmd: bytes = None) -> None:
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.7.0
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.7.0"
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