Skip to content

Commit 4eccebf

Browse files
author
NOisi-X
committed
feat(zeo): add MQTT push subscription and DPS cache with command trait
Subscribe to protocol-102 push messages on device connect, decode via decode_rpc_response, and feed _dps_cache. Add ZeoCommandTrait with start_program() (combine START+MODE+PROGRAM params, QoS 1) and resume(). _get_start_params reads from MQTT-push cache, falling back to ID_QUERY for MODE/PROGRAM on cache miss. Add ZeoFeatureTrait and ZeoFeatures for DP 237 capability discovery. DPS cache shared between ZeoApi and ZeoCommandTrait via reference.
1 parent f197e5d commit 4eccebf

5 files changed

Lines changed: 435 additions & 18 deletions

File tree

roborock/data/zeo/zeo_containers.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,31 @@
1919

2020
@dataclass
2121
class ZeoStartParams(RoborockBase):
22-
"""Parameters that must be bundled with a START command.
22+
"""All parameters that may be bundled with a START command.
2323
24-
All Zeo devices require ``mode`` and ``program`` to be sent together
25-
with the start signal. The remaining fields are optional and only
26-
included when the device reports a non-None value.
24+
``mode`` and ``program`` are mandatory for every device. Every other
25+
field is optional — when ``None`` it is simply omitted from the MQTT
26+
payload, so the same superset works for washers and dryers alike.
2727
"""
2828

2929
mode: ZeoMode
3030
program: ZeoProgram
31+
32+
# Washer
3133
temperature: ZeoTemperature | None = None
3234
rinse: ZeoRinse | None = None
3335
spin: ZeoSpin | None = None
3436
drying_mode: ZeoDryingMode | None = None
3537

38+
# Dryer
39+
drying_method: ZeoDryingMethod | None = None
40+
steam_volume: ZeoSteamVolume | None = None
41+
total_time: int | None = None
42+
43+
# Optional across both device families
44+
soak: ZeoSoak | None = None
45+
dry_and_care: ZeoDryAndCare | None = None
46+
3647

3748
# ── DP 222 (LoadCloudProgram) bitfield decoder ──────────────────────────
3849
# The official app packs all custom-program parameters into a single

roborock/devices/traits/a01/__init__.py

Lines changed: 87 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,16 +38,24 @@
3838
RoborockDyadStateCode,
3939
)
4040
from roborock.data.zeo.zeo_code_mappings import (
41+
ZeoDetergentExpansionType,
4142
ZeoDetergentType,
43+
ZeoDirtDetectionStatus,
44+
ZeoDryAndCare,
45+
ZeoDryerStartError,
46+
ZeoDryingMethod,
4247
ZeoDryingMode,
4348
ZeoError,
4449
ZeoFeatureBits,
4550
ZeoMode,
4651
ZeoProgram,
4752
ZeoRinse,
53+
ZeoSoak,
54+
ZeoSoftenerExpansionType,
4855
ZeoSoftenerType,
4956
ZeoSpin,
5057
ZeoState,
58+
ZeoSteamVolume,
5159
ZeoTemperature,
5260
)
5361
from roborock.devices.rpc.a01_channel import send_decoded_command
@@ -63,11 +71,16 @@
6371
RoborockZeoProtocol,
6472
)
6573

74+
from .command import ZeoCommandTrait # noqa: F401 — re‑export
75+
from .device_features import ZeoFeatures, ZeoFeatureTrait # noqa: F401 — re‑export
76+
6677
_LOGGER = logging.getLogger(__name__)
6778

6879
__init__ = [
6980
"DyadApi",
7081
"ZeoApi",
82+
"ZeoCommandTrait",
83+
"ZeoFeatureTrait",
7184
]
7285

7386

@@ -113,6 +126,27 @@
113126
RoborockZeoProtocol.TIMES_AFTER_CLEAN: lambda val: int(val),
114127
RoborockZeoProtocol.DETERGENT_EMPTY: lambda val: bool(val),
115128
RoborockZeoProtocol.SOFTENER_EMPTY: lambda val: bool(val),
129+
RoborockZeoProtocol.DIRT_DETECTION_STATUS: lambda val: ZeoDirtDetectionStatus(val).name,
130+
RoborockZeoProtocol.TOTAL_TIME: lambda val: int(val),
131+
RoborockZeoProtocol.FEATURE_BITS: lambda val: int(val),
132+
RoborockZeoProtocol.SMART_HOSTING_WAITED_TIME: lambda val: int(val),
133+
RoborockZeoProtocol.IS_NEED_FLUFF_CLEAN: lambda val: bool(val),
134+
RoborockZeoProtocol.PANEL_PROGRAM_PARAMS_SET_RESULT: lambda val: int(val),
135+
RoborockZeoProtocol.DEVICE_BOUND: lambda val: bool(val),
136+
RoborockZeoProtocol.CLOTH_PUT_IN: lambda val: bool(val),
137+
RoborockZeoProtocol.CLOTH_READY_TO_DRY_COUNT_DOWN: lambda val: int(val),
138+
RoborockZeoProtocol.START_DRYER_ERROR: lambda val: ZeoDryerStartError(val).name,
139+
RoborockZeoProtocol.DOORLOCK_STATE: lambda val: bool(val),
140+
RoborockZeoProtocol.APP_AUTHORIZATION: lambda val: bool(val),
141+
RoborockZeoProtocol.SMART_HOSTING_TIME: lambda val: int(val),
142+
RoborockZeoProtocol.CUSTOM_PROGRAM_CLEANING_TIME: lambda val: int(val),
143+
RoborockZeoProtocol.PANEL_TIMING_PROGRAM_PARAMS: lambda val: int(val),
144+
RoborockZeoProtocol.STEAM_CARE_TIME: lambda val: int(val),
145+
# meta — read-only (JSON)
146+
RoborockZeoProtocol.PRODUCT_INFO: lambda val: _try_json(val),
147+
RoborockZeoProtocol.WASHING_LOG: lambda val: _try_json(val),
148+
RoborockZeoProtocol.VOICE_RECORD_INFO: lambda val: _try_json(val),
149+
RoborockZeoProtocol.VOICE_RECORD: lambda val: _try_json(val),
116150
# read-write
117151
RoborockZeoProtocol.MODE: lambda val: ZeoMode(val).name,
118152
RoborockZeoProtocol.PROGRAM: lambda val: ZeoProgram(val).name,
@@ -123,6 +157,41 @@
123157
RoborockZeoProtocol.DETERGENT_TYPE: lambda val: ZeoDetergentType(val).name,
124158
RoborockZeoProtocol.SOFTENER_TYPE: lambda val: ZeoSoftenerType(val).name,
125159
RoborockZeoProtocol.SOUND_SET: lambda val: bool(val),
160+
RoborockZeoProtocol.DIRT_DETECTION_SWITCH: lambda val: bool(val),
161+
RoborockZeoProtocol.SOAK: lambda val: ZeoSoak(val).name,
162+
RoborockZeoProtocol.SILENT_MODE_ON: lambda val: bool(val),
163+
RoborockZeoProtocol.SILENT_MODE_START_TIME: lambda val: int(val),
164+
RoborockZeoProtocol.SILENT_MODE_END_TIME: lambda val: int(val),
165+
RoborockZeoProtocol.DRY_CARE_MODE: lambda val: ZeoDryAndCare(val).name,
166+
RoborockZeoProtocol.WASH_DRY_LINKED: lambda val: bool(val),
167+
RoborockZeoProtocol.DRYING_METHOD: lambda val: ZeoDryingMethod(val).name,
168+
RoborockZeoProtocol.STEAM_VOLUME: lambda val: ZeoSteamVolume(val).name,
169+
RoborockZeoProtocol.ION_DEODORIZATION: lambda val: bool(val),
170+
RoborockZeoProtocol.UV_LIGHT: lambda val: bool(val),
171+
RoborockZeoProtocol.SMART_HOSTING: lambda val: bool(val),
172+
RoborockZeoProtocol.SOFTENER_EXPANSION_TYPE: lambda val: ZeoSoftenerExpansionType(val).name,
173+
RoborockZeoProtocol.DETERGENT_EXPANSION_TYPE: lambda val: ZeoDetergentExpansionType(val).name,
174+
RoborockZeoProtocol.SMILE_LIGHT_STATUS: lambda val: bool(val),
175+
RoborockZeoProtocol.POWER_LIGHT: lambda val: bool(val),
176+
RoborockZeoProtocol.PANEL_PROGRAM_PARAMS_SET: lambda val: int(val),
177+
RoborockZeoProtocol.WIFI_LINKAGE_RESET: lambda val: int(val),
178+
RoborockZeoProtocol.SAVE_ADAPTED_CLOUD_PROGRAM: lambda val: int(val),
179+
RoborockZeoProtocol.CHILD_LOCK: lambda val: bool(val),
180+
RoborockZeoProtocol.DETERGENT_SET: lambda val: bool(val),
181+
RoborockZeoProtocol.SOFTENER_SET: lambda val: bool(val),
182+
RoborockZeoProtocol.FLUFF_CLEANED: lambda val: bool(val),
183+
# read-write (int-valued)
184+
RoborockZeoProtocol.CUSTOM_PARAM_SAVE: lambda val: int(val),
185+
RoborockZeoProtocol.CUSTOM_PARAM_GET: lambda val: int(val),
186+
RoborockZeoProtocol.DEFAULT_SETTING: lambda val: int(val),
187+
RoborockZeoProtocol.LIGHT_SETTING: lambda val: bool(val),
188+
RoborockZeoProtocol.DETERGENT_VOLUME: lambda val: int(val),
189+
RoborockZeoProtocol.SOFTENER_VOLUME: lambda val: int(val),
190+
# meta — read-write
191+
RoborockZeoProtocol.SET_SOUND_PACKAGE: lambda val: val,
192+
RoborockZeoProtocol.VOICE_VOLUME: lambda val: val,
193+
RoborockZeoProtocol.VOICE_SWITCH: lambda val: bool(val),
194+
RoborockZeoProtocol.VOICE_RECORD_DELETE: lambda val: int(val),
126195
}
127196

128197

@@ -173,20 +242,30 @@ class ZeoApi(Trait, TraitUpdateListener):
173242

174243
name = "zeo"
175244

176-
def __init__(self, channel: MqttChannel) -> None:
245+
def __init__(self, channel: MqttChannel, product_id: str | None = None) -> None:
177246
"""Initialize the Zeo API."""
178247
TraitUpdateListener.__init__(self, _LOGGER)
179248
self._channel = channel
180249
self._dps_cache: dict[int, Any] = {}
181250
self._dps_unsub: Callable[[], None] | None = None
182251
self._feature_bits: int = 0
252+
self._feature_trait = ZeoFeatureTrait(channel, product_id)
253+
self._command: ZeoCommandTrait | None = None
254+
255+
@property
256+
def command(self) -> ZeoCommandTrait:
257+
"""Lazily-built trait for wash-programme commands."""
258+
if self._command is None:
259+
self._command = ZeoCommandTrait(
260+
channel=self._channel,
261+
dps_cache=self._dps_cache,
262+
feature_trait=self._feature_trait,
263+
proto_entries=ZEO_PROTOCOL_ENTRIES,
264+
)
265+
return self._command
183266

184267
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-
"""
268+
"""Subscribe to MQTT push and discover device capabilities."""
190269
await self._ensure_subscribed()
191270
await self._discover_features()
192271

