Skip to content

Commit d5923ae

Browse files
author
NOisi-X
committed
refactor: reorganise Zeo protocol enum with semantic sections, fix boolean wire format
1 parent fc9dd3c commit d5923ae

3 files changed

Lines changed: 285 additions & 102 deletions

File tree

roborock/devices/traits/a01/__init__.py

Lines changed: 203 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -119,15 +119,16 @@ def _decode_expansion_type(val: Any, default: int) -> int:
119119
return int(val) if val is not None else default
120120

121121

122-
def to_dp_bool(val: Any) -> str:
123-
"""Encode a Zeo/Dyad boolean DP value in the official wire format.
124-
125-
The official app serialises booleans as the strings ``"True"`` /
126-
``"False"`` (its ``DPBoolean`` enum) on SET commands — not as ``1`` / ``0``
127-
or JSON ``true`` / ``false``. Mirror that exactly so the device receives
128-
what it expects. :func:`parse_bool` is the inverse used when decoding.
122+
def to_dp_bool(val: Any) -> int:
123+
"""Normalise a boolean-like value to the wire-format integer ``1`` or ``0``.
124+
125+
Used only in the ``set_value`` encoder path because callers (HA switch
126+
entities, external code) may pass Python ``True`` / ``False`` which
127+
``json.dumps`` would serialise as JSON ``true`` / ``false`` — not the
128+
integer ``1`` / ``0`` that the device expects. Cache‑reading paths
129+
do NOT need this: MQTT push already delivers integers.
129130
"""
130-
return "True" if parse_bool(val) else "False"
131+
return 1 if parse_bool(val) else 0
131132

132133

133134
DYAD_PROTOCOL_ENTRIES: dict[RoborockDyadDataProtocol, Callable] = {
@@ -166,7 +167,6 @@ def to_dp_bool(val: Any) -> str:
166167
ZEO_PROTOCOL_ENTRIES: dict[RoborockZeoProtocol, Callable] = {
167168
# read-only
168169
RoborockZeoProtocol.STATE: lambda val: ZeoState(val).name,
169-
RoborockZeoProtocol.COUNTDOWN: lambda val: int(val),
170170
RoborockZeoProtocol.WASHING_LEFT: lambda val: int(val),
171171
RoborockZeoProtocol.ERROR: lambda val: ZeoError(val).name,
172172
RoborockZeoProtocol.TIMES_AFTER_CLEAN: lambda val: int(val),
@@ -176,24 +176,25 @@ def to_dp_bool(val: Any) -> str:
176176
RoborockZeoProtocol.TOTAL_TIME: lambda val: int(val),
177177
RoborockZeoProtocol.FEATURE_BITS: lambda val: int(val),
178178
RoborockZeoProtocol.SMART_HOSTING_WAITED_TIME: lambda val: int(val),
179-
RoborockZeoProtocol.FLUFF_CLEANED: parse_bool,
180179
RoborockZeoProtocol.IS_NEED_FLUFF_CLEAN: parse_bool,
181180
RoborockZeoProtocol.PANEL_PROGRAM_PARAMS_SET_RESULT: lambda val: int(val),
182181
RoborockZeoProtocol.DEVICE_BOUND: parse_bool,
183182
RoborockZeoProtocol.CLOTH_PUT_IN: parse_bool,
184183
RoborockZeoProtocol.CLOTH_READY_TO_DRY_COUNT_DOWN: lambda val: int(val),
185184
RoborockZeoProtocol.START_DRYER_ERROR: lambda val: ZeoDryerStartError(val).name,
186185
RoborockZeoProtocol.DOORLOCK_STATE: parse_bool,
187-
RoborockZeoProtocol.DEFAULT_SETTING: lambda val: int(val),
188-
RoborockZeoProtocol.LIGHT_SETTING: parse_bool,
189-
RoborockZeoProtocol.DETERGENT_VOLUME: lambda val: int(val),
190-
RoborockZeoProtocol.SOFTENER_VOLUME: lambda val: int(val),
186+
RoborockZeoProtocol.APP_AUTHORIZATION: parse_bool,
187+
RoborockZeoProtocol.SMART_HOSTING_TIME: lambda val: int(val),
188+
RoborockZeoProtocol.CUSTOM_PROGRAM_CLEANING_TIME: lambda val: int(val),
189+
RoborockZeoProtocol.PANEL_TIMING_PROGRAM_PARAMS: lambda val: int(val),
190+
RoborockZeoProtocol.STEAM_CARE_TIME: lambda val: int(val),
191191
# meta — read-only (JSON on wire, auto-decoded by converter)
192192
RoborockZeoProtocol.PRODUCT_INFO: lambda val: _try_json(val), # robotInfo
193193
RoborockZeoProtocol.WASHING_LOG: lambda val: _try_json(val), # washHistory
194194
RoborockZeoProtocol.VOICE_RECORD_INFO: lambda val: _try_json(val),
195195
RoborockZeoProtocol.VOICE_RECORD: lambda val: _try_json(val),
196196
# read-write
197+
RoborockZeoProtocol.COUNTDOWN: lambda val: int(val),
197198
RoborockZeoProtocol.MODE: lambda val: ZeoMode(val).name,
198199
RoborockZeoProtocol.PROGRAM: lambda val: ZeoProgram(val).name,
199200
RoborockZeoProtocol.TEMP: lambda val: ZeoTemperature(val).name,
@@ -215,21 +216,24 @@ def to_dp_bool(val: Any) -> str:
215216
RoborockZeoProtocol.ION_DEODORIZATION: parse_bool,
216217
RoborockZeoProtocol.UV_LIGHT: parse_bool,
217218
RoborockZeoProtocol.SMART_HOSTING: parse_bool,
218-
RoborockZeoProtocol.SMART_HOSTING_TIME: lambda val: int(val),
219219
RoborockZeoProtocol.SOFTENER_EXPANSION_TYPE: lambda val: _decode_expansion_type(val, ZeoSoftenerExpansionType.softener),
220220
RoborockZeoProtocol.DETERGENT_EXPANSION_TYPE: lambda val: _decode_expansion_type(val, ZeoDetergentExpansionType.concentrated_detergent),
221221
RoborockZeoProtocol.SMILE_LIGHT_STATUS: parse_bool,
222222
RoborockZeoProtocol.POWER_LIGHT: parse_bool,
223223
RoborockZeoProtocol.PANEL_PROGRAM_PARAMS_SET: lambda val: int(val),
224-
RoborockZeoProtocol.PANEL_TIMING_PROGRAM_PARAMS: lambda val: int(val),
225-
RoborockZeoProtocol.STEAM_CARE_TIME: lambda val: int(val),
226224
RoborockZeoProtocol.WIFI_LINKAGE_RESET: lambda val: int(val),
227-
RoborockZeoProtocol.CUSTOM_PROGRAM_CLEANING_TIME: lambda val: int(val),
228225
RoborockZeoProtocol.SAVE_ADAPTED_CLOUD_PROGRAM: lambda val: int(val),
229226
RoborockZeoProtocol.CHILD_LOCK: parse_bool,
230227
RoborockZeoProtocol.DETERGENT_SET: parse_bool,
231228
RoborockZeoProtocol.SOFTENER_SET: parse_bool,
232-
RoborockZeoProtocol.APP_AUTHORIZATION: parse_bool,
229+
RoborockZeoProtocol.FLUFF_CLEANED: parse_bool, # bundle: setFluffCleaned(1)
230+
# read-write (int-valued, not boolean)
231+
RoborockZeoProtocol.CUSTOM_PARAM_SAVE: lambda val: int(val),
232+
RoborockZeoProtocol.CUSTOM_PARAM_GET: lambda val: int(val),
233+
RoborockZeoProtocol.DEFAULT_SETTING: lambda val: int(val),
234+
RoborockZeoProtocol.LIGHT_SETTING: parse_bool,
235+
RoborockZeoProtocol.DETERGENT_VOLUME: lambda val: int(val),
236+
RoborockZeoProtocol.SOFTENER_VOLUME: lambda val: int(val),
233237
# meta — read-write (JSON-encoded on wire by official app; raw Python dicts)
234238
RoborockZeoProtocol.SET_SOUND_PACKAGE: lambda val: val,
235239
RoborockZeoProtocol.VOICE_VOLUME: lambda val: val,
@@ -238,9 +242,10 @@ def to_dp_bool(val: Any) -> str:
238242
}
239243

240244

241-
# Protocols whose value is a boolean. Derived from the entry tables so the
242-
# write path (set_value / start) always encodes them as "True"/"False",
243-
# matching the official app's DPBoolean wire format.
245+
# Protocols whose converter is ``parse_bool``. The encoder path in
246+
# ``set_value`` uses ``to_dp_bool`` to normalise callers' Python ``True`` /
247+
# ``False`` to the wire-format integer ``1`` / ``0`` (DPBoolean). Derived
248+
# automatically from ``ZEO_PROTOCOL_ENTRIES``.
244249
_ZEO_BOOLEAN_PROTOCOLS: frozenset[RoborockZeoProtocol] = frozenset(
245250
p for p, conv in ZEO_PROTOCOL_ENTRIES.items() if conv is parse_bool
246251
)
@@ -431,7 +436,45 @@ def from_raw(cls, raw: int, total_time_min: int | None = None) -> "ZeoDryerCusto
431436

432437

433438
class ZeoApi(Trait):
434-
"""API for interacting with Zeo devices."""
439+
"""API for interacting with Zeo devices.
440+
441+
── Bundle method mapping ──
442+
443+
======================== ================================================
444+
Bundle (module 727) Python ``ZeoApi``
445+
======================== ================================================
446+
``startWith(...)`` :meth:`start` — bundles START+Mode+Program+params
447+
``start()`` / ``continue()`` :meth:`resume` — single DP ``{200: 1}``
448+
``presetWith(n)`` :meth:`start_with_preset` — startWith + Preset
449+
``stop`` / ``pause`` / ``set_value(PAUSE/SHUTDOWN/START, …)``
450+
``shutdown``
451+
``saveCloudProgramWith`` :meth:`save_cloud_program`
452+
``savePanelProgramWith`` :meth:`save_panel_program`
453+
``loadCloudProgram`` :meth:`load_cloud_program`
454+
``setCleanserConfig`` :meth:`set_cleanser_config` — 4-DP bundle
455+
``forceLoad`` + :meth:`force_load` + :meth:`load_feature_dps`
456+
``loadFeatureDps``
457+
``setSilentMode(…)`` :meth:`set_silent_mode`
458+
``checkFCCState`` :meth:`check_fcc_state`
459+
``loadGeneralInfo`` :meth:`load_general_info`
460+
``uploadLog`` :meth:`upload_log`
461+
``syncPrivacyToDevice`` :meth:`sync_privacy_to_device`
462+
``updateSoundPackageInfo`` :meth:`update_sound_package_info`
463+
28+ single-DP setters :meth:`set_value`
464+
======================== ================================================
465+
466+
── Two categories of DPs ──
467+
468+
**startWith params** — MODE, PROGRAM, TEMP, RINSE, SPIN, DRYING_MODE,
469+
DRYING_METHOD, STEAM_VOLUME, SOAK, DRY_CARE_MODE, TOTAL_TIME, and the
470+
feature‑gated DPs (WASH_DRY_LINKED, ION_DEODORIZATION). These are
471+
*staged* by ``set_value`` and committed only when ``start()`` bundles
472+
them with START.
473+
474+
**Independent** — CHILD_LOCK, SOUND, UV_LIGHT, SILENT_MODE, DETERGENT_SET,
475+
PAUSE, SHUTDOWN, VOICE_SWITCH, … — these take effect immediately via a
476+
single MQTT ``publishDps`` without needing ``start()``.
477+
"""
435478

436479
name = "zeo"
437480

@@ -529,6 +572,13 @@ async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[Robor
529572
return {protocol: convert_zeo_value(protocol, response.get(protocol)) for protocol in protocols}
530573

531574
# ── START parameter DPs ────────────────────────────────────────
575+
# These are the wash/dry programme parameters that the official app
576+
# bundles together with START in its ``startWith`` / ``presetWith``
577+
# methods. Changing any of these via ``set_value`` does NOT
578+
# immediately launch the device — you must call ``start()``
579+
# (or ``start_with_preset()``) afterwards to commit the settings
580+
# and begin the cycle.
581+
#
532582
# Washer: Mode, Program, Temp, Rinse, Spin, DryingMode
533583
# Dryer: Mode, Program, DryingMode, DryingMethod, SteamVolume
534584
_START_PARAM_DPS_WASHER: tuple[RoborockZeoProtocol, ...] = (
@@ -587,7 +637,7 @@ async def _build_feature_gated_dps(
587637
continue
588638
dp_id = int(dp)
589639
if dp_id in self._dps_cache:
590-
dps[dp] = to_dp_bool(self._dps_cache[dp_id])
640+
dps[dp] = self._dps_cache[dp_id] # cache stores raw ints from MQTT push
591641

592642
async def _get_start_params(self) -> ZeoStartParams:
593643
"""Return start parameters, using cache when available.
@@ -626,13 +676,24 @@ async def _get_start_params(self) -> ZeoStartParams:
626676
)
627677

