From c9c520dbda23e7068bbbe14709eb04b70b8a78be Mon Sep 17 00:00:00 2001 From: smariacher Date: Sat, 28 Mar 2026 20:05:21 +0100 Subject: [PATCH 01/12] Added SB1 controlability and telemetry --- SolixBLE/devices/solarbank1.py | 324 +++++++++++++++++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 SolixBLE/devices/solarbank1.py diff --git a/SolixBLE/devices/solarbank1.py b/SolixBLE/devices/solarbank1.py new file mode 100644 index 0000000..fdad6f1 --- /dev/null +++ b/SolixBLE/devices/solarbank1.py @@ -0,0 +1,324 @@ +"""Solarbank 1 power station model. + +.. moduleauthor:: Simon Mariacher https://github.com/smariacher + +""" + +from ..const import DEFAULT_METADATA_FLOAT, DEFAULT_METADATA_STRING +from ..device import SolixBLEDevice +import struct + +CMD_SB_SET_SCHEDULE = "405e" + +class Solarbank1(SolixBLEDevice): + """ + SolarBank 1 Power Station. + + Use this class to connect and monitor a Solarbank 1 power station. + This model is also known as the A17C0. + + .. note:: + This model was added using data from anker-solix-api as well as logging the actual anker app. + It seems to be working so far, altough not everything has been reverse engineered so far. + + + """ + + _EXPECTED_TELEMETRY_LENGTH: int = 253 + + @property + def serial_number(self) -> str: + """Device serial number. + + :returns: Device serial number or default str value. + """ + return self._parse_string("a2", begin=1) + + @property + def battery_percentage(self) -> int: + """Battery Percentage. + + :returns: Percentage charge of battery or default int value. + """ + return self._parse_int("a3", begin=1) + + @property + def software_version(self) -> str: + """Main software version. + + :returns: Firmware version or default str value. + """ + if self._data is None: + return DEFAULT_METADATA_STRING + + return ".".join([digit for digit in str(self._parse_int("a6", begin=1))]) + + @property + def software_version_controller(self) -> str: + """Software version of the controller. + + :returns: Firmware version or default str value. + """ + if self._data is None: + return DEFAULT_METADATA_STRING + + return ".".join([digit for digit in str(self._parse_int("a7", begin=1))]) + + @property + def hardware_version(self) -> str: + """Hardware version. + + :returns: Hardware version or default str value. + """ + if self._data is None: + return DEFAULT_METADATA_STRING + + return ".".join([digit for digit in str(self._parse_int("a8", begin=1))]) + + @property + def temperature(self) -> int: + """Temperature of the unit (C). + + :returns: Temperature of the unit in degrees C. + """ + return self._parse_int("aa", begin=1, signed=True) + + @property + def solar_power_in(self) -> int: + """Total Solar Power In. + + :returns: Total solar power in or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("ab", begin=1) / 10.0 + + @property + def output_power(self) -> int: + """Output power. + + :returns: Total power out in watts or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("ac", begin=1) + + @property + def charging_status(self) -> int: + """Charging status. + + :returns: Charging status or default int value. + """ + return self._parse_int("ad", begin=1) + + @property + def current_schedule(self) -> str: + """Parse the active daily schedule block(s). + + :returns: A human-readable string describing the current schedule or a message if no schedule is set. + """ + if self._data is None or "ae" not in self._data: + return "No Schedule Set" + + data = self._data["ae"] + + # Safely extract the raw bytes + if isinstance(data, bytes): + raw_bytes = data + elif isinstance(data, dict): + hex_str = data.get('hex', '') + raw_bytes = bytes.fromhex(hex_str) + else: + return "Invalid data format" + + # A valid payload has a 1-byte header, plus N * 8-byte blocks + if len(raw_bytes) < 9 or (len(raw_bytes) - 1) % 8 != 0: + return f"Unknown structure: {raw_bytes.hex()}" + + # We can ignore the first byte (04 header) and loop through the rest + periods = [] + for i in range(1, len(raw_bytes), 8): + chunk = raw_bytes[i:i+8] + + start_min = int.from_bytes(chunk[0:2], byteorder='little') + end_min = int.from_bytes(chunk[2:4], byteorder='little') + watts = int.from_bytes(chunk[4:6], byteorder='little') + limit = int.from_bytes(chunk[6:8], byteorder='little') + + start_time = f"{start_min // 60:02d}:{start_min % 60:02d}" + end_time = f"{end_min // 60:02d}:{end_min % 60:02d}" + + periods.append(f"[{start_time}-{end_time} @ {watts}W, Limit: {limit}%]") + + return " | ".join(periods) + + @property + def battery_charge_power(self) -> int: + """Battery charging power. + + :returns: Total battery power in or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("b0", begin=1) / 100.0 + + @property + def pv_yield(self) -> int: + """Solar power generated. + + :returns: Total solar power generated or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("b1", begin=1) / 10000.0 + + @property + def charged_energy(self) -> int: + """Probably aggregated energy charged in Wh? + + :returns: Charged energy or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("b2", begin=1) / 10000.0 + + @property + def output_energy(self) -> int: + """Output energy. + + :returns: Total energy output or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("b3", begin=1) / 10000.0 + + @property + def inverter_brand(self) -> str: + """Brand of the connected inverter. + + :returns: Inverter brand or default str value. + """ + if self._data is None: + return DEFAULT_METADATA_STRING + + return self._parse_string("b7", begin=1) # TODO: Check this later + + @property + def inverter_model(self) -> str: + """Model of the connected inverter. + + :returns: Inverter model or default str value. + """ + if self._data is None: + return DEFAULT_METADATA_STRING + + return self._parse_string("b8", begin=1) # TODO: Check this later + + @property + def min_load(self) -> int: + """Maybe minimum wattage the battery will output? + + :returns: Don't know yet or default str value. + """ + if self._data is None: + return DEFAULT_METADATA_STRING + + return self._parse_int("b9", begin=1) # TODO: Check this later + + + async def set_schedule(self, schedules: list[dict]) -> None: + """Set the daily charge/discharge schedule on the Solarbank 1. + + Sends a schedule write command (CMD 0x405e) to the device. + The base class ``_send_command`` automatically appends the current + session timestamp and handles AES-CBC encryption and framing. + + Each schedule entry is a ``dict`` with the following keys: + + ========= ======= ===================================================== + Key Type Description + ========= ======= ===================================================== + ``start`` ``str`` Start time in ``"HH:MM"`` format, e.g. ``"00:00"`` + ``end`` ``str`` End time in ``"HH:MM"`` format, e.g. ``"06:00"`` + ``power`` ``int`` Output wattage; use ``0`` for charge-only mode + ``soc`` ``int`` Max battery SOC cap as a percentage (e.g. ``80``) + ========= ======= ===================================================== + + Pass an empty list to clear/delete all schedules. + + Examples:: + + # Single schedule: charge-only midnight-06:00, cap at 80 % SOC + await sb1.set_schedule([ + {"start": "00:00", "end": "06:00", "power": 0, "soc": 80} + ]) + + # Two back-to-back schedules + await sb1.set_schedule([ + {"start": "00:00", "end": "06:00", "power": 0, "soc": 80}, + {"start": "06:00", "end": "14:30", "power": 240, "soc": 80}, + ]) + + # Clear all schedules + await sb1.set_schedule([]) + + :param schedules: List of schedule dicts. The device-side upper limit + is unknown but confirmed to be at least 10. + :raises ValueError: If a time string is not in ``"HH:MM"`` format, or + if ``power``/``soc`` values are out of range. + :raises ConnectionError: If not connected/negotiated to the device. + """ + + for i, s in enumerate(schedules): + if not (0 <= s["power"] <= 800): + raise ValueError( + f"Schedule {i}: power must be 0–800 W, got {s['power']}" + ) + if not (1 <= s["soc"] <= 100): + raise ValueError( + f"Schedule {i}: soc must be 1–100 %, got {s['soc']}" + ) + + def _time_to_minutes(t: str) -> int: + """Convert 'HH:MM' string to minutes since midnight.""" + try: + h, m = t.split(":") + return int(h) * 60 + int(m) + except (ValueError, AttributeError): + raise ValueError( + f"Time '{t}' is not in HH:MM format" + ) + + # ── Build plaintext TLV payload ──────────────────────────────────── + # + # Format per field: + # LENGTH counts the TYPE byte plus data bytes (i.e. len(DATA) + 1). + # + # 0xa1 — command marker: no data, type byte 0x21 only + payload = bytes([0xa1, 0x01, 0x21]) + + # 0xa2 — schedule count: type 0x01, 1-byte unsigned int + payload += bytes([0xa2, 0x02, 0x01, len(schedules)]) + + # 0xa3 — schedule blocks: type 0x04, then N × 8-byte entries + # Each entry: [start_min u16le][end_min u16le][power_W u16le][soc_% u16le] + schedule_bytes = b"" + for s in schedules: + schedule_bytes += struct.pack( + " Date: Sun, 29 Mar 2026 11:37:19 +0100 Subject: [PATCH 02/12] Add entry for frida_2.js --- docs/source/app_decoding.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/source/app_decoding.rst b/docs/source/app_decoding.rst index a5261d2..071d9f7 100644 --- a/docs/source/app_decoding.rst +++ b/docs/source/app_decoding.rst @@ -256,6 +256,18 @@ log their inputs and outputs. :language: javascript +frida_2.js +^^^^^^^^^^ + +This is an enhanced version of the frida.js script which includes some +additional logging functionality and more encryption hooks. If you +are having issues with the frida.js script not capturing encryption +data, then try using this one. + +.. literalinclude :: ../../scripts/frida_2.js + :language: javascript + + run.sh ^^^^^^ From 197fabcc1284e2d87b3cca0a11110ef303b52fa3 Mon Sep 17 00:00:00 2001 From: Harvey Lelliott <42912136+flip-dots@users.noreply.github.com> Date: Sun, 29 Mar 2026 11:39:06 +0100 Subject: [PATCH 03/12] Add entry for patch.bat --- docs/source/app_decoding.rst | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/source/app_decoding.rst b/docs/source/app_decoding.rst index 071d9f7..d5066a1 100644 --- a/docs/source/app_decoding.rst +++ b/docs/source/app_decoding.rst @@ -239,12 +239,23 @@ The scripts are provided below for convenience in addition to being in the main patch.sh ^^^^^^^^ -This script automates the patching of the Anker app +This script automates the patching of the Anker app. This script is +designed for MacOS and Linux. .. literalinclude :: ../../scripts/patch.sh :language: bash +patch.bat +^^^^^^^^^ + +This script automates the patching of the Anker app. This script is +designed for Windows. + +.. literalinclude :: ../../scripts/patch.bat + :language: bat + + frida.js ^^^^^^^^ From bbf6e974216079a331a708d7de6ac7846451105d Mon Sep 17 00:00:00 2001 From: Harvey Lelliott <42912136+flip-dots@users.noreply.github.com> Date: Sun, 29 Mar 2026 11:58:39 +0100 Subject: [PATCH 04/12] Add SolarBank 1 to docs --- README.md | 1 + docs/source/api.rst | 1 + docs/source/index.rst | 102 ++++++++++++++++++++----------------- docs/source/solarbank1.rst | 9 ++++ 4 files changed, 65 insertions(+), 48 deletions(-) create mode 100644 docs/source/solarbank1.rst diff --git a/README.md b/README.md index 8b82398..96851e3 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ See the [support table](https://solixble.readthedocs.io/en/latest) in the docume - C1000 Gen 2 - F2000 (767 PowerHouse) - F3800 +- Solarbank 1 - Solarbank 2 - Solarbank 3 - Prime Charger 160w diff --git a/docs/source/api.rst b/docs/source/api.rst index 5de9db5..63b5de9 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -19,6 +19,7 @@ the list of properties for that class. c1000g2 f2000 f3800 + solarbank1 solarbank2 solarbank3 prime_charger_160w diff --git a/docs/source/index.rst b/docs/source/index.rst index 0e8dd70..95f4966 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -78,54 +78,60 @@ Polled status updates ✅ ❌ ✅ ✅ ❌ Solar system support -------------------- -================================= ============ ============ -Parameter Solarbank 2 Solarbank 3 -================================= ============ ============ -AC power out ✅ ❌ -AC power out (sockets) ✅ ❌ -Total power out ✅ ✅ -Total energy out ✅ ✅ -Solar power in ✅ ✅ -Solar energy in ✅ ✅ -Individual solar power in ✅ ✅ -Battery power in/out ✅ ✅ -Battery energy in ✅ ✅ -Battery energy out ✅ ✅ -Battery percentage ✅ ✅ -Battery percentage aggregate ✅ ✅ -Expansion battery percentage ❌ ❌ -Charging status ✅ ❌ -Battery health ❌ ✅ -Expansion battery health ❌ ❌ -Temperature ✅ ✅ -Temperature unit ✅ ❌ -Expansion battery temperature ❌ ❌ -Battery heating ✅ ❌ -Batter heating power ❌ ❌ -Grid status ✅ ❌ -Grid power in/out ❔ ✅ -Grid to Home power ✅ ✅ -PV to Grid power ✅ ❌ -Grid import energy ✅ ✅ -Grid export energy ✅ ✅ -Grid export disable/enable ❌ ❌ -House demand ✅ ✅ -House consumption ❌ ✅ -Consumed energy ✅ ❌ -Error codes ✅ ❌ -Max load ✅ ❌ -Usage mode ✅ ❌ -Presets ❌ ❌ -Light mode ✅ ❌ -PV limitations ❌ ❌ -PV panel power ✅ ❌ -AC limitations ❌ ❌ -Software version ✅ ❌ -Software version controller ✅ ❌ -Software version expansion ✅ ❌ -Serial number ✅ ✅ -Expansion battery serial number ❌ ❌ -================================= ============ ============ +================================= ============ ============ ============ +Parameter Solarbank 1 Solarbank 2 Solarbank 3 +================================= ============ ============ ============ +AC power out ❌ ✅ ❌ +AC power out (sockets) ❌ ✅ ❌ +Total power out ✅ ✅ ✅ +Total energy out ✅ ✅ ✅ +Solar power in ✅ ✅ ✅ +Solar energy in ✅ ✅ ✅ +Individual solar power in ❌ ✅ ✅ +Battery power in ✅ ✅ ✅ +Battery power out ❌ ✅ ✅ +Battery energy in ✅ ✅ ✅ +Battery energy out ✅ ❌ ✅ +Battery charge power ✅ ❌ ❌ +Battery percentage ✅ ✅ ✅ +Battery percentage aggregate ❌ ✅ ✅ +Expansion battery percentage ❌ ❌ ❌ +Charging status ✅ ❌ ❌ +Battery health ❌ ❌ ✅ +Expansion battery health ❌ ❌ ❌ +Schedule ✅ ❌ ❌ +Control schedule ✅ ❌ ❌ +Temperature ✅ ✅ ✅ +Temperature unit ❌ ❌ ❌ +Expansion battery temperature ❌ ❌ ❌ +Battery heating ❌ ❌ ❌ +Batter heating power ❌ ❌ ❌ +Grid status ❌ ❌ ❌ +Grid power in/out ❌ ❌ ✅ +Grid to Home power ❌ ✅ ✅ +PV to Grid power ❌ ✅ ❌ +Grid import energy ❌ ✅ ✅ +Grid export energy ❌ ✅ ✅ +Grid export disable/enable ❌ ❌ ❌ +House demand ❌ ✅ ✅ +House consumption ❌ ❌ ✅ +Consumed energy ❌ ✅ ❌ +Error codes ❌ ❌ ❌ +Max load ❌ ❌ ❌ +Usage mode ❌ ❌ ❌ +Presets ❌ ❌ ❌ +Light mode ❌ ❌ ❌ +PV limitations ❌ ❌ ❌ +AC limitations ✅ ❌ ❌ +Software version ✅ ✅ ❌ +Software version controller ✅ ✅ ❌ +Software version expansion ❌ ✅ ❌ +Hardware version ✅ ❌ ❌ +Serial number ✅ ✅ ✅ +Expansion battery serial number ❌ ❌ ❌ +Inverter brand ✅ ❌ ❌ +Inverter model ✅ ❌ ❌ +================================= ============ ============ ============ Prime charger support diff --git a/docs/source/solarbank1.rst b/docs/source/solarbank1.rst new file mode 100644 index 0000000..0b908b8 --- /dev/null +++ b/docs/source/solarbank1.rst @@ -0,0 +1,9 @@ +Solarbank 1 +=========== + +.. autoclass:: SolixBLE.Solarbank1 + :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: From 974deb55dbbe008716ae9711a5232b31f3cce297 Mon Sep 17 00:00:00 2001 From: Harvey Lelliott <42912136+flip-dots@users.noreply.github.com> Date: Sun, 29 Mar 2026 11:59:39 +0100 Subject: [PATCH 05/12] Export Solarbank1 in API --- SolixBLE/__init__.py | 2 ++ SolixBLE/devices/__init__.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/SolixBLE/__init__.py b/SolixBLE/__init__.py index d6c614f..20a9e2c 100644 --- a/SolixBLE/__init__.py +++ b/SolixBLE/__init__.py @@ -18,6 +18,7 @@ PrimeCharger160w, PrimeCharger250w, PrimePowerBank20k, + Solarbank1, Solarbank2, Solarbank3, ) @@ -43,6 +44,7 @@ "C1000G2", "F2000", "F3800", + "Solarbank1", "Solarbank2", "Solarbank3", "PrimeCharger160w", diff --git a/SolixBLE/devices/__init__.py b/SolixBLE/devices/__init__.py index dbae16a..2ceb488 100644 --- a/SolixBLE/devices/__init__.py +++ b/SolixBLE/devices/__init__.py @@ -16,6 +16,7 @@ from .prime_charger_160w import PrimeCharger160w from .prime_charger_250w import PrimeCharger250w from .prime_power_bank_20k import PrimePowerBank20k +from .solarbank1 import Solarbank1 from .solarbank2 import Solarbank2 from .solarbank3 import Solarbank3 @@ -27,6 +28,7 @@ "C1000G2", "F2000", "F3800", + "Solarbank1", "Solarbank2", "Solarbank3", "PrimeCharger160w", From 549dcad9ac3e71c3aaa83575c8730bf43eff7159 Mon Sep 17 00:00:00 2001 From: Harvey Lelliott <42912136+flip-dots@users.noreply.github.com> Date: Sun, 29 Mar 2026 12:00:45 +0100 Subject: [PATCH 06/12] Run black code formatting --- SolixBLE/devices/solarbank1.py | 81 ++++++++++++++++------------------ 1 file changed, 39 insertions(+), 42 deletions(-) diff --git a/SolixBLE/devices/solarbank1.py b/SolixBLE/devices/solarbank1.py index fdad6f1..c9d035a 100644 --- a/SolixBLE/devices/solarbank1.py +++ b/SolixBLE/devices/solarbank1.py @@ -4,12 +4,14 @@ """ +import struct + from ..const import DEFAULT_METADATA_FLOAT, DEFAULT_METADATA_STRING from ..device import SolixBLEDevice -import struct CMD_SB_SET_SCHEDULE = "405e" + class Solarbank1(SolixBLEDevice): """ SolarBank 1 Power Station. @@ -18,7 +20,7 @@ class Solarbank1(SolixBLEDevice): This model is also known as the A17C0. .. note:: - This model was added using data from anker-solix-api as well as logging the actual anker app. + This model was added using data from anker-solix-api as well as logging the actual anker app. It seems to be working so far, altough not everything has been reverse engineered so far. @@ -116,19 +118,19 @@ def charging_status(self) -> int: @property def current_schedule(self) -> str: """Parse the active daily schedule block(s). - + :returns: A human-readable string describing the current schedule or a message if no schedule is set. """ if self._data is None or "ae" not in self._data: return "No Schedule Set" data = self._data["ae"] - + # Safely extract the raw bytes if isinstance(data, bytes): raw_bytes = data elif isinstance(data, dict): - hex_str = data.get('hex', '') + hex_str = data.get("hex", "") raw_bytes = bytes.fromhex(hex_str) else: return "Invalid data format" @@ -140,20 +142,20 @@ def current_schedule(self) -> str: # We can ignore the first byte (04 header) and loop through the rest periods = [] for i in range(1, len(raw_bytes), 8): - chunk = raw_bytes[i:i+8] - - start_min = int.from_bytes(chunk[0:2], byteorder='little') - end_min = int.from_bytes(chunk[2:4], byteorder='little') - watts = int.from_bytes(chunk[4:6], byteorder='little') - limit = int.from_bytes(chunk[6:8], byteorder='little') + chunk = raw_bytes[i : i + 8] + + start_min = int.from_bytes(chunk[0:2], byteorder="little") + end_min = int.from_bytes(chunk[2:4], byteorder="little") + watts = int.from_bytes(chunk[4:6], byteorder="little") + limit = int.from_bytes(chunk[6:8], byteorder="little") start_time = f"{start_min // 60:02d}:{start_min % 60:02d}" end_time = f"{end_min // 60:02d}:{end_min % 60:02d}" - + periods.append(f"[{start_time}-{end_time} @ {watts}W, Limit: {limit}%]") return " | ".join(periods) - + @property def battery_charge_power(self) -> int: """Battery charging power. @@ -207,7 +209,7 @@ def inverter_brand(self) -> str: if self._data is None: return DEFAULT_METADATA_STRING - return self._parse_string("b7", begin=1) # TODO: Check this later + return self._parse_string("b7", begin=1) # TODO: Check this later @property def inverter_model(self) -> str: @@ -218,7 +220,7 @@ def inverter_model(self) -> str: if self._data is None: return DEFAULT_METADATA_STRING - return self._parse_string("b8", begin=1) # TODO: Check this later + return self._parse_string("b8", begin=1) # TODO: Check this later @property def min_load(self) -> int: @@ -229,18 +231,17 @@ def min_load(self) -> int: if self._data is None: return DEFAULT_METADATA_STRING - return self._parse_int("b9", begin=1) # TODO: Check this later - + return self._parse_int("b9", begin=1) # TODO: Check this later async def set_schedule(self, schedules: list[dict]) -> None: """Set the daily charge/discharge schedule on the Solarbank 1. - + Sends a schedule write command (CMD 0x405e) to the device. The base class ``_send_command`` automatically appends the current session timestamp and handles AES-CBC encryption and framing. - + Each schedule entry is a ``dict`` with the following keys: - + ========= ======= ===================================================== Key Type Description ========= ======= ===================================================== @@ -249,63 +250,59 @@ async def set_schedule(self, schedules: list[dict]) -> None: ``power`` ``int`` Output wattage; use ``0`` for charge-only mode ``soc`` ``int`` Max battery SOC cap as a percentage (e.g. ``80``) ========= ======= ===================================================== - + Pass an empty list to clear/delete all schedules. - + Examples:: - + # Single schedule: charge-only midnight-06:00, cap at 80 % SOC await sb1.set_schedule([ {"start": "00:00", "end": "06:00", "power": 0, "soc": 80} ]) - + # Two back-to-back schedules await sb1.set_schedule([ {"start": "00:00", "end": "06:00", "power": 0, "soc": 80}, {"start": "06:00", "end": "14:30", "power": 240, "soc": 80}, ]) - + # Clear all schedules await sb1.set_schedule([]) - + :param schedules: List of schedule dicts. The device-side upper limit is unknown but confirmed to be at least 10. :raises ValueError: If a time string is not in ``"HH:MM"`` format, or if ``power``/``soc`` values are out of range. :raises ConnectionError: If not connected/negotiated to the device. """ - + for i, s in enumerate(schedules): if not (0 <= s["power"] <= 800): raise ValueError( f"Schedule {i}: power must be 0–800 W, got {s['power']}" ) if not (1 <= s["soc"] <= 100): - raise ValueError( - f"Schedule {i}: soc must be 1–100 %, got {s['soc']}" - ) - + raise ValueError(f"Schedule {i}: soc must be 1–100 %, got {s['soc']}") + def _time_to_minutes(t: str) -> int: """Convert 'HH:MM' string to minutes since midnight.""" try: h, m = t.split(":") return int(h) * 60 + int(m) except (ValueError, AttributeError): - raise ValueError( - f"Time '{t}' is not in HH:MM format" - ) - + raise ValueError(f"Time '{t}' is not in HH:MM format") + # ── Build plaintext TLV payload ──────────────────────────────────── # # Format per field: # LENGTH counts the TYPE byte plus data bytes (i.e. len(DATA) + 1). # # 0xa1 — command marker: no data, type byte 0x21 only - payload = bytes([0xa1, 0x01, 0x21]) - + payload = bytes([0xA1, 0x01, 0x21]) + # 0xa2 — schedule count: type 0x01, 1-byte unsigned int - payload += bytes([0xa2, 0x02, 0x01, len(schedules)]) - + payload += bytes([0xA2, 0x02, 0x01, len(schedules)]) + # 0xa3 — schedule blocks: type 0x04, then N × 8-byte entries # Each entry: [start_min u16le][end_min u16le][power_W u16le][soc_% u16le] schedule_bytes = b"" @@ -317,8 +314,8 @@ def _time_to_minutes(t: str) -> int: s["power"], s["soc"], ) - + # LENGTH = 1 (type byte) + len(schedule_bytes) - payload += bytes([0xa3, 1 + len(schedule_bytes), 0x04]) + schedule_bytes - + payload += bytes([0xA3, 1 + len(schedule_bytes), 0x04]) + schedule_bytes + await self._send_command(bytes.fromhex(CMD_SB_SET_SCHEDULE), payload) From 13a57b8aac031f815e344e29b96b422f50127196 Mon Sep 17 00:00:00 2001 From: Harvey Lelliott <42912136+flip-dots@users.noreply.github.com> Date: Sun, 29 Mar 2026 12:03:25 +0100 Subject: [PATCH 07/12] Fix type issues --- SolixBLE/devices/solarbank1.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/SolixBLE/devices/solarbank1.py b/SolixBLE/devices/solarbank1.py index c9d035a..4093d08 100644 --- a/SolixBLE/devices/solarbank1.py +++ b/SolixBLE/devices/solarbank1.py @@ -6,7 +6,11 @@ import struct -from ..const import DEFAULT_METADATA_FLOAT, DEFAULT_METADATA_STRING +from ..const import ( + DEFAULT_METADATA_FLOAT, + DEFAULT_METADATA_INT, + DEFAULT_METADATA_STRING, +) from ..device import SolixBLEDevice CMD_SB_SET_SCHEDULE = "405e" @@ -86,7 +90,7 @@ def temperature(self) -> int: return self._parse_int("aa", begin=1, signed=True) @property - def solar_power_in(self) -> int: + def solar_power_in(self) -> float: """Total Solar Power In. :returns: Total solar power in or default float value. @@ -103,7 +107,7 @@ def output_power(self) -> int: :returns: Total power out in watts or default float value. """ if self._data is None: - return DEFAULT_METADATA_FLOAT + return DEFAULT_METADATA_INT return self._parse_int("ac", begin=1) @@ -157,7 +161,7 @@ def current_schedule(self) -> str: return " | ".join(periods) @property - def battery_charge_power(self) -> int: + def battery_charge_power(self) -> float: """Battery charging power. :returns: Total battery power in or default float value. @@ -168,7 +172,7 @@ def battery_charge_power(self) -> int: return self._parse_int("b0", begin=1) / 100.0 @property - def pv_yield(self) -> int: + def pv_yield(self) -> float: """Solar power generated. :returns: Total solar power generated or default float value. @@ -179,7 +183,7 @@ def pv_yield(self) -> int: return self._parse_int("b1", begin=1) / 10000.0 @property - def charged_energy(self) -> int: + def charged_energy(self) -> float: """Probably aggregated energy charged in Wh? :returns: Charged energy or default float value. @@ -190,7 +194,7 @@ def charged_energy(self) -> int: return self._parse_int("b2", begin=1) / 10000.0 @property - def output_energy(self) -> int: + def output_energy(self) -> float: """Output energy. :returns: Total energy output or default float value. From d35c47b29553079777c08f78eea2ab2d9cd5d9e5 Mon Sep 17 00:00:00 2001 From: smariacher Date: Wed, 1 Apr 2026 23:10:22 +0200 Subject: [PATCH 08/12] Added ChargingSchedule dataclass, reworked everything reporting or changing schedule to use ChargingSchedule, used ChargingStatus inside charging_status property instead of int --- SolixBLE/devices/solarbank1.py | 208 +++++++++++++++++++++++---------- 1 file changed, 148 insertions(+), 60 deletions(-) diff --git a/SolixBLE/devices/solarbank1.py b/SolixBLE/devices/solarbank1.py index 4093d08..0a496b3 100644 --- a/SolixBLE/devices/solarbank1.py +++ b/SolixBLE/devices/solarbank1.py @@ -5,6 +5,7 @@ """ import struct +from dataclasses import dataclass from ..const import ( DEFAULT_METADATA_FLOAT, @@ -12,9 +13,107 @@ DEFAULT_METADATA_STRING, ) from ..device import SolixBLEDevice +from ..states import ChargingStatus CMD_SB_SET_SCHEDULE = "405e" +@dataclass +class ChargingSchedule: + start_time: int + """ + Start of schedule in minutes since midnight. + """ + end_time: int + """ + End of schedule in minutes since midnight. + """ + output_wattage: int + + max_soc : int + """ + Maximum SOC before Solarbank (presumably) goes into passthrough mode. + """ + + def __str__(self) -> str: + """Convert the integer minutes back to HH:MM format for a nice display""" + start_time_str = f"{self.start_time // 60:02d}:{self.start_time % 60:02d}" + end_time_str = f"{self.end_time // 60:02d}:{self.end_time % 60:02d}" + + return ( + f"Charging Schedule:\n" + f" Time: {start_time_str} - {end_time_str}\n" + f" Wattage: {self.output_wattage}W\n" + f" Max SOC: {self.max_soc}%" + ) + + def __post_init__(self): + MIN_WATTAGE, MAX_WATTAGE = 0, 800 + MIN_SOC, MAX_SOC = 0, 100 + + if not (MIN_WATTAGE <= self.output_wattage <= MAX_WATTAGE): + raise ValueError( + f"Invalid output_wattage: {self.output_wattage}. " + f"Must be between {MIN_WATTAGE} and {MAX_WATTAGE}." + ) + + if not (MIN_SOC <= self.max_soc <= MAX_SOC): + raise ValueError( + f"Invalid max_soc: {self.max_soc}. " + f"Must be between {MIN_SOC} and {MAX_SOC}." + ) + + if not (self.end_time - self.start_time > 0): + raise ValueError( + f"Invalid time frame: Start: {self.start_time}, End: {self.end_time}. " + f"Start time must be smaller than end time." + ) + + if not (self.start_time >= 0 and self.start_time <= 1440): + raise ValueError( + f"Invalid start time: {self.start_time}. " + f"Start time cannot be less than 0 minutes or greater than 1440 minutes (24 hours)" + ) + + if not (self.end_time >= 0 and self.end_time <= 1440): + raise ValueError( + f"Invalid start time: {self.end_time}. " + f"End time cannot be less than 0 minutes or greater than 1440 minutes (24 hours)" + ) + + @classmethod + def from_time_strings(cls, start: str, end: str, output_wattage: int, max_soc: int) -> "ChargingSchedule": + """Alternative constructor to create a schedule using HH:MM string formats.""" + return cls( + start_time=cls.time_from_string(start), + end_time=cls.time_from_string(end), + output_wattage=output_wattage, + max_soc=max_soc + ) + + @staticmethod + def time_from_string(time: str) -> int: + """ + Converts a string time in 24-hour HH:MM format to minutes since midnight. + + :param time: Time string in 24-hour HH:MM format. + :returns: Minutes since midnight. + """ + + hours_str, minutes_str = time.split(":") + hours = int(hours_str) + minutes = int(minutes_str) + + if hours > 24: + raise ValueError(f"Invalid hour value: {hours}. Hour must be between 0 and 24.") + + if minutes > 59: + raise ValueError(f"Invalid minute value: {minutes}. Minute must be between 0 and 59.") + + if hours == 24 and minutes != 0: + raise ValueError(f"Invalid time string: {time}. If hour is set to 24 then minutes may only be 0.") + + return hours * 60 + minutes + class Solarbank1(SolixBLEDevice): """ @@ -24,7 +123,7 @@ class Solarbank1(SolixBLEDevice): This model is also known as the A17C0. .. note:: - This model was added using data from anker-solix-api as well as logging the actual anker app. + This model was added using data from anker-solix-api as well as logging the actual anker app as described in the SolixBLE docs. It seems to be working so far, altough not everything has been reverse engineered so far. @@ -32,6 +131,8 @@ class Solarbank1(SolixBLEDevice): _EXPECTED_TELEMETRY_LENGTH: int = 253 + ChargingSchedule = ChargingSchedule # Added so the user only has to do one import + @property def serial_number(self) -> str: """Device serial number. @@ -112,21 +213,34 @@ def output_power(self) -> int: return self._parse_int("ac", begin=1) @property - def charging_status(self) -> int: - """Charging status. - - :returns: Charging status or default int value. + def charging_status(self) -> ChargingStatus: + """Retrieve the current charging status of the device. + Parses the charging status from the device data. If device data is unavailable + or does not contain charging status information, returns UNKNOWN. + + :returns: ChargingStatus enum member representing the current charging state + (e.g., CHARGING, DISCHARGING, IDLE, or UNKNOWN if status cannot be determined). """ - return self._parse_int("ad", begin=1) + + if self._data is None or "ad" not in self._data: + return ChargingStatus.UNKNOWN + + value = self._parse_int("ad", begin=1) + + try: + return ChargingStatus(value) + except ValueError: + return ChargingStatus.UNKNOWN @property - def current_schedule(self) -> str: + def current_schedule(self) -> list[ChargingSchedule]: """Parse the active daily schedule block(s). - :returns: A human-readable string describing the current schedule or a message if no schedule is set. + :returns: A list of ChargingSchedule objects representing the current schedule, + or an empty list if no schedule is set. """ if self._data is None or "ae" not in self._data: - return "No Schedule Set" + return [] data = self._data["ae"] @@ -137,14 +251,13 @@ def current_schedule(self) -> str: hex_str = data.get("hex", "") raw_bytes = bytes.fromhex(hex_str) else: - return "Invalid data format" + return [] # A valid payload has a 1-byte header, plus N * 8-byte blocks if len(raw_bytes) < 9 or (len(raw_bytes) - 1) % 8 != 0: - return f"Unknown structure: {raw_bytes.hex()}" + return [] - # We can ignore the first byte (04 header) and loop through the rest - periods = [] + schedules = [] for i in range(1, len(raw_bytes), 8): chunk = raw_bytes[i : i + 8] @@ -153,12 +266,16 @@ def current_schedule(self) -> str: watts = int.from_bytes(chunk[4:6], byteorder="little") limit = int.from_bytes(chunk[6:8], byteorder="little") - start_time = f"{start_min // 60:02d}:{start_min % 60:02d}" - end_time = f"{end_min // 60:02d}:{end_min % 60:02d}" - - periods.append(f"[{start_time}-{end_time} @ {watts}W, Limit: {limit}%]") + schedules.append( + ChargingSchedule( + start_time=start_min, + end_time=end_min, + output_wattage=watts, + max_soc=limit, + ) + ) - return " | ".join(periods) + return schedules @property def battery_charge_power(self) -> float: @@ -213,7 +330,7 @@ def inverter_brand(self) -> str: if self._data is None: return DEFAULT_METADATA_STRING - return self._parse_string("b7", begin=1) # TODO: Check this later + return self._parse_string("b7", begin=1) @property def inverter_model(self) -> str: @@ -224,7 +341,7 @@ def inverter_model(self) -> str: if self._data is None: return DEFAULT_METADATA_STRING - return self._parse_string("b8", begin=1) # TODO: Check this later + return self._parse_string("b8", begin=1) @property def min_load(self) -> int: @@ -233,69 +350,40 @@ def min_load(self) -> int: :returns: Don't know yet or default str value. """ if self._data is None: - return DEFAULT_METADATA_STRING + return DEFAULT_METADATA_INT return self._parse_int("b9", begin=1) # TODO: Check this later - async def set_schedule(self, schedules: list[dict]) -> None: + async def set_schedule(self, schedules: list[ChargingSchedule]) -> None: """Set the daily charge/discharge schedule on the Solarbank 1. Sends a schedule write command (CMD 0x405e) to the device. The base class ``_send_command`` automatically appends the current session timestamp and handles AES-CBC encryption and framing. - Each schedule entry is a ``dict`` with the following keys: - - ========= ======= ===================================================== - Key Type Description - ========= ======= ===================================================== - ``start`` ``str`` Start time in ``"HH:MM"`` format, e.g. ``"00:00"`` - ``end`` ``str`` End time in ``"HH:MM"`` format, e.g. ``"06:00"`` - ``power`` ``int`` Output wattage; use ``0`` for charge-only mode - ``soc`` ``int`` Max battery SOC cap as a percentage (e.g. ``80``) - ========= ======= ===================================================== - Pass an empty list to clear/delete all schedules. Examples:: # Single schedule: charge-only midnight-06:00, cap at 80 % SOC await sb1.set_schedule([ - {"start": "00:00", "end": "06:00", "power": 0, "soc": 80} + ChargingSchedule(start=0, end=360, output_wattage=0, max_soc=80) ]) # Two back-to-back schedules await sb1.set_schedule([ - {"start": "00:00", "end": "06:00", "power": 0, "soc": 80}, - {"start": "06:00", "end": "14:30", "power": 240, "soc": 80}, + ChargingSchedule(start=0, end=360, output_wattage=0, max_soc=80), + ChargingSchedule(start=360, end=870, output_wattage=240, max_soc=80), ]) # Clear all schedules await sb1.set_schedule([]) - :param schedules: List of schedule dicts. The device-side upper limit + :param schedules: List of ChargingSchedule objects. The device-side upper limit is unknown but confirmed to be at least 10. - :raises ValueError: If a time string is not in ``"HH:MM"`` format, or - if ``power``/``soc`` values are out of range. :raises ConnectionError: If not connected/negotiated to the device. """ - for i, s in enumerate(schedules): - if not (0 <= s["power"] <= 800): - raise ValueError( - f"Schedule {i}: power must be 0–800 W, got {s['power']}" - ) - if not (1 <= s["soc"] <= 100): - raise ValueError(f"Schedule {i}: soc must be 1–100 %, got {s['soc']}") - - def _time_to_minutes(t: str) -> int: - """Convert 'HH:MM' string to minutes since midnight.""" - try: - h, m = t.split(":") - return int(h) * 60 + int(m) - except (ValueError, AttributeError): - raise ValueError(f"Time '{t}' is not in HH:MM format") - # ── Build plaintext TLV payload ──────────────────────────────────── # # Format per field: @@ -310,13 +398,13 @@ def _time_to_minutes(t: str) -> int: # 0xa3 — schedule blocks: type 0x04, then N × 8-byte entries # Each entry: [start_min u16le][end_min u16le][power_W u16le][soc_% u16le] schedule_bytes = b"" - for s in schedules: + for schedule in schedules: schedule_bytes += struct.pack( " Date: Sun, 26 Apr 2026 14:23:08 +0200 Subject: [PATCH 09/12] Update to main class, documentation and resolving any previous comments --- SolixBLE/devices/solarbank1.py | 86 ++++++++++++++++++++-------------- docs/source/index.rst | 2 +- docs/source/solarbank1.rst | 11 ++++- tests/test_devices.py | 66 ++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 38 deletions(-) diff --git a/SolixBLE/devices/solarbank1.py b/SolixBLE/devices/solarbank1.py index 0a496b3..f3bb63a 100644 --- a/SolixBLE/devices/solarbank1.py +++ b/SolixBLE/devices/solarbank1.py @@ -4,6 +4,7 @@ """ +import logging import struct from dataclasses import dataclass @@ -17,8 +18,14 @@ CMD_SB_SET_SCHEDULE = "405e" +MIN_WATTAGE, MAX_WATTAGE = 0, 800 +MIN_SOC, MAX_SOC = 10, 100 + +_LOGGER = logging.getLogger(__name__) + + @dataclass -class ChargingSchedule: +class FamilyLoadSchedule: start_time: int """ Start of schedule in minutes since midnight. @@ -29,7 +36,7 @@ class ChargingSchedule: """ output_wattage: int - max_soc : int + max_soc: int """ Maximum SOC before Solarbank (presumably) goes into passthrough mode. """ @@ -38,7 +45,7 @@ def __str__(self) -> str: """Convert the integer minutes back to HH:MM format for a nice display""" start_time_str = f"{self.start_time // 60:02d}:{self.start_time % 60:02d}" end_time_str = f"{self.end_time // 60:02d}:{self.end_time % 60:02d}" - + return ( f"Charging Schedule:\n" f" Time: {start_time_str} - {end_time_str}\n" @@ -47,33 +54,30 @@ def __str__(self) -> str: ) def __post_init__(self): - MIN_WATTAGE, MAX_WATTAGE = 0, 800 - MIN_SOC, MAX_SOC = 0, 100 - if not (MIN_WATTAGE <= self.output_wattage <= MAX_WATTAGE): raise ValueError( f"Invalid output_wattage: {self.output_wattage}. " f"Must be between {MIN_WATTAGE} and {MAX_WATTAGE}." ) - + if not (MIN_SOC <= self.max_soc <= MAX_SOC): raise ValueError( f"Invalid max_soc: {self.max_soc}. " f"Must be between {MIN_SOC} and {MAX_SOC}." ) - + if not (self.end_time - self.start_time > 0): raise ValueError( f"Invalid time frame: Start: {self.start_time}, End: {self.end_time}. " f"Start time must be smaller than end time." ) - + if not (self.start_time >= 0 and self.start_time <= 1440): raise ValueError( f"Invalid start time: {self.start_time}. " f"Start time cannot be less than 0 minutes or greater than 1440 minutes (24 hours)" ) - + if not (self.end_time >= 0 and self.end_time <= 1440): raise ValueError( f"Invalid start time: {self.end_time}. " @@ -81,13 +85,15 @@ def __post_init__(self): ) @classmethod - def from_time_strings(cls, start: str, end: str, output_wattage: int, max_soc: int) -> "ChargingSchedule": + def from_time_strings( + cls, start: str, end: str, output_wattage: int, max_soc: int + ) -> "FamilyLoadSchedule": """Alternative constructor to create a schedule using HH:MM string formats.""" return cls( start_time=cls.time_from_string(start), end_time=cls.time_from_string(end), output_wattage=output_wattage, - max_soc=max_soc + max_soc=max_soc, ) @staticmethod @@ -102,15 +108,21 @@ def time_from_string(time: str) -> int: hours_str, minutes_str = time.split(":") hours = int(hours_str) minutes = int(minutes_str) - - if hours > 24: - raise ValueError(f"Invalid hour value: {hours}. Hour must be between 0 and 24.") - - if minutes > 59: - raise ValueError(f"Invalid minute value: {minutes}. Minute must be between 0 and 59.") - + + if hours > 24 or hours < 0: + raise ValueError( + f"Invalid hour value: {hours}. Hour must be between 0 and 24." + ) + + if minutes > 59 or minutes < 0: + raise ValueError( + f"Invalid minute value: {minutes}. Minute must be between 0 and 59." + ) + if hours == 24 and minutes != 0: - raise ValueError(f"Invalid time string: {time}. If hour is set to 24 then minutes may only be 0.") + raise ValueError( + f"Invalid time string: {time}. If hour is set to 24 then minutes may only be 0." + ) return hours * 60 + minutes @@ -131,7 +143,9 @@ class Solarbank1(SolixBLEDevice): _EXPECTED_TELEMETRY_LENGTH: int = 253 - ChargingSchedule = ChargingSchedule # Added so the user only has to do one import + FamilyLoadSchedule = ( + FamilyLoadSchedule # Added so the user only has to do one import + ) @property def serial_number(self) -> str: @@ -199,7 +213,7 @@ def solar_power_in(self) -> float: if self._data is None: return DEFAULT_METADATA_FLOAT - return self._parse_int("ab", begin=1) / 10.0 + return self._parse_int("ab", begin=1) @property def output_power(self) -> int: @@ -217,7 +231,7 @@ def charging_status(self) -> ChargingStatus: """Retrieve the current charging status of the device. Parses the charging status from the device data. If device data is unavailable or does not contain charging status information, returns UNKNOWN. - + :returns: ChargingStatus enum member representing the current charging state (e.g., CHARGING, DISCHARGING, IDLE, or UNKNOWN if status cannot be determined). """ @@ -226,17 +240,20 @@ def charging_status(self) -> ChargingStatus: return ChargingStatus.UNKNOWN value = self._parse_int("ad", begin=1) - + try: return ChargingStatus(value) except ValueError: + _LOGGER.exception( + f"Invalid ChargingStatus value {value} received from device; returning ChargingStatus.UNKNOWN." + ) return ChargingStatus.UNKNOWN @property - def current_schedule(self) -> list[ChargingSchedule]: - """Parse the active daily schedule block(s). + def family_load_schedule(self) -> list[FamilyLoadSchedule]: + """Parse the active daily family load schedule block(s). - :returns: A list of ChargingSchedule objects representing the current schedule, + :returns: A list of FamilyLoadSchedule objects representing the current schedule, or an empty list if no schedule is set. """ if self._data is None or "ae" not in self._data: @@ -247,9 +264,6 @@ def current_schedule(self) -> list[ChargingSchedule]: # Safely extract the raw bytes if isinstance(data, bytes): raw_bytes = data - elif isinstance(data, dict): - hex_str = data.get("hex", "") - raw_bytes = bytes.fromhex(hex_str) else: return [] @@ -267,7 +281,7 @@ def current_schedule(self) -> list[ChargingSchedule]: limit = int.from_bytes(chunk[6:8], byteorder="little") schedules.append( - ChargingSchedule( + FamilyLoadSchedule( start_time=start_min, end_time=end_min, output_wattage=watts, @@ -354,7 +368,7 @@ def min_load(self) -> int: return self._parse_int("b9", begin=1) # TODO: Check this later - async def set_schedule(self, schedules: list[ChargingSchedule]) -> None: + async def set_schedule(self, schedules: list[FamilyLoadSchedule]) -> None: """Set the daily charge/discharge schedule on the Solarbank 1. Sends a schedule write command (CMD 0x405e) to the device. @@ -367,19 +381,19 @@ async def set_schedule(self, schedules: list[ChargingSchedule]) -> None: # Single schedule: charge-only midnight-06:00, cap at 80 % SOC await sb1.set_schedule([ - ChargingSchedule(start=0, end=360, output_wattage=0, max_soc=80) + FamilyLoadSchedule(start=0, end=360, output_wattage=0, max_soc=80) ]) # Two back-to-back schedules await sb1.set_schedule([ - ChargingSchedule(start=0, end=360, output_wattage=0, max_soc=80), - ChargingSchedule(start=360, end=870, output_wattage=240, max_soc=80), + FamilyLoadSchedule(start=0, end=360, output_wattage=0, max_soc=80), + FamilyLoadSchedule(start=360, end=870, output_wattage=240, max_soc=80), ]) # Clear all schedules await sb1.set_schedule([]) - :param schedules: List of ChargingSchedule objects. The device-side upper limit + :param schedules: List of FamilyLoadSchedule objects. The device-side upper limit is unknown but confirmed to be at least 10. :raises ConnectionError: If not connected/negotiated to the device. """ diff --git a/docs/source/index.rst b/docs/source/index.rst index 95f4966..26134ef 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -96,7 +96,7 @@ Battery charge power ✅ ❌ ❌ Battery percentage ✅ ✅ ✅ Battery percentage aggregate ❌ ✅ ✅ Expansion battery percentage ❌ ❌ ❌ -Charging status ✅ ❌ ❌ +Charging status ❌ ❌ ❌ Battery health ❌ ❌ ✅ Expansion battery health ❌ ❌ ❌ Schedule ✅ ❌ ❌ diff --git a/docs/source/solarbank1.rst b/docs/source/solarbank1.rst index 0b908b8..4dcd0a0 100644 --- a/docs/source/solarbank1.rst +++ b/docs/source/solarbank1.rst @@ -1,9 +1,18 @@ Solarbank 1 =========== +.. warning:: + **Known BLE Quirks & Limitations** + + When communicating with the Solarbank 1 via BLE, please be aware of the following known behaviors: + + * **State Reporting:** The charging schedule currently always reports its state as *discharging*, regardless of the actual physical state. + * **Minimum Output:** When controlling the device via BLE, the minimum configurable output is hard-capped at **100W**. + * **Secondary Schedule Control:** There is an additional schedule control that forces the system to discharge *only* via the battery, completely ignoring solar input. Strangely, when this mode is active, the protocol reports a ``max_soc`` value of **336**. + .. autoclass:: SolixBLE.Solarbank1 :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: + :no-index: \ No newline at end of file diff --git a/tests/test_devices.py b/tests/test_devices.py index 5528069..fa4b282 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -25,6 +25,7 @@ PrimeCharger160w, PrimeDevice, PrimePowerBank20k, + Solarbank1, Solarbank2, SolixBLEDevice, TemperatureUnit, @@ -1491,3 +1492,68 @@ async def test_bad_values( assert ( getattr(device, class_property) == expected_value ), f"Mismatch for property '{class_property}'!" + + for error_message in errors: + assert error_message in caplog.text + + +def test_sb1_family_load_schedule_valid_creation(): + """Test that a valid FamilyLoadSchedule can be created without raising errors.""" + schedule = Solarbank1.FamilyLoadSchedule( + start_time=60, end_time=120, output_wattage=110, max_soc=20 + ) + assert schedule.start_time == 60 + assert schedule.end_time == 120 + assert schedule.output_wattage == 110 + assert schedule.max_soc == 20 + + +def test_sb1_family_load_schedule_from_time_strings_valid(): + """Test the alternative constructor with valid string times.""" + schedule = Solarbank1.FamilyLoadSchedule.from_time_strings( + start="01:30", end="14:45", output_wattage=360, max_soc=100 + ) + assert schedule.start_time == 360 + assert schedule.end_time == 885 + assert schedule.output_wattage == 360 + assert schedule.max_soc == 100 + + +@pytest.mark.parametrize( + "start, end, wattage, soc, expected_error_msg", + [ + (100, 200, -1, 50, "Invalid output_wattage"), + (100, 200, 800 + 1, 50, "Invalid output_wattage"), + (100, 200, 100, 10 - 1, "Invalid max_soc"), + (100, 200, 100, 100 + 1, "Invalid max_soc"), + (200, 100, 100, 50, "Start time must be smaller than end time"), + (100, 100, 100, 50, "Start time must be smaller than end time"), + (-1, 200, 100, 50, "Invalid start time"), + (1441, 1500, 100, 50, "Invalid start time"), + (100, 1441, 100, 50, "Invalid start time"), + ], +) +def test_sb1_family_load_schedule_post_init_errors( + start, end, wattage, soc, expected_error_msg +): + """Test that invalid parameters raise ValueErrors.""" + with pytest.raises(ValueError, match=expected_error_msg): + Solarbank1.FamilyLoadSchedule( + start_time=start, end_time=end, output_wattage=wattage, max_soc=soc + ) + + +@pytest.mark.parametrize( + "time_str, expected_error_msg", + [ + ("25:00", "Invalid hour value"), + ("-1:00", "Invalid hour value"), + ("12:60", "Invalid minute value"), + ("12:-1", "Invalid minute value"), + ("24:01", "If hour is set to 24 then minutes may only be 0"), + ], +) +def test_sb1_family_load_schedule_time_from_string_errors(time_str, expected_error_msg): + """Test that invalid time strings raise ValueErrors""" + with pytest.raises(ValueError, match=expected_error_msg): + Solarbank1.FamilyLoadSchedule.time_from_string(time_str) From 5ecb5a7e597eb072254ca88eefdeadce6a3d1398 Mon Sep 17 00:00:00 2001 From: smariacher Date: Tue, 7 Jul 2026 12:34:18 +0200 Subject: [PATCH 10/12] removed unknown property, made technical comments collapsible, used parametrisation for test_sb1_family_load_schedule_valid_creation(), added tests for family_load_schedule parsing from and to BLE device --- SolixBLE/devices/solarbank1.py | 20 ++---- tests/test_devices.py | 125 +++++++++++++++++++++++++++++++-- 2 files changed, 124 insertions(+), 21 deletions(-) diff --git a/SolixBLE/devices/solarbank1.py b/SolixBLE/devices/solarbank1.py index f3bb63a..6ca2737 100644 --- a/SolixBLE/devices/solarbank1.py +++ b/SolixBLE/devices/solarbank1.py @@ -357,23 +357,15 @@ def inverter_model(self) -> str: return self._parse_string("b8", begin=1) - @property - def min_load(self) -> int: - """Maybe minimum wattage the battery will output? - - :returns: Don't know yet or default str value. - """ - if self._data is None: - return DEFAULT_METADATA_INT - - return self._parse_int("b9", begin=1) # TODO: Check this later - async def set_schedule(self, schedules: list[FamilyLoadSchedule]) -> None: """Set the daily charge/discharge schedule on the Solarbank 1. - Sends a schedule write command (CMD 0x405e) to the device. - The base class ``_send_command`` automatically appends the current - session timestamp and handles AES-CBC encryption and framing. + .. note:: + :collapsible: closed + + Sends a schedule write command (CMD 0x405e) to the device. + The base class ``_send_command`` automatically appends the current + session timestamp and handles AES-CBC encryption and framing. Pass an empty list to clear/delete all schedules. diff --git a/tests/test_devices.py b/tests/test_devices.py index fa4b282..49ecc10 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -1496,16 +1496,33 @@ async def test_bad_values( for error_message in errors: assert error_message in caplog.text +@pytest.mark.parametrize( + "start_time, end_time, output_wattage, max_soc", + [ + (0, 60, 100, 20), + (60, 120, 200, 50), + (120, 180, 300, 80), + (180, 240, 400, 100), + (240, 300, 500, 30), + (300, 360, 600, 100), + ], +) -def test_sb1_family_load_schedule_valid_creation(): +def test_sb1_family_load_schedule_valid_creation( + start_time, end_time, output_wattage, max_soc +): """Test that a valid FamilyLoadSchedule can be created without raising errors.""" schedule = Solarbank1.FamilyLoadSchedule( - start_time=60, end_time=120, output_wattage=110, max_soc=20 + start_time=start_time, + end_time=end_time, + output_wattage=output_wattage, + max_soc=max_soc ) - assert schedule.start_time == 60 - assert schedule.end_time == 120 - assert schedule.output_wattage == 110 - assert schedule.max_soc == 20 + + assert schedule.start_time == start_time + assert schedule.end_time == end_time + assert schedule.output_wattage == output_wattage + assert schedule.max_soc == max_soc def test_sb1_family_load_schedule_from_time_strings_valid(): @@ -1513,7 +1530,7 @@ def test_sb1_family_load_schedule_from_time_strings_valid(): schedule = Solarbank1.FamilyLoadSchedule.from_time_strings( start="01:30", end="14:45", output_wattage=360, max_soc=100 ) - assert schedule.start_time == 360 + assert schedule.start_time == 90 assert schedule.end_time == 885 assert schedule.output_wattage == 360 assert schedule.max_soc == 100 @@ -1557,3 +1574,97 @@ def test_sb1_family_load_schedule_time_from_string_errors(time_str, expected_err """Test that invalid time strings raise ValueErrors""" with pytest.raises(ValueError, match=expected_error_msg): Solarbank1.FamilyLoadSchedule.time_from_string(time_str) + +@pytest.mark.asyncio +async def test_sb1_set_schedule_bytes() -> None: + """set_schedule builds the correct TLV payload byte-wise. + .. note:: + :collapsible: closed + + Payload format: + 0xa1: command marker -> a1 01 21 + 0xa2: schedule count -> a2 02 01 + 0xa3: schedule blocks -> a3 <1+8*N> 04 + N*(start end power soc, all u16le) + """ + device = Solarbank1(MOCK_BLE_DEVICE) + device._send_command = mock.AsyncMock() + + # ── Empty list: count 0, block length = 1 (type byte only) ── + await device.set_schedule([]) + device._send_command.assert_awaited_once_with( + bytes.fromhex("405e"), + bytes.fromhex("a10121" "a2020100" "a30104"), + ) + device._send_command.reset_mock() + + # ── Single schedule: start=0, end=360, wattage=0, soc=80 ── + # 0000 6801 0000 5000 (u16le: 0, 360=0x0168, 0, 80=0x50) + # block length = 1 + 8 = 9 = 0x09 + await device.set_schedule( + [device.FamilyLoadSchedule(start_time=0, end_time=360, output_wattage=0, max_soc=80)] + ) + device._send_command.assert_awaited_once_with( + bytes.fromhex("405e"), + bytes.fromhex( + "a10121" + "a2020101" + "a30904" "0000" "6801" "0000" "5000" + ), + ) + device._send_command.reset_mock() + + # ── Two schedules ── + # #1: 0000 6801 0000 5000 + # #2: start=360(6801) end=870(0366) wattage=240(00f0) soc=80(0050) + # block length = 1 + 16 = 17 = 0x11 + await device.set_schedule( + [ + device.FamilyLoadSchedule(start_time=0, end_time=360, output_wattage=0, max_soc=80), + device.FamilyLoadSchedule( + start_time=360, end_time=870, output_wattage=240, max_soc=80 + ), + ] + ) + device._send_command.assert_awaited_once_with( + bytes.fromhex("405e"), + bytes.fromhex( + "a10121" + "a2020102" + "a31104" + "0000" "6801" "0000" "5000" + "6801" "6603" "f000" "5000" + ), + ) + +def test_sb1_family_load_schedule_bytes() -> None: + """family_load_schedule parses the 'ae' TLV payload into FamilyLoadSchedule objects. + + Read-side counterpart to test_sb1_set_schedule_bytes above + """ + device = Solarbank1(MOCK_BLE_DEVICE) + + # ── No schedule set: 'ae' key absent -> empty list ── + assert device.family_load_schedule == [] + + # ── Single schedule: start=0, end=360, wattage=0, soc=80 ── + device._data = device._parse_payload( + bytes.fromhex("ae09" "04" "0000" "6801" "0000" "5000") + ) + assert device.family_load_schedule == [ + device.FamilyLoadSchedule(start_time=0, end_time=360, output_wattage=0, max_soc=80), + ] + + # ── Two schedules ── + device._data = device._parse_payload( + bytes.fromhex( + "ae11" "04" + "0000" "6801" "0000" "5000" + "6801" "6603" "f000" "5000" + ) + ) + assert device.family_load_schedule == [ + device.FamilyLoadSchedule(start_time=0, end_time=360, output_wattage=0, max_soc=80), + device.FamilyLoadSchedule( + start_time=360, end_time=870, output_wattage=240, max_soc=80 + ), + ] \ No newline at end of file From 228b3f7d83130be75f98cd875aa4756d55e65fc8 Mon Sep 17 00:00:00 2001 From: smariacher Date: Wed, 8 Jul 2026 13:08:05 +0200 Subject: [PATCH 11/12] Hopefully fixxed merge conflicts --- SolixBLE/devices/__init__.py | 1 + docs/source/index.rst | 108 +++---- tests/test_devices.py | 554 ++++++++++++++++++++++++++++++++++- 3 files changed, 600 insertions(+), 63 deletions(-) diff --git a/SolixBLE/devices/__init__.py b/SolixBLE/devices/__init__.py index 2ceb488..db597b6 100644 --- a/SolixBLE/devices/__init__.py +++ b/SolixBLE/devices/__init__.py @@ -4,6 +4,7 @@ """ + from .c300 import C300 from .c300dc import C300DC from .c800 import C800 diff --git a/docs/source/index.rst b/docs/source/index.rst index 26134ef..3e85a06 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -78,60 +78,60 @@ Polled status updates ✅ ❌ ✅ ✅ ❌ Solar system support -------------------- -================================= ============ ============ ============ -Parameter Solarbank 1 Solarbank 2 Solarbank 3 -================================= ============ ============ ============ -AC power out ❌ ✅ ❌ -AC power out (sockets) ❌ ✅ ❌ -Total power out ✅ ✅ ✅ -Total energy out ✅ ✅ ✅ -Solar power in ✅ ✅ ✅ -Solar energy in ✅ ✅ ✅ -Individual solar power in ❌ ✅ ✅ -Battery power in ✅ ✅ ✅ -Battery power out ❌ ✅ ✅ -Battery energy in ✅ ✅ ✅ -Battery energy out ✅ ❌ ✅ -Battery charge power ✅ ❌ ❌ -Battery percentage ✅ ✅ ✅ -Battery percentage aggregate ❌ ✅ ✅ -Expansion battery percentage ❌ ❌ ❌ -Charging status ❌ ❌ ❌ -Battery health ❌ ❌ ✅ -Expansion battery health ❌ ❌ ❌ -Schedule ✅ ❌ ❌ -Control schedule ✅ ❌ ❌ -Temperature ✅ ✅ ✅ -Temperature unit ❌ ❌ ❌ -Expansion battery temperature ❌ ❌ ❌ -Battery heating ❌ ❌ ❌ -Batter heating power ❌ ❌ ❌ -Grid status ❌ ❌ ❌ -Grid power in/out ❌ ❌ ✅ -Grid to Home power ❌ ✅ ✅ -PV to Grid power ❌ ✅ ❌ -Grid import energy ❌ ✅ ✅ -Grid export energy ❌ ✅ ✅ -Grid export disable/enable ❌ ❌ ❌ -House demand ❌ ✅ ✅ -House consumption ❌ ❌ ✅ -Consumed energy ❌ ✅ ❌ -Error codes ❌ ❌ ❌ -Max load ❌ ❌ ❌ -Usage mode ❌ ❌ ❌ -Presets ❌ ❌ ❌ -Light mode ❌ ❌ ❌ -PV limitations ❌ ❌ ❌ -AC limitations ✅ ❌ ❌ -Software version ✅ ✅ ❌ -Software version controller ✅ ✅ ❌ -Software version expansion ❌ ✅ ❌ -Hardware version ✅ ❌ ❌ -Serial number ✅ ✅ ✅ -Expansion battery serial number ❌ ❌ ❌ -Inverter brand ✅ ❌ ❌ -Inverter model ✅ ❌ ❌ -================================= ============ ============ ============ +=================================== =========== =========== =========== +Parameter Solarbank 1 Solarbank 2 Solarbank 3 +=================================== =========== =========== =========== +AC power out ❌ ✅ ❌ +AC power out (sockets) ❌ ✅ ❌ +Total power out ✅ ✅ ✅ +Total energy out ✅ ✅ ✅ +Solar power in ✅ ✅ ✅ +Solar energy in ✅ ✅ ✅ +Individual solar power in ❌ ✅ ✅ +Battery power in/out ✅ ✅ ✅ +Battery energy in ✅ ✅ ✅ +Battery energy out ✅ ✅ ✅ +Battery charge power ✅ ❌ ❌ +Battery percentage ✅ ✅ ✅ +Battery percentage aggregate ❌ ✅ ✅ +Expansion battery percentage ❌ ❌ ❌ +Charging status ❌ ✅ ❌ +Battery health ❌ ❌ ✅ +Expansion battery health ❌ ❌ ❌ +Schedule ✅ ❌ ❌ +Control schedule ✅ ❌ ❌ +Temperature ✅ ✅ ✅ +Temperature unit ❌ ✅ ❌ +Expansion battery temperature ❌ ❌ ❌ +Battery heating ❌ ✅ ❌ +Batter heating power ❌ ❌ ❌ +Grid status ❌ ✅ ❌ +Grid power in/out ❌ ❔ ✅ +Grid to Home power ❌ ✅ ✅ +PV to Grid power ❌ ✅ ❌ +Grid import energy ❌ ✅ ✅ +Grid export energy ❌ ✅ ✅ +Grid export disable/enable ❌ ❌ ❌ +House demand ❌ ✅ ✅ +House consumption ❌ ❌ ✅ +Consumed energy ❌ ✅ ❌ +Error codes ❌ ✅ ❌ +Max load ❌ ✅ ❌ +Usage mode ❌ ✅ ❌ +Presets ❌ ❌ ❌ +Light mode ❌ ✅ ❌ +PV limitations ❌ ❌ ❌ +PV panel power ❔ ✅ ❌ +AC limitations ✅ ❌ ❌ +Software version ✅ ✅ ❌ +Software version controller ✅ ✅ ❌ +Software version expansion ❌ ✅ ❌ +Hardware version ✅ ❌ ❌ +Serial number ✅ ✅ ✅ +Expansion battery serial number ❌ ❌ ❌ +Inverter brand ✅ ❌ ❌ +Inverter model ✅ ❌ ❌ +=================================== =========== =========== =========== Prime charger support diff --git a/tests/test_devices.py b/tests/test_devices.py index 49ecc10..7f127aa 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -25,8 +25,12 @@ PrimeCharger160w, PrimeDevice, PrimePowerBank20k, + PrimeCharger160w, + PrimeDevice, + PrimePowerBank20k, Solarbank1, Solarbank2, + Solarbank2, SolixBLEDevice, TemperatureUnit, ) @@ -38,6 +42,15 @@ NEGOTIATION_RESPONSES_SOLIX, ) from tests.helpers import MockDevice +) +from SolixBLE.devices.solarbank2 import MaxLoadSB2 +from SolixBLE.states import GridStatus, LightMode, SBPowerCutoff, SBUsageMode +from tests.const import ( + MOCK_BLE_DEVICE, + NEGOTIATION_RESPONSES_PRIME, + NEGOTIATION_RESPONSES_SOLIX, +) +from tests.helpers import MockDevice @pytest.mark.asyncio @@ -334,6 +347,71 @@ }, id="c1000g2_dc_on", ), + # The two cases below are decrypted telemetry frames captured from a real + # C1000 Gen 2 (A1763) on 2026-06-21 with the AC output physically off then + # on (idle, no load). They are identical except for the "a7" param, which + # locks in the AC output decode: ac_output is "a7" byte 1 (00=off, 01=on, + # latched) -- the same per-port "04 " shape used by the + # DC port (b2) and USB ports. ("a4" byte 22 is NOT the AC state: it stayed + # 01 with the port physically off, so an a4-based decode reports a false + # OUTPUT.) + pytest.param( + C1000G2, + "a10131a221062011415043444b39363047313631303033393000054131373633030401010100a30e0400000000b0040064cc00580200a41b0400000000580232010000000000f0003c00010000000100500a00a506042400396400a60a04000000000000ab2a39a70704000000000000a80404000000aa0404000000ab0404000000ac0404000000ae0404000000b20404000000d91a04000019500a0000000000000000000000000000000000000000da18040000000000000000000001e00164057f00000000000000dc06040000000000f91d0403040101060005000000000000000000090300010000000006090200fa15040101010100170300000000000000000000000000fd0e0031373832303439353930383637fe050364f4376a", + { + "serial_number": "APCDK960G16100390", + "part_number": "A1763", + "temperature": 36, + "battery_percentage": 57, + "battery_health": 100, + "ac_output": PortStatus.NOT_CONNECTED, + "ac_power_in": 0, + "ac_power_out": 0, + "power_out": 0, + "solar_port": PortStatus.NOT_CONNECTED, + "dc_output": PortStatus.NOT_CONNECTED, + "max_battery_percentage": 80, + "min_battery_percentage": 10, + }, + id="c1000g2_ac_off", + ), + pytest.param( + C1000G2, + "a10131a221062011415043444b39363047313631303033393000054131373633030401010100a30e0400000000b0040064cc00580200a41b0400000000580232010000000000f0003c00010000000100500a00a506042400396400a60a04000000000000ab2a39a70704010000000000a80404000000aa0404000000ab0404000000ac0404000000ae0404000000b20404000000d91a04000019500a0000000000000000000000000000000000000000da18040000000000000000000001e00164057f00000000000000dc06040000000000f91d0403040101060005000000000000000000090300010000000006090200fa15040101010100170300000000000000000000000000fd0e0031373832303439353930383637fe050369f4376a", + { + "serial_number": "APCDK960G16100390", + "part_number": "A1763", + "temperature": 36, + "battery_percentage": 57, + "battery_health": 100, + "ac_output": PortStatus.OUTPUT, + "ac_power_in": 0, + "ac_power_out": 0, + "power_out": 0, + "solar_port": PortStatus.NOT_CONNECTED, + "dc_output": PortStatus.NOT_CONNECTED, + "max_battery_percentage": 80, + "min_battery_percentage": 10, + }, + id="c1000g2_ac_on", + ), + # Derived from the idle "c1000g2" frame above with only the "b2" param + # changed from 04000000 to 04010600 -- the value observed live on a real + # C1000 Gen 2 with the DC output on and a ~6 W 12 V load. This locks in + # the DC decode: dc_output is "b2" byte 1 (01 = OUTPUT) and dc_power_out + # is "b2" [2:4] little-endian watts (0x0006 = 6 W). + pytest.param( + C1000G2, + "a10134a221062011415043444b39363146333734303032393000054131373633060201010100a30b0400000000b0040058dc00a41b0400000000b0043201000000000000001e00010000000000640103a506041700646400a60a04000000000000ab2a64a70704000000010000a80404000000aa0404000000ab0404000000ac0404000000ae0404000000b20404010600d91a0400001964010000000100000000000000000000000000000000da18040000000000000000000001e00164057f00000000000000dc06040000000000f91d0406020101050005000000000005000500050300010000000000020200fa150401010101001f0300000000000000000000000000fd0e0031373634363538323735393838fe0503638c2e69f0", + { + "serial_number": "APCDK961F37400290", + "battery_percentage": 100, + "ac_output": PortStatus.NOT_CONNECTED, + "dc_output": PortStatus.OUTPUT, + "dc_power_out": 6, + }, + id="c1000g2_dc_on", + ), pytest.param( C300, "a10131a2050300000000a3050300000000a40302ffffa503020000a603025400a703020000a803020000a903020000aa03020100ab03020000ac03020000ad03020000ae03025500af03020000b003020100b103021b04b20302fc01b30302fc01b403021c00b503027b00b603021b04b7020101b8020100b9020124ba020100bb020164bc020164bd020100be020100bf020100c0020101c1020100c2020100c3020100c4020100c51100415a5653424a30453339323030303438c603024a01c70302a005c803022c01c903023c00ca03020000cb020101cc020100cd020102ce020132cf020100d0020100d1020101", @@ -639,6 +717,132 @@ }, id="prime_power_bank_20k_discharge_c1_a1_charge_c2", ), + pytest.param( + PrimeCharger160w, + "a10131a20302e805a303020000a4020100a5080400000000000000a6080400000000000000a7080400000000000000a8020103a9020150aa020100ab090400000f0f0f000000ac0d0401002c0100002c0100000300ad0d0401002c0100002c0100000300ae0d0401002c0100002c0100000300af020100b0020100b1020101b2020101b3020101b40d04fafffbfffafffbfffafffbffb50d04ffffffffffffffffffffffffe0050408000000e10b0480034b53000000000000fe050300000000", + { + "usb_port_c1": PortStatus.NOT_CONNECTED, + "usb_c1_current": 0.0, + "usb_c1_power": 0.0, + "usb_c1_voltage": 0.0, + "usb_port_c2": PortStatus.NOT_CONNECTED, + "usb_c2_current": 0.0, + "usb_c2_power": 0.0, + "usb_c2_voltage": 0.0, + "usb_port_c3": PortStatus.NOT_CONNECTED, + "usb_c3_current": 0.0, + "usb_c3_power": 0.0, + "usb_c3_voltage": 0.0, + }, + id="prime_160w_idle", + ), + pytest.param( + PrimeCharger160w, + "a10131a20302e805a303020000a4020100a5080401e01374003700a608040108236c030b03a7080401d81364003200a8020103a9020150aa020100ab090400000f0f0f000000ac0d0401002c0100002c0100000000ad0d0401002c0100002c0100000203ae0d0401002c0100002c0100000000af020100b0020100b1020101b2020101b3020101b40d0400000000e804000000000000b50d04ffffffffffffffffffffffffe0050408000000e10b0480034b53000000000000fe050300000000", + { + "usb_port_c1": PortStatus.OUTPUT, + "usb_c1_current": 0.116, + "usb_c1_power": 0.55, + "usb_c1_voltage": 5.088, + "usb_port_c2": PortStatus.OUTPUT, + "usb_c2_current": 0.876, + "usb_c2_power": 7.79, + "usb_c2_voltage": 8.968, + "usb_port_c3": PortStatus.OUTPUT, + "usb_c3_current": 0.1, + "usb_c3_power": 0.5, + "usb_c3_voltage": 5.08, + }, + id="prime_160w_all_three_charging", + ), + pytest.param( + PrimePowerBank20k, + "a10131a203044d60a30404010000a4020101a50404000000a60404000000a7080400000000000000a80f0400000000009600ff00ffffffff00a90f0400000000000000ff00ffffffff00ac09040000000000000000af02011db002011eb103020900fe050300000000", + { + "battery_percentage": 77, + "temperature": 29, + "power_out": 0.0, + "usb_port_c1": PortStatus.NOT_CONNECTED, + "usb_c1_current": 0.0, + "usb_c1_power": 15.0, + "usb_c1_voltage": 0.0, + "usb_port_c2": PortStatus.NOT_CONNECTED, + "usb_c2_current": 0.0, + "usb_c2_power": 0.0, + "usb_c2_voltage": 0.0, + "usb_port_a1": PortStatus.NOT_CONNECTED, + "usb_a1_current": 0.0, + "usb_a1_power": 0.0, + "usb_a1_voltage": 0.0, + }, + id="prime_power_bank_20k_idle", + ), + pytest.param( + PrimePowerBank20k, + "a10131a20304515ca30404010000a4020101a50404000000a60404013601a7080400000000000000a80f04019500140036010107ffffffff00a90f0400000000000000ff00ffffffff00ac09040000000000000000af02011ab002011bb103020900fe050300000000", + { + "battery_percentage": 81, + "temperature": 26, + "power_out": 31.0, + "usb_port_c1": PortStatus.OUTPUT, + "usb_c1_current": 2.0, + "usb_c1_power": 31.0, + "usb_c1_voltage": 14.9, + "usb_port_c2": PortStatus.NOT_CONNECTED, + "usb_c2_current": 0.0, + "usb_c2_power": 0.0, + "usb_c2_voltage": 0.0, + "usb_port_a1": PortStatus.NOT_CONNECTED, + "usb_a1_current": 0.0, + "usb_a1_power": 0.0, + "usb_a1_voltage": 0.0, + }, + id="prime_power_bank_20k_discharge_c1", + ), + pytest.param( + PrimePowerBank20k, + "a10131a20304505ca30404010000a4020101a50404000000a60404013a01a7080400000000000000a80f0400000000003d01ff00ffffffff00a90f0401950014002a010107ffffffff00ac09040133000300100000af02011bb002011cb103020900fe050300000000", + { + "battery_percentage": 80, + "temperature": 27, + "power_out": 31.4, + "usb_port_c1": PortStatus.NOT_CONNECTED, + "usb_c1_current": 0.0, + "usb_c1_power": 31.7, + "usb_c1_voltage": 0.0, + "usb_port_c2": PortStatus.OUTPUT, + "usb_c2_current": 2.0, + "usb_c2_power": 29.8, + "usb_c2_voltage": 14.9, + "usb_port_a1": PortStatus.OUTPUT, + "usb_a1_current": 0.3, + "usb_a1_power": 1.6, + "usb_a1_voltage": 5.1, + }, + id="prime_power_bank_20k_discharge_c2_a1", + ), + pytest.param( + PrimePowerBank20k, + "a10131a203044b5da30404010018a4020101a50404014102a6040401a300a7080400000000000000a80f04015900100096000107ffffffff00a90f0402c9001c004102ff07ffffffff00ac090401330002000d0000af02011cb002011db103020900fe050300000000", + { + "battery_percentage": 75, + "temperature": 28, + "power_out": 16.3, + "usb_port_c1": PortStatus.OUTPUT, + "usb_c1_current": 1.6, + "usb_c1_power": 15.0, + "usb_c1_voltage": 8.9, + "usb_port_c2": PortStatus.INPUT, + "usb_c2_current": 2.8, + "usb_c2_power": 57.7, + "usb_c2_voltage": 20.1, + "usb_port_a1": PortStatus.OUTPUT, + "usb_a1_current": 0.2, + "usb_a1_power": 1.3, + "usb_a1_voltage": 5.1, + }, + id="prime_power_bank_20k_discharge_c1_a1_charge_c2", + ), pytest.param( C300DC, "a10131a2050300000000a303020000a403020000a503020000a603020000a703020000a803020000a903020000aa03020000ab03020000ac03020000ad03020000ae03020000af03020000b003020000b103020000b203020000b303020000b403020000b5020180b6020100b7020100b8020100b9020100ba020100bb020100bc020100bd020100be020100bf020100c0020100c1020100c2020100c3110020202020202020202020202020202020c403020000c503020000c603020000c7020100c8020100c9020100ca020100cb03020000cc020100cd020100f7050300000000f815040000000000000000000000000000000000000000", @@ -851,6 +1055,51 @@ }, id="maggo_3in1_phone_higher_power", ), + pytest.param( + Solarbank2, + "a10131a2110041504347513830453030303030303030a302013aa4020101a503020000a605030100060aa7050300000631a8050300030306a9020100aa020111ab050300000000ac0503f4010000ad02013aae020100af020100b0050300000000b10503e0bd0200b20503723c0a00b305038d840200b4020105b5020104b6020105b7050388130000b8020101b9020100ba050328000000bb020100bc050300000000bd050300000000be050300000000bf050300000000c0110000000000000000000000000000000000c1020100c203022003c40503f4010000c5020100c6020101c703023200c8050300000000c9050306000000ca050300000000cb050300000000cc050300000000cd050300000000d2020100d30503f4010000d4110000000000000000000000000000000000d503020000d6110000000000000000000000000000000000d703020000d8110000000000000000000000000000000000d903020000da110000000000000000000000000000000000db03020000dc110000000000000000000000000000000000dd03020000de110000000000000000000000000000000000df03020000e0020102e1020101e2020100e3020100e4020100e5020100e6020100e7020100e8020100e9020100ea020101fe05039a46d969fb050300000000fc1604010101010001010101010100000000000000000000", + { + "serial_number": "APCGQ80E00000000", + "battery_percentage": 58, + "battery_percentage_aggregate": 58, + "error_code": 0, + "software_version": "1.6.8.1.6.5.3.7.7", + "software_version_controller": "8.2.2.4.7.6.8.0.0", + "software_version_expansion": "1.0.0.8.6.0.6.7.2", + "temperature_unit": TemperatureUnit.CELSIUS, + "temperature": 17, + "solar_power_in": 0.0, + "solar_pv_1_power_in": 0.0, + "solar_pv_2_power_in": 0.0, + "solar_pv_3_power_in": 0.0, + "solar_pv_4_power_in": 0.0, + "ac_power_out": 50.0, + "ac_power_out_sockets": 0.0, + "battery_charge_power": 0.0, + "battery_discharge_power": 50.0, + "pv_yield": 17.968, + "charged_energy": 6.70834, + "output_energy": 16.5005, + "grid_to_home_power": 0.0, + "pv_to_grid_power": 0.0, + "grid_import_energy": 0.0, + "grid_export_energy": 0.0, + "house_demand": 50.0, + "consumed_energy": 0.0006, + "power_out": 50.0, + "max_load": MaxLoadSB2.W800, + "output_cutoff_data": SBPowerCutoff.P5, + "lowpower_input_data": 4, + "input_cutoff_data": SBPowerCutoff.P5, + "usage_mode": SBUsageMode.MANUAL, + "home_load_preset": 50, + "light_mode": LightMode.NORMAL, + "grid_status": GridStatus.OK_AS_WELL_I_GUESS, + "light_on": False, + "battery_heating": False, + }, + id="solarbank2_telemetry", + ), ], ) async def test_values( @@ -866,6 +1115,7 @@ async def test_values( device = device_class(MOCK_BLE_DEVICE) parameters = device._parse_payload(bytes.fromhex(payload)) await device._process_telemetry(parameters) + await device._process_telemetry(parameters) for class_property, expected_value in mapping.items(): assert ( @@ -898,12 +1148,45 @@ async def test_c1000g2_dc_control() -> None: @pytest.mark.asyncio -@pytest.mark.parametrize( - "device_class,packets,secret", - [ - pytest.param( - C300, - [ +async def test_c1000g2_dc_control() -> None: + """C1000 Gen 2 DC output control dispatches command 4102. + + Confirmed on real hardware (the 12 V port physically switched and acked). + Here we just lock in that turn_dc_on/off send command 4102 with the same + on/off payloads as the AC output, which is the only difference between the + two on the Gen 2. + """ + device = C1000G2(MOCK_BLE_DEVICE) + device._send_command = mock.AsyncMock() + + await device.turn_dc_on() + device._send_command.assert_awaited_once_with( + 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") + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "device_class,packets,secret", + "device_class,packets,secret", + [ + pytest.param( + C300, + [ + "ff090e00030001080100a1010152", + "ff091b00030001080300a10102a202fd00a30144a40101a50102ff", + "ff093800030001082900a10103a2054553503332a307302e302e302e33a410415a5653424a30453339323030303438a506f49d8a53a95a14", + "ff090b00030001080500f2", + "ff094d00030001082100a140c2a5a88fab34c1ac0f96a52e1b93354a47fb6c674b5afebacf5a2ed755435f41f0d26e97782e54e268b46d9f8a58a267cd7f7a239771e6289e55d94f7669ed448a", + None, + ], + [ "ff090e00030001080100a1010152", "ff091b00030001080300a10102a202fd00a30144a40101a50102ff", "ff093800030001082900a10103a2054553503332a307302e302e302e33a410415a5653424a30453339323030303438a506f49d8a53a95a14", @@ -924,6 +1207,14 @@ async def test_c1000g2_dc_control() -> None: "ff094d00030001082100a140a7b5d3824a36cae20bab9fc4d9358191e5351905a782eda157f376cc43f1f761ab772d437f33787188716d1bebd81719d1eb76b94f08499ee93895d5b43e75ef5f", None, ], + [ + "ff090e00030001080100a1010152", + "ff091b00030001080300a10102a202fd00a30144a40101a50102ff", + "ff093800030001082900a10103a2054553503332a307302e302e302e33a410415a5653424a30453339323030303438a506f49d8a53a95a14", + "ff090b00030001080500f2", + "ff094d00030001082100a140a7b5d3824a36cae20bab9fc4d9358191e5351905a782eda157f376cc43f1f761ab772d437f33787188716d1bebd81719d1eb76b94f08499ee93895d5b43e75ef5f", + None, + ], "f97b0112a955846530c60e4cf95f941df76d86ab9ca106aa4bd00fe1c4fcb14f", id="c300_2", ), @@ -937,6 +1228,14 @@ async def test_c1000g2_dc_control() -> None: "ff094d00030001082100a140d3ef70a8faeb9ae7d9be034390108c2c7b177f3d549eb87318bd7a31703fc604664efb0e4600298ca9a905fb5af170955fb76229791dd583478b84d9950bd65420", None, ], + [ + "ff090e00030001080100a1010152", + "ff091b00030001080300a10102a202fd00a30144a40101a50102ff", + "ff093800030001082900a10103a2054553503332a307302e302e302e33a41041504339464530453237333030323735a506f49d8a104e0c9a", + "ff090b00030001080500f2", + "ff094d00030001082100a140d3ef70a8faeb9ae7d9be034390108c2c7b177f3d549eb87318bd7a31703fc604664efb0e4600298ca9a905fb5af170955fb76229791dd583478b84d9950bd65420", + None, + ], "2bdc8c8bfecf40814f602e6547cf29bf125abcc1a93be0751d8f1065a2bb5570", id="c1000_1", ), @@ -950,6 +1249,14 @@ async def test_c1000g2_dc_control() -> None: "ff094d00030001082100a140b2ade5cac4f4a0c1307e44a0e9c5363cb21e4c8485ee324c23be949fa5d5929a75e57da3207c948a0c366ca9ea1ab2cb8e57d2d046a6ebefe5d96adb5d4cb35039", None, ], + [ + "ff090e00030001080100a1010152", + "ff091b00030001080300a10102a202fd00a30144a40101a50102ff", + "ff093800030001082900a10103a2054553503332a307302e302e302e33a41041504339464530453237333030323735a506f49d8a104e0c9a", + "ff090b00030001080500f2", + "ff094d00030001082100a140b2ade5cac4f4a0c1307e44a0e9c5363cb21e4c8485ee324c23be949fa5d5929a75e57da3207c948a0c366ca9ea1ab2cb8e57d2d046a6ebefe5d96adb5d4cb35039", + None, + ], "0c4d9db9ef376fcfe627b9b73089eda514315d4bf67fb7eb299f2894ef7a059c", id="c1000_2", ), @@ -982,9 +1289,42 @@ async def test_c1000g2_dc_control() -> None: "5609bc39f79166da75139feb7c335fb7524b3bf0d730db96bf6ebf450d3e165b", id="prime_power_bank_20k", ), + pytest.param( + Solarbank2, + [ + "ff090e00030001080100a1010152", + "ff091b00030001080300a10102a202fd00a30144a40101a50102ff", + "ff093800030001082900a10103a2054553503332a307302e302e302e33a41041504347513830453030303030303030a50600000000000039", + "ff090b00030001080500f2", + "ff094d00030001082100a140f809d676751fba1346f21198c8a583b1ef9b9a617fb804455c388d07090e6dc2976c1bb1cf06aee1f30a3286af9dd80f8f0c594010f60755292addedfe41385972", + None, + ], + "6a2c89888de58cce1e15d98eb22669898ec29bcb1519ce19f950439aac9dbcb5", + id="solarbank2_1", + ), + pytest.param( + PrimePowerBank20k, + [ + "ff091e000300014801ab273ed3e27270c3f4d676ac7d69a00572793732a6", + "ff092b000300014803ab273ed0443800b35db54c6d4a6ec3d48171a04ea7ebce8bf749e5e48c5d991a5e67", + "ff0958000300014829ab273ed144326ada9fc66fa02508c5ddf549ade014d1eeb352fea11c0315b70b8aaa8a734ca5830f8d5827acbaa1224f05ad300b38d27bac9862a768d95c29daed0a89e92feb1d09163a094aa700ff", + "ff091b000300014805abab709a595a803dd04246b78a927453cf65", + "ff095d000300014821ab277f4e77c3b9e1f44367539f64f85d19969d0273c2c0ca93a06f3a010cf636e3b2df75d10791adf1e3c706a3238bcf0a858cd1e2d55d4cf1164a1b7db3b0058c47dfb24c71f11f8a96209d9f0924d420f03120", + "ff091b000300014822e520695552c2745a608fd21cf84bc6e3ccb9", + "ff091b000300014827e520695552c2745a608fd21cf84bc6e3ccbc", + "ff09df000301114a00e5a17fe3ebb89758b89ffb0e7d35a36ffeaeba3e991d79323680049a018c8e719bb706b6d00a142199a6cdc7f05bb5489f1ebb093fe3d134caf7ae5ad7b456867d9a58885cee8479bc10ea2d42d5b94d3b5a929cf4f4fd25f987e5a4922ae6fa744e22289080676583f390c1351a4b68ac5c1dabdcbf8e5e23416e47a0cea7a6062326dd8505464f821ba881f0f6f2c8ea050a7c978962980a539e90879aa1499b5be92fdceb53de533fc2bdd78b7998aec24493fdcfe3d2bc7e95b383744f92a4168819350e89d0d3142d1dbedcb779e45cfad12008", + "ff098300030111430044014f704abfd87d1d38fc0d7a35a36efdaf1f9f9f1c799493804dfaa6882d789fb7aeb4d117bd2330cd63c5f13f1e4a089ce80ac2442c66c85fa1f0dcb0d6867d9a58f7a3ee8479ec124724f6d7b84d8a58939c465ffb24e43754a1889be5f8c946d82d93806765835569e75bd67cbd3ac71071159c13a83bb9", + ], + "5609bc39f79166da75139feb7c335fb7524b3bf0d730db96bf6ebf450d3e165b", + id="prime_power_bank_20k", + ), ], ) async def test_negotiation( + fast_sleep, + fast_timeouts, + device_class: type[SolixBLEDevice], + packets: list[str], fast_sleep, fast_timeouts, device_class: type[SolixBLEDevice], @@ -996,12 +1336,18 @@ async def test_negotiation( :param device_class: The class of the device being tested. :param packets: Packets sent by the mock device in response to our packets. + :param packets: Packets sent by the mock device in response to our packets. :param secret: The expected shared secret. """ async with MockDevice() as mock_bluetooth: device = device_class(MOCK_BLE_DEVICE) + for packet in packets: + mock_bluetooth.expect_ordered( + None, + [bytes.fromhex(packet)] if packet else [], + ) for packet in packets: mock_bluetooth.expect_ordered( None, @@ -1015,14 +1361,18 @@ async def test_negotiation( assert ( bytes.fromhex(secret) == device._shared_secret ), "Shared secret does not match expected" + bytes.fromhex(secret) == device._shared_secret + ), "Shared secret does not match expected" mock_bluetooth.check_assertions() @pytest.mark.parametrize( + "device_class,payload,secret,decrypted", "device_class,payload,secret,decrypted", [ pytest.param( + C300, C300, "5bc7c7b05cf74c1ba441a17a5568f4b25bc061d354f498e39ba509e2c7664ce36d6a9ee8280a40736b9b681f10ab6eb7c86bca4b88fe6fc39ca3391d7ede4e1c47b6b5f0e5ccc67c841a0eb0912039323c27f9e819244424914c9fb538e93a23bc9bfd0f4e9df1b59fec44b5236c75c6f45e42a1110152e56491f8381ae07e50113e3746ca9a16182bc8c9102bbb463eb42d27b1e6330feb3f76d21bf751fe4a1d469c64cd8c9bda426943d48fc7c583c665ea21c7ee23fdde9262d47727c9454d88dd30d291f9bc9b0936a66761846c729f898895d97c158c36e703626ea8499fbf2dc8962159f1b7380f5f84038240d5df00ce1a7eecb4f3ea0b7de9aac5b8637d78f0f3fcf6d600227148d5011bd765a99be6d6ab0e83b9ebe8dcb9ce5ba6", "23a6446c34efb9f9ab1dbc43ffc8e289fffdfed557f849c4e91bd7baec0c4814", @@ -1030,6 +1380,7 @@ async def test_negotiation( id="c300_telemetry", ), pytest.param( + C1000, C1000, "403d9e7311afd074672804704798c421db698f11a5a0fc4bd793c127871c6eea7a970666c9b614c494e62b15770b1dba3dc98019e34cf0eb0ebecb5a2c5bc9ae39441d5e5acad73a645112b779312966513b53ba6f78c0f82cda624cce3b08a1a83416bd52fa4caf37e05cfaa9b37ddea75447be949ba10b892c320398fae0191c1290af0e79791c56c0d2217aafb9259b13cd2ccb9e4d520548eb416f4f96b9d852231578d4d516495564215c297fce97549986ef47058168d77afddc8ac5c0b59c9bfaf681a4cd60eca4bfad743731ca81849b83689e452e68f82fcab9fa2404f05f22b557b73705d16bab42b8045ffcc8083f9cb4fa4acda9997de1a40a2eac55b5dfbc70d882874c1db1990b76ae009bb1997ab507d347c84f3fd39d6f6c", "0c4d9db9ef376fcfe627b9b73089eda514315d4bf67fb7eb299f2894ef7a059c", @@ -1037,6 +1388,7 @@ async def test_negotiation( id="c1000_telemetry", ), pytest.param( + C1000, C1000, "a9fdb7f5f88e0d7ec2c3a36f9cb4f226", "cf9b34f93bc679b84c9754a9484a56991cef242c586b23dbef195ba0f2ee02cb", @@ -1044,6 +1396,7 @@ async def test_negotiation( id="c1000_cmd_ack_ac_on", ), pytest.param( + C1000, C1000, "2eb0fc833d00ca9e33491eab73ccfda202cfdedb86599ba5d0e3c2c059652818", "cf9b34f93bc679b84c9754a9484a56991cef242c586b23dbef195ba0f2ee02cb", @@ -1072,8 +1425,33 @@ async def test_negotiation( "a10131a203044d60a30404010000a4020101a50404000000a60404000000a7080400000000000000a80f0400000000009600ff00ffffffff00a90f0400000000000000ff00ffffffff00ac09040000000000000000af02011db002011eb103020900fe050300000000", id="prime_power_bank_telemetry", ), + pytest.param( + PrimeCharger160w, + "57e9a883d95e4bc95b5be2baa1c366331abb9292585357de1f59c997254092ef1372bd5a26ef6b51d61dc87082ca8e7985aacad07f64181902c70c0502de2418e366f5f700b13049d9b857e95c85c66a32d64fcf31c8eead9e025ed69c1440170cca149e038501a9544b1baa044a6a65392e154357e137d917fc834e019012a01b9bd18d5ca7dc22bdb0204b0629b3f738f34bafdc26f6bb0781cec80fe547674a6a7a341a018ce3ac81e6eb6b5110d3311db692d174fe363acec5ba606a24b975c2bb2a43ddfe5351f54d9fcd295709", + "09486817d949a232b58b47a43cc72d045a617a26f3999d30e1d27e38eae52265", + "a10131a20302e805a303020000a4020100a508040150235704eb03a6080400000000000000a7080400000000000000a8020103a9020150aa020100ab090400000f0f0f000000ac0d0401002c0100002c0100000203ad0d0401002c0100002c0100000300ae0d0401002c0100002c0100000300af020100b0020100b1020101b2020101b3020101b40d04e8040000fafffbfffafffbffb50d04ffffffffffffffffffffffffe0050408000000e10b0480034b53000000000000fe050300000000", + id="prime_160w_telemetry", + ), + # Different anker prime charger from other tests + pytest.param( + PrimeCharger160w, + "14676a53fc1315457c58163660d5b7bb4a6c83be2f8511d2bc79e2428827907a591b28a709df413e4fa633dc943dd7d2902c46bdcd69ea2bfe4c529f577dfe492d3192aa04f2b2a66fa745b4ed64d34a0a8100d4dd165514edd14499cf1243fbc9d1c216239bc53b756256f4dc04723c470a10434d49e3e38c6d6e1c2054a4890ea244a14964ef6b69eecc3ce8debc0f50537a6be461f3a1b9eb6cc1f1303d8dcf9488a8d4c8bc60729fa669974a4b84a50a0d5f75833c157e5e5c54cf19f944e731932e076b25892c13e0b3979ccd11", + "c0779a39bfa7b290ba9cd3d96b6fdc22a1f6a9746d4fc81e942c3d95", + "a10131a20302e805a303020000a4020100a5080400000000000000a6080401d84e00000000a7080400000000000000a8020100a9020150aa020100ab090400001c50343b3b3bac0d0401002c0100002c0100000300ad0d0401002c0100002c0100000100ae0d0401002c0100002c0100000300af020101b0020101b1020100b2020101b30201ffb40d04fafffbff00000000fafffbffb50d04ffffffffffffffffffffffffe0050408000000e10b0400000000000000000000fe050300000000", + id="prime_160w_telemetry_alt", + ), + pytest.param( + PrimePowerBank20k, + "44014f704abfd87d1d38fc0d7a35a36efdaf1f9f9f1c799493804dfaa6882d789fb7aeb4d117bd2330cd63c5f13f1e4a089ce80ac2442c66c85fa1f0dcb0d6867d9a58f7a3ee8479ec124724f6d7b84d8a58939c465ffb24e43754a1889be5f8c946d82d93806765835569e75bd67cbd3ac71071159c13a83b", + "5609bc39f79166da75139feb7c335fb7524b3bf0d730db96bf6ebf450d3e165b", + "a10131a203044d60a30404010000a4020101a50404000000a60404000000a7080400000000000000a80f0400000000009600ff00ffffffff00a90f0400000000000000ff00ffffffff00ac09040000000000000000af02011db002011eb103020900fe050300000000", + id="prime_power_bank_telemetry", + ), ], ) +def test_payload_decryption( + device_class: type[SolixBLEDevice], payload: str, secret: str, decrypted: str +): def test_payload_decryption( device_class: type[SolixBLEDevice], payload: str, secret: str, decrypted: str ): @@ -1081,12 +1459,16 @@ def test_payload_decryption( Test the decryption of a payload only. This does not test the splitting of a packet. + :param device_class: Class of device under test. :param device_class: Class of device under test. :param payload: Payload to be decrypted. :param secret: Shared secret used for AES key and IV. + :param secret: Shared secret used for AES key and IV. :param decrypted: Expected content of decrypted payload. """ + device = device_class(MOCK_BLE_DEVICE) + device._shared_secret = bytes.fromhex(secret) device = device_class(MOCK_BLE_DEVICE) device._shared_secret = bytes.fromhex(secret) @@ -1096,10 +1478,12 @@ def test_payload_decryption( @pytest.mark.asyncio @pytest.mark.parametrize( + "device_class, packets, secret, parameters", "device_class, packets, secret, parameters", [ # Test that when there are no packets device._ data is None pytest.param( + SolixBLEDevice, SolixBLEDevice, [], "", @@ -1108,60 +1492,75 @@ def test_payload_decryption( ), # Test that when there there are 0/2 required packets device._data is None pytest.param( + C1000, C1000, [ "ff092a0003010f440156ecb95eb746de03d40ee711ce99f42837a9554c6382d3f5298a3b0648d8536936" ], "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", + "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", None, id="irrelevant_packet_only", ), # Test that when there there is only 1/2 required packets device._data is None pytest.param( + C1000, C1000, [ "ff09390003010fc40222788d127d8418b41a81719975719a26b32734ea4e44ce244683e31928bb9a2736f9ede939567cddce6b3fb0de68116c" ], "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", + "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", None, id="solix_packet_1_missing", + id="solix_packet_1_missing", ), # Test that when there there is only 1/2 required packets device._data is None pytest.param( + C1000, C1000, [ "ff09fd0003010fc402121e0e23790307a57d4adabcd8d5ad56c3a9ea3cb5b222b0152438ccd3b980eda40fbde184fa66c80c3372dad179f11cad8799858ab95696e52c7e729af87c1106343ed5be9c042c8912b14f3a0d94b32afbed432e66616e1895ba0ff5e74a6da9401117070c926631e5d7886a07bec0de35aeb689e8bb289f1d7854143dc413f25d4b57d290ca4378cfb8efc275aa779145f98956e934eaced2d1f51cef7dd21a340318bfc14fb5f90ffd33e0e484175512af33593b1f91eb9801d7c2e1ac6d56e8fe7e8883d62226484ed6f1af711d042c5e3d0c186b3f2222293bc71ccf4a156a544d5171e90ee9b6b9b8f36ae058b96e3b88" ], "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", + "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", None, id="solix_packet_2_missing", + id="solix_packet_2_missing", ), # Test that when the 1st packet arrives after the 2nd packet is it ignored pytest.param( + C1000, C1000, [ "ff09390003010fc40222788d127d8418b41a81719975719a26b32734ea4e44ce244683e31928bb9a2736f9ede939567cddce6b3fb0de68116c", "ff09fd0003010fc402121e0e23790307a57d4adabcd8d5ad56c3a9ea3cb5b222b0152438ccd3b980eda40fbde184fa66c80c3372dad179f11cad8799858ab95696e52c7e729af87c1106343ed5be9c042c8912b14f3a0d94b32afbed432e66616e1895ba0ff5e74a6da9401117070c926631e5d7886a07bec0de35aeb689e8bb289f1d7854143dc413f25d4b57d290ca4378cfb8efc275aa779145f98956e934eaced2d1f51cef7dd21a340318bfc14fb5f90ffd33e0e484175512af33593b1f91eb9801d7c2e1ac6d56e8fe7e8883d62226484ed6f1af711d042c5e3d0c186b3f2222293bc71ccf4a156a544d5171e90ee9b6b9b8f36ae058b96e3b88", ], "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", + "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", None, id="solix_both_packets_reversed", + id="solix_both_packets_reversed", ), # Test that when the packets arrive in order they are parsed and device._data is populated pytest.param( + C1000, C1000, [ "ff09fd0003010fc402121e0e23790307a57d4adabcd8d5ad56c3a9ea3cb5b222b0152438ccd3b980eda40fbde184fa66c80c3372dad179f11cad8799858ab95696e52c7e729af87c1106343ed5be9c042c8912b14f3a0d94b32afbed432e66616e1895ba0ff5e74a6da9401117070c926631e5d7886a07bec0de35aeb689e8bb289f1d7854143dc413f25d4b57d290ca4378cfb8efc275aa779145f98956e934eaced2d1f51cef7dd21a340318bfc14fb5f90ffd33e0e484175512af33593b1f91eb9801d7c2e1ac6d56e8fe7e8883d62226484ed6f1af711d042c5e3d0c186b3f2222293bc71ccf4a156a544d5171e90ee9b6b9b8f36ae058b96e3b88", "ff09390003010fc40222788d127d8418b41a81719975719a26b32734ea4e44ce244683e31928bb9a2736f9ede939567cddce6b3fb0de68116c", ], "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", + "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", """{'a1': '31', 'a2': '0300000000', 'a3': '0300000000', 'a4': '02720f', 'a5': '020000', 'a6': '020000', 'a7': '020000', 'a8': '020000', 'a9': '020000', 'aa': '020000', 'ab': '020000', 'ac': '020000', 'ad': '020000', 'ae': '020000', 'af': '020000', 'b0': '020100', 'b1': '020000', 'b2': '020100', 'b3': '02a600', 'b4': '020000', 'b5': '02ff01', 'b6': '02ff01', 'b7': '020000', 'b8': '029a00', 'b9': '020000', 'ba': '02a600', 'bb': '020000', 'bc': '0100', 'bd': '0122', 'be': '0100', 'bf': '0101', 'c0': '0100', 'c1': '0164', 'c2': '0100', 'c3': '0164', 'c4': '0100', 'c5': '0100', 'c6': '0100', 'c7': '0100', 'c8': '0100', 'c9': '0100', 'ca': '0100', 'cb': '0100', 'cc': '0100', 'cd': '0100', 'ce': '0100', 'cf': '0100', 'd0': '0041504339464530453237333030323735', 'e5': '0100', 'f7': '0301000000', 'f8': '040202010100010000000000000000000000000000', 'f9': '0102', 'fd': '0041313736315f33304168'}""", id="solix_both_packets", + id="solix_both_packets", ), # Test that when the packets arrive in order they are parsed and device._data is populated # but that the later packet does not result in any changes to the data because it is not # valid until the next telemetry packet arrives pytest.param( + C1000, C1000, [ "ff09fd0003010fc402121e0e23790307a57d4adabcd8d5ad56c3a9ea3cb5b222b0152438ccd3b980eda40fbde184fa66c80c3372dad179f11cad8799858ab95696e52c7e729af87c1106343ed5be9c042c8912b14f3a0d94b32afbed432e66616e1895ba0ff5e74a6da9401117070c926631e5d7886a07bec0de35aeb689e8bb289f1d7854143dc413f25d4b57d290ca4378cfb8efc275aa779145f98956e934eaced2d1f51cef7dd21a340318bfc14fb5f90ffd33e0e484175512af33593b1f91eb9801d7c2e1ac6d56e8fe7e8883d62226484ed6f1af711d042c5e3d0c186b3f2222293bc71ccf4a156a544d5171e90ee9b6b9b8f36ae058b96e3b88", @@ -1169,13 +1568,16 @@ def test_payload_decryption( "ff09fd0003010fc402121e0e23790307a57d4adabcd8d5ad56c3218e598b95b4b8aa7ff3483fd3cfc72612b49fad1e5e27b50be913da3b73328c0db3e5f58c5a86dce0f36a9c080db786c1b917a8541d43aec30c6cbd2b229876255894ac5269fb9f3d4258450905bbe28781c5544d7eb57553bc5c39418d02fba353983a9b0f318e951d57ccc019cea984f9a64b0cb793bec8c696936b16fac2d72c59c4b95561f5f534c448f911d5e1c9ac30601e04fb2338313498d083cc6f676b0797b587ebc5e2fc32e60562f5e41e44682b5f8f094bcbea33e0926f304366d5df28c4868d00ba37eb754c9921e9b63ebb0bb1fb76f644c0760636df1303362106", ], "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", + "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", """{'a1': '31', 'a2': '0300000000', 'a3': '0300000000', 'a4': '02720f', 'a5': '020000', 'a6': '020000', 'a7': '020000', 'a8': '020000', 'a9': '020000', 'aa': '020000', 'ab': '020000', 'ac': '020000', 'ad': '020000', 'ae': '020000', 'af': '020000', 'b0': '020100', 'b1': '020000', 'b2': '020100', 'b3': '02a600', 'b4': '020000', 'b5': '02ff01', 'b6': '02ff01', 'b7': '020000', 'b8': '029a00', 'b9': '020000', 'ba': '02a600', 'bb': '020000', 'bc': '0100', 'bd': '0122', 'be': '0100', 'bf': '0101', 'c0': '0100', 'c1': '0164', 'c2': '0100', 'c3': '0164', 'c4': '0100', 'c5': '0100', 'c6': '0100', 'c7': '0100', 'c8': '0100', 'c9': '0100', 'ca': '0100', 'cb': '0100', 'cc': '0100', 'cd': '0100', 'ce': '0100', 'cf': '0100', 'd0': '0041504339464530453237333030323735', 'e5': '0100', 'f7': '0301000000', 'f8': '040202010100010000000000000000000000000000', 'f9': '0102', 'fd': '0041313736315f33304168'}""", id="solix_both_packets_later_invalidates", + id="solix_both_packets_later_invalidates", ), # Test that when the packets arrive in order they are parsed and device._data is populated # but that the later packet does not result in any changes to the data because it is out # of order pytest.param( + C1000, C1000, [ "ff09fd0003010fc402121e0e23790307a57d4adabcd8d5ad56c3a9ea3cb5b222b0152438ccd3b980eda40fbde184fa66c80c3372dad179f11cad8799858ab95696e52c7e729af87c1106343ed5be9c042c8912b14f3a0d94b32afbed432e66616e1895ba0ff5e74a6da9401117070c926631e5d7886a07bec0de35aeb689e8bb289f1d7854143dc413f25d4b57d290ca4378cfb8efc275aa779145f98956e934eaced2d1f51cef7dd21a340318bfc14fb5f90ffd33e0e484175512af33593b1f91eb9801d7c2e1ac6d56e8fe7e8883d62226484ed6f1af711d042c5e3d0c186b3f2222293bc71ccf4a156a544d5171e90ee9b6b9b8f36ae058b96e3b88", @@ -1184,13 +1586,16 @@ def test_payload_decryption( "ff09fd0003010fc402121e0e23790307a57d4adabcd8d5ad56c3218e598b95b4b8aa7ff3483fd3cfc72612b49fad1e5e27b50be913da3b73328c0db3e5f58c5a86dce0f36a9c080db786c1b917a8541d43aec30c6cbd2b229876255894ac5269fb9f3d4258450905bbe28781c5544d7eb57553bc5c39418d02fba353983a9b0f318e951d57ccc019cea984f9a64b0cb793bec8c696936b16fac2d72c59c4b95561f5f534c448f911d5e1c9ac30601e04fb2338313498d083cc6f676b0797b587ebc5e2fc32e60562f5e41e44682b5f8f094bcbea33e0926f304366d5df28c4868d00ba37eb754c9921e9b63ebb0bb1fb76f644c0760636df1303362106", ], "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", + "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", """{'a1': '31', 'a2': '0300000000', 'a3': '0300000000', 'a4': '02720f', 'a5': '020000', 'a6': '020000', 'a7': '020000', 'a8': '020000', 'a9': '020000', 'aa': '020000', 'ab': '020000', 'ac': '020000', 'ad': '020000', 'ae': '020000', 'af': '020000', 'b0': '020100', 'b1': '020000', 'b2': '020100', 'b3': '02a600', 'b4': '020000', 'b5': '02ff01', 'b6': '02ff01', 'b7': '020000', 'b8': '029a00', 'b9': '020000', 'ba': '02a600', 'bb': '020000', 'bc': '0100', 'bd': '0122', 'be': '0100', 'bf': '0101', 'c0': '0100', 'c1': '0164', 'c2': '0100', 'c3': '0164', 'c4': '0100', 'c5': '0100', 'c6': '0100', 'c7': '0100', 'c8': '0100', 'c9': '0100', 'ca': '0100', 'cb': '0100', 'cc': '0100', 'cd': '0100', 'ce': '0100', 'cf': '0100', 'd0': '0041504339464530453237333030323735', 'e5': '0100', 'f7': '0301000000', 'f8': '040202010100010000000000000000000000000000', 'f9': '0102', 'fd': '0041313736315f33304168'}""", id="solix_both_packets_later_out_of_order", + id="solix_both_packets_later_out_of_order", ), # Test that when the packets arrive in order they are parsed and device._data is populated # but that the later non-telemetry packet does not result in any changes because it is # not a telemetry packet pytest.param( + C1000, C1000, [ "ff09fd0003010fc402121e0e23790307a57d4adabcd8d5ad56c3a9ea3cb5b222b0152438ccd3b980eda40fbde184fa66c80c3372dad179f11cad8799858ab95696e52c7e729af87c1106343ed5be9c042c8912b14f3a0d94b32afbed432e66616e1895ba0ff5e74a6da9401117070c926631e5d7886a07bec0de35aeb689e8bb289f1d7854143dc413f25d4b57d290ca4378cfb8efc275aa779145f98956e934eaced2d1f51cef7dd21a340318bfc14fb5f90ffd33e0e484175512af33593b1f91eb9801d7c2e1ac6d56e8fe7e8883d62226484ed6f1af711d042c5e3d0c186b3f2222293bc71ccf4a156a544d5171e90ee9b6b9b8f36ae058b96e3b88", @@ -1198,12 +1603,15 @@ def test_payload_decryption( "ff091a0003010f484a6e744378c57c16ca8ab3a40bebb6f39807", ], "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", + "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", """{'a1': '31', 'a2': '0300000000', 'a3': '0300000000', 'a4': '02720f', 'a5': '020000', 'a6': '020000', 'a7': '020000', 'a8': '020000', 'a9': '020000', 'aa': '020000', 'ab': '020000', 'ac': '020000', 'ad': '020000', 'ae': '020000', 'af': '020000', 'b0': '020100', 'b1': '020000', 'b2': '020100', 'b3': '02a600', 'b4': '020000', 'b5': '02ff01', 'b6': '02ff01', 'b7': '020000', 'b8': '029a00', 'b9': '020000', 'ba': '02a600', 'bb': '020000', 'bc': '0100', 'bd': '0122', 'be': '0100', 'bf': '0101', 'c0': '0100', 'c1': '0164', 'c2': '0100', 'c3': '0164', 'c4': '0100', 'c5': '0100', 'c6': '0100', 'c7': '0100', 'c8': '0100', 'c9': '0100', 'ca': '0100', 'cb': '0100', 'cc': '0100', 'cd': '0100', 'ce': '0100', 'cf': '0100', 'd0': '0041504339464530453237333030323735', 'e5': '0100', 'f7': '0301000000', 'f8': '040202010100010000000000000000000000000000', 'f9': '0102', 'fd': '0041313736315f33304168'}""", id="solix_both_packets_irrelevant_ignored", + id="solix_both_packets_irrelevant_ignored", ), # Test that when the packets arrive in order they are parsed and device._data is populated # and that once both of the next packets are received the device._data changes. pytest.param( + C1000, C1000, [ "ff09fd0003010fc402121e0e23790307a57d4adabcd8d5ad56c3a9ea3cb5b222b0152438ccd3b980eda40fbde184fa66c80c3372dad179f11cad8799858ab95696e52c7e729af87c1106343ed5be9c042c8912b14f3a0d94b32afbed432e66616e1895ba0ff5e74a6da9401117070c926631e5d7886a07bec0de35aeb689e8bb289f1d7854143dc413f25d4b57d290ca4378cfb8efc275aa779145f98956e934eaced2d1f51cef7dd21a340318bfc14fb5f90ffd33e0e484175512af33593b1f91eb9801d7c2e1ac6d56e8fe7e8883d62226484ed6f1af711d042c5e3d0c186b3f2222293bc71ccf4a156a544d5171e90ee9b6b9b8f36ae058b96e3b88", @@ -1212,6 +1620,7 @@ def test_payload_decryption( "ff09390003010fc40222922d054e0b6cd682ba63ba7cc0e158113a569150aa95c5a21bc3142c1ba2e95c06a7ce78547448520ae8cc1a2844fa", ], "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", + "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", """{'a1': '31', 'a2': '0300000000', 'a3': '0300000000', 'a4': '02d80e', 'a5': '020000', 'a6': '020000', 'a7': '020000', 'a8': '020000', 'a9': '020000', 'aa': '020000', 'ab': '020000', 'ac': '020000', 'ad': '020000', 'ae': '020000', 'af': '020000', 'b0': '020100', 'b1': '020000', 'b2': '020100', 'b3': '02a600', 'b4': '020000', 'b5': '02ff01', 'b6': '02ff01', 'b7': '020000', 'b8': '029a00', 'b9': '020000', 'ba': '02a600', 'bb': '020100', 'bc': '0100', 'bd': '0122', 'be': '0100', 'bf': '0101', 'c0': '0100', 'c1': '0164', 'c2': '0100', 'c3': '0164', 'c4': '0100', 'c5': '0100', 'c6': '0100', 'c7': '0100', 'c8': '0100', 'c9': '0100', 'ca': '0100', 'cb': '0100', 'cc': '0100', 'cd': '0100', 'ce': '0100', 'cf': '0100', 'd0': '0041504339464530453237333030323735', 'e5': '0100', 'f7': '0301000000', 'f8': '040202010100010000000000000000000000000000', 'f9': '0102', 'fd': '0041313736315f33304168'}""", id="solix_both_packets_with_update", ), @@ -1238,6 +1647,39 @@ def test_payload_decryption( # Test an Anker Prime device (single payload device) with a single telemetry packet # from the logs of someone elses unit which for some reason transmits telemetry # unencrypted + pytest.param( + PrimeCharger160w, + [ + "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'}""", + id="prime_telemetry_packet_plain_text", + id="solix_both_packets_with_update", + ), + # Test an Anker Prime device (single payload device) with a single telemetry packet. + pytest.param( + PrimeCharger160w, + [ + "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'}""", + id="prime_telemetry_packet", + ), + # Test an Anker Prime power bank (single payload device) with a single telemetry packet. + pytest.param( + PrimePowerBank20k, + [ + "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'}""", + id="prime_power_bank_telemetry_packet", + ), + # Test an Anker Prime device (single payload device) with a single telemetry packet + # from the logs of someone elses unit which for some reason transmits telemetry + # unencrypted pytest.param( PrimeCharger160w, [ @@ -1279,19 +1721,34 @@ async def test_telemetry_packet_processing( packets: list[str], secret: str, parameters: str | None, + fast_sleep, + fast_timeouts, + device_class: type[SolixBLEDevice], + packets: list[str], + secret: str, + parameters: str | None, ): """ Test the _process_notification function when processing telemetry packets end to end. + :param device_class: Class of device under test. :param device_class: Class of device under test. :param packets: List of packets to send to device. :param secret: Shared secret used as AES key and IV. + :param secret: Shared secret used as AES key and IV. :param parameters: Expected parameters in string form. """ device = device_class(MOCK_BLE_DEVICE) + negotiation_responses = ( + NEGOTIATION_RESPONSES_PRIME + if issubclass(device_class, PrimeDevice) + else NEGOTIATION_RESPONSES_SOLIX + ) + device = device_class(MOCK_BLE_DEVICE) + negotiation_responses = ( NEGOTIATION_RESPONSES_PRIME if issubclass(device_class, PrimeDevice) @@ -1301,10 +1758,12 @@ async def test_telemetry_packet_processing( async with MockDevice() as mock_bluetooth: # We first expect a negotiation + for expected, response in negotiation_responses.items(): for expected, response in negotiation_responses.items(): mock_bluetooth.expect_ordered( bytes.fromhex(expected), [bytes.fromhex(x) for x in response], + [bytes.fromhex(x) for x in response], ) # We expect the negotiations to succeed @@ -1314,10 +1773,12 @@ async def test_telemetry_packet_processing( assert device.negotiated, "Expected connected to be True" mock_bluetooth.check_assertions() + device._shared_secret = bytes.fromhex(secret) device._shared_secret = bytes.fromhex(secret) for packet in packets: await mock_bluetooth.send_data([bytes.fromhex(packet)]) + await mock_bluetooth.send_data([bytes.fromhex(packet)]) device_parameters = ( device._parameters_to_str(device._data) if device._data else None @@ -1401,6 +1862,81 @@ async def test_generic_packet_processing( ), f"Expected to find '{expected_log_entry}' in logs but it was not found!" +@pytest.mark.asyncio +@pytest.mark.parametrize( + "device_class, packets, secret, expected_logs", + [ + # Telemetry packet from logs of someone elses Prime 160w charger. + # Interestingly this packet is not encrypted at all + pytest.param( + PrimeCharger160w, + [ + "ff09ca000301110300a10131a203024606a303020000a4020100a5080401e042b105b209a6080401e81300000000a7080400000000000000a8020103a9020150aa020100ab090400000000000b0b0bac0d0401002c0100002c0100000200ad0d0401002c0100002c0100000201ae0d0401002c0100002c0100000300af020100b0020100b1020100b2020101b30201ffb40d0400000000ac051573fafffbffb50d04ffffffffffffffffffffffffe0050448000000e10b0400000000000000000000fe05030000000074" + ], + "5609bc39f79166da75139feb7c335fb7524b3bf0d730db96bf6ebf450d3e165b", + [ + "Received non-encrypted telemetry message", + "Telemetry parameters: {'a1': '31', 'a2': '024606'", + ], + id="prime_160w_other", + ), + ], +) +async def test_generic_packet_processing( + caplog, + fast_sleep, + fast_timeouts, + device_class: type[SolixBLEDevice], + packets: list[str], + secret: str, + expected_logs: list[str], +): + """ + Test the _process_notification function when processing arbitrary + packets and check for expected log entries. + + :param device_class: Class of device under test. + :param packets: List of packets to send to device. + :param secret: Shared secret used as AES key and IV. + :param expected_logs: List of expected entries in the debug log. + """ + + device = device_class(MOCK_BLE_DEVICE) + + negotiation_responses = ( + 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( + bytes.fromhex(expected), + [bytes.fromhex(x) for x in response], + ) + + # We expect the negotiations to succeed + assert await device.connect(), "Expected connect to return True" + await asyncio.sleep(0.5) + assert device.connected, "Expected connected to be True" + assert device.negotiated, "Expected connected to be True" + mock_bluetooth.check_assertions() + + device._shared_secret = bytes.fromhex(secret) + + for packet in packets: + 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!" + + @pytest.mark.asyncio @pytest.mark.parametrize( "device_class,payload,mapping,errors", @@ -1463,6 +1999,7 @@ async def test_generic_packet_processing( async def test_bad_values( caplog, device_class: type[SolixBLEDevice], + device_class: type[SolixBLEDevice], payload: str, mapping: dict[str, Any], errors: list[str], @@ -1487,14 +2024,13 @@ async def test_bad_values( device = device_class(MOCK_BLE_DEVICE) parameters = device._parse_payload(bytes.fromhex(payload)) await device._process_telemetry(parameters) + 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}'!" - for error_message in errors: - assert error_message in caplog.text @pytest.mark.parametrize( "start_time, end_time, output_wattage, max_soc", @@ -1667,4 +2203,4 @@ def test_sb1_family_load_schedule_bytes() -> None: device.FamilyLoadSchedule( start_time=360, end_time=870, output_wattage=240, max_soc=80 ), - ] \ No newline at end of file + ] From 276567c78bbe7a5cb559e245926b730faa1abfdd Mon Sep 17 00:00:00 2001 From: Harvey <42912136+flip-dots@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:31:23 +0100 Subject: [PATCH 12/12] Fix issues caused by rebase --- tests/test_devices.py | 538 ------------------------------------------ 1 file changed, 538 deletions(-) diff --git a/tests/test_devices.py b/tests/test_devices.py index 7f127aa..f7c8de6 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -25,12 +25,8 @@ PrimeCharger160w, PrimeDevice, PrimePowerBank20k, - PrimeCharger160w, - PrimeDevice, - PrimePowerBank20k, Solarbank1, Solarbank2, - Solarbank2, SolixBLEDevice, TemperatureUnit, ) @@ -42,15 +38,6 @@ NEGOTIATION_RESPONSES_SOLIX, ) from tests.helpers import MockDevice -) -from SolixBLE.devices.solarbank2 import MaxLoadSB2 -from SolixBLE.states import GridStatus, LightMode, SBPowerCutoff, SBUsageMode -from tests.const import ( - MOCK_BLE_DEVICE, - NEGOTIATION_RESPONSES_PRIME, - NEGOTIATION_RESPONSES_SOLIX, -) -from tests.helpers import MockDevice @pytest.mark.asyncio @@ -347,71 +334,6 @@ }, id="c1000g2_dc_on", ), - # The two cases below are decrypted telemetry frames captured from a real - # C1000 Gen 2 (A1763) on 2026-06-21 with the AC output physically off then - # on (idle, no load). They are identical except for the "a7" param, which - # locks in the AC output decode: ac_output is "a7" byte 1 (00=off, 01=on, - # latched) -- the same per-port "04 " shape used by the - # DC port (b2) and USB ports. ("a4" byte 22 is NOT the AC state: it stayed - # 01 with the port physically off, so an a4-based decode reports a false - # OUTPUT.) - pytest.param( - C1000G2, - "a10131a221062011415043444b39363047313631303033393000054131373633030401010100a30e0400000000b0040064cc00580200a41b0400000000580232010000000000f0003c00010000000100500a00a506042400396400a60a04000000000000ab2a39a70704000000000000a80404000000aa0404000000ab0404000000ac0404000000ae0404000000b20404000000d91a04000019500a0000000000000000000000000000000000000000da18040000000000000000000001e00164057f00000000000000dc06040000000000f91d0403040101060005000000000000000000090300010000000006090200fa15040101010100170300000000000000000000000000fd0e0031373832303439353930383637fe050364f4376a", - { - "serial_number": "APCDK960G16100390", - "part_number": "A1763", - "temperature": 36, - "battery_percentage": 57, - "battery_health": 100, - "ac_output": PortStatus.NOT_CONNECTED, - "ac_power_in": 0, - "ac_power_out": 0, - "power_out": 0, - "solar_port": PortStatus.NOT_CONNECTED, - "dc_output": PortStatus.NOT_CONNECTED, - "max_battery_percentage": 80, - "min_battery_percentage": 10, - }, - id="c1000g2_ac_off", - ), - pytest.param( - C1000G2, - "a10131a221062011415043444b39363047313631303033393000054131373633030401010100a30e0400000000b0040064cc00580200a41b0400000000580232010000000000f0003c00010000000100500a00a506042400396400a60a04000000000000ab2a39a70704010000000000a80404000000aa0404000000ab0404000000ac0404000000ae0404000000b20404000000d91a04000019500a0000000000000000000000000000000000000000da18040000000000000000000001e00164057f00000000000000dc06040000000000f91d0403040101060005000000000000000000090300010000000006090200fa15040101010100170300000000000000000000000000fd0e0031373832303439353930383637fe050369f4376a", - { - "serial_number": "APCDK960G16100390", - "part_number": "A1763", - "temperature": 36, - "battery_percentage": 57, - "battery_health": 100, - "ac_output": PortStatus.OUTPUT, - "ac_power_in": 0, - "ac_power_out": 0, - "power_out": 0, - "solar_port": PortStatus.NOT_CONNECTED, - "dc_output": PortStatus.NOT_CONNECTED, - "max_battery_percentage": 80, - "min_battery_percentage": 10, - }, - id="c1000g2_ac_on", - ), - # Derived from the idle "c1000g2" frame above with only the "b2" param - # changed from 04000000 to 04010600 -- the value observed live on a real - # C1000 Gen 2 with the DC output on and a ~6 W 12 V load. This locks in - # the DC decode: dc_output is "b2" byte 1 (01 = OUTPUT) and dc_power_out - # is "b2" [2:4] little-endian watts (0x0006 = 6 W). - pytest.param( - C1000G2, - "a10134a221062011415043444b39363146333734303032393000054131373633060201010100a30b0400000000b0040058dc00a41b0400000000b0043201000000000000001e00010000000000640103a506041700646400a60a04000000000000ab2a64a70704000000010000a80404000000aa0404000000ab0404000000ac0404000000ae0404000000b20404010600d91a0400001964010000000100000000000000000000000000000000da18040000000000000000000001e00164057f00000000000000dc06040000000000f91d0406020101050005000000000005000500050300010000000000020200fa150401010101001f0300000000000000000000000000fd0e0031373634363538323735393838fe0503638c2e69f0", - { - "serial_number": "APCDK961F37400290", - "battery_percentage": 100, - "ac_output": PortStatus.NOT_CONNECTED, - "dc_output": PortStatus.OUTPUT, - "dc_power_out": 6, - }, - id="c1000g2_dc_on", - ), pytest.param( C300, "a10131a2050300000000a3050300000000a40302ffffa503020000a603025400a703020000a803020000a903020000aa03020100ab03020000ac03020000ad03020000ae03025500af03020000b003020100b103021b04b20302fc01b30302fc01b403021c00b503027b00b603021b04b7020101b8020100b9020124ba020100bb020164bc020164bd020100be020100bf020100c0020101c1020100c2020100c3020100c4020100c51100415a5653424a30453339323030303438c603024a01c70302a005c803022c01c903023c00ca03020000cb020101cc020100cd020102ce020132cf020100d0020100d1020101", @@ -717,132 +639,6 @@ }, id="prime_power_bank_20k_discharge_c1_a1_charge_c2", ), - pytest.param( - PrimeCharger160w, - "a10131a20302e805a303020000a4020100a5080400000000000000a6080400000000000000a7080400000000000000a8020103a9020150aa020100ab090400000f0f0f000000ac0d0401002c0100002c0100000300ad0d0401002c0100002c0100000300ae0d0401002c0100002c0100000300af020100b0020100b1020101b2020101b3020101b40d04fafffbfffafffbfffafffbffb50d04ffffffffffffffffffffffffe0050408000000e10b0480034b53000000000000fe050300000000", - { - "usb_port_c1": PortStatus.NOT_CONNECTED, - "usb_c1_current": 0.0, - "usb_c1_power": 0.0, - "usb_c1_voltage": 0.0, - "usb_port_c2": PortStatus.NOT_CONNECTED, - "usb_c2_current": 0.0, - "usb_c2_power": 0.0, - "usb_c2_voltage": 0.0, - "usb_port_c3": PortStatus.NOT_CONNECTED, - "usb_c3_current": 0.0, - "usb_c3_power": 0.0, - "usb_c3_voltage": 0.0, - }, - id="prime_160w_idle", - ), - pytest.param( - PrimeCharger160w, - "a10131a20302e805a303020000a4020100a5080401e01374003700a608040108236c030b03a7080401d81364003200a8020103a9020150aa020100ab090400000f0f0f000000ac0d0401002c0100002c0100000000ad0d0401002c0100002c0100000203ae0d0401002c0100002c0100000000af020100b0020100b1020101b2020101b3020101b40d0400000000e804000000000000b50d04ffffffffffffffffffffffffe0050408000000e10b0480034b53000000000000fe050300000000", - { - "usb_port_c1": PortStatus.OUTPUT, - "usb_c1_current": 0.116, - "usb_c1_power": 0.55, - "usb_c1_voltage": 5.088, - "usb_port_c2": PortStatus.OUTPUT, - "usb_c2_current": 0.876, - "usb_c2_power": 7.79, - "usb_c2_voltage": 8.968, - "usb_port_c3": PortStatus.OUTPUT, - "usb_c3_current": 0.1, - "usb_c3_power": 0.5, - "usb_c3_voltage": 5.08, - }, - id="prime_160w_all_three_charging", - ), - pytest.param( - PrimePowerBank20k, - "a10131a203044d60a30404010000a4020101a50404000000a60404000000a7080400000000000000a80f0400000000009600ff00ffffffff00a90f0400000000000000ff00ffffffff00ac09040000000000000000af02011db002011eb103020900fe050300000000", - { - "battery_percentage": 77, - "temperature": 29, - "power_out": 0.0, - "usb_port_c1": PortStatus.NOT_CONNECTED, - "usb_c1_current": 0.0, - "usb_c1_power": 15.0, - "usb_c1_voltage": 0.0, - "usb_port_c2": PortStatus.NOT_CONNECTED, - "usb_c2_current": 0.0, - "usb_c2_power": 0.0, - "usb_c2_voltage": 0.0, - "usb_port_a1": PortStatus.NOT_CONNECTED, - "usb_a1_current": 0.0, - "usb_a1_power": 0.0, - "usb_a1_voltage": 0.0, - }, - id="prime_power_bank_20k_idle", - ), - pytest.param( - PrimePowerBank20k, - "a10131a20304515ca30404010000a4020101a50404000000a60404013601a7080400000000000000a80f04019500140036010107ffffffff00a90f0400000000000000ff00ffffffff00ac09040000000000000000af02011ab002011bb103020900fe050300000000", - { - "battery_percentage": 81, - "temperature": 26, - "power_out": 31.0, - "usb_port_c1": PortStatus.OUTPUT, - "usb_c1_current": 2.0, - "usb_c1_power": 31.0, - "usb_c1_voltage": 14.9, - "usb_port_c2": PortStatus.NOT_CONNECTED, - "usb_c2_current": 0.0, - "usb_c2_power": 0.0, - "usb_c2_voltage": 0.0, - "usb_port_a1": PortStatus.NOT_CONNECTED, - "usb_a1_current": 0.0, - "usb_a1_power": 0.0, - "usb_a1_voltage": 0.0, - }, - id="prime_power_bank_20k_discharge_c1", - ), - pytest.param( - PrimePowerBank20k, - "a10131a20304505ca30404010000a4020101a50404000000a60404013a01a7080400000000000000a80f0400000000003d01ff00ffffffff00a90f0401950014002a010107ffffffff00ac09040133000300100000af02011bb002011cb103020900fe050300000000", - { - "battery_percentage": 80, - "temperature": 27, - "power_out": 31.4, - "usb_port_c1": PortStatus.NOT_CONNECTED, - "usb_c1_current": 0.0, - "usb_c1_power": 31.7, - "usb_c1_voltage": 0.0, - "usb_port_c2": PortStatus.OUTPUT, - "usb_c2_current": 2.0, - "usb_c2_power": 29.8, - "usb_c2_voltage": 14.9, - "usb_port_a1": PortStatus.OUTPUT, - "usb_a1_current": 0.3, - "usb_a1_power": 1.6, - "usb_a1_voltage": 5.1, - }, - id="prime_power_bank_20k_discharge_c2_a1", - ), - pytest.param( - PrimePowerBank20k, - "a10131a203044b5da30404010018a4020101a50404014102a6040401a300a7080400000000000000a80f04015900100096000107ffffffff00a90f0402c9001c004102ff07ffffffff00ac090401330002000d0000af02011cb002011db103020900fe050300000000", - { - "battery_percentage": 75, - "temperature": 28, - "power_out": 16.3, - "usb_port_c1": PortStatus.OUTPUT, - "usb_c1_current": 1.6, - "usb_c1_power": 15.0, - "usb_c1_voltage": 8.9, - "usb_port_c2": PortStatus.INPUT, - "usb_c2_current": 2.8, - "usb_c2_power": 57.7, - "usb_c2_voltage": 20.1, - "usb_port_a1": PortStatus.OUTPUT, - "usb_a1_current": 0.2, - "usb_a1_power": 1.3, - "usb_a1_voltage": 5.1, - }, - id="prime_power_bank_20k_discharge_c1_a1_charge_c2", - ), pytest.param( C300DC, "a10131a2050300000000a303020000a403020000a503020000a603020000a703020000a803020000a903020000aa03020000ab03020000ac03020000ad03020000ae03020000af03020000b003020000b103020000b203020000b303020000b403020000b5020180b6020100b7020100b8020100b9020100ba020100bb020100bc020100bd020100be020100bf020100c0020100c1020100c2020100c3110020202020202020202020202020202020c403020000c503020000c603020000c7020100c8020100c9020100ca020100cb03020000cc020100cd020100f7050300000000f815040000000000000000000000000000000000000000", @@ -1055,51 +851,6 @@ }, id="maggo_3in1_phone_higher_power", ), - pytest.param( - Solarbank2, - "a10131a2110041504347513830453030303030303030a302013aa4020101a503020000a605030100060aa7050300000631a8050300030306a9020100aa020111ab050300000000ac0503f4010000ad02013aae020100af020100b0050300000000b10503e0bd0200b20503723c0a00b305038d840200b4020105b5020104b6020105b7050388130000b8020101b9020100ba050328000000bb020100bc050300000000bd050300000000be050300000000bf050300000000c0110000000000000000000000000000000000c1020100c203022003c40503f4010000c5020100c6020101c703023200c8050300000000c9050306000000ca050300000000cb050300000000cc050300000000cd050300000000d2020100d30503f4010000d4110000000000000000000000000000000000d503020000d6110000000000000000000000000000000000d703020000d8110000000000000000000000000000000000d903020000da110000000000000000000000000000000000db03020000dc110000000000000000000000000000000000dd03020000de110000000000000000000000000000000000df03020000e0020102e1020101e2020100e3020100e4020100e5020100e6020100e7020100e8020100e9020100ea020101fe05039a46d969fb050300000000fc1604010101010001010101010100000000000000000000", - { - "serial_number": "APCGQ80E00000000", - "battery_percentage": 58, - "battery_percentage_aggregate": 58, - "error_code": 0, - "software_version": "1.6.8.1.6.5.3.7.7", - "software_version_controller": "8.2.2.4.7.6.8.0.0", - "software_version_expansion": "1.0.0.8.6.0.6.7.2", - "temperature_unit": TemperatureUnit.CELSIUS, - "temperature": 17, - "solar_power_in": 0.0, - "solar_pv_1_power_in": 0.0, - "solar_pv_2_power_in": 0.0, - "solar_pv_3_power_in": 0.0, - "solar_pv_4_power_in": 0.0, - "ac_power_out": 50.0, - "ac_power_out_sockets": 0.0, - "battery_charge_power": 0.0, - "battery_discharge_power": 50.0, - "pv_yield": 17.968, - "charged_energy": 6.70834, - "output_energy": 16.5005, - "grid_to_home_power": 0.0, - "pv_to_grid_power": 0.0, - "grid_import_energy": 0.0, - "grid_export_energy": 0.0, - "house_demand": 50.0, - "consumed_energy": 0.0006, - "power_out": 50.0, - "max_load": MaxLoadSB2.W800, - "output_cutoff_data": SBPowerCutoff.P5, - "lowpower_input_data": 4, - "input_cutoff_data": SBPowerCutoff.P5, - "usage_mode": SBUsageMode.MANUAL, - "home_load_preset": 50, - "light_mode": LightMode.NORMAL, - "grid_status": GridStatus.OK_AS_WELL_I_GUESS, - "light_on": False, - "battery_heating": False, - }, - id="solarbank2_telemetry", - ), ], ) async def test_values( @@ -1115,7 +866,6 @@ async def test_values( device = device_class(MOCK_BLE_DEVICE) parameters = device._parse_payload(bytes.fromhex(payload)) await device._process_telemetry(parameters) - await device._process_telemetry(parameters) for class_property, expected_value in mapping.items(): assert ( @@ -1147,33 +897,8 @@ async def test_c1000g2_dc_control() -> None: ) -@pytest.mark.asyncio -async def test_c1000g2_dc_control() -> None: - """C1000 Gen 2 DC output control dispatches command 4102. - - Confirmed on real hardware (the 12 V port physically switched and acked). - Here we just lock in that turn_dc_on/off send command 4102 with the same - on/off payloads as the AC output, which is the only difference between the - two on the Gen 2. - """ - device = C1000G2(MOCK_BLE_DEVICE) - device._send_command = mock.AsyncMock() - - await device.turn_dc_on() - device._send_command.assert_awaited_once_with( - 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") - ) - - @pytest.mark.asyncio @pytest.mark.parametrize( - "device_class,packets,secret", "device_class,packets,secret", [ pytest.param( @@ -1186,14 +911,6 @@ async def test_c1000g2_dc_control() -> None: "ff094d00030001082100a140c2a5a88fab34c1ac0f96a52e1b93354a47fb6c674b5afebacf5a2ed755435f41f0d26e97782e54e268b46d9f8a58a267cd7f7a239771e6289e55d94f7669ed448a", None, ], - [ - "ff090e00030001080100a1010152", - "ff091b00030001080300a10102a202fd00a30144a40101a50102ff", - "ff093800030001082900a10103a2054553503332a307302e302e302e33a410415a5653424a30453339323030303438a506f49d8a53a95a14", - "ff090b00030001080500f2", - "ff094d00030001082100a140c2a5a88fab34c1ac0f96a52e1b93354a47fb6c674b5afebacf5a2ed755435f41f0d26e97782e54e268b46d9f8a58a267cd7f7a239771e6289e55d94f7669ed448a", - None, - ], "2e9edc471d11bd214d45c0a651ab42e3cd370e04f1b860fc85adfaf612aba33f", id="c300_1", ), @@ -1207,14 +924,6 @@ async def test_c1000g2_dc_control() -> None: "ff094d00030001082100a140a7b5d3824a36cae20bab9fc4d9358191e5351905a782eda157f376cc43f1f761ab772d437f33787188716d1bebd81719d1eb76b94f08499ee93895d5b43e75ef5f", None, ], - [ - "ff090e00030001080100a1010152", - "ff091b00030001080300a10102a202fd00a30144a40101a50102ff", - "ff093800030001082900a10103a2054553503332a307302e302e302e33a410415a5653424a30453339323030303438a506f49d8a53a95a14", - "ff090b00030001080500f2", - "ff094d00030001082100a140a7b5d3824a36cae20bab9fc4d9358191e5351905a782eda157f376cc43f1f761ab772d437f33787188716d1bebd81719d1eb76b94f08499ee93895d5b43e75ef5f", - None, - ], "f97b0112a955846530c60e4cf95f941df76d86ab9ca106aa4bd00fe1c4fcb14f", id="c300_2", ), @@ -1228,14 +937,6 @@ async def test_c1000g2_dc_control() -> None: "ff094d00030001082100a140d3ef70a8faeb9ae7d9be034390108c2c7b177f3d549eb87318bd7a31703fc604664efb0e4600298ca9a905fb5af170955fb76229791dd583478b84d9950bd65420", None, ], - [ - "ff090e00030001080100a1010152", - "ff091b00030001080300a10102a202fd00a30144a40101a50102ff", - "ff093800030001082900a10103a2054553503332a307302e302e302e33a41041504339464530453237333030323735a506f49d8a104e0c9a", - "ff090b00030001080500f2", - "ff094d00030001082100a140d3ef70a8faeb9ae7d9be034390108c2c7b177f3d549eb87318bd7a31703fc604664efb0e4600298ca9a905fb5af170955fb76229791dd583478b84d9950bd65420", - None, - ], "2bdc8c8bfecf40814f602e6547cf29bf125abcc1a93be0751d8f1065a2bb5570", id="c1000_1", ), @@ -1249,14 +950,6 @@ async def test_c1000g2_dc_control() -> None: "ff094d00030001082100a140b2ade5cac4f4a0c1307e44a0e9c5363cb21e4c8485ee324c23be949fa5d5929a75e57da3207c948a0c366ca9ea1ab2cb8e57d2d046a6ebefe5d96adb5d4cb35039", None, ], - [ - "ff090e00030001080100a1010152", - "ff091b00030001080300a10102a202fd00a30144a40101a50102ff", - "ff093800030001082900a10103a2054553503332a307302e302e302e33a41041504339464530453237333030323735a506f49d8a104e0c9a", - "ff090b00030001080500f2", - "ff094d00030001082100a140b2ade5cac4f4a0c1307e44a0e9c5363cb21e4c8485ee324c23be949fa5d5929a75e57da3207c948a0c366ca9ea1ab2cb8e57d2d046a6ebefe5d96adb5d4cb35039", - None, - ], "0c4d9db9ef376fcfe627b9b73089eda514315d4bf67fb7eb299f2894ef7a059c", id="c1000_2", ), @@ -1289,42 +982,9 @@ async def test_c1000g2_dc_control() -> None: "5609bc39f79166da75139feb7c335fb7524b3bf0d730db96bf6ebf450d3e165b", id="prime_power_bank_20k", ), - pytest.param( - Solarbank2, - [ - "ff090e00030001080100a1010152", - "ff091b00030001080300a10102a202fd00a30144a40101a50102ff", - "ff093800030001082900a10103a2054553503332a307302e302e302e33a41041504347513830453030303030303030a50600000000000039", - "ff090b00030001080500f2", - "ff094d00030001082100a140f809d676751fba1346f21198c8a583b1ef9b9a617fb804455c388d07090e6dc2976c1bb1cf06aee1f30a3286af9dd80f8f0c594010f60755292addedfe41385972", - None, - ], - "6a2c89888de58cce1e15d98eb22669898ec29bcb1519ce19f950439aac9dbcb5", - id="solarbank2_1", - ), - pytest.param( - PrimePowerBank20k, - [ - "ff091e000300014801ab273ed3e27270c3f4d676ac7d69a00572793732a6", - "ff092b000300014803ab273ed0443800b35db54c6d4a6ec3d48171a04ea7ebce8bf749e5e48c5d991a5e67", - "ff0958000300014829ab273ed144326ada9fc66fa02508c5ddf549ade014d1eeb352fea11c0315b70b8aaa8a734ca5830f8d5827acbaa1224f05ad300b38d27bac9862a768d95c29daed0a89e92feb1d09163a094aa700ff", - "ff091b000300014805abab709a595a803dd04246b78a927453cf65", - "ff095d000300014821ab277f4e77c3b9e1f44367539f64f85d19969d0273c2c0ca93a06f3a010cf636e3b2df75d10791adf1e3c706a3238bcf0a858cd1e2d55d4cf1164a1b7db3b0058c47dfb24c71f11f8a96209d9f0924d420f03120", - "ff091b000300014822e520695552c2745a608fd21cf84bc6e3ccb9", - "ff091b000300014827e520695552c2745a608fd21cf84bc6e3ccbc", - "ff09df000301114a00e5a17fe3ebb89758b89ffb0e7d35a36ffeaeba3e991d79323680049a018c8e719bb706b6d00a142199a6cdc7f05bb5489f1ebb093fe3d134caf7ae5ad7b456867d9a58885cee8479bc10ea2d42d5b94d3b5a929cf4f4fd25f987e5a4922ae6fa744e22289080676583f390c1351a4b68ac5c1dabdcbf8e5e23416e47a0cea7a6062326dd8505464f821ba881f0f6f2c8ea050a7c978962980a539e90879aa1499b5be92fdceb53de533fc2bdd78b7998aec24493fdcfe3d2bc7e95b383744f92a4168819350e89d0d3142d1dbedcb779e45cfad12008", - "ff098300030111430044014f704abfd87d1d38fc0d7a35a36efdaf1f9f9f1c799493804dfaa6882d789fb7aeb4d117bd2330cd63c5f13f1e4a089ce80ac2442c66c85fa1f0dcb0d6867d9a58f7a3ee8479ec124724f6d7b84d8a58939c465ffb24e43754a1889be5f8c946d82d93806765835569e75bd67cbd3ac71071159c13a83bb9", - ], - "5609bc39f79166da75139feb7c335fb7524b3bf0d730db96bf6ebf450d3e165b", - id="prime_power_bank_20k", - ), ], ) async def test_negotiation( - fast_sleep, - fast_timeouts, - device_class: type[SolixBLEDevice], - packets: list[str], fast_sleep, fast_timeouts, device_class: type[SolixBLEDevice], @@ -1336,18 +996,12 @@ async def test_negotiation( :param device_class: The class of the device being tested. :param packets: Packets sent by the mock device in response to our packets. - :param packets: Packets sent by the mock device in response to our packets. :param secret: The expected shared secret. """ async with MockDevice() as mock_bluetooth: device = device_class(MOCK_BLE_DEVICE) - for packet in packets: - mock_bluetooth.expect_ordered( - None, - [bytes.fromhex(packet)] if packet else [], - ) for packet in packets: mock_bluetooth.expect_ordered( None, @@ -1361,18 +1015,14 @@ async def test_negotiation( assert ( bytes.fromhex(secret) == device._shared_secret ), "Shared secret does not match expected" - bytes.fromhex(secret) == device._shared_secret - ), "Shared secret does not match expected" mock_bluetooth.check_assertions() @pytest.mark.parametrize( - "device_class,payload,secret,decrypted", "device_class,payload,secret,decrypted", [ pytest.param( - C300, C300, "5bc7c7b05cf74c1ba441a17a5568f4b25bc061d354f498e39ba509e2c7664ce36d6a9ee8280a40736b9b681f10ab6eb7c86bca4b88fe6fc39ca3391d7ede4e1c47b6b5f0e5ccc67c841a0eb0912039323c27f9e819244424914c9fb538e93a23bc9bfd0f4e9df1b59fec44b5236c75c6f45e42a1110152e56491f8381ae07e50113e3746ca9a16182bc8c9102bbb463eb42d27b1e6330feb3f76d21bf751fe4a1d469c64cd8c9bda426943d48fc7c583c665ea21c7ee23fdde9262d47727c9454d88dd30d291f9bc9b0936a66761846c729f898895d97c158c36e703626ea8499fbf2dc8962159f1b7380f5f84038240d5df00ce1a7eecb4f3ea0b7de9aac5b8637d78f0f3fcf6d600227148d5011bd765a99be6d6ab0e83b9ebe8dcb9ce5ba6", "23a6446c34efb9f9ab1dbc43ffc8e289fffdfed557f849c4e91bd7baec0c4814", @@ -1380,7 +1030,6 @@ async def test_negotiation( id="c300_telemetry", ), pytest.param( - C1000, C1000, "403d9e7311afd074672804704798c421db698f11a5a0fc4bd793c127871c6eea7a970666c9b614c494e62b15770b1dba3dc98019e34cf0eb0ebecb5a2c5bc9ae39441d5e5acad73a645112b779312966513b53ba6f78c0f82cda624cce3b08a1a83416bd52fa4caf37e05cfaa9b37ddea75447be949ba10b892c320398fae0191c1290af0e79791c56c0d2217aafb9259b13cd2ccb9e4d520548eb416f4f96b9d852231578d4d516495564215c297fce97549986ef47058168d77afddc8ac5c0b59c9bfaf681a4cd60eca4bfad743731ca81849b83689e452e68f82fcab9fa2404f05f22b557b73705d16bab42b8045ffcc8083f9cb4fa4acda9997de1a40a2eac55b5dfbc70d882874c1db1990b76ae009bb1997ab507d347c84f3fd39d6f6c", "0c4d9db9ef376fcfe627b9b73089eda514315d4bf67fb7eb299f2894ef7a059c", @@ -1388,7 +1037,6 @@ async def test_negotiation( id="c1000_telemetry", ), pytest.param( - C1000, C1000, "a9fdb7f5f88e0d7ec2c3a36f9cb4f226", "cf9b34f93bc679b84c9754a9484a56991cef242c586b23dbef195ba0f2ee02cb", @@ -1396,7 +1044,6 @@ async def test_negotiation( id="c1000_cmd_ack_ac_on", ), pytest.param( - C1000, C1000, "2eb0fc833d00ca9e33491eab73ccfda202cfdedb86599ba5d0e3c2c059652818", "cf9b34f93bc679b84c9754a9484a56991cef242c586b23dbef195ba0f2ee02cb", @@ -1425,33 +1072,8 @@ async def test_negotiation( "a10131a203044d60a30404010000a4020101a50404000000a60404000000a7080400000000000000a80f0400000000009600ff00ffffffff00a90f0400000000000000ff00ffffffff00ac09040000000000000000af02011db002011eb103020900fe050300000000", id="prime_power_bank_telemetry", ), - pytest.param( - PrimeCharger160w, - "57e9a883d95e4bc95b5be2baa1c366331abb9292585357de1f59c997254092ef1372bd5a26ef6b51d61dc87082ca8e7985aacad07f64181902c70c0502de2418e366f5f700b13049d9b857e95c85c66a32d64fcf31c8eead9e025ed69c1440170cca149e038501a9544b1baa044a6a65392e154357e137d917fc834e019012a01b9bd18d5ca7dc22bdb0204b0629b3f738f34bafdc26f6bb0781cec80fe547674a6a7a341a018ce3ac81e6eb6b5110d3311db692d174fe363acec5ba606a24b975c2bb2a43ddfe5351f54d9fcd295709", - "09486817d949a232b58b47a43cc72d045a617a26f3999d30e1d27e38eae52265", - "a10131a20302e805a303020000a4020100a508040150235704eb03a6080400000000000000a7080400000000000000a8020103a9020150aa020100ab090400000f0f0f000000ac0d0401002c0100002c0100000203ad0d0401002c0100002c0100000300ae0d0401002c0100002c0100000300af020100b0020100b1020101b2020101b3020101b40d04e8040000fafffbfffafffbffb50d04ffffffffffffffffffffffffe0050408000000e10b0480034b53000000000000fe050300000000", - id="prime_160w_telemetry", - ), - # Different anker prime charger from other tests - pytest.param( - PrimeCharger160w, - "14676a53fc1315457c58163660d5b7bb4a6c83be2f8511d2bc79e2428827907a591b28a709df413e4fa633dc943dd7d2902c46bdcd69ea2bfe4c529f577dfe492d3192aa04f2b2a66fa745b4ed64d34a0a8100d4dd165514edd14499cf1243fbc9d1c216239bc53b756256f4dc04723c470a10434d49e3e38c6d6e1c2054a4890ea244a14964ef6b69eecc3ce8debc0f50537a6be461f3a1b9eb6cc1f1303d8dcf9488a8d4c8bc60729fa669974a4b84a50a0d5f75833c157e5e5c54cf19f944e731932e076b25892c13e0b3979ccd11", - "c0779a39bfa7b290ba9cd3d96b6fdc22a1f6a9746d4fc81e942c3d95", - "a10131a20302e805a303020000a4020100a5080400000000000000a6080401d84e00000000a7080400000000000000a8020100a9020150aa020100ab090400001c50343b3b3bac0d0401002c0100002c0100000300ad0d0401002c0100002c0100000100ae0d0401002c0100002c0100000300af020101b0020101b1020100b2020101b30201ffb40d04fafffbff00000000fafffbffb50d04ffffffffffffffffffffffffe0050408000000e10b0400000000000000000000fe050300000000", - id="prime_160w_telemetry_alt", - ), - pytest.param( - PrimePowerBank20k, - "44014f704abfd87d1d38fc0d7a35a36efdaf1f9f9f1c799493804dfaa6882d789fb7aeb4d117bd2330cd63c5f13f1e4a089ce80ac2442c66c85fa1f0dcb0d6867d9a58f7a3ee8479ec124724f6d7b84d8a58939c465ffb24e43754a1889be5f8c946d82d93806765835569e75bd67cbd3ac71071159c13a83b", - "5609bc39f79166da75139feb7c335fb7524b3bf0d730db96bf6ebf450d3e165b", - "a10131a203044d60a30404010000a4020101a50404000000a60404000000a7080400000000000000a80f0400000000009600ff00ffffffff00a90f0400000000000000ff00ffffffff00ac09040000000000000000af02011db002011eb103020900fe050300000000", - id="prime_power_bank_telemetry", - ), ], ) -def test_payload_decryption( - device_class: type[SolixBLEDevice], payload: str, secret: str, decrypted: str -): def test_payload_decryption( device_class: type[SolixBLEDevice], payload: str, secret: str, decrypted: str ): @@ -1459,16 +1081,12 @@ def test_payload_decryption( Test the decryption of a payload only. This does not test the splitting of a packet. - :param device_class: Class of device under test. :param device_class: Class of device under test. :param payload: Payload to be decrypted. :param secret: Shared secret used for AES key and IV. - :param secret: Shared secret used for AES key and IV. :param decrypted: Expected content of decrypted payload. """ - device = device_class(MOCK_BLE_DEVICE) - device._shared_secret = bytes.fromhex(secret) device = device_class(MOCK_BLE_DEVICE) device._shared_secret = bytes.fromhex(secret) @@ -1478,12 +1096,10 @@ def test_payload_decryption( @pytest.mark.asyncio @pytest.mark.parametrize( - "device_class, packets, secret, parameters", "device_class, packets, secret, parameters", [ # Test that when there are no packets device._ data is None pytest.param( - SolixBLEDevice, SolixBLEDevice, [], "", @@ -1492,75 +1108,60 @@ def test_payload_decryption( ), # Test that when there there are 0/2 required packets device._data is None pytest.param( - C1000, C1000, [ "ff092a0003010f440156ecb95eb746de03d40ee711ce99f42837a9554c6382d3f5298a3b0648d8536936" ], "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", - "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", None, id="irrelevant_packet_only", ), # Test that when there there is only 1/2 required packets device._data is None pytest.param( - C1000, C1000, [ "ff09390003010fc40222788d127d8418b41a81719975719a26b32734ea4e44ce244683e31928bb9a2736f9ede939567cddce6b3fb0de68116c" ], "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", - "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", None, id="solix_packet_1_missing", - id="solix_packet_1_missing", ), # Test that when there there is only 1/2 required packets device._data is None pytest.param( - C1000, C1000, [ "ff09fd0003010fc402121e0e23790307a57d4adabcd8d5ad56c3a9ea3cb5b222b0152438ccd3b980eda40fbde184fa66c80c3372dad179f11cad8799858ab95696e52c7e729af87c1106343ed5be9c042c8912b14f3a0d94b32afbed432e66616e1895ba0ff5e74a6da9401117070c926631e5d7886a07bec0de35aeb689e8bb289f1d7854143dc413f25d4b57d290ca4378cfb8efc275aa779145f98956e934eaced2d1f51cef7dd21a340318bfc14fb5f90ffd33e0e484175512af33593b1f91eb9801d7c2e1ac6d56e8fe7e8883d62226484ed6f1af711d042c5e3d0c186b3f2222293bc71ccf4a156a544d5171e90ee9b6b9b8f36ae058b96e3b88" ], "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", - "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", None, id="solix_packet_2_missing", - id="solix_packet_2_missing", ), # Test that when the 1st packet arrives after the 2nd packet is it ignored pytest.param( - C1000, C1000, [ "ff09390003010fc40222788d127d8418b41a81719975719a26b32734ea4e44ce244683e31928bb9a2736f9ede939567cddce6b3fb0de68116c", "ff09fd0003010fc402121e0e23790307a57d4adabcd8d5ad56c3a9ea3cb5b222b0152438ccd3b980eda40fbde184fa66c80c3372dad179f11cad8799858ab95696e52c7e729af87c1106343ed5be9c042c8912b14f3a0d94b32afbed432e66616e1895ba0ff5e74a6da9401117070c926631e5d7886a07bec0de35aeb689e8bb289f1d7854143dc413f25d4b57d290ca4378cfb8efc275aa779145f98956e934eaced2d1f51cef7dd21a340318bfc14fb5f90ffd33e0e484175512af33593b1f91eb9801d7c2e1ac6d56e8fe7e8883d62226484ed6f1af711d042c5e3d0c186b3f2222293bc71ccf4a156a544d5171e90ee9b6b9b8f36ae058b96e3b88", ], "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", - "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", None, id="solix_both_packets_reversed", - id="solix_both_packets_reversed", ), # Test that when the packets arrive in order they are parsed and device._data is populated pytest.param( - C1000, C1000, [ "ff09fd0003010fc402121e0e23790307a57d4adabcd8d5ad56c3a9ea3cb5b222b0152438ccd3b980eda40fbde184fa66c80c3372dad179f11cad8799858ab95696e52c7e729af87c1106343ed5be9c042c8912b14f3a0d94b32afbed432e66616e1895ba0ff5e74a6da9401117070c926631e5d7886a07bec0de35aeb689e8bb289f1d7854143dc413f25d4b57d290ca4378cfb8efc275aa779145f98956e934eaced2d1f51cef7dd21a340318bfc14fb5f90ffd33e0e484175512af33593b1f91eb9801d7c2e1ac6d56e8fe7e8883d62226484ed6f1af711d042c5e3d0c186b3f2222293bc71ccf4a156a544d5171e90ee9b6b9b8f36ae058b96e3b88", "ff09390003010fc40222788d127d8418b41a81719975719a26b32734ea4e44ce244683e31928bb9a2736f9ede939567cddce6b3fb0de68116c", ], "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", - "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", """{'a1': '31', 'a2': '0300000000', 'a3': '0300000000', 'a4': '02720f', 'a5': '020000', 'a6': '020000', 'a7': '020000', 'a8': '020000', 'a9': '020000', 'aa': '020000', 'ab': '020000', 'ac': '020000', 'ad': '020000', 'ae': '020000', 'af': '020000', 'b0': '020100', 'b1': '020000', 'b2': '020100', 'b3': '02a600', 'b4': '020000', 'b5': '02ff01', 'b6': '02ff01', 'b7': '020000', 'b8': '029a00', 'b9': '020000', 'ba': '02a600', 'bb': '020000', 'bc': '0100', 'bd': '0122', 'be': '0100', 'bf': '0101', 'c0': '0100', 'c1': '0164', 'c2': '0100', 'c3': '0164', 'c4': '0100', 'c5': '0100', 'c6': '0100', 'c7': '0100', 'c8': '0100', 'c9': '0100', 'ca': '0100', 'cb': '0100', 'cc': '0100', 'cd': '0100', 'ce': '0100', 'cf': '0100', 'd0': '0041504339464530453237333030323735', 'e5': '0100', 'f7': '0301000000', 'f8': '040202010100010000000000000000000000000000', 'f9': '0102', 'fd': '0041313736315f33304168'}""", id="solix_both_packets", - id="solix_both_packets", ), # Test that when the packets arrive in order they are parsed and device._data is populated # but that the later packet does not result in any changes to the data because it is not # valid until the next telemetry packet arrives pytest.param( - C1000, C1000, [ "ff09fd0003010fc402121e0e23790307a57d4adabcd8d5ad56c3a9ea3cb5b222b0152438ccd3b980eda40fbde184fa66c80c3372dad179f11cad8799858ab95696e52c7e729af87c1106343ed5be9c042c8912b14f3a0d94b32afbed432e66616e1895ba0ff5e74a6da9401117070c926631e5d7886a07bec0de35aeb689e8bb289f1d7854143dc413f25d4b57d290ca4378cfb8efc275aa779145f98956e934eaced2d1f51cef7dd21a340318bfc14fb5f90ffd33e0e484175512af33593b1f91eb9801d7c2e1ac6d56e8fe7e8883d62226484ed6f1af711d042c5e3d0c186b3f2222293bc71ccf4a156a544d5171e90ee9b6b9b8f36ae058b96e3b88", @@ -1568,16 +1169,13 @@ def test_payload_decryption( "ff09fd0003010fc402121e0e23790307a57d4adabcd8d5ad56c3218e598b95b4b8aa7ff3483fd3cfc72612b49fad1e5e27b50be913da3b73328c0db3e5f58c5a86dce0f36a9c080db786c1b917a8541d43aec30c6cbd2b229876255894ac5269fb9f3d4258450905bbe28781c5544d7eb57553bc5c39418d02fba353983a9b0f318e951d57ccc019cea984f9a64b0cb793bec8c696936b16fac2d72c59c4b95561f5f534c448f911d5e1c9ac30601e04fb2338313498d083cc6f676b0797b587ebc5e2fc32e60562f5e41e44682b5f8f094bcbea33e0926f304366d5df28c4868d00ba37eb754c9921e9b63ebb0bb1fb76f644c0760636df1303362106", ], "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", - "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", """{'a1': '31', 'a2': '0300000000', 'a3': '0300000000', 'a4': '02720f', 'a5': '020000', 'a6': '020000', 'a7': '020000', 'a8': '020000', 'a9': '020000', 'aa': '020000', 'ab': '020000', 'ac': '020000', 'ad': '020000', 'ae': '020000', 'af': '020000', 'b0': '020100', 'b1': '020000', 'b2': '020100', 'b3': '02a600', 'b4': '020000', 'b5': '02ff01', 'b6': '02ff01', 'b7': '020000', 'b8': '029a00', 'b9': '020000', 'ba': '02a600', 'bb': '020000', 'bc': '0100', 'bd': '0122', 'be': '0100', 'bf': '0101', 'c0': '0100', 'c1': '0164', 'c2': '0100', 'c3': '0164', 'c4': '0100', 'c5': '0100', 'c6': '0100', 'c7': '0100', 'c8': '0100', 'c9': '0100', 'ca': '0100', 'cb': '0100', 'cc': '0100', 'cd': '0100', 'ce': '0100', 'cf': '0100', 'd0': '0041504339464530453237333030323735', 'e5': '0100', 'f7': '0301000000', 'f8': '040202010100010000000000000000000000000000', 'f9': '0102', 'fd': '0041313736315f33304168'}""", id="solix_both_packets_later_invalidates", - id="solix_both_packets_later_invalidates", ), # Test that when the packets arrive in order they are parsed and device._data is populated # but that the later packet does not result in any changes to the data because it is out # of order pytest.param( - C1000, C1000, [ "ff09fd0003010fc402121e0e23790307a57d4adabcd8d5ad56c3a9ea3cb5b222b0152438ccd3b980eda40fbde184fa66c80c3372dad179f11cad8799858ab95696e52c7e729af87c1106343ed5be9c042c8912b14f3a0d94b32afbed432e66616e1895ba0ff5e74a6da9401117070c926631e5d7886a07bec0de35aeb689e8bb289f1d7854143dc413f25d4b57d290ca4378cfb8efc275aa779145f98956e934eaced2d1f51cef7dd21a340318bfc14fb5f90ffd33e0e484175512af33593b1f91eb9801d7c2e1ac6d56e8fe7e8883d62226484ed6f1af711d042c5e3d0c186b3f2222293bc71ccf4a156a544d5171e90ee9b6b9b8f36ae058b96e3b88", @@ -1586,16 +1184,13 @@ def test_payload_decryption( "ff09fd0003010fc402121e0e23790307a57d4adabcd8d5ad56c3218e598b95b4b8aa7ff3483fd3cfc72612b49fad1e5e27b50be913da3b73328c0db3e5f58c5a86dce0f36a9c080db786c1b917a8541d43aec30c6cbd2b229876255894ac5269fb9f3d4258450905bbe28781c5544d7eb57553bc5c39418d02fba353983a9b0f318e951d57ccc019cea984f9a64b0cb793bec8c696936b16fac2d72c59c4b95561f5f534c448f911d5e1c9ac30601e04fb2338313498d083cc6f676b0797b587ebc5e2fc32e60562f5e41e44682b5f8f094bcbea33e0926f304366d5df28c4868d00ba37eb754c9921e9b63ebb0bb1fb76f644c0760636df1303362106", ], "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", - "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", """{'a1': '31', 'a2': '0300000000', 'a3': '0300000000', 'a4': '02720f', 'a5': '020000', 'a6': '020000', 'a7': '020000', 'a8': '020000', 'a9': '020000', 'aa': '020000', 'ab': '020000', 'ac': '020000', 'ad': '020000', 'ae': '020000', 'af': '020000', 'b0': '020100', 'b1': '020000', 'b2': '020100', 'b3': '02a600', 'b4': '020000', 'b5': '02ff01', 'b6': '02ff01', 'b7': '020000', 'b8': '029a00', 'b9': '020000', 'ba': '02a600', 'bb': '020000', 'bc': '0100', 'bd': '0122', 'be': '0100', 'bf': '0101', 'c0': '0100', 'c1': '0164', 'c2': '0100', 'c3': '0164', 'c4': '0100', 'c5': '0100', 'c6': '0100', 'c7': '0100', 'c8': '0100', 'c9': '0100', 'ca': '0100', 'cb': '0100', 'cc': '0100', 'cd': '0100', 'ce': '0100', 'cf': '0100', 'd0': '0041504339464530453237333030323735', 'e5': '0100', 'f7': '0301000000', 'f8': '040202010100010000000000000000000000000000', 'f9': '0102', 'fd': '0041313736315f33304168'}""", id="solix_both_packets_later_out_of_order", - id="solix_both_packets_later_out_of_order", ), # Test that when the packets arrive in order they are parsed and device._data is populated # but that the later non-telemetry packet does not result in any changes because it is # not a telemetry packet pytest.param( - C1000, C1000, [ "ff09fd0003010fc402121e0e23790307a57d4adabcd8d5ad56c3a9ea3cb5b222b0152438ccd3b980eda40fbde184fa66c80c3372dad179f11cad8799858ab95696e52c7e729af87c1106343ed5be9c042c8912b14f3a0d94b32afbed432e66616e1895ba0ff5e74a6da9401117070c926631e5d7886a07bec0de35aeb689e8bb289f1d7854143dc413f25d4b57d290ca4378cfb8efc275aa779145f98956e934eaced2d1f51cef7dd21a340318bfc14fb5f90ffd33e0e484175512af33593b1f91eb9801d7c2e1ac6d56e8fe7e8883d62226484ed6f1af711d042c5e3d0c186b3f2222293bc71ccf4a156a544d5171e90ee9b6b9b8f36ae058b96e3b88", @@ -1603,15 +1198,12 @@ def test_payload_decryption( "ff091a0003010f484a6e744378c57c16ca8ab3a40bebb6f39807", ], "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", - "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", """{'a1': '31', 'a2': '0300000000', 'a3': '0300000000', 'a4': '02720f', 'a5': '020000', 'a6': '020000', 'a7': '020000', 'a8': '020000', 'a9': '020000', 'aa': '020000', 'ab': '020000', 'ac': '020000', 'ad': '020000', 'ae': '020000', 'af': '020000', 'b0': '020100', 'b1': '020000', 'b2': '020100', 'b3': '02a600', 'b4': '020000', 'b5': '02ff01', 'b6': '02ff01', 'b7': '020000', 'b8': '029a00', 'b9': '020000', 'ba': '02a600', 'bb': '020000', 'bc': '0100', 'bd': '0122', 'be': '0100', 'bf': '0101', 'c0': '0100', 'c1': '0164', 'c2': '0100', 'c3': '0164', 'c4': '0100', 'c5': '0100', 'c6': '0100', 'c7': '0100', 'c8': '0100', 'c9': '0100', 'ca': '0100', 'cb': '0100', 'cc': '0100', 'cd': '0100', 'ce': '0100', 'cf': '0100', 'd0': '0041504339464530453237333030323735', 'e5': '0100', 'f7': '0301000000', 'f8': '040202010100010000000000000000000000000000', 'f9': '0102', 'fd': '0041313736315f33304168'}""", id="solix_both_packets_irrelevant_ignored", - id="solix_both_packets_irrelevant_ignored", ), # Test that when the packets arrive in order they are parsed and device._data is populated # and that once both of the next packets are received the device._data changes. pytest.param( - C1000, C1000, [ "ff09fd0003010fc402121e0e23790307a57d4adabcd8d5ad56c3a9ea3cb5b222b0152438ccd3b980eda40fbde184fa66c80c3372dad179f11cad8799858ab95696e52c7e729af87c1106343ed5be9c042c8912b14f3a0d94b32afbed432e66616e1895ba0ff5e74a6da9401117070c926631e5d7886a07bec0de35aeb689e8bb289f1d7854143dc413f25d4b57d290ca4378cfb8efc275aa779145f98956e934eaced2d1f51cef7dd21a340318bfc14fb5f90ffd33e0e484175512af33593b1f91eb9801d7c2e1ac6d56e8fe7e8883d62226484ed6f1af711d042c5e3d0c186b3f2222293bc71ccf4a156a544d5171e90ee9b6b9b8f36ae058b96e3b88", @@ -1620,7 +1212,6 @@ def test_payload_decryption( "ff09390003010fc40222922d054e0b6cd682ba63ba7cc0e158113a569150aa95c5a21bc3142c1ba2e95c06a7ce78547448520ae8cc1a2844fa", ], "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", - "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", """{'a1': '31', 'a2': '0300000000', 'a3': '0300000000', 'a4': '02d80e', 'a5': '020000', 'a6': '020000', 'a7': '020000', 'a8': '020000', 'a9': '020000', 'aa': '020000', 'ab': '020000', 'ac': '020000', 'ad': '020000', 'ae': '020000', 'af': '020000', 'b0': '020100', 'b1': '020000', 'b2': '020100', 'b3': '02a600', 'b4': '020000', 'b5': '02ff01', 'b6': '02ff01', 'b7': '020000', 'b8': '029a00', 'b9': '020000', 'ba': '02a600', 'bb': '020100', 'bc': '0100', 'bd': '0122', 'be': '0100', 'bf': '0101', 'c0': '0100', 'c1': '0164', 'c2': '0100', 'c3': '0164', 'c4': '0100', 'c5': '0100', 'c6': '0100', 'c7': '0100', 'c8': '0100', 'c9': '0100', 'ca': '0100', 'cb': '0100', 'cc': '0100', 'cd': '0100', 'ce': '0100', 'cf': '0100', 'd0': '0041504339464530453237333030323735', 'e5': '0100', 'f7': '0301000000', 'f8': '040202010100010000000000000000000000000000', 'f9': '0102', 'fd': '0041313736315f33304168'}""", id="solix_both_packets_with_update", ), @@ -1647,39 +1238,6 @@ def test_payload_decryption( # Test an Anker Prime device (single payload device) with a single telemetry packet # from the logs of someone elses unit which for some reason transmits telemetry # unencrypted - pytest.param( - PrimeCharger160w, - [ - "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'}""", - id="prime_telemetry_packet_plain_text", - id="solix_both_packets_with_update", - ), - # Test an Anker Prime device (single payload device) with a single telemetry packet. - pytest.param( - PrimeCharger160w, - [ - "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'}""", - id="prime_telemetry_packet", - ), - # Test an Anker Prime power bank (single payload device) with a single telemetry packet. - pytest.param( - PrimePowerBank20k, - [ - "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'}""", - id="prime_power_bank_telemetry_packet", - ), - # Test an Anker Prime device (single payload device) with a single telemetry packet - # from the logs of someone elses unit which for some reason transmits telemetry - # unencrypted pytest.param( PrimeCharger160w, [ @@ -1721,34 +1279,19 @@ async def test_telemetry_packet_processing( packets: list[str], secret: str, parameters: str | None, - fast_sleep, - fast_timeouts, - device_class: type[SolixBLEDevice], - packets: list[str], - secret: str, - parameters: str | None, ): """ Test the _process_notification function when processing telemetry packets end to end. - :param device_class: Class of device under test. :param device_class: Class of device under test. :param packets: List of packets to send to device. :param secret: Shared secret used as AES key and IV. - :param secret: Shared secret used as AES key and IV. :param parameters: Expected parameters in string form. """ device = device_class(MOCK_BLE_DEVICE) - negotiation_responses = ( - NEGOTIATION_RESPONSES_PRIME - if issubclass(device_class, PrimeDevice) - else NEGOTIATION_RESPONSES_SOLIX - ) - device = device_class(MOCK_BLE_DEVICE) - negotiation_responses = ( NEGOTIATION_RESPONSES_PRIME if issubclass(device_class, PrimeDevice) @@ -1758,12 +1301,10 @@ async def test_telemetry_packet_processing( async with MockDevice() as mock_bluetooth: # We first expect a negotiation - for expected, response in negotiation_responses.items(): for expected, response in negotiation_responses.items(): mock_bluetooth.expect_ordered( bytes.fromhex(expected), [bytes.fromhex(x) for x in response], - [bytes.fromhex(x) for x in response], ) # We expect the negotiations to succeed @@ -1773,12 +1314,10 @@ async def test_telemetry_packet_processing( assert device.negotiated, "Expected connected to be True" mock_bluetooth.check_assertions() - device._shared_secret = bytes.fromhex(secret) device._shared_secret = bytes.fromhex(secret) for packet in packets: await mock_bluetooth.send_data([bytes.fromhex(packet)]) - await mock_bluetooth.send_data([bytes.fromhex(packet)]) device_parameters = ( device._parameters_to_str(device._data) if device._data else None @@ -1862,81 +1401,6 @@ async def test_generic_packet_processing( ), f"Expected to find '{expected_log_entry}' in logs but it was not found!" -@pytest.mark.asyncio -@pytest.mark.parametrize( - "device_class, packets, secret, expected_logs", - [ - # Telemetry packet from logs of someone elses Prime 160w charger. - # Interestingly this packet is not encrypted at all - pytest.param( - PrimeCharger160w, - [ - "ff09ca000301110300a10131a203024606a303020000a4020100a5080401e042b105b209a6080401e81300000000a7080400000000000000a8020103a9020150aa020100ab090400000000000b0b0bac0d0401002c0100002c0100000200ad0d0401002c0100002c0100000201ae0d0401002c0100002c0100000300af020100b0020100b1020100b2020101b30201ffb40d0400000000ac051573fafffbffb50d04ffffffffffffffffffffffffe0050448000000e10b0400000000000000000000fe05030000000074" - ], - "5609bc39f79166da75139feb7c335fb7524b3bf0d730db96bf6ebf450d3e165b", - [ - "Received non-encrypted telemetry message", - "Telemetry parameters: {'a1': '31', 'a2': '024606'", - ], - id="prime_160w_other", - ), - ], -) -async def test_generic_packet_processing( - caplog, - fast_sleep, - fast_timeouts, - device_class: type[SolixBLEDevice], - packets: list[str], - secret: str, - expected_logs: list[str], -): - """ - Test the _process_notification function when processing arbitrary - packets and check for expected log entries. - - :param device_class: Class of device under test. - :param packets: List of packets to send to device. - :param secret: Shared secret used as AES key and IV. - :param expected_logs: List of expected entries in the debug log. - """ - - device = device_class(MOCK_BLE_DEVICE) - - negotiation_responses = ( - 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( - bytes.fromhex(expected), - [bytes.fromhex(x) for x in response], - ) - - # We expect the negotiations to succeed - assert await device.connect(), "Expected connect to return True" - await asyncio.sleep(0.5) - assert device.connected, "Expected connected to be True" - assert device.negotiated, "Expected connected to be True" - mock_bluetooth.check_assertions() - - device._shared_secret = bytes.fromhex(secret) - - for packet in packets: - 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!" - - @pytest.mark.asyncio @pytest.mark.parametrize( "device_class,payload,mapping,errors", @@ -1999,7 +1463,6 @@ async def test_generic_packet_processing( async def test_bad_values( caplog, device_class: type[SolixBLEDevice], - device_class: type[SolixBLEDevice], payload: str, mapping: dict[str, Any], errors: list[str], @@ -2024,7 +1487,6 @@ async def test_bad_values( device = device_class(MOCK_BLE_DEVICE) parameters = device._parse_payload(bytes.fromhex(payload)) await device._process_telemetry(parameters) - await device._process_telemetry(parameters) for class_property, expected_value in mapping.items(): assert (