diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index afa3b457578..bb08d835fca 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -42,7 +42,13 @@ from homeassistant.components.bluetooth import ( async_clear_address_from_match_history, async_discovered_service_info, ) -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + SOURCE_BLUETOOTH, + SOURCE_ZEROCONF, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.const import ( CONF_DEVICE, CONF_HOST, @@ -109,6 +115,7 @@ BLE_SCANNER_OPTIONS = [ INTERNAL_WIFI_AP_IP = "192.168.33.1" MANUAL_ENTRY_STRING = "manual" +DISCOVERY_SOURCES = {SOURCE_BLUETOOTH, SOURCE_ZEROCONF} async def async_get_ip_from_ble(ble_device: BLEDevice) -> str | None: @@ -445,11 +452,13 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): discovered_devices.update(await self._async_discover_zeroconf_devices()) # Filter out already-configured devices (excluding ignored) + # and devices with active discovery flows (already being offered to user) current_ids = self._async_current_ids(include_ignore=False) + in_progress_macs = self._async_get_in_progress_discovery_macs() discovered_devices = { mac: device for mac, device in discovered_devices.items() - if mac not in current_ids + if mac not in current_ids and mac not in in_progress_macs } # Store discovered devices for use in selection @@ -575,6 +584,22 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): step_id="credentials", data_schema=vol.Schema(schema), errors=errors ) + @callback + def _async_get_in_progress_discovery_macs(self) -> set[str]: + """Get MAC addresses of devices with active discovery flows. + + Returns MAC addresses from bluetooth and zeroconf discovery flows + that are already in progress, so they can be filtered from the + user step device list (since they're already being offered). + """ + return { + mac + for flow in self._async_in_progress(include_uninitialized=True) + if flow["flow_id"] != self.flow_id + and flow["context"].get("source") in DISCOVERY_SOURCES + and (mac := flow["context"].get("unique_id")) + } + def _abort_idle_ble_flows(self, mac: str) -> None: """Abort idle BLE provisioning flows for this device. diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 0d31833256a..526ebfa2919 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -1805,20 +1805,12 @@ async def test_user_flow_select_ble_device( # Mock empty zeroconf discovery mock_discovery.return_value = [] - # Inject BLE device with RPC-over-BLE enabled + # Inject BLE device with RPC-over-BLE enabled (no discovery flow created) inject_bluetooth_service_info_bleak(hass, BLE_DISCOVERY_INFO_GEN3) # Wait for bluetooth discovery to process await hass.async_block_till_done() - # Start a bluetooth discovery flow manually to simulate auto-discovery - ble_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_BLUETOOTH}, - data=BLE_DISCOVERY_INFO_GEN3, - ) - ble_flow_id = ble_result["flow_id"] - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -1826,7 +1818,7 @@ async def test_user_flow_select_ble_device( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - # Select the BLE device - should take over from the discovery flow + # Select the BLE device result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_DEVICE: "CCBA97C2D670"}, # MAC from manufacturer data @@ -1891,9 +1883,59 @@ async def test_user_flow_select_ble_device( assert result["result"].unique_id == "CCBA97C2D670" assert result["title"] == "Test BLE Device" - # Verify the original bluetooth discovery flow no longer exists - flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) - assert not any(f["flow_id"] == ble_flow_id for f in flows) + +async def test_user_flow_filters_devices_with_active_discovery_flows( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_rpc_device: Mock, +) -> None: + """Test user flow filters out devices that already have discovery flows.""" + # Mock empty zeroconf discovery + mock_discovery.return_value = [] + + # Inject BLE device with RPC-over-BLE enabled + inject_bluetooth_service_info_bleak(hass, BLE_DISCOVERY_INFO_GEN3) + + # Wait for bluetooth discovery to process + await hass.async_block_till_done() + + # Start a bluetooth discovery flow to simulate auto-discovery + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=BLE_DISCOVERY_INFO_GEN3, + ) + + # Start a user flow - should go to manual entry since the only + # discovered device already has an active discovery flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Should go directly to manual entry since the BLE device is filtered + # out (it already has an active discovery flow being offered to the user) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user_manual" + + # Complete the manual entry flow to reach terminal state + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={"mac": "aabbccddeeff", "model": MODEL_PLUS_2PM, "gen": 2}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.10.10.10"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Test name" + assert result["data"] == { + CONF_HOST: "10.10.10.10", + CONF_PORT: DEFAULT_HTTP_PORT, + CONF_SLEEP_PERIOD: 0, + CONF_MODEL: MODEL_PLUS_2PM, + CONF_GEN: 2, + } @pytest.mark.parametrize(