diff --git a/roborock/device_features.py b/roborock/device_features.py index e61a119d..f92d6b3e 100644 --- a/roborock/device_features.py +++ b/roborock/device_features.py @@ -4,7 +4,7 @@ from typing import Any, Self from roborock.data.code_mappings import RoborockProductNickname -from roborock.data.containers import RoborockBase +from roborock.data.containers import RoborockBase, _decamelize from roborock.data.v1 import RoborockDockTypeCode @@ -654,6 +654,26 @@ def from_feature_flags( return cls(**kwargs) + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Self | None: + """Create device features, rejecting incomplete cached capabilities. + + Persisted feature data may have been written by an older version of the + library that did not yet know about every capability. Missing boolean + fields therefore mean the cached feature set is stale, not that those + capabilities are unsupported. Return ``None`` so callers rediscover the + complete feature set from the device. + """ + if not isinstance(data, dict): + return None + + serialized_fields = {_decamelize(key) for key in data} + required_fields = {feature.name for feature in fields(cls) if feature.type is bool} + if not required_fields.issubset(serialized_fields): + return None + + return super().from_dict(data) + def get_supported_features(self) -> list[str]: """Returns a list of supported features (Primarily used for logging purposes).""" return [k for k, v in vars(self).items() if v] diff --git a/tests/test_supported_features.py b/tests/test_supported_features.py index 5a19f5b1..387eeb33 100644 --- a/tests/test_supported_features.py +++ b/tests/test_supported_features.py @@ -1,12 +1,18 @@ +import logging from dataclasses import asdict +from unittest.mock import AsyncMock import pytest from syrupy import SnapshotAssertion from roborock import SHORT_MODEL_TO_ENUM +from roborock.data import HomeDataProduct from roborock.data.code_mappings import RoborockProductNickname from roborock.data.v1 import RoborockDockTypeCode from roborock.device_features import DeviceFeatures, RoborockDockFeatures, is_valid_dock, is_wash_n_fill_dock +from roborock.devices.cache import CacheData, DeviceCache, InMemoryCache +from roborock.devices.traits.v1.device_features import DeviceFeaturesTrait +from roborock.roborock_typing import RoborockCommand from tests import mock_data # RR_API DockConfigs snapshot from the Qrevo Edge 2 bundle decoded on 2026-07-11. @@ -202,6 +208,49 @@ def test_shake_mop_feature_bit_boundary(new_feature_info: int, expected: bool) - assert device_features.is_clean_route_setting_supported is expected +async def test_from_dict_rejects_legacy_missing_capabilities(caplog: pytest.LogCaptureFixture) -> None: + """Test stale cached features become a quiet cache miss.""" + device_features = DeviceFeatures.from_feature_flags( + new_feature_info=262144, + new_feature_info_str="", + feature_info=[], + product_nickname=None, + ) + legacy_data = device_features.as_dict() + for legacy_missing_key in ( + "isAiRecognitionSettingSupported", + "isAiRecognitionObstacleSupported", + "isRollerMopSupported", + ): + del legacy_data[legacy_missing_key] + + restored = DeviceFeatures.from_dict(legacy_data) + + assert restored is None + + with caplog.at_level(logging.ERROR): + cache_data = CacheData.from_dict({"deviceInfo": {"legacy-device": {"deviceFeatures": legacy_data}}}) + + assert cache_data.device_info["legacy-device"].device_features is None + assert not caplog.records + + cache = InMemoryCache() + await cache.set(cache_data) + device_cache = DeviceCache("legacy-device", cache) + product = HomeDataProduct.from_dict(mock_data.PRODUCTS["home_data_product_a27.json"]) + trait = DeviceFeaturesTrait(product, device_cache) + rpc_channel = AsyncMock() + rpc_channel.send_command.return_value = [mock_data.APP_GET_INIT_STATUS] + trait._rpc_channel = rpc_channel + + await trait.refresh() + + rpc_channel.send_command.assert_awaited_once_with(RoborockCommand.APP_GET_INIT_STATUS) + assert trait.is_ai_recognition_setting_supported + assert trait.is_ai_recognition_obstacle_supported + assert (await device_cache.get()).device_features is trait + + @pytest.mark.parametrize( ("dock_type", "is_collectable", "is_washable"), [