diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dfe62a..0d819b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ +## v2.7.2 (2024-11-08) + +### Fix + +* Add some new roborock codes ([#233](https://github.com/humbertogontijo/python-roborock/issues/233)) ([`59546dd`](https://github.com/humbertogontijo/python-roborock/commit/59546dd68f7b40ad368d58fd502680ff9c03c81b)) + +## v2.7.1 (2024-10-28) + +### Fix + +* Check that clean area is not a str ([#230](https://github.com/humbertogontijo/python-roborock/issues/230)) ([`e66a91e`](https://github.com/humbertogontijo/python-roborock/commit/e66a91edaf6fedf5d4b2ab9117b7759295add492)) + +## v2.7.0 (2024-10-28) + +### Feature + +* Remove dacite ([#227](https://github.com/humbertogontijo/python-roborock/issues/227)) ([`86878a7`](https://github.com/humbertogontijo/python-roborock/commit/86878a71d82c2cc707daa16dec109fc07360e3f6)) + ## v2.6.1 (2024-10-22) ### Fix diff --git a/docs/source/_templates/footer.html b/docs/source/_templates/footer.html index d2276f4..768ee7a 100644 --- a/docs/source/_templates/footer.html +++ b/docs/source/_templates/footer.html @@ -1,5 +1,5 @@ {% extends "!footer.html" %} {%- block contentinfo %} -{{ super }} +{{ super() }}

