Skip to content

feat: typed commands, settings & status for Zeo devices - #897

Open
NOisi-x wants to merge 15 commits into
Python-roborock:mainfrom
NOisi-x:pr/zeo-core-api-v2
Open

feat: typed commands, settings & status for Zeo devices#897
NOisi-x wants to merge 15 commits into
Python-roborock:mainfrom
NOisi-x:pr/zeo-core-api-v2

Conversation

@NOisi-x

@NOisi-xNOisi-x commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Builds on #895
Closes#833

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 + ZeoFeatureTrait draft, restructured into a single
ZeoApi entry point with three lazily-built sub-traits, and hardened by
on-device verification.

The API surface

api: ZeoApi=device.zeo# built by a01 create() from the product category# Read-only state, typed (state, error, timers, tank levels, wash log, ...)api.status.state# ZeoState.standby / washing / ...api.status.washing_left# minutes remainingapi.status.washing_log# ZeoWashLog with typed ZeoProgram / ZeoMode# Writable state + typed setters (mode, programme, temperature, spin, ...)api.settings.mode# ZeoMode.washapi.settings.temperature# ZeoTemperature.mediumawaitapi.settings.set_temperature(ZeoTemperature.high)
# Commandsawaitapi.command.start_with(ZeoStartParams(
mode=ZeoMode.wash_and_dry,
program=ZeoProgram.silk,
temperature=ZeoTemperature.low,
rinse=ZeoRinse.high,
spin=ZeoSpin.mid,
drying_mode=ZeoDryingMode.quick,
ion_deodorization=True, # caller-supplied, feature-gated
))
awaitapi.command.start_with_custom_mode() # reuse device's saved DP 222 programmeawaitapi.command.preset_with(params, minutes=120) # delayed start (>30 min enters countdown)awaitapi.command.pause() /resume() /stop() /shutdown()

Key pieces

ModuleRole
traits/a01/__init__.pyZeoApi — MQTT push subscription, two-stage force-load, query_values/set_value/get_custom_mode, lazily exposes command/settings/status
traits/a01/command.pyZeoCommandTraitstart_with, start_with_custom_mode, preset_with, pause, resume, stop, shutdown
traits/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.pySeries whitelists (is_dryer, supports_uv_light, ...) + ZeoFeatures parsed from DP 237 FEATURE_BITS

Design decisions

  1. 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_dryer flag — resolved once at
    construction from the model ID — mirrors how V1's CommandTrait handles
    vastly different dock types through one class.

  2. Two-stage state load, matching the app.ZeoApi.start() queries the
    base 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 is
    non-fatal on failure (logged, backfilled by subsequent MQTT pushes).

  3. Caller-supplied start parameters.ZeoStartParams is the single
    source
    of what gets started. Feature-gated values like
    ion_deodorization (DP 258) and wash_dry_linked (DP 255) are passed by
    the caller (None omits the DP), instead of being silently read back from
    the device cache. start_with(params) is a pure input → command function.

  4. Integer boolean protocol. Boolean DPs (auto-dosing 211/212, feature
    gates) are sent as integers 1/0 — on-device testing showed string
    booleans ("False") are silently ignored by the device.

  5. Typed wash log.ZeoWashRecord.prog_typeZeoProgram and
    categoryZeoMode, so consumers get record.prog_type.name directly.
    Unknown values degrade to None instead 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 into
    ZeoStartParams and the device boots with exactly those parameters
    (state: standby → washing, program: boiling_wash → silk), auto-dosing
    integers accepted.
  • preset_with — a valid parameter combination with minutes > 30
    reliably enters the delay-start countdown state (under_delay_start,
    countdown set). Constraint documented in the docstring.
  • pause / resume / shutdown — single-DP commands, QoS 1.

Follow-ups (next PR)

  • Programme-config table-Solve the problem of making startup parameters valid

NOisi-Xand others added 5 commits July 20, 2026 11:39
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().
@NOisi-x
NOisi-xforce-pushed the pr/zeo-core-api-v2 branch 3 times, most recently from cbb254f to c57f0b7CompareJuly 22, 2026 14:38

@allenporterallenporter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
ContributorAuthor

@allenporter The difference between washer and dryer in ZeoCommandTrait is just the start parameter list — 10 DPs vs 7 DPs. Everything else (start_program, pause, resume, shutdown, cache checking, feature gating) is identical since they are all Zeo devices. Splitting into two classes would duplicate ~95% of the code.

V1's CommandTrait serves the same role for vacuums with vastly different dock types (pure collect vs collect+wash+dry+plumbing) — all through a single trait. The is_dryer flag here is equivalent to V1's dock_features check: resolved once at construction, not at runtime.

Given the minimal difference, is the current single-trait approach acceptable, or would you still prefer separate classes?

@allenporter

Copy link
Copy Markdown
Contributor

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)

NOisi-Xand others added 2 commits August 6, 2026 16:46
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
@NOisi-x
NOisi-xforce-pushed the pr/zeo-core-api-v2 branch from 437ec29 to 1fcacf4CompareAugust 6, 2026 08:53
@NOisi-x

Copy link
Copy Markdown
ContributorAuthor

@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?

@NOisi-x
NOisi-xforce-pushed the pr/zeo-core-api-v2 branch from 1fcacf4 to 103266aCompareAugust 6, 2026 09:06
NOisi-xand others added 4 commits August 12, 2026 08:44
…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.
@NOisi-x
NOisi-x marked this pull request as draft August 13, 2026 14:44
@NOisi-xNOisi-x changed the title feat: add core ZeoApi with ZeoCommandTrait and ZeoFeatureTraitfeat: typed commands, settings & status for Zeo devicesAug 15, 2026
@NOisi-x

Copy link
Copy Markdown
ContributorAuthor

@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.

@allenporter
allenporter marked this pull request as ready for review August 15, 2026 17:42
CopilotAI lite review requested due to automatic review settings August 15, 2026 17:42

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ZeoFeatures dataclass 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
FileDescription
roborock/roborock_message.pyExpands/clarifies Zeo DP IDs and meta command semantics (incl. sound/voice payload formats).
roborock/devices/traits/a01/__init__.pyRefactors Zeo A01 into ZeoApi with lazy command/settings/status, feature discovery, and cache handling.
roborock/devices/traits/a01/command.pyAdds Zeo command trait for starting, scheduling, and controlling programmes.
roborock/devices/traits/a01/settings.pyAdds typed writable settings, typed setters, and params→DP mapping helpers.
roborock/devices/traits/a01/status.pyAdds typed read-only status trait updated from DPS/push stream.
roborock/devices/traits/a01/device_feature.pyAdds ZeoFeatures parsing from feature bits and new series helpers.
roborock/data/zeo/zeo_containers.pyExtends 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.

Comment on lines 92 to 99
__init__ = [
"DyadApi",
"ZeoApi",
"ZeoCommandTrait",
"ZeoFeatures",
"ZeoSettingTrait",
"ZeoStatusTrait",
]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sent #920

Comment on lines +340 to +347
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
Comment on lines +357 to +359
if self._status is None:
self._status = ZeoStatusTrait()
return self._status
Comment on lines +163 to +179
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
Comment on lines +181 to +194
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

@allenporterallenporter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are you expecting to happen, end to end, here?


@property
def command(self) -> ZeoCommandTrait:
"""Lazily-built trait for wash-programme commands."""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature/Bug] Zeo Washing Machine (M1S Ultra): Deep Sleep Wakeup Failure & Missing DP Mappings

3 participants

@NOisi-x@allenporter