Skip to content

Commit 862be93

Browse files
committed
fix(a01): restart stale MQTT session on command timeouts
1 parent 3c5f788 commit 862be93

4 files changed

Lines changed: 97 additions & 2 deletions

File tree

roborock/devices/rpc/a01_channel.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,10 @@ def find_response(response_message: RoborockMessage) -> None:
9898
try:
9999
await asyncio.wait_for(finished.wait(), timeout=_TIMEOUT)
100100
except TimeoutError as ex:
101+
await mqtt_channel.health_manager.on_timeout()
101102
raise RoborockException(f"Command timed out after {_TIMEOUT}s") from ex
102103
finally:
103104
unsub()
104105

106+
await mqtt_channel.health_manager.on_success()
105107
return result # type: ignore[return-value]

roborock/devices/traits/a01/__init__.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@
88
A01 devices expose a single API object that handles all device interactions. This API is
99
available on the device instance (typically via `device.a01_properties`).
1010
11-
The API provides two main methods:
11+
The API provides these methods:
1212
1. **query_values(protocols)**: Fetches current state for specific data points.
1313
You must pass a list of protocol enums (e.g. `RoborockDyadDataProtocol` or
1414
`RoborockZeoProtocol`) to request specific data.
1515
2. **set_value(protocol, value)**: Sends a command to the device to change a setting
1616
or perform an action.
17+
3. **add_listener(callback)**: Subscribes to state the device pushes on its own (for
18+
example when its state changes), invoking the callback with decoded values.
1719
1820
Note that these APIs fetch data directly from the device upon request and do not
1921
cache state internally.
@@ -51,7 +53,9 @@
5153
from roborock.devices.rpc.a01_channel import send_decoded_command
5254
from roborock.devices.traits import Trait
5355
from roborock.devices.transport.mqtt_channel import MqttChannel
54-
from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol
56+
from roborock.exceptions import RoborockException
57+
from roborock.protocols.a01_protocol import decode_rpc_response
58+
from roborock.roborock_message import RoborockDyadDataProtocol, RoborockMessage, RoborockZeoProtocol
5559

