python-roborock 2.32.0__tar.gz → 2.34.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. {python_roborock-2.32.0 → python_roborock-2.34.0}/PKG-INFO +1 -1
  2. {python_roborock-2.32.0 → python_roborock-2.34.0}/pyproject.toml +2 -2
  3. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/cli.py +16 -9
  4. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/devices/__init__.py +1 -0
  5. python_roborock-2.34.0/roborock/devices/b01_channel.py +30 -0
  6. python_roborock-2.34.0/roborock/devices/cache.py +57 -0
  7. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/devices/device_manager.py +25 -7
  8. python_roborock-2.34.0/roborock/devices/traits/b01/props.py +31 -0
  9. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/devices/v1_channel.py +20 -7
  10. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/mqtt/roborock_session.py +8 -3
  11. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/protocol.py +10 -1
  12. python_roborock-2.34.0/roborock/protocols/b01_protocol.py +55 -0
  13. python_roborock-2.34.0/roborock/py.typed +0 -0
  14. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/roborock_message.py +93 -0
  15. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/roborock_typing.py +15 -1
  16. {python_roborock-2.32.0 → python_roborock-2.34.0}/LICENSE +0 -0
  17. {python_roborock-2.32.0 → python_roborock-2.34.0}/README.md +0 -0
  18. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/__init__.py +0 -0
  19. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/api.py +0 -0
  20. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/cloud_api.py +0 -0
  21. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/code_mappings.py +0 -0
  22. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/command_cache.py +0 -0
  23. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/const.py +0 -0
  24. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/containers.py +0 -0
  25. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/device_features.py +0 -0
  26. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/devices/README.md +0 -0
  27. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/devices/a01_channel.py +0 -0
  28. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/devices/channel.py +0 -0
  29. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/devices/device.py +0 -0
  30. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/devices/local_channel.py +0 -0
  31. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/devices/mqtt_channel.py +0 -0
  32. /python_roborock-2.32.0/roborock/py.typed → /python_roborock-2.34.0/roborock/devices/traits/b01/__init__.py +0 -0
  33. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/devices/traits/dyad.py +0 -0
  34. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/devices/traits/status.py +0 -0
  35. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/devices/traits/trait.py +0 -0
  36. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/devices/traits/zeo.py +0 -0
  37. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/devices/v1_rpc_channel.py +0 -0
  38. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/exceptions.py +0 -0
  39. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/local_api.py +0 -0
  40. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/mqtt/__init__.py +0 -0
  41. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/mqtt/session.py +0 -0
  42. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/protocols/a01_protocol.py +0 -0
  43. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/protocols/v1_protocol.py +0 -0
  44. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/roborock_future.py +0 -0
  45. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/util.py +0 -0
  46. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/version_1_apis/__init__.py +0 -0
  47. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/version_1_apis/roborock_client_v1.py +0 -0
  48. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/version_1_apis/roborock_local_client_v1.py +0 -0
  49. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/version_1_apis/roborock_mqtt_client_v1.py +0 -0
  50. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/version_a01_apis/__init__.py +0 -0
  51. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/version_a01_apis/roborock_client_a01.py +0 -0
  52. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/version_a01_apis/roborock_mqtt_client_a01.py +0 -0
  53. {python_roborock-2.32.0 → python_roborock-2.34.0}/roborock/web_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-roborock
3
- Version: 2.32.0
3
+ Version: 2.34.0
4
4
  Summary: A package to control Roborock vacuums.
5
5
  Home-page: https://github.com/humbertogontijo/python-roborock
6
6
  License: GPL-3.0-only
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python-roborock"
3
- version = "2.32.0"
3
+ version = "2.34.0"
4
4
  description = "A package to control Roborock vacuums."
5
5
  authors = ["humbertogontijo <humbertogontijo@users.noreply.github.com>"]
6
6
  license = "GPL-3.0-only"
@@ -39,7 +39,7 @@ requires = ["poetry-core==1.8.0"]
39
39
  build-backend = "poetry.core.masonry.api"
