diff --git a/roborock/data/zeo/zeo_code_mappings.py b/roborock/data/zeo/zeo_code_mappings.py index 76b8d7b8..ab68bd0e 100644 --- a/roborock/data/zeo/zeo_code_mappings.py +++ b/roborock/data/zeo/zeo_code_mappings.py @@ -1,8 +1,5 @@ """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 @@ -18,12 +15,7 @@ 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. - """ + """Bit positions in DP 237 (FEATURE_BITS).""" smart_hosting = 0 silent_mode = 1 @@ -52,7 +44,7 @@ class ZeoFeatureBits(RoborockEnum): class ZeoMode(RoborockEnum): - null = 0 # (not found in app bundle) + null = 0 wash = 1 wash_and_dry = 2 dry = 3 @@ -60,6 +52,7 @@ class ZeoMode(RoborockEnum): class ZeoState(RoborockEnum): + null = 0 standby = 1 weighing = 2 # Checking soaking = 3 @@ -83,7 +76,7 @@ class ZeoState(RoborockEnum): class ZeoProgram(RoborockEnum): - null = 0 # (not found in app bundle) + null = 0 standard = 1 # Mixed quick = 2 sanitize = 3 # Sterilization @@ -152,6 +145,7 @@ class ZeoSoak(RoborockEnum): class ZeoTemperature(RoborockEnum): + null = 0 normal = 1 # L1, 0 C low = 2 # L2, 30 C medium = 3 # L3, 40 C @@ -203,11 +197,13 @@ class ZeoSoftenerType(RoborockEnum): class ZeoDetergentExpansionType(RoborockEnum): + null = 0 concentrated_detergent = 1 detergent = 2 class ZeoSoftenerExpansionType(RoborockEnum): + null = 0 softener = 1 softener_expansion = 2 wool_detergent = 3 @@ -241,6 +237,7 @@ class ZeoError(RoborockEnum): class ZeoDryingMethod(RoborockEnum): + null = 0 l1 = 1 # L1, Saving l2 = 2 # L2, Standard l3 = 3 # L3, SuperFast @@ -255,11 +252,13 @@ class ZeoSteamVolume(RoborockEnum): class ZeoDryAndCare(RoborockEnum): + null = 0 soft = 1 normal = 2 class ZeoDryerStartError(RoborockEnum): + null = 0 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. @@ -269,7 +268,7 @@ class ZeoDryerStartError(RoborockEnum): dryer_network_fail = 7 # Please check the dryer network connection. -# The following are App-internal lookup tables extracted from the bundle. +# The following are App-internal lookup tables. # They are NOT DP protocol enums — the device never receives these values. # They control how the official app adjusts recommended dosages or UI visibility. # diff --git a/roborock/devices/device.py b/roborock/devices/device.py index f6226f07..33509c34 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() + elif 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. @@ -230,6 +232,8 @@ async def close(self) -> None: self.v1_properties.close() if self.b01_q10_properties is not None: await self.b01_q10_properties.close() + if self.zeo is not None: + self.zeo.close() if self._unsub: self._unsub() self._unsub = None diff --git a/roborock/devices/traits/a01/__init__.py b/roborock/devices/traits/a01/__init__.py index 3108cc95..6dd7b3e2 100644 --- a/roborock/devices/traits/a01/__init__.py +++ b/roborock/devices/traits/a01/__init__.py @@ -22,6 +22,7 @@ """ import json +import logging from collections.abc import Callable from datetime import time from typing import Any @@ -42,6 +43,7 @@ ZeoDetergentType, ZeoDryingMode, ZeoError, + ZeoFeatureBits, ZeoMode, ZeoProgram, ZeoRinse, @@ -52,10 +54,23 @@ ) from roborock.devices.rpc.a01_channel import send_decoded_command from roborock.devices.traits import Trait +from roborock.devices.traits.a01.device_feature import ( + build_feature_dp_list, + build_force_load_dp_list, + supports_uv_light, +) +from roborock.devices.traits.common import TraitUpdateListener from roborock.devices.transport.mqtt_channel import MqttChannel from roborock.exceptions import RoborockException from roborock.protocols.a01_protocol import decode_rpc_response -from roborock.roborock_message import RoborockDyadDataProtocol, RoborockMessage, RoborockZeoProtocol +from roborock.roborock_message import ( + RoborockDyadDataProtocol, + RoborockMessage, + RoborockMessageProtocol, + RoborockZeoProtocol, +) + +_LOGGER = logging.getLogger(__name__) __init__ = [ "DyadApi", @@ -115,6 +130,7 @@ RoborockZeoProtocol.DETERGENT_TYPE: lambda val: ZeoDetergentType(val).name, RoborockZeoProtocol.SOFTENER_TYPE: lambda val: ZeoSoftenerType(val).name, RoborockZeoProtocol.SOUND_SET: lambda val: bool(val), + RoborockZeoProtocol.FEATURE_BITS: lambda val: int(val), } @@ -187,14 +203,93 @@ def on_message(message: RoborockMessage) -> None: return await self._channel.subscribe(on_message) -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, model: 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._model = model + + async def start(self) -> None: + """Subscribe to MQTT push and trigger a full state sync. + + Subscribes to the DPS MQTT topic, then performs a two-stage + force-load: first the base DP list (including FEATURE_BITS), + then a second query for the DPs gated behind each enabled feature. + The device responds with a complete state dump; + subsequent changes arrive via incremental MQTT push. + """ + await self._ensure_subscribed() + await self._force_load() + await self._load_feature_dps() + + 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 _force_load(self) -> None: + """Send ID_QUERY with the base DP list to trigger a full state push. + + For devices known to lack FEATURE_BITS, the DP is excluded + from the query list and ``_feature_bits`` stays at 0. + """ + dp_list = build_force_load_dp_list(self._model) + result = await self.query_values(dp_list) + self._feature_bits = result.get(RoborockZeoProtocol.FEATURE_BITS, 0) + + async def _load_feature_dps(self) -> None: + """Second-stage query for feature-gated DPs. + + Called unconditionally after the first force-load; each DP is + independently gated: + + - Feature-gated DPs are queried only when their feature bit is set + in FEATURE_BITS (DP 237). + - UV light (DP 228) is gated by :func:`supports_uv_light` (series + whitelist), independent of the feature bits. + """ + feature_dps: list[RoborockZeoProtocol] = [] + if self._feature_bits: + feature_dps.extend(build_feature_dp_list(self._feature_bits)) + if supports_uv_light(self._model): + feature_dps.append(RoborockZeoProtocol.UV_LIGHT) + if not feature_dps: + return + try: + await self.query_values(feature_dps) + except RoborockException as exc: + _LOGGER.warning("Feature DPS load failed (non-fatal): %s", exc) + + 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).""" + if message.protocol != RoborockMessageProtocol.RPC_RESPONSE: + return + try: + decoded = decode_rpc_response(message) + except RoborockException: + _LOGGER.debug("Dropped malformed push message", exc_info=True) + return + self._dps_cache.update(decoded) + self._notify_update() async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[RoborockZeoProtocol, Any]: """Query the device for the values of the given protocols.""" @@ -217,6 +312,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, model=product.model) case _: raise NotImplementedError(f"Unsupported category {product.category}") diff --git a/roborock/devices/traits/a01/device_feature.py b/roborock/devices/traits/a01/device_feature.py new file mode 100644 index 00000000..ae77ccad --- /dev/null +++ b/roborock/devices/traits/a01/device_feature.py @@ -0,0 +1,396 @@ +from __future__ import annotations + +from roborock.data.zeo.zeo_code_mappings import ZeoFeatureBits +from roborock.roborock_message import RoborockZeoProtocol + +# ── Per-series model ID frozensets ────────────────────────────────────── + +_H1_SERIES: frozenset[str] = frozenset( + { + "roborock.wm.a63", # H1 + "roborock.wm.a102", # H1 Overseas + } +) + +_H1_LITE_SERIES: frozenset[str] = frozenset( + { + "roborock.wm.a90", # H1 Lite + "roborock.wm.a91", # H1 Lite Overseas + "roborock.wm.a237", # H1 Lite Resupply + } +) + +_H1C_SERIES: frozenset[str] = frozenset( + { + "roborock.wm.a114", # H1C + "roborock.wm.a242", # H1C Overseas + } +) + +_M1_SERIES: frozenset[str] = frozenset( + { + "roborock.wm.a92", # M1 + "roborock.wm.a93", # M1 Overseas + "roborock.wm.a133", # M1Lite + "roborock.wm.a162", # M1Lite Overseas + "roborock.wm.a233", # M1Lite Plus + "roborock.wm.a234", # M1 Plus + "roborock.wm.a218", # M1 Rev2 + "roborock.wm.a213", # Medusa + "roborock.wm.a276", # M1Lite Plus Rev2 + "roborock.wm.a277", # M1Lite Rev3 + } +) + +_MUSE_SERIES: frozenset[str] = frozenset( + { + "roborock.wm.a142", # Muse + "roborock.wm.a215", # Muse Overseas + "roborock.wm.a211", # Mitty + } +) + +_METIS_SERIES: frozenset[str] = frozenset( + { + "roborock.wm.a154", # Metis + "roborock.wm.a214", # Metis Overseas + } +) + +_HYPERION_SERIES: frozenset[str] = frozenset( + { + "roborock.wm.a141", # Hyperion + "roborock.wm.a149", # HyperionPro + "roborock.wm.a207", # Hyperion Plus + "roborock.wm.a230", # Hyperion Overseas + } +) + +_POSEIDON_SERIES: frozenset[str] = frozenset( + { + "roborock.wm.a180", # Pro + "roborock.wm.a181", + "roborock.wm.a201", # Pro+ + "roborock.wm.a255", # Overseas + } +) + +_HERA_SERIES: frozenset[str] = frozenset( + { + "roborock.wm.a227", # Hera + "roborock.wm.a261", # DE + "roborock.wm.a268", # KR + "roborock.wm.a269", # TW + "roborock.wm.a273", # NO + } +) + +_PANDORA_SERIES: frozenset[str] = frozenset( + { + "roborock.wm.a239", # Pandora + "roborock.wm.a262", # DE + "roborock.wm.a270", # KR + "roborock.wm.a271", # TW + "roborock.wm.a272", # NO + } +) + +_HALIA_SERIES: frozenset[str] = frozenset( + { + "roborock.wm.a240", # Halia + "roborock.wm.a241", # Halia Lite + } +) + +_APOLLO_SERIES: frozenset[str] = frozenset( + { + "roborock.cd.a188", # Apollo Pro+ + "roborock.cd.a204", # Apollo Pro + "roborock.cd.a258", # Apollo Pro Overseas + "roborock.cd.a265", # Apollo Pro+ Overseas + } +) + +# ── Device-type frozensets ────────────────────────────────────────────── + +# All dryer models (cd.* prefix). +_DRYER_PRODUCT_IDS: frozenset[str] = _APOLLO_SERIES + +# Overseas models (supports remote control via DP 232). +_OVERSEAS_PRODUCT_IDS: frozenset[str] = frozenset( + { + "roborock.wm.a102", # H1 Overseas + "roborock.wm.a91", # H1 Lite Overseas + "roborock.wm.a93", # M1 Overseas + "roborock.wm.a162", # M1Lite Overseas + "roborock.wm.a215", # Muse Overseas + "roborock.wm.a214", # Metis Overseas + "roborock.wm.a230", # Hyperion Overseas + "roborock.wm.a242", # H1C Overseas + "roborock.wm.a255", # Poseidon Overseas + "roborock.cd.a258", # Apollo Pro Overseas + "roborock.cd.a265", # Apollo Pro+ Overseas + "roborock.wm.a261", # Hera DE + "roborock.wm.a262", # Pandora DE + } +) + +# Devices known to lack FEATURE_BITS (DP 237). +_UNSUPPORTED_FEATURE_BITS: frozenset[str] = frozenset( + { + "roborock.wm.a63", # H1 + "roborock.wm.a90", # H1 Lite + } +) + +# Series that support UV light (DP 228). +_UV_LIGHT_SERIES: frozenset[str] = ( + _H1_LITE_SERIES # a90, a91, a237 + | _M1_SERIES # a92, a93, a133, a162, a233, a234, a218, a213, a276, a277 + | _MUSE_SERIES # a142, a215, a211 + | _METIS_SERIES # a154, a214 + | _HYPERION_SERIES # a141, a149, a207, a230 + | _POSEIDON_SERIES # a180, a181, a201, a255 + | _APOLLO_SERIES # cd.a188, cd.a204, cd.a258, cd.a265 + | _HALIA_SERIES # a240, a241 + | _HERA_SERIES # a227, a261, a268, a269, a273 + | _PANDORA_SERIES # a239, a262, a270, a271, a272 +) + +# ── Force-load DP lists ───────────────────────────────────────────────── + +_FORCE_LOAD_BASE_WASHER: list[RoborockZeoProtocol] = [ + RoborockZeoProtocol.START, # 200 + RoborockZeoProtocol.PAUSE, # 201 + RoborockZeoProtocol.SHUTDOWN, # 202 + RoborockZeoProtocol.STATE, # 203 + RoborockZeoProtocol.MODE, # 204 + RoborockZeoProtocol.PROGRAM, # 205 + RoborockZeoProtocol.CHILD_LOCK, # 206 + RoborockZeoProtocol.TEMP, # 207 + RoborockZeoProtocol.RINSE_TIMES, # 208 + RoborockZeoProtocol.SPIN_LEVEL, # 209 + RoborockZeoProtocol.DRYING_MODE, # 210 + RoborockZeoProtocol.DETERGENT_SET, # 211 + RoborockZeoProtocol.DETERGENT_TYPE, # 213 + RoborockZeoProtocol.COUNTDOWN, # 217 + RoborockZeoProtocol.WASHING_LEFT, # 218 + RoborockZeoProtocol.DOORLOCK_STATE, # 219 + RoborockZeoProtocol.ERROR, # 220 + RoborockZeoProtocol.CUSTOM_PARAM_SAVE, # 221 + RoborockZeoProtocol.CUSTOM_PARAM_GET, # 222 + RoborockZeoProtocol.SOUND_SET, # 223 + RoborockZeoProtocol.TIMES_AFTER_CLEAN, # 224 + RoborockZeoProtocol.DETERGENT_EMPTY, # 226 + RoborockZeoProtocol.FEATURE_BITS, # 237 + RoborockZeoProtocol.PRODUCT_INFO, # 10005 + RoborockZeoProtocol.WASHING_LOG, # 10008 + RoborockZeoProtocol.OTA_NFO, # 10007 + RoborockZeoProtocol.F_C, # 10001 +] + +_FORCE_LOAD_BASE_DRYER: list[RoborockZeoProtocol] = [ + RoborockZeoProtocol.START, # 200 + RoborockZeoProtocol.PAUSE, # 201 + RoborockZeoProtocol.SHUTDOWN, # 202 + RoborockZeoProtocol.STATE, # 203 + RoborockZeoProtocol.MODE, # 204 + RoborockZeoProtocol.PROGRAM, # 205 + RoborockZeoProtocol.CHILD_LOCK, # 206 + RoborockZeoProtocol.DRYING_MODE, # 210 + RoborockZeoProtocol.COUNTDOWN, # 217 + RoborockZeoProtocol.WASHING_LEFT, # 218 + RoborockZeoProtocol.DOORLOCK_STATE, # 219 + RoborockZeoProtocol.ERROR, # 220 + RoborockZeoProtocol.CUSTOM_PARAM_GET, # 222 + RoborockZeoProtocol.SOUND_SET, # 223 + RoborockZeoProtocol.DRYING_METHOD, # 256 + RoborockZeoProtocol.STEAM_VOLUME, # 257 + RoborockZeoProtocol.FEATURE_BITS, # 237 + RoborockZeoProtocol.PRODUCT_INFO, # 10005 + RoborockZeoProtocol.WASHING_LOG, # 10008 + RoborockZeoProtocol.OTA_NFO, # 10007 + RoborockZeoProtocol.F_C, # 10001 +] + + +# ── Public helpers ────────────────────────────────────────────────────── + + +def is_dryer(model: str | None) -> bool: + """Return True if *model* belongs to a dryer (cd.*).""" + if model is None: + return False + return model in _DRYER_PRODUCT_IDS + + +def has_softener_compartment(model: str | None) -> bool: + """M1 / Muse / Metis and all dryers lack a softener compartment.""" + if model is None: + return True # conservative: assume yes + if model in _DRYER_PRODUCT_IDS: + return False + if model in _M1_SERIES | _MUSE_SERIES | _METIS_SERIES: + return False + return True + + +def supports_feature_bits(model: str | None) -> bool: + """Older entry-level devices (H1 a63, H1 Lite a90) lack DP 237.""" + if model is None: + return True # conservative: assume yes + return model not in _UNSUPPORTED_FEATURE_BITS + + +def supports_remote_control(model: str | None) -> bool: + """Remote control (DP 232) is supported on all overseas models.""" + if model is None: + return False + return model in _OVERSEAS_PRODUCT_IDS + + +def supports_soak(model: str | None) -> bool: + """Soak (DP 233) is supported on M1 / Muse / Hyperion / Poseidon / Halia / Hera / Pandora.""" + if model is None: + return False + return model in ( + _M1_SERIES | _MUSE_SERIES | _HYPERION_SERIES | _POSEIDON_SERIES | _HALIA_SERIES | _HERA_SERIES | _PANDORA_SERIES + ) + + +def supports_smart_clean(model: str | None) -> bool: + """Smart-clean (DP 239) is supported on M1 / Muse / Hyperion / Apollo / Halia / Hera.""" + if model is None: + return False + return model in (_M1_SERIES | _MUSE_SERIES | _HYPERION_SERIES | _APOLLO_SERIES | _HALIA_SERIES | _HERA_SERIES) + + +def build_force_load_dp_list(model: str | None) -> list[RoborockZeoProtocol]: + """Return the complete DP list for ``_force_load()``.""" + if is_dryer(model): + base = list(_FORCE_LOAD_BASE_DRYER) + else: + base = list(_FORCE_LOAD_BASE_WASHER) + + # ── Softener block (212, 214, 225, 227) ── + if has_softener_compartment(model): + base.extend( + [ + RoborockZeoProtocol.SOFTENER_SET, # 212 + RoborockZeoProtocol.SOFTENER_TYPE, # 214 + RoborockZeoProtocol.DEFAULT_SETTING, # 225 + RoborockZeoProtocol.SOFTENER_EMPTY, # 227 + ] + ) + + # ── Smart-hosting DPs (always queried when FEATURE_BITS is supported) ── + if supports_feature_bits(model): + base.extend( + [ + RoborockZeoProtocol.SMART_HOSTING, # 235 + RoborockZeoProtocol.SMART_HOSTING_TIME, # 236 + RoborockZeoProtocol.SMART_HOSTING_WAITED_TIME, # 238 + ] + ) + + # ── Soak ── + if supports_soak(model): + base.append(RoborockZeoProtocol.SOAK) # 233 + + # ── Smart Clean ── + if supports_smart_clean(model): + base.append(RoborockZeoProtocol.CUSTOM_PROGRAM_CLEANING_TIME) # 239 + + # ── Remote control (overseas-only) ── + if supports_remote_control(model): + base.append(RoborockZeoProtocol.APP_AUTHORIZATION) # 232 + + # ── Strip unsupported FEATURE_BITS ── + if not supports_feature_bits(model): + base = [dp for dp in base if dp != RoborockZeoProtocol.FEATURE_BITS] + + return base + + +# ── Feature-gated DP mapping (matches Bundle's loadFeatureDps()) ───────── +# +# Each entry maps a ZeoFeatureBits flag to the DPs that should only be +# queried when that feature bit is set in DP 237 (FEATURE_BITS). + +_FEATURE_DP_MAP: dict[ZeoFeatureBits, list[RoborockZeoProtocol]] = { + ZeoFeatureBits.silent_mode: [ + RoborockZeoProtocol.SILENT_MODE_ON, # 240 + RoborockZeoProtocol.SILENT_MODE_START_TIME, # 241 + RoborockZeoProtocol.SILENT_MODE_END_TIME, # 242 + ], + ZeoFeatureBits.dry_care: [ + RoborockZeoProtocol.DRY_CARE_MODE, # 244 + ], + ZeoFeatureBits.expand_softener: [ + RoborockZeoProtocol.SOFTENER_EXPANSION_TYPE, # 245 + ], + ZeoFeatureBits.wool_detergent: [ + RoborockZeoProtocol.SOFTENER_EXPANSION_TYPE, # 245 + ], + ZeoFeatureBits.smile_light: [ + RoborockZeoProtocol.SMILE_LIGHT_STATUS, # 247 + ], + ZeoFeatureBits.concentrated_detergent: [ + RoborockZeoProtocol.DETERGENT_EXPANSION_TYPE, # 248 + ], + ZeoFeatureBits.voice_assistant: [ + RoborockZeoProtocol.VOICE_SWITCH, # 10301 + RoborockZeoProtocol.VOICE_VOLUME, # 10009 + RoborockZeoProtocol.VOICE_RECORD_INFO, # 10302 + RoborockZeoProtocol.VOICE_RECORD, # 10303 + RoborockZeoProtocol.SND_STATE, # 10004 + ], + ZeoFeatureBits.fluff_clean_notification: [ + RoborockZeoProtocol.IS_NEED_FLUFF_CLEAN, # 250 + ], + ZeoFeatureBits.power_button_indicator_light: [ + RoborockZeoProtocol.POWER_LIGHT, # 251 + ], + ZeoFeatureBits.dirt_detection: [ + RoborockZeoProtocol.DIRT_DETECTION_SWITCH, # 215 + RoborockZeoProtocol.DIRT_DETECTION_STATUS, # 216 + ], + ZeoFeatureBits.steam_care: [ + RoborockZeoProtocol.STEAM_VOLUME, # 257 + RoborockZeoProtocol.STEAM_CARE_TIME, # 261 + ], + ZeoFeatureBits.wash_dry_linkage: [ + RoborockZeoProtocol.WASH_DRY_LINKED, # 255 + RoborockZeoProtocol.DEVICE_BOUND, # 262 + RoborockZeoProtocol.CLOTH_PUT_IN, # 263 + RoborockZeoProtocol.CLOTH_READY_TO_DRY_COUNT_DOWN, # 264 + RoborockZeoProtocol.START_DRYER_ERROR, # 265 + ], + ZeoFeatureBits.save_panel_program_params: [ + RoborockZeoProtocol.WIFI_LINKAGE_RESET, # 266 + ], +} + + +def build_feature_dp_list(feature_bits: int) -> list[RoborockZeoProtocol]: + """Return the DPs gated behind feature bits enabled in *feature_bits*.""" + dps: list[RoborockZeoProtocol] = [] + for feature, feature_dps in _FEATURE_DP_MAP.items(): + if feature_bits & (1 << feature.value): + dps.extend(feature_dps) + # De-duplicate while preserving order (expand_softener and wool_detergent + # both map to SOFTENER_EXPANSION_TYPE). + seen: set[RoborockZeoProtocol] = set() + unique_dps: list[RoborockZeoProtocol] = [] + for dp in dps: + if dp not in seen: + seen.add(dp) + unique_dps.append(dp) + return unique_dps + + +def supports_uv_light(model: str | None) -> bool: + """Return True if *model* supports UV light (DP 228).""" + if model is None: + return False + return model in _UV_LIGHT_SERIES diff --git a/roborock/roborock_message.py b/roborock/roborock_message.py index 82b14ee5..9fab9357 100644 --- a/roborock/roborock_message.py +++ b/roborock/roborock_message.py @@ -179,6 +179,9 @@ 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] SMILE_LIGHT_STATUS = 247 # rw [independent] diff --git a/tests/e2e/__snapshots__/test_device_manager.ambr b/tests/e2e/__snapshots__/test_device_manager.ambr index 157f89c3..6795fa40 100644 --- a/tests/e2e/__snapshots__/test_device_manager.ambr +++ b/tests/e2e/__snapshots__/test_device_manager.ambr @@ -13,20 +13,46 @@ [mqtt <] 00000000 90 04 00 01 00 00 |......| [mqtt >] + 00000000 30 9a 02 00 20 72 72 2f 6d 2f 69 2f 75 73 65 72 |0... rr/m/i/user| + 00000010 31 32 33 2f 31 39 36 34 38 66 39 34 2f 7a 65 6f |123/19648f94/zeo| + 00000020 5f 64 75 69 64 00 41 30 31 00 00 23 82 00 00 23 |_duid.A01..#...#| + 00000030 83 68 a6 a2 25 00 65 00 e0 c5 de 2b f6 a9 ba 32 |.h..%.e....+...2| + 00000040 7e 6b 73 82 bb d8 67 d4 db ec 77 f5 38 85 be 1c |~ks...g...w.8...| + 00000050 32 75 45 d8 0c b8 58 37 e7 69 2f ef 54 4f d9 22 |2uE...X7.i/.TO."| + 00000060 11 9a 38 97 e7 62 a4 b0 cb fd 9e bb 3a c4 89 f2 |..8..b......:...| + 00000070 9b 21 65 9e 94 52 7e 19 0f b7 bb c7 f2 e8 3d 4d |.!e..R~.......=M| + 00000080 67 e1 ab 9a 2b f3 0e 08 e6 2a 38 81 ea 02 e0 5d |g...+....*8....]| + 00000090 da 59 e9 f6 60 b4 de 9f 53 c1 fd 5b 39 c1 a1 fe |.Y..`...S..[9...| + 000000a0 49 a6 b0 b0 72 13 38 9c 66 8e 38 37 65 2f 46 9c |I...r.8.f.87e/F.| + 000000b0 1b ca a2 fe ae 67 f5 e8 7f ed 8e 62 0c 9f fe 05 |.....g.....b....| + 000000c0 4c 85 f7 8f 50 d7 e1 02 bd 5a fd 70 59 e9 36 d5 |L...P....Z.pY.6.| + 000000d0 29 9d 5f 54 79 f9 d6 c4 fa 04 7e c7 eb 58 07 4c |)._Ty.....~..X.L| + 000000e0 53 5e 93 c8 f2 17 b2 1c 8a 03 82 a4 5f 1b ba b8 |S^.........._...| + 000000f0 23 2a ee 65 af 22 21 ec 8d 16 27 28 09 a2 1c 37 |#*.e."!...'(...7| + 00000100 d4 6d 3f 25 31 11 4c 1b dc 58 86 82 12 10 23 f4 |.m?%1.L..X....#.| + 00000110 8d 6f 60 d4 06 a7 fb 8d e5 7f 06 a6 80 |.o`..........| + [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 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 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| + 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 23 00 66 00 20 c6 d0 06 0c |....h..#.f. ....| + 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 951c2abc..70f17679 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)