diff --git a/deebot_client/capabilities.py b/deebot_client/capabilities.py index 3c743817d..84bd36527 100644 --- a/deebot_client/capabilities.py +++ b/deebot_client/capabilities.py @@ -11,6 +11,7 @@ from deebot_client.events import ( AdvancedModeEvent, AvailabilityEvent, + BaseStationEvent, BatteryEvent, BorderSwitchEvent, CachedMapInfoEvent, @@ -60,6 +61,7 @@ from _typeshed import DataclassInstance from deebot_client.command import Command + from deebot_client.commands import BaseStationAction from deebot_client.commands.json.common import ExecuteCommand from deebot_client.events.efficiency_mode import EfficiencyMode, EfficiencyModeEvent from deebot_client.models import CleanAction, CleanMode @@ -211,17 +213,16 @@ class CapabilitySettings: @dataclass(frozen=True, kw_only=True) -class CapabilityStation: - """Capabilities for station.""" - - auto_empty: ( - CapabilitySetTypes[ - auto_empty.AutoEmptyEvent, - [bool | None, auto_empty.Frequency | str | None], - auto_empty.Frequency, - ] - | None - ) = None +class CapabilityBaseStation: + """Capabilities for the base station.""" + + action: Callable[[BaseStationAction], Command] + auto_empty: CapabilitySetTypes[ + auto_empty.AutoEmptyEvent, + [bool | None, auto_empty.Frequency | str | None], + auto_empty.Frequency, + ] + status: CapabilityEvent[BaseStationEvent] @dataclass(frozen=True, kw_only=True) @@ -231,6 +232,7 @@ class Capabilities(ABC): device_type: DeviceType = field(kw_only=False) availability: CapabilityEvent[AvailabilityEvent] + base_station: CapabilityBaseStation | None = None battery: CapabilityEvent[BatteryEvent] charge: CapabilityExecute clean: CapabilityClean @@ -245,7 +247,6 @@ class Capabilities(ABC): play_sound: CapabilityExecute settings: CapabilitySettings state: CapabilityEvent[StateEvent] - station: CapabilityStation = field(default_factory=CapabilityStation) stats: CapabilityStats water: ( CapabilitySetTypes[WaterInfoEvent, [WaterAmount | str], WaterAmount] | None diff --git a/deebot_client/commands/__init__.py b/deebot_client/commands/__init__.py index 8c8d7df4c..90fd37db9 100644 --- a/deebot_client/commands/__init__.py +++ b/deebot_client/commands/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +from enum import IntEnum, unique from typing import TYPE_CHECKING from deebot_client.const import DataType @@ -19,3 +20,10 @@ COMMANDS_WITH_MQTT_P2P_HANDLING: dict[DataType, dict[str, type[CommandMqttP2P]]] = { DataType.JSON: JSON_COMMANDS_WITH_MQTT_P2P_HANDLING } + + +@unique +class BaseStationAction(IntEnum): + """Enum class for all possible base station actions.""" + + EMPTY_DUSTBIN = 1 diff --git a/deebot_client/commands/json/__init__.py b/deebot_client/commands/json/__init__.py index 9b4c3dfe8..e9ae08dbc 100644 --- a/deebot_client/commands/json/__init__.py +++ b/deebot_client/commands/json/__init__.py @@ -6,7 +6,7 @@ from deebot_client.command import Command, CommandMqttP2P -from . import auto_empty +from . import auto_empty, station_action, station_state from .advanced_mode import GetAdvancedMode, SetAdvancedMode from .battery import GetBattery from .border_switch import GetBorderSwitch, SetBorderSwitch @@ -211,6 +211,10 @@ GetSafeProtect, SetSafeProtect, + station_action.StationAction, + + station_state.GetStationState, + GetSweepMode, SetSweepMode, diff --git a/deebot_client/commands/json/station_action.py b/deebot_client/commands/json/station_action.py new file mode 100644 index 000000000..16330e242 --- /dev/null +++ b/deebot_client/commands/json/station_action.py @@ -0,0 +1,23 @@ +"""Charge commands.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from deebot_client.logging_filter import get_logger + +from .common import ExecuteCommand + +if TYPE_CHECKING: + from deebot_client.commands import BaseStationAction + +_LOGGER = get_logger(__name__) + + +class StationAction(ExecuteCommand): + """Station Action command.""" + + NAME = "stationAction" + + def __init__(self, action: BaseStationAction) -> None: + super().__init__({"act": 1, "type": action.value}) diff --git a/deebot_client/commands/json/station_state.py b/deebot_client/commands/json/station_state.py new file mode 100644 index 000000000..88c5047fe --- /dev/null +++ b/deebot_client/commands/json/station_state.py @@ -0,0 +1,13 @@ +"""Battery commands.""" + +from __future__ import annotations + +from deebot_client.messages.json.station_state import OnStationState + +from .common import JsonCommandWithMessageHandling + + +class GetStationState(OnStationState, JsonCommandWithMessageHandling): + """Get station state command.""" + + NAME = "getStationState" diff --git a/deebot_client/events/__init__.py b/deebot_client/events/__init__.py index 1708e6d34..32d478b78 100644 --- a/deebot_client/events/__init__.py +++ b/deebot_client/events/__init__.py @@ -8,8 +8,9 @@ from deebot_client.events.base import Event -from . import auto_empty +from . import auto_empty, base_station from .auto_empty import AutoEmptyEvent +from .base_station import BaseStationEvent from .efficiency_mode import EfficiencyMode, EfficiencyModeEvent from .fan_speed import FanSpeedEvent, FanSpeedLevel from .map import ( @@ -34,6 +35,7 @@ __all__ = [ "AutoEmptyEvent", + "BaseStationEvent", "BatteryEvent", "CachedMapInfoEvent", "CleanJobStatus", @@ -61,6 +63,7 @@ "WorkMode", "WorkModeEvent", "auto_empty", + "base_station", ] @@ -163,6 +166,7 @@ def from_xml(cls, value: str) -> LifeSpan: STRAINER = "strainer", "Strainer" HAND_FILTER = "handFilter", "HandFilter" DUST_CASE_HEAP = "dustCaseHeap", "DustCaseHeap" + BASE_STATION_FILTER = "spHeap", "SpHeap" @dataclass(frozen=True) diff --git a/deebot_client/events/base_station.py b/deebot_client/events/base_station.py new file mode 100644 index 000000000..39e609e19 --- /dev/null +++ b/deebot_client/events/base_station.py @@ -0,0 +1,25 @@ +"""Base station event module.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import IntEnum, unique + +from .base import Event as _Event + +__all__ = ["BaseStationEvent", "Status"] + + +@unique +class Status(IntEnum): + """Enum class for all possible base station statuses.""" + + IDLE = 0 + EMPTYING = 1 + + +@dataclass(frozen=True) +class BaseStationEvent(_Event): + """Base Station Event representation.""" + + state: Status diff --git a/deebot_client/hardware/deebot/buom7k.py b/deebot_client/hardware/deebot/buom7k.py index 4e91d2c5e..4ad446252 100644 --- a/deebot_client/hardware/deebot/buom7k.py +++ b/deebot_client/hardware/deebot/buom7k.py @@ -4,6 +4,7 @@ from deebot_client.capabilities import ( Capabilities, + CapabilityBaseStation, CapabilityClean, CapabilityCleanAction, CapabilityCustomCommand, @@ -18,6 +19,7 @@ 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, @@ -46,12 +48,15 @@ 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.station_action import StationAction +from deebot_client.commands.json.station_state import GetStationState 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 ( AvailabilityEvent, + BaseStationEvent, BatteryEvent, CachedMapInfoEvent, CarpetAutoFanBoostEvent, @@ -79,7 +84,9 @@ VolumeEvent, WaterAmount, WaterInfoEvent, + auto_empty, ) +from deebot_client.events.auto_empty import AutoEmptyEvent from deebot_client.models import StaticDeviceInfo from deebot_client.util import short_name @@ -92,6 +99,19 @@ availability=CapabilityEvent( AvailabilityEvent, [GetBattery(is_available_check=True)] ), + base_station=CapabilityBaseStation( + action=StationAction, + auto_empty=CapabilitySetTypes( + event=AutoEmptyEvent, + get=[GetAutoEmpty()], + set=SetAutoEmpty, + types=( + auto_empty.Frequency.AUTO, + auto_empty.Frequency.SMART, + ), + ), + status=CapabilityEvent(BaseStationEvent, [GetStationState()]), + ), battery=CapabilityEvent(BatteryEvent, [GetBattery()]), charge=CapabilityExecute(Charge), clean=CapabilityClean( diff --git a/deebot_client/hardware/deebot/p95mgv.py b/deebot_client/hardware/deebot/p95mgv.py index 97155f003..d587dcfe7 100644 --- a/deebot_client/hardware/deebot/p95mgv.py +++ b/deebot_client/hardware/deebot/p95mgv.py @@ -4,6 +4,7 @@ from deebot_client.capabilities import ( Capabilities, + CapabilityBaseStation, CapabilityClean, CapabilityCleanAction, CapabilityCustomCommand, @@ -15,7 +16,6 @@ CapabilitySetEnable, CapabilitySettings, CapabilitySetTypes, - CapabilityStation, CapabilityStats, DeviceType, ) @@ -57,6 +57,8 @@ 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.station_action import StationAction +from deebot_client.commands.json.station_state import GetStationState from deebot_client.commands.json.stats import GetStats, GetTotalStats from deebot_client.commands.json.true_detect import GetTrueDetect, SetTrueDetect from deebot_client.commands.json.voice_assistant_state import ( @@ -69,6 +71,7 @@ from deebot_client.events import ( AdvancedModeEvent, AvailabilityEvent, + BaseStationEvent, BatteryEvent, CachedMapInfoEvent, CarpetAutoFanBoostEvent, @@ -114,6 +117,21 @@ availability=CapabilityEvent( AvailabilityEvent, [GetBattery(is_available_check=True)] ), + base_station=CapabilityBaseStation( + action=StationAction, + auto_empty=CapabilitySetTypes( + event=auto_empty.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, + ), + ), + status=CapabilityEvent(BaseStationEvent, [GetStationState()]), + ), battery=CapabilityEvent(BatteryEvent, [GetBattery()]), charge=CapabilityExecute(Charge), clean=CapabilityClean( @@ -209,19 +227,6 @@ volume=CapabilitySet(VolumeEvent, [GetVolume()], SetVolume), ), state=CapabilityEvent(StateEvent, [GetChargeState(), GetCleanInfo()]), - station=CapabilityStation( - auto_empty=CapabilitySetTypes( - event=auto_empty.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, []), diff --git a/deebot_client/hardware/deebot/qhe2o2.py b/deebot_client/hardware/deebot/qhe2o2.py index 42cc73b64..a2ed86745 100644 --- a/deebot_client/hardware/deebot/qhe2o2.py +++ b/deebot_client/hardware/deebot/qhe2o2.py @@ -4,6 +4,7 @@ from deebot_client.capabilities import ( Capabilities, + CapabilityBaseStation, CapabilityClean, CapabilityCleanAction, CapabilityCustomCommand, @@ -15,7 +16,6 @@ CapabilitySetEnable, CapabilitySettings, CapabilitySetTypes, - CapabilityStation, CapabilityStats, DeviceType, ) @@ -48,6 +48,8 @@ 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.station_action import StationAction +from deebot_client.commands.json.station_state import GetStationState 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 @@ -55,6 +57,7 @@ from deebot_client.events import ( AutoEmptyEvent, AvailabilityEvent, + BaseStationEvent, BatteryEvent, CachedMapInfoEvent, CarpetAutoFanBoostEvent, @@ -97,6 +100,19 @@ AvailabilityEvent, [GetBattery(is_available_check=True)] ), battery=CapabilityEvent(BatteryEvent, [GetBattery()]), + base_station=CapabilityBaseStation( + action=StationAction, + auto_empty=CapabilitySetTypes( + event=AutoEmptyEvent, + get=[GetAutoEmpty()], + set=SetAutoEmpty, + types=( + auto_empty.Frequency.AUTO, + auto_empty.Frequency.SMART, + ), + ), + status=CapabilityEvent(BaseStationEvent, [GetStationState()]), + ), charge=CapabilityExecute(Charge), clean=CapabilityClean( action=CapabilityCleanAction(command=CleanV2, area=CleanAreaV2), @@ -173,17 +189,6 @@ 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.AUTO, - auto_empty.Frequency.SMART, - ), - ), - ), stats=CapabilityStats( clean=CapabilityEvent(StatsEvent, [GetStats()]), report=CapabilityEvent(ReportStatsEvent, []), diff --git a/deebot_client/messages/json/__init__.py b/deebot_client/messages/json/__init__.py index 8508e507b..6a916460f 100644 --- a/deebot_client/messages/json/__init__.py +++ b/deebot_client/messages/json/__init__.py @@ -7,6 +7,7 @@ from .auto_empty import OnAutoEmpty from .battery import OnBattery from .map import OnMapSetV2 +from .station_state import OnStationState from .stats import ReportStats if TYPE_CHECKING: @@ -27,7 +28,9 @@ OnMapSetV2, - ReportStats + OnStationState, + + ReportStats, ] # fmt: on diff --git a/deebot_client/messages/json/station_state.py b/deebot_client/messages/json/station_state.py new file mode 100644 index 000000000..36ca1409a --- /dev/null +++ b/deebot_client/messages/json/station_state.py @@ -0,0 +1,43 @@ +"""Base station messages.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from deebot_client.events.base_station import BaseStationEvent, Status +from deebot_client.message import HandlingResult, MessageBodyDataDict + +if TYPE_CHECKING: + from deebot_client.event_bus import EventBus + + +class OnStationState(MessageBodyDataDict): + """On battery message.""" + + NAME = "onStationState" + + @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 + """ + # "body":{"data":{"content":{"error":[],"type":0},"state":0},"code":0,"msg":"ok"} - Idle + # "body":{"data":{"content":{"error":[],"type":1,"motionState":1},"state":1},"code":0,"msg":"ok"} - Emptying + + if (state := data.get("state")) == 0: + reported_state = Status.IDLE + elif ( + state == 1 + and (content := data.get("content")) + and content.get("type") == 1 + and content.get("motionState") == 1 + ): + reported_state = Status.EMPTYING + else: + return HandlingResult.analyse() + + event_bus.notify(BaseStationEvent(reported_state)) + return HandlingResult.success() diff --git a/tests/commands/json/test_station_action.py b/tests/commands/json/test_station_action.py new file mode 100644 index 000000000..614706408 --- /dev/null +++ b/tests/commands/json/test_station_action.py @@ -0,0 +1,30 @@ +"""Auto empty tests.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from deebot_client.commands import BaseStationAction +from deebot_client.commands.json.station_action import StationAction + +from . import assert_execute_command + + +@pytest.mark.parametrize( + ("action", "args"), + [ + ( + BaseStationAction.EMPTY_DUSTBIN, + {"act": 1, "type": 1}, + ), + ], +) +async def test_StationAction( + action: BaseStationAction, + args: dict[str, Any], +) -> None: + """Test StationAction.""" + command = StationAction(action) + await assert_execute_command(command, args) diff --git a/tests/commands/json/test_station_state.py b/tests/commands/json/test_station_state.py new file mode 100644 index 000000000..aa7104687 --- /dev/null +++ b/tests/commands/json/test_station_state.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from deebot_client.commands.json.station_state import GetStationState +from deebot_client.events.base_station import BaseStationEvent, Status +from tests.helpers import get_request_json, get_success_body + +from . import assert_command + + +@pytest.mark.parametrize( + ("state", "additional_content", "expected"), + [ + (0, {"type": 0}, Status.IDLE), + (1, {"type": 1, "motionState": 1}, Status.EMPTYING), + ], +) +async def test_GetStationState( + state: int, + additional_content: dict[str, Any], + expected: Status, +) -> None: + json = get_request_json( + get_success_body( + { + "content": {"error": [], **additional_content}, + "state": state, + } + ) + ) + await assert_command(GetStationState(), json, BaseStationEvent(expected)) diff --git a/tests/hardware/test_init.py b/tests/hardware/test_init.py index 94282e9d6..23fa42c4a 100644 --- a/tests/hardware/test_init.py +++ b/tests/hardware/test_init.py @@ -35,6 +35,7 @@ from deebot_client.commands.json.ota import GetOta from deebot_client.commands.json.pos import GetPos from deebot_client.commands.json.safe_protect import GetSafeProtect +from deebot_client.commands.json.station_state import GetStationState from deebot_client.commands.json.stats import GetStats, GetTotalStats from deebot_client.commands.json.true_detect import GetTrueDetect from deebot_client.commands.json.voice_assistant_state import GetVoiceAssistantState @@ -44,6 +45,7 @@ AdvancedModeEvent, AutoEmptyEvent, AvailabilityEvent, + BaseStationEvent, BatteryEvent, BorderSwitchEvent, CarpetAutoFanBoostEvent, @@ -196,6 +198,7 @@ async def test_get_static_device_info( AutoEmptyEvent: [GetAutoEmpty()], AdvancedModeEvent: [GetAdvancedMode()], AvailabilityEvent: [GetBattery(is_available_check=True)], + BaseStationEvent: [GetStationState()], BatteryEvent: [GetBattery()], CachedMapInfoEvent: [GetCachedMapInfo()], CarpetAutoFanBoostEvent: [GetCarpetAutoFanBoost()], diff --git a/tests/messages/json/test_station_state.py b/tests/messages/json/test_station_state.py new file mode 100644 index 000000000..e862aa076 --- /dev/null +++ b/tests/messages/json/test_station_state.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from deebot_client.events.base_station import BaseStationEvent, Status +from deebot_client.messages.json.station_state import OnStationState +from tests.messages import assert_message + + +@pytest.mark.parametrize( + ("state", "additional_content", "expected"), + [ + (0, {"type": 0}, Status.IDLE), + (1, {"type": 1, "motionState": 1}, Status.EMPTYING), + ], +) +def test_onStationState( + state: int, + additional_content: dict[str, Any], + expected: Status, +) -> None: + data: dict[str, Any] = { + "header": { + "pri": 1, + "tzm": 60, + "ts": "1734719921057", + "ver": "0.0.1", + "fwVer": "1.30.0", + "hwVer": "0.1.1", + "wkVer": "0.1.54", + }, + "body": { + "data": {"content": {"error": [], **additional_content}, "state": state}, + "code": 0, + "msg": "ok", + }, + } + + assert_message(OnStationState, data, BaseStationEvent(expected))