python-roborock 5.9.1__tar.gz → 5.10.1__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 (103) hide show
  1. {python_roborock-5.9.1 → python_roborock-5.10.1}/PKG-INFO +1 -1
  2. {python_roborock-5.9.1 → python_roborock-5.10.1}/pyproject.toml +1 -1
  3. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/callbacks.py +1 -1
  4. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/cli.py +1 -1
  5. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/data/v1/v1_containers.py +7 -5
  6. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/device.py +3 -1
  7. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/device_manager.py +18 -3
  8. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/rpc/v1_channel.py +31 -6
  9. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/v1/__init__.py +32 -1
  10. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/v1/consumeable.py +23 -2
  11. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/v1/status.py +19 -1
  12. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/protocols/v1_protocol.py +54 -10
  13. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/web_api.py +29 -5
  14. {python_roborock-5.9.1 → python_roborock-5.10.1}/.gitignore +0 -0
  15. {python_roborock-5.9.1 → python_roborock-5.10.1}/LICENSE +0 -0
  16. {python_roborock-5.9.1 → python_roborock-5.10.1}/README.md +0 -0
  17. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/__init__.py +0 -0
  18. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/broadcast_protocol.py +0 -0
  19. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/const.py +0 -0
  20. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/data/__init__.py +0 -0
  21. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/data/b01_q10/__init__.py +0 -0
  22. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
  23. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
  24. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/data/b01_q7/__init__.py +0 -0
  25. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
  26. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
  27. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/data/code_mappings.py +0 -0
  28. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/data/containers.py +0 -0
  29. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/data/dyad/__init__.py +0 -0
  30. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/data/dyad/dyad_code_mappings.py +0 -0
  31. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/data/dyad/dyad_containers.py +0 -0
  32. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/data/v1/__init__.py +0 -0
  33. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/data/v1/v1_clean_modes.py +0 -0
  34. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/data/v1/v1_code_mappings.py +0 -0
  35. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/data/zeo/__init__.py +0 -0
  36. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/data/zeo/zeo_code_mappings.py +0 -0
  37. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/data/zeo/zeo_containers.py +0 -0
  38. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/device_features.py +0 -0
  39. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/README.md +0 -0
  40. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/__init__.py +0 -0
  41. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/cache.py +0 -0
  42. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/file_cache.py +0 -0
  43. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/rpc/__init__.py +0 -0
  44. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/rpc/a01_channel.py +0 -0
  45. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/rpc/b01_q10_channel.py +0 -0
  46. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/rpc/b01_q7_channel.py +0 -0
  47. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/__init__.py +0 -0
  48. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/a01/__init__.py +0 -0
  49. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/b01/__init__.py +0 -0
  50. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/b01/q10/__init__.py +0 -0
  51. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/b01/q10/command.py +0 -0
  52. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/b01/q10/status.py +0 -0
  53. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/b01/q10/vacuum.py +0 -0
  54. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/b01/q7/__init__.py +0 -0
  55. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/b01/q7/clean_summary.py +0 -0
  56. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/b01/q7/map.py +0 -0
  57. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/b01/q7/map_content.py +0 -0
  58. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/common.py +0 -0
  59. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/traits_mixin.py +0 -0
  60. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/v1/child_lock.py +0 -0
  61. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/v1/clean_summary.py +0 -0
  62. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/v1/command.py +0 -0
  63. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/v1/common.py +0 -0
  64. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/v1/device_features.py +0 -0
  65. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
  66. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
  67. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/v1/flow_led_status.py +0 -0
  68. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/v1/home.py +0 -0
  69. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/v1/led_status.py +0 -0
  70. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/v1/map_content.py +0 -0
  71. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/v1/maps.py +0 -0
  72. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/v1/network_info.py +0 -0
  73. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/v1/rooms.py +0 -0
  74. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/v1/routines.py +0 -0
  75. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
  76. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
  77. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/v1/volume.py +0 -0
  78. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
  79. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/transport/__init__.py +0 -0
  80. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/transport/channel.py +0 -0
  81. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/transport/local_channel.py +0 -0
  82. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/devices/transport/mqtt_channel.py +0 -0
  83. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/diagnostics.py +0 -0
  84. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/exceptions.py +0 -0
  85. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/map/__init__.py +0 -0
  86. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/map/b01_map_parser.py +0 -0
  87. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/map/map_parser.py +0 -0
  88. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/map/proto/__init__.py +0 -0
  89. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/map/proto/b01_scmap.proto +0 -0
  90. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/map/proto/b01_scmap_pb2.py +0 -0
  91. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/mqtt/__init__.py +0 -0
  92. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/mqtt/health_manager.py +0 -0
  93. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/mqtt/roborock_session.py +0 -0
  94. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/mqtt/session.py +0 -0
  95. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/protocol.py +0 -0
  96. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/protocols/__init__.py +0 -0
  97. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/protocols/a01_protocol.py +0 -0
  98. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/protocols/b01_q10_protocol.py +0 -0
  99. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/protocols/b01_q7_protocol.py +0 -0
  100. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/py.typed +0 -0
  101. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/roborock_message.py +0 -0
  102. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/roborock_typing.py +0 -0
  103. {python_roborock-5.9.1 → python_roborock-5.10.1}/roborock/util.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-roborock
