python-roborock 5.9.1__tar.gz → 5.10.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 (103) hide show
  1. {python_roborock-5.9.1 → python_roborock-5.10.0}/PKG-INFO +1 -1
  2. {python_roborock-5.9.1 → python_roborock-5.10.0}/pyproject.toml +1 -1
  3. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/callbacks.py +1 -1
  4. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/cli.py +1 -1
  5. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/data/v1/v1_containers.py +7 -5
  6. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/device.py +3 -1
  7. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/device_manager.py +1 -0
  8. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/rpc/v1_channel.py +31 -6
  9. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/v1/__init__.py +32 -1
  10. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/v1/consumeable.py +23 -2
  11. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/v1/status.py +19 -1
  12. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/protocols/v1_protocol.py +54 -10
  13. {python_roborock-5.9.1 → python_roborock-5.10.0}/.gitignore +0 -0
  14. {python_roborock-5.9.1 → python_roborock-5.10.0}/LICENSE +0 -0
  15. {python_roborock-5.9.1 → python_roborock-5.10.0}/README.md +0 -0
  16. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/__init__.py +0 -0
  17. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/broadcast_protocol.py +0 -0
  18. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/const.py +0 -0
  19. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/data/__init__.py +0 -0
  20. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/data/b01_q10/__init__.py +0 -0
  21. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
  22. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
  23. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/data/b01_q7/__init__.py +0 -0
  24. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
  25. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
  26. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/data/code_mappings.py +0 -0
  27. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/data/containers.py +0 -0
  28. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/data/dyad/__init__.py +0 -0
  29. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/data/dyad/dyad_code_mappings.py +0 -0
  30. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/data/dyad/dyad_containers.py +0 -0
  31. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/data/v1/__init__.py +0 -0
  32. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/data/v1/v1_clean_modes.py +0 -0
  33. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/data/v1/v1_code_mappings.py +0 -0
  34. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/data/zeo/__init__.py +0 -0
  35. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/data/zeo/zeo_code_mappings.py +0 -0
  36. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/data/zeo/zeo_containers.py +0 -0
  37. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/device_features.py +0 -0
  38. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/README.md +0 -0
  39. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/__init__.py +0 -0
  40. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/cache.py +0 -0
  41. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/file_cache.py +0 -0
  42. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/rpc/__init__.py +0 -0
  43. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/rpc/a01_channel.py +0 -0
  44. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/rpc/b01_q10_channel.py +0 -0
  45. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/rpc/b01_q7_channel.py +0 -0
  46. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/__init__.py +0 -0
  47. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/a01/__init__.py +0 -0
  48. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/b01/__init__.py +0 -0
  49. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/b01/q10/__init__.py +0 -0
  50. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/b01/q10/command.py +0 -0
  51. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/b01/q10/status.py +0 -0
  52. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/b01/q10/vacuum.py +0 -0
  53. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/b01/q7/__init__.py +0 -0
  54. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/b01/q7/clean_summary.py +0 -0
  55. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/b01/q7/map.py +0 -0
  56. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/b01/q7/map_content.py +0 -0
  57. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/common.py +0 -0
  58. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/traits_mixin.py +0 -0
  59. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/v1/child_lock.py +0 -0
  60. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/v1/clean_summary.py +0 -0
  61. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/v1/command.py +0 -0
  62. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/v1/common.py +0 -0
  63. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/v1/device_features.py +0 -0
  64. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
  65. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
  66. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/v1/flow_led_status.py +0 -0
  67. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/v1/home.py +0 -0
  68. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/v1/led_status.py +0 -0
  69. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/v1/map_content.py +0 -0
  70. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/v1/maps.py +0 -0
  71. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/v1/network_info.py +0 -0
  72. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/v1/rooms.py +0 -0
  73. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/v1/routines.py +0 -0
  74. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
  75. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
  76. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/v1/volume.py +0 -0
  77. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
  78. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/transport/__init__.py +0 -0
  79. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/transport/channel.py +0 -0
  80. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/transport/local_channel.py +0 -0
  81. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/devices/transport/mqtt_channel.py +0 -0
  82. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/diagnostics.py +0 -0
  83. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/exceptions.py +0 -0
  84. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/map/__init__.py +0 -0
  85. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/map/b01_map_parser.py +0 -0
  86. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/map/map_parser.py +0 -0
  87. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/map/proto/__init__.py +0 -0
  88. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/map/proto/b01_scmap.proto +0 -0
  89. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/map/proto/b01_scmap_pb2.py +0 -0
  90. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/mqtt/__init__.py +0 -0
  91. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/mqtt/health_manager.py +0 -0
  92. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/mqtt/roborock_session.py +0 -0
  93. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/mqtt/session.py +0 -0
  94. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/protocol.py +0 -0
  95. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/protocols/__init__.py +0 -0
  96. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/protocols/a01_protocol.py +0 -0
  97. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/protocols/b01_q10_protocol.py +0 -0
  98. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/protocols/b01_q7_protocol.py +0 -0
  99. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/py.typed +0 -0
  100. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/roborock_message.py +0 -0
  101. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/roborock_typing.py +0 -0
  102. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/util.py +0 -0
  103. {python_roborock-5.9.1 → python_roborock-5.10.0}/roborock/web_api.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.0
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.0"
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:
@@ -236,6 +236,7 @@ async def create_device_manager(
236
236
  channel.rpc_channel,
237
237
  channel.mqtt_rpc_channel,
238
238
  channel.map_rpc_channel,
239
+ channel.add_dps_listener,
239
240
  web_api,
240
241
  device_cache=device_cache,
241
242
  map_parser_config=map_parser_config,
@@ -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."""