Skip to content
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

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

smart_hosting = 12
smart_hosting_waiting = 13
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
14 changes: 12 additions & 2 deletions roborock/devices/rpc/a01_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ async def send_decoded_command(
mqtt_channel: MqttChannel,
params: dict[RoborockDyadDataProtocol, Any],
value_encoder: Callable[[Any], Any] | None = None,
qos: int = 0,
) -> dict[RoborockDyadDataProtocol, Any]: ...


Expand All @@ -38,23 +39,32 @@ async def send_decoded_command(
mqtt_channel: MqttChannel,
params: dict[RoborockZeoProtocol, Any],
value_encoder: Callable[[Any], Any] | None = None,
qos: int = 0,
) -> 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: int = 0,
) -> 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 (0, 1, or 2). Defaults to 0.
"""
_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
95 changes: 93 additions & 2 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 @@ -38,21 +39,29 @@
)
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.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol

_LOGGER = logging.getLogger(__name__)

__init__ = [
"DyadApi",
"ZeoApi",
Expand Down Expand Up @@ -101,6 +110,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 +136,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 @@ -176,8 +228,47 @@ async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[Robor

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

_LOGGER.debug("Start command detected, querying current device state")
try:
current = await send_decoded_command(
self._channel,
{
RoborockZeoProtocol.ID_QUERY: [
RoborockZeoProtocol.MODE,
RoborockZeoProtocol.PROGRAM,
RoborockZeoProtocol.TEMP,
RoborockZeoProtocol.RINSE_TIMES,
RoborockZeoProtocol.SPIN_LEVEL,
RoborockZeoProtocol.DRYING_MODE,
]
},
value_encoder=json.dumps,
)
for dp, fallback in (
(RoborockZeoProtocol.MODE, 1),
(RoborockZeoProtocol.PROGRAM, 1),
(RoborockZeoProtocol.TEMP, None),
(RoborockZeoProtocol.RINSE_TIMES, None),
(RoborockZeoProtocol.SPIN_LEVEL, None),
(RoborockZeoProtocol.DRYING_MODE, None),
):
if dp in current:
params[dp] = current[dp]
elif fallback is not None:
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?

exc_info=True,
)
params[RoborockZeoProtocol.MODE] = 1
params[RoborockZeoProtocol.PROGRAM] = 1
# START commands require QoS 1; all other A01 commands use default QoS 0.
return await send_decoded_command(
self._channel, params, value_encoder=lambda x: x, qos=1 if protocol == RoborockZeoProtocol.START else 0
)


def create(product: HomeDataProduct, mqtt_channel: MqttChannel) -> DyadApi | ZeoApi:
Expand Down
8 changes: 6 additions & 2 deletions roborock/devices/transport/mqtt_channel.py
Original file line number Diff line number Diff line change
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: 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.

"""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 0.
"""
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
16 changes: 11 additions & 5 deletions roborock/mqtt/roborock_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,8 +361,14 @@ def delayed_unsub():

return delayed_unsub

async def publish(self, topic: str, message: bytes) -> None:
"""Publish a message on the topic."""
async def publish(self, topic: str, message: bytes, qos: int = 0) -> None:
"""Publish a message on the topic.

Args:
topic: The MQTT topic to publish to.
message: The message payload.
qos: The MQTT QoS level. Defaults to 0.
"""
_LOGGER.debug("Sending message to topic %s: %s", topic, message)
client: aiomqtt.Client
async with self._client_lock:
Expand All @@ -371,7 +377,7 @@ async def publish(self, topic: str, message: bytes) -> None:
client = self._client
try:
with self._diagnostics.timer("publish"):
await client.publish(topic, message)
await client.publish(topic, message, qos=qos)
except MqttError as err:
raise MqttSessionException(f"Error publishing message: {err}") from err

Expand Down Expand Up @@ -417,13 +423,13 @@ async def subscribe(self, device_id: str, callback: Callable[[bytes], None]) ->
await self._maybe_start()
return await self._session.subscribe(device_id, callback)

async def publish(self, topic: str, message: bytes) -> None:
async def publish(self, topic: str, message: bytes, qos: int = 0) -> None:
"""Publish a message on the specified topic.

This will raise an exception if the message could not be sent.
"""
await self._maybe_start()
return await self._session.publish(topic, message)
return await self._session.publish(topic, message, qos=qos)

async def close(self) -> None:
"""Cancels the mqtt loop.
Expand Down
7 changes: 6 additions & 1 deletion roborock/mqtt/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,15 @@ async def subscribe(self, device_id: str, callback: Callable[[bytes], None]) ->
"""

@abstractmethod
async def publish(self, topic: str, message: bytes) -> None:
async def publish(self, topic: str, message: bytes, qos: int = 0) -> None:
"""Publish a message on the specified topic.

This will raise an exception if the message could not be sent.

Args:
topic: The MQTT topic to publish to.
message: The message payload.
qos: The MQTT QoS level (0, 1, or 2). Defaults to 0.
"""

@abstractmethod
Expand Down
6 changes: 5 additions & 1 deletion roborock/protocols/a01_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import json
import logging
import time
from collections.abc import Callable
from typing import Any

Expand Down Expand Up @@ -42,7 +43,10 @@ def encode_mqtt_payload(
"""
if value_encoder is None:
value_encoder = _no_encode
dps_data = {"dps": {key: value_encoder(value) for key, value in data.items()}}
dps_data = {
"dps": {key: value_encoder(value) for key, value in data.items()},
"t": int(time.time()),
}
payload = pad(json.dumps(dps_data).encode("utf-8"), AES.block_size)
return RoborockMessage(
protocol=RoborockMessageProtocol.RPC_REQUEST,
Expand Down
Loading
Loading