We are looking for contributors to help with our documentation, if you are interested please contribute here. {% endblock %} diff --git a/pyproject.toml b/pyproject.toml index 17a799b..c77301e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-roborock" -version = "2.6.1" +version = "2.7.2" description = "A package to control Roborock vacuums." authors = ["humbertogontijo "] license = "GPL-3.0-only" diff --git a/roborock/api.py b/roborock/api.py index 9b0c86f..633bddc 100644 --- a/roborock/api.py +++ b/roborock/api.py @@ -23,7 +23,7 @@ RoborockMessage, ) from .roborock_typing import RoborockCommand -from .util import RoborockLoggerAdapter, get_running_loop_or_create_one +from .util import RoborockLoggerAdapter, get_next_int, get_running_loop_or_create_one _LOGGER = logging.getLogger(__name__) KEEPALIVE = 60 @@ -113,6 +113,13 @@ def _async_response( self, request_id: int, protocol_id: int = 0 ) -> Coroutine[Any, Any, tuple[Any, VacuumError | None]]: queue = RoborockFuture(protocol_id) + if request_id in self._waiting_queue: + new_id = get_next_int(10000, 32767) + _LOGGER.warning( + f"Attempting to create a future with an existing request_id... New id is {new_id}. " + f"Code may not function properly." + ) + request_id = new_id self._waiting_queue[request_id] = queue return self._wait_response(request_id, queue) diff --git a/roborock/code_mappings.py b/roborock/code_mappings.py index bf82379..8bd58b4 100644 --- a/roborock/code_mappings.py +++ b/roborock/code_mappings.py @@ -297,6 +297,7 @@ class RoborockMopModeS8ProUltra(RoborockMopModeCode): class RoborockMopModeS8MaxVUltra(RoborockMopModeCode): standard = 300 deep = 301 + custom = 302 deep_plus = 303 fast = 304 deep_plus_pearl = 305 @@ -382,6 +383,16 @@ class RoborockMopIntensityS6MaxV(RoborockMopIntensityCode): custom_water_flow = 207 +class RoborockMopIntensityQ7Max(RoborockMopIntensityCode): + """Describes the mop intensity of the vacuum cleaner.""" + + off = 200 + low = 201 + medium = 202 + high = 203 + custom_water_flow = 207 + + class RoborockDockErrorCode(RoborockEnum): """Describes the error code of the dock.""" diff --git a/roborock/containers.py b/roborock/containers.py index 555733e..579dee4 100644 --- a/roborock/containers.py +++ b/roborock/containers.py @@ -7,9 +7,7 @@ from dataclasses import asdict, dataclass, field from datetime import timezone from enum import Enum -from typing import Any, NamedTuple - -from dacite import Config, from_dict +from typing import Any, NamedTuple, get_args, get_origin from .code_mappings import ( RoborockCategory, @@ -32,11 +30,11 @@ RoborockMopIntensityCode, RoborockMopIntensityP10, RoborockMopIntensityQRevoMaster, + RoborockMopIntensityQ7Max, RoborockMopIntensityS5Max, RoborockMopIntensityS6MaxV, RoborockMopIntensityS7, RoborockMopIntensityS8MaxVUltra, - RoborockMopIntensityV2, RoborockMopModeCode, RoborockMopModeS7, RoborockMopModeS8MaxVUltra, @@ -105,14 +103,70 @@ class RoborockBase: _ignore_keys = [] # type: ignore is_cached = False + @staticmethod + def convert_to_class_obj(type, value): + try: + class_type = eval(type) + if get_origin(class_type) is list: + return_list = [] + cls_type = get_args(class_type)[0] + for obj in value: + if issubclass(cls_type, RoborockBase): + return_list.append(cls_type.from_dict(obj)) + elif cls_type in {str, int, float}: + return_list.append(cls_type(obj)) + else: + return_list.append(cls_type(**obj)) + return return_list + if issubclass(class_type, RoborockBase): + converted_value = class_type.from_dict(value) + else: + converted_value = class_type(value) + return converted_value + except NameError as err: + _LOGGER.exception(err) + except ValueError as err: + _LOGGER.exception(err) + except Exception as err: + _LOGGER.exception(err) + raise Exception("Fail") + @classmethod def from_dict(cls, data: dict[str, Any]): if isinstance(data, dict): ignore_keys = cls._ignore_keys - try: - return from_dict(cls, decamelize_obj(data, ignore_keys), config=Config(cast=[Enum])) - except AttributeError as err: - raise RoborockException("It seems like you have an outdated version of dacite.") from err + data = decamelize_obj(data, ignore_keys) + cls_annotations: dict[str, str] = {} + for base in reversed(cls.__mro__): + cls_annotations.update(getattr(base, "__annotations__", {})) + remove_keys = [] + for key, value in data.items(): + if value == "None" or value is None: + data[key] = None + continue + if key not in cls_annotations: + remove_keys.append(key) + continue + field_type: str = cls_annotations[key] + if "|" in field_type: + # It's a union + types = field_type.split("|") + for type in types: + if "None" in type or "Any" in type: + continue + try: + data[key] = RoborockBase.convert_to_class_obj(type, value) + break + except Exception: + ... + else: + try: + data[key] = RoborockBase.convert_to_class_obj(field_type, value) + except Exception: + ... + for key in remove_keys: + del data[key] + return cls(**data) def as_dict(self) -> dict: return asdict( @@ -188,6 +242,7 @@ class HomeDataProductSchema(RoborockBase): mode: Any | None = None type: Any | None = None product_property: Any | None = None + property: Any | None = None desc: Any | None = None @@ -198,7 +253,7 @@ class HomeDataProduct(RoborockBase): model: str category: RoborockCategory code: str | None = None - iconurl: str | None = None + icon_url: str | None = None attribute: Any | None = None capability: int | None = None schema: list[HomeDataProductSchema] | None = None @@ -515,7 +570,7 @@ class S5MaxStatus(Status): @dataclass class Q7MaxStatus(Status): fan_power: RoborockFanSpeedQ7Max | None = None - water_box_mode: RoborockMopIntensityV2 | None = None + water_box_mode: RoborockMopIntensityQ7Max | None = None @dataclass @@ -622,7 +677,7 @@ class CleanSummary(RoborockBase): last_clean_t: int | None = None def __post_init__(self) -> None: - if isinstance(self.clean_area, list): + if isinstance(self.clean_area, list | str): _LOGGER.warning(f"Clean area is a unexpected type! Please give the following in a issue: {self.clean_area}") else: self.square_meter_clean_area = round(self.clean_area / 1000000, 1) if self.clean_area is not None else None diff --git a/roborock/roborock_message.py b/roborock/roborock_message.py index 7536770..df03108 100644 --- a/roborock/roborock_message.py +++ b/roborock/roborock_message.py @@ -4,9 +4,9 @@ import math import time from dataclasses import dataclass -from random import randint from roborock import RoborockEnum +from roborock.util import get_next_int class RoborockMessageProtocol(RoborockEnum): @@ -155,9 +155,9 @@ class MessageRetry: class RoborockMessage: protocol: RoborockMessageProtocol payload: bytes | None = None - seq: int = randint(100000, 999999) + seq: int = get_next_int(100000, 999999) version: bytes = b"1.0" - random: int = randint(10000, 99999) + random: int = get_next_int(10000, 99999) timestamp: int = math.floor(time.time()) message_retry: MessageRetry | None = None diff --git a/roborock/util.py b/roborock/util.py index d1d8c4d..c6013d2 100644 --- a/roborock/util.py +++ b/roborock/util.py @@ -108,3 +108,15 @@ def __init__(self, prefix: str, logger: logging.Logger) -> None: def process(self, msg: str, kwargs: MutableMapping[str, Any]) -> tuple[str, MutableMapping[str, Any]]: return f"[{self.prefix}] {msg}", kwargs + + +counter_map: dict[tuple[int, int], int] = {} + + +def get_next_int(min_val: int, max_val: int): + """Gets a random int in the range, precached to help keep it fast.""" + if (min_val, max_val) not in counter_map: + # If we have never seen this range, or if the cache is getting low, make a bunch of preshuffled values. + counter_map[(min_val, max_val)] = min_val + counter_map[(min_val, max_val)] += 1 + return counter_map[(min_val, max_val)] % max_val + min_val diff --git a/roborock/version_1_apis/roborock_client_v1.py b/roborock/version_1_apis/roborock_client_v1.py index 95f4de3..279721e 100644 --- a/roborock/version_1_apis/roborock_client_v1.py +++ b/roborock/version_1_apis/roborock_client_v1.py @@ -5,7 +5,6 @@ import struct import time from collections.abc import Callable, Coroutine -from random import randint from typing import Any, TypeVar, final from roborock import ( @@ -54,7 +53,7 @@ RoborockMessage, RoborockMessageProtocol, ) -from roborock.util import RepeatableTask, unpack_list +from roborock.util import RepeatableTask, get_next_int, unpack_list COMMANDS_SECURED = { RoborockCommand.GET_MAP_V1, @@ -338,7 +337,7 @@ def _get_payload( secured=False, ): timestamp = math.floor(time.time()) - request_id = randint(10000, 32767) + request_id = get_next_int(10000, 32767) inner = { "id": request_id, "method": method, diff --git a/tests/test_containers.py b/tests/test_containers.py index b565646..3ce72c0 100644 --- a/tests/test_containers.py +++ b/tests/test_containers.py @@ -47,7 +47,7 @@ def test_home_data(): assert product.name == "Roborock S7 MaxV" assert product.code == "a27" assert product.model == "roborock.vacuum.a27" - assert product.iconurl is None + assert product.icon_url is None assert product.attribute is None assert product.capability == 0 assert product.category == RoborockCategory.VACUUM