roborock.devices.traits.v1
Create traits for V1 devices.
Traits are modular components that encapsulate specific features of a Roborock device. This module provides a factory function to create and initialize the appropriate traits for V1 devices based on their capabilities.
Using Traits
Traits are accessed via the v1_properties attribute on a device. Each trait
represents a specific capability, such as status, consumables, or rooms.
Traits serve two main purposes:
- State: Traits are dataclasses that hold the current state of the device
feature. You can access attributes directly (e.g.,
device.v1_properties.status.battery). - Commands: Traits provide methods to control the device. For example,
device.v1_properties.volume.set_volume().
Additionally, the command trait provides a generic way to send any command to the
device (e.g. device.v1_properties.command.send("app_start")). This is often used
for basic cleaning operations like starting, stopping, or docking the vacuum.
Most traits have a refresh() method that must be called to update their state
from the device. The state is not updated automatically in real-time unless
specifically implemented by the trait or via polling.
Adding New Traits
When adding a new trait, the most common pattern is to subclass V1TraitMixin
and a RoborockBase dataclass. You must define a command class variable that
specifies the RoborockCommand used to fetch the trait data from the device.
See common.py for more details on common patterns used across traits.
There are some additional decorators in common.py that can be used to specify which
RPC channel to use for the trait (standard, MQTT/cloud, or map-specific).
@common.mqtt_rpc_channel- Use the MQTT RPC channel for this trait.@common.map_rpc_channel- Use the map RPC channel for this trait.
There are also some attributes that specify device feature dependencies for optional traits:
- `requires_feature` - The string name of the device feature that must be supported
for this trait to be enabled. See `DeviceFeaturesTrait` for a list of
available features.
- `requires_dock_type` - If set, this is a function that accepts a `RoborockDockTypeCode`
and returns a boolean indicating whether the trait is supported for that dock type.
Additionally, DeviceFeaturesTrait has a method is_field_supported that is used to
check individual trait field values. This is a more fine grained version to allow
optional fields in a dataclass, vs the above feature checks that apply to an entire
trait. The dps field metadata attribute references a schema code in
HomeDataProduct Schema that is required for the field to be supported.
1"""Create traits for V1 devices. 2 3Traits are modular components that encapsulate specific features of a Roborock 4device. This module provides a factory function to create and initialize the 5appropriate traits for V1 devices based on their capabilities. 6 7Using Traits 8------------ 9Traits are accessed via the `v1_properties` attribute on a device. Each trait 10represents a specific capability, such as `status`, `consumables`, or `rooms`. 11 12Traits serve two main purposes: 131. **State**: Traits are dataclasses that hold the current state of the device 14 feature. You can access attributes directly (e.g., `device.v1_properties.status.battery`). 152. **Commands**: Traits provide methods to control the device. For example, 16 `device.v1_properties.volume.set_volume()`. 17 18Additionally, the `command` trait provides a generic way to send any command to the 19device (e.g. `device.v1_properties.command.send("app_start")`). This is often used 20for basic cleaning operations like starting, stopping, or docking the vacuum. 21 22Most traits have a `refresh()` method that must be called to update their state 23from the device. The state is not updated automatically in real-time unless 24specifically implemented by the trait or via polling. 25 26Adding New Traits 27----------------- 28When adding a new trait, the most common pattern is to subclass `V1TraitMixin` 29and a `RoborockBase` dataclass. You must define a `command` class variable that 30specifies the `RoborockCommand` used to fetch the trait data from the device. 31See `common.py` for more details on common patterns used across traits. 32 33There are some additional decorators in `common.py` that can be used to specify which 34RPC channel to use for the trait (standard, MQTT/cloud, or map-specific). 35 36 - `@common.mqtt_rpc_channel` - Use the MQTT RPC channel for this trait. 37 - `@common.map_rpc_channel` - Use the map RPC channel for this trait. 38 39There are also some attributes that specify device feature dependencies for 40optional traits: 41 42 - `requires_feature` - The string name of the device feature that must be supported 43 for this trait to be enabled. See `DeviceFeaturesTrait` for a list of 44 available features. 45 - `requires_dock_type` - If set, this is a function that accepts a `RoborockDockTypeCode` 46 and returns a boolean indicating whether the trait is supported for that dock type. 47 48Additionally, DeviceFeaturesTrait has a method `is_field_supported` that is used to 49check individual trait field values. This is a more fine grained version to allow 50optional fields in a dataclass, vs the above feature checks that apply to an entire 51trait. The `dps` field metadata attribute references a schema code in 52HomeDataProduct Schema that is required for the field to be supported. 53""" 54 55import logging 56from collections.abc import Callable 57from dataclasses import dataclass, field, fields 58from typing import Any, get_args 59 60from roborock.data.containers import HomeData, HomeDataProduct, RoborockBase 61from roborock.data.v1.v1_code_mappings import RoborockDockTypeCode 62from roborock.devices.cache import DeviceCache 63from roborock.devices.traits import Trait 64from roborock.exceptions import RoborockException 65from roborock.map.map_parser import MapParserConfig 66from roborock.protocols.v1_protocol import V1RpcChannel, decode_data_protocol_message 67from roborock.roborock_message import RoborockDataProtocol, RoborockMessage 68from roborock.web_api import UserWebApiClient 69 70from . import ( 71 child_lock, 72 clean_summary, 73 command, 74 common, 75 consumeable, 76 device_features, 77 do_not_disturb, 78 dust_collection_mode, 79 flow_led_status, 80 home, 81 led_status, 82 map_content, 83 maps, 84 network_info, 85 rooms, 86 routines, 87 smart_wash_params, 88 status, 89 valley_electricity_timer, 90 volume, 91 wash_towel_mode, 92) 93from .child_lock import ChildLockTrait 94from .clean_summary import CleanSummaryTrait 95from .command import CommandTrait 96from .common import V1TraitMixin 97from .consumeable import ConsumableTrait 98from .device_features import DeviceFeaturesTrait 99from .do_not_disturb import DoNotDisturbTrait 100from .dust_collection_mode import DustCollectionModeTrait 101from .flow_led_status import FlowLedStatusTrait 102from .home import HomeTrait 103from .led_status import LedStatusTrait 104from .map_content import MapContentTrait 105from .maps import MapsTrait 106from .network_info import NetworkInfoTrait 107from .rooms import RoomsTrait 108from .routines import RoutinesTrait 109from .smart_wash_params import SmartWashParamsTrait 110from .status import StatusTrait 111from .valley_electricity_timer import ValleyElectricityTimerTrait 112from .volume import SoundVolumeTrait 113from .wash_towel_mode import WashTowelModeTrait 114 115_LOGGER = logging.getLogger(__name__) 116 117__all__ = [ 118 "PropertiesApi", 119 "child_lock", 120 "clean_summary", 121 "command", 122 "common", 123 "consumeable", 124 "device_features", 125 "do_not_disturb", 126 "dust_collection_mode", 127 "flow_led_status", 128 "home", 129 "led_status", 130 "map_content", 131 "maps", 132 "network_info", 133 "rooms", 134 "routines", 135 "smart_wash_params", 136 "status", 137 "valley_electricity_timer", 138 "volume", 139 "wash_towel_mode", 140] 141 142 143@dataclass 144class PropertiesApi(Trait): 145 """Common properties for V1 devices. 146 147 This class holds all the traits that are common across all V1 devices. 148 """ 149 150 # All v1 devices have these traits 151 status: StatusTrait 152 command: CommandTrait 153 dnd: DoNotDisturbTrait 154 clean_summary: CleanSummaryTrait 155 sound_volume: SoundVolumeTrait 156 rooms: RoomsTrait 157 maps: MapsTrait 158 map_content: MapContentTrait 159 consumables: ConsumableTrait 160 home: HomeTrait 161 device_features: DeviceFeaturesTrait 162 network_info: NetworkInfoTrait 163 routines: RoutinesTrait 164 165 # Optional features that may not be supported on all devices 166 child_lock: ChildLockTrait | None = None 167 led_status: LedStatusTrait | None = None 168 flow_led_status: FlowLedStatusTrait | None = None 169 valley_electricity_timer: ValleyElectricityTimerTrait | None = None 170 dust_collection_mode: DustCollectionModeTrait | None = None 171 wash_towel_mode: WashTowelModeTrait | None = None 172 smart_wash_params: SmartWashParamsTrait | None = None 173 174 def __init__( 175 self, 176 device_uid: str, 177 product: HomeDataProduct, 178 home_data: HomeData, 179 rpc_channel: V1RpcChannel, 180 mqtt_rpc_channel: V1RpcChannel, 181 map_rpc_channel: V1RpcChannel, 182 add_dps_listener: Callable[[Callable[[dict[RoborockDataProtocol, Any]], None]], Callable[[], None]], 183 web_api: UserWebApiClient, 184 device_cache: DeviceCache, 185 map_parser_config: MapParserConfig | None = None, 186 region: str | None = None, 187 ) -> None: 188 """Initialize the V1TraitProps.""" 189 self._device_uid = device_uid 190 self._rpc_channel = rpc_channel 191 self._mqtt_rpc_channel = mqtt_rpc_channel 192 self._map_rpc_channel = map_rpc_channel 193 self._web_api = web_api 194 self._device_cache = device_cache 195 self._region = region 196 self._unsub: Callable[[], None] | None = None 197 self._add_dps_listener = add_dps_listener 198 199 self.device_features = DeviceFeaturesTrait(product, self._device_cache) 200 self.status = StatusTrait(self.device_features, region=self._region) 201 self.consumables = ConsumableTrait() 202 self.rooms = RoomsTrait(home_data, device_uid, web_api) 203 self.maps = MapsTrait(self.status) 204 self.map_content = MapContentTrait(map_parser_config) 205 self.home = HomeTrait(self.status, self.maps, self.map_content, self.rooms, self._device_cache) 206 self.network_info = NetworkInfoTrait(device_uid, self._device_cache) 207 self.routines = RoutinesTrait(device_uid, web_api) 208 209 # Dynamically create any traits that need to be populated 210 for item in fields(self): 211 if (trait := getattr(self, item.name, None)) is None: 212 # We exclude optional features and them via discover_features 213 if (union_args := get_args(item.type)) is None or len(union_args) > 0: 214 continue 215 _LOGGER.debug("Trait '%s' is supported, initializing", item.name) 216 if not callable(item.type): 217 continue 218 trait = item.type() 219 setattr(self, item.name, trait) 220 # This is a hack to allow setting the rpc_channel on all traits. This is 221 # used so we can preserve the dataclass behavior when the values in the 222 # traits are updated, but still want to allow them to have a reference 223 # to the rpc channel for sending commands. 224 trait._rpc_channel = self._get_rpc_channel(trait) 225 226 def _get_rpc_channel(self, trait: V1TraitMixin) -> V1RpcChannel: 227 # The decorator `@common.mqtt_rpc_channel` means that the trait needs 228 # to use the mqtt_rpc_channel (cloud only) instead of the rpc_channel (adaptive) 229 if hasattr(trait, "mqtt_rpc_channel"): 230 return self._mqtt_rpc_channel 231 elif hasattr(trait, "map_rpc_channel"): 232 return self._map_rpc_channel 233 else: 234 return self._rpc_channel 235 236 async def start(self) -> None: 237 """Start the properties API and discover features.""" 238 if self._unsub: 239 return 240 await self.discover_features() 241 self._unsub = self._add_dps_listener(self._on_dps_update) 242 243 def close(self) -> None: 244 if self._unsub: 245 self._unsub() 246 self._unsub = None 247 248 def _on_dps_update(self, dps: dict[RoborockDataProtocol, Any]) -> None: 249 """Handle incoming messages from the device. 250 251 This will notify all traits of the new values. This can be improved in 252 the future to be dynamic when we have more traits that support dynamic 253 updates but for now we just invoke them manually. 254 """ 255 _LOGGER.debug("Received message from device: %s", dps) 256 self.status.update_from_dps(dps) 257 self.consumables.update_from_dps(dps) 258 259 async def discover_features(self) -> None: 260 """Populate any supported traits that were not initialized in __init__.""" 261 _LOGGER.debug("Starting optional trait discovery") 262 await self.device_features.refresh() 263 # Dock type also acts like a device feature for some traits. 264 dock_type = await self._dock_type() 265 266 # Initialize traits with special arguments before the generic loop 267 if self.wash_towel_mode is None and self._is_supported(WashTowelModeTrait, "wash_towel_mode", dock_type): 268 wash_towel_mode = WashTowelModeTrait(self.device_features) 269 wash_towel_mode._rpc_channel = self._get_rpc_channel(wash_towel_mode) # type: ignore[assignment] 270 self.wash_towel_mode = wash_towel_mode 271 272 # Dynamically create any traits that need to be populated 273 for item in fields(self): 274 if (trait := getattr(self, item.name, None)) is not None: 275 continue 276 if (union_args := get_args(item.type)) is None: 277 raise ValueError(f"Unexpected non-union type for trait {item.name}: {item.type}") 278 if len(union_args) != 2 or type(None) not in union_args: 279 raise ValueError(f"Unexpected non-optional type for trait {item.name}: {item.type}") 280 281 # Union args may not be in declared order 282 item_type = union_args[0] if union_args[1] is type(None) else union_args[1] 283 if not self._is_supported(item_type, item.name, dock_type): 284 _LOGGER.debug("Trait '%s' not supported, skipping", item.name) 285 continue 286 _LOGGER.debug("Trait '%s' is supported, initializing", item.name) 287 trait = item_type() 288 setattr(self, item.name, trait) 289 trait._rpc_channel = self._get_rpc_channel(trait) 290 291 def _is_supported(self, trait_type: type[V1TraitMixin], name: str, dock_type: RoborockDockTypeCode) -> bool: 292 """Check if a trait is supported by the device.""" 293 294 if (requires_dock_type := getattr(trait_type, "requires_dock_type", None)) is not None: 295 return requires_dock_type(dock_type) 296 297 if (feature_name := getattr(trait_type, "requires_feature", None)) is None: 298 _LOGGER.debug("Optional trait missing 'requires_feature' attribute %s, skipping", name) 299 return False 300 if (is_supported := getattr(self.device_features, feature_name)) is None: 301 raise ValueError(f"Device feature '{feature_name}' on trait '{name}' is unknown") 302 return is_supported 303 304 async def _dock_type(self) -> RoborockDockTypeCode: 305 """Get the dock type from the status trait or cache.""" 306 dock_type = await self._get_cached_trait_data("dock_type") 307 if dock_type is not None: 308 _LOGGER.debug("Using cached dock type: %s", dock_type) 309 try: 310 return RoborockDockTypeCode(dock_type) 311 except ValueError: 312 _LOGGER.debug("Cached dock type %s is invalid, refreshing", dock_type) 313 314 _LOGGER.debug("Starting dock type discovery") 315 await self.status.refresh() 316 _LOGGER.debug("Fetched dock type: %s", self.status.dock_type) 317 if self.status.dock_type is None: 318 # Explicitly set so we reuse cached value next type 319 dock_type = RoborockDockTypeCode.no_dock 320 else: 321 dock_type = self.status.dock_type 322 await self._set_cached_trait_data("dock_type", dock_type) 323 return dock_type 324 325 async def _get_cached_trait_data(self, name: str) -> Any: 326 """Get the dock type from the status trait or cache.""" 327 cache_data = await self._device_cache.get() 328 if cache_data.trait_data is None: 329 cache_data.trait_data = {} 330 _LOGGER.debug("Cached trait data: %s", cache_data.trait_data) 331 return cache_data.trait_data.get(name) 332 333 async def _set_cached_trait_data(self, name: str, value: Any) -> None: 334 """Set trait-specific cached data.""" 335 cache_data = await self._device_cache.get() 336 if cache_data.trait_data is None: 337 cache_data.trait_data = {} 338 cache_data.trait_data[name] = value 339 _LOGGER.debug("Updating cached trait data: %s", cache_data.trait_data) 340 await self._device_cache.set(cache_data) 341 342 def as_dict(self) -> dict[str, Any]: 343 """Return the trait data as a dictionary.""" 344 result: dict[str, Any] = {} 345 for item in fields(self): 346 trait = getattr(self, item.name, None) 347 if trait is None or not isinstance(trait, RoborockBase): 348 continue 349 data = trait.as_dict() 350 if data: # Don't omit unset traits 351 result[item.name] = data 352 return result 353 354 355def create( 356 device_uid: str, 357 product: HomeDataProduct, 358 home_data: HomeData, 359 rpc_channel: V1RpcChannel, 360 mqtt_rpc_channel: V1RpcChannel, 361 map_rpc_channel: V1RpcChannel, 362 add_dps_listener: Callable[[Callable[[dict[RoborockDataProtocol, Any]], None]], Callable[[], None]], 363 web_api: UserWebApiClient, 364 device_cache: DeviceCache, 365 map_parser_config: MapParserConfig | None = None, 366 region: str | None = None, 367) -> PropertiesApi: 368 """Create traits for V1 devices.""" 369 return PropertiesApi( 370 device_uid, 371 product, 372 home_data, 373 rpc_channel, 374 mqtt_rpc_channel, 375 map_rpc_channel, 376 add_dps_listener, 377 web_api, 378 device_cache, 379 map_parser_config, 380 region=region, 381 )
144@dataclass 145class PropertiesApi(Trait): 146 """Common properties for V1 devices. 147 148 This class holds all the traits that are common across all V1 devices. 149 """ 150 151 # All v1 devices have these traits 152 status: StatusTrait 153 command: CommandTrait 154 dnd: DoNotDisturbTrait 155 clean_summary: CleanSummaryTrait 156 sound_volume: SoundVolumeTrait 157 rooms: RoomsTrait 158 maps: MapsTrait 159 map_content: MapContentTrait 160 consumables: ConsumableTrait 161 home: HomeTrait 162 device_features: DeviceFeaturesTrait 163 network_info: NetworkInfoTrait 164 routines: RoutinesTrait 165 166 # Optional features that may not be supported on all devices 167 child_lock: ChildLockTrait | None = None 168 led_status: LedStatusTrait | None = None 169 flow_led_status: FlowLedStatusTrait | None = None 170 valley_electricity_timer: ValleyElectricityTimerTrait | None = None 171 dust_collection_mode: DustCollectionModeTrait | None = None 172 wash_towel_mode: WashTowelModeTrait | None = None 173 smart_wash_params: SmartWashParamsTrait | None = None 174 175 def __init__( 176 self, 177 device_uid: str, 178 product: HomeDataProduct, 179 home_data: HomeData, 180 rpc_channel: V1RpcChannel, 181 mqtt_rpc_channel: V1RpcChannel, 182 map_rpc_channel: V1RpcChannel, 183 add_dps_listener: Callable[[Callable[[dict[RoborockDataProtocol, Any]], None]], Callable[[], None]], 184 web_api: UserWebApiClient, 185 device_cache: DeviceCache, 186 map_parser_config: MapParserConfig | None = None, 187 region: str | None = None, 188 ) -> None: 189 """Initialize the V1TraitProps.""" 190 self._device_uid = device_uid 191 self._rpc_channel = rpc_channel 192 self._mqtt_rpc_channel = mqtt_rpc_channel 193 self._map_rpc_channel = map_rpc_channel 194 self._web_api = web_api 195 self._device_cache = device_cache 196 self._region = region 197 self._unsub: Callable[[], None] | None = None 198 self._add_dps_listener = add_dps_listener 199 200 self.device_features = DeviceFeaturesTrait(product, self._device_cache) 201 self.status = StatusTrait(self.device_features, region=self._region) 202 self.consumables = ConsumableTrait() 203 self.rooms = RoomsTrait(home_data, device_uid, web_api) 204 self.maps = MapsTrait(self.status) 205 self.map_content = MapContentTrait(map_parser_config) 206 self.home = HomeTrait(self.status, self.maps, self.map_content, self.rooms, self._device_cache) 207 self.network_info = NetworkInfoTrait(device_uid, self._device_cache) 208 self.routines = RoutinesTrait(device_uid, web_api) 209 210 # Dynamically create any traits that need to be populated 211 for item in fields(self): 212 if (trait := getattr(self, item.name, None)) is None: 213 # We exclude optional features and them via discover_features 214 if (union_args := get_args(item.type)) is None or len(union_args) > 0: 215 continue 216 _LOGGER.debug("Trait '%s' is supported, initializing", item.name) 217 if not callable(item.type): 218 continue 219 trait = item.type() 220 setattr(self, item.name, trait) 221 # This is a hack to allow setting the rpc_channel on all traits. This is 222 # used so we can preserve the dataclass behavior when the values in the 223 # traits are updated, but still want to allow them to have a reference 224 # to the rpc channel for sending commands. 225 trait._rpc_channel = self._get_rpc_channel(trait) 226 227 def _get_rpc_channel(self, trait: V1TraitMixin) -> V1RpcChannel: 228 # The decorator `@common.mqtt_rpc_channel` means that the trait needs 229 # to use the mqtt_rpc_channel (cloud only) instead of the rpc_channel (adaptive) 230 if hasattr(trait, "mqtt_rpc_channel"): 231 return self._mqtt_rpc_channel 232 elif hasattr(trait, "map_rpc_channel"): 233 return self._map_rpc_channel 234 else: 235 return self._rpc_channel 236 237 async def start(self) -> None: 238 """Start the properties API and discover features.""" 239 if self._unsub: 240 return 241 await self.discover_features() 242 self._unsub = self._add_dps_listener(self._on_dps_update) 243 244 def close(self) -> None: 245 if self._unsub: 246 self._unsub() 247 self._unsub = None 248 249 def _on_dps_update(self, dps: dict[RoborockDataProtocol, Any]) -> None: 250 """Handle incoming messages from the device. 251 252 This will notify all traits of the new values. This can be improved in 253 the future to be dynamic when we have more traits that support dynamic 254 updates but for now we just invoke them manually. 255 """ 256 _LOGGER.debug("Received message from device: %s", dps) 257 self.status.update_from_dps(dps) 258 self.consumables.update_from_dps(dps) 259 260 async def discover_features(self) -> None: 261 """Populate any supported traits that were not initialized in __init__.""" 262 _LOGGER.debug("Starting optional trait discovery") 263 await self.device_features.refresh() 264 # Dock type also acts like a device feature for some traits. 265 dock_type = await self._dock_type() 266 267 # Initialize traits with special arguments before the generic loop 268 if self.wash_towel_mode is None and self._is_supported(WashTowelModeTrait, "wash_towel_mode", dock_type): 269 wash_towel_mode = WashTowelModeTrait(self.device_features) 270 wash_towel_mode._rpc_channel = self._get_rpc_channel(wash_towel_mode) # type: ignore[assignment] 271 self.wash_towel_mode = wash_towel_mode 272 273 # Dynamically create any traits that need to be populated 274 for item in fields(self): 275 if (trait := getattr(self, item.name, None)) is not None: 276 continue 277 if (union_args := get_args(item.type)) is None: 278 raise ValueError(f"Unexpected non-union type for trait {item.name}: {item.type}") 279 if len(union_args) != 2 or type(None) not in union_args: 280 raise ValueError(f"Unexpected non-optional type for trait {item.name}: {item.type}") 281 282 # Union args may not be in declared order 283 item_type = union_args[0] if union_args[1] is type(None) else union_args[1] 284 if not self._is_supported(item_type, item.name, dock_type): 285 _LOGGER.debug("Trait '%s' not supported, skipping", item.name) 286 continue 287 _LOGGER.debug("Trait '%s' is supported, initializing", item.name) 288 trait = item_type() 289 setattr(self, item.name, trait) 290 trait._rpc_channel = self._get_rpc_channel(trait) 291 292 def _is_supported(self, trait_type: type[V1TraitMixin], name: str, dock_type: RoborockDockTypeCode) -> bool: 293 """Check if a trait is supported by the device.""" 294 295 if (requires_dock_type := getattr(trait_type, "requires_dock_type", None)) is not None: 296 return requires_dock_type(dock_type) 297 298 if (feature_name := getattr(trait_type, "requires_feature", None)) is None: 299 _LOGGER.debug("Optional trait missing 'requires_feature' attribute %s, skipping", name) 300 return False 301 if (is_supported := getattr(self.device_features, feature_name)) is None: 302 raise ValueError(f"Device feature '{feature_name}' on trait '{name}' is unknown") 303 return is_supported 304 305 async def _dock_type(self) -> RoborockDockTypeCode: 306 """Get the dock type from the status trait or cache.""" 307 dock_type = await self._get_cached_trait_data("dock_type") 308 if dock_type is not None: 309 _LOGGER.debug("Using cached dock type: %s", dock_type) 310 try: 311 return RoborockDockTypeCode(dock_type) 312 except ValueError: 313 _LOGGER.debug("Cached dock type %s is invalid, refreshing", dock_type) 314 315 _LOGGER.debug("Starting dock type discovery") 316 await self.status.refresh() 317 _LOGGER.debug("Fetched dock type: %s", self.status.dock_type) 318 if self.status.dock_type is None: 319 # Explicitly set so we reuse cached value next type 320 dock_type = RoborockDockTypeCode.no_dock 321 else: 322 dock_type = self.status.dock_type 323 await self._set_cached_trait_data("dock_type", dock_type) 324 return dock_type 325 326 async def _get_cached_trait_data(self, name: str) -> Any: 327 """Get the dock type from the status trait or cache.""" 328 cache_data = await self._device_cache.get() 329 if cache_data.trait_data is None: 330 cache_data.trait_data = {} 331 _LOGGER.debug("Cached trait data: %s", cache_data.trait_data) 332 return cache_data.trait_data.get(name) 333 334 async def _set_cached_trait_data(self, name: str, value: Any) -> None: 335 """Set trait-specific cached data.""" 336 cache_data = await self._device_cache.get() 337 if cache_data.trait_data is None: 338 cache_data.trait_data = {} 339 cache_data.trait_data[name] = value 340 _LOGGER.debug("Updating cached trait data: %s", cache_data.trait_data) 341 await self._device_cache.set(cache_data) 342 343 def as_dict(self) -> dict[str, Any]: 344 """Return the trait data as a dictionary.""" 345 result: dict[str, Any] = {} 346 for item in fields(self): 347 trait = getattr(self, item.name, None) 348 if trait is None or not isinstance(trait, RoborockBase): 349 continue 350 data = trait.as_dict() 351 if data: # Don't omit unset traits 352 result[item.name] = data 353 return result
Common properties for V1 devices.
This class holds all the traits that are common across all V1 devices.
175 def __init__( 176 self, 177 device_uid: str, 178 product: HomeDataProduct, 179 home_data: HomeData, 180 rpc_channel: V1RpcChannel, 181 mqtt_rpc_channel: V1RpcChannel, 182 map_rpc_channel: V1RpcChannel, 183 add_dps_listener: Callable[[Callable[[dict[RoborockDataProtocol, Any]], None]], Callable[[], None]], 184 web_api: UserWebApiClient, 185 device_cache: DeviceCache, 186 map_parser_config: MapParserConfig | None = None, 187 region: str | None = None, 188 ) -> None: 189 """Initialize the V1TraitProps.""" 190 self._device_uid = device_uid 191 self._rpc_channel = rpc_channel 192 self._mqtt_rpc_channel = mqtt_rpc_channel 193 self._map_rpc_channel = map_rpc_channel 194 self._web_api = web_api 195 self._device_cache = device_cache 196 self._region = region 197 self._unsub: Callable[[], None] | None = None 198 self._add_dps_listener = add_dps_listener 199 200 self.device_features = DeviceFeaturesTrait(product, self._device_cache) 201 self.status = StatusTrait(self.device_features, region=self._region) 202 self.consumables = ConsumableTrait() 203 self.rooms = RoomsTrait(home_data, device_uid, web_api) 204 self.maps = MapsTrait(self.status) 205 self.map_content = MapContentTrait(map_parser_config) 206 self.home = HomeTrait(self.status, self.maps, self.map_content, self.rooms, self._device_cache) 207 self.network_info = NetworkInfoTrait(device_uid, self._device_cache) 208 self.routines = RoutinesTrait(device_uid, web_api) 209 210 # Dynamically create any traits that need to be populated 211 for item in fields(self): 212 if (trait := getattr(self, item.name, None)) is None: 213 # We exclude optional features and them via discover_features 214 if (union_args := get_args(item.type)) is None or len(union_args) > 0: 215 continue 216 _LOGGER.debug("Trait '%s' is supported, initializing", item.name) 217 if not callable(item.type): 218 continue 219 trait = item.type() 220 setattr(self, item.name, trait) 221 # This is a hack to allow setting the rpc_channel on all traits. This is 222 # used so we can preserve the dataclass behavior when the values in the 223 # traits are updated, but still want to allow them to have a reference 224 # to the rpc channel for sending commands. 225 trait._rpc_channel = self._get_rpc_channel(trait)
Initialize the V1TraitProps.
237 async def start(self) -> None: 238 """Start the properties API and discover features.""" 239 if self._unsub: 240 return 241 await self.discover_features() 242 self._unsub = self._add_dps_listener(self._on_dps_update)
Start the properties API and discover features.
260 async def discover_features(self) -> None: 261 """Populate any supported traits that were not initialized in __init__.""" 262 _LOGGER.debug("Starting optional trait discovery") 263 await self.device_features.refresh() 264 # Dock type also acts like a device feature for some traits. 265 dock_type = await self._dock_type() 266 267 # Initialize traits with special arguments before the generic loop 268 if self.wash_towel_mode is None and self._is_supported(WashTowelModeTrait, "wash_towel_mode", dock_type): 269 wash_towel_mode = WashTowelModeTrait(self.device_features) 270 wash_towel_mode._rpc_channel = self._get_rpc_channel(wash_towel_mode) # type: ignore[assignment] 271 self.wash_towel_mode = wash_towel_mode 272 273 # Dynamically create any traits that need to be populated 274 for item in fields(self): 275 if (trait := getattr(self, item.name, None)) is not None: 276 continue 277 if (union_args := get_args(item.type)) is None: 278 raise ValueError(f"Unexpected non-union type for trait {item.name}: {item.type}") 279 if len(union_args) != 2 or type(None) not in union_args: 280 raise ValueError(f"Unexpected non-optional type for trait {item.name}: {item.type}") 281 282 # Union args may not be in declared order 283 item_type = union_args[0] if union_args[1] is type(None) else union_args[1] 284 if not self._is_supported(item_type, item.name, dock_type): 285 _LOGGER.debug("Trait '%s' not supported, skipping", item.name) 286 continue 287 _LOGGER.debug("Trait '%s' is supported, initializing", item.name) 288 trait = item_type() 289 setattr(self, item.name, trait) 290 trait._rpc_channel = self._get_rpc_channel(trait)
Populate any supported traits that were not initialized in __init__.
343 def as_dict(self) -> dict[str, Any]: 344 """Return the trait data as a dictionary.""" 345 result: dict[str, Any] = {} 346 for item in fields(self): 347 trait = getattr(self, item.name, None) 348 if trait is None or not isinstance(trait, RoborockBase): 349 continue 350 data = trait.as_dict() 351 if data: # Don't omit unset traits 352 result[item.name] = data 353 return result
Return the trait data as a dictionary.