python-roborock 7.2.3__py3-none-any.whl → 7.4.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.3
3
+ Version: 7.4.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/
@@ -11,10 +11,10 @@ roborock/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
11
  roborock/roborock_message.py,sha256=VWsytnm7nQqOMBj7oYbiqPPqwE9qClDSI03CFuHXsds,11822
12
12
  roborock/roborock_typing.py,sha256=xppavdf57-Cu6elj3MP403uT75Tjw5Zs6bLGPLQmZf4,15483
13
13
  roborock/util.py,sha256=mjw329ujLjoWPPAr9mG0VZstaVL06dmVvNnYACB-Pxk,1650
14
- roborock/web_api.py,sha256=M_A4PiRK5cwkoT3oS8fvglOU97s1jpWKuuCxeeCiF_E,37507
14
+ roborock/web_api.py,sha256=ypW9e11U0jNXnyIQO9UN0DnIdz4c6AxtJlUJmjuEl-o,40514
15
15
  roborock/data/__init__.py,sha256=jSZHOrd-LfVN-TdBkjNW334ef99rGBz4-hPT_7KvoLs,278
16
16
  roborock/data/code_mappings.py,sha256=SGLJvHSdibUsXqCbQcqCGKm3x09JqhRKLoCgMCN1_s0,9769
17
- roborock/data/containers.py,sha256=r46o2Xf6gfpw2SHBZnkVbfJIYYbpgTPYWDmAHWdgL8E,17228
17
+ roborock/data/containers.py,sha256=ISfVcuDYFqMUiYa0V7NaMDH6snjYjJqOg6oKp-URghg,17900
18
18
  roborock/data/b01_q10/__init__.py,sha256=E4lwJcz4AI1z92FcYOJ-cY3fcfHSoXqvYVgfA-TgaRk,71
19
19
  roborock/data/b01_q10/b01_q10_code_mappings.py,sha256=UBFkxCWLYCGQatENmNwR0owkXrcB8AosnD-hICfS2AY,15203
20
20
  roborock/data/b01_q10/b01_q10_containers.py,sha256=HE1qAhYKDTz35oRgekYItNfD1iWTi7605-wpETcDzxU,11823
@@ -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
@@ -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.3.dist-info/METADATA,sha256=6ZN58P8bGm0Sq6KLgtDIJrjc5GnWDRmFCH3t_xaZ1es,5278
129
- python_roborock-7.2.3.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
130
- python_roborock-7.2.3.dist-info/entry_points.txt,sha256=EvC1nMqi9ZXKgZnqlNXA33v_3nzgPNjnM0mzWlrehnY,47
131
- python_roborock-7.2.3.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
132
- python_roborock-7.2.3.dist-info/RECORD,,
128
+ python_roborock-7.4.0.dist-info/METADATA,sha256=L14mFPjco7QQODUZ4-_dCwKtLQ6cOgHQVyg6UMlitsg,5278
129
+ python_roborock-7.4.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
130
+ python_roborock-7.4.0.dist-info/entry_points.txt,sha256=EvC1nMqi9ZXKgZnqlNXA33v_3nzgPNjnM0mzWlrehnY,47
131
+ python_roborock-7.4.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
132
+ python_roborock-7.4.0.dist-info/RECORD,,
@@ -348,6 +348,24 @@ class HomeDataScene(RoborockBase):
348
348
  name: str
349
349
 
350
350
 
351
+ @dataclass
352
+ class FirmwareInfo(RoborockBase):
353
+ """Firmware/OTA info from the cloud (`ota/firmware/{duid}/updatev2`)."""
354
+
355
+ version: str | None = None
356
+ """Latest available firmware version."""
357
+ current_version: str | None = None
358
+ """Currently installed firmware version."""
359
+ updatable: bool | None = None
360
+ """Whether a newer firmware is available to install."""
361
+ desc: str | None = None
362
+ """Release notes / description."""
363
+ release_time: str | None = None
364
+ """Release date of the available firmware as an ISO-8601 date (``YYYY-MM-DD``)."""
365
+ force_update: bool | None = None
366
+ """Whether the update is mandatory (cannot be skipped)."""
367
+
368
+
351
369
  @dataclass
