From a8a079ebd699c8fd3a24b736df349069cae79e6d Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 16:13:40 +0000 Subject: [PATCH 1/2] Fix V1Channel reconnection by falling back to cache when MQTT fails When the device disconnects from the internet, the MQTT connection may also be unavailable. Previously, `V1Channel` would force a fresh network info lookup via MQTT during reconnection attempts if the cache was stale or on the first retry, leading to repeated failures. This change adds a fallback mechanism: if the MQTT request for network info fails, we check if we have cached network info. If so, we use it (with a warning) to attempt a local connection. This allows the device to reconnect locally even if the cloud is temporarily unreachable. Added `tests/devices/test_v1_channel_reconnect.py` to verify this behavior. --- roborock/devices/v1_channel.py | 10 ++- tests/devices/test_v1_channel_reconnect.py | 86 ++++++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 tests/devices/test_v1_channel_reconnect.py diff --git a/roborock/devices/v1_channel.py b/roborock/devices/v1_channel.py index f39d6521..dba30b9e 100644 --- a/roborock/devices/v1_channel.py +++ b/roborock/devices/v1_channel.py @@ -181,14 +181,20 @@ async def _get_networking_info(self, *, use_cache: bool = True) -> NetworkInfo: This is a cloud only command used to get the local device's IP address. """ cache_data = await self._cache.get() - if use_cache and cache_data.network_info and (network_info := cache_data.network_info.get(self._device_uid)): + cached_info = cache_data.network_info.get(self._device_uid) if cache_data.network_info else None + if use_cache and cached_info: _LOGGER.debug("Using cached network info for device %s", self._device_uid) - return network_info + return cached_info try: network_info = await self._mqtt_rpc_channel.send_command( RoborockCommand.GET_NETWORK_INFO, response_type=NetworkInfo ) except RoborockException as e: + if cached_info: + _LOGGER.warning( + "Failed to refresh network info for device %s, falling back to cache: %s", self._device_uid, e + ) + return cached_info raise RoborockException(f"Network info failed for device {self._device_uid}") from e _LOGGER.debug("Network info for device %s: %s", self._device_uid, network_info) self._last_network_info_refresh = datetime.datetime.now(datetime.UTC) diff --git a/tests/devices/test_v1_channel_reconnect.py b/tests/devices/test_v1_channel_reconnect.py new file mode 100644 index 00000000..d6388ab0 --- /dev/null +++ b/tests/devices/test_v1_channel_reconnect.py @@ -0,0 +1,86 @@ + +import asyncio +import datetime +import logging +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from roborock.data import NetworkInfo +from roborock.devices.cache import CacheData, InMemoryCache +from roborock.devices.local_channel import LocalSession +from roborock.devices.v1_channel import V1Channel, NETWORK_INFO_REFRESH_INTERVAL +from roborock.exceptions import RoborockException +from roborock.protocols.v1_protocol import SecurityData + +from ..conftest import FakeChannel + +TEST_DEVICE_UID = "abc123" +TEST_SECURITY_DATA = SecurityData(endpoint="test_endpoint", nonce=b"test_nonce") +TEST_IP = "192.168.1.100" + +@pytest.fixture(name="mock_mqtt_channel") +async def setup_mock_mqtt_channel() -> FakeChannel: + """Mock MQTT channel for testing.""" + channel = FakeChannel() + await channel.connect() + # Mock send_command to fail + channel.send_command = AsyncMock(side_effect=RoborockException("MQTT Failed")) + return channel + +@pytest.fixture(name="mock_local_channel") +async def setup_mock_local_channel() -> FakeChannel: + """Mock Local channel for testing.""" + channel = FakeChannel() + return channel + +@pytest.fixture(name="mock_local_session") +def setup_mock_local_session(mock_local_channel: Mock) -> Mock: + """Mock Local session factory for testing.""" + mock_session = Mock(spec=LocalSession) + mock_session.return_value = mock_local_channel + return mock_session + +@pytest.mark.asyncio +async def test_v1_channel_reconnect_with_stale_cache_and_mqtt_down( + mock_mqtt_channel: FakeChannel, + mock_local_session: Mock, + mock_local_channel: FakeChannel, +): + """ + Test that when cache is stale (> 12h) and MQTT is down, the system + falls back to the stale cache instead of failing indefinitely. + """ + # 1. Setup stale cache + cache = InMemoryCache() + cache_data = CacheData() + stale_network_info = NetworkInfo(ip=TEST_IP, ssid="ssid", bssid="bssid") + cache_data.network_info[TEST_DEVICE_UID] = stale_network_info + await cache.set(cache_data) + + v1_channel = V1Channel( + device_uid=TEST_DEVICE_UID, + security_data=TEST_SECURITY_DATA, + mqtt_channel=mock_mqtt_channel, + local_session=mock_local_session, + cache=cache, + ) + + # Manually set the last refresh to be old to simulate stale cache + v1_channel._last_network_info_refresh = datetime.datetime.now(datetime.UTC) - (NETWORK_INFO_REFRESH_INTERVAL + datetime.timedelta(hours=1)) + + # 2. Mock MQTT RPC channel to fail + # V1Channel creates _mqtt_rpc_channel in __init__. We need to mock its send_command. + v1_channel._mqtt_rpc_channel.send_command = AsyncMock(side_effect=RoborockException("MQTT Network Info Failed")) + + # 3. Attempt local connection. + # Because cache is stale, use_cache will be False. + # Because MQTT fails, it will trigger fallback to cache. + + # We call _local_connect(use_cache=False) which is what happens in the loop + # when _should_use_cache returns False (due to stale cache) + await v1_channel._local_connect(use_cache=False) + + # 4. Assert that we tried to connect to the local IP from the cache + mock_local_session.assert_called_once_with(TEST_IP) + mock_local_channel.connect.assert_called_once() From 4e5e85e95fa2863e24b3c88a53d51629bbb0c352 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 16:21:43 +0000 Subject: [PATCH 2/2] Fix linting in test_v1_channel_reconnect.py --- tests/devices/test_v1_channel_reconnect.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/devices/test_v1_channel_reconnect.py b/tests/devices/test_v1_channel_reconnect.py index d6388ab0..0065c625 100644 --- a/tests/devices/test_v1_channel_reconnect.py +++ b/tests/devices/test_v1_channel_reconnect.py @@ -1,15 +1,12 @@ - -import asyncio import datetime -import logging -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, Mock import pytest from roborock.data import NetworkInfo from roborock.devices.cache import CacheData, InMemoryCache from roborock.devices.local_channel import LocalSession -from roborock.devices.v1_channel import V1Channel, NETWORK_INFO_REFRESH_INTERVAL +from roborock.devices.v1_channel import NETWORK_INFO_REFRESH_INTERVAL, V1Channel from roborock.exceptions import RoborockException from roborock.protocols.v1_protocol import SecurityData @@ -19,6 +16,7 @@ TEST_SECURITY_DATA = SecurityData(endpoint="test_endpoint", nonce=b"test_nonce") TEST_IP = "192.168.1.100" + @pytest.fixture(name="mock_mqtt_channel") async def setup_mock_mqtt_channel() -> FakeChannel: """Mock MQTT channel for testing.""" @@ -28,12 +26,14 @@ async def setup_mock_mqtt_channel() -> FakeChannel: channel.send_command = AsyncMock(side_effect=RoborockException("MQTT Failed")) return channel + @pytest.fixture(name="mock_local_channel") async def setup_mock_local_channel() -> FakeChannel: """Mock Local channel for testing.""" channel = FakeChannel() return channel + @pytest.fixture(name="mock_local_session") def setup_mock_local_session(mock_local_channel: Mock) -> Mock: """Mock Local session factory for testing.""" @@ -41,6 +41,7 @@ def setup_mock_local_session(mock_local_channel: Mock) -> Mock: mock_session.return_value = mock_local_channel return mock_session + @pytest.mark.asyncio async def test_v1_channel_reconnect_with_stale_cache_and_mqtt_down( mock_mqtt_channel: FakeChannel, @@ -67,7 +68,9 @@ async def test_v1_channel_reconnect_with_stale_cache_and_mqtt_down( ) # Manually set the last refresh to be old to simulate stale cache - v1_channel._last_network_info_refresh = datetime.datetime.now(datetime.UTC) - (NETWORK_INFO_REFRESH_INTERVAL + datetime.timedelta(hours=1)) + # Break long line + last_refresh = datetime.datetime.now(datetime.UTC) - (NETWORK_INFO_REFRESH_INTERVAL + datetime.timedelta(hours=1)) + v1_channel._last_network_info_refresh = last_refresh # 2. Mock MQTT RPC channel to fail # V1Channel creates _mqtt_rpc_channel in __init__. We need to mock its send_command.