Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions deebot_client/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,9 +323,11 @@ def _pop_or_raise(name: str, type_: type, data: dict[str, Any]) -> Any:
value = data.pop(name)
try:
return type_(value)
except ValueError as err:
msg = f'Could not convert "{value}" of {name} into {type_}'
raise DeebotError(msg) from err
except ValueError:
# TODO: Workaround to map out custom enums
return type_.from_xml(value)
# msg = f'Could not convert "{value}" of {name} into {type_}'
# raise DeebotError(msg) from err


class GetCommand(CommandWithMessageHandling, ABC):
Expand Down
6 changes: 5 additions & 1 deletion deebot_client/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,18 @@
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

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,
}


Expand Down
7 changes: 5 additions & 2 deletions deebot_client/commands/xml/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from .charge import Charge
from .charge_state import GetChargeState
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
Expand All @@ -21,17 +21,20 @@
__all__ = [
"Charge",
"GetChargeState",
"GetCleanSpeed",
"GetCleanSum",
"GetError",
"GetFanSpeed",
"GetLifeSpan",
"GetPos",
"PlaySound",
"SetCleanSpeed",
]

# fmt: off
# ordered by file asc
_COMMANDS: list[type[XmlCommand]] = [
GetCleanSpeed,
SetCleanSpeed,
GetError,
GetLifeSpan,
PlaySound,
Expand Down
49 changes: 45 additions & 4 deletions deebot_client/commands/xml/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,25 @@
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
from deebot_client.message import (
HandlingResult,
HandlingState,
MessageStr,
)

if TYPE_CHECKING:
from deebot_client.event_bus import EventBus
Expand Down Expand Up @@ -81,8 +91,39 @@ 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, response_payload)

