Skip to content

Commit a52b328

Browse files
committed
refactor: decouple B01 (Q7/Q10) protocol layer from transport layer
1 parent 5712f92 commit a52b328

23 files changed

Lines changed: 904 additions & 532 deletions

roborock/devices/device_manager.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
from roborock.web_api import RoborockApiClient, UserWebApiClient
2727

2828
from .cache import Cache, DeviceCache, NoCache
29+
from .rpc.b01_q7_channel import create_b01_q7_channel
30+
from .rpc.b01_q10_channel import create_b01_q10_channel
2931
from .rpc.v1_channel import create_v1_channel
3032
from .traits import Trait, a01, b01, v1
3133
from .transport.channel import Channel
@@ -254,13 +256,22 @@ def device_creator(home_data: HomeData, device: HomeDataDevice, product: HomeDat
254256
channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
255257
trait = a01.create(product, channel)
256258
case DeviceVersion.B01:
257-
channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
259+
mqtt_channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
258260
model_part = product.model.split(".")[-1]
259261
if "ss" in model_part:
262+
b01_q10_channel = create_b01_q10_channel(mqtt_channel)
263+
channel = b01_q10_channel
260264
trait = b01.q10.create(channel)
261265
elif "sc" in model_part:
262266
# Q7 devices start with 'sc' in their model naming.
263-
trait = b01.q7.create(product, device, channel)
267+
b01_q7_channel = create_b01_q7_channel(device, product, mqtt_channel)
268+
channel = b01_q7_channel
269+
trait = b01.q7.create(
270+
product,
271+
device,
272+
rpc_channel=b01_q7_channel,
273+
map_rpc_channel=b01_q7_channel,
274+
)
264275
else:
265276
raise UnsupportedDeviceError(f"Device {device.name} has unsupported B01 model: {product.model}")
266277
case _:

roborock/devices/rpc/b01_q10_channel.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""Thin wrapper around the MQTT channel for Roborock B01 Q10 devices."""
22

33
import logging
4-
from collections.abc import AsyncGenerator
4+
from collections.abc import AsyncGenerator, Callable
5+
from typing import Protocol
56

67
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
8+
from roborock.devices.transport.channel import Channel
79
from roborock.devices.transport.mqtt_channel import MqttChannel
810
from roborock.exceptions import RoborockException
911
from roborock.protocols.b01_q10_protocol import (
@@ -12,10 +14,53 @@
1214
decode_message,
1315
encode_mqtt_payload,
1416
)
17+
from roborock.roborock_message import RoborockMessage
1518

1619
_LOGGER = logging.getLogger(__name__)
1720

1821

22+
class Q10RpcChannel(Protocol):
23+
"""Protocol for Q10 RPC channels."""
24+
25+
async def send_command(
26+
self,
27+
command: B01_Q10_DP,
28+
params: ParamsType = None,
29+
) -> None:
30+
"""Send a command on the MQTT channel, without waiting for a response."""
31+
...
32+
33+
34+
class B01Q10Channel(Channel, Q10RpcChannel):
35+
"""Unified B01 Q10 channel wrapping MQTT transport."""
36+
37+
def __init__(self, mqtt_channel: MqttChannel) -> None:
38+
self._mqtt_channel = mqtt_channel
39+
40+
@property
41+
def is_connected(self) -> bool:
42+
return self._mqtt_channel.is_connected
43+
44+
@property
45+
def is_local_connected(self) -> bool:
46+
return False
47+
48+
async def subscribe(self, callback: Callable[[RoborockMessage], None]) -> Callable[[], None]:
49+
return await self._mqtt_channel.subscribe(callback)
50+
51+
async def subscribe_stream(self) -> AsyncGenerator[Q10Message, None]:
52+
"""Stream decoded Q10 messages received via MQTT."""
53+
async for msg in stream_decoded_messages(self._mqtt_channel):
54+
yield msg
55+
56+
async def send_command(
57+
self,
58+
command: B01_Q10_DP,
59+
params: ParamsType = None,
60+
) -> None:
61+
await send_command(self._mqtt_channel, command, params)
62+
63+
1964
async def stream_decoded_messages(
2065
mqtt_channel: MqttChannel,
2166
) -> AsyncGenerator[Q10Message, None]:
@@ -59,3 +104,8 @@ async def send_command(
59104
ex,
60105
)
61106
raise
107+
108+
109+
def create_b01_q10_channel(mqtt_channel: MqttChannel) -> B01Q10Channel:
110+
"""Create a B01Q10Channel wrapping MQTT transport."""
111+
return B01Q10Channel(mqtt_channel)

roborock/devices/rpc/b01_q7_channel.py

Lines changed: 76 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
11
"""Thin wrapper around the MQTT channel for Roborock B01 Q7 devices."""
22

3-
from __future__ import annotations
4-
53
import asyncio
64
import json
75
import logging
86
from collections.abc import Callable
9-
from typing import TypeAlias, TypeVar
7+
from typing import Protocol, TypeAlias, TypeVar
108

9+
from roborock.data import HomeDataDevice, HomeDataProduct
10+
from roborock.devices.transport.channel import Channel
1111
from roborock.devices.transport.mqtt_channel import MqttChannel
1212
from roborock.exceptions import RoborockException
1313
from roborock.protocols.b01_q7_protocol import (
14+
B01_Q7_DPS,
1415
B01_VERSION,
16+
CommandType,
1517
MapKey,
18+
ParamsType,
1619
Q7RequestMessage,
20+
create_map_key,
1721
decode_map_payload,
1822
decode_rpc_response,
1923
encode_mqtt_payload,
@@ -26,6 +30,30 @@
2630
DecodedB01Response: TypeAlias = dict[str, object] | str
2731

2832

33+
class Q7RpcChannel(Protocol):
34+
"""Protocol for Q7 RPC channels."""
35+
36+
async def send_command(
37+
self,
38+
command: CommandType,
39+
params: ParamsType = None,
40+
) -> DecodedB01Response:
41+
"""Send a command and get a decoded response."""
42+
...
43+
44+
45+
class Q7MapRpcChannel(Protocol):
46+
"""Protocol for Q7 map RPC channels."""
47+
48+
async def send_map_command(
49+
self,
50+
command: CommandType,
51+
params: ParamsType = None,
52+
) -> bytes:
53+
"""Send a map command and get decoded bytes."""
54+
...
55+
56+
2957
def _matches_map_response(response_message: RoborockMessage, *, version: bytes | None) -> bytes | None:
3058
"""Return raw map payload bytes for matching MAP_RESPONSE messages."""
3159
if (
@@ -120,39 +148,55 @@ def find_response(response_message: RoborockMessage) -> DecodedB01Response | Non
120148
raise RoborockException(f"B01 command timed out after {_TIMEOUT}s ({request_message})") from ex
121149
except RoborockException as ex:
122150
_LOGGER.warning(
123-
"Error sending B01 decoded command (%ss): %s",
151+
"Error sending B01 decoded command (%s): %s",
124152
request_message,
125153
ex,
126154
)
127155
raise
128156
except Exception as ex:
129157
_LOGGER.exception(
130-
"Error sending B01 decoded command (%ss): %s",
158+
"Error sending B01 decoded command (%s): %s",
131159
request_message,
132160
ex,
133161
)
134162
raise
135163

136164

137-
class MapRpcChannel:
138-
"""RPC channel for map-related commands on B01/Q7 devices."""
165+
class B01Q7Channel(Channel, Q7RpcChannel, Q7MapRpcChannel):
166+
"""Unified B01 Q7 channel wrapping MQTT transport."""
139167

140168
def __init__(self, mqtt_channel: MqttChannel, map_key: MapKey) -> None:
141169
self._mqtt_channel = mqtt_channel
142170
self._map_key = map_key
143171

144-
async def send_map_command(self, request_message: Q7RequestMessage) -> bytes:
145-
"""Send a map upload command and return decoded SCMap bytes.
146-
147-
This publishes the request and waits for a matching ``MAP_RESPONSE`` message
148-
with the correct protocol version. The raw ``MAP_RESPONSE`` payload bytes are
149-
then decoded/inflated via :func:`decode_map_payload` using this channel's
150-
``map_key``, and the resulting SCMap bytes are returned.
151-
152-
The returned value is the decoded map data bytes suitable for passing to the
153-
map parser library, not the raw MQTT ``MAP_RESPONSE`` payload bytes.
154-
"""
172+
@property
173+
def is_connected(self) -> bool:
174+
return self._mqtt_channel.is_connected
175+
176+
@property
177+
def is_local_connected(self) -> bool:
178+
return False
179+
180+
async def subscribe(self, callback: Callable[[RoborockMessage], None]) -> Callable[[], None]:
181+
return await self._mqtt_channel.subscribe(callback)
182+
183+
async def send_command(
184+
self,
185+
command: CommandType,
186+
params: ParamsType = None,
187+
) -> DecodedB01Response:
188+
return await send_decoded_command(
189+
self._mqtt_channel,
190+
Q7RequestMessage(dps=B01_Q7_DPS, command=command, params=params),
191+
)
155192

193+
async def send_map_command(
194+
self,
195+
command: CommandType,
196+
params: ParamsType = None,
197+
) -> bytes:
198+
"""Send a map upload command and return decoded SCMap bytes."""
199+
request_message = Q7RequestMessage(dps=B01_Q7_DPS, command=command, params=params)
156200
try:
157201
raw_payload = await _send_command(
158202
self._mqtt_channel,
@@ -163,3 +207,17 @@ async def send_map_command(self, request_message: Q7RequestMessage) -> bytes:
163207
raise RoborockException(f"B01 map command timed out after {_TIMEOUT}s ({request_message})") from ex
164208

165209
return decode_map_payload(raw_payload, map_key=self._map_key)
210+
211+
212+
def create_b01_q7_channel(
213+
device: HomeDataDevice,
214+
product: HomeDataProduct,
215+
mqtt_channel: MqttChannel,
216+
) -> B01Q7Channel:
217+
"""Create a B01Q7Channel for the given device."""
218+
if device.sn is None or product.model is None:
219+
raise RoborockException(
220+
f"Device serial number and product model are required (sn: {device.sn}, model: {product.model})"
221+
)
222+
map_key = create_map_key(serial=device.sn, model=product.model)
223+
return B01Q7Channel(mqtt_channel, map_key)

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@
44
import logging
55

66
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
7-
from roborock.devices.rpc.b01_q10_channel import stream_decoded_messages
7+
from roborock.devices.rpc.b01_q10_channel import B01Q10Channel
88
from roborock.devices.traits import Trait
9-
from roborock.devices.transport.mqtt_channel import MqttChannel
109
from roborock.map.b01_q10_map_parser import Q10MapPacket, Q10TracePacket
1110
from roborock.protocols.b01_q10_protocol import Q10DpsUpdate, Q10Message
1211

@@ -78,7 +77,7 @@ class Q10PropertiesApi(Trait):
7877
map: MapContentTrait
7978
"""Trait for fetching the current parsed map (image + rooms)."""
8079

81-
def __init__(self, channel: MqttChannel) -> None:
80+
def __init__(self, channel: B01Q10Channel) -> None:
8281
"""Initialize the B01Props API."""
8382
self._channel = channel
8483
self.command = CommandTrait(channel)
@@ -127,7 +126,7 @@ async def refresh(self) -> None:
127126

128127
async def _subscribe_loop(self) -> None:
129128
"""Persistent loop dispatching decoded messages to the read-model traits."""
130-
async for message in stream_decoded_messages(self._channel):
129+
async for message in self._channel.subscribe_stream():
131130
self._handle_message(message)
132131

133132
def _handle_message(self, message: Q10Message) -> None:
@@ -150,6 +149,6 @@ def _handle_message(self, message: Q10Message) -> None:
150149
trait.update_from_dps(message.dps)
151150

152151

153-
def create(channel: MqttChannel) -> Q10PropertiesApi:
152+
def create(channel: B01Q10Channel) -> Q10PropertiesApi:
154153
"""Create traits for B01 devices."""
155154
return Q10PropertiesApi(channel)

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

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
from typing import Any
22

33
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
4-
from roborock.devices.rpc.b01_q10_channel import send_command
5-
from roborock.devices.transport.mqtt_channel import MqttChannel
4+
from roborock.devices.rpc.b01_q10_channel import Q10RpcChannel
65
from roborock.protocols.b01_q10_protocol import ParamsType
76

87

@@ -15,9 +14,9 @@ class CommandTrait:
1514
available.
1615
"""
1716

18-
def __init__(self, channel: MqttChannel) -> None:
17+
def __init__(self, rpc_channel: Q10RpcChannel) -> None:
1918
"""Initialize the CommandTrait."""
20-
self._channel = channel
19+
self._rpc_channel = rpc_channel
2120

2221
async def send(self, command: B01_Q10_DP, params: ParamsType = None) -> Any:
2322
"""Send a command to the device.
@@ -27,6 +26,6 @@ async def send(self, command: B01_Q10_DP, params: ParamsType = None) -> Any:
2726
caller to ensure that any traits affected by the command are refreshed
2827
as needed.
2928
"""
30-
if not self._channel:
29+
if not self._rpc_channel:
3130
raise ValueError("Device trait in invalid state")
32-
return await send_command(self._channel, command, params=params)
31+
return await self._rpc_channel.send_command(command, params=params)

0 commit comments

Comments
 (0)