Skip to content

feat: complete full Zeo device support#871

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

feat: complete full Zeo device support#871
NOisi-x wants to merge 9 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!

unsub()

async def publish(self, message: RoborockMessage) -> None:
async def publish(self, message: RoborockMessage, qos: int = 0) -> None:

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 define an enum for qos level. Just having numbers 0, 1, 2 doesn't really say what these are, or why they exist. Why would someone set a lower or higher level, etc should be explained in the code and doing it from the enum could be a good way.

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!: ?

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

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?

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

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