40
40
 
41
41
  [tool.poetry.group.dev.dependencies]
42
- pytest-asyncio = "*"
42
+ pytest-asyncio = ">=1.1.0"
43
43
  pytest = "*"
44
44
  pre-commit = ">=3.5,<5.0"
45
45
  mypy = "*"
@@ -12,6 +12,7 @@ from pyshark.packet.packet import Packet # type: ignore
12
12
 
13
13
  from roborock import RoborockException
14
14
  from roborock.containers import DeviceData, HomeData, HomeDataProduct, LoginData, NetworkInfo, RoborockBase, UserData
15
+ from roborock.devices.cache import Cache, CacheData
15
16
  from roborock.devices.device_manager import create_device_manager, create_home_data_api
16
17
  from roborock.protocol import MessageParser
17
18
  from roborock.util import run_sync
@@ -39,7 +40,7 @@ class ConnectionCache(RoborockBase):
39
40
  network_info: dict[str, NetworkInfo] | None = None
40
41
 
41
42
 
42
- class RoborockContext:
43
+ class RoborockContext(Cache):
43
44
  roborock_file = Path("~/.roborock").expanduser()
44
45
  _cache_data: ConnectionCache | None = None
45
46
 
@@ -68,6 +69,18 @@ class RoborockContext:
68
69
  self.validate()
69
70
  return self._cache_data
70
71
 
72
+ async def get(self) -> CacheData:
73
+ """Get cached value."""
74
+ connection_cache = self.cache_data()
75
+ return CacheData(home_data=connection_cache.home_data, network_info=connection_cache.network_info or {})
76
+
77
+ async def set(self, value: CacheData) -> None:
78
+ """Set value in the cache."""
79
+ connection_cache = self.cache_data()
80
+ connection_cache.home_data = value.home_data
81
+ connection_cache.network_info = value.network_info
82
+ self.update(connection_cache)
83
+
71
84
 
72
85
  @click.option("-d", "--debug", default=False, count=True)
73
86
  @click.version_option(package_name="python-roborock")
@@ -119,14 +132,8 @@ async def session(ctx, duration: int):
119
132
 
120
133
  home_data_api = create_home_data_api(cache_data.email, cache_data.user_data)
121
134
 
122
- async def home_data_cache() -> HomeData:
123
- if cache_data.home_data is None:
124
- cache_data.home_data = await home_data_api()
125
- context.update(cache_data)
126
- return cache_data.home_data
127
-
128
135
  # Create device manager
129
- device_manager = await create_device_manager(cache_data.user_data, home_data_cache)
136
+ device_manager = await create_device_manager(cache_data.user_data, home_data_api, context)
130
137
 
131
138
  devices = await device_manager.get_devices()
132
139
  click.echo(f"Discovered devices: {', '.join([device.name for device in devices])}")
@@ -156,7 +163,7 @@ async def _discover(ctx):
156
163
  if not cache_data:
157
164
  raise Exception("You need to login first")
158
165
  client = RoborockApiClient(cache_data.email)
159
- home_data = await client.get_home_data(cache_data.user_data)
166
+ home_data = await client.get_home_data_v3(cache_data.user_data)
160
167
  cache_data.home_data = home_data
161
168
  context.update(cache_data)
162
169
  click.echo(f"Discovered devices {', '.join([device.name for device in home_data.get_all_devices()])}")
@@ -3,4 +3,5 @@
3
3
  __all__ = [
4
4
  "device",
5
5
  "device_manager",
6
+ "cache",
6
7
  ]
