Skip to content

Commit b7a5208

Browse files
authored
feat: track A01 device state from push updates (#941)
* feat: track A01 device state from push updates * fix: update last_message_time on unsubscribed query responses
1 parent 49ddc48 commit b7a5208

5 files changed

Lines changed: 313 additions & 59 deletions

File tree

docs/DEVICES.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ Cloud and Network.
1212
* **Vacuums (V1)**: Use `device.v1_properties` to access traits like `status` or `consumables`.
1313
* Call `await trait.refresh()` to update state.
1414
* Use `device.v1_properties.command.send()` for raw commands (start/stop).
15-
* **Washers (A01)**: Use `device.a01_properties` for Dyad/Zeo devices.
16-
* Use `await device.a01_properties.query_values([...])` to get state.
17-
* Use `await device.a01_properties.set_value(protocol, value)` to control.
15+
* **Washers (A01)**: Use `device.dyad` or `device.zeo` for Dyad/Zeo devices.
16+
* Read `api.values` for the latest known state, merged from query responses and unsolicited pushes.
17+
* Use `api.add_update_listener(callback)` to be notified when `values` changes.
18+
* Use `await api.query_values([...])` to poll specific data points.
19+
* Use `await api.set_value(protocol, value)` to control.
1820
* **Vacuums (B01 Q10)**: Use `device.b01_q10_properties` for Q10 series devices.
1921
* Use `device.b01_q10_properties.vacuum` to access vacuum commands (start, pause, stop, dock, empty dustbin, set clean mode, set fan level).
2022
* Use `device.b01_q10_properties.command.send()` for raw DP commands.

roborock/devices/device.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,8 @@ async def connect(self) -> None:
206206
await self.b01_q7_properties.start()
207207
elif self.zeo is not None:
208208
await self.zeo.start()
209+
elif self.dyad is not None:
210+
await self.dyad.start()
209211
except RoborockException:
210212
# Expected: start() can fail transiently. Unsubscribe before propagating
211213
# so the retry by connect_loop() gets a clean channel.
@@ -238,6 +240,8 @@ async def close(self) -> None:
238240
await self.b01_q7_properties.close()
239241
if self.zeo is not None:
240242
self.zeo.close()
243+
if self.dyad is not None:
244+
self.dyad.close()
241245
if self._unsub:
242246
self._unsub()
243247
self._unsub = None

roborock/devices/device_manager.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ def device_creator(home_data: HomeData, device: HomeDataDevice, product: HomeDat
255255
)
256256
case DeviceVersion.A01:
257257
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)
259259
case DeviceVersion.B01:
260260
mqtt_channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
261261
model_part = product.model.split(".")[-1]

roborock/devices/traits/a01/__init__.py

Lines changed: 168 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -6,26 +6,30 @@
66
Using A01 APIs
77
--------------
88
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`).
1010
1111
The API provides these methods:
1212
1. **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.
1515
2. **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

2427
import json
2528
import logging
29+
from abc import abstractmethod
2630
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
2933

3034
from roborock.data import DyadProductInfo, DyadSndState, HomeDataProduct, RoborockCategory
3135
from roborock.data.dyad.dyad_code_mappings import (
@@ -73,6 +77,7 @@
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

Comments
 (0)