diff --git a/.coveragerc b/.coveragerc index 6f717a2b..f042d521 100644 --- a/.coveragerc +++ b/.coveragerc @@ -4,3 +4,8 @@ source = deebot_client omit = tests/* + +[report] +exclude_lines = + pragma: no cover + if TYPE_CHECKING: \ No newline at end of file diff --git a/.devcontainer.json b/.devcontainer.json index 201063e0..557d09e3 100644 --- a/.devcontainer.json +++ b/.devcontainer.json @@ -5,6 +5,7 @@ // Configure properties specific to VS Code. "vscode": { "extensions": [ + "eamodio.gitlens", "github.vscode-pull-request-github", "ms-python.python", "ms-python.vscode-pylance", @@ -61,7 +62,7 @@ // Use 'forwardPorts' to make a list of ports inside the container available locally. // "forwardPorts": [], // Use 'postCreateCommand' to run commands after the container is created. - "postCreateCommand": "pip install -r requirements-dev.txt && pre-commit install && pip install -e .", + "postStartCommand": "pip install -r requirements-dev.txt && pre-commit install && pip install -e .", // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. "remoteUser": "vscode", "runArgs": ["-e", "GIT_EDITOR=code --wait"] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 7e069d4b..e76611d8 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -41,7 +41,7 @@ body: validations: required: true attributes: - label: On which deebot vacuum you have the issue? + label: On which deebot device (vacuum) you have the issue? placeholder: Deebot Ozmo 950 - type: input id: version diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39174131..77a9c0d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,9 +4,8 @@ on: push: branches: - main + - dev pull_request: - branches: - - main env: DEFAULT_PYTHON: "3.11" @@ -16,10 +15,10 @@ jobs: runs-on: "ubuntu-latest" name: Check code quality steps: - - uses: "actions/checkout@v3" + - uses: "actions/checkout@v4" - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ env.DEFAULT_PYTHON }} cache: "pip" @@ -40,10 +39,10 @@ jobs: runs-on: "ubuntu-latest" name: Run tests steps: - - uses: "actions/checkout@v3" + - uses: "actions/checkout@v4" - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ env.DEFAULT_PYTHON }} cache: "pip" diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 726e6f30..cc894214 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -15,10 +15,8 @@ on: push: branches: - main + - dev pull_request: - # The branches below must be a subset of the branches above - branches: - - main schedule: - cron: "20 10 * * 0" @@ -41,7 +39,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index ddebddc4..dbf2e017 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -20,28 +20,20 @@ jobs: id-token: write steps: - name: 📥 Checkout the repository - uses: actions/checkout@v3 - - - name: 🔢 Get release version - id: version - uses: home-assistant/actions/helpers/version@master - - - name: 🖊️ Set version number - run: | - sed -i '/version=/c\ version="${{ steps.version.outputs.version }}",' "${{ github.workspace }}/setup.py" + uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: "3.11" - name: Install dependencies run: | python -m pip install --upgrade pip - pip install setuptools wheel + pip install -q build - name: 📦 Build package - run: python setup.py sdist bdist_wheel + run: python -m build - name: 📤 Publish package uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fc505ccd..3ae18850 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,20 +9,26 @@ default_language_version: python: python3.11 repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.1 + hooks: + - id: ruff + args: + - --fix - repo: https://github.com/asottile/pyupgrade - rev: v3.9.0 + rev: v3.15.0 hooks: - id: pyupgrade args: - --py311-plus - - repo: https://github.com/psf/black - rev: 23.7.0 + - repo: https://github.com/psf/black-pre-commit-mirror + rev: 23.10.0 hooks: - id: black args: - --quiet - repo: https://github.com/codespell-project/codespell - rev: v2.2.5 + rev: v2.2.6 hooks: - id: codespell args: @@ -32,40 +38,22 @@ repos: exclude_types: - csv - json - - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 - hooks: - - id: flake8 - additional_dependencies: - - flake8-docstrings==1.6.0 - - pydocstyle==6.1.1 - - repo: https://github.com/PyCQA/bandit - rev: 1.7.5 - hooks: - - id: bandit - args: - - --quiet - - --format=custom - - --configfile=bandit.yaml - - repo: https://github.com/PyCQA/isort - rev: 5.12.0 - hooks: - - id: isort - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: - id: check-executables-have-shebangs - id: check-merge-conflict - id: detect-private-key - id: no-commit-to-branch + args: [--branch, main, --branch, dev] - id: requirements-txt-fixer - repo: https://github.com/pre-commit/mirrors-prettier - rev: v3.0.0 + rev: v3.0.3 hooks: - id: prettier additional_dependencies: - - prettier@2.8.8 - - prettier-plugin-sort-json@1.0.0 + - prettier@3.0.3 + - prettier-plugin-sort-json@3.0.1 exclude_types: - python - repo: https://github.com/adrienverge/yamllint.git diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index c91a87c2..00000000 --- a/.prettierrc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "jsonRecursiveSort": true -} diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 00000000..3051fbe5 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,5 @@ +/** @type {import("prettier").Config} */ +module.exports = { + plugins: [require.resolve("prettier-plugin-sort-json")], + jsonRecursiveSort: true, +}; diff --git a/README.md b/README.md index 1cd1a837..7a0ea304 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Client Library for Deebot Vacuums +# Client Library for Deebot devices (Vacuums) [![PyPI - Downloads](https://img.shields.io/pypi/dw/deebot-client?style=for-the-badge)](https://pypi.org/project/deebot-client) Buy Me A Coffee @@ -31,7 +31,7 @@ from deebot_client.events import BatteryEvent from deebot_client.models import Configuration from deebot_client.mqtt_client import MqttClient, MqttConfiguration from deebot_client.util import md5 -from deebot_client.vacuum_bot import VacuumBot +from deebot_client.device import Device device_id = md5(str(time.time())) account_id = "your email or phonenumber (cn)" @@ -52,7 +52,7 @@ async def main(): devices_ = await api_client.get_devices() - bot = VacuumBot(devices_[0], authenticator) + bot = Device(devices_[0], authenticator) mqtt_config = MqttConfiguration(config=config) mqtt = MqttClient(mqtt_config, authenticator) diff --git a/bandit.yaml b/bandit.yaml deleted file mode 100644 index 46566cc9..00000000 --- a/bandit.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# https://bandit.readthedocs.io/en/latest/config.html - -tests: - - B103 - - B108 - - B306 - - B307 - - B313 - - B314 - - B315 - - B316 - - B317 - - B318 - - B319 - - B320 - - B601 - - B602 - - B604 - - B608 - - B609 diff --git a/deebot_client/api_client.py b/deebot_client/api_client.py index 9d2e8b0d..e1be0784 100644 --- a/deebot_client/api_client.py +++ b/deebot_client/api_client.py @@ -1,11 +1,13 @@ """Api client module.""" from typing import Any +from deebot_client.hardware.deebot import get_static_device_info + from .authentication import Authenticator from .const import PATH_API_APPSVR_APP, PATH_API_PIM_PRODUCT_IOT_MAP from .exceptions import ApiError from .logging_filter import get_logger -from .models import DeviceInfo +from .models import ApiDeviceInfo, DeviceInfo _LOGGER = get_logger(__name__) @@ -27,9 +29,11 @@ async def get_devices(self) -> list[DeviceInfo]: if resp.get("code", None) == 0: devices: list[DeviceInfo] = [] + device: ApiDeviceInfo for device in resp["devices"]: if device.get("company") == "eco-ng": - devices.append(DeviceInfo(device)) + static_device_info = get_static_device_info(device["class"]) + devices.append(DeviceInfo(device, static_device_info)) else: _LOGGER.debug("Skipping device as it is not supported: %s", device) return devices diff --git a/deebot_client/authentication.py b/deebot_client/authentication.py index 36f8ef2a..834681cd 100644 --- a/deebot_client/authentication.py +++ b/deebot_client/authentication.py @@ -1,7 +1,8 @@ """Authentication module.""" import asyncio -import time from collections.abc import Callable, Coroutine, Mapping +from http import HTTPStatus +import time from typing import Any from urllib.parse import urljoin @@ -16,9 +17,9 @@ _LOGGER = get_logger(__name__) _CLIENT_KEY = "1520391301804" -_CLIENT_SECRET = "6c319b2a5cd3e66e39159c2e28f2fce9" +_CLIENT_SECRET = "6c319b2a5cd3e66e39159c2e28f2fce9" # noqa: S105 _AUTH_CLIENT_KEY = "1520391491841" -_AUTH_CLIENT_SECRET = "77ef58ce3afbe337da74aa8c5ab963a9" +_AUTH_CLIENT_SECRET = "77ef58ce3afbe337da74aa8c5ab963a9" # noqa: S105 _USER_LOGIN_URL_FORMAT = ( "https://gl-{country}-api.ecovacs.{tld}/v1/private/{country}/{lang}/{deviceId}/{appCode}/" "{appVersion}/{channel}/{deviceType}/user/login" @@ -252,7 +253,7 @@ async def post( timeout=60, ssl=self._config.verify_ssl, ) as res: - if res.status == 200: + if res.status == HTTPStatus.OK: response_data: dict[str, Any] = await res.json() _LOGGER.debug( "Success calling api %s, response=%s", @@ -271,14 +272,14 @@ async def post( message=str(res.reason), headers=res.headers, ) - except asyncio.TimeoutError as ex: + except TimeoutError as ex: _LOGGER.warning( "Timeout reached on api path: %s%s", path, json.get("cmdName", "") ) raise ApiError("Timeout reached") from ex except ClientResponseError as ex: _LOGGER.debug("Error: %s", logger_requst_params, exc_info=True) - if ex.status == 502: + if ex.status == HTTPStatus.BAD_GATEWAY: seconds_to_sleep = 10 _LOGGER.info( "Retry calling API due 502: Unfortunately the ecovacs api is unreliable. Retrying in %d seconds", @@ -319,23 +320,19 @@ def __init__( async def authenticate(self, force: bool = False) -> Credentials: """Authenticate on ecovacs servers.""" async with self._lock: - should_login = False - if self._credentials is None or force: - _LOGGER.debug("No cached credentials, performing login") - should_login = True - elif self._credentials.expires_at < time.time(): - _LOGGER.debug("Credentials have expired, performing login") - should_login = True - - if should_login: + if ( + self._credentials is None + or force + or self._credentials.expires_at < time.time() + ): + _LOGGER.debug("Performing login") self._credentials = await self._auth_client.login() self._cancel_refresh_task() - self._create_refresh_task() + self._create_refresh_task(self._credentials) for on_changed in self._on_credentials_changed: create_task(self._tasks, on_changed(self._credentials)) - assert self._credentials is not None return self._credentials def subscribe( @@ -375,7 +372,7 @@ def _cancel_refresh_task(self) -> None: if self._refresh_handle and not self._refresh_handle.cancelled(): self._refresh_handle.cancel() - def _create_refresh_task(self) -> None: + def _create_refresh_task(self, credentials: Credentials) -> None: # refresh at 99% of validity def refresh() -> None: _LOGGER.debug("Refresh token") @@ -384,14 +381,11 @@ async def async_refresh() -> None: try: await self.authenticate(True) except Exception: # pylint: disable=broad-except - _LOGGER.error( - "An exception occurred during refreshing token", exc_info=True - ) + _LOGGER.exception("An exception occurred during refreshing token") create_task(self._tasks, async_refresh()) self._refresh_handle = None - assert self._credentials is not None - validity = (self._credentials.expires_at - time.time()) * 0.99 + validity = (credentials.expires_at - time.time()) * 0.99 self._refresh_handle = asyncio.get_event_loop().call_later(validity, refresh) diff --git a/deebot_client/capabilities.py b/deebot_client/capabilities.py new file mode 100644 index 00000000..ef1b6ac4 --- /dev/null +++ b/deebot_client/capabilities.py @@ -0,0 +1,204 @@ +"""Device capabilities module.""" +from collections.abc import Callable +from dataclasses import dataclass, field, fields, is_dataclass +from types import MappingProxyType +from typing import TYPE_CHECKING, Any, Generic, TypeVar + +from deebot_client.command import Command +from deebot_client.commands.json.common import SetCommand +from deebot_client.events import ( + AdvancedModeEvent, + AvailabilityEvent, + BatteryEvent, + CachedMapInfoEvent, + CarpetAutoFanBoostEvent, + CleanCountEvent, + CleanLogEvent, + CleanPreferenceEvent, + ContinuousCleaningEvent, + CustomCommandEvent, + ErrorEvent, + Event, + FanSpeedEvent, + FanSpeedLevel, + LifeSpan, + LifeSpanEvent, + MajorMapEvent, + MapChangedEvent, + MapTraceEvent, + MultimapStateEvent, + NetworkInfoEvent, + PositionsEvent, + ReportStatsEvent, + RoomsEvent, + StateEvent, + StatsEvent, + TotalStatsEvent, + TrueDetectEvent, + VolumeEvent, + WaterAmount, + WaterInfoEvent, + WorkMode, + WorkModeEvent, +) +from deebot_client.models import CleanAction, CleanMode + +if TYPE_CHECKING: + from _typeshed import DataclassInstance + + +_T = TypeVar("_T") +_EVENT = TypeVar("_EVENT", bound=Event) + + +def _get_events( + capabilities: "DataclassInstance", +) -> MappingProxyType[type[Event], list[Command]]: + events = {} + for field_ in fields(capabilities): + if not field_.init: + continue + field_value = getattr(capabilities, field_.name) + if isinstance(field_value, CapabilityEvent): + events[field_value.event] = field_value.get + elif is_dataclass(field_value): + events.update(_get_events(field_value)) + + return MappingProxyType(events) + + +@dataclass(frozen=True) +class CapabilityEvent(Generic[_EVENT]): + """Capability for an event with get command.""" + + event: type[_EVENT] + get: list[Command] + + +@dataclass(frozen=True) +class CapabilitySet(CapabilityEvent[_EVENT], Generic[_EVENT, _T]): + """Capability setCommand with event.""" + + set: Callable[[_T], SetCommand] + + +@dataclass(frozen=True) +class CapabilitySetEnable(CapabilitySet[_EVENT, bool]): + """Capability for SetEnableCommand with event.""" + + +@dataclass(frozen=True) +class CapabilityExecute: + """Capability to execute a command.""" + + execute: type[Command] + + +@dataclass(frozen=True, kw_only=True) +class CapabilityTypes(Generic[_T]): + """Capability to specify types support.""" + + types: tuple[_T, ...] + + +@dataclass(frozen=True, kw_only=True) +class CapabilitySetTypes(CapabilitySet[_EVENT, _T | str], CapabilityTypes[_T]): + """Capability for set command and types.""" + + +@dataclass(frozen=True, kw_only=True) +class CapabilityCleanAction: + """Capabilities for clean action.""" + + command: Callable[[CleanAction], Command] + area: Callable[[CleanMode, str, int], Command] + + +@dataclass(frozen=True, kw_only=True) +class CapabilityClean: + """Capabilities for clean.""" + + action: CapabilityCleanAction + continuous: CapabilitySetEnable[ContinuousCleaningEvent] + count: CapabilitySet[CleanCountEvent, int] | None = None + log: CapabilityEvent[CleanLogEvent] + preference: CapabilitySetEnable[CleanPreferenceEvent] | None = None + work_mode: CapabilitySetTypes[WorkModeEvent, WorkMode] | None = None + + +@dataclass(frozen=True) +class CapabilityCustomCommand(CapabilityEvent[_EVENT]): + """Capability custom command.""" + + set: Callable[[str, Any], Command] + + +@dataclass(frozen=True, kw_only=True) +class CapabilityLifeSpan(CapabilityEvent[LifeSpanEvent], CapabilityTypes[LifeSpan]): + """Capabilities for life span.""" + + reset: Callable[[LifeSpan], Command] + + +@dataclass(frozen=True, kw_only=True) +class CapabilityMap: + """Capabilities for map.""" + + chached_info: CapabilityEvent[CachedMapInfoEvent] + changed: CapabilityEvent[MapChangedEvent] + major: CapabilityEvent[MajorMapEvent] + multi_state: CapabilitySetEnable[MultimapStateEvent] + position: CapabilityEvent[PositionsEvent] + relocation: CapabilityExecute + rooms: CapabilityEvent[RoomsEvent] + trace: CapabilityEvent[MapTraceEvent] + + +@dataclass(frozen=True, kw_only=True) +class CapabilityStats: + """Capabilities for statistics.""" + + clean: CapabilityEvent[StatsEvent] + report: CapabilityEvent[ReportStatsEvent] + total: CapabilityEvent[TotalStatsEvent] + + +@dataclass(frozen=True, kw_only=True) +class CapabilitySettings: + """Capabilities for settings.""" + + advanced_mode: CapabilitySetEnable[AdvancedModeEvent] + carpet_auto_fan_boost: CapabilitySetEnable[CarpetAutoFanBoostEvent] + true_detect: CapabilitySetEnable[TrueDetectEvent] | None = None + volume: CapabilitySet[VolumeEvent, int] + + +@dataclass(frozen=True, kw_only=True) +class Capabilities: + """Capabilities.""" + + availability: CapabilityEvent[AvailabilityEvent] + battery: CapabilityEvent[BatteryEvent] + charge: CapabilityExecute + clean: CapabilityClean + custom: CapabilityCustomCommand[CustomCommandEvent] + error: CapabilityEvent[ErrorEvent] + fan_speed: CapabilitySetTypes[FanSpeedEvent, FanSpeedLevel] + life_span: CapabilityLifeSpan + map: CapabilityMap | None = None + network: CapabilityEvent[NetworkInfoEvent] + play_sound: CapabilityExecute + settings: CapabilitySettings + state: CapabilityEvent[StateEvent] + stats: CapabilityStats + water: CapabilitySetTypes[WaterInfoEvent, WaterAmount] + + _events: MappingProxyType[type[Event], list[Command]] = field(init=False) + + def __post_init__(self) -> None: + """Post init.""" + object.__setattr__(self, "_events", _get_events(self)) + + def get_refresh_commands(self, event: type[Event]) -> list[Command]: + """Return refresh command for given event.""" + return self._events.get(event, []) diff --git a/deebot_client/command.py b/deebot_client/command.py index 0beb7857..c4d0d56d 100644 --- a/deebot_client/command.py +++ b/deebot_client/command.py @@ -1,13 +1,14 @@ """Base command.""" -import asyncio from abc import ABC, abstractmethod +import asyncio from dataclasses import dataclass, field -from datetime import datetime from typing import Any, final +from deebot_client.exceptions import DeebotError + from .authentication import Authenticator -from .const import PATH_API_IOT_DEVMANAGER, REQUEST_HEADERS -from .events.event_bus import EventBus +from .const import PATH_API_IOT_DEVMANAGER, REQUEST_HEADERS, DataType +from .event_bus import EventBus from .logging_filter import get_logger from .message import HandlingResult, HandlingState from .models import DeviceInfo @@ -19,7 +20,7 @@ class CommandResult(HandlingResult): """Command result object.""" - requested_commands: list["Command"] = field(default_factory=lambda: []) + requested_commands: list["Command"] = field(default_factory=list) @classmethod def success(cls) -> "CommandResult": @@ -37,7 +38,7 @@ class Command(ABC): _targets_bot: bool = True - def __init__(self, args: dict | list | None = None) -> None: + def __init__(self, args: dict[str, Any] | list[Any] | None = None) -> None: if args is None: args = {} self._args = args @@ -48,13 +49,24 @@ def __init__(self, args: dict | list | None = None) -> None: def name(cls) -> str: """Command name.""" + @property # type: ignore[misc] + @classmethod + @abstractmethod + def data_type(cls) -> DataType: + """Data type.""" # noqa: D401 + + @abstractmethod + def _get_payload(self) -> dict[str, Any] | list[Any] | str: + """Get the payload for the rest call.""" + @final async def execute( self, authenticator: Authenticator, device_info: DeviceInfo, event_bus: EventBus ) -> bool: """Execute command. - Returns: + Returns + ------- bot_reached (bool): True if the command was targeting the bot and it responded in time. False otherwise. This value is not indicating if the command was executed successfully. """ @@ -95,30 +107,17 @@ async def _execute( result.args, result.requested_commands, ) + if result.state == HandlingState.ERROR: + _LOGGER.warning("Could not parse %s: %s", self.name, response) return result - def _get_payload(self) -> dict[str, Any] | list: - payload = { - "header": { - "pri": "1", - "ts": datetime.now().timestamp(), - "tzm": 480, - "ver": "0.0.50", - } - } - - if len(self._args) > 0: - payload["body"] = {"data": self._args} - - return payload - async def _execute_api_request( self, authenticator: Authenticator, device_info: DeviceInfo ) -> dict[str, Any]: - json = { + payload = { "cmdName": self.name, "payload": self._get_payload(), - "payloadType": "j", + "payloadType": self.data_type.value, "td": "q", "toId": device_info.did, "toRes": device_info.resource, @@ -127,9 +126,9 @@ async def _execute_api_request( credentials = await authenticator.authenticate() query_params = { - "mid": json["toType"], - "did": json["toId"], - "td": json["td"], + "mid": payload["toType"], + "did": payload["toId"], + "td": payload["td"], "u": credentials.user_id, "cv": "1.67.3", "t": "a", @@ -138,7 +137,7 @@ async def _execute_api_request( return await authenticator.post_authenticated( PATH_API_IOT_DEVMANAGER, - json, + payload, query_params=query_params, headers=REQUEST_HEADERS, ) @@ -188,3 +187,53 @@ def __eq__(self, obj: object) -> bool: def __hash__(self) -> int: return hash(self.name) + hash(self._args) + + +@dataclass +class InitParam: + """Init param.""" + + type_: type + name: str | None = None + + +class CommandMqttP2P(Command, ABC): + """Command which can handle mqtt p2p messages.""" + + _mqtt_params: dict[str, InitParam | None] + + @abstractmethod + def handle_mqtt_p2p(self, event_bus: EventBus, response: dict[str, Any]) -> None: + """Handle response received over the mqtt channel "p2p".""" + + @classmethod + def create_from_mqtt(cls, data: dict[str, Any]) -> "CommandMqttP2P": + """Create a command from the mqtt data.""" + values: dict[str, Any] = {} + if not hasattr(cls, "_mqtt_params"): + raise DeebotError("_mqtt_params not set") + + for name, param in cls._mqtt_params.items(): + if param is None: + # Remove field + data.pop(name, None) + else: + values[param.name or name] = _pop_or_raise(name, param.type_, data) + + if data: + _LOGGER.debug("Following data will be ignored: %s", data) + + return cls(**values) + + +def _pop_or_raise(name: str, type_: type, data: dict[str, Any]) -> Any: + try: + value = data.pop(name) + except KeyError as err: + raise DeebotError(f'"{name}" is missing in {data}') from err + try: + return type_(value) + except ValueError as err: + raise DeebotError( + f'Could not convert "{value}" of {name} into {type_}' + ) from err diff --git a/deebot_client/commands/__init__.py b/deebot_client/commands/__init__.py index cfbf7ea5..421803d2 100644 --- a/deebot_client/commands/__init__.py +++ b/deebot_client/commands/__init__.py @@ -1,110 +1,14 @@ """Commands module.""" -from ..command import Command -from .advanced_mode import GetAdvancedMode, SetAdvancedMode -from .battery import GetBattery -from .carpet import GetCarpetAutoFanBoost, SetCarpetAutoFanBoost -from .charge import Charge -from .charge_state import GetChargeState -from .clean import Clean, CleanArea, GetCleanInfo -from .clean_count import GetCleanCount, SetCleanCount -from .clean_logs import GetCleanLogs -from .clean_preference import GetCleanPreference, SetCleanPreference -from .common import CommandHandlingMqttP2P, SetCommand -from .continuous_cleaning import GetContinuousCleaning, SetContinuousCleaning -from .error import GetError -from .fan_speed import FanSpeedLevel, GetFanSpeed, SetFanSpeed -from .life_span import GetLifeSpan, ResetLifeSpan -from .map import ( - GetCachedMapInfo, - GetMajorMap, - GetMapSet, - GetMapSubSet, - GetMapTrace, - GetMinorMap, -) -from .multimap_state import GetMultimapState, SetMultimapState -from .play_sound import PlaySound -from .pos import GetPos -from .relocation import SetRelocationState -from .stats import GetStats, GetTotalStats -from .true_detect import GetTrueDetect, SetTrueDetect -from .volume import GetVolume, SetVolume -from .water_info import GetWaterInfo, SetWaterInfo - -# fmt: off -# ordered by file asc -_COMMANDS: list[type[Command]] = [ - GetAdvancedMode, - SetAdvancedMode, - - GetBattery, - - GetCarpetAutoFanBoost, - SetCarpetAutoFanBoost, - - GetCleanCount, - SetCleanCount, - - GetCleanPreference, - SetCleanPreference, - - Charge, - - GetChargeState, - - Clean, - CleanArea, - GetCleanInfo, - - GetCleanLogs, - - GetContinuousCleaning, - SetContinuousCleaning, - - GetError, +from deebot_client.command import Command, CommandMqttP2P +from deebot_client.const import DataType - GetFanSpeed, - SetFanSpeed, - - GetLifeSpan, - ResetLifeSpan, - - GetCachedMapInfo, - GetMajorMap, - GetMapSet, - GetMapSubSet, - GetMapTrace, - GetMinorMap, - - GetMultimapState, - SetMultimapState, - - PlaySound, - - GetPos, - - SetRelocationState, - - GetStats, - GetTotalStats, - - GetTrueDetect, - SetTrueDetect, - - GetVolume, - SetVolume, - - GetWaterInfo, - SetWaterInfo, -] -# fmt: on +from .json import ( + COMMANDS as JSON_COMMANDS, + COMMANDS_WITH_MQTT_P2P_HANDLING as JSON_COMMANDS_WITH_MQTT_P2P_HANDLING, +) -COMMANDS: dict[str, type[Command]] = { - cmd.name: cmd for cmd in _COMMANDS # type: ignore[misc] -} +COMMANDS: dict[DataType, dict[str, type[Command]]] = {DataType.JSON: JSON_COMMANDS} -COMMANDS_WITH_MQTT_P2P_HANDLING: dict[str, type[CommandHandlingMqttP2P]] = { - cmd_name: cmd - for (cmd_name, cmd) in COMMANDS.items() - if issubclass(cmd, CommandHandlingMqttP2P) +COMMANDS_WITH_MQTT_P2P_HANDLING: dict[DataType, dict[str, type[CommandMqttP2P]]] = { + DataType.JSON: JSON_COMMANDS_WITH_MQTT_P2P_HANDLING } diff --git a/deebot_client/commands/json/__init__.py b/deebot_client/commands/json/__init__.py new file mode 100644 index 00000000..2a919eea --- /dev/null +++ b/deebot_client/commands/json/__init__.py @@ -0,0 +1,165 @@ +"""Commands module.""" +from deebot_client.command import Command, CommandMqttP2P + +from .advanced_mode import GetAdvancedMode, SetAdvancedMode +from .battery import GetBattery +from .carpet import GetCarpetAutoFanBoost, SetCarpetAutoFanBoost +from .charge import Charge +from .charge_state import GetChargeState +from .clean import Clean, CleanArea, GetCleanInfo +from .clean_count import GetCleanCount, SetCleanCount +from .clean_logs import GetCleanLogs +from .clean_preference import GetCleanPreference, SetCleanPreference +from .common import JsonCommand +from .continuous_cleaning import GetContinuousCleaning, SetContinuousCleaning +from .error import GetError +from .fan_speed import GetFanSpeed, SetFanSpeed +from .life_span import GetLifeSpan, ResetLifeSpan +from .map import ( + GetCachedMapInfo, + GetMajorMap, + GetMapSet, + GetMapSubSet, + GetMapTrace, + GetMinorMap, +) +from .multimap_state import GetMultimapState, SetMultimapState +from .network import GetNetInfo +from .play_sound import PlaySound +from .pos import GetPos +from .relocation import SetRelocationState +from .stats import GetStats, GetTotalStats +from .true_detect import GetTrueDetect, SetTrueDetect +from .volume import GetVolume, SetVolume +from .water_info import GetWaterInfo, SetWaterInfo +from .work_mode import GetWorkMode, SetWorkMode + +__all__ = [ + "GetAdvancedMode", + "SetAdvancedMode", + "GetBattery", + "GetCarpetAutoFanBoost", + "SetCarpetAutoFanBoost", + "GetCleanCount", + "SetCleanCount", + "GetCleanPreference", + "SetCleanPreference", + "Charge", + "GetChargeState", + "Clean", + "CleanArea", + "GetCleanInfo", + "GetCleanLogs", + "GetContinuousCleaning", + "SetContinuousCleaning", + "GetError", + "GetFanSpeed", + "SetFanSpeed", + "GetLifeSpan", + "ResetLifeSpan", + "GetCachedMapInfo", + "GetMajorMap", + "GetMapSet", + "GetMapSubSet", + "GetMapTrace", + "GetMinorMap", + "GetMultimapState", + "SetMultimapState", + "GetNetInfo", + "PlaySound", + "GetPos", + "SetRelocationState", + "GetStats", + "GetTotalStats", + "GetTrueDetect", + "SetTrueDetect", + "GetVolume", + "SetVolume", + "GetWaterInfo", + "SetWaterInfo", + "GetWorkMode", + "SetWorkMode", +] + +# fmt: off +# ordered by file asc +_COMMANDS: list[type[JsonCommand]] = [ + GetAdvancedMode, + SetAdvancedMode, + + GetBattery, + + GetCarpetAutoFanBoost, + SetCarpetAutoFanBoost, + + GetCleanCount, + SetCleanCount, + + GetCleanPreference, + SetCleanPreference, + + Charge, + + GetChargeState, + + Clean, + CleanArea, + GetCleanInfo, + + GetCleanLogs, + + GetContinuousCleaning, + SetContinuousCleaning, + + GetError, + + GetFanSpeed, + SetFanSpeed, + + GetLifeSpan, + ResetLifeSpan, + + GetCachedMapInfo, + GetMajorMap, + GetMapSet, + GetMapSubSet, + GetMapTrace, + GetMinorMap, + + GetMultimapState, + SetMultimapState, + + GetNetInfo, + + PlaySound, + + GetPos, + + SetRelocationState, + + GetStats, + GetTotalStats, + + GetTrueDetect, + SetTrueDetect, + + GetVolume, + SetVolume, + + GetWaterInfo, + SetWaterInfo, + + GetWorkMode, + SetWorkMode +] +# fmt: on + +COMMANDS: dict[str, type[Command]] = { + cmd.name: cmd for cmd in _COMMANDS # type: ignore[misc] +} + +COMMANDS_WITH_MQTT_P2P_HANDLING: dict[str, type[CommandMqttP2P]] = { + cmd_name: cmd + for (cmd_name, cmd) in COMMANDS.items() + if issubclass(cmd, CommandMqttP2P) +} diff --git a/deebot_client/commands/advanced_mode.py b/deebot_client/commands/json/advanced_mode.py similarity index 87% rename from deebot_client/commands/advanced_mode.py rename to deebot_client/commands/json/advanced_mode.py index bc7ba398..aade829c 100644 --- a/deebot_client/commands/advanced_mode.py +++ b/deebot_client/commands/json/advanced_mode.py @@ -1,6 +1,7 @@ """Advanced mode command module.""" -from ..events import AdvancedModeEvent +from deebot_client.events import AdvancedModeEvent + from .common import GetEnableCommand, SetEnableCommand diff --git a/deebot_client/commands/battery.py b/deebot_client/commands/json/battery.py similarity index 59% rename from deebot_client/commands/battery.py rename to deebot_client/commands/json/battery.py index d28a63ff..3bd117e7 100644 --- a/deebot_client/commands/battery.py +++ b/deebot_client/commands/json/battery.py @@ -1,9 +1,10 @@ """Battery commands.""" -from ..messages import OnBattery -from .common import NoArgsCommand +from deebot_client.messages.json import OnBattery +from .common import CommandWithMessageHandling -class GetBattery(OnBattery, NoArgsCommand): + +class GetBattery(OnBattery, CommandWithMessageHandling): """Get battery command.""" name = "getBattery" diff --git a/deebot_client/commands/carpet.py b/deebot_client/commands/json/carpet.py similarity index 88% rename from deebot_client/commands/carpet.py rename to deebot_client/commands/json/carpet.py index dd93776f..a79a742c 100644 --- a/deebot_client/commands/carpet.py +++ b/deebot_client/commands/json/carpet.py @@ -1,6 +1,7 @@ """Carpet pressure command module.""" -from ..events import CarpetAutoFanBoostEvent +from deebot_client.events import CarpetAutoFanBoostEvent + from .common import GetEnableCommand, SetEnableCommand diff --git a/deebot_client/commands/charge.py b/deebot_client/commands/json/charge.py similarity index 66% rename from deebot_client/commands/charge.py rename to deebot_client/commands/json/charge.py index 3380aa74..68ee2d12 100644 --- a/deebot_client/commands/charge.py +++ b/deebot_client/commands/json/charge.py @@ -1,11 +1,13 @@ """Charge commands.""" from typing import Any -from ..events import StateEvent -from ..logging_filter import get_logger -from ..message import HandlingResult -from ..models import VacuumState -from .common import EventBus, ExecuteCommand +from deebot_client.event_bus import EventBus +from deebot_client.events import StateEvent +from deebot_client.logging_filter import get_logger +from deebot_client.message import HandlingResult +from deebot_client.models import State + +from .common import ExecuteCommand from .const import CODE _LOGGER = get_logger(__name__) @@ -27,12 +29,12 @@ def _handle_body(cls, event_bus: EventBus, body: dict[str, Any]) -> HandlingResu """ code = int(body.get(CODE, -1)) if code == 0: - event_bus.notify(StateEvent(VacuumState.RETURNING)) + event_bus.notify(StateEvent(State.RETURNING)) return HandlingResult.success() if code == 30007: # bot is already charging - event_bus.notify(StateEvent(VacuumState.DOCKED)) + event_bus.notify(StateEvent(State.DOCKED)) return HandlingResult.success() return super()._handle_body(event_bus, body) diff --git a/deebot_client/commands/charge_state.py b/deebot_client/commands/json/charge_state.py similarity index 57% rename from deebot_client/commands/charge_state.py rename to deebot_client/commands/json/charge_state.py index c4c1562e..62d73e2b 100644 --- a/deebot_client/commands/charge_state.py +++ b/deebot_client/commands/json/charge_state.py @@ -1,14 +1,16 @@ """Charge state commands.""" from typing import Any -from ..events import StateEvent -from ..message import HandlingResult, MessageBodyDataDict -from ..models import VacuumState -from .common import EventBus, NoArgsCommand +from deebot_client.event_bus import EventBus +from deebot_client.events import StateEvent +from deebot_client.message import HandlingResult, MessageBodyDataDict +from deebot_client.models import State + +from .common import CommandWithMessageHandling from .const import CODE -class GetChargeState(NoArgsCommand, MessageBodyDataDict): +class GetChargeState(CommandWithMessageHandling, MessageBodyDataDict): """Get charge state command.""" name = "getChargeState" @@ -22,7 +24,7 @@ def _handle_body_data_dict( :return: A message response """ if data.get("isCharging") == 1: - event_bus.notify(StateEvent(VacuumState.DOCKED)) + event_bus.notify(StateEvent(State.DOCKED)) return HandlingResult.success() @classmethod @@ -31,17 +33,17 @@ def _handle_body(cls, event_bus: EventBus, body: dict[str, Any]) -> HandlingResu # Call this also if code is not in the body return super()._handle_body(event_bus, body) - status: VacuumState | None = None + status: State | None = None if body.get("msg", None) == "fail": if body["code"] == "30007": # Already charging - status = VacuumState.DOCKED - elif body["code"] == "5": # Busy with another command - status = VacuumState.ERROR - elif body["code"] == "3": # Bot in stuck state, example dust bin out - status = VacuumState.ERROR + status = State.DOCKED + elif body["code"] in ("3", "5"): + # 3 -> Bot in stuck state, example dust bin out + # 5 -> Busy with another command + status = State.ERROR if status: - event_bus.notify(StateEvent(VacuumState.DOCKED)) + event_bus.notify(StateEvent(State.DOCKED)) return HandlingResult.success() return HandlingResult.analyse() diff --git a/deebot_client/commands/clean.py b/deebot_client/commands/json/clean.py similarity index 70% rename from deebot_client/commands/clean.py rename to deebot_client/commands/json/clean.py index 5e7ad795..65d7bd4f 100644 --- a/deebot_client/commands/clean.py +++ b/deebot_client/commands/json/clean.py @@ -1,35 +1,17 @@ """Clean commands.""" -from enum import Enum, unique from typing import Any -from ..authentication import Authenticator -from ..command import CommandResult -from ..events import StateEvent -from ..logging_filter import get_logger -from ..message import HandlingResult, MessageBodyDataDict -from ..models import DeviceInfo, VacuumState -from .common import EventBus, ExecuteCommand, NoArgsCommand +from deebot_client.authentication import Authenticator +from deebot_client.command import CommandResult +from deebot_client.event_bus import EventBus +from deebot_client.events import StateEvent +from deebot_client.logging_filter import get_logger +from deebot_client.message import HandlingResult, MessageBodyDataDict +from deebot_client.models import CleanAction, CleanMode, DeviceInfo, State -_LOGGER = get_logger(__name__) - - -@unique -class CleanAction(str, Enum): - """Enum class for all possible clean actions.""" - - START = "start" - PAUSE = "pause" - RESUME = "resume" - STOP = "stop" +from .common import CommandWithMessageHandling, ExecuteCommand - -@unique -class CleanMode(str, Enum): - """Enum class for all possible clean modes.""" - - AUTO = "auto" - SPOT_AREA = "spotArea" - CUSTOM_AREA = "customArea" +_LOGGER = get_logger(__name__) class Clean(ExecuteCommand): @@ -48,12 +30,12 @@ async def _execute( if state and isinstance(self._args, dict): if ( self._args["act"] == CleanAction.RESUME.value - and state.state != VacuumState.PAUSED + and state.state != State.PAUSED ): self._args = self.__get_args(CleanAction.START) elif ( self._args["act"] == CleanAction.START.value - and state.state == VacuumState.PAUSED + and state.state == State.PAUSED ): self._args = self.__get_args(CleanAction.RESUME) @@ -73,14 +55,14 @@ class CleanArea(Clean): def __init__(self, mode: CleanMode, area: str, cleanings: int = 1) -> None: super().__init__(CleanAction.START) if not isinstance(self._args, dict): - raise ValueError("args must be a dict!") + raise TypeError("args must be a dict!") self._args["type"] = mode.value self._args["content"] = str(area) self._args["count"] = cleanings -class GetCleanInfo(NoArgsCommand, MessageBodyDataDict): +class GetCleanInfo(CommandWithMessageHandling, MessageBodyDataDict): """Get clean info command.""" name = "getCleanInfo" @@ -94,19 +76,19 @@ def _handle_body_data_dict( :return: A message response """ - status: VacuumState | None = None + status: State | None = None state = data.get("state") if data.get("trigger") == "alert": - status = VacuumState.ERROR + status = State.ERROR elif state == "clean": clean_state = data.get("cleanState", {}) motion_state = clean_state.get("motionState") if motion_state == "working": - status = VacuumState.CLEANING + status = State.CLEANING elif motion_state == "pause": - status = VacuumState.PAUSED + status = State.PAUSED elif motion_state == "goCharging": - status = VacuumState.RETURNING + status = State.RETURNING clean_type = clean_state.get("type") content = clean_state.get("content", {}) @@ -121,9 +103,9 @@ def _handle_body_data_dict( _LOGGER.debug("Last custom area values (x1,y1,x2,y2): %s", area_values) elif state == "goCharging": - status = VacuumState.RETURNING + status = State.RETURNING elif state == "idle": - status = VacuumState.IDLE + status = State.IDLE if status: event_bus.notify(StateEvent(status)) diff --git a/deebot_client/commands/clean_count.py b/deebot_client/commands/json/clean_count.py similarity index 56% rename from deebot_client/commands/clean_count.py rename to deebot_client/commands/json/clean_count.py index 3d95b7e5..d2d35827 100644 --- a/deebot_client/commands/clean_count.py +++ b/deebot_client/commands/json/clean_count.py @@ -1,14 +1,16 @@ """Clean count command module.""" -from collections.abc import Mapping from typing import Any -from ..events import CleanCountEvent -from ..message import HandlingResult, MessageBodyDataDict -from .common import EventBus, NoArgsCommand, SetCommand +from deebot_client.command import InitParam +from deebot_client.event_bus import EventBus +from deebot_client.events import CleanCountEvent +from deebot_client.message import HandlingResult, MessageBodyDataDict +from .common import CommandWithMessageHandling, SetCommand -class GetCleanCount(NoArgsCommand, MessageBodyDataDict): + +class GetCleanCount(CommandWithMessageHandling, MessageBodyDataDict): """Get clean count command.""" name = "getCleanCount" @@ -31,6 +33,7 @@ class SetCleanCount(SetCommand): name = "setCleanCount" get_command = GetCleanCount + _mqtt_params = {"count": InitParam(int)} - def __init__(self, count: int, **kwargs: Mapping[str, Any]) -> None: - super().__init__({"count": count}, **kwargs) + def __init__(self, count: int) -> None: + super().__init__({"count": count}) diff --git a/deebot_client/commands/clean_logs.py b/deebot_client/commands/json/clean_logs.py similarity index 81% rename from deebot_client/commands/clean_logs.py rename to deebot_client/commands/json/clean_logs.py index 49b109d8..f74c812f 100644 --- a/deebot_client/commands/clean_logs.py +++ b/deebot_client/commands/json/clean_logs.py @@ -1,18 +1,20 @@ """clean log commands.""" from typing import Any -from ..authentication import Authenticator -from ..command import Command, CommandResult -from ..const import PATH_API_LG_LOG, REQUEST_HEADERS -from ..events import CleanJobStatus, CleanLogEntry, CleanLogEvent -from ..events.event_bus import EventBus -from ..logging_filter import get_logger -from ..models import DeviceInfo +from deebot_client.authentication import Authenticator +from deebot_client.command import CommandResult +from deebot_client.const import PATH_API_LG_LOG, REQUEST_HEADERS +from deebot_client.event_bus import EventBus +from deebot_client.events import CleanJobStatus, CleanLogEntry, CleanLogEvent +from deebot_client.logging_filter import get_logger +from deebot_client.models import DeviceInfo + +from .common import JsonCommand _LOGGER = get_logger(__name__) -class GetCleanLogs(Command): +class GetCleanLogs(JsonCommand): """Get clean logs command.""" _targets_bot: bool = False @@ -54,7 +56,7 @@ def _handle_response( :return: A message response """ if response["ret"] == "ok": - resp_logs: list[dict] | None = response.get("logs") + resp_logs: list[dict[str, Any]] | None = response.get("logs") # Ecovacs API is changing their API, this request may not work properly if resp_logs is not None and len(resp_logs) >= 0: diff --git a/deebot_client/commands/clean_preference.py b/deebot_client/commands/json/clean_preference.py similarity index 88% rename from deebot_client/commands/clean_preference.py rename to deebot_client/commands/json/clean_preference.py index e56c61f1..82c2c405 100644 --- a/deebot_client/commands/clean_preference.py +++ b/deebot_client/commands/json/clean_preference.py @@ -1,6 +1,7 @@ """Clean preference command module.""" -from ..events import CleanPreferenceEvent +from deebot_client.events import CleanPreferenceEvent + from .common import GetEnableCommand, SetEnableCommand diff --git a/deebot_client/commands/common.py b/deebot_client/commands/json/common.py similarity index 70% rename from deebot_client/commands/common.py rename to deebot_client/commands/json/common.py index 0ca901a3..0ea69e19 100644 --- a/deebot_client/commands/common.py +++ b/deebot_client/commands/json/common.py @@ -1,19 +1,47 @@ """Base commands.""" from abc import ABC, abstractmethod -from collections.abc import Mapping +from datetime import datetime from typing import Any -from ..command import Command, CommandResult -from ..events import AvailabilityEvent, EnableEvent -from ..events.event_bus import EventBus -from ..logging_filter import get_logger -from ..message import HandlingResult, HandlingState, Message, MessageBodyDataDict +from deebot_client.command import Command, CommandMqttP2P, CommandResult, InitParam +from deebot_client.const import DataType +from deebot_client.event_bus import EventBus +from deebot_client.events import AvailabilityEvent, EnableEvent +from deebot_client.logging_filter import get_logger +from deebot_client.message import ( + HandlingResult, + HandlingState, + MessageBody, + MessageBodyDataDict, +) + from .const import CODE _LOGGER = get_logger(__name__) -class CommandWithMessageHandling(Command, Message, ABC): +class JsonCommand(Command): + """Json base command.""" + + data_type: DataType = DataType.JSON + + def _get_payload(self) -> dict[str, Any] | list[Any]: + payload = { + "header": { + "pri": "1", + "ts": datetime.now().timestamp(), + "tzm": 480, + "ver": "0.0.50", + } + } + + if len(self._args) > 0: + payload["body"] = {"data": self._args} + + return payload + + +class CommandWithMessageHandling(JsonCommand, MessageBody, ABC): """Command, which handle response by itself.""" _is_available_check: bool = False @@ -35,7 +63,7 @@ def _handle_response( case 4200: # bot offline _LOGGER.info( - 'Vacuum is offline. Could not execute command "%s"', self.name + 'Device is offline. Could not execute command "%s"', self.name ) event_bus.notify(AvailabilityEvent(False)) return CommandResult(HandlingState.FAILED) @@ -47,7 +75,7 @@ def _handle_response( ) else: _LOGGER.warning( - 'No response received for command "%s". This can happen if the vacuum has network issues or does not support the command', + 'No response received for command "%s". This can happen if the device has network issues or does not support the command', self.name, ) return CommandResult(HandlingState.FAILED) @@ -56,21 +84,6 @@ def _handle_response( return CommandResult(HandlingState.ANALYSE) -class CommandHandlingMqttP2P(CommandWithMessageHandling, ABC): - """Command which handles also mqtt p2p messages.""" - - @abstractmethod - def handle_mqtt_p2p(self, event_bus: EventBus, response: dict[str, Any]) -> None: - """Handle response received over the mqtt channel "p2p".""" - - -class NoArgsCommand(CommandWithMessageHandling, ABC): - """Command without args.""" - - def __init__(self) -> None: - super().__init__() - - class ExecuteCommand(CommandWithMessageHandling, ABC): """Command, which is executing something (ex. Charge).""" @@ -88,22 +101,12 @@ def _handle_body(cls, event_bus: EventBus, body: dict[str, Any]) -> HandlingResu return HandlingResult(HandlingState.FAILED) -class SetCommand(ExecuteCommand, CommandHandlingMqttP2P, ABC): +class SetCommand(ExecuteCommand, CommandMqttP2P, ABC): """Base set command. Command needs to be linked to the "get" command, for handling (updating) the sensors. """ - def __init__( - self, - args: dict | list | None, - **kwargs: Mapping[str, Any], - ) -> None: - if kwargs: - _LOGGER.debug("Following passed parameters will be ignored: %s", kwargs) - - super().__init__(args) - @property @abstractmethod def get_command(self) -> type[CommandWithMessageHandling]: @@ -117,7 +120,7 @@ def handle_mqtt_p2p(self, event_bus: EventBus, response: dict[str, Any]) -> None self.get_command.handle(event_bus, self._args) -class GetEnableCommand(NoArgsCommand, MessageBodyDataDict, ABC): +class GetEnableCommand(CommandWithMessageHandling, MessageBodyDataDict, ABC): """Abstract get enable command.""" @property # type: ignore[misc] @@ -142,7 +145,7 @@ def _handle_body_data_dict( class SetEnableCommand(SetCommand, ABC): """Abstract set enable command.""" - def __init__(self, enable: int | bool, **kwargs: Mapping[str, Any]) -> None: - if isinstance(enable, bool): - enable = 1 if enable else 0 - super().__init__({"enable": enable}, **kwargs) + _mqtt_params = {"enable": InitParam(bool)} + + def __init__(self, enable: bool) -> None: + super().__init__({"enable": 1 if enable else 0}) diff --git a/deebot_client/commands/const.py b/deebot_client/commands/json/const.py similarity index 100% rename from deebot_client/commands/const.py rename to deebot_client/commands/json/const.py diff --git a/deebot_client/commands/continuous_cleaning.py b/deebot_client/commands/json/continuous_cleaning.py similarity index 88% rename from deebot_client/commands/continuous_cleaning.py rename to deebot_client/commands/json/continuous_cleaning.py index 964a3b4d..ebf38271 100644 --- a/deebot_client/commands/continuous_cleaning.py +++ b/deebot_client/commands/json/continuous_cleaning.py @@ -1,6 +1,7 @@ """Continuous cleaning (break point) command module.""" -from ..events import ContinuousCleaningEvent +from deebot_client.events import ContinuousCleaningEvent + from .common import GetEnableCommand, SetEnableCommand diff --git a/deebot_client/commands/custom.py b/deebot_client/commands/json/custom.py similarity index 70% rename from deebot_client/commands/custom.py rename to deebot_client/commands/json/custom.py index edd0ba46..d6d007fa 100644 --- a/deebot_client/commands/custom.py +++ b/deebot_client/commands/json/custom.py @@ -1,20 +1,24 @@ """Custom command module.""" from typing import Any -from ..command import Command, CommandResult, EventBus -from ..events import CustomCommandEvent -from ..logging_filter import get_logger -from ..message import HandlingState +from deebot_client.command import CommandResult +from deebot_client.commands.json.common import JsonCommand +from deebot_client.event_bus import EventBus +from deebot_client.events import CustomCommandEvent +from deebot_client.logging_filter import get_logger +from deebot_client.message import HandlingState _LOGGER = get_logger(__name__) -class CustomCommand(Command): +class CustomCommand(JsonCommand): """Custom command, used when user wants to execute a command, which is not part of this library.""" name: str = "CustomCommand" - def __init__(self, name: str, args: dict | list | None = None) -> None: + def __init__( + self, name: str, args: dict[str, Any] | list[Any] | None = None + ) -> None: self.name = name super().__init__(args) @@ -46,5 +50,5 @@ def __hash__(self) -> int: class CustomPayloadCommand(CustomCommand): """Custom command, where args is the raw payload.""" - def _get_payload(self) -> dict[str, Any] | list: + def _get_json_payload(self) -> dict[str, Any] | list[Any]: return self._args diff --git a/deebot_client/commands/error.py b/deebot_client/commands/json/error.py similarity index 86% rename from deebot_client/commands/error.py rename to deebot_client/commands/json/error.py index 3bfc98cd..8728e0df 100644 --- a/deebot_client/commands/error.py +++ b/deebot_client/commands/json/error.py @@ -1,13 +1,15 @@ """Error commands.""" from typing import Any -from ..events import ErrorEvent, StateEvent -from ..message import HandlingResult, MessageBodyDataDict -from ..models import VacuumState -from .common import EventBus, NoArgsCommand +from deebot_client.event_bus import EventBus +from deebot_client.events import ErrorEvent, StateEvent +from deebot_client.message import HandlingResult, MessageBodyDataDict +from deebot_client.models import State +from .common import CommandWithMessageHandling -class GetError(NoArgsCommand, MessageBodyDataDict): + +class GetError(CommandWithMessageHandling, MessageBodyDataDict): """Get error command.""" name = "getError" @@ -28,7 +30,7 @@ def _handle_body_data_dict( if error is not None: description = _ERROR_CODES.get(error) if error != 0: - event_bus.notify(StateEvent(VacuumState.ERROR)) + event_bus.notify(StateEvent(State.ERROR)) event_bus.notify(ErrorEvent(error, description)) return HandlingResult.success() diff --git a/deebot_client/commands/fan_speed.py b/deebot_client/commands/json/fan_speed.py similarity index 57% rename from deebot_client/commands/fan_speed.py rename to deebot_client/commands/json/fan_speed.py index 26e129e8..6f93a808 100644 --- a/deebot_client/commands/fan_speed.py +++ b/deebot_client/commands/json/fan_speed.py @@ -1,13 +1,15 @@ """(fan) speed commands.""" -from collections.abc import Mapping from typing import Any -from ..events import FanSpeedEvent, FanSpeedLevel -from ..message import HandlingResult, MessageBodyDataDict -from .common import EventBus, NoArgsCommand, SetCommand +from deebot_client.command import InitParam +from deebot_client.event_bus import EventBus +from deebot_client.events import FanSpeedEvent, FanSpeedLevel +from deebot_client.message import HandlingResult, MessageBodyDataDict +from .common import CommandWithMessageHandling, SetCommand -class GetFanSpeed(NoArgsCommand, MessageBodyDataDict): + +class GetFanSpeed(CommandWithMessageHandling, MessageBodyDataDict): """Get fan speed command.""" name = "getSpeed" @@ -29,13 +31,9 @@ class SetFanSpeed(SetCommand): name = "setSpeed" get_command = GetFanSpeed + _mqtt_params = {"speed": InitParam(FanSpeedLevel)} - def __init__( - self, speed: str | int | FanSpeedLevel, **kwargs: Mapping[str, Any] - ) -> None: + def __init__(self, speed: FanSpeedLevel | str) -> None: if isinstance(speed, str): speed = FanSpeedLevel.get(speed) - if isinstance(speed, FanSpeedLevel): - speed = speed.value - - super().__init__({"speed": speed}, **kwargs) + super().__init__({"speed": speed.value}) diff --git a/deebot_client/commands/life_span.py b/deebot_client/commands/json/life_span.py similarity index 59% rename from deebot_client/commands/life_span.py rename to deebot_client/commands/json/life_span.py index 29bb8ea3..57887a6b 100644 --- a/deebot_client/commands/life_span.py +++ b/deebot_client/commands/json/life_span.py @@ -1,14 +1,13 @@ """Life span commands.""" from typing import Any -from ..events import LifeSpan, LifeSpanEvent -from ..message import HandlingResult, HandlingState, MessageBodyDataList -from .common import ( - CommandHandlingMqttP2P, - CommandWithMessageHandling, - EventBus, - ExecuteCommand, -) +from deebot_client.command import CommandMqttP2P, InitParam +from deebot_client.event_bus import EventBus +from deebot_client.events import LifeSpan, LifeSpanEvent +from deebot_client.message import HandlingResult, HandlingState, MessageBodyDataList +from deebot_client.util import LST + +from .common import CommandWithMessageHandling, ExecuteCommand class GetLifeSpan(CommandWithMessageHandling, MessageBodyDataList): @@ -16,12 +15,14 @@ class GetLifeSpan(CommandWithMessageHandling, MessageBodyDataList): name = "getLifeSpan" - def __init__(self) -> None: - args = [life_span.value for life_span in LifeSpan] + def __init__(self, life_spans: LST[LifeSpan]) -> None: + args = [life_span.value for life_span in life_spans] super().__init__(args) @classmethod - def _handle_body_data_list(cls, event_bus: EventBus, data: list) -> HandlingResult: + def _handle_body_data_list( + cls, event_bus: EventBus, data: list[dict[str, Any]] + ) -> HandlingResult: """Handle message->body->data and notify the correct event subscribers. :return: A message response @@ -40,19 +41,14 @@ def _handle_body_data_list(cls, event_bus: EventBus, data: list) -> HandlingResu return HandlingResult.success() -class ResetLifeSpan(ExecuteCommand, CommandHandlingMqttP2P): +class ResetLifeSpan(ExecuteCommand, CommandMqttP2P): """Reset life span command.""" name = "resetLifeSpan" + _mqtt_params = {"type": InitParam(LifeSpan, "life_span")} - def __init__( - self, type: str | LifeSpan # pylint: disable=redefined-builtin - ) -> None: - if isinstance(type, LifeSpan): - type = type.value - - self._type = type - super().__init__({"type": type}) + def __init__(self, life_span: LifeSpan) -> None: + super().__init__({"type": life_span.value}) def handle_mqtt_p2p(self, event_bus: EventBus, response: dict[str, Any]) -> None: """Handle response received over the mqtt channel "p2p".""" diff --git a/deebot_client/commands/map.py b/deebot_client/commands/json/map.py similarity index 96% rename from deebot_client/commands/map.py rename to deebot_client/commands/json/map.py index f2a0667e..aea98f65 100644 --- a/deebot_client/commands/map.py +++ b/deebot_client/commands/json/map.py @@ -1,8 +1,9 @@ """Maps commands.""" from typing import Any -from ..command import Command -from ..events import ( +from deebot_client.command import Command, CommandResult +from deebot_client.event_bus import EventBus +from deebot_client.events import ( MajorMapEvent, MapSetEvent, MapSetType, @@ -10,10 +11,10 @@ MapTraceEvent, MinorMapEvent, ) -from ..events.event_bus import EventBus -from ..events.map import CachedMapInfoEvent -from ..message import HandlingResult, HandlingState, MessageBodyDataDict -from .common import CommandResult, CommandWithMessageHandling +from deebot_client.events.map import CachedMapInfoEvent +from deebot_client.message import HandlingResult, HandlingState, MessageBodyDataDict + +from .common import CommandWithMessageHandling class GetCachedMapInfo(CommandWithMessageHandling, MessageBodyDataDict): diff --git a/deebot_client/commands/multimap_state.py b/deebot_client/commands/json/multimap_state.py similarity index 88% rename from deebot_client/commands/multimap_state.py rename to deebot_client/commands/json/multimap_state.py index a6ea8a3c..bacdd9af 100644 --- a/deebot_client/commands/multimap_state.py +++ b/deebot_client/commands/json/multimap_state.py @@ -1,6 +1,7 @@ """Multimap state command module.""" -from ..events import MultimapStateEvent +from deebot_client.events import MultimapStateEvent + from .common import GetEnableCommand, SetEnableCommand diff --git a/deebot_client/commands/json/network.py b/deebot_client/commands/json/network.py new file mode 100644 index 00000000..6b65f5d7 --- /dev/null +++ b/deebot_client/commands/json/network.py @@ -0,0 +1,33 @@ +"""Network commands.""" +from typing import Any + +from deebot_client.event_bus import EventBus +from deebot_client.events import NetworkInfoEvent +from deebot_client.message import HandlingResult, MessageBodyDataDict + +from .common import CommandWithMessageHandling + + +class GetNetInfo(CommandWithMessageHandling, MessageBodyDataDict): + """Get network info command.""" + + name = "getNetInfo" + + @classmethod + def _handle_body_data_dict( + cls, event_bus: EventBus, data: dict[str, Any] + ) -> HandlingResult: + """Handle message->body->data and notify the correct event subscribers. + + :return: A message response + """ + + event_bus.notify( + NetworkInfoEvent( + ip=data["ip"], + ssid=data["ssid"], + rssi=int(data["rssi"]), + mac=data["mac"], + ) + ) + return HandlingResult.success() diff --git a/deebot_client/commands/play_sound.py b/deebot_client/commands/json/play_sound.py similarity index 100% rename from deebot_client/commands/play_sound.py rename to deebot_client/commands/json/play_sound.py diff --git a/deebot_client/commands/pos.py b/deebot_client/commands/json/pos.py similarity index 85% rename from deebot_client/commands/pos.py rename to deebot_client/commands/json/pos.py index 28acc67a..6f743e1d 100644 --- a/deebot_client/commands/pos.py +++ b/deebot_client/commands/json/pos.py @@ -2,9 +2,11 @@ from typing import Any -from ..events import Position, PositionsEvent, PositionType -from ..message import HandlingResult, MessageBodyDataDict -from .common import CommandWithMessageHandling, EventBus +from deebot_client.event_bus import EventBus +from deebot_client.events import Position, PositionsEvent, PositionType +from deebot_client.message import HandlingResult, MessageBodyDataDict + +from .common import CommandWithMessageHandling class GetPos(CommandWithMessageHandling, MessageBodyDataDict): diff --git a/deebot_client/commands/relocation.py b/deebot_client/commands/json/relocation.py similarity index 100% rename from deebot_client/commands/relocation.py rename to deebot_client/commands/json/relocation.py diff --git a/deebot_client/commands/stats.py b/deebot_client/commands/json/stats.py similarity index 74% rename from deebot_client/commands/stats.py rename to deebot_client/commands/json/stats.py index 5fe8a4f7..1acd0b86 100644 --- a/deebot_client/commands/stats.py +++ b/deebot_client/commands/json/stats.py @@ -1,12 +1,14 @@ """Stats commands.""" from typing import Any -from ..events import StatsEvent, TotalStatsEvent -from ..message import HandlingResult, MessageBodyDataDict -from .common import EventBus, NoArgsCommand +from deebot_client.event_bus import EventBus +from deebot_client.events import StatsEvent, TotalStatsEvent +from deebot_client.message import HandlingResult, MessageBodyDataDict +from .common import CommandWithMessageHandling -class GetStats(NoArgsCommand, MessageBodyDataDict): + +class GetStats(CommandWithMessageHandling, MessageBodyDataDict): """Get stats command.""" name = "getStats" @@ -28,7 +30,7 @@ def _handle_body_data_dict( return HandlingResult.success() -class GetTotalStats(NoArgsCommand, MessageBodyDataDict): +class GetTotalStats(CommandWithMessageHandling, MessageBodyDataDict): """Get stats command.""" name = "getTotalStats" diff --git a/deebot_client/commands/true_detect.py b/deebot_client/commands/json/true_detect.py similarity index 88% rename from deebot_client/commands/true_detect.py rename to deebot_client/commands/json/true_detect.py index 015dd3ef..2dbc7693 100644 --- a/deebot_client/commands/true_detect.py +++ b/deebot_client/commands/json/true_detect.py @@ -1,6 +1,7 @@ """True detect command module.""" -from ..events import TrueDetectEvent +from deebot_client.events import TrueDetectEvent + from .common import GetEnableCommand, SetEnableCommand diff --git a/deebot_client/commands/volume.py b/deebot_client/commands/json/volume.py similarity index 53% rename from deebot_client/commands/volume.py rename to deebot_client/commands/json/volume.py index 2ec4d57d..7bfc2081 100644 --- a/deebot_client/commands/volume.py +++ b/deebot_client/commands/json/volume.py @@ -1,14 +1,16 @@ """Volume command module.""" -from collections.abc import Mapping from typing import Any -from ..events import VolumeEvent -from ..message import HandlingResult, MessageBodyDataDict -from .common import EventBus, NoArgsCommand, SetCommand +from deebot_client.command import InitParam +from deebot_client.event_bus import EventBus +from deebot_client.events import VolumeEvent +from deebot_client.message import HandlingResult, MessageBodyDataDict +from .common import CommandWithMessageHandling, SetCommand -class GetVolume(NoArgsCommand, MessageBodyDataDict): + +class GetVolume(CommandWithMessageHandling, MessageBodyDataDict): """Get volume command.""" name = "getVolume" @@ -33,8 +35,10 @@ class SetVolume(SetCommand): name = "setVolume" get_command = GetVolume + _mqtt_params = { + "volume": InitParam(int), + "total": None, # Remove it as we don't can set it (App includes it) + } - def __init__(self, volume: int, **kwargs: Mapping[str, Any]) -> None: - # removing "total" as we don't can set it (App includes it) - kwargs.pop("total", None) - super().__init__({"volume": volume}, **kwargs) + def __init__(self, volume: int) -> None: + super().__init__({"volume": volume}) diff --git a/deebot_client/commands/water_info.py b/deebot_client/commands/json/water_info.py similarity index 58% rename from deebot_client/commands/water_info.py rename to deebot_client/commands/json/water_info.py index 0e09faa7..1d7aab36 100644 --- a/deebot_client/commands/water_info.py +++ b/deebot_client/commands/json/water_info.py @@ -1,13 +1,15 @@ """Water info commands.""" -from collections.abc import Mapping from typing import Any -from ..events import WaterAmount, WaterInfoEvent -from ..message import HandlingResult, MessageBodyDataDict -from .common import EventBus, NoArgsCommand, SetCommand +from deebot_client.command import InitParam +from deebot_client.event_bus import EventBus +from deebot_client.events import WaterAmount, WaterInfoEvent +from deebot_client.message import HandlingResult, MessageBodyDataDict +from .common import CommandWithMessageHandling, SetCommand -class GetWaterInfo(NoArgsCommand, MessageBodyDataDict): + +class GetWaterInfo(CommandWithMessageHandling, MessageBodyDataDict): """Get water info command.""" name = "getWaterInfo" @@ -33,16 +35,12 @@ class SetWaterInfo(SetCommand): name = "setWaterInfo" get_command = GetWaterInfo + _mqtt_params = { + "amount": InitParam(WaterAmount), + "enable": None, # Remove it as we don't can set it (App includes it) + } - def __init__( - self, amount: str | int | WaterAmount, **kwargs: Mapping[str, Any] - ) -> None: - # removing "enable" as we don't can set it - kwargs.pop("enable", None) - + def __init__(self, amount: WaterAmount | str) -> None: if isinstance(amount, str): amount = WaterAmount.get(amount) - if isinstance(amount, WaterAmount): - amount = amount.value - - super().__init__({"amount": amount}, **kwargs) + super().__init__({"amount": amount.value}) diff --git a/deebot_client/commands/json/work_mode.py b/deebot_client/commands/json/work_mode.py new file mode 100644 index 00000000..aa001a3e --- /dev/null +++ b/deebot_client/commands/json/work_mode.py @@ -0,0 +1,39 @@ +"""Work mode commands.""" +from typing import Any + +from deebot_client.command import InitParam +from deebot_client.event_bus import EventBus +from deebot_client.events import WorkMode, WorkModeEvent +from deebot_client.message import HandlingResult, MessageBodyDataDict + +from .common import CommandWithMessageHandling, SetCommand + + +class GetWorkMode(CommandWithMessageHandling, MessageBodyDataDict): + """Get work mode command.""" + + name = "getWorkMode" + + @classmethod + def _handle_body_data_dict( + cls, event_bus: EventBus, data: dict[str, Any] + ) -> HandlingResult: + """Handle message->body->data and notify the correct event subscribers. + + :return: A message response + """ + event_bus.notify(WorkModeEvent(WorkMode(int(data["mode"])))) + return HandlingResult.success() + + +class SetWorkMode(SetCommand): + """Set work mode command.""" + + name = "setWorkMode" + get_command = GetWorkMode + _mqtt_params = {"mode": InitParam(WorkMode)} + + def __init__(self, mode: WorkMode | str) -> None: + if isinstance(mode, str): + mode = WorkMode.get(mode) + super().__init__({"mode": mode.value}) diff --git a/deebot_client/commands/xml/common.py b/deebot_client/commands/xml/common.py new file mode 100644 index 00000000..e2f8cb41 --- /dev/null +++ b/deebot_client/commands/xml/common.py @@ -0,0 +1,31 @@ +"""Common xml based commands.""" + + +from xml.etree import ElementTree + +from deebot_client.command import Command +from deebot_client.const import DataType + + +class XmlCommand(Command): + """Xml command.""" + + data_type: DataType = DataType.XML + + @property + def has_sub_element(self) -> bool: + """Return True if command has inner element.""" + return False + + def _get_payload(self) -> str: + element = ctl_element = ElementTree.Element("ctl") + + if len(self._args) > 0: + if self.has_sub_element: + element = ElementTree.SubElement(element, self.name.lower()) + + if isinstance(self._args, dict): + for key, value in self._args.items(): + element.set(key, value) + + return ElementTree.tostring(ctl_element, "unicode") diff --git a/deebot_client/const.py b/deebot_client/const.py index 58006693..169867a1 100644 --- a/deebot_client/const.py +++ b/deebot_client/const.py @@ -1,5 +1,8 @@ """Constants module.""" +from enum import StrEnum +from typing import Self + REALM = "ecouser.net" PATH_API_APPSVR_APP = "appsvr/app.do" PATH_API_PIM_PRODUCT_IOT_MAP = "pim/product/getProductIotMap" @@ -8,3 +11,18 @@ REQUEST_HEADERS = { "User-Agent": "Dalvik/2.1.0 (Linux; U; Android 5.1.1; A5010 Build/LMY48Z)", } + + +class DataType(StrEnum): + """Data type.""" + + JSON = "j" + XML = "x" + + @classmethod + def get(cls, value: str) -> Self | None: + """Return DataType or None for given value.""" + try: + return cls(value.lower()) + except ValueError: + return None diff --git a/deebot_client/vacuum_bot.py b/deebot_client/device.py similarity index 80% rename from deebot_client/vacuum_bot.py rename to deebot_client/device.py index e33523ab..fad29d76 100644 --- a/deebot_client/vacuum_bot.py +++ b/deebot_client/device.py @@ -1,16 +1,18 @@ -"""Vacuum bot module.""" +"""Device module.""" import asyncio -import json from collections.abc import Callable from contextlib import suppress from datetime import datetime +import json from typing import Any, Final -from deebot_client.commands.battery import GetBattery +from deebot_client.events.network import NetworkInfoEvent from deebot_client.mqtt_client import MqttClient, SubscriberInfo +from deebot_client.util import cancel from .authentication import Authenticator from .command import Command +from .event_bus import EventBus from .events import ( AvailabilityEvent, CleanLogEvent, @@ -22,18 +24,17 @@ StatsEvent, TotalStatsEvent, ) -from .events.event_bus import EventBus from .logging_filter import get_logger from .map import Map from .messages import get_message -from .models import DeviceInfo, VacuumState +from .models import DeviceInfo, State _LOGGER = get_logger(__name__) _AVAILABLE_CHECK_INTERVAL = 60 -class VacuumBot: - """Vacuum bot representation.""" +class Device: + """Device representation.""" def __init__( self, @@ -41,21 +42,25 @@ def __init__( authenticator: Authenticator, ): self.device_info: Final[DeviceInfo] = device_info + self.capabilities: Final = device_info.capabilities self._authenticator = authenticator self._semaphore = asyncio.Semaphore(3) self._state: StateEvent | None = None self._last_time_available: datetime = datetime.now() - self._available_task: asyncio.Task | None = None + self._available_task: asyncio.Task[Any] | None = None self._unsubscribe: Callable[[], None] | None = None self.fw_version: str | None = None - self.events: Final[EventBus] = EventBus(self.execute_command) + self.mac: str | None = None + self.events: Final[EventBus] = EventBus( + self.execute_command, self.capabilities.get_refresh_commands + ) self.map: Final[Map] = Map(self.execute_command, self.events) async def on_pos(event: PositionsEvent) -> None: - if self._state == StateEvent(VacuumState.DOCKED): + if self._state == StateEvent(State.DOCKED): return deebot = next(p for p in event.positions if p.type == PositionType.DEEBOT) @@ -74,7 +79,7 @@ async def on_pos(event: PositionsEvent) -> None: self.events.subscribe(PositionsEvent, on_pos) async def on_state(event: StateEvent) -> None: - if event.state == VacuumState.DOCKED: + if event.state == State.DOCKED: self.events.request_refresh(CleanLogEvent) self.events.request_refresh(TotalStatsEvent) @@ -90,6 +95,11 @@ async def on_custom_command(event: CustomCommandEvent) -> None: self.events.subscribe(CustomCommandEvent, on_custom_command) + async def on_network(event: NetworkInfoEvent) -> None: + self.mac = event.mac + + self.events.subscribe(NetworkInfoEvent, on_network) + async def execute_command(self, command: Command) -> None: """Execute given command.""" await self._execute_command(command) @@ -122,14 +132,21 @@ async def _available_task_worker(self) -> None: if (datetime.now() - self._last_time_available).total_seconds() > ( _AVAILABLE_CHECK_INTERVAL - 1 ): - # request GetBattery to check availability + tasks: set[asyncio.Future[Any]] = set() try: - self._set_available(await self._execute_command(GetBattery(True))) + for command in self.capabilities.get_refresh_commands( + AvailabilityEvent + ): + tasks.add(asyncio.create_task(self._execute_command(command))) + + result = await asyncio.gather(*tasks) + self._set_available(all(result)) except Exception: # pylint: disable=broad-exception-caught _LOGGER.debug( "An exception occurred during the available check", exc_info=True, ) + await cancel(tasks) await asyncio.sleep(_AVAILABLE_CHECK_INTERVAL) async def _execute_command(self, command: Command) -> bool: @@ -164,7 +181,7 @@ def _handle_message( try: _LOGGER.debug("Try to handle message %s: %s", message_name, message_data) - if message := get_message(message_name): + if message := get_message(message_name, self.device_info.data_type): if isinstance(message_data, dict): data = message_data else: @@ -176,6 +193,4 @@ def _handle_message( message.handle(self.events, data) except Exception: # pylint: disable=broad-except - _LOGGER.error( - "An exception occurred during handling message", exc_info=True - ) + _LOGGER.exception("An exception occurred during handling message") diff --git a/deebot_client/events/event_bus.py b/deebot_client/event_bus.py similarity index 84% rename from deebot_client/events/event_bus.py rename to deebot_client/event_bus.py index cae106f7..0655acd6 100644 --- a/deebot_client/events/event_bus.py +++ b/deebot_client/event_bus.py @@ -1,17 +1,17 @@ """Event emitter module.""" import asyncio -import threading from collections.abc import Callable, Coroutine -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta +import threading from typing import TYPE_CHECKING, Any, Final, Generic, TypeVar -from ..logging_filter import get_logger -from ..models import VacuumState -from ..util import cancel, create_task -from . import AvailabilityEvent, Event, StateEvent +from .events import AvailabilityEvent, Event, StateEvent +from .logging_filter import get_logger +from .models import State +from .util import cancel, create_task if TYPE_CHECKING: - from ..command import Command + from .command import Command _LOGGER = get_logger(__name__) @@ -21,15 +21,15 @@ class _EventProcessingData(Generic[T]): """Data class, which holds all needed data per EventDto.""" - def __init__(self) -> None: - super().__init__() + def __init__(self, refresh_commands: list["Command"]) -> None: + self.refresh_commands: Final = refresh_commands self.subscriber_callbacks: Final[ list[Callable[[T], Coroutine[Any, Any, None]]] ] = [] self.semaphore: Final = asyncio.Semaphore(1) self.last_event: T | None = None - self.last_event_time: datetime = datetime(1, 1, 1, 1, 1, 1, tzinfo=timezone.utc) + self.last_event_time: datetime = datetime(1, 1, 1, 1, 1, 1, tzinfo=UTC) self.notify_handle: asyncio.TimerHandle | None = None @@ -39,12 +39,14 @@ class EventBus: def __init__( self, execute_command: Callable[["Command"], Coroutine[Any, Any, None]], + get_refresh_commands: Callable[[type[Event]], list["Command"]], ): - self._event_processing_dict: dict[type[Event], _EventProcessingData] = {} + self._event_processing_dict: dict[type[Event], _EventProcessingData[Any]] = {} self._lock = threading.Lock() self._tasks: set[asyncio.Future[Any]] = set() self._execute_command: Final = execute_command + self._get_refresh_commands = get_refresh_commands def has_subscribers(self, event: type[T]) -> bool: """Return True, if emitter has subscribers.""" @@ -86,18 +88,18 @@ def notify(self, event: T, *, debounce_time: float = 0) -> None: handle.cancel() def _notify(event: T) -> None: - event_processing_data.last_event_time = datetime.now(timezone.utc) + event_processing_data.last_event_time = datetime.now(UTC) event_processing_data.notify_handle = None if ( isinstance(event, StateEvent) - and event.state == VacuumState.IDLE + and event.state == State.IDLE and event_processing_data.last_event - and event_processing_data.last_event.state == VacuumState.DOCKED # type: ignore[attr-defined] + and event_processing_data.last_event.state == State.DOCKED # type: ignore[attr-defined] ): # todo distinguish better between docked and idle and outside event bus. # pylint: disable=fixme # Problem getCleanInfo will return state=idle, when bot is charging - event = StateEvent(VacuumState.DOCKED) # type: ignore[assignment] + event = StateEvent(State.DOCKED) # type: ignore[assignment] elif ( isinstance(event, AvailabilityEvent) and event.available @@ -121,7 +123,7 @@ def _notify(event: T) -> None: else: _LOGGER.debug("No subscribers... Discharging %s", event) - now = datetime.now(timezone.utc) + now = datetime.now(UTC) if debounce_time <= 0 or ( now - event_processing_data.last_event_time ) > timedelta(seconds=debounce_time): @@ -144,17 +146,14 @@ async def teardown(self) -> None: handle.cancel() async def _call_refresh_function(self, event_class: type[T]) -> None: - semaphore = self._event_processing_dict[event_class].semaphore + processing_data = self._event_processing_dict[event_class] + semaphore = processing_data.semaphore if semaphore.locked(): _LOGGER.debug("Already refresh function running. Skipping...") return async with semaphore: - from deebot_client.events.const import ( # pylint: disable=import-outside-toplevel - EVENT_DTO_REFRESH_COMMANDS, - ) - - commands = EVENT_DTO_REFRESH_COMMANDS.get(event_class, []) + commands = processing_data.refresh_commands if not commands: return @@ -172,7 +171,9 @@ def _get_or_create_event_processing_data( event_processing_data = self._event_processing_dict.get(event_class, None) if event_processing_data is None: - event_processing_data = _EventProcessingData() + event_processing_data = _EventProcessingData( + self._get_refresh_commands(event_class) + ) self._event_processing_dict[event_class] = event_processing_data return event_processing_data diff --git a/deebot_client/events/__init__.py b/deebot_client/events/__init__.py index e72365b0..c8df5e31 100644 --- a/deebot_client/events/__init__.py +++ b/deebot_client/events/__init__.py @@ -2,14 +2,17 @@ from dataclasses import dataclass from enum import Enum, unique -from typing import Any, Optional +from typing import Any + +from deebot_client.events.base import Event +from deebot_client.models import Room, State +from deebot_client.util import DisplayNameIntEnum -from ..events.base import Event -from ..models import Room, VacuumState -from ..util import DisplayNameIntEnum from .fan_speed import FanSpeedEvent, FanSpeedLevel from .map import ( + CachedMapInfoEvent, MajorMapEvent, + MapChangedEvent, MapSetEvent, MapSetType, MapSubsetEvent, @@ -19,7 +22,34 @@ PositionsEvent, PositionType, ) +from .network import NetworkInfoEvent from .water_info import WaterAmount, WaterInfoEvent +from .work_mode import WorkMode, WorkModeEvent + +__all__ = [ + "BatteryEvent", + "CachedMapInfoEvent", + "CleanJobStatus", + "CleanLogEntry", + "Event", + "FanSpeedEvent", + "FanSpeedLevel", + "MajorMapEvent", + "MapChangedEvent", + "MapSetEvent", + "MapSetType", + "MapSubsetEvent", + "MapTraceEvent", + "MinorMapEvent", + "NetworkInfoEvent", + "Position", + "PositionType", + "PositionsEvent", + "WaterAmount", + "WaterInfoEvent", + "WorkMode", + "WorkModeEvent", +] @dataclass(frozen=True) @@ -86,9 +116,9 @@ class ErrorEvent(Event): class LifeSpan(str, Enum): """Enum class for all possible life span components.""" - SIDE_BRUSH = "sideBrush" BRUSH = "brush" FILTER = "heap" + SIDE_BRUSH = "sideBrush" @dataclass(frozen=True) @@ -145,7 +175,7 @@ class AvailabilityEvent(Event): class StateEvent(Event): """State event representation.""" - state: VacuumState + state: State @dataclass(frozen=True) diff --git a/deebot_client/events/const.py b/deebot_client/events/const.py deleted file mode 100644 index 88538ca4..00000000 --- a/deebot_client/events/const.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Event constants.""" -from collections.abc import Mapping - -from ..command import Command -from ..commands import ( - GetAdvancedMode, - GetBattery, - GetCachedMapInfo, - GetCarpetAutoFanBoost, - GetChargeState, - GetCleanCount, - GetCleanInfo, - GetCleanLogs, - GetCleanPreference, - GetContinuousCleaning, - GetError, - GetFanSpeed, - GetLifeSpan, - GetMajorMap, - GetMapTrace, - GetMultimapState, - GetPos, - GetStats, - GetTotalStats, - GetTrueDetect, - GetVolume, - GetWaterInfo, -) -from . import ( - AdvancedModeEvent, - AvailabilityEvent, - BatteryEvent, - CarpetAutoFanBoostEvent, - CleanCountEvent, - CleanLogEvent, - CleanPreferenceEvent, - ContinuousCleaningEvent, - CustomCommandEvent, - ErrorEvent, - Event, - FanSpeedEvent, - LifeSpanEvent, - MultimapStateEvent, - PositionsEvent, - ReportStatsEvent, - RoomsEvent, - StateEvent, - StatsEvent, - TotalStatsEvent, - TrueDetectEvent, - VolumeEvent, - WaterInfoEvent, -) -from .map import ( - CachedMapInfoEvent, - MajorMapEvent, - MapSetEvent, - MapSubsetEvent, - MapTraceEvent, - MinorMapEvent, -) - -EVENT_DTO_REFRESH_COMMANDS: Mapping[type[Event], list[Command]] = { - AvailabilityEvent: [], - AdvancedModeEvent: [GetAdvancedMode()], - BatteryEvent: [GetBattery()], - CachedMapInfoEvent: [GetCachedMapInfo()], - CarpetAutoFanBoostEvent: [GetCarpetAutoFanBoost()], - CleanLogEvent: [GetCleanLogs()], - CleanCountEvent: [GetCleanCount()], - CleanPreferenceEvent: [GetCleanPreference()], - ContinuousCleaningEvent: [GetContinuousCleaning()], - CustomCommandEvent: [], - ErrorEvent: [GetError()], - FanSpeedEvent: [GetFanSpeed()], - LifeSpanEvent: [GetLifeSpan()], - MajorMapEvent: [GetMajorMap()], - MapSetEvent: [], - MapSubsetEvent: [], - MapTraceEvent: [GetMapTrace()], - MinorMapEvent: [], - MultimapStateEvent: [GetMultimapState()], - PositionsEvent: [GetPos()], - ReportStatsEvent: [], # ReportStats cannot be pulled - RoomsEvent: [GetCachedMapInfo()], - StatsEvent: [GetStats()], - StateEvent: [GetChargeState(), GetCleanInfo()], - TotalStatsEvent: [GetTotalStats()], - TrueDetectEvent: [GetTrueDetect()], - VolumeEvent: [GetVolume()], - WaterInfoEvent: [GetWaterInfo()], -} diff --git a/deebot_client/events/fan_speed.py b/deebot_client/events/fan_speed.py index c361f7b3..ac602166 100644 --- a/deebot_client/events/fan_speed.py +++ b/deebot_client/events/fan_speed.py @@ -3,7 +3,8 @@ from dataclasses import dataclass -from ..util import DisplayNameIntEnum +from deebot_client.util import DisplayNameIntEnum + from .base import Event diff --git a/deebot_client/events/map.py b/deebot_client/events/map.py index e3e5f8ea..18212f06 100644 --- a/deebot_client/events/map.py +++ b/deebot_client/events/map.py @@ -4,7 +4,7 @@ from enum import Enum, unique from typing import Any -from ..events import Event +from deebot_client.events import Event @unique diff --git a/deebot_client/events/network.py b/deebot_client/events/network.py new file mode 100644 index 00000000..75a0dd29 --- /dev/null +++ b/deebot_client/events/network.py @@ -0,0 +1,16 @@ +"""Network info event module.""" + + +from dataclasses import dataclass + +from .base import Event + + +@dataclass(frozen=True) +class NetworkInfoEvent(Event): + """Network info event representation.""" + + ip: str + ssid: str + rssi: int + mac: str diff --git a/deebot_client/events/water_info.py b/deebot_client/events/water_info.py index bf13f0a8..e5821cc1 100644 --- a/deebot_client/events/water_info.py +++ b/deebot_client/events/water_info.py @@ -1,7 +1,8 @@ """Water info event module.""" from dataclasses import dataclass -from ..util import DisplayNameIntEnum +from deebot_client.util import DisplayNameIntEnum + from .base import Event diff --git a/deebot_client/events/work_mode.py b/deebot_client/events/work_mode.py new file mode 100644 index 00000000..1c5e5d50 --- /dev/null +++ b/deebot_client/events/work_mode.py @@ -0,0 +1,22 @@ +"""Work mode event module.""" +from dataclasses import dataclass + +from deebot_client.util import DisplayNameIntEnum + +from .base import Event + + +class WorkMode(DisplayNameIntEnum): + """Enum class for all possible work modes.""" + + VACUUM_AND_MOP = 0 + VACUUM = 1 + MOP = 2 + MOP_AFTER_VACUUM = 3 + + +@dataclass(frozen=True) +class WorkModeEvent(Event): + """Work mode event representation.""" + + mode: WorkMode diff --git a/deebot_client/hardware/__init__.py b/deebot_client/hardware/__init__.py new file mode 100644 index 00000000..0d42dd42 --- /dev/null +++ b/deebot_client/hardware/__init__.py @@ -0,0 +1,6 @@ +"""Hardware module.""" + + +from .deebot import get_static_device_info + +__all__ = ["get_static_device_info"] diff --git a/deebot_client/hardware/deebot/__init__.py b/deebot_client/hardware/deebot/__init__.py new file mode 100644 index 00000000..d375c0a6 --- /dev/null +++ b/deebot_client/hardware/deebot/__init__.py @@ -0,0 +1,34 @@ +"""Hardware deebot module.""" +import importlib +import pkgutil + +from deebot_client.logging_filter import get_logger +from deebot_client.models import StaticDeviceInfo + +__all__ = ["get_static_device_info"] + +_LOGGER = get_logger(__name__) + + +FALLBACK = "fallback" + +DEVICES: dict[str, StaticDeviceInfo] = {} + + +def _load() -> None: + for _, package_name, _ in pkgutil.iter_modules(__path__): + full_package_name = f"{__package__}.{package_name}" + importlib.import_module(full_package_name) + + +def get_static_device_info(class_: str) -> StaticDeviceInfo: + """Get static device info for given class.""" + if not DEVICES: + _load() + + if device := DEVICES.get(class_): + _LOGGER.debug("Capabilities found for %s", class_) + return device + + _LOGGER.warning("No capabilities found for %s. Using fallback.", class_) + return DEVICES[FALLBACK] diff --git a/deebot_client/hardware/deebot/fallback.py b/deebot_client/hardware/deebot/fallback.py new file mode 100644 index 00000000..21e58fb9 --- /dev/null +++ b/deebot_client/hardware/deebot/fallback.py @@ -0,0 +1,177 @@ +"""Fallback Capabilities.""" +from deebot_client.capabilities import ( + Capabilities, + CapabilityClean, + CapabilityCleanAction, + CapabilityCustomCommand, + CapabilityEvent, + CapabilityExecute, + CapabilityLifeSpan, + CapabilityMap, + CapabilitySet, + CapabilitySetEnable, + CapabilitySettings, + CapabilitySetTypes, + CapabilityStats, +) +from deebot_client.commands.json.advanced_mode import GetAdvancedMode, SetAdvancedMode +from deebot_client.commands.json.battery import GetBattery +from deebot_client.commands.json.carpet import ( + GetCarpetAutoFanBoost, + SetCarpetAutoFanBoost, +) +from deebot_client.commands.json.charge import Charge +from deebot_client.commands.json.charge_state import GetChargeState +from deebot_client.commands.json.clean import Clean, CleanArea, GetCleanInfo +from deebot_client.commands.json.clean_count import GetCleanCount, SetCleanCount +from deebot_client.commands.json.clean_logs import GetCleanLogs +from deebot_client.commands.json.clean_preference import ( + GetCleanPreference, + SetCleanPreference, +) +from deebot_client.commands.json.continuous_cleaning import ( + GetContinuousCleaning, + SetContinuousCleaning, +) +from deebot_client.commands.json.custom import CustomCommand +from deebot_client.commands.json.error import GetError +from deebot_client.commands.json.fan_speed import GetFanSpeed, SetFanSpeed +from deebot_client.commands.json.life_span import GetLifeSpan, ResetLifeSpan +from deebot_client.commands.json.map import GetCachedMapInfo, GetMajorMap, GetMapTrace +from deebot_client.commands.json.multimap_state import ( + GetMultimapState, + SetMultimapState, +) +from deebot_client.commands.json.network import GetNetInfo +from deebot_client.commands.json.play_sound import PlaySound +from deebot_client.commands.json.pos import GetPos +from deebot_client.commands.json.relocation import SetRelocationState +from deebot_client.commands.json.stats import GetStats, GetTotalStats +from deebot_client.commands.json.true_detect import GetTrueDetect, SetTrueDetect +from deebot_client.commands.json.volume import GetVolume, SetVolume +from deebot_client.commands.json.water_info import GetWaterInfo, SetWaterInfo +from deebot_client.const import DataType +from deebot_client.events import ( + AdvancedModeEvent, + AvailabilityEvent, + BatteryEvent, + CachedMapInfoEvent, + CarpetAutoFanBoostEvent, + CleanCountEvent, + CleanLogEvent, + CleanPreferenceEvent, + ContinuousCleaningEvent, + CustomCommandEvent, + ErrorEvent, + FanSpeedEvent, + FanSpeedLevel, + LifeSpan, + LifeSpanEvent, + MajorMapEvent, + MapChangedEvent, + MapTraceEvent, + MultimapStateEvent, + NetworkInfoEvent, + PositionsEvent, + ReportStatsEvent, + RoomsEvent, + StateEvent, + StatsEvent, + TotalStatsEvent, + TrueDetectEvent, + VolumeEvent, + WaterAmount, + WaterInfoEvent, +) +from deebot_client.models import StaticDeviceInfo +from deebot_client.util import short_name + +from . import DEVICES + +DEVICES[short_name(__name__)] = StaticDeviceInfo( + DataType.JSON, + Capabilities( + availability=CapabilityEvent(AvailabilityEvent, [GetBattery(True)]), + battery=CapabilityEvent(BatteryEvent, [GetBattery()]), + charge=CapabilityExecute(Charge), + clean=CapabilityClean( + action=CapabilityCleanAction(command=Clean, area=CleanArea), + continuous=CapabilitySetEnable( + ContinuousCleaningEvent, + [GetContinuousCleaning()], + SetContinuousCleaning, + ), + count=CapabilitySet(CleanCountEvent, [GetCleanCount()], SetCleanCount), + log=CapabilityEvent(CleanLogEvent, [GetCleanLogs()]), + preference=CapabilitySetEnable( + CleanPreferenceEvent, [GetCleanPreference()], SetCleanPreference + ), + ), + custom=CapabilityCustomCommand( + event=CustomCommandEvent, get=[], set=CustomCommand + ), + error=CapabilityEvent(ErrorEvent, [GetError()]), + fan_speed=CapabilitySetTypes( + event=FanSpeedEvent, + get=[GetFanSpeed()], + set=SetFanSpeed, + types=( + FanSpeedLevel.QUIET, + FanSpeedLevel.NORMAL, + FanSpeedLevel.MAX, + FanSpeedLevel.MAX_PLUS, + ), + ), + life_span=CapabilityLifeSpan( + types=(LifeSpan.BRUSH, LifeSpan.FILTER, LifeSpan.SIDE_BRUSH), + event=LifeSpanEvent, + get=[GetLifeSpan([LifeSpan.BRUSH, LifeSpan.FILTER, LifeSpan.SIDE_BRUSH])], + reset=ResetLifeSpan, + ), + map=CapabilityMap( + chached_info=CapabilityEvent(CachedMapInfoEvent, [GetCachedMapInfo()]), + changed=CapabilityEvent(MapChangedEvent, []), + major=CapabilityEvent(MajorMapEvent, [GetMajorMap()]), + multi_state=CapabilitySetEnable( + MultimapStateEvent, [GetMultimapState()], SetMultimapState + ), + position=CapabilityEvent(PositionsEvent, [GetPos()]), + relocation=CapabilityExecute(SetRelocationState), + rooms=CapabilityEvent(RoomsEvent, [GetCachedMapInfo()]), + trace=CapabilityEvent(MapTraceEvent, [GetMapTrace()]), + ), + network=CapabilityEvent(NetworkInfoEvent, [GetNetInfo()]), + play_sound=CapabilityExecute(PlaySound), + settings=CapabilitySettings( + advanced_mode=CapabilitySetEnable( + AdvancedModeEvent, [GetAdvancedMode()], SetAdvancedMode + ), + carpet_auto_fan_boost=CapabilitySetEnable( + CarpetAutoFanBoostEvent, + [GetCarpetAutoFanBoost()], + SetCarpetAutoFanBoost, + ), + true_detect=CapabilitySetEnable( + TrueDetectEvent, [GetTrueDetect()], SetTrueDetect + ), + volume=CapabilitySet(VolumeEvent, [GetVolume()], SetVolume), + ), + state=CapabilityEvent(StateEvent, [GetChargeState(), GetCleanInfo()]), + stats=CapabilityStats( + clean=CapabilityEvent(StatsEvent, [GetStats()]), + report=CapabilityEvent(ReportStatsEvent, []), + total=CapabilityEvent(TotalStatsEvent, [GetTotalStats()]), + ), + water=CapabilitySetTypes( + event=WaterInfoEvent, + get=[GetWaterInfo()], + set=SetWaterInfo, + types=( + WaterAmount.LOW, + WaterAmount.MEDIUM, + WaterAmount.HIGH, + WaterAmount.ULTRAHIGH, + ), + ), + ), +) diff --git a/deebot_client/hardware/deebot/p1jij8.py b/deebot_client/hardware/deebot/p1jij8.py new file mode 100644 index 00000000..7213cfa4 --- /dev/null +++ b/deebot_client/hardware/deebot/p1jij8.py @@ -0,0 +1,191 @@ +"""Deebot T20 Omni Capabilities.""" +from deebot_client.capabilities import ( + Capabilities, + CapabilityClean, + CapabilityCleanAction, + CapabilityCustomCommand, + CapabilityEvent, + CapabilityExecute, + CapabilityLifeSpan, + CapabilityMap, + CapabilitySet, + CapabilitySetEnable, + CapabilitySettings, + CapabilitySetTypes, + CapabilityStats, +) +from deebot_client.commands.json.advanced_mode import GetAdvancedMode, SetAdvancedMode +from deebot_client.commands.json.battery import GetBattery +from deebot_client.commands.json.carpet import ( + GetCarpetAutoFanBoost, + SetCarpetAutoFanBoost, +) +from deebot_client.commands.json.charge import Charge +from deebot_client.commands.json.charge_state import GetChargeState +from deebot_client.commands.json.clean import Clean, CleanArea, GetCleanInfo +from deebot_client.commands.json.clean_count import GetCleanCount, SetCleanCount +from deebot_client.commands.json.clean_logs import GetCleanLogs +from deebot_client.commands.json.clean_preference import ( + GetCleanPreference, + SetCleanPreference, +) +from deebot_client.commands.json.continuous_cleaning import ( + GetContinuousCleaning, + SetContinuousCleaning, +) +from deebot_client.commands.json.custom import CustomCommand +from deebot_client.commands.json.error import GetError +from deebot_client.commands.json.fan_speed import GetFanSpeed, SetFanSpeed +from deebot_client.commands.json.life_span import GetLifeSpan, ResetLifeSpan +from deebot_client.commands.json.map import GetCachedMapInfo, GetMajorMap, GetMapTrace +from deebot_client.commands.json.multimap_state import ( + GetMultimapState, + SetMultimapState, +) +from deebot_client.commands.json.network import GetNetInfo +from deebot_client.commands.json.play_sound import PlaySound +from deebot_client.commands.json.pos import GetPos +from deebot_client.commands.json.relocation import SetRelocationState +from deebot_client.commands.json.stats import GetStats, GetTotalStats +from deebot_client.commands.json.true_detect import GetTrueDetect, SetTrueDetect +from deebot_client.commands.json.volume import GetVolume, SetVolume +from deebot_client.commands.json.water_info import GetWaterInfo, SetWaterInfo +from deebot_client.commands.json.work_mode import GetWorkMode, SetWorkMode +from deebot_client.const import DataType +from deebot_client.events import ( + AdvancedModeEvent, + AvailabilityEvent, + BatteryEvent, + CachedMapInfoEvent, + CarpetAutoFanBoostEvent, + CleanCountEvent, + CleanLogEvent, + CleanPreferenceEvent, + ContinuousCleaningEvent, + CustomCommandEvent, + ErrorEvent, + FanSpeedEvent, + FanSpeedLevel, + LifeSpan, + LifeSpanEvent, + MajorMapEvent, + MapChangedEvent, + MapTraceEvent, + MultimapStateEvent, + NetworkInfoEvent, + PositionsEvent, + ReportStatsEvent, + RoomsEvent, + StateEvent, + StatsEvent, + TotalStatsEvent, + TrueDetectEvent, + VolumeEvent, + WaterAmount, + WaterInfoEvent, + WorkMode, + WorkModeEvent, +) +from deebot_client.models import StaticDeviceInfo +from deebot_client.util import short_name + +from . import DEVICES + +DEVICES[short_name(__name__)] = StaticDeviceInfo( + DataType.JSON, + Capabilities( + availability=CapabilityEvent(AvailabilityEvent, [GetBattery(True)]), + battery=CapabilityEvent(BatteryEvent, [GetBattery()]), + charge=CapabilityExecute(Charge), + clean=CapabilityClean( + action=CapabilityCleanAction(command=Clean, area=CleanArea), + continuous=CapabilitySetEnable( + ContinuousCleaningEvent, + [GetContinuousCleaning()], + SetContinuousCleaning, + ), + count=CapabilitySet(CleanCountEvent, [GetCleanCount()], SetCleanCount), + log=CapabilityEvent(CleanLogEvent, [GetCleanLogs()]), + preference=CapabilitySetEnable( + CleanPreferenceEvent, [GetCleanPreference()], SetCleanPreference + ), + work_mode=CapabilitySetTypes( + event=WorkModeEvent, + get=[GetWorkMode()], + set=SetWorkMode, + types=( + WorkMode.MOP, + WorkMode.MOP_AFTER_VACUUM, + WorkMode.VACUUM, + WorkMode.VACUUM_AND_MOP, + ), + ), + ), + custom=CapabilityCustomCommand( + event=CustomCommandEvent, get=[], set=CustomCommand + ), + error=CapabilityEvent(ErrorEvent, [GetError()]), + fan_speed=CapabilitySetTypes( + event=FanSpeedEvent, + get=[GetFanSpeed()], + set=SetFanSpeed, + types=( + FanSpeedLevel.QUIET, + FanSpeedLevel.NORMAL, + FanSpeedLevel.MAX, + FanSpeedLevel.MAX_PLUS, + ), + ), + life_span=CapabilityLifeSpan( + types=(LifeSpan.BRUSH, LifeSpan.FILTER, LifeSpan.SIDE_BRUSH), + event=LifeSpanEvent, + get=[GetLifeSpan([LifeSpan.BRUSH, LifeSpan.FILTER, LifeSpan.SIDE_BRUSH])], + reset=ResetLifeSpan, + ), + map=CapabilityMap( + chached_info=CapabilityEvent(CachedMapInfoEvent, [GetCachedMapInfo()]), + changed=CapabilityEvent(MapChangedEvent, []), + major=CapabilityEvent(MajorMapEvent, [GetMajorMap()]), + multi_state=CapabilitySetEnable( + MultimapStateEvent, [GetMultimapState()], SetMultimapState + ), + position=CapabilityEvent(PositionsEvent, [GetPos()]), + relocation=CapabilityExecute(SetRelocationState), + rooms=CapabilityEvent(RoomsEvent, [GetCachedMapInfo()]), + trace=CapabilityEvent(MapTraceEvent, [GetMapTrace()]), + ), + network=CapabilityEvent(NetworkInfoEvent, [GetNetInfo()]), + play_sound=CapabilityExecute(PlaySound), + settings=CapabilitySettings( + advanced_mode=CapabilitySetEnable( + AdvancedModeEvent, [GetAdvancedMode()], SetAdvancedMode + ), + carpet_auto_fan_boost=CapabilitySetEnable( + CarpetAutoFanBoostEvent, + [GetCarpetAutoFanBoost()], + SetCarpetAutoFanBoost, + ), + true_detect=CapabilitySetEnable( + TrueDetectEvent, [GetTrueDetect()], SetTrueDetect + ), + volume=CapabilitySet(VolumeEvent, [GetVolume()], SetVolume), + ), + state=CapabilityEvent(StateEvent, [GetChargeState(), GetCleanInfo()]), + stats=CapabilityStats( + clean=CapabilityEvent(StatsEvent, [GetStats()]), + report=CapabilityEvent(ReportStatsEvent, []), + total=CapabilityEvent(TotalStatsEvent, [GetTotalStats()]), + ), + water=CapabilitySetTypes( + event=WaterInfoEvent, + get=[GetWaterInfo()], + set=SetWaterInfo, + types=( + WaterAmount.LOW, + WaterAmount.MEDIUM, + WaterAmount.HIGH, + WaterAmount.ULTRAHIGH, + ), + ), + ), +) diff --git a/deebot_client/hardware/deebot/vi829v.py b/deebot_client/hardware/deebot/vi829v.py new file mode 120000 index 00000000..4499b03c --- /dev/null +++ b/deebot_client/hardware/deebot/vi829v.py @@ -0,0 +1 @@ +yna5xi.py \ No newline at end of file diff --git a/deebot_client/hardware/deebot/yna5xi.py b/deebot_client/hardware/deebot/yna5xi.py new file mode 100644 index 00000000..8ac871fc --- /dev/null +++ b/deebot_client/hardware/deebot/yna5xi.py @@ -0,0 +1,161 @@ +"""Deebot Ozmo 920/950 Capabilities.""" +from deebot_client.capabilities import ( + Capabilities, + CapabilityClean, + CapabilityCleanAction, + CapabilityCustomCommand, + CapabilityEvent, + CapabilityExecute, + CapabilityLifeSpan, + CapabilityMap, + CapabilitySet, + CapabilitySetEnable, + CapabilitySettings, + CapabilitySetTypes, + CapabilityStats, +) +from deebot_client.commands.json.advanced_mode import GetAdvancedMode, SetAdvancedMode +from deebot_client.commands.json.battery import GetBattery +from deebot_client.commands.json.carpet import ( + GetCarpetAutoFanBoost, + SetCarpetAutoFanBoost, +) +from deebot_client.commands.json.charge import Charge +from deebot_client.commands.json.charge_state import GetChargeState +from deebot_client.commands.json.clean import Clean, CleanArea, GetCleanInfo +from deebot_client.commands.json.clean_logs import GetCleanLogs +from deebot_client.commands.json.continuous_cleaning import ( + GetContinuousCleaning, + SetContinuousCleaning, +) +from deebot_client.commands.json.custom import CustomCommand +from deebot_client.commands.json.error import GetError +from deebot_client.commands.json.fan_speed import GetFanSpeed, SetFanSpeed +from deebot_client.commands.json.life_span import GetLifeSpan, ResetLifeSpan +from deebot_client.commands.json.map import GetCachedMapInfo, GetMajorMap, GetMapTrace +from deebot_client.commands.json.multimap_state import ( + GetMultimapState, + SetMultimapState, +) +from deebot_client.commands.json.network import GetNetInfo +from deebot_client.commands.json.play_sound import PlaySound +from deebot_client.commands.json.pos import GetPos +from deebot_client.commands.json.relocation import SetRelocationState +from deebot_client.commands.json.stats import GetStats, GetTotalStats +from deebot_client.commands.json.volume import GetVolume, SetVolume +from deebot_client.commands.json.water_info import GetWaterInfo, SetWaterInfo +from deebot_client.const import DataType +from deebot_client.events import ( + AdvancedModeEvent, + AvailabilityEvent, + BatteryEvent, + CachedMapInfoEvent, + CarpetAutoFanBoostEvent, + CleanLogEvent, + ContinuousCleaningEvent, + CustomCommandEvent, + ErrorEvent, + FanSpeedEvent, + FanSpeedLevel, + LifeSpan, + LifeSpanEvent, + MajorMapEvent, + MapChangedEvent, + MapTraceEvent, + MultimapStateEvent, + PositionsEvent, + ReportStatsEvent, + RoomsEvent, + StateEvent, + StatsEvent, + TotalStatsEvent, + VolumeEvent, + WaterAmount, + WaterInfoEvent, +) +from deebot_client.events.network import NetworkInfoEvent +from deebot_client.models import StaticDeviceInfo +from deebot_client.util import short_name + +from . import DEVICES + +DEVICES[short_name(__name__)] = StaticDeviceInfo( + DataType.JSON, + Capabilities( + availability=CapabilityEvent(AvailabilityEvent, [GetBattery(True)]), + battery=CapabilityEvent(BatteryEvent, [GetBattery()]), + charge=CapabilityExecute(Charge), + clean=CapabilityClean( + action=CapabilityCleanAction(command=Clean, area=CleanArea), + continuous=CapabilitySetEnable( + ContinuousCleaningEvent, + [GetContinuousCleaning()], + SetContinuousCleaning, + ), + log=CapabilityEvent(CleanLogEvent, [GetCleanLogs()]), + ), + custom=CapabilityCustomCommand( + event=CustomCommandEvent, get=[], set=CustomCommand + ), + error=CapabilityEvent(ErrorEvent, [GetError()]), + fan_speed=CapabilitySetTypes( + event=FanSpeedEvent, + get=[GetFanSpeed()], + set=SetFanSpeed, + types=( + FanSpeedLevel.QUIET, + FanSpeedLevel.NORMAL, + FanSpeedLevel.MAX, + FanSpeedLevel.MAX_PLUS, + ), + ), + life_span=CapabilityLifeSpan( + types=(LifeSpan.BRUSH, LifeSpan.FILTER, LifeSpan.SIDE_BRUSH), + event=LifeSpanEvent, + get=[GetLifeSpan([LifeSpan.BRUSH, LifeSpan.FILTER, LifeSpan.SIDE_BRUSH])], + reset=ResetLifeSpan, + ), + map=CapabilityMap( + chached_info=CapabilityEvent(CachedMapInfoEvent, [GetCachedMapInfo()]), + changed=CapabilityEvent(MapChangedEvent, []), + major=CapabilityEvent(MajorMapEvent, [GetMajorMap()]), + multi_state=CapabilitySetEnable( + MultimapStateEvent, [GetMultimapState()], SetMultimapState + ), + position=CapabilityEvent(PositionsEvent, [GetPos()]), + relocation=CapabilityExecute(SetRelocationState), + rooms=CapabilityEvent(RoomsEvent, [GetCachedMapInfo()]), + trace=CapabilityEvent(MapTraceEvent, [GetMapTrace()]), + ), + network=CapabilityEvent(NetworkInfoEvent, [GetNetInfo()]), + play_sound=CapabilityExecute(PlaySound), + settings=CapabilitySettings( + advanced_mode=CapabilitySetEnable( + AdvancedModeEvent, [GetAdvancedMode()], SetAdvancedMode + ), + carpet_auto_fan_boost=CapabilitySetEnable( + CarpetAutoFanBoostEvent, + [GetCarpetAutoFanBoost()], + SetCarpetAutoFanBoost, + ), + volume=CapabilitySet(VolumeEvent, [GetVolume()], SetVolume), + ), + state=CapabilityEvent(StateEvent, [GetChargeState(), GetCleanInfo()]), + stats=CapabilityStats( + clean=CapabilityEvent(StatsEvent, [GetStats()]), + report=CapabilityEvent(ReportStatsEvent, []), + total=CapabilityEvent(TotalStatsEvent, [GetTotalStats()]), + ), + water=CapabilitySetTypes( + event=WaterInfoEvent, + get=[GetWaterInfo()], + set=SetWaterInfo, + types=( + WaterAmount.LOW, + WaterAmount.MEDIUM, + WaterAmount.HIGH, + WaterAmount.ULTRAHIGH, + ), + ), + ), +) diff --git a/deebot_client/map.py b/deebot_client/map.py index 4a6f00b1..c18c89b8 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -2,23 +2,25 @@ import ast import asyncio import base64 +from collections.abc import Callable, Coroutine import dataclasses +from datetime import UTC, datetime +from io import BytesIO import lzma import math import struct -import zlib -from collections.abc import Callable, Coroutine -from datetime import datetime, timezone -from io import BytesIO from typing import Any, Final +import zlib -from numpy import ndarray, reshape, zeros +from numpy import float64, reshape, zeros +from numpy.typing import NDArray from PIL import Image, ImageDraw, ImageOps from deebot_client.events.map import MapChangedEvent from .command import Command -from .commands import GetCachedMapInfo, GetMinorMap +from .commands.json import GetCachedMapInfo, GetMinorMap +from .event_bus import EventBus from .events import ( MajorMapEvent, MapSetEvent, @@ -31,7 +33,6 @@ PositionType, RoomsEvent, ) -from .events.event_bus import EventBus from .exceptions import MapError from .logging_filter import get_logger from .models import Room @@ -102,7 +103,9 @@ def _calc_point( def _draw_positions( - positions: list[Position], image: Image, image_box: tuple[int, int, int, int] + positions: list[Position], + image: Image.Image, + image_box: tuple[int, int, int, int] | None, ) -> None: for position in positions: icon = Image.open(BytesIO(base64.b64decode(_POSITION_PNG[position.type]))) @@ -116,7 +119,7 @@ def _draw_positions( def _draw_subset( subset: MapSubsetEvent, draw: "DashedImageDraw", - image_box: tuple[int, int, int, int], + image_box: tuple[int, int, int, int] | None, ) -> None: coordinates_ = ast.literal_eval(subset.coordinates) points: list[tuple[int, int]] = [] @@ -202,7 +205,7 @@ def _update_trace_points(self, data: str) -> None: _LOGGER.debug("[_update_trace_points] finish") - def _draw_map_pieces(self, draw: ImageDraw.Draw) -> None: + def _draw_map_pieces(self, draw: ImageDraw.ImageDraw) -> None: _LOGGER.debug("[_draw_map_pieces] Draw") image_x = 0 image_y = 0 @@ -394,7 +397,7 @@ class MapPiece: def __init__(self, on_change: Callable[[], None], index: int) -> None: self._on_change = on_change self._index = index - self._points: ndarray | None = None + self._points: NDArray[float64] | None = None self._crc32: int = MapPiece._NOT_INUSE_CRC32 def crc32_indicates_update(self, crc32: str) -> bool: @@ -413,7 +416,7 @@ def in_use(self) -> bool: return self._crc32 != MapPiece._NOT_INUSE_CRC32 @property - def points(self) -> ndarray: + def points(self) -> NDArray[float64]: """I'm the 'x' property.""" if not self.in_use or self._points is None: return zeros((100, 100)) @@ -444,17 +447,17 @@ def __eq__(self, obj: object) -> bool: return self._crc32 == obj._crc32 and self._index == obj._index -class DashedImageDraw(ImageDraw.ImageDraw): # type: ignore +class DashedImageDraw(ImageDraw.ImageDraw): """Class extend ImageDraw by dashed line.""" - # pylint: disable=invalid-name # Copied from https://stackoverflow.com/a/65893631 Credits ands + _FILL = str | int | tuple[int, int, int] | tuple[int, int, int, int] | None def _thick_line( self, xy: list[tuple[int, int]], direction: list[tuple[int, int]], - fill: tuple | str | None = None, + fill: _FILL = None, width: int = 0, ) -> None: if xy[0] != xy[1]: @@ -491,12 +494,11 @@ def _thick_line( def dashed_line( self, xy: list[tuple[int, int]], - dash: tuple = (2, 2), - fill: tuple | str | None = None, + dash: tuple[int, int] = (2, 2), + fill: _FILL = None, width: int = 0, ) -> None: """Draw a dashed line, or a connected sequence of line segments.""" - # pylint: disable=too-many-locals for i in range(len(xy) - 1): x1, y1 = xy[i] x2, y2 = xy[i + 1] @@ -545,9 +547,7 @@ def __init__(self, event_bus: EventBus) -> None: def on_change() -> None: self._changed = True - event_bus.notify( - MapChangedEvent(datetime.now(timezone.utc)), debounce_time=1 - ) + event_bus.notify(MapChangedEvent(datetime.now(UTC)), debounce_time=1) self._on_change = on_change self._map_pieces: OnChangedList[MapPiece] = OnChangedList( diff --git a/deebot_client/message.py b/deebot_client/message.py index 14aaee55..46c25a93 100644 --- a/deebot_client/message.py +++ b/deebot_client/message.py @@ -1,12 +1,12 @@ """Base messages.""" -import functools from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass from enum import IntEnum, auto -from typing import Any, final +import functools +from typing import Any, TypeVar, final -from .events.event_bus import EventBus +from .event_bus import EventBus from .logging_filter import get_logger _LOGGER = get_logger(__name__) @@ -40,20 +40,25 @@ def analyse(cls) -> "HandlingResult": return HandlingResult(HandlingState.ANALYSE) +_MessageT = TypeVar("_MessageT", bound="Message") + + def _handle_error_or_analyse( - func: Callable[[type["Message"], EventBus, dict[str, Any]], HandlingResult] -) -> Callable[[type["Message"], EventBus, dict[str, Any]], HandlingResult]: + func: Callable[[type[_MessageT], EventBus, dict[str, Any]], HandlingResult] +) -> Callable[[type[_MessageT], EventBus, dict[str, Any]], HandlingResult]: """Handle error or None response.""" @functools.wraps(func) def wrapper( - cls: type["Message"], event_bus: EventBus, data: dict[str, Any] + cls: type[_MessageT], event_bus: EventBus, data: dict[str, Any] ) -> HandlingResult: try: response = func(cls, event_bus, data) if response.state == HandlingState.ANALYSE: _LOGGER.debug("Could not handle %s message: %s", cls.name, data) return HandlingResult(HandlingState.ANALYSE_LOGGED, response.args) + if response.state == HandlingState.ERROR: + _LOGGER.warning("Could not parse %s: %s", cls.name, data) return response except Exception: # pylint: disable=broad-except _LOGGER.warning("Could not parse %s: %s", cls.name, data, exc_info=True) @@ -63,7 +68,7 @@ def wrapper( class Message(ABC): - """Message with handling code.""" + """Message.""" @property # type: ignore[misc] @classmethod @@ -71,6 +76,32 @@ class Message(ABC): def name(cls) -> str: """Command name.""" + @classmethod + @abstractmethod + def _handle( + cls, event_bus: EventBus, message: dict[str, Any] | str + ) -> HandlingResult: + """Handle message and notify the correct event subscribers. + + :return: A message response + """ + + @classmethod + @_handle_error_or_analyse + @final + def handle( + cls, event_bus: EventBus, message: dict[str, Any] | str + ) -> HandlingResult: + """Handle message and notify the correct event subscribers. + + :return: A message response + """ + return cls._handle(event_bus, message) + + +class MessageBody(Message): + """Dict message with body attribute.""" + @classmethod @abstractmethod def _handle_body(cls, event_bus: EventBus, body: dict[str, Any]) -> HandlingResult: @@ -86,24 +117,27 @@ def __handle_body(cls, event_bus: EventBus, body: dict[str, Any]) -> HandlingRes return cls._handle_body(event_bus, body) @classmethod - @_handle_error_or_analyse - @final - def handle(cls, event_bus: EventBus, message: dict[str, Any]) -> HandlingResult: + def _handle( + cls, event_bus: EventBus, message: dict[str, Any] | str + ) -> HandlingResult: """Handle message and notify the correct event subscribers. :return: A message response """ - data_body = message.get("body", message) - return cls.__handle_body(event_bus, data_body) + if isinstance(message, dict): + data_body = message.get("body", message) + return cls.__handle_body(event_bus, data_body) + + return super()._handle(event_bus, message) -class MessageBodyData(Message): - """Message with handling body->data code.""" +class MessageBodyData(MessageBody): + """Dict message with body->data attribute.""" @classmethod @abstractmethod def _handle_body_data( - cls, event_bus: EventBus, data: dict[str, Any] | list + cls, event_bus: EventBus, data: dict[str, Any] | list[Any] ) -> HandlingResult: """Handle message->body->data and notify the correct event subscribers. @@ -113,7 +147,7 @@ def _handle_body_data( @classmethod @final def __handle_body_data( - cls, event_bus: EventBus, data: dict[str, Any] | list + cls, event_bus: EventBus, data: dict[str, Any] | list[Any] ) -> HandlingResult: try: response = cls._handle_body_data(event_bus, data) @@ -136,7 +170,7 @@ def _handle_body(cls, event_bus: EventBus, body: dict[str, Any]) -> HandlingResu class MessageBodyDataDict(MessageBodyData): - """Message with handling body->data->dict code.""" + """Dict message with body->data attribute as dict.""" @classmethod @abstractmethod @@ -150,7 +184,7 @@ def _handle_body_data_dict( @classmethod def _handle_body_data( - cls, event_bus: EventBus, data: dict[str, Any] | list + cls, event_bus: EventBus, data: dict[str, Any] | list[Any] ) -> HandlingResult: """Handle message->body->data and notify the correct event subscribers. @@ -163,11 +197,13 @@ def _handle_body_data( class MessageBodyDataList(MessageBodyData): - """Message with handling body->data->list code.""" + """Dict message with body->data attribute as list.""" @classmethod @abstractmethod - def _handle_body_data_list(cls, event_bus: EventBus, data: list) -> HandlingResult: + def _handle_body_data_list( + cls, event_bus: EventBus, data: list[Any] + ) -> HandlingResult: """Handle message->body->data and notify the correct event subscribers. :return: A message response @@ -175,7 +211,7 @@ def _handle_body_data_list(cls, event_bus: EventBus, data: list) -> HandlingResu @classmethod def _handle_body_data( - cls, event_bus: EventBus, data: dict[str, Any] | list + cls, event_bus: EventBus, data: dict[str, Any] | list[Any] ) -> HandlingResult: """Handle message->body->data and notify the correct event subscribers. diff --git a/deebot_client/messages/__init__.py b/deebot_client/messages/__init__.py index 2e93165a..47c6aae5 100644 --- a/deebot_client/messages/__init__.py +++ b/deebot_client/messages/__init__.py @@ -3,32 +3,30 @@ import re -from ..logging_filter import get_logger -from ..message import Message -from .battery import OnBattery -from .stats import ReportStats +from deebot_client.const import DataType +from deebot_client.logging_filter import get_logger +from deebot_client.message import Message -_LOGGER = get_logger(__name__) - -# fmt: off -# ordered by file asc -_MESSAGES: list[type[Message]] = [ - OnBattery, +from .json import MESSAGES as JSON_MESSAGES - ReportStats -] -# fmt: on +_LOGGER = get_logger(__name__) -MESSAGES: dict[str, type[Message]] = {message.name: message for message in _MESSAGES} # type: ignore[misc] +MESSAGES = { + DataType.JSON: JSON_MESSAGES, +} -def get_message(message_name: str) -> type[Message] | None: +def get_message(message_name: str, data_type: DataType) -> type[Message] | None: """Try to find the message for the given name. - If there exists no exact match, some conversations are performed on the name to get message object simalr to the name. + If there exists no exact match, some conversations are performed on the name to get message object similar to the name. """ + messages = MESSAGES.get(data_type) + if messages is None: + _LOGGER.warning("Datatype %s is not supported.", data_type) + return None - if message_type := MESSAGES.get(message_name, None): + if message_type := messages.get(message_name, None): return message_type converted_name = message_name @@ -36,7 +34,7 @@ def get_message(message_name: str) -> type[Message] | None: if converted_name.endswith("_V2"): converted_name = converted_name[:-3] - if message_type := MESSAGES.get(converted_name, None): + if message_type := messages.get(converted_name, None): return message_type # Handle message starting with "on","off","report" the same as "get" commands @@ -46,9 +44,11 @@ def get_message(message_name: str) -> type[Message] | None: converted_name, ) - from ..commands import COMMANDS # pylint: disable=import-outside-toplevel + from deebot_client.commands import ( # pylint: disable=import-outside-toplevel + COMMANDS, + ) - if found_command := COMMANDS.get(converted_name, None): + if found_command := COMMANDS.get(data_type, {}).get(converted_name, None): if issubclass(found_command, Message): _LOGGER.debug("Falling back to old handling way for %s", message_name) return found_command diff --git a/deebot_client/messages/json/__init__.py b/deebot_client/messages/json/__init__.py new file mode 100644 index 00000000..002cc308 --- /dev/null +++ b/deebot_client/messages/json/__init__.py @@ -0,0 +1,20 @@ +"""Json messages.""" + + +from deebot_client.message import Message + +from .battery import OnBattery +from .stats import ReportStats + +__all__ = ["OnBattery", "ReportStats"] + +# fmt: off +# ordered by file asc +_MESSAGES: list[type[Message]] = [ + OnBattery, + + ReportStats +] +# fmt: on + +MESSAGES: dict[str, type[Message]] = {message.name: message for message in _MESSAGES} # type: ignore[misc] diff --git a/deebot_client/messages/battery.py b/deebot_client/messages/json/battery.py similarity index 75% rename from deebot_client/messages/battery.py rename to deebot_client/messages/json/battery.py index 6ff75563..cabe1f67 100644 --- a/deebot_client/messages/battery.py +++ b/deebot_client/messages/json/battery.py @@ -1,9 +1,9 @@ """Battery messages.""" from typing import Any -from ..events import BatteryEvent -from ..events.event_bus import EventBus -from ..message import HandlingResult, MessageBodyDataDict +from deebot_client.event_bus import EventBus +from deebot_client.events import BatteryEvent +from deebot_client.message import HandlingResult, MessageBodyDataDict class OnBattery(MessageBodyDataDict): diff --git a/deebot_client/messages/stats.py b/deebot_client/messages/json/stats.py similarity index 84% rename from deebot_client/messages/stats.py rename to deebot_client/messages/json/stats.py index 61559dfa..385cfb84 100644 --- a/deebot_client/messages/stats.py +++ b/deebot_client/messages/json/stats.py @@ -1,9 +1,9 @@ """Stats messages.""" from typing import Any -from ..events import CleanJobStatus, ReportStatsEvent -from ..events.event_bus import EventBus -from ..message import HandlingResult, MessageBodyDataDict +from deebot_client.event_bus import EventBus +from deebot_client.events import CleanJobStatus, ReportStatsEvent +from deebot_client.message import HandlingResult, MessageBodyDataDict class ReportStats(MessageBodyDataDict): diff --git a/deebot_client/models.py b/deebot_client/models.py index 465cbca5..2e15d1d5 100644 --- a/deebot_client/models.py +++ b/deebot_client/models.py @@ -1,53 +1,104 @@ """Models module.""" -import os from dataclasses import dataclass -from enum import IntEnum, unique +from enum import IntEnum, StrEnum, unique +import os +from typing import TYPE_CHECKING, Required, TypedDict from aiohttp import ClientSession +from deebot_client.const import DataType + +if TYPE_CHECKING: + from deebot_client.capabilities import Capabilities + + +ApiDeviceInfo = TypedDict( + "ApiDeviceInfo", + { + "company": str, + "did": Required[str], + "name": Required[str], + "nick": str, + "resource": Required[str], + "deviceName": Required[str], + "status": Required[int], + "class": Required[str], + }, + total=False, +) + + +@dataclass(frozen=True) +class StaticDeviceInfo: + """Static device info.""" + + data_type: DataType + capabilities: "Capabilities" + + +class DeviceInfo: + """Device info.""" + + def __init__( + self, api_device_info: ApiDeviceInfo, static_device_info: StaticDeviceInfo + ) -> None: + self._api_device_info = api_device_info + self._static_device_info = static_device_info -class DeviceInfo(dict): - """Class holds all values, which we get from api. Common values can be accessed through properties.""" + @property + def api_device_info(self) -> ApiDeviceInfo: + """Return all data goten from the api.""" + return self._api_device_info @property def company(self) -> str: """Return company.""" - return str(self["company"]) + return self._api_device_info["company"] @property def did(self) -> str: """Return did.""" - return str(self["did"]) + return str(self._api_device_info["did"]) @property def name(self) -> str: """Return name.""" - return str(self["name"]) + return str(self._api_device_info["name"]) @property def nick(self) -> str | None: """Return nick name.""" - return self.get("nick", None) + return self._api_device_info.get("nick", None) @property def resource(self) -> str: """Return resource.""" - return str(self["resource"]) + return str(self._api_device_info["resource"]) @property def device_name(self) -> str: """Return device name.""" - return str(self["deviceName"]) + return str(self._api_device_info["deviceName"]) @property def status(self) -> int: """Return device status.""" - return int(self["status"]) + return int(self._api_device_info["status"]) @property def get_class(self) -> str: """Return device class.""" - return str(self["class"]) + return str(self._api_device_info["class"]) + + @property + def data_type(self) -> DataType: + """Return data type.""" + return self._static_device_info.data_type + + @property + def capabilities(self) -> "Capabilities": + """Return capabilities.""" + return self._static_device_info.capabilities @dataclass(frozen=True) @@ -60,8 +111,8 @@ class Room: @unique -class VacuumState(IntEnum): - """Vacuum state representation.""" +class State(IntEnum): + """State representation.""" IDLE = 1 CLEANING = 2 @@ -71,6 +122,25 @@ class VacuumState(IntEnum): PAUSED = 6 +@unique +class CleanAction(StrEnum): + """Enum class for all possible clean actions.""" + + START = "start" + PAUSE = "pause" + RESUME = "resume" + STOP = "stop" + + +@unique +class CleanMode(StrEnum): + """Enum class for all possible clean modes.""" + + AUTO = "auto" + SPOT_AREA = "spotArea" + CUSTOM_AREA = "customArea" + + @dataclass(frozen=True) class Credentials: """Credentials representation.""" diff --git a/deebot_client/mqtt_client.py b/deebot_client/mqtt_client.py index ba41580a..b146c95d 100644 --- a/deebot_client/mqtt_client.py +++ b/deebot_client/mqtt_client.py @@ -1,20 +1,23 @@ """MQTT module.""" import asyncio -import json -import ssl from collections.abc import Callable, MutableMapping from contextlib import suppress from dataclasses import _MISSING_TYPE, InitVar, dataclass, field, fields from datetime import datetime +import json +import ssl +from typing import Any from aiomqtt import Client, Message, MqttError from cachetools import TTLCache -from deebot_client.events.event_bus import EventBus +from deebot_client.command import CommandMqttP2P +from deebot_client.const import DataType +from deebot_client.event_bus import EventBus from deebot_client.exceptions import AuthenticationError from .authentication import Authenticator -from .commands import COMMANDS_WITH_MQTT_P2P_HANDLING, CommandHandlingMqttP2P +from .commands import COMMANDS_WITH_MQTT_P2P_HANDLING from .logging_filter import get_logger from .models import Configuration, Credentials, DeviceInfo @@ -95,11 +98,11 @@ def __init__( self._subscribtion_changes: asyncio.Queue[ tuple[SubscriberInfo, bool] ] = asyncio.Queue() - self._mqtt_task: asyncio.Task | None = None + self._mqtt_task: asyncio.Task[Any] | None = None - self._received_p2p_commands: MutableMapping[ - str, CommandHandlingMqttP2P - ] = TTLCache(maxsize=60 * 60, ttl=60) + self._received_p2p_commands: MutableMapping[str, CommandMqttP2P] = TTLCache( + maxsize=60 * 60, ttl=60 + ) self._last_message_received_at: datetime | None = None async def on_credentials_changed(_: Credentials) -> None: @@ -113,7 +116,7 @@ def last_message_received_at(self) -> datetime | None: return self._last_message_received_at async def subscribe(self, info: SubscriberInfo) -> Callable[[], None]: - """Subscribe for messages from given vacuum.""" + """Subscribe for messages from given device.""" await self.connect() self._subscribtion_changes.put_nowait((info, True)) @@ -189,13 +192,12 @@ async def listen() -> None: exc_info=True, ) except AuthenticationError: - _LOGGER.error( - "Could not authenticate. Please check your credentials and afterwards reload the integration.", - exc_info=True, + _LOGGER.exception( + "Could not authenticate. Please check your credentials and afterwards reload the integration." ) return except Exception: # pylint: disable=broad-except - _LOGGER.error("An exception occurred", exc_info=True) + _LOGGER.exception("An exception occurred") return await asyncio.sleep(RECONNECT_INTERVAL) @@ -250,16 +252,20 @@ def _handle_atr( if sub_info := self._subscribtions.get(topic_split[3]): sub_info.callback(topic_split[2], payload) except Exception: # pylint: disable=broad-except - _LOGGER.error( - "An exception occurred during handling atr message", exc_info=True - ) + _LOGGER.exception("An exception occurred during handling atr message") def _handle_p2p( self, topic_split: list[str], payload: str | bytes | bytearray ) -> None: try: + if (data_type := DataType.get(topic_split[11])) is None: + _LOGGER.warning('Unsupported data type: "%s"', topic_split[11]) + return + command_name = topic_split[2] - command_type = COMMANDS_WITH_MQTT_P2P_HANDLING.get(command_name, None) + command_type = COMMANDS_WITH_MQTT_P2P_HANDLING.get(data_type, {}).get( + command_name, None + ) if command_type is None: _LOGGER.debug( "Command %s does not support p2p handling (yet)", command_name @@ -281,19 +287,18 @@ def _handle_p2p( ) return - self._received_p2p_commands[request_id] = command_type(**data) + self._received_p2p_commands[request_id] = command_type.create_from_mqtt( + data + ) + elif command := self._received_p2p_commands.pop(request_id, None): + if sub_info := self._subscribtions.get(topic_split[3]): + data = json.loads(payload) + command.handle_mqtt_p2p(sub_info.events, data) else: - if command := self._received_p2p_commands.pop(request_id, None): - if sub_info := self._subscribtions.get(topic_split[3]): - data = json.loads(payload) - command.handle_mqtt_p2p(sub_info.events, data) - else: - _LOGGER.debug( - "Response to command came in probably to late. requestId=%s, commandName=%s", - request_id, - command_name, - ) + _LOGGER.debug( + "Response to command came in probably to late. requestId=%s, commandName=%s", + request_id, + command_name, + ) except Exception: # pylint: disable=broad-except - _LOGGER.error( - "An exception occurred during handling p2p message", exc_info=True - ) + _LOGGER.exception("An exception occurred during handling p2p message") diff --git a/deebot_client/util.py b/deebot_client/util.py index c78f627e..e3ff8dae 100644 --- a/deebot_client/util.py +++ b/deebot_client/util.py @@ -2,18 +2,18 @@ from __future__ import annotations import asyncio -import hashlib from collections.abc import Callable, Coroutine, Iterable, Mapping from contextlib import suppress from enum import IntEnum, unique -from typing import Any, TypeVar +import hashlib +from typing import Any, Self, TypeVar _T = TypeVar("_T") def md5(text: str) -> str: """Hash text using md5.""" - return hashlib.md5(bytes(str(text), "utf8")).hexdigest() + return hashlib.md5(bytes(str(text), "utf8")).hexdigest() # noqa: S324 def create_task( @@ -41,7 +41,7 @@ async def cancel(tasks: set[asyncio.Future[Any]]) -> None: class DisplayNameIntEnum(IntEnum): """Int enum with a property "display_name".""" - def __new__(cls, *args: int, **_: Mapping) -> DisplayNameIntEnum: + def __new__(cls, *args: int, **_: Mapping[Any, Any]) -> Self: """Create new DisplayNameIntEnum.""" obj = int.__new__(cls) obj._value_ = args[0] @@ -61,7 +61,7 @@ def display_name(self) -> str: return self.name.lower() @classmethod - def get(cls, value: str) -> DisplayNameIntEnum: + def get(cls, value: str) -> Self: """Get enum member from name or display_name.""" value = str(value).upper() if value in cls.__members__: @@ -136,3 +136,11 @@ def __getattribute__(self, __name: str) -> Any: if __name in OnChangedDict._MODIFING_FUNCTIONS: self._on_change() return super().__getattribute__(__name) + + +LST = list[_T] | set[_T] | tuple[_T, ...] + + +def short_name(value: str) -> str: + """Return value after last dot.""" + return value.rsplit(".", maxsplit=1)[-1] diff --git a/mypy.ini b/mypy.ini index c563eb12..e7226a19 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,8 +1,6 @@ [mypy] python_version = 3.11 show_error_codes = true -follow_imports = silent -ignore_missing_imports = true strict_equality = true warn_incomplete_stub = true warn_redundant_casts = true @@ -16,4 +14,14 @@ disallow_untyped_decorators = true disallow_untyped_defs = true no_implicit_optional = true warn_return_any = true -warn_unreachable = true \ No newline at end of file +warn_unreachable = true +strict = true + +[mypy-docker.*] +ignore_missing_imports = True + +[mypy-setuptools.*] +ignore_missing_imports = True + +[mypy-testfixtures.*] +ignore_missing_imports = True \ No newline at end of file diff --git a/pylintrc b/pylintrc deleted file mode 100644 index 10e43a1e..00000000 --- a/pylintrc +++ /dev/null @@ -1,83 +0,0 @@ -[MASTER] -ignore=tests -# Use a conservative default here; 2 should speed up most setups and not hurt -# any too bad. Override on command line as appropriate. -jobs=2 - -# Return non-zero exit code if any of these messages/categories are detected, -# even if score is above --fail-under value. Syntax same as enable. Messages -# specified are enabled, while categories only check already-enabled messages. -fail-on= - useless-suppression, - -# Specify a score threshold to be exceeded before program exits with error. -fail-under=10.0 - -# List of plugins (as comma separated values of python module names) to load, -# usually to register additional checkers. -# load-plugins=pylint_strict_informational - -# Pickle collected data for later comparisons. -persistent=no - -# A comma-separated list of package or module names from where C extensions may -# be loaded. Extensions are loading into the active Python interpreter and may -# run arbitrary code. (This is an alternative name to extension-pkg-allow-list -# for backward compatibility.) -extension-pkg-whitelist=ciso8601, - cv2 - - -[BASIC] -good-names=i,j,k,ex,_,T,x,y,id,tg - -[MESSAGES CONTROL] -# Reasons disabled: -# format - handled by black -# duplicate-code - unavoidable -# cyclic-import - doesn't test if both import on load -# too-many-* - are not enforced for the sake of readability -# abstract-method - with intro of async there are always methods missing -# inconsistent-return-statements - doesn't handle raise -# wrong-import-order - isort guards this -disable= - format, - abstract-method, - cyclic-import, - duplicate-code, - inconsistent-return-statements, - too-many-instance-attributes, - wrong-import-order, - too-few-public-methods - -# enable useless-suppression temporarily every now and then to clean them up -enable= - useless-suppression, - use-symbolic-message-instead, - -[REPORTS] -score=no - -[REFACTORING] - -# Maximum number of nested blocks for function / method body -max-nested-blocks=5 - -# Complete name of functions that never returns. When checking for -# inconsistent-return-statements if a never returning function is called then -# it will be considered as an explicit return statement and no message will be -# printed. -never-returning-functions=sys.exit,argparse.parse_error - -[FORMAT] -expected-line-ending-format=LF - -[EXCEPTIONS] - -# Exceptions that will emit a warning when being caught. Defaults to -# "BaseException, Exception". -overgeneral-exceptions=BaseException, - Exception - -[DESIGN] -max-parents=8 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 70d1ec29..3fe77707 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,188 @@ +[build-system] +requires = ["setuptools>=60", + "setuptools-scm>=8.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "deebot-client" +license = {text = "GPL-3.0"} +description = "Deebot client library in python 3" +readme = "README.md" +authors = [ + {name = "Robert Resch", email = "robert@resch.dev"} +] +keywords = ["home", "automation", "homeassistant", "vacuum", "robot", "deebot", "ecovacs"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Programming Language :: Python :: 3.11", + "Topic :: Home Automation", + "Topic :: Software Development :: Libraries :: Python Modules", +] +requires-python = ">=3.11.0" +dynamic = ["dependencies", "version"] + +[project.urls] +"Homepage" = "https://deebot.readthedocs.io/" +"Source Code" = "https://github.com/DeebotUniverse/client.py" +"Bug Reports" = "https://github.com/DeebotUniverse/client.py/issues" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.dynamic] +dependencies = {file = ["requirements.txt"]} + +[tool.setuptools.packages.find] +include = ["deebot_client*"] + +[tool.setuptools_scm] [tool.black] target-version = ['py311'] -safe = true \ No newline at end of file +safe = true + +[tool.ruff.flake8-pytest-style] +fixture-parentheses = false + + +[tool.ruff.isort] +combine-as-imports = true +force-sort-within-sections = true +known-first-party = [ + "deebot_client", +] + + + +[tool.ruff] +select = [ + "ASYNC", # https://docs.astral.sh/ruff/rules/#flake8-async + "B", # https://docs.astral.sh/ruff/rules/#flake8-bugbear + "C", # complexity + "COM818", # Trailing comma on bare tuple prohibited + "C4", # https://docs.astral.sh/ruff/rules/#flake8-comprehensions-c4 + "D", # docstrings + "DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow() + "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts) + "E", # pycodestyle + "F", # pyflakes/autoflake + "G", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g + "I", # isort + "ICN001", # import concentions; {name} should be imported as {asname} + "ISC", # https://docs.astral.sh/ruff/rules/#flake8-implicit-str-concat-isc + "N", # pep8-naming + "PIE", # https://docs.astral.sh/ruff/rules/#flake8-pie-pie + "PGH001", # No builtin eval() allowed + "PGH004", # Use specific rule codes when using noqa + "PLC0414", # Useless import alias. Import alias does not rename original package. + "PLC", # pylint + "PLE", # pylint + "PLR", # pylint + "PLW", # pylint + "PT", # https://docs.astral.sh/ruff/rules/#flake8-pytest-style-pt + "PYI", # https://docs.astral.sh/ruff/rules/#flake8-pyi-pyi + "Q", # https://docs.astral.sh/ruff/rules/#flake8-quotes-q + "RET", # https://docs.astral.sh/ruff/rules/#flake8-return-ret + "RSE", # https://docs.astral.sh/ruff/rules/#flake8-raise-rse + "RUF006", # Store a reference to the return value of asyncio.create_task + "S", # flake8-bandit + "SIM", # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim + "SLF", # https://docs.astral.sh/ruff/rules/#flake8-self-slf + "SLOT", # https://docs.astral.sh/ruff/rules/#flake8-slots-slot + "T100", # Trace found: {name} used + "T20", # https://docs.astral.sh/ruff/rules/#flake8-print-t20 + "TID", # https://docs.astral.sh/ruff/rules/#flake8-tidy-imports-tid + "TRY004", # Prefer TypeError exception for invalid type + "TRY200", # Use raise from to specify exception cause + "TRY302", # Remove exception handler; error is immediately re-raised + "UP", # pyupgrade + "W", # pycodestyle +] +ignore = [ + "D105", # Missing docstring in magic method + "D107", # Missing docstring in `__init__` + "D202", # No blank lines allowed after function docstring + "D203", # 1 blank line required before class docstring + "D213", # Multi-line docstring summary should start at the second line + "E501", # line too long + + "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable + # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 + "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` +] + +[tool.ruff.per-file-ignores] +"tests/**" = [ + "D100", # Missing docstring in public module + "D103", # Missing docstring in public function + "D104", # Missing docstring in public package + "N802", # Function name {name} should be lowercase + "N816", # Variable {name} in global scope should not be mixedCase + "S101", # Use of assert detected + "SLF001", # Private member accessed: {access} + "T201", # print found +] + +[tool.ruff.mccabe] +max-complexity = 12 + +[tool.ruff.pylint] +max-args = 7 + + +[tool.pylint.MAIN] +py-version = "3.11" +ignore = [ + "tests", +] +fail-on = [ + "I", +] + +[tool.pylint.BASIC] +good-names= ["i","j","k","ex","_","T","x","y","id","tg"] + +[tool.pylint."MESSAGES CONTROL"] +# Reasons disabled: +# format - handled by black +# duplicate-code - unavoidable +# cyclic-import - doesn't test if both import on load +# abstract-class-little-used - prevents from setting right foundation +# too-many-* - are not enforced for the sake of readability +# too-few-* - same as too-many-* +# abstract-method - with intro of async there are always methods missing +# inconsistent-return-statements - doesn't handle raise +# too-many-ancestors - it's too strict. +# wrong-import-order - isort guards this +# --- +# Pylint CodeStyle plugin +# consider-using-namedtuple-or-dataclass - too opinionated +# consider-using-assignment-expr - decision to use := better left to devs +disable = [ + "format", + "cyclic-import", + "duplicate-code", + "too-many-arguments", + "too-many-instance-attributes", + "too-many-locals", + "too-many-ancestors", + "too-few-public-methods", +] +enable = [ + "useless-suppression", + "use-symbolic-message-instead", +] + +[tool.pylint.REPORTS] +score = false + + +[tool.pylint.FORMAT] +expected-line-ending-format = "LF" + +[tool.pylint.EXCEPTIONS] +overgeneral-exceptions = [ + "builtins.BaseException", + "builtins.Exception", +] \ No newline at end of file diff --git a/requirements-test.txt b/requirements-test.txt index 43cd5087..eb108bb5 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,12 +1,13 @@ -mypy==1.5.1 -pre-commit==3.3.3 -pylint==2.17.5 -pytest==7.4.0 +mypy==1.6.1 +pre-commit==3.5.0 +pylint==3.0.2 +pytest==7.4.3 pytest-asyncio==0.21.1 pytest-cov==4.1.0 pytest-docker-fixtures==1.3.17 -pytest-timeout==2.1.0 +pytest-timeout==2.2.0 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability -testfixtures==7.1.0 -types-cachetools==5.3.0.6 -types-mock==5.1.0.2 +testfixtures==7.2.2 +types-cachetools +types-mock +types-Pillow diff --git a/requirements.txt b/requirements.txt index 6b37cc1d..25bd0299 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ aiohttp>=3.8.5,<3.10 aiomqtt>=1.0.0,<2.0 cachetools>=5.0.0,<6.0 +defusedxml numpy>=1.23.2,<2.0 -Pillow>=10.0.0,<11.0 +Pillow>=10.0.1,<11.0 diff --git a/scripts/check_getLogger.sh b/scripts/check_getLogger.sh index a53b3247..5ab61f1e 100755 --- a/scripts/check_getLogger.sh +++ b/scripts/check_getLogger.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Dummy script to check if getLogger is somewhere called execpt in logging_filter.py +# Dummy script to check if getLogger is somewhere called except in logging_filter.py # LIST='list\|of\|words\|split\|by\|slash\|and\|pipe' LIST="getLogger\|logging" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 7568c64f..00000000 --- a/setup.cfg +++ /dev/null @@ -1,28 +0,0 @@ -[flake8] -# To work with Black -max-line-length = 88 -# E501: line too long -# W503: Line break occurred before a binary operator -# E203: Whitespace before ':' -# D202 No blank lines allowed after function docstring -# D105 Missing docstring in magic method -# D107 Missing docstring in __init__ -ignore = - E501, - W503, - E203, - D202, - D105, - D107 - -# Disable unused imports for __init__.py -per-file-ignores = - */__init__.py: F401 - tests/*: D100, D103, D104 - tests/__init__.py: D104, D103 - tests/*/__init__.py: D104, D103 - -[isort] -# https://github.com/timothycrosley/isort -# https://github.com/timothycrosley/isort/wiki/isort-Settings -profile = black \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 0598ea39..00000000 --- a/setup.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Setup file.""" -from setuptools import find_packages, setup - -with open("README.md", encoding="utf-8") as file: - long_description = file.read() - -with open("requirements.txt", encoding="utf-8") as file: - install_requires = list(val.strip() for val in file) - -setup( - name="deebot-client", - version="0.0.0", - url="https://github.com/DeebotUniverse/client.py", - description="a library for controlling certain deebot vacuums", - long_description=long_description, - long_description_content_type="text/markdown", - author="DeebotUniverse", - author_email="deebotuniverse@knatschig-as-hell.info", - license="GPL-3.0", - # See https://pypi.python.org/pypi?%3Aaction=list_classifiers - classifiers=[ - # How mature is this project? Common values are - # 3 - Alpha - # 4 - Beta - # 5 - Production/Stable - "Development Status :: 4 - Beta", - # Indicate who your project is intended for - "Intended Audience :: Developers", - "Topic :: Software Development :: Libraries :: Python Modules", - "Topic :: Home Automation", - "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", - "Programming Language :: Python :: 3.11", - ], - keywords="home automation vacuum robot deebot ecovacs", - packages=find_packages(exclude=["contrib", "docs", "tests"]), - package_data={"deebot_client": ["py.typed"]}, - install_requires=install_requires, -) diff --git a/tests/commands/__init__.py b/tests/commands/__init__.py index 9c2e6f64..e69de29b 100644 --- a/tests/commands/__init__.py +++ b/tests/commands/__init__.py @@ -1,86 +0,0 @@ -from collections.abc import Sequence -from typing import Any -from unittest.mock import AsyncMock, Mock, call - -from deebot_client.authentication import Authenticator -from deebot_client.command import Command -from deebot_client.commands import SetCommand -from deebot_client.events import Event -from deebot_client.events.event_bus import EventBus -from deebot_client.models import Credentials, DeviceInfo - -from ..helpers import get_message_json, get_request_json - - -async def assert_command( - command: Command, - json_api_response: dict[str, Any], - expected_events: Event | None | Sequence[Event], -) -> None: - event_bus = Mock(spec_set=EventBus) - authenticator = Mock(spec_set=Authenticator) - authenticator.authenticate = AsyncMock( - return_value=Credentials("token", "user_id", 9999) - ) - authenticator.post_authenticated = AsyncMock(return_value=json_api_response) - device_info = DeviceInfo( - { - "company": "company", - "did": "did", - "name": "name", - "nick": "nick", - "resource": "resource", - "deviceName": "device_name", - "status": 1, - "class": "get_class", - } - ) - - await command.execute(authenticator, device_info, event_bus) - - # verify - authenticator.post_authenticated.assert_called() - if expected_events: - if isinstance(expected_events, Sequence): - event_bus.notify.assert_has_calls([call(x) for x in expected_events]) - assert event_bus.notify.call_count == len(expected_events) - else: - event_bus.notify.assert_called_once_with(expected_events) - else: - event_bus.notify.assert_not_called() - - -async def assert_set_command( - command: SetCommand, - args: dict | list | None, - expected_get_command_event: Event, -) -> None: - assert command.name != "invalid" - assert command._args == args - - json = get_request_json({"code": 0, "msg": "ok"}) - await assert_command(command, json, None) - - event_bus = Mock(spec_set=EventBus) - - # Failed to set - json = { - "header": { - "pri": 1, - "tzm": 480, - "ts": "1304623069888", - "ver": "0.0.1", - "fwVer": "1.8.2", - "hwVer": "0.1.1", - }, - "body": { - "code": 500, - "msg": "fail", - }, - } - command.handle_mqtt_p2p(event_bus, json) - event_bus.notify.assert_not_called() - - # Success - command.handle_mqtt_p2p(event_bus, get_message_json(None)) - event_bus.notify.assert_called_once_with(expected_get_command_event) diff --git a/tests/commands/json/__init__.py b/tests/commands/json/__init__.py new file mode 100644 index 00000000..829eb4b7 --- /dev/null +++ b/tests/commands/json/__init__.py @@ -0,0 +1,118 @@ +from collections.abc import Sequence +from typing import Any +from unittest.mock import AsyncMock, Mock, call + +from testfixtures import LogCapture + +from deebot_client.authentication import Authenticator +from deebot_client.command import Command +from deebot_client.commands.json.common import ( + ExecuteCommand, + SetCommand, + SetEnableCommand, +) +from deebot_client.event_bus import EventBus +from deebot_client.events import EnableEvent, Event +from deebot_client.hardware.deebot import FALLBACK, get_static_device_info +from deebot_client.models import Credentials, DeviceInfo +from tests.helpers import get_message_json, get_request_json, get_success_body + + +async def assert_command( + command: Command, + json_api_response: dict[str, Any], + expected_events: Event | None | Sequence[Event], +) -> None: + event_bus = Mock(spec_set=EventBus) + authenticator = Mock(spec_set=Authenticator) + authenticator.authenticate = AsyncMock( + return_value=Credentials("token", "user_id", 9999) + ) + authenticator.post_authenticated = AsyncMock(return_value=json_api_response) + device_info = DeviceInfo( + { + "company": "company", + "did": "did", + "name": "name", + "nick": "nick", + "resource": "resource", + "deviceName": "device_name", + "status": 1, + "class": "get_class", + }, + get_static_device_info(FALLBACK), + ) + + await command.execute(authenticator, device_info, event_bus) + + # verify + authenticator.post_authenticated.assert_called() + if expected_events: + if isinstance(expected_events, Sequence): + event_bus.notify.assert_has_calls([call(x) for x in expected_events]) + assert event_bus.notify.call_count == len(expected_events) + else: + event_bus.notify.assert_called_once_with(expected_events) + else: + event_bus.notify.assert_not_called() + + +async def assert_execute_command( + command: ExecuteCommand, args: dict[str, Any] | list[Any] | None +) -> None: + assert command.name != "invalid" + assert command._args == args + + # success + json = get_request_json(get_success_body()) + await assert_command(command, json, None) + + # failed + with LogCapture() as log: + body = {"code": 500, "msg": "fail"} + json = get_request_json(body) + await assert_command(command, json, None) + + log.check_present( + ( + "deebot_client.commands.json.common", + "WARNING", + f'Command "{command.name}" was not successfully. body={body}', + ) + ) + + +async def assert_set_command( + command: SetCommand, + args: dict[str, Any], + expected_get_command_event: Event, +) -> None: + await assert_execute_command(command, args) + + event_bus = Mock(spec_set=EventBus) + + # Failed to set + json = get_message_json( + { + "code": 500, + "msg": "fail", + } + ) + command.handle_mqtt_p2p(event_bus, json) + event_bus.notify.assert_not_called() + + # Success + command.handle_mqtt_p2p(event_bus, get_message_json(get_success_body())) + event_bus.notify.assert_called_once_with(expected_get_command_event) + + mqtt_command = command.create_from_mqtt(args) + assert mqtt_command == command + + +async def assert_set_enable_command( + command: SetEnableCommand, + enabled: bool, + expected_get_command_event: type[EnableEvent], +) -> None: + args = {"enable": 1 if enabled else 0} + await assert_set_command(command, args, expected_get_command_event(enabled)) diff --git a/tests/commands/json/test_advanced_mode.py b/tests/commands/json/test_advanced_mode.py new file mode 100644 index 00000000..454ee96e --- /dev/null +++ b/tests/commands/json/test_advanced_mode.py @@ -0,0 +1,18 @@ +import pytest + +from deebot_client.commands.json import GetAdvancedMode, SetAdvancedMode +from deebot_client.events import AdvancedModeEvent +from tests.helpers import get_request_json, get_success_body + +from . import assert_command, assert_set_enable_command + + +@pytest.mark.parametrize("value", [False, True]) +async def test_GetAdvancedMode(value: bool) -> None: + json = get_request_json(get_success_body({"enable": 1 if value else 0})) + await assert_command(GetAdvancedMode(), json, AdvancedModeEvent(value)) + + +@pytest.mark.parametrize("value", [False, True]) +async def test_SetAdvancedMode(value: bool) -> None: + await assert_set_enable_command(SetAdvancedMode(value), value, AdvancedModeEvent) diff --git a/tests/commands/json/test_battery.py b/tests/commands/json/test_battery.py new file mode 100644 index 00000000..60f68328 --- /dev/null +++ b/tests/commands/json/test_battery.py @@ -0,0 +1,15 @@ +import pytest + +from deebot_client.commands.json import GetBattery +from deebot_client.events import BatteryEvent +from tests.helpers import get_request_json, get_success_body + +from . import assert_command + + +@pytest.mark.parametrize("percentage", [0, 49, 100]) +async def test_GetBattery(percentage: int) -> None: + json = get_request_json( + get_success_body({"value": percentage, "isLow": 1 if percentage < 20 else 0}) + ) + await assert_command(GetBattery(), json, BatteryEvent(percentage)) diff --git a/tests/commands/json/test_carpet.py b/tests/commands/json/test_carpet.py new file mode 100644 index 00000000..0dea13e0 --- /dev/null +++ b/tests/commands/json/test_carpet.py @@ -0,0 +1,20 @@ +import pytest + +from deebot_client.commands.json import GetCarpetAutoFanBoost, SetCarpetAutoFanBoost +from deebot_client.events import CarpetAutoFanBoostEvent +from tests.helpers import get_request_json, get_success_body + +from . import assert_command, assert_set_enable_command + + +@pytest.mark.parametrize("value", [False, True]) +async def test_GetCarpetAutoFanBoost(value: bool) -> None: + json = get_request_json(get_success_body({"enable": 1 if value else 0})) + await assert_command(GetCarpetAutoFanBoost(), json, CarpetAutoFanBoostEvent(value)) + + +@pytest.mark.parametrize("value", [False, True]) +async def test_SetCarpetAutoFanBoost(value: bool) -> None: + await assert_set_enable_command( + SetCarpetAutoFanBoost(value), value, CarpetAutoFanBoostEvent + ) diff --git a/tests/commands/json/test_charge.py b/tests/commands/json/test_charge.py new file mode 100644 index 00000000..1151e5f5 --- /dev/null +++ b/tests/commands/json/test_charge.py @@ -0,0 +1,44 @@ +import logging +from typing import Any + +import pytest + +from deebot_client.commands.json import Charge +from deebot_client.events import StateEvent +from deebot_client.models import State +from tests.helpers import get_request_json, get_success_body + +from . import assert_command + + +def _prepare_json(code: int, msg: str = "ok") -> dict[str, Any]: + json = get_request_json(get_success_body()) + json["resp"]["body"].update( + { + "code": code, + "msg": msg, + } + ) + return json + + +@pytest.mark.parametrize( + ("json", "expected"), + [ + (get_request_json(get_success_body()), StateEvent(State.RETURNING)), + (_prepare_json(30007), StateEvent(State.DOCKED)), + ], +) +async def test_Charge(json: dict[str, Any], expected: StateEvent) -> None: + await assert_command(Charge(), json, expected) + + +async def test_Charge_failed(caplog: pytest.LogCaptureFixture) -> None: + json = _prepare_json(500, "fail") + await assert_command(Charge(), json, None) + + assert ( + "deebot_client.commands.json.common", + logging.WARNING, + f"Command \"charge\" was not successfully. body={json['resp']['body']}", + ) in caplog.record_tuples diff --git a/tests/commands/test_charge_state.py b/tests/commands/json/test_charge_state.py similarity index 52% rename from tests/commands/test_charge_state.py rename to tests/commands/json/test_charge_state.py index 179a6858..814016ba 100644 --- a/tests/commands/test_charge_state.py +++ b/tests/commands/json/test_charge_state.py @@ -2,16 +2,17 @@ import pytest -from deebot_client.commands import GetChargeState +from deebot_client.commands.json import GetChargeState from deebot_client.events import StateEvent -from tests.commands import assert_command -from tests.helpers import get_request_json +from tests.helpers import get_request_json, get_success_body + +from . import assert_command @pytest.mark.parametrize( - "json, expected", + ("json", "expected"), [ - (get_request_json({"isCharging": 0, "mode": "slot"}), None), + (get_request_json(get_success_body({"isCharging": 0, "mode": "slot"})), None), ], ) async def test_GetChargeState( diff --git a/tests/commands/test_clean.py b/tests/commands/json/test_clean.py similarity index 52% rename from tests/commands/test_clean.py rename to tests/commands/json/test_clean.py index e4d6f13e..c39e25db 100644 --- a/tests/commands/test_clean.py +++ b/tests/commands/json/test_clean.py @@ -4,21 +4,22 @@ import pytest from deebot_client.authentication import Authenticator -from deebot_client.commands import GetCleanInfo -from deebot_client.commands.clean import Clean, CleanAction +from deebot_client.commands.json import GetCleanInfo +from deebot_client.commands.json.clean import Clean, CleanAction +from deebot_client.event_bus import EventBus from deebot_client.events import StateEvent -from deebot_client.events.event_bus import EventBus -from deebot_client.models import DeviceInfo, VacuumState -from tests.commands import assert_command -from tests.helpers import get_request_json +from deebot_client.models import DeviceInfo, State +from tests.helpers import get_request_json, get_success_body + +from . import assert_command @pytest.mark.parametrize( - "json, expected", + ("json", "expected"), [ ( - get_request_json({"trigger": "none", "state": "idle"}), - StateEvent(VacuumState.IDLE), + get_request_json(get_success_body({"trigger": "none", "state": "idle"})), + StateEvent(State.IDLE), ), ], ) @@ -27,26 +28,26 @@ async def test_GetCleanInfo(json: dict[str, Any], expected: StateEvent) -> None: @pytest.mark.parametrize( - "action, vacuum_state, expected", + ("action", "state", "expected"), [ (CleanAction.START, None, CleanAction.START), - (CleanAction.START, VacuumState.PAUSED, CleanAction.RESUME), - (CleanAction.START, VacuumState.DOCKED, CleanAction.START), + (CleanAction.START, State.PAUSED, CleanAction.RESUME), + (CleanAction.START, State.DOCKED, CleanAction.START), (CleanAction.RESUME, None, CleanAction.RESUME), - (CleanAction.RESUME, VacuumState.PAUSED, CleanAction.RESUME), - (CleanAction.RESUME, VacuumState.DOCKED, CleanAction.START), + (CleanAction.RESUME, State.PAUSED, CleanAction.RESUME), + (CleanAction.RESUME, State.DOCKED, CleanAction.START), ], ) async def test_Clean_act( authenticator: Authenticator, device_info: DeviceInfo, action: CleanAction, - vacuum_state: VacuumState | None, + state: State | None, expected: CleanAction, ) -> None: event_bus = Mock(spec_set=EventBus) event_bus.get_last_event.return_value = ( - StateEvent(vacuum_state) if vacuum_state is not None else None + StateEvent(state) if state is not None else None ) command = Clean(action) diff --git a/tests/commands/test_clean_count.py b/tests/commands/json/test_clean_count.py similarity index 61% rename from tests/commands/test_clean_count.py rename to tests/commands/json/test_clean_count.py index 1ad076e8..b835d02c 100644 --- a/tests/commands/test_clean_count.py +++ b/tests/commands/json/test_clean_count.py @@ -1,13 +1,14 @@ import pytest -from deebot_client.commands import GetCleanCount, SetCleanCount +from deebot_client.commands.json import GetCleanCount, SetCleanCount from deebot_client.events import CleanCountEvent -from tests.commands import assert_command, assert_set_command -from tests.helpers import get_request_json +from tests.helpers import get_request_json, get_success_body + +from . import assert_command, assert_set_command async def test_GetCleanCount() -> None: - json = get_request_json({"count": 2}) + json = get_request_json(get_success_body({"count": 2})) await assert_command(GetCleanCount(), json, CleanCountEvent(2)) diff --git a/tests/commands/test_clean_log.py b/tests/commands/json/test_clean_log.py similarity index 73% rename from tests/commands/test_clean_log.py rename to tests/commands/json/test_clean_log.py index cf910d24..c5cd3439 100644 --- a/tests/commands/test_clean_log.py +++ b/tests/commands/json/test_clean_log.py @@ -1,14 +1,15 @@ +import logging from typing import Any import pytest -from testfixtures import LogCapture -from deebot_client.commands import GetCleanLogs +from deebot_client.commands.json import GetCleanLogs from deebot_client.events import CleanJobStatus, CleanLogEntry, CleanLogEvent -from tests.commands import assert_command +from . import assert_command -async def test_GetCleanLogs() -> None: + +async def test_GetCleanLogs(caplog: pytest.LogCaptureFixture) -> None: json = { "ret": "ok", "logs": [ @@ -104,49 +105,44 @@ async def test_GetCleanLogs() -> None: ] ) - with LogCapture() as log: - await assert_command(GetCleanLogs(), json, expected) + await assert_command(GetCleanLogs(), json, expected) - log.check_present( - ( - "deebot_client.commands.clean_logs", - "WARNING", - "Skipping log entry: {'ts': 1655564616, 'invalid': 'event'}", - ) - ) + assert ( + "deebot_client.commands.json.clean_logs", + logging.WARNING, + "Skipping log entry: {'ts': 1655564616, 'invalid': 'event'}", + ) in caplog.record_tuples @pytest.mark.parametrize( "json", [{"ret": "ok"}, {"ret": "fail"}], ) -async def test_GetCleanLogs_analyse_logged(json: dict[str, Any]) -> None: - with LogCapture() as log: - await assert_command( - GetCleanLogs(), - json, - None, - ) - log.check_present( - ( - "deebot_client.command", - "DEBUG", - f"ANALYSE: Could not handle command: GetCleanLogs with {json}", - ) - ) +async def test_GetCleanLogs_analyse_logged( + json: dict[str, Any], caplog: pytest.LogCaptureFixture +) -> None: + await assert_command( + GetCleanLogs(), + json, + None, + ) + + assert ( + "deebot_client.command", + logging.DEBUG, + f"ANALYSE: Could not handle command: GetCleanLogs with {json}", + ) in caplog.record_tuples -async def test_GetCleanLogs_handle_fails() -> None: - with LogCapture() as log: - await assert_command( - GetCleanLogs(), - {}, - None, - ) - log.check_present( - ( - "deebot_client.command", - "WARNING", - f"Could not parse response for {GetCleanLogs.name}: {{}}", - ) - ) +async def test_GetCleanLogs_handle_fails(caplog: pytest.LogCaptureFixture) -> None: + await assert_command( + GetCleanLogs(), + {}, + None, + ) + + assert ( + "deebot_client.command", + logging.WARNING, + f"Could not parse response for {GetCleanLogs.name}: {{}}", + ) in caplog.record_tuples diff --git a/tests/commands/json/test_clean_preference.py b/tests/commands/json/test_clean_preference.py new file mode 100644 index 00000000..9ba26b8e --- /dev/null +++ b/tests/commands/json/test_clean_preference.py @@ -0,0 +1,20 @@ +import pytest + +from deebot_client.commands.json import GetCleanPreference, SetCleanPreference +from deebot_client.events import CleanPreferenceEvent +from tests.helpers import get_request_json, get_success_body + +from . import assert_command, assert_set_enable_command + + +@pytest.mark.parametrize("value", [False, True]) +async def test_GetCleanPreference(value: bool) -> None: + json = get_request_json(get_success_body({"enable": 1 if value else 0})) + await assert_command(GetCleanPreference(), json, CleanPreferenceEvent(value)) + + +@pytest.mark.parametrize("value", [False, True]) +async def test_SetCleanPreference(value: bool) -> None: + await assert_set_enable_command( + SetCleanPreference(value), value, CleanPreferenceEvent + ) diff --git a/tests/commands/test_common.py b/tests/commands/json/test_common.py similarity index 58% rename from tests/commands/test_common.py rename to tests/commands/json/test_common.py index 4fc734d1..c22ebe0b 100644 --- a/tests/commands/test_common.py +++ b/tests/commands/json/test_common.py @@ -1,15 +1,15 @@ from collections.abc import Callable +import logging from typing import Any from unittest.mock import Mock import pytest -from testfixtures import LogCapture -from deebot_client.commands import GetBattery -from deebot_client.commands.common import CommandWithMessageHandling -from deebot_client.commands.map import GetCachedMapInfo +from deebot_client.commands.json import GetBattery +from deebot_client.commands.json.common import CommandWithMessageHandling +from deebot_client.commands.json.map import GetCachedMapInfo +from deebot_client.event_bus import EventBus from deebot_client.events import AvailabilityEvent -from deebot_client.events.event_bus import EventBus from deebot_client.models import DeviceInfo _ERROR_500 = {"ret": "fail", "errno": 500, "debug": "wait for response timed out"} @@ -32,20 +32,20 @@ def _assert_false_and_avalable_event_false(available: bool, event_bus: Mock) -> @pytest.mark.parametrize( - "repsonse_json, expected_log, assert_func", + ("repsonse_json", "expected_log", "assert_func"), [ ( _ERROR_500, ( - "WARNING", - 'No response received for command "{}". This can happen if the vacuum has network issues or does not support the command', + logging.WARNING, + 'No response received for command "{}". This can happen if the device has network issues or does not support the command', ), _assert_false_and_not_called, ), ( {"ret": "fail", "errno": 123, "debug": "other error"}, ( - "WARNING", + logging.WARNING, 'Command "{}" was not successfully.', ), _assert_false_and_not_called, @@ -53,8 +53,8 @@ def _assert_false_and_avalable_event_false(available: bool, event_bus: Mock) -> ( _ERROR_4200, ( - "INFO", - 'Vacuum is offline. Could not execute command "{}"', + logging.INFO, + 'Device is offline. Could not execute command "{}"', ), _assert_false_and_avalable_event_false, ), @@ -68,30 +68,27 @@ async def test_common_functionality( device_info: DeviceInfo, command: CommandWithMessageHandling, repsonse_json: dict[str, Any], - expected_log: tuple[str, str], + expected_log: tuple[int, str], assert_func: Callable[[bool, Mock], None], + caplog: pytest.LogCaptureFixture, ) -> None: authenticator.post_authenticated.return_value = repsonse_json event_bus = Mock(spec_set=EventBus) - with LogCapture() as log: - available = await command.execute(authenticator, device_info, event_bus) + available = await command.execute(authenticator, device_info, event_bus) - if repsonse_json.get("errno") == 500 and command._is_available_check: - log.check_present( - ( - "deebot_client.commands.common", - "INFO", - f'No response received for command "{command.name}" during availability-check.', - ) - ) - elif expected_log: - log.check_present( - ( - "deebot_client.commands.common", - expected_log[0], - expected_log[1].format(command.name), - ) - ) + if repsonse_json.get("errno") == 500 and command._is_available_check: + assert ( + "deebot_client.commands.json.common", + logging.INFO, + f'No response received for command "{command.name}" during availability-check.', + ) in caplog.record_tuples - assert_func(available, event_bus) + elif expected_log: + assert ( + "deebot_client.commands.json.common", + expected_log[0], + expected_log[1].format(command.name), + ) in caplog.record_tuples + + assert_func(available, event_bus) diff --git a/tests/commands/json/test_continuous_cleaning.py b/tests/commands/json/test_continuous_cleaning.py new file mode 100644 index 00000000..9f8e205a --- /dev/null +++ b/tests/commands/json/test_continuous_cleaning.py @@ -0,0 +1,20 @@ +import pytest + +from deebot_client.commands.json import GetContinuousCleaning, SetContinuousCleaning +from deebot_client.events import ContinuousCleaningEvent +from tests.helpers import get_request_json, get_success_body + +from . import assert_command, assert_set_enable_command + + +@pytest.mark.parametrize("value", [False, True]) +async def test_GetContinuousCleaning(value: bool) -> None: + json = get_request_json(get_success_body({"enable": 1 if value else 0})) + await assert_command(GetContinuousCleaning(), json, ContinuousCleaningEvent(value)) + + +@pytest.mark.parametrize("value", [False, True]) +async def test_SetContinuousCleaning(value: bool) -> None: + await assert_set_enable_command( + SetContinuousCleaning(value), value, ContinuousCleaningEvent + ) diff --git a/tests/commands/test_custom.py b/tests/commands/json/test_custom.py similarity index 51% rename from tests/commands/test_custom.py rename to tests/commands/json/test_custom.py index 9a32f8d9..78db9a59 100644 --- a/tests/commands/test_custom.py +++ b/tests/commands/json/test_custom.py @@ -2,19 +2,22 @@ import pytest -from deebot_client.commands.custom import CustomCommand +from deebot_client.commands.json.custom import CustomCommand from deebot_client.events import CustomCommandEvent -from tests.commands import assert_command -from tests.helpers import get_message_json, get_request_json +from tests.helpers import get_message_json, get_request_json, get_success_body + +from . import assert_command @pytest.mark.parametrize( - "command, json, expected", + ("command", "json", "expected"), [ ( CustomCommand("getSleep"), - get_request_json({"enable": 1}), - CustomCommandEvent("getSleep", get_message_json({"enable": 1})), + get_request_json(get_success_body({"enable": 1})), + CustomCommandEvent( + "getSleep", get_message_json(get_success_body({"enable": 1})) + ), ), (CustomCommand("getSleep"), {}, None), ], diff --git a/tests/commands/json/test_fan_speed.py b/tests/commands/json/test_fan_speed.py new file mode 100644 index 00000000..9ab2a907 --- /dev/null +++ b/tests/commands/json/test_fan_speed.py @@ -0,0 +1,35 @@ +import pytest + +from deebot_client.commands.json import GetFanSpeed, SetFanSpeed +from deebot_client.events import FanSpeedEvent +from deebot_client.events.fan_speed import FanSpeedLevel +from tests.helpers import ( + get_request_json, + get_success_body, + verify_DisplayNameEnum_unique, +) + +from . import assert_command, assert_set_command + + +def test_FanSpeedLevel_unique() -> None: + verify_DisplayNameEnum_unique(FanSpeedLevel) + + +async def test_GetFanSpeed() -> None: + json = get_request_json(get_success_body({"speed": 2})) + await assert_command(GetFanSpeed(), json, FanSpeedEvent(FanSpeedLevel.MAX_PLUS)) + + +@pytest.mark.parametrize(("value"), [FanSpeedLevel.MAX, "max"]) +async def test_SetFanSpeed(value: FanSpeedLevel | str) -> None: + command = SetFanSpeed(value) + args = {"speed": 1} + await assert_set_command(command, args, FanSpeedEvent(FanSpeedLevel.MAX)) + + +def test_SetFanSpeed_inexisting_value() -> None: + with pytest.raises( + ValueError, match="'INEXSTING' is not a valid FanSpeedLevel member" + ): + SetFanSpeed("inexsting") diff --git a/tests/commands/json/test_life_span.py b/tests/commands/json/test_life_span.py new file mode 100644 index 00000000..eb751f64 --- /dev/null +++ b/tests/commands/json/test_life_span.py @@ -0,0 +1,66 @@ +from typing import Any + +import pytest + +from deebot_client.commands.json import GetLifeSpan +from deebot_client.commands.json.life_span import ResetLifeSpan +from deebot_client.events import LifeSpan, LifeSpanEvent +from tests.helpers import get_request_json, get_success_body + +from . import assert_command, assert_execute_command + + +@pytest.mark.parametrize( + ("command", "json", "expected"), + [ + ( + GetLifeSpan({LifeSpan.BRUSH, LifeSpan.FILTER, LifeSpan.SIDE_BRUSH}), + get_request_json( + get_success_body( + [ + {"type": "sideBrush", "left": 8977, "total": 9000}, + {"type": "brush", "left": 17979, "total": 18000}, + {"type": "heap", "left": 7179, "total": 7200}, + ] + ) + ), + [ + LifeSpanEvent(LifeSpan.SIDE_BRUSH, 99.74, 8977), + LifeSpanEvent(LifeSpan.BRUSH, 99.88, 17979), + LifeSpanEvent(LifeSpan.FILTER, 99.71, 7179), + ], + ), + ( + GetLifeSpan([LifeSpan.FILTER]), + get_request_json( + get_success_body([{"type": "heap", "left": 7179, "total": 7200}]) + ), + [LifeSpanEvent(LifeSpan.FILTER, 99.71, 7179)], + ), + ( + GetLifeSpan({LifeSpan.BRUSH}), + get_request_json( + get_success_body([{"type": "brush", "left": 17979, "total": 18000}]) + ), + [LifeSpanEvent(LifeSpan.BRUSH, 99.88, 17979)], + ), + ], +) +async def test_GetLifeSpan( + command: GetLifeSpan, json: dict[str, Any], expected: list[LifeSpanEvent] +) -> None: + await assert_command(command, json, expected) + + +@pytest.mark.parametrize( + ("command", "args"), + [ + (ResetLifeSpan(LifeSpan.FILTER), {"type": LifeSpan.FILTER.value}), + ( + ResetLifeSpan.create_from_mqtt({"type": "brush"}), + {"type": LifeSpan.BRUSH.value}, + ), + ], +) +async def test_ResetLifeSpan(command: ResetLifeSpan, args: dict[str, str]) -> None: + await assert_execute_command(command, args) diff --git a/tests/commands/test_map.py b/tests/commands/json/test_map.py similarity index 60% rename from tests/commands/test_map.py rename to tests/commands/json/test_map.py index 30f39698..539f3260 100644 --- a/tests/commands/test_map.py +++ b/tests/commands/json/test_map.py @@ -1,4 +1,4 @@ -from deebot_client.commands import ( +from deebot_client.commands.json import ( GetCachedMapInfo, GetMajorMap, GetMapSet, @@ -13,8 +13,9 @@ MapTraceEvent, ) from deebot_client.events.map import CachedMapInfoEvent -from tests.commands import assert_command -from tests.helpers import get_request_json +from tests.helpers import get_request_json, get_success_body + +from . import assert_command async def test_getMapSubSet_customName() -> None: @@ -22,24 +23,26 @@ async def test_getMapSubSet_customName() -> None: value = "XQAABAB5AgAAABaOQok5MfkIKbGTBxaUTX13SjXBAI1/Q3A9Kkx2gYZ1QdgwfwOSlU3hbRjNJYgr2Pr3WgFez3Gcoj3R2JmzAuc436F885ZKt5NF2AE1UPAF4qq67tK6TSA64PPfmZQ0lqwInQmqKG5/KO59RyFBbV1NKnDIGNBGVCWpH62WLlMu8N4zotA8dYMQ/UBMwr/gddQO5HU01OQM2YvF" name = "Levin" json = get_request_json( - { - "type": type.value, - "subtype": "15", - "connections": "7,", - "name": name, - "seqIndex": 0, - "seq": 0, - "count": 0, - "totalCount": 50, - "index": 0, - "cleanset": "1,0,2", - "valueSize": 633, - "compress": 1, - "center": "-6775,-9225", - "mssid": "8", - "value": value, - "mid": "98100521", - } + get_success_body( + { + "type": type.value, + "subtype": "15", + "connections": "7,", + "name": name, + "seqIndex": 0, + "seq": 0, + "count": 0, + "totalCount": 50, + "index": 0, + "cleanset": "1,0,2", + "valueSize": 633, + "compress": 1, + "center": "-6775,-9225", + "mssid": "8", + "value": value, + "mid": "98100521", + } + ) ) await assert_command( GetMapSubSet(mid="98100521", mssid="8", msid="1"), @@ -52,14 +55,16 @@ async def test_getMapSubSet_living_room() -> None: type = MapSetType.ROOMS value = "-1400,-1600;-1400,-1350;-950,-1100;-900,-150;-550,100;200,950;500,950;650,800;800,950;1850,950;1950,800;1950,-200;2050,-300;2300,-300;2550,-650;2700,-650;2700,-1600;2400,-1750;2700,-1900;2700,-2950;2450,-2950;2300,-3100;2400,-3200;2650,-3200;2700,-3500;2300,-3500;2200,-3250;2050,-3550;1200,-3550;1200,-3300;1050,-3200;950,-3300;950,-3550;600,-3550;550,-2850;850,-2800;950,-2700;850,-2600;950,-2400;900,-2350;800,-2300;550,-2500;550,-2350;400,-2250;200,-2650;-800,-2650;-950,-2550;-950,-2150;-650,-2000;-450,-2000;-400,-1950;-450,-1850;-750,-1800;-950,-1900;-1350,-1900;-1400,-1600" json = get_request_json( - { - "type": type.value, - "mssid": "7", - "value": value, - "subtype": "1", - "connections": "12", - "mid": "199390082", - } + get_success_body( + { + "type": type.value, + "mssid": "7", + "value": value, + "subtype": "1", + "connections": "12", + "mid": "199390082", + } + ) ) await assert_command( GetMapSubSet(mid="199390082", mssid="7", msid="1"), @@ -72,27 +77,29 @@ async def test_getCachedMapInfo() -> None: expected_mid = "199390082" expected_name = "Erdgeschoss" json = get_request_json( - { - "enable": 1, - "info": [ - { - "mid": expected_mid, - "index": 0, - "status": 1, - "using": 1, - "built": 1, - "name": expected_name, - }, - { - "mid": "722607162", - "index": 3, - "status": 0, - "using": 0, - "built": 0, - "name": "", - }, - ], - } + get_success_body( + { + "enable": 1, + "info": [ + { + "mid": expected_mid, + "index": 0, + "status": 1, + "using": 1, + "built": 1, + "name": expected_name, + }, + { + "mid": "722607162", + "index": 3, + "status": 0, + "using": 0, + "built": 0, + "name": "", + }, + ], + } + ) ) await assert_command( GetCachedMapInfo(), @@ -111,15 +118,17 @@ async def test_getMajorMap() -> None: expected_mid = "199390082" value = "1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,3378526980,2963288214,2739565817,729228561,2452519304,1295764014,1295764014,1295764014,2753376360,329080101,952462272,3648890579,412193448,1540631558,1295764014,1295764014,1561391782,1081327924,1096350476,2860639280,37066625,86907282,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014,1295764014" json = get_request_json( - { - "mid": expected_mid, - "pieceWidth": 100, - "pieceHeight": 100, - "cellWidth": 8, - "cellHeight": 8, - "pixel": 50, - "value": value, - } + get_success_body( + { + "mid": expected_mid, + "pieceWidth": 100, + "pieceHeight": 100, + "cellWidth": 8, + "cellHeight": 8, + "pixel": 50, + "value": value, + } + ) ) await assert_command( GetMajorMap(), json, MajorMapEvent(True, expected_mid, value.split(",")) @@ -130,21 +139,23 @@ async def test_getMapSet() -> None: mid = "199390082" # msid = "8" json = get_request_json( - { - "type": "ar", - "count": 7, - "mid": mid, - "msid": "8", - "subsets": [ - {"mssid": "7"}, - {"mssid": "12"}, - {"mssid": "17"}, - {"mssid": "14"}, - {"mssid": "10"}, - {"mssid": "11"}, - {"mssid": "13"}, - ], - } + get_success_body( + { + "type": "ar", + "count": 7, + "mid": mid, + "msid": "8", + "subsets": [ + {"mssid": "7"}, + {"mssid": "12"}, + {"mssid": "17"}, + {"mssid": "14"}, + {"mssid": "10"}, + {"mssid": "11"}, + {"mssid": "13"}, + ], + } + ) ) subsets = [7, 12, 17, 14, 10, 11, 13] await assert_command( @@ -168,13 +179,15 @@ async def test_getMapTrace() -> None: total = 160 trace_value = "REMOVED" json = get_request_json( - { - "tid": "173207", - "totalCount": total, - "traceStart": start, - "pointCount": 200, - "traceValue": trace_value, - } + get_success_body( + { + "tid": "173207", + "totalCount": total, + "traceStart": start, + "pointCount": 200, + "traceValue": trace_value, + } + ) ) await assert_command( GetMapTrace(start), diff --git a/tests/commands/json/test_mulitmap_state.py b/tests/commands/json/test_mulitmap_state.py new file mode 100644 index 00000000..d6705ec3 --- /dev/null +++ b/tests/commands/json/test_mulitmap_state.py @@ -0,0 +1,18 @@ +import pytest + +from deebot_client.commands.json import GetMultimapState, SetMultimapState +from deebot_client.events import MultimapStateEvent +from tests.helpers import get_request_json, get_success_body + +from . import assert_command, assert_set_enable_command + + +@pytest.mark.parametrize("value", [False, True]) +async def test_GetMultimapState(value: bool) -> None: + json = get_request_json(get_success_body({"enable": 1 if value else 0})) + await assert_command(GetMultimapState(), json, MultimapStateEvent(value)) + + +@pytest.mark.parametrize("value", [False, True]) +async def test_SetMultimapState(value: bool) -> None: + await assert_set_enable_command(SetMultimapState(value), value, MultimapStateEvent) diff --git a/tests/commands/json/test_network.py b/tests/commands/json/test_network.py new file mode 100644 index 00000000..60a4896b --- /dev/null +++ b/tests/commands/json/test_network.py @@ -0,0 +1,28 @@ +from deebot_client.commands.json import GetNetInfo +from deebot_client.events import NetworkInfoEvent +from tests.commands.json import assert_command +from tests.helpers import ( + get_request_json, + get_success_body, +) + + +async def test_GetFanSpeed() -> None: + json = get_request_json( + get_success_body( + { + "ip": "192.168.1.100", + "ssid": "WLAN", + "rssi": "-61", + "wkVer": "0.1.2", + "mac": "AA:BB:CC:DD:EE:FF", + } + ) + ) + await assert_command( + GetNetInfo(), + json, + NetworkInfoEvent( + ip="192.168.1.100", ssid="WLAN", rssi=-61, mac="AA:BB:CC:DD:EE:FF" + ), + ) diff --git a/tests/commands/json/test_true_detect.py b/tests/commands/json/test_true_detect.py new file mode 100644 index 00000000..0a33fb21 --- /dev/null +++ b/tests/commands/json/test_true_detect.py @@ -0,0 +1,18 @@ +import pytest + +from deebot_client.commands.json import GetTrueDetect, SetTrueDetect +from deebot_client.events import TrueDetectEvent +from tests.helpers import get_request_json, get_success_body + +from . import assert_command, assert_set_enable_command + + +@pytest.mark.parametrize("value", [False, True]) +async def test_GetTrueDetect(value: bool) -> None: + json = get_request_json(get_success_body({"enable": 1 if value else 0})) + await assert_command(GetTrueDetect(), json, TrueDetectEvent(value)) + + +@pytest.mark.parametrize("value", [False, True]) +async def test_SetTrueDetect(value: bool) -> None: + await assert_set_enable_command(SetTrueDetect(value), value, TrueDetectEvent) diff --git a/tests/commands/json/test_volume.py b/tests/commands/json/test_volume.py new file mode 100644 index 00000000..419e3a16 --- /dev/null +++ b/tests/commands/json/test_volume.py @@ -0,0 +1,18 @@ +import pytest + +from deebot_client.commands.json import GetVolume, SetVolume +from deebot_client.events import VolumeEvent +from tests.helpers import get_request_json, get_success_body + +from . import assert_command, assert_set_command + + +async def test_GetVolume() -> None: + json = get_request_json(get_success_body({"volume": 2, "total": 10})) + await assert_command(GetVolume(), json, VolumeEvent(2, 10)) + + +@pytest.mark.parametrize("level", [0, 2, 10]) +async def test_SetCleanCount(level: int) -> None: + args = {"volume": level} + await assert_set_command(SetVolume(level), args, VolumeEvent(level, None)) diff --git a/tests/commands/json/test_water_info.py b/tests/commands/json/test_water_info.py new file mode 100644 index 00000000..b4d6c230 --- /dev/null +++ b/tests/commands/json/test_water_info.py @@ -0,0 +1,44 @@ +from typing import Any + +import pytest + +from deebot_client.commands.json import GetWaterInfo, SetWaterInfo +from deebot_client.events import WaterAmount, WaterInfoEvent +from tests.helpers import ( + get_request_json, + get_success_body, + verify_DisplayNameEnum_unique, +) + +from . import assert_command, assert_set_command + + +def test_WaterAmount_unique() -> None: + verify_DisplayNameEnum_unique(WaterAmount) + + +@pytest.mark.parametrize( + ("json", "expected"), + [ + ({"amount": 2}, WaterInfoEvent(None, WaterAmount.MEDIUM)), + ({"amount": 1, "enable": 1}, WaterInfoEvent(True, WaterAmount.LOW)), + ({"amount": 4, "enable": 0}, WaterInfoEvent(False, WaterAmount.ULTRAHIGH)), + ], +) +async def test_GetWaterInfo(json: dict[str, Any], expected: WaterInfoEvent) -> None: + json = get_request_json(get_success_body(json)) + await assert_command(GetWaterInfo(), json, expected) + + +@pytest.mark.parametrize(("value"), [WaterAmount.MEDIUM, "medium"]) +async def test_SetWaterInfo(value: WaterAmount | str) -> None: + command = SetWaterInfo(value) + args = {"amount": 2} + await assert_set_command(command, args, WaterInfoEvent(None, WaterAmount.MEDIUM)) + + +def test_SetWaterInfo_inexisting_value() -> None: + with pytest.raises( + ValueError, match="'INEXSTING' is not a valid WaterAmount member" + ): + SetWaterInfo("inexsting") diff --git a/tests/commands/json/test_work_mode.py b/tests/commands/json/test_work_mode.py new file mode 100644 index 00000000..da7d6312 --- /dev/null +++ b/tests/commands/json/test_work_mode.py @@ -0,0 +1,43 @@ +from typing import Any + +import pytest + +from deebot_client.commands.json import GetWorkMode, SetWorkMode +from deebot_client.events import WorkMode, WorkModeEvent +from tests.helpers import ( + get_request_json, + get_success_body, + verify_DisplayNameEnum_unique, +) + +from . import assert_command, assert_set_command + + +def test_WorkMode_unique() -> None: + verify_DisplayNameEnum_unique(WorkMode) + + +@pytest.mark.parametrize( + ("json", "expected"), + [ + ({"mode": 0}, WorkModeEvent(WorkMode.VACUUM_AND_MOP)), + ({"mode": 1}, WorkModeEvent(WorkMode.VACUUM)), + ({"mode": 2}, WorkModeEvent(WorkMode.MOP)), + ({"mode": 3}, WorkModeEvent(WorkMode.MOP_AFTER_VACUUM)), + ], +) +async def test_GetWaterInfo(json: dict[str, Any], expected: WorkModeEvent) -> None: + json = get_request_json(get_success_body(json)) + await assert_command(GetWorkMode(), json, expected) + + +@pytest.mark.parametrize(("value"), [WorkMode.MOP_AFTER_VACUUM, "mop_after_vacuum"]) +async def test_SetWaterInfo(value: WorkMode | str) -> None: + command = SetWorkMode(value) + args = {"mode": 3} + await assert_set_command(command, args, WorkModeEvent(WorkMode.MOP_AFTER_VACUUM)) + + +def test_SetWaterInfo_inexisting_value() -> None: + with pytest.raises(ValueError, match="'INEXSTING' is not a valid WorkMode member"): + SetWorkMode("inexsting") diff --git a/tests/commands/test_advanced_mode.py b/tests/commands/test_advanced_mode.py deleted file mode 100644 index 45fbc9c6..00000000 --- a/tests/commands/test_advanced_mode.py +++ /dev/null @@ -1,18 +0,0 @@ -import pytest - -from deebot_client.commands import GetAdvancedMode, SetAdvancedMode -from deebot_client.events import AdvancedModeEvent -from tests.commands import assert_command, assert_set_command -from tests.helpers import get_request_json - - -@pytest.mark.parametrize("value", [False, True]) -async def test_GetAdvancedMode(value: bool) -> None: - json = get_request_json({"enable": 1 if value else 0}) - await assert_command(GetAdvancedMode(), json, AdvancedModeEvent(value)) - - -@pytest.mark.parametrize("value", [False, True]) -async def test_SetAdvancedMode(value: bool) -> None: - args = {"enable": 1 if value else 0} - await assert_set_command(SetAdvancedMode(value), args, AdvancedModeEvent(value)) diff --git a/tests/commands/test_battery.py b/tests/commands/test_battery.py deleted file mode 100644 index bac3fe5a..00000000 --- a/tests/commands/test_battery.py +++ /dev/null @@ -1,12 +0,0 @@ -import pytest - -from deebot_client.commands import GetBattery -from deebot_client.events import BatteryEvent -from tests.commands import assert_command -from tests.helpers import get_request_json - - -@pytest.mark.parametrize("percentage", [0, 49, 100]) -async def test_GetBattery(percentage: int) -> None: - json = get_request_json({"value": percentage, "isLow": 1 if percentage < 20 else 0}) - await assert_command(GetBattery(), json, BatteryEvent(percentage)) diff --git a/tests/commands/test_carpet.py b/tests/commands/test_carpet.py deleted file mode 100644 index a7f1c2e5..00000000 --- a/tests/commands/test_carpet.py +++ /dev/null @@ -1,20 +0,0 @@ -import pytest - -from deebot_client.commands import GetCarpetAutoFanBoost, SetCarpetAutoFanBoost -from deebot_client.events import CarpetAutoFanBoostEvent -from tests.commands import assert_command, assert_set_command -from tests.helpers import get_request_json - - -@pytest.mark.parametrize("value", [False, True]) -async def test_GetCarpetAutoFanBoost(value: bool) -> None: - json = get_request_json({"enable": 1 if value else 0}) - await assert_command(GetCarpetAutoFanBoost(), json, CarpetAutoFanBoostEvent(value)) - - -@pytest.mark.parametrize("value", [False, True]) -async def test_SetCarpetAutoFanBoost(value: bool) -> None: - args = {"enable": 1 if value else 0} - await assert_set_command( - SetCarpetAutoFanBoost(value), args, CarpetAutoFanBoostEvent(value) - ) diff --git a/tests/commands/test_charge.py b/tests/commands/test_charge.py deleted file mode 100644 index 2bfae2c0..00000000 --- a/tests/commands/test_charge.py +++ /dev/null @@ -1,46 +0,0 @@ -from typing import Any - -import pytest -from testfixtures import LogCapture - -from deebot_client.commands import Charge -from deebot_client.events import StateEvent -from deebot_client.models import VacuumState -from tests.commands import assert_command -from tests.helpers import get_request_json - - -def _prepare_json(code: int, msg: str = "ok") -> dict[str, Any]: - json = get_request_json(None) - json["resp"]["body"].update( - { - "code": code, - "msg": msg, - } - ) - return json - - -@pytest.mark.parametrize( - "json, expected", - [ - (get_request_json(None), StateEvent(VacuumState.RETURNING)), - (_prepare_json(30007), StateEvent(VacuumState.DOCKED)), - ], -) -async def test_Charge(json: dict[str, Any], expected: StateEvent) -> None: - await assert_command(Charge(), json, expected) - - -async def test_Charge_failed() -> None: - with LogCapture() as log: - json = _prepare_json(500, "fail") - await assert_command(Charge(), json, None) - - log.check_present( - ( - "deebot_client.commands.common", - "WARNING", - f"Command \"charge\" was not successfully. body={json['resp']['body']}", - ) - ) diff --git a/tests/commands/test_clean_preference.py b/tests/commands/test_clean_preference.py deleted file mode 100644 index 45cff1af..00000000 --- a/tests/commands/test_clean_preference.py +++ /dev/null @@ -1,20 +0,0 @@ -import pytest - -from deebot_client.commands import GetCleanPreference, SetCleanPreference -from deebot_client.events import CleanPreferenceEvent -from tests.commands import assert_command, assert_set_command -from tests.helpers import get_request_json - - -@pytest.mark.parametrize("value", [False, True]) -async def test_GetCleanPreference(value: bool) -> None: - json = get_request_json({"enable": 1 if value else 0}) - await assert_command(GetCleanPreference(), json, CleanPreferenceEvent(value)) - - -@pytest.mark.parametrize("value", [False, True]) -async def test_SetCleanPreference(value: bool) -> None: - args = {"enable": 1 if value else 0} - await assert_set_command( - SetCleanPreference(value), args, CleanPreferenceEvent(value) - ) diff --git a/tests/commands/test_continuous_cleaning.py b/tests/commands/test_continuous_cleaning.py deleted file mode 100644 index 9bf3fac3..00000000 --- a/tests/commands/test_continuous_cleaning.py +++ /dev/null @@ -1,20 +0,0 @@ -import pytest - -from deebot_client.commands import GetContinuousCleaning, SetContinuousCleaning -from deebot_client.events import ContinuousCleaningEvent -from tests.commands import assert_command, assert_set_command -from tests.helpers import get_request_json - - -@pytest.mark.parametrize("value", [False, True]) -async def test_GetContinuousCleaning(value: bool) -> None: - json = get_request_json({"enable": 1 if value else 0}) - await assert_command(GetContinuousCleaning(), json, ContinuousCleaningEvent(value)) - - -@pytest.mark.parametrize("value", [False, True]) -async def test_SetContinuousCleaning(value: bool) -> None: - args = {"enable": 1 if value else 0} - await assert_set_command( - SetContinuousCleaning(value), args, ContinuousCleaningEvent(value) - ) diff --git a/tests/commands/test_fan_speed.py b/tests/commands/test_fan_speed.py deleted file mode 100644 index 2d15e12f..00000000 --- a/tests/commands/test_fan_speed.py +++ /dev/null @@ -1,25 +0,0 @@ -import pytest - -from deebot_client.commands import FanSpeedLevel, GetFanSpeed, SetFanSpeed -from deebot_client.events import FanSpeedEvent -from tests.commands import assert_command -from tests.helpers import get_request_json, verify_DisplayNameEnum_unique - - -def test_FanSpeedLevel_unique() -> None: - verify_DisplayNameEnum_unique(FanSpeedLevel) - - -async def test_GetFanSpeed() -> None: - json = get_request_json({"speed": 2}) - await assert_command(GetFanSpeed(), json, FanSpeedEvent(FanSpeedLevel.MAX_PLUS)) - - -@pytest.mark.parametrize( - "value, expected", - [("quiet", 1000), ("max_plus", 2), (0, 0), (FanSpeedLevel.MAX, 1)], -) -def test_SetFanSpeed(value: str | int | FanSpeedLevel, expected: int) -> None: - command = SetFanSpeed(value) - assert command.name == "setSpeed" - assert command._args == {"speed": expected} diff --git a/tests/commands/test_life_span.py b/tests/commands/test_life_span.py deleted file mode 100644 index 57eb27db..00000000 --- a/tests/commands/test_life_span.py +++ /dev/null @@ -1,31 +0,0 @@ -from typing import Any - -import pytest - -from deebot_client.commands import GetLifeSpan -from deebot_client.events import LifeSpan, LifeSpanEvent -from tests.commands import assert_command -from tests.helpers import get_request_json - - -@pytest.mark.parametrize( - "json, expected", - [ - ( - get_request_json( - [ - {"type": "sideBrush", "left": 8977, "total": 9000}, - {"type": "brush", "left": 17979, "total": 18000}, - {"type": "heap", "left": 7179, "total": 7200}, - ] - ), - [ - LifeSpanEvent(LifeSpan.SIDE_BRUSH, 99.74, 8977), - LifeSpanEvent(LifeSpan.BRUSH, 99.88, 17979), - LifeSpanEvent(LifeSpan.FILTER, 99.71, 7179), - ], - ), - ], -) -async def test_GetLifeSpan(json: dict[str, Any], expected: list[LifeSpanEvent]) -> None: - await assert_command(GetLifeSpan(), json, expected) diff --git a/tests/commands/test_mulitmap_state.py b/tests/commands/test_mulitmap_state.py deleted file mode 100644 index f44f0289..00000000 --- a/tests/commands/test_mulitmap_state.py +++ /dev/null @@ -1,18 +0,0 @@ -import pytest - -from deebot_client.commands import GetMultimapState, SetMultimapState -from deebot_client.events import MultimapStateEvent -from tests.commands import assert_command, assert_set_command -from tests.helpers import get_request_json - - -@pytest.mark.parametrize("value", [False, True]) -async def test_GetMultimapState(value: bool) -> None: - json = get_request_json({"enable": 1 if value else 0}) - await assert_command(GetMultimapState(), json, MultimapStateEvent(value)) - - -@pytest.mark.parametrize("value", [False, True]) -async def test_SetMultimapState(value: bool) -> None: - args = {"enable": 1 if value else 0} - await assert_set_command(SetMultimapState(value), args, MultimapStateEvent(value)) diff --git a/tests/commands/test_true_detect.py b/tests/commands/test_true_detect.py deleted file mode 100644 index aff792c7..00000000 --- a/tests/commands/test_true_detect.py +++ /dev/null @@ -1,18 +0,0 @@ -import pytest - -from deebot_client.commands import GetTrueDetect, SetTrueDetect -from deebot_client.events import TrueDetectEvent -from tests.commands import assert_command, assert_set_command -from tests.helpers import get_request_json - - -@pytest.mark.parametrize("value", [False, True]) -async def test_GetTrueDetect(value: bool) -> None: - json = get_request_json({"enable": 1 if value else 0}) - await assert_command(GetTrueDetect(), json, TrueDetectEvent(value)) - - -@pytest.mark.parametrize("value", [False, True]) -async def test_SetTrueDetect(value: bool) -> None: - args = {"enable": 1 if value else 0} - await assert_set_command(SetTrueDetect(value), args, TrueDetectEvent(value)) diff --git a/tests/commands/test_water_info.py b/tests/commands/test_water_info.py deleted file mode 100644 index 48574554..00000000 --- a/tests/commands/test_water_info.py +++ /dev/null @@ -1,48 +0,0 @@ -from typing import Any - -import pytest - -from deebot_client.commands import GetWaterInfo, SetWaterInfo -from deebot_client.events import WaterAmount, WaterInfoEvent -from tests.commands import assert_command, assert_set_command -from tests.helpers import get_request_json, verify_DisplayNameEnum_unique - - -def test_WaterAmount_unique() -> None: - verify_DisplayNameEnum_unique(WaterAmount) - - -@pytest.mark.parametrize( - "json, expected", - [ - ({"amount": 2}, WaterInfoEvent(None, WaterAmount.MEDIUM)), - ({"amount": 1, "enable": 1}, WaterInfoEvent(True, WaterAmount.LOW)), - ({"amount": 4, "enable": 0}, WaterInfoEvent(False, WaterAmount.ULTRAHIGH)), - ], -) -async def test_GetWaterInfo(json: dict[str, Any], expected: WaterInfoEvent) -> None: - json = get_request_json(json) - await assert_command(GetWaterInfo(), json, expected) - - -@pytest.mark.parametrize( - "value, exptected_args_amount, expected", - [ - ("low", 1, WaterInfoEvent(None, WaterAmount.LOW)), - (WaterAmount.MEDIUM, 2, WaterInfoEvent(None, WaterAmount.MEDIUM)), - ({"amount": 3, "enable": 1}, 3, WaterInfoEvent(None, WaterAmount.HIGH)), - (4, 4, WaterInfoEvent(None, WaterAmount.ULTRAHIGH)), - ], -) -async def test_SetWaterInfo( - value: str | int | WaterAmount | dict, - exptected_args_amount: int, - expected: WaterInfoEvent, -) -> None: - if isinstance(value, dict): - command = SetWaterInfo(**value) - else: - command = SetWaterInfo(value) - - args = {"amount": exptected_args_amount} - await assert_set_command(command, args, expected) diff --git a/tests/conftest.py b/tests/conftest.py index e9019a78..2c7ce770 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,39 +1,42 @@ -import logging from collections.abc import AsyncGenerator, Generator +import logging from unittest.mock import AsyncMock, Mock import aiohttp -import pytest from aiomqtt import Client +import pytest from deebot_client.api_client import ApiClient from deebot_client.authentication import Authenticator -from deebot_client.events.event_bus import EventBus -from deebot_client.models import Configuration, Credentials, DeviceInfo +from deebot_client.event_bus import EventBus +from deebot_client.hardware.deebot import FALLBACK, get_static_device_info +from deebot_client.models import ( + Configuration, + Credentials, + DeviceInfo, + StaticDeviceInfo, +) from deebot_client.mqtt_client import MqttClient, MqttConfiguration -from deebot_client.vacuum_bot import VacuumBot from .fixtures.mqtt_server import MqttServer @pytest.fixture -async def session() -> AsyncGenerator: +async def session() -> AsyncGenerator[aiohttp.ClientSession, None]: async with aiohttp.ClientSession() as client_session: logging.basicConfig(level=logging.DEBUG) yield client_session @pytest.fixture -async def config(session: aiohttp.ClientSession) -> AsyncGenerator: - configuration = Configuration( +async def config(session: aiohttp.ClientSession) -> Configuration: + return Configuration( session, device_id="Test_device", country="it", continent="eu", ) - yield configuration - @pytest.fixture def authenticator() -> Authenticator: @@ -103,7 +106,12 @@ async def test_mqtt_client( @pytest.fixture -def device_info() -> DeviceInfo: +def static_device_info() -> StaticDeviceInfo: + return get_static_device_info(FALLBACK) + + +@pytest.fixture +def device_info(static_device_info: StaticDeviceInfo) -> DeviceInfo: return DeviceInfo( { "company": "company", @@ -114,31 +122,28 @@ def device_info() -> DeviceInfo: "deviceName": "device_name", "status": 1, "class": "get_class", - } + }, + static_device_info, ) -@pytest.fixture -async def vacuum_bot( - device_info: DeviceInfo, authenticator: Authenticator -) -> AsyncGenerator[VacuumBot, None]: - mqtt = Mock(spec_set=MqttClient) - bot = VacuumBot(device_info, authenticator) - await bot.initialize(mqtt) - yield bot - await bot.teardown() - - @pytest.fixture def execute_mock() -> AsyncMock: return AsyncMock() @pytest.fixture -def event_bus(execute_mock: AsyncMock) -> EventBus: - return EventBus(execute_mock) +def event_bus(execute_mock: AsyncMock, device_info: DeviceInfo) -> EventBus: + return EventBus(execute_mock, device_info.capabilities.get_refresh_commands) @pytest.fixture def event_bus_mock() -> Mock: return Mock(spec_set=EventBus) + + +@pytest.fixture(name="caplog") +def caplog_fixture(caplog: pytest.LogCaptureFixture) -> pytest.LogCaptureFixture: + """Set log level to debug for tests using the caplog fixture.""" + caplog.set_level(logging.DEBUG) + return caplog diff --git a/tests/events/test_events.py b/tests/events/test_events.py deleted file mode 100644 index a3630cad..00000000 --- a/tests/events/test_events.py +++ /dev/null @@ -1,11 +0,0 @@ -import inspect - -import deebot_client.events -from deebot_client.events import EnableEvent, Event -from deebot_client.events.const import EVENT_DTO_REFRESH_COMMANDS - - -def test_events_has_refresh_function() -> None: - for _, obj in inspect.getmembers(deebot_client.events, inspect.isclass): - if issubclass(obj, Event) and obj not in [Event, EnableEvent]: - assert obj in EVENT_DTO_REFRESH_COMMANDS diff --git a/tests/fixtures/base.py b/tests/fixtures/base.py index dbb39053..a0c7577e 100644 --- a/tests/fixtures/base.py +++ b/tests/fixtures/base.py @@ -1,12 +1,12 @@ -import os -import re from abc import ABC, abstractmethod -from collections import namedtuple +import contextlib from dataclasses import dataclass, field from datetime import datetime +import os from pprint import pformat +import re from time import sleep -from typing import Any +from typing import Any, NamedTuple import docker from docker.client import DockerClient @@ -16,11 +16,9 @@ DOCKER_HOST_TCP_FORMAT = re.compile(r"^tcp://(\d+\.\d+\.\d+\.\d+)(?::\d+)?$") -class ContainerNotStartedException(Exception): +class ContainerNotStartedError(Exception): """Container not started exception.""" - pass - @dataclass() class ContainerConfiguration: @@ -34,7 +32,11 @@ class ContainerConfiguration: max_wait_started: int = 30 -HostPort = namedtuple("HostPort", ["host", "port"]) +class HostPort(NamedTuple): + """Host port tuple.""" + + host: str + port: int class BaseContainer(ABC): @@ -80,7 +82,7 @@ def host(self) -> str: def get_ports(self) -> dict[str, int]: """Get all service ports and their mapping.""" if self.container is None: - raise ContainerNotStartedException + raise ContainerNotStartedError network = self.container.attrs["NetworkSettings"] result = {} @@ -108,7 +110,7 @@ def get_port(self, port: None | str | int = None) -> int: def get_host(self) -> str: """Get host.""" if self.container is None: - raise ContainerNotStartedException + raise ContainerNotStartedError host: str = self.container.attrs["NetworkSettings"]["IPAddress"] @@ -145,7 +147,7 @@ def get_image_options(self) -> dict[str, Any]: def logs(self, since_last_start: bool = True) -> str: """Get docker container logs.""" if self.container is None: - raise ContainerNotStartedException + raise ContainerNotStartedError if since_last_start: logs: bytes = self.container.logs(since=self._start_time) @@ -204,11 +206,7 @@ def run(self) -> HostPort: def stop(self) -> None: """Stop container.""" if self.container is not None: - try: + with contextlib.suppress(APIError): self.container.kill() - except APIError: - pass - try: + with contextlib.suppress(APIError): self.container.remove(v=True, force=True) - except APIError: - pass diff --git a/tests/hardware/__init__.py b/tests/hardware/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/hardware/test_init.py b/tests/hardware/test_init.py new file mode 100644 index 00000000..af15106b --- /dev/null +++ b/tests/hardware/test_init.py @@ -0,0 +1,158 @@ +"""Hardware init tests.""" + + +from collections.abc import Callable + +import pytest + +from deebot_client.command import Command +from deebot_client.commands.json.advanced_mode import GetAdvancedMode +from deebot_client.commands.json.battery import GetBattery +from deebot_client.commands.json.carpet import GetCarpetAutoFanBoost +from deebot_client.commands.json.charge_state import GetChargeState +from deebot_client.commands.json.clean import GetCleanInfo +from deebot_client.commands.json.clean_count import GetCleanCount +from deebot_client.commands.json.clean_logs import GetCleanLogs +from deebot_client.commands.json.clean_preference import GetCleanPreference +from deebot_client.commands.json.continuous_cleaning import GetContinuousCleaning +from deebot_client.commands.json.error import GetError +from deebot_client.commands.json.fan_speed import GetFanSpeed +from deebot_client.commands.json.life_span import GetLifeSpan +from deebot_client.commands.json.map import GetCachedMapInfo, GetMajorMap, GetMapTrace +from deebot_client.commands.json.multimap_state import GetMultimapState +from deebot_client.commands.json.network import GetNetInfo +from deebot_client.commands.json.pos import GetPos +from deebot_client.commands.json.stats import GetStats, GetTotalStats +from deebot_client.commands.json.true_detect import GetTrueDetect +from deebot_client.commands.json.volume import GetVolume +from deebot_client.commands.json.water_info import GetWaterInfo +from deebot_client.events import ( + AdvancedModeEvent, + AvailabilityEvent, + BatteryEvent, + CarpetAutoFanBoostEvent, + CleanCountEvent, + CleanLogEvent, + CleanPreferenceEvent, + ContinuousCleaningEvent, + CustomCommandEvent, + ErrorEvent, + LifeSpan, + LifeSpanEvent, + MultimapStateEvent, + ReportStatsEvent, + RoomsEvent, + StateEvent, + StatsEvent, + TotalStatsEvent, + TrueDetectEvent, + VolumeEvent, +) +from deebot_client.events.base import Event +from deebot_client.events.fan_speed import FanSpeedEvent +from deebot_client.events.map import ( + CachedMapInfoEvent, + MajorMapEvent, + MapChangedEvent, + MapTraceEvent, + PositionsEvent, +) +from deebot_client.events.network import NetworkInfoEvent +from deebot_client.events.water_info import WaterInfoEvent +from deebot_client.hardware import get_static_device_info +from deebot_client.hardware.deebot import DEVICES, FALLBACK +from deebot_client.models import StaticDeviceInfo + + +@pytest.mark.parametrize( + ("class_", "expected"), + [ + ("not_specified", lambda: DEVICES[FALLBACK]), + ("yna5xi", lambda: DEVICES["yna5xi"]), + ], +) +def test_get_static_device_info( + class_: str, expected: Callable[[], StaticDeviceInfo] +) -> None: + """Test get_static_device_info.""" + static_device_info = get_static_device_info(class_) + assert expected() == static_device_info + + +@pytest.mark.parametrize( + ("class_", "expected"), + [ + ( + FALLBACK, + { + AdvancedModeEvent: [GetAdvancedMode()], + AvailabilityEvent: [GetBattery(True)], + BatteryEvent: [GetBattery()], + CachedMapInfoEvent: [GetCachedMapInfo()], + CarpetAutoFanBoostEvent: [GetCarpetAutoFanBoost()], + CleanCountEvent: [GetCleanCount()], + CleanLogEvent: [GetCleanLogs()], + CleanPreferenceEvent: [GetCleanPreference()], + ContinuousCleaningEvent: [GetContinuousCleaning()], + CustomCommandEvent: [], + ErrorEvent: [GetError()], + FanSpeedEvent: [GetFanSpeed()], + LifeSpanEvent: [ + GetLifeSpan([LifeSpan.BRUSH, LifeSpan.FILTER, LifeSpan.SIDE_BRUSH]) + ], + MapChangedEvent: [], + MajorMapEvent: [GetMajorMap()], + MapTraceEvent: [GetMapTrace()], + MultimapStateEvent: [GetMultimapState()], + NetworkInfoEvent: [GetNetInfo()], + PositionsEvent: [GetPos()], + ReportStatsEvent: [], + RoomsEvent: [GetCachedMapInfo()], + StateEvent: [GetChargeState(), GetCleanInfo()], + StatsEvent: [GetStats()], + TotalStatsEvent: [GetTotalStats()], + TrueDetectEvent: [GetTrueDetect()], + VolumeEvent: [GetVolume()], + WaterInfoEvent: [GetWaterInfo()], + }, + ), + ( + "yna5xi", + { + AdvancedModeEvent: [GetAdvancedMode()], + AvailabilityEvent: [GetBattery(True)], + BatteryEvent: [GetBattery()], + CachedMapInfoEvent: [GetCachedMapInfo()], + CarpetAutoFanBoostEvent: [GetCarpetAutoFanBoost()], + CleanLogEvent: [GetCleanLogs()], + ContinuousCleaningEvent: [GetContinuousCleaning()], + CustomCommandEvent: [], + ErrorEvent: [GetError()], + FanSpeedEvent: [GetFanSpeed()], + LifeSpanEvent: [ + GetLifeSpan([LifeSpan.BRUSH, LifeSpan.FILTER, LifeSpan.SIDE_BRUSH]) + ], + MapChangedEvent: [], + MajorMapEvent: [GetMajorMap()], + MapTraceEvent: [GetMapTrace()], + MultimapStateEvent: [GetMultimapState()], + NetworkInfoEvent: [GetNetInfo()], + PositionsEvent: [GetPos()], + ReportStatsEvent: [], + RoomsEvent: [GetCachedMapInfo()], + StateEvent: [GetChargeState(), GetCleanInfo()], + StatsEvent: [GetStats()], + TotalStatsEvent: [GetTotalStats()], + VolumeEvent: [GetVolume()], + WaterInfoEvent: [GetWaterInfo()], + }, + ), + ], +) +def test_capabilities_event_extraction( + class_: str, expected: dict[type[Event], list[Command]] +) -> None: + capabilities = get_static_device_info(class_).capabilities + assert capabilities._events.keys() == expected.keys() + for event, expected_commands in expected.items(): + assert capabilities.get_refresh_commands(event) == expected_commands diff --git a/tests/helpers.py b/tests/helpers.py deleted file mode 100644 index 8d552299..00000000 --- a/tests/helpers.py +++ /dev/null @@ -1,45 +0,0 @@ -from typing import Any - -from deebot_client.util import DisplayNameIntEnum - - -def verify_DisplayNameEnum_unique(enum: type[DisplayNameIntEnum]) -> None: - assert issubclass(enum, DisplayNameIntEnum) - names: set[str] = set() - values: set[int] = set() - for member in enum: - assert member.value not in values - values.add(member.value) - - name = member.name.lower() - assert name not in names - names.add(name) - - display_name = member.display_name.lower() - if display_name != name: - assert display_name not in names - names.add(display_name) - - -def get_request_json(data: dict[str, Any] | None | list[Any]) -> dict[str, Any]: - return {"id": "ALZf", "ret": "ok", "resp": get_message_json(data)} - - -def get_message_json(data: dict[str, Any] | None | list[Any]) -> dict[str, Any]: - json = { - "header": { - "pri": 1, - "tzm": 480, - "ts": "1304623069888", - "ver": "0.0.1", - "fwVer": "1.8.2", - "hwVer": "0.1.1", - }, - "body": { - "code": 0, - "msg": "ok", - }, - } - if data: - json["body"]["data"] = data - return json diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py new file mode 100644 index 00000000..7b3cfe37 --- /dev/null +++ b/tests/helpers/__init__.py @@ -0,0 +1,74 @@ +from collections.abc import Mapping +from typing import Any +from unittest.mock import Mock + +from deebot_client.capabilities import Capabilities +from deebot_client.command import Command +from deebot_client.const import DataType +from deebot_client.events.base import Event +from deebot_client.models import StaticDeviceInfo +from deebot_client.util import DisplayNameIntEnum + + +def verify_DisplayNameEnum_unique(enum: type[DisplayNameIntEnum]) -> None: + assert issubclass(enum, DisplayNameIntEnum) + names: set[str] = set() + values: set[int] = set() + for member in enum: + assert member.value not in values + values.add(member.value) + + name = member.name.lower() + assert name not in names + names.add(name) + + display_name = member.display_name.lower() + if display_name != name: + assert display_name not in names + names.add(display_name) + + +def get_request_json(body: dict[str, Any]) -> dict[str, Any]: + return {"id": "ALZf", "ret": "ok", "resp": get_message_json(body)} + + +def get_success_body(data: dict[str, Any] | None | list[Any] = None) -> dict[str, Any]: + body = { + "code": 0, + "msg": "ok", + } + if data: + body["data"] = data + + return body + + +def get_message_json(body: dict[str, Any]) -> dict[str, Any]: + return { + "header": { + "pri": 1, + "tzm": 480, + "ts": "1304623069888", + "ver": "0.0.1", + "fwVer": "1.8.2", + "hwVer": "0.1.1", + }, + "body": body, + } + + +def mock_static_device_info( + events: Mapping[type[Event], list[Command]] | None = None +) -> StaticDeviceInfo: + """Mock static device info.""" + if events is None: + events = {} + + mock = Mock(spec_set=Capabilities) + + def get_refresh_commands(event: type[Event]) -> list[Command]: + return events.get(event, []) + + mock.get_refresh_commands.side_effect = get_refresh_commands + + return StaticDeviceInfo(DataType.JSON, mock) diff --git a/tests/helpers/tasks.py b/tests/helpers/tasks.py new file mode 100644 index 00000000..0ab66874 --- /dev/null +++ b/tests/helpers/tasks.py @@ -0,0 +1,60 @@ +# The functions in this files are copied from Home Assistant +# See https://github.com/home-assistant/core/blob/8b1cfbc46cc79e676f75dfa4da097a2e47375b6f/homeassistant/core.py#L715 + + +# How long to wait to log tasks that are blocking +import asyncio +from collections.abc import Collection +from logging import getLogger +from time import monotonic +from typing import Any + +_LOGGER = getLogger(__name__) + + +BLOCK_LOG_TIMEOUT = 1 + + +def _cancelling(task: asyncio.Future[Any]) -> bool: + """Return True if task is cancelling.""" + return bool((cancelling_ := getattr(task, "cancelling", None)) and cancelling_()) + + +async def _await_and_log_pending(pending: Collection[asyncio.Future[Any]]) -> None: + """Await and log tasks that take a long time.""" + wait_time = 0 + while pending: + _, pending = await asyncio.wait(pending, timeout=BLOCK_LOG_TIMEOUT) + if not pending: + return + wait_time += BLOCK_LOG_TIMEOUT + for task in pending: + _LOGGER.debug("Waited %s seconds for task: %s", wait_time, task) + + +async def block_till_done(tasks: set[asyncio.Future[Any]]) -> None: + """Block until all pending work is done.""" + # To flush out any call_soon_threadsafe + await asyncio.sleep(0) + start_time: float | None = None + current_task = asyncio.current_task() + + while tasks_ := [ + task for task in tasks if task is not current_task and not _cancelling(task) + ]: + await _await_and_log_pending(tasks_) + + if start_time is None: + # Avoid calling monotonic() until we know + # we may need to start logging blocked tasks. + start_time = 0 + elif start_time == 0: + # If we have waited twice then we set the start + # time + start_time = monotonic() + elif monotonic() - start_time > BLOCK_LOG_TIMEOUT: + # We have waited at least three loops and new tasks + # continue to block. At this point we start + # logging all waiting tasks. + for task in tasks_: + _LOGGER.debug("Waiting for task: %s", task) diff --git a/tests/messages/__init__.py b/tests/messages/__init__.py index 0a1ad22c..77b4e4a3 100644 --- a/tests/messages/__init__.py +++ b/tests/messages/__init__.py @@ -1,8 +1,8 @@ from typing import Any from unittest.mock import Mock +from deebot_client.event_bus import EventBus from deebot_client.events import Event -from deebot_client.events.event_bus import EventBus from deebot_client.message import HandlingState, Message diff --git a/tests/messages/json/__init__.py b/tests/messages/json/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/messages/test_battery.py b/tests/messages/json/test_battery.py similarity index 84% rename from tests/messages/test_battery.py rename to tests/messages/json/test_battery.py index adcaf5be..3a36384e 100644 --- a/tests/messages/test_battery.py +++ b/tests/messages/json/test_battery.py @@ -1,12 +1,12 @@ import pytest from deebot_client.events import BatteryEvent -from deebot_client.messages import OnBattery +from deebot_client.messages.json import OnBattery from tests.messages import assert_message @pytest.mark.parametrize("percentage", [0, 49, 100]) -def test_getBattery(percentage: int) -> None: +def test_onBattery(percentage: int) -> None: data = { "header": { "pri": 1, diff --git a/tests/messages/test_stats.py b/tests/messages/json/test_stats.py similarity index 95% rename from tests/messages/test_stats.py rename to tests/messages/json/test_stats.py index 60534f33..2ad87285 100644 --- a/tests/messages/test_stats.py +++ b/tests/messages/json/test_stats.py @@ -3,12 +3,12 @@ import pytest from deebot_client.events import CleanJobStatus, ReportStatsEvent -from deebot_client.messages import ReportStats +from deebot_client.messages.json import ReportStats from tests.messages import assert_message @pytest.mark.parametrize( - "data, expected", + ("data", "expected"), [ ( { diff --git a/tests/messages/test_get_messages.py b/tests/messages/test_get_messages.py index 225f6e45..cf58205a 100644 --- a/tests/messages/test_get_messages.py +++ b/tests/messages/test_get_messages.py @@ -1,21 +1,25 @@ import pytest -from deebot_client.commands.error import GetError +from deebot_client.commands.json.error import GetError +from deebot_client.const import DataType from deebot_client.message import Message from deebot_client.messages import get_message -from deebot_client.messages.battery import OnBattery +from deebot_client.messages.json.battery import OnBattery @pytest.mark.parametrize( - "name, expected", + ("name", "data_type", "expected"), [ - ("onBattery", OnBattery), - ("onBattery_V2", OnBattery), - ("onError", GetError), - ("GetCleanLogs", None), - ("unknown", None), + ("onBattery", DataType.JSON, OnBattery), + ("onBattery_V2", DataType.JSON, OnBattery), + ("onError", DataType.JSON, GetError), + ("GetCleanLogs", DataType.JSON, None), + ("unknown", DataType.JSON, None), + ("unknown", DataType.XML, None), ], ) -def test_get_messages(name: str, expected: type[Message] | None) -> None: +def test_get_messages( + name: str, data_type: DataType, expected: type[Message] | None +) -> None: """Test get messages.""" - assert get_message(name) == expected + assert get_message(name, data_type) == expected diff --git a/tests/messages/test_messages.py b/tests/messages/test_messages.py index 66643bea..09b45d11 100644 --- a/tests/messages/test_messages.py +++ b/tests/messages/test_messages.py @@ -3,5 +3,6 @@ def test_all_messages_4_abstract_methods() -> None: # Verify that all abstract methods are implemented - for _, message in MESSAGES.items(): - message() + for _, messages in MESSAGES.items(): + for _, message in messages.items(): + message() diff --git a/tests/test_command.py b/tests/test_command.py new file mode 100644 index 00000000..990e8fb6 --- /dev/null +++ b/tests/test_command.py @@ -0,0 +1,62 @@ +import logging +from typing import Any + +import pytest + +from deebot_client.command import CommandMqttP2P, CommandResult, InitParam +from deebot_client.const import DataType +from deebot_client.event_bus import EventBus +from deebot_client.exceptions import DeebotError + + +class _TestCommand(CommandMqttP2P): + name = "TestCommand" + data_type = DataType.JSON + _mqtt_params = {"field": InitParam(int), "remove": None} + + def __init__(self, field: int) -> None: + pass + + def handle_mqtt_p2p(self, event_bus: EventBus, response: dict[str, Any]) -> None: + pass + + def _get_payload(self) -> dict[str, Any] | list[Any] | str: + return {} + + def _handle_response( + self, event_bus: EventBus, response: dict[str, Any] + ) -> CommandResult: + return CommandResult.analyse() + + +def test_CommandMqttP2P_no_mqtt_params() -> None: + class TestCommandNoParams(CommandMqttP2P): + pass + + with pytest.raises(DeebotError, match=r"_mqtt_params not set"): + TestCommandNoParams.create_from_mqtt({}) + + +@pytest.mark.parametrize( + ("data", "expected"), + [ + ({"field": "a"}, r"""Could not convert "a" of field into """), + ({"something": "a"}, r'"field" is missing in {\'something\': \'a\'}'), + ], +) +def test_CommandMqttP2P_create_from_mqtt_error( + data: dict[str, Any], expected: str +) -> None: + with pytest.raises(DeebotError, match=expected): + _TestCommand.create_from_mqtt(data) + + +def test_CommandMqttP2P_create_from_mqtt_additional_fields( + caplog: pytest.LogCaptureFixture, +) -> None: + _TestCommand.create_from_mqtt({"field": 0, "remove": "bla", "additional": 1}) + assert ( + "deebot_client.command", + logging.DEBUG, + "Following data will be ignored: {'additional': 1}", + ) in caplog.record_tuples diff --git a/tests/test_device.py b/tests/test_device.py new file mode 100644 index 00000000..76f00d1b --- /dev/null +++ b/tests/test_device.py @@ -0,0 +1,126 @@ +import asyncio +from collections.abc import Callable +import json +from unittest.mock import Mock, patch + +from deebot_client.authentication import Authenticator +from deebot_client.commands.json.battery import GetBattery +from deebot_client.device import Device +from deebot_client.events import AvailabilityEvent +from deebot_client.events.network import NetworkInfoEvent +from deebot_client.models import DeviceInfo +from deebot_client.mqtt_client import MqttClient, SubscriberInfo +from tests.helpers import mock_static_device_info +from tests.helpers.tasks import block_till_done + + +@patch("deebot_client.device._AVAILABLE_CHECK_INTERVAL", 2) # reduce interval +async def test_available_check_and_teardown( + authenticator: Authenticator, device_info: DeviceInfo +) -> None: + """Test the available check including if the status Event is fired correctly.""" + received_statuses: asyncio.Queue[AvailabilityEvent] = asyncio.Queue() + + async def on_status(event: AvailabilityEvent) -> None: + received_statuses.put_nowait(event) + + async def assert_received_status(expected: bool) -> None: + await asyncio.sleep(0.1) + assert received_statuses.get_nowait().available is expected + + # prepare mocks + battery_mock = Mock(spec_set=GetBattery) + device_info._static_device_info = mock_static_device_info( + {AvailabilityEvent: [battery_mock]} + ) + execute_mock = battery_mock.execute + + # prepare bot and mock mqtt + bot = Device(device_info, authenticator) + mqtt_client = Mock(spec=MqttClient) + unsubscribe_mock = Mock(spec=Callable[[], None]) + mqtt_client.subscribe.return_value = unsubscribe_mock + await bot.initialize(mqtt_client) + + # deactivate refresh event subscribe refresh calls + bot.events._get_refresh_commands = lambda _: [] + + bot.events.subscribe(AvailabilityEvent, on_status) + + # verify mqtt was subscribed and available task was started + mqtt_client.subscribe.assert_called_once() + sub_info: SubscriberInfo = mqtt_client.subscribe.call_args.args[0] + assert bot._available_task is not None + assert not bot._available_task.done() + # As task was started now, no check should be performed + execute_mock.assert_not_called() + + # Simulate bot not reached by returning False + execute_mock.return_value = False + + # Wait longer than the interval to be sure task will be executed + await asyncio.sleep(2.1) + # Verify command call for available check + execute_mock.assert_awaited_once() + await assert_received_status(False) + + # Simulate bot reached by returning True + execute_mock.return_value = True + + await asyncio.sleep(2) + execute_mock.await_count = 2 + await assert_received_status(True) + + # reset mock for easier handling + battery_mock.reset_mock() + + # Simulate message over mqtt and therefore available is not needed + await asyncio.sleep(0.8) + data = { + "header": { + "pri": 1, + "tzm": 480, + "ts": "1304637391896", + "ver": "0.0.1", + "fwVer": "1.8.2", + "hwVer": "0.1.1", + }, + "body": {"data": {"value": 100, "isLow": 0}}, + } + + sub_info.callback("onBattery", json.dumps(data)) + await asyncio.sleep(1) + + # As the last message is not more than (interval-1) old, we skip the available check + execute_mock.assert_not_called() + assert received_statuses.empty() + + # teardown bot and verify that bot was unsubscribed from mqtt and available task was canceled. + await bot.teardown() + await asyncio.sleep(0.1) + + unsubscribe_mock.assert_called() + assert bot._available_task.done() + await bot.teardown() + + +async def test_mac_address( + authenticator: Authenticator, device_info: DeviceInfo +) -> None: + """Test that the mac address is change on NetwerkInfoEvent.""" + device = Device(device_info, authenticator) + # deactivate refresh event subscribe refresh calls + device.events._get_refresh_commands = lambda _: [] + + assert device.mac is None + + mac = "AA:BB:CC:DD:EE:FF" + + device.events.notify( + NetworkInfoEvent(ip="192.168.1.100", ssid="WLAN", rssi=-61, mac=mac) + ) + + await block_till_done(device.events._tasks) + + assert device.mac == mac + await device.teardown() diff --git a/tests/events/test_event_bus.py b/tests/test_event_bus.py similarity index 81% rename from tests/events/test_event_bus.py rename to tests/test_event_bus.py index 5329ac37..4f2c465f 100644 --- a/tests/events/test_event_bus.py +++ b/tests/test_event_bus.py @@ -1,26 +1,26 @@ import asyncio from collections.abc import Callable -from datetime import datetime, timezone +from datetime import UTC, datetime from unittest.mock import AsyncMock, call, patch import pytest +from deebot_client.event_bus import EventBus from deebot_client.events import AvailabilityEvent, BatteryEvent, StateEvent from deebot_client.events.base import Event -from deebot_client.events.const import EVENT_DTO_REFRESH_COMMANDS -from deebot_client.events.event_bus import EventBus from deebot_client.events.map import MapChangedEvent -from deebot_client.models import VacuumState +from deebot_client.events.water_info import WaterInfoEvent +from deebot_client.models import State def _verify_event_command_called( - execute_mock: AsyncMock, event: type[Event], expected_call: bool + execute_mock: AsyncMock, + event: type[Event], + expected_call: bool, + event_bus: EventBus, ) -> None: - for command in EVENT_DTO_REFRESH_COMMANDS[event]: - if expected_call: - assert call(command) in execute_mock.call_args_list - else: - execute_mock.assert_not_called() + for command in event_bus._get_refresh_commands(event): + assert (call(command) in execute_mock.call_args_list) == expected_call async def _subscribeAndVerify( @@ -32,7 +32,7 @@ async def _subscribeAndVerify( unsubscribe = event_bus.subscribe(to_subscribe, AsyncMock()) await asyncio.sleep(0.1) - _verify_event_command_called(execute_mock, to_subscribe, expected_call) + _verify_event_command_called(execute_mock, to_subscribe, expected_call, event_bus) execute_mock.reset_mock() return unsubscribe @@ -70,7 +70,7 @@ async def notify(available: bool) -> None: await asyncio.sleep(0.1) available_mock.assert_awaited_with(event) - event_bus.subscribe(BatteryEvent, AsyncMock()) + event_bus.subscribe(WaterInfoEvent, AsyncMock()) event_bus.subscribe(StateEvent, AsyncMock()) event_bus.subscribe(AvailabilityEvent, available_mock) await asyncio.sleep(0.1) @@ -81,9 +81,9 @@ async def notify(available: bool) -> None: await notify(False) await notify(True) - _verify_event_command_called(execute_mock, BatteryEvent, True) - _verify_event_command_called(execute_mock, StateEvent, True) - _verify_event_command_called(execute_mock, AvailabilityEvent, False) + _verify_event_command_called(execute_mock, WaterInfoEvent, True, event_bus) + _verify_event_command_called(execute_mock, StateEvent, True, event_bus) + _verify_event_command_called(execute_mock, AvailabilityEvent, False, event_bus) async def test_get_last_event(event_bus: EventBus) -> None: @@ -106,7 +106,7 @@ def notify(percent: int) -> BatteryEvent: async def test_request_refresh(execute_mock: AsyncMock, event_bus: EventBus) -> None: event = BatteryEvent event_bus.request_refresh(event) - _verify_event_command_called(execute_mock, event, False) + _verify_event_command_called(execute_mock, event, False, event_bus) event_bus.subscribe(event, AsyncMock()) execute_mock.reset_mock() @@ -114,24 +114,24 @@ async def test_request_refresh(execute_mock: AsyncMock, event_bus: EventBus) -> event_bus.request_refresh(event) await asyncio.sleep(0.1) - _verify_event_command_called(execute_mock, event, True) + _verify_event_command_called(execute_mock, event, True, event_bus) @pytest.mark.parametrize( - "last, actual, expected", + ("last", "actual", "expected"), [ - (VacuumState.DOCKED, VacuumState.IDLE, None), - (VacuumState.CLEANING, VacuumState.IDLE, VacuumState.IDLE), - (VacuumState.IDLE, VacuumState.DOCKED, VacuumState.DOCKED), + (State.DOCKED, State.IDLE, None), + (State.CLEANING, State.IDLE, State.IDLE), + (State.IDLE, State.DOCKED, State.DOCKED), ], ) async def test_StateEvent( event_bus: EventBus, - last: VacuumState, - actual: VacuumState, - expected: VacuumState | None, + last: State, + actual: State, + expected: State | None, ) -> None: - async def notify(state: VacuumState) -> None: + async def notify(state: State) -> None: event_bus.notify(StateEvent(state)) await asyncio.sleep(0.1) @@ -162,10 +162,10 @@ async def notify(event: MapChangedEvent, debounce_time: float) -> None: mock = AsyncMock() event_bus.subscribe(MapChangedEvent, mock) - with patch("deebot_client.events.event_bus.asyncio", wraps=asyncio) as aio: + with patch("deebot_client.event_bus.asyncio", wraps=asyncio) as aio: async def test_cycle(call_expected: bool) -> MapChangedEvent: - event = MapChangedEvent(datetime.now(timezone.utc)) + event = MapChangedEvent(datetime.now(UTC)) await notify(event, debounce_time) if call_expected: aio.get_running_loop.assert_not_called() diff --git a/tests/test_map.py b/tests/test_map.py index 263d3499..cc56e326 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -3,7 +3,7 @@ import pytest -from deebot_client.events.event_bus import EventBus +from deebot_client.event_bus import EventBus from deebot_client.events.map import ( MapChangedEvent, MapSetEvent, @@ -21,7 +21,7 @@ ] -@pytest.mark.parametrize("x,y,image_box,expected", _test_calc_point_data) +@pytest.mark.parametrize(("x", "y", "image_box", "expected"), _test_calc_point_data) def test_calc_point( x: int, y: int, diff --git a/tests/test_mqtt_client.py b/tests/test_mqtt_client.py index ee347970..217e5330 100644 --- a/tests/test_mqtt_client.py +++ b/tests/test_mqtt_client.py @@ -1,21 +1,22 @@ import asyncio +from collections.abc import Callable import datetime import json +import logging import ssl -from collections.abc import Callable from typing import Any from unittest.mock import DEFAULT, MagicMock, Mock, patch -import pytest from aiohttp import ClientSession from aiomqtt import Client, Message from cachetools import TTLCache -from testfixtures import LogCapture +import pytest from deebot_client.authentication import Authenticator -from deebot_client.commands.battery import GetBattery -from deebot_client.commands.volume import SetVolume -from deebot_client.events.event_bus import EventBus +from deebot_client.commands.json.battery import GetBattery +from deebot_client.commands.json.volume import SetVolume +from deebot_client.const import DataType +from deebot_client.event_bus import EventBus from deebot_client.exceptions import AuthenticationError from deebot_client.models import Configuration, DeviceInfo from deebot_client.mqtt_client import MqttClient, MqttConfiguration, SubscriberInfo @@ -79,6 +80,7 @@ async def test_client_reconnect_on_broker_error( mqtt_server: MqttServer, device_info: DeviceInfo, mqtt_config: MqttConfiguration, + caplog: pytest.LogCaptureFixture, ) -> None: (_, callback, _) = await _subscribe(mqtt_client, device_info) async with Client( @@ -90,45 +92,38 @@ async def test_client_reconnect_on_broker_error( # test client cannot be used as we restart the broker in this test await _verify_subscribe(client, device_info, True, callback) - with LogCapture() as log: - mqtt_server.stop() - await asyncio.sleep(0.1) + caplog.clear() + mqtt_server.stop() + await asyncio.sleep(0.1) - log.check_present( - ( - "deebot_client.mqtt_client", - "WARNING", - "Connection lost; Reconnecting in 5 seconds ...", - ) - ) - log.clear() - - mqtt_server.run() - - for i in range(_WAITING_AFTER_RESTART): - print(f"Wait for success reconnect... {i}/{_WAITING_AFTER_RESTART}") - try: - log.check_present( - ( - "deebot_client.mqtt_client", - "DEBUG", - "All mqtt tasks created", - ) - ) - except AssertionError: - pass # Client was not yet connected - else: - async with Client( - hostname=mqtt_config.hostname, - port=mqtt_config.port, - client_id="Test-helper", - tls_context=mqtt_config.ssl_context, - ) as client: - # test client cannot be used as we restart the broker in this test - await _verify_subscribe(client, device_info, True, callback) - return - - await asyncio.sleep(1) + assert ( + "deebot_client.mqtt_client", + logging.WARNING, + "Connection lost; Reconnecting in 5 seconds ...", + ) in caplog.record_tuples + caplog.clear() + + mqtt_server.run() + + expected_log_tuple = ( + "deebot_client.mqtt_client", + logging.DEBUG, + "All mqtt tasks created", + ) + for i in range(_WAITING_AFTER_RESTART): + print(f"Wait for success reconnect... {i}/{_WAITING_AFTER_RESTART}") + if expected_log_tuple in caplog.record_tuples: + async with Client( + hostname=mqtt_config.hostname, + port=mqtt_config.port, + client_id="Test-helper", + tls_context=mqtt_config.ssl_context, + ) as client: + # test client cannot be used as we restart the broker in this test + await _verify_subscribe(client, device_info, True, callback) + return + + await asyncio.sleep(1) pytest.fail("Reconnect failed") @@ -143,7 +138,7 @@ async def test_client_reconnect_on_broker_error( @pytest.mark.parametrize("set_ssl_context", [True, False]) @pytest.mark.parametrize( - "country,hostname,expected_hostname", _test_MqttConfiguration_data + ("country", "hostname", "expected_hostname"), _test_MqttConfiguration_data ) @pytest.mark.parametrize("device_id", ["test", "123"]) def test_MqttConfiguration( @@ -215,12 +210,13 @@ async def _publish_p2p( is_request: bool, request_id: str, test_mqtt_client: Client, + data_type: str = "j", ) -> None: data_bytes = json.dumps(data).encode("utf-8") if is_request: - topic = f"iot/p2p/{command_name}/test/test/test/{device_info.did}/{device_info.get_class}/{device_info.resource}/q/{request_id}/j" + topic = f"iot/p2p/{command_name}/test/test/test/{device_info.did}/{device_info.get_class}/{device_info.resource}/q/{request_id}/{data_type}" else: - topic = f"iot/p2p/{command_name}/{device_info.did}/{device_info.get_class}/{device_info.resource}/test/test/test/p/{request_id}/j" + topic = f"iot/p2p/{command_name}/{device_info.did}/{device_info.get_class}/{device_info.resource}/test/test/test/p/{request_id}/{data_type}" await test_mqtt_client.publish(topic, data_bytes) await asyncio.sleep(0.1) @@ -237,10 +233,12 @@ async def test_p2p_success( command_object = Mock(spec=SetVolume) command_name = SetVolume.name - command_type = Mock(spec=SetVolume, return_value=command_object) + command_type = Mock(spec=SetVolume) + create_from_mqtt = command_type.create_from_mqtt + create_from_mqtt.return_value = command_object with patch.dict( "deebot_client.mqtt_client.COMMANDS_WITH_MQTT_P2P_HANDLING", - {command_name: command_type}, + {DataType.JSON: {command_name: command_type}}, ): request_id = "req" data: dict[str, Any] = {"body": {"data": {"volume": 1}}} @@ -248,7 +246,7 @@ async def test_p2p_success( command_name, device_info, data, True, request_id, test_mqtt_client ) - command_type.assert_called_with(**(data["body"]["data"])) + create_from_mqtt.assert_called_with(data["body"]["data"]) assert len(mqtt_client._received_p2p_commands) == 1 assert mqtt_client._received_p2p_commands[request_id] == command_object @@ -266,27 +264,54 @@ async def test_p2p_not_supported( mqtt_client: MqttClient, device_info: DeviceInfo, test_mqtt_client: Client, + caplog: pytest.LogCaptureFixture, ) -> None: """Test that unsupported command will be logged.""" await _subscribe(mqtt_client, device_info) command_name: str = GetBattery.name - with LogCapture() as log: - await _publish_p2p(command_name, device_info, {}, True, "req", test_mqtt_client) + await _publish_p2p(command_name, device_info, {}, True, "req", test_mqtt_client) - log.check_present( - ( - "deebot_client.mqtt_client", - "DEBUG", - f"Command {command_name} does not support p2p handling (yet)", - ) - ) + assert ( + "deebot_client.mqtt_client", + logging.DEBUG, + f"Command {command_name} does not support p2p handling (yet)", + ) in caplog.record_tuples + + +async def test_p2p_data_type_not_supported( + mqtt_client: MqttClient, caplog: pytest.LogCaptureFixture +) -> None: + """Test that unsupported command will be logged.""" + topic_split = [ + "iot", + "p2p", + "getBattery", + "test", + "test", + "test", + "did", + "get_class", + "resource", + "q", + "req", + "z", + ] + + mqtt_client._handle_p2p(topic_split, "") + + assert ( + "deebot_client.mqtt_client", + logging.WARNING, + 'Unsupported data type: "z"', + ) in caplog.record_tuples async def test_p2p_to_late( mqtt_client: MqttClient, device_info: DeviceInfo, test_mqtt_client: Client, + caplog: pytest.LogCaptureFixture, ) -> None: """Test p2p when response comes in to late.""" # reduce ttl to 1 seconds @@ -296,10 +321,12 @@ async def test_p2p_to_late( command_object = Mock(spec=SetVolume) command_name = SetVolume.name - command_type = Mock(spec=SetVolume, return_value=command_object) + command_type = Mock(spec=SetVolume) + create_from_mqtt = command_type.create_from_mqtt + create_from_mqtt.return_value = command_object with patch.dict( "deebot_client.mqtt_client.COMMANDS_WITH_MQTT_P2P_HANDLING", - {command_name: command_type}, + {DataType.JSON: {command_name: command_type}}, ): request_id = "req" data: dict[str, Any] = {"body": {"data": {"volume": 1}}} @@ -307,32 +334,30 @@ async def test_p2p_to_late( command_name, device_info, data, True, request_id, test_mqtt_client ) - command_type.assert_called_with(**(data["body"]["data"])) + create_from_mqtt.assert_called_with(data["body"]["data"]) assert len(mqtt_client._received_p2p_commands) == 1 assert mqtt_client._received_p2p_commands[request_id] == command_object - with LogCapture() as log: - await asyncio.sleep(1.1) + await asyncio.sleep(1.1) - data = {"body": {"data": {"ret": "ok"}}} - await _publish_p2p( - command_name, device_info, data, False, request_id, test_mqtt_client - ) + data = {"body": {"data": {"ret": "ok"}}} + await _publish_p2p( + command_name, device_info, data, False, request_id, test_mqtt_client + ) - command_object.handle_mqtt_p2p.assert_not_called() - log.check_present( - ( - "deebot_client.mqtt_client", - "DEBUG", - f"Response to command came in probably to late. requestId={request_id}, commandName={command_name}", - ) - ) + command_object.handle_mqtt_p2p.assert_not_called() + assert ( + "deebot_client.mqtt_client", + logging.DEBUG, + f"Response to command came in probably to late. requestId={request_id}, commandName={command_name}", + ) in caplog.record_tuples async def test_p2p_parse_error( mqtt_client: MqttClient, device_info: DeviceInfo, test_mqtt_client: Client, + caplog: pytest.LogCaptureFixture, ) -> None: """Test p2p parse error.""" await _subscribe(mqtt_client, device_info) @@ -347,22 +372,19 @@ async def test_p2p_parse_error( request_id = "req" data: dict[str, Any] = {"volume": 1} - with LogCapture() as log: - await _publish_p2p( - command_name, device_info, data, True, request_id, test_mqtt_client - ) + await _publish_p2p( + command_name, device_info, data, True, request_id, test_mqtt_client + ) - log.check_present( - ( - "deebot_client.mqtt_client", - "WARNING", - f"Could not parse p2p payload: topic=iot/p2p/{command_name}/test/test/test/did/get_class/resource/q/{request_id}/j; payload={data}", - ) - ) + assert ( + "deebot_client.mqtt_client", + logging.WARNING, + f"Could not parse p2p payload: topic=iot/p2p/{command_name}/test/test/test/did/get_class/resource/q/{request_id}/j; payload={data}", + ) in caplog.record_tuples @pytest.mark.parametrize( - "exception_to_raise, expected_log_message", + ("exception_to_raise", "expected_log_message"), [ ( AuthenticationError, @@ -376,29 +398,27 @@ async def test_mqtt_task_exceptions( mqtt_config: MqttConfiguration, exception_to_raise: Exception, expected_log_message: str, + caplog: pytest.LogCaptureFixture, ) -> None: with patch( "deebot_client.mqtt_client.Client", MagicMock(side_effect=[exception_to_raise, DEFAULT]), ): - with LogCapture() as log: - mqtt_client = MqttClient(mqtt_config, authenticator) + mqtt_client = MqttClient(mqtt_config, authenticator) - await mqtt_client.connect() - await asyncio.sleep(0.1) + await mqtt_client.connect() + await asyncio.sleep(0.1) - log.check_present( - ( - "deebot_client.mqtt_client", - "ERROR", - expected_log_message, - ) - ) + assert ( + "deebot_client.mqtt_client", + logging.ERROR, + expected_log_message, + ) in caplog.record_tuples - assert mqtt_client._mqtt_task - assert mqtt_client._mqtt_task.done() + assert mqtt_client._mqtt_task + assert mqtt_client._mqtt_task.done() - await mqtt_client.connect() - await asyncio.sleep(0.1) + await mqtt_client.connect() + await asyncio.sleep(0.1) - assert not mqtt_client._mqtt_task.done() + assert not mqtt_client._mqtt_task.done() diff --git a/tests/test_vacuum_bot.py b/tests/test_vacuum_bot.py deleted file mode 100644 index 3aef0877..00000000 --- a/tests/test_vacuum_bot.py +++ /dev/null @@ -1,96 +0,0 @@ -import asyncio -import json -from collections.abc import Callable -from unittest.mock import Mock, patch - -from deebot_client.authentication import Authenticator -from deebot_client.events import AvailabilityEvent -from deebot_client.models import DeviceInfo -from deebot_client.mqtt_client import MqttClient, SubscriberInfo -from deebot_client.vacuum_bot import VacuumBot - - -@patch("deebot_client.vacuum_bot._AVAILABLE_CHECK_INTERVAL", 2) # reduce interval -@patch( - "deebot_client.events.const.EVENT_DTO_REFRESH_COMMANDS", {} -) # disable refresh on event subscription -async def test_available_check_and_teardown( - authenticator: Authenticator, device_info: DeviceInfo -) -> None: - """Test the available check including if the status Event is fired correctly.""" - received_statuses: asyncio.Queue[AvailabilityEvent] = asyncio.Queue() - - async def on_status(event: AvailabilityEvent) -> None: - received_statuses.put_nowait(event) - - async def assert_received_status(expected: bool) -> None: - await asyncio.sleep(0.1) - assert received_statuses.get_nowait().available is expected - - with patch("deebot_client.vacuum_bot.GetBattery", spec_set=True) as battery_command: - # prepare mocks - execute_mock = battery_command.return_value.execute - - # prepare bot and mock mqtt - bot = VacuumBot(device_info, authenticator) - mqtt_client = Mock(spec=MqttClient) - unsubscribe_mock = Mock(spec=Callable[[], None]) - mqtt_client.subscribe.return_value = unsubscribe_mock - await bot.initialize(mqtt_client) - - bot.events.subscribe(AvailabilityEvent, on_status) - - # verify mqtt was subscribed and available task was started - mqtt_client.subscribe.assert_called_once() - sub_info: SubscriberInfo = mqtt_client.subscribe.call_args.args[0] - assert bot._available_task is not None - assert not bot._available_task.done() - # As task was started now, no check should be performed - execute_mock.assert_not_called() - - # Simulate bot not reached by returning False - execute_mock.return_value = False - - # Wait longer than the interval to be sure task will be executed - await asyncio.sleep(2.1) - # Verify command call for available check - execute_mock.assert_awaited_once() - await assert_received_status(False) - - # Simulate bot reached by returning True - execute_mock.return_value = True - - await asyncio.sleep(2) - execute_mock.await_count = 2 - await assert_received_status(True) - - # reset mock for easier handling - battery_command.reset_mock() - - # Simulate message over mqtt and therefore available is not needed - await asyncio.sleep(0.8) - data = { - "header": { - "pri": 1, - "tzm": 480, - "ts": "1304637391896", - "ver": "0.0.1", - "fwVer": "1.8.2", - "hwVer": "0.1.1", - }, - "body": {"data": {"value": 100, "isLow": 0}}, - } - - sub_info.callback("onBattery", json.dumps(data)) - await asyncio.sleep(1) - - # As the last message is not more than (interval-1) old, we skip the available check - execute_mock.assert_not_called() - assert received_statuses.empty() - - # teardown bot and verify that bot was unsubscribed from mqtt and available task was canceled. - await bot.teardown() - await asyncio.sleep(0.1) - - unsubscribe_mock.assert_called() - assert bot._available_task.done()