Skip to content

Commit ca31134

Browse files
bdracoCopilot
andauthored
Keep persistent BLE connection during Shelly WiFi provisioning (home-assistant#158145)
Co-authored-by: Copilot <[email protected]>
1 parent 769578d commit ca31134

File tree

2 files changed

+758
-437
lines changed

2 files changed

+758
-437
lines changed

homeassistant/components/shelly/config_flow.py

Lines changed: 111 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,6 @@
1313
has_rpc_over_ble,
1414
parse_shelly_manufacturer_data,
1515
)
16-
from aioshelly.ble.provisioning import (
17-
async_provision_wifi,
18-
async_scan_wifi_networks,
19-
ble_rpc_device,
20-
)
2116
from aioshelly.block_device import BlockDevice
2217
from aioshelly.common import ConnectionOptions, get_info
2318
from aioshelly.const import BLOCK_GENERATIONS, DEFAULT_HTTP_PORT, RPC_GENERATIONS
@@ -119,31 +114,6 @@
119114
DISCOVERY_SOURCES = {SOURCE_BLUETOOTH, SOURCE_ZEROCONF}
120115

121116

122-
async def async_get_ip_from_ble(ble_device: BLEDevice) -> str | None:
123-
"""Get device IP address via BLE after WiFi provisioning.
124-
125-
Args:
126-
ble_device: BLE device to query
127-
128-
Returns:
129-
IP address string if available, None otherwise
130-
131-
"""
132-
try:
133-
async with ble_rpc_device(ble_device) as device:
134-
await device.update_status()
135-
if (
136-
(wifi := device.status.get("wifi"))
137-
and isinstance(wifi, dict)
138-
and (ip := wifi.get("sta_ip"))
139-
):
140-
return cast(str, ip)
141-
return None
142-
except (DeviceConnectionError, RpcCallError) as err:
143-
LOGGER.debug("Failed to get IP via BLE: %s", err)
144-
return None
145-
146-
147117
# BLE provisioning flow steps that are in the finishing state
148118
# Used to determine if a BLE flow should be aborted when zeroconf discovers the device
149119
BLUETOOTH_FINISHING_STEPS = {"do_provision", "provision_done"}
@@ -252,6 +222,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
252222
disable_ap_after_provision: bool = True
253223
disable_ble_rpc_after_provision: bool = True
254224
_discovered_devices: dict[str, DiscoveredDeviceZeroconf | DiscoveredDeviceBluetooth]
225+
_ble_rpc_device: RpcDevice | None = None
255226

256227
@staticmethod
257228
def _get_name_from_mac_and_ble_model(
@@ -300,6 +271,81 @@ def _parse_ble_device_mac_and_name(
300271

301272
return mac, device_name
302273

274+
async def _async_ensure_ble_connected(self) -> RpcDevice:
275+
"""Ensure BLE RPC device is connected, reconnecting if needed.
276+
277+
Maintains a persistent BLE connection across config flow steps to avoid
278+
the overhead of reconnecting between WiFi scan and provisioning steps.
279+
280+
Returns:
281+
Connected RpcDevice instance
282+
283+
Raises:
284+
DeviceConnectionError: If connection fails
285+
RpcCallError: If ping fails after connection
286+
287+
"""
288+
if TYPE_CHECKING:
289+
assert self.ble_device is not None
290+
291+
if self._ble_rpc_device is not None and self._ble_rpc_device.connected:
292+
# Ping to verify connection is still alive
293+
try:
294+
await self._ble_rpc_device.update_status()
295+
except (DeviceConnectionError, RpcCallError):
296+
# Connection dropped, need to reconnect
297+
LOGGER.debug("BLE connection lost, reconnecting")
298+
await self._async_disconnect_ble()
299+
else:
300+
return self._ble_rpc_device
301+
302+
# Create new connection
303+
LOGGER.debug("Creating new BLE RPC connection to %s", self.ble_device.address)
304+
options = ConnectionOptions(ble_device=self.ble_device)
305+
device = await RpcDevice.create(
306+
aiohttp_session=None, ws_context=None, ip_or_options=options
307+
)
308+
try:
309+
await device.initialize()
310+
except (DeviceConnectionError, RpcCallError):
311+
await device.shutdown()
312+
raise
313+
self._ble_rpc_device = device
314+
return self._ble_rpc_device
315+
316+
async def _async_disconnect_ble(self) -> None:
317+
"""Disconnect and cleanup BLE RPC device."""
318+
if self._ble_rpc_device is not None:
319+
try:
320+
await self._ble_rpc_device.shutdown()
321+
except Exception: # noqa: BLE001
322+
LOGGER.debug("Error during BLE shutdown", exc_info=True)
323+
finally:
324+
self._ble_rpc_device = None
325+
326+
async def _async_get_ip_from_ble(self) -> str | None:
327+
"""Get device IP address via BLE after WiFi provisioning.
328+
329+
Uses the persistent BLE connection to get the device's sta_ip from status.
330+
331+
Returns:
332+
IP address string if available, None otherwise
333+
334+
"""
335+
try:
336+
device = await self._async_ensure_ble_connected()
337+
except (DeviceConnectionError, RpcCallError) as err:
338+
LOGGER.debug("Failed to get IP via BLE: %s", err)
339+
return None
340+
341+
if (
342+
(wifi := device.status.get("wifi"))
343+
and isinstance(wifi, dict)
344+
and (ip := wifi.get("sta_ip"))
345+
):
346+
return cast(str, ip)
347+
return None
348+
303349
async def _async_discover_zeroconf_devices(
304350
self,
305351
) -> dict[str, DiscoveredDeviceZeroconf]:
@@ -737,20 +783,21 @@ async def async_step_wifi_scan(
737783
password = user_input[CONF_PASSWORD]
738784
return await self.async_step_do_provision({"password": password})
739785

740-
# Scan for WiFi networks via BLE
741-
if TYPE_CHECKING:
742-
assert self.ble_device is not None
786+
# Scan for WiFi networks via BLE using persistent connection
743787
try:
744-
self.wifi_networks = await async_scan_wifi_networks(self.ble_device)
788+
device = await self._async_ensure_ble_connected()
789+
self.wifi_networks = await device.wifi_scan()
745790
except (DeviceConnectionError, RpcCallError) as err:
746791
LOGGER.debug("Failed to scan WiFi networks via BLE: %s", err)
747792
# "Writing is not permitted" error means device rejects BLE writes
748793
# and BLE provisioning is disabled - user must use Shelly app
749794
if "not permitted" in str(err):
795+
await self._async_disconnect_ble()
750796
return self.async_abort(reason="ble_not_permitted")
751797
return await self.async_step_wifi_scan_failed()
752798
except Exception: # noqa: BLE001
753799
LOGGER.exception("Unexpected exception during WiFi scan")
800+
await self._async_disconnect_ble()
754801
return self.async_abort(reason="unknown")
755802

756803
# Sort by RSSI (strongest signal first - higher/less negative values first)
@@ -871,17 +918,21 @@ async def _async_provision_wifi_and_wait_for_zeroconf(
871918
872919
Returns the flow result to be stored in self._provision_result, or None if failed.
873920
"""
874-
# Provision WiFi via BLE
875-
if TYPE_CHECKING:
876-
assert self.ble_device is not None
921+
# Provision WiFi via BLE using persistent connection
877922
try:
878-
await async_provision_wifi(self.ble_device, self.selected_ssid, password)
923+
device = await self._async_ensure_ble_connected()
924+
await device.wifi_setconfig(
925+
sta_ssid=self.selected_ssid,
926+
sta_password=password,
927+
sta_enable=True,
928+
)
879929
except (DeviceConnectionError, RpcCallError) as err:
880930
LOGGER.debug("Failed to provision WiFi via BLE: %s", err)
881931
# BLE connection/communication failed - allow retry from network selection
882932
return None
883933
except Exception: # noqa: BLE001
884934
LOGGER.exception("Unexpected exception during WiFi provisioning")
935+
await self._async_disconnect_ble()
885936
return self.async_abort(reason="unknown")
886937

887938
LOGGER.debug(
@@ -919,7 +970,7 @@ async def _async_provision_wifi_and_wait_for_zeroconf(
919970
LOGGER.debug(
920971
"Active lookup failed, trying to get IP address via BLE as fallback"
921972
)
922-
if ip := await async_get_ip_from_ble(self.ble_device):
973+
if ip := await self._async_get_ip_from_ble():
923974
LOGGER.debug("Got IP %s from BLE, using it", ip)
924975
state.host = ip
925976
state.port = DEFAULT_HTTP_PORT
@@ -996,12 +1047,17 @@ async def _do_provision(self, password: str) -> None:
9961047
if TYPE_CHECKING:
9971048
assert mac is not None
9981049

999-
async with self._async_provision_context(mac) as state:
1000-
self._provision_result = (
1001-
await self._async_provision_wifi_and_wait_for_zeroconf(
1002-
mac, password, state
1050+
try:
1051+
async with self._async_provision_context(mac) as state:
1052+
self._provision_result = (
1053+
await self._async_provision_wifi_and_wait_for_zeroconf(
1054+
mac, password, state
1055+
)
10031056
)
1004-
)
1057+
finally:
1058+
# Always disconnect BLE after provisioning attempt completes
1059+
# We either succeeded (and will use IP now) or failed (and user will retry)
1060+
await self._async_disconnect_ble()
10051061

10061062
async def async_step_do_provision(
10071063
self, user_input: dict[str, Any] | None = None
@@ -1220,6 +1276,17 @@ async def _async_get_info(self, host: str, port: int) -> dict[str, Any]:
12201276
"""Get info from shelly device."""
12211277
return await get_info(async_get_clientsession(self.hass), host, port=port)
12221278

1279+
@callback
1280+
def async_remove(self) -> None:
1281+
"""Handle flow removal - cleanup BLE connection."""
1282+
super().async_remove()
1283+
if self._ble_rpc_device is not None:
1284+
# Schedule cleanup as background task since async_remove is sync
1285+
self.hass.async_create_background_task(
1286+
self._async_disconnect_ble(),
1287+
name="shelly_config_flow_ble_cleanup",
1288+
)
1289+
12231290
@staticmethod
12241291
@callback
12251292
def async_get_options_flow(config_entry: ShellyConfigEntry) -> OptionsFlowHandler:

0 commit comments

Comments
 (0)