352
370
  class HomeDataSchedule(RoborockBase):
353
371
  id: int
@@ -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}")
roborock/web_api.py CHANGED
@@ -15,7 +15,7 @@ from aiohttp import ContentTypeError, FormData
15
15
  from pyrate_limiter import Duration, Limiter, Rate
16
16
 
17
17
  from roborock import HomeDataSchedule
18
- from roborock.data import HomeData, HomeDataRoom, HomeDataScene, ProductResponse, RRiot, UserData
18
+ from roborock.data import FirmwareInfo, HomeData, HomeDataRoom, HomeDataScene, ProductResponse, RRiot, UserData
19
19
  from roborock.exceptions import (
20
20
  RoborockAccountDoesNotExist,
21
21
  RoborockException,
@@ -608,6 +608,73 @@ class RoborockApiClient:
608
608
  else:
609
609
  raise RoborockException("scene_response result was an unexpected type")
610
610
 
611
+ async def get_firmware_info(self, user_data: UserData, device_id: str) -> FirmwareInfo:
612
+ """Get firmware/OTA info for a device (latest version + updatable flag)."""
613
+ rriot = user_data.rriot
614
+ if rriot is None:
615
+ raise RoborockException("rriot is none")
616
+ if rriot.r.a is None:
617
+ raise RoborockException("Missing field 'a' in rriot reference")
618
+ path = f"/ota/firmware/{device_id}/updatev2"
619
+ params = {"lang": "en"}
620
+ firmware_request = PreparedRequest(
621
+ rriot.r.a,
622
+ self.session,
623
+ {
624
+ "Authorization": _get_hawk_authentication(rriot, path, params=params),
625
+ },
626
+ )
627
+ firmware_response = await firmware_request.request("get", path, params=params)
628
+ if not firmware_response or not firmware_response.get("success"):
629
+ raise RoborockException(firmware_response)
630
+ return FirmwareInfo.from_dict(firmware_response.get("result") or {})
631
+
632
+ async def start_firmware_update(self, user_data: UserData, device_id: str) -> None:
633
+ """Trigger the (irreversible) firmware update for a device.
634
+
635
+ The device downloads and flashes the latest firmware reported by
636
+ :meth:`get_firmware_info`.
637
+ """
638
+ rriot = user_data.rriot
639
+ if rriot is None:
640
+ raise RoborockException("rriot is none")
641
+ if rriot.r.a is None:
642
+ raise RoborockException("Missing field 'a' in rriot reference")
643
+ path = f"/ota/device/{device_id}/upgrade"
644
+ upgrade_request = PreparedRequest(
645
+ rriot.r.a,
646
+ self.session,
647
+ {
648
+ "Authorization": _get_hawk_authentication(rriot, path),
649
+ },
650
+ )
651
+ upgrade_response = await upgrade_request.request("post", path)
652
+ if not upgrade_response or not upgrade_response.get("success"):
653
+ raise RoborockException(upgrade_response)
654
+
655
+ async def set_silent_ota(self, user_data: UserData, device_id: str, enabled: bool) -> None:
656
+ """Enable/disable automatic (silent) firmware updates for a device.
657
+
658
+ The current value is reported as ``silent_ota_switch`` on the home-data device.
659
+ """
660
+ rriot = user_data.rriot
661
+ if rriot is None:
662
+ raise RoborockException("rriot is none")
663
+ if rriot.r.a is None:
664
+ raise RoborockException("Missing field 'a' in rriot reference")
665
+ path = f"/user/devices/{device_id}"
666
+ formdata = {"silentOtaSwitch": "true" if enabled else "false"}
667
+ silent_ota_request = PreparedRequest(
668
+ rriot.r.a,
669
+ self.session,
670
+ {
671
+ "Authorization": _get_hawk_authentication(rriot, path, formdata=formdata),
672
+ },
673
+ )
674
+ silent_ota_response = await silent_ota_request.request("put", path, data=formdata)
675
+ if not silent_ota_response or not silent_ota_response.get("success"):
676
+ raise RoborockException(silent_ota_response)
677
+
611
678
  async def execute_scene(self, user_data: UserData, scene_id: int) -> None:
612
679
  rriot = user_data.rriot
613
680
  if rriot is None: