diff --git a/deebot_client/capabilities.py b/deebot_client/capabilities.py index 01231821..976bd6b5 100644 --- a/deebot_client/capabilities.py +++ b/deebot_client/capabilities.py @@ -41,6 +41,7 @@ WorkMode, WorkModeEvent, ) +from deebot_client.events.auto_empty import AutoEmptyMode, AutoEmptyModeEvent from deebot_client.events.efficiency_mode import EfficiencyMode, EfficiencyModeEvent from deebot_client.models import CleanAction, CleanMode @@ -107,6 +108,15 @@ class CapabilitySetTypes(CapabilitySet[_EVENT, _T | str], CapabilityTypes[_T]): """Capability for set command and types.""" +@dataclass(frozen=True, kw_only=True) +class CapabilityCleanAutoEmpty( + CapabilityEvent[AutoEmptyModeEvent], CapabilityTypes[AutoEmptyMode] +): + """Capabilities for clean auto empty.""" + + set: Callable[[bool, AutoEmptyMode | str | None], SetCommand] + + @dataclass(frozen=True, kw_only=True) class CapabilityCleanAction: """Capabilities for clean action.""" @@ -119,6 +129,7 @@ class CapabilityCleanAction: class CapabilityClean: """Capabilities for clean.""" + auto_empty: CapabilityCleanAutoEmpty | None = None action: CapabilityCleanAction continuous: CapabilitySetEnable[ContinuousCleaningEvent] count: CapabilitySet[CleanCountEvent, int] | None = None diff --git a/deebot_client/commands/json/__init__.py b/deebot_client/commands/json/__init__.py index cb9f8e2f..05ce66bc 100644 --- a/deebot_client/commands/json/__init__.py +++ b/deebot_client/commands/json/__init__.py @@ -2,6 +2,7 @@ from deebot_client.command import Command, CommandMqttP2P from .advanced_mode import GetAdvancedMode, SetAdvancedMode +from .auto_empty import GetAutoEmpty, SetAutoEmpty from .battery import GetBattery from .carpet import GetCarpetAutoFanBoost, SetCarpetAutoFanBoost from .charge import Charge @@ -39,6 +40,8 @@ __all__ = [ "GetAdvancedMode", "SetAdvancedMode", + "GetAutoEmpty", + "SetAutoEmpty", "GetBattery", "GetCarpetAutoFanBoost", "SetCarpetAutoFanBoost", @@ -93,6 +96,9 @@ GetAdvancedMode, SetAdvancedMode, + GetAutoEmpty, + SetAutoEmpty, + GetBattery, GetCarpetAutoFanBoost, diff --git a/deebot_client/commands/json/auto_empty.py b/deebot_client/commands/json/auto_empty.py new file mode 100644 index 00000000..cf637971 --- /dev/null +++ b/deebot_client/commands/json/auto_empty.py @@ -0,0 +1,55 @@ +"""Auto empty command module.""" +from types import MappingProxyType +from typing import Any + +from deebot_client.command import InitParam +from deebot_client.event_bus import EventBus +from deebot_client.events import AutoEmptyMode, AutoEmptyModeEvent +from deebot_client.message import HandlingResult, MessageBodyDataDict + +from .common import JsonCommandWithMessageHandling, JsonSetCommand + + +class GetAutoEmpty(JsonCommandWithMessageHandling, MessageBodyDataDict): + """Get auto empty command.""" + + name = "getAutoEmpty" + + @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( + AutoEmptyModeEvent( + enable=bool(data["enable"]), + mode=AutoEmptyMode(str(data["frequency"])), + ) + ) + return HandlingResult.success() + + +class SetAutoEmpty(JsonSetCommand): + """Set auto empty command.""" + + name = "setAutoEmpty" + get_command = GetAutoEmpty + _mqtt_params = MappingProxyType( + {"enable": InitParam(bool), "frequency": InitParam(AutoEmptyMode)} + ) + + def __init__( + self, + enable: bool = True, # noqa: FBT001, FBT002 + frequency: AutoEmptyMode | str | None = None, + ) -> None: + args: dict[str, int | str] = {"enable": int(enable)} + if frequency is not None: + if isinstance(frequency, str): + frequency = AutoEmptyMode.get(frequency) + args["frequency"] = frequency.value + + super().__init__(args) diff --git a/deebot_client/events/__init__.py b/deebot_client/events/__init__.py index 607beefb..c1a98851 100644 --- a/deebot_client/events/__init__.py +++ b/deebot_client/events/__init__.py @@ -8,6 +8,7 @@ from deebot_client.models import Room, State from deebot_client.util import DisplayNameIntEnum +from .auto_empty import AutoEmptyMode, AutoEmptyModeEvent from .efficiency_mode import EfficiencyMode, EfficiencyModeEvent from .fan_speed import FanSpeedEvent, FanSpeedLevel from .map import ( @@ -28,6 +29,8 @@ from .work_mode import WorkMode, WorkModeEvent __all__ = [ + "AutoEmptyMode", + "AutoEmptyModeEvent", "BatteryEvent", "CachedMapInfoEvent", "CleanJobStatus", diff --git a/deebot_client/events/auto_empty.py b/deebot_client/events/auto_empty.py new file mode 100644 index 00000000..0110575d --- /dev/null +++ b/deebot_client/events/auto_empty.py @@ -0,0 +1,24 @@ +"""Auto empty event module.""" +from dataclasses import dataclass + +from deebot_client.util import DisplayNameStrEnum + +from .base import Event + + +class AutoEmptyMode(DisplayNameStrEnum): + """Enum class for all possible auto emptys.""" + + MODE_10 = "10" + MODE_15 = "15" + MODE_25 = "25" + MODE_AUTO = "auto" + MODE_SMART = "smart" + + +@dataclass(frozen=True) +class AutoEmptyModeEvent(Event): + """Auto empty event representation.""" + + enable: bool + mode: AutoEmptyMode diff --git a/deebot_client/hardware/deebot/p95mgv.py b/deebot_client/hardware/deebot/p95mgv.py index 80159e21..01b06b95 100644 --- a/deebot_client/hardware/deebot/p95mgv.py +++ b/deebot_client/hardware/deebot/p95mgv.py @@ -3,6 +3,7 @@ Capabilities, CapabilityClean, CapabilityCleanAction, + CapabilityCleanAutoEmpty, CapabilityCustomCommand, CapabilityEvent, CapabilityExecute, @@ -15,6 +16,7 @@ CapabilityStats, ) from deebot_client.commands.json.advanced_mode import GetAdvancedMode, SetAdvancedMode +from deebot_client.commands.json.auto_empty import GetAutoEmpty, SetAutoEmpty from deebot_client.commands.json.battery import GetBattery from deebot_client.commands.json.carpet import ( GetCarpetAutoFanBoost, @@ -94,6 +96,7 @@ WaterAmount, WaterInfoEvent, ) +from deebot_client.events.auto_empty import AutoEmptyMode, AutoEmptyModeEvent from deebot_client.events.efficiency_mode import EfficiencyMode from deebot_client.models import StaticDeviceInfo from deebot_client.util import short_name @@ -111,6 +114,17 @@ battery=CapabilityEvent(BatteryEvent, [GetBattery()]), charge=CapabilityExecute(Charge), clean=CapabilityClean( + auto_empty=CapabilityCleanAutoEmpty( + event=AutoEmptyModeEvent, + get=[GetAutoEmpty()], + set=SetAutoEmpty, + types=( + AutoEmptyMode.MODE_10, + AutoEmptyMode.MODE_15, + AutoEmptyMode.MODE_25, + AutoEmptyMode.MODE_AUTO, + ), + ), action=CapabilityCleanAction(command=Clean, area=CleanArea), continuous=CapabilitySetEnable( ContinuousCleaningEvent, diff --git a/deebot_client/util.py b/deebot_client/util.py index a2edb740..988becb3 100644 --- a/deebot_client/util.py +++ b/deebot_client/util.py @@ -3,7 +3,7 @@ import asyncio from contextlib import suppress -from enum import IntEnum, unique +from enum import Enum, IntEnum, unique import hashlib from typing import TYPE_CHECKING, Any, Self, TypeVar @@ -88,6 +88,55 @@ def __hash__(self) -> int: return hash(self._value_) +@unique +class DisplayNameStrEnum(Enum): + """Int enum with a property "display_name".""" + + def __new__(cls, *args: str, **_: Mapping[Any, Any]) -> Self: + """Create new DisplayNameIntEnum.""" + obj = object.__new__(cls) + obj._value_ = args[0] + return obj + + def __init__(self, value: str, display_name: str | None = None) -> None: + super().__init__() + self._value_ = value + self._display_name = display_name + + @property + def display_name(self) -> str: + """Return the custom display name or the lowered name property.""" + if self._display_name: + return self._display_name + + return self.name.lower() + + @classmethod + def get(cls, value: str) -> Self: + """Get enum member from name or display_name.""" + value = value.upper() + if value in cls.__members__: + return cls[value] + + for member in cls: + if value == member.display_name.upper(): + return member + + msg = f"'{value}' is not a valid {cls.__name__} member" + raise ValueError(msg) + + def __eq__(self, x: object) -> bool: + if not isinstance(x, type(self)): + return False + return bool(self._value_ == x._value_) + + def __ne__(self, x: object) -> bool: + return not self.__eq__(x) + + def __hash__(self) -> int: + return hash(self._value_) + + class OnChangedList(list[_T]): """List, which will call passed on_change if a change happens.""" diff --git a/tests/commands/json/test_auto_empty.py b/tests/commands/json/test_auto_empty.py new file mode 100644 index 00000000..53fbf6e1 --- /dev/null +++ b/tests/commands/json/test_auto_empty.py @@ -0,0 +1,135 @@ +from typing import Any + +import pytest + +from deebot_client.commands.json import GetAutoEmpty, SetAutoEmpty +from deebot_client.events import AutoEmptyMode, AutoEmptyModeEvent +from tests.helpers import ( + get_request_json, + get_success_body, + verify_DisplayNameStrEnum_unique, +) + +from . import assert_command, assert_set_command + + +def test_WorkMode_unique() -> None: + verify_DisplayNameStrEnum_unique(AutoEmptyMode) + + +@pytest.mark.parametrize( + ("json", "expected"), + [ + ( + {"enable": 1, "frequency": "10"}, + AutoEmptyModeEvent(enable=True, mode=AutoEmptyMode.MODE_10), + ), + ( + {"enable": 1, "frequency": "15"}, + AutoEmptyModeEvent(enable=True, mode=AutoEmptyMode.MODE_15), + ), + ( + {"enable": 1, "frequency": "25"}, + AutoEmptyModeEvent(enable=True, mode=AutoEmptyMode.MODE_25), + ), + ( + {"enable": 0, "frequency": "25"}, + AutoEmptyModeEvent(enable=False, mode=AutoEmptyMode.MODE_25), + ), + ( + {"enable": 1, "frequency": "auto"}, + AutoEmptyModeEvent(enable=True, mode=AutoEmptyMode.MODE_AUTO), + ), + ( + {"enable": 1, "frequency": "smart"}, + AutoEmptyModeEvent(enable=True, mode=AutoEmptyMode.MODE_SMART), + ), + ], +) +async def test_GetAutoEmpty(json: dict[str, Any], expected: AutoEmptyModeEvent) -> None: + json = get_request_json(get_success_body(json)) + await assert_command(GetAutoEmpty(), json, expected) + + +@pytest.mark.parametrize( + ("value", "args", "expected"), + [ + ( + (True, AutoEmptyMode.MODE_10), + {"enable": 1, "frequency": "10"}, + AutoEmptyModeEvent(enable=True, mode=AutoEmptyMode.MODE_10), + ), + ( + (True, "mode_smart"), + {"enable": 1, "frequency": "smart"}, + AutoEmptyModeEvent(enable=True, mode=AutoEmptyMode.MODE_SMART), + ), + # NOTE: this test is also working, as 'enable' will set auto to 'true' if not provided + # as 'enable' is required when set a 'frequency' + ( + (None, AutoEmptyMode.MODE_25), + {"enable": 1, "frequency": "25"}, + AutoEmptyModeEvent(enable=True, mode=AutoEmptyMode.MODE_25), + ), + # NOTE: it should be possible to only send 'True' for turn on without 'frequency', + # but not sure how to implement the test correct + ( + (True, AutoEmptyMode.MODE_AUTO), + {"enable": 1, "frequency": "auto"}, + AutoEmptyModeEvent(enable=True, mode=AutoEmptyMode.MODE_AUTO), + ), + # NOTE: it should be possible to only send 'False' for turn off without 'frequency', + # but not sure how to implement the test correct + ( + (False, AutoEmptyMode.MODE_AUTO), + {"enable": 0, "frequency": "auto"}, + AutoEmptyModeEvent(enable=False, mode=AutoEmptyMode.MODE_AUTO), + ), + ], +) +async def test_SetAutoEmpty( + value: tuple[bool | None, AutoEmptyMode | str | None], + args: dict[str, Any], + expected: AutoEmptyModeEvent, +) -> None: + command = SetAutoEmpty() + if value[0] is None and value[1] is not None: + command = SetAutoEmpty(frequency=value[1]) + elif value[1] is None and value[0] is not None: + command = SetAutoEmpty(enable=value[0]) + elif value[0] is not None and value[1] is not None: + command = SetAutoEmpty(value[0], value[1]) + + await assert_set_command(command, args, expected) + + +@pytest.mark.parametrize( + ("value", "args", "expected"), + [ + ( + (None, AutoEmptyMode.MODE_AUTO), + {"enable": 0, "frequency": "auto"}, + AutoEmptyModeEvent(enable=False, mode=AutoEmptyMode.MODE_AUTO), + ), + ( + (None, None), + {"enable": 0, "frequency": "auto"}, + AutoEmptyModeEvent(enable=False, mode=AutoEmptyMode.MODE_AUTO), + ), + ], +) +async def test_SetAutoEmptyFail( + value: tuple[bool | None, AutoEmptyMode | str | None], + args: dict[str, Any], + expected: AutoEmptyModeEvent, +) -> None: + command = SetAutoEmpty() + if value[0] is None and value[1] is not None: + command = SetAutoEmpty(frequency=value[1]) + elif value[1] is None and value[0] is not None: + command = SetAutoEmpty(enable=value[0]) + elif value[0] is not None and value[1] is not None: + command = SetAutoEmpty(value[0], value[1]) + + with pytest.raises(AssertionError): + await assert_set_command(command, args, expected) diff --git a/tests/hardware/test_init.py b/tests/hardware/test_init.py index e5684b10..8740536f 100644 --- a/tests/hardware/test_init.py +++ b/tests/hardware/test_init.py @@ -7,6 +7,7 @@ from deebot_client.command import Command from deebot_client.commands.json.advanced_mode import GetAdvancedMode +from deebot_client.commands.json.auto_empty import GetAutoEmpty 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 @@ -51,6 +52,7 @@ VoiceAssistantStateEvent, VolumeEvent, ) +from deebot_client.events.auto_empty import AutoEmptyModeEvent from deebot_client.events.base import Event from deebot_client.events.efficiency_mode import EfficiencyModeEvent from deebot_client.events.fan_speed import FanSpeedEvent @@ -155,6 +157,7 @@ def test_get_static_device_info( "p95mgv", { AdvancedModeEvent: [GetAdvancedMode()], + AutoEmptyModeEvent: [GetAutoEmpty()], AvailabilityEvent: [GetBattery(is_available_check=True)], BatteryEvent: [GetBattery()], CachedMapInfoEvent: [GetCachedMapInfo()], diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py index 7b3cfe37..80895efa 100644 --- a/tests/helpers/__init__.py +++ b/tests/helpers/__init__.py @@ -7,7 +7,7 @@ from deebot_client.const import DataType from deebot_client.events.base import Event from deebot_client.models import StaticDeviceInfo -from deebot_client.util import DisplayNameIntEnum +from deebot_client.util import DisplayNameIntEnum, DisplayNameStrEnum def verify_DisplayNameEnum_unique(enum: type[DisplayNameIntEnum]) -> None: @@ -28,6 +28,24 @@ def verify_DisplayNameEnum_unique(enum: type[DisplayNameIntEnum]) -> None: names.add(display_name) +def verify_DisplayNameStrEnum_unique(enum: type[DisplayNameStrEnum]) -> None: + assert issubclass(enum, DisplayNameStrEnum) + names: set[str] = set() + values: set[str] = set() + for member in enum: + assert member.value not in values + values.add(member.value) + + name = member.name.lower() + assert name not in names + names.add(name) + + display_name = member.display_name.lower() + if display_name != name: + assert display_name not in names + names.add(display_name) + + def get_request_json(body: dict[str, Any]) -> dict[str, Any]: return {"id": "ALZf", "ret": "ok", "resp": get_message_json(body)}