|
13 | 13 | has_rpc_over_ble, |
14 | 14 | parse_shelly_manufacturer_data, |
15 | 15 | ) |
16 | | -from aioshelly.ble.provisioning import ( |
17 | | - async_provision_wifi, |
18 | | - async_scan_wifi_networks, |
19 | | - ble_rpc_device, |
20 | | -) |
21 | 16 | from aioshelly.block_device import BlockDevice |
22 | 17 | from aioshelly.common import ConnectionOptions, get_info |
23 | 18 | from aioshelly.const import BLOCK_GENERATIONS, DEFAULT_HTTP_PORT, RPC_GENERATIONS |
|
119 | 114 | DISCOVERY_SOURCES = {SOURCE_BLUETOOTH, SOURCE_ZEROCONF} |
120 | 115 |
|
121 | 116 |
|
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 | | - |
147 | 117 | # BLE provisioning flow steps that are in the finishing state |
148 | 118 | # Used to determine if a BLE flow should be aborted when zeroconf discovers the device |
149 | 119 | BLUETOOTH_FINISHING_STEPS = {"do_provision", "provision_done"} |
@@ -252,6 +222,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): |
252 | 222 | disable_ap_after_provision: bool = True |
253 | 223 | disable_ble_rpc_after_provision: bool = True |
254 | 224 | _discovered_devices: dict[str, DiscoveredDeviceZeroconf | DiscoveredDeviceBluetooth] |
| 225 | + _ble_rpc_device: RpcDevice | None = None |
255 | 226 |
|
256 | 227 | @staticmethod |
257 | 228 | def _get_name_from_mac_and_ble_model( |
@@ -300,6 +271,81 @@ def _parse_ble_device_mac_and_name( |
300 | 271 |
|
301 | 272 | return mac, device_name |
302 | 273 |
|
| 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 | + |
303 | 349 | async def _async_discover_zeroconf_devices( |
304 | 350 | self, |
305 | 351 | ) -> dict[str, DiscoveredDeviceZeroconf]: |
@@ -737,20 +783,21 @@ async def async_step_wifi_scan( |
737 | 783 | password = user_input[CONF_PASSWORD] |
738 | 784 | return await self.async_step_do_provision({"password": password}) |
739 | 785 |
|
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 |
743 | 787 | 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() |
745 | 790 | except (DeviceConnectionError, RpcCallError) as err: |
746 | 791 | LOGGER.debug("Failed to scan WiFi networks via BLE: %s", err) |
747 | 792 | # "Writing is not permitted" error means device rejects BLE writes |
748 | 793 | # and BLE provisioning is disabled - user must use Shelly app |
749 | 794 | if "not permitted" in str(err): |
| 795 | + await self._async_disconnect_ble() |
750 | 796 | return self.async_abort(reason="ble_not_permitted") |
751 | 797 | return await self.async_step_wifi_scan_failed() |
752 | 798 | except Exception: # noqa: BLE001 |
753 | 799 | LOGGER.exception("Unexpected exception during WiFi scan") |
| 800 | + await self._async_disconnect_ble() |
754 | 801 | return self.async_abort(reason="unknown") |
755 | 802 |
|
756 | 803 | # Sort by RSSI (strongest signal first - higher/less negative values first) |
@@ -871,17 +918,21 @@ async def _async_provision_wifi_and_wait_for_zeroconf( |
871 | 918 |
|
872 | 919 | Returns the flow result to be stored in self._provision_result, or None if failed. |
873 | 920 | """ |
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 |
877 | 922 | 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 | + ) |
879 | 929 | except (DeviceConnectionError, RpcCallError) as err: |
880 | 930 | LOGGER.debug("Failed to provision WiFi via BLE: %s", err) |
881 | 931 | # BLE connection/communication failed - allow retry from network selection |
882 | 932 | return None |
883 | 933 | except Exception: # noqa: BLE001 |
884 | 934 | LOGGER.exception("Unexpected exception during WiFi provisioning") |
| 935 | + await self._async_disconnect_ble() |
885 | 936 | return self.async_abort(reason="unknown") |
886 | 937 |
|
887 | 938 | LOGGER.debug( |
@@ -919,7 +970,7 @@ async def _async_provision_wifi_and_wait_for_zeroconf( |
919 | 970 | LOGGER.debug( |
920 | 971 | "Active lookup failed, trying to get IP address via BLE as fallback" |
921 | 972 | ) |
922 | | - if ip := await async_get_ip_from_ble(self.ble_device): |
| 973 | + if ip := await self._async_get_ip_from_ble(): |
923 | 974 | LOGGER.debug("Got IP %s from BLE, using it", ip) |
924 | 975 | state.host = ip |
925 | 976 | state.port = DEFAULT_HTTP_PORT |
@@ -996,12 +1047,17 @@ async def _do_provision(self, password: str) -> None: |
996 | 1047 | if TYPE_CHECKING: |
997 | 1048 | assert mac is not None |
998 | 1049 |
|
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 | + ) |
1003 | 1056 | ) |
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() |
1005 | 1061 |
|
1006 | 1062 | async def async_step_do_provision( |
1007 | 1063 | 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]: |
1220 | 1276 | """Get info from shelly device.""" |
1221 | 1277 | return await get_info(async_get_clientsession(self.hass), host, port=port) |
1222 | 1278 |
|
| 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 | + |
1223 | 1290 | @staticmethod |
1224 | 1291 | @callback |
1225 | 1292 | def async_get_options_flow(config_entry: ShellyConfigEntry) -> OptionsFlowHandler: |
|
0 commit comments