From 3cc94f7d6a0364e7347bc526d5e9251f8f2b8169 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 15 Jun 2020 13:38:38 +0200 Subject: [PATCH] ConfigFlow default discovery without unique ID (#36754) --- homeassistant/components/abode/manifest.json | 5 +- .../components/adguard/config_flow.py | 12 +- .../components/agent_dvr/config_flow.py | 12 +- .../components/almond/config_flow.py | 4 +- .../components/arcam_fmj/config_flow.py | 10 +- .../components/brother/config_flow.py | 10 +- .../components/daikin/config_flow.py | 2 +- .../components/deconz/config_flow.py | 14 +- .../components/esphome/config_flow.py | 2 +- .../components/flick_electric/config_flow.py | 2 +- .../components/freebox/config_flow.py | 4 +- .../components/fritzbox/config_flow.py | 8 +- .../components/guardian/config_flow.py | 2 +- homeassistant/components/ipp/config_flow.py | 17 +- homeassistant/components/mqtt/config_flow.py | 4 +- .../components/opentherm_gw/config_flow.py | 4 +- homeassistant/components/plex/config_flow.py | 4 +- .../components/samsungtv/config_flow.py | 10 +- .../components/tellduslive/config_flow.py | 11 +- .../components/tellduslive/strings.json | 4 +- .../components/tradfri/config_flow.py | 10 +- homeassistant/config_entries.py | 63 +++++- homeassistant/generated/zeroconf.py | 2 + homeassistant/helpers/config_entry_flow.py | 25 ++- .../helpers/config_entry_oauth2_flow.py | 12 +- script/hassfest/config_flow.py | 50 +++-- script/hassfest/ssdp.py | 16 -- script/hassfest/zeroconf.py | 37 +--- tests/components/almond/test_config_flow.py | 2 +- tests/components/ipp/test_config_flow.py | 14 +- .../tellduslive/test_config_flow.py | 9 +- tests/test_config_entries.py | 197 +++++++++++++++++- 32 files changed, 408 insertions(+), 170 deletions(-) diff --git a/homeassistant/components/abode/manifest.json b/homeassistant/components/abode/manifest.json index c8dace4e87b..d59ddd6217f 100644 --- a/homeassistant/components/abode/manifest.json +++ b/homeassistant/components/abode/manifest.json @@ -4,5 +4,8 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/abode", "requirements": ["abodepy==0.19.0"], - "codeowners": ["@shred86"] + "codeowners": ["@shred86"], + "homekit": { + "models": ["Abode", "Iota"] + } } diff --git a/homeassistant/components/adguard/config_flow.py b/homeassistant/components/adguard/config_flow.py index e2a226eb4ce..15e8192df06 100644 --- a/homeassistant/components/adguard/config_flow.py +++ b/homeassistant/components/adguard/config_flow.py @@ -105,7 +105,7 @@ class AdGuardHomeFlowHandler(ConfigFlow): }, ) - async def async_step_hassio(self, user_input=None): + async def async_step_hassio(self, discovery_info): """Prepare configuration for a Hass.io AdGuard Home add-on. This flow is triggered by the discovery component. @@ -113,14 +113,14 @@ class AdGuardHomeFlowHandler(ConfigFlow): entries = self._async_current_entries() if not entries: - self._hassio_discovery = user_input + self._hassio_discovery = discovery_info return await self.async_step_hassio_confirm() cur_entry = entries[0] if ( - cur_entry.data[CONF_HOST] == user_input[CONF_HOST] - and cur_entry.data[CONF_PORT] == user_input[CONF_PORT] + cur_entry.data[CONF_HOST] == discovery_info[CONF_HOST] + and cur_entry.data[CONF_PORT] == discovery_info[CONF_PORT] ): return self.async_abort(reason="single_instance_allowed") @@ -133,8 +133,8 @@ class AdGuardHomeFlowHandler(ConfigFlow): cur_entry, data={ **cur_entry.data, - CONF_HOST: user_input[CONF_HOST], - CONF_PORT: user_input[CONF_PORT], + CONF_HOST: discovery_info[CONF_HOST], + CONF_PORT: discovery_info[CONF_PORT], }, ) diff --git a/homeassistant/components/agent_dvr/config_flow.py b/homeassistant/components/agent_dvr/config_flow.py index a5c98ade1cb..cc1d6355f3f 100644 --- a/homeassistant/components/agent_dvr/config_flow.py +++ b/homeassistant/components/agent_dvr/config_flow.py @@ -23,13 +23,13 @@ class AgentFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Initialize the Agent config flow.""" self.device_config = {} - async def async_step_user(self, info=None): + async def async_step_user(self, user_input=None): """Handle an Agent config flow.""" errors = {} - if info is not None: - host = info[CONF_HOST] - port = info[CONF_PORT] + if user_input is not None: + host = user_input[CONF_HOST] + port = user_input[CONF_PORT] server_origin = generate_url(host, port) agent_client = Agent(server_origin, async_get_clientsession(self.hass)) @@ -48,8 +48,8 @@ class AgentFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured( updates={ - CONF_HOST: info[CONF_HOST], - CONF_PORT: info[CONF_PORT], + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], SERVER_URL: server_origin, } ) diff --git a/homeassistant/components/almond/config_flow.py b/homeassistant/components/almond/config_flow.py index b1eb506270b..73dc85c5fd0 100644 --- a/homeassistant/components/almond/config_flow.py +++ b/homeassistant/components/almond/config_flow.py @@ -94,12 +94,12 @@ class AlmondFlowHandler(config_entry_oauth2_flow.AbstractOAuth2FlowHandler): data={"type": TYPE_LOCAL, "host": user_input["host"]}, ) - async def async_step_hassio(self, user_input=None): + async def async_step_hassio(self, discovery_info): """Receive a Hass.io discovery.""" if self._async_current_entries(): return self.async_abort(reason="already_setup") - self.hassio_discovery = user_input + self.hassio_discovery = discovery_info return await self.async_step_hassio_confirm() diff --git a/homeassistant/components/arcam_fmj/config_flow.py b/homeassistant/components/arcam_fmj/config_flow.py index debee11bbc4..d6cf1c02d3b 100644 --- a/homeassistant/components/arcam_fmj/config_flow.py +++ b/homeassistant/components/arcam_fmj/config_flow.py @@ -45,21 +45,21 @@ class ArcamFmjFlowHandler(config_entries.ConfigFlow): title=f"{DEFAULT_NAME} ({host})", data={CONF_HOST: host, CONF_PORT: port}, ) - async def async_step_user(self, user_info=None): + async def async_step_user(self, user_input=None): """Handle a discovered device.""" errors = {} - if user_info is not None: + if user_input is not None: uuid = await get_uniqueid_from_host( - async_get_clientsession(self.hass), user_info[CONF_HOST] + async_get_clientsession(self.hass), user_input[CONF_HOST] ) if uuid: await self._async_set_unique_id_and_update( - user_info[CONF_HOST], user_info[CONF_PORT], uuid + user_input[CONF_HOST], user_input[CONF_PORT], uuid ) return await self._async_check_and_create( - user_info[CONF_HOST], user_info[CONF_PORT] + user_input[CONF_HOST], user_input[CONF_PORT] ) fields = { diff --git a/homeassistant/components/brother/config_flow.py b/homeassistant/components/brother/config_flow.py index e50105e0b27..8b3a9539cc3 100644 --- a/homeassistant/components/brother/config_flow.py +++ b/homeassistant/components/brother/config_flow.py @@ -69,16 +69,18 @@ class BrotherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - async def async_step_zeroconf(self, user_input=None): + async def async_step_zeroconf(self, discovery_info): """Handle zeroconf discovery.""" - if user_input is None: + if discovery_info is None: return self.async_abort(reason="connection_error") - if not user_input.get("name") or not user_input["name"].startswith("Brother"): + if not discovery_info.get("name") or not discovery_info["name"].startswith( + "Brother" + ): return self.async_abort(reason="not_brother_printer") # Hostname is format: brother.local. - self.host = user_input["hostname"].rstrip(".") + self.host = discovery_info["hostname"].rstrip(".") self.brother = Brother(self.host) try: diff --git a/homeassistant/components/daikin/config_flow.py b/homeassistant/components/daikin/config_flow.py index 67411bfdff0..a26e2e1c05b 100644 --- a/homeassistant/components/daikin/config_flow.py +++ b/homeassistant/components/daikin/config_flow.py @@ -128,7 +128,7 @@ class FlowHandler(config_entries.ConfigFlow): async def async_step_zeroconf(self, discovery_info): """Prepare configuration for a discovered Daikin device.""" - _LOGGER.debug("Zeroconf discovery_info: %s", discovery_info) + _LOGGER.debug("Zeroconf user_input: %s", discovery_info) devices = Discovery.poll(discovery_info[CONF_HOST]) await self.async_set_unique_id(next(iter(devices.values()))[KEY_MAC]) self._abort_if_unique_id_configured() diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index f52a18bbd07..f3ae5682131 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -205,25 +205,25 @@ class DeconzFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_link() - async def async_step_hassio(self, user_input=None): + async def async_step_hassio(self, discovery_info): """Prepare configuration for a Hass.io deCONZ bridge. This flow is triggered by the discovery component. """ - LOGGER.debug("deCONZ HASSIO discovery %s", pformat(user_input)) + LOGGER.debug("deCONZ HASSIO discovery %s", pformat(discovery_info)) - self.bridge_id = normalize_bridge_id(user_input[CONF_SERIAL]) + self.bridge_id = normalize_bridge_id(discovery_info[CONF_SERIAL]) await self.async_set_unique_id(self.bridge_id) self._abort_if_unique_id_configured( updates={ - CONF_HOST: user_input[CONF_HOST], - CONF_PORT: user_input[CONF_PORT], - CONF_API_KEY: user_input[CONF_API_KEY], + CONF_HOST: discovery_info[CONF_HOST], + CONF_PORT: discovery_info[CONF_PORT], + CONF_API_KEY: discovery_info[CONF_API_KEY], } ) - self._hassio_discovery = user_input + self._hassio_discovery = discovery_info return await self.async_step_hassio_confirm() diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index cb9b7958efa..2ae380c9a7c 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -29,7 +29,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): async def async_step_user( self, user_input: Optional[ConfigType] = None, error: Optional[str] = None - ): + ): # pylint: disable=arguments-differ """Handle a flow initialized by the user.""" if user_input is not None: return await self._async_authenticate_or_add(user_input) diff --git a/homeassistant/components/flick_electric/config_flow.py b/homeassistant/components/flick_electric/config_flow.py index 2106a6f8d62..8e6020ebd8a 100644 --- a/homeassistant/components/flick_electric/config_flow.py +++ b/homeassistant/components/flick_electric/config_flow.py @@ -55,7 +55,7 @@ class FlickConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): else: return token is not None - async def async_step_user(self, user_input): + async def async_step_user(self, user_input=None): """Handle gathering login info.""" errors = {} if user_input is not None: diff --git a/homeassistant/components/freebox/config_flow.py b/homeassistant/components/freebox/config_flow.py index b2d1a0ab771..9cef6aa0c38 100644 --- a/homeassistant/components/freebox/config_flow.py +++ b/homeassistant/components/freebox/config_flow.py @@ -105,6 +105,6 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Import a config entry.""" return await self.async_step_user(user_input) - async def async_step_discovery(self, user_input=None): + async def async_step_discovery(self, discovery_info): """Initialize step from discovery.""" - return await self.async_step_user(user_input) + return await self.async_step_user(discovery_info) diff --git a/homeassistant/components/fritzbox/config_flow.py b/homeassistant/components/fritzbox/config_flow.py index 25a81333bd6..6f4befab8ff 100644 --- a/homeassistant/components/fritzbox/config_flow.py +++ b/homeassistant/components/fritzbox/config_flow.py @@ -110,12 +110,12 @@ class FritzboxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=DATA_SCHEMA_USER, errors=errors ) - async def async_step_ssdp(self, user_input): + async def async_step_ssdp(self, discovery_info): """Handle a flow initialized by discovery.""" - host = urlparse(user_input[ATTR_SSDP_LOCATION]).hostname + host = urlparse(discovery_info[ATTR_SSDP_LOCATION]).hostname self.context[CONF_HOST] = host - uuid = user_input.get(ATTR_UPNP_UDN) + uuid = discovery_info.get(ATTR_UPNP_UDN) if uuid: if uuid.startswith("uuid:"): uuid = uuid[5:] @@ -134,7 +134,7 @@ class FritzboxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="already_configured") self._host = host - self._name = user_input.get(ATTR_UPNP_FRIENDLY_NAME) or host + self._name = discovery_info.get(ATTR_UPNP_FRIENDLY_NAME) or host self.context["title_placeholders"] = {"name": self._name} return await self.async_step_confirm() diff --git a/homeassistant/components/guardian/config_flow.py b/homeassistant/components/guardian/config_flow.py index dae8fafb1e0..769344e3b01 100644 --- a/homeassistant/components/guardian/config_flow.py +++ b/homeassistant/components/guardian/config_flow.py @@ -80,7 +80,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): title=info[CONF_UID], data={CONF_UID: info["uid"], **user_input} ) - async def async_step_zeroconf(self, discovery_info=None): + async def async_step_zeroconf(self, discovery_info): """Handle the configuration via zeroconf.""" if discovery_info is None: return self.async_abort(reason="connection_error") diff --git a/homeassistant/components/ipp/config_flow.py b/homeassistant/components/ipp/config_flow.py index ba12d7ec8e2..671bb2dd4cd 100644 --- a/homeassistant/components/ipp/config_flow.py +++ b/homeassistant/components/ipp/config_flow.py @@ -152,16 +152,17 @@ class IPPFlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.debug( "Unable to determine unique id from discovery info and IPP response" ) - return self.async_abort(reason="unique_id_required") - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured( - updates={ - CONF_HOST: self.discovery_info[CONF_HOST], - CONF_NAME: self.discovery_info[CONF_NAME], - }, - ) + if unique_id: + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: self.discovery_info[CONF_HOST], + CONF_NAME: self.discovery_info[CONF_NAME], + }, + ) + await self._async_handle_discovery_without_unique_id() return await self.async_step_zeroconf_confirm() async def async_step_zeroconf_confirm( diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index b0ba58158e0..76c1889e629 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -74,12 +74,12 @@ class FlowHandler(config_entries.ConfigFlow): return self.async_create_entry(title="configuration.yaml", data={}) - async def async_step_hassio(self, user_input=None): + async def async_step_hassio(self, discovery_info): """Receive a Hass.io discovery.""" if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") - self._hassio_discovery = user_input + self._hassio_discovery = discovery_info return await self.async_step_hassio_confirm() diff --git a/homeassistant/components/opentherm_gw/config_flow.py b/homeassistant/components/opentherm_gw/config_flow.py index dc1b943686f..4afc508b8ee 100644 --- a/homeassistant/components/opentherm_gw/config_flow.py +++ b/homeassistant/components/opentherm_gw/config_flow.py @@ -68,9 +68,9 @@ class OpenThermGwConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self._show_form() - async def async_step_user(self, info=None): + async def async_step_user(self, user_input=None): """Handle manual initiation of the config flow.""" - return await self.async_step_init(info) + return await self.async_step_init(user_input) async def async_step_import(self, import_config): """ diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index 5057b535ea6..ffadba63d3a 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -96,7 +96,9 @@ class PlexFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.client_id = None self._manual = False - async def async_step_user(self, user_input=None, errors=None): + async def async_step_user( + self, user_input=None, errors=None + ): # pylint: disable=arguments-differ """Handle a flow initialized by the user.""" if user_input is not None: return await self.async_step_plex_website_auth() diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index 95283d9606c..b939479a45a 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -116,17 +116,17 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) - async def async_step_ssdp(self, user_input=None): + async def async_step_ssdp(self, discovery_info): """Handle a flow initialized by discovery.""" - host = urlparse(user_input[ATTR_SSDP_LOCATION]).hostname + host = urlparse(discovery_info[ATTR_SSDP_LOCATION]).hostname ip_address = await self.hass.async_add_executor_job(_get_ip, host) self._host = host self._ip = self.context[CONF_IP_ADDRESS] = ip_address - self._manufacturer = user_input.get(ATTR_UPNP_MANUFACTURER) - self._model = user_input.get(ATTR_UPNP_MODEL_NAME) + self._manufacturer = discovery_info.get(ATTR_UPNP_MANUFACTURER) + self._model = discovery_info.get(ATTR_UPNP_MODEL_NAME) self._name = f"Samsung {self._model}" - self._id = user_input.get(ATTR_UPNP_UDN) + self._id = discovery_info.get(ATTR_UPNP_UDN) self._title = self._model # probably access denied diff --git a/homeassistant/components/tellduslive/config_flow.py b/homeassistant/components/tellduslive/config_flow.py index 893f3b80456..36bc89d115a 100644 --- a/homeassistant/components/tellduslive/config_flow.py +++ b/homeassistant/components/tellduslive/config_flow.py @@ -114,13 +114,14 @@ class FlowHandler(config_entries.ConfigFlow): }, ) - async def async_step_discovery(self, user_input): + async def async_step_discovery(self, discovery_info): """Run when a Tellstick is discovered.""" + await self._async_handle_discovery_without_unique_id() - _LOGGER.info("Discovered tellstick device: %s", user_input) - if supports_local_api(user_input[1]): - _LOGGER.info("%s support local API", user_input[1]) - self._hosts.append(user_input[0]) + _LOGGER.info("Discovered tellstick device: %s", discovery_info) + if supports_local_api(discovery_info[1]): + _LOGGER.info("%s support local API", discovery_info[1]) + self._hosts.append(discovery_info[0]) return await self.async_step_user() diff --git a/homeassistant/components/tellduslive/strings.json b/homeassistant/components/tellduslive/strings.json index c29916be936..aabf00bc1b2 100644 --- a/homeassistant/components/tellduslive/strings.json +++ b/homeassistant/components/tellduslive/strings.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_setup": "TelldusLive is already configured", + "already_configured": "TelldusLive is already configured", "authorize_url_fail": "Unknown error generating an authorize url.", "authorize_url_timeout": "Timeout generating authorize url.", "unknown": "Unknown error occurred" @@ -22,4 +22,4 @@ } } } -} \ No newline at end of file +} diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index 2ade04cff55..e438fd20170 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -82,12 +82,12 @@ class FlowHandler(config_entries.ConfigFlow): step_id="auth", data_schema=vol.Schema(fields), errors=errors ) - async def async_step_homekit(self, user_input): + async def async_step_homekit(self, discovery_info): """Handle homekit discovery.""" - await self.async_set_unique_id(user_input["properties"]["id"]) - self._abort_if_unique_id_configured({CONF_HOST: user_input["host"]}) + await self.async_set_unique_id(discovery_info["properties"]["id"]) + self._abort_if_unique_id_configured({CONF_HOST: discovery_info["host"]}) - host = user_input["host"] + host = discovery_info["host"] for entry in self._async_current_entries(): if entry.data[CONF_HOST] != host: @@ -96,7 +96,7 @@ class FlowHandler(config_entries.ConfigFlow): # Backwards compat, we update old entries if not entry.unique_id: self.hass.config_entries.async_update_entry( - entry, unique_id=user_input["properties"]["id"] + entry, unique_id=discovery_info["properties"]["id"] ) return self.async_abort(reason="already_configured") diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 8dc88aa4da9..2f57cb50543 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -21,6 +21,8 @@ _LOGGER = logging.getLogger(__name__) _UNDEF: dict = {} SOURCE_DISCOVERY = "discovery" +SOURCE_HASSIO = "hassio" +SOURCE_HOMEKIT = "homekit" SOURCE_IMPORT = "import" SOURCE_INTEGRATION_DISCOVERY = "integration_discovery" SOURCE_SSDP = "ssdp" @@ -62,6 +64,7 @@ ENTRY_STATE_FAILED_UNLOAD = "failed_unload" UNRECOVERABLE_STATES = (ENTRY_STATE_MIGRATION_ERROR, ENTRY_STATE_FAILED_UNLOAD) +DEFAULT_DISCOVERY_UNIQUE_ID = "default_discovery_unique_id" DISCOVERY_NOTIFICATION_ID = "config_entry_discovery" DISCOVERY_SOURCES = ( SOURCE_SSDP, @@ -466,6 +469,10 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): ): self.async_abort(progress_flow["flow_id"]) + # Reset unique ID when the default discovery ID has been used + if flow.unique_id == DEFAULT_DISCOVERY_UNIQUE_ID: + await flow.async_set_unique_id(None) + # Find existing entry. for check_entry in self.config_entries.async_entries(result["handler"]): if check_entry.unique_id == flow.unique_id: @@ -857,12 +864,16 @@ class ConfigFlow(data_entry_flow.FlowHandler): raise data_entry_flow.AbortFlow("already_configured") async def async_set_unique_id( - self, unique_id: str, *, raise_on_progress: bool = True + self, unique_id: Optional[str] = None, *, raise_on_progress: bool = True ) -> Optional[ConfigEntry]: """Set a unique ID for the config flow. Returns optionally existing config entry with same ID. """ + if unique_id is None: + self.context["unique_id"] = None # pylint: disable=no-member + return None + if raise_on_progress: for progress in self._async_in_progress(): if progress["context"].get("unique_id") == unique_id: @@ -870,6 +881,13 @@ class ConfigFlow(data_entry_flow.FlowHandler): self.context["unique_id"] = unique_id # pylint: disable=no-member + # Abort discoveries done using the default discovery unique id + assert self.hass is not None + if unique_id != DEFAULT_DISCOVERY_UNIQUE_ID: + for progress in self._async_in_progress(): + if progress["context"].get("unique_id") == DEFAULT_DISCOVERY_UNIQUE_ID: + self.hass.config_entries.flow.async_abort(progress["flow_id"]) + for entry in self._async_current_entries(): if entry.unique_id == unique_id: return entry @@ -911,6 +929,49 @@ class ConfigFlow(data_entry_flow.FlowHandler): """Rediscover a config entry by it's unique_id.""" return self.async_abort(reason="not_implemented") + async def async_step_user( + self, user_input: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """Handle a flow initiated by the user.""" + return self.async_abort(reason="not_implemented") + + async def _async_handle_discovery_without_unique_id(self) -> None: + """Mark this flow discovered, without a unique identifier. + + If a flow initiated by discovery, doesn't have a unique ID, this can + be used alternatively. It will ensure only 1 flow is started and only + when the handler has no existing config entries. + + It ensures that the discovery can be ignored by the user. + """ + if self.unique_id is not None: + return + + # Abort if the handler has config entries already + if self._async_current_entries(): + raise data_entry_flow.AbortFlow("already_configured") + + # Use an special unique id to differentiate + await self.async_set_unique_id(DEFAULT_DISCOVERY_UNIQUE_ID) + self._abort_if_unique_id_configured() + + # Abort if any other flow for this handler is already in progress + assert self.hass is not None + if self._async_in_progress(): + raise data_entry_flow.AbortFlow("already_in_progress") + + async def async_step_discovery( + self, discovery_info: Dict[str, Any] + ) -> Dict[str, Any]: + """Handle a flow initialized by discovery.""" + await self._async_handle_discovery_without_unique_id() + return await self.async_step_user() + + async_step_hassio = async_step_discovery + async_step_homekit = async_step_discovery + async_step_ssdp = async_step_discovery + async_step_zeroconf = async_step_discovery + class OptionsFlowManager(data_entry_flow.FlowManager): """Flow to set options for a configuration entry.""" diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index ead2c0fa42d..8c272c49b1e 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -57,8 +57,10 @@ ZEROCONF = { HOMEKIT = { "819LMB": "myq", "AC02": "tado", + "Abode": "abode", "BSB002": "hue", "Healty Home Coach": "netatmo", + "Iota": "abode", "LIFX": "lifx", "Netatmo Relay": "netatmo", "PowerView": "hunterdouglas_powerview", diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index 43d281aa5bc..d349820978e 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -1,5 +1,5 @@ """Helpers for data entry flows for config entries.""" -from typing import Awaitable, Callable, Union +from typing import Any, Awaitable, Callable, Dict, Optional, Union from homeassistant import config_entries @@ -28,7 +28,9 @@ class DiscoveryFlowHandler(config_entries.ConfigFlow): self._discovery_function = discovery_function self.CONNECTION_CLASS = connection_class # pylint: disable=invalid-name - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: """Handle a flow initialized by the user.""" if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") @@ -37,7 +39,9 @@ class DiscoveryFlowHandler(config_entries.ConfigFlow): return await self.async_step_confirm() - async def async_step_confirm(self, user_input=None): + async def async_step_confirm( + self, user_input: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: """Confirm setup.""" if user_input is None: return self.async_show_form(step_id="confirm") @@ -48,7 +52,7 @@ class DiscoveryFlowHandler(config_entries.ConfigFlow): has_devices = in_progress if not has_devices: - has_devices = await self.hass.async_add_job( + has_devices = await self.hass.async_add_job( # type: ignore self._discovery_function, self.hass ) @@ -56,6 +60,7 @@ class DiscoveryFlowHandler(config_entries.ConfigFlow): return self.async_abort(reason="no_devices_found") # Cancel the discovered one. + assert self.hass is not None for flow in in_progress: self.hass.config_entries.flow.async_abort(flow["flow_id"]) @@ -64,7 +69,9 @@ class DiscoveryFlowHandler(config_entries.ConfigFlow): return self.async_create_entry(title=self._title, data={}) - async def async_step_discovery(self, discovery_info): + async def async_step_discovery( + self, discovery_info: Dict[str, Any] + ) -> Dict[str, Any]: """Handle a flow initialized by discovery.""" if self._async_in_progress() or self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") @@ -77,12 +84,13 @@ class DiscoveryFlowHandler(config_entries.ConfigFlow): async_step_ssdp = async_step_discovery async_step_homekit = async_step_discovery - async def async_step_import(self, _): + async def async_step_import(self, _: Optional[Dict[str, Any]]) -> Dict[str, Any]: """Handle a flow initialized by import.""" if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") # Cancel other flows. + assert self.hass is not None in_progress = self._async_in_progress() for flow in in_progress: self.hass.config_entries.flow.async_abort(flow["flow_id"]) @@ -125,7 +133,9 @@ class WebhookFlowHandler(config_entries.ConfigFlow): self._description_placeholder = description_placeholder self._allow_multiple = allow_multiple - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: """Handle a user initiated set up flow to create a webhook.""" if not self._allow_multiple and self._async_current_entries(): return self.async_abort(reason="one_instance_allowed") @@ -133,6 +143,7 @@ class WebhookFlowHandler(config_entries.ConfigFlow): if user_input is None: return self.async_show_form(step_id="user") + assert self.hass is not None webhook_id = self.hass.components.webhook.async_generate_id() if ( diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 712ea9f105c..5ef7905ae96 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -226,7 +226,9 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): ), ) - async def async_step_auth(self, user_input: Optional[dict] = None) -> dict: + async def async_step_auth( + self, user_input: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: """Create an entry for auth.""" # Flow has been triggered by external data if user_input: @@ -243,7 +245,9 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): return self.async_external_step(step_id="auth", url=url) - async def async_step_creation(self, user_input: Optional[dict] = None) -> dict: + async def async_step_creation( + self, user_input: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: """Create config entry from external data.""" token = await self.flow_impl.async_resolve_external_data(self.external_data) token["expires_at"] = time.time() + token["expires_in"] @@ -261,7 +265,9 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): """ return self.async_create_entry(title=self.flow_impl.name, data=data) - async def async_step_discovery(self, user_input: Optional[dict] = None) -> dict: + async def async_step_discovery( + self, discovery_info: Dict[str, Any] + ) -> Dict[str, Any]: """Handle a flow initialized by discovery.""" await self.async_set_unique_id(self.DOMAIN) diff --git a/script/hassfest/config_flow.py b/script/hassfest/config_flow.py index b662157ca3d..1d69504ff8a 100644 --- a/script/hassfest/config_flow.py +++ b/script/hassfest/config_flow.py @@ -2,8 +2,6 @@ import json from typing import Dict -from homeassistant.requirements import DISCOVERY_INTEGRATIONS - from .model import Config, Integration BASE = """ @@ -25,20 +23,36 @@ def validate_integration(config: Config, integration: Integration): config_flow_file = integration.path / "config_flow.py" if not config_flow_file.is_file(): - integration.add_error( - "config_flow", "Config flows need to be defined in the file config_flow.py" - ) + if integration.get("config_flow"): + integration.add_error( + "config_flow", + "Config flows need to be defined in the file config_flow.py", + ) + if integration.get("homekit"): + integration.add_error( + "config_flow", + "HomeKit information in a manifest requires a config flow to exist", + ) + if integration.get("ssdp"): + integration.add_error( + "config_flow", + "SSDP information in a manifest requires a config flow to exist", + ) + if integration.get("zeroconf"): + integration.add_error( + "config_flow", + "Zeroconf information in a manifest requires a config flow to exist", + ) return config_flow = config_flow_file.read_text() needs_unique_id = integration.domain not in UNIQUE_ID_IGNORE and ( - "async_step_hassio" in config_flow - or any( - bool(integration.manifest.get(key)) - for keys in DISCOVERY_INTEGRATIONS.values() - for key in keys - ) + "async_step_discovery" in config_flow + or "async_step_hassio" in config_flow + or "async_step_homekit" in config_flow + or "async_step_ssdp" in config_flow + or "async_step_zeroconf" in config_flow ) if not needs_unique_id: @@ -46,8 +60,9 @@ def validate_integration(config: Config, integration: Integration): has_unique_id = ( "self.async_set_unique_id" in config_flow - or "config_entry_flow.register_discovery_flow" in config_flow - or "config_entry_oauth2_flow.AbstractOAuth2FlowHandler" in config_flow + or "self._async_handle_discovery_without_unique_id" in config_flow + or "register_discovery_flow" in config_flow + or "AbstractOAuth2FlowHandler" in config_flow ) if has_unique_id: @@ -73,9 +88,12 @@ def generate_and_validate(integrations: Dict[str, Integration], config: Config): if not integration.manifest: continue - config_flow = integration.manifest.get("config_flow") - - if not config_flow: + if not ( + integration.manifest.get("config_flow") + or integration.manifest.get("homekit") + or integration.manifest.get("ssdp") + or integration.manifest.get("zeroconf") + ): continue validate_integration(config, integration) diff --git a/script/hassfest/ssdp.py b/script/hassfest/ssdp.py index 05a9dee332d..c9b3b893118 100644 --- a/script/hassfest/ssdp.py +++ b/script/hassfest/ssdp.py @@ -38,22 +38,6 @@ def generate_and_validate(integrations: Dict[str, Integration]): if not ssdp: continue - try: - with open(str(integration.path / "config_flow.py")) as fp: - content = fp.read() - if ( - " async_step_ssdp" not in content - and "AbstractOAuth2FlowHandler" not in content - and "register_discovery_flow" not in content - ): - integration.add_error("ssdp", "Config flow has no async_step_ssdp") - continue - except FileNotFoundError: - integration.add_error( - "ssdp", "SSDP info in a manifest requires a config flow to exist" - ) - continue - for matcher in ssdp: data[domain].append(sort_dict(matcher)) diff --git a/script/hassfest/zeroconf.py b/script/hassfest/zeroconf.py index 5ff102ea480..d6b39bd0d27 100644 --- a/script/hassfest/zeroconf.py +++ b/script/hassfest/zeroconf.py @@ -34,42 +34,7 @@ def generate_and_validate(integrations: Dict[str, Integration]): homekit = integration.manifest.get("homekit", {}) homekit_models = homekit.get("models", []) - if not service_types and not homekit_models: - continue - - try: - with open(str(integration.path / "config_flow.py")) as fp: - content = fp.read() - uses_discovery_flow = "register_discovery_flow" in content - uses_oauth2_flow = "AbstractOAuth2FlowHandler" in content - - if ( - service_types - and not uses_discovery_flow - and not uses_oauth2_flow - and " async_step_zeroconf" not in content - ): - integration.add_error( - "zeroconf", "Config flow has no async_step_zeroconf" - ) - continue - - if ( - homekit_models - and not uses_discovery_flow - and not uses_oauth2_flow - and " async_step_homekit" not in content - ): - integration.add_error( - "zeroconf", "Config flow has no async_step_homekit" - ) - continue - - except FileNotFoundError: - integration.add_error( - "zeroconf", - "Zeroconf info in a manifest requires a config flow to exist", - ) + if not (service_types or homekit_models): continue for service_type in service_types: diff --git a/tests/components/almond/test_config_flow.py b/tests/components/almond/test_config_flow.py index 959846bd017..a6785d2eff0 100644 --- a/tests/components/almond/test_config_flow.py +++ b/tests/components/almond/test_config_flow.py @@ -82,7 +82,7 @@ async def test_abort_if_existing_entry(hass): assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_setup" - result = await flow.async_step_hassio() + result = await flow.async_step_hassio({}) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_setup" diff --git a/tests/components/ipp/test_config_flow.py b/tests/components/ipp/test_config_flow.py index a468115f239..a3f253d3c81 100644 --- a/tests/components/ipp/test_config_flow.py +++ b/tests/components/ipp/test_config_flow.py @@ -264,10 +264,10 @@ async def test_zeroconf_with_uuid_device_exists_abort( assert result["reason"] == "already_configured" -async def test_zeroconf_empty_unique_id_required_abort( +async def test_zeroconf_empty_unique_id( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: - """Test we abort zeroconf flow if printer lacks (empty) unique identification.""" + """Test zeroconf flow if printer lacks (empty) unique identification.""" mock_connection(aioclient_mock, no_unique_id=True) discovery_info = { @@ -278,14 +278,13 @@ async def test_zeroconf_empty_unique_id_required_abort( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info, ) - assert result["type"] == RESULT_TYPE_ABORT - assert result["reason"] == "unique_id_required" + assert result["type"] == RESULT_TYPE_FORM -async def test_zeroconf_unique_id_required_abort( +async def test_zeroconf_no_unique_id( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: - """Test we abort zeroconf flow if printer lacks unique identification.""" + """Test zeroconf flow if printer lacks unique identification.""" mock_connection(aioclient_mock, no_unique_id=True) discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy() @@ -293,8 +292,7 @@ async def test_zeroconf_unique_id_required_abort( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info, ) - assert result["type"] == RESULT_TYPE_ABORT - assert result["reason"] == "unique_id_required" + assert result["type"] == RESULT_TYPE_FORM async def test_full_user_flow_implementation( diff --git a/tests/components/tellduslive/test_config_flow.py b/tests/components/tellduslive/test_config_flow.py index 6ee265de8d5..a8f188fffc7 100644 --- a/tests/components/tellduslive/test_config_flow.py +++ b/tests/components/tellduslive/test_config_flow.py @@ -13,6 +13,7 @@ from homeassistant.components.tellduslive import ( SCAN_INTERVAL, config_flow, ) +from homeassistant.config_entries import SOURCE_DISCOVERY from homeassistant.const import CONF_HOST from tests.common import MockConfigEntry, mock_coro @@ -73,6 +74,7 @@ async def test_abort_if_already_setup(hass): async def test_full_flow_implementation(hass, mock_tellduslive): """Test registering an implementation and finishing flow works.""" flow = init_config_flow(hass) + flow.context = {"source": SOURCE_DISCOVERY} result = await flow.async_step_discovery(["localhost", "tellstick"]) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" @@ -166,6 +168,7 @@ async def test_step_import_load_json(hass, mock_tellduslive): async def test_step_disco_no_local_api(hass, mock_tellduslive): """Test that we trigger when configuring from discovery, not supporting local api.""" flow = init_config_flow(hass) + flow.context = {"source": SOURCE_DISCOVERY} result = await flow.async_step_discovery(["localhost", "tellstick"]) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -242,7 +245,7 @@ async def test_discovery_already_configured(hass, mock_tellduslive): """Test abort if already configured fires from discovery.""" MockConfigEntry(domain="tellduslive", data={"host": "some-host"}).add_to_hass(hass) flow = init_config_flow(hass) + flow.context = {"source": SOURCE_DISCOVERY} - result = await flow.async_step_discovery(["some-host", ""]) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_setup" + with pytest.raises(data_entry_flow.AbortFlow): + result = await flow.async_step_discovery(["some-host", ""]) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 12b9c7308aa..6d513697daf 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -554,13 +554,15 @@ async def test_discovery_notification(hass): VERSION = 5 - async def async_step_discovery(self, user_input=None): + async def async_step_discovery(self, discovery_info): """Test discovery step.""" - if user_input is not None: - return self.async_create_entry( - title="Test Title", data={"token": "abcd"} - ) - return self.async_show_form(step_id="discovery") + return self.async_show_form(step_id="discovery_confirm") + + async def async_step_discovery_confirm(self, discovery_info): + """Test discovery confirm step.""" + return self.async_create_entry( + title="Test Title", data={"token": "abcd"} + ) result = await hass.config_entries.flow.async_init( "test", context={"source": config_entries.SOURCE_DISCOVERY} @@ -589,7 +591,7 @@ async def test_discovery_notification_not_created(hass): VERSION = 5 - async def async_step_discovery(self, user_input=None): + async def async_step_discovery(self, discovery_info): """Test discovery step.""" return self.async_abort(reason="test") @@ -1447,7 +1449,7 @@ async def test_partial_flows_hidden(hass, manager): VERSION = 1 - async def async_step_discovery(self, user_input): + async def async_step_discovery(self, discovery_info): """Test discovery step.""" discovery_started.set() await pause_discovery.wait() @@ -1577,3 +1579,182 @@ async def test_async_setup_update_entry(hass): assert len(entries) == 1 assert entries[0].state == config_entries.ENTRY_STATE_LOADED assert entries[0].data == {"value": "updated"} + + +@pytest.mark.parametrize( + "discovery_source", + ( + config_entries.SOURCE_DISCOVERY, + config_entries.SOURCE_SSDP, + config_entries.SOURCE_HOMEKIT, + config_entries.SOURCE_ZEROCONF, + config_entries.SOURCE_HASSIO, + ), +) +async def test_flow_with_default_discovery(hass, manager, discovery_source): + """Test that finishing a default discovery flow removes the unique ID in the entry.""" + mock_integration( + hass, MockModule("comp", async_setup_entry=AsyncMock(return_value=True)), + ) + mock_entity_platform(hass, "config_flow.comp", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Test user step.""" + if user_input is None: + return self.async_show_form(step_id="user") + + return self.async_create_entry(title="yo", data={}) + + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + # Create one to be in progress + result = await manager.flow.async_init( + "comp", context={"source": discovery_source} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert ( + flows[0]["context"]["unique_id"] + == config_entries.DEFAULT_DISCOVERY_UNIQUE_ID + ) + + # Finish flow + result2 = await manager.flow.async_configure( + result["flow_id"], user_input={"fake": "data"} + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + assert len(hass.config_entries.flow.async_progress()) == 0 + + entry = hass.config_entries.async_entries("comp")[0] + assert entry.title == "yo" + assert entry.source == discovery_source + assert entry.unique_id is None + + +async def test_flow_with_default_discovery_with_unique_id(hass, manager): + """Test discovery flow using the default discovery is ignored when unique ID is set.""" + mock_integration(hass, MockModule("comp")) + mock_entity_platform(hass, "config_flow.comp", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 1 + + async def async_step_discovery(self, discovery_info): + """Test discovery step.""" + await self.async_set_unique_id("mock-unique-id") + # This call should make no difference, as a unique ID is set + await self._async_handle_discovery_without_unique_id() + return self.async_show_form(step_id="mock") + + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + result = await manager.flow.async_init( + "comp", context={"source": config_entries.SOURCE_DISCOVERY} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["unique_id"] == "mock-unique-id" + + +async def test_default_discovery_abort_existing_entries(hass, manager): + """Test that a flow without discovery implementation aborts when a config entry exists.""" + hass.config.components.add("comp") + entry = MockConfigEntry(domain="comp", data={}, unique_id="mock-unique-id") + entry.add_to_hass(hass) + + mock_integration(hass, MockModule("comp")) + mock_entity_platform(hass, "config_flow.comp", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 1 + + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + result = await manager.flow.async_init( + "comp", context={"source": config_entries.SOURCE_DISCOVERY} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_default_discovery_in_progress(hass, manager): + """Test that a flow using default discovery can only be triggered once.""" + mock_integration(hass, MockModule("comp")) + mock_entity_platform(hass, "config_flow.comp", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 1 + + async def async_step_discovery(self, discovery_info): + """Test discovery step.""" + await self.async_set_unique_id(discovery_info.get("unique_id")) + await self._async_handle_discovery_without_unique_id() + return self.async_show_form(step_id="mock") + + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + result = await manager.flow.async_init( + "comp", + context={"source": config_entries.SOURCE_DISCOVERY}, + data={"unique_id": "mock-unique-id"}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + # Second discovery without a unique ID + result2 = await manager.flow.async_init( + "comp", context={"source": config_entries.SOURCE_DISCOVERY}, data={} + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["unique_id"] == "mock-unique-id" + + +async def test_default_discovery_abort_on_new_unique_flow(hass, manager): + """Test that a flow using default discovery is aborted when a second flow with unique ID is created.""" + mock_integration(hass, MockModule("comp")) + mock_entity_platform(hass, "config_flow.comp", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 1 + + async def async_step_discovery(self, discovery_info): + """Test discovery step.""" + await self.async_set_unique_id(discovery_info.get("unique_id")) + await self._async_handle_discovery_without_unique_id() + return self.async_show_form(step_id="mock") + + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + # First discovery with default, no unique ID + result2 = await manager.flow.async_init( + "comp", context={"source": config_entries.SOURCE_DISCOVERY}, data={} + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + + # Second discovery brings in a unique ID + result = await manager.flow.async_init( + "comp", + context={"source": config_entries.SOURCE_DISCOVERY}, + data={"unique_id": "mock-unique-id"}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + # Ensure the first one is cancelled and we end up with just the last one + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["unique_id"] == "mock-unique-id"