feat: typed commands, settings & status for Zeo devices - #897
Conversation
Add MqttQos enum (AT_MOST_ONCE=0, AT_LEAST_ONCE=1, EXACTLY_ONCE=2) and thread a qos parameter through the publish chain (MqttSession -> MqttChannel -> send_decoded_command). All existing callers keep default AT_MOST_ONCE (backward compatible). Also add a unix timestamp field to A01 encode_mqtt_payload, required by Zeo/Dyad devices for command acceptance.
… all 56 devices covered Expand RoborockZeoProtocol from 31 to 67 DP entries, ordered by numeric ID. Add all missing enum classes (ZeoFeatureBits, ZeoDryingMethod, ZeoSteamVolume, ZeoDryAndCare, ZeoDryerStartError) and extend existing enums to cover every state/value found in the official app plugin bundle. Add ZeoStartParams, ZeoCustomMode, and ZeoDryerCustomMode data containers inheriting from RoborockBase, placed in zeo_containers.py per reviewer guidance.
Update ZeoStartParams, ZeoCustomMode, and ZeoDryerCustomMode to use typed enum fields (ZeoMode, ZeoProgram, ZeoTemperature, etc.) instead of raw int, aligning with the V1 container pattern in v1_containers.py. Rename shorthand fields (rinse_times→rinse, spin_level→spin) for consistency across all three classes. Unify drying-mode field naming.
…overy Subscribes to the device DPS MQTT topic after connection. Incoming RPC_RESPONSE messages are decoded and merged into _dps_cache with incremental updates. _discover_features() queries FEATURE_BITS (DP 237) to wake the device and cache capabilities — equivalent to V1's discover_features(). Also fixes TraitUpdateListener init in ZeoApi and a01_properties routing in connect().
cbb254f to
c57f0b7
Compare
allenporter
left a comment
There was a problem hiding this comment.
Great work articulating the differences between the washer and dryer. Given the differences (e.g. params, modes, etc). This seems like a perfect use for separate traits. It seems like a "washer trait" and "dryer trait" now make sense to introduce.
Can you review the existing trait pattern for prior art?
|
@allenporter The difference between washer and dryer in V1's Given the minimal difference, is the current single-trait approach acceptable, or would you still prefer separate classes? |
|
II'm thinking of this more like: What do solid washer and dryer APIs look like? My assumption is we'll want APIs that return the current settings to the caller in these new modes -- not just start params but also querying the values and/or holding on to the current state. That is, if we're moving to traits there are more benefit is in terms of using these new types we have defined. The thing to prioritize is what the API looks like. We can avoid code duplicating by sharing code where it makes sense. (If its really all duplicated, then there are more solutions than just having all the code in the same file. (e.g. sharing code between separate files is possible) |
except Exception → except RoborockException (aligns with Bundle's silent fallback to 0) try/except only wraps decode_rpc_response — cache updates and notify must propagate
437ec29 to
1fcacf4
Compare
|
@allenporter I agree with the direction — separate washer/dryer traits with type-safe APIs returning our enum types is the right end state. The individual DP getters/setters (set_program(ZeoTemperature), get_mode() → ZeoMode, etc.) are where the washer/dryer API divergence really shows up, and that's exactly where splitting makes sense. However, those methods are out of scope for this PR. Right now the trait only does start_program (bundled command), pause, resume, shutdown — all of which are identical for washers and dryers. Adding typed per-DP methods would roughly double the size of this PR, and I have limited time for this project in the near future. I'd prefer to land this as-is (single trait, scope limited to composite commands + feature discovery) and handle the trait split together with the typed getter/setter work in a follow-up PR. Would that be acceptable? |
1fcacf4 to
103266a
Compare
…e's forceLoad() Sends ID_QUERY with 28 base DPs (including FEATURE_BITS) in a single round-trip after MQTT subscribe. This triggers a complete state dump from the device 鈥?matching Bundle's startup flow exactly. For devices known to lack FEATURE_BITS (a63, a90), the DP is excluded from the query list.
Address reviewer feedback: integrate ZeoApi.close() into RoborockDevice.close() and implement the second-stage feature DP load matching Bundle's loadFeatureDps(). The first force-load now also includes smart-hosting DPs (235/236/238), and a follow-up query fetches feature-gated DPs (silent mode, dry care, smile light, dirt detection, wash/dry linkage, etc.) plus UV light gated by a series whitelist. Feature-load failures are non-fatal.
…pplied start params
103266a to
7716150
Compare
|
@allenporter Now that I have some time to continue diving into this project, I've refactored this PR. Please check out my updated PR description and review it. Thanks a lot. |
There was a problem hiding this comment.
Pull request overview
This PR introduces a typed, higher-level Zeo (A01 washing machine/dryer) API surface that splits functionality into lazily constructed command, settings, and status traits, and expands the Zeo DP/protocol mappings to support richer device control and state.
Changes:
- Add new Zeo A01 traits: typed read-only status, typed writable settings + setters, and a command trait for start/pause/resume/stop/shutdown/preset flows.
- Extend Zeo protocol DP enum coverage and JSON/meta DP handling (sound package info, voice-related payloads, unknown DPs).
- Add feature-bit parsing into a typed
ZeoFeaturesdataclass and use it for feature-gated DP loading and setters.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
roborock/roborock_message.py |
Expands/clarifies Zeo DP IDs and meta command semantics (incl. sound/voice payload formats). |
roborock/devices/traits/a01/__init__.py |
Refactors Zeo A01 into ZeoApi with lazy command/settings/status, feature discovery, and cache handling. |
roborock/devices/traits/a01/command.py |
Adds Zeo command trait for starting, scheduling, and controlling programmes. |
roborock/devices/traits/a01/settings.py |
Adds typed writable settings, typed setters, and params→DP mapping helpers. |
roborock/devices/traits/a01/status.py |
Adds typed read-only status trait updated from DPS/push stream. |
roborock/devices/traits/a01/device_feature.py |
Adds ZeoFeatures parsing from feature bits and new series helpers. |
roborock/data/zeo/zeo_containers.py |
Extends Zeo typed containers (start params + wash log structures). |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| __init__ = [ | ||
| "DyadApi", | ||
| "ZeoApi", | ||
| "ZeoCommandTrait", | ||
| "ZeoFeatures", | ||
| "ZeoSettingTrait", | ||
| "ZeoStatusTrait", | ||
| ] |
| if self._settings is None: | ||
| self._settings = ZeoSettingTrait( | ||
| self._channel, | ||
| model=self._model, | ||
| is_dryer=is_dryer(self._model), | ||
| features=lambda: self._features, | ||
| ) | ||
| return self._settings |
| if self._status is None: | ||
| self._status = ZeoStatusTrait() | ||
| return self._status |
| async def pause(self) -> dict[RoborockZeoProtocol, Any]: | ||
| """Pause the current programme (DP 201 = 1). | ||
|
|
||
| Returns the DPs that were actually sent. | ||
| """ | ||
| dps = {RoborockZeoProtocol.PAUSE: 1} | ||
| await send_decoded_command(self._channel, dps) | ||
| return dps | ||
|
|
||
| async def resume(self) -> dict[RoborockZeoProtocol, Any]: | ||
| """Start/continue a paused programme (DP 200 = 1). | ||
|
|
||
| Only works while the device is powered on. Returns the DPs sent. | ||
| """ | ||
| dps = {RoborockZeoProtocol.START: 1} | ||
| await send_decoded_command(self._channel, dps) | ||
| return dps |
| async def stop(self) -> dict[RoborockZeoProtocol, Any]: | ||
| """Stop the current programme (DP 200 = 0).""" | ||
| dps = {RoborockZeoProtocol.START: 0} | ||
| await send_decoded_command(self._channel, dps) | ||
| return dps | ||
|
|
||
| async def shutdown(self) -> dict[RoborockZeoProtocol, Any]: | ||
| """Power off the device (DP 202 = 1). | ||
|
|
||
| Only works while the device is powered on. Returns the DPs sent. | ||
| """ | ||
| dps = {RoborockZeoProtocol.SHUTDOWN: 1} | ||
| await send_decoded_command(self._channel, dps) | ||
| return dps |
allenporter
left a comment
There was a problem hiding this comment.
Hi, this PR is over 1k lines and is pretty huge, so not going to be something i can review quickly.
We're adding features, fixing small issues/enums, and also there are also multiple fundamental architecture shifts we're making here because the existing support is pretty naive. We're going to need to work on these more incrementally. Can we tease this apart into a multiple smaller logical PRs please?
| try: | ||
| return json.loads(val) | ||
| except ValueError: | ||
| pass |
There was a problem hiding this comment.
What are you expecting to happen, end to end, here?
There was a problem hiding this comment.
End to end, _try_json exists because these voice DPs are sent by the device
as JSON strings, not scalar values, and the converter registered in
ZEO_PROTOCOL_ENTRIES is what turns the raw string into the parsed object
callers actually want. In this PR I touched four of them:
VOICE_VOLUME(10009) — read-write; the bundle has avoiceVolumegetter
and includes it inloadFeatureDps(gated byVoiceAssistant), so I added
its_try_jsonconverter soquery_values()returns the parsed JSON object.VOICE_SWITCH(10301) — read-write; same story (isVoiceSwitchOngetter,
in the feature query list) — converter added.SET_SOUND_PACKAGE(10003) — write-only; no getter, not in any query list —
converter removed (dead code).VOICE_RECORD_DELETE(10304) — write-only; same — converter removed.
So the end-to-end flow for the two read-write ones is:
query_values([VOICE_VOLUME])
→ device returns e.g. '{"snd_volume": 15}' (a string)
→ convert_zeo_value → _try_json → json.loads → {"snd_volume": 15}
→ caller gets the parsed dict
The except ValueError: return val fallback is deliberate: _try_json is
shared by several DPs, and it must not crash on a value that isn't valid JSON
(a plain number, a value already parsed, or a non-JSON string) — it returns
the value as-is, and convert_zeo_value catches any remaining TypeError and
yields None. So the contract is: parse when possible, leave it alone
otherwise.
|
|
||
| @property | ||
| def command(self) -> ZeoCommandTrait: | ||
| """Lazily-built trait for wash-programme commands.""" |
There was a problem hiding this comment.
What is your intent behind the laziy-built traits? (One side effect: Updates aren't applied if it hasn't been added yet, which is surprising to me)
There was a problem hiding this comment.
I just read your new comments in #895 and find I am totally wrong now.
Really sorry about that.
I want to be fully transparent here: the lazy traits + _dps_cache design was
my own choice, and I've come to see it as the root of much of this PR's
complexity — it's what forces the backfill, the pre-warm query, the null
checks, and the side-effect dependency in query_values() that you flagged in
multiple places. My lack of experience with this codebase is the honest reason
it ended up that way; I reached for a caching layer before fully working
through the data flow. I realize now that an always-present (eager) trait model
— where pushes flow straight into the traits and no cache exists — would be
simpler and match the bundle's own onDpsChange behavior directly.
Rather than restructure it inside this PR and force another review cycle, I'd
like to do this as a follow-up PR, and I'd value your confirmation on the
direction before I start. My plan:
make the traits always-present, constructed in start() after _force_load;
delete _dps_cache entirely — pushes route straight into the traits via
update_from_dps();
drop the pre-warm side-effect: _load_feature_dps keeps querying the
feature-gated DPs (the two-stage load is the bundle's design), but its
return value then gets consumed normally instead of discarded.
Scope is roughly a01/init.py plus the trait tests — no HA-facing API
change. Does that direction work, or would you restructure it differently?
I'm happy to adjust to your preference before opening the PR.
| return ZeoDryerCustomMode.from_raw(raw_int, total_time) | ||
| return ZeoCustomMode.from_raw(raw_int, total_time) | ||
|
|
||
| async def update_sound_package_info(self) -> Any: |
There was a problem hiding this comment.
We shouldn't use Any but a more specific type here.
There was a problem hiding this comment.
Agreed — changed the return type from Any to dict[str, Any] | None.
| async def get_custom_mode(self) -> ZeoCustomMode | ZeoDryerCustomMode | None: | ||
| """Query and decode the current custom programme (DP 222).""" | ||
| await self.query_values([RoborockZeoProtocol.CUSTOM_PARAM_GET, RoborockZeoProtocol.TOTAL_TIME]) | ||
| raw = self._dps_cache.get(int(RoborockZeoProtocol.CUSTOM_PARAM_GET)) |
There was a problem hiding this comment.
I don't really understand this. query values has a return value.
Is the problem you're trying to workaround here with with dps cache is that the return values don't work reliably or something? It may be that the existing approach for this device is wrong, and we need to move to refresh + trait listeners.
There was a problem hiding this comment.
You're right — there was no reason to bypass the return value. To be honest, my
original rationale for reading _dps_cache was a mistake. I reasoned that MQTT
messages are parsed continuously, and since the return value is itself an MQTT
message that gets fed back into the cache, I could just read the cache back.
So the old code discarded the reliable return value and re-read the raw string
from _dps_cache, then converted it again by hand — pointless indirection.
Fixed: get_custom_mode() now uses query_values()'s return value directly.
| ) | ||
| return dps | ||
|
|
||
| async def pause(self) -> dict[RoborockZeoProtocol, Any]: |
There was a problem hiding this comment.
what is your intent intent behind including the dps return value here? I don't think these commands should have any return value at all.
There was a problem hiding this comment.
Fair point that the commands are fire-and-forget — send_decoded_command()
only publishes the payload and never waits for a device response. But the
return value on the start methods is intentional, and it isn't just echoing
the caller's input:
start_with() / preset_with() accept any combination of start
parameters the caller provides, and not every combination is valid for the
device. There is a programme-config template (not yet implemented) that
defines the valid parameter sets; the returned DPS frame is what I plan to
validate against it. The frame also includes DPs these methods add on their
own — auto-dosing (DP 211/212) pulled from the settings trait and feature-
gated DPs (255/258) — which the caller can't derive from the params alone, so
returning it gives them the full picture of what was actually sent.
So I've kept the return value on the parameter-combination methods
(start_with, start_with_custom_mode, preset_with), since those are the
ones that accept arbitrary combos and are candidates for validation. The
simple one-shot commands (pause, resume, stop, shutdown) send a fixed
single DP and now return None, matching the rest of the codebase's command
traits.
VoiceVolume (10009) and VoiceSwitch (10301) are read-write: they have getters (voiceVolume / isVoiceSwitchOn) and appear in the bundle's loadFeatureDps query list (gated by FeatureBit.VoiceAssistant). Add their _try_json converters to the read-write protocol entries so query_values() returns parsed JSON. SetSoundPackage (10003) and VoiceRecordDelete (10304) are write-only (no getter, not in any query list) - remove their dead converters. Rename module-level __init__ to __all__ (former was a bug that overshadowed the module's __init__ attribute).
2cba1cf to
9ea7225
Compare
_settings/_status are lazily constructed; _update_settings_from_dps only routed pushes into a trait after it was built. State pushed before the first access landed in _dps_cache but was never applied, so the first access returned a fresh empty trait and silently lost all prior state. Treat _dps_cache as the single source of truth: on first construction, each trait backfills itself via update_from_dps(self._dps_cache). update_from_dps ignores undeclared DPs, so feeding the whole cache is safe.
…n on start methods
@allenporter Please don't be put off by the line count — the bulk of the diff is three new I wrote them for completeness of the semantic API surface. If you feel some of On splitting: I'd still prefer to land this as one PR, because the existing Finally — thank you for the careful review. Your comments have been really |
What this PR adds
This PR delivers the core Zeo API — the ability to actually start, pause,
resume, stop and schedule a wash programme on Zeo (washing machine / dryer)
devices, with fully typed parameters. It is the successor to the earlier
ZeoCommandTrait+ZeoFeatureTraitdraft, restructured into a singleZeoApientry point with three lazily-built sub-traits, and hardened byon-device verification.
The API surface
Key pieces
traits/a01/__init__.pyZeoApi— MQTT push subscription, two-stage force-load,query_values/set_value/get_custom_mode, lazily exposescommand/settings/statustraits/a01/command.pyZeoCommandTrait—start_with,start_with_custom_mode,preset_with,pause,resume,stop,shutdowntraits/a01/settings.pyZeoSettingTrait— typed writable state + typed setters,build_param_dps(params → DPs)traits/a01/status.pyZeoStatusTrait— read-only state (state/error/timers/tanks/wash log)traits/a01/device_feature.pyis_dryer,supports_uv_light, ...) +ZeoFeaturesparsed from DP 237 FEATURE_BITSDesign decisions
One trait family, not per-device classes. Washer vs dryer differ only in
which start parameters are sent (10 DPs vs 7). Everything else is shared, so
a single set of traits with an
is_dryerflag — resolved once atconstruction from the model ID — mirrors how V1's
CommandTraithandlesvastly different dock types through one class.
Two-stage state load, matching the app.
ZeoApi.start()queries thebase DP list first, then issues a follow-up query for the DPs gated behind
each enabled FEATURE_BITS (DP 237) bit — the same
forceLoad()→loadFeatureDps()flow in the official Bundle. The second stage isnon-fatal on failure (logged, backfilled by subsequent MQTT pushes).
Caller-supplied start parameters.
ZeoStartParamsis the singlesource of what gets started. Feature-gated values like
ion_deodorization(DP 258) andwash_dry_linked(DP 255) are passed bythe caller (
Noneomits the DP), instead of being silently read back fromthe device cache.
start_with(params)is a pure input → command function.Integer boolean protocol. Boolean DPs (auto-dosing 211/212, feature
gates) are sent as integers
1/0— on-device testing showed stringbooleans (
"False") are silently ignored by the device.Typed wash log.
ZeoWashRecord.prog_type→ZeoProgramandcategory→ZeoMode, so consumers getrecord.prog_type.namedirectly.Unknown values degrade to
Noneinstead of breaking the whole log decode.Verified on real hardware
All command paths below were tested against a physical device (MQTT trace +
state transitions confirmed):
start_with_custom_mode— DP 222 custom programme is decoded intoZeoStartParamsand the device boots with exactly those parameters(
state: standby → washing,program: boiling_wash → silk), auto-dosingintegers accepted.
preset_with— a valid parameter combination withminutes > 30reliably enters the delay-start countdown state (
under_delay_start,countdownset). Constraint documented in the docstring.pause/resume/shutdown— single-DP commands, QoS 1.Follow-ups (next PR)