@@ -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
133134DYAD_PROTOCOL_ENTRIES : dict [RoborockDyadDataProtocol , Callable ] = {
@@ -166,7 +167,6 @@ def to_dp_bool(val: Any) -> str:
166167ZEO_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
433438class 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