Skip to content

Commit 718646f

Browse files
committed
feat: Add an approach for determining if a dataclass field is supported
This consists of multiple concepts: - DeviceFeaturesTrait defines a method `is_field_supported` which will tell you if an optional field on a RoborockBase class type is supported or not - The RoborockBase field defins a metadata attribute for a product schea code which is used to check the field against some other source - We could substitue in other values in DeviceFeatures or status in the future as needed. This adds 6 fields that are straight forward to add.
1 parent 586bb3f commit 718646f

File tree

8 files changed

+183
-18
lines changed

8 files changed

+183
-18
lines changed

roborock/data/containers.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,13 @@ def summary_info(self) -> str:
232232
"""Return a string with key product information for logging purposes."""
233233
return f"{self.name} (model={self.model}, category={self.category})"
234234

235+
@cached_property
236+
def supported_schema_codes(self) -> set[str]:
237+
"""Return a set of fields that are supported by the device."""
238+
if self.schema is None:
239+
return set()
240+
return set({schema.code for schema in self.schema if schema.code is not None})
241+
235242

236243
@dataclass
237244
class HomeDataDevice(RoborockBase):

roborock/data/v1/v1_containers.py

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import datetime
22
import logging
3-
from dataclasses import dataclass
3+
from dataclasses import dataclass, field
4+
from enum import Enum
45
from typing import Any
56

