From 3d476a2907913a6deff9dfd2a07b0008a80085db Mon Sep 17 00:00:00 2001 From: Luke Date: Tue, 11 Apr 2023 18:36:48 -0400 Subject: [PATCH 01/11] chore: fix mypy errors --- .github/workflows/ci.yml | 101 +++++++++++++++++++--------------- mypy.ini | 3 + roborock/api.py | 104 +++++++++++++++++++++++------------ roborock/cloud_api.py | 12 +++- roborock/containers.py | 12 ++-- roborock/local_api.py | 23 ++++---- roborock/offline/offline.py | 41 -------------- roborock/roborock_message.py | 18 +++--- roborock/util.py | 3 +- 9 files changed, 169 insertions(+), 148 deletions(-) create mode 100644 mypy.ini delete mode 100644 roborock/offline/offline.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5643f18e..30895d18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,53 +22,68 @@ jobs: fetch-depth: 0 - uses: wagoid/commitlint-github-action@v5.3.0 - test: - strategy: - fail-fast: false - matrix: - python-version: - - "3.10" - - "3.11" - os: - - ubuntu-latest - - windows-latest - - macOS-latest - runs-on: ${{ matrix.os }} + mypy: + runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 + - name: Install mypy + run: pip install mypy + - name: Run mypy + uses: sasanquaneuf/mypy-github-action@releases/v1 with: - python-version: ${{ matrix.python-version }} - - uses: snok/install-poetry@v1.3.3 - - name: Install Dependencies - run: poetry install - shell: bash - - name: Test with Pytest - run: poetry run pytest - shell: bash - release: - runs-on: ubuntu-latest - environment: release - if: github.ref == 'refs/heads/main' - needs: - - test + checkName: 'mypy' # NOTE: this needs to be the same as the job name + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - persist-credentials: false + test: + strategy: + fail-fast: false + matrix: + python-version: + - "3.10" + - "3.11" + os: + - ubuntu-latest + - windows-latest + - macOS-latest + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - uses: snok/install-poetry@v1.3.3 + - name: Install Dependencies + run: poetry install + shell: bash + - name: Test with Pytest + run: poetry run pytest + shell: bash + release: + runs-on: ubuntu-latest + environment: release + if: github.ref == 'refs/heads/main' + needs: + - test - # Run semantic release: - # - Update CHANGELOG.md - # - Update version in code - # - Create git tag - # - Create GitHub release - # - Publish to PyPI - - name: Python Semantic Release - uses: relekang/python-semantic-release@v7.33.2 - with: - github_token: ${{ secrets.GH_TOKEN }} - repository_username: __token__ - repository_password: ${{ secrets.PYPI_TOKEN }} + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + persist-credentials: false + + # Run semantic release: + # - Update CHANGELOG.md + # - Update version in code + # - Create git tag + # - Create GitHub release + # - Publish to PyPI + - name: Python Semantic Release + uses: relekang/python-semantic-release@v7.33.2 + with: + github_token: ${{ secrets.GH_TOKEN }} + repository_username: __token__ + repository_password: ${{ secrets.PYPI_TOKEN }} diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 00000000..d4cbfbc5 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,3 @@ +[mypy] +check_untyped_defs = True +exclude = cli.py diff --git a/roborock/api.py b/roborock/api.py index 8f864aa3..0334723c 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 +from typing import Optional, Any, Callable, Coroutine, Mapping import aiohttp from Crypto.Cipher import AES @@ -63,29 +63,29 @@ def md5hex(message: str) -> str: class PreparedRequest: - def __init__(self, base_url: str, base_headers: dict = None) -> None: + def __init__(self, base_url: str, base_headers: Optional[dict] = None) -> None: self.base_url = base_url self.base_headers = base_headers or {} async def request( - self, method: str, url: str, params=None, data=None, headers=None - ) -> dict | list: + self, method: str, url: str, params=None, data=None, headers=None + ) -> dict: _url = "/".join(s.strip("/") for s in [self.base_url, url]) _headers = {**self.base_headers, **(headers or {})} async with aiohttp.ClientSession() as session: async with session.request( - method, - _url, - params=params, - data=data, - headers=_headers, + method, + _url, + params=params, + data=data, + headers=_headers, ) as resp: return await resp.json() class RoborockClient: - def __init__(self, endpoint: str, devices_info: dict[str, RoborockDeviceInfo]) -> None: + def __init__(self, endpoint: str, devices_info: Mapping[str, RoborockDeviceInfo]) -> None: self.devices_info = devices_info self._endpoint = endpoint self._nonce = secrets.token_bytes(16) @@ -161,7 +161,7 @@ async def _async_response(self, request_id: int, protocol_id: int = 0) -> tuple[ del self._waiting_queue[request_id] def _get_payload( - self, method: RoborockCommand, params: list = None, secured=False + self, method: RoborockCommand, params: Optional[list] = None, secured=False ): timestamp = math.floor(time.time()) request_id = randint(10000, 99999) @@ -187,24 +187,26 @@ def _get_payload( return request_id, timestamp, payload async def send_command( - self, device_id: str, method: RoborockCommand, params: list = None + self, device_id: str, method: RoborockCommand, params: Optional[list] = None ): raise NotImplementedError - async def get_status(self, device_id: str) -> Status: + async def get_status(self, device_id: str) -> Status | None: status = await self.send_command(device_id, RoborockCommand.GET_STATUS) if isinstance(status, dict): return Status.from_dict(status) + return None - async def get_dnd_timer(self, device_id: str) -> DNDTimer: + async def get_dnd_timer(self, device_id: str) -> DNDTimer | None: try: dnd_timer = await self.send_command(device_id, 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: + async def get_clean_summary(self, device_id: str) -> CleanSummary | None: try: clean_summary = await self.send_command( device_id, RoborockCommand.GET_CLEAN_SUMMARY @@ -215,8 +217,9 @@ async def get_clean_summary(self, device_id: str) -> CleanSummary: return CleanSummary(clean_time=int.from_bytes(clean_summary, 'big')) except RoborockTimeout as e: _LOGGER.error(e) + return None - async def get_clean_record(self, device_id: str, record_id: int) -> CleanRecord: + async def get_clean_record(self, device_id: str, record_id: int) -> CleanRecord | None: try: clean_record = await self.send_command( device_id, RoborockCommand.GET_CLEAN_RECORD, [record_id] @@ -225,42 +228,48 @@ async def get_clean_record(self, device_id: str, record_id: int) -> CleanRecord: return CleanRecord.from_dict(clean_record) except RoborockTimeout as e: _LOGGER.error(e) + return None - async def get_consumable(self, device_id: str) -> Consumable: + async def get_consumable(self, device_id: str) -> Consumable | None: try: consumable = await self.send_command(device_id, 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: + async def get_wash_towel_mode(self, device_id: str) -> WashTowelMode | None: try: washing_mode = await self.send_command(device_id, 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: + async def get_dust_collection_mode(self, device_id: str) -> DustCollectionMode | None: try: dust_collection = await self.send_command(device_id, 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: + async def get_smart_wash_params(self, device_id: str) -> SmartWashParams | None: try: mop_wash_mode = await self.send_command(device_id, 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: RoborockDockTypeCode) -> RoborockDockSummary: + async def get_dock_summary(self, device_id: str, dock_type: RoborockDockTypeCode) -> RoborockDockSummary | None: try: - commands = [self.get_dust_collection_mode(device_id)] + commands: list[Coroutine[Any, Any, DustCollectionMode | WashTowelMode | SmartWashParams | None]] = [ + self.get_dust_collection_mode(device_id)] if dock_type == RoborockDockTypeCode['3']: commands += [self.get_wash_towel_mode(device_id), self.get_smart_wash_params(device_id)] [ @@ -268,13 +277,14 @@ async def get_dock_summary(self, device_id: str, dock_type: RoborockDockTypeCode wash_towel_mode, smart_wash_params ] = ( - list(await asyncio.gather(*commands)) - + [None, None] + list(await asyncio.gather(*commands)) + + [None, None] )[:3] return RoborockDockSummary(dust_collection_mode, wash_towel_mode, smart_wash_params) except RoborockTimeout as e: _LOGGER.error(e) + return None async def get_prop(self, device_id: str) -> RoborockDeviceProp | None: [status, dnd_timer, clean_summary, consumable] = await asyncio.gather( @@ -299,7 +309,7 @@ async def get_prop(self, device_id: str) -> RoborockDeviceProp | None: ) return None - async def get_multi_maps_list(self, device_id) -> MultiMapsList: + async def get_multi_maps_list(self, device_id) -> MultiMapsList | None: try: multi_maps_list = await self.send_command( device_id, RoborockCommand.GET_MULTI_MAPS_LIST @@ -308,14 +318,16 @@ async def get_multi_maps_list(self, device_id) -> MultiMapsList: return MultiMapsList.from_dict(multi_maps_list) except RoborockTimeout as e: _LOGGER.error(e) + return None - async def get_networking(self, device_id) -> NetworkInfo: + async def get_networking(self, device_id) -> NetworkInfo | None: try: networking_info = await self.send_command(device_id, RoborockCommand.GET_NETWORK_INFO) if isinstance(networking_info, dict): return NetworkInfo.from_dict(networking_info) except RoborockTimeout as e: _LOGGER.error(e) + return None class RoborockApiClient: @@ -334,9 +346,14 @@ async def _get_base_url(self) -> str: "/api/v1/getUrlByEmail", params={"email": self._username, "needtwostepauth": "false"}, ) + if response is None: + raise RoborockException("get url by email returned None") if response.get("code") != 200: raise RoborockException(response.get("error")) - self.base_url = response.get("data").get("url") + response_data = response.get("data") + if response_data is None: + raise RoborockException("response does not have 'data'") + self.base_url = response_data.get("url") return self.base_url def _get_header_client_id(self): @@ -358,7 +375,8 @@ async def request_code(self) -> None: "type": "auth", }, ) - + if code_response is None: + raise RoborockException("Failed to get a response from send email code") if code_response.get("code") != 200: raise RoborockException(code_response.get("msg")) @@ -376,10 +394,14 @@ async def pass_login(self, password: str) -> UserData: "needtwostepauth": "false", }, ) - + if login_response is None: + raise RoborockException("Login response is none") if login_response.get("code") != 200: raise RoborockException(login_response.get("msg")) - return UserData.from_dict(login_response.get("data")) + user_data = login_response.get("data") + if not isinstance(user_data, dict): + raise RoborockException("Got unexpected data type for user_data") + return UserData.from_dict(user_data) async def code_login(self, code) -> UserData: base_url = await self._get_base_url() @@ -395,15 +417,21 @@ async def code_login(self, code) -> UserData: "verifycodetype": "AUTH_EMAIL_CODE", }, ) - + if login_response is None: + raise RoborockException("Login request response is None") if login_response.get("code") != 200: raise RoborockException(login_response.get("msg")) - return UserData.from_dict(login_response.get("data")) + user_data = login_response.get("data") + if not isinstance(user_data, dict): + raise RoborockException("Got unexpected data type for user_data") + return UserData.from_dict(user_data) async def get_home_data(self, user_data: UserData) -> HomeData: base_url = await self._get_base_url() header_clientid = self._get_header_client_id() rriot = user_data.rriot + if rriot is None: + raise RoborockException("rriot is none") home_id_request = PreparedRequest( base_url, {"header_clientid": header_clientid} ) @@ -412,9 +440,12 @@ async def get_home_data(self, user_data: UserData) -> HomeData: "/api/v1/getHomeDetail", headers={"Authorization": user_data.token}, ) + if home_id_response is None: + raise RoborockException("home_id_response is None") if home_id_response.get("code") != 200: raise RoborockException(home_id_response.get("msg")) - home_id = home_id_response.get("data").get("rrHomeId") + + home_id = home_id_response['data'].get("rrHomeId") timestamp = math.floor(time.time()) nonce = secrets.token_urlsafe(6) prestr = ":".join( @@ -431,6 +462,8 @@ async def get_home_data(self, user_data: UserData) -> HomeData: 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( rriot.r.a, { @@ -442,4 +475,7 @@ async def get_home_data(self, user_data: UserData) -> HomeData: if not home_response.get("success"): raise RoborockException(home_response) home_data = home_response.get("result") - return HomeData.from_dict(home_data) + if isinstance(home_data, dict): + return HomeData.from_dict(home_data) + else: + raise RoborockException("home_response result was an unexpected type") diff --git a/roborock/cloud_api.py b/roborock/cloud_api.py index 1c3b7443..985b819d 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 Any +from typing import Optional, Any, Mapping from urllib.parse import urlparse import paho.mqtt.client as mqtt @@ -33,14 +33,18 @@ class RoborockMqttClient(RoborockClient, mqtt.Client): _thread: threading.Thread - def __init__(self, user_data: UserData, devices_info: dict[str, RoborockDeviceInfo]) -> None: + def __init__(self, user_data: UserData, devices_info: Mapping[str, 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) mqtt.Client.__init__(self, protocol=mqtt.MQTTv5) self._mqtt_user = rriot.u self._hashed_user = md5hex(self._mqtt_user + ":" + rriot.k)[2:10] 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_port = url.port self._mqtt_ssl = url.scheme == "ssl" @@ -147,6 +151,8 @@ def sync_connect(self) -> bool: rc = mqtt.MQTT_ERR_AGAIN self.sync_start_loop() if not self.is_connected(): + if self._mqtt_port is None or self._mqtt_host is None: + raise RoborockException("Mqtt information was not entered. Cannot connect.") _LOGGER.info("Connecting to mqtt") rc = super().connect( host=self._mqtt_host, @@ -186,7 +192,7 @@ def _send_msg_raw(self, device_id, msg) -> None: raise RoborockException(f"Failed to publish (rc: {info.rc})") async def send_command( - self, device_id: str, method: RoborockCommand, params: list = None + self, device_id: str, method: RoborockCommand, params: Optional[list] = None ): await self.validate_connection() request_id, timestamp, payload = super()._get_payload(method, params, True) diff --git a/roborock/containers.py b/roborock/containers.py index 4d93e92d..ac879d62 100644 --- a/roborock/containers.py +++ b/roborock/containers.py @@ -32,7 +32,7 @@ def decamelize_obj(d: dict | list): class RoborockBase: @classmethod - def from_dict(cls, data: dict[str, any]): + def from_dict(cls, data: dict[str, Any]): return from_dict(cls, decamelize_obj(data), config=Config(cast=[Enum])) def as_dict(self): @@ -51,11 +51,11 @@ class Reference(RoborockBase): @dataclass class RRiot(RoborockBase): - u: Optional[str] = None - s: Optional[str] = None - h: Optional[str] = None - k: Optional[str] = None - r: Optional[Reference] = None + u: str + s: str + h: str + k: str + r: Reference @dataclass diff --git a/roborock/local_api.py b/roborock/local_api.py index a31d5d27..50ccf249 100644 --- a/roborock/local_api.py +++ b/roborock/local_api.py @@ -4,7 +4,7 @@ import logging import socket from asyncio import Lock -from typing import Callable, Coroutine +from typing import Optional, Callable, Awaitable, Any, Mapping import async_timeout @@ -20,7 +20,7 @@ class RoborockLocalClient(RoborockClient): - def __init__(self, devices_info: dict[str, RoborockLocalDeviceInfo]): + def __init__(self, devices_info: Mapping[str, RoborockLocalDeviceInfo]): super().__init__("abc", devices_info) self.loop = get_running_loop_or_create_one() self.device_listener: dict[str, RoborockSocketListener] = { @@ -45,7 +45,7 @@ async def async_disconnect(self) -> None: await asyncio.gather(*[listener.disconnect() for listener in self.device_listener.values()]) def build_roborock_message( - self, method: RoborockCommand, params: list = None + self, method: RoborockCommand, params: Optional[list] = None ) -> RoborockMessage: secured = True if method in SPECIAL_COMMANDS else False request_id, timestamp, payload = self._get_payload(method, params, secured) @@ -53,7 +53,10 @@ def build_roborock_message( command_info = CommandInfoMap.get(method) if not command_info: raise RoborockException(f"Request {method} have unknown prefix. Can't execute in offline mode") - prefix = CommandInfoMap.get(method).prefix + command = CommandInfoMap.get(method) + if command is None: + raise RoborockException(f"No prefix found for {method}") + prefix = command.prefix request_protocol = 4 return RoborockMessage( prefix=prefix, @@ -63,7 +66,7 @@ def build_roborock_message( ) async def send_command( - self, device_id: str, method: RoborockCommand, params: list = None + self, device_id: str, method: RoborockCommand, params: Optional[list] = None ): roborock_message = self.build_roborock_message(method, params) response = (await self.send_message(device_id, roborock_message))[0] @@ -83,7 +86,7 @@ async def async_local_response(self, roborock_message: RoborockMessage): return response async def send_message( - self, device_id: str, roborock_messages: list[RoborockMessage] | RoborockMessage + self, device_id: str, roborock_messages: list[RoborockMessage] | RoborockMessage ): if isinstance(roborock_messages, RoborockMessage): roborock_messages = [roborock_messages] @@ -91,6 +94,8 @@ async def send_message( 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}") _LOGGER.debug(f"Requesting device with {roborock_messages}") await listener.send_message(msg) @@ -110,7 +115,7 @@ def is_closed(self): class RoborockSocketListener: roborock_port = 58867 - def __init__(self, ip: str, local_key: str, on_message: Callable[[list[RoborockMessage]], Coroutine[None] | None], + def __init__(self, ip: str, local_key: str, on_message: Callable[[list[RoborockMessage]], Awaitable[Any]], timeout: float | int = 4): self.ip = ip self.local_key = local_key @@ -159,8 +164,7 @@ async def disconnect(self): self.socket.close() self.is_connected = False - async def send_message(self, data: bytes): - response = {} + async def send_message(self, data: bytes) -> None: await self.connect() try: async with self._mutex: @@ -174,4 +178,3 @@ async def send_message(self, data: bytes): except BrokenPipeError as e: _LOGGER.exception(e) await self.disconnect() - return response diff --git a/roborock/offline/offline.py b/roborock/offline/offline.py deleted file mode 100644 index 8005de4c..00000000 --- a/roborock/offline/offline.py +++ /dev/null @@ -1,41 +0,0 @@ -import asyncio -import logging - -from roborock.containers import HomeDataDevice, HomeDataDeviceField, HomeDataProduct, HomeDataProductField, NetworkInfo, \ - NetworkInfoField -from roborock.containers import RoborockLocalDeviceInfo -from roborock.local_api import RoborockLocalClient - -local_ip = "" -local_key = "" -device_id = "" - - -async def main(): - logging_config = { - "level": logging.DEBUG - } - logging.basicConfig(**logging_config) - localdevices_info = RoborockLocalDeviceInfo({ - "device": HomeDataDevice({ - HomeDataDeviceField.NAME: "test_name", - HomeDataDeviceField.DUID: device_id, - HomeDataDeviceField.FV: "1", - HomeDataDeviceField.LOCAL_KEY: local_key - }), - "product": HomeDataProduct({ - HomeDataProductField.MODEL: "test_model" - }), - "network_info": NetworkInfo({ - NetworkInfoField.IP: local_ip - })}) - client = RoborockLocalClient({ - device_id: localdevices_info - }) - await client.async_connect() - props = await client.get_prop(device_id) - print(props) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/roborock/roborock_message.py b/roborock/roborock_message.py index d338d0b2..544d39ee 100644 --- a/roborock/roborock_message.py +++ b/roborock/roborock_message.py @@ -8,13 +8,12 @@ import time from dataclasses import dataclass from random import randint -from typing import Optional from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad -from roborock.typing import RoborockCommand from roborock.exceptions import RoborockException +from roborock.typing import RoborockCommand def md5bin(message: str) -> bytes: @@ -35,11 +34,11 @@ def encode_timestamp(_timestamp: int) -> str: class RoborockMessage: protocol: int payload: bytes - seq: Optional[int] = randint(100000, 999999) - prefix: Optional[bytes] = b'' - version: Optional[bytes] = b'1.0' - random: Optional[int] = randint(10000, 99999) - timestamp: Optional[int] = math.floor(time.time()) + seq: int = randint(100000, 999999) + prefix: bytes = b'' + version: bytes = b'1.0' + random: int = randint(10000, 99999) + timestamp: int = math.floor(time.time()) def get_request_id(self) -> int | None: protocol = self.protocol @@ -108,7 +107,7 @@ def encode(roborock_messages: list[RoborockMessage] | RoborockMessage, local_key @staticmethod def decode(msg: bytes, local_key: str, index=0) -> tuple[list[RoborockMessage], bytes]: - prefix = None + prefix = b'' original_index = index if len(msg) - index < 17: ## broken message @@ -118,8 +117,7 @@ def decode(msg: bytes, local_key: str, index=0) -> tuple[list[RoborockMessage], prefix = msg[index:index + 4] index += 4 elif msg[index:index + 3] != "1.0".encode(): - raise RoborockException(f"Unknown protocol version {msg[0:3]}") - + raise RoborockException(f"Unknown protocol version {msg[0:3]!r}") if len(msg) - index in [17]: [version, request_id, random, timestamp, protocol] = struct.unpack_from( "!3sIIIH", msg, index diff --git a/roborock/util.py b/roborock/util.py index 65facd20..06184f69 100644 --- a/roborock/util.py +++ b/roborock/util.py @@ -1,8 +1,9 @@ import asyncio import functools +from asyncio import AbstractEventLoop -def get_running_loop_or_create_one(): +def get_running_loop_or_create_one() -> AbstractEventLoop: try: loop = asyncio.get_event_loop() except RuntimeError: From 964668495e607ddb2feb2c2c2e76cc9b8ca286cd Mon Sep 17 00:00:00 2001 From: Luke Date: Tue, 11 Apr 2023 18:46:49 -0400 Subject: [PATCH 02/11] fix: run mypy through pre-commit --- .github/workflows/ci.yml | 14 ++++---------- .pre-commit-config.yaml | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 10 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30895d18..ca846360 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,20 +22,14 @@ jobs: fetch-depth: 0 - uses: wagoid/commitlint-github-action@v5.3.0 - mypy: + lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v4 - - name: Install mypy - run: pip install mypy - - name: Run mypy - uses: sasanquaneuf/mypy-github-action@releases/v1 + - uses: actions/setup-python@v4 with: - checkName: 'mypy' # NOTE: this needs to be the same as the job name - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + python-version: "3.10" + - uses: pre-commit/action@v3.0.0 test: strategy: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..7a5f0768 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +default_stages: [ commit ] + + +repos: + - repo: https://github.com/python-poetry/poetry + rev: 1.3.2 + hooks: + - id: poetry-check + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.931 + hooks: + - id: mypy + additional_dependencies: [ "types-paho-mqtt" ] From 66626465cc1380fea3f4d907a6b97a11bc025a98 Mon Sep 17 00:00:00 2001 From: Luke Date: Tue, 11 Apr 2023 18:49:16 -0400 Subject: [PATCH 03/11] fix: spacing for ci --- .github/workflows/ci.yml | 97 ++++++++++++++++++++-------------------- 1 file changed, 48 insertions(+), 49 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca846360..6b818fe9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,6 @@ jobs: with: fetch-depth: 0 - uses: wagoid/commitlint-github-action@v5.3.0 - lint: runs-on: ubuntu-latest steps: @@ -31,53 +30,53 @@ jobs: python-version: "3.10" - uses: pre-commit/action@v3.0.0 - test: - strategy: - fail-fast: false - matrix: - python-version: - - "3.10" - - "3.11" - os: - - ubuntu-latest - - windows-latest - - macOS-latest - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - uses: snok/install-poetry@v1.3.3 - - name: Install Dependencies - run: poetry install - shell: bash - - name: Test with Pytest - run: poetry run pytest - shell: bash - release: - runs-on: ubuntu-latest - environment: release - if: github.ref == 'refs/heads/main' - needs: - - test + test: + strategy: + fail-fast: false + matrix: + python-version: + - "3.10" + - "3.11" + os: + - ubuntu-latest + - windows-latest + - macOS-latest + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - uses: snok/install-poetry@v1.3.3 + - name: Install Dependencies + run: poetry install + shell: bash + - name: Test with Pytest + run: poetry run pytest + shell: bash + release: + runs-on: ubuntu-latest + environment: release + if: github.ref == 'refs/heads/main' + needs: + - test - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - persist-credentials: false + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + persist-credentials: false - # Run semantic release: - # - Update CHANGELOG.md - # - Update version in code - # - Create git tag - # - Create GitHub release - # - Publish to PyPI - - name: Python Semantic Release - uses: relekang/python-semantic-release@v7.33.2 - with: - github_token: ${{ secrets.GH_TOKEN }} - repository_username: __token__ - repository_password: ${{ secrets.PYPI_TOKEN }} + # Run semantic release: + # - Update CHANGELOG.md + # - Update version in code + # - Create git tag + # - Create GitHub release + # - Publish to PyPI + - name: Python Semantic Release + uses: relekang/python-semantic-release@v7.33.2 + with: + github_token: ${{ secrets.GH_TOKEN }} + repository_username: __token__ + repository_password: ${{ secrets.PYPI_TOKEN }} From 5857a1c44b779b6b2344ca2085abefc1ab674c0e Mon Sep 17 00:00:00 2001 From: Luke Date: Tue, 11 Apr 2023 18:58:27 -0400 Subject: [PATCH 04/11] fix: tests changes --- tests/test_api.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_api.py b/tests/test_api.py index adae1598..086a5f58 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -72,6 +72,7 @@ async def test_get_dust_collection_mode(): 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) + assert dust is not None assert dust.mode == RoborockDockDustCollectionModeCode['1'] @@ -83,6 +84,7 @@ async def test_get_mop_wash_mode(): 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) + assert mop_wash is not None assert mop_wash.smart_wash == 0 assert mop_wash.wash_interval == 1500 @@ -95,4 +97,5 @@ async def test_get_washing_mode(): 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) + assert washing_mode is not None assert washing_mode.wash_mode == RoborockDockWashTowelModeCode['2'] From 746efe06c78821bd07a18257e352da523847b2a1 Mon Sep 17 00:00:00 2001 From: Luke Date: Tue, 11 Apr 2023 19:01:16 -0400 Subject: [PATCH 05/11] fix: cli exclusion --- mypy.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypy.ini b/mypy.ini index d4cbfbc5..4948e3f7 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,3 +1,3 @@ [mypy] check_untyped_defs = True -exclude = cli.py +exclude = roborock/cli.py From 275841ac748ad6942e0795cc1138586f6b319c86 Mon Sep 17 00:00:00 2001 From: Luke Date: Wed, 12 Apr 2023 17:45:07 -0400 Subject: [PATCH 06/11] fix: add typing for roborockenum --- mypy.ini | 2 +- roborock/code_mappings.py | 25 ++++++++++++++++++------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/mypy.ini b/mypy.ini index 4948e3f7..d4cbfbc5 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,3 +1,3 @@ [mypy] check_untyped_defs = True -exclude = roborock/cli.py +exclude = cli.py diff --git a/roborock/code_mappings.py b/roborock/code_mappings.py index 3d1c28d0..b79732da 100644 --- a/roborock/code_mappings.py +++ b/roborock/code_mappings.py @@ -1,30 +1,42 @@ from __future__ import annotations from enum import Enum +from typing import TypeVar, Type, Any + +_StrEnumT = TypeVar("_StrEnumT", bound="RoborockEnum") 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) @classmethod - def _missing_(cls, code: int): + def _missing_(cls: Type[_StrEnumT], code: object): return cls._member_map_.get(str(code)) @classmethod - def as_dict(cls): + def as_dict(cls: Type[_StrEnumT]): return {int(i.name): i.value for i in cls} @classmethod - def values(cls): + def values(cls: Type[_StrEnumT]): return list(cls.as_dict().values()) @classmethod - def keys(cls): + def keys(cls: Type[_StrEnumT]): return list(cls.as_dict().keys()) @classmethod - def items(cls): + def items(cls: Type[_StrEnumT]): return cls.as_dict().items() @@ -58,8 +70,7 @@ def create_code_enum(name: str, data: dict) -> RoborockEnum: 26: "going_to_wash_the_mop", # on a46, #1435 100: "charging_complete", 101: "device_offline", - }, -) + }) RoborockErrorCode = create_code_enum( "RoborockErrorCode", From b8a0e1efc467c0a35ae7206fcbcba9d388243048 Mon Sep 17 00:00:00 2001 From: Luke Date: Wed, 12 Apr 2023 18:24:38 -0400 Subject: [PATCH 07/11] fix: ignore warnings with mqtt.client --- roborock/cloud_api.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/roborock/cloud_api.py b/roborock/cloud_api.py index 985b819d..69a44beb 100644 --- a/roborock/cloud_api.py +++ b/roborock/cloud_api.py @@ -120,12 +120,15 @@ def update_client_id(self): async def _async_check_keepalive(self) -> None: async with self._mutex: now = mqtt.time_func() - if now - self._last_disconnection > self._keepalive ** 2 and now - self._last_device_msg_in > self._keepalive: + # noinspection PyUnresolvedReferences + if now - self._last_disconnection > self._keepalive ** 2 and now - self._last_device_msg_in > self._keepalive: # type: ignore[attr-defined] + self._ping_t = self._last_device_msg_in def _check_keepalive(self) -> None: self._async_check_keepalive() - super()._check_keepalive() + # noinspection PyUnresolvedReferences + super()._check_keepalive() # type: ignore[misc] def sync_stop_loop(self) -> None: if self._thread: From fe3fc75e07a31e21a167da893fef648050c67d95 Mon Sep 17 00:00:00 2001 From: Luke Date: Wed, 12 Apr 2023 19:59:53 -0400 Subject: [PATCH 08/11] fix: more mypy changes --- roborock/api.py | 9 +++++++-- roborock/code_mappings.py | 4 ++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/roborock/api.py b/roborock/api.py index 0334723c..9e4c7b00 100644 --- a/roborock/api.py +++ b/roborock/api.py @@ -23,7 +23,7 @@ from roborock.exceptions import ( RoborockException, RoborockTimeout, VacuumError, ) -from .code_mappings import RoborockDockTypeCode +from .code_mappings import RoborockDockTypeCode, RoborockEnum from .containers import ( UserData, Status, @@ -266,7 +266,12 @@ async def get_smart_wash_params(self, device_id: str) -> SmartWashParams | None: _LOGGER.error(e) return None - async def get_dock_summary(self, device_id: str, dock_type: RoborockDockTypeCode) -> RoborockDockSummary | None: + async def get_dock_summary(self, device_id: str, dock_type: RoborockEnum) -> RoborockDockSummary | None: + """Gets the status summary from the dock with the methods available for a given dock. + + :param dock_type: RoborockDockTypeCode""" + if RoborockDockTypeCode.name != "RoborockDockTypeCode": + raise RoborockException("Invalid enum given for dock type") try: commands: list[Coroutine[Any, Any, DustCollectionMode | WashTowelMode | SmartWashParams | None]] = [ self.get_dust_collection_mode(device_id)] diff --git a/roborock/code_mappings.py b/roborock/code_mappings.py index b79732da..61c5646f 100644 --- a/roborock/code_mappings.py +++ b/roborock/code_mappings.py @@ -39,6 +39,10 @@ def keys(cls: Type[_StrEnumT]): def items(cls: Type[_StrEnumT]): return cls.as_dict().items() + @classmethod + def __getitem__(cls: Type[_StrEnumT], item): + return cls.__getitem__(item) + def create_code_enum(name: str, data: dict) -> RoborockEnum: return RoborockEnum(name, {str(key): value for key, value in data.items()}) From c7109b0a1fb3a53708de8fd66cc8e1d13ee59271 Mon Sep 17 00:00:00 2001 From: Luke Date: Wed, 12 Apr 2023 20:07:45 -0400 Subject: [PATCH 09/11] fix: limit cli mypy --- .pre-commit-config.yaml | 1 + roborock/cli.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7a5f0768..f2ac1ee2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,4 +12,5 @@ repos: rev: v0.931 hooks: - id: mypy + exclude: cli.py additional_dependencies: [ "types-paho-mqtt" ] diff --git a/roborock/cli.py b/roborock/cli.py index eacc492c..bd1b9ccc 100644 --- a/roborock/cli.py +++ b/roborock/cli.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json import logging from pathlib import Path @@ -16,7 +18,7 @@ class RoborockContext: roborock_file = Path("~/.roborock").expanduser() - _login_data: LoginData = None + _login_data: LoginData | None = None def __init__(self): self.reload() From 81a4e2d003b0505aa809116bc068f8a3edd31b45 Mon Sep 17 00:00:00 2001 From: Luke Date: Wed, 12 Apr 2023 20:12:26 -0400 Subject: [PATCH 10/11] fix: ignore type for containers --- roborock/containers.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/roborock/containers.py b/roborock/containers.py index ac879d62..dc0ae18d 100644 --- a/roborock/containers.py +++ b/roborock/containers.py @@ -169,11 +169,11 @@ class LoginData(RoborockBase): class Status(RoborockBase): msg_ver: Optional[int] = None msg_seq: Optional[int] = None - state: Optional[RoborockStateCode] = None + state: Optional[RoborockStateCode] = None # type: ignore[valid-type] battery: Optional[int] = None clean_time: Optional[int] = None clean_area: Optional[int] = None - error_code: Optional[RoborockErrorCode] = None + error_code: Optional[RoborockErrorCode] = None # type: ignore[valid-type] map_present: Optional[int] = None in_cleaning: Optional[int] = None in_returning: Optional[int] = None @@ -183,12 +183,12 @@ class Status(RoborockBase): back_type: Optional[int] = None wash_phase: Optional[int] = None wash_ready: Optional[int] = None - fan_power: Optional[RoborockFanPowerCode] = None + fan_power: Optional[RoborockFanPowerCode] = None # type: ignore[valid-type] 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 + water_box_mode: Optional[RoborockMopIntensityCode] = None # type: ignore[valid-type] mop_intensity: Optional[str] = None water_box_carriage_status: Optional[int] = None mop_forbidden_enable: Optional[int] = None @@ -198,15 +198,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 + dock_type: Optional[RoborockDockTypeCode] = None # type: ignore[valid-type] dust_collection_status: Optional[int] = None auto_dust_collection: Optional[int] = None avoid_count: Optional[int] = None - mop_mode: Optional[RoborockMopModeCode] = None + mop_mode: Optional[RoborockMopModeCode] = None # type: ignore[valid-type] debug_mode: Optional[int] = None collision_avoid_status: Optional[int] = None switch_map_mode: Optional[int] = None - dock_error_status: Optional[RoborockDockErrorCode] = None + dock_error_status: Optional[RoborockDockErrorCode] = None # type: ignore[valid-type] charge_status: Optional[int] = None unsave_map_reason: Optional[int] = None unsave_map_flag: Optional[int] = None @@ -290,12 +290,12 @@ class SmartWashParams(RoborockBase): @dataclass class DustCollectionMode(RoborockBase): - mode: Optional[RoborockDockDustCollectionModeCode] = None + mode: Optional[RoborockDockDustCollectionModeCode] = None # type: ignore[valid-type] @dataclass class WashTowelMode(RoborockBase): - wash_mode: Optional[RoborockDockWashTowelModeCode] = None + wash_mode: Optional[RoborockDockWashTowelModeCode] = None # type: ignore[valid-type] @dataclass From cf22680b845199a7289b8cb783ee9b68e2314344 Mon Sep 17 00:00:00 2001 From: Luke Date: Wed, 12 Apr 2023 20:24:56 -0400 Subject: [PATCH 11/11] fix: add pre-commit information to dev poetry dependencies --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index d195d4e8..3ef77950 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,8 @@ build-backend = "poetry.core.masonry.api" [tool.poetry.dev-dependencies] pytest-asyncio = "*" pytest = "*" +pre-commit = "*" +mypy = "*" [tool.semantic_release] branch = "main"