Skip to content

Commit 03d0f37

Browse files
authored
feat: Add clean record and dock related traits (#550)
* feat: Add dock summary and clean record traits This creates a new dock type required feature. This requires the status is fetched first initially. The dock type is added to the cache since it is not expected to change once discovered. The clean record trait depends on the clean summary and fetches the last clean record from the summary. Tests are added for clean record and dock summary. The dock summary tests also exercise the feature discovery and underlying modes. * fix: Remove DockSummary and make dust collection mode optional based on dock type
1 parent 4bf5396 commit 03d0f37

File tree

17 files changed

+653
-29
lines changed

17 files changed

+653
-29
lines changed

roborock/cli.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ class ConnectionCache(RoborockBase):
119119
home_data: HomeData | None = None
120120
network_info: dict[str, NetworkInfo] | None = None
121121
home_cache: dict[int, CombinedMapInfo] | None = None
122+
trait_data: dict[str, Any] | None = None
122123

123124

124125
class DeviceConnectionManager:
@@ -267,6 +268,7 @@ async def get(self) -> CacheData:
267268
home_data=connection_cache.home_data,
268269
network_info=connection_cache.network_info or {},
269270
home_cache=connection_cache.home_cache,
271+
trait_data=connection_cache.trait_data or {},
270272
)
271273

272274
async def set(self, value: CacheData) -> None:
@@ -276,6 +278,7 @@ async def set(self, value: CacheData) -> None:
276278
connection_cache.home_data = value.home_data
277279
connection_cache.network_info = value.network_info
278280
connection_cache.home_cache = value.home_cache
281+
connection_cache.trait_data = value.trait_data
279282
self.update(connection_cache)
280283

281284

