Skip to content

Commit 610183c

Browse files
authored
Fix Improv BLE factory reset rediscovery (home-assistant#154354)
1 parent b7718f6 commit 610183c

File tree

2 files changed

+135
-1
lines changed

2 files changed

+135
-1
lines changed

homeassistant/components/improv_ble/config_flow.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,11 +138,23 @@ def _abort_if_provisioned(self) -> None:
138138
_LOGGER.debug(
139139
(
140140
"Aborting improv flow, device with bluetooth address '%s' is "
141-
"already provisioned: %s"
141+
"already provisioned: %s; clearing match history to allow "
142+
"rediscovery if device is factory reset"
142143
),
143144
self._discovery_info.address,
144145
improv_service_data.state,
145146
)
147+
# Clear match history so device can be rediscovered if factory reset.
148+
# This is safe to do on every abort because:
149+
# 1. While device stays provisioned, the Bluetooth matcher won't trigger
150+
# new discoveries since the advertisement content hasn't changed
151+
# 2. If device is factory reset (state changes to authorized), the
152+
# matcher will see new content and trigger discovery since we cleared
153+
# the history
154+
# 3. No ongoing monitoring or callbacks - zero performance overhead
155+
bluetooth.async_clear_address_from_match_history(
156+
self.hass, self._discovery_info.address
157+
)
146158
raise AbortFlow("already_provisioned")
147159

148160
@callback
@@ -180,6 +192,14 @@ async def async_step_bluetooth(
180192
self._abort_if_unique_id_configured()
181193
self._abort_if_provisioned()
182194

195+
# Clear match history at the start of discovery flow.
196+
# This ensures that if the user never provisions the device and it
197+
# disappears (powers down), the discovery flow gets cleaned up,
198+
# and then the device comes back later, it can be rediscovered.
199+
bluetooth.async_clear_address_from_match_history(
200+
self.hass, discovery_info.address
201+
)
202+
183203
self._remove_bluetooth_callback = bluetooth.async_register_callback(
184204
self.hass,
185205
self._async_update_ble,
@@ -317,6 +337,13 @@ async def _do_provision() -> None:
317337
return
318338
else:
319339
_LOGGER.debug("Provision successful, redirect URL: %s", redirect_url)
340+
# Clear match history so device can be rediscovered if factory reset.
341+
# This ensures that if the device is factory reset in the future,
342+
# it will trigger a new discovery flow.
343+
assert self._discovery_info is not None
344+
bluetooth.async_clear_address_from_match_history(
345+
self.hass, self._discovery_info.address
346+
)
320347
# Abort all flows in progress with same unique ID
321348
for flow in self._async_in_progress(include_uninitialized=True):
322349
flow_unique_id = flow["context"].get("unique_id")

tests/components/improv_ble/test_config_flow.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
)
2424

2525
from tests.common import MockConfigEntry
26+
from tests.components.bluetooth import inject_bluetooth_service_info_bleak
2627

2728
IMPROV_BLE = "homeassistant.components.improv_ble"
2829

@@ -179,6 +180,112 @@ async def test_bluetooth_step_provisioned_device_2(hass: HomeAssistant) -> None:
179180
assert len(hass.config_entries.flow.async_progress_by_handler("improv_ble")) == 0
180181

181182

183+
async def test_bluetooth_step_provisioned_no_rediscovery(hass: HomeAssistant) -> None:
184+
"""Test that provisioned device is not rediscovered while it stays provisioned."""
185+
# Step 1: Inject provisioned device advertisement (triggers discovery, aborts)
186+
inject_bluetooth_service_info_bleak(hass, PROVISIONED_IMPROV_BLE_DISCOVERY_INFO)
187+
await hass.async_block_till_done()
188+
189+
# Verify flow was aborted
190+
flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN)
191+
assert len(flows) == 0
192+
193+
# Step 2: Inject same provisioned advertisement again
194+
# This should NOT trigger a new discovery because the content hasn't changed
195+
# even though we cleared the match history
196+
inject_bluetooth_service_info_bleak(hass, PROVISIONED_IMPROV_BLE_DISCOVERY_INFO)
197+
await hass.async_block_till_done()
198+
199+
# Verify no new flow was started
200+
flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN)
201+
assert len(flows) == 0
202+
203+
204+
async def test_bluetooth_step_factory_reset_rediscovery(hass: HomeAssistant) -> None:
205+
"""Test that factory reset device can be rediscovered."""
206+
# Start a flow manually with provisioned device to ensure improv_ble is loaded
207+
result = await hass.config_entries.flow.async_init(
208+
DOMAIN,
209+
context={"source": config_entries.SOURCE_BLUETOOTH},
210+
data=PROVISIONED_IMPROV_BLE_DISCOVERY_INFO,
211+
)
212+
assert result["type"] is FlowResultType.ABORT
213+
assert result["reason"] == "already_provisioned"
214+
215+
# Now the match history has been cleared by the config flow
216+
# Inject authorized device advertisement - should trigger new discovery
217+
inject_bluetooth_service_info_bleak(hass, IMPROV_BLE_DISCOVERY_INFO)
218+
await hass.async_block_till_done()
219+
220+
# Verify discovery proceeds (new flow started)
221+
flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN)
222+
assert len(flows) == 1
223+
assert flows[0]["step_id"] == "bluetooth_confirm"
224+
225+
226+
async def test_bluetooth_rediscovery_after_successful_provision(
227+
hass: HomeAssistant,
228+
) -> None:
229+
"""Test that device can be rediscovered after successful provisioning."""
230+
# Start provisioning flow
231+
result = await hass.config_entries.flow.async_init(
232+
DOMAIN,
233+
context={"source": config_entries.SOURCE_BLUETOOTH},
234+
data=IMPROV_BLE_DISCOVERY_INFO,
235+
)
236+
assert result["type"] is FlowResultType.FORM
237+
assert result["step_id"] == "bluetooth_confirm"
238+
239+
# Confirm bluetooth setup
240+
result = await hass.config_entries.flow.async_configure(result["flow_id"])
241+
assert result["type"] is FlowResultType.FORM
242+
assert result["step_id"] == "bluetooth_confirm"
243+
244+
# Start provisioning
245+
with patch(
246+
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False
247+
):
248+
result = await hass.config_entries.flow.async_configure(
249+
result["flow_id"],
250+
{CONF_ADDRESS: IMPROV_BLE_DISCOVERY_INFO.address},
251+
)
252+
assert result["type"] is FlowResultType.FORM
253+
assert result["step_id"] == "provision"
254+
255+
# Complete provisioning successfully
256+
with (
257+
patch(
258+
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.need_authorization",
259+
return_value=False,
260+
),
261+
patch(
262+
f"{IMPROV_BLE}.config_flow.ImprovBLEClient.provision",
263+
return_value=None,
264+
),
265+
):
266+
result = await hass.config_entries.flow.async_configure(
267+
result["flow_id"], {"ssid": "TestNetwork", "password": "secret"}
268+
)
269+
assert result["type"] is FlowResultType.SHOW_PROGRESS
270+
assert result["progress_action"] == "provisioning"
271+
assert result["step_id"] == "do_provision"
272+
await hass.async_block_till_done()
273+
274+
result = await hass.config_entries.flow.async_configure(result["flow_id"])
275+
assert result["type"] is FlowResultType.ABORT
276+
assert result["reason"] == "provision_successful"
277+
278+
# Now inject the same device again (simulating factory reset)
279+
# The match history was cleared after successful provision, so it should be rediscovered
280+
inject_bluetooth_service_info_bleak(hass, IMPROV_BLE_DISCOVERY_INFO)
281+
await hass.async_block_till_done()
282+
283+
# Verify new discovery flow was created
284+
flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN)
285+
assert len(flows) == 1
286+
assert flows[0]["step_id"] == "bluetooth_confirm"
287+
288+
182289
async def test_bluetooth_step_success(hass: HomeAssistant) -> None:
183290
"""Test bluetooth step success path."""
184291
result = await hass.config_entries.flow.async_init(

0 commit comments

Comments
 (0)