From dd996daf306ae1b1ed758b130e7900a17eb9ccdf Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sat, 19 Apr 2025 13:09:50 +0200 Subject: [PATCH 01/28] Add legacy GetNetInfo JSON command --- deebot_client/commands/json/common.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/deebot_client/commands/json/common.py b/deebot_client/commands/json/common.py index 623a608b5..ed69eb5f6 100644 --- a/deebot_client/commands/json/common.py +++ b/deebot_client/commands/json/common.py @@ -23,6 +23,7 @@ HandlingState, MessageBody, MessageBodyDataDict, + MessageDict, ) from deebot_client.util import verify_required_class_variables_exists @@ -62,6 +63,12 @@ class JsonCommandWithMessageHandling( """Command, which handle response by itself.""" +class JsonCommandWithRawMessageHandling( + JsonCommand, CommandWithMessageHandling, MessageDict, ABC +): + """Command, which handle raw response by itself.""" + + class ExecuteCommand(JsonCommandWithMessageHandling, ABC): """Command, which is executing something (ex. Charge).""" From 192b023bd8b89620e93a07ff9c916c5c89cb2112 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Tue, 22 Apr 2025 15:25:41 +0200 Subject: [PATCH 02/28] Initial Ozmo 900 support --- deebot_client/command.py | 11 ++- deebot_client/commands/__init__.py | 6 +- deebot_client/commands/xml/__init__.py | 15 +++- deebot_client/commands/xml/clean.py | 89 ++++++++++++++++++++++ deebot_client/commands/xml/common.py | 45 ++++++++++- deebot_client/commands/xml/fan_speed.py | 42 +++++++---- deebot_client/hardware/deebot/2pv572.py | 99 +++++++++++++++++++++++++ tests/commands/xml/test_clean.py | 81 ++++++++++++++++++++ tests/commands/xml/test_fan_speed.py | 29 ++++++-- 9 files changed, 389 insertions(+), 28 deletions(-) create mode 100644 deebot_client/commands/xml/clean.py create mode 100644 deebot_client/hardware/deebot/2pv572.py create mode 100644 tests/commands/xml/test_clean.py diff --git a/deebot_client/command.py b/deebot_client/command.py index dc0e0cf38..74069979a 100644 --- a/deebot_client/command.py +++ b/deebot_client/command.py @@ -324,8 +324,15 @@ def _pop_or_raise(name: str, type_: type, data: dict[str, Any]) -> Any: try: return type_(value) except ValueError as err: - msg = f'Could not convert "{value}" of {name} into {type_}' - raise DeebotError(msg) from err + if hasattr(type_, "from_xml"): + try: + return type_.from_xml(value) + except ValueError as err2: + msg = f'Could not convert "{value}" of {name} into {type_}' + raise DeebotError(msg) from err2 + else: + msg = f'Could not convert "{value}" of {name} into {type_}' + raise DeebotError(msg) from err class GetCommand(CommandWithMessageHandling, ABC): diff --git a/deebot_client/commands/__init__.py b/deebot_client/commands/__init__.py index 5fb527c47..fc4574353 100644 --- a/deebot_client/commands/__init__.py +++ b/deebot_client/commands/__init__.py @@ -11,6 +11,9 @@ COMMANDS as JSON_COMMANDS, COMMANDS_WITH_MQTT_P2P_HANDLING as JSON_COMMANDS_WITH_MQTT_P2P_HANDLING, ) +from .xml import ( + COMMANDS_WITH_MQTT_P2P_HANDLING as XML_COMMANDS_WITH_MQTT_P2P_HANDLING, +) if TYPE_CHECKING: from deebot_client.command import Command, CommandMqttP2P @@ -18,7 +21,8 @@ COMMANDS: dict[DataType, dict[str, type[Command]]] = {DataType.JSON: JSON_COMMANDS} COMMANDS_WITH_MQTT_P2P_HANDLING: dict[DataType, dict[str, type[CommandMqttP2P]]] = { - DataType.JSON: JSON_COMMANDS_WITH_MQTT_P2P_HANDLING + DataType.JSON: JSON_COMMANDS_WITH_MQTT_P2P_HANDLING, + DataType.XML: XML_COMMANDS_WITH_MQTT_P2P_HANDLING, } diff --git a/deebot_client/commands/xml/__init__.py b/deebot_client/commands/xml/__init__.py index 3ac6e073d..31c01c9c7 100644 --- a/deebot_client/commands/xml/__init__.py +++ b/deebot_client/commands/xml/__init__.py @@ -9,8 +9,9 @@ from .battery import GetBatteryInfo from .charge import Charge from .charge_state import GetChargeState +from .clean import Clean, CleanArea, GetCleanState from .error import GetError -from .fan_speed import GetFanSpeed +from .fan_speed import GetCleanSpeed, SetCleanSpeed from .life_span import GetLifeSpan from .play_sound import PlaySound from .pos import GetPos @@ -21,22 +22,32 @@ __all__ = [ "Charge", + "Clean", + "CleanArea", "GetBatteryInfo", "GetChargeState", + "GetCleanSpeed", + "GetCleanState", "GetCleanSum", "GetError", - "GetFanSpeed", "GetLifeSpan", "GetPos", "PlaySound", + "SetCleanSpeed", ] # fmt: off # ordered by file asc _COMMANDS: list[type[XmlCommand]] = [ + Clean, + CleanArea, GetBatteryInfo, GetError, + GetBatteryInfo, + GetCleanSpeed, + GetCleanState, GetLifeSpan, + SetCleanSpeed, PlaySound, ] # fmt: on diff --git a/deebot_client/commands/xml/clean.py b/deebot_client/commands/xml/clean.py new file mode 100644 index 000000000..d39a1a6eb --- /dev/null +++ b/deebot_client/commands/xml/clean.py @@ -0,0 +1,89 @@ +"""Clean commands.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from deebot_client.events import FanSpeedEvent, FanSpeedLevel, StateEvent +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 + + +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)) + return HandlingResult.success() diff --git a/deebot_client/commands/xml/common.py b/deebot_client/commands/xml/common.py index 8044c09b7..d1079b01c 100644 --- a/deebot_client/commands/xml/common.py +++ b/deebot_client/commands/xml/common.py @@ -3,12 +3,18 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast from xml.etree.ElementTree import Element, SubElement from defusedxml import ElementTree # type: ignore[import-untyped] -from deebot_client.command import Command, CommandWithMessageHandling, SetCommand +from deebot_client.command import ( + Command, + CommandMqttP2P, + CommandWithMessageHandling, + GetCommand, + SetCommand, +) from deebot_client.const import DataType from deebot_client.logging_filter import get_logger from deebot_client.message import HandlingResult, HandlingState, MessageStr @@ -81,8 +87,41 @@ def _handle_xml(cls, _: EventBus, xml: Element) -> HandlingResult: return HandlingResult(HandlingState.FAILED) -class XmlSetCommand(ExecuteCommand, SetCommand, ABC): +class XmlCommandMqttP2P(XmlCommand, CommandMqttP2P, ABC): + """Json base command for mqtt p2p channel.""" + + @classmethod + def create_from_mqtt(cls, payload: str | bytes | bytearray) -> CommandMqttP2P: + """Create a command from the mqtt data.""" + xml = ElementTree.fromstring(payload) + return cls._create_from_mqtt(xml.attrib) + + def handle_mqtt_p2p( + self, event_bus: EventBus, response_payload: str | bytes | bytearray + ) -> None: + """Handle response received over the mqtt channel "p2p".""" + self._handle_mqtt_p2p(event_bus, str(response_payload)) + + @abstractmethod + def _handle_mqtt_p2p( + self, event_bus: EventBus, response: dict[str, Any] | str + ) -> None: + """Handle response received over the mqtt channel "p2p".""" + + +class XmlSetCommand(ExecuteCommand, SetCommand, XmlCommandMqttP2P, ABC): """Xml base set command. Command needs to be linked to the "get" command, for handling (updating) the sensors. """ + + +class XmlGetCommand(XmlCommandWithMessageHandling, GetCommand, ABC): + """Xml get command.""" + + @classmethod + @abstractmethod + def handle_set_args( + cls, event_bus: EventBus, args: dict[str, Any] + ) -> HandlingResult: + """Handle arguments of set command.""" diff --git a/deebot_client/commands/xml/fan_speed.py b/deebot_client/commands/xml/fan_speed.py index 2a834554c..e567d232c 100644 --- a/deebot_client/commands/xml/fan_speed.py +++ b/deebot_client/commands/xml/fan_speed.py @@ -2,12 +2,14 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from types import MappingProxyType +from typing import TYPE_CHECKING, Any +from deebot_client.command import InitParam from deebot_client.events import FanSpeedEvent, FanSpeedLevel from deebot_client.message import HandlingResult -from .common import XmlCommandWithMessageHandling +from .common import XmlGetCommand, XmlSetCommand if TYPE_CHECKING: from xml.etree.ElementTree import Element @@ -15,11 +17,22 @@ from deebot_client.event_bus import EventBus -class GetFanSpeed(XmlCommandWithMessageHandling): - """GetFanSpeed command.""" +class GetCleanSpeed(XmlGetCommand): + """GetCleanSpeed command.""" NAME = "GetCleanSpeed" + @classmethod + def handle_set_args( + cls, event_bus: EventBus, args: dict[str, Any] + ) -> HandlingResult: + """Handle message->body->data and notify the correct event subscribers. + + :return: A message response + """ + event_bus.notify(FanSpeedEvent(FanSpeedLevel.from_xml(str(args["speed"])))) + return HandlingResult.success() + @classmethod def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult: """Handle xml message and notify the correct event subscribers. @@ -29,16 +42,17 @@ def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult: if xml.attrib.get("ret") != "ok" or not (speed := xml.attrib.get("speed")): return HandlingResult.analyse() - event: FanSpeedEvent | None = None + event_bus.notify(FanSpeedEvent(FanSpeedLevel.from_xml(speed))) + return HandlingResult.success() + - match speed.lower(): - case "standard": - event = FanSpeedEvent(FanSpeedLevel.NORMAL) - case "strong": - event = FanSpeedEvent(FanSpeedLevel.MAX) +class SetCleanSpeed(XmlSetCommand): + """SetCleanSpeed command.""" - if event: - event_bus.notify(event) - return HandlingResult.success() + NAME = "SetCleanSpeed" + get_command = GetCleanSpeed + _mqtt_params = MappingProxyType({"speed": InitParam(FanSpeedLevel)}) - return HandlingResult.analyse() + def __init__(self, speed: FanSpeedLevel | str) -> None: + speed_level = speed.xml_value if isinstance(speed, FanSpeedLevel) else speed + super().__init__({"speed": speed_level}) diff --git a/deebot_client/hardware/deebot/2pv572.py b/deebot_client/hardware/deebot/2pv572.py new file mode 100644 index 000000000..f3a90549d --- /dev/null +++ b/deebot_client/hardware/deebot/2pv572.py @@ -0,0 +1,99 @@ +"""ls1ok3 Capabilities.""" + +from __future__ import annotations + +from deebot_client.capabilities import ( + Capabilities, + CapabilityClean, + CapabilityCleanAction, + CapabilityCustomCommand, + CapabilityEvent, + CapabilityExecute, + CapabilityLifeSpan, + CapabilitySet, + CapabilitySettings, + CapabilitySetTypes, + CapabilityStats, + DeviceType, +) +from deebot_client.commands.json import SetVolume +from deebot_client.commands.json.custom import CustomCommand +from deebot_client.commands.xml import ( + Clean, + CleanArea, + GetBatteryInfo, + GetCleanSpeed, + GetCleanState, + SetCleanSpeed, +) +from deebot_client.commands.xml.charge_state import GetChargeState +from deebot_client.commands.xml.common import XmlCommand +from deebot_client.commands.xml.error import GetError +from deebot_client.commands.xml.stats import GetCleanSum +from deebot_client.const import DataType +from deebot_client.events import ( + AvailabilityEvent, + BatteryEvent, + CustomCommandEvent, + ErrorEvent, + FanSpeedEvent, + FanSpeedLevel, + LifeSpanEvent, + NetworkInfoEvent, + ReportStatsEvent, + StateEvent, + StatsEvent, + TotalStatsEvent, + VolumeEvent, +) +from deebot_client.models import StaticDeviceInfo +from deebot_client.util import short_name + +from . import DEVICES + +DEVICES[short_name(__name__)] = StaticDeviceInfo( + DataType.XML, + Capabilities( + availability=CapabilityEvent(AvailabilityEvent, []), + battery=CapabilityEvent(BatteryEvent, [GetBatteryInfo()]), + charge=CapabilityExecute(XmlCommand), + clean=CapabilityClean( + action=CapabilityCleanAction(command=Clean, area=CleanArea), + ), + custom=CapabilityCustomCommand( + event=CustomCommandEvent, get=[], set=CustomCommand + ), + device_type=DeviceType.VACUUM, + error=CapabilityEvent(ErrorEvent, [GetError()]), + fan_speed=CapabilitySetTypes( + event=FanSpeedEvent, + get=[GetCleanSpeed()], + set=SetCleanSpeed, + types=( + FanSpeedLevel.NORMAL, + FanSpeedLevel.MAX, + ), + ), + life_span=CapabilityLifeSpan( + types=(), + event=LifeSpanEvent, + get=[], + reset=CustomCommand, + ), + network=CapabilityEvent(NetworkInfoEvent, []), + play_sound=CapabilityExecute(XmlCommand), + settings=CapabilitySettings( + volume=CapabilitySet( + event=VolumeEvent, + get=[], + set=SetVolume, + ), + ), + state=CapabilityEvent(StateEvent, [GetChargeState(), GetCleanState()]), + stats=CapabilityStats( + clean=CapabilityEvent(StatsEvent, [GetCleanSum()]), + report=CapabilityEvent(ReportStatsEvent, []), + total=CapabilityEvent(TotalStatsEvent, [GetCleanSum()]), + ), + ), +) diff --git a/tests/commands/xml/test_clean.py b/tests/commands/xml/test_clean.py new file mode 100644 index 000000000..4e36527c0 --- /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), + None, + ), + ], + 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), + ) diff --git a/tests/commands/xml/test_fan_speed.py b/tests/commands/xml/test_fan_speed.py index bf728ecfc..288b3c1e2 100644 --- a/tests/commands/xml/test_fan_speed.py +++ b/tests/commands/xml/test_fan_speed.py @@ -4,8 +4,8 @@ import pytest -from deebot_client.command import CommandResult -from deebot_client.commands.xml import GetFanSpeed +from deebot_client.command import CommandResult, CommandWithMessageHandling +from deebot_client.commands.xml import GetCleanSpeed, SetCleanSpeed from deebot_client.events import FanSpeedEvent, FanSpeedLevel from deebot_client.message import HandlingState from tests.commands import assert_command @@ -26,19 +26,36 @@ ) async def test_get_fan_speed(speed: str, expected_event: Event) -> None: json = get_request_xml(f"") - await assert_command(GetFanSpeed(), json, expected_event) + await assert_command(GetCleanSpeed(), json, expected_event) @pytest.mark.parametrize( "xml", - ["", ""], - ids=["error", "no_state"], + [""], + ids=["error"], ) async def test_get_fan_speed_error(xml: str) -> None: json = get_request_xml(xml) await assert_command( - GetFanSpeed(), + GetCleanSpeed(), json, None, command_result=CommandResult(HandlingState.ANALYSE_LOGGED), ) + + +@pytest.mark.parametrize( + ("command", "xml", "result"), + [ + ( + SetCleanSpeed(FanSpeedLevel.MAX), + "", + HandlingState.SUCCESS, + ), + ], +) +async def test_set_fan_speed( + command: CommandWithMessageHandling, xml: str, result: HandlingState +) -> None: + json = get_request_xml(xml) + await assert_command(command, json, None, command_result=CommandResult(result)) From fe04e137778b229c28e412e9279ed847fee8b9d4 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Tue, 22 Apr 2025 15:30:24 +0200 Subject: [PATCH 03/28] Implement XML message handling --- deebot_client/commands/xml/__init__.py | 5 ++ deebot_client/commands/xml/water_info.py | 82 ++++++++++++++++++++++++ deebot_client/device.py | 27 ++++++-- deebot_client/messages/xml/__init__.py | 25 ++++++-- deebot_client/messages/xml/charge.py | 47 ++++++++++++++ deebot_client/messages/xml/clean.py | 58 +++++++++++++++++ deebot_client/messages/xml/map.py | 32 +++++++++ deebot_client/messages/xml/pos.py | 34 ++++++++++ deebot_client/messages/xml/water_info.py | 34 ++++++++++ 9 files changed, 334 insertions(+), 10 deletions(-) create mode 100644 deebot_client/commands/xml/water_info.py create mode 100644 deebot_client/messages/xml/charge.py create mode 100644 deebot_client/messages/xml/clean.py create mode 100644 deebot_client/messages/xml/map.py create mode 100644 deebot_client/messages/xml/pos.py create mode 100644 deebot_client/messages/xml/water_info.py diff --git a/deebot_client/commands/xml/__init__.py b/deebot_client/commands/xml/__init__.py index 31c01c9c7..4dfa197ab 100644 --- a/deebot_client/commands/xml/__init__.py +++ b/deebot_client/commands/xml/__init__.py @@ -16,6 +16,7 @@ from .play_sound import PlaySound from .pos import GetPos from .stats import GetCleanSum +from .water_info import GetWaterBoxInfo, GetWaterPermeability if TYPE_CHECKING: from .common import XmlCommand @@ -32,6 +33,8 @@ "GetError", "GetLifeSpan", "GetPos", + "GetWaterBoxInfo", + "GetWaterPermeability", "PlaySound", "SetCleanSpeed", ] @@ -47,6 +50,8 @@ GetCleanSpeed, GetCleanState, GetLifeSpan, + GetWaterBoxInfo, + GetWaterPermeability, SetCleanSpeed, PlaySound, ] diff --git a/deebot_client/commands/xml/water_info.py b/deebot_client/commands/xml/water_info.py new file mode 100644 index 000000000..6625c25a9 --- /dev/null +++ b/deebot_client/commands/xml/water_info.py @@ -0,0 +1,82 @@ +"""WaterBox command module.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from deebot_client.events import ( + WaterAmount, + WaterInfoEvent, +) +from deebot_client.message import HandlingResult + +from .common import XmlGetCommand + +if TYPE_CHECKING: + from xml.etree.ElementTree import Element + + from deebot_client.event_bus import EventBus + + +class GetWaterPermeability(XmlGetCommand): + """GetWaterBoxInfo command.""" + + NAME = "GetWaterPermeability" + + @classmethod + def handle_set_args( + cls, event_bus: EventBus, args: dict[str, Any] + ) -> HandlingResult: + """Handle message->body->data and notify the correct event subscribers. + + :return: A message response + """ + event_bus.notify(WaterInfoEvent(amount=WaterAmount(int(args["v"])))) + return HandlingResult.success() + + @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 not (value := xml.attrib.get("v")): + return HandlingResult.analyse() + + event_bus.notify(WaterInfoEvent(amount=WaterAmount(int(value)))) + return HandlingResult.success() + + +class GetWaterBoxInfo(XmlGetCommand): + """GetWaterBoxInfo command.""" + + NAME = "GetWaterBoxInfo" + + @classmethod + def handle_set_args( + cls, event_bus: EventBus, args: dict[str, Any] + ) -> HandlingResult: + """Handle message->body->data and notify the correct event subscribers. + + :return: A message response + """ + event_bus.notify( + WaterInfoEvent( + amount=WaterAmount.HIGH, mop_attached=(str(args["on"]) != "0") + ) + ) + return HandlingResult.success() + + @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 not (on := xml.attrib.get("on")): + return HandlingResult.analyse() + + event_bus.notify( + WaterInfoEvent(amount=WaterAmount.HIGH, mop_attached=on != "0") + ) + return HandlingResult.success() diff --git a/deebot_client/device.py b/deebot_client/device.py index 0b2838e7c..88aa36ccb 100644 --- a/deebot_client/device.py +++ b/deebot_client/device.py @@ -14,6 +14,7 @@ from deebot_client.util import cancel from .command import Command +from .const import DataType from .event_bus import EventBus from .events import ( AvailabilityEvent, @@ -38,7 +39,6 @@ _LOGGER = get_logger(__name__) _AVAILABLE_CHECK_INTERVAL = 60 - DeviceCommandExecute = Callable[[Command], Coroutine[Any, Any, dict[str, Any]]] @@ -200,15 +200,30 @@ def _handle_message( try: _LOGGER.debug("Try to handle message %s: %s", message_name, message_data) - if message := get_message(message_name, self._device_info.static.data_type): + message_data_type = self._device_info.static.data_type + if message := get_message(message_name, message_data_type): if isinstance(message_data, dict): data = message_data - else: + elif message_data_type == DataType.JSON: data = json.loads(message_data) + elif isinstance(message_data, bytes): + data = message_data.decode() + elif isinstance(message_data, bytearray): + data = bytes(message_data).decode() + elif isinstance(message_data, str): + data = message_data + else: + msg = "Unsupported message data type {message_name}: {message_type}" + raise TypeError( + msg.format( + message_name=message_name, message_type=type(message_data) + ) + ) - fw_version = data.get("header", {}).get("fwVer", None) - if fw_version: - self.fw_version = fw_version + if isinstance(data, dict): + fw_version = data.get("header", {}).get("fwVer", None) + if fw_version: + self.fw_version = fw_version message.handle(self.events, data) except Exception: # pylint: disable=broad-except diff --git a/deebot_client/messages/xml/__init__.py b/deebot_client/messages/xml/__init__.py index 21d5370a9..024ce5605 100644 --- a/deebot_client/messages/xml/__init__.py +++ b/deebot_client/messages/xml/__init__.py @@ -5,17 +5,34 @@ from typing import TYPE_CHECKING from deebot_client.messages.xml.battery import BatteryInfo +from deebot_client.messages.xml.charge import ChargeState +from deebot_client.messages.xml.clean import CleanReport, CleanSt +from deebot_client.messages.xml.map import MapP +from deebot_client.messages.xml.pos import Pos +from deebot_client.messages.xml.water_info import WaterBoxInfo if TYPE_CHECKING: - from collections.abc import Sequence - from deebot_client.message import Message -__all__: Sequence[str] = ["BatteryInfo"] +__all__ = [ + "BatteryInfo", + "ChargeState", + "CleanReport", + "CleanSt", + "MapP", + "Pos", + "WaterBoxInfo", +] # fmt: off # ordered by file asc _MESSAGES: list[type[Message]] = [ - BatteryInfo + BatteryInfo, + ChargeState, + CleanReport, + WaterBoxInfo, + Pos, + MapP, + CleanSt ] # fmt: on diff --git a/deebot_client/messages/xml/charge.py b/deebot_client/messages/xml/charge.py new file mode 100644 index 000000000..ad455c595 --- /dev/null +++ b/deebot_client/messages/xml/charge.py @@ -0,0 +1,47 @@ +"""Charge messages.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from deebot_client.events import StateEvent +from deebot_client.message import HandlingResult +from deebot_client.messages.xml.common import XmlMessage +from deebot_client.models import State + +if TYPE_CHECKING: + from xml.etree.ElementTree import Element + + from deebot_client.event_bus import EventBus + + +class ChargeState(XmlMessage): + """ChargeState message.""" + + NAME = "ChargeState" + + @classmethod + def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult: + """Handle xml message and notify the correct event subscribers. + + :return: A message response + """ + status: State | None = None + + if (charge := xml.find("charge")) is not None: + type_ = charge.attrib["type"].lower() + match type_: + case "slotcharging" | "slot_charging" | "wirecharging": + status = State.DOCKED + case "idle": + status = State.IDLE + case "going": + status = State.RETURNING + case _: + status = State.ERROR + + if status: + event_bus.notify(StateEvent(status)) + return HandlingResult.success() + + return HandlingResult.analyse() diff --git a/deebot_client/messages/xml/clean.py b/deebot_client/messages/xml/clean.py new file mode 100644 index 000000000..04fd3d079 --- /dev/null +++ b/deebot_client/messages/xml/clean.py @@ -0,0 +1,58 @@ +"""Clean messages.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from deebot_client.events import FanSpeedEvent, FanSpeedLevel, StateEvent +from deebot_client.message import HandlingResult +from deebot_client.messages.xml.common import XmlMessage +from deebot_client.models import CleanAction, State + +if TYPE_CHECKING: + from xml.etree.ElementTree import Element + + from deebot_client.event_bus import EventBus + + +class CleanSt(XmlMessage): + """CleanSt message.""" + + NAME = "CleanSt" + + @classmethod + def _handle_xml(cls, _event_bus: EventBus, _xml: Element) -> HandlingResult: + """Handle xml message and notify the correct event subscribers. + + b"" + + :return: A message response + """ + return HandlingResult.analyse() + + +class CleanReport(XmlMessage): + """CleanReport message.""" + + NAME = "CleanReport" + + @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 (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)) + return HandlingResult.success() diff --git a/deebot_client/messages/xml/map.py b/deebot_client/messages/xml/map.py new file mode 100644 index 000000000..5b6b954c1 --- /dev/null +++ b/deebot_client/messages/xml/map.py @@ -0,0 +1,32 @@ +"""Map messages.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from deebot_client.message import HandlingResult +from deebot_client.messages.xml.common import XmlMessage + +if TYPE_CHECKING: + from xml.etree.ElementTree import Element + + from deebot_client.event_bus import EventBus + + +class MapP(XmlMessage): + """MapP message. + + Probably a map piece + """ + + NAME = "MapP" + + @classmethod + def _handle_xml(cls, _event_bus: EventBus, _xml: Element) -> HandlingResult: + """Handle xml message and notify the correct event subscribers. + + b"" + + :return: A message response + """ + return HandlingResult.analyse() diff --git a/deebot_client/messages/xml/pos.py b/deebot_client/messages/xml/pos.py new file mode 100644 index 000000000..703495337 --- /dev/null +++ b/deebot_client/messages/xml/pos.py @@ -0,0 +1,34 @@ +"""Pos messages.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from deebot_client.events import Position, PositionsEvent +from deebot_client.message import HandlingResult +from deebot_client.messages.xml.common import XmlMessage +from deebot_client.rs.map import PositionType + +if TYPE_CHECKING: + from xml.etree.ElementTree import Element + + from deebot_client.event_bus import EventBus + + +class Pos(XmlMessage): + """Pos message.""" + + NAME = "Pos" + + @classmethod + def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult: + if p := xml.attrib.get("p"): + p_x, p_y = p.split(",", 2) + p_a = xml.attrib.get("a", 0) + position = Position( + type=PositionType.DEEBOT, x=int(p_x), y=int(p_y), a=int(p_a) + ) + event_bus.notify(PositionsEvent(positions=[position])) + return HandlingResult.success() + + return HandlingResult.analyse() diff --git a/deebot_client/messages/xml/water_info.py b/deebot_client/messages/xml/water_info.py new file mode 100644 index 000000000..97ec912b6 --- /dev/null +++ b/deebot_client/messages/xml/water_info.py @@ -0,0 +1,34 @@ +"""Water messages.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from deebot_client.events import WaterAmount, WaterInfoEvent +from deebot_client.message import HandlingResult +from deebot_client.messages.xml.common import XmlMessage + +if TYPE_CHECKING: + from xml.etree.ElementTree import Element + + from deebot_client.event_bus import EventBus + + +class WaterBoxInfo(XmlMessage): + """WaterBoxInfo message.""" + + NAME = "WaterBoxInfo" + + @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 (on := xml.attrib.get("on")) is None: + return HandlingResult.analyse() + + event_bus.notify( + WaterInfoEvent(amount=WaterAmount.HIGH, mop_attached=on != "0") + ) + return HandlingResult.success() From ae3309a5e1f19a3670b9de611b678fda3c9d4da7 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Tue, 22 Apr 2025 15:30:51 +0200 Subject: [PATCH 04/28] Ignore ruff error --- deebot_client/messages/xml/common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deebot_client/messages/xml/common.py b/deebot_client/messages/xml/common.py index 05e7399bc..ee46a1aac 100644 --- a/deebot_client/messages/xml/common.py +++ b/deebot_client/messages/xml/common.py @@ -25,7 +25,7 @@ def _handle_str(cls, event_bus: EventBus, message: str) -> HandlingResult: :return: A message response """ - xml = ElementTree.fromstring(message) + xml = ET.fromstring(message) return cls._handle_xml(event_bus, xml) @classmethod From 9c5e88de7531f7555e7367e79b182774892013aa Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sun, 6 Apr 2025 17:00:16 +0200 Subject: [PATCH 05/28] Do not go idle while cleaning --- deebot_client/commands/xml/charge_state.py | 2 +- deebot_client/messages/xml/charge.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/deebot_client/commands/xml/charge_state.py b/deebot_client/commands/xml/charge_state.py index 78b7110a2..6fff4d568 100644 --- a/deebot_client/commands/xml/charge_state.py +++ b/deebot_client/commands/xml/charge_state.py @@ -38,7 +38,7 @@ def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult: case "slotcharging" | "slot_charging" | "wirecharging": status = State.DOCKED case "idle": - status = State.IDLE + pass case "going": status = State.RETURNING case _: diff --git a/deebot_client/messages/xml/charge.py b/deebot_client/messages/xml/charge.py index ad455c595..e6fff7ea6 100644 --- a/deebot_client/messages/xml/charge.py +++ b/deebot_client/messages/xml/charge.py @@ -34,7 +34,7 @@ def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult: case "slotcharging" | "slot_charging" | "wirecharging": status = State.DOCKED case "idle": - status = State.IDLE + pass case "going": status = State.RETURNING case _: From 28544b8e9601821954abc7a1da16da4d84c01dca Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sun, 6 Apr 2025 17:00:56 +0200 Subject: [PATCH 06/28] Use correct Charge and PlaySound commands --- deebot_client/hardware/deebot/2pv572.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/deebot_client/hardware/deebot/2pv572.py b/deebot_client/hardware/deebot/2pv572.py index f3a90549d..dddba2b82 100644 --- a/deebot_client/hardware/deebot/2pv572.py +++ b/deebot_client/hardware/deebot/2pv572.py @@ -19,15 +19,16 @@ from deebot_client.commands.json import SetVolume from deebot_client.commands.json.custom import CustomCommand from deebot_client.commands.xml import ( + Charge, Clean, CleanArea, GetBatteryInfo, GetCleanSpeed, GetCleanState, + PlaySound, SetCleanSpeed, ) from deebot_client.commands.xml.charge_state import GetChargeState -from deebot_client.commands.xml.common import XmlCommand from deebot_client.commands.xml.error import GetError from deebot_client.commands.xml.stats import GetCleanSum from deebot_client.const import DataType @@ -56,7 +57,7 @@ Capabilities( availability=CapabilityEvent(AvailabilityEvent, []), battery=CapabilityEvent(BatteryEvent, [GetBatteryInfo()]), - charge=CapabilityExecute(XmlCommand), + charge=CapabilityExecute(Charge), clean=CapabilityClean( action=CapabilityCleanAction(command=Clean, area=CleanArea), ), @@ -81,7 +82,7 @@ reset=CustomCommand, ), network=CapabilityEvent(NetworkInfoEvent, []), - play_sound=CapabilityExecute(XmlCommand), + play_sound=CapabilityExecute(PlaySound), settings=CapabilitySettings( volume=CapabilitySet( event=VolumeEvent, From db9af4f32c7dd3f2d13e5a55060a54aef890bdd2 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sun, 6 Apr 2025 17:03:18 +0200 Subject: [PATCH 07/28] Always notify idle and paused states --- deebot_client/commands/xml/clean.py | 18 ++++++++---------- deebot_client/messages/xml/clean.py | 8 +++----- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/deebot_client/commands/xml/clean.py b/deebot_client/commands/xml/clean.py index d39a1a6eb..b1d7d76ff 100644 --- a/deebot_client/commands/xml/clean.py +++ b/deebot_client/commands/xml/clean.py @@ -6,7 +6,7 @@ from deebot_client.events import FanSpeedEvent, FanSpeedLevel, StateEvent from deebot_client.message import HandlingResult -from deebot_client.models import CleanAction, CleanMode, State +from deebot_client.models import CleanAction, CleanMode from .common import ExecuteCommand, XmlCommandWithMessageHandling @@ -23,7 +23,7 @@ class Clean(ExecuteCommand): HAS_SUB_ELEMENT = True def __init__( - self, action: CleanAction, speed: FanSpeedLevel = FanSpeedLevel.NORMAL + self, action: CleanAction, speed: FanSpeedLevel = FanSpeedLevel.NORMAL ) -> None: # @@ -43,11 +43,11 @@ class CleanArea(ExecuteCommand): HAS_SUB_ELEMENT = True def __init__( - self, - mode: CleanMode, - area: str, - cleanings: int = 1, - speed: FanSpeedLevel = FanSpeedLevel.NORMAL, + self, + mode: CleanMode, + area: str, + cleanings: int = 1, + speed: FanSpeedLevel = FanSpeedLevel.NORMAL, ) -> None: # @@ -83,7 +83,5 @@ def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult: 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)) + event_bus.notify(StateEvent(CleanAction.from_xml(clean_attrib))) return HandlingResult.success() diff --git a/deebot_client/messages/xml/clean.py b/deebot_client/messages/xml/clean.py index 04fd3d079..a10aeb2bf 100644 --- a/deebot_client/messages/xml/clean.py +++ b/deebot_client/messages/xml/clean.py @@ -4,10 +4,10 @@ from typing import TYPE_CHECKING -from deebot_client.events import FanSpeedEvent, FanSpeedLevel, StateEvent +from deebot_client.events import FanSpeedEvent, FanSpeedLevel from deebot_client.message import HandlingResult from deebot_client.messages.xml.common import XmlMessage -from deebot_client.models import CleanAction, State +from deebot_client.models import CleanAction if TYPE_CHECKING: from xml.etree.ElementTree import Element @@ -52,7 +52,5 @@ def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult: 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)) + event_bus.notify(CleanAction.from_xml(clean_attrib)) return HandlingResult.success() From 30642f9bb5a789220c84a5ef267bb4b4307affc1 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sun, 6 Apr 2025 17:31:24 +0200 Subject: [PATCH 08/28] Add clean logs capability --- deebot_client/commands/xml/__init__.py | 3 ++ deebot_client/commands/xml/clean_logs.py | 61 ++++++++++++++++++++++++ deebot_client/hardware/deebot/2pv572.py | 3 ++ 3 files changed, 67 insertions(+) create mode 100644 deebot_client/commands/xml/clean_logs.py diff --git a/deebot_client/commands/xml/__init__.py b/deebot_client/commands/xml/__init__.py index 4dfa197ab..fc429f598 100644 --- a/deebot_client/commands/xml/__init__.py +++ b/deebot_client/commands/xml/__init__.py @@ -10,6 +10,7 @@ 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 GetCleanSpeed, SetCleanSpeed from .life_span import GetLifeSpan @@ -27,6 +28,7 @@ "CleanArea", "GetBatteryInfo", "GetChargeState", + "GetCleanLogs", "GetCleanSpeed", "GetCleanState", "GetCleanSum", @@ -47,6 +49,7 @@ GetBatteryInfo, GetError, GetBatteryInfo, + GetCleanLogs, GetCleanSpeed, GetCleanState, GetLifeSpan, diff --git a/deebot_client/commands/xml/clean_logs.py b/deebot_client/commands/xml/clean_logs.py new file mode 100644 index 000000000..0b0434b8f --- /dev/null +++ b/deebot_client/commands/xml/clean_logs.py @@ -0,0 +1,61 @@ +"""Clean Logs commands.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from deebot_client.command import CommandResult +from deebot_client.events import ( + CleanJobStatus, + CleanLogEntry, + CleanLogEvent, +) +from deebot_client.logging_filter import get_logger +from deebot_client.message import HandlingResult + +from .common import XmlCommandWithMessageHandling + +if TYPE_CHECKING: + from xml.etree.ElementTree import Element + + from deebot_client.event_bus import EventBus + +_LOGGER = get_logger(__name__) + + +class GetCleanLogs(XmlCommandWithMessageHandling): + """GetCleanLogs command.""" + + NAME = "GetCleanLogs" + + def __init__(self, count: int = 0) -> None: + super().__init__({"count": str(count)}) + + @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 (resp_logs := xml.findall("CleanSt")) is None: + return HandlingResult.analyse() + + if len(resp_logs) >= 0: + logs: list[CleanLogEntry] = [] + for log in resp_logs: + try: + logs.append( + CleanLogEntry( + timestamp=int(log.attrib["s"]), + image_url="", # Missing + type=log.attrib["t"], + area=int(log.attrib["a"]), + stop_reason=CleanJobStatus.FINISHED, # To be extracted + duration=int(log.attrib["l"]), + ) + ) + except Exception: # pylint: disable=broad-except + _LOGGER.warning("Skipping log entry: %s", log, exc_info=True) + event_bus.notify(CleanLogEvent(logs)) + return CommandResult.success() + return HandlingResult.analyse() diff --git a/deebot_client/hardware/deebot/2pv572.py b/deebot_client/hardware/deebot/2pv572.py index dddba2b82..e0e986bdd 100644 --- a/deebot_client/hardware/deebot/2pv572.py +++ b/deebot_client/hardware/deebot/2pv572.py @@ -23,6 +23,7 @@ Clean, CleanArea, GetBatteryInfo, + GetCleanLogs, GetCleanSpeed, GetCleanState, PlaySound, @@ -35,6 +36,7 @@ from deebot_client.events import ( AvailabilityEvent, BatteryEvent, + CleanLogEvent, CustomCommandEvent, ErrorEvent, FanSpeedEvent, @@ -60,6 +62,7 @@ charge=CapabilityExecute(Charge), clean=CapabilityClean( action=CapabilityCleanAction(command=Clean, area=CleanArea), + log=CapabilityEvent(CleanLogEvent, [GetCleanLogs()]), ), custom=CapabilityCustomCommand( event=CustomCommandEvent, get=[], set=CustomCommand From f3a0b60b047243451b1978c863528c6587c8392b Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sun, 6 Apr 2025 17:38:46 +0200 Subject: [PATCH 09/28] Convert CleanAction to CleanState --- deebot_client/commands/xml/clean.py | 14 ++++++++++++-- deebot_client/messages/xml/clean.py | 15 ++++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/deebot_client/commands/xml/clean.py b/deebot_client/commands/xml/clean.py index b1d7d76ff..2dc3838d2 100644 --- a/deebot_client/commands/xml/clean.py +++ b/deebot_client/commands/xml/clean.py @@ -5,8 +5,9 @@ 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 +from deebot_client.models import CleanAction, CleanMode, State from .common import ExecuteCommand, XmlCommandWithMessageHandling @@ -15,6 +16,8 @@ from deebot_client.event_bus import EventBus +_LOGGER = get_logger(__name__) + class Clean(ExecuteCommand): """Generic start/pause/stop cleaning command.""" @@ -83,5 +86,12 @@ def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult: clean_attrib = clean.attrib.get("st") if clean_attrib is not None: - event_bus.notify(StateEvent(CleanAction.from_xml(clean_attrib))) + 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/deebot_client/messages/xml/clean.py b/deebot_client/messages/xml/clean.py index a10aeb2bf..3d410ea72 100644 --- a/deebot_client/messages/xml/clean.py +++ b/deebot_client/messages/xml/clean.py @@ -4,16 +4,18 @@ from typing import TYPE_CHECKING -from deebot_client.events import FanSpeedEvent, FanSpeedLevel +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.messages.xml.common import XmlMessage -from deebot_client.models import CleanAction +from deebot_client.models import CleanAction, State if TYPE_CHECKING: from xml.etree.ElementTree import Element from deebot_client.event_bus import EventBus +_LOGGER = get_logger(__name__) class CleanSt(XmlMessage): """CleanSt message.""" @@ -52,5 +54,12 @@ def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult: clean_attrib = clean.attrib.get("st") if clean_attrib is not None: - event_bus.notify(CleanAction.from_xml(clean_attrib)) + 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() From 677616e441ec5b430a15b71f4cad9fe944f1bae5 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Mon, 7 Apr 2025 19:32:05 +0200 Subject: [PATCH 10/28] Apply suggestions from code review Co-authored-by: flubshi <4031504+flubshi@users.noreply.github.com> --- deebot_client/commands/xml/water_info.py | 2 +- deebot_client/hardware/deebot/2pv572.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/deebot_client/commands/xml/water_info.py b/deebot_client/commands/xml/water_info.py index 6625c25a9..f0da9cad3 100644 --- a/deebot_client/commands/xml/water_info.py +++ b/deebot_client/commands/xml/water_info.py @@ -19,7 +19,7 @@ class GetWaterPermeability(XmlGetCommand): - """GetWaterBoxInfo command.""" + """GetWaterPermeability command.""" NAME = "GetWaterPermeability" diff --git a/deebot_client/hardware/deebot/2pv572.py b/deebot_client/hardware/deebot/2pv572.py index e0e986bdd..555c79f56 100644 --- a/deebot_client/hardware/deebot/2pv572.py +++ b/deebot_client/hardware/deebot/2pv572.py @@ -1,4 +1,4 @@ -"""ls1ok3 Capabilities.""" +"""2pv572 Capabilities.""" from __future__ import annotations From 12fd3470245b4392b131ced4c62e152bcfe911e3 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Mon, 7 Apr 2025 22:05:39 +0200 Subject: [PATCH 11/28] Fix bytes and bytearray decoding --- deebot_client/commands/xml/common.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/deebot_client/commands/xml/common.py b/deebot_client/commands/xml/common.py index d1079b01c..e16bbe84e 100644 --- a/deebot_client/commands/xml/common.py +++ b/deebot_client/commands/xml/common.py @@ -100,7 +100,18 @@ def handle_mqtt_p2p( self, event_bus: EventBus, response_payload: str | bytes | bytearray ) -> None: """Handle response received over the mqtt channel "p2p".""" - self._handle_mqtt_p2p(event_bus, str(response_payload)) + if isinstance(response_payload, bytearray): + data = bytes(response_payload).decode() + elif isinstance(response_payload, bytes): + data = response_payload.decode() + elif isinstance(response_payload, str): + data = response_payload + else: + msg = "Unsupported message data type {message_type}" + raise TypeError( + msg.format(essage_type=type(response_payload)) + ) + self._handle_mqtt_p2p(event_bus, data) @abstractmethod def _handle_mqtt_p2p( From 7b6e737a8f882d55827af6e13b37dfbb5d4eec1d Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Mon, 7 Apr 2025 22:10:35 +0200 Subject: [PATCH 12/28] Ignore MapP word as it is a message type --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c3f533863..0180cd249 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,7 +32,7 @@ repos: hooks: - id: codespell args: - - --ignore-words-list=deebot + - --ignore-words-list=deebot,MapP - --skip="./.*,*.csv,*.json" - --quiet-level=2 - --exclude-file=deebot_client/util/continents.py From 935b92a9d1e62753f51aed4cf9777397b5664f34 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Tue, 22 Apr 2025 15:31:33 +0200 Subject: [PATCH 13/28] Run ruff format --- deebot_client/commands/xml/clean.py | 12 ++++++------ deebot_client/commands/xml/clean_logs.py | 5 ++++- deebot_client/commands/xml/common.py | 4 +--- deebot_client/messages/xml/clean.py | 1 + deebot_client/messages/xml/common.py | 2 +- 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/deebot_client/commands/xml/clean.py b/deebot_client/commands/xml/clean.py index 2dc3838d2..b04690992 100644 --- a/deebot_client/commands/xml/clean.py +++ b/deebot_client/commands/xml/clean.py @@ -26,7 +26,7 @@ class Clean(ExecuteCommand): HAS_SUB_ELEMENT = True def __init__( - self, action: CleanAction, speed: FanSpeedLevel = FanSpeedLevel.NORMAL + self, action: CleanAction, speed: FanSpeedLevel = FanSpeedLevel.NORMAL ) -> None: # @@ -46,11 +46,11 @@ class CleanArea(ExecuteCommand): HAS_SUB_ELEMENT = True def __init__( - self, - mode: CleanMode, - area: str, - cleanings: int = 1, - speed: FanSpeedLevel = FanSpeedLevel.NORMAL, + self, + mode: CleanMode, + area: str, + cleanings: int = 1, + speed: FanSpeedLevel = FanSpeedLevel.NORMAL, ) -> None: # diff --git a/deebot_client/commands/xml/clean_logs.py b/deebot_client/commands/xml/clean_logs.py index 0b0434b8f..6e706c398 100644 --- a/deebot_client/commands/xml/clean_logs.py +++ b/deebot_client/commands/xml/clean_logs.py @@ -37,7 +37,10 @@ def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult: :return: A message response """ - if xml.attrib.get("ret") != "ok" or (resp_logs := xml.findall("CleanSt")) is None: + if ( + xml.attrib.get("ret") != "ok" + or (resp_logs := xml.findall("CleanSt")) is None + ): return HandlingResult.analyse() if len(resp_logs) >= 0: diff --git a/deebot_client/commands/xml/common.py b/deebot_client/commands/xml/common.py index e16bbe84e..4d84bf042 100644 --- a/deebot_client/commands/xml/common.py +++ b/deebot_client/commands/xml/common.py @@ -108,9 +108,7 @@ def handle_mqtt_p2p( data = response_payload else: msg = "Unsupported message data type {message_type}" - raise TypeError( - msg.format(essage_type=type(response_payload)) - ) + raise TypeError(msg.format(essage_type=type(response_payload))) self._handle_mqtt_p2p(event_bus, data) @abstractmethod diff --git a/deebot_client/messages/xml/clean.py b/deebot_client/messages/xml/clean.py index 3d410ea72..1de3e2aae 100644 --- a/deebot_client/messages/xml/clean.py +++ b/deebot_client/messages/xml/clean.py @@ -17,6 +17,7 @@ _LOGGER = get_logger(__name__) + class CleanSt(XmlMessage): """CleanSt message.""" diff --git a/deebot_client/messages/xml/common.py b/deebot_client/messages/xml/common.py index ee46a1aac..5c9d4aca1 100644 --- a/deebot_client/messages/xml/common.py +++ b/deebot_client/messages/xml/common.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING -from defusedxml import ElementTree # type: ignore[import-untyped] +from defusedxml import ElementTree as ET # type: ignore[import-untyped] from deebot_client.message import MessageStr From ee9a687a834f88d87b37f3ba97c9900c2ddf65cd Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Mon, 7 Apr 2025 22:39:46 +0200 Subject: [PATCH 14/28] 2pv572: Disable volume configuration (unsupported in ecovacs app) --- deebot_client/hardware/deebot/2pv572.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/deebot_client/hardware/deebot/2pv572.py b/deebot_client/hardware/deebot/2pv572.py index 555c79f56..800acbb98 100644 --- a/deebot_client/hardware/deebot/2pv572.py +++ b/deebot_client/hardware/deebot/2pv572.py @@ -10,13 +10,10 @@ CapabilityEvent, CapabilityExecute, CapabilityLifeSpan, - CapabilitySet, - CapabilitySettings, CapabilitySetTypes, CapabilityStats, DeviceType, ) -from deebot_client.commands.json import SetVolume from deebot_client.commands.json.custom import CustomCommand from deebot_client.commands.xml import ( Charge, @@ -47,7 +44,6 @@ StateEvent, StatsEvent, TotalStatsEvent, - VolumeEvent, ) from deebot_client.models import StaticDeviceInfo from deebot_client.util import short_name @@ -86,13 +82,6 @@ ), network=CapabilityEvent(NetworkInfoEvent, []), play_sound=CapabilityExecute(PlaySound), - settings=CapabilitySettings( - volume=CapabilitySet( - event=VolumeEvent, - get=[], - set=SetVolume, - ), - ), state=CapabilityEvent(StateEvent, [GetChargeState(), GetCleanState()]), stats=CapabilityStats( clean=CapabilityEvent(StatsEvent, [GetCleanSum()]), From b520287e39e7cd0fd1370b0422766e9aa8f50b34 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sat, 12 Apr 2025 15:43:40 +0200 Subject: [PATCH 15/28] Fix cleaning tests --- tests/commands/xml/test_clean.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/commands/xml/test_clean.py b/tests/commands/xml/test_clean.py index 4e36527c0..14daae18c 100644 --- a/tests/commands/xml/test_clean.py +++ b/tests/commands/xml/test_clean.py @@ -45,7 +45,7 @@ async def test_CleanArea(command: CleanArea, command_result: HandlingState) -> N "standard", "p", FanSpeedEvent(FanSpeedLevel.NORMAL), - None, + StateEvent(State.PAUSED), ), ], ids=["standard_cleaning", "strong_cleaning", "paused"], From 47aa64d81e7e7efc63c4f751b8856de16d40079b Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sat, 12 Apr 2025 15:43:48 +0200 Subject: [PATCH 16/28] Fix charge state tests --- tests/commands/xml/test_charge_state.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/commands/xml/test_charge_state.py b/tests/commands/xml/test_charge_state.py index 926ac3ae5..2a3c6e9e9 100644 --- a/tests/commands/xml/test_charge_state.py +++ b/tests/commands/xml/test_charge_state.py @@ -21,11 +21,10 @@ ("state", "expected_event"), [ ("SlotCharging", StateEvent(State.DOCKED)), - ("Idle", StateEvent(State.IDLE)), ("Going", StateEvent(State.RETURNING)), ("unknown state returned", StateEvent(State.ERROR)), ], - ids=["slot_charging", "idle", "going", "unknown"], + ids=["slot_charging", "going", "unknown"], ) async def test_get_charge_state(state: str, expected_event: Event) -> None: json = get_request_xml(f"") @@ -34,8 +33,12 @@ async def test_get_charge_state(state: str, expected_event: Event) -> None: @pytest.mark.parametrize( "xml", - ["", ""], - ids=["error", "no_state"], + [ + "", + "", + "", + ], + ids=["error", "no_state", "idle"], ) async def test_get_charge_state_error(xml: str) -> None: json = get_request_xml(xml) From ea6060c4bce27db848a01423fedb3f237c798745 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sat, 12 Apr 2025 15:44:01 +0200 Subject: [PATCH 17/28] Make volume capability optional --- deebot_client/capabilities.py | 2 +- deebot_client/hardware/deebot/2pv572.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/deebot_client/capabilities.py b/deebot_client/capabilities.py index d03b2fc23..ddcd9678c 100644 --- a/deebot_client/capabilities.py +++ b/deebot_client/capabilities.py @@ -213,7 +213,7 @@ 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]] | None = None @dataclass(frozen=True, kw_only=True) diff --git a/deebot_client/hardware/deebot/2pv572.py b/deebot_client/hardware/deebot/2pv572.py index 800acbb98..f204c34c4 100644 --- a/deebot_client/hardware/deebot/2pv572.py +++ b/deebot_client/hardware/deebot/2pv572.py @@ -10,6 +10,7 @@ CapabilityEvent, CapabilityExecute, CapabilityLifeSpan, + CapabilitySettings, CapabilitySetTypes, CapabilityStats, DeviceType, @@ -88,5 +89,6 @@ report=CapabilityEvent(ReportStatsEvent, []), total=CapabilityEvent(TotalStatsEvent, [GetCleanSum()]), ), + settings=CapabilitySettings(), ), ) From f448abe2e59e8732df9d5d775b832c346fa31042 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sat, 12 Apr 2025 15:47:25 +0200 Subject: [PATCH 18/28] Add 2pv572 LifeSpan Capabilities --- deebot_client/hardware/deebot/2pv572.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/deebot_client/hardware/deebot/2pv572.py b/deebot_client/hardware/deebot/2pv572.py index f204c34c4..974fc26e3 100644 --- a/deebot_client/hardware/deebot/2pv572.py +++ b/deebot_client/hardware/deebot/2pv572.py @@ -24,6 +24,7 @@ GetCleanLogs, GetCleanSpeed, GetCleanState, + GetLifeSpan, PlaySound, SetCleanSpeed, ) @@ -39,6 +40,7 @@ ErrorEvent, FanSpeedEvent, FanSpeedLevel, + LifeSpan, LifeSpanEvent, NetworkInfoEvent, ReportStatsEvent, @@ -76,9 +78,13 @@ ), ), life_span=CapabilityLifeSpan( - types=(), + types=(LifeSpan.BRUSH, LifeSpan.SIDE_BRUSH, LifeSpan.DUST_CASE_HEAP), event=LifeSpanEvent, - get=[], + get=[ + GetLifeSpan(LifeSpan.BRUSH), + GetLifeSpan(LifeSpan.SIDE_BRUSH), + GetLifeSpan(LifeSpan.DUST_CASE_HEAP), + ], reset=CustomCommand, ), network=CapabilityEvent(NetworkInfoEvent, []), From cfb095f3ed402eb575c7737fc62c393b2949b25e Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sat, 12 Apr 2025 16:08:34 +0200 Subject: [PATCH 19/28] Fix type linting error in device.py --- deebot_client/device.py | 1 + 1 file changed, 1 insertion(+) diff --git a/deebot_client/device.py b/deebot_client/device.py index 88aa36ccb..9cc2f12d8 100644 --- a/deebot_client/device.py +++ b/deebot_client/device.py @@ -202,6 +202,7 @@ def _handle_message( message_data_type = self._device_info.static.data_type if message := get_message(message_name, message_data_type): + data: dict[str, Any] | str if isinstance(message_data, dict): data = message_data elif message_data_type == DataType.JSON: From ca44b20c924fa357eb8ec9df5307da13f4e12a2f Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sat, 12 Apr 2025 17:50:08 +0200 Subject: [PATCH 20/28] Add legacy GetNetInfo JSON command --- deebot_client/hardware/deebot/2pv572.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/deebot_client/hardware/deebot/2pv572.py b/deebot_client/hardware/deebot/2pv572.py index 974fc26e3..1591fb132 100644 --- a/deebot_client/hardware/deebot/2pv572.py +++ b/deebot_client/hardware/deebot/2pv572.py @@ -15,6 +15,7 @@ CapabilityStats, DeviceType, ) +from deebot_client.commands.json import GetNetInfoLegacy from deebot_client.commands.json.custom import CustomCommand from deebot_client.commands.xml import ( Charge, @@ -87,7 +88,7 @@ ], reset=CustomCommand, ), - network=CapabilityEvent(NetworkInfoEvent, []), + network=CapabilityEvent(NetworkInfoEvent, [GetNetInfoLegacy()]), play_sound=CapabilityExecute(PlaySound), state=CapabilityEvent(StateEvent, [GetChargeState(), GetCleanState()]), stats=CapabilityStats( From 75ad3a4c9cd7176ab5cfc95b14217300d077fef6 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Tue, 22 Apr 2025 15:38:05 +0200 Subject: [PATCH 21/28] Add more XML commands and messages --- deebot_client/commands/xml/__init__.py | 8 +++- deebot_client/commands/xml/clean_logs.py | 15 ++++++- deebot_client/commands/xml/enum.py | 35 ++++++++++++++++ deebot_client/messages/xml/__init__.py | 20 +++++++-- deebot_client/messages/xml/clean.py | 53 +++++++++++++++++++++++- deebot_client/messages/xml/sleep.py | 29 +++++++++++++ 6 files changed, 151 insertions(+), 9 deletions(-) create mode 100644 deebot_client/commands/xml/enum.py create mode 100644 deebot_client/messages/xml/sleep.py diff --git a/deebot_client/commands/xml/__init__.py b/deebot_client/commands/xml/__init__.py index fc429f598..a19b77d70 100644 --- a/deebot_client/commands/xml/__init__.py +++ b/deebot_client/commands/xml/__init__.py @@ -44,19 +44,23 @@ # fmt: off # ordered by file asc _COMMANDS: list[type[XmlCommand]] = [ + Charge, Clean, CleanArea, - GetBatteryInfo, GetError, GetBatteryInfo, + GetChargeState, GetCleanLogs, GetCleanSpeed, GetCleanState, + GetCleanSum, + GetError, GetLifeSpan, + GetPos, GetWaterBoxInfo, GetWaterPermeability, - SetCleanSpeed, PlaySound, + SetCleanSpeed, ] # fmt: on diff --git a/deebot_client/commands/xml/clean_logs.py b/deebot_client/commands/xml/clean_logs.py index 6e706c398..ed6361cc3 100644 --- a/deebot_client/commands/xml/clean_logs.py +++ b/deebot_client/commands/xml/clean_logs.py @@ -6,14 +6,15 @@ from deebot_client.command import CommandResult from deebot_client.events import ( - CleanJobStatus, CleanLogEntry, CleanLogEvent, ) from deebot_client.logging_filter import get_logger from deebot_client.message import HandlingResult +from deebot_client.util import get_enum from .common import XmlCommandWithMessageHandling +from .enum import XmlStopReason if TYPE_CHECKING: from xml.etree.ElementTree import Element @@ -46,6 +47,16 @@ def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult: if len(resp_logs) >= 0: logs: list[CleanLogEntry] = [] for log in resp_logs: + xml_stop_reason_attrib = str(log.attrib["f"]) + stop_reason = XmlStopReason.FINISHED + try: + stop_reason = get_enum(XmlStopReason, xml_stop_reason_attrib) + except Exception as e: + _LOGGER.error( + "Could not decode stop reason: %s", + xml_stop_reason_attrib, + exc_info=e, + ) try: logs.append( CleanLogEntry( @@ -53,7 +64,7 @@ def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult: image_url="", # Missing type=log.attrib["t"], area=int(log.attrib["a"]), - stop_reason=CleanJobStatus.FINISHED, # To be extracted + stop_reason=stop_reason.to_clean_job_status(), # To be extracted duration=int(log.attrib["l"]), ) ) diff --git a/deebot_client/commands/xml/enum.py b/deebot_client/commands/xml/enum.py new file mode 100644 index 000000000..f3f6fd49a --- /dev/null +++ b/deebot_client/commands/xml/enum.py @@ -0,0 +1,35 @@ +"""Enums used only by XML messages.""" + +from __future__ import annotations + +from enum import StrEnum + +from deebot_client.events import CleanJobStatus + + +class XmlStopReason(StrEnum): + """Reasons why cleaning has been stopped.""" + + FINISHED = "s" + BATTERY_LOW = "r" + STOPPED_BY_APP = "a" + STOPPED_BY_REMOTE_CONTROL = "i" + STOPPED_BY_BUTTON = "b" + STOPPED_BY_WARNING = "w" + STOPPED_BY_NO_DISTURB = "f" + STOPPED_BY_CLEARMAP = "m" + STOPPED_BY_NO_PATH = "n" + STOPPED_BY_NOT_IN_MAP = "u" + STOPPED_BY_VIRTUAL_WALL = "v" + + def to_clean_job_status(self) -> CleanJobStatus: + """Convert this value to a CleanJobStatus for compatibility proposes.""" + if self == XmlStopReason.FINISHED: + return CleanJobStatus.FINISHED + if self in ( + XmlStopReason.STOPPED_BY_APP, + XmlStopReason.STOPPED_BY_REMOTE_CONTROL, + XmlStopReason.STOPPED_BY_BUTTON, + ): + return CleanJobStatus.MANUALLY_STOPPED + return CleanJobStatus.FINISHED_WITH_WARNINGS diff --git a/deebot_client/messages/xml/__init__.py b/deebot_client/messages/xml/__init__.py index 024ce5605..fa06f1bd9 100644 --- a/deebot_client/messages/xml/__init__.py +++ b/deebot_client/messages/xml/__init__.py @@ -6,9 +6,15 @@ from deebot_client.messages.xml.battery import BatteryInfo from deebot_client.messages.xml.charge import ChargeState -from deebot_client.messages.xml.clean import CleanReport, CleanSt +from deebot_client.messages.xml.clean import ( + CleanedPos, + CleanReport, + CleanReportServer, + CleanSt, +) from deebot_client.messages.xml.map import MapP from deebot_client.messages.xml.pos import Pos +from deebot_client.messages.xml.sleep import SleepStatus from deebot_client.messages.xml.water_info import WaterBoxInfo if TYPE_CHECKING: @@ -18,9 +24,12 @@ "BatteryInfo", "ChargeState", "CleanReport", + "CleanReportServer", "CleanSt", + "CleanedPos", "MapP", "Pos", + "SleepStatus", "WaterBoxInfo", ] # fmt: off @@ -29,10 +38,13 @@ BatteryInfo, ChargeState, CleanReport, - WaterBoxInfo, - Pos, + CleanReportServer, + CleanSt, + CleanedPos, MapP, - CleanSt + Pos, + SleepStatus, + WaterBoxInfo, ] # fmt: on diff --git a/deebot_client/messages/xml/clean.py b/deebot_client/messages/xml/clean.py index 1de3e2aae..032e31ff8 100644 --- a/deebot_client/messages/xml/clean.py +++ b/deebot_client/messages/xml/clean.py @@ -4,11 +4,18 @@ from typing import TYPE_CHECKING -from deebot_client.events import FanSpeedEvent, FanSpeedLevel, StateEvent +from deebot_client.events import ( + FanSpeedEvent, + FanSpeedLevel, + Position, + PositionsEvent, + StateEvent, +) from deebot_client.logging_filter import get_logger from deebot_client.message import HandlingResult from deebot_client.messages.xml.common import XmlMessage from deebot_client.models import CleanAction, State +from deebot_client.rs.map import PositionType if TYPE_CHECKING: from xml.etree.ElementTree import Element @@ -43,6 +50,8 @@ class CleanReport(XmlMessage): def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult: """Handle xml message and notify the correct event subscribers. + b"" + :return: A message response """ if (clean := xml.find("clean")) is None: @@ -64,3 +73,45 @@ def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult: _LOGGER.debug("Ignored CleanState %s", clean_action) return HandlingResult.success() + + +class CleanReportServer(XmlMessage): + """CleanReportServer message.""" + + NAME = "CleanReportServer" + + @classmethod + def _handle_xml(cls, _event_bus: EventBus, _xml: Element) -> HandlingResult: + """Handle xml message and notify the correct event subscribers. + + b"" + b"" + + :return: A message response + """ + return HandlingResult.analyse() + + +class CleanedPos(XmlMessage): + """CleanedPos message.""" + + NAME = "CleanedPos" + + @classmethod + def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult: + """Handle xml message and notify the correct event subscribers. + + b"" + + :return: A message response + """ + if p := xml.attrib.get("p"): + p_x, p_y = p.split(",", 2) + p_a = xml.attrib.get("a", 0) + position = Position( + type=PositionType.DEEBOT, x=int(p_x), y=int(p_y), a=int(p_a) + ) + event_bus.notify(PositionsEvent(positions=[position])) + return HandlingResult.success() + + return HandlingResult.analyse() diff --git a/deebot_client/messages/xml/sleep.py b/deebot_client/messages/xml/sleep.py new file mode 100644 index 000000000..d1919c632 --- /dev/null +++ b/deebot_client/messages/xml/sleep.py @@ -0,0 +1,29 @@ +"""Sleep messages.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from deebot_client.message import HandlingResult +from deebot_client.messages.xml.common import XmlMessage + +if TYPE_CHECKING: + from xml.etree.ElementTree import Element + + from deebot_client.event_bus import EventBus + + +class SleepStatus(XmlMessage): + """SleepStatus message.""" + + NAME = "SleepStatus" + + @classmethod + def _handle_xml(cls, _event_bus: EventBus, _xml: Element) -> HandlingResult: + """Handle xml message and notify the correct event subscribers. + + b"" + + :return: A message response + """ + return HandlingResult.analyse() From a20467994b1840354f412e3c77c13ef91825a318 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sat, 12 Apr 2025 19:02:37 +0200 Subject: [PATCH 22/28] Add decoding for ReportStatsEvent --- deebot_client/hardware/deebot/2pv572.py | 2 +- deebot_client/messages/xml/clean.py | 37 ++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/deebot_client/hardware/deebot/2pv572.py b/deebot_client/hardware/deebot/2pv572.py index 1591fb132..03864845a 100644 --- a/deebot_client/hardware/deebot/2pv572.py +++ b/deebot_client/hardware/deebot/2pv572.py @@ -92,7 +92,7 @@ play_sound=CapabilityExecute(PlaySound), state=CapabilityEvent(StateEvent, [GetChargeState(), GetCleanState()]), stats=CapabilityStats( - clean=CapabilityEvent(StatsEvent, [GetCleanSum()]), + clean=CapabilityEvent(StatsEvent, []), report=CapabilityEvent(ReportStatsEvent, []), total=CapabilityEvent(TotalStatsEvent, [GetCleanSum()]), ), diff --git a/deebot_client/messages/xml/clean.py b/deebot_client/messages/xml/clean.py index 032e31ff8..c54da1b80 100644 --- a/deebot_client/messages/xml/clean.py +++ b/deebot_client/messages/xml/clean.py @@ -5,11 +5,14 @@ from typing import TYPE_CHECKING from deebot_client.events import ( + CleanJobStatus, FanSpeedEvent, FanSpeedLevel, Position, PositionsEvent, + ReportStatsEvent, StateEvent, + StatsEvent, ) from deebot_client.logging_filter import get_logger from deebot_client.message import HandlingResult @@ -81,7 +84,7 @@ class CleanReportServer(XmlMessage): NAME = "CleanReportServer" @classmethod - def _handle_xml(cls, _event_bus: EventBus, _xml: Element) -> HandlingResult: + def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult: """Handle xml message and notify the correct event subscribers. b"" @@ -89,6 +92,38 @@ def _handle_xml(cls, _event_bus: EventBus, _xml: Element) -> HandlingResult: :return: A message response """ + event_reported = False + if act := xml.attrib.get("act"): + clean_session = xml.attrib.get("cs") + last = xml.attrib.get("last") + area = xml.attrib.get("area") + type = xml.attrib.get("type") + clean_action = CleanAction.from_xml(act) + if clean_action == CleanAction.STOP: + event_bus.notify(StatsEvent(area=area, time=last, type=type)) + event_reported = True + if clean_session: + if clean_action == CleanAction.STOP: + job_status = CleanJobStatus.FINISHED + elif clean_action == CleanAction.START: + job_status = CleanJobStatus.CLEANING + elif clean_action == CleanAction.PAUSE: + job_status = CleanJobStatus.PAUSED + else: + job_status = CleanJobStatus.NO_STATUS + event_bus.notify( + ReportStatsEvent( + area=area, + time=last, + type=type, + cleaning_id=clean_session, + status=job_status, + content=[], + ) + ) + event_reported = True + if event_reported: + return HandlingResult.success() return HandlingResult.analyse() From 0d5fa49d4a96223bd323a7a254aa808e9a10fe04 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sat, 12 Apr 2025 19:02:49 +0200 Subject: [PATCH 23/28] Add water capability --- deebot_client/commands/xml/water_info.py | 32 +++++++++++++++++------- deebot_client/events/water_info.py | 2 +- deebot_client/hardware/deebot/2pv572.py | 14 +++++++++++ 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/deebot_client/commands/xml/water_info.py b/deebot_client/commands/xml/water_info.py index f0da9cad3..e5dd251a4 100644 --- a/deebot_client/commands/xml/water_info.py +++ b/deebot_client/commands/xml/water_info.py @@ -2,15 +2,18 @@ from __future__ import annotations +from types import MappingProxyType from typing import TYPE_CHECKING, Any +from deebot_client.command import InitParam from deebot_client.events import ( WaterAmount, WaterInfoEvent, ) from deebot_client.message import HandlingResult +from deebot_client.util import get_enum -from .common import XmlGetCommand +from .common import XmlGetCommand, XmlSetCommand if TYPE_CHECKING: from xml.etree.ElementTree import Element @@ -47,6 +50,23 @@ def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult: return HandlingResult.success() +class SetWaterPermeability(XmlSetCommand): + """SetWaterPermeability command.""" + + NAME = "SetWaterPermeability" + get_command = GetWaterPermeability + _mqtt_params = MappingProxyType( + { + "amount": InitParam(WaterAmount), + } + ) + + def __init__(self, amount: WaterAmount | str) -> None: + if isinstance(amount, str): + amount = get_enum(WaterAmount, amount) + super().__init__({"v": str(amount.value)}) + + class GetWaterBoxInfo(XmlGetCommand): """GetWaterBoxInfo command.""" @@ -60,11 +80,7 @@ def handle_set_args( :return: A message response """ - event_bus.notify( - WaterInfoEvent( - amount=WaterAmount.HIGH, mop_attached=(str(args["on"]) != "0") - ) - ) + event_bus.notify(WaterInfoEvent(mop_attached=(str(args["on"]) != "0"))) return HandlingResult.success() @classmethod @@ -76,7 +92,5 @@ def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult: if xml.attrib.get("ret") != "ok" or not (on := xml.attrib.get("on")): return HandlingResult.analyse() - event_bus.notify( - WaterInfoEvent(amount=WaterAmount.HIGH, mop_attached=on != "0") - ) + event_bus.notify(WaterInfoEvent(mop_attached=on != "0")) return HandlingResult.success() diff --git a/deebot_client/events/water_info.py b/deebot_client/events/water_info.py index 1f543f5bf..b4b67814c 100644 --- a/deebot_client/events/water_info.py +++ b/deebot_client/events/water_info.py @@ -30,7 +30,7 @@ class SweepType(IntEnum): class WaterInfoEvent(Event): """Water info event representation.""" - amount: WaterAmount # None means no data available + amount: WaterAmount | None = None sweep_type: SweepType | None = None mop_attached: bool | None = field(kw_only=True, default=None) diff --git a/deebot_client/hardware/deebot/2pv572.py b/deebot_client/hardware/deebot/2pv572.py index 03864845a..bf1976fd1 100644 --- a/deebot_client/hardware/deebot/2pv572.py +++ b/deebot_client/hardware/deebot/2pv572.py @@ -26,12 +26,14 @@ GetCleanSpeed, GetCleanState, GetLifeSpan, + GetWaterPermeability, PlaySound, SetCleanSpeed, ) from deebot_client.commands.xml.charge_state import GetChargeState from deebot_client.commands.xml.error import GetError from deebot_client.commands.xml.stats import GetCleanSum +from deebot_client.commands.xml.water_info import GetWaterBoxInfo, SetWaterPermeability from deebot_client.const import DataType from deebot_client.events import ( AvailabilityEvent, @@ -48,6 +50,8 @@ StateEvent, StatsEvent, TotalStatsEvent, + WaterAmount, + WaterInfoEvent, ) from deebot_client.models import StaticDeviceInfo from deebot_client.util import short_name @@ -97,5 +101,15 @@ total=CapabilityEvent(TotalStatsEvent, [GetCleanSum()]), ), settings=CapabilitySettings(), + water=CapabilitySetTypes( + event=WaterInfoEvent, + get=[GetWaterPermeability(), GetWaterBoxInfo()], + set=SetWaterPermeability, + types=( + WaterAmount.LOW, + WaterAmount.MEDIUM, + WaterAmount.HIGH, + ), + ), ), ) From e0ce03f0b1baa216ad266633406b275de394af5b Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sat, 19 Apr 2025 13:10:42 +0200 Subject: [PATCH 24/28] Map workflow --- deebot_client/capabilities.py | 4 +- deebot_client/commands/xml/__init__.py | 11 + deebot_client/commands/xml/map.py | 262 ++++++++++++++++++++++++ deebot_client/events/map.py | 2 + deebot_client/hardware/deebot/2pv572.py | 19 ++ deebot_client/map.py | 19 +- 6 files changed, 309 insertions(+), 8 deletions(-) create mode 100644 deebot_client/commands/xml/map.py diff --git a/deebot_client/capabilities.py b/deebot_client/capabilities.py index ddcd9678c..2c72539f9 100644 --- a/deebot_client/capabilities.py +++ b/deebot_client/capabilities.py @@ -175,9 +175,9 @@ class CapabilityMap: changed: CapabilityEvent[MapChangedEvent] clear: CapabilityExecute[[]] | None = None major: CapabilityEvent[MajorMapEvent] - multi_state: CapabilitySetEnable[MultimapStateEvent] + multi_state: CapabilitySetEnable[MultimapStateEvent] | None = None position: CapabilityEvent[PositionsEvent] - relocation: CapabilityExecute[[]] + relocation: CapabilityExecute[[]] | None = None rooms: CapabilityEvent[RoomsEvent] trace: CapabilityEvent[MapTraceEvent] diff --git a/deebot_client/commands/xml/__init__.py b/deebot_client/commands/xml/__init__.py index a19b77d70..f2c479755 100644 --- a/deebot_client/commands/xml/__init__.py +++ b/deebot_client/commands/xml/__init__.py @@ -14,6 +14,7 @@ from .error import GetError from .fan_speed import GetCleanSpeed, SetCleanSpeed from .life_span import GetLifeSpan +from .map import GetMapM, GetMapSet, GetMapSt, PullM, PullMP from .play_sound import PlaySound from .pos import GetPos from .stats import GetCleanSum @@ -34,10 +35,15 @@ "GetCleanSum", "GetError", "GetLifeSpan", + "GetMapM", + "GetMapSet", + "GetMapSt", "GetPos", "GetWaterBoxInfo", "GetWaterPermeability", "PlaySound", + "PullM", + "PullMP", "SetCleanSpeed", ] @@ -56,10 +62,15 @@ GetCleanSum, GetError, GetLifeSpan, + GetMapM, + GetMapSet, + GetMapSt, GetPos, GetWaterBoxInfo, GetWaterPermeability, PlaySound, + PullM, + PullMP, SetCleanSpeed, ] # fmt: on diff --git a/deebot_client/commands/xml/map.py b/deebot_client/commands/xml/map.py new file mode 100644 index 000000000..36a3c7e34 --- /dev/null +++ b/deebot_client/commands/xml/map.py @@ -0,0 +1,262 @@ +"""Map commands.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from deebot_client.command import Command, CommandResult +from deebot_client.const import DataType +from deebot_client.events import MajorMapEvent, MapSetEvent, MapSetType, MinorMapEvent +from deebot_client.events.map import CachedMapInfoEvent, MapSubsetEvent +from deebot_client.logging_filter import get_logger +from deebot_client.message import HandlingResult, HandlingState + +from .common import XmlCommandWithMessageHandling + +if TYPE_CHECKING: + from typing import Any + from xml.etree.ElementTree import Element + + from deebot_client.event_bus import EventBus + +_LOGGER = get_logger(__name__) + + +class GetMapSt(XmlCommandWithMessageHandling): + """GetMapSt command. + + This command checks whether the current map has been built successfully or not. + """ + + NAME = "GetMapSt" + + @classmethod + def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult: + """Handle xml message and notify the correct event subscribers. + + Sample message response: + "" + + :return: A message response + """ + if xml.attrib.get("ret") != "ok" or not (st := xml.attrib.get("st")): + return HandlingResult.analyse() + + built = st == "built" + event_bus.notify(CachedMapInfoEvent(name="", active=built)) + return HandlingResult.success() + + +class GetMapSet(XmlCommandWithMessageHandling): + """GetMapSet command. + + This commands gets the list of logical pieces each map is composed of. + XML robots do not support multiple maps, so the mid parameter is ignored. + """ + + _ARGS_MSID = "msid" + _ARGS_TYPE = "type" + _ARGS_SUBSETS = "subsets" + + NAME = "GetMapSet" + + def __init__( + self, + # pylint: disable=unused-argument + mid: str, # noqa: ARG002 + # pylint: disable=redefined-builtin + type: (MapSetType | str) = MapSetType.VIRTUAL_WALLS, + ) -> None: + if isinstance(type, MapSetType): + type = type.value + + super().__init__({"tp": type}) + + @classmethod + def _find_subsets(cls, maps: list[Element]) -> list[int]: + return [int(mid) for map in maps if (mid := map.attrib.get("mid")) is not None] + + @classmethod + def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult: + """Handle xml message and notify the correct event subscribers. + + Sample message response: + b'' + + :return: A message response + """ + if ( + xml.attrib.get("ret") != "ok" + or not (msid := xml.attrib.get("msid")) + or not (area_type := xml.attrib.get("tp")) + or not (m := xml.findall("m")) + ): + return HandlingResult.analyse() + subsets = cls._find_subsets(m) + event_bus.notify(MapSetEvent(MapSetType(area_type), subsets=subsets)) + args = { + cls._ARGS_MSID: msid, + cls._ARGS_TYPE: area_type, + cls._ARGS_SUBSETS: subsets, + } + return HandlingResult(HandlingState.SUCCESS, args) + + def _handle_response( + self, event_bus: EventBus, response: dict[str, Any] + ) -> CommandResult: + """Handle response from a command. + + :return: A message response + """ + result = super()._handle_response(event_bus, response) + if result.state == HandlingState.SUCCESS and result.args: + commands: list[Command] = [ + PullM( + mid=subset, + msid=result.args[self._ARGS_MSID], + type=result.args[self._ARGS_TYPE], + ) + for subset in result.args[self._ARGS_SUBSETS] + ] + return CommandResult(result.state, result.args, commands) + + return result + + +class GetMapM(XmlCommandWithMessageHandling): + """GetMapM command.""" + + NAME = "GetMapM" + + @classmethod + def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult: + """Handle xml message and notify the correct event subscribers. + + Sample message response: + b'' + + :return: A message response + """ + if not (map_hashes := xml.attrib.get("m")) or not (idx := xml.attrib.get("i")): + return HandlingResult.analyse() + event_bus.notify( + MajorMapEvent( + idx, + values=[int(map_hash.strip()) for map_hash in map_hashes.split(",")], + requested=True, + type=DataType.XML, + ) + ) + return HandlingResult.success() + + +class PullM(XmlCommandWithMessageHandling): + """PullM command. + + Pulls map subset coordinates + """ + + _ARG_COORDS = "coordinates" + + NAME = "PullM" + + def __init__( + self, + *, + mid: str | int, + msid: str | int, + # pylint: disable=redefined-builtin + type: (MapSetType | str) = MapSetType.ROOMS, + ) -> None: + if isinstance(type, MapSetType): + type = type.value + + self._map_type = type + self._map_subset_id = int(mid) + + super().__init__( + { + "mid": str(mid), + "msid": str(msid), + "tp": type, + "seq": "0", + }, + ) + + @classmethod + def _handle_xml(cls, _event_bus: EventBus, xml: Element) -> HandlingResult: + """Handle xml message and notify the correct event subscribers. + + Sample message responses: + + + + :return: A message response + """ + if xml.attrib.get("ret") != "ok" or not (coords := xml.attrib.get("m")): + return HandlingResult.analyse() + + args = {cls._ARG_COORDS: coords} + return HandlingResult(HandlingState.SUCCESS, args) + + def _handle_response( + self, event_bus: EventBus, response: dict[str, Any] + ) -> CommandResult: + """Handle response from a command. + + :return: A message response + """ + result = super()._handle_response(event_bus, response) + if result.state == HandlingState.SUCCESS and result.args: + coords = result.args[self._ARG_COORDS] + event_bus.notify( + MapSubsetEvent( + id=self._map_subset_id, + type=MapSetType(self._map_type), + coordinates=coords, + ) + ) + return CommandResult(result.state, result.args) + + return result + + +class PullMP(XmlCommandWithMessageHandling): + """PullMP command.""" + + _ARG_PIECE = "piece" + + NAME = "PullMP" + + def __init__(self, *, piece_index: int) -> None: + self._piece_index = piece_index + super().__init__({"pid": str(piece_index)}) + + @classmethod + def _handle_xml(cls, _event_bus: EventBus, xml: Element) -> HandlingResult: + """Handle xml message and notify the correct event subscribers. + + Sample message response: + b{'ret': 'ok', 'i': '1839263381', 'p': 'x_q_a_a_b_a_a_q_jw_a_a_a_a_bv/f//o7f/_rz5_i_f_x_i5_y_v_g4kijmo4_y_h+e7k_ho_l_t_l8_u6_p_a_f_ls_x7_jhrz0_kg_a=', 'event': 'pull_m_p'} + + :return: A message response + """ + if xml.attrib.get("ret") != "ok" or not (piece := xml.attrib.get("p")): + return HandlingResult.analyse() + args = {cls._ARG_PIECE: piece} + return HandlingResult(HandlingState.SUCCESS, args) + + def _handle_response( + self, event_bus: EventBus, response: dict[str, Any] + ) -> CommandResult: + """Handle response from a command. + + :return: A message response + """ + result = super()._handle_response(event_bus, response) + if result.state == HandlingState.SUCCESS and result.args: + piece = result.args[self._ARG_PIECE] + event_bus.notify(MinorMapEvent(index=self._piece_index, value=piece)) + return CommandResult(result.state, result.args) + + return result diff --git a/deebot_client/events/map.py b/deebot_client/events/map.py index b4d4526d7..ca2ad52c1 100644 --- a/deebot_client/events/map.py +++ b/deebot_client/events/map.py @@ -6,6 +6,7 @@ from enum import Enum, unique from typing import TYPE_CHECKING, Any +from deebot_client.const import DataType from deebot_client.events import Event if TYPE_CHECKING: @@ -47,6 +48,7 @@ class MajorMapEvent(Event): map_id: str values: list[int] requested: bool = field(kw_only=True) + type: DataType = DataType.JSON @dataclass(frozen=True) diff --git a/deebot_client/hardware/deebot/2pv572.py b/deebot_client/hardware/deebot/2pv572.py index bf1976fd1..d0d674433 100644 --- a/deebot_client/hardware/deebot/2pv572.py +++ b/deebot_client/hardware/deebot/2pv572.py @@ -10,6 +10,7 @@ CapabilityEvent, CapabilityExecute, CapabilityLifeSpan, + CapabilityMap, CapabilitySettings, CapabilitySetTypes, CapabilityStats, @@ -32,6 +33,8 @@ ) from deebot_client.commands.xml.charge_state import GetChargeState from deebot_client.commands.xml.error import GetError +from deebot_client.commands.xml.map import GetMapM, GetMapSt +from deebot_client.commands.xml.pos import GetPos from deebot_client.commands.xml.stats import GetCleanSum from deebot_client.commands.xml.water_info import GetWaterBoxInfo, SetWaterPermeability from deebot_client.const import DataType @@ -47,12 +50,20 @@ LifeSpanEvent, NetworkInfoEvent, ReportStatsEvent, + RoomsEvent, StateEvent, StatsEvent, TotalStatsEvent, WaterAmount, WaterInfoEvent, ) +from deebot_client.events.map import ( + CachedMapInfoEvent, + MajorMapEvent, + MapChangedEvent, + MapTraceEvent, + PositionsEvent, +) from deebot_client.models import StaticDeviceInfo from deebot_client.util import short_name @@ -92,6 +103,14 @@ ], reset=CustomCommand, ), + map=CapabilityMap( + cached_info=CapabilityEvent(CachedMapInfoEvent, [GetMapSt()]), + changed=CapabilityEvent(MapChangedEvent, []), + major=CapabilityEvent(MajorMapEvent, [GetMapM()]), + position=CapabilityEvent(PositionsEvent, [GetPos()]), + rooms=CapabilityEvent(RoomsEvent, [GetMapSt()]), + trace=CapabilityEvent(MapTraceEvent, []), + ), network=CapabilityEvent(NetworkInfoEvent, [GetNetInfoLegacy()]), play_sound=CapabilityExecute(PlaySound), state=CapabilityEvent(StateEvent, [GetChargeState(), GetCleanState()]), diff --git a/deebot_client/map.py b/deebot_client/map.py index c6afe8348..9065c37a2 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -6,9 +6,9 @@ from datetime import UTC, datetime from typing import TYPE_CHECKING, Final +from deebot_client.const import DataType from deebot_client.events.map import CachedMapInfoEvent, MapChangedEvent -from .commands.json import GetMinorMap from .events import ( MajorMapEvent, MapSetEvent, @@ -31,6 +31,8 @@ if TYPE_CHECKING: from collections.abc import Callable + from deebot_client.command import Command + from .device import DeviceCommandExecute from .event_bus import EventBus @@ -102,11 +104,16 @@ async def on_major_map(event: MajorMapEvent) -> None: self._map_data.map_piece_crc32_indicates_update(idx, value) and event.requested ): - tg.create_task( - self._execute_command( - GetMinorMap(map_id=event.map_id, piece_index=idx) - ) - ) + command: Command + if event.type == DataType.JSON: + from deebot_client.commands.json.map import GetMinorMap + + command = GetMinorMap(map_id=event.map_id, piece_index=idx) + else: + from deebot_client.commands.xml.map import PullMP + + command = PullMP(piece_index=idx) + tg.create_task(self._execute_command(command)) unsubscribers.append(self._event_bus.subscribe(MajorMapEvent, on_major_map)) From ffc41fc22dc2f243db25d54bfbc84c7b35006008 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Mon, 14 Apr 2025 19:23:25 +0000 Subject: [PATCH 25/28] Handle MapTrace events --- deebot_client/messages/xml/__init__.py | 4 +++- deebot_client/messages/xml/clean.py | 26 +++++++++++++++--------- deebot_client/messages/xml/map.py | 26 ++++++++++++++++++++++++ deebot_client/messages/xml/sleep.py | 4 +++- deebot_client/messages/xml/water_info.py | 6 ++---- 5 files changed, 50 insertions(+), 16 deletions(-) diff --git a/deebot_client/messages/xml/__init__.py b/deebot_client/messages/xml/__init__.py index fa06f1bd9..5364ec21a 100644 --- a/deebot_client/messages/xml/__init__.py +++ b/deebot_client/messages/xml/__init__.py @@ -12,7 +12,7 @@ CleanReportServer, CleanSt, ) -from deebot_client.messages.xml.map import MapP +from deebot_client.messages.xml.map import MapP, Trace from deebot_client.messages.xml.pos import Pos from deebot_client.messages.xml.sleep import SleepStatus from deebot_client.messages.xml.water_info import WaterBoxInfo @@ -30,6 +30,7 @@ "MapP", "Pos", "SleepStatus", + "Trace", "WaterBoxInfo", ] # fmt: off @@ -44,6 +45,7 @@ MapP, Pos, SleepStatus, + Trace, WaterBoxInfo, ] # fmt: on diff --git a/deebot_client/messages/xml/clean.py b/deebot_client/messages/xml/clean.py index c54da1b80..631608a17 100644 --- a/deebot_client/messages/xml/clean.py +++ b/deebot_client/messages/xml/clean.py @@ -39,9 +39,10 @@ def _handle_xml(cls, _event_bus: EventBus, _xml: Element) -> HandlingResult: b"" + We currently ignore this message as we prefer to use CleanReport :return: A message response """ - return HandlingResult.analyse() + return HandlingResult.success() class CleanReport(XmlMessage): @@ -93,14 +94,19 @@ def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult: :return: A message response """ event_reported = False - if act := xml.attrib.get("act"): + if ( + (act := xml.attrib.get("act")) is not None + and (last := xml.attrib.get("last")) is not None + and (area := xml.attrib.get("area")) is not None + ): clean_session = xml.attrib.get("cs") - last = xml.attrib.get("last") - area = xml.attrib.get("area") - type = xml.attrib.get("type") + clean_type = xml.attrib.get("type") + clean_action = CleanAction.from_xml(act) if clean_action == CleanAction.STOP: - event_bus.notify(StatsEvent(area=area, time=last, type=type)) + event_bus.notify( + StatsEvent(area=int(area), time=int(last), type=clean_type) + ) event_reported = True if clean_session: if clean_action == CleanAction.STOP: @@ -108,14 +114,14 @@ def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult: elif clean_action == CleanAction.START: job_status = CleanJobStatus.CLEANING elif clean_action == CleanAction.PAUSE: - job_status = CleanJobStatus.PAUSED + job_status = CleanJobStatus.MANUALLY_STOPPED else: job_status = CleanJobStatus.NO_STATUS event_bus.notify( ReportStatsEvent( - area=area, - time=last, - type=type, + area=int(area), + time=int(last), + type=clean_type, cleaning_id=clean_session, status=job_status, content=[], diff --git a/deebot_client/messages/xml/map.py b/deebot_client/messages/xml/map.py index 5b6b954c1..d7cf9919a 100644 --- a/deebot_client/messages/xml/map.py +++ b/deebot_client/messages/xml/map.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING +from deebot_client.events.map import MapTraceEvent from deebot_client.message import HandlingResult from deebot_client.messages.xml.common import XmlMessage @@ -27,6 +28,31 @@ def _handle_xml(cls, _event_bus: EventBus, _xml: Element) -> HandlingResult: b"" + This is currently ignored as we prefer to pull map pieces :return: A message response """ + return HandlingResult.success() + + +class Trace(XmlMessage): + """Trace message.""" + + NAME = "trace" + + @classmethod + def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult: + """Handle xml message and notify the correct event subscribers. + + Sample message: + + + :return: A message response + """ + if ( + (tf := xml.attrib.get("tf")) + and (tt := xml.attrib.get("tt")) + and (tr := xml.attrib.get("tr")) + ): + event_bus.notify(MapTraceEvent(start=int(tf), total=int(tt), data=tr)) + return HandlingResult.success() return HandlingResult.analyse() diff --git a/deebot_client/messages/xml/sleep.py b/deebot_client/messages/xml/sleep.py index d1919c632..d31ab0b28 100644 --- a/deebot_client/messages/xml/sleep.py +++ b/deebot_client/messages/xml/sleep.py @@ -24,6 +24,8 @@ def _handle_xml(cls, _event_bus: EventBus, _xml: Element) -> HandlingResult: b"" + We currently ignore this message + :return: A message response """ - return HandlingResult.analyse() + return HandlingResult.success() diff --git a/deebot_client/messages/xml/water_info.py b/deebot_client/messages/xml/water_info.py index 97ec912b6..701c18bdf 100644 --- a/deebot_client/messages/xml/water_info.py +++ b/deebot_client/messages/xml/water_info.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING -from deebot_client.events import WaterAmount, WaterInfoEvent +from deebot_client.events import WaterInfoEvent from deebot_client.message import HandlingResult from deebot_client.messages.xml.common import XmlMessage @@ -28,7 +28,5 @@ def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult: if (on := xml.attrib.get("on")) is None: return HandlingResult.analyse() - event_bus.notify( - WaterInfoEvent(amount=WaterAmount.HIGH, mop_attached=on != "0") - ) + event_bus.notify(WaterInfoEvent(mop_attached=on != "0")) return HandlingResult.success() From 895a635d05915be0df06109cc6223574bada6d25 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Mon, 14 Apr 2025 19:32:15 +0000 Subject: [PATCH 26/28] Realtime map updates --- deebot_client/messages/xml/map.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/deebot_client/messages/xml/map.py b/deebot_client/messages/xml/map.py index d7cf9919a..df41f3f1b 100644 --- a/deebot_client/messages/xml/map.py +++ b/deebot_client/messages/xml/map.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING -from deebot_client.events.map import MapTraceEvent +from deebot_client.events.map import MapTraceEvent, MinorMapEvent from deebot_client.message import HandlingResult from deebot_client.messages.xml.common import XmlMessage @@ -23,15 +23,17 @@ class MapP(XmlMessage): NAME = "MapP" @classmethod - def _handle_xml(cls, _event_bus: EventBus, _xml: Element) -> HandlingResult: + def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult: """Handle xml message and notify the correct event subscribers. b"" - This is currently ignored as we prefer to pull map pieces :return: A message response """ - return HandlingResult.success() + if (pid := xml.attrib.get("pid")) and (piece := xml.attrib.get("p")): + event_bus.notify(MinorMapEvent(index=int(pid), value=piece)) + return HandlingResult.success() + return HandlingResult.analyse() class Trace(XmlMessage): From b22f5935bef0cb6991bccfb6d144e195c336ce82 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Wed, 16 Apr 2025 07:11:18 +0000 Subject: [PATCH 27/28] Enable trace reporting from the bot. --- deebot_client/commands/xml/__init__.py | 4 +++- deebot_client/commands/xml/map.py | 19 +++++++++++++++++++ deebot_client/hardware/deebot/2pv572.py | 4 ++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/deebot_client/commands/xml/__init__.py b/deebot_client/commands/xml/__init__.py index f2c479755..18ee94445 100644 --- a/deebot_client/commands/xml/__init__.py +++ b/deebot_client/commands/xml/__init__.py @@ -14,7 +14,7 @@ from .error import GetError from .fan_speed import GetCleanSpeed, SetCleanSpeed from .life_span import GetLifeSpan -from .map import GetMapM, GetMapSet, GetMapSt, PullM, PullMP +from .map import GetMapM, GetMapSet, GetMapSt, GetTrM, PullM, PullMP from .play_sound import PlaySound from .pos import GetPos from .stats import GetCleanSum @@ -39,6 +39,7 @@ "GetMapSet", "GetMapSt", "GetPos", + "GetTrM", "GetWaterBoxInfo", "GetWaterPermeability", "PlaySound", @@ -66,6 +67,7 @@ GetMapSet, GetMapSt, GetPos, + GetTrM, GetWaterBoxInfo, GetWaterPermeability, PlaySound, diff --git a/deebot_client/commands/xml/map.py b/deebot_client/commands/xml/map.py index 36a3c7e34..7f2fdc57a 100644 --- a/deebot_client/commands/xml/map.py +++ b/deebot_client/commands/xml/map.py @@ -260,3 +260,22 @@ def _handle_response( return CommandResult(result.state, result.args) return result + + +class GetTrM(XmlCommandWithMessageHandling): + """GetTrM command. + + Enables trace reporting from the bot. + """ + + NAME = "GetTrM" + + @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": + return HandlingResult.analyse() + return HandlingResult(HandlingState.SUCCESS) diff --git a/deebot_client/hardware/deebot/2pv572.py b/deebot_client/hardware/deebot/2pv572.py index d0d674433..d5c126267 100644 --- a/deebot_client/hardware/deebot/2pv572.py +++ b/deebot_client/hardware/deebot/2pv572.py @@ -33,7 +33,7 @@ ) from deebot_client.commands.xml.charge_state import GetChargeState from deebot_client.commands.xml.error import GetError -from deebot_client.commands.xml.map import GetMapM, GetMapSt +from deebot_client.commands.xml.map import GetMapM, GetMapSt, GetTrM from deebot_client.commands.xml.pos import GetPos from deebot_client.commands.xml.stats import GetCleanSum from deebot_client.commands.xml.water_info import GetWaterBoxInfo, SetWaterPermeability @@ -109,7 +109,7 @@ major=CapabilityEvent(MajorMapEvent, [GetMapM()]), position=CapabilityEvent(PositionsEvent, [GetPos()]), rooms=CapabilityEvent(RoomsEvent, [GetMapSt()]), - trace=CapabilityEvent(MapTraceEvent, []), + trace=CapabilityEvent(MapTraceEvent, [GetTrM()]), ), network=CapabilityEvent(NetworkInfoEvent, [GetNetInfoLegacy()]), play_sound=CapabilityExecute(PlaySound), From e241f9e3cc95d2cc508138a45ae5d089295aab9e Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Tue, 22 Apr 2025 15:41:32 +0200 Subject: [PATCH 28/28] Rollback deprecated change --- deebot_client/command.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/deebot_client/command.py b/deebot_client/command.py index 74069979a..dc0e0cf38 100644 --- a/deebot_client/command.py +++ b/deebot_client/command.py @@ -324,15 +324,8 @@ def _pop_or_raise(name: str, type_: type, data: dict[str, Any]) -> Any: try: return type_(value) except ValueError as err: - if hasattr(type_, "from_xml"): - try: - return type_.from_xml(value) - except ValueError as err2: - msg = f'Could not convert "{value}" of {name} into {type_}' - raise DeebotError(msg) from err2 - else: - msg = f'Could not convert "{value}" of {name} into {type_}' - raise DeebotError(msg) from err + msg = f'Could not convert "{value}" of {name} into {type_}' + raise DeebotError(msg) from err class GetCommand(CommandWithMessageHandling, ABC):