diff --git a/homeassistant/components/adguard/__init__.py b/homeassistant/components/adguard/__init__.py index ea6ad5a0bb37..ec806e3558fb 100644 --- a/homeassistant/components/adguard/__init__.py +++ b/homeassistant/components/adguard/__init__.py @@ -17,9 +17,9 @@ from homeassistant.const import ( CONF_VERIFY_SSL, Platform, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType @@ -120,8 +120,37 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +@callback +def _async_migrate_device_identifiers( + hass: HomeAssistant, entry: AdGuardConfigEntry +) -> None: + """Migrate devices identified by host, port and base path to the entry ID. + + Those identifiers had four parts, while the device registry only supports two. + """ + device_registry = dr.async_get(hass) + identifiers = {(DOMAIN, entry.entry_id)} + migrated = device_registry.async_get_device_by_identifier( + (DOMAIN, entry.entry_id), entry.entry_id + ) + + for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id): + if device.identifiers == identifiers: + continue + + # Downgrading recreates the old device, leaving a duplicate behind. Its + # entities move back to the migrated device when the platforms set up. + if migrated is not None: + device_registry.async_remove_device(device.id) + continue + + device_registry.async_update_device(device.id, new_identifiers=identifiers) + + async def async_setup_entry(hass: HomeAssistant, entry: AdGuardConfigEntry) -> bool: """Set up AdGuard Home from a config entry.""" + _async_migrate_device_identifiers(hass, entry) + session = async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]) adguard = AdGuardHome( entry.data[CONF_HOST], diff --git a/homeassistant/components/adguard/entity.py b/homeassistant/components/adguard/entity.py index a6460dea1aeb..c4bec6bb5960 100644 --- a/homeassistant/components/adguard/entity.py +++ b/homeassistant/components/adguard/entity.py @@ -61,14 +61,7 @@ class AdGuardHomeEntity(Entity): return DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={ - ( # type: ignore[arg-type] - DOMAIN, - self.adguard.host, - self.adguard.port, - self.adguard.base_path, - ) - }, + identifiers={(DOMAIN, self._entry.entry_id)}, manufacturer="AdGuard Team", name="AdGuard Home", sw_version=self.data.version, diff --git a/tests/components/adguard/test_init.py b/tests/components/adguard/test_init.py index 6cbedd76be2f..f55f7273fd55 100644 --- a/tests/components/adguard/test_init.py +++ b/tests/components/adguard/test_init.py @@ -1,13 +1,15 @@ """Tests for the AdGuard Home.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from adguardhome import AdGuardHomeConnectionError import pytest +from homeassistant.components.adguard.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry @@ -39,3 +41,98 @@ async def test_setup_failed( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_device_identifiers( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_adguard: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the device is identified by a two part identifier.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.adguard.PLATFORMS", [Platform.SENSOR]): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device_by_identifier( + (DOMAIN, mock_config_entry.entry_id), mock_config_entry.entry_id + ) + assert device is not None + assert device.identifiers == {(DOMAIN, mock_config_entry.entry_id)} + + +async def test_device_identifiers_migration( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_adguard: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the device created by an older version is migrated.""" + mock_config_entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "127.0.0.1", 3000, "/control")}, # type: ignore[arg-type] + name="AdGuard Home", + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + migrated = device_registry.async_get(device.id) + assert migrated is not None + assert migrated.identifiers == {(DOMAIN, mock_config_entry.entry_id)} + + +async def test_device_identifiers_migration_when_unavailable( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_adguard: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the device is migrated even when the instance cannot be reached.""" + mock_adguard.version.side_effect = AdGuardHomeConnectionError("Connection error") + + mock_config_entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "127.0.0.1", 3000, "/control")}, # type: ignore[arg-type] + name="AdGuard Home", + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + migrated = device_registry.async_get(device.id) + assert migrated is not None + assert migrated.identifiers == {(DOMAIN, mock_config_entry.entry_id)} + + +async def test_device_identifiers_migration_with_duplicate( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_adguard: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a device left behind by a downgrade is cleaned up.""" + mock_config_entry.add_to_hass(hass) + current = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name="AdGuard Home", + ) + duplicate = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "127.0.0.1", 3000, "/control")}, # type: ignore[arg-type] + name="AdGuard Home", + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert device_registry.async_get(duplicate.id) is None + assert device_registry.async_get(current.id) is not None