python-roborock 7.2.3__tar.gz → 7.3.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 (132) hide show
  1. {python_roborock-7.2.3 → python_roborock-7.3.0}/PKG-INFO +1 -1
  2. {python_roborock-7.2.3 → python_roborock-7.3.0}/pyproject.toml +1 -1
  3. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/device.py +4 -0
  4. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/device_manager.py +1 -1
  5. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/a01/__init__.py +168 -54
  6. {python_roborock-7.2.3 → python_roborock-7.3.0}/.gitignore +0 -0
  7. {python_roborock-7.2.3 → python_roborock-7.3.0}/LICENSE +0 -0
  8. {python_roborock-7.2.3 → python_roborock-7.3.0}/README.md +0 -0
  9. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/__init__.py +0 -0
  10. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/broadcast_protocol.py +0 -0
  11. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/callbacks.py +0 -0
  12. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/cli.py +0 -0
  13. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/const.py +0 -0
  14. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/data/__init__.py +0 -0
  15. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/data/b01_q10/__init__.py +0 -0
  16. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
  17. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
  18. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/data/b01_q7/__init__.py +0 -0
  19. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
  20. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
  21. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/data/code_mappings.py +0 -0
  22. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/data/containers.py +0 -0
  23. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/data/dyad/__init__.py +0 -0
  24. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/data/dyad/dyad_code_mappings.py +0 -0
  25. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/data/dyad/dyad_containers.py +0 -0
  26. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/data/mower/__init__.py +0 -0
  27. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/data/mower/mower_code_mappings.py +0 -0
  28. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/data/mower/mower_containers.py +0 -0
  29. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/data/v1/__init__.py +0 -0
  30. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/data/v1/v1_clean_modes.py +0 -0
  31. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/data/v1/v1_code_mappings.py +0 -0
  32. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/data/v1/v1_containers.py +0 -0
  33. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/data/zeo/__init__.py +0 -0
  34. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/data/zeo/zeo_code_mappings.py +0 -0
  35. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/data/zeo/zeo_containers.py +0 -0
  36. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/device_features.py +0 -0
  37. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/README.md +0 -0
  38. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/__init__.py +0 -0
  39. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/cache.py +0 -0
  40. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/file_cache.py +0 -0
  41. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/rpc/__init__.py +0 -0
  42. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/rpc/a01_channel.py +0 -0
  43. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/rpc/b01_q10_channel.py +0 -0
  44. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/rpc/b01_q7_channel.py +0 -0
  45. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/rpc/v1_channel.py +0 -0
  46. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/__init__.py +0 -0
  47. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/a01/device_feature.py +0 -0
  48. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/b01/__init__.py +0 -0
  49. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/b01/q10/__init__.py +0 -0
  50. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/b01/q10/button_light.py +0 -0
  51. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/b01/q10/child_lock.py +0 -0
  52. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/b01/q10/clean_history.py +0 -0
  53. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/b01/q10/command.py +0 -0
  54. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/b01/q10/common.py +0 -0
  55. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/b01/q10/consumable.py +0 -0
  56. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/b01/q10/do_not_disturb.py +0 -0
  57. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/b01/q10/dust_collection.py +0 -0
  58. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/b01/q10/map.py +0 -0
  59. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/b01/q10/maps.py +0 -0
  60. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/b01/q10/network_info.py +0 -0
  61. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/b01/q10/remote.py +0 -0
  62. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/b01/q10/status.py +0 -0
  63. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/b01/q10/vacuum.py +0 -0
  64. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/b01/q10/volume.py +0 -0
  65. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/b01/q7/__init__.py +0 -0
  66. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/b01/q7/clean_summary.py +0 -0
  67. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/b01/q7/map.py +0 -0
  68. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/b01/q7/map_content.py +0 -0
  69. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/common.py +0 -0
  70. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/traits_mixin.py +0 -0
  71. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/v1/__init__.py +0 -0
  72. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/v1/child_lock.py +0 -0
  73. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/v1/clean_summary.py +0 -0
  74. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/v1/command.py +0 -0
  75. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/v1/common.py +0 -0
  76. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/v1/consumeable.py +0 -0
  77. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/v1/device_features.py +0 -0
  78. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
  79. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
  80. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/v1/flow_led_status.py +0 -0
  81. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/v1/home.py +0 -0
  82. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/v1/led_status.py +0 -0
  83. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/v1/map_content.py +0 -0
  84. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/v1/maps.py +0 -0
  85. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/v1/network_info.py +0 -0
  86. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/v1/obstacle_photos.py +0 -0
  87. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/v1/rooms.py +0 -0
  88. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/v1/routines.py +0 -0
  89. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
  90. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/v1/status.py +0 -0
  91. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
  92. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/v1/volume.py +0 -0
  93. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
  94. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/transport/__init__.py +0 -0
  95. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/transport/channel.py +0 -0
  96. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/transport/local_channel.py +0 -0
  97. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/devices/transport/mqtt_channel.py +0 -0
  98. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/diagnostics.py +0 -0
  99. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/exceptions.py +0 -0
  100. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/map/__init__.py +0 -0
  101. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/map/b01_grid_layers.py +0 -0
  102. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/map/b01_map_parser.py +0 -0
  103. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/map/b01_q10_map_parser.py +0 -0
  104. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/map/b01_q10_overlays.py +0 -0
  105. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/map/b01_q10_render.py +0 -0
  106. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/map/map_parser.py +0 -0
  107. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/map/proto/__init__.py +0 -0
  108. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/map/proto/b01_scmap.proto +0 -0
  109. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/map/proto/b01_scmap_pb2.py +0 -0
  110. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/map/room_colors.py +0 -0
  111. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/mqtt/__init__.py +0 -0
  112. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/mqtt/health_manager.py +0 -0
  113. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/mqtt/roborock_session.py +0 -0
  114. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/mqtt/session.py +0 -0
  115. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/protocol.py +0 -0
  116. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/protocols/__init__.py +0 -0
  117. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/protocols/a01_protocol.py +0 -0
  118. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/protocols/b01_q10_protocol.py +0 -0
  119. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/protocols/b01_q7_protocol.py +0 -0
  120. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/protocols/v1_protocol.py +0 -0
  121. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/py.typed +0 -0
  122. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/roborock_message.py +0 -0
  123. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/roborock_typing.py +0 -0
  124. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/testing/__init__.py +0 -0
  125. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/testing/a01_simulator.py +0 -0
  126. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/testing/b01_q10_simulator.py +0 -0
  127. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/testing/channel.py +0 -0
  128. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/testing/cloud.py +0 -0
  129. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/testing/simulator.py +0 -0
  130. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/testing/v1_simulator.py +0 -0
  131. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/util.py +0 -0
  132. {python_roborock-7.2.3 → python_roborock-7.3.0}/roborock/web_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-roborock
