Skip to content

Commit dd46cd3

Browse files
committed
Add support for XML P2P Commands
1 parent fa79905 commit dd46cd3

6 files changed

Lines changed: 220 additions & 13 deletions

File tree

deebot_client/command.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,9 @@ def _create_from_mqtt(cls, data: dict[str, Any]) -> CommandMqttP2P:
307307
data.pop(name, None)
308308
else:
309309
try:
310-
values[param.name or name] = _pop_or_raise(name, param.type_, data)
310+
values[param.name or name] = cls._pop_or_raise(
311+
name, param.type_, data
312+
)
311313
except KeyError as err:
312314
if not param.optional:
313315
msg = f'"{name}" is missing in {data}'
@@ -318,14 +320,18 @@ def _create_from_mqtt(cls, data: dict[str, Any]) -> CommandMqttP2P:
318320

319321
return cls(**values)
320322

323+
@classmethod
324+
def _pop_or_raise(cls, name: str, type_: type, data: dict[str, Any]) -> Any:
325+
value = data.pop(name)
326+
try:
327+
return cls._decode(type_, value)
328+
except ValueError as err:
329+
msg = f'Could not convert "{value}" of {name} into {type_}'
330+
raise DeebotError(msg) from err
321331

322-
def _pop_or_raise(name: str, type_: type, data: dict[str, Any]) -> Any:
323-
value = data.pop(name)
324-
try:
332+
@classmethod
333+
def _decode(cls, type_: type, value: Any) -> Any:
325334
return type_(value)
326-
except ValueError as err:
327-
msg = f'Could not convert "{value}" of {name} into {type_}'
328-
raise DeebotError(msg) from err
329335

330336

331337
class GetCommand(CommandWithMessageHandling, ABC):

deebot_client/commands/__init__.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,22 @@
1111
COMMANDS as JSON_COMMANDS,
1212
COMMANDS_WITH_MQTT_P2P_HANDLING as JSON_COMMANDS_WITH_MQTT_P2P_HANDLING,
1313
)
14+
from .xml import (
15+
COMMANDS as XML_COMMANDS,
16+
COMMANDS_WITH_MQTT_P2P_HANDLING as XML_COMMANDS_WITH_MQTT_P2P_HANDLING,
17+
)
1418

1519
if TYPE_CHECKING:
1620
from deebot_client.command import Command, CommandMqttP2P
1721

18-
COMMANDS: dict[DataType, dict[str, type[Command]]] = {DataType.JSON: JSON_COMMANDS}
22+
COMMANDS: dict[DataType, dict[str, type[Command]]] = {
23+
DataType.JSON: JSON_COMMANDS,
24+
DataType.XML: XML_COMMANDS,
25+
}
1926

2027
COMMANDS_WITH_MQTT_P2P_HANDLING: dict[DataType, dict[str, type[CommandMqttP2P]]] = {
21-
DataType.JSON: JSON_COMMANDS_WITH_MQTT_P2P_HANDLING
28+
DataType.JSON: JSON_COMMANDS_WITH_MQTT_P2P_HANDLING,
29+
DataType.XML: XML_COMMANDS_WITH_MQTT_P2P_HANDLING,
2230
}
2331

2432

deebot_client/commands/xml/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,15 @@
3636
# fmt: off
3737
# ordered by file asc
3838
_COMMANDS: list[type[XmlCommand]] = [
39+
Charge,
3940
GetBatteryInfo,
41+
GetChargeState,
4042
GetCleanLogs,
43+
GetCleanSum,
4144
GetError,
45+
GetFanSpeed,
4246
GetLifeSpan,
47+
GetPos,
4348
PlaySound,
4449
]
4550
# fmt: on

deebot_client/commands/xml/common.py

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,24 @@
33
from __future__ import annotations
44

55
from abc import ABC, abstractmethod
6-
from typing import TYPE_CHECKING, cast
6+
from typing import TYPE_CHECKING, cast, override
77
from xml.etree.ElementTree import Element, SubElement
88

99
from defusedxml import ElementTree # type: ignore[import-untyped]
1010

11-
from deebot_client.command import Command, CommandWithMessageHandling, SetCommand
11+
from deebot_client.command import (
12+
Command,
13+
CommandMqttP2P,
14+
CommandWithMessageHandling,
15+
SetCommand,
16+
)
1217
from deebot_client.const import DataType
1318
from deebot_client.logging_filter import get_logger
1419
from deebot_client.message import HandlingResult, HandlingState, MessageStr
1520

1621
if TYPE_CHECKING:
22+
from typing import Any
23+
1724
from deebot_client.event_bus import EventBus
1825

1926
_LOGGER = get_logger(__name__)
@@ -76,11 +83,52 @@ def _handle_xml(cls, _: EventBus, xml: Element) -> HandlingResult:
7683
return HandlingResult.success()
7784

7885
_LOGGER.warning(
79-
'Command "%s" was not successful. XML response: %s', cls.NAME, xml
86+
'Command "%s" was not successful. XML response: %s',
87+
cls.NAME,
88+
ElementTree.tostring(xml, "unicode"),
8089
)
8190
return HandlingResult(HandlingState.FAILED)
8291

8392

93+
class XmlCommandMqttP2P(XmlCommand, CommandMqttP2P, ABC):
94+
"""Json base command for mqtt p2p channel."""
95+
96+
@classmethod
97+
def create_from_mqtt(cls, payload: str | bytes | bytearray) -> CommandMqttP2P:
98+
"""Create a command from the mqtt data."""
99+
xml = ElementTree.fromstring(payload)
100+
return cls._create_from_mqtt(xml.attrib)
101+
102+
@override
103+
def handle_mqtt_p2p(
104+
self, event_bus: EventBus, response_payload: str | bytes | bytearray
105+
) -> None:
106+
"""Handle response received over the mqtt channel "p2p"."""
107+
if isinstance(response_payload, bytearray):
108+
data = bytes(response_payload).decode()
109+
elif isinstance(response_payload, bytes):
110+
data = response_payload.decode()
111+
elif isinstance(response_payload, str):
112+
data = response_payload
113+
else:
114+
msg = "Unsupported message data type {message_type}" # type: ignore[unreachable]
115+
raise TypeError(msg.format(message_type=type(response_payload)))
116+
117+
self._handle_mqtt_p2p(event_bus, data)
118+
119+
@classmethod
120+
def _decode(cls, type_: type, value: Any) -> Any:
121+
if hasattr(type_, "from_xml"):
122+
return type_.from_xml(value)
123+
return type_(value)
124+
125+
@abstractmethod
126+
def _handle_mqtt_p2p(
127+
self, event_bus: EventBus, response: dict[str, Any] | str
128+
) -> None:
129+
"""Handle response received over the mqtt channel "p2p"."""
130+
131+
84132
class XmlSetCommand(ExecuteCommand, SetCommand, ABC):
85133
"""Xml base set command.
86134

tests/commands/xml/__init__.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,65 @@
11
from __future__ import annotations
22

3-
from typing import Any
3+
from typing import TYPE_CHECKING, Any, cast
4+
from xml.etree.ElementTree import Element, SubElement
5+
6+
from defusedxml import ElementTree # type: ignore[import-untyped]
7+
from testfixtures import LogCapture
8+
9+
from deebot_client.command import CommandResult
10+
from deebot_client.message import HandlingState
11+
from tests.commands import assert_command
12+
13+
if TYPE_CHECKING:
14+
from deebot_client.commands.xml.common import ExecuteCommand
15+
16+
17+
def get_success_body(
18+
extra_attrs: dict[str, Any] | None = None, sub_element_name: str | None = None
19+
) -> str:
20+
element = ctl_element = Element("ctl")
21+
element.set("ret", "ok")
22+
23+
if extra_attrs is not None and len(extra_attrs) > 0:
24+
if sub_element_name is not None:
25+
element = SubElement(element, sub_element_name.lower())
26+
27+
if isinstance(extra_attrs, dict):
28+
for key, value in extra_attrs.items():
29+
element.set(key, value)
30+
31+
return cast("str", ElementTree.tostring(ctl_element, "unicode"))
32+
33+
34+
def get_failure_body() -> str:
35+
return '<ctl ret="error" />'
36+
37+
38+
async def assert_execute_command(
39+
command: ExecuteCommand, args: dict[str, Any] | list[Any] | None
40+
) -> None:
41+
assert command.NAME != "invalid"
42+
assert command._args == args
43+
44+
# success
45+
xml = get_request_xml(get_success_body())
46+
await assert_command(command, xml, None)
47+
48+
# failed
49+
with LogCapture() as log:
50+
body = get_failure_body()
51+
xml = get_request_xml(body)
52+
await assert_command(
53+
command, xml, None, command_result=CommandResult(HandlingState.FAILED)
54+
)
55+
56+
log.check_present(
57+
(
58+
"deebot_client.commands.xml.common",
59+
"WARNING",
60+
f'Command "{command.NAME}" was not successful. XML response: {body}',
61+
)
62+
)
463

564

665
def get_request_xml(data: str | None) -> dict[str, Any]:

tests/commands/xml/test_common.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
from __future__ import annotations
2+
3+
from types import MappingProxyType
4+
from typing import Any
5+
from unittest.mock import Mock, patch
6+
7+
import pytest
8+
9+
from deebot_client.command import CommandResult, InitParam
10+
from deebot_client.commands.xml.common import XmlCommandMqttP2P
11+
from deebot_client.event_bus import EventBus
12+
from deebot_client.events import LifeSpan
13+
14+
15+
@pytest.mark.parametrize(
16+
("payload", "decoded_payload"),
17+
[
18+
(bytearray(bytes("test", "utf-8")), "test"),
19+
(bytes("test", "utf-8"), "test"),
20+
("test", "test"),
21+
],
22+
ids=["bytearray", "bytes", "str"],
23+
)
24+
@patch.multiple(XmlCommandMqttP2P, __abstractmethods__=set())
25+
def test_XmlCommandMqttP2P_decoding(
26+
payload: bytearray | bytes | str, decoded_payload: str
27+
) -> None:
28+
command = XmlCommandMqttP2P() # type: ignore[abstract]
29+
event_bus = Mock(spec_set=EventBus)
30+
with patch.object(command, "_handle_mqtt_p2p", return_value=None) as mqtt_handler:
31+
command.handle_mqtt_p2p(event_bus, payload)
32+
33+
mqtt_handler.assert_called_once_with(event_bus, decoded_payload)
34+
35+
36+
@patch.multiple(XmlCommandMqttP2P, __abstractmethods__=set())
37+
def test_XmlCommandMqttP2P_invalid_decoding() -> None:
38+
command = XmlCommandMqttP2P() # type: ignore[abstract]
39+
event_bus = Mock(spec_set=EventBus)
40+
with (
41+
patch.object(command, "_handle_mqtt_p2p", return_value=None),
42+
pytest.raises(TypeError),
43+
):
44+
command.handle_mqtt_p2p(event_bus, {}) # type: ignore[arg-type]
45+
46+
47+
@pytest.mark.parametrize(
48+
("command_payload", "payload_type", "expected_argument"),
49+
[
50+
("test", str, "test"),
51+
(LifeSpan.BRUSH.xml_value, LifeSpan, LifeSpan.BRUSH),
52+
],
53+
ids=["native", "with xml_value"],
54+
)
55+
def test_XmlCommandMqttP2P_create_from_mqtt(
56+
command_payload: str, payload_type: type, expected_argument: type
57+
) -> None:
58+
class Sut(XmlCommandMqttP2P):
59+
NAME = "Sut"
60+
_mqtt_params = MappingProxyType(
61+
{"payload": InitParam(payload_type), "remove": None}
62+
)
63+
64+
def __init__(self, payload: Any) -> None:
65+
super().__init__(args={"payload": payload})
66+
67+
def _handle_mqtt_p2p(
68+
self, _event_bus: EventBus, _response: dict[str, Any] | str
69+
) -> None:
70+
pass
71+
72+
def _handle_response(
73+
self, _event_bus: EventBus, _response: dict[str, Any]
74+
) -> CommandResult:
75+
return CommandResult.analyse()
76+
77+
xml_message = f"<ctl ret='ok' payload='{command_payload}' remove='42' />"
78+
79+
sut = Sut.create_from_mqtt(xml_message)
80+
81+
assert sut._args == {"payload": expected_argument}

0 commit comments

Comments
 (0)