1
0
mirror of https://github.com/home-assistant/core.git synced 2026-02-15 07:36:16 +00:00

Keep persistent BLE connection during Shelly WiFi provisioning (#158145)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
J. Nick Koston
2025-12-07 14:28:53 -06:00
committed by GitHub
parent 769578dc51
commit ca31134caa
2 changed files with 758 additions and 437 deletions

View File

@@ -13,11 +13,6 @@ from aioshelly.ble.manufacturer_data import (
has_rpc_over_ble,
parse_shelly_manufacturer_data,
)
from aioshelly.ble.provisioning import (
async_provision_wifi,
async_scan_wifi_networks,
ble_rpc_device,
)
from aioshelly.block_device import BlockDevice
from aioshelly.common import ConnectionOptions, get_info
from aioshelly.const import BLOCK_GENERATIONS, DEFAULT_HTTP_PORT, RPC_GENERATIONS
@@ -119,31 +114,6 @@ MANUAL_ENTRY_STRING = "manual"
DISCOVERY_SOURCES = {SOURCE_BLUETOOTH, SOURCE_ZEROCONF}
async def async_get_ip_from_ble(ble_device: BLEDevice) -> str | None:
"""Get device IP address via BLE after WiFi provisioning.
Args:
ble_device: BLE device to query
Returns:
IP address string if available, None otherwise
"""
try:
async with ble_rpc_device(ble_device) as device:
await device.update_status()
if (
(wifi := device.status.get("wifi"))
and isinstance(wifi, dict)
and (ip := wifi.get("sta_ip"))
):
return cast(str, ip)
return None
except (DeviceConnectionError, RpcCallError) as err:
LOGGER.debug("Failed to get IP via BLE: %s", err)
return None
# BLE provisioning flow steps that are in the finishing state
# Used to determine if a BLE flow should be aborted when zeroconf discovers the device
BLUETOOTH_FINISHING_STEPS = {"do_provision", "provision_done"}
@@ -252,6 +222,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
disable_ap_after_provision: bool = True
disable_ble_rpc_after_provision: bool = True
_discovered_devices: dict[str, DiscoveredDeviceZeroconf | DiscoveredDeviceBluetooth]
_ble_rpc_device: RpcDevice | None = None
@staticmethod
def _get_name_from_mac_and_ble_model(
@@ -300,6 +271,81 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
return mac, device_name
async def _async_ensure_ble_connected(self) -> RpcDevice:
"""Ensure BLE RPC device is connected, reconnecting if needed.
Maintains a persistent BLE connection across config flow steps to avoid
the overhead of reconnecting between WiFi scan and provisioning steps.
Returns:
Connected RpcDevice instance
Raises:
DeviceConnectionError: If connection fails
RpcCallError: If ping fails after connection
"""
if TYPE_CHECKING:
assert self.ble_device is not None
if self._ble_rpc_device is not None and self._ble_rpc_device.connected:
# Ping to verify connection is still alive
try:
await self._ble_rpc_device.update_status()
except (DeviceConnectionError, RpcCallError):
# Connection dropped, need to reconnect
LOGGER.debug("BLE connection lost, reconnecting")
await self._async_disconnect_ble()
else:
return self._ble_rpc_device
# Create new connection
LOGGER.debug("Creating new BLE RPC connection to %s", self.ble_device.address)
options = ConnectionOptions(ble_device=self.ble_device)
device = await RpcDevice.create(
aiohttp_session=None, ws_context=None, ip_or_options=options
)
try:
await device.initialize()
except (DeviceConnectionError, RpcCallError):
await device.shutdown()
raise
self._ble_rpc_device = device
return self._ble_rpc_device
async def _async_disconnect_ble(self) -> None:
"""Disconnect and cleanup BLE RPC device."""
if self._ble_rpc_device is not None:
try:
await self._ble_rpc_device.shutdown()
except Exception: # noqa: BLE001
LOGGER.debug("Error during BLE shutdown", exc_info=True)
finally:
self._ble_rpc_device = None
async def _async_get_ip_from_ble(self) -> str | None:
"""Get device IP address via BLE after WiFi provisioning.
Uses the persistent BLE connection to get the device's sta_ip from status.
Returns:
IP address string if available, None otherwise
"""
try:
device = await self._async_ensure_ble_connected()
except (DeviceConnectionError, RpcCallError) as err:
LOGGER.debug("Failed to get IP via BLE: %s", err)
return None
if (
(wifi := device.status.get("wifi"))
and isinstance(wifi, dict)
and (ip := wifi.get("sta_ip"))
):
return cast(str, ip)
return None
async def _async_discover_zeroconf_devices(
self,
) -> dict[str, DiscoveredDeviceZeroconf]:
@@ -737,20 +783,21 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
password = user_input[CONF_PASSWORD]
return await self.async_step_do_provision({"password": password})
# Scan for WiFi networks via BLE
if TYPE_CHECKING:
assert self.ble_device is not None
# Scan for WiFi networks via BLE using persistent connection
try:
self.wifi_networks = await async_scan_wifi_networks(self.ble_device)
device = await self._async_ensure_ble_connected()
self.wifi_networks = await device.wifi_scan()
except (DeviceConnectionError, RpcCallError) as err:
LOGGER.debug("Failed to scan WiFi networks via BLE: %s", err)
# "Writing is not permitted" error means device rejects BLE writes
# and BLE provisioning is disabled - user must use Shelly app
if "not permitted" in str(err):
await self._async_disconnect_ble()
return self.async_abort(reason="ble_not_permitted")
return await self.async_step_wifi_scan_failed()
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception during WiFi scan")
await self._async_disconnect_ble()
return self.async_abort(reason="unknown")
# Sort by RSSI (strongest signal first - higher/less negative values first)
@@ -871,17 +918,21 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
Returns the flow result to be stored in self._provision_result, or None if failed.
"""
# Provision WiFi via BLE
if TYPE_CHECKING:
assert self.ble_device is not None
# Provision WiFi via BLE using persistent connection
try:
await async_provision_wifi(self.ble_device, self.selected_ssid, password)
device = await self._async_ensure_ble_connected()
await device.wifi_setconfig(
sta_ssid=self.selected_ssid,
sta_password=password,
sta_enable=True,
)
except (DeviceConnectionError, RpcCallError) as err:
LOGGER.debug("Failed to provision WiFi via BLE: %s", err)
# BLE connection/communication failed - allow retry from network selection
return None
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception during WiFi provisioning")
await self._async_disconnect_ble()
return self.async_abort(reason="unknown")
LOGGER.debug(
@@ -919,7 +970,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
LOGGER.debug(
"Active lookup failed, trying to get IP address via BLE as fallback"
)
if ip := await async_get_ip_from_ble(self.ble_device):
if ip := await self._async_get_ip_from_ble():
LOGGER.debug("Got IP %s from BLE, using it", ip)
state.host = ip
state.port = DEFAULT_HTTP_PORT
@@ -996,12 +1047,17 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
if TYPE_CHECKING:
assert mac is not None
async with self._async_provision_context(mac) as state:
self._provision_result = (
await self._async_provision_wifi_and_wait_for_zeroconf(
mac, password, state
try:
async with self._async_provision_context(mac) as state:
self._provision_result = (
await self._async_provision_wifi_and_wait_for_zeroconf(
mac, password, state
)
)
)
finally:
# Always disconnect BLE after provisioning attempt completes
# We either succeeded (and will use IP now) or failed (and user will retry)
await self._async_disconnect_ble()
async def async_step_do_provision(
self, user_input: dict[str, Any] | None = None
@@ -1220,6 +1276,17 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
"""Get info from shelly device."""
return await get_info(async_get_clientsession(self.hass), host, port=port)
@callback
def async_remove(self) -> None:
"""Handle flow removal - cleanup BLE connection."""
super().async_remove()
if self._ble_rpc_device is not None:
# Schedule cleanup as background task since async_remove is sync
self.hass.async_create_background_task(
self._async_disconnect_ble(),
name="shelly_config_flow_ble_cleanup",
)
@staticmethod
@callback
def async_get_options_flow(config_entry: ShellyConfigEntry) -> OptionsFlowHandler:

File diff suppressed because it is too large Load Diff