3
- Version: 5.9.1
3
+ Version: 5.10.1
4
4
  Summary: A package to control Roborock vacuums.
5
5
  Project-URL: Repository, https://github.com/python-roborock/python-roborock
6
6
  Project-URL: Documentation, https://python-roborock.readthedocs.io/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-roborock"
3
- version = "5.9.1"
3
+ version = "5.10.1"
4
4
  description = "A package to control Roborock vacuums."
5
5
  authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}]
6
6
  requires-python = ">=3.11, <4"
@@ -26,7 +26,7 @@ def safe_callback(
26
26
  try:
27
27
  callback(value)
28
28
  except Exception as ex: # noqa: BLE001
29
- logger.error("Uncaught error in callback '%s': %s", callback.__name__, ex)
29
+ logger.error("Uncaught error in callback '%s': %s", getattr(callback, "__name__", "Unknown"), ex)
30
30
 
31
31
  return wrapper
32
32
 
@@ -419,7 +419,7 @@ async def _v1_trait(context: RoborockContext, device_id: str, display_func: Call
419
419
  device = await device_manager.get_device(device_id)
420
420
  if device.v1_properties is None:
421
421
  raise RoborockUnsupportedFeature(f"Device {device.name} does not support V1 protocol")
422
- await device.v1_properties.discover_features()
422
+ await device.v1_properties.start()
423
423
  trait = display_func(device.v1_properties)
424
424
  if trait is None:
425
425
  raise RoborockUnsupportedFeature("Trait not supported by device")
@@ -100,13 +100,14 @@ class FieldNameBase(StrEnum):
100
100
 
101
101
 
102
102
  class StatusField(FieldNameBase):
103
- """An enum that represents a field in the `Status` class.
103
+ """An enum that represents a field in the `StatusV2` class.
104
104
 
105
105
  This is used with `roborock.devices.traits.v1.status.DeviceFeaturesTrait`
106
106
  to understand if a feature is supported by the device using `is_field_supported`.
107
107
 
108
- The enum values are names of fields in the `Status` class. Each field is annotated
109
- with a metadata value to determine if the field is supported by the device.
108
+ The enum values are names of fields in the `StatusV2` class. Each field is
109
+ annotated with `dps` metadata to map the field to a `RoborockDataProtocol`
110
+ value used to check support against the product schema.
110
111
  """
111
112
 
112
113
  STATE = "state"
@@ -670,8 +671,9 @@ class ConsumableField(FieldNameBase):
670
671
  This is used with `roborock.devices.traits.v1.status.DeviceFeaturesTrait`
671
672
  to understand if a feature is supported by the device using `is_field_supported`.
672
673
 
673
- The enum values are names of fields in the `Consumable` class. Each field is annotated
674
- with a metadata value to determine if the field is supported by the device.
674
+ The enum values are names of fields in the `Consumable` class. Each field is
675
+ annotated with `dps` metadata to map the field to a `RoborockDataProtocol`
676
+ value used to check support against the product schema.
675
677
  """
676
678
 
677
679
  MAIN_BRUSH_WORK_TIME = "main_brush_work_time"
@@ -199,7 +199,7 @@ class RoborockDevice(ABC, TraitsMixin):
199
199
  unsub = await self._channel.subscribe(self._on_message)
200
200
  try:
201
201
  if self.v1_properties is not None:
202
- await self.v1_properties.discover_features()
202
+ await self.v1_properties.start()
203
203
  elif self.b01_q10_properties is not None:
204
204
  await self.b01_q10_properties.start()
205
205
  except RoborockException:
@@ -216,6 +216,8 @@ class RoborockDevice(ABC, TraitsMixin):
216
216
  await self._connect_task
217
217
  except asyncio.CancelledError:
218
218
  pass
219
+ if self.v1_properties is not None:
220
+ self.v1_properties.close()
219
221
  if self.b01_q10_properties is not None:
220
222
  await self.b01_q10_properties.close()
221
223
  if self._unsub:
@@ -13,6 +13,7 @@ from roborock.data import (
13
13
  HomeData,
14
14
  HomeDataDevice,
15
15
  HomeDataProduct,
16
+ RoborockCategory,
16
17
  UserData,
17
18
  )
18
19
  from roborock.devices.device import DeviceReadyCallback, RoborockDevice
@@ -173,6 +174,7 @@ def create_web_api_wrapper(
173
174
  *,
174
175
  cache: Cache | None = None,
175
176
  session: aiohttp.ClientSession | None = None,
177
+ unauthorized_hook: SessionUnauthorizedHook | None = None,
176
178
  ) -> UserWebApiClient:
177
179
  """Create a home data API wrapper from an existing API client."""
178
180
 
@@ -180,7 +182,7 @@ def create_web_api_wrapper(
180
182
  # by caching this next to `UserData` if needed to avoid unnecessary API calls.
181
183
  client = RoborockApiClient(username=user_params.username, base_url=user_params.base_url, session=session)
182
184
 
183
- return UserWebApiClient(client, user_params.user_data)
185
+ return UserWebApiClient(client, user_params.user_data, unauthorized_hook=unauthorized_hook)
184
186
 
185
187
 
186
188
  async def create_device_manager(
@@ -212,7 +214,9 @@ async def create_device_manager(
212
214
  if cache is None:
213
215
  cache = NoCache()
214
216
 
215
- web_api = create_web_api_wrapper(user_params, session=session, cache=cache)
217
+ web_api = create_web_api_wrapper(
218
+ user_params, session=session, cache=cache, unauthorized_hook=mqtt_session_unauthorized_hook
219
+ )
216
220
  user_data = user_params.user_data
217
221
 
218
222
  diagnostics = Diagnostics()
@@ -228,6 +232,10 @@ async def create_device_manager(
228
232
  device_cache: DeviceCache = DeviceCache(device.duid, cache)
229
233
  match device.pv:
230
234
  case DeviceVersion.V1:
235
+ if product.category != RoborockCategory.VACUUM:
236
+ raise UnsupportedDeviceError(
237
+ f"Device {device.name} has unsupported V1 category {product.category}: {product.model}"
238
+ )
231
239
  channel = create_v1_channel(user_data, mqtt_params, mqtt_session, device, device_cache)
232
240
  trait = v1.create(
233
241
  device.duid,
@@ -236,6 +244,7 @@ async def create_device_manager(
236
244
  channel.rpc_channel,
237
245
  channel.mqtt_rpc_channel,
238
246
  channel.map_rpc_channel,
247
+ channel.add_dps_listener,
239
248
  web_api,
240
249
  device_cache=device_cache,
241
250
  map_parser_config=map_parser_config,
@@ -264,6 +273,12 @@ async def create_device_manager(
264
273
  dev.add_ready_callback(ready_callback)
265
274
  return dev
266
275
 
267
- manager = DeviceManager(web_api, device_creator, mqtt_session=mqtt_session, cache=cache, diagnostics=diagnostics)
276
+ manager = DeviceManager(
277
+ web_api,
278
+ device_creator,
279
+ mqtt_session=mqtt_session,
280
+ cache=cache,
281
+ diagnostics=diagnostics,
282
+ )
268
283
  await manager.discover_devices(prefer_cache)
269
284
  return manager
@@ -11,6 +11,7 @@ from collections.abc import Callable
11
11
  from dataclasses import dataclass
12
12
  from typing import Any, TypeVar
13
13
 
14
+ from roborock.callbacks import CallbackList
14
15
  from roborock.data import HomeDataDevice, NetworkInfo, RoborockBase, UserData
15
16
  from roborock.devices.cache import DeviceCache
16
17
  from roborock.devices.transport.channel import Channel
@@ -30,9 +31,10 @@ from roborock.protocols.v1_protocol import (
30
31
  V1RpcChannel,
31
32
  create_map_response_decoder,
32
33
  create_security_data,
34
+ decode_data_protocol_message,
33
35
  decode_rpc_response,
34
36
  )
35
- from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol
37
+ from roborock.roborock_message import RoborockDataProtocol, RoborockMessage, RoborockMessageProtocol
36
38
  from roborock.roborock_typing import RoborockCommand
37
39
  from roborock.util import RoborockLoggerAdapter
38
40
 
@@ -188,6 +190,7 @@ class V1Channel(Channel):
188
190
  self._device_cache = device_cache
189
191
  self._reconnect_task: asyncio.Task[None] | None = None
190
192
  self._last_network_info_refresh: datetime.datetime | None = None
193
+ self._dps_listeners = CallbackList[dict[RoborockDataProtocol, Any]](self._logger)
191
194
 
192
195
  @property
193
196
  def is_connected(self) -> bool:
@@ -305,12 +308,16 @@ class V1Channel(Channel):
305
308
  loop = asyncio.get_running_loop()
306
309
  self._reconnect_task = loop.create_task(self._background_reconnect())
307
310
 
308
- if not self.is_local_connected:
309
- # We were not able to connect locally, so fallback to MQTT and at least
310
- # establish that connection explicitly. If this fails then raise an
311
- # error and let the caller know we failed to subscribe.
311
+ # We maintain an active MQTT subscription even when connected locally to receive
312
+ # unsolicited status updates (DPS push messages) directly from the cloud.
313
+ try:
312
314
  self._mqtt_unsub = await self._mqtt_channel.subscribe(self._on_mqtt_message)
313
- self._logger.debug("V1Channel connected to device via MQTT")
315
+ except RoborockException as err:
316
+ if not self.is_local_connected:
317
+ # Propagate error if both local and MQTT failed
318
+ self._logger.debug("MQTT connection also failed: %s", err)
319
+ raise
320
+ self._logger.debug("MQTT subscription failed, continuing with local-only connection: %s", err)
314
321
 
315
322
  def unsub() -> None:
316
323
  """Unsubscribe from all messages."""
@@ -328,6 +335,16 @@ class V1Channel(Channel):
328
335
  self._callback = callback
329
336
  return unsub
330
337
 
338
+ def add_dps_listener(self, listener: Callable[[dict[RoborockDataProtocol, Any]], None]) -> Callable[[], None]:
339
+ """Add a listener for DPS updates.
340
+
341
+ This will attach a listener to the existing subscription, invoking
342
+ the listener whenever new DPS values arrive from the subscription.
343
+ This will only work if a subscription has already been setup, which is
344
+ handled by the device start.
345
+ """
346
+ return self._dps_listeners.add_callback(listener)
347
+
331
348
  async def _get_networking_info(self, *, prefer_cache: bool = True) -> NetworkInfo:
332
349
  """Retrieve networking information for the device.
333
350
 
@@ -428,6 +445,14 @@ class V1Channel(Channel):
428
445
  self._logger.debug("V1Channel received MQTT message: %s", message)
429
446
  if self._callback:
430
447
  self._callback(message)
448
+ try:
449
+ datapoints = decode_data_protocol_message(message)
450
+ except RoborockException as e:
451
+ self._logger.debug("Error decoding data protocol message: %s", e)
452
+ return
453
+
454
+ if datapoints:
455
+ self._dps_listeners(datapoints)
431
456
 
432
457
  def _on_local_message(self, message: RoborockMessage) -> None:
433
458
  """Handle incoming local messages."""
@@ -53,6 +53,7 @@ HomeDataProduct Schema that is required for the field to be supported.
53
53
  """
54
54
 
55
55
  import logging
56
+ from collections.abc import Callable
56
57
  from dataclasses import dataclass, field, fields
57
58
  from typing import Any, get_args
58
59
 
@@ -60,8 +61,10 @@ from roborock.data.containers import HomeData, HomeDataProduct, RoborockBase
60
61
  from roborock.data.v1.v1_code_mappings import RoborockDockTypeCode
61
62
  from roborock.devices.cache import DeviceCache
62
63
  from roborock.devices.traits import Trait
64
+ from roborock.exceptions import RoborockException
63
65
  from roborock.map.map_parser import MapParserConfig
64
- from roborock.protocols.v1_protocol import V1RpcChannel
66
+ from roborock.protocols.v1_protocol import V1RpcChannel, decode_data_protocol_message
67
+ from roborock.roborock_message import RoborockDataProtocol, RoborockMessage
65
68
  from roborock.web_api import UserWebApiClient
66
69
 
67
70
  from . import (
@@ -176,6 +179,7 @@ class PropertiesApi(Trait):
176
179
  rpc_channel: V1RpcChannel,
177
180
  mqtt_rpc_channel: V1RpcChannel,
178
181
  map_rpc_channel: V1RpcChannel,
182
+ add_dps_listener: Callable[[Callable[[dict[RoborockDataProtocol, Any]], None]], Callable[[], None]],
179
183
  web_api: UserWebApiClient,
180
184
  device_cache: DeviceCache,
181
185
  map_parser_config: MapParserConfig | None = None,
@@ -189,6 +193,8 @@ class PropertiesApi(Trait):
189
193
  self._web_api = web_api
190
194
  self._device_cache = device_cache
191
195
  self._region = region
196
+ self._unsub: Callable[[], None] | None = None
197
+ self._add_dps_listener = add_dps_listener
192
198
 
193
199
  self.device_features = DeviceFeaturesTrait(product, self._device_cache)
194
200
  self.status = StatusTrait(self.device_features, region=self._region)
@@ -227,6 +233,29 @@ class PropertiesApi(Trait):
227
233
  else:
228
234
  return self._rpc_channel
229
235
 
236
+ async def start(self) -> None:
237
+ """Start the properties API and discover features."""
238
+ if self._unsub:
239
+ return
240
+ await self.discover_features()
241
+ self._unsub = self._add_dps_listener(self._on_dps_update)
242
+
243
+ def close(self) -> None:
244
+ if self._unsub:
245
+ self._unsub()
246
+ self._unsub = None
247
+
248
+ def _on_dps_update(self, dps: dict[RoborockDataProtocol, Any]) -> None:
249
+ """Handle incoming messages from the device.
250
+
251
+ This will notify all traits of the new values. This can be improved in
252
+ the future to be dynamic when we have more traits that support dynamic
253
+ updates but for now we just invoke them manually.
254
+ """
255
+ _LOGGER.debug("Received message from device: %s", dps)
256
+ self.status.update_from_dps(dps)
257
+ self.consumables.update_from_dps(dps)
258
+
230
259
  async def discover_features(self) -> None:
231
260
  """Populate any supported traits that were not initialized in __init__."""
232
261
  _LOGGER.debug("Starting optional trait discovery")
@@ -330,6 +359,7 @@ def create(
330
359
  rpc_channel: V1RpcChannel,
331
360
  mqtt_rpc_channel: V1RpcChannel,
332
361
  map_rpc_channel: V1RpcChannel,
362
+ add_dps_listener: Callable[[Callable[[dict[RoborockDataProtocol, Any]], None]], Callable[[], None]],
333
363
  web_api: UserWebApiClient,
334
364
  device_cache: DeviceCache,
335
365
  map_parser_config: MapParserConfig | None = None,
@@ -343,6 +373,7 @@ def create(
343
373
  rpc_channel,
344
374
  mqtt_rpc_channel,
345
375
  map_rpc_channel,
376
+ add_dps_listener,
346
377
  web_api,
347
378
  device_cache,
348
379
  map_parser_config,
@@ -4,17 +4,24 @@ A consumable attribute is one that is expected to be replaced or refilled
4
4
  periodically, such as filters, brushes, etc.
5
5
  """
6
6
 
7
+ import logging
7
8
  from enum import StrEnum
8
- from typing import Self
9
+ from typing import Any, Self
9
10
 
10
11
  from roborock.data import Consumable
12
+ from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener
11
13
  from roborock.devices.traits.v1 import common
14
+ from roborock.roborock_message import RoborockDataProtocol
12
15
  from roborock.roborock_typing import RoborockCommand
13
16
 
14
17
  __all__ = [
15
18
  "ConsumableTrait",
16
19
  ]
17
20
 
21
+ _LOGGER = logging.getLogger(__name__)
22
+
23
+ _DPS_CONVERTER = DpsDataConverter.from_dataclass(Consumable)
24
+
18
25
 
19
26
  class ConsumableAttribute(StrEnum):
20
27
  """Enum for consumable attributes."""
@@ -35,7 +42,7 @@ class ConsumableAttribute(StrEnum):
35
42
  raise ValueError(f"Unknown ConsumableAttribute: {value}")
36
43
 
37
44
 
38
- class ConsumableTrait(Consumable, common.V1TraitMixin):
45
+ class ConsumableTrait(Consumable, common.V1TraitMixin, TraitUpdateListener):
39
46
  """Trait for managing consumable attributes on Roborock devices.
40
47
 
41
48
  After the first refresh, you can tell what consumables are supported by
@@ -45,7 +52,21 @@ class ConsumableTrait(Consumable, common.V1TraitMixin):
45
52
  command = RoborockCommand.GET_CONSUMABLE
46
53
  converter = common.DefaultConverter(Consumable)
47
54
 
55
+ def __init__(self) -> None:
56
+ """Initialize the consumable trait."""
57
+ super().__init__()
58
+ TraitUpdateListener.__init__(self, logger=_LOGGER)
59
+
48
60
  async def reset_consumable(self, consumable: ConsumableAttribute) -> None:
49
61
  """Reset a specific consumable attribute on the device."""
50
62
  await self.rpc_channel.send_command(RoborockCommand.RESET_CONSUMABLE, params=[consumable.value])
51
63
  await self.refresh()
64
+
65
+ def update_from_dps(self, decoded_dps: dict[RoborockDataProtocol, Any]) -> None:
66
+ """Update the trait from data protocol push message data.
67
+
68
+ This handles unsolicited status updates pushed by the device
69
+ via RoborockDataProtocol codes (e.g. STATE=121, BATTERY=122).
70
+ """
71
+ if _DPS_CONVERTER.update_from_dps(self, decoded_dps):
72
+ self._notify_update()
@@ -1,4 +1,6 @@
1
+ import logging
1
2
  from functools import cached_property
3
+ from typing import Any
2
4
 
3
5
  from roborock import (
4
6
  CleanRoutes,
@@ -10,13 +12,19 @@ from roborock import (
10
12
  get_water_mode_mapping,
11
13
  get_water_modes,
12
14
  )
15
+ from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener
16
+ from roborock.roborock_message import RoborockDataProtocol
13
17
  from roborock.roborock_typing import RoborockCommand
14
18
 
15
19
  from . import common
16
20
  from .device_features import DeviceFeaturesTrait
17
21
 
22
+ _LOGGER = logging.getLogger(__name__)
18
23
 
19
- class StatusTrait(StatusV2, common.V1TraitMixin):
24
+ _DPS_CONVERTER = DpsDataConverter.from_dataclass(StatusV2)
25
+
26
+
27
+ class StatusTrait(StatusV2, common.V1TraitMixin, TraitUpdateListener):
20
28
  """Trait for managing the status of Roborock devices.
21
29
 
22
30
  The StatusTrait gives you the access to the state of a Roborock vacuum.
@@ -47,6 +55,7 @@ class StatusTrait(StatusV2, common.V1TraitMixin):
47
55
  def __init__(self, device_feature_trait: DeviceFeaturesTrait, region: str | None = None) -> None:
48
56
  """Initialize the StatusTrait."""
49
57
  super().__init__()
58
+ TraitUpdateListener.__init__(self, logger=_LOGGER)
50
59
  self._device_features_trait = device_feature_trait
51
60
  self._region = region
52
61
 
@@ -91,3 +100,12 @@ class StatusTrait(StatusV2, common.V1TraitMixin):
91
100
  if self.mop_mode is None:
92
101
  return None
93
102
  return self.mop_route_mapping.get(self.mop_mode)
103
+
104
+ def update_from_dps(self, decoded_dps: dict[RoborockDataProtocol, Any]) -> None:
105
+ """Update the trait from data protocol push message data.
106
+
107
+ This handles unsolicited status updates pushed by the device
108
+ via RoborockDataProtocol codes (e.g. STATE=121, BATTERY=122).
109
+ """
110
+ if _DPS_CONVERTER.update_from_dps(self, decoded_dps):
111
+ self._notify_update()
@@ -15,7 +15,7 @@ from typing import Any, Protocol, TypeVar, overload
15
15
  from roborock.data import RoborockBase, RRiot
16
16
  from roborock.exceptions import RoborockException, RoborockInvalidStatus, RoborockUnsupportedFeature
17
17
  from roborock.protocol import Utils
18
- from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol
18
+ from roborock.roborock_message import RoborockDataProtocol, RoborockMessage, RoborockMessageProtocol
19
19
  from roborock.roborock_typing import RoborockCommand
20
20
  from roborock.util import get_next_int, get_timestamp
21
21
 
@@ -24,6 +24,7 @@ _LOGGER = logging.getLogger(__name__)
24
24
  __all__ = [
25
25
  "SecurityData",
26
26
  "create_security_data",
27
+ "decode_data_protocol_message",
27
28
  "decode_rpc_response",
28
29
  "V1RpcChannel",
29
30
  ]
@@ -139,6 +140,28 @@ class ResponseMessage:
139
140
  """The API error message of the response if any."""
140
141
 
141
142
 
143
+ def _decode_dps_message(message: RoborockMessage) -> dict[int, Any] | None:
144
+ """Decode a V1 push message containing data protocol updates."""
145
+ if not message.payload:
146
+ return None
147
+ try:
148
+ payload = json.loads(message.payload.decode())
149
+ except (json.JSONDecodeError, TypeError, UnicodeDecodeError) as e:
150
+ raise RoborockException(f"Invalid V1 message payload: {e} for {message.payload!r}") from e
151
+
152
+ datapoints = payload.get("dps")
153
+ if not isinstance(datapoints, dict):
154
+ return None
155
+ result: dict[int, Any] = {}
156
+ for key, value in datapoints.items():
157
+ try:
158
+ code = int(key)
159
+ except (ValueError, TypeError):
160
+ continue
161
+ result[code] = value
162
+ return result if result else None
163
+
164
+
142
165
  def decode_rpc_response(message: RoborockMessage) -> ResponseMessage:
143
166
  """Decode a V1 RPC_RESPONSE message.
144
167
 
@@ -149,17 +172,13 @@ def decode_rpc_response(message: RoborockMessage) -> ResponseMessage:
149
172
  """
150
173
  if not message.payload:
151
174
  return ResponseMessage(request_id=message.seq, data={})
152
- try:
153
- payload = json.loads(message.payload.decode())
154
- except (json.JSONDecodeError, TypeError, UnicodeDecodeError) as e:
155
- raise RoborockException(f"Invalid V1 message payload: {e} for {message.payload!r}") from e
156
175
 
157
- _LOGGER.debug("Decoded V1 message payload: %s", payload)
158
- datapoints = payload.get("dps", {})
159
- if not isinstance(datapoints, dict):
160
- raise RoborockException(f"Invalid V1 message format: 'dps' should be a dictionary for {message.payload!r}")
176
+ if (datapoints := _decode_dps_message(message)) is None:
177
+ raise RoborockException(
178
+ f"Invalid V1 message format: missing or invalid 'dps' in payload for {message.payload!r}"
179
+ )
161
180
 
162
- if not (data_point := datapoints.get(str(RoborockMessageProtocol.RPC_RESPONSE))):
181
+ if not (data_point := datapoints.get(RoborockMessageProtocol.RPC_RESPONSE)):
163
182
  raise RoborockException(
164
183
  f"Invalid V1 message format: missing '{RoborockMessageProtocol.RPC_RESPONSE}' data point"
165
184
  )
@@ -206,6 +225,31 @@ def decode_rpc_response(message: RoborockMessage) -> ResponseMessage:
206
225
  return ResponseMessage(request_id=request_id, data=result, api_error=api_error)
207
226
 
208
227
 
228
+ def decode_data_protocol_message(message: RoborockMessage) -> dict[RoborockDataProtocol, Any] | None:
229
+ """Decode a V1 push message containing data protocol updates.
230
+
231
+ V1 devices push unsolicited status updates containing data points keyed
232
+ by RoborockDataProtocol codes (e.g., 121=STATE, 122=BATTERY). This function
233
+ extracts those data points from the message payload.
234
+
235
+ Returns a dict mapping RoborockDataProtocol to values, or None if the
236
+ message does not contain any recognized data protocol updates.
237
+ """
238
+ if (datapoints := _decode_dps_message(message)) is None:
239
+ return None
240
+
241
+ result: dict[RoborockDataProtocol, Any] = {}
242
+ for code, value in datapoints.items():
243
+ try:
244
+ protocol = RoborockDataProtocol(code)
245
+ except ValueError:
246
+ _LOGGER.debug("Ignoring unknown V1 data protocol code: %s", code)
247
+ continue
248
+ result[protocol] = value
249
+
250
+ return result if result else None
251
+
252
+
209
253
  @dataclass
210
254
  class MapResponse:
211
255
  """Data structure for the V1 Map response."""
@@ -6,6 +6,7 @@ import math
6
6
  import secrets
7
7
  import string
8
8
  import time
9
+ from collections.abc import Callable
9
10
  from dataclasses import dataclass
10
11
 
11
12
  import aiohttp
@@ -737,23 +738,46 @@ class UserWebApiClient:
737
738
  to avoid needing to pass UserData around and mock out the web API.
738
739
  """
739
740
 
740
- def __init__(self, web_api: RoborockApiClient, user_data: UserData) -> None:
741
+ def __init__(
742
+ self, web_api: RoborockApiClient, user_data: UserData, unauthorized_hook: Callable[[], None] | None = None
743
+ ) -> None:
741
744
  """Initialize the wrapper with the API client and user data."""
742
745
  self._web_api = web_api
743
746
  self._user_data = user_data
747
+ self._unauthorized_hook = unauthorized_hook
744
748
 
745
749
  async def get_home_data(self) -> HomeData:
746
750
  """Fetch home data using the API client."""
747
- return await self._web_api.get_home_data_v3(self._user_data)
751
+ try:
752
+ return await self._web_api.get_home_data_v3(self._user_data)
753
+ except RoborockInvalidCredentials:
754
+ if self._unauthorized_hook:
755
+ self._unauthorized_hook()
756
+ raise
748
757
 
749
758
  async def get_routines(self, device_id: str) -> list[HomeDataScene]:
750
759
  """Fetch routines (scenes) for a specific device."""
751
- return await self._web_api.get_scenes(self._user_data, device_id)
760
+ try:
761
+ return await self._web_api.get_scenes(self._user_data, device_id)
762
+ except RoborockInvalidCredentials:
763
+ if self._unauthorized_hook:
764
+ self._unauthorized_hook()
765
+ raise
752
766
 
753
767
  async def get_rooms(self) -> list[HomeDataRoom]:
754
768
  """Fetch rooms using the API client."""
755
- return await self._web_api.get_rooms(self._user_data)
769
+ try:
770
+ return await self._web_api.get_rooms(self._user_data)
771
+ except RoborockInvalidCredentials:
772
+ if self._unauthorized_hook:
773
+ self._unauthorized_hook()
774
+ raise
756
775
 
757
776
  async def execute_routine(self, scene_id: int) -> None:
758
777
  """Execute a specific routine (scene) by its ID."""
759
- await self._web_api.execute_scene(self._user_data, scene_id)
778
+ try:
779
+ await self._web_api.execute_scene(self._user_data, scene_id)
780
+ except RoborockInvalidCredentials:
781
+ if self._unauthorized_hook:
782
+ self._unauthorized_hook()
783
+ raise