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
333import logging
434from 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
738from roborock .containers import HomeData , HomeDataProduct
839from roborock .devices .cache import Cache
940from roborock .devices .traits import Trait
1041from roborock .devices .v1_rpc_channel import V1RpcChannel
1142from roborock .map .map_parser import MapParserConfig
1243
1344from .child_lock import ChildLockTrait
45+ from .clean_record import CleanRecordTrait
1446from .clean_summary import CleanSummaryTrait
1547from .command import CommandTrait
1648from .common import V1TraitMixin
1749from .consumeable import ConsumableTrait
1850from .device_features import DeviceFeaturesTrait
1951from .do_not_disturb import DoNotDisturbTrait
52+ from .dust_collection_mode import DustCollectionModeTrait
2053from .flow_led_status import FlowLedStatusTrait
2154from .home import HomeTrait
2255from .led_status import LedStatusTrait
2356from .map_content import MapContentTrait
2457from .maps import MapsTrait
2558from .rooms import RoomsTrait
59+ from .smart_wash_params import SmartWashParamsTrait
2660from .status import StatusTrait
2761from .valley_electricity_timer import ValleyElectricityTimerTrait
2862from .volume import SoundVolumeTrait
63+ from .wash_towel_mode import WashTowelModeTrait
2964
3065_LOGGER = logging .getLogger (__name__ )
3166
3570 "StatusTrait" ,
3671 "DoNotDisturbTrait" ,
3772 "CleanSummaryTrait" ,
73+ "CleanRecordTrait" ,
3874 "SoundVolumeTrait" ,
3975 "MapsTrait" ,
4076 "MapContentTrait" ,
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
156250def create (
157251 product : HomeDataProduct ,
0 commit comments