From 0f9bad2c946492d8da02e84f8f925045d9f73fe5 Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Mon, 20 Jul 2026 11:39:56 +0800 Subject: [PATCH 1/8] feat: add MQTT QoS support and timestamp to A01 protocol payload Add MqttQos enum (AT_MOST_ONCE=0, AT_LEAST_ONCE=1, EXACTLY_ONCE=2) and thread a qos parameter through the publish chain (MqttSession -> MqttChannel -> send_decoded_command). All existing callers keep default AT_MOST_ONCE (backward compatible). Also add a unix timestamp field to A01 encode_mqtt_payload, required by Zeo/Dyad devices for command acceptance. --- roborock/devices/rpc/a01_channel.py | 15 +++++++++-- roborock/devices/transport/mqtt_channel.py | 10 ++++--- roborock/mqtt/roborock_session.py | 18 ++++++++----- roborock/mqtt/session.py | 26 ++++++++++++++++++- roborock/protocols/a01_protocol.py | 6 ++++- roborock/testing/channel.py | 7 ++++- tests/devices/traits/a01/test_init.py | 12 ++++++--- .../__snapshots__/test_device_manager.ambr | 9 ++++--- tests/fixtures/logging_fixtures.py | 1 + 9 files changed, 82 insertions(+), 22 deletions(-) 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/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/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/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), From 9077983cbc115b66dffef127dcf4001e93d19795 Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Mon, 20 Jul 2026 13:17:31 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat!:=20full=20Zeo=20protocol=20definition?= =?UTF-8?q?=20=E2=80=94=2067=20DPs,=20complete=20enum=20mappings,=20all=20?= =?UTF-8?q?56=20devices=20covered?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand RoborockZeoProtocol from 31 to 67 DP entries, ordered by numeric ID. Add all missing enum classes (ZeoFeatureBits, ZeoDryingMethod, ZeoSteamVolume, ZeoDryAndCare, ZeoDryerStartError) and extend existing enums to cover every state/value found in the official app plugin bundle. Add ZeoStartParams, ZeoCustomMode, and ZeoDryerCustomMode data containers inheriting from RoborockBase, placed in zeo_containers.py per reviewer guidance. --- roborock/data/zeo/zeo_code_mappings.py | 356 ++++++++++++++++++------- roborock/data/zeo/zeo_containers.py | 139 ++++++++++ roborock/roborock_message.py | 113 +++++--- 3 files changed, 489 insertions(+), 119 deletions(-) diff --git a/roborock/data/zeo/zeo_code_mappings.py b/roborock/data/zeo/zeo_code_mappings.py index 4e5c79d16..76b8d7b8f 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_things = 37 + 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/data/zeo/zeo_containers.py b/roborock/data/zeo/zeo_containers.py index e69de29bb..00d8017ce 100644 --- a/roborock/data/zeo/zeo_containers.py +++ b/roborock/data/zeo/zeo_containers.py @@ -0,0 +1,139 @@ +"""Data containers for Zeo (washing machine / dryer) devices.""" + +from dataclasses import dataclass + +from ..containers import RoborockBase + + +@dataclass +class ZeoStartParams(RoborockBase): + """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(RoborockBase): + """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(RoborockBase): + """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, + ) diff --git a/roborock/roborock_message.py b/roborock/roborock_message.py index b78ce5bab..82b14ee57 100644 --- a/roborock/roborock_message.py +++ b/roborock/roborock_message.py @@ -125,45 +125,98 @@ class RoborockDyadDataProtocol(RoborockEnum): class RoborockZeoProtocol(RoborockEnum): - START = 200 # rw + """Zeo device Data Point protocol IDs (200-266) and meta/RPC commands (10000+). + + Comment tags: + ro — read-only + rw — read-write + wo — write-only + [startWith] — must be bundled with START via start() + [independent] — immediate effect, works via set_value() + """ + + # ── DP 200-266 ───────────────────────────────────────────────────── + START = 200 # rw [action → start()] PAUSE = 201 # rw SHUTDOWN = 202 # rw 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 + MODE = 204 # rw [startWith] + PROGRAM = 205 # rw [startWith] + CHILD_LOCK = 206 # rw [independent] + TEMP = 207 # rw [startWith] + RINSE_TIMES = 208 # rw [startWith] + SPIN_LEVEL = 209 # rw [startWith] + DRYING_MODE = 210 # rw [startWith] + 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] + DIRT_DETECTION_STATUS = 216 # ro + COUNTDOWN = 217 # rw [independent] also used in start_with_preset() 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 + 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] TIMES_AFTER_CLEAN = 224 # ro - DEFAULT_SETTING = 225 # rw + DEFAULT_SETTING = 225 # rw [independent] 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 + 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 + APP_AUTHORIZATION = 232 # ro + SOAK = 233 # rw [startWith] + TOTAL_TIME = 234 # ro used in dryer startWith payload + SMART_HOSTING = 235 # rw [independent] + SMART_HOSTING_TIME = 236 # ro + FEATURE_BITS = 237 # ro decoded by ZeoFeatureBits + SMART_HOSTING_WAITED_TIME = 238 # ro + CUSTOM_PROGRAM_CLEANING_TIME = 239 # ro + 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 + DRY_CARE_MODE = 244 # rw [startWith] + SOFTENER_EXPANSION_TYPE = 245 # rw [independent] + SMILE_LIGHT_STATUS = 247 # rw [independent] + DETERGENT_EXPANSION_TYPE = 248 # rw [independent] + FLUFF_CLEANED = 249 # rw [independent] + IS_NEED_FLUFF_CLEAN = 250 # ro + POWER_LIGHT = 251 # rw [independent] + PANEL_PROGRAM_PARAMS_SET = 252 # rw [independent] + PANEL_PROGRAM_PARAMS_SET_RESULT = 253 # ro + SAVE_ADAPTED_CLOUD_PROGRAM = 254 # rw [independent] + 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] + 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 + 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): From 2d4a191d38c81cc40850f79fa230e39c24f5afd1 Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Tue, 21 Jul 2026 16:38:44 +0800 Subject: [PATCH 3/8] refactor(zeo): replace raw integer fields with enum types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update ZeoStartParams, ZeoCustomMode, and ZeoDryerCustomMode to use typed enum fields (ZeoMode, ZeoProgram, ZeoTemperature, etc.) instead of raw int, aligning with the V1 container pattern in v1_containers.py. Rename shorthand fields (rinse_times→rinse, spin_level→spin) for consistency across all three classes. Unify drying-mode field naming. --- roborock/data/zeo/zeo_containers.py | 91 +++++++++++++++-------------- 1 file changed, 46 insertions(+), 45 deletions(-) diff --git a/roborock/data/zeo/zeo_containers.py b/roborock/data/zeo/zeo_containers.py index 00d8017ce..5711cc1f9 100644 --- a/roborock/data/zeo/zeo_containers.py +++ b/roborock/data/zeo/zeo_containers.py @@ -3,6 +3,18 @@ from dataclasses import dataclass from ..containers import RoborockBase +from .zeo_code_mappings import ( + ZeoDryAndCare, + ZeoDryingMethod, + ZeoDryingMode, + ZeoMode, + ZeoProgram, + ZeoRinse, + ZeoSoak, + ZeoSpin, + ZeoSteamVolume, + ZeoTemperature, +) @dataclass @@ -14,23 +26,12 @@ class ZeoStartParams(RoborockBase): 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).""" + mode: ZeoMode + program: ZeoProgram + temperature: ZeoTemperature | None = None + rinse: ZeoRinse | None = None + spin: ZeoSpin | None = None + drying_mode: ZeoDryingMode | None = None # ── DP 222 (LoadCloudProgram) bitfield decoder ────────────────────────── @@ -48,31 +49,31 @@ class ZeoCustomMode(RoborockBase): always non-negative). """ - program: int + program: ZeoProgram """Wash program (bits 0-7).""" - mode: int + mode: ZeoMode """Wash mode (bits 8-9).""" - temperature: int + temperature: ZeoTemperature """Temperature (bits 10-12).""" - rinse: int + rinse: ZeoRinse """Rinse cycle (bits 13-15).""" - spin: int + spin: ZeoSpin """Spin speed (bits 16-18).""" - dry: int + drying_mode: ZeoDryingMode """Drying mode (bits 19-21).""" - soak: int + soak: ZeoSoak """Soak (bits 22-24).""" - dry_care_mode: int + dry_and_care: ZeoDryAndCare """Dry-care mode (bits 25-27).""" - steam_volume: int + steam_volume: ZeoSteamVolume """Steam volume (bits 28-30).""" total_time_min: int = 0 @@ -82,15 +83,15 @@ class ZeoCustomMode(RoborockBase): 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, + program=ZeoProgram(raw & 0xFF), + mode=ZeoMode((raw >> 8) & 0x3), + temperature=ZeoTemperature((raw >> 10) & 0x7), + rinse=ZeoRinse((raw >> 13) & 0x7), + spin=ZeoSpin((raw >> 16) & 0x7), + drying_mode=ZeoDryingMode((raw >> 19) & 0x7), + soak=ZeoSoak((raw >> 22) & 0x7), + dry_and_care=ZeoDryAndCare((raw >> 25) & 0x7), + steam_volume=ZeoSteamVolume((raw >> 28) & 0x7), total_time_min=total_time_min or 0, ) @@ -104,19 +105,19 @@ class ZeoDryerCustomMode(RoborockBase): in module 725 of the plugin bundle. """ - program: int + program: ZeoProgram """Drying program (bits 0-7).""" - mode: int + mode: ZeoMode """Drying mode (bits 8-10).""" - dry: int + drying_mode: ZeoDryingMode """Drying level (bits 11-13).""" - dry_method: int + drying_method: ZeoDryingMethod """Drying method (bits 14-16).""" - steam_volume: int + steam_volume: ZeoSteamVolume """Steam volume (bits 17-19).""" total_time_min: int = 0 @@ -126,14 +127,14 @@ class ZeoDryerCustomMode(RoborockBase): 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), + program=ZeoProgram(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, + mode=ZeoMode((raw >> 8) & 0x7), + drying_mode=ZeoDryingMode((raw >> 11) & 0x7), + drying_method=ZeoDryingMethod((raw >> 14) & 0x7), + steam_volume=ZeoSteamVolume((raw >> 17) & 0x7), total_time_min=total_time_min or 0, ) From 910375b53de4d0163702f9da1c5727ea3191f5db Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Wed, 22 Jul 2026 21:48:42 +0800 Subject: [PATCH 4/8] feat(zeo): add MQTT push subscription with DPS cache and feature discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subscribes to the device DPS MQTT topic after connection. Incoming RPC_RESPONSE messages are decoded and merged into _dps_cache with incremental updates. _discover_features() queries FEATURE_BITS (DP 237) to wake the device and cache capabilities — equivalent to V1's discover_features(). Also fixes TraitUpdateListener init in ZeoApi and a01_properties routing in connect(). --- roborock/devices/device.py | 2 + roborock/devices/traits/a01/__init__.py | 79 ++++++++++++++++++- roborock/roborock_message.py | 3 + .../__snapshots__/test_device_manager.ambr | 27 +++++-- tests/e2e/test_device_manager.py | 6 +- 5 files changed, 107 insertions(+), 10 deletions(-) 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..ad15ef646 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 @@ -40,6 +41,7 @@ ZeoDetergentType, ZeoDryingMode, ZeoError, + ZeoFeatureBits, ZeoMode, ZeoProgram, ZeoRinse, @@ -50,8 +52,18 @@ ) 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, +) + +_LOGGER = logging.getLogger(__name__) __init__ = [ "DyadApi", @@ -156,14 +168,74 @@ 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: """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 + + async def start(self) -> None: + """Subscribe to MQTT push and discover device features. + + Subscribes to the DPS MQTT topic, then queries FEATURE_BITS + (DP 237) to wake the device and cache supported 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 to wake the device and cache capabilities. + + Sending an RPC query after subscribing triggers the device to + start pushing its full state — equivalent to how V1's + ``discover_features()`` uses ``device_features.refresh()`` to + initiate the push cycle. + """ + 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 +244,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]: diff --git a/roborock/roborock_message.py b/roborock/roborock_message.py index 82b14ee57..9fab9357a 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 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) From 169a6a94198f95e312846a5a6965ea7184d79947 Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Tue, 28 Jul 2026 09:14:13 +0800 Subject: [PATCH 5/8] fix(zeo): narrow exceptions, tighten try/except scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit except Exception → except RoborockException (aligns with Bundle's silent fallback to 0) try/except only wraps decode_rpc_response — cache updates and notify must propagate --- roborock/devices/device.py | 2 +- roborock/devices/traits/a01/__init__.py | 45 +++++++++++++------------ 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/roborock/devices/device.py b/roborock/devices/device.py index eaf2d6306..3e6c7df6d 100644 --- a/roborock/devices/device.py +++ b/roborock/devices/device.py @@ -202,7 +202,7 @@ 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: + elif self.zeo is not None: await self.zeo.start() except RoborockException: # Expected: start() can fail transiently. Unsubscribe before propagating diff --git a/roborock/devices/traits/a01/__init__.py b/roborock/devices/traits/a01/__init__.py index ad15ef646..ae98418ad 100644 --- a/roborock/devices/traits/a01/__init__.py +++ b/roborock/devices/traits/a01/__init__.py @@ -54,7 +54,7 @@ from roborock.devices.traits import Trait from roborock.devices.traits.common import TraitUpdateListener from roborock.devices.transport.mqtt_channel import MqttChannel -from roborock.exceptions import RoborockException +from roborock.exceptions import RoborockException, RoborockTimeout from roborock.protocols.a01_protocol import decode_rpc_response from roborock.roborock_message import ( RoborockDyadDataProtocol, @@ -104,6 +104,14 @@ RoborockDyadDataProtocol.PRODUCT_INFO: lambda val: DyadProductInfo.from_dict(val), } +# Devices known to lack FEATURE_BITS (DP 237). +_UNSUPPORTED_FEATURE_BITS: frozenset[str] = frozenset( + { + "roborock.wm.a63", # H1 + "roborock.wm.a90", # H1 Lite + } +) + ZEO_PROTOCOL_ENTRIES: dict[RoborockZeoProtocol, Callable] = { # read-only RoborockZeoProtocol.STATE: lambda val: ZeoState(val).name, @@ -173,13 +181,14 @@ class ZeoApi(Trait, TraitUpdateListener): 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._product_id = product_id async def start(self) -> None: """Subscribe to MQTT push and discover device features. @@ -205,15 +214,17 @@ async def _ensure_subscribed(self) -> None: async def _discover_features(self) -> None: """Query FEATURE_BITS to wake the device and cache capabilities. - Sending an RPC query after subscribing triggers the device to - start pushing its full state — equivalent to how V1's - ``discover_features()`` uses ``device_features.refresh()`` to - initiate the push cycle. + Only devices that support the FeatureBits DP will respond; + For devices known to lack this DP + the query is skipped entirely; for all other devices a + timeout propagates as a connection error. """ + if self._product_id in _UNSUPPORTED_FEATURE_BITS: + return try: result = await self.query_values([RoborockZeoProtocol.FEATURE_BITS]) self._feature_bits = result.get(RoborockZeoProtocol.FEATURE_BITS, 0) - except Exception: + except RoborockTimeout: self._feature_bits = 0 def supports(self, feature: ZeoFeatureBits) -> bool: @@ -221,21 +232,16 @@ def supports(self, feature: ZeoFeatureBits) -> bool: 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. - """ + """Handle unsolicited MQTT push (protocol 102 — RPC_RESPONSE).""" 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) + _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.""" @@ -244,9 +250,6 @@ 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]: @@ -261,6 +264,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}") From d692352b05681f2b81fbd18dae0b7e2d3395111f Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Wed, 12 Aug 2026 08:59:02 +0800 Subject: [PATCH 6/8] feat(zeo): replace _discover_features with _force_load matching Bundle's forceLoad() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sends ID_QUERY with 28 base DPs (including FEATURE_BITS) in a single round-trip after MQTT subscribe. This triggers a complete state dump from the device 鈥?matching Bundle's startup flow exactly. For devices known to lack FEATURE_BITS (a63, a90), the DP is excluded from the query list. --- roborock/devices/traits/a01/__init__.py | 51 ++- roborock/devices/traits/a01/device_feature.py | 294 ++++++++++++++++++ .../__snapshots__/test_device_manager.ambr | 24 +- 3 files changed, 333 insertions(+), 36 deletions(-) create mode 100644 roborock/devices/traits/a01/device_feature.py diff --git a/roborock/devices/traits/a01/__init__.py b/roborock/devices/traits/a01/__init__.py index b6e39e478..cc4c924a9 100644 --- a/roborock/devices/traits/a01/__init__.py +++ b/roborock/devices/traits/a01/__init__.py @@ -54,9 +54,10 @@ ) 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_force_load_dp_list from roborock.devices.traits.common import TraitUpdateListener from roborock.devices.transport.mqtt_channel import MqttChannel -from roborock.exceptions import RoborockException, RoborockTimeout +from roborock.exceptions import RoborockException from roborock.protocols.a01_protocol import decode_rpc_response from roborock.roborock_message import ( RoborockDyadDataProtocol, @@ -106,14 +107,6 @@ RoborockDyadDataProtocol.PRODUCT_INFO: lambda val: DyadProductInfo.from_dict(val), } -# Devices known to lack FEATURE_BITS (DP 237). -_UNSUPPORTED_FEATURE_BITS: frozenset[str] = frozenset( - { - "roborock.wm.a63", # H1 - "roborock.wm.a90", # H1 Lite - } -) - ZEO_PROTOCOL_ENTRIES: dict[RoborockZeoProtocol, Callable] = { # read-only RoborockZeoProtocol.STATE: lambda val: ZeoState(val).name, @@ -133,6 +126,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), } @@ -210,23 +204,25 @@ class ZeoApi(Trait, TraitUpdateListener): name = "zeo" - def __init__(self, channel: MqttChannel, product_id: str | None = None) -> 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._product_id = product_id + self._model = model async def start(self) -> None: - """Subscribe to MQTT push and discover device features. + """Subscribe to MQTT push and trigger a full state sync. - Subscribes to the DPS MQTT topic, then queries FEATURE_BITS - (DP 237) to wake the device and cache supported capabilities. + Subscribes to the DPS MQTT topic, then sends ID_QUERY with + the base DP list (including FEATURE_BITS). The device responds + with a complete state dump; subsequent changes arrive + via incremental MQTT push. """ await self._ensure_subscribed() - await self._discover_features() + await self._force_load() def close(self) -> None: """Unsubscribe from MQTT push and release resources.""" @@ -240,21 +236,18 @@ async def _ensure_subscribed(self) -> None: return self._dps_unsub = await self._channel.subscribe(self._on_dps_message) - async def _discover_features(self) -> None: - """Query FEATURE_BITS to wake the device and cache capabilities. + async def _force_load(self) -> None: + """Send ID_QUERY with the base DP list to trigger a full state push. - Only devices that support the FeatureBits DP will respond; - For devices known to lack this DP - the query is skipped entirely; for all other devices a - timeout propagates as a connection error. + Uses ``build_force_load_dp_list()`` which selects the correct + base list (washer vs dryer) and appends conditional DPs based + on the device's series (softener, soak, smart-clean, etc.). + For devices known to lack FEATURE_BITS, the DP is excluded + from the query list and ``_feature_bits`` stays at 0. """ - if self._product_id in _UNSUPPORTED_FEATURE_BITS: - return - try: - result = await self.query_values([RoborockZeoProtocol.FEATURE_BITS]) - self._feature_bits = result.get(RoborockZeoProtocol.FEATURE_BITS, 0) - except RoborockTimeout: - self._feature_bits = 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) def supports(self, feature: ZeoFeatureBits) -> bool: """Check whether the device supports a given feature bit.""" @@ -293,6 +286,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, product_id=product.id) + 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 000000000..faafa57e3 --- /dev/null +++ b/roborock/devices/traits/a01/device_feature.py @@ -0,0 +1,294 @@ +from __future__ import annotations + +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 + } +) + +# ── 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 + ] + ) + + # ── Feature-bits-gated DPs (queried immediately, not deferred) ── + if supports_feature_bits(model): + # These are always appended when FEATURE_BITS is supported. + # The actual bitmask is checked later in loadFeatureDps() for + # feature-gated sub-queries, but Bundle appends these upfront. + pass # DP 237 is already in the base list. + + # ── 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 diff --git a/tests/e2e/__snapshots__/test_device_manager.ambr b/tests/e2e/__snapshots__/test_device_manager.ambr index b13ebad91..9f7967b6a 100644 --- a/tests/e2e/__snapshots__/test_device_manager.ambr +++ b/tests/e2e/__snapshots__/test_device_manager.ambr @@ -13,13 +13,23 @@ [mqtt <] 00000000 90 04 00 01 00 00 |......| [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 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| + 00000000 30 8a 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 d0 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 14 a4 ec a6 8a 4d 7d |S^............M}| + 000000f0 89 7a 06 93 38 ec 7b 9b 7d ba 6c 12 f7 f4 5a 41 |.z..8.{.}.l...ZA| + 00000100 c3 55 ff 46 8c 9b b4 80 a7 17 1f 29 dc |.U.F.......).| [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_| From f3b5144c6aed76084208a6cc7281ddfb1c79f24f Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Wed, 12 Aug 2026 17:02:35 +0800 Subject: [PATCH 7/8] fix(zeo): add null=0 to multiple Zeo enums, trim verbose docstrings --- roborock/data/zeo/zeo_code_mappings.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/roborock/data/zeo/zeo_code_mappings.py b/roborock/data/zeo/zeo_code_mappings.py index 76b8d7b8f..ab68bd0e8 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. # From 0f863db9fc0f06996eb287294346aafb5c2739b7 Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Thu, 13 Aug 2026 21:48:08 +0800 Subject: [PATCH 8/8] feat(zeo): implement two-stage force-load and close() cleanup Address reviewer feedback: integrate ZeoApi.close() into RoborockDevice.close() and implement the second-stage feature DP load matching Bundle's loadFeatureDps(). The first force-load now also includes smart-hosting DPs (235/236/238), and a follow-up query fetches feature-gated DPs (silent mode, dry care, smile light, dirt detection, wash/dry linkage, etc.) plus UV light gated by a series whitelist. Feature-load failures are non-fatal. --- roborock/devices/device.py | 2 + roborock/devices/traits/a01/__init__.py | 42 +++++-- roborock/devices/traits/a01/device_feature.py | 112 +++++++++++++++++- .../__snapshots__/test_device_manager.ambr | 11 +- 4 files changed, 149 insertions(+), 18 deletions(-) diff --git a/roborock/devices/device.py b/roborock/devices/device.py index 3e6c7df6d..33509c347 100644 --- a/roborock/devices/device.py +++ b/roborock/devices/device.py @@ -232,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 cc4c924a9..6dd7b3e2c 100644 --- a/roborock/devices/traits/a01/__init__.py +++ b/roborock/devices/traits/a01/__init__.py @@ -54,7 +54,11 @@ ) 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_force_load_dp_list +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 @@ -216,13 +220,15 @@ def __init__(self, channel: MqttChannel, model: str | None = None) -> None: async def start(self) -> None: """Subscribe to MQTT push and trigger a full state sync. - Subscribes to the DPS MQTT topic, then sends ID_QUERY with - the base DP list (including FEATURE_BITS). The device responds - with a complete state dump; subsequent changes arrive - via incremental MQTT push. + 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.""" @@ -239,9 +245,6 @@ async def _ensure_subscribed(self) -> None: async def _force_load(self) -> None: """Send ID_QUERY with the base DP list to trigger a full state push. - Uses ``build_force_load_dp_list()`` which selects the correct - base list (washer vs dryer) and appends conditional DPs based - on the device's series (softener, soak, smart-clean, etc.). For devices known to lack FEATURE_BITS, the DP is excluded from the query list and ``_feature_bits`` stays at 0. """ @@ -249,6 +252,29 @@ async def _force_load(self) -> None: 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)) diff --git a/roborock/devices/traits/a01/device_feature.py b/roborock/devices/traits/a01/device_feature.py index faafa57e3..ae77ccad4 100644 --- a/roborock/devices/traits/a01/device_feature.py +++ b/roborock/devices/traits/a01/device_feature.py @@ -1,5 +1,6 @@ from __future__ import annotations +from roborock.data.zeo.zeo_code_mappings import ZeoFeatureBits from roborock.roborock_message import RoborockZeoProtocol # ── Per-series model ID frozensets ────────────────────────────────────── @@ -142,6 +143,20 @@ } ) +# 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] = [ @@ -268,12 +283,15 @@ def build_force_load_dp_list(model: str | None) -> list[RoborockZeoProtocol]: ] ) - # ── Feature-bits-gated DPs (queried immediately, not deferred) ── + # ── Smart-hosting DPs (always queried when FEATURE_BITS is supported) ── if supports_feature_bits(model): - # These are always appended when FEATURE_BITS is supported. - # The actual bitmask is checked later in loadFeatureDps() for - # feature-gated sub-queries, but Bundle appends these upfront. - pass # DP 237 is already in the base list. + base.extend( + [ + RoborockZeoProtocol.SMART_HOSTING, # 235 + RoborockZeoProtocol.SMART_HOSTING_TIME, # 236 + RoborockZeoProtocol.SMART_HOSTING_WAITED_TIME, # 238 + ] + ) # ── Soak ── if supports_soak(model): @@ -292,3 +310,87 @@ def build_force_load_dp_list(model: str | None) -> list[RoborockZeoProtocol]: 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/tests/e2e/__snapshots__/test_device_manager.ambr b/tests/e2e/__snapshots__/test_device_manager.ambr index 9f7967b6a..6795fa408 100644 --- a/tests/e2e/__snapshots__/test_device_manager.ambr +++ b/tests/e2e/__snapshots__/test_device_manager.ambr @@ -13,10 +13,10 @@ [mqtt <] 00000000 90 04 00 01 00 00 |......| [mqtt >] - 00000000 30 8a 02 00 20 72 72 2f 6d 2f 69 2f 75 73 65 72 |0... rr/m/i/user| + 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 d0 c5 de 2b f6 a9 ba 32 |.h..%.e....+...2| + 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......:...| @@ -27,9 +27,10 @@ 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 14 a4 ec a6 8a 4d 7d |S^............M}| - 000000f0 89 7a 06 93 38 ec 7b 9b 7d ba 6c 12 f7 f4 5a 41 |.z..8.{.}.l...ZA| - 00000100 c3 55 ff 46 8c 9b b4 80 a7 17 1f 29 dc |.U.F.......).| + 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_|