Skip to content

Commit 5db13b8

Browse files
committed
chore: Add explicit Q7 request message handling code
This is preparing for Q10 devices which have a different application level protocl than the Q7 devices, while sharing the lower level B01 encoding. This adds an explicit structure to the Q7 request message, encapsulating some of the detail like message id generation inside the mssage, similar to how v1 code base is setup.
1 parent 7e40857 commit 5db13b8

File tree

8 files changed

+164
-179
lines changed

8 files changed

+164
-179
lines changed

roborock/devices/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -490,7 +490,7 @@ For each V1 command:
490490
| **RPC Abstraction** | `RpcChannel` with strategies | Helper functions |
491491
| **Strategy Pattern** | ✅ Multi-strategy (Local → MQTT) | ❌ Direct MQTT only |
492492
| **Health Manager** | ✅ Tracks local/MQTT health | ❌ Not needed |
493-
| **Code Location** | `v1_channel.py` | `a01_channel.py`, `b01_channel.py` |
493+
| **Code Location** | `v1_channel.py` | `a01_channel.py`, `b01_q7_channel.py` |
494494

495495
#### Health Management (V1 Only)
496496

@@ -572,7 +572,7 @@ roborock/
572572
│ ├── local_channel.py # Local TCP channel implementation
573573
│ ├── v1_channel.py # V1 protocol channel with RPC strategies
574574
│ ├── a01_channel.py # A01 protocol helpers
575-
│ ├── b01_channel.py # B01 protocol helpers
575+
│ ├── b01_q7_channel.py # B01 Q7 protocol helpers
576576
│ └── traits/ # Device-specific command traits
577577
│ └── v1/ # V1 device traits
578578
│ ├── __init__.py # Trait initialization
@@ -585,7 +585,7 @@ roborock/
585585
├── protocols/ # Protocol encoders/decoders
586586
│ ├── v1_protocol.py # V1 JSON RPC protocol
587587
│ ├── a01_protocol.py # A01 protocol
588-
│ ├── b01_protocol.py # B01 protocol
588+
│ ├── b01_q7_protocol.py # B01 Q7 protocol
589589
│ └── ...
590590
└── data/ # Data containers and mappings
591591
├── containers.py # Status, HomeData, etc.
Lines changed: 16 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,12 @@
88
from typing import Any
99

1010
from roborock.exceptions import RoborockException
11-
from roborock.protocols.b01_protocol import (
12-
CommandType,
13-
ParamsType,
11+
from roborock.protocols.b01_q7_protocol import (
12+
Q7RequestMessage,
1413
decode_rpc_response,
1514
encode_mqtt_payload,
1615
)
1716
from roborock.roborock_message import RoborockMessage
18-
from roborock.util import get_next_int
1917

2018
from .mqtt_channel import MqttChannel
2119

@@ -25,20 +23,11 @@
2523

2624
async def send_decoded_command(
2725
mqtt_channel: MqttChannel,
28-
dps: int,
29-
command: CommandType,
30-
params: ParamsType,
26+
request_message: Q7RequestMessage,
3127
) -> dict[str, Any] | None:
3228
"""Send a command on the MQTT channel and get a decoded response."""
33-
msg_id = str(get_next_int(100000000000, 999999999999))
34-
_LOGGER.debug(
35-
"Sending B01 MQTT command: dps=%s method=%s msg_id=%s params=%s",
36-
dps,
37-
command,
38-
msg_id,
39-
params,
40-
)
41-
roborock_message = encode_mqtt_payload(dps, command, params, msg_id)
29+
_LOGGER.debug("Sending B01 MQTT command: %s", request_message)
30+
roborock_message = encode_mqtt_payload(request_message)
4231
future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
4332

4433
def find_response(response_message: RoborockMessage) -> None:
@@ -48,13 +37,12 @@ def find_response(response_message: RoborockMessage) -> None:
4837
except RoborockException as ex:
4938
_LOGGER.debug(
5039
"Failed to decode B01 RPC response (expecting method=%s msg_id=%s): %s: %s",
51-
command,
52-
msg_id,
40+
request_message.command,
41+
request_message.msg_id,
5342
response_message,
5443
ex,
5544
)
5645
return
57-
5846
for dps_value in decoded_dps.values():
5947
# valid responses are JSON strings wrapped in the dps value
6048
if not isinstance(dps_value, str):
@@ -66,29 +54,22 @@ def find_response(response_message: RoborockMessage) -> None:
6654
except (json.JSONDecodeError, TypeError):
6755
_LOGGER.debug("Received unexpected response: %s", dps_value)
6856
continue
69-
70-
if isinstance(inner, dict) and inner.get("msgId") == msg_id:
57+
if isinstance(inner, dict) and inner.get("msgId") == str(request_message.msg_id):
7158
_LOGGER.debug("Received query response: %s", inner)
7259
# Check for error code (0 = success, non-zero = error)
7360
code = inner.get("code", 0)
7461
if code != 0:
75-
error_msg = (
76-
f"B01 command failed with code {code} "
77-
f"(method={command}, msg_id={msg_id}, dps={dps}, params={params})"
78-
)
62+
error_msg = f"B01 command failed with code {code} ({request_message})"
7963
_LOGGER.debug("B01 error response: %s", error_msg)
8064
if not future.done():
8165
future.set_exception(RoborockException(error_msg))
8266
return
8367
data = inner.get("data")
8468
# All get commands should be dicts
85-
if command.endswith(".get") and not isinstance(data, dict):
69+
if request_message.command.endswith(".get") and not isinstance(data, dict):
8670
if not future.done():
8771
future.set_exception(
88-
RoborockException(
89-
f"Unexpected data type for response "
90-
f"(method={command}, msg_id={msg_id}, dps={dps}, params={params})"
91-
)
72+
RoborockException(f"Unexpected data type for response {data} ({request_message})")
9273
)
9374
return
9475
if not future.done():
@@ -101,27 +82,19 @@ def find_response(response_message: RoborockMessage) -> None:
10182
await mqtt_channel.publish(roborock_message)
10283
return await asyncio.wait_for(future, timeout=_TIMEOUT)
10384
except TimeoutError as ex:
104-
raise RoborockException(
105-
f"B01 command timed out after {_TIMEOUT}s (method={command}, msg_id={msg_id}, dps={dps}, params={params})"
106-
) from ex
85+
raise RoborockException(f"B01 command timed out after {_TIMEOUT}s ({request_message})") from ex
10786
except RoborockException as ex:
10887
_LOGGER.warning(
109-
"Error sending B01 decoded command (method=%s msg_id=%s dps=%s params=%s): %s",
110-
command,
111-
msg_id,
112-
dps,
113-
params,
88+
"Error sending B01 decoded command (%ss): %s",
89+
request_message,
11490
ex,
11591
)
11692
raise
11793

11894
except Exception as ex:
11995
_LOGGER.exception(
120-
"Error sending B01 decoded command (method=%s msg_id=%s dps=%s params=%s): %s",
121-
command,
122-
msg_id,
123-
dps,
124-
params,
96+
"Error sending B01 decoded command (%ss): %s",
97+
request_message,
12598
ex,
12699
)
127100
raise

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010
SCWindMapping,
1111
WaterLevelMapping,
1212
)
13-
from roborock.devices.b01_channel import CommandType, ParamsType, send_decoded_command
13+
from roborock.devices.b01_q7_channel import send_decoded_command
1414
from roborock.devices.mqtt_channel import MqttChannel
1515
from roborock.devices.traits import Trait
16+
from roborock.protocols.b01_q7_protocol import CommandType, ParamsType, Q7RequestMessage
1617
from roborock.roborock_message import RoborockB01Props
1718
from roborock.roborock_typing import RoborockB01Q7Methods
1819

