From 233a0af8132f5100679cc4c442e1c17d7288ad34 Mon Sep 17 00:00:00 2001 From: dschuld Date: Tue, 18 Aug 2026 09:35:31 +0000 Subject: [PATCH 1/2] feat: add MopDryerTrait for controlling the dock mop dryer Adds a switch trait for starting and stopping a mop drying cycle via APP_SET_DRYER_STATUS, gated on the dock being able to dry. The dryer has no dedicated query command: whether a cycle is running is reported as dry_status on the device status. The trait therefore holds the status trait, reads is_on from it, and refreshes through it, applying the commanded value optimistically like the other switch traits. Named MopDryerTrait rather than DryerTrait to stay clear of the Zeo washer/dryer appliance support, which has its own dryer concepts. --- roborock/devices/traits/v1/__init__.py | 9 ++ roborock/devices/traits/v1/mop_dryer.py | 48 ++++++++ tests/devices/traits/v1/test_mop_dryer.py | 128 ++++++++++++++++++++++ 3 files changed, 185 insertions(+) create mode 100644 roborock/devices/traits/v1/mop_dryer.py create mode 100644 tests/devices/traits/v1/test_mop_dryer.py diff --git a/roborock/devices/traits/v1/__init__.py b/roborock/devices/traits/v1/__init__.py index ac8f29658..e0bca984e 100644 --- a/roborock/devices/traits/v1/__init__.py +++ b/roborock/devices/traits/v1/__init__.py @@ -82,6 +82,7 @@ led_status, map_content, maps, + mop_dryer, network_info, obstacle_photos, rooms, @@ -105,6 +106,7 @@ from .led_status import LedStatusTrait from .map_content import MapContentTrait from .maps import MapsTrait +from .mop_dryer import MopDryerTrait from .network_info import NetworkInfoTrait from .obstacle_photos import ObstaclePhotoTrait from .rooms import RoomsTrait @@ -132,6 +134,7 @@ "led_status", "map_content", "maps", + "mop_dryer", "network_info", "obstacle_photos", "rooms", @@ -175,6 +178,7 @@ class PropertiesApi(Trait): wash_towel_mode: WashTowelModeTrait | None = None smart_wash_params: SmartWashParamsTrait | None = None obstacle_photos: ObstaclePhotoTrait | None = None + mop_dryer: MopDryerTrait | None = None def __init__( self, @@ -285,6 +289,11 @@ async def discover_features(self) -> None: obstacle_photos._rpc_channel = self._get_rpc_channel(obstacle_photos) self.obstacle_photos = obstacle_photos + if self.mop_dryer is None and self._is_supported(MopDryerTrait, "mop_dryer", dock_features): + mop_dryer = MopDryerTrait(self.status) + mop_dryer._rpc_channel = self._get_rpc_channel(mop_dryer) + self.mop_dryer = mop_dryer + # Dynamically create any traits that need to be populated for item in fields(self): if (trait := getattr(self, item.name, None)) is not None: diff --git a/roborock/devices/traits/v1/mop_dryer.py b/roborock/devices/traits/v1/mop_dryer.py new file mode 100644 index 000000000..930703c51 --- /dev/null +++ b/roborock/devices/traits/v1/mop_dryer.py @@ -0,0 +1,48 @@ +"""Trait for the dock mop dryer.""" + +from roborock.device_features import RoborockDockFeatures +from roborock.devices.traits.v1 import common +from roborock.devices.traits.v1.status import StatusTrait +from roborock.roborock_typing import RoborockCommand + +_STATUS_PARAM = "status" + + +def _supports_mop_dryer(dock_features: RoborockDockFeatures) -> bool: + return dock_features.is_dryable + + +class MopDryerTrait(common.V1TraitMixin, common.RoborockSwitchBase): + """Trait for controlling the dock mop dryer. + + The dryer has no dedicated query command. Whether a drying cycle is running + is reported as ``dry_status`` on the device status, so this trait reads its + state from the status trait and refreshes through it. + """ + + requires_dock_features = _supports_mop_dryer + + def __init__(self, status_trait: StatusTrait) -> None: + super().__init__() + self._status_trait = status_trait + + async def refresh(self) -> None: + """Refresh the dryer state, which is reported through the device status.""" + await self._status_trait.refresh() + + @property + def is_on(self) -> bool: + """Return whether a drying cycle is currently running.""" + return bool(self._status_trait.dry_status) + + async def enable(self) -> None: + """Start drying the mop.""" + await self.rpc_channel.send_command(RoborockCommand.APP_SET_DRYER_STATUS, params={_STATUS_PARAM: 1}) + # Optimistic update to avoid an extra refresh + self._status_trait.dry_status = 1 + + async def disable(self) -> None: + """Stop drying the mop.""" + await self.rpc_channel.send_command(RoborockCommand.APP_SET_DRYER_STATUS, params={_STATUS_PARAM: 0}) + # Optimistic update to avoid an extra refresh + self._status_trait.dry_status = 0 diff --git a/tests/devices/traits/v1/test_mop_dryer.py b/tests/devices/traits/v1/test_mop_dryer.py new file mode 100644 index 000000000..126382012 --- /dev/null +++ b/tests/devices/traits/v1/test_mop_dryer.py @@ -0,0 +1,128 @@ +"""Tests for the MopDryerTrait class.""" + +from unittest.mock import AsyncMock, call + +import pytest + +from roborock.data import RoborockDockTypeCode +from roborock.devices.device import RoborockDevice +from roborock.devices.traits.v1.mop_dryer import MopDryerTrait +from roborock.roborock_typing import RoborockCommand +from tests import mock_data +from tests.devices.traits.v1.helpers import dock_types_with_capability + +DRYABLE_DOCK = RoborockDockTypeCode.o4_dock + + +@pytest.fixture(name="mop_dryer") +def mop_dryer_trait( + device: RoborockDevice, + discover_features_fixture: None, +) -> MopDryerTrait | None: + """Create a MopDryerTrait instance with mocked dependencies.""" + assert device.v1_properties + return device.v1_properties.mop_dryer + + +@pytest.mark.parametrize( + ("dock_type_code"), + dock_types_with_capability("is_dryable"), +) +async def test_mop_dryer_available(mop_dryer: MopDryerTrait | None, dock_type_code: RoborockDockTypeCode) -> None: + """Test that the trait is available for every dryable dock type.""" + assert mop_dryer is not None + + +@pytest.mark.parametrize( + ("dock_type_code"), + dock_types_with_capability("is_dryable", expected=False), +) +async def test_unsupported_mop_dryer(mop_dryer: MopDryerTrait | None, dock_type_code: RoborockDockTypeCode) -> None: + """Test that the trait is not available for dock types that cannot dry.""" + assert mop_dryer is None + + +@pytest.mark.parametrize( + ("dock_type_code"), + [(DRYABLE_DOCK)], +) +@pytest.mark.parametrize( + ("dry_status", "expected_is_on"), + [ + pytest.param(None, False, id="not_reported"), + pytest.param(0, False, id="idle"), + pytest.param(1, True, id="drying"), + ], +) +async def test_is_on_reads_status( + mop_dryer: MopDryerTrait, + device: RoborockDevice, + dock_type_code: RoborockDockTypeCode, + dry_status: int | None, + expected_is_on: bool, +) -> None: + """Test that the mop dryer state is read from the device status.""" + assert mop_dryer is not None + assert device.v1_properties + + device.v1_properties.status.dry_status = dry_status + + assert mop_dryer.is_on is expected_is_on + + +@pytest.mark.parametrize( + ("dock_type_code"), + [(DRYABLE_DOCK)], +) +@pytest.mark.parametrize( + ("method_name", "expected_status"), + [ + pytest.param("enable", 1, id="enable"), + pytest.param("disable", 0, id="disable"), + ], +) +async def test_set_mop_dryer_status( + mop_dryer: MopDryerTrait, + device: RoborockDevice, + mock_rpc_channel: AsyncMock, + dock_type_code: RoborockDockTypeCode, + method_name: str, + expected_status: int, +) -> None: + """Test starting and stopping the mop dryer sends the right command.""" + assert mop_dryer is not None + assert device.v1_properties + + await getattr(mop_dryer, method_name)() + + mock_rpc_channel.send_command.assert_called_with( + RoborockCommand.APP_SET_DRYER_STATUS, params={"status": expected_status} + ) + # The command result is applied optimistically to avoid an extra refresh + assert device.v1_properties.status.dry_status == expected_status + assert mop_dryer.is_on is bool(expected_status) + + +@pytest.mark.parametrize( + ("dock_type_code"), + [(DRYABLE_DOCK)], +) +async def test_refresh_delegates_to_status( + mop_dryer: MopDryerTrait, + device: RoborockDevice, + mock_rpc_channel: AsyncMock, + dock_type_code: RoborockDockTypeCode, +) -> None: + """Test refreshing the mop dryer refreshes the status it reads from.""" + assert mop_dryer is not None + assert device.v1_properties + + mock_rpc_channel.send_command.side_effect = [ + {**mock_data.STATUS, "dry_status": 1}, + ] + + await mop_dryer.refresh() + + mock_rpc_channel.send_command.assert_has_calls([call(RoborockCommand.GET_STATUS)]) + assert device.v1_properties.status.dry_status == 1 + assert mop_dryer.is_on is True From 9299567876e2e62c730cfa7762341b39c1d21499 Mon Sep 17 00:00:00 2001 From: dschuld Date: Sun, 13 Sep 2026 20:05:08 +0000 Subject: [PATCH 2/2] refactor: back MopDryerTrait with the dryer setting instead of device status Model the trait on APP_GET_DRYER_SETTING with a MopDryerSetting dataclass so it fulfils the V1TraitMixin contract (command, converter, dataclass fields) rather than borrowing state from StatusTrait. The switch now controls the auto mop-drying setting: is_on reflects `status`, and enable/disable send APP_SET_DRYER_SETTING with a partial `{"status": N}`, which the device accepts and merges without touching the `on`/`off` profiles. Starting and stopping a drying cycle directly moves to start_dry/stop_dry via APP_SET_DRYER_STATUS. Whether a cycle is running remains reported by `dry_status` on the device status. The trait no longer takes constructor arguments, so it is created by the generic discovery loop and the special-case block in discover_features is removed. Tests assert only on the commands sent and the trait's own state; the refresh test uses a payload captured from a real dock. --- roborock/data/v1/v1_containers.py | 22 +++++ roborock/devices/traits/v1/__init__.py | 5 -- roborock/devices/traits/v1/mop_dryer.py | 47 +++++----- tests/devices/traits/v1/test_mop_dryer.py | 102 +++++++++++++++------- 4 files changed, 117 insertions(+), 59 deletions(-) diff --git a/roborock/data/v1/v1_containers.py b/roborock/data/v1/v1_containers.py index 5efc379ab..bf1be98ba 100644 --- a/roborock/data/v1/v1_containers.py +++ b/roborock/data/v1/v1_containers.py @@ -459,6 +459,28 @@ class SmartWashParams(RoborockBase): wash_interval: int | None = None +@dataclass +class MopDryerProfile(RoborockBase): + """Dryer parameters for one state of the auto mop-drying setting.""" + + cliff_on: int | None = None + cliff_off: int | None = None + count: int | None = None + dry_time: int | None = None + """Drying duration in seconds. Only present in the ``on`` profile.""" + dry_heating_film_time: int | None = None + """Heating element run time in seconds. Only present in the ``on`` profile.""" + + +@dataclass +class MopDryerSetting(RoborockBase): + """Auto mop-drying setting, as returned by APP_GET_DRYER_SETTING.""" + + status: int | None = None + on: MopDryerProfile | None = None + off: MopDryerProfile | None = None + + @dataclass class DustCollectionMode(RoborockBase): mode: RoborockDockDustCollectionModeCode | None = None diff --git a/roborock/devices/traits/v1/__init__.py b/roborock/devices/traits/v1/__init__.py index e0bca984e..e3f93d4b7 100644 --- a/roborock/devices/traits/v1/__init__.py +++ b/roborock/devices/traits/v1/__init__.py @@ -289,11 +289,6 @@ async def discover_features(self) -> None: obstacle_photos._rpc_channel = self._get_rpc_channel(obstacle_photos) self.obstacle_photos = obstacle_photos - if self.mop_dryer is None and self._is_supported(MopDryerTrait, "mop_dryer", dock_features): - mop_dryer = MopDryerTrait(self.status) - mop_dryer._rpc_channel = self._get_rpc_channel(mop_dryer) - self.mop_dryer = mop_dryer - # Dynamically create any traits that need to be populated for item in fields(self): if (trait := getattr(self, item.name, None)) is not None: diff --git a/roborock/devices/traits/v1/mop_dryer.py b/roborock/devices/traits/v1/mop_dryer.py index 930703c51..56e1cf675 100644 --- a/roborock/devices/traits/v1/mop_dryer.py +++ b/roborock/devices/traits/v1/mop_dryer.py @@ -1,8 +1,8 @@ """Trait for the dock mop dryer.""" +from roborock.data import MopDryerSetting from roborock.device_features import RoborockDockFeatures from roborock.devices.traits.v1 import common -from roborock.devices.traits.v1.status import StatusTrait from roborock.roborock_typing import RoborockCommand _STATUS_PARAM = "status" @@ -12,37 +12,40 @@ def _supports_mop_dryer(dock_features: RoborockDockFeatures) -> bool: return dock_features.is_dryable -class MopDryerTrait(common.V1TraitMixin, common.RoborockSwitchBase): - """Trait for controlling the dock mop dryer. +class MopDryerTrait(MopDryerSetting, common.V1TraitMixin, common.RoborockSwitchBase): + """Trait for the dock mop dryer. - The dryer has no dedicated query command. Whether a drying cycle is running - is reported as ``dry_status`` on the device status, so this trait reads its - state from the status trait and refreshes through it. + The switch controls the auto mop-drying setting, i.e. whether the dock + dries the mop after washing. ``start_dry`` and ``stop_dry`` control a + drying cycle directly. Whether a cycle is currently running is reported + as ``dry_status`` on the device status. """ + command = RoborockCommand.APP_GET_DRYER_SETTING + converter = common.DefaultConverter(MopDryerSetting) requires_dock_features = _supports_mop_dryer - def __init__(self, status_trait: StatusTrait) -> None: - super().__init__() - self._status_trait = status_trait - - async def refresh(self) -> None: - """Refresh the dryer state, which is reported through the device status.""" - await self._status_trait.refresh() - @property def is_on(self) -> bool: - """Return whether a drying cycle is currently running.""" - return bool(self._status_trait.dry_status) + """Return whether auto mop drying is enabled.""" + return self.status == 1 async def enable(self) -> None: - """Start drying the mop.""" - await self.rpc_channel.send_command(RoborockCommand.APP_SET_DRYER_STATUS, params={_STATUS_PARAM: 1}) + """Enable auto mop drying.""" + await self.rpc_channel.send_command(RoborockCommand.APP_SET_DRYER_SETTING, params={_STATUS_PARAM: 1}) # Optimistic update to avoid an extra refresh - self._status_trait.dry_status = 1 + self.status = 1 async def disable(self) -> None: - """Stop drying the mop.""" - await self.rpc_channel.send_command(RoborockCommand.APP_SET_DRYER_STATUS, params={_STATUS_PARAM: 0}) + """Disable auto mop drying.""" + await self.rpc_channel.send_command(RoborockCommand.APP_SET_DRYER_SETTING, params={_STATUS_PARAM: 0}) # Optimistic update to avoid an extra refresh - self._status_trait.dry_status = 0 + self.status = 0 + + async def start_dry(self) -> None: + """Start a mop drying cycle.""" + await self.rpc_channel.send_command(RoborockCommand.APP_SET_DRYER_STATUS, params={_STATUS_PARAM: 1}) + + async def stop_dry(self) -> None: + """Stop the running mop drying cycle.""" + await self.rpc_channel.send_command(RoborockCommand.APP_SET_DRYER_STATUS, params={_STATUS_PARAM: 0}) diff --git a/tests/devices/traits/v1/test_mop_dryer.py b/tests/devices/traits/v1/test_mop_dryer.py index 126382012..a4b33f4f7 100644 --- a/tests/devices/traits/v1/test_mop_dryer.py +++ b/tests/devices/traits/v1/test_mop_dryer.py @@ -8,11 +8,23 @@ from roborock.devices.device import RoborockDevice from roborock.devices.traits.v1.mop_dryer import MopDryerTrait from roborock.roborock_typing import RoborockCommand -from tests import mock_data from tests.devices.traits.v1.helpers import dock_types_with_capability DRYABLE_DOCK = RoborockDockTypeCode.o4_dock +# Captured from a real dock via APP_GET_DRYER_SETTING. +MOP_DRYER_SETTING_DATA = { + "status": 1, + "on": { + "cliff_on": 1000, + "cliff_off": 1000, + "count": 10, + "dry_time": 7200, + "dry_heating_film_time": 3600, + }, + "off": {"cliff_on": 500, "cliff_off": 500, "count": 10}, +} + @pytest.fixture(name="mop_dryer") def mop_dryer_trait( @@ -46,26 +58,51 @@ async def test_unsupported_mop_dryer(mop_dryer: MopDryerTrait | None, dock_type_ ("dock_type_code"), [(DRYABLE_DOCK)], ) +async def test_refresh( + mop_dryer: MopDryerTrait, + mock_rpc_channel: AsyncMock, + dock_type_code: RoborockDockTypeCode, +) -> None: + """Test refreshing the mop dryer setting from the device.""" + assert mop_dryer is not None + + mock_rpc_channel.send_command.side_effect = [ + MOP_DRYER_SETTING_DATA, + ] + + await mop_dryer.refresh() + + mock_rpc_channel.send_command.assert_has_calls([call(RoborockCommand.APP_GET_DRYER_SETTING)]) + assert mop_dryer.is_on is True + assert mop_dryer.on is not None + assert mop_dryer.on.dry_time == 7200 + assert mop_dryer.on.dry_heating_film_time == 3600 + assert mop_dryer.off is not None + assert mop_dryer.off.dry_time is None + + @pytest.mark.parametrize( - ("dry_status", "expected_is_on"), + ("dock_type_code"), + [(DRYABLE_DOCK)], +) +@pytest.mark.parametrize( + ("status", "expected_is_on"), [ pytest.param(None, False, id="not_reported"), - pytest.param(0, False, id="idle"), - pytest.param(1, True, id="drying"), + pytest.param(0, False, id="disabled"), + pytest.param(1, True, id="enabled"), ], ) -async def test_is_on_reads_status( +async def test_is_on( mop_dryer: MopDryerTrait, - device: RoborockDevice, dock_type_code: RoborockDockTypeCode, - dry_status: int | None, + status: int | None, expected_is_on: bool, ) -> None: - """Test that the mop dryer state is read from the device status.""" + """Test that is_on reflects the auto mop-drying setting.""" assert mop_dryer is not None - assert device.v1_properties - device.v1_properties.status.dry_status = dry_status + mop_dryer.status = status assert mop_dryer.is_on is expected_is_on @@ -75,54 +112,55 @@ async def test_is_on_reads_status( [(DRYABLE_DOCK)], ) @pytest.mark.parametrize( - ("method_name", "expected_status"), + ("method_name", "expected_status", "expected_is_on"), [ - pytest.param("enable", 1, id="enable"), - pytest.param("disable", 0, id="disable"), + pytest.param("enable", 1, True, id="enable"), + pytest.param("disable", 0, False, id="disable"), ], ) -async def test_set_mop_dryer_status( +async def test_set_auto_dry( mop_dryer: MopDryerTrait, - device: RoborockDevice, mock_rpc_channel: AsyncMock, dock_type_code: RoborockDockTypeCode, method_name: str, expected_status: int, + expected_is_on: bool, ) -> None: - """Test starting and stopping the mop dryer sends the right command.""" + """Test enabling and disabling auto mop drying.""" assert mop_dryer is not None - assert device.v1_properties await getattr(mop_dryer, method_name)() mock_rpc_channel.send_command.assert_called_with( - RoborockCommand.APP_SET_DRYER_STATUS, params={"status": expected_status} + RoborockCommand.APP_SET_DRYER_SETTING, params={"status": expected_status} ) # The command result is applied optimistically to avoid an extra refresh - assert device.v1_properties.status.dry_status == expected_status - assert mop_dryer.is_on is bool(expected_status) + assert mop_dryer.is_on is expected_is_on @pytest.mark.parametrize( ("dock_type_code"), [(DRYABLE_DOCK)], ) -async def test_refresh_delegates_to_status( +@pytest.mark.parametrize( + ("method_name", "expected_status"), + [ + pytest.param("start_dry", 1, id="start"), + pytest.param("stop_dry", 0, id="stop"), + ], +) +async def test_dry_cycle( mop_dryer: MopDryerTrait, - device: RoborockDevice, mock_rpc_channel: AsyncMock, dock_type_code: RoborockDockTypeCode, + method_name: str, + expected_status: int, ) -> None: - """Test refreshing the mop dryer refreshes the status it reads from.""" + """Test starting and stopping a mop drying cycle.""" assert mop_dryer is not None - assert device.v1_properties - - mock_rpc_channel.send_command.side_effect = [ - {**mock_data.STATUS, "dry_status": 1}, - ] - await mop_dryer.refresh() + await getattr(mop_dryer, method_name)() - mock_rpc_channel.send_command.assert_has_calls([call(RoborockCommand.GET_STATUS)]) - assert device.v1_properties.status.dry_status == 1 - assert mop_dryer.is_on is True + mock_rpc_channel.send_command.assert_called_with( + RoborockCommand.APP_SET_DRYER_STATUS, params={"status": expected_status} + )