628678
async def start(self) -> dict[RoborockZeoProtocol, Any]:
629-
"""Start the device using the current mode and program parameters."""
679+
"""Start the device, bundling the current programme parameters.
680+
681+
Corresponds to the official app's ``startWith`` — queries the
682+
device for Mode, Program, Temperature, Rinse, Spin, DryingMode
683+
(or their dryer equivalents), discovers capabilities via DP 237,
684+
and sends everything together with the START command.
685+
686+
Call ``set_value(MODE, …)`` / ``set_value(TEMP, …)`` etc. first,
687+
then call ``start()`` to commit and launch.
688+
689+
For pausing / resuming, use :meth:`resume` which sends only
690+
``START = 1`` without re‑bundling parameters."""
630691
_LOGGER.debug("Start command: discovering features and building payload")
631692
await self._feature_trait.refresh()
632693
features = self._feature_trait.features
633694
p = await self._get_start_params()
634695
dps: dict[RoborockZeoProtocol, Any] = {
635-
RoborockZeoProtocol.START: "True",
696+
RoborockZeoProtocol.START: 1,
636697
RoborockZeoProtocol.MODE: p.mode,
637698
RoborockZeoProtocol.PROGRAM: p.program,
638699
}
@@ -660,6 +721,19 @@ async def start(self) -> dict[RoborockZeoProtocol, Any]:
660721
await self._build_feature_gated_dps(features, dps)
661722
return await send_decoded_command(self._channel, dps, value_encoder=lambda x: x, qos=MqttQos.AT_LEAST_ONCE)
662723

