Skip to content

Commit fa79905

Browse files
authored
Implement XML Command GetCleanLogs (#910)
1 parent d7557ca commit fa79905

File tree

3 files changed

+175
-0
lines changed

3 files changed

+175
-0
lines changed

deebot_client/commands/xml/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from .battery import GetBatteryInfo
1010
from .charge import Charge
1111
from .charge_state import GetChargeState
12+
from .clean_logs import GetCleanLogs
1213
from .error import GetError
1314
from .fan_speed import GetFanSpeed
1415
from .life_span import GetLifeSpan
@@ -23,6 +24,7 @@
2324
"Charge",
2425
"GetBatteryInfo",
2526
"GetChargeState",
27+
"GetCleanLogs",
2628
"GetCleanSum",
2729
"GetError",
2830
"GetFanSpeed",
@@ -35,6 +37,7 @@
3537
# ordered by file asc
3638
_COMMANDS: list[type[XmlCommand]] = [
3739
GetBatteryInfo,
40+
GetCleanLogs,
3841
GetError,
3942
GetLifeSpan,
4043
PlaySound,
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""Clean Logs commands."""
2+
3+
from __future__ import annotations
4+
5+
from enum import StrEnum, unique
6+
from typing import TYPE_CHECKING, Self
7+
8+
from deebot_client.command import CommandResult
9+
from deebot_client.events import (
10+
CleanJobStatus,
11+
CleanLogEntry,
12+
CleanLogEvent,
13+
)
14+
from deebot_client.logging_filter import get_logger
15+
from deebot_client.message import HandlingResult
16+
17+
from .common import XmlCommandWithMessageHandling
18+
19+
if TYPE_CHECKING:
20+
from xml.etree.ElementTree import Element
21+
22+
from deebot_client.event_bus import EventBus
23+
24+
_LOGGER = get_logger(__name__)
25+
26+
27+
@unique
28+
class XmlStopReason(StrEnum):
29+
"""XML Reasons why cleaning has been stopped."""
30+
31+
clean_job_status: CleanJobStatus
32+
33+
def __new__(cls, value: str, clean_job_status: CleanJobStatus) -> Self:
34+
"""Create new XmlStopReason."""
35+
obj = str.__new__(cls, value)
36+
obj._value_ = value
37+
obj.clean_job_status = clean_job_status
38+
return obj
39+
40+
FINISHED = "s", CleanJobStatus.FINISHED
41+
BATTERY_LOW = "r", CleanJobStatus.FINISHED_WITH_WARNINGS
42+
STOPPED_BY_APP = "a", CleanJobStatus.MANUALLY_STOPPED
43+
STOPPED_BY_REMOTE_CONTROL = "i", CleanJobStatus.MANUALLY_STOPPED
44+
STOPPED_BY_BUTTON = "b", CleanJobStatus.MANUALLY_STOPPED
45+
STOPPED_BY_WARNING = "w", CleanJobStatus.FINISHED_WITH_WARNINGS
46+
STOPPED_BY_NO_DISTURB = "f", CleanJobStatus.FINISHED_WITH_WARNINGS
47+
STOPPED_BY_CLEARMAP = "m", CleanJobStatus.FINISHED_WITH_WARNINGS
48+
STOPPED_BY_NO_PATH = "n", CleanJobStatus.FINISHED_WITH_WARNINGS
49+
STOPPED_BY_NOT_IN_MAP = "u", CleanJobStatus.FINISHED_WITH_WARNINGS
50+
STOPPED_BY_VIRTUAL_WALL = "v", CleanJobStatus.FINISHED_WITH_WARNINGS
51+
52+
@classmethod
53+
def from_value(cls, value: str) -> XmlStopReason:
54+
"""Fetch the right enum member given its string value."""
55+
for elem in cls.__members__.values():
56+
if elem.value == value:
57+
return elem
58+
raise ValueError(value)
59+
60+
61+
class GetCleanLogs(XmlCommandWithMessageHandling):
62+
"""GetCleanLogs command."""
63+
64+
NAME = "GetCleanLogs"
65+
66+
def __init__(self, count: int = 0) -> None:
67+
super().__init__({"count": str(count)})
68+
69+
@classmethod
70+
def _handle_xml(cls, event_bus: EventBus, xml: Element) -> HandlingResult:
71+
"""Handle xml message and notify the correct event subscribers.
72+
73+
:return: A message response
74+
"""
75+
if xml.attrib.get("ret") != "ok":
76+
return HandlingResult.analyse()
77+
78+
resp_logs = xml.findall("CleanSt")
79+
logs: list[CleanLogEntry] = []
80+
for log in resp_logs:
81+
xml_stop_reason_attrib = str(log.attrib["f"])
82+
stop_reason = XmlStopReason.FINISHED
83+
try:
84+
stop_reason = XmlStopReason.from_value(xml_stop_reason_attrib)
85+
except ValueError as e:
86+
_LOGGER.error(
87+
"Could not decode stop reason: %s",
88+
xml_stop_reason_attrib,
89+
exc_info=e,
90+
)
91+
try:
92+
logs.append(
93+
CleanLogEntry(
94+
timestamp=int(log.attrib["s"]),
95+
image_url="", # Not available
96+
type=log.attrib["t"],
97+
area=int(log.attrib["a"]),
98+
stop_reason=stop_reason.clean_job_status,
99+
duration=int(log.attrib["l"]),
100+
)
101+
)
102+
except Exception: # pylint: disable = broad-exception-caught
103+
_LOGGER.warning("Skipping log entry: %s", log, exc_info=True)
104+
event_bus.notify(CleanLogEvent(logs))
105+
return CommandResult.success()
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
import pytest
6+
7+
from deebot_client.command import CommandResult
8+
from deebot_client.commands.xml import GetCleanLogs
9+
from deebot_client.events import CleanJobStatus, CleanLogEntry, CleanLogEvent
10+
from deebot_client.message import HandlingState
11+
from tests.commands import assert_command
12+
13+
from . import get_request_xml
14+
15+
if TYPE_CHECKING:
16+
from deebot_client.events.base import Event
17+
18+
19+
@pytest.mark.parametrize(
20+
("payload", "expected_event"),
21+
[
22+
(
23+
"<CleanSt a='0' s='1710433763' l='14' t='' f='s'/><CleanSt a='1' s='1710433269' l='22' t='' f='s'/>",
24+
CleanLogEvent(
25+
[
26+
CleanLogEntry(1710433763, "", "", 0, CleanJobStatus.FINISHED, 14),
27+
CleanLogEntry(1710433269, "", "", 1, CleanJobStatus.FINISHED, 22),
28+
]
29+
),
30+
),
31+
(
32+
"<CleanSt a='20' s='1710244976' l='1392' t='a' f='a'/>"
33+
"<CleanSt a='wrong' s='1710083567' l='894' t='a' f='a'/>"
34+
"<CleanSt a='21' s='1710244999' l='2392' t='a' f='????'/>",
35+
CleanLogEvent(
36+
[
37+
CleanLogEntry(
38+
1710244976, "", "a", 20, CleanJobStatus.MANUALLY_STOPPED, 1392
39+
),
40+
CleanLogEntry(
41+
1710244999, "", "a", 21, CleanJobStatus.FINISHED, 2392
42+
),
43+
]
44+
),
45+
),
46+
("", CleanLogEvent([])),
47+
],
48+
ids=["finished_two_areas", "skipping_invalid_data", "no_data"],
49+
)
50+
async def test_get_clean_logs(payload: str, expected_event: Event | None) -> None:
51+
json = get_request_xml(f"<ctl ret='ok'>{payload}</ctl>")
52+
await assert_command(GetCleanLogs(count=3), json, expected_event)
53+
54+
55+
@pytest.mark.parametrize(
56+
"xml",
57+
["<ctl ret='error'/>"],
58+
ids=["error"],
59+
)
60+
async def test_get_clean_logs_error(xml: str) -> None:
61+
json = get_request_xml(xml)
62+
await assert_command(
63+
GetCleanLogs(),
64+
json,
65+
None,
66+
command_result=CommandResult(HandlingState.ANALYSE_LOGGED),
67+
)

0 commit comments

Comments
 (0)