67
from roborock.const import (
@@ -91,12 +92,35 @@
9192
_LOGGER = logging.getLogger(__name__)
9293

9394

95+
class FieldNameBase(str, Enum):
96+
"""A base enum class that represents a field name in a RoborockBase dataclass."""
97+
98+
99+
class StatusField(FieldNameBase):
100+
"""An enum that represents a field in the `Status` class.
101+
102+
This is used with `roborock.devices.traits.v1.status.DeviceFeaturesTrait`
103+
to understand if a feature is supported by the device using `is_field_supported`.
104+
105+
The enum values are names of fields in the `Status` class. Each field is
106+
annotated with `requires_schema_code` metadata to map the field to a schema
107+
code in the product schema, which may have a different name than the field/attribute name.
108+
"""
109+
110+
STATE = "state"
111+
BATTERY = "battery"
112+
FAN_POWER = "fan_power"
113+
WATER_BOX_MODE = "water_box_mode"
114+
CHARGE_STATUS = "charge_status"
115+
DRY_STATUS = "dry_status"
116+
117+
94118
@dataclass
95119
class Status(RoborockBase):
96120
msg_ver: int | None = None
97121
msg_seq: int | None = None
98-
state: RoborockStateCode | None = None
99-
battery: int | None = None
122+
state: RoborockStateCode | None = field(metadata={"requires_schema_code": "state"}, default=None)
123+
battery: int | None = field(metadata={"requires_schema_code": "battery"}, default=None)
100124
clean_time: int | None = None
101125
clean_area: int | None = None
102126
error_code: RoborockErrorCode | None = None
@@ -109,12 +133,14 @@ class Status(RoborockBase):
109133
back_type: int | None = None
110134
wash_phase: int | None = None
111135
wash_ready: int | None = None
112-
fan_power: RoborockFanPowerCode | None = None
136+
fan_power: RoborockFanPowerCode | None = field(metadata={"requires_schema_code": "fan_power"}, default=None)
113137
dnd_enabled: int | None = None
114138
map_status: int | None = None
115139
is_locating: int | None = None
116140
lock_status: int | None = None
117-
water_box_mode: RoborockMopIntensityCode | None = None
141+
water_box_mode: RoborockMopIntensityCode | None = field(
142+
metadata={"requires_schema_code": "water_box_mode"}, default=None
143+
)
118144
water_box_carriage_status: int | None = None
119145
mop_forbidden_enable: int | None = None
120146
camera_status: int | None = None
@@ -132,13 +158,13 @@ class Status(RoborockBase):
132158
collision_avoid_status: int | None = None
133159
switch_map_mode: int | None = None
134160
dock_error_status: RoborockDockErrorCode | None = None
135-
charge_status: int | None = None
161+
charge_status: int | None = field(metadata={"requires_schema_code": "charge_status"}, default=None)
136162
unsave_map_reason: int | None = None
137163
unsave_map_flag: int | None = None
138164
wash_status: int | None = None
139165
distance_off: int | None = None
140166
in_warmup: int | None = None
141-
dry_status: int | None = None
167+
dry_status: int | None = field(metadata={"requires_schema_code": "drying_status"}, default=None)
142168
rdt: int | None = None
143169
clean_percent: int | None = None
144170
rss: int | None = None

roborock/devices/traits/v1/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@
4444
available features.
4545
- `requires_dock_type` - If set, this is a function that accepts a `RoborockDockTypeCode`
4646
and returns a boolean indicating whether the trait is supported for that dock type.
47+
48+
Additionally, DeviceFeaturesTrait has a method `is_field_supported` that is used to
49+
check individual trait field values. This is a more fine grained version to allow
50+
optional fields in a dataclass, vs the above feature checks that apply to an entire
51+
trait. The `requires_schema_code` field metadata attribute is a string of the schema
52+
code in HomeDataProduct Schema that is required for the field to be supported.
4753
"""
4854

4955
import logging
@@ -189,7 +195,7 @@ def __init__(
189195
self.maps = MapsTrait(self.status)
190196
self.map_content = MapContentTrait(map_parser_config)
191197
self.home = HomeTrait(self.status, self.maps, self.map_content, self.rooms, self._device_cache)
192-
self.device_features = DeviceFeaturesTrait(product.product_nickname, self._device_cache)
198+
self.device_features = DeviceFeaturesTrait(product, self._device_cache)
193199
self.network_info = NetworkInfoTrait(device_uid, self._device_cache)
194200
self.routines = RoutinesTrait(device_uid, web_api)
195201

roborock/devices/traits/v1/device_features.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,50 @@
1-
from dataclasses import fields
1+
from dataclasses import Field, fields
22

3-
from roborock.data import AppInitStatus, RoborockProductNickname
3+
from roborock.data import AppInitStatus, HomeDataProduct, RoborockBase
4+
from roborock.data.v1.v1_containers import FieldNameBase
45
from roborock.device_features import DeviceFeatures
56
from roborock.devices.cache import DeviceCache
67
from roborock.devices.traits.v1 import common
78
from roborock.roborock_typing import RoborockCommand
89

910

1011
class DeviceFeaturesTrait(DeviceFeatures, common.V1TraitMixin):
11-
"""Trait for managing Do Not Disturb (DND) settings on Roborock devices."""
12+
"""Trait for managing supported features on Roborock devices."""
1213

1314
command = RoborockCommand.APP_GET_INIT_STATUS
1415

15-
def __init__(self, product_nickname: RoborockProductNickname, device_cache: DeviceCache) -> None: # pylint: disable=super-init-not-called
16+
def __init__(self, product: HomeDataProduct, device_cache: DeviceCache) -> None: # pylint: disable=super-init-not-called
1617
"""Initialize MapContentTrait."""
17-
self._nickname = product_nickname
18+
self._product = product
19+
self._nickname = product.product_nickname
1820
self._device_cache = device_cache
1921
# All fields of DeviceFeatures are required. Initialize them to False
2022
# so we have some known state.
2123
for field in fields(self):
2224
setattr(self, field.name, False)
2325

26+
def is_field_supported(self, cls: type[RoborockBase], field_name: FieldNameBase) -> bool:
27+
"""Determines if the specified field is supported by this device.
28+
29+
We use dataclass attributes on the field to specify the schema code that is required
30+
for the field to be supported and it is compared against the list of
31+
supported schema codes for the device returned in the product information.
32+
"""
33+
dataclass_field: Field | None = None
34+
for field in fields(cls):
35+
if field.name == field_name:
36+
dataclass_field = field
37+
break
38+
if dataclass_field is None:
39+
raise ValueError(f"Field {field_name} not found in {cls}")
40+
41+
requires_schema_code = dataclass_field.metadata.get("requires_schema_code", None)
42+
if requires_schema_code is None:
43+
# We assume the field is supported
44+
return True
45+
# If the field requires a protocol that is not supported, we return False
46+
return requires_schema_code in self._product.supported_schema_codes
47+
2448
async def refresh(self) -> None:
2549
"""Refresh the contents of this trait.
2650

tests/data/test_containers.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,25 @@ def test_home_data():
195195
assert schema[0].type == "RAW"
196196
assert schema[0].product_property is None
197197
assert schema[0].desc is None
198+
assert product.supported_schema_codes == {
199+
"additional_props",
200+
"battery",
201+
"charge_status",
202+
"drying_status",
203+
"error_code",
204+
"fan_power",
205+
"filter_life",
206+
"main_brush_life",
207+
"rpc_request_code",
208+
"rpc_response",
209+
"side_brush_life",
210+
"state",
211+
"task_cancel_in_motion",
212+
"task_cancel_low_power",
213+
"task_complete",
214+
"water_box_mode",
215+
}
216+
198217
device = hd.devices[0]
199218
assert device.duid == "abc123"
200219
assert device.name == "Roborock S7 MaxV"
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# serializer version: 1
2+
# name: test_is_attribute_supported[home_data_device_s5e.json]
3+
dict({
4+
'battery': True,
5+
'charge_status': True,
6+
'dry_status': True,
7+
'fan_power': True,
8+
'state': True,
9+
'water_box_mode': True,
10+
})
11+
# ---
12+
# name: test_is_attribute_supported[home_data_device_s7_maxv.json]
13+
dict({
14+
'battery': True,
15+
'charge_status': True,
16+
'dry_status': True,
17+
'fan_power': True,
18+
'state': True,
19+
'water_box_mode': True,
20+
})
21+
# ---
22+
# name: test_is_attribute_supported[home_data_device_saros_10r.json]
23+
dict({
24+
'battery': True,
25+
'charge_status': True,
26+
'dry_status': True,
27+
'fan_power': True,
28+
'state': True,
29+
'water_box_mode': True,
30+
})
31+
# ---

tests/devices/traits/v1/fixtures.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import pytest
66

7-
from roborock.data import HomeData, RoborockDockTypeCode, S7MaxVStatus, UserData
7+
from roborock.data import HomeData, HomeDataDevice, HomeDataProduct, RoborockDockTypeCode, S7MaxVStatus, UserData
88
from roborock.devices.cache import Cache, DeviceCache, InMemoryCache
99
from roborock.devices.device import RoborockDevice
1010
from roborock.devices.traits import v1
@@ -57,6 +57,18 @@ def device_cache_fixture(roborock_cache: Cache) -> DeviceCache:
5757
return DeviceCache(HOME_DATA.devices[0].duid, roborock_cache)
5858

5959

60+
@pytest.fixture(name="device_info")
61+
def device_info_fixture() -> HomeDataDevice:
62+
"""Fixture to provide a DeviceInfo instance for tests."""
63+
return HOME_DATA.devices[0]
64+
65+
66+
@pytest.fixture(name="products")
67+
def products_fixture() -> list[HomeDataProduct]:
68+
"""Fixture to provide a Product instance for tests."""
69+
return [HomeDataProduct.from_dict(product) for product in mock_data.PRODUCTS.values()]
70+
71+
6072
@pytest.fixture(autouse=True, name="device")
6173
def device_fixture(
6274
channel: AsyncMock,
@@ -65,15 +77,18 @@ def device_fixture(
6577
mock_map_rpc_channel: AsyncMock,
6678
web_api_client: AsyncMock,
6779
device_cache: DeviceCache,
80+
device_info: HomeDataDevice,
81+
products: list[HomeDataProduct],
6882
) -> RoborockDevice:
6983
"""Fixture to set up the device for tests."""
84+
product = next(filter(lambda product: product.id == device_info.product_id, products))
7085
return RoborockDevice(
71-
device_info=HOME_DATA.devices[0],
72-
product=HOME_DATA.products[0],
86+
device_info=device_info,
87+
product=product,
7388
channel=channel,
7489
trait=v1.create(
75-
HOME_DATA.devices[0].duid,
76-
HOME_DATA.products[0],
90+
device_info.duid,
91+
product,
7792
HOME_DATA,
7893
mock_rpc_channel,
7994
mock_mqtt_rpc_channel,
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Tests for the DeviceFeaturesTrait related functionality."""
2+
3+
import pytest
4+
from syrupy import SnapshotAssertion
5+
6+
from roborock.data import HomeDataDevice
7+
from roborock.data.v1.v1_containers import StatusField
8+
from roborock.devices.device import RoborockDevice
9+
from roborock.devices.traits.v1.status import StatusTrait
10+
from tests import mock_data
11+
12+
V1_DEVICES = {
13+
k: HomeDataDevice.from_dict(device)
14+
for k, device in mock_data.DEVICES.items()
15+
if device.get("pv") == "1.0"
16+
}
17+
18+
19+
@pytest.mark.parametrize(
20+
("device_info"),
21+
V1_DEVICES.values(),
22+
ids=list(V1_DEVICES.keys()),
23+
)
24+
async def test_is_attribute_supported(
25+
device_info: HomeDataDevice,
26+
device: RoborockDevice,
27+
snapshot: SnapshotAssertion,
28+
) -> None:
29+
"""Test successfully getting multi maps list."""
30+
assert device.v1_properties is not None
31+
assert device.v1_properties.device_features is not None
32+
device_features_trait = device.v1_properties.device_features
33+
34+
is_supported: list[tuple[str, bool]] = {
35+
field.value: device_features_trait.is_field_supported(StatusTrait, field) for field in StatusField
36+
}
37+
assert is_supported == snapshot

0 commit comments

Comments
 (0)