Skip to content

Commit b7dc3cc

Browse files
committed
feat(q10): add decoded command helper and status trait
1 parent ea5953f commit b7dc3cc

File tree

2 files changed

+137
-0
lines changed

2 files changed

+137
-0
lines changed

roborock/devices/rpc/b01_q10_channel.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,23 @@
22

33
from __future__ import annotations
44

5+
import asyncio
56
import logging
7+
from collections.abc import Iterable
8+
from typing import Any
69

710
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
811
from roborock.devices.transport.mqtt_channel import MqttChannel
912
from roborock.exceptions import RoborockException
1013
from roborock.protocols.b01_q10_protocol import (
1114
ParamsType,
15+
decode_rpc_response,
1216
encode_mqtt_payload,
1317
)
18+
from roborock.roborock_message import RoborockMessage
1419

1520
_LOGGER = logging.getLogger(__name__)
21+
_TIMEOUT = 10.0
1622

1723

1824
async def send_command(
@@ -34,3 +40,61 @@ async def send_command(
3440
ex,
3541
)
3642
raise
43+
44+
45+
async def send_decoded_command(
46+
mqtt_channel: MqttChannel,
47+
command: B01_Q10_DP,
48+
params: ParamsType,
49+
expected_dps: Iterable[B01_Q10_DP] | None = None,
50+
) -> dict[B01_Q10_DP, Any]:
51+
"""Send a command and await the first decoded response.
52+
53+
Q10 responses are not correlated with a message id, so we filter on
54+
expected datapoints when provided.
55+
"""
56+
roborock_message = encode_mqtt_payload(command, params)
57+
future: asyncio.Future[dict[B01_Q10_DP, Any]] = asyncio.get_running_loop().create_future()
58+
59+
expected_set = set(expected_dps) if expected_dps is not None else None
60+
61+
def find_response(response_message: RoborockMessage) -> None:
62+
try:
63+
decoded_dps = decode_rpc_response(response_message)
64+
except RoborockException as ex:
65+
_LOGGER.debug(
66+
"Failed to decode B01 Q10 RPC response (expecting %s): %s: %s",
67+
command,
68+
response_message,
69+
ex,
70+
)
71+
return
72+
if expected_set and not any(dps in decoded_dps for dps in expected_set):
73+
return
74+
if not future.done():
75+
future.set_result(decoded_dps)
76+
77+
unsub = await mqtt_channel.subscribe(find_response)
78+
79+
_LOGGER.debug("Sending MQTT message: %s", roborock_message)
80+
try:
81+
await mqtt_channel.publish(roborock_message)
82+
return await asyncio.wait_for(future, timeout=_TIMEOUT)
83+
except TimeoutError as ex:
84+
raise RoborockException(f"B01 Q10 command timed out after {_TIMEOUT}s ({command})") from ex
85+
except RoborockException as ex:
86+
_LOGGER.warning(
87+
"Error sending B01 Q10 decoded command (%s): %s",
88+
command,
89+
ex,
90+
)
91+
raise
92+
except Exception as ex:
93+
_LOGGER.exception(
94+
"Error sending B01 Q10 decoded command (%s): %s",
95+
command,
96+
ex,
97+
)
98+
raise
99+
finally:
100+
unsub()
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Status trait for Q10 B01 devices."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any, cast
6+
7+
from roborock.data.b01_q10.b01_q10_code_mappings import (
8+
B01_Q10_DP,
9+
YXDeviceCleanTask,
10+
YXDeviceState,
11+
YXDeviceWorkMode,
12+
YXFanLevel,
13+
)
14+
from roborock.devices.rpc.b01_q10_channel import send_decoded_command
15+
from roborock.devices.transport.mqtt_channel import MqttChannel
16+
17+
18+
class StatusTrait:
19+
"""Trait for requesting and holding Q10 status values."""
20+
21+
def __init__(self, channel: MqttChannel) -> None:
22+
self._channel = channel
23+
self._data: dict[B01_Q10_DP, Any] = {}
24+
25+
@property
26+
def data(self) -> dict[B01_Q10_DP, Any]:
27+
"""Return the latest raw status data."""
28+
return self._data
29+
30+
async def refresh(self) -> dict[B01_Q10_DP, Any]:
31+
"""Refresh status values from the device."""
32+
decoded = await send_decoded_command(
33+
self._channel,
34+
command=B01_Q10_DP.REQUEST_DPS,
35+
params={},
36+
expected_dps={B01_Q10_DP.STATUS, B01_Q10_DP.BATTERY},
37+
)
38+
self._data = decoded
39+
return decoded
40+
41+
@property
42+
def state_code(self) -> int | None:
43+
return self._data.get(B01_Q10_DP.STATUS)
44+
45+
@property
46+
def state(self) -> YXDeviceState | None:
47+
code = self.state_code
48+
return cast(YXDeviceState | None, YXDeviceState.from_code_optional(code)) if code is not None else None
49+
50+
@property
51+
def battery(self) -> int | None:
52+
return self._data.get(B01_Q10_DP.BATTERY)
53+
54+
@property
55+
def fan_level(self) -> YXFanLevel | None:
56+
value = self._data.get(B01_Q10_DP.FAN_LEVEL)
57+
return cast(YXFanLevel | None, YXFanLevel.from_code_optional(value)) if value is not None else None
58+
59+
@property
60+
def clean_mode(self) -> YXDeviceWorkMode | None:
61+
value = self._data.get(B01_Q10_DP.CLEAN_MODE)
62+
return cast(YXDeviceWorkMode | None, YXDeviceWorkMode.from_code_optional(value)) if value is not None else None
63+
64+
@property
65+
def clean_task(self) -> YXDeviceCleanTask | None:
66+
value = self._data.get(B01_Q10_DP.CLEAN_TASK_TYPE)
67+
return (
68+
cast(YXDeviceCleanTask | None, YXDeviceCleanTask.from_code_optional(value)) if value is not None else None
69+
)
70+
71+
@property
72+
def cleaning_progress(self) -> int | None:
73+
return self._data.get(B01_Q10_DP.CLEANING_PROGRESS)

0 commit comments

Comments
 (0)