@@ -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]
@@ -0,0 +1,57 @@
1
+ """This module provides caching functionality for the Roborock device management system.
2
+
3
+ This module defines a cache interface that you may use to cache device
4
+ information to avoid unnecessary API calls. Callers may implement
5
+ this interface to provide their own caching mechanism.
6
+ """
7
+
8
+ from dataclasses import dataclass, field
9
+ from typing import Protocol
10
+
11
+ from roborock.containers import HomeData, NetworkInfo
12
+
13
+
14
+ @dataclass
15
+ class CacheData:
16
+ """Data structure for caching device information."""
17
+
18
+ home_data: HomeData | None = None
19
+ """Home data containing device and product information."""
20
+
21
+ network_info: dict[str, NetworkInfo] = field(default_factory=dict)
22
+ """Network information indexed by device DUID."""
23
+
24
+
25
+ class Cache(Protocol):
26
+ """Protocol for a cache that can store and retrieve values."""
27
+
28
+ async def get(self) -> CacheData:
29
+ """Get cached value."""
30
+ ...
31
+
32
+ async def set(self, value: CacheData) -> None:
33
+ """Set value in the cache."""
34
+ ...
35
+
36
+
37
+ class InMemoryCache(Cache):
38
+ """In-memory cache implementation."""
39
+
40
+ def __init__(self):
41
+ self._data = CacheData()
42
+
43
+ async def get(self) -> CacheData:
44
+ return self._data
45
+
46
+ async def set(self, value: CacheData) -> None:
47
+ self._data = value
48
+
49
+
50
+ class NoCache(Cache):
51
+ """No-op cache implementation."""
52
+
53
+ async def get(self) -> CacheData:
54
+ return CacheData()
55
+
56
+ async def set(self, value: CacheData) -> None:
57
+ pass
@@ -18,8 +18,10 @@ from roborock.mqtt.session import MqttSession
18
18
  from roborock.protocol import create_mqtt_params
19
19
  from roborock.web_api import RoborockApiClient
20
20
 
21
+ from .cache import Cache, NoCache
21
22
  from .channel import Channel
22
23
  from .mqtt_channel import create_mqtt_channel
24
+ from .traits.b01.props import B01PropsApi
23
25
  from .traits.dyad import DyadApi
24
26
  from .traits.status import StatusTrait
25
27
  from .traits.trait import Trait
@@ -32,8 +34,6 @@ __all__ = [
32
34
  "create_device_manager",
33
35
  "create_home_data_api",
34
36
  "DeviceManager",
35
- "HomeDataApi",
36
- "DeviceCreator",
37
37
  ]
38
38
 
39
39
 
@@ -46,6 +46,7 @@ class DeviceVersion(enum.StrEnum):
46
46
 
47
47
  V1 = "1.0"
48
48
  A01 = "A01"
49
+ B01 = "B01"
49
50
  UNKNOWN = "unknown"
50
51
 
51
52
 
@@ -57,19 +58,27 @@ class DeviceManager:
57
58
  home_data_api: HomeDataApi,
58
59
  device_creator: DeviceCreator,
59
60
  mqtt_session: MqttSession,
61
+ cache: Cache,
60
62
  ) -> None:
61
63
  """Initialize the DeviceManager with user data and optional cache storage.
62
64
 
63
65
  This takes ownership of the MQTT session and will close it when the manager is closed.
64
66
  """
65
67
  self._home_data_api = home_data_api
68
+ self._cache = cache
66
69
  self._device_creator = device_creator
67
70
  self._devices: dict[str, RoborockDevice] = {}
68
71
  self._mqtt_session = mqtt_session
69
72
 
70
73
  async def discover_devices(self) -> list[RoborockDevice]:
71
74
  """Discover all devices for the logged-in user."""
72
- home_data = await self._home_data_api()
75
+ cache_data = await self._cache.get()
76
+ if not cache_data.home_data:
77
+ _LOGGER.debug("No cached home data found, fetching from API")
78
+ cache_data.home_data = await self._home_data_api()
79
+ await self._cache.set(cache_data)
80
+ home_data = cache_data.home_data
81
+
73
82
  device_products = home_data.device_products
74
83
  _LOGGER.debug("Discovered %d devices %s", len(device_products), home_data)
75
84
 
