Skip to content

Commit 45aecd5

Browse files
bdracoCopilot
andauthored
Fix Shelly BLE rediscovery after factory reset (home-assistant#157113)
Co-authored-by: Copilot <[email protected]>
1 parent ce11464 commit 45aecd5

File tree

2 files changed

+239
-0
lines changed

2 files changed

+239
-0
lines changed

homeassistant/components/shelly/config_flow.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from homeassistant.components.bluetooth import (
3434
BluetoothServiceInfoBleak,
3535
async_ble_device_from_address,
36+
async_clear_address_from_match_history,
3637
)
3738
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
3839
from homeassistant.const import (
@@ -395,6 +396,14 @@ async def async_step_bluetooth(
395396
if not mac:
396397
return self.async_abort(reason="invalid_discovery_info")
397398

399+
# Clear match history at the start of discovery flow.
400+
# This ensures that if the user never provisions the device and it
401+
# disappears (powers down), the discovery flow gets cleaned up,
402+
# and then the device comes back later, it can be rediscovered.
403+
# Also handles factory reset scenarios where the device may reappear
404+
# with different advertisement content (RPC-over-BLE re-enabled).
405+
async_clear_address_from_match_history(self.hass, discovery_info.address)
406+
398407
# Check if RPC-over-BLE is enabled - required for WiFi provisioning
399408
if not has_rpc_over_ble(discovery_info.manufacturer_data):
400409
LOGGER.debug(
@@ -685,6 +694,13 @@ async def _async_provision_wifi_and_wait_for_zeroconf(
685694
# Secure device after provisioning if requested (disable AP/BLE)
686695
await self._async_secure_device_after_provision(self.host, self.port)
687696

697+
# Clear match history so device can be rediscovered if factory reset
698+
# This ensures that if the device is factory reset in the future
699+
# (re-enabling BLE provisioning), it will trigger a new discovery flow
700+
if TYPE_CHECKING:
701+
assert self.ble_device is not None
702+
async_clear_address_from_match_history(self.hass, self.ble_device.address)
703+
688704
# User just provisioned this device - create entry directly without confirmation
689705
return self.async_create_entry(
690706
title=device_info["title"],

tests/components/shelly/test_config_flow.py

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,11 @@
101101
0x0BA9: bytes.fromhex("0105000b99990a70d6c297bacc")
102102
} # Flags (0x01, 0x05, 0x00), Model (0x0b, 0x99, 0x99) - unknown model ID, MAC (0x0a, 0x70, 0xd6, 0xc2, 0x97, 0xba, 0xcc)
103103

104+
BLE_MANUFACTURER_DATA_FOR_CLEAR_TEST = {
105+
0x0BA9: bytes.fromhex("0105000b30100a00eeddccbbaa")
106+
} # Flags (0x01, 0x05, 0x00), Model (0x0b, 0x30, 0x10), MAC (0x0a, 0x00, 0xee, 0xdd, 0xcc, 0xbb, 0xaa)
107+
# Device WiFi MAC: 00eeddccbbaa (little-endian) -> AABBCCDDEE00 (reversed to big-endian)
108+
104109
BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak(
105110
name="ShellyPlus2PM-C049EF8873E8",
106111
address="AA:BB:CC:DD:EE:FF",
@@ -121,6 +126,26 @@
121126
tx_power=-127,
122127
)
123128

129+
BLE_DISCOVERY_INFO_FOR_CLEAR_TEST = BluetoothServiceInfoBleak(
130+
name="ShellyPlus2PM-AABBCCDDEE00",
131+
address="AA:BB:CC:DD:EE:00",
132+
rssi=-60,
133+
manufacturer_data=BLE_MANUFACTURER_DATA_FOR_CLEAR_TEST,
134+
service_uuids=[],
135+
service_data={},
136+
source="local",
137+
device=generate_ble_device(
138+
address="AA:BB:CC:DD:EE:00",
139+
name="ShellyPlus2PM-AABBCCDDEE00",
140+
),
141+
advertisement=generate_advertisement_data(
142+
manufacturer_data=BLE_MANUFACTURER_DATA_FOR_CLEAR_TEST,
143+
),
144+
time=0,
145+
connectable=True,
146+
tx_power=-127,
147+
)
148+
124149
BLE_DISCOVERY_INFO_NO_RPC = BluetoothServiceInfoBleak(
125150
name="ShellyPlus2PM-C049EF8873E8",
126151
address="AA:BB:CC:DD:EE:FF",
@@ -2077,6 +2102,87 @@ async def test_bluetooth_discovery(
20772102
assert len(mock_setup_entry.mock_calls) == 1
20782103

20792104

2105+
async def test_bluetooth_provisioning_clears_match_history(
2106+
hass: HomeAssistant,
2107+
mock_setup_entry: AsyncMock,
2108+
mock_setup: AsyncMock,
2109+
) -> None:
2110+
"""Test bluetooth provisioning clears match history at discovery start and after successful provisioning."""
2111+
# Inject BLE device so it's available in the bluetooth scanner
2112+
inject_bluetooth_service_info_bleak(hass, BLE_DISCOVERY_INFO_FOR_CLEAR_TEST)
2113+
2114+
with patch(
2115+
"homeassistant.components.shelly.config_flow.async_clear_address_from_match_history",
2116+
) as mock_clear:
2117+
result = await hass.config_entries.flow.async_init(
2118+
DOMAIN,
2119+
data=BLE_DISCOVERY_INFO_FOR_CLEAR_TEST,
2120+
context={"source": config_entries.SOURCE_BLUETOOTH},
2121+
)
2122+
2123+
assert result["type"] is FlowResultType.FORM
2124+
assert result["step_id"] == "bluetooth_confirm"
2125+
2126+
# Confirm
2127+
with patch(
2128+
"homeassistant.components.shelly.config_flow.async_scan_wifi_networks",
2129+
return_value=[{"ssid": "MyNetwork", "rssi": -50, "auth": 2}],
2130+
):
2131+
result = await hass.config_entries.flow.async_configure(
2132+
result["flow_id"], {}
2133+
)
2134+
2135+
# Select network
2136+
result = await hass.config_entries.flow.async_configure(
2137+
result["flow_id"],
2138+
{CONF_SSID: "MyNetwork"},
2139+
)
2140+
2141+
# Reset mock to only count calls during provisioning
2142+
mock_clear.reset_mock()
2143+
2144+
# Enter password and provision
2145+
with (
2146+
patch(
2147+
"homeassistant.components.shelly.config_flow.async_provision_wifi",
2148+
),
2149+
patch(
2150+
"homeassistant.components.shelly.config_flow.async_lookup_device_by_name",
2151+
return_value=("1.1.1.1", 80),
2152+
),
2153+
patch(
2154+
"homeassistant.components.shelly.config_flow.get_info",
2155+
return_value=MOCK_DEVICE_INFO,
2156+
),
2157+
):
2158+
result = await hass.config_entries.flow.async_configure(
2159+
result["flow_id"],
2160+
{CONF_PASSWORD: "my_password"},
2161+
)
2162+
2163+
# Provisioning happens in background, shows progress
2164+
assert result["type"] is FlowResultType.SHOW_PROGRESS
2165+
await hass.async_block_till_done()
2166+
2167+
# Complete provisioning by configuring the progress step
2168+
result = await hass.config_entries.flow.async_configure(result["flow_id"])
2169+
2170+
# Provisioning should complete and create entry
2171+
assert result["type"] is FlowResultType.CREATE_ENTRY
2172+
assert result["result"].unique_id == "AABBCCDDEE00"
2173+
2174+
# Verify match history was cleared once during provisioning
2175+
# Only count calls with our test device's address to avoid interference from other tests
2176+
our_device_calls = [
2177+
call
2178+
for call in mock_clear.call_args_list
2179+
if len(call.args) > 1
2180+
and call.args[1] == BLE_DISCOVERY_INFO_FOR_CLEAR_TEST.address
2181+
]
2182+
assert our_device_calls
2183+
mock_clear.assert_called_with(hass, BLE_DISCOVERY_INFO_FOR_CLEAR_TEST.address)
2184+
2185+
20802186
@pytest.mark.usefixtures("mock_zeroconf")
20812187
async def test_bluetooth_discovery_no_rpc_over_ble(
20822188
hass: HomeAssistant,
@@ -2092,6 +2198,88 @@ async def test_bluetooth_discovery_no_rpc_over_ble(
20922198
assert result["reason"] == "invalid_discovery_info"
20932199

20942200

2201+
async def test_bluetooth_factory_reset_rediscovery(
2202+
hass: HomeAssistant,
2203+
mock_setup_entry: AsyncMock,
2204+
mock_setup: AsyncMock,
2205+
) -> None:
2206+
"""Test device can be rediscovered after factory reset when RPC-over-BLE is re-enabled."""
2207+
# First discovery: device is already provisioned (no RPC-over-BLE)
2208+
# Inject the device without RPC so it's in the bluetooth scanner
2209+
inject_bluetooth_service_info_bleak(hass, BLE_DISCOVERY_INFO_NO_RPC)
2210+
2211+
result = await hass.config_entries.flow.async_init(
2212+
DOMAIN,
2213+
data=BLE_DISCOVERY_INFO_NO_RPC,
2214+
context={"source": config_entries.SOURCE_BLUETOOTH},
2215+
)
2216+
2217+
# Should abort because RPC-over-BLE is not enabled
2218+
assert result["type"] is FlowResultType.ABORT
2219+
assert result["reason"] == "invalid_discovery_info"
2220+
2221+
# Simulate factory reset: device now advertises with RPC-over-BLE enabled
2222+
# Inject the updated advertisement
2223+
inject_bluetooth_service_info_bleak(hass, BLE_DISCOVERY_INFO)
2224+
2225+
# Second discovery: device after factory reset (RPC-over-BLE now enabled)
2226+
# Wait for automatic discovery to happen
2227+
await hass.async_block_till_done()
2228+
2229+
# Find the flow that was automatically created
2230+
flows = hass.config_entries.flow.async_progress()
2231+
assert len(flows) == 1
2232+
result = flows[0]
2233+
2234+
# Should successfully start config flow since match history was cleared
2235+
assert result["step_id"] == "bluetooth_confirm"
2236+
assert (
2237+
result["context"]["title_placeholders"]["name"] == "ShellyPlus2PM-C049EF8873E8"
2238+
)
2239+
2240+
with patch(
2241+
"homeassistant.components.shelly.config_flow.async_scan_wifi_networks",
2242+
return_value=[{"ssid": "MyNetwork", "rssi": -50, "auth": 2}],
2243+
):
2244+
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
2245+
2246+
# Select network
2247+
result = await hass.config_entries.flow.async_configure(
2248+
result["flow_id"],
2249+
{CONF_SSID: "MyNetwork"},
2250+
)
2251+
2252+
# Enter password and provision
2253+
with (
2254+
patch(
2255+
"homeassistant.components.shelly.config_flow.async_provision_wifi",
2256+
),
2257+
patch(
2258+
"homeassistant.components.shelly.config_flow.async_lookup_device_by_name",
2259+
return_value=("1.1.1.1", 80),
2260+
),
2261+
patch(
2262+
"homeassistant.components.shelly.config_flow.get_info",
2263+
return_value=MOCK_DEVICE_INFO,
2264+
),
2265+
):
2266+
result = await hass.config_entries.flow.async_configure(
2267+
result["flow_id"],
2268+
{CONF_PASSWORD: "my_password"},
2269+
)
2270+
2271+
# Provisioning happens in background
2272+
assert result["type"] is FlowResultType.SHOW_PROGRESS
2273+
await hass.async_block_till_done()
2274+
2275+
# Complete provisioning
2276+
result = await hass.config_entries.flow.async_configure(result["flow_id"])
2277+
2278+
# Provisioning should complete and create entry
2279+
assert result["type"] is FlowResultType.CREATE_ENTRY
2280+
assert result["result"].unique_id == "C049EF8873E8"
2281+
2282+
20952283
@pytest.mark.usefixtures("mock_zeroconf")
20962284
async def test_bluetooth_discovery_invalid_name(
20972285
hass: HomeAssistant,
@@ -2184,6 +2372,41 @@ async def test_bluetooth_discovery_already_configured(
21842372
assert result["reason"] == "already_configured"
21852373

21862374

2375+
async def test_bluetooth_discovery_already_configured_clears_match_history(
2376+
hass: HomeAssistant,
2377+
) -> None:
2378+
"""Test bluetooth discovery clears match history when device already configured."""
2379+
# Inject BLE device so it's available in the bluetooth scanner
2380+
inject_bluetooth_service_info_bleak(hass, BLE_DISCOVERY_INFO)
2381+
2382+
entry = MockConfigEntry(
2383+
domain=DOMAIN,
2384+
unique_id="C049EF8873E8", # MAC from device name - uppercase no colons
2385+
data={
2386+
CONF_HOST: "1.1.1.1",
2387+
CONF_MODEL: MODEL_PLUS_2PM,
2388+
CONF_SLEEP_PERIOD: 0,
2389+
CONF_GEN: 2,
2390+
},
2391+
)
2392+
entry.add_to_hass(hass)
2393+
2394+
with patch(
2395+
"homeassistant.components.shelly.config_flow.async_clear_address_from_match_history"
2396+
) as mock_clear:
2397+
result = await hass.config_entries.flow.async_init(
2398+
DOMAIN,
2399+
data=BLE_DISCOVERY_INFO,
2400+
context={"source": config_entries.SOURCE_BLUETOOTH},
2401+
)
2402+
2403+
assert result["type"] is FlowResultType.ABORT
2404+
assert result["reason"] == "already_configured"
2405+
2406+
# Verify match history was cleared to allow rediscovery if factory reset
2407+
mock_clear.assert_called_once_with(hass, BLE_DISCOVERY_INFO.address)
2408+
2409+
21872410
@pytest.mark.usefixtures("mock_zeroconf")
21882411
async def test_bluetooth_discovery_no_ble_device(
21892412
hass: HomeAssistant,

0 commit comments

Comments
 (0)