From 357e51d91a32b4cc4072693166346f5bc0b4c4b6 Mon Sep 17 00:00:00 2001 From: infy1995 Date: Sat, 7 Nov 2026 21:22:08 +0100 Subject: [PATCH] feat(dyad): decode F25 Ultra data points Add the A01 data points that the F25 Ultra (roborock.wdv.a194) reports, named after the product schema from the cloud. Value mappings were verified on the device by changing each setting in the Roborock app and watching the pushes. New data points: - 238 detergent dosing (off/low/standard/high) - 239 smart self-clean intensity switch - 240 brush light switch - 241 wheel assist power (gentle/moderate/strong) - 242 self-clean water temperature setting (high/steam) - 243 dock type (raw value) - 244/245 suction and brush speed of the sponge mode - 246 1 while not charging (off the dock, self-clean, drying) - 247 drying progress in percent - 248 lying flat - 249/250 remote control drive direction and speed - 270/271 intensity and water temperature of the current self-clean run - 272 drying time in minutes, follows 225 Also decode AUTO_SELF_CLEAN_SET (212) and AUTO_SELF_CLEAN_SET_MODE (223), which were defined without a converter, so their values were dropped. --- roborock/data/dyad/dyad_code_mappings.py | 51 ++++++++++++++++ roborock/devices/traits/a01/__init__.py | 23 +++++++ roborock/roborock_message.py | 16 +++++ tests/devices/traits/a01/test_init.py | 76 ++++++++++++++++++++++++ 4 files changed, 166 insertions(+) diff --git a/roborock/data/dyad/dyad_code_mappings.py b/roborock/data/dyad/dyad_code_mappings.py index ec12fdb2a..0b049047e 100644 --- a/roborock/data/dyad/dyad_code_mappings.py +++ b/roborock/data/dyad/dyad_code_mappings.py @@ -79,6 +79,57 @@ class DyadCleanser(RoborockEnum): max = 3 +class DyadCleanserAmount(RoborockEnum): + """Detergent dosing level (DP 238).""" + + unknown = -1 + off = 0 + low = 1 + standard = 2 + high = 3 + + +class DyadCleanAssistPower(RoborockEnum): + """Wheel assist power (DP 241).""" + + unknown = -1 + gentle = 1 + moderate = 2 + strong = 3 + + +class DyadSelfCleanWaterTemp(RoborockEnum): + """Water temperature used for self-cleaning (DP 242, reported back on DP 271).""" + + unknown = -1 + high = 3 + steam = 4 + + +class DyadSelfCleanIntensity(RoborockEnum): + """Self-cleaning intensity (DP 270). + + Smart mode is enabled through DP 239; standard and deep follow DP 203. + """ + + unknown = -1 + smart = 0 + standard = 1 + deep = 2 + + +class DyadPowerwheelDirection(RoborockEnum): + """Drive direction while the vacuum is remote controlled from the app (DP 249).""" + + unknown = -1 + stop = 0 + forward = 1 + backward = 2 + left = 5 + right = 6 + exit = 9 # sent when remote control ends + + 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 8161c3a50..0df06f743 100644 --- a/roborock/devices/traits/a01/__init__.py +++ b/roborock/devices/traits/a01/__init__.py @@ -34,10 +34,15 @@ from roborock.data import DyadProductInfo, DyadSndState, HomeDataProduct, RoborockCategory from roborock.data.dyad.dyad_code_mappings import ( DyadBrushSpeed, + DyadCleanAssistPower, DyadCleanMode, + DyadCleanserAmount, DyadError, + DyadPowerwheelDirection, + DyadSelfCleanIntensity, DyadSelfCleanLevel, DyadSelfCleanMode, + DyadSelfCleanWaterTemp, DyadSuction, DyadWarmLevel, DyadWaterLevel, @@ -93,12 +98,14 @@ RoborockDyadDataProtocol.WATER_LEVEL: lambda val: DyadWaterLevel(val).name, RoborockDyadDataProtocol.BRUSH_SPEED: lambda val: DyadBrushSpeed(val).name, RoborockDyadDataProtocol.POWER: lambda val: int(val), + RoborockDyadDataProtocol.AUTO_SELF_CLEAN_SET: lambda val: bool(val), # self-clean after docking RoborockDyadDataProtocol.AUTO_DRY: lambda val: bool(val), RoborockDyadDataProtocol.MESH_LEFT: lambda val: int(360000 - val * 60), RoborockDyadDataProtocol.BRUSH_LEFT: lambda val: int(360000 - val * 60), RoborockDyadDataProtocol.ERROR: lambda val: DyadError(val).name, RoborockDyadDataProtocol.VOLUME_SET: lambda val: int(val), 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_MODE: lambda val: bool(val), @@ -112,6 +119,22 @@ int(v) for v in val.split(",") ], # minutes of cleaning in past few days. RoborockDyadDataProtocol.TOTAL_RUN_TIME: lambda val: int(val), + RoborockDyadDataProtocol.SET_CLEANSER_AMOUNT: lambda val: DyadCleanserAmount(val).name, + RoborockDyadDataProtocol.AUTO_SMART_SELF_CLEAN_SET: lambda val: bool(val), + RoborockDyadDataProtocol.LIGHT_SET: lambda val: bool(val), + RoborockDyadDataProtocol.CLEAN_ASSIST_POWER_SET: lambda val: DyadCleanAssistPower(val).name, + RoborockDyadDataProtocol.SELF_CLEAN_WATER_TEMP_LEVEL_SET: lambda val: DyadSelfCleanWaterTemp(val).name, + RoborockDyadDataProtocol.DOCK_TYPE: lambda val: int(val), + RoborockDyadDataProtocol.WATER_MODE_SUCTION: lambda val: DyadSuction(val).name, + RoborockDyadDataProtocol.WATER_MODE_BRUSH_SPEED: lambda val: DyadBrushSpeed(val).name, + RoborockDyadDataProtocol.CHARGE_ERROR_STATUS: lambda val: bool(val), + RoborockDyadDataProtocol.DRYING_RATE: lambda val: int(val), # percent + RoborockDyadDataProtocol.FLAT_STATUS: lambda val: bool(val), + RoborockDyadDataProtocol.POWERWHEEL_DIRECTION: lambda val: DyadPowerwheelDirection(val).name, + 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.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 b044b8ac7..50bd3c7c3 100644 --- a/roborock/roborock_message.py +++ b/roborock/roborock_message.py @@ -113,6 +113,22 @@ class RoborockDyadDataProtocol(RoborockEnum): FEATURE_INFO = 235 RECOVER_SETTINGS = 236 DRY_COUNTDOWN = 237 + SET_CLEANSER_AMOUNT = 238 # detergent dosing level + AUTO_SMART_SELF_CLEAN_SET = 239 # smart self-clean intensity enabled + LIGHT_SET = 240 # brush head light + CLEAN_ASSIST_POWER_SET = 241 # wheel assist power + SELF_CLEAN_WATER_TEMP_LEVEL_SET = 242 # self-clean water temperature setting + DOCK_TYPE = 243 + WATER_MODE_SUCTION = 244 # suction in sponge (water pickup) mode + WATER_MODE_BRUSH_SPEED = 245 # brush speed in sponge (water pickup) mode + CHARGE_ERROR_STATUS = 246 # F25 Ultra: 1 while not charging (off the dock, self-clean, drying) + DRYING_RATE = 247 # percent + FLAT_STATUS = 248 # 1 while the vacuum is lying flat + POWERWHEEL_DIRECTION = 249 # remote control drive direction + 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 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 4da98f5b3..5fcc3ee32 100644 --- a/tests/devices/traits/a01/test_init.py +++ b/tests/devices/traits/a01/test_init.py @@ -157,6 +157,82 @@ async def test_dyad_values_track_pushes(dyad_api: DyadApi, fake_channel: FakeCha } +async def test_dyad_f25_ultra_status_snapshot(dyad_api: DyadApi, fake_channel: FakeChannel): + """Data points pushed by an F25 Ultra (roborock.wdv.a194) are decoded.""" + await dyad_api.start() + + fake_channel.notify_subscribers( + build_a01_message({238: 0, 239: 1, 240: 1, 241: 2, 242: 3, 243: 2, 244: 3, 245: 2, 246: 0, 247: 100, 248: 0}) + ) + fake_channel.notify_subscribers(build_a01_message({270: 0, 271: 3, 272: 5})) + + assert dyad_api.values == { + RoborockDyadDataProtocol.SET_CLEANSER_AMOUNT: "off", + RoborockDyadDataProtocol.AUTO_SMART_SELF_CLEAN_SET: True, + RoborockDyadDataProtocol.LIGHT_SET: True, + RoborockDyadDataProtocol.CLEAN_ASSIST_POWER_SET: "moderate", + RoborockDyadDataProtocol.SELF_CLEAN_WATER_TEMP_LEVEL_SET: "high", + RoborockDyadDataProtocol.DOCK_TYPE: 2, + RoborockDyadDataProtocol.WATER_MODE_SUCTION: "l3", + RoborockDyadDataProtocol.WATER_MODE_BRUSH_SPEED: "l2", + RoborockDyadDataProtocol.CHARGE_ERROR_STATUS: False, + RoborockDyadDataProtocol.DRYING_RATE: 100, + RoborockDyadDataProtocol.FLAT_STATUS: False, + RoborockDyadDataProtocol.SELF_CLEAN_CUR_MODE: "smart", + RoborockDyadDataProtocol.SELF_CLEAN_CUR_TEMP: "high", + RoborockDyadDataProtocol.DRY_CUR_MODE: 5, + } + + +@pytest.mark.parametrize( + ("protocol", "raw", "expected"), + [ + (RoborockDyadDataProtocol.SET_CLEANSER_AMOUNT, 0, "off"), + (RoborockDyadDataProtocol.SET_CLEANSER_AMOUNT, 1, "low"), + (RoborockDyadDataProtocol.SET_CLEANSER_AMOUNT, 2, "standard"), + (RoborockDyadDataProtocol.SET_CLEANSER_AMOUNT, 3, "high"), + (RoborockDyadDataProtocol.SET_CLEANSER_AMOUNT, 42, "unknown"), + (RoborockDyadDataProtocol.CLEAN_ASSIST_POWER_SET, 1, "gentle"), + (RoborockDyadDataProtocol.CLEAN_ASSIST_POWER_SET, 2, "moderate"), + (RoborockDyadDataProtocol.CLEAN_ASSIST_POWER_SET, 3, "strong"), + (RoborockDyadDataProtocol.SELF_CLEAN_WATER_TEMP_LEVEL_SET, 3, "high"), + (RoborockDyadDataProtocol.SELF_CLEAN_WATER_TEMP_LEVEL_SET, 4, "steam"), + (RoborockDyadDataProtocol.SELF_CLEAN_WATER_TEMP_LEVEL_SET, 1, "unknown"), + (RoborockDyadDataProtocol.SELF_CLEAN_CUR_TEMP, 4, "steam"), + (RoborockDyadDataProtocol.SELF_CLEAN_CUR_MODE, 0, "smart"), + (RoborockDyadDataProtocol.SELF_CLEAN_CUR_MODE, 1, "standard"), + (RoborockDyadDataProtocol.SELF_CLEAN_CUR_MODE, 2, "deep"), + (RoborockDyadDataProtocol.WATER_MODE_SUCTION, 4, "l4"), + (RoborockDyadDataProtocol.LIGHT_SET, 0, False), + (RoborockDyadDataProtocol.AUTO_SMART_SELF_CLEAN_SET, 0, False), + (RoborockDyadDataProtocol.CHARGE_ERROR_STATUS, 1, True), + (RoborockDyadDataProtocol.AUTO_SELF_CLEAN_SET, 1, True), + (RoborockDyadDataProtocol.AUTO_SELF_CLEAN_SET, 0, False), + (RoborockDyadDataProtocol.AUTO_SELF_CLEAN_SET_MODE, 2, "deep"), + (RoborockDyadDataProtocol.POWERWHEEL_DIRECTION, 0, "stop"), + (RoborockDyadDataProtocol.POWERWHEEL_DIRECTION, 1, "forward"), + (RoborockDyadDataProtocol.POWERWHEEL_DIRECTION, 2, "backward"), + (RoborockDyadDataProtocol.POWERWHEEL_DIRECTION, 5, "left"), + (RoborockDyadDataProtocol.POWERWHEEL_DIRECTION, 6, "right"), + (RoborockDyadDataProtocol.POWERWHEEL_DIRECTION, 9, "exit"), + (RoborockDyadDataProtocol.POWERWHEEL_DIRECTION, 3, "unknown"), + (RoborockDyadDataProtocol.POWERWHEEL_SPEED, 15, 15), + (RoborockDyadDataProtocol.DRY_CUR_MODE, 22, 22), + ], +) +async def test_dyad_f25_ultra_value_conversion( + protocol: RoborockDyadDataProtocol, + raw: int, + expected: Any, + dyad_api: DyadApi, + fake_channel: FakeChannel, +): + """Values observed on an F25 Ultra while changing settings in the app.""" + await dyad_api.start() + fake_channel.notify_subscribers(build_a01_message({int(protocol): raw})) + assert dyad_api.values == {protocol: expected} + + async def test_dyad_update_listener(dyad_api: DyadApi, fake_channel: FakeChannel): """Update listeners are notified when a value changes, not on identical pushes.""" updates: list[dict[RoborockDyadDataProtocol, Any]] = []