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_features` - If set, this is a function that accepts a `RoborockDockFeatures`
and returns a boolean indicating whether the trait is supported for that dock.
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_features` - If set, this is a function that accepts a `RoborockDockFeatures` 46 and returns a boolean indicating whether the trait is supported for that dock. 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.device_features import RoborockDockFeatures 63from roborock.devices.cache import DeviceCache 64from roborock.devices.traits import Trait 65from roborock.exceptions import RoborockException 66from roborock.map.map_parser import MapParserConfig 67from roborock.protocols.v1_protocol import V1RpcChannel, decode_data_protocol_message 68from roborock.roborock_message import RoborockDataProtocol, RoborockMessage 69from roborock.web_api import UserWebApiClient 70 71from . import ( 72 child_lock, 73 clean_summary, 74 command, 75 common, 76 consumeable, 77 device_features, 78 do_not_disturb, 79 dust_collection_mode, 80 flow_led_status, 81 home, 82 led_status, 83 map_content, 84 maps, 85 network_info, 86 obstacle_photos, 87 rooms, 88 routines, 89 smart_wash_params, 90 status, 91 valley_electricity_timer, 92 volume, 93 wash_towel_mode, 94) 95from .child_lock import ChildLockTrait 96from .clean_summary import CleanSummaryTrait 97from .command import CommandTrait 98from .common import V1TraitMixin 99from .consumeable import ConsumableTrait 100from .device_features import DeviceFeaturesTrait 101from .do_not_disturb import DoNotDisturbTrait 102from .dust_collection_mode import DustCollectionModeTrait 103from .flow_led_status import FlowLedStatusTrait 104from .home import HomeTrait 105from .led_status import LedStatusTrait 106from .map_content import MapContentTrait 107from .maps import MapsTrait 108from .network_info import NetworkInfoTrait 109from .obstacle_photos import ObstaclePhotoTrait 110from .rooms import RoomsTrait 111from .routines import RoutinesTrait 112from .smart_wash_params import SmartWashParamsTrait 113from .status import StatusTrait 114from .valley_electricity_timer import ValleyElectricityTimerTrait 115from .volume import SoundVolumeTrait 116from .wash_towel_mode import WashTowelModeTrait 117 118_LOGGER = logging.getLogger(__name__) 119 120__all__ = [ 121 "PropertiesApi", 122 "child_lock", 123 "clean_summary", 124 "command", 125 "common", 126 "consumeable", 127 "device_features", 128 "do_not_disturb", 129 "dust_collection_mode", 130 "flow_led_status", 131 "home", 132 "led_status", 133 "map_content", 134 "maps", 135 "network_info", 136 "obstacle_photos", 137 "rooms", 138 "routines", 139 "smart_wash_params", 140 "status", 141 "valley_electricity_timer", 142 "volume", 143 "wash_towel_mode", 144] 145 146 147@dataclass 148class PropertiesApi(Trait): 149 """Common properties for V1 devices. 150 151 This class holds all the traits that are common across all V1 devices. 152 """ 153 154 # All v1 devices have these traits 155 status: StatusTrait 156 command: CommandTrait 157 dnd: DoNotDisturbTrait 158 clean_summary: CleanSummaryTrait 159 sound_volume: SoundVolumeTrait 160 rooms: RoomsTrait 161 maps: MapsTrait 162 map_content: MapContentTrait 163 consumables: ConsumableTrait 164 home: HomeTrait 165 device_features: DeviceFeaturesTrait 166 network_info: NetworkInfoTrait 167 routines: RoutinesTrait 168 169 # Optional features that may not be supported on all devices 170 child_lock: ChildLockTrait | None = None 171 led_status: LedStatusTrait | None = None 172 flow_led_status: FlowLedStatusTrait | None = None 173 valley_electricity_timer: ValleyElectricityTimerTrait | None = None 174 dust_collection_mode: DustCollectionModeTrait | None = None 175 wash_towel_mode: WashTowelModeTrait | None = None 176 smart_wash_params: SmartWashParamsTrait | None = None 177 obstacle_photos: ObstaclePhotoTrait | None = None 178 179 def __init__( 180 self, 181 device_uid: str, 182 product: HomeDataProduct, 183 home_data: HomeData, 184 rpc_channel: V1RpcChannel, 185 mqtt_rpc_channel: V1RpcChannel, 186 map_rpc_channel: V1RpcChannel, 187 blob_rpc_channel: V1RpcChannel, 188 add_dps_listener: Callable[[Callable[[dict[RoborockDataProtocol, Any]], None]], Callable[[], None]], 189 web_api: UserWebApiClient, 190 device_cache: DeviceCache, 191 map_parser_config: MapParserConfig | None = None, 192 region: str | None = None, 193 ) -> None: 194 """Initialize the V1TraitProps.""" 195 self._device_uid = device_uid 196 self._rpc_channel = rpc_channel 197 self._mqtt_rpc_channel = mqtt_rpc_channel 198 self._map_rpc_channel = map_rpc_channel 199 self._blob_rpc_channel = blob_rpc_channel 200 self._web_api = web_api 201 self._device_cache = device_cache 202 self._region = region 203 self._unsub: Callable[[], None] | None = None 204 self._add_dps_listener = add_dps_listener 205 206 self.device_features = DeviceFeaturesTrait(product, self._device_cache) 207 self.status = StatusTrait(self.device_features, region=self._region) 208 self.consumables = ConsumableTrait() 209 self.rooms = RoomsTrait(home_data, device_uid, web_api) 210 self.maps = MapsTrait(self.status) 211 self.map_content = MapContentTrait(map_parser_config) 212 self.home = HomeTrait(self.status, self.maps, self.map_content, self.rooms, self._device_cache) 213 self.network_info = NetworkInfoTrait(device_uid, self._device_cache) 214 self.routines = RoutinesTrait(device_uid, web_api) 215 216 # Dynamically create any traits that need to be populated 217 for item in fields(self): 218 if (trait := getattr(self, item.name, None)) is None: 219 # We exclude optional features and them via discover_features 220 if (union_args := get_args(item.type)) is None or len(union_args) > 0: 221 continue 222 _LOGGER.debug("Trait '%s' is supported, initializing", item.name) 223 if not callable(item.type): 224 continue 225 trait = item.type() 226 setattr(self, item.name, trait) 227 # This is a hack to allow setting the rpc_channel on all traits. This is 228 # used so we can preserve the dataclass behavior when the values in the 229 # traits are updated, but still want to allow them to have a reference 230 # to the rpc channel for sending commands. 231 trait._rpc_channel = self._get_rpc_channel(trait) 232 233 def _get_rpc_channel(self, trait: V1TraitMixin) -> V1RpcChannel: 234 # The decorator `@common.mqtt_rpc_channel` means that the trait needs 235 # to use the mqtt_rpc_channel (cloud only) instead of the rpc_channel (adaptive) 236 if hasattr(trait, "mqtt_rpc_channel"): 237 return self._mqtt_rpc_channel 238 elif hasattr(trait, "blob_rpc_channel"): 239 return self._blob_rpc_channel 240 elif hasattr(trait, "map_rpc_channel"): 241 return self._map_rpc_channel 242 else: 243 return self._rpc_channel 244 245 async def start(self) -> None: 246 """Start the properties API and discover features.""" 247 if self._unsub: 248 return 249 await self.discover_features() 250 self._unsub = self._add_dps_listener(self._on_dps_update) 251 252 def close(self) -> None: 253 if self._unsub: 254 self._unsub() 255 self._unsub = None 256 257 def _on_dps_update(self, dps: dict[RoborockDataProtocol, Any]) -> None: 258 """Handle incoming messages from the device. 259 260 This will notify all traits of the new values. This can be improved in 261 the future to be dynamic when we have more traits that support dynamic 262 updates but for now we just invoke them manually. 263 """ 264 _LOGGER.debug("Received message from device: %s", dps) 265 self.status.update_from_dps(dps) 266 self.consumables.update_from_dps(dps) 267 268 async def discover_features(self) -> None: 269 """Populate any supported traits that were not initialized in __init__.""" 270 _LOGGER.debug("Starting optional trait discovery") 271 await self.device_features.refresh() 272 # Dock type also acts like a device feature for some traits. 273 dock_type = await self._dock_type() 274 dock_features = RoborockDockFeatures.from_dock_type(dock_type, has_am=self.status.has_am) 275 self.device_features.dock_features = dock_features 276 277 # Initialize traits with special arguments before the generic loop 278 if self.wash_towel_mode is None and self._is_supported(WashTowelModeTrait, "wash_towel_mode", dock_features): 279 wash_towel_mode = WashTowelModeTrait(self.device_features) 280 wash_towel_mode._rpc_channel = self._get_rpc_channel(wash_towel_mode) # type: ignore[assignment] 281 self.wash_towel_mode = wash_towel_mode 282 283 if self.obstacle_photos is None and self._is_supported(ObstaclePhotoTrait, "obstacle_photos", dock_features): 284 obstacle_photos = ObstaclePhotoTrait(self._rpc_channel) 285 obstacle_photos._rpc_channel = self._get_rpc_channel(obstacle_photos) 286 self.obstacle_photos = obstacle_photos 287 288 # Dynamically create any traits that need to be populated 289 for item in fields(self): 290 if (trait := getattr(self, item.name, None)) is not None: 291 continue 292 if (union_args := get_args(item.type)) is None: 293 raise ValueError(f"Unexpected non-union type for trait {item.name}: {item.type}") 294 if len(union_args) != 2 or type(None) not in union_args: 295 raise ValueError(f"Unexpected non-optional type for trait {item.name}: {item.type}") 296 297 # Union args may not be in declared order 298 item_type = union_args[0] if union_args[1] is type(None) else union_args[1] 299 if item_type is ObstaclePhotoTrait: 300 continue 301 if not self._is_supported(item_type, item.name, dock_features): 302 _LOGGER.debug("Trait '%s' not supported, skipping", item.name) 303 continue 304 _LOGGER.debug("Trait '%s' is supported, initializing", item.name) 305 trait = item_type() 306 setattr(self, item.name, trait) 307 trait._rpc_channel = self._get_rpc_channel(trait) 308 309 def _is_supported(self, trait_type: type[V1TraitMixin], name: str, dock_features: RoborockDockFeatures) -> bool: 310 """Check if a trait is supported by the device.""" 311 312 if (requires_dock_features := getattr(trait_type, "requires_dock_features", None)) is not None: 313 return requires_dock_features(dock_features) 314 315 if (feature_name := getattr(trait_type, "requires_feature", None)) is None: 316 _LOGGER.debug("Optional trait missing 'requires_feature' attribute %s, skipping", name) 317 return False 318 if (is_supported := getattr(self.device_features, feature_name)) is None: 319 raise ValueError(f"Device feature '{feature_name}' on trait '{name}' is unknown") 320 return is_supported 321 322 async def _dock_type(self) -> RoborockDockTypeCode: 323 """Get the dock type from the status trait or cache.""" 324 dock_type = await self._get_cached_trait_data("dock_type") 325 if dock_type is not None: 326 _LOGGER.debug("Using cached dock type: %s", dock_type) 327 try: 328 dock_type = RoborockDockTypeCode(dock_type) 329 except ValueError: 330 _LOGGER.debug("Cached dock type %s is invalid, refreshing", dock_type) 331 else: 332 if self.status.dss is None: 333 await self.status.refresh() 334 if self.status.dock_type is not None: 335 dock_type = self.status.dock_type 336 await self._set_cached_trait_data("dock_type", dock_type) 337 return dock_type 338 339 _LOGGER.debug("Starting dock type discovery") 340 await self.status.refresh() 341 _LOGGER.debug("Fetched dock type: %s", self.status.dock_type) 342 if self.status.dock_type is None: 343 # Explicitly set so we reuse cached value next type 344 dock_type = RoborockDockTypeCode.o0_dock 345 else: 346 dock_type = self.status.dock_type 347 await self._set_cached_trait_data("dock_type", dock_type) 348 return dock_type 349 350 async def _get_cached_trait_data(self, name: str) -> Any: 351 """Get the dock type from the status trait or cache.""" 352 cache_data = await self._device_cache.get() 353 if cache_data.trait_data is None: 354 cache_data.trait_data = {} 355 _LOGGER.debug("Cached trait data: %s", cache_data.trait_data) 356 return cache_data.trait_data.get(name) 357 358 async def _set_cached_trait_data(self, name: str, value: Any) -> None: 359 """Set trait-specific cached data.""" 360 cache_data = await self._device_cache.get() 361 if cache_data.trait_data is None: 362 cache_data.trait_data = {} 363 cache_data.trait_data[name] = value 364 _LOGGER.debug("Updating cached trait data: %s", cache_data.trait_data) 365 await self._device_cache.set(cache_data) 366 367 def as_dict(self) -> dict[str, Any]: 368 """Return the trait data as a dictionary.""" 369 result: dict[str, Any] = {} 370 for item in fields(self): 371 trait = getattr(self, item.name, None) 372 if trait is None or not isinstance(trait, RoborockBase): 373 continue 374 data = trait.as_dict() 375 if data: # Don't omit unset traits 376 result[item.name] = data 377 return result 378 379 380def create( 381 device_uid: str, 382 product: HomeDataProduct, 383 home_data: HomeData, 384 rpc_channel: V1RpcChannel, 385 mqtt_rpc_channel: V1RpcChannel, 386 map_rpc_channel: V1RpcChannel, 387 blob_rpc_channel: V1RpcChannel, 388 add_dps_listener: Callable[[Callable[[dict[RoborockDataProtocol, Any]], None]], Callable[[], None]], 389 web_api: UserWebApiClient, 390 device_cache: DeviceCache, 391 map_parser_config: MapParserConfig | None = None, 392 region: str | None = None, 393) -> PropertiesApi: 394 """Create traits for V1 devices.""" 395 return PropertiesApi( 396 device_uid, 397 product, 398 home_data, 399 rpc_channel, 400 mqtt_rpc_channel, 401 map_rpc_channel, 402 blob_rpc_channel, 403 add_dps_listener, 404 web_api, 405 device_cache, 406 map_parser_config, 407 region=region, 408 )
148@dataclass 149class PropertiesApi(Trait): 150 """Common properties for V1 devices. 151 152 This class holds all the traits that are common across all V1 devices. 153 """ 154 155 # All v1 devices have these traits 156 status: StatusTrait 157 command: CommandTrait 158 dnd: DoNotDisturbTrait 159 clean_summary: CleanSummaryTrait 160 sound_volume: SoundVolumeTrait 161 rooms: RoomsTrait 162 maps: MapsTrait 163 map_content: MapContentTrait 164 consumables: ConsumableTrait 165 home: HomeTrait 166 device_features: DeviceFeaturesTrait 167 network_info: NetworkInfoTrait 168 routines: RoutinesTrait 169 170 # Optional features that may not be supported on all devices 171 child_lock: ChildLockTrait | None = None 172 led_status: LedStatusTrait | None = None 173 flow_led_status: FlowLedStatusTrait | None = None 174 valley_electricity_timer: ValleyElectricityTimerTrait | None = None 175 dust_collection_mode: DustCollectionModeTrait | None = None 176 wash_towel_mode: WashTowelModeTrait | None = None 177 smart_wash_params: SmartWashParamsTrait | None = None 178 obstacle_photos: ObstaclePhotoTrait | None = None 179 180 def __init__( 181 self, 182 device_uid: str, 183 product: HomeDataProduct, 184 home_data: HomeData, 185 rpc_channel: V1RpcChannel, 186 mqtt_rpc_channel: V1RpcChannel, 187 map_rpc_channel: V1RpcChannel, 188 blob_rpc_channel: V1RpcChannel, 189 add_dps_listener: Callable[[Callable[[dict[RoborockDataProtocol, Any]], None]], Callable[[], None]], 190 web_api: UserWebApiClient, 191 device_cache: DeviceCache, 192 map_parser_config: MapParserConfig | None = None, 193 region: str | None = None, 194 ) -> None: 195 """Initialize the V1TraitProps.""" 196 self._device_uid = device_uid 197 self._rpc_channel = rpc_channel 198 self._mqtt_rpc_channel = mqtt_rpc_channel 199 self._map_rpc_channel = map_rpc_channel 200 self._blob_rpc_channel = blob_rpc_channel 201 self._web_api = web_api 202 self._device_cache = device_cache 203 self._region = region 204 self._unsub: Callable[[], None] | None = None 205 self._add_dps_listener = add_dps_listener 206 207 self.device_features = DeviceFeaturesTrait(product, self._device_cache) 208 self.status = StatusTrait(self.device_features, region=self._region) 209 self.consumables = ConsumableTrait() 210 self.rooms = RoomsTrait(home_data, device_uid, web_api) 211 self.maps = MapsTrait(self.status) 212 self.map_content = MapContentTrait(map_parser_config) 213 self.home = HomeTrait(self.status, self.maps, self.map_content, self.rooms, self._device_cache) 214 self.network_info = NetworkInfoTrait(device_uid, self._device_cache) 215 self.routines = RoutinesTrait(device_uid, web_api) 216 217 # Dynamically create any traits that need to be populated 218 for item in fields(self): 219 if (trait := getattr(self, item.name, None)) is None: 220 # We exclude optional features and them via discover_features 221 if (union_args := get_args(item.type)) is None or len(union_args) > 0: 222 continue 223 _LOGGER.debug("Trait '%s' is supported, initializing", item.name) 224 if not callable(item.type): 225 continue 226 trait = item.type() 227 setattr(self, item.name, trait) 228 # This is a hack to allow setting the rpc_channel on all traits. This is 229 # used so we can preserve the dataclass behavior when the values in the 230 # traits are updated, but still want to allow them to have a reference 231 # to the rpc channel for sending commands. 232 trait._rpc_channel = self._get_rpc_channel(trait) 233 234 def _get_rpc_channel(self, trait: V1TraitMixin) -> V1RpcChannel: 235 # The decorator `@common.mqtt_rpc_channel` means that the trait needs 236 # to use the mqtt_rpc_channel (cloud only) instead of the rpc_channel (adaptive) 237 if hasattr(trait, "mqtt_rpc_channel"): 238 return self._mqtt_rpc_channel 239 elif hasattr(trait, "blob_rpc_channel"): 240 return self._blob_rpc_channel 241 elif hasattr(trait, "map_rpc_channel"): 242 return self._map_rpc_channel 243 else: 244 return self._rpc_channel 245 246 async def start(self) -> None: 247 """Start the properties API and discover features.""" 248 if self._unsub: 249 return 250 await self.discover_features() 251 self._unsub = self._add_dps_listener(self._on_dps_update) 252 253 def close(self) -> None: 254 if self._unsub: 255 self._unsub() 256 self._unsub = None 257 258 def _on_dps_update(self, dps: dict[RoborockDataProtocol, Any]) -> None: 259 """Handle incoming messages from the device. 260 261 This will notify all traits of the new values. This can be improved in 262 the future to be dynamic when we have more traits that support dynamic 263 updates but for now we just invoke them manually. 264 """ 265 _LOGGER.debug("Received message from device: %s", dps) 266 self.status.update_from_dps(dps) 267 self.consumables.update_from_dps(dps) 268 269 async def discover_features(self) -> None: 270 """Populate any supported traits that were not initialized in __init__.""" 271 _LOGGER.debug("Starting optional trait discovery") 272 await self.device_features.refresh() 273 # Dock type also acts like a device feature for some traits. 274 dock_type = await self._dock_type() 275 dock_features = RoborockDockFeatures.from_dock_type(dock_type, has_am=self.status.has_am) 276 self.device_features.dock_features = dock_features 277 278 # Initialize traits with special arguments before the generic loop 279 if self.wash_towel_mode is None and self._is_supported(WashTowelModeTrait, "wash_towel_mode", dock_features): 280 wash_towel_mode = WashTowelModeTrait(self.device_features) 281 wash_towel_mode._rpc_channel = self._get_rpc_channel(wash_towel_mode) # type: ignore[assignment] 282 self.wash_towel_mode = wash_towel_mode 283 284 if self.obstacle_photos is None and self._is_supported(ObstaclePhotoTrait, "obstacle_photos", dock_features): 285 obstacle_photos = ObstaclePhotoTrait(self._rpc_channel) 286 obstacle_photos._rpc_channel = self._get_rpc_channel(obstacle_photos) 287 self.obstacle_photos = obstacle_photos 288 289 # Dynamically create any traits that need to be populated 290 for item in fields(self): 291 if (trait := getattr(self, item.name, None)) is not None: 292 continue 293 if (union_args := get_args(item.type)) is None: 294 raise ValueError(f"Unexpected non-union type for trait {item.name}: {item.type}") 295 if len(union_args) != 2 or type(None) not in union_args: 296 raise ValueError(f"Unexpected non-optional type for trait {item.name}: {item.type}") 297 298 # Union args may not be in declared order 299 item_type = union_args[0] if union_args[1] is type(None) else union_args[1] 300 if item_type is ObstaclePhotoTrait: 301 continue 302 if not self._is_supported(item_type, item.name, dock_features): 303 _LOGGER.debug("Trait '%s' not supported, skipping", item.name) 304 continue 305 _LOGGER.debug("Trait '%s' is supported, initializing", item.name) 306 trait = item_type() 307 setattr(self, item.name, trait) 308 trait._rpc_channel = self._get_rpc_channel(trait) 309 310 def _is_supported(self, trait_type: type[V1TraitMixin], name: str, dock_features: RoborockDockFeatures) -> bool: 311 """Check if a trait is supported by the device.""" 312 313 if (requires_dock_features := getattr(trait_type, "requires_dock_features", None)) is not None: 314 return requires_dock_features(dock_features) 315 316 if (feature_name := getattr(trait_type, "requires_feature", None)) is None: 317 _LOGGER.debug("Optional trait missing 'requires_feature' attribute %s, skipping", name) 318 return False 319 if (is_supported := getattr(self.device_features, feature_name)) is None: 320 raise ValueError(f"Device feature '{feature_name}' on trait '{name}' is unknown") 321 return is_supported 322 323 async def _dock_type(self) -> RoborockDockTypeCode: 324 """Get the dock type from the status trait or cache.""" 325 dock_type = await self._get_cached_trait_data("dock_type") 326 if dock_type is not None: 327 _LOGGER.debug("Using cached dock type: %s", dock_type) 328 try: 329 dock_type = RoborockDockTypeCode(dock_type) 330 except ValueError: 331 _LOGGER.debug("Cached dock type %s is invalid, refreshing", dock_type) 332 else: 333 if self.status.dss is None: 334 await self.status.refresh() 335 if self.status.dock_type is not None: 336 dock_type = self.status.dock_type 337 await self._set_cached_trait_data("dock_type", dock_type) 338 return dock_type 339 340 _LOGGER.debug("Starting dock type discovery") 341 await self.status.refresh() 342 _LOGGER.debug("Fetched dock type: %s", self.status.dock_type) 343 if self.status.dock_type is None: 344 # Explicitly set so we reuse cached value next type 345 dock_type = RoborockDockTypeCode.o0_dock 346 else: 347 dock_type = self.status.dock_type 348 await self._set_cached_trait_data("dock_type", dock_type) 349 return dock_type 350 351 async def _get_cached_trait_data(self, name: str) -> Any: 352 """Get the dock type from the status trait or cache.""" 353 cache_data = await self._device_cache.get() 354 if cache_data.trait_data is None: 355 cache_data.trait_data = {} 356 _LOGGER.debug("Cached trait data: %s", cache_data.trait_data) 357 return cache_data.trait_data.get(name) 358 359 async def _set_cached_trait_data(self, name: str, value: Any) -> None: 360 """Set trait-specific cached data.""" 361 cache_data = await self._device_cache.get() 362 if cache_data.trait_data is None: 363 cache_data.trait_data = {} 364 cache_data.trait_data[name] = value 365 _LOGGER.debug("Updating cached trait data: %s", cache_data.trait_data) 366 await self._device_cache.set(cache_data) 367 368 def as_dict(self) -> dict[str, Any]: 369 """Return the trait data as a dictionary.""" 370 result: dict[str, Any] = {} 371 for item in fields(self): 372 trait = getattr(self, item.name, None) 373 if trait is None or not isinstance(trait, RoborockBase): 374 continue 375 data = trait.as_dict() 376 if data: # Don't omit unset traits 377 result[item.name] = data 378 return result
Common properties for V1 devices.
This class holds all the traits that are common across all V1 devices.
180 def __init__( 181 self, 182 device_uid: str, 183 product: HomeDataProduct, 184 home_data: HomeData, 185 rpc_channel: V1RpcChannel, 186 mqtt_rpc_channel: V1RpcChannel, 187 map_rpc_channel: V1RpcChannel, 188 blob_rpc_channel: V1RpcChannel, 189 add_dps_listener: Callable[[Callable[[dict[RoborockDataProtocol, Any]], None]], Callable[[], None]], 190 web_api: UserWebApiClient, 191 device_cache: DeviceCache, 192 map_parser_config: MapParserConfig | None = None, 193 region: str | None = None, 194 ) -> None: 195 """Initialize the V1TraitProps.""" 196 self._device_uid = device_uid 197 self._rpc_channel = rpc_channel 198 self._mqtt_rpc_channel = mqtt_rpc_channel 199 self._map_rpc_channel = map_rpc_channel 200 self._blob_rpc_channel = blob_rpc_channel 201 self._web_api = web_api 202 self._device_cache = device_cache 203 self._region = region 204 self._unsub: Callable[[], None] | None = None 205 self._add_dps_listener = add_dps_listener 206 207 self.device_features = DeviceFeaturesTrait(product, self._device_cache) 208 self.status = StatusTrait(self.device_features, region=self._region) 209 self.consumables = ConsumableTrait() 210 self.rooms = RoomsTrait(home_data, device_uid, web_api) 211 self.maps = MapsTrait(self.status) 212 self.map_content = MapContentTrait(map_parser_config) 213 self.home = HomeTrait(self.status, self.maps, self.map_content, self.rooms, self._device_cache) 214 self.network_info = NetworkInfoTrait(device_uid, self._device_cache) 215 self.routines = RoutinesTrait(device_uid, web_api) 216 217 # Dynamically create any traits that need to be populated 218 for item in fields(self): 219 if (trait := getattr(self, item.name, None)) is None: 220 # We exclude optional features and them via discover_features 221 if (union_args := get_args(item.type)) is None or len(union_args) > 0: 222 continue 223 _LOGGER.debug("Trait '%s' is supported, initializing", item.name) 224 if not callable(item.type): 225 continue 226 trait = item.type() 227 setattr(self, item.name, trait) 228 # This is a hack to allow setting the rpc_channel on all traits. This is 229 # used so we can preserve the dataclass behavior when the values in the 230 # traits are updated, but still want to allow them to have a reference 231 # to the rpc channel for sending commands. 232 trait._rpc_channel = self._get_rpc_channel(trait)
Initialize the V1TraitProps.
246 async def start(self) -> None: 247 """Start the properties API and discover features.""" 248 if self._unsub: 249 return 250 await self.discover_features() 251 self._unsub = self._add_dps_listener(self._on_dps_update)
Start the properties API and discover features.
269 async def discover_features(self) -> None: 270 """Populate any supported traits that were not initialized in __init__.""" 271 _LOGGER.debug("Starting optional trait discovery") 272 await self.device_features.refresh() 273 # Dock type also acts like a device feature for some traits. 274 dock_type = await self._dock_type() 275 dock_features = RoborockDockFeatures.from_dock_type(dock_type, has_am=self.status.has_am) 276 self.device_features.dock_features = dock_features 277 278 # Initialize traits with special arguments before the generic loop 279 if self.wash_towel_mode is None and self._is_supported(WashTowelModeTrait, "wash_towel_mode", dock_features): 280 wash_towel_mode = WashTowelModeTrait(self.device_features) 281 wash_towel_mode._rpc_channel = self._get_rpc_channel(wash_towel_mode) # type: ignore[assignment] 282 self.wash_towel_mode = wash_towel_mode 283 284 if self.obstacle_photos is None and self._is_supported(ObstaclePhotoTrait, "obstacle_photos", dock_features): 285 obstacle_photos = ObstaclePhotoTrait(self._rpc_channel) 286 obstacle_photos._rpc_channel = self._get_rpc_channel(obstacle_photos) 287 self.obstacle_photos = obstacle_photos 288 289 # Dynamically create any traits that need to be populated 290 for item in fields(self): 291 if (trait := getattr(self, item.name, None)) is not None: 292 continue 293 if (union_args := get_args(item.type)) is None: 294 raise ValueError(f"Unexpected non-union type for trait {item.name}: {item.type}") 295 if len(union_args) != 2 or type(None) not in union_args: 296 raise ValueError(f"Unexpected non-optional type for trait {item.name}: {item.type}") 297 298 # Union args may not be in declared order 299 item_type = union_args[0] if union_args[1] is type(None) else union_args[1] 300 if item_type is ObstaclePhotoTrait: 301 continue 302 if not self._is_supported(item_type, item.name, dock_features): 303 _LOGGER.debug("Trait '%s' not supported, skipping", item.name) 304 continue 305 _LOGGER.debug("Trait '%s' is supported, initializing", item.name) 306 trait = item_type() 307 setattr(self, item.name, trait) 308 trait._rpc_channel = self._get_rpc_channel(trait)
Populate any supported traits that were not initialized in __init__.
368 def as_dict(self) -> dict[str, Any]: 369 """Return the trait data as a dictionary.""" 370 result: dict[str, Any] = {} 371 for item in fields(self): 372 trait = getattr(self, item.name, None) 373 if trait is None or not isinstance(trait, RoborockBase): 374 continue 375 data = trait.as_dict() 376 if data: # Don't omit unset traits 377 result[item.name] = data 378 return result
Return the trait data as a dictionary.