From bcb9bc5deb14803b6046b95f575a3f3023515274 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 11 Sep 2026 12:17:38 +0200 Subject: [PATCH] Adjust unifiprotect to not access DeviceEntry.config_entries (#181766) --- homeassistant/components/unifiprotect/data.py | 17 ------ .../components/unifiprotect/services.py | 38 +++++------- .../components/unifiprotect/strings.json | 3 - tests/components/unifiprotect/test_init.py | 60 +------------------ .../components/unifiprotect/test_services.py | 43 ++++++++++++- 5 files changed, 55 insertions(+), 106 deletions(-) diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index dda330cba92b..7d9c2cc86b5a 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -833,23 +833,6 @@ class ProtectData: update_callback(obj) -@callback -def async_ufp_instance_for_config_entry_ids( - hass: HomeAssistant, config_entry_ids: set[str] -) -> ProtectApiClient | None: - """Find the UFP instance for the config entry ids.""" - return next( - iter( - entry.runtime_data.api - for entry_id in config_entry_ids - if (entry := hass.config_entries.async_get_entry(entry_id)) - and entry.domain == DOMAIN - and hasattr(entry, "runtime_data") - ), - None, - ) - - @callback def async_get_ufp_entries(hass: HomeAssistant) -> list[UFPConfigEntry]: """Get all the UFP entries.""" diff --git a/homeassistant/components/unifiprotect/services.py b/homeassistant/components/unifiprotect/services.py index 93cdd16e5067..211b64db30e3 100644 --- a/homeassistant/components/unifiprotect/services.py +++ b/homeassistant/components/unifiprotect/services.py @@ -25,6 +25,7 @@ from homeassistant.helpers import ( config_validation as cv, device_registry as dr, entity_registry as er, + service, ) from homeassistant.helpers.target import ( TargetSelection, @@ -43,7 +44,7 @@ from .const import ( KEYRINGS_USER_FULL_NAME, KEYRINGS_USER_STATUS, ) -from .data import async_ufp_instance_for_config_entry_ids +from .data import UFPConfigEntry _LOGGER = logging.getLogger(__name__) @@ -104,35 +105,24 @@ PTZ_GOTO_PRESET_SCHEMA = vol.Schema( @callback def _async_get_ufp_instance(hass: HomeAssistant, device_id: str) -> ProtectApiClient: device_registry = dr.async_get(hass) - if not (device_entry := device_registry.async_get(device_id)): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="device_not_found", - translation_placeholders={"device_id": device_id}, - ) + device_entry = device_registry.async_get(device_id) if isinstance(device_entry, dr.ChildDeviceEntry): return _async_get_ufp_instance(hass, device_entry.parent_device_id) - if device_entry.via_device_id is not None: + if device_entry is not None and device_entry.via_device_id is not None: return _async_get_ufp_instance(hass, device_entry.via_device_id) - config_entry_ids = device_entry.config_entries - if ufp_instance := async_ufp_instance_for_config_entry_ids(hass, config_entry_ids): - if ufp_instance.is_public_only: - # Actions read/write through the private bootstrap, which an - # API-key-only entry never initializes. - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="public_only_no_actions", - ) - return ufp_instance - - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="device_not_found", - translation_placeholders={"device_id": device_id}, - ) + _, config_entry = service.async_get_device_and_config_entry(hass, DOMAIN, device_id) + ufp_instance = cast(UFPConfigEntry, config_entry).runtime_data.api + if ufp_instance.is_public_only: + # Actions read/write through the private bootstrap, which an + # API-key-only entry never initializes. + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="public_only_no_actions", + ) + return ufp_instance @callback diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 49272c1732af..debed2d482a7 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -841,9 +841,6 @@ "command_error": { "message": "Error communicating with UniFi Protect while sending command: {error}" }, - "device_not_found": { - "message": "No device found for device id: {device_id}" - }, "entry_auth_failed": { "message": "Authentication failed, please reauthenticate" }, diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index f462d23032e9..1f1f0e54528a 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -25,10 +25,7 @@ from homeassistant.components.unifiprotect.const import ( PLATFORMS, PUBLIC_ONLY_PLATFORMS, ) -from homeassistant.components.unifiprotect.data import ( - ProtectData, - async_ufp_instance_for_config_entry_ids, -) +from homeassistant.components.unifiprotect.data import ProtectData from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry, ConfigEntryState from homeassistant.const import CONF_API_KEY, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant @@ -474,61 +471,6 @@ async def test_remove_config_entry_device_rejects_child_device( assert device_registry.async_get(child_device.id) -@pytest.mark.parametrize( - ("mock_entries", "expected_result"), - [ - pytest.param( - [ - MockConfigEntry( - domain=DOMAIN, - entry_id="1", - data={}, - ), - MockConfigEntry( - domain="other_domain", - entry_id="2", - data={}, - ), - ], - "mock_api_instance_1", - id="one_matching_domain", - ), - pytest.param( - [ - MockConfigEntry( - domain="other_domain", - entry_id="1", - data={}, - ), - MockConfigEntry( - domain="other_domain", - entry_id="2", - data={}, - ), - ], - None, - id="no_matching_domain", - ), - ], -) -async def test_async_ufp_instance_for_config_entry_ids( - hass: HomeAssistant, - mock_entries: list[MockConfigEntry], - expected_result: str | None, -) -> None: - """Test async_ufp_instance_for_config_entry_ids with various configs.""" - - for index, entry in enumerate(mock_entries): - entry.add_to_hass(hass) - entry.runtime_data = Mock(api=f"mock_api_instance_{index + 1}") - - entry_ids = {entry.entry_id for entry in mock_entries} - - result = async_ufp_instance_for_config_entry_ids(hass, entry_ids) - - assert result == expected_result - - @pytest.mark.parametrize("mock_user_can_write_nvr", [True], indirect=True) async def test_setup_creates_api_key_when_missing( hass: HomeAssistant, ufp: MockUFPFixture, mock_user_can_write_nvr: Mock diff --git a/tests/components/unifiprotect/test_services.py b/tests/components/unifiprotect/test_services.py index 0fba21282a40..ef7fb8d62a02 100644 --- a/tests/components/unifiprotect/test_services.py +++ b/tests/components/unifiprotect/test_services.py @@ -30,7 +30,7 @@ from homeassistant.components.unifiprotect.services import ( ) from homeassistant.config_entries import ConfigEntryDisabler from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_NAME -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -38,6 +38,8 @@ from . import patch_ufp_method from .conftest import UNIFI_MAC from .utils import MockUFPFixture, init_entry +from tests.common import MockConfigEntry + @pytest.fixture(name="device") async def device_fixture( @@ -69,12 +71,13 @@ async def test_global_service_bad_device( ) -> None: """Test global service, invalid device ID.""" + await init_entry(hass, ufp, []) nvr = ufp.api.bootstrap.nvr with patch_ufp_method( nvr, "add_custom_doorbell_message", new_callable=AsyncMock ) as mock_method: - with pytest.raises(HomeAssistantError): + with pytest.raises(ServiceValidationError) as error: await hass.services.async_call( DOMAIN, SERVICE_ADD_DOORBELL_TEXT, @@ -83,6 +86,37 @@ async def test_global_service_bad_device( ) assert not mock_method.called + assert error.value.translation_domain == HOMEASSISTANT_DOMAIN + assert error.value.translation_key == "service_device_not_found" + + +async def test_global_service_device_from_other_integration( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + ufp: MockUFPFixture, +) -> None: + """Test a device not owned by a UniFi Protect config entry is rejected.""" + + await init_entry(hass, ufp, []) + other_entry = MockConfigEntry(domain="other") + other_entry.add_to_hass(hass) + other_device = device_registry.async_get_or_create( + config_entry_id=other_entry.entry_id, + identifiers={("other", "other-device")}, + name="Other device", + ) + + with pytest.raises(ServiceValidationError) as error: + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_DOORBELL_TEXT, + {ATTR_DEVICE_ID: other_device.id, ATTR_MESSAGE: "Test Message"}, + blocking=True, + ) + + assert error.value.translation_domain == HOMEASSISTANT_DOMAIN + assert error.value.translation_key == "service_device_wrong_domain" + async def test_global_service_exception( hass: HomeAssistant, device: dr.DeviceEntry, ufp: MockUFPFixture @@ -159,7 +193,7 @@ async def test_add_doorbell_text_disabled_config_entry( with patch_ufp_method( nvr, "add_custom_doorbell_message", new_callable=AsyncMock ) as mock_method: - with pytest.raises(HomeAssistantError): + with pytest.raises(ServiceValidationError) as error: await hass.services.async_call( DOMAIN, SERVICE_ADD_DOORBELL_TEXT, @@ -168,6 +202,9 @@ async def test_add_doorbell_text_disabled_config_entry( ) assert not mock_method.called + assert error.value.translation_domain == HOMEASSISTANT_DOMAIN + assert error.value.translation_key == "service_config_entry_not_loaded" + async def test_set_chime_paired_doorbells( hass: HomeAssistant,