Repository navigation
feat(dyad): add F25 Ultra drying mode, schedule and reset support - #1019
Conversation
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
left a comment
There was a problem hiding this comment.
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.
| 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}) |
There was a problem hiding this comment.
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)},
)
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:522Changes:
DyadDryingModeenum (fast,silent,unknown = -1), used to decode DP 272.DRY_CUR_MODEnow returns"fast"/"silent"instead of an int. The int value only exists in 7.16.0.SILENT_DRY_DURATIONis the silent drying duration in minutes on older Dyad models. A comment documents the F25 Ultra codes.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
DyadScheduleTaskand a converter forSCHEDULE_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()andDyadApi.reset_brush()write 1 toMESH_RESET(218) andBRUSH_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:
Library commands:
Reset, from the app and through the new methods (the same device response in both cases):
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:
The app allows one schedule entry, and
cronuses the device's local time. Six scheduled runs on the device:dpActionwas{"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 fromdp_action.Not verified
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
pytestunder WSL2 (Ubuntu, Python 3.14) with dependency versions fromuv.lock: 2104 passed, 52 xfailedpre-commiton the changed files: cleanWith the latest
yarl(1.25.1) andaioresponses(0.7.9),tests/web_api/test_base.py::test_url_cyclingfails with aKeyErroron the mocked URL. It fails the same way onmainwithout these commits and passes with theuv.lockversions, so it is unrelated to this PR.