From 2f5fbc1f0e70d98d42ff28081fdbdf3c23ce46f7 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 19 Oct 2025 21:37:10 +0200 Subject: [PATCH] Add instance ID (mDNS) conflict detection and repair flow for zeroconf integration (#151487) Co-authored-by: J. Nick Koston --- homeassistant/components/zeroconf/__init__.py | 88 +++--- .../components/zeroconf/discovery.py | 82 +++++ homeassistant/components/zeroconf/repairs.py | 60 ++++ .../components/zeroconf/strings.json | 14 + homeassistant/helpers/instance_id.py | 11 + tests/components/zeroconf/test_repairs.py | 280 ++++++++++++++++++ tests/helpers/test_instance_id.py | 12 + 7 files changed, 506 insertions(+), 41 deletions(-) create mode 100644 homeassistant/components/zeroconf/repairs.py create mode 100644 homeassistant/components/zeroconf/strings.json create mode 100644 tests/components/zeroconf/test_repairs.py diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 311c42ee18e..56cfed920c2 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -190,12 +190,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: homekit_model_lookup, homekit_model_matchers = build_homekit_model_lookups( homekit_models ) + local_service_info = await _async_get_local_service_info(hass) discovery = ZeroconfDiscovery( hass, zeroconf, zeroconf_types, homekit_model_lookup, homekit_model_matchers, + local_service_info, ) await discovery.async_setup() hass.data[DATA_DISCOVERY] = discovery @@ -206,8 +208,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: Wait till started or otherwise HTTP is not up and running. """ - uuid = await instance_id.async_get(hass) - await _async_register_hass_zc_service(hass, aio_zc, uuid) + await _async_register_hass_zc_service(aio_zc, local_service_info) async def _async_zeroconf_hass_stop(_event: Event) -> None: await discovery.async_stop() @@ -227,48 +228,12 @@ def _filter_disallowed_characters(name: str) -> str: async def _async_register_hass_zc_service( - hass: HomeAssistant, aio_zc: HaAsyncZeroconf, uuid: str + aio_zc: HaAsyncZeroconf, local_service_info: AsyncServiceInfo ) -> None: - # Get instance UUID - valid_location_name = _truncate_location_name_to_valid( - _filter_disallowed_characters(hass.config.location_name or "Home") - ) - - params = { - "location_name": valid_location_name, - "uuid": uuid, - "version": __version__, - "external_url": "", - "internal_url": "", - # Old base URL, for backward compatibility - "base_url": "", - # Always needs authentication - "requires_api_password": True, - } - - # Get instance URL's - with suppress(NoURLAvailableError): - params["external_url"] = get_url(hass, allow_internal=False) - - with suppress(NoURLAvailableError): - params["internal_url"] = get_url(hass, allow_external=False) - - # Set old base URL based on external or internal - params["base_url"] = params["external_url"] or params["internal_url"] - - _suppress_invalid_properties(params) - - info = AsyncServiceInfo( - ZEROCONF_TYPE, - name=f"{valid_location_name}.{ZEROCONF_TYPE}", - server=f"{uuid}.local.", - parsed_addresses=await network.async_get_announce_addresses(hass), - port=hass.http.server_port, - properties=params, - ) + """Register the zeroconf service for the local Home Assistant instance.""" _LOGGER.info("Starting Zeroconf broadcast") - await aio_zc.async_register_service(info, allow_name_change=True) + await aio_zc.async_register_service(local_service_info, allow_name_change=True) def _suppress_invalid_properties(properties: dict) -> None: @@ -307,6 +272,47 @@ def _truncate_location_name_to_valid(location_name: str) -> str: return location_name.encode("utf-8")[:MAX_NAME_LEN].decode("utf-8", "ignore") +async def _async_get_local_service_info(hass: HomeAssistant) -> AsyncServiceInfo: + """Return the zeroconf service info for the local Home Assistant instance.""" + valid_location_name = _truncate_location_name_to_valid( + _filter_disallowed_characters(hass.config.location_name or "Home") + ) + uuid = await instance_id.async_get(hass) + + params = { + "location_name": valid_location_name, + "uuid": uuid, + "version": __version__, + "external_url": "", + "internal_url": "", + # Old base URL, for backward compatibility + "base_url": "", + # Always needs authentication + "requires_api_password": True, + } + + # Get instance URL's + with suppress(NoURLAvailableError): + params["external_url"] = get_url(hass, allow_internal=False) + + with suppress(NoURLAvailableError): + params["internal_url"] = get_url(hass, allow_external=False) + + # Set old base URL based on external or internal + params["base_url"] = params["external_url"] or params["internal_url"] + + _suppress_invalid_properties(params) + + return AsyncServiceInfo( + ZEROCONF_TYPE, + name=f"{valid_location_name}.{ZEROCONF_TYPE}", + server=f"{uuid}.local.", + parsed_addresses=await network.async_get_announce_addresses(hass), + port=hass.http.server_port, + properties=params, + ) + + # These can be removed if no deprecated constant are in this module anymore __getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) __dir__ = partial( diff --git a/homeassistant/components/zeroconf/discovery.py b/homeassistant/components/zeroconf/discovery.py index e9b4508caee..6ebf4a00d48 100644 --- a/homeassistant/components/zeroconf/discovery.py +++ b/homeassistant/components/zeroconf/discovery.py @@ -19,6 +19,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import discovery_flow from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.helpers.dispatcher import async_dispatcher_connect +import homeassistant.helpers.issue_registry as ir from homeassistant.helpers.service_info.zeroconf import ( ZeroconfServiceInfo as _ZeroconfServiceInfo, ) @@ -49,6 +50,8 @@ ATTR_DOMAIN: Final = "domain" ATTR_NAME: Final = "name" ATTR_PROPERTIES: Final = "properties" +DUPLICATE_INSTANCE_ID_ISSUE_ID = "duplicate_instance_id" + DATA_DISCOVERY: HassKey[ZeroconfDiscovery] = HassKey("zeroconf_discovery") @@ -183,6 +186,7 @@ class ZeroconfDiscovery: zeroconf_types: dict[str, list[ZeroconfMatcher]], homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration], homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration], + local_service_info: AsyncServiceInfo, ) -> None: """Init discovery.""" self.hass = hass @@ -193,6 +197,11 @@ class ZeroconfDiscovery: self.async_service_browser: AsyncServiceBrowser | None = None self._service_update_listeners: set[Callable[[AsyncServiceInfo], None]] = set() self._service_removed_listeners: set[Callable[[str], None]] = set() + self._conflicting_instances: set[str] = set() + self._local_service_info = info_from_service(local_service_info) + self._local_ips: set[IPv4Address | IPv6Address] = set() + if self._local_service_info: + self._local_ips = set(self._local_service_info.ip_addresses) @callback def async_register_service_update_listener( @@ -278,6 +287,16 @@ class ZeroconfDiscovery: ) if state_change is ServiceStateChange.Removed: + # Check if other Home Assistant instances has been removed. + # Then we can remove the duplicate instance ID issue + # as probably the conflicting instance has been shut down + if service_type == ZEROCONF_TYPE and name in self._conflicting_instances: + self._conflicting_instances.remove(name) + if len(self._conflicting_instances) == 0: + ir.async_delete_issue( + self.hass, DOMAIN, DUPLICATE_INSTANCE_ID_ISSUE_ID + ) + self._async_dismiss_discoveries(name) for listener in self._service_removed_listeners: listener(name) @@ -336,6 +355,13 @@ class ZeroconfDiscovery: return _LOGGER.debug("Discovered new device %s %s", name, info) props: dict[str, str | None] = info.properties + + # Instance ID conflict detection for Home Assistant core + if service_type == ZEROCONF_TYPE and ( + discovered_instance_id := props.get("uuid") + ): + self._async_check_instance_id_conflict(discovered_instance_id, info) + discovery_key = DiscoveryKey( domain=DOMAIN, key=(info.type, info.name), @@ -408,3 +434,59 @@ class ZeroconfDiscovery: info, discovery_key=discovery_key, ) + + @callback + def _async_check_instance_id_conflict( + self, discovered_instance_id: str, info: _ZeroconfServiceInfo + ) -> None: + """Check for instance ID conflicts and create repair issues if needed.""" + if not self._local_service_info: + _LOGGER.debug( + "No local service info, cannot check for instance ID conflicts" + ) + return + + discovered_ips = set(info.ip_addresses) + is_disjoint = self._local_ips.isdisjoint(discovered_ips) + local_instance_id = self._local_service_info.properties.get("uuid") + + if not is_disjoint: + # No conflict, IP addresses of service contain a local IP + # Ignore it as it's probably a mDNS reflection + return + + if discovered_instance_id != local_instance_id: + # Conflict resolved, different instance IDs + # No conflict, different instance IDs + # If there was a conflict issue before, we remove it + # since the other instance may have changed its ID + if info.name in self._conflicting_instances: + self._conflicting_instances.remove(info.name) + + if len(self._conflicting_instances) == 0: + ir.async_delete_issue(self.hass, DOMAIN, DUPLICATE_INSTANCE_ID_ISSUE_ID) + return + + # Conflict detected, create repair issue + _joined_ips = ", ".join(str(ip_address) for ip_address in discovered_ips) + _LOGGER.warning( + "Discovered another Home Assistant instance with the same instance ID (%s) at %s", + discovered_instance_id, + _joined_ips, + ) + + self._conflicting_instances.add(info.name) + ir.async_create_issue( + self.hass, + DOMAIN, + DUPLICATE_INSTANCE_ID_ISSUE_ID, + is_fixable=True, + is_persistent=False, + severity=ir.IssueSeverity.ERROR, + translation_key=DUPLICATE_INSTANCE_ID_ISSUE_ID, + translation_placeholders={ + "instance_id": local_instance_id, + "other_ip": _joined_ips, + "other_host_url": info.hostname.rstrip("."), + }, + ) diff --git a/homeassistant/components/zeroconf/repairs.py b/homeassistant/components/zeroconf/repairs.py new file mode 100644 index 00000000000..3afde331a42 --- /dev/null +++ b/homeassistant/components/zeroconf/repairs.py @@ -0,0 +1,60 @@ +"""Repairs for the zeroconf integration.""" + +from __future__ import annotations + +from homeassistant import data_entry_flow +from homeassistant.components.homeassistant import ( + DOMAIN as DOMAIN_HOMEASSISTANT, + SERVICE_HOMEASSISTANT_RESTART, +) +from homeassistant.components.repairs import RepairsFlow +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import instance_id, issue_registry as ir + + +class DuplicateInstanceIDRepairFlow(RepairsFlow): + """Handler for duplicate instance ID repair.""" + + @callback + def _async_get_placeholders(self) -> dict[str, str]: + issue_registry = ir.async_get(self.hass) + issue = issue_registry.async_get_issue(self.handler, self.issue_id) + assert issue is not None + return issue.translation_placeholders or {} + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the initial step.""" + return await self.async_step_confirm_recreate() + + async def async_step_confirm_recreate( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step.""" + if user_input is not None: + await instance_id.async_recreate(self.hass) + await self.hass.services.async_call( + DOMAIN_HOMEASSISTANT, SERVICE_HOMEASSISTANT_RESTART + ) + + return self.async_create_entry(title="", data={}) + + return self.async_show_form( + step_id="confirm_recreate", + description_placeholders=self._async_get_placeholders(), + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + if issue_id == "duplicate_instance_id": + return DuplicateInstanceIDRepairFlow() + + # If Zeroconf adds confirm-only repairs in the future, this should be changed + # to return a ConfirmRepairFlow instead of raising a ValueError + raise ValueError(f"unknown repair {issue_id}") diff --git a/homeassistant/components/zeroconf/strings.json b/homeassistant/components/zeroconf/strings.json new file mode 100644 index 00000000000..65d992a382e --- /dev/null +++ b/homeassistant/components/zeroconf/strings.json @@ -0,0 +1,14 @@ +{ + "issues": { + "duplicate_instance_id": { + "title": "Duplicate Home Assistant instance detected on your network", + "fix_flow": { + "step": { + "confirm_recreate": { + "description": "Another device ({other_ip}) on your network is advertising the same Home Assistant instance ID ({instance_id} reachable via {other_host_url}) as this instance. This can cause network instability and excessive traffic.\n\nTo fix this issue:\n1. Change the instance ID on **only one** of the Home Assistant instances.\n2. Once the conflict is resolved, the repair issue on the other instance will disappear automatically.\n\nAfter confirming, a new instance ID will be generated for this Home Assistant instance and the instance will restart. This will not affect your configuration or data, but it may take a few minutes for other devices on your network to recognize the change.\n\nTo proceed, click 'Submit' below." + } + } + } + } + } +} diff --git a/homeassistant/helpers/instance_id.py b/homeassistant/helpers/instance_id.py index 3c9790ad13d..1d62ca633ee 100644 --- a/homeassistant/helpers/instance_id.py +++ b/homeassistant/helpers/instance_id.py @@ -47,3 +47,14 @@ async def async_get(hass: HomeAssistant) -> str: await store.async_save(data) return data["uuid"] + + +async def async_recreate(hass: HomeAssistant) -> str: + """Recreate a new unique ID for the hass instance.""" + store = storage.Store[dict[str, str]](hass, DATA_VERSION, DATA_KEY, True) + + data = {"uuid": uuid.uuid4().hex} + + await store.async_save(data) + + return data["uuid"] diff --git a/tests/components/zeroconf/test_repairs.py b/tests/components/zeroconf/test_repairs.py new file mode 100644 index 00000000000..d86b6285218 --- /dev/null +++ b/tests/components/zeroconf/test_repairs.py @@ -0,0 +1,280 @@ +"""Tests for zeroconf repair issues.""" + +from unittest.mock import patch + +import pytest +from zeroconf import ServiceStateChange +from zeroconf.asyncio import AsyncServiceInfo + +from homeassistant.components.homeassistant import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN +from homeassistant.components.zeroconf import DOMAIN, discovery, repairs +from homeassistant.components.zeroconf.discovery import ZEROCONF_TYPE +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import instance_id, issue_registry as ir +from homeassistant.setup import async_setup_component + +from .test_init import service_update_mock + +from tests.components.repairs import ( + async_process_repairs_platforms, + process_repair_fix_flow, + start_repair_fix_flow, +) +from tests.typing import ClientSessionGenerator + + +def service_state_change_mock( + zeroconf, + services, + handlers, + *, + state_change: ServiceStateChange = ServiceStateChange.Removed, +) -> None: + """Call service update handler.""" + for service in services: + handlers[0](zeroconf, service, f"_name.{service}", state_change) + + +def _get_hass_service_info_mock( + service_type: str, + name: str, + *, + instance_id="abc123", +) -> AsyncServiceInfo: + """Return service info for Home Assistant instance.""" + return AsyncServiceInfo( + ZEROCONF_TYPE, + name, + addresses=[b"\n\x00\x00\x01"], + port=8123, + weight=0, + priority=0, + server="other-host.local.", + properties={ + "base_url": "http://10.0.0.1:8123", + "external_url": None, + "internal_url": "http://10.0.0.1:8123", + "location_name": "Home", + "requires_api_password": "True", + "uuid": instance_id, + "version": "2025.9.0.dev0", + }, + ) + + +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_instance_id_conflict_creates_repair_issue_remove( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test that a repair issue is created on instance ID conflict and gets removed when instance disappears.""" + with ( + patch("homeassistant.helpers.instance_id.async_get", return_value="abc123"), + patch.object( + discovery, "AsyncServiceBrowser", side_effect=service_update_mock + ) as mock_browser, + patch.object(hass.config_entries.flow, "async_init"), + patch( + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", + side_effect=_get_hass_service_info_mock, + ), + ): + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue( + domain="zeroconf", issue_id="duplicate_instance_id" + ) + assert issue + assert issue.severity == ir.IssueSeverity.ERROR + assert issue.translation_key == "duplicate_instance_id" + assert issue.translation_placeholders == { + "other_host_url": "other-host.local", + "other_ip": "10.0.0.1", + "instance_id": "abc123", + } + + # Now test that the issue is removed when the service goes away + service_state_change_mock( + mock_browser.call_args[0][0], + [ZEROCONF_TYPE], + mock_browser.call_args[1]["handlers"], + ) + assert ( + issue_registry.async_get_issue( + domain="zeroconf", issue_id="duplicate_instance_id" + ) + is None + ) + + +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_instance_id_conflict_creates_repair_issue_changing_id( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test that a repair issue is created on instance ID conflict and gets removed when instance ID changes.""" + with ( + patch("homeassistant.helpers.instance_id.async_get", return_value="abc123"), + patch.object( + discovery, "AsyncServiceBrowser", side_effect=service_update_mock + ) as mock_browser, + patch.object(hass.config_entries.flow, "async_init"), + patch( + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", + side_effect=_get_hass_service_info_mock, + ), + ): + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue( + domain="zeroconf", issue_id="duplicate_instance_id" + ) + assert issue + assert issue.severity == ir.IssueSeverity.ERROR + assert issue.translation_key == "duplicate_instance_id" + assert issue.translation_placeholders == { + "other_host_url": "other-host.local", + "other_ip": "10.0.0.1", + "instance_id": "abc123", + } + + with ( + patch( + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", + side_effect=lambda service_type, name: _get_hass_service_info_mock( + service_type, name, instance_id="different-id" + ), + ), + ): + # Now test that the issue is removed when the service goes away + service_state_change_mock( + mock_browser.call_args[0][0], + [ZEROCONF_TYPE], + mock_browser.call_args[1]["handlers"], + state_change=ServiceStateChange.Updated, + ) + assert ( + issue_registry.async_get_issue( + domain="zeroconf", issue_id="duplicate_instance_id" + ) + is None + ) + + +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_instance_id_no_repair_issue_own_ip( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test that no repair issue is created when the other instance ID matches our IP.""" + with ( + patch("homeassistant.helpers.instance_id.async_get", return_value="abc123"), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(hass.config_entries.flow, "async_init"), + patch( + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", + side_effect=_get_hass_service_info_mock, + ), + patch( + "homeassistant.components.network.async_get_announce_addresses", + return_value=["10.0.0.1", "10.0.0.2"], + ), + ): + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert ( + issue_registry.async_get_issue( + domain="zeroconf", issue_id="duplicate_instance_id" + ) + is None + ) + + +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_instance_id_no_conflict_no_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test that a repair issue is not created when no instance ID conflict exists.""" + with ( + patch("homeassistant.helpers.instance_id.async_get", return_value="xyz123"), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(hass.config_entries.flow, "async_init"), + patch( + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", + side_effect=_get_hass_service_info_mock, + ), + patch("homeassistant.helpers.issue_registry.async_create_issue"), + ): + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert ( + issue_registry.async_get_issue( + domain="zeroconf", issue_id="duplicate_instance_id" + ) + is None + ) + + +async def test_create_fix_flow_raises_on_unknown_issue_id(hass: HomeAssistant) -> None: + """Test create_fix_flow raises on unknown issue_id.""" + + with pytest.raises(ValueError): + await repairs.async_create_fix_flow(hass, "no_such_issue", None) + + +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_duplicate_repair_issue_repair_flow( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, +) -> None: + """Test desired flow of the fix flow for duplicate instance ID.""" + assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) + assert await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {}) + await async_process_repairs_platforms(hass) + + with ( + patch("homeassistant.helpers.instance_id.async_get", return_value="abc123"), + patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock), + patch.object(hass.config_entries.flow, "async_init"), + patch( + "homeassistant.components.zeroconf.discovery.AsyncServiceInfo", + side_effect=_get_hass_service_info_mock, + ), + patch.object( + instance_id, "async_recreate", return_value="new-uuid" + ) as mock_recreate, + patch("homeassistant.config.async_check_ha_config_file", return_value=None), + patch("homeassistant.core.HomeAssistant.async_stop", return_value=None), + ): + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue( + domain="zeroconf", issue_id="duplicate_instance_id" + ) + assert issue is not None + + client = await hass_client() + + result = await start_repair_fix_flow(client, DOMAIN, issue.issue_id) + + flow_id = result["flow_id"] + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "confirm_recreate" + + result = await process_repair_fix_flow(client, flow_id, json={}) + assert result["type"] == "create_entry" + + await hass.async_block_till_done() + + assert mock_recreate.called diff --git a/tests/helpers/test_instance_id.py b/tests/helpers/test_instance_id.py index ad2e4626af5..9bc8a8b0c03 100644 --- a/tests/helpers/test_instance_id.py +++ b/tests/helpers/test_instance_id.py @@ -83,3 +83,15 @@ async def test_get_id_migrate_fail( "Could not read hass instance ID from 'core.uuid' or '.uuid', a " "new instance ID will be generated" in caplog.text ) + + +async def test_async_recreate( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test recreating instance ID.""" + uuid1 = await instance_id.async_get(hass) + uuid2 = await instance_id.async_recreate(hass) + assert uuid1 != uuid2 + + # Assert it's stored + assert hass_storage["core.uuid"]["data"]["uuid"] == uuid2