diff --git a/roborock/data/zeo/zeo_code_mappings.py b/roborock/data/zeo/zeo_code_mappings.py index 4e5c79d16..96d0c2694 100644 --- a/roborock/data/zeo/zeo_code_mappings.py +++ b/roborock/data/zeo/zeo_code_mappings.py @@ -6,6 +6,7 @@ class ZeoMode(RoborockEnum): wash = 1 wash_and_dry = 2 dry = 3 + treatment = 4 class ZeoState(RoborockEnum): @@ -19,8 +20,16 @@ class ZeoState(RoborockEnum): cooling = 8 under_delay_start = 9 done = 10 - aftercare = 12 - waiting_for_aftercare = 13 + updating = 11 + 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): @@ -68,6 +77,7 @@ class ZeoSoak(RoborockEnum): medium = 2 high = 3 max = 4 + very_max = 5 # 30 minutes class ZeoTemperature(RoborockEnum): @@ -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 diff --git a/roborock/devices/rpc/a01_channel.py b/roborock/devices/rpc/a01_channel.py index 2e2f9ceac..66b8b27d7 100644 --- a/roborock/devices/rpc/a01_channel.py +++ b/roborock/devices/rpc/a01_channel.py @@ -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, @@ -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]: ... @@ -38,6 +40,7 @@ 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]: ... @@ -45,8 +48,16 @@ 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) @@ -54,7 +65,7 @@ async def send_decoded_command( # 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 diff --git a/roborock/devices/traits/a01/__init__.py b/roborock/devices/traits/a01/__init__.py index 537c84377..300f02413 100644 --- a/roborock/devices/traits/a01/__init__.py +++ b/roborock/devices/traits/a01/__init__.py @@ -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 @@ -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", @@ -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, @@ -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), } @@ -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.""" @@ -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: + return await self.start() + params: dict[RoborockZeoProtocol, Any] = {protocol: value} return await send_decoded_command(self._channel, params, value_encoder=lambda x: x) diff --git a/roborock/devices/transport/mqtt_channel.py b/roborock/devices/transport/mqtt_channel.py index 5ff0ab085..1a0f81e64 100644 --- a/roborock/devices/transport/mqtt_channel.py +++ b/roborock/devices/transport/mqtt_channel.py @@ -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 @@ -89,11 +89,15 @@ 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) @@ -101,7 +105,7 @@ async def publish(self, message: RoborockMessage) -> None: 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 diff --git a/roborock/mqtt/roborock_session.py b/roborock/mqtt/roborock_session.py index ec6e5aa71..15202372e 100644 --- a/roborock/mqtt/roborock_session.py +++ b/roborock/mqtt/roborock_session.py @@ -22,7 +22,7 @@ from roborock.diagnostics import Diagnostics, redact_topic_name from .health_manager import HealthManager -from .session import MqttParams, MqttSession, MqttSessionException, MqttSessionUnauthorized +from .session import MqttParams, MqttQos, MqttSession, MqttSessionException, MqttSessionUnauthorized _LOGGER = logging.getLogger(__name__) _MQTT_LOGGER = logging.getLogger(f"{__name__}.aiomqtt") @@ -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: MqttQos = MqttQos.AT_MOST_ONCE) -> 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 AT_MOST_ONCE. + """ _LOGGER.debug("Sending message to topic %s: %s", topic, message) client: aiomqtt.Client async with self._client_lock: @@ -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 @@ -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: MqttQos = MqttQos.AT_MOST_ONCE) -> 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. diff --git a/roborock/mqtt/session.py b/roborock/mqtt/session.py index 9e77b4a86..319615fc4 100644 --- a/roborock/mqtt/session.py +++ b/roborock/mqtt/session.py @@ -3,6 +3,7 @@ from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass, field +from enum import IntEnum from roborock.diagnostics import Diagnostics from roborock.exceptions import RoborockException @@ -10,6 +11,24 @@ DEFAULT_TIMEOUT = 30.0 + +class MqttQos(IntEnum): + """MQTT Quality of Service levels. + + A01 devices (Zeo, Dyad) require ``AT_LEAST_ONCE`` for DP200 (start) + commands. Other protocol versions use ``AT_MOST_ONCE``. + """ + + AT_MOST_ONCE = 0 + """Fire-and-forget. No acknowledgment required.""" + + AT_LEAST_ONCE = 1 + """Guaranteed delivery with possible duplicates. Broker sends PUBACK.""" + + EXACTLY_ONCE = 2 + """Guaranteed delivery with no duplicates. Broker sends PUBREC/PUBREL/PUBCOMP.""" + + SessionUnauthorizedHook = Callable[[], None] @@ -76,10 +95,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: MqttQos = MqttQos.AT_MOST_ONCE) -> 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. Defaults to AT_MOST_ONCE. """ @abstractmethod diff --git a/roborock/protocols/a01_protocol.py b/roborock/protocols/a01_protocol.py index f3166de87..46db6ef4a 100644 --- a/roborock/protocols/a01_protocol.py +++ b/roborock/protocols/a01_protocol.py @@ -2,6 +2,7 @@ import json import logging +import time from collections.abc import Callable from typing import Any @@ -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, diff --git a/roborock/roborock_message.py b/roborock/roborock_message.py index b78ce5bab..191a7275b 100644 --- a/roborock/roborock_message.py +++ b/roborock/roborock_message.py @@ -140,6 +140,8 @@ class RoborockZeoProtocol(RoborockEnum): SOFTENER_SET = 212 # rw DETERGENT_TYPE = 213 # rw SOFTENER_TYPE = 214 # rw + DIRT_DETECTION_SWITCH = 215 # rw + DIRT_DETECTION_STATUS = 216 # ro COUNTDOWN = 217 # rw WASHING_LEFT = 218 # ro DOORLOCK_STATE = 219 # ro @@ -151,10 +153,42 @@ class RoborockZeoProtocol(RoborockEnum): DEFAULT_SETTING = 225 # rw DETERGENT_EMPTY = 226 # ro SOFTENER_EMPTY = 227 # ro + UV_LIGHT = 228 # rw LIGHT_SETTING = 229 # rw DETERGENT_VOLUME = 230 # rw SOFTENER_VOLUME = 231 # rw APP_AUTHORIZATION = 232 # rw + SOAK = 233 # rw + TOTAL_TIME = 234 # ro + SMART_HOSTING = 235 # rw + SMART_HOSTING_TIME = 236 # rw + FEATURE_BITS = 237 # ro + SMART_HOSTING_WAITED_TIME = 238 # ro + CUSTOM_PROGRAM_CLEANING_TIME = 239 # rw + SILENT_MODE_ON = 240 # rw + SILENT_MODE_START_TIME = 241 # rw + SILENT_MODE_END_TIME = 242 # rw + DRY_CARE_MODE = 244 # rw + SOFTENER_EXPANSION_TYPE = 245 # rw + SMILE_LIGHT_STATUS = 247 # rw + DETERGENT_EXPANSION_TYPE = 248 # rw + FLUFF_CLEANED = 249 # ro + IS_NEED_FLUFF_CLEAN = 250 # ro + POWER_LIGHT = 251 # rw + PANEL_PROGRAM_PARAMS_SET = 252 # rw + PANEL_PROGRAM_PARAMS_SET_RESULT = 253 # ro + SAVE_ADAPTED_CLOUD_PROGRAM = 254 # rw + WASH_DRY_LINKED = 255 # rw + DRYING_METHOD = 256 # rw + STEAM_VOLUME = 257 # rw + ION_DEODORIZATION = 258 # rw + PANEL_TIMING_PROGRAM_PARAMS = 260 # rw + STEAM_CARE_TIME = 261 # rw + DEVICE_BOUND = 262 # ro + CLOTH_PUT_IN = 263 # ro + CLOTH_READY_TO_DRY_COUNT_DOWN = 264 # ro + START_DRYER_ERROR = 265 # ro + WIFI_LINKAGE_RESET = 266 # rw ID_QUERY = 10000 F_C = 10001 SND_STATE = 10004 diff --git a/roborock/testing/channel.py b/roborock/testing/channel.py index 7ffccc2de..9bb811532 100644 --- a/roborock/testing/channel.py +++ b/roborock/testing/channel.py @@ -11,6 +11,7 @@ from roborock.devices.transport.channel import Channel from roborock.mqtt.health_manager import HealthManager +from roborock.mqtt.session import MqttQos from roborock.protocols.v1_protocol import LocalProtocolVersion from roborock.roborock_message import RoborockMessage @@ -82,6 +83,7 @@ def __init__(self, is_local: bool = False): self.close = MagicMock(side_effect=self._close) self.protocol_version = LocalProtocolVersion.V1 + self.restart = AsyncMock() self.health_manager = HealthManager(self.restart) @@ -101,10 +103,13 @@ def is_local_connected(self) -> bool: """Return true if locally connected.""" return self._is_connected and self._is_local - async def _publish(self, message: RoborockMessage) -> None: + async def _publish(self, message: RoborockMessage, qos: MqttQos = MqttQos.AT_MOST_ONCE) -> None: """Default publish implementation. Records the message in ``published_messages`` and executes ``publish_handler``. + + The ``qos`` parameter is accepted for compatibility with + ``MqttChannel.publish`` but not simulated by the fake channel. """ self.published_messages.append(message) if self.publish_side_effect: diff --git a/tests/devices/traits/a01/test_init.py b/tests/devices/traits/a01/test_init.py index 8e1cb7dd8..7e2fd6a69 100644 --- a/tests/devices/traits/a01/test_init.py +++ b/tests/devices/traits/a01/test_init.py @@ -77,7 +77,8 @@ async def test_dyad_api_query_values(dyad_api: DyadApi, fake_channel: FakeChanne assert message.protocol == RoborockMessageProtocol.RPC_REQUEST assert message.version == b"A01" payload_data = json.loads(unpad(message.payload, AES.block_size)) - assert payload_data == {"dps": {"10000": "[209, 201, 207, 214, 215, 227, 229, 230, 222, 224]"}} + assert payload_data["dps"] == {"10000": "[209, 201, 207, 214, 215, 227, 229, 230, 222, 224]"} + assert "t" in payload_data @pytest.mark.parametrize( @@ -174,7 +175,8 @@ async def test_zeo_api_query_values(zeo_api: ZeoApi, fake_channel: FakeChannel): assert message.protocol == RoborockMessageProtocol.RPC_REQUEST assert message.version == b"A01" payload_data = json.loads(unpad(message.payload, AES.block_size)) - assert payload_data == {"dps": {"10000": "[203, 207, 226, 227, 224, 218]"}} + assert payload_data["dps"] == {"10000": "[203, 207, 226, 227, 224, 218]"} + assert "t" in payload_data @pytest.mark.parametrize( @@ -245,7 +247,8 @@ async def test_dyad_api_set_value(dyad_api: DyadApi, fake_channel: FakeChannel): # decode the payload to verify contents payload_data = json.loads(unpad(message.payload, AES.block_size)) # A01 protocol expects values to be strings in the dps dict - assert payload_data == {"dps": {"209": 1}} + assert payload_data["dps"] == {"209": 1} + assert "t" in payload_data async def test_zeo_api_set_value(zeo_api: ZeoApi, fake_channel: FakeChannel): @@ -261,4 +264,5 @@ async def test_zeo_api_set_value(zeo_api: ZeoApi, fake_channel: FakeChannel): # decode the payload to verify contents payload_data = json.loads(unpad(message.payload, AES.block_size)) # A01 protocol expects values to be strings in the dps dict - assert payload_data == {"dps": {"204": "standard"}} + assert payload_data["dps"] == {"204": "standard"} + assert "t" in payload_data diff --git a/tests/e2e/__snapshots__/test_device_manager.ambr b/tests/e2e/__snapshots__/test_device_manager.ambr index 90f2398dc..157f89c38 100644 --- a/tests/e2e/__snapshots__/test_device_manager.ambr +++ b/tests/e2e/__snapshots__/test_device_manager.ambr @@ -13,12 +13,13 @@ [mqtt <] 00000000 90 04 00 01 00 00 |......| [mqtt >] - 00000000 30 5a 00 20 72 72 2f 6d 2f 69 2f 75 73 65 72 31 |0Z. rr/m/i/user1| + 00000000 30 6a 00 20 72 72 2f 6d 2f 69 2f 75 73 65 72 31 |0j. rr/m/i/user1| 00000010 32 33 2f 31 39 36 34 38 66 39 34 2f 7a 65 6f 5f |23/19648f94/zeo_| 00000020 64 75 69 64 00 41 30 31 00 00 23 82 00 00 23 83 |duid.A01..#...#.| - 00000030 68 a6 a2 24 00 65 00 20 c5 de 2b f6 a9 ba 32 7e |h..$.e. ..+...2~| - 00000040 6b 73 82 bb d8 67 d4 db 7e cd 61 aa 8c 38 56 53 |ks...g..~.a..8VS| - 00000050 ca 4e 15 0d b1 b7 80 a2 0f 16 58 36 |.N........X6| + 00000030 68 a6 a2 24 00 65 00 30 c5 de 2b f6 a9 ba 32 7e |h..$.e.0..+...2~| + 00000040 6b 73 82 bb d8 67 d4 db 7d 80 60 67 80 96 b8 a1 |ks...g..}.`g....| + 00000050 c6 bc 9e d2 da 07 fb d3 79 f5 6f 6d 04 9c 71 00 |........y.om..q.| + 00000060 48 66 3d 7e 5d fe d3 df 18 e4 26 38 |Hf=~].....&8| [mqtt <] 00000000 30 5e 00 20 72 72 2f 6d 2f 6f 2f 75 73 65 72 31 |0^. rr/m/o/user1| 00000010 32 33 2f 31 39 36 34 38 66 39 34 2f 7a 65 6f 5f |23/19648f94/zeo_| diff --git a/tests/fixtures/logging_fixtures.py b/tests/fixtures/logging_fixtures.py index e267f0488..136326983 100644 --- a/tests/fixtures/logging_fixtures.py +++ b/tests/fixtures/logging_fixtures.py @@ -52,6 +52,7 @@ def get_token_bytes(n: int) -> bytes: with ( patch("roborock.devices.transport.local_channel.get_next_int", side_effect=get_next_int), + patch("roborock.protocols.a01_protocol.time.time", return_value=1755750947.0), patch("roborock.protocols.b01_q7_protocol.get_next_int", side_effect=get_next_int), patch("roborock.protocols.v1_protocol.get_next_int", side_effect=get_next_int), patch("roborock.protocols.v1_protocol.get_timestamp", side_effect=get_timestamp),