Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions roborock/testing/channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
in-memory replacement for `MqttChannel` and `LocalChannel` during testing.
"""

from collections.abc import Callable
from collections.abc import Awaitable, Callable
from typing import Any
from unittest.mock import AsyncMock, MagicMock

Expand Down Expand Up @@ -37,6 +37,9 @@ class FakeChannel(Channel):
publish (useful for low-level RPC request/response testing).
- **Push unsolicited messages**: Call ``channel.notify_subscribers(msg)``
to simulate the device broadcasting a state change.
- **Intercept published messages**: Register a handler/callback via
``channel.publish_handler = my_handler`` (e.g. stateful simulator)
to reactively process commands.
"""

subscribe: Any
Expand All @@ -49,6 +52,11 @@ def __init__(self, is_local: bool = False):
self._is_connected = False
self._is_local = is_local

# A callback to intercept published messages (e.g., bound simulator handler).
# Can be either synchronous or asynchronous: Callable[[RoborockMessage], Awaitable[Any]].
# By default, routes to self._default_publish_handler to handle the response_queue.
self.publish_handler: Callable[[RoborockMessage], Awaitable[Any]] | None = self._default_publish_handler

# Set this to an exception instance to make the next publish raise it.
# This is a convenience shortcut; callers can also replace
# ``publish.side_effect`` directly for more control.
Expand Down Expand Up @@ -96,14 +104,16 @@ def is_local_connected(self) -> bool:
async def _publish(self, message: RoborockMessage) -> None:
"""Default publish implementation.

Records the message in ``published_messages`` and, if
``response_queue`` is non-empty, pops the first response and
delivers it to all current subscribers (simulating a
request/response round-trip).
Records the message in ``published_messages`` and executes ``publish_handler``.
"""
self.published_messages.append(message)
if self.publish_side_effect:
raise self.publish_side_effect
if self.publish_handler:
await self.publish_handler(message)

async def _default_publish_handler(self, message: RoborockMessage) -> None:
"""Default handler that pops canned responses from response_queue."""
if self.response_queue:
response = self.response_queue.pop(0)
self.notify_subscribers(response)
Expand All @@ -124,3 +134,15 @@ def notify_subscribers(self, message: RoborockMessage) -> None:
"""
for subscriber in list(self.subscribers):
subscriber(message)

def inject_error(self, exception: Exception) -> None:
"""Inject a transient failure into all channel operations (publish, subscribe, connect)."""
self.publish.side_effect = exception
self.subscribe.side_effect = exception
self.connect.side_effect = exception

def clear_error(self) -> None:
"""Restore default success behaviors on all channel operations."""
self.publish.side_effect = self._publish
self.subscribe.side_effect = self._subscribe
self.connect.side_effect = self._connect
45 changes: 44 additions & 1 deletion roborock/testing/cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"""

import contextlib
import datetime
import re
from typing import Any
from unittest.mock import AsyncMock, patch
Expand Down Expand Up @@ -173,6 +174,44 @@ def get_homes_callback(url, **kwargs):
callback=get_homes_callback,
)

def get_default_home_data(self) -> HomeData:
"""Construct the default HomeData using simulated devices registered in the cloud."""
devices = [d.device_info for d in self.cloud.simulated_devices.values()]
products = [d.product for d in self.cloud.simulated_devices.values()]
return HomeData(
id=self.cloud.home_id,
name=self.cloud.home_name,
devices=devices,
products=products,
received_devices=[],
rooms=[],
)

def set_homes_response(
self,
home_data: HomeData | None = None,
status: int = 200,
) -> None:
"""Easily set the faked response payload for the homes endpoint using a HomeData dataclass.

If home_data is None, it defaults to the active get_default_home_data() state.
"""
self.homes_status = status
if status != 200:
self.homes_payload_override = None
return

if home_data is None:
home_data = self.get_default_home_data()

self.homes_payload_override = {
"api": None,
"code": 200,
"result": home_data.as_dict(),
"status": "ok",
"success": True,
}


class FakeRoborockCloud:
"""A central state object representing the Roborock Cloud environment under test."""
Expand Down Expand Up @@ -234,7 +273,9 @@ def mock_create_mqtt_channel(user_data, mqtt_params, mqtt_session, device):
with aioresponses() as mocked:
self.web_api.mock_requests(mocked)

# Patch Channel factories and rate limiters
# Mock sleep logic to speed up tests.
sleep_time = datetime.timedelta(seconds=0.001)
# Patch Channel factories, rate limiters, and backoff sleep intervals
with (
patch(
"roborock.web_api.RoborockApiClient._login_limiter.try_acquire_async",
Expand All @@ -243,5 +284,7 @@ def mock_create_mqtt_channel(user_data, mqtt_params, mqtt_session, device):
patch("roborock.web_api.RoborockApiClient._home_data_limiter.try_acquire", return_value=True),
patch("roborock.devices.device_manager.create_v1_channel", side_effect=mock_create_v1_channel),
patch("roborock.devices.device_manager.create_mqtt_channel", side_effect=mock_create_mqtt_channel),
patch("roborock.devices.device.MIN_BACKOFF_INTERVAL", sleep_time),
patch("roborock.devices.device.MAX_BACKOFF_INTERVAL", sleep_time),
):
yield
10 changes: 2 additions & 8 deletions roborock/testing/simulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,26 +73,20 @@ def __init__(

# MQTT channel is always present — all protocols use it.
self.mqtt_channel = FakeChannel(is_local=False)
self.mqtt_channel.publish.side_effect = self._handle_mqtt_publish
self.mqtt_channel.publish_handler = self._handle_mqtt_publish

# Local channel is only used by V1 devices. A01/B01 (MQTT-only)
# simulators should pass has_local_channel=False.
self.local_channel: FakeChannel | None = None
if has_local_channel:
self.local_channel = FakeChannel(is_local=True)
self.local_channel.publish.side_effect = self._handle_local_publish
self.local_channel.publish_handler = self._handle_local_publish

async def _handle_local_publish(self, message: RoborockMessage) -> None:
assert self.local_channel is not None
self.local_channel.published_messages.append(message)
if self.local_channel.publish_side_effect:
raise self.local_channel.publish_side_effect
await self._handle_publish(message, self.local_channel)

async def _handle_mqtt_publish(self, message: RoborockMessage) -> None:
self.mqtt_channel.published_messages.append(message)
if self.mqtt_channel.publish_side_effect:
raise self.mqtt_channel.publish_side_effect
await self._handle_publish(message, self.mqtt_channel)

async def _handle_publish(self, message: RoborockMessage, channel: FakeChannel) -> None:
Expand Down
16 changes: 2 additions & 14 deletions tests/devices/__snapshots__/test_device_manager.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@
'debugMode': 0,
'dndEnabled': 0,
'dockErrorStatus': 0,
'dockType': 3,
'dockType': 7,
'dss': 169,
'dustCollectionStatus': 0,
'errorCode': 0,
Expand All @@ -409,7 +409,7 @@
'washPhase': 0,
'washReady': 0,
'waterBoxCarriageStatus': 1,
'waterBoxMode': 203,
'waterBoxMode': 200,
'waterBoxStatus': 1,
'waterShortageStatus': 0,
}),
Expand Down Expand Up @@ -595,18 +595,6 @@
'receivedDevices': list([
]),
'rooms': list([
dict({
'id': 2362048,
'name': '**REDACTED**',
}),
dict({
'id': 2362044,
'name': '**REDACTED**',
}),
dict({
'id': 2362041,
'name': '**REDACTED**',
}),
]),
}),
})
Expand Down
Loading
Loading