Skip to content

Commit 7b0e410

Browse files
committed
feat(q10): enhance Q10 device traits with status management and data conversion utilities
1 parent 07a1cec commit 7b0e410

File tree

12 files changed

+284
-431
lines changed

12 files changed

+284
-431
lines changed
Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,102 @@
1-
from ..containers import RoborockBase
1+
"""Data container classes for Q10 B01 devices.
2+
3+
Many of these classes use the `field(metadata={"dps": ...})` convention to map
4+
fields to device Data Points (DPS). This metadata is utilized by the
5+
`update_from_dps` helper in `roborock.devices.traits.b01.q10.common` to
6+
automatically update objects from raw device responses.
7+
"""
28

9+
from dataclasses import dataclass, field
310

11+
from ..containers import RoborockBase
12+
from .b01_q10_code_mappings import (
13+
B01_Q10_DP,
14+
YXBackType,
15+
YXDeviceCleanTask,
16+
YXDeviceState,
17+
YXDeviceWorkMode,
18+
YXFanLevel,
19+
YXWaterLevel,
20+
)
21+
22+
23+
@dataclass
424
class dpCleanRecord(RoborockBase):
525
op: str
626
result: int
727
id: str
828
data: list
929

1030

31+
@dataclass
1132
class dpMultiMap(RoborockBase):
1233
op: str
1334
result: int
1435
data: list
1536

1637

38+
@dataclass
1739
class dpGetCarpet(RoborockBase):
1840
op: str
1941
result: int
2042
data: str
2143

2244

45+
@dataclass
2346
class dpSelfIdentifyingCarpet(RoborockBase):
2447
op: str
2548
result: int
2649
data: str
2750

2851

52+
@dataclass
2953
class dpNetInfo(RoborockBase):
3054
wifiName: str
3155
ipAdress: str
3256
mac: str
3357
signal: int
3458

3559

60+
@dataclass
3661
class dpNotDisturbExpand(RoborockBase):
3762
disturb_dust_enable: int
3863
disturb_light: int
3964
disturb_resume_clean: int
4065
disturb_voice: int
4166

4267

68+
@dataclass
4369
class dpCurrentCleanRoomIds(RoborockBase):
4470
room_id_list: list
4571

4672

73+
@dataclass
4774
class dpVoiceVersion(RoborockBase):
4875
version: int
4976

5077

78+
@dataclass
5179
class dpTimeZone(RoborockBase):
5280
timeZoneCity: str
5381
timeZoneSec: int
82+
83+
84+
@dataclass
85+
class Q10Status(RoborockBase):
86+
"""Status for Q10 devices.
87+
88+
Fields are mapped to DPS values using metadata. Objects of this class can be
89+
automatically updated using the `update_from_dps` helper.
90+
"""
91+
92+
clean_time: int | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_TIME})
93+
clean_area: int | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_AREA})
94+
battery: int | None = field(default=None, metadata={"dps": B01_Q10_DP.BATTERY})
95+
status: YXDeviceState | None = field(default=None, metadata={"dps": B01_Q10_DP.STATUS})
96+
fan_level: YXFanLevel | None = field(default=None, metadata={"dps": B01_Q10_DP.FAN_LEVEL})
97+
water_level: YXWaterLevel | None = field(default=None, metadata={"dps": B01_Q10_DP.WATER_LEVEL})
98+
clean_count: int | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_COUNT})
99+
clean_mode: YXDeviceWorkMode | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_MODE})
100+
clean_task_type: YXDeviceCleanTask | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_TASK_TYPE})
101+
back_type: YXBackType | None = field(default=None, metadata={"dps": B01_Q10_DP.BACK_TYPE})
102+
cleaning_progress: int | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEANING_PROGRESS})

roborock/data/containers.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,10 @@ def from_dict(cls, data: dict[str, Any]):
9191
if not isinstance(data, dict):
9292
return None
9393
field_types = {field.name: field.type for field in dataclasses.fields(cls)}
94-
result: dict[str, Any] = {}
94+
normalized_data: dict[str, Any] = {}
9595
for orig_key, value in data.items():
9696
key = _decamelize(orig_key)
97-
if (field_type := field_types.get(key)) is None:
97+
if field_types.get(key) is None:
9898
if (log_key := f"{cls.__name__}.{key}") not in RoborockBase._missing_logged:
9999
_LOGGER.debug(
100100
"Key '%s' (decamelized: '%s') not found in %s fields, skipping",
@@ -104,6 +104,19 @@ def from_dict(cls, data: dict[str, Any]):
104104
)
105105
RoborockBase._missing_logged.add(log_key)
106106
continue
107+
normalized_data[key] = value
108+
109+
result = RoborockBase.convert_dict(field_types, normalized_data)
110+
return cls(**result)
111+
112+
@staticmethod
113+
def convert_dict(types_map: dict[Any, type], data: dict[Any, Any]) -> dict[Any, Any]:
114+
"""Convert a dictionary of values based on a schema map of types."""
115+
result: dict[Any, Any] = {}
116+
for key, value in data.items():
117+
if key not in types_map:
118+
continue
119+
field_type = types_map[key]
107120
if value == "None" or value is None:
108121
result[key] = None
109122
continue
@@ -124,7 +137,7 @@ def from_dict(cls, data: dict[str, Any]):
124137
_LOGGER.exception(f"Failed to convert {key} with value {value} to type {field_type}")
125138
continue
126139

127-
return cls(**result)
140+
return result
128141

129142
def as_dict(self) -> dict:
130143
return asdict(

roborock/devices/device.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -197,12 +197,14 @@ async def connect(self) -> None:
197197
if self._unsub:
198198
raise ValueError("Already connected to the device")
199199
unsub = await self._channel.subscribe(self._on_message)
200-
if self.v1_properties is not None:
201-
try:
200+
try:
201+
if self.v1_properties is not None:
202202
await self.v1_properties.discover_features()
203-
except RoborockException:
204-
unsub()
205-
raise
203+
elif self.b01_q10_properties is not None:
204+
await self.b01_q10_properties.start()
205+
except RoborockException:
206+
unsub()
207+
raise
206208
self._logger.info("Connected to device")
207209
self._unsub = unsub
208210

@@ -214,6 +216,8 @@ async def close(self) -> None:
214216
await self._connect_task
215217
except asyncio.CancelledError:
216218
pass
219+
if self.b01_q10_properties is not None:
220+
await self.b01_q10_properties.close()
217221
if self._unsub:
218222
self._unsub()
219223
self._unsub = None

roborock/devices/rpc/b01_q10_channel.py

Lines changed: 21 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -2,99 +2,53 @@
22

33
from __future__ import annotations
44

5-
import asyncio
65
import logging
7-
from collections.abc import Iterable
6+
from collections.abc import AsyncGenerator
87
from typing import Any
98

109
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
1110
from roborock.devices.transport.mqtt_channel import MqttChannel
1211
from roborock.exceptions import RoborockException
13-
from roborock.protocols.b01_q10_protocol import (
14-
ParamsType,
15-
decode_rpc_response,
16-
encode_mqtt_payload,
17-
)
18-
from roborock.roborock_message import RoborockMessage
12+
from roborock.protocols.b01_q10_protocol import ParamsType, encode_mqtt_payload
13+
from roborock.protocols.b01_q10_protocol import decode_rpc_response
1914

2015
_LOGGER = logging.getLogger(__name__)
21-
_TIMEOUT = 10.0
2216

2317

24-
async def send_command(
18+
async def stream_decoded_responses(
2519
mqtt_channel: MqttChannel,
26-
command: B01_Q10_DP,
27-
params: ParamsType,
28-
) -> None:
29-
"""Send a command on the MQTT channel, without waiting for a response"""
30-
_LOGGER.debug("Sending B01 MQTT command: cmd=%s params=%s", command, params)
31-
roborock_message = encode_mqtt_payload(command, params)
32-
_LOGGER.debug("Sending MQTT message: %s", roborock_message)
33-
try:
34-
await mqtt_channel.publish(roborock_message)
35-
except RoborockException as ex:
36-
_LOGGER.debug(
37-
"Error sending B01 decoded command (method=%s params=%s): %s",
38-
command,
39-
params,
40-
ex,
41-
)
42-
raise
43-
20+
) -> AsyncGenerator[dict[B01_Q10_DP, Any], None]:
21+
"""Stream decoded DPS messages received via MQTT."""
4422

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:
23+
async for response_message in mqtt_channel.subscribe_stream():
6224
try:
6325
decoded_dps = decode_rpc_response(response_message)
6426
except RoborockException as ex:
6527
_LOGGER.debug(
66-
"Failed to decode B01 Q10 RPC response (expecting %s): %s: %s",
67-
command,
28+
"Failed to decode B01 RPC response: %s: %s",
6829
response_message,
6930
ex,
7031
)
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)
32+
continue
33+
yield decoded_dps
7634