3
- Version: 7.2.3
3
+ Version: 7.3.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 = "7.2.3"
3
+ version = "7.3.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"
@@ -206,6 +206,8 @@ class RoborockDevice(ABC, TraitsMixin):
206
206
  await self.b01_q7_properties.start()
207
207
  elif self.zeo is not None:
208
208
  await self.zeo.start()
209
+ elif self.dyad is not None:
210
+ await self.dyad.start()
209
211
  except RoborockException:
210
212
  # Expected: start() can fail transiently. Unsubscribe before propagating
211
213
  # so the retry by connect_loop() gets a clean channel.
@@ -238,6 +240,8 @@ class RoborockDevice(ABC, TraitsMixin):
238
240
  await self.b01_q7_properties.close()
239
241
  if self.zeo is not None:
240
242
  self.zeo.close()
243
+ if self.dyad is not None:
244
+ self.dyad.close()
241
245
  if self._unsub:
242
246
  self._unsub()
243
247
  self._unsub = None
@@ -255,7 +255,7 @@ async def create_device_manager(
255
255
  )
256
256
  case DeviceVersion.A01:
257
257
  channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
258
- trait = a01.create(product, channel)
258
+ trait = a01.create(product, channel, device_status=device.device_status)
259
259
  case DeviceVersion.B01:
260
260
  mqtt_channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
261
261
  model_part = product.model.split(".")[-1]
@@ -6,7 +6,7 @@ Dyad (Wet/Dry Vacuums) and Zeo (Washing Machines).
6
6
  Using A01 APIs
7
7
  --------------
8
8
  A01 devices expose a single API object that handles all device interactions. This API is
9
- available on the device instance (typically via `device.a01_properties`).
9
+ available on the device instance (`device.dyad` or `device.zeo`).
10
10
 
11
11
  The API provides these methods:
12
12
  1. **query_values(protocols)**: Fetches current state for specific data points.
@@ -14,18 +14,22 @@ The API provides these methods:
14
14
  `RoborockZeoProtocol`) to request specific data.
15
15
  2. **set_value(protocol, value)**: Sends a command to the device to change a setting
16
16
  or perform an action.
17
- 3. **add_listener(callback)**: Subscribes to state the device pushes on its own (for
18
- example when its state changes), invoking the callback with decoded values.
19
-
20
- Note that these APIs fetch data directly from the device upon request and do not
21
- cache state internally.
17
+ 3. **values**: The latest known state, merged from query responses and unsolicited
18
+ pushes in arrival order.
19
+ 4. **add_update_listener(callback)**: Registers a callback invoked whenever `values`
20
+ changes; read `values` from the callback to get the updated state.
21
+
22
+ The device pushes only the data points that changed, so `values` is the merged view
23
+ of everything seen so far. State tracking is active once the device is connected
24
+ (the device calls `start()` on the API, which subscribes to the MQTT topic).
22
25
  """
23
26
 
24
27
  import json
25
28
  import logging
29
+ from abc import abstractmethod
26
30
  from collections.abc import Callable
27
- from datetime import time
28
- from typing import Any
31
+ from datetime import UTC, datetime, time
32
+ from typing import Any, Generic, TypeVar
29
33
 
30
34
  from roborock.data import DyadProductInfo, DyadSndState, HomeDataProduct, RoborockCategory
31
35
  from roborock.data.dyad.dyad_code_mappings import (
@@ -73,6 +77,7 @@ from roborock.roborock_message import (
73
77
  _LOGGER = logging.getLogger(__name__)
74
78
 
75
79
  __all__ = [
80
+ "A01Api",
76
81
  "DyadApi",
77
82
  "ZeoApi",
78
83
  ]
@@ -155,14 +160,121 @@ def convert_zeo_value(protocol_value: RoborockZeoProtocol, value: Any) -> Any:
155
160
 
156
161
 
157
162
  _DYAD_PROTOCOL_VALUES = frozenset(protocol.value for protocol in RoborockDyadDataProtocol)
163
+ _ZEO_PROTOCOL_VALUES = frozenset(protocol.value for protocol in RoborockZeoProtocol)
158
164
 
165
+ _P = TypeVar("_P", RoborockDyadDataProtocol, RoborockZeoProtocol)
159
166
 
160
- class DyadApi(Trait):
161
- """API for interacting with Dyad devices."""
162
167
 
163
- def __init__(self, channel: MqttChannel) -> None:
164
- """Initialize the Dyad API."""
168
+ class A01Api(Trait, TraitUpdateListener, Generic[_P]):
169
+ """Base class for A01 device APIs with device state tracking.
170
+
171
+ Query responses and unsolicited pushes both arrive on the same MQTT topic,
172
+ so a single subscription merges every decoded message into `values` in
173
+ arrival order. Update listeners are notified whenever a value changes.
174
+ """
175
+
176
+ def __init__(self, channel: MqttChannel, initial_status: dict[int, Any] | None = None) -> None:
177
+ """Initialize the A01 API, optionally seeding `values` from a cloud status snapshot."""
178
+ TraitUpdateListener.__init__(self, _LOGGER)
165
179
  self._channel = channel
180
+ self._values: dict[_P, Any] = {}
181
+ self._unsub: Callable[[], None] | None = None
182
+ self._last_message_time: datetime | None = None
183
+ if initial_status:
184
+ self._merge_values(self._decode_datapoints(initial_status))
185
+
186
+ @property
187
+ def values(self) -> dict[_P, Any]:
188
+ """Latest known device state, merged from query responses and pushes.
189
+
190
+ The device pushes only the data points that changed, so this is the
191
+ merged view of everything seen so far. A protocol the device has not
192
+ reported yet is absent from the dictionary.
193
+ """
194
+ return dict(self._values)
195
+
196
+ @property
197
+ def last_message_time(self) -> datetime | None:
198
+ """Time the last message was received from the device.
199
+
200
+ Updated on every decoded message, even when no value changed: idle
201
+ devices push an identical heartbeat, so this is the liveness signal
202
+ even when `values` stays the same and update listeners stay silent.
203
+ The initial cloud status snapshot does not count as a message.
204
+ """
205
+ return self._last_message_time
206
+
207
+ async def start(self) -> None:
208
+ """Subscribe to the device state topic and start tracking `values`."""
209
+ await self._ensure_subscribed()
210
+
211
+ def close(self) -> None:
212
+ """Unsubscribe from MQTT push and release resources."""
213
+ if self._unsub is not None:
214
+ self._unsub()
215
+ self._unsub = None
216
+
217
+ async def _ensure_subscribed(self) -> None:
218
+ """Subscribe to MQTT DPS push (idempotent)."""
219
+ if self._unsub is not None:
220
+ return
221
+ self._unsub = await self._channel.subscribe(self._on_message)
222
+
223
+ @abstractmethod
224
+ def _decode_datapoints(self, datapoints: dict[int, Any]) -> dict[_P, Any]:
225
+ """Convert raw datapoints to typed values, skipping unknown codes."""
226
+
227
+ def _on_message(self, message: RoborockMessage) -> None:
228
+ """Handle a message on the device topic (query response or push)."""
229
+ if message.protocol != RoborockMessageProtocol.RPC_RESPONSE:
230
+ return
231
+ try:
232
+ datapoints = decode_rpc_response(message)
233
+ except RoborockException:
234
+ _LOGGER.debug("Dropped malformed push message", exc_info=True)
235
+ return
236
+ self._last_message_time = datetime.now(UTC)
237
+ self._merge_values(self._decode_datapoints(datapoints))
238
+
239
+ def _merge_query_response(self, values: dict[_P, Any]) -> None:
240
+ """Record a successful query response when there is no subscription.
241
+
242
+ When subscribed, the response was already merged in arrival order and
243
+ timestamped by `_on_message`; merging again here could overwrite a
244
+ push that arrived after it.
245
+ """
246
+ if self._unsub is not None:
247
+ return
248
+ self._last_message_time = datetime.now(UTC)
249
+ self._merge_values(values)
250
+
251
+ def _merge_values(self, values: dict[_P, Any]) -> None:
252
+ """Merge decoded values into the cache and notify on change."""
253
+ changed = False
254
+ for protocol, value in values.items():
255
+ if value is None:
256
+ continue
257
+ if protocol not in self._values or self._values[protocol] != value:
258
+ self._values[protocol] = value
259
+ changed = True
260
+ if changed:
261
+ self._notify_update()
262
+
263
+
264
+ class DyadApi(A01Api[RoborockDyadDataProtocol]):
265
+ """API for interacting with Dyad devices."""
266
+
267
+ name = "dyad"
268
+
269
+ def _decode_datapoints(self, datapoints: dict[int, Any]) -> dict[RoborockDyadDataProtocol, Any]:
270
+ """Convert raw datapoints to typed values, skipping unknown codes."""
271
+ values: dict[RoborockDyadDataProtocol, Any] = {}
272
+ for code, value in datapoints.items():
273
+ if code not in _DYAD_PROTOCOL_VALUES:
274
+ continue
275
+ protocol = RoborockDyadDataProtocol(code)
276
+ values[protocol] = convert_dyad_value(protocol, value)
277
+ return values
166
278
 
167
279
  async def query_values(self, protocols: list[RoborockDyadDataProtocol]) -> dict[RoborockDyadDataProtocol, Any]:
168
280
  """Query the device for the values of the given Dyad protocols."""
@@ -171,7 +283,9 @@ class DyadApi(Trait):
171
283
  {RoborockDyadDataProtocol.ID_QUERY: protocols},
172
284
  value_encoder=json.dumps,
173
285
  )
174
- return {protocol: convert_dyad_value(protocol, response.get(protocol)) for protocol in protocols}
286
+ values = {protocol: convert_dyad_value(protocol, response.get(protocol)) for protocol in protocols}
287
+ self._merge_query_response(values)
288
+ return values
175
289
 
176
290
  async def set_value(self, protocol: RoborockDyadDataProtocol, value: Any) -> dict[RoborockDyadDataProtocol, Any]:
177
291
  """Set a value for a specific protocol on the device."""
@@ -184,6 +298,9 @@ class DyadApi(Trait):
184
298
  The callback is invoked with decoded values whenever the device sends a
185
299
  message, including unsolicited pushes when its state changes. Only known
186
300
  protocols are delivered. Returns a callable to remove the listener.
301
+
302
+ Prefer `add_update_listener` together with `values`, which handle the
303
+ merging of partial pushes for you.
187
304
  """
188
305
 
189
306
  def on_message(message: RoborockMessage) -> None:
@@ -191,31 +308,24 @@ class DyadApi(Trait):
191
308
  datapoints = decode_rpc_response(message)
192
309
  except RoborockException:
193
310
  return
194
- values: dict[RoborockDyadDataProtocol, Any] = {}
195
- for code, value in datapoints.items():
196
- if code not in _DYAD_PROTOCOL_VALUES:
197
- continue
198
- protocol = RoborockDyadDataProtocol(code)
199
- values[protocol] = convert_dyad_value(protocol, value)
200
- if values:
311
+ if values := self._decode_datapoints(datapoints):
201
312
  callback(values)
202
313
 
203
314
  return await self._channel.subscribe(on_message)
204
315
 
205
316
 
206
- class ZeoApi(Trait, TraitUpdateListener):
317
+ class ZeoApi(A01Api[RoborockZeoProtocol]):
207
318
  """API for interacting with Zeo devices."""
208
319
 
209
320
  name = "zeo"
210
321
 
211
- def __init__(self, channel: MqttChannel, model: str | None = None) -> None:
322
+ def __init__(
323
+ self, channel: MqttChannel, model: str | None = None, initial_status: dict[int, Any] | None = None
324
+ ) -> None:
212
325
  """Initialize the Zeo API."""
