Skip to content

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

Open
NOisi-x wants to merge 20 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 20 commits into
Python-roborock:mainfrom
NOisi-x:pr/zeo-core-api-v2

Conversation

@NOisi-x

@NOisi-x NOisi-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 remaining
api.status.washing_log           # ZeoWashLog with typed ZeoProgram / ZeoMode

# Writable state + typed setters (mode, programme, temperature, spin, ...)
api.settings.mode                # ZeoMode.wash
api.settings.temperature         # ZeoTemperature.medium
await api.settings.set_temperature(ZeoTemperature.high)

# Commands
await api.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
))
await api.command.start_with_custom_mode()   # reuse device's saved DP 222 programme
await api.command.preset_with(params, minutes=120)  # delayed start (>30 min enters countdown)
await api.command.pause() / resume() / stop() / shutdown()

Key pieces

Module Role
traits/a01/__init__.py ZeoApi — MQTT push subscription, two-stage force-load, query_values/set_value/get_custom_mode, lazily exposes command/settings/status
traits/a01/command.py ZeoCommandTraitstart_with, start_with_custom_mode, preset_with, pause, resume, stop, shutdown
traits/a01/settings.py ZeoSettingTrait — typed writable state + typed setters, build_param_dps (params → DPs)
traits/a01/status.py ZeoStatusTrait — read-only state (state/error/timers/tanks/wash log)
traits/a01/device_feature.py Series 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-X and 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-x force-pushed the pr/zeo-core-api-v2 branch 3 times, most recently from cbb254f to c57f0b7 Compare July 22, 2026 14:38

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

NOisi-x commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

@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-X and 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-x force-pushed the pr/zeo-core-api-v2 branch from 437ec29 to 1fcacf4 Compare August 6, 2026 08:53
@NOisi-x

NOisi-x commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

@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-x force-pushed the pr/zeo-core-api-v2 branch from 1fcacf4 to 103266a Compare August 6, 2026 09:06
NOisi-x and 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-x
NOisi-x force-pushed the pr/zeo-core-api-v2 branch from 103266a to 7716150 Compare August 15, 2026 06:29
@NOisi-x NOisi-x changed the title feat: add core ZeoApi with ZeoCommandTrait and ZeoFeatureTrait feat: typed commands, settings & status for Zeo devices Aug 15, 2026
@NOisi-x

NOisi-x commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

@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
Copilot AI lite review requested due to automatic review settings August 15, 2026 17:42

Copilot AI 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
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.

Comment thread roborock/devices/traits/a01/__init__.py Outdated
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 thread roborock/devices/traits/a01/command.py Outdated
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 thread roborock/devices/traits/a01/command.py Outdated
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

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

Comment thread roborock/devices/traits/a01/__init__.py Outdated
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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 a voiceVolume getter
    and includes it in loadFeatureDps (gated by VoiceAssistant), so I added
    its _try_json converter so query_values() returns the parsed JSON object.
  • VOICE_SWITCH (10301) — read-write; same story (isVoiceSwitchOn getter,
    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."""

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)

@NOisi-x NOisi-x Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread roborock/devices/traits/a01/__init__.py Outdated
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed — changed the return type from Any to dict[str, Any] | None.

Comment thread roborock/devices/traits/a01/__init__.py Outdated
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread roborock/devices/traits/a01/command.py Outdated
)
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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).
@NOisi-x
NOisi-x force-pushed the pr/zeo-core-api-v2 branch from 2cba1cf to 9ea7225 Compare August 17, 2026 02:31
NOisi-X added 4 commits August 17, 2026 10:47
_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.
@NOisi-x

NOisi-x commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

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?

@allenporter Please don't be put off by the line count — the bulk of the diff is three new
trait files (settings.py, command.py, status.py), and most of their
methods are trivial semantic wrappers over the raw MQTT/DPS protocol. There's almost no branching logic in them.

I wrote them for completeness of the semantic API surface. If you feel some of
them are unnecessary, they can be trimmed — I'm happy to drop the ones that
carry no real logic and keep only the wrappers that do meaningful work.

On splitting: I'd still prefer to land this as one PR, because the existing
Zeo support is a raw passthrough with almost no typed surface, and this PR
collectively gives Zeo a nearly complete typed API (commands, setters, status,
feature detection). Splitting would mean landing half-working slices. But I'm open to it — if you point at a seam
you'd like to split along, I'll follow your lead. There's no deadline here, so
please review at whatever pace is comfortable.

Finally — thank you for the careful review. Your comments have been really
valuable to me; each one made me re-examine my work and the resulting code
is much better for it. I really appreciate the time you're putting into this.

Sign up for free to 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