From 5bad249a8eeba8a91b558f041775b28f86f9265e Mon Sep 17 00:00:00 2001 From: infy1995 Date: Sun, 8 Nov 2026 13:09:16 +0100 Subject: [PATCH 1/3] fix(dyad): decode F25 Ultra drying mode DP 272 was documented and decoded as a drying time in minutes, but on the F25 Ultra it carries a mode code that follows DP 225: 5 for fast drying and 22 for silent drying. Decode it with a new DyadDryingMode enum. Keep DP 225 as an integer, because it is the silent drying duration in minutes on older models, and document the F25 Ultra codes. Also document that DP 204 reports the mode of the current or last drying run: it does not change when the mode setting changes. --- roborock/data/dyad/dyad_code_mappings.py | 8 ++++++++ roborock/devices/traits/a01/__init__.py | 5 +++-- roborock/roborock_message.py | 6 +++--- tests/devices/traits/a01/test_init.py | 7 +++++-- 4 files changed, 19 insertions(+), 7 deletions(-) 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/devices/traits/a01/__init__.py b/roborock/devices/traits/a01/__init__.py index 0df06f74..efa7270c 100644 --- a/roborock/devices/traits/a01/__init__.py +++ b/roborock/devices/traits/a01/__init__.py @@ -37,6 +37,7 @@ DyadCleanAssistPower, DyadCleanMode, DyadCleanserAmount, + DyadDryingMode, DyadError, DyadPowerwheelDirection, DyadSelfCleanIntensity, @@ -107,7 +108,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 +135,7 @@ 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.SND_STATE: lambda val: DyadSndState.from_dict(val), RoborockDyadDataProtocol.PRODUCT_INFO: lambda val: DyadProductInfo.from_dict(val), } 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..9cec45ef 100644 --- a/tests/devices/traits/a01/test_init.py +++ b/tests/devices/traits/a01/test_init.py @@ -180,7 +180,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 +217,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( From 48eddc1ec048cbf082098ae1c9f56ff52f95bcae Mon Sep 17 00:00:00 2001 From: infy1995 Date: Sun, 8 Nov 2026 13:31:29 +0100 Subject: [PATCH 2/3] feat(dyad): decode schedule and add drying commands Decode SCHEDULE_TASK (10002) into DyadScheduleTask. The device sends a JSON list of tasks or an empty string. The cron field uses local time. On an F25 Ultra every task had the same dpAction, while the device ran nothing, drying, or self-clean and drying, so dpAction does not identify the action. Add DyadApi.start_drying and DyadApi.stop_drying, which send the same data points as the Roborock app and were verified on an F25 Ultra. --- roborock/data/dyad/dyad_containers.py | 15 +++++++ roborock/devices/traits/a01/__init__.py | 24 +++++++++++- tests/devices/traits/a01/test_init.py | 52 +++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) 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 efa7270c..8e726b1c 100644 --- a/roborock/devices/traits/a01/__init__.py +++ b/roborock/devices/traits/a01/__init__.py @@ -31,7 +31,7 @@ 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, @@ -89,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, @@ -136,6 +143,7 @@ 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: 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), } @@ -316,6 +324,20 @@ 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 add_listener(self, callback: Callable[[dict[RoborockDyadDataProtocol, Any]], None]) -> Callable[[], None]: """Listen for state the device pushes on its own. diff --git a/tests/devices/traits/a01/test_init.py b/tests/devices/traits/a01/test_init.py index 9cec45ef..d9ef8062 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 @@ -484,6 +485,57 @@ 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}), + ], +) +async def test_dyad_drying_commands( + method: str, + expected_dps: dict[str, int], + dyad_api: DyadApi, + fake_channel: FakeChannel, +): + """Drying 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") From e6fc38eb7f84c2b8f22a5f7f2d4917866f767a5b Mon Sep 17 00:00:00 2001 From: infy1995 Date: Sun, 8 Nov 2026 14:28:46 +0100 Subject: [PATCH 3/3] feat(dyad): add filter and brush reset commands Add DyadApi.reset_filter and DyadApi.reset_brush, which write 1 to MESH_RESET (218) and BRUSH_RESET (219). On an F25 Ultra the device answers with the matching usage counter (214 or 215) reset to 0, the same response as when resetting from the Roborock app. --- roborock/devices/traits/a01/__init__.py | 8 ++++++++ tests/devices/traits/a01/test_init.py | 6 ++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/roborock/devices/traits/a01/__init__.py b/roborock/devices/traits/a01/__init__.py index 8e726b1c..7682bc7f 100644 --- a/roborock/devices/traits/a01/__init__.py +++ b/roborock/devices/traits/a01/__init__.py @@ -338,6 +338,14 @@ 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/tests/devices/traits/a01/test_init.py b/tests/devices/traits/a01/test_init.py index d9ef8062..aea824e6 100644 --- a/tests/devices/traits/a01/test_init.py +++ b/tests/devices/traits/a01/test_init.py @@ -520,15 +520,17 @@ async def test_dyad_schedule_task_invalid(dyad_api: DyadApi, fake_channel: FakeC [ ("start_drying", {"200": 1, "204": 1}), ("stop_drying", {"200": 0}), + ("reset_filter", {"218": 1}), + ("reset_brush", {"219": 1}), ], ) -async def test_dyad_drying_commands( +async def test_dyad_action_commands( method: str, expected_dps: dict[str, int], dyad_api: DyadApi, fake_channel: FakeChannel, ): - """Drying commands send the data points used by the app.""" + """Action commands send the data points used by the app.""" await getattr(dyad_api, method)() assert len(fake_channel.published_messages) == 1