@abstractmethod
def _handle_mqtt_p2p(self, event_bus: EventBus, response: dict[str, Any]) -> 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."""
43 changes: 29 additions & 14 deletions deebot_client/commands/xml/fan_speed.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,37 @@

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

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.
Expand All @@ -29,16 +42,18 @@ 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):
"""Set clean speed command."""

if event:
event_bus.notify(event)
return HandlingResult.success()
NAME = "SetCleanSpeed"
get_command = GetCleanSpeed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@edenhaus could you please help here:

I runtime tested get/set methods for my robot and the setting is changed as expected. From runtime testing this feature works as expected. However, there seems to be some typing issue. I think we should fix the issue for this PR before I continue implementing other Set-Commands.

mypy issue:

Incompatible types in assignment (expression has type "type[GetCleanSpeed]", base class "SetCommand" defined the type as "type[GetCommand]")  [assignment]

Could you please help me fixing this issue or better fix this issue directly, such that I have a blueprint for the further PRs? Thanks a lot!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error is correct as GetCleanSpeed is missing the base class GetCommand.

In json I have a class where the required function is implemented.

class JsonGetCommand(
JsonCommandWithMessageHandling, MessageBodyDataDict, GetCommand, ABC
):
"""Json get command."""
@classmethod
def handle_set_args(
cls, event_bus: EventBus, args: dict[str, Any]
) -> HandlingResult:
"""Handle arguments of set command."""
return cls._handle_body_data_dict(event_bus, args)

The best is probably to create also one for XML and then change the base class of GetCleanSpeed from XmlCommandWithMessageHandling to the new class.

Feel free to ask if you need further assistance :)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@edenhaus I implemented it in ca7ed53 analogous to the JSON function. Tests are working and runtime testing is also fine.
However, especially the implementation in GetCleanSpeed->_handle_body_data_dict feels wrong.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_handle_body_data_dict is used in json commands as there the data is a dictionary.

handle_set_args should call the correct function on the GetCommand with the args of the SetCommand to simulate the response of the GetCommand (so we can update HA).
In the case of FanSpeed, you need somehow to create <ctl ret='ok' speed='{speed}'/>. Not sure if you have a generic solution to create it. If that is to complicated you could also create a new abstract function that needs to be implemented in every getCommand. This function is than called with the args of the set command and will notify the event bus

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @edenhaus for your reply! If I understand your message correctly, linking of setCommand and getCommand is only used to re-use the "parse method" of the getCommand to "fake" a response that confirms the set. Wouldn't it be easier to simply send a bus notification event_bus.notify(FanSpeedEvent(FanSpeedLevel(int(data["speed"])))) in SetFanSpeed?

For the current implementation:
I think, I would like to implement the non-generic solution and implement it in every getCommand. At least for now. When implemented a few more getCommands, maybe I can improve the implementation in future.

When implementing it in every getCommand for now, would it be fine to make handle_set_args in XmlGetCommand abstract and implement it in every getCommand for now? Like in dd7a3dd ?

Are there tests to check if this set-/get-mechanism is working as expected? Using the example from readme and calling await bot.execute_command(SetCleanSpeed(FanSpeedLevel.STANDARD)) seems not to call handle_set_args.

@flubshi flubshi Feb 9, 2025

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm,something seem broken in my part of the application. I got:

Traceback (most recent call last):
  File "client.py/deebot_client/mqtt_client.py", line 317, in _handle_p2p
    payload_json = json.loads(payload)
  File "/usr/lib/python3.13/json/__init__.py", line 346, in loads
    return _default_decoder.decode(s)
           ~~~~~~~~~~~~~~~~~~~~~~~^^^
  File "/usr/lib/python3.13/json/decoder.py", line 345, in decode
    obj, end = self.raw_decode(s, idx=_w(s, 0).end())
               ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.13/json/decoder.py", line 363, in raw_decode
    raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

In "client.py/deebot_client/mqtt_client.py", line 317, in _handle_p2p:

payload_json = json.loads(payload)

fails, because payload is b'<ctl speed="strong" />'. Do we have do distinguish between DataType.XML and DataType.JSON there, or do we have to adjust sth else in XML implementation that payload is a JSON that contains the payload as body->data?

@coderabbitai can you please make a suggestion to fix this problem?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mm,something seem broken in my part of the application.

Is this solved? If yes please resolve the conversation

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, unfortunately it is not resolved. :(

Last comment with the traceback is still my current problem. In Line 317, with my current implementation (using this branch, latest commit 07cd5db), it expects JSON to be parse, but payload is b'<ctl speed="strong" />'.

image

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wil look into it

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed it with #820
Please merge dev into this PR and adopt the required changes

_mqtt_params = MappingProxyType({"speed": InitParam(FanSpeedLevel)})

return HandlingResult.analyse()
def __init__(self, speed: FanSpeedLevel | str) -> None:
if isinstance(speed, FanSpeedLevel):
speed = speed.xml_value
super().__init__({"speed": speed})
28 changes: 24 additions & 4 deletions deebot_client/events/fan_speed.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from dataclasses import dataclass
from enum import IntEnum, unique
from typing import Self

from .base import Event

Expand All @@ -12,11 +13,30 @@
class FanSpeedLevel(IntEnum):
"""Enum class for all possible fan speed levels."""

xml_value: str

def __new__(cls, value: int, xml_value: str = "") -> Self:
"""Get new instance."""
obj = int.__new__(cls)
obj._value_ = value
obj.xml_value = xml_value
return obj

@classmethod
def from_xml(cls, value: str) -> FanSpeedLevel:
"""Get FanSpeedLevel from xml value."""
for fan_speed_level in FanSpeedLevel:
if fan_speed_level.xml_value == value:
return fan_speed_level

msg = f"{value} is not a valid {cls.__name__}"
raise ValueError(msg)

# Values should be sort from low to high on their meanings
QUIET = 1000
NORMAL = 0
MAX = 1
MAX_PLUS = 2
QUIET = 1000, ""
NORMAL = 0, "standard"
MAX = 1, "strong"
MAX_PLUS = 2, ""


@dataclass(frozen=True)
Expand Down
33 changes: 25 additions & 8 deletions tests/commands/xml/test_fan_speed.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,21 +24,38 @@
],
ids=["standard", "strong"],
)
async def test_get_fan_speed(speed: str, expected_event: Event) -> None:
async def test_get_clean_speed(speed: str, expected_event: Event) -> None:
json = get_request_xml(f"<ctl ret='ok' speed='{speed}'/>")
await assert_command(GetFanSpeed(), json, expected_event)
await assert_command(GetCleanSpeed(), json, expected_event)


@pytest.mark.parametrize(
"xml",
["<ctl ret='error'/>", "<ctl ret='ok' speed='invalid'/>"],
ids=["error", "no_state"],
["<ctl ret='error'/>"],
ids=["error"],
)
async def test_get_fan_speed_error(xml: str) -> None:
async def test_get_clean_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),
"<ctl ret='ok' />",
HandlingState.SUCCESS,
),
],
)
async def test_set_clean_speed(
command: CommandWithMessageHandling, xml: str, result: HandlingState
) -> None:
json = get_request_xml(xml)
await assert_command(command, json, None, command_result=CommandResult(result))