python-roborock 7.2.2__py3-none-any.whl → 7.3.0__py3-none-any.whl

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-roborock
3
- Version: 7.2.2
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,7 +1,7 @@
1
1
  roborock/__init__.py,sha256=jpp3MEmevRvoJ5tq2waCDGH5VfJk--Lv1DmrwEdDyEQ,375
2
2
  roborock/broadcast_protocol.py,sha256=fjNQW7NdBKRzSy6126TfCSoYGpT2Esj9Jd-t7tXFc_E,4015
3
3
  roborock/callbacks.py,sha256=w2yPXtWFH7zfoFsLPYoCTbfwbE9p1ivA7iDcw20lJQI,4300
4
- roborock/cli.py,sha256=3OrJAmd9c52cyKLC9jF4-O5UkVXJAWZilOzvnxP7jyc,64097
4
+ roborock/cli.py,sha256=0xTv7rfXSKU19bc2b2SILuFifTVYDcAj-FSQDSOgi1k,63308
5
5
  roborock/const.py,sha256=fWpgUPrCgLnaXmncgjCLZET-t7wHWQlpyzV8qHtTjb4,2860
6
6
  roborock/device_features.py,sha256=EbMvw4vz0HvN23x9stj5CfNu45HE1tqg4HyygAzkU5Q,46753
7
7
  roborock/diagnostics.py,sha256=OVfPCjBVidAlQtFz-WRlgCWOBIaXlO-73xQloXJaLwI,5107
@@ -37,8 +37,8 @@ roborock/data/zeo/zeo_containers.py,sha256=huDeisutagKz1rzsLZr_tGAietec_qOd0KB3i
37
37
  roborock/devices/README.md,sha256=zjssyRIlFWTgQz8hLdsJC9_stlHdUQybz7r2af_8q1k,2186
38
38
  roborock/devices/__init__.py,sha256=yOWuncnp5wwKPLzcnblsBMGD0v8hcDDDC_x7YdSrN70,129
39
39
  roborock/devices/cache.py,sha256=V3KC16LzTEAp_cdAirNtWPjZAgz6ugn3zfsYPyrbWsE,4633
40
- roborock/devices/device.py,sha256=bhRvHsZC2yhNiwg2yBJVjC8ZO_BJ_NkZMajuABXMHuo,10122
41
- roborock/devices/device_manager.py,sha256=RPuvbhYxLjdCBrsjYP91dvHTnO8inODsa26m5987VR4,11777
40
+ roborock/devices/device.py,sha256=npRjwXubKBubgFmCesYWugo90kS4TfiLlN6Z_xnP55o,10266
41
+ roborock/devices/device_manager.py,sha256=_1ofbZluoDM-ro7yMnppwaCPAPEGMhDytppuEcMCNyE,11813
42
42
  roborock/devices/file_cache.py,sha256=-G2Bm4ra8K_DvSBjntv5hGaRDB-VzSoKWcI0Y_kK6jw,2724
43
43
  roborock/devices/rpc/__init__.py,sha256=MDtZovvHi3F4Wbgyzu6gyjAjkZ6xYGU1LRQmtiIapuU,561
44
44
  roborock/devices/rpc/a01_channel.py,sha256=R33NQ3hK6pWjPzF2kaWVv93r9RrTWskg6_YWi3Oiiq4,3886
@@ -48,7 +48,7 @@ roborock/devices/rpc/v1_channel.py,sha256=xCQ9YqgTFteU-ptc9-H0w6HtZK3OyH8GBS4QNs
48
48
  roborock/devices/traits/__init__.py,sha256=YaL9qoxG6IBG0XXDP0HMTvoc0Jkmq7AF6iH2oYWNtcg,765
49
49
  roborock/devices/traits/common.py,sha256=n3fHK4i4bkTU1QL7skMStJiUPIDlpN37z0T9mPQYoOY,4588
50
50
  roborock/devices/traits/traits_mixin.py,sha256=7t15l_Ty1cN-3-fKNtRH1XR6-mfadPnTBCQRZP4rAtc,2092
51
- roborock/devices/traits/a01/__init__.py,sha256=GKu_SWJ9l57u0DU_vd8ec4j6smwrR9s4gUF0yMB-xWA,13284
51
+ roborock/devices/traits/a01/__init__.py,sha256=rRrdXgSsolyNC6MOCyYIfW8oaUmzt-p_uppuhzfYYIU,18375
52
52
  roborock/devices/traits/a01/device_feature.py,sha256=6--fV8kRv4nxWIaQlxHt0XFyovhqk6dGeVm-E1n5y_U,13514
53
53
  roborock/devices/traits/b01/__init__.py,sha256=pGN6wEocE6Fr2BTasG8Z_75crD6OdTwpju59Vw7eTDk,202
54
54
  roborock/devices/traits/b01/q10/__init__.py,sha256=BpeCO7vrM11ISy8JZwBiSkHsTs5-vp68c9YwnWKEIDk,6747
@@ -60,7 +60,7 @@ roborock/devices/traits/b01/q10/common.py,sha256=Qxl3zBAUulqdrjFZJ0DPEFEIJLvlPqU
60
60
  roborock/devices/traits/b01/q10/consumable.py,sha256=MbtMgYKqzNdVn6M1iyOAqZPCYTbN_Q_Pr4h8EYGEiuY,669
61
61
  roborock/devices/traits/b01/q10/do_not_disturb.py,sha256=klyGQZ_nHyO6bhPlwBl8vElTnMwPf4A8VXwxkU8rMlM,1157
62
62
  roborock/devices/traits/b01/q10/dust_collection.py,sha256=LM-0YH4pMrEqLUCFP2g-DymhL5ZUQ4bHd-2EyKHmucY,1582
63
- roborock/devices/traits/b01/q10/map.py,sha256=1_gFfiSTgJ0XbdfBsiH6QM2Nh6aF25i-IElojEf85v4,7541
63
+ roborock/devices/traits/b01/q10/map.py,sha256=xWODSaUo4LHOhmF89WLPHvxpLgBzlTt6pPmOVCP2Oyc,7276
64
64
  roborock/devices/traits/b01/q10/maps.py,sha256=qWk1K47aIf3iJead3A2zQ3Lo0wZ1wcS2mo4qVfiiTDw,2101
65
65
  roborock/devices/traits/b01/q10/network_info.py,sha256=XiCr6YPu7xkvhgeaGwXli-_4SB88QW63ykiegyaau2k,656
66
66
  roborock/devices/traits/b01/q10/remote.py,sha256=8-fg4SA5M-tdMufHPeD9hIcax7y9Qh7pzcE7rBBVW_o,1267
@@ -125,8 +125,8 @@ roborock/testing/channel.py,sha256=vjulT5MHBe8lAq6XITUqcxJVcCILWx7ZEcw5imTx0Tk,7
125
125
  roborock/testing/cloud.py,sha256=L2L0KlZDYaMuoV19KUQUoq5EAUDeNQ1lJ8Ew5YaOgjo,11946
126
126
  roborock/testing/simulator.py,sha256=g8ZUWVSjgAzuRRxWOCeOq-7deodyEItwUpqEw9RzCmQ,4347
127
127
  roborock/testing/v1_simulator.py,sha256=TQ2PsRoP_4kJvVn-Tm532ByHuaCKltsVjvTa2Ju4PAo,14407
128
- python_roborock-7.2.2.dist-info/METADATA,sha256=vTfS-_x5Ut3raIemhgfVUN--QARO-jlmV5Ft9Fr0Xrs,5278
129
- python_roborock-7.2.2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
130
- python_roborock-7.2.2.dist-info/entry_points.txt,sha256=EvC1nMqi9ZXKgZnqlNXA33v_3nzgPNjnM0mzWlrehnY,47
131
- python_roborock-7.2.2.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
132
- python_roborock-7.2.2.dist-info/RECORD,,
128
+ python_roborock-7.3.0.dist-info/METADATA,sha256=-UHI17_l7Ty6Txpqlc7NcpxYnkhKNsFsb3u4L3qxn7k,5278
129
+ python_roborock-7.3.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
130
+ python_roborock-7.3.0.dist-info/entry_points.txt,sha256=EvC1nMqi9ZXKgZnqlNXA33v_3nzgPNjnM0mzWlrehnY,47
131
+ python_roborock-7.3.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
132
+ python_roborock-7.3.0.dist-info/RECORD,,
roborock/cli.py CHANGED
@@ -594,9 +594,9 @@ async def maps(ctx, device_id: str):
594
594
  await _display_v1_trait(context, device_id, lambda v1: v1.maps)
595
595
 
596
596
 