77-
unsub = await mqtt_channel.subscribe(find_response)
7835

36+
async def send_command(
37+
mqtt_channel: MqttChannel,
38+
command: B01_Q10_DP,
39+
params: ParamsType,
40+
) -> None:
41+
"""Send a command on the MQTT channel, without waiting for a response."""
42+
_LOGGER.debug("Sending B01 MQTT command: cmd=%s params=%s", command, params)
43+
roborock_message = encode_mqtt_payload(command, params)
7944
_LOGGER.debug("Sending MQTT message: %s", roborock_message)
8045
try:
8146
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
8547
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",
48+
_LOGGER.debug(
49+
"Error sending B01 decoded command (method=%s params=%s): %s",
9550
command,
51+
params,
9652
ex,
9753
)
9854
raise
99-
finally:
100-
unsub()

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

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
"""Traits for Q10 B01 devices."""
22

3+
import asyncio
4+
import logging
5+
6+
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
7+
from roborock.devices.rpc.b01_q10_channel import stream_decoded_responses
38
from roborock.devices.traits import Trait
49
from roborock.devices.transport.mqtt_channel import MqttChannel
510

@@ -9,9 +14,10 @@
914

1015
__all__ = [
1116
"Q10PropertiesApi",
12-
"StatusTrait",
1317
]
1418

19+
_LOGGER = logging.getLogger(__name__)
20+
1521

1622
class Q10PropertiesApi(Trait):
1723
"""API for interacting with B01 devices."""
@@ -20,16 +26,42 @@ class Q10PropertiesApi(Trait):
2026
"""Trait for sending commands to Q10 devices."""
2127

2228
status: StatusTrait
23-
"""Trait for querying Q10 device status."""
29+
"""Trait for managing the status of Q10 devices."""
2430

2531
vacuum: VacuumTrait
2632
"""Trait for sending vacuum related commands to Q10 devices."""
2733

2834
def __init__(self, channel: MqttChannel) -> None:
2935
"""Initialize the B01Props API."""
36+
self._channel = channel
3037
self.command = CommandTrait(channel)
31-
self.status = StatusTrait(channel)
3238
self.vacuum = VacuumTrait(self.command)
39+
self.status = StatusTrait()
40+
self._subscribe_task: asyncio.Task[None] | None = None
41+
42+
async def start(self) -> None:
43+
"""Start any necessary subscriptions for the trait."""
44+
self._subscribe_task = asyncio.create_task(self._subscribe_loop())
45+
46+
async def close(self) -> None:
47+
"""Close any resources held by the trait."""
48+
if self._subscribe_task is not None:
49+
self._subscribe_task.cancel()
50+
try:
51+
await self._subscribe_task
52+
except asyncio.CancelledError:
53+
pass
54+
self._subscribe_task = None
55+
56+
async def refresh(self) -> None:
57+
"""Refresh all traits."""
58+
await self.command.send(B01_Q10_DP.REQUEST_DPS, params={})
59+
60+
async def _subscribe_loop(self) -> None:
61+
"""Persistent loop to listen for status updates."""
62+
async for decoded_dps in stream_decoded_responses(self._channel):
63+
_LOGGER.debug("Received Q10 status update: %s", decoded_dps)
64+
self.status.update_from_dps(decoded_dps)
3365

3466

3567
def create(channel: MqttChannel) -> Q10PropertiesApi:

0 commit comments

Comments
 (0)