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)
@@ -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()