diff --git a/roborock/data/zeo/zeo_containers.py b/roborock/data/zeo/zeo_containers.py index 5711cc1f9..d38094ebb 100644 --- a/roborock/data/zeo/zeo_containers.py +++ b/roborock/data/zeo/zeo_containers.py @@ -19,20 +19,31 @@ @dataclass class ZeoStartParams(RoborockBase): - """Parameters that must be bundled with a START command. + """All parameters that may 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`` and ``program`` are mandatory for every device. Every other + field is optional — when ``None`` it is simply omitted from the MQTT + payload, so the same superset works for washers and dryers alike. """ mode: ZeoMode program: ZeoProgram + + # Washer temperature: ZeoTemperature | None = None rinse: ZeoRinse | None = None spin: ZeoSpin | None = None drying_mode: ZeoDryingMode | None = None + # Dryer + drying_method: ZeoDryingMethod | None = None + steam_volume: ZeoSteamVolume | None = None + total_time: int | None = None + + # Optional across both device families + soak: ZeoSoak | None = None + dry_and_care: ZeoDryAndCare | None = None + # ── DP 222 (LoadCloudProgram) bitfield decoder ────────────────────────── # The official app packs all custom-program parameters into a single diff --git a/roborock/devices/device.py b/roborock/devices/device.py index f6226f07a..eaf2d6306 100644 --- a/roborock/devices/device.py +++ b/roborock/devices/device.py @@ -202,6 +202,8 @@ async def connect(self) -> None: await self.v1_properties.start() elif self.b01_q10_properties is not None: await self.b01_q10_properties.start() + if self.zeo is not None: + await self.zeo.start() except RoborockException: # Expected: start() can fail transiently. Unsubscribe before propagating # so the retry by connect_loop() gets a clean channel. diff --git a/roborock/devices/traits/a01/__init__.py b/roborock/devices/traits/a01/__init__.py index 537c84377..01480001f 100644 --- a/roborock/devices/traits/a01/__init__.py +++ b/roborock/devices/traits/a01/__init__.py @@ -20,6 +20,7 @@ """ import json +import logging from collections.abc import Callable from datetime import time from typing import Any @@ -37,25 +38,49 @@ RoborockDyadStateCode, ) from roborock.data.zeo.zeo_code_mappings import ( + ZeoDetergentExpansionType, ZeoDetergentType, + ZeoDirtDetectionStatus, + ZeoDryAndCare, + ZeoDryerStartError, + ZeoDryingMethod, ZeoDryingMode, ZeoError, + ZeoFeatureBits, ZeoMode, ZeoProgram, ZeoRinse, + ZeoSoak, + ZeoSoftenerExpansionType, ZeoSoftenerType, ZeoSpin, ZeoState, + ZeoSteamVolume, ZeoTemperature, ) from roborock.devices.rpc.a01_channel import send_decoded_command from roborock.devices.traits import Trait +from roborock.devices.traits.common import TraitUpdateListener from roborock.devices.transport.mqtt_channel import MqttChannel -from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol +from roborock.exceptions import RoborockException +from roborock.protocols.a01_protocol import decode_rpc_response +from roborock.roborock_message import ( + RoborockDyadDataProtocol, + RoborockMessage, + RoborockMessageProtocol, + RoborockZeoProtocol, +) + +from .command import ZeoCommandTrait # noqa: F401 — re‑export +from .device_features import ZeoFeatures, ZeoFeatureTrait # noqa: F401 — re‑export + +_LOGGER = logging.getLogger(__name__) __init__ = [ "DyadApi", "ZeoApi", + "ZeoCommandTrait", + "ZeoFeatureTrait", ] @@ -92,6 +117,17 @@ RoborockDyadDataProtocol.PRODUCT_INFO: lambda val: DyadProductInfo.from_dict(val), } + +def _try_json(val: Any) -> Any: + """Return *val* parsed as JSON when it is a JSON string, else *val*.""" + if isinstance(val, str): + try: + return json.loads(val) + except (json.JSONDecodeError, TypeError): + pass + return val + + ZEO_PROTOCOL_ENTRIES: dict[RoborockZeoProtocol, Callable] = { # read-only RoborockZeoProtocol.STATE: lambda val: ZeoState(val).name, @@ -101,6 +137,27 @@ RoborockZeoProtocol.TIMES_AFTER_CLEAN: lambda val: int(val), RoborockZeoProtocol.DETERGENT_EMPTY: lambda val: bool(val), RoborockZeoProtocol.SOFTENER_EMPTY: lambda val: bool(val), + 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: lambda val: bool(val), + RoborockZeoProtocol.PANEL_PROGRAM_PARAMS_SET_RESULT: lambda val: int(val), + RoborockZeoProtocol.DEVICE_BOUND: lambda val: bool(val), + RoborockZeoProtocol.CLOTH_PUT_IN: lambda val: bool(val), + RoborockZeoProtocol.CLOTH_READY_TO_DRY_COUNT_DOWN: lambda val: int(val), + RoborockZeoProtocol.START_DRYER_ERROR: lambda val: ZeoDryerStartError(val).name, + RoborockZeoProtocol.DOORLOCK_STATE: lambda val: bool(val), + RoborockZeoProtocol.APP_AUTHORIZATION: lambda val: bool(val), + 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) + RoborockZeoProtocol.PRODUCT_INFO: lambda val: _try_json(val), + RoborockZeoProtocol.WASHING_LOG: lambda val: _try_json(val), + RoborockZeoProtocol.VOICE_RECORD_INFO: lambda val: _try_json(val), + RoborockZeoProtocol.VOICE_RECORD: lambda val: _try_json(val), # read-write RoborockZeoProtocol.MODE: lambda val: ZeoMode(val).name, RoborockZeoProtocol.PROGRAM: lambda val: ZeoProgram(val).name, @@ -111,6 +168,41 @@ RoborockZeoProtocol.DETERGENT_TYPE: lambda val: ZeoDetergentType(val).name, RoborockZeoProtocol.SOFTENER_TYPE: lambda val: ZeoSoftenerType(val).name, RoborockZeoProtocol.SOUND_SET: lambda val: bool(val), + RoborockZeoProtocol.DIRT_DETECTION_SWITCH: lambda val: bool(val), + RoborockZeoProtocol.SOAK: lambda val: ZeoSoak(val).name, + RoborockZeoProtocol.SILENT_MODE_ON: lambda val: bool(val), + 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: lambda val: bool(val), + RoborockZeoProtocol.DRYING_METHOD: lambda val: ZeoDryingMethod(val).name, + RoborockZeoProtocol.STEAM_VOLUME: lambda val: ZeoSteamVolume(val).name, + RoborockZeoProtocol.ION_DEODORIZATION: lambda val: bool(val), + RoborockZeoProtocol.UV_LIGHT: lambda val: bool(val), + RoborockZeoProtocol.SMART_HOSTING: lambda val: bool(val), + RoborockZeoProtocol.SOFTENER_EXPANSION_TYPE: lambda val: ZeoSoftenerExpansionType(val).name, + RoborockZeoProtocol.DETERGENT_EXPANSION_TYPE: lambda val: ZeoDetergentExpansionType(val).name, + RoborockZeoProtocol.SMILE_LIGHT_STATUS: lambda val: bool(val), + RoborockZeoProtocol.POWER_LIGHT: lambda val: bool(val), + 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: lambda val: bool(val), + RoborockZeoProtocol.DETERGENT_SET: lambda val: bool(val), + RoborockZeoProtocol.SOFTENER_SET: lambda val: bool(val), + RoborockZeoProtocol.FLUFF_CLEANED: lambda val: bool(val), + # read-write (int-valued) + 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: lambda val: bool(val), + RoborockZeoProtocol.DETERGENT_VOLUME: lambda val: int(val), + RoborockZeoProtocol.SOFTENER_VOLUME: lambda val: int(val), + # meta — read-write + RoborockZeoProtocol.SET_SOUND_PACKAGE: lambda val: val, + RoborockZeoProtocol.VOICE_VOLUME: lambda val: val, + RoborockZeoProtocol.VOICE_SWITCH: lambda val: bool(val), + RoborockZeoProtocol.VOICE_RECORD_DELETE: lambda val: int(val), } @@ -156,14 +248,78 @@ async def set_value(self, protocol: RoborockDyadDataProtocol, value: Any) -> dic return await send_decoded_command(self._channel, params) -class ZeoApi(Trait): +class ZeoApi(Trait, TraitUpdateListener): """API for interacting with Zeo devices.""" name = "zeo" - def __init__(self, channel: MqttChannel) -> None: + def __init__(self, channel: MqttChannel, product_id: str | None = None) -> None: """Initialize the Zeo API.""" + TraitUpdateListener.__init__(self, _LOGGER) self._channel = channel + self._dps_cache: dict[int, Any] = {} + self._dps_unsub: Callable[[], None] | None = None + self._feature_bits: int = 0 + self._feature_trait = ZeoFeatureTrait(channel, product_id) + self._command: ZeoCommandTrait | None = None + + @property + def command(self) -> ZeoCommandTrait: + """Lazily-built trait for wash-programme commands.""" + if self._command is None: + self._command = ZeoCommandTrait( + channel=self._channel, + dps_cache=self._dps_cache, + feature_trait=self._feature_trait, + proto_entries=ZEO_PROTOCOL_ENTRIES, + ) + return self._command + + async def start(self) -> None: + """Subscribe to MQTT push and discover device capabilities.""" + await self._ensure_subscribed() + await self._discover_features() + + def close(self) -> None: + """Unsubscribe from MQTT push and release resources.""" + 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 (idempotent).""" + if self._dps_unsub is not None: + return + self._dps_unsub = await self._channel.subscribe(self._on_dps_message) + + async def _discover_features(self) -> None: + """Query FEATURE_BITS (DP 237) and cache device capabilities.""" + try: + result = await self.query_values([RoborockZeoProtocol.FEATURE_BITS]) + self._feature_bits = result.get(RoborockZeoProtocol.FEATURE_BITS, 0) + except Exception: + self._feature_bits = 0 + + def supports(self, feature: ZeoFeatureBits) -> bool: + """Check whether the device supports a given feature bit.""" + return bool(self._feature_bits & (1 << feature.value)) + + def _on_dps_message(self, message: RoborockMessage) -> None: + """Handle unsolicited MQTT push (protocol 102 — RPC_RESPONSE). + + Zeo devices broadcast status changes as ``{"dps": {...}}`` JSON + payloads. This callback decodes them and feeds the cache so + that ``query_values`` can skip the device round-trip when the + requested DPs are already up to date. + """ + if message.protocol != RoborockMessageProtocol.RPC_RESPONSE: + return + try: + decoded = decode_rpc_response(message) + self._dps_cache.update(decoded) + self._notify_update() + 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.""" @@ -172,6 +328,9 @@ async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[Robor {RoborockZeoProtocol.ID_QUERY: protocols}, value_encoder=json.dumps, ) + for protocol, value in response.items(): + if value is not None: + self._dps_cache[int(protocol)] = value return {protocol: convert_zeo_value(protocol, response.get(protocol)) for protocol in protocols} async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[RoborockZeoProtocol, Any]: @@ -186,6 +345,6 @@ 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, product_id=product.id) case _: raise NotImplementedError(f"Unsupported category {product.category}") diff --git a/roborock/devices/traits/a01/command.py b/roborock/devices/traits/a01/command.py new file mode 100644 index 000000000..2f70556e6 --- /dev/null +++ b/roborock/devices/traits/a01/command.py @@ -0,0 +1,163 @@ +"""Zeo command trait""" + +import json +import logging +from collections.abc import Callable +from typing import Any + +from roborock.data.zeo.zeo_containers import ZeoStartParams +from roborock.devices.rpc.a01_channel import send_decoded_command +from roborock.devices.traits.a01.device_features import ZeoFeatureTrait +from roborock.devices.transport.mqtt_channel import MqttChannel +from roborock.mqtt.session import MqttQos +from roborock.roborock_message import RoborockZeoProtocol + +_LOGGER = logging.getLogger(__name__) + +_START_PARAM_DPS_WASHER: list[RoborockZeoProtocol] = [ + RoborockZeoProtocol.MODE, + RoborockZeoProtocol.PROGRAM, + RoborockZeoProtocol.TEMP, + RoborockZeoProtocol.RINSE_TIMES, + RoborockZeoProtocol.SPIN_LEVEL, + RoborockZeoProtocol.DRYING_MODE, + RoborockZeoProtocol.DETERGENT_SET, + RoborockZeoProtocol.SOFTENER_SET, + RoborockZeoProtocol.COUNTDOWN, + RoborockZeoProtocol.SOAK, +] + +_START_PARAM_DPS_DRYER: list[RoborockZeoProtocol] = [ + RoborockZeoProtocol.MODE, + RoborockZeoProtocol.PROGRAM, + RoborockZeoProtocol.DRYING_MODE, + RoborockZeoProtocol.TOTAL_TIME, + RoborockZeoProtocol.DRYING_METHOD, + RoborockZeoProtocol.STEAM_VOLUME, + RoborockZeoProtocol.COUNTDOWN, +] + +_FIELD_TO_DP: dict[str, RoborockZeoProtocol] = { + "mode": RoborockZeoProtocol.MODE, + "program": RoborockZeoProtocol.PROGRAM, + "temperature": RoborockZeoProtocol.TEMP, + "rinse": RoborockZeoProtocol.RINSE_TIMES, + "spin": RoborockZeoProtocol.SPIN_LEVEL, + "drying_mode": RoborockZeoProtocol.DRYING_MODE, + "drying_method": RoborockZeoProtocol.DRYING_METHOD, + "steam_volume": RoborockZeoProtocol.STEAM_VOLUME, + "total_time": RoborockZeoProtocol.TOTAL_TIME, + "soak": RoborockZeoProtocol.SOAK, + "dry_and_care": RoborockZeoProtocol.DRY_CARE_MODE, +} + +_FEATURE_GATED_DPS: dict[RoborockZeoProtocol, str] = { + RoborockZeoProtocol.ION_DEODORIZATION: "ion_deodorization", + RoborockZeoProtocol.WASH_DRY_LINKED: "wash_dry_linkage", + RoborockZeoProtocol.SMART_HOSTING: "smart_hosting", +} + + +class ZeoCommandTrait: + """Trait for sending commands to Zeo devices.""" + + def __init__( + self, + *, + channel: MqttChannel, + dps_cache: dict[int, Any], + feature_trait: ZeoFeatureTrait, + proto_entries: dict[RoborockZeoProtocol, Callable], + ) -> None: + """Initialize the command trait.""" + + self._channel = channel + self._dps_cache = dps_cache + self._feature_trait = feature_trait + self._proto_entries = proto_entries + + def _convert_value(self, protocol: RoborockZeoProtocol, value: Any) -> Any: + """Convert a protocol value using the injected entries table.""" + if (converter := self._proto_entries.get(protocol)) is not None: + try: + return converter(value) + except (ValueError, TypeError): + return None + return None + + async def start_program(self) -> dict[RoborockZeoProtocol, Any]: + """Start the device, bundling the current programme parameters.""" + features = self._feature_trait.features + p = await self._get_start_params() + dps: dict[RoborockZeoProtocol, Any] = {RoborockZeoProtocol.START: "True"} + for field_name, dp in _FIELD_TO_DP.items(): + val = getattr(p, field_name) + if val is not None: + dps[dp] = val + for dp, attr_name in _FEATURE_GATED_DPS.items(): + if features is not None and getattr(features, attr_name, False): + val = self._dps_cache.get(int(dp)) + if val is not None: + dps[dp] = val + await send_decoded_command( + self._channel, + dps, + qos=MqttQos.AT_LEAST_ONCE, + value_encoder=lambda x: x, + ) + for dp, v in dps.items(): + self._dps_cache[int(dp)] = v + return {proto: self._convert_value(proto, dps.get(proto)) for proto in dps} + + async def pause(self) -> dict[RoborockZeoProtocol, Any]: + """Pause the current programme (DP 201 = "True").""" + dps = {RoborockZeoProtocol.PAUSE: "True"} + result = await send_decoded_command(self._channel, dps) + self._dps_cache[int(RoborockZeoProtocol.PAUSE)] = 1 + return result + + async def resume(self) -> dict[RoborockZeoProtocol, Any]: + """Start/continue a paused programme (DP 200 = "True"). + Only works while the device is powered on. + """ + dps = {RoborockZeoProtocol.START: "True"} + result = await send_decoded_command(self._channel, dps) + self._dps_cache[int(RoborockZeoProtocol.START)] = 1 + return result + + async def shutdown(self) -> dict[RoborockZeoProtocol, Any]: + """Power off the device (DP 202 = "True"). + Only works while the device is powered on. + """ + dps = {RoborockZeoProtocol.SHUTDOWN: "True"} + result = await send_decoded_command(self._channel, dps) + self._dps_cache[int(RoborockZeoProtocol.SHUTDOWN)] = 1 + return result + + async def _get_start_params(self) -> ZeoStartParams: + """Read programme settings, querying the device on cache miss.""" + cache = self._dps_cache + wanted = _START_PARAM_DPS_DRYER if self._feature_trait.is_dryer else _START_PARAM_DPS_WASHER + need_refresh = int(RoborockZeoProtocol.MODE) not in cache or int(RoborockZeoProtocol.PROGRAM) not in cache + if need_refresh: + raw = await send_decoded_command( + self._channel, + {RoborockZeoProtocol.ID_QUERY: wanted}, + value_encoder=json.dumps, + ) + for dp in wanted: + if (val := raw.get(dp)) is not None: + cache[int(dp)] = val + return ZeoStartParams( + mode=cache[int(RoborockZeoProtocol.MODE)], + program=cache[int(RoborockZeoProtocol.PROGRAM)], + temperature=cache.get(int(RoborockZeoProtocol.TEMP)), + rinse=cache.get(int(RoborockZeoProtocol.RINSE_TIMES)), + spin=cache.get(int(RoborockZeoProtocol.SPIN_LEVEL)), + drying_mode=cache.get(int(RoborockZeoProtocol.DRYING_MODE)), + drying_method=cache.get(int(RoborockZeoProtocol.DRYING_METHOD)), + steam_volume=cache.get(int(RoborockZeoProtocol.STEAM_VOLUME)), + total_time=cache.get(int(RoborockZeoProtocol.TOTAL_TIME)), + soak=cache.get(int(RoborockZeoProtocol.SOAK)), + dry_and_care=cache.get(int(RoborockZeoProtocol.DRY_CARE_MODE)), + ) diff --git a/roborock/devices/traits/a01/device_features.py b/roborock/devices/traits/a01/device_features.py new file mode 100644 index 000000000..bfbb585bc --- /dev/null +++ b/roborock/devices/traits/a01/device_features.py @@ -0,0 +1,185 @@ +"""Zeo device feature discovery trait. + +A dedicated trait that queries DP 237 (FEATURE_BITS) and resolves the +product model against known ID lists — both computed once per session +and cached. +""" + +import json +import logging +from dataclasses import dataclass, fields + +from roborock.data.zeo.zeo_code_mappings import ZeoFeatureBits +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__) + +# ── Product model detection ────────────────────────────────────── + +_DRYER_PRODUCT_IDS: frozenset[str] = frozenset( + { + "roborock.wm.a188", + "roborock.wm.a204", + "roborock.wm.a258", + "roborock.wm.a265", + } +) + +_HYPERION_HALIA_HERA_PRODUCT_IDS: frozenset[str] = frozenset( + { + "roborock.wm.a141", + "roborock.wm.a149", + "roborock.wm.a207", + "roborock.wm.a230", + "roborock.wm.a240", + "roborock.wm.a241", + "roborock.wm.a227", + "roborock.wm.a261", + "roborock.wm.a273", + "roborock.wm.a268", + "roborock.wm.a269", + } +) + +_M1_MUSE_METIS_PRODUCT_IDS: frozenset[str] = frozenset( + { + "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", + "roborock.wm.a142", + "roborock.wm.a215", + "roborock.wm.a154", + "roborock.wm.a214", + } +) + + +@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": + """Parse a raw FEATURE_BITS integer into a ZeoFeatures instance. + + Field names must match members of :class:`ZeoFeatureBits` + one-to-one, so the mapping is derived by name reflection + instead of a hardcoded lookup table. + """ + 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) + + +class ZeoFeatureTrait(Trait): + """Discovers and caches Zeo device capabilities. + + Two sources of capability information are resolved: + + * **Product model ID** (from ``HomeDataProduct.id``) — determines + whether the device is a standalone dryer, Hyperion/Halia/Hera + series, or M1/Muse/Metis series. These are static per model and + do not change across sessions. + * **FEATURE_BITS** (DP 237) — a 24‑bit mask of runtime feature + flags, queried once from the device and parsed into + :class:`ZeoFeatures`. + """ + + name = "zeo_features" + + def __init__(self, channel: MqttChannel, product_id: str | None = None) -> None: + """Initialize the feature trait. + + Args: + channel: The MQTT channel for sending commands. + product_id: The ``HomeDataProduct.id`` string (e.g. + ``"roborock.wm.a234"``). Used for static product‑type + detection. Pass ``None`` when the product is unknown. + """ + self._channel = channel + self._features: ZeoFeatures | None = None + self._product_id = product_id or "" + + # ---------------------------------------------------------------- + # Product type queries — determined from static model ID, + # available immediately without any device round‑trip. + # ---------------------------------------------------------------- + + @property + def is_dryer(self) -> bool: + """Return ``True`` for standalone dryers.""" + return self._product_id in _DRYER_PRODUCT_IDS + + @property + def is_hyperion_halia_hera(self) -> bool: + """Return ``True`` for Hyperion / Halia / Hera series washers.""" + return self._product_id in _HYPERION_HALIA_HERA_PRODUCT_IDS + + @property + def is_m1_muse_metis(self) -> bool: + """Return ``True`` for M1 / Muse / Metis series washers.""" + return self._product_id in _M1_MUSE_METIS_PRODUCT_IDS + + # ---------------------------------------------------------------- + # DP 237 feature bits — queried once, cached in memory. + # ---------------------------------------------------------------- + + @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/roborock_message.py b/roborock/roborock_message.py index 82b14ee57..75663f6b6 100644 --- a/roborock/roborock_message.py +++ b/roborock/roborock_message.py @@ -179,8 +179,12 @@ class RoborockZeoProtocol(RoborockEnum): 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 + UNKNOWN_243 = ( + 243 # unknown, not found in plugin bundle; present in MQTT push from some devices, increments with each push + ) DRY_CARE_MODE = 244 # rw [startWith] SOFTENER_EXPANSION_TYPE = 245 # rw [independent] + UNKNOWN_246 = 246 # not found in bundle SMILE_LIGHT_STATUS = 247 # rw [independent] DETERGENT_EXPANSION_TYPE = 248 # rw [independent] FLUFF_CLEANED = 249 # rw [independent] @@ -193,6 +197,7 @@ class RoborockZeoProtocol(RoborockEnum): DRYING_METHOD = 256 # rw [startWith] STEAM_VOLUME = 257 # rw [startWith] ION_DEODORIZATION = 258 # rw [startWith / feature-gated] + UNKNOWN_259 = 259 # not found in bundle PANEL_TIMING_PROGRAM_PARAMS = 260 # ro STEAM_CARE_TIME = 261 # ro DEVICE_BOUND = 262 # ro diff --git a/tests/e2e/__snapshots__/test_device_manager.ambr b/tests/e2e/__snapshots__/test_device_manager.ambr index 157f89c38..b13ebad91 100644 --- a/tests/e2e/__snapshots__/test_device_manager.ambr +++ b/tests/e2e/__snapshots__/test_device_manager.ambr @@ -16,17 +16,32 @@ 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 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| + 00000030 68 a6 a2 25 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 a3 e2 16 16 7e 83 5f |ks...g..}....~._| + 00000050 bb 3d 2b 79 6b d0 52 9b 60 17 2d f4 06 3b a8 66 |.=+yk.R.`.-..;.f| + 00000060 f7 20 c0 6a c2 94 1e 84 f8 91 41 67 |. .j......Ag| [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_| 00000020 64 75 69 64 00 00 00 00 37 41 30 31 00 00 00 00 |duid....7A01....| - 00000030 00 00 00 17 68 a6 a2 23 00 66 00 20 c6 d0 06 0c |....h..#.f. ....| + 00000030 00 00 00 17 68 a6 a2 23 00 66 00 20 fe 9c 2a 27 |....h..#.f. ..*'| + 00000040 da c4 6b 9f 0e cf 2c 56 ba 5b e6 99 1f a1 29 78 |..k...,V.[....)x| + 00000050 37 37 42 66 bb d5 70 0e d4 c0 a7 d1 7f ef 8b 33 |77Bf..p........3| + [mqtt >] + 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 84 00 00 23 85 |duid.A01..#...#.| + 00000030 68 a6 a2 26 00 65 00 30 00 5e b7 20 93 7b 53 a7 |h..&.e.0.^. .{S.| + 00000040 f9 47 d4 53 49 51 cd 59 c7 92 65 09 f3 7d 20 58 |.G.SIQ.Y..e..} X| + 00000050 c6 9e 25 54 cd 4f ab c5 fc 48 0e 67 4a 97 28 59 |..%T.O...H.gJ.(Y| + 00000060 14 a6 c6 77 39 ab 2a ef 77 d9 9d 63 |...w9.*.w..c| + [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_| + 00000020 64 75 69 64 00 00 00 00 37 41 30 31 00 00 00 00 |duid....7A01....| + 00000030 00 00 00 17 68 a6 a2 24 00 66 00 20 c6 d0 06 0c |....h..$.f. ....| 00000040 04 eb 86 8c 96 8c 51 45 4f 8e 96 93 9e 3d de 35 |......QEO....=.5| - 00000050 bb a3 92 cf 68 49 69 ba 83 25 cc 5d 77 e8 62 8a |....hIi..%.]w.b.| + 00000050 bb a3 92 cf 68 49 69 ba 83 25 cc 5d 43 da 0b 55 |....hIi..%.]C..U| # --- # name: test_l01_device [mqtt >] diff --git a/tests/e2e/test_device_manager.py b/tests/e2e/test_device_manager.py index 951c2abc2..70f17679f 100644 --- a/tests/e2e/test_device_manager.py +++ b/tests/e2e/test_device_manager.py @@ -527,8 +527,10 @@ async def test_a01_device( test_topic = TEST_TOPIC_FORMAT.format(duid="zeo_duid") mqtt_responses: list[bytes] = [ *MQTT_DEFAULT_RESPONSES, - # ACK the Query state call sent below. id is deterministic based on deterministic_message_fixtures - mqtt_packet.gen_publish(test_topic, mid=2, payload=response_builder.build_a01_rpc({"203": 6})), + # ACK the FEATURE_BITS query sent by _discover_features() + mqtt_packet.gen_publish(test_topic, mid=2, payload=response_builder.build_a01_rpc({"237": 1})), + # ACK the Query state call sent below + mqtt_packet.gen_publish(test_topic, mid=3, payload=response_builder.build_a01_rpc({"203": 6})), ] for response in mqtt_responses: push_mqtt_response(response)