4141 CONF_CONNECT_DATA ,
4242 CONF_DEVICE_DATA ,
4343 CONF_DEVICE_NAME ,
44+ CONF_HAS_CLOUD_ACCOUNT ,
4445 CONF_MAMMOTION_DATA ,
4546 CONF_MAMMOTION_DEVICE_LIST ,
4647 CONF_MAMMOTION_DEVICE_RECORDS ,
8384type MammotionConfigEntry = ConfigEntry [MammotionDevices ]
8485
8586
87+ def _has_ble_devices (entry : MammotionConfigEntry ) -> bool :
88+ """Return True if the entry has at least one BLE device address."""
89+ return bool (entry .data .get (CONF_BLE_DEVICES ))
90+
91+
92+ async def _async_attempt_login (
93+ hass : HomeAssistant ,
94+ entry : MammotionConfigEntry ,
95+ mammotion : MammotionClient ,
96+ account : str ,
97+ password : str ,
98+ * ,
99+ ble_fallback : bool ,
100+ ) -> bool :
101+ """Attempt cloud login with credential-cache support.
102+
103+ Returns True on success. Returns False when login fails and ``ble_fallback``
104+ is True (BLE devices are available as a fallback). Raises the appropriate
105+ ConfigEntry exception when login fails with no BLE fallback available.
106+ """
107+ session = aiohttp_client .async_get_clientsession (hass )
108+ cached = _load_cached_credentials (entry )
109+ try :
110+ if cached :
111+ await mammotion .restore_credentials (account , password , cached , session )
112+ else :
113+ await mammotion .login_and_initiate_cloud (account , password , session )
114+ return True
115+ except ClientConnectorError as err :
116+ raise ConfigEntryNotReady (err )
117+ except LoginFailedError as err :
118+ if ble_fallback :
119+ LOGGER .warning (
120+ "Mammotion login failed; continuing in BLE-only mode: %s" , err
121+ )
122+ hass .config_entries .async_update_entry (
123+ entry , data = {** entry .data , CONF_HAS_CLOUD_ACCOUNT : False }
124+ )
125+ return False
126+ raise ConfigEntryAuthFailed (err ) from err
127+ except EXPIRED_CREDENTIAL_EXCEPTIONS as exc :
128+ LOGGER .debug (exc )
129+ if cached :
130+ LOGGER .warning (
131+ "Aliyun cache is stale (%s) — clearing cached gateway credentials" ,
132+ exc ,
133+ )
134+ stale_keys = (
135+ CONF_AEP_DATA ,
136+ CONF_AUTH_DATA ,
137+ CONF_REGION_DATA ,
138+ CONF_SESSION_DATA ,
139+ CONF_DEVICE_DATA ,
140+ CONF_CONNECT_DATA ,
141+ CONF_MAMMOTION_DATA ,
142+ )
143+ hass .config_entries .async_update_entry (
144+ entry ,
145+ data = {k : v for k , v in entry .data .items () if k not in stale_keys },
146+ )
147+ try :
148+ await mammotion .login_and_initiate_cloud (
149+ account , password , aiohttp_client .async_get_clientsession (hass )
150+ )
151+ return True
152+ except LoginFailedError as retry_err :
153+ if ble_fallback :
154+ LOGGER .warning (
155+ "Login failed after cache clear; continuing in BLE-only mode: %s" ,
156+ retry_err ,
157+ )
158+ hass .config_entries .async_update_entry (
159+ entry , data = {** entry .data , CONF_HAS_CLOUD_ACCOUNT : False }
160+ )
161+ return False
162+ raise ConfigEntryAuthFailed (retry_err ) from retry_err
163+ except TooManyRequestsException as err :
164+ if ble_fallback :
165+ LOGGER .warning ("Mammotion API rate limited; continuing in BLE-only mode" )
166+ return False
167+ raise ConfigEntryError (
168+ translation_domain = DOMAIN , translation_key = "api_limit_exceeded"
169+ ) from err
170+ except UnretryableException as err :
171+ if ble_fallback :
172+ LOGGER .warning (
173+ "Unretryable login error; continuing in BLE-only mode: %s" , err
174+ )
175+ return False
176+ raise ConfigEntryError (err )
177+
178+
86179async def _attach_ble_to_mower (
87180 hass : HomeAssistant ,
88181 entry : MammotionConfigEntry ,
89182 mammotion : MammotionClient ,
90183 device : Device ,
91184 ble_address : str ,
92- * ,
93- stay_connected_ble : bool ,
94185) -> None :
95186 """Attach a BLE transport to a mower device and register a persistent update callback."""
96187 mowing_device = mammotion .get_device_by_name (device .device_name )
@@ -101,85 +192,49 @@ async def _attach_ble_to_mower(
101192 hass , ble_address .upper (), True
102193 )
103194 if ble_device :
104- await mammotion .add_ble_to_device (
105- device .device_name ,
106- ble_device ,
107- disconnect_on_idle = not stay_connected_ble ,
108- )
195+ await mammotion .add_ble_to_device (device .device_name , ble_device )
109196
110197 _device_name = device .device_name
111198
112- def _ble_seen (
113- service_info : BluetoothServiceInfoBleak ,
114- change : BluetoothChange ,
115- ) -> None :
116- """Wire up the BLETransport on the first advertisement we observe.
117-
118- Once the transport exists, ``MammotionReportUpdateCoordinator._async_handle_bluetooth_event``
119- owns per-advertisement freshness via a sync ``set_ble_device`` pointer
120- swap. This callback only handles the initial-attach case (e.g. mower
121- was out of range at integration startup).
122- """
123- handle = mammotion .mower (_device_name )
124- if handle is None or handle .has_transport (TransportType .BLE ):
125- return
126- hass .async_create_task (
127- mammotion .add_ble_to_device (
128- _device_name ,
129- service_info .device ,
130- disconnect_on_idle = not stay_connected_ble ,
131- )
132- )
133-
134- entry .async_on_unload (
135- bluetooth .async_register_callback (
136- hass ,
137- _ble_seen ,
138- BluetoothCallbackMatcher (address = ble_address .upper ()),
139- BluetoothScanningMode .ACTIVE ,
140- )
141- )
142-
143199
144200async def _attach_ble_to_rtk (
145201 hass : HomeAssistant ,
146202 entry : MammotionConfigEntry ,
147203 mammotion : MammotionClient ,
148204 rtk : Device ,
149205 ble_address : str ,
150- * ,
151- stay_connected_ble : bool ,
152206) -> None :
153207 """Attach a BLE transport to an RTK base station and register a persistent update callback."""
208+ rtk_device = mammotion .get_device_by_name (rtk .device_name )
209+ if rtk_device is not None :
210+ rtk_device .ble_mac = ble_address
211+
154212 ble_device = bluetooth .async_ble_device_from_address (
155213 hass , ble_address .upper (), True
156214 )
157215 if ble_device :
158- await mammotion .add_ble_to_device (
159- rtk .device_name ,
160- ble_device ,
161- disconnect_on_idle = not stay_connected_ble ,
162- )
216+ await mammotion .add_ble_to_device (rtk .device_name , ble_device )
163217
164- _device_name = rtk .device_name
218+
219+ def _register_ble_reconnect_callback (
220+ hass : HomeAssistant ,
221+ entry : MammotionConfigEntry ,
222+ mammotion : MammotionClient ,
223+ device_name : str ,
224+ ble_address : str ,
225+ ) -> None :
226+ """Register a persistent BLE callback to reconnect when a device comes in range."""
165227
166228 def _ble_seen (
167229 service_info : BluetoothServiceInfoBleak ,
168230 change : BluetoothChange ,
169231 ) -> None :
170- handle = mammotion .mower (_device_name )
171- if handle is not None and handle .has_transport (TransportType .BLE ):
172- hass .async_create_task (
173- mammotion .update_ble_device (_device_name , service_info .device )
174- )
175- else :
176- hass .async_create_task (
177- mammotion .add_ble_to_device (
178- _device_name ,
179- service_info .device ,
180- disconnect_on_idle = not stay_connected_ble ,
181- )
182- )
232+ handle = mammotion .mower (device_name )
233+ if handle is None or handle .has_transport (TransportType .BLE ):
234+ return
235+ hass .async_create_task (
236+ mammotion .add_ble_to_device (device_name , service_info .device )
237+ )
183238
184239 entry .async_on_unload (
185240 bluetooth .async_register_callback (
@@ -205,71 +260,46 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) ->
205260 mammotion = MammotionClient (ha_version = integration .version )
206261 account = entry .data .get (CONF_ACCOUNTNAME )
207262 password = entry .data .get (CONF_PASSWORD )
263+ use_wifi = entry .data .get (CONF_USE_WIFI , True )
208264
209- stay_connected_ble = entry .data .get (CONF_STAY_CONNECTED_BLUETOOTH , False )
210-
265+ # Migrate options: move from stay_connected_bluetooth to prefer_ble default.
211266 if not entry .options :
212- hass .config_entries .async_update_entry (
213- entry ,
214- options = {CONF_STAY_CONNECTED_BLUETOOTH : stay_connected_ble },
215- )
216-
217- stay_connected_ble = entry .options .get (CONF_STAY_CONNECTED_BLUETOOTH , False )
218- prefer_ble = entry .options .get (CONF_PREFER_BLE , False )
219-
220- use_wifi = entry .data .get (CONF_USE_WIFI , True )
267+ hass .config_entries .async_update_entry (entry , options = {CONF_PREFER_BLE : True })
268+ elif (
269+ CONF_STAY_CONNECTED_BLUETOOTH in entry .options
270+ and CONF_PREFER_BLE not in entry .options
271+ ):
272+ new_opts = {
273+ k : v for k , v in entry .options .items () if k != CONF_STAY_CONNECTED_BLUETOOTH
274+ }
275+ new_opts [CONF_PREFER_BLE ] = True
276+ hass .config_entries .async_update_entry (entry , options = new_opts )
277+
278+ prefer_ble = entry .options .get (CONF_PREFER_BLE , True )
279+
280+ # Default to True for older entries that predate this key, as long as they
281+ # have account credentials configured.
282+ has_cloud_account = entry .data .get (
283+ CONF_HAS_CLOUD_ACCOUNT , bool (account and password )
284+ )
221285
222286 mammotion_mowers : list [MammotionMowerData ] = []
223287 mammotion_devices : MammotionDevices = MammotionDevices ([], [])
224288 mammotion_rtk : list [MammotionRTKData ] = []
225289
226- if account and password :
227- credentials = Credentials ()
228- credentials .email = account
229- credentials .password = password
230- try :
231- session = aiohttp_client .async_get_clientsession (hass )
232- cached = _load_cached_credentials (entry )
233- if cached :
234- await mammotion .restore_credentials (account , password , cached , session )
235- else :
236- await mammotion .login_and_initiate_cloud (account , password , session )
237- except ClientConnectorError as err :
238- raise ConfigEntryNotReady (err )
239- except LoginFailedError as err :
240- raise ConfigEntryAuthFailed (err ) from err
241- except EXPIRED_CREDENTIAL_EXCEPTIONS as exc :
242- LOGGER .debug (exc )
243- if cached :
244- # The cached Aliyun gateway data is stale — strip it from the config entry
245- # so the next restart doesn't re-attempt a broken cache restore.
246- LOGGER .warning (
247- "Aliyun cache is stale (%s) — clearing cached gateway credentials" ,
248- exc ,
249- )
250- stale_keys = (
251- CONF_AEP_DATA ,
252- CONF_AUTH_DATA ,
253- CONF_REGION_DATA ,
254- CONF_SESSION_DATA ,
255- CONF_DEVICE_DATA ,
256- CONF_CONNECT_DATA ,
257- CONF_MAMMOTION_DATA ,
258- )
259- hass .config_entries .async_update_entry (
260- entry ,
261- data = {k : v for k , v in entry .data .items () if k not in stale_keys },
262- )
263- await mammotion .login_and_initiate_cloud (
264- account , password , aiohttp_client .async_get_clientsession (hass )
265- )
266- except TooManyRequestsException as err :
267- raise ConfigEntryError (
268- translation_domain = DOMAIN , translation_key = "api_limit_exceeded"
269- ) from err
270- except UnretryableException as err :
271- raise ConfigEntryError (err )
290+ cloud_available = False
272291
292+ if has_cloud_account and account and password :
293+ cloud_available = await _async_attempt_login (
294+ hass ,
295+ entry ,
296+ mammotion ,
297+ account ,
298+ password ,
299+ ble_fallback = _has_ble_devices (entry ),
300+ )
301+
302+ if cloud_available :
273303 store_cloud_credentials (hass , entry , mammotion )
274304
275305 mower_devices , mammotion_rtk_devices = _build_device_list (mammotion )
@@ -282,16 +312,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) ->
282312 mammotion ,
283313 device ,
284314 device_ble_address ,
285- stay_connected_ble = stay_connected_ble ,
286315 )
287316
288- # Apply transport preference BEFORE the coordinators run their first
289- # refresh — otherwise their setup commands (read_write_device,
290- # version queries, etc.) fire while handle._prefer_ble is still the
291- # default False, and active_transport() sends them over MQTT even
292- # though the user opted into BLE. See pymammotion handle.send_raw:
293- # ``use_ble = prefer_ble or self._prefer_ble`` only honours the
294- # flag once it's set on the handle.
295317 if not use_wifi :
296318 mammotion .set_prefer_ble (device .device_name , prefer_ble = True )
297319 handle = mammotion .mower (device .device_name )
@@ -321,7 +343,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) ->
321343 error_coordinator = MammotionDeviceErrorUpdateCoordinator (
322344 hass , entry , device , mammotion , unique_name = unique_name
323345 )
324- # sometimes device is not there when restoring data
325346 await report_coordinator .async_restore_data ()
326347 await version_coordinator .async_config_entry_first_refresh ()
327348
@@ -367,7 +388,6 @@ async def _async_refresh_map(_: datetime) -> None:
367388 mammotion ,
368389 rtk ,
369390 rtk_ble_address ,
370- stay_connected_ble = stay_connected_ble ,
371391 )
372392
373393 rtk_unique_name = rtk .device_name
@@ -386,8 +406,9 @@ async def _async_refresh_map(_: datetime) -> None:
386406 )
387407 )
388408
389- elif not use_wifi and addresses :
390- # BLE-only mode — register each device without any HTTP or MQTT transport
409+ elif addresses :
410+ # BLE-only mode: either the user set use_wifi=False, has no account, or
411+ # cloud login failed and we are falling back to BLE for each known device.
391412 for device_name , ble_address in addresses .items ():
392413 ble_device = bluetooth .async_ble_device_from_address (
393414 hass , ble_address .upper (), True
@@ -404,6 +425,10 @@ async def _async_refresh_map(_: datetime) -> None:
404425 initial_device = MowingDevice (name = device_name ),
405426 )
406427
428+ _register_ble_reconnect_callback (
429+ hass , entry , mammotion , device_name , ble_address
430+ )
431+
407432 synthetic_device = _create_ble_only_device (device_name )
408433 unique_name = device_name
409434
@@ -526,7 +551,6 @@ def store_cloud_credentials(
526551 cache = client .to_cache ()
527552 if not cache :
528553 return
529- # Translate library cache keys → HA config-entry keys.
530554 translated = {_LIBRARY_TO_HA_KEY .get (k , k ): v for k , v in cache .items ()}
531555 hass .config_entries .async_update_entry (
532556 config_entry ,
@@ -567,7 +591,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -
567591 await handle .stop ()
568592 mower .api .teardown_device_watchers (mower .name )
569593 mower .api .remove_device (mower .name )
570- # await mower.reporting_coordinator.remove_saved_data()
571594 except TimeoutError :
572595 """Do nothing as this sometimes occurs with disconnecting BLE."""
573596 return bool (unload_ok )
0 commit comments