Skip to content

Commit 656f715

Browse files
authored
fix: Add b01 q10 protocol encoding/decoding and tests (#718)
* fix: Add b01 q10 protocol encoding/decoding and tests Pulled from #692 and #709 * fix: Support unknown q10 DPS enum codes
1 parent d593baa commit 656f715

File tree

8 files changed

+316
-1
lines changed

8 files changed

+316
-1
lines changed

roborock/data/code_mappings.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ class RoborockModeEnum(StrEnum):
5555
"""A custom StrEnum that also stores an integer code for each member."""
5656

5757
code: int
58+
"""The integer code associated with the enum member."""
5859

5960
def __new__(cls, value: str, code: int) -> RoborockModeEnum:
6061
"""Creates a new enum member."""
@@ -68,7 +69,18 @@ def from_code(cls, code: int) -> RoborockModeEnum:
6869
for member in cls:
6970
if member.code == code:
7071
return member
71-
raise ValueError(f"{code} is not a valid code for {cls.__name__}")
72+
message = f"{code} is not a valid code for {cls.__name__}"
73+
if message not in completed_warnings:
74+
completed_warnings.add(message)
75+
_LOGGER.warning(message)
76+
raise ValueError(message)
77+
78+
@classmethod
79+
def from_code_optional(cls, code: int) -> RoborockModeEnum | None:
80+
try:
81+
return cls.from_code(code)
82+
except ValueError:
83+
return None
7284

7385
@classmethod
7486
def from_value(cls, value: str) -> RoborockModeEnum:
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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+
if (dps := B01_Q10_DP.from_code_optional(code)) is not None:
48+
# Update from_code to use `Self` on newer python version to remove this type ignore
49+
result[dps] = value # type: ignore[index]
50+
return result
51+
52+
53+
def decode_rpc_response(message: RoborockMessage) -> dict[B01_Q10_DP, Any]:
54+
"""Decode a B01 Q10 RPC_RESPONSE message.
55+
56+
This does not perform any special decoding for the response body, but does
57+
convert the 'dps' keys from strings to B01_Q10_DP enums.
58+
"""
59+
if not message.payload:
60+
raise RoborockException("Invalid B01 message format: missing payload")
61+
try:
62+
payload = json.loads(message.payload.decode())
63+
except (json.JSONDecodeError, UnicodeDecodeError) as e:
64+
raise RoborockException(f"Invalid B01 json payload: {e} for {message.payload!r}") from e
65+
66+
if (datapoints := payload.get("dps")) is None:
67+
raise RoborockException(f"Invalid B01 json payload: missing 'dps' for {message.payload!r}")
68+
if not isinstance(datapoints, dict):
69+
raise RoborockException(f"Invalid B01 message format: 'dps' should be a dictionary for {message.payload!r}")
70+
71+
try:
72+
result = _convert_datapoints(datapoints, message)
73+
except ValueError as e:
74+
raise RoborockException(f"Invalid B01 message format: {e}") from e
75+
76+
# The COMMON response contains nested datapoints need conversion. To simplify
77+
# response handling at higher levels we flatten these into the main result.
78+
if B01_Q10_DP.COMMON in result:
79+
common_result = result.pop(B01_Q10_DP.COMMON)
80+
if not isinstance(common_result, dict):
81+
raise RoborockException(f"Invalid dpCommon format: expected dict, got {type(common_result).__name__}")
82+
try:
83+
common_dps_result = _convert_datapoints(common_result, message)
84+
except ValueError as e:
85+
raise RoborockException(f"Invalid dpCommon format: {e}") from e
86+
result.update(common_dps_result)
87+
88+
return result

tests/data/test_code_mappings.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ def test_invalid_from_code() -> None:
2020
B01_Q10_DP.from_code(999999)
2121

2222

23+
def test_invalid_from_code_optional() -> None:
24+
"""Test invalid from_code_optional method."""
25+
assert B01_Q10_DP.from_code_optional(999999) is None
26+
27+
2328
def test_from_name() -> None:
2429
"""Test from_name method."""
2530
assert B01_Q10_DP.START_CLEAN == B01_Q10_DP.from_name("START_CLEAN")
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: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
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+
],
62+
)
63+
def test_decode_invalid_rpc_payload(payload: bytes, expected_error_message: str) -> None:
64+
"""Test decoding a B01 RPC response protocol message."""
65+
message = RoborockMessage(
66+
protocol=RoborockMessageProtocol.RPC_RESPONSE,
67+
payload=payload,
68+
seq=12750,
69+
version=b"B01",
70+
random=97431,
71+
timestamp=1652547161,
72+
)
73+
with pytest.raises(RoborockException, match=expected_error_message):
74+
decode_rpc_response(message)
75+
76+
77+
def test_decode_unknown_dps_code() -> None:
78+
"""Test decoding a B01 RPC response protocol message."""
79+
message = RoborockMessage(
80+
protocol=RoborockMessageProtocol.RPC_RESPONSE,
81+
payload=b'{"dps": {"909090": 123, "122":100}}',
82+
seq=12750,
83+
version=b"B01",
84+
random=97431,
85+
timestamp=1652547161,
86+
)
87+
88+
decoded_message = decode_rpc_response(message)
89+
assert decoded_message == {
90+
B01_Q10_DP.BATTERY: 100,
91+
}
92+
93+
94+
@pytest.mark.parametrize(
95+
("command", "params"),
96+
[
97+
(B01_Q10_DP.REQUETDPS, {}),
98+
(B01_Q10_DP.REQUETDPS, None),
99+
(B01_Q10_DP.START_CLEAN, {"cmd": 1}),
100+
(B01_Q10_DP.WATER_LEVEL, YXWaterLevel.MIDDLE.code),
101+
],
102+
)
103+
def test_encode_mqtt_payload(command: B01_Q10_DP, params: dict[str, Any], snapshot) -> None:
104+
"""Test encoding of MQTT payload for B01 Q10 commands."""
105+
106+
message = encode_mqtt_payload(command, params)
107+
assert isinstance(message, RoborockMessage)
108+
assert message.protocol == RoborockMessageProtocol.RPC_REQUEST
109+
assert message.version == b"B01"
110+
assert message.payload is not None
111+
112+
# Snapshot the raw payload to ensure stable encoding. We verify it is
113+
# valid json
114+
assert snapshot == message.payload
115+
116+
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)