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: 22 additions & 0 deletions roborock/data/v1/v1_containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions roborock/devices/traits/v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
led_status,
map_content,
maps,
mop_dryer,
network_info,
obstacle_photos,
rooms,
Expand All @@ -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
Expand Down Expand Up @@ -132,6 +134,7 @@
"led_status",
"map_content",
"maps",
"mop_dryer",
"network_info",
"obstacle_photos",
"rooms",
Expand Down Expand Up @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions roborock/devices/traits/v1/mop_dryer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""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.roborock_typing import RoborockCommand

_STATUS_PARAM = "status"


def _supports_mop_dryer(dock_features: RoborockDockFeatures) -> bool:
return dock_features.is_dryable


class MopDryerTrait(MopDryerSetting, common.V1TraitMixin, common.RoborockSwitchBase):
"""Trait for the dock mop dryer.

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

@property
def is_on(self) -> bool:
"""Return whether auto mop drying is enabled."""
return self.status == 1

async def enable(self) -> None:
"""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 = 1

async def disable(self) -> None:
"""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 = 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})
166 changes: 166 additions & 0 deletions tests/devices/traits/v1/test_mop_dryer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
"""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.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(
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)],
)
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(
("dock_type_code"),
[(DRYABLE_DOCK)],
)
@pytest.mark.parametrize(
("status", "expected_is_on"),
[
pytest.param(None, False, id="not_reported"),
pytest.param(0, False, id="disabled"),
pytest.param(1, True, id="enabled"),
],
)
async def test_is_on(
mop_dryer: MopDryerTrait,
dock_type_code: RoborockDockTypeCode,
status: int | None,
expected_is_on: bool,
) -> None:
"""Test that is_on reflects the auto mop-drying setting."""
assert mop_dryer is not None

mop_dryer.status = 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", "expected_is_on"),
[
pytest.param("enable", 1, True, id="enable"),
pytest.param("disable", 0, False, id="disable"),
],
)
async def test_set_auto_dry(
mop_dryer: MopDryerTrait,
mock_rpc_channel: AsyncMock,
dock_type_code: RoborockDockTypeCode,
method_name: str,
expected_status: int,
expected_is_on: bool,
) -> None:
"""Test enabling and disabling auto mop drying."""
assert mop_dryer is not None

await getattr(mop_dryer, method_name)()

mock_rpc_channel.send_command.assert_called_with(
RoborockCommand.APP_SET_DRYER_SETTING, params={"status": expected_status}
)
# The command result is applied optimistically to avoid an extra refresh
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("start_dry", 1, id="start"),
pytest.param("stop_dry", 0, id="stop"),
],
)
async def test_dry_cycle(
mop_dryer: MopDryerTrait,
mock_rpc_channel: AsyncMock,
dock_type_code: RoborockDockTypeCode,
method_name: str,
expected_status: int,
) -> None:
"""Test starting and stopping a mop drying cycle."""
assert mop_dryer is not None

await getattr(mop_dryer, method_name)()

mock_rpc_channel.send_command.assert_called_with(
RoborockCommand.APP_SET_DRYER_STATUS, params={"status": expected_status}
)
Loading