From b2f4ddfba69a510327e4b3292e1f5891b066890c Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 12 Jul 2026 14:38:17 -0700 Subject: [PATCH 1/6] feat: Implement device capability metadata for status computed property fields --- roborock/data/containers.py | 14 +++++ roborock/data/v1/v1_containers.py | 34 +++++++++--- roborock/devices/traits/v1/__init__.py | 1 + roborock/devices/traits/v1/device_features.py | 53 ++++++++++++------ .../__snapshots__/test_device_features.ambr | 54 +++++++++++++++++++ tests/devices/traits/v1/fixtures.py | 4 +- .../devices/traits/v1/test_device_features.py | 2 + tests/mock_data.py | 16 ++++++ 8 files changed, 154 insertions(+), 24 deletions(-) diff --git a/roborock/data/containers.py b/roborock/data/containers.py index 86dc602a..0d15f975 100644 --- a/roborock/data/containers.py +++ b/roborock/data/containers.py @@ -60,6 +60,20 @@ def _attr_repr(obj: Any) -> str: return f"{type(obj).__name__}({', '.join(parts)})" +def field_metadata(**kwargs): + """Decorator to attach metadata to a function or property. + + This attaches a `_field_metadata` dictionary to the underlying getter function, + which is then preserved when decorated with `@property`. + """ + + def decorator(func): + func._field_metadata = kwargs + return func + + return decorator + + @dataclass(repr=False) class RoborockBase: """Base class for all Roborock data classes.""" diff --git a/roborock/data/v1/v1_containers.py b/roborock/data/v1/v1_containers.py index 477d5e54..9ae6771f 100644 --- a/roborock/data/v1/v1_containers.py +++ b/roborock/data/v1/v1_containers.py @@ -39,7 +39,7 @@ from roborock.exceptions import RoborockException from roborock.roborock_message import RoborockDataProtocol -from ..containers import NamedRoomMapping, RoborockBase, RoborockBaseTimer, _attr_repr +from ..containers import NamedRoomMapping, RoborockBase, RoborockBaseTimer, _attr_repr, field_metadata from .v1_clean_modes import WashTowelModes from .v1_code_mappings import ( CleanFluidStatus, @@ -117,6 +117,15 @@ class StatusField(FieldNameBase): CHARGE_STATUS = "charge_status" DRY_STATUS = "dry_status" ERROR_CODE = "error_code" + WATER_BOX_CARRIAGE_STATUS = "water_box_carriage_status" + WATER_BOX_STATUS = "water_box_status" + WATER_SHORTAGE_STATUS = "water_shortage_status" + DIRTY_WATER_BOX_STATUS = "dirty_water_box_status" + CLEAR_WATER_BOX_STATUS = "clear_water_box_status" + CLEAN_FLUID_STATUS = "clean_fluid_status" + CLEAN_PERCENT = "clean_percent" + DOCK_ERROR_STATUS = "dock_error_status" + RDT = "rdt" @dataclass @@ -246,12 +255,14 @@ def has_am(self) -> bool | None: return (self.dss & 3) == 2 @property + @field_metadata(dock_feature="is_washable") def clear_water_box_status(self) -> ClearWaterBoxStatus | None: if self.dss: return ClearWaterBoxStatus((self.dss >> 2) & 3) return None @property + @field_metadata(dock_feature="is_washable") def dirty_water_box_status(self) -> DirtyWaterBoxStatus | None: if self.dss: return DirtyWaterBoxStatus((self.dss >> 4) & 3) @@ -270,6 +281,9 @@ def water_box_filter_status(self) -> int | None: return None @property + @field_metadata( + dock_feature="is_clean_fluid_auto_delivery_supported", + ) def clean_fluid_status(self) -> CleanFluidStatus | None: if self.dss: value = (self.dss >> 10) & 3 @@ -313,7 +327,7 @@ class StatusV2(RoborockBase): in_returning: int | None = None in_fresh_state: int | None = None lab_status: int | None = None - water_box_status: int | None = None + water_box_status: int | None = field(default=None, metadata={"feature": "is_support_water_mode"}) back_type: int | None = None wash_phase: int | None = None wash_ready: int | None = None @@ -323,14 +337,14 @@ class StatusV2(RoborockBase): is_locating: int | None = None lock_status: int | None = None water_box_mode: int | None = field(default=None, metadata={"dps": RoborockDataProtocol.WATER_BOX_MODE}) - water_box_carriage_status: int | None = None + water_box_carriage_status: int | None = field(default=None, metadata={"feature": "is_support_water_mode"}) mop_forbidden_enable: int | None = None camera_status: int | None = None is_exploring: int | None = None home_sec_status: int | None = None home_sec_enable_password: int | None = None adbumper_status: list[int] | None = None - water_shortage_status: int | None = None + water_shortage_status: int | None = field(default=None, metadata={"feature": "is_support_water_mode"}) dock_type: RoborockDockTypeCode | None = None dust_collection_status: int | None = None auto_dust_collection: int | None = None @@ -339,7 +353,8 @@ 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 = None + dock_error_status: RoborockDockErrorCode | None = field(default=None, metadata={"dock_feature": "is_collectable"}) + charge_status: RoborockChargeStatus | None = field( default=None, metadata={"dps": RoborockDataProtocol.CHARGE_STATUS} ) @@ -349,8 +364,8 @@ class StatusV2(RoborockBase): distance_off: int | None = None in_warmup: int | None = None dry_status: int | None = field(default=None, metadata={"dps": RoborockDataProtocol.DRYING_STATUS}) - rdt: int | None = None - clean_percent: int | None = None + rdt: int | None = field(default=None, metadata={"feature": "is_supported_drying"}) + clean_percent: int | None = field(default=None, metadata={"feature": "is_support_clean_estimate"}) rss: int | None = None dss: int | None = None common_status: int | None = None @@ -389,12 +404,14 @@ def has_am(self) -> bool | None: return (self.dss & 3) == 2 @property + @field_metadata(dock_feature="is_washable") def clear_water_box_status(self) -> ClearWaterBoxStatus | None: if self.dss: return ClearWaterBoxStatus((self.dss >> 2) & 3) return None @property + @field_metadata(dock_feature="is_washable") def dirty_water_box_status(self) -> DirtyWaterBoxStatus | None: if self.dss: return DirtyWaterBoxStatus((self.dss >> 4) & 3) @@ -413,6 +430,9 @@ def water_box_filter_status(self) -> int | None: return None @property + @field_metadata( + dock_feature="is_clean_fluid_auto_delivery_supported", + ) def clean_fluid_status(self) -> CleanFluidStatus | None: if self.dss: value = (self.dss >> 10) & 3 diff --git a/roborock/devices/traits/v1/__init__.py b/roborock/devices/traits/v1/__init__.py index 7cfbd3b7..928b443e 100644 --- a/roborock/devices/traits/v1/__init__.py +++ b/roborock/devices/traits/v1/__init__.py @@ -264,6 +264,7 @@ async def discover_features(self) -> None: # Dock type also acts like a device feature for some traits. dock_type = await self._dock_type() dock_features = RoborockDockFeatures.from_dock_type(dock_type, has_am=self.status.has_am) + self.device_features.dock_features = dock_features # Initialize traits with special arguments before the generic loop if self.wash_towel_mode is None and self._is_supported(WashTowelModeTrait, "wash_towel_mode", dock_features): diff --git a/roborock/devices/traits/v1/device_features.py b/roborock/devices/traits/v1/device_features.py index 4343fd3a..dc6e9cd4 100644 --- a/roborock/devices/traits/v1/device_features.py +++ b/roborock/devices/traits/v1/device_features.py @@ -1,12 +1,33 @@ -from dataclasses import Field, fields +from dataclasses import fields +from typing import Any from roborock.data import AppInitStatus, HomeDataProduct, RoborockBase +from roborock.data.v1 import RoborockDockTypeCode from roborock.data.v1.v1_containers import FieldNameBase -from roborock.device_features import DeviceFeatures +from roborock.device_features import DeviceFeatures, RoborockDockFeatures from roborock.devices.cache import DeviceCache from roborock.devices.traits.v1 import common from roborock.roborock_typing import RoborockCommand +# Cache of metadata for each trait class +_metadata_cache: dict[type[RoborockBase], dict[str, dict[str, Any]]] = {} + + +def _get_field_metadata(cls: type[RoborockBase]) -> dict[str, Any]: + """Helper to get metadata from either class properties or dataclass fields.""" + if cls not in _metadata_cache: + metadata_map = {} + # Inspect properties with @field_metadata + for name in dir(cls): + prop = getattr(cls, name, None) + if isinstance(prop, property): + metadata_map[name] = getattr(prop.fget, "_field_metadata", {}) + # Inspect dataclass fields metadata + for f in fields(cls): + metadata_map[f.name] = f.metadata + _metadata_cache[cls] = metadata_map + return _metadata_cache[cls] + class DeviceTraitsConverter(common.V1TraitDataConverter): """Converter for APP_GET_INIT_STATUS responses into DeviceFeatures.""" @@ -40,19 +61,14 @@ def __init__(self, product: HomeDataProduct, device_cache: DeviceCache) -> None: self.converter = DeviceTraitsConverter(product) self._product = product self._device_cache = device_cache + # Dock features are populated after device feature discovery + # is triggered. + self.dock_features: RoborockDockFeatures = RoborockDockFeatures.from_dock_type(RoborockDockTypeCode.o0_dock) # All fields of DeviceFeatures are required. Initialize them to False # so we have some known state. 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. @@ -60,11 +76,18 @@ def is_field_supported(self, cls: type[RoborockBase], field_name: FieldNameBase) integer ID and check it against the set of supported schema IDs for the device returned in the product information. """ - 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 - return int(dps) in self._product.supported_schema_ids + if self.dock_features is None: + raise ValueError("DeviceFeaturesTrait was invoked but was not fully initialized") + metadata_map = _get_field_metadata(cls) + if (field_metadata := metadata_map.get(field_name)) is not None: + if (feature := field_metadata.get("feature")) is not None: + return getattr(self, feature, False) + if (dock_feature := field_metadata.get("dock_feature")) is not None: + return getattr(self.dock_features, dock_feature, False) + if (dps := field_metadata.get("dps")) is not None: + return int(dps) in self._product.supported_schema_ids + # No metadata, field is assumed always supported + return True async def refresh(self) -> None: """Refresh the contents of this trait. diff --git a/tests/devices/traits/v1/__snapshots__/test_device_features.ambr b/tests/devices/traits/v1/__snapshots__/test_device_features.ambr index c803498d..1a862764 100644 --- a/tests/devices/traits/v1/__snapshots__/test_device_features.ambr +++ b/tests/devices/traits/v1/__snapshots__/test_device_features.ambr @@ -3,66 +3,120 @@ dict({ 'battery': True, 'charge_status': True, + 'clean_fluid_status': True, + 'clean_percent': False, + 'clear_water_box_status': True, + 'dirty_water_box_status': True, + 'dock_error_status': True, 'dry_status': True, 'error_code': True, 'fan_power': True, + 'rdt': False, 'state': True, + 'water_box_carriage_status': True, 'water_box_mode': True, + 'water_box_status': True, + 'water_shortage_status': True, }) # --- # name: test_is_attribute_supported[home_data_device_q5_max.json] dict({ 'battery': True, 'charge_status': True, + 'clean_fluid_status': False, + 'clean_percent': False, + 'clear_water_box_status': False, + 'dirty_water_box_status': False, + 'dock_error_status': True, 'dry_status': True, 'error_code': True, 'fan_power': True, + 'rdt': False, 'state': True, + 'water_box_carriage_status': True, 'water_box_mode': True, + 'water_box_status': True, + 'water_shortage_status': True, }) # --- # name: test_is_attribute_supported[home_data_device_s5e.json] dict({ 'battery': True, 'charge_status': True, + 'clean_fluid_status': False, + 'clean_percent': False, + 'clear_water_box_status': False, + 'dirty_water_box_status': False, + 'dock_error_status': False, 'dry_status': True, 'error_code': True, 'fan_power': True, + 'rdt': False, 'state': True, + 'water_box_carriage_status': True, 'water_box_mode': True, + 'water_box_status': True, + 'water_shortage_status': True, }) # --- # name: test_is_attribute_supported[home_data_device_s7_maxv.json] dict({ 'battery': True, 'charge_status': True, + 'clean_fluid_status': False, + 'clean_percent': False, + 'clear_water_box_status': True, + 'dirty_water_box_status': True, + 'dock_error_status': True, 'dry_status': True, 'error_code': True, 'fan_power': True, + 'rdt': False, 'state': True, + 'water_box_carriage_status': True, 'water_box_mode': True, + 'water_box_status': True, + 'water_shortage_status': True, }) # --- # name: test_is_attribute_supported[home_data_device_saros.json] dict({ 'battery': True, 'charge_status': True, + 'clean_fluid_status': True, + 'clean_percent': False, + 'clear_water_box_status': True, + 'dirty_water_box_status': True, + 'dock_error_status': True, 'dry_status': True, 'error_code': True, 'fan_power': True, + 'rdt': False, 'state': True, + 'water_box_carriage_status': True, 'water_box_mode': True, + 'water_box_status': True, + 'water_shortage_status': True, }) # --- # name: test_is_attribute_supported[home_data_device_saros_10r.json] dict({ 'battery': True, 'charge_status': True, + 'clean_fluid_status': True, + 'clean_percent': False, + 'clear_water_box_status': True, + 'dirty_water_box_status': True, + 'dock_error_status': True, 'dry_status': True, 'error_code': True, 'fan_power': True, + 'rdt': False, 'state': True, + 'water_box_carriage_status': True, 'water_box_mode': True, + 'water_box_status': True, + 'water_shortage_status': True, }) # --- # name: test_is_consumable_field_supported[home_data_device_pearls.json] diff --git a/tests/devices/traits/v1/fixtures.py b/tests/devices/traits/v1/fixtures.py index 58b02cbc..44ca973c 100644 --- a/tests/devices/traits/v1/fixtures.py +++ b/tests/devices/traits/v1/fixtures.py @@ -110,9 +110,9 @@ def device_fixture( @pytest.fixture(name="dock_type_code", autouse=True) -def dock_type_code_fixture(request: pytest.FixtureRequest) -> RoborockDockTypeCode | None: +def dock_type_code_fixture(device_info: HomeDataDevice) -> RoborockDockTypeCode | None: """Fixture to provide the dock type code for parameterized tests.""" - return RoborockDockTypeCode.o3_plus_dock + return mock_data.PRODUCT_DOCK_TYPE_MAP.get(device_info.product_id, RoborockDockTypeCode.o3_plus_dock) @pytest.fixture(autouse=True) diff --git a/tests/devices/traits/v1/test_device_features.py b/tests/devices/traits/v1/test_device_features.py index 7beb529d..f44479ee 100644 --- a/tests/devices/traits/v1/test_device_features.py +++ b/tests/devices/traits/v1/test_device_features.py @@ -23,6 +23,7 @@ async def test_is_attribute_supported( device_info: HomeDataDevice, device: RoborockDevice, + discover_features_fixture: None, snapshot: SnapshotAssertion, ) -> None: """Test if a field is supported.""" @@ -44,6 +45,7 @@ async def test_is_attribute_supported( async def test_is_consumable_field_supported( device_info: HomeDataDevice, device: RoborockDevice, + discover_features_fixture: None, snapshot: SnapshotAssertion, ) -> None: """Test if a field is supported.""" diff --git a/tests/mock_data.py b/tests/mock_data.py index 226a417a..71442c0d 100644 --- a/tests/mock_data.py +++ b/tests/mock_data.py @@ -6,6 +6,7 @@ from typing import Any from roborock.data.containers import HomeDataDevice, HomeDataProduct +from roborock.data.v1 import RoborockDockTypeCode # All data is based on a U.S. customer with a Roborock S7 MaxV Ultra USER_EMAIL = "user@domain.com" @@ -162,6 +163,21 @@ } +# Map product IDs to their expected dock type code for testing purposes. +# Roborock devices can be sold in bundles with different dock variants +# (e.g. S7 MaxV standalone vs S7 MaxV Plus with auto-empty vs S7 MaxV Ultra +# with empty-wash-fill). During test runs, we map each test product ID +# to its representative dock setup to ensure granular capability checks. +PRODUCT_DOCK_TYPE_MAP: dict[str, RoborockDockTypeCode] = { + "product-id-s7-maxv": RoborockDockTypeCode.o3_plus_dock, # Ultra (Wash/Empty) + "product-id-a125": RoborockDockTypeCode.o1_dock, # Q5 Max+ (Auto-Empty) + "73EnOOM2NhDujvnvb7hvvv": RoborockDockTypeCode.o0_dock, # S5 Max (Standard Charging) + "product-id-a123": RoborockDockTypeCode.pearl_plus_dock, # PearlS Lite / Q Revo (Wash/Empty) + "product-saros-10": RoborockDockTypeCode.o4_dock, # Saros 10 / Q Revo Pro (Wash/Empty/Auto Fluid) + "product-saros-10r": RoborockDockTypeCode.o6_dock, # JC / Q Revo Master (Wash/Empty/Hot water) +} + + HOME_DATA_RAW: dict[str, Any] = { "id": 123456, "name": "My Home", From e64030e1fba0dc88085d7c724843603f34a564d7 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 12 Jul 2026 14:40:23 -0700 Subject: [PATCH 2/6] docs: Document metadata keys in field_metadata and is_field_supported --- roborock/data/containers.py | 7 ++++++- roborock/devices/traits/v1/device_features.py | 10 +++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/roborock/data/containers.py b/roborock/data/containers.py index 0d15f975..b0735852 100644 --- a/roborock/data/containers.py +++ b/roborock/data/containers.py @@ -61,10 +61,15 @@ def _attr_repr(obj: Any) -> str: def field_metadata(**kwargs): - """Decorator to attach metadata to a function or property. + """Decorator to attach capability check metadata to a property. This attaches a `_field_metadata` dictionary to the underlying getter function, which is then preserved when decorated with `@property`. + + Supported metadata keys: + - `feature` (str): Name of a capability property on `DeviceFeaturesTrait`. + - `dock_feature` (str): Name of a capability property on `RoborockDockFeatures`. + - `dps` (str/int): RoborockDataProtocol ID to check against supported schema IDs. """ def decorator(func): diff --git a/roborock/devices/traits/v1/device_features.py b/roborock/devices/traits/v1/device_features.py index dc6e9cd4..1f785e65 100644 --- a/roborock/devices/traits/v1/device_features.py +++ b/roborock/devices/traits/v1/device_features.py @@ -72,9 +72,13 @@ def __init__(self, product: HomeDataProduct, device_cache: DeviceCache) -> None: def is_field_supported(self, cls: type[RoborockBase], field_name: FieldNameBase) -> bool: """Determines if the specified field is supported by this device. - 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. + We inspect the metadata defined for the field (either via dataclass field metadata + or the `@field_metadata` decorator on properties). Supported checks include: + + - `feature`: Maps to a boolean capability property on `DeviceFeatures` / `DeviceFeaturesTrait` + (e.g. `is_support_water_mode`). + - `dock_feature`: Maps to a boolean capability property on `RoborockDockFeatures` (e.g. `is_washable`). + - `dps`: Maps to a `RoborockDataProtocol` ID checked against the product's supported schema IDs. """ if self.dock_features is None: raise ValueError("DeviceFeaturesTrait was invoked but was not fully initialized") From f50dba5fae8a38ac96f053386536d7310cac464f Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 12 Jul 2026 14:43:51 -0700 Subject: [PATCH 3/6] test: Dynamically mock new_feature_info from device_info.new_feature_set --- .../v1/__snapshots__/test_device_features.ambr | 14 +++++++------- tests/devices/traits/v1/fixtures.py | 16 +++++++++++++++- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/tests/devices/traits/v1/__snapshots__/test_device_features.ambr b/tests/devices/traits/v1/__snapshots__/test_device_features.ambr index 1a862764..731f6f16 100644 --- a/tests/devices/traits/v1/__snapshots__/test_device_features.ambr +++ b/tests/devices/traits/v1/__snapshots__/test_device_features.ambr @@ -4,14 +4,14 @@ 'battery': True, 'charge_status': True, 'clean_fluid_status': True, - 'clean_percent': False, + 'clean_percent': True, 'clear_water_box_status': True, 'dirty_water_box_status': True, 'dock_error_status': True, 'dry_status': True, 'error_code': True, 'fan_power': True, - 'rdt': False, + 'rdt': True, 'state': True, 'water_box_carriage_status': True, 'water_box_mode': True, @@ -24,7 +24,7 @@ 'battery': True, 'charge_status': True, 'clean_fluid_status': False, - 'clean_percent': False, + 'clean_percent': True, 'clear_water_box_status': False, 'dirty_water_box_status': False, 'dock_error_status': True, @@ -84,14 +84,14 @@ 'battery': True, 'charge_status': True, 'clean_fluid_status': True, - 'clean_percent': False, + 'clean_percent': True, 'clear_water_box_status': True, 'dirty_water_box_status': True, 'dock_error_status': True, 'dry_status': True, 'error_code': True, 'fan_power': True, - 'rdt': False, + 'rdt': True, 'state': True, 'water_box_carriage_status': True, 'water_box_mode': True, @@ -104,14 +104,14 @@ 'battery': True, 'charge_status': True, 'clean_fluid_status': True, - 'clean_percent': False, + 'clean_percent': True, 'clear_water_box_status': True, 'dirty_water_box_status': True, 'dock_error_status': True, 'dry_status': True, 'error_code': True, 'fan_power': True, - 'rdt': False, + 'rdt': True, 'state': True, 'water_box_carriage_status': True, 'water_box_mode': True, diff --git a/tests/devices/traits/v1/fixtures.py b/tests/devices/traits/v1/fixtures.py index 44ca973c..69ae84fe 100644 --- a/tests/devices/traits/v1/fixtures.py +++ b/tests/devices/traits/v1/fixtures.py @@ -120,11 +120,25 @@ async def discover_features_fixture( device: RoborockDevice, mock_rpc_channel: AsyncMock, dock_type_code: RoborockDockTypeCode | None, + device_info: HomeDataDevice, ) -> None: """Fixture to handle device feature discovery.""" assert device.v1_properties + + new_feature_info_str = device_info.new_feature_set or "0000000000002000" + try: + new_feature_info = int(new_feature_info_str, 16) + except ValueError: + new_feature_info = 0 + mock_rpc_channel.send_command.side_effect = [ - [mock_data.APP_GET_INIT_STATUS], + [ + { + **mock_data.APP_GET_INIT_STATUS, + "new_feature_info_str": new_feature_info_str, + "new_feature_info": new_feature_info, + } + ], { **mock_data.STATUS, "dock_type": dock_type_code, From 8b6d9cda836a0becfe7868cc39e25c4afd584ab6 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 12 Jul 2026 14:46:16 -0700 Subject: [PATCH 4/6] test: Share default new_feature_info_str from APP_GET_INIT_STATUS and parse unconditionally --- tests/devices/traits/v1/fixtures.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/devices/traits/v1/fixtures.py b/tests/devices/traits/v1/fixtures.py index 69ae84fe..eef77d25 100644 --- a/tests/devices/traits/v1/fixtures.py +++ b/tests/devices/traits/v1/fixtures.py @@ -125,11 +125,8 @@ async def discover_features_fixture( """Fixture to handle device feature discovery.""" assert device.v1_properties - new_feature_info_str = device_info.new_feature_set or "0000000000002000" - try: - new_feature_info = int(new_feature_info_str, 16) - except ValueError: - new_feature_info = 0 + new_feature_info_str = device_info.new_feature_set or str(mock_data.APP_GET_INIT_STATUS["new_feature_info_str"]) + new_feature_info = int(new_feature_info_str, 16) mock_rpc_channel.send_command.side_effect = [ [ From 686ab2b668b1867179e059db599e42014a15b4b2 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 12 Jul 2026 14:47:10 -0700 Subject: [PATCH 5/6] test: Unconditionally retrieve new_feature_set and assert it exists --- tests/devices/traits/v1/fixtures.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/devices/traits/v1/fixtures.py b/tests/devices/traits/v1/fixtures.py index eef77d25..f73a9b11 100644 --- a/tests/devices/traits/v1/fixtures.py +++ b/tests/devices/traits/v1/fixtures.py @@ -125,7 +125,8 @@ async def discover_features_fixture( """Fixture to handle device feature discovery.""" assert device.v1_properties - new_feature_info_str = device_info.new_feature_set or str(mock_data.APP_GET_INIT_STATUS["new_feature_info_str"]) + new_feature_info_str = device_info.new_feature_set + assert new_feature_info_str is not None, f"Device {device_info.name} is missing new_feature_set" new_feature_info = int(new_feature_info_str, 16) mock_rpc_channel.send_command.side_effect = [ From e90946e8fecc79826e51e1e5688b64978f199bf4 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 12 Jul 2026 18:20:24 -0700 Subject: [PATCH 6/6] fix: Gate dock_error_status on has_dock rather than is_collectable --- roborock/data/v1/v1_containers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roborock/data/v1/v1_containers.py b/roborock/data/v1/v1_containers.py index 9ae6771f..2f01870b 100644 --- a/roborock/data/v1/v1_containers.py +++ b/roborock/data/v1/v1_containers.py @@ -353,7 +353,7 @@ 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 = field(default=None, metadata={"dock_feature": "is_collectable"}) + dock_error_status: RoborockDockErrorCode | None = field(default=None, metadata={"dock_feature": "has_dock"}) charge_status: RoborockChargeStatus | None = field( default=None, metadata={"dps": RoborockDataProtocol.CHARGE_STATUS}