From 0f9bad2c946492d8da02e84f8f925045d9f73fe5 Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Mon, 20 Jul 2026 11:39:56 +0800 Subject: [PATCH 01/12] 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 02/12] =?UTF-8?q?feat!:=20full=20Zeo=20protocol=20definiti?= =?UTF-8?q?on=20=E2=80=94=2067=20DPs,=20complete=20enum=20mappings,=20all?= =?UTF-8?q?=2056=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 03/12] 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 04/12] 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 05/12] 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 06/12] 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 07/12] 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 08/12] 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_| From 77161507665d3a458b3ffedbec74e814e3c1b264 Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Sat, 15 Aug 2026 14:27:26 +0800 Subject: [PATCH 09/12] feat(zeo): add A01 wash command/settings/status traits with caller-supplied start params --- roborock/data/zeo/zeo_containers.py | 84 ++- roborock/devices/traits/a01/__init__.py | 191 ++++++- roborock/devices/traits/a01/command.py | 218 ++++++++ roborock/devices/traits/a01/device_feature.py | 76 ++- roborock/devices/traits/a01/settings.py | 519 ++++++++++++++++++ roborock/devices/traits/a01/status.py | 134 +++++ roborock/roborock_message.py | 18 +- 7 files changed, 1213 insertions(+), 27 deletions(-) create mode 100644 roborock/devices/traits/a01/command.py create mode 100644 roborock/devices/traits/a01/settings.py create mode 100644 roborock/devices/traits/a01/status.py diff --git a/roborock/data/zeo/zeo_containers.py b/roborock/data/zeo/zeo_containers.py index 5711cc1f9..019835394 100644 --- a/roborock/data/zeo/zeo_containers.py +++ b/roborock/data/zeo/zeo_containers.py @@ -1,6 +1,7 @@ """Data containers for Zeo (washing machine / dryer) devices.""" -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import Any from ..containers import RoborockBase from .zeo_code_mappings import ( @@ -17,27 +18,91 @@ ) +@dataclass +class ZeoWashRecord(RoborockBase): + """A single entry in the device's wash-history log (DP 10008).""" + + ctrl_type: int = 0 + """Control type of the programme (2 = app, 1 = panel).""" + + prog_type: ZeoProgram | None = None + + category: ZeoMode | None = None + + end_type: int = 0 + """End type (1 = completed, 2 = cancelled/failed).""" + + duration: int = 0 + """Programme duration in minutes (0 for entries that never ran).""" + + t: int = 0 + """Start timestamp (Unix seconds).""" + + end_t: int = 0 + """End timestamp (Unix seconds).""" + + +@dataclass +class ZeoWashLog(RoborockBase): + """Wash-history log reported at DP 10008 (JSON string).""" + + cnt: int = 0 + """Total number of log entries.""" + + wash_cnt: int = 0 + """Total wash count.""" + + dry_cnt: int = 0 + """Total dry count.""" + + wash_dry_cnt: int = 0 + """Total wash-and-dry count.""" + + washes: list[ZeoWashRecord] = field(default_factory=list) + """The individual wash records (oldest → newest).""" + + @dataclass class ZeoStartParams(RoborockBase): - """Parameters that must be bundled with a START command. + """All parameters that may be bundled with a START command. - All Zeo devices require ``mode`` and ``program`` to be sent together - with the start signal. The remaining fields are optional and only - included when the device reports a non-None value. + ``mode`` and ``program`` are mandatory for every device. Every other + field is optional — when ``None`` it is simply omitted from the MQTT + payload, so the same superset works for washers and dryers alike. """ mode: ZeoMode program: ZeoProgram + + # Washer temperature: ZeoTemperature | None = None rinse: ZeoRinse | None = None spin: ZeoSpin | None = None drying_mode: ZeoDryingMode | None = None + # Dryer + drying_method: ZeoDryingMethod | None = None + steam_volume: ZeoSteamVolume | None = None + # Timed-program running duration in minutes (DP 234). + # This is a fixed parameter of the programme config. + # TODO(program-config): once a programme table exists, this should be + # auto-populated from the programme config instead of passed by the caller. + total_time: int | None = None # depends on programme configs + + # Optional across both device families + soak: ZeoSoak | None = None + dry_and_care: ZeoDryAndCare | None = None + + # Feature-gated start options (DP 258 / DP 255). In the Bundle these are + # the programme's config ``defaultIonStatus`` and the UI's + # ``wash_dry_linked`` state + ion_deodorization: bool | None = None + wash_dry_linked: bool | None = None + # ── 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. +# All custom-program parameters are packed into a single +# 32-bit integer at DP 222. @dataclass @@ -101,8 +166,7 @@ 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. + fields after program/mode. """ program: ZeoProgram diff --git a/roborock/devices/traits/a01/__init__.py b/roborock/devices/traits/a01/__init__.py index 6dd7b3e2c..fccaee5bd 100644 --- a/roborock/devices/traits/a01/__init__.py +++ b/roborock/devices/traits/a01/__init__.py @@ -40,25 +40,41 @@ RoborockDyadStateCode, ) from roborock.data.zeo.zeo_code_mappings import ( + ZeoDetergentExpansionType, ZeoDetergentType, + ZeoDirtDetectionStatus, + ZeoDryAndCare, + ZeoDryerStartError, + ZeoDryingMethod, ZeoDryingMode, ZeoError, - ZeoFeatureBits, ZeoMode, ZeoProgram, ZeoRinse, + ZeoSoak, + ZeoSoftenerExpansionType, ZeoSoftenerType, ZeoSpin, ZeoState, + ZeoSteamVolume, ZeoTemperature, ) +from roborock.data.zeo.zeo_containers import ( + ZeoCustomMode, + ZeoDryerCustomMode, +) from roborock.devices.rpc.a01_channel import send_decoded_command from roborock.devices.traits import Trait +from roborock.devices.traits.a01.command import ZeoCommandTrait from roborock.devices.traits.a01.device_feature import ( + ZeoFeatures, build_feature_dp_list, build_force_load_dp_list, + is_dryer, supports_uv_light, ) +from roborock.devices.traits.a01.settings import ZeoSettingTrait +from roborock.devices.traits.a01.status import ZeoStatusTrait from roborock.devices.traits.common import TraitUpdateListener from roborock.devices.transport.mqtt_channel import MqttChannel from roborock.exceptions import RoborockException @@ -75,6 +91,10 @@ __init__ = [ "DyadApi", "ZeoApi", + "ZeoCommandTrait", + "ZeoFeatures", + "ZeoSettingTrait", + "ZeoStatusTrait", ] @@ -111,6 +131,17 @@ RoborockDyadDataProtocol.PRODUCT_INFO: lambda val: DyadProductInfo.from_dict(val), } + +def _try_json(val: Any) -> Any: + """Return *val* parsed as JSON when it is a JSON string, else *val*.""" + if isinstance(val, str): + try: + return json.loads(val) + except ValueError: + pass + return val + + ZEO_PROTOCOL_ENTRIES: dict[RoborockZeoProtocol, Callable] = { # read-only RoborockZeoProtocol.STATE: lambda val: ZeoState(val).name, @@ -120,6 +151,28 @@ RoborockZeoProtocol.TIMES_AFTER_CLEAN: lambda val: int(val), RoborockZeoProtocol.DETERGENT_EMPTY: lambda val: bool(val), RoborockZeoProtocol.SOFTENER_EMPTY: lambda val: bool(val), + RoborockZeoProtocol.DIRT_DETECTION_STATUS: lambda val: ZeoDirtDetectionStatus(val).name, + RoborockZeoProtocol.TOTAL_TIME: lambda val: int(val), + RoborockZeoProtocol.FEATURE_BITS: lambda val: int(val), + RoborockZeoProtocol.SMART_HOSTING_WAITED_TIME: lambda val: int(val), + RoborockZeoProtocol.IS_NEED_FLUFF_CLEAN: lambda val: bool(val), + RoborockZeoProtocol.PANEL_PROGRAM_PARAMS_SET_RESULT: lambda val: int(val), + RoborockZeoProtocol.DEVICE_BOUND: lambda val: bool(val), + RoborockZeoProtocol.CLOTH_PUT_IN: lambda val: bool(val), + RoborockZeoProtocol.CLOTH_READY_TO_DRY_COUNT_DOWN: lambda val: int(val), + RoborockZeoProtocol.START_DRYER_ERROR: lambda val: ZeoDryerStartError(val).name, + RoborockZeoProtocol.DOORLOCK_STATE: lambda val: bool(val), + RoborockZeoProtocol.APP_AUTHORIZATION: lambda val: bool(val), + RoborockZeoProtocol.SMART_HOSTING_TIME: lambda val: int(val), + RoborockZeoProtocol.CUSTOM_PROGRAM_CLEANING_TIME: lambda val: int(val), + RoborockZeoProtocol.PANEL_TIMING_PROGRAM_PARAMS: lambda val: int(val), + RoborockZeoProtocol.STEAM_CARE_TIME: lambda val: int(val), + # meta — read-only (JSON) + RoborockZeoProtocol.PRODUCT_INFO: lambda val: _try_json(val), + RoborockZeoProtocol.WASHING_LOG: lambda val: _try_json(val), + RoborockZeoProtocol.SOUND_PACKAGE_INFO: lambda val: _try_json(val), + RoborockZeoProtocol.VOICE_RECORD_INFO: lambda val: _try_json(val), + RoborockZeoProtocol.VOICE_RECORD: lambda val: _try_json(val), # read-write RoborockZeoProtocol.MODE: lambda val: ZeoMode(val).name, RoborockZeoProtocol.PROGRAM: lambda val: ZeoProgram(val).name, @@ -130,7 +183,41 @@ RoborockZeoProtocol.DETERGENT_TYPE: lambda val: ZeoDetergentType(val).name, RoborockZeoProtocol.SOFTENER_TYPE: lambda val: ZeoSoftenerType(val).name, RoborockZeoProtocol.SOUND_SET: lambda val: bool(val), - RoborockZeoProtocol.FEATURE_BITS: lambda val: int(val), + RoborockZeoProtocol.DIRT_DETECTION_SWITCH: lambda val: bool(val), + RoborockZeoProtocol.SOAK: lambda val: ZeoSoak(val).name, + RoborockZeoProtocol.SILENT_MODE_ON: lambda val: bool(val), + RoborockZeoProtocol.SILENT_MODE_START_TIME: lambda val: int(val), + RoborockZeoProtocol.SILENT_MODE_END_TIME: lambda val: int(val), + RoborockZeoProtocol.DRY_CARE_MODE: lambda val: ZeoDryAndCare(val).name, + RoborockZeoProtocol.WASH_DRY_LINKED: lambda val: bool(val), + RoborockZeoProtocol.DRYING_METHOD: lambda val: ZeoDryingMethod(val).name, + RoborockZeoProtocol.STEAM_VOLUME: lambda val: ZeoSteamVolume(val).name, + RoborockZeoProtocol.ION_DEODORIZATION: lambda val: bool(val), + RoborockZeoProtocol.UV_LIGHT: lambda val: bool(val), + RoborockZeoProtocol.SMART_HOSTING: lambda val: bool(val), + RoborockZeoProtocol.SOFTENER_EXPANSION_TYPE: lambda val: ZeoSoftenerExpansionType(val).name, + RoborockZeoProtocol.DETERGENT_EXPANSION_TYPE: lambda val: ZeoDetergentExpansionType(val).name, + RoborockZeoProtocol.SMILE_LIGHT_STATUS: lambda val: bool(val), + RoborockZeoProtocol.POWER_LIGHT: lambda val: bool(val), + RoborockZeoProtocol.PANEL_PROGRAM_PARAMS_SET: lambda val: int(val), + RoborockZeoProtocol.WIFI_LINKAGE_RESET: lambda val: int(val), + RoborockZeoProtocol.SAVE_ADAPTED_CLOUD_PROGRAM: lambda val: int(val), + RoborockZeoProtocol.CHILD_LOCK: lambda val: bool(val), + RoborockZeoProtocol.DETERGENT_SET: lambda val: bool(val), + RoborockZeoProtocol.SOFTENER_SET: lambda val: bool(val), + RoborockZeoProtocol.FLUFF_CLEANED: lambda val: bool(val), + # read-write (int-valued) + RoborockZeoProtocol.CUSTOM_PARAM_SAVE: lambda val: int(val), + RoborockZeoProtocol.CUSTOM_PARAM_GET: lambda val: int(val), + RoborockZeoProtocol.DEFAULT_SETTING: lambda val: bool(val), + # NOTE: LIGHT_SETTING(229) / DETERGENT_VOLUME(230) / SOFTENER_VOLUME(231) + # are "server schema only" and do NOT exist in the device bundle — they + # have no device-side implementation, so no converters are registered. + # meta — write-only (JSON payloads; echo back as JSON strings) + RoborockZeoProtocol.SET_SOUND_PACKAGE: lambda val: _try_json(val), + RoborockZeoProtocol.VOICE_VOLUME: lambda val: _try_json(val), + RoborockZeoProtocol.VOICE_SWITCH: lambda val: _try_json(val), + RoborockZeoProtocol.VOICE_RECORD_DELETE: lambda val: _try_json(val), } @@ -206,8 +293,6 @@ def on_message(message: RoborockMessage) -> None: class ZeoApi(Trait, TraitUpdateListener): """API for interacting with Zeo devices.""" - name = "zeo" - def __init__(self, channel: MqttChannel, model: str | None = None) -> None: """Initialize the Zeo API.""" TraitUpdateListener.__init__(self, _LOGGER) @@ -215,7 +300,74 @@ def __init__(self, channel: MqttChannel, model: str | None = None) -> None: self._dps_cache: dict[int, Any] = {} self._dps_unsub: Callable[[], None] | None = None self._feature_bits: int = 0 + self._features: ZeoFeatures | None = None self._model = model + self._command: ZeoCommandTrait | None = None + self._settings: ZeoSettingTrait | None = None + self._status: ZeoStatusTrait | None = None + + @property + def features(self) -> ZeoFeatures | None: + """The device capabilities parsed from FEATURE_BITS (DP 237). + + ``None`` until the first force-load completes. + """ + return self._features + + @property + def command(self) -> ZeoCommandTrait: + """Lazily-built trait for wash-programme commands.""" + if self._command is None: + self._command = ZeoCommandTrait( + channel=self._channel, + settings=lambda: self.settings, + features=lambda: self._features, + custom_mode=lambda: self.get_custom_mode(), + ) + return self._command + + @property + def settings(self) -> ZeoSettingTrait: + """Lazily-built typed writable-state/setter trait. + + Holds typed writable state (``mode``, ``temperature``, + ``drying_method``, ...) refreshed from the device push stream, plus + typed setters (``set_temperature(ZeoTemperature)``, ...). Fields that + the device family does not support (e.g. ``temperature`` on a dryer) + remain ``None``. + """ + if self._settings is None: + self._settings = ZeoSettingTrait( + self._channel, + model=self._model, + is_dryer=is_dryer(self._model), + features=lambda: self._features, + ) + return self._settings + + @property + def status(self) -> ZeoStatusTrait: + """Lazily-built typed read-only state trait. + + Holds device-reported read-only state (``state``, ``error``, + ``washing_left``, ``countdown``, tank levels, ...) refreshed from the + MQTT push stream. No setters — this is status only. + """ + if self._status is None: + self._status = ZeoStatusTrait() + return self._status + + def _update_settings_from_dps(self, decoded_dps: dict[int, Any]) -> None: + """Route raw DPS data to the settings/status traits, if built. + + Writable DPs go to :class:`ZeoSettingTrait`; read-only DPs go to + :class:`ZeoStatusTrait`. Both are lazily built, so pushes received + before they exist are re-processed on first access via the raw cache. + """ + if self._settings is not None: + self._settings.update_from_dps(decoded_dps) + if self._status is not None: + self._status.update_from_dps(decoded_dps) async def start(self) -> None: """Subscribe to MQTT push and trigger a full state sync. @@ -251,6 +403,7 @@ async def _force_load(self) -> None: 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) + self._features = ZeoFeatures.from_feature_bits(self._feature_bits) async def _load_feature_dps(self) -> None: """Second-stage query for feature-gated DPs. @@ -275,10 +428,6 @@ async def _load_feature_dps(self) -> None: except RoborockException as exc: _LOGGER.warning("Feature DPS load failed (non-fatal): %s", exc) - def supports(self, feature: ZeoFeatureBits) -> bool: - """Check whether the device supports a given feature bit.""" - return bool(self._feature_bits & (1 << feature.value)) - def _on_dps_message(self, message: RoborockMessage) -> None: """Handle unsolicited MQTT push (protocol 102 — RPC_RESPONSE).""" if message.protocol != RoborockMessageProtocol.RPC_RESPONSE: @@ -289,6 +438,7 @@ def _on_dps_message(self, message: RoborockMessage) -> None: _LOGGER.debug("Dropped malformed push message", exc_info=True) return self._dps_cache.update(decoded) + self._update_settings_from_dps(decoded) self._notify_update() async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[RoborockZeoProtocol, Any]: @@ -298,6 +448,9 @@ async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[Robor {RoborockZeoProtocol.ID_QUERY: protocols}, value_encoder=json.dumps, ) + for protocol in protocols: + if (raw := response.get(protocol)) is not None: + self._dps_cache[int(protocol)] = raw 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]: @@ -305,6 +458,28 @@ async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[Rob params = {protocol: value} return await send_decoded_command(self._channel, params, value_encoder=lambda x: x) + async def get_custom_mode(self) -> ZeoCustomMode | ZeoDryerCustomMode | None: + """Query and decode the current custom programme (DP 222).""" + await self.query_values( + [RoborockZeoProtocol.CUSTOM_PARAM_GET, RoborockZeoProtocol.TOTAL_TIME] + ) + raw = self._dps_cache.get(int(RoborockZeoProtocol.CUSTOM_PARAM_GET)) + if raw is None: + return None + total_time = self._dps_cache.get(int(RoborockZeoProtocol.TOTAL_TIME)) + try: + raw_int = int(raw) + except (TypeError, ValueError): + return None + if is_dryer(self._model): + return ZeoDryerCustomMode.from_raw(raw_int, total_time) + return ZeoCustomMode.from_raw(raw_int, total_time) + + async def update_sound_package_info(self) -> Any: + """Query the sound-package info (DP 10004).""" + result = await self.query_values([RoborockZeoProtocol.SOUND_PACKAGE_INFO]) + return result.get(RoborockZeoProtocol.SOUND_PACKAGE_INFO) + def create(product: HomeDataProduct, mqtt_channel: MqttChannel) -> DyadApi | ZeoApi: """Create traits for A01 devices.""" diff --git a/roborock/devices/traits/a01/command.py b/roborock/devices/traits/a01/command.py new file mode 100644 index 000000000..952588f45 --- /dev/null +++ b/roborock/devices/traits/a01/command.py @@ -0,0 +1,218 @@ +"""Zeo command trait""" + +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING, Any + +from roborock.data.zeo.zeo_containers import ( + ZeoCustomMode, + ZeoDryerCustomMode, + ZeoStartParams, +) +from roborock.devices.rpc.a01_channel import send_decoded_command +from roborock.devices.traits.a01.settings import FeaturesFn, build_param_dps +from roborock.devices.transport.mqtt_channel import MqttChannel +from roborock.mqtt.session import MqttQos +from roborock.roborock_message import RoborockZeoProtocol + +if TYPE_CHECKING: + from roborock.devices.traits.a01.settings import ZeoSettingTrait + +CustomModeFn = Callable[[], Awaitable[ZeoCustomMode | ZeoDryerCustomMode | None]] + +_FEATURE_GATED_DPS: dict[RoborockZeoProtocol, tuple[str, str]] = { + RoborockZeoProtocol.ION_DEODORIZATION: ("ion_deodorization", "ion_deodorization"), + RoborockZeoProtocol.WASH_DRY_LINKED: ("wash_dry_linkage", "wash_dry_linked"), +} + +class ZeoCommandTrait: + """Trait for sending commands to Zeo devices.""" + + def __init__( + self, + *, + channel: MqttChannel, + settings: Callable[[], "ZeoSettingTrait"], + features: FeaturesFn, + custom_mode: CustomModeFn, + ) -> None: + """Initialize the command trait. + + ``settings`` returns the shared :class:`ZeoSettingTrait` holding typed + device state (the single source of truth for auto-dosing and + feature-gated values). ``features`` returns the (lazily loaded) feature + bits. ``custom_mode`` reads and decodes the device's saved custom + programme (DP 222) for :meth:`start_with_custom_mode`. + """ + + self._channel = channel + self._settings = settings + self._features = features + self._custom_mode = custom_mode + + async def start_with(self, params: ZeoStartParams) -> dict[RoborockZeoProtocol, Any]: + """Start the device with the given programme parameters. + + This is the primary start API: the caller needs to provide the + parameters like mode/program/options/etc. + + Returns the DPs that were actually sent. + """ + dps: dict[RoborockZeoProtocol, Any] = {RoborockZeoProtocol.START: 1} + dps.update(build_param_dps(params)) + dps.update(self._build_auto_dosing_dps()) + dps.update(self._build_feature_gated_dps(params)) + await send_decoded_command( + self._channel, + dps, + qos=MqttQos.AT_LEAST_ONCE, + value_encoder=lambda x: x, + ) + return dps + + def _build_auto_dosing_dps(self) -> dict[RoborockZeoProtocol, Any]: + """Map the settings' auto-dosing toggles to wire DPs (211/212). + + Both values are integers 1/0; + ``None`` (e.g. dryers) is omitted. + """ + settings = self._settings() + dps: dict[RoborockZeoProtocol, Any] = {} + if settings.auto_detergent is not None: + dps[RoborockZeoProtocol.DETERGENT_SET] = 1 if settings.auto_detergent else 0 + if settings.auto_softener is not None: + dps[RoborockZeoProtocol.SOFTENER_SET] = 1 if settings.auto_softener else 0 + return dps + + def _build_feature_gated_dps( + self, params: ZeoStartParams, *, include_wash_dry_linked: bool = True + ) -> dict[RoborockZeoProtocol, Any]: + """Map caller-supplied feature-gated booleans to wire DPs (255/258). + + The value comes from ``params`` (the programme's config + ``defaultIonStatus`` / UI ``wash_dry_linked`` state), not from the + device's echo; ``None`` omits the DP. The feature bit only gates + whether the DP is sent at all. + + ``include_wash_dry_linked`` defaults to True for ``startWith``. + """ + features = self._features() + dps: dict[RoborockZeoProtocol, Any] = {} + for dp, (feature_name, field_name) in _FEATURE_GATED_DPS.items(): + if dp is RoborockZeoProtocol.WASH_DRY_LINKED and not include_wash_dry_linked: + continue + if features is not None and getattr(features, feature_name, False): + value = getattr(params, field_name) + if value is not None: + dps[dp] = 1 if value else 0 + return dps + + async def start_with_custom_mode(self) -> dict[RoborockZeoProtocol, Any]: + """Start the device using its saved custom programme. + + Reads the device's custom programme (DP 222, see + :meth:`ZeoApi.get_custom_mode`) and starts with those parameters. + + Raises :class:`ValueError` when the device has no saved custom + programme. + """ + custom = await self._custom_mode() + if custom is None: + raise ValueError("Device has no saved custom programme (DP 222)") + params = _custom_mode_to_start_params(custom) + return await self.start_with(params) + + async def preset_with(self, params: ZeoStartParams, minutes: int) -> dict[RoborockZeoProtocol, Any]: + """Schedule a delayed start with full programme parameters. + + ``COUNTDOWN`` (DP 217) is the **last** DP in the payload, + and its value is the integer number of minutes. + + When *minutes* is ``<= 0`` only ``COUNTDOWN=0`` is sent to cancel an + existing schedule (the sole case that is a single-DP command). + + Device behavior note: a ``COUNTDOWN`` value **> 30** is required to + enter the delay-start countdown state. Smaller values (e.g. 20) cause + the machine to start immediately instead. The caller is responsible + for ensuring *minutes* satisfies this constraint. + + Returns the DPs that were sent. + """ + if minutes <= 0: + dps: dict[RoborockZeoProtocol, Any] = {RoborockZeoProtocol.COUNTDOWN: 0} + await send_decoded_command( + self._channel, + dps, + qos=MqttQos.AT_LEAST_ONCE, + value_encoder=lambda x: x, + ) + return dps + dps: dict[RoborockZeoProtocol, Any] = {RoborockZeoProtocol.START: 1} + dps.update(build_param_dps(params)) + dps.update(self._build_auto_dosing_dps()) + dps.update(self._build_feature_gated_dps(params, include_wash_dry_linked=False)) + dps[RoborockZeoProtocol.COUNTDOWN] = minutes + await send_decoded_command( + self._channel, + dps, + qos=MqttQos.AT_LEAST_ONCE, + value_encoder=lambda x: x, + ) + return dps + + async def pause(self) -> dict[RoborockZeoProtocol, Any]: + """Pause the current programme (DP 201 = 1). + + Returns the DPs that were actually sent. + """ + dps = {RoborockZeoProtocol.PAUSE: 1} + await send_decoded_command(self._channel, dps) + return dps + + async def resume(self) -> dict[RoborockZeoProtocol, Any]: + """Start/continue a paused programme (DP 200 = 1). + + Only works while the device is powered on. Returns the DPs sent. + """ + dps = {RoborockZeoProtocol.START: 1} + await send_decoded_command(self._channel, dps) + return dps + + async def stop(self) -> dict[RoborockZeoProtocol, Any]: + """Stop the current programme (DP 200 = 0).""" + dps = {RoborockZeoProtocol.START: 0} + await send_decoded_command(self._channel, dps) + return dps + + async def shutdown(self) -> dict[RoborockZeoProtocol, Any]: + """Power off the device (DP 202 = 1). + + Only works while the device is powered on. Returns the DPs sent. + """ + dps = {RoborockZeoProtocol.SHUTDOWN: 1} + await send_decoded_command(self._channel, dps) + return dps + + +def _custom_mode_to_start_params( + custom: ZeoCustomMode | ZeoDryerCustomMode, +) -> ZeoStartParams: + """Convert a decoded custom programme into :class:`ZeoStartParams`. + + Washer and dryer custom modes carry the same fields (program, mode, + drying_mode, drying_method, steam_volume) plus wash-only fields + (temperature, rinse, spin, soak, dry_and_care) on the washer layout. + ``total_time_min`` maps to ``total_time`` (the timed-program duration). + """ + return ZeoStartParams( + mode=custom.mode, + program=custom.program, + temperature=getattr(custom, "temperature", None), + rinse=getattr(custom, "rinse", None), + spin=getattr(custom, "spin", None), + drying_mode=custom.drying_mode, + drying_method=getattr(custom, "drying_method", None), + steam_volume=custom.steam_volume, + total_time=custom.total_time_min or None, + soak=getattr(custom, "soak", None), + dry_and_care=getattr(custom, "dry_and_care", None), + ) diff --git a/roborock/devices/traits/a01/device_feature.py b/roborock/devices/traits/a01/device_feature.py index ae77ccad4..2b82844a8 100644 --- a/roborock/devices/traits/a01/device_feature.py +++ b/roborock/devices/traits/a01/device_feature.py @@ -1,5 +1,7 @@ from __future__ import annotations +from dataclasses import dataclass, fields + from roborock.data.zeo.zeo_code_mappings import ZeoFeatureBits from roborock.roborock_message import RoborockZeoProtocol @@ -143,6 +145,55 @@ } ) + +@dataclass +class ZeoFeatures: + """Device capability flags parsed from DP 237 (FEATURE_BITS). + + Field names match :class:`ZeoFeatureBits` members one-to-one, so + :meth:`from_feature_bits` derives the mapping by name reflection + instead of a hardcoded lookup table. + """ + + adapted_custom_program: bool = False + concentrated_detergent: bool = False + deep_self_clean: bool = False + detect_door_status: bool = False + dirt_detection: bool = False + dry_care: bool = False + expand_softener: bool = False + fluff_clean_notification: bool = False + ion_deodorization: bool = False + new_custom_program: bool = False + power_button_indicator_light: bool = False + save_panel_program_params: bool = False + set_params_in_working: bool = False + set_uvc_in_appointment: bool = False + set_uvc_in_pause: bool = False + silent_mode: bool = False + smart_hosting: bool = False + smile_light: bool = False + steam_care: bool = False + thirty_min_soak: bool = False + voice_assistant: bool = False + voice_assistant_record: bool = False + wash_dry_linkage: bool = False + wool_detergent: bool = False + + @classmethod + def from_feature_bits(cls, raw: int) -> ZeoFeatures: + """Parse a raw FEATURE_BITS integer into a :class:`ZeoFeatures`.""" + kwargs: dict[str, bool] = {} + for f in fields(cls): + bit_pos = getattr(ZeoFeatureBits, f.name) + kwargs[f.name] = bool(raw & (1 << int(bit_pos))) + return cls(**kwargs) + + def get_supported_features(self) -> list[str]: + """Return the names of all feature bits enabled on this device.""" + return [name for name, value in vars(self).items() if value] + + # Series that support UV light (DP 228). _UV_LIGHT_SERIES: frozenset[str] = ( _H1_LITE_SERIES # a90, a91, a237 @@ -265,6 +316,29 @@ def supports_smart_clean(model: str | None) -> bool: return model in (_M1_SERIES | _MUSE_SERIES | _HYPERION_SERIES | _APOLLO_SERIES | _HALIA_SERIES | _HERA_SERIES) +def is_addition_type_control_auto_addition(model: str | None) -> bool: + """True when the DetergentType DP itself controls auto-addition.""" + if model is None: + return False + excluded = ( + _H1_SERIES + | _H1_LITE_SERIES + | _HYPERION_SERIES + | _POSEIDON_SERIES + | _HALIA_SERIES + | _HERA_SERIES + | _PANDORA_SERIES + ) + return model not in excluded + + +def is_hyperion_halia_hera(model: str | None) -> bool: + """True for Hyperion / Halia / Hera series.""" + if model is None: + return False + return model in (_HYPERION_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): @@ -343,7 +417,7 @@ def build_force_load_dp_list(model: str | None) -> list[RoborockZeoProtocol]: RoborockZeoProtocol.VOICE_VOLUME, # 10009 RoborockZeoProtocol.VOICE_RECORD_INFO, # 10302 RoborockZeoProtocol.VOICE_RECORD, # 10303 - RoborockZeoProtocol.SND_STATE, # 10004 + RoborockZeoProtocol.SOUND_PACKAGE_INFO, # 10004 ], ZeoFeatureBits.fluff_clean_notification: [ RoborockZeoProtocol.IS_NEED_FLUFF_CLEAN, # 250 diff --git a/roborock/devices/traits/a01/settings.py b/roborock/devices/traits/a01/settings.py new file mode 100644 index 000000000..92fe542c2 --- /dev/null +++ b/roborock/devices/traits/a01/settings.py @@ -0,0 +1,519 @@ +"""Typed writable state and setters for Zeo (washing machine / dryer) devices. + +This module provides ``ZeoSettingTrait``, a single typed trait holding the +*writable* state of a Zeo device (mode, programme, wash/dry parameters, +auto-dosing, switches) plus its typed setters. The trait is shared across +washers and dryers: washers expose wash parameters (temperature, rinse, spin, +drying mode, soak, dry care) and dryers expose drying parameters (drying +method, steam volume). Fields that a given device family does not support +simply remain ``None``. + +Read-only device state (state code, errors, timers, tank levels, ...) lives +in :mod:`roborock.devices.traits.a01.status` (``ZeoStatusTrait``). +""" + +import datetime +import json +import logging +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any, TypeVar + +from roborock.data.code_mappings import RoborockEnum +from roborock.data.containers import RoborockBase +from roborock.data.zeo.zeo_code_mappings import ( + ZeoDetergentExpansionType, + ZeoDetergentType, + ZeoDryAndCare, + ZeoDryingMethod, + ZeoDryingMode, + ZeoMode, + ZeoProgram, + ZeoRinse, + ZeoSoak, + ZeoSoftenerExpansionType, + ZeoSoftenerType, + ZeoSpin, + ZeoSteamVolume, + ZeoTemperature, +) +from roborock.data.zeo.zeo_containers import ZeoStartParams +from roborock.devices.rpc.a01_channel import send_decoded_command +from roborock.devices.traits.a01.device_feature import ( + ZeoFeatures, + is_addition_type_control_auto_addition, + is_hyperion_halia_hera, + supports_uv_light, +) +from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener +from roborock.devices.transport.mqtt_channel import MqttChannel +from roborock.mqtt.session import MqttQos +from roborock.roborock_message import RoborockZeoProtocol + +_LOGGER = logging.getLogger(__name__) + +_T = TypeVar("_T", bound=RoborockEnum) + +FeaturesFn = Callable[[], ZeoFeatures | None] + +# Map ZeoStartParams field names to their DP ids. +# Field → DP ordering follows the order +# (wash branch): Start, Mode, Program, Soak, Temperature, Rinse, Spin, +# DryingMode, DryCareMode, DryingMethod, SteamVolume. +_FIELD_TO_DP: dict[str, RoborockZeoProtocol] = { + "mode": RoborockZeoProtocol.MODE, + "program": RoborockZeoProtocol.PROGRAM, + "soak": RoborockZeoProtocol.SOAK, + "temperature": RoborockZeoProtocol.TEMP, + "rinse": RoborockZeoProtocol.RINSE_TIMES, + "spin": RoborockZeoProtocol.SPIN_LEVEL, + "drying_mode": RoborockZeoProtocol.DRYING_MODE, + "dry_and_care": RoborockZeoProtocol.DRY_CARE_MODE, + "drying_method": RoborockZeoProtocol.DRYING_METHOD, + "steam_volume": RoborockZeoProtocol.STEAM_VOLUME, + "total_time": RoborockZeoProtocol.TOTAL_TIME, +} + + +def build_param_dps(params: ZeoStartParams) -> dict[RoborockZeoProtocol, Any]: + """Map the params onto their DP ids for a START/preset frame. + + - ``mode``/``program`` are always sent. + - Every other optional enum parameter is pushed only when it is non-null. + - ``total_time`` (DP 234) is only sent when ``> 0`` — it doubles as the + washer/dryer branch selector. + """ + # TODO(program-config): ``total_time`` is a fixed programme-config value + dps: dict[RoborockZeoProtocol, Any] = {} + for field_name, dp in _FIELD_TO_DP.items(): + val = getattr(params, field_name) + if field_name == "total_time": + if val is not None and val > 0: + dps[dp] = val + elif field_name in ("mode", "program"): + if val is not None: + dps[dp] = val + elif val is not None and int(val) != 0: + # Skip "empty" enum members (null/none/empty = 0), matching the + # Bundle's `x != null` guards for optional parameters. + dps[dp] = val + return dps + + +@dataclass(init=False) +class ZeoSettingTrait(RoborockBase, TraitUpdateListener): + """Base trait holding shared Zeo state and providing typed setters.""" + + # Shared writable state (both washers and dryers), updated from the MQTT + # push stream. Read-only state lives in ``ZeoStatusTrait`` (status.py). + mode: ZeoMode | None = field(default=None, metadata={"dps": RoborockZeoProtocol.MODE}) + program: ZeoProgram | None = field(default=None, metadata={"dps": RoborockZeoProtocol.PROGRAM}) + # Countdown for a delayed start (minutes). Writable via preset_with; the + # device also reports the active countdown here. + countdown: int | None = field(default=None, metadata={"dps": RoborockZeoProtocol.COUNTDOWN}) + + # Washer-specific state (None on dryers). + temperature: ZeoTemperature | None = field(default=None, metadata={"dps": RoborockZeoProtocol.TEMP}) + rinse: ZeoRinse | None = field(default=None, metadata={"dps": RoborockZeoProtocol.RINSE_TIMES}) + spin: ZeoSpin | None = field(default=None, metadata={"dps": RoborockZeoProtocol.SPIN_LEVEL}) + drying_mode: ZeoDryingMode | None = field(default=None, metadata={"dps": RoborockZeoProtocol.DRYING_MODE}) + soak: ZeoSoak | None = field(default=None, metadata={"dps": RoborockZeoProtocol.SOAK}) + dry_and_care: ZeoDryAndCare | None = field(default=None, metadata={"dps": RoborockZeoProtocol.DRY_CARE_MODE}) + + # Dryer-specific state (None on washers). Note ``total_time`` is read-only + # (device-reported running duration) and lives in ZeoStatusTrait. + drying_method: ZeoDryingMethod | None = field(default=None, metadata={"dps": RoborockZeoProtocol.DRYING_METHOD}) + steam_volume: ZeoSteamVolume | None = field(default=None, metadata={"dps": RoborockZeoProtocol.STEAM_VOLUME}) + + # Auto-dosing state (washers only; None on dryers). ``detergent_set``/ + # ``softener_set`` are the dedicated toggles on new series, while + # ``detergent_type``/``softener_type`` double as the toggle on old series + # (where a non-``none`` type implies auto-addition is on). + detergent_set: bool | None = field(default=None, metadata={"dps": RoborockZeoProtocol.DETERGENT_SET}) + softener_set: bool | None = field(default=None, metadata={"dps": RoborockZeoProtocol.SOFTENER_SET}) + detergent_type: ZeoDetergentType | None = field(default=None, metadata={"dps": RoborockZeoProtocol.DETERGENT_TYPE}) + softener_type: ZeoSoftenerType | None = field(default=None, metadata={"dps": RoborockZeoProtocol.SOFTENER_TYPE}) + + # Feature-gated boolean state (shared across washers and dryers). + ion_deodorization: bool | None = field(default=None, metadata={"dps": RoborockZeoProtocol.ION_DEODORIZATION}) + wash_dry_linked: bool | None = field(default=None, metadata={"dps": RoborockZeoProtocol.WASH_DRY_LINKED}) + + def __init__( + self, + channel: MqttChannel, + *, + model: str | None, + is_dryer: bool, + features: FeaturesFn, + ) -> None: + """Initialize the settings trait.""" + TraitUpdateListener.__init__(self, _LOGGER) + self._channel = channel + self._model = model + self._is_dryer = is_dryer + self._features = features + self._converter = DpsDataConverter.from_dataclass(type(self)) + + def update_from_dps(self, decoded_dps: dict[int, Any]) -> bool: + """Update trait fields from raw device DPS data. + + Returns True if any field changed (and notifies update listeners). + """ + if self._converter.update_from_dps(self, decoded_dps): + self._notify_update() + return True + return False + + @property + def auto_detergent(self) -> bool | None: + """Whether auto-dosing of detergent is on, derived from series state.""" + if self._is_dryer: + return None + if is_addition_type_control_auto_addition(self._model): + return self.detergent_type is not None and self.detergent_type != ZeoDetergentType.empty + return self.detergent_set + + @property + def auto_softener(self) -> bool | None: + """Whether auto-dosing of softener is on, derived from series state.""" + if self._is_dryer: + return None + if is_addition_type_control_auto_addition(self._model): + return self.softener_type is not None and self.softener_type != ZeoSoftenerType.empty + return self.softener_set + + async def _set_enum( + self, dp: RoborockZeoProtocol, enum_cls: type[_T], value: _T + ) -> dict[RoborockZeoProtocol, Any]: + """Validate *value* against *enum_cls* and send it to the device.""" + if not isinstance(value, enum_cls): + raise TypeError(f"Expected {enum_cls.__name__}, got {type(value).__name__}") + return await self._send({dp: int(value)}) + + # -- Start-parameter setters (TEST-ONLY) -------------------------------- + # + # The individual setters below (mode/program/temperature/rinse/spin/ + # drying_mode/soak/dry_and_care/drying_method/steam_volume plus the + # feature-gated ion_deodorization/wash_dry_linked setters) each map to a + # DP that is *also* carried by the START command (see ``ZeoStartParams`` + # and ``build_param_dps``). Although the device responds to these + # single-DP writes, they have no practical use in production: a start + # sends the caller-supplied ``ZeoStartParams`` values, not the state held + # here. + # + # They are retained purely as a test/debug convenience. + + async def set_mode(self, mode: ZeoMode) -> dict[RoborockZeoProtocol, Any]: + """Set the current mode (DP 204). Test-only: carried by START.""" + return await self._set_enum(RoborockZeoProtocol.MODE, ZeoMode, mode) + + async def set_program(self, program: ZeoProgram) -> dict[RoborockZeoProtocol, Any]: + """Set the current programme (DP 205). Test-only: carried by START.""" + return await self._set_enum(RoborockZeoProtocol.PROGRAM, ZeoProgram, program) + + # -- Washer-specific setters -------------------------------------------- + + async def set_temperature(self, temperature: ZeoTemperature) -> dict[RoborockZeoProtocol, Any]: + """Set the wash temperature (DP 207). Test-only: carried by START.""" + return await self._set_enum(RoborockZeoProtocol.TEMP, ZeoTemperature, temperature) + + async def set_rinse(self, rinse: ZeoRinse) -> dict[RoborockZeoProtocol, Any]: + """Set the rinse count (DP 208). Test-only: carried by START.""" + return await self._set_enum(RoborockZeoProtocol.RINSE_TIMES, ZeoRinse, rinse) + + async def set_spin(self, spin: ZeoSpin) -> dict[RoborockZeoProtocol, Any]: + """Set the spin speed (DP 209). Test-only: carried by START.""" + return await self._set_enum(RoborockZeoProtocol.SPIN_LEVEL, ZeoSpin, spin) + + async def set_drying_mode(self, drying_mode: ZeoDryingMode) -> dict[RoborockZeoProtocol, Any]: + """Set the drying mode (DP 210). Test-only: carried by START.""" + return await self._set_enum(RoborockZeoProtocol.DRYING_MODE, ZeoDryingMode, drying_mode) + + async def set_soak(self, soak: ZeoSoak) -> dict[RoborockZeoProtocol, Any]: + """Set the soak level (DP 233). Test-only: carried by START.""" + return await self._set_enum(RoborockZeoProtocol.SOAK, ZeoSoak, soak) + + async def set_dry_and_care(self, dry_and_care: ZeoDryAndCare) -> dict[RoborockZeoProtocol, Any]: + """Set the dry-and-care mode (DP 244). Test-only: carried by START.""" + return await self._set_enum(RoborockZeoProtocol.DRY_CARE_MODE, ZeoDryAndCare, dry_and_care) + + # -- Dryer-specific setters --------------------------------------------- + + async def set_drying_method(self, drying_method: ZeoDryingMethod) -> dict[RoborockZeoProtocol, Any]: + """Set the drying method (DP 256). Test-only: carried by START.""" + return await self._set_enum(RoborockZeoProtocol.DRYING_METHOD, ZeoDryingMethod, drying_method) + + async def set_steam_volume(self, steam_volume: ZeoSteamVolume) -> dict[RoborockZeoProtocol, Any]: + """Set the steam volume (DP 257). Test-only: carried by START.""" + return await self._set_enum(RoborockZeoProtocol.STEAM_VOLUME, ZeoSteamVolume, steam_volume) + + async def set_detergent_type(self, detergent_type: ZeoDetergentType) -> dict[RoborockZeoProtocol, Any]: + """Set the detergent type (DP 213), optionally toggling auto-dosing.""" + e = int(detergent_type) + dps: dict[RoborockZeoProtocol, Any] + if is_addition_type_control_auto_addition(self._model): + dps = {RoborockZeoProtocol.DETERGENT_TYPE: e} + elif is_hyperion_halia_hera(self._model): + if e == 0: + dps = {RoborockZeoProtocol.DETERGENT_SET: 0} + else: + dps = { + RoborockZeoProtocol.DETERGENT_SET: 1, + RoborockZeoProtocol.DETERGENT_TYPE: e, + } + else: + dps = { + RoborockZeoProtocol.DETERGENT_SET: 0 if e == 0 else 1, + RoborockZeoProtocol.DETERGENT_TYPE: e, + } + return await self._send(dps) + + async def set_softener_type(self, softener_type: ZeoSoftenerType) -> dict[RoborockZeoProtocol, Any]: + """Set the softener type (DP 214).""" + e = int(softener_type) + dps: dict[RoborockZeoProtocol, Any] + if is_addition_type_control_auto_addition(self._model): + dps = {RoborockZeoProtocol.SOFTENER_TYPE: e} + elif is_hyperion_halia_hera(self._model): + if e == 0: + dps = {RoborockZeoProtocol.SOFTENER_SET: 0} + else: + dps = { + RoborockZeoProtocol.SOFTENER_SET: 1, + RoborockZeoProtocol.SOFTENER_TYPE: e, + } + else: + dps = { + RoborockZeoProtocol.SOFTENER_SET: 0 if e == 0 else 1, + RoborockZeoProtocol.SOFTENER_TYPE: e, + } + return await self._send(dps) + + async def set_detergent_box_type( + self, expansion_type: ZeoDetergentExpansionType + ) -> dict[RoborockZeoProtocol, Any]: + """Set the detergent expansion type (DP 248).""" + dps = {RoborockZeoProtocol.DETERGENT_EXPANSION_TYPE: int(expansion_type)} + return await self._send(dps) + + async def set_softener_box_type( + self, expansion_type: ZeoSoftenerExpansionType + ) -> dict[RoborockZeoProtocol, Any]: + """Set the softener expansion type (DP 245).""" + dps = {RoborockZeoProtocol.SOFTENER_EXPANSION_TYPE: int(expansion_type)} + return await self._send(dps) + + async def set_cleanser_config( + self, + auto_detergent: bool, + auto_softener: bool, + detergent_type: ZeoDetergentType, + softener_type: ZeoSoftenerType, + ) -> dict[RoborockZeoProtocol, Any]: + """Set the full cleanser config in one command (DP 211/212/213/214).""" + dps: dict[RoborockZeoProtocol, Any] = { + RoborockZeoProtocol.DETERGENT_SET: 1 if auto_detergent else 0, + RoborockZeoProtocol.SOFTENER_SET: 1 if auto_softener else 0, + RoborockZeoProtocol.DETERGENT_TYPE: int(detergent_type), + RoborockZeoProtocol.SOFTENER_TYPE: int(softener_type), + } + return await self._send(dps) + + async def set_voice_volume(self, volume: int) -> dict[RoborockZeoProtocol, Any]: + """Set the voice volume (DP 10009).""" + dps = {RoborockZeoProtocol.VOICE_VOLUME: json.dumps({"snd_volume": volume})} + return await self._send(dps) + + async def set_voice_switch(self, enabled: bool) -> dict[RoborockZeoProtocol, Any]: + """Enable/disable voice assistant (DP 10301).""" + dps = { + RoborockZeoProtocol.VOICE_SWITCH: json.dumps( + {"speech_switch": 1 if enabled else 0} + ) + } + return await self._send(dps) + + async def delete_voice_record(self, record_id: str) -> dict[RoborockZeoProtocol, Any]: + """Delete a voice record by id (DP 10304).""" + dps = { + RoborockZeoProtocol.VOICE_RECORD_DELETE: json.dumps( + {"dialog_delete": record_id} + ) + } + return await self._send(dps) + + async def set_sound_package(self, package: dict[str, Any]) -> dict[RoborockZeoProtocol, Any]: + """Set the sound package (DP 10003).""" + dps = {RoborockZeoProtocol.SET_SOUND_PACKAGE: json.dumps(package)} + return await self._send(dps) + + async def set_silent_mode( + self, + enabled: bool, + start_time: datetime.time, + end_time: datetime.time, + ) -> dict[RoborockZeoProtocol, Any]: + """Enable or disable silent mode with the given quiet hours. + + The three DPs (240/241/242) must be sent together. + + Requires the ``silent_mode`` feature bit; raises :class:`ValueError` + if the device does not support it. + """ + features = self._features() + if features is not None and not features.silent_mode: + raise ValueError("This device does not support silent mode") + dps: dict[RoborockZeoProtocol, Any] = { + RoborockZeoProtocol.SILENT_MODE_ON: 1 if enabled else 0, + RoborockZeoProtocol.SILENT_MODE_START_TIME: start_time.hour * 60 + start_time.minute, + RoborockZeoProtocol.SILENT_MODE_END_TIME: end_time.hour * 60 + end_time.minute, + } + return await self._send(dps) + + # -- Plain boolean switch setters --------------------------------------- + # + # These map one-to-one onto a boolean DP. Unlike the start-parameter + # setters above, they are *not* carried by the START command, so they are + # genuine runtime toggles. Each is feature-gated where the device has a + # corresponding feature bit (or series whitelist); gated setters raise + # :class:`ValueError` when the device does not support the feature. + + async def _set_bool(self, dp: RoborockZeoProtocol, enabled: bool) -> dict[RoborockZeoProtocol, Any]: + """Send a boolean DP as its integer ``1``/``0`` (confirmed wire format).""" + return await self._send({dp: 1 if enabled else 0}) + + async def set_child_lock(self, enabled: bool) -> dict[RoborockZeoProtocol, Any]: + """Enable/disable the child lock (DP 206).""" + return await self._set_bool(RoborockZeoProtocol.CHILD_LOCK, enabled) + + async def set_sound(self, enabled: bool) -> dict[RoborockZeoProtocol, Any]: + """Enable/disable the audible beeper (DP 223).""" + return await self._set_bool(RoborockZeoProtocol.SOUND_SET, enabled) + + async def set_dirt_detection(self, enabled: bool) -> dict[RoborockZeoProtocol, Any]: + """Enable/disable dirt detection (DP 215). + + Requires the ``dirt_detection`` feature bit. + """ + features = self._features() + if features is not None and not features.dirt_detection: + raise ValueError("This device does not support dirt detection") + return await self._set_bool(RoborockZeoProtocol.DIRT_DETECTION_SWITCH, enabled) + + async def set_default_setting(self, enabled: bool) -> dict[RoborockZeoProtocol, Any]: + """Set the default setting (DP 225).""" + return await self._set_bool(RoborockZeoProtocol.DEFAULT_SETTING, enabled) + + async def set_uv_light(self, enabled: bool) -> dict[RoborockZeoProtocol, Any]: + """Enable/disable the UV light (DP 228). + + Requires UV-light support for the device series. + """ + if not supports_uv_light(self._model): + raise ValueError("This device does not support UV light") + return await self._set_bool(RoborockZeoProtocol.UV_LIGHT, enabled) + + async def set_smart_hosting(self, enabled: bool) -> dict[RoborockZeoProtocol, Any]: + """Enable/disable smart hosting (DP 235). + + Requires the ``smart_hosting`` feature bit. + """ + features = self._features() + if features is not None and not features.smart_hosting: + raise ValueError("This device does not support smart hosting") + return await self._set_bool(RoborockZeoProtocol.SMART_HOSTING, enabled) + + async def set_smile_light(self, enabled: bool) -> dict[RoborockZeoProtocol, Any]: + """Enable/disable the smile light (DP 247). + + Requires the ``smile_light`` feature bit. + """ + features = self._features() + if features is not None and not features.smile_light: + raise ValueError("This device does not support smile light") + return await self._set_bool(RoborockZeoProtocol.SMILE_LIGHT_STATUS, enabled) + + async def set_fluff_cleaned(self, enabled: bool) -> dict[RoborockZeoProtocol, Any]: + """Mark the fluff filter as cleaned (DP 249). + + Requires the ``fluff_clean_notification`` feature bit. + """ + features = self._features() + if features is not None and not features.fluff_clean_notification: + raise ValueError("This device does not support fluff cleaning") + return await self._set_bool(RoborockZeoProtocol.FLUFF_CLEANED, enabled) + + async def set_power_light(self, enabled: bool) -> dict[RoborockZeoProtocol, Any]: + """Enable/disable the power-button indicator light (DP 251). + + Requires the ``power_button_indicator_light`` feature bit. + """ + features = self._features() + if features is not None and not features.power_button_indicator_light: + raise ValueError("This device does not support the power indicator light") + return await self._set_bool(RoborockZeoProtocol.POWER_LIGHT, enabled) + + async def set_ion_deodorization(self, enabled: bool) -> dict[RoborockZeoProtocol, Any]: + """Enable/disable ion deodorization (DP 258). Test-only: carried by START. + + Requires the ``ion_deodorization`` feature bit. + """ + features = self._features() + if features is not None and not features.ion_deodorization: + raise ValueError("This device does not support ion deodorization") + return await self._set_bool(RoborockZeoProtocol.ION_DEODORIZATION, enabled) + + async def set_wash_dry_linked(self, enabled: bool) -> dict[RoborockZeoProtocol, Any]: + """Enable/disable the wash-dry linkage (DP 255). Test-only: carried by START. + + Requires the ``wash_dry_linkage`` feature bit. + """ + features = self._features() + if features is not None and not features.wash_dry_linkage: + raise ValueError("This device does not support wash-dry linkage") + return await self._set_bool(RoborockZeoProtocol.WASH_DRY_LINKED, enabled) + + async def _send( + self, + dps: dict[RoborockZeoProtocol, Any], + qos: MqttQos = MqttQos.AT_MOST_ONCE, + ) -> dict[RoborockZeoProtocol, Any]: + """Send already wire-formatted DPs over the device channel.""" + return await send_decoded_command(self._channel, dps, value_encoder=lambda x: x, qos=qos) + + async def save_cloud_program(self, params: ZeoStartParams) -> dict[RoborockZeoProtocol, Any]: + """Save the current programme parameters as a cloud custom program. + + Sends Mode/Program (and the optional Soak/Temperature/Rinse/Spin/ + DryingMode/DryCareMode/DryingMethod/SteamVolume) followed by a trigger + signal of integer ``1``: either ``SaveAdaptedCloudProgram``(254) when + the ``adapted_custom_program`` feature is supported, or + ``SaveCloudProgram``(221) otherwise. + """ + dps = build_param_dps(params) + features = self._features() + if features is not None and features.adapted_custom_program: + dps[RoborockZeoProtocol.SAVE_ADAPTED_CLOUD_PROGRAM] = 1 + else: + dps[RoborockZeoProtocol.CUSTOM_PARAM_SAVE] = 1 + return await self._send(dps, MqttQos.AT_LEAST_ONCE) + + async def save_panel_program(self, params: ZeoStartParams) -> dict[RoborockZeoProtocol, Any]: + """Save the current programme parameters as a panel program (DP 252). + + Same parameter set as :meth:`save_cloud_program`, but the trigger + signal is ``PanelProgramParamsSet``(252) instead. + """ + dps = build_param_dps(params) + dps[RoborockZeoProtocol.PANEL_PROGRAM_PARAMS_SET] = 1 + return await self._send(dps, MqttQos.AT_LEAST_ONCE) + + async def load_cloud_program(self) -> dict[RoborockZeoProtocol, Any]: + """Apply the currently saved cloud custom program (DP 222 = 1). + + This only sends the trigger signal ``CUSTOM_PARAM_GET``(222) with an + integer ``1``. The device responds by pushing the current program's + 32-bit bitfield back on DP 222 (see ``ZeoCustomMode.from_raw``). + """ + dps: dict[RoborockZeoProtocol, Any] = {RoborockZeoProtocol.CUSTOM_PARAM_GET: 1} + return await self._send(dps, MqttQos.AT_LEAST_ONCE) diff --git a/roborock/devices/traits/a01/status.py b/roborock/devices/traits/a01/status.py new file mode 100644 index 000000000..d97c6eb9b --- /dev/null +++ b/roborock/devices/traits/a01/status.py @@ -0,0 +1,134 @@ +"""Read-only typed state for Zeo (washing machine / dryer) devices. + +This module provides ``ZeoStatusTrait``, a single typed trait holding the +read-only DPs reported by the device (state, errors, timers, tank levels, +etc.). +""" + +import json +import logging +from dataclasses import dataclass, field +from typing import Any + +from roborock.data.containers import RoborockBase +from roborock.data.zeo.zeo_code_mappings import ( + ZeoDirtDetectionStatus, + ZeoDryerStartError, + ZeoError, + ZeoState, +) +from roborock.data.zeo.zeo_containers import ZeoWashLog +from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener +from roborock.roborock_message import RoborockZeoProtocol + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(init=False) +class ZeoStatusTrait(RoborockBase, TraitUpdateListener): + """Read-only state of a Zeo device, updated from the MQTT push stream. + + Fields are populated by :meth:`update_from_dps` from the device-reported + data (no setters — everything here is read-only). Fields that a given + device family does not report simply remain ``None``. + """ + + # Shared state (both washers and dryers). + state: ZeoState | None = field(default=None, metadata={"dps": RoborockZeoProtocol.STATE}) + error: ZeoError | None = field(default=None, metadata={"dps": RoborockZeoProtocol.ERROR}) + washing_left: int | None = field(default=None, metadata={"dps": RoborockZeoProtocol.WASHING_LEFT}) + doorlock_state: bool | None = field( + default=None, metadata={"dps": RoborockZeoProtocol.DOORLOCK_STATE} + ) + times_after_clean: int | None = field( + default=None, metadata={"dps": RoborockZeoProtocol.TIMES_AFTER_CLEAN} + ) + app_authorization: bool | None = field( + default=None, metadata={"dps": RoborockZeoProtocol.APP_AUTHORIZATION} + ) + + # Washer-specific status (None on dryers). + dirt_detection_status: ZeoDirtDetectionStatus | None = field( + default=None, metadata={"dps": RoborockZeoProtocol.DIRT_DETECTION_STATUS} + ) + detergent_empty: bool | None = field( + default=None, metadata={"dps": RoborockZeoProtocol.DETERGENT_EMPTY} + ) + softener_empty: bool | None = field( + default=None, metadata={"dps": RoborockZeoProtocol.SOFTENER_EMPTY} + ) + + # Dryer-specific status (None on washers). + total_time: int | None = field(default=None, metadata={"dps": RoborockZeoProtocol.TOTAL_TIME}) + cloth_put_in: bool | None = field( + default=None, metadata={"dps": RoborockZeoProtocol.CLOTH_PUT_IN} + ) + cloth_ready_to_dry_count_down: int | None = field( + default=None, metadata={"dps": RoborockZeoProtocol.CLOTH_READY_TO_DRY_COUNT_DOWN} + ) + start_dryer_error: ZeoDryerStartError | None = field( + default=None, metadata={"dps": RoborockZeoProtocol.START_DRYER_ERROR} + ) + + # Smart-hosting status. + smart_hosting_time: int | None = field( + default=None, metadata={"dps": RoborockZeoProtocol.SMART_HOSTING_TIME} + ) + smart_hosting_waited_time: int | None = field( + default=None, metadata={"dps": RoborockZeoProtocol.SMART_HOSTING_WAITED_TIME} + ) + is_need_fluff_clean: bool | None = field( + default=None, metadata={"dps": RoborockZeoProtocol.IS_NEED_FLUFF_CLEAN} + ) + + # Other read-only status. + custom_program_cleaning_time: int | None = field( + default=None, metadata={"dps": RoborockZeoProtocol.CUSTOM_PROGRAM_CLEANING_TIME} + ) + panel_program_params_set_result: int | None = field( + default=None, metadata={"dps": RoborockZeoProtocol.PANEL_PROGRAM_PARAMS_SET_RESULT} + ) + panel_timing_program_params: int | None = field( + default=None, metadata={"dps": RoborockZeoProtocol.PANEL_TIMING_PROGRAM_PARAMS} + ) + steam_care_time: int | None = field( + default=None, metadata={"dps": RoborockZeoProtocol.STEAM_CARE_TIME} + ) + device_bound: bool | None = field( + default=None, metadata={"dps": RoborockZeoProtocol.DEVICE_BOUND} + ) + + # Meta — device-reported JSON documents (read-only). + product_info: dict[str, Any] | None = field( + default=None, metadata={"dps": RoborockZeoProtocol.PRODUCT_INFO} + ) + washing_log: ZeoWashLog | None = field( + default=None, metadata={"dps": RoborockZeoProtocol.WASHING_LOG} + ) + + def __init__(self) -> None: + """Initialize the status trait.""" + TraitUpdateListener.__init__(self, _LOGGER) + self._converter = DpsDataConverter.from_dataclass(type(self)) + + def update_from_dps(self, decoded_dps: dict[int, Any]) -> bool: + """Update trait fields from raw device DPS data. + + Meta fields that arrive as JSON strings (``product_info``, + ``washing_log``) are parsed before conversion. Returns True if any + field changed (and notifies update listeners). + """ + # JSON-string meta fields: parse them into dicts so the converter can + # build the typed containers / dicts from them. + for dp in (RoborockZeoProtocol.PRODUCT_INFO, RoborockZeoProtocol.WASHING_LOG): + raw = decoded_dps.get(int(dp)) + if isinstance(raw, str): + try: + decoded_dps[int(dp)] = json.loads(raw) + except ValueError: + _LOGGER.debug("Failed to parse JSON for DP %s", dp, exc_info=True) + + if self._converter.update_from_dps(self, decoded_dps): + self._notify_update() + return True + return False diff --git a/roborock/roborock_message.py b/roborock/roborock_message.py index 9fab9357a..64b575125 100644 --- a/roborock/roborock_message.py +++ b/roborock/roborock_message.py @@ -180,10 +180,11 @@ class RoborockZeoProtocol(RoborockEnum): 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 + 243 # int, present in MQTT push from some devices, increments with each push ) DRY_CARE_MODE = 244 # rw [startWith] SOFTENER_EXPANSION_TYPE = 245 # rw [independent] + UNKNOWN_246 = 246 SMILE_LIGHT_STATUS = 247 # rw [independent] DETERGENT_EXPANSION_TYPE = 248 # rw [independent] FLUFF_CLEANED = 249 # rw [independent] @@ -196,6 +197,7 @@ class RoborockZeoProtocol(RoborockEnum): DRYING_METHOD = 256 # rw [startWith] STEAM_VOLUME = 257 # rw [startWith] ION_DEODORIZATION = 258 # rw [startWith / feature-gated] + UNKNOWN_259 = 259 PANEL_TIMING_PROGRAM_PARAMS = 260 # ro STEAM_CARE_TIME = 261 # ro DEVICE_BOUND = 262 # ro @@ -207,19 +209,19 @@ class RoborockZeoProtocol(RoborockEnum): # ── 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() + SET_SOUND_PACKAGE = 10003 # wo setSoundPackage(obj) → JSON.stringify(obj) + SOUND_PACKAGE_INFO = 10004 # ro query via updateSoundPackageInfo(), JSON object PRODUCT_INFO = 10005 # ro query via loadGeneralInfo() (10s timeout) - PRIVACY_INFO = 10006 # wo syncPrivacyToDevice(agreed) + PRIVACY_INFO = 10006 # wo OTA_NFO = 10007 # ro forceLoad only WASHING_LOG = 10008 # ro forceLoad only, JSON - VOICE_VOLUME = 10009 # wo [independent] setVoiceVolume(int) → JSON + VOICE_VOLUME = 10009 # wo [independent] setVoiceVolume(int) → JSON.stringify({snd_volume: int}) 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_SWITCH = 10301 # wo [independent] setVoiceSwitchStatus(bool) → JSON.stringify({speech_switch: 1/0}) 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 + VOICE_RECORD = 10303 # ro query via getVoiceControlRecord(), JSON {result:{history:[{id,ts,...}]}} + VOICE_RECORD_DELETE = 10304 # wo [independent] deleteVoiceControlRecord(id) → JSON.stringify({dialog_delete: id}) class RoborockB01Protocol(RoborockEnum): From 21d535a218b8d31218aacf2fd8294d80e0f363f9 Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Sat, 15 Aug 2026 14:39:07 +0800 Subject: [PATCH 10/12] style(zeo): fix pre-commit findings (formatting, unused import, no-redef) --- roborock/data/zeo/zeo_containers.py | 1 - roborock/devices/traits/a01/__init__.py | 4 +-- roborock/devices/traits/a01/command.py | 11 +++--- roborock/devices/traits/a01/settings.py | 24 +++---------- roborock/devices/traits/a01/status.py | 48 +++++++------------------ roborock/roborock_message.py | 4 +-- 6 files changed, 25 insertions(+), 67 deletions(-) diff --git a/roborock/data/zeo/zeo_containers.py b/roborock/data/zeo/zeo_containers.py index 019835394..ff1a41ad4 100644 --- a/roborock/data/zeo/zeo_containers.py +++ b/roborock/data/zeo/zeo_containers.py @@ -1,7 +1,6 @@ """Data containers for Zeo (washing machine / dryer) devices.""" from dataclasses import dataclass, field -from typing import Any from ..containers import RoborockBase from .zeo_code_mappings import ( diff --git a/roborock/devices/traits/a01/__init__.py b/roborock/devices/traits/a01/__init__.py index edebb43ce..3428ff0b1 100644 --- a/roborock/devices/traits/a01/__init__.py +++ b/roborock/devices/traits/a01/__init__.py @@ -461,9 +461,7 @@ async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[Rob async def get_custom_mode(self) -> ZeoCustomMode | ZeoDryerCustomMode | None: """Query and decode the current custom programme (DP 222).""" - await self.query_values( - [RoborockZeoProtocol.CUSTOM_PARAM_GET, RoborockZeoProtocol.TOTAL_TIME] - ) + await self.query_values([RoborockZeoProtocol.CUSTOM_PARAM_GET, RoborockZeoProtocol.TOTAL_TIME]) raw = self._dps_cache.get(int(RoborockZeoProtocol.CUSTOM_PARAM_GET)) if raw is None: return None diff --git a/roborock/devices/traits/a01/command.py b/roborock/devices/traits/a01/command.py index 952588f45..6f3b8980e 100644 --- a/roborock/devices/traits/a01/command.py +++ b/roborock/devices/traits/a01/command.py @@ -24,6 +24,7 @@ RoborockZeoProtocol.WASH_DRY_LINKED: ("wash_dry_linkage", "wash_dry_linked"), } + class ZeoCommandTrait: """Trait for sending commands to Zeo devices.""" @@ -52,7 +53,7 @@ def __init__( async def start_with(self, params: ZeoStartParams) -> dict[RoborockZeoProtocol, Any]: """Start the device with the given programme parameters. - This is the primary start API: the caller needs to provide the + This is the primary start API: the caller needs to provide the parameters like mode/program/options/etc. Returns the DPs that were actually sent. @@ -110,7 +111,7 @@ async def start_with_custom_mode(self) -> dict[RoborockZeoProtocol, Any]: """Start the device using its saved custom programme. Reads the device's custom programme (DP 222, see - :meth:`ZeoApi.get_custom_mode`) and starts with those parameters. + :meth:`ZeoApi.get_custom_mode`) and starts with those parameters. Raises :class:`ValueError` when the device has no saved custom programme. @@ -138,14 +139,14 @@ async def preset_with(self, params: ZeoStartParams, minutes: int) -> dict[Roboro Returns the DPs that were sent. """ if minutes <= 0: - dps: dict[RoborockZeoProtocol, Any] = {RoborockZeoProtocol.COUNTDOWN: 0} + cancel_dps: dict[RoborockZeoProtocol, Any] = {RoborockZeoProtocol.COUNTDOWN: 0} await send_decoded_command( self._channel, - dps, + cancel_dps, qos=MqttQos.AT_LEAST_ONCE, value_encoder=lambda x: x, ) - return dps + return cancel_dps dps: dict[RoborockZeoProtocol, Any] = {RoborockZeoProtocol.START: 1} dps.update(build_param_dps(params)) dps.update(self._build_auto_dosing_dps()) diff --git a/roborock/devices/traits/a01/settings.py b/roborock/devices/traits/a01/settings.py index 92fe542c2..c0b3cce5f 100644 --- a/roborock/devices/traits/a01/settings.py +++ b/roborock/devices/traits/a01/settings.py @@ -182,9 +182,7 @@ def auto_softener(self) -> bool | None: return self.softener_type is not None and self.softener_type != ZeoSoftenerType.empty return self.softener_set - async def _set_enum( - self, dp: RoborockZeoProtocol, enum_cls: type[_T], value: _T - ) -> dict[RoborockZeoProtocol, Any]: + async def _set_enum(self, dp: RoborockZeoProtocol, enum_cls: type[_T], value: _T) -> dict[RoborockZeoProtocol, Any]: """Validate *value* against *enum_cls* and send it to the device.""" if not isinstance(value, enum_cls): raise TypeError(f"Expected {enum_cls.__name__}, got {type(value).__name__}") @@ -289,16 +287,12 @@ async def set_softener_type(self, softener_type: ZeoSoftenerType) -> dict[Roboro } return await self._send(dps) - async def set_detergent_box_type( - self, expansion_type: ZeoDetergentExpansionType - ) -> dict[RoborockZeoProtocol, Any]: + async def set_detergent_box_type(self, expansion_type: ZeoDetergentExpansionType) -> dict[RoborockZeoProtocol, Any]: """Set the detergent expansion type (DP 248).""" dps = {RoborockZeoProtocol.DETERGENT_EXPANSION_TYPE: int(expansion_type)} return await self._send(dps) - async def set_softener_box_type( - self, expansion_type: ZeoSoftenerExpansionType - ) -> dict[RoborockZeoProtocol, Any]: + async def set_softener_box_type(self, expansion_type: ZeoSoftenerExpansionType) -> dict[RoborockZeoProtocol, Any]: """Set the softener expansion type (DP 245).""" dps = {RoborockZeoProtocol.SOFTENER_EXPANSION_TYPE: int(expansion_type)} return await self._send(dps) @@ -326,20 +320,12 @@ async def set_voice_volume(self, volume: int) -> dict[RoborockZeoProtocol, Any]: async def set_voice_switch(self, enabled: bool) -> dict[RoborockZeoProtocol, Any]: """Enable/disable voice assistant (DP 10301).""" - dps = { - RoborockZeoProtocol.VOICE_SWITCH: json.dumps( - {"speech_switch": 1 if enabled else 0} - ) - } + dps = {RoborockZeoProtocol.VOICE_SWITCH: json.dumps({"speech_switch": 1 if enabled else 0})} return await self._send(dps) async def delete_voice_record(self, record_id: str) -> dict[RoborockZeoProtocol, Any]: """Delete a voice record by id (DP 10304).""" - dps = { - RoborockZeoProtocol.VOICE_RECORD_DELETE: json.dumps( - {"dialog_delete": record_id} - ) - } + dps = {RoborockZeoProtocol.VOICE_RECORD_DELETE: json.dumps({"dialog_delete": record_id})} return await self._send(dps) async def set_sound_package(self, package: dict[str, Any]) -> dict[RoborockZeoProtocol, Any]: diff --git a/roborock/devices/traits/a01/status.py b/roborock/devices/traits/a01/status.py index d97c6eb9b..fb81c7ca8 100644 --- a/roborock/devices/traits/a01/status.py +++ b/roborock/devices/traits/a01/status.py @@ -37,32 +37,20 @@ class ZeoStatusTrait(RoborockBase, TraitUpdateListener): state: ZeoState | None = field(default=None, metadata={"dps": RoborockZeoProtocol.STATE}) error: ZeoError | None = field(default=None, metadata={"dps": RoborockZeoProtocol.ERROR}) washing_left: int | None = field(default=None, metadata={"dps": RoborockZeoProtocol.WASHING_LEFT}) - doorlock_state: bool | None = field( - default=None, metadata={"dps": RoborockZeoProtocol.DOORLOCK_STATE} - ) - times_after_clean: int | None = field( - default=None, metadata={"dps": RoborockZeoProtocol.TIMES_AFTER_CLEAN} - ) - app_authorization: bool | None = field( - default=None, metadata={"dps": RoborockZeoProtocol.APP_AUTHORIZATION} - ) + doorlock_state: bool | None = field(default=None, metadata={"dps": RoborockZeoProtocol.DOORLOCK_STATE}) + times_after_clean: int | None = field(default=None, metadata={"dps": RoborockZeoProtocol.TIMES_AFTER_CLEAN}) + app_authorization: bool | None = field(default=None, metadata={"dps": RoborockZeoProtocol.APP_AUTHORIZATION}) # Washer-specific status (None on dryers). dirt_detection_status: ZeoDirtDetectionStatus | None = field( default=None, metadata={"dps": RoborockZeoProtocol.DIRT_DETECTION_STATUS} ) - detergent_empty: bool | None = field( - default=None, metadata={"dps": RoborockZeoProtocol.DETERGENT_EMPTY} - ) - softener_empty: bool | None = field( - default=None, metadata={"dps": RoborockZeoProtocol.SOFTENER_EMPTY} - ) + detergent_empty: bool | None = field(default=None, metadata={"dps": RoborockZeoProtocol.DETERGENT_EMPTY}) + softener_empty: bool | None = field(default=None, metadata={"dps": RoborockZeoProtocol.SOFTENER_EMPTY}) # Dryer-specific status (None on washers). total_time: int | None = field(default=None, metadata={"dps": RoborockZeoProtocol.TOTAL_TIME}) - cloth_put_in: bool | None = field( - default=None, metadata={"dps": RoborockZeoProtocol.CLOTH_PUT_IN} - ) + cloth_put_in: bool | None = field(default=None, metadata={"dps": RoborockZeoProtocol.CLOTH_PUT_IN}) cloth_ready_to_dry_count_down: int | None = field( default=None, metadata={"dps": RoborockZeoProtocol.CLOTH_READY_TO_DRY_COUNT_DOWN} ) @@ -71,15 +59,11 @@ class ZeoStatusTrait(RoborockBase, TraitUpdateListener): ) # Smart-hosting status. - smart_hosting_time: int | None = field( - default=None, metadata={"dps": RoborockZeoProtocol.SMART_HOSTING_TIME} - ) + smart_hosting_time: int | None = field(default=None, metadata={"dps": RoborockZeoProtocol.SMART_HOSTING_TIME}) smart_hosting_waited_time: int | None = field( default=None, metadata={"dps": RoborockZeoProtocol.SMART_HOSTING_WAITED_TIME} ) - is_need_fluff_clean: bool | None = field( - default=None, metadata={"dps": RoborockZeoProtocol.IS_NEED_FLUFF_CLEAN} - ) + is_need_fluff_clean: bool | None = field(default=None, metadata={"dps": RoborockZeoProtocol.IS_NEED_FLUFF_CLEAN}) # Other read-only status. custom_program_cleaning_time: int | None = field( @@ -91,20 +75,12 @@ class ZeoStatusTrait(RoborockBase, TraitUpdateListener): panel_timing_program_params: int | None = field( default=None, metadata={"dps": RoborockZeoProtocol.PANEL_TIMING_PROGRAM_PARAMS} ) - steam_care_time: int | None = field( - default=None, metadata={"dps": RoborockZeoProtocol.STEAM_CARE_TIME} - ) - device_bound: bool | None = field( - default=None, metadata={"dps": RoborockZeoProtocol.DEVICE_BOUND} - ) + steam_care_time: int | None = field(default=None, metadata={"dps": RoborockZeoProtocol.STEAM_CARE_TIME}) + device_bound: bool | None = field(default=None, metadata={"dps": RoborockZeoProtocol.DEVICE_BOUND}) # Meta — device-reported JSON documents (read-only). - product_info: dict[str, Any] | None = field( - default=None, metadata={"dps": RoborockZeoProtocol.PRODUCT_INFO} - ) - washing_log: ZeoWashLog | None = field( - default=None, metadata={"dps": RoborockZeoProtocol.WASHING_LOG} - ) + product_info: dict[str, Any] | None = field(default=None, metadata={"dps": RoborockZeoProtocol.PRODUCT_INFO}) + washing_log: ZeoWashLog | None = field(default=None, metadata={"dps": RoborockZeoProtocol.WASHING_LOG}) def __init__(self) -> None: """Initialize the status trait.""" diff --git a/roborock/roborock_message.py b/roborock/roborock_message.py index 64b575125..366eeb9c0 100644 --- a/roborock/roborock_message.py +++ b/roborock/roborock_message.py @@ -179,9 +179,7 @@ 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 # int, present in MQTT push from some devices, increments with each push - ) + UNKNOWN_243 = 243 # int, present in MQTT push from some devices, increments with each push DRY_CARE_MODE = 244 # rw [startWith] SOFTENER_EXPANSION_TYPE = 245 # rw [independent] UNKNOWN_246 = 246 From 9ea722586b6fcc484e3f2ba9852802a2bdf4728a Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Mon, 17 Aug 2026 10:30:13 +0800 Subject: [PATCH 11/12] fix(zeo): correct voice DP read/write semantics per bundle VoiceVolume (10009) and VoiceSwitch (10301) are read-write: they have getters (voiceVolume / isVoiceSwitchOn) and appear in the bundle's loadFeatureDps query list (gated by FeatureBit.VoiceAssistant). Add their _try_json converters to the read-write protocol entries so query_values() returns parsed JSON. SetSoundPackage (10003) and VoiceRecordDelete (10304) are write-only (no getter, not in any query list) - remove their dead converters. Rename module-level __init__ to __all__ (former was a bug that overshadowed the module's __init__ attribute). --- roborock/devices/traits/a01/__init__.py | 14 +++++++------- roborock/roborock_message.py | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/roborock/devices/traits/a01/__init__.py b/roborock/devices/traits/a01/__init__.py index 3428ff0b1..c3da01ef4 100644 --- a/roborock/devices/traits/a01/__init__.py +++ b/roborock/devices/traits/a01/__init__.py @@ -89,7 +89,7 @@ _LOGGER = logging.getLogger(__name__) -__init__ = [ +__all__ = [ "DyadApi", "ZeoApi", "ZeoCommandTrait", @@ -139,7 +139,9 @@ def _try_json(val: Any) -> Any: try: return json.loads(val) except ValueError: - pass + _LOGGER.debug( + "Failed to parse JSON for value %r, returning as-is", val + ) return val @@ -207,6 +209,9 @@ def _try_json(val: Any) -> Any: RoborockZeoProtocol.DETERGENT_SET: lambda val: bool(val), RoborockZeoProtocol.SOFTENER_SET: lambda val: bool(val), RoborockZeoProtocol.FLUFF_CLEANED: lambda val: bool(val), + # read-write (JSON objects — bundle reads via JSON.parse) + RoborockZeoProtocol.VOICE_VOLUME: lambda val: _try_json(val), # {"snd_volume": int} + RoborockZeoProtocol.VOICE_SWITCH: lambda val: _try_json(val), # {"speech_switch": 1/0} # read-write (int-valued) RoborockZeoProtocol.CUSTOM_PARAM_SAVE: lambda val: int(val), RoborockZeoProtocol.CUSTOM_PARAM_GET: lambda val: int(val), @@ -214,11 +219,6 @@ def _try_json(val: Any) -> Any: # NOTE: LIGHT_SETTING(229) / DETERGENT_VOLUME(230) / SOFTENER_VOLUME(231) # are "server schema only" and do NOT exist in the device bundle — they # have no device-side implementation, so no converters are registered. - # meta — write-only (JSON payloads; echo back as JSON strings) - RoborockZeoProtocol.SET_SOUND_PACKAGE: lambda val: _try_json(val), - RoborockZeoProtocol.VOICE_VOLUME: lambda val: _try_json(val), - RoborockZeoProtocol.VOICE_SWITCH: lambda val: _try_json(val), - RoborockZeoProtocol.VOICE_RECORD_DELETE: lambda val: _try_json(val), } diff --git a/roborock/roborock_message.py b/roborock/roborock_message.py index 366eeb9c0..ee8a14b5c 100644 --- a/roborock/roborock_message.py +++ b/roborock/roborock_message.py @@ -213,10 +213,10 @@ class RoborockZeoProtocol(RoborockEnum): PRIVACY_INFO = 10006 # wo OTA_NFO = 10007 # ro forceLoad only WASHING_LOG = 10008 # ro forceLoad only, JSON - VOICE_VOLUME = 10009 # wo [independent] setVoiceVolume(int) → JSON.stringify({snd_volume: int}) + VOICE_VOLUME = 10009 # rw [independent] setVoiceVolume(int) → JSON.stringify({snd_volume: int}); readable via voiceVolume getter (FeatureBit.VoiceAssistant gated) 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.stringify({speech_switch: 1/0}) + VOICE_SWITCH = 10301 # rw [independent] setVoiceSwitchStatus(bool) → JSON.stringify({speech_switch: 1/0}); readable via isVoiceSwitchOn getter (FeatureBit.VoiceAssistant gated) VOICE_RECORD_INFO = 10302 # ro cache-derived, auto JSON decoded VOICE_RECORD = 10303 # ro query via getVoiceControlRecord(), JSON {result:{history:[{id,ts,...}]}} VOICE_RECORD_DELETE = 10304 # wo [independent] deleteVoiceControlRecord(id) → JSON.stringify({dialog_delete: id}) From 96a0d5eb2325b0201646b0f2914bcd0328c172c1 Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Mon, 17 Aug 2026 10:47:07 +0800 Subject: [PATCH 12/12] style: fix E501 long comment lines in voice DP enums --- roborock/devices/traits/a01/__init__.py | 4 +--- roborock/roborock_message.py | 6 ++++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/roborock/devices/traits/a01/__init__.py b/roborock/devices/traits/a01/__init__.py index c3da01ef4..25f5453b9 100644 --- a/roborock/devices/traits/a01/__init__.py +++ b/roborock/devices/traits/a01/__init__.py @@ -139,9 +139,7 @@ def _try_json(val: Any) -> Any: try: return json.loads(val) except ValueError: - _LOGGER.debug( - "Failed to parse JSON for value %r, returning as-is", val - ) + _LOGGER.debug("Failed to parse JSON for value %r, returning as-is", val) return val diff --git a/roborock/roborock_message.py b/roborock/roborock_message.py index ee8a14b5c..04c05017a 100644 --- a/roborock/roborock_message.py +++ b/roborock/roborock_message.py @@ -213,10 +213,12 @@ class RoborockZeoProtocol(RoborockEnum): PRIVACY_INFO = 10006 # wo OTA_NFO = 10007 # ro forceLoad only WASHING_LOG = 10008 # ro forceLoad only, JSON - VOICE_VOLUME = 10009 # rw [independent] setVoiceVolume(int) → JSON.stringify({snd_volume: int}); readable via voiceVolume getter (FeatureBit.VoiceAssistant gated) + VOICE_VOLUME = 10009 # rw [independent] setVoiceVolume(int) → JSON.stringify({snd_volume: int}); readable + # via voiceVolume getter (FeatureBit.VoiceAssistant gated) RPC_REQUEST = 10101 # wo rpcRequest(method) → JSON RPC_RESPONSE = 10102 # -- MQTT push protocol 102, not a device DP - VOICE_SWITCH = 10301 # rw [independent] setVoiceSwitchStatus(bool) → JSON.stringify({speech_switch: 1/0}); readable via isVoiceSwitchOn getter (FeatureBit.VoiceAssistant gated) + VOICE_SWITCH = 10301 # rw [independent] setVoiceSwitchStatus(bool) → JSON.stringify({speech_switch: 1/0}); + # readable via isVoiceSwitchOn getter (FeatureBit.VoiceAssistant gated) VOICE_RECORD_INFO = 10302 # ro cache-derived, auto JSON decoded VOICE_RECORD = 10303 # ro query via getVoiceControlRecord(), JSON {result:{history:[{id,ts,...}]}} VOICE_RECORD_DELETE = 10304 # wo [independent] deleteVoiceControlRecord(id) → JSON.stringify({dialog_delete: id})