Skip to content

feat(dyad): add F25 Ultra drying mode, schedule and reset support - #1019

Merged
allenporter merged 3 commits into
Python-roborock:mainfrom
infy1995:fix/dyad-f25-drying-mappings
Nov 8, 2026
Merged

allenporter merged 3 commits into
Python-roborock:mainfrom
infy1995:fix/dyad-f25-drying-mappings

Conversation

@infy1995

@infy1995 infy1995 commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #1017 for the F25 Ultra (roborock.wdv.a194). It corrects one mapping that #1017 got wrong and adds the remaining drying, schedule and reset support, all checked on a real device.

Correction: DP 272 is a drying mode, not minutes

#1017 documented and decoded DRY_CUR_MODE (272) as a drying time in minutes. Further testing showed that was wrong. On the F25 Ultra, DPs 225 and 272 carry a mode code:

  • fast drying: 5
  • silent drying: 22

Changes:

  • New DyadDryingMode enum (fast, silent, unknown = -1), used to decode DP 272.
  • Behavior change: DRY_CUR_MODE now returns "fast" / "silent" instead of an int. The int value only exists in 7.16.0.
  • DP 225 stays an int, because SILENT_DRY_DURATION is the silent drying duration in minutes on older Dyad models. A comment documents the F25 Ultra codes.
  • A comment documents DP 204 on the F25 Ultra: it reports the mode of the current or last drying run (2 fast, 1 silent) and does not change when the mode setting changes.

The drying rate does not support the minutes reading either. Fast drying takes about 5 minutes. Silent drying gains 1 % about every 18 s, so about 30 minutes in total, not 22.

Additions

  • DyadScheduleTask and a converter for SCHEDULE_TASK (10002). The device sends a JSON list of tasks or an empty string.
  • DyadApi.start_drying() sends {200: 1, 204: 1}, the same data points as the app. The device uses the drying mode currently set in DP 225.
  • DyadApi.stop_drying() sends {200: 0}.
  • DyadApi.reset_filter() and DyadApi.reset_brush() write 1 to MESH_RESET (218) and BRUSH_RESET (219).

Verification on a real device

I watched decoded pushes with a script built on this branch, while changing settings in the app or calling the new methods.

Drying mode, set from the app and by writing DP 225:

{"225":22,"243":2} {"272":22}                 # silent
{"225":5,"243":2}  {"272":5}                  # fast

Library commands:

start_drying() -> status 10 (app shows "Drying"), 204 = 2 in fast mode, 204 = 1 in silent mode
stop_drying()  -> status 3 (charging) after about 5 s

Reset, from the app and through the new methods (the same device response in both cases):

{"214":0,"218":1}   # filter
{"215":0,"219":1}   # brush

The app showed 15 h used and 85 % remaining for both parts before the reset, which matches the existing 100 h calculation for MESH_LEFT / BRUSH_LEFT.

Schedule payloads:

"0 15 * * 1,2,3,4,5"          # weekdays
"0 15 * * 0,6"                # weekend (0 is Sunday, 6 is Saturday)
"15 14 * * 2,4,6"             # Tue, Thu, Sat
"11 14 8 11 *"                # one time, today

The app allows one schedule entry, and cron uses the device's local time. Six scheduled runs on the device:

Schedule Vacuuming before the run What ran
one time none nothing
daily none nothing
daily none nothing
one time under 1 s drying only
daily 8 s self-clean, then drying
daily (2 runs) about 1 min self-clean, then drying

dpAction was {"200":1,"204":1} in every case, and the run used the self-clean and drying settings active at start time. The docstring therefore warns consumers not to infer the action from dp_action.

Not verified

  • Why a scheduled run does nothing, dries only, or runs a full cycle. The table points at prior use, but the sample is small.
  • reset_filter() / reset_brush() were called when the counters were already 0. The device response matched the app reset, which did bring both counters from 947 minutes to 0.
  • DOCK_TYPE (243) is still always 2 on this device.

Tests

  • Full pytest under WSL2 (Ubuntu, Python 3.14) with dependency versions from uv.lock: 2104 passed, 52 xfailed
  • pre-commit on the changed files: clean

With the latest yarl (1.25.1) and aioresponses (0.7.9), tests/web_api/test_base.py::test_url_cycling fails with a KeyError on the mocked URL. It fails the same way on main without these commits and passes with the uv.lock versions, so it is unrelated to this PR.

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.
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.
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.

@allenporter allenporter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: Automated code review by Gemini 4 Argon in Antigravity using our code-review skill and AGENTS.md.

Thanks for the thorough real-device verification and follow-up to #1017! Keeping SILENT_DRY_DURATION (DP 225) as int for older Dyad compatibility while correcting DRY_CUR_MODE (DP 272) with a resilient RoborockEnum (unknown = -1), modeling DyadScheduleTask on RoborockBase, and exposing concrete action helpers on DyadApi all look great.

Just a couple of small defensive items inline — to be upfront, we're intentionally being a bit picky/strict here out of caution! We aren't following all of these checks consistently everywhere in older parts of the codebase yet (including elsewhere in a01), so definitely not a knock on matching existing surroundings — we just think it's worth tightening up wire boundary validation and uv run mypy checks on new code going forward.

Comment thread roborock/devices/traits/a01/__init__.py Resolved
Comment thread tests/devices/traits/a01/test_init.py Resolved
Comment thread tests/devices/traits/a01/test_init.py Resolved
Comment on lines +327 to +339
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})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking suggestion (either here or in a follow-up): Since this PR introduces DyadDryingMode alongside start_drying() / stop_drying(), what do you think about also adding a typed set_drying_mode(self, mode: DyadDryingMode) -> None helper on DyadApi?

Right now, callers wanting to change the drying mode have to drop down to set_value(protocol, value: Any) and know the protocol quirk that drying mode is read from DRY_CUR_MODE (DP 272) but written to SILENT_DRY_DURATION (DP 225). A typed method keeps the enum on the public boundary (and can guard against sending DyadDryingMode.unknown = -1) while hiding the DP 225/272 split:

async def set_drying_mode(self, mode: DyadDryingMode) -> None:
    """Set the drying mode (e.g. fast or silent on the F25 Ultra)."""
    if mode == DyadDryingMode.unknown:
        raise ValueError("Cannot set drying mode to unknown")
    await send_decoded_command(
        self._channel,
        {RoborockDyadDataProtocol.SILENT_DRY_DURATION: int(mode)},
    )

@allenporter
allenporter merged commit a2cfdcf into Python-roborock:main Nov 8, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants