Skip to content

Commit f439471

Browse files
authored
Add BLE IP fallback for Shelly provisioning when zeroconf fails (home-assistant#157144)
1 parent 5ff3233 commit f439471

File tree

2 files changed

+272
-8
lines changed

2 files changed

+272
-8
lines changed

homeassistant/components/shelly/config_flow.py

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,18 @@
66
from collections.abc import AsyncIterator, Mapping
77
from contextlib import asynccontextmanager
88
from dataclasses import dataclass
9-
from typing import TYPE_CHECKING, Any, Final
9+
from typing import TYPE_CHECKING, Any, Final, cast
1010

1111
from aioshelly.ble import get_name_from_model_id
1212
from aioshelly.ble.manufacturer_data import (
1313
has_rpc_over_ble,
1414
parse_shelly_manufacturer_data,
1515
)
16-
from aioshelly.ble.provisioning import async_provision_wifi, async_scan_wifi_networks
16+
from aioshelly.ble.provisioning import (
17+
async_provision_wifi,
18+
async_scan_wifi_networks,
19+
ble_rpc_device,
20+
)
1721
from aioshelly.block_device import BlockDevice
1822
from aioshelly.common import ConnectionOptions, get_info
1923
from aioshelly.const import BLOCK_GENERATIONS, DEFAULT_HTTP_PORT, RPC_GENERATIONS
@@ -106,6 +110,32 @@
106110
INTERNAL_WIFI_AP_IP = "192.168.33.1"
107111
MANUAL_ENTRY_STRING = "manual"
108112

113+
114+
async def async_get_ip_from_ble(ble_device: BLEDevice) -> str | None:
115+
"""Get device IP address via BLE after WiFi provisioning.
116+
117+
Args:
118+
ble_device: BLE device to query
119+
120+
Returns:
121+
IP address string if available, None otherwise
122+
123+
"""
124+
try:
125+
async with ble_rpc_device(ble_device) as device:
126+
await device.update_status()
127+
if (
128+
(wifi := device.status.get("wifi"))
129+
and isinstance(wifi, dict)
130+
and (ip := wifi.get("sta_ip"))
131+
):
132+
return cast(str, ip)
133+
return None
134+
except (DeviceConnectionError, RpcCallError) as err:
135+
LOGGER.debug("Failed to get IP via BLE: %s", err)
136+
return None
137+
138+
109139
# BLE provisioning flow steps that are in the finishing state
110140
# Used to determine if a BLE flow should be aborted when zeroconf discovers the device
111141
BLUETOOTH_FINISHING_STEPS = {"do_provision", "provision_done"}
@@ -864,13 +894,21 @@ async def _async_provision_wifi_and_wait_for_zeroconf(
864894
aiozc = await zeroconf.async_get_async_instance(self.hass)
865895
result = await async_lookup_device_by_name(aiozc, self.device_name)
866896

867-
# If we still don't have a host, provisioning failed
897+
# If we still don't have a host, try BLE fallback for alternate subnets
868898
if not result:
869-
LOGGER.debug("Active lookup failed - provisioning unsuccessful")
870-
# Store failure info and return None - provision_done will handle redirect
871-
return None
872-
873-
state.host, state.port = result
899+
LOGGER.debug(
900+
"Active lookup failed, trying to get IP address via BLE as fallback"
901+
)
902+
if ip := await async_get_ip_from_ble(self.ble_device):
903+
LOGGER.debug("Got IP %s from BLE, using it", ip)
904+
state.host = ip
905+
state.port = DEFAULT_HTTP_PORT
906+
else:
907+
LOGGER.debug("BLE fallback also failed - provisioning unsuccessful")
908+
# Store failure info and return None - provision_done will handle redirect
909+
return None
910+
else:
911+
state.host, state.port = result
874912
else:
875913
LOGGER.debug(
876914
"Zeroconf discovery received for device after WiFi provisioning at %s",

tests/components/shelly/test_config_flow.py

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4459,6 +4459,232 @@ async def test_bluetooth_provision_timeout_active_lookup_fails(
44594459
assert result["reason"] == "unknown"
44604460

44614461

4462+
async def test_bluetooth_provision_timeout_ble_fallback_succeeds(
4463+
hass: HomeAssistant,
4464+
mock_setup_entry: AsyncMock,
4465+
mock_setup: AsyncMock,
4466+
) -> None:
4467+
"""Test WiFi provisioning times out, active lookup fails, but BLE fallback succeeds."""
4468+
# Inject BLE device
4469+
inject_bluetooth_service_info_bleak(hass, BLE_DISCOVERY_INFO)
4470+
4471+
result = await hass.config_entries.flow.async_init(
4472+
DOMAIN,
4473+
data=BLE_DISCOVERY_INFO,
4474+
context={"source": config_entries.SOURCE_BLUETOOTH},
4475+
)
4476+
4477+
# Confirm and scan
4478+
with patch(
4479+
"homeassistant.components.shelly.config_flow.async_scan_wifi_networks",
4480+
return_value=[{"ssid": "MyNetwork", "rssi": -50, "auth": 2}],
4481+
):
4482+
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
4483+
4484+
# Select network
4485+
result = await hass.config_entries.flow.async_configure(
4486+
result["flow_id"],
4487+
{CONF_SSID: "MyNetwork"},
4488+
)
4489+
4490+
# Mock device for BLE status query
4491+
mock_ble_status_device = AsyncMock()
4492+
mock_ble_status_device.status = {"wifi": {"sta_ip": "192.168.1.100"}}
4493+
4494+
# Mock device for secure device feature
4495+
mock_device = AsyncMock()
4496+
mock_device.initialize = AsyncMock()
4497+
mock_device.name = "Test name"
4498+
mock_device.status = {"sys": {}}
4499+
mock_device.xmod_info = {}
4500+
mock_device.shelly = {"model": MODEL_PLUS_2PM}
4501+
mock_device.wifi_setconfig = AsyncMock(return_value={})
4502+
mock_device.ble_setconfig = AsyncMock(return_value={"restart_required": False})
4503+
mock_device.shutdown = AsyncMock()
4504+
4505+
# Provision WiFi but no zeroconf discovery arrives, active lookup fails, BLE fallback succeeds
4506+
with (
4507+
patch(
4508+
"homeassistant.components.shelly.config_flow.PROVISIONING_TIMEOUT",
4509+
0.01, # Short timeout to trigger timeout path
4510+
),
4511+
patch("homeassistant.components.shelly.config_flow.async_provision_wifi"),
4512+
patch(
4513+
"homeassistant.components.shelly.config_flow.async_lookup_device_by_name",
4514+
return_value=None, # Active lookup fails
4515+
),
4516+
patch(
4517+
"homeassistant.components.shelly.config_flow.ble_rpc_device",
4518+
) as mock_ble_rpc,
4519+
patch(
4520+
"homeassistant.components.shelly.config_flow.get_info",
4521+
return_value=MOCK_DEVICE_INFO,
4522+
),
4523+
patch(
4524+
"homeassistant.components.shelly.config_flow.RpcDevice.create",
4525+
return_value=mock_device,
4526+
),
4527+
):
4528+
# Configure BLE RPC mock to return device with IP
4529+
mock_ble_rpc.return_value.__aenter__.return_value = mock_ble_status_device
4530+
4531+
result = await hass.config_entries.flow.async_configure(
4532+
result["flow_id"],
4533+
{CONF_PASSWORD: "my_password"},
4534+
)
4535+
4536+
# Provisioning shows progress
4537+
assert result["type"] is FlowResultType.SHOW_PROGRESS
4538+
await hass.async_block_till_done()
4539+
4540+
# Timeout occurs, active lookup fails, but BLE fallback gets IP
4541+
result = await hass.config_entries.flow.async_configure(result["flow_id"])
4542+
4543+
# Should create entry successfully with IP from BLE
4544+
assert result["type"] is FlowResultType.CREATE_ENTRY
4545+
assert result["title"] == "Test name"
4546+
assert result["data"][CONF_HOST] == "192.168.1.100"
4547+
assert result["data"][CONF_PORT] == DEFAULT_HTTP_PORT
4548+
4549+
4550+
async def test_bluetooth_provision_timeout_ble_fallback_fails(
4551+
hass: HomeAssistant,
4552+
) -> None:
4553+
"""Test WiFi provisioning times out, active lookup fails, and BLE fallback also fails."""
4554+
# Inject BLE device
4555+
inject_bluetooth_service_info_bleak(hass, BLE_DISCOVERY_INFO)
4556+
4557+
result = await hass.config_entries.flow.async_init(
4558+
DOMAIN,
4559+
data=BLE_DISCOVERY_INFO,
4560+
context={"source": config_entries.SOURCE_BLUETOOTH},
4561+
)
4562+
4563+
# Confirm and scan
4564+
with patch(
4565+
"homeassistant.components.shelly.config_flow.async_scan_wifi_networks",
4566+
return_value=[{"ssid": "MyNetwork", "rssi": -50, "auth": 2}],
4567+
):
4568+
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
4569+
4570+
# Select network
4571+
result = await hass.config_entries.flow.async_configure(
4572+
result["flow_id"],
4573+
{CONF_SSID: "MyNetwork"},
4574+
)
4575+
4576+
# Provision WiFi but no zeroconf discovery, active lookup fails, BLE fallback fails
4577+
with (
4578+
patch(
4579+
"homeassistant.components.shelly.config_flow.PROVISIONING_TIMEOUT",
4580+
0.01, # Short timeout to trigger timeout path
4581+
),
4582+
patch("homeassistant.components.shelly.config_flow.async_provision_wifi"),
4583+
patch(
4584+
"homeassistant.components.shelly.config_flow.async_lookup_device_by_name",
4585+
return_value=None, # Active lookup fails
4586+
),
4587+
patch(
4588+
"homeassistant.components.shelly.config_flow.async_get_ip_from_ble",
4589+
return_value=None, # BLE fallback also fails
4590+
),
4591+
):
4592+
result = await hass.config_entries.flow.async_configure(
4593+
result["flow_id"],
4594+
{CONF_PASSWORD: "my_password"},
4595+
)
4596+
4597+
# Provisioning shows progress
4598+
assert result["type"] is FlowResultType.SHOW_PROGRESS
4599+
await hass.async_block_till_done()
4600+
4601+
# Timeout occurs, both active lookup and BLE fallback fail
4602+
result = await hass.config_entries.flow.async_configure(result["flow_id"])
4603+
4604+
# Should show provision_failed form
4605+
assert result["type"] is FlowResultType.FORM
4606+
assert result["step_id"] == "provision_failed"
4607+
4608+
# User aborts after failure
4609+
with patch(
4610+
"homeassistant.components.shelly.config_flow.async_scan_wifi_networks",
4611+
side_effect=RuntimeError("BLE device unavailable"),
4612+
):
4613+
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
4614+
4615+
assert result["type"] is FlowResultType.ABORT
4616+
assert result["reason"] == "unknown"
4617+
4618+
4619+
async def test_bluetooth_provision_timeout_ble_exception(
4620+
hass: HomeAssistant,
4621+
) -> None:
4622+
"""Test WiFi provisioning times out, active lookup fails, and BLE raises exception."""
4623+
# Inject BLE device
4624+
inject_bluetooth_service_info_bleak(hass, BLE_DISCOVERY_INFO)
4625+
4626+
result = await hass.config_entries.flow.async_init(
4627+
DOMAIN,
4628+
data=BLE_DISCOVERY_INFO,
4629+
context={"source": config_entries.SOURCE_BLUETOOTH},
4630+
)
4631+
4632+
# Confirm and scan
4633+
with patch(
4634+
"homeassistant.components.shelly.config_flow.async_scan_wifi_networks",
4635+
return_value=[{"ssid": "MyNetwork", "rssi": -50, "auth": 2}],
4636+
):
4637+
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
4638+
4639+
# Select network
4640+
result = await hass.config_entries.flow.async_configure(
4641+
result["flow_id"],
4642+
{CONF_SSID: "MyNetwork"},
4643+
)
4644+
4645+
# Provision WiFi but no zeroconf discovery, active lookup fails, BLE raises exception
4646+
with (
4647+
patch(
4648+
"homeassistant.components.shelly.config_flow.PROVISIONING_TIMEOUT",
4649+
0.01, # Short timeout to trigger timeout path
4650+
),
4651+
patch("homeassistant.components.shelly.config_flow.async_provision_wifi"),
4652+
patch(
4653+
"homeassistant.components.shelly.config_flow.async_lookup_device_by_name",
4654+
return_value=None, # Active lookup fails
4655+
),
4656+
patch(
4657+
"homeassistant.components.shelly.config_flow.ble_rpc_device",
4658+
side_effect=DeviceConnectionError, # BLE raises exception
4659+
),
4660+
):
4661+
result = await hass.config_entries.flow.async_configure(
4662+
result["flow_id"],
4663+
{CONF_PASSWORD: "my_password"},
4664+
)
4665+
4666+
# Provisioning shows progress
4667+
assert result["type"] is FlowResultType.SHOW_PROGRESS
4668+
await hass.async_block_till_done()
4669+
4670+
# Timeout occurs, both active lookup and BLE fallback fail (exception)
4671+
result = await hass.config_entries.flow.async_configure(result["flow_id"])
4672+
4673+
# Should show provision_failed form
4674+
assert result["type"] is FlowResultType.FORM
4675+
assert result["step_id"] == "provision_failed"
4676+
4677+
# User aborts after failure
4678+
with patch(
4679+
"homeassistant.components.shelly.config_flow.async_scan_wifi_networks",
4680+
side_effect=RuntimeError("BLE device unavailable"),
4681+
):
4682+
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
4683+
4684+
assert result["type"] is FlowResultType.ABORT
4685+
assert result["reason"] == "unknown"
4686+
4687+
44624688
async def test_bluetooth_provision_secure_device_both_enabled(
44634689
hass: HomeAssistant,
44644690
mock_setup_entry: AsyncMock,

0 commit comments

Comments
 (0)