Skip to content

Commit 51495ce

Browse files
committed
fix: Add b01 q10 protocol encoding/decoding and tests
Pulled from #692 and #709
1 parent d593baa commit 51495ce

File tree

6 files changed

+285
-0
lines changed

6 files changed

+285
-0
lines changed
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""Roborock B01 Protocol encoding and decoding."""
2+
3+
import json
4+
import logging
5+
from typing import Any
6+
7+
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
8+
from roborock.exceptions import RoborockException
9+
from roborock.roborock_message import (
10+
RoborockMessage,
11+
RoborockMessageProtocol,
12+
)
13+
14+
_LOGGER = logging.getLogger(__name__)
15+
16+
B01_VERSION = b"B01"
17+
ParamsType = list | dict | int | None
18+
19+
20+
def encode_mqtt_payload(command: B01_Q10_DP, params: ParamsType) -> RoborockMessage:
21+
"""Encode payload for B01 Q10 commands over MQTT.
22+
23+
This does not perform any special encoding for the command parameters and expects
24+
them to already be in a request specific format.
25+
"""
26+
dps_data = {
27+
"dps": {
28+
# Important: some commands use falsy values so only default to `{}` when params is actually None.
29+
command.code: params if params is not None else {},
30+
}
31+
}
32+
return RoborockMessage(
33+
protocol=RoborockMessageProtocol.RPC_REQUEST,
34+
version=B01_VERSION,
35+
payload=json.dumps(dps_data).encode("utf-8"),
36+
)
37+
38+
39+
def _convert_datapoints(datapoints: dict[str, Any], message: RoborockMessage) -> dict[B01_Q10_DP, Any]:
40+
"""Convert the 'dps' dictionary keys from strings to B01_Q10_DP enums."""
41+
result: dict[B01_Q10_DP, Any] = {}
42+
for key, value in datapoints.items():
43+
try:
44+
code = int(key)
45+
except ValueError as e:
46+
raise ValueError(f"dps key is not a valid integer: {e} for {message.payload!r}") from e
47+
try:
48+
dps = B01_Q10_DP.from_code(code)
49+
except ValueError as e:
50+
raise ValueError(f"dps key is not a valid B01_Q10_DP: {e} for {message.payload!r}") from e
51+
# Update from_code to use `Self` on newer python version to remove this type ignore
52+
result[dps] = value # type: ignore[index]
53+
return result
54+
55+
56+
def decode_rpc_response(message: RoborockMessage) -> dict[B01_Q10_DP, Any]:
57+
"""Decode a B01 Q10 RPC_RESPONSE message.
58+
59+
This does not perform any special decoding for the response body, but does
60+
convert the 'dps' keys from strings to B01_Q10_DP enums.
61+
"""
62+
if not message.payload:
63+
raise RoborockException("Invalid B01 message format: missing payload")
64+
try:
65+
payload = json.loads(message.payload.decode())
66+
except (json.JSONDecodeError, UnicodeDecodeError) as e:
67+
raise RoborockException(f"Invalid B01 json payload: {e} for {message.payload!r}") from e
68+
69+
if (datapoints := payload.get("dps")) is None:
70+
raise RoborockException(f"Invalid B01 json payload: missing 'dps' for {message.payload!r}")
71+
if not isinstance(datapoints, dict):
72+
raise RoborockException(f"Invalid B01 message format: 'dps' should be a dictionary for {message.payload!r}")
73+
74+
try:
75+
result = _convert_datapoints(datapoints, message)
76+
except ValueError as e:
77+
raise RoborockException(f"Invalid B01 message format: {e}") from e
78+
79+
# The COMMON response contains nested datapoints need conversion. To simplify
80+
# response handling at higher levels we flatten these into the main result.
81+
if B01_Q10_DP.COMMON in result:
82+
common_result = result.pop(B01_Q10_DP.COMMON)
83+
if not isinstance(common_result, dict):
84+
raise RoborockException(f"Invalid dpCommon format: expected dict, got {type(common_result).__name__}")
85+
try:
86+
common_dps_result = _convert_datapoints(common_result, message)
87+
except ValueError as e:
88+
raise RoborockException(f"Invalid dpCommon format: {e}") from e
89+
result.update(common_dps_result)
90+
91+
return result
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# serializer version: 1
2+
# name: test_decode_rpc_payload[dpBattery]
3+
'''
4+
{
5+
"dpBattery": 100
6+
}
7+
'''
8+
# ---
9+
# name: test_decode_rpc_payload[dpRequetdps]
10+
'''
11+
{
12+
"dpStatus": 8,
13+
"dpBattery": 100,
14+
"dpfunLevel": 2,
15+
"dpWaterLevel": 1,
16+
"dpMainBrushLife": 0,
17+
"dpSideBrushLife": 0,
18+
"dpFilterLife": 0,
19+
"dpCleanCount": 1,
20+
"dpCleanMode": 1,
21+
"dpCleanTaskType": 0,
22+
"dpBackType": 5,
23+
"dpBreakpointClean": 0,
24+
"dpValleyPointCharging": false,
25+
"dpRobotCountryCode": "us",
26+
"dpUserPlan": 0,
27+
"dpNotDisturb": 1,
28+
"dpVolume": 74,
29+
"dpTotalCleanArea": 0,
30+
"dpTotalCleanCount": 0,
31+
"dpTotalCleanTime": 0,
32+
"dpDustSwitch": 1,
33+
"dpMopState": 1,
34+
"dpAutoBoost": 0,
35+
"dpChildLock": 0,
36+
"dpDustSetting": 0,
37+
"dpMapSaveSwitch": true,
38+
"dpRecendCleanRecord": false,
39+
"dpCleanTime": 0,
40+
"dpMultiMapSwitch": 1,
41+
"dpSensorLife": 0,
42+
"dpCleanArea": 0,
43+
"dpCarpetCleanType": 0,
44+
"dpCleanLine": 0,
45+
"dpTimeZone": {
46+
"timeZoneCity": "America/Los_Angeles",
47+
"timeZoneSec": -28800
48+
},
49+
"dpAreaUnit": 0,
50+
"dpNetInfo": {
51+
"ipAdress": "1.1.1.2",
52+
"mac": "99:AA:88:BB:77:CC",
53+
"signal": -50,
54+
"wifiName": "wifi-network-name"
55+
},
56+
"dpRobotType": 1,
57+
"dpLineLaserObstacleAvoidance": 1,
58+
"dpCleanProgess": 100,
59+
"dpGroundClean": 0,
60+
"dpFault": 0,
61+
"dpNotDisturbExpand": {
62+
"disturb_dust_enable": 1,
63+
"disturb_light": 1,
64+
"disturb_resume_clean": 1,
65+
"disturb_voice": 1
66+
},
67+
"dpTimerType": 1,
68+
"dpAddCleanState": 0
69+
}
70+
'''
71+
# ---
72+
# name: test_decode_rpc_payload[dpStatus-dpCleanTaskType]
73+
'''
74+
{
75+
"dpStatus": 8,
76+
"dpCleanTaskType": 0
77+
}
78+
'''
79+
# ---
80+
# name: test_encode_mqtt_payload[dpRequetdps-None]
81+
b'{"dps": {"102": {}}}'
82+
# ---
83+
# name: test_encode_mqtt_payload[dpRequetdps-params0]
84+
b'{"dps": {"102": {}}}'
85+
# ---
86+
# name: test_encode_mqtt_payload[dpStartClean-params2]
87+
b'{"dps": {"201": {"cmd": 1}}}'
88+
# ---
89+
# name: test_encode_mqtt_payload[dpWaterLevel-2]
90+
b'{"dps": {"124": 2}}'
91+
# ---
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Tests for the B01 protocol message encoding and decoding."""
2+
3+
import json
4+
import pathlib
5+
from collections.abc import Generator
6+
from typing import Any
7+
8+
import pytest
9+
from freezegun import freeze_time
10+
from syrupy import SnapshotAssertion
11+
12+
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXWaterLevel
13+
from roborock.exceptions import RoborockException
14+
from roborock.protocols.b01_q10_protocol import (
15+
decode_rpc_response,
16+
encode_mqtt_payload,
17+
)
18+
from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol
19+
20+
TESTDATA_PATH = pathlib.Path("tests/protocols/testdata/b01_q10_protocol/")
21+
TESTDATA_FILES = list(TESTDATA_PATH.glob("*.json"))
22+
TESTDATA_IDS = [x.stem for x in TESTDATA_FILES]
23+
24+
25+
@pytest.fixture(autouse=True)
26+
def fixed_time_fixture() -> Generator[None, None, None]:
27+
"""Fixture to freeze time for predictable request IDs."""
28+
with freeze_time("2025-01-20T12:00:00"):
29+
yield
30+
31+
32+
@pytest.mark.parametrize("filename", TESTDATA_FILES, ids=TESTDATA_IDS)
33+
def test_decode_rpc_payload(filename: str, snapshot: SnapshotAssertion) -> None:
34+
"""Test decoding a B01 RPC response protocol message."""
35+
with open(filename, "rb") as f:
36+
payload = f.read()
37+
38+
message = RoborockMessage(
39+
protocol=RoborockMessageProtocol.RPC_RESPONSE,
40+
payload=payload,
41+
seq=12750,
42+
version=b"B01",
43+
random=97431,
44+
timestamp=1652547161,
45+
)
46+
47+
decoded_message = decode_rpc_response(message)
48+
assert json.dumps(decoded_message, indent=2) == snapshot
49+
50+
51+
@pytest.mark.parametrize(
52+
("payload", "expected_error_message"),
53+
[
54+
(b"", "missing payload"),
55+
(b"n", "Invalid B01 json payload"),
56+
(b"{}", "missing 'dps'"),
57+
(b'{"dps": []}', "'dps' should be a dictionary"),
58+
(b'{"dps": {"not_a_number": 123}}', "dps key is not a valid integer"),
59+
(b'{"dps": {"101": 123}}', "Invalid dpCommon format: expected dict"),
60+
(b'{"dps": {"101": {"not_a_number": 123}}}', "Invalid dpCommon format: dps key is not a valid intege"),
61+
(b'{"dps": {"909090": 123}}', "dps key is not a valid B01_Q10_DP"),
62+
],
63+
)
64+
def test_decode_invalid_rpc_payload(payload: bytes, expected_error_message: str) -> None:
65+
"""Test decoding a B01 RPC response protocol message."""
66+
message = RoborockMessage(
67+
protocol=RoborockMessageProtocol.RPC_RESPONSE,
68+
payload=payload,
69+
seq=12750,
70+
version=b"B01",
71+
random=97431,
72+
timestamp=1652547161,
73+
)
74+
with pytest.raises(RoborockException, match=expected_error_message):
75+
decode_rpc_response(message)
76+
77+
78+
@pytest.mark.parametrize(
79+
("command", "params"),
80+
[
81+
(B01_Q10_DP.REQUETDPS, {}),
82+
(B01_Q10_DP.REQUETDPS, None),
83+
(B01_Q10_DP.START_CLEAN, {"cmd": 1}),
84+
(B01_Q10_DP.WATER_LEVEL, YXWaterLevel.MIDDLE.code),
85+
],
86+
)
87+
def test_encode_mqtt_payload(command: B01_Q10_DP, params: dict[str, Any], snapshot) -> None:
88+
"""Test encoding of MQTT payload for B01 Q10 commands."""
89+
90+
message = encode_mqtt_payload(command, params)
91+
assert isinstance(message, RoborockMessage)
92+
assert message.protocol == RoborockMessageProtocol.RPC_REQUEST
93+
assert message.version == b"B01"
94+
assert message.payload is not None
95+
96+
# Snapshot the raw payload to ensure stable encoding. We verify it is
97+
# valid json
98+
assert snapshot == message.payload
99+
100+
json.loads(message.payload.decode())
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"dps":{"122":100},"t":1766800902}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"dps":{"101":{"104":0,"105":false,"109":"us","207":0,"25":1,"26":74,"29":0,"30":0,"31":0,"37":1,"40":1,"45":0,"47":0,"50":0,"51":true,"53":false,"6":0,"60":1,"67":0,"7":0,"76":0,"78":0,"79":{"timeZoneCity":"America/Los_Angeles","timeZoneSec":-28800},"80":0,"81":{"ipAdress":"1.1.1.2","mac":"99:AA:88:BB:77:CC","signal":-50,"wifiName":"wifi-network-name"},"83":1,"86":1,"87":100,"88":0,"90":0,"92":{"disturb_dust_enable":1,"disturb_light":1,"disturb_resume_clean":1,"disturb_voice":1},"93":1,"96":0},"121":8,"122":100,"123":2,"124":1,"125":0,"126":0,"127":0,"136":1,"137":1,"138":0,"139":5},"t":1766802312}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"dps":{"121":8,"138":0},"t":1766800904}

0 commit comments

Comments
 (0)