Skip to content

Commit d9e226c

Browse files
committed
feat(q10): Add status API for B01 Q10 devices
1 parent dc8ba01 commit d9e226c

File tree

3 files changed

+146
-0
lines changed

3 files changed

+146
-0
lines changed

roborock/devices/rpc/b01_q10_channel.py

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

33
from __future__ import annotations
44

5+
import asyncio
56
import logging
7+
from typing import Any, Iterable
68

79
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
810
from roborock.devices.transport.mqtt_channel import MqttChannel
911
from roborock.exceptions import RoborockException
1012
from roborock.protocols.b01_q10_protocol import (
1113
ParamsType,
14+
decode_rpc_response,
1215
encode_mqtt_payload,
1316
)
17+
from roborock.roborock_message import RoborockMessage
1418

1519
_LOGGER = logging.getLogger(__name__)
20+
_TIMEOUT = 10.0
1621

1722

1823
async def send_command(
@@ -34,3 +39,61 @@ async def send_command(
3439
ex,
3540
)
3641
raise
42+
43+
44+
async def send_decoded_command(
45+
mqtt_channel: MqttChannel,
46+
command: B01_Q10_DP,
47+
params: ParamsType,
48+
expected_dps: Iterable[B01_Q10_DP] | None = None,
49+
) -> dict[B01_Q10_DP, Any]:
50+
"""Send a command and await the first decoded response.
51+
52+
Q10 responses are not correlated with a message id, so we filter on
53+
expected datapoints when provided.
54+
"""
55+
roborock_message = encode_mqtt_payload(command, params)
56+
future: asyncio.Future[dict[B01_Q10_DP, Any]] = asyncio.get_running_loop().create_future()
57+
58+
expected_set = set(expected_dps) if expected_dps is not None else None
59+
60+
def find_response(response_message: RoborockMessage) -> None:
61+
try:
62+
decoded_dps = decode_rpc_response(response_message)
63+
except RoborockException as ex:
64+
_LOGGER.debug(
65+
"Failed to decode B01 Q10 RPC response (expecting %s): %s: %s",
66+
command,
67+
response_message,
68+
ex,
69+
)
70+
return
71+
if expected_set and not any(dps in decoded_dps for dps in expected_set):
72+
return
73+
if not future.done():
74+
future.set_result(decoded_dps)
75+
76+
unsub = await mqtt_channel.subscribe(find_response)
77+
78+
_LOGGER.debug("Sending MQTT message: %s", roborock_message)
79+
try:
80+
await mqtt_channel.publish(roborock_message)
81+
return await asyncio.wait_for(future, timeout=_TIMEOUT)
82+
except TimeoutError as ex:
83+
raise RoborockException(f"B01 Q10 command timed out after {_TIMEOUT}s ({command})") from ex
84+
except RoborockException as ex:
85+
_LOGGER.warning(
86+
"Error sending B01 Q10 decoded command (%s): %s",
87+
command,
88+
ex,
89+
)
90+
raise
91+
except Exception as ex:
92+
_LOGGER.exception(
93+
"Error sending B01 Q10 decoded command (%s): %s",
94+
command,
95+
ex,
96+
)
97+
raise
98+
finally:
99+
unsub()

roborock/devices/traits/b01/q10/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
from roborock.devices.transport.mqtt_channel import MqttChannel
55

66
from .command import CommandTrait
7+
from .status import StatusTrait
78
from .vacuum import VacuumTrait
89

910
__all__ = [
1011
"Q10PropertiesApi",
12+
"StatusTrait",
1113
]
1214

1315

@@ -20,10 +22,14 @@ class Q10PropertiesApi(Trait):
2022
vacuum: VacuumTrait
2123
"""Trait for sending vacuum related commands to Q10 devices."""
2224

25+
status: StatusTrait
26+
"""Trait for reading device status values."""
27+
2328
def __init__(self, channel: MqttChannel) -> None:
2429
"""Initialize the B01Props API."""
2530
self.command = CommandTrait(channel)
2631
self.vacuum = VacuumTrait(self.command)
32+
self.status = StatusTrait(channel)
2733

2834

2935
def create(channel: MqttChannel) -> Q10PropertiesApi:
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Status trait for Q10 B01 devices."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
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+
YXWaterLevel,
14+
)
15+
from roborock.devices.rpc.b01_q10_channel import send_decoded_command
16+
from roborock.devices.transport.mqtt_channel import MqttChannel
17+
18+
19+
class StatusTrait:
20+
"""Trait for requesting and holding Q10 status values."""
21+
22+
def __init__(self, channel: MqttChannel) -> None:
23+
self._channel = channel
24+
self._data: dict[B01_Q10_DP, Any] = {}
25+
26+
@property
27+
def data(self) -> dict[B01_Q10_DP, Any]:
28+
"""Return the latest raw status data."""
29+
return self._data
30+
31+
async def refresh(self) -> dict[B01_Q10_DP, Any]:
32+
"""Refresh status values from the device."""
33+
decoded = await send_decoded_command(
34+
self._channel,
35+
command=B01_Q10_DP.REQUETDPS,
36+
params={},
37+
expected_dps={B01_Q10_DP.STATUS, B01_Q10_DP.BATTERY},
38+
)
39+
self._data = decoded
40+
return decoded
41+
42+
@property
43+
def state_code(self) -> int | None:
44+
return self._data.get(B01_Q10_DP.STATUS)
45+
46+
@property
47+
def state(self) -> YXDeviceState | None:
48+
code = self.state_code
49+
return YXDeviceState.from_code_optional(code) if code is not None else None
50+
51+
@property
52+
def battery(self) -> int | None:
53+
return self._data.get(B01_Q10_DP.BATTERY)
54+
55+
@property
56+
def fan_level(self) -> YXFanLevel | None:
57+
value = self._data.get(B01_Q10_DP.FUN_LEVEL)
58+
return YXFanLevel.from_code_optional(value) if value is not None else None
59+
60+
@property
61+
def water_level(self) -> YXWaterLevel | None:
62+
value = self._data.get(B01_Q10_DP.WATER_LEVEL)
63+
return YXWaterLevel.from_code_optional(value) if value is not None else None
64+
65+
@property
66+
def clean_mode(self) -> YXDeviceWorkMode | None:
67+
value = self._data.get(B01_Q10_DP.CLEAN_MODE)
68+
return YXDeviceWorkMode.from_code_optional(value) if value is not None else None
69+
70+
@property
71+
def clean_task(self) -> YXDeviceCleanTask | None:
72+
value = self._data.get(B01_Q10_DP.CLEAN_TASK_TYPE)
73+
return YXDeviceCleanTask.from_code_optional(value) if value is not None else None
74+
75+
@property
76+
def cleaning_progress(self) -> int | None:
77+
return self._data.get(B01_Q10_DP.CLEANING_PROGRESS)

0 commit comments

Comments
 (0)