Uh oh!
There was an error while loading. Please reload this page.
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
c57f0b7Compare
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?
NOisi-x
commented
Jul 28, 2026
@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? |
allenporter
commented
Jul 30, 2026
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
1fcacf4CompareNOisi-x
commented
Aug 6, 2026
@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
103266aCompare…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
7716150CompareNOisi-x
commented
Aug 15, 2026
@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?
| @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)
| 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.
| 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.
| ) | ||
| 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.
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)