@@ -104,9 +105,7 @@ async def send(self, command: CommandType, params: ParamsType) -> Any:
104105
"""Send a command to the device."""
105106
return await send_decoded_command(
106107
self._channel,
107-
dps=10000,
108-
command=command,
109-
params=params,
108+
Q7RequestMessage(dps=10000, command=command, params=params),
110109
)
111110

112111

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import json
44
import logging
5+
from dataclasses import dataclass, field
56
from typing import Any
67

78
from Crypto.Cipher import AES
@@ -13,6 +14,7 @@
1314
RoborockMessage,
1415
RoborockMessageProtocol,
1516
)
17+
from roborock.util import get_next_int
1618

1719
_LOGGER = logging.getLogger(__name__)
1820

@@ -21,20 +23,32 @@
2123
ParamsType = list | dict | int | None
2224

2325

24-
def encode_mqtt_payload(dps: int, command: CommandType, params: ParamsType, msg_id: str) -> RoborockMessage:
25-
"""Encode payload for B01 commands over MQTT."""
26-
dps_data = {
27-
"dps": {
28-
dps: {
29-
"method": str(command),
30-
"msgId": msg_id,
26+
@dataclass
27+
class Q7RequestMessage:
28+
"""Data class for B01 Q7 request message."""
29+
30+
dps: int
31+
command: CommandType
32+
params: ParamsType
33+
msg_id: int = field(default_factory=lambda: get_next_int(100000000000, 999999999999))
34+
35+
def to_dps_value(self) -> dict[int, Any]:
36+
"""Return the 'dps' payload dictionary."""
37+
return {
38+
self.dps: {
39+
"method": str(self.command),
40+
"msgId": str(self.msg_id),
3141
# Important: some B01 methods use an empty object `{}` (not `[]`) for
3242
# "no params", and some setters legitimately send `0` which is falsy.
3343
# Only default to `[]` when params is actually None.
34-
"params": params if params is not None else [],
44+
"params": self.params if self.params is not None else [],
3545
}
3646
}
37-
}
47+
48+
49+
def encode_mqtt_payload(request: Q7RequestMessage) -> RoborockMessage:
50+
"""Encode payload for B01 commands over MQTT."""
51+
dps_data = {"dps": request.to_dps_value()}
3852
payload = pad(json.dumps(dps_data).encode("utf-8"), AES.block_size)
3953
return RoborockMessage(
4054
protocol=RoborockMessageProtocol.RPC_REQUEST,

tests/devices/traits/b01/q7/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)