Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 96
feat: add MQTT push subscription and real-time state tracking for Zeo devices#895
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
0f9bad290779832d4a191910375b4432a62d57b81e169a6a92ccb7762c03138d692352f3b51440f863dbfd5496dFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -22,6 +22,7 @@ | ||
| """ | ||
| import json | ||
| import logging | ||
| from collections.abc import Callable | ||
| from datetime import time | ||
| from typing import Any | ||
| @@ -42,6 +43,7 @@ | ||
| ZeoDetergentType, | ||
| ZeoDryingMode, | ||
| ZeoError, | ||
| ZeoFeatureBits, | ||
| ZeoMode, | ||
| ZeoProgram, | ||
| ZeoRinse, | ||
| @@ -52,10 +54,23 @@ | ||
| ) | ||
| from roborock.devices.rpc.a01_channel import send_decoded_command | ||
| from roborock.devices.traits import Trait | ||
| from roborock.devices.traits.a01.device_feature import ( | ||
| build_feature_dp_list, | ||
| build_force_load_dp_list, | ||
| supports_uv_light, | ||
| ) | ||
| from roborock.devices.traits.common import TraitUpdateListener | ||
| from roborock.devices.transport.mqtt_channel import MqttChannel | ||
| from roborock.exceptions import RoborockException | ||
| from roborock.protocols.a01_protocol import decode_rpc_response | ||
| from roborock.roborock_message import RoborockDyadDataProtocol, RoborockMessage, RoborockZeoProtocol | ||
| from roborock.roborock_message import ( | ||
| RoborockDyadDataProtocol, | ||
| RoborockMessage, | ||
| RoborockMessageProtocol, | ||
| RoborockZeoProtocol, | ||
| ) | ||
| _LOGGER = logging.getLogger(__name__) | ||
| __init__ = [ | ||
| "DyadApi", | ||
| @@ -115,6 +130,7 @@ | ||
| RoborockZeoProtocol.DETERGENT_TYPE: lambda val: ZeoDetergentType(val).name, | ||
| RoborockZeoProtocol.SOFTENER_TYPE: lambda val: ZeoSoftenerType(val).name, | ||
| RoborockZeoProtocol.SOUND_SET: lambda val: bool(val), | ||
| RoborockZeoProtocol.FEATURE_BITS: lambda val: int(val), | ||
| } | ||
| @@ -187,14 +203,93 @@ def on_message(message: RoborockMessage) -> None: | ||
| return await self._channel.subscribe(on_message) | ||
| class ZeoApi(Trait): | ||
| class ZeoApi(Trait, TraitUpdateListener): | ||
| """API for interacting with Zeo devices.""" | ||
| name = "zeo" | ||
| def __init__(self, channel: MqttChannel) -> None: | ||
| def __init__(self, channel: MqttChannel, model: str | None = None) -> None: | ||
| """Initialize the Zeo API.""" | ||
| TraitUpdateListener.__init__(self, _LOGGER) | ||
| self._channel = channel | ||
| self._dps_cache: dict[int, Any] = {} | ||
| self._dps_unsub: Callable[[], None] | None = None | ||
| self._feature_bits: int = 0 | ||
| self._model = model | ||
| async def start(self) -> None: | ||
| """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. | ||
| """ | ||
| await self._ensure_subscribed() | ||
| await self._force_load() | ||
| await self._load_feature_dps() | ||
| def close(self) -> None: | ||
| """Unsubscribe from MQTT push and release resources.""" | ||
| if self._dps_unsub is not None: | ||
| self._dps_unsub() | ||
| self._dps_unsub = None | ||
Comment on lines
+233
to
+237
Collaborator There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think(?) this should be called inside RoborockDevice.close() ContributorAuthor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. fixed | ||
| async def _ensure_subscribed(self) -> None: | ||
| """Subscribe to MQTT DPS push (idempotent).""" | ||
| if self._dps_unsub is not None: | ||
| return | ||
| self._dps_unsub = await self._channel.subscribe(self._on_dps_message) | ||
| async def _force_load(self) -> None: | ||
| """Send ID_QUERY with the base DP list to trigger a full state push. | ||
| For devices known to lack FEATURE_BITS, the DP is excluded | ||
| from the query list and ``_feature_bits`` stays at 0. | ||
| """ | ||
| dp_list = build_force_load_dp_list(self._model) | ||
| result = await self.query_values(dp_list) | ||
| self._feature_bits = result.get(RoborockZeoProtocol.FEATURE_BITS, 0) | ||
| async def _load_feature_dps(self) -> None: | ||
| """Second-stage query for feature-gated DPs. | ||
| Called unconditionally after the first force-load; each DP is | ||
| independently gated: | ||
| - Feature-gated DPs are queried only when their feature bit is set | ||
| in FEATURE_BITS (DP 237). | ||
| - UV light (DP 228) is gated by :func:`supports_uv_light` (series | ||
| whitelist), independent of the feature bits. | ||
| """ | ||
| feature_dps: list[RoborockZeoProtocol] = [] | ||
| if self._feature_bits: | ||
| feature_dps.extend(build_feature_dp_list(self._feature_bits)) | ||
| if supports_uv_light(self._model): | ||
| feature_dps.append(RoborockZeoProtocol.UV_LIGHT) | ||
| if not feature_dps: | ||
| return | ||
| try: | ||
| await self.query_values(feature_dps) | ||
| except RoborockException as exc: | ||
Contributor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What I was saying before with my comment is this may still fail, it wasn't clear why its Ok to proceed here without failing startup. I'm am a little confused by a few things happening:
| ||
| _LOGGER.warning("Feature DPS load failed (non-fatal): %s", exc) | ||
| def supports(self, feature: ZeoFeatureBits) -> bool: | ||
| """Check whether the device supports a given feature bit.""" | ||
| return bool(self._feature_bits & (1 << feature.value)) | ||
| def _on_dps_message(self, message: RoborockMessage) -> None: | ||
| """Handle unsolicited MQTT push (protocol 102 — RPC_RESPONSE).""" | ||
| if message.protocol != RoborockMessageProtocol.RPC_RESPONSE: | ||
| return | ||
| try: | ||
| decoded = decode_rpc_response(message) | ||
| except RoborockException: | ||
| _LOGGER.debug("Dropped malformed push message", exc_info=True) | ||
| return | ||
| self._dps_cache.update(decoded) | ||
| self._notify_update() | ||
| async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[RoborockZeoProtocol, Any]: | ||
| """Query the device for the values of the given protocols.""" | ||
| @@ -217,6 +312,6 @@ def create(product: HomeDataProduct, mqtt_channel: MqttChannel) -> DyadApi | Zeo | ||
| case RoborockCategory.WET_DRY_VAC: | ||
| return DyadApi(mqtt_channel) | ||
| case RoborockCategory.WASHING_MACHINE: | ||
| return ZeoApi(mqtt_channel) | ||
| return ZeoApi(mqtt_channel, model=product.model) | ||
| case _: | ||
| raise NotImplementedError(f"Unsupported category {product.category}") | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This appears unused in this PR. Can we explain how we expect this to be used here and what the semantics are? when it is ok to use vs when do we need to refresh, etc.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See #897
ZeoCommandTrait._get_start_params()checks the cache forMODE/PROGRAMand only issues a device query on cache miss — avoiding redundant network round-trips when values were already received via push.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, see comments below.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK i think in the future we may only want to store device features in this and not have other dps values here, but instead expose them via the traits they belong to.