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, DyadScheduleTask, DyadSndState, HomeDataProduct, RoborockCategory 35from roborock.data.dyad.dyad_code_mappings import ( 36 DyadBrushSpeed, 37 DyadCleanAssistPower, 38 DyadCleanMode, 39 DyadCleanserAmount, 40 DyadDryingMode, 41 DyadError, 42 DyadPowerwheelDirection, 43 DyadSelfCleanIntensity, 44 DyadSelfCleanLevel, 45 DyadSelfCleanMode, 46 DyadSelfCleanWaterTemp, 47 DyadSuction, 48 DyadWarmLevel, 49 DyadWaterLevel, 50 RoborockDyadStateCode, 51) 52from roborock.data.zeo.zeo_code_mappings import ( 53 ZeoDetergentType, 54 ZeoDryingMode, 55 ZeoError, 56 ZeoFeatureBits, 57 ZeoMode, 58 ZeoProgram, 59 ZeoRinse, 60 ZeoSoftenerType, 61 ZeoSpin, 62 ZeoState, 63 ZeoTemperature, 64) 65from roborock.devices.rpc.a01_channel import send_decoded_command 66from roborock.devices.traits import Trait 67from roborock.devices.traits.a01.device_feature import ( 68 build_feature_dp_list, 69 build_force_load_dp_list, 70 supports_uv_light, 71) 72from roborock.devices.traits.common import TraitUpdateListener 73from roborock.devices.transport.mqtt_channel import MqttChannel 74from roborock.exceptions import RoborockException 75from roborock.protocols.a01_protocol import decode_rpc_response 76from roborock.roborock_message import ( 77 RoborockDyadDataProtocol, 78 RoborockMessage, 79 RoborockMessageProtocol, 80 RoborockZeoProtocol, 81) 82 83_LOGGER = logging.getLogger(__name__) 84 85__all__ = [ 86 "A01Api", 87 "DyadApi", 88 "ZeoApi", 89] 90 91 92def _decode_dyad_schedule(value: str) -> list[DyadScheduleTask]: 93 """Decode the schedule data point, which is a JSON list or an empty string.""" 94 if not value: 95 return [] 96 return [DyadScheduleTask.from_dict(task) for task in json.loads(value)] 97 98 99DYAD_PROTOCOL_ENTRIES: dict[RoborockDyadDataProtocol, Callable] = { 100 RoborockDyadDataProtocol.STATUS: lambda val: RoborockDyadStateCode(val).name, 101 RoborockDyadDataProtocol.SELF_CLEAN_MODE: lambda val: DyadSelfCleanMode(val).name, 102 RoborockDyadDataProtocol.SELF_CLEAN_LEVEL: lambda val: DyadSelfCleanLevel(val).name, 103 RoborockDyadDataProtocol.WARM_LEVEL: lambda val: DyadWarmLevel(val).name, 104 RoborockDyadDataProtocol.CLEAN_MODE: lambda val: DyadCleanMode(val).name, 105 RoborockDyadDataProtocol.SUCTION: lambda val: DyadSuction(val).name, 106 RoborockDyadDataProtocol.WATER_LEVEL: lambda val: DyadWaterLevel(val).name, 107 RoborockDyadDataProtocol.BRUSH_SPEED: lambda val: DyadBrushSpeed(val).name, 108 RoborockDyadDataProtocol.POWER: lambda val: int(val), 109 RoborockDyadDataProtocol.AUTO_SELF_CLEAN_SET: lambda val: bool(val), # self-clean after docking 110 RoborockDyadDataProtocol.AUTO_DRY: lambda val: bool(val), 111 RoborockDyadDataProtocol.MESH_LEFT: lambda val: int(360000 - val * 60), 112 RoborockDyadDataProtocol.BRUSH_LEFT: lambda val: int(360000 - val * 60), 113 RoborockDyadDataProtocol.ERROR: lambda val: DyadError(val).name, 114 RoborockDyadDataProtocol.VOLUME_SET: lambda val: int(val), 115 RoborockDyadDataProtocol.STAND_LOCK_AUTO_RUN: lambda val: bool(val), 116 RoborockDyadDataProtocol.AUTO_SELF_CLEAN_SET_MODE: lambda val: DyadSelfCleanLevel(val).name, 117 RoborockDyadDataProtocol.AUTO_DRY_MODE: lambda val: bool(val), 118 RoborockDyadDataProtocol.SILENT_DRY_DURATION: lambda val: int(val), # minutes, or DyadDryingMode on F25 119 RoborockDyadDataProtocol.SILENT_MODE: lambda val: bool(val), 120 RoborockDyadDataProtocol.SILENT_MODE_START_TIME: lambda val: time( 121 hour=int(val / 60), minute=val % 60 122 ), # in minutes since 00:00 123 RoborockDyadDataProtocol.SILENT_MODE_END_TIME: lambda val: time( 124 hour=int(val / 60), minute=val % 60 125 ), # in minutes since 00:00 126 RoborockDyadDataProtocol.RECENT_RUN_TIME: lambda val: [ 127 int(v) for v in val.split(",") 128 ], # minutes of cleaning in past few days. 129 RoborockDyadDataProtocol.TOTAL_RUN_TIME: lambda val: int(val), 130 RoborockDyadDataProtocol.SET_CLEANSER_AMOUNT: lambda val: DyadCleanserAmount(val).name, 131 RoborockDyadDataProtocol.AUTO_SMART_SELF_CLEAN_SET: lambda val: bool(val), 132 RoborockDyadDataProtocol.LIGHT_SET: lambda val: bool(val), 133 RoborockDyadDataProtocol.CLEAN_ASSIST_POWER_SET: lambda val: DyadCleanAssistPower(val).name, 134 RoborockDyadDataProtocol.SELF_CLEAN_WATER_TEMP_LEVEL_SET: lambda val: DyadSelfCleanWaterTemp(val).name, 135 RoborockDyadDataProtocol.DOCK_TYPE: lambda val: int(val), 136 RoborockDyadDataProtocol.WATER_MODE_SUCTION: lambda val: DyadSuction(val).name, 137 RoborockDyadDataProtocol.WATER_MODE_BRUSH_SPEED: lambda val: DyadBrushSpeed(val).name, 138 RoborockDyadDataProtocol.CHARGE_ERROR_STATUS: lambda val: bool(val), 139 RoborockDyadDataProtocol.DRYING_RATE: lambda val: int(val), # percent 140 RoborockDyadDataProtocol.FLAT_STATUS: lambda val: bool(val), 141 RoborockDyadDataProtocol.POWERWHEEL_DIRECTION: lambda val: DyadPowerwheelDirection(val).name, 142 RoborockDyadDataProtocol.POWERWHEEL_SPEED: lambda val: int(val), 143 RoborockDyadDataProtocol.SELF_CLEAN_CUR_MODE: lambda val: DyadSelfCleanIntensity(val).name, 144 RoborockDyadDataProtocol.SELF_CLEAN_CUR_TEMP: lambda val: DyadSelfCleanWaterTemp(val).name, 145 RoborockDyadDataProtocol.DRY_CUR_MODE: lambda val: DyadDryingMode(val).name, 146 RoborockDyadDataProtocol.SCHEDULE_TASK: _decode_dyad_schedule, 147 RoborockDyadDataProtocol.SND_STATE: lambda val: DyadSndState.from_dict(val), 148 RoborockDyadDataProtocol.PRODUCT_INFO: lambda val: DyadProductInfo.from_dict(val), 149} 150 151ZEO_PROTOCOL_ENTRIES: dict[RoborockZeoProtocol, Callable] = { 152 # read-only 153 RoborockZeoProtocol.STATE: lambda val: ZeoState(val).name, 154 RoborockZeoProtocol.COUNTDOWN: lambda val: int(val), 155 RoborockZeoProtocol.WASHING_LEFT: lambda val: int(val), 156 RoborockZeoProtocol.ERROR: lambda val: ZeoError(val).name, 157 RoborockZeoProtocol.TIMES_AFTER_CLEAN: lambda val: int(val), 158 RoborockZeoProtocol.DETERGENT_EMPTY: lambda val: bool(val), 159 RoborockZeoProtocol.SOFTENER_EMPTY: lambda val: bool(val), 160 # read-write 161 RoborockZeoProtocol.MODE: lambda val: ZeoMode(val).name, 162 RoborockZeoProtocol.PROGRAM: lambda val: ZeoProgram(val).name, 163 RoborockZeoProtocol.TEMP: lambda val: ZeoTemperature(val).name, 164 RoborockZeoProtocol.RINSE_TIMES: lambda val: ZeoRinse(val).name, 165 RoborockZeoProtocol.SPIN_LEVEL: lambda val: ZeoSpin(val).name, 166 RoborockZeoProtocol.DRYING_MODE: lambda val: ZeoDryingMode(val).name, 167 RoborockZeoProtocol.DETERGENT_TYPE: lambda val: ZeoDetergentType(val).name, 168 RoborockZeoProtocol.SOFTENER_TYPE: lambda val: ZeoSoftenerType(val).name, 169 RoborockZeoProtocol.SOUND_SET: lambda val: bool(val), 170 RoborockZeoProtocol.FEATURE_BITS: lambda val: int(val), 171} 172 173 174def convert_dyad_value(protocol_value: RoborockDyadDataProtocol, value: Any) -> Any: 175 """Convert a dyad protocol value to its corresponding type.""" 176 if (converter := DYAD_PROTOCOL_ENTRIES.get(protocol_value)) is not None: 177 try: 178 return converter(value) 179 except (ValueError, TypeError): 180 return None 181 return None 182 183 184def convert_zeo_value(protocol_value: RoborockZeoProtocol, value: Any) -> Any: 185 """Convert a zeo protocol value to its corresponding type.""" 186 if (converter := ZEO_PROTOCOL_ENTRIES.get(protocol_value)) is not None: 187 try: 188 return converter(value) 189 except (ValueError, TypeError): 190 return None 191 return None 192 193 194_DYAD_PROTOCOL_VALUES = frozenset(protocol.value for protocol in RoborockDyadDataProtocol) 195_ZEO_PROTOCOL_VALUES = frozenset(protocol.value for protocol in RoborockZeoProtocol) 196 197_P = TypeVar("_P", RoborockDyadDataProtocol, RoborockZeoProtocol) 198 199 200class A01Api(Trait, TraitUpdateListener, Generic[_P]): 201 """Base class for A01 device APIs with device state tracking. 202 203 Query responses and unsolicited pushes both arrive on the same MQTT topic, 204 so a single subscription merges every decoded message into `values` in 205 arrival order. Update listeners are notified whenever a value changes. 206 """ 207 208 def __init__(self, channel: MqttChannel, initial_status: dict[int, Any] | None = None) -> None: 209 """Initialize the A01 API, optionally seeding `values` from a cloud status snapshot.""" 210 TraitUpdateListener.__init__(self, _LOGGER) 211 self._channel = channel 212 self._values: dict[_P, Any] = {} 213 self._unsub: Callable[[], None] | None = None 214 self._last_message_time: datetime | None = None 215 if initial_status: 216 self._merge_values(self._decode_datapoints(initial_status)) 217 218 @property 219 def values(self) -> dict[_P, Any]: 220 """Latest known device state, merged from query responses and pushes. 221 222 The device pushes only the data points that changed, so this is the 223 merged view of everything seen so far. A protocol the device has not 224 reported yet is absent from the dictionary. 225 """ 226 return dict(self._values) 227 228 @property 229 def last_message_time(self) -> datetime | None: 230 """Time the last message was received from the device. 231 232 Updated on every decoded message, even when no value changed: idle 233 devices push an identical heartbeat, so this is the liveness signal 234 even when `values` stays the same and update listeners stay silent. 235 The initial cloud status snapshot does not count as a message. 236 """ 237 return self._last_message_time 238 239 async def start(self) -> None: 240 """Subscribe to the device state topic and start tracking `values`.""" 241 await self._ensure_subscribed() 242 243 def close(self) -> None: 244 """Unsubscribe from MQTT push and release resources.""" 245 if self._unsub is not None: 246 self._unsub() 247 self._unsub = None 248 249 async def _ensure_subscribed(self) -> None: 250 """Subscribe to MQTT DPS push (idempotent).""" 251 if self._unsub is not None: 252 return 253 self._unsub = await self._channel.subscribe(self._on_message) 254 255 @abstractmethod 256 def _decode_datapoints(self, datapoints: dict[int, Any]) -> dict[_P, Any]: 257 """Convert raw datapoints to typed values, skipping unknown codes.""" 258 259 def _on_message(self, message: RoborockMessage) -> None: 260 """Handle a message on the device topic (query response or push).""" 261 if message.protocol != RoborockMessageProtocol.RPC_RESPONSE: 262 return 263 try: 264 datapoints = decode_rpc_response(message) 265 except RoborockException: 266 _LOGGER.debug("Dropped malformed push message", exc_info=True) 267 return 268 self._last_message_time = datetime.now(UTC) 269 self._merge_values(self._decode_datapoints(datapoints)) 270 271 def _merge_query_response(self, values: dict[_P, Any]) -> None: 272 """Record a successful query response when there is no subscription. 273 274 When subscribed, the response was already merged in arrival order and 275 timestamped by `_on_message`; merging again here could overwrite a 276 push that arrived after it. 277 """ 278 if self._unsub is not None: 279 return 280 self._last_message_time = datetime.now(UTC) 281 self._merge_values(values) 282 283 def _merge_values(self, values: dict[_P, Any]) -> None: 284 """Merge decoded values into the cache and notify on change.""" 285 changed = False 286 for protocol, value in values.items(): 287 if value is None: 288 continue 289 if protocol not in self._values or self._values[protocol] != value: 290 self._values[protocol] = value 291 changed = True 292 if changed: 293 self._notify_update() 294 295 296class DyadApi(A01Api[RoborockDyadDataProtocol]): 297 """API for interacting with Dyad devices.""" 298 299 name = "dyad" 300 301 def _decode_datapoints(self, datapoints: dict[int, Any]) -> dict[RoborockDyadDataProtocol, Any]: 302 """Convert raw datapoints to typed values, skipping unknown codes.""" 303 values: dict[RoborockDyadDataProtocol, Any] = {} 304 for code, value in datapoints.items(): 305 if code not in _DYAD_PROTOCOL_VALUES: 306 continue 307 protocol = RoborockDyadDataProtocol(code) 308 values[protocol] = convert_dyad_value(protocol, value) 309 return values 310 311 async def query_values(self, protocols: list[RoborockDyadDataProtocol]) -> dict[RoborockDyadDataProtocol, Any]: 312 """Query the device for the values of the given Dyad protocols.""" 313 response = await send_decoded_command( 314 self._channel, 315 {RoborockDyadDataProtocol.ID_QUERY: protocols}, 316 value_encoder=json.dumps, 317 ) 318 values = {protocol: convert_dyad_value(protocol, response.get(protocol)) for protocol in protocols} 319 self._merge_query_response(values) 320 return values 321 322 async def set_value(self, protocol: RoborockDyadDataProtocol, value: Any) -> dict[RoborockDyadDataProtocol, Any]: 323 """Set a value for a specific protocol on the device.""" 324 params = {protocol: value} 325 return await send_decoded_command(self._channel, params) 326 327 async def start_drying(self) -> None: 328 """Start drying with the drying mode currently set on the device. 329 330 The app sends the same command for fast and silent drying on the F25 Ultra. 331 """ 332 await send_decoded_command( 333 self._channel, 334 {RoborockDyadDataProtocol.START: 1, RoborockDyadDataProtocol.WARM_LEVEL: 1}, 335 ) 336 337 async def stop_drying(self) -> None: 338 """Stop a running drying cycle.""" 339 await send_decoded_command(self._channel, {RoborockDyadDataProtocol.START: 0}) 340 341 async def reset_filter(self) -> None: 342 """Reset the filter usage counter.""" 343 await send_decoded_command(self._channel, {RoborockDyadDataProtocol.MESH_RESET: 1}) 344 345 async def reset_brush(self) -> None: 346 """Reset the brush roller usage counter.""" 347 await send_decoded_command(self._channel, {RoborockDyadDataProtocol.BRUSH_RESET: 1}) 348 349 async def add_listener(self, callback: Callable[[dict[RoborockDyadDataProtocol, Any]], None]) -> Callable[[], None]: 350 """Listen for state the device pushes on its own. 351 352 The callback is invoked with decoded values whenever the device sends a 353 message, including unsolicited pushes when its state changes. Only known 354 protocols are delivered. Returns a callable to remove the listener. 355 356 Prefer `add_update_listener` together with `values`, which handle the 357 merging of partial pushes for you. 358 """ 359 360 def on_message(message: RoborockMessage) -> None: 361 try: 362 datapoints = decode_rpc_response(message) 363 except RoborockException: 364 return 365 if values := self._decode_datapoints(datapoints): 366 callback(values) 367 368 return await self._channel.subscribe(on_message) 369 370 371class ZeoApi(A01Api[RoborockZeoProtocol]): 372 """API for interacting with Zeo devices.""" 373 374 name = "zeo" 375 376 def __init__( 377 self, channel: MqttChannel, model: str | None = None, initial_status: dict[int, Any] | None = None 378 ) -> None: 379 """Initialize the Zeo API.""" 380 self._feature_bits: int = 0 381 self._model = model 382 super().__init__(channel, initial_status) 383 384 async def start(self) -> None: 385 """Subscribe to MQTT push and trigger a full state sync. 386 387 Subscribes to the DPS MQTT topic, then performs a two-stage 388 force-load: first the base DP list (including FEATURE_BITS), 389 then a second query for the DPs gated behind each enabled feature. 390 The device responds with a complete state dump; 391 subsequent changes arrive via incremental MQTT push. 392 """ 393 await self._ensure_subscribed() 394 await self._force_load() 395 await self._load_feature_dps() 396 397 def _decode_datapoints(self, datapoints: dict[int, Any]) -> dict[RoborockZeoProtocol, Any]: 398 """Convert raw datapoints to typed values, skipping unknown codes.""" 399 values: dict[RoborockZeoProtocol, Any] = {} 400 for code, value in datapoints.items(): 401 if code not in _ZEO_PROTOCOL_VALUES: 402 continue 403 protocol = RoborockZeoProtocol(code) 404 values[protocol] = convert_zeo_value(protocol, value) 405 return values 406 407 async def _force_load(self) -> None: 408 """Send ID_QUERY with the base DP list to trigger a full state push. 409 410 For devices known to lack FEATURE_BITS, the DP is excluded 411 from the query list and ``_feature_bits`` stays at 0. 412 """ 413 dp_list = build_force_load_dp_list(self._model) 414 result = await self.query_values(dp_list) 415 self._feature_bits = result.get(RoborockZeoProtocol.FEATURE_BITS, 0) 416 417 async def _load_feature_dps(self) -> None: 418 """Second-stage query for feature-gated DPs. 419 420 Called unconditionally after the first force-load; each DP is 421 independently gated: 422 423 - Feature-gated DPs are queried only when their feature bit is set 424 in FEATURE_BITS (DP 237). 425 - UV light (DP 228) is gated by :func:`supports_uv_light` (series 426 whitelist), independent of the feature bits. 427 """ 428 feature_dps: list[RoborockZeoProtocol] = [] 429 if self._feature_bits: 430 feature_dps.extend(build_feature_dp_list(self._feature_bits)) 431 if supports_uv_light(self._model): 432 feature_dps.append(RoborockZeoProtocol.UV_LIGHT) 433 if not feature_dps: 434 return 435 try: 436 await self.query_values(feature_dps) 437 except RoborockException as exc: 438 _LOGGER.warning("Feature DPS load failed (non-fatal): %s", exc) 439 440 def supports(self, feature: ZeoFeatureBits) -> bool: 441 """Check whether the device supports a given feature bit.""" 442 return bool(self._feature_bits & (1 << feature.value)) 443 444 async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[RoborockZeoProtocol, Any]: 445 """Query the device for the values of the given protocols.""" 446 response = await send_decoded_command( 447 self._channel, 448 {RoborockZeoProtocol.ID_QUERY: protocols}, 449 value_encoder=json.dumps, 450 ) 451 values = {protocol: convert_zeo_value(protocol, response.get(protocol)) for protocol in protocols} 452 self._merge_query_response(values) 453 return values 454 455 async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[RoborockZeoProtocol, Any]: 456 """Set a value for a specific protocol on the device.""" 457 params = {protocol: value} 458 return await send_decoded_command(self._channel, params, value_encoder=lambda x: x) 459 460 461def _parse_device_status(device_status: dict | None) -> dict[int, Any] | None: 462 """Normalize the cloud home data status snapshot to integer datapoint codes.""" 463 if not device_status: 464 return None 465 try: 466 return {int(code): value for code, value in device_status.items()} 467 except (TypeError, ValueError): 468 _LOGGER.debug("Ignoring malformed device status snapshot: %s", device_status) 469 return None 470 471 472def create(product: HomeDataProduct, mqtt_channel: MqttChannel, device_status: dict | None = None) -> DyadApi | ZeoApi: 473 """Create traits for A01 devices. 474 475 The optional `device_status` is the cloud home data status snapshot, used 476 to seed `values` so state is available before the first device round trip. 477 """ 478 initial_status = _parse_device_status(device_status) 479 match product.category: 480 case RoborockCategory.WET_DRY_VAC: 481 return DyadApi(mqtt_channel, initial_status=initial_status) 482 case RoborockCategory.WASHING_MACHINE: 483 return ZeoApi(mqtt_channel, model=product.model, initial_status=initial_status) 484 case _: 485 raise NotImplementedError(f"Unsupported category {product.category}")
201class A01Api(Trait, TraitUpdateListener, Generic[_P]): 202 """Base class for A01 device APIs with device state tracking. 203 204 Query responses and unsolicited pushes both arrive on the same MQTT topic, 205 so a single subscription merges every decoded message into `values` in 206 arrival order. Update listeners are notified whenever a value changes. 207 """ 208 209 def __init__(self, channel: MqttChannel, initial_status: dict[int, Any] | None = None) -> None: 210 """Initialize the A01 API, optionally seeding `values` from a cloud status snapshot.""" 211 TraitUpdateListener.__init__(self, _LOGGER) 212 self._channel = channel 213 self._values: dict[_P, Any] = {} 214 self._unsub: Callable[[], None] | None = None 215 self._last_message_time: datetime | None = None 216 if initial_status: 217 self._merge_values(self._decode_datapoints(initial_status)) 218 219 @property 220 def values(self) -> dict[_P, Any]: 221 """Latest known device state, merged from query responses and pushes. 222 223 The device pushes only the data points that changed, so this is the 224 merged view of everything seen so far. A protocol the device has not 225 reported yet is absent from the dictionary. 226 """ 227 return dict(self._values) 228 229 @property 230 def last_message_time(self) -> datetime | None: 231 """Time the last message was received from the device. 232 233 Updated on every decoded message, even when no value changed: idle 234 devices push an identical heartbeat, so this is the liveness signal 235 even when `values` stays the same and update listeners stay silent. 236 The initial cloud status snapshot does not count as a message. 237 """ 238 return self._last_message_time 239 240 async def start(self) -> None: 241 """Subscribe to the device state topic and start tracking `values`.""" 242 await self._ensure_subscribed() 243 244 def close(self) -> None: 245 """Unsubscribe from MQTT push and release resources.""" 246 if self._unsub is not None: 247 self._unsub() 248 self._unsub = None 249 250 async def _ensure_subscribed(self) -> None: 251 """Subscribe to MQTT DPS push (idempotent).""" 252 if self._unsub is not None: 253 return 254 self._unsub = await self._channel.subscribe(self._on_message) 255 256 @abstractmethod 257 def _decode_datapoints(self, datapoints: dict[int, Any]) -> dict[_P, Any]: 258 """Convert raw datapoints to typed values, skipping unknown codes.""" 259 260 def _on_message(self, message: RoborockMessage) -> None: 261 """Handle a message on the device topic (query response or push).""" 262 if message.protocol != RoborockMessageProtocol.RPC_RESPONSE: 263 return 264 try: 265 datapoints = decode_rpc_response(message) 266 except RoborockException: 267 _LOGGER.debug("Dropped malformed push message", exc_info=True) 268 return 269 self._last_message_time = datetime.now(UTC) 270 self._merge_values(self._decode_datapoints(datapoints)) 271 272 def _merge_query_response(self, values: dict[_P, Any]) -> None: 273 """Record a successful query response when there is no subscription. 274 275 When subscribed, the response was already merged in arrival order and 276 timestamped by `_on_message`; merging again here could overwrite a 277 push that arrived after it. 278 """ 279 if self._unsub is not None: 280 return 281 self._last_message_time = datetime.now(UTC) 282 self._merge_values(values) 283 284 def _merge_values(self, values: dict[_P, Any]) -> None: 285 """Merge decoded values into the cache and notify on change.""" 286 changed = False 287 for protocol, value in values.items(): 288 if value is None: 289 continue 290 if protocol not in self._values or self._values[protocol] != value: 291 self._values[protocol] = value 292 changed = True 293 if changed: 294 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.
219 @property 220 def values(self) -> dict[_P, Any]: 221 """Latest known device state, merged from query responses and pushes. 222 223 The device pushes only the data points that changed, so this is the 224 merged view of everything seen so far. A protocol the device has not 225 reported yet is absent from the dictionary. 226 """ 227 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.
229 @property 230 def last_message_time(self) -> datetime | None: 231 """Time the last message was received from the device. 232 233 Updated on every decoded message, even when no value changed: idle 234 devices push an identical heartbeat, so this is the liveness signal 235 even when `values` stays the same and update listeners stay silent. 236 The initial cloud status snapshot does not count as a message. 237 """ 238 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.
297class DyadApi(A01Api[RoborockDyadDataProtocol]): 298 """API for interacting with Dyad devices.""" 299 300 name = "dyad" 301 302 def _decode_datapoints(self, datapoints: dict[int, Any]) -> dict[RoborockDyadDataProtocol, Any]: 303 """Convert raw datapoints to typed values, skipping unknown codes.""" 304 values: dict[RoborockDyadDataProtocol, Any] = {} 305 for code, value in datapoints.items(): 306 if code not in _DYAD_PROTOCOL_VALUES: 307 continue 308 protocol = RoborockDyadDataProtocol(code) 309 values[protocol] = convert_dyad_value(protocol, value) 310 return values 311 312 async def query_values(self, protocols: list[RoborockDyadDataProtocol]) -> dict[RoborockDyadDataProtocol, Any]: 313 """Query the device for the values of the given Dyad protocols.""" 314 response = await send_decoded_command( 315 self._channel, 316 {RoborockDyadDataProtocol.ID_QUERY: protocols}, 317 value_encoder=json.dumps, 318 ) 319 values = {protocol: convert_dyad_value(protocol, response.get(protocol)) for protocol in protocols} 320 self._merge_query_response(values) 321 return values 322 323 async def set_value(self, protocol: RoborockDyadDataProtocol, value: Any) -> dict[RoborockDyadDataProtocol, Any]: 324 """Set a value for a specific protocol on the device.""" 325 params = {protocol: value} 326 return await send_decoded_command(self._channel, params) 327 328 async def start_drying(self) -> None: 329 """Start drying with the drying mode currently set on the device. 330 331 The app sends the same command for fast and silent drying on the F25 Ultra. 332 """ 333 await send_decoded_command( 334 self._channel, 335 {RoborockDyadDataProtocol.START: 1, RoborockDyadDataProtocol.WARM_LEVEL: 1}, 336 ) 337 338 async def stop_drying(self) -> None: 339 """Stop a running drying cycle.""" 340 await send_decoded_command(self._channel, {RoborockDyadDataProtocol.START: 0}) 341 342 async def reset_filter(self) -> None: 343 """Reset the filter usage counter.""" 344 await send_decoded_command(self._channel, {RoborockDyadDataProtocol.MESH_RESET: 1}) 345 346 async def reset_brush(self) -> None: 347 """Reset the brush roller usage counter.""" 348 await send_decoded_command(self._channel, {RoborockDyadDataProtocol.BRUSH_RESET: 1}) 349 350 async def add_listener(self, callback: Callable[[dict[RoborockDyadDataProtocol, Any]], None]) -> Callable[[], None]: 351 """Listen for state the device pushes on its own. 352 353 The callback is invoked with decoded values whenever the device sends a 354 message, including unsolicited pushes when its state changes. Only known 355 protocols are delivered. Returns a callable to remove the listener. 356 357 Prefer `add_update_listener` together with `values`, which handle the 358 merging of partial pushes for you. 359 """ 360 361 def on_message(message: RoborockMessage) -> None: 362 try: 363 datapoints = decode_rpc_response(message) 364 except RoborockException: 365 return 366 if values := self._decode_datapoints(datapoints): 367 callback(values) 368 369 return await self._channel.subscribe(on_message)
API for interacting with Dyad devices.
312 async def query_values(self, protocols: list[RoborockDyadDataProtocol]) -> dict[RoborockDyadDataProtocol, Any]: 313 """Query the device for the values of the given Dyad protocols.""" 314 response = await send_decoded_command( 315 self._channel, 316 {RoborockDyadDataProtocol.ID_QUERY: protocols}, 317 value_encoder=json.dumps, 318 ) 319 values = {protocol: convert_dyad_value(protocol, response.get(protocol)) for protocol in protocols} 320 self._merge_query_response(values) 321 return values
Query the device for the values of the given Dyad protocols.
323 async def set_value(self, protocol: RoborockDyadDataProtocol, value: Any) -> dict[RoborockDyadDataProtocol, Any]: 324 """Set a value for a specific protocol on the device.""" 325 params = {protocol: value} 326 return await send_decoded_command(self._channel, params)
Set a value for a specific protocol on the device.
328 async def start_drying(self) -> None: 329 """Start drying with the drying mode currently set on the device. 330 331 The app sends the same command for fast and silent drying on the F25 Ultra. 332 """ 333 await send_decoded_command( 334 self._channel, 335 {RoborockDyadDataProtocol.START: 1, RoborockDyadDataProtocol.WARM_LEVEL: 1}, 336 )
Start drying with the drying mode currently set on the device.
The app sends the same command for fast and silent drying on the F25 Ultra.
338 async def stop_drying(self) -> None: 339 """Stop a running drying cycle.""" 340 await send_decoded_command(self._channel, {RoborockDyadDataProtocol.START: 0})
Stop a running drying cycle.
342 async def reset_filter(self) -> None: 343 """Reset the filter usage counter.""" 344 await send_decoded_command(self._channel, {RoborockDyadDataProtocol.MESH_RESET: 1})
Reset the filter usage counter.
346 async def reset_brush(self) -> None: 347 """Reset the brush roller usage counter.""" 348 await send_decoded_command(self._channel, {RoborockDyadDataProtocol.BRUSH_RESET: 1})
Reset the brush roller usage counter.
350 async def add_listener(self, callback: Callable[[dict[RoborockDyadDataProtocol, Any]], None]) -> Callable[[], None]: 351 """Listen for state the device pushes on its own. 352 353 The callback is invoked with decoded values whenever the device sends a 354 message, including unsolicited pushes when its state changes. Only known 355 protocols are delivered. Returns a callable to remove the listener. 356 357 Prefer `add_update_listener` together with `values`, which handle the 358 merging of partial pushes for you. 359 """ 360 361 def on_message(message: RoborockMessage) -> None: 362 try: 363 datapoints = decode_rpc_response(message) 364 except RoborockException: 365 return 366 if values := self._decode_datapoints(datapoints): 367 callback(values) 368 369 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.
372class ZeoApi(A01Api[RoborockZeoProtocol]): 373 """API for interacting with Zeo devices.""" 374 375 name = "zeo" 376 377 def __init__( 378 self, channel: MqttChannel, model: str | None = None, initial_status: dict[int, Any] | None = None 379 ) -> None: 380 """Initialize the Zeo API.""" 381 self._feature_bits: int = 0 382 self._model = model 383 super().__init__(channel, initial_status) 384 385 async def start(self) -> None: 386 """Subscribe to MQTT push and trigger a full state sync. 387 388 Subscribes to the DPS MQTT topic, then performs a two-stage 389 force-load: first the base DP list (including FEATURE_BITS), 390 then a second query for the DPs gated behind each enabled feature. 391 The device responds with a complete state dump; 392 subsequent changes arrive via incremental MQTT push. 393 """ 394 await self._ensure_subscribed() 395 await self._force_load() 396 await self._load_feature_dps() 397 398 def _decode_datapoints(self, datapoints: dict[int, Any]) -> dict[RoborockZeoProtocol, Any]: 399 """Convert raw datapoints to typed values, skipping unknown codes.""" 400 values: dict[RoborockZeoProtocol, Any] = {} 401 for code, value in datapoints.items(): 402 if code not in _ZEO_PROTOCOL_VALUES: 403 continue 404 protocol = RoborockZeoProtocol(code) 405 values[protocol] = convert_zeo_value(protocol, value) 406 return values 407 408 async def _force_load(self) -> None: 409 """Send ID_QUERY with the base DP list to trigger a full state push. 410 411 For devices known to lack FEATURE_BITS, the DP is excluded 412 from the query list and ``_feature_bits`` stays at 0. 413 """ 414 dp_list = build_force_load_dp_list(self._model) 415 result = await self.query_values(dp_list) 416 self._feature_bits = result.get(RoborockZeoProtocol.FEATURE_BITS, 0) 417 418 async def _load_feature_dps(self) -> None: 419 """Second-stage query for feature-gated DPs. 420 421 Called unconditionally after the first force-load; each DP is 422 independently gated: 423 424 - Feature-gated DPs are queried only when their feature bit is set 425 in FEATURE_BITS (DP 237). 426 - UV light (DP 228) is gated by :func:`supports_uv_light` (series 427 whitelist), independent of the feature bits. 428 """ 429 feature_dps: list[RoborockZeoProtocol] = [] 430 if self._feature_bits: 431 feature_dps.extend(build_feature_dp_list(self._feature_bits)) 432 if supports_uv_light(self._model): 433 feature_dps.append(RoborockZeoProtocol.UV_LIGHT) 434 if not feature_dps: 435 return 436 try: 437 await self.query_values(feature_dps) 438 except RoborockException as exc: 439 _LOGGER.warning("Feature DPS load failed (non-fatal): %s", exc) 440 441 def supports(self, feature: ZeoFeatureBits) -> bool: 442 """Check whether the device supports a given feature bit.""" 443 return bool(self._feature_bits & (1 << feature.value)) 444 445 async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[RoborockZeoProtocol, Any]: 446 """Query the device for the values of the given protocols.""" 447 response = await send_decoded_command( 448 self._channel, 449 {RoborockZeoProtocol.ID_QUERY: protocols}, 450 value_encoder=json.dumps, 451 ) 452 values = {protocol: convert_zeo_value(protocol, response.get(protocol)) for protocol in protocols} 453 self._merge_query_response(values) 454 return values 455 456 async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[RoborockZeoProtocol, Any]: 457 """Set a value for a specific protocol on the device.""" 458 params = {protocol: value} 459 return await send_decoded_command(self._channel, params, value_encoder=lambda x: x)
API for interacting with Zeo devices.
377 def __init__( 378 self, channel: MqttChannel, model: str | None = None, initial_status: dict[int, Any] | None = None 379 ) -> None: 380 """Initialize the Zeo API.""" 381 self._feature_bits: int = 0 382 self._model = model 383 super().__init__(channel, initial_status)
Initialize the Zeo API.
385 async def start(self) -> None: 386 """Subscribe to MQTT push and trigger a full state sync. 387 388 Subscribes to the DPS MQTT topic, then performs a two-stage 389 force-load: first the base DP list (including FEATURE_BITS), 390 then a second query for the DPs gated behind each enabled feature. 391 The device responds with a complete state dump; 392 subsequent changes arrive via incremental MQTT push. 393 """ 394 await self._ensure_subscribed() 395 await self._force_load() 396 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.
441 def supports(self, feature: ZeoFeatureBits) -> bool: 442 """Check whether the device supports a given feature bit.""" 443 return bool(self._feature_bits & (1 << feature.value))
Check whether the device supports a given feature bit.
445 async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[RoborockZeoProtocol, Any]: 446 """Query the device for the values of the given protocols.""" 447 response = await send_decoded_command( 448 self._channel, 449 {RoborockZeoProtocol.ID_QUERY: protocols}, 450 value_encoder=json.dumps, 451 ) 452 values = {protocol: convert_zeo_value(protocol, response.get(protocol)) for protocol in protocols} 453 self._merge_query_response(values) 454 return values
Query the device for the values of the given protocols.
456 async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[RoborockZeoProtocol, Any]: 457 """Set a value for a specific protocol on the device.""" 458 params = {protocol: value} 459 return await send_decoded_command(self._channel, params, value_encoder=lambda x: x)
Set a value for a specific protocol on the device.