From 839dee94471f51c071dcc67c25cd796770b4e179 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 28 Aug 2023 00:58:27 +0200 Subject: [PATCH 01/41] Add support for multiple data types (#292) * split messages * adjust message tests * prepare for multiple data types * improve tests --- deebot_client/command.py | 50 ++++---- deebot_client/commands/__init__.py | 114 ++---------------- deebot_client/commands/json/__init__.py | 111 +++++++++++++++++ .../commands/{ => json}/advanced_mode.py | 3 +- deebot_client/commands/{ => json}/battery.py | 3 +- deebot_client/commands/{ => json}/carpet.py | 3 +- deebot_client/commands/{ => json}/charge.py | 9 +- .../commands/{ => json}/charge_state.py | 7 +- deebot_client/commands/{ => json}/clean.py | 13 +- .../commands/{ => json}/clean_count.py | 5 +- .../commands/{ => json}/clean_logs.py | 18 +-- .../commands/{ => json}/clean_preference.py | 3 +- deebot_client/commands/{ => json}/common.py | 51 +++++--- deebot_client/commands/{ => json}/const.py | 0 .../{ => json}/continuous_cleaning.py | 3 +- deebot_client/commands/{ => json}/custom.py | 13 +- deebot_client/commands/{ => json}/error.py | 7 +- .../commands/{ => json}/fan_speed.py | 5 +- .../commands/{ => json}/life_span.py | 15 +-- deebot_client/commands/{ => json}/map.py | 11 +- .../commands/{ => json}/multimap_state.py | 3 +- .../commands/{ => json}/play_sound.py | 0 deebot_client/commands/{ => json}/pos.py | 5 +- .../commands/{ => json}/relocation.py | 0 deebot_client/commands/{ => json}/stats.py | 5 +- .../commands/{ => json}/true_detect.py | 3 +- deebot_client/commands/{ => json}/volume.py | 5 +- .../commands/{ => json}/water_info.py | 5 +- deebot_client/commands/xml/common.py | 30 +++++ deebot_client/const.py | 18 +++ deebot_client/events/const.py | 2 +- deebot_client/map.py | 2 +- deebot_client/message.py | 62 +++++++--- deebot_client/messages/__init__.py | 32 +++-- deebot_client/messages/json/__init__.py | 18 +++ deebot_client/messages/{ => json}/battery.py | 6 +- deebot_client/messages/{ => json}/stats.py | 6 +- deebot_client/models.py | 7 ++ deebot_client/mqtt_client.py | 18 ++- deebot_client/vacuum_bot.py | 4 +- pylintrc | 6 +- requirements.txt | 1 + tests/commands/__init__.py | 86 ------------- tests/commands/json/__init__.py | 85 +++++++++++++ .../commands/{ => json}/test_advanced_mode.py | 5 +- tests/commands/{ => json}/test_battery.py | 5 +- tests/commands/{ => json}/test_carpet.py | 5 +- tests/commands/{ => json}/test_charge.py | 7 +- .../commands/{ => json}/test_charge_state.py | 5 +- tests/commands/{ => json}/test_clean.py | 7 +- tests/commands/{ => json}/test_clean_count.py | 5 +- tests/commands/{ => json}/test_clean_log.py | 7 +- .../{ => json}/test_clean_preference.py | 5 +- tests/commands/{ => json}/test_common.py | 10 +- .../{ => json}/test_continuous_cleaning.py | 5 +- tests/commands/{ => json}/test_custom.py | 5 +- tests/commands/{ => json}/test_fan_speed.py | 6 +- tests/commands/{ => json}/test_life_span.py | 5 +- tests/commands/{ => json}/test_map.py | 5 +- .../{ => json}/test_mulitmap_state.py | 5 +- tests/commands/{ => json}/test_true_detect.py | 5 +- tests/commands/{ => json}/test_water_info.py | 5 +- tests/messages/json/__init__.py | 0 tests/messages/{ => json}/test_battery.py | 4 +- tests/messages/{ => json}/test_stats.py | 2 +- tests/messages/test_get_messages.py | 24 ++-- tests/messages/test_messages.py | 5 +- tests/test_mqtt_client.py | 45 ++++++- 68 files changed, 631 insertions(+), 404 deletions(-) create mode 100644 deebot_client/commands/json/__init__.py rename deebot_client/commands/{ => json}/advanced_mode.py (87%) rename deebot_client/commands/{ => json}/battery.py (85%) rename deebot_client/commands/{ => json}/carpet.py (88%) rename deebot_client/commands/{ => json}/charge.py (82%) rename deebot_client/commands/{ => json}/charge_state.py (90%) rename deebot_client/commands/{ => json}/clean.py (92%) rename deebot_client/commands/{ => json}/clean_count.py (88%) rename deebot_client/commands/{ => json}/clean_logs.py (83%) rename deebot_client/commands/{ => json}/clean_preference.py (88%) rename deebot_client/commands/{ => json}/common.py (81%) rename deebot_client/commands/{ => json}/const.py (100%) rename deebot_client/commands/{ => json}/continuous_cleaning.py (88%) rename deebot_client/commands/{ => json}/custom.py (78%) rename deebot_client/commands/{ => json}/error.py (93%) rename deebot_client/commands/{ => json}/fan_speed.py (88%) rename deebot_client/commands/{ => json}/life_span.py (83%) rename deebot_client/commands/{ => json}/map.py (97%) rename deebot_client/commands/{ => json}/multimap_state.py (88%) rename deebot_client/commands/{ => json}/play_sound.py (100%) rename deebot_client/commands/{ => json}/pos.py (90%) rename deebot_client/commands/{ => json}/relocation.py (100%) rename deebot_client/commands/{ => json}/stats.py (90%) rename deebot_client/commands/{ => json}/true_detect.py (88%) rename deebot_client/commands/{ => json}/volume.py (89%) rename deebot_client/commands/{ => json}/water_info.py (90%) create mode 100644 deebot_client/commands/xml/common.py create mode 100644 deebot_client/messages/json/__init__.py rename deebot_client/messages/{ => json}/battery.py (74%) rename deebot_client/messages/{ => json}/stats.py (84%) create mode 100644 tests/commands/json/__init__.py rename tests/commands/{ => json}/test_advanced_mode.py (81%) rename tests/commands/{ => json}/test_battery.py (80%) rename tests/commands/{ => json}/test_carpet.py (81%) rename tests/commands/{ => json}/test_charge.py (89%) rename tests/commands/{ => json}/test_charge_state.py (81%) rename tests/commands/{ => json}/test_clean.py (91%) rename tests/commands/{ => json}/test_clean_count.py (78%) rename tests/commands/{ => json}/test_clean_log.py (97%) rename tests/commands/{ => json}/test_clean_preference.py (81%) rename tests/commands/{ => json}/test_common.py (90%) rename tests/commands/{ => json}/test_continuous_cleaning.py (81%) rename tests/commands/{ => json}/test_custom.py (86%) rename tests/commands/{ => json}/test_fan_speed.py (82%) rename tests/commands/{ => json}/test_life_span.py (90%) rename tests/commands/{ => json}/test_map.py (98%) rename tests/commands/{ => json}/test_mulitmap_state.py (81%) rename tests/commands/{ => json}/test_true_detect.py (81%) rename tests/commands/{ => json}/test_water_info.py (92%) create mode 100644 tests/messages/json/__init__.py rename tests/messages/{ => json}/test_battery.py (84%) rename tests/messages/{ => json}/test_stats.py (96%) diff --git a/deebot_client/command.py b/deebot_client/command.py index 0beb7857..d3a075c5 100644 --- a/deebot_client/command.py +++ b/deebot_client/command.py @@ -2,11 +2,10 @@ import asyncio from abc import ABC, abstractmethod from dataclasses import dataclass, field -from datetime import datetime from typing import Any, final from .authentication import Authenticator -from .const import PATH_API_IOT_DEVMANAGER, REQUEST_HEADERS +from .const import PATH_API_IOT_DEVMANAGER, REQUEST_HEADERS, DataType from .events.event_bus import EventBus from .logging_filter import get_logger from .message import HandlingResult, HandlingState @@ -48,6 +47,16 @@ 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 | str: + """Get the payload for the rest call.""" + @final async def execute( self, authenticator: Authenticator, device_info: DeviceInfo, event_bus: EventBus @@ -95,30 +104,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 +123,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 +134,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 +184,11 @@ def __eq__(self, obj: object) -> bool: def __hash__(self) -> int: return hash(self.name) + hash(self._args) + + +class CommandMqttP2P(Command, ABC): + """Command which can handle 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".""" diff --git a/deebot_client/commands/__init__.py b/deebot_client/commands/__init__.py index cfbf7ea5..9f3e4709 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 +from .json import ( + 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..7c4932a3 --- /dev/null +++ b/deebot_client/commands/json/__init__.py @@ -0,0 +1,111 @@ +"""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 .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[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, + + PlaySound, + + GetPos, + + SetRelocationState, + + GetStats, + GetTotalStats, + + GetTrueDetect, + SetTrueDetect, + + GetVolume, + SetVolume, + + GetWaterInfo, + SetWaterInfo, +] +# 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 85% rename from deebot_client/commands/battery.py rename to deebot_client/commands/json/battery.py index d28a63ff..5a812f9a 100644 --- a/deebot_client/commands/battery.py +++ b/deebot_client/commands/json/battery.py @@ -1,5 +1,6 @@ """Battery commands.""" -from ..messages import OnBattery +from deebot_client.messages.json import OnBattery + from .common import NoArgsCommand 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 82% rename from deebot_client/commands/charge.py rename to deebot_client/commands/json/charge.py index 3380aa74..6c853187 100644 --- a/deebot_client/commands/charge.py +++ b/deebot_client/commands/json/charge.py @@ -1,10 +1,11 @@ """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 deebot_client.events import StateEvent +from deebot_client.logging_filter import get_logger +from deebot_client.message import HandlingResult +from deebot_client.models import VacuumState + from .common import EventBus, ExecuteCommand from .const import CODE diff --git a/deebot_client/commands/charge_state.py b/deebot_client/commands/json/charge_state.py similarity index 90% rename from deebot_client/commands/charge_state.py rename to deebot_client/commands/json/charge_state.py index c4c1562e..1399daf8 100644 --- a/deebot_client/commands/charge_state.py +++ b/deebot_client/commands/json/charge_state.py @@ -1,9 +1,10 @@ """Charge state commands.""" from typing import Any -from ..events import StateEvent -from ..message import HandlingResult, MessageBodyDataDict -from ..models import VacuumState +from deebot_client.events import StateEvent +from deebot_client.message import HandlingResult, MessageBodyDataDict +from deebot_client.models import VacuumState + from .common import EventBus, NoArgsCommand from .const import CODE diff --git a/deebot_client/commands/clean.py b/deebot_client/commands/json/clean.py similarity index 92% rename from deebot_client/commands/clean.py rename to deebot_client/commands/json/clean.py index 5e7ad795..7623de06 100644 --- a/deebot_client/commands/clean.py +++ b/deebot_client/commands/json/clean.py @@ -2,12 +2,13 @@ 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 deebot_client.authentication import Authenticator +from deebot_client.command import CommandResult +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 DeviceInfo, VacuumState + from .common import EventBus, ExecuteCommand, NoArgsCommand _LOGGER = get_logger(__name__) diff --git a/deebot_client/commands/clean_count.py b/deebot_client/commands/json/clean_count.py similarity index 88% rename from deebot_client/commands/clean_count.py rename to deebot_client/commands/json/clean_count.py index 3d95b7e5..f3ba8ea8 100644 --- a/deebot_client/commands/clean_count.py +++ b/deebot_client/commands/json/clean_count.py @@ -3,8 +3,9 @@ from collections.abc import Mapping from typing import Any -from ..events import CleanCountEvent -from ..message import HandlingResult, MessageBodyDataDict +from deebot_client.events import CleanCountEvent +from deebot_client.message import HandlingResult, MessageBodyDataDict + from .common import EventBus, NoArgsCommand, SetCommand diff --git a/deebot_client/commands/clean_logs.py b/deebot_client/commands/json/clean_logs.py similarity index 83% rename from deebot_client/commands/clean_logs.py rename to deebot_client/commands/json/clean_logs.py index 49b109d8..7d317640 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.events import CleanJobStatus, CleanLogEntry, CleanLogEvent +from deebot_client.events.event_bus import EventBus +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 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 81% rename from deebot_client/commands/common.py rename to deebot_client/commands/json/common.py index 0ca901a3..ecf84aa0 100644 --- a/deebot_client/commands/common.py +++ b/deebot_client/commands/json/common.py @@ -1,19 +1,48 @@ """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 +from deebot_client.const import DataType +from deebot_client.events import AvailabilityEvent, EnableEvent +from deebot_client.events.event_bus import EventBus +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: + 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 @@ -56,14 +85,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.""" @@ -88,7 +109,7 @@ 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. 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 78% rename from deebot_client/commands/custom.py rename to deebot_client/commands/json/custom.py index edd0ba46..2e8a035a 100644 --- a/deebot_client/commands/custom.py +++ b/deebot_client/commands/json/custom.py @@ -1,15 +1,16 @@ """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, EventBus +from deebot_client.commands.json.common import JsonCommand +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" @@ -46,5 +47,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: return self._args diff --git a/deebot_client/commands/error.py b/deebot_client/commands/json/error.py similarity index 93% rename from deebot_client/commands/error.py rename to deebot_client/commands/json/error.py index 3bfc98cd..275a45d8 100644 --- a/deebot_client/commands/error.py +++ b/deebot_client/commands/json/error.py @@ -1,9 +1,10 @@ """Error commands.""" from typing import Any -from ..events import ErrorEvent, StateEvent -from ..message import HandlingResult, MessageBodyDataDict -from ..models import VacuumState +from deebot_client.events import ErrorEvent, StateEvent +from deebot_client.message import HandlingResult, MessageBodyDataDict +from deebot_client.models import VacuumState + from .common import EventBus, NoArgsCommand diff --git a/deebot_client/commands/fan_speed.py b/deebot_client/commands/json/fan_speed.py similarity index 88% rename from deebot_client/commands/fan_speed.py rename to deebot_client/commands/json/fan_speed.py index 26e129e8..dca6fa15 100644 --- a/deebot_client/commands/fan_speed.py +++ b/deebot_client/commands/json/fan_speed.py @@ -2,8 +2,9 @@ from collections.abc import Mapping from typing import Any -from ..events import FanSpeedEvent, FanSpeedLevel -from ..message import HandlingResult, MessageBodyDataDict +from deebot_client.events import FanSpeedEvent, FanSpeedLevel +from deebot_client.message import HandlingResult, MessageBodyDataDict + from .common import EventBus, NoArgsCommand, SetCommand diff --git a/deebot_client/commands/life_span.py b/deebot_client/commands/json/life_span.py similarity index 83% rename from deebot_client/commands/life_span.py rename to deebot_client/commands/json/life_span.py index 29bb8ea3..b0630d8a 100644 --- a/deebot_client/commands/life_span.py +++ b/deebot_client/commands/json/life_span.py @@ -1,14 +1,11 @@ """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 +from deebot_client.events import LifeSpan, LifeSpanEvent +from deebot_client.message import HandlingResult, HandlingState, MessageBodyDataList + +from .common import CommandWithMessageHandling, EventBus, ExecuteCommand class GetLifeSpan(CommandWithMessageHandling, MessageBodyDataList): @@ -40,7 +37,7 @@ 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" diff --git a/deebot_client/commands/map.py b/deebot_client/commands/json/map.py similarity index 97% rename from deebot_client/commands/map.py rename to deebot_client/commands/json/map.py index f2a0667e..42bd857f 100644 --- a/deebot_client/commands/map.py +++ b/deebot_client/commands/json/map.py @@ -1,8 +1,8 @@ """Maps commands.""" from typing import Any -from ..command import Command -from ..events import ( +from deebot_client.command import Command +from deebot_client.events import ( MajorMapEvent, MapSetEvent, MapSetType, @@ -10,9 +10,10 @@ MapTraceEvent, MinorMapEvent, ) -from ..events.event_bus import EventBus -from ..events.map import CachedMapInfoEvent -from ..message import HandlingResult, HandlingState, MessageBodyDataDict +from deebot_client.events.event_bus import EventBus +from deebot_client.events.map import CachedMapInfoEvent +from deebot_client.message import HandlingResult, HandlingState, MessageBodyDataDict + from .common import CommandResult, CommandWithMessageHandling 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/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 90% rename from deebot_client/commands/pos.py rename to deebot_client/commands/json/pos.py index 28acc67a..db20f13c 100644 --- a/deebot_client/commands/pos.py +++ b/deebot_client/commands/json/pos.py @@ -2,8 +2,9 @@ from typing import Any -from ..events import Position, PositionsEvent, PositionType -from ..message import HandlingResult, MessageBodyDataDict +from deebot_client.events import Position, PositionsEvent, PositionType +from deebot_client.message import HandlingResult, MessageBodyDataDict + from .common import CommandWithMessageHandling, EventBus 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 90% rename from deebot_client/commands/stats.py rename to deebot_client/commands/json/stats.py index 5fe8a4f7..04837710 100644 --- a/deebot_client/commands/stats.py +++ b/deebot_client/commands/json/stats.py @@ -1,8 +1,9 @@ """Stats commands.""" from typing import Any -from ..events import StatsEvent, TotalStatsEvent -from ..message import HandlingResult, MessageBodyDataDict +from deebot_client.events import StatsEvent, TotalStatsEvent +from deebot_client.message import HandlingResult, MessageBodyDataDict + from .common import EventBus, NoArgsCommand 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 89% rename from deebot_client/commands/volume.py rename to deebot_client/commands/json/volume.py index 2ec4d57d..3dd8c4ae 100644 --- a/deebot_client/commands/volume.py +++ b/deebot_client/commands/json/volume.py @@ -3,8 +3,9 @@ from collections.abc import Mapping from typing import Any -from ..events import VolumeEvent -from ..message import HandlingResult, MessageBodyDataDict +from deebot_client.events import VolumeEvent +from deebot_client.message import HandlingResult, MessageBodyDataDict + from .common import EventBus, NoArgsCommand, SetCommand diff --git a/deebot_client/commands/water_info.py b/deebot_client/commands/json/water_info.py similarity index 90% rename from deebot_client/commands/water_info.py rename to deebot_client/commands/json/water_info.py index 0e09faa7..cb09e358 100644 --- a/deebot_client/commands/water_info.py +++ b/deebot_client/commands/json/water_info.py @@ -2,8 +2,9 @@ from collections.abc import Mapping from typing import Any -from ..events import WaterAmount, WaterInfoEvent -from ..message import HandlingResult, MessageBodyDataDict +from deebot_client.events import WaterAmount, WaterInfoEvent +from deebot_client.message import HandlingResult, MessageBodyDataDict + from .common import EventBus, NoArgsCommand, SetCommand diff --git a/deebot_client/commands/xml/common.py b/deebot_client/commands/xml/common.py new file mode 100644 index 00000000..b08bcaec --- /dev/null +++ b/deebot_client/commands/xml/common.py @@ -0,0 +1,30 @@ +"""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()) + + for key in self._args: + element.set(key, self._args[key]) + + 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/events/const.py b/deebot_client/events/const.py index 88538ca4..03cb711c 100644 --- a/deebot_client/events/const.py +++ b/deebot_client/events/const.py @@ -2,7 +2,7 @@ from collections.abc import Mapping from ..command import Command -from ..commands import ( +from ..commands.json import ( GetAdvancedMode, GetBattery, GetCachedMapInfo, diff --git a/deebot_client/map.py b/deebot_client/map.py index 088f47ab..8d5c4cf8 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -18,7 +18,7 @@ from deebot_client.events.map import MapChangedEvent from .command import Command -from .commands import GetCachedMapInfo, GetMinorMap +from .commands.json import GetCachedMapInfo, GetMinorMap from .events import ( MajorMapEvent, MapSetEvent, diff --git a/deebot_client/message.py b/deebot_client/message.py index 14aaee55..1708631c 100644 --- a/deebot_client/message.py +++ b/deebot_client/message.py @@ -4,7 +4,7 @@ from collections.abc import Callable from dataclasses import dataclass from enum import IntEnum, auto -from typing import Any, final +from typing import Any, TypeVar, final from .events.event_bus import EventBus from .logging_filter import get_logger @@ -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,19 +117,22 @@ 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 @@ -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 @@ -163,7 +197,7 @@ def _handle_body_data( class MessageBodyDataList(MessageBodyData): - """Message with handling body->data->list code.""" + """Dict message with body->data attribute as list.""" @classmethod @abstractmethod diff --git a/deebot_client/messages/__init__.py b/deebot_client/messages/__init__.py index 2e93165a..71afa72b 100644 --- a/deebot_client/messages/__init__.py +++ b/deebot_client/messages/__init__.py @@ -3,32 +3,30 @@ import re +from deebot_client.const import DataType + from ..logging_filter import get_logger from ..message import Message -from .battery import OnBattery -from .stats import ReportStats +from .json import MESSAGES as JSON_MESSAGES _LOGGER = get_logger(__name__) -# 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] +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 @@ -48,7 +46,7 @@ def get_message(message_name: str) -> type[Message] | None: from ..commands import COMMANDS # pylint: disable=import-outside-toplevel - 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..2b09576f --- /dev/null +++ b/deebot_client/messages/json/__init__.py @@ -0,0 +1,18 @@ +"""Json messages.""" + + +from deebot_client.message import Message + +from .battery import OnBattery +from .stats import 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 74% rename from deebot_client/messages/battery.py rename to deebot_client/messages/json/battery.py index 6ff75563..c93fafa7 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.events import BatteryEvent +from deebot_client.events.event_bus import EventBus +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..310ae98e 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.events import CleanJobStatus, ReportStatsEvent +from deebot_client.events.event_bus import EventBus +from deebot_client.message import HandlingResult, MessageBodyDataDict class ReportStats(MessageBodyDataDict): diff --git a/deebot_client/models.py b/deebot_client/models.py index 465cbca5..cc0a9e62 100644 --- a/deebot_client/models.py +++ b/deebot_client/models.py @@ -5,6 +5,8 @@ from aiohttp import ClientSession +from deebot_client.const import DataType + class DeviceInfo(dict): """Class holds all values, which we get from api. Common values can be accessed through properties.""" @@ -49,6 +51,11 @@ def get_class(self) -> str: """Return device class.""" return str(self["class"]) + @property + def data_type(self) -> DataType: + """Return data type.""" + return DataType.JSON + @dataclass(frozen=True) class Room: diff --git a/deebot_client/mqtt_client.py b/deebot_client/mqtt_client.py index ba41580a..bfd44485 100644 --- a/deebot_client/mqtt_client.py +++ b/deebot_client/mqtt_client.py @@ -10,11 +10,13 @@ from aiomqtt import Client, Message, MqttError from cachetools import TTLCache +from deebot_client.command import CommandMqttP2P +from deebot_client.const import DataType from deebot_client.events.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 @@ -97,9 +99,9 @@ def __init__( ] = asyncio.Queue() self._mqtt_task: asyncio.Task | 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: @@ -258,8 +260,14 @@ 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 diff --git a/deebot_client/vacuum_bot.py b/deebot_client/vacuum_bot.py index e33523ab..73bdb1c1 100644 --- a/deebot_client/vacuum_bot.py +++ b/deebot_client/vacuum_bot.py @@ -6,7 +6,7 @@ from datetime import datetime from typing import Any, Final -from deebot_client.commands.battery import GetBattery +from deebot_client.commands.json.battery import GetBattery from deebot_client.mqtt_client import MqttClient, SubscriberInfo from .authentication import Authenticator @@ -164,7 +164,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: diff --git a/pylintrc b/pylintrc index 10e43a1e..9df831de 100644 --- a/pylintrc +++ b/pylintrc @@ -76,8 +76,8 @@ expected-line-ending-format=LF # Exceptions that will emit a warning when being caught. Defaults to # "BaseException, Exception". -overgeneral-exceptions=BaseException, - Exception +overgeneral-exceptions=builtins.BaseException, + builtins.Exception [DESIGN] -max-parents=8 \ No newline at end of file +max-parents=10 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index cd2f1a9c..1a233e7d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ aiohttp>=3.8.5,<3.9 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 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..cceac162 --- /dev/null +++ b/tests/commands/json/__init__.py @@ -0,0 +1,85 @@ +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.json.common import SetCommand +from deebot_client.events import Event +from deebot_client.events.event_bus import EventBus +from deebot_client.models import Credentials, DeviceInfo +from tests.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/test_advanced_mode.py b/tests/commands/json/test_advanced_mode.py similarity index 81% rename from tests/commands/test_advanced_mode.py rename to tests/commands/json/test_advanced_mode.py index 45fbc9c6..4a3c18fa 100644 --- a/tests/commands/test_advanced_mode.py +++ b/tests/commands/json/test_advanced_mode.py @@ -1,10 +1,11 @@ import pytest -from deebot_client.commands import GetAdvancedMode, SetAdvancedMode +from deebot_client.commands.json 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 +from . import assert_command, assert_set_command + @pytest.mark.parametrize("value", [False, True]) async def test_GetAdvancedMode(value: bool) -> None: diff --git a/tests/commands/test_battery.py b/tests/commands/json/test_battery.py similarity index 80% rename from tests/commands/test_battery.py rename to tests/commands/json/test_battery.py index bac3fe5a..12afffcc 100644 --- a/tests/commands/test_battery.py +++ b/tests/commands/json/test_battery.py @@ -1,10 +1,11 @@ import pytest -from deebot_client.commands import GetBattery +from deebot_client.commands.json import GetBattery from deebot_client.events import BatteryEvent -from tests.commands import assert_command from tests.helpers import get_request_json +from . import assert_command + @pytest.mark.parametrize("percentage", [0, 49, 100]) async def test_GetBattery(percentage: int) -> None: diff --git a/tests/commands/test_carpet.py b/tests/commands/json/test_carpet.py similarity index 81% rename from tests/commands/test_carpet.py rename to tests/commands/json/test_carpet.py index a7f1c2e5..d1a2d399 100644 --- a/tests/commands/test_carpet.py +++ b/tests/commands/json/test_carpet.py @@ -1,10 +1,11 @@ import pytest -from deebot_client.commands import GetCarpetAutoFanBoost, SetCarpetAutoFanBoost +from deebot_client.commands.json 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 +from . import assert_command, assert_set_command + @pytest.mark.parametrize("value", [False, True]) async def test_GetCarpetAutoFanBoost(value: bool) -> None: diff --git a/tests/commands/test_charge.py b/tests/commands/json/test_charge.py similarity index 89% rename from tests/commands/test_charge.py rename to tests/commands/json/test_charge.py index 2bfae2c0..ff46550c 100644 --- a/tests/commands/test_charge.py +++ b/tests/commands/json/test_charge.py @@ -3,12 +3,13 @@ import pytest from testfixtures import LogCapture -from deebot_client.commands import Charge +from deebot_client.commands.json 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 +from . import assert_command + def _prepare_json(code: int, msg: str = "ok") -> dict[str, Any]: json = get_request_json(None) @@ -39,7 +40,7 @@ async def test_Charge_failed() -> None: log.check_present( ( - "deebot_client.commands.common", + "deebot_client.commands.json.common", "WARNING", f"Command \"charge\" was not successfully. body={json['resp']['body']}", ) diff --git a/tests/commands/test_charge_state.py b/tests/commands/json/test_charge_state.py similarity index 81% rename from tests/commands/test_charge_state.py rename to tests/commands/json/test_charge_state.py index 179a6858..4d29beee 100644 --- a/tests/commands/test_charge_state.py +++ b/tests/commands/json/test_charge_state.py @@ -2,11 +2,12 @@ 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 . import assert_command + @pytest.mark.parametrize( "json, expected", diff --git a/tests/commands/test_clean.py b/tests/commands/json/test_clean.py similarity index 91% rename from tests/commands/test_clean.py rename to tests/commands/json/test_clean.py index e4d6f13e..d6119388 100644 --- a/tests/commands/test_clean.py +++ b/tests/commands/json/test_clean.py @@ -4,14 +4,15 @@ 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.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 . import assert_command + @pytest.mark.parametrize( "json, expected", diff --git a/tests/commands/test_clean_count.py b/tests/commands/json/test_clean_count.py similarity index 78% rename from tests/commands/test_clean_count.py rename to tests/commands/json/test_clean_count.py index 1ad076e8..ee60c0c2 100644 --- a/tests/commands/test_clean_count.py +++ b/tests/commands/json/test_clean_count.py @@ -1,10 +1,11 @@ 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 . import assert_command, assert_set_command + async def test_GetCleanCount() -> None: json = get_request_json({"count": 2}) diff --git a/tests/commands/test_clean_log.py b/tests/commands/json/test_clean_log.py similarity index 97% rename from tests/commands/test_clean_log.py rename to tests/commands/json/test_clean_log.py index cf910d24..d7e6bcf6 100644 --- a/tests/commands/test_clean_log.py +++ b/tests/commands/json/test_clean_log.py @@ -3,9 +3,10 @@ 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: @@ -109,7 +110,7 @@ async def test_GetCleanLogs() -> None: log.check_present( ( - "deebot_client.commands.clean_logs", + "deebot_client.commands.json.clean_logs", "WARNING", "Skipping log entry: {'ts': 1655564616, 'invalid': 'event'}", ) diff --git a/tests/commands/test_clean_preference.py b/tests/commands/json/test_clean_preference.py similarity index 81% rename from tests/commands/test_clean_preference.py rename to tests/commands/json/test_clean_preference.py index 45cff1af..329fcb5d 100644 --- a/tests/commands/test_clean_preference.py +++ b/tests/commands/json/test_clean_preference.py @@ -1,10 +1,11 @@ import pytest -from deebot_client.commands import GetCleanPreference, SetCleanPreference +from deebot_client.commands.json 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 +from . import assert_command, assert_set_command + @pytest.mark.parametrize("value", [False, True]) async def test_GetCleanPreference(value: bool) -> None: diff --git a/tests/commands/test_common.py b/tests/commands/json/test_common.py similarity index 90% rename from tests/commands/test_common.py rename to tests/commands/json/test_common.py index 4fc734d1..270cba3a 100644 --- a/tests/commands/test_common.py +++ b/tests/commands/json/test_common.py @@ -5,9 +5,9 @@ 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.events import AvailabilityEvent from deebot_client.events.event_bus import EventBus from deebot_client.models import DeviceInfo @@ -80,7 +80,7 @@ async def test_common_functionality( if repsonse_json.get("errno") == 500 and command._is_available_check: log.check_present( ( - "deebot_client.commands.common", + "deebot_client.commands.json.common", "INFO", f'No response received for command "{command.name}" during availability-check.', ) @@ -88,7 +88,7 @@ async def test_common_functionality( elif expected_log: log.check_present( ( - "deebot_client.commands.common", + "deebot_client.commands.json.common", expected_log[0], expected_log[1].format(command.name), ) diff --git a/tests/commands/test_continuous_cleaning.py b/tests/commands/json/test_continuous_cleaning.py similarity index 81% rename from tests/commands/test_continuous_cleaning.py rename to tests/commands/json/test_continuous_cleaning.py index 9bf3fac3..f2886e93 100644 --- a/tests/commands/test_continuous_cleaning.py +++ b/tests/commands/json/test_continuous_cleaning.py @@ -1,10 +1,11 @@ import pytest -from deebot_client.commands import GetContinuousCleaning, SetContinuousCleaning +from deebot_client.commands.json 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 +from . import assert_command, assert_set_command + @pytest.mark.parametrize("value", [False, True]) async def test_GetContinuousCleaning(value: bool) -> None: diff --git a/tests/commands/test_custom.py b/tests/commands/json/test_custom.py similarity index 86% rename from tests/commands/test_custom.py rename to tests/commands/json/test_custom.py index 9a32f8d9..fb588833 100644 --- a/tests/commands/test_custom.py +++ b/tests/commands/json/test_custom.py @@ -2,11 +2,12 @@ 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 . import assert_command + @pytest.mark.parametrize( "command, json, expected", diff --git a/tests/commands/test_fan_speed.py b/tests/commands/json/test_fan_speed.py similarity index 82% rename from tests/commands/test_fan_speed.py rename to tests/commands/json/test_fan_speed.py index 2d15e12f..ab59b1ad 100644 --- a/tests/commands/test_fan_speed.py +++ b/tests/commands/json/test_fan_speed.py @@ -1,10 +1,12 @@ import pytest -from deebot_client.commands import FanSpeedLevel, GetFanSpeed, SetFanSpeed +from deebot_client.commands.json import GetFanSpeed, SetFanSpeed from deebot_client.events import FanSpeedEvent -from tests.commands import assert_command +from deebot_client.events.fan_speed import FanSpeedLevel from tests.helpers import get_request_json, verify_DisplayNameEnum_unique +from . import assert_command + def test_FanSpeedLevel_unique() -> None: verify_DisplayNameEnum_unique(FanSpeedLevel) diff --git a/tests/commands/test_life_span.py b/tests/commands/json/test_life_span.py similarity index 90% rename from tests/commands/test_life_span.py rename to tests/commands/json/test_life_span.py index 57eb27db..bd2b73a7 100644 --- a/tests/commands/test_life_span.py +++ b/tests/commands/json/test_life_span.py @@ -2,11 +2,12 @@ import pytest -from deebot_client.commands import GetLifeSpan +from deebot_client.commands.json import GetLifeSpan from deebot_client.events import LifeSpan, LifeSpanEvent -from tests.commands import assert_command from tests.helpers import get_request_json +from . import assert_command + @pytest.mark.parametrize( "json, expected", diff --git a/tests/commands/test_map.py b/tests/commands/json/test_map.py similarity index 98% rename from tests/commands/test_map.py rename to tests/commands/json/test_map.py index 30f39698..e199626f 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,9 +13,10 @@ MapTraceEvent, ) from deebot_client.events.map import CachedMapInfoEvent -from tests.commands import assert_command from tests.helpers import get_request_json +from . import assert_command + async def test_getMapSubSet_customName() -> None: type = MapSetType.ROOMS diff --git a/tests/commands/test_mulitmap_state.py b/tests/commands/json/test_mulitmap_state.py similarity index 81% rename from tests/commands/test_mulitmap_state.py rename to tests/commands/json/test_mulitmap_state.py index f44f0289..b4791e34 100644 --- a/tests/commands/test_mulitmap_state.py +++ b/tests/commands/json/test_mulitmap_state.py @@ -1,10 +1,11 @@ import pytest -from deebot_client.commands import GetMultimapState, SetMultimapState +from deebot_client.commands.json 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 +from . import assert_command, assert_set_command + @pytest.mark.parametrize("value", [False, True]) async def test_GetMultimapState(value: bool) -> None: diff --git a/tests/commands/test_true_detect.py b/tests/commands/json/test_true_detect.py similarity index 81% rename from tests/commands/test_true_detect.py rename to tests/commands/json/test_true_detect.py index aff792c7..123328f9 100644 --- a/tests/commands/test_true_detect.py +++ b/tests/commands/json/test_true_detect.py @@ -1,10 +1,11 @@ import pytest -from deebot_client.commands import GetTrueDetect, SetTrueDetect +from deebot_client.commands.json 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 +from . import assert_command, assert_set_command + @pytest.mark.parametrize("value", [False, True]) async def test_GetTrueDetect(value: bool) -> None: diff --git a/tests/commands/test_water_info.py b/tests/commands/json/test_water_info.py similarity index 92% rename from tests/commands/test_water_info.py rename to tests/commands/json/test_water_info.py index 48574554..6bc8272f 100644 --- a/tests/commands/test_water_info.py +++ b/tests/commands/json/test_water_info.py @@ -2,11 +2,12 @@ import pytest -from deebot_client.commands import GetWaterInfo, SetWaterInfo +from deebot_client.commands.json 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 +from . import assert_command, assert_set_command + def test_WaterAmount_unique() -> None: verify_DisplayNameEnum_unique(WaterAmount) 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 96% rename from tests/messages/test_stats.py rename to tests/messages/json/test_stats.py index 60534f33..78446940 100644 --- a/tests/messages/test_stats.py +++ b/tests/messages/json/test_stats.py @@ -3,7 +3,7 @@ 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 diff --git a/tests/messages/test_get_messages.py b/tests/messages/test_get_messages.py index 225f6e45..bbcea11a 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_mqtt_client.py b/tests/test_mqtt_client.py index ee347970..1c5c40b5 100644 --- a/tests/test_mqtt_client.py +++ b/tests/test_mqtt_client.py @@ -13,8 +13,9 @@ from testfixtures import LogCapture from deebot_client.authentication import Authenticator -from deebot_client.commands.battery import GetBattery -from deebot_client.commands.volume import SetVolume +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.events.event_bus import EventBus from deebot_client.exceptions import AuthenticationError from deebot_client.models import Configuration, DeviceInfo @@ -215,12 +216,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) @@ -240,7 +242,7 @@ async def test_p2p_success( command_type = Mock(spec=SetVolume, 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}}} @@ -283,6 +285,37 @@ async def test_p2p_not_supported( ) +async def test_p2p_data_type_not_supported( + mqtt_client: MqttClient, +) -> None: + """Test that unsupported command will be logged.""" + topic_split = [ + "iot", + "p2p", + "getBattery", + "test", + "test", + "test", + "did", + "get_class", + "resource", + "q", + "req", + "z", + ] + + with LogCapture() as log: + mqtt_client._handle_p2p(topic_split, "") + + log.check_present( + ( + "deebot_client.mqtt_client", + "WARNING", + 'Unsupported data type: "z"', + ) + ) + + async def test_p2p_to_late( mqtt_client: MqttClient, device_info: DeviceInfo, @@ -299,7 +332,7 @@ async def test_p2p_to_late( command_type = Mock(spec=SetVolume, 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}}} From 021aaac466ff44d836e1d004c9f49aa39c09894c Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 28 Aug 2023 01:11:30 +0200 Subject: [PATCH 02/41] Fix PR CI (#293) * Run Ci on each branch * Run Ci on each branch --- .github/workflows/ci.yml | 3 +-- .github/workflows/codeql-analysis.yml | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39174131..8fe652fc 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" diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 726e6f30..0b3d1c5b 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" From c3cee07586696d2b9695f1c5b20b6de1fbf07eda Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 1 Sep 2023 10:20:37 +0200 Subject: [PATCH 03/41] Remove NoArgsCommand (#294) --- deebot_client/commands/json/battery.py | 4 ++-- deebot_client/commands/json/charge_state.py | 4 ++-- deebot_client/commands/json/clean.py | 4 ++-- deebot_client/commands/json/clean_count.py | 4 ++-- deebot_client/commands/json/common.py | 9 +-------- deebot_client/commands/json/error.py | 4 ++-- deebot_client/commands/json/fan_speed.py | 4 ++-- deebot_client/commands/json/stats.py | 6 +++--- deebot_client/commands/json/volume.py | 4 ++-- deebot_client/commands/json/water_info.py | 4 ++-- 10 files changed, 20 insertions(+), 27 deletions(-) diff --git a/deebot_client/commands/json/battery.py b/deebot_client/commands/json/battery.py index 5a812f9a..3bd117e7 100644 --- a/deebot_client/commands/json/battery.py +++ b/deebot_client/commands/json/battery.py @@ -1,10 +1,10 @@ """Battery commands.""" from deebot_client.messages.json import OnBattery -from .common import NoArgsCommand +from .common import CommandWithMessageHandling -class GetBattery(OnBattery, NoArgsCommand): +class GetBattery(OnBattery, CommandWithMessageHandling): """Get battery command.""" name = "getBattery" diff --git a/deebot_client/commands/json/charge_state.py b/deebot_client/commands/json/charge_state.py index 1399daf8..40c07bf6 100644 --- a/deebot_client/commands/json/charge_state.py +++ b/deebot_client/commands/json/charge_state.py @@ -5,11 +5,11 @@ from deebot_client.message import HandlingResult, MessageBodyDataDict from deebot_client.models import VacuumState -from .common import EventBus, NoArgsCommand +from .common import CommandWithMessageHandling, EventBus from .const import CODE -class GetChargeState(NoArgsCommand, MessageBodyDataDict): +class GetChargeState(CommandWithMessageHandling, MessageBodyDataDict): """Get charge state command.""" name = "getChargeState" diff --git a/deebot_client/commands/json/clean.py b/deebot_client/commands/json/clean.py index 7623de06..9933bc86 100644 --- a/deebot_client/commands/json/clean.py +++ b/deebot_client/commands/json/clean.py @@ -9,7 +9,7 @@ from deebot_client.message import HandlingResult, MessageBodyDataDict from deebot_client.models import DeviceInfo, VacuumState -from .common import EventBus, ExecuteCommand, NoArgsCommand +from .common import CommandWithMessageHandling, EventBus, ExecuteCommand _LOGGER = get_logger(__name__) @@ -81,7 +81,7 @@ def __init__(self, mode: CleanMode, area: str, cleanings: int = 1) -> None: self._args["count"] = cleanings -class GetCleanInfo(NoArgsCommand, MessageBodyDataDict): +class GetCleanInfo(CommandWithMessageHandling, MessageBodyDataDict): """Get clean info command.""" name = "getCleanInfo" diff --git a/deebot_client/commands/json/clean_count.py b/deebot_client/commands/json/clean_count.py index f3ba8ea8..d57ce90f 100644 --- a/deebot_client/commands/json/clean_count.py +++ b/deebot_client/commands/json/clean_count.py @@ -6,10 +6,10 @@ from deebot_client.events import CleanCountEvent from deebot_client.message import HandlingResult, MessageBodyDataDict -from .common import EventBus, NoArgsCommand, SetCommand +from .common import CommandWithMessageHandling, EventBus, SetCommand -class GetCleanCount(NoArgsCommand, MessageBodyDataDict): +class GetCleanCount(CommandWithMessageHandling, MessageBodyDataDict): """Get clean count command.""" name = "getCleanCount" diff --git a/deebot_client/commands/json/common.py b/deebot_client/commands/json/common.py index ecf84aa0..1dd5acab 100644 --- a/deebot_client/commands/json/common.py +++ b/deebot_client/commands/json/common.py @@ -85,13 +85,6 @@ def _handle_response( return CommandResult(HandlingState.ANALYSE) -class NoArgsCommand(CommandWithMessageHandling, ABC): - """Command without args.""" - - def __init__(self) -> None: - super().__init__() - - class ExecuteCommand(CommandWithMessageHandling, ABC): """Command, which is executing something (ex. Charge).""" @@ -138,7 +131,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] diff --git a/deebot_client/commands/json/error.py b/deebot_client/commands/json/error.py index 275a45d8..7e299b31 100644 --- a/deebot_client/commands/json/error.py +++ b/deebot_client/commands/json/error.py @@ -5,10 +5,10 @@ from deebot_client.message import HandlingResult, MessageBodyDataDict from deebot_client.models import VacuumState -from .common import EventBus, NoArgsCommand +from .common import CommandWithMessageHandling, EventBus -class GetError(NoArgsCommand, MessageBodyDataDict): +class GetError(CommandWithMessageHandling, MessageBodyDataDict): """Get error command.""" name = "getError" diff --git a/deebot_client/commands/json/fan_speed.py b/deebot_client/commands/json/fan_speed.py index dca6fa15..1bfab1f9 100644 --- a/deebot_client/commands/json/fan_speed.py +++ b/deebot_client/commands/json/fan_speed.py @@ -5,10 +5,10 @@ from deebot_client.events import FanSpeedEvent, FanSpeedLevel from deebot_client.message import HandlingResult, MessageBodyDataDict -from .common import EventBus, NoArgsCommand, SetCommand +from .common import CommandWithMessageHandling, EventBus, SetCommand -class GetFanSpeed(NoArgsCommand, MessageBodyDataDict): +class GetFanSpeed(CommandWithMessageHandling, MessageBodyDataDict): """Get fan speed command.""" name = "getSpeed" diff --git a/deebot_client/commands/json/stats.py b/deebot_client/commands/json/stats.py index 04837710..8502ec5c 100644 --- a/deebot_client/commands/json/stats.py +++ b/deebot_client/commands/json/stats.py @@ -4,10 +4,10 @@ from deebot_client.events import StatsEvent, TotalStatsEvent from deebot_client.message import HandlingResult, MessageBodyDataDict -from .common import EventBus, NoArgsCommand +from .common import CommandWithMessageHandling, EventBus -class GetStats(NoArgsCommand, MessageBodyDataDict): +class GetStats(CommandWithMessageHandling, MessageBodyDataDict): """Get stats command.""" name = "getStats" @@ -29,7 +29,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/json/volume.py b/deebot_client/commands/json/volume.py index 3dd8c4ae..6a7eb81f 100644 --- a/deebot_client/commands/json/volume.py +++ b/deebot_client/commands/json/volume.py @@ -6,10 +6,10 @@ from deebot_client.events import VolumeEvent from deebot_client.message import HandlingResult, MessageBodyDataDict -from .common import EventBus, NoArgsCommand, SetCommand +from .common import CommandWithMessageHandling, EventBus, SetCommand -class GetVolume(NoArgsCommand, MessageBodyDataDict): +class GetVolume(CommandWithMessageHandling, MessageBodyDataDict): """Get volume command.""" name = "getVolume" diff --git a/deebot_client/commands/json/water_info.py b/deebot_client/commands/json/water_info.py index cb09e358..4ceed610 100644 --- a/deebot_client/commands/json/water_info.py +++ b/deebot_client/commands/json/water_info.py @@ -5,10 +5,10 @@ from deebot_client.events import WaterAmount, WaterInfoEvent from deebot_client.message import HandlingResult, MessageBodyDataDict -from .common import EventBus, NoArgsCommand, SetCommand +from .common import CommandWithMessageHandling, EventBus, SetCommand -class GetWaterInfo(NoArgsCommand, MessageBodyDataDict): +class GetWaterInfo(CommandWithMessageHandling, MessageBodyDataDict): """Get water info command.""" name = "getWaterInfo" From c0a5cd9941b463f0cc0e5c4a6febc2177bb172ee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 Sep 2023 14:53:33 +0200 Subject: [PATCH 04/41] Bump actions/checkout from 3 to 4 (#297) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/python-publish.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fe652fc..bb50790a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ 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 @@ -39,7 +39,7 @@ 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 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 0b3d1c5b..cc894214 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -39,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..2e5d5842 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -20,7 +20,7 @@ jobs: id-token: write steps: - name: 📥 Checkout the repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: 🔢 Get release version id: version From 0052258e0fc199b89e895ef21c39bb4f4c2affac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 Sep 2023 14:55:12 +0200 Subject: [PATCH 05/41] Bump pre-commit from 3.3.3 to 3.4.0 (#296) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements-test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-test.txt b/requirements-test.txt index 8ad337d8..dc208021 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,5 +1,5 @@ mypy==1.5.1 -pre-commit==3.3.3 +pre-commit==3.4.0 pylint==2.17.5 pytest==7.4.0 pytest-asyncio==0.21.1 From ed132489d51bdaa51d1a656c0944d192d25eb75c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 Sep 2023 14:55:32 +0200 Subject: [PATCH 06/41] Bump pytest from 7.4.0 to 7.4.2 (#298) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements-test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-test.txt b/requirements-test.txt index dc208021..13999c58 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,7 +1,7 @@ mypy==1.5.1 pre-commit==3.4.0 pylint==2.17.5 -pytest==7.4.0 +pytest==7.4.2 pytest-asyncio==0.21.1 pytest-cov==4.1.0 pytest-docker-fixtures==1.3.17 From 1c4f6d50458fc2f93ed622d2f4a4050bc4e84150 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 24 Sep 2023 11:10:25 +0200 Subject: [PATCH 07/41] Bump testfixtures from 7.1.0 to 7.2.0 (#299) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements-test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-test.txt b/requirements-test.txt index 13999c58..97085581 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -7,5 +7,5 @@ pytest-cov==4.1.0 pytest-docker-fixtures==1.3.17 pytest-timeout==2.1.0 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability -testfixtures==7.1.0 +testfixtures==7.2.0 types-cachetools==5.3.0.6 From 3cecc0dd2cb9f48b0026799e4647d184218da916 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sun, 24 Sep 2023 11:46:58 +0200 Subject: [PATCH 08/41] Run init script on each startup (#302) --- .devcontainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer.json b/.devcontainer.json index 201063e0..6ea7d8e7 100644 --- a/.devcontainer.json +++ b/.devcontainer.json @@ -61,7 +61,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"] From fd48fccac87be67d6042d7ed731c054154f6387b Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sun, 24 Sep 2023 11:54:38 +0200 Subject: [PATCH 09/41] Fix rooms without enabled map (#303) --- deebot_client/map.py | 72 +++++++++++++++++++++++-------------------- requirements-test.txt | 1 + tests/conftest.py | 5 +++ tests/test_map.py | 25 +++++++++++++-- 4 files changed, 67 insertions(+), 36 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index 8d5c4cf8..70226f83 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -147,8 +147,42 @@ def __init__( self._amount_rooms: int = 0 self._last_image: LastImage | None = None self._unsubscribers: list[Callable[[], None]] = [] + self._unsubscribers_internal: list[Callable[[], None]] = [] self._tasks: set[asyncio.Future[Any]] = set() + async def on_map_set(event: MapSetEvent) -> None: + if event.type == MapSetType.ROOMS: + self._amount_rooms = len(event.subsets) + for room_id, _ in self._map_data.rooms.copy().items(): + if room_id not in event.subsets: + self._map_data.rooms.pop(room_id, None) + else: + for subset_id, subset in self._map_data.map_subsets.copy().items(): + if subset.type == event.type and subset_id not in event.subsets: + self._map_data.map_subsets.pop(subset_id, None) + + self._unsubscribers_internal.append( + self._event_bus.subscribe(MapSetEvent, on_map_set) + ) + + async def on_map_subset(event: MapSubsetEvent) -> None: + if event.type == MapSetType.ROOMS and event.name: + room = Room(event.name, event.id, event.coordinates) + if self._map_data.rooms.get(event.id, None) != room: + self._map_data.rooms[room.id] = room + + if len(self._map_data.rooms) == self._amount_rooms: + self._event_bus.notify( + RoomsEvent(list(self._map_data.rooms.values())) + ) + + elif self._map_data.map_subsets.get(event.id, None) != event: + self._map_data.map_subsets[event.id] = event + + self._unsubscribers_internal.append( + self._event_bus.subscribe(MapSubsetEvent, on_map_subset) + ) + # ---------------------------- METHODS ---------------------------- def _update_trace_points(self, data: str) -> None: @@ -206,37 +240,6 @@ def enable(self) -> None: create_task(self._tasks, self._execute_command(GetCachedMapInfo())) - async def on_map_set(event: MapSetEvent) -> None: - if event.type == MapSetType.ROOMS: - self._amount_rooms = len(event.subsets) - for room_id, _ in self._map_data.rooms.copy().items(): - if room_id not in event.subsets: - self._map_data.rooms.pop(room_id, None) - else: - for subset_id, subset in self._map_data.map_subsets.copy().items(): - if subset.type == event.type and subset_id not in event.subsets: - self._map_data.map_subsets.pop(subset_id, None) - - self._unsubscribers.append(self._event_bus.subscribe(MapSetEvent, on_map_set)) - - async def on_map_subset(event: MapSubsetEvent) -> None: - if event.type == MapSetType.ROOMS and event.name: - room = Room(event.name, event.id, event.coordinates) - if self._map_data.rooms.get(event.id, None) != room: - self._map_data.rooms[room.id] = room - - if len(self._map_data.rooms) == self._amount_rooms: - self._event_bus.notify( - RoomsEvent(list(self._map_data.rooms.values())) - ) - - elif self._map_data.map_subsets.get(event.id, None) != event: - self._map_data.map_subsets[event.id] = event - - self._unsubscribers.append( - self._event_bus.subscribe(MapSubsetEvent, on_map_subset) - ) - async def on_position(event: PositionsEvent) -> None: self._map_data.positions = event.positions @@ -280,10 +283,12 @@ async def on_minor_map(event: MinorMapEvent) -> None: def disable(self) -> None: """Disable map.""" - unsubscribers = self._unsubscribers - self._unsubscribers.clear() + self._unsubscribe_from(self._unsubscribers) + + def _unsubscribe_from(self, unsubscribers: list[Callable[[], None]]) -> None: for unsubscribe in unsubscribers: unsubscribe() + unsubscribers.clear() def refresh(self) -> None: """Manually refresh map.""" @@ -377,6 +382,7 @@ def get_base64_map(self, width: int | None = None) -> bytes: async def teardown(self) -> None: """Teardown map.""" self.disable() + self._unsubscribe_from(self._unsubscribers_internal) await cancel(self._tasks) diff --git a/requirements-test.txt b/requirements-test.txt index 97085581..793fe774 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -9,3 +9,4 @@ pytest-timeout==2.1.0 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability testfixtures==7.2.0 types-cachetools==5.3.0.6 +types-mock==5.1.0.2 diff --git a/tests/conftest.py b/tests/conftest.py index 40920618..e9019a78 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -137,3 +137,8 @@ def execute_mock() -> AsyncMock: @pytest.fixture def event_bus(execute_mock: AsyncMock) -> EventBus: return EventBus(execute_mock) + + +@pytest.fixture +def event_bus_mock() -> Mock: + return Mock(spec_set=EventBus) diff --git a/tests/test_map.py b/tests/test_map.py index 2e78c9ee..263d3499 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -1,11 +1,17 @@ import asyncio -from unittest.mock import AsyncMock +from unittest.mock import ANY, AsyncMock, Mock, call import pytest from deebot_client.events.event_bus import EventBus -from deebot_client.events.map import MapChangedEvent, Position, PositionType -from deebot_client.map import MapData, _calc_point +from deebot_client.events.map import ( + MapChangedEvent, + MapSetEvent, + MapSubsetEvent, + Position, + PositionType, +) +from deebot_client.map import Map, MapData, _calc_point from deebot_client.models import Room _test_calc_point_data = [ @@ -50,3 +56,16 @@ async def test_cycle() -> None: await asyncio.sleep(1.1) await test_cycle() + + +async def test_Map_internal_subscriptions( + execute_mock: AsyncMock, event_bus_mock: Mock +) -> None: + map = Map(execute_mock, event_bus_mock) + + calls = [call(MapSetEvent, ANY), call(MapSubsetEvent, ANY)] + event_bus_mock.subscribe.assert_has_calls(calls) + assert len(map._unsubscribers_internal) == len(calls) + + await map.teardown() + assert not map._unsubscribers_internal From 6a04efe921056b3e724d80b6c8f38c61ccea63c5 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sun, 24 Sep 2023 11:59:35 +0200 Subject: [PATCH 10/41] Don't commit to main and dev (#304) --- .pre-commit-config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fc505ccd..fc27a24d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -58,6 +58,7 @@ repos: - 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 From cde7372cb2ff2cc5363a6e16781f1f1cfe2dc72f Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sun, 24 Sep 2023 12:04:11 +0200 Subject: [PATCH 11/41] add gitlens extension (#305) --- .devcontainer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.devcontainer.json b/.devcontainer.json index 6ea7d8e7..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", From c17b03d89dba8595de7bfce17d75ab78dae3036f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Oct 2023 18:07:15 +0200 Subject: [PATCH 12/41] Bump actions/setup-python from 4.7.0 to 4.7.1 (#309) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- .github/workflows/python-publish.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb50790a..77a9c0d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: - 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" @@ -42,7 +42,7 @@ jobs: - 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/python-publish.yml b/.github/workflows/python-publish.yml index 2e5d5842..5c3a186b 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -31,7 +31,7 @@ jobs: sed -i '/version=/c\ version="${{ steps.version.outputs.version }}",' "${{ github.workspace }}/setup.py" - name: Set up Python - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: "3.11" From a929c7fdd3533082ca4798c734811a43388dd706 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 3 Oct 2023 18:11:31 +0200 Subject: [PATCH 13/41] Add device capabilities (#307) --- deebot_client/events/const.py | 92 ------------------ deebot_client/events/event_bus.py | 9 +- deebot_client/hardware/__init__.py | 93 +++++++++++++++++++ deebot_client/hardware/deebot.py | 88 ++++++++++++++++++ deebot_client/hardware/device_capabilities.py | 85 +++++++++++++++++ deebot_client/hardware/exceptions.py | 39 ++++++++ deebot_client/vacuum_bot.py | 19 +++- tests/conftest.py | 13 ++- tests/events/test_event_bus.py | 28 +++--- tests/events/test_events.py | 11 --- tests/hardware/__init__.py | 10 ++ tests/hardware/test_deebot.py | 11 +++ tests/hardware/test_device_capabilities.py | 56 +++++++++++ tests/hardware/test_init.py | 24 +++++ tests/helpers.py | 19 ++++ tests/test_vacuum_bot.py | 20 ++-- 16 files changed, 483 insertions(+), 134 deletions(-) delete mode 100644 deebot_client/events/const.py create mode 100644 deebot_client/hardware/__init__.py create mode 100644 deebot_client/hardware/deebot.py create mode 100644 deebot_client/hardware/device_capabilities.py create mode 100644 deebot_client/hardware/exceptions.py delete mode 100644 tests/events/test_events.py create mode 100644 tests/hardware/__init__.py create mode 100644 tests/hardware/test_deebot.py create mode 100644 tests/hardware/test_device_capabilities.py create mode 100644 tests/hardware/test_init.py diff --git a/deebot_client/events/const.py b/deebot_client/events/const.py deleted file mode 100644 index 03cb711c..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.json 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/event_bus.py b/deebot_client/events/event_bus.py index cae106f7..c5b1787a 100644 --- a/deebot_client/events/event_bus.py +++ b/deebot_client/events/event_bus.py @@ -12,6 +12,7 @@ if TYPE_CHECKING: from ..command import Command + from ..hardware.device_capabilities import DeviceCapabilities _LOGGER = get_logger(__name__) @@ -39,12 +40,14 @@ class EventBus: def __init__( self, execute_command: Callable[["Command"], Coroutine[Any, Any, None]], + device_capabilities: "DeviceCapabilities", ): self._event_processing_dict: dict[type[Event], _EventProcessingData] = {} self._lock = threading.Lock() self._tasks: set[asyncio.Future[Any]] = set() self._execute_command: Final = execute_command + self._device_capabilities = device_capabilities def has_subscribers(self, event: type[T]) -> bool: """Return True, if emitter has subscribers.""" @@ -150,11 +153,7 @@ async def _call_refresh_function(self, event_class: type[T]) -> None: 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 = self._device_capabilities.get_refresh_commands(event_class) if not commands: return diff --git a/deebot_client/hardware/__init__.py b/deebot_client/hardware/__init__.py new file mode 100644 index 00000000..55424d94 --- /dev/null +++ b/deebot_client/hardware/__init__.py @@ -0,0 +1,93 @@ +"""Hardware module.""" + + +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.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, + ErrorEvent, + LifeSpanEvent, + MultimapStateEvent, + RoomsEvent, + StateEvent, + StatsEvent, + TotalStatsEvent, + TrueDetectEvent, + VolumeEvent, +) +from deebot_client.events.fan_speed import FanSpeedEvent +from deebot_client.events.map import ( + CachedMapInfoEvent, + MajorMapEvent, + MapTraceEvent, + PositionsEvent, +) +from deebot_client.events.water_info import WaterInfoEvent +from deebot_client.hardware.device_capabilities import DeviceCapabilities +from deebot_client.logging_filter import get_logger + +from . import deebot + +_LOGGER = get_logger(__name__) + +_DEFAULT = DeviceCapabilities( + "_default", + { + AvailabilityEvent: [GetBattery(True)], + AdvancedModeEvent: [GetAdvancedMode()], + BatteryEvent: [GetBattery()], + CachedMapInfoEvent: [GetCachedMapInfo()], + CarpetAutoFanBoostEvent: [GetCarpetAutoFanBoost()], + CleanLogEvent: [GetCleanLogs()], + CleanCountEvent: [GetCleanCount()], + CleanPreferenceEvent: [GetCleanPreference()], + ContinuousCleaningEvent: [GetContinuousCleaning()], + ErrorEvent: [GetError()], + FanSpeedEvent: [GetFanSpeed()], + LifeSpanEvent: [GetLifeSpan()], + MajorMapEvent: [GetMajorMap()], + MapTraceEvent: [GetMapTrace()], + MultimapStateEvent: [GetMultimapState()], + PositionsEvent: [GetPos()], + RoomsEvent: [GetCachedMapInfo()], + StatsEvent: [GetStats()], + StateEvent: [GetChargeState(), GetCleanInfo()], + TotalStatsEvent: [GetTotalStats()], + TrueDetectEvent: [GetTrueDetect()], + VolumeEvent: [GetVolume()], + WaterInfoEvent: [GetWaterInfo()], + }, +) + + +def get_device_capabilities(clazz: str) -> DeviceCapabilities: + """Get device capabilities for given class.""" + if device := deebot.DEVICES.get(clazz): + return device + + _LOGGER.debug("No device capabilities found for %s. Fallback to default.", clazz) + return _DEFAULT diff --git a/deebot_client/hardware/deebot.py b/deebot_client/hardware/deebot.py new file mode 100644 index 00000000..24a18af3 --- /dev/null +++ b/deebot_client/hardware/deebot.py @@ -0,0 +1,88 @@ +"""Deebot devices.""" + +from collections.abc import Mapping + +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_logs import GetCleanLogs +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.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, + CleanLogEvent, + ContinuousCleaningEvent, + CustomCommandEvent, + ErrorEvent, + LifeSpanEvent, + MultimapStateEvent, + RoomsEvent, + StateEvent, + StatsEvent, + TotalStatsEvent, + TrueDetectEvent, + VolumeEvent, +) +from deebot_client.events.fan_speed import FanSpeedEvent +from deebot_client.events.map import ( + CachedMapInfoEvent, + MajorMapEvent, + MapTraceEvent, + PositionsEvent, +) +from deebot_client.events.water_info import WaterInfoEvent +from deebot_client.hardware.device_capabilities import ( + AbstractDeviceCapabilities, + DeviceCapabilities, + DeviceCapabilitiesRef, + convert, +) + +_DEVICES: Mapping[str, AbstractDeviceCapabilities] = { + "vi829v": DeviceCapabilitiesRef("Deebot Ozmo 920", "yna5x1"), + "yna5x1": DeviceCapabilities( + "Deebot Ozmo 950", + { + AdvancedModeEvent: [GetAdvancedMode()], + AvailabilityEvent: [GetBattery(True)], + BatteryEvent: [GetBattery()], + CachedMapInfoEvent: [GetCachedMapInfo()], + CarpetAutoFanBoostEvent: [GetCarpetAutoFanBoost()], + CleanLogEvent: [GetCleanLogs()], + ContinuousCleaningEvent: [GetContinuousCleaning()], + CustomCommandEvent: [], + ErrorEvent: [GetError()], + FanSpeedEvent: [GetFanSpeed()], + LifeSpanEvent: [GetLifeSpan()], + MajorMapEvent: [GetMajorMap()], + MapTraceEvent: [GetMapTrace()], + MultimapStateEvent: [GetMultimapState()], + PositionsEvent: [GetPos()], + RoomsEvent: [GetCachedMapInfo()], + StateEvent: [GetChargeState(), GetCleanInfo()], + StatsEvent: [GetStats()], + TotalStatsEvent: [GetTotalStats()], + TrueDetectEvent: [GetTrueDetect()], + VolumeEvent: [GetVolume()], + WaterInfoEvent: [GetWaterInfo()], + }, + ), +} + +DEVICES: Mapping[str, DeviceCapabilities] = { + _class: convert(_class, device, _DEVICES) for _class, device in _DEVICES.items() +} diff --git a/deebot_client/hardware/device_capabilities.py b/deebot_client/hardware/device_capabilities.py new file mode 100644 index 00000000..8e43c1b5 --- /dev/null +++ b/deebot_client/hardware/device_capabilities.py @@ -0,0 +1,85 @@ +"""Device module.""" +from abc import ABC +from collections.abc import Mapping +from dataclasses import dataclass + +from deebot_client.command import Command +from deebot_client.events import AvailabilityEvent, CustomCommandEvent, ReportStatsEvent +from deebot_client.events.base import Event +from deebot_client.events.map import MapSetEvent, MapSubsetEvent, MinorMapEvent + +from .exceptions import ( + DeviceCapabilitiesRefNotFoundError, + InvalidDeviceCapabilitiesError, + RequiredEventMissingError, +) + +_COMMON_NO_POLL_EVENTS = [ + CustomCommandEvent, + MapSetEvent, + MapSubsetEvent, + MinorMapEvent, + ReportStatsEvent, +] + +_REQUIRED_EVENTS = [AvailabilityEvent] + + +@dataclass(frozen=True) +class AbstractDeviceCapabilities(ABC): + """Abstract device capabilities.""" + + name: str + + +@dataclass(frozen=True) +class DeviceCapabilities(AbstractDeviceCapabilities): + """Device capabilities.""" + + events: Mapping[type[Event], list[Command]] + + def __post_init__(self) -> None: + events = {**self.events} + for event in _COMMON_NO_POLL_EVENTS: + events.setdefault(event, []) + + object.__setattr__(self, "capabilities", events) + + for event in _REQUIRED_EVENTS: + if event not in events: + raise RequiredEventMissingError(event) + + def get_refresh_commands(self, event: type[Event]) -> list[Command]: + """Return refresh command for given event.""" + return self.events.get(event, []) + + +@dataclass(frozen=True) +class DeviceCapabilitiesRef(AbstractDeviceCapabilities): + """Device capabilitie referring another device.""" + + ref: str + + def create( + self, devices: Mapping[str, AbstractDeviceCapabilities] + ) -> DeviceCapabilities: + """Create and return device capbabilities.""" + if (device := devices.get(self.ref)) and isinstance(device, DeviceCapabilities): + return DeviceCapabilities(self.name, device.events) + + raise DeviceCapabilitiesRefNotFoundError(self.ref) + + +def convert( + _class: str, + device: AbstractDeviceCapabilities, + devices: Mapping[str, AbstractDeviceCapabilities], +) -> DeviceCapabilities: + """Convert the device into a device capbabilities.""" + if isinstance(device, DeviceCapabilities): + return device + + if isinstance(device, DeviceCapabilitiesRef): + return device.create(devices) + + raise InvalidDeviceCapabilitiesError(_class, device) diff --git a/deebot_client/hardware/exceptions.py b/deebot_client/hardware/exceptions.py new file mode 100644 index 00000000..5dc3d431 --- /dev/null +++ b/deebot_client/hardware/exceptions.py @@ -0,0 +1,39 @@ +"""Deebot hardware exception module.""" + + +from typing import TYPE_CHECKING + +from deebot_client.events.base import Event +from deebot_client.exceptions import DeebotError + +if TYPE_CHECKING: + from deebot_client.hardware.device_capabilities import AbstractDeviceCapabilities + + +class HardwareError(DeebotError): + """Hardware error.""" + + +class DeviceCapabilitiesRefNotFoundError(HardwareError): + """Device capabilities reference not found error.""" + + def __init__(self, ref: str) -> None: + super().__init__(f'Device ref: "{ref}" not found') + + +class RequiredEventMissingError(HardwareError): + """Required event missing error.""" + + def __init__(self, event: type["Event"]) -> None: + super().__init__(f'Required event "{event.__name__}" is missing.') + + +class InvalidDeviceCapabilitiesError(HardwareError): + """Invalid device capabilities error.""" + + def __init__( + self, _class: str, device_cababilities: "AbstractDeviceCapabilities" + ) -> None: + super().__init__( + f'The class "{_class} has a invalid device capabilities "{device_cababilities.__class__.__name__}"' + ) diff --git a/deebot_client/vacuum_bot.py b/deebot_client/vacuum_bot.py index 73bdb1c1..039966cb 100644 --- a/deebot_client/vacuum_bot.py +++ b/deebot_client/vacuum_bot.py @@ -6,8 +6,9 @@ from datetime import datetime from typing import Any, Final -from deebot_client.commands.json.battery import GetBattery +from deebot_client.hardware import get_device_capabilities from deebot_client.mqtt_client import MqttClient, SubscriberInfo +from deebot_client.util import cancel from .authentication import Authenticator from .command import Command @@ -43,6 +44,7 @@ def __init__( self.device_info: Final[DeviceInfo] = device_info self._authenticator = authenticator + self._device_capabilities = get_device_capabilities(device_info.get_class) self._semaphore = asyncio.Semaphore(3) self._state: StateEvent | None = None self._last_time_available: datetime = datetime.now() @@ -50,7 +52,9 @@ def __init__( self._unsubscribe: Callable[[], None] | None = None self.fw_version: str | None = None - self.events: Final[EventBus] = EventBus(self.execute_command) + self.events: Final[EventBus] = EventBus( + self.execute_command, self._device_capabilities + ) self.map: Final[Map] = Map(self.execute_command, self.events) @@ -122,14 +126,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._device_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: diff --git a/tests/conftest.py b/tests/conftest.py index e9019a78..9061315e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,9 +6,11 @@ import pytest from aiomqtt import Client +from deebot_client import hardware from deebot_client.api_client import ApiClient from deebot_client.authentication import Authenticator from deebot_client.events.event_bus import EventBus +from deebot_client.hardware.device_capabilities import DeviceCapabilities from deebot_client.models import Configuration, Credentials, DeviceInfo from deebot_client.mqtt_client import MqttClient, MqttConfiguration from deebot_client.vacuum_bot import VacuumBot @@ -135,8 +137,15 @@ def execute_mock() -> AsyncMock: @pytest.fixture -def event_bus(execute_mock: AsyncMock) -> EventBus: - return EventBus(execute_mock) +def device_capabilities() -> DeviceCapabilities: + return hardware._DEFAULT + + +@pytest.fixture +def event_bus( + execute_mock: AsyncMock, device_capabilities: DeviceCapabilities +) -> EventBus: + return EventBus(execute_mock, device_capabilities) @pytest.fixture diff --git a/tests/events/test_event_bus.py b/tests/events/test_event_bus.py index 5329ac37..53d7a317 100644 --- a/tests/events/test_event_bus.py +++ b/tests/events/test_event_bus.py @@ -7,20 +7,20 @@ 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.events.water_info import WaterInfoEvent from deebot_client.models import VacuumState 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._device_capabilities.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,7 +114,7 @@ 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( 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/hardware/__init__.py b/tests/hardware/__init__.py new file mode 100644 index 00000000..924142fa --- /dev/null +++ b/tests/hardware/__init__.py @@ -0,0 +1,10 @@ +from collections.abc import Mapping + +from deebot_client.hardware.device_capabilities import DeviceCapabilities + + +def verify_sorted_devices(devices: Mapping[str, DeviceCapabilities]) -> None: + sorted_keys = sorted(devices.keys()) + assert sorted_keys == list( + devices.keys() + ), f"Devices expected to sort like {sorted_keys}" diff --git a/tests/hardware/test_deebot.py b/tests/hardware/test_deebot.py new file mode 100644 index 00000000..b2554062 --- /dev/null +++ b/tests/hardware/test_deebot.py @@ -0,0 +1,11 @@ +"""Hardware deebot tests.""" + + +from deebot_client.hardware.deebot import DEVICES + +from . import verify_sorted_devices + + +def test_sorted() -> None: + """Test if all devices are sorted correctly.""" + verify_sorted_devices(DEVICES) diff --git a/tests/hardware/test_device_capabilities.py b/tests/hardware/test_device_capabilities.py new file mode 100644 index 00000000..ff886555 --- /dev/null +++ b/tests/hardware/test_device_capabilities.py @@ -0,0 +1,56 @@ +"""Hardware device capabilities tests.""" + + +import pytest + +from deebot_client.hardware.device_capabilities import ( + AbstractDeviceCapabilities, + DeviceCapabilities, + DeviceCapabilitiesRef, + convert, +) +from deebot_client.hardware.exceptions import ( + DeviceCapabilitiesRefNotFoundError, + InvalidDeviceCapabilitiesError, + RequiredEventMissingError, +) +from tests.helpers import get_device_capabilities + + +def test_invalid_ref() -> None: + """Test error is raised if the ref is invalid.""" + device_ref = "not existing" + device_capbabilities_ref = DeviceCapabilitiesRef("invalid", device_ref) + devices = {"valid": get_device_capabilities(), "invalid": device_capbabilities_ref} + + with pytest.raises( + DeviceCapabilitiesRefNotFoundError, + match=rf'Device ref: "{device_ref}" not found', + ): + device_capbabilities_ref.create(devices) + + +def test_convert_raises_error() -> None: + """Test if convert raises error for unsporrted class.""" + + class _TestCapabilities(AbstractDeviceCapabilities): + pass + + _class = "abc" + device_capabilities = _TestCapabilities("test") + + with pytest.raises( + InvalidDeviceCapabilitiesError, + match=rf'The class "{_class} has a invalid device capabilities "_TestCapabilities"', + ): + convert(_class, device_capabilities, {}) + + +def test_DeviceCapabilites_check_for_required_events() -> None: + """Test if DevcieCapabilites raises error if not all required events are present.""" + + with pytest.raises( + RequiredEventMissingError, + match=r'Required event "AvailabilityEvent" is missing.', + ): + DeviceCapabilities("test", {}) diff --git a/tests/hardware/test_init.py b/tests/hardware/test_init.py new file mode 100644 index 00000000..86497885 --- /dev/null +++ b/tests/hardware/test_init.py @@ -0,0 +1,24 @@ +"""Hardware init tests.""" + + +import pytest + +from deebot_client.hardware import _DEFAULT, get_device_capabilities +from deebot_client.hardware.deebot import _DEVICES, DEVICES +from deebot_client.hardware.device_capabilities import DeviceCapabilities + + +@pytest.mark.parametrize( + "_class, expected", + [ + ("not_specified", _DEFAULT), + ("yna5x1", _DEVICES["yna5x1"]), + ( + "vi829v", + DeviceCapabilities(_DEVICES["vi829v"].name, DEVICES["yna5x1"].events), + ), + ], +) +def test_get_device_capabilities(_class: str, expected: DeviceCapabilities) -> None: + """Test get_device_capabilities.""" + assert expected == get_device_capabilities(_class) diff --git a/tests/helpers.py b/tests/helpers.py index 8d552299..4cf43fa7 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,5 +1,12 @@ +from collections.abc import Mapping from typing import Any +from deebot_client.command import Command +from deebot_client.events.base import Event +from deebot_client.hardware.device_capabilities import ( + _REQUIRED_EVENTS, + DeviceCapabilities, +) from deebot_client.util import DisplayNameIntEnum @@ -43,3 +50,15 @@ def get_message_json(data: dict[str, Any] | None | list[Any]) -> dict[str, Any]: if data: json["body"]["data"] = data return json + + +def get_device_capabilities( + events: Mapping[type[Event], list[Command]] | None = None +) -> DeviceCapabilities: + """Get test device capabilities.""" + _events = {**events} if events else {} + for event in _REQUIRED_EVENTS: + if event not in _events: + _events[event] = [] + + return DeviceCapabilities("test", _events) diff --git a/tests/test_vacuum_bot.py b/tests/test_vacuum_bot.py index 3aef0877..82caf56f 100644 --- a/tests/test_vacuum_bot.py +++ b/tests/test_vacuum_bot.py @@ -4,16 +4,15 @@ from unittest.mock import Mock, patch from deebot_client.authentication import Authenticator +from deebot_client.commands.json.battery import GetBattery 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 +from tests.helpers import get_device_capabilities @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: @@ -27,9 +26,15 @@ 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: + with patch( + "deebot_client.vacuum_bot.get_device_capabilities" + ) as get_device_capabilities_patch: # prepare mocks - execute_mock = battery_command.return_value.execute + battery_mock = Mock(spec_set=GetBattery) + get_device_capabilities_patch.return_value = get_device_capabilities( + {AvailabilityEvent: [battery_mock]} + ) + execute_mock = battery_mock.execute # prepare bot and mock mqtt bot = VacuumBot(device_info, authenticator) @@ -38,6 +43,9 @@ async def assert_received_status(expected: bool) -> None: mqtt_client.subscribe.return_value = unsubscribe_mock await bot.initialize(mqtt_client) + # deactivate refresh event subscribe refresh calls + bot.events._device_capabilities = get_device_capabilities() + bot.events.subscribe(AvailabilityEvent, on_status) # verify mqtt was subscribed and available task was started @@ -65,7 +73,7 @@ async def assert_received_status(expected: bool) -> None: await assert_received_status(True) # reset mock for easier handling - battery_command.reset_mock() + battery_mock.reset_mock() # Simulate message over mqtt and therefore available is not needed await asyncio.sleep(0.8) From 990c564dfe9ecfa45a0886db13daee0988360781 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 3 Oct 2023 18:25:35 +0200 Subject: [PATCH 14/41] [pre-commit.ci] pre-commit autoupdate (#289) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Robert Resch --- .pre-commit-config.yaml | 14 +++++++------- .prettierrc | 3 --- .prettierrc.js | 5 +++++ deebot_client/authentication.py | 2 +- deebot_client/hardware/device_capabilities.py | 4 ++-- scripts/check_getLogger.sh | 2 +- 6 files changed, 16 insertions(+), 14 deletions(-) delete mode 100644 .prettierrc create mode 100644 .prettierrc.js diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fc27a24d..1b7df0b8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,19 +10,19 @@ default_language_version: repos: - repo: https://github.com/asottile/pyupgrade - rev: v3.9.0 + rev: v3.14.0 hooks: - id: pyupgrade args: - --py311-plus - repo: https://github.com/psf/black - rev: 23.7.0 + rev: 23.9.1 hooks: - id: black args: - --quiet - repo: https://github.com/codespell-project/codespell - rev: v2.2.5 + rev: v2.2.6 hooks: - id: codespell args: @@ -33,7 +33,7 @@ repos: - csv - json - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 + rev: 6.1.0 hooks: - id: flake8 additional_dependencies: @@ -61,12 +61,12 @@ repos: 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/deebot_client/authentication.py b/deebot_client/authentication.py index 36f8ef2a..aeda87f2 100644 --- a/deebot_client/authentication.py +++ b/deebot_client/authentication.py @@ -271,7 +271,7 @@ 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", "") ) diff --git a/deebot_client/hardware/device_capabilities.py b/deebot_client/hardware/device_capabilities.py index 8e43c1b5..88d0b3e2 100644 --- a/deebot_client/hardware/device_capabilities.py +++ b/deebot_client/hardware/device_capabilities.py @@ -63,7 +63,7 @@ class DeviceCapabilitiesRef(AbstractDeviceCapabilities): def create( self, devices: Mapping[str, AbstractDeviceCapabilities] ) -> DeviceCapabilities: - """Create and return device capbabilities.""" + """Create and return device capabilities.""" if (device := devices.get(self.ref)) and isinstance(device, DeviceCapabilities): return DeviceCapabilities(self.name, device.events) @@ -75,7 +75,7 @@ def convert( device: AbstractDeviceCapabilities, devices: Mapping[str, AbstractDeviceCapabilities], ) -> DeviceCapabilities: - """Convert the device into a device capbabilities.""" + """Convert the device into a device capabilities.""" if isinstance(device, DeviceCapabilities): return device 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" From f13872bfafd41ad8296bf844a21b6afa550440c8 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 4 Oct 2023 09:57:58 +0200 Subject: [PATCH 15/41] Update pillow to fix CVE (#311) Credits: @frenck --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1a233e7d..590100d3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,4 @@ 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 From 69e46758dc07f8510d67601c68e0b7ece6314297 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Oct 2023 16:24:15 +0200 Subject: [PATCH 16/41] Bump pylint from 2.17.5 to 3.0.0 (#310) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Robert Resch --- deebot_client/map.py | 1 - pylintrc | 3 ++- requirements-test.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index 70226f83..d40db127 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -447,7 +447,6 @@ def __eq__(self, obj: object) -> bool: class DashedImageDraw(ImageDraw.ImageDraw): # type: ignore """Class extend ImageDraw by dashed line.""" - # pylint: disable=invalid-name # Copied from https://stackoverflow.com/a/65893631 Credits ands def _thick_line( diff --git a/pylintrc b/pylintrc index 9df831de..c1927a39 100644 --- a/pylintrc +++ b/pylintrc @@ -80,4 +80,5 @@ overgeneral-exceptions=builtins.BaseException, builtins.Exception [DESIGN] -max-parents=10 \ No newline at end of file +max-parents=10 +max-args=6 \ No newline at end of file diff --git a/requirements-test.txt b/requirements-test.txt index 793fe774..792e7491 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,6 +1,6 @@ mypy==1.5.1 pre-commit==3.4.0 -pylint==2.17.5 +pylint==3.0.0 pytest==7.4.2 pytest-asyncio==0.21.1 pytest-cov==4.1.0 From c06d07d8ab18a3cb493d4c82adbedc2ec0ec6a54 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 6 Oct 2023 13:50:46 +0200 Subject: [PATCH 17/41] add DeviceCapabilities.capabilities (#312) --- deebot_client/commands/json/life_span.py | 27 +-- deebot_client/hardware/__init__.py | 4 +- deebot_client/hardware/deebot.py | 6 +- deebot_client/hardware/device_capabilities.py | 28 ++- deebot_client/util.py | 3 + tests/commands/json/__init__.py | 55 +++--- tests/commands/json/test_advanced_mode.py | 4 +- tests/commands/json/test_battery.py | 6 +- tests/commands/json/test_carpet.py | 4 +- tests/commands/json/test_charge.py | 6 +- tests/commands/json/test_charge_state.py | 4 +- tests/commands/json/test_clean.py | 4 +- tests/commands/json/test_clean_count.py | 4 +- tests/commands/json/test_clean_preference.py | 4 +- .../commands/json/test_continuous_cleaning.py | 4 +- tests/commands/json/test_custom.py | 8 +- tests/commands/json/test_fan_speed.py | 8 +- tests/commands/json/test_life_span.py | 51 ++++-- tests/commands/json/test_map.py | 170 ++++++++++-------- tests/commands/json/test_mulitmap_state.py | 4 +- tests/commands/json/test_true_detect.py | 4 +- tests/commands/json/test_water_info.py | 8 +- tests/hardware/__init__.py | 7 + tests/hardware/test_device_capabilities.py | 28 +++ tests/hardware/test_init.py | 12 +- tests/helpers.py | 37 ++-- 26 files changed, 325 insertions(+), 175 deletions(-) diff --git a/deebot_client/commands/json/life_span.py b/deebot_client/commands/json/life_span.py index b0630d8a..98702e47 100644 --- a/deebot_client/commands/json/life_span.py +++ b/deebot_client/commands/json/life_span.py @@ -4,17 +4,30 @@ from deebot_client.command import CommandMqttP2P 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, EventBus, ExecuteCommand +LifeSpanType = LifeSpan | str + + +def _get_str(_type: LifeSpanType) -> str: + if isinstance(_type, LifeSpan): + return _type.value + + return _type + class GetLifeSpan(CommandWithMessageHandling, MessageBodyDataList): """Get life span command.""" name = "getLifeSpan" - def __init__(self) -> None: - args = [life_span.value for life_span in LifeSpan] + def __init__(self, _types: LifeSpanType | LST[LifeSpanType]) -> None: + if isinstance(_types, LifeSpanType): # type: ignore[misc, arg-type] + _types = set(_types) + + args = [_get_str(life_span) for life_span in _types] super().__init__(args) @classmethod @@ -42,14 +55,8 @@ class ResetLifeSpan(ExecuteCommand, CommandMqttP2P): name = "resetLifeSpan" - 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, type: LifeSpanType) -> None: # pylint: disable=redefined-builtin + super().__init__({"type": _get_str(type)}) 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/hardware/__init__.py b/deebot_client/hardware/__init__.py index 55424d94..f5da905a 100644 --- a/deebot_client/hardware/__init__.py +++ b/deebot_client/hardware/__init__.py @@ -30,6 +30,7 @@ CleanPreferenceEvent, ContinuousCleaningEvent, ErrorEvent, + LifeSpan, LifeSpanEvent, MultimapStateEvent, RoomsEvent, @@ -68,7 +69,7 @@ ContinuousCleaningEvent: [GetContinuousCleaning()], ErrorEvent: [GetError()], FanSpeedEvent: [GetFanSpeed()], - LifeSpanEvent: [GetLifeSpan()], + LifeSpanEvent: [(lambda dc: GetLifeSpan(dc.capabilities[LifeSpan]))], MajorMapEvent: [GetMajorMap()], MapTraceEvent: [GetMapTrace()], MultimapStateEvent: [GetMultimapState()], @@ -81,6 +82,7 @@ VolumeEvent: [GetVolume()], WaterInfoEvent: [GetWaterInfo()], }, + {LifeSpan: list(LifeSpan)}, ) diff --git a/deebot_client/hardware/deebot.py b/deebot_client/hardware/deebot.py index 24a18af3..b6bc1ca1 100644 --- a/deebot_client/hardware/deebot.py +++ b/deebot_client/hardware/deebot.py @@ -26,8 +26,8 @@ CarpetAutoFanBoostEvent, CleanLogEvent, ContinuousCleaningEvent, - CustomCommandEvent, ErrorEvent, + LifeSpan, LifeSpanEvent, MultimapStateEvent, RoomsEvent, @@ -64,10 +64,9 @@ CarpetAutoFanBoostEvent: [GetCarpetAutoFanBoost()], CleanLogEvent: [GetCleanLogs()], ContinuousCleaningEvent: [GetContinuousCleaning()], - CustomCommandEvent: [], ErrorEvent: [GetError()], FanSpeedEvent: [GetFanSpeed()], - LifeSpanEvent: [GetLifeSpan()], + LifeSpanEvent: [(lambda dc: GetLifeSpan(dc.capabilities[LifeSpan]))], MajorMapEvent: [GetMajorMap()], MapTraceEvent: [GetMapTrace()], MultimapStateEvent: [GetMultimapState()], @@ -80,6 +79,7 @@ VolumeEvent: [GetVolume()], WaterInfoEvent: [GetWaterInfo()], }, + {LifeSpan: {LifeSpan.BRUSH, LifeSpan.FILTER, LifeSpan.SIDE_BRUSH}}, ), } diff --git a/deebot_client/hardware/device_capabilities.py b/deebot_client/hardware/device_capabilities.py index 88d0b3e2..1f6a4c65 100644 --- a/deebot_client/hardware/device_capabilities.py +++ b/deebot_client/hardware/device_capabilities.py @@ -1,12 +1,16 @@ """Device module.""" from abc import ABC -from collections.abc import Mapping +from collections.abc import Callable, Mapping from dataclasses import dataclass +from typing import Any, TypeVar + +from attr import field from deebot_client.command import Command from deebot_client.events import AvailabilityEvent, CustomCommandEvent, ReportStatsEvent from deebot_client.events.base import Event from deebot_client.events.map import MapSetEvent, MapSubsetEvent, MinorMapEvent +from deebot_client.util import LST from .exceptions import ( DeviceCapabilitiesRefNotFoundError, @@ -24,6 +28,10 @@ _REQUIRED_EVENTS = [AvailabilityEvent] +_T = TypeVar("_T") + +CapabilitiesDict = dict[type[_T], LST[_T]] + @dataclass(frozen=True) class AbstractDeviceCapabilities(ABC): @@ -36,14 +44,17 @@ class AbstractDeviceCapabilities(ABC): class DeviceCapabilities(AbstractDeviceCapabilities): """Device capabilities.""" - events: Mapping[type[Event], list[Command]] + events: Mapping[ + type[Event], list[Command | Callable[["DeviceCapabilities"], Command]] + ] + capabilities: CapabilitiesDict[Any] = field(factory=dict) def __post_init__(self) -> None: events = {**self.events} for event in _COMMON_NO_POLL_EVENTS: events.setdefault(event, []) - object.__setattr__(self, "capabilities", events) + object.__setattr__(self, "events", events) for event in _REQUIRED_EVENTS: if event not in events: @@ -51,7 +62,14 @@ def __post_init__(self) -> None: def get_refresh_commands(self, event: type[Event]) -> list[Command]: """Return refresh command for given event.""" - return self.events.get(event, []) + commands = [] + for command in self.events.get(event, []): + if isinstance(command, Command): + commands.append(command) + else: + commands.append(command(self)) + + return commands @dataclass(frozen=True) @@ -65,7 +83,7 @@ def create( ) -> DeviceCapabilities: """Create and return device capabilities.""" if (device := devices.get(self.ref)) and isinstance(device, DeviceCapabilities): - return DeviceCapabilities(self.name, device.events) + return DeviceCapabilities(self.name, device.events, device.capabilities) raise DeviceCapabilitiesRefNotFoundError(self.ref) diff --git a/deebot_client/util.py b/deebot_client/util.py index c78f627e..10155301 100644 --- a/deebot_client/util.py +++ b/deebot_client/util.py @@ -136,3 +136,6 @@ 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, ...] diff --git a/tests/commands/json/__init__.py b/tests/commands/json/__init__.py index cceac162..80cded3d 100644 --- a/tests/commands/json/__init__.py +++ b/tests/commands/json/__init__.py @@ -2,13 +2,15 @@ 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 SetCommand +from deebot_client.commands.json.common import ExecuteCommand, SetCommand from deebot_client.events import Event from deebot_client.events.event_bus import EventBus from deebot_client.models import Credentials, DeviceInfo -from tests.helpers import get_message_json, get_request_json +from tests.helpers import get_message_json, get_request_json, get_success_body async def assert_command( @@ -49,37 +51,50 @@ async def assert_command( event_bus.notify.assert_not_called() -async def assert_set_command( - command: SetCommand, - args: dict | list | None, - expected_get_command_event: Event, +async def assert_execute_command( + command: ExecuteCommand, args: dict | list | None ) -> None: assert command.name != "invalid" assert command._args == args - json = get_request_json({"code": 0, "msg": "ok"}) + # 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 | list | None, + expected_get_command_event: Event, +) -> None: + await assert_execute_command(command, args) + 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": { + 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(None)) + command.handle_mqtt_p2p(event_bus, get_message_json(get_success_body())) event_bus.notify.assert_called_once_with(expected_get_command_event) diff --git a/tests/commands/json/test_advanced_mode.py b/tests/commands/json/test_advanced_mode.py index 4a3c18fa..529a24bd 100644 --- a/tests/commands/json/test_advanced_mode.py +++ b/tests/commands/json/test_advanced_mode.py @@ -2,14 +2,14 @@ from deebot_client.commands.json import GetAdvancedMode, SetAdvancedMode from deebot_client.events import AdvancedModeEvent -from tests.helpers import get_request_json +from tests.helpers import get_request_json, get_success_body from . import assert_command, assert_set_command @pytest.mark.parametrize("value", [False, True]) async def test_GetAdvancedMode(value: bool) -> None: - json = get_request_json({"enable": 1 if value else 0}) + json = get_request_json(get_success_body({"enable": 1 if value else 0})) await assert_command(GetAdvancedMode(), json, AdvancedModeEvent(value)) diff --git a/tests/commands/json/test_battery.py b/tests/commands/json/test_battery.py index 12afffcc..60f68328 100644 --- a/tests/commands/json/test_battery.py +++ b/tests/commands/json/test_battery.py @@ -2,12 +2,14 @@ from deebot_client.commands.json import GetBattery from deebot_client.events import BatteryEvent -from tests.helpers import get_request_json +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({"value": percentage, "isLow": 1 if percentage < 20 else 0}) + 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 index d1a2d399..4229a6fe 100644 --- a/tests/commands/json/test_carpet.py +++ b/tests/commands/json/test_carpet.py @@ -2,14 +2,14 @@ from deebot_client.commands.json import GetCarpetAutoFanBoost, SetCarpetAutoFanBoost from deebot_client.events import CarpetAutoFanBoostEvent -from tests.helpers import get_request_json +from tests.helpers import get_request_json, get_success_body from . import assert_command, assert_set_command @pytest.mark.parametrize("value", [False, True]) async def test_GetCarpetAutoFanBoost(value: bool) -> None: - json = get_request_json({"enable": 1 if value else 0}) + json = get_request_json(get_success_body({"enable": 1 if value else 0})) await assert_command(GetCarpetAutoFanBoost(), json, CarpetAutoFanBoostEvent(value)) diff --git a/tests/commands/json/test_charge.py b/tests/commands/json/test_charge.py index ff46550c..3be4a516 100644 --- a/tests/commands/json/test_charge.py +++ b/tests/commands/json/test_charge.py @@ -6,13 +6,13 @@ from deebot_client.commands.json import Charge from deebot_client.events import StateEvent from deebot_client.models import VacuumState -from tests.helpers import get_request_json +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(None) + json = get_request_json(get_success_body()) json["resp"]["body"].update( { "code": code, @@ -25,7 +25,7 @@ def _prepare_json(code: int, msg: str = "ok") -> dict[str, Any]: @pytest.mark.parametrize( "json, expected", [ - (get_request_json(None), StateEvent(VacuumState.RETURNING)), + (get_request_json(get_success_body()), StateEvent(VacuumState.RETURNING)), (_prepare_json(30007), StateEvent(VacuumState.DOCKED)), ], ) diff --git a/tests/commands/json/test_charge_state.py b/tests/commands/json/test_charge_state.py index 4d29beee..5c017790 100644 --- a/tests/commands/json/test_charge_state.py +++ b/tests/commands/json/test_charge_state.py @@ -4,7 +4,7 @@ from deebot_client.commands.json import GetChargeState from deebot_client.events import StateEvent -from tests.helpers import get_request_json +from tests.helpers import get_request_json, get_success_body from . import assert_command @@ -12,7 +12,7 @@ @pytest.mark.parametrize( "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/json/test_clean.py b/tests/commands/json/test_clean.py index d6119388..65d704d3 100644 --- a/tests/commands/json/test_clean.py +++ b/tests/commands/json/test_clean.py @@ -9,7 +9,7 @@ from deebot_client.events import StateEvent from deebot_client.events.event_bus import EventBus from deebot_client.models import DeviceInfo, VacuumState -from tests.helpers import get_request_json +from tests.helpers import get_request_json, get_success_body from . import assert_command @@ -18,7 +18,7 @@ "json, expected", [ ( - get_request_json({"trigger": "none", "state": "idle"}), + get_request_json(get_success_body({"trigger": "none", "state": "idle"})), StateEvent(VacuumState.IDLE), ), ], diff --git a/tests/commands/json/test_clean_count.py b/tests/commands/json/test_clean_count.py index ee60c0c2..b835d02c 100644 --- a/tests/commands/json/test_clean_count.py +++ b/tests/commands/json/test_clean_count.py @@ -2,13 +2,13 @@ from deebot_client.commands.json import GetCleanCount, SetCleanCount from deebot_client.events import CleanCountEvent -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/json/test_clean_preference.py b/tests/commands/json/test_clean_preference.py index 329fcb5d..51946049 100644 --- a/tests/commands/json/test_clean_preference.py +++ b/tests/commands/json/test_clean_preference.py @@ -2,14 +2,14 @@ from deebot_client.commands.json import GetCleanPreference, SetCleanPreference from deebot_client.events import CleanPreferenceEvent -from tests.helpers import get_request_json +from tests.helpers import get_request_json, get_success_body from . import assert_command, assert_set_command @pytest.mark.parametrize("value", [False, True]) async def test_GetCleanPreference(value: bool) -> None: - json = get_request_json({"enable": 1 if value else 0}) + json = get_request_json(get_success_body({"enable": 1 if value else 0})) await assert_command(GetCleanPreference(), json, CleanPreferenceEvent(value)) diff --git a/tests/commands/json/test_continuous_cleaning.py b/tests/commands/json/test_continuous_cleaning.py index f2886e93..1c596401 100644 --- a/tests/commands/json/test_continuous_cleaning.py +++ b/tests/commands/json/test_continuous_cleaning.py @@ -2,14 +2,14 @@ from deebot_client.commands.json import GetContinuousCleaning, SetContinuousCleaning from deebot_client.events import ContinuousCleaningEvent -from tests.helpers import get_request_json +from tests.helpers import get_request_json, get_success_body from . import assert_command, assert_set_command @pytest.mark.parametrize("value", [False, True]) async def test_GetContinuousCleaning(value: bool) -> None: - json = get_request_json({"enable": 1 if value else 0}) + json = get_request_json(get_success_body({"enable": 1 if value else 0})) await assert_command(GetContinuousCleaning(), json, ContinuousCleaningEvent(value)) diff --git a/tests/commands/json/test_custom.py b/tests/commands/json/test_custom.py index fb588833..b2f9b1a4 100644 --- a/tests/commands/json/test_custom.py +++ b/tests/commands/json/test_custom.py @@ -4,7 +4,7 @@ from deebot_client.commands.json.custom import CustomCommand from deebot_client.events import CustomCommandEvent -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 @@ -14,8 +14,10 @@ [ ( 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 index ab59b1ad..d741dd1f 100644 --- a/tests/commands/json/test_fan_speed.py +++ b/tests/commands/json/test_fan_speed.py @@ -3,7 +3,11 @@ 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, verify_DisplayNameEnum_unique +from tests.helpers import ( + get_request_json, + get_success_body, + verify_DisplayNameEnum_unique, +) from . import assert_command @@ -13,7 +17,7 @@ def test_FanSpeedLevel_unique() -> None: async def test_GetFanSpeed() -> None: - json = get_request_json({"speed": 2}) + json = get_request_json(get_success_body({"speed": 2})) await assert_command(GetFanSpeed(), json, FanSpeedEvent(FanSpeedLevel.MAX_PLUS)) diff --git a/tests/commands/json/test_life_span.py b/tests/commands/json/test_life_span.py index bd2b73a7..de596812 100644 --- a/tests/commands/json/test_life_span.py +++ b/tests/commands/json/test_life_span.py @@ -3,22 +3,26 @@ import pytest from deebot_client.commands.json import GetLifeSpan +from deebot_client.commands.json.life_span import LifeSpanType, ResetLifeSpan from deebot_client.events import LifeSpan, LifeSpanEvent -from tests.helpers import get_request_json +from tests.helpers import get_request_json, get_success_body -from . import assert_command +from . import assert_command, assert_execute_command @pytest.mark.parametrize( - "json, expected", + "command, json, expected", [ ( + GetLifeSpan({"brush", LifeSpan.FILTER, LifeSpan.SIDE_BRUSH}), get_request_json( - [ - {"type": "sideBrush", "left": 8977, "total": 9000}, - {"type": "brush", "left": 17979, "total": 18000}, - {"type": "heap", "left": 7179, "total": 7200}, - ] + 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), @@ -26,7 +30,34 @@ 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("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( + "_type, args", + [ + (LifeSpan.FILTER, {"type": LifeSpan.FILTER.value}), + ("brush", {"type": LifeSpan.BRUSH.value}), ], ) -async def test_GetLifeSpan(json: dict[str, Any], expected: list[LifeSpanEvent]) -> None: - await assert_command(GetLifeSpan(), json, expected) +async def test_ResetLifeSpan(_type: LifeSpanType, args: dict[str, str]) -> None: + await assert_execute_command(ResetLifeSpan(_type), args) diff --git a/tests/commands/json/test_map.py b/tests/commands/json/test_map.py index e199626f..539f3260 100644 --- a/tests/commands/json/test_map.py +++ b/tests/commands/json/test_map.py @@ -13,7 +13,7 @@ MapTraceEvent, ) from deebot_client.events.map import CachedMapInfoEvent -from tests.helpers import get_request_json +from tests.helpers import get_request_json, get_success_body from . import assert_command @@ -23,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"), @@ -53,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"), @@ -73,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(), @@ -112,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(",")) @@ -131,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( @@ -169,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 index b4791e34..10b19cd2 100644 --- a/tests/commands/json/test_mulitmap_state.py +++ b/tests/commands/json/test_mulitmap_state.py @@ -2,14 +2,14 @@ from deebot_client.commands.json import GetMultimapState, SetMultimapState from deebot_client.events import MultimapStateEvent -from tests.helpers import get_request_json +from tests.helpers import get_request_json, get_success_body from . import assert_command, assert_set_command @pytest.mark.parametrize("value", [False, True]) async def test_GetMultimapState(value: bool) -> None: - json = get_request_json({"enable": 1 if value else 0}) + json = get_request_json(get_success_body({"enable": 1 if value else 0})) await assert_command(GetMultimapState(), json, MultimapStateEvent(value)) diff --git a/tests/commands/json/test_true_detect.py b/tests/commands/json/test_true_detect.py index 123328f9..f0ec7ab7 100644 --- a/tests/commands/json/test_true_detect.py +++ b/tests/commands/json/test_true_detect.py @@ -2,14 +2,14 @@ from deebot_client.commands.json import GetTrueDetect, SetTrueDetect from deebot_client.events import TrueDetectEvent -from tests.helpers import get_request_json +from tests.helpers import get_request_json, get_success_body from . import assert_command, assert_set_command @pytest.mark.parametrize("value", [False, True]) async def test_GetTrueDetect(value: bool) -> None: - json = get_request_json({"enable": 1 if value else 0}) + json = get_request_json(get_success_body({"enable": 1 if value else 0})) await assert_command(GetTrueDetect(), json, TrueDetectEvent(value)) diff --git a/tests/commands/json/test_water_info.py b/tests/commands/json/test_water_info.py index 6bc8272f..17d75800 100644 --- a/tests/commands/json/test_water_info.py +++ b/tests/commands/json/test_water_info.py @@ -4,7 +4,11 @@ from deebot_client.commands.json import GetWaterInfo, SetWaterInfo from deebot_client.events import WaterAmount, WaterInfoEvent -from tests.helpers import get_request_json, verify_DisplayNameEnum_unique +from tests.helpers import ( + get_request_json, + get_success_body, + verify_DisplayNameEnum_unique, +) from . import assert_command, assert_set_command @@ -22,7 +26,7 @@ def test_WaterAmount_unique() -> None: ], ) async def test_GetWaterInfo(json: dict[str, Any], expected: WaterInfoEvent) -> None: - json = get_request_json(json) + json = get_request_json(get_success_body(json)) await assert_command(GetWaterInfo(), json, expected) diff --git a/tests/hardware/__init__.py b/tests/hardware/__init__.py index 924142fa..50faae22 100644 --- a/tests/hardware/__init__.py +++ b/tests/hardware/__init__.py @@ -8,3 +8,10 @@ def verify_sorted_devices(devices: Mapping[str, DeviceCapabilities]) -> None: assert sorted_keys == list( devices.keys() ), f"Devices expected to sort like {sorted_keys}" + for device in devices.values(): + verify_get_refresh_commands(device) + + +def verify_get_refresh_commands(device_capabilites: DeviceCapabilities) -> None: + for event in device_capabilites.events.keys(): + device_capabilites.get_refresh_commands(event) diff --git a/tests/hardware/test_device_capabilities.py b/tests/hardware/test_device_capabilities.py index ff886555..030b6dd4 100644 --- a/tests/hardware/test_device_capabilities.py +++ b/tests/hardware/test_device_capabilities.py @@ -3,6 +3,11 @@ import pytest +from deebot_client.commands.json.battery import GetBattery +from deebot_client.commands.json.charge_state import GetChargeState +from deebot_client.commands.json.clean import GetCleanInfo +from deebot_client.commands.json.life_span import GetLifeSpan +from deebot_client.events import AvailabilityEvent, LifeSpan, LifeSpanEvent, StateEvent from deebot_client.hardware.device_capabilities import ( AbstractDeviceCapabilities, DeviceCapabilities, @@ -54,3 +59,26 @@ def test_DeviceCapabilites_check_for_required_events() -> None: match=r'Required event "AvailabilityEvent" is missing.', ): DeviceCapabilities("test", {}) + + +def test_get_refresh_commands() -> None: + device_capabilites = DeviceCapabilities( + "Test", + { + AvailabilityEvent: [GetBattery(True)], + LifeSpanEvent: [(lambda dc: GetLifeSpan(dc.capabilities[LifeSpan]))], + StateEvent: [GetChargeState(), GetCleanInfo()], + }, + {LifeSpan: {LifeSpan.BRUSH, LifeSpan.SIDE_BRUSH}}, + ) + + assert device_capabilites.get_refresh_commands(AvailabilityEvent) == [ + GetBattery(True) + ] + assert device_capabilites.get_refresh_commands(LifeSpanEvent) == [ + GetLifeSpan({LifeSpan.BRUSH, LifeSpan.SIDE_BRUSH}) + ] + assert device_capabilites.get_refresh_commands(StateEvent) == [ + GetChargeState(), + GetCleanInfo(), + ] diff --git a/tests/hardware/test_init.py b/tests/hardware/test_init.py index 86497885..7b1473c3 100644 --- a/tests/hardware/test_init.py +++ b/tests/hardware/test_init.py @@ -7,6 +7,8 @@ from deebot_client.hardware.deebot import _DEVICES, DEVICES from deebot_client.hardware.device_capabilities import DeviceCapabilities +from . import verify_get_refresh_commands + @pytest.mark.parametrize( "_class, expected", @@ -15,10 +17,16 @@ ("yna5x1", _DEVICES["yna5x1"]), ( "vi829v", - DeviceCapabilities(_DEVICES["vi829v"].name, DEVICES["yna5x1"].events), + DeviceCapabilities( + _DEVICES["vi829v"].name, + DEVICES["yna5x1"].events, + DEVICES["yna5x1"].capabilities, + ), ), ], ) def test_get_device_capabilities(_class: str, expected: DeviceCapabilities) -> None: """Test get_device_capabilities.""" - assert expected == get_device_capabilities(_class) + device_capabilities = get_device_capabilities(_class) + assert expected == device_capabilities + verify_get_refresh_commands(device_capabilities) diff --git a/tests/helpers.py b/tests/helpers.py index 4cf43fa7..62eb573c 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,4 +1,4 @@ -from collections.abc import Mapping +from collections.abc import Callable, Mapping from typing import Any from deebot_client.command import Command @@ -28,12 +28,23 @@ def verify_DisplayNameEnum_unique(enum: type[DisplayNameIntEnum]) -> None: 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_request_json(body: dict[str, Any]) -> dict[str, Any]: + return {"id": "ALZf", "ret": "ok", "resp": get_message_json(body)} -def get_message_json(data: dict[str, Any] | None | list[Any]) -> dict[str, Any]: - json = { +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, @@ -42,23 +53,19 @@ def get_message_json(data: dict[str, Any] | None | list[Any]) -> dict[str, Any]: "fwVer": "1.8.2", "hwVer": "0.1.1", }, - "body": { - "code": 0, - "msg": "ok", - }, + "body": body, } - if data: - json["body"]["data"] = data - return json def get_device_capabilities( - events: Mapping[type[Event], list[Command]] | None = None + events: Mapping[ + type[Event], list[Command | Callable[["DeviceCapabilities"], Command]] + ] + | None = None ) -> DeviceCapabilities: """Get test device capabilities.""" _events = {**events} if events else {} for event in _REQUIRED_EVENTS: - if event not in _events: - _events[event] = [] + _events.setdefault(event, []) return DeviceCapabilities("test", _events) From 36137179303326483c51ef5e75e08a099da74e2c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 8 Oct 2023 20:05:54 +0200 Subject: [PATCH 18/41] Bump pylint from 3.0.0 to 3.0.1 (#313) Bumps [pylint](https://github.com/pylint-dev/pylint) from 3.0.0 to 3.0.1. - [Release notes](https://github.com/pylint-dev/pylint/releases) - [Commits](https://github.com/pylint-dev/pylint/compare/v3.0.0...v3.0.1) --- updated-dependencies: - dependency-name: pylint dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements-test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-test.txt b/requirements-test.txt index 792e7491..6476e767 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,6 +1,6 @@ mypy==1.5.1 pre-commit==3.4.0 -pylint==3.0.0 +pylint==3.0.1 pytest==7.4.2 pytest-asyncio==0.21.1 pytest-cov==4.1.0 From 65ce80e6bacdef01fdbcfec085cc7ae1830fc0a0 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 9 Oct 2023 17:59:55 +0200 Subject: [PATCH 19/41] Call get_refresh_commands only once per event (#315) --- deebot_client/events/event_bus.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/deebot_client/events/event_bus.py b/deebot_client/events/event_bus.py index c5b1787a..c47031cf 100644 --- a/deebot_client/events/event_bus.py +++ b/deebot_client/events/event_bus.py @@ -22,8 +22,8 @@ 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]]] @@ -147,13 +147,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: - commands = self._device_capabilities.get_refresh_commands(event_class) + commands = processing_data.refresh_commands if not commands: return @@ -171,7 +172,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._device_capabilities.get_refresh_commands(event_class) + ) self._event_processing_dict[event_class] = event_processing_data return event_processing_data From 73ea7724b1e743d49638a1afa8b708ed0b95edcb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Oct 2023 18:17:31 +0200 Subject: [PATCH 20/41] Bump pytest-timeout from 2.1.0 to 2.2.0 (#314) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements-test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-test.txt b/requirements-test.txt index 6476e767..e35ccb7f 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -5,7 +5,7 @@ pytest==7.4.2 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.2.0 types-cachetools==5.3.0.6 From c2578b31e00fe7604913e8d1dcdaeed6f3c3ae6c Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 13 Oct 2023 08:37:28 +0200 Subject: [PATCH 21/41] Upgrade aiohttp to allow working on nightlies (#318) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 590100d3..25bd0299 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -aiohttp>=3.8.5,<3.9 +aiohttp>=3.8.5,<3.10 aiomqtt>=1.0.0,<2.0 cachetools>=5.0.0,<6.0 defusedxml From 97d641ddec00980cd305525a9d38314d9fc2c79e Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sun, 15 Oct 2023 10:40:14 +0200 Subject: [PATCH 22/41] Add create_from_mqtt and make strict types for command init (#319) --- deebot_client/command.py | 44 +++++++++++++ deebot_client/commands/json/clean_count.py | 7 +- deebot_client/commands/json/common.py | 21 ++---- deebot_client/commands/json/fan_speed.py | 14 ++-- deebot_client/commands/json/life_span.py | 23 ++----- deebot_client/commands/json/volume.py | 12 ++-- deebot_client/commands/json/water_info.py | 20 ++---- deebot_client/mqtt_client.py | 4 +- tests/commands/json/__init__.py | 22 ++++++- tests/commands/json/test_advanced_mode.py | 5 +- tests/commands/json/test_carpet.py | 7 +- tests/commands/json/test_clean_preference.py | 7 +- .../commands/json/test_continuous_cleaning.py | 7 +- tests/commands/json/test_fan_speed.py | 16 ++--- tests/commands/json/test_life_span.py | 21 +++--- tests/commands/json/test_mulitmap_state.py | 5 +- tests/commands/json/test_true_detect.py | 5 +- tests/commands/json/test_volume.py | 18 ++++++ tests/commands/json/test_water_info.py | 25 ++------ tests/test_command.py | 64 +++++++++++++++++++ tests/test_mqtt_client.py | 12 ++-- 21 files changed, 225 insertions(+), 134 deletions(-) create mode 100644 tests/commands/json/test_volume.py create mode 100644 tests/test_command.py diff --git a/deebot_client/command.py b/deebot_client/command.py index d3a075c5..3458b865 100644 --- a/deebot_client/command.py +++ b/deebot_client/command.py @@ -4,6 +4,8 @@ from dataclasses import dataclass, field from typing import Any, final +from deebot_client.exceptions import DeebotError + from .authentication import Authenticator from .const import PATH_API_IOT_DEVMANAGER, REQUEST_HEADERS, DataType from .events.event_bus import EventBus @@ -186,9 +188,51 @@ 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/json/clean_count.py b/deebot_client/commands/json/clean_count.py index d57ce90f..f44700bd 100644 --- a/deebot_client/commands/json/clean_count.py +++ b/deebot_client/commands/json/clean_count.py @@ -1,8 +1,8 @@ """Clean count command module.""" -from collections.abc import Mapping from typing import Any +from deebot_client.command import InitParam from deebot_client.events import CleanCountEvent from deebot_client.message import HandlingResult, MessageBodyDataDict @@ -32,6 +32,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/json/common.py b/deebot_client/commands/json/common.py index 1dd5acab..fbf6e6f0 100644 --- a/deebot_client/commands/json/common.py +++ b/deebot_client/commands/json/common.py @@ -1,10 +1,9 @@ """Base commands.""" from abc import ABC, abstractmethod -from collections.abc import Mapping from datetime import datetime from typing import Any -from deebot_client.command import Command, CommandMqttP2P, CommandResult +from deebot_client.command import Command, CommandMqttP2P, CommandResult, InitParam from deebot_client.const import DataType from deebot_client.events import AvailabilityEvent, EnableEvent from deebot_client.events.event_bus import EventBus @@ -108,16 +107,6 @@ class SetCommand(ExecuteCommand, CommandMqttP2P, ABC): 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]: @@ -156,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/json/fan_speed.py b/deebot_client/commands/json/fan_speed.py index 1bfab1f9..6f874033 100644 --- a/deebot_client/commands/json/fan_speed.py +++ b/deebot_client/commands/json/fan_speed.py @@ -1,7 +1,7 @@ """(fan) speed commands.""" -from collections.abc import Mapping from typing import Any +from deebot_client.command import InitParam from deebot_client.events import FanSpeedEvent, FanSpeedLevel from deebot_client.message import HandlingResult, MessageBodyDataDict @@ -30,13 +30,7 @@ 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: - if isinstance(speed, str): - speed = FanSpeedLevel.get(speed) - if isinstance(speed, FanSpeedLevel): - speed = speed.value - - super().__init__({"speed": speed}, **kwargs) + def __init__(self, speed: FanSpeedLevel) -> None: + super().__init__({"speed": speed.value}) diff --git a/deebot_client/commands/json/life_span.py b/deebot_client/commands/json/life_span.py index 98702e47..86dc0c13 100644 --- a/deebot_client/commands/json/life_span.py +++ b/deebot_client/commands/json/life_span.py @@ -1,33 +1,21 @@ """Life span commands.""" from typing import Any -from deebot_client.command import CommandMqttP2P +from deebot_client.command import CommandMqttP2P, InitParam 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, EventBus, ExecuteCommand -LifeSpanType = LifeSpan | str - - -def _get_str(_type: LifeSpanType) -> str: - if isinstance(_type, LifeSpan): - return _type.value - - return _type - class GetLifeSpan(CommandWithMessageHandling, MessageBodyDataList): """Get life span command.""" name = "getLifeSpan" - def __init__(self, _types: LifeSpanType | LST[LifeSpanType]) -> None: - if isinstance(_types, LifeSpanType): # type: ignore[misc, arg-type] - _types = set(_types) - - args = [_get_str(life_span) for life_span in _types] + def __init__(self, life_spans: LST[LifeSpan]) -> None: + args = [life_span.value for life_span in life_spans] super().__init__(args) @classmethod @@ -54,9 +42,10 @@ class ResetLifeSpan(ExecuteCommand, CommandMqttP2P): """Reset life span command.""" name = "resetLifeSpan" + _mqtt_params = {"type": InitParam(LifeSpan, "life_span")} - def __init__(self, type: LifeSpanType) -> None: # pylint: disable=redefined-builtin - super().__init__({"type": _get_str(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/json/volume.py b/deebot_client/commands/json/volume.py index 6a7eb81f..843348cb 100644 --- a/deebot_client/commands/json/volume.py +++ b/deebot_client/commands/json/volume.py @@ -1,8 +1,8 @@ """Volume command module.""" -from collections.abc import Mapping from typing import Any +from deebot_client.command import InitParam from deebot_client.events import VolumeEvent from deebot_client.message import HandlingResult, MessageBodyDataDict @@ -34,8 +34,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/json/water_info.py b/deebot_client/commands/json/water_info.py index 4ceed610..af3422e4 100644 --- a/deebot_client/commands/json/water_info.py +++ b/deebot_client/commands/json/water_info.py @@ -1,7 +1,7 @@ """Water info commands.""" -from collections.abc import Mapping from typing import Any +from deebot_client.command import InitParam from deebot_client.events import WaterAmount, WaterInfoEvent from deebot_client.message import HandlingResult, MessageBodyDataDict @@ -34,16 +34,10 @@ 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) - - if isinstance(amount, str): - amount = WaterAmount.get(amount) - if isinstance(amount, WaterAmount): - amount = amount.value - - super().__init__({"amount": amount}, **kwargs) + def __init__(self, amount: WaterAmount) -> None: + super().__init__({"amount": amount.value}) diff --git a/deebot_client/mqtt_client.py b/deebot_client/mqtt_client.py index bfd44485..8089fe48 100644 --- a/deebot_client/mqtt_client.py +++ b/deebot_client/mqtt_client.py @@ -289,7 +289,9 @@ 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 + ) else: if command := self._received_p2p_commands.pop(request_id, None): if sub_info := self._subscribtions.get(topic_split[3]): diff --git a/tests/commands/json/__init__.py b/tests/commands/json/__init__.py index 80cded3d..fa5232b9 100644 --- a/tests/commands/json/__init__.py +++ b/tests/commands/json/__init__.py @@ -6,8 +6,12 @@ from deebot_client.authentication import Authenticator from deebot_client.command import Command -from deebot_client.commands.json.common import ExecuteCommand, SetCommand -from deebot_client.events import Event +from deebot_client.commands.json.common import ( + ExecuteCommand, + SetCommand, + SetEnableCommand, +) +from deebot_client.events import EnableEvent, Event from deebot_client.events.event_bus import EventBus from deebot_client.models import Credentials, DeviceInfo from tests.helpers import get_message_json, get_request_json, get_success_body @@ -78,7 +82,7 @@ async def assert_execute_command( async def assert_set_command( command: SetCommand, - args: dict | list | None, + args: dict, expected_get_command_event: Event, ) -> None: await assert_execute_command(command, args) @@ -98,3 +102,15 @@ async def assert_set_command( # 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 index 529a24bd..454ee96e 100644 --- a/tests/commands/json/test_advanced_mode.py +++ b/tests/commands/json/test_advanced_mode.py @@ -4,7 +4,7 @@ from deebot_client.events import AdvancedModeEvent from tests.helpers import get_request_json, get_success_body -from . import assert_command, assert_set_command +from . import assert_command, assert_set_enable_command @pytest.mark.parametrize("value", [False, True]) @@ -15,5 +15,4 @@ async def test_GetAdvancedMode(value: bool) -> None: @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)) + await assert_set_enable_command(SetAdvancedMode(value), value, AdvancedModeEvent) diff --git a/tests/commands/json/test_carpet.py b/tests/commands/json/test_carpet.py index 4229a6fe..0dea13e0 100644 --- a/tests/commands/json/test_carpet.py +++ b/tests/commands/json/test_carpet.py @@ -4,7 +4,7 @@ from deebot_client.events import CarpetAutoFanBoostEvent from tests.helpers import get_request_json, get_success_body -from . import assert_command, assert_set_command +from . import assert_command, assert_set_enable_command @pytest.mark.parametrize("value", [False, True]) @@ -15,7 +15,6 @@ async def test_GetCarpetAutoFanBoost(value: bool) -> None: @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) + await assert_set_enable_command( + SetCarpetAutoFanBoost(value), value, CarpetAutoFanBoostEvent ) diff --git a/tests/commands/json/test_clean_preference.py b/tests/commands/json/test_clean_preference.py index 51946049..9ba26b8e 100644 --- a/tests/commands/json/test_clean_preference.py +++ b/tests/commands/json/test_clean_preference.py @@ -4,7 +4,7 @@ from deebot_client.events import CleanPreferenceEvent from tests.helpers import get_request_json, get_success_body -from . import assert_command, assert_set_command +from . import assert_command, assert_set_enable_command @pytest.mark.parametrize("value", [False, True]) @@ -15,7 +15,6 @@ async def test_GetCleanPreference(value: bool) -> None: @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) + await assert_set_enable_command( + SetCleanPreference(value), value, CleanPreferenceEvent ) diff --git a/tests/commands/json/test_continuous_cleaning.py b/tests/commands/json/test_continuous_cleaning.py index 1c596401..9f8e205a 100644 --- a/tests/commands/json/test_continuous_cleaning.py +++ b/tests/commands/json/test_continuous_cleaning.py @@ -4,7 +4,7 @@ from deebot_client.events import ContinuousCleaningEvent from tests.helpers import get_request_json, get_success_body -from . import assert_command, assert_set_command +from . import assert_command, assert_set_enable_command @pytest.mark.parametrize("value", [False, True]) @@ -15,7 +15,6 @@ async def test_GetContinuousCleaning(value: bool) -> None: @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) + await assert_set_enable_command( + SetContinuousCleaning(value), value, ContinuousCleaningEvent ) diff --git a/tests/commands/json/test_fan_speed.py b/tests/commands/json/test_fan_speed.py index d741dd1f..e211d96c 100644 --- a/tests/commands/json/test_fan_speed.py +++ b/tests/commands/json/test_fan_speed.py @@ -1,5 +1,3 @@ -import pytest - from deebot_client.commands.json import GetFanSpeed, SetFanSpeed from deebot_client.events import FanSpeedEvent from deebot_client.events.fan_speed import FanSpeedLevel @@ -9,7 +7,7 @@ verify_DisplayNameEnum_unique, ) -from . import assert_command +from . import assert_command, assert_set_command def test_FanSpeedLevel_unique() -> None: @@ -21,11 +19,7 @@ async def test_GetFanSpeed() -> None: 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} +async def test_SetFanSpeed() -> None: + command = SetFanSpeed(FanSpeedLevel.MAX) + args = {"speed": 1} + await assert_set_command(command, args, FanSpeedEvent(FanSpeedLevel.MAX)) diff --git a/tests/commands/json/test_life_span.py b/tests/commands/json/test_life_span.py index de596812..d06ce391 100644 --- a/tests/commands/json/test_life_span.py +++ b/tests/commands/json/test_life_span.py @@ -3,7 +3,7 @@ import pytest from deebot_client.commands.json import GetLifeSpan -from deebot_client.commands.json.life_span import LifeSpanType, ResetLifeSpan +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 @@ -14,7 +14,7 @@ "command, json, expected", [ ( - GetLifeSpan({"brush", LifeSpan.FILTER, LifeSpan.SIDE_BRUSH}), + GetLifeSpan({LifeSpan.BRUSH, LifeSpan.FILTER, LifeSpan.SIDE_BRUSH}), get_request_json( get_success_body( [ @@ -31,14 +31,14 @@ ], ), ( - GetLifeSpan(LifeSpan.FILTER), + GetLifeSpan([LifeSpan.FILTER]), get_request_json( get_success_body([{"type": "heap", "left": 7179, "total": 7200}]) ), [LifeSpanEvent(LifeSpan.FILTER, 99.71, 7179)], ), ( - GetLifeSpan("brush"), + GetLifeSpan({LifeSpan.BRUSH}), get_request_json( get_success_body([{"type": "brush", "left": 17979, "total": 18000}]) ), @@ -53,11 +53,14 @@ async def test_GetLifeSpan( @pytest.mark.parametrize( - "_type, args", + "command, args", [ - (LifeSpan.FILTER, {"type": LifeSpan.FILTER.value}), - ("brush", {"type": LifeSpan.BRUSH.value}), + (ResetLifeSpan(LifeSpan.FILTER), {"type": LifeSpan.FILTER.value}), + ( + ResetLifeSpan.create_from_mqtt({"type": "brush"}), + {"type": LifeSpan.BRUSH.value}, + ), ], ) -async def test_ResetLifeSpan(_type: LifeSpanType, args: dict[str, str]) -> None: - await assert_execute_command(ResetLifeSpan(_type), args) +async def test_ResetLifeSpan(command: ResetLifeSpan, args: dict[str, str]) -> None: + await assert_execute_command(command, args) diff --git a/tests/commands/json/test_mulitmap_state.py b/tests/commands/json/test_mulitmap_state.py index 10b19cd2..d6705ec3 100644 --- a/tests/commands/json/test_mulitmap_state.py +++ b/tests/commands/json/test_mulitmap_state.py @@ -4,7 +4,7 @@ from deebot_client.events import MultimapStateEvent from tests.helpers import get_request_json, get_success_body -from . import assert_command, assert_set_command +from . import assert_command, assert_set_enable_command @pytest.mark.parametrize("value", [False, True]) @@ -15,5 +15,4 @@ async def test_GetMultimapState(value: bool) -> None: @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)) + await assert_set_enable_command(SetMultimapState(value), value, MultimapStateEvent) diff --git a/tests/commands/json/test_true_detect.py b/tests/commands/json/test_true_detect.py index f0ec7ab7..0a33fb21 100644 --- a/tests/commands/json/test_true_detect.py +++ b/tests/commands/json/test_true_detect.py @@ -4,7 +4,7 @@ from deebot_client.events import TrueDetectEvent from tests.helpers import get_request_json, get_success_body -from . import assert_command, assert_set_command +from . import assert_command, assert_set_enable_command @pytest.mark.parametrize("value", [False, True]) @@ -15,5 +15,4 @@ async def test_GetTrueDetect(value: bool) -> None: @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)) + 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 index 17d75800..a228a913 100644 --- a/tests/commands/json/test_water_info.py +++ b/tests/commands/json/test_water_info.py @@ -30,24 +30,7 @@ async def test_GetWaterInfo(json: dict[str, Any], expected: WaterInfoEvent) -> N 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) +async def test_SetWaterInfo() -> None: + command = SetWaterInfo(WaterAmount.MEDIUM) + args = {"amount": 2} + await assert_set_command(command, args, WaterInfoEvent(None, WaterAmount.MEDIUM)) diff --git a/tests/test_command.py b/tests/test_command.py new file mode 100644 index 00000000..3bd914de --- /dev/null +++ b/tests/test_command.py @@ -0,0 +1,64 @@ +from typing import Any + +import pytest +from testfixtures import LogCapture + +from deebot_client.command import CommandMqttP2P, CommandResult, InitParam +from deebot_client.const import DataType +from deebot_client.events.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 | 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() -> None: + with LogCapture() as log: + _TestCommand.create_from_mqtt({"field": 0, "remove": "bla", "additional": 1}) + + log.check_present( + ( + "deebot_client.command", + "DEBUG", + "Following data will be ignored: {'additional': 1}", + ) + ) diff --git a/tests/test_mqtt_client.py b/tests/test_mqtt_client.py index 1c5c40b5..3bb1fdf0 100644 --- a/tests/test_mqtt_client.py +++ b/tests/test_mqtt_client.py @@ -239,7 +239,9 @@ 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", {DataType.JSON: {command_name: command_type}}, @@ -250,7 +252,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 @@ -329,7 +331,9 @@ 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", {DataType.JSON: {command_name: command_type}}, @@ -340,7 +344,7 @@ 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 From aedcefd3514231675c0f72bbc5abd49f5a81fb79 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 15 Oct 2023 10:41:20 +0200 Subject: [PATCH 23/41] [pre-commit.ci] pre-commit autoupdate (#316) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1b7df0b8..b505ced0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ default_language_version: repos: - repo: https://github.com/asottile/pyupgrade - rev: v3.14.0 + rev: v3.15.0 hooks: - id: pyupgrade args: @@ -52,7 +52,7 @@ repos: 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 From 20285a03b20e5a4d93ceb00f7abf1a47a3f967eb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Oct 2023 17:24:34 +0200 Subject: [PATCH 24/41] Bump pre-commit from 3.4.0 to 3.5.0 (#320) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements-test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-test.txt b/requirements-test.txt index e35ccb7f..324a086e 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,5 +1,5 @@ mypy==1.5.1 -pre-commit==3.4.0 +pre-commit==3.5.0 pylint==3.0.1 pytest==7.4.2 pytest-asyncio==0.21.1 From 14a640fca39fd8d057b0b6c8c3acc3269d7a5a6a Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 16 Oct 2023 19:54:09 +0200 Subject: [PATCH 25/41] Exclude TYPE_CHECKING from test coverage (#322) --- .coveragerc | 5 +++++ 1 file changed, 5 insertions(+) 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 From b79fd85b9d5868334f034a0bc3e770ce6609b061 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Oct 2023 12:15:27 +0200 Subject: [PATCH 26/41] Bump mypy from 1.5.1 to 1.6.0 (#317) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Robert Resch --- deebot_client/models.py | 3 ++- requirements-test.txt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/deebot_client/models.py b/deebot_client/models.py index cc0a9e62..a2c02f4d 100644 --- a/deebot_client/models.py +++ b/deebot_client/models.py @@ -2,6 +2,7 @@ import os from dataclasses import dataclass from enum import IntEnum, unique +from typing import cast from aiohttp import ClientSession @@ -29,7 +30,7 @@ def name(self) -> str: @property def nick(self) -> str | None: """Return nick name.""" - return self.get("nick", None) + return cast(str | None, self.get("nick", None)) @property def resource(self) -> str: diff --git a/requirements-test.txt b/requirements-test.txt index 324a086e..fbdd3649 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,4 +1,4 @@ -mypy==1.5.1 +mypy==1.6.0 pre-commit==3.5.0 pylint==3.0.1 pytest==7.4.2 From 2faec526322cf982db4e2b87f76e61366093cc07 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 17 Oct 2023 17:07:50 +0200 Subject: [PATCH 27/41] Activate mypy strict (#323) --- deebot_client/command.py | 6 +- deebot_client/commands/json/__init__.py | 44 +++++ deebot_client/commands/json/charge.py | 3 +- deebot_client/commands/json/charge_state.py | 3 +- deebot_client/commands/json/clean.py | 3 +- deebot_client/commands/json/clean_count.py | 3 +- deebot_client/commands/json/clean_logs.py | 4 +- deebot_client/commands/json/common.py | 4 +- deebot_client/commands/json/custom.py | 9 +- deebot_client/commands/json/error.py | 3 +- deebot_client/commands/json/fan_speed.py | 3 +- deebot_client/commands/json/life_span.py | 7 +- deebot_client/commands/json/map.py | 6 +- deebot_client/commands/json/pos.py | 3 +- deebot_client/commands/json/stats.py | 3 +- deebot_client/commands/json/volume.py | 3 +- deebot_client/commands/json/water_info.py | 3 +- deebot_client/commands/xml/common.py | 5 +- deebot_client/{events => }/event_bus.py | 14 +- deebot_client/events/__init__.py | 22 ++- deebot_client/map.py | 26 +-- deebot_client/message.py | 14 +- deebot_client/messages/json/__init__.py | 2 + deebot_client/messages/json/battery.py | 2 +- deebot_client/messages/json/stats.py | 2 +- deebot_client/models.py | 4 +- deebot_client/mqtt_client.py | 5 +- deebot_client/util.py | 2 +- deebot_client/vacuum_bot.py | 4 +- mypy.ini | 14 +- requirements-test.txt | 5 +- tests/commands/json/__init__.py | 6 +- tests/commands/json/test_charge.py | 21 +-- tests/commands/json/test_clean.py | 2 +- tests/commands/json/test_clean_log.py | 73 ++++---- tests/commands/json/test_common.py | 47 +++-- tests/conftest.py | 13 +- tests/messages/__init__.py | 2 +- tests/test_command.py | 26 ++- tests/{events => }/test_event_bus.py | 4 +- tests/test_map.py | 2 +- tests/test_mqtt_client.py | 183 +++++++++----------- 42 files changed, 343 insertions(+), 267 deletions(-) rename deebot_client/{events => }/event_bus.py (96%) rename tests/{events => }/test_event_bus.py (98%) diff --git a/deebot_client/command.py b/deebot_client/command.py index 3458b865..f16ab9f3 100644 --- a/deebot_client/command.py +++ b/deebot_client/command.py @@ -8,7 +8,7 @@ from .authentication import Authenticator from .const import PATH_API_IOT_DEVMANAGER, REQUEST_HEADERS, DataType -from .events.event_bus import EventBus +from .event_bus import EventBus from .logging_filter import get_logger from .message import HandlingResult, HandlingState from .models import DeviceInfo @@ -38,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 @@ -56,7 +56,7 @@ def data_type(cls) -> DataType: """Data type.""" # noqa: D401 @abstractmethod - def _get_payload(self) -> dict[str, Any] | list | str: + def _get_payload(self) -> dict[str, Any] | list[Any] | str: """Get the payload for the rest call.""" @final diff --git a/deebot_client/commands/json/__init__.py b/deebot_client/commands/json/__init__.py index 7c4932a3..a1609a33 100644 --- a/deebot_client/commands/json/__init__.py +++ b/deebot_client/commands/json/__init__.py @@ -32,6 +32,50 @@ from .volume import GetVolume, SetVolume from .water_info import GetWaterInfo, SetWaterInfo +__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", + "PlaySound", + "GetPos", + "SetRelocationState", + "GetStats", + "GetTotalStats", + "GetTrueDetect", + "SetTrueDetect", + "GetVolume", + "SetVolume", + "GetWaterInfo", + "SetWaterInfo", +] + # fmt: off # ordered by file asc _COMMANDS: list[type[JsonCommand]] = [ diff --git a/deebot_client/commands/json/charge.py b/deebot_client/commands/json/charge.py index 6c853187..516f76c4 100644 --- a/deebot_client/commands/json/charge.py +++ b/deebot_client/commands/json/charge.py @@ -1,12 +1,13 @@ """Charge commands.""" from typing import Any +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 VacuumState -from .common import EventBus, ExecuteCommand +from .common import ExecuteCommand from .const import CODE _LOGGER = get_logger(__name__) diff --git a/deebot_client/commands/json/charge_state.py b/deebot_client/commands/json/charge_state.py index 40c07bf6..857c666a 100644 --- a/deebot_client/commands/json/charge_state.py +++ b/deebot_client/commands/json/charge_state.py @@ -1,11 +1,12 @@ """Charge state commands.""" from typing import Any +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 VacuumState -from .common import CommandWithMessageHandling, EventBus +from .common import CommandWithMessageHandling from .const import CODE diff --git a/deebot_client/commands/json/clean.py b/deebot_client/commands/json/clean.py index 9933bc86..b38aafc6 100644 --- a/deebot_client/commands/json/clean.py +++ b/deebot_client/commands/json/clean.py @@ -4,12 +4,13 @@ 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 DeviceInfo, VacuumState -from .common import CommandWithMessageHandling, EventBus, ExecuteCommand +from .common import CommandWithMessageHandling, ExecuteCommand _LOGGER = get_logger(__name__) diff --git a/deebot_client/commands/json/clean_count.py b/deebot_client/commands/json/clean_count.py index f44700bd..d2d35827 100644 --- a/deebot_client/commands/json/clean_count.py +++ b/deebot_client/commands/json/clean_count.py @@ -3,10 +3,11 @@ from typing import Any 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, EventBus, SetCommand +from .common import CommandWithMessageHandling, SetCommand class GetCleanCount(CommandWithMessageHandling, MessageBodyDataDict): diff --git a/deebot_client/commands/json/clean_logs.py b/deebot_client/commands/json/clean_logs.py index 7d317640..f74c812f 100644 --- a/deebot_client/commands/json/clean_logs.py +++ b/deebot_client/commands/json/clean_logs.py @@ -4,8 +4,8 @@ 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.events.event_bus import EventBus from deebot_client.logging_filter import get_logger from deebot_client.models import DeviceInfo @@ -56,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/json/common.py b/deebot_client/commands/json/common.py index fbf6e6f0..02747a42 100644 --- a/deebot_client/commands/json/common.py +++ b/deebot_client/commands/json/common.py @@ -5,8 +5,8 @@ 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.events.event_bus import EventBus from deebot_client.logging_filter import get_logger from deebot_client.message import ( HandlingResult, @@ -25,7 +25,7 @@ class JsonCommand(Command): data_type: DataType = DataType.JSON - def _get_payload(self) -> dict[str, Any] | list: + def _get_payload(self) -> dict[str, Any] | list[Any]: payload = { "header": { "pri": "1", diff --git a/deebot_client/commands/json/custom.py b/deebot_client/commands/json/custom.py index 2e8a035a..d6d007fa 100644 --- a/deebot_client/commands/json/custom.py +++ b/deebot_client/commands/json/custom.py @@ -1,8 +1,9 @@ """Custom command module.""" from typing import Any -from deebot_client.command import CommandResult, EventBus +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 @@ -15,7 +16,9 @@ class CustomCommand(JsonCommand): 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) @@ -47,5 +50,5 @@ def __hash__(self) -> int: class CustomPayloadCommand(CustomCommand): """Custom command, where args is the raw payload.""" - def _get_json_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/json/error.py b/deebot_client/commands/json/error.py index 7e299b31..7c914154 100644 --- a/deebot_client/commands/json/error.py +++ b/deebot_client/commands/json/error.py @@ -1,11 +1,12 @@ """Error commands.""" from typing import Any +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 VacuumState -from .common import CommandWithMessageHandling, EventBus +from .common import CommandWithMessageHandling class GetError(CommandWithMessageHandling, MessageBodyDataDict): diff --git a/deebot_client/commands/json/fan_speed.py b/deebot_client/commands/json/fan_speed.py index 6f874033..243f9c4c 100644 --- a/deebot_client/commands/json/fan_speed.py +++ b/deebot_client/commands/json/fan_speed.py @@ -2,10 +2,11 @@ from typing import Any 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, EventBus, SetCommand +from .common import CommandWithMessageHandling, SetCommand class GetFanSpeed(CommandWithMessageHandling, MessageBodyDataDict): diff --git a/deebot_client/commands/json/life_span.py b/deebot_client/commands/json/life_span.py index 86dc0c13..57887a6b 100644 --- a/deebot_client/commands/json/life_span.py +++ b/deebot_client/commands/json/life_span.py @@ -2,11 +2,12 @@ from typing import Any 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, EventBus, ExecuteCommand +from .common import CommandWithMessageHandling, ExecuteCommand class GetLifeSpan(CommandWithMessageHandling, MessageBodyDataList): @@ -19,7 +20,9 @@ def __init__(self, life_spans: LST[LifeSpan]) -> None: 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 diff --git a/deebot_client/commands/json/map.py b/deebot_client/commands/json/map.py index 42bd857f..aea98f65 100644 --- a/deebot_client/commands/json/map.py +++ b/deebot_client/commands/json/map.py @@ -1,7 +1,8 @@ """Maps commands.""" from typing import Any -from deebot_client.command import Command +from deebot_client.command import Command, CommandResult +from deebot_client.event_bus import EventBus from deebot_client.events import ( MajorMapEvent, MapSetEvent, @@ -10,11 +11,10 @@ MapTraceEvent, MinorMapEvent, ) -from deebot_client.events.event_bus import EventBus from deebot_client.events.map import CachedMapInfoEvent from deebot_client.message import HandlingResult, HandlingState, MessageBodyDataDict -from .common import CommandResult, CommandWithMessageHandling +from .common import CommandWithMessageHandling class GetCachedMapInfo(CommandWithMessageHandling, MessageBodyDataDict): diff --git a/deebot_client/commands/json/pos.py b/deebot_client/commands/json/pos.py index db20f13c..6f743e1d 100644 --- a/deebot_client/commands/json/pos.py +++ b/deebot_client/commands/json/pos.py @@ -2,10 +2,11 @@ from typing import Any +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, EventBus +from .common import CommandWithMessageHandling class GetPos(CommandWithMessageHandling, MessageBodyDataDict): diff --git a/deebot_client/commands/json/stats.py b/deebot_client/commands/json/stats.py index 8502ec5c..1acd0b86 100644 --- a/deebot_client/commands/json/stats.py +++ b/deebot_client/commands/json/stats.py @@ -1,10 +1,11 @@ """Stats commands.""" from typing import Any +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, EventBus +from .common import CommandWithMessageHandling class GetStats(CommandWithMessageHandling, MessageBodyDataDict): diff --git a/deebot_client/commands/json/volume.py b/deebot_client/commands/json/volume.py index 843348cb..7bfc2081 100644 --- a/deebot_client/commands/json/volume.py +++ b/deebot_client/commands/json/volume.py @@ -3,10 +3,11 @@ from typing import Any 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, EventBus, SetCommand +from .common import CommandWithMessageHandling, SetCommand class GetVolume(CommandWithMessageHandling, MessageBodyDataDict): diff --git a/deebot_client/commands/json/water_info.py b/deebot_client/commands/json/water_info.py index af3422e4..812b7e81 100644 --- a/deebot_client/commands/json/water_info.py +++ b/deebot_client/commands/json/water_info.py @@ -2,10 +2,11 @@ from typing import Any 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, EventBus, SetCommand +from .common import CommandWithMessageHandling, SetCommand class GetWaterInfo(CommandWithMessageHandling, MessageBodyDataDict): diff --git a/deebot_client/commands/xml/common.py b/deebot_client/commands/xml/common.py index b08bcaec..e2f8cb41 100644 --- a/deebot_client/commands/xml/common.py +++ b/deebot_client/commands/xml/common.py @@ -24,7 +24,8 @@ def _get_payload(self) -> str: if self.has_sub_element: element = ElementTree.SubElement(element, self.name.lower()) - for key in self._args: - element.set(key, self._args[key]) + 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/events/event_bus.py b/deebot_client/event_bus.py similarity index 96% rename from deebot_client/events/event_bus.py rename to deebot_client/event_bus.py index c47031cf..223a6859 100644 --- a/deebot_client/events/event_bus.py +++ b/deebot_client/event_bus.py @@ -5,14 +5,14 @@ from datetime import datetime, timedelta, timezone 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 VacuumState +from .util import cancel, create_task if TYPE_CHECKING: - from ..command import Command - from ..hardware.device_capabilities import DeviceCapabilities + from .command import Command + from .hardware.device_capabilities import DeviceCapabilities _LOGGER = get_logger(__name__) @@ -42,7 +42,7 @@ def __init__( execute_command: Callable[["Command"], Coroutine[Any, Any, None]], device_capabilities: "DeviceCapabilities", ): - 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() diff --git a/deebot_client/events/__init__.py b/deebot_client/events/__init__.py index e72365b0..a893e14f 100644 --- a/deebot_client/events/__init__.py +++ b/deebot_client/events/__init__.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from enum import Enum, unique -from typing import Any, Optional +from typing import Any from ..events.base import Event from ..models import Room, VacuumState @@ -21,6 +21,26 @@ ) from .water_info import WaterAmount, WaterInfoEvent +__all__ = [ + "Event", + "BatteryEvent", + "CleanJobStatus", + "CleanLogEntry", + "WaterAmount", + "WaterInfoEvent", + "MajorMapEvent", + "MapSetEvent", + "MapSetType", + "MapSubsetEvent", + "MapTraceEvent", + "MinorMapEvent", + "Position", + "PositionsEvent", + "PositionType", + "FanSpeedEvent", + "FanSpeedLevel", +] + @dataclass(frozen=True) class BatteryEvent(Event): diff --git a/deebot_client/map.py b/deebot_client/map.py index d40db127..ea52f89a 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -12,13 +12,15 @@ from io import BytesIO from typing import Any, Final -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.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,16 +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.""" # 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]: @@ -490,8 +494,8 @@ 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.""" diff --git a/deebot_client/message.py b/deebot_client/message.py index 1708631c..97a72acc 100644 --- a/deebot_client/message.py +++ b/deebot_client/message.py @@ -6,7 +6,7 @@ from enum import IntEnum, auto 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__) @@ -137,7 +137,7 @@ class MessageBodyData(MessageBody): @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. @@ -147,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) @@ -184,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. @@ -201,7 +201,9 @@ class MessageBodyDataList(MessageBodyData): @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 @@ -209,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/json/__init__.py b/deebot_client/messages/json/__init__.py index 2b09576f..002cc308 100644 --- a/deebot_client/messages/json/__init__.py +++ b/deebot_client/messages/json/__init__.py @@ -6,6 +6,8 @@ from .battery import OnBattery from .stats import ReportStats +__all__ = ["OnBattery", "ReportStats"] + # fmt: off # ordered by file asc _MESSAGES: list[type[Message]] = [ diff --git a/deebot_client/messages/json/battery.py b/deebot_client/messages/json/battery.py index c93fafa7..cabe1f67 100644 --- a/deebot_client/messages/json/battery.py +++ b/deebot_client/messages/json/battery.py @@ -1,8 +1,8 @@ """Battery messages.""" from typing import Any +from deebot_client.event_bus import EventBus from deebot_client.events import BatteryEvent -from deebot_client.events.event_bus import EventBus from deebot_client.message import HandlingResult, MessageBodyDataDict diff --git a/deebot_client/messages/json/stats.py b/deebot_client/messages/json/stats.py index 310ae98e..385cfb84 100644 --- a/deebot_client/messages/json/stats.py +++ b/deebot_client/messages/json/stats.py @@ -1,8 +1,8 @@ """Stats messages.""" from typing import Any +from deebot_client.event_bus import EventBus from deebot_client.events import CleanJobStatus, ReportStatsEvent -from deebot_client.events.event_bus import EventBus from deebot_client.message import HandlingResult, MessageBodyDataDict diff --git a/deebot_client/models.py b/deebot_client/models.py index a2c02f4d..2946b832 100644 --- a/deebot_client/models.py +++ b/deebot_client/models.py @@ -2,14 +2,14 @@ import os from dataclasses import dataclass from enum import IntEnum, unique -from typing import cast +from typing import Any, cast from aiohttp import ClientSession from deebot_client.const import DataType -class DeviceInfo(dict): +class DeviceInfo(dict[str, Any]): """Class holds all values, which we get from api. Common values can be accessed through properties.""" @property diff --git a/deebot_client/mqtt_client.py b/deebot_client/mqtt_client.py index 8089fe48..0b34a34d 100644 --- a/deebot_client/mqtt_client.py +++ b/deebot_client/mqtt_client.py @@ -6,13 +6,14 @@ from contextlib import suppress from dataclasses import _MISSING_TYPE, InitVar, dataclass, field, fields from datetime import datetime +from typing import Any from aiomqtt import Client, Message, MqttError from cachetools import TTLCache from deebot_client.command import CommandMqttP2P from deebot_client.const import DataType -from deebot_client.events.event_bus import EventBus +from deebot_client.event_bus import EventBus from deebot_client.exceptions import AuthenticationError from .authentication import Authenticator @@ -97,7 +98,7 @@ 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, CommandMqttP2P] = TTLCache( maxsize=60 * 60, ttl=60 diff --git a/deebot_client/util.py b/deebot_client/util.py index 10155301..856b8a16 100644 --- a/deebot_client/util.py +++ b/deebot_client/util.py @@ -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]) -> DisplayNameIntEnum: """Create new DisplayNameIntEnum.""" obj = int.__new__(cls) obj._value_ = args[0] diff --git a/deebot_client/vacuum_bot.py b/deebot_client/vacuum_bot.py index 039966cb..4b09ae33 100644 --- a/deebot_client/vacuum_bot.py +++ b/deebot_client/vacuum_bot.py @@ -12,6 +12,7 @@ from .authentication import Authenticator from .command import Command +from .event_bus import EventBus from .events import ( AvailabilityEvent, CleanLogEvent, @@ -23,7 +24,6 @@ StatsEvent, TotalStatsEvent, ) -from .events.event_bus import EventBus from .logging_filter import get_logger from .map import Map from .messages import get_message @@ -48,7 +48,7 @@ def __init__( 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 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/requirements-test.txt b/requirements-test.txt index fbdd3649..adc6dbff 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -8,5 +8,6 @@ pytest-docker-fixtures==1.3.17 pytest-timeout==2.2.0 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability testfixtures==7.2.0 -types-cachetools==5.3.0.6 -types-mock==5.1.0.2 +types-cachetools +types-mock +types-Pillow diff --git a/tests/commands/json/__init__.py b/tests/commands/json/__init__.py index fa5232b9..81fa3d00 100644 --- a/tests/commands/json/__init__.py +++ b/tests/commands/json/__init__.py @@ -11,8 +11,8 @@ SetCommand, SetEnableCommand, ) +from deebot_client.event_bus import EventBus from deebot_client.events import EnableEvent, Event -from deebot_client.events.event_bus import EventBus from deebot_client.models import Credentials, DeviceInfo from tests.helpers import get_message_json, get_request_json, get_success_body @@ -56,7 +56,7 @@ async def assert_command( async def assert_execute_command( - command: ExecuteCommand, args: dict | list | None + command: ExecuteCommand, args: dict[str, Any] | list[Any] | None ) -> None: assert command.name != "invalid" assert command._args == args @@ -82,7 +82,7 @@ async def assert_execute_command( async def assert_set_command( command: SetCommand, - args: dict, + args: dict[str, Any], expected_get_command_event: Event, ) -> None: await assert_execute_command(command, args) diff --git a/tests/commands/json/test_charge.py b/tests/commands/json/test_charge.py index 3be4a516..4adc1013 100644 --- a/tests/commands/json/test_charge.py +++ b/tests/commands/json/test_charge.py @@ -1,7 +1,7 @@ +import logging from typing import Any import pytest -from testfixtures import LogCapture from deebot_client.commands.json import Charge from deebot_client.events import StateEvent @@ -33,15 +33,12 @@ 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) +async def test_Charge_failed(caplog: pytest.LogCaptureFixture) -> None: + json = _prepare_json(500, "fail") + await assert_command(Charge(), json, None) - log.check_present( - ( - "deebot_client.commands.json.common", - "WARNING", - f"Command \"charge\" was not successfully. body={json['resp']['body']}", - ) - ) + 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/json/test_clean.py b/tests/commands/json/test_clean.py index 65d704d3..083734b5 100644 --- a/tests/commands/json/test_clean.py +++ b/tests/commands/json/test_clean.py @@ -6,8 +6,8 @@ from deebot_client.authentication import Authenticator 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.helpers import get_request_json, get_success_body diff --git a/tests/commands/json/test_clean_log.py b/tests/commands/json/test_clean_log.py index d7e6bcf6..c5cd3439 100644 --- a/tests/commands/json/test_clean_log.py +++ b/tests/commands/json/test_clean_log.py @@ -1,7 +1,7 @@ +import logging from typing import Any import pytest -from testfixtures import LogCapture from deebot_client.commands.json import GetCleanLogs from deebot_client.events import CleanJobStatus, CleanLogEntry, CleanLogEvent @@ -9,7 +9,7 @@ from . import assert_command -async def test_GetCleanLogs() -> None: +async def test_GetCleanLogs(caplog: pytest.LogCaptureFixture) -> None: json = { "ret": "ok", "logs": [ @@ -105,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.json.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_common.py b/tests/commands/json/test_common.py index 270cba3a..7370236c 100644 --- a/tests/commands/json/test_common.py +++ b/tests/commands/json/test_common.py @@ -1,15 +1,15 @@ +import logging from collections.abc import Callable from typing import Any from unittest.mock import Mock import pytest -from testfixtures import LogCapture 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"} @@ -37,7 +37,7 @@ def _assert_false_and_avalable_event_false(available: bool, event_bus: Mock) -> ( _ERROR_500, ( - "WARNING", + logging.WARNING, 'No response received for command "{}". This can happen if the vacuum has network issues or does not support the command', ), _assert_false_and_not_called, @@ -45,7 +45,7 @@ def _assert_false_and_avalable_event_false(available: bool, event_bus: Mock) -> ( {"ret": "fail", "errno": 123, "debug": "other error"}, ( - "WARNING", + logging.WARNING, 'Command "{}" was not successfully.', ), _assert_false_and_not_called, @@ -53,7 +53,7 @@ def _assert_false_and_avalable_event_false(available: bool, event_bus: Mock) -> ( _ERROR_4200, ( - "INFO", + logging.INFO, 'Vacuum 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.json.common", - "INFO", - f'No response received for command "{command.name}" during availability-check.', - ) - ) - elif expected_log: - log.check_present( - ( - "deebot_client.commands.json.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/conftest.py b/tests/conftest.py index 9061315e..72d4b246 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,7 @@ from deebot_client import hardware from deebot_client.api_client import ApiClient from deebot_client.authentication import Authenticator -from deebot_client.events.event_bus import EventBus +from deebot_client.event_bus import EventBus from deebot_client.hardware.device_capabilities import DeviceCapabilities from deebot_client.models import Configuration, Credentials, DeviceInfo from deebot_client.mqtt_client import MqttClient, MqttConfiguration @@ -19,14 +19,14 @@ @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: +async def config(session: aiohttp.ClientSession) -> AsyncGenerator[Configuration, None]: configuration = Configuration( session, device_id="Test_device", @@ -151,3 +151,10 @@ def event_bus( @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/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/test_command.py b/tests/test_command.py index 3bd914de..24043ef9 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -1,11 +1,11 @@ +import logging from typing import Any import pytest -from testfixtures import LogCapture from deebot_client.command import CommandMqttP2P, CommandResult, InitParam from deebot_client.const import DataType -from deebot_client.events.event_bus import EventBus +from deebot_client.event_bus import EventBus from deebot_client.exceptions import DeebotError @@ -20,7 +20,7 @@ def __init__(self, field: int) -> None: def handle_mqtt_p2p(self, event_bus: EventBus, response: dict[str, Any]) -> None: pass - def _get_payload(self) -> dict[str, Any] | list | str: + def _get_payload(self) -> dict[str, Any] | list[Any] | str: return {} def _handle_response( @@ -51,14 +51,12 @@ def test_CommandMqttP2P_create_from_mqtt_error( _TestCommand.create_from_mqtt(data) -def test_CommandMqttP2P_create_from_mqtt_additional_fields() -> None: - with LogCapture() as log: - _TestCommand.create_from_mqtt({"field": 0, "remove": "bla", "additional": 1}) - - log.check_present( - ( - "deebot_client.command", - "DEBUG", - "Following data will be ignored: {'additional': 1}", - ) - ) +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/events/test_event_bus.py b/tests/test_event_bus.py similarity index 98% rename from tests/events/test_event_bus.py rename to tests/test_event_bus.py index 53d7a317..0d35d666 100644 --- a/tests/events/test_event_bus.py +++ b/tests/test_event_bus.py @@ -5,9 +5,9 @@ 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.event_bus import EventBus from deebot_client.events.map import MapChangedEvent from deebot_client.events.water_info import WaterInfoEvent from deebot_client.models import VacuumState @@ -162,7 +162,7 @@ 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)) diff --git a/tests/test_map.py b/tests/test_map.py index 263d3499..0111a175 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, diff --git a/tests/test_mqtt_client.py b/tests/test_mqtt_client.py index 3bb1fdf0..32445d4e 100644 --- a/tests/test_mqtt_client.py +++ b/tests/test_mqtt_client.py @@ -1,6 +1,7 @@ import asyncio import datetime import json +import logging import ssl from collections.abc import Callable from typing import Any @@ -10,13 +11,12 @@ from aiohttp import ClientSession from aiomqtt import Client, Message from cachetools import TTLCache -from testfixtures import LogCapture from deebot_client.authentication import Authenticator 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.events.event_bus import EventBus +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 @@ -80,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( @@ -91,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") @@ -270,25 +264,23 @@ 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, + mqtt_client: MqttClient, caplog: pytest.LogCaptureFixture ) -> None: """Test that unsupported command will be logged.""" topic_split = [ @@ -306,22 +298,20 @@ async def test_p2p_data_type_not_supported( "z", ] - with LogCapture() as log: - mqtt_client._handle_p2p(topic_split, "") + mqtt_client._handle_p2p(topic_split, "") - log.check_present( - ( - "deebot_client.mqtt_client", - "WARNING", - 'Unsupported data type: "z"', - ) - ) + 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 @@ -348,28 +338,26 @@ async def test_p2p_to_late( 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) @@ -384,18 +372,15 @@ 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( @@ -413,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() From 9bb27b9337961b166a752bcfed80dc9f84504407 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Oct 2023 18:27:12 +0200 Subject: [PATCH 28/41] Bump mypy from 1.6.0 to 1.6.1 (#324) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements-test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-test.txt b/requirements-test.txt index adc6dbff..306a67d4 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,4 +1,4 @@ -mypy==1.6.0 +mypy==1.6.1 pre-commit==3.5.0 pylint==3.0.1 pytest==7.4.2 From 61f6a4ad93a09638cf5825d8ff91aeb1168d4fa2 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 19 Oct 2023 08:05:29 +0200 Subject: [PATCH 29/41] Add ruff (#325) --- .pre-commit-config.yaml | 19 ++---- deebot_client/authentication.py | 9 ++- deebot_client/command.py | 3 +- deebot_client/commands/json/clean.py | 2 +- deebot_client/mqtt_client.py | 34 ++++------ deebot_client/vacuum_bot.py | 4 +- pyproject.toml | 98 +++++++++++++++++++++++++++- setup.cfg | 28 -------- setup.py | 2 +- tests/fixtures/base.py | 9 +-- tests/hardware/__init__.py | 2 +- 11 files changed, 131 insertions(+), 79 deletions(-) delete mode 100644 setup.cfg diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b505ced0..ce5e461f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,13 +9,19 @@ default_language_version: python: python3.11 repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.0 + hooks: + - id: ruff + args: + - --fix - repo: https://github.com/asottile/pyupgrade rev: v3.15.0 hooks: - id: pyupgrade args: - --py311-plus - - repo: https://github.com/psf/black + - repo: https://github.com/psf/black-pre-commit-mirror rev: 23.9.1 hooks: - id: black @@ -32,13 +38,6 @@ repos: exclude_types: - csv - json - - repo: https://github.com/PyCQA/flake8 - rev: 6.1.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: @@ -47,10 +46,6 @@ repos: - --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.5.0 hooks: diff --git a/deebot_client/authentication.py b/deebot_client/authentication.py index aeda87f2..be1a4a06 100644 --- a/deebot_client/authentication.py +++ b/deebot_client/authentication.py @@ -2,6 +2,7 @@ import asyncio import time from collections.abc import Callable, Coroutine, Mapping +from http import HTTPStatus from typing import Any from urllib.parse import urljoin @@ -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", @@ -278,7 +279,7 @@ async def post( 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", @@ -384,9 +385,7 @@ 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 diff --git a/deebot_client/command.py b/deebot_client/command.py index f16ab9f3..7414c063 100644 --- a/deebot_client/command.py +++ b/deebot_client/command.py @@ -65,7 +65,8 @@ async def execute( ) -> 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. """ diff --git a/deebot_client/commands/json/clean.py b/deebot_client/commands/json/clean.py index b38aafc6..974f73a2 100644 --- a/deebot_client/commands/json/clean.py +++ b/deebot_client/commands/json/clean.py @@ -75,7 +75,7 @@ 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) diff --git a/deebot_client/mqtt_client.py b/deebot_client/mqtt_client.py index 0b34a34d..95cf4b66 100644 --- a/deebot_client/mqtt_client.py +++ b/deebot_client/mqtt_client.py @@ -192,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) @@ -253,9 +252,7 @@ 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 @@ -293,18 +290,15 @@ def _handle_p2p( 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/vacuum_bot.py b/deebot_client/vacuum_bot.py index 4b09ae33..06038a23 100644 --- a/deebot_client/vacuum_bot.py +++ b/deebot_client/vacuum_bot.py @@ -187,6 +187,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/pyproject.toml b/pyproject.toml index 70d1ec29..3b1488e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,99 @@ [tool.black] target-version = ['py311'] -safe = true \ No newline at end of file +safe = true + + +[tool.ruff] +select = [ + "B002", # Python does not support the unary prefix increment + "B007", # Loop control variable {name} not used within loop body + "B014", # Exception handler with duplicate exception + "B023", # Function definition does not bind loop variable {name} + "B026", # Star-arg unpacking after a keyword argument is strongly discouraged + "C", # complexity + "COM818", # Trailing comma on bare tuple prohibited + "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", # flake8-logging-format + "I", # isort + "ICN001", # import concentions; {name} should be imported as {asname} + "ISC001", # Implicitly concatenated string literals on one line + "N804", # First argument of a class method should be named cls + "N805", # First argument of a method should be named self + "N815", # Variable {name} in class scope should not be mixedCase + "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 + "Q000", # Double quotes found but single quotes preferred + "RUF006", # Store a reference to the return value of asyncio.create_task + "S102", # Use of exec detected + "S103", # bad-file-permissions + "S108", # hardcoded-temp-file + "S306", # suspicious-mktemp-usage + "S307", # suspicious-eval-usage + "S313", # suspicious-xmlc-element-tree-usage + "S314", # suspicious-xml-element-tree-usage + "S315", # suspicious-xml-expat-reader-usage + "S316", # suspicious-xml-expat-builder-usage + "S317", # suspicious-xml-sax-usage + "S318", # suspicious-xml-mini-dom-usage + "S319", # suspicious-xml-pull-dom-usage + "S320", # suspicious-xmle-tree-usage + "S601", # paramiko-call + "S602", # subprocess-popen-with-shell-equals-true + "S604", # call-with-shell-equals-true + "S608", # hardcoded-sql-expression + "S609", # unix-command-wildcard-injection + "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass + "SIM117", # Merge with-statements that use the same scope + "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() + "SIM201", # Use {left} != {right} instead of not {left} == {right} + "SIM208", # Use {expr} instead of not (not {expr}) + "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} + "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. + "SIM401", # Use get from dict with default instead of an if block + "T100", # Trace found: {name} used + "T20", # flake8-print + "TID251", # Banned imports + "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 + "T201", # print found +] + +[tool.ruff.mccabe] +max-complexity = 12 + +[tool.ruff.pylint] +max-args = 7 + + 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 index 0598ea39..85fee9a7 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ long_description = file.read() with open("requirements.txt", encoding="utf-8") as file: - install_requires = list(val.strip() for val in file) + install_requires = [val.strip() for val in file] setup( name="deebot-client", diff --git a/tests/fixtures/base.py b/tests/fixtures/base.py index dbb39053..a53d5c2b 100644 --- a/tests/fixtures/base.py +++ b/tests/fixtures/base.py @@ -1,3 +1,4 @@ +import contextlib import os import re from abc import ABC, abstractmethod @@ -204,11 +205,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 index 50faae22..8cc1dc08 100644 --- a/tests/hardware/__init__.py +++ b/tests/hardware/__init__.py @@ -13,5 +13,5 @@ def verify_sorted_devices(devices: Mapping[str, DeviceCapabilities]) -> None: def verify_get_refresh_commands(device_capabilites: DeviceCapabilities) -> None: - for event in device_capabilites.events.keys(): + for event in device_capabilites.events: device_capabilites.get_refresh_commands(event) From 165da7b5ef19e066c54b7a6229cf4c9c43d45ae0 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 19 Oct 2023 08:21:01 +0200 Subject: [PATCH 30/41] Remove bandit (#326) --- .pre-commit-config.yaml | 8 -------- bandit.yaml | 20 -------------------- deebot_client/authentication.py | 27 +++++++++++---------------- deebot_client/util.py | 2 +- pyproject.toml | 20 ++------------------ 5 files changed, 14 insertions(+), 63 deletions(-) delete mode 100644 bandit.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ce5e461f..1f393dd2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,14 +38,6 @@ repos: exclude_types: - csv - json - - repo: https://github.com/PyCQA/bandit - rev: 1.7.5 - hooks: - - id: bandit - args: - - --quiet - - --format=custom - - --configfile=bandit.yaml - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.5.0 hooks: 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/authentication.py b/deebot_client/authentication.py index be1a4a06..c0a29e44 100644 --- a/deebot_client/authentication.py +++ b/deebot_client/authentication.py @@ -17,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" @@ -320,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( @@ -376,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") @@ -390,7 +386,6 @@ async def async_refresh() -> None: 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/util.py b/deebot_client/util.py index 856b8a16..11d6ada5 100644 --- a/deebot_client/util.py +++ b/deebot_client/util.py @@ -13,7 +13,7 @@ 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( diff --git a/pyproject.toml b/pyproject.toml index 3b1488e6..c2bc3940 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,24 +33,7 @@ select = [ "PLW", # pylint "Q000", # Double quotes found but single quotes preferred "RUF006", # Store a reference to the return value of asyncio.create_task - "S102", # Use of exec detected - "S103", # bad-file-permissions - "S108", # hardcoded-temp-file - "S306", # suspicious-mktemp-usage - "S307", # suspicious-eval-usage - "S313", # suspicious-xmlc-element-tree-usage - "S314", # suspicious-xml-element-tree-usage - "S315", # suspicious-xml-expat-reader-usage - "S316", # suspicious-xml-expat-builder-usage - "S317", # suspicious-xml-sax-usage - "S318", # suspicious-xml-mini-dom-usage - "S319", # suspicious-xml-pull-dom-usage - "S320", # suspicious-xmle-tree-usage - "S601", # paramiko-call - "S602", # subprocess-popen-with-shell-equals-true - "S604", # call-with-shell-equals-true - "S608", # hardcoded-sql-expression - "S609", # unix-command-wildcard-injection + "S", # flake8-bandit "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass "SIM117", # Merge with-statements that use the same scope "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() @@ -87,6 +70,7 @@ ignore = [ "D103", # Missing docstring in public function "D104", # Missing docstring in public package "N802", # Function name {name} should be lowercase + "S101", # Use of assert detected "T201", # print found ] From 2a4ff03ab4347603529da6b82f050f24feb3f3b2 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 19 Oct 2023 08:49:19 +0200 Subject: [PATCH 31/41] Fix imports (#327) --- deebot_client/authentication.py | 2 +- deebot_client/command.py | 2 +- deebot_client/commands/__init__.py | 2 +- deebot_client/event_bus.py | 10 +++++----- deebot_client/map.py | 12 +++++------- deebot_client/message.py | 2 +- deebot_client/models.py | 2 +- deebot_client/mqtt_client.py | 4 ++-- deebot_client/util.py | 2 +- deebot_client/vacuum_bot.py | 2 +- pyproject.toml | 12 ++++++++++++ tests/commands/json/test_common.py | 2 +- tests/conftest.py | 4 ++-- tests/fixtures/base.py | 6 +++--- tests/test_event_bus.py | 4 ++-- tests/test_mqtt_client.py | 4 ++-- tests/test_vacuum_bot.py | 2 +- 17 files changed, 42 insertions(+), 32 deletions(-) diff --git a/deebot_client/authentication.py b/deebot_client/authentication.py index c0a29e44..834681cd 100644 --- a/deebot_client/authentication.py +++ b/deebot_client/authentication.py @@ -1,8 +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 diff --git a/deebot_client/command.py b/deebot_client/command.py index 7414c063..7177877d 100644 --- a/deebot_client/command.py +++ b/deebot_client/command.py @@ -1,6 +1,6 @@ """Base command.""" -import asyncio from abc import ABC, abstractmethod +import asyncio from dataclasses import dataclass, field from typing import Any, final diff --git a/deebot_client/commands/__init__.py b/deebot_client/commands/__init__.py index 9f3e4709..421803d2 100644 --- a/deebot_client/commands/__init__.py +++ b/deebot_client/commands/__init__.py @@ -2,8 +2,8 @@ from deebot_client.command import Command, CommandMqttP2P from deebot_client.const import DataType -from .json import COMMANDS as JSON_COMMANDS from .json import ( + COMMANDS as JSON_COMMANDS, COMMANDS_WITH_MQTT_P2P_HANDLING as JSON_COMMANDS_WITH_MQTT_P2P_HANDLING, ) diff --git a/deebot_client/event_bus.py b/deebot_client/event_bus.py index 223a6859..86be8e0c 100644 --- a/deebot_client/event_bus.py +++ b/deebot_client/event_bus.py @@ -1,8 +1,8 @@ """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 .events import AvailabilityEvent, Event, StateEvent @@ -30,7 +30,7 @@ def __init__(self, refresh_commands: list["Command"]) -> 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 @@ -89,7 +89,7 @@ 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 ( @@ -124,7 +124,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): diff --git a/deebot_client/map.py b/deebot_client/map.py index ea52f89a..4650f4e7 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -2,15 +2,15 @@ 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 float64, reshape, zeros from numpy.typing import NDArray @@ -548,9 +548,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 97a72acc..46c25a93 100644 --- a/deebot_client/message.py +++ b/deebot_client/message.py @@ -1,9 +1,9 @@ """Base messages.""" -import functools from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass from enum import IntEnum, auto +import functools from typing import Any, TypeVar, final from .event_bus import EventBus diff --git a/deebot_client/models.py b/deebot_client/models.py index 2946b832..3b44a260 100644 --- a/deebot_client/models.py +++ b/deebot_client/models.py @@ -1,7 +1,7 @@ """Models module.""" -import os from dataclasses import dataclass from enum import IntEnum, unique +import os from typing import Any, cast from aiohttp import ClientSession diff --git a/deebot_client/mqtt_client.py b/deebot_client/mqtt_client.py index 95cf4b66..97dc6959 100644 --- a/deebot_client/mqtt_client.py +++ b/deebot_client/mqtt_client.py @@ -1,11 +1,11 @@ """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 diff --git a/deebot_client/util.py b/deebot_client/util.py index 11d6ada5..cecef8d8 100644 --- a/deebot_client/util.py +++ b/deebot_client/util.py @@ -2,10 +2,10 @@ 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 +import hashlib from typing import Any, TypeVar _T = TypeVar("_T") diff --git a/deebot_client/vacuum_bot.py b/deebot_client/vacuum_bot.py index 06038a23..5d96e82b 100644 --- a/deebot_client/vacuum_bot.py +++ b/deebot_client/vacuum_bot.py @@ -1,9 +1,9 @@ """Vacuum bot 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.hardware import get_device_capabilities diff --git a/pyproject.toml b/pyproject.toml index c2bc3940..8c8c78af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,6 +2,18 @@ target-version = ['py311'] 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 = [ diff --git a/tests/commands/json/test_common.py b/tests/commands/json/test_common.py index 7370236c..0e0d6696 100644 --- a/tests/commands/json/test_common.py +++ b/tests/commands/json/test_common.py @@ -1,5 +1,5 @@ -import logging from collections.abc import Callable +import logging from typing import Any from unittest.mock import Mock diff --git a/tests/conftest.py b/tests/conftest.py index 72d4b246..9bfbac17 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,10 @@ -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 import hardware from deebot_client.api_client import ApiClient diff --git a/tests/fixtures/base.py b/tests/fixtures/base.py index a53d5c2b..de1957ff 100644 --- a/tests/fixtures/base.py +++ b/tests/fixtures/base.py @@ -1,11 +1,11 @@ -import contextlib -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 diff --git a/tests/test_event_bus.py b/tests/test_event_bus.py index 0d35d666..56f316be 100644 --- a/tests/test_event_bus.py +++ b/tests/test_event_bus.py @@ -1,6 +1,6 @@ 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 @@ -165,7 +165,7 @@ async def notify(event: MapChangedEvent, debounce_time: float) -> None: 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_mqtt_client.py b/tests/test_mqtt_client.py index 32445d4e..3008c820 100644 --- a/tests/test_mqtt_client.py +++ b/tests/test_mqtt_client.py @@ -1,16 +1,16 @@ 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 +import pytest from deebot_client.authentication import Authenticator from deebot_client.commands.json.battery import GetBattery diff --git a/tests/test_vacuum_bot.py b/tests/test_vacuum_bot.py index 82caf56f..2735cce8 100644 --- a/tests/test_vacuum_bot.py +++ b/tests/test_vacuum_bot.py @@ -1,6 +1,6 @@ import asyncio -import json from collections.abc import Callable +import json from unittest.mock import Mock, patch from deebot_client.authentication import Authenticator From e1e9766f6ded8db4104ce29acb88eed0616abbce Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 19 Oct 2023 21:13:37 +0200 Subject: [PATCH 32/41] Activate some ruff rules (#328) --- deebot_client/command.py | 2 +- deebot_client/commands/json/charge_state.py | 6 ++-- deebot_client/events/__init__.py | 7 ++-- deebot_client/events/fan_speed.py | 3 +- deebot_client/events/map.py | 2 +- deebot_client/events/water_info.py | 3 +- deebot_client/messages/__init__.py | 8 +++-- deebot_client/util.py | 6 ++-- pyproject.toml | 40 ++++++++++----------- tests/commands/json/test_charge.py | 2 +- tests/commands/json/test_charge_state.py | 2 +- tests/commands/json/test_clean.py | 4 +-- tests/commands/json/test_common.py | 2 +- tests/commands/json/test_custom.py | 2 +- tests/commands/json/test_life_span.py | 4 +-- tests/commands/json/test_water_info.py | 2 +- tests/conftest.py | 6 ++-- tests/fixtures/base.py | 19 +++++----- tests/hardware/test_init.py | 6 ++-- tests/messages/json/test_stats.py | 2 +- tests/messages/test_get_messages.py | 2 +- tests/test_command.py | 2 +- tests/test_event_bus.py | 2 +- tests/test_map.py | 2 +- tests/test_mqtt_client.py | 4 +-- 25 files changed, 71 insertions(+), 69 deletions(-) diff --git a/deebot_client/command.py b/deebot_client/command.py index 7177877d..c4d0d56d 100644 --- a/deebot_client/command.py +++ b/deebot_client/command.py @@ -20,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": diff --git a/deebot_client/commands/json/charge_state.py b/deebot_client/commands/json/charge_state.py index 857c666a..7b6730b7 100644 --- a/deebot_client/commands/json/charge_state.py +++ b/deebot_client/commands/json/charge_state.py @@ -37,9 +37,9 @@ def _handle_body(cls, event_bus: EventBus, body: dict[str, Any]) -> HandlingResu 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 + elif body["code"] in ("3", "5"): + # 3 -> Bot in stuck state, example dust bin out + # 5 -> Busy with another command status = VacuumState.ERROR if status: diff --git a/deebot_client/events/__init__.py b/deebot_client/events/__init__.py index a893e14f..e66db5f4 100644 --- a/deebot_client/events/__init__.py +++ b/deebot_client/events/__init__.py @@ -4,9 +4,10 @@ from enum import Enum, unique from typing import Any -from ..events.base import Event -from ..models import Room, VacuumState -from ..util import DisplayNameIntEnum +from deebot_client.events.base import Event +from deebot_client.models import Room, VacuumState +from deebot_client.util import DisplayNameIntEnum + from .fan_speed import FanSpeedEvent, FanSpeedLevel from .map import ( MajorMapEvent, 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/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/messages/__init__.py b/deebot_client/messages/__init__.py index 71afa72b..47c6aae5 100644 --- a/deebot_client/messages/__init__.py +++ b/deebot_client/messages/__init__.py @@ -4,9 +4,9 @@ import re from deebot_client.const import DataType +from deebot_client.logging_filter import get_logger +from deebot_client.message import Message -from ..logging_filter import get_logger -from ..message import Message from .json import MESSAGES as JSON_MESSAGES _LOGGER = get_logger(__name__) @@ -44,7 +44,9 @@ def get_message(message_name: str, data_type: DataType) -> 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(data_type, {}).get(converted_name, None): if issubclass(found_command, Message): diff --git a/deebot_client/util.py b/deebot_client/util.py index cecef8d8..28488685 100644 --- a/deebot_client/util.py +++ b/deebot_client/util.py @@ -6,7 +6,7 @@ from contextlib import suppress from enum import IntEnum, unique import hashlib -from typing import Any, TypeVar +from typing import Any, Self, TypeVar _T = TypeVar("_T") @@ -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[Any, Any]) -> 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__: diff --git a/pyproject.toml b/pyproject.toml index 8c8c78af..b5e15b9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,25 +17,22 @@ known-first-party = [ [tool.ruff] select = [ - "B002", # Python does not support the unary prefix increment - "B007", # Loop control variable {name} not used within loop body - "B014", # Exception handler with duplicate exception - "B023", # Function definition does not bind loop variable {name} - "B026", # Star-arg unpacking after a keyword argument is strongly discouraged + "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", # flake8-logging-format + "G", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g "I", # isort "ICN001", # import concentions; {name} should be imported as {asname} - "ISC001", # Implicitly concatenated string literals on one line - "N804", # First argument of a class method should be named cls - "N805", # First argument of a method should be named self - "N815", # Variable {name} in class scope should not be mixedCase + "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. @@ -43,20 +40,19 @@ select = [ "PLE", # pylint "PLR", # pylint "PLW", # pylint - "Q000", # Double quotes found but single quotes preferred + "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 - "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass - "SIM117", # Merge with-statements that use the same scope - "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() - "SIM201", # Use {left} != {right} instead of not {left} == {right} - "SIM208", # Use {expr} instead of not (not {expr}) - "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} - "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. - "SIM401", # Use get from dict with default instead of an if block + "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", # flake8-print - "TID251", # Banned imports + "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 @@ -82,7 +78,9 @@ ignore = [ "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 ] diff --git a/tests/commands/json/test_charge.py b/tests/commands/json/test_charge.py index 4adc1013..bcd2cebb 100644 --- a/tests/commands/json/test_charge.py +++ b/tests/commands/json/test_charge.py @@ -23,7 +23,7 @@ def _prepare_json(code: int, msg: str = "ok") -> dict[str, Any]: @pytest.mark.parametrize( - "json, expected", + ("json", "expected"), [ (get_request_json(get_success_body()), StateEvent(VacuumState.RETURNING)), (_prepare_json(30007), StateEvent(VacuumState.DOCKED)), diff --git a/tests/commands/json/test_charge_state.py b/tests/commands/json/test_charge_state.py index 5c017790..814016ba 100644 --- a/tests/commands/json/test_charge_state.py +++ b/tests/commands/json/test_charge_state.py @@ -10,7 +10,7 @@ @pytest.mark.parametrize( - "json, expected", + ("json", "expected"), [ (get_request_json(get_success_body({"isCharging": 0, "mode": "slot"})), None), ], diff --git a/tests/commands/json/test_clean.py b/tests/commands/json/test_clean.py index 083734b5..f26ff69f 100644 --- a/tests/commands/json/test_clean.py +++ b/tests/commands/json/test_clean.py @@ -15,7 +15,7 @@ @pytest.mark.parametrize( - "json, expected", + ("json", "expected"), [ ( get_request_json(get_success_body({"trigger": "none", "state": "idle"})), @@ -28,7 +28,7 @@ async def test_GetCleanInfo(json: dict[str, Any], expected: StateEvent) -> None: @pytest.mark.parametrize( - "action, vacuum_state, expected", + ("action", "vacuum_state", "expected"), [ (CleanAction.START, None, CleanAction.START), (CleanAction.START, VacuumState.PAUSED, CleanAction.RESUME), diff --git a/tests/commands/json/test_common.py b/tests/commands/json/test_common.py index 0e0d6696..4580dc06 100644 --- a/tests/commands/json/test_common.py +++ b/tests/commands/json/test_common.py @@ -32,7 +32,7 @@ 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, diff --git a/tests/commands/json/test_custom.py b/tests/commands/json/test_custom.py index b2f9b1a4..78db9a59 100644 --- a/tests/commands/json/test_custom.py +++ b/tests/commands/json/test_custom.py @@ -10,7 +10,7 @@ @pytest.mark.parametrize( - "command, json, expected", + ("command", "json", "expected"), [ ( CustomCommand("getSleep"), diff --git a/tests/commands/json/test_life_span.py b/tests/commands/json/test_life_span.py index d06ce391..eb751f64 100644 --- a/tests/commands/json/test_life_span.py +++ b/tests/commands/json/test_life_span.py @@ -11,7 +11,7 @@ @pytest.mark.parametrize( - "command, json, expected", + ("command", "json", "expected"), [ ( GetLifeSpan({LifeSpan.BRUSH, LifeSpan.FILTER, LifeSpan.SIDE_BRUSH}), @@ -53,7 +53,7 @@ async def test_GetLifeSpan( @pytest.mark.parametrize( - "command, args", + ("command", "args"), [ (ResetLifeSpan(LifeSpan.FILTER), {"type": LifeSpan.FILTER.value}), ( diff --git a/tests/commands/json/test_water_info.py b/tests/commands/json/test_water_info.py index a228a913..4d80bcfa 100644 --- a/tests/commands/json/test_water_info.py +++ b/tests/commands/json/test_water_info.py @@ -18,7 +18,7 @@ def test_WaterAmount_unique() -> None: @pytest.mark.parametrize( - "json, expected", + ("json", "expected"), [ ({"amount": 2}, WaterInfoEvent(None, WaterAmount.MEDIUM)), ({"amount": 1, "enable": 1}, WaterInfoEvent(True, WaterAmount.LOW)), diff --git a/tests/conftest.py b/tests/conftest.py index 9bfbac17..b5e2bfd5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,16 +26,14 @@ async def session() -> AsyncGenerator[aiohttp.ClientSession, None]: @pytest.fixture -async def config(session: aiohttp.ClientSession) -> AsyncGenerator[Configuration, None]: - 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: diff --git a/tests/fixtures/base.py b/tests/fixtures/base.py index de1957ff..a0c7577e 100644 --- a/tests/fixtures/base.py +++ b/tests/fixtures/base.py @@ -1,5 +1,4 @@ from abc import ABC, abstractmethod -from collections import namedtuple import contextlib from dataclasses import dataclass, field from datetime import datetime @@ -7,7 +6,7 @@ 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 @@ -17,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: @@ -35,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): @@ -81,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 = {} @@ -109,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"] @@ -146,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) diff --git a/tests/hardware/test_init.py b/tests/hardware/test_init.py index 7b1473c3..570c3578 100644 --- a/tests/hardware/test_init.py +++ b/tests/hardware/test_init.py @@ -11,7 +11,7 @@ @pytest.mark.parametrize( - "_class, expected", + ("class_", "expected"), [ ("not_specified", _DEFAULT), ("yna5x1", _DEVICES["yna5x1"]), @@ -25,8 +25,8 @@ ), ], ) -def test_get_device_capabilities(_class: str, expected: DeviceCapabilities) -> None: +def test_get_device_capabilities(class_: str, expected: DeviceCapabilities) -> None: """Test get_device_capabilities.""" - device_capabilities = get_device_capabilities(_class) + device_capabilities = get_device_capabilities(class_) assert expected == device_capabilities verify_get_refresh_commands(device_capabilities) diff --git a/tests/messages/json/test_stats.py b/tests/messages/json/test_stats.py index 78446940..2ad87285 100644 --- a/tests/messages/json/test_stats.py +++ b/tests/messages/json/test_stats.py @@ -8,7 +8,7 @@ @pytest.mark.parametrize( - "data, expected", + ("data", "expected"), [ ( { diff --git a/tests/messages/test_get_messages.py b/tests/messages/test_get_messages.py index bbcea11a..cf58205a 100644 --- a/tests/messages/test_get_messages.py +++ b/tests/messages/test_get_messages.py @@ -8,7 +8,7 @@ @pytest.mark.parametrize( - "name, data_type, expected", + ("name", "data_type", "expected"), [ ("onBattery", DataType.JSON, OnBattery), ("onBattery_V2", DataType.JSON, OnBattery), diff --git a/tests/test_command.py b/tests/test_command.py index 24043ef9..990e8fb6 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -38,7 +38,7 @@ class TestCommandNoParams(CommandMqttP2P): @pytest.mark.parametrize( - "data, expected", + ("data", "expected"), [ ({"field": "a"}, r"""Could not convert "a" of field into """), ({"something": "a"}, r'"field" is missing in {\'something\': \'a\'}'), diff --git a/tests/test_event_bus.py b/tests/test_event_bus.py index 56f316be..124a9c6d 100644 --- a/tests/test_event_bus.py +++ b/tests/test_event_bus.py @@ -118,7 +118,7 @@ async def test_request_refresh(execute_mock: AsyncMock, event_bus: EventBus) -> @pytest.mark.parametrize( - "last, actual, expected", + ("last", "actual", "expected"), [ (VacuumState.DOCKED, VacuumState.IDLE, None), (VacuumState.CLEANING, VacuumState.IDLE, VacuumState.IDLE), diff --git a/tests/test_map.py b/tests/test_map.py index 0111a175..cc56e326 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -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 3008c820..217e5330 100644 --- a/tests/test_mqtt_client.py +++ b/tests/test_mqtt_client.py @@ -138,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( @@ -384,7 +384,7 @@ async def test_p2p_parse_error( @pytest.mark.parametrize( - "exception_to_raise, expected_log_message", + ("exception_to_raise", "expected_log_message"), [ ( AuthenticationError, From aca261f6af6bddd124666219372ed7a3374a2e39 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sat, 21 Oct 2023 12:12:17 +0200 Subject: [PATCH 33/41] Update capabilities (#321) --- deebot_client/api_client.py | 8 +- deebot_client/capabilities.py | 199 ++++++++++++++++++ deebot_client/commands/json/clean.py | 22 +- deebot_client/commands/json/fan_speed.py | 4 +- deebot_client/commands/json/water_info.py | 4 +- deebot_client/event_bus.py | 7 +- deebot_client/events/__init__.py | 18 +- deebot_client/hardware/__init__.py | 93 +------- deebot_client/hardware/deebot.py | 88 -------- deebot_client/hardware/deebot/__init__.py | 34 +++ deebot_client/hardware/deebot/fallback.py | 173 +++++++++++++++ deebot_client/hardware/deebot/vi829v.py | 1 + deebot_client/hardware/deebot/yna5xi.py | 158 ++++++++++++++ deebot_client/hardware/device_capabilities.py | 103 --------- deebot_client/hardware/exceptions.py | 39 ---- deebot_client/models.py | 88 ++++++-- deebot_client/util.py | 5 + deebot_client/vacuum_bot.py | 7 +- tests/commands/json/__init__.py | 4 +- tests/commands/json/test_fan_speed.py | 14 +- tests/commands/json/test_water_info.py | 12 +- tests/conftest.py | 31 +-- tests/hardware/__init__.py | 17 -- tests/hardware/test_deebot.py | 11 - tests/hardware/test_device_capabilities.py | 84 -------- tests/hardware/test_init.py | 156 ++++++++++++-- tests/helpers.py | 35 +-- tests/test_event_bus.py | 2 +- tests/test_vacuum_bot.py | 151 +++++++------ 29 files changed, 952 insertions(+), 616 deletions(-) create mode 100644 deebot_client/capabilities.py delete mode 100644 deebot_client/hardware/deebot.py create mode 100644 deebot_client/hardware/deebot/__init__.py create mode 100644 deebot_client/hardware/deebot/fallback.py create mode 120000 deebot_client/hardware/deebot/vi829v.py create mode 100644 deebot_client/hardware/deebot/yna5xi.py delete mode 100644 deebot_client/hardware/device_capabilities.py delete mode 100644 deebot_client/hardware/exceptions.py delete mode 100644 tests/hardware/test_deebot.py delete mode 100644 tests/hardware/test_device_capabilities.py 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/capabilities.py b/deebot_client/capabilities.py new file mode 100644 index 00000000..da4d0ec4 --- /dev/null +++ b/deebot_client/capabilities.py @@ -0,0 +1,199 @@ +"""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, + PositionsEvent, + ReportStatsEvent, + RoomsEvent, + StateEvent, + StatsEvent, + TotalStatsEvent, + TrueDetectEvent, + VolumeEvent, + WaterAmount, + WaterInfoEvent, +) +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 + + +@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 + 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/commands/json/clean.py b/deebot_client/commands/json/clean.py index 974f73a2..85a4104e 100644 --- a/deebot_client/commands/json/clean.py +++ b/deebot_client/commands/json/clean.py @@ -1,5 +1,4 @@ """Clean commands.""" -from enum import Enum, unique from typing import Any from deebot_client.authentication import Authenticator @@ -8,32 +7,13 @@ 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 DeviceInfo, VacuumState +from deebot_client.models import CleanAction, CleanMode, DeviceInfo, VacuumState from .common import CommandWithMessageHandling, ExecuteCommand _LOGGER = get_logger(__name__) -@unique -class CleanAction(str, Enum): - """Enum class for all possible clean actions.""" - - START = "start" - PAUSE = "pause" - RESUME = "resume" - STOP = "stop" - - -@unique -class CleanMode(str, Enum): - """Enum class for all possible clean modes.""" - - AUTO = "auto" - SPOT_AREA = "spotArea" - CUSTOM_AREA = "customArea" - - class Clean(ExecuteCommand): """Clean command.""" diff --git a/deebot_client/commands/json/fan_speed.py b/deebot_client/commands/json/fan_speed.py index 243f9c4c..6f93a808 100644 --- a/deebot_client/commands/json/fan_speed.py +++ b/deebot_client/commands/json/fan_speed.py @@ -33,5 +33,7 @@ class SetFanSpeed(SetCommand): get_command = GetFanSpeed _mqtt_params = {"speed": InitParam(FanSpeedLevel)} - def __init__(self, speed: FanSpeedLevel) -> None: + def __init__(self, speed: FanSpeedLevel | str) -> None: + if isinstance(speed, str): + speed = FanSpeedLevel.get(speed) super().__init__({"speed": speed.value}) diff --git a/deebot_client/commands/json/water_info.py b/deebot_client/commands/json/water_info.py index 812b7e81..1d7aab36 100644 --- a/deebot_client/commands/json/water_info.py +++ b/deebot_client/commands/json/water_info.py @@ -40,5 +40,7 @@ class SetWaterInfo(SetCommand): "enable": None, # Remove it as we don't can set it (App includes it) } - def __init__(self, amount: WaterAmount) -> None: + def __init__(self, amount: WaterAmount | str) -> None: + if isinstance(amount, str): + amount = WaterAmount.get(amount) super().__init__({"amount": amount.value}) diff --git a/deebot_client/event_bus.py b/deebot_client/event_bus.py index 86be8e0c..afc9275c 100644 --- a/deebot_client/event_bus.py +++ b/deebot_client/event_bus.py @@ -12,7 +12,6 @@ if TYPE_CHECKING: from .command import Command - from .hardware.device_capabilities import DeviceCapabilities _LOGGER = get_logger(__name__) @@ -40,14 +39,14 @@ class EventBus: def __init__( self, execute_command: Callable[["Command"], Coroutine[Any, Any, None]], - device_capabilities: "DeviceCapabilities", + get_refresh_commands: Callable[[type[Event]], list["Command"]], ): 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._device_capabilities = device_capabilities + self._get_refresh_commands = get_refresh_commands def has_subscribers(self, event: type[T]) -> bool: """Return True, if emitter has subscribers.""" @@ -173,7 +172,7 @@ def _get_or_create_event_processing_data( if event_processing_data is None: event_processing_data = _EventProcessingData( - self._device_capabilities.get_refresh_commands(event_class) + self._get_refresh_commands(event_class) ) self._event_processing_dict[event_class] = event_processing_data diff --git a/deebot_client/events/__init__.py b/deebot_client/events/__init__.py index e66db5f4..55f66b4c 100644 --- a/deebot_client/events/__init__.py +++ b/deebot_client/events/__init__.py @@ -10,7 +10,9 @@ from .fan_speed import FanSpeedEvent, FanSpeedLevel from .map import ( + CachedMapInfoEvent, MajorMapEvent, + MapChangedEvent, MapSetEvent, MapSetType, MapSubsetEvent, @@ -23,23 +25,25 @@ from .water_info import WaterAmount, WaterInfoEvent __all__ = [ - "Event", "BatteryEvent", + "CachedMapInfoEvent", "CleanJobStatus", "CleanLogEntry", - "WaterAmount", - "WaterInfoEvent", + "Event", + "FanSpeedEvent", + "FanSpeedLevel", "MajorMapEvent", + "MapChangedEvent", "MapSetEvent", "MapSetType", "MapSubsetEvent", "MapTraceEvent", "MinorMapEvent", "Position", - "PositionsEvent", "PositionType", - "FanSpeedEvent", - "FanSpeedLevel", + "PositionsEvent", + "WaterAmount", + "WaterInfoEvent", ] @@ -107,9 +111,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) diff --git a/deebot_client/hardware/__init__.py b/deebot_client/hardware/__init__.py index f5da905a..0d42dd42 100644 --- a/deebot_client/hardware/__init__.py +++ b/deebot_client/hardware/__init__.py @@ -1,95 +1,6 @@ """Hardware module.""" -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.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, - ErrorEvent, - LifeSpan, - LifeSpanEvent, - MultimapStateEvent, - RoomsEvent, - StateEvent, - StatsEvent, - TotalStatsEvent, - TrueDetectEvent, - VolumeEvent, -) -from deebot_client.events.fan_speed import FanSpeedEvent -from deebot_client.events.map import ( - CachedMapInfoEvent, - MajorMapEvent, - MapTraceEvent, - PositionsEvent, -) -from deebot_client.events.water_info import WaterInfoEvent -from deebot_client.hardware.device_capabilities import DeviceCapabilities -from deebot_client.logging_filter import get_logger +from .deebot import get_static_device_info -from . import deebot - -_LOGGER = get_logger(__name__) - -_DEFAULT = DeviceCapabilities( - "_default", - { - AvailabilityEvent: [GetBattery(True)], - AdvancedModeEvent: [GetAdvancedMode()], - BatteryEvent: [GetBattery()], - CachedMapInfoEvent: [GetCachedMapInfo()], - CarpetAutoFanBoostEvent: [GetCarpetAutoFanBoost()], - CleanLogEvent: [GetCleanLogs()], - CleanCountEvent: [GetCleanCount()], - CleanPreferenceEvent: [GetCleanPreference()], - ContinuousCleaningEvent: [GetContinuousCleaning()], - ErrorEvent: [GetError()], - FanSpeedEvent: [GetFanSpeed()], - LifeSpanEvent: [(lambda dc: GetLifeSpan(dc.capabilities[LifeSpan]))], - MajorMapEvent: [GetMajorMap()], - MapTraceEvent: [GetMapTrace()], - MultimapStateEvent: [GetMultimapState()], - PositionsEvent: [GetPos()], - RoomsEvent: [GetCachedMapInfo()], - StatsEvent: [GetStats()], - StateEvent: [GetChargeState(), GetCleanInfo()], - TotalStatsEvent: [GetTotalStats()], - TrueDetectEvent: [GetTrueDetect()], - VolumeEvent: [GetVolume()], - WaterInfoEvent: [GetWaterInfo()], - }, - {LifeSpan: list(LifeSpan)}, -) - - -def get_device_capabilities(clazz: str) -> DeviceCapabilities: - """Get device capabilities for given class.""" - if device := deebot.DEVICES.get(clazz): - return device - - _LOGGER.debug("No device capabilities found for %s. Fallback to default.", clazz) - return _DEFAULT +__all__ = ["get_static_device_info"] diff --git a/deebot_client/hardware/deebot.py b/deebot_client/hardware/deebot.py deleted file mode 100644 index b6bc1ca1..00000000 --- a/deebot_client/hardware/deebot.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Deebot devices.""" - -from collections.abc import Mapping - -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_logs import GetCleanLogs -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.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, - CleanLogEvent, - ContinuousCleaningEvent, - ErrorEvent, - LifeSpan, - LifeSpanEvent, - MultimapStateEvent, - RoomsEvent, - StateEvent, - StatsEvent, - TotalStatsEvent, - TrueDetectEvent, - VolumeEvent, -) -from deebot_client.events.fan_speed import FanSpeedEvent -from deebot_client.events.map import ( - CachedMapInfoEvent, - MajorMapEvent, - MapTraceEvent, - PositionsEvent, -) -from deebot_client.events.water_info import WaterInfoEvent -from deebot_client.hardware.device_capabilities import ( - AbstractDeviceCapabilities, - DeviceCapabilities, - DeviceCapabilitiesRef, - convert, -) - -_DEVICES: Mapping[str, AbstractDeviceCapabilities] = { - "vi829v": DeviceCapabilitiesRef("Deebot Ozmo 920", "yna5x1"), - "yna5x1": DeviceCapabilities( - "Deebot Ozmo 950", - { - AdvancedModeEvent: [GetAdvancedMode()], - AvailabilityEvent: [GetBattery(True)], - BatteryEvent: [GetBattery()], - CachedMapInfoEvent: [GetCachedMapInfo()], - CarpetAutoFanBoostEvent: [GetCarpetAutoFanBoost()], - CleanLogEvent: [GetCleanLogs()], - ContinuousCleaningEvent: [GetContinuousCleaning()], - ErrorEvent: [GetError()], - FanSpeedEvent: [GetFanSpeed()], - LifeSpanEvent: [(lambda dc: GetLifeSpan(dc.capabilities[LifeSpan]))], - MajorMapEvent: [GetMajorMap()], - MapTraceEvent: [GetMapTrace()], - MultimapStateEvent: [GetMultimapState()], - PositionsEvent: [GetPos()], - RoomsEvent: [GetCachedMapInfo()], - StateEvent: [GetChargeState(), GetCleanInfo()], - StatsEvent: [GetStats()], - TotalStatsEvent: [GetTotalStats()], - TrueDetectEvent: [GetTrueDetect()], - VolumeEvent: [GetVolume()], - WaterInfoEvent: [GetWaterInfo()], - }, - {LifeSpan: {LifeSpan.BRUSH, LifeSpan.FILTER, LifeSpan.SIDE_BRUSH}}, - ), -} - -DEVICES: Mapping[str, DeviceCapabilities] = { - _class: convert(_class, device, _DEVICES) for _class, device in _DEVICES.items() -} 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..dd1ba4f9 --- /dev/null +++ b/deebot_client/hardware/deebot/fallback.py @@ -0,0 +1,173 @@ +"""Deebot ozmo 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_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.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, + PositionsEvent, + ReportStatsEvent, + RoomsEvent, + StateEvent, + StatsEvent, + TotalStatsEvent, + TrueDetectEvent, + VolumeEvent, + WaterAmount, + WaterInfoEvent, +) +from deebot_client.models import StaticDeviceInfo + +from . import DEVICES, FALLBACK + +DEVICES[FALLBACK] = 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()]), + ), + 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..3edc657e --- /dev/null +++ b/deebot_client/hardware/deebot/yna5xi.py @@ -0,0 +1,158 @@ +"""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.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.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()]), + ), + 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/hardware/device_capabilities.py b/deebot_client/hardware/device_capabilities.py deleted file mode 100644 index 1f6a4c65..00000000 --- a/deebot_client/hardware/device_capabilities.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Device module.""" -from abc import ABC -from collections.abc import Callable, Mapping -from dataclasses import dataclass -from typing import Any, TypeVar - -from attr import field - -from deebot_client.command import Command -from deebot_client.events import AvailabilityEvent, CustomCommandEvent, ReportStatsEvent -from deebot_client.events.base import Event -from deebot_client.events.map import MapSetEvent, MapSubsetEvent, MinorMapEvent -from deebot_client.util import LST - -from .exceptions import ( - DeviceCapabilitiesRefNotFoundError, - InvalidDeviceCapabilitiesError, - RequiredEventMissingError, -) - -_COMMON_NO_POLL_EVENTS = [ - CustomCommandEvent, - MapSetEvent, - MapSubsetEvent, - MinorMapEvent, - ReportStatsEvent, -] - -_REQUIRED_EVENTS = [AvailabilityEvent] - -_T = TypeVar("_T") - -CapabilitiesDict = dict[type[_T], LST[_T]] - - -@dataclass(frozen=True) -class AbstractDeviceCapabilities(ABC): - """Abstract device capabilities.""" - - name: str - - -@dataclass(frozen=True) -class DeviceCapabilities(AbstractDeviceCapabilities): - """Device capabilities.""" - - events: Mapping[ - type[Event], list[Command | Callable[["DeviceCapabilities"], Command]] - ] - capabilities: CapabilitiesDict[Any] = field(factory=dict) - - def __post_init__(self) -> None: - events = {**self.events} - for event in _COMMON_NO_POLL_EVENTS: - events.setdefault(event, []) - - object.__setattr__(self, "events", events) - - for event in _REQUIRED_EVENTS: - if event not in events: - raise RequiredEventMissingError(event) - - def get_refresh_commands(self, event: type[Event]) -> list[Command]: - """Return refresh command for given event.""" - commands = [] - for command in self.events.get(event, []): - if isinstance(command, Command): - commands.append(command) - else: - commands.append(command(self)) - - return commands - - -@dataclass(frozen=True) -class DeviceCapabilitiesRef(AbstractDeviceCapabilities): - """Device capabilitie referring another device.""" - - ref: str - - def create( - self, devices: Mapping[str, AbstractDeviceCapabilities] - ) -> DeviceCapabilities: - """Create and return device capabilities.""" - if (device := devices.get(self.ref)) and isinstance(device, DeviceCapabilities): - return DeviceCapabilities(self.name, device.events, device.capabilities) - - raise DeviceCapabilitiesRefNotFoundError(self.ref) - - -def convert( - _class: str, - device: AbstractDeviceCapabilities, - devices: Mapping[str, AbstractDeviceCapabilities], -) -> DeviceCapabilities: - """Convert the device into a device capabilities.""" - if isinstance(device, DeviceCapabilities): - return device - - if isinstance(device, DeviceCapabilitiesRef): - return device.create(devices) - - raise InvalidDeviceCapabilitiesError(_class, device) diff --git a/deebot_client/hardware/exceptions.py b/deebot_client/hardware/exceptions.py deleted file mode 100644 index 5dc3d431..00000000 --- a/deebot_client/hardware/exceptions.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Deebot hardware exception module.""" - - -from typing import TYPE_CHECKING - -from deebot_client.events.base import Event -from deebot_client.exceptions import DeebotError - -if TYPE_CHECKING: - from deebot_client.hardware.device_capabilities import AbstractDeviceCapabilities - - -class HardwareError(DeebotError): - """Hardware error.""" - - -class DeviceCapabilitiesRefNotFoundError(HardwareError): - """Device capabilities reference not found error.""" - - def __init__(self, ref: str) -> None: - super().__init__(f'Device ref: "{ref}" not found') - - -class RequiredEventMissingError(HardwareError): - """Required event missing error.""" - - def __init__(self, event: type["Event"]) -> None: - super().__init__(f'Required event "{event.__name__}" is missing.') - - -class InvalidDeviceCapabilitiesError(HardwareError): - """Invalid device capabilities error.""" - - def __init__( - self, _class: str, device_cababilities: "AbstractDeviceCapabilities" - ) -> None: - super().__init__( - f'The class "{_class} has a invalid device capabilities "{device_cababilities.__class__.__name__}"' - ) diff --git a/deebot_client/models.py b/deebot_client/models.py index 3b44a260..0765aa7b 100644 --- a/deebot_client/models.py +++ b/deebot_client/models.py @@ -1,61 +1,104 @@ """Models module.""" from dataclasses import dataclass -from enum import IntEnum, unique +from enum import IntEnum, StrEnum, unique import os -from typing import Any, cast +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 -class DeviceInfo(dict[str, Any]): - """Class holds all values, which we get from api. Common values can be accessed through properties.""" + +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 + + @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 cast(str | None, 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 DataType.JSON + return self._static_device_info.data_type + + @property + def capabilities(self) -> "Capabilities": + """Return capabilities.""" + return self._static_device_info.capabilities @dataclass(frozen=True) @@ -79,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/util.py b/deebot_client/util.py index 28488685..e3ff8dae 100644 --- a/deebot_client/util.py +++ b/deebot_client/util.py @@ -139,3 +139,8 @@ def __getattribute__(self, __name: str) -> Any: 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/deebot_client/vacuum_bot.py b/deebot_client/vacuum_bot.py index 5d96e82b..68bbe759 100644 --- a/deebot_client/vacuum_bot.py +++ b/deebot_client/vacuum_bot.py @@ -6,7 +6,6 @@ import json from typing import Any, Final -from deebot_client.hardware import get_device_capabilities from deebot_client.mqtt_client import MqttClient, SubscriberInfo from deebot_client.util import cancel @@ -42,9 +41,9 @@ def __init__( authenticator: Authenticator, ): self.device_info: Final[DeviceInfo] = device_info + self.capabilities: Final = device_info.capabilities self._authenticator = authenticator - self._device_capabilities = get_device_capabilities(device_info.get_class) self._semaphore = asyncio.Semaphore(3) self._state: StateEvent | None = None self._last_time_available: datetime = datetime.now() @@ -53,7 +52,7 @@ def __init__( self.fw_version: str | None = None self.events: Final[EventBus] = EventBus( - self.execute_command, self._device_capabilities + self.execute_command, self.capabilities.get_refresh_commands ) self.map: Final[Map] = Map(self.execute_command, self.events) @@ -128,7 +127,7 @@ async def _available_task_worker(self) -> None: ): tasks: set[asyncio.Future[Any]] = set() try: - for command in self._device_capabilities.get_refresh_commands( + for command in self.capabilities.get_refresh_commands( AvailabilityEvent ): tasks.add(asyncio.create_task(self._execute_command(command))) diff --git a/tests/commands/json/__init__.py b/tests/commands/json/__init__.py index 81fa3d00..829eb4b7 100644 --- a/tests/commands/json/__init__.py +++ b/tests/commands/json/__init__.py @@ -13,6 +13,7 @@ ) 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 @@ -38,7 +39,8 @@ async def assert_command( "deviceName": "device_name", "status": 1, "class": "get_class", - } + }, + get_static_device_info(FALLBACK), ) await command.execute(authenticator, device_info, event_bus) diff --git a/tests/commands/json/test_fan_speed.py b/tests/commands/json/test_fan_speed.py index e211d96c..9ab2a907 100644 --- a/tests/commands/json/test_fan_speed.py +++ b/tests/commands/json/test_fan_speed.py @@ -1,3 +1,5 @@ +import pytest + from deebot_client.commands.json import GetFanSpeed, SetFanSpeed from deebot_client.events import FanSpeedEvent from deebot_client.events.fan_speed import FanSpeedLevel @@ -19,7 +21,15 @@ async def test_GetFanSpeed() -> None: await assert_command(GetFanSpeed(), json, FanSpeedEvent(FanSpeedLevel.MAX_PLUS)) -async def test_SetFanSpeed() -> None: - command = SetFanSpeed(FanSpeedLevel.MAX) +@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_water_info.py b/tests/commands/json/test_water_info.py index 4d80bcfa..b4d6c230 100644 --- a/tests/commands/json/test_water_info.py +++ b/tests/commands/json/test_water_info.py @@ -30,7 +30,15 @@ async def test_GetWaterInfo(json: dict[str, Any], expected: WaterInfoEvent) -> N await assert_command(GetWaterInfo(), json, expected) -async def test_SetWaterInfo() -> None: - command = SetWaterInfo(WaterAmount.MEDIUM) +@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/conftest.py b/tests/conftest.py index b5e2bfd5..552d4033 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,12 +6,16 @@ from aiomqtt import Client import pytest -from deebot_client import hardware from deebot_client.api_client import ApiClient from deebot_client.authentication import Authenticator from deebot_client.event_bus import EventBus -from deebot_client.hardware.device_capabilities import DeviceCapabilities -from deebot_client.models import Configuration, Credentials, DeviceInfo +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 @@ -103,7 +107,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,7 +123,8 @@ def device_info() -> DeviceInfo: "deviceName": "device_name", "status": 1, "class": "get_class", - } + }, + static_device_info, ) @@ -135,15 +145,8 @@ def execute_mock() -> AsyncMock: @pytest.fixture -def device_capabilities() -> DeviceCapabilities: - return hardware._DEFAULT - - -@pytest.fixture -def event_bus( - execute_mock: AsyncMock, device_capabilities: DeviceCapabilities -) -> EventBus: - return EventBus(execute_mock, device_capabilities) +def event_bus(execute_mock: AsyncMock, device_info: DeviceInfo) -> EventBus: + return EventBus(execute_mock, device_info.capabilities.get_refresh_commands) @pytest.fixture diff --git a/tests/hardware/__init__.py b/tests/hardware/__init__.py index 8cc1dc08..e69de29b 100644 --- a/tests/hardware/__init__.py +++ b/tests/hardware/__init__.py @@ -1,17 +0,0 @@ -from collections.abc import Mapping - -from deebot_client.hardware.device_capabilities import DeviceCapabilities - - -def verify_sorted_devices(devices: Mapping[str, DeviceCapabilities]) -> None: - sorted_keys = sorted(devices.keys()) - assert sorted_keys == list( - devices.keys() - ), f"Devices expected to sort like {sorted_keys}" - for device in devices.values(): - verify_get_refresh_commands(device) - - -def verify_get_refresh_commands(device_capabilites: DeviceCapabilities) -> None: - for event in device_capabilites.events: - device_capabilites.get_refresh_commands(event) diff --git a/tests/hardware/test_deebot.py b/tests/hardware/test_deebot.py deleted file mode 100644 index b2554062..00000000 --- a/tests/hardware/test_deebot.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Hardware deebot tests.""" - - -from deebot_client.hardware.deebot import DEVICES - -from . import verify_sorted_devices - - -def test_sorted() -> None: - """Test if all devices are sorted correctly.""" - verify_sorted_devices(DEVICES) diff --git a/tests/hardware/test_device_capabilities.py b/tests/hardware/test_device_capabilities.py deleted file mode 100644 index 030b6dd4..00000000 --- a/tests/hardware/test_device_capabilities.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Hardware device capabilities tests.""" - - -import pytest - -from deebot_client.commands.json.battery import GetBattery -from deebot_client.commands.json.charge_state import GetChargeState -from deebot_client.commands.json.clean import GetCleanInfo -from deebot_client.commands.json.life_span import GetLifeSpan -from deebot_client.events import AvailabilityEvent, LifeSpan, LifeSpanEvent, StateEvent -from deebot_client.hardware.device_capabilities import ( - AbstractDeviceCapabilities, - DeviceCapabilities, - DeviceCapabilitiesRef, - convert, -) -from deebot_client.hardware.exceptions import ( - DeviceCapabilitiesRefNotFoundError, - InvalidDeviceCapabilitiesError, - RequiredEventMissingError, -) -from tests.helpers import get_device_capabilities - - -def test_invalid_ref() -> None: - """Test error is raised if the ref is invalid.""" - device_ref = "not existing" - device_capbabilities_ref = DeviceCapabilitiesRef("invalid", device_ref) - devices = {"valid": get_device_capabilities(), "invalid": device_capbabilities_ref} - - with pytest.raises( - DeviceCapabilitiesRefNotFoundError, - match=rf'Device ref: "{device_ref}" not found', - ): - device_capbabilities_ref.create(devices) - - -def test_convert_raises_error() -> None: - """Test if convert raises error for unsporrted class.""" - - class _TestCapabilities(AbstractDeviceCapabilities): - pass - - _class = "abc" - device_capabilities = _TestCapabilities("test") - - with pytest.raises( - InvalidDeviceCapabilitiesError, - match=rf'The class "{_class} has a invalid device capabilities "_TestCapabilities"', - ): - convert(_class, device_capabilities, {}) - - -def test_DeviceCapabilites_check_for_required_events() -> None: - """Test if DevcieCapabilites raises error if not all required events are present.""" - - with pytest.raises( - RequiredEventMissingError, - match=r'Required event "AvailabilityEvent" is missing.', - ): - DeviceCapabilities("test", {}) - - -def test_get_refresh_commands() -> None: - device_capabilites = DeviceCapabilities( - "Test", - { - AvailabilityEvent: [GetBattery(True)], - LifeSpanEvent: [(lambda dc: GetLifeSpan(dc.capabilities[LifeSpan]))], - StateEvent: [GetChargeState(), GetCleanInfo()], - }, - {LifeSpan: {LifeSpan.BRUSH, LifeSpan.SIDE_BRUSH}}, - ) - - assert device_capabilites.get_refresh_commands(AvailabilityEvent) == [ - GetBattery(True) - ] - assert device_capabilites.get_refresh_commands(LifeSpanEvent) == [ - GetLifeSpan({LifeSpan.BRUSH, LifeSpan.SIDE_BRUSH}) - ] - assert device_capabilites.get_refresh_commands(StateEvent) == [ - GetChargeState(), - GetCleanInfo(), - ] diff --git a/tests/hardware/test_init.py b/tests/hardware/test_init.py index 570c3578..97d3be10 100644 --- a/tests/hardware/test_init.py +++ b/tests/hardware/test_init.py @@ -1,32 +1,154 @@ """Hardware init tests.""" +from collections.abc import Callable + import pytest -from deebot_client.hardware import _DEFAULT, get_device_capabilities -from deebot_client.hardware.deebot import _DEVICES, DEVICES -from deebot_client.hardware.device_capabilities import DeviceCapabilities +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.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.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 -from . import verify_get_refresh_commands + +@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"), [ - ("not_specified", _DEFAULT), - ("yna5x1", _DEVICES["yna5x1"]), ( - "vi829v", - DeviceCapabilities( - _DEVICES["vi829v"].name, - DEVICES["yna5x1"].events, - DEVICES["yna5x1"].capabilities, - ), + 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()], + 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()], + PositionsEvent: [GetPos()], + ReportStatsEvent: [], + RoomsEvent: [GetCachedMapInfo()], + StateEvent: [GetChargeState(), GetCleanInfo()], + StatsEvent: [GetStats()], + TotalStatsEvent: [GetTotalStats()], + VolumeEvent: [GetVolume()], + WaterInfoEvent: [GetWaterInfo()], + }, ), ], ) -def test_get_device_capabilities(class_: str, expected: DeviceCapabilities) -> None: - """Test get_device_capabilities.""" - device_capabilities = get_device_capabilities(class_) - assert expected == device_capabilities - verify_get_refresh_commands(device_capabilities) +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 index 62eb573c..7b3cfe37 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,12 +1,12 @@ -from collections.abc import Callable, Mapping +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.hardware.device_capabilities import ( - _REQUIRED_EVENTS, - DeviceCapabilities, -) +from deebot_client.models import StaticDeviceInfo from deebot_client.util import DisplayNameIntEnum @@ -57,15 +57,18 @@ def get_message_json(body: dict[str, Any]) -> dict[str, Any]: } -def get_device_capabilities( - events: Mapping[ - type[Event], list[Command | Callable[["DeviceCapabilities"], Command]] - ] - | None = None -) -> DeviceCapabilities: - """Get test device capabilities.""" - _events = {**events} if events else {} - for event in _REQUIRED_EVENTS: - _events.setdefault(event, []) +def mock_static_device_info( + events: Mapping[type[Event], list[Command]] | None = None +) -> StaticDeviceInfo: + """Mock static device info.""" + if events is None: + events = {} - return DeviceCapabilities("test", _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/test_event_bus.py b/tests/test_event_bus.py index 124a9c6d..2b3bae7e 100644 --- a/tests/test_event_bus.py +++ b/tests/test_event_bus.py @@ -19,7 +19,7 @@ def _verify_event_command_called( expected_call: bool, event_bus: EventBus, ) -> None: - for command in event_bus._device_capabilities.get_refresh_commands(event): + for command in event_bus._get_refresh_commands(event): assert (call(command) in execute_mock.call_args_list) == expected_call diff --git a/tests/test_vacuum_bot.py b/tests/test_vacuum_bot.py index 2735cce8..2018f98f 100644 --- a/tests/test_vacuum_bot.py +++ b/tests/test_vacuum_bot.py @@ -9,7 +9,7 @@ from deebot_client.models import DeviceInfo from deebot_client.mqtt_client import MqttClient, SubscriberInfo from deebot_client.vacuum_bot import VacuumBot -from tests.helpers import get_device_capabilities +from tests.helpers import mock_static_device_info @patch("deebot_client.vacuum_bot._AVAILABLE_CHECK_INTERVAL", 2) # reduce interval @@ -26,79 +26,76 @@ 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.get_device_capabilities" - ) as get_device_capabilities_patch: - # prepare mocks - battery_mock = Mock(spec_set=GetBattery) - get_device_capabilities_patch.return_value = get_device_capabilities( - {AvailabilityEvent: [battery_mock]} - ) - execute_mock = battery_mock.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) - - # deactivate refresh event subscribe refresh calls - bot.events._device_capabilities = get_device_capabilities() - - 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() + # 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 = 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) + + # 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() From 018e48a3b85aa10ef89f99be6a2dbac1d70f50f6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 21 Oct 2023 12:12:32 +0200 Subject: [PATCH 34/41] Bump testfixtures from 7.2.0 to 7.2.2 (#329) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements-test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-test.txt b/requirements-test.txt index 306a67d4..722d7147 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -7,7 +7,7 @@ pytest-cov==4.1.0 pytest-docker-fixtures==1.3.17 pytest-timeout==2.2.0 setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability -testfixtures==7.2.0 +testfixtures==7.2.2 types-cachetools types-mock types-Pillow From 438c5bc34a482d454ebb1dd3b454a80e698fa573 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Oct 2023 12:23:31 +0000 Subject: [PATCH 35/41] Bump pylint from 3.0.1 to 3.0.2 (#331) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements-test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-test.txt b/requirements-test.txt index 722d7147..cf1bbddd 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,6 +1,6 @@ mypy==1.6.1 pre-commit==3.5.0 -pylint==3.0.1 +pylint==3.0.2 pytest==7.4.2 pytest-asyncio==0.21.1 pytest-cov==4.1.0 From a4eb34a2b0bb7cede9a072246c153df1055987e9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 24 Oct 2023 13:06:35 +0200 Subject: [PATCH 36/41] [pre-commit.ci] pre-commit autoupdate (#332) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1f393dd2..3ae18850 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ default_language_version: repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.0 + rev: v0.1.1 hooks: - id: ruff args: @@ -22,7 +22,7 @@ repos: args: - --py311-plus - repo: https://github.com/psf/black-pre-commit-mirror - rev: 23.9.1 + rev: 23.10.0 hooks: - id: black args: From f60332757ec768bb8f987c14b7ee1579d4bb3eb2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 25 Oct 2023 18:37:48 +0200 Subject: [PATCH 37/41] Bump pytest from 7.4.2 to 7.4.3 (#335) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements-test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-test.txt b/requirements-test.txt index cf1bbddd..eb108bb5 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,7 +1,7 @@ mypy==1.6.1 pre-commit==3.5.0 pylint==3.0.2 -pytest==7.4.2 +pytest==7.4.3 pytest-asyncio==0.21.1 pytest-cov==4.1.0 pytest-docker-fixtures==1.3.17 From b2404591a79d19bcaab4a0055af00aafbb9c2314 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 26 Oct 2023 08:29:44 +0200 Subject: [PATCH 38/41] Add work mode (#330) --- deebot_client/capabilities.py | 3 + deebot_client/commands/json/__init__.py | 6 + deebot_client/commands/json/work_mode.py | 39 +++++ deebot_client/events/__init__.py | 3 + deebot_client/events/work_mode.py | 22 +++ deebot_client/hardware/deebot/fallback.py | 7 +- deebot_client/hardware/deebot/p1jij8.py | 188 ++++++++++++++++++++++ tests/commands/json/test_work_mode.py | 43 +++++ 8 files changed, 308 insertions(+), 3 deletions(-) create mode 100644 deebot_client/commands/json/work_mode.py create mode 100644 deebot_client/events/work_mode.py create mode 100644 deebot_client/hardware/deebot/p1jij8.py create mode 100644 tests/commands/json/test_work_mode.py diff --git a/deebot_client/capabilities.py b/deebot_client/capabilities.py index da4d0ec4..0d9b78bf 100644 --- a/deebot_client/capabilities.py +++ b/deebot_client/capabilities.py @@ -37,6 +37,8 @@ VolumeEvent, WaterAmount, WaterInfoEvent, + WorkMode, + WorkModeEvent, ) from deebot_client.models import CleanAction, CleanMode @@ -120,6 +122,7 @@ class CapabilityClean: count: CapabilitySet[CleanCountEvent, int] | None = None log: CapabilityEvent[CleanLogEvent] preference: CapabilitySetEnable[CleanPreferenceEvent] | None = None + work_mode: CapabilitySetTypes[WorkModeEvent, WorkMode] | None = None @dataclass(frozen=True) diff --git a/deebot_client/commands/json/__init__.py b/deebot_client/commands/json/__init__.py index a1609a33..6927bb00 100644 --- a/deebot_client/commands/json/__init__.py +++ b/deebot_client/commands/json/__init__.py @@ -31,6 +31,7 @@ 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", @@ -74,6 +75,8 @@ "SetVolume", "GetWaterInfo", "SetWaterInfo", + "GetWorkMode", + "SetWorkMode", ] # fmt: off @@ -141,6 +144,9 @@ GetWaterInfo, SetWaterInfo, + + GetWorkMode, + SetWorkMode ] # fmt: on 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/events/__init__.py b/deebot_client/events/__init__.py index 55f66b4c..a6e1ca37 100644 --- a/deebot_client/events/__init__.py +++ b/deebot_client/events/__init__.py @@ -23,6 +23,7 @@ PositionType, ) from .water_info import WaterAmount, WaterInfoEvent +from .work_mode import WorkMode, WorkModeEvent __all__ = [ "BatteryEvent", @@ -44,6 +45,8 @@ "PositionsEvent", "WaterAmount", "WaterInfoEvent", + "WorkMode", + "WorkModeEvent", ] 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/deebot/fallback.py b/deebot_client/hardware/deebot/fallback.py index dd1ba4f9..d9ba83b9 100644 --- a/deebot_client/hardware/deebot/fallback.py +++ b/deebot_client/hardware/deebot/fallback.py @@ -1,4 +1,4 @@ -"""Deebot ozmo 950 Capabilities.""" +"""Fallback Capabilities.""" from deebot_client.capabilities import ( Capabilities, CapabilityClean, @@ -82,10 +82,11 @@ WaterInfoEvent, ) from deebot_client.models import StaticDeviceInfo +from deebot_client.util import short_name -from . import DEVICES, FALLBACK +from . import DEVICES -DEVICES[FALLBACK] = StaticDeviceInfo( +DEVICES[short_name(__name__)] = StaticDeviceInfo( DataType.JSON, Capabilities( availability=CapabilityEvent(AvailabilityEvent, [GetBattery(True)]), diff --git a/deebot_client/hardware/deebot/p1jij8.py b/deebot_client/hardware/deebot/p1jij8.py new file mode 100644 index 00000000..1291d5a2 --- /dev/null +++ b/deebot_client/hardware/deebot/p1jij8.py @@ -0,0 +1,188 @@ +"""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.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, + 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()]), + ), + 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/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") From 4de108187df3c7efb738988319559ad81b30eff9 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 26 Oct 2023 08:38:29 +0200 Subject: [PATCH 39/41] add getNetInfo command (#333) --- deebot_client/capabilities.py | 2 + deebot_client/commands/json/__init__.py | 4 ++ deebot_client/commands/json/network.py | 33 +++++++++++++ deebot_client/events/__init__.py | 2 + deebot_client/events/network.py | 16 ++++++ deebot_client/hardware/deebot/fallback.py | 3 ++ deebot_client/hardware/deebot/p1jij8.py | 3 ++ deebot_client/hardware/deebot/yna5xi.py | 3 ++ deebot_client/vacuum_bot.py | 7 +++ tests/commands/json/test_network.py | 28 +++++++++++ tests/hardware/test_init.py | 4 ++ tests/{helpers.py => helpers/__init__.py} | 0 tests/helpers/tasks.py | 60 +++++++++++++++++++++++ tests/test_vacuum_bot.py | 25 ++++++++++ 14 files changed, 190 insertions(+) create mode 100644 deebot_client/commands/json/network.py create mode 100644 deebot_client/events/network.py create mode 100644 tests/commands/json/test_network.py rename tests/{helpers.py => helpers/__init__.py} (100%) create mode 100644 tests/helpers/tasks.py diff --git a/deebot_client/capabilities.py b/deebot_client/capabilities.py index 0d9b78bf..ef1b6ac4 100644 --- a/deebot_client/capabilities.py +++ b/deebot_client/capabilities.py @@ -27,6 +27,7 @@ MapChangedEvent, MapTraceEvent, MultimapStateEvent, + NetworkInfoEvent, PositionsEvent, ReportStatsEvent, RoomsEvent, @@ -185,6 +186,7 @@ class Capabilities: fan_speed: CapabilitySetTypes[FanSpeedEvent, FanSpeedLevel] life_span: CapabilityLifeSpan map: CapabilityMap | None = None + network: CapabilityEvent[NetworkInfoEvent] play_sound: CapabilityExecute settings: CapabilitySettings state: CapabilityEvent[StateEvent] diff --git a/deebot_client/commands/json/__init__.py b/deebot_client/commands/json/__init__.py index 6927bb00..2a919eea 100644 --- a/deebot_client/commands/json/__init__.py +++ b/deebot_client/commands/json/__init__.py @@ -24,6 +24,7 @@ GetMinorMap, ) from .multimap_state import GetMultimapState, SetMultimapState +from .network import GetNetInfo from .play_sound import PlaySound from .pos import GetPos from .relocation import SetRelocationState @@ -64,6 +65,7 @@ "GetMinorMap", "GetMultimapState", "SetMultimapState", + "GetNetInfo", "PlaySound", "GetPos", "SetRelocationState", @@ -127,6 +129,8 @@ GetMultimapState, SetMultimapState, + GetNetInfo, + PlaySound, GetPos, 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/events/__init__.py b/deebot_client/events/__init__.py index a6e1ca37..f5c71312 100644 --- a/deebot_client/events/__init__.py +++ b/deebot_client/events/__init__.py @@ -22,6 +22,7 @@ PositionsEvent, PositionType, ) +from .network import NetworkInfoEvent from .water_info import WaterAmount, WaterInfoEvent from .work_mode import WorkMode, WorkModeEvent @@ -40,6 +41,7 @@ "MapSubsetEvent", "MapTraceEvent", "MinorMapEvent", + "NetworkInfoEvent", "Position", "PositionType", "PositionsEvent", 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/hardware/deebot/fallback.py b/deebot_client/hardware/deebot/fallback.py index d9ba83b9..21e58fb9 100644 --- a/deebot_client/hardware/deebot/fallback.py +++ b/deebot_client/hardware/deebot/fallback.py @@ -42,6 +42,7 @@ 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 @@ -70,6 +71,7 @@ MapChangedEvent, MapTraceEvent, MultimapStateEvent, + NetworkInfoEvent, PositionsEvent, ReportStatsEvent, RoomsEvent, @@ -138,6 +140,7 @@ rooms=CapabilityEvent(RoomsEvent, [GetCachedMapInfo()]), trace=CapabilityEvent(MapTraceEvent, [GetMapTrace()]), ), + network=CapabilityEvent(NetworkInfoEvent, [GetNetInfo()]), play_sound=CapabilityExecute(PlaySound), settings=CapabilitySettings( advanced_mode=CapabilitySetEnable( diff --git a/deebot_client/hardware/deebot/p1jij8.py b/deebot_client/hardware/deebot/p1jij8.py index 1291d5a2..7213cfa4 100644 --- a/deebot_client/hardware/deebot/p1jij8.py +++ b/deebot_client/hardware/deebot/p1jij8.py @@ -42,6 +42,7 @@ 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 @@ -71,6 +72,7 @@ MapChangedEvent, MapTraceEvent, MultimapStateEvent, + NetworkInfoEvent, PositionsEvent, ReportStatsEvent, RoomsEvent, @@ -152,6 +154,7 @@ rooms=CapabilityEvent(RoomsEvent, [GetCachedMapInfo()]), trace=CapabilityEvent(MapTraceEvent, [GetMapTrace()]), ), + network=CapabilityEvent(NetworkInfoEvent, [GetNetInfo()]), play_sound=CapabilityExecute(PlaySound), settings=CapabilitySettings( advanced_mode=CapabilitySetEnable( diff --git a/deebot_client/hardware/deebot/yna5xi.py b/deebot_client/hardware/deebot/yna5xi.py index 3edc657e..8ac871fc 100644 --- a/deebot_client/hardware/deebot/yna5xi.py +++ b/deebot_client/hardware/deebot/yna5xi.py @@ -37,6 +37,7 @@ 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 @@ -72,6 +73,7 @@ WaterAmount, WaterInfoEvent, ) +from deebot_client.events.network import NetworkInfoEvent from deebot_client.models import StaticDeviceInfo from deebot_client.util import short_name @@ -125,6 +127,7 @@ rooms=CapabilityEvent(RoomsEvent, [GetCachedMapInfo()]), trace=CapabilityEvent(MapTraceEvent, [GetMapTrace()]), ), + network=CapabilityEvent(NetworkInfoEvent, [GetNetInfo()]), play_sound=CapabilityExecute(PlaySound), settings=CapabilitySettings( advanced_mode=CapabilitySetEnable( diff --git a/deebot_client/vacuum_bot.py b/deebot_client/vacuum_bot.py index 68bbe759..66a598db 100644 --- a/deebot_client/vacuum_bot.py +++ b/deebot_client/vacuum_bot.py @@ -6,6 +6,7 @@ import json from typing import Any, Final +from deebot_client.events.network import NetworkInfoEvent from deebot_client.mqtt_client import MqttClient, SubscriberInfo from deebot_client.util import cancel @@ -51,6 +52,7 @@ def __init__( self._unsubscribe: Callable[[], None] | None = None self.fw_version: str | None = None + self.mac: str | None = None self.events: Final[EventBus] = EventBus( self.execute_command, self.capabilities.get_refresh_commands ) @@ -93,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) 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/hardware/test_init.py b/tests/hardware/test_init.py index 97d3be10..af15106b 100644 --- a/tests/hardware/test_init.py +++ b/tests/hardware/test_init.py @@ -20,6 +20,7 @@ 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 @@ -56,6 +57,7 @@ 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 @@ -102,6 +104,7 @@ def test_get_static_device_info( MajorMapEvent: [GetMajorMap()], MapTraceEvent: [GetMapTrace()], MultimapStateEvent: [GetMultimapState()], + NetworkInfoEvent: [GetNetInfo()], PositionsEvent: [GetPos()], ReportStatsEvent: [], RoomsEvent: [GetCachedMapInfo()], @@ -133,6 +136,7 @@ def test_get_static_device_info( MajorMapEvent: [GetMajorMap()], MapTraceEvent: [GetMapTrace()], MultimapStateEvent: [GetMultimapState()], + NetworkInfoEvent: [GetNetInfo()], PositionsEvent: [GetPos()], ReportStatsEvent: [], RoomsEvent: [GetCachedMapInfo()], diff --git a/tests/helpers.py b/tests/helpers/__init__.py similarity index 100% rename from tests/helpers.py rename to tests/helpers/__init__.py 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/test_vacuum_bot.py b/tests/test_vacuum_bot.py index 2018f98f..6fc99233 100644 --- a/tests/test_vacuum_bot.py +++ b/tests/test_vacuum_bot.py @@ -6,10 +6,12 @@ from deebot_client.authentication import Authenticator from deebot_client.commands.json.battery import GetBattery 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 deebot_client.vacuum_bot import VacuumBot from tests.helpers import mock_static_device_info +from tests.helpers.tasks import block_till_done @patch("deebot_client.vacuum_bot._AVAILABLE_CHECK_INTERVAL", 2) # reduce interval @@ -99,3 +101,26 @@ async def assert_received_status(expected: bool) -> None: 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 = VacuumBot(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() From af82898e2c4d3c6322acfad58297f0bee62f77ab Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 27 Oct 2023 11:14:52 +0200 Subject: [PATCH 40/41] Replace setup.py by pyproject.toml (#336) --- .github/workflows/python-publish.yml | 12 +--- deebot_client/map.py | 1 - pylintrc | 84 ------------------------ pyproject.toml | 95 ++++++++++++++++++++++++++++ setup.py | 38 ----------- 5 files changed, 97 insertions(+), 133 deletions(-) delete mode 100644 pylintrc delete mode 100644 setup.py diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 5c3a186b..dbf2e017 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -22,14 +22,6 @@ jobs: - name: 📥 Checkout the repository uses: actions/checkout@v4 - - 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" - - name: Set up Python uses: actions/setup-python@v4.7.1 with: @@ -38,10 +30,10 @@ jobs: - 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/deebot_client/map.py b/deebot_client/map.py index 4650f4e7..c18c89b8 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -499,7 +499,6 @@ def dashed_line( 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] diff --git a/pylintrc b/pylintrc deleted file mode 100644 index c1927a39..00000000 --- a/pylintrc +++ /dev/null @@ -1,84 +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=builtins.BaseException, - builtins.Exception - -[DESIGN] -max-parents=10 -max-args=6 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index b5e15b9b..3fe77707 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,43 @@ +[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 @@ -91,3 +131,58 @@ max-complexity = 12 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/setup.py b/setup.py deleted file mode 100644 index 85fee9a7..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 = [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, -) From 8e1b7aa5226a5b7e5aea5de9bb4953fa75754604 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 27 Oct 2023 22:23:54 +0200 Subject: [PATCH 41/41] Rename vacuum_bot to device (#334) --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- README.md | 6 +++--- deebot_client/commands/json/charge.py | 6 +++--- deebot_client/commands/json/charge_state.py | 12 ++++++------ deebot_client/commands/json/clean.py | 20 ++++++++++---------- deebot_client/commands/json/common.py | 4 ++-- deebot_client/commands/json/error.py | 4 ++-- deebot_client/{vacuum_bot.py => device.py} | 12 ++++++------ deebot_client/event_bus.py | 8 ++++---- deebot_client/events/__init__.py | 4 ++-- deebot_client/models.py | 4 ++-- deebot_client/mqtt_client.py | 2 +- tests/commands/json/test_charge.py | 6 +++--- tests/commands/json/test_clean.py | 18 +++++++++--------- tests/commands/json/test_common.py | 4 ++-- tests/conftest.py | 12 ------------ tests/{test_vacuum_bot.py => test_device.py} | 8 ++++---- tests/test_event_bus.py | 16 ++++++++-------- 18 files changed, 68 insertions(+), 80 deletions(-) rename deebot_client/{vacuum_bot.py => device.py} (96%) rename tests/{test_vacuum_bot.py => test_device.py} (94%) 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/README.md b/README.md index 1cd1a837..7a0ea304 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Client Library for Deebot Vacuums +# Client Library for Deebot devices (Vacuums) [![PyPI - Downloads](https://img.shields.io/pypi/dw/deebot-client?style=for-the-badge)](https://pypi.org/project/deebot-client) Buy Me A Coffee @@ -31,7 +31,7 @@ from deebot_client.events import BatteryEvent from deebot_client.models import Configuration from deebot_client.mqtt_client import MqttClient, MqttConfiguration from deebot_client.util import md5 -from deebot_client.vacuum_bot import VacuumBot +from deebot_client.device import Device device_id = md5(str(time.time())) account_id = "your email or phonenumber (cn)" @@ -52,7 +52,7 @@ async def main(): devices_ = await api_client.get_devices() - bot = VacuumBot(devices_[0], authenticator) + bot = Device(devices_[0], authenticator) mqtt_config = MqttConfiguration(config=config) mqtt = MqttClient(mqtt_config, authenticator) diff --git a/deebot_client/commands/json/charge.py b/deebot_client/commands/json/charge.py index 516f76c4..68ee2d12 100644 --- a/deebot_client/commands/json/charge.py +++ b/deebot_client/commands/json/charge.py @@ -5,7 +5,7 @@ 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 VacuumState +from deebot_client.models import State from .common import ExecuteCommand from .const import CODE @@ -29,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/json/charge_state.py b/deebot_client/commands/json/charge_state.py index 7b6730b7..62d73e2b 100644 --- a/deebot_client/commands/json/charge_state.py +++ b/deebot_client/commands/json/charge_state.py @@ -4,7 +4,7 @@ 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 VacuumState +from deebot_client.models import State from .common import CommandWithMessageHandling from .const import CODE @@ -24,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 @@ -33,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 + status = State.DOCKED elif body["code"] in ("3", "5"): # 3 -> Bot in stuck state, example dust bin out # 5 -> Busy with another command - status = VacuumState.ERROR + 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/json/clean.py b/deebot_client/commands/json/clean.py index 85a4104e..65d7bd4f 100644 --- a/deebot_client/commands/json/clean.py +++ b/deebot_client/commands/json/clean.py @@ -7,7 +7,7 @@ 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, VacuumState +from deebot_client.models import CleanAction, CleanMode, DeviceInfo, State from .common import CommandWithMessageHandling, ExecuteCommand @@ -30,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) @@ -76,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", {}) @@ -103,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/json/common.py b/deebot_client/commands/json/common.py index 02747a42..0ea69e19 100644 --- a/deebot_client/commands/json/common.py +++ b/deebot_client/commands/json/common.py @@ -63,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) @@ -75,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) diff --git a/deebot_client/commands/json/error.py b/deebot_client/commands/json/error.py index 7c914154..8728e0df 100644 --- a/deebot_client/commands/json/error.py +++ b/deebot_client/commands/json/error.py @@ -4,7 +4,7 @@ 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 VacuumState +from deebot_client.models import State from .common import CommandWithMessageHandling @@ -30,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/vacuum_bot.py b/deebot_client/device.py similarity index 96% rename from deebot_client/vacuum_bot.py rename to deebot_client/device.py index 66a598db..fad29d76 100644 --- a/deebot_client/vacuum_bot.py +++ b/deebot_client/device.py @@ -1,4 +1,4 @@ -"""Vacuum bot module.""" +"""Device module.""" import asyncio from collections.abc import Callable from contextlib import suppress @@ -27,14 +27,14 @@ 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, @@ -60,7 +60,7 @@ def __init__( 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) @@ -79,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) diff --git a/deebot_client/event_bus.py b/deebot_client/event_bus.py index afc9275c..0655acd6 100644 --- a/deebot_client/event_bus.py +++ b/deebot_client/event_bus.py @@ -7,7 +7,7 @@ from .events import AvailabilityEvent, Event, StateEvent from .logging_filter import get_logger -from .models import VacuumState +from .models import State from .util import cancel, create_task if TYPE_CHECKING: @@ -93,13 +93,13 @@ def _notify(event: T) -> 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 diff --git a/deebot_client/events/__init__.py b/deebot_client/events/__init__.py index f5c71312..c8df5e31 100644 --- a/deebot_client/events/__init__.py +++ b/deebot_client/events/__init__.py @@ -5,7 +5,7 @@ from typing import Any from deebot_client.events.base import Event -from deebot_client.models import Room, VacuumState +from deebot_client.models import Room, State from deebot_client.util import DisplayNameIntEnum from .fan_speed import FanSpeedEvent, FanSpeedLevel @@ -175,7 +175,7 @@ class AvailabilityEvent(Event): class StateEvent(Event): """State event representation.""" - state: VacuumState + state: State @dataclass(frozen=True) diff --git a/deebot_client/models.py b/deebot_client/models.py index 0765aa7b..2e15d1d5 100644 --- a/deebot_client/models.py +++ b/deebot_client/models.py @@ -111,8 +111,8 @@ class Room: @unique -class VacuumState(IntEnum): - """Vacuum state representation.""" +class State(IntEnum): + """State representation.""" IDLE = 1 CLEANING = 2 diff --git a/deebot_client/mqtt_client.py b/deebot_client/mqtt_client.py index 97dc6959..b146c95d 100644 --- a/deebot_client/mqtt_client.py +++ b/deebot_client/mqtt_client.py @@ -116,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)) diff --git a/tests/commands/json/test_charge.py b/tests/commands/json/test_charge.py index bcd2cebb..1151e5f5 100644 --- a/tests/commands/json/test_charge.py +++ b/tests/commands/json/test_charge.py @@ -5,7 +5,7 @@ from deebot_client.commands.json import Charge from deebot_client.events import StateEvent -from deebot_client.models import VacuumState +from deebot_client.models import State from tests.helpers import get_request_json, get_success_body from . import assert_command @@ -25,8 +25,8 @@ def _prepare_json(code: int, msg: str = "ok") -> dict[str, Any]: @pytest.mark.parametrize( ("json", "expected"), [ - (get_request_json(get_success_body()), StateEvent(VacuumState.RETURNING)), - (_prepare_json(30007), StateEvent(VacuumState.DOCKED)), + (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: diff --git a/tests/commands/json/test_clean.py b/tests/commands/json/test_clean.py index f26ff69f..c39e25db 100644 --- a/tests/commands/json/test_clean.py +++ b/tests/commands/json/test_clean.py @@ -8,7 +8,7 @@ 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.models import DeviceInfo, VacuumState +from deebot_client.models import DeviceInfo, State from tests.helpers import get_request_json, get_success_body from . import assert_command @@ -19,7 +19,7 @@ [ ( get_request_json(get_success_body({"trigger": "none", "state": "idle"})), - StateEvent(VacuumState.IDLE), + StateEvent(State.IDLE), ), ], ) @@ -28,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/json/test_common.py b/tests/commands/json/test_common.py index 4580dc06..c22ebe0b 100644 --- a/tests/commands/json/test_common.py +++ b/tests/commands/json/test_common.py @@ -38,7 +38,7 @@ def _assert_false_and_avalable_event_false(available: bool, event_bus: Mock) -> _ERROR_500, ( logging.WARNING, - 'No response received for command "{}". This can happen if the vacuum has network issues or does not support the command', + '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, ), @@ -54,7 +54,7 @@ def _assert_false_and_avalable_event_false(available: bool, event_bus: Mock) -> _ERROR_4200, ( logging.INFO, - 'Vacuum is offline. Could not execute command "{}"', + 'Device is offline. Could not execute command "{}"', ), _assert_false_and_avalable_event_false, ), diff --git a/tests/conftest.py b/tests/conftest.py index 552d4033..2c7ce770 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,7 +17,6 @@ StaticDeviceInfo, ) from deebot_client.mqtt_client import MqttClient, MqttConfiguration -from deebot_client.vacuum_bot import VacuumBot from .fixtures.mqtt_server import MqttServer @@ -128,17 +127,6 @@ def device_info(static_device_info: StaticDeviceInfo) -> DeviceInfo: ) -@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() diff --git a/tests/test_vacuum_bot.py b/tests/test_device.py similarity index 94% rename from tests/test_vacuum_bot.py rename to tests/test_device.py index 6fc99233..76f00d1b 100644 --- a/tests/test_vacuum_bot.py +++ b/tests/test_device.py @@ -5,16 +5,16 @@ 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 deebot_client.vacuum_bot import VacuumBot from tests.helpers import mock_static_device_info from tests.helpers.tasks import block_till_done -@patch("deebot_client.vacuum_bot._AVAILABLE_CHECK_INTERVAL", 2) # reduce interval +@patch("deebot_client.device._AVAILABLE_CHECK_INTERVAL", 2) # reduce interval async def test_available_check_and_teardown( authenticator: Authenticator, device_info: DeviceInfo ) -> None: @@ -36,7 +36,7 @@ async def assert_received_status(expected: bool) -> None: execute_mock = battery_mock.execute # prepare bot and mock mqtt - bot = VacuumBot(device_info, authenticator) + bot = Device(device_info, authenticator) mqtt_client = Mock(spec=MqttClient) unsubscribe_mock = Mock(spec=Callable[[], None]) mqtt_client.subscribe.return_value = unsubscribe_mock @@ -108,7 +108,7 @@ async def test_mac_address( authenticator: Authenticator, device_info: DeviceInfo ) -> None: """Test that the mac address is change on NetwerkInfoEvent.""" - device = VacuumBot(device_info, authenticator) + device = Device(device_info, authenticator) # deactivate refresh event subscribe refresh calls device.events._get_refresh_commands = lambda _: [] diff --git a/tests/test_event_bus.py b/tests/test_event_bus.py index 2b3bae7e..4f2c465f 100644 --- a/tests/test_event_bus.py +++ b/tests/test_event_bus.py @@ -10,7 +10,7 @@ from deebot_client.events.base import Event from deebot_client.events.map import MapChangedEvent from deebot_client.events.water_info import WaterInfoEvent -from deebot_client.models import VacuumState +from deebot_client.models import State def _verify_event_command_called( @@ -120,18 +120,18 @@ async def test_request_refresh(execute_mock: AsyncMock, event_bus: EventBus) -> @pytest.mark.parametrize( ("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)