roborock.devices.traits.a01
Create traits for A01 devices.
This module provides the API implementations for A01 protocol devices, which include Dyad (Wet/Dry Vacuums) and Zeo (Washing Machines).
Using A01 APIs
A01 devices expose a single API object that handles all device interactions. This API is
available on the device instance (device.dyad or device.zeo).
The API provides these methods:
- query_values(protocols): Fetches current state for specific data points.
You must pass a list of protocol enums (e.g.
RoborockDyadDataProtocolorRoborockZeoProtocol) to request specific data. - set_value(protocol, value): Sends a command to the device to change a setting or perform an action.
- values: The latest known state, merged from query responses and unsolicited pushes in arrival order.
- add_update_listener(callback): Registers a callback invoked whenever
valueschanges; readvaluesfrom the callback to get the updated state.
The device pushes only the data points that changed, so values is the merged view
of everything seen so far. State tracking is active once the device is connected
(the device calls start() on the API, which subscribes to the MQTT topic).
1"""Create traits for A01 devices. 2 3This module provides the API implementations for A01 protocol devices, which include 4Dyad (Wet/Dry Vacuums) and Zeo (Washing Machines). 5 6Using A01 APIs 7-------------- 8A01 devices expose a single API object that handles all device interactions. This API is 9available on the device instance (`device.dyad` or `device.zeo`). 10 11The API provides these methods: 121. **query_values(protocols)**: Fetches current state for specific data points. 13 You must pass a list of protocol enums (e.g. `RoborockDyadDataProtocol` or 14 `RoborockZeoProtocol`) to request specific data. 152. **set_value(protocol, value)**: Sends a command to the device to change a setting 16 or perform an action. 173. **values**: The latest known state, merged from query responses and unsolicited 18 pushes in arrival order. 194. **add_update_listener(callback)**: Registers a callback invoked whenever `values` 20 changes; read `values` from the callback to get the updated state. 21 22The device pushes only the data points that changed, so `values` is the merged view 23of 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). 25""" 26 27import json 28import logging 29from abc import abstractmethod 30from collections.abc import Callable 31from datetime import UTC, datetime, time 32from typing import Any, Generic, TypeVar 33 34from roborock.data import DyadProductInfo, DyadSndState, HomeDataProduct, RoborockCategory 35from roborock.data.dyad.dyad_code_mappings import ( 36 DyadBrushSpeed, 37 DyadCleanMode, 38 DyadError, 39 DyadSelfCleanLevel, 40 DyadSelfCleanMode, 41 DyadSuction, 42 DyadWarmLevel, 43 DyadWaterLevel, 44 RoborockDyadStateCode, 45) 46from roborock.data.zeo.zeo_code_mappings import ( 47 ZeoDetergentType, 48 ZeoDryingMode, 49 ZeoError, 50 ZeoFeatureBits, 51 ZeoMode, 52 ZeoProgram, 53 ZeoRinse, 54 ZeoSoftenerType, 55 ZeoSpin, 56 ZeoState, 57 ZeoTemperature, 58) 59from roborock.devices.rpc.a01_channel import send_decoded_command 60from roborock.devices.traits import Trait 61from roborock.devices.traits.a01.device_feature import ( 62 build_feature_dp_list, 63 build_force_load_dp_list, 64 supports_uv_light, 65) 66from roborock.devices.traits.common import TraitUpdateListener 67from roborock.devices.transport.mqtt_channel import MqttChannel 68from roborock.exceptions import RoborockException 69from roborock.protocols.a01_protocol import decode_rpc_response 70from roborock.roborock_message import ( 71 RoborockDyadDataProtocol, 72 RoborockMessage, 73 RoborockMessageProtocol, 74 RoborockZeoProtocol, 75) 76 77_LOGGER = logging.getLogger(__name__) 78 79__all__ = [ 80 "A01Api", 81 "DyadApi", 82 "ZeoApi", 83] 84 85 86DYAD_PROTOCOL_ENTRIES: dict[RoborockDyadDataProtocol, Callable] = { 87 RoborockDyadDataProtocol.STATUS: lambda val: RoborockDyadStateCode(val).name, 88 RoborockDyadDataProtocol.SELF_CLEAN_MODE: lambda val: DyadSelfCleanMode(val).name, 89 RoborockDyadDataProtocol.SELF_CLEAN_LEVEL: lambda val: DyadSelfCleanLevel(val).name, 90 RoborockDyadDataProtocol.WARM_LEVEL: lambda val: DyadWarmLevel(val).name, 91 RoborockDyadDataProtocol.CLEAN_MODE: lambda val: DyadCleanMode(val).name, 92 RoborockDyadDataProtocol.SUCTION: lambda val: DyadSuction(val).name, 93 RoborockDyadDataProtocol.WATER_LEVEL: lambda val: DyadWaterLevel(val).name, 94 RoborockDyadDataProtocol.BRUSH_SPEED: lambda val: DyadBrushSpeed(val).name, 95 RoborockDyadDataProtocol.POWER: lambda val: int(val), 96 RoborockDyadDataProtocol.AUTO_DRY: lambda val: bool(val), 97 RoborockDyadDataProtocol.MESH_LEFT: lambda val: int(360000 - val * 60), 98 RoborockDyadDataProtocol.BRUSH_LEFT: lambda val: int(360000 - val * 60), 99 RoborockDyadDataProtocol.ERROR: lambda val: DyadError(val).name, 100 RoborockDyadDataProtocol.VOLUME_SET: lambda val: int(val), 101 RoborockDyadDataProtocol.STAND_LOCK_AUTO_RUN: lambda val: bool(val), 102 RoborockDyadDataProtocol.AUTO_DRY_MODE: lambda val: bool(val), 103 RoborockDyadDataProtocol.SILENT_DRY_DURATION: lambda val: int(val), # in minutes 104 RoborockDyadDataProtocol.SILENT_MODE: lambda val: bool(val), 105 RoborockDyadDataProtocol.SILENT_MODE_START_TIME: lambda val: time( 106 hour=int(val / 60), minute=val % 60 107 ), # in minutes since 00:00 108 RoborockDyadDataProtocol.SILENT_MODE_END_TIME: lambda val: time( 109 hour=int(val / 60), minute=val % 60 110 ), # in minutes since 00:00 111 RoborockDyadDataProtocol.RECENT_RUN_TIME: lambda val: [ 112 int(v) for v in val.split(",") 113 ], # minutes of cleaning in past few days. 114 RoborockDyadDataProtocol.TOTAL_RUN_TIME: lambda val: int(val), 115 RoborockDyadDataProtocol.SND_STATE: lambda val: DyadSndState.from_dict(val), 116 RoborockDyadDataProtocol.PRODUCT_INFO: lambda val: DyadProductInfo.from_dict(val), 117} 118 119ZEO_PROTOCOL_ENTRIES: dict[RoborockZeoProtocol, Callable] = { 120 # read-only 121 RoborockZeoProtocol.STATE: lambda val: ZeoState(val).name, 122 RoborockZeoProtocol.COUNTDOWN: lambda val: int(val), 123 RoborockZeoProtocol.WASHING_LEFT: lambda val: int(val), 124 RoborockZeoProtocol.ERROR: lambda val: ZeoError(val).name, 125 RoborockZeoProtocol.TIMES_AFTER_CLEAN: lambda val: int(val), 126 RoborockZeoProtocol.DETERGENT_EMPTY: lambda val: bool(val), 127 RoborockZeoProtocol.SOFTENER_EMPTY: lambda val: bool(val), 128 # read-write 129 RoborockZeoProtocol.MODE: lambda val: ZeoMode(val).name, 130 RoborockZeoProtocol.PROGRAM: lambda val: ZeoProgram(val).name, 131 RoborockZeoProtocol.TEMP: lambda val: ZeoTemperature(val).name, 132 RoborockZeoProtocol.RINSE_TIMES: lambda val: ZeoRinse(val).name, 133 RoborockZeoProtocol.SPIN_LEVEL: lambda val: ZeoSpin(val).name, 134 RoborockZeoProtocol.DRYING_MODE: lambda val: ZeoDryingMode(val).name, 135 RoborockZeoProtocol.DETERGENT_TYPE: lambda val: ZeoDetergentType(val).name, 136 RoborockZeoProtocol.SOFTENER_TYPE: lambda val: ZeoSoftenerType(val).name, 137 RoborockZeoProtocol.SOUND_SET: lambda val: bool(val), 138 RoborockZeoProtocol.FEATURE_BITS: lambda val: int(val), 139} 140 141 142def convert_dyad_value(protocol_value: RoborockDyadDataProtocol, value: Any) -> Any: 143 """Convert a dyad protocol value to its corresponding type.""" 144 if (converter := DYAD_PROTOCOL_ENTRIES.get(protocol_value)) is not None: 145 try: 146 return converter(value) 147 except (ValueError, TypeError): 148 return None 149 return None 150 151 152def convert_zeo_value(protocol_value: RoborockZeoProtocol, value: Any) -> Any: 153 """Convert a zeo protocol value to its corresponding type.""" 154 if (converter := ZEO_PROTOCOL_ENTRIES.get(protocol_value)) is not None: 155 try: 156 return converter(value) 157 except (ValueError, TypeError): 158 return None 159 return None 160 161 162_DYAD_PROTOCOL_VALUES = frozenset(protocol.value for protocol in RoborockDyadDataProtocol) 163_ZEO_PROTOCOL_VALUES = frozenset(protocol.value for protocol in RoborockZeoProtocol) 164 165_P = TypeVar("_P", RoborockDyadDataProtocol, RoborockZeoProtocol) 166 167 168class 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) 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 264class 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 278 279 async def query_values(self, protocols: list[RoborockDyadDataProtocol]) -> dict[RoborockDyadDataProtocol, Any]: 280 """Query the device for the values of the given Dyad protocols.""" 281 response = await send_decoded_command( 282 self._channel, 283 {RoborockDyadDataProtocol.ID_QUERY: protocols}, 284 value_encoder=json.dumps, 285 ) 286 values = {protocol: convert_dyad_value(protocol, response.get(protocol)) for protocol in protocols} 287 self._merge_query_response(values) 288 return values 289 290 async def set_value(self, protocol: RoborockDyadDataProtocol, value: Any) -> dict[RoborockDyadDataProtocol, Any]: 291 """Set a value for a specific protocol on the device.""" 292 params = {protocol: value} 293 return await send_decoded_command(self._channel, params) 294 295 async def add_listener(self, callback: Callable[[dict[RoborockDyadDataProtocol, Any]], None]) -> Callable[[], None]: 296 """Listen for state the device pushes on its own. 297 298 The callback is invoked with decoded values whenever the device sends a 299 message, including unsolicited pushes when its state changes. Only known 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. 304 """ 305 306 def on_message(message: RoborockMessage) -> None: 307 try: 308 datapoints = decode_rpc_response(message) 309 except RoborockException: 310 return 311 if values := self._decode_datapoints(datapoints): 312 callback(values) 313 314 return await self._channel.subscribe(on_message) 315 316 317class ZeoApi(A01Api[RoborockZeoProtocol]): 318 """API for interacting with Zeo devices.""" 319 320 name = "zeo" 321 322 def __init__( 323 self, channel: MqttChannel, model: str | None = None, initial_status: dict[int, Any] | None = None 324 ) -> None: 325 """Initialize the Zeo API.""" 326 self._feature_bits: int = 0 327 self._model = model 328 super().__init__(channel, initial_status) 329 330 async def start(self) -> None: 331 """Subscribe to MQTT push and trigger a full state sync. 332 333 Subscribes to the DPS MQTT topic, then performs a two-stage 334 force-load: first the base DP list (including FEATURE_BITS), 335 then a second query for the DPs gated behind each enabled feature. 336 The device responds with a complete state dump; 337 subsequent changes arrive via incremental MQTT push. 338 """ 339 await self._ensure_subscribed() 340 await self._force_load() 341 await self._load_feature_dps() 342 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 352 353 async def _force_load(self) -> None: 354 """Send ID_QUERY with the base DP list to trigger a full state push. 355 356 For devices known to lack FEATURE_BITS, the DP is excluded 357 from the query list and ``_feature_bits`` stays at 0. 358 """ 359 dp_list = build_force_load_dp_list(self._model) 360 result = await self.query_values(dp_list) 361 self._feature_bits = result.get(RoborockZeoProtocol.FEATURE_BITS, 0) 362 363 async def _load_feature_dps(self) -> None: 364 """Second-stage query for feature-gated DPs. 365 366 Called unconditionally after the first force-load; each DP is 367 independently gated: 368 369 - Feature-gated DPs are queried only when their feature bit is set 370 in FEATURE_BITS (DP 237). 371 - UV light (DP 228) is gated by :func:`supports_uv_light` (series 372 whitelist), independent of the feature bits. 373 """ 374 feature_dps: list[RoborockZeoProtocol] = [] 375 if self._feature_bits: 376 feature_dps.extend(build_feature_dp_list(self._feature_bits)) 377 if supports_uv_light(self._model): 378 feature_dps.append(RoborockZeoProtocol.UV_LIGHT) 379 if not feature_dps: 380 return 381 try: 382 await self.query_values(feature_dps) 383 except RoborockException as exc: 384 _LOGGER.warning("Feature DPS load failed (non-fatal): %s", exc) 385 386 def supports(self, feature: ZeoFeatureBits) -> bool: 387 """Check whether the device supports a given feature bit.""" 388 return bool(self._feature_bits & (1 << feature.value)) 389 390 async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[RoborockZeoProtocol, Any]: 391 """Query the device for the values of the given protocols.""" 392 response = await send_decoded_command( 393 self._channel, 394 {RoborockZeoProtocol.ID_QUERY: protocols}, 395 value_encoder=json.dumps, 396 ) 397 values = {protocol: convert_zeo_value(protocol, response.get(protocol)) for protocol in protocols} 398 self._merge_query_response(values) 399 return values 400 401 async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[RoborockZeoProtocol, Any]: 402 """Set a value for a specific protocol on the device.""" 403 params = {protocol: value} 404 return await send_decoded_command(self._channel, params, value_encoder=lambda x: x) 405 406 407def _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 418def 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) 425 match product.category: 426 case RoborockCategory.WET_DRY_VAC: 427 return DyadApi(mqtt_channel, initial_status=initial_status) 428 case RoborockCategory.WASHING_MACHINE: 429 return ZeoApi(mqtt_channel, model=product.model, initial_status=initial_status) 430 case _: 431 raise NotImplementedError(f"Unsupported category {product.category}")
169class A01Api(Trait, TraitUpdateListener, Generic[_P]): 170 """Base class for A01 device APIs with device state tracking. 171 172 Query responses and unsolicited pushes both arrive on the same MQTT topic, 173 so a single subscription merges every decoded message into `values` in 174 arrival order. Update listeners are notified whenever a value changes. 175 """ 176 177 def __init__(self, channel: MqttChannel, initial_status: dict[int, Any] | None = None) -> None: 178 """Initialize the A01 API, optionally seeding `values` from a cloud status snapshot.""" 179 TraitUpdateListener.__init__(self, _LOGGER) 180 self._channel = channel 181 self._values: dict[_P, Any] = {} 182 self._unsub: Callable[[], None] | None = None 183 self._last_message_time: datetime | None = None 184 if initial_status: 185 self._merge_values(self._decode_datapoints(initial_status)) 186 187 @property 188 def values(self) -> dict[_P, Any]: 189 """Latest known device state, merged from query responses and pushes. 190 191 The device pushes only the data points that changed, so this is the 192 merged view of everything seen so far. A protocol the device has not 193 reported yet is absent from the dictionary. 194 """ 195 return dict(self._values) 196 197 @property 198 def last_message_time(self) -> datetime | None: 199 """Time the last message was received from the device. 200 201 Updated on every decoded message, even when no value changed: idle 202 devices push an identical heartbeat, so this is the liveness signal 203 even when `values` stays the same and update listeners stay silent. 204 The initial cloud status snapshot does not count as a message. 205 """ 206 return self._last_message_time 207 208 async def start(self) -> None: 209 """Subscribe to the device state topic and start tracking `values`.""" 210 await self._ensure_subscribed() 211 212 def close(self) -> None: 213 """Unsubscribe from MQTT push and release resources.""" 214 if self._unsub is not None: 215 self._unsub() 216 self._unsub = None 217 218 async def _ensure_subscribed(self) -> None: 219 """Subscribe to MQTT DPS push (idempotent).""" 220 if self._unsub is not None: 221 return 222 self._unsub = await self._channel.subscribe(self._on_message) 223 224 @abstractmethod 225 def _decode_datapoints(self, datapoints: dict[int, Any]) -> dict[_P, Any]: 226 """Convert raw datapoints to typed values, skipping unknown codes.""" 227 228 def _on_message(self, message: RoborockMessage) -> None: 229 """Handle a message on the device topic (query response or push).""" 230 if message.protocol != RoborockMessageProtocol.RPC_RESPONSE: 231 return 232 try: 233 datapoints = decode_rpc_response(message) 234 except RoborockException: 235 _LOGGER.debug("Dropped malformed push message", exc_info=True) 236 return 237 self._last_message_time = datetime.now(UTC) 238 self._merge_values(self._decode_datapoints(datapoints)) 239 240 def _merge_query_response(self, values: dict[_P, Any]) -> None: 241 """Record a successful query response when there is no subscription. 242 243 When subscribed, the response was already merged in arrival order and 244 timestamped by `_on_message`; merging again here could overwrite a 245 push that arrived after it. 246 """ 247 if self._unsub is not None: 248 return 249 self._last_message_time = datetime.now(UTC) 250 self._merge_values(values) 251 252 def _merge_values(self, values: dict[_P, Any]) -> None: 253 """Merge decoded values into the cache and notify on change.""" 254 changed = False 255 for protocol, value in values.items(): 256 if value is None: 257 continue 258 if protocol not in self._values or self._values[protocol] != value: 259 self._values[protocol] = value 260 changed = True 261 if changed: 262 self._notify_update()
Base class for A01 device APIs with device state tracking.
Query responses and unsolicited pushes both arrive on the same MQTT topic,
so a single subscription merges every decoded message into values in
arrival order. Update listeners are notified whenever a value changes.
187 @property 188 def values(self) -> dict[_P, Any]: 189 """Latest known device state, merged from query responses and pushes. 190 191 The device pushes only the data points that changed, so this is the 192 merged view of everything seen so far. A protocol the device has not 193 reported yet is absent from the dictionary. 194 """ 195 return dict(self._values)
Latest known device state, merged from query responses and pushes.
The device pushes only the data points that changed, so this is the merged view of everything seen so far. A protocol the device has not reported yet is absent from the dictionary.
197 @property 198 def last_message_time(self) -> datetime | None: 199 """Time the last message was received from the device. 200 201 Updated on every decoded message, even when no value changed: idle 202 devices push an identical heartbeat, so this is the liveness signal 203 even when `values` stays the same and update listeners stay silent. 204 The initial cloud status snapshot does not count as a message. 205 """ 206 return self._last_message_time
Time the last message was received from the device.
Updated on every decoded message, even when no value changed: idle
devices push an identical heartbeat, so this is the liveness signal
even when values stays the same and update listeners stay silent.
The initial cloud status snapshot does not count as a message.
265class DyadApi(A01Api[RoborockDyadDataProtocol]): 266 """API for interacting with Dyad devices.""" 267 268 name = "dyad" 269 270 def _decode_datapoints(self, datapoints: dict[int, Any]) -> dict[RoborockDyadDataProtocol, Any]: 271 """Convert raw datapoints to typed values, skipping unknown codes.""" 272 values: dict[RoborockDyadDataProtocol, Any] = {} 273 for code, value in datapoints.items(): 274 if code not in _DYAD_PROTOCOL_VALUES: 275 continue 276 protocol = RoborockDyadDataProtocol(code) 277 values[protocol] = convert_dyad_value(protocol, value) 278 return values 279 280 async def query_values(self, protocols: list[RoborockDyadDataProtocol]) -> dict[RoborockDyadDataProtocol, Any]: 281 """Query the device for the values of the given Dyad protocols.""" 282 response = await send_decoded_command( 283 self._channel, 284 {RoborockDyadDataProtocol.ID_QUERY: protocols}, 285 value_encoder=json.dumps, 286 ) 287 values = {protocol: convert_dyad_value(protocol, response.get(protocol)) for protocol in protocols} 288 self._merge_query_response(values) 289 return values 290 291 async def set_value(self, protocol: RoborockDyadDataProtocol, value: Any) -> dict[RoborockDyadDataProtocol, Any]: 292 """Set a value for a specific protocol on the device.""" 293 params = {protocol: value} 294 return await send_decoded_command(self._channel, params) 295 296 async def add_listener(self, callback: Callable[[dict[RoborockDyadDataProtocol, Any]], None]) -> Callable[[], None]: 297 """Listen for state the device pushes on its own. 298 299 The callback is invoked with decoded values whenever the device sends a 300 message, including unsolicited pushes when its state changes. Only known 301 protocols are delivered. Returns a callable to remove the listener. 302 303 Prefer `add_update_listener` together with `values`, which handle the 304 merging of partial pushes for you. 305 """ 306 307 def on_message(message: RoborockMessage) -> None: 308 try: 309 datapoints = decode_rpc_response(message) 310 except RoborockException: 311 return 312 if values := self._decode_datapoints(datapoints): 313 callback(values) 314 315 return await self._channel.subscribe(on_message)
API for interacting with Dyad devices.
280 async def query_values(self, protocols: list[RoborockDyadDataProtocol]) -> dict[RoborockDyadDataProtocol, Any]: 281 """Query the device for the values of the given Dyad protocols.""" 282 response = await send_decoded_command( 283 self._channel, 284 {RoborockDyadDataProtocol.ID_QUERY: protocols}, 285 value_encoder=json.dumps, 286 ) 287 values = {protocol: convert_dyad_value(protocol, response.get(protocol)) for protocol in protocols} 288 self._merge_query_response(values) 289 return values
Query the device for the values of the given Dyad protocols.
291 async def set_value(self, protocol: RoborockDyadDataProtocol, value: Any) -> dict[RoborockDyadDataProtocol, Any]: 292 """Set a value for a specific protocol on the device.""" 293 params = {protocol: value} 294 return await send_decoded_command(self._channel, params)
Set a value for a specific protocol on the device.
296 async def add_listener(self, callback: Callable[[dict[RoborockDyadDataProtocol, Any]], None]) -> Callable[[], None]: 297 """Listen for state the device pushes on its own. 298 299 The callback is invoked with decoded values whenever the device sends a 300 message, including unsolicited pushes when its state changes. Only known 301 protocols are delivered. Returns a callable to remove the listener. 302 303 Prefer `add_update_listener` together with `values`, which handle the 304 merging of partial pushes for you. 305 """ 306 307 def on_message(message: RoborockMessage) -> None: 308 try: 309 datapoints = decode_rpc_response(message) 310 except RoborockException: 311 return 312 if values := self._decode_datapoints(datapoints): 313 callback(values) 314 315 return await self._channel.subscribe(on_message)
Listen for state the device pushes on its own.
The callback is invoked with decoded values whenever the device sends a message, including unsolicited pushes when its state changes. Only known protocols are delivered. Returns a callable to remove the listener.
Prefer add_update_listener together with values, which handle the
merging of partial pushes for you.
318class ZeoApi(A01Api[RoborockZeoProtocol]): 319 """API for interacting with Zeo devices.""" 320 321 name = "zeo" 322 323 def __init__( 324 self, channel: MqttChannel, model: str | None = None, initial_status: dict[int, Any] | None = None 325 ) -> None: 326 """Initialize the Zeo API.""" 327 self._feature_bits: int = 0 328 self._model = model 329 super().__init__(channel, initial_status) 330 331 async def start(self) -> None: 332 """Subscribe to MQTT push and trigger a full state sync. 333 334 Subscribes to the DPS MQTT topic, then performs a two-stage 335 force-load: first the base DP list (including FEATURE_BITS), 336 then a second query for the DPs gated behind each enabled feature. 337 The device responds with a complete state dump; 338 subsequent changes arrive via incremental MQTT push. 339 """ 340 await self._ensure_subscribed() 341 await self._force_load() 342 await self._load_feature_dps() 343 344 def _decode_datapoints(self, datapoints: dict[int, Any]) -> dict[RoborockZeoProtocol, Any]: 345 """Convert raw datapoints to typed values, skipping unknown codes.""" 346 values: dict[RoborockZeoProtocol, Any] = {} 347 for code, value in datapoints.items(): 348 if code not in _ZEO_PROTOCOL_VALUES: 349 continue 350 protocol = RoborockZeoProtocol(code) 351 values[protocol] = convert_zeo_value(protocol, value) 352 return values 353 354 async def _force_load(self) -> None: 355 """Send ID_QUERY with the base DP list to trigger a full state push. 356 357 For devices known to lack FEATURE_BITS, the DP is excluded 358 from the query list and ``_feature_bits`` stays at 0. 359 """ 360 dp_list = build_force_load_dp_list(self._model) 361 result = await self.query_values(dp_list) 362 self._feature_bits = result.get(RoborockZeoProtocol.FEATURE_BITS, 0) 363 364 async def _load_feature_dps(self) -> None: 365 """Second-stage query for feature-gated DPs. 366 367 Called unconditionally after the first force-load; each DP is 368 independently gated: 369 370 - Feature-gated DPs are queried only when their feature bit is set 371 in FEATURE_BITS (DP 237). 372 - UV light (DP 228) is gated by :func:`supports_uv_light` (series 373 whitelist), independent of the feature bits. 374 """ 375 feature_dps: list[RoborockZeoProtocol] = [] 376 if self._feature_bits: 377 feature_dps.extend(build_feature_dp_list(self._feature_bits)) 378 if supports_uv_light(self._model): 379 feature_dps.append(RoborockZeoProtocol.UV_LIGHT) 380 if not feature_dps: 381 return 382 try: 383 await self.query_values(feature_dps) 384 except RoborockException as exc: 385 _LOGGER.warning("Feature DPS load failed (non-fatal): %s", exc) 386 387 def supports(self, feature: ZeoFeatureBits) -> bool: 388 """Check whether the device supports a given feature bit.""" 389 return bool(self._feature_bits & (1 << feature.value)) 390 391 async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[RoborockZeoProtocol, Any]: 392 """Query the device for the values of the given protocols.""" 393 response = await send_decoded_command( 394 self._channel, 395 {RoborockZeoProtocol.ID_QUERY: protocols}, 396 value_encoder=json.dumps, 397 ) 398 values = {protocol: convert_zeo_value(protocol, response.get(protocol)) for protocol in protocols} 399 self._merge_query_response(values) 400 return values 401 402 async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[RoborockZeoProtocol, Any]: 403 """Set a value for a specific protocol on the device.""" 404 params = {protocol: value} 405 return await send_decoded_command(self._channel, params, value_encoder=lambda x: x)
API for interacting with Zeo devices.
323 def __init__( 324 self, channel: MqttChannel, model: str | None = None, initial_status: dict[int, Any] | None = None 325 ) -> None: 326 """Initialize the Zeo API.""" 327 self._feature_bits: int = 0 328 self._model = model 329 super().__init__(channel, initial_status)
Initialize the Zeo API.
331 async def start(self) -> None: 332 """Subscribe to MQTT push and trigger a full state sync. 333 334 Subscribes to the DPS MQTT topic, then performs a two-stage 335 force-load: first the base DP list (including FEATURE_BITS), 336 then a second query for the DPs gated behind each enabled feature. 337 The device responds with a complete state dump; 338 subsequent changes arrive via incremental MQTT push. 339 """ 340 await self._ensure_subscribed() 341 await self._force_load() 342 await self._load_feature_dps()
Subscribe to MQTT push and trigger a full state sync.
Subscribes to the DPS MQTT topic, then performs a two-stage force-load: first the base DP list (including FEATURE_BITS), then a second query for the DPs gated behind each enabled feature. The device responds with a complete state dump; subsequent changes arrive via incremental MQTT push.
387 def supports(self, feature: ZeoFeatureBits) -> bool: 388 """Check whether the device supports a given feature bit.""" 389 return bool(self._feature_bits & (1 << feature.value))
Check whether the device supports a given feature bit.
391 async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[RoborockZeoProtocol, Any]: 392 """Query the device for the values of the given protocols.""" 393 response = await send_decoded_command( 394 self._channel, 395 {RoborockZeoProtocol.ID_QUERY: protocols}, 396 value_encoder=json.dumps, 397 ) 398 values = {protocol: convert_zeo_value(protocol, response.get(protocol)) for protocol in protocols} 399 self._merge_query_response(values) 400 return values
Query the device for the values of the given protocols.
402 async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[RoborockZeoProtocol, Any]: 403 """Set a value for a specific protocol on the device.""" 404 params = {protocol: value} 405 return await send_decoded_command(self._channel, params, value_encoder=lambda x: x)
Set a value for a specific protocol on the device.