66Using A01 APIs
77--------------
88A01 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 `).
1010
1111The API provides these methods:
12121. **query_values(protocols)**: Fetches current state for specific data points.
1313 You must pass a list of protocol enums (e.g. `RoborockDyadDataProtocol` or
1414 `RoborockZeoProtocol`) to request specific data.
15152. **set_value(protocol, value)**: Sends a command to the device to change a setting
1616 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).
2225"""
2326
2427import json
2528import logging
29+ from abc import abstractmethod
2630from 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
2933
3034from roborock .data import DyadProductInfo , DyadSndState , HomeDataProduct , RoborockCategory
3135from roborock .data .dyad .dyad_code_mappings import (
7377_LOGGER = logging .getLogger (__name__ )
7478
7579__all__ = [
80+ "A01Api" ,
7681 "DyadApi" ,
7782 "ZeoApi" ,
7883]
@@ -155,14 +160,121 @@ def convert_zeo_value(protocol_value: RoborockZeoProtocol, value: Any) -> Any:
155160
156161
157162_DYAD_PROTOCOL_VALUES = frozenset (protocol .value for protocol in RoborockDyadDataProtocol )
163+ _ZEO_PROTOCOL_VALUES = frozenset (protocol .value for protocol in RoborockZeoProtocol )
158164
165+ _P = TypeVar ("_P" , RoborockDyadDataProtocol , RoborockZeoProtocol )
159166
160- class DyadApi (Trait ):
161- """API for interacting with Dyad devices."""
162167
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 )
165179 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
166278
167279 async def query_values (self , protocols : list [RoborockDyadDataProtocol ]) -> dict [RoborockDyadDataProtocol , Any ]:
168280 """Query the device for the values of the given Dyad protocols."""
@@ -171,7 +283,9 @@ async def query_values(self, protocols: list[RoborockDyadDataProtocol]) -> dict[
171283 {RoborockDyadDataProtocol .ID_QUERY : protocols },
172284 value_encoder = json .dumps ,
173285 )
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
175289
176290 async def set_value (self , protocol : RoborockDyadDataProtocol , value : Any ) -> dict [RoborockDyadDataProtocol , Any ]:
177291 """Set a value for a specific protocol on the device."""
@@ -184,38 +298,34 @@ async def add_listener(self, callback: Callable[[dict[RoborockDyadDataProtocol,
184298 The callback is invoked with decoded values whenever the device sends a
185299 message, including unsolicited pushes when its state changes. Only known
186300 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.
187304 """
188305
189306 def on_message (message : RoborockMessage ) -> None :
190307 try :
191308 datapoints = decode_rpc_response (message )
192309 except RoborockException :
193310 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 ):
201312 callback (values )
202313
203314 return await self ._channel .subscribe (on_message )
204315
205316
206- class ZeoApi (Trait , TraitUpdateListener ):
317+ class ZeoApi (A01Api [ RoborockZeoProtocol ] ):
207318 """API for interacting with Zeo devices."""
208319
209320 name = "zeo"
210321
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 :
212325 """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
217326 self ._feature_bits : int = 0
218327 self ._model = model
328+ super ().__init__ (channel , initial_status )
219329
220330 async def start (self ) -> None :
221331 """Subscribe to MQTT push and trigger a full state sync.
@@ -230,17 +340,15 @@ async def start(self) -> None:
230340 await self ._force_load ()
231341 await self ._load_feature_dps ()
232342
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
244352
245353 async def _force_load (self ) -> None :
246354 """Send ID_QUERY with the base DP list to trigger a full state push.
@@ -279,39 +387,45 @@ def supports(self, feature: ZeoFeatureBits) -> bool:
279387 """Check whether the device supports a given feature bit."""
280388 return bool (self ._feature_bits & (1 << feature .value ))
281389
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-
294390 async def query_values (self , protocols : list [RoborockZeoProtocol ]) -> dict [RoborockZeoProtocol , Any ]:
295391 """Query the device for the values of the given protocols."""
296392 response = await send_decoded_command (
297393 self ._channel ,
298394 {RoborockZeoProtocol .ID_QUERY : protocols },
299395 value_encoder = json .dumps ,
300396 )
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
302400
303401 async def set_value (self , protocol : RoborockZeoProtocol , value : Any ) -> dict [RoborockZeoProtocol , Any ]:
304402 """Set a value for a specific protocol on the device."""
305403 params = {protocol : value }
306404 return await send_decoded_command (self ._channel , params , value_encoder = lambda x : x )
307405
308406
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 )
311425 match product .category :
312426 case RoborockCategory .WET_DRY_VAC :
313- return DyadApi (mqtt_channel )
427+ return DyadApi (mqtt_channel , initial_status = initial_status )
314428 case RoborockCategory .WASHING_MACHINE :
315- return ZeoApi (mqtt_channel , model = product .model )
429+ return ZeoApi (mqtt_channel , model = product .model , initial_status = initial_status )
316430 case _:
317431 raise NotImplementedError (f"Unsupported category { product .category } " )
0 commit comments