From 1045ed3566d499ca56b7f7f12ae13f2daf1a2d3d Mon Sep 17 00:00:00 2001 From: cnico Date: Fri, 21 Aug 2026 07:20:05 +0200 Subject: [PATCH 1/7] Split user flow init and data submission in flipr tests (#179690) --- tests/components/flipr/test_config_flow.py | 37 +++++++++++++++++++--- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/tests/components/flipr/test_config_flow.py b/tests/components/flipr/test_config_flow.py index c578156a265d4..0ac3428e2303d 100644 --- a/tests/components/flipr/test_config_flow.py +++ b/tests/components/flipr/test_config_flow.py @@ -26,7 +26,14 @@ async def test_full_flow(hass: HomeAssistant, mock_flipr_client: AsyncMock) -> N result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data={ + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_EMAIL: "dummylogin", CONF_PASSWORD: "dummypass", }, @@ -63,7 +70,14 @@ async def test_errors( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data={ + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_EMAIL: "nada", CONF_PASSWORD: "nadap", }, @@ -102,11 +116,19 @@ async def test_no_flipr_found( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data={ + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_EMAIL: "nada", CONF_PASSWORD: "nadap", }, ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {"base": "no_flipr_id_found"} @@ -117,7 +139,14 @@ async def test_no_flipr_found( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data={ + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_EMAIL: "dummylogin", CONF_PASSWORD: "dummypass", }, From 13a39495eadc93ed89c4a8ad8ccbba4bf0b8f30f Mon Sep 17 00:00:00 2001 From: cnico Date: Fri, 21 Aug 2026 07:20:32 +0200 Subject: [PATCH 2/7] Split user flow init and data submission in dio_chacon tests (#179689) --- .../components/chacon_dio/test_config_flow.py | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/tests/components/chacon_dio/test_config_flow.py b/tests/components/chacon_dio/test_config_flow.py index cd6d1939008d7..9ed646092bc5d 100644 --- a/tests/components/chacon_dio/test_config_flow.py +++ b/tests/components/chacon_dio/test_config_flow.py @@ -28,9 +28,15 @@ async def test_full_flow( assert not result["errors"] result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_USERNAME: "dummylogin", CONF_PASSWORD: "dummypass", }, @@ -64,9 +70,15 @@ async def test_errors( mock_dio_chacon_client.get_user_id.side_effect = exception result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_USERNAME: "nada", CONF_PASSWORD: "nadap", }, From 6f5c6c091eddf91ca26077cda1ba96240ba5bbf3 Mon Sep 17 00:00:00 2001 From: Jeremiah Paige Date: Thu, 20 Aug 2026 22:21:02 -0700 Subject: [PATCH 3/7] Split user flow init and data submission in wsdot config flow tests (#179675) --- tests/components/wsdot/test_config_flow.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/components/wsdot/test_config_flow.py b/tests/components/wsdot/test_config_flow.py index bc86ecc3da8fd..e49f56043b20a 100644 --- a/tests/components/wsdot/test_config_flow.py +++ b/tests/components/wsdot/test_config_flow.py @@ -114,12 +114,12 @@ async def test_create_travel_time_subentry( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" + assert result["errors"] == {} # User data; the user made a choice and hit submit - result = await hass.config_entries.subentries.async_init( - (init_integration.entry_id, SUBENTRY_TRAVEL_TIMES), - context={"source": SOURCE_USER}, - data=VALID_USER_TRAVEL_TIME_CONFIG, + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + VALID_USER_TRAVEL_TIME_CONFIG, ) assert result["type"] is FlowResultType.CREATE_ENTRY From d4c7e19419ef5a7bb5a3771114ae53cd6057dcc2 Mon Sep 17 00:00:00 2001 From: Christophe Gagnier Date: Fri, 21 Aug 2026 01:24:33 -0400 Subject: [PATCH 4/7] Add zeroconf discovery to Hot Spring (#179457) Co-authored-by: Moustachauve <2206577+Moustachauve@users.noreply.github.com> --- .../components/hotspring/config_flow.py | 38 +++++++++ .../components/hotspring/manifest.json | 8 +- .../components/hotspring/quality_scale.yaml | 4 +- .../components/hotspring/strings.json | 4 + homeassistant/generated/zeroconf.py | 6 ++ .../components/hotspring/test_config_flow.py | 80 ++++++++++++++++++- 6 files changed, 136 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/hotspring/config_flow.py b/homeassistant/components/hotspring/config_flow.py index d900ea1f10c7c..1377c7cf5e632 100644 --- a/homeassistant/components/hotspring/config_flow.py +++ b/homeassistant/components/hotspring/config_flow.py @@ -15,6 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import TextSelector +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -38,6 +39,9 @@ class HotSpringConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Hot Spring.""" VERSION = 1 + discovered_host: str + discovered_spa: Spa + discovered_title: str @override async def async_step_user( @@ -86,3 +90,37 @@ async def async_step_reconfigure( ) -> ConfigFlowResult: """Handle reconfiguration of the Hot Spring spa.""" return await self.async_step_user(user_input) + + @override + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + self.discovered_host = discovery_info.host + try: + self.discovered_spa = await validate_input( + self.hass, {CONF_HOST: discovery_info.host} + ) + except HotSpringConnectionError, HotSpringError: + return self.async_abort(reason="cannot_connect") + + await self.async_set_unique_id(self.discovered_spa.info.mac_address) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host}) + + self.discovered_title = self.discovered_spa.info.hostname or "Hot Spring Spa" + self.context["title_placeholders"] = {"name": self.discovered_title} + + self._set_confirm_only() + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={"name": self.discovered_title}, + ) + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initiated by zeroconf.""" + return self.async_create_entry( + title=self.discovered_title, + data={CONF_HOST: self.discovered_host}, + ) diff --git a/homeassistant/components/hotspring/manifest.json b/homeassistant/components/hotspring/manifest.json index d6041b7d89e22..58026c901d4ff 100644 --- a/homeassistant/components/hotspring/manifest.json +++ b/homeassistant/components/hotspring/manifest.json @@ -8,5 +8,11 @@ "iot_class": "local_polling", "loggers": ["hotspring"], "quality_scale": "silver", - "requirements": ["python-hotspring==1.3.0"] + "requirements": ["python-hotspring==1.3.0"], + "zeroconf": [ + { + "name": "watkins_spa*", + "type": "_ws._tcp.local." + } + ] } diff --git a/homeassistant/components/hotspring/quality_scale.yaml b/homeassistant/components/hotspring/quality_scale.yaml index a783b783b7408..171e6cfae2f13 100644 --- a/homeassistant/components/hotspring/quality_scale.yaml +++ b/homeassistant/components/hotspring/quality_scale.yaml @@ -50,8 +50,8 @@ rules: # Gold devices: done diagnostics: todo - discovery-update-info: todo - discovery: todo + discovery-update-info: done + discovery: done docs-data-update: done docs-examples: done docs-known-limitations: done diff --git a/homeassistant/components/hotspring/strings.json b/homeassistant/components/hotspring/strings.json index 32ae2b0cd1fa6..c4513d9d5f9d2 100644 --- a/homeassistant/components/hotspring/strings.json +++ b/homeassistant/components/hotspring/strings.json @@ -18,6 +18,10 @@ "host": "Hostname or IP address of your Hot Spring Home Network Adapter (HNA)." }, "description": "Set up your Hot Spring Home Network Adapter (HNA) to integrate with Home Assistant." + }, + "zeroconf_confirm": { + "description": "Do you want to add the Hot Spring spa named `{name}` to Home Assistant?", + "title": "Discovered Hot Spring spa" } } }, diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index dede930fe343f..906453b152124 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -1067,6 +1067,12 @@ "domain": "wled", }, ], + "_ws._tcp.local.": [ + { + "domain": "hotspring", + "name": "watkins_spa*", + }, + ], "_wyoming._tcp.local.": [ { "domain": "wyoming", diff --git a/tests/components/hotspring/test_config_flow.py b/tests/components/hotspring/test_config_flow.py index e8cad058d888d..677da008b306d 100644 --- a/tests/components/hotspring/test_config_flow.py +++ b/tests/components/hotspring/test_config_flow.py @@ -1,18 +1,31 @@ """Tests for the Hot Spring config flow.""" +import dataclasses +from ipaddress import ip_address from unittest.mock import MagicMock from hotspring import HotSpringConnectionError, HotSpringError, Spa import pytest from homeassistant.components.hotspring.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry, get_schema_suggested_value +MOCK_ZEROCONF_DATA = ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.100"), + ip_addresses=[ip_address("192.168.1.100")], + hostname="Watkins_SpaAABBCCDDEEFF.local.", + name="Watkins_SpaAABBCCDDEEFF._ws._tcp.local.", + port=80, + properties={}, + type="_ws._tcp.local.", +) + @pytest.mark.usefixtures("mock_setup_entry", "mock_hotspring") async def test_full_user_flow_implementation(hass: HomeAssistant) -> None: @@ -121,6 +134,71 @@ async def test_form_no_mac_address( assert result["result"].unique_id == "AA:BB:CC:DD:EE:FF" +@pytest.mark.usefixtures("mock_setup_entry", "mock_hotspring") +async def test_full_zeroconf_flow_implementation(hass: HomeAssistant) -> None: + """Test the full zeroconf flow from start to finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=MOCK_ZEROCONF_DATA, + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] is FlowResultType.FORM + assert result["description_placeholders"] == {"name": "ConnectedSpa_DDEEFF"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["title"] == "ConnectedSpa_DDEEFF" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_HOST: "192.168.1.100"} + assert result["result"].unique_id == "AA:BB:CC:DD:EE:FF" + + +@pytest.mark.parametrize( + "exception", + [HotSpringConnectionError, HotSpringError], +) +async def test_zeroconf_connection_error( + hass: HomeAssistant, mock_hotspring: MagicMock, exception: type[Exception] +) -> None: + """Test we abort zeroconf flow on Hot Spring connection error.""" + mock_hotspring.update.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=MOCK_ZEROCONF_DATA, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +@pytest.mark.usefixtures("mock_hotspring") +async def test_zeroconf_device_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we abort zeroconf flow and update host if already configured.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=dataclasses.replace( + MOCK_ZEROCONF_DATA, + ip_address=ip_address("192.168.1.200"), + ip_addresses=[ip_address("192.168.1.200")], + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_HOST] == "192.168.1.200" + + @pytest.mark.usefixtures("mock_setup_entry") async def test_full_reconfigure_flow_success( hass: HomeAssistant, From 23aa6c0a790e6ca7acd0d579e99a48369120d1ee Mon Sep 17 00:00:00 2001 From: Chris <1105672+firstof9@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:07:06 -0700 Subject: [PATCH 5/7] Bump python-openevse-http to 1.5.0 (#179683) --- homeassistant/components/openevse/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/openevse/manifest.json b/homeassistant/components/openevse/manifest.json index ff0e3902d0b8a..98279e460572a 100644 --- a/homeassistant/components/openevse/manifest.json +++ b/homeassistant/components/openevse/manifest.json @@ -9,6 +9,6 @@ "iot_class": "local_push", "loggers": ["openevsehttp"], "quality_scale": "silver", - "requirements": ["python-openevse-http==1.0.1"], + "requirements": ["python-openevse-http==1.5.0"], "zeroconf": ["_openevse._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 0668de64978eb..60b455deca03a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2737,7 +2737,7 @@ python-open-router==0.4.0 python-opendata-transport==0.5.0 # homeassistant.components.openevse -python-openevse-http==1.0.1 +python-openevse-http==1.5.0 # homeassistant.components.opensky python-opensky==1.0.1 From 48017e6bb3666a6a053657a65abd965d03627d85 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 21 Aug 2026 09:50:58 +0200 Subject: [PATCH 6/7] Protect the DeviceRegistry.devices container (#179578) --- .../components/analytics/analytics.py | 2 +- .../components/config/device_registry.py | 4 +- .../components/device_automation/__init__.py | 2 +- homeassistant/components/ecobee/climate.py | 4 +- .../components/homematicip_cloud/entity.py | 5 +- homeassistant/helpers/device_registry.py | 192 ++++++++++----- .../helpers/template/extensions/devices.py | 2 +- tests/common.py | 7 +- .../alexa_devices/test_diagnostics.py | 2 +- tests/components/anthropic/test_init.py | 2 +- tests/components/blue_current/test_init.py | 8 +- .../components/bryant_evolution/test_init.py | 2 +- tests/components/deconz/test_services.py | 4 +- .../components/device_automation/test_init.py | 6 +- .../components/device_tracker/test_entity.py | 12 +- tests/components/ecobee/test_climate.py | 4 +- .../test_init.py | 2 +- .../homeassistant/triggers/test_event.py | 6 +- .../homekit_controller/test_connection.py | 6 +- tests/components/lcn/test_init.py | 2 +- tests/components/lg_netcast/test_trigger.py | 2 +- .../components/lutron_caseta/test_logbook.py | 2 +- tests/components/nut/test_device_action.py | 16 +- tests/components/ollama/test_init.py | 2 +- .../openai_conversation/test_init.py | 2 +- tests/components/ps4/test_media_player.py | 6 +- tests/components/samsungtv/test_trigger.py | 2 +- tests/components/search/test_init.py | 4 +- tests/components/template/test_repairs.py | 6 +- tests/components/tuya/test_diagnostics.py | 2 +- tests/components/unifiprotect/test_init.py | 2 +- .../components/unifiprotect/test_services.py | 4 +- tests/components/waqi/test_init.py | 4 +- .../components/websocket_api/test_commands.py | 3 +- tests/components/wemo/test_coordinator.py | 6 +- tests/components/wolflink/test_init.py | 2 +- tests/components/zinvolt/test_init.py | 2 +- tests/helpers/test_device_registry.py | 226 +++++++++++++----- tests/helpers/test_entity_registry.py | 14 +- tests/helpers/test_helper_integration.py | 4 +- 40 files changed, 383 insertions(+), 202 deletions(-) diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index e5c5e4419ce24..0d5211773ca76 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -774,7 +774,7 @@ async def _async_snapshot_payload(hass: HomeAssistant) -> dict: # noqa: C901 removed_devices: set[str] = set() # Get device list - for device_entry in (*dev_reg.devices.values(), *dev_reg.child_devices.values()): + for device_entry in (*dev_reg.devices, *dev_reg.child_devices.values()): config_entry = hass.config_entries.async_get_entry(device_entry.config_entry_id) if config_entry is None: diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index 0a93b68cd532b..f2934b4dd6236 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -65,7 +65,7 @@ def websocket_list_composite_splits( None, ), } - for composite_id, devices in registry.devices.get_composite_splits().items() + for composite_id, devices in registry._devices.get_composite_splits().items() # noqa: SLF001 }, ) @@ -92,7 +92,7 @@ def websocket_list_devices( inner = b",".join( [ entry.json_repr - for container in (registry.devices, registry.child_devices) + for container in (registry._devices, registry.child_devices) # noqa: SLF001 for entry in container.values() if entry.json_repr is not None ] diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index 24872752d7da1..689e478f655ca 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -240,7 +240,7 @@ async def async_get_device_automations( entity_registry = er.async_get(hass) domain_devices: dict[str, set[str]] = {} device_entities_domains: dict[str, set[str]] = {} - match_device_ids = set(device_ids or device_registry.devices) + match_device_ids = set(device_ids or device_registry._devices) # noqa: SLF001 combined_results: dict[str, list[dict[str, Any]]] = {} for device_id in match_device_ids: diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index a6602bce1ccc4..ec7bde1e9ef81 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -494,7 +494,7 @@ def remote_sensor_ids_names(self) -> list: "id": device.id, "name_by_user": device.name_by_user or device.name, } - for device in device_registry.devices.values() + for device in device_registry.devices for sensor_info in sensors_info if device.name == sensor_info["name"] and any(identifier[0] == DOMAIN for identifier in device.identifiers) @@ -830,7 +830,7 @@ def _sensor_devices_in_preset_mode(self, preset_mode: str | None) -> list[str]: return sorted( [ device.name_by_user or device.name - for device in device_registry.devices.values() + for device in device_registry.devices for sensor_name in sensor_names if device.name == sensor_name and any(identifier[0] == DOMAIN for identifier in device.identifiers) diff --git a/homeassistant/components/homematicip_cloud/entity.py b/homeassistant/components/homematicip_cloud/entity.py index f5e56c2715a66..4ac44c80c11d8 100644 --- a/homeassistant/components/homematicip_cloud/entity.py +++ b/homeassistant/components/homematicip_cloud/entity.py @@ -213,8 +213,9 @@ def async_remove_from_registries(self) -> None: if device_id := self.registry_entry.device_id: # Remove from device registry. device_registry = dr.async_get(self.hass) - if device_id in device_registry.devices: - # This will also remove associated entities from entity registry. + # This will also remove associated entities from entity registry, + # ignore an already removed device. + with contextlib.suppress(KeyError): device_registry.async_remove_device(device_id) else: # noqa: PLR5501 # Remove from entity registry. diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 9ffb5c842bb38..6ee4115aa62ad 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -2,7 +2,7 @@ import asyncio from collections import defaultdict -from collections.abc import Iterable, Mapping, Set as AbstractSet +from collections.abc import Collection, Iterable, Iterator, Mapping, Set as AbstractSet import copy from dataclasses import dataclass from datetime import datetime @@ -1528,6 +1528,76 @@ def get_composite_splits(self) -> dict[str, list[DeviceEntry]]: } +class _DeprecatedDeviceRegistryItemsView: + """Backwards-compatible view returned by the `DeviceRegistry.devices` property. + + Can be removed in release 2027.9. + + Iterating this yields the `DeviceEntry` values, which is the supported way to + enumerate the registry (`for entry in registry.devices`, `list(registry.devices)` + and similar). Using it as a mapping - subscription, device-id membership, + `.values()`, `.get()`, `.get_entry()` and the other container methods - is + deprecated: each such access is reported via `report_usage` (raising for core code + and core integrations, warning for custom integrations) and then delegated to the + underlying container. + """ + + __slots__ = ("_devices",) + + def __init__(self, devices: ActiveDeviceRegistryItems) -> None: + """Initialize the view over a device registry.""" + self._devices = devices + + def __iter__(self) -> Iterator[DeviceEntry]: + """Iterate over the device entries.""" + return iter(self._devices.values()) + + def __len__(self) -> int: + """Return the number of device entries.""" + return len(self._devices) + + def _report_deprecated_use(self) -> None: + """Report deprecated use of `DeviceRegistry.devices`.""" + report_usage( + "uses `device_registry.devices` as a mapping or calls its lookup " + "methods, which is deprecated; iterate it to get the device entries, " + "or use `async_get`, `async_entries_for_config_entry` and similar " + "helpers for lookups", + breaks_in_ha_version="2027.9.0", + core_behavior=ReportBehavior.ERROR, + core_integration_behavior=ReportBehavior.ERROR, + custom_integration_behavior=ReportBehavior.LOG, + ) + + def __getitem__(self, key: str) -> DeviceEntry: + """Return the device entry for a device id (deprecated).""" + self._report_deprecated_use() + return self._devices[key] + + def __contains__(self, obj: object) -> bool: + """Return whether a device entry - or, deprecated, a device id - is registered. + + Value membership (`DeviceEntry in registry.devices`) is the supported use and + matches the `Collection[DeviceEntry]` type. Membership by device id (a `str`) + is the old key-based mapping behavior and is deprecated. + """ + # DeviceEntry is never subclassed, a direct type check is safe + if type(obj) is DeviceEntry: + return self._devices.get(obj.id) == obj + if isinstance(obj, str): + self._report_deprecated_use() + return obj in self._devices + return False + + def __getattr__(self, name: str) -> Any: + """Delegate the remaining mapping methods to the container (deprecated).""" + # Private and dunder names are never proxied. + if name.startswith("_"): + raise AttributeError(name) + self._report_deprecated_use() + return getattr(self._devices, name) + + class ChildDeviceRegistryItems(BaseRegistryItems[ChildDeviceEntry]): """Container for child device registry entries, maps child device id -> entry. @@ -1715,7 +1785,8 @@ def get_orphaned_entry( class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): """Class to hold a registry of devices.""" - devices: ActiveDeviceRegistryItems + _devices: ActiveDeviceRegistryItems + devices: Collection[DeviceEntry] child_devices: ChildDeviceRegistryItems deleted_devices: DeletedDeviceRegistryItems _device_data: dict[str, DeviceEntry] @@ -1814,7 +1885,9 @@ def async_get( ): return child_device if include_composite_devices and ( - split_devices := self.devices.get_devices_for_composite_device_id(device_id) + split_devices := self._devices.get_devices_for_composite_device_id( + device_id + ) ): return self._restore_composite_device(device_id, split_devices) return None @@ -1917,7 +1990,7 @@ def async_get_device_by_identifier( Identifiers are unique within a config entry, so unlike async_get_device the lookup cannot be ambiguous. """ - return self.devices.get_entry( + return self._devices.get_entry( identifiers={identifier}, config_entry_id=config_entry_id ) @@ -1943,7 +2016,7 @@ def async_get_device_by_connection( Connections are unique within a config entry, so unlike async_get_device the lookup cannot be ambiguous. """ - return self.devices.get_entry( + return self._devices.get_entry( connections={connection}, config_entry_id=config_entry_id ) @@ -1962,7 +2035,7 @@ def async_get_devices( If config_entry_id is given, only devices owned by that config entry are returned. """ - return self.devices.get_entries( + return self._devices.get_entries( identifiers, connections, config_entry_id=config_entry_id ) @@ -1983,7 +2056,7 @@ def _async_matching_devices( connections: AbstractSet[tuple[str, str]] | None, ) -> list[DeviceEntry]: """Return devices matching the lookup, narrowed by identifier-domain priority.""" - matches = self.devices.get_entries(identifiers, connections) + matches = self._devices.get_entries(identifiers, connections) if len(matches) > 1 and identifiers: domains = {identifier[0] for identifier in identifiers} preferred = [ @@ -2005,9 +2078,11 @@ def _async_device_ids_for_composite_device_id( self, device_id: str ) -> list[str] | None: """Return the underlying real device ids if device_id is a composite.""" - if device_id in self.devices: + if device_id in self._devices: return None - if split_devices := self.devices.get_devices_for_composite_device_id(device_id): + if split_devices := self._devices.get_devices_for_composite_device_id( + device_id + ): return [split_device.id for split_device in split_devices] return None @@ -2025,7 +2100,7 @@ def async_get_devices_for_composite_device_id( identifier/connection resolved to a single multi-config-entry device. Returns an empty list for a device id which is not a composite device id. """ - return self.devices.get_devices_for_composite_device_id(composite_device_id) + return self._devices.get_devices_for_composite_device_id(composite_device_id) @callback def async_is_composite_device_id(self, device_id: str) -> bool | None: @@ -2044,9 +2119,9 @@ def async_is_composite_device_id(self, device_id: str) -> bool | None: core_integration_behavior=ReportBehavior.ERROR, breaks_in_ha_version="2027.9.0", ) - if device_id in self.devices: + if device_id in self._devices: return False - if self.devices.get_devices_for_composite_device_id(device_id): + if self._devices.get_devices_for_composite_device_id(device_id): return True return None @@ -2060,9 +2135,9 @@ def _resolve_via_device_id( it was split into - preferring the split owned by config_entry_id, then one owned by the same domain, then any of them. Returns None for an unknown id. """ - if via_device_id in self.devices: + if via_device_id in self._devices: return via_device_id - if splits := self.devices.get_devices_for_composite_device_id(via_device_id): + if splits := self._devices.get_devices_for_composite_device_id(via_device_id): # The composite resolution can be removed in HA Core 2027.8 report_usage( f"passes the id of a pre-migration composite device {via_device_id} " @@ -2270,7 +2345,7 @@ def async_get_or_create( # noqa: C901 f"{sorted(matched_child_device.identifiers)}", ) - device = self.devices.get_entry( + device = self._devices.get_entry( connections=connections, identifiers=identifiers, config_entry_id=config_entry_id, @@ -2286,7 +2361,7 @@ def async_get_or_create( # noqa: C901 if device is not None: # Collision reconciliation can update the matched device (e.g. detach # its via link) - device = self.devices[device.id] + device = self._devices[device.id] # Resolved after collision reconciliation so a removed stale duplicate can't be # linked @@ -2363,7 +2438,7 @@ def async_get_or_create( # noqa: C901 ) disabled_by = UNDEFINED - self.devices[device.id] = device + self._devices[device.id] = device # If creating a new device, default to the config entry name if not name or name is UNDEFINED: name = config_entry.title @@ -2407,14 +2482,14 @@ def async_get_or_create( # noqa: C901 # config entry (a via device may legitimately belong to a different config # entry). This ambiguity is why via_device is deprecated. via = ( - self.devices.get_entry( + self._devices.get_entry( identifiers={via_device}, config_entry_id=config_entry_id ) or self._first_device_in_domain( - self.devices.get_entries(identifiers={via_device}), + self._devices.get_entries(identifiers={via_device}), config_entry.domain, ) - or self.devices.get_entry(identifiers={via_device}) + or self._devices.get_entry(identifiers={via_device}) ) if via is None: report_usage( @@ -2630,7 +2705,7 @@ def async_get_or_create_child( matched_device: DeviceEntry | None = None if child_device is None: - matched_device = self.devices.get_entry( + matched_device = self._devices.get_entry( identifiers=identifiers, config_entry_id=config_entry_id ) @@ -2653,7 +2728,7 @@ def async_get_or_create_child( # The identifiers are registered by a full device of the config entry: # the integration split the device into child devices, so convert it, # preserving its id. - matched_device = self.devices[matched_device.id] + matched_device = self._devices[matched_device.id] child_device = self._async_convert_device_to_child( matched_device, parent, identifiers ) @@ -2831,13 +2906,13 @@ def _async_convert_device_to_child( name_by_user=device.name_by_user, parent_device_id=parent.id, ) - del self.devices[device.id] + del self._devices[device.id] self.child_devices[child_device.id] = child_device # A via_device_id must not resolve to a child device; detach inbound via # links to the converted device, as async_remove_device does, before firing # the conversion event. - for other_device in list(self.devices.values()): + for other_device in list(self._devices.values()): if other_device.via_device_id == device.id: self._async_update_device(other_device.id, via_device_id=None) @@ -2895,7 +2970,7 @@ def _async_update_device( # noqa: C901 :param remove_config_subentry_id: Remove the device from a specific subentry of remove_config_entry_id """ - old = self.devices[device_id] + old = self._devices[device_id] new_values: dict[str, Any] = {} # Dict with new key/value pairs old_values: dict[str, Any] = {} # Dict with old key/value pairs @@ -3079,14 +3154,14 @@ def _async_update_device( # noqa: C901 # completes the move to the target entry the others must not also move # there and collide; clear their pending moves. if old.composite_device_id is not None: - for sibling in self.devices.get_devices_for_composite_device_id( + for sibling in self._devices.get_devices_for_composite_device_id( old.composite_device_id ): if ( sibling.id != device_id and sibling._pending_move is not None # noqa: SLF001 ): - self.devices[sibling.id] = attr.evolve( + self._devices[sibling.id] = attr.evolve( sibling, pending_move=None ) @@ -3295,7 +3370,7 @@ def _async_update_device( # noqa: C901 self.hass.verify_event_loop_thread("device_registry._async_update_device") new = attr.evolve(old, **new_values) - self.devices[device_id] = new + self._devices[device_id] = new # On a move, the device's whole retained identity newly appears in the target # config entry; added_identifiers/added_connections are empty on a retained- @@ -3753,7 +3828,7 @@ def _async_reconcile_collisions( if not matched_device.has_composite_identifiers: identifiers = matched_device.identifiers | identifiers connections = matched_device.connections | connections - colliding = self.devices.get_colliding_device_ids( + colliding = self._devices.get_colliding_device_ids( identifiers, connections, config_entry_id=config_entry.entry_id, @@ -3771,7 +3846,7 @@ def _async_reconcile_collisions( f"registered for device {holder_id} of the same config entry", ) for holder_id, (shared_identifiers, shared_connections) in colliding.items(): - holder = self.devices[holder_id] + holder = self._devices[holder_id] remaining_identifiers = holder.identifiers - shared_identifiers remaining_connections = holder.connections - shared_connections if not remaining_identifiers and not remaining_connections: @@ -3848,7 +3923,7 @@ def _validate_connections( # conflict, the index will only see the last one and we will not # be able to tell which one caused the conflict if ( - existing_device := self.devices.get_entry( + existing_device := self._devices.get_entry( connections={connection}, config_entry_id=config_entry_id ) ) and existing_device.id != device_id: @@ -3879,7 +3954,7 @@ def _validate_identifiers( # conflict, the index will only see the last one and we will not # be able to tell which one caused the conflict if ( - existing_device := self.devices.get_entry( + existing_device := self._devices.get_entry( identifiers={identifier}, config_entry_id=config_entry_id ) ) and existing_device.id != device_id: @@ -3913,7 +3988,7 @@ def _validate_child_identifiers( ) and existing_child_device.id != child_device_id: raise DeviceIdentifierCollisionError(identifiers, existing_child_device) if ( - existing_device := self.devices.get_entry( + existing_device := self._devices.get_entry( identifiers={identifier}, config_entry_id=config_entry_id ) ) is not None: @@ -3953,9 +4028,9 @@ def _async_update_composite_device( for underlying_id in underlying_ids: self.async_update_device(underlying_id, **forward) remaining = [ - self.devices[underlying_id] + self._devices[underlying_id] for underlying_id in underlying_ids - if underlying_id in self.devices + if underlying_id in self._devices ] if not remaining: return None @@ -3977,7 +4052,7 @@ def async_remove_device(self, device_id: str) -> None: # Removing the parent removes its child devices for child in self.child_devices.get_children_for_device_id(device_id): self._async_remove_child_device(child) - device = self.devices.pop(device_id) + device = self._devices.pop(device_id) config_entry = self.hass.config_entries.async_get_entry(device.config_entry_id) self.deleted_devices[device_id] = DeletedDeviceEntry( area_id=device.area_id, @@ -3994,7 +4069,7 @@ def async_remove_device(self, device_id: str) -> None: orphaned_timestamp=None, domain=config_entry.domain if config_entry is not None else None, ) - for other_device in list(self.devices.values()): + for other_device in list(self._devices.values()): if other_device.via_device_id == device_id: self._async_update_device(other_device.id, via_device_id=None) self.hass.bus.async_fire_internal( @@ -4186,7 +4261,8 @@ def get_optional_enum[_EnumT: StrEnum]( shadowed_count, ) - self.devices = devices + self._devices = devices + self.devices = _DeprecatedDeviceRegistryItemsView(self._devices) self.child_devices = child_devices self.deleted_devices = deleted_devices self._device_data = devices.data @@ -4211,7 +4287,7 @@ def _data_to_save(self) -> dict[str, Any]: # other than the event loop. return { "devices": [ - entry.as_storage_fragment for entry in list(self.devices.values()) + entry.as_storage_fragment for entry in list(self._devices.values()) ], "child_devices": [ entry.as_storage_fragment for entry in list(self.child_devices.values()) @@ -4278,7 +4354,7 @@ def async_clear_config_entry( self._live_device_ids.pop(config_entry_id, None) domain = self._resolve_orphan_domain(config_entry_id, domain) now_time = time.time() - for device in self.devices.get_devices_for_config_entry_id(config_entry_id): + for device in self._devices.get_devices_for_config_entry_id(config_entry_id): self.async_remove_device(device.id) # Child devices share their parent's config entry, so the loop above removes # them through the parent cascade; guard against store corruption anyway. @@ -4289,22 +4365,22 @@ def async_clear_config_entry( # A split device records the composite's former primary config entry; when that # config entry is removed, clear the now-dangling reference so a restored # composite no longer points at a config entry that no longer exists. - for device in list(self.devices.values()): + for device in list(self._devices.values()): if device.composite_primary_config_entry == config_entry_id: - self.devices[device.id] = attr.evolve( + self._devices[device.id] = attr.evolve( device, composite_primary_config_entry=None ) self.async_schedule_save() # A device owned by another config entry may hold a transient pending move # targeting the entry being removed; clear it so a later completion deletes the # device instead of moving it onto the removed entry. - for device in list(self.devices.values()): + for device in list(self._devices.values()): pending_move = device._pending_move # noqa: SLF001 if ( pending_move is not None and pending_move.config_entry_id == config_entry_id ): - self.devices[device.id] = attr.evolve(device, pending_move=None) + self._devices[device.id] = attr.evolve(device, pending_move=None) for deleted_device in list(self.deleted_devices.values()): if deleted_device.config_entry_id != config_entry_id: continue @@ -4317,7 +4393,7 @@ def async_clear_config_subentry( """Clear config subentry from registry entries.""" domain = self._resolve_orphan_domain(config_entry_id, domain) now_time = time.time() - for device in self.devices.get_devices_for_config_entry_id(config_entry_id): + for device in self._devices.get_devices_for_config_entry_id(config_entry_id): if device.config_subentry_id != config_subentry_id: continue self.async_remove_device(device.id) @@ -4332,14 +4408,14 @@ def async_clear_config_subentry( # A device may hold a transient pending move targeting the subentry being removed; # clear it so a later completion deletes the device instead of validating against # the removed subentry. - for device in list(self.devices.values()): + for device in list(self._devices.values()): pending_move = device._pending_move # noqa: SLF001 if ( pending_move is not None and pending_move.config_entry_id == config_entry_id and pending_move.config_subentry_id == config_subentry_id ): - self.devices[device.id] = attr.evolve(device, pending_move=None) + self._devices[device.id] = attr.evolve(device, pending_move=None) for deleted_device in list(self.deleted_devices.values()): if ( deleted_device.config_entry_id != config_entry_id @@ -4369,7 +4445,7 @@ def async_purge_expired_orphaned_devices(self) -> None: @callback def async_clear_area_id(self, area_id: str) -> None: """Clear area id from registry entries.""" - for device in self.devices.get_devices_for_area_id(area_id): + for device in self._devices.get_devices_for_area_id(area_id): self._async_update_device(device.id, area_id=None) for child_device in self.child_devices.get_devices_for_area_id(area_id): self._async_update_child_device(child_device.id, area_id=None) @@ -4384,7 +4460,7 @@ def async_clear_area_id(self, area_id: str) -> None: @callback def async_clear_label_id(self, label_id: str) -> None: """Clear label from registry entries.""" - for device in self.devices.get_devices_for_label(label_id): + for device in self._devices.get_devices_for_label(label_id): self._async_update_device(device.id, labels=device.labels - {label_id}) for child_device in self.child_devices.get_devices_for_label(label_id): self._async_update_child_device( @@ -4446,7 +4522,7 @@ def async_get_device_and_config_entry_for_domain( composite is returned as the device. """ registry = async_get(hass) - if (device := registry.devices.get(device_id)) is not None: + if (device := registry._devices.get(device_id)) is not None: # noqa: SLF001 config_entry = hass.config_entries.async_get_entry(device.config_entry_id) if config_entry is not None and config_entry.domain == domain: return device, config_entry @@ -4479,7 +4555,7 @@ def async_entries_for_area( Includes child devices with the area set explicitly, and child devices inheriting the area from their parent device. """ - devices = registry.devices.get_devices_for_area_id(area_id) + devices = registry._devices.get_devices_for_area_id(area_id) # noqa: SLF001 entries: list[AnyDeviceEntry] = list(devices) entries.extend(registry.child_devices.get_devices_for_area_id(area_id)) for device in devices: @@ -4522,7 +4598,7 @@ def async_entries_for_label( parent, so a child appears here only when the label is set on the child itself. """ entries: list[AnyDeviceEntry] = list( - registry.devices.get_devices_for_label(label_id) + registry._devices.get_devices_for_label(label_id) # noqa: SLF001 ) entries.extend(registry.child_devices.get_devices_for_label(label_id)) return entries @@ -4533,7 +4609,9 @@ def async_entries_for_config_entry( registry: DeviceRegistry, config_entry_id: str ) -> list[DeviceEntry]: """Return entries that match a config entry.""" - return registry.devices.get_devices_for_config_entry_id(config_entry_id) + return registry._devices.get_devices_for_config_entry_id( # noqa: SLF001 + config_entry_id + ) @callback @@ -4626,7 +4704,7 @@ def async_cleanup( config_entry_ids = set(hass.config_entries.async_entry_ids()) references_config_entries = { device.id - for device in dev_reg.devices.values() + for device in dev_reg._devices.values() # noqa: SLF001 if device.config_entry_id in config_entry_ids } @@ -4634,7 +4712,7 @@ def async_cleanup( device_ids_referenced_by_entities = set(ent_reg.entities.get_device_ids()) orphan = ( - set(dev_reg.devices) + set(dev_reg._devices) # noqa: SLF001 - device_ids_referenced_by_entities - references_config_entries ) @@ -4644,7 +4722,7 @@ def async_cleanup( # Find all referenced config entries that no longer exist # This shouldn't happen but have not been able to track down the bug :( - for device in list(dev_reg.devices.values()): + for device in list(dev_reg._devices.values()): # noqa: SLF001 if device.config_entry_id not in config_entry_ids: dev_reg._async_update_device( # noqa: SLF001 device.id, remove_config_entry_id=device.config_entry_id @@ -4653,7 +4731,7 @@ def async_cleanup( # A child device shares its parent's (valid) config entry, and the remove cascade # makes a child without its parent impossible; guard against store corruption anyway. for child_device in list(dev_reg.child_devices.values()): - if child_device.parent_device_id not in dev_reg.devices: + if child_device.parent_device_id not in dev_reg._devices: # noqa: SLF001 _LOGGER.error( "Removing child device %s: its parent device %s is not in the " "device registry", diff --git a/homeassistant/helpers/template/extensions/devices.py b/homeassistant/helpers/template/extensions/devices.py index 17f7b521337ee..b1312b8f61e92 100644 --- a/homeassistant/helpers/template/extensions/devices.py +++ b/homeassistant/helpers/template/extensions/devices.py @@ -85,7 +85,7 @@ def device_id(self, entity_id_or_device_name: str) -> str | None: return next( ( device_id - for container in (dev_reg.devices, dev_reg.child_devices) + for container in (dev_reg._devices, dev_reg.child_devices) # noqa: SLF001 for device_id, device in container.items() if (name := device.name_by_user or device.name) and (str(entity_id_or_device_name) == name) diff --git a/tests/common.py b/tests/common.py index 9966c166cca95..cbf4a742c31d7 100644 --- a/tests/common.py +++ b/tests/common.py @@ -759,14 +759,15 @@ def mock_device_registry( fixture instead. """ registry = dr.DeviceRegistry(hass) - registry.devices = dr.ActiveDeviceRegistryItems() - registry._device_data = registry.devices.data + registry._devices = dr.ActiveDeviceRegistryItems() + registry.devices = registry._devices.values() + registry._device_data = registry._devices.data registry.child_devices = dr.ChildDeviceRegistryItems() registry._child_device_data = registry.child_devices.data if mock_entries is None: mock_entries = {} for key, entry in mock_entries.items(): - registry.devices[key] = entry + registry._devices[key] = entry registry.deleted_devices = dr.DeletedDeviceRegistryItems() hass.data[dr.DATA_REGISTRY] = registry diff --git a/tests/components/alexa_devices/test_diagnostics.py b/tests/components/alexa_devices/test_diagnostics.py index effdae585ed2d..00a63db8ca8f4 100644 --- a/tests/components/alexa_devices/test_diagnostics.py +++ b/tests/components/alexa_devices/test_diagnostics.py @@ -55,7 +55,7 @@ async def test_device_diagnostics( device = device_registry.async_get_device_by_identifier( (DOMAIN, TEST_DEVICE_1_SN), mock_config_entry.entry_id ) - assert device, repr(device_registry.devices) + assert device, repr(device_registry._devices) assert await get_diagnostics_for_device( hass, hass_client, mock_config_entry, device diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py index bf8159eb1be7d..b4ce16a0b0d54 100644 --- a/tests/components/anthropic/test_init.py +++ b/tests/components/anthropic/test_init.py @@ -957,7 +957,7 @@ async def test_migrate_entry_to_v2_3( conversation_device = attr.evolve( conversation_device, disabled_by=device_disabled_by ) - device_registry.devices[conversation_device.id] = conversation_device + device_registry._devices[conversation_device.id] = conversation_device conversation_entity = entity_registry.async_get_or_create( "conversation", DOMAIN, diff --git a/tests/components/blue_current/test_init.py b/tests/components/blue_current/test_init.py index 3b2dec064c995..4225177cfd5e7 100644 --- a/tests/components/blue_current/test_init.py +++ b/tests/components/blue_current/test_init.py @@ -119,7 +119,7 @@ async def test_start_charging_action( DOMAIN, SERVICE_START_CHARGE_SESSION, { - CONF_DEVICE_ID: list(device_registry.devices)[0], + CONF_DEVICE_ID: list(device_registry._devices)[0], CHARGING_CARD_ID: "TEST_CARD", }, blocking=True, @@ -139,7 +139,7 @@ async def test_start_charging_action_without_card( DOMAIN, SERVICE_START_CHARGE_SESSION, { - CONF_DEVICE_ID: list(device_registry.devices)[0], + CONF_DEVICE_ID: list(device_registry._devices)[0], }, blocking=True, ) @@ -187,7 +187,7 @@ async def test_start_charging_action_errors( DOMAIN, SERVICE_START_CHARGE_SESSION, { - CONF_DEVICE_ID: list(device_registry.devices)[0], + CONF_DEVICE_ID: list(device_registry._devices)[0], }, blocking=True, ) @@ -207,7 +207,7 @@ async def test_start_charging_action_errors( DOMAIN, SERVICE_START_CHARGE_SESSION, { - CONF_DEVICE_ID: list(device_registry.devices)[0], + CONF_DEVICE_ID: list(device_registry._devices)[0], }, blocking=True, ) diff --git a/tests/components/bryant_evolution/test_init.py b/tests/components/bryant_evolution/test_init.py index 4c0246a7c0ad1..6b03382bb8b28 100644 --- a/tests/components/bryant_evolution/test_init.py +++ b/tests/components/bryant_evolution/test_init.py @@ -95,7 +95,7 @@ async def test_setup_multiple_systems_zones( device_registry = dr.async_get(hass) # pylint: disable=home-assistant-tests-registry-fixtures def find_device(name): - return next(filter(lambda x: x.name == name, device_registry.devices.values())) + return next(filter(lambda x: x.name == name, device_registry.devices)) sam = find_device("System Access Module") s1 = find_device("System 1") diff --git a/tests/components/deconz/test_services.py b/tests/components/deconz/test_services.py index 0ebb4bb191d3c..66d284d0418ee 100644 --- a/tests/components/deconz/test_services.py +++ b/tests/components/deconz/test_services.py @@ -363,7 +363,7 @@ async def test_remove_orphaned_entries_service( len( [ entry - for entry in device_registry.devices.values() + for entry in device_registry.devices if config_entry_setup.entry_id in entry.config_entries ] ) @@ -399,7 +399,7 @@ async def test_remove_orphaned_entries_service( len( [ entry - for entry in device_registry.devices.values() + for entry in device_registry.devices if config_entry_setup.entry_id in entry.config_entries ] ) diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index f57a1abe45c38..de1481a9b8165 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -1865,13 +1865,13 @@ async def test_validate_config_rewrites_composite_device_id( ) old_id = "composite00000000000000000000ab" # Simulate a migration split: both devices carry the pre-migration composite id - device_registry.devices[device_fake.id] = attr.evolve( + device_registry._devices[device_fake.id] = attr.evolve( device_fake, composite_device_id=old_id ) - device_registry.devices[device_other.id] = attr.evolve( + device_registry._devices[device_other.id] = attr.evolve( device_other, composite_device_id=old_id ) - assert old_id not in device_registry.devices + assert old_id not in device_registry._devices validated = await async_validate_device_automation_config( hass, diff --git a/tests/components/device_tracker/test_entity.py b/tests/components/device_tracker/test_entity.py index 9f7c3a2cc23be..861d5f7de2a61 100644 --- a/tests/components/device_tracker/test_entity.py +++ b/tests/components/device_tracker/test_entity.py @@ -1711,10 +1711,10 @@ async def test_scanner_entity_attaches_to_split_of_composite_device( identifiers={("other", "x")}, ) # Simulate a migration split: both devices share the pre-migration composite id - device_registry.devices[own_split.id] = attr.evolve( + device_registry._devices[own_split.id] = attr.evolve( own_split, composite_device_id=old_id ) - device_registry.devices[other_split.id] = attr.evolve( + device_registry._devices[other_split.id] = attr.evolve( other_split, composite_device_id=old_id ) # async_get_device now resolves the shared MAC to the synthesized composite @@ -1723,7 +1723,7 @@ async def test_scanner_entity_attaches_to_split_of_composite_device( ) assert composite is not None assert composite.id == old_id - assert old_id not in device_registry.devices + assert old_id not in device_registry._devices scanner_entity = MockScannerEntity(mac_address=mac, unique_id=f"{mac}_scanner") scanner_entity.entity_id = "device_tracker.composite_scanner" @@ -1760,7 +1760,7 @@ async def test_scanner_entity_composite_device_without_own_split( connections={(dr.CONNECTION_NETWORK_MAC, mac)}, identifiers={("other", identifier)}, ) - device_registry.devices[split.id] = attr.evolve( + device_registry._devices[split.id] = attr.evolve( split, composite_device_id=old_id ) composite = device_registry.async_get_device( @@ -1768,7 +1768,7 @@ async def test_scanner_entity_composite_device_without_own_split( ) assert composite is not None assert composite.id == old_id - assert old_id not in device_registry.devices + assert old_id not in device_registry._devices scanner_entity = MockScannerEntity(mac_address=mac, unique_id=f"{mac}_scanner") scanner_entity.entity_id = "device_tracker.composite_scanner" @@ -1921,7 +1921,7 @@ async def test_scanner_entity_prunes_composite_identifiers( connections={(dr.CONNECTION_NETWORK_MAC, mac)}, identifiers={("other", "copied-identifier")}, ) - device_registry.devices[own_split.id] = attr.evolve( + device_registry._devices[own_split.id] = attr.evolve( own_split, composite_device_id="composite00000000000000000000000", has_composite_identifiers=True, diff --git a/tests/components/ecobee/test_climate.py b/tests/components/ecobee/test_climate.py index 972bb4dfd9c48..ff8fe064d065b 100644 --- a/tests/components/ecobee/test_climate.py +++ b/tests/components/ecobee/test_climate.py @@ -464,7 +464,7 @@ async def test_remote_sensor_devices( async_fire_time_changed(hass) state = hass.states.get(ENTITY_ID) device_registry = dr.async_get(hass) # pylint: disable=home-assistant-tests-registry-fixtures - for device in device_registry.devices.values(): + for device in device_registry.devices: if device.name == "Remote Sensor 1": remote_sensor_1_id = device.id if device.name == "ecobee": @@ -582,7 +582,7 @@ async def test_set_sensors_used_in_climate(hass: HomeAssistant) -> None: # Get device_id of remote sensor from the device registry. await setup_platform(hass, [const.Platform.CLIMATE, const.Platform.SENSOR]) device_registry = dr.async_get(hass) # pylint: disable=home-assistant-tests-registry-fixtures - for device in device_registry.devices.values(): + for device in device_registry.devices: if device.name == "Remote Sensor 1": remote_sensor_1_id = device.id if device.name == "ecobee": diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index bc139d00c9036..03fd39fd62f18 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -1202,7 +1202,7 @@ async def test_migrate_entry_from_v2_3( conversation_device = attr.evolve( conversation_device, disabled_by=device_disabled_by ) - device_registry.devices[conversation_device.id] = conversation_device + device_registry._devices[conversation_device.id] = conversation_device conversation_entity = entity_registry.async_get_or_create( "conversation", DOMAIN, diff --git a/tests/components/homeassistant/triggers/test_event.py b/tests/components/homeassistant/triggers/test_event.py index 316d2eba42d8f..667f29ba528c0 100644 --- a/tests/components/homeassistant/triggers/test_event.py +++ b/tests/components/homeassistant/triggers/test_event.py @@ -657,13 +657,13 @@ def split_devices( identifiers={("itg2", "1")}, name="Split device 2", ) - device_registry.devices[device_1.id] = attr.evolve( + device_registry._devices[device_1.id] = attr.evolve( device_1, composite_device_id=COMPOSITE_ID ) - device_registry.devices[device_2.id] = attr.evolve( + device_registry._devices[device_2.id] = attr.evolve( device_2, composite_device_id=COMPOSITE_ID ) - return device_registry.devices[device_1.id], device_registry.devices[device_2.id] + return device_registry._devices[device_1.id], device_registry._devices[device_2.id] _EVENT_TRIGGER = { diff --git a/tests/components/homekit_controller/test_connection.py b/tests/components/homekit_controller/test_connection.py index c723c4e010590..198c641eb1782 100644 --- a/tests/components/homekit_controller/test_connection.py +++ b/tests/components/homekit_controller/test_connection.py @@ -251,8 +251,10 @@ async def test_migrate_device_id_shared_identifier_only_migrates_own( name="Other", ) old_id = "composite00000000000000000000ab" - device_registry.devices[device.id] = attr.evolve(device, composite_device_id=old_id) - device_registry.devices[other_device.id] = attr.evolve( + device_registry._devices[device.id] = attr.evolve( + device, composite_device_id=old_id + ) + device_registry._devices[other_device.id] = attr.evolve( other_device, composite_device_id=old_id ) # The shared identifier now resolves to the read-only composite diff --git a/tests/components/lcn/test_init.py b/tests/components/lcn/test_init.py index b1b2eedafb6ac..eb46429748ae0 100644 --- a/tests/components/lcn/test_init.py +++ b/tests/components/lcn/test_init.py @@ -87,7 +87,7 @@ async def test_async_setup_entry_update( ) assert dummy_entity in entity_registry.entities.values() - assert dummy_device in device_registry.devices.values() + assert dummy_device in device_registry.devices @pytest.mark.parametrize( diff --git a/tests/components/lg_netcast/test_trigger.py b/tests/components/lg_netcast/test_trigger.py index d6959f792379e..08cc411e500e8 100644 --- a/tests/components/lg_netcast/test_trigger.py +++ b/tests/components/lg_netcast/test_trigger.py @@ -36,7 +36,7 @@ async def test_lg_netcast_turn_on_trigger_device_id( device = device_registry.async_get_device_by_identifier( (DOMAIN, UNIQUE_ID), config_entry.entry_id ) - assert device, repr(device_registry.devices) + assert device, repr(device_registry._devices) assert await async_setup_component( hass, diff --git a/tests/components/lutron_caseta/test_logbook.py b/tests/components/lutron_caseta/test_logbook.py index e0b9bcc2d0002..e73ecde8294a9 100644 --- a/tests/components/lutron_caseta/test_logbook.py +++ b/tests/components/lutron_caseta/test_logbook.py @@ -101,7 +101,7 @@ async def test_humanify_lutron_caseta_button_event_integration_not_loaded( await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() - for device in device_registry.devices.values(): + for device in device_registry.devices: if device.config_entries == {config_entry.entry_id}: dr_device_id = device.id break diff --git a/tests/components/nut/test_device_action.py b/tests/components/nut/test_device_action.py index 3f48d073f9fa7..7d77b9eb9b7ff 100644 --- a/tests/components/nut/test_device_action.py +++ b/tests/components/nut/test_device_action.py @@ -41,7 +41,7 @@ async def test_get_all_actions_for_specified_user( list_vars={"ups.status": "OL"}, list_commands_return_value=list_commands_return_value, ) - device_entry = next(device for device in device_registry.devices.values()) + device_entry = next(device for device in device_registry.devices) expected_actions = [ { "domain": DOMAIN, @@ -71,7 +71,7 @@ async def test_no_actions_for_anonymous_user( list_vars={"ups.status": "OL"}, list_commands_return_value=list_commands_return_value, ) - device_entry = next(device for device in device_registry.devices.values()) + device_entry = next(device for device in device_registry.devices) actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) @@ -110,7 +110,7 @@ async def test_no_actions_device_invalid( list_vars={"ups.status": "OL"}, list_commands_return_value=list_commands_return_value, ) - device_entry = next(device for device in device_registry.devices.values()) + device_entry = next(device for device in device_registry.devices) assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() @@ -131,7 +131,7 @@ async def test_list_commands_exception( hass, list_vars={"ups.status": "OL"}, list_commands_side_effect=NUTError ) - device_entry = next(device for device in device_registry.devices.values()) + device_entry = next(device for device in device_registry.devices) actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) @@ -152,7 +152,7 @@ async def test_unsupported_command( list_vars={"ups.status": "OL"}, list_commands_return_value=list_commands_return_value, ) - device_entry = next(device for device in device_registry.devices.values()) + device_entry = next(device for device in device_registry.devices) actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) @@ -174,7 +174,7 @@ async def test_action(hass: HomeAssistant, device_registry: dr.DeviceRegistry) - list_commands_return_value=list_commands_return_value, run_command=run_command, ) - device_entry = next(device for device in device_registry.devices.values()) + device_entry = next(device for device in device_registry.devices) assert await async_setup_component( hass, @@ -232,7 +232,7 @@ async def test_run_command_exception( list_commands_return_value={command_name: None}, run_command=run_command, ) - device_entry = next(device for device in device_registry.devices.values()) + device_entry = next(device for device in device_registry.devices) platform = await device_automation.async_get_device_automation_platform( hass, DOMAIN, DeviceAutomationType.ACTION @@ -315,7 +315,7 @@ async def test_action_exception_device_invalid( list_vars={"ups.status": "OL"}, list_commands_return_value=list_commands_return_value, ) - device_entry = next(device for device in device_registry.devices.values()) + device_entry = next(device for device in device_registry.devices) assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/ollama/test_init.py b/tests/components/ollama/test_init.py index 460ce25337fee..9fddcc99335db 100644 --- a/tests/components/ollama/test_init.py +++ b/tests/components/ollama/test_init.py @@ -1051,7 +1051,7 @@ async def test_migrate_entry_from_v3_2( conversation_device = attr.evolve( conversation_device, disabled_by=device_disabled_by ) - device_registry.devices[conversation_device.id] = conversation_device + device_registry._devices[conversation_device.id] = conversation_device conversation_entity = entity_registry.async_get_or_create( "conversation", DOMAIN, diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index dcd6d492ddc8a..9a09697af7a93 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -1636,7 +1636,7 @@ async def test_migrate_entry_from_v2_3( conversation_device = attr.evolve( conversation_device, disabled_by=device_disabled_by ) - device_registry.devices[conversation_device.id] = conversation_device + device_registry._devices[conversation_device.id] = conversation_device conversation_entity = entity_registry.async_get_or_create( "conversation", DOMAIN, diff --git a/tests/components/ps4/test_media_player.py b/tests/components/ps4/test_media_player.py index f47d9961a39f2..a45406a2ebb02 100644 --- a/tests/components/ps4/test_media_player.py +++ b/tests/components/ps4/test_media_player.py @@ -313,7 +313,7 @@ async def test_device_info_is_set_from_status_correctly( mock_state = hass.states.get(mock_entity_id).state - mock_d_entries = device_registry.devices + mock_d_entries = device_registry._devices mock_entry = device_registry.async_get_device_by_identifier( (DOMAIN, MOCK_HOST_ID), MOCK_ENTRY_ID ) @@ -359,7 +359,7 @@ async def test_device_info_is_assummed( identifiers={(DOMAIN, MOCK_HOST_ID)}, sw_version=MOCK_HOST_VERSION, ) - mock_d_entries = device_registry.devices + mock_d_entries = device_registry._devices assert len(mock_d_entries) == 1 # Create a entity_registry entry which is using identifiers from device. @@ -389,7 +389,7 @@ async def test_device_info_assummed_works( """Reverse test that device info assumption works.""" mock_entity_id = await setup_mock_component(hass) mock_state = hass.states.get(mock_entity_id).state - mock_d_entries = device_registry.devices + mock_d_entries = device_registry._devices # Ensure that state is not set. assert mock_state == STATE_UNKNOWN diff --git a/tests/components/samsungtv/test_trigger.py b/tests/components/samsungtv/test_trigger.py index 61d1a943df361..ba23551602856 100644 --- a/tests/components/samsungtv/test_trigger.py +++ b/tests/components/samsungtv/test_trigger.py @@ -33,7 +33,7 @@ async def test_turn_on_trigger_device_id( device = device_registry.async_get_device_by_identifier( (DOMAIN, "be9554b9-c9fb-41f4-8920-22da015376a4"), entry.entry_id ) - assert device, repr(device_registry.devices) + assert device, repr(device_registry._devices) assert await async_setup_component( hass, diff --git a/tests/components/search/test_init.py b/tests/components/search/test_init.py index daa6396e35bec..fd71359d15fd5 100644 --- a/tests/components/search/test_init.py +++ b/tests/components/search/test_init.py @@ -1143,10 +1143,10 @@ async def test_search_pre_migration_composite_device( # Simulate a migration split: both devices carry the pre-migration composite id composite_device_id = "composite00000000000000000000ab" - device_registry.devices[device_1.id] = attr.evolve( + device_registry._devices[device_1.id] = attr.evolve( device_1, composite_device_id=composite_device_id ) - device_registry.devices[device_2.id] = attr.evolve( + device_registry._devices[device_2.id] = attr.evolve( device_2, composite_device_id=composite_device_id ) diff --git a/tests/components/template/test_repairs.py b/tests/components/template/test_repairs.py index 1be7befffd1e9..32017c95be177 100644 --- a/tests/components/template/test_repairs.py +++ b/tests/components/template/test_repairs.py @@ -50,13 +50,13 @@ def split_devices( identifiers={("itg2", "1")}, name="Split device 2", ) - device_registry.devices[device_1.id] = attr.evolve( + device_registry._devices[device_1.id] = attr.evolve( device_1, composite_device_id=COMPOSITE_ID ) - device_registry.devices[device_2.id] = attr.evolve( + device_registry._devices[device_2.id] = attr.evolve( device_2, composite_device_id=COMPOSITE_ID ) - return device_registry.devices[device_1.id], device_registry.devices[device_2.id] + return device_registry._devices[device_1.id], device_registry._devices[device_2.id] async def _setup_template_entry( diff --git a/tests/components/tuya/test_diagnostics.py b/tests/components/tuya/test_diagnostics.py index 76608b1a1dda6..90900bae43cf1 100644 --- a/tests/components/tuya/test_diagnostics.py +++ b/tests/components/tuya/test_diagnostics.py @@ -69,7 +69,7 @@ async def test_device_diagnostics( device = device_registry.async_get_device_by_identifier( (DOMAIN, mock_device.id), mock_config_entry.entry_id ) - assert device, repr(device_registry.devices) + assert device, repr(device_registry._devices) result = await get_diagnostics_for_device( hass, hass_client, mock_config_entry, device diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index be6e8ee13fabe..a24675a10d2e4 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -417,7 +417,7 @@ async def test_device_remove_devices_nvr( await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() - live_device_entry = list(device_registry.devices.values())[0] + live_device_entry = list(device_registry.devices)[0] client = await hass_ws_client(hass) response = await client.remove_device(live_device_entry.id) assert not response["success"] diff --git a/tests/components/unifiprotect/test_services.py b/tests/components/unifiprotect/test_services.py index 003d0e514b93d..847d28950eacd 100644 --- a/tests/components/unifiprotect/test_services.py +++ b/tests/components/unifiprotect/test_services.py @@ -44,7 +44,7 @@ async def device_fixture( await init_entry(hass, ufp, []) - return list(device_registry.devices.values())[0] + return list(device_registry.devices)[0] @pytest.fixture(name="subdevice") @@ -58,7 +58,7 @@ async def subdevice_fixture( await init_entry(hass, ufp, [light]) - return [d for d in device_registry.devices.values() if d.name != "UnifiProtect"][0] + return [d for d in device_registry.devices if d.name != "UnifiProtect"][0] async def test_global_service_bad_device( diff --git a/tests/components/waqi/test_init.py b/tests/components/waqi/test_init.py index 92adbf32b6fac..a3d909717120b 100644 --- a/tests/components/waqi/test_init.py +++ b/tests/components/waqi/test_init.py @@ -263,7 +263,7 @@ async def test_migration_from_v1_disabled( # validates it against the config entry's disabled state; write it # directly to simulate existing storage. device_1 = attr.evolve(device_1, disabled_by=DeviceEntryDisabler.CONFIG_ENTRY) - device_registry.devices[device_1.id] = device_1 + device_registry._devices[device_1.id] = device_1 entity_registry.async_get_or_create( "sensor", DOMAIN, @@ -284,7 +284,7 @@ async def test_migration_from_v1_disabled( # API; clear the flag directly to simulate existing storage with a stale # enabled device. device_2 = attr.evolve(device_2, disabled_by=None) - device_registry.devices[device_2.id] = device_2 + device_registry._devices[device_2.id] = device_2 entity_registry.async_get_or_create( "sensor", DOMAIN, diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 5aeb3f78bcd1d..d08cdb16afb26 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -311,7 +311,8 @@ async def target_entities( } assert set(label_registry.labels) == {"label_1", "label_2", "label_3"} assert set(area_registry.areas) == {"kitchen", "living_room", "bathroom", "garage"} - assert set(dr.async_get(hass).devices) == { # pylint: disable=home-assistant-tests-registry-fixtures + # pylint: disable-next=home-assistant-tests-registry-fixtures + assert {device.id for device in dr.async_get(hass).devices} == { "device1", "device2", "area_device", diff --git a/tests/components/wemo/test_coordinator.py b/tests/components/wemo/test_coordinator.py index 17061aea2f6fb..b825d57af91eb 100644 --- a/tests/components/wemo/test_coordinator.py +++ b/tests/components/wemo/test_coordinator.py @@ -50,7 +50,7 @@ async def test_async_register_device_longpress_fails( }, ) await hass.async_block_till_done() - device_entries = list(device_registry.devices.values()) + device_entries = list(device_registry.devices) assert len(device_entries) == 1 device = async_get_coordinator(hass, device_entries[0].id) assert device.supports_long_press is False @@ -170,7 +170,7 @@ async def test_device_info( hass: HomeAssistant, wemo_entity, device_registry: dr.DeviceRegistry ) -> None: """Verify the DeviceInfo data is set properly.""" - device_entries = list(device_registry.devices.values()) + device_entries = list(device_registry.devices) assert len(device_entries) == 1 assert device_entries[0].connections == { @@ -186,7 +186,7 @@ async def test_dli_device_info( hass: HomeAssistant, wemo_dli_entity, device_registry: dr.DeviceRegistry ) -> None: """Verify the DeviceInfo data for Digital Loggers emulated wemo device.""" - device_entries = list(device_registry.devices.values()) + device_entries = list(device_registry.devices) assert device_entries[0].configuration_url == "http://127.0.0.1" assert device_entries[0].identifiers == {(DOMAIN, "123456789")} diff --git a/tests/components/wolflink/test_init.py b/tests/components/wolflink/test_init.py index a01af2871b305..a817f6f2120c9 100644 --- a/tests/components/wolflink/test_init.py +++ b/tests/components/wolflink/test_init.py @@ -81,7 +81,7 @@ async def test_migration_v1_to_v2( # validates it against the config entry's disabled state; write it # directly to simulate existing storage. device = attr.evolve(device, disabled_by=dr.DeviceEntryDisabler.CONFIG_ENTRY) - device_registry.devices[device.id] = device + device_registry._devices[device.id] = device entity = entity_registry.async_get_or_create( domain="sensor", platform=DOMAIN, diff --git a/tests/components/zinvolt/test_init.py b/tests/components/zinvolt/test_init.py index fc0c0c365f301..0dfe5280c3adf 100644 --- a/tests/components/zinvolt/test_init.py +++ b/tests/components/zinvolt/test_init.py @@ -22,7 +22,7 @@ async def test_device( ) -> None: """Test the Zinvolt device.""" await setup_integration(hass, mock_config_entry) - devices = device_registry.devices + devices = device_registry._devices for device in devices.values(): assert device == snapshot(name=list(device.identifiers)[0][1]) diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 9eb621e68c4b5..d541e5413c536 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -78,7 +78,9 @@ def _get_device_for_config_entry( connections: set[tuple[str, str]] | None = None, ) -> dr.DeviceEntry | None: """Return the device for a config entry matching identifiers or connections.""" - for device in device_registry.devices.get_entries(identifiers, connections): + for device in device_registry.async_get_devices( + identifiers=identifiers, connections=connections + ): if device.config_entry_id == config_entry_id: return device return None @@ -2034,7 +2036,7 @@ async def test_migration_from_1_12( assert single.has_composite_identifiers is False # The composite spanning two config entries is split into one device per config entry - assert "composite0000000000000000000000" not in registry.devices + assert "composite0000000000000000000000" not in registry._devices entry_splits = registry.async_get_devices_for_composite_device_id( "composite0000000000000000000000" ) @@ -2063,7 +2065,7 @@ async def test_migration_from_1_12( # on one subentry - preferring a real subentry over the main entry (None) - rather # than split into duplicate devices sharing the same identifiers/connections. It # keeps its id and gains no composite bookkeeping. - assert "subentries00000000000000000000" in registry.devices + assert "subentries00000000000000000000" in registry._devices assert ( registry.async_get_devices_for_composite_device_id( "subentries00000000000000000000" @@ -2304,7 +2306,7 @@ async def test_migration_clears_composite_via_device_self_reference( await dr.async_load(hass) registry = dr.async_get(hass) - splits = registry.devices.get_devices_for_composite_device_id(composite_id) + splits = registry._devices.get_devices_for_composite_device_id(composite_id) assert len(splits) == 2 assert all(split.via_device_id is None for split in splits) @@ -2489,7 +2491,7 @@ async def test_async_get_device_returns_first_match_for_ambiguous_lookup( match = device_registry.async_get_device(identifiers={("test", "shared")}) # A real registry device (the first match), not a synthesized composite assert match is device_1 - assert match.id in device_registry.devices + assert match.id in device_registry._devices assert match.config_entries == {entry_1.entry_id} @@ -2736,17 +2738,17 @@ async def test_async_remove_device_fans_out_to_migration_composite( ) old_id = "composite00000000000000000000ab" # Simulate a migration split: both devices carry the pre-migration composite id - device_registry.devices[device_1.id] = attr.evolve( + device_registry._devices[device_1.id] = attr.evolve( device_1, composite_device_id=old_id ) - device_registry.devices[device_2.id] = attr.evolve( + device_registry._devices[device_2.id] = attr.evolve( device_2, composite_device_id=old_id ) device_registry.async_remove_device(old_id) - assert device_1.id not in device_registry.devices - assert device_2.id not in device_registry.devices + assert device_1.id not in device_registry._devices + assert device_2.id not in device_registry._devices async def test_async_update_device_fans_out_to_migration_composite( @@ -2765,10 +2767,10 @@ async def test_async_update_device_fans_out_to_migration_composite( ) old_id = "composite00000000000000000000ab" # Simulate a migration split: both devices carry the pre-migration composite id - device_registry.devices[device_1.id] = attr.evolve( + device_registry._devices[device_1.id] = attr.evolve( device_1, composite_device_id=old_id ) - device_registry.devices[device_2.id] = attr.evolve( + device_registry._devices[device_2.id] = attr.evolve( device_2, composite_device_id=old_id ) @@ -2788,7 +2790,7 @@ async def test_get_entry_by_connection_without_config_entry_scope( device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, connections={connection} ) - assert device_registry.devices.get_entry(connections={connection}) is device + assert device_registry._devices.get_entry(connections={connection}) is device async def test_update_unknown_device_id_raises( @@ -2818,7 +2820,7 @@ async def test_cleanup_removes_device_referencing_missing_config_entry( with patch.object(hass.config_entries, "async_entry_ids", return_value=[]): dr.async_cleanup(hass, device_registry, entity_registry) - assert device.id not in device_registry.devices + assert device.id not in device_registry._devices async def test_clear_config_entry_removes_device_with_pending_move( @@ -2840,7 +2842,7 @@ async def test_clear_config_entry_removes_device_with_pending_move( device_registry.async_clear_config_entry(entry_1.entry_id) - assert device.id not in device_registry.devices + assert device.id not in device_registry._devices assert device_registry.async_get_device(identifiers={("test", "1")}) is None @@ -2872,7 +2874,7 @@ async def test_clear_config_entry_clears_pending_move_targeting_it( device.id, remove_config_entry_id=entry_1.entry_id ) assert result is None - assert device.id not in device_registry.devices + assert device.id not in device_registry._devices async def test_move_to_config_entry_clears_target_entry_deleted_device( @@ -2968,7 +2970,7 @@ async def test_add_current_config_entry_is_noop( ) assert result is None - assert device.id not in device_registry.devices + assert device.id not in device_registry._devices @pytest.mark.parametrize( @@ -3231,7 +3233,7 @@ async def test_clear_config_subentry_removes_device_with_pending_move( device_registry.async_clear_config_subentry(entry_1.entry_id, "mock-subentry-id-1") - assert device.id not in device_registry.devices + assert device.id not in device_registry._devices assert device_registry.async_get_device(identifiers={("test", "1")}) is None @@ -3277,7 +3279,7 @@ async def test_clear_config_subentry_clears_pending_move_targeting_it( device.id, remove_config_entry_id=entry_1.entry_id ) assert result is None - assert device.id not in device_registry.devices + assert device.id not in device_registry._devices async def test_async_is_composite_device_id( @@ -3296,10 +3298,10 @@ async def test_async_is_composite_device_id( ) old_id = "composite00000000000000000000ab" # Simulate a migration split: both devices carry the pre-migration composite id - device_registry.devices[device_1.id] = attr.evolve( + device_registry._devices[device_1.id] = attr.evolve( device_1, composite_device_id=old_id ) - device_registry.devices[device_2.id] = attr.evolve( + device_registry._devices[device_2.id] = attr.evolve( device_2, composite_device_id=old_id ) @@ -3370,10 +3372,10 @@ async def test_async_get_include_composite_devices( ) old_id = "composite00000000000000000000ab" # Simulate a migration split: both devices carry the pre-migration composite id - device_registry.devices[device_1.id] = attr.evolve( + device_registry._devices[device_1.id] = attr.evolve( device_1, composite_device_id=old_id ) - device_registry.devices[device_2.id] = attr.evolve( + device_registry._devices[device_2.id] = attr.evolve( device_2, composite_device_id=old_id ) @@ -3384,8 +3386,8 @@ async def test_async_get_include_composite_devices( assert device_registry.async_get(old_id, include_child_devices=False) == composite # include_composite_devices=False resolves a composite id to None, matching - # `old_id in device_registry.devices`, which is composite-blind - assert old_id not in device_registry.devices + # `old_id in device_registry._devices`, which is composite-blind + assert old_id not in device_registry._devices assert device_registry.async_get(old_id, include_composite_devices=False) is None assert ( device_registry.async_get( @@ -3519,12 +3521,12 @@ async def test_async_get_device_composite_reuses_pre_migration_id( ) assert composite is not None assert composite.id == "composite00000000000000000000" - assert composite.id not in registry.devices + assert composite.id not in registry._devices # It is the same composite async_get resolves for the old id assert registry.async_get("composite00000000000000000000").id == composite.id # An identifier lookup still domain-resolves to the single owning split (real id) resolved = registry.async_get_device(identifiers={("domain_a", "1")}) - assert resolved.id in registry.devices + assert resolved.id in registry._devices assert resolved.config_entry_id == entry_a.entry_id @@ -3563,10 +3565,10 @@ async def test_async_update_device_composite_drops_identity_args( ) old_id = "composite00000000000000000000ab" # Simulate a migration split: both devices carry the pre-migration composite id - device_registry.devices[device_1.id] = attr.evolve( + device_registry._devices[device_1.id] = attr.evolve( device_1, composite_device_id=old_id ) - device_registry.devices[device_2.id] = attr.evolve( + device_registry._devices[device_2.id] = attr.evolve( device_2, composite_device_id=old_id ) @@ -3598,10 +3600,10 @@ async def test_async_update_device_composite_drops_only_disallowed_args( ) old_id = "composite00000000000000000000ab" # Simulate a migration split: both devices carry the pre-migration composite id - device_registry.devices[device_1.id] = attr.evolve( + device_registry._devices[device_1.id] = attr.evolve( device_1, composite_device_id=old_id ) - device_registry.devices[device_2.id] = attr.evolve( + device_registry._devices[device_2.id] = attr.evolve( device_2, composite_device_id=old_id ) @@ -3647,10 +3649,10 @@ async def test_async_update_device_composite_drops_move_args( config_entry_id=entry_2.entry_id, identifiers={("test", "2")} ) old_id = "composite00000000000000000000ab" - device_registry.devices[device_1.id] = attr.evolve( + device_registry._devices[device_1.id] = attr.evolve( device_1, composite_device_id=old_id ) - device_registry.devices[device_2.id] = attr.evolve( + device_registry._devices[device_2.id] = attr.evolve( device_2, composite_device_id=old_id ) @@ -3739,7 +3741,7 @@ async def test_migration_drops_device_without_config_entries( # The orphan device was dropped, the normal device kept assert registry.async_get("orphan00000000000000000000000") is None - assert "orphan00000000000000000000000" not in registry.devices + assert "orphan00000000000000000000000" not in registry._devices kept = registry.async_get("keptdevice0000000000000000000") assert kept is not None assert kept.config_entry_id == mock_config_entry.entry_id @@ -4343,6 +4345,102 @@ async def test_update_device_unknown_via_device_id_raises_before_removal( assert device_registry.async_get(device.id) == device +async def test_devices_collection_operations( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the supported `Collection[DeviceEntry]` surface of `DeviceRegistry.devices`. + + Iteration yields the entries (not the ids), `len()` returns the count, and + `DeviceEntry` membership works. + """ + entry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("bridgeid", "0123")}, + ) + + assert list(device_registry.devices) == [entry] + assert [device.id for device in device_registry.devices] == [entry.id] + assert len(device_registry.devices) == 1 + assert entry in device_registry.devices + + +@pytest.mark.parametrize( + ("integration_frame_path", "expectation", "expected_log"), + [ + pytest.param( + "homeassistant/test_core", pytest.raises(RuntimeError), 0, id="core" + ), + pytest.param( + "homeassistant/components/test_integration", + pytest.raises(RuntimeError), + 1, + id="core integration", + ), + pytest.param( + "custom_components/test_integration", + nullcontext(), + 1, + id="custom integration", + ), + ], +) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_devices_mapping_access_deprecated( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, + expectation: AbstractContextManager, + expected_log: int, +) -> None: + """Test mapping-style access to `DeviceRegistry.devices` is deprecated. + + It logs for custom integrations and raises for core and core integrations, while + iterating the view keeps working for every caller. + """ + entry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("bridgeid", "0123")}, + ) + what = "uses `device_registry.devices` as a mapping" + + # Iterating the view is the supported API and is never reported. + assert list(device_registry.devices) == [entry] + assert caplog.text.count(what) == 0 + + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()), expectation: + _ = device_registry.devices[entry.id] + + assert caplog.text.count(what) == expected_log + + +@pytest.mark.parametrize( + "integration_frame_path", ["custom_components/test_integration"] +) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_devices_membership_by_entry_supported_by_id_deprecated( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test `DeviceEntry` membership is supported while device-id (str) membership warns.""" + entry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("bridgeid", "0123")}, + ) + what = "uses `device_registry.devices` as a mapping" + + # DeviceEntry (value) membership is supported and never reported. + assert entry in device_registry.devices + assert caplog.text.count(what) == 0 + + # Device-id (str) membership is the deprecated key lookup; it warns here (custom + # integration). + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): + assert entry.id in device_registry.devices + assert caplog.text.count(what) == 1 + + @pytest.mark.parametrize( ("integration_frame_path", "expectation", "expected_log"), [ @@ -4721,10 +4819,10 @@ async def test_update_device_composite_via_device_id_self_reference_raises_befor ) old_id = "composite00000000000000000000ab" # Simulate a migration split: both devices carry the pre-migration composite id - device_registry.devices[device_1.id] = attr.evolve( + device_registry._devices[device_1.id] = attr.evolve( device_1, composite_device_id=old_id ) - device_registry.devices[device_2.id] = attr.evolve( + device_registry._devices[device_2.id] = attr.evolve( device_2, composite_device_id=old_id ) @@ -4739,7 +4837,7 @@ async def test_update_device_composite_via_device_id_self_reference_raises_befor via_device_id=old_id, ) - assert device_1.id in device_registry.devices + assert device_1.id in device_registry._devices async def test_get_or_create_composite_via_device_id_resolved( @@ -4764,10 +4862,10 @@ async def test_get_or_create_composite_via_device_id_resolved( ) old_id = "composite00000000000000000000ab" # Simulate a migration split: both devices carry the pre-migration composite id - device_registry.devices[split_1.id] = attr.evolve( + device_registry._devices[split_1.id] = attr.evolve( split_1, composite_device_id=old_id ) - device_registry.devices[split_2.id] = attr.evolve( + device_registry._devices[split_2.id] = attr.evolve( split_2, composite_device_id=old_id ) @@ -4815,10 +4913,10 @@ async def test_update_device_composite_via_device_id_resolved( ) old_id = "composite00000000000000000000ab" # Simulate a migration split: both devices carry the pre-migration composite id - device_registry.devices[split_1.id] = attr.evolve( + device_registry._devices[split_1.id] = attr.evolve( split_1, composite_device_id=old_id ) - device_registry.devices[split_2.id] = attr.evolve( + device_registry._devices[split_2.id] = attr.evolve( split_2, composite_device_id=old_id ) child = device_registry.async_get_or_create( @@ -5014,7 +5112,7 @@ async def test_loading_saving_data( await registry2.async_load() # Ensure same order - assert list(device_registry.devices) == list(registry2.devices) + assert list(device_registry._devices) == list(registry2._devices) assert list(device_registry.deleted_devices) == list(registry2.deleted_devices) new_via = registry2.async_get_device(identifiers={("hue", "0123")}) @@ -6262,20 +6360,20 @@ def _device( registry = dr.async_get(hass) assert ( - registry.devices["childa000000000000000000000000"].via_device_id + registry._devices["childa000000000000000000000000"].via_device_id == "splita000000000000000000000000" ) assert ( - registry.devices["childa200000000000000000000000"].via_device_id + registry._devices["childa200000000000000000000000"].via_device_id == "splita000000000000000000000000" ) - assert registry.devices["childc000000000000000000000000"].via_device_id in { + assert registry._devices["childc000000000000000000000000"].via_device_id in { "splita000000000000000000000000", "splitb000000000000000000000000", } - assert registry.devices["childx000000000000000000000000"].via_device_id is None + assert registry._devices["childx000000000000000000000000"].via_device_id is None assert ( - registry.devices["childl000000000000000000000000"].via_device_id + registry._devices["childl000000000000000000000000"].via_device_id == "splitb000000000000000000000000" ) @@ -8431,7 +8529,7 @@ async def test_remove_shadowed_collision_keeps_index_consistent( ("test", "1"), ("test", "2"), } - assert shadowed.id in device_registry.devices + assert shadowed.id in device_registry._devices # Remove the shadowed device, then the indexed one - neither must raise device_registry.async_remove_device(shadowed.id) @@ -9496,10 +9594,10 @@ async def test_composite_move_clears_sibling_pending_moves( ) old_id = "composite00000000000000000000ab" # Simulate a migration split: both devices carry the pre-migration composite id - device_registry.devices[device_1.id] = attr.evolve( + device_registry._devices[device_1.id] = attr.evolve( device_1, composite_device_id=old_id ) - device_registry.devices[device_2.id] = attr.evolve( + device_registry._devices[device_2.id] = attr.evolve( device_2, composite_device_id=old_id ) @@ -9550,10 +9648,10 @@ async def test_composite_move_unknown_via_device_id_keeps_sibling_moves( ) old_id = "composite00000000000000000000ab" # Simulate a migration split: both devices carry the pre-migration composite id - device_registry.devices[device_1.id] = attr.evolve( + device_registry._devices[device_1.id] = attr.evolve( device_1, composite_device_id=old_id ) - device_registry.devices[device_2.id] = attr.evolve( + device_registry._devices[device_2.id] = attr.evolve( device_2, composite_device_id=old_id ) @@ -9723,8 +9821,8 @@ async def test_async_get_returns_restored_composite( assert composite.serial_number == "SERIAL" # Invisible to membership, enumeration and identifier search - assert COMPOSITE_ID not in device_registry.devices - assert COMPOSITE_ID not in {d.id for d in device_registry.devices.values()} + assert COMPOSITE_ID not in device_registry._devices + assert COMPOSITE_ID not in {d.id for d in device_registry.devices} assert ( device_registry.async_get_device(identifiers={("domain_a", "1")}).id != COMPOSITE_ID @@ -9786,7 +9884,7 @@ async def test_get_composite_splits( device_registry, entry_b.entry_id, identifiers={("domain_b", "1")} ) - splits = device_registry.devices.get_composite_splits() + splits = device_registry._devices.get_composite_splits() assert set(splits) == {COMPOSITE_ID} assert {device.id for device in splits[COMPOSITE_ID]} == {split_a.id, split_b.id} @@ -9794,18 +9892,18 @@ async def test_get_composite_splits( device_registry.async_get_or_create( config_entry_id=entry_a.entry_id, identifiers={("domain_a", "2")} ) - splits = device_registry.devices.get_composite_splits() + splits = device_registry._devices.get_composite_splits() assert set(splits) == {COMPOSITE_ID} assert {device.id for device in splits[COMPOSITE_ID]} == {split_a.id, split_b.id} # A removed split is dropped from the mapping device_registry.async_remove_device(split_a.id) - splits = device_registry.devices.get_composite_splits() + splits = device_registry._devices.get_composite_splits() assert {device.id for device in splits[COMPOSITE_ID]} == {split_b.id} # Removing the last split drops the composite id from the mapping device_registry.async_remove_device(split_b.id) - assert device_registry.devices.get_composite_splits() == {} + assert device_registry._devices.get_composite_splits() == {} async def test_async_get_device_and_config_entry_for_domain( @@ -11025,7 +11123,7 @@ async def test_convert_device_to_child_detaches_via_links( # No live device links to a child device through via_device_id child_via_targets = [ device.id - for device in device_registry.devices.values() + for device in device_registry.devices if device.via_device_id is not None and device_registry.async_get(device.via_device_id, include_main_devices=False) is not None @@ -11212,7 +11310,7 @@ async def test_child_device_orphan_restore( device_registry.async_update_child_device(child_device.id, area_id="garden") device_registry.async_clear_config_entry(mock_config_entry.entry_id) - assert not device_registry.devices + assert not device_registry._devices assert not device_registry.child_devices new_entry = MockConfigEntry(title=None) @@ -11252,7 +11350,7 @@ async def test_child_device_load_and_save( first_save = deepcopy(hass_storage[dr.STORAGE_KEY]["data"]) await registry2.async_load() - assert list(device_registry.devices) == list(registry2.devices) + assert list(device_registry._devices) == list(registry2._devices) assert list(device_registry.child_devices) == list(registry2.child_devices) loaded_child = registry2.async_get(child_device.id, include_main_devices=False) assert loaded_child is not None @@ -11529,7 +11627,7 @@ async def test_async_cleanup_removes_child_device_with_missing_parent( device_registry, mock_config_entry.entry_id ) # Simulate store corruption: drop the parent without the remove cascade - del device_registry.devices[parent.id] + del device_registry._devices[parent.id] dr.async_cleanup(hass, device_registry, entity_registry) @@ -12435,7 +12533,7 @@ async def test_clear_config_entry_removes_orphaned_child_device( parent, child_device = _create_parent_and_child( device_registry, mock_config_entry.entry_id ) - del device_registry.devices[parent.id] + del device_registry._devices[parent.id] device_registry.async_clear_config_entry(mock_config_entry.entry_id) @@ -12483,8 +12581,8 @@ async def test_clear_config_subentry_removes_orphaned_child_device( parent_device_id=parent_2.id, name="Outlet 2", ) - del device_registry.devices[parent_1.id] - del device_registry.devices[parent_2.id] + del device_registry._devices[parent_1.id] + del device_registry._devices[parent_2.id] device_registry.async_clear_config_subentry(entry_id, "mock-subentry-id-1-1") diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 3a469bd160395..39e13a77340f6 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -4235,15 +4235,15 @@ async def test_composite_device_id_ignored( ) old_id = "composite00000000000000000000ab" # Simulate a migration split: both devices carry the pre-migration composite id - device_registry.devices[device_1.id] = attr.evolve( + device_registry._devices[device_1.id] = attr.evolve( device_1, composite_device_id=old_id ) - device_registry.devices[device_2.id] = attr.evolve( + device_registry._devices[device_2.id] = attr.evolve( device_2, composite_device_id=old_id ) # The composite id resolves to a synthesized device, but is not a real registry entry assert device_registry.async_get(old_id) is not None - assert old_id not in device_registry.devices + assert old_id not in device_registry._devices warning = f"Ignoring request to link entity from integration hue to device {old_id}" @@ -6179,7 +6179,7 @@ async def test_async_entries_for_device_legacy_composite_id( entity_registry = er.async_get(hass) # The composite id is no longer a live device; its entities were repointed to splits - assert COMPOSITE_ID not in device_registry.devices + assert COMPOSITE_ID not in device_registry._devices # get_entries_for_device_id resolves the composite id to the split entities assert { @@ -6254,14 +6254,14 @@ async def test_async_entries_for_device_composite_id( ) old_id = "composite00000000000000000000ab" # Simulate a migration split: both devices carry the pre-migration composite id - device_registry.devices[device_1.id] = attr.evolve( + device_registry._devices[device_1.id] = attr.evolve( device_1, composite_device_id=old_id ) - device_registry.devices[device_2.id] = attr.evolve( + device_registry._devices[device_2.id] = attr.evolve( device_2, composite_device_id=old_id ) - assert old_id not in device_registry.devices + assert old_id not in device_registry._devices assert { entry.entity_id for entry in er.async_entries_for_device(entity_registry, old_id) diff --git a/tests/helpers/test_helper_integration.py b/tests/helpers/test_helper_integration.py index 7322453ecf585..e09b84661fdbd 100644 --- a/tests/helpers/test_helper_integration.py +++ b/tests/helpers/test_helper_integration.py @@ -558,11 +558,11 @@ async def test_async_remove_helper_devices( identifiers=helper_identifiers, ) # Both are splits of the same pre-migration device, sharing its id - device_registry.devices[source_split.id] = attr.evolve( + device_registry._devices[source_split.id] = attr.evolve( source_split, composite_device_id=composite_id, ) - device_registry.devices[helper_split.id] = attr.evolve( + device_registry._devices[helper_split.id] = attr.evolve( helper_split, composite_device_id=composite_id, has_composite_identifiers=helper_has_composite_identifiers, From 8067f4c7c6213323755c9cd536c62a2f79e43f42 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:03:58 +0200 Subject: [PATCH 7/7] Bump volvocarsapi to v0.4.4 for the Volvo integration (#179712) --- homeassistant/components/volvo/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/volvo/manifest.json b/homeassistant/components/volvo/manifest.json index 9238f4a770bf6..8be6c4da16edc 100644 --- a/homeassistant/components/volvo/manifest.json +++ b/homeassistant/components/volvo/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["volvocarsapi"], "quality_scale": "platinum", - "requirements": ["volvocarsapi==0.4.3"] + "requirements": ["volvocarsapi==0.4.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 60b455deca03a..f6ae7245ea1a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3345,7 +3345,7 @@ voip-utils==0.4.0 volkszaehler==0.4.0 # homeassistant.components.volvo -volvocarsapi==0.4.3 +volvocarsapi==0.4.4 # homeassistant.components.verisure vsure==2.10.0