diff --git a/roborock/data/containers.py b/roborock/data/containers.py index 2e469e6b..86dc602a 100644 --- a/roborock/data/containers.py +++ b/roborock/data/containers.py @@ -251,11 +251,26 @@ def summary_info(self) -> str: @cached_property def supported_schema_codes(self) -> set[str]: - """Return a set of fields that are supported by the device.""" + """Return a set of schema codes that are supported by the device. + + These correspond with string field names like "state" or "error_code" that + correspond to RoborockDataProtocol or RoborockB01Protocol code values. + """ if self.schema is None: return set() return {schema.code for schema in self.schema if schema.code is not None} + @cached_property + def supported_schema_ids(self) -> set[int]: + """Return a set of schema IDs (DPS integers) that are supported by the device. + + These correspond to RoborockMessageProtocol and RoborockDataProtocol or + RoborockB01Protocol enum number values (depends on the device protocol versions). + """ + if self.schema is None: + return set() + return {int(schema.id) for schema in self.schema if schema.id is not None} + @dataclass class HomeDataDevice(RoborockBase): diff --git a/roborock/data/v1/v1_containers.py b/roborock/data/v1/v1_containers.py index 3066d1a3..746b30cc 100644 --- a/roborock/data/v1/v1_containers.py +++ b/roborock/data/v1/v1_containers.py @@ -37,6 +37,7 @@ ROBOROCK_G20S_Ultra, ) from roborock.exceptions import RoborockException +from roborock.roborock_message import RoborockDataProtocol from ..containers import NamedRoomMapping, RoborockBase, RoborockBaseTimer, _attr_repr from .v1_clean_modes import WashTowelModes @@ -102,9 +103,8 @@ class StatusField(FieldNameBase): This is used with `roborock.devices.traits.v1.status.DeviceFeaturesTrait` to understand if a feature is supported by the device using `is_field_supported`. - The enum values are names of fields in the `Status` class. Each field is - annotated with `requires_schema_code` metadata to map the field to a schema - code in the product schema, which may have a different name than the field/attribute name. + The enum values are names of fields in the `Status` class. Each field is annotated + with a metadata value to determine if the field is supported by the device. """ STATE = "state" @@ -116,21 +116,17 @@ class StatusField(FieldNameBase): ERROR_CODE = "error_code" -def _requires_schema_code(requires_schema_code: str, default=None) -> Any: - return field(metadata={"requires_schema_code": requires_schema_code}, default=default) - - @dataclass class Status(RoborockBase): """This status will be deprecated in favor of StatusV2.""" msg_ver: int | None = None msg_seq: int | None = None - state: RoborockStateCode | None = _requires_schema_code("state") - battery: int | None = _requires_schema_code("battery") + state: RoborockStateCode | None = field(default=None, metadata={"dps": RoborockDataProtocol.STATE}) + battery: int | None = field(default=None, metadata={"dps": RoborockDataProtocol.BATTERY}) clean_time: int | None = None clean_area: int | None = None - error_code: RoborockErrorCode | None = _requires_schema_code("error_code") + error_code: RoborockErrorCode | None = field(default=None, metadata={"dps": RoborockDataProtocol.ERROR_CODE}) map_present: int | None = None in_cleaning: RoborockInCleaning | None = None in_returning: int | None = None @@ -140,12 +136,14 @@ class Status(RoborockBase): back_type: int | None = None wash_phase: int | None = None wash_ready: int | None = None - fan_power: RoborockFanPowerCode | None = _requires_schema_code("fan_power") + fan_power: RoborockFanPowerCode | None = field(default=None, metadata={"dps": RoborockDataProtocol.FAN_POWER}) dnd_enabled: int | None = None map_status: int | None = None is_locating: int | None = None lock_status: int | None = None - water_box_mode: RoborockMopIntensityCode | None = _requires_schema_code("water_box_mode") + water_box_mode: RoborockMopIntensityCode | None = field( + default=None, metadata={"dps": RoborockDataProtocol.WATER_BOX_MODE} + ) water_box_carriage_status: int | None = None mop_forbidden_enable: int | None = None camera_status: int | None = None @@ -163,13 +161,13 @@ class Status(RoborockBase): collision_avoid_status: int | None = None switch_map_mode: int | None = None dock_error_status: RoborockDockErrorCode | None = None - charge_status: int | None = _requires_schema_code("charge_status") + charge_status: int | None = field(default=None, metadata={"dps": RoborockDataProtocol.CHARGE_STATUS}) unsave_map_reason: int | None = None unsave_map_flag: int | None = None wash_status: int | None = None distance_off: int | None = None in_warmup: int | None = None - dry_status: int | None = _requires_schema_code("drying_status") + dry_status: int | None = field(default=None, metadata={"dps": RoborockDataProtocol.DRYING_STATUS}) rdt: int | None = None clean_percent: int | None = None rss: int | None = None @@ -294,11 +292,11 @@ class StatusV2(RoborockBase): msg_ver: int | None = None msg_seq: int | None = None - state: RoborockStateCode | None = _requires_schema_code("state") - battery: int | None = _requires_schema_code("battery") + state: RoborockStateCode | None = field(default=None, metadata={"dps": RoborockDataProtocol.STATE}) + battery: int | None = field(default=None, metadata={"dps": RoborockDataProtocol.BATTERY}) clean_time: int | None = None clean_area: int | None = None - error_code: RoborockErrorCode | None = _requires_schema_code("error_code") + error_code: RoborockErrorCode | None = field(default=None, metadata={"dps": RoborockDataProtocol.ERROR_CODE}) map_present: int | None = None in_cleaning: RoborockInCleaning | None = None in_returning: int | None = None @@ -308,12 +306,12 @@ class StatusV2(RoborockBase): back_type: int | None = None wash_phase: int | None = None wash_ready: int | None = None - fan_power: int | None = _requires_schema_code("fan_power") + fan_power: int | None = field(default=None, metadata={"dps": RoborockDataProtocol.FAN_POWER}) dnd_enabled: int | None = None map_status: int | None = None is_locating: int | None = None lock_status: int | None = None - water_box_mode: int | None = _requires_schema_code("water_box_mode") + water_box_mode: int | None = field(default=None, metadata={"dps": RoborockDataProtocol.WATER_BOX_MODE}) water_box_carriage_status: int | None = None mop_forbidden_enable: int | None = None camera_status: int | None = None @@ -330,14 +328,14 @@ class StatusV2(RoborockBase): debug_mode: int | None = None collision_avoid_status: int | None = None switch_map_mode: int | None = None - dock_error_status: RoborockDockErrorCode | None = _requires_schema_code("dock_error_status") - charge_status: int | None = _requires_schema_code("charge_status") + dock_error_status: RoborockDockErrorCode | None = None + charge_status: int | None = field(default=None, metadata={"dps": RoborockDataProtocol.CHARGE_STATUS}) unsave_map_reason: int | None = None unsave_map_flag: int | None = None wash_status: int | None = None distance_off: int | None = None in_warmup: int | None = None - dry_status: int | None = _requires_schema_code("drying_status") + dry_status: int | None = field(default=None, metadata={"dps": RoborockDataProtocol.DRYING_STATUS}) rdt: int | None = None clean_percent: int | None = None rss: int | None = None @@ -631,9 +629,8 @@ class ConsumableField(FieldNameBase): This is used with `roborock.devices.traits.v1.status.DeviceFeaturesTrait` to understand if a feature is supported by the device using `is_field_supported`. - The enum values are names of fields in the `Consumable` class. Each field is - annotated with `requires_schema_code` metadata to map the field to a schema - code in the product schema, which may have a different name than the field/attribute name. + The enum values are names of fields in the `Consumable` class. Each field is annotated + with a metadata value to determine if the field is supported by the device. """ MAIN_BRUSH_WORK_TIME = "main_brush_work_time" @@ -643,9 +640,9 @@ class ConsumableField(FieldNameBase): @dataclass class Consumable(RoborockBase): - main_brush_work_time: int | None = field(metadata={"requires_schema_code": "main_brush_life"}, default=None) - side_brush_work_time: int | None = field(metadata={"requires_schema_code": "side_brush_life"}, default=None) - filter_work_time: int | None = field(metadata={"requires_schema_code": "filter_life"}, default=None) + main_brush_work_time: int | None = field(default=None, metadata={"dps": RoborockDataProtocol.MAIN_BRUSH_WORK_TIME}) + side_brush_work_time: int | None = field(default=None, metadata={"dps": RoborockDataProtocol.SIDE_BRUSH_WORK_TIME}) + filter_work_time: int | None = field(default=None, metadata={"dps": RoborockDataProtocol.FILTER_WORK_TIME}) filter_element_work_time: int | None = None sensor_dirty_time: int | None = None strainer_work_times: int | None = None diff --git a/roborock/devices/traits/v1/__init__.py b/roborock/devices/traits/v1/__init__.py index ad4b062a..a55280f2 100644 --- a/roborock/devices/traits/v1/__init__.py +++ b/roborock/devices/traits/v1/__init__.py @@ -48,8 +48,8 @@ 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. +trait. The `dps` field metadata attribute references a schema code in +HomeDataProduct Schema that is required for the field to be supported. """ import logging diff --git a/roborock/devices/traits/v1/device_features.py b/roborock/devices/traits/v1/device_features.py index ed6e186e..4343fd3a 100644 --- a/roborock/devices/traits/v1/device_features.py +++ b/roborock/devices/traits/v1/device_features.py @@ -45,27 +45,26 @@ def __init__(self, product: HomeDataProduct, device_cache: DeviceCache) -> None: for field in fields(self): setattr(self, field.name, False) + @staticmethod + def _get_dataclass_field(cls: type[RoborockBase], field_name: FieldNameBase) -> Field: + """Look up a dataclass field by its FieldNameBase name.""" + for f in fields(cls): + if f.name == field_name: + return f + raise ValueError(f"Field {field_name!r} not found in {cls}") + def is_field_supported(self, cls: type[RoborockBase], field_name: FieldNameBase) -> bool: """Determines if the specified field is supported by this device. - We use dataclass attributes on the field to specify the schema code that is required - for the field to be supported and it is compared against the list of - supported schema codes for the device returned in the product information. + We use the `dps` dataclass field metadata to get the `RoborockDataProtocol` + integer ID and check it against the set of supported schema IDs for the + device returned in the product information. """ - dataclass_field: Field | None = None - for field in fields(cls): - if field.name == field_name: - dataclass_field = field - break - if dataclass_field is None: - raise ValueError(f"Field {field_name} not found in {cls}") - - requires_schema_code = dataclass_field.metadata.get("requires_schema_code", None) - if requires_schema_code is None: - # We assume the field is supported + dataclass_field = self._get_dataclass_field(cls, field_name) + if (dps := dataclass_field.metadata.get("dps")) is None: + # No DPS metadata — field is assumed always supported return True - # If the field requires a protocol that is not supported, we return False - return requires_schema_code in self._product.supported_schema_codes + return int(dps) in self._product.supported_schema_ids async def refresh(self) -> None: """Refresh the contents of this trait. diff --git a/roborock/roborock_message.py b/roborock/roborock_message.py index 7c7f65a6..ae669670 100644 --- a/roborock/roborock_message.py +++ b/roborock/roborock_message.py @@ -2,7 +2,7 @@ from enum import StrEnum from typing import Self -from roborock import RoborockEnum +from roborock.data.code_mappings import RoborockEnum from roborock.util import get_next_int, get_timestamp diff --git a/tests/data/test_containers.py b/tests/data/test_containers.py index 16dd0b2c..27ca1e3e 100644 --- a/tests/data/test_containers.py +++ b/tests/data/test_containers.py @@ -15,6 +15,7 @@ _camelize, _decamelize, ) +from roborock.roborock_message import RoborockDataProtocol, RoborockMessageProtocol from tests.mock_data import ( HOME_DATA_RAW, K_VALUE, @@ -213,6 +214,27 @@ def test_home_data(): "task_complete", "water_box_mode", } + assert product.supported_schema_ids == { + int(v) + for v in ( + RoborockMessageProtocol.RPC_REQUEST, + RoborockMessageProtocol.RPC_RESPONSE, + RoborockDataProtocol.ERROR_CODE, + RoborockDataProtocol.STATE, + RoborockDataProtocol.BATTERY, + RoborockDataProtocol.FAN_POWER, + RoborockDataProtocol.WATER_BOX_MODE, + RoborockDataProtocol.MAIN_BRUSH_WORK_TIME, + RoborockDataProtocol.SIDE_BRUSH_WORK_TIME, + RoborockDataProtocol.FILTER_WORK_TIME, + RoborockDataProtocol.ADDITIONAL_PROPS, + RoborockDataProtocol.TASK_COMPLETE, + RoborockDataProtocol.TASK_CANCEL_LOW_POWER, + RoborockDataProtocol.TASK_CANCEL_IN_MOTION, + RoborockDataProtocol.CHARGE_STATUS, + RoborockDataProtocol.DRYING_STATUS, + ) + } device = hd.devices[0] assert device.duid == "abc123"