diff --git a/deebot_client/capabilities.py b/deebot_client/capabilities.py index 99235c2e..3c743817 100644 --- a/deebot_client/capabilities.py +++ b/deebot_client/capabilities.py @@ -6,7 +6,7 @@ from dataclasses import dataclass, field, fields, is_dataclass from enum import StrEnum from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Generic, TypeVar +from typing import TYPE_CHECKING, Any, Generic, ParamSpec, TypeVar from deebot_client.events import ( AdvancedModeEvent, @@ -67,6 +67,7 @@ _T = TypeVar("_T") _EVENT = TypeVar("_EVENT", bound=Event) +_P = ParamSpec("_P") def _get_events( @@ -94,14 +95,14 @@ class CapabilityEvent(Generic[_EVENT]): @dataclass(frozen=True) -class CapabilitySet(CapabilityEvent[_EVENT], Generic[_EVENT, _T]): +class CapabilitySet(CapabilityEvent[_EVENT], Generic[_EVENT, _P]): """Capability setCommand with event.""" - set: Callable[[_T], ExecuteCommand] + set: Callable[_P, ExecuteCommand] @dataclass(frozen=True) -class CapabilitySetEnable(CapabilitySet[_EVENT, bool]): +class CapabilitySetEnable(CapabilitySet[_EVENT, [bool]]): """Capability for SetEnableCommand with event.""" @@ -120,7 +121,7 @@ class CapabilityTypes(Generic[_T]): @dataclass(frozen=True, kw_only=True) -class CapabilitySetTypes(CapabilitySet[_EVENT, _T | str], CapabilityTypes[_T]): +class CapabilitySetTypes(CapabilitySet[_EVENT, _P], CapabilityTypes[_T]): """Capability for set command and types.""" @@ -138,10 +139,12 @@ class CapabilityClean: action: CapabilityCleanAction continuous: CapabilitySetEnable[ContinuousCleaningEvent] | None = None - count: CapabilitySet[CleanCountEvent, int] | None = None + count: CapabilitySet[CleanCountEvent, [int]] | None = None log: CapabilityEvent[CleanLogEvent] | None = None preference: CapabilitySetEnable[CleanPreferenceEvent] | None = None - work_mode: CapabilitySetTypes[WorkModeEvent, WorkMode] | None = None + work_mode: CapabilitySetTypes[WorkModeEvent, [WorkMode | str], WorkMode] | None = ( + None + ) @dataclass(frozen=True) @@ -188,12 +191,13 @@ class CapabilitySettings: advanced_mode: CapabilitySetEnable[AdvancedModeEvent] | None = None carpet_auto_fan_boost: CapabilitySetEnable[CarpetAutoFanBoostEvent] | None = None - efficiency_mode: CapabilitySetTypes[EfficiencyModeEvent, EfficiencyMode] | None = ( - None - ) + efficiency_mode: ( + CapabilitySetTypes[EfficiencyModeEvent, [EfficiencyMode | str], EfficiencyMode] + | None + ) = None border_switch: CapabilitySetEnable[BorderSwitchEvent] | None = None child_lock: CapabilitySetEnable[ChildLockEvent] | None = None - cut_direction: CapabilitySet[CutDirectionEvent, int] | None = None + cut_direction: CapabilitySet[CutDirectionEvent, [int]] | None = None moveup_warning: CapabilitySetEnable[MoveUpWarningEvent] | None = None cross_map_border_warning: CapabilitySetEnable[CrossMapBorderWarningEvent] | None = ( None @@ -203,14 +207,21 @@ class CapabilitySettings: sweep_mode: CapabilitySetEnable[SweepModeEvent] | None = None true_detect: CapabilitySetEnable[TrueDetectEvent] | None = None voice_assistant: CapabilitySetEnable[VoiceAssistantStateEvent] | None = None - volume: CapabilitySet[VolumeEvent, int] + volume: CapabilitySet[VolumeEvent, [int]] @dataclass(frozen=True, kw_only=True) class CapabilityStation: """Capabilities for station.""" - auto_empty: CapabilitySetTypes[auto_empty.Event, auto_empty.Frequency] | None = None + auto_empty: ( + CapabilitySetTypes[ + auto_empty.AutoEmptyEvent, + [bool | None, auto_empty.Frequency | str | None], + auto_empty.Frequency, + ] + | None + ) = None @dataclass(frozen=True, kw_only=True) @@ -225,7 +236,9 @@ class Capabilities(ABC): clean: CapabilityClean custom: CapabilityCustomCommand[CustomCommandEvent] error: CapabilityEvent[ErrorEvent] - fan_speed: CapabilitySetTypes[FanSpeedEvent, FanSpeedLevel] | None = None + fan_speed: ( + CapabilitySetTypes[FanSpeedEvent, [FanSpeedLevel | str], FanSpeedLevel] | None + ) = None life_span: CapabilityLifeSpan map: CapabilityMap | None = None network: CapabilityEvent[NetworkInfoEvent] @@ -234,7 +247,9 @@ class Capabilities(ABC): state: CapabilityEvent[StateEvent] station: CapabilityStation = field(default_factory=CapabilityStation) stats: CapabilityStats - water: CapabilitySetTypes[WaterInfoEvent, WaterAmount] | None = None + water: ( + CapabilitySetTypes[WaterInfoEvent, [WaterAmount | str], WaterAmount] | None + ) = None _events: MappingProxyType[type[Event], list[Command]] = field(init=False) diff --git a/deebot_client/commands/json/__init__.py b/deebot_client/commands/json/__init__.py index edc4bc0c..9898d2a5 100644 --- a/deebot_client/commands/json/__init__.py +++ b/deebot_client/commands/json/__init__.py @@ -6,8 +6,8 @@ from deebot_client.command import Command, CommandMqttP2P +from . import auto_empty from .advanced_mode import GetAdvancedMode, SetAdvancedMode -from .auto_empty import GetAutoEmpty, SetAutoEmpty from .battery import GetBattery from .border_switch import GetBorderSwitch, SetBorderSwitch from .carpet import GetCarpetAutoFanBoost, SetCarpetAutoFanBoost @@ -131,8 +131,8 @@ GetAdvancedMode, SetAdvancedMode, - GetAutoEmpty, - SetAutoEmpty, + auto_empty.GetAutoEmpty, + auto_empty.SetAutoEmpty, GetBorderSwitch, SetBorderSwitch, diff --git a/deebot_client/commands/json/auto_empty.py b/deebot_client/commands/json/auto_empty.py index 2712de89..45130536 100644 --- a/deebot_client/commands/json/auto_empty.py +++ b/deebot_client/commands/json/auto_empty.py @@ -10,11 +10,6 @@ from .common import ExecuteCommand, JsonGetCommand -__all__ = [ - "GetAutoEmpty", - "SetAutoEmpty", -] - class GetAutoEmpty(OnAutoEmpty, JsonGetCommand): """Get auto empty command.""" @@ -28,7 +23,7 @@ class SetAutoEmpty(ExecuteCommand): name = "setAutoEmpty" def __init__( - self, *, enable: bool | None = None, frequency: Frequency | str | None = None + self, enable: bool | None = None, frequency: Frequency | str | None = None ) -> None: if frequency is not None and not isinstance(frequency, Frequency): frequency = get_enum(Frequency, frequency) diff --git a/deebot_client/commands/json/common.py b/deebot_client/commands/json/common.py index bbb25aff..996b528b 100644 --- a/deebot_client/commands/json/common.py +++ b/deebot_client/commands/json/common.py @@ -132,5 +132,5 @@ def __init_subclass__(cls, **kwargs: Any) -> None: cls._mqtt_params = MappingProxyType({cls._field_name: InitParam(bool, _ENABLE)}) super().__init_subclass__(**kwargs) - def __init__(self, enable: bool) -> None: # noqa: FBT001 + def __init__(self, enable: bool) -> None: super().__init__({self._field_name: 1 if enable else 0}) diff --git a/deebot_client/commands/json/ota.py b/deebot_client/commands/json/ota.py index 8ee5bdcd..ed6fc55c 100644 --- a/deebot_client/commands/json/ota.py +++ b/deebot_client/commands/json/ota.py @@ -55,5 +55,5 @@ class SetOta(JsonSetCommand): _mqtt_params = MappingProxyType({"autoSwitch": InitParam(bool, "auto_enabled")}) - def __init__(self, auto_enabled: bool) -> None: # noqa: FBT001 + def __init__(self, auto_enabled: bool) -> None: super().__init__({"autoSwitch": 1 if auto_enabled else 0}) diff --git a/deebot_client/events/__init__.py b/deebot_client/events/__init__.py index 3c08112c..7c44c8e7 100644 --- a/deebot_client/events/__init__.py +++ b/deebot_client/events/__init__.py @@ -9,6 +9,7 @@ from deebot_client.events.base import Event from . import auto_empty +from .auto_empty import AutoEmptyEvent from .efficiency_mode import EfficiencyMode, EfficiencyModeEvent from .fan_speed import FanSpeedEvent, FanSpeedLevel from .map import ( @@ -32,7 +33,7 @@ from deebot_client.models import Room, State __all__ = [ - "auto_empty", + "AutoEmptyEvent", "BatteryEvent", "CachedMapInfoEvent", "CleanJobStatus", @@ -59,6 +60,7 @@ "WaterInfoEvent", "WorkMode", "WorkModeEvent", + "auto_empty", ] diff --git a/deebot_client/events/auto_empty.py b/deebot_client/events/auto_empty.py index 10106ffd..b9936c30 100644 --- a/deebot_client/events/auto_empty.py +++ b/deebot_client/events/auto_empty.py @@ -7,7 +7,7 @@ from .base import Event as _Event -__all__ = ["Event", "Frequency"] +__all__ = ["AutoEmptyEvent", "Frequency"] @unique @@ -22,7 +22,7 @@ class Frequency(StrEnum): @dataclass(frozen=True) -class Event(_Event): +class AutoEmptyEvent(_Event): """Auto empty event representation.""" enabled: bool diff --git a/deebot_client/hardware/deebot/p95mgv.py b/deebot_client/hardware/deebot/p95mgv.py index 543ee436..97155f00 100644 --- a/deebot_client/hardware/deebot/p95mgv.py +++ b/deebot_client/hardware/deebot/p95mgv.py @@ -211,7 +211,7 @@ state=CapabilityEvent(StateEvent, [GetChargeState(), GetCleanInfo()]), station=CapabilityStation( auto_empty=CapabilitySetTypes( - event=auto_empty.Event, + event=auto_empty.AutoEmptyEvent, get=[GetAutoEmpty()], set=SetAutoEmpty, types=( diff --git a/deebot_client/hardware/deebot/qhe2o2.py b/deebot_client/hardware/deebot/qhe2o2.py deleted file mode 120000 index ee958842..00000000 --- a/deebot_client/hardware/deebot/qhe2o2.py +++ /dev/null @@ -1 +0,0 @@ -kr0277.py \ No newline at end of file diff --git a/deebot_client/hardware/deebot/qhe2o2.py b/deebot_client/hardware/deebot/qhe2o2.py new file mode 100644 index 00000000..13c720a5 --- /dev/null +++ b/deebot_client/hardware/deebot/qhe2o2.py @@ -0,0 +1,205 @@ +"""Deebot N20 Pro Plus Capabilities.""" + +from __future__ import annotations + +from deebot_client.capabilities import ( + Capabilities, + CapabilityClean, + CapabilityCleanAction, + CapabilityCustomCommand, + CapabilityEvent, + CapabilityExecute, + CapabilityLifeSpan, + CapabilityMap, + CapabilitySet, + CapabilitySetEnable, + CapabilitySettings, + CapabilitySetTypes, + CapabilityStation, + CapabilityStats, + DeviceType, +) +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, + SetCarpetAutoFanBoost, +) +from deebot_client.commands.json.charge import Charge +from deebot_client.commands.json.charge_state import GetChargeState +from deebot_client.commands.json.child_lock import GetChildLock, SetChildLock +from deebot_client.commands.json.clean import CleanAreaV2, CleanV2, GetCleanInfoV2 +from deebot_client.commands.json.clean_count import GetCleanCount, SetCleanCount +from deebot_client.commands.json.clean_logs import GetCleanLogs +from deebot_client.commands.json.continuous_cleaning import ( + GetContinuousCleaning, + SetContinuousCleaning, +) +from deebot_client.commands.json.custom import CustomCommand +from deebot_client.commands.json.error import GetError +from deebot_client.commands.json.fan_speed import GetFanSpeed, SetFanSpeed +from deebot_client.commands.json.life_span import GetLifeSpan, ResetLifeSpan +from deebot_client.commands.json.map import GetCachedMapInfo, GetMajorMap, GetMapTrace +from deebot_client.commands.json.multimap_state import ( + GetMultimapState, + SetMultimapState, +) +from deebot_client.commands.json.network import GetNetInfo +from deebot_client.commands.json.play_sound import PlaySound +from deebot_client.commands.json.pos import GetPos +from deebot_client.commands.json.relocation import SetRelocationState +from deebot_client.commands.json.stats import GetStats, GetTotalStats +from deebot_client.commands.json.volume import GetVolume, SetVolume +from deebot_client.commands.json.water_info import GetWaterInfo, SetWaterInfo +from deebot_client.const import DataType +from deebot_client.events import ( + AutoEmptyEvent, + AvailabilityEvent, + BatteryEvent, + CachedMapInfoEvent, + CarpetAutoFanBoostEvent, + ChildLockEvent, + CleanCountEvent, + CleanLogEvent, + ContinuousCleaningEvent, + CustomCommandEvent, + ErrorEvent, + FanSpeedEvent, + FanSpeedLevel, + LifeSpan, + LifeSpanEvent, + MajorMapEvent, + MapChangedEvent, + MapTraceEvent, + MultimapStateEvent, + NetworkInfoEvent, + PositionsEvent, + ReportStatsEvent, + RoomsEvent, + StateEvent, + StatsEvent, + TotalStatsEvent, + VolumeEvent, + WaterAmount, + WaterInfoEvent, + auto_empty, +) +from deebot_client.models import StaticDeviceInfo +from deebot_client.util import short_name + +from . import DEVICES + +DEVICES[short_name(__name__)] = StaticDeviceInfo( + DataType.JSON, + Capabilities( + device_type=DeviceType.VACUUM, + availability=CapabilityEvent( + AvailabilityEvent, [GetBattery(is_available_check=True)] + ), + battery=CapabilityEvent(BatteryEvent, [GetBattery()]), + charge=CapabilityExecute(Charge), + clean=CapabilityClean( + action=CapabilityCleanAction(command=CleanV2, area=CleanAreaV2), + continuous=CapabilitySetEnable( + ContinuousCleaningEvent, + [GetContinuousCleaning()], + SetContinuousCleaning, + ), + count=CapabilitySet(CleanCountEvent, [GetCleanCount()], SetCleanCount), + 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, + LifeSpan.UNIT_CARE, + LifeSpan.ROUND_MOP, + ), + event=LifeSpanEvent, + get=[ + GetLifeSpan( + [ + LifeSpan.BRUSH, + LifeSpan.FILTER, + LifeSpan.SIDE_BRUSH, + LifeSpan.UNIT_CARE, + LifeSpan.ROUND_MOP, + ] + ) + ], + reset=ResetLifeSpan, + ), + map=CapabilityMap( + cached_info=CapabilityEvent( + CachedMapInfoEvent, [GetCachedMapInfo(version=2)] + ), + changed=CapabilityEvent(MapChangedEvent, []), + major=CapabilityEvent(MajorMapEvent, [GetMajorMap()]), + multi_state=CapabilitySetEnable( + MultimapStateEvent, [GetMultimapState()], SetMultimapState + ), + position=CapabilityEvent(PositionsEvent, [GetPos()]), + relocation=CapabilityExecute(SetRelocationState), + rooms=CapabilityEvent(RoomsEvent, [GetCachedMapInfo(version=2)]), + trace=CapabilityEvent(MapTraceEvent, [GetMapTrace()]), + ), + network=CapabilityEvent(NetworkInfoEvent, [GetNetInfo()]), + play_sound=CapabilityExecute(PlaySound), + settings=CapabilitySettings( + carpet_auto_fan_boost=CapabilitySetEnable( + CarpetAutoFanBoostEvent, + [GetCarpetAutoFanBoost()], + SetCarpetAutoFanBoost, + ), + child_lock=CapabilitySetEnable( + ChildLockEvent, [GetChildLock()], SetChildLock + ), + volume=CapabilitySet(VolumeEvent, [GetVolume()], SetVolume), + ), + state=CapabilityEvent(StateEvent, [GetChargeState(), GetCleanInfoV2()]), + station=CapabilityStation( + auto_empty=CapabilitySetTypes( + event=AutoEmptyEvent, + get=[GetAutoEmpty()], + set=SetAutoEmpty, + types=( + auto_empty.Frequency.MIN_10, + auto_empty.Frequency.MIN_15, + auto_empty.Frequency.MIN_25, + auto_empty.Frequency.AUTO, + ), + ), + ), + 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, + ), + ), + ), +) diff --git a/deebot_client/messages/json/auto_empty.py b/deebot_client/messages/json/auto_empty.py index 7c9222d4..1d73b61a 100644 --- a/deebot_client/messages/json/auto_empty.py +++ b/deebot_client/messages/json/auto_empty.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Any -from deebot_client.events.auto_empty import Event, Frequency +from deebot_client.events.auto_empty import AutoEmptyEvent, Frequency from deebot_client.message import HandlingResult, MessageBodyDataDict if TYPE_CHECKING: @@ -27,5 +27,5 @@ def _handle_body_data_dict( frequency: Frequency | None = None if frequency_str := data.get("frequency"): frequency = Frequency(frequency_str) - event_bus.notify(Event(bool(data["enable"]), frequency)) + event_bus.notify(AutoEmptyEvent(bool(data["enable"]), frequency)) return HandlingResult.success() diff --git a/pyproject.toml b/pyproject.toml index 8dbb99b4..8fd92c2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,8 +74,9 @@ ignore = [ "D107", # Missing docstring in `__init__` "E501", # line too long - "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable + "FBT", # flake8-boolean-trap + "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable # Conflicts with the Ruff formatter "COM812", diff --git a/tests/commands/json/test_auto_empty.py b/tests/commands/json/test_auto_empty.py index 599ff135..cae4f0c7 100644 --- a/tests/commands/json/test_auto_empty.py +++ b/tests/commands/json/test_auto_empty.py @@ -7,7 +7,7 @@ import pytest from deebot_client.commands.json.auto_empty import GetAutoEmpty, SetAutoEmpty -from deebot_client.events.auto_empty import Event, Frequency +from deebot_client.events.auto_empty import AutoEmptyEvent, Frequency from tests.helpers import ( get_request_json, get_success_body, @@ -21,65 +21,101 @@ [ ( {"enable": 1, "frequency": "10"}, - Event(Frequency.MIN_10), + AutoEmptyEvent(True, Frequency.MIN_10), ), ( {"enable": 1, "frequency": "15"}, - Event(Frequency.MIN_15), + AutoEmptyEvent(True, Frequency.MIN_15), ), ( {"enable": 1, "frequency": "25"}, - Event(Frequency.MIN_25), + AutoEmptyEvent(True, Frequency.MIN_25), ), ( {"enable": 0, "frequency": "auto"}, - Event(Frequency.OFF), + AutoEmptyEvent(False, Frequency.AUTO), ), ( {"enable": 1, "frequency": "auto"}, - Event(Frequency.AUTO), + AutoEmptyEvent(True, Frequency.AUTO), ), ( {"enable": 1, "frequency": "smart"}, - Event(Frequency.SMART), + AutoEmptyEvent(True, Frequency.SMART), ), ], ) -async def test_GetAutoEmpty(json: dict[str, Any], expected: Event) -> None: +async def test_GetAutoEmpty(json: dict[str, Any], expected: AutoEmptyEvent) -> None: """Test GetAutoEmpty.""" json = get_request_json(get_success_body(json)) await assert_command(GetAutoEmpty(), json, expected) @pytest.mark.parametrize( - ("frequency", "args"), + ("enabled", "frequency", "args"), [ ( + True, "min_10", {"enable": 1, "frequency": "10"}, ), ( + True, Frequency.MIN_10, {"enable": 1, "frequency": "10"}, ), ( + True, "smart", {"enable": 1, "frequency": "smart"}, ), ( - Frequency.OFF, - {"enable": 0}, + False, + "min_10", + {"enable": 0, "frequency": "10"}, + ), + ( + False, + Frequency.MIN_10, + {"enable": 0, "frequency": "10"}, + ), + ( + False, + "smart", + {"enable": 0, "frequency": "smart"}, + ), + ( + None, + "min_10", + {"frequency": "10"}, + ), + ( + None, + Frequency.MIN_10, + {"frequency": "10"}, + ), + ( + None, + "smart", + {"frequency": "smart"}, + ), + ( + True, + None, + {"enable": 1}, ), ( - "off", + False, + None, {"enable": 0}, ), ], ) async def test_SetAutoEmpty( - frequency: str | Frequency, + enabled: bool, + frequency: str | Frequency | None, args: dict[str, Any], ) -> None: """Test SetAutoEmpty.""" - command = SetAutoEmpty(frequency) + command = SetAutoEmpty(enabled, frequency) await assert_execute_command(command, args) diff --git a/tests/hardware/test_init.py b/tests/hardware/test_init.py index eb8650cf..94282e9d 100644 --- a/tests/hardware/test_init.py +++ b/tests/hardware/test_init.py @@ -42,6 +42,7 @@ from deebot_client.commands.json.water_info import GetWaterInfo from deebot_client.events import ( AdvancedModeEvent, + AutoEmptyEvent, AvailabilityEvent, BatteryEvent, BorderSwitchEvent, @@ -69,7 +70,6 @@ TrueDetectEvent, VoiceAssistantStateEvent, VolumeEvent, - auto_empty, ) from deebot_client.events.efficiency_mode import EfficiencyModeEvent from deebot_client.events.fan_speed import FanSpeedEvent @@ -193,7 +193,7 @@ async def test_get_static_device_info( ( "p95mgv", { - auto_empty.Event: [GetAutoEmpty()], + AutoEmptyEvent: [GetAutoEmpty()], AdvancedModeEvent: [GetAdvancedMode()], AvailabilityEvent: [GetBattery(is_available_check=True)], BatteryEvent: [GetBattery()], diff --git a/tests/messages/json/test_auto_empty.py b/tests/messages/json/test_auto_empty.py index 5da3c9c2..5fff7e93 100644 --- a/tests/messages/json/test_auto_empty.py +++ b/tests/messages/json/test_auto_empty.py @@ -21,4 +21,4 @@ def test_onAutoEmpty(percentage: int) -> None: "body": {"data": {"value": percentage, "isLow": 1 if percentage < 20 else 0}}, } - assert_message(OnAutoEmpty, data, auto_empty.Event(percentage)) + assert_message(OnAutoEmpty, data, auto_empty.AutoEmptyEvent(percentage))