Skip to content

Commit 8dca548

Browse files
committed
feat: add Q10 support
1 parent 7570e51 commit 8dca548

File tree

15 files changed

+1448
-17
lines changed

15 files changed

+1448
-17
lines changed

roborock/data/b01_q10/b01_q10_containers.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,49 @@ class dpVoiceVersion(RoborockBase):
5151
class dpTimeZone(RoborockBase):
5252
timeZoneCity: str
5353
timeZoneSec: int
54+
55+
56+
class Q10Status(RoborockBase):
57+
"""Status for Q10 devices."""
58+
59+
clean_time: int | None = None
60+
clean_area: int | None = None
61+
battery: int | None = None
62+
status: int | None = None
63+
fun_level: int | None = None
64+
water_level: int | None = None
65+
clean_count: int | None = None
66+
clean_mode: int | None = None
67+
clean_task_type: int | None = None
68+
back_type: int | None = None
69+
cleaning_progress: int | None = None
70+
71+
72+
class Q10Consumable(RoborockBase):
73+
"""Consumable status for Q10 devices."""
74+
75+
main_brush_life: int | None = None
76+
side_brush_life: int | None = None
77+
filter_life: int | None = None
78+
rag_life: int | None = None
79+
sensor_life: int | None = None
80+
81+
82+
class Q10DND(RoborockBase):
83+
"""DND status for Q10 devices."""
84+
85+
enabled: bool | None = None
86+
start_time: str | None = None
87+
end_time: str | None = None
88+
89+
90+
class Q10Volume(RoborockBase):
91+
"""Volume status for Q10 devices."""
92+
93+
volume: int | None = None
94+
95+
96+
class Q10ChildLock(RoborockBase):
97+
"""Child lock status for Q10 devices."""
98+
99+
enabled: bool | None = None

roborock/devices/b01_channel.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
CommandType,
1313
ParamsType,
1414
decode_rpc_response,
15-
encode_mqtt_payload,
15+
encode_q7_payload,
1616
)
1717
from roborock.roborock_message import RoborockMessage
1818
from roborock.util import get_next_int
@@ -31,14 +31,14 @@ async def send_decoded_command(
3131
) -> dict[str, Any] | None:
3232
"""Send a command on the MQTT channel and get a decoded response."""
3333
msg_id = str(get_next_int(100000000000, 999999999999))
34+
roborock_message = encode_q7_payload(dps, command, params, msg_id)
3435
_LOGGER.debug(
3536
"Sending B01 MQTT command: dps=%s method=%s msg_id=%s params=%s",
3637
dps,
3738
command,
3839
msg_id,
3940
params,
4041
)
41-
roborock_message = encode_mqtt_payload(dps, command, params, msg_id)
4242
future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
4343

