From 0f9bad2c946492d8da02e84f8f925045d9f73fe5 Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Mon, 20 Jul 2026 11:39:56 +0800 Subject: [PATCH 1/4] 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/4] =?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/4] 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/4] 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)