diff --git a/roborock/data/zeo/zeo_containers.py b/roborock/data/zeo/zeo_containers.py index 5711cc1f..ff1a41ad 100644 --- a/roborock/data/zeo/zeo_containers.py +++ b/roborock/data/zeo/zeo_containers.py @@ -1,6 +1,6 @@ """Data containers for Zeo (washing machine / dryer) devices.""" -from dataclasses import dataclass +from dataclasses import dataclass, field from ..containers import RoborockBase from .zeo_code_mappings import ( @@ -17,27 +17,91 @@ ) +@dataclass +class ZeoWashRecord(RoborockBase): + """A single entry in the device's wash-history log (DP 10008).""" + + ctrl_type: int = 0 + """Control type of the programme (2 = app, 1 = panel).""" + + prog_type: ZeoProgram | None = None + + category: ZeoMode | None = None + + end_type: int = 0 + """End type (1 = completed, 2 = cancelled/failed).""" + + duration: int = 0 + """Programme duration in minutes (0 for entries that never ran).""" + + t: int = 0 + """Start timestamp (Unix seconds).""" + + end_t: int = 0 + """End timestamp (Unix seconds).""" + + +@dataclass +class ZeoWashLog(RoborockBase): + """Wash-history log reported at DP 10008 (JSON string).""" + + cnt: int = 0 + """Total number of log entries.""" + + wash_cnt: int = 0 + """Total wash count.""" + + dry_cnt: int = 0 + """Total dry count.""" + + wash_dry_cnt: int = 0 + """Total wash-and-dry count.""" + + washes: list[ZeoWashRecord] = field(default_factory=list) + """The individual wash records (oldest → newest).""" + + @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 + # Timed-program running duration in minutes (DP 234). + # This is a fixed parameter of the programme config. + # TODO(program-config): once a programme table exists, this should be + # auto-populated from the programme config instead of passed by the caller. + total_time: int | None = None # depends on programme configs + + # Optional across both device families + soak: ZeoSoak | None = None + dry_and_care: ZeoDryAndCare | None = None + + # Feature-gated start options (DP 258 / DP 255). In the Bundle these are + # the programme's config ``defaultIonStatus`` and the UI's + # ``wash_dry_linked`` state + ion_deodorization: bool | None = None + wash_dry_linked: bool | None = None + # ── DP 222 (LoadCloudProgram) bitfield decoder ────────────────────────── -# The official app packs all custom-program parameters into a single -# 32-bit integer at DP 222. This mirrors WasherDpsCache.customMode in -# module 725 of the React Native plugin bundle. +# All custom-program parameters are packed into a single +# 32-bit integer at DP 222. @dataclass @@ -101,8 +165,7 @@ class ZeoDryerCustomMode(RoborockBase): """Decoded custom programme from DP 222 for a standalone dryer. Dryers pack a different (shorter) bitfield than washers — only 5 - fields after program/mode. Mirrors ``WasherDpsCache.dryerCustomMode`` - in module 725 of the plugin bundle. + fields after program/mode. """ program: ZeoProgram diff --git a/roborock/devices/traits/a01/__init__.py b/roborock/devices/traits/a01/__init__.py index 6dd7b3e2..63282c1f 100644 --- a/roborock/devices/traits/a01/__init__.py +++ b/roborock/devices/traits/a01/__init__.py @@ -40,25 +40,42 @@ 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.data.zeo.zeo_containers import ( + ZeoCustomMode, + ZeoDryerCustomMode, +) from roborock.devices.rpc.a01_channel import send_decoded_command from roborock.devices.traits import Trait +from roborock.devices.traits.a01.command import ZeoCommandTrait from roborock.devices.traits.a01.device_feature import ( + ZeoFeatures, build_feature_dp_list, build_force_load_dp_list, + is_dryer, supports_uv_light, ) +from roborock.devices.traits.a01.settings import ZeoSettingTrait +from roborock.devices.traits.a01.status import ZeoStatusTrait from roborock.devices.traits.common import TraitUpdateListener from roborock.devices.transport.mqtt_channel import MqttChannel from roborock.exceptions import RoborockException @@ -72,9 +89,13 @@ _LOGGER = logging.getLogger(__name__) -__init__ = [ +__all__ = [ "DyadApi", "ZeoApi", + "ZeoCommandTrait", + "ZeoFeatures", + "ZeoSettingTrait", + "ZeoStatusTrait", ] @@ -111,6 +132,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 ValueError: + _LOGGER.debug("Failed to parse JSON for value %r, returning as-is", val) + return val + + ZEO_PROTOCOL_ENTRIES: dict[RoborockZeoProtocol, Callable] = { # read-only RoborockZeoProtocol.STATE: lambda val: ZeoState(val).name, @@ -120,6 +152,28 @@ 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.SOUND_PACKAGE_INFO: 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, @@ -130,7 +184,39 @@ RoborockZeoProtocol.DETERGENT_TYPE: lambda val: ZeoDetergentType(val).name, RoborockZeoProtocol.SOFTENER_TYPE: lambda val: ZeoSoftenerType(val).name, RoborockZeoProtocol.SOUND_SET: lambda val: bool(val), - RoborockZeoProtocol.FEATURE_BITS: lambda val: int(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 (JSON objects — bundle reads via JSON.parse) + RoborockZeoProtocol.VOICE_VOLUME: lambda val: _try_json(val), # {"snd_volume": int} + RoborockZeoProtocol.VOICE_SWITCH: lambda val: _try_json(val), # {"speech_switch": 1/0} + # 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: bool(val), + # NOTE: LIGHT_SETTING(229) / DETERGENT_VOLUME(230) / SOFTENER_VOLUME(231) + # are "server schema only" and do NOT exist in the device bundle — they + # have no device-side implementation, so no converters are registered. } @@ -206,8 +292,6 @@ def on_message(message: RoborockMessage) -> None: class ZeoApi(Trait, TraitUpdateListener): """API for interacting with Zeo devices.""" - name = "zeo" - def __init__(self, channel: MqttChannel, model: str | None = None) -> None: """Initialize the Zeo API.""" TraitUpdateListener.__init__(self, _LOGGER) @@ -215,7 +299,80 @@ def __init__(self, channel: MqttChannel, model: str | None = None) -> None: self._dps_cache: dict[int, Any] = {} self._dps_unsub: Callable[[], None] | None = None self._feature_bits: int = 0 + self._features: ZeoFeatures | None = None self._model = model + self._command: ZeoCommandTrait | None = None + self._settings: ZeoSettingTrait | None = None + self._status: ZeoStatusTrait | None = None + + @property + def features(self) -> ZeoFeatures | None: + """The device capabilities parsed from FEATURE_BITS (DP 237). + + ``None`` until the first force-load completes. + """ + return self._features + + @property + def command(self) -> ZeoCommandTrait: + """Lazily-built trait for wash-programme commands.""" + if self._command is None: + self._command = ZeoCommandTrait( + channel=self._channel, + settings=lambda: self.settings, + features=lambda: self._features, + custom_mode=lambda: self.get_custom_mode(), + ) + return self._command + + @property + def settings(self) -> ZeoSettingTrait: + """Lazily-built typed writable-state/setter trait. + + Holds typed writable state (``mode``, ``temperature``, + ``drying_method``, ...) refreshed from the device push stream, plus + typed setters (``set_temperature(ZeoTemperature)``, ...). Fields that + the device family does not support (e.g. ``temperature`` on a dryer) + remain ``None``. + """ + if self._settings is None: + self._settings = ZeoSettingTrait( + self._channel, + model=self._model, + is_dryer=is_dryer(self._model), + features=lambda: self._features, + ) + # Backfill from the raw DPS cache so state pushed before this + # trait was first accessed is not lost. + self._settings.update_from_dps(self._dps_cache) + return self._settings + + @property + def status(self) -> ZeoStatusTrait: + """Lazily-built typed read-only state trait. + + Holds device-reported read-only state (``state``, ``error``, + ``washing_left``, ``countdown``, tank levels, ...) refreshed from the + MQTT push stream. No setters — this is status only. + """ + if self._status is None: + self._status = ZeoStatusTrait() + # Backfill from the raw DPS cache so state pushed before this + # trait was first accessed is not lost. + self._status.update_from_dps(self._dps_cache) + return self._status + + def _update_settings_from_dps(self, decoded_dps: dict[int, Any]) -> None: + """Route raw DPS data to the settings/status traits, if built. + + Writable DPs go to :class:`ZeoSettingTrait`; read-only DPs go to + :class:`ZeoStatusTrait`. Both are lazily built, so pushes received + before they exist are re-processed on first access via the raw cache. + """ + if self._settings is not None: + self._settings.update_from_dps(decoded_dps) + if self._status is not None: + self._status.update_from_dps(decoded_dps) async def start(self) -> None: """Subscribe to MQTT push and trigger a full state sync. @@ -251,6 +408,7 @@ async def _force_load(self) -> None: dp_list = build_force_load_dp_list(self._model) result = await self.query_values(dp_list) self._feature_bits = result.get(RoborockZeoProtocol.FEATURE_BITS, 0) + self._features = ZeoFeatures.from_feature_bits(self._feature_bits) async def _load_feature_dps(self) -> None: """Second-stage query for feature-gated DPs. @@ -275,10 +433,6 @@ async def _load_feature_dps(self) -> None: except RoborockException as exc: _LOGGER.warning("Feature DPS load failed (non-fatal): %s", exc) - 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).""" if message.protocol != RoborockMessageProtocol.RPC_RESPONSE: @@ -289,6 +443,7 @@ def _on_dps_message(self, message: RoborockMessage) -> None: _LOGGER.debug("Dropped malformed push message", exc_info=True) return self._dps_cache.update(decoded) + self._update_settings_from_dps(decoded) self._notify_update() async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[RoborockZeoProtocol, Any]: @@ -298,6 +453,9 @@ async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[Robor {RoborockZeoProtocol.ID_QUERY: protocols}, value_encoder=json.dumps, ) + for protocol in protocols: + if (raw := response.get(protocol)) is not None: + self._dps_cache[int(protocol)] = raw 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]: @@ -305,6 +463,29 @@ async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[Rob params = {protocol: value} return await send_decoded_command(self._channel, params, value_encoder=lambda x: x) + async def get_custom_mode(self) -> ZeoCustomMode | ZeoDryerCustomMode | None: + """Query and decode the current custom programme (DP 222).""" + result = await self.query_values([RoborockZeoProtocol.CUSTOM_PARAM_GET, RoborockZeoProtocol.TOTAL_TIME]) + raw = result.get(RoborockZeoProtocol.CUSTOM_PARAM_GET) + if raw is None: + return None + total_time = result.get(RoborockZeoProtocol.TOTAL_TIME) + try: + raw_int = int(raw) + except (TypeError, ValueError): + return None + if is_dryer(self._model): + return ZeoDryerCustomMode.from_raw(raw_int, total_time) + return ZeoCustomMode.from_raw(raw_int, total_time) + + async def update_sound_package_info(self) -> dict[str, Any] | None: + """Query the sound-package info (DP 10004).""" + result = await self.query_values([RoborockZeoProtocol.SOUND_PACKAGE_INFO]) + raw = result.get(RoborockZeoProtocol.SOUND_PACKAGE_INFO) + if isinstance(raw, dict): + return raw + return None + def create(product: HomeDataProduct, mqtt_channel: MqttChannel) -> DyadApi | ZeoApi: """Create traits for A01 devices.""" diff --git a/roborock/devices/traits/a01/command.py b/roborock/devices/traits/a01/command.py new file mode 100644 index 00000000..5a1132c8 --- /dev/null +++ b/roborock/devices/traits/a01/command.py @@ -0,0 +1,215 @@ +"""Zeo command trait""" + +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING, Any + +from roborock.data.zeo.zeo_containers import ( + ZeoCustomMode, + ZeoDryerCustomMode, + ZeoStartParams, +) +from roborock.devices.rpc.a01_channel import send_decoded_command +from roborock.devices.traits.a01.settings import FeaturesFn, build_param_dps +from roborock.devices.transport.mqtt_channel import MqttChannel +from roborock.mqtt.session import MqttQos +from roborock.roborock_message import RoborockZeoProtocol + +if TYPE_CHECKING: + from roborock.devices.traits.a01.settings import ZeoSettingTrait + +CustomModeFn = Callable[[], Awaitable[ZeoCustomMode | ZeoDryerCustomMode | None]] + +_FEATURE_GATED_DPS: dict[RoborockZeoProtocol, tuple[str, str]] = { + RoborockZeoProtocol.ION_DEODORIZATION: ("ion_deodorization", "ion_deodorization"), + RoborockZeoProtocol.WASH_DRY_LINKED: ("wash_dry_linkage", "wash_dry_linked"), +} + + +class ZeoCommandTrait: + """Trait for sending commands to Zeo devices.""" + + def __init__( + self, + *, + channel: MqttChannel, + settings: Callable[[], "ZeoSettingTrait"], + features: FeaturesFn, + custom_mode: CustomModeFn, + ) -> None: + """Initialize the command trait. + + ``settings`` returns the shared :class:`ZeoSettingTrait` holding typed + device state (the single source of truth for auto-dosing and + feature-gated values). ``features`` returns the (lazily loaded) feature + bits. ``custom_mode`` reads and decodes the device's saved custom + programme (DP 222) for :meth:`start_with_custom_mode`. + """ + + self._channel = channel + self._settings = settings + self._features = features + self._custom_mode = custom_mode + + async def start_with(self, params: ZeoStartParams) -> dict[RoborockZeoProtocol, Any]: + """Start the device with the given programme parameters. + + This is the primary start API: the caller needs to provide the + parameters like mode/program/options/etc. + + Returns the full DPS frame that was sent, including DPs this method + adds on its own (auto-dosing and feature-gated). Exposed so callers can + inspect/validate the frame — e.g. against the programme-config template + (not yet implemented) that defines valid parameter sets. + """ + dps: dict[RoborockZeoProtocol, Any] = {RoborockZeoProtocol.START: 1} + dps.update(build_param_dps(params)) + dps.update(self._build_auto_dosing_dps()) + dps.update(self._build_feature_gated_dps(params)) + await send_decoded_command( + self._channel, + dps, + qos=MqttQos.AT_LEAST_ONCE, + value_encoder=lambda x: x, + ) + return dps + + def _build_auto_dosing_dps(self) -> dict[RoborockZeoProtocol, Any]: + """Map the settings' auto-dosing toggles to wire DPs (211/212). + + Both values are integers 1/0; + ``None`` (e.g. dryers) is omitted. + """ + settings = self._settings() + dps: dict[RoborockZeoProtocol, Any] = {} + if settings.auto_detergent is not None: + dps[RoborockZeoProtocol.DETERGENT_SET] = 1 if settings.auto_detergent else 0 + if settings.auto_softener is not None: + dps[RoborockZeoProtocol.SOFTENER_SET] = 1 if settings.auto_softener else 0 + return dps + + def _build_feature_gated_dps( + self, params: ZeoStartParams, *, include_wash_dry_linked: bool = True + ) -> dict[RoborockZeoProtocol, Any]: + """Map caller-supplied feature-gated booleans to wire DPs (255/258). + + The value comes from ``params`` (the programme's config + ``defaultIonStatus`` / UI ``wash_dry_linked`` state), not from the + device's echo; ``None`` omits the DP. The feature bit only gates + whether the DP is sent at all. + + ``include_wash_dry_linked`` defaults to True for ``startWith``. + """ + features = self._features() + dps: dict[RoborockZeoProtocol, Any] = {} + for dp, (feature_name, field_name) in _FEATURE_GATED_DPS.items(): + if dp is RoborockZeoProtocol.WASH_DRY_LINKED and not include_wash_dry_linked: + continue + if features is not None and getattr(features, feature_name, False): + value = getattr(params, field_name) + if value is not None: + dps[dp] = 1 if value else 0 + return dps + + async def start_with_custom_mode(self) -> dict[RoborockZeoProtocol, Any]: + """Start the device using its saved custom programme. + + Reads the device's custom programme (DP 222, see + :meth:`ZeoApi.get_custom_mode`) and starts with those parameters. + + Raises :class:`ValueError` when the device has no saved custom + programme. + """ + custom = await self._custom_mode() + if custom is None: + raise ValueError("Device has no saved custom programme (DP 222)") + params = _custom_mode_to_start_params(custom) + return await self.start_with(params) + + async def preset_with(self, params: ZeoStartParams, minutes: int) -> dict[RoborockZeoProtocol, Any]: + """Schedule a delayed start with full programme parameters. + + ``COUNTDOWN`` (DP 217) is the **last** DP in the payload, + and its value is the integer number of minutes. + + When *minutes* is ``<= 0`` only ``COUNTDOWN=0`` is sent to cancel an + existing schedule (the sole case that is a single-DP command). + + Device behavior note: a ``COUNTDOWN`` value **> 30** is required to + enter the delay-start countdown state. Smaller values (e.g. 20) cause + the machine to start immediately instead. The caller is responsible + for ensuring *minutes* satisfies this constraint. + + Returns the full DPS frame that was sent (see :meth:`start_with`). + """ + if minutes <= 0: + cancel_dps: dict[RoborockZeoProtocol, Any] = {RoborockZeoProtocol.COUNTDOWN: 0} + await send_decoded_command( + self._channel, + cancel_dps, + qos=MqttQos.AT_LEAST_ONCE, + value_encoder=lambda x: x, + ) + return cancel_dps + dps: dict[RoborockZeoProtocol, Any] = {RoborockZeoProtocol.START: 1} + dps.update(build_param_dps(params)) + dps.update(self._build_auto_dosing_dps()) + dps.update(self._build_feature_gated_dps(params, include_wash_dry_linked=False)) + dps[RoborockZeoProtocol.COUNTDOWN] = minutes + await send_decoded_command( + self._channel, + dps, + qos=MqttQos.AT_LEAST_ONCE, + value_encoder=lambda x: x, + ) + return dps + + async def pause(self) -> None: + """Pause the current programme (DP 201 = 1).""" + dps = {RoborockZeoProtocol.PAUSE: 1} + await send_decoded_command(self._channel, dps) + + async def resume(self) -> None: + """Start/continue a paused programme (DP 200 = 1). + + Only works while the device is powered on. + """ + dps = {RoborockZeoProtocol.START: 1} + await send_decoded_command(self._channel, dps) + + async def stop(self) -> None: + """Stop the current programme (DP 200 = 0).""" + dps = {RoborockZeoProtocol.START: 0} + await send_decoded_command(self._channel, dps) + + async def shutdown(self) -> None: + """Power off the device (DP 202 = 1). + + Only works while the device is powered on. + """ + dps = {RoborockZeoProtocol.SHUTDOWN: 1} + await send_decoded_command(self._channel, dps) + + +def _custom_mode_to_start_params( + custom: ZeoCustomMode | ZeoDryerCustomMode, +) -> ZeoStartParams: + """Convert a decoded custom programme into :class:`ZeoStartParams`. + + Washer and dryer custom modes carry the same fields (program, mode, + drying_mode, drying_method, steam_volume) plus wash-only fields + (temperature, rinse, spin, soak, dry_and_care) on the washer layout. + ``total_time_min`` maps to ``total_time`` (the timed-program duration). + """ + return ZeoStartParams( + mode=custom.mode, + program=custom.program, + temperature=getattr(custom, "temperature", None), + rinse=getattr(custom, "rinse", None), + spin=getattr(custom, "spin", None), + drying_mode=custom.drying_mode, + drying_method=getattr(custom, "drying_method", None), + steam_volume=custom.steam_volume, + total_time=custom.total_time_min or None, + soak=getattr(custom, "soak", None), + dry_and_care=getattr(custom, "dry_and_care", None), + ) diff --git a/roborock/devices/traits/a01/device_feature.py b/roborock/devices/traits/a01/device_feature.py index ae77ccad..2b82844a 100644 --- a/roborock/devices/traits/a01/device_feature.py +++ b/roborock/devices/traits/a01/device_feature.py @@ -1,5 +1,7 @@ from __future__ import annotations +from dataclasses import dataclass, fields + from roborock.data.zeo.zeo_code_mappings import ZeoFeatureBits from roborock.roborock_message import RoborockZeoProtocol @@ -143,6 +145,55 @@ } ) + +@dataclass +class ZeoFeatures: + """Device capability flags parsed from DP 237 (FEATURE_BITS). + + Field names match :class:`ZeoFeatureBits` members one-to-one, so + :meth:`from_feature_bits` derives the mapping by name reflection + instead of a hardcoded lookup table. + """ + + adapted_custom_program: bool = False + concentrated_detergent: bool = False + deep_self_clean: bool = False + detect_door_status: bool = False + dirt_detection: bool = False + dry_care: bool = False + expand_softener: bool = False + fluff_clean_notification: bool = False + ion_deodorization: bool = False + new_custom_program: bool = False + power_button_indicator_light: bool = False + save_panel_program_params: bool = False + set_params_in_working: bool = False + set_uvc_in_appointment: bool = False + set_uvc_in_pause: bool = False + silent_mode: bool = False + smart_hosting: bool = False + smile_light: bool = False + steam_care: bool = False + thirty_min_soak: bool = False + voice_assistant: bool = False + voice_assistant_record: bool = False + wash_dry_linkage: bool = False + wool_detergent: bool = False + + @classmethod + def from_feature_bits(cls, raw: int) -> ZeoFeatures: + """Parse a raw FEATURE_BITS integer into a :class:`ZeoFeatures`.""" + kwargs: dict[str, bool] = {} + for f in fields(cls): + bit_pos = getattr(ZeoFeatureBits, f.name) + kwargs[f.name] = bool(raw & (1 << int(bit_pos))) + return cls(**kwargs) + + def get_supported_features(self) -> list[str]: + """Return the names of all feature bits enabled on this device.""" + return [name for name, value in vars(self).items() if value] + + # Series that support UV light (DP 228). _UV_LIGHT_SERIES: frozenset[str] = ( _H1_LITE_SERIES # a90, a91, a237 @@ -265,6 +316,29 @@ def supports_smart_clean(model: str | None) -> bool: return model in (_M1_SERIES | _MUSE_SERIES | _HYPERION_SERIES | _APOLLO_SERIES | _HALIA_SERIES | _HERA_SERIES) +def is_addition_type_control_auto_addition(model: str | None) -> bool: + """True when the DetergentType DP itself controls auto-addition.""" + if model is None: + return False + excluded = ( + _H1_SERIES + | _H1_LITE_SERIES + | _HYPERION_SERIES + | _POSEIDON_SERIES + | _HALIA_SERIES + | _HERA_SERIES + | _PANDORA_SERIES + ) + return model not in excluded + + +def is_hyperion_halia_hera(model: str | None) -> bool: + """True for Hyperion / Halia / Hera series.""" + if model is None: + return False + return model in (_HYPERION_SERIES | _HALIA_SERIES | _HERA_SERIES) + + def build_force_load_dp_list(model: str | None) -> list[RoborockZeoProtocol]: """Return the complete DP list for ``_force_load()``.""" if is_dryer(model): @@ -343,7 +417,7 @@ def build_force_load_dp_list(model: str | None) -> list[RoborockZeoProtocol]: RoborockZeoProtocol.VOICE_VOLUME, # 10009 RoborockZeoProtocol.VOICE_RECORD_INFO, # 10302 RoborockZeoProtocol.VOICE_RECORD, # 10303 - RoborockZeoProtocol.SND_STATE, # 10004 + RoborockZeoProtocol.SOUND_PACKAGE_INFO, # 10004 ], ZeoFeatureBits.fluff_clean_notification: [ RoborockZeoProtocol.IS_NEED_FLUFF_CLEAN, # 250 diff --git a/roborock/devices/traits/a01/settings.py b/roborock/devices/traits/a01/settings.py new file mode 100644 index 00000000..c0b3cce5 --- /dev/null +++ b/roborock/devices/traits/a01/settings.py @@ -0,0 +1,505 @@ +"""Typed writable state and setters for Zeo (washing machine / dryer) devices. + +This module provides ``ZeoSettingTrait``, a single typed trait holding the +*writable* state of a Zeo device (mode, programme, wash/dry parameters, +auto-dosing, switches) plus its typed setters. The trait is shared across +washers and dryers: washers expose wash parameters (temperature, rinse, spin, +drying mode, soak, dry care) and dryers expose drying parameters (drying +method, steam volume). Fields that a given device family does not support +simply remain ``None``. + +Read-only device state (state code, errors, timers, tank levels, ...) lives +in :mod:`roborock.devices.traits.a01.status` (``ZeoStatusTrait``). +""" + +import datetime +import json +import logging +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any, TypeVar + +from roborock.data.code_mappings import RoborockEnum +from roborock.data.containers import RoborockBase +from roborock.data.zeo.zeo_code_mappings import ( + ZeoDetergentExpansionType, + ZeoDetergentType, + ZeoDryAndCare, + ZeoDryingMethod, + ZeoDryingMode, + ZeoMode, + ZeoProgram, + ZeoRinse, + ZeoSoak, + ZeoSoftenerExpansionType, + ZeoSoftenerType, + ZeoSpin, + ZeoSteamVolume, + ZeoTemperature, +) +from roborock.data.zeo.zeo_containers import ZeoStartParams +from roborock.devices.rpc.a01_channel import send_decoded_command +from roborock.devices.traits.a01.device_feature import ( + ZeoFeatures, + is_addition_type_control_auto_addition, + is_hyperion_halia_hera, + supports_uv_light, +) +from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener +from roborock.devices.transport.mqtt_channel import MqttChannel +from roborock.mqtt.session import MqttQos +from roborock.roborock_message import RoborockZeoProtocol + +_LOGGER = logging.getLogger(__name__) + +_T = TypeVar("_T", bound=RoborockEnum) + +FeaturesFn = Callable[[], ZeoFeatures | None] + +# Map ZeoStartParams field names to their DP ids. +# Field → DP ordering follows the order +# (wash branch): Start, Mode, Program, Soak, Temperature, Rinse, Spin, +# DryingMode, DryCareMode, DryingMethod, SteamVolume. +_FIELD_TO_DP: dict[str, RoborockZeoProtocol] = { + "mode": RoborockZeoProtocol.MODE, + "program": RoborockZeoProtocol.PROGRAM, + "soak": RoborockZeoProtocol.SOAK, + "temperature": RoborockZeoProtocol.TEMP, + "rinse": RoborockZeoProtocol.RINSE_TIMES, + "spin": RoborockZeoProtocol.SPIN_LEVEL, + "drying_mode": RoborockZeoProtocol.DRYING_MODE, + "dry_and_care": RoborockZeoProtocol.DRY_CARE_MODE, + "drying_method": RoborockZeoProtocol.DRYING_METHOD, + "steam_volume": RoborockZeoProtocol.STEAM_VOLUME, + "total_time": RoborockZeoProtocol.TOTAL_TIME, +} + + +def build_param_dps(params: ZeoStartParams) -> dict[RoborockZeoProtocol, Any]: + """Map the params onto their DP ids for a START/preset frame. + + - ``mode``/``program`` are always sent. + - Every other optional enum parameter is pushed only when it is non-null. + - ``total_time`` (DP 234) is only sent when ``> 0`` — it doubles as the + washer/dryer branch selector. + """ + # TODO(program-config): ``total_time`` is a fixed programme-config value + dps: dict[RoborockZeoProtocol, Any] = {} + for field_name, dp in _FIELD_TO_DP.items(): + val = getattr(params, field_name) + if field_name == "total_time": + if val is not None and val > 0: + dps[dp] = val + elif field_name in ("mode", "program"): + if val is not None: + dps[dp] = val + elif val is not None and int(val) != 0: + # Skip "empty" enum members (null/none/empty = 0), matching the + # Bundle's `x != null` guards for optional parameters. + dps[dp] = val + return dps + + +@dataclass(init=False) +class ZeoSettingTrait(RoborockBase, TraitUpdateListener): + """Base trait holding shared Zeo state and providing typed setters.""" + + # Shared writable state (both washers and dryers), updated from the MQTT + # push stream. Read-only state lives in ``ZeoStatusTrait`` (status.py). + mode: ZeoMode | None = field(default=None, metadata={"dps": RoborockZeoProtocol.MODE}) + program: ZeoProgram | None = field(default=None, metadata={"dps": RoborockZeoProtocol.PROGRAM}) + # Countdown for a delayed start (minutes). Writable via preset_with; the + # device also reports the active countdown here. + countdown: int | None = field(default=None, metadata={"dps": RoborockZeoProtocol.COUNTDOWN}) + + # Washer-specific state (None on dryers). + temperature: ZeoTemperature | None = field(default=None, metadata={"dps": RoborockZeoProtocol.TEMP}) + rinse: ZeoRinse | None = field(default=None, metadata={"dps": RoborockZeoProtocol.RINSE_TIMES}) + spin: ZeoSpin | None = field(default=None, metadata={"dps": RoborockZeoProtocol.SPIN_LEVEL}) + drying_mode: ZeoDryingMode | None = field(default=None, metadata={"dps": RoborockZeoProtocol.DRYING_MODE}) + soak: ZeoSoak | None = field(default=None, metadata={"dps": RoborockZeoProtocol.SOAK}) + dry_and_care: ZeoDryAndCare | None = field(default=None, metadata={"dps": RoborockZeoProtocol.DRY_CARE_MODE}) + + # Dryer-specific state (None on washers). Note ``total_time`` is read-only + # (device-reported running duration) and lives in ZeoStatusTrait. + drying_method: ZeoDryingMethod | None = field(default=None, metadata={"dps": RoborockZeoProtocol.DRYING_METHOD}) + steam_volume: ZeoSteamVolume | None = field(default=None, metadata={"dps": RoborockZeoProtocol.STEAM_VOLUME}) + + # Auto-dosing state (washers only; None on dryers). ``detergent_set``/ + # ``softener_set`` are the dedicated toggles on new series, while + # ``detergent_type``/``softener_type`` double as the toggle on old series + # (where a non-``none`` type implies auto-addition is on). + detergent_set: bool | None = field(default=None, metadata={"dps": RoborockZeoProtocol.DETERGENT_SET}) + softener_set: bool | None = field(default=None, metadata={"dps": RoborockZeoProtocol.SOFTENER_SET}) + detergent_type: ZeoDetergentType | None = field(default=None, metadata={"dps": RoborockZeoProtocol.DETERGENT_TYPE}) + softener_type: ZeoSoftenerType | None = field(default=None, metadata={"dps": RoborockZeoProtocol.SOFTENER_TYPE}) + + # Feature-gated boolean state (shared across washers and dryers). + ion_deodorization: bool | None = field(default=None, metadata={"dps": RoborockZeoProtocol.ION_DEODORIZATION}) + wash_dry_linked: bool | None = field(default=None, metadata={"dps": RoborockZeoProtocol.WASH_DRY_LINKED}) + + def __init__( + self, + channel: MqttChannel, + *, + model: str | None, + is_dryer: bool, + features: FeaturesFn, + ) -> None: + """Initialize the settings trait.""" + TraitUpdateListener.__init__(self, _LOGGER) + self._channel = channel + self._model = model + self._is_dryer = is_dryer + self._features = features + self._converter = DpsDataConverter.from_dataclass(type(self)) + + def update_from_dps(self, decoded_dps: dict[int, Any]) -> bool: + """Update trait fields from raw device DPS data. + + Returns True if any field changed (and notifies update listeners). + """ + if self._converter.update_from_dps(self, decoded_dps): + self._notify_update() + return True + return False + + @property + def auto_detergent(self) -> bool | None: + """Whether auto-dosing of detergent is on, derived from series state.""" + if self._is_dryer: + return None + if is_addition_type_control_auto_addition(self._model): + return self.detergent_type is not None and self.detergent_type != ZeoDetergentType.empty + return self.detergent_set + + @property + def auto_softener(self) -> bool | None: + """Whether auto-dosing of softener is on, derived from series state.""" + if self._is_dryer: + return None + if is_addition_type_control_auto_addition(self._model): + return self.softener_type is not None and self.softener_type != ZeoSoftenerType.empty + return self.softener_set + + async def _set_enum(self, dp: RoborockZeoProtocol, enum_cls: type[_T], value: _T) -> dict[RoborockZeoProtocol, Any]: + """Validate *value* against *enum_cls* and send it to the device.""" + if not isinstance(value, enum_cls): + raise TypeError(f"Expected {enum_cls.__name__}, got {type(value).__name__}") + return await self._send({dp: int(value)}) + + # -- Start-parameter setters (TEST-ONLY) -------------------------------- + # + # The individual setters below (mode/program/temperature/rinse/spin/ + # drying_mode/soak/dry_and_care/drying_method/steam_volume plus the + # feature-gated ion_deodorization/wash_dry_linked setters) each map to a + # DP that is *also* carried by the START command (see ``ZeoStartParams`` + # and ``build_param_dps``). Although the device responds to these + # single-DP writes, they have no practical use in production: a start + # sends the caller-supplied ``ZeoStartParams`` values, not the state held + # here. + # + # They are retained purely as a test/debug convenience. + + async def set_mode(self, mode: ZeoMode) -> dict[RoborockZeoProtocol, Any]: + """Set the current mode (DP 204). Test-only: carried by START.""" + return await self._set_enum(RoborockZeoProtocol.MODE, ZeoMode, mode) + + async def set_program(self, program: ZeoProgram) -> dict[RoborockZeoProtocol, Any]: + """Set the current programme (DP 205). Test-only: carried by START.""" + return await self._set_enum(RoborockZeoProtocol.PROGRAM, ZeoProgram, program) + + # -- Washer-specific setters -------------------------------------------- + + async def set_temperature(self, temperature: ZeoTemperature) -> dict[RoborockZeoProtocol, Any]: + """Set the wash temperature (DP 207). Test-only: carried by START.""" + return await self._set_enum(RoborockZeoProtocol.TEMP, ZeoTemperature, temperature) + + async def set_rinse(self, rinse: ZeoRinse) -> dict[RoborockZeoProtocol, Any]: + """Set the rinse count (DP 208). Test-only: carried by START.""" + return await self._set_enum(RoborockZeoProtocol.RINSE_TIMES, ZeoRinse, rinse) + + async def set_spin(self, spin: ZeoSpin) -> dict[RoborockZeoProtocol, Any]: + """Set the spin speed (DP 209). Test-only: carried by START.""" + return await self._set_enum(RoborockZeoProtocol.SPIN_LEVEL, ZeoSpin, spin) + + async def set_drying_mode(self, drying_mode: ZeoDryingMode) -> dict[RoborockZeoProtocol, Any]: + """Set the drying mode (DP 210). Test-only: carried by START.""" + return await self._set_enum(RoborockZeoProtocol.DRYING_MODE, ZeoDryingMode, drying_mode) + + async def set_soak(self, soak: ZeoSoak) -> dict[RoborockZeoProtocol, Any]: + """Set the soak level (DP 233). Test-only: carried by START.""" + return await self._set_enum(RoborockZeoProtocol.SOAK, ZeoSoak, soak) + + async def set_dry_and_care(self, dry_and_care: ZeoDryAndCare) -> dict[RoborockZeoProtocol, Any]: + """Set the dry-and-care mode (DP 244). Test-only: carried by START.""" + return await self._set_enum(RoborockZeoProtocol.DRY_CARE_MODE, ZeoDryAndCare, dry_and_care) + + # -- Dryer-specific setters --------------------------------------------- + + async def set_drying_method(self, drying_method: ZeoDryingMethod) -> dict[RoborockZeoProtocol, Any]: + """Set the drying method (DP 256). Test-only: carried by START.""" + return await self._set_enum(RoborockZeoProtocol.DRYING_METHOD, ZeoDryingMethod, drying_method) + + async def set_steam_volume(self, steam_volume: ZeoSteamVolume) -> dict[RoborockZeoProtocol, Any]: + """Set the steam volume (DP 257). Test-only: carried by START.""" + return await self._set_enum(RoborockZeoProtocol.STEAM_VOLUME, ZeoSteamVolume, steam_volume) + + async def set_detergent_type(self, detergent_type: ZeoDetergentType) -> dict[RoborockZeoProtocol, Any]: + """Set the detergent type (DP 213), optionally toggling auto-dosing.""" + e = int(detergent_type) + dps: dict[RoborockZeoProtocol, Any] + if is_addition_type_control_auto_addition(self._model): + dps = {RoborockZeoProtocol.DETERGENT_TYPE: e} + elif is_hyperion_halia_hera(self._model): + if e == 0: + dps = {RoborockZeoProtocol.DETERGENT_SET: 0} + else: + dps = { + RoborockZeoProtocol.DETERGENT_SET: 1, + RoborockZeoProtocol.DETERGENT_TYPE: e, + } + else: + dps = { + RoborockZeoProtocol.DETERGENT_SET: 0 if e == 0 else 1, + RoborockZeoProtocol.DETERGENT_TYPE: e, + } + return await self._send(dps) + + async def set_softener_type(self, softener_type: ZeoSoftenerType) -> dict[RoborockZeoProtocol, Any]: + """Set the softener type (DP 214).""" + e = int(softener_type) + dps: dict[RoborockZeoProtocol, Any] + if is_addition_type_control_auto_addition(self._model): + dps = {RoborockZeoProtocol.SOFTENER_TYPE: e} + elif is_hyperion_halia_hera(self._model): + if e == 0: + dps = {RoborockZeoProtocol.SOFTENER_SET: 0} + else: + dps = { + RoborockZeoProtocol.SOFTENER_SET: 1, + RoborockZeoProtocol.SOFTENER_TYPE: e, + } + else: + dps = { + RoborockZeoProtocol.SOFTENER_SET: 0 if e == 0 else 1, + RoborockZeoProtocol.SOFTENER_TYPE: e, + } + return await self._send(dps) + + async def set_detergent_box_type(self, expansion_type: ZeoDetergentExpansionType) -> dict[RoborockZeoProtocol, Any]: + """Set the detergent expansion type (DP 248).""" + dps = {RoborockZeoProtocol.DETERGENT_EXPANSION_TYPE: int(expansion_type)} + return await self._send(dps) + + async def set_softener_box_type(self, expansion_type: ZeoSoftenerExpansionType) -> dict[RoborockZeoProtocol, Any]: + """Set the softener expansion type (DP 245).""" + dps = {RoborockZeoProtocol.SOFTENER_EXPANSION_TYPE: int(expansion_type)} + return await self._send(dps) + + async def set_cleanser_config( + self, + auto_detergent: bool, + auto_softener: bool, + detergent_type: ZeoDetergentType, + softener_type: ZeoSoftenerType, + ) -> dict[RoborockZeoProtocol, Any]: + """Set the full cleanser config in one command (DP 211/212/213/214).""" + dps: dict[RoborockZeoProtocol, Any] = { + RoborockZeoProtocol.DETERGENT_SET: 1 if auto_detergent else 0, + RoborockZeoProtocol.SOFTENER_SET: 1 if auto_softener else 0, + RoborockZeoProtocol.DETERGENT_TYPE: int(detergent_type), + RoborockZeoProtocol.SOFTENER_TYPE: int(softener_type), + } + return await self._send(dps) + + async def set_voice_volume(self, volume: int) -> dict[RoborockZeoProtocol, Any]: + """Set the voice volume (DP 10009).""" + dps = {RoborockZeoProtocol.VOICE_VOLUME: json.dumps({"snd_volume": volume})} + return await self._send(dps) + + async def set_voice_switch(self, enabled: bool) -> dict[RoborockZeoProtocol, Any]: + """Enable/disable voice assistant (DP 10301).""" + dps = {RoborockZeoProtocol.VOICE_SWITCH: json.dumps({"speech_switch": 1 if enabled else 0})} + return await self._send(dps) + + async def delete_voice_record(self, record_id: str) -> dict[RoborockZeoProtocol, Any]: + """Delete a voice record by id (DP 10304).""" + dps = {RoborockZeoProtocol.VOICE_RECORD_DELETE: json.dumps({"dialog_delete": record_id})} + return await self._send(dps) + + async def set_sound_package(self, package: dict[str, Any]) -> dict[RoborockZeoProtocol, Any]: + """Set the sound package (DP 10003).""" + dps = {RoborockZeoProtocol.SET_SOUND_PACKAGE: json.dumps(package)} + return await self._send(dps) + + async def set_silent_mode( + self, + enabled: bool, + start_time: datetime.time, + end_time: datetime.time, + ) -> dict[RoborockZeoProtocol, Any]: + """Enable or disable silent mode with the given quiet hours. + + The three DPs (240/241/242) must be sent together. + + Requires the ``silent_mode`` feature bit; raises :class:`ValueError` + if the device does not support it. + """ + features = self._features() + if features is not None and not features.silent_mode: + raise ValueError("This device does not support silent mode") + dps: dict[RoborockZeoProtocol, Any] = { + RoborockZeoProtocol.SILENT_MODE_ON: 1 if enabled else 0, + RoborockZeoProtocol.SILENT_MODE_START_TIME: start_time.hour * 60 + start_time.minute, + RoborockZeoProtocol.SILENT_MODE_END_TIME: end_time.hour * 60 + end_time.minute, + } + return await self._send(dps) + + # -- Plain boolean switch setters --------------------------------------- + # + # These map one-to-one onto a boolean DP. Unlike the start-parameter + # setters above, they are *not* carried by the START command, so they are + # genuine runtime toggles. Each is feature-gated where the device has a + # corresponding feature bit (or series whitelist); gated setters raise + # :class:`ValueError` when the device does not support the feature. + + async def _set_bool(self, dp: RoborockZeoProtocol, enabled: bool) -> dict[RoborockZeoProtocol, Any]: + """Send a boolean DP as its integer ``1``/``0`` (confirmed wire format).""" + return await self._send({dp: 1 if enabled else 0}) + + async def set_child_lock(self, enabled: bool) -> dict[RoborockZeoProtocol, Any]: + """Enable/disable the child lock (DP 206).""" + return await self._set_bool(RoborockZeoProtocol.CHILD_LOCK, enabled) + + async def set_sound(self, enabled: bool) -> dict[RoborockZeoProtocol, Any]: + """Enable/disable the audible beeper (DP 223).""" + return await self._set_bool(RoborockZeoProtocol.SOUND_SET, enabled) + + async def set_dirt_detection(self, enabled: bool) -> dict[RoborockZeoProtocol, Any]: + """Enable/disable dirt detection (DP 215). + + Requires the ``dirt_detection`` feature bit. + """ + features = self._features() + if features is not None and not features.dirt_detection: + raise ValueError("This device does not support dirt detection") + return await self._set_bool(RoborockZeoProtocol.DIRT_DETECTION_SWITCH, enabled) + + async def set_default_setting(self, enabled: bool) -> dict[RoborockZeoProtocol, Any]: + """Set the default setting (DP 225).""" + return await self._set_bool(RoborockZeoProtocol.DEFAULT_SETTING, enabled) + + async def set_uv_light(self, enabled: bool) -> dict[RoborockZeoProtocol, Any]: + """Enable/disable the UV light (DP 228). + + Requires UV-light support for the device series. + """ + if not supports_uv_light(self._model): + raise ValueError("This device does not support UV light") + return await self._set_bool(RoborockZeoProtocol.UV_LIGHT, enabled) + + async def set_smart_hosting(self, enabled: bool) -> dict[RoborockZeoProtocol, Any]: + """Enable/disable smart hosting (DP 235). + + Requires the ``smart_hosting`` feature bit. + """ + features = self._features() + if features is not None and not features.smart_hosting: + raise ValueError("This device does not support smart hosting") + return await self._set_bool(RoborockZeoProtocol.SMART_HOSTING, enabled) + + async def set_smile_light(self, enabled: bool) -> dict[RoborockZeoProtocol, Any]: + """Enable/disable the smile light (DP 247). + + Requires the ``smile_light`` feature bit. + """ + features = self._features() + if features is not None and not features.smile_light: + raise ValueError("This device does not support smile light") + return await self._set_bool(RoborockZeoProtocol.SMILE_LIGHT_STATUS, enabled) + + async def set_fluff_cleaned(self, enabled: bool) -> dict[RoborockZeoProtocol, Any]: + """Mark the fluff filter as cleaned (DP 249). + + Requires the ``fluff_clean_notification`` feature bit. + """ + features = self._features() + if features is not None and not features.fluff_clean_notification: + raise ValueError("This device does not support fluff cleaning") + return await self._set_bool(RoborockZeoProtocol.FLUFF_CLEANED, enabled) + + async def set_power_light(self, enabled: bool) -> dict[RoborockZeoProtocol, Any]: + """Enable/disable the power-button indicator light (DP 251). + + Requires the ``power_button_indicator_light`` feature bit. + """ + features = self._features() + if features is not None and not features.power_button_indicator_light: + raise ValueError("This device does not support the power indicator light") + return await self._set_bool(RoborockZeoProtocol.POWER_LIGHT, enabled) + + async def set_ion_deodorization(self, enabled: bool) -> dict[RoborockZeoProtocol, Any]: + """Enable/disable ion deodorization (DP 258). Test-only: carried by START. + + Requires the ``ion_deodorization`` feature bit. + """ + features = self._features() + if features is not None and not features.ion_deodorization: + raise ValueError("This device does not support ion deodorization") + return await self._set_bool(RoborockZeoProtocol.ION_DEODORIZATION, enabled) + + async def set_wash_dry_linked(self, enabled: bool) -> dict[RoborockZeoProtocol, Any]: + """Enable/disable the wash-dry linkage (DP 255). Test-only: carried by START. + + Requires the ``wash_dry_linkage`` feature bit. + """ + features = self._features() + if features is not None and not features.wash_dry_linkage: + raise ValueError("This device does not support wash-dry linkage") + return await self._set_bool(RoborockZeoProtocol.WASH_DRY_LINKED, enabled) + + async def _send( + self, + dps: dict[RoborockZeoProtocol, Any], + qos: MqttQos = MqttQos.AT_MOST_ONCE, + ) -> dict[RoborockZeoProtocol, Any]: + """Send already wire-formatted DPs over the device channel.""" + return await send_decoded_command(self._channel, dps, value_encoder=lambda x: x, qos=qos) + + async def save_cloud_program(self, params: ZeoStartParams) -> dict[RoborockZeoProtocol, Any]: + """Save the current programme parameters as a cloud custom program. + + Sends Mode/Program (and the optional Soak/Temperature/Rinse/Spin/ + DryingMode/DryCareMode/DryingMethod/SteamVolume) followed by a trigger + signal of integer ``1``: either ``SaveAdaptedCloudProgram``(254) when + the ``adapted_custom_program`` feature is supported, or + ``SaveCloudProgram``(221) otherwise. + """ + dps = build_param_dps(params) + features = self._features() + if features is not None and features.adapted_custom_program: + dps[RoborockZeoProtocol.SAVE_ADAPTED_CLOUD_PROGRAM] = 1 + else: + dps[RoborockZeoProtocol.CUSTOM_PARAM_SAVE] = 1 + return await self._send(dps, MqttQos.AT_LEAST_ONCE) + + async def save_panel_program(self, params: ZeoStartParams) -> dict[RoborockZeoProtocol, Any]: + """Save the current programme parameters as a panel program (DP 252). + + Same parameter set as :meth:`save_cloud_program`, but the trigger + signal is ``PanelProgramParamsSet``(252) instead. + """ + dps = build_param_dps(params) + dps[RoborockZeoProtocol.PANEL_PROGRAM_PARAMS_SET] = 1 + return await self._send(dps, MqttQos.AT_LEAST_ONCE) + + async def load_cloud_program(self) -> dict[RoborockZeoProtocol, Any]: + """Apply the currently saved cloud custom program (DP 222 = 1). + + This only sends the trigger signal ``CUSTOM_PARAM_GET``(222) with an + integer ``1``. The device responds by pushing the current program's + 32-bit bitfield back on DP 222 (see ``ZeoCustomMode.from_raw``). + """ + dps: dict[RoborockZeoProtocol, Any] = {RoborockZeoProtocol.CUSTOM_PARAM_GET: 1} + return await self._send(dps, MqttQos.AT_LEAST_ONCE) diff --git a/roborock/devices/traits/a01/status.py b/roborock/devices/traits/a01/status.py new file mode 100644 index 00000000..fb81c7ca --- /dev/null +++ b/roborock/devices/traits/a01/status.py @@ -0,0 +1,110 @@ +"""Read-only typed state for Zeo (washing machine / dryer) devices. + +This module provides ``ZeoStatusTrait``, a single typed trait holding the +read-only DPs reported by the device (state, errors, timers, tank levels, +etc.). +""" + +import json +import logging +from dataclasses import dataclass, field +from typing import Any + +from roborock.data.containers import RoborockBase +from roborock.data.zeo.zeo_code_mappings import ( + ZeoDirtDetectionStatus, + ZeoDryerStartError, + ZeoError, + ZeoState, +) +from roborock.data.zeo.zeo_containers import ZeoWashLog +from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener +from roborock.roborock_message import RoborockZeoProtocol + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(init=False) +class ZeoStatusTrait(RoborockBase, TraitUpdateListener): + """Read-only state of a Zeo device, updated from the MQTT push stream. + + Fields are populated by :meth:`update_from_dps` from the device-reported + data (no setters — everything here is read-only). Fields that a given + device family does not report simply remain ``None``. + """ + + # Shared state (both washers and dryers). + state: ZeoState | None = field(default=None, metadata={"dps": RoborockZeoProtocol.STATE}) + error: ZeoError | None = field(default=None, metadata={"dps": RoborockZeoProtocol.ERROR}) + washing_left: int | None = field(default=None, metadata={"dps": RoborockZeoProtocol.WASHING_LEFT}) + doorlock_state: bool | None = field(default=None, metadata={"dps": RoborockZeoProtocol.DOORLOCK_STATE}) + times_after_clean: int | None = field(default=None, metadata={"dps": RoborockZeoProtocol.TIMES_AFTER_CLEAN}) + app_authorization: bool | None = field(default=None, metadata={"dps": RoborockZeoProtocol.APP_AUTHORIZATION}) + + # Washer-specific status (None on dryers). + dirt_detection_status: ZeoDirtDetectionStatus | None = field( + default=None, metadata={"dps": RoborockZeoProtocol.DIRT_DETECTION_STATUS} + ) + detergent_empty: bool | None = field(default=None, metadata={"dps": RoborockZeoProtocol.DETERGENT_EMPTY}) + softener_empty: bool | None = field(default=None, metadata={"dps": RoborockZeoProtocol.SOFTENER_EMPTY}) + + # Dryer-specific status (None on washers). + total_time: int | None = field(default=None, metadata={"dps": RoborockZeoProtocol.TOTAL_TIME}) + cloth_put_in: bool | None = field(default=None, metadata={"dps": RoborockZeoProtocol.CLOTH_PUT_IN}) + cloth_ready_to_dry_count_down: int | None = field( + default=None, metadata={"dps": RoborockZeoProtocol.CLOTH_READY_TO_DRY_COUNT_DOWN} + ) + start_dryer_error: ZeoDryerStartError | None = field( + default=None, metadata={"dps": RoborockZeoProtocol.START_DRYER_ERROR} + ) + + # Smart-hosting status. + smart_hosting_time: int | None = field(default=None, metadata={"dps": RoborockZeoProtocol.SMART_HOSTING_TIME}) + smart_hosting_waited_time: int | None = field( + default=None, metadata={"dps": RoborockZeoProtocol.SMART_HOSTING_WAITED_TIME} + ) + is_need_fluff_clean: bool | None = field(default=None, metadata={"dps": RoborockZeoProtocol.IS_NEED_FLUFF_CLEAN}) + + # Other read-only status. + custom_program_cleaning_time: int | None = field( + default=None, metadata={"dps": RoborockZeoProtocol.CUSTOM_PROGRAM_CLEANING_TIME} + ) + panel_program_params_set_result: int | None = field( + default=None, metadata={"dps": RoborockZeoProtocol.PANEL_PROGRAM_PARAMS_SET_RESULT} + ) + panel_timing_program_params: int | None = field( + default=None, metadata={"dps": RoborockZeoProtocol.PANEL_TIMING_PROGRAM_PARAMS} + ) + steam_care_time: int | None = field(default=None, metadata={"dps": RoborockZeoProtocol.STEAM_CARE_TIME}) + device_bound: bool | None = field(default=None, metadata={"dps": RoborockZeoProtocol.DEVICE_BOUND}) + + # Meta — device-reported JSON documents (read-only). + product_info: dict[str, Any] | None = field(default=None, metadata={"dps": RoborockZeoProtocol.PRODUCT_INFO}) + washing_log: ZeoWashLog | None = field(default=None, metadata={"dps": RoborockZeoProtocol.WASHING_LOG}) + + def __init__(self) -> None: + """Initialize the status trait.""" + TraitUpdateListener.__init__(self, _LOGGER) + self._converter = DpsDataConverter.from_dataclass(type(self)) + + def update_from_dps(self, decoded_dps: dict[int, Any]) -> bool: + """Update trait fields from raw device DPS data. + + Meta fields that arrive as JSON strings (``product_info``, + ``washing_log``) are parsed before conversion. Returns True if any + field changed (and notifies update listeners). + """ + # JSON-string meta fields: parse them into dicts so the converter can + # build the typed containers / dicts from them. + for dp in (RoborockZeoProtocol.PRODUCT_INFO, RoborockZeoProtocol.WASHING_LOG): + raw = decoded_dps.get(int(dp)) + if isinstance(raw, str): + try: + decoded_dps[int(dp)] = json.loads(raw) + except ValueError: + _LOGGER.debug("Failed to parse JSON for DP %s", dp, exc_info=True) + + if self._converter.update_from_dps(self, decoded_dps): + self._notify_update() + return True + return False diff --git a/roborock/roborock_message.py b/roborock/roborock_message.py index 9fab9357..04c05017 100644 --- a/roborock/roborock_message.py +++ b/roborock/roborock_message.py @@ -179,11 +179,10 @@ class RoborockZeoProtocol(RoborockEnum): SILENT_MODE_ON = 240 # rw [independent] use set_silent_mode() for bundled set SILENT_MODE_START_TIME = 241 # rw [independent] minute-of-day SILENT_MODE_END_TIME = 242 # rw [independent] minute-of-day - UNKNOWN_243 = ( - 243 # unknown, not found in plugin bundle; present in MQTT push from some devices, increments with each push - ) + UNKNOWN_243 = 243 # int, present in MQTT push from some devices, increments with each push DRY_CARE_MODE = 244 # rw [startWith] SOFTENER_EXPANSION_TYPE = 245 # rw [independent] + UNKNOWN_246 = 246 SMILE_LIGHT_STATUS = 247 # rw [independent] DETERGENT_EXPANSION_TYPE = 248 # rw [independent] FLUFF_CLEANED = 249 # rw [independent] @@ -196,6 +195,7 @@ class RoborockZeoProtocol(RoborockEnum): DRYING_METHOD = 256 # rw [startWith] STEAM_VOLUME = 257 # rw [startWith] ION_DEODORIZATION = 258 # rw [startWith / feature-gated] + UNKNOWN_259 = 259 PANEL_TIMING_PROGRAM_PARAMS = 260 # ro STEAM_CARE_TIME = 261 # ro DEVICE_BOUND = 262 # ro @@ -207,19 +207,21 @@ class RoborockZeoProtocol(RoborockEnum): # ── Meta / RPC / Voice (10000+) ──────────────────────────────────── ID_QUERY = 10000 # -- multi-DP query request (not a device DP) F_C = 10001 # ro query via checkFCCState() - SET_SOUND_PACKAGE = 10003 # wo setSoundPackage(JSON) - SND_STATE = 10004 # ro query via updateSoundPackageInfo() + SET_SOUND_PACKAGE = 10003 # wo setSoundPackage(obj) → JSON.stringify(obj) + SOUND_PACKAGE_INFO = 10004 # ro query via updateSoundPackageInfo(), JSON object PRODUCT_INFO = 10005 # ro query via loadGeneralInfo() (10s timeout) - PRIVACY_INFO = 10006 # wo syncPrivacyToDevice(agreed) + PRIVACY_INFO = 10006 # wo OTA_NFO = 10007 # ro forceLoad only WASHING_LOG = 10008 # ro forceLoad only, JSON - VOICE_VOLUME = 10009 # wo [independent] setVoiceVolume(int) → JSON + VOICE_VOLUME = 10009 # rw [independent] setVoiceVolume(int) → JSON.stringify({snd_volume: int}); readable + # via voiceVolume getter (FeatureBit.VoiceAssistant gated) RPC_REQUEST = 10101 # wo rpcRequest(method) → JSON RPC_RESPONSE = 10102 # -- MQTT push protocol 102, not a device DP - VOICE_SWITCH = 10301 # wo [independent] setVoiceSwitchStatus(bool) → JSON + VOICE_SWITCH = 10301 # rw [independent] setVoiceSwitchStatus(bool) → JSON.stringify({speech_switch: 1/0}); + # readable via isVoiceSwitchOn getter (FeatureBit.VoiceAssistant gated) VOICE_RECORD_INFO = 10302 # ro cache-derived, auto JSON decoded - VOICE_RECORD = 10303 # ro query via getVoiceControlRecord(), JSON - VOICE_RECORD_DELETE = 10304 # wo [independent] deleteVoiceControlRecord(id) → JSON + VOICE_RECORD = 10303 # ro query via getVoiceControlRecord(), JSON {result:{history:[{id,ts,...}]}} + VOICE_RECORD_DELETE = 10304 # wo [independent] deleteVoiceControlRecord(id) → JSON.stringify({dialog_delete: id}) class RoborockB01Protocol(RoborockEnum):