4444
def find_response(response_message: RoborockMessage) -> None:
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""B01 Q10 MQTT helpers (send + async inbound routing).
2+
3+
Q10 devices do not reliably correlate request/response via the message sequence
4+
number. Additionally, DP updates ("prop updates") can arrive at any time.
5+
6+
To avoid race conditions, we route inbound messages through a single async
7+
consumer and then dispatch:
8+
- prop updates (DP changes) -> trait update callbacks + DP waiters
9+
- other response types -> placeholders for future routing
10+
"""
11+
12+
import asyncio
13+
import logging
14+
from collections.abc import Callable
15+
from typing import Any, Final
16+
17+
from roborock.exceptions import RoborockException
18+
from roborock.protocols.b01_protocol import decode_rpc_response, encode_b01_mqtt_payload
19+
from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol
20+
21+
from .mqtt_channel import MqttChannel
22+
23+
_LOGGER = logging.getLogger(__name__)
24+
25+
26+
class B01Q10MessageRouter:
27+
"""Async router for inbound B01 Q10 messages."""
28+
29+
def __init__(self) -> None:
30+
self._queue: asyncio.Queue[RoborockMessage] = asyncio.Queue()
31+
self._task: asyncio.Task[None] | None = None
32+
self._prop_update_callbacks: list[Callable[[dict[int, Any]], None]] = []
33+
34+
def add_prop_update_callback(self, callback: Callable[[dict[int, Any]], None]) -> Callable[[], None]:
35+
"""Register a callback for prop updates (decoded DP dict)."""
36+
self._prop_update_callbacks.append(callback)
37+
38+
def remove() -> None:
39+
try:
40+
self._prop_update_callbacks.remove(callback)
41+
except ValueError:
42+
pass
43+
44+
return remove
45+
46+
def feed(self, message: RoborockMessage) -> None:
47+
"""Feed an inbound message into the router (non-async safe)."""
48+
if self._task is None or self._task.done():
49+
self._task = asyncio.create_task(self._run(), name="b01-q10-message-router")
50+
self._queue.put_nowait(message)
51+
52+
def close(self) -> None:
53+
"""Stop the router task."""
54+
if self._task and not self._task.done():
55+
self._task.cancel()
56+
57+
async def _run(self) -> None:
58+
while True:
59+
message = await self._queue.get()
60+
try:
61+
self._handle_message(message)
62+
except Exception as ex: # noqa: BLE001
63+
_LOGGER.debug("Unhandled error routing B01 Q10 message: %s", ex)
64+
65+
def _handle_message(self, message: RoborockMessage) -> None:
66+
# Placeholder for additional response types.
67+
match message.protocol:
68+
case RoborockMessageProtocol.RPC_RESPONSE:
69+
self._handle_rpc_response(message)
70+
case RoborockMessageProtocol.MAP_RESPONSE:
71+
_LOGGER.debug("B01 Q10 map response received (unrouted placeholder)")
72+
case _:
73+
_LOGGER.debug("B01 Q10 message protocol %s received (unrouted placeholder)", message.protocol)
74+
75+
def _handle_rpc_response(self, message: RoborockMessage) -> None:
76+
try:
77+
decoded = decode_rpc_response(message)
78+
except RoborockException as ex:
79+
_LOGGER.info("Failed to decode B01 Q10 message: %s: %s", message, ex)
80+
return
81+
82+
# Identify response type and route accordingly.
83+
#
84+
# Based on Hermes Q10: DP changes are delivered as "deviceDpChanged" events.
85+
# Many DPs are delivered nested inside dpCommon (101), so we flatten that
86+
# envelope into regular DP keys for downstream trait updates.
87+
dps = _flatten_q10_dps(decoded)
88+
if not dps:
89+
return
90+
91+
for cb in list(self._prop_update_callbacks):
92+
try:
93+
cb(dps)
94+
except Exception as ex: # noqa: BLE001
95+
_LOGGER.debug("Error in B01 Q10 prop update callback: %s", ex)
96+
97+
98+
_ROUTER_ATTR: Final[str] = "_b01_q10_router"
99+
100+
101+
def get_b01_q10_router(mqtt_channel: MqttChannel) -> B01Q10MessageRouter:
102+
"""Get (or create) the per-channel B01 Q10 router."""
103+
router = getattr(mqtt_channel, _ROUTER_ATTR, None)
104+
if router is None:
105+
router = B01Q10MessageRouter()
106+
setattr(mqtt_channel, _ROUTER_ATTR, router)
107+
return router
108+
109+
110+
def _flatten_q10_dps(decoded: dict[int, Any]) -> dict[int, Any]:
111+
"""Flatten Q10 dpCommon (101) payload into normal DP keys.
112+
113+
Example input from device:
114+
{101: {"25": 1, "26": 54, "6": 876}, 122: 88, 123: 2, ...}
115+
116+
Output:
117+
{25: 1, 26: 54, 6: 876, 122: 88, 123: 2, ...}
118+
"""
119+
flat: dict[int, Any] = {}
120+
for dp, value in decoded.items():
121+
if dp == 101 and isinstance(value, dict):
122+
for inner_k, inner_v in value.items():
123+
try:
124+
inner_dp = int(inner_k)
125+
except (TypeError, ValueError):
126+
continue
127+
flat[inner_dp] = inner_v
128+
continue
129+
flat[dp] = value
130+
return flat
131+
132+
133+
async def send_b01_dp_command(
134+
mqtt_channel: MqttChannel,
135+
dps: dict[int, Any],
136+
) -> None:
137+
"""Send a raw DP command on the MQTT channel.
138+
139+
Q10 devices can emit DP updates at any time, and do not reliably correlate
140+
request/response via the message sequence number.
141+
142+
For Q10 we treat **all** outbound messages as fire-and-forget:
143+
- We publish the DP command.
144+
- We do not wait for any response payload.
145+
- Traits are updated via async prop updates routed by `B01Q10MessageRouter`.
146+
147+
"""
148+
_LOGGER.debug("Sending MQTT DP command: %s", dps)
149+
msg = encode_b01_mqtt_payload(dps)
150+
151+
_LOGGER.debug("Publishing B01 Q10 MQTT message: %s", msg)
152+
try:
153+
await mqtt_channel.publish(msg)
154+
await mqtt_channel.health_manager.on_success()
155+
except TimeoutError:
156+
await mqtt_channel.health_manager.on_timeout()
157+
_LOGGER.debug("B01 Q10 MQTT publish timed out for dps=%s", dps)
158+
except Exception as ex: # noqa: BLE001
159+
# Fire-and-forget means callers never see errors; keep the task quiet.
160+
_LOGGER.debug("B01 Q10 MQTT publish failed for dps=%s: %s", dps, ex)
161+
162+
return None

roborock/devices/device.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ def __init__(
6565
protocol channel. Use `close()` to clean up all connections.
6666
"""
6767
TraitsMixin.__init__(self, trait)
68+
self._trait = trait
6869
self._duid = device_info.duid
6970
self._logger = RoborockLoggerAdapter(duid=self._duid, logger=_LOGGER)
7071
self._name = device_info.name
@@ -215,10 +216,24 @@ async def close(self) -> None:
215216
if self._unsub:
216217
self._unsub()
217218
self._unsub = None
219+
close_trait = getattr(self._trait, "close", None)
220+
if callable(close_trait):
221+
try:
222+
result = close_trait()
223+
if asyncio.iscoroutine(result):
224+
await result
225+
except Exception as ex: # noqa: BLE001
226+
self._logger.debug("Error closing trait: %s", ex)
218227