@@ -113,18 +122,24 @@ def create_home_data_api(email: str, user_data: UserData) -> HomeDataApi:
113
122
  client = RoborockApiClient(email)
114
123
 
115
124
  async def home_data_api() -> HomeData:
116
- return await client.get_home_data(user_data)
125
+ return await client.get_home_data_v3(user_data)
117
126
 
118
127
  return home_data_api
119
128
 
120
129
 
121
- async def create_device_manager(user_data: UserData, home_data_api: HomeDataApi) -> DeviceManager:
130
+ async def create_device_manager(
131
+ user_data: UserData,
132
+ home_data_api: HomeDataApi,
133
+ cache: Cache | None = None,
134
+ ) -> DeviceManager:
122
135
  """Convenience function to create and initialize a DeviceManager.
123
136
 
124
137
  The Home Data is fetched using the provided home_data_api callable which
125
138
  is exposed this way to allow for swapping out other implementations to
126
139
  include caching or other optimizations.
127
140
  """
141
+ if cache is None:
142
+ cache = NoCache()
128
143
 
129
144
  mqtt_params = create_mqtt_params(user_data.rriot)
130
145
  mqtt_session = await create_mqtt_session(mqtt_params)
@@ -135,7 +150,7 @@ async def create_device_manager(user_data: UserData, home_data_api: HomeDataApi)
135
150
  # TODO: Define a registration mechanism/factory for v1 traits
136
151
  match device.pv:
137
152
  case DeviceVersion.V1:
138
- channel = create_v1_channel(user_data, mqtt_params, mqtt_session, device)
153
+ channel = create_v1_channel(user_data, mqtt_params, mqtt_session, device, cache)
139
154
  traits.append(StatusTrait(product, channel.rpc_channel))
140
155
  case DeviceVersion.A01:
141
156
  mqtt_channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
@@ -146,10 +161,13 @@ async def create_device_manager(user_data: UserData, home_data_api: HomeDataApi)
146
161
  traits.append(ZeoApi(mqtt_channel))
147
162
  case _:
148
163
  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))
149
167
  case _:
150
168
  raise NotImplementedError(f"Device {device.name} has unsupported version {device.pv}")
151
169
  return RoborockDevice(device, channel, traits)
152
170
 
153
- manager = DeviceManager(home_data_api, device_creator, mqtt_session=mqtt_session)
171
+ manager = DeviceManager(home_data_api, device_creator, mqtt_session=mqtt_session, cache=cache)
154
172
  await manager.discover_devices()
155
173
  return manager
@@ -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)
@@ -18,6 +18,7 @@ from roborock.protocols.v1_protocol import (
18
18
  from roborock.roborock_message import RoborockMessage
19
19
  from roborock.roborock_typing import RoborockCommand
20
20
 
21
+ from .cache import Cache
21
22
  from .channel import Channel
22
23
  from .local_channel import LocalChannel, LocalSession, create_local_session
23
24
  from .mqtt_channel import MqttChannel
@@ -46,6 +47,7 @@ class V1Channel(Channel):
46
47
  security_data: SecurityData,
47
48
  mqtt_channel: MqttChannel,
48
49
  local_session: LocalSession,
50
+ cache: Cache,
49
51
  ) -> None:
50
52
  """Initialize the V1Channel.
51
53
 
@@ -62,7 +64,7 @@ class V1Channel(Channel):
62
64
  self._mqtt_unsub: Callable[[], None] | None = None
63
65
  self._local_unsub: Callable[[], None] | None = None
64
66
  self._callback: Callable[[RoborockMessage], None] | None = None
65
- self._networking_info: NetworkInfo | None = None
67
+ self._cache = cache
66
68
 
67
69
  @property
68
70
  def is_connected(self) -> bool:
@@ -131,19 +133,26 @@ class V1Channel(Channel):
131
133
 
132
134
  This is a cloud only command used to get the local device's IP address.
133
135
  """
