Skip to content

Commit 3deed81

Browse files
NOisi-xNOisi-X
andauthored
feat: add MQTT QoS support and timestamp to A01 protocol payload (#891)
Add MqttQos enum (AT_MOST_ONCE=0, AT_LEAST_ONCE=1, EXACTLY_ONCE=2) and thread a qos parameter through the publish chain (MqttSession -> MqttChannel -> send_decoded_command). All existing callers keep default AT_MOST_ONCE (backward compatible). Also add a unix timestamp field to A01 encode_mqtt_payload, required by Zeo/Dyad devices for command acceptance. Co-authored-by: NOisi-X <xcynoisi@gmail.com>
1 parent ce71dff commit 3deed81

9 files changed

Lines changed: 82 additions & 22 deletions

File tree

roborock/devices/rpc/a01_channel.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from roborock.devices.transport.mqtt_channel import MqttChannel
99
from roborock.exceptions import RoborockException
10+
from roborock.mqtt.session import MqttQos
1011
from roborock.protocols.a01_protocol import (
1112
decode_rpc_response,
1213
encode_mqtt_payload,
@@ -30,6 +31,7 @@ async def send_decoded_command(
3031
mqtt_channel: MqttChannel,
3132
params: dict[RoborockDyadDataProtocol, Any],
3233
value_encoder: Callable[[Any], Any] | None = None,
34+
qos: MqttQos = MqttQos.AT_MOST_ONCE,
3335
) -> dict[RoborockDyadDataProtocol, Any]: ...
3436

3537

@@ -38,23 +40,32 @@ async def send_decoded_command(
3840
mqtt_channel: MqttChannel,
3941
params: dict[RoborockZeoProtocol, Any],
4042
value_encoder: Callable[[Any], Any] | None = None,
43+
qos: MqttQos = MqttQos.AT_MOST_ONCE,
4144
) -> dict[RoborockZeoProtocol, Any]: ...
4245

4346

4447
async def send_decoded_command(
4548
mqtt_channel: MqttChannel,
4649
params: dict[RoborockDyadDataProtocol, Any] | dict[RoborockZeoProtocol, Any],
4750
value_encoder: Callable[[Any], Any] | None = None,
51+
qos: MqttQos = MqttQos.AT_MOST_ONCE,
4852
) -> dict[RoborockDyadDataProtocol, Any] | dict[RoborockZeoProtocol, Any]:
49-
"""Send a command on the MQTT channel and get a decoded response."""
53+
"""Send a command on the MQTT channel and get a decoded response.
54+
55+
Args:
56+
mqtt_channel: The MQTT channel to send the command on.
57+
params: The parameters to send.
58+
value_encoder: A function to encode the values of the dictionary.
59+
qos: The MQTT QoS level. Defaults to AT_MOST_ONCE.
60+
"""
5061
_LOGGER.debug("Sending MQTT command: %s", params)
5162
roborock_message = encode_mqtt_payload(params, value_encoder)
5263

5364
# For commands that set values: send the command and do not
5465
# block waiting for a response. Queries are handled below.
5566
param_values = {int(k): v for k, v in params.items()}
5667
if not (query_values := param_values.get(_ID_QUERY)):
57-
await mqtt_channel.publish(roborock_message)
68+
await mqtt_channel.publish(roborock_message, qos=qos)
5869
return {}
5970

6071
# Merge any results together than contain the requested data. This

roborock/devices/transport/mqtt_channel.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from roborock.data import HomeDataDevice, RRiot, UserData
99
from roborock.exceptions import RoborockException
1010
from roborock.mqtt.health_manager import HealthManager
11-
from roborock.mqtt.session import MqttParams, MqttSession, MqttSessionException
11+
from roborock.mqtt.session import MqttParams, MqttQos, MqttSession, MqttSessionException
1212
from roborock.protocol import create_mqtt_decoder, create_mqtt_encoder
1313
from roborock.roborock_message import RoborockMessage
1414
from roborock.util import RoborockLoggerAdapter
@@ -89,19 +89,23 @@ async def subscribe_stream(self) -> AsyncGenerator[RoborockMessage, None]:
8989
finally:
9090
unsub()
9191

92-
async def publish(self, message: RoborockMessage) -> None:
92+
async def publish(self, message: RoborockMessage, qos: MqttQos = MqttQos.AT_MOST_ONCE) -> None:
9393
"""Publish a command message.
9494
9595
The caller is responsible for handling any responses and associating them
9696
with the incoming request.
97+
98+
Args:
99+
message: The message to publish.
100+
qos: The MQTT QoS level. Defaults to AT_MOST_ONCE.
97101
"""
98102
try:
99103
encoded_msg = self._encoder(message)
100104
except Exception as e:
101105
self._logger.exception("Error encoding MQTT message: %s", e)
102106
raise RoborockException(f"Failed to encode MQTT message: {e}") from e
103107
try:
104-
return await self._mqtt_session.publish(self._publish_topic, encoded_msg)
108+
return await self._mqtt_session.publish(self._publish_topic, encoded_msg, qos=qos)
105109
except MqttSessionException as e:
106110
self._logger.debug("Error publishing MQTT message: %s", e)
107111
raise RoborockException(f"Failed to publish MQTT message: {e}") from e

roborock/mqtt/roborock_session.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from roborock.diagnostics import Diagnostics, redact_topic_name
2323

2424
from .health_manager import HealthManager
25-
from .session import MqttParams, MqttSession, MqttSessionException, MqttSessionUnauthorized
25+
from .session import MqttParams, MqttQos, MqttSession, MqttSessionException, MqttSessionUnauthorized
2626

2727
_LOGGER = logging.getLogger(__name__)
2828
_MQTT_LOGGER = logging.getLogger(f"{__name__}.aiomqtt")
@@ -361,8 +361,14 @@ def delayed_unsub():
361361

362362
return delayed_unsub
363363

364-
async def publish(self, topic: str, message: bytes) -> None:
365-
"""Publish a message on the topic."""
364+
async def publish(self, topic: str, message: bytes, qos: MqttQos = MqttQos.AT_MOST_ONCE) -> None:
365+
"""Publish a message on the topic.
366+
367+
Args:
368+
topic: The MQTT topic to publish to.
369+
message: The message payload.
370+
qos: The MQTT QoS level. Defaults to AT_MOST_ONCE.
371+
"""
366372
_LOGGER.debug("Sending message to topic %s: %s", topic, message)
367373
client: aiomqtt.Client
368374
async with self._client_lock:
@@ -371,7 +377,7 @@ async def publish(self, topic: str, message: bytes) -> None:
371377
client = self._client
372378
try:
373379
with self._diagnostics.timer("publish"):
374-
await client.publish(topic, message)
380+
await client.publish(topic, message, qos=qos)
375381
except MqttError as err:
376382
raise MqttSessionException(f"Error publishing message: {err}") from err
377383

@@ -417,13 +423,13 @@ async def subscribe(self, device_id: str, callback: Callable[[bytes], None]) ->
417423
await self._maybe_start()
418424
return await self._session.subscribe(device_id, callback)
419425

420-
async def publish(self, topic: str, message: bytes) -> None:
426+
async def publish(self, topic: str, message: bytes, qos: MqttQos = MqttQos.AT_MOST_ONCE) -> None:
421427
"""Publish a message on the specified topic.
422428
423429
This will raise an exception if the message could not be sent.
424430
"""
425431
await self._maybe_start()
426-
return await self._session.publish(topic, message)
432+
return await self._session.publish(topic, message, qos=qos)
427433

428434
async def close(self) -> None:
429435
"""Cancels the mqtt loop.

roborock/mqtt/session.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,32 @@
33
from abc import ABC, abstractmethod
44
from collections.abc import Callable
55
from dataclasses import dataclass, field
6+
from enum import IntEnum
67

78
from roborock.diagnostics import Diagnostics
89
from roborock.exceptions import RoborockException
910
from roborock.mqtt.health_manager import HealthManager
1011

1112
DEFAULT_TIMEOUT = 30.0
1213

14+
15+
class MqttQos(IntEnum):
16+
"""MQTT Quality of Service levels.
17+
18+
A01 devices (Zeo, Dyad) require ``AT_LEAST_ONCE`` for DP200 (start)
19+
commands. Other protocol versions use ``AT_MOST_ONCE``.
20+
"""
21+
22+
AT_MOST_ONCE = 0
23+
"""Fire-and-forget. No acknowledgment required."""
24+
25+
AT_LEAST_ONCE = 1
26+
"""Guaranteed delivery with possible duplicates. Broker sends PUBACK."""
27+
28+
EXACTLY_ONCE = 2
29+
"""Guaranteed delivery with no duplicates. Broker sends PUBREC/PUBREL/PUBCOMP."""
30+
31+
1332
SessionUnauthorizedHook = Callable[[], None]
1433

1534

@@ -76,10 +95,15 @@ async def subscribe(self, device_id: str, callback: Callable[[bytes], None]) ->
7695
"""
7796

7897
@abstractmethod
79-
async def publish(self, topic: str, message: bytes) -> None:
98+
async def publish(self, topic: str, message: bytes, qos: MqttQos = MqttQos.AT_MOST_ONCE) -> None:
8099
"""Publish a message on the specified topic.
81100
82101
This will raise an exception if the message could not be sent.
102+
103+
Args:
104+
topic: The MQTT topic to publish to.
105+
message: The message payload.
106+
qos: The MQTT QoS level. Defaults to AT_MOST_ONCE.
83107
"""
84108

85109
@abstractmethod

roborock/protocols/a01_protocol.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import json
44
import logging
5+
import time
56
from collections.abc import Callable
67
from typing import Any
78

@@ -42,7 +43,10 @@ def encode_mqtt_payload(
4243
"""
4344
if value_encoder is None:
4445
value_encoder = _no_encode
45-
dps_data = {"dps": {key: value_encoder(value) for key, value in data.items()}}
46+
dps_data = {
47+
"dps": {key: value_encoder(value) for key, value in data.items()},
48+
"t": int(time.time()),
49+
}
4650
payload = pad(json.dumps(dps_data).encode("utf-8"), AES.block_size)
4751
return RoborockMessage(
4852
protocol=RoborockMessageProtocol.RPC_REQUEST,

roborock/testing/channel.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from roborock.devices.transport.channel import Channel
1414
from roborock.mqtt.health_manager import HealthManager
15+
from roborock.mqtt.session import MqttQos
1516
from roborock.protocols.v1_protocol import LocalProtocolVersion
1617
from roborock.roborock_message import RoborockMessage
1718

@@ -83,6 +84,7 @@ def __init__(self, is_local: bool = False):
8384
self.close = MagicMock(side_effect=self._close)
8485

8586
self.protocol_version = LocalProtocolVersion.V1
87+
8688
self.restart = AsyncMock()
8789
self.health_manager = HealthManager(self.restart)
8890

@@ -102,10 +104,13 @@ def is_local_connected(self) -> bool:
102104
"""Return true if locally connected."""
103105
return self._is_connected and self._is_local
104106

105-
async def _publish(self, message: RoborockMessage) -> None:
107+
async def _publish(self, message: RoborockMessage, qos: MqttQos = MqttQos.AT_MOST_ONCE) -> None:
106108
"""Default publish implementation.
107109
108110
Records the message in ``published_messages`` and executes ``publish_handler``.
111+
112+
The ``qos`` parameter is accepted for compatibility with
113+
``MqttChannel.publish`` but not simulated by the fake channel.
109114
"""
110115
self.published_messages.append(message)
111116
if self.publish_side_effect:

tests/devices/traits/a01/test_init.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,8 @@ async def test_dyad_api_query_values(dyad_api: DyadApi, fake_channel: FakeChanne
7777
assert message.protocol == RoborockMessageProtocol.RPC_REQUEST
7878
assert message.version == b"A01"
7979
payload_data = json.loads(unpad(message.payload, AES.block_size))
80-
assert payload_data == {"dps": {"10000": "[209, 201, 207, 214, 215, 227, 229, 230, 222, 224]"}}
80+
assert payload_data["dps"] == {"10000": "[209, 201, 207, 214, 215, 227, 229, 230, 222, 224]"}
81+
assert "t" in payload_data
8182

8283

8384
@pytest.mark.parametrize(
@@ -174,7 +175,8 @@ async def test_zeo_api_query_values(zeo_api: ZeoApi, fake_channel: FakeChannel):
174175
assert message.protocol == RoborockMessageProtocol.RPC_REQUEST
175176
assert message.version == b"A01"
176177
payload_data = json.loads(unpad(message.payload, AES.block_size))
177-
assert payload_data == {"dps": {"10000": "[203, 207, 226, 227, 224, 218]"}}
178+
assert payload_data["dps"] == {"10000": "[203, 207, 226, 227, 224, 218]"}
179+
assert "t" in payload_data
178180

179181

180182
@pytest.mark.parametrize(
@@ -245,7 +247,8 @@ async def test_dyad_api_set_value(dyad_api: DyadApi, fake_channel: FakeChannel):
245247
# decode the payload to verify contents
246248
payload_data = json.loads(unpad(message.payload, AES.block_size))
247249
# A01 protocol expects values to be strings in the dps dict
248-
assert payload_data == {"dps": {"209": 1}}
250+
assert payload_data["dps"] == {"209": 1}
251+
assert "t" in payload_data
249252

250253

251254
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):
261264
# decode the payload to verify contents
262265
payload_data = json.loads(unpad(message.payload, AES.block_size))
263266
# A01 protocol expects values to be strings in the dps dict
264-
assert payload_data == {"dps": {"204": "standard"}}
267+
assert payload_data["dps"] == {"204": "standard"}
268+
assert "t" in payload_data

tests/e2e/__snapshots__/test_device_manager.ambr

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@
1313
[mqtt <]
1414
00000000 90 04 00 01 00 00 |......|
1515
[mqtt >]
16-
00000000 30 5a 00 20 72 72 2f 6d 2f 69 2f 75 73 65 72 31 |0Z. rr/m/i/user1|
16+
00000000 30 6a 00 20 72 72 2f 6d 2f 69 2f 75 73 65 72 31 |0j. rr/m/i/user1|
1717
00000010 32 33 2f 31 39 36 34 38 66 39 34 2f 7a 65 6f 5f |23/19648f94/zeo_|
1818
00000020 64 75 69 64 00 41 30 31 00 00 23 82 00 00 23 83 |duid.A01..#...#.|
19-
00000030 68 a6 a2 24 00 65 00 20 c5 de 2b f6 a9 ba 32 7e |h..$.e. ..+...2~|
20-
00000040 6b 73 82 bb d8 67 d4 db 7e cd 61 aa 8c 38 56 53 |ks...g..~.a..8VS|
21-
00000050 ca 4e 15 0d b1 b7 80 a2 0f 16 58 36 |.N........X6|
19+
00000030 68 a6 a2 24 00 65 00 30 c5 de 2b f6 a9 ba 32 7e |h..$.e.0..+...2~|
20+
00000040 6b 73 82 bb d8 67 d4 db 7d 80 60 67 80 96 b8 a1 |ks...g..}.`g....|
21+
00000050 c6 bc 9e d2 da 07 fb d3 79 f5 6f 6d 04 9c 71 00 |........y.om..q.|
22+
00000060 48 66 3d 7e 5d fe d3 df 18 e4 26 38 |Hf=~].....&8|
2223
[mqtt <]
2324
00000000 30 5e 00 20 72 72 2f 6d 2f 6f 2f 75 73 65 72 31 |0^. rr/m/o/user1|
2425
00000010 32 33 2f 31 39 36 34 38 66 39 34 2f 7a 65 6f 5f |23/19648f94/zeo_|

tests/fixtures/logging_fixtures.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ def get_token_bytes(n: int) -> bytes:
5252

5353
with (
5454
patch("roborock.devices.transport.local_channel.get_next_int", side_effect=get_next_int),
55+
patch("roborock.protocols.a01_protocol.time.time", return_value=1755750947.0),
5556
patch("roborock.protocols.b01_q7_protocol.get_next_int", side_effect=get_next_int),
5657
patch("roborock.protocols.v1_protocol.get_next_int", side_effect=get_next_int),
5758
patch("roborock.protocols.v1_protocol.get_timestamp", side_effect=get_timestamp),

0 commit comments

Comments
 (0)