@@ -203,13 +282,7 @@ async def _ensure_subscribed(self) -> None:
203282
self._dps_unsub = await self._channel.subscribe(self._on_dps_message)
204283

205284
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-
"""
285+
"""Query FEATURE_BITS (DP 237) and cache device capabilities."""
213286
try:
214287
result = await self.query_values([RoborockZeoProtocol.FEATURE_BITS])
215288
self._feature_bits = result.get(RoborockZeoProtocol.FEATURE_BITS, 0)
@@ -261,6 +334,6 @@ def create(product: HomeDataProduct, mqtt_channel: MqttChannel) -> DyadApi | Zeo
261334
case RoborockCategory.WET_DRY_VAC:
262335
return DyadApi(mqtt_channel)
263336
case RoborockCategory.WASHING_MACHINE:
264-
return ZeoApi(mqtt_channel)
337+
return ZeoApi(mqtt_channel, product_id=product.id)
265338
case _:
266339
raise NotImplementedError(f"Unsupported category {product.category}")
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""Zeo command trait"""
2+
3+
import json
4+
import logging
5+
from collections.abc import Callable
6+
from typing import Any
7+
8+
from roborock.data.zeo.zeo_containers import ZeoStartParams
9+
from roborock.devices.rpc.a01_channel import send_decoded_command
10+
from roborock.devices.traits.a01.device_features import ZeoFeatureTrait
11+
from roborock.devices.transport.mqtt_channel import MqttChannel
12+
from roborock.roborock_message import RoborockZeoProtocol
13+
14+
_LOGGER = logging.getLogger(__name__)
15+
16+
_START_PARAM_DPS: list[RoborockZeoProtocol] = [
17+
RoborockZeoProtocol.MODE,
18+
RoborockZeoProtocol.PROGRAM,
19+
RoborockZeoProtocol.TEMP,
20+
RoborockZeoProtocol.RINSE_TIMES,
21+
RoborockZeoProtocol.SPIN_LEVEL,
22+
RoborockZeoProtocol.DRYING_MODE,
23+
RoborockZeoProtocol.DETERGENT_SET,
24+
RoborockZeoProtocol.SOFTENER_SET,
25+
RoborockZeoProtocol.COUNTDOWN,
26+
RoborockZeoProtocol.SOAK,
27+
RoborockZeoProtocol.TOTAL_TIME,
28+
RoborockZeoProtocol.DRYING_METHOD,
29+
RoborockZeoProtocol.STEAM_VOLUME,
30+
]
31+
32+
_FIELD_TO_DP: dict[str, RoborockZeoProtocol] = {
33+
"mode": RoborockZeoProtocol.MODE,
34+
"program": RoborockZeoProtocol.PROGRAM,
35+
"temperature": RoborockZeoProtocol.TEMP,
36+
"rinse": RoborockZeoProtocol.RINSE_TIMES,
37+
"spin": RoborockZeoProtocol.SPIN_LEVEL,
38+
"drying_mode": RoborockZeoProtocol.DRYING_MODE,
39+
"drying_method": RoborockZeoProtocol.DRYING_METHOD,
40+
"steam_volume": RoborockZeoProtocol.STEAM_VOLUME,
41+
"total_time": RoborockZeoProtocol.TOTAL_TIME,
42+
"soak": RoborockZeoProtocol.SOAK,
43+
"dry_and_care": RoborockZeoProtocol.DRY_CARE_MODE,
44+
}
45+
46+
_FEATURE_GATED_DPS: dict[RoborockZeoProtocol, str] = {
47+
RoborockZeoProtocol.ION_DEODORIZATION: "ion_deodorization",
48+
RoborockZeoProtocol.WASH_DRY_LINKED: "wash_dry_linkage",
49+
RoborockZeoProtocol.SMART_HOSTING: "smart_hosting",
50+
}
51+
52+
class ZeoCommandTrait:
53+
"""Trait for sending wash-programme commands to Zeo devices."""
54+
55+
def __init__(
56+
self,
57+
*,
58+
channel: MqttChannel,
59+
dps_cache: dict[int, Any],
60+
feature_trait: ZeoFeatureTrait,
61+
proto_entries: dict[RoborockZeoProtocol, Callable],
62+
) -> None:
63+
"""Initialize the command trait."""
64+
65+
self._channel = channel
66+
self._dps_cache = dps_cache
67+
self._feature_trait = feature_trait
68+
self._proto_entries = proto_entries
69+
70+
def _convert_value(self, protocol: RoborockZeoProtocol, value: Any) -> Any:
71+
"""Convert a protocol value using the injected entries table."""
72+
if (converter := self._proto_entries.get(protocol)) is not None:
73+
try:
74+
return converter(value)
75+
except (ValueError, TypeError):
76+
return None
77+
return None
78+
79+
80+
async def start_program(self) -> dict[RoborockZeoProtocol, Any]:
81+
"""Start the device, bundling the current programme parameters."""
82+
_LOGGER.debug("Start command: discovering features and building payload")
83+
features = self._feature_trait.features
84+
p = await self._get_start_params()
85+
dps: dict[RoborockZeoProtocol, Any] = {RoborockZeoProtocol.START: "True"}
86+
for field_name, dp in _FIELD_TO_DP.items():
87+
val = getattr(p, field_name)
88+
if val is not None:
89+
dps[dp] = val
90+
for dp, attr_name in _FEATURE_GATED_DPS.items():
91+
if features is not None and getattr(features, attr_name, False):
92+
val = self._dps_cache.get(int(dp))
93+
if val is not None:
94+
dps[dp] = val
95+
result = await send_decoded_command(
96+
self._channel,
97+
dps,
98+
qos=1,
99+
value_encoder=lambda x: x,
100+
)
101+
for dp, v in dps.items():
102+
self._dps_cache[int(dp)] = v
103+
return {proto: self._convert_value(proto, dps.get(proto)) for proto in dps}
104+
105+
async def resume(self) -> dict[RoborockZeoProtocol, Any]:
106+
"""Resume a paused programme or start without rebundling).
107+
Only works while the device is powered on."""
108+
_LOGGER.debug("Resume command")
109+
dps = {RoborockZeoProtocol.START: "True"}
110+
result = await send_decoded_command(
111+
self._channel,
112+
dps,
113+
)
114+
self._dps_cache[int(RoborockZeoProtocol.START)] = 1
115+
return result
116+
117+
118+
async def _get_start_params(self) -> ZeoStartParams:
119+
"""Read programme settings, querying the device on cache miss."""
120+
cache = self._dps_cache
121+
need_refresh = (
122+
int(RoborockZeoProtocol.MODE) not in cache
123+
or int(RoborockZeoProtocol.PROGRAM) not in cache
124+
)
125+
if need_refresh:
126+
raw = await send_decoded_command(
127+
self._channel,
128+
{RoborockZeoProtocol.ID_QUERY: [RoborockZeoProtocol.MODE, RoborockZeoProtocol.PROGRAM]},
129+
value_encoder=json.dumps,
130+
)
131+
for dp in (RoborockZeoProtocol.MODE, RoborockZeoProtocol.PROGRAM):
132+
if (val := raw.get(dp)) is not None:
133+
cache[int(dp)] = val
134+
return ZeoStartParams(
135+
mode=cache[int(RoborockZeoProtocol.MODE)],
136+
program=cache[int(RoborockZeoProtocol.PROGRAM)],
137+
temperature=cache.get(int(RoborockZeoProtocol.TEMP)),
138+
rinse=cache.get(int(RoborockZeoProtocol.RINSE_TIMES)),
139+
spin=cache.get(int(RoborockZeoProtocol.SPIN_LEVEL)),
140+
drying_mode=cache.get(int(RoborockZeoProtocol.DRYING_MODE)),
141+
drying_method=cache.get(int(RoborockZeoProtocol.DRYING_METHOD)),
142+
steam_volume=cache.get(int(RoborockZeoProtocol.STEAM_VOLUME)),
143+
total_time=cache.get(int(RoborockZeoProtocol.TOTAL_TIME)),
144+
soak=cache.get(int(RoborockZeoProtocol.SOAK)),
145+
dry_and_care=cache.get(int(RoborockZeoProtocol.DRY_CARE_MODE)),
146+
)

0 commit comments

Comments
 (0)