From c49d9bed2d6e8381871cbefacfe7e3600566c3f7 Mon Sep 17 00:00:00 2001 From: Shawn Stricker Date: Fri, 10 Jul 2026 01:31:43 -0400 Subject: [PATCH 1/8] fix(prime): stream telemetry from Prime chargers (live subscribe + ca00 decode) The Prime negotiation replayed the stage-7 subscribe/registration with a hard-coded timestamp, which the device rejects as a replay: it then never streamed telemetry and dropped the link after ~40s. Send stage-7a/7b via _send_command so each carries the live session timestamp. - prime_device: send stage-7 subscribe (4200/a10121) and registration (420a) via _send_command; drop the frozen fe04 trailer from the payload constants. - prime_device: remove the single-packet _process_telemetry_packet override so Prime devices inherit the base multi-fragment reassembly (the 250W ca00 frame arrives as two fragments). - prime_charger_250w: telemetry command ca00; per-port params a4..a9 (C1..A2). Confirmed on an A2345 (250W): negotiates, streams ca00, and decodes per-port voltage/current/power/status matching the Anker cloud values. Co-Authored-By: Claude Opus 4.8 (1M context) --- SolixBLE/devices/prime_charger_250w.py | 68 +++++++++---------- SolixBLE/prime_device.py | 93 ++++++-------------------- 2 files changed, 51 insertions(+), 110 deletions(-) diff --git a/SolixBLE/devices/prime_charger_250w.py b/SolixBLE/devices/prime_charger_250w.py index 45b7468..a501fdf 100644 --- a/SolixBLE/devices/prime_charger_250w.py +++ b/SolixBLE/devices/prime_charger_250w.py @@ -16,28 +16,24 @@ class PrimeCharger250w(PrimeDevice): Use this class to connect and monitor the 250w charger. This model is also known as the A2345. - .. note:: - This model was added using data from anker-solix-api. It has not been - tested! - - .. note:: - It should be possible to add more sensors. I think devices with lots of - telemetry values split them up into multiple messages but I have not - played around with this yet. That and I am being a bit conservative with - these initial implementations, if you want more sensors and are willing - to help with testing feel free to raise a GitHub issue. - + Telemetry has been confirmed on real hardware and cross-checked against the + Anker cloud values. Per-port voltage/current/power/status for the four USB-C + and two USB-A ports live in parameters ``a4`` (C1) through ``a9`` (A2), each a + ``04 `` block. """ _EXPECTED_TELEMETRY_LENGTH: int = 198 + #: The 250w charger streams telemetry on command ``ca00`` (msgtype ``0a00``). + _TELEMETRY_COMMANDS: tuple[str, ...] = ("ca00",) + @property def usb_port_c1(self) -> PortStatus: """USB C1 Port Status. :returns: Status of the USB C1 port. """ - return PortStatus(self._parse_int("a2", begin=1, end=2)) + return PortStatus(self._parse_int("a4", begin=1, end=2)) @property def usb_c1_voltage(self) -> float: @@ -48,7 +44,7 @@ def usb_c1_voltage(self) -> float: if self._data is None: return DEFAULT_METADATA_FLOAT - return self._parse_int("a2", begin=2, end=4) / 1000.0 + return self._parse_int("a4", begin=2, end=4) / 1000.0 @property def usb_c1_current(self) -> float: @@ -59,7 +55,7 @@ def usb_c1_current(self) -> float: if self._data is None: return DEFAULT_METADATA_FLOAT - return self._parse_int("a2", begin=4, end=6) / 1000.0 + return self._parse_int("a4", begin=4, end=6) / 1000.0 @property def usb_c1_power(self) -> float: @@ -70,7 +66,7 @@ def usb_c1_power(self) -> float: if self._data is None: return DEFAULT_METADATA_FLOAT - return self._parse_int("a2", begin=6, end=8) / 100.0 + return self._parse_int("a4", begin=6, end=8) / 100.0 @property def usb_port_c2(self) -> PortStatus: @@ -78,7 +74,7 @@ def usb_port_c2(self) -> PortStatus: :returns: Status of the USB C2 port. """ - return PortStatus(self._parse_int("a3", begin=1, end=2)) + return PortStatus(self._parse_int("a5", begin=1, end=2)) @property def usb_c2_voltage(self) -> float: @@ -89,7 +85,7 @@ def usb_c2_voltage(self) -> float: if self._data is None: return DEFAULT_METADATA_FLOAT - return self._parse_int("a3", begin=2, end=4) / 1000.0 + return self._parse_int("a5", begin=2, end=4) / 1000.0 @property def usb_c2_current(self) -> float: @@ -100,7 +96,7 @@ def usb_c2_current(self) -> float: if self._data is None: return DEFAULT_METADATA_FLOAT - return self._parse_int("a3", begin=4, end=6) / 1000.0 + return self._parse_int("a5", begin=4, end=6) / 1000.0 @property def usb_c2_power(self) -> float: @@ -111,7 +107,7 @@ def usb_c2_power(self) -> float: if self._data is None: return DEFAULT_METADATA_FLOAT - return self._parse_int("a3", begin=6, end=8) / 100.0 + return self._parse_int("a5", begin=6, end=8) / 100.0 @property def usb_port_c3(self) -> PortStatus: @@ -119,7 +115,7 @@ def usb_port_c3(self) -> PortStatus: :returns: Status of the USB C3 port. """ - return PortStatus(self._parse_int("a4", begin=1, end=2)) + return PortStatus(self._parse_int("a6", begin=1, end=2)) @property def usb_c3_voltage(self) -> float: @@ -130,7 +126,7 @@ def usb_c3_voltage(self) -> float: if self._data is None: return DEFAULT_METADATA_FLOAT - return self._parse_int("a4", begin=2, end=4) / 1000.0 + return self._parse_int("a6", begin=2, end=4) / 1000.0 @property def usb_c3_current(self) -> float: @@ -141,7 +137,7 @@ def usb_c3_current(self) -> float: if self._data is None: return DEFAULT_METADATA_FLOAT - return self._parse_int("a4", begin=4, end=6) / 1000.0 + return self._parse_int("a6", begin=4, end=6) / 1000.0 @property def usb_c3_power(self) -> float: @@ -152,7 +148,7 @@ def usb_c3_power(self) -> float: if self._data is None: return DEFAULT_METADATA_FLOAT - return self._parse_int("a4", begin=6, end=8) / 100.0 + return self._parse_int("a6", begin=6, end=8) / 100.0 @property def usb_port_c4(self) -> PortStatus: @@ -160,7 +156,7 @@ def usb_port_c4(self) -> PortStatus: :returns: Status of the USB C4 port. """ - return PortStatus(self._parse_int("a5", begin=1, end=2)) + return PortStatus(self._parse_int("a7", begin=1, end=2)) @property def usb_c4_voltage(self) -> float: @@ -171,18 +167,18 @@ def usb_c4_voltage(self) -> float: if self._data is None: return DEFAULT_METADATA_FLOAT - return self._parse_int("a5", begin=2, end=4) / 1000.0 + return self._parse_int("a7", begin=2, end=4) / 1000.0 @property def usb_c4_current(self) -> float: - """USB C3 Port current (A). + """USB C4 Port current (A). :returns: Current of the USB C4 port or default float value. """ if self._data is None: return DEFAULT_METADATA_FLOAT - return self._parse_int("a5", begin=4, end=6) / 1000.0 + return self._parse_int("a7", begin=4, end=6) / 1000.0 @property def usb_c4_power(self) -> float: @@ -193,7 +189,7 @@ def usb_c4_power(self) -> float: if self._data is None: return DEFAULT_METADATA_FLOAT - return self._parse_int("a5", begin=6, end=8) / 100.0 + return self._parse_int("a7", begin=6, end=8) / 100.0 @property def usb_port_a1(self) -> PortStatus: @@ -201,7 +197,7 @@ def usb_port_a1(self) -> PortStatus: :returns: Status of the USB A1 port. """ - return PortStatus(self._parse_int("a6", begin=1, end=2)) + return PortStatus(self._parse_int("a8", begin=1, end=2)) @property def usb_a1_voltage(self) -> float: @@ -212,7 +208,7 @@ def usb_a1_voltage(self) -> float: if self._data is None: return DEFAULT_METADATA_FLOAT - return self._parse_int("a6", begin=2, end=4) / 1000.0 + return self._parse_int("a8", begin=2, end=4) / 1000.0 @property def usb_a1_current(self) -> float: @@ -223,7 +219,7 @@ def usb_a1_current(self) -> float: if self._data is None: return DEFAULT_METADATA_FLOAT - return self._parse_int("a6", begin=4, end=6) / 1000.0 + return self._parse_int("a8", begin=4, end=6) / 1000.0 @property def usb_a1_power(self) -> float: @@ -234,7 +230,7 @@ def usb_a1_power(self) -> float: if self._data is None: return DEFAULT_METADATA_FLOAT - return self._parse_int("a6", begin=6, end=8) / 100.0 + return self._parse_int("a8", begin=6, end=8) / 100.0 @property def usb_port_a2(self) -> PortStatus: @@ -242,7 +238,7 @@ def usb_port_a2(self) -> PortStatus: :returns: Status of the USB A2 port. """ - return PortStatus(self._parse_int("a7", begin=1, end=2)) + return PortStatus(self._parse_int("a9", begin=1, end=2)) @property def usb_a2_voltage(self) -> float: @@ -253,7 +249,7 @@ def usb_a2_voltage(self) -> float: if self._data is None: return DEFAULT_METADATA_FLOAT - return self._parse_int("a7", begin=2, end=4) / 1000.0 + return self._parse_int("a9", begin=2, end=4) / 1000.0 @property def usb_a2_current(self) -> float: @@ -264,7 +260,7 @@ def usb_a2_current(self) -> float: if self._data is None: return DEFAULT_METADATA_FLOAT - return self._parse_int("a7", begin=4, end=6) / 1000.0 + return self._parse_int("a9", begin=4, end=6) / 1000.0 @property def usb_a2_power(self) -> float: @@ -275,4 +271,4 @@ def usb_a2_power(self) -> float: if self._data is None: return DEFAULT_METADATA_FLOAT - return self._parse_int("a7", begin=6, end=8) / 100.0 + return self._parse_int("a9", begin=6, end=8) / 100.0 diff --git a/SolixBLE/prime_device.py b/SolixBLE/prime_device.py index 7064ebe..012ca1a 100644 --- a/SolixBLE/prime_device.py +++ b/SolixBLE/prime_device.py @@ -55,17 +55,17 @@ #: The payload to put in the response to receiving 6th negotiation message NEGOTIATION_COMMAND_6_PAYLOAD = "a104f079b569a22437396562656433352d646339632d343930342d623430632d373263346538363361613130" -#: The cmd to put in the first response to receiving 7th negotiation message +#: Stage 7a starts the telemetry stream; its body matches the C1000 Gen 2 +#: subscribe (a10121). Sent via _send_command so the current session timestamp is +#: appended: the device rejects a stale timestamp as a replay and then never +#: streams, so this body must NOT carry a hard-coded fe04 trailer. NEGOTIATION_COMMAND_7_CMD = "4200" +NEGOTIATION_COMMAND_7_PAYLOAD = "a10121" -#: The payload to put in the first response to receiving 7th negotiation message -NEGOTIATION_COMMAND_7_PAYLOAD = "a10121fe04f079b569" - -#: The cmd to put in the second response to receiving 7th negotiation message +#: Stage 7b registers the client (region + app UUID). Also sent via _send_command, +#: so likewise no hard-coded timestamp trailer. NEGOTIATION_COMMAND_8_CMD = "420a" - -#: The payload to put in the second response to receiving 7th negotiation message -NEGOTIATION_COMMAND_8_PAYLOAD = "a10121a203044742a3250437396562656433352d646339632d343930342d623430632d373263346538363361613130a5020101fe04f079b569" +NEGOTIATION_COMMAND_8_PAYLOAD = "a10121a203044742a3250437396562656433352d646339632d343930342d623430632d373263346538363361613130a5020101" #: Anker Prime devices encrypt the negotiation using a static key NEGOTIATION_KEY = "b8ff7422955d4eb6d554a2c470280559" @@ -421,61 +421,20 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: ) decrypted_payload = self._decrypt_payload(payload) _LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}") - parameters = self._parse_payload(decrypted_payload) - _LOGGER.debug( - f"Parameters: {self._parameters_to_str(parameters, types=True)}" - ) - - _LOGGER.debug("Sending stage 7 response messages...") - # Packet A - new_payload_a = self._encrypt_payload( - bytes.fromhex(NEGOTIATION_COMMAND_7_PAYLOAD) + # Stage 7 is the telemetry subscribe (7a) plus client registration + # (7b). Both MUST go through _send_command so each carries the + # current session timestamp: the device rejects a stale timestamp + # as a replay and then holds the link but never streams telemetry. + _LOGGER.debug("Sending stage 7 subscribe + registration...") + await self._send_command( + bytes.fromhex(NEGOTIATION_COMMAND_7_CMD), + bytes.fromhex(NEGOTIATION_COMMAND_7_PAYLOAD), ) - new_packet_a = self._build_packet( - pattern=bytes.fromhex(TELEMETRY_PATTERN), - cmd=bytes.fromhex(NEGOTIATION_COMMAND_7_CMD), - payload=new_payload_a, - ) - _LOGGER.debug(f"Built stage 7a response packet: {new_packet_a.hex()}") - await self._client.write_gatt_char( - UUID_COMMAND, - new_packet_a, + await self._send_command( + bytes.fromhex(NEGOTIATION_COMMAND_8_CMD), + bytes.fromhex(NEGOTIATION_COMMAND_8_PAYLOAD), ) - - # Log parameters we will send if debugging (makes handshake easier to see in logs) - if _LOGGER.isEnabledFor(logging.DEBUG): - new_parameters = self._parse_payload( - bytes.fromhex(NEGOTIATION_COMMAND_7_PAYLOAD) - ) - _LOGGER.debug( - f"Stage 7a response message parameters: {self._parameters_to_str(new_parameters, types=True)}" - ) - - # Packet B - new_payload_b = self._encrypt_payload( - bytes.fromhex(NEGOTIATION_COMMAND_8_PAYLOAD) - ) - new_packet_b = self._build_packet( - pattern=bytes.fromhex(TELEMETRY_PATTERN), - cmd=bytes.fromhex(NEGOTIATION_COMMAND_8_CMD), - payload=new_payload_b, - ) - _LOGGER.debug(f"Built stage 7b response packet: {new_packet_b.hex()}") - await self._client.write_gatt_char( - UUID_COMMAND, - new_packet_b, - ) - - # Log parameters we will send if debugging (makes handshake easier to see in logs) - if _LOGGER.isEnabledFor(logging.DEBUG): - new_parameters = self._parse_payload( - bytes.fromhex(NEGOTIATION_COMMAND_8_PAYLOAD) - ) - _LOGGER.debug( - f"Stage 7b response message parameters: {self._parameters_to_str(new_parameters, types=True)}" - ) - return case _: @@ -487,20 +446,6 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: # Packet processing # ##################### - async def _process_telemetry_packet( - self, payload: bytes, cmd: bytes = None - ) -> None: - """ - Process a telemetry packet from an Anker Prime device. - - Anker Prime devices pack all telemetry data into a single packet - requiring no special logic to handle. - """ - decrypted_payload = self._decrypt_payload(payload) - _LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}") - parameters = self._parse_payload(decrypted_payload) - return await self._process_telemetry(parameters) - async def _send_command(self, cmd: bytes, payload: bytes) -> None: """Send a command to the device. From a831524e77fc4389cf5c3b0edd81ca09e3ef8234 Mon Sep 17 00:00:00 2001 From: Shawn Stricker Date: Fri, 10 Jul 2026 02:08:12 -0400 Subject: [PATCH 2/8] feat(prime): expose serial, total power, and per-port switches (A2345) - prime_device: persist the stage-3 device identity (serial/BLE-firmware/MAC), which is sent only during negotiation and never in telemetry. - prime_charger_250w: add serial_number (from that identity), total_power_out (sum of the six ports), and the five usbc_/usba_switch states (fields aa-ae). Confirmed on an A2345: serial AQLQJB1G06101746, total 13.42 W, switches on. Co-Authored-By: Claude Opus 4.8 (1M context) --- SolixBLE/devices/prime_charger_250w.py | 92 +++++++++++++++++++++++++- SolixBLE/prime_device.py | 4 ++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/SolixBLE/devices/prime_charger_250w.py b/SolixBLE/devices/prime_charger_250w.py index a501fdf..eb799a6 100644 --- a/SolixBLE/devices/prime_charger_250w.py +++ b/SolixBLE/devices/prime_charger_250w.py @@ -4,7 +4,11 @@ """ -from ..const import DEFAULT_METADATA_FLOAT +from ..const import ( + DEFAULT_METADATA_BOOL, + DEFAULT_METADATA_FLOAT, + DEFAULT_METADATA_STRING, +) from ..prime_device import PrimeDevice from ..states import PortStatus @@ -272,3 +276,89 @@ def usb_a2_power(self) -> float: return DEFAULT_METADATA_FLOAT return self._parse_int("a9", begin=6, end=8) / 100.0 + + @property + def serial_number(self) -> str: + """Device serial number. + + Read from the negotiation handshake; the telemetry frame does not carry + the serial number. + + :returns: Device serial number or default str value. + """ + value = (getattr(self, "_device_info", None) or {}).get("a4", b"") + return value.decode("ascii", "ignore") if value else DEFAULT_METADATA_STRING + + @property + def total_power_out(self) -> float: + """Total output power across all six ports (W). + + :returns: Sum of the per-port powers or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return round( + self.usb_c1_power + + self.usb_c2_power + + self.usb_c3_power + + self.usb_c4_power + + self.usb_a1_power + + self.usb_a2_power, + 2, + ) + + @property + def usb_c1_switch(self) -> bool: + """USB C1 port on/off switch state. + + :returns: True when the port is switched on or default bool value. + """ + if self._data is None: + return DEFAULT_METADATA_BOOL + + return bool(self._parse_int("aa", begin=1, end=2)) + + @property + def usb_c2_switch(self) -> bool: + """USB C2 port on/off switch state. + + :returns: True when the port is switched on or default bool value. + """ + if self._data is None: + return DEFAULT_METADATA_BOOL + + return bool(self._parse_int("ab", begin=1, end=2)) + + @property + def usb_c3_switch(self) -> bool: + """USB C3 port on/off switch state. + + :returns: True when the port is switched on or default bool value. + """ + if self._data is None: + return DEFAULT_METADATA_BOOL + + return bool(self._parse_int("ac", begin=1, end=2)) + + @property + def usb_c4_switch(self) -> bool: + """USB C4 port on/off switch state. + + :returns: True when the port is switched on or default bool value. + """ + if self._data is None: + return DEFAULT_METADATA_BOOL + + return bool(self._parse_int("ad", begin=1, end=2)) + + @property + def usba_switch(self) -> bool: + """USB-A ports on/off switch state (shared by both A ports). + + :returns: True when the USB-A ports are switched on or default bool value. + """ + if self._data is None: + return DEFAULT_METADATA_BOOL + + return bool(self._parse_int("ae", begin=1, end=2)) diff --git a/SolixBLE/prime_device.py b/SolixBLE/prime_device.py index 012ca1a..1757f09 100644 --- a/SolixBLE/prime_device.py +++ b/SolixBLE/prime_device.py @@ -264,6 +264,10 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: decrypted_payload = self._decrypt_payload(payload) _LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}") parameters = self._parse_payload(decrypted_payload) + # Device identity (chip, BLE firmware, serial, MAC) is sent only + # in this stage-3 message, never in telemetry, so persist it for + # the identity properties to read. + self._device_info = parameters _LOGGER.debug( f"Parameters: {self._parameters_to_str(parameters, types=True)}" ) From 5faf7d1810d76ba57385cc054dadd5be43ec2190 Mon Sep 17 00:00:00 2001 From: Shawn Stricker Date: Fri, 10 Jul 2026 09:33:03 -0400 Subject: [PATCH 3/8] feat(prime): add A91B2 charging station + shared base + post-connect refactor - Extract PrimeUsbCharger (six-USB-port telemetry + serial + the ca00 command), shared by both Prime chargers; PrimeCharger250w becomes a thin subclass. - Add PrimeChargingStation240w (A91B2): USB ports via the base plus the two AC-outlet switch states (ac_1_switch/ac_2_switch); decode validated against the anker-solix-api A91B2 map. Live telemetry pending its post-ECDH enable step. - Refactor negotiation: the device's trailing 4822/4827 after the stage-5 ECDH are acks, not stages needing a response. Move the registration (4027) and subscribe (4200) to _post_connect, mirroring the gen-2 power stations' 4100. Negotiation writes use write-with-response for reliability on weaker links. - PrimeChargingStation240w overrides _post_connect to a no-op: it rejects the charger's 4027 registration (dropping the link) and its own enable sequence is unknown (needs an Anker-app handshake capture), so it negotiates and stays connected but does not stream yet. Confirmed on an A2345: negotiates, registers + subscribes from _post_connect, and streams ca00 telemetry, decoding per-port values matching the cloud. Co-Authored-By: Claude Opus 4.8 (1M context) --- SolixBLE/__init__.py | 4 + SolixBLE/devices/__init__.py | 4 + SolixBLE/devices/prime_charger_250w.py | 278 +---------------- .../devices/prime_charging_station_240w.py | 81 +++++ SolixBLE/devices/prime_usb_charger.py | 280 ++++++++++++++++++ SolixBLE/prime_device.py | 108 +++---- 6 files changed, 414 insertions(+), 341 deletions(-) create mode 100644 SolixBLE/devices/prime_charging_station_240w.py create mode 100644 SolixBLE/devices/prime_usb_charger.py diff --git a/SolixBLE/__init__.py b/SolixBLE/__init__.py index d6c614f..9f7078d 100644 --- a/SolixBLE/__init__.py +++ b/SolixBLE/__init__.py @@ -17,7 +17,9 @@ MagGo3in1, PrimeCharger160w, PrimeCharger250w, + PrimeChargingStation240w, PrimePowerBank20k, + PrimeUsbCharger, Solarbank2, Solarbank3, ) @@ -47,7 +49,9 @@ "Solarbank3", "PrimeCharger160w", "PrimeCharger250w", + "PrimeChargingStation240w", "PrimePowerBank20k", + "PrimeUsbCharger", "MagGo3in1", "Generic", "ChargingStatus", diff --git a/SolixBLE/devices/__init__.py b/SolixBLE/devices/__init__.py index dbae16a..08588a7 100644 --- a/SolixBLE/devices/__init__.py +++ b/SolixBLE/devices/__init__.py @@ -15,7 +15,9 @@ from .maggo_3in1 import MagGo3in1 from .prime_charger_160w import PrimeCharger160w from .prime_charger_250w import PrimeCharger250w +from .prime_charging_station_240w import PrimeChargingStation240w from .prime_power_bank_20k import PrimePowerBank20k +from .prime_usb_charger import PrimeUsbCharger from .solarbank2 import Solarbank2 from .solarbank3 import Solarbank3 @@ -31,7 +33,9 @@ "Solarbank3", "PrimeCharger160w", "PrimeCharger250w", + "PrimeChargingStation240w", "PrimePowerBank20k", + "PrimeUsbCharger", "MagGo3in1", "Generic", ] diff --git a/SolixBLE/devices/prime_charger_250w.py b/SolixBLE/devices/prime_charger_250w.py index eb799a6..973f208 100644 --- a/SolixBLE/devices/prime_charger_250w.py +++ b/SolixBLE/devices/prime_charger_250w.py @@ -4,16 +4,11 @@ """ -from ..const import ( - DEFAULT_METADATA_BOOL, - DEFAULT_METADATA_FLOAT, - DEFAULT_METADATA_STRING, -) -from ..prime_device import PrimeDevice -from ..states import PortStatus +from ..const import DEFAULT_METADATA_BOOL, DEFAULT_METADATA_FLOAT +from .prime_usb_charger import PrimeUsbCharger -class PrimeCharger250w(PrimeDevice): +class PrimeCharger250w(PrimeUsbCharger): """ Anker Prime Charger (250W) model. @@ -21,274 +16,13 @@ class PrimeCharger250w(PrimeDevice): This model is also known as the A2345. Telemetry has been confirmed on real hardware and cross-checked against the - Anker cloud values. Per-port voltage/current/power/status for the four USB-C - and two USB-A ports live in parameters ``a4`` (C1) through ``a9`` (A2), each a - ``04 `` block. + Anker cloud values. The per-port voltage/current/power/status live in the + base class; this model adds the total output power and per-port on/off + switch states (fields ``aa``-``ae``). """ _EXPECTED_TELEMETRY_LENGTH: int = 198 - #: The 250w charger streams telemetry on command ``ca00`` (msgtype ``0a00``). - _TELEMETRY_COMMANDS: tuple[str, ...] = ("ca00",) - - @property - def usb_port_c1(self) -> PortStatus: - """USB C1 Port Status. - - :returns: Status of the USB C1 port. - """ - return PortStatus(self._parse_int("a4", begin=1, end=2)) - - @property - def usb_c1_voltage(self) -> float: - """USB C1 Port voltage (V). - - :returns: Voltage of the USB C1 port or default float value. - """ - if self._data is None: - return DEFAULT_METADATA_FLOAT - - return self._parse_int("a4", begin=2, end=4) / 1000.0 - - @property - def usb_c1_current(self) -> float: - """USB C1 Port current (A). - - :returns: Current of the USB C1 port or default float value. - """ - if self._data is None: - return DEFAULT_METADATA_FLOAT - - return self._parse_int("a4", begin=4, end=6) / 1000.0 - - @property - def usb_c1_power(self) -> float: - """USB C1 Port power (W). - - :returns: Power of the USB C1 port or default float value. - """ - if self._data is None: - return DEFAULT_METADATA_FLOAT - - return self._parse_int("a4", begin=6, end=8) / 100.0 - - @property - def usb_port_c2(self) -> PortStatus: - """USB C2 Port Status. - - :returns: Status of the USB C2 port. - """ - return PortStatus(self._parse_int("a5", begin=1, end=2)) - - @property - def usb_c2_voltage(self) -> float: - """USB C2 Port voltage (V). - - :returns: Voltage of the USB C2 port or default float value. - """ - if self._data is None: - return DEFAULT_METADATA_FLOAT - - return self._parse_int("a5", begin=2, end=4) / 1000.0 - - @property - def usb_c2_current(self) -> float: - """USB C2 Port current (A). - - :returns: Current of the USB C2 port or default float value. - """ - if self._data is None: - return DEFAULT_METADATA_FLOAT - - return self._parse_int("a5", begin=4, end=6) / 1000.0 - - @property - def usb_c2_power(self) -> float: - """USB C2 Port power (W). - - :returns: Power of the USB C2 port or default float value. - """ - if self._data is None: - return DEFAULT_METADATA_FLOAT - - return self._parse_int("a5", begin=6, end=8) / 100.0 - - @property - def usb_port_c3(self) -> PortStatus: - """USB C3 Port Status. - - :returns: Status of the USB C3 port. - """ - return PortStatus(self._parse_int("a6", begin=1, end=2)) - - @property - def usb_c3_voltage(self) -> float: - """USB C3 Port voltage (V). - - :returns: Voltage of the USB C3 port or default float value. - """ - if self._data is None: - return DEFAULT_METADATA_FLOAT - - return self._parse_int("a6", begin=2, end=4) / 1000.0 - - @property - def usb_c3_current(self) -> float: - """USB C3 Port current (A). - - :returns: Current of the USB C3 port or default float value. - """ - if self._data is None: - return DEFAULT_METADATA_FLOAT - - return self._parse_int("a6", begin=4, end=6) / 1000.0 - - @property - def usb_c3_power(self) -> float: - """USB C3 Port power (W). - - :returns: Power of the USB C3 port or default float value. - """ - if self._data is None: - return DEFAULT_METADATA_FLOAT - - return self._parse_int("a6", begin=6, end=8) / 100.0 - - @property - def usb_port_c4(self) -> PortStatus: - """USB C4 Port Status. - - :returns: Status of the USB C4 port. - """ - return PortStatus(self._parse_int("a7", begin=1, end=2)) - - @property - def usb_c4_voltage(self) -> float: - """USB C4 Port voltage (V). - - :returns: Voltage of the USB C4 port or default float value. - """ - if self._data is None: - return DEFAULT_METADATA_FLOAT - - return self._parse_int("a7", begin=2, end=4) / 1000.0 - - @property - def usb_c4_current(self) -> float: - """USB C4 Port current (A). - - :returns: Current of the USB C4 port or default float value. - """ - if self._data is None: - return DEFAULT_METADATA_FLOAT - - return self._parse_int("a7", begin=4, end=6) / 1000.0 - - @property - def usb_c4_power(self) -> float: - """USB C4 Port power (W). - - :returns: Power of the USB C4 port or default float value. - """ - if self._data is None: - return DEFAULT_METADATA_FLOAT - - return self._parse_int("a7", begin=6, end=8) / 100.0 - - @property - def usb_port_a1(self) -> PortStatus: - """USB A1 Port Status. - - :returns: Status of the USB A1 port. - """ - return PortStatus(self._parse_int("a8", begin=1, end=2)) - - @property - def usb_a1_voltage(self) -> float: - """USB A1 Port voltage (V). - - :returns: Voltage of the USB A1 port or default float value. - """ - if self._data is None: - return DEFAULT_METADATA_FLOAT - - return self._parse_int("a8", begin=2, end=4) / 1000.0 - - @property - def usb_a1_current(self) -> float: - """USB A1 Port current (A). - - :returns: Current of the USB A1 port or default float value. - """ - if self._data is None: - return DEFAULT_METADATA_FLOAT - - return self._parse_int("a8", begin=4, end=6) / 1000.0 - - @property - def usb_a1_power(self) -> float: - """USB A1 Port power (W). - - :returns: Power of the USB A1 port or default float value. - """ - if self._data is None: - return DEFAULT_METADATA_FLOAT - - return self._parse_int("a8", begin=6, end=8) / 100.0 - - @property - def usb_port_a2(self) -> PortStatus: - """USB A2 Port Status. - - :returns: Status of the USB A2 port. - """ - return PortStatus(self._parse_int("a9", begin=1, end=2)) - - @property - def usb_a2_voltage(self) -> float: - """USB A2 Port voltage (V). - - :returns: Voltage of the USB A2 port or default float value. - """ - if self._data is None: - return DEFAULT_METADATA_FLOAT - - return self._parse_int("a9", begin=2, end=4) / 1000.0 - - @property - def usb_a2_current(self) -> float: - """USB A2 Port current (A). - - :returns: Current of the USB A2 port or default float value. - """ - if self._data is None: - return DEFAULT_METADATA_FLOAT - - return self._parse_int("a9", begin=4, end=6) / 1000.0 - - @property - def usb_a2_power(self) -> float: - """USB A2 Port power (W). - - :returns: Power of the USB A2 port or default float value. - """ - if self._data is None: - return DEFAULT_METADATA_FLOAT - - return self._parse_int("a9", begin=6, end=8) / 100.0 - - @property - def serial_number(self) -> str: - """Device serial number. - - Read from the negotiation handshake; the telemetry frame does not carry - the serial number. - - :returns: Device serial number or default str value. - """ - value = (getattr(self, "_device_info", None) or {}).get("a4", b"") - return value.decode("ascii", "ignore") if value else DEFAULT_METADATA_STRING - @property def total_power_out(self) -> float: """Total output power across all six ports (W). diff --git a/SolixBLE/devices/prime_charging_station_240w.py b/SolixBLE/devices/prime_charging_station_240w.py new file mode 100644 index 0000000..92eb19b --- /dev/null +++ b/SolixBLE/devices/prime_charging_station_240w.py @@ -0,0 +1,81 @@ +"""Anker Prime Charging Station (240W) model. + +.. moduleauthor:: Harvey Lelliott (flip-dots) + +""" + +from ..const import DEFAULT_METADATA_BOOL, DEFAULT_METADATA_FLOAT +from .prime_usb_charger import PrimeUsbCharger + + +class PrimeChargingStation240w(PrimeUsbCharger): + """ + Anker Prime Charging Station (240W) model. + + Use this class to connect and monitor the 240w charging station. This model + is also known as the A91B2. It shares the four USB-C and two USB-A ports of + the 250W charger (decoded by the base class) and adds two switchable 120V AC + outlets and a display. + + .. note:: + Modelled on the anker-solix-api ``A91B2`` map. The AC-outlet switch + states are decoded here; AC-outlet power/voltage and the display state + are not yet mapped (they are absent from the cloud map too) and await a + live capture with an AC load. + """ + + _EXPECTED_TELEMETRY_LENGTH: int = 198 + + async def _post_connect(self) -> None: + """No post-connect subscribe yet -- the station's enable sequence is unknown. + + The 250W charger's registration (command 4027) is rejected by the station + (it drops the link), and no subscribe alone starts its stream, so the base + PrimeDevice._post_connect is not used here. The correct post-ECDH enable + needs an Anker-app handshake capture; until then the station negotiates and + stays connected but does not stream telemetry. + """ + return + + @property + def usb_total_power_out(self) -> float: + """Total USB output power across the six USB ports (W). + + Excludes the AC outlets, whose power is not yet decoded. + + :returns: Sum of the six USB port powers or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return round( + self.usb_c1_power + + self.usb_c2_power + + self.usb_c3_power + + self.usb_c4_power + + self.usb_a1_power + + self.usb_a2_power, + 2, + ) + + @property + def ac_1_switch(self) -> bool: + """AC outlet 1 on/off switch state. + + :returns: True when AC outlet 1 is switched on or default bool value. + """ + if self._data is None: + return DEFAULT_METADATA_BOOL + + return bool(self._parse_int("aa", begin=1, end=2)) + + @property + def ac_2_switch(self) -> bool: + """AC outlet 2 on/off switch state. + + :returns: True when AC outlet 2 is switched on or default bool value. + """ + if self._data is None: + return DEFAULT_METADATA_BOOL + + return bool(self._parse_int("ab", begin=1, end=2)) diff --git a/SolixBLE/devices/prime_usb_charger.py b/SolixBLE/devices/prime_usb_charger.py new file mode 100644 index 0000000..d0f3a2d --- /dev/null +++ b/SolixBLE/devices/prime_usb_charger.py @@ -0,0 +1,280 @@ +"""Shared base for Anker Prime USB chargers (A2345 250W, A91B2 240W station). + +.. moduleauthor:: Harvey Lelliott (flip-dots) + +""" + +from ..const import DEFAULT_METADATA_FLOAT, DEFAULT_METADATA_STRING +from ..prime_device import PrimeDevice +from ..states import PortStatus + + +class PrimeUsbCharger(PrimeDevice): + """Base for Prime chargers that stream per-USB-port telemetry on ``ca00``. + + The four USB-C and two USB-A ports live in parameters ``a4`` (C1) through + ``a9`` (A2), each a ``04 `` block. + Subclasses add their model-specific fields (USB switches on the 250W charger, + AC-outlet switches on the 240W station). + """ + + #: Prime chargers stream telemetry on command ``ca00`` (msgtype ``0a00``). + _TELEMETRY_COMMANDS: tuple[str, ...] = ("ca00",) + + @property + def serial_number(self) -> str: + """Device serial number. + + Read from the negotiation handshake; the telemetry frame does not carry + the serial number. + + :returns: Device serial number or default str value. + """ + value = (getattr(self, "_device_info", None) or {}).get("a4", b"") + return value.decode("ascii", "ignore") if value else DEFAULT_METADATA_STRING + + @property + def usb_port_c1(self) -> PortStatus: + """USB C1 Port Status. + + :returns: Status of the USB C1 port. + """ + return PortStatus(self._parse_int("a4", begin=1, end=2)) + + @property + def usb_c1_voltage(self) -> float: + """USB C1 Port voltage (V). + + :returns: Voltage of the USB C1 port or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("a4", begin=2, end=4) / 1000.0 + + @property + def usb_c1_current(self) -> float: + """USB C1 Port current (A). + + :returns: Current of the USB C1 port or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("a4", begin=4, end=6) / 1000.0 + + @property + def usb_c1_power(self) -> float: + """USB C1 Port power (W). + + :returns: Power of the USB C1 port or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("a4", begin=6, end=8) / 100.0 + + @property + def usb_port_c2(self) -> PortStatus: + """USB C2 Port Status. + + :returns: Status of the USB C2 port. + """ + return PortStatus(self._parse_int("a5", begin=1, end=2)) + + @property + def usb_c2_voltage(self) -> float: + """USB C2 Port voltage (V). + + :returns: Voltage of the USB C2 port or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("a5", begin=2, end=4) / 1000.0 + + @property + def usb_c2_current(self) -> float: + """USB C2 Port current (A). + + :returns: Current of the USB C2 port or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("a5", begin=4, end=6) / 1000.0 + + @property + def usb_c2_power(self) -> float: + """USB C2 Port power (W). + + :returns: Power of the USB C2 port or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("a5", begin=6, end=8) / 100.0 + + @property + def usb_port_c3(self) -> PortStatus: + """USB C3 Port Status. + + :returns: Status of the USB C3 port. + """ + return PortStatus(self._parse_int("a6", begin=1, end=2)) + + @property + def usb_c3_voltage(self) -> float: + """USB C3 Port voltage (V). + + :returns: Voltage of the USB C3 port or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("a6", begin=2, end=4) / 1000.0 + + @property + def usb_c3_current(self) -> float: + """USB C3 Port current (A). + + :returns: Current of the USB C3 port or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("a6", begin=4, end=6) / 1000.0 + + @property + def usb_c3_power(self) -> float: + """USB C3 Port power (W). + + :returns: Power of the USB C3 port or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("a6", begin=6, end=8) / 100.0 + + @property + def usb_port_c4(self) -> PortStatus: + """USB C4 Port Status. + + :returns: Status of the USB C4 port. + """ + return PortStatus(self._parse_int("a7", begin=1, end=2)) + + @property + def usb_c4_voltage(self) -> float: + """USB C4 Port voltage (V). + + :returns: Voltage of the USB C4 port or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("a7", begin=2, end=4) / 1000.0 + + @property + def usb_c4_current(self) -> float: + """USB C4 Port current (A). + + :returns: Current of the USB C4 port or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("a7", begin=4, end=6) / 1000.0 + + @property + def usb_c4_power(self) -> float: + """USB C4 Port power (W). + + :returns: Power of the USB C4 port or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("a7", begin=6, end=8) / 100.0 + + @property + def usb_port_a1(self) -> PortStatus: + """USB A1 Port Status. + + :returns: Status of the USB A1 port. + """ + return PortStatus(self._parse_int("a8", begin=1, end=2)) + + @property + def usb_a1_voltage(self) -> float: + """USB A1 Port voltage (V). + + :returns: Voltage of the USB A1 port or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("a8", begin=2, end=4) / 1000.0 + + @property + def usb_a1_current(self) -> float: + """USB A1 Port current (A). + + :returns: Current of the USB A1 port or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("a8", begin=4, end=6) / 1000.0 + + @property + def usb_a1_power(self) -> float: + """USB A1 Port power (W). + + :returns: Power of the USB A1 port or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("a8", begin=6, end=8) / 100.0 + + @property + def usb_port_a2(self) -> PortStatus: + """USB A2 Port Status. + + :returns: Status of the USB A2 port. + """ + return PortStatus(self._parse_int("a9", begin=1, end=2)) + + @property + def usb_a2_voltage(self) -> float: + """USB A2 Port voltage (V). + + :returns: Voltage of the USB A2 port or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("a9", begin=2, end=4) / 1000.0 + + @property + def usb_a2_current(self) -> float: + """USB A2 Port current (A). + + :returns: Current of the USB A2 port or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("a9", begin=4, end=6) / 1000.0 + + @property + def usb_a2_power(self) -> float: + """USB A2 Port power (W). + + :returns: Power of the USB A2 port or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("a9", begin=6, end=8) / 100.0 diff --git a/SolixBLE/prime_device.py b/SolixBLE/prime_device.py index 1757f09..2f69728 100644 --- a/SolixBLE/prime_device.py +++ b/SolixBLE/prime_device.py @@ -4,6 +4,7 @@ """ +import asyncio import logging import time @@ -181,7 +182,7 @@ async def _initiate_negotiations(self) -> None: ) await self._client.write_gatt_char( - UUID_COMMAND, bytes.fromhex(NEGOTIATION_COMMAND_0) + UUID_COMMAND, bytes.fromhex(NEGOTIATION_COMMAND_0), response=True ) async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: @@ -190,7 +191,6 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: """ match cmd.hex(): - # There is a "stage 0" in which we automatically send a negotiation # request as soon as we establish the initial connection. That # should lead to the power station sending a response landing us @@ -223,8 +223,7 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: _LOGGER.debug("Sending stage 1 response message...") return await self._client.write_gatt_char( - UUID_COMMAND, - bytes.fromhex(NEGOTIATION_COMMAND_1), + UUID_COMMAND, bytes.fromhex(NEGOTIATION_COMMAND_1) ) # Negotiation stage 2 @@ -252,8 +251,7 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: _LOGGER.debug("Sending stage 2 response message...") return await self._client.write_gatt_char( - UUID_COMMAND, - bytes.fromhex(NEGOTIATION_COMMAND_2), + UUID_COMMAND, bytes.fromhex(NEGOTIATION_COMMAND_2) ) # Negotiation stage 3 @@ -285,8 +283,7 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: _LOGGER.debug("Sending stage 3 response message...") return await self._client.write_gatt_char( - UUID_COMMAND, - bytes.fromhex(NEGOTIATION_COMMAND_3), + UUID_COMMAND, bytes.fromhex(NEGOTIATION_COMMAND_3) ) # Negotiation stage 4 @@ -314,8 +311,7 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: _LOGGER.debug("Sending stage 4 response message...") return await self._client.write_gatt_char( - UUID_COMMAND, - bytes.fromhex(NEGOTIATION_COMMAND_4), + UUID_COMMAND, bytes.fromhex(NEGOTIATION_COMMAND_4) ) # Negotiation stage 5 @@ -379,73 +375,47 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: new_packet, ) - # Negotiations past this point are encrypted using the shared secret - - # Negotiation stage 6 - case "4822": - _LOGGER.debug( - "Entered negotiation stage 6 due to response from device!" - ) - decrypted_payload = self._decrypt_payload(payload) - _LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}") - parameters = self._parse_payload(decrypted_payload) - _LOGGER.debug( - f"Parameters: {self._parameters_to_str(parameters, types=True)}" - ) - - _LOGGER.debug("Sending stage 6 response message...") - - # Log parameters we will send if debugging (makes handshake easier to see in logs) - if _LOGGER.isEnabledFor(logging.DEBUG): - new_parameters = self._parse_payload( - bytes.fromhex(NEGOTIATION_COMMAND_6_PAYLOAD) - ) - _LOGGER.debug( - f"Stage 6 response message parameters: {self._parameters_to_str(new_parameters, types=True)}" - ) - - new_payload = self._encrypt_payload( - bytes.fromhex(NEGOTIATION_COMMAND_6_PAYLOAD) - ) - new_packet = self._build_packet( - pattern=bytes.fromhex(NEGOTIATION_PATTERN), - cmd=bytes.fromhex(NEGOTIATION_COMMAND_6_CMD), - payload=new_payload, - ) - _LOGGER.debug(f"Built stage 6 response packet: {new_packet.hex()}") - return await self._client.write_gatt_char( - UUID_COMMAND, - new_packet, - ) - - # Negotiation stage 7 - case "4827": + # The ECDH handshake is complete after stage 5; the device's trailing + # "stage 6/7" messages (4822/4827) are just acks. The registration and + # telemetry subscribe are post-connect commands, not responses to + # these -- they are sent from _post_connect once the handshake settles. + case "4822" | "4827": _LOGGER.debug( - "Entered negotiation stage 7 due to response from device!" - ) - decrypted_payload = self._decrypt_payload(payload) - _LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}") - - # Stage 7 is the telemetry subscribe (7a) plus client registration - # (7b). Both MUST go through _send_command so each carries the - # current session timestamp: the device rejects a stale timestamp - # as a replay and then holds the link but never streams telemetry. - _LOGGER.debug("Sending stage 7 subscribe + registration...") - await self._send_command( - bytes.fromhex(NEGOTIATION_COMMAND_7_CMD), - bytes.fromhex(NEGOTIATION_COMMAND_7_PAYLOAD), - ) - await self._send_command( - bytes.fromhex(NEGOTIATION_COMMAND_8_CMD), - bytes.fromhex(NEGOTIATION_COMMAND_8_PAYLOAD), + "Received post-ECDH ack %s; registration/subscribe run in " + "_post_connect", + cmd.hex(), ) return case _: _LOGGER.warning( - f"Received unexpected negotiation request response from device! cmd: '{cmd}', parameters: '{self._parameters_to_str(parameters, types=True)}'" + "Received unexpected negotiation response from device! cmd: %s", + cmd.hex(), ) + async def _post_connect(self) -> None: + """Register the client and subscribe to telemetry after negotiating. + + The registration (command 4027) and the telemetry subscribe (4200) are + post-connect commands, not responses to the device's trailing 4822/4827 + acks. They run here, after a short settle, mirroring how the gen-2 power + stations subscribe from _post_connect. The subscribe goes through + _send_command so it carries the live session timestamp (a stale one is + rejected as a replay, which blocks the stream). Overridden by models whose + post-connect sequence differs (e.g. the 240W charging station). + """ + await asyncio.sleep(1) + registration = self._build_packet( + pattern=bytes.fromhex(NEGOTIATION_PATTERN), + cmd=bytes.fromhex(NEGOTIATION_COMMAND_6_CMD), + payload=self._encrypt_payload(bytes.fromhex(NEGOTIATION_COMMAND_6_PAYLOAD)), + ) + await self._client.write_gatt_char(UUID_COMMAND, registration) + await self._send_command( + bytes.fromhex(NEGOTIATION_COMMAND_7_CMD), + bytes.fromhex(NEGOTIATION_COMMAND_7_PAYLOAD), + ) + ##################### # Packet processing # ##################### From 7936d5ed778ab61c8c8ec24fa55ac9d784a930f9 Mon Sep 17 00:00:00 2001 From: Shawn Stricker Date: Mon, 13 Jul 2026 18:32:50 -0400 Subject: [PATCH 4/8] fix(prime): correct stale _EXPECTED_TELEMETRY_LENGTH to 253 The Prime charger classes carried _EXPECTED_TELEMETRY_LENGTH = 198, an untested guess inherited from the original prime_charger_250w file. On real hardware the A2345 fragments its ca00 telemetry at 253 (253 + 101), identical to the c1000g2 family, so the expected full-fragment length is 253, not 198. The field is currently unused, but the reassembly rework in flip-dots/SolixBLE#42 keys the single-vs-fragment gate on this constant, so the correct value matters ahead of that landing. Co-Authored-By: Claude Opus 4.8 (1M context) --- SolixBLE/devices/prime_charger_250w.py | 2 +- SolixBLE/devices/prime_charging_station_240w.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/SolixBLE/devices/prime_charger_250w.py b/SolixBLE/devices/prime_charger_250w.py index 973f208..76202fe 100644 --- a/SolixBLE/devices/prime_charger_250w.py +++ b/SolixBLE/devices/prime_charger_250w.py @@ -21,7 +21,7 @@ class PrimeCharger250w(PrimeUsbCharger): switch states (fields ``aa``-``ae``). """ - _EXPECTED_TELEMETRY_LENGTH: int = 198 + _EXPECTED_TELEMETRY_LENGTH: int = 253 @property def total_power_out(self) -> float: diff --git a/SolixBLE/devices/prime_charging_station_240w.py b/SolixBLE/devices/prime_charging_station_240w.py index 92eb19b..d5b15e0 100644 --- a/SolixBLE/devices/prime_charging_station_240w.py +++ b/SolixBLE/devices/prime_charging_station_240w.py @@ -24,7 +24,7 @@ class PrimeChargingStation240w(PrimeUsbCharger): live capture with an AC load. """ - _EXPECTED_TELEMETRY_LENGTH: int = 198 + _EXPECTED_TELEMETRY_LENGTH: int = 253 async def _post_connect(self) -> None: """No post-connect subscribe yet -- the station's enable sequence is unknown. From 8dd2a495cdbf17697ba39fe9cc36ba49f3e3e6fc Mon Sep 17 00:00:00 2001 From: Shawn Stricker Date: Wed, 15 Jul 2026 19:35:05 -0400 Subject: [PATCH 5/8] feat(ble): reassemble multi-fragment frames for every session command (#42) Fragment reassembly lived inside _process_telemetry_packet and only ran for _TELEMETRY_COMMANDS, so any other multi-fragment session frame (e.g. the C2000 G2 c490 device-info blob) had only its first fragment decrypted and the rest dropped (flip-dots/SolixBLE#42). Extract it into a shared _reassemble()/_join_fragments() that runs in _process_notification ahead of the cipher split, so telemetry and unknown session frames share one reassembler regardless of the AES variant (GCM vs CBC). Single vs fragment is decided by the live notification length (ATT_MTU - 3, via the ff09 _FRAME_OVERHEAD) rather than the frag byte, so families that put no frag byte on singles (the A91B2 station) need no per-device override; a short single keeps a 0x11 frag byte only when it is a valid single marker. Runs start only on index 1 and terminate on the count (so an exact multiple of the cap, with no short tail, still completes); a partial/cold fragment that cannot decrypt is dropped rather than crashing the notification handler. Adds tests/test_reassembly.py (single-no-frag, 0x11 single, two-fragment, exact-multiple-no-tail, cold index!=1) and gives the mock client a realistic 256-byte MTU so the length gate exercises as it does on device. Co-Authored-By: Claude Opus 4.8 (1M context) --- SolixBLE/device.py | 369 +++++++++++++++++++++++---------------- tests/helpers.py | 48 +++-- tests/test_reassembly.py | 70 ++++++++ 3 files changed, 322 insertions(+), 165 deletions(-) create mode 100644 tests/test_reassembly.py diff --git a/SolixBLE/device.py b/SolixBLE/device.py index 226afc3..ac263b9 100644 --- a/SolixBLE/device.py +++ b/SolixBLE/device.py @@ -57,12 +57,18 @@ class SolixBLEDevice: #: (e.g the C1000 Gen 2 uses ``c421``/``c900`` instead of ``c402``/``c405``). _TELEMETRY_COMMANDS: tuple[str, ...] = ("c402", "4300", "c405") + #: Fixed ff09-frame overhead between the on-wire notification value and the + #: ``payload`` that :meth:`_split_packet` returns: ``ff09`` (2) + length (2) + + #: pattern (3) + cmd (2) + checksum (1). Added back so the fragmentation gate can + #: compare the notification length to the live ``ATT_MTU - 3`` cap. + _FRAME_OVERHEAD: int = 10 + def __init__(self, ble_device: BLEDevice) -> None: """Initialise device object. Does not connect automatically.""" _LOGGER.debug( f"Initializing Solix device '{ble_device.name}' with" - f"address '{ble_device.address}' and details '{ble_device.details}'" + f"address '{ble_device.address}' and details '{ble_device.details}'", ) self._ble_device: BLEDevice = ble_device @@ -118,7 +124,6 @@ async def connect(self, max_attempts: int = 3, run_callbacks: bool = True) -> bo self._connection_attempts = self._connection_attempts + 1 try: - # If we have an old client get rid of it if self._client is not None: await self._dispose_of_client() @@ -138,23 +143,24 @@ async def connect(self, max_attempts: int = 3, run_callbacks: bool = True) -> bo except BleakError: _LOGGER.exception( - f"Error establishing initial connection to '{self.name}'!" + f"Error establishing initial connection to '{self.name}'!", ) # If we are still not connected then we have failed if not self.connected: _LOGGER.error( - f"Failed to establish initial connection to '{self.name}' on attempt {self._connection_attempts}!" + f"Failed to establish initial connection to '{self.name}' on attempt {self._connection_attempts}!", ) return False _LOGGER.debug( - f"Established initial connection to '{self.name}' on attempt {self._connection_attempts}!" + f"Established initial connection to '{self.name}' on attempt {self._connection_attempts}!", ) try: _LOGGER.debug(f"Subscribing to notifications from device '{self.name}'!") await self._client.start_notify( - UUID_TELEMETRY, partial(self._process_notification, self._client) + UUID_TELEMETRY, + partial(self._process_notification, self._client), ) except BleakError: _LOGGER.exception(f"Error subscribing/negotiating with '{self.name}'!") @@ -163,10 +169,8 @@ async def connect(self, max_attempts: int = 3, run_callbacks: bool = True) -> bo # Negotiate try: async with asyncio.timeout(NEGOTIATION_TIMEOUT): - # While negotiations have not completed while not self.negotiated: - # If we have not received any packet from the device in # any stage then restart negotiations from the start if ( @@ -174,16 +178,15 @@ async def connect(self, max_attempts: int = 3, run_callbacks: bool = True) -> bo or (time.time() - self._last_packet_timestamp) > NEGOTIATION_RESPONSE_TIMEOUT ): - _LOGGER.debug( - f"Sending negotiation initiation request to '{self.name}'..." + f"Sending negotiation initiation request to '{self.name}'...", ) await self._initiate_negotiations() # Wait at this long to see if we get any response to # our initial request in stage 0. This weird layout # allows us to exit immediately when negotiation occurs - for _ in range(0, NEGOTIATION_RESPONSE_TIMEOUT): + for _ in range(NEGOTIATION_RESPONSE_TIMEOUT): await asyncio.sleep(1) if self.negotiated: break @@ -228,7 +231,6 @@ async def _post_connect(self) -> None: for example, send a subscribe command to start a telemetry stream (see :class:`~SolixBLE.devices.c1000g2.C1000G2`). """ - pass async def disconnect(self) -> None: """Disconnect from device and reset internal state. @@ -306,7 +308,11 @@ def last_update(self) -> datetime | None: return self._last_data_timestamp def _parse_int( - self, key: str, begin: int = None, end: int = None, signed: bool = False + self, + key: str, + begin: int = None, + end: int = None, + signed: bool = False, ) -> int: """Parse an integer at the specified key in the telemetry data. @@ -350,23 +356,24 @@ def _split_packet(self, packet: bytes) -> tuple[bytes, bytes, bytes]: # Validate encoded length is correct packet_length = int.from_bytes( - bytes([packet_copy.pop(0), packet_copy.pop(0)]), byteorder="little" + bytes([packet_copy.pop(0), packet_copy.pop(0)]), + byteorder="little", ) if packet_length != len(packet): raise ValueError( - f"Packet length is encoded as {packet_length} but its length was {len(packet)}!" + f"Packet length is encoded as {packet_length} but its length was {len(packet)}!", ) # Validate checksum is correct packet_checksum = packet_copy.pop(-1).to_bytes() if packet_checksum != self._checksum(packet[:-1]): raise ValueError( - f"Packet checksum is encoded as {packet_checksum.hex()} but it is actually {self._checksum(packet[:-1]).hex()}!" + f"Packet checksum is encoded as {packet_checksum.hex()} but it is actually {self._checksum(packet[:-1]).hex()}!", ) # Extract pattern packet_pattern = bytes( - [packet_copy.pop(0), packet_copy.pop(0), packet_copy.pop(0)] + [packet_copy.pop(0), packet_copy.pop(0), packet_copy.pop(0)], ) # Extract command @@ -440,17 +447,19 @@ def _verbose_pop(data: bytearray, length: int, name: str) -> bytes: # Sometimes there is just a param_id with no length or values if len(remaining_data) == 0: - parsed_data[param_id] = bytes() + parsed_data[param_id] = b"" break # Extract encoded length of parameter param_len = int.from_bytes( - _verbose_pop(remaining_data, 1, f"param_len (id={param_id})") + _verbose_pop(remaining_data, 1, f"param_len (id={param_id})"), ) # Extract data/body from parameter param_data = _verbose_pop( - remaining_data, param_len, f"param_data (id={param_id})" + remaining_data, + param_len, + f"param_data (id={param_id})", ) parsed_data[param_id] = param_data @@ -458,13 +467,15 @@ def _verbose_pop(data: bytearray, length: int, name: str) -> bytes: _LOGGER.exception( f"Unexpected end of packet! Data may be missing or invalid!" f" Extracted so far: '{self._parameters_to_str(parsed_data)}'." - f" Payload: '{payload.hex()}'" + f" Payload: '{payload.hex()}'", ) return parsed_data def _parameters_to_str( - self, parameters: dict[str, bytes], types: bool = False + self, + parameters: dict[str, bytes], + types: bool = False, ) -> str: if types: with_types = { @@ -477,8 +488,7 @@ def _parameters_to_str( for k, v in parameters.items() } return json.dumps(with_types, indent=4, sort_keys=True) - else: - return str({k: v.hex() for k, v in parameters.items()}) + return str({k: v.hex() for k, v in parameters.items()}) def _log_diff(self, old: dict[str, bytes], new: dict[str, bytes]) -> None: """Log any differences between parameters.""" @@ -493,13 +503,15 @@ def _log_diff(self, old: dict[str, bytes], new: dict[str, bytes]) -> None: if new[k] != old[k] } _LOGGER.debug( - f"Parameter changes: \n{json.dumps(differences, indent=4, sort_keys=True)}" + f"Parameter changes: \n{json.dumps(differences, indent=4, sort_keys=True)}", ) def _decrypt_payload(self, payload: bytes) -> bytes: """Decrypt telemetry packet using negotiated shared secret and IV.""" cipher = AES.new( - self._shared_secret[:16], AES.MODE_CBC, iv=self._shared_secret[16:] + self._shared_secret[:16], + AES.MODE_CBC, + iv=self._shared_secret[16:], ) decrypted = cipher.decrypt(payload) unpadder = PKCS7(128).unpadder() @@ -514,59 +526,110 @@ def _encrypt_payload(self, payload: bytes) -> bytes: padded_data = padder.update(payload) padded_data += padder.finalize() cipher = AES.new( - self._shared_secret[:16], AES.MODE_CBC, iv=self._shared_secret[16:] + self._shared_secret[:16], + AES.MODE_CBC, + iv=self._shared_secret[16:], ) return cipher.encrypt(padded_data) - async def _process_telemetry_packet( - self, payload: bytes, cmd: bytes = None - ) -> None: - """Process a telemetry packet from the device. - - This performs the default processing of telemetry packets in which - telemetry payloads are spread across multiple packets. This is - overridden for devices which do not use multi-packet payloads for - telemetry. + def _reassemble(self, cmd: bytes, payload: bytes) -> bytes | None: + """Reassemble a possibly-fragmented session frame into one payload. + + Fragmentation is a transport effect that sits *below* the cipher: a frame + larger than a single notification (``ATT_MTU - 3`` bytes on the wire) is + split, and every fragment is prefixed with a ```` byte (high + nibble = 1-based index, low nibble = fragment count). A frame that fits in + one notification arrives whole. Because this operates on the still-encrypted + payload, the same reassembler serves telemetry and unknown session frames + alike, regardless of the AES variant used to decrypt the result (SolixBLE + #42). + + Single frames are told apart from fragments by length, not by trusting the + first byte: only a full-length notification (or the continuation of an open + run) is a fragment. A short frame is a standalone single, and it carries a + ```` byte *only* if that byte is a valid single marker + (``0x11``); some families (e.g. the A91B2 station) put no frag byte on + singles, so their first byte is already ciphertext and must be kept. + + :returns: the complete (still-encrypted) payload ready to decrypt, or + ``None`` while fragments are still outstanding. """ + if not payload: + return payload - # First byte encodes fragment info (high nibble = index, low = total) - fragment_index = (payload[0] >> 4) & 0x0F - fragment_total = payload[0] & 0x0F + cmd_key = bytes(cmd) + index = (payload[0] >> 4) & 0x0F + total = payload[0] & 0x0F - # Multi-part message - if fragment_total > 1: - fragment_data = payload[1:] - cmd_key = bytes(cmd) + # Continuation (or short tail) of a run already in progress for this cmd. + if cmd_key in self._fragment_buffers: _LOGGER.debug( - f"Fragment {fragment_index}/{fragment_total} for cmd {cmd.hex()}, {len(fragment_data)} bytes" + f"Fragment {index}/{self._fragment_totals[cmd_key]} for cmd " + f"{cmd.hex()}, {len(payload) - 1} bytes", ) + self._fragment_buffers[cmd_key][index] = payload[1:] + return self._join_fragments(cmd_key) + + # A run only starts on a full-length first fragment. The length guard stops a + # short frame whose first ciphertext byte happens to look like a ``0x1x`` + # header from opening a run that never completes. + notification_length = len(payload) + self._FRAME_OVERHEAD + if ( + index == 1 + and total > 1 + and notification_length >= self._client.mtu_size - 3 + ): + _LOGGER.debug( + f"Fragment 1/{total} for cmd {cmd.hex()}, {len(payload) - 1} bytes", + ) + self._fragment_buffers[cmd_key] = {1: payload[1:]} + self._fragment_totals[cmd_key] = total + return self._join_fragments(cmd_key) + + # Standalone single notification. Strip the frag byte only when it is a valid + # single marker; otherwise the whole payload is data. + if payload[0] == 0x11: + return payload[1:] + return payload + + def _join_fragments(self, cmd_key: bytes) -> bytes | None: + """Join a completed fragment run, or ``None`` if more fragments are due.""" + if len(self._fragment_buffers[cmd_key]) < self._fragment_totals[cmd_key]: + _LOGGER.debug("Waiting for remaining fragments...") + return None - # Store fragment - if cmd_key not in self._fragment_buffers or fragment_index == 1: - self._fragment_buffers[cmd_key] = {} - self._fragment_totals[cmd_key] = fragment_total - - self._fragment_buffers[cmd_key][fragment_index] = fragment_data + payload = b"".join( + self._fragment_buffers[cmd_key][i] + for i in sorted(self._fragment_buffers[cmd_key]) + ) + del self._fragment_buffers[cmd_key] + del self._fragment_totals[cmd_key] + _LOGGER.debug(f"Reassembled payload: {len(payload)} bytes") + return payload - # Wait until all fragments have arrived - if len(self._fragment_buffers[cmd_key]) < fragment_total: - _LOGGER.debug("Waiting for remaining fragments...") - return + async def _process_telemetry_packet( + self, + payload: bytes, + cmd: bytes = None, + ) -> None: + """Decrypt and dispatch a (reassembled) telemetry payload. - # Reassemble in order - payload = b"".join( - self._fragment_buffers[cmd_key][i] - for i in sorted(self._fragment_buffers[cmd_key]) + The payload has already been made whole by :meth:`_reassemble`, so this just + decrypts and parses it. Kept as an override point for devices whose telemetry + needs post-processing (e.g. the A91B2 station remaps two frame layouts onto + one property set). + """ + try: + decrypted_payload = self._decrypt_payload(payload) + except Exception: # noqa: BLE001 + # A fragment that arrived with no matching run (out of order, or its + # first fragment was lost) is returned whole by :meth:`_reassemble` and + # will not decrypt; drop it rather than crash the notification handler. + _LOGGER.debug( + f"Discarding undecryptable telemetry frame for cmd " + f"{cmd.hex() if cmd else '?'}", ) - del self._fragment_buffers[cmd_key] - del self._fragment_totals[cmd_key] - _LOGGER.debug(f"Reassembled payload: {len(payload)} bytes") - - else: - # Strip fragment info - payload = payload[1:] - - decrypted_payload = self._decrypt_payload(payload) + return None _LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}") parameters = self._parse_payload(decrypted_payload) return await self._process_telemetry(parameters) @@ -578,12 +641,11 @@ async def _process_telemetry(self, parameters: dict[str, bytes]) -> None: if _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug( - f"Telemetry parameters: {self._parameters_to_str(parameters)}" + f"Telemetry parameters: {self._parameters_to_str(parameters)}", ) # Print state update if changes if state_changed: - # If we have previous data to compare against log the diff if self._data is not None: _LOGGER.debug("Parameters have changed since previous update!") @@ -592,7 +654,7 @@ async def _process_telemetry(self, parameters: dict[str, bytes]) -> None: # Else log the parameters but with the types else: _LOGGER.debug( - f"Telemetry parameters: {self._parameters_to_str(parameters, types=True)}" + f"Telemetry parameters: {self._parameters_to_str(parameters, types=True)}", ) # Update internal parameters @@ -601,12 +663,14 @@ async def _process_telemetry(self, parameters: dict[str, bytes]) -> None: # Run callbacks if state changed if state_changed: - _LOGGER.debug(self) self._run_state_changed_callbacks() async def _process_notification( - self, client: BleakClient, handle: int, data: bytearray + self, + client: BleakClient, + handle: int, + data: bytearray, ) -> None: """Process a notification from the device.""" @@ -614,11 +678,11 @@ async def _process_notification( if self._client is not client: _LOGGER.debug("Ignoring notification from old client") - return + return None # Split packet into pattern, command, and payload _LOGGER.debug( - f"Received notification from '{self.name}'. length: {len(data)}, packet: '{data.hex()}'" + f"Received notification from '{self.name}'. length: {len(data)}, packet: '{data.hex()}'", ) self._last_packet_timestamp = time.time() pattern, cmd, payload = self._split_packet(data) @@ -631,15 +695,14 @@ async def _process_notification( # future instead of processing it here if pattern + cmd in self._packet_futures: _LOGGER.debug( - "Packet has future(s) registered. Triggering future(s) and ignoring packet..." + "Packet has future(s) registered. Triggering future(s) and ignoring packet...", ) for future in self._packet_futures[pattern + cmd]: future.set_result(payload) - return + return None # Match against common message types match pattern.hex(): - # Negotiation messages case "030001": _LOGGER.debug("Received negotiation message!") @@ -647,59 +710,60 @@ async def _process_notification( # Session messages case "03010f" | "030111": - # Non-encrypted telemetry messages if cmd.hex() == "0300": _LOGGER.debug("Received non-encrypted telemetry message!") parameters = self._parse_payload(payload) return await self._process_telemetry(parameters) + # Reassemble multi-fragment frames before the cipher, so telemetry + # and unknown session frames share one reassembler (SolixBLE #42). + payload = self._reassemble(cmd, payload) + if payload is None: + return None + # Encrypted telemetry messages - elif cmd.hex() in self._TELEMETRY_COMMANDS: + if cmd.hex() in self._TELEMETRY_COMMANDS: _LOGGER.debug("Received encrypted telemetry message!") return await self._process_telemetry_packet(payload, cmd) # Unknown messages - else: - _LOGGER.debug(f"Received unknown message of type: {cmd.hex()}") - try: - - # If the payload is one byte too short and we are - # using the default AES (CBC) then try putting the - # last byte of the cmd in front of it - if ( - len(payload) % 16 == 15 - and self._decrypt_payload - is SolixBLEDevice._decrypt_payload - ): - _LOGGER.debug( - "Using special trick of embedded part of CMD in payload..." - ) - payload = cmd[1].to_bytes() + payload - - decrypted_payload = self._decrypt_payload(payload) - _LOGGER.debug( - f"Decrypted payload: {decrypted_payload.hex()}" - ) - parameters = self._parse_payload(decrypted_payload) + _LOGGER.debug(f"Received unknown message of type: {cmd.hex()}") + try: + # If the payload is one byte too short and we are + # using the default AES (CBC) then try putting the + # last byte of the cmd in front of it + if ( + len(payload) % 16 == 15 + and self._decrypt_payload is SolixBLEDevice._decrypt_payload + ): _LOGGER.debug( - f"Parameters: {self._parameters_to_str(parameters, types=True)}" - ) - except Exception: - _LOGGER.exception( - "Exception decrypting unknown message type" + "Using special trick of embedded part of CMD in payload...", ) + payload = cmd[1].to_bytes() + payload + + decrypted_payload = self._decrypt_payload(payload) + _LOGGER.debug( + f"Decrypted payload: {decrypted_payload.hex()}", + ) + parameters = self._parse_payload(decrypted_payload) + _LOGGER.debug( + f"Parameters: {self._parameters_to_str(parameters, types=True)}", + ) + except Exception: + _LOGGER.exception( + "Exception decrypting unknown message type", + ) case _: _LOGGER.warning( - f"Unexpected packet type '{pattern}' sent by device! Packet: {data.hex()}" + f"Unexpected packet type '{pattern}' sent by device! Packet: {data.hex()}", ) async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: """Negotiate encryption with the device.""" match cmd.hex(): - # There is a "stage 0" in which we automatically send a negotiation # request as soon as we establish the initial connection. That # should lead to the power station sending a response landing us @@ -708,56 +772,60 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: # Negotiation stage 1 case "0801": _LOGGER.debug( - "Entered negotiation stage 1 due to response from device!" + "Entered negotiation stage 1 due to response from device!", ) parameters = self._parse_payload(payload) _LOGGER.debug(f"Parameters: {self._parameters_to_str(parameters)}") _LOGGER.debug("Sending stage 1 response message...") return await self._client.write_gatt_char( - UUID_COMMAND, bytes.fromhex(NEGOTIATION_COMMAND_1) + UUID_COMMAND, + bytes.fromhex(NEGOTIATION_COMMAND_1), ) # Negotiation stage 2 case "0803": _LOGGER.debug( - "Entered negotiation stage 2 due to response from device!" + "Entered negotiation stage 2 due to response from device!", ) parameters = self._parse_payload(payload) _LOGGER.debug(f"Parameters: {self._parameters_to_str(parameters)}") _LOGGER.debug("Sending stage 2 response message...") return await self._client.write_gatt_char( - UUID_COMMAND, bytes.fromhex(NEGOTIATION_COMMAND_2) + UUID_COMMAND, + bytes.fromhex(NEGOTIATION_COMMAND_2), ) # Negotiation stage 3 case "0829": _LOGGER.debug( - "Entered negotiation stage 3 due to response from device!" + "Entered negotiation stage 3 due to response from device!", ) parameters = self._parse_payload(payload) _LOGGER.debug(f"Parameters: {self._parameters_to_str(parameters)}") self._negotiation_timestamp = time.time() _LOGGER.debug("Sending stage 3 response message...") return await self._client.write_gatt_char( - UUID_COMMAND, bytes.fromhex(NEGOTIATION_COMMAND_3) + UUID_COMMAND, + bytes.fromhex(NEGOTIATION_COMMAND_3), ) # Negotiation stage 4 case "0805": _LOGGER.debug( - "Entered negotiation stage 4 due to response from device!" + "Entered negotiation stage 4 due to response from device!", ) parameters = self._parse_payload(payload) _LOGGER.debug(f"Parameters: {self._parameters_to_str(parameters)}") _LOGGER.debug("Sending stage 4 response message...") return await self._client.write_gatt_char( - UUID_COMMAND, bytes.fromhex(NEGOTIATION_COMMAND_4) + UUID_COMMAND, + bytes.fromhex(NEGOTIATION_COMMAND_4), ) # Negotiation stage 5 case "0821": _LOGGER.debug( - "Entered negotiation stage 5 due to response from device!" + "Entered negotiation stage 5 due to response from device!", ) parameters = self._parse_payload(payload) _LOGGER.debug(f"Parameters: {self._parameters_to_str(parameters)}") @@ -766,14 +834,16 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: device_public_key_bytes = bytes.fromhex("04") + parameters["a1"] _LOGGER.debug(f"Public key of device: {device_public_key_bytes.hex()}") device_public_key = EllipticCurvePublicKey.from_encoded_point( - SECP256R1(), device_public_key_bytes + SECP256R1(), + device_public_key_bytes, ) # Calculate the shared secret # The first half of the shared secret is the encryption key # and the second half is the IV private_value = int.from_bytes( - bytes.fromhex(PRIVATE_KEY), byteorder="big" + bytes.fromhex(PRIVATE_KEY), + byteorder="big", ) private_key = derive_private_key(private_value, SECP256R1()) self._shared_secret = private_key.exchange(ECDH(), device_public_key) @@ -781,7 +851,8 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: _LOGGER.debug("Sending stage 5 response message...") return await self._client.write_gatt_char( - UUID_COMMAND, bytes.fromhex(NEGOTIATION_COMMAND_5) + UUID_COMMAND, + bytes.fromhex(NEGOTIATION_COMMAND_5), ) # Negotiation stage 6 (Optional) @@ -790,7 +861,7 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: # but it does not hurt to decrypt it anyway. case "4822": _LOGGER.debug( - "Entered negotiation stage 6 (optional) due to response from device!" + "Entered negotiation stage 6 (optional) due to response from device!", ) decrypted_payload = self._decrypt_payload(payload) parameters = self._parse_payload(decrypted_payload) @@ -798,7 +869,7 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: case _: _LOGGER.warning( - f"Received unexpected negotiation request response from device! cmd: '{cmd}', parameters: '{self._parameters_to_str(parameters)}'" + f"Received unexpected negotiation request response from device! cmd: '{cmd}', parameters: '{self._parameters_to_str(parameters)}'", ) def _checksum(self, packet: bytes) -> bytes: @@ -822,10 +893,12 @@ async def _send_command(self, cmd: bytes, payload: bytes) -> None: # and that timestamp is set during negotiations time_passed = int(time.time() - self._negotiation_timestamp) base_timestamp = int.from_bytes( - bytes.fromhex(BASE_TIMESTAMP), byteorder="little" + bytes.fromhex(BASE_TIMESTAMP), + byteorder="little", ) new_timestamp = (base_timestamp + time_passed).to_bytes( - length=4, byteorder="little" + length=4, + byteorder="little", ) new_payload = payload + bytes.fromhex("fe0503") + new_timestamp await self._send_encrypted_packet(cmd, new_payload) @@ -853,7 +926,7 @@ def _build_packet(self, pattern: bytes, cmd: bytes, payload: bytes) -> bytes: async def _send_encrypted_packet(self, cmd: bytes, payload: bytes) -> None: """Send an encrypted packet using negotiated shared secret and IV.""" _LOGGER.debug( - f"Building packet with cmd: {cmd.hex()} and payload: {payload.hex()}" + f"Building packet with cmd: {cmd.hex()} and payload: {payload.hex()}", ) encrypted_payload = self._encrypt_payload(payload) @@ -864,7 +937,10 @@ async def _send_encrypted_packet(self, cmd: bytes, payload: bytes) -> None: await self._client.write_gatt_char(UUID_COMMAND, packet) def _register_future( - self, future: asyncio.Future, pattern: bytes, cmd: bytes + self, + future: asyncio.Future, + pattern: bytes, + cmd: bytes, ) -> None: """Register a future to be triggered when the pattern and cmd bytes are received.""" @@ -878,7 +954,10 @@ def _register_future( self._packet_futures[pattern + cmd].append(future) def _deregister_future( - self, future: asyncio.Future, pattern: bytes, cmd: bytes + self, + future: asyncio.Future, + pattern: bytes, + cmd: bytes, ) -> None: """Deregister a future to be triggered when the pattern and cmd bytes are received.""" @@ -898,7 +977,10 @@ def _deregister_future( self._packet_futures.pop(pattern + cmd) async def _listen_for_packet( - self, pattern: bytes, cmd: bytes, timeout: int = 10 + self, + pattern: bytes, + cmd: bytes, + timeout: int = 10, ) -> bytes | None: """Wait for a response and return its payload bytes. @@ -931,7 +1013,7 @@ def _run_state_changed_callbacks(self) -> None: function() except Exception: _LOGGER.exception( - f"Exception raised by a registered state change callback '{function}'!" + f"Exception raised by a registered state change callback '{function}'!", ) async def _auto_reconnect(self) -> None: @@ -951,26 +1033,24 @@ def _can_retry() -> bool: ) try: - # If callbacks need to be run on reconnection, we silently # reconnect if the timeout has not been exceeded, else we # run callbacks to let subscribers know we were disconnected run_callbacks_on_reconnect = False while _can_retry(): - # If we are already connected and negotiated then wait for disconnection if self.negotiated: _LOGGER.debug( - f"Automatic reconnect task ready and waiting for disconnect event from '{self.name}'!" + f"Automatic reconnect task ready and waiting for disconnect event from '{self.name}'!", ) await self._disconnect_event.wait() _LOGGER.debug( - f"Disconnection event signalled by '{self.name}', starting reconnection..." + f"Disconnection event signalled by '{self.name}', starting reconnection...", ) else: _LOGGER.debug( - f"We are still not connected to '{self.name}', starting reconnection..." + f"We are still not connected to '{self.name}', starting reconnection...", ) # If we have reached this stage we are not connected @@ -980,18 +1060,16 @@ def _can_retry() -> bool: # we have to trigger callbacks to let subscribers know we # are disconnected async with asyncio.timeout(DISCONNECT_TIMEOUT): - while _can_retry(): - await asyncio.sleep(RECONNECT_DELAY) try: attempt_number = self._connection_attempts if await self.connect( - run_callbacks=run_callbacks_on_reconnect + run_callbacks=run_callbacks_on_reconnect, ): _LOGGER.debug( - f"""Successfully reconnected to '{self.name}' {"silently" if not run_callbacks_on_reconnect else ""} on attempt {attempt_number}!""" + f"""Successfully reconnected to '{self.name}' {"silently" if not run_callbacks_on_reconnect else ""} on attempt {attempt_number}!""", ) # Reset back to false on successful connection @@ -1001,13 +1079,13 @@ def _can_retry() -> bool: break except Exception: _LOGGER.exception( - f"""Exception raised attempting to {"silently" if not run_callbacks_on_reconnect else ""} reconnect to '{self.name}'!""" + f"""Exception raised attempting to {"silently" if not run_callbacks_on_reconnect else ""} reconnect to '{self.name}'!""", ) # If timeout exceeded - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning( - f"Timed out attempting to silently reconnect to '{self.name}', callbacks will be triggered due to disconnect!" + f"Timed out attempting to silently reconnect to '{self.name}', callbacks will be triggered due to disconnect!", ) self._reset_session(reset_data=True) self._run_state_changed_callbacks() @@ -1016,8 +1094,7 @@ def _can_retry() -> bool: # need to run them again on reconnect run_callbacks_on_reconnect = True - else: - _LOGGER.warning("Maximum reconnect limit exceeded!") + _LOGGER.warning("Maximum reconnect limit exceeded!") except asyncio.CancelledError: _LOGGER.debug("Automatic reconnect task has been canceled/stopped") @@ -1041,7 +1118,7 @@ def _disconnect_callback(self, client: BaseBleakClient) -> None: # Ignore disconnect callbacks from old clients if client is not self._client: _LOGGER.debug( - f"Disconnect of '{self.name}' came from other client. Ignoring..." + f"Disconnect of '{self.name}' came from other client. Ignoring...", ) return @@ -1061,7 +1138,7 @@ async def _dispose_of_client(self) -> None: await client.disconnect() except Exception: _LOGGER.exception( - f"Exception raised when disposing of bleak client '{client}'!" + f"Exception raised when disposing of bleak client '{client}'!", ) def _reset_session(self, reset_data: bool = True) -> None: @@ -1097,7 +1174,7 @@ def _safe_get(name: str, prop: property) -> str: return prop.fget(self) except Exception as e: _LOGGER.exception( - f"Failed to parse property '{name}' when stringifying class! Is there an undocumented state?" + f"Failed to parse property '{name}' when stringifying class! Is there an undocumented state?", ) return f"{type(e).__name__}: {e}" diff --git a/tests/helpers.py b/tests/helpers.py index 0bd60b3..1f0270f 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -8,7 +8,7 @@ import logging from collections.abc import Callable from dataclasses import dataclass, field -from typing import Any, Union +from typing import Any from unittest import mock from bleak import BleakClient @@ -29,7 +29,7 @@ class RequestResponse: Name of request to produce more useful error messages. """ - expected: Union[bytes, None] + expected: bytes | None """ The bytes expected by this request. Use none to accept any bytes. """ @@ -105,19 +105,22 @@ def custom_init(*args, **kwargs): # We give it a name so we can tell the difference between them in logs mock_bleak_client = mock.AsyncMock( - name=f"bleak_client_{len(self._mock_bleak_clients)}" + name=f"bleak_client_{len(self._mock_bleak_clients)}", ) # Set functions/properties mock_bleak_client.write_gatt_char.side_effect = self.write_gatt_char mock_bleak_client.start_notify.side_effect = self.start_notify + # Emulate an Anker 256-byte ATT MTU so fragment reassembly (which gates on + # the live ``mtu_size - 3`` notification cap) behaves as it does on device. + mock_bleak_client.mtu_size = 256 type(mock_bleak_client).is_connected = mock.PropertyMock( - side_effect=lambda: self._is_connected + side_effect=lambda: self._is_connected, ) # Add it to the list of all bleak clients self._mock_bleak_clients.append( - (mock_bleak_client, [kwargs["disconnected_callback"]], []) + (mock_bleak_client, [kwargs["disconnected_callback"]], []), ) # Set this as the most current bleak client and return it @@ -161,7 +164,9 @@ def disconnect(self): callback(bleak_client) def expect_ordered( - self, value: Union[bytes, None] = None, response: list[bytes] = [] + self, + value: bytes | None = None, + response: list[bytes] = [], ): """ Expect an ordered request to be made to the mock device with @@ -175,8 +180,10 @@ def expect_ordered( """ self._assertions.append( RequestResponse( - name=f"num {len(self._assertions)}", expected=value, response=response - ) + name=f"num {len(self._assertions)}", + expected=value, + response=response, + ), ) def expect_ordered_all(self, requests: list[RequestResponse]): @@ -215,7 +222,7 @@ async def send_data(self, data: list[bytes]) -> None: for callback in n_callbacks: for packet in data: _LOGGER.debug( - f"Mock device sending '{packet.hex()}' to client '{client}' for callback '{callback}'..." + f"Mock device sending '{packet.hex()}' to client '{client}' for callback '{callback}'...", ) # Handle is not used await callback(None, packet) @@ -224,7 +231,10 @@ async def send_data(self, data: list[bytes]) -> None: await asyncio.sleep(0.1) async def write_gatt_char( - self, char_specifier: str, data: bytes, response: bool = False + self, + char_specifier: str, + data: bytes, + response: bool = False, ): """ Patched version of the bleak clients write_gatt_char function @@ -244,15 +254,15 @@ async def write_gatt_char( request_response = self._assertions[self._position] except IndexError: print(self._assertions) - assert ( - False - ), f"Received an unexpected request '{data.hex()}'. Number: {self._position+1}, Num expected: {len(self._assertions)}" + assert False, ( + f"Received an unexpected request '{data.hex()}'. Number: {self._position + 1}, Num expected: {len(self._assertions)}" + ) if request_response.expected is not None: # Assert it matches - assert ( - request_response.expected == data - ), f"Expected bytes {request_response.expected.hex()}' for request '{request_response.name}' ({self._position+1}) but got '{data.hex()}'!" + assert request_response.expected == data, ( + f"Expected bytes {request_response.expected.hex()}' for request '{request_response.name}' ({self._position + 1}) but got '{data.hex()}'!" + ) # Increment position self._position = self._position + 1 @@ -273,9 +283,9 @@ def check_assertions(self): Check that all specified requests have been made by the module. """ for i, item in enumerate(self._assertions): - assert ( - item.called - ), f"Request '{item.name}' ({i}) with expected bytes '{item.expected.hex()}' was not called!" + assert item.called, ( + f"Request '{item.name}' ({i}) with expected bytes '{item.expected.hex()}' was not called!" + ) async def __aexit__(self, *exc): """ diff --git a/tests/test_reassembly.py b/tests/test_reassembly.py new file mode 100644 index 0000000..b37b14b --- /dev/null +++ b/tests/test_reassembly.py @@ -0,0 +1,70 @@ +"""Tests for multi-fragment session-frame reassembly (SolixBLE #42). + +``_reassemble`` runs below the cipher and serves telemetry and unknown session +frames alike, so these tests exercise it directly on raw (still-encrypted) +payloads for a link with a 256-byte ATT MTU (``ATT_MTU - 3 == 253`` on the wire). +""" + +from unittest import mock + +from SolixBLE import SolixBLEDevice +from tests.const import MOCK_BLE_DEVICE + +#: ``ATT_MTU - 3`` for a 256-byte link -- a full (fragmenting) notification value. +CAP = 253 +CMD = b"\xc4\x90" + + +def _device() -> SolixBLEDevice: + dev = SolixBLEDevice(MOCK_BLE_DEVICE) + dev._client = mock.Mock(mtu_size=256) + return dev + + +def test_short_single_without_frag_byte_kept_whole() -> None: + # A91B2-style single: first byte 0xd8 is ciphertext (index 13 / total 8 is an + # impossible header), so the whole payload is data and must be kept. + device = _device() + payload = bytes([0xD8]) + b"\x11" * 200 + assert device._reassemble(CMD, payload) == payload + assert device._fragment_buffers == {} + + +def test_short_single_with_frag_byte_stripped() -> None: + # MagGo/Prime-style single: 0x11 is a valid single marker (index 1 / total 1), + # so the frag byte is stripped before it reaches the (GCM/CBC) decrypt. + device = _device() + payload = bytes([0x11]) + b"\xcd" * 60 + assert device._reassemble(CMD, payload) == b"\xcd" * 60 + assert device._fragment_buffers == {} + + +def test_two_fragment_reassembly_with_short_tail() -> None: + device = _device() + frag1 = bytes([0x12]) + b"\xaa" * (CAP - 1) # full-length first fragment + frag2 = bytes([0x22]) + b"\xbb" * 40 # short tail closes the run + assert device._reassemble(CMD, frag1) is None # still buffering + assert device._reassemble(CMD, frag2) == b"\xaa" * (CAP - 1) + b"\xbb" * 40 + assert device._fragment_buffers == {} + + +def test_exact_multiple_no_short_tail() -> None: + # Two full 253-byte notifications, no short tail (the 506-on-the-wire case). + # Termination must come from the count, not from a shorter + # packet closing the run -- otherwise this hangs forever. + device = _device() + frag1 = bytes([0x12]) + b"\xaa" * (CAP - 1) + frag2 = bytes([0x22]) + b"\xbb" * (CAP - 1) + assert device._reassemble(CMD, frag1) is None + assert device._reassemble(CMD, frag2) == b"\xaa" * (CAP - 1) + b"\xbb" * (CAP - 1) + assert device._fragment_buffers == {} + + +def test_cold_non_first_fragment_decoded_whole() -> None: + # A full-length frame whose header reads index != 1 with no open run cannot be a + # joinable fragment (a run always opens on index 1). Treat it as a single and + # keep the whole payload rather than opening a run that never completes. + device = _device() + payload = bytes([0x35]) + b"\x00" * (CAP - 1) # index 3 / total 5, no buffer + assert device._reassemble(CMD, payload) == payload + assert device._fragment_buffers == {} From ac7fa5be757df8f72633fe14e7240c9568f80ce9 Mon Sep 17 00:00:00 2001 From: Shawn Stricker Date: Fri, 17 Jul 2026 15:51:49 -0400 Subject: [PATCH 6/8] feat(prime): stream hardened Prime chargers + finish shared telemetry decode Hardened Prime firmware (the C1000 G2 "#22" lockdown, now shipping on some A2345 units) negotiates normally but withholds BLE telemetry until the client proves account ownership: the 4027 registration must bind the account owner_user_id (a228). A generic app UUID (a224) is acknowledged but rejected (ack status 09) and the link idle-drops, so streaming was impossible on those units. This completes live Prime telemetry and the shared decode plumbing the newer models need. prime_device: - Add OWNER_USER_ID (opt-in; None keeps the legacy a224 registration for older firmware). When set, register with 4027 = a104 a228, which the hardened firmware accepts (ack 00) and then streams. - Send the real 420b REALTIME_TRIGGER enable block (a20a...) that starts the 4303 per-port stream; it was previously a no-op bare a10121. - Drop the stale app-UUID from the 420a getter. - Build every negotiation and session frame with a live timestamp (init, each stage, the confer, registration, and _send_command) so the device's replay protection accepts them instead of dropping a stale/replayed value. A91B2 240W charging station: full support -- base/CBC transport layered over the Prime class, with the 4a00 snapshot and 4303/4302 stream remapped onto one port view. C2000 G2: add the model and factor the nested self-delimiting telemetry walkers into a shared parsing module (also used by c1000g2). device: reassemble multi-fragment session frames with a length gate so single- and multi-packet telemetry both decode. docs: guide for obtaining owner_user_id without the Anker app (anker-solix-api login or app-traffic capture). tests: make the Prime and base negotiation fixtures timestamp-agnostic via a frozen_time fixture (frames now carry a live clock), regenerate the affected fixtures, and add parsing coverage. Note: only PrimeCharger250w (A2345) was validated on hardware. PrimeCharger160w and PrimePowerBank20k inherit the same post-connect path and should still work (the negotiation handshake order is unchanged; they add the 420b trigger with OWNER_USER_ID unset), but were not re-tested on real devices. Co-Authored-By: Claude Opus 4.8 --- SolixBLE/__init__.py | 26 +- SolixBLE/device.py | 133 ++++++-- SolixBLE/devices/__init__.py | 10 +- SolixBLE/devices/c1000g2.py | 150 +++++++- SolixBLE/devices/c2000g2.py | 53 +++ .../devices/prime_charging_station_240w.py | 323 +++++++++++++++--- SolixBLE/parsing.py | 134 ++++++++ SolixBLE/prime_device.py | 251 +++++++------- SolixBLE/states.py | 2 + docs/source/index.rst | 1 + docs/source/owner_user_id.rst | 131 +++++++ tests/conftest.py | 30 ++ tests/const.py | 69 ++-- tests/test_connection.py | 31 +- tests/test_devices.py | 71 ++-- tests/test_parsing.py | 48 +++ tests/test_prime.py | 15 +- 17 files changed, 1189 insertions(+), 289 deletions(-) create mode 100644 SolixBLE/devices/c2000g2.py create mode 100644 SolixBLE/parsing.py create mode 100644 docs/source/owner_user_id.rst create mode 100644 tests/test_parsing.py diff --git a/SolixBLE/__init__.py b/SolixBLE/__init__.py index 9f7078d..70b2e90 100644 --- a/SolixBLE/__init__.py +++ b/SolixBLE/__init__.py @@ -11,6 +11,7 @@ C800, C1000, C1000G2, + C2000G2, F2000, F3800, Generic, @@ -36,30 +37,31 @@ from .utilities import discover_devices __all__ = [ - "SolixBLEDevice", - "PrimeDevice", "C300", "C300DC", "C800", "C1000", "C1000G2", + "C2000G2", "F2000", "F3800", - "Solarbank2", - "Solarbank3", - "PrimeCharger160w", - "PrimeCharger250w", - "PrimeChargingStation240w", - "PrimePowerBank20k", - "PrimeUsbCharger", - "MagGo3in1", - "Generic", "ChargingStatus", "ChargingStatusF3800", "DisplayTimeout", + "Generic", "LightStatus", + "MagGo3in1", + "PortOverload", "PortStatus", + "PrimeCharger160w", + "PrimeCharger250w", + "PrimeChargingStation240w", + "PrimeDevice", + "PrimePowerBank20k", + "PrimeUsbCharger", + "Solarbank2", + "Solarbank3", + "SolixBLEDevice", "TemperatureUnit", - "PortOverload", "discover_devices", ] diff --git a/SolixBLE/device.py b/SolixBLE/device.py index ac263b9..318dcfb 100644 --- a/SolixBLE/device.py +++ b/SolixBLE/device.py @@ -27,16 +27,10 @@ from cryptography.hazmat.primitives.padding import PKCS7 from .const import ( - BASE_TIMESTAMP, DEFAULT_METADATA_INT, DEFAULT_METADATA_STRING, DISCONNECT_TIMEOUT, - NEGOTIATION_COMMAND_0, - NEGOTIATION_COMMAND_1, - NEGOTIATION_COMMAND_2, - NEGOTIATION_COMMAND_3, NEGOTIATION_COMMAND_4, - NEGOTIATION_COMMAND_5, NEGOTIATION_RESPONSE_TIMEOUT, NEGOTIATION_TIMEOUT, PRIVATE_KEY, @@ -48,6 +42,13 @@ _LOGGER = logging.getLogger(__name__) +#: Packet pattern for the cleartext negotiation stages (the ``0xxx`` commands). Each +#: stage carries a live ``a1`` timestamp and no client UUID: newer base firmware (e.g. +#: C1000 G2 FW 1.1.4.6, and the A91B2 station) drops the link on the stale replayed +#: values older firmware accepted. Verified against the A91B2, which negotiates with +#: ``a104`` + timestamp alone. +_NEGOTIATION_PATTERN = bytes.fromhex("030001") + class SolixBLEDevice: """Solix BLE device object.""" @@ -57,6 +58,13 @@ class SolixBLEDevice: #: (e.g the C1000 Gen 2 uses ``c421``/``c900`` instead of ``c402``/``c405``). _TELEMETRY_COMMANDS: tuple[str, ...] = ("c402", "4300", "c405") + #: Telemetry command codes whose payload is protobuf, not the 1-byte-tag TLV + #: format :meth:`_parse_payload` understands (e.g. the C2000 G2's ``c490`` + #: device-summary post). They are still reassembled and decrypted -- so a + #: consumer that understands the frame can decode the cleartext -- but the base + #: skips TLV-parsing them, which would misread the protobuf varint tags. + _PROTOBUF_TELEMETRY_COMMANDS: tuple[str, ...] = () + #: Fixed ff09-frame overhead between the on-wire notification value and the #: ``payload`` that :meth:`_split_packet` returns: ``ff09`` (2) + length (2) + #: pattern (3) + cmd (2) + checksum (1). Added back so the fragmentation gate can @@ -76,6 +84,7 @@ def __init__(self, ble_device: BLEDevice) -> None: self._fragment_buffers: dict[bytes, dict[int, bytes]] = {} self._fragment_totals: dict[bytes, int] = {} self._data: dict[str, bytes] | None = None + self._summary: dict[str, object] = {} self._last_data_timestamp: datetime | None = None self._last_packet_timestamp: datetime | None = None self._negotiation_timestamp: float | None = None @@ -104,11 +113,50 @@ def remove_callback(self, function: Callable[[], None]) -> None: """ self._state_changed_callbacks.remove(function) + @staticmethod + def _ts() -> str: + """Current unix time as a 4-byte little-endian hex string. + + Used as the live replay-protection timestamp in the negotiation and session + commands; newer firmware rejects a stale (frozen) value and drops the link. + """ + return int(time.time()).to_bytes(4, "little").hex() + + @staticmethod + def _local_posix_tz() -> str: + """Local timezone as a full POSIX TZ string (e.g. ``EST5EDT,M3.2.0,M11.1.0``). + + Sent in the stage-5 confer. The complete rule string, including the DST + transition dates, is read from the system zoneinfo file's POSIX footer + (TZif v2+ trailer); it falls back to abbreviations + offset without the + transition rules only when that file can't be read (e.g. non-Unix hosts). + """ + try: + with open("/etc/localtime", "rb") as tzfile: + data = tzfile.read() + if footer := data[data.rfind(b"\n", 0, -1) + 1 : -1].decode(): + return footer + except OSError: + pass + std, dst = time.tzname + offset = time.timezone // 3600 + return f"{std}{offset}{dst}" if time.daylight else f"{std}{offset}" + + def _negotiation_packet(self, cmd: str, extra: str = "") -> bytes: + """Build a cleartext negotiation frame carrying a live timestamp. + + :param cmd: 2-byte command hex (e.g. ``0001``). + :param extra: Optional trailing TLV hex appended after the timestamp. + :returns: Full packet; :meth:`_build_packet` adds header/length/checksum. + """ + payload = bytes.fromhex("a104" + self._ts() + extra) + return self._build_packet(_NEGOTIATION_PATTERN, bytes.fromhex(cmd), payload) + async def _initiate_negotiations(self) -> None: - """Send the negotiation initiation command.""" + """Send the negotiation initiation command with a live timestamp.""" await self._client.write_gatt_char( UUID_COMMAND, - bytes.fromhex(NEGOTIATION_COMMAND_0), + self._negotiation_packet("0001"), response=True, ) @@ -307,6 +355,18 @@ def last_update(self) -> datetime | None: """ return self._last_data_timestamp + @property + def summary(self) -> dict[str, object]: + """Fields from the latest protobuf device-summary frame, if any. + + Populated from a ``_PROTOBUF_TELEMETRY_COMMANDS`` frame (e.g. the C2000 G2's + ``c490``) by :func:`SolixBLE.parsing.walk_protobuf`, keyed by protobuf + ``.path``. Empty until such a frame is received. + + :returns: Mapping of ``.path`` to value. + """ + return self._summary + def _parse_int( self, key: str, @@ -631,6 +691,14 @@ async def _process_telemetry_packet( ) return None _LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}") + # Protobuf telemetry (e.g. the C2000 G2's c490 device summary) is not the + # 1-byte-tag TLV format _parse_payload understands. Walk it into a `.path` + # field map (see :mod:`SolixBLE.parsing`), exposed via :attr:`summary`, rather + # than TLV-parsing it -- which would misread the varint tags. + if cmd is not None and cmd.hex() in self._PROTOBUF_TELEMETRY_COMMANDS: + self._summary = walk_protobuf(decrypted_payload) + _LOGGER.debug(f"Protobuf summary ({len(self._summary)} fields)") + return None parameters = self._parse_payload(decrypted_payload) return await self._process_telemetry(parameters) @@ -746,10 +814,11 @@ async def _process_notification( _LOGGER.debug( f"Decrypted payload: {decrypted_payload.hex()}", ) - parameters = self._parse_payload(decrypted_payload) - _LOGGER.debug( - f"Parameters: {self._parameters_to_str(parameters, types=True)}", - ) + # Do NOT TLV-parse unknown frames: some (e.g. the C2000's c490 + # device summary) are protobuf, not the 1-byte-tag TLV format, so + # _parse_payload would misread the varint tags and log an error + # per frame. The decrypted cleartext above is the useful output; + # a consumer that understands the frame decodes it. except Exception: _LOGGER.exception( "Exception decrypting unknown message type", @@ -779,7 +848,7 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: _LOGGER.debug("Sending stage 1 response message...") return await self._client.write_gatt_char( UUID_COMMAND, - bytes.fromhex(NEGOTIATION_COMMAND_1), + self._negotiation_packet("0003", "a30120a40200f0"), ) # Negotiation stage 2 @@ -792,7 +861,7 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: _LOGGER.debug("Sending stage 2 response message...") return await self._client.write_gatt_char( UUID_COMMAND, - bytes.fromhex(NEGOTIATION_COMMAND_2), + self._negotiation_packet("0029"), ) # Negotiation stage 3 @@ -806,7 +875,7 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: _LOGGER.debug("Sending stage 3 response message...") return await self._client.write_gatt_char( UUID_COMMAND, - bytes.fromhex(NEGOTIATION_COMMAND_3), + self._negotiation_packet("0005", "a30120a40200f0a50140"), ) # Negotiation stage 4 @@ -849,10 +918,24 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: self._shared_secret = private_key.exchange(ECDH(), device_public_key) _LOGGER.debug(f"Shared secret: {self._shared_secret.hex()}") - _LOGGER.debug("Sending stage 5 response message...") + # Send the 4022 confer built live (CBC-encrypted with the negotiated + # secret) instead of replaying the frozen NEGOTIATION_COMMAND_5: newer + # firmware rejects the stale timestamp. Structure mirrors the A91B2 + # station's CBC confer -- a1 timestamp, a3/a5 flags, local timezone. + _LOGGER.debug("Sending stage 5 (confer) response message...") + confer = bytes.fromhex( + "a104" + + self._ts() + + "a30440380000a516" + + self._local_posix_tz().encode().hex(), + ) return await self._client.write_gatt_char( UUID_COMMAND, - bytes.fromhex(NEGOTIATION_COMMAND_5), + self._build_packet( + _NEGOTIATION_PATTERN, + bytes.fromhex("4022"), + self._encrypt_payload(confer), + ), ) # Negotiation stage 6 (Optional) @@ -889,18 +972,9 @@ async def _send_command(self, cmd: bytes, payload: bytes) -> None: if not self.negotiated: raise ConnectionError("Not connected to device") - # Commands include a timestamp in the payload to prevent replay attacks - # and that timestamp is set during negotiations - time_passed = int(time.time() - self._negotiation_timestamp) - base_timestamp = int.from_bytes( - bytes.fromhex(BASE_TIMESTAMP), - byteorder="little", - ) - new_timestamp = (base_timestamp + time_passed).to_bytes( - length=4, - byteorder="little", - ) - new_payload = payload + bytes.fromhex("fe0503") + new_timestamp + # Commands carry a live timestamp in the payload to prevent replay attacks; + # newer firmware rejects a stale one, which blocks the telemetry stream. + new_payload = payload + bytes.fromhex("fe0503") + bytes.fromhex(self._ts()) await self._send_encrypted_packet(cmd, new_payload) def _build_packet(self, pattern: bytes, cmd: bytes, payload: bytes) -> bytes: @@ -1146,6 +1220,7 @@ def _reset_session(self, reset_data: bool = True) -> None: if reset_data: self._data = None + self._summary = {} self._last_data_timestamp = None self._fragment_buffers = {} diff --git a/SolixBLE/devices/__init__.py b/SolixBLE/devices/__init__.py index 08588a7..f3447a2 100644 --- a/SolixBLE/devices/__init__.py +++ b/SolixBLE/devices/__init__.py @@ -9,6 +9,7 @@ from .c800 import C800 from .c1000 import C1000 from .c1000g2 import C1000G2 +from .c2000g2 import C2000G2 from .f2000 import F2000 from .f3800 import F3800 from .generic import Generic @@ -27,15 +28,16 @@ "C800", "C1000", "C1000G2", + "C2000G2", "F2000", "F3800", - "Solarbank2", - "Solarbank3", + "Generic", + "MagGo3in1", "PrimeCharger160w", "PrimeCharger250w", "PrimeChargingStation240w", "PrimePowerBank20k", "PrimeUsbCharger", - "MagGo3in1", - "Generic", + "Solarbank2", + "Solarbank3", ] diff --git a/SolixBLE/devices/c1000g2.py b/SolixBLE/devices/c1000g2.py index 424700d..66ad704 100644 --- a/SolixBLE/devices/c1000g2.py +++ b/SolixBLE/devices/c1000g2.py @@ -4,6 +4,7 @@ """ +from ..const import DEFAULT_METADATA_FLOAT, DEFAULT_METADATA_INT from ..device import SolixBLEDevice from ..states import PortStatus @@ -37,7 +38,14 @@ class C1000G2(SolixBLEDevice): _EXPECTED_TELEMETRY_LENGTH: int = 253 #: The Gen 2 pushes telemetry on different command codes to the gen-1 models. - _TELEMETRY_COMMANDS: tuple[str, ...] = ("c421", "c900") + #: ``c490`` is the device-summary the C2000 G2 (A1783) posts unsolicited every + #: ~9 min: routed here (not the dropped unknown-frame branch) so it's reassembled + #: and decrypted, but its payload is protobuf, so it's flagged below to skip the + #: TLV parse -- a protobuf-aware consumer decodes the delivered cleartext. + _TELEMETRY_COMMANDS: tuple[str, ...] = ("c421", "c900", "c490") + + #: ``c490`` carries a protobuf device summary, not the TLV format the base parses. + _PROTOBUF_TELEMETRY_COMMANDS: tuple[str, ...] = ("c490",) async def _post_connect(self) -> None: """Subscribe to telemetry once connected. @@ -57,7 +65,8 @@ async def turn_ac_on(self) -> None: :raises BleakError: If command transmission fails. """ await self._send_command( - cmd=bytes.fromhex(CMD_AC_OUTPUT), payload=bytes.fromhex(PAYLOAD_ON) + cmd=bytes.fromhex(CMD_AC_OUTPUT), + payload=bytes.fromhex(PAYLOAD_ON), ) async def turn_ac_off(self) -> None: @@ -67,7 +76,8 @@ async def turn_ac_off(self) -> None: :raises BleakError: If command transmission fails. """ await self._send_command( - cmd=bytes.fromhex(CMD_AC_OUTPUT), payload=bytes.fromhex(PAYLOAD_OFF) + cmd=bytes.fromhex(CMD_AC_OUTPUT), + payload=bytes.fromhex(PAYLOAD_OFF), ) async def turn_dc_on(self) -> None: @@ -81,7 +91,8 @@ async def turn_dc_on(self) -> None: :raises BleakError: If command transmission fails. """ await self._send_command( - cmd=bytes.fromhex(CMD_DC_OUTPUT), payload=bytes.fromhex(PAYLOAD_ON) + cmd=bytes.fromhex(CMD_DC_OUTPUT), + payload=bytes.fromhex(PAYLOAD_ON), ) async def turn_dc_off(self) -> None: @@ -91,7 +102,8 @@ async def turn_dc_off(self) -> None: :raises BleakError: If command transmission fails. """ await self._send_command( - cmd=bytes.fromhex(CMD_DC_OUTPUT), payload=bytes.fromhex(PAYLOAD_OFF) + cmd=bytes.fromhex(CMD_DC_OUTPUT), + payload=bytes.fromhex(PAYLOAD_OFF), ) @property @@ -299,3 +311,131 @@ def min_battery_percentage(self) -> int: :returns: Battery charge percentage lower limit or default int value. """ return self._parse_int("d9", begin=5, end=6) + + @property + def max_input_power(self) -> int: + """Maximum charge-input limit in watts (tag ``a3``). + + :returns: Maximum input limit in watts or default int value. + """ + return self._parse_int("a3", begin=5, end=7) + + @property + def dc_input_power(self) -> int: + """DC/solar input power in watts (tag ``a6``). + + :returns: DC input power in watts or default int value. + """ + return self._parse_int("a6", begin=5, end=7) + + @property + def remaining_time_hours(self) -> float: + """Remaining time in hours (tag ``a6``, deci-hours ``x0.1``). + + Direction-aware: time until full when charging, time until empty when + discharging. + + :returns: Remaining time in hours or default float value. + """ + raw = self._parse_int("a6", begin=7, end=9) + return raw / 10 if raw != DEFAULT_METADATA_INT else DEFAULT_METADATA_FLOAT + + @property + def main_battery_soc(self) -> int: + """Main-battery state of charge in percent, excluding expansion (tag ``a6``). + + :returns: Main battery SOC percent or default int value. + """ + return self._parse_int("a6", begin=9, end=10) + + # -- c490 device-summary fields (walker output; see :attr:`summary`) ---------- + # ``c490`` is a ~9-minute rollup, armed by a cloud session, so these read their + # defaults until a frame arrives. Fields that also exist in the per-second + # ``0421`` stream are exposed namespaced ``c490_*`` so a stale summary value + # never masks the live one. + + def _summary_int(self, path: str) -> int: + """Read an int leaf from the latest c490 summary, or the default.""" + value = self._summary.get(path) + return value if isinstance(value, int) else DEFAULT_METADATA_INT + + @property + def battery_voltage(self) -> float: + """Battery pack voltage in volts, from the c490 summary (``.15.3``, ``x0.1``). + + :returns: Pack voltage in volts or default float value. + """ + raw = self._summary_int(".15.3") + return raw / 10 if raw != DEFAULT_METADATA_INT else DEFAULT_METADATA_FLOAT + + @property + def flow_state(self) -> int: + """Power-flow state from the c490 summary (``.23.7``). + + ``0`` = idle/balanced, ``1`` = net discharge, ``2`` = charge. + + :returns: Flow state or default int value. + """ + return self._summary_int(".23.7") + + @property + def charge_presence(self) -> int: + """Charge-presence indicator from the c490 summary (``.23.6``); units uncalibrated. + + :returns: Charge presence value or default int value. + """ + return self._summary_int(".23.6") + + @property + def cumulative_discharge_energy(self) -> int: + """Cumulative discharge energy in watt-hours, from the c490 summary (``.19.8``). + + :returns: Cumulative discharge energy in Wh or default int value. + """ + return self._summary_int(".19.8") + + @property + def c490_battery_soc(self) -> int: + """Battery SOC percent from the ~9-min c490 summary (``.23.1``). + + See :attr:`battery_percentage` for the live per-second value. + + :returns: SOC percent or default int value. + """ + return self._summary_int(".23.1") + + @property + def c490_output_power_total(self) -> int: + """Total output power in watts from the c490 summary (``.23.2``). + + :returns: Total output power in watts or default int value. + """ + return self._summary_int(".23.2") + + @property + def c490_ac_output_power(self) -> int: + """AC output power in watts from the c490 summary (``.23.3``). + + DC/USB output = :attr:`c490_output_power_total` minus this. + + :returns: AC output power in watts or default int value. + """ + return self._summary_int(".23.3") + + @property + def c490_input_power_total(self) -> int: + """Total input power in watts from the c490 summary (``.23.4``). + + :returns: Total input power in watts or default int value. + """ + return self._summary_int(".23.4") + + @property + def c490_dc_input_power(self) -> int: + """DC/solar input power in watts from the c490 summary (``.23.5``). + + AC input = :attr:`c490_input_power_total` minus this. + + :returns: DC input power in watts or default int value. + """ + return self._summary_int(".23.5") diff --git a/SolixBLE/devices/c2000g2.py b/SolixBLE/devices/c2000g2.py new file mode 100644 index 0000000..88e1542 --- /dev/null +++ b/SolixBLE/devices/c2000g2.py @@ -0,0 +1,53 @@ +"""C2000(X) Gen 2 power station model. + +.. moduleauthor:: kb1ibt + +""" + +from ..parsing import walk_lv +from .c1000g2 import C1000G2 + + +class C2000G2(C1000G2): + """ + C2000(X) Gen 2 Power Station. + + Use this class to connect, monitor and control a Gen 2 C2000(X) power + station. This model is also known as the A1783. + + The C2000 G2 is the larger sibling of the C1000 G2 (A1763) and shares its + Gen 2 BLE stack: the same ``c421``/``c900`` telemetry framing and TLV field + map, the same ``4100`` subscribe command, and the same AC (``4101``) and DC + (``4102``) control. Its three USB-C ports, single USB-A port, AC, DC and + solar all decode identically -- confirmed against a live A1783 frame (serial, + part number ``A1783``, temperature, battery percentage and min/max SOC all + read correctly through the inherited + :class:`~SolixBLE.devices.c1000g2.C1000G2` properties) -- so it is driven + almost entirely by that inherited behaviour. + + The C2000 additionally reports a parallel/expansion battery (a BP2000) in tag + ``ce`` of the ``c421`` telemetry. Unlike the flat fields, ``ce`` is a nested + length-value block (the app's "ๅนถๆœบ"/``DeviceCombinationInfo``) whose first + field is a 16-byte combination-device ID -- all-zero when no unit is combined. + :attr:`expansion_present` decodes that ID via :func:`SolixBLE.parsing.walk_lv`; + the remaining sub-fields (the combined pack's identity/mode/power) are left + undecoded pending a capture from a device that actually has one combined. The + ``c490`` protobuf device summary (exposed via :attr:`summary`) carries the + cumulative energy ledgers. + """ + + @property + def expansion_present(self) -> bool: + """Whether a parallel/expansion battery (e.g. a BP2000) is combined. + + The ``ce`` tag's first length-value field is a 16-byte combination-device + ID: all-zero means no unit is combined (the app's + ``DeviceCombinationInfo.status == single``), non-zero means one is. + + :returns: True if a parallel/expansion unit is combined, else False. + """ + if not self._data or "ce" not in self._data: + return False + # ce value is a `bin` field (0x04 type byte) wrapping a length-value block. + fields = walk_lv(self._data["ce"][1:]) + return bool(fields and any(fields[0])) diff --git a/SolixBLE/devices/prime_charging_station_240w.py b/SolixBLE/devices/prime_charging_station_240w.py index d5b15e0..a397520 100644 --- a/SolixBLE/devices/prime_charging_station_240w.py +++ b/SolixBLE/devices/prime_charging_station_240w.py @@ -1,81 +1,310 @@ -"""Anker Prime Charging Station (240W) model. +"""Anker Prime Charging Station (240W / A91B2) model. .. moduleauthor:: Harvey Lelliott (flip-dots) """ -from ..const import DEFAULT_METADATA_BOOL, DEFAULT_METADATA_FLOAT +import asyncio +import logging +import time + +from cryptography.hazmat.primitives.asymmetric.ec import ( + ECDH, + SECP256R1, + EllipticCurvePublicKey, + derive_private_key, +) +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + +from ..const import ( + DEFAULT_METADATA_BOOL, + DEFAULT_METADATA_FLOAT, + UUID_COMMAND, +) +from ..device import SolixBLEDevice +from ..prime_device import PRIVATE_KEY from .prime_usb_charger import PrimeUsbCharger +_LOGGER = logging.getLogger(__name__) + +#: Cleartext-negotiation / confer packet pattern (``0xxx`` and ``4022``/``4023``). +_NEGOTIATION_PATTERN = b"\x03\x00\x01" +#: Encrypted session (data command) packet pattern. +_SESSION_PATTERN = b"\x03\x00\x0f" + class PrimeChargingStation240w(PrimeUsbCharger): - """ - Anker Prime Charging Station (240W) model. - - Use this class to connect and monitor the 240w charging station. This model - is also known as the A91B2. It shares the four USB-C and two USB-A ports of - the 250W charger (decoded by the base class) and adds two switchable 120V AC - outlets and a display. - - .. note:: - Modelled on the anker-solix-api ``A91B2`` map. The AC-outlet switch - states are decoded here; AC-outlet power/voltage and the display state - are not yet mapped (they are absent from the cloud map too) and await a - live capture with an AC load. + """Anker Prime Charging Station (240W / A91B2), an 8-in-1 charging station. + + Despite sharing the Prime USB-charger telemetry layout, the station is **not** + a Prime/GCM device -- it is a base/**CBC**-lineage device (like the C1000 Gen 2): + it negotiates *in the clear* (``0xxx``) rather than with the encrypted ``4xxx`` + handshake, and encrypts the session with AES-CBC (key ``ss[:16]``, IV ``ss[16:]``). + Talking GCM to it (as :class:`PrimeDevice` does) leaves every session command + silently un-decryptable, so this class overrides the crypto and negotiation while + inheriting the port decode from :class:`PrimeUsbCharger`. + + Two telemetry frames with **different tag layouts** are handled separately: + + * ``4a00`` (msgtype ``0a00``) -- full snapshot: ``a4``-``a9`` = the six USB ports + (decoded by the inherited properties) plus ``aa``/``ab`` = the two AC-outlet + switch states. Requested with ``4200``; refreshed by re-running the confer. + * ``4303`` (msgtype ``0303``) -- ~1/s stream: the same six ports, one tag earlier + (``a2``-``a7``). Remapped onto the snapshot tags and merged into :attr:`_data`, + so the one inherited ``usb_c*``/``usb_a*`` property set reflects either frame. + Started with ``420b`` (realtime trigger). + + No cloud/account data is needed: the confer is self-contained, and the device + serial (which it binds) comes from the negotiation itself (``0829`` stage, + ``a4``). Verified on hardware -- the station streams with no owner user_id. """ - _EXPECTED_TELEMETRY_LENGTH: int = 253 + #: The station streams the full snapshot on ``4a00`` and the ~1/s port stream on + #: ``4303``; ``4302`` is the switch-change ack (only after a ``4207``, never sent). + _TELEMETRY_COMMANDS: tuple[str, ...] = ("4a00", "4303", "4302") - async def _post_connect(self) -> None: - """No post-connect subscribe yet -- the station's enable sequence is unknown. + #: Local timezone string the app sends in the ``4022`` confer. + _TIMEZONE = "EST5EDT,M3.2.0,M11.1.0" + + #: The station is a base/CBC device -- use the base session crypto, not + #: :class:`PrimeDevice`'s AES-GCM. + _encrypt_payload = SolixBLEDevice._encrypt_payload + _decrypt_payload = SolixBLEDevice._decrypt_payload + + #: Where each port lives in the ``4a00`` full snapshot (msgtype ``0a00``); the + #: inherited ``usb_c*``/``usb_a*`` properties read these tags from :attr:`_data`. + _SNAPSHOT_PORT_TAGS = { + "usb_c1": "a4", + "usb_c2": "a5", + "usb_c3": "a6", + "usb_c4": "a7", + "usb_a1": "a8", + "usb_a2": "a9", + } + #: Where the same ports live in the ``4303`` stream (msgtype ``0303``) -- one tag + #: earlier; remapped onto the snapshot tags so both frames feed one property set. + _STREAM_PORT_TAGS = { + "usb_c1": "a2", + "usb_c2": "a3", + "usb_c3": "a4", + "usb_c4": "a5", + "usb_a1": "a6", + "usb_a2": "a7", + } + + #: Command of the telemetry frame currently being processed (routing hint). + _routing_cmd: str | None = None + + # ------------------------------------------------------------------ helpers + + @staticmethod + def _ts() -> str: + """Current unix time as a 4-byte little-endian hex string.""" + return int(time.time()).to_bytes(4, "little").hex() + + async def _send_packet(self, pattern: bytes, cmd: str, plaintext: bytes) -> None: + """Encrypt (CBC) and send a session/confer command, no response awaited.""" + packet = self._build_packet( + pattern, + bytes.fromhex(cmd), + self._encrypt_payload(plaintext), + ) + await self._client.write_gatt_char(UUID_COMMAND, packet, response=True) + + async def _exchange( + self, + cmd: str, + payload_hex: str, + resp_cmd: str, + timeout: int = 6, + ) -> bytes | None: + """Send a cleartext ``0xxx`` negotiation frame and await its ``08xx`` reply.""" + future = asyncio.get_running_loop().create_future() + resp = bytes.fromhex(resp_cmd) + self._register_future(future, _NEGOTIATION_PATTERN, resp) + try: + packet = self._build_packet( + _NEGOTIATION_PATTERN, + bytes.fromhex(cmd), + bytes.fromhex(payload_hex), + ) + await self._client.write_gatt_char(UUID_COMMAND, packet, response=True) + return await asyncio.wait_for(future, timeout) + except (TimeoutError, asyncio.CancelledError): + return None + finally: + self._deregister_future(future, _NEGOTIATION_PATTERN, resp) - The 250W charger's registration (command 4027) is rejected by the station - (it drops the link), and no subscribe alone starts its stream, so the base - PrimeDevice._post_connect is not used here. The correct post-ECDH enable - needs an Anker-app handshake capture; until then the station negotiates and - stays connected but does not stream telemetry. + # ------------------------------------------------------------- negotiation + + async def _initiate_negotiations(self) -> None: + """Run the whole cleartext (``0xxx``) handshake and derive the CBC session key. + + Base :meth:`connect` calls this and then waits on :attr:`negotiated` + (``_shared_secret is not None``); doing the full handshake here lets us reuse + the base connect/reconnect machinery while replacing only the negotiation. """ - return + private_key = derive_private_key(int(PRIVATE_KEY, 16), SECP256R1()) + public_key = private_key.public_key().public_bytes( + Encoding.X962, + PublicFormat.UncompressedPoint, + )[1:] - @property - def usb_total_power_out(self) -> float: - """Total USB output power across the six USB ports (W). + stages = ( + ("0001", "a104" + self._ts(), "0801"), + ("0003", "a104" + self._ts() + "a30120a40200f0", "0803"), + ("0029", "a104" + self._ts(), "0829"), + ("0005", "a104" + self._ts() + "a30120a40200f0a50140", "0805"), + ) + for cmd, payload, resp_cmd in stages: + response = await self._exchange(cmd, payload, resp_cmd) + if response is None: + _LOGGER.warning( + "A91B2 '%s' negotiation stalled awaiting %s", + self.name, + resp_cmd, + ) + return + # Stage 3 (0829) carries the device identity: a4 = serial, a5 = MAC. + if cmd == "0029": + self._device_info = self._parse_payload(response) - Excludes the AC outlets, whose power is not yet decoded. + response = await self._exchange("0021", "a140" + public_key.hex(), "0821") + if response is None: + _LOGGER.warning("A91B2 '%s' no device public key (0821)", self.name) + return + device_public_key = EllipticCurvePublicKey.from_encoded_point( + SECP256R1(), + b"\x04" + self._parse_payload(response)["a1"], + ) + self._shared_secret = private_key.exchange(ECDH(), device_public_key) + self._negotiation_timestamp = time.time() + _LOGGER.debug( + "A91B2 '%s' negotiated (serial=%s)", + self.name, + self.serial_number, + ) - :returns: Sum of the six USB port powers or default float value. + async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: + """No-op: the station negotiates in the clear in :meth:`_initiate_negotiations`. + + The base ``030001`` handler drives :class:`PrimeDevice`'s encrypted ``4xxx`` + reactor, which must never run here; stray ``030001`` frames (e.g. a confer ack + not consumed by a future) are simply logged. """ - if self._data is None: - return DEFAULT_METADATA_FLOAT + _LOGGER.debug( + "A91B2 '%s' ignoring unsolicited 030001 cmd %s", + self.name, + cmd.hex(), + ) - return round( - self.usb_c1_power - + self.usb_c2_power - + self.usb_c3_power - + self.usb_c4_power - + self.usb_a1_power - + self.usb_a2_power, - 2, + async def _post_connect(self) -> None: + """Send the CBC confer, request the full snapshot, and start the stream. + + Runs on every (re)connection once the session is negotiated. Fire-and-forget: + ``4822``/``4823`` confer acks are harmless (see :meth:`_process_negotiation`), + and ``4a00``/``4303`` responses flow through the telemetry path. + """ + serial = (self._device_info or {}).get("a4", b"") + timezone = self._TIMEZONE.encode().hex() + + # 4022 -- timezone; 4023 -- bind device serial (both AES-CBC, 030001). + await self._send_packet( + _NEGOTIATION_PATTERN, + "4022", + bytes.fromhex("a104" + self._ts() + "a30440380000a516" + timezone), + ) + await asyncio.sleep(0.4) + await self._send_packet( + _NEGOTIATION_PATTERN, + "4023", + bytes.fromhex("a104" + self._ts() + "a310") + serial, + ) + await asyncio.sleep(0.4) + # 4200 -- status request (-> 4a00 snapshot); 420b -- realtime trigger (-> 4303). + await self._send_packet( + _SESSION_PATTERN, + "4200", + bytes.fromhex("a10121fe0503" + self._ts()), + ) + await asyncio.sleep(0.4) + await self._send_packet( + _SESSION_PATTERN, + "420b", + bytes.fromhex("a10121fe0503" + self._ts()), ) + # --------------------------------------------------------------- telemetry + + async def _process_telemetry_packet( + self, + payload: bytes, + cmd: bytes = None, + ) -> None: + """Tag the frame so :meth:`_process_telemetry` can tell ``4a00`` from ``4303``. + + The base reassembles and length-gates every session frame (SolixBLE #42) -- + including the station's frag-byte-less single ``4a00``/``4303`` notifications + -- so this only records which telemetry command produced the payload. The two + frames carry the six ports at different tags, and :meth:`_process_telemetry` + uses this hint to remap them onto one property set. + """ + self._routing_cmd = bytes(cmd).hex() if cmd else None + return await super()._process_telemetry_packet(payload, cmd) + + async def _process_telemetry(self, parameters: dict[str, bytes]) -> None: + """Normalise both telemetry frames onto the one (snapshot) port view. + + The two frames tag the same six ports differently (``4a00`` at ``a4``-``a9``, + ``4303`` at ``a2``-``a7``). Rather than expose that as two property families, + the ``4303`` stream is remapped onto the snapshot tags and merged into + :attr:`_data`, so the inherited ``usb_c*``/``usb_a*`` properties always read + the freshest of either frame. The merge preserves the ``4a00``-only fields + (``aa``/``ab`` AC switches) between snapshots. ``4302`` (switch ack) is ignored. + """ + if self._routing_cmd == "4303": + merged = dict(self._data or {}) + for port, snapshot_tag in self._SNAPSHOT_PORT_TAGS.items(): + stream_tag = self._STREAM_PORT_TAGS[port] + if stream_tag in parameters: + merged[snapshot_tag] = parameters[stream_tag] + await super()._process_telemetry(merged) + elif self._routing_cmd == "4a00": + await super()._process_telemetry(parameters) + + # ------------------------------------------------- 4a00 snapshot additions + @property def ac_1_switch(self) -> bool: - """AC outlet 1 on/off switch state. + """AC outlet 1 switch state (from the ``4a00`` snapshot). - :returns: True when AC outlet 1 is switched on or default bool value. + :returns: True if on, else False (or default bool if no data). """ - if self._data is None: + if not self._data or "aa" not in self._data: return DEFAULT_METADATA_BOOL - return bool(self._parse_int("aa", begin=1, end=2)) @property def ac_2_switch(self) -> bool: - """AC outlet 2 on/off switch state. + """AC outlet 2 switch state (from the ``4a00`` snapshot). - :returns: True when AC outlet 2 is switched on or default bool value. + :returns: True if on, else False (or default bool if no data). """ - if self._data is None: + if not self._data or "ab" not in self._data: return DEFAULT_METADATA_BOOL - return bool(self._parse_int("ab", begin=1, end=2)) + + @property + def usb_total_power_out(self) -> float: + """Total output power over the six USB ports (W), from either frame.""" + if self._data is None: + return DEFAULT_METADATA_FLOAT + return round( + self.usb_c1_power + + self.usb_c2_power + + self.usb_c3_power + + self.usb_c4_power + + self.usb_a1_power + + self.usb_a2_power, + 2, + ) diff --git a/SolixBLE/parsing.py b/SolixBLE/parsing.py new file mode 100644 index 0000000..9893938 --- /dev/null +++ b/SolixBLE/parsing.py @@ -0,0 +1,134 @@ +"""Walkers for nested self-delimiting telemetry payloads. + +.. moduleauthor:: kb1ibt + +Most telemetry fields are flat, fixed-layout TLV that +:meth:`SolixBLE.device.SolixBLEDevice._parse_payload` decodes directly. A few fields +instead carry a *nested* self-delimiting structure that fixed offsets cannot decode, +in one of two encodings (distinguished by the value's leading type byte): + +* **protobuf** (type byte ``0x07``) -- e.g. the C2000 G2's ``c490`` device summary. + Walked by :func:`walk_protobuf`. +* **length-value** (a ```` sequence, type byte ``0x04`` binary) -- e.g. + the ``ce`` combination-battery block in the C2000 G2's ``c421`` telemetry. Walked by + :func:`walk_lv`. + +Both encodings self-delimit, so a value that grows past a byte boundary never shifts +the fields after it -- the whole point over a brittle fixed-offset map. +""" + +from __future__ import annotations + + +def read_varint(buf: bytes, pos: int) -> tuple[int, int]: + """Read one LEB128 varint from ``buf`` at ``pos``. + + :param buf: Buffer to read from. + :param pos: Index to start reading at. + :returns: ``(value, next_pos)``. + """ + result = shift = 0 + while True: + byte = buf[pos] + result |= (byte & 0x7F) << shift + pos += 1 + if not byte & 0x80: + return result, pos + shift += 7 + + +def _is_protobuf_message(sub: bytes) -> bool: + """True if ``sub`` parses cleanly as a protobuf message (so it is recursed into).""" + pos = 0 + try: + while pos < len(sub): + tag, pos = read_varint(sub, pos) + wire = tag & 7 + if wire == 0: + _, pos = read_varint(sub, pos) + elif wire == 2: + length, pos = read_varint(sub, pos) + pos += length + elif wire == 5: + pos += 4 + elif wire == 1: + pos += 8 + else: + return False + return pos == len(sub) + except (IndexError, ValueError): + return False + + +def walk_protobuf( + buf: bytes, prefix: str = "", out: dict[str, object] | None = None +) -> dict[str, object]: + """Flatten a protobuf(-like) blob to a ``.field.subfield`` -> value map. + + Repeated tags keep wire order (occurrence index appended as ``#n``) and every field + is addressed by its ``.path``, so byte offsets never matter -- a leaf value crossing + a varint byte boundary grows in place without shifting anything after it. + Length-delimited fields that themselves parse cleanly as a sub-message are recursed + into; otherwise they are kept as an ASCII string (if fully printable) or hex. + + :param buf: The (decrypted, reassembled) protobuf payload. + :param prefix: Path prefix used during recursion. + :param out: Accumulator dict used during recursion. + :returns: Mapping of ``.path`` to value (int, str or hex str). + """ + if out is None: + out = {} + pos = 0 + seen: dict[int, int] = {} + while pos < len(buf): + try: + tag, pos = read_varint(buf, pos) + except IndexError: + break + fnum, wire = tag >> 3, tag & 7 + occ = seen.get(fnum, 0) + seen[fnum] = occ + 1 + path = f"{prefix}.{fnum}" + (f"#{occ}" if occ else "") + if wire == 0: + out[path], pos = read_varint(buf, pos) + elif wire == 2: + length, pos = read_varint(buf, pos) + sub = buf[pos : pos + length] + pos += length + if length and _is_protobuf_message(sub) and any(sub): + walk_protobuf(sub, path, out) + elif sub and all(32 <= b < 127 for b in sub): + out[path] = sub.decode("ascii") + else: + out[path] = sub.hex() + elif wire == 5: + out[path] = int.from_bytes(buf[pos : pos + 4], "little") + pos += 4 + elif wire == 1: + out[path] = int.from_bytes(buf[pos : pos + 8], "little") + pos += 8 + else: + break + return out + + +def walk_lv(buf: bytes) -> list[bytes]: + """Walk a length-value blob into its fields. + + Each field is a single length byte followed by that many value bytes, repeated to + the end of ``buf``. Used for nested ``bin`` fields such as the C2000 G2's ``ce`` + combination-battery block, whose first field is a fixed 16-byte device ID (all-zero + when no unit is combined). Trailing zero padding therefore appears as trailing + zero-length fields. Pass the value **without** its leading type byte. + + :param buf: The field value, with its ``0x04`` type byte already stripped. + :returns: The fields in wire order. + """ + fields: list[bytes] = [] + pos = 0 + while pos < len(buf): + length = buf[pos] + pos += 1 + fields.append(buf[pos : pos + length]) + pos += length + return fields diff --git a/SolixBLE/prime_device.py b/SolixBLE/prime_device.py index 2f69728..24f4794 100644 --- a/SolixBLE/prime_device.py +++ b/SolixBLE/prime_device.py @@ -50,6 +50,13 @@ "a104f079b569a30400000000a518474d54304253542c4d332e352e302f312c4d31302e352e30" ) +#: Account owner_user_id bound into the 4027 registration (``a228``). Hardened +#: (SolixBLE #22) firmware rejects a plain ``a224`` registration (ack status +#: ``09``) and only arms telemetry once the registration carries the account that +#: owns the device. Account-specific -- MUST be sourced from config, never hard-coded +#: upstream. ``None`` falls back to the legacy ``a224`` payload. +OWNER_USER_ID: str | None = None + #: The cmd to put in the response to receiving 6th negotiation message NEGOTIATION_COMMAND_6_CMD = "4027" @@ -63,10 +70,19 @@ NEGOTIATION_COMMAND_7_CMD = "4200" NEGOTIATION_COMMAND_7_PAYLOAD = "a10121" -#: Stage 7b registers the client (region + app UUID). Also sent via _send_command, -#: so likewise no hard-coded timestamp trailer. +#: Stage 7b is the getter (region only). Also sent via _send_command, so likewise no +#: hard-coded timestamp trailer. The a3 field the app also sends is dropped: +#: the device binds its own serial via the 4027 registration, so the stale author UUID +#: is unnecessary (and a foreign one risks the device dropping the link). NEGOTIATION_COMMAND_8_CMD = "420a" -NEGOTIATION_COMMAND_8_PAYLOAD = "a10121a203044742a3250437396562656433352d646339632d343930342d623430632d373263346538363361613130a5020101" +NEGOTIATION_COMMAND_8_PAYLOAD = "a10121a203044742a5020101" + +#: Stage 7c is the realtime trigger (REALTIME_TRIGGER, 020b) that starts the ~1/s +#: per-port telemetry stream. The ``a20a`` block is the enable payload the app sends +#: (``04 01 0003 15 01 01 00 00 00``); a bare ``a10121`` trigger does nothing. Sent +#: via _send_command so it carries the live session timestamp trailer. +NEGOTIATION_COMMAND_9_CMD = "420b" +NEGOTIATION_COMMAND_9_PAYLOAD = "a10121a20a04010003150101000000" #: Anker Prime devices encrypt the negotiation using a static key NEGOTIATION_KEY = "b8ff7422955d4eb6d554a2c470280559" @@ -113,11 +129,21 @@ def _encrypt_payload(self, payload: bytes) -> bytes: Anker Prime devices use AES GCM with the first 16 bytes of the shared secret as the AES key and next 12 bytes as the nonce. The MAC tag is - 16 bytes and appended to the end of the payload. + 16 bytes and appended to the end of the payload. Before the shared secret + is established the static negotiation key and nonce are used instead (so the + early ``40xx`` stages can be built live rather than replayed). """ - cipher = AES.new( - self._shared_secret[:16], AES.MODE_GCM, nonce=self._shared_secret[16:28] + key = ( + self._shared_secret[:16] + if self._shared_secret is not None + else bytes.fromhex(NEGOTIATION_KEY) ) + nonce = ( + self._shared_secret[16:28] + if self._shared_secret is not None + else bytes.fromhex(NEGOTIATION_NONCE) + ) + cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) cipher.update(bytes.fromhex(AAD)) encrypted_payload, mac_bytes = cipher.encrypt_and_digest(payload) return encrypted_payload + mac_bytes @@ -153,10 +179,12 @@ def _decrypt_payload(self, payload: bytes) -> bytes: cipher.update(bytes.fromhex(AAD)) return cipher.decrypt_and_verify(encrypted_payload, mac) - # If validation fails decrypt anyway + # If validation fails decrypt anyway (Anker tolerates a MAC mismatch, and + # control-response acks are not meant to decrypt at all) -- this is expected + # and non-fatal, so log at DEBUG rather than an ERROR traceback per frame. except ValueError: - _LOGGER.exception( - "Failed to validate authenticity of payload, decoding anyway..." + _LOGGER.debug( + "Failed to validate authenticity of payload, decoding anyway...", ) cipher = AES.new(key, AES.MODE_GCM, nonce) return cipher.decrypt(encrypted_payload) @@ -165,24 +193,28 @@ def _decrypt_payload(self, payload: bytes) -> bytes: # Negotiation # ############### - async def _initiate_negotiations(self) -> None: - """ - Send the negotiation initiation command. - """ + def _live_negotiation_packet(self, cmd: str, extra: str = "") -> bytes: + """Build an encrypted 40xx negotiation frame carrying a live timestamp. - # Log parameters we will send if debugging (makes handshake easier to see in logs) - if _LOGGER.isEnabledFor(logging.DEBUG): - new_parameters = self._parse_payload( - self._decrypt_payload( - self._split_packet(bytes.fromhex(NEGOTIATION_COMMAND_0))[2] - ) - ) - _LOGGER.debug( - f"Stage 0 message parameters: {self._parameters_to_str(new_parameters, types=True)}" - ) + ``_encrypt_payload`` selects the key: the static ``NEGOTIATION_KEY``/nonce + before the ECDH secret exists (stages 0-4), the negotiated secret after (the + 4022 confer and 4027 registration). This replaces the frozen constants so + every frame shares one live clock -- newer firmware rejects a stale or + internally-inconsistent timestamp (the device acks but never streams). + """ + payload = bytes.fromhex("a104" + self._ts() + extra) + return self._build_packet( + bytes.fromhex(NEGOTIATION_PATTERN), + bytes.fromhex(cmd), + self._encrypt_payload(payload), + ) + async def _initiate_negotiations(self) -> None: + """Send the negotiation initiation command with a live timestamp.""" await self._client.write_gatt_char( - UUID_COMMAND, bytes.fromhex(NEGOTIATION_COMMAND_0), response=True + UUID_COMMAND, + self._live_negotiation_packet("4001"), + response=True, ) async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: @@ -201,63 +233,43 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: # Negotiation stage 1 case "4801": _LOGGER.debug( - "Entered negotiation stage 1 due to response from device!" + "Entered negotiation stage 1 due to response from device!", ) decrypted_payload = self._decrypt_payload(payload) _LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}") parameters = self._parse_payload(decrypted_payload) _LOGGER.debug( - f"Parameters: {self._parameters_to_str(parameters, types=True)}" + f"Parameters: {self._parameters_to_str(parameters, types=True)}", ) - # Log parameters we will send if debugging (makes handshake easier to see in logs) - if _LOGGER.isEnabledFor(logging.DEBUG): - new_parameters = self._parse_payload( - self._decrypt_payload( - self._split_packet(bytes.fromhex(NEGOTIATION_COMMAND_1))[2] - ) - ) - _LOGGER.debug( - f"Stage 1 response message parameters: {self._parameters_to_str(new_parameters, types=True)}" - ) - _LOGGER.debug("Sending stage 1 response message...") return await self._client.write_gatt_char( - UUID_COMMAND, bytes.fromhex(NEGOTIATION_COMMAND_1) + UUID_COMMAND, + self._live_negotiation_packet("4003", "a30120a40200f0"), ) # Negotiation stage 2 case "4803": _LOGGER.debug( - "Entered negotiation stage 2 due to response from device!" + "Entered negotiation stage 2 due to response from device!", ) decrypted_payload = self._decrypt_payload(payload) _LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}") parameters = self._parse_payload(decrypted_payload) _LOGGER.debug( - f"Parameters: {self._parameters_to_str(parameters, types=True)}" + f"Parameters: {self._parameters_to_str(parameters, types=True)}", ) - # Log parameters we will send if debugging (makes handshake easier to see in logs) - if _LOGGER.isEnabledFor(logging.DEBUG): - new_parameters = self._parse_payload( - self._decrypt_payload( - self._split_packet(bytes.fromhex(NEGOTIATION_COMMAND_2))[2] - ) - ) - _LOGGER.debug( - f"Stage 2 response message parameters: {self._parameters_to_str(new_parameters, types=True)}" - ) - _LOGGER.debug("Sending stage 2 response message...") return await self._client.write_gatt_char( - UUID_COMMAND, bytes.fromhex(NEGOTIATION_COMMAND_2) + UUID_COMMAND, + self._live_negotiation_packet("4029"), ) # Negotiation stage 3 case "4829": _LOGGER.debug( - "Entered negotiation stage 3 due to response from device!" + "Entered negotiation stage 3 due to response from device!", ) decrypted_payload = self._decrypt_payload(payload) _LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}") @@ -267,63 +279,54 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: # the identity properties to read. self._device_info = parameters _LOGGER.debug( - f"Parameters: {self._parameters_to_str(parameters, types=True)}" + f"Parameters: {self._parameters_to_str(parameters, types=True)}", ) - # Log parameters we will send if debugging (makes handshake easier to see in logs) - if _LOGGER.isEnabledFor(logging.DEBUG): - new_parameters = self._parse_payload( - self._decrypt_payload( - self._split_packet(bytes.fromhex(NEGOTIATION_COMMAND_3))[2] - ) - ) - _LOGGER.debug( - f"Stage 3 response message parameters: {self._parameters_to_str(new_parameters, types=True)}" - ) - _LOGGER.debug("Sending stage 3 response message...") return await self._client.write_gatt_char( - UUID_COMMAND, bytes.fromhex(NEGOTIATION_COMMAND_3) + UUID_COMMAND, + self._live_negotiation_packet("4005", "a30120a4022901a50144a60102"), ) # Negotiation stage 4 case "4805": _LOGGER.debug( - "Entered negotiation stage 4 due to response from device!" + "Entered negotiation stage 4 due to response from device!", ) decrypted_payload = self._decrypt_payload(payload) _LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}") parameters = self._parse_payload(decrypted_payload) _LOGGER.debug( - f"Parameters: {self._parameters_to_str(parameters, types=True)}" + f"Parameters: {self._parameters_to_str(parameters, types=True)}", ) # Log parameters we will send if debugging (makes handshake easier to see in logs) if _LOGGER.isEnabledFor(logging.DEBUG): new_parameters = self._parse_payload( self._decrypt_payload( - self._split_packet(bytes.fromhex(NEGOTIATION_COMMAND_4))[2] - ) + self._split_packet(bytes.fromhex(NEGOTIATION_COMMAND_4))[2], + ), ) _LOGGER.debug( - f"Stage 4 response message parameters: {self._parameters_to_str(new_parameters, types=True)}" + f"Stage 4 response message parameters: {self._parameters_to_str(new_parameters, types=True)}", ) _LOGGER.debug("Sending stage 4 response message...") return await self._client.write_gatt_char( - UUID_COMMAND, bytes.fromhex(NEGOTIATION_COMMAND_4) + UUID_COMMAND, + bytes.fromhex(NEGOTIATION_COMMAND_4), ) # Negotiation stage 5 case "4821": _LOGGER.debug( - "Entered negotiation stage 5 due to response from device!" + "Entered negotiation stage 5 due to response from device!", ) decrypted_payload = self._decrypt_payload(payload) _LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}") parameters = self._parse_payload(decrypted_payload) _LOGGER.debug( - f"Parameters: {self._parameters_to_str(parameters, types=True)}" + f"Parameters: {self._parameters_to_str(parameters, types=True)}", ) self._negotiation_timestamp = time.time() @@ -332,7 +335,8 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: device_public_key_bytes = bytes.fromhex("04") + parameters["a1"] _LOGGER.debug(f"Public key of device: {device_public_key_bytes.hex()}") device_public_key = EllipticCurvePublicKey.from_encoded_point( - SECP256R1(), device_public_key_bytes + SECP256R1(), + device_public_key_bytes, ) # Calculate the shared secret @@ -350,29 +354,14 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: # shared secret for encryption rather than the static key. # This means we need to build these messages instead of using # pre-defined ones. - _LOGGER.debug("Sending stage 5 response message...") - - # Log parameters we will send if debugging (makes handshake easier to see in logs) - if _LOGGER.isEnabledFor(logging.DEBUG): - new_parameters = self._parse_payload( - bytes.fromhex(NEGOTIATION_COMMAND_5_PAYLOAD) - ) - _LOGGER.debug( - f"Stage 5 response message parameters: {self._parameters_to_str(new_parameters, types=True)}" - ) - - new_payload = self._encrypt_payload( - bytes.fromhex(NEGOTIATION_COMMAND_5_PAYLOAD) - ) - new_packet = self._build_packet( - pattern=bytes.fromhex(NEGOTIATION_PATTERN), - cmd=bytes.fromhex(NEGOTIATION_COMMAND_5_CMD), - payload=new_payload, - ) - _LOGGER.debug(f"Built stage 5 response packet: {new_packet.hex()}") + # Build the 4022 confer live (a1 timestamp + local timezone), encrypted + # with the just-derived secret, instead of replaying the frozen payload. + _LOGGER.debug("Sending stage 5 (confer) response message...") + tz = self._local_posix_tz().encode() + confer = "a30400000000a5" + f"{len(tz):02x}" + tz.hex() return await self._client.write_gatt_char( UUID_COMMAND, - new_packet, + self._live_negotiation_packet(NEGOTIATION_COMMAND_5_CMD, confer), ) # The ECDH handshake is complete after stage 5; the device's trailing @@ -385,7 +374,7 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: "_post_connect", cmd.hex(), ) - return + return None case _: _LOGGER.warning( @@ -394,27 +383,54 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: ) async def _post_connect(self) -> None: - """Register the client and subscribe to telemetry after negotiating. - - The registration (command 4027) and the telemetry subscribe (4200) are - post-connect commands, not responses to the device's trailing 4822/4827 - acks. They run here, after a short settle, mirroring how the gen-2 power - stations subscribe from _post_connect. The subscribe goes through - _send_command so it carries the live session timestamp (a stale one is - rejected as a replay, which blocks the stream). Overridden by models whose - post-connect sequence differs (e.g. the 240W charging station). + """Register the client and start the realtime telemetry stream. + + The Prime stream is armed post-connect (not as responses to the device's + trailing 4822/4827 acks): + + 1. **Registration** -- ``4027`` carries ``a224``. The device acks + it (``4827``) and stays connected. (Binding the device's own serial via + ``a3`` -- the 240W station's ``4023`` shape -- is *rejected* + on the genuine-Prime ``4027`` and drops the link.) + 2. **The realtime trigger** -- ``420b`` (REALTIME_TRIGGER) is the enable that + starts the ``ca00`` stream. A plain ``4200`` subscribe streams on some + firmware, but the hardened units stay silent until ``420b`` is sent. + + All session bodies carry the live session timestamp (via ``_send_command`` / + ``_live_negotiation_packet``) since a stale one is rejected as a replay. + Overridden by models whose sequence differs (e.g. the 240W station). """ await asyncio.sleep(1) - registration = self._build_packet( - pattern=bytes.fromhex(NEGOTIATION_PATTERN), - cmd=bytes.fromhex(NEGOTIATION_COMMAND_6_CMD), - payload=self._encrypt_payload(bytes.fromhex(NEGOTIATION_COMMAND_6_PAYLOAD)), - ) + # 4027 registration (live timestamp, shares the session clock with the commands + # below). Hardened firmware (SolixBLE #22) only arms telemetry when the + # registration binds the account owner_user_id (a228); without it the device + # withholds telemetry (a224 is rejected with ack 09; the cloud-free + # a310 is acked 04 but still never streams). Falls back to the legacy + # a224 payload when no owner_user_id is configured. + if OWNER_USER_ID is not None: + extra = "a228" + OWNER_USER_ID.encode().hex() + else: + extra = NEGOTIATION_COMMAND_6_PAYLOAD[12:] + registration = self._live_negotiation_packet(NEGOTIATION_COMMAND_6_CMD, extra) await self._client.write_gatt_char(UUID_COMMAND, registration) + # Space each command (like the 240W station) so the device isn't overrun. + await asyncio.sleep(0.4) + # 4200 status request, 420a getter, then 420b realtime trigger -- the enable + # that actually starts the ca00 stream. await self._send_command( bytes.fromhex(NEGOTIATION_COMMAND_7_CMD), bytes.fromhex(NEGOTIATION_COMMAND_7_PAYLOAD), ) + await asyncio.sleep(0.4) + await self._send_command( + bytes.fromhex(NEGOTIATION_COMMAND_8_CMD), + bytes.fromhex(NEGOTIATION_COMMAND_8_PAYLOAD), + ) + await asyncio.sleep(0.4) + await self._send_command( + bytes.fromhex(NEGOTIATION_COMMAND_9_CMD), + bytes.fromhex(NEGOTIATION_COMMAND_9_PAYLOAD), + ) ##################### # Packet processing # @@ -430,14 +446,7 @@ async def _send_command(self, cmd: bytes, payload: bytes) -> None: if not self.negotiated: raise ConnectionError("Not connected to device") - # Commands include a timestamp in the payload to prevent replay attacks - # and that timestamp is set during negotiations - time_passed = int(time.time() - self._negotiation_timestamp) - base_timestamp = int.from_bytes( - bytes.fromhex(BASE_TIMESTAMP), byteorder="little" - ) - new_timestamp = (base_timestamp + time_passed).to_bytes( - length=4, byteorder="little" - ) - new_payload = payload + bytes.fromhex("fe04") + new_timestamp + # Commands carry a live timestamp in the payload to prevent replay attacks; + # newer firmware rejects a stale one, which blocks the stream. + new_payload = payload + bytes.fromhex("fe04") + bytes.fromhex(self._ts()) await self._send_encrypted_packet(cmd, new_payload) diff --git a/SolixBLE/states.py b/SolixBLE/states.py index a89c38d..692eadd 100644 --- a/SolixBLE/states.py +++ b/SolixBLE/states.py @@ -124,6 +124,7 @@ class DisplayTimeout(Enum): #: 1800 seconds (30m). S1800 = 1800 + class TemperatureUnit(Enum): """The status of the temperature unit of the device.""" @@ -136,6 +137,7 @@ class TemperatureUnit(Enum): #: Display unit is Fahrenheit. FAHRENHEIT = 1 + class GridStatus(Enum): """The grid connection status.""" diff --git a/docs/source/index.rst b/docs/source/index.rst index 0e8dd70..5803f91 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -190,4 +190,5 @@ Contents limitations new_devices app_decoding + owner_user_id source diff --git a/docs/source/owner_user_id.rst b/docs/source/owner_user_id.rst new file mode 100644 index 0000000..502503e --- /dev/null +++ b/docs/source/owner_user_id.rst @@ -0,0 +1,131 @@ +Finding your owner_user_id +========================== + +Some Anker Prime devices ship (or update to) a hardened firmware that will **not** +stream telemetry over BLE until the client proves it belongs to the account that owns +the device. On those units, the registration step must carry your Anker account's +``owner_user_id``; without it the device accepts the connection, acknowledges the +registration, and then stays silent until the link idle-drops. This section explains +what that value is and how to obtain it **without** the Anker app running, so it can be +passed to ``PrimeDevice`` (see `Using it`_). + + +Background +---------- + +The Prime negotiation completes with an ECDH handshake, after which the client sends a +registration command (``4027``) and a subscribe (``4200``). On older firmware the +registration is cosmetic and any value is accepted. On hardened firmware the device +inspects the registration and only arms telemetry when it binds the **owning account**: + +.. code-block:: + + 4027 = a104 a228 -> ack 00 (accepted); telemetry streams + 4027 = a104 a224 -> ack 09 (rejected); device stays silent + +The ``owner_user_id`` is a 40-character hex string identifying your Anker account. It is +**static** and the **same for every device you own**, so you only need to fetch it once. + +.. note:: + This is not a per-device secret and it is not read from the device -- it is your + account identity. There is no on-device or cloud-free way to derive it; it must come + from your Anker account (login), which is what the methods below do. + + +What you are looking for +------------------------ + +A value that looks like this (40 hex characters):: + + 0123456789abcdef0123456789abcdef01234567 + +The same string appears in your account's cloud MQTT topics +(``dt/anker_power//...``) and as ``user_id`` in the Anker login response, +which is why either can be used to recover it. + + +Method 1: anker-solix-api (recommended) +--------------------------------------- + +`anker-solix-api `_ is a Python client for +the Anker cloud. Logging in once returns your ``user_id``. This needs only your Anker +account credentials -- no phone, no rooted device. + +Requirements: + +- `anker-solix-api `_ (``pip install anker-solix-api``) +- Your Anker account email and password + +.. code-block:: python + + import asyncio + + from aiohttp import ClientSession + from api.api import AnkerSolixApi # anker-solix-api + + + async def main() -> None: + async with ClientSession() as session: + api = AnkerSolixApi("EMAIL", "PASSWORD", "US", session) + await api.async_authenticate() + print(api.apisession.get_login_info("user_id")) + + + asyncio.run(main()) + +.. note:: + The exact accessor has moved across anker-solix-api versions. If + ``get_login_info("user_id")`` is not available, use the version-independent fallback + below: any successful login caches the full response (including ``user_id``) to disk. + +anker-solix-api writes the login response to a JSON cache named after your email. After a +successful login you can read the value straight out of it: + +.. code-block:: bash + + # the cache is a JSON file named after your account email; locate it, then read user_id + find ~ . -name '*@*.json' 2>/dev/null + python -c "import json,sys; print(json.load(open(sys.argv[1]))['user_id'])" .json + + +Method 2: Anker app traffic +--------------------------- + +If you are already intercepting the Anker app (see :doc:`app_decoding`), the value is in +plain sight and no extra login is needed. + +- **Android (Frida):** the packet/log dump prints the outgoing registration payload + ``A104A228`` and the MQTT topics the app subscribes to + (``dt/anker_power//...``). The 40 hex characters after ``A228`` (decoded + from ASCII) are the value. +- **iOS backup:** the app's ``app_log.log`` (inside an unencrypted device backup) records + the login response and the same MQTT topics. Grep for the topic prefix: + + .. code-block:: bash + + grep -oE 'anker_power/[0-9a-f]{40}' app_log.log | head -1 + +.. note:: + In the raw app frames the id is sent as its **ASCII characters** (each hex digit is a + byte), i.e. tag ``a2`` length ``0x28`` (40) followed by the 40-byte string. When you + read it from a login response or MQTT topic it is already the plain 40-char string. + + +Using it +-------- + +Set the value on the module before connecting; ``PrimeDevice`` reads it when it builds the +registration. When it is ``None`` (the default) the legacy registration is used, which is +fine for older firmware but will not stream on a hardened unit. + +.. code-block:: python + + import SolixBLE.prime_device + + SolixBLE.prime_device.OWNER_USER_ID = "0123456789abcdef0123456789abcdef01234567" + # ... now connect() your PrimeDevice as usual + +.. note:: + ``OWNER_USER_ID`` is a module-level default shared by every ``PrimeDevice`` in the + process. That is intentional for the common single-account case; if you drive devices + from more than one Anker account in one process, set it per connection instead. diff --git a/tests/conftest.py b/tests/conftest.py index d5f3746..1d4dd65 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,3 +32,33 @@ async def scaled_sleep(delay): with mock.patch("asyncio.sleep", side_effect=scaled_sleep): yield + + +#: Unix time the frozen_time fixture pins. Its little-endian 4 bytes are ``42ad8c69``, +#: which the negotiation fixtures encode as the ``a1`` timestamp. +FROZEN_TIME = 1770827074 + + +#: POSIX timezone the frozen_time fixture pins for the stage-5 confer, so the encrypted +#: confer fixture is reproducible regardless of the host's actual timezone. +FROZEN_TZ = "EST5EDT,M3.2.0,M11.1.0" + + +@pytest.fixture +def frozen_time(): + """Pin ``time.time()`` and the local timezone so negotiation/confer frames are stable. + + Devices stamp each negotiation and session command with the live time, and the + stage-5 confer also carries the local POSIX timezone, so the on-wire bytes vary run + to run and host to host; pinning both makes the expected fixtures stable (and the + encrypted frames reproducible). Only ``time.time`` is frozen for the clock -- asyncio + scheduling uses the monotonic loop clock, so retry/timeout timing is unaffected. + """ + with ( + mock.patch("time.time", return_value=FROZEN_TIME), + mock.patch( + "SolixBLE.device.SolixBLEDevice._local_posix_tz", + return_value=FROZEN_TZ, + ), + ): + yield diff --git a/tests/const.py b/tests/const.py index 42ca316..e17061a 100644 --- a/tests/const.py +++ b/tests/const.py @@ -19,7 +19,10 @@ """ MOCK_BLE_DEVICE = BLEDevice( - address=MOCK_DEVICE_ADDRESS, name=MOCK_DEVICE_NAME, details={}, rssi=-1 + address=MOCK_DEVICE_ADDRESS, + name=MOCK_DEVICE_NAME, + details={}, + rssi=-1, ) """ Mock BLEDevice used to emulate Anker devices for tests. @@ -27,34 +30,37 @@ NEGOTIATION_RESPONSES_PRIME: dict[str, list[str]] = { - prime_device.NEGOTIATION_COMMAND_0: [ - "ff091e000300014801ab273ed3e27270c3f4d676ac7d69a00572793732a6" + # Stages 0-3 are built live (encrypted with the static key) under the frozen_time + # fixture; regenerate these literals if that fixture's clock changes. + "ff09200003000140010a827d7f6a531e146637b9a16158348e7baa5e5ebeac25": [ + "ff091e000300014801ab273ed3e27270c3f4d676ac7d69a00572793732a6", ], - prime_device.NEGOTIATION_COMMAND_1: [ - "ff092b000300014803ab273ed0443800b35db54c6d4a6ec3d48171a04ea7ebce8bf749e5e48c5d991a5e67" + "ff09270003000140030a827d7f6a538ab3de100ac9bb9a57744bd99325c8ca77e287ab3672438d": [ + "ff092b000300014803ab273ed0443800b35db54c6d4a6ec3d48171a04ea7ebce8bf749e5e48c5d991a5e67", ], - prime_device.NEGOTIATION_COMMAND_2: [ - "ff0958000300014829ab273ed144326ada9fc66fa02508c5ddf549ade014d1eeb252fea1057c15b00985ab8a724fa3830e8e5b27acbaa1224fd2172c0439d27aaf9e62a66bda5c41c424f23c5c8d7df8d3b89422ddff2266" + "ff09200003000140290a827d7f6a531e146637b9a16158348e7baa5e5ebeac0d": [ + "ff0958000300014829ab273ed144326ada9fc66fa02508c5ddf549ade014d1eeb252fea1057c15b00985ab8a724fa3830e8e5b27acbaa1224fd2172c0439d27aaf9e62a66bda5c41c424f23c5c8d7df8d3b89422ddff2266", ], - prime_device.NEGOTIATION_COMMAND_3: [ - "ff091b000300014805abab709a595a803dd04246b78a927453cf65" + "ff092d0003000140050a827d7f6a538ab3de100ae04aca6791257881e2e19a569ca195a8b15d5d0edb66230274": [ + "ff091b000300014805abab709a595a803dd04246b78a927453cf65", ], prime_device.NEGOTIATION_COMMAND_4: [ - "ff095d000300014821ab277fc01de436d341de628c79c1384d0aea25ce030622fa3ca0808ce5d1b7365ec1b1753a11ab78fba3ca07dda95cd57c93d1267b1222bef9908f7633a758ab924eba63ee01e715be5b9c3b082e6d81c2204241" + "ff095d000300014821ab277fc01de436d341de628c79c1384d0aea25ce030622fa3ca0808ce5d1b7365ec1b1753a11ab78fba3ca07dda95cd57c93d1267b1222bef9908f7633a758ab924eba63ee01e715be5b9c3b082e6d81c2204241", ], # These packets below are dynamically generated by the library unlike previous # negotiation packets as past stage 5 negotiations the packets use the shared # secret for encryption which is different from device to device due to different # public keys. This is why constants are not used. But we can use constants for # the earlier packets which use static keys. - "ff094000030001402257ec69586f3500c8f858e0ba047f237f4e2ed8c50d2f39ba3587e4010275bea22242936f08788849272fb3f4cf7493be4a60bb9c9f0693": [ - "ff091b000300014822f60b45600839b2c171b33dc5790ed64ae32d" + "ff093e00030001402257ecdb8c563500c8f858e0ba047121614e2bdfd20d2f39ba3580e4010109a3de3d43936a2ddbda49225d4b02a31e62f8ce022210c1": [ + "ff091b000300014822f60b45600839b2c171b33dc5790ed64ae32d", ], - "ff094600030001402757ec69586f3501e8cf6185d8c4035707377af9af3a2e40b02b86e7531974f1c22440de6e43705566b77cf940e235b65abf4d413ece5f2c3781712f3742": [ - "ff091b000300014827f60b45600839b2c171b33dc5790ed64ae328" + "ff094600030001402757ecdb8c563501e8cf6185d8c4035707377af9af3a2e40b02b86e7531974f1c22440de6e43705566b77cf94035eb28eaef98a9f67aa3810abc40de8409": [ + "ff091b000300014827f60b45600839b2c171b33dc5790ed64ae328", ], - "ff09230003000f420057e9b8dfdeacda7991d3eb7f12093e55ff002aa9799bcc9216e3": [], - "ff09530003000f420a57e9b883d958e48e5b7de48d980206577e2dafbb3d604dea3686f3011969f0db2311906d142b5730ee2bfb11e3fbbe7485aac8877995310669156ec74645c962b419e579b385fd079967": [], + "ff09230003000f420057e9b8dfde1e0e409121ab89a1119c200c35523e9f0118bcc7fa": [], + "ff092c0003000f420a57e9b883d958e48e5d5ae1bb5f63269f96779ebdbf43bd5ab4babfae73120d63897d15": [], + "ff092f0003000f420b57e9b883d058a2ccfb4de1bba16764cc1e5c371a30deae32e05babe273066746b24b06c4da27": [], } """ This maps the expected commands sent by the library to what my Anker Prime 160w @@ -62,19 +68,34 @@ """ +# MagGo 3-in-1 only subscribes post-connect (a single 4200); it sends none of the +# 4027 registration, 420a getter, or 420b realtime trigger, so drop those from the +# shared prime negotiation to match its shorter post-connect sequence. +NEGOTIATION_RESPONSES_MAGGO: dict[str, list[str]] = { + frame: responses + for frame, responses in NEGOTIATION_RESPONSES_PRIME.items() + if frame[14:18] not in ("4027", "420a", "420b") +} + + +# Stages 0-3 are built live (UUID-free, live ``a1`` timestamp) under the ``frozen_time`` +# fixture -- regenerate these literals if that fixture's clock changes. Stages 4 (static +# pubkey) and 5 (frozen confer) are unchanged, so they still key off the constants. NEGOTIATION_RESPONSES_SOLIX: dict[str, list[str]] = { - const.NEGOTIATION_COMMAND_0: ["ff090e00030001080100a1010152"], - const.NEGOTIATION_COMMAND_1: [ - "ff091b00030001080300a10102a202fd00a30144a40101a50102ff" + "ff0910000300010001a10442ad8c694a": ["ff090e00030001080100a1010152"], + "ff0917000300010003a10442ad8c69a30120a40200f09b": [ + "ff091b00030001080300a10102a202fd00a30144a40101a50102ff", ], - const.NEGOTIATION_COMMAND_2: [ - "ff093800030001082900a10103a2054553503332a307302e302e302e33a41041504339464530453237333030323735a506f49d8a104e0c9a" + "ff0910000300010029a10442ad8c6962": [ + "ff093800030001082900a10103a2054553503332a307302e302e302e33a41041504339464530453237333030323735a506f49d8a104e0c9a", ], - const.NEGOTIATION_COMMAND_3: ["ff090b00030001080500f2"], + "ff091a000300010005a10442ad8c69a30120a40200f0a5014074": ["ff090b00030001080500f2"], const.NEGOTIATION_COMMAND_4: [ - "ff094d00030001082100a140b2ade5cac4f4a0c1307e44a0e9c5363cb21e4c8485ee324c23be949fa5d5929a75e57da3207c948a0c366ca9ea1ab2cb8e57d2d046a6ebefe5d96adb5d4cb35039" + "ff094d00030001082100a140b2ade5cac4f4a0c1307e44a0e9c5363cb21e4c8485ee324c23be949fa5d5929a75e57da3207c948a0c366ca9ea1ab2cb8e57d2d046a6ebefe5d96adb5d4cb35039", ], - const.NEGOTIATION_COMMAND_5: [], + # Stage 5 is now the live CBC 4022 confer (a1 timestamp + local tz), reproducible + # here because frozen_time pins both the clock and the timezone. + "ff093a000300014022903b2e4bc916b8515b35b6c171fbe160f1be2ffe9874600a8582c2a937c9dfdadf867985507518af4229e35cb7fe30f5a6": [], } """ This maps the expected commands sent by the library to what my Anker Solix C300 diff --git a/tests/test_connection.py b/tests/test_connection.py index 9659679..ea60665 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -26,7 +26,11 @@ ], ) async def test_automatic_retry( - fast_sleep, fast_timeouts, device_class: type[SolixBLEDevice], negotiation: dict + fast_sleep, + fast_timeouts, + frozen_time, + device_class: type[SolixBLEDevice], + negotiation: dict, ): """ Test the automatic retrying of a lost connection when the @@ -41,7 +45,6 @@ async def test_automatic_retry( """ async with MockDevice() as mock_bluetooth: - device = device_class(MOCK_BLE_DEVICE) def my_callback(*args, **kwargs): @@ -100,6 +103,7 @@ def my_callback(*args, **kwargs): async def test_automatic_retry_timeout( fast_sleep, fast_timeouts, + frozen_time, device_class: type[SolixBLEDevice], negotiation: dict, ): @@ -119,7 +123,6 @@ async def test_automatic_retry_timeout( """ async with MockDevice() as mock_bluetooth: - device = device_class(MOCK_BLE_DEVICE) num_calls = 0 @@ -190,6 +193,7 @@ def my_callback(*args, **kwargs): async def test_disconnect( fast_timeouts, fast_sleep, + frozen_time, device_class: type[SolixBLEDevice], negotiation: dict, ): @@ -205,22 +209,21 @@ async def test_disconnect( """ async with MockDevice() as mock_bluetooth: - device = device_class(MOCK_BLE_DEVICE) async def assert_still_disconnected(): """Assert that device is still disconnected.""" - for i in range(0, 100): + for i in range(100): await asyncio.sleep(1) - assert ( - not device.connected - ), f"Expected connected to be False after {i} seconds" - assert ( - not device.negotiated - ), f"Expected negotiated to be False after {i} seconds" - assert ( - device._client is None - ), f"Expected client to be None after {i} seconds" + assert not device.connected, ( + f"Expected connected to be False after {i} seconds" + ) + assert not device.negotiated, ( + f"Expected negotiated to be False after {i} seconds" + ) + assert device._client is None, ( + f"Expected client to be None after {i} seconds" + ) def my_callback(*args, **kwargs): """We expect this to not be called.""" diff --git a/tests/test_devices.py b/tests/test_devices.py index 5528069..2a18bcf 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -33,6 +33,7 @@ from SolixBLE.states import GridStatus, LightMode, SBPowerCutoff, SBUsageMode from tests.const import ( MOCK_BLE_DEVICE, + NEGOTIATION_RESPONSES_MAGGO, NEGOTIATION_RESPONSES_PRIME, NEGOTIATION_RESPONSES_SOLIX, ) @@ -853,7 +854,9 @@ ], ) async def test_values( - device_class: type[SolixBLEDevice], payload: str, mapping: dict[str, Any] + device_class: type[SolixBLEDevice], + payload: str, + mapping: dict[str, Any], ) -> None: """ Test that a payload is parsed into the correct values. @@ -867,9 +870,9 @@ async def test_values( await device._process_telemetry(parameters) for class_property, expected_value in mapping.items(): - assert ( - getattr(device, class_property) == expected_value - ), f"Mismatch for property '{class_property}'!" + assert getattr(device, class_property) == expected_value, ( + f"Mismatch for property '{class_property}'!" + ) @pytest.mark.asyncio @@ -886,13 +889,15 @@ async def test_c1000g2_dc_control() -> None: await device.turn_dc_on() device._send_command.assert_awaited_once_with( - cmd=bytes.fromhex("4102"), payload=bytes.fromhex("a10121a2020101") + cmd=bytes.fromhex("4102"), + payload=bytes.fromhex("a10121a2020101"), ) device._send_command.reset_mock() await device.turn_dc_off() device._send_command.assert_awaited_once_with( - cmd=bytes.fromhex("4102"), payload=bytes.fromhex("a10121a2020100") + cmd=bytes.fromhex("4102"), + payload=bytes.fromhex("a10121a2020100"), ) @@ -998,7 +1003,6 @@ async def test_negotiation( :param secret: The expected shared secret. """ async with MockDevice() as mock_bluetooth: - device = device_class(MOCK_BLE_DEVICE) for packet in packets: @@ -1011,9 +1015,9 @@ async def test_negotiation( assert await device.connect(), "Expected connect to return True" # Assert that the correct shared secret is calculated - assert ( - bytes.fromhex(secret) == device._shared_secret - ), "Shared secret does not match expected" + assert bytes.fromhex(secret) == device._shared_secret, ( + "Shared secret does not match expected" + ) mock_bluetooth.check_assertions() @@ -1074,7 +1078,10 @@ async def test_negotiation( ], ) def test_payload_decryption( - device_class: type[SolixBLEDevice], payload: str, secret: str, decrypted: str + device_class: type[SolixBLEDevice], + payload: str, + secret: str, + decrypted: str, ): """ Test the decryption of a payload only. This does not test the @@ -1109,7 +1116,7 @@ def test_payload_decryption( pytest.param( C1000, [ - "ff092a0003010f440156ecb95eb746de03d40ee711ce99f42837a9554c6382d3f5298a3b0648d8536936" + "ff092a0003010f440156ecb95eb746de03d40ee711ce99f42837a9554c6382d3f5298a3b0648d8536936", ], "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", None, @@ -1119,7 +1126,7 @@ def test_payload_decryption( pytest.param( C1000, [ - "ff09390003010fc40222788d127d8418b41a81719975719a26b32734ea4e44ce244683e31928bb9a2736f9ede939567cddce6b3fb0de68116c" + "ff09390003010fc40222788d127d8418b41a81719975719a26b32734ea4e44ce244683e31928bb9a2736f9ede939567cddce6b3fb0de68116c", ], "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", None, @@ -1129,7 +1136,7 @@ def test_payload_decryption( pytest.param( C1000, [ - "ff09fd0003010fc402121e0e23790307a57d4adabcd8d5ad56c3a9ea3cb5b222b0152438ccd3b980eda40fbde184fa66c80c3372dad179f11cad8799858ab95696e52c7e729af87c1106343ed5be9c042c8912b14f3a0d94b32afbed432e66616e1895ba0ff5e74a6da9401117070c926631e5d7886a07bec0de35aeb689e8bb289f1d7854143dc413f25d4b57d290ca4378cfb8efc275aa779145f98956e934eaced2d1f51cef7dd21a340318bfc14fb5f90ffd33e0e484175512af33593b1f91eb9801d7c2e1ac6d56e8fe7e8883d62226484ed6f1af711d042c5e3d0c186b3f2222293bc71ccf4a156a544d5171e90ee9b6b9b8f36ae058b96e3b88" + "ff09fd0003010fc402121e0e23790307a57d4adabcd8d5ad56c3a9ea3cb5b222b0152438ccd3b980eda40fbde184fa66c80c3372dad179f11cad8799858ab95696e52c7e729af87c1106343ed5be9c042c8912b14f3a0d94b32afbed432e66616e1895ba0ff5e74a6da9401117070c926631e5d7886a07bec0de35aeb689e8bb289f1d7854143dc413f25d4b57d290ca4378cfb8efc275aa779145f98956e934eaced2d1f51cef7dd21a340318bfc14fb5f90ffd33e0e484175512af33593b1f91eb9801d7c2e1ac6d56e8fe7e8883d62226484ed6f1af711d042c5e3d0c186b3f2222293bc71ccf4a156a544d5171e90ee9b6b9b8f36ae058b96e3b88", ], "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", None, @@ -1218,7 +1225,7 @@ def test_payload_decryption( pytest.param( PrimeCharger160w, [ - "ff09da00030111430057e9a883d95e4bc95b5be2baa1c366331abb929258ab5077108dc197254092ef1372bd5a26ef6b51d61dc87082ca8e7985aacad07f64181902c70c0502de2418e366f5f700b13049d9b857e95c85c66a32d64fcf31c8eead9e025ed69c1440170cca149e038501a9544b1baa044a6a65392e154357e137d917fc834e019012a01b9bd18d5ca7dc22bdb0204b0629b3f738f34bafdc26f6bb0781cec80fe547674a6a7a341a018ce3ac81e6eb6b5110d3311db692d174fe363acec5ba606a24b92dcc95a6cdd8fee1843a26694ddd23ac74" + "ff09da00030111430057e9a883d95e4bc95b5be2baa1c366331abb929258ab5077108dc197254092ef1372bd5a26ef6b51d61dc87082ca8e7985aacad07f64181902c70c0502de2418e366f5f700b13049d9b857e95c85c66a32d64fcf31c8eead9e025ed69c1440170cca149e038501a9544b1baa044a6a65392e154357e137d917fc834e019012a01b9bd18d5ca7dc22bdb0204b0629b3f738f34bafdc26f6bb0781cec80fe547674a6a7a341a018ce3ac81e6eb6b5110d3311db692d174fe363acec5ba606a24b92dcc95a6cdd8fee1843a26694ddd23ac74", ], "09486817d949a232b58b47a43cc72d045a617a26f3999d30e1d27e38eae52265", """{'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'}""", @@ -1228,7 +1235,7 @@ def test_payload_decryption( pytest.param( PrimePowerBank20k, [ - "ff098300030111430044014f704abfd87d1d38fc0d7a35a36efdaf1f9f9f1c799493804dfaa6882d789fb7aeb4d117bd2330cd63c5f13f1e4a089ce80ac2442c66c85fa1f0dcb0d6867d9a58f7a3ee8479ec124724f6d7b84d8a58939c465ffb24e43754a1889be5f8c946d82d93806765835569e75bd67cbd3ac71071159c13a83bb9" + "ff098300030111430044014f704abfd87d1d38fc0d7a35a36efdaf1f9f9f1c799493804dfaa6882d789fb7aeb4d117bd2330cd63c5f13f1e4a089ce80ac2442c66c85fa1f0dcb0d6867d9a58f7a3ee8479ec124724f6d7b84d8a58939c465ffb24e43754a1889be5f8c946d82d93806765835569e75bd67cbd3ac71071159c13a83bb9", ], "5609bc39f79166da75139feb7c335fb7524b3bf0d730db96bf6ebf450d3e165b", """{'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'}""", @@ -1240,7 +1247,7 @@ def test_payload_decryption( pytest.param( PrimeCharger160w, [ - "ff09ca000301110300a10131a203024606a303020000a4020100a5080401d8459906bb0ba6080401e81300000000a7080400000000000000a8020103a9020150aa020100ab090400000000000b0b0bac0d0401002c0100002c0100000200ad0d0401002c0100002c0100000201ae0d0401002c0100002c0100000300af020100b0020100b1020100b2020101b30201ffb40d0400000000ac051573fafffbffb50d04ffffffffffffffffffffffffe0050448000000e10b0400000000000000000000fe0503000000006b" + "ff09ca000301110300a10131a203024606a303020000a4020100a5080401d8459906bb0ba6080401e81300000000a7080400000000000000a8020103a9020150aa020100ab090400000000000b0b0bac0d0401002c0100002c0100000200ad0d0401002c0100002c0100000201ae0d0401002c0100002c0100000300af020100b0020100b1020100b2020101b30201ffb40d0400000000ac051573fafffbffb50d04ffffffffffffffffffffffffe0050448000000e10b0400000000000000000000fe0503000000006b", ], "5609bc39f79166da75139feb7c335fb7524b3bf0d730db96bf6ebf450d3e165b", """{'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'}""", @@ -1253,7 +1260,7 @@ def test_payload_decryption( pytest.param( MagGo3in1, [ - "ff094e00030111430044014f7041bf9427bc0ef8117b960f68fd68b88f9b9279303d80490ea7888a709b12adb02ee81b269cc267c5f1c11b499e9c170a1514a45ceafe1bbd3925d3a766ac4aa32d" + "ff094e00030111430044014f7041bf9427bc0ef8117b960f68fd68b88f9b9279303d80490ea7888a709b12adb02ee81b269cc267c5f1c11b499e9c170a1514a45ceafe1bbd3925d3a766ac4aa32d", ], "5609bc39f79166da75139feb7c335fb7524b3bf0d730db96bf6ebf450d3e165b", """{'a1': '31', 'a2': '04013a0232001d01', 'a3': '0401c60214008e00', 'a4': '0400f40100000000', 'a5': '04ffff', 'a6': '0400000000', 'fe': '0300000000'}""", @@ -1263,7 +1270,7 @@ def test_payload_decryption( pytest.param( MagGo3in1, [ - "ff094e00030111430044014f7041bf9427bc0ef8117b960f68fd7eb8859bc479303d80490ea7888a709b12adb02ee81b269cc267c5f1c11b499e9c170a7a6261f98ce9db7373fe83ccb3d81475e2" + "ff094e00030111430044014f7041bf9427bc0ef8117b960f68fd7eb8859bc479303d80490ea7888a709b12adb02ee81b269cc267c5f1c11b499e9c170a7a6261f98ce9db7373fe83ccb3d81475e2", ], "5609bc39f79166da75139feb7c335fb7524b3bf0d730db96bf6ebf450d3e165b", """{'a1': '31', 'a2': '04013a0232001d01', 'a3': '0401d0021e00d800', 'a4': '0400f40100000000', 'a5': '04ffff', 'a6': '0400000000', 'fe': '0300000000'}""", @@ -1274,6 +1281,7 @@ def test_payload_decryption( async def test_telemetry_packet_processing( fast_sleep, fast_timeouts, + frozen_time, device_class: type[SolixBLEDevice], packets: list[str], secret: str, @@ -1292,13 +1300,14 @@ async def test_telemetry_packet_processing( device = device_class(MOCK_BLE_DEVICE) negotiation_responses = ( - NEGOTIATION_RESPONSES_PRIME + NEGOTIATION_RESPONSES_MAGGO + if issubclass(device_class, MagGo3in1) + else NEGOTIATION_RESPONSES_PRIME if issubclass(device_class, PrimeDevice) else NEGOTIATION_RESPONSES_SOLIX ) async with MockDevice() as mock_bluetooth: - # We first expect a negotiation for expected, response in negotiation_responses.items(): mock_bluetooth.expect_ordered( @@ -1334,7 +1343,7 @@ async def test_telemetry_packet_processing( pytest.param( PrimeCharger160w, [ - "ff09ca000301110300a10131a203024606a303020000a4020100a5080401e042b105b209a6080401e81300000000a7080400000000000000a8020103a9020150aa020100ab090400000000000b0b0bac0d0401002c0100002c0100000200ad0d0401002c0100002c0100000201ae0d0401002c0100002c0100000300af020100b0020100b1020100b2020101b30201ffb40d0400000000ac051573fafffbffb50d04ffffffffffffffffffffffffe0050448000000e10b0400000000000000000000fe05030000000074" + "ff09ca000301110300a10131a203024606a303020000a4020100a5080401e042b105b209a6080401e81300000000a7080400000000000000a8020103a9020150aa020100ab090400000000000b0b0bac0d0401002c0100002c0100000200ad0d0401002c0100002c0100000201ae0d0401002c0100002c0100000300af020100b0020100b1020100b2020101b30201ffb40d0400000000ac051573fafffbffb50d04ffffffffffffffffffffffffe0050448000000e10b0400000000000000000000fe05030000000074", ], "5609bc39f79166da75139feb7c335fb7524b3bf0d730db96bf6ebf450d3e165b", [ @@ -1349,6 +1358,7 @@ async def test_generic_packet_processing( caplog, fast_sleep, fast_timeouts, + frozen_time, device_class: type[SolixBLEDevice], packets: list[str], secret: str, @@ -1367,14 +1377,15 @@ async def test_generic_packet_processing( device = device_class(MOCK_BLE_DEVICE) negotiation_responses = ( - NEGOTIATION_RESPONSES_PRIME + NEGOTIATION_RESPONSES_MAGGO + if issubclass(device_class, MagGo3in1) + else NEGOTIATION_RESPONSES_PRIME if issubclass(device_class, PrimeDevice) else NEGOTIATION_RESPONSES_SOLIX ) async with MockDevice() as mock_bluetooth: with caplog.at_level(logging.DEBUG): - # We first expect a negotiation for expected, response in negotiation_responses.items(): mock_bluetooth.expect_ordered( @@ -1395,9 +1406,9 @@ async def test_generic_packet_processing( await mock_bluetooth.send_data([bytes.fromhex(packet)]) for expected_log_entry in expected_logs: - assert ( - expected_log_entry in caplog.text - ), f"Expected to find '{expected_log_entry}' in logs but it was not found!" + assert expected_log_entry in caplog.text, ( + f"Expected to find '{expected_log_entry}' in logs but it was not found!" + ) @pytest.mark.asyncio @@ -1488,6 +1499,6 @@ async def test_bad_values( await device._process_telemetry(parameters) for class_property, expected_value in mapping.items(): - assert ( - getattr(device, class_property) == expected_value - ), f"Mismatch for property '{class_property}'!" + assert getattr(device, class_property) == expected_value, ( + f"Mismatch for property '{class_property}'!" + ) diff --git a/tests/test_parsing.py b/tests/test_parsing.py new file mode 100644 index 0000000..5be3ca7 --- /dev/null +++ b/tests/test_parsing.py @@ -0,0 +1,48 @@ +"""Tests for the nested-payload walkers (protobuf + length-value).""" + +from SolixBLE.parsing import read_varint, walk_lv, walk_protobuf + + +def test_read_varint_multibyte(): + # 0x96 0x01 -> 150 (the canonical protobuf varint example), next pos after both bytes + assert read_varint(bytes.fromhex("9601"), 0) == (150, 2) + assert read_varint(bytes.fromhex("00"), 0) == (0, 1) + + +def test_walk_protobuf_scalar_and_string(): + # field 1 = varint 150 (08 96 01); field 2 = "ABC" (12 03 414243) + out = walk_protobuf(bytes.fromhex("0896011203414243")) + assert out == {".1": 150, ".2": "ABC"} + + +def test_walk_protobuf_recurses_submessages(): + # field 1 = submessage{ field 1 = varint 150 } -> 0a 03 08 96 01 + assert walk_protobuf(bytes.fromhex("0a03089601")) == {".1.1": 150} + + +def test_walk_protobuf_repeated_fields_keep_order(): + # field 1 = 1, field 1 = 2 -> 08 01 08 02 + assert walk_protobuf(bytes.fromhex("08010802")) == {".1": 1, ".1#1": 2} + + +def test_walk_lv_ce_empty_combination_block(): + # Real C2000 G2 `ce` value with no BP2000: type 04, then len16 + 16-byte zero ID, + # then a 1-byte status (0x11), then zero padding. Pass without the 04 type byte. + ce_value = bytes.fromhex( + "0410000000000000000000000000000000000111" + "000000000000000000000000000000000000000000000000", + ) + fields = walk_lv(ce_value[1:]) + # first field is the 16-byte combination-device ID -- all zero == no unit combined + assert fields[0] == b"\x00" * 16 + assert not any(fields[0]) + # the status byte that follows the empty ID + assert fields[1] == b"\x11" + + +def test_walk_lv_reads_populated_first_field(): + # A non-zero 16-byte ID -> first field carries it verbatim + ident = bytes(range(1, 17)) + fields = walk_lv(bytes([0x10]) + ident + bytes.fromhex("0111")) + assert fields[0] == ident + assert any(fields[0]) diff --git a/tests/test_prime.py b/tests/test_prime.py index 3d85c0c..66e037f 100644 --- a/tests/test_prime.py +++ b/tests/test_prime.py @@ -33,7 +33,7 @@ id="stage_7a_response", ), pytest.param( - "ff09530003000f420a57e9b883d958e48e5b7de48d980206577e2dafbb3d604dea3686f3011969f0db2311906d142b5730ee2bfb11e3fbbe7485aac8877995310669156ec74645c962b419e579b385fd079967", + "ff092c0003000f420a57e9b883d958e48e5d5ae1bb5f63269f96779ebdbf43bd5ab4babfae73120d63897d15", prime_device.NEGOTIATION_COMMAND_8_PAYLOAD, "09486817d949a232b58b47a43cc72d045a617a26f3999d30e1d27e38eae52265", id="stage_7b_response", @@ -47,7 +47,9 @@ ], ) def test_negotiation_encryption_session( - packet: str, decrypted_payload: str, shared_secret: str + packet: str, + decrypted_payload: str, + shared_secret: str, ): """ Test that the encrypted packets produced by the library @@ -68,7 +70,14 @@ def test_negotiation_encryption_session( prime._shared_secret = bytes.fromhex(shared_secret) decrypted = prime._decrypt_payload(payload) - assert decrypted.hex() == decrypted_payload + # Session commands append a fe04<4-byte timestamp> replay guard; accept and ignore + # it so the assertion is timestamp-agnostic (negotiation responses have no suffix). + decrypted_hex = decrypted.hex() + tail = decrypted_hex[len(decrypted_payload) :] + assert not tail or (len(tail) == 12 and tail.startswith("fe04")), ( + f"unexpected trailing bytes: {tail}" + ) + assert decrypted_hex[: len(decrypted_payload)] == decrypted_payload re_encrypted = prime._encrypt_payload(decrypted) assert payload.hex() == re_encrypted.hex() From f102712746131a4c009025c0dbf394fd373ded61 Mon Sep 17 00:00:00 2001 From: Shawn Stricker Date: Fri, 17 Jul 2026 22:21:13 -0400 Subject: [PATCH 7/8] fix(ble): decode c490 device-summary correctly; broad test coverage Adds the missing coverage for the C2000 G2 c490 protobuf summary and the Prime charger telemetry, and fixes the bugs that coverage surfaced. c490 summary path (C1000 G2 / C2000 G2): - import walk_protobuf in device.py -- the summary path raised NameError on the first real frame; nothing exercised it before. - walk a2's protobuf value, not the whole frame: the summary was filling with garbage (.20/.6) so every .23.x property silently returned its default. _protobuf_body strips the a1/a2 wrapper (a2 is a bin field with a 2-byte length the flat TLV parser can't read) and bounds the slice to a2's declared length, so the walk sees exactly the protobuf and never the a3 trailer. - walk_protobuf now records message containers and the wire-3/4 group marker instead of dropping them, matching the definitive 116-field reference -- an under-count is a wrong decode. Prime chargers: - promote the 4303/ca00 shared telemetry decode from the A91B2 station up to PrimeUsbCharger, so the A2345 charger inherits it and decodes its 4303 stream (ports remapped a2-a7 -> a4-a9) instead of logging it as unknown. Tests (83 -> 123): - test_summary.py: the c490 decrypt -> unwrap -> walk -> property path on real captured A1763 frames, plus _protobuf_body wrapper/bounding and defaults. - test_parsing.py: read_varint boundaries, walk_protobuf across all wire types (container recording, group stop, string/hex leaves), walk_lv. - test_reassembly.py: single-vs-fragment classification, the full-length threshold (derived, not hardcoded), 3-fragment runs, interleaved per-command buffers. - test_devices.py: a real A1783 c421 frame's fields, expansion_present, and the Prime 250w/station ca00/4a00 snapshot + 4303 stream remap. Co-Authored-By: Claude Opus 4.8 (1M context) --- SolixBLE/device.py | 40 +++- .../devices/prime_charging_station_240w.py | 66 ------ SolixBLE/devices/prime_usb_charger.py | 65 +++++- SolixBLE/parsing.py | 16 +- tests/test_devices.py | 215 ++++++++++++++++++ tests/test_parsing.py | 120 +++++++--- tests/test_reassembly.py | 83 +++++++ tests/test_summary.py | 134 +++++++++++ 8 files changed, 630 insertions(+), 109 deletions(-) create mode 100644 tests/test_summary.py diff --git a/SolixBLE/device.py b/SolixBLE/device.py index 318dcfb..1019199 100644 --- a/SolixBLE/device.py +++ b/SolixBLE/device.py @@ -39,6 +39,7 @@ UUID_COMMAND, UUID_TELEMETRY, ) +from .parsing import walk_protobuf _LOGGER = logging.getLogger(__name__) @@ -667,6 +668,36 @@ def _join_fragments(self, cmd_key: bytes) -> bytes | None: _LOGGER.debug(f"Reassembled payload: {len(payload)} bytes") return payload + @staticmethod + def _protobuf_body(payload: bytes) -> bytes: + """Return the protobuf blob carried in a device-post's outer ``a2`` field. + + A protobuf device post (e.g. the C2000 G2's ``c490``) is a multi-field outer + TLV: ``a1`` -- a one-byte command echo -- then ``a2``, whose value *is* the + protobuf blob, then a trailing ``a3`` string. Unlike the flat telemetry + frames, ``a2`` is a ``bin`` field with a **2-byte** little-endian length and + an ``04`` type byte (the payload is ~340 bytes, past the 1-byte length + :meth:`_parse_payload` handles), so the header is ``a1 a2 + 04`` -- 7 bytes for the usual ``a1 01 31 a2 04``. Walking the + whole frame instead would misread those header bytes as protobuf tags and + yield almost nothing; running past ``a2`` into the ``a3`` trailer would append + a spurious field. The slice is bounded to ``a2``'s declared length so the walk + sees exactly the protobuf and nothing else. + + :param payload: The decrypted device-post frame. + :returns: The protobuf blob (``a2``'s value), or the whole payload if it is + too short to carry the wrapper. + """ + if len(payload) <= 6: + return payload + # Skip the a1 TLV (tag + 1-byte length + value) to reach the a2 field. + a2_start = 2 + payload[1] + # a2's 2-byte length counts its 04 type byte + the protobuf value, so the + # blob is that length minus the type byte, starting after tag+len+type. + a2_length = int.from_bytes(payload[a2_start + 1 : a2_start + 3], "little") + blob_start = a2_start + 4 + return payload[blob_start : blob_start + a2_length - 1] + async def _process_telemetry_packet( self, payload: bytes, @@ -692,11 +723,12 @@ async def _process_telemetry_packet( return None _LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}") # Protobuf telemetry (e.g. the C2000 G2's c490 device summary) is not the - # 1-byte-tag TLV format _parse_payload understands. Walk it into a `.path` - # field map (see :mod:`SolixBLE.parsing`), exposed via :attr:`summary`, rather - # than TLV-parsing it -- which would misread the varint tags. + # 1-byte-tag TLV format _parse_payload understands. Walk the protobuf blob + # into a `.path` field map (see :mod:`SolixBLE.parsing`), exposed via + # :attr:`summary`, rather than TLV-parsing it -- which would misread the + # varint tags. if cmd is not None and cmd.hex() in self._PROTOBUF_TELEMETRY_COMMANDS: - self._summary = walk_protobuf(decrypted_payload) + self._summary = walk_protobuf(self._protobuf_body(decrypted_payload)) _LOGGER.debug(f"Protobuf summary ({len(self._summary)} fields)") return None parameters = self._parse_payload(decrypted_payload) diff --git a/SolixBLE/devices/prime_charging_station_240w.py b/SolixBLE/devices/prime_charging_station_240w.py index a397520..74f3163 100644 --- a/SolixBLE/devices/prime_charging_station_240w.py +++ b/SolixBLE/devices/prime_charging_station_240w.py @@ -59,10 +59,6 @@ class PrimeChargingStation240w(PrimeUsbCharger): ``a4``). Verified on hardware -- the station streams with no owner user_id. """ - #: The station streams the full snapshot on ``4a00`` and the ~1/s port stream on - #: ``4303``; ``4302`` is the switch-change ack (only after a ``4207``, never sent). - _TELEMETRY_COMMANDS: tuple[str, ...] = ("4a00", "4303", "4302") - #: Local timezone string the app sends in the ``4022`` confer. _TIMEZONE = "EST5EDT,M3.2.0,M11.1.0" @@ -71,30 +67,6 @@ class PrimeChargingStation240w(PrimeUsbCharger): _encrypt_payload = SolixBLEDevice._encrypt_payload _decrypt_payload = SolixBLEDevice._decrypt_payload - #: Where each port lives in the ``4a00`` full snapshot (msgtype ``0a00``); the - #: inherited ``usb_c*``/``usb_a*`` properties read these tags from :attr:`_data`. - _SNAPSHOT_PORT_TAGS = { - "usb_c1": "a4", - "usb_c2": "a5", - "usb_c3": "a6", - "usb_c4": "a7", - "usb_a1": "a8", - "usb_a2": "a9", - } - #: Where the same ports live in the ``4303`` stream (msgtype ``0303``) -- one tag - #: earlier; remapped onto the snapshot tags so both frames feed one property set. - _STREAM_PORT_TAGS = { - "usb_c1": "a2", - "usb_c2": "a3", - "usb_c3": "a4", - "usb_c4": "a5", - "usb_a1": "a6", - "usb_a2": "a7", - } - - #: Command of the telemetry frame currently being processed (routing hint). - _routing_cmd: str | None = None - # ------------------------------------------------------------------ helpers @staticmethod @@ -234,44 +206,6 @@ async def _post_connect(self) -> None: bytes.fromhex("a10121fe0503" + self._ts()), ) - # --------------------------------------------------------------- telemetry - - async def _process_telemetry_packet( - self, - payload: bytes, - cmd: bytes = None, - ) -> None: - """Tag the frame so :meth:`_process_telemetry` can tell ``4a00`` from ``4303``. - - The base reassembles and length-gates every session frame (SolixBLE #42) -- - including the station's frag-byte-less single ``4a00``/``4303`` notifications - -- so this only records which telemetry command produced the payload. The two - frames carry the six ports at different tags, and :meth:`_process_telemetry` - uses this hint to remap them onto one property set. - """ - self._routing_cmd = bytes(cmd).hex() if cmd else None - return await super()._process_telemetry_packet(payload, cmd) - - async def _process_telemetry(self, parameters: dict[str, bytes]) -> None: - """Normalise both telemetry frames onto the one (snapshot) port view. - - The two frames tag the same six ports differently (``4a00`` at ``a4``-``a9``, - ``4303`` at ``a2``-``a7``). Rather than expose that as two property families, - the ``4303`` stream is remapped onto the snapshot tags and merged into - :attr:`_data`, so the inherited ``usb_c*``/``usb_a*`` properties always read - the freshest of either frame. The merge preserves the ``4a00``-only fields - (``aa``/``ab`` AC switches) between snapshots. ``4302`` (switch ack) is ignored. - """ - if self._routing_cmd == "4303": - merged = dict(self._data or {}) - for port, snapshot_tag in self._SNAPSHOT_PORT_TAGS.items(): - stream_tag = self._STREAM_PORT_TAGS[port] - if stream_tag in parameters: - merged[snapshot_tag] = parameters[stream_tag] - await super()._process_telemetry(merged) - elif self._routing_cmd == "4a00": - await super()._process_telemetry(parameters) - # ------------------------------------------------- 4a00 snapshot additions @property diff --git a/SolixBLE/devices/prime_usb_charger.py b/SolixBLE/devices/prime_usb_charger.py index d0f3a2d..6cb4e71 100644 --- a/SolixBLE/devices/prime_usb_charger.py +++ b/SolixBLE/devices/prime_usb_charger.py @@ -18,8 +18,69 @@ class PrimeUsbCharger(PrimeDevice): AC-outlet switches on the 240W station). """ - #: Prime chargers stream telemetry on command ``ca00`` (msgtype ``0a00``). - _TELEMETRY_COMMANDS: tuple[str, ...] = ("ca00",) + #: Prime chargers report per-port telemetry on two frame families: a ``0a00`` + #: snapshot (``ca00`` on the A2345 charger, ``4a00`` on the A91B2 station) with the + #: ports at ``a4``-``a9``, and a ~1/s ``0303`` stream (``4303``) with the same ports + #: one tag earlier (``a2``-``a7``). ``4302`` is the switch-change ack. + _TELEMETRY_COMMANDS: tuple[str, ...] = ("ca00", "4a00", "4303", "4302") + + #: Command of the telemetry frame currently being processed -- a routing hint set + #: by :meth:`_process_telemetry_packet`, read by :meth:`_process_telemetry`. + _routing_cmd: str | None = None + + #: Where each port lives in the ``0a00`` snapshot (``ca00``/``4a00``) -- the layout + #: the inherited ``usb_c*``/``usb_a*`` properties read from :attr:`_data`. + _SNAPSHOT_PORT_TAGS = { + "usb_c1": "a4", + "usb_c2": "a5", + "usb_c3": "a6", + "usb_c4": "a7", + "usb_a1": "a8", + "usb_a2": "a9", + } + #: Where the same ports live in the ``0303`` stream (``4303``) -- one tag earlier; + #: remapped onto the snapshot tags so both frames feed one property set. + _STREAM_PORT_TAGS = { + "usb_c1": "a2", + "usb_c2": "a3", + "usb_c3": "a4", + "usb_c4": "a5", + "usb_a1": "a6", + "usb_a2": "a7", + } + + async def _process_telemetry_packet( + self, + payload: bytes, + cmd: bytes = None, + ) -> None: + """Record which telemetry command produced the payload, then decode it. + + The ``0303`` stream and the ``0a00`` snapshot carry the six ports at + different tags; :meth:`_process_telemetry` uses this hint to remap them onto + the one snapshot layout the port properties read. + """ + self._routing_cmd = bytes(cmd).hex() if cmd else None + return await super()._process_telemetry_packet(payload, cmd) + + async def _process_telemetry(self, parameters: dict[str, bytes]) -> None: + """Normalise the stream and snapshot frames onto one port view. + + The ``0303`` stream (``a2``-``a7``) is remapped onto the snapshot tags + (``a4``-``a9``) and merged into :attr:`_data`, so the ``usb_c*``/``usb_a*`` + properties always read the freshest of either frame while snapshot-only fields + (the port switches) persist between streamed updates. ``4302`` (the bare + switch-change ack) carries no port data and is ignored. + """ + if self._routing_cmd == "4303": + merged = dict(self._data or {}) + for port, snapshot_tag in self._SNAPSHOT_PORT_TAGS.items(): + stream_tag = self._STREAM_PORT_TAGS[port] + if stream_tag in parameters: + merged[snapshot_tag] = parameters[stream_tag] + await super()._process_telemetry(merged) + elif self._routing_cmd in ("ca00", "4a00"): + await super()._process_telemetry(parameters) @property def serial_number(self) -> str: diff --git a/SolixBLE/parsing.py b/SolixBLE/parsing.py index 9893938..0c4c297 100644 --- a/SolixBLE/parsing.py +++ b/SolixBLE/parsing.py @@ -61,20 +61,26 @@ def _is_protobuf_message(sub: bytes) -> bool: def walk_protobuf( - buf: bytes, prefix: str = "", out: dict[str, object] | None = None + buf: bytes, + prefix: str = "", + out: dict[str, object] | None = None, ) -> dict[str, object]: """Flatten a protobuf(-like) blob to a ``.field.subfield`` -> value map. Repeated tags keep wire order (occurrence index appended as ``#n``) and every field is addressed by its ``.path``, so byte offsets never matter -- a leaf value crossing a varint byte boundary grows in place without shifting anything after it. - Length-delimited fields that themselves parse cleanly as a sub-message are recursed - into; otherwise they are kept as an ASCII string (if fully printable) or hex. + Length-delimited fields that themselves parse cleanly as a sub-message are recorded + as their byte length **and** recursed into (so the container and its leaves both + appear); otherwise they are kept as an ASCII string (if fully printable) or hex. A + wire-type 3/4 group marker is recorded as ``None`` and stops the walk. Every field + is recorded -- silently dropping containers under-counts the message, which is a + wrong decode. :param buf: The (decrypted, reassembled) protobuf payload. :param prefix: Path prefix used during recursion. :param out: Accumulator dict used during recursion. - :returns: Mapping of ``.path`` to value (int, str or hex str). + :returns: Mapping of ``.path`` to value (int, str, hex str or None). """ if out is None: out = {} @@ -96,6 +102,7 @@ def walk_protobuf( sub = buf[pos : pos + length] pos += length if length and _is_protobuf_message(sub) and any(sub): + out[path] = length # container: record its length, then its fields walk_protobuf(sub, path, out) elif sub and all(32 <= b < 127 for b in sub): out[path] = sub.decode("ascii") @@ -108,6 +115,7 @@ def walk_protobuf( out[path] = int.from_bytes(buf[pos : pos + 8], "little") pos += 8 else: + out[path] = None # group marker (wire type 3/4): record and stop break return out diff --git a/tests/test_devices.py b/tests/test_devices.py index 2a18bcf..fc9fd17 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -17,12 +17,15 @@ C800, C1000, C1000G2, + C2000G2, ChargingStatus, LightStatus, MagGo3in1, PortOverload, PortStatus, PrimeCharger160w, + PrimeCharger250w, + PrimeChargingStation240w, PrimeDevice, PrimePowerBank20k, Solarbank2, @@ -1502,3 +1505,215 @@ async def test_bad_values( assert getattr(device, class_property) == expected_value, ( f"Mismatch for property '{class_property}'!" ) + + +#: A real, full C2000 G2 (A1783) ``c421`` telemetry frame (decrypted cleartext), +#: captured from hardware with no expansion battery combined (the ``ce`` tag's +#: combination-device ID is all-zero). +A1783_C421_FRAME = "a10131a221062011415043444b4b453046333936303030313100054131373833010102010001a30e040000000008070064cc00580200a41b0400000000f4013c000000000000a0052c01020001000001640500a506042100640000a60a04000000000000d80e64a70704000000010000a80404000000aa0404010000ab0404000000ac0404000000ae0404010000b20404000000c0230410000000000000000000000000000000000100000000ef0000000100000000022020ce2c0410000000000000000000000000000000000111000000000000000000000000000000000000000000000000d91a0400001464050000000000000000000000000000000000000000da18040100000000000000000000e00138047f0101003804e001dc06040000000000f91d0401010201060201000000000006020100020209010000000006000300fa150401010101001f0700000000000000000000000000fd0e0031373833303232303033333233fe050324d2466a" # noqa: E501 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "device_class,payload,mapping", + [ + pytest.param( + C2000G2, + A1783_C421_FRAME, + { + # inherited C1000 G2 flat-TLV fields, confirmed on the A1783 + "serial_number": "APCDKKE0F39600011", + "part_number": "A1783", + "temperature": 33, + "battery_percentage": 100, + "battery_health": 0, + "max_battery_percentage": 100, + "min_battery_percentage": 5, + # the a3/a6 charge fields added on C1000 G2 + "max_input_power": 1800, + "dc_input_power": 0, + "remaining_time_hours": 380.0, + "main_battery_soc": 100, + # the C2000-only expansion decode (no BP2000 combined -> ce all-zero) + "expansion_present": False, + }, + id="c2000g2_a1783_c421", + ), + ], +) +async def test_c2000g2_c421_values( + device_class: type[SolixBLEDevice], + payload: str, + mapping: dict[str, Any], +) -> None: + """A real A1783 c421 frame parses into the inherited and C2000-specific fields.""" + device = device_class(MOCK_BLE_DEVICE) + parameters = device._parse_payload(bytes.fromhex(payload)) + await device._process_telemetry(parameters) + + for class_property, expected_value in mapping.items(): + assert getattr(device, class_property) == expected_value, ( + f"Mismatch for property '{class_property}'!" + ) + + +@pytest.mark.parametrize( + "data,expected", + [ + # data present but no ce tag at all -> no expansion + ({"a5": b"\x04\x00"}, False), + # ce present, combination-device ID all-zero -> no unit combined + ({"ce": bytes.fromhex("0410" + "00" * 16 + "0111" + "00" * 12)}, False), + # ce present, non-zero combination-device ID -> a unit is combined + ({"ce": bytes.fromhex("0410" + bytes(range(1, 17)).hex() + "0111")}, True), + ], +) +def test_c2000g2_expansion_present(data: dict, expected: bool) -> None: + """expansion_present reflects the ce combination-device ID (all-zero == none).""" + device = C2000G2(MOCK_BLE_DEVICE) + device._data = data + assert device.expansion_present is expected + + +def _port(status: int, mv: int, ma: int, cw: int) -> str: + """Build a Prime per-port block: ``04 `` (u16 LE each).""" + return ( + bytes([0x04, status]) + + mv.to_bytes(2, "little") + + ma.to_bytes(2, "little") + + cw.to_bytes(2, "little") + ).hex() + + +_ZERO_PORT = _port(0, 0, 0, 0) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "device_class,routing_cmd,params,mapping", + [ + # A2345 charger: the ca00 snapshot carries the ports at a4-a9 and the per-port + # on/off switches at aa-ae; it passes straight through to the properties. + pytest.param( + PrimeCharger250w, + "ca00", + { + "a4": _port(1, 20000, 3000, 6000), + "a5": _port(2, 9000, 1000, 900), + "a6": _ZERO_PORT, + "a7": _ZERO_PORT, + "a8": _port(1, 5000, 500, 250), + "a9": _ZERO_PORT, + "aa": "0401", + "ab": "0400", + "ac": "0400", + "ad": "0400", + "ae": "0401", + }, + { + "usb_c1_voltage": 20.0, + "usb_c1_current": 3.0, + "usb_c1_power": 60.0, + "usb_port_c1": PortStatus.OUTPUT, + "usb_port_c2": PortStatus.INPUT, + "usb_c2_power": 9.0, + "usb_a1_voltage": 5.0, + "usb_a1_power": 2.5, + "total_power_out": 71.5, + "usb_c1_switch": True, + "usb_c2_switch": False, + "usba_switch": True, + }, + id="prime_250w_ca00_snapshot", + ), + # A2345 charger: the ~1/s 4303 stream carries the same ports one tag earlier + # (a2-a7); they are remapped onto the a4-a9 property view. + pytest.param( + PrimeCharger250w, + "4303", + { + "a2": _port(1, 19824, 3000, 5900), + "a3": _ZERO_PORT, + "a4": _ZERO_PORT, + "a5": _ZERO_PORT, + "a6": _port(1, 5100, 200, 100), + "a7": _ZERO_PORT, + }, + { + "usb_c1_voltage": 19.824, + "usb_c1_current": 3.0, + "usb_c1_power": 59.0, + "usb_a1_voltage": 5.1, + "usb_a1_power": 1.0, + }, + id="prime_250w_4303_stream_remap", + ), + # A91B2 station: the 4a00 snapshot carries the ports at a4-a9 and the two AC + # outlet switches at aa/ab. + pytest.param( + PrimeChargingStation240w, + "4a00", + { + "a4": _port(1, 20000, 2000, 4000), + "a5": _ZERO_PORT, + "a6": _ZERO_PORT, + "a7": _ZERO_PORT, + "a8": _port(1, 5000, 500, 300), + "a9": _ZERO_PORT, + "aa": "0401", + "ab": "0400", + }, + { + "usb_c1_voltage": 20.0, + "usb_c1_power": 40.0, + "usb_a1_power": 3.0, + "usb_total_power_out": 43.0, + "ac_1_switch": True, + "ac_2_switch": False, + }, + id="prime_station_4a00_snapshot", + ), + # A91B2 station: the 4303 stream remaps the same way as the charger's. + pytest.param( + PrimeChargingStation240w, + "4303", + { + "a2": _port(1, 12000, 1000, 1200), + "a3": _ZERO_PORT, + "a4": _ZERO_PORT, + "a5": _ZERO_PORT, + "a6": _ZERO_PORT, + "a7": _ZERO_PORT, + }, + { + "usb_c1_voltage": 12.0, + "usb_c1_power": 12.0, + "usb_total_power_out": 12.0, + }, + id="prime_station_4303_stream_remap", + ), + ], +) +async def test_prime_usb_charger_telemetry( + device_class: type[SolixBLEDevice], + routing_cmd: str, + params: dict[str, str], + mapping: dict[str, Any], +) -> None: + """Prime chargers decode the 0a00 snapshot straight through and remap the 0303 stream. + + Both the A2345 charger and the A91B2 station inherit the shared decode from + :class:`~SolixBLE.devices.prime_usb_charger.PrimeUsbCharger`: the ``4303`` stream's + a2-a7 ports are remapped onto the a4-a9 snapshot tags, while ``ca00``/``4a00`` + snapshots pass straight through -- so one property set serves either frame. + """ + device = device_class(MOCK_BLE_DEVICE) + device._routing_cmd = routing_cmd + await device._process_telemetry( + {tag: bytes.fromhex(value) for tag, value in params.items()}, + ) + + for class_property, expected_value in mapping.items(): + assert getattr(device, class_property) == expected_value, ( + f"Mismatch for property '{class_property}'!" + ) diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 5be3ca7..d725748 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -1,48 +1,102 @@ """Tests for the nested-payload walkers (protobuf + length-value).""" +import pytest + from SolixBLE.parsing import read_varint, walk_lv, walk_protobuf -def test_read_varint_multibyte(): - # 0x96 0x01 -> 150 (the canonical protobuf varint example), next pos after both bytes - assert read_varint(bytes.fromhex("9601"), 0) == (150, 2) - assert read_varint(bytes.fromhex("00"), 0) == (0, 1) +@pytest.mark.parametrize( + "hexstr,pos,expected", + [ + # 0x96 0x01 -> 150 (the canonical protobuf varint example) + ("9601", 0, (150, 2)), + ("00", 0, (0, 1)), + ("7f", 0, (127, 1)), # largest single-byte varint + ("8001", 0, (128, 2)), # smallest two-byte varint + ("ff01", 0, (255, 2)), + ("ffff03", 0, (65535, 3)), + # read starting partway through a buffer + ("aa9601", 1, (150, 3)), + ], +) +def test_read_varint(hexstr: str, pos: int, expected: tuple[int, int]) -> None: + assert read_varint(bytes.fromhex(hexstr), pos) == expected -def test_walk_protobuf_scalar_and_string(): - # field 1 = varint 150 (08 96 01); field 2 = "ABC" (12 03 414243) - out = walk_protobuf(bytes.fromhex("0896011203414243")) - assert out == {".1": 150, ".2": "ABC"} +@pytest.mark.parametrize( + "hexstr,expected,note", + [ + # field 1 = varint 150 (08 96 01); field 2 = "ABC" (12 03 414243) + ("0896011203414243", {".1": 150, ".2": "ABC"}, "varint + printable string"), + # field 1 = submessage{ field 1 = varint 150 }: the container records its byte + # length AND its leaves are recursed -- dropping the container under-counts. + ("0a03089601", {".1": 3, ".1.1": 150}, "submessage records container + leaf"), + # repeated field 1 keeps wire order (second occurrence suffixed #1) + ("08010802", {".1": 1, ".1#1": 2}, "repeated tag keeps order"), + # field 1, wire type 5 (32-bit fixed), little-endian 0x12345678 + ("0d78563412", {".1": 0x12345678}, "wire 5 (32-bit fixed)"), + # field 1, wire type 1 (64-bit fixed), little-endian 01..08 + ( + "090102030405060708", + {".1": int.from_bytes(bytes(range(1, 9)), "little")}, + "wire 1 (64-bit fixed)", + ), + # field 2, non-printable length-delimited bytes -> kept as hex + ("120200ff", {".2": "00ff"}, "non-printable bytes leaf -> hex"), + # field 2, zero-length length-delimited -> empty (falls through to hex "") + ("1200", {".2": ""}, "empty length-delimited leaf"), + # field 2, all-zero sub is NOT recursed (any(sub) guard) -> kept as hex + ("12020000", {".2": "0000"}, "all-zero sub kept as hex, not recursed"), + # field 1 varint, then a wire-type-3 group marker for field 1 (0x0b): the group + # is recorded as None and stops the walk -- the trailing 0802 is not parsed. + ("08010b0802", {".1": 1, ".1#1": None}, "wire 3 group -> record None and stop"), + ], +) +def test_walk_protobuf(hexstr: str, expected: dict, note: str) -> None: + assert walk_protobuf(bytes.fromhex(hexstr)) == expected, note -def test_walk_protobuf_recurses_submessages(): - # field 1 = submessage{ field 1 = varint 150 } -> 0a 03 08 96 01 - assert walk_protobuf(bytes.fromhex("0a03089601")) == {".1.1": 150} +def test_walk_protobuf_empty_buffer() -> None: + assert walk_protobuf(b"") == {} -def test_walk_protobuf_repeated_fields_keep_order(): - # field 1 = 1, field 1 = 2 -> 08 01 08 02 - assert walk_protobuf(bytes.fromhex("08010802")) == {".1": 1, ".1#1": 2} +@pytest.mark.parametrize( + "value_hex,first_field,first_nonzero,note", + [ + # Real C2000 G2 `ce` value with no BP2000: a 16-byte zero combination-device ID, + # then a 1-byte status (0x11), then zero padding. Passed without the 04 type byte. + ( + "10000000000000000000000000000000000111" + "000000000000000000000000000000000000000000000000", + b"\x00" * 16, + False, + "empty combination id (no expansion unit)", + ), + # A non-zero 16-byte ID -> first field carries it verbatim. + ( + (bytes([0x10]) + bytes(range(1, 17)) + bytes.fromhex("0111")).hex(), + bytes(range(1, 17)), + True, + "populated combination id (expansion unit present)", + ), + ], +) +def test_walk_lv_first_field( + value_hex: str, + first_field: bytes, + first_nonzero: bool, + note: str, +) -> None: + fields = walk_lv(bytes.fromhex(value_hex)) + assert fields[0] == first_field, note + assert any(fields[0]) is first_nonzero -def test_walk_lv_ce_empty_combination_block(): - # Real C2000 G2 `ce` value with no BP2000: type 04, then len16 + 16-byte zero ID, - # then a 1-byte status (0x11), then zero padding. Pass without the 04 type byte. - ce_value = bytes.fromhex( - "0410000000000000000000000000000000000111" - "000000000000000000000000000000000000000000000000", - ) - fields = walk_lv(ce_value[1:]) - # first field is the 16-byte combination-device ID -- all zero == no unit combined - assert fields[0] == b"\x00" * 16 - assert not any(fields[0]) - # the status byte that follows the empty ID - assert fields[1] == b"\x11" +def test_walk_lv_reads_all_fields_and_trailing_padding() -> None: + # pairs to the end; trailing zero padding appears as zero-length fields. + fields = walk_lv(bytes.fromhex("02aabb01cc0000")) + assert fields == [b"\xaa\xbb", b"\xcc", b"", b""] -def test_walk_lv_reads_populated_first_field(): - # A non-zero 16-byte ID -> first field carries it verbatim - ident = bytes(range(1, 17)) - fields = walk_lv(bytes([0x10]) + ident + bytes.fromhex("0111")) - assert fields[0] == ident - assert any(fields[0]) +def test_walk_lv_empty_buffer() -> None: + assert walk_lv(b"") == [] diff --git a/tests/test_reassembly.py b/tests/test_reassembly.py index b37b14b..d184b55 100644 --- a/tests/test_reassembly.py +++ b/tests/test_reassembly.py @@ -7,6 +7,8 @@ from unittest import mock +import pytest + from SolixBLE import SolixBLEDevice from tests.const import MOCK_BLE_DEVICE @@ -14,6 +16,12 @@ CAP = 253 CMD = b"\xc4\x90" +#: Smallest payload that counts as full-length. ``_reassemble`` gates on +#: ``len(payload) + _FRAME_OVERHEAD >= mtu_size - 3`` (i.e. ``>= CAP``), so the +#: threshold is ``CAP - _FRAME_OVERHEAD`` -- derived, not hardcoded, so it tracks +#: the overhead constant rather than silently drifting if it changes. +FULL_LEN = CAP - SolixBLEDevice._FRAME_OVERHEAD + def _device() -> SolixBLEDevice: dev = SolixBLEDevice(MOCK_BLE_DEVICE) @@ -68,3 +76,78 @@ def test_cold_non_first_fragment_decoded_whole() -> None: payload = bytes([0x35]) + b"\x00" * (CAP - 1) # index 3 / total 5, no buffer assert device._reassemble(CMD, payload) == payload assert device._fragment_buffers == {} + + +def test_empty_payload_returned_as_is() -> None: + device = _device() + assert device._reassemble(CMD, b"") == b"" + assert device._fragment_buffers == {} + + +@pytest.mark.parametrize( + "first_byte,body,stripped", + [ + # A short frame is a single. Its first byte is stripped only when it is a + # valid single marker (0x11 = index 1 / total 1); otherwise it is ciphertext. + (0x11, b"\xcd" * 60, True), # MagGo/Prime single: frag byte stripped + (0xD8, b"\x11" * 200, False), # A91B2 single: no frag byte, first byte is data + (0x21, b"\xab" * 50, False), # index 2 / total 1 (impossible) -> data + (0x35, b"\x00" * 40, False), # index 3 / total 5 but short -> data + ], +) +def test_short_single_classification( + first_byte: int, + body: bytes, + stripped: bool, +) -> None: + device = _device() + payload = bytes([first_byte]) + body + expected = body if stripped else payload + assert device._reassemble(CMD, payload) == expected + assert device._fragment_buffers == {} + + +@pytest.mark.parametrize( + "body_len,is_fragment", + [(FULL_LEN, True), (FULL_LEN - 1, False)], +) +def test_full_length_threshold_opens_run(body_len: int, is_fragment: bool) -> None: + # A run only opens on a full-length (>= FULL_LEN) index-1 first fragment. One byte + # under that is a single (kept whole here: first byte 0x12 is not the 0x11 marker). + device = _device() + payload = bytes([0x12]) + b"\xaa" * (body_len - 1) + result = device._reassemble(CMD, payload) + if is_fragment: + assert result is None + assert device._fragment_buffers[CMD] == {1: b"\xaa" * (body_len - 1)} + else: + assert result == payload + assert device._fragment_buffers == {} + + +def test_three_fragment_reassembly() -> None: + device = _device() + frag1 = bytes([0x13]) + b"\xaa" * (CAP - 1) # index 1 / total 3 + frag2 = bytes([0x23]) + b"\xbb" * (CAP - 1) # index 2 / total 3 + frag3 = bytes([0x33]) + b"\xcc" * 30 # index 3 / total 3, short tail + assert device._reassemble(CMD, frag1) is None + assert device._reassemble(CMD, frag2) is None + joined = device._reassemble(CMD, frag3) + assert joined == b"\xaa" * (CAP - 1) + b"\xbb" * (CAP - 1) + b"\xcc" * 30 + assert device._fragment_buffers == {} + + +def test_interleaved_runs_buffered_per_command() -> None: + # Two commands fragmenting at once keep independent buffers keyed by cmd. + device = _device() + cmd_a, cmd_b = b"\xc4\x90", b"\xc4\x21" + assert device._reassemble(cmd_a, bytes([0x12]) + b"\xaa" * (CAP - 1)) is None + assert device._reassemble(cmd_b, bytes([0x12]) + b"\x11" * (CAP - 1)) is None + assert set(device._fragment_buffers) == {cmd_a, cmd_b} + assert device._reassemble(cmd_a, bytes([0x22]) + b"\xaa" * 20) == b"\xaa" * ( + CAP - 1 + 20 + ) + assert device._reassemble(cmd_b, bytes([0x22]) + b"\x11" * 20) == b"\x11" * ( + CAP - 1 + 20 + ) + assert device._fragment_buffers == {} diff --git a/tests/test_summary.py b/tests/test_summary.py new file mode 100644 index 0000000..a784962 --- /dev/null +++ b/tests/test_summary.py @@ -0,0 +1,134 @@ +"""Tests for the c490 protobuf device-summary path (C1000 G2 / C2000 G2). + +The C2000 G2 (A1783) posts a protobuf device summary on command ``c490`` every +~9 min. It is delivered whole by the reassembler, decrypted, then walked into a +``.path`` field map exposed via :attr:`~SolixBLE.device.SolixBLEDevice.summary` +(see :mod:`SolixBLE.parsing`). The protobuf is wrapped in an outer ``a1``/``a2`` +TLV, so it must be un-wrapped before walking. The frames below are real captures +(decrypted cleartext from the collector's journald log), re-encrypted with the +test secret to exercise the whole decrypt -> unwrap -> walk -> property path. +""" + +import pytest + +from SolixBLE import C1000G2, C2000G2 +from SolixBLE.const import DEFAULT_METADATA_FLOAT, DEFAULT_METADATA_INT +from SolixBLE.device import SolixBLEDevice +from tests.const import MOCK_BLE_DEVICE + +#: AES key/IV used to re-encrypt the captured cleartext (same secret the other +#: telemetry tests use); the decrypt round-trips it back to the cleartext below. +SECRET = "5609bc39f79166da75139feb7c335fb7524b3bf0d730db96bf6ebf450d3e165b" + +C490_CMD = b"\xc4\x90" + +#: Real A1763 c490 frame, idle regime (flow-state 0, 95 % SoC), decrypted cleartext. +FRAME_IDLE = "a10131a25001040a0541313736331200320538b4094007722e0a200000040075411d61894900005201000000013c00000000000100000080000000100018cb1e20cf1628003000722c0a200000000000000000000000000000000000000000000000000000000000000000100018002000280030007a1b08820f1002189504208f04280330093800400048005000580060007a18080010001800200028003000380040004800500058006000920110080010001800200028003000380040009a011a08cefd0110900118d5ea0320aad00228c63a302438df0a409c42a2011608ee01100018e40220c101281330003800400048ee01aa01130800100018002000280030003800400048ee01b001da9702ba0119085f08001000180020002800302a3800409a1c48bb0950b202c20127080410011800200028003000380040004800500058006000680070007800800100880100900100a31c046368617267696e675f7070735f7365726965735f635f3030303500" # noqa: E501 + +#: Real A1763 c490 frame, discharge regime (flow-state 1, 44 W out, 52.9 V). +FRAME_DISCHARGE = "a10131a25001040a0541313736331200320538b4094007722e0a200000000075001d61894800005201000000003c00000000000100000080000000100018cb1e20d01628003000722c0a200000000000000000000000000000000000000000000000000000000000000000100018002000280030007a1b08820f1002189104209104280830093800400048005000580060007a18080010001800200028003000380040004800500058006000920110080010001800200028003000380040009a011a08cefd0110900118d5ea0320aad00228d73a302438df0a40a442a2011608ee01100018e40220c101281330003800400048ee01aa01130800100018002000280030003800400048ee01b001eb9702ba0119085f0800102c180020002800302a380140910348bb0950b202c20127080410011800200028003000380040004800500058006000680070007800800100880100900100a31c046368617267696e675f7070735f7365726965735f635f3030303500" # noqa: E501 + + +async def _feed_c490(device: SolixBLEDevice, frame_hex: str) -> None: + """Re-encrypt a captured cleartext frame and run it through the telemetry path.""" + device._shared_secret = bytes.fromhex(SECRET) + encrypted = device._encrypt_payload(bytes.fromhex(frame_hex)) + await device._process_telemetry_packet(encrypted, cmd=C490_CMD) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "device_class,frame_hex,mapping", + [ + pytest.param( + C1000G2, + FRAME_IDLE, + { + "c490_battery_soc": 95, + "c490_output_power_total": 0, + "c490_ac_output_power": 0, + "c490_input_power_total": 0, + "c490_dc_input_power": 0, + "charge_presence": 42, + "flow_state": 0, + "battery_voltage": 53.3, + "cumulative_discharge_energy": 8476, + }, + id="c1000g2_c490_idle", + ), + pytest.param( + C2000G2, + FRAME_DISCHARGE, + { + "c490_battery_soc": 95, + "c490_output_power_total": 44, + "flow_state": 1, + "battery_voltage": 52.9, + "cumulative_discharge_energy": 8484, + }, + id="c2000g2_c490_discharge", + ), + ], +) +async def test_c490_summary_properties( + device_class: type[SolixBLEDevice], + frame_hex: str, + mapping: dict, +) -> None: + """A real c490 frame decrypts, unwraps and walks into the summary properties.""" + device = device_class(MOCK_BLE_DEVICE) + await _feed_c490(device, frame_hex) + + for prop, expected in mapping.items(): + assert getattr(device, prop) == expected, f"Mismatch for '{prop}'" + + +@pytest.mark.asyncio +async def test_c490_summary_is_faithful_field_map() -> None: + """The walk records every field (containers included) and stops at the trailer. + + Recording message containers (not just their leaves) keeps the map faithful -- + an under-count is a wrong decode. The one wire-type-3 marker is the trailing + ``a3`` string field past the protobuf, recorded as ``None`` and terminating the + walk (see :func:`SolixBLE.parsing.walk_protobuf`). + """ + device = C2000G2(MOCK_BLE_DEVICE) + await _feed_c490(device, FRAME_IDLE) + + summary = device.summary + assert summary[".1"] == "A1763" # part number leaf + assert isinstance(summary[".23"], int) # the .23 rollup container is recorded + assert summary[".23.1"] == 95 # ... and its leaf + # the walk is bounded to a2's declared length, so it never runs into the trailing + # a3 string field: no spurious .452 group marker, and every value is a real field. + assert ".452" not in summary + assert None not in summary.values() + + +@pytest.mark.parametrize( + "payload_hex,expected_hex", + [ + # a1 01 31 | a2 type(04)+3-byte blob> 04 | 089601 -> just 089601 + ("a10131a2040004089601", "089601"), + # an 8-byte blob: a2 length is 9 (04 type + 8) -> returns the 8 blob bytes + ("a10131a2090004" + "08" * 8, "08" * 8), + # a trailing a3 string field past a2 is bounded out, not appended to the blob + ("a10131a2040004089601a31c0463686172", "089601"), + # too short to carry the wrapper -> returned whole rather than mis-sliced + ("a101", "a101"), + ], +) +def test_protobuf_body_strips_wrapper(payload_hex: str, expected_hex: str) -> None: + body = SolixBLEDevice._protobuf_body(bytes.fromhex(payload_hex)) + assert body == bytes.fromhex(expected_hex) + + +def test_summary_defaults_before_any_c490() -> None: + """With no c490 frame yet, the summary is empty and its properties read defaults.""" + device = C2000G2(MOCK_BLE_DEVICE) + + assert device.summary == {} + assert device.battery_voltage == DEFAULT_METADATA_FLOAT + assert device.flow_state == DEFAULT_METADATA_INT + assert device.c490_battery_soc == DEFAULT_METADATA_INT + assert device.cumulative_discharge_energy == DEFAULT_METADATA_INT From 702c016b12cfed8054c341ccc9123132fc598990 Mon Sep 17 00:00:00 2001 From: Shawn Stricker Date: Fri, 17 Jul 2026 23:43:07 -0400 Subject: [PATCH 8/8] docs: document C2000 G2 + A91B2 station; refresh support tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add device pages for C2000G2 and PrimeChargingStation240w and wire them into the api toctree. - power-station table: add a C2000 G2 (A1783) column; Time remaining is now โœ… (remaining_time_hours); mark the C1000/C2000 G2 fields the device exposes but the library does not yet decode as ๐Ÿšง; add the c490-summary rows (max charge power, pack voltage, cumulative energy, charge presence). - prime-charger table: add a 240w (A91B2) column and the A2345's newly exposed capabilities (port on/off state, total power, serial); mark the device-known-but-unimplemented features (port control, timer, charging mode, clock, brightness, knob, DevNote, ...) as ๐Ÿšง; give the A91B2's two AC outlets their own row. - add a legend (โœ… / ๐Ÿšง / โŒ / N/A / โ”) and notes for the A91B2 USB-C-only control and the cloud-armed c490 rows. - fix a duplicate ``anker-solix-api`` hyperlink target in owner_user_id.rst (use anonymous links) so the branch's Sphinx build is warning-clean. The index rewrite also strips ragged trailing whitespace from the (otherwise unchanged) solar and power-bank tables. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/source/api.rst | 2 + docs/source/c2000g2.rst | 9 ++ docs/source/index.rst | 157 ++++++++++++-------- docs/source/owner_user_id.rst | 4 +- docs/source/prime_charging_station_240w.rst | 9 ++ 5 files changed, 114 insertions(+), 67 deletions(-) create mode 100644 docs/source/c2000g2.rst create mode 100644 docs/source/prime_charging_station_240w.rst diff --git a/docs/source/api.rst b/docs/source/api.rst index 5de9db5..9da1662 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -17,12 +17,14 @@ the list of properties for that class. c800 c1000 c1000g2 + c2000g2 f2000 f3800 solarbank2 solarbank3 prime_charger_160w prime_charger_250w + prime_charging_station_240w prime_maggo_3in1 prime_power_bank_20k generic diff --git a/docs/source/c2000g2.rst b/docs/source/c2000g2.rst new file mode 100644 index 0000000..363f27f --- /dev/null +++ b/docs/source/c2000g2.rst @@ -0,0 +1,9 @@ +C2000(X) G2 +=========== + +.. autoclass:: SolixBLE.C2000G2 + :members: + :inherited-members: connect, disconnect, add_callback, remove_callback, connected, available, address, name, supports_telemetry, last_update + :special-members: __init__ + :member-order: groupwise + :no-index: diff --git a/docs/source/index.rst b/docs/source/index.rst index 5803f91..f892d47 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -7,7 +7,7 @@ Welcome to SolixBLE's documentation! .. image:: https://readthedocs.org/projects/solixble/badge/?version=latest :target: https://solixble.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status - + .. image:: https://img.shields.io/badge/code%20style-black-000000.svg :target: https://github.com/psf/black :alt: Black @@ -29,58 +29,73 @@ No pairing is required in order to receive telemetry data or control the device. This project is under active development. +The support tables below use these marks: โœ… supported ยท ๐Ÿšง known but not yet +implemented ยท โŒ not supported ยท N/A not applicable ยท โ” not investigated. A +``read/control`` pair such as โœ…/๐Ÿšง gives the two states separately (e.g. the max +charge limit is readable but not yet settable). + Power station support --------------------- -======================= ======== ========== ========= ========= ========= ============ ====== -Parameter C300(X) C300(X) DC C800(X) C1000(X) C1000 G2 F2000 (767) F3800 -======================= ======== ========== ========= ========= ========= ============ ====== -Charging status โœ… โœ… โŒ โŒ โŒ โŒ โœ… -Time remaining โœ… โœ… โœ… โœ… โŒ โœ… โœ… -Battery percentage โœ… โœ… โœ… โœ… โœ… โœ… โœ… -Battery health โŒ โœ… โœ… โœ… โœ… โœ… โŒ -Temperature โœ… โœ… โœ… โœ… โœ… โœ… โœ… -Total Power In โœ… โœ… โœ… โœ… โŒ โŒ โœ… -Total Power Out โœ… โœ… โœ… โœ… โœ… โŒ โœ… -AC on/off control โœ… N/A โœ… โœ… โœ… โŒ โŒ -AC Power in โœ… N/A โœ… โœ… โœ… โœ… โœ… -AC Power out โœ… N/A โœ… โœ… โœ… โœ… โœ… -AC on/off state โœ… N/A โœ… โœ… โœ… โŒ โœ… -AC Timer โœ… N/A โœ… โœ… โŒ โŒ โŒ -DC on/off control โœ… โŒ โœ… โœ… โœ… โŒ โŒ -DC Power in โœ… โœ… โœ… โœ… โœ… โœ… โœ… -DC Power out โœ… โœ… โŒ โœ… โœ… โœ… โœ… -DC Power in status โœ… โœ… โŒ โŒ โœ… โŒ โŒ -DC Power out status โœ… โŒ โŒ โœ… โœ… โŒ โœ… -DC Timer โœ… โœ… โŒ โŒ โŒ โŒ โŒ -USB Power out โœ… โœ… โœ… โœ… โœ… โœ… โœ… -USB Port status โœ… โœ… โŒ โŒ โœ… โŒ โœ… -Light control โœ… โŒ โœ… โœ… โŒ โŒ โŒ -Light status โœ… โœ… โŒ โŒ N/A โŒ โŒ -Display on/off control โœ… โŒ โœ… โœ… โŒ โŒ โŒ -Display on/off status โŒ โœ… โŒ โŒ โŒ โŒ โŒ -Display brightness ctrl โœ… โŒ โœ… โœ… โŒ โŒ โŒ -Display brightness stat โŒ โœ… โŒ โŒ โŒ โŒ โŒ -Display timeout ctrl โœ… โŒ โœ… โœ… โŒ โŒ โŒ -Display timeout stat โŒ โœ… โŒ โŒ โŒ โŒ โŒ -Firmware version โœ… โœ… โœ… โœ… โŒ โœ… โœ… -Serial number โœ… โœ… โœ… โœ… โœ… โœ… โœ… -Expansion temperature N/A N/A N/A โœ… N/A โœ… โŒ -Expansion percentage N/A N/A N/A โœ… N/A โœ… โœ… -Expansion health N/A N/A N/A โœ… N/A โœ… โŒ -Expansion firmware N/A N/A N/A โœ… N/A โœ… โœ… -Expansion num N/A N/A N/A โœ… N/A โœ… โŒ -Polled status updates โœ… โŒ โœ… โœ… โŒ โŒ โŒ -======================= ======== ========== ========= ========= ========= ============ ====== +======================= ======= ========== ======= ======== ======== ======== =========== ===== +Parameter C300(X) C300(X) DC C800(X) C1000(X) C1000 G2 C2000 G2 F2000 (767) F3800 +======================= ======= ========== ======= ======== ======== ======== =========== ===== +Charging status โœ… โœ… โŒ โŒ ๐Ÿšง ๐Ÿšง โŒ โœ… +Time remaining โœ… โœ… โœ… โœ… โœ… โœ… โœ… โœ… +Battery percentage โœ… โœ… โœ… โœ… โœ… โœ… โœ… โœ… +Battery health โŒ โœ… โœ… โœ… โœ… โœ… โœ… โŒ +Temperature โœ… โœ… โœ… โœ… โœ… โœ… โœ… โœ… +Total Power In โœ… โœ… โœ… โœ… ๐Ÿšง ๐Ÿšง โŒ โœ… +Total Power Out โœ… โœ… โœ… โœ… โœ… โœ… โŒ โœ… +AC on/off control โœ… N/A โœ… โœ… โœ… โœ… โŒ โŒ +AC Power in โœ… N/A โœ… โœ… โœ… โœ… โœ… โœ… +AC Power out โœ… N/A โœ… โœ… โœ… โœ… โœ… โœ… +AC on/off state โœ… N/A โœ… โœ… โœ… โœ… โŒ โœ… +AC Timer โœ… N/A โœ… โœ… ๐Ÿšง ๐Ÿšง โŒ โŒ +DC on/off control โœ… โŒ โœ… โœ… โœ… โœ… โŒ โŒ +DC Power in โœ… โœ… โœ… โœ… โœ… โœ… โœ… โœ… +DC Power out โœ… โœ… โŒ โœ… โœ… โœ… โœ… โœ… +DC Power in status โœ… โœ… โŒ โŒ โœ… โœ… โŒ โŒ +DC Power out status โœ… โŒ โŒ โœ… โœ… โœ… โŒ โœ… +DC Timer โœ… โœ… โŒ โŒ ๐Ÿšง ๐Ÿšง โŒ โŒ +USB Power out โœ… โœ… โœ… โœ… โœ… โœ… โœ… โœ… +USB Port status โœ… โœ… โŒ โŒ โœ… โœ… โŒ โœ… +Max charge power โ” โ” โ” โ” โœ…/๐Ÿšง โœ…/๐Ÿšง โ” โ” +Pack voltage โ” โ” โ” โ” ๐Ÿšง ๐Ÿšง โ” โ” +Cumulative energy out โ” โ” โ” โ” ๐Ÿšง ๐Ÿšง โ” โ” +Charge presence โ” โ” โ” โ” ๐Ÿšง ๐Ÿšง โ” โ” +Light control โœ… โŒ โœ… โœ… ๐Ÿšง ๐Ÿšง โŒ โŒ +Light status โœ… โœ… โŒ โŒ N/A N/A โŒ โŒ +Display on/off control โœ… โŒ โœ… โœ… ๐Ÿšง ๐Ÿšง โŒ โŒ +Display on/off status โŒ โœ… โŒ โŒ ๐Ÿšง ๐Ÿšง โŒ โŒ +Display brightness ctrl โœ… โŒ โœ… โœ… ๐Ÿšง ๐Ÿšง โŒ โŒ +Display brightness stat โŒ โœ… โŒ โŒ ๐Ÿšง ๐Ÿšง โŒ โŒ +Display timeout ctrl โœ… โŒ โœ… โœ… ๐Ÿšง ๐Ÿšง โŒ โŒ +Display timeout stat โŒ โœ… โŒ โŒ ๐Ÿšง ๐Ÿšง โŒ โŒ +Firmware version โœ… โœ… โœ… โœ… ๐Ÿšง ๐Ÿšง โœ… โœ… +Serial number โœ… โœ… โœ… โœ… โœ… โœ… โœ… โœ… +Expansion temperature N/A N/A N/A โœ… N/A ๐Ÿšง โœ… โŒ +Expansion percentage N/A N/A N/A โœ… N/A ๐Ÿšง โœ… โœ… +Expansion health N/A N/A N/A โœ… N/A ๐Ÿšง โœ… โŒ +Expansion firmware N/A N/A N/A โœ… N/A ๐Ÿšง โœ… โœ… +Expansion num N/A N/A N/A โœ… N/A โœ… โœ… โŒ +Polled status updates โœ… โŒ โœ… โœ… ๐Ÿšง ๐Ÿšง โŒ โŒ +======================= ======= ========== ======= ======== ======== ======== =========== ===== + +The C2000 G2 (A1783) shares the C1000 G2 telemetry stack; its ๐Ÿšง cells are the +fields the device is known to expose but the library does not yet decode. The +``Pack voltage``, ``Cumulative energy out`` and ``Charge presence`` rows come +from the ~9-minute, cloud-armed ``c490`` device-summary rather than the live +per-second stream. Solar system support -------------------- -================================= ============ ============ -Parameter Solarbank 2 Solarbank 3 -================================= ============ ============ +================================= ============ ============ +Parameter Solarbank 2 Solarbank 3 +================================= ============ ============ AC power out โœ… โŒ AC power out (sockets) โœ… โŒ Total power out โœ… โœ… @@ -120,39 +135,51 @@ Light mode โœ… โŒ PV limitations โŒ โŒ PV panel power โœ… โŒ AC limitations โŒ โŒ -Software version โœ… โŒ +Software version โœ… โŒ Software version controller โœ… โŒ Software version expansion โœ… โŒ -Serial number โœ… โœ… +Serial number โœ… โœ… Expansion battery serial number โŒ โŒ -================================= ============ ============ +================================= ============ ============ Prime charger support --------------------- -======================= ============= ============= =================== -Parameter 250w (A2345) 160w (A2687) MagGo 3in1 (A25X7) -======================= ============= ============= =================== -Display status โŒ โŒ โŒ -Total power out โŒ โŒ โœ… -Port on/off control โŒ โœ… โŒ -Port protocol control โŒ โŒ โŒ -Timer control โŒ โœ… โŒ -Individual port status โœ… โœ… โœ… -Individual port voltage โœ… โœ… โŒ -Individual port current โœ… โœ… โŒ -Individual port power โœ… โœ… โœ… -Temperature โŒ โŒ โŒ -Firmware version โŒ โŒ โŒ -Serial number โŒ โŒ โŒ -======================= ============= ============= =================== +======================= ============ ============ ============ ================== +Parameter 250w (A2345) 240w (A91B2) 160w (A2687) MagGo 3in1 (A25X7) +======================= ============ ============ ============ ================== +Individual port status โœ… โœ… โœ… โœ… +Individual port voltage โœ… โœ… โœ… โŒ +Individual port current โœ… โœ… โœ… โŒ +Individual port power โœ… โœ… โœ… โœ… +Total power out โœ… โœ… โŒ โœ… +Serial number โœ… โœ… โŒ โŒ +Port on/off control ๐Ÿšง ๐Ÿšง โœ… โŒ +Port on/off state โœ… ๐Ÿšง โŒ โŒ +AC outlet on/off N/A โœ… N/A N/A +Port protocol control ๐Ÿšง โŒ โŒ โŒ +Timer control ๐Ÿšง โŒ โœ… โŒ +Power schedule ๐Ÿšง ๐Ÿšง โŒ โŒ +Port naming (DevNote) ๐Ÿšง โŒ โŒ โŒ +Charging mode ๐Ÿšง ๐Ÿšง โŒ โŒ +Clock display ๐Ÿšง ๐Ÿšง โŒ โŒ +Screen brightness ๐Ÿšง ๐Ÿšง โŒ โŒ +Knob orientation ๐Ÿšง N/A N/A N/A +Display status ๐Ÿšง ๐Ÿšง โŒ โŒ +Temperature โŒ โŒ โŒ โŒ +Firmware version ๐Ÿšง ๐Ÿšง โŒ โŒ +======================= ============ ============ ============ ================== + +The A91B2 station's port on/off control is USB-C only (no USB-A switch); its two +AC outlets are the ``AC outlet on/off`` row (``ac_1_switch`` / ``ac_2_switch``). +Its ๐Ÿšง cells mark features the app exposes that the library does not yet decode. Prime power bank support ------------------------ -======================= ================= +======================= ================= Parameter 20k/220w (A110B) ======================= ================= Battery percentage โœ… @@ -173,7 +200,7 @@ Disclaimer ---------- SolixBLE is a software library designed to work with Anker Solix/Prime devices. -ANKER is a registered trademark of Anker Innovations Limited. +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. diff --git a/docs/source/owner_user_id.rst b/docs/source/owner_user_id.rst index 502503e..24feab1 100644 --- a/docs/source/owner_user_id.rst +++ b/docs/source/owner_user_id.rst @@ -47,13 +47,13 @@ which is why either can be used to recover it. Method 1: anker-solix-api (recommended) --------------------------------------- -`anker-solix-api `_ is a Python client for +`anker-solix-api `__ is a Python client for the Anker cloud. Logging in once returns your ``user_id``. This needs only your Anker account credentials -- no phone, no rooted device. Requirements: -- `anker-solix-api `_ (``pip install anker-solix-api``) +- `anker-solix-api `__ (``pip install anker-solix-api``) - Your Anker account email and password .. code-block:: python diff --git a/docs/source/prime_charging_station_240w.rst b/docs/source/prime_charging_station_240w.rst new file mode 100644 index 0000000..fb12d86 --- /dev/null +++ b/docs/source/prime_charging_station_240w.rst @@ -0,0 +1,9 @@ +Prime Charging Station 240w +=========================== + +.. autoclass:: SolixBLE.PrimeChargingStation240w + :members: + :inherited-members: connect, disconnect, add_callback, remove_callback, connected, available, address, name, supports_telemetry, last_update + :special-members: __init__ + :member-order: groupwise + :no-index: