diff --git a/roborock/data/zeo/zeo_code_mappings.py b/roborock/data/zeo/zeo_code_mappings.py index 4e5c79d16..d2c5d6495 100644 --- a/roborock/data/zeo/zeo_code_mappings.py +++ b/roborock/data/zeo/zeo_code_mappings.py @@ -1,142 +1,320 @@ +"""Zeo (washing machine) device enums. + +Member-level comments show the manufacturer's official identifiers +extracted from the React Native app plugin bundle. + +For enums that map between protocol-level positions and physical units +(e.g. spin speed, temperature), the comment format is +``# L, `` where ``L`` is the protocol position +and ```` is the user-facing display value +(RPM, degrees Celsius, minutes, etc.). + +Enums commented out at the bottom of this file are App-internal lookup +tables and UI state machines — they are not DP protocol enums and are +never sent to the device. +""" + from ..code_mappings import RoborockEnum +class ZeoFeatureBits(RoborockEnum): + """Bit positions in DP 237 (FEATURE_BITS). + + Extracted from the official Roborock Washer app plugin bundle + (index.ios.bundle, module 726). Each member's integer value + is the exact bit offset the device reports at DP 237. + """ + + smart_hosting = 0 + silent_mode = 1 + new_custom_program = 2 + dry_care = 3 + set_uvc_in_appointment = 4 + detect_door_status = 5 + expand_softener = 6 + set_params_in_working = 7 + thirty_min_soak = 8 + smile_light = 9 + set_uvc_in_pause = 10 + concentrated_detergent = 11 + wool_detergent = 12 + voice_assistant = 13 + adapted_custom_program = 14 + voice_assistant_record = 15 + fluff_clean_notification = 16 + power_button_indicator_light = 17 + dirt_detection = 18 + deep_self_clean = 19 + save_panel_program_params = 20 + steam_care = 21 + wash_dry_linkage = 22 + ion_deodorization = 23 + + class ZeoMode(RoborockEnum): - null = 0 + null = 0 # (not found in app bundle) wash = 1 wash_and_dry = 2 dry = 3 + treatment = 4 class ZeoState(RoborockEnum): standby = 1 - weighing = 2 + weighing = 2 # Checking soaking = 3 washing = 4 rinsing = 5 - spinning = 6 + spinning = 6 # Dewatering drying = 7 cooling = 8 - under_delay_start = 9 - done = 10 - aftercare = 12 - waiting_for_aftercare = 13 + under_delay_start = 9 # Appointment + done = 10 # Complete + updating = 11 + aftercare = 12 # SmartHosting + waiting_for_aftercare = 13 # SmartHostingWaiting + steam_caring = 14 + descaling = 15 + cloth_ready = 16 + waiting_for_drying = 17 + pre_heating = 18 + pre_heat_complete = 19 + in_care = 20 class ZeoProgram(RoborockEnum): - null = 0 - standard = 1 + null = 0 # (not found in app bundle) + standard = 1 # Mixed quick = 2 - sanitize = 3 + sanitize = 3 # Sterilization wool = 4 - air_refresh = 5 - custom = 6 - bedding = 7 + air_refresh = 5 # Air + custom = 6 # CloudProgram + bedding = 7 # HomeTextile down = 8 silk = 9 - rinse_and_spin = 10 - spin = 11 - down_clean = 12 - baby_care = 13 - anti_allergen = 14 - sportswear = 15 + rinse_and_spin = 10 # RinseAndDehydrate + spin = 11 # Dehydrate + down_clean = 12 # SelfClean + baby_care = 13 # Baby + anti_allergen = 14 # MitesRemoval + sportswear = 15 # Sports night = 16 - new_clothes = 17 - shirts = 18 - synthetics = 19 + new_clothes = 17 # New + shirts = 18 # Shirt + synthetics = 19 # ChemicalFiber underwear = 20 - gentle = 21 - intensive = 22 - cotton_linen = 23 + gentle = 21 # Soft + intensive = 22 # Strong + cotton_linen = 23 # CottonOrLinen season = 24 - warming = 25 + warming = 25 # Warm bra = 26 - panties = 27 - boiling_wash = 28 + panties = 27 # Underpants + boiling_wash = 28 # Boiling + soaking = 29 socks = 30 - towels = 31 - anti_mite = 32 - exo_40_60 = 33 - twenty_c = 34 - t_shirts = 35 - stain_removal = 36 + towels = 31 # Towel + anti_mite = 32 # MitesRemoval2 + exo_40_60 = 33 # Eco + twenty_c = 34 # TwentyDegrees + t_shirts = 35 # TShirt + stain_removal = 36 # Dirt + small_items = 37 # SmallThings + mixing = 39 + bath_towel = 40 + jeans = 41 + outdoors = 42 + timed_drying = 43 + outdoor_jackets = 44 + yoga = 45 + quilt_drying = 46 + flax = 47 + suit = 48 + sport_shoes = 49 + rack_drying = 50 + summer_quilt = 51 + wind_breaker = 52 + steam_care = 53 + descaling = 54 + deep_self_clean = 55 + pet_care = 56 + small_things_drying = 57 class ZeoSoak(RoborockEnum): - normal = 0 - low = 1 - medium = 2 - high = 3 - max = 4 + normal = 0 # L0, 0min + low = 1 # L1, 5min + medium = 2 # L2, 10min + high = 3 # L3, 15min + max = 4 # L4, 20min + very_max = 5 # L5, 30min class ZeoTemperature(RoborockEnum): - normal = 1 - low = 2 - medium = 3 - high = 4 - max = 5 - twenty_c = 6 - ninety_c = 7 + normal = 1 # L1, 0 C + low = 2 # L2, 30 C + medium = 3 # L3, 40 C + high = 4 # L4, 60 C + max = 5 # L5, 90 C + twenty_c = 6 # L6, 20 C + ninety_c = 7 # L7, 95 C class ZeoRinse(RoborockEnum): - none = 0 - min = 1 - low = 2 - mid = 3 - high = 4 - max = 5 + none = 0 # L0, None + min = 1 # L1, Min + low = 2 # L2, Low + mid = 3 # L3, Mid + high = 4 # L4, High + max = 5 # L5, Max class ZeoSpin(RoborockEnum): - null = 0 - none = 1 - very_low = 2 - low = 3 - mid = 4 - high = 5 - very_high = 6 - max = 7 + null = 0 # (not found in app bundle) + none = 1 # L1, 0 RPM + very_low = 2 # L2, 400 RPM + low = 3 # L3, 600 RPM + mid = 4 # L4, 800 RPM + high = 5 # L5, 1000 RPM + very_high = 6 # L6, 1200 RPM + max = 7 # L7, 1400 RPM class ZeoDryingMode(RoborockEnum): none = 0 - quick = 1 - iron = 2 - store = 3 + quick = 1 # Quick, Mid + iron = 2 # Iron, Low + store = 3 # Store, High class ZeoDetergentType(RoborockEnum): - empty = 0 - low = 1 - medium = 2 - high = 3 + empty = 0 # T0 + low = 1 # T1 + medium = 2 # T2 + high = 3 # T3 class ZeoSoftenerType(RoborockEnum): - empty = 0 - low = 1 - medium = 2 - high = 3 + empty = 0 # T0 + low = 1 # T1 + medium = 2 # T2 + high = 3 # T3 + + +class ZeoDetergentExpansionType(RoborockEnum): + concentrated_detergent = 1 + detergent = 2 + + +class ZeoSoftenerExpansionType(RoborockEnum): + softener = 1 + softener_expansion = 2 + wool_detergent = 3 + + +class ZeoDirtDetectionStatus(RoborockEnum): + idle = 0 + detecting = 1 + detection_completed = 2 class ZeoError(RoborockEnum): - none = 0 - refill_error = 1 - drain_error = 2 - door_lock_error = 3 - water_level_error = 4 - inverter_error = 5 - heating_error = 6 - temperature_error = 7 - communication_error = 10 - drying_error = 11 - drying_error_e_12 = 12 - drying_error_e_13 = 13 - drying_error_e_14 = 14 - drying_error_e_15 = 15 - drying_error_e_16 = 16 - drying_error_water_flow = 17 # Check for normal water flow - drying_error_restart = 18 # Restart the washer and try again - spin_error = 19 # re-arrange clothes + none = 0 # No error + refill_error = 1 # Refill error (E1). Check if the water tap is turned on. + drain_error = 2 # Drain error (E2). Check the drain hose. + door_lock_error = 3 # Door lock error (E3). Close the door properly. + water_level_error = 4 # Drum water level error (E4). + inverter_error = 5 # DD motor variable-frequency drive error (E5). + heating_error = 6 # Water heater error (E6). + temperature_error = 7 # Drum water temperature error (E7). + communication_error = 10 # Communication error (E10). + drying_error = 11 # Temperature error (E11). + drying_error_e_12 = 12 # Temperature error (E12). + drying_error_e_13 = 13 # Temperature error (E13). + drying_error_e_14 = 14 # Temperature error (E14). + drying_error_e_15 = 15 # Drying air heater error (E15). + drying_error_e_16 = 16 # Fan RPM error (E16). + drying_error_water_flow = 17 # Drying temperature protection (E17). + drying_error_restart = 18 # Fan RPM error (E18). + spin_error = 19 # Balance error (Unb). + + +class ZeoDryingMethod(RoborockEnum): + l1 = 1 # L1, Saving + l2 = 2 # L2, Standard + l3 = 3 # L3, SuperFast + + +class ZeoSteamVolume(RoborockEnum): + none = 0 # L0, None + low = 1 # L1, Min + medium = 2 # L2, Low + high = 3 # L3, Mid + max = 4 # L4, High + + +class ZeoDryAndCare(RoborockEnum): + soft = 1 + normal = 2 + + +class ZeoDryerStartError(RoborockEnum): + dryer_running = 1 # Washer-Dryer Pairing cannot start: Dryer is running. + dryer_error = 2 # Washer-Dryer Pairing cannot start: Dryer has an error. + dryer_done = 3 # Dryer drying is complete, please remove the clothes first. + dryer_waiting_hosting = 4 # Dryer is waiting for smart hosting. + dryer_smart_hosting = 5 # Dryer is in smart hosting mode. + dryer_countdown = 6 # Dryer is in preset countdown. + dryer_network_fail = 7 # Please check the dryer network connection. + + +# The following are App-internal lookup tables extracted from the bundle. +# They are NOT DP protocol enums — the device never receives these values. +# They control how the official app adjusts recommended dosages or UI visibility. +# +# class Cleanser(RoborockEnum): +# detergent = 0 +# additions = 1 +# +# class Detergents(RoborockEnum): +# concentrated = 1 +# regular = 2 +# baby = 3 +# +# class Additions(RoborockEnum): +# softener = 1 +# disinfectant = 2 +# fragrance = 3 +# +# The following are App UI state enums — internal state machines used by the +# official app for page rendering and progress display. +# +# class HomePageStatus(RoborockEnum): +# updating = 0 +# loading = 1 +# load_failed = 2 +# idle = 3 +# working = 4 +# smart_hosting = 5 +# preset = 6 +# descaling = 7 +# cloth_ready = 8 +# wait_to_dry = 9 +# +# class Progress(RoborockEnum): +# soak = 0 +# wash = 1 +# rinse = 2 +# spin = 3 +# dry = 4 +# steam_care = 5 +# +# class ProgressStatus(RoborockEnum): +# done = 0 +# doing = 1 +# will_do = 2 +# +# class SmartHostStatus(RoborockEnum): +# smart_host_waiting = 0 +# smart_hosting = 1 diff --git a/roborock/device_features.py b/roborock/device_features.py index b2a3e34f5..bc3134618 100644 --- a/roborock/device_features.py +++ b/roborock/device_features.py @@ -863,3 +863,44 @@ def is_wash_n_fill_dock(dock_type: RoborockDockTypeCode) -> bool: def is_valid_dock(dock_type: RoborockDockTypeCode) -> bool: """Check if device supports a dock.""" return RoborockDockFeatures.from_dock_type(dock_type).has_dock + + +@dataclass +class ZeoFeatures: + """Device capability flags for Zeo devices, parsed from DP 237 (FEATURE_BITS).""" + + adapted_custom_program: bool = False + concentrated_detergent: bool = False + deep_self_clean: bool = False + detect_door_status: bool = False + dirt_detection: bool = False + dry_care: bool = False + expand_softener: bool = False + fluff_clean_notification: bool = False + ion_deodorization: bool = False + new_custom_program: bool = False + power_button_indicator_light: bool = False + save_panel_program_params: bool = False + set_params_in_working: bool = False + set_uvc_in_appointment: bool = False + set_uvc_in_pause: bool = False + silent_mode: bool = False + smart_hosting: bool = False + smile_light: bool = False + steam_care: bool = False + thirty_min_soak: bool = False + voice_assistant: bool = False + voice_assistant_record: bool = False + wash_dry_linkage: bool = False + wool_detergent: bool = False + + @classmethod + def from_feature_bits(cls, raw: int) -> "ZeoFeatures": + """Return a new instance from a raw feature bits value.""" + from roborock.data.zeo.zeo_code_mappings import ZeoFeatureBits + + kwargs: dict[str, bool] = {} + for f in fields(cls): + bit_pos = getattr(ZeoFeatureBits, f.name) + kwargs[f.name] = bool(raw & (1 << int(bit_pos))) + return cls(**kwargs) diff --git a/roborock/devices/rpc/a01_channel.py b/roborock/devices/rpc/a01_channel.py index 2e2f9ceac..66b8b27d7 100644 --- a/roborock/devices/rpc/a01_channel.py +++ b/roborock/devices/rpc/a01_channel.py @@ -7,6 +7,7 @@ from roborock.devices.transport.mqtt_channel import MqttChannel from roborock.exceptions import RoborockException +from roborock.mqtt.session import MqttQos from roborock.protocols.a01_protocol import ( decode_rpc_response, encode_mqtt_payload, @@ -30,6 +31,7 @@ async def send_decoded_command( mqtt_channel: MqttChannel, params: dict[RoborockDyadDataProtocol, Any], value_encoder: Callable[[Any], Any] | None = None, + qos: MqttQos = MqttQos.AT_MOST_ONCE, ) -> dict[RoborockDyadDataProtocol, Any]: ... @@ -38,6 +40,7 @@ async def send_decoded_command( mqtt_channel: MqttChannel, params: dict[RoborockZeoProtocol, Any], value_encoder: Callable[[Any], Any] | None = None, + qos: MqttQos = MqttQos.AT_MOST_ONCE, ) -> dict[RoborockZeoProtocol, Any]: ... @@ -45,8 +48,16 @@ async def send_decoded_command( mqtt_channel: MqttChannel, params: dict[RoborockDyadDataProtocol, Any] | dict[RoborockZeoProtocol, Any], value_encoder: Callable[[Any], Any] | None = None, + qos: MqttQos = MqttQos.AT_MOST_ONCE, ) -> dict[RoborockDyadDataProtocol, Any] | dict[RoborockZeoProtocol, Any]: - """Send a command on the MQTT channel and get a decoded response.""" + """Send a command on the MQTT channel and get a decoded response. + + Args: + mqtt_channel: The MQTT channel to send the command on. + params: The parameters to send. + value_encoder: A function to encode the values of the dictionary. + qos: The MQTT QoS level. Defaults to AT_MOST_ONCE. + """ _LOGGER.debug("Sending MQTT command: %s", params) roborock_message = encode_mqtt_payload(params, value_encoder) @@ -54,7 +65,7 @@ async def send_decoded_command( # block waiting for a response. Queries are handled below. param_values = {int(k): v for k, v in params.items()} if not (query_values := param_values.get(_ID_QUERY)): - await mqtt_channel.publish(roborock_message) + await mqtt_channel.publish(roborock_message, qos=qos) return {} # Merge any results together than contain the requested data. This diff --git a/roborock/devices/traits/a01/__init__.py b/roborock/devices/traits/a01/__init__.py index 537c84377..19d4b2229 100644 --- a/roborock/devices/traits/a01/__init__.py +++ b/roborock/devices/traits/a01/__init__.py @@ -15,12 +15,15 @@ 2. **set_value(protocol, value)**: Sends a command to the device to change a setting or perform an action. -Note that these APIs fetch data directly from the device upon request and do not -cache state internally. +``DyadApi`` is stateless. ``ZeoApi`` maintains an internal DPS cache +(populated by MQTT push, ``set_value``, and ``query_values``) to bridge +the ``set_value`` → ``start()`` call window without extra MQTT round trips. """ import json +import logging from collections.abc import Callable +from dataclasses import dataclass from datetime import time from typing import Any @@ -37,28 +40,103 @@ RoborockDyadStateCode, ) from roborock.data.zeo.zeo_code_mappings import ( + ZeoDetergentExpansionType, ZeoDetergentType, + ZeoDirtDetectionStatus, + ZeoDryAndCare, + ZeoDryerStartError, + ZeoDryingMethod, ZeoDryingMode, ZeoError, ZeoMode, ZeoProgram, ZeoRinse, + ZeoSoak, + ZeoSoftenerExpansionType, ZeoSoftenerType, ZeoSpin, ZeoState, + ZeoSteamVolume, ZeoTemperature, ) +from roborock.device_features import ZeoFeatures from roborock.devices.rpc.a01_channel import send_decoded_command from roborock.devices.traits import Trait +from roborock.devices.traits.a01.device_features import ZeoFeatureTrait from roborock.devices.transport.mqtt_channel import MqttChannel -from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol +from roborock.exceptions import RoborockException +from roborock.mqtt.session import MqttQos +from roborock.protocols.a01_protocol import decode_rpc_response +from roborock.roborock_message import ( + RoborockDyadDataProtocol, + RoborockMessage, + RoborockMessageProtocol, + RoborockZeoProtocol, +) + +_LOGGER = logging.getLogger(__name__) __init__ = [ "DyadApi", "ZeoApi", + "ZeoFeatureTrait", + "ZeoCustomMode", + "ZeoDryerCustomMode", + "ZeoStartParams", ] +def _try_json(val: Any) -> Any: + """Return ``val`` parsed as JSON when it is a JSON string, else ``val``. + + Several meta DPs (robotInfo / WashHistory / VoiceRecord …) arrive as + JSON-encoded strings from the device. This lets converters return + decoded dicts/lists transparently while keeping raw values for + non‑JSON payloads. + """ + if isinstance(val, str): + try: + return json.loads(val) + except (json.JSONDecodeError, TypeError): + pass + return val + + +def parse_bool(val: Any) -> bool: + """Parse a Zeo/Dyad boolean DP value robustly. + + The official app sends and receives booleans as the strings + ``"True"`` / ``"False"`` (its ``DPBoolean`` enum), but firmware revisions + and sibling protocols also use ``0`` / ``1`` or JSON ``true`` / ``false``. + Accept every representation so decoding never mis-classifies a falsy + string such as ``"False"`` (``bool("False")`` is ``True`` — a bug we avoid). + """ + if isinstance(val, str): + return val.strip().lower() in ("true", "1") + return bool(val) + + +def _decode_expansion_type(val: Any, default: int) -> int: + """Decode an expansion-type DP, returning *default* when the device + reports ``None`` (type not yet configured). Keeps the protocol + entry lambda clean. + """ + return int(val) if val is not None else default + + +def to_dp_bool(val: Any) -> str: + """Normalise a boolean-like value to the wire-format string ``"True"`` or ``"False"``. + + The official app serialises booleans as the strings ``"True"`` / + ``"False"`` (its ``DPBoolean`` enum) on SET commands — not as ``1`` / ``0`` + or JSON ``true`` / ``false``. Used only in the ``set_value`` encoder + path because callers (HA switch entities, external code) may pass + Python ``True`` / ``False`` which ``json.dumps`` would serialise as + JSON ``true`` / ``false``. + """ + return "True" if parse_bool(val) else "False" + + DYAD_PROTOCOL_ENTRIES: dict[RoborockDyadDataProtocol, Callable] = { RoborockDyadDataProtocol.STATUS: lambda val: RoborockDyadStateCode(val).name, RoborockDyadDataProtocol.SELF_CLEAN_MODE: lambda val: DyadSelfCleanMode(val).name, @@ -69,15 +147,15 @@ RoborockDyadDataProtocol.WATER_LEVEL: lambda val: DyadWaterLevel(val).name, RoborockDyadDataProtocol.BRUSH_SPEED: lambda val: DyadBrushSpeed(val).name, RoborockDyadDataProtocol.POWER: lambda val: int(val), - RoborockDyadDataProtocol.AUTO_DRY: lambda val: bool(val), + RoborockDyadDataProtocol.AUTO_DRY: parse_bool, 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_DRY_MODE: lambda val: bool(val), + RoborockDyadDataProtocol.STAND_LOCK_AUTO_RUN: parse_bool, + RoborockDyadDataProtocol.AUTO_DRY_MODE: parse_bool, RoborockDyadDataProtocol.SILENT_DRY_DURATION: lambda val: int(val), # in minutes - RoborockDyadDataProtocol.SILENT_MODE: lambda val: bool(val), + RoborockDyadDataProtocol.SILENT_MODE: parse_bool, RoborockDyadDataProtocol.SILENT_MODE_START_TIME: lambda val: time( hour=int(val / 60), minute=val % 60 ), # in minutes since 00:00 @@ -95,13 +173,34 @@ ZEO_PROTOCOL_ENTRIES: dict[RoborockZeoProtocol, Callable] = { # read-only RoborockZeoProtocol.STATE: lambda val: ZeoState(val).name, - RoborockZeoProtocol.COUNTDOWN: lambda val: int(val), RoborockZeoProtocol.WASHING_LEFT: lambda val: int(val), RoborockZeoProtocol.ERROR: lambda val: ZeoError(val).name, RoborockZeoProtocol.TIMES_AFTER_CLEAN: lambda val: int(val), - RoborockZeoProtocol.DETERGENT_EMPTY: lambda val: bool(val), - RoborockZeoProtocol.SOFTENER_EMPTY: lambda val: bool(val), + RoborockZeoProtocol.DETERGENT_EMPTY: parse_bool, + RoborockZeoProtocol.SOFTENER_EMPTY: parse_bool, + RoborockZeoProtocol.DIRT_DETECTION_STATUS: lambda val: ZeoDirtDetectionStatus(val).name, + RoborockZeoProtocol.TOTAL_TIME: lambda val: int(val), + RoborockZeoProtocol.FEATURE_BITS: lambda val: int(val), + RoborockZeoProtocol.SMART_HOSTING_WAITED_TIME: lambda val: int(val), + RoborockZeoProtocol.IS_NEED_FLUFF_CLEAN: parse_bool, + RoborockZeoProtocol.PANEL_PROGRAM_PARAMS_SET_RESULT: lambda val: int(val), + RoborockZeoProtocol.DEVICE_BOUND: parse_bool, + RoborockZeoProtocol.CLOTH_PUT_IN: parse_bool, + RoborockZeoProtocol.CLOTH_READY_TO_DRY_COUNT_DOWN: lambda val: int(val), + RoborockZeoProtocol.START_DRYER_ERROR: lambda val: ZeoDryerStartError(val).name, + RoborockZeoProtocol.DOORLOCK_STATE: parse_bool, + RoborockZeoProtocol.APP_AUTHORIZATION: parse_bool, + RoborockZeoProtocol.SMART_HOSTING_TIME: lambda val: int(val), + RoborockZeoProtocol.CUSTOM_PROGRAM_CLEANING_TIME: lambda val: int(val), + RoborockZeoProtocol.PANEL_TIMING_PROGRAM_PARAMS: lambda val: int(val), + RoborockZeoProtocol.STEAM_CARE_TIME: lambda val: int(val), + # meta — read-only (JSON on wire, auto-decoded by converter) + RoborockZeoProtocol.PRODUCT_INFO: lambda val: _try_json(val), # robotInfo + RoborockZeoProtocol.WASHING_LOG: lambda val: _try_json(val), # washHistory + RoborockZeoProtocol.VOICE_RECORD_INFO: lambda val: _try_json(val), + RoborockZeoProtocol.VOICE_RECORD: lambda val: _try_json(val), # read-write + RoborockZeoProtocol.COUNTDOWN: lambda val: int(val), RoborockZeoProtocol.MODE: lambda val: ZeoMode(val).name, RoborockZeoProtocol.PROGRAM: lambda val: ZeoProgram(val).name, RoborockZeoProtocol.TEMP: lambda val: ZeoTemperature(val).name, @@ -110,10 +209,61 @@ RoborockZeoProtocol.DRYING_MODE: lambda val: ZeoDryingMode(val).name, RoborockZeoProtocol.DETERGENT_TYPE: lambda val: ZeoDetergentType(val).name, RoborockZeoProtocol.SOFTENER_TYPE: lambda val: ZeoSoftenerType(val).name, - RoborockZeoProtocol.SOUND_SET: lambda val: bool(val), + RoborockZeoProtocol.SOUND_SET: parse_bool, + RoborockZeoProtocol.DIRT_DETECTION_SWITCH: parse_bool, + RoborockZeoProtocol.SOAK: lambda val: ZeoSoak(val).name, + RoborockZeoProtocol.SILENT_MODE_ON: parse_bool, + RoborockZeoProtocol.SILENT_MODE_START_TIME: lambda val: int(val), + RoborockZeoProtocol.SILENT_MODE_END_TIME: lambda val: int(val), + RoborockZeoProtocol.DRY_CARE_MODE: lambda val: ZeoDryAndCare(val).name, + RoborockZeoProtocol.WASH_DRY_LINKED: parse_bool, + RoborockZeoProtocol.DRYING_METHOD: lambda val: ZeoDryingMethod(val).name, + RoborockZeoProtocol.STEAM_VOLUME: lambda val: ZeoSteamVolume(val).name, + RoborockZeoProtocol.ION_DEODORIZATION: parse_bool, + RoborockZeoProtocol.UV_LIGHT: parse_bool, + RoborockZeoProtocol.SMART_HOSTING: parse_bool, + RoborockZeoProtocol.SOFTENER_EXPANSION_TYPE: lambda val: _decode_expansion_type( + val, ZeoSoftenerExpansionType.softener + ), + RoborockZeoProtocol.DETERGENT_EXPANSION_TYPE: lambda val: _decode_expansion_type( + val, ZeoDetergentExpansionType.concentrated_detergent + ), + RoborockZeoProtocol.SMILE_LIGHT_STATUS: parse_bool, + RoborockZeoProtocol.POWER_LIGHT: parse_bool, + RoborockZeoProtocol.PANEL_PROGRAM_PARAMS_SET: lambda val: int(val), + RoborockZeoProtocol.WIFI_LINKAGE_RESET: lambda val: int(val), + RoborockZeoProtocol.SAVE_ADAPTED_CLOUD_PROGRAM: lambda val: int(val), + RoborockZeoProtocol.CHILD_LOCK: parse_bool, + RoborockZeoProtocol.DETERGENT_SET: parse_bool, + RoborockZeoProtocol.SOFTENER_SET: parse_bool, + RoborockZeoProtocol.FLUFF_CLEANED: parse_bool, # bundle: setFluffCleaned(1) + # read-write (int-valued, not boolean) + RoborockZeoProtocol.CUSTOM_PARAM_SAVE: lambda val: int(val), + RoborockZeoProtocol.CUSTOM_PARAM_GET: lambda val: int(val), + RoborockZeoProtocol.DEFAULT_SETTING: lambda val: int(val), + RoborockZeoProtocol.LIGHT_SETTING: parse_bool, + RoborockZeoProtocol.DETERGENT_VOLUME: lambda val: int(val), + RoborockZeoProtocol.SOFTENER_VOLUME: lambda val: int(val), + # meta — read-write (JSON-encoded on wire by official app; raw Python dicts) + RoborockZeoProtocol.SET_SOUND_PACKAGE: lambda val: val, + RoborockZeoProtocol.VOICE_VOLUME: lambda val: val, + RoborockZeoProtocol.VOICE_SWITCH: parse_bool, + RoborockZeoProtocol.VOICE_RECORD_DELETE: lambda val: int(val), } +# Protocols whose converter is ``parse_bool``. The encoder path in +# ``set_value`` uses ``to_dp_bool`` to normalise callers' Python ``True`` / +# ``False`` to the wire-format integer ``1`` / ``0`` (DPBoolean). Derived +# automatically from ``ZEO_PROTOCOL_ENTRIES``. +_ZEO_BOOLEAN_PROTOCOLS: frozenset[RoborockZeoProtocol] = frozenset( + p for p, conv in ZEO_PROTOCOL_ENTRIES.items() if conv is parse_bool +) +_DYAD_BOOLEAN_PROTOCOLS: frozenset[RoborockDyadDataProtocol] = frozenset( + p for p, conv in DYAD_PROTOCOL_ENTRIES.items() if conv is parse_bool +) + + def convert_dyad_value(protocol_value: RoborockDyadDataProtocol, value: Any) -> Any: """Convert a dyad protocol value to its corresponding type.""" if (converter := DYAD_PROTOCOL_ENTRIES.get(protocol_value)) is not None: @@ -151,33 +301,1121 @@ async def query_values(self, protocols: list[RoborockDyadDataProtocol]) -> dict[ return {protocol: convert_dyad_value(protocol, response.get(protocol)) for protocol in protocols} 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. + + Booleans are serialised as "True"/"False" (DPBoolean) on the wire, + matching the official app; all other values pass through. + """ + # Booleans are serialised as "True"/"False" (DPBoolean) on the wire. + encoder = to_dp_bool if protocol in _DYAD_BOOLEAN_PROTOCOLS else None params = {protocol: value} - return await send_decoded_command(self._channel, params) + return await send_decoded_command(self._channel, params, value_encoder=encoder) + + +@dataclass +class ZeoStartParams: + """Parameters that must be bundled with a START command. + + All Zeo devices require ``mode`` and ``program`` to be sent together + with the start signal. The remaining fields are optional and only + included when the device reports a non-None value. + """ + + mode: int + """Wash mode (e.g. wash, wash-and-dry, dry, treatment).""" + + program: int + """Wash program (e.g. standard, quick, wool).""" + + temp: int | None = None + """Water temperature (always ``None`` for dryers).""" + + rinse_times: int | None = None + """Number of rinse cycles (always ``None`` for dryers).""" + + spin_level: int | None = None + """Spin speed in RPM (always ``None`` for dryers).""" + + drying_mode: int | None = None + """Drying mode (e.g. quick, iron, store).""" + + +# ── DP 222 (LoadCloudProgram) bitfield decoder ────────────────────────── +# The official app packs all custom-program parameters into a single +# 32-bit integer at DP 222. This mirrors WasherDpsCache.customMode in +# module 725 of the React Native plugin bundle. + + +@dataclass +class ZeoCustomMode: + """Decoded custom programme parameters from DP 222 (LoadCloudProgram). + + Null / absent fields are represented as ``0`` which matches the + official app's behaviour (the right-shifted-and-masked value is + always non-negative). + """ + + program: int + """Wash program (bits 0-7).""" + + mode: int + """Wash mode (bits 8-9).""" + + temperature: int + """Temperature (bits 10-12).""" + + rinse: int + """Rinse cycle (bits 13-15).""" + + spin: int + """Spin speed (bits 16-18).""" + + dry: int + """Drying mode (bits 19-21).""" + + soak: int + """Soak (bits 22-24).""" + + dry_care_mode: int + """Dry-care mode (bits 25-27).""" + + steam_volume: int + """Steam volume (bits 28-30).""" + + total_time_min: int = 0 + """Total programme time in minutes (from DP 239).""" + + @classmethod + def from_raw(cls, raw: int, total_time_min: int | None = None) -> "ZeoCustomMode": + """Decode a raw 32-bit custom-programme value.""" + return cls( + program=(raw & 0xFF), + mode=(raw >> 8) & 0x3, + temperature=(raw >> 10) & 0x7, + rinse=(raw >> 13) & 0x7, + spin=(raw >> 16) & 0x7, + dry=(raw >> 19) & 0x7, + soak=(raw >> 22) & 0x7, + dry_care_mode=(raw >> 25) & 0x7, + steam_volume=(raw >> 28) & 0x7, + total_time_min=total_time_min or 0, + ) + + +@dataclass +class ZeoDryerCustomMode: + """Decoded custom programme from DP 222 for a standalone dryer. + + Dryers pack a different (shorter) bitfield than washers — only 5 + fields after program/mode. Mirrors ``WasherDpsCache.dryerCustomMode`` + in module 725 of the plugin bundle. + """ + + program: int + """Drying program (bits 0-7).""" + + mode: int + """Drying mode (bits 8-10).""" + + dry: int + """Drying level (bits 11-13).""" + + dry_method: int + """Drying method (bits 14-16).""" + + steam_volume: int + """Steam volume (bits 17-19).""" + + total_time_min: int = 0 + """Total programme time in minutes (from DP 239).""" + + @classmethod + def from_raw(cls, raw: int, total_time_min: int | None = None) -> "ZeoDryerCustomMode": + """Decode a raw 32-bit dryer custom-programme value.""" + return cls( + program=(raw & 0xFF), + # Dryer mode spans bits 8-10 (0x700, 3 bits) because the + # temperature field is absent from the dryer bitfield and + # bit 10 is re-allocated to mode. Washer uses 2 bits + # (0x300, bits 8-9) to make room for temperature at 10-12. + mode=(raw >> 8) & 0x7, + dry=(raw >> 11) & 0x7, + dry_method=(raw >> 14) & 0x7, + steam_volume=(raw >> 17) & 0x7, + total_time_min=total_time_min or 0, + ) class ZeoApi(Trait): - """API for interacting with Zeo devices.""" + """API for interacting with Zeo devices. + + ── Bundle method mapping ── + + ======================== ================================================ + Bundle (module 727) Python ``ZeoApi`` + ======================== ================================================ + ``startWith(...)`` :meth:`start` — bundles START+Mode+Program+params + ``start()`` / ``continue()`` :meth:`resume` — single DP ``{200: 1}`` + ``presetWith(n)`` :meth:`start_with_preset` — startWith + Preset + ``stop`` / ``pause`` / ``set_value(PAUSE/SHUTDOWN/START, …)`` + ``shutdown`` + ``saveCloudProgramWith`` :meth:`save_cloud_program` + ``savePanelProgramWith`` :meth:`save_panel_program` + ``loadCloudProgram`` :meth:`load_cloud_program` + ``setCleanserConfig`` :meth:`set_cleanser_config` — 4-DP bundle + ``forceLoad`` + :meth:`force_load` + :meth:`load_feature_dps` + ``loadFeatureDps`` + ``setSilentMode(…)`` :meth:`set_silent_mode` + ``checkFCCState`` :meth:`check_fcc_state` + ``loadGeneralInfo`` :meth:`load_general_info` + ``uploadLog`` :meth:`upload_log` + ``syncPrivacyToDevice`` :meth:`sync_privacy_to_device` + ``updateSoundPackageInfo`` :meth:`update_sound_package_info` + 28+ single-DP setters :meth:`set_value` + ======================== ================================================ + + ── Two categories of DPs ── + + **startWith params** — MODE, PROGRAM, TEMP, RINSE, SPIN, DRYING_MODE, + DRYING_METHOD, STEAM_VOLUME, SOAK, DRY_CARE_MODE, TOTAL_TIME, and the + feature‑gated DPs (WASH_DRY_LINKED, ION_DEODORIZATION). These are + *staged* by ``set_value`` and committed only when ``start()`` bundles + them with START. + + **Independent** — CHILD_LOCK, SOUND, UV_LIGHT, SILENT_MODE, DETERGENT_SET, + PAUSE, SHUTDOWN, VOICE_SWITCH, … — these take effect immediately via a + single MQTT ``publishDps`` without needing ``start()``. + """ name = "zeo" - def __init__(self, channel: MqttChannel) -> None: - """Initialize the Zeo API.""" + def __init__( + self, + channel: MqttChannel, + *, + is_dryer: bool = False, + is_hyperion_halia_hera: bool = False, + is_m1_muse_metis: bool = False, + ) -> None: + """Initialize the Zeo API. + + Args: + channel: The MQTT channel for device communication. + is_dryer: ``True`` for standalone dryers (Apollo series). + is_hyperion_halia_hera: ``True`` for Hyperion/Halia/Hera. + These series use a different detergent‑type auto‑enable + protocol (type=0 sends *only* AutoDetergent=False, + skipping DetergentType entirely). + is_m1_muse_metis: ``True`` for M1/Muse/Metis series. + These series skip softener‑related DPs in force_load + (they do not have a softener compartment). + """ self._channel = channel + self._feature_trait = ZeoFeatureTrait(channel) + self.is_dryer = is_dryer + self.is_hyperion_halia_hera = is_hyperion_halia_hera + self.is_m1_muse_metis = is_m1_muse_metis + # The DPS cache is populated from three sources. The final + # writer wins; there is no priority — ``start()`` reads + # whatever is in the cache at call time. + # + # 1. MQTT push — unsolicited device status updates (canonical) + # 2. set_value — local write after MQTT publish (PERMANENT) + # 3. query_values — coordinator polling fill + # + # Point 2 can NOT be removed even after MQTT push is adopted: + # ``start()`` packs cached values into the START command, and + # a user flow like ``set_value(MODE, 2); start()`` relies on + # point 2 to bridge the gap before the device echoes the change + # back via MQTT push. Point 3 CAN be removed once the HA + # integration subscribes to push. + self._dps_cache: dict[int, Any] = {} + self._dps_unsub: Callable[[], None] | None = None + + def close(self) -> None: + """Unsubscribe from MQTT push updates and release resources. + + Must be called when the API instance is no longer needed to + prevent stale callbacks from referencing a deallocated object. + """ + if self._dps_unsub is not None: + self._dps_unsub() + self._dps_unsub = None + + async def _ensure_subscribed(self) -> None: + """Subscribe to MQTT DPS push updates (idempotent).""" + if self._dps_unsub is not None: + return + self._dps_unsub = await self._channel.subscribe(self._on_dps_message) + + def _on_dps_message(self, message: RoborockMessage) -> None: + """Primary cache injection: MQTT push (protocol 102) from the device. + + Protocol 102 (RPC_RESPONSE) messages carry JSON ``{"dps": {...}}`` + payloads — both query responses and unsolicited status updates. + This is the canonical data source; ``query_values`` and ``set_value`` + serve as compatibility fallbacks. + """ + if message.protocol != RoborockMessageProtocol.RPC_RESPONSE: + return + try: + decoded = decode_rpc_response(message) + self._dps_cache.update(decoded) + except RoborockException: + _LOGGER.debug("Failed to decode push message, skipping: %s", message, exc_info=True) async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[RoborockZeoProtocol, Any]: - """Query the device for the values of the given protocols.""" + """Query the device for the values of the given protocols. + + Also feeds the DPS cache so that ``start()`` can use cached + values when the HA integration relies on coordinator polling + instead of MQTT push. This cache write (point 3 of 3) CAN be + removed once HA switches to push-driven state updates. + """ response = await send_decoded_command( self._channel, {RoborockZeoProtocol.ID_QUERY: protocols}, value_encoder=json.dumps, ) + for protocol, raw_val in response.items(): + if raw_val is not None: + self._dps_cache[int(protocol)] = raw_val return {protocol: convert_zeo_value(protocol, response.get(protocol)) for protocol in protocols} + # ── START parameter DPs ──────────────────────────────────────── + # These are the wash/dry programme parameters that the official app + # bundles together with START in its ``startWith`` / ``presetWith`` + # methods. Changing any of these via ``set_value`` does NOT + # immediately launch the device — you must call ``start()`` + # (or ``start_with_preset()``) afterwards to commit the settings + # and begin the cycle. + # + # Washer: Mode, Program, Temp, Rinse, Spin, DryingMode + # Dryer: Mode, Program, DryingMode, DryingMethod, SteamVolume + _START_PARAM_DPS_WASHER: tuple[RoborockZeoProtocol, ...] = ( + RoborockZeoProtocol.MODE, + RoborockZeoProtocol.PROGRAM, + RoborockZeoProtocol.TEMP, + RoborockZeoProtocol.RINSE_TIMES, + RoborockZeoProtocol.SPIN_LEVEL, + RoborockZeoProtocol.DRYING_MODE, + ) + _START_PARAM_DPS_DRYER: tuple[RoborockZeoProtocol, ...] = ( + RoborockZeoProtocol.MODE, + RoborockZeoProtocol.PROGRAM, + RoborockZeoProtocol.DRYING_MODE, + RoborockZeoProtocol.DRYING_METHOD, + RoborockZeoProtocol.STEAM_VOLUME, + ) + + @property + def _start_params(self) -> tuple[RoborockZeoProtocol, ...]: + """Return the START-parameter DP set for this device type.""" + return self._START_PARAM_DPS_DRYER if self.is_dryer else self._START_PARAM_DPS_WASHER + + # DPs that are only included in START when the device reports + # the corresponding FeatureBit in DP 237. + _FEATURE_GATED_DPS: tuple[tuple[RoborockZeoProtocol, str], ...] = ( + (RoborockZeoProtocol.WASH_DRY_LINKED, "wash_dry_linkage"), + (RoborockZeoProtocol.ION_DEODORIZATION, "ion_deodorization"), + ) + + async def _build_feature_gated_dps(self, features: ZeoFeatures | None, dps: dict[RoborockZeoProtocol, Any]) -> None: + """Add feature-gated DPs to *dps* in-place, batch-querying uncached ones. + + Only includes DPs whose corresponding FeatureBit is set in *features*. + Uncached DPs are collected and fetched in a single MQTT round trip. + """ + if features is None: + return + to_query: list[RoborockZeoProtocol] = [] + for dp, attr in self._FEATURE_GATED_DPS: + if getattr(features, attr, False) and int(dp) not in self._dps_cache: + to_query.append(dp) + if to_query: + current = await send_decoded_command( + self._channel, + {RoborockZeoProtocol.ID_QUERY: to_query}, + value_encoder=json.dumps, + ) + for fetched_dp, raw_val in current.items(): + if raw_val is not None: + self._dps_cache[fetched_dp] = raw_val + for dp, attr in self._FEATURE_GATED_DPS: + if not getattr(features, attr, False): + continue + dp_id = int(dp) + if dp_id in self._dps_cache: + dps[dp] = self._dps_cache[dp_id] # cache stores raw ints from MQTT push + + async def _get_start_params(self) -> ZeoStartParams: + """Return start parameters, using cache when available. + + The DPS cache is populated by MQTT push updates and ``query_values()`` + calls. If the required core DPs are present in the cache, they are + returned immediately with zero latency. Otherwise a single MQTT + query fills the cache as a fallback. + """ + await self._ensure_subscribed() + start_dps = self._start_params + required_ids = [int(dp) for dp in start_dps] + if all(dp_id in self._dps_cache for dp_id in required_ids): + _LOGGER.debug("Using cached start parameters") + else: + _LOGGER.debug("Cache miss, querying start parameters") + current = await send_decoded_command( + self._channel, + {RoborockZeoProtocol.ID_QUERY: list(start_dps)}, + value_encoder=json.dumps, + ) + for dp, raw_val in current.items(): + if raw_val is not None: + self._dps_cache[int(dp)] = raw_val + cache = self._dps_cache + for dp in start_dps: + if int(dp) not in cache: + raise RoborockException(f"Device did not return required DP {dp.name} ({int(dp)})") + return ZeoStartParams( + mode=cache[int(RoborockZeoProtocol.MODE)], + program=cache[int(RoborockZeoProtocol.PROGRAM)], + temp=cache.get(int(RoborockZeoProtocol.TEMP)), + rinse_times=cache.get(int(RoborockZeoProtocol.RINSE_TIMES)), + spin_level=cache.get(int(RoborockZeoProtocol.SPIN_LEVEL)), + drying_mode=cache.get(int(RoborockZeoProtocol.DRYING_MODE)), + ) + + async def start(self) -> dict[RoborockZeoProtocol, Any]: + """Start the device, bundling the current programme parameters. + + Corresponds to the official app's ``startWith`` — queries the + device for Mode, Program, Temperature, Rinse, Spin, DryingMode + (or their dryer equivalents), discovers capabilities via DP 237, + and sends everything together with the START command. + + Call ``set_value(MODE, …)`` / ``set_value(TEMP, …)`` etc. first, + then call ``start()`` to commit and launch. + + For pausing / resuming, use :meth:`resume` which sends only + ``START = "True"`` without re‑bundling parameters.""" + _LOGGER.debug("Start command: discovering features and building payload") + await self._feature_trait.refresh() + features = self._feature_trait.features + p = await self._get_start_params() + dps: dict[RoborockZeoProtocol, Any] = { + RoborockZeoProtocol.START: "True", + RoborockZeoProtocol.MODE: p.mode, + RoborockZeoProtocol.PROGRAM: p.program, + } + if self.is_dryer: + total = self._dps_cache.get(int(RoborockZeoProtocol.TOTAL_TIME)) + if total and int(total) > 0: + dps[RoborockZeoProtocol.TOTAL_TIME] = int(total) + for dp in ( + RoborockZeoProtocol.DRYING_MODE, + RoborockZeoProtocol.DRYING_METHOD, + RoborockZeoProtocol.STEAM_VOLUME, + ): + val = self._dps_cache.get(int(dp)) + if val is not None: + dps[dp] = val + else: + for dp, val in ( + (RoborockZeoProtocol.TEMP, p.temp), + (RoborockZeoProtocol.RINSE_TIMES, p.rinse_times), + (RoborockZeoProtocol.SPIN_LEVEL, p.spin_level), + (RoborockZeoProtocol.DRYING_MODE, p.drying_mode), + ): + if val is not None: + dps[dp] = val + await self._build_feature_gated_dps(features, dps) + return await send_decoded_command(self._channel, dps, value_encoder=lambda x: x, qos=MqttQos.AT_LEAST_ONCE) + + async def resume(self) -> dict[RoborockZeoProtocol, Any]: + """Resume a paused cycle — sends only ``START = "True"``. + + Matches the official app's ``continue()`` / ``start()`` + (simple single-DP version). Unlike :meth:`start`, this does + **not** query or re‑bundle Mode, Program or other parameters — + the device simply resumes whatever programme was already in + progress. ``continue`` is a Python keyword so the method is + named ``resume``. + """ + dps: dict[RoborockZeoProtocol, Any] = {RoborockZeoProtocol.START: "True"} + return await send_decoded_command(self._channel, dps, value_encoder=lambda x: x, qos=MqttQos.AT_LEAST_ONCE) + + # ── Custom programme (DP 222 bitfield) ────────────────────────── + + async def get_custom_mode(self) -> ZeoCustomMode | ZeoDryerCustomMode: + """Decode the current custom programme from DP 222 + DP 239. + + Returns :class:`ZeoDryerCustomMode` for dryers, else + :class:`ZeoCustomMode`. + """ + result = await send_decoded_command( + self._channel, + { + RoborockZeoProtocol.ID_QUERY: [ + RoborockZeoProtocol.CUSTOM_PARAM_GET, + RoborockZeoProtocol.CUSTOM_PROGRAM_CLEANING_TIME, + ], + }, + value_encoder=json.dumps, + ) + raw = result.get(RoborockZeoProtocol.CUSTOM_PARAM_GET, 0) + total = result.get(RoborockZeoProtocol.CUSTOM_PROGRAM_CLEANING_TIME) + raw_int = int(raw) if raw else 0 + total_int = int(total) if total else None + if self.is_dryer: + return ZeoDryerCustomMode.from_raw(raw_int, total_int) + return ZeoCustomMode.from_raw(raw_int, total_int) + + # ── Silent mode (bundled 3-DP set) ────────────────────────────── + + async def set_silent_mode( + self, + on: bool, + start_hour: int, + start_min: int, + end_hour: int, + end_min: int, + ) -> dict[RoborockZeoProtocol, Any]: + """Enable / disable silent mode with the configured time window. + + Mirroring the official app, this bundles DP 240 (on/off), + 241 (start minute-of-day) and 242 (end minute-of-day) in a + single ``publishDps`` call. + """ + start_mins = start_hour * 60 + start_min + end_mins = end_hour * 60 + end_min + dps: dict[RoborockZeoProtocol, Any] = { + RoborockZeoProtocol.SILENT_MODE_ON: 1 if on else 0, + RoborockZeoProtocol.SILENT_MODE_START_TIME: start_mins, + RoborockZeoProtocol.SILENT_MODE_END_TIME: end_mins, + } + result = await send_decoded_command(self._channel, dps, value_encoder=lambda x: x) + self._dps_cache[int(RoborockZeoProtocol.SILENT_MODE_ON)] = on + self._dps_cache[int(RoborockZeoProtocol.SILENT_MODE_START_TIME)] = start_mins + self._dps_cache[int(RoborockZeoProtocol.SILENT_MODE_END_TIME)] = end_mins + return result + + # ── Cloud / Panel program save ────────────────────────────────── + # TODO: extract shared save logic from save_cloud_program and + # save_panel_program — the two methods are near-identical + # apart from the save-DP selection at the end. + + # DP set that mirrors the optional parameters in the official + # app's saveCloudProgramWith / savePanelProgramWith. + _SAVE_PARAM_DPS_WASHER: tuple[RoborockZeoProtocol, ...] = ( + RoborockZeoProtocol.SOAK, + RoborockZeoProtocol.TEMP, + RoborockZeoProtocol.RINSE_TIMES, + RoborockZeoProtocol.SPIN_LEVEL, + RoborockZeoProtocol.DRYING_MODE, + RoborockZeoProtocol.DRY_CARE_MODE, + RoborockZeoProtocol.DRYING_METHOD, + RoborockZeoProtocol.STEAM_VOLUME, + ) + _SAVE_PARAM_DPS_DRYER: tuple[RoborockZeoProtocol, ...] = ( + RoborockZeoProtocol.DRYING_MODE, + RoborockZeoProtocol.DRYING_METHOD, + RoborockZeoProtocol.STEAM_VOLUME, + ) + + @property + def _save_params(self) -> tuple[RoborockZeoProtocol, ...]: + """Return the save-parameter DP set for this device type.""" + return self._SAVE_PARAM_DPS_DRYER if self.is_dryer else self._SAVE_PARAM_DPS_WASHER + + async def _build_save_payload(self) -> dict[RoborockZeoProtocol, Any]: + """Bundle Mode + Program + cached optionals for a save command.""" + await self._ensure_subscribed() + payload: dict[RoborockZeoProtocol, Any] = { + RoborockZeoProtocol.MODE: self._dps_cache.get(int(RoborockZeoProtocol.MODE), 1), + RoborockZeoProtocol.PROGRAM: self._dps_cache.get(int(RoborockZeoProtocol.PROGRAM), 1), + } + for dp in self._save_params: + val = self._dps_cache.get(int(dp)) + if val is not None: + payload[dp] = val + return payload + + async def save_cloud_program( + self, + mode: int | None = None, + program: int | None = None, + *, + soak: int | None = None, + temp: int | None = None, + rinse: int | None = None, + spin: int | None = None, + drying_mode: int | None = None, + dry_care_mode: int | None = None, + drying_method: int | None = None, + steam_volume: int | None = None, + total_time_min: int | None = None, + ) -> dict[RoborockZeoProtocol, Any]: + """Save wash parameters as the custom cloud programme. + + Bundles Mode + Program + optional parameters with either + DP 254 (SaveAdaptedCloudProgram) or DP 221 (SaveCloudProgram), + matching the official app's ``saveCloudProgramWith``. + + When parameters are provided they take precedence over the + internal DPS cache, allowing callers to save a specific + configuration without first issuing individual set_value calls. + """ + await self._feature_trait.refresh() + features = self._feature_trait.features + user_params: dict[RoborockZeoProtocol, Any] = {} + if mode is not None: + user_params[RoborockZeoProtocol.MODE] = mode + if program is not None: + user_params[RoborockZeoProtocol.PROGRAM] = program + for dp, val in ( + (RoborockZeoProtocol.SOAK, soak), + (RoborockZeoProtocol.TEMP, temp), + (RoborockZeoProtocol.RINSE_TIMES, rinse), + (RoborockZeoProtocol.SPIN_LEVEL, spin), + (RoborockZeoProtocol.DRYING_MODE, drying_mode), + (RoborockZeoProtocol.DRY_CARE_MODE, dry_care_mode), + (RoborockZeoProtocol.DRYING_METHOD, drying_method), + (RoborockZeoProtocol.STEAM_VOLUME, steam_volume), + ): + if val is not None: + user_params[dp] = val + if total_time_min is not None and total_time_min > 0: + user_params[RoborockZeoProtocol.TOTAL_TIME] = total_time_min + payload = await self._build_save_payload() + payload.update(user_params) + save_dp = ( + RoborockZeoProtocol.SAVE_ADAPTED_CLOUD_PROGRAM + if features is not None and features.adapted_custom_program + else RoborockZeoProtocol.CUSTOM_PARAM_SAVE + ) + payload[save_dp] = 1 + return await send_decoded_command(self._channel, payload, value_encoder=lambda x: x) + + async def save_panel_program( + self, + mode: int | None = None, + program: int | None = None, + *, + soak: int | None = None, + temp: int | None = None, + rinse: int | None = None, + spin: int | None = None, + drying_mode: int | None = None, + dry_care_mode: int | None = None, + drying_method: int | None = None, + steam_volume: int | None = None, + total_time_min: int | None = None, + ) -> dict[RoborockZeoProtocol, Any]: + """Save wash parameters as the device panel-onboard programme. + + Bundles Mode + Program + optional parameters with DP 252 + (PanelProgramParamsSet) = 1, matching the official app's + ``savePanelProgramWith``. + """ + user_params: dict[RoborockZeoProtocol, Any] = {} + if mode is not None: + user_params[RoborockZeoProtocol.MODE] = mode + if program is not None: + user_params[RoborockZeoProtocol.PROGRAM] = program + for dp, val in ( + (RoborockZeoProtocol.SOAK, soak), + (RoborockZeoProtocol.TEMP, temp), + (RoborockZeoProtocol.RINSE_TIMES, rinse), + (RoborockZeoProtocol.SPIN_LEVEL, spin), + (RoborockZeoProtocol.DRYING_MODE, drying_mode), + (RoborockZeoProtocol.DRY_CARE_MODE, dry_care_mode), + (RoborockZeoProtocol.DRYING_METHOD, drying_method), + (RoborockZeoProtocol.STEAM_VOLUME, steam_volume), + ): + if val is not None: + user_params[dp] = val + if total_time_min is not None and total_time_min > 0: + user_params[RoborockZeoProtocol.TOTAL_TIME] = total_time_min + payload = await self._build_save_payload() + payload.update(user_params) + payload[RoborockZeoProtocol.PANEL_PROGRAM_PARAMS_SET] = 1 + return await send_decoded_command(self._channel, payload, value_encoder=lambda x: x) + + # ── Preset / delayed start ───────────────────────────────────── + # Corresponds to the official app's ``presetWith`` — same as + # ``startWith`` but appends DP 217 (countdown) at the end. + + async def start_with_preset(self, countdown_minutes: int) -> dict[RoborockZeoProtocol, Any]: + """Start the device with a delayed-start countdown. + + Bundles the current wash settings with DP 217 (Preset / + countdown in minutes) — a zero-countdown is treated as an + immediate start. + """ + _LOGGER.debug("Preset start: discovering features and building payload") + await self._feature_trait.refresh() + features = self._feature_trait.features + p = await self._get_start_params() + dps: dict[RoborockZeoProtocol, Any] = { + RoborockZeoProtocol.START: "True", + RoborockZeoProtocol.MODE: p.mode, + RoborockZeoProtocol.PROGRAM: p.program, + } + if self.is_dryer: + total = self._dps_cache.get(int(RoborockZeoProtocol.TOTAL_TIME)) + if total and int(total) > 0: + dps[RoborockZeoProtocol.TOTAL_TIME] = int(total) + for dp in ( + RoborockZeoProtocol.DRYING_MODE, + RoborockZeoProtocol.DRYING_METHOD, + RoborockZeoProtocol.STEAM_VOLUME, + ): + val = self._dps_cache.get(int(dp)) + if val is not None: + dps[dp] = val + else: + for dp, val in ( + (RoborockZeoProtocol.TEMP, p.temp), + (RoborockZeoProtocol.RINSE_TIMES, p.rinse_times), + (RoborockZeoProtocol.SPIN_LEVEL, p.spin_level), + (RoborockZeoProtocol.DRYING_MODE, p.drying_mode), + ): + if val is not None: + dps[dp] = val + await self._build_feature_gated_dps(features, dps) + dps[RoborockZeoProtocol.COUNTDOWN] = countdown_minutes + return await send_decoded_command(self._channel, dps, value_encoder=lambda x: x, qos=MqttQos.AT_LEAST_ONCE) + + # ── Auto-detergent / softener mode detection ────────────────── + # Mirror WasherDpsCache.isAutoDetergent / isAutoSoftener from + # module 725. When ``is_addition_type_control`` is True the device + # uses DetergentType/SoftenerType to control auto-dispense; + # otherwise it uses the legacy AutoDetergent/AutoSoftener DPs. + is_addition_type_control: bool = False + + async def get_auto_detergent(self) -> bool: + """Return whether automatic detergent dispensing is enabled.""" + result = await send_decoded_command( + self._channel, + { + RoborockZeoProtocol.ID_QUERY: [ + RoborockZeoProtocol.DETERGENT_TYPE + if self.is_addition_type_control + else RoborockZeoProtocol.DETERGENT_SET, + ], + }, + value_encoder=json.dumps, + ) + raw = result.get( + RoborockZeoProtocol.DETERGENT_TYPE if self.is_addition_type_control else RoborockZeoProtocol.DETERGENT_SET + ) + return parse_bool(raw) if not self.is_addition_type_control else bool(raw and int(raw) > 0) + + async def get_auto_softener(self) -> bool: + """Return whether automatic softener dispensing is enabled.""" + result = await send_decoded_command( + self._channel, + { + RoborockZeoProtocol.ID_QUERY: [ + RoborockZeoProtocol.SOFTENER_TYPE + if self.is_addition_type_control + else RoborockZeoProtocol.SOFTENER_SET, + ], + }, + value_encoder=json.dumps, + ) + raw = result.get( + RoborockZeoProtocol.SOFTENER_TYPE if self.is_addition_type_control else RoborockZeoProtocol.SOFTENER_SET + ) + return parse_bool(raw) if not self.is_addition_type_control else bool(raw and int(raw) > 0) + + # ── Feature-gated DP preloading ───────────────────────────────── + # Exposes the canonical "forceLoad" + "loadFeatureDps" two-phase + # polling strategy from WasherDpsManager (module 727). + + _FORCE_LOAD_WASHER_DPS: tuple[RoborockZeoProtocol, ...] = ( + RoborockZeoProtocol.START, + RoborockZeoProtocol.PAUSE, + RoborockZeoProtocol.STATE, + RoborockZeoProtocol.SHUTDOWN, + RoborockZeoProtocol.MODE, + RoborockZeoProtocol.PROGRAM, + RoborockZeoProtocol.CHILD_LOCK, + RoborockZeoProtocol.TEMP, + RoborockZeoProtocol.RINSE_TIMES, + RoborockZeoProtocol.SPIN_LEVEL, + RoborockZeoProtocol.DRYING_MODE, + RoborockZeoProtocol.DETERGENT_SET, + RoborockZeoProtocol.COUNTDOWN, + RoborockZeoProtocol.PRODUCT_INFO, + RoborockZeoProtocol.DETERGENT_TYPE, + RoborockZeoProtocol.WASHING_LEFT, + RoborockZeoProtocol.DOORLOCK_STATE, + RoborockZeoProtocol.ERROR, + RoborockZeoProtocol.CUSTOM_PARAM_SAVE, + RoborockZeoProtocol.CUSTOM_PARAM_GET, + RoborockZeoProtocol.SOUND_SET, + RoborockZeoProtocol.TIMES_AFTER_CLEAN, + RoborockZeoProtocol.DETERGENT_EMPTY, + RoborockZeoProtocol.WASHING_LOG, + RoborockZeoProtocol.OTA_NFO, + RoborockZeoProtocol.F_C, + ) + + _FORCE_LOAD_DRYER_DPS: tuple[RoborockZeoProtocol, ...] = ( + RoborockZeoProtocol.START, + RoborockZeoProtocol.PAUSE, + RoborockZeoProtocol.STATE, + RoborockZeoProtocol.SHUTDOWN, + RoborockZeoProtocol.MODE, + RoborockZeoProtocol.PROGRAM, + RoborockZeoProtocol.CHILD_LOCK, + RoborockZeoProtocol.DRYING_MODE, + RoborockZeoProtocol.COUNTDOWN, + RoborockZeoProtocol.PRODUCT_INFO, + RoborockZeoProtocol.WASHING_LEFT, + RoborockZeoProtocol.DOORLOCK_STATE, + RoborockZeoProtocol.ERROR, + RoborockZeoProtocol.CUSTOM_PARAM_GET, + RoborockZeoProtocol.SOUND_SET, + RoborockZeoProtocol.WASHING_LOG, + RoborockZeoProtocol.OTA_NFO, + RoborockZeoProtocol.F_C, + ) + + async def force_load(self) -> dict[RoborockZeoProtocol, Any]: + """Phase‑1 poll: load all essential DPs (mirrors the app's ``forceLoad``).""" + await self._ensure_subscribed() + if self.is_dryer: + dps = list(self._FORCE_LOAD_DRYER_DPS) + else: + dps = list(self._FORCE_LOAD_WASHER_DPS) + result = await self.query_values(dps) + # M1 / Muse / Metis series have no softener compartment — the + # official app skips softener DPs for those models entirely. + if not self.is_dryer and not self.is_m1_muse_metis: + optional: list[RoborockZeoProtocol] = [ + RoborockZeoProtocol.SOFTENER_SET, + RoborockZeoProtocol.SOFTENER_TYPE, + ] + if self.is_addition_type_control: + optional.append(RoborockZeoProtocol.DEFAULT_SETTING) + optional.append(RoborockZeoProtocol.SOFTENER_EMPTY) + await self.query_values(optional) + return result + + async def load_feature_dps(self) -> dict[RoborockZeoProtocol, Any]: + """Phase‑2 poll: load DPs gated by FeatureBits from DP 237. + + Call :meth:`force_load` first so that DP 237 is in the cache, + then call this to discover and load feature-specific DPs. + """ + await self._feature_trait.refresh() + features = self._feature_trait.features + if features is None: + return {} + + wanted: list[RoborockZeoProtocol] = [] + if features.silent_mode: + wanted += [ + RoborockZeoProtocol.SILENT_MODE_ON, + RoborockZeoProtocol.SILENT_MODE_START_TIME, + RoborockZeoProtocol.SILENT_MODE_END_TIME, + ] + if features.dry_care: + wanted.append(RoborockZeoProtocol.DRY_CARE_MODE) + if features.smile_light: + wanted.append(RoborockZeoProtocol.SMILE_LIGHT_STATUS) + if features.expand_softener or features.wool_detergent: + wanted.append(RoborockZeoProtocol.SOFTENER_EXPANSION_TYPE) + if features.concentrated_detergent: + wanted.append(RoborockZeoProtocol.DETERGENT_EXPANSION_TYPE) + if features.voice_assistant: + wanted += [ + RoborockZeoProtocol.VOICE_SWITCH, + RoborockZeoProtocol.VOICE_VOLUME, + RoborockZeoProtocol.VOICE_RECORD_INFO, + RoborockZeoProtocol.VOICE_RECORD, + RoborockZeoProtocol.SND_STATE, + ] + if features.fluff_clean_notification: + wanted.append(RoborockZeoProtocol.IS_NEED_FLUFF_CLEAN) + wanted.append(RoborockZeoProtocol.UV_LIGHT) + if features.power_button_indicator_light: + wanted.append(RoborockZeoProtocol.POWER_LIGHT) + if features.dirt_detection: + wanted += [ + RoborockZeoProtocol.DIRT_DETECTION_SWITCH, + RoborockZeoProtocol.DIRT_DETECTION_STATUS, + ] + if features.steam_care: + wanted += [ + RoborockZeoProtocol.STEAM_VOLUME, + RoborockZeoProtocol.STEAM_CARE_TIME, + ] + if features.wash_dry_linkage: + wanted += [ + RoborockZeoProtocol.WASH_DRY_LINKED, + RoborockZeoProtocol.DEVICE_BOUND, + RoborockZeoProtocol.CLOTH_PUT_IN, + ] + if not self.is_dryer: + wanted += [ + RoborockZeoProtocol.CLOTH_READY_TO_DRY_COUNT_DOWN, + RoborockZeoProtocol.START_DRYER_ERROR, + ] + if features.save_panel_program_params: + wanted.append(RoborockZeoProtocol.WIFI_LINKAGE_RESET) + if not wanted: + return {} + return await self.query_values(wanted) + + # ── Cleanser config (bundled 4-DP set) ─────────────────────────── + # Matches the official app's ``setCleanserConfig`` which sends + # DetergentExpansionType + SoftenerExpansionType + DetergentType + + # SoftenerType in a single ``publishDps`` call. + + async def set_cleanser_config( + self, + detergent_expansion: int, + softener_expansion: int, + detergent_type: int, + softener_type: int, + ) -> dict[RoborockZeoProtocol, Any]: + """Set all four detergent/softener configuration DPs in one call.""" + dps: dict[RoborockZeoProtocol, Any] = { + RoborockZeoProtocol.DETERGENT_EXPANSION_TYPE: detergent_expansion, + RoborockZeoProtocol.SOFTENER_EXPANSION_TYPE: softener_expansion, + RoborockZeoProtocol.DETERGENT_TYPE: detergent_type, + RoborockZeoProtocol.SOFTENER_TYPE: softener_type, + } + result = await send_decoded_command(self._channel, dps, value_encoder=lambda x: x) + for dp, v in dps.items(): + self._dps_cache[int(dp)] = v + return result + + # ── Cloud program load ────────────────────────────────────────── + # Matches the official app's ``loadCloudProgram`` — sends DP 222=1 + # to instruct the device to load the saved custom programme. + + async def load_cloud_program(self) -> dict[RoborockZeoProtocol, Any]: + """Load the saved cloud programme from the device (DP 222 = 1).""" + return await send_decoded_command( + self._channel, + {RoborockZeoProtocol.CUSTOM_PARAM_GET: 1}, + value_encoder=lambda x: x, + ) + + # ── Management utilities ──────────────────────────────────────── + # Thin wrappers around bundle WasherDpsManager methods (module 727). + + async def check_fcc_state(self) -> dict[RoborockZeoProtocol, Any]: + """Query DP 10001 for FCC compliance state.""" + return await send_decoded_command( + self._channel, + {RoborockZeoProtocol.ID_QUERY: [RoborockZeoProtocol.F_C]}, + value_encoder=json.dumps, + ) + + async def load_general_info(self) -> dict[RoborockZeoProtocol, Any]: + """Query DP 10005 (robotInfo) with 10 s timeout.""" + return await send_decoded_command( + self._channel, + {RoborockZeoProtocol.ID_QUERY: [RoborockZeoProtocol.PRODUCT_INFO]}, + value_encoder=json.dumps, + ) + + async def upload_log(self) -> dict[RoborockZeoProtocol, Any]: + """Request the device to upload diagnostic logs (RPC call).""" + import random as _random + + return await send_decoded_command( + self._channel, + { + RoborockZeoProtocol.RPC_REQUEST: { + "id": _random.randint(0, 999999), + "method": "user_upload_log", + } + }, + value_encoder=lambda x: x, + ) + + async def sync_privacy_to_device(self, agreed: bool) -> dict[RoborockZeoProtocol, Any]: + """Push the user's privacy-agreement state to the device (DP 10006).""" + return await send_decoded_command( + self._channel, + {RoborockZeoProtocol.PRIVACY_INFO: {"userAgreementState": 1 if agreed else 0}}, + value_encoder=lambda x: x, + ) + + async def update_sound_package_info(self) -> dict[RoborockZeoProtocol, Any]: + """Re‑fetch sound-package metadata (DP 10004).""" + return await send_decoded_command( + self._channel, + {RoborockZeoProtocol.ID_QUERY: [RoborockZeoProtocol.SND_STATE]}, + value_encoder=json.dumps, + ) + + # ── Voice / Sound JSON wire formats ───────────────────────────── + # The official app serialises voice DPs as JSON objects on the wire. + # Return raw dicts here — ``encode_mqtt_payload`` wraps the entire + # payload in ``json.dumps``, so pre-serialising would double-encode. + _VOICE_ENCODERS: dict[RoborockZeoProtocol, Callable[[Any], Any]] = { + RoborockZeoProtocol.SET_SOUND_PACKAGE: lambda v: v, + RoborockZeoProtocol.VOICE_SWITCH: lambda v: {"speech_switch": 1 if parse_bool(v) else 0}, + RoborockZeoProtocol.VOICE_VOLUME: lambda v: {"snd_volume": int(v)}, + RoborockZeoProtocol.VOICE_RECORD_DELETE: lambda v: {"dialog_delete": int(v)}, + } + async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[RoborockZeoProtocol, Any]: - """Set a value for a specific protocol on the device.""" - params = {protocol: value} - return await send_decoded_command(self._channel, params, value_encoder=lambda x: x) + """Set a value for a specific protocol on the device. + + ── Two categories of DPs ── + + **startWith params** (MODE, PROGRAM, TEMP, RINSE_TIMES, SPIN_LEVEL, + DRYING_MODE, DRYING_METHOD, STEAM_VOLUME, SOAK, DRY_CARE_MODE, + TOTAL_TIME) — these are *settings* that the device stages locally. + They do **not** take effect until ``start()`` (bundle + ``startWith``) commits them together with START. Call + ``set_value`` to choose the programme, then ``start()`` to + launch. + + **Independent** (CHILD_LOCK, SOUND_SET, UV_LIGHT, SILENT_MODE_ON, + DETERGENT_SET, PAUSE, SHUTDOWN, DETERGENT_TYPE, …) — these take + effect immediately via a single MQTT ``publishDps`` call. No + ``start()`` is needed. + + Writes the value to the DPS cache after a successful MQTT + publish so that a subsequent ``start()`` call sees the latest + setting immediately. This cache write is PERMANENT and cannot + be replaced by MQTT push alone: ``start()`` needs the value + before the device echoes the change back.""" + if protocol == RoborockZeoProtocol.START and parse_bool(value): + return await self.start() + + # ── Detergent / Softener type auto-enable (washer only) ──── + # The official app uses three different protocols depending on + # device series. Default / Hyperion-Halia-Hera are handled + # here; ``is_addition_type_control`` devices (newer firmware) + # send DetergentType alone — callers should set that flag to + # opt in. + params: dict[RoborockZeoProtocol, Any] = {protocol: value} + if not self.is_dryer: + try: + int_val = int(value) + except (ValueError, TypeError): + int_val = 0 + if protocol == RoborockZeoProtocol.DETERGENT_TYPE: + if self.is_addition_type_control: + pass # Only DetergentType — no AutoDetergent bundling + elif self.is_hyperion_halia_hera: + if int_val == 0: + # Mirror the official app: type=0 sends only + # AutoDetergent=False, deleting DetergentType + # from the payload. The deleted DP is NOT + # cached below — MQTT push will correct it. + params[RoborockZeoProtocol.DETERGENT_SET] = False + del params[RoborockZeoProtocol.DETERGENT_TYPE] + else: + params[RoborockZeoProtocol.DETERGENT_SET] = True + else: + params[RoborockZeoProtocol.DETERGENT_SET] = int_val != 0 + elif protocol == RoborockZeoProtocol.SOFTENER_TYPE: + if self.is_addition_type_control: + pass + elif self.is_hyperion_halia_hera: + if int_val == 0: + # Mirror the official app: same pattern as + # detergent above — SoftenerType is deleted + # from the payload and not cached. + params[RoborockZeoProtocol.SOFTENER_SET] = False + del params[RoborockZeoProtocol.SOFTENER_TYPE] + else: + params[RoborockZeoProtocol.SOFTENER_SET] = True + else: + params[RoborockZeoProtocol.SOFTENER_SET] = int_val != 0 + + # ── Encoder selection ───────────────────────────────────── + if (voice_enc := self._VOICE_ENCODERS.get(protocol)) is not None: + encoder = voice_enc + elif protocol in _ZEO_BOOLEAN_PROTOCOLS: + encoder = to_dp_bool + else: + encoder = None + + result = await send_decoded_command(self._channel, params, value_encoder=encoder) + # Cache update must mirror exactly what was sent (params may + # have been mutated above for Hyperion/Halia/Hera series). + for dp, v in params.items(): + self._dps_cache[int(dp)] = v + return result + + +# ── Dryer model detection ──────────────────────────────────────── +# Matches the ``target: "dryer"`` entries in the plugin bundle's +# publishConfig (Apollo series: a188, a204, a258, a265). +_DRYER_PRODUCT_IDS: frozenset[str] = frozenset( + {"roborock.wm.a188", "roborock.wm.a204", "roborock.wm.a258", "roborock.wm.a265"} +) + +# ── Device series detection ────────────────────────────────────── +# The plugin bundle branches on device series for detergent/softener +# auto‑enable logic and for force‑load DP selection. These frozensets +# mirror the ``isHyperionSeries / isHaliaSeries / isHeraSeries`` and +# ``isM1Series / isMuseSeries / isMetisSeries`` guards. +_HYPERION_HALIA_HERA_PRODUCT_IDS: frozenset[str] = frozenset( + { + # Hyperion series + "roborock.wm.a141", + "roborock.wm.a149", + "roborock.wm.a207", + "roborock.wm.a230", + # Halia series + "roborock.wm.a240", + "roborock.wm.a241", + # Hera series + "roborock.wm.a227", + "roborock.wm.a261", + "roborock.wm.a273", + "roborock.wm.a268", + "roborock.wm.a269", + } +) + +_M1_MUSE_METIS_PRODUCT_IDS: frozenset[str] = frozenset( + { + # M1 series + "roborock.wm.a92", + "roborock.wm.a93", + "roborock.wm.a133", + "roborock.wm.a277", + "roborock.wm.a162", + "roborock.wm.a233", + "roborock.wm.a276", + "roborock.wm.a234", + "roborock.wm.a218", + # Muse series + "roborock.wm.a142", + "roborock.wm.a215", + # Metis series + "roborock.wm.a154", + "roborock.wm.a214", + } +) + + +def _is_dryer(product: HomeDataProduct) -> bool: + """Return ``True`` when *product* is a known standalone-dryer model.""" + return product.id in _DRYER_PRODUCT_IDS + + +def _is_hyperion_halia_hera(product: HomeDataProduct) -> bool: + """Return ``True`` for Hyperion / Halia / Hera series washers.""" + return product.id in _HYPERION_HALIA_HERA_PRODUCT_IDS + + +def _is_m1_muse_metis(product: HomeDataProduct) -> bool: + """Return ``True`` for M1 / Muse / Metis series washers.""" + return product.id in _M1_MUSE_METIS_PRODUCT_IDS def create(product: HomeDataProduct, mqtt_channel: MqttChannel) -> DyadApi | ZeoApi: @@ -186,6 +1424,11 @@ def create(product: HomeDataProduct, mqtt_channel: MqttChannel) -> DyadApi | Zeo case RoborockCategory.WET_DRY_VAC: return DyadApi(mqtt_channel) case RoborockCategory.WASHING_MACHINE: - return ZeoApi(mqtt_channel) + return ZeoApi( + mqtt_channel, + is_dryer=_is_dryer(product), + is_hyperion_halia_hera=_is_hyperion_halia_hera(product), + is_m1_muse_metis=_is_m1_muse_metis(product), + ) case _: raise NotImplementedError(f"Unsupported category {product.category}") diff --git a/roborock/devices/traits/a01/device_features.py b/roborock/devices/traits/a01/device_features.py new file mode 100644 index 000000000..04db083de --- /dev/null +++ b/roborock/devices/traits/a01/device_features.py @@ -0,0 +1,57 @@ +"""Zeo device feature discovery trait. + +Mirrors the V1 :class:`roborock.devices.traits.v1.device_features.DeviceFeaturesTrait` +pattern: a dedicated trait that queries DP 237 (FEATURE_BITS) once per session +and caches the parsed result. +""" + +import json +import logging + +from roborock.device_features import ZeoFeatures +from roborock.devices.rpc.a01_channel import send_decoded_command +from roborock.devices.traits import Trait +from roborock.devices.transport.mqtt_channel import MqttChannel +from roborock.roborock_message import RoborockZeoProtocol + +_LOGGER = logging.getLogger(__name__) + + +class ZeoFeatureTrait(Trait): + """Discovers and caches Zeo device capabilities from DP 237. + + Device features are immutable during a session (they only change + after a firmware update) so the result is cached in memory after + the first query. + """ + + name = "zeo_features" + + def __init__(self, channel: MqttChannel) -> None: + """Initialize the feature trait.""" + self._channel = channel + self._features: ZeoFeatures | None = None + + @property + def features(self) -> ZeoFeatures | None: + """The cached device features, or ``None`` if not yet discovered.""" + return self._features + + async def refresh(self) -> ZeoFeatures: + """Query DP 237 and parse into a :class:`ZeoFeatures` instance. + + On the first call this sends an MQTT query; subsequent calls + return the cached result. + """ + if self._features is not None: + return self._features + _LOGGER.debug("Discovering Zeo device features") + current = await send_decoded_command( + self._channel, + {RoborockZeoProtocol.ID_QUERY: [RoborockZeoProtocol.FEATURE_BITS]}, + value_encoder=json.dumps, + ) + raw = current.get(RoborockZeoProtocol.FEATURE_BITS, 0) + self._features = ZeoFeatures.from_feature_bits(raw) + _LOGGER.debug("Device features: %s", self._features) + return self._features diff --git a/roborock/devices/transport/mqtt_channel.py b/roborock/devices/transport/mqtt_channel.py index 5ff0ab085..1a0f81e64 100644 --- a/roborock/devices/transport/mqtt_channel.py +++ b/roborock/devices/transport/mqtt_channel.py @@ -8,7 +8,7 @@ from roborock.data import HomeDataDevice, RRiot, UserData from roborock.exceptions import RoborockException from roborock.mqtt.health_manager import HealthManager -from roborock.mqtt.session import MqttParams, MqttSession, MqttSessionException +from roborock.mqtt.session import MqttParams, MqttQos, MqttSession, MqttSessionException from roborock.protocol import create_mqtt_decoder, create_mqtt_encoder from roborock.roborock_message import RoborockMessage from roborock.util import RoborockLoggerAdapter @@ -89,11 +89,15 @@ async def subscribe_stream(self) -> AsyncGenerator[RoborockMessage, None]: finally: unsub() - async def publish(self, message: RoborockMessage) -> None: + async def publish(self, message: RoborockMessage, qos: MqttQos = MqttQos.AT_MOST_ONCE) -> None: """Publish a command message. The caller is responsible for handling any responses and associating them with the incoming request. + + Args: + message: The message to publish. + qos: The MQTT QoS level. Defaults to AT_MOST_ONCE. """ try: encoded_msg = self._encoder(message) @@ -101,7 +105,7 @@ async def publish(self, message: RoborockMessage) -> None: self._logger.exception("Error encoding MQTT message: %s", e) raise RoborockException(f"Failed to encode MQTT message: {e}") from e try: - return await self._mqtt_session.publish(self._publish_topic, encoded_msg) + return await self._mqtt_session.publish(self._publish_topic, encoded_msg, qos=qos) except MqttSessionException as e: self._logger.debug("Error publishing MQTT message: %s", e) raise RoborockException(f"Failed to publish MQTT message: {e}") from e diff --git a/roborock/mqtt/roborock_session.py b/roborock/mqtt/roborock_session.py index ec6e5aa71..15202372e 100644 --- a/roborock/mqtt/roborock_session.py +++ b/roborock/mqtt/roborock_session.py @@ -22,7 +22,7 @@ from roborock.diagnostics import Diagnostics, redact_topic_name from .health_manager import HealthManager -from .session import MqttParams, MqttSession, MqttSessionException, MqttSessionUnauthorized +from .session import MqttParams, MqttQos, MqttSession, MqttSessionException, MqttSessionUnauthorized _LOGGER = logging.getLogger(__name__) _MQTT_LOGGER = logging.getLogger(f"{__name__}.aiomqtt") @@ -361,8 +361,14 @@ def delayed_unsub(): return delayed_unsub - async def publish(self, topic: str, message: bytes) -> None: - """Publish a message on the topic.""" + async def publish(self, topic: str, message: bytes, qos: MqttQos = MqttQos.AT_MOST_ONCE) -> None: + """Publish a message on the topic. + + Args: + topic: The MQTT topic to publish to. + message: The message payload. + qos: The MQTT QoS level. Defaults to AT_MOST_ONCE. + """ _LOGGER.debug("Sending message to topic %s: %s", topic, message) client: aiomqtt.Client async with self._client_lock: @@ -371,7 +377,7 @@ async def publish(self, topic: str, message: bytes) -> None: client = self._client try: with self._diagnostics.timer("publish"): - await client.publish(topic, message) + await client.publish(topic, message, qos=qos) except MqttError as err: raise MqttSessionException(f"Error publishing message: {err}") from err @@ -417,13 +423,13 @@ async def subscribe(self, device_id: str, callback: Callable[[bytes], None]) -> await self._maybe_start() return await self._session.subscribe(device_id, callback) - async def publish(self, topic: str, message: bytes) -> None: + async def publish(self, topic: str, message: bytes, qos: MqttQos = MqttQos.AT_MOST_ONCE) -> None: """Publish a message on the specified topic. This will raise an exception if the message could not be sent. """ await self._maybe_start() - return await self._session.publish(topic, message) + return await self._session.publish(topic, message, qos=qos) async def close(self) -> None: """Cancels the mqtt loop. diff --git a/roborock/mqtt/session.py b/roborock/mqtt/session.py index 9e77b4a86..319615fc4 100644 --- a/roborock/mqtt/session.py +++ b/roborock/mqtt/session.py @@ -3,6 +3,7 @@ from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass, field +from enum import IntEnum from roborock.diagnostics import Diagnostics from roborock.exceptions import RoborockException @@ -10,6 +11,24 @@ DEFAULT_TIMEOUT = 30.0 + +class MqttQos(IntEnum): + """MQTT Quality of Service levels. + + A01 devices (Zeo, Dyad) require ``AT_LEAST_ONCE`` for DP200 (start) + commands. Other protocol versions use ``AT_MOST_ONCE``. + """ + + AT_MOST_ONCE = 0 + """Fire-and-forget. No acknowledgment required.""" + + AT_LEAST_ONCE = 1 + """Guaranteed delivery with possible duplicates. Broker sends PUBACK.""" + + EXACTLY_ONCE = 2 + """Guaranteed delivery with no duplicates. Broker sends PUBREC/PUBREL/PUBCOMP.""" + + SessionUnauthorizedHook = Callable[[], None] @@ -76,10 +95,15 @@ async def subscribe(self, device_id: str, callback: Callable[[bytes], None]) -> """ @abstractmethod - async def publish(self, topic: str, message: bytes) -> None: + async def publish(self, topic: str, message: bytes, qos: MqttQos = MqttQos.AT_MOST_ONCE) -> None: """Publish a message on the specified topic. This will raise an exception if the message could not be sent. + + Args: + topic: The MQTT topic to publish to. + message: The message payload. + qos: The MQTT QoS level. Defaults to AT_MOST_ONCE. """ @abstractmethod diff --git a/roborock/protocols/a01_protocol.py b/roborock/protocols/a01_protocol.py index f3166de87..46db6ef4a 100644 --- a/roborock/protocols/a01_protocol.py +++ b/roborock/protocols/a01_protocol.py @@ -2,6 +2,7 @@ import json import logging +import time from collections.abc import Callable from typing import Any @@ -42,7 +43,10 @@ def encode_mqtt_payload( """ if value_encoder is None: value_encoder = _no_encode - dps_data = {"dps": {key: value_encoder(value) for key, value in data.items()}} + dps_data = { + "dps": {key: value_encoder(value) for key, value in data.items()}, + "t": int(time.time()), + } payload = pad(json.dumps(dps_data).encode("utf-8"), AES.block_size) return RoborockMessage( protocol=RoborockMessageProtocol.RPC_REQUEST, diff --git a/roborock/roborock_message.py b/roborock/roborock_message.py index b78ce5bab..6c4b68202 100644 --- a/roborock/roborock_message.py +++ b/roborock/roborock_message.py @@ -125,45 +125,96 @@ class RoborockDyadDataProtocol(RoborockEnum): class RoborockZeoProtocol(RoborockEnum): - START = 200 # rw + # ── Control actions ─────────────────────────────────────────── + START = 200 # rw [action → start()] set_value(START,"True") triggers bundled start() PAUSE = 201 # rw SHUTDOWN = 202 # rw + + # ── Read-only ───────────────────────────────────────────────── STATE = 203 # ro - MODE = 204 # rw - PROGRAM = 205 # rw - CHILD_LOCK = 206 # rw - TEMP = 207 # rw - RINSE_TIMES = 208 # rw - SPIN_LEVEL = 209 # rw - DRYING_MODE = 210 # rw - DETERGENT_SET = 211 # rw - SOFTENER_SET = 212 # rw - DETERGENT_TYPE = 213 # rw - SOFTENER_TYPE = 214 # rw - COUNTDOWN = 217 # rw + DIRT_DETECTION_STATUS = 216 # ro WASHING_LEFT = 218 # ro DOORLOCK_STATE = 219 # ro ERROR = 220 # ro - CUSTOM_PARAM_SAVE = 221 # rw - CUSTOM_PARAM_GET = 222 # ro - SOUND_SET = 223 # rw TIMES_AFTER_CLEAN = 224 # ro - DEFAULT_SETTING = 225 # rw DETERGENT_EMPTY = 226 # ro SOFTENER_EMPTY = 227 # ro - LIGHT_SETTING = 229 # rw - DETERGENT_VOLUME = 230 # rw - SOFTENER_VOLUME = 231 # rw - APP_AUTHORIZATION = 232 # rw - ID_QUERY = 10000 - F_C = 10001 - SND_STATE = 10004 - PRODUCT_INFO = 10005 - PRIVACY_INFO = 10006 - OTA_NFO = 10007 - WASHING_LOG = 10008 - RPC_REQ = 10101 - RPC_RESp = 10102 + APP_AUTHORIZATION = 232 # ro + TOTAL_TIME = 234 # ro used in dryer startWith payload + SMART_HOSTING_TIME = 236 # ro + FEATURE_BITS = 237 # ro decoded by ZeoFeatureBits + SMART_HOSTING_WAITED_TIME = 238 # ro + CUSTOM_PROGRAM_CLEANING_TIME = 239 # ro + IS_NEED_FLUFF_CLEAN = 250 # ro + PANEL_PROGRAM_PARAMS_SET_RESULT = 253 # ro + PANEL_TIMING_PROGRAM_PARAMS = 260 # ro + STEAM_CARE_TIME = 261 # ro + DEVICE_BOUND = 262 # ro + CLOTH_PUT_IN = 263 # ro + CLOTH_READY_TO_DRY_COUNT_DOWN = 264 # ro + START_DRYER_ERROR = 265 # ro + + # ── startWith params ──────────────────────────────────────── + # Best sent via start() which bundles them into one command. + # set_value() also works but issues individual MQTT publishes. + MODE = 204 # rw [startWith] + PROGRAM = 205 # rw [startWith] + TEMP = 207 # rw [startWith] + RINSE_TIMES = 208 # rw [startWith] + SPIN_LEVEL = 209 # rw [startWith] + DRYING_MODE = 210 # rw [startWith] + SOAK = 233 # rw [startWith] + DRY_CARE_MODE = 244 # rw [startWith] + WASH_DRY_LINKED = 255 # rw [startWith / feature-gated] + DRYING_METHOD = 256 # rw [startWith] + STEAM_VOLUME = 257 # rw [startWith] + ION_DEODORIZATION = 258 # rw [startWith / feature-gated] + + # ── Independent (immediate effect, set_value() works well) ────────── + CHILD_LOCK = 206 # rw [independent] + DETERGENT_SET = 211 # rw [independent] + SOFTENER_SET = 212 # rw [independent] + DETERGENT_TYPE = 213 # rw [independent] + SOFTENER_TYPE = 214 # rw [independent] + DIRT_DETECTION_SWITCH = 215 # rw [independent] + COUNTDOWN = 217 # rw [independent] also used in start_with_preset() + CUSTOM_PARAM_SAVE = 221 # rw [independent] see save_cloud_program() + CUSTOM_PARAM_GET = 222 # rw [independent] read via get_custom_mode(), write via load_cloud_program() + SOUND_SET = 223 # rw [independent] + DEFAULT_SETTING = 225 # rw [independent] + UV_LIGHT = 228 # rw [independent] + LIGHT_SETTING = 229 # rw [independent] server schema only, not found in bundle + DETERGENT_VOLUME = 230 # rw [independent] server schema only, not found in bundle + SOFTENER_VOLUME = 231 # rw [independent] server schema only, not found in bundle + SMART_HOSTING = 235 # rw [independent] + SILENT_MODE_ON = 240 # rw [independent] use set_silent_mode() for bundled set + SILENT_MODE_START_TIME = 241 # rw [independent] minute-of-day + SILENT_MODE_END_TIME = 242 # rw [independent] minute-of-day + SOFTENER_EXPANSION_TYPE = 245 # rw [independent] + SMILE_LIGHT_STATUS = 247 # rw [independent] + DETERGENT_EXPANSION_TYPE = 248 # rw [independent] + FLUFF_CLEANED = 249 # rw [independent] + POWER_LIGHT = 251 # rw [independent] + PANEL_PROGRAM_PARAMS_SET = 252 # rw [independent] + SAVE_ADAPTED_CLOUD_PROGRAM = 254 # rw [independent] + WIFI_LINKAGE_RESET = 266 # rw [independent] + + # ── Meta / RPC / Voice (10000+) ─────────────────────────────── + ID_QUERY = 10000 # -- multi-DP query request (not a device DP) + F_C = 10001 # ro query via checkFCCState() + SET_SOUND_PACKAGE = 10003 # wo setSoundPackage(JSON) + SND_STATE = 10004 # ro query via updateSoundPackageInfo() + PRODUCT_INFO = 10005 # ro query via loadGeneralInfo() (10s timeout) + PRIVACY_INFO = 10006 # wo syncPrivacyToDevice(agreed) + OTA_NFO = 10007 # ro forceLoad only + WASHING_LOG = 10008 # ro forceLoad only, JSON + VOICE_VOLUME = 10009 # wo [independent] setVoiceVolume(int) → JSON + RPC_REQUEST = 10101 # wo rpcRequest(method) → JSON + RPC_RESPONSE = 10102 # -- MQTT push protocol 102, not a device DP + VOICE_SWITCH = 10301 # wo [independent] setVoiceSwitchStatus(bool) → JSON + VOICE_RECORD_INFO = 10302 # ro cache-derived, auto JSON decoded + VOICE_RECORD = 10303 # ro query via getVoiceControlRecord(), JSON + VOICE_RECORD_DELETE = 10304 # wo [independent] deleteVoiceControlRecord(id) → JSON class RoborockB01Protocol(RoborockEnum): diff --git a/roborock/testing/channel.py b/roborock/testing/channel.py index 022d449bd..65a36bc79 100644 --- a/roborock/testing/channel.py +++ b/roborock/testing/channel.py @@ -12,6 +12,7 @@ from roborock.devices.transport.channel import Channel from roborock.mqtt.health_manager import HealthManager +from roborock.mqtt.session import MqttQos from roborock.protocols.v1_protocol import LocalProtocolVersion from roborock.roborock_message import RoborockMessage @@ -83,6 +84,7 @@ def __init__(self, is_local: bool = False): self.close = MagicMock(side_effect=self._close) self.protocol_version = LocalProtocolVersion.V1 + self.restart = AsyncMock() self.health_manager = HealthManager(self.restart) @@ -102,10 +104,13 @@ def is_local_connected(self) -> bool: """Return true if locally connected.""" return self._is_connected and self._is_local - async def _publish(self, message: RoborockMessage) -> None: + async def _publish(self, message: RoborockMessage, qos: MqttQos = MqttQos.AT_MOST_ONCE) -> None: """Default publish implementation. Records the message in ``published_messages`` and executes ``publish_handler``. + + The ``qos`` parameter is accepted for compatibility with + ``MqttChannel.publish`` but not simulated by the fake channel. """ self.published_messages.append(message) if self.publish_side_effect: diff --git a/tests/devices/test_a01_code_review.py b/tests/devices/test_a01_code_review.py new file mode 100644 index 000000000..bec54be8a --- /dev/null +++ b/tests/devices/test_a01_code_review.py @@ -0,0 +1,329 @@ +"""Regression tests for A01/Zeo code-review findings. + +These guard the two highest-risk findings from the review against the +official Roborock Washer app plugin bundle: + +1. Boolean decode/encode must survive the ``bool("False") is True`` trap and + must serialise booleans as the strings ``"True"`` / ``"False"`` (the + official ``DPBoolean`` enum), never ``1`` / ``0`` or JSON booleans. +2. ``ZeoFeatureBits`` bit positions must match the bundle exactly; a single + off-by-one bit shift silently corrupts every device-capability gate. +""" + +from dataclasses import fields +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from roborock.data.zeo.zeo_code_mappings import ZeoFeatureBits +from roborock.device_features import ZeoFeatures +from roborock.devices.traits.a01 import ( + _ZEO_BOOLEAN_PROTOCOLS, + DyadApi, + ZeoApi, + parse_bool, + to_dp_bool, +) +from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol + +_ID_QUERY_INT = int(RoborockZeoProtocol.ID_QUERY) + + +@pytest.fixture +def mock_channel(): + channel = Mock() + channel.send_command = AsyncMock() + channel.subscribe = AsyncMock(return_value=Mock()) + return channel + + +# --------------------------------------------------------------------------- # +# Pure-function guards: boolean decode/encode +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("True", True), + ("true", True), + ("False", False), # the trap: bool("False") == True + ("false", False), + (1, True), + (0, False), + (True, True), + (False, False), + ("1", True), + ("0", False), + ], +) +def test_parse_bool(raw, expected): + assert parse_bool(raw) is expected + + +def test_parse_bool_trap_is_avoided(): + # This is the exact bug class the review caught: a naive bool() on the + # wire string "False" would evaluate to True. + assert parse_bool("False") is False + assert bool("False") is True # documents why parse_bool is needed + + +@pytest.mark.parametrize( + ("val", "expected"), + [(True, "True"), (False, "False"), (1, "True"), (0, "False"), ("True", "True"), ("False", "False")], +) +def test_to_dp_bool(val, expected): + assert to_dp_bool(val) == expected, f"to_dp_bool({val!r}) should be {expected}" + + +def test_boolean_protocol_sets_are_consistent(): + # Every protocol whose decoder is parse_bool must be in the boolean set, + # and vice-versa. Drift here means a boolean would be serialised wrong. + assert RoborockZeoProtocol.UV_LIGHT in _ZEO_BOOLEAN_PROTOCOLS + assert RoborockZeoProtocol.WASH_DRY_LINKED in _ZEO_BOOLEAN_PROTOCOLS + assert RoborockZeoProtocol.ION_DEODORIZATION in _ZEO_BOOLEAN_PROTOCOLS + # Non-boolean protocols must NOT be coerced to strings. + assert RoborockZeoProtocol.MODE not in _ZEO_BOOLEAN_PROTOCOLS + assert RoborockZeoProtocol.TEMP not in _ZEO_BOOLEAN_PROTOCOLS + + +# --------------------------------------------------------------------------- # +# Pure-function guard: feature-bit bit order (the most severe bug class) +# --------------------------------------------------------------------------- # + + +def test_zeo_feature_bits_match_bundle_positions(): + """Every ZeoFeatureBits member must have the correct integer value. + + These are the exact bit offsets extracted from the official Roborock + Washer app plugin bundle (index.ios.bundle, module 726). A single + off-by-one shift silently corrupts every device-capability gate. + """ + expected = { + ZeoFeatureBits.smart_hosting: 0, + ZeoFeatureBits.silent_mode: 1, + ZeoFeatureBits.new_custom_program: 2, + ZeoFeatureBits.dry_care: 3, + ZeoFeatureBits.set_uvc_in_appointment: 4, + ZeoFeatureBits.detect_door_status: 5, + ZeoFeatureBits.expand_softener: 6, + ZeoFeatureBits.set_params_in_working: 7, + ZeoFeatureBits.thirty_min_soak: 8, + ZeoFeatureBits.smile_light: 9, + ZeoFeatureBits.set_uvc_in_pause: 10, + ZeoFeatureBits.concentrated_detergent: 11, + ZeoFeatureBits.wool_detergent: 12, + ZeoFeatureBits.voice_assistant: 13, + ZeoFeatureBits.adapted_custom_program: 14, + ZeoFeatureBits.voice_assistant_record: 15, + ZeoFeatureBits.fluff_clean_notification: 16, + ZeoFeatureBits.power_button_indicator_light: 17, + ZeoFeatureBits.dirt_detection: 18, + ZeoFeatureBits.deep_self_clean: 19, + ZeoFeatureBits.save_panel_program_params: 20, + ZeoFeatureBits.steam_care: 21, + ZeoFeatureBits.wash_dry_linkage: 22, + ZeoFeatureBits.ion_deodorization: 23, + } + for member, expected_pos in expected.items(): + assert int(member) == expected_pos, f"{member.name} is at bit {int(member)}, expected {expected_pos}" + + +def test_zeo_features_fields_map_to_feature_bits(): + """Every field on ZeoFeatures must have a corresponding ZeoFeatureBits member. + + A field without a matching bit-position member would cause + ZeoFeatures.from_feature_bits() to raise AttributeError. + """ + for f in fields(ZeoFeatures): + assert hasattr(ZeoFeatureBits, f.name), f"ZeoFeatures.{f.name} has no matching ZeoFeatureBits member" + + +def test_zeo_features_from_feature_bits_decodes_correct_bits(): + """Setting a single bit only affects its field; all others stay False.""" + # Set bit 0 (smart_hosting) and bit 22 (wash_dry_linkage) only. + raw = (1 << int(ZeoFeatureBits.smart_hosting)) | (1 << int(ZeoFeatureBits.wash_dry_linkage)) + features = ZeoFeatures.from_feature_bits(raw) + assert features.smart_hosting is True + assert features.wash_dry_linkage is True + # Adjacent / unrelated bits must stay False — this is exactly what the + # original off-by-one bit order got wrong. + assert features.silent_mode is False + assert features.ion_deodorization is False + assert features.new_custom_program is False + + +def test_zeo_features_all_bits_set_decodes_all_true(): + """Raw = all bits set → every field must be True.""" + all_bits = (1 << len(ZeoFeatureBits)) - 1 + features = ZeoFeatures.from_feature_bits(all_bits) + for f in fields(ZeoFeatures): + assert getattr(features, f.name) is True, f"{f.name} should be True" + + +def test_zeo_features_all_bits_clear_decodes_all_false(): + """Raw = 0 → every field must be False.""" + features = ZeoFeatures.from_feature_bits(0) + for f in fields(ZeoFeatures): + assert getattr(features, f.name) is False, f"{f.name} should be False" + + +# --------------------------------------------------------------------------- # +# Write-path guards: boolean SET serialisation and START value +# --------------------------------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_zeo_set_value_bool_serialises_as_string(mock_channel): + with patch("roborock.devices.traits.a01.send_decoded_command", new_callable=AsyncMock) as mock_send: + api = ZeoApi(mock_channel) + for py_val, wire in ((True, "True"), (False, "False"), (1, "True"), (0, "False")): + await api.set_value(RoborockZeoProtocol.UV_LIGHT, py_val) + args, kwargs = mock_send.call_args + params = args[1] + encoder = kwargs.get("value_encoder") + encoded = {k: (encoder(v) if encoder else v) for k, v in params.items()} + assert encoded[RoborockZeoProtocol.UV_LIGHT] == wire + mock_send.reset_mock() + + +@pytest.mark.asyncio +async def test_dyad_set_value_bool_serialises_as_string(mock_channel): + with patch("roborock.devices.traits.a01.send_decoded_command", new_callable=AsyncMock) as mock_send: + api = DyadApi(mock_channel) + await api.set_value(RoborockDyadDataProtocol.SILENT_MODE, True) + args, kwargs = mock_send.call_args + params = args[1] + encoder = kwargs.get("value_encoder") + encoded = {k: (encoder(v) if encoder else v) for k, v in params.items()} + assert encoded[RoborockDyadDataProtocol.SILENT_MODE] == "True" + + +@pytest.mark.asyncio +async def test_zeo_start_sends_true_and_bundles_core_params(mock_channel): + """start() must send START="True" (DPBoolean.True), not an integer.""" + mock_channel.subscribe = AsyncMock(return_value=Mock()) + + captured = [] + + async def _side_effect(channel, params, **kwargs): + captured.append((params, kwargs)) + if _ID_QUERY_INT in {int(k) for k in params}: + queried = params[_ID_QUERY_INT] + if RoborockZeoProtocol.FEATURE_BITS in queried: + return {int(RoborockZeoProtocol.FEATURE_BITS): 0} # no features + return { + int(RoborockZeoProtocol.MODE): 1, + int(RoborockZeoProtocol.PROGRAM): 1, + int(RoborockZeoProtocol.TEMP): 30, + int(RoborockZeoProtocol.RINSE_TIMES): 2, + int(RoborockZeoProtocol.SPIN_LEVEL): 800, + int(RoborockZeoProtocol.DRYING_MODE): 1, + } + return {} + + with ( + patch("roborock.devices.traits.a01.send_decoded_command", new_callable=AsyncMock, side_effect=_side_effect), + patch( + "roborock.devices.traits.a01.device_features.send_decoded_command", + new_callable=AsyncMock, + side_effect=_side_effect, + ), + ): + api = ZeoApi(mock_channel) + await api.start() + + # The SET (non-query) command is the one that carries START. + set_calls = [params for params, _ in captured if _ID_QUERY_INT not in {int(k) for k in params}] + assert set_calls, "start() should issue a SET command" + start_params = set_calls[-1] + assert start_params[RoborockZeoProtocol.START] == "True" + # Core numeric params travel as integers, not strings. + assert start_params[RoborockZeoProtocol.MODE] == 1 + assert start_params[RoborockZeoProtocol.PROGRAM] == 1 + + +@pytest.mark.asyncio +async def test_zeo_start_feature_gated_dps_batched(mock_channel): + """Feature-gated DPs must be queried in a single batch, not one-by-one.""" + mock_channel.subscribe = AsyncMock(return_value=Mock()) + + query_call_count = 0 + + async def _side_effect(channel, params, **kwargs): + nonlocal query_call_count + if _ID_QUERY_INT in {int(k) for k in params}: + queried = params[_ID_QUERY_INT] + if RoborockZeoProtocol.FEATURE_BITS in queried: + query_call_count += 1 + # Enable both wash_dry_linkage (bit 22) and ion_deodorization (bit 23) + raw_bits = (1 << 22) | (1 << 23) + return {int(RoborockZeoProtocol.FEATURE_BITS): raw_bits} + # Feature-gated batch query — capture it and return empty + query_call_count += 1 + return {} + # SET — let through + query_call_count += 1 + return {} + + # Pre-populate cache with START params so _get_start_params() doesn't + # issue an extra query that would distort the count. + with ( + patch("roborock.devices.traits.a01.send_decoded_command", new_callable=AsyncMock, side_effect=_side_effect), + patch( + "roborock.devices.traits.a01.device_features.send_decoded_command", + new_callable=AsyncMock, + side_effect=_side_effect, + ), + ): + api = ZeoApi(mock_channel) + # Seed the DPS cache with required START params so only FEATURE_BITS + # and feature-gated queries are counted. + api._dps_cache.update( + { + int(RoborockZeoProtocol.MODE): 1, + int(RoborockZeoProtocol.PROGRAM): 1, + int(RoborockZeoProtocol.TEMP): 30, + int(RoborockZeoProtocol.RINSE_TIMES): 2, + int(RoborockZeoProtocol.SPIN_LEVEL): 800, + int(RoborockZeoProtocol.DRYING_MODE): 1, + } + ) + await api.start() + + # FEATURE_BITS query (1) + batch gated-DP query (1) + SET (1) = 3 total calls. + # If not batched, there would be 1 + 2 + 1 = 4 calls. + assert query_call_count == 3, f"Expected 3 queries (batched), got {query_call_count}" + + +def test_zeo_api_close_unsubscribes_from_channel(mock_channel): + """close() must call the unsubscribe function and clear it.""" + unsub_mock = Mock() + mock_channel.subscribe.return_value = unsub_mock + + import asyncio + + async def _init(): + api = ZeoApi(mock_channel) + await api._ensure_subscribed() + assert api._dps_unsub is not None + api.close() + unsub_mock.assert_called_once() + assert api._dps_unsub is None + + asyncio.run(_init()) + + +def test_zeo_api_close_idempotent(mock_channel): + """Calling close() multiple times must not fail.""" + mock_channel.subscribe.return_value = Mock() + + import asyncio + + async def _init(): + api = ZeoApi(mock_channel) + await api._ensure_subscribed() + api.close() + api.close() # Should not raise + + asyncio.run(_init()) diff --git a/tests/devices/traits/a01/test_init.py b/tests/devices/traits/a01/test_init.py index 8e1cb7dd8..7e2fd6a69 100644 --- a/tests/devices/traits/a01/test_init.py +++ b/tests/devices/traits/a01/test_init.py @@ -77,7 +77,8 @@ async def test_dyad_api_query_values(dyad_api: DyadApi, fake_channel: FakeChanne assert message.protocol == RoborockMessageProtocol.RPC_REQUEST assert message.version == b"A01" payload_data = json.loads(unpad(message.payload, AES.block_size)) - assert payload_data == {"dps": {"10000": "[209, 201, 207, 214, 215, 227, 229, 230, 222, 224]"}} + assert payload_data["dps"] == {"10000": "[209, 201, 207, 214, 215, 227, 229, 230, 222, 224]"} + assert "t" in payload_data @pytest.mark.parametrize( @@ -174,7 +175,8 @@ async def test_zeo_api_query_values(zeo_api: ZeoApi, fake_channel: FakeChannel): assert message.protocol == RoborockMessageProtocol.RPC_REQUEST assert message.version == b"A01" payload_data = json.loads(unpad(message.payload, AES.block_size)) - assert payload_data == {"dps": {"10000": "[203, 207, 226, 227, 224, 218]"}} + assert payload_data["dps"] == {"10000": "[203, 207, 226, 227, 224, 218]"} + assert "t" in payload_data @pytest.mark.parametrize( @@ -245,7 +247,8 @@ async def test_dyad_api_set_value(dyad_api: DyadApi, fake_channel: FakeChannel): # decode the payload to verify contents payload_data = json.loads(unpad(message.payload, AES.block_size)) # A01 protocol expects values to be strings in the dps dict - assert payload_data == {"dps": {"209": 1}} + assert payload_data["dps"] == {"209": 1} + assert "t" in payload_data async def test_zeo_api_set_value(zeo_api: ZeoApi, fake_channel: FakeChannel): @@ -261,4 +264,5 @@ async def test_zeo_api_set_value(zeo_api: ZeoApi, fake_channel: FakeChannel): # decode the payload to verify contents payload_data = json.loads(unpad(message.payload, AES.block_size)) # A01 protocol expects values to be strings in the dps dict - assert payload_data == {"dps": {"204": "standard"}} + assert payload_data["dps"] == {"204": "standard"} + assert "t" in payload_data diff --git a/tests/e2e/__snapshots__/test_device_manager.ambr b/tests/e2e/__snapshots__/test_device_manager.ambr index 90f2398dc..157f89c38 100644 --- a/tests/e2e/__snapshots__/test_device_manager.ambr +++ b/tests/e2e/__snapshots__/test_device_manager.ambr @@ -13,12 +13,13 @@ [mqtt <] 00000000 90 04 00 01 00 00 |......| [mqtt >] - 00000000 30 5a 00 20 72 72 2f 6d 2f 69 2f 75 73 65 72 31 |0Z. rr/m/i/user1| + 00000000 30 6a 00 20 72 72 2f 6d 2f 69 2f 75 73 65 72 31 |0j. rr/m/i/user1| 00000010 32 33 2f 31 39 36 34 38 66 39 34 2f 7a 65 6f 5f |23/19648f94/zeo_| 00000020 64 75 69 64 00 41 30 31 00 00 23 82 00 00 23 83 |duid.A01..#...#.| - 00000030 68 a6 a2 24 00 65 00 20 c5 de 2b f6 a9 ba 32 7e |h..$.e. ..+...2~| - 00000040 6b 73 82 bb d8 67 d4 db 7e cd 61 aa 8c 38 56 53 |ks...g..~.a..8VS| - 00000050 ca 4e 15 0d b1 b7 80 a2 0f 16 58 36 |.N........X6| + 00000030 68 a6 a2 24 00 65 00 30 c5 de 2b f6 a9 ba 32 7e |h..$.e.0..+...2~| + 00000040 6b 73 82 bb d8 67 d4 db 7d 80 60 67 80 96 b8 a1 |ks...g..}.`g....| + 00000050 c6 bc 9e d2 da 07 fb d3 79 f5 6f 6d 04 9c 71 00 |........y.om..q.| + 00000060 48 66 3d 7e 5d fe d3 df 18 e4 26 38 |Hf=~].....&8| [mqtt <] 00000000 30 5e 00 20 72 72 2f 6d 2f 6f 2f 75 73 65 72 31 |0^. rr/m/o/user1| 00000010 32 33 2f 31 39 36 34 38 66 39 34 2f 7a 65 6f 5f |23/19648f94/zeo_| diff --git a/tests/fixtures/logging_fixtures.py b/tests/fixtures/logging_fixtures.py index e267f0488..136326983 100644 --- a/tests/fixtures/logging_fixtures.py +++ b/tests/fixtures/logging_fixtures.py @@ -52,6 +52,7 @@ def get_token_bytes(n: int) -> bytes: with ( patch("roborock.devices.transport.local_channel.get_next_int", side_effect=get_next_int), + patch("roborock.protocols.a01_protocol.time.time", return_value=1755750947.0), patch("roborock.protocols.b01_q7_protocol.get_next_int", side_effect=get_next_int), patch("roborock.protocols.v1_protocol.get_next_int", side_effect=get_next_int), patch("roborock.protocols.v1_protocol.get_timestamp", side_effect=get_timestamp),