From b548abbdcc52d1e5751d5ab83a364406fb811a3b Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 6 Jul 2026 20:40:53 -0400 Subject: [PATCH 1/4] feat: add better dock feature control --- roborock/data/v1/v1_code_mappings.py | 41 ++-- roborock/data/v1/v1_containers.py | 12 + roborock/device_features.py | 216 ++++++++++++++++-- roborock/devices/traits/v1/__init__.py | 23 +- .../devices/traits/v1/dust_collection_mode.py | 8 +- .../devices/traits/v1/smart_wash_params.py | 8 +- roborock/devices/traits/v1/wash_towel_mode.py | 8 +- tests/data/v1/test_v1_containers.py | 6 +- tests/devices/traits/v1/fixtures.py | 2 +- tests/devices/traits/v1/helpers.py | 13 ++ .../traits/v1/test_dust_collection_mode.py | 15 +- .../traits/v1/test_smart_wash_params.py | 14 +- .../devices/traits/v1/test_wash_towel_mode.py | 22 +- tests/test_supported_features.py | 71 +++++- 14 files changed, 371 insertions(+), 88 deletions(-) create mode 100644 tests/devices/traits/v1/helpers.py diff --git a/roborock/data/v1/v1_code_mappings.py b/roborock/data/v1/v1_code_mappings.py index 730be40ee..7ceea598b 100644 --- a/roborock/data/v1/v1_code_mappings.py +++ b/roborock/data/v1/v1_code_mappings.py @@ -650,22 +650,31 @@ class RoborockDockErrorCode(RoborockEnum): class RoborockDockTypeCode(RoborockEnum): unknown = -9999 - no_dock = 0 - auto_empty_dock = 1 - empty_wash_fill_dock = 3 - auto_empty_dock_pure = 5 - s7_max_ultra_dock = 6 - s8_dock = 7 - p10_dock = 8 - p10_pro_dock = 9 - s8_maxv_ultra_dock = 10 - qrevo_master_dock = 14 - qrevo_s_dock = 15 - saros_r10_dock = 16 - qrevo_curv_dock = 17 - saros_10_dock = 18 - qrevo_s5v_dock = 22 - saros_20_dock = 27 + o0_dock = 0 + o1_dock = 1 + o2_dock = 2 + o3_dock = 3 + oc_dock = 5 + o3_plus_dock = 6 + o4_dock = 7 + pearl_dock = 8 + pearl_plus_dock = 9 + o5_dock = 10 + shell_2s_dock = 11 + couple_dock = 13 + shell_3_dock = 14 + shell_2c_dock = 15 + shell_3s_dock = 16 + k1_dock = 17 + o6_dock = 18 + k1c_dock = 19 + k1s_dock = 20 + shell_e_dock = 21 + shell_2e_dock = 22 + shell_3c_dock = 23 + type_27_dock = 27 + k1c_lite_dock = 28 + shell_2e_lite_dock = 30 class RoborockDockDustCollectionModeCode(RoborockEnum): diff --git a/roborock/data/v1/v1_containers.py b/roborock/data/v1/v1_containers.py index 46fb32912..ce874f7fa 100644 --- a/roborock/data/v1/v1_containers.py +++ b/roborock/data/v1/v1_containers.py @@ -239,6 +239,12 @@ def current_map(self) -> int | None: return map_flag return None + @property + def has_am(self) -> bool | None: + if not self.dss: + return None + return (self.dss & 3) == 2 + @property def clear_water_box_status(self) -> ClearWaterBoxStatus | None: if self.dss: @@ -376,6 +382,12 @@ def current_map(self) -> int | None: return map_flag return None + @property + def has_am(self) -> bool | None: + if not self.dss: + return None + return (self.dss & 3) == 2 + @property def clear_water_box_status(self) -> ClearWaterBoxStatus | None: if self.dss: diff --git a/roborock/device_features.py b/roborock/device_features.py index 9b03b9724..a5f35b878 100644 --- a/roborock/device_features.py +++ b/roborock/device_features.py @@ -1,5 +1,6 @@ from dataclasses import dataclass, field, fields from enum import IntEnum, StrEnum +from functools import cache from typing import Any, Self from roborock.data.code_mappings import RoborockProductNickname @@ -644,25 +645,204 @@ def get_supported_features(self) -> list[str]: return [k for k, v in vars(self).items() if v] -WASH_N_FILL_DOCK_TYPES = [ - RoborockDockTypeCode.empty_wash_fill_dock, - RoborockDockTypeCode.s8_dock, - RoborockDockTypeCode.p10_dock, - RoborockDockTypeCode.p10_pro_dock, - RoborockDockTypeCode.s8_maxv_ultra_dock, - RoborockDockTypeCode.qrevo_s_dock, - RoborockDockTypeCode.saros_r10_dock, - RoborockDockTypeCode.qrevo_curv_dock, - RoborockDockTypeCode.qrevo_s5v_dock, - RoborockDockTypeCode.saros_20_dock, -] +_NO_DOCK_TYPES = { + RoborockDockTypeCode.unknown, + RoborockDockTypeCode.o0_dock, +} + +_PURE_COLLECT_DOCK_TYPES = { + RoborockDockTypeCode.o1_dock, + RoborockDockTypeCode.oc_dock, +} + +_PURE_WASH_DOCK_TYPES = { + RoborockDockTypeCode.o2_dock, +} + +_COLLECT_WASH_DOCK_TYPES = { + RoborockDockTypeCode.o3_dock, +} + +_ONLY_X_SERIES_DOCK_TYPES = { + RoborockDockTypeCode.o2_dock, + RoborockDockTypeCode.o3_dock, + RoborockDockTypeCode.o3_plus_dock, + RoborockDockTypeCode.o4_dock, + RoborockDockTypeCode.o5_dock, + RoborockDockTypeCode.o6_dock, +} + +_CLEAN_FLUID_AUTO_DELIVERY_DOCK_TYPES = { + RoborockDockTypeCode.o4_dock, + RoborockDockTypeCode.pearl_plus_dock, + RoborockDockTypeCode.shell_3s_dock, + RoborockDockTypeCode.o6_dock, + RoborockDockTypeCode.couple_dock, +} + +_CLEAN_FLUID_AUTO_DELIVERY_AM_DOCK_TYPES = { + RoborockDockTypeCode.o5_dock, + RoborockDockTypeCode.shell_2s_dock, + RoborockDockTypeCode.shell_3_dock, + RoborockDockTypeCode.shell_2c_dock, + RoborockDockTypeCode.k1_dock, + RoborockDockTypeCode.k1s_dock, + RoborockDockTypeCode.shell_e_dock, + RoborockDockTypeCode.shell_2e_dock, + RoborockDockTypeCode.shell_3c_dock, +} + +_AUTO_STERILIZE_DOCK_TYPES = { + RoborockDockTypeCode.shell_3s_dock, +} + +_INNER_CHARGE_HEAD_DOCK_TYPES = { + RoborockDockTypeCode.o0_dock, + RoborockDockTypeCode.o1_dock, + RoborockDockTypeCode.oc_dock, + RoborockDockTypeCode.o2_dock, + RoborockDockTypeCode.o3_dock, + RoborockDockTypeCode.o3_plus_dock, + RoborockDockTypeCode.o4_dock, +} + +_DUST_BUCKET_DOCK_TYPES = { + RoborockDockTypeCode.o3_dock, + RoborockDockTypeCode.o3_plus_dock, + RoborockDockTypeCode.o4_dock, +} + +_CLEAN_CAROUSEL_SELF_CLEAN_DOCK_TYPES = { + RoborockDockTypeCode.shell_3_dock, + RoborockDockTypeCode.k1_dock, + RoborockDockTypeCode.couple_dock, + RoborockDockTypeCode.shell_3s_dock, + RoborockDockTypeCode.k1s_dock, + RoborockDockTypeCode.shell_3c_dock, +} + +_WATER_UPDOWN_DRAIN_DOCK_TYPES = { + RoborockDockTypeCode.o4_dock, + RoborockDockTypeCode.pearl_plus_dock, + RoborockDockTypeCode.couple_dock, +} + +_WATER_UPDOWN_DRAIN_AM_DOCK_TYPES = { + RoborockDockTypeCode.o5_dock, + RoborockDockTypeCode.shell_2s_dock, + RoborockDockTypeCode.shell_3_dock, + RoborockDockTypeCode.shell_2c_dock, + RoborockDockTypeCode.shell_3s_dock, + RoborockDockTypeCode.k1_dock, + RoborockDockTypeCode.o6_dock, + RoborockDockTypeCode.shell_e_dock, + RoborockDockTypeCode.shell_2e_dock, +} + +_HATCH_DOOR_DOCK_COOL_FAN_DOCK_TYPES = { + RoborockDockTypeCode.couple_dock, +} + +_SPECIAL_WASH_TEMP_DOCK_TYPES = { + RoborockDockTypeCode.shell_2e_lite_dock, +} -def is_wash_n_fill_dock(dock_type: RoborockDockTypeCode) -> bool: - """Check if the dock type is a wash and fill dock.""" - return dock_type in WASH_N_FILL_DOCK_TYPES +@dataclass(frozen=True) +class RoborockDockFeatures: + """Capabilities derived from a V1 dock type. + This mirrors the Roborock app's DK capability model: feature availability + should be attached to the dock family instead of repeated per trait. + """ -def is_valid_dock(dock_type: RoborockDockTypeCode) -> bool: - """Check if device supports a dock.""" - return dock_type != RoborockDockTypeCode.no_dock + dock_type: RoborockDockTypeCode + has_am: bool | None = None + + @classmethod + @cache + def from_dock_type(cls, dock_type: RoborockDockTypeCode | None, has_am: bool | None = None) -> Self: + """Return cached capabilities for a V1 dock type.""" + return cls(dock_type or RoborockDockTypeCode.o0_dock, has_am=has_am) + + def _matches_am_variant(self, dock_types: set[RoborockDockTypeCode]) -> bool: + return self.has_am is True and self.dock_type in dock_types + + def _matches_non_am_variant(self, dock_types: set[RoborockDockTypeCode]) -> bool: + return self.has_am is not True and self.dock_type in dock_types + + @property + def has_dock(self) -> bool: + return self.dock_type not in _NO_DOCK_TYPES + + @property + def is_pure_collect(self) -> bool: + return self.dock_type in _PURE_COLLECT_DOCK_TYPES + + @property + def is_pure_wash(self) -> bool: + return self.dock_type in _PURE_WASH_DOCK_TYPES + + @property + def is_collect_wash(self) -> bool: + return self.dock_type in _COLLECT_WASH_DOCK_TYPES + + @property + def is_collect_wash_dry(self) -> bool: + return self.has_dock and not (self.is_pure_collect or self.is_pure_wash or self.is_collect_wash) + + @property + def is_collectable(self) -> bool: + return self.is_collect_wash or self.is_collect_wash_dry or self.is_pure_collect + + @property + def is_washable(self) -> bool: + return self.is_pure_wash or self.is_collect_wash or self.is_collect_wash_dry + + @property + def is_dryable(self) -> bool: + return self.is_collect_wash_dry + + @property + def is_only_x_series(self) -> bool: + return self.dock_type in _ONLY_X_SERIES_DOCK_TYPES + + @property + def is_cleaning_brush_supported(self) -> bool: + return self.is_only_x_series + + @property + def is_clean_fluid_auto_delivery_supported(self) -> bool: + return self.dock_type in _CLEAN_FLUID_AUTO_DELIVERY_DOCK_TYPES or self._matches_am_variant( + _CLEAN_FLUID_AUTO_DELIVERY_AM_DOCK_TYPES + ) + + @property + def is_auto_sterilize_supported(self) -> bool: + return self.dock_type in _AUTO_STERILIZE_DOCK_TYPES + + @property + def is_inner_charge_head(self) -> bool: + return self.dock_type in _INNER_CHARGE_HEAD_DOCK_TYPES + + @property + def is_dust_bucket_supported(self) -> bool: + return self.dock_type in _DUST_BUCKET_DOCK_TYPES + + @property + def is_clean_carousel_self_clean_supported(self) -> bool: + return self._matches_non_am_variant(_CLEAN_CAROUSEL_SELF_CLEAN_DOCK_TYPES) + + @property + def is_water_updown_drain_supported(self) -> bool: + return self.dock_type in _WATER_UPDOWN_DRAIN_DOCK_TYPES or self._matches_am_variant( + _WATER_UPDOWN_DRAIN_AM_DOCK_TYPES + ) + + @property + def is_hatch_door_dock_cool_fan_supported(self) -> bool: + return self.dock_type in _HATCH_DOOR_DOCK_COOL_FAN_DOCK_TYPES + + @property + def is_special_support_wash_temp(self) -> bool: + return self.dock_type in _SPECIAL_WASH_TEMP_DOCK_TYPES diff --git a/roborock/devices/traits/v1/__init__.py b/roborock/devices/traits/v1/__init__.py index cb17e352c..ada389e2e 100644 --- a/roborock/devices/traits/v1/__init__.py +++ b/roborock/devices/traits/v1/__init__.py @@ -42,8 +42,8 @@ - `requires_feature` - The string name of the device feature that must be supported for this trait to be enabled. See `DeviceFeaturesTrait` for a list of available features. - - `requires_dock_type` - If set, this is a function that accepts a `RoborockDockTypeCode` - and returns a boolean indicating whether the trait is supported for that dock type. + - `requires_dock_features` - If set, this is a function that accepts a `RoborockDockFeatures` + and returns a boolean indicating whether the trait is supported for that dock. 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 @@ -59,6 +59,7 @@ from roborock.data.containers import HomeData, HomeDataProduct, RoborockBase from roborock.data.v1.v1_code_mappings import RoborockDockTypeCode +from roborock.device_features import RoborockDockFeatures from roborock.devices.cache import DeviceCache from roborock.devices.traits import Trait from roborock.exceptions import RoborockException @@ -262,9 +263,15 @@ async def discover_features(self) -> None: await self.device_features.refresh() # Dock type also acts like a device feature for some traits. dock_type = await self._dock_type() + if self.status.dss is None: + await self.status.refresh() + if self.status.dock_type is not None: + dock_type = self.status.dock_type + await self._set_cached_trait_data("dock_type", dock_type) + dock_features = RoborockDockFeatures.from_dock_type(dock_type, has_am=self.status.has_am) # 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_type): + if self.wash_towel_mode is None and self._is_supported(WashTowelModeTrait, "wash_towel_mode", dock_features): wash_towel_mode = WashTowelModeTrait(self.device_features) wash_towel_mode._rpc_channel = self._get_rpc_channel(wash_towel_mode) # type: ignore[assignment] self.wash_towel_mode = wash_towel_mode @@ -280,7 +287,7 @@ async def discover_features(self) -> None: # Union args may not be in declared order item_type = union_args[0] if union_args[1] is type(None) else union_args[1] - if not self._is_supported(item_type, item.name, dock_type): + if not self._is_supported(item_type, item.name, dock_features): _LOGGER.debug("Trait '%s' not supported, skipping", item.name) continue _LOGGER.debug("Trait '%s' is supported, initializing", item.name) @@ -288,11 +295,11 @@ async def discover_features(self) -> None: setattr(self, item.name, trait) trait._rpc_channel = self._get_rpc_channel(trait) - def _is_supported(self, trait_type: type[V1TraitMixin], name: str, dock_type: RoborockDockTypeCode) -> bool: + def _is_supported(self, trait_type: type[V1TraitMixin], name: str, dock_features: RoborockDockFeatures) -> bool: """Check if a trait is supported by the device.""" - if (requires_dock_type := getattr(trait_type, "requires_dock_type", None)) is not None: - return requires_dock_type(dock_type) + if (requires_dock_features := getattr(trait_type, "requires_dock_features", None)) is not None: + return requires_dock_features(dock_features) if (feature_name := getattr(trait_type, "requires_feature", None)) is None: _LOGGER.debug("Optional trait missing 'requires_feature' attribute %s, skipping", name) @@ -316,7 +323,7 @@ async def _dock_type(self) -> RoborockDockTypeCode: _LOGGER.debug("Fetched dock type: %s", self.status.dock_type) if self.status.dock_type is None: # Explicitly set so we reuse cached value next type - dock_type = RoborockDockTypeCode.no_dock + dock_type = RoborockDockTypeCode.o0_dock else: dock_type = self.status.dock_type await self._set_cached_trait_data("dock_type", dock_type) diff --git a/roborock/devices/traits/v1/dust_collection_mode.py b/roborock/devices/traits/v1/dust_collection_mode.py index afda53c1b..e738e30d1 100644 --- a/roborock/devices/traits/v1/dust_collection_mode.py +++ b/roborock/devices/traits/v1/dust_collection_mode.py @@ -1,14 +1,18 @@ """Trait for dust collection mode.""" from roborock.data import DustCollectionMode -from roborock.device_features import is_valid_dock +from roborock.device_features import RoborockDockFeatures from roborock.devices.traits.v1 import common from roborock.roborock_typing import RoborockCommand +def _supports_dust_collection_mode(dock_features: RoborockDockFeatures) -> bool: + return dock_features.is_collectable + + class DustCollectionModeTrait(DustCollectionMode, common.V1TraitMixin): """Trait for dust collection mode.""" command = RoborockCommand.GET_DUST_COLLECTION_MODE converter = common.DefaultConverter(DustCollectionMode) - requires_dock_type = is_valid_dock + requires_dock_features = _supports_dust_collection_mode diff --git a/roborock/devices/traits/v1/smart_wash_params.py b/roborock/devices/traits/v1/smart_wash_params.py index 9df0a53d1..78da74e20 100644 --- a/roborock/devices/traits/v1/smart_wash_params.py +++ b/roborock/devices/traits/v1/smart_wash_params.py @@ -1,14 +1,18 @@ """Trait for smart wash parameters.""" from roborock.data import SmartWashParams -from roborock.device_features import is_wash_n_fill_dock +from roborock.device_features import RoborockDockFeatures from roborock.devices.traits.v1 import common from roborock.roborock_typing import RoborockCommand +def _supports_smart_wash_params(dock_features: RoborockDockFeatures) -> bool: + return dock_features.is_washable + + class SmartWashParamsTrait(SmartWashParams, common.V1TraitMixin): """Trait for smart wash parameters.""" command = RoborockCommand.GET_SMART_WASH_PARAMS converter = common.DefaultConverter(SmartWashParams) - requires_dock_type = is_wash_n_fill_dock + requires_dock_features = _supports_smart_wash_params diff --git a/roborock/devices/traits/v1/wash_towel_mode.py b/roborock/devices/traits/v1/wash_towel_mode.py index de607bc7b..909e96b68 100644 --- a/roborock/devices/traits/v1/wash_towel_mode.py +++ b/roborock/devices/traits/v1/wash_towel_mode.py @@ -4,18 +4,22 @@ from typing import Self from roborock.data import WashTowelMode, WashTowelModes, get_wash_towel_modes -from roborock.device_features import is_wash_n_fill_dock +from roborock.device_features import RoborockDockFeatures from roborock.devices.traits.v1 import common from roborock.devices.traits.v1.device_features import DeviceFeaturesTrait from roborock.roborock_typing import RoborockCommand +def _supports_wash_towel_mode(dock_features: RoborockDockFeatures) -> bool: + return dock_features.is_washable + + class WashTowelModeTrait(WashTowelMode, common.V1TraitMixin): """Trait for wash towel mode.""" command = RoborockCommand.GET_WASH_TOWEL_MODE converter = common.DefaultConverter(WashTowelMode) - requires_dock_type = is_wash_n_fill_dock + requires_dock_features = _supports_wash_towel_mode def __init__( self, diff --git a/tests/data/v1/test_v1_containers.py b/tests/data/v1/test_v1_containers.py index f8625eb35..29a80ce8c 100644 --- a/tests/data/v1/test_v1_containers.py +++ b/tests/data/v1/test_v1_containers.py @@ -82,7 +82,7 @@ def test_status(): assert s.home_sec_enable_password == 0 assert s.adbumper_status == [0, 0, 0] assert s.water_shortage_status == 0 - assert s.dock_type == RoborockDockTypeCode.empty_wash_fill_dock + assert s.dock_type == RoborockDockTypeCode.o3_dock assert s.dust_collection_status == 0 assert s.auto_dust_collection == 1 assert s.avoid_count == 19 @@ -194,7 +194,7 @@ def test_status_v2() -> None: assert s.fan_power == 102 assert s.water_box_mode == 203 assert s.mop_mode == 300 - assert s.dock_type == RoborockDockTypeCode.empty_wash_fill_dock + assert s.dock_type == RoborockDockTypeCode.o3_dock assert s.dock_error_status == RoborockDockErrorCode.ok assert s.current_map == 0 @@ -268,7 +268,7 @@ def test_qrevo_s5v_dock_type(): modified_status = STATUS.copy() modified_status["dock_type"] = 22 s = S7MaxVStatus.from_dict(modified_status) - assert s.dock_type == RoborockDockTypeCode.qrevo_s5v_dock + assert s.dock_type == RoborockDockTypeCode.shell_2e_dock assert s.dock_type.value == 22 diff --git a/tests/devices/traits/v1/fixtures.py b/tests/devices/traits/v1/fixtures.py index fba0b86a5..58b02cbc7 100644 --- a/tests/devices/traits/v1/fixtures.py +++ b/tests/devices/traits/v1/fixtures.py @@ -112,7 +112,7 @@ def device_fixture( @pytest.fixture(name="dock_type_code", autouse=True) def dock_type_code_fixture(request: pytest.FixtureRequest) -> RoborockDockTypeCode | None: """Fixture to provide the dock type code for parameterized tests.""" - return RoborockDockTypeCode.s7_max_ultra_dock + return RoborockDockTypeCode.o3_plus_dock @pytest.fixture(autouse=True) diff --git a/tests/devices/traits/v1/helpers.py b/tests/devices/traits/v1/helpers.py new file mode 100644 index 000000000..6493ce34c --- /dev/null +++ b/tests/devices/traits/v1/helpers.py @@ -0,0 +1,13 @@ +"""Shared helpers for V1 trait tests.""" + +from roborock.data import RoborockDockTypeCode +from roborock.device_features import RoborockDockFeatures + + +def dock_types_with_capability(capability: str, expected: bool = True) -> list[RoborockDockTypeCode]: + """Return dock types matching a RoborockDockFeatures capability.""" + return [ + dock_type + for dock_type in RoborockDockTypeCode + if getattr(RoborockDockFeatures.from_dock_type(dock_type), capability) is expected + ] diff --git a/tests/devices/traits/v1/test_dust_collection_mode.py b/tests/devices/traits/v1/test_dust_collection_mode.py index 1b68304eb..f8e847b4d 100644 --- a/tests/devices/traits/v1/test_dust_collection_mode.py +++ b/tests/devices/traits/v1/test_dust_collection_mode.py @@ -8,6 +8,7 @@ from roborock.devices.device import RoborockDevice from roborock.devices.traits.v1.dust_collection_mode import DustCollectionModeTrait from roborock.roborock_typing import RoborockCommand +from tests.devices.traits.v1.helpers import dock_types_with_capability DUST_COLLECTION_MODE_DATA = [{"mode": 2}] @@ -24,14 +25,7 @@ def dust_collection_mode_trait( @pytest.mark.parametrize( ("dock_type_code"), - [ - (RoborockDockTypeCode.s7_max_ultra_dock), - (RoborockDockTypeCode.s8_dock), - (RoborockDockTypeCode.p10_dock), - (RoborockDockTypeCode.qrevo_s_dock), - (RoborockDockTypeCode.qrevo_s5v_dock), - (RoborockDockTypeCode.saros_20_dock), - ], + dock_types_with_capability("is_collectable"), ) async def test_dust_collection_mode_available( dust_collection_mode: DustCollectionModeTrait | None, @@ -56,7 +50,10 @@ async def test_dust_collection_mode_available( assert dust_collection_mode.mode == RoborockDockDustCollectionModeCode.balanced -@pytest.mark.parametrize(("dock_type_code"), [(RoborockDockTypeCode.no_dock)]) +@pytest.mark.parametrize( + ("dock_type_code"), + dock_types_with_capability("is_collectable", expected=False), +) async def test_unsupported_dust_collection_mode( dust_collection_mode: DustCollectionModeTrait | None, dock_type_code: RoborockDockTypeCode, diff --git a/tests/devices/traits/v1/test_smart_wash_params.py b/tests/devices/traits/v1/test_smart_wash_params.py index 63ec757f5..328d5c649 100644 --- a/tests/devices/traits/v1/test_smart_wash_params.py +++ b/tests/devices/traits/v1/test_smart_wash_params.py @@ -10,6 +10,7 @@ from roborock.devices.device import RoborockDevice from roborock.devices.traits.v1.smart_wash_params import SmartWashParamsTrait from roborock.roborock_typing import RoborockCommand +from tests.devices.traits.v1.helpers import dock_types_with_capability SMART_WASH_DATA = [{"smart_wash": 5, "wash_interval": 6}] @@ -26,13 +27,7 @@ def smart_wash_params_trait( @pytest.mark.parametrize( ("dock_type_code"), - [ - (RoborockDockTypeCode.s8_dock), - (RoborockDockTypeCode.p10_dock), - (RoborockDockTypeCode.qrevo_s_dock), - (RoborockDockTypeCode.qrevo_s5v_dock), - (RoborockDockTypeCode.saros_20_dock), - ], + dock_types_with_capability("is_washable"), ) async def test_smart_wash_available( smart_wash_params: SmartWashParamsTrait | None, @@ -64,10 +59,7 @@ async def test_smart_wash_available( @pytest.mark.parametrize( ("dock_type_code"), - [ - (RoborockDockTypeCode.s7_max_ultra_dock), # Not in WASH_N_FILL_DOCK_TYPES - (RoborockDockTypeCode.no_dock), - ], + dock_types_with_capability("is_washable", expected=False), ) async def test_unsupported_smart_wash_params( smart_wash_params: SmartWashParamsTrait | None, dock_type_code: RoborockDockTypeCode diff --git a/tests/devices/traits/v1/test_wash_towel_mode.py b/tests/devices/traits/v1/test_wash_towel_mode.py index f3eceea2c..8395c70ce 100644 --- a/tests/devices/traits/v1/test_wash_towel_mode.py +++ b/tests/devices/traits/v1/test_wash_towel_mode.py @@ -11,6 +11,7 @@ from roborock.devices.device import RoborockDevice from roborock.devices.traits.v1.wash_towel_mode import WashTowelModeTrait from roborock.roborock_typing import RoborockCommand +from tests.devices.traits.v1.helpers import dock_types_with_capability WASH_TOWEL_MODE_DATA = {"wash_mode": WashTowelModes.SMART.code} @@ -27,13 +28,7 @@ def wash_towel_mode_trait( @pytest.mark.parametrize( ("dock_type_code"), - [ - (RoborockDockTypeCode.s8_dock), - (RoborockDockTypeCode.p10_dock), - (RoborockDockTypeCode.qrevo_s_dock), - (RoborockDockTypeCode.qrevo_s5v_dock), - (RoborockDockTypeCode.saros_20_dock), - ], + dock_types_with_capability("is_washable"), ) async def test_wash_towel_mode_available( wash_towel_mode: WashTowelModeTrait, @@ -60,10 +55,7 @@ async def test_wash_towel_mode_available( @pytest.mark.parametrize( ("dock_type_code"), - [ - (RoborockDockTypeCode.s7_max_ultra_dock), - (RoborockDockTypeCode.no_dock), - ], + dock_types_with_capability("is_washable", expected=False), ) async def test_unsupported_wash_towel_mode( wash_towel_mode: WashTowelModeTrait | None, dock_type_code: RoborockDockTypeCode @@ -74,7 +66,7 @@ async def test_unsupported_wash_towel_mode( @pytest.mark.parametrize( ("dock_type_code"), - [(RoborockDockTypeCode.s8_dock)], + [(RoborockDockTypeCode.o4_dock)], ) @pytest.mark.parametrize( ("wash_mode"), @@ -101,7 +93,7 @@ async def test_set_wash_towel_mode( @pytest.mark.parametrize( ("dock_type_code"), - [(RoborockDockTypeCode.s8_dock)], + [(RoborockDockTypeCode.o4_dock)], ) async def test_start_wash( wash_towel_mode: WashTowelModeTrait, @@ -118,7 +110,7 @@ async def test_start_wash( @pytest.mark.parametrize( ("dock_type_code"), - [(RoborockDockTypeCode.s8_dock)], + [(RoborockDockTypeCode.o4_dock)], ) async def test_stop_wash( wash_towel_mode: WashTowelModeTrait, @@ -135,7 +127,7 @@ async def test_stop_wash( @pytest.mark.parametrize( ("dock_type_code"), - [(RoborockDockTypeCode.s8_dock)], + [(RoborockDockTypeCode.o4_dock)], ) @pytest.mark.parametrize( ( diff --git a/tests/test_supported_features.py b/tests/test_supported_features.py index 8f3aa853c..7e6c94869 100644 --- a/tests/test_supported_features.py +++ b/tests/test_supported_features.py @@ -5,7 +5,8 @@ from roborock import SHORT_MODEL_TO_ENUM from roborock.data.code_mappings import RoborockProductNickname -from roborock.device_features import DeviceFeatures +from roborock.data.v1 import RoborockDockTypeCode +from roborock.device_features import DeviceFeatures, RoborockDockFeatures from tests import mock_data @@ -79,6 +80,74 @@ def test_new_feature_str_missing(): assert not device_features.is_show_clean_finish_reason_supported +@pytest.mark.parametrize( + ("dock_type", "is_collectable", "is_washable"), + [ + (RoborockDockTypeCode.unknown, False, False), + (RoborockDockTypeCode.o0_dock, False, False), + (RoborockDockTypeCode.o1_dock, True, False), + (RoborockDockTypeCode.o2_dock, False, True), + (RoborockDockTypeCode.o3_dock, True, True), + (RoborockDockTypeCode.oc_dock, True, False), + (RoborockDockTypeCode.o3_plus_dock, True, True), + (RoborockDockTypeCode.o4_dock, True, True), + (RoborockDockTypeCode.pearl_dock, True, True), + (RoborockDockTypeCode.pearl_plus_dock, True, True), + (RoborockDockTypeCode.o5_dock, True, True), + (RoborockDockTypeCode.shell_2s_dock, True, True), + (RoborockDockTypeCode.couple_dock, True, True), + (RoborockDockTypeCode.shell_3_dock, True, True), + (RoborockDockTypeCode.shell_2c_dock, True, True), + (RoborockDockTypeCode.shell_3s_dock, True, True), + (RoborockDockTypeCode.k1_dock, True, True), + (RoborockDockTypeCode.o6_dock, True, True), + (RoborockDockTypeCode.k1c_dock, True, True), + (RoborockDockTypeCode.k1s_dock, True, True), + (RoborockDockTypeCode.shell_e_dock, True, True), + (RoborockDockTypeCode.shell_2e_dock, True, True), + (RoborockDockTypeCode.shell_3c_dock, True, True), + (RoborockDockTypeCode.type_27_dock, True, True), + (RoborockDockTypeCode.k1c_lite_dock, True, True), + (RoborockDockTypeCode.shell_2e_lite_dock, True, True), + ], +) +def test_dock_features(dock_type: RoborockDockTypeCode, is_collectable: bool, is_washable: bool) -> None: + dock_features = RoborockDockFeatures.from_dock_type(dock_type) + + assert dock_features.has_dock is (dock_type not in {RoborockDockTypeCode.unknown, RoborockDockTypeCode.o0_dock}) + assert dock_features.is_collectable is is_collectable + assert dock_features.is_washable is is_washable + + +def test_dock_feature_flags_from_rr_api() -> None: + """Verify dock-specific feature flags that are not currently trait gates.""" + assert RoborockDockFeatures.from_dock_type(RoborockDockTypeCode.shell_3s_dock).is_auto_sterilize_supported + assert RoborockDockFeatures.from_dock_type(RoborockDockTypeCode.o4_dock).is_cleaning_brush_supported + assert RoborockDockFeatures.from_dock_type(RoborockDockTypeCode.o4_dock).is_clean_fluid_auto_delivery_supported + assert RoborockDockFeatures.from_dock_type(RoborockDockTypeCode.couple_dock).is_hatch_door_dock_cool_fan_supported + assert RoborockDockFeatures.from_dock_type(RoborockDockTypeCode.shell_2e_lite_dock).is_special_support_wash_temp + + +def test_dock_feature_flags_with_am_variants_from_rr_api() -> None: + """Verify dock flags where the app distinguishes AM and non-AM docks by the same type code.""" + assert not RoborockDockFeatures.from_dock_type( + RoborockDockTypeCode.shell_3_dock + ).is_clean_fluid_auto_delivery_supported + assert RoborockDockFeatures.from_dock_type( + RoborockDockTypeCode.shell_3_dock, has_am=True + ).is_clean_fluid_auto_delivery_supported + + assert RoborockDockFeatures.from_dock_type(RoborockDockTypeCode.shell_3_dock).is_clean_carousel_self_clean_supported + assert not RoborockDockFeatures.from_dock_type( + RoborockDockTypeCode.shell_3_dock, has_am=True + ).is_clean_carousel_self_clean_supported + + assert not RoborockDockFeatures.from_dock_type(RoborockDockTypeCode.o5_dock).is_water_updown_drain_supported + assert RoborockDockFeatures.from_dock_type( + RoborockDockTypeCode.o5_dock, has_am=True + ).is_water_updown_drain_supported + + @pytest.mark.parametrize( ("device_filename"), list(mock_data.DEVICE_PRODUCT_PAIRS.keys()), From 30ae010daf5e6367bfda4bc14a5762de7f025420 Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 6 Jul 2026 21:34:01 -0400 Subject: [PATCH 2/4] fix: address dock feature review comments --- roborock/data/v1/v1_containers.py | 4 ++-- roborock/device_features.py | 15 +++++++++++++++ roborock/testing/v1_simulator.py | 2 +- tests/data/v1/test_v1_containers.py | 8 ++++++++ tests/test_supported_features.py | 4 +++- 5 files changed, 29 insertions(+), 4 deletions(-) diff --git a/roborock/data/v1/v1_containers.py b/roborock/data/v1/v1_containers.py index ce874f7fa..477d5e545 100644 --- a/roborock/data/v1/v1_containers.py +++ b/roborock/data/v1/v1_containers.py @@ -241,7 +241,7 @@ def current_map(self) -> int | None: @property def has_am(self) -> bool | None: - if not self.dss: + if self.dss is None: return None return (self.dss & 3) == 2 @@ -384,7 +384,7 @@ def current_map(self) -> int | None: @property def has_am(self) -> bool | None: - if not self.dss: + if self.dss is None: return None return (self.dss & 3) == 2 diff --git a/roborock/device_features.py b/roborock/device_features.py index a5f35b878..c3db83161 100644 --- a/roborock/device_features.py +++ b/roborock/device_features.py @@ -846,3 +846,18 @@ def is_hatch_door_dock_cool_fan_supported(self) -> bool: @property def is_special_support_wash_temp(self) -> bool: return self.dock_type in _SPECIAL_WASH_TEMP_DOCK_TYPES + + +WASH_N_FILL_DOCK_TYPES = [ + dock_type for dock_type in RoborockDockTypeCode if RoborockDockFeatures.from_dock_type(dock_type).is_washable +] + + +def is_wash_n_fill_dock(dock_type: RoborockDockTypeCode) -> bool: + """Check if the dock type is a wash and fill dock.""" + return RoborockDockFeatures.from_dock_type(dock_type).is_washable + + +def is_valid_dock(dock_type: RoborockDockTypeCode) -> bool: + """Check if device supports a dock.""" + return RoborockDockFeatures.from_dock_type(dock_type).has_dock diff --git a/roborock/testing/v1_simulator.py b/roborock/testing/v1_simulator.py index 686d37843..83bbba99e 100644 --- a/roborock/testing/v1_simulator.py +++ b/roborock/testing/v1_simulator.py @@ -81,7 +81,7 @@ def _serialize_dataclass(obj: Any) -> dict[str, Any]: home_sec_enable_password=0, adbumper_status=[0, 0, 0], water_shortage_status=0, - dock_type=RoborockDockTypeCode.s8_dock, + dock_type=RoborockDockTypeCode.o4_dock, dust_collection_status=0, auto_dust_collection=1, avoid_count=19, diff --git a/tests/data/v1/test_v1_containers.py b/tests/data/v1/test_v1_containers.py index 29a80ce8c..34b671e7e 100644 --- a/tests/data/v1/test_v1_containers.py +++ b/tests/data/v1/test_v1_containers.py @@ -272,6 +272,14 @@ def test_qrevo_s5v_dock_type(): assert s.dock_type.value == 22 +def test_has_am_dss_zero_is_not_missing(): + modified_status = STATUS.copy() + modified_status["dss"] = 0 + + assert S7MaxVStatus.from_dict(modified_status).has_am is False + assert StatusV2.from_dict(modified_status).has_am is False + + def test_multi_maps_list_info(snapshot: SnapshotAssertion) -> None: """Test that MultiMapsListInfo can be deserialized correctly.""" data = { diff --git a/tests/test_supported_features.py b/tests/test_supported_features.py index 7e6c94869..e72d2e2ed 100644 --- a/tests/test_supported_features.py +++ b/tests/test_supported_features.py @@ -6,7 +6,7 @@ from roborock import SHORT_MODEL_TO_ENUM from roborock.data.code_mappings import RoborockProductNickname from roborock.data.v1 import RoborockDockTypeCode -from roborock.device_features import DeviceFeatures, RoborockDockFeatures +from roborock.device_features import DeviceFeatures, RoborockDockFeatures, is_valid_dock, is_wash_n_fill_dock from tests import mock_data @@ -117,6 +117,8 @@ def test_dock_features(dock_type: RoborockDockTypeCode, is_collectable: bool, is assert dock_features.has_dock is (dock_type not in {RoborockDockTypeCode.unknown, RoborockDockTypeCode.o0_dock}) assert dock_features.is_collectable is is_collectable assert dock_features.is_washable is is_washable + assert is_valid_dock(dock_type) is dock_features.has_dock + assert is_wash_n_fill_dock(dock_type) is dock_features.is_washable def test_dock_feature_flags_from_rr_api() -> None: From fbb6df9a74ea0a9ab66e71e7cc2dc43dd20ec96c Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 6 Jul 2026 21:46:24 -0400 Subject: [PATCH 3/4] test: update dock feature discovery expectations --- .../devices/__snapshots__/test_v1_device.ambr | 2 +- .../__snapshots__/test_device_manager.ambr | 136 +++++++++--------- tests/e2e/test_device_manager.py | 13 +- 3 files changed, 72 insertions(+), 79 deletions(-) diff --git a/tests/devices/__snapshots__/test_v1_device.ambr b/tests/devices/__snapshots__/test_v1_device.ambr index 61858ce2b..32f5c5427 100644 --- a/tests/devices/__snapshots__/test_v1_device.ambr +++ b/tests/devices/__snapshots__/test_v1_device.ambr @@ -870,7 +870,7 @@ }) # --- # name: test_device_trait_command_parsing[status] - StatusTrait(adbumper_status=None, auto_dust_collection=None, avoid_count=None, back_type=None, battery=100, camera_status=None, charge_status=None, clean_area=91287500, clean_fluid_status=None, clean_percent=None, clean_time=5405, cleaning_mode_options=[, , , ], clear_water_box_status=None, collision_avoid_status=None, command=, common_status=None, converter=DefaultConverter, corner_clean_mode=None, current_cleaning_mode=, current_cleaning_mode_name='custom', current_map=0, debug_mode=None, dirty_water_box_status=None, distance_off=0, dnd_enabled=1, dock_cool_fan_status=None, dock_error_status=None, dock_state=, dock_type=None, dry_status=None, dss=None, dust_bag_status=None, dust_collection_status=None, error_code=, error_code_name='none', fan_power=106, fan_speed_mapping={101: 'quiet', 102: 'balanced', 103: 'turbo', 104: 'max', 108: 'max_plus', 105: 'off', 106: 'custom'}, fan_speed_name='custom', fan_speed_options=[, , , , , , ], hatch_door_status=None, home_sec_enable_password=None, home_sec_status=None, in_cleaning=, in_fresh_state=1, in_returning=0, in_warmup=None, is_exploring=None, is_locating=0, kct=None, lab_status=1, last_clean_t=None, lock_status=0, map_present=1, map_status=3, mop_forbidden_enable=0, mop_mode=None, mop_route_mapping={300: 'standard', 301: 'deep', 302: 'custom'}, mop_route_name=None, mop_route_options=[, , ], msg_seq=515, msg_ver=2, rdt=None, repeat=None, replenish_mode=None, rss=None, square_meter_clean_area=91.3, state=, state_name='charging', subdivision_sets=None, switch_map_mode=None, unsave_map_flag=0, unsave_map_reason=4, wash_phase=None, wash_ready=None, wash_status=None, water_box_carriage_status=0, water_box_filter_status=None, water_box_mode=204, water_box_status=0, water_mode_mapping={200: 'off', 201: 'mild', 202: 'standard', 203: 'intense', 207: 'custom_water_flow', 204: 'custom'}, water_mode_name='custom', water_mode_options=[, , , , , ], water_shortage_status=None) + StatusTrait(adbumper_status=None, auto_dust_collection=None, avoid_count=None, back_type=None, battery=100, camera_status=None, charge_status=None, clean_area=91287500, clean_fluid_status=None, clean_percent=None, clean_time=5405, cleaning_mode_options=[, , , ], clear_water_box_status=None, collision_avoid_status=None, command=, common_status=None, converter=DefaultConverter, corner_clean_mode=None, current_cleaning_mode=, current_cleaning_mode_name='custom', current_map=0, debug_mode=None, dirty_water_box_status=None, distance_off=0, dnd_enabled=1, dock_cool_fan_status=None, dock_error_status=None, dock_state=, dock_type=None, dry_status=None, dss=None, dust_bag_status=None, dust_collection_status=None, error_code=, error_code_name='none', fan_power=106, fan_speed_mapping={101: 'quiet', 102: 'balanced', 103: 'turbo', 104: 'max', 108: 'max_plus', 105: 'off', 106: 'custom'}, fan_speed_name='custom', fan_speed_options=[, , , , , , ], has_am=None, hatch_door_status=None, home_sec_enable_password=None, home_sec_status=None, in_cleaning=, in_fresh_state=1, in_returning=0, in_warmup=None, is_exploring=None, is_locating=0, kct=None, lab_status=1, last_clean_t=None, lock_status=0, map_present=1, map_status=3, mop_forbidden_enable=0, mop_mode=None, mop_route_mapping={300: 'standard', 301: 'deep', 302: 'custom'}, mop_route_name=None, mop_route_options=[, , ], msg_seq=515, msg_ver=2, rdt=None, repeat=None, replenish_mode=None, rss=None, square_meter_clean_area=91.3, state=, state_name='charging', subdivision_sets=None, switch_map_mode=None, unsave_map_flag=0, unsave_map_reason=4, wash_phase=None, wash_ready=None, wash_status=None, water_box_carriage_status=0, water_box_filter_status=None, water_box_mode=204, water_box_status=0, water_mode_mapping={200: 'off', 201: 'mild', 202: 'standard', 203: 'intense', 207: 'custom_water_flow', 204: 'custom'}, water_mode_name='custom', water_mode_options=[, , , , , ], water_shortage_status=None) # --- # name: test_device_trait_command_parsing[status].1 dict({ diff --git a/tests/e2e/__snapshots__/test_device_manager.ambr b/tests/e2e/__snapshots__/test_device_manager.ambr index 0e8290eea..90f2398dc 100644 --- a/tests/e2e/__snapshots__/test_device_manager.ambr +++ b/tests/e2e/__snapshots__/test_device_manager.ambr @@ -534,7 +534,7 @@ 00000000 90 04 00 01 00 00 |......| [local >] 00000000 00 00 00 15 31 2e 30 00 00 00 01 00 00 23 8c 68 |....1.0......#.h| - 00000010 a6 a2 2c 00 00 b8 95 0e 86 |..,......| + 00000010 a6 a2 2d 00 00 b9 57 64 b1 |..-...Wd.| [local <] 00000000 00 00 00 27 31 2e 30 00 00 00 01 00 00 00 17 68 |...'1.0........h| 00000010 a6 a2 2b 00 01 00 10 6d b9 48 37 ed 43 59 7a 90 |..+....m.H7.CYz.| @@ -558,71 +558,71 @@ 00000070 38 a4 39 90 99 6f 4c 84 b5 1b ba |8.9..oL....| [local <] 00000000 00 00 04 37 31 2e 30 00 00 00 02 00 00 00 17 68 |...71.0........h| - 00000010 a6 a2 2d 00 66 04 20 5e 5f 0b 3d ac 87 95 d7 72 |..-.f. ^_.=....r| - 00000020 96 43 49 74 68 c3 35 e3 c7 bd 9d d2 41 b1 15 99 |.CIth.5.....A...| - 00000030 23 aa 66 a2 1a b7 54 da d3 d1 17 a7 d5 96 8c 29 |#.f...T........)| - 00000040 1e a0 17 b7 40 ed 89 49 70 0d f9 1e aa b0 68 73 |....@..Ip.....hs| - 00000050 6c 0e 82 1e 46 6b b7 7f 75 d6 7e b8 a9 4c d1 57 |l...Fk..u.~..L.W| - 00000060 42 95 10 c8 f1 d9 04 9e b9 2c b6 3f dd 1c e5 2f |B........,.?.../| - 00000070 08 1f 0f b7 e9 85 5a 34 4b 69 9b 6f 68 dd 45 74 |......Z4Ki.oh.Et| - 00000080 d4 bc c6 ad 11 07 98 9f fb aa 7b 67 96 2f 67 60 |..........{g./g`| - 00000090 e1 4b 2d 80 72 48 47 09 f0 c3 a2 65 a4 32 3b d2 |.K-.rHG....e.2;.| - 000000a0 02 ac 38 76 72 30 27 9d 75 d6 1a aa 05 e5 08 c5 |..8vr0'.u.......| - 000000b0 2e c4 3e ab 86 72 6b 61 ce d0 77 1e c4 6f 91 6f |..>..rka..w..o.o| - 000000c0 29 d3 19 89 45 21 6b 1e 7a 27 76 e1 1a 71 32 0f |)...E!k.z'v..q2.| - 000000d0 af bb 54 7d ea 30 19 e6 95 0b db 0b 88 0b eb 19 |..T}.0..........| - 000000e0 18 f1 03 80 3e 2d d3 dd 47 61 93 d1 5a b9 b1 13 |....>-..Ga..Z...| - 000000f0 c6 5c b1 f6 52 0a 94 6f 27 05 fe 67 80 3d 3a 51 |.\..R..o'..g.=:Q| - 00000100 d6 65 28 10 23 4f 42 9c d6 40 c3 3e c6 64 13 a8 |.e(.#OB..@.>.d..| - 00000110 c1 7d 13 17 d2 a0 a1 7b 6d 35 a5 62 73 75 f0 9b |.}.....{m5.bsu..| - 00000120 e9 32 96 62 14 0e 97 b3 73 b1 df 48 b4 4c 96 b1 |.2.b....s..H.L..| - 00000130 52 8d d5 5b 2f b2 21 f0 e6 81 9b e4 a3 68 11 6c |R..[/.!......h.l| - 00000140 bd d8 6c 4c ba a3 1a fd 12 4a c8 e1 39 ce 74 f5 |..lL.....J..9.t.| - 00000150 52 71 4d 28 f1 d4 8e a6 68 95 fb e2 b9 f6 62 10 |RqM(....h.....b.| - 00000160 a0 51 1b 44 13 53 71 1d 32 94 54 e8 d8 3a d9 d1 |.Q.D.Sq.2.T..:..| - 00000170 15 e3 f9 09 91 38 2a ba 4c e1 ca a8 6e 3f cf 38 |.....8*.L...n?.8| - 00000180 8a 8f 38 f7 da d1 b4 13 17 df 84 d4 74 0c 2a 65 |..8.........t.*e| - 00000190 64 96 ec 5e 9b fb 4d f5 eb ab e8 9a 91 ae 3b d1 |d..^..M.......;.| - 000001a0 ac 54 66 a1 18 65 92 f9 7c 67 a0 e2 d2 7e 79 3f |.Tf..e..|g...~y?| - 000001b0 5a 9d 31 8d f4 98 cd cf 1b c5 51 04 12 05 34 6c |Z.1.......Q...4l| - 000001c0 36 4e 9a 00 2b 10 9c 53 1f cf fe ff 28 d3 ab fe |6N..+..S....(...| - 000001d0 dc 7c 85 fb 23 06 00 58 2f 4d 36 fb 1b 04 76 a4 |.|..#..X/M6...v.| - 000001e0 7f 73 b8 58 b1 1a 22 2c e6 27 29 09 c7 48 72 f7 |.s.X..",.')..Hr.| - 000001f0 63 ce 7b ff 61 60 23 8a 92 06 a0 fa cc fd 6a d0 |c.{.a`#.......j.| - 00000200 13 e5 24 33 f7 2c c2 95 06 06 d8 c8 28 21 09 c9 |..$3.,......(!..| - 00000210 d8 d5 52 67 a6 81 68 80 00 f1 8b d9 dc bf 16 ea |..Rg..h.........| - 00000220 8f 76 2f bd 6e a8 89 77 94 d3 58 52 0e da aa 06 |.v/.n..w..XR....| - 00000230 2c 4d 35 89 23 45 8a 58 d0 b1 19 fb d4 05 da f0 |,M5.#E.X........| - 00000240 6f 2f 00 e5 11 85 4e 34 a8 55 13 39 a9 2a 46 75 |o/....N4.U.9.*Fu| - 00000250 52 e1 1f 66 e3 b8 2a 55 ab b0 ad ea 9b b6 4b 4d |R..f..*U......KM| - 00000260 9c 79 9b 09 d1 b6 61 48 89 37 b0 98 3c bf bb 4a |.y....aH.7..<..J| - 00000270 f1 22 e2 dd ad e6 3c 98 f6 d0 1f 14 c1 73 ed 7e |."....<......s.~| - 00000280 f8 9a fb 4e 64 e9 2f d9 f9 94 58 b1 f4 9d 67 13 |...Nd./...X...g.| - 00000290 6d 08 4b 6e 1b ca d6 76 02 3f b0 2c fc 0b 0b 32 |m.Kn...v.?.,...2| - 000002a0 ce 38 a1 05 86 d3 db ce dd 06 63 49 e8 c1 d1 41 |.8........cI...A| - 000002b0 00 e0 3b 0a ed 7b 7a b1 04 86 a8 bb 1b 18 52 63 |..;..{z.......Rc| - 000002c0 db 42 2a c3 41 d8 1c 3c 31 ef 6b 72 7e c2 54 a5 |.B*.A..<1.kr~.T.| - 000002d0 20 94 23 b9 8d d0 5e 94 ef 85 b0 73 47 5d 7c f0 | .#...^....sG]|.| - 000002e0 ef ac 82 55 13 04 ba ca 4f 1b af fb 5c 0c b9 e7 |...U....O...\...| - 000002f0 21 c7 97 75 d3 db 18 51 d1 92 5c 51 97 8a ba 3e |!..u...Q..\Q...>| - 00000300 79 80 5c c9 21 8f 6b 8f 04 9e 9a 47 ed ef fe 66 |y.\.!.k....G...f| - 00000310 fe d3 76 5b ff dc 31 d9 f9 94 58 b1 f4 9d 67 13 |..v[..1...X...g.| - 00000320 6d 08 4b 6e 1b ca d6 7f 0c de c5 f4 a5 0f 49 cf |m.Kn..........I.| - 00000330 f9 e5 74 88 96 8a 14 bd 8a 19 0a ce 93 be 30 b8 |..t...........0.| - 00000340 35 1a 1e bb f3 0b ed 34 28 7f 73 ca 76 4a 8d 03 |5......4(.s.vJ..| - 00000350 81 0a 2c 2c cc fe e8 c6 e2 dd 9d e0 be 45 63 57 |..,,.........EcW| - 00000360 ae 34 40 e1 c4 5c 2b ee 40 68 5e d0 35 83 94 ce |.4@..\+.@h^.5...| - 00000370 be 2a 27 08 a5 dc 00 d5 b6 af 48 b3 6b 57 99 80 |.*'.......H.kW..| - 00000380 ba e8 2d 7e b5 57 4e 91 5e 15 7a 13 af 9d ed 24 |..-~.WN.^.z....$| - 00000390 03 6a 35 fb 75 b0 56 36 90 70 ff 59 f9 3f dd 7e |.j5.u.V6.p.Y.?.~| - 000003a0 09 1b 05 a1 38 dc 1c 12 5b 9c 4d 82 b2 32 95 1f |....8...[.M..2..| - 000003b0 80 4f 29 53 51 9f b3 12 72 1a b4 b9 79 d5 7b 74 |.O)SQ...r...y.{t| - 000003c0 99 94 71 90 83 b5 51 1a 47 4e 99 ae 54 78 98 fe |..q...Q.GN..Tx..| - 000003d0 38 91 1e 5e 67 72 1b f1 b8 7d 66 83 55 21 94 7c |8..^gr...}f.U!.|| - 000003e0 d6 79 8b 61 65 0e 0d 46 c4 3b 6f e8 0e f2 ec 4b |.y.ae..F.;o....K| - 000003f0 b6 60 24 40 83 2b 94 06 de a8 88 76 d6 c4 7b 17 |.`$@.+.....v..{.| - 00000400 77 50 dd e5 02 1f 70 7f 9b 8e 2a 66 11 62 f2 18 |wP....p...*f.b..| - 00000410 e7 61 68 58 95 9f 05 25 06 86 73 14 ce 85 1c c9 |.ahX...%..s.....| - 00000420 0c f5 7b 1a aa 95 74 e4 5c e7 c1 2a 55 4b c8 96 |..{...t.\..*UK..| - 00000430 23 c2 9b 9c e9 e2 68 96 7d d6 86 |#.....h.}..| + 00000010 a6 a2 2c 00 66 04 20 18 b5 83 e4 47 b0 8e 1b ef |..,.f. ....G....| + 00000020 12 9a 66 1b b4 1c b5 6b 71 79 d5 5d 6a 00 7e d0 |..f....kqy.]j.~.| + 00000030 60 35 d0 cb 55 bf 7e f4 39 8f 13 eb ac fb d5 b0 |`5..U.~.9.......| + 00000040 58 b0 8f da c7 a9 03 d4 a1 17 02 e4 eb 07 e5 c3 |X...............| + 00000050 85 f4 57 89 4f 01 15 b8 87 ad 28 12 93 2c 4a 76 |..W.O.....(..,Jv| + 00000060 49 1d 7f cc 3c bf 08 64 43 df 22 1d ea 5d 89 a4 |I...<..dC."..]..| + 00000070 09 a5 0a 7d 3d 06 a5 19 45 87 ea 3f f5 bf 8d 1d |...}=...E..?....| + 00000080 b9 ca 34 b0 58 9e e6 b0 68 ed a2 b8 b2 11 4c 76 |..4.X...h.....Lv| + 00000090 c7 99 e2 27 87 8b 45 76 9d 5e 21 43 09 34 e5 c6 |...'..Ev.^!C.4..| + 000000a0 6b ac 5a 66 d9 59 6d 5e 13 2a 21 e7 c1 2e 91 6e |k.Zf.Ym^.*!....n| + 000000b0 84 bc bd db 86 be 0b 86 db ef 40 75 34 bf 9a 5d |..........@u4..]| + 000000c0 7a 22 b0 1a 22 b6 5c 9e 32 3c 8f a6 39 83 ff bb |z"..".\.2<..9...| + 000000d0 b3 80 29 3f 12 31 50 df 18 d1 31 bb 7e 04 68 c7 |..)?.1P...1.~.h.| + 000000e0 d3 39 79 ab d0 8f 24 64 20 85 a5 33 31 b7 51 4a |.9y...$d ..31.QJ| + 000000f0 3b 2f b8 5c 12 08 1e 15 e9 88 3b 9a c7 5e 01 fe |;/.\......;..^..| + 00000100 e7 4b 4a 30 21 73 26 8d 50 b7 6b 18 b2 94 fa f9 |.KJ0!s&.P.k.....| + 00000110 a9 30 24 7a 1d a6 1d d9 fa 84 de eb d3 c4 1e 64 |.0$z...........d| + 00000120 3a ba d6 5e 7c 7c 32 87 98 9f d7 64 b7 19 ae ac |:..^||2....d....| + 00000130 a5 0e 3a 0b 4a c2 78 67 4e 8c 16 50 31 c0 95 70 |..:.J.xgN..P1..p| + 00000140 61 75 5d 90 fd d9 41 92 64 f4 cd 58 4e 2f 21 2e |au]...A.d..XN/!.| + 00000150 e3 d8 0f 9c 27 fc 95 03 13 32 54 e5 5a b4 ba b2 |....'....2T.Z...| + 00000160 2d 9b 90 21 84 96 4c f5 0b 36 53 ae 9a 57 01 44 |-..!..L..6S..W.D| + 00000170 3e 5b e2 b2 70 e1 2d a5 3c 05 d2 cd 41 7c f2 b4 |>[..p.-.<...A|..| + 00000180 98 95 d4 9f 6e aa 68 c0 43 1f f9 69 cc 09 41 5f |....n.h.C..i..A_| + 00000190 8a 37 13 e9 75 72 51 07 ad 01 61 e6 36 2b 14 b2 |.7..urQ...a.6+..| + 000001a0 da fc 34 74 33 e8 08 ce 6b 5a 3b 36 36 c0 8f c5 |..4t3...kZ;66...| + 000001b0 54 27 bc 7b 49 94 c2 13 4a f7 ec ef 74 28 26 e6 |T'.{I...J...t(&.| + 000001c0 29 a9 e3 f2 6e 99 22 80 85 85 00 02 23 5b 47 c9 |)...n.".....#[G.| + 000001d0 06 d5 77 70 10 5a 16 3e ba cd 4c d1 e9 83 6a 62 |..wp.Z.>..L...jb| + 000001e0 33 6e f8 30 55 ab cd ea 62 e0 86 9e cb 64 8e 2a |3n.0U...b....d.*| + 000001f0 10 5d 3f 67 c8 9d ac 12 62 79 62 ce 50 0d b3 60 |.]?g....byb.P..`| + 00000200 15 c2 5a 8f 13 fb 6a d9 75 56 54 d2 05 8a 51 7e |..Z...j.uVT...Q~| + 00000210 55 7e 53 f3 34 da e3 dc 2c b2 c8 4e 32 0d 38 cf |U~S.4...,..N2.8.| + 00000220 7a 9d d9 91 33 dc 68 7b ed 86 ce 27 58 1f 90 eb |z...3.h{...'X...| + 00000230 a9 83 43 2e ed 29 60 79 81 89 00 9c 24 66 a2 44 |..C..)`y....$f.D| + 00000240 8b d9 ba 14 86 cd b6 66 2d 6b e7 1f 0c ce b7 bb |.......f-k......| + 00000250 95 04 97 61 b1 4f 9b ed 08 44 c3 41 5e 31 64 57 |...a.O...D.A^1dW| + 00000260 5d 6d 94 14 14 a7 5b 43 3b 58 79 b2 b8 f7 11 2c |]m....[C;Xy....,| + 00000270 81 4f c1 bd e6 a6 77 79 07 e5 a9 34 5c 8b 0e c7 |.O....wy...4\...| + 00000280 8c ee a0 c2 ce d2 69 e2 a0 bd b3 23 ad 15 17 25 |......i....#...%| + 00000290 62 0b b3 e0 0f a4 43 34 24 de 17 09 73 5d 15 67 |b.....C4$...s].g| + 000002a0 dd 29 eb 91 34 fe 95 07 c0 6e ae ad 8b 1c c5 f7 |.)..4....n......| + 000002b0 e6 5e a2 09 65 d1 ce 2e 48 6d b6 d8 82 fa 4d 60 |.^..e...Hm....M`| + 000002c0 f0 35 4b 78 9c 18 c3 c1 e4 41 ce 8e 6a 5b ae b4 |.5Kx.....A..j[..| + 000002d0 9d 80 e8 09 68 b3 eb ac a4 9d 74 36 7f c3 bb 17 |....h.....t6....| + 000002e0 f6 19 c5 db db 99 cc 7f 8b e4 03 75 fa 21 a2 ae |...........u.!..| + 000002f0 a6 a3 2c 56 6c 67 e6 31 69 54 2b 89 74 3f 21 99 |..,Vlg.1iT+.t?!.| + 00000300 78 bb da 36 77 6c a0 92 ce fc 41 d1 03 01 59 e5 |x..6wl....A...Y.| + 00000310 ef de d3 df 5a 8a f2 e2 a0 bd b3 23 ad 15 17 25 |....Z......#...%| + 00000320 62 0b b3 e0 0f a4 43 b0 7f 70 ef 1f 2b 0c 4e 51 |b.....C..p..+.NQ| + 00000330 26 4a e7 0b db 41 85 b7 32 33 bc 18 2f c2 a4 db |&J...A..23../...| + 00000340 7f db 4d fe 88 c4 2f 68 9a 4f 29 55 4b 1c 96 b6 |..M.../h.O)UK...| + 00000350 16 fc 5a 61 67 95 96 03 ea 3d bf 84 d7 9a d3 12 |..Zag....=......| + 00000360 f2 f1 f6 51 8c 02 6a e5 17 57 c3 ab 34 bf 17 61 |...Q..j..W..4..a| + 00000370 7c ae a6 c1 f0 70 a5 6e fe fd bd 8e 06 1e 10 f2 ||....p.n........| + 00000380 3c 7d f7 af 66 f9 44 c6 ec 62 9a 90 1c be 2c fb |<}..f.D..b....,.| + 00000390 b0 e6 9a 24 b0 09 fa ea 9d d3 8b 16 92 c7 da f1 |...$............| + 000003a0 f9 9c eb 38 d2 01 d2 51 d4 ad 5e f3 e3 63 20 a9 |...8...Q..^..c .| + 000003b0 aa 87 e2 18 9f b4 04 35 dd 4e af 43 00 95 b2 fe |.......5.N.C....| + 000003c0 68 02 0b c8 f0 ad 44 e4 c2 f7 0d 8b 45 1c a1 d2 |h.....D.....E...| + 000003d0 49 aa 3b db b2 5a 73 7b ae 7a 17 b4 bf f0 1b a9 |I.;..Zs{.z......| + 000003e0 16 a1 e8 b6 89 d8 b1 d2 a9 80 df b8 59 fe f8 8d |............Y...| + 000003f0 03 e4 af c5 c2 0b c3 31 d9 95 06 8f b5 65 7d 85 |.......1.....e}.| + 00000400 a0 b1 a9 ea d6 bb 43 26 4a fc 3e d1 74 a9 6d 13 |......C&J.>.t.m.| + 00000410 85 60 dc 0b e3 d4 14 f4 07 0f b3 57 09 27 5f b2 |.`.........W.'_.| + 00000420 dd 70 6b d2 45 f1 e7 2d 63 3c cf 59 a1 88 62 21 |.pk.E..-c<.Y..b!| + 00000430 b7 c3 14 ce e8 ab 03 56 7c d6 16 |.......V|..| # --- diff --git a/tests/e2e/test_device_manager.py b/tests/e2e/test_device_manager.py index 449d6fbe8..951c2abc2 100644 --- a/tests/e2e/test_device_manager.py +++ b/tests/e2e/test_device_manager.py @@ -291,6 +291,7 @@ async def test_v1_device( local_response_queue.put_nowait( response_builder.build_v1(protocol=RoborockMessageProtocol.HELLO_RESPONSE, payload=b"ok") ) + local_response_queue.put_nowait(response_builder.build_v1_rpc(data={"id": 9101, "result": [mock_data.STATUS]})) device_manager = await device_manager_factory(user_params) @@ -310,18 +311,10 @@ async def test_v1_device( assert device.v1_properties.device_features.is_customized_clean_supported assert not device.v1_properties.device_features.is_matter_supported - # In the previous test, the dock information is fetched and has the side effect of - # populating the status trait. This test gets dock information from the cache so - # we have to manually refresh status the first time (like other traits). + # Dock type is loaded from the cache, but status is refreshed during trait discovery + # so AM dock capabilities can be evaluated. assert device.v1_properties assert device.v1_properties.status - assert device.v1_properties.status.state_name is None - - # Exercise a GET_STATUS call. id is deterministic based on deterministic_message_fixtures - local_response_queue.put_nowait(response_builder.build_v1_rpc(data={"id": 9101, "result": [mock_data.STATUS]})) - - # Verify GET_STATUS response - await device.v1_properties.status.refresh() assert device.v1_properties.status.state_name == "charging" assert snapshot == log From 17a25b97a39e61c59bff77bb9ffa5b47241950b9 Mon Sep 17 00:00:00 2001 From: Luke Date: Tue, 7 Jul 2026 22:45:37 -0400 Subject: [PATCH 4/4] chore: move status --- roborock/devices/traits/v1/__init__.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/roborock/devices/traits/v1/__init__.py b/roborock/devices/traits/v1/__init__.py index ada389e2e..7cfbd3b7e 100644 --- a/roborock/devices/traits/v1/__init__.py +++ b/roborock/devices/traits/v1/__init__.py @@ -263,11 +263,6 @@ async def discover_features(self) -> None: await self.device_features.refresh() # Dock type also acts like a device feature for some traits. dock_type = await self._dock_type() - if self.status.dss is None: - await self.status.refresh() - if self.status.dock_type is not None: - dock_type = self.status.dock_type - await self._set_cached_trait_data("dock_type", dock_type) dock_features = RoborockDockFeatures.from_dock_type(dock_type, has_am=self.status.has_am) # Initialize traits with special arguments before the generic loop @@ -314,9 +309,16 @@ async def _dock_type(self) -> RoborockDockTypeCode: if dock_type is not None: _LOGGER.debug("Using cached dock type: %s", dock_type) try: - return RoborockDockTypeCode(dock_type) + dock_type = RoborockDockTypeCode(dock_type) except ValueError: _LOGGER.debug("Cached dock type %s is invalid, refreshing", dock_type) + else: + if self.status.dss is None: + await self.status.refresh() + if self.status.dock_type is not None: + dock_type = self.status.dock_type + await self._set_cached_trait_data("dock_type", dock_type) + return dock_type _LOGGER.debug("Starting dock type discovery") await self.status.refresh()