Skip to content

Commit 7ad548a

Browse files
authored
Improve roborock update handling (#1685)
Not all devices support all features, but we have currently no way of knowing what is supported. In order to allow the embedding of all supported information in the status container while avoiding making unnecessary I/O on subsequent queries, this introduces a small helper to do just that. The initial status() call will call all defined devicestatus-returning methods to find out which information is acquired correctly, and skip the unsupported queries in the following update cycles. This also embeds some more information (last clean details, mop dryer settings).
1 parent 8d24738 commit 7ad548a

File tree

5 files changed

+132
-29
lines changed

5 files changed

+132
-29
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from unittest.mock import MagicMock
2+
3+
from miio import DeviceException
4+
5+
from ..updatehelper import UpdateHelper
6+
7+
8+
def test_updatehelper():
9+
"""Test that update helper removes erroring methods from future updates."""
10+
main_status = MagicMock()
11+
second_status = MagicMock()
12+
unsupported = MagicMock(side_effect=DeviceException("Broken"))
13+
helper = UpdateHelper(main_status)
14+
helper.add_update_method("working", second_status)
15+
helper.add_update_method("broken", unsupported)
16+
17+
helper.status()
18+
19+
main_status.assert_called_once()
20+
second_status.assert_called_once()
21+
unsupported.assert_called_once()
22+
23+
# perform second update
24+
helper.status()
25+
26+
assert main_status.call_count == 2
27+
assert second_status.call_count == 2
28+
assert unsupported.call_count == 1

miio/integrations/roborock/vacuum/tests/test_vacuum.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44

55
import pytest
66

7-
from miio import RoborockVacuum, UnsupportedFeatureException
8-
from miio.tests.dummies import DummyDevice
7+
from miio import DeviceError, RoborockVacuum, UnsupportedFeatureException
8+
from miio.tests.dummies import DummyDevice, DummyMiIOProtocol
99

10+
from ..updatehelper import UpdateHelper
1011
from ..vacuum import (
1112
ROCKROBO_Q7_MAX,
1213
ROCKROBO_S7,
@@ -18,6 +19,20 @@
1819
from ..vacuumcontainers import VacuumStatus
1920

2021

22+
class DummyRoborockProtocol(DummyMiIOProtocol):
23+
"""Roborock-specific dummy protocol handler.
24+
25+
The vacuum reports 'unknown_method' instead of device error for unknown commands.
26+
"""
27+
28+
def send(self, command: str, parameters=None, retry_count=3, extra_parameters=None):
29+
"""Overridden send() to return values from `self.return_values`."""
30+
try:
31+
return super().send(command, parameters, retry_count, extra_parameters)
32+
except DeviceError:
33+
return "unknown_method"
34+
35+
2136
class DummyVacuum(DummyDevice, RoborockVacuum):
2237
STATE_CHARGING = 8
2338
STATE_CLEANING = 5
@@ -48,7 +63,7 @@ def __init__(self, *args, **kwargs):
4863
}
4964
self._maps = None
5065
self._map_enum_cache = None
51-
66+
self._status_helper = UpdateHelper(self.vacuum_status)
5267
self.dummies = {
5368
"consumables": [
5469
{
@@ -138,6 +153,7 @@ def __init__(self, *args, **kwargs):
138153
}
139154

140155
super().__init__(args, kwargs)
156+
self._protocol = DummyRoborockProtocol(self)
141157

142158
def set_water_box_custom_mode_callback(self, parameters):
143159
assert parameters == self.dummies["water_box_custom_mode"]
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import logging
2+
from typing import Callable, Dict
3+
4+
from miio import DeviceException, DeviceStatus
5+
6+
_LOGGER = logging.getLogger(__name__)
7+
8+
9+
class UpdateHelper:
10+
"""Helper class to construct status containers using multiple status methods.
11+
12+
This is used to perform status fetching on integrations that require calling
13+
multiple methods, some of which may not be supported by the target device.
14+
15+
This class automatically removes the methods that failed from future updates,
16+
to avoid unnecessary device I/O.
17+
"""
18+
19+
def __init__(self, main_update_method: Callable):
20+
self._update_methods: Dict[str, Callable] = {}
21+
self._main_update_method = main_update_method
22+
23+
def add_update_method(self, name: str, update_method: Callable):
24+
"""Add status method to be called."""
25+
_LOGGER.debug(f"Adding {name} to update cycle: {update_method}")
26+
self._update_methods[name] = update_method
27+
28+
def status(self) -> DeviceStatus:
29+
statuses = self._update_methods.copy()
30+
main_status = self._main_update_method()
31+
for name, method in statuses.items():
32+
try:
33+
main_status.embed(name, method())
34+
_LOGGER.debug(f"Success for {name}")
35+
except DeviceException as ex:
36+
_LOGGER.debug(
37+
"Unable to query %s, removing from next query: %s", name, ex
38+
)
39+
self._update_methods.pop(name)
40+
41+
return main_status

miio/integrations/roborock/vacuum/vacuum.py

Lines changed: 37 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
import os
88
import pathlib
99
import time
10-
from typing import List, Optional, Type, Union
10+
from enum import Enum
11+
from typing import Any, List, Optional, Type
1112

1213
import click
1314
import pytz
@@ -21,10 +22,11 @@
2122
command,
2223
)
2324
from miio.device import Device, DeviceInfo
24-
from miio.devicestatus import action
25+
from miio.devicestatus import DeviceStatus, action
2526
from miio.exceptions import DeviceInfoUnavailableException, UnsupportedFeatureException
2627
from miio.interfaces import FanspeedPresets, VacuumInterface
2728

29+
from .updatehelper import UpdateHelper
2830
from .vacuum_enums import (
2931
CarpetCleaningMode,
3032
Consumable,
@@ -143,6 +145,33 @@ def __init__(
143145
self.manual_seqnum = -1
144146
self._maps: Optional[MapList] = None
145147
self._map_enum_cache = None
148+
self._status_helper = UpdateHelper(self.vacuum_status)
149+
self._status_helper.add_update_method("consumables", self.consumable_status)
150+
self._status_helper.add_update_method("dnd_status", self.dnd_status)
151+
self._status_helper.add_update_method("clean_history", self.clean_history)
152+
self._status_helper.add_update_method("last_clean", self.last_clean_details)
153+
self._status_helper.add_update_method("mop_dryer", self.mop_dryer_settings)
154+
155+
def send(
156+
self,
157+
command: str,
158+
parameters: Optional[Any] = None,
159+
retry_count: Optional[int] = None,
160+
*,
161+
extra_parameters=None,
162+
) -> Any:
163+
"""Send command to the device.
164+
165+
This is overridden to raise an exception on unknown methods.
166+
"""
167+
res = super().send(
168+
command, parameters, retry_count, extra_parameters=extra_parameters
169+
)
170+
if res == "unknown_method":
171+
raise UnsupportedFeatureException(
172+
f"Command {command} is not supported by the device"
173+
)
174+
return res
146175

147176
@command()
148177
def start(self):
@@ -335,13 +364,9 @@ def manual_control(
335364
self.send("app_rc_move", [params])
336365

337366
@command()
338-
def status(self) -> VacuumStatus:
367+
def status(self) -> DeviceStatus:
339368
"""Return status of the vacuum."""
340-
status = self.vacuum_status()
341-
status.embed("consumables", self.consumable_status())
342-
status.embed("cleaning_history", self.clean_history())
343-
status.embed("dnd", self.dnd_status())
344-
return status
369+
return self._status_helper.status()
345370

346371
@command()
347372
def vacuum_status(self) -> VacuumStatus:
@@ -382,7 +407,7 @@ def get_maps(self) -> MapList:
382407
self._maps = MapList(self.send("get_multi_maps_list")[0])
383408
return self._maps
384409

385-
def _map_enum(self) -> Optional[enum.Enum]:
410+
def _map_enum(self) -> Optional[Type[Enum]]:
386411
"""Enum of the available map names."""
387412
if self._map_enum_cache is not None:
388413
return self._map_enum_cache
@@ -508,9 +533,7 @@ def last_clean_details(self) -> Optional[CleaningDetails]:
508533
@command(
509534
click.argument("id_", type=int, metavar="ID"),
510535
)
511-
def clean_details(
512-
self, id_: int
513-
) -> Union[List[CleaningDetails], Optional[CleaningDetails]]:
536+
def clean_details(self, id_: int) -> Optional[CleaningDetails]:
514537
"""Return details about specific cleaning."""
515538
details = self.send("get_clean_record", [id_])
516539

@@ -583,7 +606,7 @@ def update_timer(self, timer_id: str, mode: TimerState):
583606
return self.send("upd_timer", [timer_id, mode.value])
584607

585608
@command()
586-
def dnd_status(self):
609+
def dnd_status(self) -> DNDStatus:
587610
"""Returns do-not-disturb status."""
588611
# {'result': [{'enabled': 1, 'start_minute': 0, 'end_minute': 0,
589612
# 'start_hour': 22, 'end_hour': 8}], 'id': 1}
@@ -760,7 +783,7 @@ def configure_wifi(self, ssid, password, uid=0, timezone=None):
760783
return super().configure_wifi(ssid, password, uid, extra_params)
761784

762785
@command()
763-
def carpet_mode(self):
786+
def carpet_mode(self) -> CarpetModeStatus:
764787
"""Get carpet mode settings."""
765788
return CarpetModeStatus(self.send("get_carpet_mode")[0])
766789

@@ -975,28 +998,19 @@ def set_child_lock(self, lock: bool) -> bool:
975998
"""Set child lock setting."""
976999
return self.send("set_child_lock_status", {"lock_status": int(lock)})[0] == "ok"
9771000

978-
def _verify_mop_dryer_supported(self) -> None:
979-
"""Checks if model supports mop dryer add-on."""
980-
# dryer add-on is only supported by following models
981-
if self.model not in [ROCKROBO_S7, ROCKROBO_S7_MAXV]:
982-
raise UnsupportedFeatureException("Dryer not supported by %s", self.model)
983-
9841001
@command()
9851002
def mop_dryer_settings(self) -> MopDryerSettings:
9861003
"""Get mop dryer settings."""
987-
self._verify_mop_dryer_supported()
9881004
return MopDryerSettings(self.send("app_get_dryer_setting"))
9891005

9901006
@command(click.argument("enabled", type=bool))
9911007
def set_mop_dryer_enabled(self, enabled: bool) -> bool:
9921008
"""Set mop dryer add-on enabled."""
993-
self._verify_mop_dryer_supported()
9941009
return self.send("app_set_dryer_setting", {"status": int(enabled)})[0] == "ok"
9951010

9961011
@command(click.argument("dry_time", type=int))
9971012
def set_mop_dryer_dry_time(self, dry_time_seconds: int) -> bool:
9981013
"""Set mop dryer add-on dry time."""
999-
self._verify_mop_dryer_supported()
10001014
return (
10011015
self.send("app_set_dryer_setting", {"on": {"dry_time": dry_time_seconds}})[
10021016
0
@@ -1008,14 +1022,12 @@ def set_mop_dryer_dry_time(self, dry_time_seconds: int) -> bool:
10081022
@action(name="Start mop drying", icon="mdi:tumble-dryer")
10091023
def start_mop_drying(self) -> bool:
10101024
"""Start mop drying."""
1011-
self._verify_mop_dryer_supported()
10121025
return self.send("app_set_dryer_status", {"status": 1})[0] == "ok"
10131026

10141027
@command()
10151028
@action(name="Stop mop drying", icon="mdi:tumble-dryer")
10161029
def stop_mop_drying(self) -> bool:
10171030
"""Stop mop drying."""
1018-
self._verify_mop_dryer_supported()
10191031
return self.send("app_set_dryer_status", {"status": 0})[0] == "ok"
10201032

10211033
@command()

miio/tests/dummies.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from miio import DeviceError
2+
3+
14
class DummyMiIOProtocol:
25
"""DummyProtocol allows you mock MiIOProtocol."""
36

@@ -8,7 +11,10 @@ def __init__(self, dummy_device):
811

912
def send(self, command: str, parameters=None, retry_count=3, extra_parameters=None):
1013
"""Overridden send() to return values from `self.return_values`."""
11-
return self.dummy_device.return_values[command](parameters)
14+
try:
15+
return self.dummy_device.return_values[command](parameters)
16+
except KeyError:
17+
raise DeviceError({"code": -32601, "message": "Method not found."})
1218

1319

1420
class DummyDevice:

0 commit comments

Comments
 (0)