Skip to content

Commit 1fcacf4

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 169a6a9 commit 1fcacf4

5 files changed

Lines changed: 462 additions & 12 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: 97 additions & 8 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

@@ -112,6 +125,16 @@
112125
}
113126
)
114127

128+
129+
def _try_json(val: Any) -> Any:
130+
"""Return *val* parsed as JSON when it is a JSON string, else *val*."""
131+
if isinstance(val, str):
132+
try:
133+
return json.loads(val)
134+
except (json.JSONDecodeError, TypeError):
135+
pass
136+
return val
137+
115138
ZEO_PROTOCOL_ENTRIES: dict[RoborockZeoProtocol, Callable] = {
116139
# read-only
117140
RoborockZeoProtocol.STATE: lambda val: ZeoState(val).name,
@@ -121,6 +144,27 @@
121144
RoborockZeoProtocol.TIMES_AFTER_CLEAN: lambda val: int(val),
122145
RoborockZeoProtocol.DETERGENT_EMPTY: lambda val: bool(val),
123146
RoborockZeoProtocol.SOFTENER_EMPTY: lambda val: bool(val),
147+
RoborockZeoProtocol.DIRT_DETECTION_STATUS: lambda val: ZeoDirtDetectionStatus(val).name,
148+
RoborockZeoProtocol.TOTAL_TIME: lambda val: int(val),
149+
RoborockZeoProtocol.FEATURE_BITS: lambda val: int(val),
150+
RoborockZeoProtocol.SMART_HOSTING_WAITED_TIME: lambda val: int(val),
151+
RoborockZeoProtocol.IS_NEED_FLUFF_CLEAN: lambda val: bool(val),
152+
RoborockZeoProtocol.PANEL_PROGRAM_PARAMS_SET_RESULT: lambda val: int(val),
153+
RoborockZeoProtocol.DEVICE_BOUND: lambda val: bool(val),
154+
RoborockZeoProtocol.CLOTH_PUT_IN: lambda val: bool(val),
155+
RoborockZeoProtocol.CLOTH_READY_TO_DRY_COUNT_DOWN: lambda val: int(val),
156+
RoborockZeoProtocol.START_DRYER_ERROR: lambda val: ZeoDryerStartError(val).name,
157+
RoborockZeoProtocol.DOORLOCK_STATE: lambda val: bool(val),
158+
RoborockZeoProtocol.APP_AUTHORIZATION: lambda val: bool(val),
159+
RoborockZeoProtocol.SMART_HOSTING_TIME: lambda val: int(val),
160+
RoborockZeoProtocol.CUSTOM_PROGRAM_CLEANING_TIME: lambda val: int(val),
161+
RoborockZeoProtocol.PANEL_TIMING_PROGRAM_PARAMS: lambda val: int(val),
162+
RoborockZeoProtocol.STEAM_CARE_TIME: lambda val: int(val),
163+
# meta — read-only (JSON)
164+
RoborockZeoProtocol.PRODUCT_INFO: lambda val: _try_json(val),
165+
RoborockZeoProtocol.WASHING_LOG: lambda val: _try_json(val),
166+
RoborockZeoProtocol.VOICE_RECORD_INFO: lambda val: _try_json(val),
167+
RoborockZeoProtocol.VOICE_RECORD: lambda val: _try_json(val),
124168
# read-write
125169
RoborockZeoProtocol.MODE: lambda val: ZeoMode(val).name,
126170
RoborockZeoProtocol.PROGRAM: lambda val: ZeoProgram(val).name,
@@ -131,6 +175,41 @@
131175
RoborockZeoProtocol.DETERGENT_TYPE: lambda val: ZeoDetergentType(val).name,
132176
RoborockZeoProtocol.SOFTENER_TYPE: lambda val: ZeoSoftenerType(val).name,
133177
RoborockZeoProtocol.SOUND_SET: lambda val: bool(val),
178+
RoborockZeoProtocol.DIRT_DETECTION_SWITCH: lambda val: bool(val),
179+
RoborockZeoProtocol.SOAK: lambda val: ZeoSoak(val).name,
180+
RoborockZeoProtocol.SILENT_MODE_ON: lambda val: bool(val),
181+
RoborockZeoProtocol.SILENT_MODE_START_TIME: lambda val: int(val),
182+
RoborockZeoProtocol.SILENT_MODE_END_TIME: lambda val: int(val),
183+
RoborockZeoProtocol.DRY_CARE_MODE: lambda val: ZeoDryAndCare(val).name,
184+
RoborockZeoProtocol.WASH_DRY_LINKED: lambda val: bool(val),
185+
RoborockZeoProtocol.DRYING_METHOD: lambda val: ZeoDryingMethod(val).name,
186+
RoborockZeoProtocol.STEAM_VOLUME: lambda val: ZeoSteamVolume(val).name,
187+
RoborockZeoProtocol.ION_DEODORIZATION: lambda val: bool(val),
188+
RoborockZeoProtocol.UV_LIGHT: lambda val: bool(val),
189+
RoborockZeoProtocol.SMART_HOSTING: lambda val: bool(val),
190+
RoborockZeoProtocol.SOFTENER_EXPANSION_TYPE: lambda val: ZeoSoftenerExpansionType(val).name,
191+
RoborockZeoProtocol.DETERGENT_EXPANSION_TYPE: lambda val: ZeoDetergentExpansionType(val).name,
192+
RoborockZeoProtocol.SMILE_LIGHT_STATUS: lambda val: bool(val),
193+
RoborockZeoProtocol.POWER_LIGHT: lambda val: bool(val),
194+
RoborockZeoProtocol.PANEL_PROGRAM_PARAMS_SET: lambda val: int(val),
195+
RoborockZeoProtocol.WIFI_LINKAGE_RESET: lambda val: int(val),
196+
RoborockZeoProtocol.SAVE_ADAPTED_CLOUD_PROGRAM: lambda val: int(val),
197+
RoborockZeoProtocol.CHILD_LOCK: lambda val: bool(val),
198+
RoborockZeoProtocol.DETERGENT_SET: lambda val: bool(val),
199+
RoborockZeoProtocol.SOFTENER_SET: lambda val: bool(val),
200+
RoborockZeoProtocol.FLUFF_CLEANED: lambda val: bool(val),
201+
# read-write (int-valued)
202+
RoborockZeoProtocol.CUSTOM_PARAM_SAVE: lambda val: int(val),
203+
RoborockZeoProtocol.CUSTOM_PARAM_GET: lambda val: int(val),
204+
RoborockZeoProtocol.DEFAULT_SETTING: lambda val: int(val),
205+
RoborockZeoProtocol.LIGHT_SETTING: lambda val: bool(val),
206+
RoborockZeoProtocol.DETERGENT_VOLUME: lambda val: int(val),
207+
RoborockZeoProtocol.SOFTENER_VOLUME: lambda val: int(val),
208+
# meta — read-write
209+
RoborockZeoProtocol.SET_SOUND_PACKAGE: lambda val: val,
210+
RoborockZeoProtocol.VOICE_VOLUME: lambda val: val,
211+
RoborockZeoProtocol.VOICE_SWITCH: lambda val: bool(val),
212+
RoborockZeoProtocol.VOICE_RECORD_DELETE: lambda val: int(val),
134213
}
135214

136215

@@ -189,13 +268,23 @@ def __init__(self, channel: MqttChannel, product_id: str | None = None) -> None:
189268
self._dps_unsub: Callable[[], None] | None = None
190269
self._feature_bits: int = 0
191270
self._product_id = product_id
271+
self._feature_trait = ZeoFeatureTrait(channel, product_id)
272+
self._command: ZeoCommandTrait | None = None
273+
274+
@property
275+
def command(self) -> ZeoCommandTrait:
276+
"""Lazily-built trait for wash-programme commands."""
277+
if self._command is None:
278+
self._command = ZeoCommandTrait(
279+
channel=self._channel,
280+
dps_cache=self._dps_cache,
281+
feature_trait=self._feature_trait,
282+
proto_entries=ZEO_PROTOCOL_ENTRIES,
283+
)
284+
return self._command
192285

193286
async def start(self) -> None:
194-
"""Subscribe to MQTT push and discover device features.
195-
196-
Subscribes to the DPS MQTT topic, then queries FEATURE_BITS
197-
(DP 237) to wake the device and cache supported capabilities.
198-
"""
287+
"""Subscribe to MQTT push and discover device capabilities."""
199288
await self._ensure_subscribed()
200289
await self._discover_features()
201290

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

0 commit comments

Comments
 (0)