Skip to content

Commit 910375b

Browse files
author
NOisi-X
committed
feat(zeo): add MQTT push subscription with DPS cache and feature discovery
Subscribes to the device DPS MQTT topic after connection. Incoming RPC_RESPONSE messages are decoded and merged into _dps_cache with incremental updates. _discover_features() queries FEATURE_BITS (DP 237) to wake the device and cache capabilities — equivalent to V1's discover_features(). Also fixes TraitUpdateListener init in ZeoApi and a01_properties routing in connect().
1 parent 2d4a191 commit 910375b

5 files changed

Lines changed: 107 additions & 10 deletions

File tree

roborock/devices/device.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,8 @@ async def connect(self) -> None:
202202
await self.v1_properties.start()
203203
elif self.b01_q10_properties is not None:
204204
await self.b01_q10_properties.start()
205+
if self.zeo is not None:
206+
await self.zeo.start()
205207
except RoborockException:
206208
# Expected: start() can fail transiently. Unsubscribe before propagating
207209
# so the retry by connect_loop() gets a clean channel.

roborock/devices/traits/a01/__init__.py

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"""
2121

2222
import json
23+
import logging
2324
from collections.abc import Callable
2425
from datetime import time
2526
from typing import Any
@@ -40,6 +41,7 @@
4041
ZeoDetergentType,
4142
ZeoDryingMode,
4243
ZeoError,
44+
ZeoFeatureBits,
4345
ZeoMode,
4446
ZeoProgram,
4547
ZeoRinse,
@@ -50,8 +52,18 @@
5052
)
5153
from roborock.devices.rpc.a01_channel import send_decoded_command
5254
from roborock.devices.traits import Trait
55+
from roborock.devices.traits.common import TraitUpdateListener
5356
from roborock.devices.transport.mqtt_channel import MqttChannel
54-
from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol
57+
from roborock.exceptions import RoborockException
58+
from roborock.protocols.a01_protocol import decode_rpc_response
59+
from roborock.roborock_message import (
60+
RoborockDyadDataProtocol,
61+
RoborockMessage,
62+
RoborockMessageProtocol,
63+
RoborockZeoProtocol,
64+
)
65+
66+
_LOGGER = logging.getLogger(__name__)
5567

5668
__init__ = [
5769
"DyadApi",
@@ -156,14 +168,74 @@ async def set_value(self, protocol: RoborockDyadDataProtocol, value: Any) -> dic
156168
return await send_decoded_command(self._channel, params)
157169

158170

159-
class ZeoApi(Trait):
171+
class ZeoApi(Trait, TraitUpdateListener):
160172
"""API for interacting with Zeo devices."""
161173

162174
name = "zeo"
163175

164176
def __init__(self, channel: MqttChannel) -> None:
165177
"""Initialize the Zeo API."""
178+
TraitUpdateListener.__init__(self, _LOGGER)
166179
self._channel = channel
180+
self._dps_cache: dict[int, Any] = {}
181+
self._dps_unsub: Callable[[], None] | None = None
182+
self._feature_bits: int = 0
183+
184+
async def start(self) -> None:
185+
"""Subscribe to MQTT push and discover device features.
186+
187+
Subscribes to the DPS MQTT topic, then queries FEATURE_BITS
188+
(DP 237) to wake the device and cache supported capabilities.
189+
"""
190+
await self._ensure_subscribed()
191+
await self._discover_features()
192+
193+
def close(self) -> None:
194+
"""Unsubscribe from MQTT push and release resources."""
195+
if self._dps_unsub is not None:
196+
self._dps_unsub()
197+
self._dps_unsub = None
198+
199+
async def _ensure_subscribed(self) -> None:
200+
"""Subscribe to MQTT DPS push (idempotent)."""
201+
if self._dps_unsub is not None:
202+
return
203+
self._dps_unsub = await self._channel.subscribe(self._on_dps_message)
204+
205+
async def _discover_features(self) -> None:
206+
"""Query FEATURE_BITS to wake the device and cache capabilities.
207+
208+
Sending an RPC query after subscribing triggers the device to
209+
start pushing its full state — equivalent to how V1's
210+
``discover_features()`` uses ``device_features.refresh()`` to
211+
initiate the push cycle.
212+
"""
213+
try:
214+
result = await self.query_values([RoborockZeoProtocol.FEATURE_BITS])
215+
self._feature_bits = result.get(RoborockZeoProtocol.FEATURE_BITS, 0)
216+
except Exception:
217+
self._feature_bits = 0
218+
219+
def supports(self, feature: ZeoFeatureBits) -> bool:
220+
"""Check whether the device supports a given feature bit."""
221+
return bool(self._feature_bits & (1 << feature.value))
222+
223+
def _on_dps_message(self, message: RoborockMessage) -> None:
224+
"""Handle unsolicited MQTT push (protocol 102 — RPC_RESPONSE).
225+
226+
Zeo devices broadcast status changes as ``{"dps": {...}}`` JSON
227+
payloads. This callback decodes them and feeds the cache so
228+
that ``query_values`` can skip the device round-trip when the
229+
requested DPs are already up to date.
230+
"""
231+
if message.protocol != RoborockMessageProtocol.RPC_RESPONSE:
232+
return
233+
try:
234+
decoded = decode_rpc_response(message)
235+
self._dps_cache.update(decoded)
236+
self._notify_update()
237+
except RoborockException:
238+
_LOGGER.debug("Failed to decode push message, skipping: %s", message, exc_info=True)
167239

168240
async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[RoborockZeoProtocol, Any]:
169241
"""Query the device for the values of the given protocols."""
@@ -172,6 +244,9 @@ async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[Robor
172244
{RoborockZeoProtocol.ID_QUERY: protocols},
173245
value_encoder=json.dumps,
174246
)
247+
for protocol, value in response.items():
248+
if value is not None:
249+
self._dps_cache[int(protocol)] = value
175250
return {protocol: convert_zeo_value(protocol, response.get(protocol)) for protocol in protocols}
176251

177252
async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[RoborockZeoProtocol, Any]:

roborock/roborock_message.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,9 @@ class RoborockZeoProtocol(RoborockEnum):
179179
SILENT_MODE_ON = 240 # rw [independent] use set_silent_mode() for bundled set
180180
SILENT_MODE_START_TIME = 241 # rw [independent] minute-of-day
181181
SILENT_MODE_END_TIME = 242 # rw [independent] minute-of-day
182+
UNKNOWN_243 = (
183+
243 # unknown, not found in plugin bundle; present in MQTT push from some devices, increments with each push
184+
)
182185
DRY_CARE_MODE = 244 # rw [startWith]
183186
SOFTENER_EXPANSION_TYPE = 245 # rw [independent]
184187
SMILE_LIGHT_STATUS = 247 # rw [independent]

tests/e2e/__snapshots__/test_device_manager.ambr

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,32 @@
1616
00000000 30 6a 00 20 72 72 2f 6d 2f 69 2f 75 73 65 72 31 |0j. rr/m/i/user1|
1717
00000010 32 33 2f 31 39 36 34 38 66 39 34 2f 7a 65 6f 5f |23/19648f94/zeo_|
1818
00000020 64 75 69 64 00 41 30 31 00 00 23 82 00 00 23 83 |duid.A01..#...#.|
19-
00000030 68 a6 a2 24 00 65 00 30 c5 de 2b f6 a9 ba 32 7e |h..$.e.0..+...2~|
20-
00000040 6b 73 82 bb d8 67 d4 db 7d 80 60 67 80 96 b8 a1 |ks...g..}.`g....|
21-
00000050 c6 bc 9e d2 da 07 fb d3 79 f5 6f 6d 04 9c 71 00 |........y.om..q.|
22-
00000060 48 66 3d 7e 5d fe d3 df 18 e4 26 38 |Hf=~].....&8|
19+
00000030 68 a6 a2 25 00 65 00 30 c5 de 2b f6 a9 ba 32 7e |h..%.e.0..+...2~|
20+
00000040 6b 73 82 bb d8 67 d4 db 7d a3 e2 16 16 7e 83 5f |ks...g..}....~._|
21+
00000050 bb 3d 2b 79 6b d0 52 9b 60 17 2d f4 06 3b a8 66 |.=+yk.R.`.-..;.f|
22+
00000060 f7 20 c0 6a c2 94 1e 84 f8 91 41 67 |. .j......Ag|
2323
[mqtt <]
2424
00000000 30 5e 00 20 72 72 2f 6d 2f 6f 2f 75 73 65 72 31 |0^. rr/m/o/user1|
2525
00000010 32 33 2f 31 39 36 34 38 66 39 34 2f 7a 65 6f 5f |23/19648f94/zeo_|
2626
00000020 64 75 69 64 00 00 00 00 37 41 30 31 00 00 00 00 |duid....7A01....|
27-
00000030 00 00 00 17 68 a6 a2 23 00 66 00 20 c6 d0 06 0c |....h..#.f. ....|
27+
00000030 00 00 00 17 68 a6 a2 23 00 66 00 20 fe 9c 2a 27 |....h..#.f. ..*'|
28+
00000040 da c4 6b 9f 0e cf 2c 56 ba 5b e6 99 1f a1 29 78 |..k...,V.[....)x|
29+
00000050 37 37 42 66 bb d5 70 0e d4 c0 a7 d1 7f ef 8b 33 |77Bf..p........3|
30+
[mqtt >]
31+
00000000 30 6a 00 20 72 72 2f 6d 2f 69 2f 75 73 65 72 31 |0j. rr/m/i/user1|
32+
00000010 32 33 2f 31 39 36 34 38 66 39 34 2f 7a 65 6f 5f |23/19648f94/zeo_|
33+
00000020 64 75 69 64 00 41 30 31 00 00 23 84 00 00 23 85 |duid.A01..#...#.|
34+
00000030 68 a6 a2 26 00 65 00 30 00 5e b7 20 93 7b 53 a7 |h..&.e.0.^. .{S.|
35+
00000040 f9 47 d4 53 49 51 cd 59 c7 92 65 09 f3 7d 20 58 |.G.SIQ.Y..e..} X|
36+
00000050 c6 9e 25 54 cd 4f ab c5 fc 48 0e 67 4a 97 28 59 |..%T.O...H.gJ.(Y|
37+
00000060 14 a6 c6 77 39 ab 2a ef 77 d9 9d 63 |...w9.*.w..c|
38+
[mqtt <]
39+
00000000 30 5e 00 20 72 72 2f 6d 2f 6f 2f 75 73 65 72 31 |0^. rr/m/o/user1|
40+
00000010 32 33 2f 31 39 36 34 38 66 39 34 2f 7a 65 6f 5f |23/19648f94/zeo_|
41+
00000020 64 75 69 64 00 00 00 00 37 41 30 31 00 00 00 00 |duid....7A01....|
42+
00000030 00 00 00 17 68 a6 a2 24 00 66 00 20 c6 d0 06 0c |....h..$.f. ....|
2843
00000040 04 eb 86 8c 96 8c 51 45 4f 8e 96 93 9e 3d de 35 |......QEO....=.5|
29-
00000050 bb a3 92 cf 68 49 69 ba 83 25 cc 5d 77 e8 62 8a |....hIi..%.]w.b.|
44+
00000050 bb a3 92 cf 68 49 69 ba 83 25 cc 5d 43 da 0b 55 |....hIi..%.]C..U|
3045
# ---
3146
# name: test_l01_device
3247
[mqtt >]

tests/e2e/test_device_manager.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -527,8 +527,10 @@ async def test_a01_device(
527527
test_topic = TEST_TOPIC_FORMAT.format(duid="zeo_duid")
528528
mqtt_responses: list[bytes] = [
529529
*MQTT_DEFAULT_RESPONSES,
530-
# ACK the Query state call sent below. id is deterministic based on deterministic_message_fixtures
531-
mqtt_packet.gen_publish(test_topic, mid=2, payload=response_builder.build_a01_rpc({"203": 6})),
530+
# ACK the FEATURE_BITS query sent by _discover_features()
531+
mqtt_packet.gen_publish(test_topic, mid=2, payload=response_builder.build_a01_rpc({"237": 1})),
532+
# ACK the Query state call sent below
533+
mqtt_packet.gen_publish(test_topic, mid=3, payload=response_builder.build_a01_rpc({"203": 6})),
532534
]
533535
for response in mqtt_responses:
534536
push_mqtt_response(response)

0 commit comments

Comments
 (0)