diff --git a/roborock/data/dyad/dyad_code_mappings.py b/roborock/data/dyad/dyad_code_mappings.py index afd72149..c8c18e70 100644 --- a/roborock/data/dyad/dyad_code_mappings.py +++ b/roborock/data/dyad/dyad_code_mappings.py @@ -9,7 +9,7 @@ class RoborockDyadStateCode(RoborockEnum): washing = 1 ready = 2 charging = 3 - mop_washing = 4 + mop_washing = 4 # self-clean started self_clean_cleaning = 5 self_clean_deep_cleaning = 6 self_clean_rinsing = 7 @@ -18,7 +18,8 @@ class RoborockDyadStateCode(RoborockEnum): ventilating = 10 # drying reserving = 12 mop_washing_paused = 13 - dusting_mode = 14 + dusting_mode = 14 # a component is removed, as the app calls this state + combination = 15 class DyadSelfCleanMode(RoborockEnum): @@ -106,6 +107,8 @@ class DyadSelfCleanWaterTemp(RoborockEnum): """Water temperature used for self-cleaning (DP 242, reported back on DP 271).""" unknown = -1 + low = 1 + mid = 2 high = 3 steam = 4 @@ -129,13 +132,43 @@ class DyadPowerwheelDirection(RoborockEnum): stop = 0 forward = 1 backward = 2 - left = 5 - right = 6 + left = 3 + right = 4 + forward_left = 5 + forward_right = 6 + backward_left = 7 + backward_right = 8 exit = 9 # sent when remote control ends +class DyadFlatStatus(RoborockEnum): + """Position of the vacuum, which decides whether it can be remote controlled (DP 248).""" + + unknown = -1 + standing = 0 + flat = 1 + error = 2 + + +class DyadFeatureBits(RoborockEnum): + """Bit positions in DP 235 (FEATURE_INFO).""" + + unknown = -1 + three_plus_ten_dry_plan = 0 + percentage_dry_timer = 1 + remote_control = 2 + upright_lock = 3 + spray_water_mode = 4 + show_self_clean_progress = 5 + show_current_mode = 6 + mechanical_arm = 7 + trigger_mode = 8 + self_clean_reminder = 9 + battery_threshold = 10 + + class DyadDryingMode(RoborockEnum): - """Drying mode of the F25 Ultra, reported on DP 272 and set through DP 225.""" + """Drying mode, reported on DP 272 and set through DP 225 on models that have one.""" unknown = -1 fast = 5 @@ -143,6 +176,7 @@ class DyadDryingMode(RoborockEnum): class DyadError(RoborockEnum): + unknown = -1 none = 0 dirty_tank_full = 20000 # Dirty tank full. Empty it water_level_sensor_stuck = 20001 # Water level sensor is stuck. Clean it. @@ -160,6 +194,7 @@ class DyadError(RoborockEnum): ) battery_temperature_protection_2 = (20008, "battery_temperature_protection") power_adapter_error = 20009 # Check if the power adapter is working properly. + error_20010 = 20010 # Listed by the app, its description is not known. dirty_charging_contacts = 10007 # Disconnection between the device and dock. Wipe charging contacts. low_battery = 20017 # Low battery level. Charge before starting self-cleaning. battery_under_10 = 20018 # Charge until the battery level exceeds 10% before manually starting self-cleaning. diff --git a/roborock/devices/traits/a01/__init__.py b/roborock/devices/traits/a01/__init__.py index 7682bc7f..85aea801 100644 --- a/roborock/devices/traits/a01/__init__.py +++ b/roborock/devices/traits/a01/__init__.py @@ -39,6 +39,8 @@ DyadCleanserAmount, DyadDryingMode, DyadError, + DyadFeatureBits, + DyadFlatStatus, DyadPowerwheelDirection, DyadSelfCleanIntensity, DyadSelfCleanLevel, @@ -64,6 +66,7 @@ ) from roborock.devices.rpc.a01_channel import send_decoded_command from roborock.devices.traits import Trait +from roborock.devices.traits.a01 import dyad_feature from roborock.devices.traits.a01.device_feature import ( build_feature_dp_list, build_force_load_dp_list, @@ -71,7 +74,7 @@ ) from roborock.devices.traits.common import TraitUpdateListener from roborock.devices.transport.mqtt_channel import MqttChannel -from roborock.exceptions import RoborockException +from roborock.exceptions import RoborockException, RoborockUnsupportedFeature from roborock.protocols.a01_protocol import decode_rpc_response from roborock.roborock_message import ( RoborockDyadDataProtocol, @@ -96,6 +99,16 @@ def _decode_dyad_schedule(value: str) -> list[DyadScheduleTask]: return [DyadScheduleTask.from_dict(task) for task in json.loads(value)] +# The app pairs each suction level (eco, standard, turbo) with a brush speed. +_PAIRED_SUCTION_BRUSH_SPEED = { + DyadSuction.l2: DyadBrushSpeed.l1, + DyadSuction.l3: DyadBrushSpeed.l2, + DyadSuction.l4: DyadBrushSpeed.l2, +} +_WATER_LEVELS = (DyadWaterLevel.l2, DyadWaterLevel.l3, DyadWaterLevel.l4) +_DRYING_MODES = (DyadDryingMode.fast, DyadDryingMode.silent) +_SELF_CLEAN_INTENSITIES = (DyadSelfCleanIntensity.smart, DyadSelfCleanIntensity.standard, DyadSelfCleanIntensity.deep) + DYAD_PROTOCOL_ENTRIES: dict[RoborockDyadDataProtocol, Callable] = { RoborockDyadDataProtocol.STATUS: lambda val: RoborockDyadStateCode(val).name, RoborockDyadDataProtocol.SELF_CLEAN_MODE: lambda val: DyadSelfCleanMode(val).name, @@ -115,7 +128,7 @@ def _decode_dyad_schedule(value: str) -> list[DyadScheduleTask]: 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), # minutes, or DyadDryingMode on F25 + RoborockDyadDataProtocol.SILENT_DRY_DURATION: lambda val: int(val), # minutes, or a DyadDryingMode code RoborockDyadDataProtocol.SILENT_MODE: lambda val: bool(val), RoborockDyadDataProtocol.SILENT_MODE_START_TIME: lambda val: time( hour=int(val / 60), minute=val % 60 @@ -137,9 +150,16 @@ def _decode_dyad_schedule(value: str) -> list[DyadScheduleTask]: 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.FLAT_STATUS: lambda val: DyadFlatStatus(val).name, RoborockDyadDataProtocol.POWERWHEEL_DIRECTION: lambda val: DyadPowerwheelDirection(val).name, RoborockDyadDataProtocol.POWERWHEEL_SPEED: lambda val: int(val), + RoborockDyadDataProtocol.FEATURE_INFO: lambda val: int(val), + RoborockDyadDataProtocol.SELF_CLEAN_PROGRESS: lambda val: int(val), # percent + RoborockDyadDataProtocol.SPRAY_MODE: lambda val: int(val), + RoborockDyadDataProtocol.MECHANICAL_ARM: lambda val: bool(val), + RoborockDyadDataProtocol.TRIGGER_MODE: lambda val: int(val), + RoborockDyadDataProtocol.SELF_CLEAN_REMINDER: lambda val: int(val), + RoborockDyadDataProtocol.BATTERY_THRESHOLD: 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: DyadDryingMode(val).name, @@ -298,6 +318,27 @@ class DyadApi(A01Api[RoborockDyadDataProtocol]): name = "dyad" + def __init__( + self, channel: MqttChannel, model: str | None = None, initial_status: dict[int, Any] | None = None + ) -> None: + """Initialize the Dyad API.""" + self._model = model + super().__init__(channel, initial_status) + + def _require_support(self, is_supported: Callable[[str | None], bool]) -> None: + """Raise if the device model does not support a command.""" + if not is_supported(self._model): + raise RoborockUnsupportedFeature(f"Command is not supported on model {self._model}") + + def supports(self, feature: DyadFeatureBits) -> bool: + """Return whether the device reported a feature bit on `FEATURE_INFO`. + + The result is False until the device has reported `FEATURE_INFO`. + """ + if feature == DyadFeatureBits.unknown: + return False + return bool(self._values.get(RoborockDyadDataProtocol.FEATURE_INFO, 0) & (1 << feature.value)) + def _decode_datapoints(self, datapoints: dict[int, Any]) -> dict[RoborockDyadDataProtocol, Any]: """Convert raw datapoints to typed values, skipping unknown codes.""" values: dict[RoborockDyadDataProtocol, Any] = {} @@ -320,24 +361,151 @@ async def query_values(self, protocols: list[RoborockDyadDataProtocol]) -> dict[ return values async def set_value(self, protocol: RoborockDyadDataProtocol, value: Any) -> dict[RoborockDyadDataProtocol, Any]: - """Set a value for a specific protocol on the device.""" + """Set a value for a specific protocol on the device. + + The value is sent as is and is not checked against the model. Prefer the + dedicated methods, which accept only what the app offers for the model. + """ params = {protocol: value} return await send_decoded_command(self._channel, params) + async def set_clean_mode(self, mode: DyadCleanMode) -> None: + """Set the clean mode, limited to the modes the app offers for the model.""" + if mode not in dyad_feature.clean_modes(self._model): + raise RoborockUnsupportedFeature(f"Clean mode {mode!r} is not supported on model {self._model}") + await send_decoded_command(self._channel, {RoborockDyadDataProtocol.CLEAN_MODE: int(mode)}) + + async def set_water_level(self, level: DyadWaterLevel) -> None: + """Set the auto mode water level. + + Supports `l2`, `l3` and `l4` on models where the app offers these levels. + """ + self._require_support(dyad_feature.supports_clean_intensity) + if level not in _WATER_LEVELS: + raise ValueError(f"Unsupported water level: {level!r}") + await send_decoded_command(self._channel, {RoborockDyadDataProtocol.WATER_LEVEL: int(level)}) + 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. + The app sends the same command for fast and silent drying. """ await send_decoded_command( self._channel, {RoborockDyadDataProtocol.START: 1, RoborockDyadDataProtocol.WARM_LEVEL: 1}, ) - async def stop_drying(self) -> None: - """Stop a running drying cycle.""" + async def stop(self) -> None: + """Stop a running drying or self-clean cycle. + + The device has one stop command for both cycles. + """ await send_decoded_command(self._channel, {RoborockDyadDataProtocol.START: 0}) + async def set_drying_mode(self, mode: DyadDryingMode) -> None: + """Set the drying mode on models that have one, such as the F25 series. + + Other models raise `RoborockUnsupportedFeature`: there the same data point is + the silent drying duration in minutes. The device reports the selected mode on + `DRY_CUR_MODE`. + """ + self._require_support(dyad_feature.supports_drying_mode) + if mode not in _DRYING_MODES: + raise ValueError(f"Unsupported drying mode: {mode!r}") + await send_decoded_command(self._channel, {RoborockDyadDataProtocol.SILENT_DRY_DURATION: int(mode)}) + + async def set_self_clean_intensity(self, intensity: DyadSelfCleanIntensity) -> None: + """Set the self-clean intensity. + + The setting spans several data points, and smart differs from standard only in + `AUTO_SMART_SELF_CLEAN_SET`. Models without smart self-clean raise + `RoborockUnsupportedFeature` for `smart`. `SELF_CLEAN_CUR_MODE` reports the + current or last run, not this setting. + """ + self._require_support(dyad_feature.is_known_model) + if intensity not in _SELF_CLEAN_INTENSITIES: + raise ValueError(f"Unsupported self-clean intensity: {intensity!r}") + has_smart = dyad_feature.supports_smart_self_clean(self._model) + if intensity == DyadSelfCleanIntensity.smart and not has_smart: + raise RoborockUnsupportedFeature(f"Smart self-clean is not supported on model {self._model}") + level = DyadSelfCleanLevel.deep if intensity == DyadSelfCleanIntensity.deep else DyadSelfCleanLevel.normal + params = { + RoborockDyadDataProtocol.SELF_CLEAN_LEVEL: int(level), + RoborockDyadDataProtocol.AUTO_SELF_CLEAN_SET_MODE: int(level), + } + if has_smart: + params[RoborockDyadDataProtocol.AUTO_SMART_SELF_CLEAN_SET] = int(intensity == DyadSelfCleanIntensity.smart) + await send_decoded_command(self._channel, params) + + async def set_suction(self, suction: DyadSuction) -> None: + """Set the auto mode suction on models that pair it with a brush speed. + + Supports `l2` (eco), `l3` (standard) and `l4` (turbo). The device does not + change the brush speed by itself, so it is sent along with the suction. + """ + await self._set_paired_suction( + RoborockDyadDataProtocol.SUCTION, + RoborockDyadDataProtocol.BRUSH_SPEED, + suction, + ) + + async def set_sponge_suction(self, suction: DyadSuction) -> None: + """Set the sponge mode suction on models that pair it with a brush speed. + + Supports `l2` (eco), `l3` (standard) and `l4` (turbo). The device does not + change the brush speed by itself, so it is sent along with the suction. + """ + await self._set_paired_suction( + RoborockDyadDataProtocol.WATER_MODE_SUCTION, + RoborockDyadDataProtocol.WATER_MODE_BRUSH_SPEED, + suction, + ) + + async def _set_paired_suction( + self, + suction_protocol: RoborockDyadDataProtocol, + brush_protocol: RoborockDyadDataProtocol, + suction: DyadSuction, + ) -> None: + """Send a suction level with the brush speed the app pairs with it.""" + self._require_support(dyad_feature.supports_clean_intensity) + if (brush_speed := _PAIRED_SUCTION_BRUSH_SPEED.get(suction)) is None: + raise ValueError(f"Unsupported suction level: {suction!r}") + await send_decoded_command( + self._channel, + {suction_protocol: int(suction), brush_protocol: int(brush_speed)}, + ) + + async def start_self_clean(self) -> None: + """Start self-clean with the settings currently set on the device. + + The app includes the current `SELF_CLEAN_LEVEL` in the start command, so it + is read from the device first. After changing the intensity, wait until the + device reports the new value before calling this. + """ + response = await send_decoded_command( + self._channel, + {RoborockDyadDataProtocol.ID_QUERY: [RoborockDyadDataProtocol.SELF_CLEAN_LEVEL]}, + value_encoder=json.dumps, + ) + await send_decoded_command( + self._channel, + { + RoborockDyadDataProtocol.START: 1, + RoborockDyadDataProtocol.SELF_CLEAN_LEVEL: response[RoborockDyadDataProtocol.SELF_CLEAN_LEVEL], + }, + ) + + async def pause_self_clean(self) -> None: + """Pause a running self-clean cycle.""" + self._require_support(dyad_feature.supports_self_clean_pause) + await send_decoded_command(self._channel, {RoborockDyadDataProtocol.START: 2}) + + async def resume_self_clean(self) -> None: + """Resume a paused self-clean cycle.""" + self._require_support(dyad_feature.supports_self_clean_pause) + await send_decoded_command(self._channel, {RoborockDyadDataProtocol.START: 1}) + async def reset_filter(self) -> None: """Reset the filter usage counter.""" await send_decoded_command(self._channel, {RoborockDyadDataProtocol.MESH_RESET: 1}) @@ -478,7 +646,7 @@ def create(product: HomeDataProduct, mqtt_channel: MqttChannel, device_status: d initial_status = _parse_device_status(device_status) match product.category: case RoborockCategory.WET_DRY_VAC: - return DyadApi(mqtt_channel, initial_status=initial_status) + return DyadApi(mqtt_channel, model=product.model, initial_status=initial_status) case RoborockCategory.WASHING_MACHINE: return ZeoApi(mqtt_channel, model=product.model, initial_status=initial_status) case _: diff --git a/roborock/devices/traits/a01/dyad_feature.py b/roborock/devices/traits/a01/dyad_feature.py new file mode 100644 index 00000000..0bd146fd --- /dev/null +++ b/roborock/devices/traits/a01/dyad_feature.py @@ -0,0 +1,139 @@ +"""Dyad model capabilities. + +The Roborock app decides what a Dyad supports from its model series and variant. +These sets follow the model table and capability checks in the app plugin. Only the +F25 Ultra (`a194`) has been checked against a real device. + +Model IDs are the last part of the model name, for example `a194` in `roborock.wdv.a194`. +""" + +from __future__ import annotations + +from roborock.data.dyad.dyad_code_mappings import DyadCleanMode + +_HALBERD = frozenset({"a55", "a56", "a61", "a107"}) # Dyad Pro, Dyad Air +_COMBO = frozenset({"a82", "a83", "a99", "a108"}) # Dyad Pro Combo +_SABER_BASE = frozenset({"a80", "a81"}) +_SABER_PLUS = frozenset({"a137", "a138"}) +_DAGGER = frozenset({"a109", "a110", "a111", "a112", "a113", "a132", "a145"}) # Flexi, Flexi Pro +_MARVIS = frozenset({"a365", "a366", "a367", "a368", "a369"}) +_MORPH_WITH_INTENSITY = frozenset( + { + "a150", + "a151", # F25 ACE + "a173", + "a174", # F25 ACE Combo + "a259", + "a260", # F25 ACE Pro + "a319", + "a320", # F25 Pro Turbo + "a341", + "a342", # F25 Pro Turbo Combo + } +) +_MORPH = _MORPH_WITH_INTENSITY | frozenset( + { + "a152", + "a163", + "a165", + "a171", + "a175", + "a177", + "a200", + "a238", # F25 XT + "a251", + "a252", + "a253", + "a280", + "a283", + "a309", + "a310", + "a313", + "a314", + "a315", + "a316", + "a317", + "a318", + "a323", + "a343", + "a344", + } +) +_YAOGUANG = frozenset( + { + "a193", + "a194", # F25 Ultra + "a202", + "a256", + "a257", + "a263", + "a264", + "a285", + "a286", + "a289", + "a299", + "a300", # F25 Ultra Steam Gen 2 + "a301", + "a321", + "a322", + "a356", + "a357", + "a362", + "a363", + "a364", + } +) + +_ALL_MODELS = _HALBERD | _COMBO | _SABER_BASE | _SABER_PLUS | _DAGGER | _MARVIS | _MORPH | _YAOGUANG +_SMART_SELF_CLEAN = _DAGGER | _SABER_PLUS | _MORPH | _YAOGUANG | _MARVIS +_DRYING_MODE = _MORPH | _YAOGUANG +_CLEAN_INTENSITY = _MORPH_WITH_INTENSITY | _YAOGUANG +_SELF_CLEAN_PAUSE = _ALL_MODELS - _DAGGER - _SABER_PLUS + +_CLEAN_MODES_WITHOUT_SPONGE = frozenset({DyadCleanMode.auto, DyadCleanMode.max, DyadCleanMode.power_saving}) +_CLEAN_MODES_WITH_SPONGE = _CLEAN_MODES_WITHOUT_SPONGE | {DyadCleanMode.dehydration} +_CLEAN_MODES_HEATED = frozenset( + {DyadCleanMode.auto, DyadCleanMode.steam, DyadCleanMode.hot_water, DyadCleanMode.dehydration} +) + + +def _model_id(model: str | None) -> str | None: + """Return the model ID from a full model name.""" + return model.rsplit(".", 1)[-1] if model else None + + +def is_known_model(model: str | None) -> bool: + """Return whether the model is in the app's Dyad model table.""" + return _model_id(model) in _ALL_MODELS + + +def supports_drying_mode(model: str | None) -> bool: + """Return whether the silent drying data point holds a drying mode instead of a duration.""" + return _model_id(model) in _DRYING_MODE + + +def supports_smart_self_clean(model: str | None) -> bool: + """Return whether the model has the smart self-clean intensity.""" + return _model_id(model) in _SMART_SELF_CLEAN + + +def supports_clean_intensity(model: str | None) -> bool: + """Return whether the app offers suction levels paired with a brush speed, and water levels.""" + return _model_id(model) in _CLEAN_INTENSITY + + +def clean_modes(model: str | None) -> frozenset[DyadCleanMode]: + """Return the clean modes the app offers for the model, empty for an unknown model.""" + model_id = _model_id(model) + if model_id not in _ALL_MODELS: + return frozenset() + if model_id in _YAOGUANG: + return _CLEAN_MODES_HEATED + if model_id in _DAGGER | _SABER_PLUS: + return _CLEAN_MODES_WITHOUT_SPONGE + return _CLEAN_MODES_WITH_SPONGE + + +def supports_self_clean_pause(model: str | None) -> bool: + """Return whether self-clean can be paused.""" + return _model_id(model) in _SELF_CLEAN_PAUSE diff --git a/roborock/roborock_message.py b/roborock/roborock_message.py index e3620d06..497a5448 100644 --- a/roborock/roborock_message.py +++ b/roborock/roborock_message.py @@ -104,13 +104,13 @@ class RoborockDyadDataProtocol(RoborockEnum): STAND_LOCK_AUTO_RUN = 222 AUTO_SELF_CLEAN_SET_MODE = 223 AUTO_DRY_MODE = 224 - SILENT_DRY_DURATION = 225 # minutes on older models; F25 Ultra uses DyadDryingMode codes + SILENT_DRY_DURATION = 225 # minutes on older models; DyadDryingMode codes on models with drying modes SILENT_MODE = 226 SILENT_MODE_START_TIME = 227 SILENT_MODE_END_TIME = 228 RECENT_RUN_TIME = 229 TOTAL_RUN_TIME = 230 - FEATURE_INFO = 235 + FEATURE_INFO = 235 # decoded by DyadFeatureBits RECOVER_SETTINGS = 236 DRY_COUNTDOWN = 237 SET_CLEANSER_AMOUNT = 238 # detergent dosing level @@ -118,17 +118,23 @@ class RoborockDyadDataProtocol(RoborockEnum): 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 + DOCK_TYPE = 243 # app values: 0 unknown, 1 normal, 2 turbo; meaning unconfirmed on the F25 Ultra 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 + FLAT_STATUS = 248 # decoded by DyadFlatStatus POWERWHEEL_DIRECTION = 249 # remote control drive direction POWERWHEEL_SPEED = 250 # remote control drive speed + SELF_CLEAN_PROGRESS = 251 # percent + SPRAY_MODE = 256 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 # DyadDryingMode on the F25 Ultra, follows SILENT_DRY_DURATION + DRY_CUR_MODE = 272 # DyadDryingMode, follows SILENT_DRY_DURATION + MECHANICAL_ARM = 273 + TRIGGER_MODE = 274 + SELF_CLEAN_REMINDER = 275 + BATTERY_THRESHOLD = 276 ID_QUERY = 10000 F_C = 10001 SCHEDULE_TASK = 10002 @@ -137,6 +143,7 @@ class RoborockDyadDataProtocol(RoborockEnum): PRODUCT_INFO = 10005 PRIVACY_INFO = 10006 OTA_NFO = 10007 + HISTORY = 10008 RPC_REQUEST = 10101 RPC_RESPONSE = 10102 diff --git a/tests/conformance/test_enum_conformance.py b/tests/conformance/test_enum_conformance.py index 37e446ef..013eb8f5 100644 --- a/tests/conformance/test_enum_conformance.py +++ b/tests/conformance/test_enum_conformance.py @@ -25,7 +25,6 @@ KNOWN_ENUMS_MISSING_UNKNOWN = { "roborock.data.dyad.dyad_code_mappings.DyadBrushSpeed", "roborock.data.dyad.dyad_code_mappings.DyadCleanser", - "roborock.data.dyad.dyad_code_mappings.DyadError", "roborock.data.dyad.dyad_code_mappings.DyadMode", "roborock.data.dyad.dyad_code_mappings.DyadSelfCleanLevel", "roborock.data.dyad.dyad_code_mappings.DyadSelfCleanMode", diff --git a/tests/devices/test_device_manager.py b/tests/devices/test_device_manager.py index 734c2065..a2627cb3 100644 --- a/tests/devices/test_device_manager.py +++ b/tests/devices/test_device_manager.py @@ -291,15 +291,15 @@ async def test_start_connect_failure( fake_device.mqtt_channel.clear_error() fake_device.local_channel.clear_error() - # Wait for the device to attempt to connect again and succeed + # The ready callback runs once the device has finished starting, which is after + # the channel reports that it is connected. attempts = 0 - while not devices[0].is_connected: + while not ready_devices: await asyncio.sleep(0.01) attempts += 1 - assert attempts < 10, "Device did not connect after multiple attempts" + assert attempts < 100, "Device did not become ready after multiple attempts" assert devices[0].is_connected - assert ready_devices assert len(ready_devices) == 1 await device_manager.close() diff --git a/tests/devices/traits/a01/test_init.py b/tests/devices/traits/a01/test_init.py index aea824e6..f2d037c5 100644 --- a/tests/devices/traits/a01/test_init.py +++ b/tests/devices/traits/a01/test_init.py @@ -1,5 +1,6 @@ import datetime import json +from dataclasses import replace from typing import Any import pytest @@ -8,8 +9,17 @@ from freezegun import freeze_time from roborock.data import DyadScheduleTask +from roborock.data.dyad.dyad_code_mappings import ( + DyadCleanMode, + DyadDryingMode, + DyadFeatureBits, + DyadSelfCleanIntensity, + DyadSuction, + DyadWaterLevel, +) from roborock.devices.traits.a01 import DyadApi, ZeoApi, create from roborock.devices.traits.a01.device_feature import build_force_load_dp_list +from roborock.exceptions import RoborockUnsupportedFeature from roborock.roborock_message import RoborockDyadDataProtocol, RoborockMessageProtocol, RoborockZeoProtocol from roborock.testing.a01_simulator import DEFAULT_DYAD_PRODUCT from tests.fixtures.channel_fixtures import FakeChannel @@ -23,7 +33,7 @@ def fake_channel_fixture() -> FakeChannel: @pytest.fixture(name="dyad_api") def dyad_api_fixture(fake_channel: FakeChannel) -> DyadApi: - return DyadApi(fake_channel) # type: ignore[arg-type] + return DyadApi(fake_channel, model="roborock.wdv.a194") # type: ignore[arg-type] @pytest.fixture(name="zeo_api") @@ -178,7 +188,7 @@ async def test_dyad_f25_ultra_status_snapshot(dyad_api: DyadApi, fake_channel: F RoborockDyadDataProtocol.WATER_MODE_BRUSH_SPEED: "l2", RoborockDyadDataProtocol.CHARGE_ERROR_STATUS: False, RoborockDyadDataProtocol.DRYING_RATE: 100, - RoborockDyadDataProtocol.FLAT_STATUS: False, + RoborockDyadDataProtocol.FLAT_STATUS: "standing", RoborockDyadDataProtocol.SELF_CLEAN_CUR_MODE: "smart", RoborockDyadDataProtocol.SELF_CLEAN_CUR_TEMP: "high", RoborockDyadDataProtocol.DRY_CUR_MODE: "fast", @@ -198,7 +208,9 @@ async def test_dyad_f25_ultra_status_snapshot(dyad_api: DyadApi, fake_channel: F (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_WATER_TEMP_LEVEL_SET, 1, "low"), + (RoborockDyadDataProtocol.SELF_CLEAN_WATER_TEMP_LEVEL_SET, 2, "mid"), + (RoborockDyadDataProtocol.SELF_CLEAN_WATER_TEMP_LEVEL_SET, 9, "unknown"), (RoborockDyadDataProtocol.SELF_CLEAN_CUR_TEMP, 4, "steam"), (RoborockDyadDataProtocol.SELF_CLEAN_CUR_MODE, 0, "smart"), (RoborockDyadDataProtocol.SELF_CLEAN_CUR_MODE, 1, "standard"), @@ -213,10 +225,23 @@ async def test_dyad_f25_ultra_status_snapshot(dyad_api: DyadApi, fake_channel: F (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, 3, "left"), + (RoborockDyadDataProtocol.POWERWHEEL_DIRECTION, 4, "right"), + (RoborockDyadDataProtocol.POWERWHEEL_DIRECTION, 5, "forward_left"), + (RoborockDyadDataProtocol.POWERWHEEL_DIRECTION, 6, "forward_right"), + (RoborockDyadDataProtocol.POWERWHEEL_DIRECTION, 7, "backward_left"), + (RoborockDyadDataProtocol.POWERWHEEL_DIRECTION, 8, "backward_right"), (RoborockDyadDataProtocol.POWERWHEEL_DIRECTION, 9, "exit"), - (RoborockDyadDataProtocol.POWERWHEEL_DIRECTION, 3, "unknown"), + (RoborockDyadDataProtocol.POWERWHEEL_DIRECTION, 10, "unknown"), + (RoborockDyadDataProtocol.FLAT_STATUS, 1, "flat"), + (RoborockDyadDataProtocol.FLAT_STATUS, 2, "error"), + (RoborockDyadDataProtocol.FLAT_STATUS, 3, "unknown"), + (RoborockDyadDataProtocol.STATUS, 15, "combination"), + (RoborockDyadDataProtocol.ERROR, 20010, "error_20010"), + (RoborockDyadDataProtocol.ERROR, 12345, "unknown"), + (RoborockDyadDataProtocol.FEATURE_INFO, 102, 102), + (RoborockDyadDataProtocol.SELF_CLEAN_PROGRESS, 50, 50), + (RoborockDyadDataProtocol.MECHANICAL_ARM, 1, True), (RoborockDyadDataProtocol.POWERWHEEL_SPEED, 15, 15), (RoborockDyadDataProtocol.DRY_CUR_MODE, 5, "fast"), (RoborockDyadDataProtocol.DRY_CUR_MODE, 22, "silent"), @@ -519,7 +544,9 @@ async def test_dyad_schedule_task_invalid(dyad_api: DyadApi, fake_channel: FakeC ("method", "expected_dps"), [ ("start_drying", {"200": 1, "204": 1}), - ("stop_drying", {"200": 0}), + ("stop", {"200": 0}), + ("pause_self_clean", {"200": 2}), + ("resume_self_clean", {"200": 1}), ("reset_filter", {"218": 1}), ("reset_brush", {"219": 1}), ], @@ -538,6 +565,312 @@ async def test_dyad_action_commands( assert payload_data["dps"] == expected_dps +@pytest.mark.parametrize( + ("mode", "expected_dps"), + [ + (DyadDryingMode.fast, {"225": 5}), + (DyadDryingMode.silent, {"225": 22}), + ], +) +async def test_dyad_set_drying_mode( + mode: DyadDryingMode, + expected_dps: dict[str, int], + dyad_api: DyadApi, + fake_channel: FakeChannel, +): + """The drying mode is written to the data point used by the app.""" + await dyad_api.set_drying_mode(mode) + + 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_dyad_set_drying_mode_unknown(dyad_api: DyadApi, fake_channel: FakeChannel): + """The unknown placeholder is rejected and nothing is sent to the device.""" + with pytest.raises(ValueError): + await dyad_api.set_drying_mode(DyadDryingMode.unknown) + + assert fake_channel.published_messages == [] + + +@pytest.mark.parametrize( + ("intensity", "expected_dps"), + [ + (DyadSelfCleanIntensity.smart, {"203": 1, "223": 1, "239": 1}), + (DyadSelfCleanIntensity.standard, {"203": 1, "223": 1, "239": 0}), + (DyadSelfCleanIntensity.deep, {"203": 2, "223": 2, "239": 0}), + ], +) +async def test_dyad_set_self_clean_intensity( + intensity: DyadSelfCleanIntensity, + expected_dps: dict[str, int], + dyad_api: DyadApi, + fake_channel: FakeChannel, +): + """The intensity is written to all the data points the device reports it on.""" + await dyad_api.set_self_clean_intensity(intensity) + + 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_dyad_set_self_clean_intensity_unknown(dyad_api: DyadApi, fake_channel: FakeChannel): + """The unknown placeholder is rejected and nothing is sent to the device.""" + with pytest.raises(ValueError): + await dyad_api.set_self_clean_intensity(DyadSelfCleanIntensity.unknown) + + assert fake_channel.published_messages == [] + + +@pytest.mark.parametrize( + ("method", "suction", "expected_dps"), + [ + ("set_suction", DyadSuction.l2, {"206": 2, "208": 1}), + ("set_suction", DyadSuction.l3, {"206": 3, "208": 2}), + ("set_suction", DyadSuction.l4, {"206": 4, "208": 2}), + ("set_sponge_suction", DyadSuction.l2, {"244": 2, "245": 1}), + ("set_sponge_suction", DyadSuction.l3, {"244": 3, "245": 2}), + ("set_sponge_suction", DyadSuction.l4, {"244": 4, "245": 2}), + ], +) +async def test_dyad_set_suction( + method: str, + suction: DyadSuction, + expected_dps: dict[str, int], + dyad_api: DyadApi, + fake_channel: FakeChannel, +): + """Suction is sent together with the brush speed the app pairs with it.""" + await getattr(dyad_api, method)(suction) + + 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 + + +@pytest.mark.parametrize("method", ["set_suction", "set_sponge_suction"]) +@pytest.mark.parametrize("suction", [DyadSuction.l1, DyadSuction.l5, DyadSuction.l6]) +async def test_dyad_set_suction_unsupported( + method: str, + suction: DyadSuction, + dyad_api: DyadApi, + fake_channel: FakeChannel, +): + """Levels not seen on the F25 Ultra are rejected and nothing is sent.""" + with pytest.raises(ValueError): + await getattr(dyad_api, method)(suction) + + assert fake_channel.published_messages == [] + + +@pytest.mark.parametrize("level", [1, 2]) +async def test_dyad_start_self_clean(level: int, dyad_api: DyadApi, fake_channel: FakeChannel): + """Self-clean starts with the level currently set on the device.""" + fake_channel.response_queue.append(build_a01_message({203: level})) + + await dyad_api.start_self_clean() + + assert len(fake_channel.published_messages) == 2 + payload_data = json.loads(unpad(fake_channel.published_messages[1].payload, AES.block_size)) + assert payload_data["dps"] == {"200": 1, "203": level} + + +async def test_dyad_start_self_clean_on_any_model(fake_channel: FakeChannel): + """The app starts self-clean the same way on every model.""" + api = create(DEFAULT_DYAD_PRODUCT, fake_channel) # type: ignore[arg-type] + assert isinstance(api, DyadApi) + fake_channel.response_queue.append(build_a01_message({203: 1})) + + await api.start_self_clean() + + assert len(fake_channel.published_messages) == 2 + + +@pytest.mark.parametrize( + ("method", "args", "model"), + [ + ("set_drying_mode", (DyadDryingMode.fast,), "roborock.wetdryvac.a151"), + ("set_drying_mode", (DyadDryingMode.silent,), "roborock.wdv.a300"), + ("set_drying_mode", (DyadDryingMode.fast,), "roborock.wetdryvac.a238"), + ("set_self_clean_intensity", (DyadSelfCleanIntensity.smart,), "roborock.wetdryvac.a132"), + ("set_suction", (DyadSuction.l3,), "roborock.wetdryvac.a174"), + ("set_sponge_suction", (DyadSuction.l3,), "roborock.wdv.a260"), + ("pause_self_clean", (), "roborock.wetdryvac.a56"), + ], +) +async def test_dyad_commands_accepted_by_model( + method: str, + args: tuple[Any, ...], + model: str, + fake_channel: FakeChannel, +): + """Commands are sent to the models the app enables them for.""" + api = DyadApi(fake_channel, model=model) # type: ignore[arg-type] + + await getattr(api, method)(*args) + + assert len(fake_channel.published_messages) == 1 + + +@pytest.mark.parametrize( + ("method", "args", "model"), + [ + ("set_drying_mode", (DyadDryingMode.fast,), "roborock.wetdryvac.a56"), + ("set_drying_mode", (DyadDryingMode.fast,), "roborock.wetdryvac.a132"), + ("set_drying_mode", (DyadDryingMode.fast,), "roborock.wetdryvac.a01"), + ("set_drying_mode", (DyadDryingMode.fast,), None), + ("set_self_clean_intensity", (DyadSelfCleanIntensity.smart,), "roborock.wetdryvac.a56"), + ("set_self_clean_intensity", (DyadSelfCleanIntensity.deep,), "roborock.wetdryvac.a01"), + ("set_suction", (DyadSuction.l3,), "roborock.wetdryvac.a238"), + ("set_suction", (DyadSuction.l3,), "roborock.wetdryvac.a56"), + ("set_sponge_suction", (DyadSuction.l3,), "roborock.wetdryvac.a238"), + ("pause_self_clean", (), "roborock.wetdryvac.a132"), + ("pause_self_clean", (), "roborock.wetdryvac.a138"), + ("resume_self_clean", (), "roborock.wetdryvac.a132"), + ("pause_self_clean", (), None), + ], +) +async def test_dyad_commands_rejected_by_model( + method: str, + args: tuple[Any, ...], + model: str | None, + fake_channel: FakeChannel, +): + """Commands fail on models the app does not enable them for, and nothing is sent.""" + api = DyadApi(fake_channel, model=model) # type: ignore[arg-type] + + with pytest.raises(RoborockUnsupportedFeature): + await getattr(api, method)(*args) + + assert fake_channel.published_messages == [] + + +@pytest.mark.parametrize( + ("method", "value"), + [ + ("set_drying_mode", 7), + ("set_drying_mode", "fast"), + ("set_drying_mode", None), + ("set_self_clean_intensity", 7), + ("set_self_clean_intensity", "deep"), + ("set_suction", 9), + ("set_sponge_suction", 9), + ("set_water_level", DyadWaterLevel.l1), + ("set_water_level", 9), + ], +) +async def test_dyad_invalid_values_rejected(method: str, value: Any, dyad_api: DyadApi, fake_channel: FakeChannel): + """Values the app does not offer are rejected and nothing is sent.""" + with pytest.raises(ValueError): + await getattr(dyad_api, method)(value) + + assert fake_channel.published_messages == [] + + +@pytest.mark.parametrize( + ("model", "mode"), + [ + ("roborock.wdv.a194", DyadCleanMode.auto), + ("roborock.wdv.a194", DyadCleanMode.steam), + ("roborock.wdv.a194", DyadCleanMode.hot_water), + ("roborock.wdv.a194", DyadCleanMode.dehydration), + ("roborock.wetdryvac.a151", DyadCleanMode.max), + ("roborock.wetdryvac.a151", DyadCleanMode.power_saving), + ("roborock.wetdryvac.a151", DyadCleanMode.dehydration), + ("roborock.wetdryvac.a132", DyadCleanMode.power_saving), + ("roborock.wetdryvac.a56", DyadCleanMode.dehydration), + ], +) +async def test_dyad_set_clean_mode(model: str, mode: DyadCleanMode, fake_channel: FakeChannel): + """Clean modes the app offers for the model are written to the clean mode data point.""" + api = DyadApi(fake_channel, model=model) # type: ignore[arg-type] + + await api.set_clean_mode(mode) + + payload_data = json.loads(unpad(fake_channel.published_messages[0].payload, AES.block_size)) + assert payload_data["dps"] == {"205": int(mode)} + + +@pytest.mark.parametrize( + ("model", "mode"), + [ + ("roborock.wdv.a194", DyadCleanMode.max), + ("roborock.wdv.a194", DyadCleanMode.power_saving), + ("roborock.wdv.a194", DyadCleanMode.unknown), + ("roborock.wdv.a194", 9), + ("roborock.wetdryvac.a151", DyadCleanMode.steam), + ("roborock.wetdryvac.a132", DyadCleanMode.dehydration), + ("roborock.wetdryvac.a01", DyadCleanMode.auto), + (None, DyadCleanMode.auto), + ], +) +async def test_dyad_set_clean_mode_rejected(model: str | None, mode: Any, fake_channel: FakeChannel): + """Clean modes the app does not offer for the model are rejected.""" + api = DyadApi(fake_channel, model=model) # type: ignore[arg-type] + + with pytest.raises(RoborockUnsupportedFeature): + await api.set_clean_mode(mode) + + assert fake_channel.published_messages == [] + + +@pytest.mark.parametrize("level", [DyadWaterLevel.l2, DyadWaterLevel.l3, DyadWaterLevel.l4]) +async def test_dyad_set_water_level(level: DyadWaterLevel, dyad_api: DyadApi, fake_channel: FakeChannel): + """Water levels offered by the app are written to the water level data point.""" + await dyad_api.set_water_level(level) + + payload_data = json.loads(unpad(fake_channel.published_messages[0].payload, AES.block_size)) + assert payload_data["dps"] == {"207": int(level)} + + +async def test_dyad_set_water_level_rejected_by_model(fake_channel: FakeChannel): + """Models without the app's water levels are rejected.""" + api = DyadApi(fake_channel, model="roborock.wetdryvac.a238") # type: ignore[arg-type] + + with pytest.raises(RoborockUnsupportedFeature): + await api.set_water_level(DyadWaterLevel.l3) + + assert fake_channel.published_messages == [] + + +async def test_dyad_set_self_clean_intensity_without_smart(fake_channel: FakeChannel): + """Models without smart self-clean do not get the smart data point.""" + api = DyadApi(fake_channel, model="roborock.wetdryvac.a56") # type: ignore[arg-type] + + await api.set_self_clean_intensity(DyadSelfCleanIntensity.deep) + + payload_data = json.loads(unpad(fake_channel.published_messages[0].payload, AES.block_size)) + assert payload_data["dps"] == {"203": 2, "223": 2} + + +async def test_dyad_supports_feature_bits(dyad_api: DyadApi, fake_channel: FakeChannel): + """Feature bits are read from the reported FEATURE_INFO value.""" + assert not dyad_api.supports(DyadFeatureBits.show_current_mode) + + await dyad_api.start() + fake_channel.notify_subscribers(build_a01_message({235: 102})) + + assert dyad_api.supports(DyadFeatureBits.percentage_dry_timer) + assert dyad_api.supports(DyadFeatureBits.remote_control) + assert dyad_api.supports(DyadFeatureBits.show_self_clean_progress) + assert dyad_api.supports(DyadFeatureBits.show_current_mode) + assert not dyad_api.supports(DyadFeatureBits.three_plus_ten_dry_plan) + assert not dyad_api.supports(DyadFeatureBits.unknown) + + +async def test_create_passes_dyad_model(fake_channel: FakeChannel): + """The product model from home data enables model specific commands.""" + product = replace(DEFAULT_DYAD_PRODUCT, model="roborock.wdv.a194") + api = create(product, fake_channel) # type: ignore[arg-type] + assert isinstance(api, DyadApi) + + await api.set_drying_mode(DyadDryingMode.fast) + + assert len(fake_channel.published_messages) == 1 + + 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")