Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6eaf4f9
feat: add full Zeo device support
Jul 9, 2026
613db33
fix: update tests for A01 QoS 1 and timestamp changes
Jul 10, 2026
9ae9fb7
Revert "fix: update tests for A01 QoS 1 and timestamp changes"
Jul 10, 2026
5399235
fix: update tests and FakeChannel for A01 timestamp and qos changes
Jul 10, 2026
a8ac09f
Merge branch 'main' of https://github.com/NOisi-x/python-roborock
Jul 10, 2026
8dd5c3b
fix: update test snapshots and freeze time for A01 device test
Jul 10, 2026
99c2b53
fix: enable tick option for freeze_time in A01 device test
Jul 10, 2026
e7bcb14
fix: update A01 device test snapshots and use fixed timestamp for det…
Jul 10, 2026
75ecfe1
fix: remove obsolete test_result.txt file
Jul 10, 2026
00bdca6
feat: update MQTT QoS handling to use MqttQos enum for better clarity…
Jul 13, 2026
b6ccf15
fix: refactor ZeoApi to streamline START command parameter handling
Jul 13, 2026
784d95a
fix: enforce required Zeo START params and remove fallback defaults
Jul 13, 2026
0843e8a
fix: move A01 timestamp patch into deterministic_message_fixtures
Jul 13, 2026
9d072ba
fix: improve MqttQos docstring clarity by restructuring delivery guar…
Jul 13, 2026
17fc535
feat: add ZeoStartParams dataclass and enhance ZeoApi start command h…
Jul 13, 2026
324481d
fix: update ZeoState class to use canonical names for aftercare states
Jul 13, 2026
c2c688f
chore: resolve merge conflicts with upstream main
Jul 13, 2026
2f9d661
Merge branch 'main' into main
NOisi-x Jul 13, 2026
6dd21f6
refactor: streamline function definitions and remove unnecessary line…
Jul 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions roborock/data/zeo/zeo_code_mappings.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ class ZeoMode(RoborockEnum):
wash = 1
wash_and_dry = 2
dry = 3
treatment = 4


class ZeoState(RoborockEnum):
Expand All @@ -19,8 +20,16 @@ class ZeoState(RoborockEnum):
cooling = 8
under_delay_start = 9
done = 10
aftercare = 12
waiting_for_aftercare = 13
updating = 11
Comment thread
NOisi-x marked this conversation as resolved.
aftercare = 12 # canonical name from app bundle: smart_hosting
waiting_for_aftercare = 13 # canonical name from app bundle: smart_hosting_waiting
steam_caring = 14
descaling = 15
cloth_ready = 16
waiting_for_drying = 17
pre_heating = 18
pre_heat_complete = 19
in_care = 20


class ZeoProgram(RoborockEnum):
Expand Down Expand Up @@ -68,6 +77,7 @@ class ZeoSoak(RoborockEnum):
medium = 2
high = 3
max = 4
very_max = 5 # 30 minutes


class ZeoTemperature(RoborockEnum):
Expand Down Expand Up @@ -140,3 +150,32 @@ class ZeoError(RoborockEnum):
drying_error_water_flow = 17 # Check for normal water flow
drying_error_restart = 18 # Restart the washer and try again
spin_error = 19 # re-arrange clothes


class ZeoDryingMethod(RoborockEnum):
l1 = 1
l2 = 2
l3 = 3


class ZeoSteamVolume(RoborockEnum):
none = 0
low = 1
medium = 2
high = 3
max = 4


class ZeoDryAndCare(RoborockEnum):
soft = 1
normal = 2


