2020"""
2121
2222import json
23+ import logging
2324from collections .abc import Callable
2425from datetime import time
2526from typing import Any
2627
28+ from roborock .exceptions import RoborockException
29+ from roborock .protocols .a01_protocol import decode_rpc_response
30+
2731from roborock .data import DyadProductInfo , DyadSndState , HomeDataProduct , RoborockCategory
2832from roborock .data .dyad .dyad_code_mappings import (
2933 DyadBrushSpeed ,
5054)
5155from roborock .devices .rpc .a01_channel import send_decoded_command
5256from roborock .devices .traits import Trait
57+ from roborock .devices .traits .common import TraitUpdateListener
5358from roborock .devices .transport .mqtt_channel import MqttChannel
54- from roborock .roborock_message import RoborockDyadDataProtocol , RoborockZeoProtocol
59+ from roborock .roborock_message import (
60+ RoborockDyadDataProtocol ,
61+ RoborockMessage ,
62+ RoborockMessageProtocol ,
63+ RoborockZeoProtocol ,
64+ )
65+
66+ _LOGGER = logging .getLogger (__name__ )
5567
5668__init__ = [
5769 "DyadApi" ,
@@ -156,14 +168,50 @@ async def set_value(self, protocol: RoborockDyadDataProtocol, value: Any) -> dic
156168 return await send_decoded_command (self ._channel , params )
157169
158170
159- class ZeoApi (Trait ):
171+ class ZeoApi (Trait , TraitUpdateListener ):
160172 """API for interacting with Zeo devices."""
161173
162174 name = "zeo"
163175
164176 def __init__ (self , channel : MqttChannel ) -> None :
165177 """Initialize the Zeo API."""
178+ TraitUpdateListener .__init__ (self , _LOGGER )
166179 self ._channel = channel
180+ self ._dps_cache : dict [int , Any ] = {}
181+ self ._dps_unsub : Callable [[], None ] | None = None
182+
183+ async def start (self ) -> None :
184+ """Subscribe to MQTT push (called once after device connects)."""
185+ await self ._ensure_subscribed ()
186+
187+ def close (self ) -> None :
188+ """Unsubscribe from MQTT push and release resources."""
189+ if self ._dps_unsub is not None :
190+ self ._dps_unsub ()
191+ self ._dps_unsub = None
192+
193+ async def _ensure_subscribed (self ) -> None :
194+ """Subscribe to MQTT DPS push (idempotent)."""
195+ if self ._dps_unsub is not None :
196+ return
197+ self ._dps_unsub = await self ._channel .subscribe (self ._on_dps_message )
198+
199+ def _on_dps_message (self , message : RoborockMessage ) -> None :
200+ """Handle unsolicited MQTT push (protocol 102 — RPC_RESPONSE).
201+
202+ Zeo devices broadcast status changes as ``{"dps": {...}}`` JSON
203+ payloads. This callback decodes them and feeds the cache so
204+ that ``query_values`` can skip the device round-trip when the
205+ requested DPs are already up to date.
206+ """
207+ if message .protocol != RoborockMessageProtocol .RPC_RESPONSE :
208+ return
209+ try :
210+ decoded = decode_rpc_response (message )
211+ self ._dps_cache .update (decoded )
212+ self ._notify_update ()
213+ except RoborockException :
214+ _LOGGER .debug ("Failed to decode push message, skipping: %s" , message , exc_info = True )
167215
168216 async def query_values (self , protocols : list [RoborockZeoProtocol ]) -> dict [RoborockZeoProtocol , Any ]:
169217 """Query the device for the values of the given protocols."""
@@ -172,6 +220,9 @@ async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[Robor
172220 {RoborockZeoProtocol .ID_QUERY : protocols },
173221 value_encoder = json .dumps ,
174222 )
223+ for protocol , value in response .items ():
224+ if value is not None :
225+ self ._dps_cache [int (protocol )] = value
175226 return {protocol : convert_zeo_value (protocol , response .get (protocol )) for protocol in protocols }
176227
177228 async def set_value (self , protocol : RoborockZeoProtocol , value : Any ) -> dict [RoborockZeoProtocol , Any ]:
0 commit comments