213
- TraitUpdateListener.__init__(self, _LOGGER)
214
- self._channel = channel
215
- self._dps_cache: dict[int, Any] = {}
216
- self._dps_unsub: Callable[[], None] | None = None
217
326
  self._feature_bits: int = 0
218
327
  self._model = model
328
+ super().__init__(channel, initial_status)
219
329
 
220
330
  async def start(self) -> None:
221
331
  """Subscribe to MQTT push and trigger a full state sync.
@@ -230,17 +340,15 @@ class ZeoApi(Trait, TraitUpdateListener):
230
340
  await self._force_load()
231
341
  await self._load_feature_dps()
232
342
 
233
- def close(self) -> None:
234
- """Unsubscribe from MQTT push and release resources."""
235
- if self._dps_unsub is not None:
236
- self._dps_unsub()
237
- self._dps_unsub = None
238
-
239
- async def _ensure_subscribed(self) -> None:
240
- """Subscribe to MQTT DPS push (idempotent)."""
241
- if self._dps_unsub is not None:
242
- return
243
- self._dps_unsub = await self._channel.subscribe(self._on_dps_message)
343
+ def _decode_datapoints(self, datapoints: dict[int, Any]) -> dict[RoborockZeoProtocol, Any]:
344
+ """Convert raw datapoints to typed values, skipping unknown codes."""
345
+ values: dict[RoborockZeoProtocol, Any] = {}
346
+ for code, value in datapoints.items():
347
+ if code not in _ZEO_PROTOCOL_VALUES:
348
+ continue
349
+ protocol = RoborockZeoProtocol(code)
350
+ values[protocol] = convert_zeo_value(protocol, value)
351
+ return values
244
352
 
245
353
  async def _force_load(self) -> None:
246
354
  """Send ID_QUERY with the base DP list to trigger a full state push.
