diff --git a/poetry.lock b/poetry.lock index 728771b..2d4bdf4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -930,28 +930,28 @@ files = [ [[package]] name = "ruff" -version = "0.2.2" +version = "0.3.0" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.2.2-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:0a9efb032855ffb3c21f6405751d5e147b0c6b631e3ca3f6b20f917572b97eb6"}, - {file = "ruff-0.2.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d450b7fbff85913f866a5384d8912710936e2b96da74541c82c1b458472ddb39"}, - {file = "ruff-0.2.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecd46e3106850a5c26aee114e562c329f9a1fbe9e4821b008c4404f64ff9ce73"}, - {file = "ruff-0.2.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e22676a5b875bd72acd3d11d5fa9075d3a5f53b877fe7b4793e4673499318ba"}, - {file = "ruff-0.2.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1695700d1e25a99d28f7a1636d85bafcc5030bba9d0578c0781ba1790dbcf51c"}, - {file = "ruff-0.2.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b0c232af3d0bd8f521806223723456ffebf8e323bd1e4e82b0befb20ba18388e"}, - {file = "ruff-0.2.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f63d96494eeec2fc70d909393bcd76c69f35334cdbd9e20d089fb3f0640216ca"}, - {file = "ruff-0.2.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a61ea0ff048e06de273b2e45bd72629f470f5da8f71daf09fe481278b175001"}, - {file = "ruff-0.2.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e1439c8f407e4f356470e54cdecdca1bd5439a0673792dbe34a2b0a551a2fe3"}, - {file = "ruff-0.2.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:940de32dc8853eba0f67f7198b3e79bc6ba95c2edbfdfac2144c8235114d6726"}, - {file = "ruff-0.2.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0c126da55c38dd917621552ab430213bdb3273bb10ddb67bc4b761989210eb6e"}, - {file = "ruff-0.2.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3b65494f7e4bed2e74110dac1f0d17dc8e1f42faaa784e7c58a98e335ec83d7e"}, - {file = "ruff-0.2.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1ec49be4fe6ddac0503833f3ed8930528e26d1e60ad35c2446da372d16651ce9"}, - {file = "ruff-0.2.2-py3-none-win32.whl", hash = "sha256:d920499b576f6c68295bc04e7b17b6544d9d05f196bb3aac4358792ef6f34325"}, - {file = "ruff-0.2.2-py3-none-win_amd64.whl", hash = "sha256:cc9a91ae137d687f43a44c900e5d95e9617cb37d4c989e462980ba27039d239d"}, - {file = "ruff-0.2.2-py3-none-win_arm64.whl", hash = "sha256:c9d15fc41e6054bfc7200478720570078f0b41c9ae4f010bcc16bd6f4d1aacdd"}, - {file = "ruff-0.2.2.tar.gz", hash = "sha256:e62ed7f36b3068a30ba39193a14274cd706bc486fad521276458022f7bccb31d"}, + {file = "ruff-0.3.0-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7deb528029bacf845bdbb3dbb2927d8ef9b4356a5e731b10eef171e3f0a85944"}, + {file = "ruff-0.3.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e1e0d4381ca88fb2b73ea0766008e703f33f460295de658f5467f6f229658c19"}, + {file = "ruff-0.3.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f7dbba46e2827dfcb0f0cc55fba8e96ba7c8700e0a866eb8cef7d1d66c25dcb"}, + {file = "ruff-0.3.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:23dbb808e2f1d68eeadd5f655485e235c102ac6f12ad31505804edced2a5ae77"}, + {file = "ruff-0.3.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ef655c51f41d5fa879f98e40c90072b567c666a7114fa2d9fe004dffba00932"}, + {file = "ruff-0.3.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d0d3d7ef3d4f06433d592e5f7d813314a34601e6c5be8481cccb7fa760aa243e"}, + {file = "ruff-0.3.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b08b356d06a792e49a12074b62222f9d4ea2a11dca9da9f68163b28c71bf1dd4"}, + {file = "ruff-0.3.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9343690f95710f8cf251bee1013bf43030072b9f8d012fbed6ad702ef70d360a"}, + {file = "ruff-0.3.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1f3ed501a42f60f4dedb7805fa8d4534e78b4e196f536bac926f805f0743d49"}, + {file = "ruff-0.3.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cc30a9053ff2f1ffb505a585797c23434d5f6c838bacfe206c0e6cf38c921a1e"}, + {file = "ruff-0.3.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5da894a29ec018a8293d3d17c797e73b374773943e8369cfc50495573d396933"}, + {file = "ruff-0.3.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:755c22536d7f1889be25f2baf6fedd019d0c51d079e8417d4441159f3bcd30c2"}, + {file = "ruff-0.3.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dd73fe7f4c28d317855da6a7bc4aa29a1500320818dd8f27df95f70a01b8171f"}, + {file = "ruff-0.3.0-py3-none-win32.whl", hash = "sha256:19eacceb4c9406f6c41af806418a26fdb23120dfe53583df76d1401c92b7c14b"}, + {file = "ruff-0.3.0-py3-none-win_amd64.whl", hash = "sha256:128265876c1d703e5f5e5a4543bd8be47c73a9ba223fd3989d4aa87dd06f312f"}, + {file = "ruff-0.3.0-py3-none-win_arm64.whl", hash = "sha256:e3a4a6d46aef0a84b74fcd201a4401ea9a6cd85614f6a9435f2d33dd8cefbf83"}, + {file = "ruff-0.3.0.tar.gz", hash = "sha256:0886184ba2618d815067cf43e005388967b67ab9c80df52b32ec1152ab49f53a"}, ] [[package]] @@ -997,13 +997,13 @@ files = [ [[package]] name = "typing-extensions" -version = "4.9.0" +version = "4.10.0" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, - {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, + {file = "typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475"}, + {file = "typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 0a687ef..383062c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ construct = "^2.10.57" [build-system] -requires = ["poetry-core==1.7.1"] +requires = ["poetry-core==1.8.0"] build-backend = "poetry.core.masonry.api" [tool.poetry.group.dev.dependencies] diff --git a/roborock/api.py b/roborock/api.py index 86d2a71..92b579c 100644 --- a/roborock/api.py +++ b/roborock/api.py @@ -8,36 +8,20 @@ import hashlib import json import logging -import math import secrets import struct import time from collections.abc import Callable, Coroutine -from random import randint from typing import Any, TypeVar, final -from .code_mappings import RoborockDockTypeCode from .command_cache import CacheableAttribute, CommandType, RoborockAttribute, find_cacheable_attribute, get_cache_map from .containers import ( - ChildLockStatus, - CleanRecord, - CleanSummary, Consumable, DeviceData, - DnDTimer, - DustCollectionMode, - FlowLedStatus, ModelStatus, - MultiMapsList, - NetworkInfo, RoborockBase, - RoomMapping, S7MaxVStatus, - ServerTimer, - SmartWashParams, Status, - ValleyElectricityTimer, - WashTowelMode, ) from .exceptions import ( RoborockException, @@ -54,21 +38,12 @@ RoborockMessage, RoborockMessageProtocol, ) -from .roborock_typing import DeviceProp, DockSummary, RoborockCommand -from .util import RepeatableTask, RoborockLoggerAdapter, get_running_loop_or_create_one, unpack_list +from .roborock_typing import RoborockCommand +from .util import RepeatableTask, RoborockLoggerAdapter, get_running_loop_or_create_one _LOGGER = logging.getLogger(__name__) KEEPALIVE = 60 -COMMANDS_SECURED = [ - RoborockCommand.GET_MAP_V1, - RoborockCommand.GET_MULTI_MAP, -] RT = TypeVar("RT", bound=RoborockBase) -WASH_N_FILL_DOCK = [ - RoborockDockTypeCode.empty_wash_fill_dock, - RoborockDockTypeCode.s8_dock, - RoborockDockTypeCode.p10_dock, -] def md5hex(message: str) -> str: @@ -286,7 +261,7 @@ def on_message_received(self, messages: list[RoborockMessage]) -> None: try: decrypted = Utils.decrypt_cbc(data.payload[24:], self._nonce) except ValueError as err: - raise RoborockException("Failed to decode %s for %s", data.payload, data.protocol) from err + raise RoborockException(f"Failed to decode {data.payload!r} for {data.protocol}") from err decompressed = Utils.decompress(decrypted) queue = self._waiting_queue.get(request_id) if queue: @@ -336,35 +311,6 @@ def _async_response( self._waiting_queue[request_id] = queue return self._wait_response(request_id, queue) - def _get_payload( - self, - method: RoborockCommand | str, - params: list | dict | int | None = None, - secured=False, - ): - timestamp = math.floor(time.time()) - request_id = randint(10000, 32767) - inner = { - "id": request_id, - "method": method, - "params": params or [], - } - if secured: - inner["security"] = { - "endpoint": self._endpoint, - "nonce": self._nonce.hex().lower(), - } - payload = bytes( - json.dumps( - { - "dps": {"101": json.dumps(inner, separators=(",", ":"))}, - "t": timestamp, - }, - separators=(",", ":"), - ).encode() - ) - return request_id, timestamp, payload - async def send_message(self, roborock_message: RoborockMessage): raise NotImplementedError @@ -402,148 +348,6 @@ async def send_command( return return_type.from_dict(response) return response - async def get_status(self) -> Status: - data = self._status_type.from_dict(await self.cache[CacheableAttribute.status].async_value()) - if data is None: - return self._status_type() - return data - - async def get_dnd_timer(self) -> DnDTimer | None: - return DnDTimer.from_dict(await self.cache[CacheableAttribute.dnd_timer].async_value()) - - async def get_valley_electricity_timer(self) -> ValleyElectricityTimer | None: - return ValleyElectricityTimer.from_dict( - await self.cache[CacheableAttribute.valley_electricity_timer].async_value() - ) - - async def get_clean_summary(self) -> CleanSummary | None: - clean_summary: dict | list | int = await self.send_command(RoborockCommand.GET_CLEAN_SUMMARY) - 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) - return CleanSummary( - clean_time=clean_time, - clean_area=clean_area, - clean_count=clean_count, - records=records, - ) - elif isinstance(clean_summary, int): - return CleanSummary(clean_time=clean_summary) - return None - - async def get_clean_record(self, record_id: int) -> CleanRecord | None: - record: dict | list = await self.send_command(RoborockCommand.GET_CLEAN_RECORD, [record_id]) - if isinstance(record, dict): - return CleanRecord.from_dict(record) - elif isinstance(record, list): - # There are still a few unknown variables in this. - begin, end, duration, area = unpack_list(record, 4) - return CleanRecord(begin=begin, end=end, duration=duration, area=area) - else: - _LOGGER.warning("Clean record was of a new type, please submit an issue request: %s", record) - return None - - async def get_consumable(self) -> Consumable: - data = Consumable.from_dict(await self.cache[CacheableAttribute.consumable].async_value()) - if data is None: - return Consumable() - return data - - async def get_wash_towel_mode(self) -> WashTowelMode | None: - return WashTowelMode.from_dict(await self.cache[CacheableAttribute.wash_towel_mode].async_value()) - - async def get_dust_collection_mode(self) -> DustCollectionMode | None: - return DustCollectionMode.from_dict(await self.cache[CacheableAttribute.dust_collection_mode].async_value()) - - async def get_smart_wash_params(self) -> SmartWashParams | None: - return SmartWashParams.from_dict(await self.cache[CacheableAttribute.smart_wash_params].async_value()) - - async def get_dock_summary(self, dock_type: RoborockDockTypeCode) -> DockSummary: - """Gets the status summary from the dock with the methods available for a given dock. - - :param dock_type: RoborockDockTypeCode""" - commands: list[ - Coroutine[ - Any, - Any, - DustCollectionMode | WashTowelMode | SmartWashParams | None, - ] - ] = [self.get_dust_collection_mode()] - if dock_type in WASH_N_FILL_DOCK: - commands += [ - 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 - ) # type: DustCollectionMode, WashTowelMode | None, SmartWashParams | None # type: ignore - - return DockSummary(dust_collection_mode, wash_towel_mode, smart_wash_params) - - async def get_prop(self) -> DeviceProp | None: - """Gets device general properties.""" - # Mypy thinks that each one of these is typed as a union of all the others. so we do type ignore. - status, clean_summary, consumable = await asyncio.gather( - *[ - self.get_status(), - self.get_clean_summary(), - self.get_consumable(), - ] - ) # type: Status, CleanSummary, Consumable # type: ignore - 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(clean_summary.records[0]) - dock_summary = None - 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, clean_summary, consumable]): - return DeviceProp( - status, - clean_summary, - consumable, - last_clean_record, - dock_summary, - ) - return None - - async def get_multi_maps_list(self) -> MultiMapsList | None: - return await self.send_command(RoborockCommand.GET_MULTI_MAPS_LIST, return_type=MultiMapsList) - - async def get_networking(self) -> NetworkInfo | None: - return await self.send_command(RoborockCommand.GET_NETWORK_INFO, return_type=NetworkInfo) - - async def get_room_mapping(self) -> list[RoomMapping] | None: - """Gets the mapping from segment id -> iot id. Only works on local api.""" - mapping: list = await self.send_command(RoborockCommand.GET_ROOM_MAPPING) - if isinstance(mapping, list): - return [ - RoomMapping(segment_id=segment_id, iot_id=iot_id) # type: ignore - for segment_id, iot_id in [unpack_list(room, 2) for room in mapping if isinstance(room, list)] - ] - return None - - async def get_child_lock_status(self) -> ChildLockStatus: - """Gets current child lock status.""" - return ChildLockStatus.from_dict(await self.cache[CacheableAttribute.child_lock_status].async_value()) - - async def get_flow_led_status(self) -> FlowLedStatus: - """Gets current flow led status.""" - return FlowLedStatus.from_dict(await self.cache[CacheableAttribute.flow_led_status].async_value()) - - async def get_sound_volume(self) -> int | None: - """Gets current volume level.""" - return await self.cache[CacheableAttribute.sound_volume].async_value() - - async def get_server_timer(self) -> list[ServerTimer]: - """Gets current server timer.""" - server_timers = await self.cache[CacheableAttribute.server_timer].async_value() - if server_timers: - if isinstance(server_timers[0], list): - return [ServerTimer(*server_timer) for server_timer in server_timers] - return [ServerTimer(*server_timers)] - return [] - def add_listener( self, protocol: RoborockDataProtocol, listener: Callable, cache: dict[CacheableAttribute, AttributeCache] ) -> None: diff --git a/roborock/cli.py b/roborock/cli.py index 1d5bbfc..455c191 100644 --- a/roborock/cli.py +++ b/roborock/cli.py @@ -11,10 +11,10 @@ from pyshark.packet.packet import Packet # type: ignore from roborock import RoborockException -from roborock.cloud_api import RoborockMqttClient from roborock.containers import DeviceData, LoginData from roborock.protocol import MessageParser from roborock.util import run_sync +from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1 from roborock.web_api import RoborockApiClient _LOGGER = logging.getLogger(__name__) @@ -135,7 +135,7 @@ async def command(ctx, cmd, device_id, params): if model is None: raise RoborockException(f"Could not find model for device {device.name}") device_info = DeviceData(device=device, model=model) - mqtt_client = RoborockMqttClient(login_data.user_data, device_info) + mqtt_client = RoborockMqttClientV1(login_data.user_data, device_info) await mqtt_client.send_command(cmd, json.loads(params) if params is not None else None) mqtt_client.__del__() diff --git a/roborock/cloud_api.py b/roborock/cloud_api.py index ca56e38..a8fa408 100644 --- a/roborock/cloud_api.py +++ b/roborock/cloud_api.py @@ -11,12 +11,12 @@ import paho.mqtt.client as mqtt -from .api import COMMANDS_SECURED, KEEPALIVE, RoborockClient, md5hex +from .api import KEEPALIVE, RoborockClient, md5hex from .containers import DeviceData, UserData -from .exceptions import CommandVacuumError, RoborockException, VacuumError +from .exceptions import RoborockException, VacuumError from .protocol import MessageParser, Utils from .roborock_future import RoborockFuture -from .roborock_message import RoborockMessage, RoborockMessageProtocol +from .roborock_message import RoborockMessage from .roborock_typing import RoborockCommand from .util import RoborockLoggerAdapter @@ -167,44 +167,11 @@ def _send_msg_raw(self, msg: bytes) -> None: raise RoborockException(f"Failed to publish ({mqtt.error_string(info.rc)})") async def send_message(self, roborock_message: RoborockMessage): - await self.validate_connection() - method = roborock_message.get_method() - params = roborock_message.get_params() - request_id = roborock_message.get_request_id() - if request_id is None: - raise RoborockException(f"Failed build message {roborock_message}") - response_protocol = ( - RoborockMessageProtocol.MAP_RESPONSE if method in COMMANDS_SECURED else RoborockMessageProtocol.RPC_RESPONSE - ) - - local_key = self.device_info.device.local_key - msg = MessageParser.build(roborock_message, local_key, False) - self._logger.debug(f"id={request_id} Requesting method {method} with {params}") - async_response = asyncio.ensure_future(self._async_response(request_id, response_protocol)) - self._send_msg_raw(msg) - (response, err) = await async_response - self._diagnostic_data[method if method is not None else "unknown"] = { - "params": roborock_message.get_params(), - "response": response, - "error": err, - } - if err: - raise CommandVacuumError(method, err) from err - if response_protocol == RoborockMessageProtocol.MAP_RESPONSE: - self._logger.debug(f"id={request_id} Response from {method}: {len(response)} bytes") - else: - self._logger.debug(f"id={request_id} Response from {method}: {response}") - return response + raise NotImplementedError async def _send_command( self, method: RoborockCommand | str, params: list | dict | int | None = None, ): - request_id, timestamp, payload = super()._get_payload(method, params, True) - request_protocol = RoborockMessageProtocol.RPC_REQUEST - roborock_message = RoborockMessage(timestamp=timestamp, protocol=request_protocol, payload=payload) - return await self.send_message(roborock_message) - - async def get_map_v1(self): - return await self.send_command(RoborockCommand.GET_MAP_V1) + raise NotImplementedError diff --git a/roborock/local_api.py b/roborock/local_api.py index 9ec3690..2f519fc 100644 --- a/roborock/local_api.py +++ b/roborock/local_api.py @@ -7,10 +7,10 @@ import async_timeout from . import DeviceData -from .api import COMMANDS_SECURED, RoborockClient +from .api import RoborockClient from .exceptions import CommandVacuumError, RoborockConnectionException, RoborockException from .protocol import MessageParser -from .roborock_message import MessageRetry, RoborockMessage, RoborockMessageProtocol +from .roborock_message import RoborockMessage, RoborockMessageProtocol from .roborock_typing import RoborockCommand from .util import RoborockLoggerAdapter @@ -21,7 +21,6 @@ class RoborockLocalClient(RoborockClient, asyncio.Protocol): def __init__(self, device_data: DeviceData, queue_timeout: int = 4): if device_data.host is None: raise RoborockException("Host is required") - super().__init__("abc", device_data, queue_timeout) self.host = device_data.host self._batch_structs: list[RoborockMessage] = [] self._executing = False @@ -30,6 +29,7 @@ def __init__(self, device_data: DeviceData, queue_timeout: int = 4): self._mutex = Lock() self.keep_alive_task: TimerHandle | None = None self._logger = RoborockLoggerAdapter(device_data.device.name, _LOGGER) + RoborockClient.__init__(self, "abc", device_data, queue_timeout) def data_received(self, message): if self.remaining: @@ -82,19 +82,6 @@ async def async_disconnect(self) -> None: async with self._mutex: self.sync_disconnect() - def build_roborock_message( - self, method: RoborockCommand | str, params: list | dict | int | None = None - ) -> RoborockMessage: - secured = True if method in COMMANDS_SECURED else False - request_id, timestamp, payload = self._get_payload(method, params, secured) - request_protocol = RoborockMessageProtocol.GENERAL_REQUEST - message_retry: MessageRetry | None = None - if method == RoborockCommand.RETRY_REQUEST and isinstance(params, dict): - message_retry = MessageRetry(method=params["method"], retry_id=params["retry_id"]) - return RoborockMessage( - timestamp=timestamp, protocol=request_protocol, payload=payload, message_retry=message_retry - ) - async def hello(self): request_id = 1 protocol = RoborockMessageProtocol.HELLO_REQUEST @@ -125,8 +112,7 @@ async def _send_command( method: RoborockCommand | str, params: list | dict | int | None = None, ): - roborock_message = self.build_roborock_message(method, params) - return await self.send_message(roborock_message) + raise NotImplementedError def _send_msg_raw(self, data: bytes): try: diff --git a/roborock/version_1_apis/__init__.py b/roborock/version_1_apis/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/roborock/version_1_apis/roborock_client_v1.py b/roborock/version_1_apis/roborock_client_v1.py new file mode 100644 index 0000000..908872e --- /dev/null +++ b/roborock/version_1_apis/roborock_client_v1.py @@ -0,0 +1,227 @@ +import asyncio +import json +import math +import time +from collections.abc import Coroutine +from random import randint +from typing import Any + +from roborock import DeviceProp, DockSummary, RoborockCommand, RoborockDockTypeCode +from roborock.api import RoborockClient +from roborock.command_cache import CacheableAttribute +from roborock.containers import ( + ChildLockStatus, + CleanRecord, + CleanSummary, + Consumable, + DeviceData, + DnDTimer, + DustCollectionMode, + FlowLedStatus, + ModelStatus, + MultiMapsList, + NetworkInfo, + RoomMapping, + S7MaxVStatus, + ServerTimer, + SmartWashParams, + Status, + ValleyElectricityTimer, + WashTowelMode, +) +from roborock.util import unpack_list + +COMMANDS_SECURED = [ + RoborockCommand.GET_MAP_V1, + RoborockCommand.GET_MULTI_MAP, +] + +WASH_N_FILL_DOCK = [ + RoborockDockTypeCode.empty_wash_fill_dock, + RoborockDockTypeCode.s8_dock, + RoborockDockTypeCode.p10_dock, +] + + +class RoborockClientV1(RoborockClient): + def __init__(self, device_info: DeviceData, cache, logger, endpoint: str): + super().__init__(endpoint, device_info) + self._status_type: type[Status] = ModelStatus.get(device_info.model, S7MaxVStatus) + self.cache = cache + self._logger = logger + + @property + def status_type(self) -> type[Status]: + """Gets the status type for this device""" + return self._status_type + + async def get_status(self) -> Status: + data = self._status_type.from_dict(await self.cache[CacheableAttribute.status].async_value()) + if data is None: + return self._status_type() + return data + + async def get_dnd_timer(self) -> DnDTimer | None: + return DnDTimer.from_dict(await self.cache[CacheableAttribute.dnd_timer].async_value()) + + async def get_valley_electricity_timer(self) -> ValleyElectricityTimer | None: + return ValleyElectricityTimer.from_dict( + await self.cache[CacheableAttribute.valley_electricity_timer].async_value() + ) + + async def get_clean_summary(self) -> CleanSummary | None: + clean_summary: dict | list | int = await self.send_command(RoborockCommand.GET_CLEAN_SUMMARY) + 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) + return CleanSummary( + clean_time=clean_time, + clean_area=clean_area, + clean_count=clean_count, + records=records, + ) + elif isinstance(clean_summary, int): + return CleanSummary(clean_time=clean_summary) + return None + + async def get_clean_record(self, record_id: int) -> CleanRecord | None: + record: dict | list = await self.send_command(RoborockCommand.GET_CLEAN_RECORD, [record_id]) + if isinstance(record, dict): + return CleanRecord.from_dict(record) + elif isinstance(record, list): + # There are still a few unknown variables in this. + begin, end, duration, area = unpack_list(record, 4) + return CleanRecord(begin=begin, end=end, duration=duration, area=area) + else: + self._logger.warning("Clean record was of a new type, please submit an issue request: %s", record) + return None + + async def get_consumable(self) -> Consumable: + data = Consumable.from_dict(await self.cache[CacheableAttribute.consumable].async_value()) + if data is None: + return Consumable() + return data + + async def get_wash_towel_mode(self) -> WashTowelMode | None: + return WashTowelMode.from_dict(await self.cache[CacheableAttribute.wash_towel_mode].async_value()) + + async def get_dust_collection_mode(self) -> DustCollectionMode | None: + return DustCollectionMode.from_dict(await self.cache[CacheableAttribute.dust_collection_mode].async_value()) + + async def get_smart_wash_params(self) -> SmartWashParams | None: + return SmartWashParams.from_dict(await self.cache[CacheableAttribute.smart_wash_params].async_value()) + + async def get_dock_summary(self, dock_type: RoborockDockTypeCode) -> DockSummary: + """Gets the status summary from the dock with the methods available for a given dock. + + :param dock_type: RoborockDockTypeCode""" + commands: list[ + Coroutine[ + Any, + Any, + DustCollectionMode | WashTowelMode | SmartWashParams | None, + ] + ] = [self.get_dust_collection_mode()] + if dock_type in WASH_N_FILL_DOCK: + commands += [ + 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 + ) # type: DustCollectionMode, WashTowelMode | None, SmartWashParams | None # type: ignore + + return DockSummary(dust_collection_mode, wash_towel_mode, smart_wash_params) + + async def get_prop(self) -> DeviceProp | None: + """Gets device general properties.""" + # Mypy thinks that each one of these is typed as a union of all the others. so we do type ignore. + status, clean_summary, consumable = await asyncio.gather( + *[ + self.get_status(), + self.get_clean_summary(), + self.get_consumable(), + ] + ) # type: Status, CleanSummary, Consumable # type: ignore + 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(clean_summary.records[0]) + dock_summary = None + 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, clean_summary, consumable]): + return DeviceProp( + status, + clean_summary, + consumable, + last_clean_record, + dock_summary, + ) + return None + + async def get_multi_maps_list(self) -> MultiMapsList | None: + return await self.send_command(RoborockCommand.GET_MULTI_MAPS_LIST, return_type=MultiMapsList) + + async def get_networking(self) -> NetworkInfo | None: + return await self.send_command(RoborockCommand.GET_NETWORK_INFO, return_type=NetworkInfo) + + async def get_room_mapping(self) -> list[RoomMapping] | None: + """Gets the mapping from segment id -> iot id. Only works on local api.""" + mapping: list = await self.send_command(RoborockCommand.GET_ROOM_MAPPING) + if isinstance(mapping, list): + return [ + RoomMapping(segment_id=segment_id, iot_id=iot_id) # type: ignore + for segment_id, iot_id in [unpack_list(room, 2) for room in mapping if isinstance(room, list)] + ] + return None + + async def get_child_lock_status(self) -> ChildLockStatus: + """Gets current child lock status.""" + return ChildLockStatus.from_dict(await self.cache[CacheableAttribute.child_lock_status].async_value()) + + async def get_flow_led_status(self) -> FlowLedStatus: + """Gets current flow led status.""" + return FlowLedStatus.from_dict(await self.cache[CacheableAttribute.flow_led_status].async_value()) + + async def get_sound_volume(self) -> int | None: + """Gets current volume level.""" + return await self.cache[CacheableAttribute.sound_volume].async_value() + + async def get_server_timer(self) -> list[ServerTimer]: + """Gets current server timer.""" + server_timers = await self.cache[CacheableAttribute.server_timer].async_value() + if server_timers: + if isinstance(server_timers[0], list): + return [ServerTimer(*server_timer) for server_timer in server_timers] + return [ServerTimer(*server_timers)] + return [] + + def _get_payload( + self, + method: RoborockCommand | str, + params: list | dict | int | None = None, + secured=False, + ): + timestamp = math.floor(time.time()) + request_id = randint(10000, 32767) + inner = { + "id": request_id, + "method": method, + "params": params or [], + } + if secured: + inner["security"] = { + "endpoint": self._endpoint, + "nonce": self._nonce.hex().lower(), + } + payload = bytes( + json.dumps( + { + "dps": {"101": json.dumps(inner, separators=(",", ":"))}, + "t": timestamp, + }, + separators=(",", ":"), + ).encode() + ) + return request_id, timestamp, payload diff --git a/roborock/version_1_apis/roborock_local_client_v1.py b/roborock/version_1_apis/roborock_local_client_v1.py new file mode 100644 index 0000000..5ef1567 --- /dev/null +++ b/roborock/version_1_apis/roborock_local_client_v1.py @@ -0,0 +1,32 @@ +from roborock.local_api import RoborockLocalClient + +from .. import DeviceData, RoborockCommand +from ..roborock_message import MessageRetry, RoborockMessage, RoborockMessageProtocol +from .roborock_client_v1 import COMMANDS_SECURED, RoborockClientV1 + + +class RoborockLocalClientV1(RoborockLocalClient, RoborockClientV1): + def __init__(self, device_data: DeviceData, queue_timeout: int = 4): + RoborockLocalClient.__init__(self, device_data, queue_timeout) + RoborockClientV1.__init__(self, device_data, self.cache, self._logger, "abc") + + def build_roborock_message( + self, method: RoborockCommand | str, params: list | dict | int | None = None + ) -> RoborockMessage: + secured = True if method in COMMANDS_SECURED else False + request_id, timestamp, payload = self._get_payload(method, params, secured) + request_protocol = RoborockMessageProtocol.GENERAL_REQUEST + message_retry: MessageRetry | None = None + if method == RoborockCommand.RETRY_REQUEST and isinstance(params, dict): + message_retry = MessageRetry(method=params["method"], retry_id=params["retry_id"]) + return RoborockMessage( + timestamp=timestamp, protocol=request_protocol, payload=payload, message_retry=message_retry + ) + + async def _send_command( + self, + method: RoborockCommand | str, + params: list | dict | int | None = None, + ): + roborock_message = self.build_roborock_message(method, params) + return await self.send_message(roborock_message) diff --git a/roborock/version_1_apis/roborock_mqtt_client_v1.py b/roborock/version_1_apis/roborock_mqtt_client_v1.py new file mode 100644 index 0000000..a403cea --- /dev/null +++ b/roborock/version_1_apis/roborock_mqtt_client_v1.py @@ -0,0 +1,72 @@ +import asyncio +import base64 + +import paho.mqtt.client as mqtt + +from roborock.cloud_api import RoborockMqttClient + +from ..containers import DeviceData, UserData +from ..exceptions import CommandVacuumError, RoborockException +from ..protocol import MessageParser, Utils +from ..roborock_message import RoborockMessage, RoborockMessageProtocol +from ..roborock_typing import RoborockCommand +from .roborock_client_v1 import COMMANDS_SECURED, RoborockClientV1 + + +class RoborockMqttClientV1(RoborockMqttClient, RoborockClientV1): + def __init__(self, user_data: UserData, device_info: DeviceData, queue_timeout: int = 10) -> None: + rriot = user_data.rriot + if rriot is None: + raise RoborockException("Got no rriot data from user_data") + endpoint = base64.b64encode(Utils.md5(rriot.k.encode())[8:14]).decode() + + RoborockMqttClient.__init__(self, user_data, device_info, queue_timeout) + RoborockClientV1.__init__(self, device_info, self.cache, self._logger, endpoint) + + 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 ({mqtt.error_string(info.rc)})") + + async def send_message(self, roborock_message: RoborockMessage): + await self.validate_connection() + method = roborock_message.get_method() + params = roborock_message.get_params() + request_id = roborock_message.get_request_id() + if request_id is None: + raise RoborockException(f"Failed build message {roborock_message}") + response_protocol = ( + RoborockMessageProtocol.MAP_RESPONSE if method in COMMANDS_SECURED else RoborockMessageProtocol.RPC_RESPONSE + ) + + local_key = self.device_info.device.local_key + msg = MessageParser.build(roborock_message, local_key, False) + self._logger.debug(f"id={request_id} Requesting method {method} with {params}") + async_response = asyncio.ensure_future(self._async_response(request_id, response_protocol)) + self._send_msg_raw(msg) + (response, err) = await async_response + self._diagnostic_data[method if method is not None else "unknown"] = { + "params": roborock_message.get_params(), + "response": response, + "error": err, + } + if err: + raise CommandVacuumError(method, err) from err + if response_protocol == RoborockMessageProtocol.MAP_RESPONSE: + self._logger.debug(f"id={request_id} Response from {method}: {len(response)} bytes") + else: + self._logger.debug(f"id={request_id} Response from {method}: {response}") + return response + + async def _send_command( + self, + method: RoborockCommand | str, + params: list | dict | int | None = None, + ): + request_id, timestamp, payload = self._get_payload(method, params, True) + request_protocol = RoborockMessageProtocol.RPC_REQUEST + roborock_message = RoborockMessage(timestamp=timestamp, protocol=request_protocol, payload=payload) + return await self.send_message(roborock_message) + + async def get_map_v1(self): + return await self.send_command(RoborockCommand.GET_MAP_V1) diff --git a/tests/conftest.py b/tests/conftest.py index f22d658..685b326 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,8 @@ import pytest from roborock import HomeData, UserData -from roborock.cloud_api import RoborockMqttClient from roborock.containers import DeviceData +from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1 from tests.mock_data import HOME_DATA_RAW, USER_DATA @@ -14,6 +14,6 @@ def mqtt_client(): device=home_data.devices[0], model=home_data.products[0].model, ) - client = RoborockMqttClient(user_data, device_info) + client = RoborockMqttClientV1(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 a467787..07136d4 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -10,8 +10,8 @@ RoborockDockWashTowelModeCode, UserData, ) -from roborock.cloud_api import RoborockMqttClient from roborock.containers import DeviceData, S7MaxVStatus +from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1 from roborock.web_api import PreparedRequest, RoborockApiClient from tests.mock_data import BASE_URL_REQUEST, GET_CODE_RESPONSE, HOME_DATA_RAW, STATUS, USER_DATA @@ -27,7 +27,7 @@ def test_can_create_prepared_request(): def test_can_create_mqtt_roborock(): home_data = HomeData.from_dict(HOME_DATA_RAW) device_info = DeviceData(device=home_data.devices[0], model=home_data.products[0].model) - RoborockMqttClient(UserData.from_dict(USER_DATA), device_info) + RoborockMqttClientV1(UserData.from_dict(USER_DATA), device_info) @pytest.mark.asyncio @@ -81,7 +81,7 @@ async def test_get_home_data(): async def test_get_dust_collection_mode(): home_data = HomeData.from_dict(HOME_DATA_RAW) device_info = DeviceData(device=home_data.devices[0], model=home_data.products[0].model) - rmc = RoborockMqttClient(UserData.from_dict(USER_DATA), device_info) + rmc = RoborockMqttClientV1(UserData.from_dict(USER_DATA), device_info) with patch("roborock.api.AttributeCache.async_value") as command: command.return_value = {"mode": 1} dust = await rmc.get_dust_collection_mode() @@ -93,7 +93,7 @@ async def test_get_dust_collection_mode(): async def test_get_mop_wash_mode(): home_data = HomeData.from_dict(HOME_DATA_RAW) device_info = DeviceData(device=home_data.devices[0], model=home_data.products[0].model) - rmc = RoborockMqttClient(UserData.from_dict(USER_DATA), device_info) + rmc = RoborockMqttClientV1(UserData.from_dict(USER_DATA), device_info) with patch("roborock.api.AttributeCache.async_value") as command: command.return_value = {"smart_wash": 0, "wash_interval": 1500} mop_wash = await rmc.get_smart_wash_params() @@ -106,7 +106,7 @@ async def test_get_mop_wash_mode(): async def test_get_washing_mode(): home_data = HomeData.from_dict(HOME_DATA_RAW) device_info = DeviceData(device=home_data.devices[0], model=home_data.products[0].model) - rmc = RoborockMqttClient(UserData.from_dict(USER_DATA), device_info) + rmc = RoborockMqttClientV1(UserData.from_dict(USER_DATA), device_info) with patch("roborock.api.AttributeCache.async_value") as command: command.return_value = {"wash_mode": 2} washing_mode = await rmc.get_wash_towel_mode() @@ -119,11 +119,11 @@ async def test_get_washing_mode(): async def test_get_prop(): home_data = HomeData.from_dict(HOME_DATA_RAW) device_info = DeviceData(device=home_data.devices[0], model=home_data.products[0].model) - rmc = RoborockMqttClient(UserData.from_dict(USER_DATA), device_info) - with patch("roborock.cloud_api.RoborockMqttClient.get_status") as get_status, patch( + rmc = RoborockMqttClientV1(UserData.from_dict(USER_DATA), device_info) + with patch("roborock.version_1_apis.roborock_mqtt_client_v1.RoborockMqttClientV1.get_status") as get_status, patch( "roborock.api.RoborockClient.send_command" ), patch("roborock.api.AttributeCache.async_value"), patch( - "roborock.cloud_api.RoborockMqttClient.get_dust_collection_mode" + "roborock.version_1_apis.roborock_mqtt_client_v1.RoborockMqttClientV1.get_dust_collection_mode" ): status = S7MaxVStatus.from_dict(STATUS) status.dock_type = RoborockDockTypeCode.auto_empty_dock_pure