136
+ cache_data = await self._cache.get()
137
+ if cache_data.network_info and (network_info := cache_data.network_info.get(self._device_uid)):
138
+ _LOGGER.debug("Using cached network info for device %s", self._device_uid)
139
+ return network_info
134
140
  try:
135
- return await self._mqtt_rpc_channel.send_command(
141
+ network_info = await self._mqtt_rpc_channel.send_command(
136
142
  RoborockCommand.GET_NETWORK_INFO, response_type=NetworkInfo
137
143
  )
138
144
  except RoborockException as e:
139
145
  raise RoborockException(f"Network info failed for device {self._device_uid}") from e
146
+ _LOGGER.debug("Network info for device %s: %s", self._device_uid, network_info)
147
+ cache_data.network_info[self._device_uid] = network_info
148
+ await self._cache.set(cache_data)
149
+ return network_info
140
150
 
141
151
  async def _local_connect(self) -> Callable[[], None]:
142
152
  """Set up local connection if possible."""
143
153
  _LOGGER.debug("Attempting to connect to local channel for device %s", self._device_uid)
144
- if self._networking_info is None:
145
- self._networking_info = await self._get_networking_info()
146
- host = self._networking_info.ip
154
+ networking_info = await self._get_networking_info()
155
+ host = networking_info.ip
147
156
  _LOGGER.debug("Connecting to local channel at %s", host)
148
157
  self._local_channel = self._local_session(host)
149
158
  try:
@@ -168,10 +177,14 @@ class V1Channel(Channel):
168
177
 
169
178
 
170
179
  def create_v1_channel(
171
- user_data: UserData, mqtt_params: MqttParams, mqtt_session: MqttSession, device: HomeDataDevice
180
+ user_data: UserData,
181
+ mqtt_params: MqttParams,
182
+ mqtt_session: MqttSession,
183
+ device: HomeDataDevice,
184
+ cache: Cache,
172
185
  ) -> V1Channel:
173
186
  """Create a V1Channel for the given device."""
174
187
  security_data = create_security_data(user_data.rriot)
175
188
  mqtt_channel = MqttChannel(mqtt_session, device.duid, device.local_key, user_data.rriot, mqtt_params)
176
189
  local_session = create_local_session(device.local_key)
177
- return V1Channel(device.duid, security_data, mqtt_channel, local_session=local_session)
190
+ return V1Channel(device.duid, security_data, mqtt_channel, local_session=local_session, cache=cache)
@@ -49,6 +49,7 @@ class RoborockMqttSession(MqttSession):
49
49
  self._params = params
50
50
  self._background_task: asyncio.Task[None] | None = None
51
51
  self._healthy = False
52
+ self._stop = False
52
53
  self._backoff = MIN_BACKOFF_INTERVAL
53
54
  self._client: aiomqtt.Client | None = None
54
55
  self._client_lock = asyncio.Lock()
@@ -81,6 +82,7 @@ class RoborockMqttSession(MqttSession):
81
82
 
82
83
  async def close(self) -> None:
83
84
  """Cancels the MQTT loop and shutdown the client library."""
85
+ self._stop = True
84
86
  if self._background_task:
85
87
  self._background_task.cancel()
86
88
  try:
@@ -125,16 +127,19 @@ class RoborockMqttSession(MqttSession):
125
127
  except Exception as err:
126
128
  # This error is thrown when the MQTT loop is cancelled
127
129
  # and the generator is not stopped.
128
- if "generator didn't stop" in str(err):
130
+ if "generator didn't stop" in str(err) or "generator didn't yield" in str(err):
129
131
  _LOGGER.debug("MQTT loop was cancelled")
130
132
  return
131
133
  if start_future:
132
134
  _LOGGER.error("Uncaught error starting MQTT session: %s", err)
133
135
  start_future.set_exception(err)
134
136
  return
135
- _LOGGER.error("Uncaught error during MQTT session: %s", err)
137
+ _LOGGER.exception("Uncaught error during MQTT session: %s", err)
136
138
 
137
139
  self._healthy = False
140
+ if self._stop:
141
+ _LOGGER.debug("MQTT session closed, stopping retry loop")
142
+ return
138
143
  _LOGGER.info("MQTT session disconnected, retrying in %s seconds", self._backoff.total_seconds())
139
144
  await asyncio.sleep(self._backoff.total_seconds())
140
145
  self._backoff = min(self._backoff * BACKOFF_MULTIPLIER, MAX_BACKOFF_INTERVAL)
@@ -180,7 +185,7 @@ class RoborockMqttSession(MqttSession):
180
185
  except asyncio.CancelledError:
181
186
  raise
182
187
  except Exception as e:
183
- _LOGGER.error("Uncaught exception in subscriber callback: %s", e)
188
+ _LOGGER.exception("Uncaught exception in subscriber callback: %s", e)
184
189
 
185
190
  async def subscribe(self, topic: str, callback: Callable[[bytes], None]) -> Callable[[], None]:
186
191
  """Subscribe to messages on the specified topic and invoke the callback for new messages.
@@ -39,6 +39,7 @@ from roborock.roborock_message import RoborockMessage
39
39
  _LOGGER = logging.getLogger(__name__)
40
40
  SALT = b"TXdfu$jyZ#TZHsg4"
41
41
  A01_HASH = "726f626f726f636b2d67a6d6da"
42
+ B01_HASH = "5wwh9ikChRjASpMU8cxg7o1d2E"
42
43
  BROADCAST_TOKEN = b"qWKYcdQWrbm9hPqe"
43
44
  AP_CONFIG = 1
44
45
  SOCK_DISCOVERY = 2
@@ -213,6 +214,10 @@ class EncryptionAdapter(Construct):
213
214
  decipher = AES.new(bytes(context.search("local_key"), "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8"))
214
215
  f = decipher.encrypt(obj)
215
216
  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)
216
221
  token = self.token_func(context)
217
222
  encrypted = Utils.encrypt_ecb(obj, token)
218
223
  return encrypted
@@ -224,6 +229,10 @@ class EncryptionAdapter(Construct):
224
229
  decipher = AES.new(bytes(context.search("local_key"), "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8"))
225
230
  f = decipher.decrypt(obj)
226
231
  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)
227
236
  token = self.token_func(context)
228
237
  decrypted = Utils.decrypt_ecb(obj, token)
229
238
  return decrypted
@@ -248,7 +257,7 @@ class PrefixedStruct(Struct):
248
257
  def _parse(self, stream, context, path):
249
258
  subcon1 = Peek(Optional(Bytes(3)))
250
259
  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"):
252
261
  subcon2 = Bytes(4)
253
262
  subcon2.parse_stream(stream, **context)
254
263
  return super()._parse(stream, context, path)
@@ -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}")
File without changes
@@ -4,6 +4,7 @@ import json
4
4
  import math
5
5
  import time
6
6
  from dataclasses import dataclass, field
7
+ from enum import StrEnum
7
8
 
8
9
  from roborock import RoborockEnum
9
10
  from roborock.util import get_next_int
@@ -130,6 +131,98 @@ class RoborockZeoProtocol(RoborockEnum):
130
131
  RPC_RESp = 10102
131
132
 
132
133
 
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
+
133
226
  ROBOROCK_DATA_STATUS_PROTOCOL = [
134
227
  RoborockDataProtocol.ERROR_CODE,
135
228
  RoborockDataProtocol.STATE,
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass, field
4
- from enum import Enum
4
+ from enum import Enum, StrEnum
5
5
 
6
6
  from .containers import (
7
7
  CleanRecord,
@@ -271,6 +271,20 @@ class RoborockCommand(str, Enum):
271
271
  APP_GET_ROBOT_SETTING = "app_get_robot_setting"
272
272
 
273
273
 
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
+
274
288
  @dataclass
275
289
  class DockSummary(RoborockBase):
276
290
  dust_collection_mode: DustCollectionMode | None = None