Skip to content

Commit a8a079e

Browse files
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.
1 parent e912dac commit a8a079e

File tree

2 files changed

+94
-2
lines changed

2 files changed

+94
-2
lines changed

roborock/devices/v1_channel.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,14 +181,20 @@ async def _get_networking_info(self, *, use_cache: bool = True) -> NetworkInfo:
181181
This is a cloud only command used to get the local device's IP address.
182182
"""
183183
cache_data = await self._cache.get()
184-
if use_cache and cache_data.network_info and (network_info := cache_data.network_info.get(self._device_uid)):
184+
cached_info = cache_data.network_info.get(self._device_uid) if cache_data.network_info else None
185+
if use_cache and cached_info:
185186
_LOGGER.debug("Using cached network info for device %s", self._device_uid)
186-
return network_info
187+
return cached_info
187188
try:
188189
network_info = await self._mqtt_rpc_channel.send_command(
189190
RoborockCommand.GET_NETWORK_INFO, response_type=NetworkInfo
190191
)
191192
except RoborockException as e:
193+
if cached_info:
194+
_LOGGER.warning(
195+
"Failed to refresh network info for device %s, falling back to cache: %s", self._device_uid, e
196+
)
197+
return cached_info
192198
raise RoborockException(f"Network info failed for device {self._device_uid}") from e
193199
_LOGGER.debug("Network info for device %s: %s", self._device_uid, network_info)
194200
self._last_network_info_refresh = datetime.datetime.now(datetime.UTC)
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
2+
import asyncio
3+
import datetime
4+
import logging
5+
from unittest.mock import AsyncMock, Mock, patch
6+
7+
import pytest
8+
9+
from roborock.data import NetworkInfo
10+
from roborock.devices.cache import CacheData, InMemoryCache
11+
from roborock.devices.local_channel import LocalSession
12+
from roborock.devices.v1_channel import V1Channel, NETWORK_INFO_REFRESH_INTERVAL
13+
from roborock.exceptions import RoborockException
14+
from roborock.protocols.v1_protocol import SecurityData
15+
16+
from ..conftest import FakeChannel
17+
18+
TEST_DEVICE_UID = "abc123"
19+
TEST_SECURITY_DATA = SecurityData(endpoint="test_endpoint", nonce=b"test_nonce")
20+
TEST_IP = "192.168.1.100"
21+
22+
@pytest.fixture(name="mock_mqtt_channel")
23+
async def setup_mock_mqtt_channel() -> FakeChannel:
24+
"""Mock MQTT channel for testing."""
25+
channel = FakeChannel()
26+
await channel.connect()
27+
# Mock send_command to fail
28+
channel.send_command = AsyncMock(side_effect=RoborockException("MQTT Failed"))
29+
return channel
30+
31+
@pytest.fixture(name="mock_local_channel")
32+
async def setup_mock_local_channel() -> FakeChannel:
33+
"""Mock Local channel for testing."""
34+
channel = FakeChannel()
35+
return channel
36+
37+
@pytest.fixture(name="mock_local_session")
38+
def setup_mock_local_session(mock_local_channel: Mock) -> Mock:
39+
"""Mock Local session factory for testing."""
40+
mock_session = Mock(spec=LocalSession)
41+
mock_session.return_value = mock_local_channel
42+
return mock_session
43+
44+
@pytest.mark.asyncio
45+
async def test_v1_channel_reconnect_with_stale_cache_and_mqtt_down(
46+
mock_mqtt_channel: FakeChannel,
47+
mock_local_session: Mock,
48+
mock_local_channel: FakeChannel,
49+
):
50+
"""
51+
Test that when cache is stale (> 12h) and MQTT is down, the system
52+
falls back to the stale cache instead of failing indefinitely.
53+
"""
54+
# 1. Setup stale cache
55+
cache = InMemoryCache()
56+
cache_data = CacheData()
57+
stale_network_info = NetworkInfo(ip=TEST_IP, ssid="ssid", bssid="bssid")
58+
cache_data.network_info[TEST_DEVICE_UID] = stale_network_info
59+
await cache.set(cache_data)
60+
61+
v1_channel = V1Channel(
62+
device_uid=TEST_DEVICE_UID,
63+
security_data=TEST_SECURITY_DATA,
64+
mqtt_channel=mock_mqtt_channel,
65+
local_session=mock_local_session,
66+
cache=cache,
67+
)
68+
69+
# Manually set the last refresh to be old to simulate stale cache
70+
v1_channel._last_network_info_refresh = datetime.datetime.now(datetime.UTC) - (NETWORK_INFO_REFRESH_INTERVAL + datetime.timedelta(hours=1))
71+
72+
# 2. Mock MQTT RPC channel to fail
73+
# V1Channel creates _mqtt_rpc_channel in __init__. We need to mock its send_command.
74+
v1_channel._mqtt_rpc_channel.send_command = AsyncMock(side_effect=RoborockException("MQTT Network Info Failed"))
75+
76+
# 3. Attempt local connection.
77+
# Because cache is stale, use_cache will be False.
78+
# Because MQTT fails, it will trigger fallback to cache.
79+
80+
# We call _local_connect(use_cache=False) which is what happens in the loop
81+
# when _should_use_cache returns False (due to stale cache)
82+
await v1_channel._local_connect(use_cache=False)
83+
84+
# 4. Assert that we tried to connect to the local IP from the cache
85+
mock_local_session.assert_called_once_with(TEST_IP)
86+
mock_local_channel.connect.assert_called_once()

0 commit comments

Comments
 (0)