Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 15 additions & 4 deletions roborock/data/zeo/zeo_containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,31 @@

@dataclass
class ZeoStartParams(RoborockBase):
"""Parameters that must be bundled with a START command.
"""All parameters that may 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`` and ``program`` are mandatory for every device. Every other
field is optional — when ``None`` it is simply omitted from the MQTT
payload, so the same superset works for washers and dryers alike.
"""

mode: ZeoMode
program: ZeoProgram

# Washer
temperature: ZeoTemperature | None = None
rinse: ZeoRinse | None = None
spin: ZeoSpin | None = None
drying_mode: ZeoDryingMode | None = None

# Dryer
drying_method: ZeoDryingMethod | None = None
steam_volume: ZeoSteamVolume | None = None
total_time: int | None = None

# Optional across both device families
soak: ZeoSoak | None = None
dry_and_care: ZeoDryAndCare | None = None


# ── DP 222 (LoadCloudProgram) bitfield decoder ──────────────────────────
# The official app packs all custom-program parameters into a single
Expand Down
2 changes: 2 additions & 0 deletions roborock/devices/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,8 @@ async def connect(self) -> None:
await self.v1_properties.start()
elif self.b01_q10_properties is not None:
await self.b01_q10_properties.start()
if self.zeo is not None:
await self.zeo.start()
except RoborockException:
# Expected: start() can fail transiently. Unsubscribe before propagating
# so the retry by connect_loop() gets a clean channel.
Expand Down
167 changes: 163 additions & 4 deletions roborock/devices/traits/a01/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"""

import json
import logging
from collections.abc import Callable
from datetime import time
from typing import Any
Expand All @@ -37,25 +38,49 @@
RoborockDyadStateCode,
)
from roborock.data.zeo.zeo_code_mappings import (
ZeoDetergentExpansionType,
ZeoDetergentType,
ZeoDirtDetectionStatus,
ZeoDryAndCare,
ZeoDryerStartError,
ZeoDryingMethod,
ZeoDryingMode,
ZeoError,
ZeoFeatureBits,
ZeoMode,
ZeoProgram,
ZeoRinse,
ZeoSoak,
ZeoSoftenerExpansionType,
ZeoSoftenerType,
ZeoSpin,
ZeoState,
ZeoSteamVolume,
ZeoTemperature,
)
from roborock.devices.rpc.a01_channel import send_decoded_command
from roborock.devices.traits import Trait
from roborock.devices.traits.common import TraitUpdateListener
from roborock.devices.transport.mqtt_channel import MqttChannel
from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol
from roborock.exceptions import RoborockException
from roborock.protocols.a01_protocol import decode_rpc_response
from roborock.roborock_message import (
RoborockDyadDataProtocol,
RoborockMessage,
RoborockMessageProtocol,
RoborockZeoProtocol,
)

from .command import ZeoCommandTrait # noqa: F401 — re‑export
from .device_features import ZeoFeatures, ZeoFeatureTrait # noqa: F401 — re‑export

_LOGGER = logging.getLogger(__name__)

__init__ = [
"DyadApi",
"ZeoApi",
"ZeoCommandTrait",
"ZeoFeatureTrait",
]


Expand Down Expand Up @@ -92,6 +117,17 @@
RoborockDyadDataProtocol.PRODUCT_INFO: lambda val: DyadProductInfo.from_dict(val),
}


def _try_json(val: Any) -> Any:
"""Return *val* parsed as JSON when it is a JSON string, else *val*."""
if isinstance(val, str):
try:
return json.loads(val)
except (json.JSONDecodeError, TypeError):
pass
return val


