Skip to content

Commit 72274e9

Browse files
Lash-Lallenporter
andauthored
feat: add some basic B01 support (#429)
* feat: add some basic B01 support * fix: lint * fix: lint --------- Co-authored-by: Allen Porter <[email protected]>
1 parent 98ea911 commit 72274e9

File tree

8 files changed

+239
-2
lines changed

8 files changed

+239
-2
lines changed

roborock/devices/b01_channel.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Thin wrapper around the MQTT channel for Roborock B01 devices."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import Any
7+
8+
from roborock.protocols.b01_protocol import (
9+
CommandType,
10+
ParamsType,
11+
decode_rpc_response,
12+
encode_mqtt_payload,
13+
)
14+
15+
from .mqtt_channel import MqttChannel
16+
17+
_LOGGER = logging.getLogger(__name__)
18+
19+
20+
async def send_decoded_command(
21+
mqtt_channel: MqttChannel,
22+
dps: int,
23+
command: CommandType,
24+
params: ParamsType,
25+
) -> dict[int, Any]:
26+
"""Send a command on the MQTT channel and get a decoded response."""
27+
_LOGGER.debug("Sending MQTT command: %s", params)
28+
roborock_message = encode_mqtt_payload(dps, command, params)
29+
response = await mqtt_channel.send_message(roborock_message)
30+
return decode_rpc_response(response) # type: ignore[return-value]

roborock/devices/device_manager.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from .cache import Cache, NoCache
2222
from .channel import Channel
2323
from .mqtt_channel import create_mqtt_channel
24+
from .traits.b01.props import B01PropsApi
2425
from .traits.dyad import DyadApi
2526
from .traits.status import StatusTrait
2627
from .traits.trait import Trait
@@ -45,6 +46,7 @@ class DeviceVersion(enum.StrEnum):
4546

4647
V1 = "1.0"
4748
A01 = "A01"
49+
B01 = "B01"
4850
UNKNOWN = "unknown"
4951

5052

@@ -159,6 +161,9 @@ def device_creator(device: HomeDataDevice, product: HomeDataProduct) -> Roborock
159161
traits.append(ZeoApi(mqtt_channel))
160162
case _:
161163
raise NotImplementedError(f"Device {device.name} has unsupported category {product.category}")
164+
case DeviceVersion.B01:
165+
mqtt_channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
166+
traits.append(B01PropsApi(mqtt_channel))
162167
case _:
163168
raise NotImplementedError(f"Device {device.name} has unsupported version {device.pv}")
164169
return RoborockDevice(device, channel, traits)

roborock/devices/traits/b01/__init__.py

Whitespace-only changes.

roborock/devices/traits/b01/props.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from typing import Any
5+
6+
from roborock import RoborockB01Methods
7+
from roborock.roborock_message import RoborockB01Props
8+
9+
from ...b01_channel import send_decoded_command
10+
from ...mqtt_channel import MqttChannel
11+
from ..trait import Trait
12+
13+
_LOGGER = logging.getLogger(__name__)
14+
15+
__all__ = [
16+
"B01PropsApi",
17+
]
18+
19+
20+
class B01PropsApi(Trait):
21+
"""API for interacting with B01 devices."""
22+
23+
name = "B01_props"
24+
25+
def __init__(self, channel: MqttChannel) -> None:
26+
"""Initialize the B01Props API."""
27+
self._channel = channel
28+
29+
async def query_values(self, props: list[RoborockB01Props]) -> dict[int, Any]:
30+
"""Query the device for the values of the given Dyad protocols."""
31+
return await send_decoded_command(self._channel, dps=10000, command=RoborockB01Methods.GET_PROP, params=props)

roborock/protocol.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
_LOGGER = logging.getLogger(__name__)
4040
SALT = b"TXdfu$jyZ#TZHsg4"
4141
A01_HASH = "726f626f726f636b2d67a6d6da"
42+
B01_HASH = "5wwh9ikChRjASpMU8cxg7o1d2E"
4243
BROADCAST_TOKEN = b"qWKYcdQWrbm9hPqe"
4344
AP_CONFIG = 1
4445
SOCK_DISCOVERY = 2
@@ -213,6 +214,10 @@ def _encode(self, obj, context, _):
213214
decipher = AES.new(bytes(context.search("local_key"), "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8"))
214215
f = decipher.encrypt(obj)
215216
return f
217+
elif context.version == b"B01":
218+
iv = md5hex(f"{context.random:08x}" + B01_HASH)[9:25]
219+
decipher = AES.new(bytes(context.search("local_key"), "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8"))
220+
return decipher.encrypt(obj)
216221
token = self.token_func(context)
217222
encrypted = Utils.encrypt_ecb(obj, token)
218223
return encrypted
@@ -224,6 +229,10 @@ def _decode(self, obj, context, _):
224229
decipher = AES.new(bytes(context.search("local_key"), "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8"))
225230
f = decipher.decrypt(obj)
226231
return f
232+
elif context.version == b"B01":
233+
iv = md5hex(f"{context.random:08x}" + B01_HASH)[9:25]
234+
decipher = AES.new(bytes(context.search("local_key"), "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8"))
235+
return decipher.decrypt(obj)
227236
token = self.token_func(context)
228237
decrypted = Utils.decrypt_ecb(obj, token)
229238
return decrypted
@@ -248,7 +257,7 @@ class PrefixedStruct(Struct):
248257
def _parse(self, stream, context, path):
249258
subcon1 = Peek(Optional(Bytes(3)))
250259
peek_version = subcon1.parse_stream(stream, **context)
251-
if peek_version not in (b"1.0", b"A01"):
260+
if peek_version not in (b"1.0", b"A01", b"B01"):
252261
subcon2 = Bytes(4)
253262
subcon2.parse_stream(stream, **context)
254263
return super()._parse(stream, context, path)

roborock/protocols/b01_protocol.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Roborock B01 Protocol encoding and decoding."""
2+
3+
import json
4+
import logging
5+
from typing import Any
6+
7+
from Crypto.Cipher import AES
8+
from Crypto.Util.Padding import pad, unpad
9+
10+
from roborock import RoborockB01Methods
11+
from roborock.exceptions import RoborockException
12+
from roborock.roborock_message import (
13+
RoborockMessage,
14+
RoborockMessageProtocol,
15+
)
16+
17+
_LOGGER = logging.getLogger(__name__)
18+
19+
B01_VERSION = b"B01"
20+
CommandType = RoborockB01Methods | str
21+
ParamsType = list | dict | int | None
22+
23+
24+
def encode_mqtt_payload(dps: int, command: CommandType, params: ParamsType) -> RoborockMessage:
25+
"""Encode payload for B01 commands over MQTT."""
26+
dps_data = {"dps": {dps: {"method": command, "params": params or []}}}
27+
payload = pad(json.dumps(dps_data).encode("utf-8"), AES.block_size)
28+
return RoborockMessage(
29+
protocol=RoborockMessageProtocol.RPC_REQUEST,
30+
version=B01_VERSION,
31+
payload=payload,
32+
)
33+
34+
35+
def decode_rpc_response(message: RoborockMessage) -> dict[int, Any]:
36+
"""Decode a B01 RPC_RESPONSE message."""
37+
if not message.payload:
38+
raise RoborockException("Invalid B01 message format: missing payload")
39+
try:
40+
unpadded = unpad(message.payload, AES.block_size)
41+
except ValueError as err:
42+
raise RoborockException(f"Unable to unpad B01 payload: {err}")
43+
44+
try:
45+
payload = json.loads(unpadded.decode())
46+
except (json.JSONDecodeError, TypeError) as e:
47+
raise RoborockException(f"Invalid B01 message payload: {e} for {message.payload!r}") from e
48+
49+
datapoints = payload.get("dps", {})
50+
if not isinstance(datapoints, dict):
51+
raise RoborockException(f"Invalid B01 message format: 'dps' should be a dictionary for {message.payload!r}")
52+
try:
53+
return {int(key): value for key, value in datapoints.items()}
54+
except ValueError:
55+
raise RoborockException(f"Invalid B01 message format: 'dps' key should be an integer for {message.payload!r}")

roborock/roborock_message.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import math
55
import time
66
from dataclasses import dataclass, field
7+
from enum import StrEnum
78

89
from roborock import RoborockEnum
910
from roborock.util import get_next_int
@@ -130,6 +131,98 @@ class RoborockZeoProtocol(RoborockEnum):
130131
RPC_RESp = 10102
131132

132133

134+
class RoborockB01Protocol(RoborockEnum):
135+
RPC_REQUEST = 101
136+
RPC_RESPONSE = 102
137+
ERROR_CODE = 120
138+
STATE = 121
139+
BATTERY = 122
140+
FAN_POWER = 123
141+
WATER_BOX_MODE = 124
142+
MAIN_BRUSH_LIFE = 125
143+
SIDE_BRUSH_LIFE = 126
144+
FILTER_LIFE = 127
145+
OFFLINE_STATUS = 135
146+
CLEAN_TIMES = 136
147+
CLEANING_PREFERENCE = 137
148+
CLEAN_TASK_TYPE = 138
149+
BACK_TYPE = 139
150+
DOCK_TASK_TYPE = 140
151+
CLEANING_PROGRESS = 141
152+
FC_STATE = 142
153+
START_CLEAN_TASK = 201
154+
START_BACK_DOCK_TASK = 202
155+
START_DOCK_TASK = 203
156+
PAUSE = 204
157+
RESUME = 205
158+
STOP = 206
159+
CEIP = 207
160+
161+
162+
class RoborockB01Props(StrEnum):
163+
"""Properties requested by the Roborock B01 model."""
164+
165+
STATUS = "status"
166+
FAULT = "fault"
167+
WIND = "wind"
168+
WATER = "water"
169+
MODE = "mode"
170+
QUANTITY = "quantity"
171+
ALARM = "alarm"
172+
VOLUME = "volume"
173+
HYPA = "hypa"
174+
MAIN_BRUSH = "main_brush"
175+
SIDE_BRUSH = "side_brush"
176+
MOP_LIFE = "mop_life"
177+
MAIN_SENSOR = "main_sensor"
178+
NET_STATUS = "net_status"
179+
REPEAT_STATE = "repeat_state"
180+
TANK_STATE = "tank_state"
181+
SWEEP_TYPE = "sweep_type"
182+
CLEAN_PATH_PREFERENCE = "clean_path_preference"
183+
CLOTH_STATE = "cloth_state"
184+
TIME_ZONE = "time_zone"
185+
TIME_ZONE_INFO = "time_zone_info"
186+
LANGUAGE = "language"
187+
CLEANING_TIME = "cleaning_time"
188+
REAL_CLEAN_TIME = "real_clean_time"
189+
CLEANING_AREA = "cleaning_area"
190+
CUSTOM_TYPE = "custom_type"
191+
SOUND = "sound"
192+
WORK_MODE = "work_mode"
193+
STATION_ACT = "station_act"
194+
CHARGE_STATE = "charge_state"
195+
CURRENT_MAP_ID = "current_map_id"
196+
MAP_NUM = "map_num"
197+
DUST_ACTION = "dust_action"
198+
QUIET_IS_OPEN = "quiet_is_open"
199+
QUIET_BEGIN_TIME = "quiet_begin_time"
200+
QUIET_END_TIME = "quiet_end_time"
201+
CLEAN_FINISH = "clean_finish"
202+
VOICE_TYPE = "voice_type"
203+
VOICE_TYPE_VERSION = "voice_type_version"
204+
ORDER_TOTAL = "order_total"
205+
BUILD_MAP = "build_map"
206+
PRIVACY = "privacy"
207+
DUST_AUTO_STATE = "dust_auto_state"
208+
DUST_FREQUENCY = "dust_frequency"
209+
CHILD_LOCK = "child_lock"
210+
MULTI_FLOOR = "multi_floor"
211+
MAP_SAVE = "map_save"
212+
LIGHT_MODE = "light_mode"
213+
GREEN_LASER = "green_laser"
214+
DUST_BAG_USED = "dust_bag_used"
215+
ORDER_SAVE_MODE = "order_save_mode"
216+
MANUFACTURER = "manufacturer"
217+
BACK_TO_WASH = "back_to_wash"
218+
CHARGE_STATION_TYPE = "charge_station_type"
219+
PV_CUT_CHARGE = "pv_cut_charge"
220+
PV_CHARGING = "pv_charging"
221+
SERIAL_NUMBER = "serial_number"
222+
RECOMMEND = "recommend"
223+
ADD_SWEEP_STATUS = "add_sweep_status"
224+
225+
133226
ROBOROCK_DATA_STATUS_PROTOCOL = [
134227
RoborockDataProtocol.ERROR_CODE,
135228
RoborockDataProtocol.STATE,

roborock/roborock_typing.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
from dataclasses import dataclass, field
4-
from enum import Enum
4+
from enum import Enum, StrEnum
55

66
from .containers import (
77
CleanRecord,
@@ -271,6 +271,20 @@ class RoborockCommand(str, Enum):
271271
APP_GET_ROBOT_SETTING = "app_get_robot_setting"
272272

273273

274+
class RoborockB01Methods(StrEnum):
275+
"""Methods used by the Roborock B01 model."""
276+
277+
GET_PROP = "prop.get"
278+
GET_MAP_LIST = "service.get_map_list"
279+
UPLOAD_BY_MAPTYPE = "service.upload_by_maptype"
280+
SET_PROP = "prop.set"
281+
GET_PREFERENCE = "service.get_preference"
282+
GET_RECORD_LIST = "service.get_record_list"
283+
GET_ORDER = "service.get_order"
284+
EVENT_ORDER_LIST_POST = "event.order_list.post"
285+
POST_PROP = "prop.post"
286+
287+
274288
@dataclass
275289
class DockSummary(RoborockBase):
276290
dust_collection_mode: DustCollectionMode | None = None

0 commit comments

Comments
 (0)