Skip to content

Commit 8becec1

Browse files
author
NOisi-X
committed
feat(zeo): add core ZeoApi with ZeoCommandTrait and ZeoFeatureTrait
ZeoCommandTrait (new: command.py, 165 lines) - start_program: bundles start params via FIELD_TO_DP, QoS 1 - pause / resume / shutdown: single-DP commands - Start param sets split by device type (washer 10 DPs, dryer 7 DPs) - Feature-gated DPs conditionally included from cache - Dependencies injected: channel, dps_cache, feature_trait, proto_entries ZeoFeatureTrait (new: device_features.py, 185 lines) - ZeoFeatures dataclass (24 bool flags): from_feature_bits() via name reflection - Product type detection: static model ID whitelists (dryer/Hyperion/M1) - is_dryer / is_hyperion_halia_hera / is_m1_muse_metis — no device query - refresh(): query DP 237 once, cache in memory ZeoStartParams (zeo_containers.py): expanded to 13 fields covering washer + dryer ZEO_PROTOCOL_ENTRIES (__init__.py): expanded to ~69 DP-type mappings RoborockZeoProtocol: add UNKNOWN_246, UNKNOWN_259 placeholders Builds on #895 (MQTT push subscription).
1 parent 910375b commit 8becec1

5 files changed

