1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339
|
"""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:
1. **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`).
2. **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 `requires_schema_code` field metadata attribute is a string of the schema
code in HomeDataProduct Schema that is required for the field to be supported.
"""
import logging
from dataclasses import dataclass, field, fields
from functools import cache
from typing import Any, get_args
from roborock.data.containers import HomeData, HomeDataProduct, RoborockBase
from roborock.data.v1.v1_code_mappings import RoborockDockTypeCode
from roborock.devices.cache import DeviceCache
from roborock.devices.traits import Trait
from roborock.map.map_parser import MapParserConfig
from roborock.protocols.v1_protocol import V1RpcChannel
from roborock.web_api import UserWebApiClient
from . import (
child_lock,
clean_summary,
command,
common,
consumeable,
device_features,
do_not_disturb,
dust_collection_mode,
flow_led_status,
home,
led_status,
map_content,
maps,
network_info,
rooms,
routines,
smart_wash_params,
status,
valley_electricity_timer,
volume,
wash_towel_mode,
)
from .child_lock import ChildLockTrait
from .clean_summary import CleanSummaryTrait
from .command import CommandTrait
from .common import V1TraitMixin
from .consumeable import ConsumableTrait
from .device_features import DeviceFeaturesTrait
from .do_not_disturb import DoNotDisturbTrait
from .dust_collection_mode import DustCollectionModeTrait
from .flow_led_status import FlowLedStatusTrait
from .home import HomeTrait
from .led_status import LedStatusTrait
from .map_content import MapContentTrait
from .maps import MapsTrait
from .network_info import NetworkInfoTrait
from .rooms import RoomsTrait
from .routines import RoutinesTrait
from .smart_wash_params import SmartWashParamsTrait
from .status import StatusTrait
from .valley_electricity_timer import ValleyElectricityTimerTrait
from .volume import SoundVolumeTrait
from .wash_towel_mode import WashTowelModeTrait
_LOGGER = logging.getLogger(__name__)
__all__ = [
"PropertiesApi",
"child_lock",
"clean_summary",
"command",
"common",
"consumeable",
"device_features",
"do_not_disturb",
"dust_collection_mode",
"flow_led_status",
"home",
"led_status",
"map_content",
"maps",
"network_info",
"rooms",
"routines",
"smart_wash_params",
"status",
"valley_electricity_timer",
"volume",
"wash_towel_mode",
]
@dataclass
class PropertiesApi(Trait):
"""Common properties for V1 devices.
This class holds all the traits that are common across all V1 devices.
"""
# All v1 devices have these traits
status: StatusTrait
command: CommandTrait
dnd: DoNotDisturbTrait
clean_summary: CleanSummaryTrait
sound_volume: SoundVolumeTrait
rooms: RoomsTrait
maps: MapsTrait
map_content: MapContentTrait
consumables: ConsumableTrait
home: HomeTrait
device_features: DeviceFeaturesTrait
network_info: NetworkInfoTrait
routines: RoutinesTrait
# Optional features that may not be supported on all devices
child_lock: ChildLockTrait | None = None
led_status: LedStatusTrait | None = None
flow_led_status: FlowLedStatusTrait | None = None
valley_electricity_timer: ValleyElectricityTimerTrait | None = None
dust_collection_mode: DustCollectionModeTrait | None = None
wash_towel_mode: WashTowelModeTrait | None = None
smart_wash_params: SmartWashParamsTrait | None = None
def __init__(
self,
device_uid: str,
product: HomeDataProduct,
home_data: HomeData,
rpc_channel: V1RpcChannel,
mqtt_rpc_channel: V1RpcChannel,
map_rpc_channel: V1RpcChannel,
web_api: UserWebApiClient,
device_cache: DeviceCache,
map_parser_config: MapParserConfig | None = None,
) -> None:
"""Initialize the V1TraitProps."""
self._device_uid = device_uid
self._rpc_channel = rpc_channel
self._mqtt_rpc_channel = mqtt_rpc_channel
self._map_rpc_channel = map_rpc_channel
self._web_api = web_api
self._device_cache = device_cache
self.status = StatusTrait(product)
self.consumables = ConsumableTrait()
self.rooms = RoomsTrait(home_data)
self.maps = MapsTrait(self.status)
self.map_content = MapContentTrait(map_parser_config)
self.home = HomeTrait(self.status, self.maps, self.map_content, self.rooms, self._device_cache)
self.device_features = DeviceFeaturesTrait(product, self._device_cache)
self.network_info = NetworkInfoTrait(device_uid, self._device_cache)
self.routines = RoutinesTrait(device_uid, web_api)
# Dynamically create any traits that need to be populated
for item in fields(self):
if (trait := getattr(self, item.name, None)) is None:
# We exclude optional features and them via discover_features
if (union_args := get_args(item.type)) is None or len(union_args) > 0:
continue
_LOGGER.debug("Trait '%s' is supported, initializing", item.name)
trait = item.type()
setattr(self, item.name, trait)
# This is a hack to allow setting the rpc_channel on all traits. This is
# used so we can preserve the dataclass behavior when the values in the
# traits are updated, but still want to allow them to have a reference
# to the rpc channel for sending commands.
trait._rpc_channel = self._get_rpc_channel(trait)
def _get_rpc_channel(self, trait: V1TraitMixin) -> V1RpcChannel:
# The decorator `@common.mqtt_rpc_channel` means that the trait needs
# to use the mqtt_rpc_channel (cloud only) instead of the rpc_channel (adaptive)
if hasattr(trait, "mqtt_rpc_channel"):
return self._mqtt_rpc_channel
elif hasattr(trait, "map_rpc_channel"):
return self._map_rpc_channel
else:
return self._rpc_channel
async def discover_features(self) -> None:
"""Populate any supported traits that were not initialized in __init__."""
_LOGGER.debug("Starting optional trait discovery")
await self.device_features.refresh()
# Dock type also acts like a device feature for some traits.
dock_type = await self._dock_type()
# Dynamically create any traits that need to be populated
for item in fields(self):
if (trait := getattr(self, item.name, None)) is not None:
continue
if (union_args := get_args(item.type)) is None:
raise ValueError(f"Unexpected non-union type for trait {item.name}: {item.type}")
if len(union_args) != 2 or type(None) not in union_args:
raise ValueError(f"Unexpected non-optional type for trait {item.name}: {item.type}")
# Union args may not be in declared order
item_type = union_args[0] if union_args[1] is type(None) else union_args[1]
if not self._is_supported(item_type, item.name, dock_type):
_LOGGER.debug("Trait '%s' not supported, skipping", item.name)
continue
_LOGGER.debug("Trait '%s' is supported, initializing", item.name)
trait = item_type()
setattr(self, item.name, trait)
trait._rpc_channel = self._get_rpc_channel(trait)
def _is_supported(self, trait_type: type[V1TraitMixin], name: str, dock_type: RoborockDockTypeCode) -> bool:
"""Check if a trait is supported by the device."""
if (requires_dock_type := getattr(trait_type, "requires_dock_type", None)) is not None:
return requires_dock_type(dock_type)
if (feature_name := getattr(trait_type, "requires_feature", None)) is None:
_LOGGER.debug("Optional trait missing 'requires_feature' attribute %s, skipping", name)
return False
if (is_supported := getattr(self.device_features, feature_name)) is None:
raise ValueError(f"Device feature '{feature_name}' on trait '{name}' is unknown")
return is_supported
async def _dock_type(self) -> RoborockDockTypeCode:
"""Get the dock type from the status trait or cache."""
dock_type = await self._get_cached_trait_data("dock_type")
if dock_type is not None:
_LOGGER.debug("Using cached dock type: %s", dock_type)
try:
return RoborockDockTypeCode(dock_type)
except ValueError:
_LOGGER.debug("Cached dock type %s is invalid, refreshing", dock_type)
_LOGGER.debug("Starting dock type discovery")
await self.status.refresh()
_LOGGER.debug("Fetched dock type: %s", self.status.dock_type)
if self.status.dock_type is None:
# Explicitly set so we reuse cached value next type
dock_type = RoborockDockTypeCode.no_dock
else:
dock_type = self.status.dock_type
await self._set_cached_trait_data("dock_type", dock_type)
return dock_type
async def _get_cached_trait_data(self, name: str) -> Any:
"""Get the dock type from the status trait or cache."""
cache_data = await self._device_cache.get()
if cache_data.trait_data is None:
cache_data.trait_data = {}
_LOGGER.debug("Cached trait data: %s", cache_data.trait_data)
return cache_data.trait_data.get(name)
async def _set_cached_trait_data(self, name: str, value: Any) -> None:
"""Set trait-specific cached data."""
cache_data = await self._device_cache.get()
if cache_data.trait_data is None:
cache_data.trait_data = {}
cache_data.trait_data[name] = value
_LOGGER.debug("Updating cached trait data: %s", cache_data.trait_data)
await self._device_cache.set(cache_data)
def as_dict(self) -> dict[str, Any]:
"""Return the trait data as a dictionary."""
result: dict[str, Any] = {}
for item in fields(self):
trait = getattr(self, item.name, None)
if trait is None or not isinstance(trait, RoborockBase):
continue
data = trait.as_dict()
if data: # Don't omit unset traits
result[item.name] = data
return result
def create(
device_uid: str,
product: HomeDataProduct,
home_data: HomeData,
rpc_channel: V1RpcChannel,
mqtt_rpc_channel: V1RpcChannel,
map_rpc_channel: V1RpcChannel,
web_api: UserWebApiClient,
device_cache: DeviceCache,
map_parser_config: MapParserConfig | None = None,
) -> PropertiesApi:
"""Create traits for V1 devices."""
return PropertiesApi(
device_uid,
product,
home_data,
rpc_channel,
mqtt_rpc_channel,
map_rpc_channel,
web_api,
device_cache,
map_parser_config,
)
|