@@ -279,18 +387,6 @@ class ZeoApi(Trait, TraitUpdateListener):
279
387
  """Check whether the device supports a given feature bit."""
280
388
  return bool(self._feature_bits & (1 << feature.value))
281
389
 
282
- def _on_dps_message(self, message: RoborockMessage) -> None:
283
- """Handle unsolicited MQTT push (protocol 102 — RPC_RESPONSE)."""
284
- if message.protocol != RoborockMessageProtocol.RPC_RESPONSE:
285
- return
286
- try:
287
- decoded = decode_rpc_response(message)
288
- except RoborockException:
289
- _LOGGER.debug("Dropped malformed push message", exc_info=True)
290
- return
291
- self._dps_cache.update(decoded)
292
- self._notify_update()
293
-
294
390
  async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[RoborockZeoProtocol, Any]:
295
391
  """Query the device for the values of the given protocols."""
296
392
  response = await send_decoded_command(
@@ -298,7 +394,9 @@ class ZeoApi(Trait, TraitUpdateListener):
298
394
  {RoborockZeoProtocol.ID_QUERY: protocols},
299
395
  value_encoder=json.dumps,
300
396
  )
301
- return {protocol: convert_zeo_value(protocol, response.get(protocol)) for protocol in protocols}
397
+ values = {protocol: convert_zeo_value(protocol, response.get(protocol)) for protocol in protocols}
398
+ self._merge_query_response(values)
399
+ return values
302
400
 
303
401
  async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[RoborockZeoProtocol, Any]:
304
402
  """Set a value for a specific protocol on the device."""
@@ -306,12 +404,28 @@ class ZeoApi(Trait, TraitUpdateListener):
306
404
  return await send_decoded_command(self._channel, params, value_encoder=lambda x: x)
307
405
 
308
406
 
309
- def create(product: HomeDataProduct, mqtt_channel: MqttChannel) -> DyadApi | ZeoApi:
310
- """Create traits for A01 devices."""
407
+ def _parse_device_status(device_status: dict | None) -> dict[int, Any] | None:
408
+ """Normalize the cloud home data status snapshot to integer datapoint codes."""
409
+ if not device_status:
410
+ return None
411
+ try:
412
+ return {int(code): value for code, value in device_status.items()}
413
+ except (TypeError, ValueError):
414
+ _LOGGER.debug("Ignoring malformed device status snapshot: %s", device_status)
415
+ return None
416
+
417
+
418
+ def create(product: HomeDataProduct, mqtt_channel: MqttChannel, device_status: dict | None = None) -> DyadApi | ZeoApi:
419
+ """Create traits for A01 devices.
420
+
421
+ The optional `device_status` is the cloud home data status snapshot, used
422
+ to seed `values` so state is available before the first device round trip.
423
+ """
424
+ initial_status = _parse_device_status(device_status)
311
425
  match product.category:
312
426
  case RoborockCategory.WET_DRY_VAC:
313
- return DyadApi(mqtt_channel)
427
+ return DyadApi(mqtt_channel, initial_status=initial_status)
314
428
  case RoborockCategory.WASHING_MACHINE:
315
- return ZeoApi(mqtt_channel, model=product.model)
429
+ return ZeoApi(mqtt_channel, model=product.model, initial_status=initial_status)
316
430
  case _:
317
431
  raise NotImplementedError(f"Unsupported category {product.category}")
File without changes