From 230e2f789b585a57d8b67d08c64242e196494644 Mon Sep 17 00:00:00 2001 From: Luke Date: Fri, 28 Apr 2023 12:36:02 -0400 Subject: [PATCH 01/17] feat: add support for old mop and vacuum codes --- roborock/api.py | 4 +++- roborock/code_mappings.py | 32 +++++++++++++++++++++++++++++++- roborock/containers.py | 11 +++++++++++ roborock/typing.py | 1 + tests/test_containers.py | 12 ++++++++++-- 5 files changed, 56 insertions(+), 4 deletions(-) diff --git a/roborock/api.py b/roborock/api.py index bcda9a26..12ed22cc 100644 --- a/roborock/api.py +++ b/roborock/api.py @@ -35,7 +35,7 @@ SmartWashParams, Status, UserData, - WashTowelMode, + WashTowelMode, StatusOldModes, ) from .exceptions import ( RoborockAccountDoesNotExist, @@ -206,6 +206,8 @@ async def send_command(self, device_id: str, method: RoborockCommand, params: Op async def get_status(self, device_id: str) -> Status | None: status = await self.send_command(device_id, RoborockCommand.GET_STATUS) if isinstance(status, dict): + if self.devices_info[device_id].device.uses_old_codes: + return StatusOldModes.from_dict(status) return Status.from_dict(status) return None diff --git a/roborock/code_mappings.py b/roborock/code_mappings.py index 9bdd3cb5..c60aa108 100644 --- a/roborock/code_mappings.py +++ b/roborock/code_mappings.py @@ -160,7 +160,14 @@ def create_code_enum(name: str, data: dict) -> RoborockEnum: RoborockDockTypeCode = create_code_enum( "RoborockDockTypeCode", - {0: "no_dock", 1: "unknown", 2: "unknown", 3: "empty_wash_fill_dock", 4: "unknown", 5: "auto_empty_dock_pure"}, + { + 0: "no_dock", + 1: "unknown", + 2: "unknown", + 3: "empty_wash_fill_dock", + 4: "unknown", + 5: "auto_empty_dock_pure" + }, ) RoborockDockDustCollectionModeCode = create_code_enum( @@ -181,3 +188,26 @@ def create_code_enum(name: str, data: dict) -> RoborockEnum: 2: "deep", }, ) + +OldRoborockFanPowerCode = create_code_enum( + "OldRoborockFanPowerCode", + { + 101: "silent", + 102: "balanced", + 103: "turbo", + 104: "max", + 105: "gentle", + 106: "customize (auto)" + } +) + +OldRoborockMopIntensityCode = create_code_enum( + "OldRoborockMopModeCode", + { + 200: "off", + 201: "low", + 202: "medium", + 203: "high", + 207: "custom (levels)" + } +) diff --git a/roborock/containers.py b/roborock/containers.py index 0ccf7846..bdec9baf 100644 --- a/roborock/containers.py +++ b/roborock/containers.py @@ -20,6 +20,8 @@ RoborockErrorCode, RoborockFanPowerCode, RoborockMopModeCode, + OldRoborockFanPowerCode, + OldRoborockMopIntensityCode, ) from .const import FILTER_REPLACE_TIME, MAIN_BRUSH_REPLACE_TIME, SENSOR_DIRTY_REPLACE_TIME, SIDE_BRUSH_REPLACE_TIME @@ -160,6 +162,11 @@ class HomeDataDevice(RoborockBase): new_feature_set: Optional[str] = None device_status: Optional[HomeDataDeviceStatus] = None silent_ota_switch: Optional[bool] = None + uses_old_codes: bool = False + + def __post_init__(self): + if self.device_status.model == "roborock.vacuum.a10": + self.uses_old_codes = True @dataclass @@ -234,6 +241,10 @@ class Status(RoborockBase): unsave_map_reason: Optional[int] = None unsave_map_flag: Optional[int] = None +class StatusOldModes(Status): + water_box_mode: Optional[OldRoborockMopIntensityCode] = None # type: ignore[valid-type] + fan_power: Optional[OldRoborockFanPowerCode] = None # type: ignore[valid-type] + @dataclass class DNDTimer(RoborockBase): diff --git a/roborock/typing.py b/roborock/typing.py index 6a23d202..71b016d4 100644 --- a/roborock/typing.py +++ b/roborock/typing.py @@ -107,6 +107,7 @@ class RoborockCommand(str, Enum): GET_DEVICE_ICE = "get_device_ice" START_VOICE_CHAT = "start_voice_chat" SEND_SDP_TO_ROBOT = "send_sdp_to_robot" + GET_FW_FEATURES = "get_fw_features" @dataclass diff --git a/tests/test_containers.py b/tests/test_containers.py index 0b82c0cb..2b0510c7 100644 --- a/tests/test_containers.py +++ b/tests/test_containers.py @@ -1,4 +1,4 @@ -from roborock import CleanRecord, CleanSummary, Consumable, DNDTimer, HomeData, Status, UserData +from roborock import CleanRecord, CleanSummary, Consumable, DNDTimer, HomeData, Status, UserData, StatusOldModes from roborock.code_mappings import ( RoborockDockErrorCode, RoborockDockTypeCode, @@ -6,7 +6,7 @@ RoborockFanPowerCode, RoborockMopIntensityCode, RoborockMopModeCode, - RoborockStateCode, + RoborockStateCode, OldRoborockFanPowerCode, OldRoborockMopIntensityCode, ) from .mock_data import CLEAN_RECORD, CLEAN_SUMMARY, CONSUMABLE, DND_TIMER, HOME_DATA_RAW, STATUS, USER_DATA @@ -152,6 +152,14 @@ def test_status(): assert s.unsave_map_reason == 0 assert s.unsave_map_flag == 0 +def test_old_status(): + s = StatusOldModes.from_dict(STATUS) + assert isinstance(s.water_box_mode, OldRoborockMopIntensityCode) + assert isinstance(s.fan_power, OldRoborockFanPowerCode) + assert s.msg_ver == 2 + assert s.msg_seq == 458 + assert s.state == RoborockStateCode["8"] + assert s.fan_power == OldRoborockFanPowerCode["102"] def test_dnd_timer(): dnd = DNDTimer.from_dict(DND_TIMER) From 908f115b0c1ba74876de572cf4676e7150afba69 Mon Sep 17 00:00:00 2001 From: Luke Date: Fri, 28 Apr 2023 12:38:31 -0400 Subject: [PATCH 02/17] fix: linting --- roborock/api.py | 3 ++- roborock/code_mappings.py | 27 +++------------------------ roborock/containers.py | 5 +++-- tests/test_containers.py | 8 ++++++-- 4 files changed, 14 insertions(+), 29 deletions(-) diff --git a/roborock/api.py b/roborock/api.py index 12ed22cc..611aaf3d 100644 --- a/roborock/api.py +++ b/roborock/api.py @@ -34,8 +34,9 @@ RoomMapping, SmartWashParams, Status, + StatusOldModes, UserData, - WashTowelMode, StatusOldModes, + WashTowelMode, ) from .exceptions import ( RoborockAccountDoesNotExist, diff --git a/roborock/code_mappings.py b/roborock/code_mappings.py index c60aa108..0be4f681 100644 --- a/roborock/code_mappings.py +++ b/roborock/code_mappings.py @@ -160,14 +160,7 @@ def create_code_enum(name: str, data: dict) -> RoborockEnum: RoborockDockTypeCode = create_code_enum( "RoborockDockTypeCode", - { - 0: "no_dock", - 1: "unknown", - 2: "unknown", - 3: "empty_wash_fill_dock", - 4: "unknown", - 5: "auto_empty_dock_pure" - }, + {0: "no_dock", 1: "unknown", 2: "unknown", 3: "empty_wash_fill_dock", 4: "unknown", 5: "auto_empty_dock_pure"}, ) RoborockDockDustCollectionModeCode = create_code_enum( @@ -191,23 +184,9 @@ def create_code_enum(name: str, data: dict) -> RoborockEnum: OldRoborockFanPowerCode = create_code_enum( "OldRoborockFanPowerCode", - { - 101: "silent", - 102: "balanced", - 103: "turbo", - 104: "max", - 105: "gentle", - 106: "customize (auto)" - } + {101: "silent", 102: "balanced", 103: "turbo", 104: "max", 105: "gentle", 106: "customize (auto)"}, ) OldRoborockMopIntensityCode = create_code_enum( - "OldRoborockMopModeCode", - { - 200: "off", - 201: "low", - 202: "medium", - 203: "high", - 207: "custom (levels)" - } + "OldRoborockMopModeCode", {200: "off", 201: "low", 202: "medium", 203: "high", 207: "custom (levels)"} ) diff --git a/roborock/containers.py b/roborock/containers.py index bdec9baf..109a1b7b 100644 --- a/roborock/containers.py +++ b/roborock/containers.py @@ -15,13 +15,13 @@ ) from .code_mappings import ( + OldRoborockFanPowerCode, + OldRoborockMopIntensityCode, RoborockDockDustCollectionModeCode, RoborockDockErrorCode, RoborockErrorCode, RoborockFanPowerCode, RoborockMopModeCode, - OldRoborockFanPowerCode, - OldRoborockMopIntensityCode, ) from .const import FILTER_REPLACE_TIME, MAIN_BRUSH_REPLACE_TIME, SENSOR_DIRTY_REPLACE_TIME, SIDE_BRUSH_REPLACE_TIME @@ -241,6 +241,7 @@ class Status(RoborockBase): unsave_map_reason: Optional[int] = None unsave_map_flag: Optional[int] = None + class StatusOldModes(Status): water_box_mode: Optional[OldRoborockMopIntensityCode] = None # type: ignore[valid-type] fan_power: Optional[OldRoborockFanPowerCode] = None # type: ignore[valid-type] diff --git a/tests/test_containers.py b/tests/test_containers.py index 2b0510c7..ab63117a 100644 --- a/tests/test_containers.py +++ b/tests/test_containers.py @@ -1,12 +1,14 @@ -from roborock import CleanRecord, CleanSummary, Consumable, DNDTimer, HomeData, Status, UserData, StatusOldModes +from roborock import CleanRecord, CleanSummary, Consumable, DNDTimer, HomeData, Status, StatusOldModes, UserData from roborock.code_mappings import ( + OldRoborockFanPowerCode, + OldRoborockMopIntensityCode, RoborockDockErrorCode, RoborockDockTypeCode, RoborockErrorCode, RoborockFanPowerCode, RoborockMopIntensityCode, RoborockMopModeCode, - RoborockStateCode, OldRoborockFanPowerCode, OldRoborockMopIntensityCode, + RoborockStateCode, ) from .mock_data import CLEAN_RECORD, CLEAN_SUMMARY, CONSUMABLE, DND_TIMER, HOME_DATA_RAW, STATUS, USER_DATA @@ -152,6 +154,7 @@ def test_status(): assert s.unsave_map_reason == 0 assert s.unsave_map_flag == 0 + def test_old_status(): s = StatusOldModes.from_dict(STATUS) assert isinstance(s.water_box_mode, OldRoborockMopIntensityCode) @@ -161,6 +164,7 @@ def test_old_status(): assert s.state == RoborockStateCode["8"] assert s.fan_power == OldRoborockFanPowerCode["102"] + def test_dnd_timer(): dnd = DNDTimer.from_dict(DND_TIMER) assert dnd.start_hour == 22 From b2f0295f33db926a646f638cc37dd29352004c63 Mon Sep 17 00:00:00 2001 From: Luke Date: Fri, 28 Apr 2023 12:40:03 -0400 Subject: [PATCH 03/17] fix: linting --- roborock/containers.py | 4 ++-- tests/test_containers.py | 3 --- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/roborock/containers.py b/roborock/containers.py index 109a1b7b..73e0589e 100644 --- a/roborock/containers.py +++ b/roborock/containers.py @@ -127,7 +127,7 @@ class HomeDataDeviceStatus(RoborockBase): id: Optional[Any] = None name: Optional[Any] = None code: Optional[Any] = None - model: Optional[Any] = None + model: Optional[str] = None icon_url: Optional[Any] = None attribute: Optional[Any] = None capability: Optional[Any] = None @@ -165,7 +165,7 @@ class HomeDataDevice(RoborockBase): uses_old_codes: bool = False def __post_init__(self): - if self.device_status.model == "roborock.vacuum.a10": + if self.device_status and self.device_status.model == "roborock.vacuum.a10": self.uses_old_codes = True diff --git a/tests/test_containers.py b/tests/test_containers.py index ab63117a..85859c49 100644 --- a/tests/test_containers.py +++ b/tests/test_containers.py @@ -1,7 +1,6 @@ from roborock import CleanRecord, CleanSummary, Consumable, DNDTimer, HomeData, Status, StatusOldModes, UserData from roborock.code_mappings import ( OldRoborockFanPowerCode, - OldRoborockMopIntensityCode, RoborockDockErrorCode, RoborockDockTypeCode, RoborockErrorCode, @@ -157,8 +156,6 @@ def test_status(): def test_old_status(): s = StatusOldModes.from_dict(STATUS) - assert isinstance(s.water_box_mode, OldRoborockMopIntensityCode) - assert isinstance(s.fan_power, OldRoborockFanPowerCode) assert s.msg_ver == 2 assert s.msg_seq == 458 assert s.state == RoborockStateCode["8"] From 1997c4e9f012953755fe67d89535bf2fb7667541 Mon Sep 17 00:00:00 2001 From: humbertogontijo Date: Fri, 28 Apr 2023 21:11:35 -0300 Subject: [PATCH 04/17] feat: using api for single device and adding new commands --- roborock/api.py | 57 +++-- roborock/cloud_api.py | 34 ++- roborock/local_api.py | 107 ++++----- roborock/roborock_message.py | 17 +- roborock/typing.py | 415 ++++++++++++++++++++++------------- 5 files changed, 373 insertions(+), 257 deletions(-) diff --git a/roborock/api.py b/roborock/api.py index 92f8034c..345cabc8 100644 --- a/roborock/api.py +++ b/roborock/api.py @@ -94,6 +94,9 @@ def __init__(self, endpoint: str, device_info: RoborockDeviceInfo) -> None: self._last_disconnection = self.time_func() self.keep_alive = KEEPALIVE + def __del__(self) -> None: + self.sync_disconnect() + @property def time_func(self) -> Callable[[], float]: try: @@ -103,10 +106,16 @@ def time_func(self) -> Callable[[], float]: time_func = time.time return time_func + async def async_connect(self): + raise NotImplementedError + + def sync_disconnect(self) -> Any: + raise NotImplementedError + async def async_disconnect(self) -> Any: raise NotImplementedError - def on_message(self, messages: list[RoborockMessage]) -> None: + def on_message_received(self, messages: list[RoborockMessage]) -> None: try: self._last_device_msg_in = self.time_func() for data in messages: @@ -118,24 +127,23 @@ def on_message(self, messages: list[RoborockMessage]) -> None: data_point_response = json.loads(data_point) request_id = data_point_response.get("id") queue = self._waiting_queue.get(request_id) - if queue: - if queue.protocol == protocol: - error = data_point_response.get("error") - if error: - queue.resolve( - ( - None, - VacuumError( - error.get("code"), - error.get("message"), - ), - ) + if queue and queue.protocol == protocol: + error = data_point_response.get("error") + if error: + queue.resolve( + ( + None, + VacuumError( + error.get("code"), + error.get("message"), + ), ) - else: - result = data_point_response.get("result") - if isinstance(result, list) and len(result) == 1: - result = result[0] - queue.resolve((result, None)) + ) + else: + result = data_point_response.get("result") + if isinstance(result, list) and len(result) == 1: + result = result[0] + queue.resolve((result, None)) elif protocol == 301: payload = data.payload[0:24] [endpoint, _, request_id, _] = struct.unpack("<15sBH6s", payload) @@ -149,10 +157,14 @@ def on_message(self, messages: list[RoborockMessage]) -> None: if isinstance(decrypted, list): decrypted = decrypted[0] queue.resolve((decrypted, None)) + else: + queue = self._waiting_queue.get(data.seq) + if queue: + queue.resolve((data.payload, None)) except Exception as ex: _LOGGER.exception(ex) - def on_disconnect(self, exc: Optional[Exception]) -> None: + def on_connection_lost(self, exc: Optional[Exception]) -> None: self._last_disconnection = self.time_func() _LOGGER.warning("Roborock client disconnected") if exc is not None: @@ -161,10 +173,15 @@ def on_disconnect(self, exc: Optional[Exception]) -> None: def should_keepalive(self) -> bool: now = self.time_func() # noinspection PyUnresolvedReferences - if now - self._last_disconnection > self.keep_alive**2 and now - self._last_device_msg_in > self.keep_alive: + if now - self._last_disconnection > self.keep_alive ** 2 and now - self._last_device_msg_in > self.keep_alive: return False return True + async def validate_connection(self) -> None: + if not self.should_keepalive(): + await self.async_disconnect() + await self.async_connect() + async def _async_response(self, request_id: int, protocol_id: int = 0) -> tuple[Any, VacuumError | None]: try: queue = RoborockFuture(protocol_id) diff --git a/roborock/cloud_api.py b/roborock/cloud_api.py index baa71aec..52da6b55 100644 --- a/roborock/cloud_api.py +++ b/roborock/cloud_api.py @@ -24,6 +24,7 @@ class RoborockMqttClient(RoborockClient, mqtt.Client): _thread: threading.Thread + _client_id: str def __init__(self, user_data: UserData, device_info: RoborockDeviceInfo) -> None: rriot = user_data.rriot @@ -37,7 +38,7 @@ def __init__(self, user_data: UserData, device_info: RoborockDeviceInfo) -> None url = urlparse(rriot.r.m) if not isinstance(url.hostname, str): raise RoborockException("Url parsing returned an invalid hostname") - self._mqtt_host = url.hostname + self._mqtt_host = str(url.hostname) self._mqtt_port = url.port self._mqtt_ssl = url.scheme == "ssl" if self._mqtt_ssl: @@ -50,10 +51,7 @@ def __init__(self, user_data: UserData, device_info: RoborockDeviceInfo) -> None self._mutex = Lock() self.update_client_id() - def __del__(self) -> None: - self.sync_disconnect() - - def on_connect(self, *args, **kwargs) -> None: + def on_connect(self, *args, **kwargs): _, __, ___, rc, ____ = args connection_queue = self._waiting_queue.get(CONNECT_REQUEST_ID) if rc != mqtt.MQTT_ERR_SUCCESS: @@ -75,15 +73,18 @@ def on_connect(self, *args, **kwargs) -> None: if connection_queue: connection_queue.resolve((True, None)) - def on_message(self, *args, **kwargs) -> None: + def on_message(self, *args, **kwargs): _, __, msg = args - messages, _ = RoborockParser.decode(msg.payload, self.device_info.device.local_key) - super().on_message(messages) + try: + messages, _ = RoborockParser.decode(msg.payload, self.device_info.device.local_key) + super().on_message_received(messages) + except Exception as ex: + _LOGGER.exception(ex) - def on_disconnect(self, *args, **kwargs) -> None: + def on_disconnect(self, *args, **kwargs): + _, __, rc, ___ = args try: - _, __, rc, ___ = args - super().on_disconnect(RoborockException(f"(rc: {rc})")) + super().on_connection_lost(RoborockException(f"(rc: {rc})")) if rc == mqtt.MQTT_ERR_PROTOCOL: self.update_client_id() connection_queue = self._waiting_queue.get(DISCONNECT_REQUEST_ID) @@ -95,12 +96,6 @@ def on_disconnect(self, *args, **kwargs) -> None: def update_client_id(self): self._client_id = mqtt.base62(uuid.uuid4().int, padding=22) - def _check_keepalive(self) -> None: - if not self.should_keepalive(): - self._ping_t = self.time_func() - KEEPALIVE - # noinspection PyUnresolvedReferences - super()._check_keepalive() # type: ignore[misc] - def sync_stop_loop(self) -> None: if self._thread: _LOGGER.info("Stopping mqtt loop") @@ -147,10 +142,7 @@ async def async_connect(self) -> None: if err: raise RoborockException(err) from err - async def validate_connection(self) -> None: - await self.async_connect() - - def _send_msg_raw(self, msg) -> None: + def _send_msg_raw(self, msg: bytes) -> None: info = self.publish(f"rr/m/i/{self._mqtt_user}/{self._hashed_user}/{self.device_info.device.duid}", msg) if info.rc != mqtt.MQTT_ERR_SUCCESS: raise RoborockException(f"Failed to publish (rc: {info.rc})") diff --git a/roborock/local_api.py b/roborock/local_api.py index 3eebd5e1..0c9ecca6 100644 --- a/roborock/local_api.py +++ b/roborock/local_api.py @@ -2,7 +2,6 @@ import asyncio import logging -import socket from asyncio import Lock, Transport from typing import Optional @@ -35,26 +34,34 @@ def data_received(self, message): self.remaining = b"" (parser_msg, remaining) = RoborockParser.decode(message, self.device_info.device.local_key) self.remaining = remaining - self.on_message(parser_msg) + self.on_message_received(parser_msg) def connection_lost(self, exc: Optional[Exception]): - self.on_disconnect(exc) + self.on_connection_lost(exc) def is_connected(self): return self.transport and self.transport.is_reading() async def async_connect(self) -> None: - try: - if not self.is_connected(): - async with async_timeout.timeout(QUEUE_TIMEOUT): - _LOGGER.info(f"Connecting to {self.ip}") - self.transport, _ = await self.loop.create_connection(lambda: self, self.ip, 58867) # type: ignore - except Exception as e: - raise RoborockConnectionException(f"Failed connecting to {self.ip}") from e + async with self._mutex: + try: + if not self.is_connected(): + async with async_timeout.timeout(QUEUE_TIMEOUT): + _LOGGER.info(f"Connecting to {self.ip}") + self.transport, _ = await self.loop.create_connection( + lambda: self, self.ip, 58867 + ) # type: ignore + _LOGGER.info(f"Connected to {self.ip}") + except Exception as e: + raise RoborockConnectionException(f"Failed connecting to {self.ip}") from e + + def sync_disconnect(self) -> None: + if self.transport and not self.loop.is_closed(): + self.transport.close() async def async_disconnect(self) -> None: - if self.transport: - self.transport.close() + async with self._mutex: + self.sync_disconnect() def build_roborock_message(self, method: RoborockCommand, params: Optional[list] = None) -> RoborockMessage: secured = True if method in SPECIAL_COMMANDS else False @@ -75,20 +82,30 @@ def build_roborock_message(self, method: RoborockCommand, params: Optional[list] payload=payload, ) - async def send_command(self, method: RoborockCommand, params: Optional[list] = None): + async def ping(self): + command_info = CommandInfoMap.get(None) + roborock_message = RoborockMessage( + prefix=command_info.prefix, + protocol=0, + payload=b'' + ) + return (await self.send_message(roborock_message))[0] + + async def send_command(self, method: RoborockCommand, params: Optional[list | dict] = None): roborock_message = self.build_roborock_message(method, params) return (await self.send_message(roborock_message))[0] async def async_local_response(self, roborock_message: RoborockMessage): request_id = roborock_message.get_request_id() - if request_id is not None: - # response_protocol = 5 if roborock_message.prefix == secured_prefix else 4 - response_protocol = 4 - (response, err) = await self._async_response(request_id, response_protocol) - if err: - raise CommandVacuumError("", err) from err - _LOGGER.debug(f"id={request_id} Response from {roborock_message.get_method()}: {response}") - return response + if request_id is None: + request_id = roborock_message.seq + # response_protocol = 5 if roborock_message.prefix == secured_prefix else 4 + response_protocol = 4 + (response, err) = await self._async_response(request_id, response_protocol) + if err: + raise CommandVacuumError("", err) from err + _LOGGER.debug(f"id={request_id} Response from {roborock_message.get_method()}: {response}") + return response def _send_msg_raw(self, data: bytes): try: @@ -99,33 +116,21 @@ def _send_msg_raw(self, data: bytes): raise RoborockException(e) from e async def send_message(self, roborock_messages: list[RoborockMessage] | RoborockMessage): - async with self._mutex: - await self.async_connect() - if isinstance(roborock_messages, RoborockMessage): - roborock_messages = [roborock_messages] - local_key = self.device_info.device.local_key - msg = RoborockParser.encode(roborock_messages, local_key) - # Send the command to the Roborock device - if not self.should_keepalive(): - await self.async_disconnect() - - _LOGGER.debug(f"Requesting device with {roborock_messages}") - self._send_msg_raw(msg) - - responses = await asyncio.gather( - *[self.async_local_response(roborock_message) for roborock_message in roborock_messages], - return_exceptions=True, - ) - exception = next((response for response in responses if isinstance(response, BaseException)), None) - if exception: - await self.async_disconnect() - raise exception - return responses - - -class RoborockSocket(socket.socket): - _closed = None - - @property - def is_closed(self): - return self._closed + await self.validate_connection() + if isinstance(roborock_messages, RoborockMessage): + roborock_messages = [roborock_messages] + local_key = self.device_info.device.local_key + msg = RoborockParser.encode(roborock_messages, local_key) + # Send the command to the Roborock device + _LOGGER.debug(f"Requesting device with {roborock_messages}") + self._send_msg_raw(msg) + + responses = await asyncio.gather( + *[self.async_local_response(roborock_message) for roborock_message in roborock_messages], + return_exceptions=True, + ) + exception = next((response for response in responses if isinstance(response, BaseException)), None) + if exception: + await self.async_disconnect() + raise exception + return responses diff --git a/roborock/roborock_message.py b/roborock/roborock_message.py index 57de8cdf..895aff57 100644 --- a/roborock/roborock_message.py +++ b/roborock/roborock_message.py @@ -52,7 +52,7 @@ def get_request_id(self) -> int | None: def get_method(self) -> RoborockCommand | None: protocol = self.protocol - if protocol in [4, 101, 102]: + if protocol in [4, 5, 101, 102]: payload = json.loads(self.payload.decode()) for data_point_number, data_point in payload.get("dps").items(): if data_point_number in ["101", "102"]: @@ -111,7 +111,7 @@ def decode(msg: bytes, local_key: str, index=0) -> tuple[list[RoborockMessage], prefix = b"" original_index = index if len(msg) - index < 17: - ## broken message + # broken message return [], msg[original_index:] if msg[index + 4 : index + 7] == "1.0".encode(): @@ -119,7 +119,8 @@ def decode(msg: bytes, local_key: str, index=0) -> tuple[list[RoborockMessage], index += 4 elif msg[index : index + 3] != "1.0".encode(): raise RoborockException(f"Unknown protocol version {msg[0:3]!r}") - if len(msg) - index in [17]: + message_size = len(msg) - index + if message_size == 17: [version, request_id, random, timestamp, protocol] = struct.unpack_from("!3sIIIH", msg, index) return [ RoborockMessage( @@ -134,9 +135,10 @@ def decode(msg: bytes, local_key: str, index=0) -> tuple[list[RoborockMessage], ], b"" if len(msg) - index < 19: - ## broken message + # broken message return [], msg[original_index:] + _format = "!3sIIIHH" [ version, request_id, @@ -144,8 +146,9 @@ def decode(msg: bytes, local_key: str, index=0) -> tuple[list[RoborockMessage], timestamp, protocol, payload_len, - ] = struct.unpack_from("!3sIIIHH", msg, index) - index += 19 + ] = struct.unpack_from(_format, msg, index) + format_size = struct.calcsize(_format) + index += format_size if payload_len + index + 4 > len(msg): ## broken message @@ -156,7 +159,7 @@ def decode(msg: bytes, local_key: str, index=0) -> tuple[list[RoborockMessage], index += 2 else: [payload, expected_crc32] = struct.unpack_from(f"!{payload_len}sI", msg, index) - crc32 = binascii.crc32(msg[index - 19 : index + payload_len]) + crc32 = binascii.crc32(msg[index - format_size : index + payload_len]) index += 4 + payload_len if crc32 != expected_crc32: raise RoborockException(f"Wrong CRC32 {crc32}, expected {expected_crc32}") diff --git a/roborock/typing.py b/roborock/typing.py index 6a23d202..72f7f01b 100644 --- a/roborock/typing.py +++ b/roborock/typing.py @@ -17,192 +17,291 @@ class RoborockCommand(str, Enum): - GET_PROP = "get_prop" - GET_MAP_V1 = "get_map_v1" - GET_STATUS = "get_status" - GET_DND_TIMER = "get_dnd_timer" - GET_CLEAN_SUMMARY = "get_clean_summary" - GET_CLEAN_RECORD = "get_clean_record" - GET_CONSUMABLE = "get_consumable" - GET_MULTI_MAPS_LIST = "get_multi_maps_list" - APP_START = "app_start" - APP_PAUSE = "app_pause" - APP_STOP = "app_stop" APP_CHARGE = "app_charge" - APP_SPOT = "app_spot" - FIND_ME = "find_me" - RESUME_ZONED_CLEAN = "resume_zoned_clean" - RESUME_SEGMENT_CLEAN = "resume_segment_clean" - SET_CUSTOM_MODE = "set_custom_mode" - SET_MOP_MODE = "set_mop_mode" - SET_WATER_BOX_CUSTOM_MODE = "set_water_box_custom_mode" - RESET_CONSUMABLE = "reset_consumable" - LOAD_MULTI_MAP = "load_multi_map" - APP_RC_START = "app_rc_start" + APP_GET_DRYER_SETTING = "app_get_dryer_setting" + APP_GET_INIT_STATUS = "app_get_init_status" + APP_GOTO_TARGET = "app_goto_target" + APP_PAUSE = "app_pause" APP_RC_END = "app_rc_end" APP_RC_MOVE = "app_rc_move" - APP_GOTO_TARGET = "app_goto_target" + APP_RC_START = "app_rc_start" + APP_RC_STOP = "APP_RC_STOP" APP_SEGMENT_CLEAN = "app_segment_clean" - APP_ZONED_CLEAN = "app_zoned_clean" - APP_GET_DRYER_SETTING = "app_get_dryer_setting" APP_SET_DRYER_SETTING = "app_set_dryer_setting" + APP_SET_SMART_CLIFF_FORBIDDEN = "app_set_smart_cliff_forbidden" + APP_SPOT = "app_spot" + APP_START = "app_start" APP_START_WASH = "app_start_wash" + APP_STAT = "app_stat" + APP_STOP = "app_stop" APP_STOP_WASH = "app_stop_wash" - GET_DUST_COLLECTION_MODE = "get_dust_collection_mode" - SET_DUST_COLLECTION_MODE = "set_dust_collection_mode" - GET_SMART_WASH_PARAMS = "get_smart_wash_params" - SET_SMART_WASH_PARAMS = "set_smart_wash_params" - GET_WASH_TOWEL_MODE = "get_wash_towel_mode" - SET_WASH_TOWEL_MODE = "set_wash_towel_mode" - SET_CHILD_LOCK_STATUS = "set_child_lock_status" + APP_WAKEUP_ROBOT = "app_wakeup_robot" + APP_ZONED_CLEAN = "app_zoned_clean" + CHANGE_SOUND_VOLUME = "change_sound_volume" + CLOSE_DND_TIMER = "close_dnd_timer" + CLOSE_VALLEY_ELECTRICITY_TIMER = "close_valley_electricity_timer" + DNLD_INSTALL_SOUND = "dnld_install_sound" + ENABLE_LOG_UPLOAD = "enable_log_upload" + END_EDIT_MAP = "end_edit_map" + FIND_ME = "find_me" + GET_CAMERA_STATUS = "get_camera_status" + GET_CARPET_CLEAN_MODE = "get_carpet_clean_mode" + GET_CARPET_MODE = "get_carpet_mode" GET_CHILD_LOCK_STATUS = "get_child_lock_status" - START_WASH_THEN_CHARGE = "start_wash_then_charge" + GET_CLEAN_RECORD = "get_clean_record" + GET_CLEAN_RECORD_MAP = "get_clean_record_map" + GET_CLEAN_SEQUENCE = "get_clean_sequence" + GET_CLEAN_SUMMARY = "get_clean_summary" + GET_COLLISION_AVOID_STATUS = "get_collision_avoid_status" + GET_CONSUMABLE = "get_consumable" GET_CURRENT_SOUND = "get_current_sound" - GET_SERIAL_NUMBER = "get_serial_number" - GET_TIMEZONE = "get_timezone" - GET_SERVER_TIMER = "get_server_timer" GET_CUSTOMIZE_CLEAN_MODE = "get_customize_clean_mode" - GET_CLEAN_SEQUENCE = "get_clean_sequence" - SET_FDS_ENDPOINT = "set_fds_endpoint" # Seems to be logging server - ENABLE_LOG_UPLOAD = "enable_log_upload" - APP_WAKEUP_ROBOT = "app_wakeup_robot" - GET_LED_STATUS = "get_led_status" + GET_CUSTOM_MODE = "get_custom_mode" + GET_DEVICE_ICE = "get_device_ice" + GET_DEVICE_SDP = "get_device_sdp" + GET_DND_TIMER = "get_dnd_timer" + GET_DUST_COLLECTION_MODE = "get_dust_collection_mode" GET_FLOW_LED_STATUS = "get_flow_led_status" - SET_FLOW_LED_STATUS = "set_flow_led_status" + GET_HOMESEC_CONNECT_STATUS = "get_homesec_connect_status" + GET_IDENTIFY_FURNITURE_STATUS = "get_identify_furniture_status" + GET_IDENTIFY_GROUND_MATERIAL_STATUS = "get_identify_ground_material_status" + GET_LED_STATUS = "get_led_status" + GET_MAP_V1 = "get_map_v1" + GET_MOP_TEMPLATE_PARAMS_SUMMARY = "get_mop_template_params_summary" + GET_MULTI_MAP = "get_multi_map" + GET_MULTI_MAPS_LIST = "get_multi_maps_list" + GET_NETWORK_INFO = "get_network_info" + GET_PROP = "get_prop" + GET_ROOM_MAPPING = "get_room_mapping" + GET_SCENES_VALID_TIDS = "get_scenes_valid_tids" + GET_SERIAL_NUMBER = "get_serial_number" + GET_SERVER_TIMER = "get_server_timer" + GET_SMART_WASH_PARAMS = "get_smart_wash_params" GET_SOUND_PROGRESS = "get_sound_progress" GET_SOUND_VOLUME = "get_sound_volume" - TEST_SOUND_VOLUME = "test_sound_volume" - CHANGE_SOUND_VOLUME = "change_sound_volume" - GET_CARPET_MODE = "get_carpet_mode" - SET_CARPET_MODE = "set_carpet_mode" - GET_CARPET_CLEAN_MODE = "get_carpet_clean_mode" - SET_CARPET_CLEAN_MODE = "set_carpet_clean_mode" - UPD_SERVER_TIMER = "upd_server_timer" # Server timer seems to be with schedules - SET_SERVER_TIMER = "set_server_timer" + GET_STATUS = "get_status" + GET_TIMEZONE = "get_timezone" + GET_TURN_SERVER = "get_turn_server" + GET_VALLEY_ELECTRICITY_TIMER = "get_valley_electricity_timer" + GET_WASH_TOWEL_MODE = "get_wash_towel_mode" + LOAD_MULTI_MAP = "load_multi_map" + NAME_SEGMENT = "name_segment" + RESET_CONSUMABLE = "reset_consumable" + RESUME_SEGMENT_CLEAN = "resume_segment_clean" + RESUME_ZONED_CLEAN = "resume_zoned_clean" + RETRY_REQUEST = "retry_request" + SAVE_MAP = "save_map" + SEND_ICE_TO_ROBOT = "send_ice_to_robot" + SEND_SDP_TO_ROBOT = "send_sdp_to_robot" SET_APP_TIMEZONE = "set_app_timezone" - GET_NETWORK_INFO = "get_network_info" - GET_IDENTIFY_FURNITURE_STATUS = "get_identify_furniture_status" SET_CAMERA_STATUS = "set_camera_status" - SET_DND_TIMER = "set_dnd_timer" - GET_COLLISION_AVOID_STATUS = "get_collision_avoid_status" - CLOSE_VALLEY_ELECTRICITY_TIMER = "close_valley_electricity_timer" - GET_VALLEY_ELECTRICITY_TIMER = "get_valley_electricity_timer" + SET_CARPET_CLEAN_MODE = "set_carpet_clean_mode" + SET_CARPET_MODE = "set_carpet_mode" + SET_CHILD_LOCK_STATUS = "set_child_lock_status" SET_CLEAN_MOTOR_MODE = "set_clean_motor_mode" - SET_LED_STATUS = "set_led_status" - GET_CAMERA_STATUS = "get_camera_status" - CLOSE_DND_TIMER = "close_dnd_timer" SET_COLLISION_AVOID_STATUS = "set_collision_avoid_status" - SET_IDENTIFY_GROUND_MATERIAL_STATUS = "set_identify_ground_material_status" - GET_IDENTIFY_GROUND_MATERIAL_STATUS = "get_identify_ground_material_status" - SET_VALLEY_ELECTRICITY_TIMER = "set_valley_electricity_timer" - SWITCH_WATER_MARK = "switch_water_mark" + SET_CUSTOMIZE_CLEAN_MODE = "set_customize_clean_mode" + SET_CUSTOM_MODE = "set_custom_mode" + SET_DND_TIMER = "set_dnd_timer" + SET_DUST_COLLECTION_MODE = "set_dust_collection_mode" + SET_FDS_ENDPOINT = "set_fds_endpoint" + SET_FLOW_LED_STATUS = "set_flow_led_status" SET_IDENTIFY_FURNITURE_STATUS = "set_identify_furniture_status" - GET_CLEAN_RECORD_MAP = "get_clean_record_map" - GET_ROOM_MAPPING = "get_room_mapping" - NAME_SEGMENT = "name_segment" + SET_IDENTIFY_GROUND_MATERIAL_STATUS = "set_identify_ground_material_status" + SET_LED_STATUS = "set_led_status" + SET_MOP_MODE = "set_mop_mode" + SET_SERVER_TIMER = "set_server_timer" + SET_SMART_WASH_PARAMS = "set_smart_wash_params" SET_TIMEZONE = "set_timezone" - GET_HOMESEC_CONNECT_STATUS = "get_homesec_connect_status" + SET_VALLEY_ELECTRICITY_TIMER = "set_valley_electricity_timer" + SET_WASH_TOWEL_MODE = "set_wash_towel_mode" + SET_WATER_BOX_CUSTOM_MODE = "set_water_box_custom_mode" START_CAMERA_PREVIEW = "start_camera_preview" - GET_TURN_SERVER = "get_turn_server" - GET_DEVICE_ICE = "get_device_ice" + START_EDIT_MAP = "start_edit_map" START_VOICE_CHAT = "start_voice_chat" - SEND_SDP_TO_ROBOT = "send_sdp_to_robot" + START_WASH_THEN_CHARGE = "start_wash_then_charge" + STOP_CAMERA_PREVIEW = "stop_camera_preview" + SWITCH_WATER_MARK = "switch_water_mark" + TEST_SOUND_VOLUME = "test_sound_volume" + UPD_SERVER_TIMER = "upd_server_timer" @dataclass class CommandInfo: prefix: bytes + params: Optional[list | dict] = None -CommandInfoMap: dict[RoborockCommand, CommandInfo] = { - RoborockCommand.GET_PROP: CommandInfo(prefix=b"\x00\x00\x00\x87"), - RoborockCommand.GET_STATUS: CommandInfo(prefix=b"\x00\x00\x00\x77"), - RoborockCommand.SET_CUSTOM_MODE: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.GET_CHILD_LOCK_STATUS: CommandInfo(prefix=b"\x00\x00\x00\x87"), - RoborockCommand.GET_MULTI_MAPS_LIST: CommandInfo(prefix=b"\x00\x00\x00\x87"), - RoborockCommand.GET_IDENTIFY_FURNITURE_STATUS: CommandInfo(prefix=b"\x00\x00\x00\x87"), - RoborockCommand.SET_WATER_BOX_CUSTOM_MODE: CommandInfo(prefix=b"\x00\x00\x00\x87"), - RoborockCommand.GET_CLEAN_SEQUENCE: CommandInfo(prefix=b"\x00\x00\x00\x87"), - RoborockCommand.GET_CUSTOMIZE_CLEAN_MODE: CommandInfo(prefix=b"\x00\x00\x00\x87"), - RoborockCommand.GET_CARPET_CLEAN_MODE: CommandInfo(prefix=b"\x00\x00\x00\x87"), - RoborockCommand.SET_CAMERA_STATUS: CommandInfo(prefix=b"\x00\x00\x00\x87"), - RoborockCommand.SET_DND_TIMER: CommandInfo(prefix=b"\x00\x00\x00\x87"), - RoborockCommand.GET_FLOW_LED_STATUS: CommandInfo(prefix=b"\x00\x00\x00\x87"), - RoborockCommand.GET_COLLISION_AVOID_STATUS: CommandInfo(prefix=b"\x00\x00\x00\x87"), - RoborockCommand.CLOSE_VALLEY_ELECTRICITY_TIMER: CommandInfo(prefix=b"\x00\x00\x00\x87"), - RoborockCommand.SET_FLOW_LED_STATUS: CommandInfo(prefix=b"\x00\x00\x00\x87"), - RoborockCommand.GET_VALLEY_ELECTRICITY_TIMER: CommandInfo(prefix=b"\x00\x00\x00\x87"), - RoborockCommand.GET_CLEAN_RECORD: CommandInfo(prefix=b"\x00\x00\x00\x87"), - RoborockCommand.GET_MAP_V1: CommandInfo(prefix=b"\x00\x00\x00\xc7"), - RoborockCommand.SET_CLEAN_MOTOR_MODE: CommandInfo(prefix=b"\x00\x00\x00\xb7"), - RoborockCommand.GET_CONSUMABLE: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.GET_SERVER_TIMER: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.GET_SERIAL_NUMBER: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.GET_CURRENT_SOUND: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.SET_LED_STATUS: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.GET_CAMERA_STATUS: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.APP_PAUSE: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.GET_CLEAN_SUMMARY: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.GET_NETWORK_INFO: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.GET_LED_STATUS: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.CLOSE_DND_TIMER: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.APP_WAKEUP_ROBOT: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.SET_MOP_MODE: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.GET_DND_TIMER: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.GET_CARPET_MODE: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.GET_TIMEZONE: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.SET_CARPET_MODE: CommandInfo(prefix=b"\x00\x00\x00\xd7"), - RoborockCommand.SET_COLLISION_AVOID_STATUS: CommandInfo(prefix=b"\x00\x00\x00\x97"), - RoborockCommand.SET_CARPET_CLEAN_MODE: CommandInfo(prefix=b"\x00\x00\x00\x97"), - RoborockCommand.SET_IDENTIFY_GROUND_MATERIAL_STATUS: CommandInfo(prefix=b"\x00\x00\x00\x97"), - RoborockCommand.GET_IDENTIFY_GROUND_MATERIAL_STATUS: CommandInfo(prefix=b"\x00\x00\x00\x97"), - RoborockCommand.SET_VALLEY_ELECTRICITY_TIMER: CommandInfo(prefix=b"\x00\x00\x00\x97"), - RoborockCommand.SWITCH_WATER_MARK: CommandInfo(prefix=b"\x00\x00\x00\x97"), - RoborockCommand.SET_IDENTIFY_FURNITURE_STATUS: CommandInfo(prefix=b"\x00\x00\x00\x97"), - RoborockCommand.SET_CHILD_LOCK_STATUS: CommandInfo(prefix=b"\x00\x00\x00\x97"), - RoborockCommand.GET_CLEAN_RECORD_MAP: CommandInfo(prefix=b"\x00\x00\x00\xe7"), - RoborockCommand.APP_START: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.APP_STOP: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.APP_CHARGE: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.APP_SPOT: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.FIND_ME: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.RESUME_ZONED_CLEAN: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.RESUME_SEGMENT_CLEAN: CommandInfo(prefix=b"\x00\x00\x00\x87"), - RoborockCommand.RESET_CONSUMABLE: CommandInfo(prefix=b"\x00\x00\x00\x97"), - RoborockCommand.LOAD_MULTI_MAP: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.APP_RC_START: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.APP_RC_END: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.APP_RC_MOVE: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.APP_GOTO_TARGET: CommandInfo(prefix=b"\x00\x00\x00\x87"), - RoborockCommand.APP_SEGMENT_CLEAN: CommandInfo(prefix=b"\x00\x00\x00\x87"), - RoborockCommand.APP_ZONED_CLEAN: CommandInfo(prefix=b"\x00\x00\x00\x97"), - RoborockCommand.APP_START_WASH: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.APP_STOP_WASH: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.SET_FDS_ENDPOINT: CommandInfo(prefix=b"\x00\x00\x00\x97"), - RoborockCommand.ENABLE_LOG_UPLOAD: CommandInfo(prefix=b"\x00\x00\x87"), - RoborockCommand.GET_SOUND_VOLUME: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.TEST_SOUND_VOLUME: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.UPD_SERVER_TIMER: CommandInfo(prefix=b"\x00\x00\x00\x97"), - RoborockCommand.SET_APP_TIMEZONE: CommandInfo(prefix=b"\x00\x00\x00\x97"), - RoborockCommand.CHANGE_SOUND_VOLUME: CommandInfo(prefix=b"\x00\x00\x00\x87"), - RoborockCommand.GET_SOUND_PROGRESS: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.SET_SERVER_TIMER: CommandInfo(prefix=b"\x00\x00\x00\xc7"), - RoborockCommand.GET_ROOM_MAPPING: CommandInfo(prefix=b"\x00\x00\x00w"), - RoborockCommand.NAME_SEGMENT: CommandInfo(prefix=b"\x00\x00\x027"), - RoborockCommand.SET_TIMEZONE: CommandInfo(prefix=b"\x00\x00\x00\x97"), - RoborockCommand.GET_HOMESEC_CONNECT_STATUS: CommandInfo(prefix=b"\x00\x00\x00\x87"), - RoborockCommand.START_CAMERA_PREVIEW: CommandInfo(prefix=b"\x00\x00\x00\x87"), - RoborockCommand.GET_TURN_SERVER: CommandInfo(prefix=b"\x00\x00\x00\x77"), - RoborockCommand.GET_DEVICE_ICE: CommandInfo(prefix=b"\x00\x00\x00\x77"), - RoborockCommand.GET_DUST_COLLECTION_MODE: CommandInfo(prefix=b"\x00\x00\x00\x87"), - RoborockCommand.APP_GET_DRYER_SETTING: CommandInfo(prefix=b"\x00\x00\x00\x87"), - RoborockCommand.APP_SET_DRYER_SETTING: CommandInfo(prefix=b"\x00\x00\x00\x87"), - RoborockCommand.SET_DUST_COLLECTION_MODE: CommandInfo(prefix=b"\x00\x00\x00\x87"), - RoborockCommand.GET_SMART_WASH_PARAMS: CommandInfo(prefix=b"\x00\x00\x00\x87"), - RoborockCommand.SET_SMART_WASH_PARAMS: CommandInfo(prefix=b"\x00\x00\x00\x87"), - RoborockCommand.GET_WASH_TOWEL_MODE: CommandInfo(prefix=b"\x00\x00\x00\x87"), - RoborockCommand.SET_WASH_TOWEL_MODE: CommandInfo(prefix=b"\x00\x00\x00\x87"), - RoborockCommand.START_WASH_THEN_CHARGE: CommandInfo(prefix=b"\x00\x00\x00\x87"), +CommandInfoMap: dict[RoborockCommand | None, CommandInfo] = { + None: CommandInfo(prefix=b"\x00\x00\x00\x15", params=None), + RoborockCommand.APP_CHARGE: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), + RoborockCommand.APP_GET_DRYER_SETTING: CommandInfo(prefix=b"\x00\x00\x00\x87", params=None), + RoborockCommand.APP_GET_INIT_STATUS: CommandInfo(prefix=b"\x00\x00\x00\x87", params=[]), + RoborockCommand.APP_GOTO_TARGET: CommandInfo(prefix=b"\x00\x00\x00\x87", params=[25000, 24850]), + RoborockCommand.APP_PAUSE: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), + RoborockCommand.APP_RC_END: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), + RoborockCommand.APP_RC_MOVE: CommandInfo(prefix=b"\x00\x00\x00w", params=None), + RoborockCommand.APP_RC_START: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), + RoborockCommand.APP_RC_STOP: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), + RoborockCommand.APP_SEGMENT_CLEAN: CommandInfo(prefix=b"\x00\x00\x00\x87", params=None), + RoborockCommand.APP_SET_DRYER_SETTING: CommandInfo(prefix=b"\x00\x00\x00\x87", params=None), + RoborockCommand.APP_SET_SMART_CLIFF_FORBIDDEN: CommandInfo( + prefix=b"\x00\x00\x00\xa7", params={"zones": [], "map_index": 0} + ), + RoborockCommand.APP_SPOT: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), + RoborockCommand.APP_START: CommandInfo(prefix=b"\x00\x00\x00\x87", params=[{"use_new_map": 1}]), + RoborockCommand.APP_START_WASH: CommandInfo(prefix=b"\x00\x00\x00w", params=None), + RoborockCommand.APP_STAT: CommandInfo( + prefix=b"\x00\x00\x01\xa7", + params=[ + { + "ver": "0.1", + "data": [ + { + "times": [1682723478], + "data": { + "region": "America/Sao_Paulo", + "pluginVersion": "2820", + "mnc": "*", + "os": "ios", + "osVersion": "16.1", + "mcc": "not-cn", + "language": "en_BR", + "mobileBrand": "*", + "appType": "roborock", + "mobileModel": "iPhone13,1", + }, + "type": 2, + } + ], + } + ], + ), + RoborockCommand.APP_STOP: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), + RoborockCommand.APP_STOP_WASH: CommandInfo(prefix=b"\x00\x00\x00w", params=None), + RoborockCommand.APP_WAKEUP_ROBOT: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), + RoborockCommand.APP_ZONED_CLEAN: CommandInfo( + prefix=b"\x00\x00\x00\x97", params=[[24900, 25100, 26300, 26450, 1]] + ), + RoborockCommand.CHANGE_SOUND_VOLUME: CommandInfo(prefix=b"\x00\x00\x00\x87", params=None), + RoborockCommand.CLOSE_DND_TIMER: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), + RoborockCommand.CLOSE_VALLEY_ELECTRICITY_TIMER: CommandInfo(prefix=b"\x00\x00\x00\x87", params=[]), + RoborockCommand.DNLD_INSTALL_SOUND: CommandInfo( + prefix=b"\x00\x00\x00\xf7", + params={"url": "https://awsusor0.fds.api.xiaomi.com/app/topazsv/voice-pkg/package/en.pkg", "sid": 3, "sver": 5}, + ), + RoborockCommand.ENABLE_LOG_UPLOAD: CommandInfo(prefix=b"\x00\x00\x00\x87", params=[9, 2]), + RoborockCommand.END_EDIT_MAP: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), + RoborockCommand.FIND_ME: CommandInfo(prefix=b"\x00\x00\x00w", params=None), + RoborockCommand.GET_CAMERA_STATUS: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), + RoborockCommand.GET_CARPET_CLEAN_MODE: CommandInfo(prefix=b"\x00\x00\x00\x87", params=[]), + RoborockCommand.GET_CARPET_MODE: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), + RoborockCommand.GET_CHILD_LOCK_STATUS: CommandInfo(prefix=b"\x00\x00\x00\x87", params=[]), + RoborockCommand.GET_CLEAN_RECORD: CommandInfo(prefix=b"\x00\x00\x00\x87", params=[1682257961]), + RoborockCommand.GET_CLEAN_RECORD_MAP: CommandInfo(prefix=b"\x00\x00\x00\xe7", params={"start_time": 1682597877}), + RoborockCommand.GET_CLEAN_SEQUENCE: CommandInfo(prefix=b"\x00\x00\x00\x87", params=[]), + RoborockCommand.GET_CLEAN_SUMMARY: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), + RoborockCommand.GET_COLLISION_AVOID_STATUS: CommandInfo(prefix=b"\x00\x00\x00\x87", params=[]), + RoborockCommand.GET_CONSUMABLE: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), + RoborockCommand.GET_CURRENT_SOUND: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), + RoborockCommand.GET_CUSTOMIZE_CLEAN_MODE: CommandInfo(prefix=b"\x00\x00\x00\x87", params=[]), + RoborockCommand.GET_CUSTOM_MODE: CommandInfo(prefix=b"\x00\x00\x00w", params=None), + RoborockCommand.GET_DEVICE_ICE: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), + RoborockCommand.GET_DEVICE_SDP: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), + RoborockCommand.GET_DND_TIMER: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), + RoborockCommand.GET_DUST_COLLECTION_MODE: CommandInfo(prefix=b"\x00\x00\x00\x87", params=None), + RoborockCommand.GET_FLOW_LED_STATUS: CommandInfo(prefix=b"\x00\x00\x00\x87", params=[]), + RoborockCommand.GET_HOMESEC_CONNECT_STATUS: CommandInfo(prefix=b"\x00\x00\x00\x87", params=[]), + RoborockCommand.GET_IDENTIFY_FURNITURE_STATUS: CommandInfo(prefix=b"\x00\x00\x00\x87", params=[]), + RoborockCommand.GET_IDENTIFY_GROUND_MATERIAL_STATUS: CommandInfo(prefix=b"\x00\x00\x00\x97", params=[]), + RoborockCommand.GET_LED_STATUS: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), + RoborockCommand.GET_MAP_V1: CommandInfo(prefix=b"\x00\x00\x00\xc7", params={}), + RoborockCommand.GET_MOP_TEMPLATE_PARAMS_SUMMARY: CommandInfo(prefix=b"\x00\x00\x00\x87", params={}), + RoborockCommand.GET_MULTI_MAP: CommandInfo(prefix=b"\x00\x00\x00\xd7", params={"map_index": 0}), + RoborockCommand.GET_MULTI_MAPS_LIST: CommandInfo(prefix=b"\x00\x00\x00\x87", params=[]), + RoborockCommand.GET_NETWORK_INFO: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), + RoborockCommand.GET_PROP: CommandInfo(prefix=b"\x00\x00\x00\x87", params=["get_status"]), + RoborockCommand.GET_ROOM_MAPPING: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), + RoborockCommand.GET_SCENES_VALID_TIDS: CommandInfo(prefix=b"\x00\x00\x00\x87", params={}), + RoborockCommand.GET_SERIAL_NUMBER: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), + RoborockCommand.GET_SERVER_TIMER: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), + RoborockCommand.GET_SMART_WASH_PARAMS: CommandInfo(prefix=b"\x00\x00\x00\x87", params=None), + RoborockCommand.GET_SOUND_PROGRESS: CommandInfo(prefix=b"\x00\x00\x00\x87", params=[]), + RoborockCommand.GET_SOUND_VOLUME: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), + RoborockCommand.GET_STATUS: CommandInfo(prefix=b"\x00\x00\x00w", params=None), + RoborockCommand.GET_TIMEZONE: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), + RoborockCommand.GET_TURN_SERVER: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), + RoborockCommand.GET_VALLEY_ELECTRICITY_TIMER: CommandInfo(prefix=b"\x00\x00\x00\x87", params=[]), + RoborockCommand.GET_WASH_TOWEL_MODE: CommandInfo(prefix=b"\x00\x00\x00\x87", params=None), + RoborockCommand.LOAD_MULTI_MAP: CommandInfo(prefix=b"\x00\x00\x00w", params=None), + RoborockCommand.NAME_SEGMENT: CommandInfo(prefix=b"\x00\x00\x027", params=None), + RoborockCommand.RESET_CONSUMABLE: CommandInfo(prefix=b"\x00\x00\x00\x97", params=None), + RoborockCommand.RESUME_SEGMENT_CLEAN: CommandInfo(prefix=b"\x00\x00\x00\x87", params=None), + RoborockCommand.RESUME_ZONED_CLEAN: CommandInfo(prefix=b"\x00\x00\x00w", params=None), + RoborockCommand.RETRY_REQUEST: CommandInfo( + prefix=b"\x00\x00\x00\xb7", params={"retry_id": 439374, "retry_count": 8, "method": "save_map"} + ), + RoborockCommand.SAVE_MAP: CommandInfo( + prefix=b"\x00\x00\x01\x17", + params={ + "data": [ + [1, 25043, 24952, 26167, 24952], + [0, 25043, 25514, 26167, 25514, 26167, 24390, 25043, 24390], + [2, 25038, 26782, 26162, 26782, 26162, 25658, 25038, 25658], + [100, 0], + ], + "need_retry": 1, + }, + ), + RoborockCommand.SEND_ICE_TO_ROBOT: CommandInfo( + prefix=b"\x00\x00\x01\x97", + params={ + "app_ice": "eyJjYW5kaWRhdGUiOiAiY2FuZGlkYXRlOjE1MzE5NzE5NTEgMSB1ZHAgNDE4MTk5MDMgNTQuMTc0LjE4Ni4yNDkgNTQxNzU" + "gdHlwIHJlbGF5IHJhZGRyIDE3Ny4xOC4xMzQuOTkgcnBvcnQgNjQ2OTEgZ2VuZXJhdGlvbiAwIHVmcmFnIDVOMVogbmV0d2" + "9yay1pZCAxIG5ldHdvcmstY29zdCAxMCIsICJzZHBNTGluZUluZGV4IjogMSwgInNkcE1pZCI6ICIxIn0=" + }, + ), + RoborockCommand.SET_APP_TIMEZONE: CommandInfo(prefix=b"\x00\x00\x00\x97", params=["America/Sao_Paulo", 2]), + RoborockCommand.SET_CAMERA_STATUS: CommandInfo(prefix=b"\x00\x00\x00\x87", params=[3493]), + RoborockCommand.SET_CARPET_CLEAN_MODE: CommandInfo(prefix=b"\x00\x00\x00\x97", params={"carpet_clean_mode": 0}), + RoborockCommand.SET_CARPET_MODE: CommandInfo( + prefix=b"\x00\x00\x00\xd7", + params=[{"enable": 1, "current_high": 500, "current_integral": 450, "current_low": 400, "stall_time": 10}], + ), + RoborockCommand.SET_CHILD_LOCK_STATUS: CommandInfo(prefix=b"\x00\x00\x00\x97", params={"lock_status": 0}), + RoborockCommand.SET_CLEAN_MOTOR_MODE: CommandInfo( + prefix=b"\x00\x00\x00\xb7", params=[{"fan_power": 106, "mop_mode": 302, "water_box_mode": 204}] + ), + RoborockCommand.SET_COLLISION_AVOID_STATUS: CommandInfo(prefix=b"\x00\x00\x00\x97", params={"status": 1}), + RoborockCommand.SET_CUSTOMIZE_CLEAN_MODE: CommandInfo( + prefix=b"\x00\x00\x00\xa7", params={"data": [], "need_retry": 1} + ), + RoborockCommand.SET_CUSTOM_MODE: CommandInfo(prefix=b"\x00\x00\x00\x87", params=[108]), + RoborockCommand.SET_DND_TIMER: CommandInfo(prefix=b"\x00\x00\x00\x87", params=[22, 0, 8, 0]), + RoborockCommand.SET_DUST_COLLECTION_MODE: CommandInfo(prefix=b"\x00\x00\x00\x87", params=None), + RoborockCommand.SET_FDS_ENDPOINT: CommandInfo(prefix=b"\x00\x00\x00\x97", params=["awsusor0.fds.api.xiaomi.com"]), + RoborockCommand.SET_FLOW_LED_STATUS: CommandInfo(prefix=b"\x00\x00\x00\x87", params={"status": 1}), + RoborockCommand.SET_IDENTIFY_FURNITURE_STATUS: CommandInfo(prefix=b"\x00\x00\x00\x97", params={"status": 1}), + RoborockCommand.SET_IDENTIFY_GROUND_MATERIAL_STATUS: CommandInfo( + prefix=b"\x00\x00\x00\x97", params={"status": 1} + ), + RoborockCommand.SET_LED_STATUS: CommandInfo(prefix=b"\x00\x00\x00w", params=[1]), + RoborockCommand.SET_MOP_MODE: CommandInfo(prefix=b"\x00\x00\x00w", params=None), + RoborockCommand.SET_SERVER_TIMER: CommandInfo(prefix=b"\x00\x00\x00\xc7", params=None), + RoborockCommand.SET_SMART_WASH_PARAMS: CommandInfo(prefix=b"\x00\x00\x00\x87", params=None), + RoborockCommand.SET_TIMEZONE: CommandInfo(prefix=b"\x00\x00\x00\x97", params=["America/Sao_Paulo"]), + RoborockCommand.SET_VALLEY_ELECTRICITY_TIMER: CommandInfo(prefix=b"\x00\x00\x00\x97", params=[0, 0, 8, 0]), + RoborockCommand.SET_WASH_TOWEL_MODE: CommandInfo(prefix=b"\x00\x00\x00\x87", params=None), + RoborockCommand.SET_WATER_BOX_CUSTOM_MODE: CommandInfo(prefix=b"\x00\x00\x00\x87", params=[203]), + RoborockCommand.START_CAMERA_PREVIEW: CommandInfo( + prefix=b"\x00\x00\x00\xa7", params={"client_id": "443f8636", "quality": "SD"} + ), + RoborockCommand.START_EDIT_MAP: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), + RoborockCommand.START_WASH_THEN_CHARGE: CommandInfo(prefix=b"\x00\x00\x00\x87", params=None), + RoborockCommand.STOP_CAMERA_PREVIEW: CommandInfo(prefix=b"\x00\x00\x00\x97", params={"client_id": "443f8636"}), + RoborockCommand.SWITCH_WATER_MARK: CommandInfo(prefix=b"\x00\x00\x00\x97", params={"waterMark": "OFF"}), + RoborockCommand.TEST_SOUND_VOLUME: CommandInfo(prefix=b"\x00\x00\x00w", params=None), + RoborockCommand.UPD_SERVER_TIMER: CommandInfo(prefix=b"\x00\x00\x00\x97", params=None), } From bbac2dc34d992e7ce31b46803af6b33cc06bcb73 Mon Sep 17 00:00:00 2001 From: humbertogontijo Date: Thu, 27 Apr 2023 13:07:26 -0300 Subject: [PATCH 05/17] fix: using single device api (cherry picked from commit e689e8d141acff998fd524ace923621fc0f91d0c) --- roborock/api.py | 75 ++++++++++--------- roborock/cli.py | 18 +++-- roborock/cloud_api.py | 25 ++++--- roborock/local_api.py | 164 +++++++++++++++++------------------------- tests/test_api.py | 23 +++--- 5 files changed, 137 insertions(+), 168 deletions(-) diff --git a/roborock/api.py b/roborock/api.py index bcda9a26..92f8034c 100644 --- a/roborock/api.py +++ b/roborock/api.py @@ -14,7 +14,7 @@ import struct import time from random import randint -from typing import Any, Callable, Coroutine, Mapping, Optional +from typing import Any, Callable, Coroutine, Optional import aiohttp from Crypto.Cipher import AES @@ -85,8 +85,8 @@ async def request(self, method: str, url: str, params=None, data=None, headers=N class RoborockClient: - def __init__(self, endpoint: str, devices_info: Mapping[str, RoborockDeviceInfo]) -> None: - self.devices_info = devices_info + def __init__(self, endpoint: str, device_info: RoborockDeviceInfo) -> None: + self.device_info = device_info self._endpoint = endpoint self._nonce = secrets.token_bytes(16) self._waiting_queue: dict[int, RoborockFuture] = {} @@ -200,27 +200,27 @@ def _get_payload(self, method: RoborockCommand, params: Optional[list] = None, s ) return request_id, timestamp, payload - async def send_command(self, device_id: str, method: RoborockCommand, params: Optional[list] = None): + async def send_command(self, method: RoborockCommand, params: Optional[list] = None): raise NotImplementedError - async def get_status(self, device_id: str) -> Status | None: - status = await self.send_command(device_id, RoborockCommand.GET_STATUS) + async def get_status(self) -> Status | None: + status = await self.send_command(RoborockCommand.GET_STATUS) if isinstance(status, dict): return Status.from_dict(status) return None - async def get_dnd_timer(self, device_id: str) -> DNDTimer | None: + async def get_dnd_timer(self) -> DNDTimer | None: try: - dnd_timer = await self.send_command(device_id, RoborockCommand.GET_DND_TIMER) + dnd_timer = await self.send_command(RoborockCommand.GET_DND_TIMER) if isinstance(dnd_timer, dict): return DNDTimer.from_dict(dnd_timer) except RoborockTimeout as e: _LOGGER.error(e) return None - async def get_clean_summary(self, device_id: str) -> CleanSummary | None: + async def get_clean_summary(self) -> CleanSummary | None: try: - clean_summary = await self.send_command(device_id, RoborockCommand.GET_CLEAN_SUMMARY) + clean_summary = await self.send_command(RoborockCommand.GET_CLEAN_SUMMARY) if isinstance(clean_summary, dict): return CleanSummary.from_dict(clean_summary) elif isinstance(clean_summary, list): @@ -232,55 +232,54 @@ async def get_clean_summary(self, device_id: str) -> CleanSummary | None: _LOGGER.error(e) return None - async def get_clean_record(self, device_id: str, record_id: int) -> CleanRecord | None: + async def get_clean_record(self, record_id: int) -> CleanRecord | None: try: - clean_record = await self.send_command(device_id, RoborockCommand.GET_CLEAN_RECORD, [record_id]) + clean_record = await self.send_command(RoborockCommand.GET_CLEAN_RECORD, [record_id]) if isinstance(clean_record, dict): return CleanRecord.from_dict(clean_record) except RoborockTimeout as e: _LOGGER.error(e) return None - async def get_consumable(self, device_id: str) -> Consumable | None: + async def get_consumable(self) -> Consumable | None: try: - consumable = await self.send_command(device_id, RoborockCommand.GET_CONSUMABLE) + consumable = await self.send_command(RoborockCommand.GET_CONSUMABLE) if isinstance(consumable, dict): return Consumable.from_dict(consumable) except RoborockTimeout as e: _LOGGER.error(e) return None - async def get_wash_towel_mode(self, device_id: str) -> WashTowelMode | None: + async def get_wash_towel_mode(self) -> WashTowelMode | None: try: - washing_mode = await self.send_command(device_id, RoborockCommand.GET_WASH_TOWEL_MODE) + washing_mode = await self.send_command(RoborockCommand.GET_WASH_TOWEL_MODE) if isinstance(washing_mode, dict): return WashTowelMode.from_dict(washing_mode) except RoborockTimeout as e: _LOGGER.error(e) return None - async def get_dust_collection_mode(self, device_id: str) -> DustCollectionMode | None: + async def get_dust_collection_mode(self) -> DustCollectionMode | None: try: - dust_collection = await self.send_command(device_id, RoborockCommand.GET_DUST_COLLECTION_MODE) + dust_collection = await self.send_command(RoborockCommand.GET_DUST_COLLECTION_MODE) if isinstance(dust_collection, dict): return DustCollectionMode.from_dict(dust_collection) except RoborockTimeout as e: _LOGGER.error(e) return None - async def get_smart_wash_params(self, device_id: str) -> SmartWashParams | None: + async def get_smart_wash_params(self) -> SmartWashParams | None: try: - mop_wash_mode = await self.send_command(device_id, RoborockCommand.GET_SMART_WASH_PARAMS) + mop_wash_mode = await self.send_command(RoborockCommand.GET_SMART_WASH_PARAMS) if isinstance(mop_wash_mode, dict): return SmartWashParams.from_dict(mop_wash_mode) except RoborockTimeout as e: _LOGGER.error(e) return None - async def get_dock_summary(self, device_id: str, dock_type: RoborockEnum) -> DockSummary | None: + async def get_dock_summary(self, dock_type: RoborockEnum) -> DockSummary | None: """Gets the status summary from the dock with the methods available for a given dock. - :param device_id: Device id :param dock_type: RoborockDockTypeCode""" try: commands: list[ @@ -289,11 +288,11 @@ async def get_dock_summary(self, device_id: str, dock_type: RoborockEnum) -> Doc Any, DustCollectionMode | WashTowelMode | SmartWashParams | None, ] - ] = [self.get_dust_collection_mode(device_id)] + ] = [self.get_dust_collection_mode()] if dock_type == RoborockDockTypeCode["3"]: commands += [ - self.get_wash_towel_mode(device_id), - self.get_smart_wash_params(device_id), + self.get_wash_towel_mode(), + self.get_smart_wash_params(), ] [dust_collection_mode, wash_towel_mode, smart_wash_params] = unpack_list( list(await asyncio.gather(*commands)), 3 @@ -304,21 +303,21 @@ async def get_dock_summary(self, device_id: str, dock_type: RoborockEnum) -> Doc _LOGGER.error(e) return None - async def get_prop(self, device_id: str) -> DeviceProp | None: + async def get_prop(self) -> DeviceProp | None: [status, dnd_timer, clean_summary, consumable] = await asyncio.gather( *[ - self.get_status(device_id), - self.get_dnd_timer(device_id), - self.get_clean_summary(device_id), - self.get_consumable(device_id), + self.get_status(), + self.get_dnd_timer(), + self.get_clean_summary(), + self.get_consumable(), ] ) last_clean_record = None if clean_summary and clean_summary.records and len(clean_summary.records) > 0: - last_clean_record = await self.get_clean_record(device_id, clean_summary.records[0]) + last_clean_record = await self.get_clean_record(clean_summary.records[0]) dock_summary = None if status and status.dock_type is not None and status.dock_type != RoborockDockTypeCode["0"]: - dock_summary = await self.get_dock_summary(device_id, status.dock_type) + dock_summary = await self.get_dock_summary(status.dock_type) if any([status, dnd_timer, clean_summary, consumable]): return DeviceProp( status, @@ -330,27 +329,27 @@ async def get_prop(self, device_id: str) -> DeviceProp | None: ) return None - async def get_multi_maps_list(self, device_id) -> MultiMapsList | None: + async def get_multi_maps_list(self) -> MultiMapsList | None: try: - multi_maps_list = await self.send_command(device_id, RoborockCommand.GET_MULTI_MAPS_LIST) + multi_maps_list = await self.send_command(RoborockCommand.GET_MULTI_MAPS_LIST) if isinstance(multi_maps_list, dict): return MultiMapsList.from_dict(multi_maps_list) except RoborockTimeout as e: _LOGGER.error(e) return None - async def get_networking(self, device_id) -> NetworkInfo | None: + async def get_networking(self) -> NetworkInfo | None: try: - networking_info = await self.send_command(device_id, RoborockCommand.GET_NETWORK_INFO) + networking_info = await self.send_command(RoborockCommand.GET_NETWORK_INFO) if isinstance(networking_info, dict): return NetworkInfo.from_dict(networking_info) except RoborockTimeout as e: _LOGGER.error(e) return None - async def get_room_mapping(self, device_id: str) -> list[RoomMapping]: + async def get_room_mapping(self) -> list[RoomMapping]: """Gets the mapping from segment id -> iot id. Only works on local api.""" - mapping = await self.send_command(device_id, RoborockCommand.GET_ROOM_MAPPING) + mapping = await self.send_command(RoborockCommand.GET_ROOM_MAPPING) if isinstance(mapping, list): return [ RoomMapping(segment_id=segment_id, iot_id=iot_id) # type: ignore diff --git a/roborock/cli.py b/roborock/cli.py index 2727a67d..8669be46 100644 --- a/roborock/cli.py +++ b/roborock/cli.py @@ -102,26 +102,30 @@ async def list_devices(ctx): await _discover(ctx) login_data = context.login_data() home_data = login_data.home_data - click.echo(f"Known devices {', '.join([device.name for device in home_data.devices + home_data.received_devices])}") + device_name_id = ", ".join( + [f"{device.name}: {device.duid}" for device in home_data.devices + home_data.received_devices] + ) + click.echo(f"Known devices {device_name_id}") @click.command() +@click.option("--device_id", required=True) @click.option("--cmd", required=True) @click.option("--params", required=False) @click.pass_context @run_sync() -async def command(ctx, cmd, params): +async def command(ctx, cmd, device_id, params): context: RoborockContext = ctx.obj login_data = context.login_data() if not login_data.home_data: await _discover(ctx) login_data = context.login_data() home_data = login_data.home_data - device_map: dict[str, RoborockDeviceInfo] = {} - for device in home_data.devices + home_data.received_devices: - device_map[device.duid] = RoborockDeviceInfo(device=device) - mqtt_client = RoborockMqttClient(login_data.user_data, device_map) - await mqtt_client.send_command(home_data.devices[0].duid, cmd, params) + devices = home_data.devices + home_data.received_devices + device = next((device for device in devices if device.duid == device_id), None) + device_info = RoborockDeviceInfo(device=device) + mqtt_client = RoborockMqttClient(login_data.user_data, device_info) + await mqtt_client.send_command(cmd, params) mqtt_client.__del__() diff --git a/roborock/cloud_api.py b/roborock/cloud_api.py index 0444be28..baa71aec 100644 --- a/roborock/cloud_api.py +++ b/roborock/cloud_api.py @@ -5,7 +5,7 @@ import threading import uuid from asyncio import Lock -from typing import Mapping, Optional +from typing import Optional from urllib.parse import urlparse import paho.mqtt.client as mqtt @@ -25,12 +25,12 @@ class RoborockMqttClient(RoborockClient, mqtt.Client): _thread: threading.Thread - def __init__(self, user_data: UserData, devices_info: Mapping[str, RoborockDeviceInfo]) -> None: + def __init__(self, user_data: UserData, device_info: RoborockDeviceInfo) -> None: rriot = user_data.rriot if rriot is None: raise RoborockException("Got no rriot data from user_data") endpoint = base64.b64encode(md5bin(rriot.k)[8:14]).decode() - RoborockClient.__init__(self, endpoint, devices_info) + RoborockClient.__init__(self, endpoint, device_info) mqtt.Client.__init__(self, protocol=mqtt.MQTTv5) self._mqtt_user = rriot.u self._hashed_user = md5hex(self._mqtt_user + ":" + rriot.k)[2:10] @@ -63,7 +63,7 @@ def on_connect(self, *args, **kwargs) -> None: connection_queue.resolve((None, VacuumError(rc, message))) return _LOGGER.info(f"Connected to mqtt {self._mqtt_host}:{self._mqtt_port}") - topic = f"rr/m/o/{self._mqtt_user}/{self._hashed_user}/#" + topic = f"rr/m/o/{self._mqtt_user}/{self._hashed_user}/{self.device_info.device.duid}" (result, mid) = self.subscribe(topic) if result != 0: message = f"Failed to subscribe (rc: {result})" @@ -77,8 +77,7 @@ def on_connect(self, *args, **kwargs) -> None: def on_message(self, *args, **kwargs) -> None: _, __, msg = args - device_id = msg.topic.split("/").pop() - messages, _ = RoborockParser.decode(msg.payload, self.devices_info[device_id].device.local_key) + messages, _ = RoborockParser.decode(msg.payload, self.device_info.device.local_key) super().on_message(messages) def on_disconnect(self, *args, **kwargs) -> None: @@ -151,21 +150,21 @@ async def async_connect(self) -> None: async def validate_connection(self) -> None: await self.async_connect() - def _send_msg_raw(self, device_id, msg) -> None: - info = self.publish(f"rr/m/i/{self._mqtt_user}/{self._hashed_user}/{device_id}", msg) + def _send_msg_raw(self, msg) -> None: + info = self.publish(f"rr/m/i/{self._mqtt_user}/{self._hashed_user}/{self.device_info.device.duid}", msg) if info.rc != mqtt.MQTT_ERR_SUCCESS: raise RoborockException(f"Failed to publish (rc: {info.rc})") - async def send_command(self, device_id: str, method: RoborockCommand, params: Optional[list] = None): + async def send_command(self, method: RoborockCommand, params: Optional[list] = None): await self.validate_connection() request_id, timestamp, payload = super()._get_payload(method, params, True) _LOGGER.debug(f"id={request_id} Requesting method {method} with {params}") request_protocol = 101 response_protocol = 301 if method in SPECIAL_COMMANDS else 102 roborock_message = RoborockMessage(timestamp=timestamp, protocol=request_protocol, payload=payload) - local_key = self.devices_info[device_id].device.local_key + local_key = self.device_info.device.local_key msg = RoborockParser.encode(roborock_message, local_key) - self._send_msg_raw(device_id, msg) + self._send_msg_raw(msg) (response, err) = await self._async_response(request_id, response_protocol) if err: raise CommandVacuumError(method, err) from err @@ -175,9 +174,9 @@ async def send_command(self, device_id: str, method: RoborockCommand, params: Op _LOGGER.debug(f"id={request_id} Response from {method}: {response}") return response - async def get_map_v1(self, device_id): + async def get_map_v1(self): try: - return await self.send_command(device_id, RoborockCommand.GET_MAP_V1) + return await self.send_command(RoborockCommand.GET_MAP_V1) except RoborockException as e: _LOGGER.error(e) return None diff --git a/roborock/local_api.py b/roborock/local_api.py index 89ea7559..ac33e5f0 100644 --- a/roborock/local_api.py +++ b/roborock/local_api.py @@ -4,39 +4,57 @@ import logging import socket from asyncio import Lock, Transport -from typing import Callable, Mapping, Optional +from typing import Optional import async_timeout +from roborock.util import get_running_loop_or_create_one from .api import QUEUE_TIMEOUT, SPECIAL_COMMANDS, RoborockClient from .containers import RoborockLocalDeviceInfo from .exceptions import CommandVacuumError, RoborockConnectionException, RoborockException from .roborock_message import RoborockMessage, RoborockParser from .typing import CommandInfoMap, RoborockCommand -from .util import get_running_loop_or_create_one _LOGGER = logging.getLogger(__name__) -class RoborockLocalClient(RoborockClient): - def __init__(self, devices_info: Mapping[str, RoborockLocalDeviceInfo]): - super().__init__("abc", devices_info) +class RoborockLocalClient(RoborockClient, asyncio.Protocol): + def __init__(self, device_info: RoborockLocalDeviceInfo): + super().__init__("abc", device_info) self.loop = get_running_loop_or_create_one() - self.device_listener: dict[str, RoborockSocketListener] = { - device_id: RoborockSocketListener( - device_info.network_info.ip, device_info.device.local_key, self.on_message, self.on_disconnect - ) - for device_id, device_info in devices_info.items() - } + self.ip = device_info.network_info.ip self._batch_structs: list[RoborockMessage] = [] self._executing = False + self.remaining = b"" + self.transport: Transport | None = None + self._mutex = Lock() + + def data_received(self, message): + if self.remaining: + message = self.remaining + message + self.remaining = b"" + (parser_msg, remaining) = RoborockParser.decode(message, self.device_info.device.local_key) + self.remaining = remaining + self.on_message(parser_msg) + + def connection_lost(self, exc: Optional[Exception]): + self.on_disconnect(exc) + + def is_connected(self): + return self.transport and self.transport.is_reading() async def async_connect(self) -> None: - await asyncio.gather(*[listener.connect() for listener in self.device_listener.values()]) + try: + if not self.is_connected(): + async with async_timeout.timeout(QUEUE_TIMEOUT): + _LOGGER.info(f"Connecting to {self.ip}") + self.transport, _ = await self.loop.create_connection(lambda: self, self.ip, 58867) # type: ignore + except Exception as e: + raise RoborockConnectionException(f"Failed connecting to {self.ip}") from e async def async_disconnect(self) -> None: - for listener in self.device_listener.values(): - listener.disconnect() + if self.transport: + self.transport.close() def build_roborock_message(self, method: RoborockCommand, params: Optional[list] = None) -> RoborockMessage: secured = True if method in SPECIAL_COMMANDS else False @@ -57,9 +75,9 @@ def build_roborock_message(self, method: RoborockCommand, params: Optional[list] payload=payload, ) - async def send_command(self, device_id: str, method: RoborockCommand, params: Optional[list] = None): + async def send_command(self, method: RoborockCommand, params: Optional[list] = None): roborock_message = self.build_roborock_message(method, params) - return (await self.send_message(device_id, roborock_message))[0] + return (await self.send_message(roborock_message))[0] async def async_local_response(self, roborock_message: RoborockMessage): request_id = roborock_message.get_request_id() @@ -72,30 +90,37 @@ async def async_local_response(self, roborock_message: RoborockMessage): _LOGGER.debug(f"id={request_id} Response from {roborock_message.get_method()}: {response}") return response - async def send_message(self, device_id: str, roborock_messages: list[RoborockMessage] | RoborockMessage): - if isinstance(roborock_messages, RoborockMessage): - roborock_messages = [roborock_messages] - local_key = self.devices_info[device_id].device.local_key - msg = RoborockParser.encode(roborock_messages, local_key) - # Send the command to the Roborock device - listener = self.device_listener.get(device_id) - if listener is None: - raise RoborockException(f"No device listener for {device_id}") - if not self.should_keepalive(): - listener.disconnect() - - _LOGGER.debug(f"Requesting device with {roborock_messages}") - await listener.send_message(msg) - - responses = await asyncio.gather( - *[self.async_local_response(roborock_message) for roborock_message in roborock_messages], - return_exceptions=True, - ) - exception = next((response for response in responses if isinstance(response, BaseException)), None) - if exception: - listener.disconnect() - raise exception - return responses + def _send_msg_raw(self, data: bytes): + try: + if not self.transport: + raise RoborockException("Can not send message without connection") + self.transport.write(data) + except Exception as e: + raise RoborockException(e) from e + + async def send_message(self, roborock_messages: list[RoborockMessage] | RoborockMessage): + async with self._mutex: + await self.async_connect() + if isinstance(roborock_messages, RoborockMessage): + roborock_messages = [roborock_messages] + local_key = self.device_info.device.local_key + msg = RoborockParser.encode(roborock_messages, local_key) + # Send the command to the Roborock device + if not self.should_keepalive(): + await self.async_disconnect() + + _LOGGER.debug(f"Requesting device with {roborock_messages}") + self._send_msg_raw(msg) + + responses = await asyncio.gather( + *[self.async_local_response(roborock_message) for roborock_message in roborock_messages], + return_exceptions=True, + ) + exception = next((response for response in responses if isinstance(response, BaseException)), None) + if exception: + await self.async_disconnect() + raise exception + return responses class RoborockSocket(socket.socket): @@ -104,62 +129,3 @@ class RoborockSocket(socket.socket): @property def is_closed(self): return self._closed - - -class RoborockSocketListener(asyncio.Protocol): - roborock_port = 58867 - - def __init__( - self, - ip: str, - local_key: str, - on_message: Callable[[list[RoborockMessage]], None], - on_disconnect: Callable[[Optional[Exception]], None], - timeout: float | int = QUEUE_TIMEOUT, - ): - self.ip = ip - self.local_key = local_key - self.loop = get_running_loop_or_create_one() - self.on_message = on_message - self.on_disconnect = on_disconnect - self.timeout = timeout - self.remaining = b"" - self.transport: Transport | None = None - self._mutex = Lock() - - def data_received(self, message): - if self.remaining: - message = self.remaining + message - self.remaining = b"" - (parser_msg, remaining) = RoborockParser.decode(message, self.local_key) - self.remaining = remaining - self.on_message(parser_msg) - - def connection_lost(self, exc: Optional[Exception]): - self.on_disconnect(exc) - - def is_connected(self): - return self.transport and self.transport.is_reading() - - async def connect(self): - try: - if not self.is_connected(): - async with async_timeout.timeout(self.timeout): - _LOGGER.info(f"Connecting to {self.ip}") - self.transport, _ = await self.loop.create_connection(lambda: self, self.ip, 58867) # type: ignore - except Exception as e: - raise RoborockConnectionException(f"Failed connecting to {self.ip}") from e - - def disconnect(self): - if self.transport: - self.transport.close() - - async def send_message(self, data: bytes) -> None: - async with self._mutex: - await self.connect() - try: - if not self.transport: - raise RoborockException("Can not send message without connection") - self.transport.write(data) - except Exception as e: - raise RoborockException(e) from e diff --git a/tests/test_api.py b/tests/test_api.py index 5fa0c85a..4e821b0e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -2,6 +2,7 @@ import paho.mqtt.client as mqtt import pytest +from roborock.containers import RoborockDeviceInfo from roborock import HomeData, RoborockDockDustCollectionModeCode, RoborockDockWashTowelModeCode, UserData from roborock.api import PreparedRequest, RoborockApiClient @@ -19,8 +20,8 @@ def test_can_create_prepared_request(): def test_can_create_mqtt_roborock(): home_data = HomeData.from_dict(HOME_DATA_RAW) - device_map = {home_data.devices[0].duid: home_data.devices[0]} - RoborockMqttClient(UserData.from_dict(USER_DATA), device_map) + device_info = RoborockDeviceInfo(device=home_data.devices[0]) + RoborockMqttClient(UserData.from_dict(USER_DATA), device_info) def test_sync_connect(mqtt_client): @@ -68,11 +69,11 @@ async def test_get_home_data(): @pytest.mark.asyncio async def test_get_dust_collection_mode(): home_data = HomeData.from_dict(HOME_DATA_RAW) - device_map = {home_data.devices[0].duid: home_data.devices[0]} - rmc = RoborockMqttClient(UserData.from_dict(USER_DATA), device_map) + device_info = RoborockDeviceInfo(device=home_data.devices[0]) + rmc = RoborockMqttClient(UserData.from_dict(USER_DATA), device_info) with patch("roborock.cloud_api.RoborockMqttClient.send_command") as command: command.return_value = {"mode": 1} - dust = await rmc.get_dust_collection_mode(home_data.devices[0].duid) + dust = await rmc.get_dust_collection_mode() assert dust is not None assert dust.mode == RoborockDockDustCollectionModeCode["1"] @@ -80,11 +81,11 @@ async def test_get_dust_collection_mode(): @pytest.mark.asyncio async def test_get_mop_wash_mode(): home_data = HomeData.from_dict(HOME_DATA_RAW) - device_map = {home_data.devices[0].duid: home_data.devices[0]} - rmc = RoborockMqttClient(UserData.from_dict(USER_DATA), device_map) + device_info = RoborockDeviceInfo(device=home_data.devices[0]) + rmc = RoborockMqttClient(UserData.from_dict(USER_DATA), device_info) with patch("roborock.cloud_api.RoborockMqttClient.send_command") as command: command.return_value = {"smart_wash": 0, "wash_interval": 1500} - mop_wash = await rmc.get_smart_wash_params(home_data.devices[0].duid) + mop_wash = await rmc.get_smart_wash_params() assert mop_wash is not None assert mop_wash.smart_wash == 0 assert mop_wash.wash_interval == 1500 @@ -93,10 +94,10 @@ async def test_get_mop_wash_mode(): @pytest.mark.asyncio async def test_get_washing_mode(): home_data = HomeData.from_dict(HOME_DATA_RAW) - device_map = {home_data.devices[0].duid: home_data.devices[0]} - rmc = RoborockMqttClient(UserData.from_dict(USER_DATA), device_map) + device_info = RoborockDeviceInfo(device=home_data.devices[0]) + rmc = RoborockMqttClient(UserData.from_dict(USER_DATA), device_info) with patch("roborock.cloud_api.RoborockMqttClient.send_command") as command: command.return_value = {"wash_mode": 2} - washing_mode = await rmc.get_wash_towel_mode(home_data.devices[0].duid) + washing_mode = await rmc.get_wash_towel_mode() assert washing_mode is not None assert washing_mode.wash_mode == RoborockDockWashTowelModeCode["2"] From 5bcf7e0512d81ca329e83cc032ce68f608f8e00f Mon Sep 17 00:00:00 2001 From: humbertogontijo Date: Thu, 27 Apr 2023 13:48:01 -0300 Subject: [PATCH 06/17] chore: linting (cherry picked from commit 2ed367cba5e9b4199fdea935305fb47f85a8c1e7) --- roborock/local_api.py | 2 +- tests/test_api.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/roborock/local_api.py b/roborock/local_api.py index ac33e5f0..3eebd5e1 100644 --- a/roborock/local_api.py +++ b/roborock/local_api.py @@ -8,12 +8,12 @@ import async_timeout -from roborock.util import get_running_loop_or_create_one from .api import QUEUE_TIMEOUT, SPECIAL_COMMANDS, RoborockClient from .containers import RoborockLocalDeviceInfo from .exceptions import CommandVacuumError, RoborockConnectionException, RoborockException from .roborock_message import RoborockMessage, RoborockParser from .typing import CommandInfoMap, RoborockCommand +from .util import get_running_loop_or_create_one _LOGGER = logging.getLogger(__name__) diff --git a/tests/test_api.py b/tests/test_api.py index 4e821b0e..99c10a0a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -2,11 +2,11 @@ import paho.mqtt.client as mqtt import pytest -from roborock.containers import RoborockDeviceInfo from roborock import HomeData, RoborockDockDustCollectionModeCode, RoborockDockWashTowelModeCode, UserData from roborock.api import PreparedRequest, RoborockApiClient from roborock.cloud_api import RoborockMqttClient +from roborock.containers import RoborockDeviceInfo from tests.mock_data import BASE_URL_REQUEST, GET_CODE_RESPONSE, HOME_DATA_RAW, USER_DATA From 0261d483d4df97f157e77e98d727b7d95d6a4c5d Mon Sep 17 00:00:00 2001 From: humbertogontijo Date: Thu, 27 Apr 2023 13:58:21 -0300 Subject: [PATCH 07/17] chore: linting (cherry picked from commit 58b46835d609794210f8c49daddbc7d25cee011d) --- tests/conftest.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index ab90395e..d5cb5a54 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ from roborock import HomeData, UserData from roborock.cloud_api import RoborockMqttClient +from roborock.containers import RoborockDeviceInfo from tests.mock_data import HOME_DATA_RAW, USER_DATA @@ -9,7 +10,7 @@ def mqtt_client(): user_data = UserData.from_dict(USER_DATA) home_data = HomeData.from_dict(HOME_DATA_RAW) - device_map = {home_data.devices[0].duid: home_data.devices[0].local_key} - client = RoborockMqttClient(user_data, device_map) + device_info = RoborockDeviceInfo(device=home_data.devices[0]) + client = RoborockMqttClient(user_data, device_info) yield client # Clean up any resources after the test From 75e3c775812a25a521c508e7c6b422189e8eb250 Mon Sep 17 00:00:00 2001 From: humbertogontijo Date: Fri, 28 Apr 2023 21:20:37 -0300 Subject: [PATCH 08/17] chore: linting --- roborock/api.py | 6 +++--- roborock/cloud_api.py | 2 +- roborock/local_api.py | 10 +++------- roborock/typing.py | 15 ++++++--------- 4 files changed, 13 insertions(+), 20 deletions(-) diff --git a/roborock/api.py b/roborock/api.py index 345cabc8..4cc71697 100644 --- a/roborock/api.py +++ b/roborock/api.py @@ -173,7 +173,7 @@ def on_connection_lost(self, exc: Optional[Exception]) -> None: def should_keepalive(self) -> bool: now = self.time_func() # noinspection PyUnresolvedReferences - if now - self._last_disconnection > self.keep_alive ** 2 and now - self._last_device_msg_in > self.keep_alive: + if now - self._last_disconnection > self.keep_alive**2 and now - self._last_device_msg_in > self.keep_alive: return False return True @@ -193,7 +193,7 @@ async def _async_response(self, request_id: int, protocol_id: int = 0) -> tuple[ finally: del self._waiting_queue[request_id] - def _get_payload(self, method: RoborockCommand, params: Optional[list] = None, secured=False): + def _get_payload(self, method: RoborockCommand, params: Optional[list | dict] = None, secured=False): timestamp = math.floor(time.time()) request_id = randint(10000, 99999) inner = { @@ -217,7 +217,7 @@ def _get_payload(self, method: RoborockCommand, params: Optional[list] = None, s ) return request_id, timestamp, payload - async def send_command(self, method: RoborockCommand, params: Optional[list] = None): + async def send_command(self, method: RoborockCommand, params: Optional[list | dict] = None): raise NotImplementedError async def get_status(self) -> Status | None: diff --git a/roborock/cloud_api.py b/roborock/cloud_api.py index 52da6b55..d8c30368 100644 --- a/roborock/cloud_api.py +++ b/roborock/cloud_api.py @@ -147,7 +147,7 @@ def _send_msg_raw(self, msg: bytes) -> None: if info.rc != mqtt.MQTT_ERR_SUCCESS: raise RoborockException(f"Failed to publish (rc: {info.rc})") - async def send_command(self, method: RoborockCommand, params: Optional[list] = None): + async def send_command(self, method: RoborockCommand, params: Optional[list | dict] = None): await self.validate_connection() request_id, timestamp, payload = super()._get_payload(method, params, True) _LOGGER.debug(f"id={request_id} Requesting method {method} with {params}") diff --git a/roborock/local_api.py b/roborock/local_api.py index 0c9ecca6..9bbe4438 100644 --- a/roborock/local_api.py +++ b/roborock/local_api.py @@ -63,7 +63,7 @@ async def async_disconnect(self) -> None: async with self._mutex: self.sync_disconnect() - def build_roborock_message(self, method: RoborockCommand, params: Optional[list] = None) -> RoborockMessage: + def build_roborock_message(self, method: RoborockCommand, params: Optional[list | dict] = None) -> RoborockMessage: secured = True if method in SPECIAL_COMMANDS else False request_id, timestamp, payload = self._get_payload(method, params, secured) _LOGGER.debug(f"id={request_id} Requesting method {method} with {params}") @@ -83,12 +83,8 @@ def build_roborock_message(self, method: RoborockCommand, params: Optional[list] ) async def ping(self): - command_info = CommandInfoMap.get(None) - roborock_message = RoborockMessage( - prefix=command_info.prefix, - protocol=0, - payload=b'' - ) + command_info = CommandInfoMap[RoborockCommand.NONE] + roborock_message = RoborockMessage(prefix=command_info.prefix, protocol=0, payload=b"") return (await self.send_message(roborock_message))[0] async def send_command(self, method: RoborockCommand, params: Optional[list | dict] = None): diff --git a/roborock/typing.py b/roborock/typing.py index 72f7f01b..cc834315 100644 --- a/roborock/typing.py +++ b/roborock/typing.py @@ -86,6 +86,7 @@ class RoborockCommand(str, Enum): GET_WASH_TOWEL_MODE = "get_wash_towel_mode" LOAD_MULTI_MAP = "load_multi_map" NAME_SEGMENT = "name_segment" + NONE = "" RESET_CONSUMABLE = "reset_consumable" RESUME_SEGMENT_CLEAN = "resume_segment_clean" RESUME_ZONED_CLEAN = "resume_zoned_clean" @@ -133,7 +134,6 @@ class CommandInfo: CommandInfoMap: dict[RoborockCommand | None, CommandInfo] = { - None: CommandInfo(prefix=b"\x00\x00\x00\x15", params=None), RoborockCommand.APP_CHARGE: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), RoborockCommand.APP_GET_DRYER_SETTING: CommandInfo(prefix=b"\x00\x00\x00\x87", params=None), RoborockCommand.APP_GET_INIT_STATUS: CommandInfo(prefix=b"\x00\x00\x00\x87", params=[]), @@ -180,9 +180,7 @@ class CommandInfo: RoborockCommand.APP_STOP: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), RoborockCommand.APP_STOP_WASH: CommandInfo(prefix=b"\x00\x00\x00w", params=None), RoborockCommand.APP_WAKEUP_ROBOT: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), - RoborockCommand.APP_ZONED_CLEAN: CommandInfo( - prefix=b"\x00\x00\x00\x97", params=[[24900, 25100, 26300, 26450, 1]] - ), + RoborockCommand.APP_ZONED_CLEAN: CommandInfo(prefix=b"\x00\x00\x00\x97", params=[[24900, 25100, 26300, 26450, 1]]), RoborockCommand.CHANGE_SOUND_VOLUME: CommandInfo(prefix=b"\x00\x00\x00\x87", params=None), RoborockCommand.CLOSE_DND_TIMER: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), RoborockCommand.CLOSE_VALLEY_ELECTRICITY_TIMER: CommandInfo(prefix=b"\x00\x00\x00\x87", params=[]), @@ -235,6 +233,7 @@ class CommandInfo: RoborockCommand.GET_WASH_TOWEL_MODE: CommandInfo(prefix=b"\x00\x00\x00\x87", params=None), RoborockCommand.LOAD_MULTI_MAP: CommandInfo(prefix=b"\x00\x00\x00w", params=None), RoborockCommand.NAME_SEGMENT: CommandInfo(prefix=b"\x00\x00\x027", params=None), + RoborockCommand.NONE: CommandInfo(prefix=b"\x00\x00\x00\x15", params=None), RoborockCommand.RESET_CONSUMABLE: CommandInfo(prefix=b"\x00\x00\x00\x97", params=None), RoborockCommand.RESUME_SEGMENT_CLEAN: CommandInfo(prefix=b"\x00\x00\x00\x87", params=None), RoborockCommand.RESUME_ZONED_CLEAN: CommandInfo(prefix=b"\x00\x00\x00w", params=None), @@ -257,8 +256,8 @@ class CommandInfo: prefix=b"\x00\x00\x01\x97", params={ "app_ice": "eyJjYW5kaWRhdGUiOiAiY2FuZGlkYXRlOjE1MzE5NzE5NTEgMSB1ZHAgNDE4MTk5MDMgNTQuMTc0LjE4Ni4yNDkgNTQxNzU" - "gdHlwIHJlbGF5IHJhZGRyIDE3Ny4xOC4xMzQuOTkgcnBvcnQgNjQ2OTEgZ2VuZXJhdGlvbiAwIHVmcmFnIDVOMVogbmV0d2" - "9yay1pZCAxIG5ldHdvcmstY29zdCAxMCIsICJzZHBNTGluZUluZGV4IjogMSwgInNkcE1pZCI6ICIxIn0=" + "gdHlwIHJlbGF5IHJhZGRyIDE3Ny4xOC4xMzQuOTkgcnBvcnQgNjQ2OTEgZ2VuZXJhdGlvbiAwIHVmcmFnIDVOMVogbmV0d2" + "9yay1pZCAxIG5ldHdvcmstY29zdCAxMCIsICJzZHBNTGluZUluZGV4IjogMSwgInNkcE1pZCI6ICIxIn0=" }, ), RoborockCommand.SET_APP_TIMEZONE: CommandInfo(prefix=b"\x00\x00\x00\x97", params=["America/Sao_Paulo", 2]), @@ -282,9 +281,7 @@ class CommandInfo: RoborockCommand.SET_FDS_ENDPOINT: CommandInfo(prefix=b"\x00\x00\x00\x97", params=["awsusor0.fds.api.xiaomi.com"]), RoborockCommand.SET_FLOW_LED_STATUS: CommandInfo(prefix=b"\x00\x00\x00\x87", params={"status": 1}), RoborockCommand.SET_IDENTIFY_FURNITURE_STATUS: CommandInfo(prefix=b"\x00\x00\x00\x97", params={"status": 1}), - RoborockCommand.SET_IDENTIFY_GROUND_MATERIAL_STATUS: CommandInfo( - prefix=b"\x00\x00\x00\x97", params={"status": 1} - ), + RoborockCommand.SET_IDENTIFY_GROUND_MATERIAL_STATUS: CommandInfo(prefix=b"\x00\x00\x00\x97", params={"status": 1}), RoborockCommand.SET_LED_STATUS: CommandInfo(prefix=b"\x00\x00\x00w", params=[1]), RoborockCommand.SET_MOP_MODE: CommandInfo(prefix=b"\x00\x00\x00w", params=None), RoborockCommand.SET_SERVER_TIMER: CommandInfo(prefix=b"\x00\x00\x00\xc7", params=None), From d882c1c9d2ce967795b3e6ac644cdb2e35a7f5e2 Mon Sep 17 00:00:00 2001 From: humbertogontijo Date: Fri, 28 Apr 2023 21:24:21 -0300 Subject: [PATCH 09/17] chore: linting --- roborock/local_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/roborock/local_api.py b/roborock/local_api.py index 9bbe4438..a0c250cc 100644 --- a/roborock/local_api.py +++ b/roborock/local_api.py @@ -48,9 +48,9 @@ async def async_connect(self) -> None: if not self.is_connected(): async with async_timeout.timeout(QUEUE_TIMEOUT): _LOGGER.info(f"Connecting to {self.ip}") - self.transport, _ = await self.loop.create_connection( + self.transport, _ = await self.loop.create_connection( # type: ignore lambda: self, self.ip, 58867 - ) # type: ignore + ) _LOGGER.info(f"Connected to {self.ip}") except Exception as e: raise RoborockConnectionException(f"Failed connecting to {self.ip}") from e From f3602064eae84cda7690089365cccda8d9ed7fe5 Mon Sep 17 00:00:00 2001 From: Luke Date: Fri, 28 Apr 2023 23:21:46 -0400 Subject: [PATCH 10/17] chore: init work --- roborock/api.py | 1 + roborock/code_mappings.py | 361 +++++++++++++++++++++----------------- roborock/containers.py | 19 +- tests/test_api.py | 4 +- 4 files changed, 216 insertions(+), 169 deletions(-) diff --git a/roborock/api.py b/roborock/api.py index 4cc71697..9c2bc1d3 100644 --- a/roborock/api.py +++ b/roborock/api.py @@ -223,6 +223,7 @@ async def send_command(self, method: RoborockCommand, params: Optional[list | di async def get_status(self) -> Status | None: status = await self.send_command(RoborockCommand.GET_STATUS) if isinstance(status, dict): + # TODO: Change status based off of which mop mode/ intensity/ vacuum to use return Status.from_dict(status) return None diff --git a/roborock/code_mappings.py b/roborock/code_mappings.py index 9bdd3cb5..264e29d1 100644 --- a/roborock/code_mappings.py +++ b/roborock/code_mappings.py @@ -1,183 +1,228 @@ from __future__ import annotations import logging -from enum import Enum -from typing import Any, Type, TypeVar - -_StrEnumT = TypeVar("_StrEnumT", bound="RoborockEnum") +from enum import IntEnum, Enum +from typing import Type _LOGGER = logging.getLogger(__name__) -class RoborockEnum(str, Enum): - def __new__(cls: Type[_StrEnumT], value: str, *args: Any, **kwargs: Any) -> _StrEnumT: - """Create a new StrEnum instance.""" - if not isinstance(value, str): - raise TypeError(f"{value!r} is not a string") - return super().__new__(cls, value, *args, **kwargs) - - def __str__(self): - return str(self.value) +class RoborockEnum(IntEnum): + """Roborock Enum for codes with int values""" @classmethod - def _missing_(cls: Type[_StrEnumT], code: object): - if cls._member_map_.get(str(code)): - return cls._member_map_.get(str(code)) - else: - _LOGGER.warning(f"Unknown code {code} for {cls.__name__}") - return cls._member_map_.get(str(-9999)) + def __missing__(cls: Type[RoborockEnum], key): + _LOGGER.debug(f"Missing {cls.__name__} code: {key}") + return cls.keys()[0] @classmethod - def as_dict(cls: Type[_StrEnumT]): - return {int(i.name): i.value for i in cls if i.value != "UNKNOWN"} + def as_dict(cls: Type[RoborockEnum]): + # TODO: check + return {i.value: i.name for i in cls if i.name != "missing"} @classmethod - def values(cls: Type[_StrEnumT]): + def values(cls: Type[RoborockEnum]): return list(cls.as_dict().values()) @classmethod - def keys(cls: Type[_StrEnumT]): + def keys(cls: Type[RoborockEnum]): return list(cls.as_dict().keys()) @classmethod - def items(cls: Type[_StrEnumT]): + def items(cls: Type[RoborockEnum]): return cls.as_dict().items() @classmethod - def __getitem__(cls: Type[_StrEnumT], item): + def __getitem__(cls: Type[RoborockEnum], item): return cls.__getitem__(item) -def create_code_enum(name: str, data: dict) -> RoborockEnum: - data[-9999] = "UNKNOWN" - return RoborockEnum(name, {str(key): value for key, value in data.items()}) - - -RoborockStateCode = create_code_enum( - "RoborockStateCode", - { - 1: "starting", - 2: "charger_disconnected", - 3: "idle", - 4: "remote_control_active", - 5: "cleaning", - 6: "returning_home", - 7: "manual_mode", - 8: "charging", - 9: "charging_problem", - 10: "paused", - 11: "spot_cleaning", - 12: "error", - 13: "shutting_down", - 14: "updating", - 15: "docking", - 16: "going_to_target", - 17: "zoned_cleaning", - 18: "segment_cleaning", - 22: "emptying_the_bin", # on s7+, see #1189 - 23: "washing_the_mop", # on a46, #1435 - 26: "going_to_wash_the_mop", # on a46, #1435 - 100: "charging_complete", - 101: "device_offline", - }, -) - -RoborockErrorCode = create_code_enum( - "RoborockErrorCode", - { - 0: "none", - 1: "lidar_blocked", - 2: "bumper_stuck", - 3: "wheels_suspended", - 4: "cliff_sensor_error", - 5: "main_brush_jammed", - 6: "side_brush_jammed", - 7: "wheels_jammed", - 8: "robot_trapped", - 9: "no_dustbin", - 12: "low_battery", - 13: "charging_error", - 14: "battery_error", - 15: "wall_sensor_dirty", - 16: "robot_tilted", - 17: "side_brush_error", - 18: "fan_error", - 21: "vertical_bumper_pressed", - 22: "dock_locator_error", - 23: "return_to_dock_fail", - 24: "nogo_zone_detected", - 27: "vibrarise_jammed", - 28: "robot_on_carpet", - 29: "filter_blocked", - 30: "invisible_wall_detected", - 31: "cannot_cross_carpet", - 32: "internal_error", - }, -) - -RoborockFanPowerCode = create_code_enum( - "RoborockFanPowerCode", - { - 105: "off", - 101: "silent", - 102: "balanced", - 103: "turbo", - 104: "max", - 108: "max_plus", - 106: "custom", - }, -) - -RoborockMopModeCode = create_code_enum( - "RoborockMopModeCode", - { - 300: "standard", - 301: "deep", - 303: "deep_plus", - 302: "custom", - }, -) - -RoborockMopIntensityCode = create_code_enum( - "RoborockMopIntensityCode", - { - 200: "off", - 201: "mild", - 202: "moderate", - 203: "intense", - 204: "custom", - }, -) - -RoborockDockErrorCode = create_code_enum( - "RoborockDockErrorCode", - { - 0: "ok", - 38: "water empty", - 39: "waste water tank full", - }, -) - -RoborockDockTypeCode = create_code_enum( - "RoborockDockTypeCode", - {0: "no_dock", 1: "unknown", 2: "unknown", 3: "empty_wash_fill_dock", 4: "unknown", 5: "auto_empty_dock_pure"}, -) - -RoborockDockDustCollectionModeCode = create_code_enum( - "RoborockDockDustCollectionModeCode", - { - 0: "smart", - 1: "light", - 2: "balanced", - 4: "max", - }, -) - -RoborockDockWashTowelModeCode = create_code_enum( - "RoborockDockWashTowelModeCode", - { - 0: "light", - 1: "balanced", - 2: "deep", - }, -) +class RoborockStateCode(RoborockEnum): + starting = 1 + charger_disconnected = 2 + idle = 3 + remote_control_active = 4 + cleaning = 5 + returning_home = 6 + manual_mode = 7 + charging = 8 + charging_problem = 9 + paused = 10 + spot_cleaning = 11 + error = 12 + shutting_down = 13 + updating = 14 + docking = 15 + going_to_target = 16 + zoned_cleaning = 17 + segment_cleaning = 18 + emptying_the_bin = 22 # on s7+ + washing_the_mop = 23 # on a46 + going_to_wash_the_mop = 26 # on a46 + charging_complete = 100 + device_offline = 101 + + +class RoborockErrorCode(RoborockEnum): + none = 0 + lidar_blocked = 1 + bumper_stuck = 2 + wheels_suspended = 3 + cliff_sensor_error = 4 + main_brush_jammed = 5 + side_brush_jammed = 6 + wheels_jammed = 7 + robot_trapped = 8 + no_dustbin = 9 + low_battery = 12 + charging_error = 13 + battery_error = 14 + wall_sensor_dirty = 15 + robot_tilted = 16 + side_brush_error = 17 + fan_error = 18 + vertical_bumper_pressed = 21 + dock_locator_error = 22 + return_to_dock_fail = 23 + nogo_zone_detected = 24 + vibrarise_jammed = 27 + robot_on_carpet = 28 + filter_blocked = 29 + invisible_wall_detected = 30 + cannot_cross_carpet = 31 + internal_error = 32 + + +class RoborockFanPowerCode(RoborockEnum): + """Describes the fan power of the vacuum cleaner.""" + +class RoborockFanSpeedV1(RoborockFanPowerCode): + silent = 38 + standard = 60 + medium = 77 + turbo = 90 + + +class RoborockFanSpeedV2(RoborockFanPowerCode): + silent = 101 + balanced = 102 + turbo = 103 + max = 104 + gentle = 105 + auto = 106 + + +class RoborockFanSpeedV3(RoborockFanPowerCode): + silent = 38 + standard = 60 + medium = 75 + turbo = 100 + + +class RoborockFanSpeedE2(RoborockFanPowerCode): + gentle = 41 + silent = 50 + standard = 68 + medium = 79 + turbo = 100 + + +class RoborockFanSpeedS7(RoborockFanPowerCode): + off = 105 + silent = 101 + standard = 102 + medium = 103 + turbo = 104 + + +class RoborockFanSpeedS7MaxV(RoborockFanPowerCode): + off = 105 + quiet = 101 + balanced = 102 + turbo = 103 + max = 104 + max_plus = 108 + + +class RoborockMopModeCode(RoborockEnum): + """Describes the mop mode of the vacuum cleaner.""" + + +class RoborockMopModeS7(RoborockMopModeCode): + """Describes the mop mode of the vacuum cleaner.""" + + standard = 300 + deep = 301 + custom = 302 + deep_plus = 303 + + +class RoborockMopIntensityCode(RoborockEnum): + """Describes the mop intensity of the vacuum cleaner.""" + + +class RoborockMopIntensitySy(RoborockMopIntensityCode): + """Describes the mop intensity of the vacuum cleaner.""" + + off = 200 + mild = 201 + moderate = 202 + intense = 203 + custom = 204 + + +class RoborockMopIntensityV2(RoborockMopIntensityCode): + """Describes the mop intensity of the vacuum cleaner.""" + + off = 200 + low = 201 + medium = 202 + high = 203 + custom = 207 + + +class RoborockDockErrorCode(RoborockEnum): + """Describes the error code of the dock.""" + + ok = 0 + water_empty = 38 + waste_water_tank_full = 39 + + +class RoborockDockTypeCode(RoborockEnum): + missing = -9999 + no_dock = 0 + empty_wash_fill_dock = 3 + auto_empty_dock_pure = 5 + + def __missing__(self, key): + return self.missing + + +class RoborockDockDustCollectionModeCode(RoborockEnum): + """Describes the dust collection mode of the vacuum cleaner.""" + + # TODO: Get the correct values for various different docks + missing = -9999 + smart = 0 + light = 1 + balanced = 2 + max = 4 + + def __missing__(self, key): + return self.missing + + + +class RoborockDockWashTowelModeCode(RoborockEnum): + """Describes the wash towel mode of the vacuum cleaner.""" + + # TODO: Get the correct values for various different docks + missing = -9999 + light = 0 + balanced = 1 + deep = 2 + + def __missing__(self, key): + return self.missing + diff --git a/roborock/containers.py b/roborock/containers.py index 0ccf7846..fea1b2fa 100644 --- a/roborock/containers.py +++ b/roborock/containers.py @@ -20,6 +20,7 @@ RoborockErrorCode, RoborockFanPowerCode, RoborockMopModeCode, + RoborockFanSpeedV2 ) from .const import FILTER_REPLACE_TIME, MAIN_BRUSH_REPLACE_TIME, SENSOR_DIRTY_REPLACE_TIME, SIDE_BRUSH_REPLACE_TIME @@ -192,11 +193,11 @@ class LoginData(RoborockBase): class Status(RoborockBase): msg_ver: Optional[int] = None msg_seq: Optional[int] = None - state: Optional[RoborockStateCode] = None # type: ignore[valid-type] + state: Optional[RoborockStateCode] = None battery: Optional[int] = None clean_time: Optional[int] = None clean_area: Optional[int] = None - error_code: Optional[RoborockErrorCode] = None # type: ignore[valid-type] + error_code: Optional[RoborockErrorCode] = None map_present: Optional[int] = None in_cleaning: Optional[int] = None in_returning: Optional[int] = None @@ -206,12 +207,12 @@ class Status(RoborockBase): back_type: Optional[int] = None wash_phase: Optional[int] = None wash_ready: Optional[int] = None - fan_power: Optional[RoborockFanPowerCode] = None # type: ignore[valid-type] + fan_power: Optional[int] = None dnd_enabled: Optional[int] = None map_status: Optional[int] = None is_locating: Optional[int] = None lock_status: Optional[int] = None - water_box_mode: Optional[RoborockMopIntensityCode] = None # type: ignore[valid-type] + water_box_mode: Optional[int] = None mop_intensity: Optional[str] = None water_box_carriage_status: Optional[int] = None mop_forbidden_enable: Optional[int] = None @@ -221,15 +222,15 @@ class Status(RoborockBase): home_sec_enable_password: Optional[int] = None adbumper_status: Optional[list[int]] = None water_shortage_status: Optional[int] = None - dock_type: Optional[RoborockDockTypeCode] = None # type: ignore[valid-type] + dock_type: Optional[RoborockDockTypeCode] = None dust_collection_status: Optional[int] = None auto_dust_collection: Optional[int] = None avoid_count: Optional[int] = None - mop_mode: Optional[RoborockMopModeCode] = None # type: ignore[valid-type] + mop_mode: Optional[int] = None debug_mode: Optional[int] = None collision_avoid_status: Optional[int] = None switch_map_mode: Optional[int] = None - dock_error_status: Optional[RoborockDockErrorCode] = None # type: ignore[valid-type] + dock_error_status: Optional[RoborockDockErrorCode] = None charge_status: Optional[int] = None unsave_map_reason: Optional[int] = None unsave_map_flag: Optional[int] = None @@ -331,12 +332,12 @@ class SmartWashParams(RoborockBase): @dataclass class DustCollectionMode(RoborockBase): - mode: Optional[RoborockDockDustCollectionModeCode] = None # type: ignore[valid-type] + mode: Optional[RoborockDockDustCollectionModeCode] = None @dataclass class WashTowelMode(RoborockBase): - wash_mode: Optional[RoborockDockWashTowelModeCode] = None # type: ignore[valid-type] + wash_mode: Optional[RoborockDockWashTowelModeCode] = None @dataclass diff --git a/tests/test_api.py b/tests/test_api.py index 99c10a0a..6aaeaa20 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -75,7 +75,7 @@ async def test_get_dust_collection_mode(): command.return_value = {"mode": 1} dust = await rmc.get_dust_collection_mode() assert dust is not None - assert dust.mode == RoborockDockDustCollectionModeCode["1"] + assert dust.mode == RoborockDockDustCollectionModeCode.light @pytest.mark.asyncio @@ -100,4 +100,4 @@ async def test_get_washing_mode(): command.return_value = {"wash_mode": 2} washing_mode = await rmc.get_wash_towel_mode() assert washing_mode is not None - assert washing_mode.wash_mode == RoborockDockWashTowelModeCode["2"] + assert washing_mode.wash_mode == RoborockDockWashTowelModeCode.deep From 0b58df173e91cdda251db13bd6465f657511c0d8 Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 1 May 2023 10:41:58 -0400 Subject: [PATCH 11/17] feat: added more device specific --- roborock/api.py | 6 ++- roborock/code_mappings.py | 103 ++++++++++++++++++++++++++++++++++---- roborock/const.py | 49 ++++++++++++++++++ roborock/containers.py | 47 ++++++++++++----- tests/test_containers.py | 31 ++++++------ 5 files changed, 197 insertions(+), 39 deletions(-) diff --git a/roborock/api.py b/roborock/api.py index 9c2bc1d3..96d3cf9e 100644 --- a/roborock/api.py +++ b/roborock/api.py @@ -224,7 +224,11 @@ async def get_status(self) -> Status | None: status = await self.send_command(RoborockCommand.GET_STATUS) if isinstance(status, dict): # TODO: Change status based off of which mop mode/ intensity/ vacuum to use - return Status.from_dict(status) + status = Status.from_dict(status) + # TODO: Check this - it seems like in our mock data, there is never model, we may need to pass this in from product + status.update_status(self.device_info.device.device_status.model_specification) + return status + return None async def get_dnd_timer(self) -> DNDTimer | None: diff --git a/roborock/code_mappings.py b/roborock/code_mappings.py index 264e29d1..c8572f16 100644 --- a/roborock/code_mappings.py +++ b/roborock/code_mappings.py @@ -1,9 +1,19 @@ from __future__ import annotations import logging -from enum import IntEnum, Enum +from dataclasses import dataclass +from enum import IntEnum from typing import Type +from roborock.const import ( + ROBOROCK_Q7_MAX, + ROBOROCK_S5_MAX, + ROBOROCK_S6_MAXV, + ROBOROCK_S6_PURE, + ROBOROCK_S7, + ROBOROCK_S7_MAXV, +) + _LOGGER = logging.getLogger(__name__) @@ -11,21 +21,21 @@ class RoborockEnum(IntEnum): """Roborock Enum for codes with int values""" @classmethod - def __missing__(cls: Type[RoborockEnum], key): - _LOGGER.debug(f"Missing {cls.__name__} code: {key}") + def __missing__(cls: Type[RoborockEnum], key) -> str: + _LOGGER.warning(f"Missing {cls.__name__} code: {key} - defaulting to {cls.keys()[0]}") return cls.keys()[0] @classmethod def as_dict(cls: Type[RoborockEnum]): # TODO: check - return {i.value: i.name for i in cls if i.name != "missing"} + return {i.value: i for i in cls if i.name != "missing"} @classmethod - def values(cls: Type[RoborockEnum]): + def values(cls: Type[RoborockEnum]) -> list[int]: return list(cls.as_dict().values()) @classmethod - def keys(cls: Type[RoborockEnum]): + def keys(cls: Type[RoborockEnum]) -> list[str]: return list(cls.as_dict().keys()) @classmethod @@ -33,7 +43,7 @@ def items(cls: Type[RoborockEnum]): return cls.as_dict().items() @classmethod - def __getitem__(cls: Type[RoborockEnum], item): + def __getitem__(cls: Type[RoborockEnum], item) -> str: return cls.__getitem__(item) @@ -96,6 +106,7 @@ class RoborockErrorCode(RoborockEnum): class RoborockFanPowerCode(RoborockEnum): """Describes the fan power of the vacuum cleaner.""" + class RoborockFanSpeedV1(RoborockFanPowerCode): silent = 38 standard = 60 @@ -144,6 +155,22 @@ class RoborockFanSpeedS7MaxV(RoborockFanPowerCode): max_plus = 108 +class RoborockFanSpeedS6Pure(RoborockFanPowerCode): + # TODO: GET CODES + missing = -9999 + + def __missing__(self, key): + return self.missing + + +class RoborockFanSpeedQ7Max(RoborockFanPowerCode): + # TODO: GET CODES + missing = -9999 + + def __missing__(self, key): + return self.missing + + class RoborockMopModeCode(RoborockEnum): """Describes the mop mode of the vacuum cleaner.""" @@ -161,7 +188,7 @@ class RoborockMopIntensityCode(RoborockEnum): """Describes the mop intensity of the vacuum cleaner.""" -class RoborockMopIntensitySy(RoborockMopIntensityCode): +class RoborockMopIntensityS7(RoborockMopIntensityCode): """Describes the mop intensity of the vacuum cleaner.""" off = 200 @@ -195,8 +222,9 @@ class RoborockDockTypeCode(RoborockEnum): empty_wash_fill_dock = 3 auto_empty_dock_pure = 5 - def __missing__(self, key): - return self.missing + @classmethod + def __missing__(cls, key): + return cls.missing class RoborockDockDustCollectionModeCode(RoborockEnum): @@ -213,7 +241,6 @@ def __missing__(self, key): return self.missing - class RoborockDockWashTowelModeCode(RoborockEnum): """Describes the wash towel mode of the vacuum cleaner.""" @@ -226,3 +253,57 @@ class RoborockDockWashTowelModeCode(RoborockEnum): def __missing__(self, key): return self.missing + +@dataclass +class ModelSpecification: + model_name: str + model_code: str + fan_power_code: Type[RoborockFanPowerCode] + mop_mode_code: Type[RoborockMopModeCode] | None + mop_intensity_code: Type[RoborockMopIntensityCode] | None + + +model_specifications = { + ROBOROCK_S5_MAX: ModelSpecification( + model_name="Roborock S5 Max", + model_code=ROBOROCK_S5_MAX, + fan_power_code=RoborockFanSpeedS6Pure, + mop_mode_code=None, + mop_intensity_code=RoborockMopIntensityV2, + ), + ROBOROCK_Q7_MAX: ModelSpecification( + model_name="Roborock Q7 Max", + model_code=ROBOROCK_Q7_MAX, + fan_power_code=RoborockFanSpeedQ7Max, + mop_mode_code=None, + mop_intensity_code=RoborockMopIntensityV2, + ), + ROBOROCK_S6_MAXV: ModelSpecification( + model_name="Roborock S6 MaxV", + model_code=ROBOROCK_S6_MAXV, + fan_power_code=RoborockFanSpeedE2, + mop_mode_code=None, + mop_intensity_code=RoborockMopIntensityV2, + ), + ROBOROCK_S6_PURE: ModelSpecification( + model_name="Roborock S6 Pure", + model_code=ROBOROCK_S6_PURE, + fan_power_code=RoborockFanSpeedS6Pure, + mop_mode_code=None, + mop_intensity_code=None, + ), + ROBOROCK_S7_MAXV: ModelSpecification( + model_name="Roborock S7 MaxV", + model_code=ROBOROCK_S7_MAXV, + fan_power_code=RoborockFanSpeedS7MaxV, + mop_mode_code=RoborockMopModeS7, + mop_intensity_code=RoborockMopIntensityS7, + ), + ROBOROCK_S7: ModelSpecification( + model_name="Roborock S7", + model_code=ROBOROCK_S7, + fan_power_code=RoborockFanSpeedS7, + mop_mode_code=RoborockMopModeS7, + mop_intensity_code=RoborockMopIntensityS7, + ), +} diff --git a/roborock/const.py b/roborock/const.py index a2f84f2f..5df7f17c 100644 --- a/roborock/const.py +++ b/roborock/const.py @@ -3,3 +3,52 @@ SIDE_BRUSH_REPLACE_TIME = 720000 FILTER_REPLACE_TIME = 540000 SENSOR_DIRTY_REPLACE_TIME = 108000 + + +ROBOROCK_V1 = "ROBOROCK.vacuum.v1" +ROBOROCK_S4 = "roborock.vacuum.s4" +ROBOROCK_S4_MAX = "roborock.vacuum.a19" +ROBOROCK_S5 = "roborock.vacuum.s5" +ROBOROCK_S5_MAX = "roborock.vacuum.s5e" +ROBOROCK_S6 = "roborock.vacuum.s6" +ROBOROCK_T6 = "roborock.vacuum.t6" # cn s6 +ROBOROCK_E4 = "roborock.vacuum.a01" +ROBOROCK_S6_PURE = "roborock.vacuum.a08" +ROBOROCK_T7 = "roborock.vacuum.a11" # cn s7 +ROBOROCK_T7S = "roborock.vacuum.a14" +ROBOROCK_T7SPLUS = "roborock.vacuum.a23" +ROBOROCK_S7_MAXV = "roborock.vacuum.a27" +ROBOROCK_S7_PRO_ULTRA = "roborock.vacuum.a62" +ROBOROCK_Q5 = "roborock.vacuum.a34" +ROBOROCK_Q7 = "roborock.vacuum.a37" # CHECK THIS +ROBOROCK_Q7_MAX = "roborock.vacuum.a38" +ROBOROCK_Q7PLUS = "roborock.vacuum.a40" +ROBOROCK_G10S = "roborock.vacuum.a46" +ROBOROCK_G10 = "roborock.vacuum.a29" +ROBOROCK_S7 = "roborock.vacuum.a15" +ROBOROCK_S6_MAXV = "roborock.vacuum.a10" +ROBOROCK_E2 = "roborock.vacuum.e2" +ROBOROCK_1S = "roborock.vacuum.m1s" +ROBOROCK_C1 = "roborock.vacuum.c1" +ROBOROCK_S8_PRO_ULTRA = "roborock.vacuum.a61" # CHECK THIS +ROBOROCK_S8 = "roborock.vacuum.a60" # CHECK THIS +ROBOROCK_WILD = "roborock.vacuum.*" # wildcard + +SUPPORTED_VACUUMS = ( + [ # These are the devices that show up when you add a device - more could be supported and just not show up + ROBOROCK_G10, + ROBOROCK_Q5, + ROBOROCK_Q7, + ROBOROCK_Q7_MAX, + ROBOROCK_S4, + ROBOROCK_S5_MAX, + ROBOROCK_S6, + ROBOROCK_S6_MAXV, + ROBOROCK_S6_PURE, + ROBOROCK_S7_MAXV, + ROBOROCK_S8_PRO_ULTRA, + ROBOROCK_S8, + ROBOROCK_S4_MAX, + ROBOROCK_S7, + ] +) diff --git a/roborock/containers.py b/roborock/containers.py index fea1b2fa..611b91c4 100644 --- a/roborock/containers.py +++ b/roborock/containers.py @@ -7,23 +7,21 @@ from dacite import Config, from_dict -from roborock.code_mappings import ( - RoborockDockTypeCode, - RoborockDockWashTowelModeCode, - RoborockMopIntensityCode, - RoborockStateCode, -) - from .code_mappings import ( RoborockDockDustCollectionModeCode, RoborockDockErrorCode, + RoborockDockTypeCode, + RoborockDockWashTowelModeCode, RoborockErrorCode, - RoborockFanPowerCode, - RoborockMopModeCode, - RoborockFanSpeedV2 + RoborockStateCode, + ModelSpecification, + model_specifications, ) -from .const import FILTER_REPLACE_TIME, MAIN_BRUSH_REPLACE_TIME, SENSOR_DIRTY_REPLACE_TIME, SIDE_BRUSH_REPLACE_TIME - +from .const import FILTER_REPLACE_TIME, MAIN_BRUSH_REPLACE_TIME, SENSOR_DIRTY_REPLACE_TIME, SIDE_BRUSH_REPLACE_TIME, \ + ROBOROCK_S7_MAXV +from .exceptions import RoborockException +import logging +_LOGGER = logging.getLogger(__name__) def camelize(s: str): first, *others = s.split("_") @@ -119,6 +117,15 @@ class HomeDataProduct(RoborockBase): capability: Optional[int] = None category: Optional[str] = None schema: Optional[list[HomeDataProductSchema]] = None + model_specification: ModelSpecification | None = None + + def __post_init__(self): + if self.model not in model_specifications: + _LOGGER.warning("We don't have specific device information for your model, please open an issue.") + self.model_specification = model_specifications.get(ROBOROCK_S7_MAXV) + else: + self.model_specification = model_specifications.get(self.model) + @dataclass @@ -132,6 +139,14 @@ class HomeDataDeviceStatus(RoborockBase): capability: Optional[Any] = None category: Optional[Any] = None schema: Optional[Any] = None + model_specification: ModelSpecification | None = None + + def __post_init__(self): + if self.model not in model_specifications: + _LOGGER.warning("We don't have specific device information for your model, please open an issue.") + self.model_specification = model_specifications.get(ROBOROCK_S7_MAXV) + else: + self.model_specification = model_specifications.get(self.model) @dataclass @@ -213,7 +228,6 @@ class Status(RoborockBase): is_locating: Optional[int] = None lock_status: Optional[int] = None water_box_mode: Optional[int] = None - mop_intensity: Optional[str] = None water_box_carriage_status: Optional[int] = None mop_forbidden_enable: Optional[int] = None camera_status: Optional[int] = None @@ -235,6 +249,13 @@ class Status(RoborockBase): unsave_map_reason: Optional[int] = None unsave_map_flag: Optional[int] = None + def update_status(self, model_specification: ModelSpecification) -> None: + self.fan_power: model_specification.fan_power_code = model_specification.fan_power_code.as_dict()[self.fan_power] + if model_specification.mop_mode_code is not None: + self.mop_mode: model_specification.mop_mode_code = model_specification.mop_mode_code.as_dict()[self.mop_mode] + if model_specification.mop_intensity_code is not None: + self.water_box_mode: model_specification.mop_intensity_code = model_specification.mop_intensity_code.as_dict()[self.water_box_mode] + @dataclass class DNDTimer(RoborockBase): diff --git a/tests/test_containers.py b/tests/test_containers.py index 0b82c0cb..47f79ce7 100644 --- a/tests/test_containers.py +++ b/tests/test_containers.py @@ -1,4 +1,4 @@ -from roborock import CleanRecord, CleanSummary, Consumable, DNDTimer, HomeData, Status, UserData +from roborock import CleanRecord, CleanSummary, Consumable, DNDTimer, HomeData, Status, UserData, ROBOROCK_S7_MAXV from roborock.code_mappings import ( RoborockDockErrorCode, RoborockDockTypeCode, @@ -6,7 +6,7 @@ RoborockFanPowerCode, RoborockMopIntensityCode, RoborockMopModeCode, - RoborockStateCode, + RoborockStateCode, model_specifications, RoborockFanSpeedS7MaxV, RoborockMopModeS7, RoborockMopIntensityS7, ) from .mock_data import CLEAN_RECORD, CLEAN_SUMMARY, CONSUMABLE, DND_TIMER, HOME_DATA_RAW, STATUS, USER_DATA @@ -111,11 +111,11 @@ def test_status(): s = Status.from_dict(STATUS) assert s.msg_ver == 2 assert s.msg_seq == 458 - assert s.state == RoborockStateCode["8"] + assert s.state == RoborockStateCode.charging assert s.battery == 100 assert s.clean_time == 1176 assert s.clean_area == 20965000 - assert s.error_code == RoborockErrorCode["0"] + assert s.error_code == RoborockErrorCode.none assert s.map_present == 1 assert s.in_cleaning == 0 assert s.in_returning == 0 @@ -125,12 +125,12 @@ def test_status(): assert s.back_type == -1 assert s.wash_phase == 0 assert s.wash_ready == 0 - assert s.fan_power == RoborockFanPowerCode["102"] + assert s.fan_power == 102 assert s.dnd_enabled == 0 assert s.map_status == 3 assert s.is_locating == 0 assert s.lock_status == 0 - assert s.water_box_mode == RoborockMopIntensityCode["203"] + assert s.water_box_mode == 203 assert s.water_box_carriage_status == 1 assert s.mop_forbidden_enable == 1 assert s.camera_status == 3457 @@ -139,18 +139,22 @@ def test_status(): assert s.home_sec_enable_password == 0 assert s.adbumper_status == [0, 0, 0] assert s.water_shortage_status == 0 - assert s.dock_type == RoborockDockTypeCode["3"] + assert s.dock_type == RoborockDockTypeCode.empty_wash_fill_dock assert s.dust_collection_status == 0 assert s.auto_dust_collection == 1 assert s.avoid_count == 19 - assert s.mop_mode == RoborockMopModeCode["300"] + assert s.mop_mode == 300 assert s.debug_mode == 0 assert s.collision_avoid_status == 1 assert s.switch_map_mode == 0 - assert s.dock_error_status == RoborockDockErrorCode["0"] + assert s.dock_error_status == RoborockDockErrorCode.ok assert s.charge_status == 1 assert s.unsave_map_reason == 0 assert s.unsave_map_flag == 0 + s.update_status(model_specification=model_specifications[ROBOROCK_S7_MAXV]) + assert s.fan_power == RoborockFanSpeedS7MaxV.balanced + assert s.mop_mode == RoborockMopModeS7.standard + assert s.water_box_mode == RoborockMopIntensityS7.intense def test_dnd_timer(): @@ -191,9 +195,8 @@ def test_clean_record(): def test_no_value(): modified_status = STATUS.copy() - modified_status["mop_mode"] = 9999 + modified_status["dock_type"] = 9999 s = Status.from_dict(modified_status) - - assert s.mop_mode == RoborockMopModeCode["-9999"] - assert "-9999" not in RoborockMopModeCode.keys() - assert "UNKNOWN" not in RoborockMopModeCode.values() + assert s.dock_type == RoborockDockTypeCode.missing + assert -9999 not in RoborockDockTypeCode.keys() + assert "missing" not in RoborockDockTypeCode.values() From 1c65189e1f0ba1377b63d37bfabdc737f224ab9d Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 1 May 2023 10:43:31 -0400 Subject: [PATCH 12/17] fix: linting --- roborock/containers.py | 23 ++++++++++++++--------- tests/test_containers.py | 11 ++++++----- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/roborock/containers.py b/roborock/containers.py index 611b91c4..c2b2bebf 100644 --- a/roborock/containers.py +++ b/roborock/containers.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging import re from dataclasses import asdict, dataclass from enum import Enum @@ -8,21 +9,26 @@ from dacite import Config, from_dict from .code_mappings import ( + ModelSpecification, RoborockDockDustCollectionModeCode, RoborockDockErrorCode, RoborockDockTypeCode, RoborockDockWashTowelModeCode, RoborockErrorCode, RoborockStateCode, - ModelSpecification, model_specifications, ) -from .const import FILTER_REPLACE_TIME, MAIN_BRUSH_REPLACE_TIME, SENSOR_DIRTY_REPLACE_TIME, SIDE_BRUSH_REPLACE_TIME, \ - ROBOROCK_S7_MAXV -from .exceptions import RoborockException -import logging +from .const import ( + FILTER_REPLACE_TIME, + MAIN_BRUSH_REPLACE_TIME, + ROBOROCK_S7_MAXV, + SENSOR_DIRTY_REPLACE_TIME, + SIDE_BRUSH_REPLACE_TIME, +) + _LOGGER = logging.getLogger(__name__) + def camelize(s: str): first, *others = s.split("_") if len(others) == 0: @@ -127,7 +133,6 @@ def __post_init__(self): self.model_specification = model_specifications.get(self.model) - @dataclass class HomeDataDeviceStatus(RoborockBase): id: Optional[Any] = None @@ -250,11 +255,11 @@ class Status(RoborockBase): unsave_map_flag: Optional[int] = None def update_status(self, model_specification: ModelSpecification) -> None: - self.fan_power: model_specification.fan_power_code = model_specification.fan_power_code.as_dict()[self.fan_power] + self.fan_power = model_specification.fan_power_code.as_dict()[self.fan_power] if model_specification.mop_mode_code is not None: - self.mop_mode: model_specification.mop_mode_code = model_specification.mop_mode_code.as_dict()[self.mop_mode] + self.mop_mode = model_specification.mop_mode_code.as_dict()[self.mop_mode] if model_specification.mop_intensity_code is not None: - self.water_box_mode: model_specification.mop_intensity_code = model_specification.mop_intensity_code.as_dict()[self.water_box_mode] + self.water_box_mode = model_specification.mop_intensity_code.as_dict()[self.water_box_mode] @dataclass diff --git a/tests/test_containers.py b/tests/test_containers.py index 47f79ce7..a902dc6b 100644 --- a/tests/test_containers.py +++ b/tests/test_containers.py @@ -1,12 +1,13 @@ -from roborock import CleanRecord, CleanSummary, Consumable, DNDTimer, HomeData, Status, UserData, ROBOROCK_S7_MAXV +from roborock import ROBOROCK_S7_MAXV, CleanRecord, CleanSummary, Consumable, DNDTimer, HomeData, Status, UserData from roborock.code_mappings import ( RoborockDockErrorCode, RoborockDockTypeCode, RoborockErrorCode, - RoborockFanPowerCode, - RoborockMopIntensityCode, - RoborockMopModeCode, - RoborockStateCode, model_specifications, RoborockFanSpeedS7MaxV, RoborockMopModeS7, RoborockMopIntensityS7, + RoborockFanSpeedS7MaxV, + RoborockMopIntensityS7, + RoborockMopModeS7, + RoborockStateCode, + model_specifications, ) from .mock_data import CLEAN_RECORD, CLEAN_SUMMARY, CONSUMABLE, DND_TIMER, HOME_DATA_RAW, STATUS, USER_DATA From e1ad90cc93d0aebb9494649aa95ea6497ca4f637 Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 1 May 2023 13:02:19 -0400 Subject: [PATCH 13/17] fix: merge issues --- roborock/__init__.py | 2 +- roborock/api.py | 3 +- roborock/cloud_api.py | 2 +- roborock/containers.py | 6 ---- roborock/local_api.py | 2 +- roborock/roborock_message.py | 2 +- roborock/{typing.py => roborock_typing.py} | 38 +++++++++++++++++++--- tests/test_containers.py | 8 ----- 8 files changed, 38 insertions(+), 25 deletions(-) rename roborock/{typing.py => roborock_typing.py} (91%) diff --git a/roborock/__init__.py b/roborock/__init__.py index dbbfbb69..de9af4cf 100644 --- a/roborock/__init__.py +++ b/roborock/__init__.py @@ -3,4 +3,4 @@ from roborock.code_mappings import * from roborock.containers import * from roborock.exceptions import * -from roborock.typing import * +from roborock.roborock_typing import * diff --git a/roborock/api.py b/roborock/api.py index a41dae0c..24f4fcfb 100644 --- a/roborock/api.py +++ b/roborock/api.py @@ -34,7 +34,6 @@ RoomMapping, SmartWashParams, Status, - StatusOldModes, UserData, WashTowelMode, ) @@ -49,7 +48,7 @@ ) from .roborock_future import RoborockFuture from .roborock_message import RoborockMessage -from .typing import DeviceProp, DockSummary, RoborockCommand +from .roborock_typing import DeviceProp, DockSummary, RoborockCommand from .util import unpack_list _LOGGER = logging.getLogger(__name__) diff --git a/roborock/cloud_api.py b/roborock/cloud_api.py index d8c30368..b4f191a5 100644 --- a/roborock/cloud_api.py +++ b/roborock/cloud_api.py @@ -15,7 +15,7 @@ from .exceptions import CommandVacuumError, RoborockException, VacuumError from .roborock_future import RoborockFuture from .roborock_message import RoborockMessage, RoborockParser, md5bin -from .typing import RoborockCommand +from .roborock_typing import RoborockCommand _LOGGER = logging.getLogger(__name__) CONNECT_REQUEST_ID = 0 diff --git a/roborock/containers.py b/roborock/containers.py index a3d473f7..30e2e9d0 100644 --- a/roborock/containers.py +++ b/roborock/containers.py @@ -271,12 +271,6 @@ def update_status(self, model_specification: ModelSpecification) -> None: self.water_box_mode] -class StatusOldModes(Status): - # type: ignore[valid-type] - water_box_mode: Optional[OldRoborockMopIntensityCode] = None - fan_power: Optional[OldRoborockFanPowerCode] = None # type: ignore[valid-type] - - @dataclass class DNDTimer(RoborockBase): start_hour: Optional[int] = None diff --git a/roborock/local_api.py b/roborock/local_api.py index a0c250cc..6d079159 100644 --- a/roborock/local_api.py +++ b/roborock/local_api.py @@ -11,7 +11,7 @@ from .containers import RoborockLocalDeviceInfo from .exceptions import CommandVacuumError, RoborockConnectionException, RoborockException from .roborock_message import RoborockMessage, RoborockParser -from .typing import CommandInfoMap, RoborockCommand +from .roborock_typing import CommandInfoMap, RoborockCommand from .util import get_running_loop_or_create_one _LOGGER = logging.getLogger(__name__) diff --git a/roborock/roborock_message.py b/roborock/roborock_message.py index 895aff57..84a1576f 100644 --- a/roborock/roborock_message.py +++ b/roborock/roborock_message.py @@ -13,7 +13,7 @@ from Crypto.Util.Padding import pad, unpad from roborock.exceptions import RoborockException -from roborock.typing import RoborockCommand +from roborock.roborock_typing import RoborockCommand def md5bin(message: str) -> bytes: diff --git a/roborock/typing.py b/roborock/roborock_typing.py similarity index 91% rename from roborock/typing.py rename to roborock/roborock_typing.py index 9fc5a33f..cc834315 100644 --- a/roborock/typing.py +++ b/roborock/roborock_typing.py @@ -94,7 +94,37 @@ class RoborockCommand(str, Enum): SAVE_MAP = "save_map" SEND_ICE_TO_ROBOT = "send_ice_to_robot" SEND_SDP_TO_ROBOT = "send_sdp_to_robot" - GET_FW_FEATURES = "get_fw_features" + SET_APP_TIMEZONE = "set_app_timezone" + SET_CAMERA_STATUS = "set_camera_status" + SET_CARPET_CLEAN_MODE = "set_carpet_clean_mode" + SET_CARPET_MODE = "set_carpet_mode" + SET_CHILD_LOCK_STATUS = "set_child_lock_status" + SET_CLEAN_MOTOR_MODE = "set_clean_motor_mode" + SET_COLLISION_AVOID_STATUS = "set_collision_avoid_status" + SET_CUSTOMIZE_CLEAN_MODE = "set_customize_clean_mode" + SET_CUSTOM_MODE = "set_custom_mode" + SET_DND_TIMER = "set_dnd_timer" + SET_DUST_COLLECTION_MODE = "set_dust_collection_mode" + SET_FDS_ENDPOINT = "set_fds_endpoint" + SET_FLOW_LED_STATUS = "set_flow_led_status" + SET_IDENTIFY_FURNITURE_STATUS = "set_identify_furniture_status" + SET_IDENTIFY_GROUND_MATERIAL_STATUS = "set_identify_ground_material_status" + SET_LED_STATUS = "set_led_status" + SET_MOP_MODE = "set_mop_mode" + SET_SERVER_TIMER = "set_server_timer" + SET_SMART_WASH_PARAMS = "set_smart_wash_params" + SET_TIMEZONE = "set_timezone" + SET_VALLEY_ELECTRICITY_TIMER = "set_valley_electricity_timer" + SET_WASH_TOWEL_MODE = "set_wash_towel_mode" + SET_WATER_BOX_CUSTOM_MODE = "set_water_box_custom_mode" + START_CAMERA_PREVIEW = "start_camera_preview" + START_EDIT_MAP = "start_edit_map" + START_VOICE_CHAT = "start_voice_chat" + START_WASH_THEN_CHARGE = "start_wash_then_charge" + STOP_CAMERA_PREVIEW = "stop_camera_preview" + SWITCH_WATER_MARK = "switch_water_mark" + TEST_SOUND_VOLUME = "test_sound_volume" + UPD_SERVER_TIMER = "upd_server_timer" @dataclass @@ -156,8 +186,7 @@ class CommandInfo: RoborockCommand.CLOSE_VALLEY_ELECTRICITY_TIMER: CommandInfo(prefix=b"\x00\x00\x00\x87", params=[]), RoborockCommand.DNLD_INSTALL_SOUND: CommandInfo( prefix=b"\x00\x00\x00\xf7", - params={"url": "https://awsusor0.fds.api.xiaomi.com/app/topazsv/voice-pkg/package/en.pkg", - "sid": 3, "sver": 5}, + params={"url": "https://awsusor0.fds.api.xiaomi.com/app/topazsv/voice-pkg/package/en.pkg", "sid": 3, "sver": 5}, ), RoborockCommand.ENABLE_LOG_UPLOAD: CommandInfo(prefix=b"\x00\x00\x00\x87", params=[9, 2]), RoborockCommand.END_EDIT_MAP: CommandInfo(prefix=b"\x00\x00\x00w", params=[]), @@ -236,8 +265,7 @@ class CommandInfo: RoborockCommand.SET_CARPET_CLEAN_MODE: CommandInfo(prefix=b"\x00\x00\x00\x97", params={"carpet_clean_mode": 0}), RoborockCommand.SET_CARPET_MODE: CommandInfo( prefix=b"\x00\x00\x00\xd7", - params=[{"enable": 1, "current_high": 500, "current_integral": 450, - "current_low": 400, "stall_time": 10}], + params=[{"enable": 1, "current_high": 500, "current_integral": 450, "current_low": 400, "stall_time": 10}], ), RoborockCommand.SET_CHILD_LOCK_STATUS: CommandInfo(prefix=b"\x00\x00\x00\x97", params={"lock_status": 0}), RoborockCommand.SET_CLEAN_MOTOR_MODE: CommandInfo( diff --git a/tests/test_containers.py b/tests/test_containers.py index 40e97caf..0203be3c 100644 --- a/tests/test_containers.py +++ b/tests/test_containers.py @@ -1,6 +1,5 @@ from roborock import ROBOROCK_S7_MAXV, CleanRecord, CleanSummary, Consumable, DNDTimer, HomeData, Status, UserData from roborock.code_mappings import ( - OldRoborockFanPowerCode, RoborockDockErrorCode, RoborockDockTypeCode, RoborockErrorCode, @@ -159,13 +158,6 @@ def test_status(): assert s.water_box_mode == RoborockMopIntensityS7.intense -def test_old_status(): - s = StatusOldModes.from_dict(STATUS) - assert s.msg_ver == 2 - assert s.msg_seq == 458 - assert s.state == RoborockStateCode["8"] - assert s.fan_power == OldRoborockFanPowerCode["102"] - def test_dnd_timer(): dnd = DNDTimer.from_dict(DND_TIMER) From bbab3d970f54d76d5dbcd656563a6c794bb006cf Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 1 May 2023 16:23:08 -0400 Subject: [PATCH 14/17] feat: finalize specific device work --- roborock/api.py | 46 ++++++++++++---------------------- roborock/cli.py | 5 +++- roborock/code_mappings.py | 50 ++++++++++++++----------------------- roborock/containers.py | 29 +++++----------------- tests/conftest.py | 4 ++- tests/test_api.py | 52 +++++++++++++++++++++++++++++++++------ tests/test_containers.py | 1 - 7 files changed, 93 insertions(+), 94 deletions(-) diff --git a/roborock/api.py b/roborock/api.py index 24f4fcfb..2d2b1fd4 100644 --- a/roborock/api.py +++ b/roborock/api.py @@ -20,7 +20,7 @@ from Crypto.Cipher import AES from Crypto.Util.Padding import unpad -from .code_mappings import RoborockDockTypeCode, RoborockEnum +from .code_mappings import RoborockDockTypeCode from .containers import ( CleanRecord, CleanSummary, @@ -150,8 +150,7 @@ def on_message_received(self, messages: list[RoborockMessage]) -> None: if endpoint.decode().startswith(self._endpoint): iv = bytes(AES.block_size) decipher = AES.new(self._nonce, AES.MODE_CBC, iv) - decrypted = unpad(decipher.decrypt( - data.payload[24:]), AES.block_size) + decrypted = unpad(decipher.decrypt(data.payload[24:]), AES.block_size) decrypted = gzip.decompress(decrypted) queue = self._waiting_queue.get(request_id) if queue: @@ -190,8 +189,7 @@ async def _async_response(self, request_id: int, protocol_id: int = 0) -> tuple[ (response, err) = await queue.async_get(QUEUE_TIMEOUT) return response, err except (asyncio.TimeoutError, asyncio.CancelledError): - raise RoborockTimeout( - f"Timeout after {QUEUE_TIMEOUT} seconds waiting for response") from None + raise RoborockTimeout(f"Timeout after {QUEUE_TIMEOUT} seconds waiting for response") from None finally: del self._waiting_queue[request_id] @@ -225,11 +223,8 @@ async def send_command(self, method: RoborockCommand, params: Optional[list | di async def get_status(self) -> Status | None: status = await self.send_command(RoborockCommand.GET_STATUS) if isinstance(status, dict): - # TODO: Change status based off of which mop mode/ intensity/ vacuum to use status = Status.from_dict(status) - # TODO: Check this - it seems like in our mock data, there is never model, we may need to pass this in from product - status.update_status( - self.device_info.device.device_status.model_specification) + status.update_status(self.device_info.model_specification) return status return None @@ -249,8 +244,7 @@ async def get_clean_summary(self) -> CleanSummary | None: if isinstance(clean_summary, dict): return CleanSummary.from_dict(clean_summary) elif isinstance(clean_summary, list): - clean_time, clean_area, clean_count, records = unpack_list( - clean_summary, 4) + clean_time, clean_area, clean_count, records = unpack_list(clean_summary, 4) return CleanSummary( clean_time=clean_time, clean_area=clean_area, clean_count=clean_count, records=records ) @@ -303,7 +297,7 @@ async def get_smart_wash_params(self) -> SmartWashParams | None: _LOGGER.error(e) return None - async def get_dock_summary(self, dock_type: RoborockEnum) -> DockSummary | None: + async def get_dock_summary(self, dock_type: RoborockDockTypeCode) -> DockSummary | None: """Gets the status summary from the dock with the methods available for a given dock. :param dock_type: RoborockDockTypeCode""" @@ -315,7 +309,7 @@ async def get_dock_summary(self, dock_type: RoborockEnum) -> DockSummary | None: DustCollectionMode | WashTowelMode | SmartWashParams | None, ] ] = [self.get_dust_collection_mode()] - if dock_type == RoborockDockTypeCode["3"]: + if dock_type == RoborockDockTypeCode.empty_wash_fill_dock: commands += [ self.get_wash_towel_mode(), self.get_smart_wash_params(), @@ -342,7 +336,7 @@ async def get_prop(self) -> DeviceProp | None: if clean_summary and clean_summary.records and len(clean_summary.records) > 0: last_clean_record = await self.get_clean_record(clean_summary.records[0]) dock_summary = None - if status and status.dock_type is not None and status.dock_type != RoborockDockTypeCode["0"]: + if status and status.dock_type is not None and status.dock_type != RoborockDockTypeCode.no_dock: dock_summary = await self.get_dock_summary(status.dock_type) if any([status, dnd_timer, clean_summary, consumable]): return DeviceProp( @@ -437,11 +431,9 @@ async def request_code(self) -> None: response_code = code_response.get("code") if response_code != 200: if response_code == 2008: - raise RoborockAccountDoesNotExist( - "Account does not exist - check your login and try again.") + raise RoborockAccountDoesNotExist("Account does not exist - check your login and try again.") else: - raise RoborockException( - f"{code_response.get('msg')} - response code: {code_response.get('code')}") + raise RoborockException(f"{code_response.get('msg')} - response code: {code_response.get('code')}") async def pass_login(self, password: str) -> UserData: base_url = await self._get_base_url() @@ -460,8 +452,7 @@ async def pass_login(self, password: str) -> UserData: if login_response is None: raise RoborockException("Login response is none") if login_response.get("code") != 200: - raise RoborockException( - f"{login_response.get('msg')} - response code: {login_response.get('code')}") + raise RoborockException(f"{login_response.get('msg')} - response code: {login_response.get('code')}") user_data = login_response.get("data") if not isinstance(user_data, dict): raise RoborockException("Got unexpected data type for user_data") @@ -486,10 +477,8 @@ async def code_login(self, code) -> UserData: response_code = login_response.get("code") if response_code != 200: if response_code == 2018: - raise RoborockInvalidCode( - "Invalid code - check your code and try again.") - raise RoborockException( - f"{login_response.get('msg')} - response code: {response_code}") + raise RoborockInvalidCode("Invalid code - check your code and try again.") + raise RoborockException(f"{login_response.get('msg')} - response code: {response_code}") user_data = login_response.get("data") if not isinstance(user_data, dict): raise RoborockException("Got unexpected data type for user_data") @@ -501,8 +490,7 @@ async def get_home_data(self, user_data: UserData) -> HomeData: rriot = user_data.rriot if rriot is None: raise RoborockException("rriot is none") - home_id_request = PreparedRequest( - base_url, {"header_clientid": header_clientid}) + home_id_request = PreparedRequest(base_url, {"header_clientid": header_clientid}) home_id_response = await home_id_request.request( "get", "/api/v1/getHomeDetail", @@ -511,8 +499,7 @@ async def get_home_data(self, user_data: UserData) -> HomeData: if home_id_response is None: raise RoborockException("home_id_response is None") if home_id_response.get("code") != 200: - raise RoborockException( - f"{home_id_response.get('msg')} - response code: {home_id_response.get('code')}") + raise RoborockException(f"{home_id_response.get('msg')} - response code: {home_id_response.get('code')}") home_id = home_id_response["data"].get("rrHomeId") timestamp = math.floor(time.time()) @@ -528,8 +515,7 @@ async def get_home_data(self, user_data: UserData) -> HomeData: "", ] ) - mac = base64.b64encode( - hmac.new(rriot.h.encode(), prestr.encode(), hashlib.sha256).digest()).decode() + mac = base64.b64encode(hmac.new(rriot.h.encode(), prestr.encode(), hashlib.sha256).digest()).decode() if rriot.r.a is None: raise RoborockException("Missing field 'a' in rriot reference") home_request = PreparedRequest( diff --git a/roborock/cli.py b/roborock/cli.py index 8669be46..f768a195 100644 --- a/roborock/cli.py +++ b/roborock/cli.py @@ -123,7 +123,10 @@ async def command(ctx, cmd, device_id, params): home_data = login_data.home_data devices = home_data.devices + home_data.received_devices device = next((device for device in devices if device.duid == device_id), None) - device_info = RoborockDeviceInfo(device=device) + model_specification = next( + (product.model_specification for product in home_data.products if product.did == device.duid), None + ) + device_info = RoborockDeviceInfo(device=device, model_specification=model_specification) mqtt_client = RoborockMqttClient(login_data.user_data, device_info) await mqtt_client.send_command(cmd, params) mqtt_client.__del__() diff --git a/roborock/code_mappings.py b/roborock/code_mappings.py index 5b373a1a..8b174fbe 100644 --- a/roborock/code_mappings.py +++ b/roborock/code_mappings.py @@ -21,9 +21,11 @@ class RoborockEnum(IntEnum): """Roborock Enum for codes with int values""" @classmethod - def __missing__(cls: Type[RoborockEnum], key) -> str: - _LOGGER.warning( - f"Missing {cls.__name__} code: {key} - defaulting to {cls.keys()[0]}") + def _missing_(cls: Type[RoborockEnum], key) -> str: + if hasattr(cls, "missing"): + _LOGGER.warning(f"Missing {cls.__name__} code: {key} - defaulting to 'missing'") + return cls.missing # type: ignore + _LOGGER.warning(f"Missing {cls.__name__} code: {key} - defaulting to {cls.keys()[0]}") return cls.keys()[0] @classmethod @@ -43,10 +45,6 @@ def keys(cls: Type[RoborockEnum]) -> list[str]: def items(cls: Type[RoborockEnum]): return cls.as_dict().items() - @classmethod - def __getitem__(cls: Type[RoborockEnum], item) -> str: - return cls.__getitem__(item) - class RoborockStateCode(RoborockEnum): starting = 1 @@ -141,10 +139,11 @@ class RoborockFanSpeedE2(RoborockFanPowerCode): class RoborockFanSpeedS7(RoborockFanPowerCode): off = 105 - silent = 101 - standard = 102 - medium = 103 - turbo = 104 + quiet = 101 + balanced = 102 + turbo = 103 + max = 104 + custom = 106 class RoborockFanSpeedS7MaxV(RoborockFanPowerCode): @@ -157,19 +156,18 @@ class RoborockFanSpeedS7MaxV(RoborockFanPowerCode): class RoborockFanSpeedS6Pure(RoborockFanPowerCode): - # TODO: GET CODES - missing = -9999 - - def __missing__(self, key): - return self.missing + gentle = 105 + quiet = 101 + balanced = 102 + turbo = 103 + max = 104 class RoborockFanSpeedQ7Max(RoborockFanPowerCode): - # TODO: GET CODES - missing = -9999 - - def __missing__(self, key): - return self.missing + quiet = 101 + balanced = 102 + turbo = 103 + max = 104 class RoborockMopModeCode(RoborockEnum): @@ -223,10 +221,6 @@ class RoborockDockTypeCode(RoborockEnum): empty_wash_fill_dock = 3 auto_empty_dock_pure = 5 - @classmethod - def __missing__(cls, key): - return cls.missing - class RoborockDockDustCollectionModeCode(RoborockEnum): """Describes the dust collection mode of the vacuum cleaner.""" @@ -238,9 +232,6 @@ class RoborockDockDustCollectionModeCode(RoborockEnum): balanced = 2 max = 4 - def __missing__(self, key): - return self.missing - class RoborockDockWashTowelModeCode(RoborockEnum): """Describes the wash towel mode of the vacuum cleaner.""" @@ -251,9 +242,6 @@ class RoborockDockWashTowelModeCode(RoborockEnum): balanced = 1 deep = 2 - def __missing__(self, key): - return self.missing - @dataclass class ModelSpecification: diff --git a/roborock/containers.py b/roborock/containers.py index 30e2e9d0..dbb3310c 100644 --- a/roborock/containers.py +++ b/roborock/containers.py @@ -65,8 +65,7 @@ def from_dict(cls, data: dict[str, Any]): def as_dict(self) -> dict: return asdict( self, - dict_factory=lambda _fields: {camelize(key): value for ( - key, value) in _fields if value is not None}, + dict_factory=lambda _fields: {camelize(key): value for (key, value) in _fields if value is not None}, ) @@ -128,8 +127,7 @@ class HomeDataProduct(RoborockBase): def __post_init__(self): if self.model not in model_specifications: - _LOGGER.warning( - "We don't have specific device information for your model, please open an issue.") + _LOGGER.warning("We don't have specific device information for your model, please open an issue.") self.model_specification = model_specifications.get(ROBOROCK_S7_MAXV) else: self.model_specification = model_specifications.get(self.model) @@ -148,14 +146,6 @@ class HomeDataDeviceStatus(RoborockBase): schema: Optional[Any] = None model_specification: ModelSpecification | None = None - def __post_init__(self): - if self.model not in model_specifications: - _LOGGER.warning( - "We don't have specific device information for your model, please open an issue.") - self.model_specification = model_specifications.get(ROBOROCK_S7_MAXV) - else: - self.model_specification = model_specifications.get(self.model) - @dataclass class HomeDataDevice(RoborockBase): @@ -184,11 +174,6 @@ class HomeDataDevice(RoborockBase): new_feature_set: Optional[str] = None device_status: Optional[HomeDataDeviceStatus] = None silent_ota_switch: Optional[bool] = None - uses_old_codes: bool = False - - def __post_init__(self): - if self.device_status and self.device_status.model == "roborock.vacuum.a10": - self.uses_old_codes = True @dataclass @@ -267,8 +252,7 @@ def update_status(self, model_specification: ModelSpecification) -> None: if model_specification.mop_mode_code is not None: self.mop_mode = model_specification.mop_mode_code.as_dict()[self.mop_mode] if model_specification.mop_intensity_code is not None: - self.water_box_mode = model_specification.mop_intensity_code.as_dict()[ - self.water_box_mode] + self.water_box_mode = model_specification.mop_intensity_code.as_dict()[self.water_box_mode] @dataclass @@ -328,10 +312,8 @@ def __post_init__(self): self.side_brush_time_left = ( SIDE_BRUSH_REPLACE_TIME - self.side_brush_work_time if self.side_brush_work_time else None ) - self.filter_time_left = FILTER_REPLACE_TIME - \ - self.filter_work_time if self.filter_work_time else None - self.sensor_time_left = SENSOR_DIRTY_REPLACE_TIME - \ - self.sensor_dirty_time if self.sensor_dirty_time else None + self.filter_time_left = FILTER_REPLACE_TIME - self.filter_work_time if self.filter_work_time else None + self.sensor_time_left = SENSOR_DIRTY_REPLACE_TIME - self.sensor_dirty_time if self.sensor_dirty_time else None @dataclass @@ -389,6 +371,7 @@ class NetworkInfo(RoborockBase): @dataclass class RoborockDeviceInfo(RoborockBase): device: HomeDataDevice + model_specification: ModelSpecification @dataclass diff --git a/tests/conftest.py b/tests/conftest.py index d5cb5a54..7d55bdfe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,9 @@ def mqtt_client(): user_data = UserData.from_dict(USER_DATA) home_data = HomeData.from_dict(HOME_DATA_RAW) - device_info = RoborockDeviceInfo(device=home_data.devices[0]) + device_info = RoborockDeviceInfo( + device=home_data.devices[0], model_specification=home_data.products[0].model_specification + ) client = RoborockMqttClient(user_data, device_info) yield client # Clean up any resources after the test diff --git a/tests/test_api.py b/tests/test_api.py index 6aaeaa20..7eb0ab5b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -3,11 +3,17 @@ import paho.mqtt.client as mqtt import pytest -from roborock import HomeData, RoborockDockDustCollectionModeCode, RoborockDockWashTowelModeCode, UserData +from roborock import ( + HomeData, + RoborockDockDustCollectionModeCode, + RoborockDockTypeCode, + RoborockDockWashTowelModeCode, + UserData, +) from roborock.api import PreparedRequest, RoborockApiClient from roborock.cloud_api import RoborockMqttClient -from roborock.containers import RoborockDeviceInfo -from tests.mock_data import BASE_URL_REQUEST, GET_CODE_RESPONSE, HOME_DATA_RAW, USER_DATA +from roborock.containers import RoborockDeviceInfo, Status +from tests.mock_data import BASE_URL_REQUEST, GET_CODE_RESPONSE, HOME_DATA_RAW, STATUS, USER_DATA def test_can_create_roborock_client(): @@ -20,7 +26,9 @@ def test_can_create_prepared_request(): def test_can_create_mqtt_roborock(): home_data = HomeData.from_dict(HOME_DATA_RAW) - device_info = RoborockDeviceInfo(device=home_data.devices[0]) + device_info = RoborockDeviceInfo( + device=home_data.devices[0], model_specification=home_data.products[0].model_specification + ) RoborockMqttClient(UserData.from_dict(USER_DATA), device_info) @@ -69,7 +77,9 @@ async def test_get_home_data(): @pytest.mark.asyncio async def test_get_dust_collection_mode(): home_data = HomeData.from_dict(HOME_DATA_RAW) - device_info = RoborockDeviceInfo(device=home_data.devices[0]) + device_info = RoborockDeviceInfo( + device=home_data.devices[0], model_specification=home_data.products[0].model_specification + ) rmc = RoborockMqttClient(UserData.from_dict(USER_DATA), device_info) with patch("roborock.cloud_api.RoborockMqttClient.send_command") as command: command.return_value = {"mode": 1} @@ -81,7 +91,9 @@ async def test_get_dust_collection_mode(): @pytest.mark.asyncio async def test_get_mop_wash_mode(): home_data = HomeData.from_dict(HOME_DATA_RAW) - device_info = RoborockDeviceInfo(device=home_data.devices[0]) + device_info = RoborockDeviceInfo( + device=home_data.devices[0], model_specification=home_data.products[0].model_specification + ) rmc = RoborockMqttClient(UserData.from_dict(USER_DATA), device_info) with patch("roborock.cloud_api.RoborockMqttClient.send_command") as command: command.return_value = {"smart_wash": 0, "wash_interval": 1500} @@ -94,10 +106,36 @@ async def test_get_mop_wash_mode(): @pytest.mark.asyncio async def test_get_washing_mode(): home_data = HomeData.from_dict(HOME_DATA_RAW) - device_info = RoborockDeviceInfo(device=home_data.devices[0]) + device_info = RoborockDeviceInfo( + device=home_data.devices[0], model_specification=home_data.products[0].model_specification + ) rmc = RoborockMqttClient(UserData.from_dict(USER_DATA), device_info) with patch("roborock.cloud_api.RoborockMqttClient.send_command") as command: command.return_value = {"wash_mode": 2} washing_mode = await rmc.get_wash_towel_mode() assert washing_mode is not None assert washing_mode.wash_mode == RoborockDockWashTowelModeCode.deep + assert washing_mode.wash_mode == 2 + + +@pytest.mark.asyncio +async def test_get_prop(): + home_data = HomeData.from_dict(HOME_DATA_RAW) + device_info = RoborockDeviceInfo( + device=home_data.devices[0], model_specification=home_data.products[0].model_specification + ) + rmc = RoborockMqttClient(UserData.from_dict(USER_DATA), device_info) + with patch("roborock.cloud_api.RoborockMqttClient.get_status") as get_status, patch( + "roborock.cloud_api.RoborockMqttClient.send_command" + ), patch("roborock.cloud_api.RoborockMqttClient.get_dust_collection_mode"): + status = Status.from_dict(STATUS) + status.update_status(home_data.products[0].model_specification) + status.dock_type = RoborockDockTypeCode.auto_empty_dock_pure + get_status.return_value = status + + props = await rmc.get_prop() + assert props + assert props.dock_summary + assert props.dock_summary.wash_towel_mode is None + assert props.dock_summary.smart_wash_params is None + assert props.dock_summary.dust_collection_mode is not None diff --git a/tests/test_containers.py b/tests/test_containers.py index 0203be3c..a902dc6b 100644 --- a/tests/test_containers.py +++ b/tests/test_containers.py @@ -158,7 +158,6 @@ def test_status(): assert s.water_box_mode == RoborockMopIntensityS7.intense - def test_dnd_timer(): dnd = DNDTimer.from_dict(DND_TIMER) assert dnd.start_hour == 22 From b7729ba5f521daf0907a25e4a76d18b12567aa65 Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 1 May 2023 20:49:38 -0400 Subject: [PATCH 15/17] feat: finished specific device with current info --- roborock/api.py | 2 +- roborock/code_mappings.py | 103 ++++++++++++++++++++++-------------- roborock/containers.py | 14 +++-- roborock/roborock_typing.py | 2 +- tests/test_containers.py | 2 +- 5 files changed, 75 insertions(+), 48 deletions(-) diff --git a/roborock/api.py b/roborock/api.py index 7c9ee107..f9fa7880 100644 --- a/roborock/api.py +++ b/roborock/api.py @@ -166,7 +166,7 @@ def on_message_received(self, messages: list[RoborockMessage]) -> None: def on_connection_lost(self, exc: Optional[Exception]) -> None: self._last_disconnection = self.time_func() - _LOGGER.warning("Roborock client disconnected") + _LOGGER.info("Roborock client disconnected") if exc is not None: _LOGGER.warning(exc) diff --git a/roborock/code_mappings.py b/roborock/code_mappings.py index 8b174fbe..99f24607 100644 --- a/roborock/code_mappings.py +++ b/roborock/code_mappings.py @@ -12,6 +12,7 @@ ROBOROCK_S6_PURE, ROBOROCK_S7, ROBOROCK_S7_MAXV, + ROBOROCK_S8_PRO_ULTRA, ) _LOGGER = logging.getLogger(__name__) @@ -30,7 +31,10 @@ def _missing_(cls: Type[RoborockEnum], key) -> str: @classmethod def as_dict(cls: Type[RoborockEnum]): - # TODO: check + return {i.value: i.name for i in cls if i.name != "missing"} + + @classmethod + def as_enum_dict(cls: Type[RoborockEnum]): return {i.value: i for i in cls if i.name != "missing"} @classmethod @@ -105,69 +109,72 @@ class RoborockErrorCode(RoborockEnum): class RoborockFanPowerCode(RoborockEnum): """Describes the fan power of the vacuum cleaner.""" + # Fan speeds should have the first letter capitalized - as there is no way to change the name in translations as + # far as I am aware + class RoborockFanSpeedV1(RoborockFanPowerCode): - silent = 38 - standard = 60 - medium = 77 - turbo = 90 + Silent = 38 + Standard = 60 + Medium = 77 + Turbo = 90 class RoborockFanSpeedV2(RoborockFanPowerCode): - silent = 101 - balanced = 102 - turbo = 103 - max = 104 - gentle = 105 - auto = 106 + Silent = 101 + Balanced = 102 + Turbo = 103 + Max = 104 + Gentle = 105 + Auto = 106 class RoborockFanSpeedV3(RoborockFanPowerCode): - silent = 38 - standard = 60 - medium = 75 - turbo = 100 + Silent = 38 + Standard = 60 + Medium = 75 + Turbo = 100 class RoborockFanSpeedE2(RoborockFanPowerCode): - gentle = 41 - silent = 50 - standard = 68 - medium = 79 - turbo = 100 + Gentle = 41 + Silent = 50 + Standard = 68 + Medium = 79 + Turbo = 100 class RoborockFanSpeedS7(RoborockFanPowerCode): - off = 105 - quiet = 101 - balanced = 102 - turbo = 103 - max = 104 - custom = 106 + Off = 105 + Quiet = 101 + Balanced = 102 + Turbo = 103 + Max = 104 + Custom = 106 class RoborockFanSpeedS7MaxV(RoborockFanPowerCode): - off = 105 - quiet = 101 - balanced = 102 - turbo = 103 - max = 104 - max_plus = 108 + Off = 105 + Quiet = 101 + Balanced = 102 + Turbo = 103 + Max = 104 + Max_plus = 108 class RoborockFanSpeedS6Pure(RoborockFanPowerCode): - gentle = 105 - quiet = 101 - balanced = 102 - turbo = 103 - max = 104 + Gentle = 105 + Quiet = 101 + Balanced = 102 + Turbo = 103 + Max = 104 class RoborockFanSpeedQ7Max(RoborockFanPowerCode): - quiet = 101 - balanced = 102 - turbo = 103 - max = 104 + Quiet = 101 + Balanced = 102 + Turbo = 103 + Max = 104 class RoborockMopModeCode(RoborockEnum): @@ -183,6 +190,13 @@ class RoborockMopModeS7(RoborockMopModeCode): deep_plus = 303 +class RoborockMopModeS8ProUltra(RoborockMopModeCode): + # TODO: Add code for fast + standard = 300 + deep = 301 + deep_plus = 303 + + class RoborockMopIntensityCode(RoborockEnum): """Describes the mop intensity of the vacuum cleaner.""" @@ -295,4 +309,11 @@ class ModelSpecification: mop_mode_code=RoborockMopModeS7, mop_intensity_code=RoborockMopIntensityS7, ), + ROBOROCK_S8_PRO_ULTRA: ModelSpecification( + model_name="Roborock S8 Pro Ultra", + model_code=ROBOROCK_S8_PRO_ULTRA, + fan_power_code=RoborockFanSpeedS7MaxV, + mop_mode_code=RoborockMopModeS8ProUltra, + mop_intensity_code=RoborockMopIntensityS7, + ), } diff --git a/roborock/containers.py b/roborock/containers.py index 6bbf8f3a..26a152be 100644 --- a/roborock/containers.py +++ b/roborock/containers.py @@ -4,7 +4,7 @@ import re from dataclasses import asdict, dataclass from enum import Enum -from typing import Any, Optional +from typing import Any, Optional, Type from dacite import Config, from_dict @@ -15,6 +15,9 @@ RoborockDockTypeCode, RoborockDockWashTowelModeCode, RoborockErrorCode, + RoborockFanPowerCode, + RoborockMopIntensityCode, + RoborockMopModeCode, RoborockStateCode, model_specifications, ) @@ -229,11 +232,13 @@ class Status(RoborockBase): wash_phase: Optional[int] = None wash_ready: Optional[int] = None fan_power: Optional[int] = None + fan_power_enum: Optional[Type[RoborockFanPowerCode]] = None dnd_enabled: Optional[int] = None map_status: Optional[int] = None is_locating: Optional[int] = None lock_status: Optional[int] = None water_box_mode: Optional[int] = None + water_box_mode_enum: Optional[Type[RoborockMopIntensityCode]] = None water_box_carriage_status: Optional[int] = None mop_forbidden_enable: Optional[int] = None camera_status: Optional[int] = None @@ -247,6 +252,7 @@ class Status(RoborockBase): auto_dust_collection: Optional[int] = None avoid_count: Optional[int] = None mop_mode: Optional[int] = None + mop_mode_enum: Optional[Type[RoborockMopModeCode]] = None debug_mode: Optional[int] = None collision_avoid_status: Optional[int] = None switch_map_mode: Optional[int] = None @@ -256,11 +262,11 @@ class Status(RoborockBase): unsave_map_flag: Optional[int] = None def update_status(self, model_specification: ModelSpecification) -> None: - self.fan_power = model_specification.fan_power_code.as_dict()[self.fan_power] + self.fan_power_enum = model_specification.fan_power_code.as_enum_dict()[self.fan_power] if model_specification.mop_mode_code is not None: - self.mop_mode = model_specification.mop_mode_code.as_dict()[self.mop_mode] + self.mop_mode_enum = model_specification.mop_mode_code.as_enum_dict()[self.mop_mode] if model_specification.mop_intensity_code is not None: - self.water_box_mode = model_specification.mop_intensity_code.as_dict()[self.water_box_mode] + self.water_box_mode_enum = model_specification.mop_intensity_code.as_enum_dict()[self.water_box_mode] @dataclass diff --git a/roborock/roborock_typing.py b/roborock/roborock_typing.py index cc834315..a43fc444 100644 --- a/roborock/roborock_typing.py +++ b/roborock/roborock_typing.py @@ -275,7 +275,7 @@ class CommandInfo: RoborockCommand.SET_CUSTOMIZE_CLEAN_MODE: CommandInfo( prefix=b"\x00\x00\x00\xa7", params={"data": [], "need_retry": 1} ), - RoborockCommand.SET_CUSTOM_MODE: CommandInfo(prefix=b"\x00\x00\x00\x87", params=[108]), + RoborockCommand.SET_CUSTOM_MODE: CommandInfo(prefix=b"\x00\x00\x00w", params=[108]), RoborockCommand.SET_DND_TIMER: CommandInfo(prefix=b"\x00\x00\x00\x87", params=[22, 0, 8, 0]), RoborockCommand.SET_DUST_COLLECTION_MODE: CommandInfo(prefix=b"\x00\x00\x00\x87", params=None), RoborockCommand.SET_FDS_ENDPOINT: CommandInfo(prefix=b"\x00\x00\x00\x97", params=["awsusor0.fds.api.xiaomi.com"]), diff --git a/tests/test_containers.py b/tests/test_containers.py index a902dc6b..e91dc59d 100644 --- a/tests/test_containers.py +++ b/tests/test_containers.py @@ -153,7 +153,7 @@ def test_status(): assert s.unsave_map_reason == 0 assert s.unsave_map_flag == 0 s.update_status(model_specification=model_specifications[ROBOROCK_S7_MAXV]) - assert s.fan_power == RoborockFanSpeedS7MaxV.balanced + assert s.fan_power == RoborockFanSpeedS7MaxV.Balanced assert s.mop_mode == RoborockMopModeS7.standard assert s.water_box_mode == RoborockMopIntensityS7.intense From 3e85b9f66ad2f0f20ccd68dedcb05d8c1453231b Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 1 May 2023 20:54:34 -0400 Subject: [PATCH 16/17] fix: add fast for S8 --- roborock/code_mappings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roborock/code_mappings.py b/roborock/code_mappings.py index 99f24607..41f6cbf1 100644 --- a/roborock/code_mappings.py +++ b/roborock/code_mappings.py @@ -191,10 +191,10 @@ class RoborockMopModeS7(RoborockMopModeCode): class RoborockMopModeS8ProUltra(RoborockMopModeCode): - # TODO: Add code for fast standard = 300 deep = 301 deep_plus = 303 + fast = 304 class RoborockMopIntensityCode(RoborockEnum): From d7414a80d608ba6b30099393727a115c6daa8805 Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 1 May 2023 21:09:03 -0400 Subject: [PATCH 17/17] fix: add s8 dock --- roborock/api.py | 2 +- roborock/code_mappings.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/roborock/api.py b/roborock/api.py index f9fa7880..6348dd39 100644 --- a/roborock/api.py +++ b/roborock/api.py @@ -309,7 +309,7 @@ async def get_dock_summary(self, dock_type: RoborockDockTypeCode) -> DockSummary DustCollectionMode | WashTowelMode | SmartWashParams | None, ] ] = [self.get_dust_collection_mode()] - if dock_type == RoborockDockTypeCode.empty_wash_fill_dock: + if dock_type == RoborockDockTypeCode.empty_wash_fill_dock or dock_type == RoborockDockTypeCode.s8_dock: commands += [ self.get_wash_towel_mode(), self.get_smart_wash_params(), diff --git a/roborock/code_mappings.py b/roborock/code_mappings.py index 41f6cbf1..b50fa145 100644 --- a/roborock/code_mappings.py +++ b/roborock/code_mappings.py @@ -234,6 +234,7 @@ class RoborockDockTypeCode(RoborockEnum): no_dock = 0 empty_wash_fill_dock = 3 auto_empty_dock_pure = 5 + s8_dock = 7 class RoborockDockDustCollectionModeCode(RoborockEnum):