597
- # The Q10 publishes its map asynchronously after a dpMultiMap list/get request.
598
- # Firmware throttles pushes to ~once per 60-70s, so rapid re-requests may not be
599
- # answered immediately. This bounds how long a one-shot CLI command waits.
597
+ # The Q10 publishes its current map asynchronously after a REQUEST_DPS. Firmware
598
+ # throttles pushes to ~once per 60-70s, so rapid re-requests may not be answered
599
+ # immediately. This bounds how long a one-shot CLI command waits.
600
600
  _Q10_MAP_PUSH_TIMEOUT = 30.0
601
601
 
602
602
 
@@ -609,10 +609,9 @@ async def _await_q10_map_push(
609
609
  ) -> bool:
610
610
  """Request Q10 map content and wait for usable map-trait state.
611
611
 
612
- A Q10 needs a saved-map ID before it can request content. The map list and
613
- content have independent refresh schedules, so the list is requested only
614
- when no ID is stored. The content then arrives as a later ``MAP_RESPONSE``
615
- and is published through the standard trait update interface.
612
+ The read-only ``REQUEST_DPS`` request returns immediately; current map
613
+ content arrives as a later ``MAP_RESPONSE`` and is published through the
614
+ standard trait update interface.
616
615
  """
617
616
  loop = asyncio.get_running_loop()
618
617
  updated: asyncio.Future[None] = loop.create_future()
@@ -624,20 +623,6 @@ async def _await_q10_map_push(
624
623
  unsub = properties.map.add_update_listener(on_update)
625
624
  try:
626
625
  async with asyncio.timeout(timeout):
627
- if properties.maps.current_map_id is None:
628
- map_list_updated: asyncio.Future[None] = loop.create_future()
629
-
630
- def on_map_list_update() -> None:
631
- if properties.maps.current_map_id is not None and not map_list_updated.done():
632
- map_list_updated.set_result(None)
633
-
634
- unsub_maps = properties.maps.add_update_listener(on_map_list_update)
635
- try:
636
- await properties.maps.refresh()
637
- if properties.maps.current_map_id is None:
638
- await map_list_updated
639
- finally:
640
- unsub_maps()
641
626
  await properties.map.refresh()
642
627
  await updated
643
628
  return True
@@ -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}")
@@ -7,10 +7,10 @@ Map-related state arrives on three independent streams:
7
7
  * restricted zones, virtual walls and dock state arrive as ordinary DPS values.
8
8
 
9
9
  ``MapDpsTrait`` owns the low-level map-specific DPS read model.
10
- ``MapContentTrait`` uses a stored ID from ``MapsTrait`` only when it requests
11
- content. It combines the latest map and trace packets with the map DPS state
12
- through the pure functions in :mod:`roborock.map.b01_q10_render`. Map-list
13
- updates do not refresh content.
10
+ ``MapContentTrait`` requests a current-map push through ``REQUEST_DPS`` and
11
+ combines the latest map and trace packets with the map DPS state through the
12
+ pure functions in :mod:`roborock.map.b01_q10_render`. Saved-map list/detail
13
+ operations remain on ``MapsTrait``.
14
14
  """
15
15
 
16
16
  import logging
@@ -107,20 +107,13 @@ class MapContentTrait(TraitUpdateListener):
107
107
  self._map_dps.add_update_listener(self._map_dps_updated)
108
108
 
109
109
  async def refresh(self) -> None:
110
- """Request content for the first map in the latest saved-map list."""
111
- if (map_id := self._maps.current_map_id) is None:
112
- raise RoborockException("Cannot request Q10 map content before the map list is available")
113
- # Map lists and map content can change at different times. Reuse the
114
- # stored ID so a content refresh does not also refresh the list.
115
- await self._command.send(
116
- B01_Q10_DP.COMMON,
117
- {
118
- str(B01_Q10_DP.MULTI_MAP.code): {
119
- "op": "get",
120
- "id": map_id,
121
- }
122
- },
123
- )
110
+ """Request a safe asynchronous current-map/status push.
111
+
112
+ Some ss07 firmware treats ``dpMultiMap op:get`` as an active
113
+ cleaning/relocation command. ``REQUEST_DPS`` is the device's read-only
114
+ current-map request and does not depend on a saved-map ID.
115
+ """
116
+ await self._command.send(B01_Q10_DP.REQUEST_DPS, params={})
124
117
 
125
118
  @property
126
119
  def image_content(self) -> bytes | None: