diff --git a/CHANGELOG.md b/CHANGELOG.md index 04fbbe4..1894785 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ +## v2.5.0 (2024-06-25) + +### Feature + +* Add some typing ([#219](https://github.com/humbertogontijo/python-roborock/issues/219)) ([`35d0900`](https://github.com/humbertogontijo/python-roborock/commit/35d09000b8d144cbaf935069952ea135950d0e78)) + +## v2.4.0 (2024-06-25) + +### Feature + +* Add some missing codes and make warnings only message once ([#218](https://github.com/humbertogontijo/python-roborock/issues/218)) ([`12361b5`](https://github.com/humbertogontijo/python-roborock/commit/12361b58e7a4d368281c4ffd9ac3d8e9d8155e62)) + +## v2.3.0 (2024-06-07) + +### Feature + +* Add warning in web requests if it fails to decode ([#215](https://github.com/humbertogontijo/python-roborock/issues/215)) ([`6ae69e9`](https://github.com/humbertogontijo/python-roborock/commit/6ae69e9bcba6a98736f2f480114922186f6ca458)) + +## v2.2.3 (2024-06-04) + +### Fix + +* S8 maxv has a wash and fill dock ([#213](https://github.com/humbertogontijo/python-roborock/issues/213)) ([`018fd05`](https://github.com/humbertogontijo/python-roborock/commit/018fd052360dffd238919e336943809720457c4e)) + +## v2.2.2 (2024-05-16) + +### Fix + +* Handle weird clean record response ([#206](https://github.com/humbertogontijo/python-roborock/issues/206)) ([`07ce71a`](https://github.com/humbertogontijo/python-roborock/commit/07ce71a2cd8085136952bd7639f6f4a2e273faf9)) + ## v2.2.1 (2024-05-11) ### Fix diff --git a/docs/source/api_commands.rst b/docs/source/api_commands.rst index 5f0f50b..668f74e 100644 --- a/docs/source/api_commands.rst +++ b/docs/source/api_commands.rst @@ -992,7 +992,9 @@ reset_consumable Description: -Parameters: +Parameters: List of consumables to reset. For example, to reset consumables 'strainer_work_times' and 'sensor_dirty_time' the parameter would be + + ['strainer_work_times', 'sensor_dirty_time'] ====================== ========= Vacuum Model Supported @@ -1247,7 +1249,7 @@ load_multi_map Description: -Parameters: ??? +Parameters: number (the floor/map index) .. Need to work out parameter format diff --git a/pyproject.toml b/pyproject.toml index 1a50edb..e8c9d45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-roborock" -version = "2.2.1" +version = "2.5.0" description = "A package to control Roborock vacuums." authors = ["humbertogontijo "] license = "GPL-3.0-only" diff --git a/roborock/code_mappings.py b/roborock/code_mappings.py index ea64fe1..284475d 100644 --- a/roborock/code_mappings.py +++ b/roborock/code_mappings.py @@ -4,6 +4,7 @@ from enum import Enum, IntEnum _LOGGER = logging.getLogger(__name__) +completed_warnings = set() class RoborockEnum(IntEnum): @@ -16,10 +17,16 @@ def name(self) -> str: @classmethod def _missing_(cls: type[RoborockEnum], key) -> RoborockEnum: if hasattr(cls, "unknown"): - _LOGGER.warning(f"Missing {cls.__name__} code: {key} - defaulting to 'unknown'") + warning = f"Missing {cls.__name__} code: {key} - defaulting to 'unknown'" + if warning not in completed_warnings: + completed_warnings.add(warning) + _LOGGER.warning(warning) return cls.unknown # type: ignore default_value = next(item for item in cls) - _LOGGER.warning(f"Missing {cls.__name__} code: {key} - defaulting to {default_value}") + warning = f"Missing {cls.__name__} code: {key} - defaulting to {default_value}" + if warning not in completed_warnings: + completed_warnings.add(warning) + _LOGGER.warning(warning) return default_value @classmethod @@ -248,7 +255,10 @@ class RoborockFanSpeedP10(RoborockFanPowerCode): class RoborockFanSpeedS8MaxVUltra(RoborockFanPowerCode): off = 105 + quiet = 101 balanced = 102 + turbo = 103 + max = 104 custom = 106 max_plus = 108 smart_mode = 110 @@ -324,6 +334,8 @@ class RoborockMopIntensityS8MaxVUltra(RoborockMopIntensityCode): low = 201 medium = 202 high = 203 + custom = 204 + max = 208 smart_mode = 209 custom_water_flow = 207 @@ -394,6 +406,7 @@ class RoborockDockWashTowelModeCode(RoborockEnum): light = 0 balanced = 1 deep = 2 + smart = 10 class RoborockCategory(Enum): @@ -409,6 +422,93 @@ def __missing__(self, key): return RoborockCategory.UNKNOWN +class RoborockFinishReason(RoborockEnum): + manual_interrupt = 21 # Cleaning interrupted by user + cleanup_interrupted = 24 # Cleanup interrupted + manual_interrupt_2 = 21 + breakpoint = 32 # Could not continue cleaning + breakpoint_2 = 33 + cleanup_interrupted_2 = 34 + manual_interrupt_3 = 35 + manual_interrupt_4 = 36 + manual_interrupt_5 = 37 + manual_interrupt_6 = 43 + locate_fail = 45 # Positioning Failed + cleanup_interrupted_3 = 64 + locate_fail_2 = 65 + manual_interrupt_7 = 48 + manual_interrupt_8 = 49 + manual_interrupt_9 = 50 + cleanup_interrupted_4 = 51 + finished_cleaning = 52 # Finished cleaning + finished_cleaning_2 = 54 + finished_cleaning_3 = 55 + finished_cleaning_4 = 56 + finished_clenaing_5 = 57 + manual_interrupt_10 = 60 + area_unreachable = 61 # Area unreachable + area_unreachable_2 = 62 + washing_error = 67 # Washing error + back_to_wash_failure = 68 # Failed to return to the dock + cleanup_interrupted_5 = 101 + breakpoint_4 = 102 + manual_interrupt_11 = 103 + cleanup_interrupted_6 = 104 + cleanup_interrupted_7 = 105 + cleanup_interrupted_8 = 106 + cleanup_interrupted_9 = 107 + cleanup_interrupted_10 = 109 + cleanup_interrupted_11 = 110 + patrol_success = 114 # Cruise completed + patrol_fail = 115 # Cruise failed + pet_patrol_success = 116 # Pet found + pet_patrol_fail = 117 # Pet found failed + + +class RoborockInCleaning(RoborockEnum): + complete = 0 + global_clean_not_complete = 1 + zone_clean_not_complete = 2 + segment_clean_not_complete = 3 + + +class RoborockCleanType(RoborockEnum): + all_zone = 1 + draw_zone = 2 + select_zone = 3 + quick_build = 4 + video_patrol = 5 + pet_patrol = 6 + + +class RoborockStartType(RoborockEnum): + button = 1 + app = 2 + schedule = 3 + mi_home = 4 + quick_start = 5 + voice_control = 13 + routines = 101 + alexa = 801 + google = 802 + ifttt = 803 + yandex = 804 + homekit = 805 + xiaoai = 806 + tmall_genie = 807 + duer = 808 + dingdong = 809 + siri = 810 + clova = 811 + wechat = 901 + alipay = 902 + aqara = 903 + hisense = 904 + huawei = 905 + widget_launch = 820 + smart_watch = 821 + + class DyadSelfCleanMode(RoborockEnum): self_clean = 1 self_clean_and_dry = 2 diff --git a/roborock/const.py b/roborock/const.py index c3bfc11..4b6b34d 100644 --- a/roborock/const.py +++ b/roborock/const.py @@ -42,8 +42,11 @@ ROBOROCK_C1 = "roborock.vacuum.c1" ROBOROCK_S8_PRO_ULTRA = "roborock.vacuum.a70" ROBOROCK_S8 = "roborock.vacuum.a51" -ROBOROCK_P10 = "roborock.vacuum.a75" +ROBOROCK_P10 = "roborock.vacuum.a75" # also known as q_revo ROBOROCK_S8_MAXV_ULTRA = "roborock.vacuum.a97" +ROBOROCK_QREVO_S = "roborock.vacuum.a104" +ROBOROCK_QREVO_PRO = "roborock.vacuum.a101" +ROBOROCK_QREVO_MAXV = "roborock.vacuum.a87" ROBOROCK_DYAD_AIR = "roborock.wetdryvac.a107" ROBOROCK_DYAD_PRO_COMBO = "roborock.wetdryvac.a83" diff --git a/roborock/containers.py b/roborock/containers.py index 74a9630..65dac3b 100644 --- a/roborock/containers.py +++ b/roborock/containers.py @@ -13,6 +13,7 @@ from .code_mappings import ( RoborockCategory, + RoborockCleanType, RoborockDockDustCollectionModeCode, RoborockDockErrorCode, RoborockDockTypeCode, @@ -25,6 +26,8 @@ RoborockFanSpeedS7, RoborockFanSpeedS7MaxV, RoborockFanSpeedS8MaxVUltra, + RoborockFinishReason, + RoborockInCleaning, RoborockMopIntensityCode, RoborockMopIntensityP10, RoborockMopIntensityS5Max, @@ -36,6 +39,7 @@ RoborockMopModeS7, RoborockMopModeS8MaxVUltra, RoborockMopModeS8ProUltra, + RoborockStartType, RoborockStateCode, ) from .const import ( @@ -47,6 +51,9 @@ ROBOROCK_G10S_PRO, ROBOROCK_P10, ROBOROCK_Q7_MAX, + ROBOROCK_QREVO_MAXV, + ROBOROCK_QREVO_PRO, + ROBOROCK_QREVO_S, ROBOROCK_S4_MAX, ROBOROCK_S5_MAX, ROBOROCK_S6, @@ -408,7 +415,7 @@ class Status(RoborockBase): square_meter_clean_area: float | None = None error_code: RoborockErrorCode | None = None map_present: int | None = None - in_cleaning: int | None = None + in_cleaning: RoborockInCleaning | None = None in_returning: int | None = None in_fresh_state: int | None = None lab_status: int | None = None @@ -574,6 +581,12 @@ class S8MaxvUltraStatus(Status): ROBOROCK_S8_PRO_ULTRA: S8ProUltraStatus, ROBOROCK_G10S_PRO: S7MaxVStatus, ROBOROCK_P10: P10Status, + # These likely are not correct, + # but i am currently unable to do my typical reverse engineering/ get any data from users on this, + # so this will be here in the mean time. + ROBOROCK_QREVO_S: P10Status, + ROBOROCK_QREVO_MAXV: P10Status, + ROBOROCK_QREVO_PRO: P10Status, ROBOROCK_S8_MAXV_ULTRA: S8MaxvUltraStatus, } @@ -613,9 +626,9 @@ class CleanRecord(RoborockBase): square_meter_area: float | None = None error: int | None = None complete: int | None = None - start_type: int | None = None - clean_type: int | None = None - finish_reason: int | None = None + start_type: RoborockStartType | None = None + clean_type: RoborockCleanType | None = None + finish_reason: RoborockFinishReason | None = None dust_collection_status: int | None = None avoid_count: int | None = None wash_count: int | None = None diff --git a/roborock/local_api.py b/roborock/local_api.py index 92e95bc..7fdc4ac 100644 --- a/roborock/local_api.py +++ b/roborock/local_api.py @@ -96,7 +96,7 @@ async def hello(self): except Exception as e: self._logger.error(e) - async def ping(self): + async def ping(self) -> None: request_id = 2 protocol = RoborockMessageProtocol.PING_REQUEST return await self.send_message( diff --git a/roborock/version_1_apis/roborock_client_v1.py b/roborock/version_1_apis/roborock_client_v1.py index 1910f6c..4e8bfda 100644 --- a/roborock/version_1_apis/roborock_client_v1.py +++ b/roborock/version_1_apis/roborock_client_v1.py @@ -65,6 +65,7 @@ RoborockDockTypeCode.empty_wash_fill_dock, RoborockDockTypeCode.s8_dock, RoborockDockTypeCode.p10_dock, + RoborockDockTypeCode.s8_maxv_ultra_dock, ] RT = TypeVar("RT", bound=RoborockBase) EVICT_TIME = 60 @@ -104,7 +105,7 @@ async def async_value(self): def stop(self): self.task.cancel() - async def update_value(self, params): + async def update_value(self, params) -> None: if self.attribute.set_command is None: raise RoborockException(f"{self.attribute.attribute} have no set command") response = await self.api._send_command(self.attribute.set_command, params) @@ -118,7 +119,7 @@ async def add_value(self, params): await self._async_value() return response - async def close_value(self, params=None): + async def close_value(self, params=None) -> None: if self.attribute.close_command is None: raise RoborockException(f"{self.attribute.attribute} have no close command") response = await self.api._send_command(self.attribute.close_command, params) @@ -153,7 +154,7 @@ def release(self): super().release() [item.stop() for item in self.cache.values()] - async def async_release(self): + async def async_release(self) -> None: await super().async_release() [item.stop() for item in self.cache.values()] @@ -197,6 +198,24 @@ async def get_clean_record(self, record_id: int) -> CleanRecord | None: if isinstance(record, dict): return CleanRecord.from_dict(record) elif isinstance(record, list): + if isinstance(record[-1], dict): + records = [CleanRecord.from_dict(rec) for rec in record] + final_record = records[-1] + try: + # This code is semi-presumptions - so it is put in a try finally to be safe. + final_record.begin = records[0].begin + final_record.begin_datetime = records[0].begin_datetime + final_record.start_type = records[0].start_type + for rec in records[0:-1]: + final_record.duration += rec.duration if rec.duration is not None else 0 + final_record.area += rec.area if rec.area is not None else 0 + final_record.avoid_count += rec.avoid_count if rec.avoid_count is not None else 0 + final_record.wash_count += rec.wash_count if rec.wash_count is not None else 0 + final_record.square_meter_area += ( + rec.square_meter_area if rec.square_meter_area is not None else 0 + ) + finally: + return final_record # There are still a few unknown variables in this. begin, end, duration, area = unpack_list(record, 4) return CleanRecord(begin=begin, end=end, duration=duration, area=area) @@ -277,7 +296,7 @@ async def get_room_mapping(self) -> list[RoomMapping] | None: """Gets the mapping from segment id -> iot id. Only works on local api.""" mapping: list = await self.send_command(RoborockCommand.GET_ROOM_MAPPING) if isinstance(mapping, list): - if not isinstance(mapping[0], list) and len(mapping) == 2: + if len(mapping) == 2 and not isinstance(mapping[0], list): return [RoomMapping(segment_id=mapping[0], iot_id=mapping[1])] return [ RoomMapping(segment_id=segment_id, iot_id=iot_id) # type: ignore diff --git a/roborock/web_api.py b/roborock/web_api.py index 33450dd..44646f6 100644 --- a/roborock/web_api.py +++ b/roborock/web_api.py @@ -3,11 +3,13 @@ import base64 import hashlib import hmac +import logging import math import secrets import time import aiohttp +from aiohttp import ContentTypeError from roborock.containers import HomeData, HomeDataRoom, ProductResponse, RRiot, UserData from roborock.exceptions import ( @@ -23,6 +25,8 @@ RoborockUrlException, ) +_LOGGER = logging.getLogger(__name__) + class RoborockApiClient: def __init__(self, username: str, base_url=None) -> None: @@ -294,11 +298,23 @@ async def request(self, method: str, url: str, params=None, data=None, headers=N _url = "/".join(s.strip("/") for s in [self.base_url, url]) _headers = {**self.base_headers, **(headers or {})} async with aiohttp.ClientSession() as session: - async with session.request( - method, - _url, - params=params, - data=data, - headers=_headers, - ) as resp: - return await resp.json() + try: + async with session.request( + method, + _url, + params=params, + data=data, + headers=_headers, + ) as resp: + return await resp.json() + except ContentTypeError as err: + """If we get an error, lets log everything for debugging.""" + try: + resp_json = await resp.json(content_type=None) + _LOGGER.info("Resp: %s", resp_json) + except ContentTypeError as err_2: + _LOGGER.info(err_2) + resp_raw = await resp.read() + _LOGGER.info("Resp raw: %s", resp_raw) + # Still raise the err so that it's clear it failed. + raise err