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
8 changes: 8 additions & 0 deletions roborock/data/dyad/dyad_code_mappings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions roborock/data/dyad/dyad_containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
37 changes: 34 additions & 3 deletions roborock/devices/traits/a01/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)]
Comment thread
allenporter marked this conversation as resolved.
Resolved


DYAD_PROTOCOL_ENTRIES: dict[RoborockDyadDataProtocol, Callable] = {
RoborockDyadDataProtocol.STATUS: lambda val: RoborockDyadStateCode(val).name,
RoborockDyadDataProtocol.SELF_CLEAN_MODE: lambda val: DyadSelfCleanMode(val).name,
Expand All @@ -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
Expand All @@ -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),
}
Expand Down Expand Up @@ -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})
Comment on lines +327 to +339

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)},
    )


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.

Expand Down
6 changes: 3 additions & 3 deletions roborock/roborock_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
61 changes: 59 additions & 2 deletions tests/devices/traits/a01/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
}


Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 == {}
Comment thread
allenporter marked this conversation as resolved.
Resolved


@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
Comment thread
allenporter marked this conversation as resolved.
Resolved


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")
Expand Down
Loading