@@ -401,9 +404,11 @@ async def _v1_trait(context: RoborockContext, device_id: str, display_func: Call
401404
device_manager = await context.get_device_manager()
402405
device = await device_manager.get_device(device_id)
403406
if device.v1_properties is None:
404-
raise RoborockException(f"Device {device.name} does not support V1 protocol")
407+
raise RoborockUnsupportedFeature(f"Device {device.name} does not support V1 protocol")
405408
await device.v1_properties.discover_features()
406409
trait = display_func(device.v1_properties)
410+
if trait is None:
411+
raise RoborockUnsupportedFeature("Trait not supported by device")
407412
await trait.refresh()
408413
return trait
409414

@@ -440,6 +445,26 @@ async def clean_summary(ctx, device_id: str):
440445
await _display_v1_trait(context, device_id, lambda v1: v1.clean_summary)
441446

442447

448+
@session.command()
449+
@click.option("--device_id", required=True)
450+
@click.pass_context
451+
@async_command
452+
async def clean_record(ctx, device_id: str):
453+
"""Get device last clean record."""
454+
context: RoborockContext = ctx.obj
455+
await _display_v1_trait(context, device_id, lambda v1: v1.clean_record)
456+
457+
458+
@session.command()
459+
@click.option("--device_id", required=True)
460+
@click.pass_context
461+
@async_command
462+
async def dock_summary(ctx, device_id: str):
463+
"""Get device dock summary."""
464+
context: RoborockContext = ctx.obj
465+
await _display_v1_trait(context, device_id, lambda v1: v1.dock_summary)
466+
467+
443468
@session.command()
444469
@click.option("--device_id", required=True)
445470
@click.pass_context
@@ -938,6 +963,8 @@ def write_markdown_table(product_features: dict[str, dict[str, any]], all_featur
938963
cli.add_command(get_device_info)
939964
cli.add_command(update_docs)
940965
cli.add_command(clean_summary)
966+
cli.add_command(clean_record)
967+
cli.add_command(dock_summary)
941968
cli.add_command(volume)
942969
cli.add_command(set_volume)
943970
cli.add_command(maps)

roborock/device_features.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from enum import IntEnum, StrEnum
55
from typing import Any
66

7-
from .code_mappings import RoborockProductNickname
7+
from .code_mappings import RoborockDockTypeCode, RoborockProductNickname
88
from .containers import RoborockBase
99

1010

@@ -630,3 +630,25 @@ def from_feature_flags(
630630
def get_supported_features(self) -> list[str]:
631631
"""Returns a list of supported features (Primarily used for logging purposes)."""
632632
return [k for k, v in vars(self).items() if v]
633+
634+
635+
WASH_N_FILL_DOCK_TYPES = [
636+
RoborockDockTypeCode.empty_wash_fill_dock,
637+
RoborockDockTypeCode.s8_dock,
638+
RoborockDockTypeCode.p10_dock,
639+
RoborockDockTypeCode.p10_pro_dock,
640+
RoborockDockTypeCode.s8_maxv_ultra_dock,
641+
RoborockDockTypeCode.qrevo_s_dock,
642+
RoborockDockTypeCode.saros_r10_dock,
643+
RoborockDockTypeCode.qrevo_curv_dock,
644+
]
645+
646+
647+
def is_wash_n_fill_dock(dock_type: RoborockDockTypeCode) -> bool:
648+
"""Check if the dock type is a wash and fill dock."""
649+
return dock_type in WASH_N_FILL_DOCK_TYPES
650+
651+
652+
def is_valid_dock(dock_type: RoborockDockTypeCode) -> bool:
653+
"""Check if device supports a dock."""
654+
return dock_type != RoborockDockTypeCode.no_dock

roborock/devices/cache.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"""
77

88
from dataclasses import dataclass, field
9-
from typing import Protocol
9+
from typing import Any, Protocol
1010

1111
from roborock.containers import CombinedMapInfo, HomeData, NetworkInfo
1212
from roborock.device_features import DeviceFeatures
@@ -28,6 +28,9 @@ class CacheData:
2828
device_features: DeviceFeatures | None = None
2929
"""Device features information."""
3030

31+
trait_data: dict[str, Any] | None = None
32+
"""Trait-specific cached data used internally for caching device features."""
33+
3134

3235
class Cache(Protocol):
3336
"""Protocol for a cache that can store and retrieve values."""

roborock/devices/traits/v1/__init__.py

Lines changed: 109 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,66 @@
1-
"""Create traits for V1 devices."""
1+
"""Create traits for V1 devices.
2+
3+
Traits are modular components that encapsulate specific features of a Roborock
4+
device. This module provides a factory function to create and initialize the
5+
appropriate traits for V1 devices based on their capabilities. They can also
6+
be considered groups of commands and parsing logic for that command.
7+
8+
Traits have a `refresh()` method that can be called to update their state
9+
from the device. Some traits may also provide additional methods for modifying
10+
the device state.
11+
12+
The most common pattern for a trait is to subclass `V1TraitMixin` and a `RoborockBase`
13+
dataclass, and define a `command` class variable that specifies the `RoborockCommand`
14+
used to fetch the trait data from the device. See `common.py` for more details
15+
on common patterns used across traits.
16+
17+
There are some additional decorators in `common.py` that can be used to specify which
18+
RPC channel to use for the trait (standard, MQTT/cloud, or map-specific).
19+
20+
- `@common.mqtt_rpc_channel` - Use the MQTT RPC channel for this trait.
21+
- `@common.map_rpc_channel` - Use the map RPC channel for this trait.
22+
23+
There are also some attributes that specify device feature dependencies for
24+
optional traits:
25+
26+
- `requires_feature` - The string name of the device feature that must be supported
27+
for this trait to be enabled. See `DeviceFeaturesTrait` for a list of
28+
available features.
29+
- `requires_dock_type` - If set, this is a function that accepts a `RoborockDockTypeCode`
30+
and returns a boolean indicating whether the trait is supported for that dock type.
31+
"""
232

333
import logging
434
from dataclasses import dataclass, field, fields
5-
from typing import get_args
35+
from typing import Any, get_args
636

37+
from roborock.code_mappings import RoborockDockTypeCode
738
from roborock.containers import HomeData, HomeDataProduct
839
from roborock.devices.cache import Cache
940
from roborock.devices.traits import Trait
1041
from roborock.devices.v1_rpc_channel import V1RpcChannel
1142
from roborock.map.map_parser import MapParserConfig
1243

1344
from .child_lock import ChildLockTrait
45+
from .clean_record import CleanRecordTrait
1446
from .clean_summary import CleanSummaryTrait
1547
from .command import CommandTrait
1648
from .common import V1TraitMixin
1749
from .consumeable import ConsumableTrait
1850
from .device_features import DeviceFeaturesTrait
1951
from .do_not_disturb import DoNotDisturbTrait
52+
from .dust_collection_mode import DustCollectionModeTrait
2053
from .flow_led_status import FlowLedStatusTrait
2154
from .home import HomeTrait
2255
from .led_status import LedStatusTrait
2356
from .map_content import MapContentTrait
2457
from .maps import MapsTrait
2558
from .rooms import RoomsTrait
59+
from .smart_wash_params import SmartWashParamsTrait
2660
from .status import StatusTrait
2761
from .valley_electricity_timer import ValleyElectricityTimerTrait
2862
from .volume import SoundVolumeTrait
63+
from .wash_towel_mode import WashTowelModeTrait
2964

3065
_LOGGER = logging.getLogger(__name__)
3166

@@ -35,6 +70,7 @@
3570
"StatusTrait",
3671
"DoNotDisturbTrait",
3772
"CleanSummaryTrait",
73+
"CleanRecordTrait",
3874
"SoundVolumeTrait",
3975
"MapsTrait",
4076
"MapContentTrait",
@@ -46,6 +82,9 @@
4682
"FlowLedStatusTrait",
4783
"LedStatusTrait",
4884
"ValleyElectricityTimerTrait",
85+
"DustCollectionModeTrait",
86+
"WashTowelModeTrait",
87+
"SmartWashParamsTrait",
4988
]
5089

5190

@@ -61,6 +100,7 @@ class PropertiesApi(Trait):
61100
command: CommandTrait
62101
dnd: DoNotDisturbTrait
63102
clean_summary: CleanSummaryTrait
103+
clean_record: CleanRecordTrait
64104
sound_volume: SoundVolumeTrait
65105
rooms: RoomsTrait
66106
maps: MapsTrait
@@ -74,6 +114,9 @@ class PropertiesApi(Trait):
74114
led_status: LedStatusTrait | None = None
75115
flow_led_status: FlowLedStatusTrait | None = None
76116
valley_electricity_timer: ValleyElectricityTimerTrait | None = None
117+
dust_collection_mode: DustCollectionModeTrait | None = None
118+
wash_towel_mode: WashTowelModeTrait | None = None
119+
smart_wash_params: SmartWashParamsTrait | None = None
77120

78121
def __init__(
79122
self,
@@ -89,8 +132,12 @@ def __init__(
89132
self._rpc_channel = rpc_channel
90133
self._mqtt_rpc_channel = mqtt_rpc_channel
91134
self._map_rpc_channel = map_rpc_channel
135+
self._cache = cache
92136

93137
self.status = StatusTrait(product)
138+
self.clean_summary = CleanSummaryTrait()
139+
self.clean_record = CleanRecordTrait(self.clean_summary)
140+
self.consumables = ConsumableTrait()
94141
self.rooms = RoomsTrait(home_data)
95142
self.maps = MapsTrait(self.status)
96143
self.map_content = MapContentTrait(map_parser_config)
@@ -103,7 +150,7 @@ def __init__(
103150
# We exclude optional features and them via discover_features
104151
if (union_args := get_args(item.type)) is None or len(union_args) > 0:
105152
continue
106-
_LOGGER.debug("Initializing trait %s", item.name)
153+
_LOGGER.debug("Trait '%s' is supported, initializing", item.name)
107154
trait = item.type()
108155
setattr(self, item.name, trait)
109156
# This is a hack to allow setting the rpc_channel on all traits. This is
@@ -124,8 +171,12 @@ def _get_rpc_channel(self, trait: V1TraitMixin) -> V1RpcChannel:
124171

125172
async def discover_features(self) -> None:
126173
"""Populate any supported traits that were not initialized in __init__."""
174+
_LOGGER.debug("Starting optional trait discovery")
127175
await self.device_features.refresh()
176+
# Dock type also acts like a device feature for some traits.
177+
dock_type = await self._dock_type()
128178

179+
# Dynamically create any traits that need to be populated
129180
for item in fields(self):
130181
if (trait := getattr(self, item.name, None)) is not None:
131182
continue
@@ -136,22 +187,65 @@ async def discover_features(self) -> None:
136187

137188
# Union args may not be in declared order
138189
item_type = union_args[0] if union_args[1] is type(None) else union_args[1]
139-
trait = item_type()
140-
if not hasattr(trait, "requires_feature"):
141-
_LOGGER.debug("Trait missing required feature %s", item.name)
142-
continue
143-
_LOGGER.debug("Checking for feature %s", trait.requires_feature)
144-
is_supported = getattr(self.device_features, trait.requires_feature)
145-
# _LOGGER.debug("Device features: %s", self.device_features)
146-
if is_supported is None:
147-
raise ValueError(f"Device feature '{trait.requires_feature}' on trait '{item.name}' is unknown")
148-
if not is_supported:
149-
_LOGGER.debug("Disabling optional feature trait %s", item.name)
190+
if not self._is_supported(item_type, item.name, dock_type):
191+
_LOGGER.debug("Trait '%s' not supported, skipping", item.name)
150192
continue
151-
_LOGGER.debug("Enabling optional feature trait %s", item.name)
193+
_LOGGER.debug("Trait '%s' is supported, initializing", item.name)
194+
trait = item_type()
152195
setattr(self, item.name, trait)
153196
trait._rpc_channel = self._get_rpc_channel(trait)
154197

198+
def _is_supported(self, trait_type: type[V1TraitMixin], name: str, dock_type: RoborockDockTypeCode) -> bool:
199+
"""Check if a trait is supported by the device."""
200+
201+
if (requires_dock_type := getattr(trait_type, "requires_dock_type", None)) is not None:
202+
return requires_dock_type(dock_type)
203+
204+
if (feature_name := getattr(trait_type, "requires_feature", None)) is None:
205+
_LOGGER.debug("Optional trait missing 'requires_feature' attribute %s, skipping", name)
206+
return False
207+
if (is_supported := getattr(self.device_features, feature_name)) is None:
208+
raise ValueError(f"Device feature '{feature_name}' on trait '{name}' is unknown")
209+
return is_supported
210+
211+
async def _dock_type(self) -> RoborockDockTypeCode:
212+
"""Get the dock type from the status trait or cache."""
213+
dock_type = await self._get_cached_trait_data("dock_type")
214+
if dock_type is not None:
215+
_LOGGER.debug("Using cached dock type: %s", dock_type)
216+
try:
217+
return RoborockDockTypeCode(dock_type)
218+
except ValueError:
219+
_LOGGER.debug("Cached dock type %s is invalid, refreshing", dock_type)
220+
221+
_LOGGER.debug("Starting dock type discovery")
222+
await self.status.refresh()
223+
_LOGGER.debug("Fetched dock type: %s", self.status.dock_type)
224+
if self.status.dock_type is None:
225+
# Explicitly set so we reuse cached value next type
226+
dock_type = RoborockDockTypeCode.no_dock
227+
else:
228+
dock_type = self.status.dock_type
229+
await self._set_cached_trait_data("dock_type", dock_type)
230+
return dock_type
231+
232+
async def _get_cached_trait_data(self, name: str) -> Any:
233+
"""Get the dock type from the status trait or cache."""
234+
cache_data = await self._cache.get()
235+
if cache_data.trait_data is None:
236+
cache_data.trait_data = {}
237+
_LOGGER.debug("Cached trait data: %s", cache_data.trait_data)
238+
return cache_data.trait_data.get(name)
239+
240+
async def _set_cached_trait_data(self, name: str, value: Any) -> None:
241+
"""Set trait-specific cached data."""
242+
cache_data = await self._cache.get()
243+
if cache_data.trait_data is None:
244+
cache_data.trait_data = {}
245+
cache_data.trait_data[name] = value
246+
_LOGGER.debug("Updating cached trait data: %s", cache_data.trait_data)
247+
await self._cache.set(cache_data)
248+
155249

156250
def create(
157251
product: HomeDataProduct,

0 commit comments

Comments
 (0)