33This module provides a unified channel interface for V1 protocol devices,
44handling both MQTT and local connections with automatic fallback.
55"""
6-
6+ import asyncio
7+ import datetime
78import logging
89from collections .abc import Callable
910from typing import TypeVar
2223from .channel import Channel
2324from .local_channel import LocalChannel , LocalSession , create_local_session
2425from .mqtt_channel import MqttChannel
25- from .v1_rpc_channel import PickFirstAvailable , V1RpcChannel , create_local_rpc_channel , create_mqtt_rpc_channel
26+ from .v1_rpc_channel import (
27+ PickFirstAvailable ,
28+ V1RpcChannel ,
29+ create_local_rpc_channel ,
30+ create_mqtt_rpc_channel ,
31+ )
2632
2733_LOGGER = logging .getLogger (__name__ )
2834
3238
3339_T = TypeVar ("_T" , bound = RoborockBase )
3440
41+ # Exponential backoff parameters for reconnecting to local
42+ MIN_RECONNECT_INTERVAL = datetime .timedelta (minutes = 1 )
43+ MAX_RECONNECT_INTERVAL = datetime .timedelta (minutes = 10 )
44+ RECONNECT_MULTIPLIER = 1.5
45+ # After this many hours, the network info is refreshed
46+ NETWORK_INFO_REFRESH_INTERVAL = datetime .timedelta (hours = 12 )
47+ # Interval to check that the local connection is healthy
48+ LOCAL_CONNECTION_CHECK_INTERVAL = datetime .timedelta (seconds = 15 )
49+
3550
3651class V1Channel (Channel ):
3752 """Unified V1 protocol channel with automatic MQTT/local connection handling.
@@ -69,6 +84,8 @@ def __init__(
6984 self ._local_unsub : Callable [[], None ] | None = None
7085 self ._callback : Callable [[RoborockMessage ], None ] | None = None
7186 self ._cache = cache
87+ self ._reconnect_task : asyncio .Task [None ] | None = None
88+ self ._last_network_info_refresh : datetime .datetime | None = None
7289
7390 @property
7491 def is_connected (self ) -> bool :
@@ -78,7 +95,7 @@ def is_connected(self) -> bool:
7895 @property
7996 def is_local_connected (self ) -> bool :
8097 """Return whether local connection is available."""
81- return self ._local_unsub is not None
98+ return self ._local_channel is not None and self . _local_channel . is_connected
8299
83100 @property
84101 def is_mqtt_connected (self ) -> bool :
@@ -103,25 +120,35 @@ async def subscribe(self, callback: Callable[[RoborockMessage], None]) -> Callab
103120 a RoborockException. A local connection failure will not raise an exception,
104121 since the local connection is optional.
105122 """
123+ if self ._callback is not None :
124+ raise ValueError ("Only one subscription allowed at a time" )
106125
107- if self ._mqtt_unsub :
108- raise ValueError ("Already connected to the device" )
109- self ._callback = callback
110-
111- # First establish MQTT connection
112- self ._mqtt_unsub = await self ._mqtt_channel .subscribe (self ._on_mqtt_message )
113- _LOGGER .debug ("V1Channel connected to device %s via MQTT" , self ._device_uid )
114-
115- # Try to establish an optional local connection as well.
126+ # Make an initial, optimistic attempt to connect to local with the
127+ # cache. The cache information will be refreshed by the background task.
116128 try :
117- self . _local_unsub = await self ._local_connect ()
129+ await self ._local_connect (use_cache = True )
118130 except RoborockException as err :
119131 _LOGGER .warning ("Could not establish local connection for device %s: %s" , self ._device_uid , err )
120- else :
121- _LOGGER .debug ("Local connection established for device %s" , self ._device_uid )
132+
133+ # Start a background task to manage the local connection health. This
134+ # happens independent of whether we were able to connect locally now.
135+ _LOGGER .info ("self._reconnect_task=%s" , self ._reconnect_task )
136+ if self ._reconnect_task is None :
137+ loop = asyncio .get_running_loop ()
138+ self ._reconnect_task = loop .create_task (self ._background_reconnect ())
139+
140+ if not self .is_local_connected :
141+ # We were not able to connect locally, so fallback to MQTT and at least
142+ # establish that connection explicitly. If this fails then raise an
143+ # error and let the caller know we failed to subscribe.
144+ self ._mqtt_unsub = await self ._mqtt_channel .subscribe (self ._on_mqtt_message )
145+ _LOGGER .debug ("V1Channel connected to device %s via MQTT" , self ._device_uid )
122146
123147 def unsub () -> None :
124148 """Unsubscribe from all messages."""
149+ if self ._reconnect_task :
150+ self ._reconnect_task .cancel ()
151+ self ._reconnect_task = None
125152 if self ._mqtt_unsub :
126153 self ._mqtt_unsub ()
127154 self ._mqtt_unsub = None
@@ -130,15 +157,16 @@ def unsub() -> None:
130157 self ._local_unsub = None
131158 _LOGGER .debug ("Unsubscribed from device %s" , self ._device_uid )
132159
160+ self ._callback = callback
133161 return unsub
134162
135- async def _get_networking_info (self ) -> NetworkInfo :
163+ async def _get_networking_info (self , * , use_cache : bool = True ) -> NetworkInfo :
136164 """Retrieve networking information for the device.
137165
138166 This is a cloud only command used to get the local device's IP address.
139167 """
140168 cache_data = await self ._cache .get ()
141- if cache_data .network_info and (network_info := cache_data .network_info .get (self ._device_uid )):
169+ if use_cache and cache_data .network_info and (network_info := cache_data .network_info .get (self ._device_uid )):
142170 _LOGGER .debug ("Using cached network info for device %s" , self ._device_uid )
143171 return network_info
144172 try :
@@ -148,24 +176,81 @@ async def _get_networking_info(self) -> NetworkInfo:
148176 except RoborockException as e :
149177 raise RoborockException (f"Network info failed for device { self ._device_uid } " ) from e
150178 _LOGGER .debug ("Network info for device %s: %s" , self ._device_uid , network_info )
179+ self ._last_network_info_refresh = datetime .datetime .now (datetime .timezone .utc )
151180 cache_data .network_info [self ._device_uid ] = network_info
152181 await self ._cache .set (cache_data )
153182 return network_info
154183
155- async def _local_connect (self ) -> Callable [[], None ] :
184+ async def _local_connect (self , * , use_cache : bool = True ) -> None :
156185 """Set up local connection if possible."""
157- _LOGGER .debug ("Attempting to connect to local channel for device %s" , self ._device_uid )
158- networking_info = await self ._get_networking_info ()
186+ _LOGGER .debug (
187+ "Attempting to connect to local channel for device %s (use_cache=%s)" , self ._device_uid , use_cache
188+ )
189+ networking_info = await self ._get_networking_info (use_cache = use_cache )
159190 host = networking_info .ip
160191 _LOGGER .debug ("Connecting to local channel at %s" , host )
161- self ._local_channel = self ._local_session (host )
192+ # Create a new local channel and connect
193+ local_channel = self ._local_session (host )
162194 try :
163- await self . _local_channel .connect ()
195+ await local_channel .connect ()
164196 except RoborockException as e :
165- self ._local_channel = None
166197 raise RoborockException (f"Error connecting to local device { self ._device_uid } : { e } " ) from e
198+ # Wire up the new channel
199+ self ._local_channel = local_channel
167200 self ._local_rpc_channel = create_local_rpc_channel (self ._local_channel )
168- return await self ._local_channel .subscribe (self ._on_local_message )
201+ self ._local_unsub = await self ._local_channel .subscribe (self ._on_local_message )
202+ _LOGGER .info ("Successfully connected to local device %s" , self ._device_uid )
203+
204+ async def _background_reconnect (self ) -> None :
205+ """Task to run in the background to manage the local connection."""
206+ _LOGGER .debug ("Starting background task to manage local connection for %s" , self ._device_uid )
207+ reconnect_backoff = MIN_RECONNECT_INTERVAL
208+ local_connect_failures = 0
209+
210+ while True :
211+ try :
212+ if self .is_local_connected :
213+ await asyncio .sleep (LOCAL_CONNECTION_CHECK_INTERVAL .total_seconds ())
214+ continue
215+
216+ # Not connected, so wait with backoff before trying to connect.
217+ # The first time through, we don't sleep, we just try to connect.
218+ local_connect_failures += 1
219+ if local_connect_failures > 1 :
220+ await asyncio .sleep (reconnect_backoff .total_seconds ())
221+ reconnect_backoff = min (reconnect_backoff * RECONNECT_MULTIPLIER , MAX_RECONNECT_INTERVAL )
222+
223+ use_cache = self ._should_use_cache (local_connect_failures )
224+ await self ._local_connect (use_cache = use_cache )
225+ # Reset backoff and failures on success
226+ reconnect_backoff = MIN_RECONNECT_INTERVAL
227+ local_connect_failures = 0
228+
229+ except asyncio .CancelledError :
230+ _LOGGER .debug ("Background reconnect task cancelled" )
231+ if self ._local_channel :
232+ self ._local_channel .close ()
233+ return
234+ except RoborockException as err :
235+ _LOGGER .debug ("Background reconnect failed: %s" , err )
236+ except Exception :
237+ _LOGGER .exception ("Unhandled exception in background reconnect task" )
238+
239+ def _should_use_cache (self , local_connect_failures : int ) -> bool :
240+ """Determine whether to use cached network info on retries.
241+
242+ On the first retry we'll avoid the cache to handle the case where
243+ the network ip may have recently changed. Otherwise, use the cache
244+ if available then expire at some point.
245+ """
246+ if local_connect_failures == 1 :
247+ return False
248+ elif self ._last_network_info_refresh and (
249+ datetime .datetime .now (datetime .timezone .utc ) - self ._last_network_info_refresh
250+ > NETWORK_INFO_REFRESH_INTERVAL
251+ ):
252+ return False
253+ return True
169254
170255 def _on_mqtt_message (self , message : RoborockMessage ) -> None :
171256 """Handle incoming MQTT messages."""
0 commit comments