Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add XML command "SetFanSpeed" #560

Open
wants to merge 15 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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 @@ -8,7 +8,7 @@

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 @@ -19,17 +19,20 @@

__all__ = [
"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
26 changes: 23 additions & 3 deletions deebot_client/commands/xml/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,24 @@
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,
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 @@ -86,3 +95,14 @@ class XmlSetCommand(ExecuteCommand, SetCommand, ABC):

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."""
38 changes: 32 additions & 6 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(int(args["speed"]))))
return HandlingResult.success()

Check warning on line 34 in deebot_client/commands/xml/fan_speed.py

View check run for this annotation

Codecov / codecov/patch

deebot_client/commands/xml/fan_speed.py#L33-L34

Added lines #L33 - L34 were not covered by tests

@classmethod
def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult:
"""Handle xml message and notify the correct event subscribers.
Expand All @@ -33,12 +46,25 @@

match speed.lower():
case "standard":
event = FanSpeedEvent(FanSpeedLevel.NORMAL)
event = FanSpeedEvent(FanSpeedLevel.STANDARD)
case "strong":
event = FanSpeedEvent(FanSpeedLevel.MAX)
event = FanSpeedEvent(FanSpeedLevel.STRONG)

if event:
event_bus.notify(event)
return HandlingResult.success()

return HandlingResult.analyse()


class SetCleanSpeed(XmlSetCommand):
"""Set clean speed command."""

NAME = "SetCleanSpeed"
get_command = GetCleanSpeed
Copy link
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
Contributor

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
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
Contributor

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
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.

Copy link
Contributor

@edenhaus edenhaus Jan 24, 2025

Choose a reason for hiding this comment

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

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 ?

That is perfectly fine :)

Wouldn't it be easier to simply send a bus notification event_bus.notify(FanSpeedEvent(FanSpeedLevel(int(data["speed"])))) in SetFanSpeed?

No, as this would work only for commands sent by HA. For example, the mqtt client subscribes to any command sent to the bot and if he detects a successful command sent to the bot, it will call handle_set_args with the arguments of the command.

command.handle_mqtt_p2p(sub_info.events, data)

(Just realized that we currently subscribe only for json commands, but that we should fix in a different PR) Will be fixed with #779, but I your feedback for it

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.

I'm simulating it in the test by manually calling the mqtt function

command.handle_mqtt_p2p(event_bus, json)
event_bus.notify.assert_not_called()
# Success
command.handle_mqtt_p2p(event_bus, get_message_json(get_success_body()))

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Now, since you @edenhaus merged #779 and I merged dev into this Branch, I get

DEBUG:deebot_client.mqtt_client.client:Received PUBLISH (d0, q0, r0, m0), 'iot/p2p/SetCleanSpeed/HelperMQClientId-awseu-sts-ngiot-mqsjmq-59/ecosys/1234/1234a07a-40ec-4b82-abcd-123456789012/ls1ok3/Y1OW/q/pUgV/x', ...  (24 bytes)
DEBUG:deebot_client.mqtt_client:Got message: topic=iot/p2p/SetCleanSpeed/HelperMQClientId-awseu-sts-ngiot-mqsjmq-59/ecosys/1234/1234a07a-40ec-4b82-abcd-123456789012/ls1ok3/Y1OW/q/pUgV/x, payload=b'<ctl speed="standard" />'
DEBUG:deebot_client.mqtt_client:Command SetCleanSpeed does not support p2p handling (yet)
DEBUG:deebot_client.mqtt_client.client:Received PUBLISH (d0, q0, r0, m0), 'iot/p2p/SetCleanSpeed/1234a07a-40ec-4b82-abcd-123456789012/ls1ok3/Y1OW/HelperMQClientId-awseu-sts-ngiot-mqsjmq-59/ecosys/1234/p/pUgV/x', ...  (15 bytes)
DEBUG:deebot_client.mqtt_client:Got message: topic=iot/p2p/SetCleanSpeed/1234a07a-40ec-4b82-abcd-123456789012/ls1ok3/Y1OW/HelperMQClientId-awseu-sts-ngiot-mqsjmq-59/ecosys/1234/p/pUgV/x, payload=b"<ctl ret='ok'/>"
DEBUG:deebot_client.mqtt_client:Command SetCleanSpeed does not support p2p handling (yet)

There seems something missing in this PR, since I would expected that the implementation handles p2p. But log contains Command SetCleanSpeed does not support p2p handling (yet).

Copy link
Contributor

Choose a reason for hiding this comment

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

p2p was not enabled for xml. I did it for you with the commit 4c6e862 in this PR

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

def __init__(self, speed: FanSpeedLevel | str) -> None:
if isinstance(speed, FanSpeedLevel):
speed = speed.name.lower()
super().__init__({"speed": speed})
2 changes: 2 additions & 0 deletions deebot_client/events/fan_speed.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ class FanSpeedLevel(IntEnum):

# Values should be sort from low to high on their meanings
QUIET = 1000
STANDARD = -1
flubshi marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we do the same as what we did with the Lifespan enum

NORMAL = 0
MAX = 1
STRONG = -2
flubshi marked this conversation as resolved.
Show resolved Hide resolved
MAX_PLUS = 2


Expand Down
34 changes: 26 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 @@ -19,26 +19,44 @@
@pytest.mark.parametrize(
("speed", "expected_event"),
[
("standard", FanSpeedEvent(FanSpeedLevel.NORMAL)),
("strong", FanSpeedEvent(FanSpeedLevel.MAX)),
("standard", FanSpeedEvent(FanSpeedLevel.STANDARD)),
("strong", FanSpeedEvent(FanSpeedLevel.STRONG)),
],
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"],
)
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.STRONG),
"<ctl ret='ok' />",
HandlingState.SUCCESS,
),
(SetCleanSpeed("invalid"), "<ctl ret='error' />", HandlingState.FAILED),
],
)
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))
Loading