Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion roborock/device_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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]
Expand Down
49 changes: 49 additions & 0 deletions tests/test_supported_features.py
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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"),
[
Expand Down
Loading