3
3
This module provides a unified channel interface for V1 protocol devices,
4
4
handling both MQTT and local connections with automatic fallback.
5
5
"""
6
-
6
+ import asyncio
7
+ import datetime
7
8
import logging
8
9
from collections .abc import Callable
9
10
from typing import TypeVar
22
23
from .channel import Channel
23
24
from .local_channel import LocalChannel , LocalSession , create_local_session
24
25
from .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
+ )
26
32
27
33
_LOGGER = logging .getLogger (__name__ )
28
34
32
38
33
39
_T = TypeVar ("_T" , bound = RoborockBase )
34
40
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
+
35
50
36
51
class V1Channel (Channel ):
37
52
"""Unified V1 protocol channel with automatic MQTT/local connection handling.
@@ -69,6 +84,8 @@ def __init__(
69
84
self ._local_unsub : Callable [[], None ] | None = None
70
85
self ._callback : Callable [[RoborockMessage ], None ] | None = None
71
86
self ._cache = cache
87
+ self ._reconnect_task : asyncio .Task [None ] | None = None
88
+ self ._last_network_info_refresh : datetime .datetime | None = None
72
89
73
90
@property
74
91
def is_connected (self ) -> bool :
@@ -78,7 +95,7 @@ def is_connected(self) -> bool:
78
95
@property
79
96
def is_local_connected (self ) -> bool :
80
97
"""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
82
99
83
100
@property
84
101
def is_mqtt_connected (self ) -> bool :
@@ -103,25 +120,35 @@ async def subscribe(self, callback: Callable[[RoborockMessage], None]) -> Callab
103
120
a RoborockException. A local connection failure will not raise an exception,
104
121
since the local connection is optional.
105
122
"""
123
+ if self ._callback is not None :
124
+ raise ValueError ("Only one subscription allowed at a time" )
106
125
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.
116
128
try :
117
- self . _local_unsub = await self ._local_connect ()
129
+ await self ._local_connect (use_cache = True )
118
130
except RoborockException as err :
119
131
_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 )
122
146
123
147
def unsub () -> None :
124
148
"""Unsubscribe from all messages."""
149
+ if self ._reconnect_task :
150
+ self ._reconnect_task .cancel ()
151
+ self ._reconnect_task = None
125
152
if self ._mqtt_unsub :
126
153
self ._mqtt_unsub ()
127
154
self ._mqtt_unsub = None
@@ -130,15 +157,16 @@ def unsub() -> None:
130
157
self ._local_unsub = None
131
158
_LOGGER .debug ("Unsubscribed from device %s" , self ._device_uid )
132
159
160
+ self ._callback = callback
133
161
return unsub
134
162
135
- async def _get_networking_info (self ) -> NetworkInfo :
163
+ async def _get_networking_info (self , * , use_cache : bool = True ) -> NetworkInfo :
136
164
"""Retrieve networking information for the device.
137
165
138
166
This is a cloud only command used to get the local device's IP address.
139
167
"""
140
168
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 )):
142
170
_LOGGER .debug ("Using cached network info for device %s" , self ._device_uid )
143
171
return network_info
144
172
try :
@@ -148,24 +176,81 @@ async def _get_networking_info(self) -> NetworkInfo:
148
176
except RoborockException as e :
149
177
raise RoborockException (f"Network info failed for device { self ._device_uid } " ) from e
150
178
_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 )
151
180
cache_data .network_info [self ._device_uid ] = network_info
152
181
await self ._cache .set (cache_data )
153
182
return network_info
154
183
155
- async def _local_connect (self ) -> Callable [[], None ] :
184
+ async def _local_connect (self , * , use_cache : bool = True ) -> None :
156
185
"""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 )
159
190
host = networking_info .ip
160
191
_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 )
162
194
try :
163
- await self . _local_channel .connect ()
195
+ await local_channel .connect ()
164
196
except RoborockException as e :
165
- self ._local_channel = None
166
197
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
167
200
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
169
254
170
255
def _on_mqtt_message (self , message : RoborockMessage ) -> None :
171
256
"""Handle incoming MQTT messages."""
0 commit comments