Skip to content

Commit 1b15efd

Browse files
committed
remove stay connected ble, tidy up the listeners for ble advertisements to just be in the coordinator, try to let accounts continue even if they fail to allow ble functions
1 parent bbd3029 commit 1b15efd

8 files changed

Lines changed: 374 additions & 388 deletions

File tree

custom_components/mammotion/__init__.py

Lines changed: 153 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
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,
@@ -83,14 +84,104 @@
8384
type 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+
86179
async 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

144200
async 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

Comments
 (0)