Lines changed: 451 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: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
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_WASHER: 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+
]
28+
29+
_START_PARAM_DPS_DRYER: list[RoborockZeoProtocol] = [
30+
RoborockZeoProtocol.MODE,
31+
RoborockZeoProtocol.PROGRAM,
32+
RoborockZeoProtocol.DRYING_MODE,
33+
RoborockZeoProtocol.TOTAL_TIME,
34+
RoborockZeoProtocol.DRYING_METHOD,
35+
RoborockZeoProtocol.STEAM_VOLUME,
36+
RoborockZeoProtocol.COUNTDOWN,
37+
]
38+
39+
_FIELD_TO_DP: dict[str, RoborockZeoProtocol] = {
40+
"mode": RoborockZeoProtocol.MODE,
41+
"program": RoborockZeoProtocol.PROGRAM,
42+
"temperature": RoborockZeoProtocol.TEMP,
43+
"rinse": RoborockZeoProtocol.RINSE_TIMES,
44+
"spin": RoborockZeoProtocol.SPIN_LEVEL,
45+
"drying_mode": RoborockZeoProtocol.DRYING_MODE,
46+
"drying_method": RoborockZeoProtocol.DRYING_METHOD,
47+
"steam_volume": RoborockZeoProtocol.STEAM_VOLUME,
48+
"total_time": RoborockZeoProtocol.TOTAL_TIME,
49+
"soak": RoborockZeoProtocol.SOAK,
50+
"dry_and_care": RoborockZeoProtocol.DRY_CARE_MODE,
51+
}
52+
53+
_FEATURE_GATED_DPS: dict[RoborockZeoProtocol, str] = {
54+
RoborockZeoProtocol.ION_DEODORIZATION: "ion_deodorization",
55+
RoborockZeoProtocol.WASH_DRY_LINKED: "wash_dry_linkage",
56+
RoborockZeoProtocol.SMART_HOSTING: "smart_hosting",
57+
}
58+
59+
60+
class ZeoCommandTrait:
61+
"""Trait for sending commands to Zeo devices."""
62+
63+
def __init__(
64+
self,
65+
*,
66+
channel: MqttChannel,
67+
dps_cache: dict[int, Any],
68+
feature_trait: ZeoFeatureTrait,
69+
proto_entries: dict[RoborockZeoProtocol, Callable],
70+
) -> None:
71+
"""Initialize the command trait."""
72+
73+
self._channel = channel
74+
self._dps_cache = dps_cache
75+
self._feature_trait = feature_trait
76+
self._proto_entries = proto_entries
77+
78+
def _convert_value(self, protocol: RoborockZeoProtocol, value: Any) -> Any:
79+
"""Convert a protocol value using the injected entries table."""
80+
if (converter := self._proto_entries.get(protocol)) is not None:
81+
try:
82+
return converter(value)
83+
except (ValueError, TypeError):
84+
return None
85+
return None
86+
87+
async def start_program(self) -> dict[RoborockZeoProtocol, Any]:
88+
"""Start the device, bundling the current programme parameters."""
89+
features = self._feature_trait.features
90+
p = await self._get_start_params()
91+
dps: dict[RoborockZeoProtocol, Any] = {RoborockZeoProtocol.START: "True"}
92+
for field_name, dp in _FIELD_TO_DP.items():
93+
val = getattr(p, field_name)
94+
if val is not None:
95+
dps[dp] = val
96+
for dp, attr_name in _FEATURE_GATED_DPS.items():
97+
if features is not None and getattr(features, attr_name, False):
98+
val = self._dps_cache.get(int(dp))
99+
if val is not None:
100+
dps[dp] = val
101+
result = await send_decoded_command(
102+
self._channel,
103+
dps,
104+
qos=1,
105+
value_encoder=lambda x: x,
106+
)
107+
for dp, v in dps.items():
108+
self._dps_cache[int(dp)] = v
109+
return {proto: self._convert_value(proto, dps.get(proto)) for proto in dps}
110+
111+
async def pause(self) -> dict[RoborockZeoProtocol, Any]:
112+
"""Pause the current programme (DP 201 = "True")."""
113+
dps = {RoborockZeoProtocol.PAUSE: "True"}
114+
result = await send_decoded_command(self._channel, dps)
115+
self._dps_cache[int(RoborockZeoProtocol.PAUSE)] = 1
116+
return result
117+
118+
async def resume(self) -> dict[RoborockZeoProtocol, Any]:
119+
"""Start/continue a paused programme (DP 200 = "True").
120+
Only works while the device is powered on.
121+
"""
122+
dps = {RoborockZeoProtocol.START: "True"}
123+
result = await send_decoded_command(self._channel, dps)
124+
self._dps_cache[int(RoborockZeoProtocol.START)] = 1
125+
return result
126+
127+
async def shutdown(self) -> dict[RoborockZeoProtocol, Any]:
128+
"""Power off the device (DP 202 = "True").
129+
Only works while the device is powered on.
130+
"""
131+
dps = {RoborockZeoProtocol.SHUTDOWN: "True"}
132+
result = await send_decoded_command(self._channel, dps)
133+
self._dps_cache[int(RoborockZeoProtocol.SHUTDOWN)] = 1
134+
return result
135+
136+
async def _get_start_params(self) -> ZeoStartParams:
137+
"""Read programme settings, querying the device on cache miss."""
138+
cache = self._dps_cache
139+
wanted = _START_PARAM_DPS_DRYER if self._feature_trait.is_dryer else _START_PARAM_DPS_WASHER
140+
need_refresh = int(RoborockZeoProtocol.MODE) not in cache or int(RoborockZeoProtocol.PROGRAM) not in cache
141+
if need_refresh:
142+
raw = await send_decoded_command(
143+
self._channel,
144+
{RoborockZeoProtocol.ID_QUERY: wanted},
145+
value_encoder=json.dumps,
146+
)
147+
for dp in wanted:
148+
if (val := raw.get(dp)) is not None:
149+
cache[int(dp)] = val
150+
return ZeoStartParams(
151+
mode=cache[int(RoborockZeoProtocol.MODE)],
152+
program=cache[int(RoborockZeoProtocol.PROGRAM)],
153+
temperature=cache.get(int(RoborockZeoProtocol.TEMP)),
154+
rinse=cache.get(int(RoborockZeoProtocol.RINSE_TIMES)),
155+
spin=cache.get(int(RoborockZeoProtocol.SPIN_LEVEL)),
156+
drying_mode=cache.get(int(RoborockZeoProtocol.DRYING_MODE)),
157+
drying_method=cache.get(int(RoborockZeoProtocol.DRYING_METHOD)),
158+
steam_volume=cache.get(int(RoborockZeoProtocol.STEAM_VOLUME)),
159+
total_time=cache.get(int(RoborockZeoProtocol.TOTAL_TIME)),
160+
soak=cache.get(int(RoborockZeoProtocol.SOAK)),
161+
dry_and_care=cache.get(int(RoborockZeoProtocol.DRY_CARE_MODE)),
162+
)

0 commit comments

Comments
 (0)