Skip to content

feat!: complete full Zeo device support#871

Open
NOisi-x wants to merge 19 commits into
Python-roborock:mainfrom
NOisi-x:main
Open

feat!: complete full Zeo device support#871
NOisi-x wants to merge 19 commits into
Python-roborock:mainfrom
NOisi-x:main

Conversation

@NOisi-x

@NOisi-x NOisi-x commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR completes Zeo (washing machine) device support in python-roborock.

The library already had foundational A01 protocol support for Zeo devices, but several critical gaps prevented practical use: the start command didn't work, roughly half of the DP IDs were undefined, several device states and enum classes were missing, and most data points had no value converters. This PR closes those gaps, bringing the DP and enum definitions in line with the Roborock Washer app protocol.

With these changes, Zeo devices support the full range of operations defined by the protocol: start/stop/pause/shutdown, mode and program selection, temperature, rinse, spin, drying control, child lock, auto detergent/softener, UV sterilization, soak, silent mode, dry care, steam, ion deodorization, wash-dry linkage, and more.

Motivation

The library's existing Zeo set_value(START, 1) did not actually start the device. Through protocol analysis and comparison with the Roborock Washer HomeAssistant integration, three root causes were identified:

  1. QoS level: DP200 must be published at QoS 1; the default QoS 0 was rejected by the broker.
  2. Missing timestamp: A01 command payloads require a "t" (Unix timestamp) field.
  3. Missing parameter bundle: The device firmware requires MODE and PROGRAM to be sent together with START in a single MQTT message.

Beyond the start command, RoborockZeoProtocol covered only 31 of 67 known DP IDs, zeo_code_mappings.py was missing 4 enum classes and several enum values, and ZEO_PROTOCOL_ENTRIES provided converters for only 16 of the 52 user-facing DPs — meaning query_values() silently returned None for most data points.

Changes

1. MQTT QoS — roborock/mqtt/roborock_session.py

Changed client.publish() to use qos=1 (at-least-once delivery).

2. A01 payload timestamp — roborock/protocols/a01_protocol.py

Added "t": int(time.time()) to the JSON payload structure in encode_mqtt_payload().

3. Combined start command — roborock/devices/traits/a01/__init__.py

ZeoApi.set_value(START, 1) now:

  • Queries the device for its current MODE, PROGRAM, TEMP, RINSE_TIMES, SPIN_LEVEL, and DRYING_MODE
  • Bundles the returned values with {START: 1} into a single MQTT message
  • Falls back to MODE=1, PROGRAM=1 if the query fails

4. Extended DP ID enum — roborock/roborock_message.py

RoborockZeoProtocol expanded from 31 to 67 entries, now covering all 52 user-facing DPs (200-266) plus meta DPs. Newly added: dirt detection (215-216), UV light (228), soak (233), total time (234), smart hosting (235-238), silent mode (240-242), dry care mode (244), wash-dry linkage (255), drying method (256), steam volume (257), ion deodorization (258), dryer start error (265), and more.

5. Enum updates — roborock/data/zeo/zeo_code_mappings.py

  • ZeoMode: added treatment = 4
  • ZeoState: expanded from 13 to 20 states
  • ZeoSoak: added very_max = 5
  • New enum classes: ZeoDryingMethod, ZeoSteamVolume, ZeoDryAndCare, ZeoDryerStartError

6. Value converters — roborock/devices/traits/a01/__init__.py

ZEO_PROTOCOL_ENTRIES expanded from 16 to 57 entries, providing typed converters for all user-facing DPs (200-266).

Backward Compatibility

All changes are additive or transparent:

  • ZeoApi.query_values() and ZeoApi.set_value() signatures unchanged
  • New DP IDs and enums are pure additions
  • QoS change applies to all MQTT publishes, matching the expected at-least-once delivery
  • Start command query adds ~1-2s latency; all other set_value calls unaffected

Known Limitation

sent_decoded_command uses a strict "all requested DPs must respond" completion condition. Since A01 devices do not expose a capability probing mechanism, there is currently no clean way to determine which optional DPs (e.g., SOAK, STEAM_VOLUME) a given device supports before querying them.

As a temporary workaround, the start command queries and bundles only the following 6 core DPs, which has been verified to successfully start devices on physical Zeo hardware:

DP Name Purpose
204 MODE Wash mode (e.g. wash, wash-and-dry, dry)
205 PROGRAM Wash program (e.g. standard, quick, wool)
207 TEMP Water temperature
208 RINSE_TIMES Number of rinse cycles
209 SPIN_LEVEL Spin speed
210 DRYING_MODE Drying mode (e.g. quick, iron, store)

These are bundled with {START: 1} and sent as a single MQTT message. The device applies its internally-stored values for any parameters not explicitly included.

A better long-term solution for dynamically detecting device capabilities is welcome — contributions or ideas in this area are appreciated.

Testing

  • set_value(START, 1) verified working on physical Zeo One and Zeo M1S devices
  • set_value for non-START DPs (mode, program, temperature, etc.) continues to work as before
  • Coordinator polling and DPS push updates unaffected
  • All changes pass linting (ruff, mypy)

Acknowledgments

This PR draws heavily on the excellent reverse-engineering work done in ndwzy/roborock_washer_ha. The A01 protocol structure, DP definitions, enum mappings, and the combined start command behavior were all cross-referenced against that project's implementation. The DP ID list (200-266), state enum values, and device error codes were verified against the Roborock app bundle extracted and analyzed by that project.


Closes #833

@Lash-L

Lash-L commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Hey @NOisi-x something about your changes seem to have broken tests. Tests are running indefinitely, can you take a look?

@NOisi-x

NOisi-x commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Hey @NOisi-x something about your changes seem to have broken tests. Tests are running indefinitely, can you take a look?

Hi, @Lash-L ,I've updated the test files and all tests are now passing. Thanks!

CI Failure Analysis and Fix Summary

Apologies for the broken CI — I'm a hobbyist with fairly limited programming experience and I overlooked the fact that the source changes would affect the existing test suite. Lesson learned for next time. Below is an analysis of what went wrong and what was changed to fix it.

Root Causes

The CI test suite failed for three reasons:

  1. Payload format mismatch: encode_mqtt_payload() now includes a "t" field. 4 unit test assertions used exact-match checks.

  2. FakeChannel signature: MqttChannel.publish() gained a qos parameter. FakeChannel._publish did not accept it.

  3. Time-dependent snapshot: The "t" field value comes from time.time(), making the A01 e2e snapshot non-deterministic across CI runs.

Changes Made

tests/devices/traits/a01/test_init.py — 4 assertions updated

Replaced exact-match with key-check: payload_data["dps"] == {...} + assert "t" in payload_data.

roborock/testing/channel.py — 1 signature update

FakeChannel._publish now accepts qos: int = 0 to match the updated MqttChannel.publish signature.

tests/e2e/test_device_manager.py — time frozen for A01 test

Added from freezegun import freeze_time and decorated test_a01_device with @freeze_time("2025-01-20T12:00:00") to ensure deterministic MQTT payloads across CI runs. Snapshot regenerated via --snapshot-update.

roborock/devices/traits/a01/__init__.py — 1 import fix

Removed stray blank line between RoborockException and roborock.data imports (ruff/isort ordering).

Tests Not Affected

  • tests/protocols/test_a01_protocol.pydecode_rpc_response() ignores the "t" field
  • tests/e2e/test_mqtt_session.py — no client-side publish affected
  • tests/devices/test_a01_traits.py — patches send_decoded_command entirely
  • All V1/B01/Q7/Q10/Dyad tests — no changes needed

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

Fantastic contribution, thank you so much for the improvements here!

Comment thread roborock/devices/transport/mqtt_channel.py Outdated
done = 10
aftercare = 12
waiting_for_aftercare = 13
updating = 11

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.

let's tag as a breaking change. i think it's that commits have feat!: ?

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 change the title. Is that right?

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 think so yeah.

For context: This is currently used by Home Assistant for example, so it can't be merged without changing the translation strings there. Another way to do it could be to leave the enum alone, add a comment on the enum, then later go add the translations in home assistant.

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.

The names smart_hosting / smart_hosting_waiting come from reverse-engineering the official Roborock app bundle. The previous names aftercare / waiting_for_aftercare were also from my PR — I translated them from the Chinese status labels shown in the app UI. I've reverted to the old names and added a comment noting the canonical app-bundle names.

params = {protocol: value}
return await send_decoded_command(self._channel, params, value_encoder=lambda x: x)
params: dict[RoborockZeoProtocol, Any] = {protocol: value}
if protocol == RoborockZeoProtocol.START and value == 1:

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.

This makes me think it may be time to define actual API methods for these specialized commands rather than a single generic query_values/set_values (e.g. a "start" and "stop" methods etc for the washing machine.

Do you expect there to be more specialized commands like this or is this the only one?

At a minimum: Split out the function that queries the current values to a separate method

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.

The query logic has been split into _get_current_params() as requested. Based on testing with physical Zeo devices, I believe START is the only command that requires parameter bundling.

START (200) will wake up the device. The Zeo firmware requires MODE and PROGRAM to be present in the same MQTT message as the start signal because the device needs to know what to do when it wakes up. If the device is already manually powered on (via the physical power button), even START(200) can be sent as a simple set_value with QoS 0, and commands like PAUSE (201) and SHUTDOWN (202) work without any bundling at all.

Through testing with the actual Roborock app, I confirmed the DPs fall into two distinct categories:

  • Immediate DPs (sound, child lock, UV light, etc) — set independently via set_value and take effect immediately.
  • Deferred DPs (mode, program, temperature, rinse, spin, drying, etc) — the app collects user selections locally and only sends them bundled with the START command. The device internally caches these values — either factory defaults or the user's last selection depending on whether the device model supports parameter persistence — which is why _get_current_params() can query them at start time.

The current query+bundle approach in _get_current_params() is functional and has been verified on physical hardware. However, in the broader HomeAssistant ecosystem similar devices (thermostats, climate controls) use a coordinator caching pattern: entity state changes update a local value immediately but don't push to the device; all accumulated changes are sent as a single payload when the user triggers an action (e.g. "set temperature"). Implementing this pattern for Zeo devices would require changes to the HA integration layer and is probably best left for a separate PR. I would greatly appreciate it if someone could help make thses improvements.

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.

This is helpful to understand the end state you recommend.

We can do a lot of that in this library without large changes to Home Assistant. The way we do this in other device types is through traits that (a) define typed interfaces for data (b) handle commands related to that data/state and (c) hold on to the latest state with functions for refreshing. So we won't solve all of that now in this PR, but here is what we can do:

  • Define a dataclass with the arguments to start that are required (mode, program, etc)
  • Define a method (can be private) that can query and return those
  • Define a separate method start that users will call that handles the now logic for querying and calling the start command

We can work on the rest in the future.

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.

Thanks for the explanation and guidance — that was really helpful for understanding this project. I've updated the code per your suggestions.

Comment thread roborock/devices/traits/a01/__init__.py Outdated
params[dp] = fallback
except RoborockException:
_LOGGER.warning(
"Failed to query device state before start, using defaults",

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'm not sure this fallback is a great idea e.g. if the user is expecting some values to be preserved then silently not preserving them worries me e.g. as a user I would be concerned if my washing machine changed my water temp due to flaky wifi and i might prefer it just fail rather than shrinking cotton clothes or whatever.

Can we either remove or provide some rationale here why this is expected or ok?

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.

Your concern was completely valid — I've removed the fallback.

Comment thread tests/e2e/test_device_manager.py Outdated
) -> None:
"""Test the device manager end to end flow with an A01 device."""
# Use a fixed timestamp so the MQTT payload snapshot is deterministic.
monkeypatch.setattr("roborock.protocols.a01_protocol.time.time", lambda: 1755750947.0)

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 have deterministic_message_fixtures

where these kinds of patches can live

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 move A01 timestamp patch into deterministic_message_fixtures.

@NOisi-x NOisi-x changed the title feat: complete full Zeo device support feat!: complete full Zeo device support Jul 13, 2026
Comment thread roborock/mqtt/session.py Outdated

Determines the delivery guarantee for published messages:

- AT_MOST_ONCE (0): Fire-and-forget. No acknowledgment required.

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.

You can move these to the actual enums like:

AT_MOST_ONCE = 0
"""Fire-and-forget. No acknowledgment required.""""

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.

done

params = {protocol: value}
return await send_decoded_command(self._channel, params, value_encoder=lambda x: x)
params: dict[RoborockZeoProtocol, Any] = {protocol: value}
if protocol == RoborockZeoProtocol.START and value == 1:

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.

This is helpful to understand the end state you recommend.

We can do a lot of that in this library without large changes to Home Assistant. The way we do this in other device types is through traits that (a) define typed interfaces for data (b) handle commands related to that data/state and (c) hold on to the latest state with functions for refreshing. So we won't solve all of that now in this PR, but here is what we can do:

  • Define a dataclass with the arguments to start that are required (mode, program, etc)
  • Define a method (can be private) that can query and return those
  • Define a separate method start that users will call that handles the now logic for querying and calling the start command

We can work on the rest in the future.

done = 10
aftercare = 12
waiting_for_aftercare = 13
updating = 11

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 think so yeah.

For context: This is currently used by Home Assistant for example, so it can't be merged without changing the translation strings there. Another way to do it could be to leave the enum alone, add a comment on the enum, then later go add the translations in home assistant.

@NOisi-x NOisi-x requested a review from allenporter July 13, 2026 16:21
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