From 6eaf4f9becf2998e93ecc4869f03e935735a5787 Mon Sep 17 00:00:00 2001 From: NOisi-X Date: Thu, 9 Jul 2026 22:35:30 +0800 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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