2020"""
2121
2222import json
23+ import logging
2324from collections .abc import Callable
2425from datetime import time
2526from typing import Any
4041 ZeoDetergentType ,
4142 ZeoDryingMode ,
4243 ZeoError ,
44+ ZeoFeatureBits ,
4345 ZeoMode ,
4446 ZeoProgram ,
4547 ZeoRinse ,
5052)
5153from roborock .devices .rpc .a01_channel import send_decoded_command
5254from roborock .devices .traits import Trait
55+ from roborock .devices .traits .common import TraitUpdateListener
5356from roborock .devices .transport .mqtt_channel import MqttChannel
54- from roborock .roborock_message import RoborockDyadDataProtocol , RoborockZeoProtocol
57+ from roborock .exceptions import RoborockException
58+ from roborock .protocols .a01_protocol import decode_rpc_response
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,74 @@ 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+ self ._feature_bits : int = 0
183+
184+ async def start (self ) -> None :
185+ """Subscribe to MQTT push and discover device features.
186+
187+ Subscribes to the DPS MQTT topic, then queries FEATURE_BITS
188+ (DP 237) to wake the device and cache supported capabilities.
189+ """
190+ await self ._ensure_subscribed ()
191+ await self ._discover_features ()
192+
193+ def close (self ) -> None :
194+ """Unsubscribe from MQTT push and release resources."""
195+ if self ._dps_unsub is not None :
196+ self ._dps_unsub ()
197+ self ._dps_unsub = None
198+
199+ async def _ensure_subscribed (self ) -> None :
200+ """Subscribe to MQTT DPS push (idempotent)."""
201+ if self ._dps_unsub is not None :
202+ return
203+ self ._dps_unsub = await self ._channel .subscribe (self ._on_dps_message )
204+
205+ async def _discover_features (self ) -> None :
206+ """Query FEATURE_BITS to wake the device and cache capabilities.
207+
208+ Sending an RPC query after subscribing triggers the device to
209+ start pushing its full state — equivalent to how V1's
210+ ``discover_features()`` uses ``device_features.refresh()`` to
211+ initiate the push cycle.
212+ """
213+ try :
214+ result = await self .query_values ([RoborockZeoProtocol .FEATURE_BITS ])
215+ self ._feature_bits = result .get (RoborockZeoProtocol .FEATURE_BITS , 0 )
216+ except Exception :
217+ self ._feature_bits = 0
218+
219+ def supports (self , feature : ZeoFeatureBits ) -> bool :
220+ """Check whether the device supports a given feature bit."""
221+ return bool (self ._feature_bits & (1 << feature .value ))
222+
223+ def _on_dps_message (self , message : RoborockMessage ) -> None :
224+ """Handle unsolicited MQTT push (protocol 102 — RPC_RESPONSE).
225+
226+ Zeo devices broadcast status changes as ``{"dps": {...}}`` JSON
227+ payloads. This callback decodes them and feeds the cache so
228+ that ``query_values`` can skip the device round-trip when the
229+ requested DPs are already up to date.
230+ """
231+ if message .protocol != RoborockMessageProtocol .RPC_RESPONSE :
232+ return
233+ try :
234+ decoded = decode_rpc_response (message )
235+ self ._dps_cache .update (decoded )
236+ self ._notify_update ()
237+ except RoborockException :
238+ _LOGGER .debug ("Failed to decode push message, skipping: %s" , message , exc_info = True )
167239
168240 async def query_values (self , protocols : list [RoborockZeoProtocol ]) -> dict [RoborockZeoProtocol , Any ]:
169241 """Query the device for the values of the given protocols."""
@@ -172,6 +244,9 @@ async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[Robor
172244 {RoborockZeoProtocol .ID_QUERY : protocols },
173245 value_encoder = json .dumps ,
174246 )
247+ for protocol , value in response .items ():
248+ if value is not None :
249+ self ._dps_cache [int (protocol )] = value
175250 return {protocol : convert_zeo_value (protocol , response .get (protocol )) for protocol in protocols }
176251
177252 async def set_value (self , protocol : RoborockZeoProtocol , value : Any ) -> dict [RoborockZeoProtocol , Any ]:
0 commit comments