ZEO_PROTOCOL_ENTRIES: dict[RoborockZeoProtocol, Callable] = {
# read-only
RoborockZeoProtocol.STATE: lambda val: ZeoState(val).name,
Expand All @@ -101,6 +137,27 @@
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: ZeoDirtDetectionStatus(val).name,
RoborockZeoProtocol.TOTAL_TIME: lambda val: int(val),
RoborockZeoProtocol.FEATURE_BITS: lambda val: int(val),
RoborockZeoProtocol.SMART_HOSTING_WAITED_TIME: lambda val: int(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.APP_AUTHORIZATION: lambda val: bool(val),
RoborockZeoProtocol.SMART_HOSTING_TIME: lambda val: int(val),
RoborockZeoProtocol.CUSTOM_PROGRAM_CLEANING_TIME: lambda val: int(val),
RoborockZeoProtocol.PANEL_TIMING_PROGRAM_PARAMS: lambda val: int(val),
RoborockZeoProtocol.STEAM_CARE_TIME: lambda val: int(val),
# meta — read-only (JSON)
RoborockZeoProtocol.PRODUCT_INFO: lambda val: _try_json(val),
RoborockZeoProtocol.WASHING_LOG: lambda val: _try_json(val),
RoborockZeoProtocol.VOICE_RECORD_INFO: lambda val: _try_json(val),
RoborockZeoProtocol.VOICE_RECORD: lambda val: _try_json(val),
# read-write
RoborockZeoProtocol.MODE: lambda val: ZeoMode(val).name,
RoborockZeoProtocol.PROGRAM: lambda val: ZeoProgram(val).name,
Expand All @@ -111,6 +168,41 @@
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.SOFTENER_EXPANSION_TYPE: lambda val: ZeoSoftenerExpansionType(val).name,
RoborockZeoProtocol.DETERGENT_EXPANSION_TYPE: lambda val: ZeoDetergentExpansionType(val).name,
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.WIFI_LINKAGE_RESET: 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.FLUFF_CLEANED: lambda val: bool(val),
# read-write (int-valued)
RoborockZeoProtocol.CUSTOM_PARAM_SAVE: lambda val: int(val),
RoborockZeoProtocol.CUSTOM_PARAM_GET: lambda val: int(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),
# meta — read-write
RoborockZeoProtocol.SET_SOUND_PACKAGE: lambda val: val,
RoborockZeoProtocol.VOICE_VOLUME: lambda val: val,
RoborockZeoProtocol.VOICE_SWITCH: lambda val: bool(val),
RoborockZeoProtocol.VOICE_RECORD_DELETE: lambda val: int(val),
}


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


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

name = "zeo"

def __init__(self, channel: MqttChannel) -> None:
def __init__(self, channel: MqttChannel, product_id: str | None = None) -> None:
"""Initialize the Zeo API."""
TraitUpdateListener.__init__(self, _LOGGER)
self._channel = channel
self._dps_cache: dict[int, Any] = {}
self._dps_unsub: Callable[[], None] | None = None
self._feature_bits: int = 0
self._feature_trait = ZeoFeatureTrait(channel, product_id)
self._command: ZeoCommandTrait | None = None

@property
def command(self) -> ZeoCommandTrait:
"""Lazily-built trait for wash-programme commands."""
if self._command is None:
self._command = ZeoCommandTrait(
channel=self._channel,
dps_cache=self._dps_cache,
feature_trait=self._feature_trait,
proto_entries=ZEO_PROTOCOL_ENTRIES,
)
return self._command

async def start(self) -> None:
"""Subscribe to MQTT push and discover device capabilities."""
await self._ensure_subscribed()
await self._discover_features()

def close(self) -> None:
"""Unsubscribe from MQTT push and release resources."""
if self._dps_unsub is not None:
self._dps_unsub()
self._dps_unsub = None

async def _ensure_subscribed(self) -> None:
"""Subscribe to MQTT DPS push (idempotent)."""
if self._dps_unsub is not None:
return
self._dps_unsub = await self._channel.subscribe(self._on_dps_message)

async def _discover_features(self) -> None:
"""Query FEATURE_BITS (DP 237) and cache device capabilities."""
try:
result = await self.query_values([RoborockZeoProtocol.FEATURE_BITS])
self._feature_bits = result.get(RoborockZeoProtocol.FEATURE_BITS, 0)
except Exception:
self._feature_bits = 0

def supports(self, feature: ZeoFeatureBits) -> bool:
"""Check whether the device supports a given feature bit."""
return bool(self._feature_bits & (1 << feature.value))

def _on_dps_message(self, message: RoborockMessage) -> None:
"""Handle unsolicited MQTT push (protocol 102 — RPC_RESPONSE).

Zeo devices broadcast status changes as ``{"dps": {...}}`` JSON
payloads. This callback decodes them and feeds the cache so
that ``query_values`` can skip the device round-trip when the
requested DPs are already up to date.
"""
if message.protocol != RoborockMessageProtocol.RPC_RESPONSE:
return
try:
decoded = decode_rpc_response(message)
self._dps_cache.update(decoded)
self._notify_update()
except RoborockException:
_LOGGER.debug("Failed to decode push message, skipping: %s", message, exc_info=True)

async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[RoborockZeoProtocol, Any]:
"""Query the device for the values of the given protocols."""
Expand All @@ -172,6 +328,9 @@ async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[Robor
{RoborockZeoProtocol.ID_QUERY: protocols},
value_encoder=json.dumps,
)
for protocol, value in response.items():
if value is not None:
self._dps_cache[int(protocol)] = value
return {protocol: convert_zeo_value(protocol, response.get(protocol)) for protocol in protocols}

async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[RoborockZeoProtocol, Any]:
Expand All @@ -186,6 +345,6 @@ def create(product: HomeDataProduct, mqtt_channel: MqttChannel) -> DyadApi | Zeo
case RoborockCategory.WET_DRY_VAC:
return DyadApi(mqtt_channel)
case RoborockCategory.WASHING_MACHINE:
return ZeoApi(mqtt_channel)
return ZeoApi(mqtt_channel, product_id=product.id)
case _:
raise NotImplementedError(f"Unsupported category {product.category}")
Loading
Loading