From c90b39627f755097f4640dc02b5419d3cc9b7658 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 7 Jul 2026 11:05:16 -0700 Subject: [PATCH 1/3] refactor: use stateful fakes and fixtures in integration tests --- roborock/testing/channel.py | 32 +- roborock/testing/cloud.py | 45 +- roborock/testing/simulator.py | 10 +- .../__snapshots__/test_device_manager.ambr | 16 +- tests/devices/test_device_manager.py | 447 +++++++++--------- 5 files changed, 286 insertions(+), 264 deletions(-) diff --git a/roborock/testing/channel.py b/roborock/testing/channel.py index 1550393bf..7ffccc2de 100644 --- a/roborock/testing/channel.py +++ b/roborock/testing/channel.py @@ -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 @@ -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 @@ -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. @@ -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) @@ -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 diff --git a/roborock/testing/cloud.py b/roborock/testing/cloud.py index 704e0f548..9fee2bb2f 100644 --- a/roborock/testing/cloud.py +++ b/roborock/testing/cloud.py @@ -6,6 +6,7 @@ """ import contextlib +import datetime import re from typing import Any from unittest.mock import AsyncMock, patch @@ -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.""" @@ -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", @@ -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 diff --git a/roborock/testing/simulator.py b/roborock/testing/simulator.py index caebc0009..6dd05240d 100644 --- a/roborock/testing/simulator.py +++ b/roborock/testing/simulator.py @@ -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: diff --git a/tests/devices/__snapshots__/test_device_manager.ambr b/tests/devices/__snapshots__/test_device_manager.ambr index 98017c92e..558e04e89 100644 --- a/tests/devices/__snapshots__/test_device_manager.ambr +++ b/tests/devices/__snapshots__/test_device_manager.ambr @@ -382,7 +382,7 @@ 'debugMode': 0, 'dndEnabled': 0, 'dockErrorStatus': 0, - 'dockType': 3, + 'dockType': 7, 'dss': 169, 'dustCollectionStatus': 0, 'errorCode': 0, @@ -409,7 +409,7 @@ 'washPhase': 0, 'washReady': 0, 'waterBoxCarriageStatus': 1, - 'waterBoxMode': 203, + 'waterBoxMode': 200, 'waterBoxStatus': 1, 'waterShortageStatus': 0, }), @@ -595,18 +595,6 @@ 'receivedDevices': list([ ]), 'rooms': list([ - dict({ - 'id': 2362048, - 'name': '**REDACTED**', - }), - dict({ - 'id': 2362044, - 'name': '**REDACTED**', - }), - dict({ - 'id': 2362041, - 'name': '**REDACTED**', - }), ]), }), }) diff --git a/tests/devices/test_device_manager.py b/tests/devices/test_device_manager.py index bd2ed1256..d5851935b 100644 --- a/tests/devices/test_device_manager.py +++ b/tests/devices/test_device_manager.py @@ -1,120 +1,64 @@ """Tests for the DeviceManager class.""" import asyncio -import datetime -from collections.abc import Generator, Iterator +from collections.abc import Generator +from dataclasses import replace from typing import Any -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import Mock, patch import pytest import syrupy from roborock.data import HomeData, UserData +from roborock.data.containers import HomeDataDevice, HomeDataProduct, RoborockCategory from roborock.devices.cache import InMemoryCache from roborock.devices.device import RoborockDevice from roborock.devices.device_manager import UserParams, create_device_manager, create_web_api_wrapper from roborock.exceptions import RoborockException, RoborockInvalidCredentials +from roborock.testing import FakeRoborockCloud, V1VacuumSimulator from tests import mock_data USER_DATA = UserData.from_dict(mock_data.USER_DATA) USER_PARAMS = UserParams(username="test_user", user_data=USER_DATA) +HOME_DATA = HomeData.from_dict(mock_data.HOME_DATA_RAW) NETWORK_INFO = mock_data.NETWORK_INFO -@pytest.fixture(autouse=True, name="mqtt_session") -def setup_mqtt_session() -> Generator[Mock, None, None]: - """Fixture to set up the MQTT session for the tests.""" - with patch("roborock.devices.device_manager.create_lazy_mqtt_session") as mock_create_session: - yield mock_create_session +@pytest.fixture(name="cloud") +def cloud_fixture() -> FakeRoborockCloud: + """Fixture to set up FakeRoborockCloud.""" + return FakeRoborockCloud(user_data=USER_DATA) -@pytest.fixture(autouse=True, name="mock_rpc_channel") -def rpc_channel_fixture() -> AsyncMock: - """Fixture to set up the channel for tests.""" - return AsyncMock() +@pytest.fixture(name="fake_device") +def fake_device_fixture(cloud: FakeRoborockCloud) -> V1VacuumSimulator: + """Fixture to set up a stateful V1VacuumSimulator registered to the cloud.""" + device = V1VacuumSimulator( + duid="abc123", + device_info=HOME_DATA.devices[0], + product=HOME_DATA.products[0], + ) + cloud.add_device(device) + return device -@pytest.fixture(autouse=True) -async def discover_features_fixture( - mock_rpc_channel: AsyncMock, -) -> None: - """Fixture to handle device feature discovery.""" - mock_rpc_channel.send_command.side_effect = [ - [mock_data.APP_GET_INIT_STATUS], - mock_data.STATUS, - ] - - -@pytest.fixture(autouse=True) -def channel_fixture(mock_rpc_channel: AsyncMock) -> Generator[Mock, None, None]: - """Fixture to set up the local session for the tests.""" - with patch("roborock.devices.device_manager.create_v1_channel") as mock_channel: - mock_unsub = Mock() - mock_channel.return_value.subscribe = AsyncMock() - mock_channel.return_value.subscribe.return_value = mock_unsub - mock_channel.return_value.rpc_channel = mock_rpc_channel - yield mock_channel - - -@pytest.fixture(autouse=True) -def mock_sleep() -> Generator[None, None, None]: - """Mock sleep logic to speed up tests.""" - sleep_time = datetime.timedelta(seconds=0.001) - with ( - patch("roborock.devices.device.MIN_BACKOFF_INTERVAL", sleep_time), - patch("roborock.devices.device.MAX_BACKOFF_INTERVAL", sleep_time), - ): +@pytest.fixture(name="patch_device_manager") +def patch_device_manager_fixture(cloud: FakeRoborockCloud) -> Generator[None, None, None]: + """Fixture to patch the device manager and HTTP client.""" + with cloud.patch_device_manager(): yield -@pytest.fixture(name="channel_exception") -def channel_failure_exception_fixture(mock_rpc_channel: AsyncMock) -> Exception: - """Fixture that provides the exception to be raised by the failing channel.""" - return RoborockException("Connection failed") - - -@pytest.fixture(name="channel_failure") -def channel_failure_fixture(mock_rpc_channel: AsyncMock, channel_exception: Exception) -> Generator[Mock, None, None]: - """Fixture that makes channel subscribe fail.""" - with patch("roborock.devices.device_manager.create_v1_channel") as mock_channel: - mock_channel.return_value.subscribe = AsyncMock(side_effect=channel_exception) - mock_channel.return_value.is_connected = False - mock_channel.return_value.rpc_channel = mock_rpc_channel - yield mock_channel - - -@pytest.fixture(name="home_data_no_devices") -def home_data_no_devices_fixture() -> Iterator[HomeData]: - """Mock home data API that returns no devices.""" - with patch("roborock.devices.device_manager.UserWebApiClient.get_home_data") as mock_home_data: - home_data = HomeData( - id=1, - name="Test Home", - devices=[], - products=[], - ) - mock_home_data.return_value = home_data - yield home_data - - -@pytest.fixture(name="home_data") -def home_data_fixture() -> Iterator[HomeData]: - """Mock home data API that returns devices.""" - with patch("roborock.devices.device_manager.UserWebApiClient.get_home_data") as mock_home_data: - home_data = HomeData.from_dict(mock_data.HOME_DATA_RAW) - mock_home_data.return_value = home_data - yield home_data - - -async def test_no_devices(home_data_no_devices: HomeData) -> None: +async def test_no_devices(cloud: FakeRoborockCloud, patch_device_manager: None) -> None: """Test the DeviceManager created with no devices returned from the API.""" - device_manager = await create_device_manager(USER_PARAMS) devices = await device_manager.get_devices() assert devices == [] -async def test_with_device(home_data: HomeData) -> None: +async def test_with_device( + cloud: FakeRoborockCloud, fake_device: V1VacuumSimulator, patch_device_manager: None +) -> None: """Test the DeviceManager created with devices returned from the API.""" device_manager = await create_device_manager(USER_PARAMS) devices = await device_manager.get_devices() @@ -130,7 +74,9 @@ async def test_with_device(home_data: HomeData) -> None: await device_manager.close() -async def test_get_non_existent_device(home_data: HomeData) -> None: +async def test_get_non_existent_device( + cloud: FakeRoborockCloud, fake_device: V1VacuumSimulator, patch_device_manager: None +) -> None: """Test getting a non-existent device.""" device_manager = await create_device_manager(USER_PARAMS) device = await device_manager.get_device("non_existent_duid") @@ -140,7 +86,6 @@ async def test_get_non_existent_device(home_data: HomeData) -> None: async def test_create_home_data_api_exception() -> None: """Test that exceptions from the home data API are propagated through the wrapper.""" - with patch("roborock.devices.device_manager.RoborockApiClient.get_home_data_v3") as mock_get_home_data: mock_get_home_data.side_effect = RoborockException("Test exception") user_params = UserParams(username="test_user", user_data=USER_DATA) @@ -164,7 +109,13 @@ async def test_device_manager_unauthorized_hook() -> None: @pytest.mark.parametrize(("prefer_cache", "expected_call_count"), [(True, 1), (False, 2)]) -async def test_cache_logic(prefer_cache: bool, expected_call_count: int) -> None: +async def test_cache_logic( + cloud: FakeRoborockCloud, + fake_device: V1VacuumSimulator, + patch_device_manager: None, + prefer_cache: bool, + expected_call_count: int, +) -> None: """Test that the cache logic works correctly.""" call_count = 0 @@ -173,15 +124,15 @@ async def mock_home_data_with_counter(*args, **kwargs) -> HomeData: call_count += 1 return HomeData.from_dict(mock_data.HOME_DATA_RAW) - # First call happens during create_device_manager initialization with patch( "roborock.devices.device_manager.RoborockApiClient.get_home_data_v3", side_effect=mock_home_data_with_counter, ): device_manager = await create_device_manager(USER_PARAMS, cache=InMemoryCache()) + # First call happens during create_device_manager initialization assert call_count == 1 - # Second call should use cache, not increment call_count + # Second call should use cache, not increment call_count if prefer_cache=True devices2 = await device_manager.discover_devices(prefer_cache=prefer_cache) assert call_count == expected_call_count assert len(devices2) == 1 @@ -193,9 +144,10 @@ async def mock_home_data_with_counter(*args, **kwargs) -> HomeData: await device_manager.close() -async def test_home_data_api_fails_with_cache_fallback() -> None: +async def test_home_data_api_fails_with_cache_fallback( + cloud: FakeRoborockCloud, fake_device: V1VacuumSimulator, patch_device_manager: None +) -> None: """Test that home data exceptions may still fall back to use the cache when available.""" - cache = InMemoryCache() cache_data = await cache.get() cache_data.home_data = HomeData.from_dict(mock_data.HOME_DATA_RAW) @@ -216,10 +168,13 @@ async def test_home_data_api_fails_with_cache_fallback() -> None: await device_manager.close() -async def test_ready_callback(home_data: HomeData) -> None: +async def test_ready_callback( + cloud: FakeRoborockCloud, fake_device: V1VacuumSimulator, patch_device_manager: None +) -> None: """Test that the ready callback is invoked when a device connects.""" ready_devices: list[RoborockDevice] = [] device_manager = await create_device_manager(USER_PARAMS, ready_callback=ready_devices.append) + await device_manager.get_devices() # Callback should be called for the discovered device assert len(ready_devices) == 1 @@ -236,41 +191,32 @@ async def test_ready_callback(home_data: HomeData) -> None: await device_manager.close() -@pytest.mark.parametrize( - ("channel_exception"), - [ - RoborockException("Connection failed"), - ], -) -async def test_start_connect_failure(home_data: HomeData, channel_failure: Mock, mock_sleep: Mock) -> None: +async def test_start_connect_failure( + cloud: FakeRoborockCloud, fake_device: V1VacuumSimulator, patch_device_manager: None +) -> None: """Test that start_connect retries when connection fails.""" + # Make connection fail initially + channel_exception = RoborockException("Connection failed") + fake_device.mqtt_channel.inject_error(channel_exception) + assert fake_device.local_channel is not None + fake_device.local_channel.inject_error(channel_exception) + ready_devices: list[RoborockDevice] = [] device_manager = await create_device_manager(USER_PARAMS, ready_callback=ready_devices.append) devices = await device_manager.get_devices() - # The device should attempt to connect in the background at least once - # by the time this function returns. - subscribe_mock = channel_failure.return_value.subscribe - assert subscribe_mock.call_count > 0 - # Device should exist but not be connected assert len(devices) == 1 assert not devices[0].is_connected assert not ready_devices - # Verify retry attempts - assert channel_failure.return_value.subscribe.call_count >= 1 - - # Reset the mock channel so that it succeeds on the next attempt - mock_unsub = Mock() - subscribe_mock = AsyncMock() - subscribe_mock.return_value = mock_unsub - channel_failure.return_value.subscribe = subscribe_mock - channel_failure.return_value.is_connected = True + # Clear error so connection succeeds on retry + fake_device.mqtt_channel.clear_error() + fake_device.local_channel.clear_error() - # Wait for the device to attempt to connect again + # Wait for the device to attempt to connect again and succeed attempts = 0 - while subscribe_mock.call_count < 1: + while not devices[0].is_connected: await asyncio.sleep(0.01) attempts += 1 assert attempts < 10, "Device did not connect after multiple attempts" @@ -280,10 +226,11 @@ async def test_start_connect_failure(home_data: HomeData, channel_failure: Mock, assert len(ready_devices) == 1 await device_manager.close() - assert mock_unsub.call_count == 1 -async def test_rediscover_devices(mock_rpc_channel: AsyncMock) -> None: +async def test_rediscover_devices( + cloud: FakeRoborockCloud, fake_device: V1VacuumSimulator, patch_device_manager: None +) -> None: """Test that we can discover devices multiple times and discover new devices.""" raw_devices: list[dict[str, Any]] = mock_data.HOME_DATA_RAW["devices"] assert len(raw_devices) > 0 @@ -291,8 +238,7 @@ async def test_rediscover_devices(mock_rpc_channel: AsyncMock) -> None: home_data_responses = [ HomeData.from_dict(mock_data.HOME_DATA_RAW), - # New device added on second call. We make a copy and updated fields to simulate - # a new device. + # New device added on second call. HomeData.from_dict( { **mock_data.HOME_DATA_RAW, @@ -310,19 +256,31 @@ async def test_rediscover_devices(mock_rpc_channel: AsyncMock) -> None: ), ] - mock_rpc_channel.send_command.side_effect = [ - [mock_data.APP_GET_INIT_STATUS], - mock_data.STATUS, - # Device #2 - [mock_data.APP_GET_INIT_STATUS], - mock_data.STATUS, - ] - async def mock_home_data_with_counter(*args, **kwargs) -> HomeData: nonlocal home_data_responses return home_data_responses.pop(0) - # First call happens during create_device_manager initialization + # The new device is also a V1 vacuum simulator + new_device_product = HomeDataProduct( + id="product-id-123", + name="New Device", + model="roborock.newmodel.v1", + category=RoborockCategory.VACUUM, + ) + fake_device2 = V1VacuumSimulator( + duid="new_device_duid", + device_info=HomeDataDevice( + duid="new_device_duid", + name="New Device", + local_key=mock_data.LOCAL_KEY, + product_id="product-id-123", + sn="fake_sn_new", + pv="1.0", + ), + product=new_device_product, + ) + cloud.add_device(fake_device2) + with patch( "roborock.devices.device_manager.RoborockApiClient.get_home_data_v3", side_effect=mock_home_data_with_counter, @@ -344,26 +302,31 @@ async def mock_home_data_with_counter(*args, **kwargs) -> HomeData: assert device_1.name == "Roborock S7 MaxV" new_device = await device_manager.get_device("new_device_duid") - assert new_device + assert new_device is not None assert new_device.name == "New Device" - # Ensure closing again works without error await device_manager.close() -@pytest.mark.parametrize( - ("channel_exception"), - [ - Exception("Unexpected error"), - ], -) -async def test_start_connect_unexpected_error(home_data: HomeData, channel_failure: Mock, mock_sleep: Mock) -> None: +async def test_start_connect_unexpected_error( + cloud: FakeRoborockCloud, fake_device: V1VacuumSimulator, patch_device_manager: None +) -> None: """Test that some unexpected errors from start_connect are propagated.""" + # Raise generic Exception + fake_device.mqtt_channel.inject_error(Exception("Unexpected error")) + assert fake_device.local_channel is not None + fake_device.local_channel.inject_error(Exception("Unexpected error")) + with pytest.raises(Exception, match="Unexpected error"): await create_device_manager(USER_PARAMS) -async def test_diagnostics_collection(home_data: HomeData, snapshot: syrupy.SnapshotAssertion) -> None: +async def test_diagnostics_collection( + cloud: FakeRoborockCloud, + fake_device: V1VacuumSimulator, + patch_device_manager: None, + snapshot: syrupy.SnapshotAssertion, +) -> None: """Test that diagnostics are collected correctly in the DeviceManager.""" device_manager = await create_device_manager(USER_PARAMS) devices = await device_manager.get_devices() @@ -381,107 +344,119 @@ async def test_diagnostics_collection(home_data: HomeData, snapshot: syrupy.Snap await device_manager.close() -async def test_unsupported_protocol_version() -> None: +async def test_unsupported_protocol_version(cloud: FakeRoborockCloud, patch_device_manager: None) -> None: """Test the DeviceManager with some supported and unsupported product IDs.""" - with patch("roborock.devices.device_manager.UserWebApiClient.get_home_data") as mock_home_data: - home_data = HomeData.from_dict( - { - "id": 1, - "name": "Test Home", - "devices": [ - { - "duid": "device-uid-1", - "name": "Device 1", - "pv": "1.0", - "productId": "product-id-1", - "localKey": mock_data.LOCAL_KEY, - }, - { - "duid": "device-uid-2", - "name": "Device 2", - "pv": "unknown-pv", # Fake new protocol version we've never seen - "productId": "product-id-2", - "localKey": mock_data.LOCAL_KEY, - }, - ], - "products": [ - { - "id": "product-id-1", - "name": "Roborock S7 MaxV", - "model": "roborock.vacuum.a27", - "category": "robot.vacuum.cleaner", - }, - { - "id": "product-id-2", - "name": "New Roborock Model", - "model": "roborock.vacuum.newmodel", - "category": "robot.vacuum.cleaner", - }, - ], - } - ) - mock_home_data.return_value = home_data + fake_device = V1VacuumSimulator( + duid="device-uid-1", + device_info=HomeDataDevice( + duid="device-uid-1", + name="Device 1", + local_key=mock_data.LOCAL_KEY, + product_id="product-id-1", + pv="1.0", + ), + product=HomeDataProduct( + id="product-id-1", + name="Roborock S7 MaxV", + model="roborock.vacuum.a27", + category=RoborockCategory.VACUUM, + ), + ) + cloud.add_device(fake_device) + + # Fetch default home data and modify it + home_data = cloud.web_api.get_default_home_data() + modified_home_data = replace( + home_data, + devices=list(home_data.devices) + + [ + HomeDataDevice( + duid="device-uid-2", + name="Device 2", + local_key=mock_data.LOCAL_KEY, + product_id="product-id-2", + pv="unknown-pv", + ), + ], + products=list(home_data.products) + + [ + HomeDataProduct( + id="product-id-2", + name="New Roborock Model", + model="roborock.vacuum.newmodel", + category=RoborockCategory.VACUUM, + ), + ], + ) + cloud.web_api.set_homes_response(modified_home_data) + + device_manager = await create_device_manager(USER_PARAMS) + devices = await device_manager.get_devices() + assert [device.duid for device in devices] == ["device-uid-1"] - device_manager = await create_device_manager(USER_PARAMS) - # Only the supported device should be created. The other device is ignored - devices = await device_manager.get_devices() - assert [device.duid for device in devices] == ["device-uid-1"] + diagnostics = device_manager.diagnostic_data() + diagnostics_data = diagnostics.get("diagnostics") + assert diagnostics_data + assert diagnostics_data.get("supported_devices") == {"1.0": 1} + assert diagnostics_data.get("unsupported_devices") == {"unknown-pv": 1} - # Verify diagnostics - diagnostics = device_manager.diagnostic_data() - diagnostics_data = diagnostics.get("diagnostics") - assert diagnostics_data - assert diagnostics_data.get("supported_devices") == {"1.0": 1} - assert diagnostics_data.get("unsupported_devices") == {"unknown-pv": 1} + await device_manager.close() -async def test_unsupported_v1_category() -> None: +async def test_unsupported_v1_category(cloud: FakeRoborockCloud, patch_device_manager: None) -> None: """Test that non-vacuum V1 devices are skipped as unsupported.""" - with patch("roborock.devices.device_manager.UserWebApiClient.get_home_data") as mock_home_data: - home_data = HomeData.from_dict( - { - "id": 1, - "name": "Test Home", - "devices": [ - { - "duid": "device-uid-1", - "name": "Device 1", - "pv": "1.0", - "productId": "product-id-1", - "localKey": mock_data.LOCAL_KEY, - }, - { - "duid": "device-uid-2", - "name": "Device 2", - "pv": "1.0", - "productId": "product-id-2", - "localKey": mock_data.LOCAL_KEY, - }, - ], - "products": [ - { - "id": "product-id-1", - "name": "Roborock S7 MaxV", - "model": "roborock.vacuum.a27", - "category": "robot.vacuum.cleaner", - }, - { - "id": "product-id-2", - "name": "Roborock RockNeo", - "model": "roborock.mower.q105", - "category": "roborock.mower", - }, - ], - } - ) - mock_home_data.return_value = home_data - - device_manager = await create_device_manager(USER_PARAMS) - devices = await device_manager.get_devices() - assert [device.duid for device in devices] == ["device-uid-1"] - - diagnostics = device_manager.diagnostic_data() - diagnostics_data = diagnostics.get("diagnostics") - assert diagnostics_data - assert diagnostics_data.get("supported_devices") == {"1.0": 1} - assert diagnostics_data.get("unsupported_devices") == {"1.0": 1} + fake_device = V1VacuumSimulator( + duid="device-uid-1", + device_info=HomeDataDevice( + duid="device-uid-1", + name="Device 1", + local_key=mock_data.LOCAL_KEY, + product_id="product-id-1", + pv="1.0", + ), + product=HomeDataProduct( + id="product-id-1", + name="Roborock S7 MaxV", + model="roborock.vacuum.a27", + category=RoborockCategory.VACUUM, + ), + ) + cloud.add_device(fake_device) + + # Fetch default home data and modify it + home_data = cloud.web_api.get_default_home_data() + modified_home_data = replace( + home_data, + devices=list(home_data.devices) + + [ + HomeDataDevice( + duid="device-uid-2", + name="Device 2", + local_key=mock_data.LOCAL_KEY, + product_id="product-id-2", + pv="1.0", + ), + ], + products=list(home_data.products) + + [ + HomeDataProduct( + id="product-id-2", + name="Roborock RockNeo", + model="roborock.mower.q105", + category=RoborockCategory.MOWER, + ), + ], + ) + cloud.web_api.set_homes_response(modified_home_data) + + device_manager = await create_device_manager(USER_PARAMS) + devices = await device_manager.get_devices() + assert [device.duid for device in devices] == ["device-uid-1"] + + diagnostics = device_manager.diagnostic_data() + diagnostics_data = diagnostics.get("diagnostics") + assert diagnostics_data + assert diagnostics_data.get("supported_devices") == {"1.0": 1} + assert diagnostics_data.get("unsupported_devices") == {"1.0": 1} + + await device_manager.close() From 7bbf593a2d1b972fcfabd913f536ba3bc33ece21 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 7 Jul 2026 14:16:44 -0700 Subject: [PATCH 2/3] feat: implement Q10VacuumSimulator and export it --- roborock/testing/__init__.py | 6 + roborock/testing/b01_q10_simulator.py | 213 ++++++++++++++++++++++++++ roborock/testing/cloud.py | 16 +- tests/testing/test_q10_simulator.py | 162 ++++++++++++++++++++ 4 files changed, 389 insertions(+), 8 deletions(-) create mode 100644 roborock/testing/b01_q10_simulator.py create mode 100644 tests/testing/test_q10_simulator.py diff --git a/roborock/testing/__init__.py b/roborock/testing/__init__.py index 4eed0b52b..12e86e562 100644 --- a/roborock/testing/__init__.py +++ b/roborock/testing/__init__.py @@ -63,6 +63,10 @@ async def test_start_vacuum_service(): ``` """ +from roborock.testing.b01_q10_simulator import ( + DEFAULT_Q10_STATUS, + Q10VacuumSimulator, +) from roborock.testing.channel import FakeChannel from roborock.testing.cloud import FakeRoborockCloud, FakeWebApiClient from roborock.testing.simulator import ( @@ -92,10 +96,12 @@ async def test_start_vacuum_service(): "DEFAULT_LOCAL_KEY", "DEFAULT_NETWORK_INFO", "DEFAULT_PRODUCT_ID", + "DEFAULT_Q10_STATUS", "DEFAULT_STATUS", "FakeChannel", "FakeRoborockCloud", "FakeWebApiClient", + "Q10VacuumSimulator", "RoborockDeviceSimulator", "V1VacuumSimulator", ] diff --git a/roborock/testing/b01_q10_simulator.py b/roborock/testing/b01_q10_simulator.py new file mode 100644 index 000000000..859619dc4 --- /dev/null +++ b/roborock/testing/b01_q10_simulator.py @@ -0,0 +1,213 @@ +"""Stateful simulator for Roborock Q10 (B01) devices.""" + +import copy +import json +import logging +from typing import Any + +from roborock.data import HomeDataDevice, HomeDataProduct, RoborockCategory +from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol +from roborock.testing.simulator import RoborockDeviceSimulator + +_LOGGER = logging.getLogger(__name__) + +B01_VERSION = b"B01" +DEFAULT_PRODUCT_ID = "product-id-q10" + +DEFAULT_Q10_STATUS = { + 101: { + 104: 0, + 105: False, + 109: "us", + 207: 0, + 25: 1, + 26: 74, + 29: 0, + 30: 0, + 31: 0, + 37: 1, + 40: 1, + 45: 0, + 47: 0, + 50: 0, + 51: True, + 53: False, + 6: 0, + 60: 1, + 67: 0, + 7: 0, + 76: 0, + 78: 0, + 79: {"timeZoneCity": "America/Los_Angeles", "timeZoneSec": -28800}, + 80: 0, + 81: {"ipAdress": "1.1.1.2", "mac": "99:AA:88:BB:77:CC", "signal": -50, "wifiName": "wifi-network-name"}, + 83: 1, + 86: 1, + 87: 100, + 88: 0, + 90: 0, + 92: {"disturb_dust_enable": 1, "disturb_light": 1, "disturb_resume_clean": 1, "disturb_voice": 1}, + 93: 1, + 96: 0, + }, + 121: 8, + 122: 100, + 123: 2, + 124: 1, + 125: 0, + 126: 0, + 127: 0, + 136: 1, + 137: 1, + 138: 0, + 139: 5, +} + + +class Q10VacuumSimulator(RoborockDeviceSimulator): + """Stateful firmware simulator for Roborock Q10 (B01 ss07) devices.""" + + def __init__( + self, + duid: str, + status: dict[int, Any] | None = None, + device_info: HomeDataDevice | None = None, + product: HomeDataProduct | None = None, + ): + product = product or HomeDataProduct( + id=DEFAULT_PRODUCT_ID, + name="Roborock Q10", + model="roborock.vacuum.ss07", + category=RoborockCategory.VACUUM, + ) + device_info = device_info or HomeDataDevice( + duid=duid, + name="Roborock Q10", + local_key="fake_localkey_16bytes", + product_id=product.id, + sn="fake_serial_number", + pv="B01", + ) + super().__init__(duid, device_info, product, has_local_channel=False) + self.status = copy.deepcopy(status or DEFAULT_Q10_STATUS) + + async def _handle_publish(self, message: RoborockMessage, channel: Any) -> None: + """Process incoming Q10 RPC command payload.""" + if not message.payload: + return + + try: + payload = json.loads(message.payload.decode()) + except (json.JSONDecodeError, UnicodeDecodeError) as ex: + _LOGGER.warning("Simulator failed to parse incoming B01 payload: %s", ex) + return + + datapoints = payload.get("dps", {}) + if not isinstance(datapoints, dict): + return + + updated_dps: dict[int, Any] = {} + + for code_str, params in datapoints.items(): + try: + code = int(code_str) + except ValueError: + continue + + # Command: REQUEST_DPS (102) -> trigger status push + if code == 102: + # REQUEST_DPS triggers a dump of all status properties + # (both root properties and nested properties under 101) + self.trigger_push_update() + continue + + # Command: START_CLEAN (201) + elif code == 201: + # 1 = sweep/mop, cmd inside dict = segment clean + self.status[121] = 5 # YXDeviceState.CLEANING + if isinstance(params, dict) and "cmd" in params: + self.status[138] = params.get("cmd", 0) # clean_task_type + else: + self.status[138] = 1 # CLEANING + updated_dps[121] = self.status[121] + updated_dps[138] = self.status[138] + + # Command: START_BACK (202) + elif code == 202: + # 5 = returning to charge + self.status[121] = 6 # YXDeviceState.RETURNING_HOME + self.status[139] = 5 # BACK_CHARGING + updated_dps[121] = self.status[121] + updated_dps[139] = self.status[139] + + # Command: START_DOCK_TASK (203) + elif code == 203: + # Empty dustbin + self.status[121] = 22 # EMPTYING_THE_BIN + updated_dps[121] = self.status[121] + + # Command: PAUSE (204) + elif code == 204: + self.status[121] = 10 # YXDeviceState.PAUSED + updated_dps[121] = self.status[121] + + # Command: RESUME (205) + elif code == 205: + self.status[121] = 5 # YXDeviceState.CLEANING + updated_dps[121] = self.status[121] + + # Command: STOP (206) + elif code == 206: + self.status[121] = 3 # YXDeviceState.IDLE + self.status[138] = 0 # YXDeviceCleanTask.UNKNOWN/IDLE + updated_dps[121] = self.status[121] + updated_dps[138] = self.status[138] + + # Command: FAN_LEVEL (123) + elif code == 123: + self.status[123] = params + updated_dps[123] = params + + # Command: WATER_LEVEL (124) + elif code == 124: + self.status[124] = params + updated_dps[124] = params + + # Command: CLEAN_MODE (137) + elif code == 137: + self.status[137] = params + updated_dps[137] = params + + # Custom settings properties routed to sub-traits (DND, child lock, volume, dust collections) + elif code in (26, 47, 25, 37, 50): + # These live in the nested dpCommon (101) dict + common_dict = self.status[101] + if isinstance(common_dict, dict): + common_dict[code] = params + # We return them nested under 101 + if 101 not in updated_dps: + updated_dps[101] = {} + updated_dps[101][code] = params + + if updated_dps: + # Send the changed datapoints back to the client + self.push_dps(updated_dps) + + def push_dps(self, dps_updates: dict[int, Any]) -> None: + """Push a set of status datapoint changes to the client.""" + payload = { + "dps": { + str(k): ({str(sub_k): sub_v for sub_k, sub_v in v.items()} if isinstance(v, dict) and k == 101 else v) + for k, v in dps_updates.items() + } + } + message = RoborockMessage( + protocol=RoborockMessageProtocol.RPC_RESPONSE, + version=B01_VERSION, + payload=json.dumps(payload).encode("utf-8"), + ) + self.mqtt_channel.notify_subscribers(message) + + def trigger_push_update(self) -> None: + """Send the full status dump to the client.""" + self.push_dps(self.status) diff --git a/roborock/testing/cloud.py b/roborock/testing/cloud.py index 9fee2bb2f..03b596b7e 100644 --- a/roborock/testing/cloud.py +++ b/roborock/testing/cloud.py @@ -242,11 +242,6 @@ def patch_device_manager(self): # Wrapper function for create_v1_channel def mock_create_v1_channel(user_data, mqtt_params, mqtt_session, device, device_cache): - if device.pv in ("A01", "B01"): - raise NotImplementedError( - f"Simulating protocol {device.pv} is not yet supported. " - "TODO: Implement stateful simulators for B01 (Q7/Q10) and A01 (Zeo/Dyad) devices." - ) server = self.simulated_devices.get(device.duid) if server is not None: if not isinstance(server, V1VacuumSimulator): @@ -255,18 +250,23 @@ def mock_create_v1_channel(user_data, mqtt_params, mqtt_session, device, device_ f"simulator, but create_v1_channel requires a V1VacuumSimulator." ) return server.v1_channel + if device.pv in ("A01", "B01"): + raise NotImplementedError( + f"Simulating protocol {device.pv} is not yet supported. " + "TODO: Implement stateful simulators for B01 (Q7/Q10) and A01 (Zeo/Dyad) devices." + ) return original_create_v1_channel(user_data, mqtt_params, mqtt_session, device, device_cache) # Wrapper function for create_mqtt_channel def mock_create_mqtt_channel(user_data, mqtt_params, mqtt_session, device): + server = self.simulated_devices.get(device.duid) + if server: + return server.mqtt_channel if device.pv in ("A01", "B01"): raise NotImplementedError( f"Simulating protocol {device.pv} is not yet supported. " "TODO: Implement stateful simulators for B01 (Q7/Q10) and A01 (Zeo/Dyad) devices." ) - server = self.simulated_devices.get(device.duid) - if server: - return server.mqtt_channel return original_create_mqtt_channel(user_data, mqtt_params, mqtt_session, device) # Route Web requests using the dynamic FakeWebApiClient diff --git a/tests/testing/test_q10_simulator.py b/tests/testing/test_q10_simulator.py new file mode 100644 index 000000000..ed6e84168 --- /dev/null +++ b/tests/testing/test_q10_simulator.py @@ -0,0 +1,162 @@ +"""Unit tests for Q10VacuumSimulator.""" + +import json +from typing import Any + +import pytest + +from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol +from roborock.testing import DEFAULT_Q10_STATUS, Q10VacuumSimulator + +B01_VERSION = b"B01" + + +@pytest.fixture(name="q10_sim") +def q10_sim_fixture() -> Q10VacuumSimulator: + """Fixture to create a Q10VacuumSimulator instance.""" + return Q10VacuumSimulator(duid="test_q10_duid") + + +def test_default_status(q10_sim: Q10VacuumSimulator) -> None: + """Test that the simulator initializes with default Q10 status.""" + assert q10_sim.status == DEFAULT_Q10_STATUS + assert q10_sim.status[121] == 8 # Charging + assert q10_sim.status[122] == 100 # Battery + assert isinstance(q10_sim.status[101], dict) + assert q10_sim.status[101][26] == 74 # Volume + + +async def test_request_dps(q10_sim: Q10VacuumSimulator) -> None: + """Test that the simulator responds to REQUEST_DPS with a full status push.""" + notified_messages = [] + + def mock_notify(message: RoborockMessage) -> None: + notified_messages.append(message) + + await q10_sim.mqtt_channel.subscribe(mock_notify) + + # Send REQUEST_DPS command (102) + req_payload: dict[str, Any] = {"dps": {"102": {}}} + msg = RoborockMessage( + protocol=RoborockMessageProtocol.RPC_REQUEST, + version=B01_VERSION, + payload=json.dumps(req_payload).encode("utf-8"), + ) + + await q10_sim._handle_publish(msg, q10_sim.mqtt_channel) + + assert len(notified_messages) == 1 + resp_msg = notified_messages[0] + assert resp_msg.protocol == RoborockMessageProtocol.RPC_RESPONSE + assert resp_msg.version == B01_VERSION + assert resp_msg.payload is not None + + resp_payload = json.loads(resp_msg.payload.decode()) + assert "dps" in resp_payload + # The response is keyed by strings representing DP integer codes + assert resp_payload["dps"]["121"] == 8 + assert resp_payload["dps"]["122"] == 100 + # Nested dictionary 101 must also have string keys + assert resp_payload["dps"]["101"]["26"] == 74 + + +async def test_clean_pause_resume_stop(q10_sim: Q10VacuumSimulator) -> None: + """Test clean, pause, resume, and stop command sequences on Q10 simulator.""" + notified_messages = [] + + def mock_notify(message: RoborockMessage) -> None: + notified_messages.append(message) + + await q10_sim.mqtt_channel.subscribe(mock_notify) + + # 1. Start Clean + req_payload: dict[str, Any] = {"dps": {"201": 1}} + msg = RoborockMessage( + protocol=RoborockMessageProtocol.RPC_REQUEST, + version=B01_VERSION, + payload=json.dumps(req_payload).encode("utf-8"), + ) + await q10_sim._handle_publish(msg, q10_sim.mqtt_channel) + assert q10_sim.status[121] == 5 # Cleaning + assert q10_sim.status[138] == 1 # Standard Clean + last_msg = notified_messages[-1] + assert last_msg.payload is not None + assert json.loads(last_msg.payload.decode())["dps"] == {"121": 5, "138": 1} + + # 2. Pause + req_payload = {"dps": {"204": 0}} + msg = RoborockMessage( + protocol=RoborockMessageProtocol.RPC_REQUEST, + version=B01_VERSION, + payload=json.dumps(req_payload).encode("utf-8"), + ) + await q10_sim._handle_publish(msg, q10_sim.mqtt_channel) + assert q10_sim.status[121] == 10 # Paused + last_msg = notified_messages[-1] + assert last_msg.payload is not None + assert json.loads(last_msg.payload.decode())["dps"] == {"121": 10} + + # 3. Resume + req_payload = {"dps": {"205": 0}} + msg = RoborockMessage( + protocol=RoborockMessageProtocol.RPC_REQUEST, + version=B01_VERSION, + payload=json.dumps(req_payload).encode("utf-8"), + ) + await q10_sim._handle_publish(msg, q10_sim.mqtt_channel) + assert q10_sim.status[121] == 5 # Cleaning + last_msg = notified_messages[-1] + assert last_msg.payload is not None + assert json.loads(last_msg.payload.decode())["dps"] == {"121": 5} + + # 4. Stop + req_payload = {"dps": {"206": 0}} + msg = RoborockMessage( + protocol=RoborockMessageProtocol.RPC_REQUEST, + version=B01_VERSION, + payload=json.dumps(req_payload).encode("utf-8"), + ) + await q10_sim._handle_publish(msg, q10_sim.mqtt_channel) + assert q10_sim.status[121] == 3 # Idle + assert q10_sim.status[138] == 0 # Task type idle + last_msg = notified_messages[-1] + assert last_msg.payload is not None + assert json.loads(last_msg.payload.decode())["dps"] == {"121": 3, "138": 0} + + +async def test_setters(q10_sim: Q10VacuumSimulator) -> None: + """Test setting fan speed, water level, and sound volume.""" + notified_messages = [] + + def mock_notify(message: RoborockMessage) -> None: + notified_messages.append(message) + + await q10_sim.mqtt_channel.subscribe(mock_notify) + + # Set Fan level (123) to TURBO (3) + req_payload: dict[str, Any] = {"dps": {"123": 3}} + msg = RoborockMessage( + protocol=RoborockMessageProtocol.RPC_REQUEST, + version=B01_VERSION, + payload=json.dumps(req_payload).encode("utf-8"), + ) + await q10_sim._handle_publish(msg, q10_sim.mqtt_channel) + assert q10_sim.status[123] == 3 + last_msg = notified_messages[-1] + assert last_msg.payload is not None + assert json.loads(last_msg.payload.decode())["dps"] == {"123": 3} + + # Set Volume (26) to 50 + req_payload = {"dps": {"26": 50}} + msg = RoborockMessage( + protocol=RoborockMessageProtocol.RPC_REQUEST, + version=B01_VERSION, + payload=json.dumps(req_payload).encode("utf-8"), + ) + await q10_sim._handle_publish(msg, q10_sim.mqtt_channel) + assert isinstance(q10_sim.status[101], dict) + assert q10_sim.status[101][26] == 50 + # The output payload should map nested 101 dict keys to string + last_msg = notified_messages[-1] + assert last_msg.payload is not None + assert json.loads(last_msg.payload.decode())["dps"] == {"101": {"26": 50}} From fb6e1aeda793ecd37bbf32a2d0acad511eb122dd Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 7 Jul 2026 14:20:00 -0700 Subject: [PATCH 3/3] feat: add Q10 integration test and support stream streaming on FakeChannel --- roborock/testing/channel.py | 19 ++++++- roborock/testing/cloud.py | 11 +++- tests/devices/test_device_manager.py | 79 +++++++++++++++++++++++++++- 3 files changed, 104 insertions(+), 5 deletions(-) diff --git a/roborock/testing/channel.py b/roborock/testing/channel.py index 7ffccc2de..022d449bd 100644 --- a/roborock/testing/channel.py +++ b/roborock/testing/channel.py @@ -5,7 +5,8 @@ in-memory replacement for `MqttChannel` and `LocalChannel` during testing. """ -from collections.abc import Awaitable, Callable +import asyncio +from collections.abc import AsyncGenerator, Awaitable, Callable from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -53,7 +54,7 @@ def __init__(self, is_local: bool = 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]]. + # Must be 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 @@ -146,3 +147,17 @@ def clear_error(self) -> None: self.publish.side_effect = self._publish self.subscribe.side_effect = self._subscribe self.connect.side_effect = self._connect + + async def subscribe_stream(self) -> AsyncGenerator[RoborockMessage, None]: + """Stream messages received via this channel.""" + queue: asyncio.Queue[RoborockMessage] = asyncio.Queue() + + def callback(message: RoborockMessage) -> None: + queue.put_nowait(message) + + unsub = await self.subscribe(callback) + try: + while True: + yield await queue.get() + finally: + unsub() diff --git a/roborock/testing/cloud.py b/roborock/testing/cloud.py index 03b596b7e..61a0c5435 100644 --- a/roborock/testing/cloud.py +++ b/roborock/testing/cloud.py @@ -16,6 +16,7 @@ from roborock.data import HomeData, Reference, RRiot, UserData from roborock.devices.rpc.v1_channel import create_v1_channel as original_create_v1_channel from roborock.devices.transport.mqtt_channel import create_mqtt_channel as original_create_mqtt_channel +from roborock.testing.b01_q10_simulator import Q10VacuumSimulator from roborock.testing.simulator import RoborockDeviceSimulator from roborock.testing.v1_simulator import V1VacuumSimulator @@ -253,7 +254,7 @@ def mock_create_v1_channel(user_data, mqtt_params, mqtt_session, device, device_ if device.pv in ("A01", "B01"): raise NotImplementedError( f"Simulating protocol {device.pv} is not yet supported. " - "TODO: Implement stateful simulators for B01 (Q7/Q10) and A01 (Zeo/Dyad) devices." + "TODO: Implement stateful simulators for B01 (Q7) and A01 (Zeo/Dyad) devices." ) return original_create_v1_channel(user_data, mqtt_params, mqtt_session, device, device_cache) @@ -261,11 +262,17 @@ def mock_create_v1_channel(user_data, mqtt_params, mqtt_session, device, device_ def mock_create_mqtt_channel(user_data, mqtt_params, mqtt_session, device): server = self.simulated_devices.get(device.duid) if server: + if device.pv == "B01" and not isinstance(server, Q10VacuumSimulator): + raise NotImplementedError( + f"Simulating protocol {device.pv} is not yet supported. " + "Only Q10VacuumSimulator is supported for B01 protocols currently." + ) + server.mqtt_channel._is_connected = True return server.mqtt_channel if device.pv in ("A01", "B01"): raise NotImplementedError( f"Simulating protocol {device.pv} is not yet supported. " - "TODO: Implement stateful simulators for B01 (Q7/Q10) and A01 (Zeo/Dyad) devices." + "TODO: Implement stateful simulators for B01 (Q7) and A01 (Zeo/Dyad) devices." ) return original_create_mqtt_channel(user_data, mqtt_params, mqtt_session, device) diff --git a/tests/devices/test_device_manager.py b/tests/devices/test_device_manager.py index d5851935b..734c20657 100644 --- a/tests/devices/test_device_manager.py +++ b/tests/devices/test_device_manager.py @@ -15,7 +15,7 @@ from roborock.devices.device import RoborockDevice from roborock.devices.device_manager import UserParams, create_device_manager, create_web_api_wrapper from roborock.exceptions import RoborockException, RoborockInvalidCredentials -from roborock.testing import FakeRoborockCloud, V1VacuumSimulator +from roborock.testing import FakeRoborockCloud, Q10VacuumSimulator, V1VacuumSimulator from tests import mock_data USER_DATA = UserData.from_dict(mock_data.USER_DATA) @@ -56,6 +56,83 @@ async def test_no_devices(cloud: FakeRoborockCloud, patch_device_manager: None) assert devices == [] +async def test_with_q10_device(cloud: FakeRoborockCloud, patch_device_manager: None) -> None: + """Test DeviceManager with a Q10 device simulator registered.""" + product = HomeDataProduct( + id="product-id-q10", + name="Roborock Q10", + model="roborock.vacuum.ss07", + category=RoborockCategory.VACUUM, + ) + device_info = HomeDataDevice( + duid="q10_duid", + name="My Q10", + local_key="key123key123key1", + product_id=product.id, + sn="q10_serial", + pv="B01", + ) + + home_data = cloud.web_api.get_default_home_data() + home_data.devices.append(device_info) + home_data.products.append(product) + cloud.web_api.set_homes_response(home_data) + + q10_sim = Q10VacuumSimulator( + duid="q10_duid", + device_info=device_info, + product=product, + ) + cloud.add_device(q10_sim) + + device_manager = await create_device_manager(USER_PARAMS) + devices = await device_manager.get_devices() + + # The setup includes fake_device (V1) by default because of the fake_device fixture + # which is not requested here (we only request cloud and patch_device_manager), + # but the mock EAPI returns the full home_data layout containing our Q10 device. + assert len(devices) == 1 + + q10_device = await device_manager.get_device("q10_duid") + assert q10_device is not None + assert q10_device.name == "My Q10" + assert q10_device.product.model == "roborock.vacuum.ss07" + + # Wait for background connect to establish + for _ in range(20): + if q10_device.is_connected: + break + await asyncio.sleep(0.05) + assert q10_device.is_connected + + assert q10_device.b01_q10_properties is not None + assert q10_device.b01_q10_properties.status.status is None + + await q10_device.b01_q10_properties.refresh() + + for _ in range(20): + if q10_device.b01_q10_properties.status.battery == 100: + break + await asyncio.sleep(0.05) + + assert q10_device.b01_q10_properties.status.battery == 100 + from roborock.data.b01_q10.b01_q10_code_mappings import YXDeviceState + + assert q10_device.b01_q10_properties.status.status == YXDeviceState.CHARGING + + await q10_device.b01_q10_properties.vacuum.start_clean() + + for _ in range(20): + if q10_device.b01_q10_properties.status.status == YXDeviceState.CLEANING: + break + await asyncio.sleep(0.05) + + assert q10_device.b01_q10_properties.status.status == YXDeviceState.CLEANING + assert q10_sim.status[121] == 5 + + await device_manager.close() + + async def test_with_device( cloud: FakeRoborockCloud, fake_device: V1VacuumSimulator, patch_device_manager: None ) -> None: