From fc63dfc5192eaed2f1b1886193a2cd8ffb31a754 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sat, 26 Apr 2025 11:58:55 +0200 Subject: [PATCH 1/5] Add XML Clean Commands --- deebot_client/commands/xml/__init__.py | 7 ++ deebot_client/commands/xml/clean.py | 97 ++++++++++++++++++++++++++ tests/commands/xml/test_clean.py | 81 +++++++++++++++++++++ 3 files changed, 185 insertions(+) create mode 100644 deebot_client/commands/xml/clean.py create mode 100644 tests/commands/xml/test_clean.py diff --git a/deebot_client/commands/xml/__init__.py b/deebot_client/commands/xml/__init__.py index 9561f6581..e1da8eb39 100644 --- a/deebot_client/commands/xml/__init__.py +++ b/deebot_client/commands/xml/__init__.py @@ -9,6 +9,7 @@ from .battery import GetBatteryInfo from .charge import Charge from .charge_state import GetChargeState +from .clean import Clean, CleanArea, GetCleanState from .clean_logs import GetCleanLogs from .error import GetError from .fan_speed import GetFanSpeed @@ -22,10 +23,13 @@ __all__ = [ "Charge", + "Clean", + "CleanArea", "GetBatteryInfo", "GetChargeState", "GetChargerPos", "GetCleanLogs", + "GetCleanState", "GetCleanSum", "GetError", "GetFanSpeed", @@ -37,10 +41,13 @@ # fmt: off # ordered by file asc _COMMANDS: list[type[XmlCommand]] = [ + Clean, + CleanArea, GetBatteryInfo, GetChargerPos, GetCleanLogs, GetError, + GetCleanState, GetLifeSpan, GetPos, PlaySound, diff --git a/deebot_client/commands/xml/clean.py b/deebot_client/commands/xml/clean.py new file mode 100644 index 000000000..b04690992 --- /dev/null +++ b/deebot_client/commands/xml/clean.py @@ -0,0 +1,97 @@ +"""Clean commands.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from deebot_client.events import FanSpeedEvent, FanSpeedLevel, StateEvent +from deebot_client.logging_filter import get_logger +from deebot_client.message import HandlingResult +from deebot_client.models import CleanAction, CleanMode, State + +from .common import ExecuteCommand, XmlCommandWithMessageHandling + +if TYPE_CHECKING: + from xml.etree.ElementTree import Element + + from deebot_client.event_bus import EventBus + +_LOGGER = get_logger(__name__) + + +class Clean(ExecuteCommand): + """Generic start/pause/stop cleaning command.""" + + NAME = "Clean" + HAS_SUB_ELEMENT = True + + def __init__( + self, action: CleanAction, speed: FanSpeedLevel = FanSpeedLevel.NORMAL + ) -> None: + # + + super().__init__( + { + "type": CleanMode.AUTO.xml_value, + "act": action.xml_value, + "speed": speed.xml_value, + } + ) + + +class CleanArea(ExecuteCommand): + """Clean area command.""" + + NAME = "Clean" + HAS_SUB_ELEMENT = True + + def __init__( + self, + mode: CleanMode, + area: str, + cleanings: int = 1, + speed: FanSpeedLevel = FanSpeedLevel.NORMAL, + ) -> None: + # + + super().__init__( + { + "type": mode.xml_value, + "act": CleanAction.START.xml_value, + "speed": speed.xml_value, + "deep": str(cleanings), + "mid": area, + } + ) + + +class GetCleanState(XmlCommandWithMessageHandling): + """GetCleanState command.""" + + NAME = "GetCleanState" + + @classmethod + def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult: + """Handle xml message and notify the correct event subscribers. + + :return: A message response + """ + if xml.attrib.get("ret") != "ok" or (clean := xml.find("clean")) is None: + return HandlingResult.analyse() + + speed_attrib = clean.attrib.get("speed") + if speed_attrib is not None: + fan_speed_level = FanSpeedLevel.from_xml(speed_attrib) + event_bus.notify(FanSpeedEvent(fan_speed_level)) + + clean_attrib = clean.attrib.get("st") + if clean_attrib is not None: + clean_action = CleanAction.from_xml(clean_attrib) + if clean_action == CleanAction.START: + event_bus.notify(StateEvent(State.CLEANING)) + elif clean_action == CleanAction.PAUSE: + event_bus.notify(StateEvent(State.PAUSED)) + else: + _LOGGER.debug("Ignored CleanState %s", clean_action) + + return HandlingResult.success() diff --git a/tests/commands/xml/test_clean.py b/tests/commands/xml/test_clean.py new file mode 100644 index 000000000..14daae18c --- /dev/null +++ b/tests/commands/xml/test_clean.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import pytest + +from deebot_client.command import CommandResult +from deebot_client.commands.xml import GetCleanState +from deebot_client.commands.xml.clean import CleanArea +from deebot_client.events import FanSpeedEvent, FanSpeedLevel, StateEvent +from deebot_client.message import HandlingState +from deebot_client.models import CleanMode, State +from tests.commands import assert_command + +from . import get_request_xml + + +@pytest.mark.parametrize( + ("command", "command_result"), + [ + (CleanArea(CleanMode.SPOT_AREA, "4", 1), HandlingState.SUCCESS), + ], +) +async def test_CleanArea(command: CleanArea, command_result: HandlingState) -> None: + json = get_request_xml("") + await assert_command( + command, json, None, command_result=CommandResult(command_result) + ) + + +@pytest.mark.parametrize( + ("speed", "state", "expected_fan_speed_event", "expected_state_event"), + [ + ( + "standard", + "s", + FanSpeedEvent(FanSpeedLevel.NORMAL), + StateEvent(State.CLEANING), + ), + ( + "strong", + "s", + FanSpeedEvent(FanSpeedLevel.MAX), + StateEvent(State.CLEANING), + ), + ( + "standard", + "p", + FanSpeedEvent(FanSpeedLevel.NORMAL), + StateEvent(State.PAUSED), + ), + ], + ids=["standard_cleaning", "strong_cleaning", "paused"], +) +async def test_get_clean_state( + speed: str, + state: str, + expected_fan_speed_event: FanSpeedEvent, + expected_state_event: StateEvent, +) -> None: + json = get_request_xml( + f"" + ) + await assert_command( + GetCleanState(), + json, + [x for x in [expected_fan_speed_event, expected_state_event] if x is not None], + ) + + +@pytest.mark.parametrize( + "xml", + ["", ""], + ids=["error", "no_state"], +) +async def test_get_clean_state_error(xml: str) -> None: + json = get_request_xml(xml) + await assert_command( + GetCleanState(), + json, + None, + command_result=CommandResult(HandlingState.ANALYSE_LOGGED), + ) From a853a75ac3d7bc2daf37857fd99765e1f62417ba Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sat, 19 Apr 2025 19:33:21 +0200 Subject: [PATCH 2/5] Add missing test cases --- deebot_client/commands/xml/clean.py | 4 +- tests/commands/xml/test_clean.py | 68 +++++++++++++++++++++++------ 2 files changed, 56 insertions(+), 16 deletions(-) diff --git a/deebot_client/commands/xml/clean.py b/deebot_client/commands/xml/clean.py index b04690992..5a854e5e4 100644 --- a/deebot_client/commands/xml/clean.py +++ b/deebot_client/commands/xml/clean.py @@ -91,7 +91,7 @@ def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult: event_bus.notify(StateEvent(State.CLEANING)) elif clean_action == CleanAction.PAUSE: event_bus.notify(StateEvent(State.PAUSED)) - else: - _LOGGER.debug("Ignored CleanState %s", clean_action) + elif clean_action in (CleanAction.RESUME, CleanAction.STOP): + event_bus.notify(StateEvent(State.IDLE)) return HandlingResult.success() diff --git a/tests/commands/xml/test_clean.py b/tests/commands/xml/test_clean.py index 14daae18c..aa8738f81 100644 --- a/tests/commands/xml/test_clean.py +++ b/tests/commands/xml/test_clean.py @@ -3,16 +3,29 @@ import pytest from deebot_client.command import CommandResult -from deebot_client.commands.xml import GetCleanState -from deebot_client.commands.xml.clean import CleanArea +from deebot_client.commands.xml import Clean, CleanArea, GetCleanState from deebot_client.events import FanSpeedEvent, FanSpeedLevel, StateEvent from deebot_client.message import HandlingState -from deebot_client.models import CleanMode, State +from deebot_client.models import CleanAction, CleanMode, State from tests.commands import assert_command from . import get_request_xml +@pytest.mark.parametrize( + ("command", "command_result"), + [ + (Clean(CleanAction.START, speed=FanSpeedLevel.MAX), HandlingState.SUCCESS), + (Clean(CleanAction.PAUSE), HandlingState.SUCCESS), + ], +) +async def test_Clean(command: Clean, command_result: HandlingState) -> None: + json = get_request_xml("") + await assert_command( + command, json, None, command_result=CommandResult(command_result) + ) + + @pytest.mark.parametrize( ("command", "command_result"), [ @@ -27,37 +40,64 @@ async def test_CleanArea(command: CleanArea, command_result: HandlingState) -> N @pytest.mark.parametrize( - ("speed", "state", "expected_fan_speed_event", "expected_state_event"), + ("speed", "action", "expected_fan_speed_event", "expected_state_event"), [ ( - "standard", - "s", + FanSpeedLevel.NORMAL, + CleanAction.START, FanSpeedEvent(FanSpeedLevel.NORMAL), StateEvent(State.CLEANING), ), ( - "strong", - "s", + FanSpeedLevel.MAX, + CleanAction.START, FanSpeedEvent(FanSpeedLevel.MAX), StateEvent(State.CLEANING), ), ( - "standard", - "p", + FanSpeedLevel.NORMAL, + CleanAction.PAUSE, FanSpeedEvent(FanSpeedLevel.NORMAL), StateEvent(State.PAUSED), ), + ( + None, + CleanAction.RESUME, + None, + StateEvent(State.IDLE), + ), + ( + None, + CleanAction.STOP, + None, + StateEvent(State.IDLE), + ), + ( + FanSpeedLevel.MAX, + None, + FanSpeedEvent(FanSpeedLevel.MAX), + None, + ), + ], + ids=[ + "standard_cleaning", + "strong_cleaning", + "paused", + "resume/idle", + "stop/idle", + "fanspeed_only", ], - ids=["standard_cleaning", "strong_cleaning", "paused"], ) async def test_get_clean_state( - speed: str, - state: str, + speed: FanSpeedLevel | None, + action: CleanAction | None, expected_fan_speed_event: FanSpeedEvent, expected_state_event: StateEvent, ) -> None: + speed_section = f"speed='{speed.xml_value}'" if speed is not None else "" + state_section = f"st='{action.xml_value}'" if action is not None else "" json = get_request_xml( - f"" + f"" ) await assert_command( GetCleanState(), From 2be7cc6963076866b569f2ef731549375ddf90a7 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sat, 26 Apr 2025 12:00:31 +0200 Subject: [PATCH 3/5] Re-order XML Messages and Commands --- deebot_client/commands/xml/__init__.py | 22 ++++++++++++++++++---- deebot_client/messages/xml/__init__.py | 1 + 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/deebot_client/commands/xml/__init__.py b/deebot_client/commands/xml/__init__.py index e1da8eb39..f8c6f86e9 100644 --- a/deebot_client/commands/xml/__init__.py +++ b/deebot_client/commands/xml/__init__.py @@ -41,16 +41,30 @@ # fmt: off # ordered by file asc _COMMANDS: list[type[XmlCommand]] = [ + GetBatteryInfo, + + Charge, + + GetChargeState, + Clean, CleanArea, - GetBatteryInfo, - GetChargerPos, + GetCleanState, + GetCleanLogs, + GetError, - GetCleanState, + + GetFanSpeed, + GetLifeSpan, - GetPos, + PlaySound, + + GetChargerPos, + GetPos, + + GetCleanSum, ] # fmt: on diff --git a/deebot_client/messages/xml/__init__.py b/deebot_client/messages/xml/__init__.py index c0cb694d6..1440574bb 100644 --- a/deebot_client/messages/xml/__init__.py +++ b/deebot_client/messages/xml/__init__.py @@ -17,6 +17,7 @@ # ordered by file asc _MESSAGES: list[type[Message]] = [ BatteryInfo, + Pos ] # fmt: on From 944de264ffe828b889536908d95bcb9088085054 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sun, 27 Apr 2025 16:02:10 +0000 Subject: [PATCH 4/5] review --- deebot_client/commands/xml/clean.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/deebot_client/commands/xml/clean.py b/deebot_client/commands/xml/clean.py index 5a854e5e4..68bdd9365 100644 --- a/deebot_client/commands/xml/clean.py +++ b/deebot_client/commands/xml/clean.py @@ -5,7 +5,6 @@ from typing import TYPE_CHECKING from deebot_client.events import FanSpeedEvent, FanSpeedLevel, StateEvent -from deebot_client.logging_filter import get_logger from deebot_client.message import HandlingResult from deebot_client.models import CleanAction, CleanMode, State @@ -16,8 +15,6 @@ from deebot_client.event_bus import EventBus -_LOGGER = get_logger(__name__) - class Clean(ExecuteCommand): """Generic start/pause/stop cleaning command.""" @@ -28,8 +25,6 @@ class Clean(ExecuteCommand): def __init__( self, action: CleanAction, speed: FanSpeedLevel = FanSpeedLevel.NORMAL ) -> None: - # - super().__init__( { "type": CleanMode.AUTO.xml_value, @@ -52,8 +47,6 @@ def __init__( cleanings: int = 1, speed: FanSpeedLevel = FanSpeedLevel.NORMAL, ) -> None: - # - super().__init__( { "type": mode.xml_value, From de04f1b769aca6fd939b8baa058d0259c9fce8f6 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sun, 27 Apr 2025 16:09:57 +0000 Subject: [PATCH 5/5] Review and refactor tests --- tests/commands/xml/test_clean.py | 73 ++++++++++++++------------------ 1 file changed, 31 insertions(+), 42 deletions(-) diff --git a/tests/commands/xml/test_clean.py b/tests/commands/xml/test_clean.py index aa8738f81..731dad556 100644 --- a/tests/commands/xml/test_clean.py +++ b/tests/commands/xml/test_clean.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import pytest from deebot_client.command import CommandResult @@ -11,72 +13,63 @@ from . import get_request_xml +if TYPE_CHECKING: + from deebot_client.events.base import Event + @pytest.mark.parametrize( - ("command", "command_result"), + "command", [ - (Clean(CleanAction.START, speed=FanSpeedLevel.MAX), HandlingState.SUCCESS), - (Clean(CleanAction.PAUSE), HandlingState.SUCCESS), + Clean(CleanAction.START, speed=FanSpeedLevel.MAX), + Clean(CleanAction.PAUSE), ], ) -async def test_Clean(command: Clean, command_result: HandlingState) -> None: +async def test_Clean(command: Clean) -> None: json = get_request_xml("") await assert_command( - command, json, None, command_result=CommandResult(command_result) + command, json, None, command_result=CommandResult(HandlingState.SUCCESS) ) @pytest.mark.parametrize( - ("command", "command_result"), + "command", [ - (CleanArea(CleanMode.SPOT_AREA, "4", 1), HandlingState.SUCCESS), + CleanArea(CleanMode.SPOT_AREA, "4", 1), ], ) -async def test_CleanArea(command: CleanArea, command_result: HandlingState) -> None: +async def test_CleanArea(command: CleanArea) -> None: json = get_request_xml("") await assert_command( - command, json, None, command_result=CommandResult(command_result) + command, json, None, command_result=CommandResult(HandlingState.SUCCESS) ) @pytest.mark.parametrize( - ("speed", "action", "expected_fan_speed_event", "expected_state_event"), + ("params", "expected_events"), [ ( - FanSpeedLevel.NORMAL, - CleanAction.START, - FanSpeedEvent(FanSpeedLevel.NORMAL), - StateEvent(State.CLEANING), + "speed='standard' st='s'", + [FanSpeedEvent(FanSpeedLevel.NORMAL), StateEvent(State.CLEANING)], ), ( - FanSpeedLevel.MAX, - CleanAction.START, - FanSpeedEvent(FanSpeedLevel.MAX), - StateEvent(State.CLEANING), + "speed='strong' st='s'", + [FanSpeedEvent(FanSpeedLevel.MAX), StateEvent(State.CLEANING)], ), ( - FanSpeedLevel.NORMAL, - CleanAction.PAUSE, - FanSpeedEvent(FanSpeedLevel.NORMAL), - StateEvent(State.PAUSED), + "speed='standard' st='p'", + [FanSpeedEvent(FanSpeedLevel.NORMAL), StateEvent(State.PAUSED)], ), ( - None, - CleanAction.RESUME, - None, - StateEvent(State.IDLE), + "st='r'", + [StateEvent(State.IDLE)], ), ( - None, - CleanAction.STOP, - None, - StateEvent(State.IDLE), + "st='h'", + [StateEvent(State.IDLE)], ), ( - FanSpeedLevel.MAX, - None, - FanSpeedEvent(FanSpeedLevel.MAX), - None, + "speed='strong'", + [FanSpeedEvent(FanSpeedLevel.MAX)], ), ], ids=[ @@ -89,20 +82,16 @@ async def test_CleanArea(command: CleanArea, command_result: HandlingState) -> N ], ) async def test_get_clean_state( - speed: FanSpeedLevel | None, - action: CleanAction | None, - expected_fan_speed_event: FanSpeedEvent, - expected_state_event: StateEvent, + params: str, + expected_events: list[Event], ) -> None: - speed_section = f"speed='{speed.xml_value}'" if speed is not None else "" - state_section = f"st='{action.xml_value}'" if action is not None else "" json = get_request_xml( - f"" + f"" ) await assert_command( GetCleanState(), json, - [x for x in [expected_fan_speed_event, expected_state_event] if x is not None], + expected_events, )