Skip to content

Commit 86acd87

Browse files
authored
feat: Implement device capability metadata for status computed property fields (#875)
* feat: Implement device capability metadata for status computed property fields * docs: Document metadata keys in field_metadata and is_field_supported * test: Dynamically mock new_feature_info from device_info.new_feature_set * test: Share default new_feature_info_str from APP_GET_INIT_STATUS and parse unconditionally * test: Unconditionally retrieve new_feature_set and assert it exists * fix: Gate dock_error_status on has_dock rather than is_collectable
1 parent 8dc9598 commit 86acd87

8 files changed

Lines changed: 179 additions & 28 deletions

File tree

roborock/data/containers.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,25 @@ def _attr_repr(obj: Any) -> str:
6060
return f"{type(obj).__name__}({', '.join(parts)})"
6161

6262

63+
def field_metadata(**kwargs):
64+
"""Decorator to attach capability check metadata to a property.
65+
66+
This attaches a `_field_metadata` dictionary to the underlying getter function,
67+
which is then preserved when decorated with `@property`.
68+
69+
Supported metadata keys:
70+
- `feature` (str): Name of a capability property on `DeviceFeaturesTrait`.
71+
- `dock_feature` (str): Name of a capability property on `RoborockDockFeatures`.
72+
- `dps` (str/int): RoborockDataProtocol ID to check against supported schema IDs.
73+
"""
74+
75+
def decorator(func):
76+
func._field_metadata = kwargs
77+
return func
78+
79+
return decorator
80+
81+
6382
@dataclass(repr=False)
6483
class RoborockBase:
6584
"""Base class for all Roborock data classes."""

roborock/data/v1/v1_containers.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
from roborock.exceptions import RoborockException
4040
from roborock.roborock_message import RoborockDataProtocol
4141

42-
from ..containers import NamedRoomMapping, RoborockBase, RoborockBaseTimer, _attr_repr
42+
from ..containers import NamedRoomMapping, RoborockBase, RoborockBaseTimer, _attr_repr, field_metadata
4343
from .v1_clean_modes import WashTowelModes
4444
from .v1_code_mappings import (
4545
CleanFluidStatus,
@@ -117,6 +117,15 @@ class StatusField(FieldNameBase):
117117
CHARGE_STATUS = "charge_status"
118118
DRY_STATUS = "dry_status"
119119
ERROR_CODE = "error_code"
120+
WATER_BOX_CARRIAGE_STATUS = "water_box_carriage_status"
121+
WATER_BOX_STATUS = "water_box_status"
122+
WATER_SHORTAGE_STATUS = "water_shortage_status"
123+
DIRTY_WATER_BOX_STATUS = "dirty_water_box_status"
124+
CLEAR_WATER_BOX_STATUS = "clear_water_box_status"
125+
CLEAN_FLUID_STATUS = "clean_fluid_status"
126+
CLEAN_PERCENT = "clean_percent"
127+
DOCK_ERROR_STATUS = "dock_error_status"
128+
RDT = "rdt"
120129

121130

122131
@dataclass
@@ -246,12 +255,14 @@ def has_am(self) -> bool | None:
246255
return (self.dss & 3) == 2
247256

248257
@property
258+
@field_metadata(dock_feature="is_washable")
249259
def clear_water_box_status(self) -> ClearWaterBoxStatus | None:
250260
if self.dss:
251261
return ClearWaterBoxStatus((self.dss >> 2) & 3)
252262
return None
253263

254264
@property
265+
@field_metadata(dock_feature="is_washable")
255266
def dirty_water_box_status(self) -> DirtyWaterBoxStatus | None:
256267
if self.dss:
257268
return DirtyWaterBoxStatus((self.dss >> 4) & 3)
@@ -270,6 +281,9 @@ def water_box_filter_status(self) -> int | None:
270281
return None
271282

272283
@property
284+
@field_metadata(
285+
dock_feature="is_clean_fluid_auto_delivery_supported",
286+
)
273287
def clean_fluid_status(self) -> CleanFluidStatus | None:
274288
if self.dss:
275289
value = (self.dss >> 10) & 3
@@ -313,7 +327,7 @@ class StatusV2(RoborockBase):
313327
in_returning: int | None = None
314328
in_fresh_state: int | None = None
315329
lab_status: int | None = None
316-
water_box_status: int | None = None
330+
water_box_status: int | None = field(default=None, metadata={"feature": "is_support_water_mode"})
317331
back_type: int | None = None
318332
wash_phase: int | None = None
319333
wash_ready: int | None = None
@@ -323,14 +337,14 @@ class StatusV2(RoborockBase):
323337
is_locating: int | None = None
324338
lock_status: int | None = None
325339
water_box_mode: int | None = field(default=None, metadata={"dps": RoborockDataProtocol.WATER_BOX_MODE})
326-
water_box_carriage_status: int | None = None
340+
water_box_carriage_status: int | None = field(default=None, metadata={"feature": "is_support_water_mode"})
327341
mop_forbidden_enable: int | None = None
328342
camera_status: int | None = None
329343
is_exploring: int | None = None
330344
home_sec_status: int | None = None
331345
home_sec_enable_password: int | None = None
332346
adbumper_status: list[int] | None = None
333-
water_shortage_status: int | None = None
347+
water_shortage_status: int | None = field(default=None, metadata={"feature": "is_support_water_mode"})
334348
dock_type: RoborockDockTypeCode | None = None
335349
dust_collection_status: int | None = None
336350
auto_dust_collection: int | None = None
@@ -339,7 +353,8 @@ class StatusV2(RoborockBase):
339353
debug_mode: int | None = None
340354
collision_avoid_status: int | None = None
341355
switch_map_mode: int | None = None
342-
dock_error_status: RoborockDockErrorCode | None = None
356+
dock_error_status: RoborockDockErrorCode | None = field(default=None, metadata={"dock_feature": "has_dock"})
357+
343358
charge_status: RoborockChargeStatus | None = field(
344359
default=None, metadata={"dps": RoborockDataProtocol.CHARGE_STATUS}
345360
)
@@ -349,8 +364,8 @@ class StatusV2(RoborockBase):
349364
distance_off: int | None = None
350365
in_warmup: int | None = None
351366
dry_status: int | None = field(default=None, metadata={"dps": RoborockDataProtocol.DRYING_STATUS})
352-
rdt: int | None = None
353-
clean_percent: int | None = None
367+
rdt: int | None = field(default=None, metadata={"feature": "is_supported_drying"})
368+
clean_percent: int | None = field(default=None, metadata={"feature": "is_support_clean_estimate"})
354369
rss: int | None = None
355370
dss: int | None = None
356371
common_status: int | None = None
@@ -389,12 +404,14 @@ def has_am(self) -> bool | None:
389404
return (self.dss & 3) == 2
390405

391406
@property
407+
@field_metadata(dock_feature="is_washable")
392408
def clear_water_box_status(self) -> ClearWaterBoxStatus | None:
393409
if self.dss:
394410
return ClearWaterBoxStatus((self.dss >> 2) & 3)
395411
return None
396412

397413
@property
414+
@field_metadata(dock_feature="is_washable")
398415
def dirty_water_box_status(self) -> DirtyWaterBoxStatus | None:
399416
if self.dss:
400417
return DirtyWaterBoxStatus((self.dss >> 4) & 3)
@@ -413,6 +430,9 @@ def water_box_filter_status(self) -> int | None:
413430
return None
414431

415432
@property
433+
@field_metadata(
434+
dock_feature="is_clean_fluid_auto_delivery_supported",
435+
)
416436
def clean_fluid_status(self) -> CleanFluidStatus | None:
417437
if self.dss:
418438
value = (self.dss >> 10) & 3

roborock/devices/traits/v1/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ async def discover_features(self) -> None:
264264
# Dock type also acts like a device feature for some traits.
265265
dock_type = await self._dock_type()
266266
dock_features = RoborockDockFeatures.from_dock_type(dock_type, has_am=self.status.has_am)
267+
self.device_features.dock_features = dock_features
267268

268269
# Initialize traits with special arguments before the generic loop
269270
if self.wash_towel_mode is None and self._is_supported(WashTowelModeTrait, "wash_towel_mode", dock_features):

roborock/devices/traits/v1/device_features.py

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,33 @@
1-
from dataclasses import Field, fields
1+
from dataclasses import fields
2+
from typing import Any
23

34
from roborock.data import AppInitStatus, HomeDataProduct, RoborockBase
5+
from roborock.data.v1 import RoborockDockTypeCode
46
from roborock.data.v1.v1_containers import FieldNameBase
5-
from roborock.device_features import DeviceFeatures
7+
from roborock.device_features import DeviceFeatures, RoborockDockFeatures
68
from roborock.devices.cache import DeviceCache
79
from roborock.devices.traits.v1 import common
810
from roborock.roborock_typing import RoborockCommand
911

12+
# Cache of metadata for each trait class
13+
_metadata_cache: dict[type[RoborockBase], dict[str, dict[str, Any]]] = {}
14+
15+
16+
def _get_field_metadata(cls: type[RoborockBase]) -> dict[str, Any]:
17+
"""Helper to get metadata from either class properties or dataclass fields."""
18+
if cls not in _metadata_cache:
19+
metadata_map = {}
20+
# Inspect properties with @field_metadata
21+
for name in dir(cls):
22+
prop = getattr(cls, name, None)
23+
if isinstance(prop, property):
24+
metadata_map[name] = getattr(prop.fget, "_field_metadata", {})
25+
# Inspect dataclass fields metadata
26+
for f in fields(cls):
27+
metadata_map[f.name] = f.metadata
28+
_metadata_cache[cls] = metadata_map
29+
return _metadata_cache[cls]
30+
1031

1132
class DeviceTraitsConverter(common.V1TraitDataConverter):
1233
"""Converter for APP_GET_INIT_STATUS responses into DeviceFeatures."""
@@ -40,31 +61,37 @@ def __init__(self, product: HomeDataProduct, device_cache: DeviceCache) -> None:
4061
self.converter = DeviceTraitsConverter(product)
4162
self._product = product
4263
self._device_cache = device_cache
64+
# Dock features are populated after device feature discovery
65+
# is triggered.
66+
self.dock_features: RoborockDockFeatures = RoborockDockFeatures.from_dock_type(RoborockDockTypeCode.o0_dock)
4367
# All fields of DeviceFeatures are required. Initialize them to False
4468
# so we have some known state.
4569
for field in fields(self):
4670
setattr(self, field.name, False)
4771

48-
@staticmethod
49-
def _get_dataclass_field(cls: type[RoborockBase], field_name: FieldNameBase) -> Field:
50-
"""Look up a dataclass field by its FieldNameBase name."""
51-
for f in fields(cls):
52-
if f.name == field_name:
53-
return f
54-
raise ValueError(f"Field {field_name!r} not found in {cls}")
55-
5672
def is_field_supported(self, cls: type[RoborockBase], field_name: FieldNameBase) -> bool:
5773
"""Determines if the specified field is supported by this device.
5874
59-
We use the `dps` dataclass field metadata to get the `RoborockDataProtocol`
60-
integer ID and check it against the set of supported schema IDs for the
61-
device returned in the product information.
75+
We inspect the metadata defined for the field (either via dataclass field metadata
76+
or the `@field_metadata` decorator on properties). Supported checks include:
77+
78+
- `feature`: Maps to a boolean capability property on `DeviceFeatures` / `DeviceFeaturesTrait`
79+
(e.g. `is_support_water_mode`).
80+
- `dock_feature`: Maps to a boolean capability property on `RoborockDockFeatures` (e.g. `is_washable`).
81+
- `dps`: Maps to a `RoborockDataProtocol` ID checked against the product's supported schema IDs.
6282
"""
63-
dataclass_field = self._get_dataclass_field(cls, field_name)
64-
if (dps := dataclass_field.metadata.get("dps")) is None:
65-
# No DPS metadata — field is assumed always supported
66-
return True
67-
return int(dps) in self._product.supported_schema_ids
83+
if self.dock_features is None:
84+
raise ValueError("DeviceFeaturesTrait was invoked but was not fully initialized")
85+
metadata_map = _get_field_metadata(cls)
86+
if (field_metadata := metadata_map.get(field_name)) is not None:
87+
if (feature := field_metadata.get("feature")) is not None:
88+
return getattr(self, feature, False)
89+
if (dock_feature := field_metadata.get("dock_feature")) is not None:
90+
return getattr(self.dock_features, dock_feature, False)
91+
if (dps := field_metadata.get("dps")) is not None:
92+
return int(dps) in self._product.supported_schema_ids
93+
# No metadata, field is assumed always supported
94+
return True
6895

6996
async def refresh(self) -> None:
7097
"""Refresh the contents of this trait.

tests/devices/traits/v1/__snapshots__/test_device_features.ambr

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,66 +3,120 @@
33
dict({
44
'battery': True,
55
'charge_status': True,
6+
'clean_fluid_status': True,
7+
'clean_percent': True,
8+
'clear_water_box_status': True,
9+
'dirty_water_box_status': True,
10+
'dock_error_status': True,
611
'dry_status': True,
712
'error_code': True,
813
'fan_power': True,
14+
'rdt': True,
915
'state': True,
16+
'water_box_carriage_status': True,
1017
'water_box_mode': True,
18+
'water_box_status': True,
19+
'water_shortage_status': True,
1120
})
1221
# ---
1322
# name: test_is_attribute_supported[home_data_device_q5_max.json]
1423
dict({
1524
'battery': True,
1625
'charge_status': True,
26+
'clean_fluid_status': False,
27+
'clean_percent': True,
28+
'clear_water_box_status': False,
29+
'dirty_water_box_status': False,
30+
'dock_error_status': True,
1731
'dry_status': True,
1832
'error_code': True,
1933
'fan_power': True,
34+
'rdt': False,
2035
'state': True,
36+
'water_box_carriage_status': True,
2137
'water_box_mode': True,
38+
'water_box_status': True,
39+
'water_shortage_status': True,
2240
})
2341
# ---
2442
# name: test_is_attribute_supported[home_data_device_s5e.json]
2543
dict({
2644
'battery': True,
2745
'charge_status': True,
46+
'clean_fluid_status': False,
47+
'clean_percent': False,
48+
'clear_water_box_status': False,
49+
'dirty_water_box_status': False,
50+
'dock_error_status': False,
2851
'dry_status': True,
2952
'error_code': True,
3053
'fan_power': True,
54+
'rdt': False,
3155
'state': True,
56+
'water_box_carriage_status': True,
3257
'water_box_mode': True,
58+
'water_box_status': True,
59+
'water_shortage_status': True,
3360
})
3461
# ---
3562
# name: test_is_attribute_supported[home_data_device_s7_maxv.json]
3663
dict({
3764
'battery': True,
3865
'charge_status': True,
66+
'clean_fluid_status': False,
67+
'clean_percent': False,
68+
'clear_water_box_status': True,
69+
'dirty_water_box_status': True,
70+
'dock_error_status': True,
3971
'dry_status': True,
4072
'error_code': True,
4173
'fan_power': True,
74+
'rdt': False,
4275
'state': True,
76+
'water_box_carriage_status': True,
4377
'water_box_mode': True,
78+
'water_box_status': True,
79+
'water_shortage_status': True,
4480
})
4581
# ---
4682
# name: test_is_attribute_supported[home_data_device_saros.json]
4783
dict({
4884
'battery': True,
4985
'charge_status': True,
86+
'clean_fluid_status': True,
87+
'clean_percent': True,
88+
'clear_water_box_status': True,
89+
'dirty_water_box_status': True,
90+
'dock_error_status': True,
5091
'dry_status': True,
5192
'error_code': True,
5293
'fan_power': True,
94+
'rdt': True,
5395
'state': True,
96+
'water_box_carriage_status': True,
5497
'water_box_mode': True,
98+
'water_box_status': True,
99+
'water_shortage_status': True,
55100
})
56101
# ---
57102
# name: test_is_attribute_supported[home_data_device_saros_10r.json]
58103
dict({
59104
'battery': True,
60105
'charge_status': True,
106+
'clean_fluid_status': True,
107+
'clean_percent': True,
108+
'clear_water_box_status': True,
109+
'dirty_water_box_status': True,
110+
'dock_error_status': True,
61111
'dry_status': True,
62112
'error_code': True,
63113
'fan_power': True,
114+
'rdt': True,
64115
'state': True,
116+
'water_box_carriage_status': True,
65117
'water_box_mode': True,
118+
'water_box_status': True,
119+
'water_shortage_status': True,
66120
})
67121
# ---
68122
# name: test_is_consumable_field_supported[home_data_device_pearls.json]

0 commit comments

Comments
 (0)