mirror of
https://github.com/home-assistant/core.git
synced 2025-12-24 21:06:19 +00:00
Add instance ID (mDNS) conflict detection and repair flow for zeroconf integration (#151487)
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
committed by
GitHub
parent
e79c76cd35
commit
2f5fbc1f0e
@@ -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(
|
||||
|
||||
@@ -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("."),
|
||||
},
|
||||
)
|
||||
|
||||
60
homeassistant/components/zeroconf/repairs.py
Normal file
60
homeassistant/components/zeroconf/repairs.py
Normal file
@@ -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}")
|
||||
14
homeassistant/components/zeroconf/strings.json
Normal file
14
homeassistant/components/zeroconf/strings.json
Normal file
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
280
tests/components/zeroconf/test_repairs.py
Normal file
280
tests/components/zeroconf/test_repairs.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user