724+
async def resume(self) -> dict[RoborockZeoProtocol, Any]:
725+
"""Resume a paused cycle — sends only ``START = 1``.
726+
727+
Matches the official app's ``continue()`` / ``start()``
728+
(simple single-DP version). Unlike :meth:`start`, this does
729+
**not** query or re‑bundle Mode, Program or other parameters —
730+
the device simply resumes whatever programme was already in
731+
progress. ``continue`` is a Python keyword so the method is
732+
named ``resume``.
733+
"""
734+
dps: dict[RoborockZeoProtocol, Any] = {RoborockZeoProtocol.START: 1}
735+
return await send_decoded_command(self._channel, dps, value_encoder=lambda x: x, qos=MqttQos.AT_LEAST_ONCE)
736+
663737
# ── Custom programme (DP 222 bitfield) ──────────────────────────
664738

665739
async def get_custom_mode(self) -> ZeoCustomMode | ZeoDryerCustomMode:
@@ -704,9 +778,8 @@ async def set_silent_mode(
704778
"""
705779
start_mins = start_hour * 60 + start_min
706780
end_mins = end_hour * 60 + end_min
707-
# Encode only the boolean; time values must stay as integers.
708781
dps: dict[RoborockZeoProtocol, Any] = {
709-
RoborockZeoProtocol.SILENT_MODE_ON: to_dp_bool(on),
782+
RoborockZeoProtocol.SILENT_MODE_ON: 1 if on else 0,
710783
RoborockZeoProtocol.SILENT_MODE_START_TIME: start_mins,
711784
RoborockZeoProtocol.SILENT_MODE_END_TIME: end_mins,
712785
}
@@ -863,6 +936,8 @@ async def save_panel_program(
863936
return await send_decoded_command(self._channel, payload, value_encoder=lambda x: x)
864937

865938
# ── Preset / delayed start ─────────────────────────────────────
939+
# Corresponds to the official app's ``presetWith`` — same as
940+
# ``startWith`` but appends DP 217 (countdown) at the end.
866941

867942
async def start_with_preset(self, countdown_minutes: int) -> dict[RoborockZeoProtocol, Any]:
868943
"""Start the device with a delayed-start countdown.
@@ -876,7 +951,7 @@ async def start_with_preset(self, countdown_minutes: int) -> dict[RoborockZeoPro
876951
features = self._feature_trait.features
877952
p = await self._get_start_params()
878953
dps: dict[RoborockZeoProtocol, Any] = {
879-
RoborockZeoProtocol.START: "True",
954+
RoborockZeoProtocol.START: 1,
880955
RoborockZeoProtocol.MODE: p.mode,
881956
RoborockZeoProtocol.PROGRAM: p.program,
882957
}
@@ -1089,6 +1164,89 @@ async def load_feature_dps(self) -> dict[RoborockZeoProtocol, Any]:
10891164
return {}
10901165
return await self.query_values(wanted)
10911166

1167+
# ── Cleanser config (bundled 4-DP set) ───────────────────────────
1168+
# Matches the official app's ``setCleanserConfig`` which sends
1169+
# DetergentExpansionType + SoftenerExpansionType + DetergentType +
1170+
# SoftenerType in a single ``publishDps`` call.
1171+
1172+
async def set_cleanser_config(
1173+
self,
1174+
detergent_expansion: int,
1175+
softener_expansion: int,
1176+
detergent_type: int,
1177+
softener_type: int,
1178+
) -> dict[RoborockZeoProtocol, Any]:
1179+
"""Set all four detergent/softener configuration DPs in one call."""
1180+
dps: dict[RoborockZeoProtocol, Any] = {
1181+
RoborockZeoProtocol.DETERGENT_EXPANSION_TYPE: detergent_expansion,
1182+
RoborockZeoProtocol.SOFTENER_EXPANSION_TYPE: softener_expansion,
1183+
RoborockZeoProtocol.DETERGENT_TYPE: detergent_type,
1184+
RoborockZeoProtocol.SOFTENER_TYPE: softener_type,
1185+
}
1186+
result = await send_decoded_command(self._channel, dps, value_encoder=lambda x: x)
1187+
for dp, v in dps.items():
1188+
self._dps_cache[int(dp)] = v
1189+
return result
1190+
1191+
# ── Cloud program load ──────────────────────────────────────────
1192+
# Matches the official app's ``loadCloudProgram`` — sends DP 222=1
1193+
# to instruct the device to load the saved custom programme.
1194+
1195+
async def load_cloud_program(self) -> dict[RoborockZeoProtocol, Any]:
1196+
"""Load the saved cloud programme from the device (DP 222 = 1)."""
1197+
return await send_decoded_command(
1198+
self._channel,
1199+
{RoborockZeoProtocol.CUSTOM_PARAM_GET: 1},
1200+
value_encoder=lambda x: x,
1201+
)
1202+
1203+
# ── Management utilities ────────────────────────────────────────
1204+
# Thin wrappers around bundle WasherDpsManager methods (module 727).
1205+
1206+
async def check_fcc_state(self) -> dict[RoborockZeoProtocol, Any]:
1207+
"""Query DP 10001 for FCC compliance state."""
1208+
return await send_decoded_command(
1209+
self._channel,
1210+
{RoborockZeoProtocol.ID_QUERY: [RoborockZeoProtocol.F_C]},
1211+
value_encoder=json.dumps,
1212+
)
1213+
1214+
async def load_general_info(self) -> dict[RoborockZeoProtocol, Any]:
1215+
"""Query DP 10005 (robotInfo) with 10 s timeout."""
1216+
return await send_decoded_command(
1217+
self._channel,
1218+
{RoborockZeoProtocol.ID_QUERY: [RoborockZeoProtocol.PRODUCT_INFO]},
1219+
value_encoder=json.dumps,
1220+
)
1221+
1222+
async def upload_log(self) -> dict[RoborockZeoProtocol, Any]:
1223+
"""Request the device to upload diagnostic logs (RPC call)."""
1224+
import random as _random
1225+
return await send_decoded_command(
1226+
self._channel,
1227+
{RoborockZeoProtocol.RPC_REQUEST: {
1228+
"id": _random.randint(0, 999999),
1229+
"method": "user_upload_log",
1230+
}},
1231+
value_encoder=lambda x: x,
1232+
)
1233+
1234+
async def sync_privacy_to_device(self, agreed: bool) -> dict[RoborockZeoProtocol, Any]:
1235+
"""Push the user's privacy-agreement state to the device (DP 10006)."""
1236+
return await send_decoded_command(
1237+
self._channel,
1238+
{RoborockZeoProtocol.PRIVACY_INFO: {"userAgreementState": 1 if agreed else 0}},
1239+
value_encoder=lambda x: x,
1240+
)
1241+
1242+
async def update_sound_package_info(self) -> dict[RoborockZeoProtocol, Any]:
1243+
"""Re‑fetch sound-package metadata (DP 10004)."""
1244+
return await send_decoded_command(
1245+
self._channel,
1246+
{RoborockZeoProtocol.ID_QUERY: [RoborockZeoProtocol.SND_STATE]},
1247+
value_encoder=json.dumps,
1248+
)
1249+
10921250
# ── Voice / Sound JSON wire formats ─────────────────────────────
10931251
# The official app serialises voice DPs as JSON objects on the wire.
10941252
# Return raw dicts here — ``encode_mqtt_payload`` wraps the entire
@@ -1103,12 +1261,26 @@ async def load_feature_dps(self) -> dict[RoborockZeoProtocol, Any]:
11031261
async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[RoborockZeoProtocol, Any]:
11041262
"""Set a value for a specific protocol on the device.
11051263
1264+
── Two categories of DPs ──
1265+
1266+
**startWith params** (MODE, PROGRAM, TEMP, RINSE_TIMES, SPIN_LEVEL,
1267+
DRYING_MODE, DRYING_METHOD, STEAM_VOLUME, SOAK, DRY_CARE_MODE,
1268+
TOTAL_TIME) — these are *settings* that the device stages locally.
1269+
They do **not** take effect until ``start()`` (bundle
1270+
``startWith``) commits them together with START. Call
1271+
``set_value`` to choose the programme, then ``start()`` to
1272+
launch.
1273+
1274+
**Independent** (CHILD_LOCK, SOUND_SET, UV_LIGHT, SILENT_MODE_ON,
1275+
DETERGENT_SET, PAUSE, SHUTDOWN, DETERGENT_TYPE, …) — these take
1276+
effect immediately via a single MQTT ``publishDps`` call. No
1277+
``start()`` is needed.
1278+
11061279
Writes the value to the DPS cache after a successful MQTT
11071280
publish so that a subsequent ``start()`` call sees the latest
11081281
setting immediately. This cache write is PERMANENT and cannot
11091282
be replaced by MQTT push alone: ``start()`` needs the value
1110-
before the device echoes the change back.
1111-
"""
1283+
before the device echoes the change back."""
11121284
if protocol == RoborockZeoProtocol.START and parse_bool(value):
11131285
return await self.start()
11141286

0 commit comments

Comments
 (0)