diff --git a/SolixBLE/const.py b/SolixBLE/const.py index f85cffd..518c874 100644 --- a/SolixBLE/const.py +++ b/SolixBLE/const.py @@ -44,6 +44,12 @@ #: Bool value for unknown boolean attributes. DEFAULT_METADATA_BOOL = None +#: The pattern used in telemetry packers from some Anker devices +TELEMETRY_PATTERN_A = "03010f" + +#: The pattern used in negotiation packets from Anker devices +NEGOTIATION_PATTERN = "030001" + #: Command used to initiate negotiations NEGOTIATION_COMMAND_0 = "ff0936000300010001a10442ad8c69a22462326463306231372d623735642d346162662d626136652d656337633939376332336537b9" @@ -54,7 +60,7 @@ NEGOTIATION_COMMAND_2 = "ff0936000300010029a10442ad8c69a22462326463306231372d623735642d346162662d626136652d65633763393937633233653791" #: Response to receiving 3rd negotiation message -NEGOTIATION_COMMAND_3 = "ff0940000300010005a10443ad8c69a22462326463306231372d623735642d346162662d626136652d656337633939376332336537a30120a40200f0a50140fa" +NEGOTIATION_COMMAND_3 = "ff0940000300010005a10442ad8c69a22462326463306231372d623735642d346162662d626136652d656337633939376332336537a30120a40200f0a50140fb" #: Response to receiving 4th negotiation message NEGOTIATION_COMMAND_4 = "ff094c000300010021a140060ea168f232aedb37fb2d120c49180329ac72ab5ec3eb8fd30a2f252dc5e151dabccd9b1dc1e288704ca760a0d8c918e5c94823a1f609a4bf07fb4c33ee219085" @@ -62,12 +68,6 @@ #: Response to receiving 5th negotiation message NEGOTIATION_COMMAND_5 = "ff095a000300014022580bc0532a53c739adf3da7b994a7b5f221bcc16bab6392c215cb4faaf41d9d58e2c81c016e474c78eed5569147cb74a1f22ca2b3fad2e209dbbcfbdaca352034a6c479f055f68581b5f1e22348809f526" -#: The unix timestamp that is agreed upon in the negotiations. This is used -#: by Anker to protect against replay attacks as commands must contain the -#: current encrypted time. -BASE_TIMESTAMP = "42ad8c69" - - #: The private key this program uses to perform the ECDH negotiation to #: get a shared secret which is then used as an AES key for encrypting #: communications between the program and the power station. Yes I know it @@ -76,3 +76,6 @@ #: reason this has to be done at all is because Anker power stations no longer #: support sending telemetry in plain text after the latest firmware update. PRIVATE_KEY = "7dfbea61cd95cee49c458ad7419e817f1ade9a66136de3c7d5787af1458e39f4" + +# POSIX timezone to use if determining the system time zone fails +FALLBACK_TZ = "GMT0BST,M3.5.0/1,M10.5.0" diff --git a/SolixBLE/constructs.py b/SolixBLE/constructs.py new file mode 100644 index 0000000..b33a4dd --- /dev/null +++ b/SolixBLE/constructs.py @@ -0,0 +1,359 @@ +"""Data structures of packets. + +This module contains the byte structures used for encoding and +decoding the packet format used by Anker devices. + +.. moduleauthor:: Harvey Lelliott (flip-dots) + +""" + +import json +import operator +from functools import reduce +from typing import Any, Self + +from construct import ( + BitStruct, + Bytes, + Checksum, + Const, + Container, + ExprAdapter, + GreedyBytes, + GreedyRange, + Hex, + HexDump, + If, + Int8ul, + Int16ul, + Nibble, + Optional, + RawCopy, + Rebuild, + Struct, + this, +) + +from SolixBLE.utilities import _to_bytes + + +def _get_val(obj: Any, key: str, default: Any=None) -> Any: + """ + Return value from dictionary, container, objects, or None. + + :param obj: Object to extract value from. + :param key: The key or property to extract from the object. + :param default: Default to return if not found. + :returns: Found value or default. + """ + if obj is None: + return default + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + +Packet = ExprAdapter( + + # Structure of the packet + Struct( + + # Bytes of packet excluding checksum + "content" / RawCopy( + Struct( + + # Header of the packet + "header" / Hex(Const(bytes.fromhex("ff09"))), + + # Length of the entire packet + "length" / Rebuild(Int16ul, lambda this: 10 + len(this.payload_bytes)), + + # Pattern of the packet (e.g negotiation type, telemetry type etc) + "pattern" / Hex(Bytes(3)), + + # Command of the packet (e.g turn on, off, etc) + "cmd" / Hex(Bytes(2)), + + # Payload bytes of the packet (may be encrypted or fragmented) + "payload_bytes" / HexDump(Bytes(lambda this: this.length - 10)), + ), + ), + + # XOR checksum of the packet + "checksum" / Hex(Checksum( + Int8ul, + lambda data: reduce(operator.xor, data, 0), + this.content.data, + )), + ), + + # Encoders and decoders which allow for direct access + # (e.g packet.cmd rather than packet.content.cmd) + decoder=lambda p, _: p.content.value, + encoder=lambda p, _: { + "content": { + "value": { + "header": _to_bytes(_get_val(p, "header", "ff09")), + "pattern": _to_bytes(_get_val(p, "pattern")), + "cmd": _to_bytes(_get_val(p, "cmd")), + "payload_bytes": _to_bytes(_get_val(p, "payload_bytes", b"")), + }, + }, + }, +) +""" +Anker device packet. + +This class represents a packet of an Anker device. Packets are made up of a +header, size, pattern, cmd, payload, and a checksum. + +Structure:
. + +Usage: + .. code-block:: python + :linenos: + + packet = Packet.parse(packet_bytes) + print(f"p: {packet.pattern}, c: {packet.cmd}, b: {packet.payload_bytes}") + + packet_bytes = Packet.build({ + "pattern": "030001", + "cmd": "0000", + "payload_bytes": "a101a20200a303010000", + }) + +""" + +FragmentedPayload = Struct( + + # Fragment information + "frag" / BitStruct( + "index" / Nibble, + "total" / Nibble, + ), + + # The content of the payload + "data" / GreedyBytes, +) +""" +Payload section of an Anker packet that is fragmented. + +The "frag" section represents the fragmentation information of the payload. +This information is not always present in non-fragmented packets. + +The "data" section represents the content of the fragment and may be encrypted. +The fragments must be re-assembled before decryption can begin. + +This structure is used for re-assembling fragmented payloads only. + +Structure: . + +Usage: + + .. code-block:: python + :linenos: + + frag_payload = FragmentedPayload.parse(payload_bytes) + print(f"{frag_p.frag.index}/{frag_p.frag.total}: {frag_p.data}") + +""" + + +class ParameterContainer(Container): + """Subclass to allow for direct action on the parameter type.""" + + @property + def value_legacy(self) -> bytes: + """Return the type byte prepended to the value bytes for non-typed values.""" + type_byte = self.type.to_bytes(1) if self.get("type") is not None else b"" + val_bytes = self.get("value") or b"" + return type_byte + val_bytes + + def to_dict(self, types: bool | None = None) -> dict[str, str]: # noqa: FBT001 + """Return possible representations of the parameter in dict form. + + :param: Parameter to be interpreted. + :types: Display parameter with type information (T=y, F=n, N=both). + :returns: Dictionary of encodings to decoded values. + """ + representation: dict[str, str] = {} + + # Representation where no type info is encoded + p_bytes = self.value_legacy + + # Representation where first byte encodes type information + p_bytes_t = bytes(self.value) + + if types is not True: + representation.update({ + "bytes": str(p_bytes), + "hex": p_bytes.hex(), + "int": int.from_bytes(p_bytes, byteorder="little", signed=True), + "uint": int.from_bytes(p_bytes, byteorder="little", signed=False), + "length": len(p_bytes), + }) + + if types is not False: + representation.update({ + "type (t)": self.type, + "bytes (t)": str(p_bytes_t), + "hex (t)": p_bytes_t.hex(), + "int (t)": int.from_bytes(p_bytes_t, byteorder="little", signed=True), + "uint (t)": int.from_bytes(p_bytes_t, byteorder="little", signed=False), + "length (t)": len(p_bytes_t), + }) + + return representation + + +Parameter = ExprAdapter( + Struct( + + # The key of the parameter (e.g a1, a2, ...) + "key" / Hex(Bytes(1)), + + # The length of the parameter excluding the key + "length" / Rebuild( + Int8ul, + lambda p: (1 if p.get("type") is not None else 0) + len(p.get("value") or b""), + ), + + # Optional type of the parameter + "type" / If( + lambda p: p.get("type") is not None if p._building else p.length > 1, + Int8ul, + ), + + # Optional content of the parameter + "value" / If( + lambda p: (p.length - (1 if p.type is not None else 0)) > 0, + HexDump(Bytes(lambda p: p.length - (1 if p.type is not None else 0))), + ), + ), + decoder=lambda obj, _: ParameterContainer(obj), + encoder=lambda obj, _: obj, +) +""" +Individual parameter of a payload of an Anker packet. + +Paramaters contain a key (e.g a1, a2, ...), the length, optional +type information, and an optional content. + +Structure: . + +The length value is the length of the entire parameter excluding the key. + +This structure is only used as a part of the Parameters type for creating, +modifying, encoding, and decoding payloads. +""" + + +class ParameterDict(dict): + """Subclass to allow for direct action on the paramaters type.""" + + def __init__(self, *args, prefix: bytes | None = None, **kwargs): + super().__init__(*args, **kwargs) + self.prefix = prefix + + def diff(self, old: Self, types: bool | None = None) -> str: # noqa: FBT001 + """ + Return changes from previous parameters to this in string representation. + + :param old: Previous entry to compare against. + :param types: Display parameter with type information (T=y, F=n, N=both). + """ + differences: dict[str, str] = {} + + changed = {k for k in old.keys() & self.keys() if old[k] != self[k]} + added = self.keys() - old.keys() + removed = old.keys() - self.keys() + + for k in sorted(changed | added | removed): + + # Parameter modified + if k in changed: + old_p = old[k].to_dict(types=types) + new_p = self[k].to_dict(types=types) + + differences[k] = { + "state": "~", + **{f: f"{old_p[f]} -> {new_p[f]}" for f in old_p}, + } + + # Parameter added + elif k in added: + differences[k] = { + "state": "+", + **self[k].to_dict(types=types), + } + + # Parameter removed + elif k in removed: + differences[k] = { + "state": "-", + **old[k].to_dict(types=types), + } + + return json.dumps(differences, indent=4) + + def to_str(self, verbose: bool = False, types: bool | None = None) -> str: + """ + Return string representation of potential parameter encodings. + + :param verbose: Return possible representations instead of plain bytes. + :param types: Display parameter with type information (T=y, F=n, N=both). + :returns: String representation of parameters. + """ + if verbose: + return json.dumps({k: p.to_dict(types) for k, p in self.items()}, indent=4) + return str({k: v.value_legacy.hex() for k, v in self.items()}) + + def __str__(self) -> str: + """Return string representation of potential parameter encodings.""" + return self.to_str() + +Parameters = ExprAdapter( + Struct( + + # 0x00 optional prefix + "prefix" / If( + this._parsing or (this._building and this._.prefix is not None), + Optional(Const(bytes.fromhex("00"))), + ), + + # List of parameters + "parameters" / GreedyRange(Parameter), + ), + decoder=lambda obj, _: ParameterDict( + {p.key.hex(): p for p in obj.parameters}, + prefix=obj.prefix, + ), + encoder=lambda ps, _: { + "prefix": getattr(ps, "prefix", None), + "parameters": list(ps.values()) if isinstance(ps, dict) else ps, + }, +) +""" +Decoded parameters of the payload of an Anker packet. + +The payload of Anker packets is made up of a list of +parameters and is sometimes prefixed with 00. + +Structure: ... . + +This structure is used to encode, decode, modify, and generate payloads. + +Usage: + + .. code-block:: python + :linenos: + + parameters = Parameters.parse(reassembled_payload) + parameters["a1"] = Parameter({ + "key": "a1", + "type": 12, + "value": "00ff", + }) + + plaintext_payload = Parameters.build(parameters) + +""" diff --git a/SolixBLE/device.py b/SolixBLE/device.py index 2a52206..8e1d935 100644 --- a/SolixBLE/device.py +++ b/SolixBLE/device.py @@ -5,8 +5,8 @@ """ import asyncio +import copy import inspect -import json import logging import time from collections.abc import Callable @@ -26,17 +26,15 @@ ) from cryptography.hazmat.primitives.padding import PKCS7 +from SolixBLE.constructs import FragmentedPayload, Packet, ParameterDict, Parameters +from SolixBLE.utilities import _to_bytes, get_posix_tz + from .const import ( - BASE_TIMESTAMP, DEFAULT_METADATA_INT, DEFAULT_METADATA_STRING, DISCONNECT_TIMEOUT, - NEGOTIATION_COMMAND_0, - NEGOTIATION_COMMAND_1, - NEGOTIATION_COMMAND_2, - NEGOTIATION_COMMAND_3, - NEGOTIATION_COMMAND_4, - NEGOTIATION_COMMAND_5, + FALLBACK_TZ, + NEGOTIATION_PATTERN, NEGOTIATION_RESPONSE_TIMEOUT, NEGOTIATION_TIMEOUT, PRIVATE_KEY, @@ -48,6 +46,9 @@ _LOGGER = logging.getLogger(__name__) +#: The UUID sent to the device during negotiation +UUID_STRING = "b2dc0b17-b75d-4abf-ba6e-ec7c997c23e7" + class SolixBLEDevice: """Solix BLE device object.""" @@ -57,6 +58,9 @@ class SolixBLEDevice: #: (e.g the C1000 Gen 2 uses ``c421``/``c900`` instead of ``c402``/``c405``). _TELEMETRY_COMMANDS: tuple[str, ...] = ("c402", "4300", "c405") + #: The maximum packet size an Anker device is able to send + _mtu = 253 + def __init__(self, ble_device: BLEDevice) -> None: """Initialise device object. Does not connect automatically.""" @@ -67,9 +71,8 @@ def __init__(self, ble_device: BLEDevice) -> None: self._ble_device: BLEDevice = ble_device self._client: BleakClient | None = None - self._fragment_buffers: dict[bytes, dict[int, bytes]] = {} - self._fragment_totals: dict[bytes, int] = {} - self._data: dict[str, bytes] | None = None + self._fragment_buffers: dict[bytes, list[FragmentedPayload]] = {} + self._data: ParameterDict | None = None self._last_data_timestamp: datetime | None = None self._last_packet_timestamp: datetime | None = None self._negotiation_timestamp: float | None = None @@ -101,10 +104,18 @@ def remove_callback(self, function: Callable[[], None]) -> None: async def _initiate_negotiations(self) -> None: """Send the negotiation initiation command.""" - await self._client.write_gatt_char( - UUID_COMMAND, - bytes.fromhex(NEGOTIATION_COMMAND_0), - response=True, + await self._send_packet(pattern=NEGOTIATION_PATTERN, cmd="0001", + parameters={ + "a1": { + "key": bytes.fromhex("a1"), + "type": None, + "value": lambda self: self._timestamp(), + }, "a2": { + "key": bytes.fromhex("a2"), + "type": None, + "value": UUID_STRING.encode(), + }, + }, ) async def connect(self, max_attempts: int = 3, run_callbacks: bool = True) -> bool: @@ -349,7 +360,7 @@ def _parse_int( """ if self._data is None: return DEFAULT_METADATA_INT - int_bytes = self._data[key][begin:end] + int_bytes = self._data[key].value_legacy[begin:end] return int.from_bytes(int_bytes, byteorder="little", signed=signed) def _parse_string(self, key: str, begin: int = None, end: int = None) -> str: @@ -362,173 +373,20 @@ def _parse_string(self, key: str, begin: int = None, end: int = None) -> str: :raises UnicodeDecodeError: If bytes are not ASCII text. """ return ( - self._data[key][begin:end].decode("ascii") + self._data[key].value_legacy[begin:end].decode("ascii") if self._data else DEFAULT_METADATA_STRING ) - def _split_packet(self, packet: bytes) -> tuple[bytes, bytes, bytes]: - """Validate packet and split into pattern, command, and payload bytes.""" - - packet_copy = bytearray(packet) - - # Validate header is correct - packet_header = bytes([packet_copy.pop(0), packet_copy.pop(0)]) - if packet_header != bytes.fromhex("ff09"): - raise ValueError("Packet does not start with FF09!") - - # Validate encoded length is correct - packet_length = int.from_bytes( - bytes([packet_copy.pop(0), packet_copy.pop(0)]), byteorder="little" - ) - if packet_length != len(packet): - raise ValueError( - f"Packet length is encoded as {packet_length} but its length was {len(packet)}!" - ) - - # Validate checksum is correct - packet_checksum = packet_copy.pop(-1).to_bytes() - if packet_checksum != self._checksum(packet[:-1]): - raise ValueError( - f"Packet checksum is encoded as {packet_checksum.hex()} but it is actually {self._checksum(packet[:-1]).hex()}!" - ) - - # Extract pattern - packet_pattern = bytes( - [packet_copy.pop(0), packet_copy.pop(0), packet_copy.pop(0)] - ) - - # Extract command - packet_cmd = bytes([packet_copy.pop(0), packet_copy.pop(0)]) - - # Extract payload - packet_payload = bytes(packet_copy) - - return packet_pattern, packet_cmd, packet_payload - - def _parse_payload(self, payload: bytearray | bytes) -> dict[str, bytes]: - """ - Parse payload bytes into parameters. - - Payloads contain a list of parameters and these parameters - have a format of: . - - If an error occurs when decoding a parameter it prevents all - further parameters from being parsed and logs an exception, - but the successfully parsed parameters (if any) will be returned. - - :param payload: Payload to parse into parameters. - :returns: Dictionary mapping parameter ids (a1, a2, ...) to data. - """ - - def _verbose_pop(data: bytearray, length: int, name: str) -> bytes: - """ - Pop specified number of bytes from bytearray and log if error. - - :param data: Data to be popped. - :param length: Number of bytes to pop and return. - :param name: Name of value being popped to put in logs if error. - :raises IndexError: If popping fails. - """ - - # Copy of bytes to use in error message if needed - data_copy = bytes(data) - - # Bytes extracted so far - new_bytes = bytes([]) - - try: - # Pop length bytes from data and return - for _ in range(length): - new_bytes = new_bytes + bytes([data.pop(0)]) - return new_bytes - - # Build error message - except IndexError as e: - message = ( - f"Error extracting {name} (len={length}) from '{data_copy.hex()}'" - f" (len={len(data_copy)}) at index {len(new_bytes)}. We extracted:" - f" '{new_bytes.hex()}' but expected {length - len(data_copy)}" - f" more bytes!" - ) - _LOGGER.exception(message) - raise IndexError(message) from e - - parsed_data: dict[str, bytes] = {} - remaining_data = bytearray(payload) - - # Payloads sometimes start with 00 and we must strip that - if remaining_data.startswith(bytes.fromhex("00")): - _LOGGER.debug("Stripped 00 from start of payload") - _verbose_pop(remaining_data, 1, "special 00 header") - - while len(remaining_data) != 0: - try: - # Extract param id (e.g a1, a2, ...) - param_id = _verbose_pop(remaining_data, 1, "param_id").hex() - - # Sometimes there is just a param_id with no length or values - if len(remaining_data) == 0: - parsed_data[param_id] = bytes() - break - - # Extract encoded length of parameter - param_len = int.from_bytes( - _verbose_pop(remaining_data, 1, f"param_len (id={param_id})") - ) - - # Extract data/body from parameter - param_data = _verbose_pop( - remaining_data, param_len, f"param_data (id={param_id})" - ) - parsed_data[param_id] = param_data - - except IndexError: - _LOGGER.exception( - f"Unexpected end of packet! Data may be missing or invalid!" - f" Extracted so far: '{self._parameters_to_str(parsed_data)}'." - f" Payload: '{payload.hex()}'" - ) + def _decrypt_payload(self, payload: bytes) -> bytes: + """Decrypt payload using negotiated shared secret and IV if available.""" - return parsed_data - - def _parameters_to_str( - self, parameters: dict[str, bytes], types: bool = False - ) -> str: - if types: - with_types = { - k: { - "bytes": f"""{v}""", - "hex": f"""{v.hex()}""", - "uint": f"""{int.from_bytes(v[1:], byteorder="little")}""", - "int": f"""{int.from_bytes(v[1:], byteorder="little", signed=True)}""", - } - for k, v in parameters.items() - } - return json.dumps(with_types, indent=4, sort_keys=True) - else: - return str({k: v.hex() for k, v in parameters.items()}) - - def _log_diff(self, old: dict[str, bytes], new: dict[str, bytes]) -> None: - """Log any differences between parameters.""" - differences = { - k: { - "bytes": f"""{old[k]} -> {new[k]}""", - "hex": f"""{old[k].hex()} -> {new[k].hex()}""", - "uint": f"""{int.from_bytes(old[k][1:], byteorder="little")} -> {int.from_bytes(new[k][1:], byteorder="little")}""", - "int": f"""{int.from_bytes(old[k][1:], byteorder="little", signed=True)} -> {int.from_bytes(new[k][1:], byteorder="little", signed=True)}""", - } - for k in old.keys() & new.keys() - if new[k] != old[k] - } - _LOGGER.debug( - f"Parameter changes: \n{json.dumps(differences, indent=4, sort_keys=True)}" - ) + if self._shared_secret is None: + _LOGGER.debug("Skipping decryption as key not negotiated...") + return payload - def _decrypt_payload(self, payload: bytes) -> bytes: - """Decrypt telemetry packet using negotiated shared secret and IV.""" cipher = AES.new( - self._shared_secret[:16], AES.MODE_CBC, iv=self._shared_secret[16:] + self._shared_secret[:16], AES.MODE_CBC, iv=self._shared_secret[16:], ) decrypted = cipher.decrypt(payload) unpadder = PKCS7(128).unpadder() @@ -536,7 +394,11 @@ def _decrypt_payload(self, payload: bytes) -> bytes: return unpadded_data + unpadder.finalize() def _encrypt_payload(self, payload: bytes) -> bytes: - """Encrypt telemetry packet using negotiated shared secret and IV.""" + """Encrypt payload using negotiated shared secret if available.""" + + if self._shared_secret is None: + _LOGGER.debug("Skipping encryption as key not negotiated...") + return payload # Pad and encrypt payload padder = PKCS7(128).padder() @@ -547,186 +409,205 @@ def _encrypt_payload(self, payload: bytes) -> bytes: ) return cipher.encrypt(padded_data) - async def _process_telemetry_packet( - self, payload: bytes, cmd: bytes = None - ) -> None: - """Process a telemetry packet from the device. + async def _process_telemetry(self, parameters: ParameterDict) -> None: + """Process telemetry data from the device.""" - This performs the default processing of telemetry packets in which - telemetry payloads are spread across multiple packets. This is - overridden for devices which do not use multi-packet payloads for - telemetry. - """ + state_changed = self._data is None or parameters != self._data - # First byte encodes fragment info (high nibble = index, low = total) - fragment_index = (payload[0] >> 4) & 0x0F - fragment_total = payload[0] & 0x0F + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug(f"Telemetry parameters: {parameters.to_str(verbose=True)}") - # Multi-part message - if fragment_total > 1: - fragment_data = payload[1:] - cmd_key = bytes(cmd) - _LOGGER.debug( - f"Fragment {fragment_index}/{fragment_total} for cmd {cmd.hex()}, {len(fragment_data)} bytes" - ) + # Log state update if changes + if state_changed and self._data is not None: + _LOGGER.debug(f"Telemetry changes: {parameters.diff(self._data)}") - # Store fragment - if cmd_key not in self._fragment_buffers or fragment_index == 1: - self._fragment_buffers[cmd_key] = {} - self._fragment_totals[cmd_key] = fragment_total + # Update internal parameters + self._data = parameters + self._last_data_timestamp = datetime.now() - self._fragment_buffers[cmd_key][fragment_index] = fragment_data + # Run callbacks if state changed + if state_changed: - # Wait until all fragments have arrived - if len(self._fragment_buffers[cmd_key]) < fragment_total: - _LOGGER.debug("Waiting for remaining fragments...") - return + _LOGGER.debug(self) + self._run_state_changed_callbacks() - # Reassemble in order - payload = b"".join( - self._fragment_buffers[cmd_key][i] - for i in sorted(self._fragment_buffers[cmd_key]) - ) - del self._fragment_buffers[cmd_key] - del self._fragment_totals[cmd_key] - _LOGGER.debug(f"Reassembled payload: {len(payload)} bytes") + def _reassemble(self, packet: Packet) -> bytes | None: + """ + Re-assemble a packet. - else: - # Strip fragment info - payload = payload[1:] + Given a packet containing a fragment of a payload, re-assemble + it if all fragments are available and return it, else return + None. - decrypted_payload = self._decrypt_payload(payload) - _LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}") - parameters = self._parse_payload(decrypted_payload) - return await self._process_telemetry(parameters) + :param packet: The packet to be re-assembled. + :returns: Payload bytes if re-assembled. + :returns: None if not all fragments are available. + """ + # Parse payload + payload = FragmentedPayload.parse(packet.payload_bytes) - async def _process_telemetry(self, parameters: dict[str, bytes]) -> None: - """Process telemetry data from the device.""" + _LOGGER.debug(f"Received fragment {payload.frag.index}/{payload.frag.total} for p: {packet.pattern.hex()}, c: {packet.cmd.hex()}") - state_changed = self._data is None or parameters != self._data + # Get existing fragments or create list of one does not exist + fragments = self._fragment_buffers.get(packet.pattern + packet.cmd) + if fragments is None: + fragments = [] + self._fragment_buffers[packet.pattern + packet.cmd] = fragments - if _LOGGER.isEnabledFor(logging.DEBUG): - _LOGGER.debug( - f"Telemetry parameters: {self._parameters_to_str(parameters)}" - ) + # Add to list of fragments + fragments.append(payload) - # Print state update if changes - if state_changed: + # If out of order then ignore and clear buffers + if payload.frag.index != len(fragments): + _LOGGER.debug("Fragment is out of order, ignoring and clearing buffers!") + fragments.clear() + return None - # If we have previous data to compare against log the diff - if self._data is not None: - _LOGGER.debug("Parameters have changed since previous update!") - self._log_diff(self._data, parameters) + # If not all fragments available return + if payload.frag.total != len(fragments): + _LOGGER.debug("Not all fragments available for reassembly!") + return None - # Else log the parameters but with the types - else: - _LOGGER.debug( - f"Telemetry parameters: {self._parameters_to_str(parameters, types=True)}" - ) + _LOGGER.debug("Re-assembling payload from fragments...") - # Update internal parameters - self._data = parameters - self._last_data_timestamp = datetime.now() + # Assemble fragment payloads in order + complete_payload = bytearray() + for x in sorted(fragments, key=lambda p: int(p.frag.index)): + complete_payload.extend(x.data) - # Run callbacks if state changed - if state_changed: - - _LOGGER.debug(self) - self._run_state_changed_callbacks() + # Clear fragment cache for this message cmd and return + fragments.clear() + return bytes(complete_payload) async def _process_notification( self, client: BleakClient, handle: int, data: bytearray ) -> None: """Process a notification from the device.""" - _LOGGER.debug(f"The client the notification is from: {client}") + try: - if self._client is not client: - _LOGGER.debug("Ignoring notification from old client") - return + _LOGGER.debug(f"The client the notification is from: {client}") - # Split packet into pattern, command, and payload - _LOGGER.debug( - f"Received notification from '{self.name}'. length: {len(data)}, packet: '{data.hex()}'" - ) - self._last_packet_timestamp = time.time() - pattern, cmd, payload = self._split_packet(data) - _LOGGER.debug(f"Pattern: {pattern.hex()}") - _LOGGER.debug(f"CMD: {cmd.hex()}") - _LOGGER.debug(f"Payload: {payload.hex()}") - _LOGGER.debug(f"Payload length: {len(payload)}") - - # If the packet has a future registered then we just trigger that - # future instead of processing it here - if pattern + cmd in self._packet_futures: + if self._client is not client: + _LOGGER.debug("Ignoring notification from old client") + return None + + # Log reception of packet _LOGGER.debug( - "Packet has future(s) registered. Triggering future(s) and ignoring packet..." + f"Received notification from '{self.name}'. length: {len(data)}, packet: '{data.hex()}'" ) - for future in self._packet_futures[pattern + cmd]: - future.set_result(payload) - return - - # Match against common message types - match pattern.hex(): + self._last_packet_timestamp = time.time() + + # Parse packet + packet = Packet.parse(data) + _LOGGER.debug(f"Packet: {packet}") + pattern = packet.pattern + cmd = packet.cmd + payload = packet.payload_bytes + + # If packet is maximum size or a previous one of the same + # type was, then hand off to the fragment re-assembler which + # will re-assemble the payload when all fragments are available + if (len(data) == self._mtu or + pattern + cmd in self._fragment_buffers): + + payload = self._reassemble(packet) + if payload is None: + return None + + # If the packet has a future registered then we just trigger that + # future instead of processing it here + if pattern + cmd in self._packet_futures: + _LOGGER.debug( + "Packet has future(s) registered. Triggering future(s) and ignoring packet..." + ) + for future in self._packet_futures[pattern + cmd]: - # Negotiation messages - case "030001": - _LOGGER.debug("Received negotiation message!") - return await self._process_negotiation(cmd, payload) + # Decrypt payload + payload = self._decrypt_payload(payload) + future.set_result(payload) + return None - # Session messages - case "03010f" | "030111": + # Match against common message types + match pattern.hex(): - # Non-encrypted telemetry messages - if cmd.hex() == "0300": - _LOGGER.debug("Received non-encrypted telemetry message!") - parameters = self._parse_payload(payload) - return await self._process_telemetry(parameters) + # Negotiation messages + case "030001": + _LOGGER.debug("Received negotiation message!") + return await self._process_negotiation(cmd, payload) - # Encrypted telemetry messages - elif cmd.hex() in self._TELEMETRY_COMMANDS: - _LOGGER.debug("Received encrypted telemetry message!") - return await self._process_telemetry_packet(payload, cmd) + # Session messages + case "03010f" | "030111": - # Unknown messages - else: - _LOGGER.debug(f"Received unknown message of type: {cmd.hex()}") - try: - - # If the payload is one byte too short and we are - # using the default AES (CBC) then try putting the - # last byte of the cmd in front of it - if ( - len(payload) % 16 == 15 - and self._decrypt_payload - is SolixBLEDevice._decrypt_payload - ): - _LOGGER.debug( - "Using special trick of embedded part of CMD in payload..." - ) - payload = cmd[1].to_bytes() + payload + # Non-encrypted telemetry messages + if cmd.hex() == "0300": + _LOGGER.debug("Received non-encrypted telemetry message!") + parameters = Parameters.parse(payload) + return await self._process_telemetry(parameters) + # Encrypted telemetry messages + elif cmd.hex() in self._TELEMETRY_COMMANDS: + _LOGGER.debug("Received encrypted telemetry message!") decrypted_payload = self._decrypt_payload(payload) - _LOGGER.debug( - f"Decrypted payload: {decrypted_payload.hex()}" - ) - parameters = self._parse_payload(decrypted_payload) - _LOGGER.debug( - f"Parameters: {self._parameters_to_str(parameters, types=True)}" - ) - except Exception: - _LOGGER.exception( - "Exception decrypting unknown message type" - ) + _LOGGER.debug(f"Plain-text payload: {decrypted_payload.hex()}") + parameters = Parameters.parse(decrypted_payload) + return await self._process_telemetry(parameters) - case _: - _LOGGER.warning( - f"Unexpected packet type '{pattern}' sent by device! Packet: {data.hex()}" - ) + # Unknown messages + else: + _LOGGER.debug(f"Received unknown message of type: {cmd.hex()}") + + case _: + _LOGGER.warning( + f"Unexpected packet type '{pattern}' sent by device! Packet: {data.hex()}" + ) + + except Exception: + _LOGGER.exception(f"Failed to process packet from {self.name}!") + + return None + + async def _send_packet(self, pattern: str, cmd: str, parameters: dict, **kwargs: dict) -> None: + """ + Build and send packet to device. + + Parameter values may use lambda functions which will be executed at + this point, where variables may be passed in as keyword arguments. + """ + _LOGGER.debug(f"Building payload with parameters: {parameters}") + + parameters = copy.deepcopy(parameters) + for key, item in parameters.items(): + item["key"] = bytes.fromhex(key) + item["type"] = item.get("type", None) + item["value"] = _to_bytes(data=item["value"], **kwargs | { "self": self }) + _LOGGER.debug(f"Generated payload parameters: {parameters}") + + payload = Parameters.build(parameters) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug(f"Parameters: {Parameters.parse(payload).to_str(verbose=True)}") + _LOGGER.debug(f"Payload bytes: {payload.hex()}") + encrypted_payload = self._encrypt_payload(payload) + + _LOGGER.debug(f"Building packet with pattern: {pattern} and cmd: {cmd}...") + packet = Packet.build({ + "pattern": bytes.fromhex(pattern), + "cmd": bytes.fromhex(cmd), + "payload_bytes": encrypted_payload, + }) + _LOGGER.debug(f"Built packet: {packet.hex()}") + _LOGGER.debug("Sending packet...") + await self._client.write_gatt_char(UUID_COMMAND, packet) + _LOGGER.debug("Packet sent!") async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: """Negotiate encryption with the device.""" + plain_text_payload = self._decrypt_payload(payload) + _LOGGER.debug(f"Plain-text payload: {plain_text_payload.hex()}") + parameters = Parameters.parse(plain_text_payload) + _LOGGER.debug(f"Parameters: {parameters.to_str(verbose=True, types=False)}") + match cmd.hex(): # There is a "stage 0" in which we automatically send a negotiation @@ -737,80 +618,151 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: # Negotiation stage 1 case "0801": _LOGGER.debug( - "Entered negotiation stage 1 due to response from device!" + "Entered negotiation stage 1 due to response from device!", ) - parameters = self._parse_payload(payload) - _LOGGER.debug(f"Parameters: {self._parameters_to_str(parameters)}") _LOGGER.debug("Sending stage 1 response message...") - return await self._client.write_gatt_char( - UUID_COMMAND, bytes.fromhex(NEGOTIATION_COMMAND_1) + await self._send_packet(pattern=NEGOTIATION_PATTERN, cmd="0003", + parameters={ + "a1": { + "key": bytes.fromhex("a1"), + "type": None, + "value": lambda self: self._timestamp(), + }, "a2": { + "key": bytes.fromhex("a2"), + "type": None, + "value": UUID_STRING.encode(), + }, "a3": { + "key": bytes.fromhex("a3"), + "type": None, + "value": bytes.fromhex("20"), + }, "a4": { + "key": bytes.fromhex("a4"), + "type": None, + "value": bytes.fromhex("00f0"), + }, + }, ) # Negotiation stage 2 case "0803": _LOGGER.debug( - "Entered negotiation stage 2 due to response from device!" + "Entered negotiation stage 2 due to response from device!", ) - parameters = self._parse_payload(payload) - _LOGGER.debug(f"Parameters: {self._parameters_to_str(parameters)}") + self._mtu = int.from_bytes(parameters["a2"].value_legacy, byteorder="little") + _LOGGER.debug(f"MTU of device: {self._mtu}") + _LOGGER.debug("Sending stage 2 response message...") - return await self._client.write_gatt_char( - UUID_COMMAND, bytes.fromhex(NEGOTIATION_COMMAND_2) + await self._send_packet(pattern=NEGOTIATION_PATTERN, cmd="0029", + parameters={ + "a1": { + "key": bytes.fromhex("a1"), + "type": None, + "value": lambda self: self._timestamp(), + }, "a2": { + "key": bytes.fromhex("a2"), + "type": None, + "value": UUID_STRING.encode(), + }, + }, ) # Negotiation stage 3 case "0829": _LOGGER.debug( - "Entered negotiation stage 3 due to response from device!" + "Entered negotiation stage 3 due to response from device!", ) - parameters = self._parse_payload(payload) - _LOGGER.debug(f"Parameters: {self._parameters_to_str(parameters)}") self._negotiation_timestamp = time.time() _LOGGER.debug("Sending stage 3 response message...") - return await self._client.write_gatt_char( - UUID_COMMAND, bytes.fromhex(NEGOTIATION_COMMAND_3) + await self._send_packet(pattern=NEGOTIATION_PATTERN, cmd="0005", + parameters={ + "a1": { + "key": bytes.fromhex("a1"), + "type": None, + "value": lambda self: self._timestamp(), + }, "a2": { + "key": bytes.fromhex("a2"), + "type": None, + "value": UUID_STRING.encode(), + }, "a3": { + "key": bytes.fromhex("a3"), + "type": None, + "value": bytes.fromhex("20"), + }, "a4": { + "key": bytes.fromhex("a4"), + "type": None, + "value": bytes.fromhex("00f0"), + }, "a5": { + "key": bytes.fromhex("a5"), + "type": None, + "value": bytes.fromhex("40"), + }, + }, ) # Negotiation stage 4 case "0805": _LOGGER.debug( - "Entered negotiation stage 4 due to response from device!" + "Entered negotiation stage 4 due to response from device!", ) - parameters = self._parse_payload(payload) - _LOGGER.debug(f"Parameters: {self._parameters_to_str(parameters)}") _LOGGER.debug("Sending stage 4 response message...") - return await self._client.write_gatt_char( - UUID_COMMAND, bytes.fromhex(NEGOTIATION_COMMAND_4) + await self._send_packet(pattern=NEGOTIATION_PATTERN, cmd="0021", + parameters={ + "a1": { + "key": bytes.fromhex("a1"), + "type": None, + "value": bytes.fromhex("060ea168f232aedb37fb2d120c49180329ac72ab5ec3eb8fd30a2f252dc5e151dabccd9b1dc1e288704ca760a0d8c918e5c94823a1f609a4bf07fb4c33ee2190"), + }, + }, ) # Negotiation stage 5 case "0821": _LOGGER.debug( - "Entered negotiation stage 5 due to response from device!" + "Entered negotiation stage 5 due to response from device!", ) - parameters = self._parse_payload(payload) - _LOGGER.debug(f"Parameters: {self._parameters_to_str(parameters)}") # Extract public key of device from payload - device_public_key_bytes = bytes.fromhex("04") + parameters["a1"] + device_public_key_bytes = bytes.fromhex("04") + parameters["a1"].value_legacy _LOGGER.debug(f"Public key of device: {device_public_key_bytes.hex()}") device_public_key = EllipticCurvePublicKey.from_encoded_point( - SECP256R1(), device_public_key_bytes + SECP256R1(), device_public_key_bytes, ) # Calculate the shared secret # The first half of the shared secret is the encryption key # and the second half is the IV private_value = int.from_bytes( - bytes.fromhex(PRIVATE_KEY), byteorder="big" + bytes.fromhex(PRIVATE_KEY), byteorder="big", ) private_key = derive_private_key(private_value, SECP256R1()) self._shared_secret = private_key.exchange(ECDH(), device_public_key) _LOGGER.debug(f"Shared secret: {self._shared_secret.hex()}") _LOGGER.debug("Sending stage 5 response message...") - return await self._client.write_gatt_char( - UUID_COMMAND, bytes.fromhex(NEGOTIATION_COMMAND_5) + await self._send_packet(pattern=NEGOTIATION_PATTERN, cmd="4022", + parameters={ + "a1": { + "key": bytes.fromhex("a1"), + "type": None, + "value": lambda self: self._timestamp(), + }, "a2": { + "key": bytes.fromhex("a2"), + "type": None, + "value": UUID_STRING.encode(), + }, "a3": { + "key": bytes.fromhex("a3"), + "type": None, + "value": bytes.fromhex("20"), + }, "a4": { + "key": bytes.fromhex("a4"), + "type": None, + "value": bytes.fromhex("00000000"), + }, "a5": { + "key": bytes.fromhex("a5"), + "type": None, + "value": (get_posix_tz() or FALLBACK_TZ).encode(), + }, + }, ) # Negotiation stage 6 (Optional) @@ -821,76 +773,41 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: _LOGGER.debug( "Entered negotiation stage 6 (optional) due to response from device!" ) - decrypted_payload = self._decrypt_payload(payload) - parameters = self._parse_payload(decrypted_payload) - _LOGGER.debug(f"Parameters: {self._parameters_to_str(parameters)}") case _: + parameters = Parameters.parse(payload) _LOGGER.warning( - f"Received unexpected negotiation request response from device! cmd: '{cmd}', parameters: '{self._parameters_to_str(parameters)}'" + f"Received unexpected negotiation request response from device! cmd: '{cmd}', parameters: '{parameters}'" ) - def _checksum(self, packet: bytes) -> bytes: - """Calculate the checksum byte for a packet.""" - checksum_value = 0 - for b in packet: - checksum_value = checksum_value ^ b - return checksum_value.to_bytes(1) + def _timestamp(self) -> bytes: + """Unix timestamp in byte form (4B).""" + return int(time.time()).to_bytes(length=4, byteorder="little", signed=False) - async def _send_command(self, cmd: bytes, payload: bytes) -> None: + async def _send_command(self, cmd: str, parameters: dict, **kwargs: dict) -> None: """Send a command to the device. + Parameter values may use lambda functions which will be executed at + this point, where variables may be passed in as keyword arguments. + :param cmd: 2 bytes containing command type. - :param payload: Variable number of bytes containing arguments. + :param parameters: Parameter dictionary to send. :raises ConnectionError: If not connected/negotiated to device. """ + if not self.negotiated: raise ConnectionError("Not connected to device") - # Commands include a timestamp in the payload to prevent replay attacks - # and that timestamp is set during negotiations - time_passed = int(time.time() - self._negotiation_timestamp) - base_timestamp = int.from_bytes( - bytes.fromhex(BASE_TIMESTAMP), byteorder="little" - ) - new_timestamp = (base_timestamp + time_passed).to_bytes( - length=4, byteorder="little" + await self._send_packet( + pattern="03000f", + cmd=cmd, + parameters=parameters | { "fe": { + "key": bytes.fromhex("fe"), + "type": 3, + "value": lambda self: self._timestamp(), + }}, + **kwargs, ) - new_payload = payload + bytes.fromhex("fe0503") + new_timestamp - await self._send_encrypted_packet(cmd, new_payload) - - def _build_packet(self, pattern: bytes, cmd: bytes, payload: bytes) -> bytes: - """ - Build a packet to be send to a device. - - Packet format:
. - - :param pattern: Pattern of packet (e.g encrypted, negotiation, etc). - :param cmd: Command in packet (e.g telemetry, power on, etc). - :param payload: Payload of command (e.g a1...). - :returns: Packet bytes ready to be sent. - """ - - # Calculate length of message - length = 2 + 2 + 3 + 2 + len(payload) + 1 - length_bytes = length.to_bytes(length=2, byteorder="little") - - # Build packet - packet = bytes.fromhex("ff09") + length_bytes + pattern + cmd + payload - return packet + self._checksum(packet) - - async def _send_encrypted_packet(self, cmd: bytes, payload: bytes) -> None: - """Send an encrypted packet using negotiated shared secret and IV.""" - _LOGGER.debug( - f"Building packet with cmd: {cmd.hex()} and payload: {payload.hex()}" - ) - encrypted_payload = self._encrypt_payload(payload) - - packet = self._build_packet(bytes.fromhex("03000f"), cmd, encrypted_payload) - _LOGGER.debug(f"Sending encrypted packet: {packet.hex()}") - - # Send packet - await self._client.write_gatt_char(UUID_COMMAND, packet) def _register_future( self, future: asyncio.Future, pattern: bytes, cmd: bytes diff --git a/SolixBLE/devices/c1000.py b/SolixBLE/devices/c1000.py index 30b4f8d..02c8922 100644 --- a/SolixBLE/devices/c1000.py +++ b/SolixBLE/devices/c1000.py @@ -7,11 +7,13 @@ import logging from datetime import datetime, timedelta +from SolixBLE.constructs import ParameterDict, Parameters + from ..const import ( - DEFAULT_METADATA_BOOL, DEFAULT_METADATA_FLOAT, DEFAULT_METADATA_INT, DEFAULT_METADATA_STRING, + TELEMETRY_PATTERN_A, ) from ..device import SolixBLEDevice from ..states import DisplayTimeout, LightStatus, PortStatus @@ -22,11 +24,55 @@ CMD_DISPLAY_MODE = "404c" CMD_DISPLAY_TIMEOUT = "4046" CMD_DISPLAY_ON_OFF = "4052" - -PAYLOAD_ON = "a10121a2020101" -PAYLOAD_OFF = "a10121a2020100" -PAYLOAD_LIGHT_MODE = "a10121a20201" -PAYLOAD_TIMEOUT_TIME = "a10121a20302" +CMD_GET_STATUS = "4040" + +CMD_RESPONSE_GET_STATUS = "c840" + +PARAMETERS_ON = { + "a1": { + "value": "21", + }, "a2": { + "type": 1, + "value": 1, + }, +} + +PARAMETERS_OFF = { + "a1": { + "value": "21", + }, "a2": { + "type": 1, + "value": 0, + }, +} + +PARAMETERS_LIGHT_MODE = { + "a1": { + "value": "21", + }, "a2": { + "type": 1, + "value": lambda mode: mode.value, + }, +} + +PARAMETERS_TIMEOUT_TIME = { + "a1": { + "value": "21", + }, "a2": { + "type": 2, + "value": lambda time: time.value.to_bytes( + length=2, + byteorder="little", + signed=False, + ), + }, +} + +PARAMETERS_GET_STATUS = { + "a1": { + "value": "21", + }, +} _LOGGER = logging.getLogger(__name__) @@ -321,9 +367,7 @@ async def turn_ac_on(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_AC_OUTPUT), payload=bytes.fromhex(PAYLOAD_ON) - ) + await self._send_command(cmd=CMD_AC_OUTPUT, parameters=PARAMETERS_ON) async def turn_ac_off(self) -> None: """Turn the AC output off. @@ -331,9 +375,7 @@ async def turn_ac_off(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_AC_OUTPUT), payload=bytes.fromhex(PAYLOAD_OFF) - ) + await self._send_command(cmd=CMD_AC_OUTPUT, parameters=PARAMETERS_OFF) async def turn_dc_on(self) -> None: """Turn the DC output on. @@ -341,9 +383,7 @@ async def turn_dc_on(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_DC_OUTPUT), payload=bytes.fromhex(PAYLOAD_ON) - ) + await self._send_command(cmd=CMD_DC_OUTPUT, parameters=PARAMETERS_ON) async def turn_dc_off(self) -> None: """Turn the DC output off. @@ -351,9 +391,7 @@ async def turn_dc_off(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_DC_OUTPUT), payload=bytes.fromhex(PAYLOAD_OFF) - ) + await self._send_command(cmd=CMD_DC_OUTPUT, parameters=PARAMETERS_OFF) async def set_light_mode(self, mode: LightStatus) -> None: """Set the light mode of the LED bar. @@ -366,8 +404,9 @@ async def set_light_mode(self, mode: LightStatus) -> None: if mode is LightStatus.UNKNOWN: raise ValueError("You cannot set the light status to unknown") await self._send_command( - cmd=bytes.fromhex(CMD_LIGHT_MODE), - payload=bytes.fromhex(PAYLOAD_LIGHT_MODE) + mode.value.to_bytes(), + cmd=CMD_LIGHT_MODE, + parameters=PARAMETERS_LIGHT_MODE, + mode=mode, ) async def set_display_mode(self, mode: LightStatus) -> None: @@ -383,8 +422,9 @@ async def set_display_mode(self, mode: LightStatus) -> None: if mode is LightStatus.SOS: raise ValueError("You cannot set the display brightness status to SOS") await self._send_command( - cmd=bytes.fromhex(CMD_DISPLAY_MODE), - payload=bytes.fromhex(PAYLOAD_LIGHT_MODE) + mode.value.to_bytes(), + cmd=CMD_DISPLAY_MODE, + parameters=PARAMETERS_LIGHT_MODE, + mode=mode, ) async def set_display_timeout(self, timeout: DisplayTimeout) -> None: @@ -399,9 +439,9 @@ async def set_display_timeout(self, timeout: DisplayTimeout) -> None: if timeout is DisplayTimeout.UNKNOWN: raise ValueError("You cannot set the display timeout to unknown") await self._send_command( - cmd=bytes.fromhex(CMD_DISPLAY_TIMEOUT), - payload=bytes.fromhex(PAYLOAD_TIMEOUT_TIME) - + timeout.value.to_bytes(length=2, byteorder="little", signed=False), + cmd=CMD_DISPLAY_TIMEOUT, + parameters=PARAMETERS_TIMEOUT_TIME, + time=timeout, ) async def turn_display_on(self) -> None: @@ -410,9 +450,7 @@ async def turn_display_on(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_DISPLAY_ON_OFF), payload=bytes.fromhex(PAYLOAD_ON) - ) + await self._send_command(cmd=CMD_DISPLAY_ON_OFF, parameters=PARAMETERS_ON) async def turn_display_off(self) -> None: """Turn the display off. @@ -420,11 +458,9 @@ async def turn_display_off(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_DISPLAY_ON_OFF), payload=bytes.fromhex(PAYLOAD_OFF) - ) + await self._send_command(cmd=CMD_DISPLAY_ON_OFF, parameters=PARAMETERS_OFF) - async def get_status_update(self) -> dict[str, bytes]: + async def get_status_update(self) -> ParameterDict: """Request and retrieve a status update from the device. :raises ConnectionError: If not connected to device. @@ -432,26 +468,13 @@ async def get_status_update(self) -> dict[str, bytes]: :raises BleakError: If command transmission fails. :returns: Dictionary containing telemetry parameters. """ - await self._send_command( - cmd=bytes.fromhex("4040"), - payload=bytes.fromhex("a10121"), - ) - - packet_1 = await self._listen_for_packet( - bytes.fromhex("03010f"), bytes.fromhex("c840") + await self._send_command(cmd=CMD_GET_STATUS, parameters=PARAMETERS_GET_STATUS) + payload = await self._listen_for_packet( + bytes.fromhex(TELEMETRY_PATTERN_A), bytes.fromhex(CMD_RESPONSE_GET_STATUS), ) - if not packet_1: - raise TimeoutError("Timed out waiting for packet 1!") + if not payload: + raise TimeoutError("Timed out waiting for payload!") - packet_2 = await self._listen_for_packet( - bytes.fromhex("03010f"), bytes.fromhex("c840") - ) - if not packet_2: - raise TimeoutError("Timed out waiting for packet 2!") - - # We need to ignore the first byte of each packet with these types - new_payload = packet_1[1:] + packet_2[1:] - decrypted_payload = self._decrypt_payload(new_payload) - parameters = self._parse_payload(decrypted_payload) - _LOGGER.debug(f"Parameters: {self._parameters_to_str(parameters, types=True)}") + parameters = Parameters.parse(payload) + _LOGGER.debug(f"Parameters: {parameters}") return parameters diff --git a/SolixBLE/devices/c1000g2.py b/SolixBLE/devices/c1000g2.py index 424700d..279b316 100644 --- a/SolixBLE/devices/c1000g2.py +++ b/SolixBLE/devices/c1000g2.py @@ -15,9 +15,23 @@ CMD_AC_OUTPUT = "4101" CMD_DC_OUTPUT = "4102" -PAYLOAD_ON = "a10121a2020101" -PAYLOAD_OFF = "a10121a2020100" - +PARAMETERS_ON = { + "a1": { + "value": "21", + }, "a2": { + "type": 1, + "value": 1, + }, +} + +PARAMETERS_OFF = { + "a1": { + "value": "21", + }, "a2": { + "type": 1, + "value": 0, + }, +} class C1000G2(SolixBLEDevice): """ @@ -56,9 +70,7 @@ async def turn_ac_on(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_AC_OUTPUT), payload=bytes.fromhex(PAYLOAD_ON) - ) + await self._send_command(cmd=CMD_AC_OUTPUT, parameters=PARAMETERS_ON) async def turn_ac_off(self) -> None: """Turn the AC output off. @@ -66,33 +78,23 @@ async def turn_ac_off(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_AC_OUTPUT), payload=bytes.fromhex(PAYLOAD_OFF) - ) + await self._send_command(cmd=CMD_AC_OUTPUT, parameters=PARAMETERS_OFF) async def turn_dc_on(self) -> None: - """Turn the DC (12 V) output on. - - Confirmed on real hardware: the 12 V port physically switched and the - ``b2`` status byte latched on. The Gen 2 reuses the AC on/off payload on - a different command code (``4102``). + """Turn the DC output on. :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_DC_OUTPUT), payload=bytes.fromhex(PAYLOAD_ON) - ) + await self._send_command(cmd=CMD_DC_OUTPUT, parameters=PARAMETERS_ON) async def turn_dc_off(self) -> None: - """Turn the DC (12 V) output off. + """Turn the DC output off. :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_DC_OUTPUT), payload=bytes.fromhex(PAYLOAD_OFF) - ) + await self._send_command(cmd=CMD_DC_OUTPUT, parameters=PARAMETERS_OFF) @property def serial_number(self) -> str: diff --git a/SolixBLE/devices/c300.py b/SolixBLE/devices/c300.py index 1272dab..3865a92 100644 --- a/SolixBLE/devices/c300.py +++ b/SolixBLE/devices/c300.py @@ -7,11 +7,13 @@ import logging from datetime import datetime, timedelta +from SolixBLE.constructs import ParameterDict, Parameters + from ..const import ( - DEFAULT_METADATA_BOOL, DEFAULT_METADATA_FLOAT, DEFAULT_METADATA_INT, DEFAULT_METADATA_STRING, + TELEMETRY_PATTERN_A, ) from ..device import SolixBLEDevice from ..states import ChargingStatus, DisplayTimeout, LightStatus, PortStatus @@ -22,11 +24,56 @@ CMD_LIGHT_MODE = "404f" CMD_DISPLAY_TIMEOUT = "4046" CMD_DISPLAY_MODE = "404c" - -PAYLOAD_ON = "a10121a2020101" -PAYLOAD_OFF = "a10121a2020100" -PAYLOAD_LIGHT_MODE = "a10121a20201" -PAYLOAD_TIMEOUT_TIME = "a10121a20302" +CMD_GET_STATUS = "4040" + +CMD_RESPONSE_GET_STATUS = "c840" + + +PARAMETERS_ON = { + "a1": { + "value": "21", + }, "a2": { + "type": 1, + "value": 1, + }, +} + +PARAMETERS_OFF = { + "a1": { + "value": "21", + }, "a2": { + "type": 1, + "value": 0, + }, +} + +PARAMETERS_LIGHT_MODE = { + "a1": { + "value": "21", + }, "a2": { + "type": 1, + "value": lambda mode: mode.value, + }, +} + +PARAMETERS_TIMEOUT_TIME = { + "a1": { + "value": "21", + }, "a2": { + "type": 2, + "value": lambda time: time.value.to_bytes( + length=2, + byteorder="little", + signed=False, + ), + }, +} + +PARAMETERS_GET_STATUS = { + "a1": { + "value": "21", + }, +} _LOGGER = logging.getLogger(__name__) @@ -320,7 +367,7 @@ def serial_number(self) -> str: """ return self._parse_string("c5", begin=1) - async def get_status_update(self) -> dict[str, bytes]: + async def get_status_update(self) -> ParameterDict: """Request and retrieve a status update from the device. :raises ConnectionError: If not connected to device. @@ -328,28 +375,15 @@ async def get_status_update(self) -> dict[str, bytes]: :raises BleakError: If command transmission fails. :returns: Dictionary containing telemetry parameters. """ - await self._send_command( - cmd=bytes.fromhex("4040"), - payload=bytes.fromhex("a10121"), - ) - - packet_1 = await self._listen_for_packet( - bytes.fromhex("03010f"), bytes.fromhex("c840") + await self._send_command(cmd=CMD_GET_STATUS, parameters=PARAMETERS_GET_STATUS) + payload = await self._listen_for_packet( + bytes.fromhex(TELEMETRY_PATTERN_A), bytes.fromhex(CMD_RESPONSE_GET_STATUS), ) - if not packet_1: - raise TimeoutError("Timed out waiting for packet 1!") + if not payload: + raise TimeoutError("Timed out waiting for payload!") - packet_2 = await self._listen_for_packet( - bytes.fromhex("03010f"), bytes.fromhex("c840") - ) - if not packet_2: - raise TimeoutError("Timed out waiting for packet 2!") - - # We need to ignore the first byte of each packet with these types - new_payload = packet_1[1:] + packet_2[1:] - decrypted_payload = self._decrypt_payload(new_payload) - parameters = self._parse_payload(decrypted_payload) - _LOGGER.debug(f"Parameters: {self._parameters_to_str(parameters, types=True)}") + parameters = Parameters.parse(payload) + _LOGGER.debug(f"Parameters: {parameters}") return parameters async def turn_ac_on(self) -> None: @@ -358,9 +392,7 @@ async def turn_ac_on(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_AC_OUTPUT), payload=bytes.fromhex(PAYLOAD_ON) - ) + await self._send_command(cmd=CMD_AC_OUTPUT, parameters=PARAMETERS_ON) async def turn_ac_off(self) -> None: """Turn the AC output off. @@ -368,9 +400,7 @@ async def turn_ac_off(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_AC_OUTPUT), payload=bytes.fromhex(PAYLOAD_OFF) - ) + await self._send_command(cmd=CMD_AC_OUTPUT, parameters=PARAMETERS_OFF) async def turn_dc_on(self) -> None: """Turn the DC output on. @@ -378,9 +408,7 @@ async def turn_dc_on(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_DC_OUTPUT), payload=bytes.fromhex(PAYLOAD_ON) - ) + await self._send_command(cmd=CMD_DC_OUTPUT, parameters=PARAMETERS_ON) async def turn_dc_off(self) -> None: """Turn the DC output off. @@ -388,9 +416,7 @@ async def turn_dc_off(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_DC_OUTPUT), payload=bytes.fromhex(PAYLOAD_OFF) - ) + await self._send_command(cmd=CMD_DC_OUTPUT, parameters=PARAMETERS_OFF) async def turn_display_on(self) -> None: """Turn the display on. @@ -398,9 +424,7 @@ async def turn_display_on(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_DISPLAY_ON_OFF), payload=bytes.fromhex(PAYLOAD_ON) - ) + await self._send_command(cmd=CMD_DISPLAY_ON_OFF, parameters=PARAMETERS_ON) async def turn_display_off(self) -> None: """Turn the display off. @@ -408,9 +432,7 @@ async def turn_display_off(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_DISPLAY_ON_OFF), payload=bytes.fromhex(PAYLOAD_OFF) - ) + await self._send_command(cmd=CMD_DISPLAY_ON_OFF, parameters=PARAMETERS_OFF) async def set_light_mode(self, mode: LightStatus) -> None: """Set the light mode of the LED bar. @@ -423,8 +445,9 @@ async def set_light_mode(self, mode: LightStatus) -> None: if mode is LightStatus.UNKNOWN: raise ValueError("You cannot set the light status to unknown") await self._send_command( - cmd=bytes.fromhex(CMD_LIGHT_MODE), - payload=bytes.fromhex(PAYLOAD_LIGHT_MODE) + mode.value.to_bytes(), + cmd=CMD_LIGHT_MODE, + parameters=PARAMETERS_LIGHT_MODE, + mode=mode, ) async def set_display_timeout(self, timeout: DisplayTimeout) -> None: @@ -439,9 +462,9 @@ async def set_display_timeout(self, timeout: DisplayTimeout) -> None: if timeout is DisplayTimeout.UNKNOWN: raise ValueError("You cannot set the display timeout to unknown") await self._send_command( - cmd=bytes.fromhex(CMD_DISPLAY_TIMEOUT), - payload=bytes.fromhex(PAYLOAD_TIMEOUT_TIME) - + timeout.value.to_bytes(length=2, byteorder="little", signed=False), + cmd=CMD_DISPLAY_TIMEOUT, + parameters=PARAMETERS_TIMEOUT_TIME, + time=timeout, ) async def set_display_mode(self, mode: LightStatus) -> None: @@ -457,6 +480,7 @@ async def set_display_mode(self, mode: LightStatus) -> None: if mode is LightStatus.SOS: raise ValueError("You cannot set the display brightness status to SOS") await self._send_command( - cmd=bytes.fromhex(CMD_DISPLAY_MODE), - payload=bytes.fromhex(PAYLOAD_LIGHT_MODE) + mode.value.to_bytes(), + cmd=CMD_DISPLAY_MODE, + parameters=PARAMETERS_LIGHT_MODE, + mode=mode, ) diff --git a/SolixBLE/devices/c300dc.py b/SolixBLE/devices/c300dc.py index c46c915..183c5ce 100644 --- a/SolixBLE/devices/c300dc.py +++ b/SolixBLE/devices/c300dc.py @@ -28,10 +28,45 @@ CMD_DISPLAY_TIMEOUT = "4046" CMD_DISPLAY_MODE = "404c" -PAYLOAD_ON = "a10121a2020101" -PAYLOAD_OFF = "a10121a2020100" -PAYLOAD_LIGHT_MODE = "a10121a20201" -PAYLOAD_TIMEOUT_TIME = "a10121a20302" +PARAMETERS_ON = { + "a1": { + "value": "21", + }, "a2": { + "type": 1, + "value": 1, + }, +} + +PARAMETERS_OFF = { + "a1": { + "value": "21", + }, "a2": { + "type": 1, + "value": 0, + }, +} + +PARAMETERS_LIGHT_MODE = { + "a1": { + "value": "21", + }, "a2": { + "type": 1, + "value": lambda mode: mode.value, + }, +} + +PARAMETERS_TIMEOUT_TIME = { + "a1": { + "value": "21", + }, "a2": { + "type": 2, + "value": lambda time: time.value.to_bytes( + length=2, + byteorder="little", + signed=False, + ), + }, +} class C300DC(SolixBLEDevice): """ @@ -405,9 +440,7 @@ async def turn_dc_on(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_DC_OUTPUT), payload=bytes.fromhex(PAYLOAD_ON) - ) + await self._send_command(cmd=CMD_DC_OUTPUT, parameters=PARAMETERS_ON) async def turn_dc_off(self) -> None: """Turn the DC output off. @@ -415,9 +448,7 @@ async def turn_dc_off(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_DC_OUTPUT), payload=bytes.fromhex(PAYLOAD_OFF) - ) + await self._send_command(cmd=CMD_DC_OUTPUT, parameters=PARAMETERS_OFF) async def turn_display_on(self) -> None: """Turn the display on. @@ -425,9 +456,7 @@ async def turn_display_on(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_DISPLAY_ON_OFF), payload=bytes.fromhex(PAYLOAD_ON) - ) + await self._send_command(cmd=CMD_DISPLAY_ON_OFF, parameters=PARAMETERS_ON) async def turn_display_off(self) -> None: """Turn the display off. @@ -435,9 +464,7 @@ async def turn_display_off(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_DISPLAY_ON_OFF), payload=bytes.fromhex(PAYLOAD_OFF) - ) + await self._send_command(cmd=CMD_DISPLAY_ON_OFF, parameters=PARAMETERS_OFF) async def set_light_mode(self, mode: LightStatus) -> None: """Set the light mode of the LED bar. @@ -450,8 +477,9 @@ async def set_light_mode(self, mode: LightStatus) -> None: if mode is LightStatus.UNKNOWN: raise ValueError("You cannot set the light status to unknown") await self._send_command( - cmd=bytes.fromhex(CMD_LIGHT_MODE), - payload=bytes.fromhex(PAYLOAD_LIGHT_MODE) + mode.value.to_bytes(), + cmd=CMD_LIGHT_MODE, + parameters=PARAMETERS_LIGHT_MODE, + mode=mode, ) async def set_display_timeout(self, timeout: DisplayTimeout) -> None: @@ -466,9 +494,9 @@ async def set_display_timeout(self, timeout: DisplayTimeout) -> None: if timeout is DisplayTimeout.UNKNOWN: raise ValueError("You cannot set the display timeout to unknown") await self._send_command( - cmd=bytes.fromhex(CMD_DISPLAY_TIMEOUT), - payload=bytes.fromhex(PAYLOAD_TIMEOUT_TIME) - + timeout.value.to_bytes(length=2, byteorder="little", signed=False), + cmd=CMD_DISPLAY_TIMEOUT, + parameters=PARAMETERS_TIMEOUT_TIME, + time=timeout, ) async def set_display_mode(self, mode: LightStatus) -> None: @@ -484,6 +512,7 @@ async def set_display_mode(self, mode: LightStatus) -> None: if mode is LightStatus.SOS: raise ValueError("You cannot set the display brightness status to SOS") await self._send_command( - cmd=bytes.fromhex(CMD_DISPLAY_MODE), - payload=bytes.fromhex(PAYLOAD_LIGHT_MODE) + mode.value.to_bytes(), + cmd=CMD_DISPLAY_MODE, + parameters=PARAMETERS_LIGHT_MODE, + mode=mode, ) diff --git a/SolixBLE/devices/c800.py b/SolixBLE/devices/c800.py index 804c6f0..55012ae 100644 --- a/SolixBLE/devices/c800.py +++ b/SolixBLE/devices/c800.py @@ -7,10 +7,13 @@ import logging from datetime import datetime, timedelta +from SolixBLE.constructs import Parameters + from ..const import ( DEFAULT_METADATA_FLOAT, DEFAULT_METADATA_INT, DEFAULT_METADATA_STRING, + TELEMETRY_PATTERN_A, ) from ..device import SolixBLEDevice from ..states import DisplayTimeout, LightStatus, PortStatus @@ -21,11 +24,55 @@ CMD_DISPLAY_MODE = "404c" CMD_DISPLAY_TIMEOUT = "4046" CMD_DISPLAY_ON_OFF = "4052" - -PAYLOAD_ON = "a10121a2020101" -PAYLOAD_OFF = "a10121a2020100" -PAYLOAD_LIGHT_MODE = "a10121a20201" -PAYLOAD_TIMEOUT_TIME = "a10121a20302" +CMD_GET_STATUS = "4040" + +CMD_RESPONSE_GET_STATUS = "c840" + +PARAMETERS_ON = { + "a1": { + "value": "21", + }, "a2": { + "type": 1, + "value": 1, + }, +} + +PARAMETERS_OFF = { + "a1": { + "value": "21", + }, "a2": { + "type": 1, + "value": 0, + }, +} + +PARAMETERS_LIGHT_MODE = { + "a1": { + "value": "21", + }, "a2": { + "type": 1, + "value": lambda mode: mode.value, + }, +} + +PARAMETERS_TIMEOUT_TIME = { + "a1": { + "value": "21", + }, "a2": { + "type": 2, + "value": lambda time: time.value.to_bytes( + length=2, + byteorder="little", + signed=False, + ), + }, +} + +PARAMETERS_GET_STATUS = { + "a1": { + "value": "21", + }, +} _LOGGER = logging.getLogger(__name__) @@ -245,9 +292,7 @@ async def turn_ac_on(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_AC_OUTPUT), payload=bytes.fromhex(PAYLOAD_ON) - ) + await self._send_command(cmd=CMD_AC_OUTPUT, parameters=PARAMETERS_ON) async def turn_ac_off(self) -> None: """Turn the AC output off. @@ -255,9 +300,7 @@ async def turn_ac_off(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_AC_OUTPUT), payload=bytes.fromhex(PAYLOAD_OFF) - ) + await self._send_command(cmd=CMD_AC_OUTPUT, parameters=PARAMETERS_OFF) async def turn_dc_on(self) -> None: """Turn the DC output on. @@ -265,9 +308,7 @@ async def turn_dc_on(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_DC_OUTPUT), payload=bytes.fromhex(PAYLOAD_ON) - ) + await self._send_command(cmd=CMD_DC_OUTPUT, parameters=PARAMETERS_ON) async def turn_dc_off(self) -> None: """Turn the DC output off. @@ -275,9 +316,7 @@ async def turn_dc_off(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_DC_OUTPUT), payload=bytes.fromhex(PAYLOAD_OFF) - ) + await self._send_command(cmd=CMD_DC_OUTPUT, parameters=PARAMETERS_OFF) async def set_light_mode(self, mode: LightStatus) -> None: """Set the light mode of the LED bar. @@ -290,8 +329,9 @@ async def set_light_mode(self, mode: LightStatus) -> None: if mode is LightStatus.UNKNOWN: raise ValueError("You cannot set the light status to unknown") await self._send_command( - cmd=bytes.fromhex(CMD_LIGHT_MODE), - payload=bytes.fromhex(PAYLOAD_LIGHT_MODE) + mode.value.to_bytes(), + cmd=CMD_LIGHT_MODE, + parameters=PARAMETERS_LIGHT_MODE, + mode=mode, ) async def set_display_mode(self, mode: LightStatus) -> None: @@ -307,8 +347,9 @@ async def set_display_mode(self, mode: LightStatus) -> None: if mode is LightStatus.SOS: raise ValueError("You cannot set the display brightness status to SOS") await self._send_command( - cmd=bytes.fromhex(CMD_DISPLAY_MODE), - payload=bytes.fromhex(PAYLOAD_LIGHT_MODE) + mode.value.to_bytes(), + cmd=CMD_DISPLAY_MODE, + parameters=PARAMETERS_LIGHT_MODE, + mode=mode, ) async def set_display_timeout(self, timeout: DisplayTimeout) -> None: @@ -323,9 +364,9 @@ async def set_display_timeout(self, timeout: DisplayTimeout) -> None: if timeout is DisplayTimeout.UNKNOWN: raise ValueError("You cannot set the display timeout to unknown") await self._send_command( - cmd=bytes.fromhex(CMD_DISPLAY_TIMEOUT), - payload=bytes.fromhex(PAYLOAD_TIMEOUT_TIME) - + timeout.value.to_bytes(length=2, byteorder="little", signed=False), + cmd=CMD_DISPLAY_TIMEOUT, + parameters=PARAMETERS_TIMEOUT_TIME, + time=timeout, ) async def turn_display_on(self) -> None: @@ -334,9 +375,7 @@ async def turn_display_on(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_DISPLAY_ON_OFF), payload=bytes.fromhex(PAYLOAD_ON) - ) + await self._send_command(cmd=CMD_DISPLAY_ON_OFF, parameters=PARAMETERS_ON) async def turn_display_off(self) -> None: """Turn the display off. @@ -344,9 +383,7 @@ async def turn_display_off(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_DISPLAY_ON_OFF), payload=bytes.fromhex(PAYLOAD_OFF) - ) + await self._send_command(cmd=CMD_DISPLAY_ON_OFF, parameters=PARAMETERS_OFF) async def get_status_update(self) -> dict[str, bytes]: """Request and retrieve a status update from the device. @@ -356,26 +393,13 @@ async def get_status_update(self) -> dict[str, bytes]: :raises BleakError: If command transmission fails. :returns: Dictionary containing telemetry parameters. """ - await self._send_command( - cmd=bytes.fromhex("4040"), - payload=bytes.fromhex("a10121"), - ) - - packet_1 = await self._listen_for_packet( - bytes.fromhex("03010f"), bytes.fromhex("c840") + await self._send_command(cmd=CMD_GET_STATUS, parameters=PARAMETERS_GET_STATUS) + payload = await self._listen_for_packet( + bytes.fromhex(TELEMETRY_PATTERN_A), bytes.fromhex(CMD_RESPONSE_GET_STATUS), ) - if not packet_1: - raise TimeoutError("Timed out waiting for packet 1!") + if not payload: + raise TimeoutError("Timed out waiting for payload!") - packet_2 = await self._listen_for_packet( - bytes.fromhex("03010f"), bytes.fromhex("c840") - ) - if not packet_2: - raise TimeoutError("Timed out waiting for packet 2!") - - # We need to ignore the first byte of each packet with these types - new_payload = packet_1[1:] + packet_2[1:] - decrypted_payload = self._decrypt_payload(new_payload) - parameters = self._parse_payload(decrypted_payload) - _LOGGER.debug(f"Parameters: {self._parameters_to_str(parameters, types=True)}") + parameters = Parameters.parse(payload) + _LOGGER.debug(f"Parameters: {parameters}") return parameters diff --git a/SolixBLE/devices/f2600.py b/SolixBLE/devices/f2600.py index 89bcac7..7ea5218 100644 --- a/SolixBLE/devices/f2600.py +++ b/SolixBLE/devices/f2600.py @@ -8,12 +8,14 @@ import logging from datetime import datetime, timedelta +from SolixBLE.constructs import ParameterDict, Parameters + from ..const import ( DEFAULT_METADATA_BOOL, DEFAULT_METADATA_INT, + TELEMETRY_PATTERN_A, ) from ..states import ChargingStatus, DisplayTimeout, LightStatus, PortStatus - from . import F2000 CMD_AC_TIMER = "4042" @@ -26,13 +28,81 @@ CMD_DISPLAY_MODE = "404c" CMD_POWER_SAVING_MODE = "404e" CMD_LIGHT_MODE = "404f" - -PAYLOAD_ON = "a10121a2020101" -PAYLOAD_OFF = "a10121a2020100" -PAYLOAD_LIGHT_MODE = "a10121a20201" -PAYLOAD_TIMEOUT_TIME = "a10121a20302" -PAYLOAD_AC_CHARGING_POWER = "a10121a20302" -PAYLOAD_TIMER = "a10121a20502" +CMD_GET_STATUS = "4040" + +CMD_RESPONSE_GET_STATUS = "c840" + +PARAMETERS_ON = { + "a1": { + "value": "21", + }, "a2": { + "type": 1, + "value": 1, + }, +} + +PARAMETERS_OFF = { + "a1": { + "value": "21", + }, "a2": { + "type": 1, + "value": 0, + }, +} + +PARAMETERS_LIGHT_MODE = { + "a1": { + "value": "21", + }, "a2": { + "type": 1, + "value": lambda mode: mode.value, + }, +} + +PARAMETERS_TIMEOUT_TIME = { + "a1": { + "value": "21", + }, "a2": { + "type": 2, + "value": lambda time: time.value.to_bytes( + length=2, + byteorder="little", + signed=False, + ), + }, +} + +PARAMETERS_CHARGE_POWER = { + "a1": { + "value": "21", + }, "a2": { + "type": 2, + "value": lambda watts: watts.to_bytes( + length=2, + byteorder="little", + signed=False, + ), + }, +} + +PARAMETERS_TIMER = { + "a1": { + "value": "21", + }, "a2": { + "type": 2, + "value": lambda seconds: seconds.to_bytes( + length=4, + byteorder="little", + signed=False, + ), + }, +} + +PARAMETERS_GET_STATUS = { + "a1": { + "value": "21", + }, +} _LOGGER = logging.getLogger(__name__) @@ -295,9 +365,7 @@ async def turn_ac_on(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_AC_OUTPUT), payload=bytes.fromhex(PAYLOAD_ON) - ) + await self._send_command(cmd=CMD_AC_OUTPUT, parameters=PARAMETERS_ON) async def turn_ac_off(self) -> None: """Turn the AC output off. @@ -305,9 +373,7 @@ async def turn_ac_off(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_AC_OUTPUT), payload=bytes.fromhex(PAYLOAD_OFF) - ) + await self._send_command(cmd=CMD_AC_OUTPUT, parameters=PARAMETERS_OFF) async def turn_dc_on(self) -> None: """Turn the DC output on. @@ -315,9 +381,7 @@ async def turn_dc_on(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_DC_OUTPUT), payload=bytes.fromhex(PAYLOAD_ON) - ) + await self._send_command(cmd=CMD_DC_OUTPUT, parameters=PARAMETERS_ON) async def turn_dc_off(self) -> None: """Turn the DC output off. @@ -325,9 +389,7 @@ async def turn_dc_off(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_DC_OUTPUT), payload=bytes.fromhex(PAYLOAD_OFF) - ) + await self._send_command(cmd=CMD_DC_OUTPUT, parameters=PARAMETERS_OFF) async def set_ac_timer(self, seconds: int) -> None: """Set the AC auto-off timer. @@ -337,9 +399,9 @@ async def set_ac_timer(self, seconds: int) -> None: :raises BleakError: If command transmission fails. """ await self._send_command( - cmd=bytes.fromhex(CMD_AC_TIMER), - payload=bytes.fromhex(PAYLOAD_TIMER) - + seconds.to_bytes(length=4, byteorder="little", signed=False), + cmd=CMD_AC_TIMER, + parameters=PARAMETERS_TIMER, + seconds=seconds, ) async def set_dc_timer(self, seconds: int) -> None: @@ -350,9 +412,9 @@ async def set_dc_timer(self, seconds: int) -> None: :raises BleakError: If command transmission fails. """ await self._send_command( - cmd=bytes.fromhex(CMD_DC_TIMER), - payload=bytes.fromhex(PAYLOAD_TIMER) - + seconds.to_bytes(length=4, byteorder="little", signed=False), + cmd=CMD_DC_TIMER, + parameters=PARAMETERS_TIMER, + seconds=seconds, ) async def set_light_mode(self, mode: LightStatus) -> None: @@ -366,8 +428,9 @@ async def set_light_mode(self, mode: LightStatus) -> None: if mode is LightStatus.UNKNOWN: raise ValueError("You cannot set the light status to unknown") await self._send_command( - cmd=bytes.fromhex(CMD_LIGHT_MODE), - payload=bytes.fromhex(PAYLOAD_LIGHT_MODE) + mode.value.to_bytes(), + cmd=CMD_LIGHT_MODE, + parameters=PARAMETERS_LIGHT_MODE, + mode=mode, ) async def set_display_mode(self, mode: LightStatus) -> None: @@ -383,8 +446,9 @@ async def set_display_mode(self, mode: LightStatus) -> None: if mode is LightStatus.SOS: raise ValueError("You cannot set the display brightness status to SOS") await self._send_command( - cmd=bytes.fromhex(CMD_DISPLAY_MODE), - payload=bytes.fromhex(PAYLOAD_LIGHT_MODE) + mode.value.to_bytes(), + cmd=CMD_DISPLAY_MODE, + parameters=PARAMETERS_LIGHT_MODE, + mode=mode, ) async def set_display_timeout(self, timeout: DisplayTimeout) -> None: @@ -399,9 +463,9 @@ async def set_display_timeout(self, timeout: DisplayTimeout) -> None: if timeout is DisplayTimeout.UNKNOWN: raise ValueError("You cannot set the display timeout to unknown") await self._send_command( - cmd=bytes.fromhex(CMD_DISPLAY_TIMEOUT), - payload=bytes.fromhex(PAYLOAD_TIMEOUT_TIME) - + timeout.value.to_bytes(length=2, byteorder="little", signed=False), + cmd=CMD_DISPLAY_TIMEOUT, + parameters=PARAMETERS_TIMEOUT_TIME, + time=timeout, ) async def turn_display_on(self) -> None: @@ -410,9 +474,7 @@ async def turn_display_on(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_DISPLAY_ON_OFF), payload=bytes.fromhex(PAYLOAD_ON) - ) + await self._send_command(cmd=CMD_DISPLAY_ON_OFF, parameters=PARAMETERS_ON) async def turn_display_off(self) -> None: """Turn the display off. @@ -420,9 +482,7 @@ async def turn_display_off(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_DISPLAY_ON_OFF), payload=bytes.fromhex(PAYLOAD_OFF) - ) + await self._send_command(cmd=CMD_DISPLAY_ON_OFF, parameters=PARAMETERS_OFF) async def turn_power_saving_mode_on(self) -> None: """Turn the power saving mode on. @@ -430,10 +490,7 @@ async def turn_power_saving_mode_on(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_POWER_SAVING_MODE), - payload=bytes.fromhex(PAYLOAD_ON), - ) + await self._send_command(cmd=CMD_POWER_SAVING_MODE, parameters=PARAMETERS_ON) async def turn_power_saving_mode_off(self) -> None: """Turn the power saving mode off. @@ -441,10 +498,7 @@ async def turn_power_saving_mode_off(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_POWER_SAVING_MODE), - payload=bytes.fromhex(PAYLOAD_OFF), - ) + await self._send_command(cmd=CMD_POWER_SAVING_MODE, parameters=PARAMETERS_OFF) async def set_ac_charging_power(self, watts: int) -> None: """Set the AC charging power limit in watts. @@ -460,12 +514,12 @@ async def set_ac_charging_power(self, watts: int) -> None: raise ValueError("AC charging power must be between 100 and 1440 W") await self._send_command( - cmd=bytes.fromhex(CMD_AC_CHARGING_POWER), - payload=bytes.fromhex(PAYLOAD_AC_CHARGING_POWER) - + watts.to_bytes(length=2, byteorder="little", signed=False), + cmd=CMD_AC_CHARGING_POWER, + parameters=PARAMETERS_CHARGE_POWER, + watts=watts, ) - async def get_status_update(self) -> dict[str, bytes]: + async def get_status_update(self) -> ParameterDict: """Request and retrieve a status update from the device. :raises ConnectionError: If not connected to device. @@ -473,29 +527,16 @@ async def get_status_update(self) -> dict[str, bytes]: :raises BleakError: If command transmission fails. :returns: Dictionary containing telemetry parameters. """ - await self._send_command( - cmd=bytes.fromhex("4040"), - payload=bytes.fromhex("a10121"), + await self._send_command(cmd=CMD_GET_STATUS, parameters=PARAMETERS_GET_STATUS) + payload = await self._listen_for_packet( + bytes.fromhex(TELEMETRY_PATTERN_A), bytes.fromhex(CMD_RESPONSE_GET_STATUS), ) + if not payload: + raise TimeoutError("Timed out waiting for payload!") - packet_1 = await self._listen_for_packet( - bytes.fromhex("03010f"), bytes.fromhex("c840") - ) - if not packet_1: - raise TimeoutError("Timed out waiting for packet 1!") - - packet_2 = await self._listen_for_packet( - bytes.fromhex("03010f"), bytes.fromhex("c840") - ) - if not packet_2: - raise TimeoutError("Timed out waiting for packet 2!") - - # We need to ignore the first byte of each packet with these types - new_payload = packet_1[1:] + packet_2[1:] - decrypted_payload = self._decrypt_payload(new_payload) - parameters = self._parse_payload(decrypted_payload) - _LOGGER.debug(f"Parameters: {self._parameters_to_str(parameters, types=True)}") + parameters = Parameters.parse(payload) + _LOGGER.debug(f"Parameters: {parameters}") await self._process_telemetry( - parameters + parameters, ) # update the internal parameters as well return parameters diff --git a/SolixBLE/devices/f3800.py b/SolixBLE/devices/f3800.py index f9d68a0..c442ed8 100644 --- a/SolixBLE/devices/f3800.py +++ b/SolixBLE/devices/f3800.py @@ -16,9 +16,24 @@ CMD_AC_OUTPUT = "404a" CMD_DC_OUTPUT = "404b" -PAYLOAD_ON = "a10121a2020101" -PAYLOAD_OFF = "a10121a2020100" +PARAMETERS_ON = { + "a1": { + "value": "21", + }, "a2": { + "type": 1, + "value": 1, + }, +} + +PARAMETERS_OFF = { + "a1": { + "value": "21", + }, "a2": { + "type": 1, + "value": 0, + }, +} class F3800(SolixBLEDevice): """ @@ -343,9 +358,7 @@ async def turn_ac_on(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_AC_OUTPUT), payload=bytes.fromhex(PAYLOAD_ON), - ) + await self._send_command(cmd=CMD_AC_OUTPUT, parameters=PARAMETERS_ON) async def turn_ac_off(self) -> None: """Turn the AC output off. @@ -353,9 +366,7 @@ async def turn_ac_off(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_AC_OUTPUT), payload=bytes.fromhex(PAYLOAD_OFF), - ) + await self._send_command(cmd=CMD_AC_OUTPUT, parameters=PARAMETERS_OFF) async def turn_dc_on(self) -> None: """Turn the DC output on. @@ -363,9 +374,7 @@ async def turn_dc_on(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_DC_OUTPUT), payload=bytes.fromhex(PAYLOAD_ON), - ) + await self._send_command(cmd=CMD_DC_OUTPUT, parameters=PARAMETERS_ON) async def turn_dc_off(self) -> None: """Turn the DC output off. @@ -373,6 +382,4 @@ async def turn_dc_off(self) -> None: :raises ConnectionError: If not connected to device. :raises BleakError: If command transmission fails. """ - await self._send_command( - cmd=bytes.fromhex(CMD_DC_OUTPUT), payload=bytes.fromhex(PAYLOAD_OFF), - ) + await self._send_command(cmd=CMD_DC_OUTPUT, parameters=PARAMETERS_OFF) diff --git a/SolixBLE/devices/prime_charger_160w.py b/SolixBLE/devices/prime_charger_160w.py index 6e8bd85..9204f33 100644 --- a/SolixBLE/devices/prime_charger_160w.py +++ b/SolixBLE/devices/prime_charger_160w.py @@ -11,18 +11,33 @@ CMD_USB_OUTPUT = "4207" CMD_USB_TIMER = "4209" -PAYLOAD_USB_C1_ON = "a10121a2020100a3020101" -PAYLOAD_USB_C1_OFF = "a10121a2020100a3020100" -PAYLOAD_USB_C1_TIMER = "a10121a2020100a30504" - -PAYLOAD_USB_C2_ON = "a10121a2020101a3020101" -PAYLOAD_USB_C2_OFF = "a10121a2020101a3020100" -PAYLOAD_USB_C2_TIMER = "a10121a2020101a30504" - -PAYLOAD_USB_C3_ON = "a10121a2020102a3020101" -PAYLOAD_USB_C3_OFF = "a10121a2020102a3020100" -PAYLOAD_USB_C3_TIMER = "a10121a2020102a30504" - +PARAMETERS_ON_OFF = { + "a1": { + "value": "21", + }, "a2": { + "type": 1, + "value": lambda port: port - 1, + }, "a3": { + "type": 1, + "value": lambda on: 1 if on else 0, + }, +} + +PARAMETERS_TIMER = { + "a1": { + "value": "21", + }, "a2": { + "type": 1, + "value": lambda port: port - 1, + }, "a3": { + "type": 4, + "value": lambda seconds: seconds.to_bytes( + length=4, + byteorder="little", + signed=False, + ), + }, +} class PrimeCharger160w(PrimeDevice): """ @@ -162,8 +177,10 @@ async def turn_usb_c1_on(self) -> None: :raises BleakError: If command transmission fails. """ await self._send_command( - cmd=bytes.fromhex(CMD_USB_OUTPUT), - payload=bytes.fromhex(PAYLOAD_USB_C1_ON), + cmd=CMD_USB_OUTPUT, + parameters=PARAMETERS_ON_OFF, + port=1, + on=True, ) async def turn_usb_c1_off(self) -> None: @@ -173,8 +190,10 @@ async def turn_usb_c1_off(self) -> None: :raises BleakError: If command transmission fails. """ await self._send_command( - cmd=bytes.fromhex(CMD_USB_OUTPUT), - payload=bytes.fromhex(PAYLOAD_USB_C1_OFF), + cmd=CMD_USB_OUTPUT, + parameters=PARAMETERS_ON_OFF, + port=1, + on=False, ) async def set_timer_usb_c1(self, time: int) -> None: @@ -185,9 +204,10 @@ async def set_timer_usb_c1(self, time: int) -> None: :raises BleakError: If command transmission fails. """ await self._send_command( - cmd=bytes.fromhex(CMD_USB_TIMER), - payload=bytes.fromhex(PAYLOAD_USB_C1_TIMER) - + time.to_bytes(4, byteorder="little"), + cmd=CMD_USB_TIMER, + parameters=PARAMETERS_TIMER, + port=1, + seconds=time, ) async def turn_usb_c2_on(self) -> None: @@ -197,8 +217,10 @@ async def turn_usb_c2_on(self) -> None: :raises BleakError: If command transmission fails. """ await self._send_command( - cmd=bytes.fromhex(CMD_USB_OUTPUT), - payload=bytes.fromhex(PAYLOAD_USB_C2_ON), + cmd=CMD_USB_OUTPUT, + parameters=PARAMETERS_ON_OFF, + port=2, + on=True, ) async def turn_usb_c2_off(self) -> None: @@ -208,8 +230,10 @@ async def turn_usb_c2_off(self) -> None: :raises BleakError: If command transmission fails. """ await self._send_command( - cmd=bytes.fromhex(CMD_USB_OUTPUT), - payload=bytes.fromhex(PAYLOAD_USB_C2_OFF), + cmd=CMD_USB_OUTPUT, + parameters=PARAMETERS_ON_OFF, + port=2, + on=False, ) async def set_timer_usb_c2(self, time: int) -> None: @@ -220,9 +244,10 @@ async def set_timer_usb_c2(self, time: int) -> None: :raises BleakError: If command transmission fails. """ await self._send_command( - cmd=bytes.fromhex(CMD_USB_TIMER), - payload=bytes.fromhex(PAYLOAD_USB_C2_TIMER) - + time.to_bytes(4, byteorder="little"), + cmd=CMD_USB_TIMER, + parameters=PARAMETERS_TIMER, + port=2, + seconds=time, ) async def turn_usb_c3_on(self) -> None: @@ -232,8 +257,10 @@ async def turn_usb_c3_on(self) -> None: :raises BleakError: If command transmission fails. """ await self._send_command( - cmd=bytes.fromhex(CMD_USB_OUTPUT), - payload=bytes.fromhex(PAYLOAD_USB_C3_ON), + cmd=CMD_USB_OUTPUT, + parameters=PARAMETERS_ON_OFF, + port=3, + on=True, ) async def turn_usb_c3_off(self) -> None: @@ -243,8 +270,10 @@ async def turn_usb_c3_off(self) -> None: :raises BleakError: If command transmission fails. """ await self._send_command( - cmd=bytes.fromhex(CMD_USB_OUTPUT), - payload=bytes.fromhex(PAYLOAD_USB_C3_OFF), + cmd=CMD_USB_OUTPUT, + parameters=PARAMETERS_ON_OFF, + port=3, + on=False, ) async def set_timer_usb_c3(self, time: int) -> None: @@ -255,7 +284,8 @@ async def set_timer_usb_c3(self, time: int) -> None: :raises BleakError: If command transmission fails. """ await self._send_command( - cmd=bytes.fromhex(CMD_USB_TIMER), - payload=bytes.fromhex(PAYLOAD_USB_C3_TIMER) - + time.to_bytes(4, byteorder="little"), + cmd=CMD_USB_TIMER, + parameters=PARAMETERS_TIMER, + port=3, + seconds=time, ) diff --git a/SolixBLE/devices/prime_charger_250w.py b/SolixBLE/devices/prime_charger_250w.py index 11d26c0..32b2a7f 100644 --- a/SolixBLE/devices/prime_charger_250w.py +++ b/SolixBLE/devices/prime_charger_250w.py @@ -17,26 +17,39 @@ CMD_USB_OUTPUT = "4207" CMD_USB_TIMER = "4209" -PAYLOAD_USB_C1_ON = "a10121a2020100a3020101" -PAYLOAD_USB_C1_OFF = "a10121a2020100a3020100" -PAYLOAD_USB_C1_TIMER = "a10121a2020100a30604" - -PAYLOAD_USB_C2_ON = "a10121a2020101a3020101" -PAYLOAD_USB_C2_OFF = "a10121a2020101a3020100" -PAYLOAD_USB_C2_TIMER = "a10121a2020101a30604" - -PAYLOAD_USB_C3_ON = "a10121a2020102a3020101" -PAYLOAD_USB_C3_OFF = "a10121a2020102a3020100" -PAYLOAD_USB_C3_TIMER = "a10121a2020102a30604" - -PAYLOAD_USB_C4_ON = "a10121a2020103a3020101" -PAYLOAD_USB_C4_OFF = "a10121a2020103a3020100" -PAYLOAD_USB_C4_TIMER = "a10121a2020103a30604" - -PAYLOAD_USB_A1_A2_ON = "a10121a2020104a3020101" -PAYLOAD_USB_A1_A2_OFF = "a10121a2020104a3020100" -PAYLOAD_USB_A1_A2_TIMER = "a10121a2020104a30604" - +PARAMETERS_ON_OFF = { + "a1": { + "value": "21", + }, "a2": { + "type": 1, + "value": lambda port: port - 1, + }, "a3": { + "type": 1, + "value": lambda on: 1 if on else 0, + }, +} + +PARAMETERS_TIMER = { + "a1": { + "value": "21", + }, "a2": { + "type": 1, + "value": lambda port: port - 1, + }, "a3": { + "type": 4, + "value": lambda seconds: seconds.to_bytes( + length=5, + byteorder="little", + signed=False, + ), + }, +} + +PARAMETERS_KEEP_ALIVE = { + "a1": { + "value": "21", + }, +} class PrimeCharger250w(PrimeDevice): """ @@ -46,12 +59,12 @@ class PrimeCharger250w(PrimeDevice): This model is also known as the A2345. """ - _TELEMETRY_COMMANDS = ("4303") + _TELEMETRY_COMMANDS = ("4303", "ca00") async def _keep_alive(self) -> int | None: await self._send_command( - cmd=bytes.fromhex(CMD_SUB_AND_KEEP_ALIVE), - payload=bytes.fromhex(SUB_AND_KEEP_ALIVE_PAYLOAD), + cmd=CMD_SUB_AND_KEEP_ALIVE, + parameters=PARAMETERS_KEEP_ALIVE, ) return KEEP_ALIVE_INTERNAL @@ -308,8 +321,10 @@ async def turn_usb_c1_on(self) -> None: :raises BleakError: If command transmission fails. """ await self._send_command( - cmd=bytes.fromhex(CMD_USB_OUTPUT), - payload=bytes.fromhex(PAYLOAD_USB_C1_ON), + cmd=CMD_USB_OUTPUT, + parameters=PARAMETERS_ON_OFF, + port=1, + on=True, ) async def turn_usb_c1_off(self) -> None: @@ -319,8 +334,10 @@ async def turn_usb_c1_off(self) -> None: :raises BleakError: If command transmission fails. """ await self._send_command( - cmd=bytes.fromhex(CMD_USB_OUTPUT), - payload=bytes.fromhex(PAYLOAD_USB_C1_OFF), + cmd=CMD_USB_OUTPUT, + parameters=PARAMETERS_ON_OFF, + port=1, + on=False, ) async def set_timer_usb_c1(self, time: int) -> None: @@ -331,9 +348,10 @@ async def set_timer_usb_c1(self, time: int) -> None: :raises BleakError: If command transmission fails. """ await self._send_command( - cmd=bytes.fromhex(CMD_USB_TIMER), - payload=bytes.fromhex(PAYLOAD_USB_C1_TIMER) - + time.to_bytes(5, byteorder="little"), + cmd=CMD_USB_TIMER, + parameters=PARAMETERS_TIMER, + port=1, + seconds=time, ) async def turn_usb_c2_on(self) -> None: @@ -343,8 +361,10 @@ async def turn_usb_c2_on(self) -> None: :raises BleakError: If command transmission fails. """ await self._send_command( - cmd=bytes.fromhex(CMD_USB_OUTPUT), - payload=bytes.fromhex(PAYLOAD_USB_C2_ON), + cmd=CMD_USB_OUTPUT, + parameters=PARAMETERS_ON_OFF, + port=2, + on=True, ) async def turn_usb_c2_off(self) -> None: @@ -354,8 +374,10 @@ async def turn_usb_c2_off(self) -> None: :raises BleakError: If command transmission fails. """ await self._send_command( - cmd=bytes.fromhex(CMD_USB_OUTPUT), - payload=bytes.fromhex(PAYLOAD_USB_C2_OFF), + cmd=CMD_USB_OUTPUT, + parameters=PARAMETERS_ON_OFF, + port=2, + on=False, ) async def set_timer_usb_c2(self, time: int) -> None: @@ -366,9 +388,10 @@ async def set_timer_usb_c2(self, time: int) -> None: :raises BleakError: If command transmission fails. """ await self._send_command( - cmd=bytes.fromhex(CMD_USB_TIMER), - payload=bytes.fromhex(PAYLOAD_USB_C2_TIMER) - + time.to_bytes(5, byteorder="little"), + cmd=CMD_USB_TIMER, + parameters=PARAMETERS_TIMER, + port=2, + seconds=time, ) async def turn_usb_c3_on(self) -> None: @@ -378,8 +401,10 @@ async def turn_usb_c3_on(self) -> None: :raises BleakError: If command transmission fails. """ await self._send_command( - cmd=bytes.fromhex(CMD_USB_OUTPUT), - payload=bytes.fromhex(PAYLOAD_USB_C3_ON), + cmd=CMD_USB_OUTPUT, + parameters=PARAMETERS_ON_OFF, + port=3, + on=True, ) async def turn_usb_c3_off(self) -> None: @@ -389,8 +414,10 @@ async def turn_usb_c3_off(self) -> None: :raises BleakError: If command transmission fails. """ await self._send_command( - cmd=bytes.fromhex(CMD_USB_OUTPUT), - payload=bytes.fromhex(PAYLOAD_USB_C3_OFF), + cmd=CMD_USB_OUTPUT, + parameters=PARAMETERS_ON_OFF, + port=3, + on=False, ) async def set_timer_usb_c3(self, time: int) -> None: @@ -401,9 +428,10 @@ async def set_timer_usb_c3(self, time: int) -> None: :raises BleakError: If command transmission fails. """ await self._send_command( - cmd=bytes.fromhex(CMD_USB_TIMER), - payload=bytes.fromhex(PAYLOAD_USB_C3_TIMER) - + time.to_bytes(5, byteorder="little"), + cmd=CMD_USB_TIMER, + parameters=PARAMETERS_TIMER, + port=3, + seconds=time, ) async def turn_usb_c4_on(self) -> None: @@ -413,8 +441,10 @@ async def turn_usb_c4_on(self) -> None: :raises BleakError: If command transmission fails. """ await self._send_command( - cmd=bytes.fromhex(CMD_USB_OUTPUT), - payload=bytes.fromhex(PAYLOAD_USB_C4_ON), + cmd=CMD_USB_OUTPUT, + parameters=PARAMETERS_ON_OFF, + port=4, + on=True, ) async def turn_usb_c4_off(self) -> None: @@ -424,8 +454,10 @@ async def turn_usb_c4_off(self) -> None: :raises BleakError: If command transmission fails. """ await self._send_command( - cmd=bytes.fromhex(CMD_USB_OUTPUT), - payload=bytes.fromhex(PAYLOAD_USB_C4_OFF), + cmd=CMD_USB_OUTPUT, + parameters=PARAMETERS_ON_OFF, + port=4, + on=False, ) async def set_timer_usb_c4(self, time: int) -> None: @@ -436,9 +468,10 @@ async def set_timer_usb_c4(self, time: int) -> None: :raises BleakError: If command transmission fails. """ await self._send_command( - cmd=bytes.fromhex(CMD_USB_TIMER), - payload=bytes.fromhex(PAYLOAD_USB_C4_TIMER) - + time.to_bytes(5, byteorder="little"), + cmd=CMD_USB_TIMER, + parameters=PARAMETERS_TIMER, + port=4, + seconds=time, ) async def turn_usb_a1_a2_on(self) -> None: @@ -448,8 +481,10 @@ async def turn_usb_a1_a2_on(self) -> None: :raises BleakError: If command transmission fails. """ await self._send_command( - cmd=bytes.fromhex(CMD_USB_OUTPUT), - payload=bytes.fromhex(PAYLOAD_USB_A1_A2_ON), + cmd=CMD_USB_OUTPUT, + parameters=PARAMETERS_ON_OFF, + port=5, + on=True, ) async def turn_usb_a1_a2_off(self) -> None: @@ -459,8 +494,10 @@ async def turn_usb_a1_a2_off(self) -> None: :raises BleakError: If command transmission fails. """ await self._send_command( - cmd=bytes.fromhex(CMD_USB_OUTPUT), - payload=bytes.fromhex(PAYLOAD_USB_A1_A2_OFF), + cmd=CMD_USB_OUTPUT, + parameters=PARAMETERS_ON_OFF, + port=5, + on=False, ) async def set_timer_usb_a1_a2(self, time: int) -> None: @@ -471,7 +508,8 @@ async def set_timer_usb_a1_a2(self, time: int) -> None: :raises BleakError: If command transmission fails. """ await self._send_command( - cmd=bytes.fromhex(CMD_USB_TIMER), - payload=bytes.fromhex(PAYLOAD_USB_A1_A2_TIMER) - + time.to_bytes(5, byteorder="little"), + cmd=CMD_USB_TIMER, + parameters=PARAMETERS_TIMER, + port=5, + seconds=time, ) diff --git a/SolixBLE/prime_device.py b/SolixBLE/prime_device.py index 7064ebe..62b0fd5 100644 --- a/SolixBLE/prime_device.py +++ b/SolixBLE/prime_device.py @@ -15,57 +15,15 @@ derive_private_key, ) -from SolixBLE.const import UUID_COMMAND +from SolixBLE.const import FALLBACK_TZ, NEGOTIATION_PATTERN +from SolixBLE.constructs import Parameters from SolixBLE.device import SolixBLEDevice +from SolixBLE.utilities import get_posix_tz _LOGGER = logging.getLogger(__name__) -#: Command used to initiate negotiations -NEGOTIATION_COMMAND_0 = ( - "ff09200003000140010a82d0ab535303e3aa9f0c2f9c868465bc8476f556fb7d" -) - -#: Response to receiving 1st negotiation message -NEGOTIATION_COMMAND_1 = ( - "ff09270003000140030a82d0ab53538ab3de100ac9bb87a0b8e36c1dd8167a9c25a9839d9a14d5" -) - -#: Response to receiving 2nd negotiation message -NEGOTIATION_COMMAND_2 = ( - "ff09200003000140290a82d0ab535303e3aa9f0c2f9c868465bc8476f556fb55" -) - -#: Response to receiving 3rd negotiation message -NEGOTIATION_COMMAND_3 = "ff092d0003000140050a82d0ab53538ab3de100ae04aca6791257881a90164eac7460450e0c82f2c03de4f9604" - -#: Response to receiving 4th negotiation message -NEGOTIATION_COMMAND_4 = "ff095c0003000140210ac6ea31e4300bb2877d6ddeb628b0d7be8d768333f00ceab5454d20fbd97e091457b1f3b6efb6511eb9e98ac2b2c46eee211ae359ad246e1ae9886b4a29e41eddd5a5064d8b9ffdbfb43eb6b8e307fcde9de7" - -#: The cmd to put in the response to receiving 5th negotiation message -NEGOTIATION_COMMAND_5_CMD = "4022" - -#: The payload to put in the response to receiving 5th negotiation message -NEGOTIATION_COMMAND_5_PAYLOAD = ( - "a104f079b569a30400000000a518474d54304253542c4d332e352e302f312c4d31302e352e30" -) - -#: The cmd to put in the response to receiving 6th negotiation message -NEGOTIATION_COMMAND_6_CMD = "4027" - -#: The payload to put in the response to receiving 6th negotiation message -NEGOTIATION_COMMAND_6_PAYLOAD = "a104f079b569a22437396562656433352d646339632d343930342d623430632d373263346538363361613130" - -#: The cmd to put in the first response to receiving 7th negotiation message -NEGOTIATION_COMMAND_7_CMD = "4200" - -#: The payload to put in the first response to receiving 7th negotiation message -NEGOTIATION_COMMAND_7_PAYLOAD = "a10121fe04f079b569" - -#: The cmd to put in the second response to receiving 7th negotiation message -NEGOTIATION_COMMAND_8_CMD = "420a" - -#: The payload to put in the second response to receiving 7th negotiation message -NEGOTIATION_COMMAND_8_PAYLOAD = "a10121a203044742a3250437396562656433352d646339632d343930342d623430632d373263346538363361613130a5020101fe04f079b569" +#: The pattern used in telemetry packets from Anker Prime and Solix devices +TELEMETRY_PATTERN = "03000f" #: Anker Prime devices encrypt the negotiation using a static key NEGOTIATION_KEY = "b8ff7422955d4eb6d554a2c470280559" @@ -73,12 +31,6 @@ #: Anker Prime devices encrypt the negotiation using a static nonce NEGOTIATION_NONCE = "6ba3e3f2f3a60f2971ce5d1f" -#: The pattern used in negotiation packets from Anker Prime devices -NEGOTIATION_PATTERN = "030001" - -#: The pattern used in telemetry packets from Anker Prime and Solix devices -TELEMETRY_PATTERN = "03000f" - #: Additional Authenticated Data bytes used by protocol AAD = "3322110077665544bbaa9988ffeeddcc" @@ -89,10 +41,9 @@ #: talking over Bluetooth with a range of like 10m... I don't care. PRIVATE_KEY = "754744d72984c378bc4fa77d7fcdf6bbb6d9df119fa9be4948eb8a3b4cd6071f" -#: The unix timestamp that is agreed upon in the negotiations. This is used -#: by Anker to protect against replay attacks as commands must contain the -#: current encrypted time. -BASE_TIMESTAMP = "ef79b569" +#: The UUID sent to the device during negotiation +UUID_STRING = "79ebed35-dc9c-4904-b40c-72c4e863aa10" + class PrimeDevice(SolixBLEDevice): @@ -114,9 +65,17 @@ def _encrypt_payload(self, payload: bytes) -> bytes: secret as the AES key and next 12 bytes as the nonce. The MAC tag is 16 bytes and appended to the end of the payload. """ - cipher = AES.new( - self._shared_secret[:16], AES.MODE_GCM, nonce=self._shared_secret[16:28] + key = ( + self._shared_secret[:16] + if self._shared_secret is not None + else bytes.fromhex(NEGOTIATION_KEY) + ) + nonce = ( + self._shared_secret[16:28] + if self._shared_secret is not None + else bytes.fromhex(NEGOTIATION_NONCE) ) + cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) cipher.update(bytes.fromhex(AAD)) encrypted_payload, mac_bytes = cipher.encrypt_and_digest(payload) return encrypted_payload + mac_bytes @@ -165,29 +124,22 @@ def _decrypt_payload(self, payload: bytes) -> bytes: ############### async def _initiate_negotiations(self) -> None: - """ - Send the negotiation initiation command. - """ - - # Log parameters we will send if debugging (makes handshake easier to see in logs) - if _LOGGER.isEnabledFor(logging.DEBUG): - new_parameters = self._parse_payload( - self._decrypt_payload( - self._split_packet(bytes.fromhex(NEGOTIATION_COMMAND_0))[2] - ) - ) - _LOGGER.debug( - f"Stage 0 message parameters: {self._parameters_to_str(new_parameters, types=True)}" - ) - - await self._client.write_gatt_char( - UUID_COMMAND, bytes.fromhex(NEGOTIATION_COMMAND_0) + """Send the negotiation initiation command.""" + await self._send_packet(pattern=NEGOTIATION_PATTERN, cmd="4001", + parameters={ "a1": { + "key": bytes.fromhex("a1"), + "type": None, + "value": lambda self: self._timestamp(), + }}, ) async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: - """ - Negotiate encryption with the device. - """ + """Negotiate encryption with the device.""" + + decrypted_payload = self._decrypt_payload(payload) + _LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}") + parameters = Parameters.parse(decrypted_payload) + _LOGGER.debug(f"Parameters: {parameters.to_str(verbose=True, types=False)}") match cmd.hex(): @@ -201,138 +153,97 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: # Negotiation stage 1 case "4801": _LOGGER.debug( - "Entered negotiation stage 1 due to response from device!" - ) - decrypted_payload = self._decrypt_payload(payload) - _LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}") - parameters = self._parse_payload(decrypted_payload) - _LOGGER.debug( - f"Parameters: {self._parameters_to_str(parameters, types=True)}" - ) - - # Log parameters we will send if debugging (makes handshake easier to see in logs) - if _LOGGER.isEnabledFor(logging.DEBUG): - new_parameters = self._parse_payload( - self._decrypt_payload( - self._split_packet(bytes.fromhex(NEGOTIATION_COMMAND_1))[2] - ) - ) - _LOGGER.debug( - f"Stage 1 response message parameters: {self._parameters_to_str(new_parameters, types=True)}" - ) - - _LOGGER.debug("Sending stage 1 response message...") - return await self._client.write_gatt_char( - UUID_COMMAND, - bytes.fromhex(NEGOTIATION_COMMAND_1), + "Entered negotiation stage 1 due to response from device!", + ) + await self._send_packet(pattern=NEGOTIATION_PATTERN, cmd="4003", + parameters={ + "a1": { + "key": bytes.fromhex("a1"), + "type": None, + "value": lambda self: self._timestamp(), + }, "a3": { + "key": bytes.fromhex("a3"), + "type": None, + "value": bytes.fromhex("20"), + }, "a4": { + "key": bytes.fromhex("a4"), + "type": None, + "value": bytes.fromhex("00f0"), + }, + }, ) # Negotiation stage 2 case "4803": _LOGGER.debug( - "Entered negotiation stage 2 due to response from device!" - ) - decrypted_payload = self._decrypt_payload(payload) - _LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}") - parameters = self._parse_payload(decrypted_payload) - _LOGGER.debug( - f"Parameters: {self._parameters_to_str(parameters, types=True)}" + "Entered negotiation stage 2 due to response from device!", ) - - # Log parameters we will send if debugging (makes handshake easier to see in logs) - if _LOGGER.isEnabledFor(logging.DEBUG): - new_parameters = self._parse_payload( - self._decrypt_payload( - self._split_packet(bytes.fromhex(NEGOTIATION_COMMAND_2))[2] - ) - ) - _LOGGER.debug( - f"Stage 2 response message parameters: {self._parameters_to_str(new_parameters, types=True)}" - ) - - _LOGGER.debug("Sending stage 2 response message...") - return await self._client.write_gatt_char( - UUID_COMMAND, - bytes.fromhex(NEGOTIATION_COMMAND_2), + self._mtu = int.from_bytes(parameters["a2"].value_legacy, byteorder="little") + _LOGGER.debug(f"MTU of device: {self._mtu}") + await self._send_packet(pattern=NEGOTIATION_PATTERN, cmd="4029", + parameters={ "a1": { + "key": bytes.fromhex("a1"), + "type": None, + "value": lambda self: self._timestamp(), + }}, ) # Negotiation stage 3 case "4829": _LOGGER.debug( - "Entered negotiation stage 3 due to response from device!" - ) - decrypted_payload = self._decrypt_payload(payload) - _LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}") - parameters = self._parse_payload(decrypted_payload) - _LOGGER.debug( - f"Parameters: {self._parameters_to_str(parameters, types=True)}" - ) - - # Log parameters we will send if debugging (makes handshake easier to see in logs) - if _LOGGER.isEnabledFor(logging.DEBUG): - new_parameters = self._parse_payload( - self._decrypt_payload( - self._split_packet(bytes.fromhex(NEGOTIATION_COMMAND_3))[2] - ) - ) - _LOGGER.debug( - f"Stage 3 response message parameters: {self._parameters_to_str(new_parameters, types=True)}" - ) - - _LOGGER.debug("Sending stage 3 response message...") - return await self._client.write_gatt_char( - UUID_COMMAND, - bytes.fromhex(NEGOTIATION_COMMAND_3), + "Entered negotiation stage 3 due to response from device!", + ) + await self._send_packet(pattern=NEGOTIATION_PATTERN, cmd="4005", + parameters={ + "a1": { + "key": bytes.fromhex("a1"), + "type": None, + "value": lambda self: self._timestamp(), + }, "a3": { + "key": bytes.fromhex("a3"), + "type": None, + "value": bytes.fromhex("20"), + }, "a4": { + "key": bytes.fromhex("a4"), + "type": None, + "value": bytes.fromhex("2901"), + }, "a5": { + "key": bytes.fromhex("a5"), + "type": None, + "value": bytes.fromhex("44"), + }, "a6": { + "key": bytes.fromhex("a6"), + "type": None, + "value": bytes.fromhex("02"), + }, + }, ) # Negotiation stage 4 case "4805": _LOGGER.debug( - "Entered negotiation stage 4 due to response from device!" + "Entered negotiation stage 4 due to response from device!", ) - decrypted_payload = self._decrypt_payload(payload) - _LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}") - parameters = self._parse_payload(decrypted_payload) - _LOGGER.debug( - f"Parameters: {self._parameters_to_str(parameters, types=True)}" - ) - - # Log parameters we will send if debugging (makes handshake easier to see in logs) - if _LOGGER.isEnabledFor(logging.DEBUG): - new_parameters = self._parse_payload( - self._decrypt_payload( - self._split_packet(bytes.fromhex(NEGOTIATION_COMMAND_4))[2] - ) - ) - _LOGGER.debug( - f"Stage 4 response message parameters: {self._parameters_to_str(new_parameters, types=True)}" - ) - - _LOGGER.debug("Sending stage 4 response message...") - return await self._client.write_gatt_char( - UUID_COMMAND, - bytes.fromhex(NEGOTIATION_COMMAND_4), + await self._send_packet(pattern=NEGOTIATION_PATTERN, cmd="4021", + parameters={ "a1": { + "key": bytes.fromhex("a1"), + "type": None, + "value": bytes.fromhex("d5e3020a220079c96517fd47d6023df4f5530914cc6843aaad76cf888537c4cd7db4c879056ea7d5ff83696f0f32bd7034b251396bf0b1bb1f37a7446857d1a6"), + }}, ) # Negotiation stage 5 case "4821": _LOGGER.debug( - "Entered negotiation stage 5 due to response from device!" + "Entered negotiation stage 5 due to response from device!", ) - decrypted_payload = self._decrypt_payload(payload) - _LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}") - parameters = self._parse_payload(decrypted_payload) - _LOGGER.debug( - f"Parameters: {self._parameters_to_str(parameters, types=True)}" - ) - self._negotiation_timestamp = time.time() # Extract public key of device from payload - device_public_key_bytes = bytes.fromhex("04") + parameters["a1"] + device_public_key_bytes = bytes.fromhex("04") + parameters["a1"].value_legacy _LOGGER.debug(f"Public key of device: {device_public_key_bytes.hex()}") device_public_key = EllipticCurvePublicKey.from_encoded_point( - SECP256R1(), device_public_key_bytes + SECP256R1(), device_public_key_bytes, ) # Calculate the shared secret @@ -346,33 +257,23 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: self._shared_secret = private_key.exchange(ECDH(), device_public_key) _LOGGER.debug(f"Shared secret: {self._shared_secret.hex()}") - # All negotiation packets past this point use the - # shared secret for encryption rather than the static key. - # This means we need to build these messages instead of using - # pre-defined ones. _LOGGER.debug("Sending stage 5 response message...") - - # Log parameters we will send if debugging (makes handshake easier to see in logs) - if _LOGGER.isEnabledFor(logging.DEBUG): - new_parameters = self._parse_payload( - bytes.fromhex(NEGOTIATION_COMMAND_5_PAYLOAD) - ) - _LOGGER.debug( - f"Stage 5 response message parameters: {self._parameters_to_str(new_parameters, types=True)}" - ) - - new_payload = self._encrypt_payload( - bytes.fromhex(NEGOTIATION_COMMAND_5_PAYLOAD) - ) - new_packet = self._build_packet( - pattern=bytes.fromhex(NEGOTIATION_PATTERN), - cmd=bytes.fromhex(NEGOTIATION_COMMAND_5_CMD), - payload=new_payload, - ) - _LOGGER.debug(f"Built stage 5 response packet: {new_packet.hex()}") - return await self._client.write_gatt_char( - UUID_COMMAND, - new_packet, + await self._send_packet(pattern=NEGOTIATION_PATTERN, cmd="4022", + parameters={ + "a1": { + "key": bytes.fromhex("a1"), + "type": None, + "value": lambda self: self._timestamp(), + }, "a3": { + "key": bytes.fromhex("a3"), + "type": None, + "value": bytes.fromhex("00000000"), + }, "a5": { + "key": bytes.fromhex("a5"), + "type": None, + "value": (get_posix_tz() or FALLBACK_TZ).encode(), + }, + }, ) # Negotiations past this point are encrypted using the shared secret @@ -382,143 +283,93 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: _LOGGER.debug( "Entered negotiation stage 6 due to response from device!" ) - decrypted_payload = self._decrypt_payload(payload) - _LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}") - parameters = self._parse_payload(decrypted_payload) - _LOGGER.debug( - f"Parameters: {self._parameters_to_str(parameters, types=True)}" - ) - - _LOGGER.debug("Sending stage 6 response message...") - - # Log parameters we will send if debugging (makes handshake easier to see in logs) - if _LOGGER.isEnabledFor(logging.DEBUG): - new_parameters = self._parse_payload( - bytes.fromhex(NEGOTIATION_COMMAND_6_PAYLOAD) - ) - _LOGGER.debug( - f"Stage 6 response message parameters: {self._parameters_to_str(new_parameters, types=True)}" - ) - - new_payload = self._encrypt_payload( - bytes.fromhex(NEGOTIATION_COMMAND_6_PAYLOAD) - ) - new_packet = self._build_packet( - pattern=bytes.fromhex(NEGOTIATION_PATTERN), - cmd=bytes.fromhex(NEGOTIATION_COMMAND_6_CMD), - payload=new_payload, - ) - _LOGGER.debug(f"Built stage 6 response packet: {new_packet.hex()}") - return await self._client.write_gatt_char( - UUID_COMMAND, - new_packet, + await self._send_packet(pattern=NEGOTIATION_PATTERN, cmd="4027", + parameters={ + "a1": { + "key": bytes.fromhex("a1"), + "type": None, + "value": lambda self: self._timestamp(), + }, "a2": { + "key": bytes.fromhex("a2"), + "type": None, + "value": UUID_STRING.encode(), + }, + }, ) # Negotiation stage 7 case "4827": _LOGGER.debug( - "Entered negotiation stage 7 due to response from device!" - ) - decrypted_payload = self._decrypt_payload(payload) - _LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}") - parameters = self._parse_payload(decrypted_payload) - _LOGGER.debug( - f"Parameters: {self._parameters_to_str(parameters, types=True)}" - ) - - _LOGGER.debug("Sending stage 7 response messages...") - - # Packet A - new_payload_a = self._encrypt_payload( - bytes.fromhex(NEGOTIATION_COMMAND_7_PAYLOAD) - ) - new_packet_a = self._build_packet( - pattern=bytes.fromhex(TELEMETRY_PATTERN), - cmd=bytes.fromhex(NEGOTIATION_COMMAND_7_CMD), - payload=new_payload_a, - ) - _LOGGER.debug(f"Built stage 7a response packet: {new_packet_a.hex()}") - await self._client.write_gatt_char( - UUID_COMMAND, - new_packet_a, + "Entered negotiation stage 7 due to response from device!", + ) + await self._send_packet(pattern=TELEMETRY_PATTERN, cmd="4200", + parameters={ + "a1": { + "key": bytes.fromhex("a1"), + "type": None, + "value": bytes.fromhex("21"), + }, "fe": { + "key": bytes.fromhex("fe"), + "type": None, + "value": lambda self: self._timestamp(), + }, + }, + ) + await self._send_packet(pattern=TELEMETRY_PATTERN, cmd="420a", + parameters={ + "a1": { + "key": bytes.fromhex("a1"), + "type": None, + "value": bytes.fromhex("21"), + }, "a2": { + "key": bytes.fromhex("a2"), + "type": None, + "value": bytes.fromhex("044742"), + }, "a3": { + "key": bytes.fromhex("a3"), + "type": 4, + "value": UUID_STRING.encode(), + }, "a5": { + "key": bytes.fromhex("a5"), + "type": None, + "value": bytes.fromhex("0101"), + }, "fe": { + "key": bytes.fromhex("fe"), + "type": None, + "value": lambda self: self._timestamp(), + }, + }, ) - # Log parameters we will send if debugging (makes handshake easier to see in logs) - if _LOGGER.isEnabledFor(logging.DEBUG): - new_parameters = self._parse_payload( - bytes.fromhex(NEGOTIATION_COMMAND_7_PAYLOAD) - ) - _LOGGER.debug( - f"Stage 7a response message parameters: {self._parameters_to_str(new_parameters, types=True)}" - ) - - # Packet B - new_payload_b = self._encrypt_payload( - bytes.fromhex(NEGOTIATION_COMMAND_8_PAYLOAD) - ) - new_packet_b = self._build_packet( - pattern=bytes.fromhex(TELEMETRY_PATTERN), - cmd=bytes.fromhex(NEGOTIATION_COMMAND_8_CMD), - payload=new_payload_b, - ) - _LOGGER.debug(f"Built stage 7b response packet: {new_packet_b.hex()}") - await self._client.write_gatt_char( - UUID_COMMAND, - new_packet_b, - ) - - # Log parameters we will send if debugging (makes handshake easier to see in logs) - if _LOGGER.isEnabledFor(logging.DEBUG): - new_parameters = self._parse_payload( - bytes.fromhex(NEGOTIATION_COMMAND_8_PAYLOAD) - ) - _LOGGER.debug( - f"Stage 7b response message parameters: {self._parameters_to_str(new_parameters, types=True)}" - ) - - return - case _: _LOGGER.warning( - f"Received unexpected negotiation request response from device! cmd: '{cmd}', parameters: '{self._parameters_to_str(parameters, types=True)}'" + f"Received unexpected negotiation request response from device! cmd: '{cmd}'" ) ##################### # Packet processing # ##################### - async def _process_telemetry_packet( - self, payload: bytes, cmd: bytes = None - ) -> None: - """ - Process a telemetry packet from an Anker Prime device. - - Anker Prime devices pack all telemetry data into a single packet - requiring no special logic to handle. - """ - decrypted_payload = self._decrypt_payload(payload) - _LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}") - parameters = self._parse_payload(decrypted_payload) - return await self._process_telemetry(parameters) - - async def _send_command(self, cmd: bytes, payload: bytes) -> None: + async def _send_command(self, cmd: str, parameters: dict, **kwargs: dict) -> None: """Send a command to the device. - :param cmd: 2 bytes containing command type. - :param payload: Variable number of bytes containing arguments. + Parameter values may use lambda functions which will be executed at + this point, where variables may be passed in as keyword arguments. + + :param cmd: The command type (e.g 4200, 0001, etc). + :param parameters: Parameters of the command. :raises ConnectionError: If not connected/negotiated to device. """ if not self.negotiated: raise ConnectionError("Not connected to device") - # Commands include a timestamp in the payload to prevent replay attacks - # and that timestamp is set during negotiations - time_passed = int(time.time() - self._negotiation_timestamp) - base_timestamp = int.from_bytes( - bytes.fromhex(BASE_TIMESTAMP), byteorder="little" - ) - new_timestamp = (base_timestamp + time_passed).to_bytes( - length=4, byteorder="little" + await self._send_packet( + pattern="03000f", + cmd=cmd, + parameters=parameters | { "fe": { + "key": bytes.fromhex("fe"), + "type": None, + "value": lambda self: self._timestamp(), + }}, + **kwargs, ) - new_payload = payload + bytes.fromhex("fe04") + new_timestamp - await self._send_encrypted_packet(cmd, new_payload) diff --git a/SolixBLE/utilities.py b/SolixBLE/utilities.py index 7c11909..5438e88 100644 --- a/SolixBLE/utilities.py +++ b/SolixBLE/utilities.py @@ -5,8 +5,12 @@ """ import asyncio +import importlib.resources as resources +import inspect import logging +from typing import Callable +import tzlocal from bleak import BleakScanner, BLEDevice from .const import UUID_IDENTIFIER @@ -45,3 +49,57 @@ def callback(device, advertising_data): await asyncio.sleep(timeout) return devices + +def _filter_kwargs(function: Callable, args: dict) -> dict: + """ + Return only the keyword arguments which are valid for the function. + + :param function: Function to filter arguments for. + :param args: Arguments to filter. + :returns: Filtered arguments. + """ + signature = inspect.signature(function) + return { + k: v for k, v in args.items() + if k in signature.parameters and k != "self" + } + +def _to_bytes(data: bytes | str | int | Callable | None, **kwargs: dict) -> bytes: + """Return input in byte form. + + Lambda functions are executed using keyword arguments. + Keyword arguments are passed through to conversion functions. + + :param data: Data to convert to bytes. + :returns: Byte form of input. + :raises ValueError: If input type unsupported. + """ + if data is None: + return b"" + if isinstance(data, bytes): + return data + if type(data) is str: + return bytes.fromhex(data) + if type(data) is int: + return int.to_bytes(data, **_filter_kwargs(int.to_bytes, kwargs)) + if isinstance(data, Callable): + return _to_bytes(data(*[kwargs[x] for x in data.__code__.co_varnames]), **kwargs) + raise ValueError(f"Unable to convert '{type(data)}' to bytes!") + +def get_posix_tz() -> str | None: + """Return the current time zone as a POSIX timezone string. + + Examples: `EST5EDT,M3.2.0,M11.1.0`, `GMT0BST,M3.5.0/1,M10.5.0` + + :returns: String of the systems timezone in POSIX format or None if unable. + """ + + try: + local_zone = tzlocal.get_localzone_name() + + # The POSIX tz string is present on the last line of the tz db + with resources.files("tzdata.zoneinfo").joinpath(local_zone).open("rb") as f: + lines = f.readlines() + return lines[-1].decode("ascii").strip() + except Exception: + _LOGGER.exception("Unable to determine system time zone!") diff --git a/pyproject.toml b/pyproject.toml index caa4777..cb85872 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,10 @@ dependencies = [ "bleak>=0.19.0", "cryptography", "pycryptodome", - "bleak-retry-connector" + "bleak-retry-connector", + "construct", + "tzdata", + "tzlocal" ] requires-python = ">= 3.11" authors = [ diff --git a/requirements_dev.txt b/requirements_dev.txt index c7967e2..c296155 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -5,5 +5,8 @@ bleak cryptography pycryptodome bleak-retry-connector +construct +tzdata +tzlocal ruff mypy \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index d5f3746..686db36 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,10 +5,15 @@ """ import asyncio +from collections.abc import Generator from unittest import mock import pytest +from SolixBLE.const import FALLBACK_TZ +from SolixBLE.device import SolixBLEDevice +from SolixBLE.prime_device import PrimeDevice + @pytest.fixture def fast_timeouts(): @@ -32,3 +37,21 @@ async def scaled_sleep(delay): with mock.patch("asyncio.sleep", side_effect=scaled_sleep): yield + + +@pytest.fixture +def fake_time() -> Generator[None, None, None]: + """Use the timestamp used in the test data for all packets.""" + + solix = bytes.fromhex("42ad8c69") + prime = bytes.fromhex("ef79b569") + + def _mocked_timestamp(self) -> bytes: # noqa: ANN001 + return prime if isinstance(self, PrimeDevice) else solix + + with ( + mock.patch.object(SolixBLEDevice, "_timestamp", new=_mocked_timestamp), + mock.patch("SolixBLE.device.get_posix_tz", return_value=FALLBACK_TZ), + mock.patch("SolixBLE.prime_device.get_posix_tz", return_value=FALLBACK_TZ), + ): + yield diff --git a/tests/const.py b/tests/const.py index 42ca316..ce6d573 100644 --- a/tests/const.py +++ b/tests/const.py @@ -27,19 +27,19 @@ NEGOTIATION_RESPONSES_PRIME: dict[str, list[str]] = { - prime_device.NEGOTIATION_COMMAND_0: [ + "ff09200003000140010a82d0ab535303e3aa9f0c2f9c868465bc8476f556fb7d": [ "ff091e000300014801ab273ed3e27270c3f4d676ac7d69a00572793732a6" ], - prime_device.NEGOTIATION_COMMAND_1: [ + "ff09270003000140030a82d0ab53538ab3de100ac9bb87a0b8e36c1dd8167a9c25a9839d9a14d5": [ "ff092b000300014803ab273ed0443800b35db54c6d4a6ec3d48171a04ea7ebce8bf749e5e48c5d991a5e67" ], - prime_device.NEGOTIATION_COMMAND_2: [ + "ff09200003000140290a82d0ab535303e3aa9f0c2f9c868465bc8476f556fb55": [ "ff0958000300014829ab273ed144326ada9fc66fa02508c5ddf549ade014d1eeb252fea1057c15b00985ab8a724fa3830e8e5b27acbaa1224fd2172c0439d27aaf9e62a66bda5c41c424f23c5c8d7df8d3b89422ddff2266" ], - prime_device.NEGOTIATION_COMMAND_3: [ + "ff092d0003000140050a82d0ab53538ab3de100ae04aca6791257881a90164eac7460450e0c82f2c03de4f9604": [ "ff091b000300014805abab709a595a803dd04246b78a927453cf65" ], - prime_device.NEGOTIATION_COMMAND_4: [ + "ff095c0003000140210ac6ea31e4300bb2877d6ddeb628b0d7be8d768333f00ceab5454d20fbd97e091457b1f3b6efb6511eb9e98ac2b2c46eee211ae359ad246e1ae9886b4a29e41eddd5a5064d8b9ffdbfb43eb6b8e307fcde9de7": [ "ff095d000300014821ab277fc01de436d341de628c79c1384d0aea25ce030622fa3ca0808ce5d1b7365ec1b1753a11ab78fba3ca07dda95cd57c93d1267b1222bef9908f7633a758ab924eba63ee01e715be5b9c3b082e6d81c2204241" ], # These packets below are dynamically generated by the library unlike previous @@ -47,14 +47,14 @@ # secret for encryption which is different from device to device due to different # public keys. This is why constants are not used. But we can use constants for # the earlier packets which use static keys. - "ff094000030001402257ec69586f3500c8f858e0ba047f237f4e2ed8c50d2f39ba3587e4010275bea22242936f08788849272fb3f4cf7493be4a60bb9c9f0693": [ + "ff094000030001402257ec76586f3500c8f858e0ba047f237f4e2ed8c50d2f39ba3587e4010275bea22242936f08784271e19d67a6275ff6bb50577acec0a068": [ "ff091b000300014822f60b45600839b2c171b33dc5790ed64ae32d" ], - "ff094600030001402757ec69586f3501e8cf6185d8c4035707377af9af3a2e40b02b86e7531974f1c22440de6e43705566b77cf940e235b65abf4d413ece5f2c3781712f3742": [ + "ff094600030001402757ec76586f3501e8cf6185d8c4035707377af9af3a2e40b02b86e7531974f1c22440de6e43705566b77cf940280d70e86b1fa915ab5a360040237091b9": [ "ff091b000300014827f60b45600839b2c171b33dc5790ed64ae328" ], - "ff09230003000f420057e9b8dfdeacda7991d3eb7f12093e55ff002aa9799bcc9216e3": [], - "ff09530003000f420a57e9b883d958e48e5b7de48d980206577e2dafbb3d604dea3686f3011969f0db2311906d142b5730ee2bfb11e3fbbe7485aac8877995310669156ec74645c962b419e579b385fd079967": [], + "ff09230003000f420057e9b8dfdeb3da799151684e584bb99eaaccfac9baf7cbcfa6e4": [], + "ff09530003000f420a57e9b883d958e48e5b7de48d980206577e2dafbb3d604dea3686f3011969f0db2311906d142b5730ee2bfb11e3fbbe7485aac887798a31066997edf60c074ea9e1d5351970e9fa5a2960": [], } """ This maps the expected commands sent by the library to what my Anker Prime 160w @@ -74,9 +74,11 @@ const.NEGOTIATION_COMMAND_4: [ "ff094d00030001082100a140b2ade5cac4f4a0c1307e44a0e9c5363cb21e4c8485ee324c23be949fa5d5929a75e57da3207c948a0c366ca9ea1ab2cb8e57d2d046a6ebefe5d96adb5d4cb35039" ], - const.NEGOTIATION_COMMAND_5: [], + # The command sent to the device in response to entering stage 5 + # is encrypted using the shared secret + "ff095a00030001402222c97d5c5bf02e0b43c62c864817cd38b9fd152113728513cc88bc4a1b4de3062473fcd5819618c4b926694d2732c337095a18974243127aa5e266f76f9ac7de06ba357763abe88aaef98f8c7a5e48a324": [], } """ -This maps the expected commands sent by the library to what my Anker Solix C300 +This maps the expected commands sent by the library to what my Anker Solix C1000(X) sends in response. Its used to emulate it for testing negotiations. -""" +""" \ No newline at end of file diff --git a/tests/devices/__init__.py b/tests/devices/__init__.py new file mode 100644 index 0000000..28281f8 --- /dev/null +++ b/tests/devices/__init__.py @@ -0,0 +1,5 @@ +"""Tests for device functions of SolixBLE module. + +.. moduleauthor:: Harvey Lelliott (flip-dots) + +""" diff --git a/tests/devices/c1000.py b/tests/devices/c1000.py new file mode 100644 index 0000000..b61634b --- /dev/null +++ b/tests/devices/c1000.py @@ -0,0 +1,307 @@ +"""C1000(X) power station device tests. + +.. moduleauthor:: Harvey Lelliott (flip-dots) + +""" +import pytest + +from SolixBLE.devices.c1000 import C1000 +from SolixBLE.states import DisplayTimeout, LightStatus +from tests.const import NEGOTIATION_RESPONSES_SOLIX + +######################## +# Test device commands # +######################## + +# These tests are for sending commands to the device and making sure the +# correct calls are made to the command sending functions and errors are +# raised where appropriate. See test_send_command() in test_commands.py. + +C1000_TEST_COMMANDS = [ + pytest.param( + C1000, + "turn_ac_on", + [], + [("404a", "a10121a2020101")], + id="c1000_ac_on", + ), + pytest.param( + C1000, + "turn_ac_off", + [], + [("404a", "a10121a2020100")], + id="c1000_ac_off", + ), + pytest.param( + C1000, + "turn_dc_on", + [], + [("404b", "a10121a2020101")], + id="c1000_dc_on", + ), + pytest.param( + C1000, + "turn_dc_off", + [], + [("404b", "a10121a2020100")], + id="c1000_dc_off", + ), + pytest.param( + C1000, + "set_light_mode", + [LightStatus.LOW], + [("404f", "a10121a2020101")], + id="c1000_light_low", + ), + pytest.param( + C1000, + "set_light_mode", + [LightStatus.MEDIUM], + [("404f", "a10121a2020102")], + id="c1000_light_med", + ), + pytest.param( + C1000, + "set_light_mode", + [LightStatus.HIGH], + [("404f", "a10121a2020103")], + id="c1000_light_high", + ), + pytest.param( + C1000, + "set_light_mode", + [LightStatus.SOS], + [("404f", "a10121a2020104")], + id="c1000_light_sos", + ), + pytest.param( + C1000, + "set_light_mode", + [LightStatus.UNKNOWN], + ValueError, + id="c1000_light_unknown", + ), + pytest.param( + C1000, + "set_display_mode", + [LightStatus.LOW], + [("404c", "a10121a2020101")], + id="c1000_display_low", + ), + pytest.param( + C1000, + "set_display_mode", + [LightStatus.MEDIUM], + [("404c", "a10121a2020102")], + id="c1000_display_med", + ), + pytest.param( + C1000, + "set_display_mode", + [LightStatus.HIGH], + [("404c", "a10121a2020103")], + id="c1000_display_high", + ), + pytest.param( + C1000, + "set_display_mode", + [LightStatus.SOS], + ValueError, + id="c1000_display_sos", + ), + pytest.param( + C1000, + "set_display_mode", + [LightStatus.UNKNOWN], + ValueError, + id="c1000_display_unknown", + ), + pytest.param( + C1000, + "set_display_timeout", + [DisplayTimeout.S20], + [("4046", "a10121a203021400")], + id="c1000_display_timeout_20s", + ), + pytest.param( + C1000, + "set_display_timeout", + [DisplayTimeout.S1800], + [("4046", "a10121a203020807")], + id="c1000_display_timeout_30m", + ), + pytest.param( + C1000, + "set_display_timeout", + [DisplayTimeout.UNKNOWN], + ValueError, + id="c1000_display_timeout_unknown", + ), + pytest.param( + C1000, + "turn_display_on", + [], + [("4052", "a10121a2020101")], + id="c1000_display_on", + ), + pytest.param( + C1000, + "turn_display_off", + [], + [("4052", "a10121a2020100")], + id="c1000_display_off", + ), +] + + +#################################### +# Test device commands & responses # +#################################### + +# These tests are for sending commands to the device and making sure the correct +# calls are made to the command sending functions, that the response is handled +# appropriately, the correct value is returned, and errors are raised where +# appropriate. See test_send_command_response() in test_commands.py. + +C1000_TEST_COMMANDS_RESPONSES = [ + pytest.param( + C1000, + "get_status_update", + [], + [("4040", "a10121")], + [("03010f", "c840", None)], + TimeoutError, + id="c1000_status_update_error", + ), +] + + +############################ +# Test device commands E2E # +############################ + +# These tests end-to-end tests check that the correct bytes are sent +# by the command. See test_send_command_e2e() in test_commands.py. + +C1000_TEST_COMMANDS_E2E = [ + pytest.param( + C1000, + NEGOTIATION_RESPONSES_SOLIX, + "turn_ac_on", + [], + "ff091a0003000f404acf1b676bb8c648a6f066b90d0c2025028b", + id="c1000_ac_on", + ), + pytest.param( + C1000, + NEGOTIATION_RESPONSES_SOLIX, + "turn_ac_off", + [], + "ff091a0003000f404aa665f0bcc4f9a3a154d50bb71d7c300e38", + id="c1000_ac_off", + ), + pytest.param( + C1000, + NEGOTIATION_RESPONSES_SOLIX, + "turn_dc_on", + [], + "ff091a0003000f404bcf1b676bb8c648a6f066b90d0c2025028a", + id="c1000_dc_on", + ), + pytest.param( + C1000, + NEGOTIATION_RESPONSES_SOLIX, + "turn_dc_off", + [], + "ff091a0003000f404ba665f0bcc4f9a3a154d50bb71d7c300e39", + id="c1000_dc_off", + ), + pytest.param( + C1000, + NEGOTIATION_RESPONSES_SOLIX, + "set_light_mode", + [LightStatus.LOW], + "ff091a0003000f404fcf1b676bb8c648a6f066b90d0c2025028e", + id="c1000_light_low", + ), + pytest.param( + C1000, + NEGOTIATION_RESPONSES_SOLIX, + "set_light_mode", + [LightStatus.MEDIUM], + "ff091a0003000f404f78e6e204ae7a3858b1aac611fd4bdec146", + id="c1000_light_med", + ), + pytest.param( + C1000, + NEGOTIATION_RESPONSES_SOLIX, + "set_light_mode", + [LightStatus.HIGH], + "ff091a0003000f404f3fa145b4757507f18b3503e0cc3bcae3f5", + id="c1000_light_high", + ), + pytest.param( + C1000, + NEGOTIATION_RESPONSES_SOLIX, + "set_light_mode", + [LightStatus.SOS], + "ff091a0003000f404f2c28e49e5cd5ed57b9749702b802f3fb48", + id="c1000_light_sos", + ), + pytest.param( + C1000, + NEGOTIATION_RESPONSES_SOLIX, + "set_display_mode", + [LightStatus.LOW], + "ff091a0003000f404ccf1b676bb8c648a6f066b90d0c2025028d", + id="c1000_display_low", + ), + pytest.param( + C1000, + NEGOTIATION_RESPONSES_SOLIX, + "set_display_mode", + [LightStatus.MEDIUM], + "ff091a0003000f404c78e6e204ae7a3858b1aac611fd4bdec145", + id="c1000_display_med", + ), + pytest.param( + C1000, + NEGOTIATION_RESPONSES_SOLIX, + "set_display_mode", + [LightStatus.HIGH], + "ff091a0003000f404c3fa145b4757507f18b3503e0cc3bcae3f6", + id="c1000_display_high", + ), + pytest.param( + C1000, + NEGOTIATION_RESPONSES_SOLIX, + "set_display_timeout", + [DisplayTimeout.S20], + "ff091a0003000f4046def18b6e3fa7434937ef01fecb95dfd3cb", + id="c1000_display_timeout_20s", + ), + pytest.param( + C1000, + NEGOTIATION_RESPONSES_SOLIX, + "set_display_timeout", + [DisplayTimeout.S1800], + "ff091a0003000f404665b9a755e0b46d3947a6937b5f7be4d2d3", + id="c1000_display_timeout_30m", + ), + pytest.param( + C1000, + NEGOTIATION_RESPONSES_SOLIX, + "turn_display_on", + [], + "ff091a0003000f4052cf1b676bb8c648a6f066b90d0c20250293", + id="c1000_display_on", + ), + pytest.param( + C1000, + NEGOTIATION_RESPONSES_SOLIX, + "turn_display_off", + [], + "ff091a0003000f4052a665f0bcc4f9a3a154d50bb71d7c300e20", + id="c1000_display_off", + ), +] diff --git a/tests/devices/c1000g2.py b/tests/devices/c1000g2.py new file mode 100644 index 0000000..b3b2a8c --- /dev/null +++ b/tests/devices/c1000g2.py @@ -0,0 +1,91 @@ +"""C1000G2 power station device tests. + +.. moduleauthor:: Harvey Lelliott (flip-dots) + +""" +import pytest + +from SolixBLE.devices.c1000g2 import C1000G2 +from tests.const import NEGOTIATION_RESPONSES_SOLIX + +######################## +# Test device commands # +######################## + +# These tests are for sending commands to the device and making sure the +# correct calls are made to the command sending functions and errors are +# raised where appropriate. See test_send_command() in test_commands.py. + +C1000G2_TEST_COMMANDS = [ + pytest.param( + C1000G2, + "turn_ac_on", + [], + [("4101", "a10121a2020101")], + id="c1000g2_ac_on", + ), + pytest.param( + C1000G2, + "turn_ac_off", + [], + [("4101", "a10121a2020100")], + id="c1000g2_ac_off", + ), + pytest.param( + C1000G2, + "turn_dc_on", + [], + [("4102", "a10121a2020101")], + id="c1000g2_dc_on", + ), + pytest.param( + C1000G2, + "turn_dc_off", + [], + [("4102", "a10121a2020100")], + id="c1000g2_dc_off", + ), +] + + +############################ +# Test device commands E2E # +############################ + +# These tests end-to-end tests check that the correct bytes are sent +# by the command. See test_send_command_e2e() in test_commands.py. + +C1000G2_TEST_COMMANDS_E2E = [ + pytest.param( + C1000G2, + NEGOTIATION_RESPONSES_SOLIX, + "turn_ac_on", + [], + "ff091a0003000f4101cf1b676bb8c648a6f066b90d0c202502c1", + id="c1000g2_ac_on", + ), + pytest.param( + C1000G2, + NEGOTIATION_RESPONSES_SOLIX, + "turn_ac_off", + [], + "ff091a0003000f4101a665f0bcc4f9a3a154d50bb71d7c300e72", + id="c1000g2_ac_off", + ), + pytest.param( + C1000G2, + NEGOTIATION_RESPONSES_SOLIX, + "turn_dc_on", + [], + "ff091a0003000f4102cf1b676bb8c648a6f066b90d0c202502c2", + id="c1000g2_dc_on", + ), + pytest.param( + C1000G2, + NEGOTIATION_RESPONSES_SOLIX, + "turn_dc_off", + [], + "ff091a0003000f4102a665f0bcc4f9a3a154d50bb71d7c300e71", + id="c1000g2_dc_off", + ), +] diff --git a/tests/devices/c300.py b/tests/devices/c300.py new file mode 100644 index 0000000..70fd752 --- /dev/null +++ b/tests/devices/c300.py @@ -0,0 +1,307 @@ +"""C300(X) power station device tests. + +.. moduleauthor:: Harvey Lelliott (flip-dots) + +""" +import pytest + +from SolixBLE.devices.c300 import C300 +from SolixBLE.states import DisplayTimeout, LightStatus +from tests.const import NEGOTIATION_RESPONSES_SOLIX + +######################## +# Test device commands # +######################## + +# These tests are for sending commands to the device and making sure the +# correct calls are made to the command sending functions and errors are +# raised where appropriate. See test_send_command() in test_commands.py. + +C300_TEST_COMMANDS = [ + pytest.param( + C300, + "turn_ac_on", + [], + [("404a", "a10121a2020101")], + id="c300_ac_on", + ), + pytest.param( + C300, + "turn_ac_off", + [], + [("404a", "a10121a2020100")], + id="c300_ac_off", + ), + pytest.param( + C300, + "turn_dc_on", + [], + [("404b", "a10121a2020101")], + id="c300_dc_on", + ), + pytest.param( + C300, + "turn_dc_off", + [], + [("404b", "a10121a2020100")], + id="c300_dc_off", + ), + pytest.param( + C300, + "set_light_mode", + [LightStatus.LOW], + [("404f", "a10121a2020101")], + id="c300_light_low", + ), + pytest.param( + C300, + "set_light_mode", + [LightStatus.MEDIUM], + [("404f", "a10121a2020102")], + id="c300_light_med", + ), + pytest.param( + C300, + "set_light_mode", + [LightStatus.HIGH], + [("404f", "a10121a2020103")], + id="c300_light_high", + ), + pytest.param( + C300, + "set_light_mode", + [LightStatus.SOS], + [("404f", "a10121a2020104")], + id="c300_light_sos", + ), + pytest.param( + C300, + "set_light_mode", + [LightStatus.UNKNOWN], + ValueError, + id="c300_light_unknown", + ), + pytest.param( + C300, + "set_display_mode", + [LightStatus.LOW], + [("404c", "a10121a2020101")], + id="c300_display_low", + ), + pytest.param( + C300, + "set_display_mode", + [LightStatus.MEDIUM], + [("404c", "a10121a2020102")], + id="c300_display_med", + ), + pytest.param( + C300, + "set_display_mode", + [LightStatus.HIGH], + [("404c", "a10121a2020103")], + id="c300_display_high", + ), + pytest.param( + C300, + "set_display_mode", + [LightStatus.SOS], + ValueError, + id="c300_display_sos", + ), + pytest.param( + C300, + "set_display_mode", + [LightStatus.UNKNOWN], + ValueError, + id="c300_display_unknown", + ), + pytest.param( + C300, + "set_display_timeout", + [DisplayTimeout.S20], + [("4046", "a10121a203021400")], + id="c300_display_timeout_20s", + ), + pytest.param( + C300, + "set_display_timeout", + [DisplayTimeout.S1800], + [("4046", "a10121a203020807")], + id="c300_display_timeout_30m", + ), + pytest.param( + C300, + "set_display_timeout", + [DisplayTimeout.UNKNOWN], + ValueError, + id="c300_display_timeout_unknown", + ), + pytest.param( + C300, + "turn_display_on", + [], + [("4052", "a10121a2020101")], + id="c300_display_on", + ), + pytest.param( + C300, + "turn_display_off", + [], + [("4052", "a10121a2020100")], + id="c300_display_off", + ), +] + + +#################################### +# Test device commands & responses # +#################################### + +# These tests are for sending commands to the device and making sure the correct +# calls are made to the command sending functions, that the response is handled +# appropriately, the correct value is returned, and errors are raised where +# appropriate. See test_send_command_response() in test_commands.py. + +C300_TEST_COMMANDS_RESPONSES = [ + pytest.param( + C300, + "get_status_update", + [], + [("4040", "a10121")], + [("03010f", "c840", None)], + TimeoutError, + id="c300_status_update_error", + ), +] + + +############################ +# Test device commands E2E # +############################ + +# These tests end-to-end tests check that the correct bytes are sent +# by the command. See test_send_command_e2e() in test_commands.py. + +C300_TEST_COMMANDS_E2E = [ + pytest.param( + C300, + NEGOTIATION_RESPONSES_SOLIX, + "turn_ac_on", + [], + "ff091a0003000f404acf1b676bb8c648a6f066b90d0c2025028b", + id="c300_ac_on", + ), + pytest.param( + C300, + NEGOTIATION_RESPONSES_SOLIX, + "turn_ac_off", + [], + "ff091a0003000f404aa665f0bcc4f9a3a154d50bb71d7c300e38", + id="c300_ac_off", + ), + pytest.param( + C300, + NEGOTIATION_RESPONSES_SOLIX, + "turn_dc_on", + [], + "ff091a0003000f404bcf1b676bb8c648a6f066b90d0c2025028a", + id="c300_dc_on", + ), + pytest.param( + C300, + NEGOTIATION_RESPONSES_SOLIX, + "turn_dc_off", + [], + "ff091a0003000f404ba665f0bcc4f9a3a154d50bb71d7c300e39", + id="c300_dc_off", + ), + pytest.param( + C300, + NEGOTIATION_RESPONSES_SOLIX, + "set_light_mode", + [LightStatus.LOW], + "ff091a0003000f404fcf1b676bb8c648a6f066b90d0c2025028e", + id="c300_light_low", + ), + pytest.param( + C300, + NEGOTIATION_RESPONSES_SOLIX, + "set_light_mode", + [LightStatus.MEDIUM], + "ff091a0003000f404f78e6e204ae7a3858b1aac611fd4bdec146", + id="c300_light_med", + ), + pytest.param( + C300, + NEGOTIATION_RESPONSES_SOLIX, + "set_light_mode", + [LightStatus.HIGH], + "ff091a0003000f404f3fa145b4757507f18b3503e0cc3bcae3f5", + id="c300_light_high", + ), + pytest.param( + C300, + NEGOTIATION_RESPONSES_SOLIX, + "set_light_mode", + [LightStatus.SOS], + "ff091a0003000f404f2c28e49e5cd5ed57b9749702b802f3fb48", + id="c300_light_sos", + ), + pytest.param( + C300, + NEGOTIATION_RESPONSES_SOLIX, + "set_display_mode", + [LightStatus.LOW], + "ff091a0003000f404ccf1b676bb8c648a6f066b90d0c2025028d", + id="c300_display_low", + ), + pytest.param( + C300, + NEGOTIATION_RESPONSES_SOLIX, + "set_display_mode", + [LightStatus.MEDIUM], + "ff091a0003000f404c78e6e204ae7a3858b1aac611fd4bdec145", + id="c300_display_med", + ), + pytest.param( + C300, + NEGOTIATION_RESPONSES_SOLIX, + "set_display_mode", + [LightStatus.HIGH], + "ff091a0003000f404c3fa145b4757507f18b3503e0cc3bcae3f6", + id="c300_display_high", + ), + pytest.param( + C300, + NEGOTIATION_RESPONSES_SOLIX, + "set_display_timeout", + [DisplayTimeout.S20], + "ff091a0003000f4046def18b6e3fa7434937ef01fecb95dfd3cb", + id="c300_display_timeout_20s", + ), + pytest.param( + C300, + NEGOTIATION_RESPONSES_SOLIX, + "set_display_timeout", + [DisplayTimeout.S1800], + "ff091a0003000f404665b9a755e0b46d3947a6937b5f7be4d2d3", + id="c300_display_timeout_30m", + ), + pytest.param( + C300, + NEGOTIATION_RESPONSES_SOLIX, + "turn_display_on", + [], + "ff091a0003000f4052cf1b676bb8c648a6f066b90d0c20250293", + id="c300_display_on", + ), + pytest.param( + C300, + NEGOTIATION_RESPONSES_SOLIX, + "turn_display_off", + [], + "ff091a0003000f4052a665f0bcc4f9a3a154d50bb71d7c300e20", + id="c300_display_off", + ), +] \ No newline at end of file diff --git a/tests/devices/c300dc.py b/tests/devices/c300dc.py new file mode 100644 index 0000000..255596d --- /dev/null +++ b/tests/devices/c300dc.py @@ -0,0 +1,226 @@ +"""C300(X) DC power station device tests. + +.. moduleauthor:: Harvey Lelliott (flip-dots) + +""" +import pytest + +from SolixBLE.devices.c300dc import C300DC +from SolixBLE.states import DisplayTimeout, LightStatus +from tests.const import NEGOTIATION_RESPONSES_SOLIX + +######################## +# Test device commands # +######################## + +# These tests are for sending commands to the device and making sure the +# correct calls are made to the command sending functions and errors are +# raised where appropriate. See test_send_command() in test_commands.py. + +C300DC_TEST_COMMANDS = [ + pytest.param( + C300DC, + "turn_dc_on", + [], + [("404b", "a10121a2020101")], + id="c300dc_dc_on", + ), + pytest.param( + C300DC, + "turn_dc_off", + [], + [("404b", "a10121a2020100")], + id="c300dc_dc_off", + ), + pytest.param( + C300DC, + "turn_display_on", + [], + [("4052", "a10121a2020101")], + id="c300dc_display_on", + ), + pytest.param( + C300DC, + "turn_display_off", + [], + [("4052", "a10121a2020100")], + id="c300dc_display_off", + ), + pytest.param( + C300DC, + "set_light_mode", + [LightStatus.LOW], + [("404f", "a10121a2020101")], + id="c300dc_light_low", + ), + pytest.param( + C300DC, + "set_light_mode", + [LightStatus.MEDIUM], + [("404f", "a10121a2020102")], + id="c300dc_light_med", + ), + pytest.param( + C300DC, + "set_light_mode", + [LightStatus.HIGH], + [("404f", "a10121a2020103")], + id="c300dc_light_high", + ), + pytest.param( + C300DC, + "set_display_timeout", + [DisplayTimeout.S20], + [("4046", "a10121a203021400")], + id="c300dc_display_timeout_20s", + ), + pytest.param( + C300DC, + "set_display_timeout", + [DisplayTimeout.S1800], + [("4046", "a10121a203020807")], + id="c300dc_display_timeout_30m", + ), + pytest.param( + C300DC, + "set_display_mode", + [LightStatus.LOW], + [("404c", "a10121a2020101")], + id="c300dc_display_low", + ), + pytest.param( + C300DC, + "set_display_mode", + [LightStatus.MEDIUM], + [("404c", "a10121a2020102")], + id="c300dc_display_med", + ), + pytest.param( + C300DC, + "set_display_mode", + [LightStatus.HIGH], + [("404c", "a10121a2020103")], + id="c300dc_display_high", + ), + pytest.param( + C300DC, + "set_display_mode", + [LightStatus.SOS], + ValueError, + id="c300dc_display_sos", + ), + pytest.param( + C300DC, + "set_display_mode", + [LightStatus.UNKNOWN], + ValueError, + id="c300dc_display_unknown", + ), +] + + +############################ +# Test device commands E2E # +############################ + +# These tests end-to-end tests check that the correct bytes are sent +# by the command. See test_send_command_e2e() in test_commands.py. + +C300DC_TEST_COMMANDS_E2E = [ + pytest.param( + C300DC, + NEGOTIATION_RESPONSES_SOLIX, + "turn_dc_on", + [], + "ff091a0003000f404bcf1b676bb8c648a6f066b90d0c2025028a", + id="c300dc_dc_on", + ), + pytest.param( + C300DC, + NEGOTIATION_RESPONSES_SOLIX, + "turn_dc_off", + [], + "ff091a0003000f404ba665f0bcc4f9a3a154d50bb71d7c300e39", + id="c300dc_dc_off", + ), + pytest.param( + C300DC, + NEGOTIATION_RESPONSES_SOLIX, + "turn_display_on", + [], + "ff091a0003000f4052cf1b676bb8c648a6f066b90d0c20250293", + id="c300dc_display_on", + ), + pytest.param( + C300DC, + NEGOTIATION_RESPONSES_SOLIX, + "turn_display_off", + [], + "ff091a0003000f4052a665f0bcc4f9a3a154d50bb71d7c300e20", + id="c300dc_display_off", + ), + pytest.param( + C300DC, + NEGOTIATION_RESPONSES_SOLIX, + "set_light_mode", + [LightStatus.LOW], + "ff091a0003000f404fcf1b676bb8c648a6f066b90d0c2025028e", + id="c300dc_light_low", + ), + pytest.param( + C300DC, + NEGOTIATION_RESPONSES_SOLIX, + "set_light_mode", + [LightStatus.MEDIUM], + "ff091a0003000f404f78e6e204ae7a3858b1aac611fd4bdec146", + id="c300dc_light_med", + ), + pytest.param( + C300DC, + NEGOTIATION_RESPONSES_SOLIX, + "set_light_mode", + [LightStatus.HIGH], + "ff091a0003000f404f3fa145b4757507f18b3503e0cc3bcae3f5", + id="c300dc_light_high", + ), + pytest.param( + C300DC, + NEGOTIATION_RESPONSES_SOLIX, + "set_display_timeout", + [DisplayTimeout.S20], + "ff091a0003000f4046def18b6e3fa7434937ef01fecb95dfd3cb", + id="c300dc_display_timeout_20s", + ), + pytest.param( + C300DC, + NEGOTIATION_RESPONSES_SOLIX, + "set_display_timeout", + [DisplayTimeout.S1800], + "ff091a0003000f404665b9a755e0b46d3947a6937b5f7be4d2d3", + id="c300dc_display_timeout_30m", + ), + pytest.param( + C300DC, + NEGOTIATION_RESPONSES_SOLIX, + "set_display_mode", + [LightStatus.LOW], + "ff091a0003000f404ccf1b676bb8c648a6f066b90d0c2025028d", + id="c300dc_display_low", + ), + pytest.param( + C300DC, + NEGOTIATION_RESPONSES_SOLIX, + "set_display_mode", + [LightStatus.MEDIUM], + "ff091a0003000f404c78e6e204ae7a3858b1aac611fd4bdec145", + id="c300dc_display_med", + ), + pytest.param( + C300DC, + NEGOTIATION_RESPONSES_SOLIX, + "set_display_mode", + [LightStatus.HIGH], + "ff091a0003000f404c3fa145b4757507f18b3503e0cc3bcae3f6", + id="c300dc_display_high", + ), +] diff --git a/tests/devices/c800.py b/tests/devices/c800.py new file mode 100644 index 0000000..1a38a58 --- /dev/null +++ b/tests/devices/c800.py @@ -0,0 +1,307 @@ +"""C800(X) power station device tests. + +.. moduleauthor:: Harvey Lelliott (flip-dots) + +""" +import pytest + +from SolixBLE.devices.c800 import C800 +from SolixBLE.states import DisplayTimeout, LightStatus +from tests.const import NEGOTIATION_RESPONSES_SOLIX + +######################## +# Test device commands # +######################## + +# These tests are for sending commands to the device and making sure the +# correct calls are made to the command sending functions and errors are +# raised where appropriate. See test_send_command() in test_commands.py. + +C800_TEST_COMMANDS = [ + pytest.param( + C800, + "turn_ac_on", + [], + [("404a", "a10121a2020101")], + id="c800_ac_on", + ), + pytest.param( + C800, + "turn_ac_off", + [], + [("404a", "a10121a2020100")], + id="c800_ac_off", + ), + pytest.param( + C800, + "turn_dc_on", + [], + [("404b", "a10121a2020101")], + id="c800_dc_on", + ), + pytest.param( + C800, + "turn_dc_off", + [], + [("404b", "a10121a2020100")], + id="c800_dc_off", + ), + pytest.param( + C800, + "set_light_mode", + [LightStatus.LOW], + [("404f", "a10121a2020101")], + id="c800_light_low", + ), + pytest.param( + C800, + "set_light_mode", + [LightStatus.MEDIUM], + [("404f", "a10121a2020102")], + id="c800_light_med", + ), + pytest.param( + C800, + "set_light_mode", + [LightStatus.HIGH], + [("404f", "a10121a2020103")], + id="c800_light_high", + ), + pytest.param( + C800, + "set_light_mode", + [LightStatus.SOS], + [("404f", "a10121a2020104")], + id="c800_light_sos", + ), + pytest.param( + C800, + "set_light_mode", + [LightStatus.UNKNOWN], + ValueError, + id="c800_light_unknown", + ), + pytest.param( + C800, + "set_display_mode", + [LightStatus.LOW], + [("404c", "a10121a2020101")], + id="c800_display_low", + ), + pytest.param( + C800, + "set_display_mode", + [LightStatus.MEDIUM], + [("404c", "a10121a2020102")], + id="c800_display_med", + ), + pytest.param( + C800, + "set_display_mode", + [LightStatus.HIGH], + [("404c", "a10121a2020103")], + id="c800_display_high", + ), + pytest.param( + C800, + "set_display_mode", + [LightStatus.SOS], + ValueError, + id="c800_display_sos", + ), + pytest.param( + C800, + "set_display_mode", + [LightStatus.UNKNOWN], + ValueError, + id="c800_display_unknown", + ), + pytest.param( + C800, + "set_display_timeout", + [DisplayTimeout.S20], + [("4046", "a10121a203021400")], + id="c800_display_timeout_20s", + ), + pytest.param( + C800, + "set_display_timeout", + [DisplayTimeout.S1800], + [("4046", "a10121a203020807")], + id="c800_display_timeout_30m", + ), + pytest.param( + C800, + "set_display_timeout", + [DisplayTimeout.UNKNOWN], + ValueError, + id="c800_display_timeout_unknown", + ), + pytest.param( + C800, + "turn_display_on", + [], + [("4052", "a10121a2020101")], + id="c800_display_on", + ), + pytest.param( + C800, + "turn_display_off", + [], + [("4052", "a10121a2020100")], + id="c800_display_off", + ), +] + + +#################################### +# Test device commands & responses # +#################################### + +# These tests are for sending commands to the device and making sure the correct +# calls are made to the command sending functions, that the response is handled +# appropriately, the correct value is returned, and errors are raised where +# appropriate. See test_send_command_response() in test_commands.py. + +C800_TEST_COMMANDS_RESPONSES = [ + pytest.param( + C800, + "get_status_update", + [], + [("4040", "a10121")], + [("03010f", "c840", None)], + TimeoutError, + id="c800_status_update_error", + ), +] + + +############################ +# Test device commands E2E # +############################ + +# These tests end-to-end tests check that the correct bytes are sent +# by the command. See test_send_command_e2e() in test_commands.py. + +C800_TEST_COMMANDS_E2E = [ + pytest.param( + C800, + NEGOTIATION_RESPONSES_SOLIX, + "turn_ac_on", + [], + "ff091a0003000f404acf1b676bb8c648a6f066b90d0c2025028b", + id="c800_ac_on", + ), + pytest.param( + C800, + NEGOTIATION_RESPONSES_SOLIX, + "turn_ac_off", + [], + "ff091a0003000f404aa665f0bcc4f9a3a154d50bb71d7c300e38", + id="c800_ac_off", + ), + pytest.param( + C800, + NEGOTIATION_RESPONSES_SOLIX, + "turn_dc_on", + [], + "ff091a0003000f404bcf1b676bb8c648a6f066b90d0c2025028a", + id="c800_dc_on", + ), + pytest.param( + C800, + NEGOTIATION_RESPONSES_SOLIX, + "turn_dc_off", + [], + "ff091a0003000f404ba665f0bcc4f9a3a154d50bb71d7c300e39", + id="c800_dc_off", + ), + pytest.param( + C800, + NEGOTIATION_RESPONSES_SOLIX, + "set_light_mode", + [LightStatus.LOW], + "ff091a0003000f404fcf1b676bb8c648a6f066b90d0c2025028e", + id="c800_light_low", + ), + pytest.param( + C800, + NEGOTIATION_RESPONSES_SOLIX, + "set_light_mode", + [LightStatus.MEDIUM], + "ff091a0003000f404f78e6e204ae7a3858b1aac611fd4bdec146", + id="c800_light_med", + ), + pytest.param( + C800, + NEGOTIATION_RESPONSES_SOLIX, + "set_light_mode", + [LightStatus.HIGH], + "ff091a0003000f404f3fa145b4757507f18b3503e0cc3bcae3f5", + id="c800_light_high", + ), + pytest.param( + C800, + NEGOTIATION_RESPONSES_SOLIX, + "set_light_mode", + [LightStatus.SOS], + "ff091a0003000f404f2c28e49e5cd5ed57b9749702b802f3fb48", + id="c800_light_sos", + ), + pytest.param( + C800, + NEGOTIATION_RESPONSES_SOLIX, + "set_display_mode", + [LightStatus.LOW], + "ff091a0003000f404ccf1b676bb8c648a6f066b90d0c2025028d", + id="c800_display_low", + ), + pytest.param( + C800, + NEGOTIATION_RESPONSES_SOLIX, + "set_display_mode", + [LightStatus.MEDIUM], + "ff091a0003000f404c78e6e204ae7a3858b1aac611fd4bdec145", + id="c800_display_med", + ), + pytest.param( + C800, + NEGOTIATION_RESPONSES_SOLIX, + "set_display_mode", + [LightStatus.HIGH], + "ff091a0003000f404c3fa145b4757507f18b3503e0cc3bcae3f6", + id="c800_display_high", + ), + pytest.param( + C800, + NEGOTIATION_RESPONSES_SOLIX, + "set_display_timeout", + [DisplayTimeout.S20], + "ff091a0003000f4046def18b6e3fa7434937ef01fecb95dfd3cb", + id="c800_display_timeout_20s", + ), + pytest.param( + C800, + NEGOTIATION_RESPONSES_SOLIX, + "set_display_timeout", + [DisplayTimeout.S1800], + "ff091a0003000f404665b9a755e0b46d3947a6937b5f7be4d2d3", + id="c800_display_timeout_30m", + ), + pytest.param( + C800, + NEGOTIATION_RESPONSES_SOLIX, + "turn_display_on", + [], + "ff091a0003000f4052cf1b676bb8c648a6f066b90d0c20250293", + id="c800_display_on", + ), + pytest.param( + C800, + NEGOTIATION_RESPONSES_SOLIX, + "turn_display_off", + [], + "ff091a0003000f4052a665f0bcc4f9a3a154d50bb71d7c300e20", + id="c800_display_off", + ), +] diff --git a/tests/devices/f2600.py b/tests/devices/f2600.py new file mode 100644 index 0000000..e50869e --- /dev/null +++ b/tests/devices/f2600.py @@ -0,0 +1,452 @@ +"""F2600 power station device tests. + +.. moduleauthor:: Harvey Lelliott (flip-dots) + +""" +import pytest +from construct import Container + +from SolixBLE.devices.f2600 import F2600 +from SolixBLE.states import DisplayTimeout, LightStatus +from tests.const import NEGOTIATION_RESPONSES_SOLIX + +######################## +# Test device commands # +######################## + +# These tests are for sending commands to the device and making sure the +# correct calls are made to the command sending functions and errors are +# raised where appropriate. See test_send_command() in test_commands.py. + +F2600_TEST_COMMANDS = [ + pytest.param( + F2600, + "turn_ac_on", + [], + [("404a", "a10121a2020101")], + id="f2600_ac_on", + ), + pytest.param( + F2600, + "turn_ac_off", + [], + [("404a", "a10121a2020100")], + id="f2600_ac_off", + ), + pytest.param( + F2600, + "turn_dc_on", + [], + [("404b", "a10121a2020101")], + id="f2600_dc_on", + ), + pytest.param( + F2600, + "turn_dc_off", + [], + [("404b", "a10121a2020100")], + id="f2600_dc_off", + ), + pytest.param( + F2600, + "set_ac_timer", + [300], + [("4042", "a10121a205022c010000")], + id="f2600_ac_timer_5m", + ), + pytest.param( + F2600, + "set_dc_timer", + [300], + [("4043", "a10121a205022c010000")], + id="f2600_dc_timer_5m", + ), + pytest.param( + F2600, + "set_ac_timer", + [10], + [("4042", "a10121a205020a000000")], + id="f2600_ac_timer_10s", + ), + pytest.param( + F2600, + "set_dc_timer", + [10], + [("4043", "a10121a205020a000000")], + id="f2600_dc_timer_10s", + ), + pytest.param( + F2600, + "set_light_mode", + [LightStatus.LOW], + [("404f", "a10121a2020101")], + id="f2600_light_low", + ), + pytest.param( + F2600, + "set_light_mode", + [LightStatus.MEDIUM], + [("404f", "a10121a2020102")], + id="f2600_light_med", + ), + pytest.param( + F2600, + "set_light_mode", + [LightStatus.HIGH], + [("404f", "a10121a2020103")], + id="f2600_light_high", + ), + pytest.param( + F2600, + "set_light_mode", + [LightStatus.SOS], + [("404f", "a10121a2020104")], + id="f2600_light_sos", + ), + pytest.param( + F2600, + "set_light_mode", + [LightStatus.UNKNOWN], + ValueError, + id="f2600_light_unknown", + ), + pytest.param( + F2600, + "set_display_mode", + [LightStatus.LOW], + [("404c", "a10121a2020101")], + id="f2600_display_low", + ), + pytest.param( + F2600, + "set_display_mode", + [LightStatus.MEDIUM], + [("404c", "a10121a2020102")], + id="f2600_display_med", + ), + pytest.param( + F2600, + "set_display_mode", + [LightStatus.HIGH], + [("404c", "a10121a2020103")], + id="f2600_display_high", + ), + pytest.param( + F2600, + "set_display_mode", + [LightStatus.SOS], + ValueError, + id="f2600_display_sos", + ), + pytest.param( + F2600, + "set_display_mode", + [LightStatus.UNKNOWN], + ValueError, + id="f2600_display_unknown", + ), + pytest.param( + F2600, + "set_display_timeout", + [DisplayTimeout.S20], + [("4046", "a10121a203021400")], + id="f2600_display_timeout_20s", + ), + pytest.param( + F2600, + "set_display_timeout", + [DisplayTimeout.S1800], + [("4046", "a10121a203020807")], + id="f2600_display_timeout_30m", + ), + pytest.param( + F2600, + "set_display_timeout", + [DisplayTimeout.UNKNOWN], + ValueError, + id="f2600_display_timeout_unknown", + ), + pytest.param( + F2600, + "turn_display_on", + [], + [("4052", "a10121a2020101")], + id="f2600_display_on", + ), + pytest.param( + F2600, + "turn_display_off", + [], + [("4052", "a10121a2020100")], + id="f2600_display_off", + ), + pytest.param( + F2600, + "turn_power_saving_mode_on", + [], + [("404e", "a10121a2020101")], + id="f2600_power_saving_on", + ), + pytest.param( + F2600, + "turn_power_saving_mode_off", + [], + [("404e", "a10121a2020100")], + id="f2600_power_saving_off", + ), + pytest.param( + F2600, + "set_ac_charging_power", + [150], + [("4044", "a10121a203029600")], + id="f2600_ac_charge_150w", + ), + pytest.param( + F2600, + "set_ac_charging_power", + [700], + [("4044", "a10121a20302bc02")], + id="f2600_ac_charge_700w", + ), + pytest.param( + F2600, + "set_ac_charging_power", + [50], + ValueError, + id="f2600_ac_charge_50w", + ), + pytest.param( + F2600, + "set_ac_charging_power", + [1500], + ValueError, + id="f2600_ac_charge_1500w", + ), +] + + +#################################### +# Test device commands & responses # +#################################### + +# These tests are for sending commands to the device and making sure the correct +# calls are made to the command sending functions, that the response is handled +# appropriately, the correct value is returned, and errors are raised where +# appropriate. See test_send_command_response() in test_commands.py. + + +F2600_TEST_COMMANDS_RESPONSES = [ + pytest.param( + F2600, + "get_status_update", + [], + [("4040", "a10121")], + [("03010f", "c840", "00a10131a2050300000000a3050300000000a403020900a50302a405a603021801a703020000a803020000a903020000aa03020000ab03020000ac03020000ad03020000ae03020000af0302a405b003021801b103020000b203020000b303025a01b403022e01b503027400b603026c00b703020000b803027500b903020000ba03025a01bb03020100bc020102bd020122be020100bf020102c0020100c1020140c2020100c3020164c4020100c5020100c6020100c7020100c8020100c9020100ca020100cb020100cc020100cd020100ce020100cf020100d01100415a56334e4d30463038373030343131d10302a005d203020000d303021400d403023c00d503020000d603020000d7020101d8020100d9020103da02013cdb020100dc020100dd020101de020100f815040000000001000000000000000000000000000000fd0a0041313738315f354168fe0503372b136a")], # noqa: E501 + {'a1': Container(key=b'\xa1', length=1, type=None, value=b'1'), 'a2': Container(key=b'\xa2', length=5, type=3, value=b'\x00\x00\x00\x00'), 'a3': Container(key=b'\xa3', length=5, type=3, value=b'\x00\x00\x00\x00'), 'a4': Container(key=b'\xa4', length=3, type=2, value=b'\t\x00'), 'a5': Container(key=b'\xa5', length=3, type=2, value=b'\xa4\x05'), 'a6': Container(key=b'\xa6', length=3, type=2, value=b'\x18\x01'), 'a7': Container(key=b'\xa7', length=3, type=2, value=b'\x00\x00'), 'a8': Container(key=b'\xa8', length=3, type=2, value=b'\x00\x00'), 'a9': Container(key=b'\xa9', length=3, type=2, value=b'\x00\x00'), 'aa': Container(key=b'\xaa', length=3, type=2, value=b'\x00\x00'), 'ab': Container(key=b'\xab', length=3, type=2, value=b'\x00\x00'), 'ac': Container(key=b'\xac', length=3, type=2, value=b'\x00\x00'), 'ad': Container(key=b'\xad', length=3, type=2, value=b'\x00\x00'), 'ae': Container(key=b'\xae', length=3, type=2, value=b'\x00\x00'), 'af': Container(key=b'\xaf', length=3, type=2, value=b'\xa4\x05'), 'b0': Container(key=b'\xb0', length=3, type=2, value=b'\x18\x01'), 'b1': Container(key=b'\xb1', length=3, type=2, value=b'\x00\x00'), 'b2': Container(key=b'\xb2', length=3, type=2, value=b'\x00\x00'), 'b3': Container(key=b'\xb3', length=3, type=2, value=b'Z\x01'), 'b4': Container(key=b'\xb4', length=3, type=2, value=b'.\x01'), 'b5': Container(key=b'\xb5', length=3, type=2, value=b't\x00'), 'b6': Container(key=b'\xb6', length=3, type=2, value=b'l\x00'), 'b7': Container(key=b'\xb7', length=3, type=2, value=b'\x00\x00'), 'b8': Container(key=b'\xb8', length=3, type=2, value=b'u\x00'), 'b9': Container(key=b'\xb9', length=3, type=2, value=b'\x00\x00'), 'ba': Container(key=b'\xba', length=3, type=2, value=b'Z\x01'), 'bb': Container(key=b'\xbb', length=3, type=2, value=b'\x01\x00'), 'bc': Container(key=b'\xbc', length=2, type=1, value=b'\x02'), 'bd': Container(key=b'\xbd', length=2, type=1, value=b'"'), 'be': Container(key=b'\xbe', length=2, type=1, value=b'\x00'), 'bf': Container(key=b'\xbf', length=2, type=1, value=b'\x02'), 'c0': Container(key=b'\xc0', length=2, type=1, value=b'\x00'), 'c1': Container(key=b'\xc1', length=2, type=1, value=b'@'), 'c2': Container(key=b'\xc2', length=2, type=1, value=b'\x00'), 'c3': Container(key=b'\xc3', length=2, type=1, value=b'd'), 'c4': Container(key=b'\xc4', length=2, type=1, value=b'\x00'), 'c5': Container(key=b'\xc5', length=2, type=1, value=b'\x00'), 'c6': Container(key=b'\xc6', length=2, type=1, value=b'\x00'), 'c7': Container(key=b'\xc7', length=2, type=1, value=b'\x00'), 'c8': Container(key=b'\xc8', length=2, type=1, value=b'\x00'), 'c9': Container(key=b'\xc9', length=2, type=1, value=b'\x00'), 'ca': Container(key=b'\xca', length=2, type=1, value=b'\x00'), 'cb': Container(key=b'\xcb', length=2, type=1, value=b'\x00'), 'cc': Container(key=b'\xcc', length=2, type=1, value=b'\x00'), 'cd': Container(key=b'\xcd', length=2, type=1, value=b'\x00'), 'ce': Container(key=b'\xce', length=2, type=1, value=b'\x00'), 'cf': Container(key=b'\xcf', length=2, type=1, value=b'\x00'), 'd0': Container(key=b'\xd0', length=17, type=0, value=b'AZV3NM0F08700411'), 'd1': Container(key=b'\xd1', length=3, type=2, value=b'\xa0\x05'), 'd2': Container(key=b'\xd2', length=3, type=2, value=b'\x00\x00'), 'd3': Container(key=b'\xd3', length=3, type=2, value=b'\x14\x00'), 'd4': Container(key=b'\xd4', length=3, type=2, value=b'<\x00'), 'd5': Container(key=b'\xd5', length=3, type=2, value=b'\x00\x00'), 'd6': Container(key=b'\xd6', length=3, type=2, value=b'\x00\x00'), 'd7': Container(key=b'\xd7', length=2, type=1, value=b'\x01'), 'd8': Container(key=b'\xd8', length=2, type=1, value=b'\x00'), 'd9': Container(key=b'\xd9', length=2, type=1, value=b'\x03'), 'da': Container(key=b'\xda', length=2, type=1, value=b'<'), 'db': Container(key=b'\xdb', length=2, type=1, value=b'\x00'), 'dc': Container(key=b'\xdc', length=2, type=1, value=b'\x00'), 'dd': Container(key=b'\xdd', length=2, type=1, value=b'\x01'), 'de': Container(key=b'\xde', length=2, type=1, value=b'\x00'), 'f8': Container(key=b'\xf8', length=21, type=4, value=b'\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'), 'fd': Container(key=b'\xfd', length=10, type=0, value=b'A1781_5Ah'), 'fe': Container(key=b'\xfe', length=5, type=3, value=b'7+\x13j')}, # noqa: E501 + id="f2600_status_update", + ), + pytest.param( + F2600, + "get_status_update", + [], + [("4040", "a10121")], + [("03010f", "c840", None)], + TimeoutError, + id="f2600_status_update_error", + ), +] + + +############################ +# Test device commands E2E # +############################ + +# These tests end-to-end tests check that the correct bytes are sent +# by the command. See test_send_command_e2e() in test_commands.py. + +F2600_TEST_COMMANDS_E2E = [ + pytest.param( + F2600, + NEGOTIATION_RESPONSES_SOLIX, + "turn_ac_on", + [], + "ff091a0003000f404acf1b676bb8c648a6f066b90d0c2025028b", + id="f2600_ac_on", + ), + pytest.param( + F2600, + NEGOTIATION_RESPONSES_SOLIX, + "turn_ac_off", + [], + "ff091a0003000f404aa665f0bcc4f9a3a154d50bb71d7c300e38", + id="f2600_ac_off", + ), + pytest.param( + F2600, + NEGOTIATION_RESPONSES_SOLIX, + "turn_dc_on", + [], + "ff091a0003000f404bcf1b676bb8c648a6f066b90d0c2025028a", + id="f2600_dc_on", + ), + pytest.param( + F2600, + NEGOTIATION_RESPONSES_SOLIX, + "turn_dc_off", + [], + "ff091a0003000f404ba665f0bcc4f9a3a154d50bb71d7c300e39", + id="f2600_dc_off", + ), + pytest.param( + F2600, + NEGOTIATION_RESPONSES_SOLIX, + "set_ac_timer", + [300], + "ff092a0003000f4042396047ce2148c486a0a797e65b37d310fc0ba06f11c351de824b814dfe516aaaff", + id="f2600_ac_timer_5m", + ), + pytest.param( + F2600, + NEGOTIATION_RESPONSES_SOLIX, + "set_dc_timer", + [300], + "ff092a0003000f4043396047ce2148c486a0a797e65b37d310fc0ba06f11c351de824b814dfe516aaafe", + id="f2600_dc_timer_5m", + ), + pytest.param( + F2600, + NEGOTIATION_RESPONSES_SOLIX, + "set_ac_timer", + [10], + "ff092a0003000f40424e9bd8a15edbf3bf768a607175daf29210060037a24c580ab066d23e0cdaa73e7d", + id="f2600_ac_timer_10s", + ), + pytest.param( + F2600, + NEGOTIATION_RESPONSES_SOLIX, + "set_dc_timer", + [10], + "ff092a0003000f40434e9bd8a15edbf3bf768a607175daf29210060037a24c580ab066d23e0cdaa73e7c", + id="f2600_dc_timer_10s", + ), + pytest.param( + F2600, + NEGOTIATION_RESPONSES_SOLIX, + "set_light_mode", + [LightStatus.LOW], + "ff091a0003000f404fcf1b676bb8c648a6f066b90d0c2025028e", + id="f2600_light_low", + ), + pytest.param( + F2600, + NEGOTIATION_RESPONSES_SOLIX, + "set_light_mode", + [LightStatus.MEDIUM], + "ff091a0003000f404f78e6e204ae7a3858b1aac611fd4bdec146", + id="f2600_light_med", + ), + pytest.param( + F2600, + NEGOTIATION_RESPONSES_SOLIX, + "set_light_mode", + [LightStatus.HIGH], + "ff091a0003000f404f3fa145b4757507f18b3503e0cc3bcae3f5", + id="f2600_light_high", + ), + pytest.param( + F2600, + NEGOTIATION_RESPONSES_SOLIX, + "set_light_mode", + [LightStatus.SOS], + "ff091a0003000f404f2c28e49e5cd5ed57b9749702b802f3fb48", + id="f2600_light_sos", + ), + pytest.param( + F2600, + NEGOTIATION_RESPONSES_SOLIX, + "set_display_mode", + [LightStatus.LOW], + "ff091a0003000f404ccf1b676bb8c648a6f066b90d0c2025028d", + id="f2600_display_low", + ), + pytest.param( + F2600, + NEGOTIATION_RESPONSES_SOLIX, + "set_display_mode", + [LightStatus.MEDIUM], + "ff091a0003000f404c78e6e204ae7a3858b1aac611fd4bdec145", + id="f2600_display_med", + ), + pytest.param( + F2600, + NEGOTIATION_RESPONSES_SOLIX, + "set_display_mode", + [LightStatus.HIGH], + "ff091a0003000f404c3fa145b4757507f18b3503e0cc3bcae3f6", + id="f2600_display_high", + ), + pytest.param( + F2600, + NEGOTIATION_RESPONSES_SOLIX, + "set_display_timeout", + [DisplayTimeout.S20], + "ff091a0003000f4046def18b6e3fa7434937ef01fecb95dfd3cb", + id="f2600_display_timeout_20s", + ), + pytest.param( + F2600, + NEGOTIATION_RESPONSES_SOLIX, + "set_display_timeout", + [DisplayTimeout.S1800], + "ff091a0003000f404665b9a755e0b46d3947a6937b5f7be4d2d3", + id="f2600_display_timeout_30m", + ), + pytest.param( + F2600, + NEGOTIATION_RESPONSES_SOLIX, + "turn_display_on", + [], + "ff091a0003000f4052cf1b676bb8c648a6f066b90d0c20250293", + id="f2600_display_on", + ), + pytest.param( + F2600, + NEGOTIATION_RESPONSES_SOLIX, + "turn_display_off", + [], + "ff091a0003000f4052a665f0bcc4f9a3a154d50bb71d7c300e20", + id="f2600_display_off", + ), + pytest.param( + F2600, + NEGOTIATION_RESPONSES_SOLIX, + "turn_power_saving_mode_on", + [], + "ff091a0003000f404ecf1b676bb8c648a6f066b90d0c2025028f", + id="f2600_power_saving_on", + ), + pytest.param( + F2600, + NEGOTIATION_RESPONSES_SOLIX, + "turn_power_saving_mode_off", + [], + "ff091a0003000f404ea665f0bcc4f9a3a154d50bb71d7c300e3c", + id="f2600_power_saving_off", + ), + pytest.param( + F2600, + NEGOTIATION_RESPONSES_SOLIX, + "set_ac_charging_power", + [150], + "ff091a0003000f40449f3e0c5587a55142942d2896d550e9f3b5", + id="f2600_ac_charge_150w", + ), + pytest.param( + F2600, + NEGOTIATION_RESPONSES_SOLIX, + "set_ac_charging_power", + [700], + "ff091a0003000f4044145d777bfee71fbe496c9c7e8611c320aa", + id="f2600_ac_charge_700w", + ), +] diff --git a/tests/devices/f3800.py b/tests/devices/f3800.py new file mode 100644 index 0000000..537299b --- /dev/null +++ b/tests/devices/f3800.py @@ -0,0 +1,91 @@ +"""F3800 power station device tests. + +.. moduleauthor:: Harvey Lelliott (flip-dots) + +""" +import pytest + +from SolixBLE.devices.f3800 import F3800 +from tests.const import NEGOTIATION_RESPONSES_SOLIX + +######################## +# Test device commands # +######################## + +# These tests are for sending commands to the device and making sure the +# correct calls are made to the command sending functions and errors are +# raised where appropriate. See test_send_command() in test_commands.py. + +F3800_TEST_COMMANDS = [ + pytest.param( + F3800, + "turn_ac_on", + [], + [("404a", "a10121a2020101")], + id="f3800_ac_on", + ), + pytest.param( + F3800, + "turn_ac_off", + [], + [("404a", "a10121a2020100")], + id="f3800_ac_off", + ), + pytest.param( + F3800, + "turn_dc_on", + [], + [("404b", "a10121a2020101")], + id="f3800_dc_on", + ), + pytest.param( + F3800, + "turn_dc_off", + [], + [("404b", "a10121a2020100")], + id="f3800_dc_off", + ), +] + + +############################ +# Test device commands E2E # +############################ + +# These tests end-to-end tests check that the correct bytes are sent +# by the command. See test_send_command_e2e() in test_commands.py. + +F3800_TEST_COMMANDS_E2E = [ + pytest.param( + F3800, + NEGOTIATION_RESPONSES_SOLIX, + "turn_ac_on", + [], + "ff091a0003000f404acf1b676bb8c648a6f066b90d0c2025028b", + id="f3800_ac_on", + ), + pytest.param( + F3800, + NEGOTIATION_RESPONSES_SOLIX, + "turn_ac_off", + [], + "ff091a0003000f404aa665f0bcc4f9a3a154d50bb71d7c300e38", + id="f3800_ac_off", + ), + pytest.param( + F3800, + NEGOTIATION_RESPONSES_SOLIX, + "turn_dc_on", + [], + "ff091a0003000f404bcf1b676bb8c648a6f066b90d0c2025028a", + id="f3800_dc_on", + ), + pytest.param( + F3800, + NEGOTIATION_RESPONSES_SOLIX, + "turn_dc_off", + [], + "ff091a0003000f404ba665f0bcc4f9a3a154d50bb71d7c300e39", + id="f3800_dc_off", + ), +] diff --git a/tests/devices/prime_160w_charger.py b/tests/devices/prime_160w_charger.py new file mode 100644 index 0000000..638d85e --- /dev/null +++ b/tests/devices/prime_160w_charger.py @@ -0,0 +1,211 @@ +"""Anker Prime 160w charger tests. + +.. moduleauthor:: Harvey Lelliott (flip-dots) + +""" +import pytest + +from SolixBLE.devices.prime_charger_160w import PrimeCharger160w +from tests.const import NEGOTIATION_RESPONSES_PRIME + +######################## +# Test device commands # +######################## + +# These tests are for sending commands to the device and making sure the +# correct calls are made to the command sending functions and errors are +# raised where appropriate. See test_send_command() in test_commands.py. + +PRIME_CHARGER_160W_TEST_COMMANDS = [ + pytest.param( + PrimeCharger160w, + "turn_usb_c1_on", + [], + [("4207", "a10121a2020100a3020101")], + id="prime_charger_160w_usb_c1_on", + ), + pytest.param( + PrimeCharger160w, + "turn_usb_c1_off", + [], + [("4207", "a10121a2020100a3020100")], + id="prime_charger_160w_usb_c1_off", + ), + pytest.param( + PrimeCharger160w, + "set_timer_usb_c1", + [300], + [("4209", "a10121a2020100a305042c010000")], + id="prime_charger_160w_usb_c1_timer_5m", + ), + pytest.param( + PrimeCharger160w, + "set_timer_usb_c1", + [7200], + [("4209", "a10121a2020100a30504201c0000")], + id="prime_charger_160w_usb_c1_timer_120m", + ), + pytest.param( + PrimeCharger160w, + "turn_usb_c2_on", + [], + [("4207", "a10121a2020101a3020101")], + id="prime_charger_160w_usb_c2_on", + ), + pytest.param( + PrimeCharger160w, + "turn_usb_c2_off", + [], + [("4207", "a10121a2020101a3020100")], + id="prime_charger_160w_usb_c2_off", + ), + pytest.param( + PrimeCharger160w, + "set_timer_usb_c2", + [300], + [("4209", "a10121a2020101a305042c010000")], + id="prime_charger_160w_usb_c2_timer_5m", + ), + pytest.param( + PrimeCharger160w, + "set_timer_usb_c2", + [7200], + [("4209", "a10121a2020101a30504201c0000")], + id="prime_charger_160w_usb_c2_timer_120m", + ), + pytest.param( + PrimeCharger160w, + "turn_usb_c3_on", + [], + [("4207", "a10121a2020102a3020101")], + id="prime_charger_160w_usb_c3_on", + ), + pytest.param( + PrimeCharger160w, + "turn_usb_c3_off", + [], + [("4207", "a10121a2020102a3020100")], + id="prime_charger_160w_usb_c3_off", + ), + pytest.param( + PrimeCharger160w, + "set_timer_usb_c3", + [300], + [("4209", "a10121a2020102a305042c010000")], + id="prime_charger_160w_usb_c3_timer_5m", + ), + pytest.param( + PrimeCharger160w, + "set_timer_usb_c3", + [7200], + [("4209", "a10121a2020102a30504201c0000")], + id="prime_charger_160w_usb_c3_timer_120m", + ), +] + + +############################ +# Test device commands E2E # +############################ + +# These tests end-to-end tests check that the correct bytes are sent +# by the command. See test_send_command_e2e() in test_commands.py. + +PRIME_CHARGER_160W_TEST_COMMANDS_E2E = [ + pytest.param( + PrimeCharger160w, + NEGOTIATION_RESPONSES_PRIME, + "turn_usb_c1_on", + [], + "ff092b0003000f420757e9b883d85da36ffa59e144a5881d8773e6bacd6c24e0484da6030bc35f27c50771", + id="prime_charger_160w_usb_c1_on", + ), + pytest.param( + PrimeCharger160w, + NEGOTIATION_RESPONSES_PRIME, + "turn_usb_c1_off", + [], + "ff092b0003000f420757e9b883d85da36ffa59e044a5881d8773eea0d2dbe21151b3eae6b5fa935c38ed94", + id="prime_charger_160w_usb_c1_off", + ), + pytest.param( + PrimeCharger160w, + NEGOTIATION_RESPONSES_PRIME, + "set_timer_usb_c1", + [300], + "ff092e0003000f420957e9b883d85da36ffd5cccbba1679a36f5672fff283580d22c655e1542fe96137072a1c7bd", + id="prime_charger_160w_usb_c1_timer_5m", + ), + pytest.param( + PrimeCharger160w, + NEGOTIATION_RESPONSES_PRIME, + "set_timer_usb_c1", + [7200], + "ff092e0003000f420957e9b883d85da36ffd5cc0a6a1679a36f5672fff8e8000d4aaac5a3007939a6eae1b4d0496", + id="prime_charger_160w_usb_c1_timer_120m", + ), + pytest.param( + PrimeCharger160w, + NEGOTIATION_RESPONSES_PRIME, + "turn_usb_c2_on", + [], + "ff092b0003000f420757e9b883d85da26ffa59e144a5881d8773304bd4926805f6746a78f6295290e98f20", + id="prime_charger_160w_usb_c2_on", + ), + pytest.param( + PrimeCharger160w, + NEGOTIATION_RESPONSES_PRIME, + "turn_usb_c2_off", + [], + "ff092b0003000f420757e9b883d85da26ffa59e044a5881d87733851cb25aef4ef8a269d48109eeb1465c5", + id="prime_charger_160w_usb_c2_off", + ), + pytest.param( + PrimeCharger160w, + NEGOTIATION_RESPONSES_PRIME, + "set_timer_usb_c2", + [300], + "ff092e0003000f420957e9b883d85da26ffd5cccbba1679a36f5672ffffec4992c6080e02c8e856bf97dc58d4fec", + id="prime_charger_160w_usb_c2_timer_5m", + ), + pytest.param( + PrimeCharger160w, + NEGOTIATION_RESPONSES_PRIME, + "set_timer_usb_c2", + [7200], + "ff092e0003000f420957e9b883d85da26ffd5cc0a6a1679a36f5672fff5871192ae649e409cbe86784a3ac618cc7", + id="prime_charger_160w_usb_c2_timer_120m", + ), + pytest.param( + PrimeCharger160w, + NEGOTIATION_RESPONSES_PRIME, + "turn_usb_c3_on", + [], + "ff092b0003000f420757e9b883d85da16ffa59e144a5881d87738958fe90bd2b343e3ef4f01744499c1611", + id="prime_charger_160w_usb_c3_on", + ), + pytest.param( + PrimeCharger160w, + NEGOTIATION_RESPONSES_PRIME, + "turn_usb_c3_off", + [], + "ff092b0003000f420757e9b883d85da16ffa59e044a5881d87738142e1277bda2dc072114e2e883261fcf4", + id="prime_charger_160w_usb_c3_off", + ), + pytest.param( + PrimeCharger160w, + NEGOTIATION_RESPONSES_PRIME, + "set_timer_usb_c3", + [300], + "ff092e0003000f420957e9b883d85da16ffd5cccbba1679a36f5672fff47d7b32eb5ae2266da096dc76b1cf8d6dd", + id="prime_charger_160w_usb_c3_timer_5m", + ), + pytest.param( + PrimeCharger160w, + NEGOTIATION_RESPONSES_PRIME, + "set_timer_usb_c3", + [7200], + "ff092e0003000f420957e9b883d85da16ffd5cc0a6a1679a36f5672fffe1623328336726439f6461bab5751415f6", + id="prime_charger_160w_usb_c3_timer_120m", + ), +] diff --git a/tests/devices/prime_250w_charger.py b/tests/devices/prime_250w_charger.py new file mode 100644 index 0000000..a95b131 --- /dev/null +++ b/tests/devices/prime_250w_charger.py @@ -0,0 +1,331 @@ +"""Anker Prime 250w charger tests. + +.. moduleauthor:: Harvey Lelliott (flip-dots) + +""" +import pytest + +from SolixBLE.devices.prime_charger_250w import PrimeCharger250w +from tests.const import NEGOTIATION_RESPONSES_PRIME + +######################## +# Test device commands # +######################## + +# These tests are for sending commands to the device and making sure the +# correct calls are made to the command sending functions and errors are +# raised where appropriate. See test_send_command() in test_commands.py. + +PRIME_CHARGER_250W_TEST_COMMANDS = [ + pytest.param( + PrimeCharger250w, + "turn_usb_c1_on", + [], + [("4207", "a10121a2020100a3020101")], + id="prime_charger_250w_usb_c1_on", + ), + pytest.param( + PrimeCharger250w, + "turn_usb_c1_off", + [], + [("4207", "a10121a2020100a3020100")], + id="prime_charger_250w_usb_c1_off", + ), + pytest.param( + PrimeCharger250w, + "set_timer_usb_c1", + [300], + [("4209", "a10121a2020100a306042c01000000")], + id="prime_charger_250w_usb_c1_timer_5m", + ), + pytest.param( + PrimeCharger250w, + "set_timer_usb_c1", + [7200], + [("4209", "a10121a2020100a30604201c000000")], + id="prime_charger_250w_usb_c1_timer_120m", + ), + pytest.param( + PrimeCharger250w, + "turn_usb_c2_on", + [], + [("4207", "a10121a2020101a3020101")], + id="prime_charger_250w_usb_c2_on", + ), + pytest.param( + PrimeCharger250w, + "turn_usb_c2_off", + [], + [("4207", "a10121a2020101a3020100")], + id="prime_charger_250w_usb_c2_off", + ), + pytest.param( + PrimeCharger250w, + "set_timer_usb_c2", + [300], + [("4209", "a10121a2020101a306042c01000000")], + id="prime_charger_250w_usb_c2_timer_5m", + ), + pytest.param( + PrimeCharger250w, + "set_timer_usb_c2", + [7200], + [("4209", "a10121a2020101a30604201c000000")], + id="prime_charger_250w_usb_c2_timer_120m", + ), + pytest.param( + PrimeCharger250w, + "turn_usb_c3_on", + [], + [("4207", "a10121a2020102a3020101")], + id="prime_charger_250w_usb_c3_on", + ), + pytest.param( + PrimeCharger250w, + "turn_usb_c3_off", + [], + [("4207", "a10121a2020102a3020100")], + id="prime_charger_250w_usb_c3_off", + ), + pytest.param( + PrimeCharger250w, + "set_timer_usb_c3", + [300], + [("4209", "a10121a2020102a306042c01000000")], + id="prime_charger_250w_usb_c3_timer_5m", + ), + pytest.param( + PrimeCharger250w, + "set_timer_usb_c3", + [7200], + [("4209", "a10121a2020102a30604201c000000")], + id="prime_charger_250w_usb_c3_timer_120m", + ), + pytest.param( + PrimeCharger250w, + "turn_usb_c4_on", + [], + [("4207", "a10121a2020103a3020101")], + id="prime_charger_250w_usb_c4_on", + ), + pytest.param( + PrimeCharger250w, + "turn_usb_c4_off", + [], + [("4207", "a10121a2020103a3020100")], + id="prime_charger_250w_usb_c4_off", + ), + pytest.param( + PrimeCharger250w, + "set_timer_usb_c4", + [300], + [("4209", "a10121a2020103a306042c01000000")], + id="prime_charger_250w_usb_c4_timer_5m", + ), + pytest.param( + PrimeCharger250w, + "set_timer_usb_c4", + [7200], + [("4209", "a10121a2020103a30604201c000000")], + id="prime_charger_250w_usb_c4_timer_120m", + ), + pytest.param( + PrimeCharger250w, + "turn_usb_a1_a2_on", + [], + [("4207", "a10121a2020104a3020101")], + id="prime_charger_250w_usb_a1_a2_on", + ), + pytest.param( + PrimeCharger250w, + "turn_usb_a1_a2_off", + [], + [("4207", "a10121a2020104a3020100")], + id="prime_charger_250w_usb_a1_a2_off", + ), + pytest.param( + PrimeCharger250w, + "set_timer_usb_a1_a2", + [300], + [("4209", "a10121a2020104a306042c01000000")], + id="prime_charger_250w_usb_a1_a2_timer_5m", + ), + pytest.param( + PrimeCharger250w, + "set_timer_usb_a1_a2", + [7200], + [("4209", "a10121a2020104a30604201c000000")], + id="prime_charger_250w_usb_a1_a2_timer_120m", + ), +] + + +############################ +# Test device commands E2E # +############################ + +# These tests end-to-end tests check that the correct bytes are sent +# by the command. See test_send_command_e2e() in test_commands.py. + +PRIME_CHARGER_250W_TEST_COMMANDS_E2E = [ + pytest.param( + PrimeCharger250w, + NEGOTIATION_RESPONSES_PRIME, + "turn_usb_c1_on", + [], + "ff092b0003000f420757e9b883d85da36ffa59e144a5881d8773e6bacd6c24e0484da6030bc35f27c50771", + id="prime_charger_250w_usb_c1_on", + ), + pytest.param( + PrimeCharger250w, + NEGOTIATION_RESPONSES_PRIME, + "turn_usb_c1_off", + [], + "ff092b0003000f420757e9b883d85da36ffa59e044a5881d8773eea0d2dbe21151b3eae6b5fa935c38ed94", + id="prime_charger_250w_usb_c1_off", + ), + pytest.param( + PrimeCharger250w, + NEGOTIATION_RESPONSES_PRIME, + "set_timer_usb_c1", + [300], + "ff092f0003000f420957e9b883d85da36ffe5cccbba16764cc1ef1e323304e7635b22b4abb99ae19c243af2bf10a43", + id="prime_charger_250w_usb_c1_timer_5m", + ), + pytest.param( + PrimeCharger250w, + NEGOTIATION_RESPONSES_PRIME, + "set_timer_usb_c1", + [7200], + "ff092f0003000f420957e9b883d85da36ffe5cc0a6a16764cc1ef1e32330e8c3b5b4ad83bfbceb74ce3e71421dc968", + id="prime_charger_250w_usb_c1_timer_120m", + ), + pytest.param( + PrimeCharger250w, + NEGOTIATION_RESPONSES_PRIME, + "turn_usb_c2_on", + [], + "ff092b0003000f420757e9b883d85da26ffa59e144a5881d8773304bd4926805f6746a78f6295290e98f20", + id="prime_charger_250w_usb_c2_on", + ), + pytest.param( + PrimeCharger250w, + NEGOTIATION_RESPONSES_PRIME, + "turn_usb_c2_off", + [], + "ff092b0003000f420757e9b883d85da26ffa59e044a5881d87733851cb25aef4ef8a269d48109eeb1465c5", + id="prime_charger_250w_usb_c2_off", + ), + pytest.param( + PrimeCharger250w, + NEGOTIATION_RESPONSES_PRIME, + "set_timer_usb_c2", + [300], + "ff092f0003000f420957e9b883d85da26ffe5cccbba16764cc1ef1e3233098872c4c67af05a062623fa9a29cdd8212", + id="prime_charger_250w_usb_c2_timer_5m", + ), + pytest.param( + PrimeCharger250w, + NEGOTIATION_RESPONSES_PRIME, + "set_timer_usb_c2", + [7200], + "ff092f0003000f420957e9b883d85da26ffe5cc0a6a16764cc1ef1e323303e32ac4ae1660185270f33d47cf5314139", + id="prime_charger_250w_usb_c2_timer_120m", + ), + pytest.param( + PrimeCharger250w, + NEGOTIATION_RESPONSES_PRIME, + "turn_usb_c3_on", + [], + "ff092b0003000f420757e9b883d85da16ffa59e144a5881d87738958fe90bd2b343e3ef4f01744499c1611", + id="prime_charger_250w_usb_c3_on", + ), + pytest.param( + PrimeCharger250w, + NEGOTIATION_RESPONSES_PRIME, + "turn_usb_c3_off", + [], + "ff092b0003000f420757e9b883d85da16ffa59e044a5881d87738142e1277bda2dc072114e2e883261fcf4", + id="prime_charger_250w_usb_c3_off", + ), + pytest.param( + PrimeCharger250w, + NEGOTIATION_RESPONSES_PRIME, + "set_timer_usb_c3", + [300], + "ff092f0003000f420957e9b883d85da16ffe5cccbba16764cc1ef1e323302194064eb281c7ea36ee3997b445a81b23", + id="prime_charger_250w_usb_c3_timer_5m", + ), + pytest.param( + PrimeCharger250w, + NEGOTIATION_RESPONSES_PRIME, + "set_timer_usb_c3", + [7200], + "ff092f0003000f420957e9b883d85da16ffe5cc0a6a16764cc1ef1e32330872186483448c3cf738335ea6a2c44d808", + id="prime_charger_250w_usb_c3_timer_120m", + ), + pytest.param( + PrimeCharger250w, + NEGOTIATION_RESPONSES_PRIME, + "turn_usb_c4_on", + [], + "ff092b0003000f420757e9b883d85da06ffa59e144a5881d87735fa9e76ef1ce8a07f28f0dfd49feb09e40", + id="prime_charger_250w_usb_c4_on", + ), + pytest.param( + PrimeCharger250w, + NEGOTIATION_RESPONSES_PRIME, + "turn_usb_c4_off", + [], + "ff092b0003000f420757e9b883d85da06ffa59e044a5881d877357b3f8d9373f93f9be6ab3c485854d74a5", + id="prime_charger_250w_usb_c4_off", + ), + pytest.param( + PrimeCharger250w, + NEGOTIATION_RESPONSES_PRIME, + "set_timer_usb_c4", + [300], + "ff092f0003000f420957e9b883d85da06ffe5cccbba16764cc1ef1e32330f7651fb0fe6479d3fa95c47db9f2849372", + id="prime_charger_250w_usb_c4_timer_5m", + ), + pytest.param( + PrimeCharger250w, + NEGOTIATION_RESPONSES_PRIME, + "set_timer_usb_c4", + [7200], + "ff092f0003000f420957e9b883d85da06ffe5cc0a6a16764cc1ef1e3233051d09fb678ad7df6bff8c800679b685059", + id="prime_charger_250w_usb_c4_timer_120m", + ), + pytest.param( + PrimeCharger250w, + NEGOTIATION_RESPONSES_PRIME, + "turn_usb_a1_a2_on", + [], + "ff092b0003000f420757e9b883d85da76ffa59e144a5881d8773397eaa951776b0aa97ecfc6b69fb7725b1", + id="prime_charger_250w_usb_a1_a2_on", + ), + pytest.param( + PrimeCharger250w, + NEGOTIATION_RESPONSES_PRIME, + "turn_usb_a1_a2_off", + [], + "ff092b0003000f420757e9b883d85da76ffa59e044a5881d87733164b522d187a954db094252a5808acf54", + id="prime_charger_250w_usb_a1_a2_off", + ), + pytest.param( + PrimeCharger250w, + NEGOTIATION_RESPONSES_PRIME, + "set_timer_usb_a1_a2", + [300], + "ff092f0003000f420957e9b883d85da76ffe5cccbba16764cc1ef1e3233091b2524b18dc437e9ff635eb99f7432883", + id="prime_charger_250w_usb_a1_a2_timer_5m", + ), + pytest.param( + PrimeCharger250w, + NEGOTIATION_RESPONSES_PRIME, + "set_timer_usb_a1_a2", + [7200], + "ff092f0003000f420957e9b883d85da76ffe5cc0a6a16764cc1ef1e323303707d24d9e15475bda9b3996479eafeba8", + id="prime_charger_250w_usb_a1_a2_timer_120m", + ), +] diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..205b6a6 --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,242 @@ +"""Tests for the execution of on-device commands. + +.. moduleauthor:: Harvey Lelliott (flip-dots) + +""" + +import asyncio +import time +from contextlib import nullcontext +from unittest import mock + +import pytest + +from SolixBLE.device import SolixBLEDevice +from SolixBLE.prime_device import PrimeDevice +from tests.const import MOCK_BLE_DEVICE +from tests.devices.c300 import ( + C300_TEST_COMMANDS, + C300_TEST_COMMANDS_E2E, + C300_TEST_COMMANDS_RESPONSES, +) +from tests.devices.c300dc import C300DC_TEST_COMMANDS, C300DC_TEST_COMMANDS_E2E +from tests.devices.c800 import ( + C800_TEST_COMMANDS, + C800_TEST_COMMANDS_E2E, + C800_TEST_COMMANDS_RESPONSES, +) +from tests.devices.c1000 import ( + C1000_TEST_COMMANDS, + C1000_TEST_COMMANDS_E2E, + C1000_TEST_COMMANDS_RESPONSES, +) +from tests.devices.c1000g2 import C1000G2_TEST_COMMANDS, C1000G2_TEST_COMMANDS_E2E +from tests.devices.f2600 import ( + F2600_TEST_COMMANDS, + F2600_TEST_COMMANDS_E2E, + F2600_TEST_COMMANDS_RESPONSES, +) +from tests.devices.f3800 import F3800_TEST_COMMANDS, F3800_TEST_COMMANDS_E2E +from tests.devices.prime_160w_charger import ( + PRIME_CHARGER_160W_TEST_COMMANDS, + PRIME_CHARGER_160W_TEST_COMMANDS_E2E, +) +from tests.devices.prime_250w_charger import ( + PRIME_CHARGER_250W_TEST_COMMANDS, + PRIME_CHARGER_250W_TEST_COMMANDS_E2E, +) +from tests.helpers import MockDevice + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("device_class", "function", "arguments", "expected"), + [ + *C300_TEST_COMMANDS, + *C300DC_TEST_COMMANDS, + *C800_TEST_COMMANDS, + *C1000_TEST_COMMANDS, + *C1000G2_TEST_COMMANDS, + *F2600_TEST_COMMANDS, + *F3800_TEST_COMMANDS, + *PRIME_CHARGER_160W_TEST_COMMANDS, + *PRIME_CHARGER_250W_TEST_COMMANDS, + ], +) +async def test_send_command( + fake_time, + device_class: type[SolixBLEDevice], + function: str, + arguments: list, + expected: Exception | list[(str, str)], +) -> None: + """ + Test that the correct build command is executed for a command or an error is raised. + + :param device_class: Class of device under test. + :param function: Function to be called. + :param arguments: Arguments to be given to function. + :param expected: Error or expected cmd and payload output. + """ + device = device_class(MOCK_BLE_DEVICE) + device._negotiation_timestamp = time.time() + device._client = mock.AsyncMock() + device._encrypt_payload = lambda x: x + with ( + mock.patch("SolixBLE.constructs.Packet.build") as mock_build, + mock.patch("SolixBLE.SolixBLEDevice.negotiated", return_value=True), + pytest.raises(expected) if isinstance(expected, type) else nullcontext(), + ): + + fn = getattr(device, function) + await fn(*arguments) + + # The send command function automatically adds a + # timestamp to the parameters which we need to account for + timestamp_bytes = (f"fe04{device._timestamp().hex()}" + if issubclass(device_class, PrimeDevice) + else f"fe0503{device._timestamp().hex()}" + ) + + for call in expected: + mock_build.assert_called_once_with({ + "pattern": bytes.fromhex("03000f"), + "cmd": bytes.fromhex(call[0]), + "payload_bytes": bytes.fromhex(call[1] + timestamp_bytes), + }) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("device_class", "function", "arguments", "expected", "listen", "returned"), + [ + *C300_TEST_COMMANDS_RESPONSES, + *C800_TEST_COMMANDS_RESPONSES, + *C1000_TEST_COMMANDS_RESPONSES, + *F2600_TEST_COMMANDS_RESPONSES, + ], +) +async def test_send_command_response( # noqa: PLR0913, PLR0917 + fake_time, # noqa: ANN001, ARG001 + device_class: type[SolixBLEDevice], + function: str, + arguments: list, + expected: list[(str, str)], + listen: list[str, str, str | None], + returned: dict | Exception | None, +) -> None: + """ + Test sending of commands and handling of response. + + Test that the expected command is sent to the mock device + and return a response and assert that the correct result + is returned by the function, if any. + + :param device_class: Class of device under test. + :param function: Function to be called. + :param arguments: Arguments to be given to function. + :param expected: Expected cmd and payload calls to _send_command. + :param listen: Result(s) of calling _listen_for_packet(pattern, cmd). + :param returned: Expected return value of the function. + """ + device = device_class(MOCK_BLE_DEVICE) + device._negotiation_timestamp = time.time() + device._client = mock.AsyncMock() + device._encrypt_payload = lambda x: x + + with ( + mock.patch("SolixBLE.constructs.Packet.build") as mock_build, + mock.patch("SolixBLE.SolixBLEDevice.negotiated", return_value=True), + mock.patch("SolixBLE.SolixBLEDevice._listen_for_packet") as mock_listen, + pytest.raises(returned) if isinstance(returned, type) else nullcontext(), + ): + mock_listen.side_effect = [bytes.fromhex(p[2] or "") for p in listen] + + fn = getattr(device, function) + result = await fn(*arguments) + assert result == returned + + # The send command function automatically adds a + # timestamp to the parameters which we need to account for + timestamp_bytes = (f"fe04{device._timestamp().hex()}" + if issubclass(device_class, PrimeDevice) + else f"fe0503{device._timestamp().hex()}" + ) + + for call in expected: + mock_build.assert_called_once_with({ + "pattern": bytes.fromhex("03000f"), + "cmd": bytes.fromhex(call[0]), + "payload_bytes": bytes.fromhex(call[1] + timestamp_bytes), + }) + + for call in listen: + mock_listen.assert_called_once_with( + bytes.fromhex(call[0]), + bytes.fromhex(call[1]), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("device_class", "negotiation", "function", "arguments", "expected"), + [ + *C300_TEST_COMMANDS_E2E, + *C300DC_TEST_COMMANDS_E2E, + *C800_TEST_COMMANDS_E2E, + *C1000_TEST_COMMANDS_E2E, + *C1000G2_TEST_COMMANDS_E2E, + *F2600_TEST_COMMANDS_E2E, + *F3800_TEST_COMMANDS_E2E, + *PRIME_CHARGER_160W_TEST_COMMANDS_E2E, + *PRIME_CHARGER_250W_TEST_COMMANDS_E2E, + ], +) +async def test_send_command_e2e( # noqa: PLR0913, PLR0917 + fake_time, # noqa: ANN001, ARG001 + fast_sleep, # noqa: ANN001, ARG001 + fast_timeouts, # noqa: ANN001, ARG001 + device_class: type[SolixBLEDevice], + negotiation: dict, + function: str, + arguments: list, + expected: str, +) -> None: + """ + Test that the expected command is sent to the mock device. + + :param device_class: Class of device under test. + :param negotiation: Negotiation requests and responses. + :param function: Function to be called. + :param arguments: Arguments to be given to function. + :param expected: Expected bytes sent to the device. + """ + + async def _keep_alive(*args: list, **kwargs: dict) -> None: # noqa: ARG001 + return None + + async with MockDevice() as mock_bluetooth: + + device = device_class(MOCK_BLE_DEVICE) + device._keep_alive = _keep_alive # noqa: SLF001 + + # We first expect a negotiation + for k, v in negotiation.items(): + mock_bluetooth.expect_ordered( + bytes.fromhex(k), + [bytes.fromhex(x) for x in v], + ) + + # 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() + + mock_bluetooth.expect_ordered(bytes.fromhex(expected)) + + fn = getattr(device, function) + await fn(*arguments) + + mock_bluetooth.check_assertions() diff --git a/tests/test_connection.py b/tests/test_connection.py index 9659679..157b653 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -19,18 +19,21 @@ @pytest.mark.asyncio @pytest.mark.parametrize( - "device_class,negotiation", + ("device_class", "negotiation"), [ pytest.param(C300, NEGOTIATION_RESPONSES_SOLIX, id="solix"), pytest.param(PrimeCharger160w, NEGOTIATION_RESPONSES_PRIME, id="prime"), ], ) async def test_automatic_retry( - fast_sleep, fast_timeouts, device_class: type[SolixBLEDevice], negotiation: dict -): + fake_time, # noqa: ANN001, ARG001 + fast_sleep, # noqa: ANN001, ARG001 + fast_timeouts, # noqa: ANN001, ARG001 + device_class: type[SolixBLEDevice], + negotiation: dict, +) -> None: """ - Test the automatic retrying of a lost connection when the - reconnection happens within the timeout. + Test automatic retry of lost connection within timeout period. This test expects the module to connect the the mock device and then the mock device drops the connection and we expect @@ -98,14 +101,14 @@ def my_callback(*args, **kwargs): ], ) async def test_automatic_retry_timeout( - fast_sleep, - fast_timeouts, + fake_time, # noqa: ANN001, ARG001 + fast_sleep, # noqa: ANN001, ARG001 + fast_timeouts, # noqa: ANN001, ARG001 device_class: type[SolixBLEDevice], negotiation: dict, -): +) -> None: """ - Test the automatic retrying of a lost connection when - the reconnection takes longer than the timeout. + Test automatic retry of lost connection outside of timeout period. This test expects the module to connect the the mock device and then the mock device drops the connection and we expect @@ -188,13 +191,16 @@ def my_callback(*args, **kwargs): ], ) async def test_disconnect( - fast_timeouts, - fast_sleep, + fake_time, # noqa: ANN001, ARG001 + fast_sleep, # noqa: ANN001, ARG001 + fast_timeouts, # noqa: ANN001, ARG001 device_class: type[SolixBLEDevice], negotiation: dict, -): +) -> None: """ - Test the mock device is disconnected and no automatic + Test disconnecting the device. + + We expect that the mock device is disconnected and no automatic reconnection attempts are executed when disconnect is called. We also expect no callbacks to be run and multiple calls diff --git a/tests/test_devices.py b/tests/test_devices.py index 89825d3..437ba26 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -8,7 +8,6 @@ import logging from datetime import datetime, timedelta from typing import Any -from unittest import mock import pytest @@ -20,7 +19,6 @@ C1000G2, F2600, ChargingStatus, - DisplayTimeout, LightStatus, MagGo3in1, PortOverload, @@ -33,7 +31,7 @@ SolixBLEDevice, TemperatureUnit, ) -from SolixBLE.devices.f3800 import F3800 +from SolixBLE.constructs import Parameters from SolixBLE.devices.solarbank2 import MaxLoadSB2 from SolixBLE.states import GridStatus, LightMode, SBPowerCutoff, SBUsageMode from tests.const import ( @@ -1022,7 +1020,7 @@ async def test_values( :param mapping: Mapping of class properties to their expected value. """ device = device_class(MOCK_BLE_DEVICE) - parameters = device._parse_payload(bytes.fromhex(payload)) + parameters = Parameters.parse(bytes.fromhex(payload)) await device._process_telemetry(parameters) for class_property, expected_value in mapping.items(): @@ -1043,7 +1041,7 @@ async def test_f2600_timers() -> None: """ device = F2600(MOCK_BLE_DEVICE) payload = "a20503100e0000a3050308070000a403022d00a50302b004a603020000a703020000a803020000a903020000aa03020000ab03020000ac03020000ad03020000ae03020000af0302b004b003020000b303026a00b903020000ba03026e00bb020100bc020102bd020119be020100bf020100c102013ec2020100c3020164c4020100c5020100c6020100c7020100c8020100c9020100ca020100cb020100cf020100d0110041313738314142434445464748323334d10302e803d303022c01d9020102db020101de020101" - await device._process_telemetry(device._parse_payload(bytes.fromhex(payload))) + await device._process_telemetry(Parameters.parse(bytes.fromhex(payload))) assert device.ac_timer_remaining == 3600 assert device.dc_timer_remaining == 1800 @@ -1058,361 +1056,6 @@ async def test_f2600_timers() -> None: assert timedelta(minutes=29) < dc_timer - now < timedelta(minutes=31) -@pytest.mark.asyncio -async def test_f2600_status_update(fast_sleep, fast_timeouts) -> None: - """ - Test that a status update response is reassembled and parsed. - - The F2600 splits its telemetry across two packets which get_status_update - waits for one at a time, so the second can only be delivered once the first - has been consumed. Both packets are a real capture of the same state as the - f2600_ac_charging case in test_values. - """ - packets = [ - "ff09fd0003010fc840121d0c33c131a989f42599468694c5ae12a4fefe22077259298f3d55e53945a587d5b57b6f753bad94f98cb73b83b7f941437047efffcd2e1bc7bf6f5ad6025c100c489d768f32d0b7109149f577d3c421d38cab71f56f327ddfe1d31615c863b5452abfb8fe515afc08e8e020199d6c354f6e87a319c2a2a057f5879ffdfcb250b974a99ed6ac66c5c54f955363a5e36bacaf0b3782cf58dc3bdcf5f92aa034cc946e77a70dae2a6e8d998c69507dce227ec7f4aff4f39246a4471913443d374ffe784731cb561f1a688574a4a2ab18cd22af78bff26debce0132b8bb8c66a9376b67834a07234aad0e437ac6f4a20eb4da9d50", - "ff09790003010fc84022d9ecf7817f965014c285c67f2b043bb132c112af3837ebb36ffce45ad0714007b23ec0986fa6ca826b67e69c4155622c165f9a906ad30be10677e4796ee324f18529bba09f8df569b8550e58f8fd69055deda4d72d75ae415e699d3290a005cebc3ceed0ba628ac9ebb37d89f3c0d4", - ] - - device = F2600(MOCK_BLE_DEVICE) - - async with MockDevice() as mock_bluetooth: - - # We first expect a negotiation - for expected, response in NEGOTIATION_RESPONSES_SOLIX.items(): - mock_bluetooth.expect_ordered( - bytes.fromhex(expected), - [bytes.fromhex(x) for x in response], - ) - - assert await device.connect(), "Expected connect to return True" - await asyncio.sleep(0.5) - assert device.negotiated, "Expected negotiated to be True" - mock_bluetooth.check_assertions() - - # Swap in the secret the packets below were captured with - device._shared_secret = bytes.fromhex( - "691d425d79574b56e59524c7e2e592701e13441aba03e4d1b251211f113f980c" - ) - - async def wait_until_listening() -> None: - """Block until the device is waiting for a telemetry packet. - - A packet that arrives before get_status_update has registered a - future for it is routed as a regular notification and dropped, so - the packets cannot just be sent one after the other. - """ - key = bytes.fromhex("03010f") + bytes.fromhex("c840") - for _ in range(1000): - if any( - not future.done() for future in device._packet_futures.get(key, []) - ): - return - await asyncio.sleep(0.01) - raise AssertionError("Device never listened for a telemetry packet!") - - # The request itself gets no response, the packets are fed in - # afterwards so that they arrive while it is waiting for them - mock_bluetooth.expect_ordered(None, []) - update = asyncio.create_task(device.get_status_update()) - - for packet in packets: - await wait_until_listening() - await mock_bluetooth.send_data([bytes.fromhex(packet)]) - - parameters = await update - mock_bluetooth.check_assertions() - - # The values are asserted on properly by the f2600_ac_charging case in - # test_values, this only confirms the packets went back together in order - assert parameters["c1"] == bytes.fromhex("0140"), "Expected 64% battery!" - assert parameters["d0"] == b"\x00AZV3NM0F08700411", "Expected the serial!" - assert device._data == parameters, "Expected the update to be stored!" - - -@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", "method", "args", "cmd", "payload"), - [ - pytest.param( - F2600, - "turn_ac_on", - (), - "404a", - "a10121a2020101", - id="f2600_ac_on", - ), - pytest.param( - F2600, - "turn_ac_off", - (), - "404a", - "a10121a2020100", - id="f2600_ac_off", - ), - pytest.param( - F2600, - "turn_dc_on", - (), - "404b", - "a10121a2020101", - id="f2600_dc_on", - ), - pytest.param( - F2600, - "turn_dc_off", - (), - "404b", - "a10121a2020100", - id="f2600_dc_off", - ), - pytest.param( - F2600, - "turn_display_on", - (), - "4052", - "a10121a2020101", - id="f2600_display_on", - ), - pytest.param( - F2600, - "turn_display_off", - (), - "4052", - "a10121a2020100", - id="f2600_display_off", - ), - pytest.param( - F2600, - "turn_power_saving_mode_on", - (), - "404e", - "a10121a2020101", - id="f2600_power_saving_on", - ), - pytest.param( - F2600, - "turn_power_saving_mode_off", - (), - "404e", - "a10121a2020100", - id="f2600_power_saving_off", - ), - # Timers take a 32 bit little endian second count. Zero cancels. - pytest.param( - F2600, - "set_ac_timer", - (3600,), - "4042", - "a10121a20502100e0000", - id="f2600_ac_timer_1h", - ), - pytest.param( - F2600, - "set_ac_timer", - (0,), - "4042", - "a10121a2050200000000", - id="f2600_ac_timer_cancel", - ), - pytest.param( - F2600, - "set_dc_timer", - (1800,), - "4043", - "a10121a2050208070000", - id="f2600_dc_timer_30m", - ), - # Light and display brightness take a single byte enum value. - pytest.param( - F2600, - "set_light_mode", - (LightStatus.OFF,), - "404f", - "a10121a2020100", - id="f2600_light_off", - ), - pytest.param( - F2600, - "set_light_mode", - (LightStatus.HIGH,), - "404f", - "a10121a2020103", - id="f2600_light_high", - ), - pytest.param( - F2600, - "set_display_mode", - (LightStatus.MEDIUM,), - "404c", - "a10121a2020102", - id="f2600_display_medium", - ), - # Display timeout and AC charging power take a 16 bit little endian value. - pytest.param( - F2600, - "set_display_timeout", - (DisplayTimeout.S300,), - "4046", - "a10121a203022c01", - id="f2600_display_timeout_5m", - ), - pytest.param( - F2600, - "set_display_timeout", - (DisplayTimeout.S1800,), - "4046", - "a10121a203020807", - id="f2600_display_timeout_30m", - ), - pytest.param( - F2600, - "set_ac_charging_power", - (1000,), - "4044", - "a10121a20302e803", - id="f2600_ac_charging_power_1000w", - ), - # Both ends of the accepted range. - pytest.param( - F2600, - "set_ac_charging_power", - (100,), - "4044", - "a10121a203026400", - id="f2600_ac_charging_power_min", - ), - pytest.param( - F2600, - "set_ac_charging_power", - (1440,), - "4044", - "a10121a20302a005", - id="f2600_ac_charging_power_max", - ), - pytest.param( - F3800, - "turn_ac_on", - (), - "404a", - "a10121a2020101", - id="f2600_ac_on", - ), - pytest.param( - F3800, - "turn_ac_off", - (), - "404a", - "a10121a2020100", - id="f2600_ac_off", - ), - pytest.param( - F3800, - "turn_dc_on", - (), - "404b", - "a10121a2020101", - id="f2600_dc_on", - ), - pytest.param( - F3800, - "turn_dc_off", - (), - "404b", - "a10121a2020100", - id="f2600_dc_off", - ), - ], -) -async def test_control_commands( - device_class: type[SolixBLEDevice], - method: str, - args: tuple[Any, ...], - cmd: str, payload: str, -) -> None: - """ - Test that an F2600 control method dispatches the correct command. - - :param method: Name of the method under test. - :param args: Positional arguments to call the method with. - :param cmd: Expected command bytes. - :param payload: Expected payload bytes. - """ - device = device_class(MOCK_BLE_DEVICE) - device._send_command = mock.AsyncMock() - await getattr(device, method)(*args) - - device._send_command.assert_awaited_once_with( - cmd=bytes.fromhex(cmd), payload=bytes.fromhex(payload), - ) - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "method,args", - [ - pytest.param("set_light_mode", (LightStatus.UNKNOWN,), id="light_unknown"), - pytest.param( - "set_display_mode", - (LightStatus.UNKNOWN,), - id="display_unknown", - ), - # The LCD has no SOS brightness, unlike the light bar. - pytest.param("set_display_mode", (LightStatus.SOS,), id="display_sos"), - pytest.param( - "set_display_timeout", - (DisplayTimeout.UNKNOWN,), - id="timeout_unknown", - ), - # Below 100 W the device charges at full power instead, and 1440 W is - # the highest the app allows. - pytest.param("set_ac_charging_power", (99,), id="ac_charging_power_too_low"), - pytest.param( - "set_ac_charging_power", - (1441,), - id="ac_charging_power_too_high", - ), - ], -) -async def test_f2600_invalid_commands(method: str, args: tuple[Any, ...]) -> None: - """ - Test that an invalid F2600 command is rejected without being transmitted. - - :param method: Name of the method under test. - :param args: Positional arguments to call the method with. - """ - device = F2600(MOCK_BLE_DEVICE) - device._send_command = mock.AsyncMock() - - with pytest.raises(ValueError): - await getattr(device, method)(*args) - - device._send_command.assert_not_awaited() - - @pytest.mark.asyncio @pytest.mark.parametrize( "device_class,packets,secret", @@ -1513,13 +1156,14 @@ async def test_f2600_invalid_commands(method: str, args: tuple[Any, ...]) -> Non ), ], ) -async def test_negotiation( - fast_sleep, - fast_timeouts, +async def test_negotiation( # noqa: PLR0913 + fake_time, # noqa: ANN001, ARG001 + fast_sleep, # noqa: ANN001, ARG001 + fast_timeouts, # noqa: ANN001, ARG001 device_class: type[SolixBLEDevice], packets: list[str], secret: str, -): +) -> None: """ Test negotiation of the shared secret by mocking a device. @@ -1665,17 +1309,6 @@ def test_payload_decryption( None, id="solix_packet_2_missing", ), - # Test that when the 1st packet arrives after the 2nd packet is it ignored - pytest.param( - C1000, - [ - "ff09390003010fc40222788d127d8418b41a81719975719a26b32734ea4e44ce244683e31928bb9a2736f9ede939567cddce6b3fb0de68116c", - "ff09fd0003010fc402121e0e23790307a57d4adabcd8d5ad56c3a9ea3cb5b222b0152438ccd3b980eda40fbde184fa66c80c3372dad179f11cad8799858ab95696e52c7e729af87c1106343ed5be9c042c8912b14f3a0d94b32afbed432e66616e1895ba0ff5e74a6da9401117070c926631e5d7886a07bec0de35aeb689e8bb289f1d7854143dc413f25d4b57d290ca4378cfb8efc275aa779145f98956e934eaced2d1f51cef7dd21a340318bfc14fb5f90ffd33e0e484175512af33593b1f91eb9801d7c2e1ac6d56e8fe7e8883d62226484ed6f1af711d042c5e3d0c186b3f2222293bc71ccf4a156a544d5171e90ee9b6b9b8f36ae058b96e3b88", - ], - "645ca871528991eb38ebb327a781e932b1d9d7a613b04c966b317db056c83428", - None, - id="solix_both_packets_reversed", - ), # Test that when the packets arrive in order they are parsed and device._data is populated pytest.param( C1000, @@ -1801,15 +1434,18 @@ def test_payload_decryption( ), ], ) -async def test_telemetry_packet_processing( - fast_sleep, - fast_timeouts, +async def test_telemetry_packet_processing( # noqa: PLR0913, PLR0917 + fake_time, # noqa: ANN001, ARG001 + fast_sleep, # noqa: ANN001, ARG001 + fast_timeouts, # noqa: ANN001, ARG001 device_class: type[SolixBLEDevice], packets: list[str], secret: str, parameters: str | None, -): +) -> None: """ + Test that telemetry packets are processed. + Test the _process_notification function when processing telemetry packets end to end. @@ -1849,7 +1485,8 @@ async def test_telemetry_packet_processing( await mock_bluetooth.send_data([bytes.fromhex(packet)]) device_parameters = ( - device._parameters_to_str(device._data) if device._data else None + device._data.to_str(verbose=False) + if device._data else None ) assert parameters == device_parameters, "Parameters do not match expected!" @@ -1869,22 +1506,25 @@ async def test_telemetry_packet_processing( "5609bc39f79166da75139feb7c335fb7524b3bf0d730db96bf6ebf450d3e165b", [ "Received non-encrypted telemetry message", - "Telemetry parameters: {'a1': '31', 'a2': '024606'", + """Telemetry parameters: {\n "a1": {\n "bytes": """, ], id="prime_160w_other", ), ], ) -async def test_generic_packet_processing( - caplog, - fast_sleep, - fast_timeouts, +async def test_generic_packet_processing( # noqa: PLR0913, PLR0917 + caplog, # noqa: ANN001 + fake_time, # noqa: ANN001, ARG001 + fast_sleep, # noqa: ANN001, ARG001 + fast_timeouts, # noqa: ANN001, ARG001 device_class: type[SolixBLEDevice], packets: list[str], secret: str, expected_logs: list[str], -): +) -> None: """ + Test the processing of arbitrary packets. + Test the _process_notification function when processing arbitrary packets and check for expected log entries. @@ -1926,7 +1566,7 @@ async def test_generic_packet_processing( for expected_log_entry in expected_logs: assert ( - expected_log_entry in caplog.text + expected_log_entry in str(caplog.text) ), f"Expected to find '{expected_log_entry}' in logs but it was not found!" @@ -2014,7 +1654,7 @@ async def test_bad_values( caplog.set_level(logging.DEBUG) device = device_class(MOCK_BLE_DEVICE) - parameters = device._parse_payload(bytes.fromhex(payload)) + parameters = Parameters.parse(bytes.fromhex(payload)) await device._process_telemetry(parameters) for class_property, expected_value in mapping.items(): diff --git a/tests/test_prime.py b/tests/test_prime.py index 3d85c0c..cbae69c 100644 --- a/tests/test_prime.py +++ b/tests/test_prime.py @@ -7,6 +7,7 @@ import pytest from SolixBLE import prime_device +from SolixBLE.constructs import Packet from SolixBLE.prime_device import PrimeDevice from tests.const import MOCK_BLE_DEVICE @@ -16,25 +17,25 @@ [ pytest.param( "ff094000030001402257ec69586f3500c8f858e0ba047f237f4e2ed8c50d2f39ba3587e4010275bea22242936f08788849272fb3f4cf7493be4a60bb9c9f0693", - prime_device.NEGOTIATION_COMMAND_5_PAYLOAD, + "a104f079b569a30400000000a518474d54304253542c4d332e352e302f312c4d31302e352e30", "09486817d949a232b58b47a43cc72d045a617a26f3999d30e1d27e38eae52265", id="stage_5_response", ), pytest.param( "ff094600030001402757ec69586f3501e8cf6185d8c4035707377af9af3a2e40b02b86e7531974f1c22440de6e43705566b77cf940e235b65abf4d413ece5f2c3781712f3742", - prime_device.NEGOTIATION_COMMAND_6_PAYLOAD, + "a104f079b569a22437396562656433352d646339632d343930342d623430632d373263346538363361613130", "09486817d949a232b58b47a43cc72d045a617a26f3999d30e1d27e38eae52265", id="stage_6_response", ), pytest.param( "ff09230003000f420057e9b8dfdeacda7991d3eb7f12093e55ff002aa9799bcc9216e3", - prime_device.NEGOTIATION_COMMAND_7_PAYLOAD, + "a10121fe04f079b569", "09486817d949a232b58b47a43cc72d045a617a26f3999d30e1d27e38eae52265", id="stage_7a_response", ), pytest.param( "ff09530003000f420a57e9b883d958e48e5b7de48d980206577e2dafbb3d604dea3686f3011969f0db2311906d142b5730ee2bfb11e3fbbe7485aac8877995310669156ec74645c962b419e579b385fd079967", - prime_device.NEGOTIATION_COMMAND_8_PAYLOAD, + "a10121a203044742a3250437396562656433352d646339632d343930342d623430632d373263346538363361613130a5020101fe04f079b569", "09486817d949a232b58b47a43cc72d045a617a26f3999d30e1d27e38eae52265", id="stage_7b_response", ), @@ -64,7 +65,7 @@ def test_negotiation_encryption_session( prime = PrimeDevice(MOCK_BLE_DEVICE) - _, _, payload = prime._split_packet(bytes.fromhex(packet)) + payload = Packet.parse(bytes.fromhex(packet)).payload_bytes prime._shared_secret = bytes.fromhex(shared_secret) decrypted = prime._decrypt_payload(payload) diff --git a/tests/test_utilities.py b/tests/test_utilities.py new file mode 100644 index 0000000..4ce6cfb --- /dev/null +++ b/tests/test_utilities.py @@ -0,0 +1,49 @@ +""" +Tests for the module utilities. + +.. moduleauthor:: Harvey Lelliott (flip-dots) +""" + +from unittest import mock + +import pytest + +from SolixBLE.utilities import get_posix_tz + + +@pytest.mark.parametrize( + ("tz", "output"), + [ + pytest.param( + "Europe/London", + "GMT0BST,M3.5.0/1,M10.5.0", + id="london", + ), + pytest.param( + "America/New_York", + "EST5EDT,M3.2.0,M11.1.0", + id="new_york", + ), + pytest.param( + Exception, + None, + id="no_tz", + ), + pytest.param( + "not_a_tz", + None, + id="invalid_tz", + ), + ], +) +def test_util_tz( + tz: str | Exception, output: str | None, +) -> None: + """ + Test the generation of POSIX timezone strings. + + :param tz: The time zone (e.g Europe/London) or error. + :param output: Expected output of function. + """ + with mock.patch("tzlocal.get_localzone_name", side_effect=[tz]): + assert get_posix_tz() == output