diff --git a/roborock/data/dyad/dyad_code_mappings.py b/roborock/data/dyad/dyad_code_mappings.py index 3c3d9e74..afd72149 100644 --- a/roborock/data/dyad/dyad_code_mappings.py +++ b/roborock/data/dyad/dyad_code_mappings.py @@ -134,6 +134,14 @@ class DyadPowerwheelDirection(RoborockEnum): exit = 9 # sent when remote control ends +class DyadDryingMode(RoborockEnum): + """Drying mode of the F25 Ultra, reported on DP 272 and set through DP 225.""" + + unknown = -1 + fast = 5 + silent = 22 + + class DyadError(RoborockEnum): none = 0 dirty_tank_full = 20000 # Dirty tank full. Empty it diff --git a/roborock/data/dyad/dyad_containers.py b/roborock/data/dyad/dyad_containers.py index ee0441bf..814f4b8f 100644 --- a/roborock/data/dyad/dyad_containers.py +++ b/roborock/data/dyad/dyad_containers.py @@ -23,6 +23,21 @@ class DyadSndState(RoborockBase): language: str +@dataclass +class DyadScheduleTask(RoborockBase): + """A task from the device schedule (DP 10002). + + `cron` uses the device's local time. On the F25 Ultra every task had the same + `dp_action`, while the device ran nothing, drying, or self-clean and drying, + so the action that will run cannot be inferred from `dp_action`. + """ + + id: int + on: bool + cron: str + dp_action: dict[int, int] + + @dataclass class DyadOtaNfo(RoborockBase): mqttOtaData: dict diff --git a/roborock/devices/traits/a01/__init__.py b/roborock/devices/traits/a01/__init__.py index 0df06f74..7682bc7f 100644 --- a/roborock/devices/traits/a01/__init__.py +++ b/roborock/devices/traits/a01/__init__.py @@ -31,12 +31,13 @@ from datetime import UTC, datetime, time from typing import Any, Generic, TypeVar -from roborock.data import DyadProductInfo, DyadSndState, HomeDataProduct, RoborockCategory +from roborock.data import DyadProductInfo, DyadScheduleTask, DyadSndState, HomeDataProduct, RoborockCategory from roborock.data.dyad.dyad_code_mappings import ( DyadBrushSpeed, DyadCleanAssistPower, DyadCleanMode, DyadCleanserAmount, + DyadDryingMode, DyadError, DyadPowerwheelDirection, DyadSelfCleanIntensity, @@ -88,6 +89,13 @@ ] +def _decode_dyad_schedule(value: str) -> list[DyadScheduleTask]: + """Decode the schedule data point, which is a JSON list or an empty string.""" + if not value: + return [] + return [DyadScheduleTask.from_dict(task) for task in json.loads(value)] + + DYAD_PROTOCOL_ENTRIES: dict[RoborockDyadDataProtocol, Callable] = { RoborockDyadDataProtocol.STATUS: lambda val: RoborockDyadStateCode(val).name, RoborockDyadDataProtocol.SELF_CLEAN_MODE: lambda val: DyadSelfCleanMode(val).name, @@ -107,7 +115,7 @@ RoborockDyadDataProtocol.STAND_LOCK_AUTO_RUN: lambda val: bool(val), RoborockDyadDataProtocol.AUTO_SELF_CLEAN_SET_MODE: lambda val: DyadSelfCleanLevel(val).name, RoborockDyadDataProtocol.AUTO_DRY_MODE: lambda val: bool(val), - RoborockDyadDataProtocol.SILENT_DRY_DURATION: lambda val: int(val), # in minutes + RoborockDyadDataProtocol.SILENT_DRY_DURATION: lambda val: int(val), # minutes, or DyadDryingMode on F25 RoborockDyadDataProtocol.SILENT_MODE: lambda val: bool(val), RoborockDyadDataProtocol.SILENT_MODE_START_TIME: lambda val: time( hour=int(val / 60), minute=val % 60 @@ -134,7 +142,8 @@ RoborockDyadDataProtocol.POWERWHEEL_SPEED: lambda val: int(val), RoborockDyadDataProtocol.SELF_CLEAN_CUR_MODE: lambda val: DyadSelfCleanIntensity(val).name, RoborockDyadDataProtocol.SELF_CLEAN_CUR_TEMP: lambda val: DyadSelfCleanWaterTemp(val).name, - RoborockDyadDataProtocol.DRY_CUR_MODE: lambda val: int(val), # in minutes + RoborockDyadDataProtocol.DRY_CUR_MODE: lambda val: DyadDryingMode(val).name, + RoborockDyadDataProtocol.SCHEDULE_TASK: _decode_dyad_schedule, RoborockDyadDataProtocol.SND_STATE: lambda val: DyadSndState.from_dict(val), RoborockDyadDataProtocol.PRODUCT_INFO: lambda val: DyadProductInfo.from_dict(val), } @@ -315,6 +324,28 @@ async def set_value(self, protocol: RoborockDyadDataProtocol, value: Any) -> dic params = {protocol: value} return await send_decoded_command(self._channel, params) + async def start_drying(self) -> None: + """Start drying with the drying mode currently set on the device. + + The app sends the same command for fast and silent drying on the F25 Ultra. + """ + await send_decoded_command( + self._channel, + {RoborockDyadDataProtocol.START: 1, RoborockDyadDataProtocol.WARM_LEVEL: 1}, + ) + + async def stop_drying(self) -> None: + """Stop a running drying cycle.""" + await send_decoded_command(self._channel, {RoborockDyadDataProtocol.START: 0}) + + async def reset_filter(self) -> None: + """Reset the filter usage counter.""" + await send_decoded_command(self._channel, {RoborockDyadDataProtocol.MESH_RESET: 1}) + + async def reset_brush(self) -> None: + """Reset the brush roller usage counter.""" + await send_decoded_command(self._channel, {RoborockDyadDataProtocol.BRUSH_RESET: 1}) + async def add_listener(self, callback: Callable[[dict[RoborockDyadDataProtocol, Any]], None]) -> Callable[[], None]: """Listen for state the device pushes on its own. diff --git a/roborock/roborock_message.py b/roborock/roborock_message.py index 50bd3c7c..e3620d06 100644 --- a/roborock/roborock_message.py +++ b/roborock/roborock_message.py @@ -86,7 +86,7 @@ class RoborockDyadDataProtocol(RoborockEnum): STATUS = 201 SELF_CLEAN_MODE = 202 SELF_CLEAN_LEVEL = 203 - WARM_LEVEL = 204 + WARM_LEVEL = 204 # F25 Ultra: mode of the current or last drying run, 2 fast and 1 silent CLEAN_MODE = 205 SUCTION = 206 WATER_LEVEL = 207 @@ -104,7 +104,7 @@ class RoborockDyadDataProtocol(RoborockEnum): STAND_LOCK_AUTO_RUN = 222 AUTO_SELF_CLEAN_SET_MODE = 223 AUTO_DRY_MODE = 224 - SILENT_DRY_DURATION = 225 + SILENT_DRY_DURATION = 225 # minutes on older models; F25 Ultra uses DyadDryingMode codes SILENT_MODE = 226 SILENT_MODE_START_TIME = 227 SILENT_MODE_END_TIME = 228 @@ -128,7 +128,7 @@ class RoborockDyadDataProtocol(RoborockEnum): POWERWHEEL_SPEED = 250 # remote control drive speed SELF_CLEAN_CUR_MODE = 270 # intensity of the current or last self-clean run SELF_CLEAN_CUR_TEMP = 271 # water temperature of the current or last self-clean run - DRY_CUR_MODE = 272 # drying time in minutes of the current or next run, follows SILENT_DRY_DURATION + DRY_CUR_MODE = 272 # DyadDryingMode on the F25 Ultra, follows SILENT_DRY_DURATION ID_QUERY = 10000 F_C = 10001 SCHEDULE_TASK = 10002 diff --git a/tests/devices/traits/a01/test_init.py b/tests/devices/traits/a01/test_init.py index 5fcc3ee3..aea824e6 100644 --- a/tests/devices/traits/a01/test_init.py +++ b/tests/devices/traits/a01/test_init.py @@ -7,6 +7,7 @@ from Crypto.Util.Padding import unpad from freezegun import freeze_time +from roborock.data import DyadScheduleTask from roborock.devices.traits.a01 import DyadApi, ZeoApi, create from roborock.devices.traits.a01.device_feature import build_force_load_dp_list from roborock.roborock_message import RoborockDyadDataProtocol, RoborockMessageProtocol, RoborockZeoProtocol @@ -180,7 +181,7 @@ async def test_dyad_f25_ultra_status_snapshot(dyad_api: DyadApi, fake_channel: F RoborockDyadDataProtocol.FLAT_STATUS: False, RoborockDyadDataProtocol.SELF_CLEAN_CUR_MODE: "smart", RoborockDyadDataProtocol.SELF_CLEAN_CUR_TEMP: "high", - RoborockDyadDataProtocol.DRY_CUR_MODE: 5, + RoborockDyadDataProtocol.DRY_CUR_MODE: "fast", } @@ -217,7 +218,10 @@ async def test_dyad_f25_ultra_status_snapshot(dyad_api: DyadApi, fake_channel: F (RoborockDyadDataProtocol.POWERWHEEL_DIRECTION, 9, "exit"), (RoborockDyadDataProtocol.POWERWHEEL_DIRECTION, 3, "unknown"), (RoborockDyadDataProtocol.POWERWHEEL_SPEED, 15, 15), - (RoborockDyadDataProtocol.DRY_CUR_MODE, 22, 22), + (RoborockDyadDataProtocol.DRY_CUR_MODE, 5, "fast"), + (RoborockDyadDataProtocol.DRY_CUR_MODE, 22, "silent"), + (RoborockDyadDataProtocol.DRY_CUR_MODE, 7, "unknown"), + (RoborockDyadDataProtocol.SILENT_DRY_DURATION, 22, 22), ], ) async def test_dyad_f25_ultra_value_conversion( @@ -481,6 +485,59 @@ async def test_dyad_api_set_value(dyad_api: DyadApi, fake_channel: FakeChannel): assert "t" in payload_data +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("", []), + ( + '[{"id":1794139256726,"on":true,"cron":"0 15 * * 1,2,3,4,5","dpAction":{"200":1,"204":1}}]', + [DyadScheduleTask(id=1794139256726, on=True, cron="0 15 * * 1,2,3,4,5", dp_action={200: 1, 204: 1})], + ), + ], + ids=["empty", "weekdays"], +) +async def test_dyad_schedule_task( + raw: str, + expected: list[DyadScheduleTask], + dyad_api: DyadApi, + fake_channel: FakeChannel, +): + """The schedule payload seen on an F25 Ultra is decoded into typed tasks.""" + await dyad_api.start() + fake_channel.notify_subscribers(build_a01_message({10002: raw})) + assert dyad_api.values == {RoborockDyadDataProtocol.SCHEDULE_TASK: expected} + + +async def test_dyad_schedule_task_invalid(dyad_api: DyadApi, fake_channel: FakeChannel): + """A malformed schedule payload does not produce a value.""" + await dyad_api.start() + fake_channel.notify_subscribers(build_a01_message({10002: "not json"})) + assert dyad_api.values == {} + + +@pytest.mark.parametrize( + ("method", "expected_dps"), + [ + ("start_drying", {"200": 1, "204": 1}), + ("stop_drying", {"200": 0}), + ("reset_filter", {"218": 1}), + ("reset_brush", {"219": 1}), + ], +) +async def test_dyad_action_commands( + method: str, + expected_dps: dict[str, int], + dyad_api: DyadApi, + fake_channel: FakeChannel, +): + """Action commands send the data points used by the app.""" + await getattr(dyad_api, method)() + + assert len(fake_channel.published_messages) == 1 + payload_data = json.loads(unpad(fake_channel.published_messages[0].payload, AES.block_size)) + assert payload_data["dps"] == expected_dps + + async def test_zeo_api_set_value(zeo_api: ZeoApi, fake_channel: FakeChannel): """Test ZeoApi set_value sends correct command.""" await zeo_api.set_value(RoborockZeoProtocol.MODE, "standard")