Skip to content

Commit 1809b5a

Browse files
committed
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.
1 parent 4bf5396 commit 1809b5a

File tree

14 files changed

+609
-28
lines changed

14 files changed

+609
-28
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: 13 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,15 @@ 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+
]

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: 126 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,68 @@
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 trait requires the
30+
device to have any of the specified dock types to be enabled. See
31+
`RoborockDockTypeCode` for a list of available dock types.
32+
"""
233

334
import logging
435
from dataclasses import dataclass, field, fields
5-
from typing import get_args
36+
from typing import Any, get_args
637

38+
from roborock.code_mappings import RoborockDockTypeCode
739
from roborock.containers import HomeData, HomeDataProduct
840
from roborock.devices.cache import Cache
941
from roborock.devices.traits import Trait
1042
from roborock.devices.v1_rpc_channel import V1RpcChannel
1143
from roborock.map.map_parser import MapParserConfig
1244

1345
from .child_lock import ChildLockTrait
46+
from .clean_record import CleanRecordTrait
1447
from .clean_summary import CleanSummaryTrait
1548
from .command import CommandTrait
1649
from .common import V1TraitMixin
1750
from .consumeable import ConsumableTrait
1851
from .device_features import DeviceFeaturesTrait
1952
from .do_not_disturb import DoNotDisturbTrait
53+
from .dock_summary import DockSummaryTrait
54+
from .dust_collection_mode import DustCollectionModeTrait
2055
from .flow_led_status import FlowLedStatusTrait
2156
from .home import HomeTrait
2257
from .led_status import LedStatusTrait
2358
from .map_content import MapContentTrait
2459
from .maps import MapsTrait
2560
from .rooms import RoomsTrait
61+
from .smart_wash_params import SmartWashParamsTrait
2662
from .status import StatusTrait
2763
from .valley_electricity_timer import ValleyElectricityTimerTrait
2864
from .volume import SoundVolumeTrait
65+
from .wash_towel_mode import WashTowelModeTrait
2966

3067
_LOGGER = logging.getLogger(__name__)
3168

@@ -35,6 +72,7 @@
3572
"StatusTrait",
3673
"DoNotDisturbTrait",
3774
"CleanSummaryTrait",
75+
"CleanRecordTrait",
3876
"SoundVolumeTrait",
3977
"MapsTrait",
4078
"MapContentTrait",
@@ -46,6 +84,10 @@
4684
"FlowLedStatusTrait",
4785
"LedStatusTrait",
4886
"ValleyElectricityTimerTrait",
87+
"DockSummaryTrait",
88+
"DustCollectionModeTrait",
89+
"WashTowelModeTrait",
90+
"SmartWashParamsTrait",
4991
]
5092

5193

@@ -61,19 +103,24 @@ class PropertiesApi(Trait):
61103
command: CommandTrait
62104
dnd: DoNotDisturbTrait
63105
clean_summary: CleanSummaryTrait
106+
clean_record: CleanRecordTrait
64107
sound_volume: SoundVolumeTrait
65108
rooms: RoomsTrait
66109
maps: MapsTrait
67110
map_content: MapContentTrait
68111
consumables: ConsumableTrait
69112
home: HomeTrait
70113
device_features: DeviceFeaturesTrait
114+
dust_collection_mode: DustCollectionModeTrait
71115

72116
# Optional features that may not be supported on all devices
73117
child_lock: ChildLockTrait | None = None
74118
led_status: LedStatusTrait | None = None
75119
flow_led_status: FlowLedStatusTrait | None = None
76120
valley_electricity_timer: ValleyElectricityTimerTrait | None = None
121+
wash_towel_mode: WashTowelModeTrait | None = None
122+
smart_wash_params: SmartWashParamsTrait | None = None
123+
dock_summary: DockSummaryTrait | None = None
77124

78125
def __init__(
79126
self,
@@ -89,8 +136,12 @@ def __init__(
89136
self._rpc_channel = rpc_channel
90137
self._mqtt_rpc_channel = mqtt_rpc_channel
91138
self._map_rpc_channel = map_rpc_channel
139+
self._cache = cache
92140

93141
self.status = StatusTrait(product)
142+
self.clean_summary = CleanSummaryTrait()
143+
self.clean_record = CleanRecordTrait(self.clean_summary)
144+
self.consumables = ConsumableTrait()
94145
self.rooms = RoomsTrait(home_data)
95146
self.maps = MapsTrait(self.status)
96147
self.map_content = MapContentTrait(map_parser_config)
@@ -103,7 +154,7 @@ def __init__(
103154
# We exclude optional features and them via discover_features
104155
if (union_args := get_args(item.type)) is None or len(union_args) > 0:
105156
continue
106-
_LOGGER.debug("Initializing trait %s", item.name)
157+
_LOGGER.debug("Trait '%s' is supported, initializing", item.name)
107158
trait = item.type()
108159
setattr(self, item.name, trait)
109160
# This is a hack to allow setting the rpc_channel on all traits. This is
@@ -124,8 +175,12 @@ def _get_rpc_channel(self, trait: V1TraitMixin) -> V1RpcChannel:
124175

125176
async def discover_features(self) -> None:
126177
"""Populate any supported traits that were not initialized in __init__."""
178+
_LOGGER.debug("Starting optional trait discovery")
127179
await self.device_features.refresh()
180+
# Dock type also acts like a device feature for some traits.
181+
dock_type = await self._dock_type()
128182

183+
# Dynamically create any traits that need to be populated
129184
for item in fields(self):
130185
if (trait := getattr(self, item.name, None)) is not None:
131186
continue
@@ -136,22 +191,79 @@ async def discover_features(self) -> None:
136191

137192
# Union args may not be in declared order
138193
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)
194+
if item_type is DockSummaryTrait:
195+
# DockSummaryTrait is created manually below
142196
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)
197+
trait = item_type()
198+
if not self._is_supported(trait, item.name, dock_type):
199+
_LOGGER.debug("Trait '%s' not supported, skipping", item.name)
150200
continue
151-
_LOGGER.debug("Enabling optional feature trait %s", item.name)
201+
202+
_LOGGER.debug("Trait '%s' is supported, initializing", item.name)
152203
setattr(self, item.name, trait)
153204
trait._rpc_channel = self._get_rpc_channel(trait)
154205

206+
if dock_type is None or dock_type == RoborockDockTypeCode.no_dock:
207+
_LOGGER.debug("Trait 'dock_summary' not supported, skipping")
208+
else:
209+
_LOGGER.debug("Trait 'dock_summary' is supported, initializing")
210+
self.dock_summary = DockSummaryTrait(
211+
self.dust_collection_mode,
212+
self.wash_towel_mode,
213+
self.smart_wash_params,
214+
)
215+
216+
def _is_supported(self, trait: V1TraitMixin, name: str, dock_type: RoborockDockTypeCode) -> bool:
217+
"""Check if a trait is supported by the device."""
218+
219+
if (requires_dock_type := getattr(trait, "requires_dock_type", None)) is not None:
220+
return dock_type in requires_dock_type
221+
222+
if (feature_name := getattr(trait, "requires_feature", None)) is None:
223+
_LOGGER.debug("Optional trait missing 'requires_feature' attribute %s, skipping", name)
224+
return False
225+
if (is_supported := getattr(self.device_features, feature_name)) is None:
226+
raise ValueError(f"Device feature '{feature_name}' on trait '{name}' is unknown")
227+
return is_supported
228+
229+
async def _dock_type(self) -> RoborockDockTypeCode:
230+
"""Get the dock type from the status trait or cache."""
231+
dock_type = await self._get_cached_trait_data("dock_type")
232+
if dock_type is not None:
233+
_LOGGER.debug("Using cached dock type: %s", dock_type)
234+
try:
235+
return RoborockDockTypeCode(dock_type)
236+
except ValueError:
237+
_LOGGER.debug("Cached dock type %s is invalid, refreshing", dock_type)
238+
239+
_LOGGER.debug("Starting dock type discovery")
240+
await self.status.refresh()
241+
_LOGGER.debug("Fetched dock type: %s", self.status.dock_type)
242+
if self.status.dock_type is None:
243+
# Explicitly set so we reuse cached value next type
244+
dock_type = RoborockDockTypeCode.no_dock
245+
else:
246+
dock_type = self.status.dock_type
247+
await self._set_cached_trait_data("dock_type", dock_type)
248+
return dock_type
249+
250+
async def _get_cached_trait_data(self, name: str) -> Any:
251+
"""Get the dock type from the status trait or cache."""
252+
cache_data = await self._cache.get()
253+
if cache_data.trait_data is None:
254+
cache_data.trait_data = {}
255+
_LOGGER.debug("Cached trait data: %s", cache_data.trait_data)
256+
return cache_data.trait_data.get(name)
257+
258+
async def _set_cached_trait_data(self, name: str, value: Any) -> None:
259+
"""Set trait-specific cached data."""
260+
cache_data = await self._cache.get()
261+
if cache_data.trait_data is None:
262+
cache_data.trait_data = {}
263+
cache_data.trait_data[name] = value
264+
_LOGGER.debug("Updating cached trait data: %s", cache_data.trait_data)
265+
await self._cache.set(cache_data)
266+
155267

156268
def create(
157269
product: HomeDataProduct,

0 commit comments

Comments
 (0)