class ZeoDryerStartError(RoborockEnum):
dryer_running = 1
dryer_error = 2
dryer_done = 3
dryer_waiting_hosting = 4
dryer_smart_hosting = 5
dryer_countdown = 6
dryer_network_fail = 7
15 changes: 13 additions & 2 deletions roborock/devices/rpc/a01_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from roborock.devices.transport.mqtt_channel import MqttChannel
from roborock.exceptions import RoborockException
from roborock.mqtt.session import MqttQos
from roborock.protocols.a01_protocol import (
decode_rpc_response,
encode_mqtt_payload,
Expand All @@ -30,6 +31,7 @@ async def send_decoded_command(
mqtt_channel: MqttChannel,
params: dict[RoborockDyadDataProtocol, Any],
value_encoder: Callable[[Any], Any] | None = None,
qos: MqttQos = MqttQos.AT_MOST_ONCE,
) -> dict[RoborockDyadDataProtocol, Any]: ...


Expand All @@ -38,23 +40,32 @@ async def send_decoded_command(
mqtt_channel: MqttChannel,
params: dict[RoborockZeoProtocol, Any],
value_encoder: Callable[[Any], Any] | None = None,
qos: MqttQos = MqttQos.AT_MOST_ONCE,
) -> dict[RoborockZeoProtocol, Any]: ...


async def send_decoded_command(
mqtt_channel: MqttChannel,
params: dict[RoborockDyadDataProtocol, Any] | dict[RoborockZeoProtocol, Any],
value_encoder: Callable[[Any], Any] | None = None,
qos: MqttQos = MqttQos.AT_MOST_ONCE,
) -> dict[RoborockDyadDataProtocol, Any] | dict[RoborockZeoProtocol, Any]:
"""Send a command on the MQTT channel and get a decoded response."""
"""Send a command on the MQTT channel and get a decoded response.

Args:
mqtt_channel: The MQTT channel to send the command on.
params: The parameters to send.
value_encoder: A function to encode the values of the dictionary.
qos: The MQTT QoS level. Defaults to AT_MOST_ONCE.
"""
_LOGGER.debug("Sending MQTT command: %s", params)
roborock_message = encode_mqtt_payload(params, value_encoder)

# For commands that set values: send the command and do not
# block waiting for a response. Queries are handled below.
param_values = {int(k): v for k, v in params.items()}
if not (query_values := param_values.get(_ID_QUERY)):
await mqtt_channel.publish(roborock_message)
await mqtt_channel.publish(roborock_message, qos=qos)
return {}

# Merge any results together than contain the requested data. This
Expand Down
144 changes: 143 additions & 1 deletion roborock/devices/traits/a01/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
"""

import json
import logging
from collections.abc import Callable
from dataclasses import dataclass
from datetime import time
from typing import Any

Expand All @@ -38,21 +40,30 @@
)
from roborock.data.zeo.zeo_code_mappings import (
ZeoDetergentType,
ZeoDryAndCare,
ZeoDryerStartError,
ZeoDryingMethod,
ZeoDryingMode,
ZeoError,
ZeoMode,
ZeoProgram,
ZeoRinse,
ZeoSoak,
ZeoSoftenerType,
ZeoSpin,
ZeoState,
ZeoSteamVolume,
ZeoTemperature,
)
from roborock.devices.rpc.a01_channel import send_decoded_command
from roborock.devices.traits import Trait
from roborock.devices.transport.mqtt_channel import MqttChannel
from roborock.exceptions import RoborockException
from roborock.mqtt.session import MqttQos
from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol

_LOGGER = logging.getLogger(__name__)

__init__ = [
"DyadApi",
"ZeoApi",
Expand Down Expand Up @@ -101,6 +112,22 @@
RoborockZeoProtocol.TIMES_AFTER_CLEAN: lambda val: int(val),
RoborockZeoProtocol.DETERGENT_EMPTY: lambda val: bool(val),
RoborockZeoProtocol.SOFTENER_EMPTY: lambda val: bool(val),
RoborockZeoProtocol.DIRT_DETECTION_STATUS: lambda val: int(val),
RoborockZeoProtocol.TOTAL_TIME: lambda val: int(val),
RoborockZeoProtocol.FEATURE_BITS: lambda val: int(val),
RoborockZeoProtocol.SMART_HOSTING_WAITED_TIME: lambda val: int(val),
RoborockZeoProtocol.FLUFF_CLEANED: lambda val: bool(val),
RoborockZeoProtocol.IS_NEED_FLUFF_CLEAN: lambda val: bool(val),
RoborockZeoProtocol.PANEL_PROGRAM_PARAMS_SET_RESULT: lambda val: int(val),
RoborockZeoProtocol.DEVICE_BOUND: lambda val: bool(val),
RoborockZeoProtocol.CLOTH_PUT_IN: lambda val: bool(val),
RoborockZeoProtocol.CLOTH_READY_TO_DRY_COUNT_DOWN: lambda val: int(val),
RoborockZeoProtocol.START_DRYER_ERROR: lambda val: ZeoDryerStartError(val).name,
RoborockZeoProtocol.DOORLOCK_STATE: lambda val: bool(val),
RoborockZeoProtocol.DEFAULT_SETTING: lambda val: int(val),
RoborockZeoProtocol.LIGHT_SETTING: lambda val: bool(val),
RoborockZeoProtocol.DETERGENT_VOLUME: lambda val: int(val),
RoborockZeoProtocol.SOFTENER_VOLUME: lambda val: int(val),
# read-write
RoborockZeoProtocol.MODE: lambda val: ZeoMode(val).name,
RoborockZeoProtocol.PROGRAM: lambda val: ZeoProgram(val).name,
Expand All @@ -111,6 +138,33 @@
RoborockZeoProtocol.DETERGENT_TYPE: lambda val: ZeoDetergentType(val).name,
RoborockZeoProtocol.SOFTENER_TYPE: lambda val: ZeoSoftenerType(val).name,
RoborockZeoProtocol.SOUND_SET: lambda val: bool(val),
RoborockZeoProtocol.DIRT_DETECTION_SWITCH: lambda val: bool(val),
RoborockZeoProtocol.SOAK: lambda val: ZeoSoak(val).name,
RoborockZeoProtocol.SILENT_MODE_ON: lambda val: bool(val),
RoborockZeoProtocol.SILENT_MODE_START_TIME: lambda val: int(val),
RoborockZeoProtocol.SILENT_MODE_END_TIME: lambda val: int(val),
RoborockZeoProtocol.DRY_CARE_MODE: lambda val: ZeoDryAndCare(val).name,
RoborockZeoProtocol.WASH_DRY_LINKED: lambda val: bool(val),
RoborockZeoProtocol.DRYING_METHOD: lambda val: ZeoDryingMethod(val).name,
RoborockZeoProtocol.STEAM_VOLUME: lambda val: ZeoSteamVolume(val).name,
RoborockZeoProtocol.ION_DEODORIZATION: lambda val: bool(val),
RoborockZeoProtocol.UV_LIGHT: lambda val: bool(val),
RoborockZeoProtocol.SMART_HOSTING: lambda val: bool(val),
RoborockZeoProtocol.SMART_HOSTING_TIME: lambda val: int(val),
RoborockZeoProtocol.SOFTENER_EXPANSION_TYPE: lambda val: int(val),
RoborockZeoProtocol.DETERGENT_EXPANSION_TYPE: lambda val: int(val),
RoborockZeoProtocol.SMILE_LIGHT_STATUS: lambda val: bool(val),
RoborockZeoProtocol.POWER_LIGHT: lambda val: bool(val),
RoborockZeoProtocol.PANEL_PROGRAM_PARAMS_SET: lambda val: int(val),
RoborockZeoProtocol.PANEL_TIMING_PROGRAM_PARAMS: lambda val: int(val),
RoborockZeoProtocol.STEAM_CARE_TIME: lambda val: int(val),
RoborockZeoProtocol.WIFI_LINKAGE_RESET: lambda val: int(val),
RoborockZeoProtocol.CUSTOM_PROGRAM_CLEANING_TIME: lambda val: int(val),
RoborockZeoProtocol.SAVE_ADAPTED_CLOUD_PROGRAM: lambda val: int(val),
RoborockZeoProtocol.CHILD_LOCK: lambda val: bool(val),
RoborockZeoProtocol.DETERGENT_SET: lambda val: bool(val),
RoborockZeoProtocol.SOFTENER_SET: lambda val: bool(val),
RoborockZeoProtocol.APP_AUTHORIZATION: lambda val: bool(val),
}


Expand Down Expand Up @@ -156,6 +210,34 @@ async def set_value(self, protocol: RoborockDyadDataProtocol, value: Any) -> dic
return await send_decoded_command(self._channel, params)


@dataclass
class ZeoStartParams:
"""Parameters that must be bundled with a START command.

All Zeo devices require ``mode`` and ``program`` to be sent together
with the start signal. The remaining fields are optional and only
included when the device reports a non-None value.
"""

mode: int
"""Wash mode (e.g. wash, wash-and-dry, dry, treatment)."""

program: int
"""Wash program (e.g. standard, quick, wool)."""

temp: int | None = None
"""Water temperature."""

rinse_times: int | None = None
"""Number of rinse cycles."""

spin_level: int | None = None
"""Spin speed (RPM)."""

drying_mode: int | None = None
"""Drying mode (e.g. quick, iron, store)."""


class ZeoApi(Trait):
"""API for interacting with Zeo devices."""

Expand All @@ -174,9 +256,69 @@ async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[Robor
)
return {protocol: convert_zeo_value(protocol, response.get(protocol)) for protocol in protocols}

# The DP IDs that must be bundled with START commands. These are the
# universally-supported core parameters common to all Zeo devices.
_START_PARAM_DPS: tuple[RoborockZeoProtocol, ...] = (
RoborockZeoProtocol.MODE,
RoborockZeoProtocol.PROGRAM,
RoborockZeoProtocol.TEMP,
RoborockZeoProtocol.RINSE_TIMES,
RoborockZeoProtocol.SPIN_LEVEL,
RoborockZeoProtocol.DRYING_MODE,
)

async def _get_current_params(self) -> ZeoStartParams:
"""Query the device and return typed start parameters.

Raises :exc:`RoborockException` if any of the required DPs are not
returned by the device.
"""
current = await send_decoded_command(
self._channel,
{RoborockZeoProtocol.ID_QUERY: list(self._START_PARAM_DPS)},
value_encoder=json.dumps,
)
for dp in self._START_PARAM_DPS:
if dp not in current:
raise RoborockException(f"Device did not return required DP {dp.name} ({int(dp)})")
return ZeoStartParams(
mode=current[RoborockZeoProtocol.MODE],
program=current[RoborockZeoProtocol.PROGRAM],
temp=current.get(RoborockZeoProtocol.TEMP),
rinse_times=current.get(RoborockZeoProtocol.RINSE_TIMES),
spin_level=current.get(RoborockZeoProtocol.SPIN_LEVEL),
drying_mode=current.get(RoborockZeoProtocol.DRYING_MODE),
)

async def start(self) -> dict[RoborockZeoProtocol, Any]:
"""Start the device using the current mode and program parameters.

Queries the device for the current wash settings and sends them
bundled with the START command. This uses QoS 1 as required by the
device firmware.
"""
_LOGGER.debug("Start command: querying current device state")
p = await self._get_current_params()
dps: dict[RoborockZeoProtocol, Any] = {
RoborockZeoProtocol.START: 1,
RoborockZeoProtocol.MODE: p.mode,
RoborockZeoProtocol.PROGRAM: p.program,
}
for dp, val in (
(RoborockZeoProtocol.TEMP, p.temp),
(RoborockZeoProtocol.RINSE_TIMES, p.rinse_times),
(RoborockZeoProtocol.SPIN_LEVEL, p.spin_level),
(RoborockZeoProtocol.DRYING_MODE, p.drying_mode),
):
if val is not None:
dps[dp] = val
return await send_decoded_command(self._channel, dps, value_encoder=lambda x: x, qos=MqttQos.AT_LEAST_ONCE)

async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[RoborockZeoProtocol, Any]:
"""Set a value for a specific protocol on the device."""
params = {protocol: value}
if protocol == RoborockZeoProtocol.START and value == 1:
Comment thread
NOisi-x marked this conversation as resolved.
return await self.start()
params: dict[RoborockZeoProtocol, Any] = {protocol: value}
return await send_decoded_command(self._channel, params, value_encoder=lambda x: x)


Expand Down
10 changes: 7 additions & 3 deletions roborock/devices/transport/mqtt_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from roborock.data import HomeDataDevice, RRiot, UserData
from roborock.exceptions import RoborockException
from roborock.mqtt.health_manager import HealthManager
from roborock.mqtt.session import MqttParams, MqttSession, MqttSessionException
from roborock.mqtt.session import MqttParams, MqttQos, MqttSession, MqttSessionException
from roborock.protocol import create_mqtt_decoder, create_mqtt_encoder
from roborock.roborock_message import RoborockMessage
from roborock.util import RoborockLoggerAdapter
Expand Down Expand Up @@ -89,19 +89,23 @@ async def subscribe_stream(self) -> AsyncGenerator[RoborockMessage, None]:
finally:
unsub()

async def publish(self, message: RoborockMessage) -> None:
async def publish(self, message: RoborockMessage, qos: MqttQos = MqttQos.AT_MOST_ONCE) -> None:
"""Publish a command message.

The caller is responsible for handling any responses and associating them
with the incoming request.

Args:
message: The message to publish.
qos: The MQTT QoS level. Defaults to AT_MOST_ONCE.
"""
try:
encoded_msg = self._encoder(message)
except Exception as e:
self._logger.exception("Error encoding MQTT message: %s", e)
raise RoborockException(f"Failed to encode MQTT message: {e}") from e
try:
return await self._mqtt_session.publish(self._publish_topic, encoded_msg)
return await self._mqtt_session.publish(self._publish_topic, encoded_msg, qos=qos)
except MqttSessionException as e:
self._logger.debug("Error publishing MQTT message: %s", e)
raise RoborockException(f"Failed to publish MQTT message: {e}") from e
Expand Down
Loading
Loading