219228
def _on_message(self, message: RoborockMessage) -> None:
220229
"""Handle incoming messages from the device."""
221230
self._logger.debug("Received message from device: %s", message)
231+
on_message = getattr(self._trait, "on_message", None)
232+
if callable(on_message):
233+
try:
234+
on_message(message)
235+
except Exception as ex: # noqa: BLE001
236+
self._logger.debug("Error in trait on_message handler: %s", ex)
222237

223238
def diagnostic_data(self) -> dict[str, Any]:
224239
"""Return diagnostics information about the device."""

roborock/devices/device_manager.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,9 +223,7 @@ def device_creator(home_data: HomeData, device: HomeDataDevice, product: HomeDat
223223
channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
224224
model_part = product.model.split(".")[-1]
225225
if "ss" in model_part:
226-
raise NotImplementedError(
227-
f"Device {device.name} has unsupported version B01_{product.model.strip('.')[-1]}"
228-
)
226+
trait = b01.q10.create(channel)
229227
elif "sc" in model_part:
230228
# Q7 devices start with 'sc' in their model naming.
231229
trait = b01.q7.create(channel)
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Traits for B01 devices."""
22

3+
from . import q7, q10
34
from .q7 import Q7PropertiesApi
5+
from .q10 import Q10PropertiesApi
46

5-
__all__ = ["Q7PropertiesApi", "q7", "q10"]
7+
__all__ = ["Q7PropertiesApi", "Q10PropertiesApi", "q7", "q10"]

0 commit comments

Comments
 (0)