-
Notifications
You must be signed in to change notification settings - Fork 94
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
base: main
Are you sure you want to change the base?
Changes from 6 commits
0f9bad2
9077983
2d4a191
910375b
4432a62
d57b81e
169a6a9
2ccb776
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,6 +20,7 @@ | |
| """ | ||
|
|
||
| import json | ||
| import logging | ||
| from collections.abc import Callable | ||
| from datetime import time | ||
| from typing import Any | ||
|
|
@@ -40,6 +41,7 @@ | |
| ZeoDetergentType, | ||
| ZeoDryingMode, | ||
| ZeoError, | ||
| ZeoFeatureBits, | ||
| ZeoMode, | ||
| ZeoProgram, | ||
| ZeoRinse, | ||
|
|
@@ -50,8 +52,18 @@ | |
| ) | ||
| from roborock.devices.rpc.a01_channel import send_decoded_command | ||
| from roborock.devices.traits import Trait | ||
| from roborock.devices.traits.common import TraitUpdateListener | ||
| from roborock.devices.transport.mqtt_channel import MqttChannel | ||
| from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol | ||
| from roborock.exceptions import RoborockException | ||
| from roborock.protocols.a01_protocol import decode_rpc_response | ||
| from roborock.roborock_message import ( | ||
| RoborockDyadDataProtocol, | ||
| RoborockMessage, | ||
| RoborockMessageProtocol, | ||
| RoborockZeoProtocol, | ||
| ) | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
| __init__ = [ | ||
| "DyadApi", | ||
|
|
@@ -156,14 +168,74 @@ async def set_value(self, protocol: RoborockDyadDataProtocol, value: Any) -> dic | |
| return await send_decoded_command(self._channel, params) | ||
|
|
||
|
|
||
| class ZeoApi(Trait): | ||
| class ZeoApi(Trait, TraitUpdateListener): | ||
| """API for interacting with Zeo devices.""" | ||
|
|
||
| name = "zeo" | ||
|
|
||
| def __init__(self, channel: MqttChannel) -> None: | ||
| """Initialize the Zeo API.""" | ||
| TraitUpdateListener.__init__(self, _LOGGER) | ||
| self._channel = channel | ||
| self._dps_cache: dict[int, Any] = {} | ||
|
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. 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.
Contributor
Author
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. See #897
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. OK, see comments below. |
||
| self._dps_unsub: Callable[[], None] | None = None | ||
| self._feature_bits: int = 0 | ||
|
|
||
| async def start(self) -> None: | ||
| """Subscribe to MQTT push and discover device features. | ||
|
|
||
| Subscribes to the DPS MQTT topic, then queries FEATURE_BITS | ||
| (DP 237) to wake the device and cache supported capabilities. | ||
| """ | ||
| await self._ensure_subscribed() | ||
| await self._discover_features() | ||
|
|
||
| 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 | ||
|
|
||
| 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 _discover_features(self) -> None: | ||
| """Query FEATURE_BITS to wake the device and cache capabilities. | ||
|
|
||
| Sending an RPC query after subscribing triggers the device to | ||
| start pushing its full state — equivalent to how V1's | ||
| ``discover_features()`` uses ``device_features.refresh()`` to | ||
| initiate the push cycle. | ||
| """ | ||
| try: | ||
| result = await self.query_values([RoborockZeoProtocol.FEATURE_BITS]) | ||
| self._feature_bits = result.get(RoborockZeoProtocol.FEATURE_BITS, 0) | ||
| except Exception: | ||
|
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. Catch more narrow exceptions here, only what we actually expect to see raised here. If there is a transient RPC here, not clear to me it is OK to fail silently with no features, is it? what do you expect the user to do here with this device?
Contributor
Author
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. Changed Bundle also skips the FEATURE_BITS query entirely for older device series via |
||
| self._feature_bits = 0 | ||
|
|
||
| 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). | ||
|
|
||
| Zeo devices broadcast status changes as ``{"dps": {...}}`` JSON | ||
| payloads. This callback decodes them and feeds the cache so | ||
| that ``query_values`` can skip the device round-trip when the | ||
| requested DPs are already up to date. | ||
|
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. how does query values know when a DPS is up to date? This implies we should be switching to a different API model like traits. (e.g. we have a
Contributor
Author
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 |
||
| """ | ||
| if message.protocol != RoborockMessageProtocol.RPC_RESPONSE: | ||
| return | ||
| try: | ||
| decoded = decode_rpc_response(message) | ||
| self._dps_cache.update(decoded) | ||
| self._notify_update() | ||
| except RoborockException: | ||
| _LOGGER.debug("Failed to decode push message, skipping: %s", message, exc_info=True) | ||
|
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. This seems like it should just be around the
Contributor
Author
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. yes yes. fixed |
||
|
|
||
| async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[RoborockZeoProtocol, Any]: | ||
| """Query the device for the values of the given protocols.""" | ||
|
|
@@ -172,6 +244,9 @@ async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[Robor | |
| {RoborockZeoProtocol.ID_QUERY: protocols}, | ||
| value_encoder=json.dumps, | ||
| ) | ||
| for protocol, value in response.items(): | ||
| if value is not None: | ||
| self._dps_cache[int(protocol)] = value | ||
| return {protocol: convert_zeo_value(protocol, response.get(protocol)) for protocol in protocols} | ||
|
|
||
| async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[RoborockZeoProtocol, Any]: | ||
|
|
||
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.
the others are elif and this is if. can you make it the same unless there is a reason to be different?
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.
fixed