From 6eaf4f9becf2998e93ecc4869f03e935735a5787 Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Thu, 9 Jul 2026 22:35:30 +0800 Subject: [PATCH 01/17] feat: add full Zeo device support --- roborock/data/zeo/zeo_code_mappings.py | 43 +++++++++++- roborock/devices/traits/a01/__init__.py | 91 ++++++++++++++++++++++++- roborock/mqtt/roborock_session.py | 2 +- roborock/protocols/a01_protocol.py | 6 +- roborock/roborock_message.py | 34 +++++++++ 5 files changed, 171 insertions(+), 5 deletions(-) diff --git a/roborock/data/zeo/zeo_code_mappings.py b/roborock/data/zeo/zeo_code_mappings.py index 4e5c79d16..7e9aa7fe7 100644 --- a/roborock/data/zeo/zeo_code_mappings.py +++ b/roborock/data/zeo/zeo_code_mappings.py @@ -6,6 +6,7 @@ class ZeoMode(RoborockEnum): wash = 1 wash_and_dry = 2 dry = 3 + treatment = 4 class ZeoState(RoborockEnum): @@ -19,8 +20,16 @@ class ZeoState(RoborockEnum): cooling = 8 under_delay_start = 9 done = 10 - aftercare = 12 - waiting_for_aftercare = 13 + updating = 11 + smart_hosting = 12 + smart_hosting_waiting = 13 + 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): @@ -68,6 +77,7 @@ class ZeoSoak(RoborockEnum): medium = 2 high = 3 max = 4 + very_max = 5 # 30 minutes class ZeoTemperature(RoborockEnum): @@ -140,3 +150,32 @@ class ZeoError(RoborockEnum): 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 + + +class ZeoDryingMethod(RoborockEnum): + l1 = 1 + l2 = 2 + l3 = 3 + + +class ZeoSteamVolume(RoborockEnum): + none = 0 + low = 1 + medium = 2 + high = 3 + max = 4 + + +class ZeoDryAndCare(RoborockEnum): + soft = 1 + normal = 2 + + +class ZeoDryerStartError(RoborockEnum): + dryer_running = 1 + dryer_error = 2 + dryer_done = 3 + dryer_waiting_hosting = 4 + dryer_smart_hosting = 5 + dryer_countdown = 6 + dryer_network_fail = 7 diff --git a/roborock/devices/traits/a01/__init__.py b/roborock/devices/traits/a01/__init__.py index 537c84377..91d7fd0f5 100644 --- a/roborock/devices/traits/a01/__init__.py +++ b/roborock/devices/traits/a01/__init__.py @@ -20,10 +20,13 @@ """ import json +import logging from collections.abc import Callable from datetime import time from typing import Any +from roborock.exceptions import RoborockException + from roborock.data import DyadProductInfo, DyadSndState, HomeDataProduct, RoborockCategory from roborock.data.dyad.dyad_code_mappings import ( DyadBrushSpeed, @@ -38,14 +41,19 @@ ) from roborock.data.zeo.zeo_code_mappings import ( ZeoDetergentType, + ZeoDryAndCare, + ZeoDryerStartError, + ZeoDryingMethod, ZeoDryingMode, ZeoError, ZeoMode, ZeoProgram, ZeoRinse, + ZeoSoak, ZeoSoftenerType, ZeoSpin, ZeoState, + ZeoSteamVolume, ZeoTemperature, ) from roborock.devices.rpc.a01_channel import send_decoded_command @@ -53,6 +61,8 @@ from roborock.devices.transport.mqtt_channel import MqttChannel from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol +_LOGGER = logging.getLogger(__name__) + __init__ = [ "DyadApi", "ZeoApi", @@ -101,6 +111,22 @@ 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: int(val), + RoborockZeoProtocol.TOTAL_TIME: lambda val: int(val), + RoborockZeoProtocol.FEATURE_BITS: lambda val: int(val), + RoborockZeoProtocol.SMART_HOSTING_WAITED_TIME: lambda val: int(val), + RoborockZeoProtocol.FLUFF_CLEANED: lambda val: bool(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.DEFAULT_SETTING: lambda val: int(val), + RoborockZeoProtocol.LIGHT_SETTING: lambda val: bool(val), + RoborockZeoProtocol.DETERGENT_VOLUME: lambda val: int(val), + RoborockZeoProtocol.SOFTENER_VOLUME: lambda val: int(val), # read-write RoborockZeoProtocol.MODE: lambda val: ZeoMode(val).name, RoborockZeoProtocol.PROGRAM: lambda val: ZeoProgram(val).name, @@ -111,6 +137,33 @@ RoborockZeoProtocol.DETERGENT_TYPE: lambda val: ZeoDetergentType(val).name, RoborockZeoProtocol.SOFTENER_TYPE: lambda val: ZeoSoftenerType(val).name, RoborockZeoProtocol.SOUND_SET: lambda val: bool(val), + RoborockZeoProtocol.DIRT_DETECTION_SWITCH: lambda val: bool(val), + RoborockZeoProtocol.SOAK: lambda val: ZeoSoak(val).name, + RoborockZeoProtocol.SILENT_MODE_ON: lambda val: bool(val), + RoborockZeoProtocol.SILENT_MODE_START_TIME: lambda val: int(val), + RoborockZeoProtocol.SILENT_MODE_END_TIME: lambda val: int(val), + RoborockZeoProtocol.DRY_CARE_MODE: lambda val: ZeoDryAndCare(val).name, + RoborockZeoProtocol.WASH_DRY_LINKED: lambda val: bool(val), + RoborockZeoProtocol.DRYING_METHOD: lambda val: ZeoDryingMethod(val).name, + RoborockZeoProtocol.STEAM_VOLUME: lambda val: ZeoSteamVolume(val).name, + RoborockZeoProtocol.ION_DEODORIZATION: lambda val: bool(val), + RoborockZeoProtocol.UV_LIGHT: lambda val: bool(val), + RoborockZeoProtocol.SMART_HOSTING: lambda val: bool(val), + RoborockZeoProtocol.SMART_HOSTING_TIME: lambda val: int(val), + RoborockZeoProtocol.SOFTENER_EXPANSION_TYPE: lambda val: int(val), + RoborockZeoProtocol.DETERGENT_EXPANSION_TYPE: lambda val: int(val), + 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.PANEL_TIMING_PROGRAM_PARAMS: lambda val: int(val), + RoborockZeoProtocol.STEAM_CARE_TIME: lambda val: int(val), + RoborockZeoProtocol.WIFI_LINKAGE_RESET: lambda val: int(val), + RoborockZeoProtocol.CUSTOM_PROGRAM_CLEANING_TIME: 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.APP_AUTHORIZATION: lambda val: bool(val), } @@ -176,7 +229,43 @@ async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[Robor async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[RoborockZeoProtocol, Any]: """Set a value for a specific protocol on the device.""" - params = {protocol: value} + params: dict[RoborockZeoProtocol, Any] = {protocol: value} + if protocol == RoborockZeoProtocol.START and value == 1: + _LOGGER.debug("Start command detected, querying current device state") + try: + current = await send_decoded_command( + self._channel, + { + RoborockZeoProtocol.ID_QUERY: [ + RoborockZeoProtocol.MODE, + RoborockZeoProtocol.PROGRAM, + RoborockZeoProtocol.TEMP, + RoborockZeoProtocol.RINSE_TIMES, + RoborockZeoProtocol.SPIN_LEVEL, + RoborockZeoProtocol.DRYING_MODE, + ] + }, + value_encoder=json.dumps, + ) + for dp, fallback in ( + (RoborockZeoProtocol.MODE, 1), + (RoborockZeoProtocol.PROGRAM, 1), + (RoborockZeoProtocol.TEMP, None), + (RoborockZeoProtocol.RINSE_TIMES, None), + (RoborockZeoProtocol.SPIN_LEVEL, None), + (RoborockZeoProtocol.DRYING_MODE, None), + ): + if dp in current: + params[dp] = current[dp] + elif fallback is not None: + params[dp] = fallback + except RoborockException: + _LOGGER.warning( + "Failed to query device state before start, using defaults", + exc_info=True, + ) + params[RoborockZeoProtocol.MODE] = 1 + params[RoborockZeoProtocol.PROGRAM] = 1 return await send_decoded_command(self._channel, params, value_encoder=lambda x: x) diff --git a/roborock/mqtt/roborock_session.py b/roborock/mqtt/roborock_session.py index ec6e5aa71..8bb4ed45a 100644 --- a/roborock/mqtt/roborock_session.py +++ b/roborock/mqtt/roborock_session.py @@ -371,7 +371,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=1) except MqttError as err: raise MqttSessionException(f"Error publishing message: {err}") from err 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/roborock_message.py b/roborock/roborock_message.py index 72564b38c..5ac007a01 100644 --- a/roborock/roborock_message.py +++ b/roborock/roborock_message.py @@ -140,6 +140,8 @@ class RoborockZeoProtocol(RoborockEnum): SOFTENER_SET = 212 # rw DETERGENT_TYPE = 213 # rw SOFTENER_TYPE = 214 # rw + DIRT_DETECTION_SWITCH = 215 # rw + DIRT_DETECTION_STATUS = 216 # ro COUNTDOWN = 217 # rw WASHING_LEFT = 218 # ro DOORLOCK_STATE = 219 # ro @@ -151,10 +153,42 @@ class RoborockZeoProtocol(RoborockEnum): DEFAULT_SETTING = 225 # rw DETERGENT_EMPTY = 226 # ro SOFTENER_EMPTY = 227 # ro + UV_LIGHT = 228 # rw LIGHT_SETTING = 229 # rw DETERGENT_VOLUME = 230 # rw SOFTENER_VOLUME = 231 # rw APP_AUTHORIZATION = 232 # rw + SOAK = 233 # rw + TOTAL_TIME = 234 # ro + SMART_HOSTING = 235 # rw + SMART_HOSTING_TIME = 236 # rw + FEATURE_BITS = 237 # ro + SMART_HOSTING_WAITED_TIME = 238 # ro + CUSTOM_PROGRAM_CLEANING_TIME = 239 # rw + SILENT_MODE_ON = 240 # rw + SILENT_MODE_START_TIME = 241 # rw + SILENT_MODE_END_TIME = 242 # rw + DRY_CARE_MODE = 244 # rw + SOFTENER_EXPANSION_TYPE = 245 # rw + SMILE_LIGHT_STATUS = 247 # rw + DETERGENT_EXPANSION_TYPE = 248 # rw + FLUFF_CLEANED = 249 # ro + IS_NEED_FLUFF_CLEAN = 250 # ro + POWER_LIGHT = 251 # rw + PANEL_PROGRAM_PARAMS_SET = 252 # rw + PANEL_PROGRAM_PARAMS_SET_RESULT = 253 # ro + SAVE_ADAPTED_CLOUD_PROGRAM = 254 # rw + WASH_DRY_LINKED = 255 # rw + DRYING_METHOD = 256 # rw + STEAM_VOLUME = 257 # rw + ION_DEODORIZATION = 258 # rw + PANEL_TIMING_PROGRAM_PARAMS = 260 # rw + STEAM_CARE_TIME = 261 # rw + 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 ID_QUERY = 10000 F_C = 10001 SND_STATE = 10004 From 613db3315e5c19b0006113f93621576be6fae5dd Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Fri, 10 Jul 2026 08:54:23 +0800 Subject: [PATCH 02/17] fix: update tests for A01 QoS 1 and timestamp changes --- tests/devices/traits/a01/test_init.py | 12 ++++++++---- tests/e2e/test_device_manager.py | 10 ++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) 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/test_device_manager.py b/tests/e2e/test_device_manager.py index 951c2abc2..247dd210f 100644 --- a/tests/e2e/test_device_manager.py +++ b/tests/e2e/test_device_manager.py @@ -226,6 +226,8 @@ async def test_v1_device( test_topic = TEST_TOPIC_FORMAT.format(duid="abc123") mqtt_responses: list[bytes] = [ *MQTT_DEFAULT_RESPONSES, + # PUBACK for the QoS 1 publish (MID=2) + mqtt_packet.gen_puback(mid=2), # ACK the GET_NETWORK_INFO call. id is deterministic based on deterministic_message_fixtures mqtt_packet.gen_publish( test_topic, mid=2, payload=response_builder.build_v1_rpc(data={"id": 9090, "result": NETWORK_INFO}) @@ -335,6 +337,8 @@ async def test_l01_device( test_topic = TEST_TOPIC_FORMAT.format(duid="abc123") mqtt_responses: list[bytes] = [ *MQTT_DEFAULT_RESPONSES, + # PUBACK for the QoS 1 publish (MID=2) + mqtt_packet.gen_puback(mid=2), # ACK the GET_NETWORK_INFO call. id is deterministic based on deterministic_message_fixtures mqtt_packet.gen_publish( test_topic, mid=2, payload=mqtt_response_builder.build_v1_rpc(data={"id": 9090, "result": NETWORK_INFO}) @@ -466,10 +470,14 @@ async def test_q7_device( test_topic = TEST_TOPIC_FORMAT.format(duid="device-id-q7") mqtt_responses: list[bytes] = [ *MQTT_DEFAULT_RESPONSES, + # PUBACK for the QoS 1 query publish (MID=2) + mqtt_packet.gen_puback(mid=2), # ACK the Query status call sent below. id is deterministic based on deterministic_message_fixtures mqtt_packet.gen_publish( test_topic, mid=2, payload=response_builder.build_b01_q7_rpc({"status": 2}, msg_id=9090) ), + # PUBACK for the QoS 1 start clean publish (MID=3) + mqtt_packet.gen_puback(mid=3), # ACK the start clean call sent below. id is deterministic based on deterministic_message_fixtures mqtt_packet.gen_publish(test_topic, mid=2, payload=response_builder.build_b01_q7_rpc("ok", msg_id=9093)), ] @@ -527,6 +535,8 @@ async def test_a01_device( test_topic = TEST_TOPIC_FORMAT.format(duid="zeo_duid") mqtt_responses: list[bytes] = [ *MQTT_DEFAULT_RESPONSES, + # PUBACK for the QoS 1 publish (MID=2, matches the query publish below) + mqtt_packet.gen_puback(mid=2), # 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})), ] From 9ae9fb7f09eab51a96e9c0a69fb49251a505519a Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Fri, 10 Jul 2026 09:27:29 +0800 Subject: [PATCH 03/17] Revert "fix: update tests for A01 QoS 1 and timestamp changes" This reverts commit 613db3315e5c19b0006113f93621576be6fae5dd. --- tests/devices/traits/a01/test_init.py | 12 ++++-------- tests/e2e/test_device_manager.py | 10 ---------- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/tests/devices/traits/a01/test_init.py b/tests/devices/traits/a01/test_init.py index 7e2fd6a69..8e1cb7dd8 100644 --- a/tests/devices/traits/a01/test_init.py +++ b/tests/devices/traits/a01/test_init.py @@ -77,8 +77,7 @@ 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 "t" in payload_data + assert payload_data == {"dps": {"10000": "[209, 201, 207, 214, 215, 227, 229, 230, 222, 224]"}} @pytest.mark.parametrize( @@ -175,8 +174,7 @@ 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 "t" in payload_data + assert payload_data == {"dps": {"10000": "[203, 207, 226, 227, 224, 218]"}} @pytest.mark.parametrize( @@ -247,8 +245,7 @@ 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 "t" in payload_data + assert payload_data == {"dps": {"209": 1}} async def test_zeo_api_set_value(zeo_api: ZeoApi, fake_channel: FakeChannel): @@ -264,5 +261,4 @@ 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 "t" in payload_data + assert payload_data == {"dps": {"204": "standard"}} diff --git a/tests/e2e/test_device_manager.py b/tests/e2e/test_device_manager.py index 247dd210f..951c2abc2 100644 --- a/tests/e2e/test_device_manager.py +++ b/tests/e2e/test_device_manager.py @@ -226,8 +226,6 @@ async def test_v1_device( test_topic = TEST_TOPIC_FORMAT.format(duid="abc123") mqtt_responses: list[bytes] = [ *MQTT_DEFAULT_RESPONSES, - # PUBACK for the QoS 1 publish (MID=2) - mqtt_packet.gen_puback(mid=2), # ACK the GET_NETWORK_INFO call. id is deterministic based on deterministic_message_fixtures mqtt_packet.gen_publish( test_topic, mid=2, payload=response_builder.build_v1_rpc(data={"id": 9090, "result": NETWORK_INFO}) @@ -337,8 +335,6 @@ async def test_l01_device( test_topic = TEST_TOPIC_FORMAT.format(duid="abc123") mqtt_responses: list[bytes] = [ *MQTT_DEFAULT_RESPONSES, - # PUBACK for the QoS 1 publish (MID=2) - mqtt_packet.gen_puback(mid=2), # ACK the GET_NETWORK_INFO call. id is deterministic based on deterministic_message_fixtures mqtt_packet.gen_publish( test_topic, mid=2, payload=mqtt_response_builder.build_v1_rpc(data={"id": 9090, "result": NETWORK_INFO}) @@ -470,14 +466,10 @@ async def test_q7_device( test_topic = TEST_TOPIC_FORMAT.format(duid="device-id-q7") mqtt_responses: list[bytes] = [ *MQTT_DEFAULT_RESPONSES, - # PUBACK for the QoS 1 query publish (MID=2) - mqtt_packet.gen_puback(mid=2), # ACK the Query status call sent below. id is deterministic based on deterministic_message_fixtures mqtt_packet.gen_publish( test_topic, mid=2, payload=response_builder.build_b01_q7_rpc({"status": 2}, msg_id=9090) ), - # PUBACK for the QoS 1 start clean publish (MID=3) - mqtt_packet.gen_puback(mid=3), # ACK the start clean call sent below. id is deterministic based on deterministic_message_fixtures mqtt_packet.gen_publish(test_topic, mid=2, payload=response_builder.build_b01_q7_rpc("ok", msg_id=9093)), ] @@ -535,8 +527,6 @@ async def test_a01_device( test_topic = TEST_TOPIC_FORMAT.format(duid="zeo_duid") mqtt_responses: list[bytes] = [ *MQTT_DEFAULT_RESPONSES, - # PUBACK for the QoS 1 publish (MID=2, matches the query publish below) - mqtt_packet.gen_puback(mid=2), # 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})), ] From 5399235a9092cf4a52fb04d9f989933d4a1ea36a Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Fri, 10 Jul 2026 09:35:02 +0800 Subject: [PATCH 04/17] fix: update tests and FakeChannel for A01 timestamp and qos changes --- roborock/devices/rpc/a01_channel.py | 12 ++++++++++-- roborock/devices/traits/a01/__init__.py | 8 +++++--- roborock/devices/transport/mqtt_channel.py | 8 ++++++-- roborock/mqtt/roborock_session.py | 16 +++++++++++----- roborock/mqtt/session.py | 7 ++++++- roborock/testing/channel.py | 5 ++++- tests/devices/traits/a01/test_init.py | 12 ++++++++---- tests/e2e/__snapshots__/test_device_manager.ambr | 9 +++++---- 8 files changed, 55 insertions(+), 22 deletions(-) diff --git a/roborock/devices/rpc/a01_channel.py b/roborock/devices/rpc/a01_channel.py index 2e2f9ceac..6ad8ee1aa 100644 --- a/roborock/devices/rpc/a01_channel.py +++ b/roborock/devices/rpc/a01_channel.py @@ -45,8 +45,16 @@ async def send_decoded_command( mqtt_channel: MqttChannel, params: dict[RoborockDyadDataProtocol, Any] | dict[RoborockZeoProtocol, Any], value_encoder: Callable[[Any], Any] | None = None, + qos: int = 0, ) -> 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 (0, 1, or 2). Defaults to 0. + """ _LOGGER.debug("Sending MQTT command: %s", params) roborock_message = encode_mqtt_payload(params, value_encoder) @@ -54,7 +62,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/traits/a01/__init__.py b/roborock/devices/traits/a01/__init__.py index 91d7fd0f5..1b849cdba 100644 --- a/roborock/devices/traits/a01/__init__.py +++ b/roborock/devices/traits/a01/__init__.py @@ -25,8 +25,6 @@ from datetime import time from typing import Any -from roborock.exceptions import RoborockException - from roborock.data import DyadProductInfo, DyadSndState, HomeDataProduct, RoborockCategory from roborock.data.dyad.dyad_code_mappings import ( DyadBrushSpeed, @@ -59,6 +57,7 @@ from roborock.devices.rpc.a01_channel import send_decoded_command from roborock.devices.traits import Trait from roborock.devices.transport.mqtt_channel import MqttChannel +from roborock.exceptions import RoborockException from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol _LOGGER = logging.getLogger(__name__) @@ -266,7 +265,10 @@ async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[Rob ) params[RoborockZeoProtocol.MODE] = 1 params[RoborockZeoProtocol.PROGRAM] = 1 - return await send_decoded_command(self._channel, params, value_encoder=lambda x: x) + # START commands require QoS 1; all other A01 commands use default QoS 0. + return await send_decoded_command( + self._channel, params, value_encoder=lambda x: x, qos=1 if protocol == RoborockZeoProtocol.START else 0 + ) def create(product: HomeDataProduct, mqtt_channel: MqttChannel) -> DyadApi | ZeoApi: diff --git a/roborock/devices/transport/mqtt_channel.py b/roborock/devices/transport/mqtt_channel.py index 5ff0ab085..e727f2011 100644 --- a/roborock/devices/transport/mqtt_channel.py +++ b/roborock/devices/transport/mqtt_channel.py @@ -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: int = 0) -> 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 0. """ 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 8bb4ed45a..1e7689d41 100644 --- a/roborock/mqtt/roborock_session.py +++ b/roborock/mqtt/roborock_session.py @@ -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: int = 0) -> 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 0. + """ _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, qos=1) + 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: int = 0) -> 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..93ea7e7d1 100644 --- a/roborock/mqtt/session.py +++ b/roborock/mqtt/session.py @@ -76,10 +76,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: int = 0) -> 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 (0, 1, or 2). Defaults to 0. """ @abstractmethod diff --git a/roborock/testing/channel.py b/roborock/testing/channel.py index 1550393bf..08b176603 100644 --- a/roborock/testing/channel.py +++ b/roborock/testing/channel.py @@ -93,13 +93,16 @@ 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: int = 0) -> None: """Default publish implementation. Records the message in ``published_messages`` and, if ``response_queue`` is non-empty, pops the first response and delivers it to all current subscribers (simulating a request/response round-trip). + + 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..59897228b 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 0d 04 a8 e3 83 03 57 f1 |..............W.| + 00000060 72 30 cd 82 45 f2 05 7f d3 3b e4 86 |r0..E....;..| [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 8dd5c3bd52074f4ae38abca1f0277929f86939ea Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Fri, 10 Jul 2026 09:59:59 +0800 Subject: [PATCH 05/17] fix: update test snapshots and freeze time for A01 device test --- tests/e2e/__snapshots__/test_device_manager.ambr | 4 ++-- tests/e2e/test_device_manager.py | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/e2e/__snapshots__/test_device_manager.ambr b/tests/e2e/__snapshots__/test_device_manager.ambr index 59897228b..b98903cb7 100644 --- a/tests/e2e/__snapshots__/test_device_manager.ambr +++ b/tests/e2e/__snapshots__/test_device_manager.ambr @@ -18,8 +18,8 @@ 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 0d 04 a8 e3 83 03 57 f1 |..............W.| - 00000060 72 30 cd 82 45 f2 05 7f d3 3b e4 86 |r0..E....;..| + 00000050 c6 bc 9e d2 da 07 fb d3 1d 80 db 56 f2 96 82 fe |...........V....| + 00000060 27 a5 31 29 05 b8 89 50 b3 94 d0 cd |'.1)...P....| [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/e2e/test_device_manager.py b/tests/e2e/test_device_manager.py index 951c2abc2..b988ab67f 100644 --- a/tests/e2e/test_device_manager.py +++ b/tests/e2e/test_device_manager.py @@ -16,6 +16,7 @@ import pytest import syrupy from Crypto.Cipher import AES +from freezegun import freeze_time from Crypto.Util.Padding import pad from roborock.data.b01_q7 import WorkStatusMapping @@ -512,6 +513,7 @@ async def test_q7_device( ) ], ) +@freeze_time("2025-01-20T12:00:00") async def test_a01_device( mock_rest: Any, push_mqtt_response: Callable[[bytes], None], From 99c2b53290925799ad6f75f495687ecedd42a857 Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Fri, 10 Jul 2026 10:09:47 +0800 Subject: [PATCH 06/17] fix: enable tick option for freeze_time in A01 device test --- roborock/devices/rpc/a01_channel.py | 2 ++ tests/e2e/test_device_manager.py | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/roborock/devices/rpc/a01_channel.py b/roborock/devices/rpc/a01_channel.py index 6ad8ee1aa..ff2a84062 100644 --- a/roborock/devices/rpc/a01_channel.py +++ b/roborock/devices/rpc/a01_channel.py @@ -30,6 +30,7 @@ async def send_decoded_command( mqtt_channel: MqttChannel, params: dict[RoborockDyadDataProtocol, Any], value_encoder: Callable[[Any], Any] | None = None, + qos: int = 0, ) -> dict[RoborockDyadDataProtocol, Any]: ... @@ -38,6 +39,7 @@ async def send_decoded_command( mqtt_channel: MqttChannel, params: dict[RoborockZeoProtocol, Any], value_encoder: Callable[[Any], Any] | None = None, + qos: int = 0, ) -> dict[RoborockZeoProtocol, Any]: ... diff --git a/tests/e2e/test_device_manager.py b/tests/e2e/test_device_manager.py index b988ab67f..22dfa2d15 100644 --- a/tests/e2e/test_device_manager.py +++ b/tests/e2e/test_device_manager.py @@ -16,8 +16,8 @@ import pytest import syrupy from Crypto.Cipher import AES -from freezegun import freeze_time from Crypto.Util.Padding import pad +from freezegun import freeze_time from roborock.data.b01_q7 import WorkStatusMapping from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP @@ -513,7 +513,7 @@ async def test_q7_device( ) ], ) -@freeze_time("2025-01-20T12:00:00") +@freeze_time("2025-01-20T12:00:00", tick=True) async def test_a01_device( mock_rest: Any, push_mqtt_response: Callable[[bytes], None], From e7bcb14fac776c1f1d3c762b5de4b2ea21835839 Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Fri, 10 Jul 2026 10:16:24 +0800 Subject: [PATCH 07/17] fix: update A01 device test snapshots and use fixed timestamp for deterministic results --- test_result.txt | Bin 0 -> 4770 bytes tests/e2e/__snapshots__/test_device_manager.ambr | 4 ++-- tests/e2e/test_device_manager.py | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 test_result.txt diff --git a/test_result.txt b/test_result.txt new file mode 100644 index 0000000000000000000000000000000000000000..f612270e80606e6e38eb8811160ffbca2abd2815 GIT binary patch literal 4770 zcmd6rOHbQC5Xa|Sso%ku-kR_xfW!gyajRO1_C$~sLI^1iHhJ*qhj07)&&I?isTu+a z$ckg{%+CDhx$FJ&d*9~0A{$uC`rIS#dg@!pQoHo`fiL z**)tb?*=`Wwqff$SGXekW_NI$kF;-C)z+X^ZPngG&72yrR&3YDtIz{2v7xoO?vS(w zZQJR& zXuXE_l6&0_ncc(6jQP@8oLhzKmY;mn#4}BhwCoh_9#|vf6zz=*JenbdDN@J$%d#-w zReqd-we9x=#U8lA?(@}QBlp5 zSN>^LQs#(>s_HKGuAoZm_`0Wh3wwxAag(u@4>#z`%R`van@;FE9jrVD@hk{8|4}Jg z5Axok?lZvthIc5vg$NdVk-D7v*EElw(k0bTW9}JTG0_+zzsGot7OggV*WRmKG4kOi zy>Xfk7lTlC1VkN2thJyIYOPtyj3VA6Y-uyDeV+Pw#n*-lAGzUPVV?Fc+8JmiOs4b% zS-3@pySEjzE)7UiJ!k@+4(d8eJNb^zMFT#lkx%*g3hZj+UZj@U2I zq;u3v=-xGY?6MHPM9tLL*S%Kz9#=6tOQ`-lmmU|X=c|_;TV_|tR~hwdt@-16ez?Ab z9k2S None: """Test the device manager end to end flow with an A01 device.""" + # Use a fixed timestamp so the MQTT payload snapshot is deterministic. + monkeypatch.setattr("roborock.protocols.a01_protocol.time.time", lambda: 1755750947.0) # Prepare MQTT requests response_builder = ResponseBuilder() response_builder.version = A01_VERSION From 75ecfe15910def27c99f07a546275212ee8b20c6 Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Fri, 10 Jul 2026 10:18:40 +0800 Subject: [PATCH 08/17] fix: remove obsolete test_result.txt file --- test_result.txt | Bin 4770 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 test_result.txt diff --git a/test_result.txt b/test_result.txt deleted file mode 100644 index f612270e80606e6e38eb8811160ffbca2abd2815..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4770 zcmd6rOHbQC5Xa|Sso%ku-kR_xfW!gyajRO1_C$~sLI^1iHhJ*qhj07)&&I?isTu+a z$ckg{%+CDhx$FJ&d*9~0A{$uC`rIS#dg@!pQoHo`fiL z**)tb?*=`Wwqff$SGXekW_NI$kF;-C)z+X^ZPngG&72yrR&3YDtIz{2v7xoO?vS(w zZQJR& zXuXE_l6&0_ncc(6jQP@8oLhzKmY;mn#4}BhwCoh_9#|vf6zz=*JenbdDN@J$%d#-w zReqd-we9x=#U8lA?(@}QBlp5 zSN>^LQs#(>s_HKGuAoZm_`0Wh3wwxAag(u@4>#z`%R`van@;FE9jrVD@hk{8|4}Jg z5Axok?lZvthIc5vg$NdVk-D7v*EElw(k0bTW9}JTG0_+zzsGot7OggV*WRmKG4kOi zy>Xfk7lTlC1VkN2thJyIYOPtyj3VA6Y-uyDeV+Pw#n*-lAGzUPVV?Fc+8JmiOs4b% zS-3@pySEjzE)7UiJ!k@+4(d8eJNb^zMFT#lkx%*g3hZj+UZj@U2I zq;u3v=-xGY?6MHPM9tLL*S%Kz9#=6tOQ`-lmmU|X=c|_;TV_|tR~hwdt@-16ez?Ab z9k2S Date: Mon, 13 Jul 2026 16:13:56 +0800 Subject: [PATCH 09/17] feat: update MQTT QoS handling to use MqttQos enum for better clarity and consistency --- roborock/devices/rpc/a01_channel.py | 9 ++++---- roborock/devices/traits/a01/__init__.py | 8 +++++-- roborock/devices/transport/mqtt_channel.py | 6 +++--- roborock/mqtt/roborock_session.py | 12 +++++++---- roborock/mqtt/session.py | 25 ++++++++++++++++++++-- roborock/testing/channel.py | 3 ++- 6 files changed, 47 insertions(+), 16 deletions(-) diff --git a/roborock/devices/rpc/a01_channel.py b/roborock/devices/rpc/a01_channel.py index ff2a84062..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,7 +31,7 @@ async def send_decoded_command( mqtt_channel: MqttChannel, params: dict[RoborockDyadDataProtocol, Any], value_encoder: Callable[[Any], Any] | None = None, - qos: int = 0, + qos: MqttQos = MqttQos.AT_MOST_ONCE, ) -> dict[RoborockDyadDataProtocol, Any]: ... @@ -39,7 +40,7 @@ async def send_decoded_command( mqtt_channel: MqttChannel, params: dict[RoborockZeoProtocol, Any], value_encoder: Callable[[Any], Any] | None = None, - qos: int = 0, + qos: MqttQos = MqttQos.AT_MOST_ONCE, ) -> dict[RoborockZeoProtocol, Any]: ... @@ -47,7 +48,7 @@ async def send_decoded_command( mqtt_channel: MqttChannel, params: dict[RoborockDyadDataProtocol, Any] | dict[RoborockZeoProtocol, Any], value_encoder: Callable[[Any], Any] | None = None, - qos: int = 0, + qos: MqttQos = MqttQos.AT_MOST_ONCE, ) -> dict[RoborockDyadDataProtocol, Any] | dict[RoborockZeoProtocol, Any]: """Send a command on the MQTT channel and get a decoded response. @@ -55,7 +56,7 @@ async def send_decoded_command( 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 (0, 1, or 2). Defaults to 0. + 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) diff --git a/roborock/devices/traits/a01/__init__.py b/roborock/devices/traits/a01/__init__.py index 1b849cdba..ef9ea7e3e 100644 --- a/roborock/devices/traits/a01/__init__.py +++ b/roborock/devices/traits/a01/__init__.py @@ -57,6 +57,7 @@ from roborock.devices.rpc.a01_channel import send_decoded_command from roborock.devices.traits import Trait from roborock.devices.transport.mqtt_channel import MqttChannel +from roborock.mqtt.session import MqttQos from roborock.exceptions import RoborockException from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol @@ -265,9 +266,12 @@ async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[Rob ) params[RoborockZeoProtocol.MODE] = 1 params[RoborockZeoProtocol.PROGRAM] = 1 - # START commands require QoS 1; all other A01 commands use default QoS 0. + # START commands require AT_LEAST_ONCE; all other A01 commands use default AT_MOST_ONCE. return await send_decoded_command( - self._channel, params, value_encoder=lambda x: x, qos=1 if protocol == RoborockZeoProtocol.START else 0 + self._channel, + params, + value_encoder=lambda x: x, + qos=MqttQos.AT_LEAST_ONCE if protocol == RoborockZeoProtocol.START else MqttQos.AT_MOST_ONCE, ) diff --git a/roborock/devices/transport/mqtt_channel.py b/roborock/devices/transport/mqtt_channel.py index e727f2011..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,7 +89,7 @@ async def subscribe_stream(self) -> AsyncGenerator[RoborockMessage, None]: finally: unsub() - async def publish(self, message: RoborockMessage, qos: int = 0) -> 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 @@ -97,7 +97,7 @@ async def publish(self, message: RoborockMessage, qos: int = 0) -> None: Args: message: The message to publish. - qos: The MQTT QoS level. Defaults to 0. + qos: The MQTT QoS level. Defaults to AT_MOST_ONCE. """ try: encoded_msg = self._encoder(message) diff --git a/roborock/mqtt/roborock_session.py b/roborock/mqtt/roborock_session.py index 1e7689d41..ab4b83aa6 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,13 +361,15 @@ def delayed_unsub(): return delayed_unsub - async def publish(self, topic: str, message: bytes, qos: int = 0) -> None: + 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 0. + qos: The MQTT QoS level. Defaults to AT_MOST_ONCE. """ _LOGGER.debug("Sending message to topic %s: %s", topic, message) client: aiomqtt.Client @@ -423,7 +425,9 @@ 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, qos: int = 0) -> 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. diff --git a/roborock/mqtt/session.py b/roborock/mqtt/session.py index 93ea7e7d1..d2b6b2303 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,26 @@ DEFAULT_TIMEOUT = 30.0 + +class MqttQos(IntEnum): + """MQTT Quality of Service levels. + + Determines the delivery guarantee for published messages: + + - 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. + + A01 devices (Zeo, Dyad) require ``AT_LEAST_ONCE`` for DP200 (start) + commands. Other protocol versions use ``AT_MOST_ONCE``. + """ + + AT_MOST_ONCE = 0 + AT_LEAST_ONCE = 1 + EXACTLY_ONCE = 2 + SessionUnauthorizedHook = Callable[[], None] @@ -76,7 +97,7 @@ async def subscribe(self, device_id: str, callback: Callable[[bytes], None]) -> """ @abstractmethod - async def publish(self, topic: str, message: bytes, qos: int = 0) -> 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. @@ -84,7 +105,7 @@ async def publish(self, topic: str, message: bytes, qos: int = 0) -> None: Args: topic: The MQTT topic to publish to. message: The message payload. - qos: The MQTT QoS level (0, 1, or 2). Defaults to 0. + qos: The MQTT QoS level. Defaults to AT_MOST_ONCE. """ @abstractmethod diff --git a/roborock/testing/channel.py b/roborock/testing/channel.py index 08b176603..7da163bdc 100644 --- a/roborock/testing/channel.py +++ b/roborock/testing/channel.py @@ -11,6 +11,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 @@ -93,7 +94,7 @@ 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, qos: int = 0) -> None: + async def _publish(self, message: RoborockMessage, qos: MqttQos = MqttQos.AT_MOST_ONCE) -> None: """Default publish implementation. Records the message in ``published_messages`` and, if From b6ccf15ce8169545eb647deef335af3192ce95d0 Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Mon, 13 Jul 2026 16:57:35 +0800 Subject: [PATCH 10/17] fix: refactor ZeoApi to streamline START command parameter handling --- roborock/devices/traits/a01/__init__.py | 81 ++++++++++++------------- 1 file changed, 39 insertions(+), 42 deletions(-) diff --git a/roborock/devices/traits/a01/__init__.py b/roborock/devices/traits/a01/__init__.py index ef9ea7e3e..ff12e2316 100644 --- a/roborock/devices/traits/a01/__init__.py +++ b/roborock/devices/traits/a01/__init__.py @@ -58,7 +58,6 @@ from roborock.devices.traits import Trait from roborock.devices.transport.mqtt_channel import MqttChannel from roborock.mqtt.session import MqttQos -from roborock.exceptions import RoborockException from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol _LOGGER = logging.getLogger(__name__) @@ -227,52 +226,50 @@ async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[Robor ) return {protocol: convert_zeo_value(protocol, response.get(protocol)) for protocol in protocols} + # The DP IDs that must be bundled with START commands. These are the + # universally-supported core parameters common to all Zeo devices. + _START_PARAM_DPS: tuple[RoborockZeoProtocol, ...] = ( + RoborockZeoProtocol.MODE, + RoborockZeoProtocol.PROGRAM, + RoborockZeoProtocol.TEMP, + RoborockZeoProtocol.RINSE_TIMES, + RoborockZeoProtocol.SPIN_LEVEL, + RoborockZeoProtocol.DRYING_MODE, + ) + + async def _get_current_params( + self, + ) -> dict[RoborockZeoProtocol, Any]: + """Query the device for the parameters needed by a START command. + + Returns a dictionary of the current values for each start-relevant + DP. If a DP is not returned by the device it is omitted from the + result. + """ + current = await send_decoded_command( + self._channel, + {RoborockZeoProtocol.ID_QUERY: list(self._START_PARAM_DPS)}, + value_encoder=json.dumps, + ) + return {dp: current[dp] for dp in self._START_PARAM_DPS if dp in current} + async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[RoborockZeoProtocol, Any]: """Set a value for a specific protocol on the device.""" params: dict[RoborockZeoProtocol, Any] = {protocol: value} if protocol == RoborockZeoProtocol.START and value == 1: _LOGGER.debug("Start command detected, querying current device state") - try: - current = await send_decoded_command( - self._channel, - { - RoborockZeoProtocol.ID_QUERY: [ - RoborockZeoProtocol.MODE, - RoborockZeoProtocol.PROGRAM, - RoborockZeoProtocol.TEMP, - RoborockZeoProtocol.RINSE_TIMES, - RoborockZeoProtocol.SPIN_LEVEL, - RoborockZeoProtocol.DRYING_MODE, - ] - }, - value_encoder=json.dumps, - ) - for dp, fallback in ( - (RoborockZeoProtocol.MODE, 1), - (RoborockZeoProtocol.PROGRAM, 1), - (RoborockZeoProtocol.TEMP, None), - (RoborockZeoProtocol.RINSE_TIMES, None), - (RoborockZeoProtocol.SPIN_LEVEL, None), - (RoborockZeoProtocol.DRYING_MODE, None), - ): - if dp in current: - params[dp] = current[dp] - elif fallback is not None: - params[dp] = fallback - except RoborockException: - _LOGGER.warning( - "Failed to query device state before start, using defaults", - exc_info=True, - ) - params[RoborockZeoProtocol.MODE] = 1 - params[RoborockZeoProtocol.PROGRAM] = 1 - # START commands require AT_LEAST_ONCE; all other A01 commands use default AT_MOST_ONCE. - return await send_decoded_command( - self._channel, - params, - value_encoder=lambda x: x, - qos=MqttQos.AT_LEAST_ONCE if protocol == RoborockZeoProtocol.START else MqttQos.AT_MOST_ONCE, - ) + current = await self._get_current_params() + for dp, fallback in ( + (RoborockZeoProtocol.MODE, 1), + (RoborockZeoProtocol.PROGRAM, 1), + ): + if dp not in current: + current[dp] = fallback + params.update(current) + return await send_decoded_command( + self._channel, params, value_encoder=lambda x: x, qos=MqttQos.AT_LEAST_ONCE + ) + return await send_decoded_command(self._channel, params, value_encoder=lambda x: x) def create(product: HomeDataProduct, mqtt_channel: MqttChannel) -> DyadApi | ZeoApi: From 784d95a812df32549bcc861d64004f82771a6ab8 Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Mon, 13 Jul 2026 23:03:21 +0800 Subject: [PATCH 11/17] fix: enforce required Zeo START params and remove fallback defaults --- roborock/devices/traits/a01/__init__.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/roborock/devices/traits/a01/__init__.py b/roborock/devices/traits/a01/__init__.py index ff12e2316..af410dcb0 100644 --- a/roborock/devices/traits/a01/__init__.py +++ b/roborock/devices/traits/a01/__init__.py @@ -243,15 +243,22 @@ async def _get_current_params( """Query the device for the parameters needed by a START command. Returns a dictionary of the current values for each start-relevant - DP. If a DP is not returned by the device it is omitted from the - result. + DP. Raises :exc:`RoborockException` if any of the requested DPs + are not returned by the device. """ + from roborock.exceptions import RoborockException + current = await send_decoded_command( self._channel, {RoborockZeoProtocol.ID_QUERY: list(self._START_PARAM_DPS)}, value_encoder=json.dumps, ) - return {dp: current[dp] for dp in self._START_PARAM_DPS if dp in current} + for dp in self._START_PARAM_DPS: + if dp not in current: + raise RoborockException( + f"Device did not return required DP {dp.name} ({int(dp)})" + ) + return {dp: current[dp] for dp in self._START_PARAM_DPS} async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[RoborockZeoProtocol, Any]: """Set a value for a specific protocol on the device.""" @@ -259,12 +266,6 @@ async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[Rob if protocol == RoborockZeoProtocol.START and value == 1: _LOGGER.debug("Start command detected, querying current device state") current = await self._get_current_params() - for dp, fallback in ( - (RoborockZeoProtocol.MODE, 1), - (RoborockZeoProtocol.PROGRAM, 1), - ): - if dp not in current: - current[dp] = fallback params.update(current) return await send_decoded_command( self._channel, params, value_encoder=lambda x: x, qos=MqttQos.AT_LEAST_ONCE From 0843e8a7b3948b389603d5a04d3f097cd5d038bf Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Mon, 13 Jul 2026 23:14:31 +0800 Subject: [PATCH 12/17] fix: move A01 timestamp patch into deterministic_message_fixtures --- tests/e2e/test_device_manager.py | 3 --- tests/fixtures/logging_fixtures.py | 1 + 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/e2e/test_device_manager.py b/tests/e2e/test_device_manager.py index 65cb2210f..951c2abc2 100644 --- a/tests/e2e/test_device_manager.py +++ b/tests/e2e/test_device_manager.py @@ -513,7 +513,6 @@ async def test_q7_device( ], ) async def test_a01_device( - monkeypatch: Any, mock_rest: Any, push_mqtt_response: Callable[[bytes], None], log: CapturedRequestLog, @@ -522,8 +521,6 @@ async def test_a01_device( snapshot: syrupy.SnapshotAssertion, ) -> None: """Test the device manager end to end flow with an A01 device.""" - # Use a fixed timestamp so the MQTT payload snapshot is deterministic. - monkeypatch.setattr("roborock.protocols.a01_protocol.time.time", lambda: 1755750947.0) # Prepare MQTT requests response_builder = ResponseBuilder() response_builder.version = A01_VERSION 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 9d072ba586b8e7b3ec792abd8406c8a9bd62ca6d Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Mon, 13 Jul 2026 23:17:39 +0800 Subject: [PATCH 13/17] fix: improve MqttQos docstring clarity by restructuring delivery guarantee descriptions --- roborock/mqtt/session.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/roborock/mqtt/session.py b/roborock/mqtt/session.py index d2b6b2303..52d891541 100644 --- a/roborock/mqtt/session.py +++ b/roborock/mqtt/session.py @@ -15,21 +15,18 @@ class MqttQos(IntEnum): """MQTT Quality of Service levels. - Determines the delivery guarantee for published messages: - - - 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. - 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] From 17fc535c19f63863083b62f8a60f6c4429e4b36b Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Mon, 13 Jul 2026 23:29:54 +0800 Subject: [PATCH 14/17] feat: add ZeoStartParams dataclass and enhance ZeoApi start command handling --- roborock/devices/traits/a01/__init__.py | 87 ++++++++++++++++++++----- 1 file changed, 70 insertions(+), 17 deletions(-) diff --git a/roborock/devices/traits/a01/__init__.py b/roborock/devices/traits/a01/__init__.py index af410dcb0..ac748cf2d 100644 --- a/roborock/devices/traits/a01/__init__.py +++ b/roborock/devices/traits/a01/__init__.py @@ -22,6 +22,7 @@ import json import logging from collections.abc import Callable +from dataclasses import dataclass from datetime import time from typing import Any @@ -57,6 +58,7 @@ from roborock.devices.rpc.a01_channel import send_decoded_command from roborock.devices.traits import Trait from roborock.devices.transport.mqtt_channel import MqttChannel +from roborock.exceptions import RoborockException from roborock.mqtt.session import MqttQos from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol @@ -208,6 +210,34 @@ async def set_value(self, protocol: RoborockDyadDataProtocol, value: Any) -> dic return await send_decoded_command(self._channel, params) +@dataclass +class ZeoStartParams: + """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.""" + + rinse_times: int | None = None + """Number of rinse cycles.""" + + spin_level: int | None = None + """Spin speed (RPM).""" + + drying_mode: int | None = None + """Drying mode (e.g. quick, iron, store).""" + + class ZeoApi(Trait): """API for interacting with Zeo devices.""" @@ -237,17 +267,12 @@ async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[Robor RoborockZeoProtocol.DRYING_MODE, ) - async def _get_current_params( - self, - ) -> dict[RoborockZeoProtocol, Any]: - """Query the device for the parameters needed by a START command. + async def _get_current_params(self) -> ZeoStartParams: + """Query the device and return typed start parameters. - Returns a dictionary of the current values for each start-relevant - DP. Raises :exc:`RoborockException` if any of the requested DPs - are not returned by the device. + Raises :exc:`RoborockException` if any of the required DPs are not + returned by the device. """ - from roborock.exceptions import RoborockException - current = await send_decoded_command( self._channel, {RoborockZeoProtocol.ID_QUERY: list(self._START_PARAM_DPS)}, @@ -258,18 +283,46 @@ async def _get_current_params( raise RoborockException( f"Device did not return required DP {dp.name} ({int(dp)})" ) - return {dp: current[dp] for dp in self._START_PARAM_DPS} + return ZeoStartParams( + mode=current[RoborockZeoProtocol.MODE], + program=current[RoborockZeoProtocol.PROGRAM], + temp=current.get(RoborockZeoProtocol.TEMP), + rinse_times=current.get(RoborockZeoProtocol.RINSE_TIMES), + spin_level=current.get(RoborockZeoProtocol.SPIN_LEVEL), + drying_mode=current.get(RoborockZeoProtocol.DRYING_MODE), + ) + + async def start(self) -> dict[RoborockZeoProtocol, Any]: + """Start the device using the current mode and program parameters. + + Queries the device for the current wash settings and sends them + bundled with the START command. This uses QoS 1 as required by the + device firmware. + """ + _LOGGER.debug("Start command: querying current device state") + p = await self._get_current_params() + dps: dict[RoborockZeoProtocol, Any] = { + RoborockZeoProtocol.START: 1, + RoborockZeoProtocol.MODE: p.mode, + RoborockZeoProtocol.PROGRAM: p.program, + } + for dp, val in ( + (RoborockZeoProtocol.TEMP, p.temp), + (RoborockZeoProtocol.RINSE_TIMES, p.rinse_times), + (RoborockZeoProtocol.SPIN_LEVEL, p.spin_level), + (RoborockZeoProtocol.DRYING_MODE, p.drying_mode), + ): + if val is not None: + dps[dp] = val + return await send_decoded_command( + self._channel, dps, value_encoder=lambda x: x, qos=MqttQos.AT_LEAST_ONCE + ) async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[RoborockZeoProtocol, Any]: """Set a value for a specific protocol on the device.""" - params: dict[RoborockZeoProtocol, Any] = {protocol: value} if protocol == RoborockZeoProtocol.START and value == 1: - _LOGGER.debug("Start command detected, querying current device state") - current = await self._get_current_params() - params.update(current) - return await send_decoded_command( - self._channel, params, value_encoder=lambda x: x, qos=MqttQos.AT_LEAST_ONCE - ) + return await self.start() + params: dict[RoborockZeoProtocol, Any] = {protocol: value} return await send_decoded_command(self._channel, params, value_encoder=lambda x: x) From 324481dc45af754fa13d37b39e335659ce6c82cc Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Mon, 13 Jul 2026 23:33:15 +0800 Subject: [PATCH 15/17] fix: update ZeoState class to use canonical names for aftercare states --- roborock/data/zeo/zeo_code_mappings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/roborock/data/zeo/zeo_code_mappings.py b/roborock/data/zeo/zeo_code_mappings.py index 7e9aa7fe7..96d0c2694 100644 --- a/roborock/data/zeo/zeo_code_mappings.py +++ b/roborock/data/zeo/zeo_code_mappings.py @@ -21,8 +21,8 @@ class ZeoState(RoborockEnum): under_delay_start = 9 done = 10 updating = 11 - smart_hosting = 12 - smart_hosting_waiting = 13 + aftercare = 12 # canonical name from app bundle: smart_hosting + waiting_for_aftercare = 13 # canonical name from app bundle: smart_hosting_waiting steam_caring = 14 descaling = 15 cloth_ready = 16 From c2c688f44cd166368af0f24fdc0ccc17e5bbe198 Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Mon, 13 Jul 2026 23:50:51 +0800 Subject: [PATCH 16/17] chore: resolve merge conflicts with upstream main --- roborock/testing/channel.py | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/roborock/testing/channel.py b/roborock/testing/channel.py index 7da163bdc..fdd7bc621 100644 --- a/roborock/testing/channel.py +++ b/roborock/testing/channel.py @@ -75,6 +75,8 @@ def __init__(self, is_local: bool = False): self.close = MagicMock(side_effect=self._close) self.protocol_version = LocalProtocolVersion.V1 + self.publish_handler: Callable[[RoborockMessage], None] | None = self._default_publish_handler + self.restart = AsyncMock() self.health_manager = HealthManager(self.restart) @@ -97,10 +99,7 @@ def is_local_connected(self) -> bool: async def _publish(self, message: RoborockMessage, qos: MqttQos = MqttQos.AT_MOST_ONCE) -> None: """Default publish implementation. - Records the message in ``published_messages`` and, if - ``response_queue`` is non-empty, pops the first response and - delivers it to all current subscribers (simulating a - request/response round-trip). + 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. @@ -108,9 +107,8 @@ async def _publish(self, message: RoborockMessage, qos: MqttQos = MqttQos.AT_MOS self.published_messages.append(message) if self.publish_side_effect: raise self.publish_side_effect - if self.response_queue: - response = self.response_queue.pop(0) - self.notify_subscribers(response) + if self.publish_handler: + await self.publish_handler(message) async def _subscribe(self, callback: Callable[[RoborockMessage], None]) -> Callable[[], None]: """Default subscribe implementation. @@ -128,3 +126,21 @@ def notify_subscribers(self, message: RoborockMessage) -> None: """ for subscriber in list(self.subscribers): subscriber(message) + + async def _default_publish_handler(self, message: RoborockMessage) -> None: + """Default handler that pops canned responses from response_queue.""" + if self.response_queue: + response = self.response_queue.pop(0) + self.notify_subscribers(response) + + def inject_error(self, exception: Exception) -> None: + """Inject a transient failure into all channel operations (publish, subscribe, connect).""" + self.publish.side_effect = exception + self.subscribe.side_effect = exception + self.connect.side_effect = exception + + def clear_error(self) -> None: + """Restore default success behaviors on all channel operations.""" + self.publish.side_effect = self._publish + self.subscribe.side_effect = self._subscribe + self.connect.side_effect = self._connect From 6dd21f622c9e96e0ad1ec57bc482c356102fc7c3 Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Tue, 14 Jul 2026 00:04:11 +0800 Subject: [PATCH 17/17] refactor: streamline function definitions and remove unnecessary line breaks --- roborock/devices/traits/a01/__init__.py | 8 ++------ roborock/mqtt/roborock_session.py | 8 ++------ roborock/mqtt/session.py | 1 + roborock/testing/channel.py | 7 ------- 4 files changed, 5 insertions(+), 19 deletions(-) diff --git a/roborock/devices/traits/a01/__init__.py b/roborock/devices/traits/a01/__init__.py index ac748cf2d..300f02413 100644 --- a/roborock/devices/traits/a01/__init__.py +++ b/roborock/devices/traits/a01/__init__.py @@ -280,9 +280,7 @@ async def _get_current_params(self) -> ZeoStartParams: ) for dp in self._START_PARAM_DPS: if dp not in current: - raise RoborockException( - f"Device did not return required DP {dp.name} ({int(dp)})" - ) + raise RoborockException(f"Device did not return required DP {dp.name} ({int(dp)})") return ZeoStartParams( mode=current[RoborockZeoProtocol.MODE], program=current[RoborockZeoProtocol.PROGRAM], @@ -314,9 +312,7 @@ async def start(self) -> dict[RoborockZeoProtocol, Any]: ): if val is not None: dps[dp] = val - return await send_decoded_command( - self._channel, dps, value_encoder=lambda x: x, qos=MqttQos.AT_LEAST_ONCE - ) + return await send_decoded_command(self._channel, dps, value_encoder=lambda x: x, qos=MqttQos.AT_LEAST_ONCE) async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[RoborockZeoProtocol, Any]: """Set a value for a specific protocol on the device.""" diff --git a/roborock/mqtt/roborock_session.py b/roborock/mqtt/roborock_session.py index ab4b83aa6..15202372e 100644 --- a/roborock/mqtt/roborock_session.py +++ b/roborock/mqtt/roborock_session.py @@ -361,9 +361,7 @@ def delayed_unsub(): return delayed_unsub - async def publish( - self, topic: str, message: bytes, qos: MqttQos = MqttQos.AT_MOST_ONCE - ) -> None: + async def publish(self, topic: str, message: bytes, qos: MqttQos = MqttQos.AT_MOST_ONCE) -> None: """Publish a message on the topic. Args: @@ -425,9 +423,7 @@ 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, qos: MqttQos = MqttQos.AT_MOST_ONCE - ) -> 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. diff --git a/roborock/mqtt/session.py b/roborock/mqtt/session.py index 52d891541..319615fc4 100644 --- a/roborock/mqtt/session.py +++ b/roborock/mqtt/session.py @@ -28,6 +28,7 @@ class MqttQos(IntEnum): EXACTLY_ONCE = 2 """Guaranteed delivery with no duplicates. Broker sends PUBREC/PUBREL/PUBCOMP.""" + SessionUnauthorizedHook = Callable[[], None] diff --git a/roborock/testing/channel.py b/roborock/testing/channel.py index 4fc66c4f0..9bb811532 100644 --- a/roborock/testing/channel.py +++ b/roborock/testing/channel.py @@ -83,7 +83,6 @@ def __init__(self, is_local: bool = False): self.close = MagicMock(side_effect=self._close) self.protocol_version = LocalProtocolVersion.V1 - self.publish_handler: Callable[[RoborockMessage], None] | None = self._default_publish_handler self.restart = AsyncMock() self.health_manager = HealthManager(self.restart) @@ -141,12 +140,6 @@ def notify_subscribers(self, message: RoborockMessage) -> None: for subscriber in list(self.subscribers): subscriber(message) - async def _default_publish_handler(self, message: RoborockMessage) -> None: - """Default handler that pops canned responses from response_queue.""" - if self.response_queue: - response = self.response_queue.pop(0) - self.notify_subscribers(response) - def inject_error(self, exception: Exception) -> None: """Inject a transient failure into all channel operations (publish, subscribe, connect).""" self.publish.side_effect = exception