-
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 all commits
0f9bad2
9077983
2d4a191
910375b
4432a62
d57b81e
fa44017
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,80 @@ 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. | ||
|
|
||
| Only devices that support the FeatureBits DP will respond; | ||
| older or unsupported devices return nothing. | ||
| A failed query defaults to 0 — all feature-gated DPs are | ||
| disabled and the device operates in basic mode. | ||
| """ | ||
| try: | ||
| result = await self.query_values([RoborockZeoProtocol.FEATURE_BITS]) | ||
| self._feature_bits = result.get(RoborockZeoProtocol.FEATURE_BITS, 0) | ||
| except RoborockException: | ||
|
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. silently supporting no features on a transient error (e.g. network unreachable) is a problem. are there cases you expect this to fail that are more narrow? |
||
| 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 |
||
| """ | ||
| 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.""" | ||
|
|
@@ -172,6 +250,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?