5660
__init__ = [
5761
"DyadApi",
@@ -134,6 +138,12 @@ def convert_zeo_value(protocol_value: RoborockZeoProtocol, value: Any) -> Any:
134138
return None
135139

136140

141+
# RoborockDyadDataProtocol._missing_ maps any unknown code to its first member
142+
# instead of raising, so incoming data points must be checked against this set
143+
# before being converted to a protocol.
144+
_DYAD_PROTOCOL_VALUES = frozenset(protocol.value for protocol in RoborockDyadDataProtocol)
145+
146+
137147
class DyadApi(Trait):
138148
"""API for interacting with Dyad devices."""
139149

@@ -155,6 +165,32 @@ async def set_value(self, protocol: RoborockDyadDataProtocol, value: Any) -> dic
155165
params = {protocol: value}
156166
return await send_decoded_command(self._channel, params)
157167

168+
async def add_listener(
169+
self, callback: Callable[[dict[RoborockDyadDataProtocol, Any]], None]
170+
) -> Callable[[], None]:
171+
"""Listen for state the device pushes on its own.
172+
173+
The callback is invoked with decoded values whenever the device sends a
174+
message, including unsolicited pushes when its state changes. Only known
175+
protocols are delivered. Returns a callable to remove the listener.
176+
"""
177+
178+
def on_message(message: RoborockMessage) -> None:
179+
try:
180+
datapoints = decode_rpc_response(message)
181+
except RoborockException:
182+
return
183+
values: dict[RoborockDyadDataProtocol, Any] = {}
184+
for code, value in datapoints.items():
185+
if code not in _DYAD_PROTOCOL_VALUES:
186+
continue
187+
protocol = RoborockDyadDataProtocol(code)
188+
values[protocol] = convert_dyad_value(protocol, value)
189+
if values:
190+
callback(values)
191+
192+
return await self._channel.subscribe(on_message)
193+
158194

159195
class ZeoApi(Trait):
160196
"""API for interacting with Zeo devices."""

tests/devices/rpc/test_a01_channel.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
"""Tests for the a01_channel."""
22

33
from typing import Any
4+
from unittest.mock import AsyncMock
45

56
import pytest
67

78
from roborock.devices.rpc.a01_channel import send_decoded_command
9+
from roborock.exceptions import RoborockException
810
from roborock.protocols.a01_protocol import encode_mqtt_payload
911
from roborock.roborock_message import (
1012
RoborockDyadDataProtocol,
@@ -51,3 +53,37 @@ async def test_id_query(mock_mqtt_channel: FakeChannel):
5153
}
5254
mock_mqtt_channel.publish.assert_awaited_once()
5355
mock_mqtt_channel.subscribe.assert_awaited_once()
56+
57+
58+
async def test_query_marks_session_healthy(mock_mqtt_channel: FakeChannel):
59+
"""A completed query reports success to the health manager."""
60+
mock_mqtt_channel.health_manager.on_success = AsyncMock() # type: ignore[method-assign]
61+
62+
params: dict[RoborockDyadDataProtocol, Any] = {
63+
RoborockDyadDataProtocol.ID_QUERY: [RoborockDyadDataProtocol.POWER]
64+
}
65+
encoded = encode_mqtt_payload({RoborockDyadDataProtocol.POWER: 75}, value_encoder=lambda x: x)
66+
mock_mqtt_channel.response_queue.append(
67+
RoborockMessage(
68+
protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=encoded.payload, version=encoded.version
69+
)
70+
)
71+
72+
await send_decoded_command(mock_mqtt_channel, params) # type: ignore[call-overload]
73+
74+
mock_mqtt_channel.health_manager.on_success.assert_awaited_once()
75+
76+
77+
async def test_query_timeout_reports_to_health_manager(mock_mqtt_channel: FakeChannel, monkeypatch):
78+
"""A timed-out query reports to the health manager so a stale session can be restarted."""
79+
monkeypatch.setattr("roborock.devices.rpc.a01_channel._TIMEOUT", 0.01)
80+
mock_mqtt_channel.health_manager.on_timeout = AsyncMock() # type: ignore[method-assign]
81+
82+
params: dict[RoborockDyadDataProtocol, Any] = {
83+
RoborockDyadDataProtocol.ID_QUERY: [RoborockDyadDataProtocol.POWER]
84+
}
85+
86+
with pytest.raises(RoborockException, match="timed out"):
87+
await send_decoded_command(mock_mqtt_channel, params) # type: ignore[call-overload]
88+
89+
mock_mqtt_channel.health_manager.on_timeout.assert_awaited_once()

tests/devices/traits/a01/test_init.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,27 @@ async def test_dyad_invalid_response_value(
136136
assert result == expected_result
137137

138138

139+
async def test_dyad_add_listener(dyad_api: DyadApi, fake_channel: FakeChannel):
140+
"""add_listener delivers decoded values for pushed messages and skips unknown codes."""
141+
received: list[dict[RoborockDyadDataProtocol, Any]] = []
142+
unsub = await dyad_api.add_listener(received.append)
143+
144+
# 999 is not a known protocol: it must be skipped, not mapped to the first enum member.
145+
fake_channel.notify_subscribers(build_a01_message({206: 3, 209: 80, 216: 0, 999: 1}))
146+
147+
assert received == [
148+
{
149+
RoborockDyadDataProtocol.SUCTION: "l3",
150+
RoborockDyadDataProtocol.POWER: 80,
151+
RoborockDyadDataProtocol.ERROR: "none",
152+
}
153+
]
154+
155+
unsub()
156+
fake_channel.notify_subscribers(build_a01_message({206: 1}))
157+
assert len(received) == 1 # no callback fires after unsubscribing
158+
159+
139160
async def test_zeo_api_query_values(zeo_api: ZeoApi, fake_channel: FakeChannel):
140161
"""Test that ZeoApi currently returns raw values without conversion."""
141162
fake_channel.response_queue.append(

0 commit comments

Comments
 (0)