From 18a0b2ff6a9d84a0f583f029ba988488804f2425 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Jul 2026 07:27:15 +0200 Subject: [PATCH 01/24] Adapt ping to new device registry API (#176950) --- homeassistant/components/ping/__init__.py | 6 +++--- homeassistant/components/ping/device_tracker.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ping/__init__.py b/homeassistant/components/ping/__init__.py index 1153d496b92114..f00319f547a107 100644 --- a/homeassistant/components/ping/__init__.py +++ b/homeassistant/components/ping/__init__.py @@ -29,10 +29,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: PingConfigEntry) -> bo # Migrate device registry identifiers from homeassistant domain to ping domain registry = dr.async_get(hass) if ( - device := registry.async_get_device( - identifiers={(HOMEASSISTANT_DOMAIN, entry.entry_id)} + device := registry.async_get_device_by_identifier( + (HOMEASSISTANT_DOMAIN, entry.entry_id), entry.entry_id ) - ) is not None and entry.entry_id in device.config_entries: + ) is not None: registry.async_update_device( device_id=device.id, new_identifiers={(DOMAIN, entry.entry_id)}, diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index 77a4fc47e57b88..e3c7386dc155cc 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -50,8 +50,8 @@ def __init__( ) if ( - device := dr.async_get(hass).async_get_device( - identifiers={(DOMAIN, config_entry.entry_id)} + device := dr.async_get(hass).async_get_device_by_identifier( + (DOMAIN, config_entry.entry_id), config_entry.entry_id ) ) is not None: self.device_entry = device From 7891b0e55e63672e9f5d347b0779e792d63e86bd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 08:09:32 +0200 Subject: [PATCH 02/24] Update syrupy to 5.5.3 (#176963) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index c812d79d5b2710..c131bd14ae567d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -38,7 +38,7 @@ pytest==9.0.3 requests==2.34.2 requests-mock==1.12.1 respx==0.23.1 -syrupy==5.5.2 +syrupy==5.5.3 tqdm==4.67.1 types-aiofiles==24.1.0.20250822 types-atomicwrites==1.4.5.1 From f98fa2b8e3652ebf38c219ccf247901fc5bba7c4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Jul 2026 08:21:21 +0200 Subject: [PATCH 03/24] Adapt pglab to new device registry API (#176949) --- homeassistant/components/pglab/discovery.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/pglab/discovery.py b/homeassistant/components/pglab/discovery.py index 8c2a313f9b7c6f..371c935e033289 100644 --- a/homeassistant/components/pglab/discovery.py +++ b/homeassistant/components/pglab/discovery.py @@ -162,7 +162,9 @@ async def __build_device( return pglab_device - def __clean_discovered_device(self, hass: HomeAssistant, device_id: str) -> None: + def __clean_discovered_device( + self, hass: HomeAssistant, device_id: str, config_entry: PGLabConfigEntry + ) -> None: """Destroy the device and any entities connected to the device.""" if device_id not in self._discovered: @@ -180,8 +182,8 @@ def __clean_discovered_device(self, hass: HomeAssistant, device_id: str) -> None # Destroy the device. device_registry = dr.async_get(hass) - if device_entry := device_registry.async_get_device( - identifiers={(DOMAIN, device_id)} + if device_entry := device_registry.async_get_device_by_identifier( + (DOMAIN, device_id), config_entry.entry_id ): device_registry.async_remove_device(device_entry.id) @@ -214,7 +216,7 @@ async def discovery_message_received(msg: ReceiveMessage) -> None: # If there is a valid topic device_id clean # everything relative to the device. if device_id: - self.__clean_discovered_device(hass, device_id) + self.__clean_discovered_device(hass, device_id, config_entry) return @@ -252,7 +254,7 @@ async def discovery_message_received(msg: ReceiveMessage) -> None: # Something has changed, all previous entities # must be destroyed and re-created. - self.__clean_discovered_device(hass, pglab_device.id) + self.__clean_discovered_device(hass, pglab_device.id, config_entry) # Add a new device. discovery_info = await create_discover_device_info( From 7f34edfd4b2be937d5d77291c892294de938b734 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Jul 2026 08:22:01 +0200 Subject: [PATCH 04/24] Adapt bang_olufsen to new device registry API (#176937) --- homeassistant/components/bang_olufsen/util.py | 6 ++++-- homeassistant/components/bang_olufsen/websocket.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bang_olufsen/util.py b/homeassistant/components/bang_olufsen/util.py index d869c63efa2a5b..4203417424d496 100644 --- a/homeassistant/components/bang_olufsen/util.py +++ b/homeassistant/components/bang_olufsen/util.py @@ -21,10 +21,12 @@ ) -def get_device(hass: HomeAssistant, unique_id: str) -> DeviceEntry: +def get_device(hass: HomeAssistant, unique_id: str, entry_id: str) -> DeviceEntry: """Get the device.""" device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, unique_id)}) + device = device_registry.async_get_device_by_identifier( + (DOMAIN, unique_id), entry_id + ) assert device return device diff --git a/homeassistant/components/bang_olufsen/websocket.py b/homeassistant/components/bang_olufsen/websocket.py index 93e352b47441e9..0ed29ed916f640 100644 --- a/homeassistant/components/bang_olufsen/websocket.py +++ b/homeassistant/components/bang_olufsen/websocket.py @@ -50,7 +50,7 @@ def __init__( BeoBase.__init__(self, entry, client) self.hass = hass - self._device = get_device(hass, self._unique_id) + self._device = get_device(hass, self._unique_id, self.entry.entry_id) # WebSocket callbacks self._client.get_notification_notifications(self.on_notification_notification) From c6a13340a923b1b472e9fa51e19fa89b33e59657 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 21 Jul 2026 08:24:43 +0200 Subject: [PATCH 05/24] Use registry for Alexa name override (#176926) --- homeassistant/components/alexa/entities.py | 14 +++++++++----- tests/components/alexa/test_entities.py | 11 ++++++++++- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index d5cf6d447eb417..9920a5ad7f1a81 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -45,7 +45,7 @@ __version__, ) from homeassistant.core import HomeAssistant, State, callback -from homeassistant.helpers import network +from homeassistant.helpers import entity_registry as er, intent, network from homeassistant.helpers.entity import entity_sources from homeassistant.util.decorator import Registry @@ -283,10 +283,14 @@ def entity_id(self) -> str: def friendly_name(self) -> str: """Return the Alexa API friendly name.""" - friendly_name: str = self.entity_conf.get( - CONF_NAME, self.entity.name - ).translate(TRANSLATION_TABLE) - return friendly_name + name: str | None = self.entity_conf.get(CONF_NAME) + if name is None: + entity_entry = er.async_get(self.hass).async_get(self.entity_id) + aliases = intent.async_get_entity_aliases( + self.hass, entity_entry, state=self.entity, allow_empty=False + ) + name = aliases[0] + return name.translate(TRANSLATION_TABLE) def description(self) -> str: """Return the Alexa API description.""" diff --git a/tests/components/alexa/test_entities.py b/tests/components/alexa/test_entities.py index b909ff544fef7a..79786003cd96fc 100644 --- a/tests/components/alexa/test_entities.py +++ b/tests/components/alexa/test_entities.py @@ -77,10 +77,18 @@ async def test_categorized_hidden_entities( assert not msg["payload"]["endpoints"] -async def test_serialize_discovery(hass: HomeAssistant) -> None: +async def test_serialize_discovery( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test we can serialize a discovery.""" request = get_new_request("Alexa.Discovery", "Discover") + entity_entry = entity_registry.async_get_or_create( + "switch", "test", "bla", suggested_object_id="bla" + ) + entity_registry.async_update_entity( + entity_entry.entity_id, aliases=["Alexa Switch"] + ) hass.states.async_set("switch.bla", "on", {"friendly_name": "Boop Woz"}) msg = await smart_home.async_handle_message(hass, get_default_config(hass), request) @@ -89,6 +97,7 @@ async def test_serialize_discovery(hass: HomeAssistant) -> None: msg = msg["event"] endpoint = msg["payload"]["endpoints"][0] + assert endpoint["friendlyName"] == "Alexa Switch" assert endpoint["additionalAttributes"] == { "manufacturer": "Home Assistant", "model": "switch", From 6d387ad10de3a3e3153544a68b90b4bf8075b484 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 21 Jul 2026 08:26:52 +0200 Subject: [PATCH 06/24] Add port validation to ws http config command (#176876) --- homeassistant/components/http/__init__.py | 68 +++++--- .../components/http/websocket_api.py | 26 +++- tests/components/http/test_init.py | 146 ++++++++++++++++++ 3 files changed, 216 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index a1a0467be05273..1c6c0c857ddc19 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -231,6 +231,52 @@ async def _async_fallback_config( return _DEFAULT_CONFIG +def _make_server( + hass: HomeAssistant, + conf: ConfData, + supervisor_unix_socket_path: Path | None = None, +) -> HomeAssistantHTTP: + """Create a server instance for the given config.""" + return HomeAssistantHTTP( + hass, + server_host=conf.get(CONF_SERVER_HOST, _DEFAULT_BIND), + server_port=conf[CONF_SERVER_PORT], + ssl_certificate=conf.get(CONF_SSL_CERTIFICATE), + ssl_peer_certificate=conf.get(CONF_SSL_PEER_CERTIFICATE), + ssl_key=conf.get(CONF_SSL_KEY), + # The loaded config stores trusted proxies as strings + # (JSON-serializable); the forwarded middleware needs + # IPv4Network/IPv6Network objects. + trusted_proxies=[ + ip_network(proxy) for proxy in conf.get(CONF_TRUSTED_PROXIES) or [] + ], + ssl_profile=conf[CONF_SSL_PROFILE], + supervisor_unix_socket_path=supervisor_unix_socket_path, + ) + + +async def async_verify_can_bind(hass: HomeAssistant, conf: ConfData) -> None: + """Verify a server for ``conf`` can be created and its address bound. + + Used to validate a new user-supplied config before it is stored and + applied via a restart; the sockets are released right away. Best effort: + the address can still be taken by another process before the restart, so + the setup fallback chain remains the safety net. + + Raises ``HomeAssistantError`` if the SSL configuration is unusable or the + configured address cannot be bound. + """ + server = _make_server(hass, conf) + try: + await server.async_bind() + except OSError as err: + raise HomeAssistantError( + f"Failed to create HTTP server at port {conf[CONF_SERVER_PORT]}: {err}" + ) from err + finally: + await server.stop() + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the HTTP API and debug interface.""" # Late import to ensure isal is updated before @@ -262,25 +308,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: socket_env, ) - def _make_server(conf: ConfData) -> HomeAssistantHTTP: - return HomeAssistantHTTP( - hass, - server_host=conf.get(CONF_SERVER_HOST, _DEFAULT_BIND), - server_port=conf[CONF_SERVER_PORT], - ssl_certificate=conf.get(CONF_SSL_CERTIFICATE), - ssl_peer_certificate=conf.get(CONF_SSL_PEER_CERTIFICATE), - ssl_key=conf.get(CONF_SSL_KEY), - # The loaded config stores trusted proxies as strings - # (JSON-serializable); the forwarded middleware needs - # IPv4Network/IPv6Network objects. - trusted_proxies=[ - ip_network(proxy) for proxy in conf.get(CONF_TRUSTED_PROXIES) or [] - ], - ssl_profile=conf[CONF_SSL_PROFILE], - supervisor_unix_socket_path=supervisor_unix_socket_path, - ) - - server = _make_server(conf) + server = _make_server(hass, conf, supervisor_unix_socket_path) trial_reverted = False while True: try: @@ -289,7 +317,7 @@ def _make_server(conf: ConfData) -> HomeAssistantHTTP: store = await async_get_and_load_store(hass) trial_reverted = store.revert_deadline is not None conf = await _async_fallback_config(hass, store, conf, err) - server = _make_server(conf) + server = _make_server(hass, conf, supervisor_unix_socket_path) continue if trial_reverted: _LOGGER.warning( diff --git a/homeassistant/components/http/websocket_api.py b/homeassistant/components/http/websocket_api.py index 9aff44f1f68291..343e1850d96df5 100644 --- a/homeassistant/components/http/websocket_api.py +++ b/homeassistant/components/http/websocket_api.py @@ -1,6 +1,6 @@ """WebSocket API for the HTTP integration user config.""" -from typing import Any +from typing import Any, Final import voluptuous as vol @@ -12,8 +12,11 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from .config import HTTP_STORAGE_SCHEMA, async_get_and_load_store -from .const import ATTR_CONFIG +from . import async_verify_can_bind +from .config import HTTP_STORAGE_SCHEMA, ConfData, async_get_and_load_store +from .const import ATTR_CONFIG, CONF_SERVER_PORT + +ERR_BIND_FAILED: Final = "bind_failed" @callback @@ -65,13 +68,28 @@ async def websocket_set_config( ) -> None: """Store a new pending HTTP configuration and restart to apply it. + A new config is first verified to be applicable by binding its + configured address, so an unusable config is rejected here instead of + being discovered after the restart. The check is skipped when the port + matches the currently bound one: the running server holds that port + until the restart releases it, so a probe would always fail against + ourselves. + Restart whenever the pending slot changes, so the runtime config is refreshed. The result reports whether a restart was triggered via ``{"restart": bool}``. """ + config: ConfData | None = msg[ATTR_CONFIG] + if config is not None and config[CONF_SERVER_PORT] != hass.http.server_port: + try: + await async_verify_can_bind(hass, config) + except HomeAssistantError as err: + connection.send_error(msg["id"], ERR_BIND_FAILED, str(err)) + return + store = await async_get_and_load_store(hass) previous_pending = store.pending - await store.async_set_pending(msg[ATTR_CONFIG]) + await store.async_set_pending(config) restart = store.pending != previous_pending connection.send_result(msg["id"], {"restart": restart}) diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 94d924d73304bd..b28e57d015ae2e 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -1507,6 +1507,152 @@ async def test_websocket_http_config( assert len(restart_calls) == 3 +async def test_websocket_configure_verifies_new_port( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_create_server: Mock, +) -> None: + """Configuring a new port probes that it can be bound before restarting.""" + assert await async_setup_component(hass, DOMAIN, {}) + await async_setup_component(hass, "websocket_api", {}) + await hass.async_start() + await hass.async_block_till_done() + + restart_calls = async_mock_service(hass, "homeassistant", "restart") + + ws_client = await hass_ws_client(hass) + await ws_client.send_json_auto_id( + {"type": "http/config/configure", "config": {"server_port": 9123}} + ) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == {"restart": True} + + # One bind for setup, one for the probe of the new config. + assert mock_create_server.call_count == 2 + assert mock_create_server.call_args_list[1].args[0].server_port == 9123 + + await hass.async_block_till_done() + assert len(restart_calls) == 1 + + +@pytest.mark.parametrize( + "bind_error", + [ + OSError(errno.EADDRINUSE, "Address already in use"), + PermissionError(errno.EACCES, "Permission denied"), + socket.gaierror(socket.EAI_NONAME, "Name or service not known"), + ], + ids=["address-in-use", "permission-denied", "unresolvable-host"], +) +async def test_websocket_configure_rejects_unbindable_config( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], + mock_create_server: Mock, + bind_error: OSError, +) -> None: + """A new config whose address cannot be bound is rejected without a restart.""" + assert await async_setup_component(hass, DOMAIN, {}) + await async_setup_component(hass, "websocket_api", {}) + await hass.async_start() + await hass.async_block_till_done() + + restart_calls = async_mock_service(hass, "homeassistant", "restart") + mock_create_server.side_effect = bind_error + + ws_client = await hass_ws_client(hass) + await ws_client.send_json_auto_id( + {"type": "http/config/configure", "config": {"server_port": 9123}} + ) + response = await ws_client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "bind_failed" + assert response["error"]["message"] == ( + f"Failed to create HTTP server at port 9123: {bind_error}" + ) + + # The rejected config is not stored and no restart is triggered. + assert hass_storage[DOMAIN]["data"]["pending"] is None + assert len(restart_calls) == 0 + + +async def test_websocket_configure_rejects_unusable_ssl( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], + tmp_path: Path, +) -> None: + """A new config whose SSL certificate cannot be loaded is rejected.""" + cert_path, key_path = _setup_broken_ssl_pem_files(tmp_path) + + assert await async_setup_component(hass, DOMAIN, {}) + await async_setup_component(hass, "websocket_api", {}) + await hass.async_start() + await hass.async_block_till_done() + + restart_calls = async_mock_service(hass, "homeassistant", "restart") + + ws_client = await hass_ws_client(hass) + await ws_client.send_json_auto_id( + { + "type": "http/config/configure", + "config": { + "server_port": 9123, + "ssl_certificate": str(cert_path), + "ssl_key": str(key_path), + }, + } + ) + response = await ws_client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "bind_failed" + assert "Could not use SSL certificate" in response["error"]["message"] + + assert hass_storage[DOMAIN]["data"]["pending"] is None + assert len(restart_calls) == 0 + + +async def test_websocket_configure_same_port_skips_bind_check( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], + mock_create_server: Mock, +) -> None: + """No bind probe runs when the new config keeps the currently bound port. + + The running server holds the port until the restart releases it, so a + probe would always fail against ourselves. + """ + assert await async_setup_component(hass, DOMAIN, {}) + await async_setup_component(hass, "websocket_api", {}) + await hass.async_start() + await hass.async_block_till_done() + + restart_calls = async_mock_service(hass, "homeassistant", "restart") + # Any probe would fail; success proves the check was skipped. + mock_create_server.side_effect = OSError(errno.EADDRINUSE, "Address already in use") + + current_port = default_server_port() + ws_client = await hass_ws_client(hass) + await ws_client.send_json_auto_id( + { + "type": "http/config/configure", + "config": { + "server_port": current_port, + "cors_allowed_origins": ["https://example.com"], + }, + } + ) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == {"restart": True} + assert hass_storage[DOMAIN]["data"]["pending"]["server_port"] == current_port + + await hass.async_block_till_done() + assert len(restart_calls) == 1 + + async def test_pending_config_auto_reverts_to_stable( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, From cecee55659ce111f1f2a53a0bded4ade601fb7a1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Jul 2026 08:28:07 +0200 Subject: [PATCH 07/24] Adapt airly to new device registry API (#176934) --- homeassistant/components/airly/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/airly/__init__.py b/homeassistant/components/airly/__init__.py index a6a94f1ccbe17c..1c815668b6a17a 100644 --- a/homeassistant/components/airly/__init__.py +++ b/homeassistant/components/airly/__init__.py @@ -43,8 +43,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirlyConfigEntry) -> boo str(longitude), ), ): - device_entry = device_registry.async_get_device(identifiers={old_ids}) # type: ignore[arg-type] - if device_entry and entry.entry_id in device_entry.config_entries: + device_entry = device_registry.async_get_device_by_identifier( + old_ids, # type: ignore[arg-type] + entry.entry_id, + ) + if device_entry: new_ids = (DOMAIN, f"{latitude}-{longitude}") device_registry.async_update_device( device_entry.id, new_identifiers={new_ids} From 94334d854d0c8f77159581e5cbd918875bf89617 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Jul 2026 08:31:17 +0200 Subject: [PATCH 08/24] Adapt asuswrt to new device registry API (#176936) --- homeassistant/components/asuswrt/diagnostics.py | 5 +++-- homeassistant/components/asuswrt/router.py | 8 +++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/asuswrt/diagnostics.py b/homeassistant/components/asuswrt/diagnostics.py index 175c35c8297fb1..d562267adfe2e1 100644 --- a/homeassistant/components/asuswrt/diagnostics.py +++ b/homeassistant/components/asuswrt/diagnostics.py @@ -18,6 +18,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from . import AsusWrtConfigEntry +from .router import get_device_identifier TO_REDACT = {CONF_PASSWORD, CONF_UNIQUE_ID, CONF_USERNAME} TO_REDACT_DEV = {ATTR_CONNECTIONS, ATTR_IDENTIFIERS} @@ -34,8 +35,8 @@ async def async_get_config_entry_diagnostics( # Gather information how this AsusWrt device is represented in Home Assistant device_registry = dr.async_get(hass) entity_registry = er.async_get(hass) - hass_device = device_registry.async_get_device( - identifiers=router.device_info[ATTR_IDENTIFIERS] + hass_device = device_registry.async_get_device_by_identifier( + get_device_identifier(entry), entry.entry_id ) if not hass_device: return data diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py index d6a98465ca69b4..2b2e821cc34019 100644 --- a/homeassistant/components/asuswrt/router.py +++ b/homeassistant/components/asuswrt/router.py @@ -53,6 +53,12 @@ _LOGGER = logging.getLogger(__name__) + +def get_device_identifier(entry: ConfigEntry) -> tuple[str, str]: + """Return the device registry identifier of the router.""" + return (DOMAIN, entry.unique_id or "AsusWRT") + + _ENTITY_MIGRATION_ID = { "sensor_connected_device": "Devices Connected", "sensor_rx_bytes": "Download", @@ -389,7 +395,7 @@ def device_info(self) -> DeviceInfo: """Return the device information.""" info = DeviceInfo( configuration_url=self._api.configuration_url, - identifiers={(DOMAIN, self._entry.unique_id or "AsusWRT")}, + identifiers={get_device_identifier(self._entry)}, name=self.host, model=self._api.model or "Asus Router", model_id=self._api.model_id, From 2072d386af76623c695be7f27e541a5b8f6e30c7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Jul 2026 08:35:49 +0200 Subject: [PATCH 09/24] Adapt bond to new device registry API (#176938) --- homeassistant/components/bond/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bond/__init__.py b/homeassistant/components/bond/__init__.py index 8ef67fbf3a5543..fa4fd49f86cf78 100644 --- a/homeassistant/components/bond/__init__.py +++ b/homeassistant/components/bond/__init__.py @@ -120,10 +120,10 @@ def _async_remove_old_device_identifiers( ) -> None: """Remove the non-unique device registry entries.""" for device in hub.devices: - dev = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - if dev is None: - continue - if config_entry_id in dev.config_entries: + dev = device_registry.async_get_device_by_identifier( + (DOMAIN, device.device_id), config_entry_id + ) + if dev is not None: device_registry.async_remove_device(dev.id) From 1090a1776392a45d976a83d411882efa9549804e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Jul 2026 08:36:17 +0200 Subject: [PATCH 10/24] Adapt daikin to new device registry API (#176939) --- homeassistant/components/daikin/__init__.py | 33 +++++++++------------ 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py index 3fcc809a0f193c..a4b6c66325b99c 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -100,31 +100,26 @@ def _update_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None: if new_unique_id == old_unique_id: return - duplicate = dev_reg.async_get_device( - connections={(CONNECTION_NETWORK_MAC, new_mac)}, identifiers=None + duplicate = dev_reg.async_get_device_by_connection( + (CONNECTION_NETWORK_MAC, new_mac), config_entry.entry_id ) # Remove duplicated device if duplicate is not None: - if config_entry.entry_id in duplicate.config_entries: - _LOGGER.debug( - "Removing duplicated device %s", - duplicate.name, - ) + _LOGGER.debug( + "Removing duplicated device %s", + duplicate.name, + ) - # The automatic cleanup in entity registry is scheduled as a task, remove - # the entities manually to avoid unique_id collision when the entities - # are migrated. - duplicate_entities = er.async_entries_for_device( - ent_reg, duplicate.id, True - ) - for entity in duplicate_entities: - if entity.config_entry_id == config_entry.entry_id: - ent_reg.async_remove(entity.entity_id) + # The automatic cleanup in entity registry is scheduled as a task, remove + # the entities manually to avoid unique_id collision when the entities + # are migrated. + duplicate_entities = er.async_entries_for_device(ent_reg, duplicate.id, True) + for entity in duplicate_entities: + if entity.config_entry_id == config_entry.entry_id: + ent_reg.async_remove(entity.entity_id) - dev_reg.async_update_device( - duplicate.id, remove_config_entry_id=config_entry.entry_id - ) + dev_reg.async_remove_device(duplicate.id) # Migrate devices for device_entry in dr.async_entries_for_config_entry( From 4503c83b2163ff33ef19af1fec7e4f1b4c2bc1ac Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Jul 2026 08:37:20 +0200 Subject: [PATCH 11/24] Adapt habitica to new device registry API (#176944) --- homeassistant/components/habitica/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py index f864db7beb5d06..4b4db64374c5c2 100644 --- a/homeassistant/components/habitica/__init__.py +++ b/homeassistant/components/habitica/__init__.py @@ -100,9 +100,7 @@ def _party_update_listener() -> None: if device := device_reg.async_get_device_by_identifier( identifier, config_entry.entry_id ): - device_reg.async_update_device( - device.id, remove_config_entry_id=config_entry.entry_id - ) + device_reg.async_remove_device(device.id) notify_entities = [ entry.entity_id From b0f82dc76c3485661374ec58489d66b1e52908fc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Jul 2026 08:38:06 +0200 Subject: [PATCH 12/24] Adapt lutron to new device registry API (#176946) --- homeassistant/components/lutron/__init__.py | 33 +++++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py index ddecffb1a8f479..033d784852dc62 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -94,12 +94,24 @@ async def async_setup_entry( _LOGGER.debug("Working on area %s", area.name) for output in area.outputs: _setup_output( - hass, entry_data, output, area.name, entity_registry, device_registry + hass, + entry_data, + output, + area.name, + entity_registry, + device_registry, + config_entry.entry_id, ) for keypad in area.keypads: _setup_keypad( - hass, entry_data, keypad, area.name, entity_registry, device_registry + hass, + entry_data, + keypad, + area.name, + entity_registry, + device_registry, + config_entry.entry_id, ) if area.occupancy_group is not None: @@ -119,6 +131,7 @@ async def async_setup_entry( area.occupancy_group.uuid, area.occupancy_group.legacy_uuid, entry_data.client.guid, + config_entry.entry_id, ) device_registry.async_get_or_create( @@ -142,6 +155,7 @@ def _setup_output( area_name: str, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, + config_entry_id: str, ) -> None: """Set up a Lutron output.""" _LOGGER.debug("Working on output %s", output.type) @@ -172,6 +186,7 @@ def _setup_output( output.uuid, output.legacy_uuid, entry_data.client.guid, + config_entry_id, ) @@ -182,6 +197,7 @@ def _setup_keypad( area_name: str, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, + config_entry_id: str, ) -> None: """Set up a Lutron keypad.""" @@ -192,6 +208,7 @@ def _setup_keypad( keypad.uuid, keypad.legacy_uuid, entry_data.client.guid, + config_entry_id, ) leds_by_number = {led.number: led for led in keypad.leds} for button in keypad.buttons: @@ -259,6 +276,7 @@ def _async_check_device_identifiers( uuid: str, legacy_uuid: str, controller_guid: str, + config_entry_id: str, ) -> None: """If uuid becomes available update to use it.""" @@ -266,7 +284,9 @@ def _async_check_device_identifiers( return unique_id = f"{controller_guid}_{legacy_uuid}" - device = device_registry.async_get_device(identifiers={(DOMAIN, unique_id)}) + device = device_registry.async_get_device_by_identifier( + (DOMAIN, unique_id), config_entry_id + ) if device: new_unique_id = f"{controller_guid}_{uuid}" _LOGGER.debug("Updating device id from %s to %s", unique_id, new_unique_id) @@ -282,14 +302,15 @@ def _async_check_keypad_identifiers( uuid: str, legacy_uuid: str, controller_guid: str, + config_entry_id: str, ) -> None: """Migrate from integer based keypad.ids to proper uuids.""" # First check for the very old integer-based ID # We use cast(Any, ...) here because legacy devices may have integer identifiers # in the registry, but modern Home Assistant expects strings. - device = device_registry.async_get_device( - identifiers={(DOMAIN, cast(Any, keypad_id))} + device = device_registry.async_get_device_by_identifier( + (DOMAIN, cast(Any, keypad_id)), config_entry_id ) if device: new_unique_id = f"{controller_guid}_{uuid or legacy_uuid}" @@ -301,7 +322,7 @@ def _async_check_keypad_identifiers( # Now handle legacy_uuid to uuid migration if needed _async_check_device_identifiers( - hass, device_registry, uuid, legacy_uuid, controller_guid + hass, device_registry, uuid, legacy_uuid, controller_guid, config_entry_id ) From 22143e08ffada03b8c2f7a30e20a2fd3d50ac8ae Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Jul 2026 08:38:22 +0200 Subject: [PATCH 13/24] Adapt lutron_caseta to new device registry API (#176947) --- homeassistant/components/lutron_caseta/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index ad3e02b00e4686..0ee597d0ec684f 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -144,7 +144,9 @@ def _async_migrator(entity_entry: er.RegistryEntry) -> dict[str, Any] | None: return None sensor_id = unique_id.split("_")[1] new_unique_id = f"occupancygroup_{bridge_unique_id}_{sensor_id}" - if dev_entry := dev_reg.async_get_device(identifiers={(DOMAIN, unique_id)}): + if dev_entry := dev_reg.async_get_device_by_identifier( + (DOMAIN, unique_id), entry.entry_id + ): dev_reg.async_update_device( dev_entry.id, new_identifiers={(DOMAIN, new_unique_id)} ) From dadfe982586bb0d795865f8e55990d19d2fd52de Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Jul 2026 08:38:43 +0200 Subject: [PATCH 14/24] Adapt rfxtrx to new device registry API (#176951) --- homeassistant/components/rfxtrx/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index a67dec515aeb4e..051848885366f3 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -201,8 +201,9 @@ def async_handle_receive(event: rfxtrxmod.RFXtrxEvent) -> None: find_possible_pt2262_device(pt2262_devices, event.device.id_string) pt2262_devices.add(event.device.id_string) - device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, *device_id)}, # type: ignore[arg-type] + device_entry = device_registry.async_get_device_by_identifier( + (DOMAIN, *device_id), # type: ignore[arg-type] + entry.entry_id, ) if device_entry: event_data[ATTR_DEVICE_ID] = device_entry.id From 457ba8768f9eefbf01a82c832f781de134190c67 Mon Sep 17 00:00:00 2001 From: James Shannon Date: Mon, 20 Jul 2026 23:41:25 -0700 Subject: [PATCH 15/24] Bump yalexs to 9.2.10 (#176929) Co-authored-by: Claude Opus 4.8 --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yale/manifest.json | 2 +- requirements_all.txt | 2 +- tests/components/august/test_camera.py | 29 +------------------ tests/components/august/test_init.py | 2 +- 5 files changed, 5 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 82300f9a615c45..77df42a851ece8 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -30,5 +30,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==9.2.7", "yalexs-ble==3.3.1"] + "requirements": ["yalexs==9.2.10", "yalexs-ble==3.3.1"] } diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json index ce44726eecbac3..65e77934bd806d 100644 --- a/homeassistant/components/yale/manifest.json +++ b/homeassistant/components/yale/manifest.json @@ -14,5 +14,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["socketio", "engineio", "yalexs"], - "requirements": ["yalexs==9.2.7", "yalexs-ble==3.3.1"] + "requirements": ["yalexs==9.2.10", "yalexs-ble==3.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index fbbd0ba93bc4ac..882aa97a5c4efa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3434,7 +3434,7 @@ yalexs-ble==3.3.1 # homeassistant.components.august # homeassistant.components.yale -yalexs==9.2.7 +yalexs==9.2.10 # homeassistant.components.yeelight yeelight==0.7.16 diff --git a/tests/components/august/test_camera.py b/tests/components/august/test_camera.py index 724b862a3285dd..506fed4a1267f1 100644 --- a/tests/components/august/test_camera.py +++ b/tests/components/august/test_camera.py @@ -39,33 +39,6 @@ async def test_create_doorbell( assert body == "image" -async def test_doorbell_refresh_content_token_recover( - hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator -) -> None: - """Test camera image content token expired.""" - doorbell_two = await _mock_doorbell_from_fixture(hass, "get_doorbell.json") - with patch.object( - doorbell_two, - "async_get_doorbell_image", - create=False, - side_effect=[ContentTokenExpired, "image"], - ): - await _create_august_with_devices( - hass, - [doorbell_two], - brand=Brand.YALE_HOME, - ) - url = hass.states.get( - "camera.k98gidt45gul_name_k98gidt45gul_name_camera" - ).attributes["entity_picture"] - - client = await hass_client_no_auth() - resp = await client.get(url) - assert resp.status == HTTPStatus.OK - body = await resp.text() - assert body == "image" - - async def test_doorbell_refresh_content_token_fail( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator ) -> None: @@ -80,7 +53,7 @@ async def test_doorbell_refresh_content_token_fail( await _create_august_with_devices( hass, [doorbell_two], - brand=Brand.YALE_HOME, + brand=Brand.YALE_AUGUST, ) url = hass.states.get( "camera.k98gidt45gul_name_k98gidt45gul_name_camera" diff --git a/tests/components/august/test_init.py b/tests/components/august/test_init.py index b619d7ccefc704..9f77688bfbf088 100644 --- a/tests/components/august/test_init.py +++ b/tests/components/august/test_init.py @@ -261,7 +261,7 @@ async def test_brand_migration_issue(hass: HomeAssistant) -> None: """Test removing the brand migration issue.""" august_operative_lock = await _mock_operative_august_lock_detail(hass) config_entry, _ = await _create_august_with_devices( - hass, [august_operative_lock], brand=Brand.YALE_HOME + hass, [august_operative_lock], brand=Brand.YALE_AUGUST ) assert config_entry.state is ConfigEntryState.LOADED From 53c4711a4eca4b1daf67bdd38026a40818618dc0 Mon Sep 17 00:00:00 2001 From: Markus Tuominen <3738613+Markus98@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:02:01 +0200 Subject: [PATCH 16/24] Add test-before-configure pylint quality scale checker (#176894) --- .../edifier_infrared/quality_scale.yaml | 6 +- .../marantz_infrared/quality_scale.yaml | 6 +- .../components/probe_plus/quality_scale.yaml | 7 +- .../samsung_infrared/quality_scale.yaml | 6 +- pylint/plugins/README.md | 22 + .../quality_scale/test_before_configure.py | 188 +++++++++ .../test_test_before_configure.py | 386 ++++++++++++++++++ 7 files changed, 617 insertions(+), 4 deletions(-) create mode 100644 pylint/plugins/pylint_home_assistant/checkers/quality_scale/test_before_configure.py create mode 100644 tests/pylint/quality_scale/test_test_before_configure.py diff --git a/homeassistant/components/edifier_infrared/quality_scale.yaml b/homeassistant/components/edifier_infrared/quality_scale.yaml index f071a3cd0bebed..fbf8ae4bd11894 100644 --- a/homeassistant/components/edifier_infrared/quality_scale.yaml +++ b/homeassistant/components/edifier_infrared/quality_scale.yaml @@ -33,7 +33,11 @@ rules: status: exempt comment: | This integration does not store runtime data. - test-before-configure: done + test-before-configure: + status: exempt + comment: | + This integration only proxies commands through an existing infrared + entity, so there is no connection to test in the config flow. test-before-setup: status: exempt comment: | diff --git a/homeassistant/components/marantz_infrared/quality_scale.yaml b/homeassistant/components/marantz_infrared/quality_scale.yaml index 44acbe99475b28..337a88861899c5 100644 --- a/homeassistant/components/marantz_infrared/quality_scale.yaml +++ b/homeassistant/components/marantz_infrared/quality_scale.yaml @@ -30,7 +30,11 @@ rules: entity-unique-id: done has-entity-name: done runtime-data: done - test-before-configure: done + test-before-configure: + status: exempt + comment: | + This integration only proxies commands through an existing infrared + entity, so there is no connection to test in the config flow. test-before-setup: status: exempt comment: | diff --git a/homeassistant/components/probe_plus/quality_scale.yaml b/homeassistant/components/probe_plus/quality_scale.yaml index 048339455e1c33..2c9866551594f0 100644 --- a/homeassistant/components/probe_plus/quality_scale.yaml +++ b/homeassistant/components/probe_plus/quality_scale.yaml @@ -30,7 +30,12 @@ rules: entity-unique-id: done has-entity-name: done runtime-data: done - test-before-configure: done + test-before-configure: + status: exempt + comment: | + The integration relies on Bluetooth auto-discovery; the config flow + only offers discovered devices, so there is no user-provided + connection to test. test-before-setup: status: exempt comment: | diff --git a/homeassistant/components/samsung_infrared/quality_scale.yaml b/homeassistant/components/samsung_infrared/quality_scale.yaml index 0de64b1789ed4d..5f15a692387f4f 100644 --- a/homeassistant/components/samsung_infrared/quality_scale.yaml +++ b/homeassistant/components/samsung_infrared/quality_scale.yaml @@ -33,7 +33,11 @@ rules: status: exempt comment: | This integration does not store runtime data. - test-before-configure: done + test-before-configure: + status: exempt + comment: | + This integration only proxies commands through an existing infrared + entity, so there is no connection to test in the config flow. test-before-setup: status: exempt comment: | diff --git a/pylint/plugins/README.md b/pylint/plugins/README.md index 01d6af257e39e5..2646bae0b140c2 100644 --- a/pylint/plugins/README.md +++ b/pylint/plugins/README.md @@ -131,6 +131,7 @@ Every check has a code following the | `W7413` | [`home-assistant-missing-config-entry-unloading`](#w7413-home-assistant-missing-config-entry-unloading) | Integration should implement `async_unload_entry` | | `W7415` | [`home-assistant-sequential-executor-jobs`](#w7415-home-assistant-sequential-executor-jobs) | Sequential `async_add_executor_job` calls should be grouped | | `W7416` | [`home-assistant-missing-has-entity-name`](#w7416-home-assistant-missing-has-entity-name) | Entity class should set `_attr_has_entity_name = True` | +| `W7433` | [`home-assistant-missing-test-before-configure`](#w7433-home-assistant-missing-test-before-configure) | Config flow should test the connection before creating an entry | | `W7429` | [`home-assistant-unnecessary-format-mac`](#w7429-home-assistant-unnecessary-format-mac) | `format_mac()` is unnecessary with `CONNECTION_NETWORK_MAC` | | `W7430` | [`home-assistant-serial-port-selector-usb-dependency`](#w7430-home-assistant-serial-port-selector-usb-dependency) | Config flow using `SerialPortSelector` must declare `usb` in `dependencies` | @@ -831,6 +832,27 @@ supplied by an `entity_description` whose class sets `has_entity_name = True`. Conditional patterns are rejected. +## `home_assistant_test_before_configure` checker + +Quality-scale-gated checker for the +[`test-before-configure`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/test-before-configure) +Bronze rule. Fires only when the integration claims the rule as `done`. + +### `W7433`: `home-assistant-missing-test-before-configure` + +The config flow creates entries but shows no evidence of surfacing +connection failures to the user: no `errors=` keyword passed to a call +(with a non-empty literal or dynamic value) and no abort inside an +`except` handler. A failure can only be surfaced if it was detected +first, so this single footprint covers the whole test-before-configure +chain. Evidence is searched in `config_flow.py` and in the defining +modules of inherited flow classes from other integrations. OAuth flows +(`AbstractOAuth2FlowHandler`) are skipped; the token exchange is the +connection test. Integrations that rely on auto-discovery without +user-provided connection data should mark the rule `exempt`, per the +rule's exceptions. + + ## `home_assistant_unnecessary_format_mac` checker Detects redundant `format_mac()` calls inside `CONNECTION_NETWORK_MAC` diff --git a/pylint/plugins/pylint_home_assistant/checkers/quality_scale/test_before_configure.py b/pylint/plugins/pylint_home_assistant/checkers/quality_scale/test_before_configure.py new file mode 100644 index 00000000000000..f4dd44655af22f --- /dev/null +++ b/pylint/plugins/pylint_home_assistant/checkers/quality_scale/test_before_configure.py @@ -0,0 +1,188 @@ +"""Checker for the ``test-before-configure`` Bronze quality-scale rule. + +**Quality-scale-gated**: only fires for integrations whose +``quality_scale.yaml`` marks ``test-before-configure`` as ``done``. + +The config flow must test the connection with the user-provided data and +surface failures to the user before creating the entry. Testing cannot +be proven statically, so the checker looks for the footprint that +surfacing a failure always leaves behind, and fires when none is found: + +- an ``errors=`` keyword passed to a call (e.g. ``async_show_form``) + with a non-empty literal or any dynamic value, or +- an ``async_abort`` call or ``AbortFlow`` raise inside an ``except`` + handler (the catch-and-abort pattern of confirm-only flows). + +A failure can only be surfaced if it was detected first, so this single +footprint covers the whole test-before-configure chain; flows that +detect failures but never show them to the user fail the check. + +The footprint is searched in ``config_flow.py`` itself and in the +defining modules of inherited flow classes from other integrations +(e.g. the shared ``homeassistant_hardware`` firmware flow, which probes +the device before an entry can be created). Framework modules outside +``homeassistant.components`` are never treated as evidence. + +Config flow classes inheriting ``AbstractOAuth2FlowHandler`` are +skipped: the OAuth token exchange is the connection test. Integrations +that rely on auto-discovery without user-provided connection data should +mark the rule ``exempt`` instead, per the rule's exceptions. + +https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/test-before-configure/ +""" + +from astroid import nodes +from pylint.checkers import BaseChecker +from pylint.lint import PyLinter + +from pylint_home_assistant.const import Module, QualityScaleRule +from pylint_home_assistant.helpers.ast_utils import extended_ancestors +from pylint_home_assistant.helpers.module_info import ( + get_module_platform, + is_integration_module, +) +from pylint_home_assistant.helpers.quality_scale import quality_scale_rule_is_done + +_CONFIG_FLOW_QNAME = "homeassistant.config_entries.ConfigFlow" +_OAUTH_FLOW_QNAME = ( + "homeassistant.helpers.config_entry_oauth2_flow.AbstractOAuth2FlowHandler" +) + + +def _is_surfacing_errors_value(value: nodes.NodeNG) -> bool: + """Return True if the ``errors=`` value can show something to the user. + + A non-empty dict literal or any dynamic expression counts; an empty + literal or ``None`` cannot surface anything. + """ + match value: + case nodes.Dict(items=items): + return bool(items) + case nodes.Const(): + return False + case _: + return True + + +def _handler_aborts(handler: nodes.ExceptHandler) -> bool: + """Return True if the except handler aborts the flow.""" + for node in handler.nodes_of_class((nodes.Call, nodes.Raise)): + match node: + case nodes.Call(func=nodes.Attribute(attrname="async_abort")): + return True + case nodes.Raise( + exc=nodes.Call( + func=nodes.Name(name="AbortFlow") + | nodes.Attribute(attrname="AbortFlow") + ) + ): + return True + return False + + +def _module_surfaces_failures(module: nodes.Module) -> bool: + """Return True if the module shows evidence of surfacing failures.""" + for node in module.nodes_of_class((nodes.Call, nodes.ExceptHandler)): + match node: + case nodes.ExceptHandler(): + if _handler_aborts(node): + return True + case nodes.Call(keywords=keywords): + if any( + keyword.arg == "errors" + and _is_surfacing_errors_value(keyword.value) + for keyword in keywords + ): + return True + return False + + +def _creates_entry(class_node: nodes.ClassDef) -> bool: + """Return True if the class calls ``async_create_entry``.""" + return any( + isinstance(call.func, nodes.Attribute) + and call.func.attrname == "async_create_entry" + for call in class_node.nodes_of_class(nodes.Call) + ) + + +class TestBeforeConfigureChecker(BaseChecker): + """Checker for connection testing in config flow modules.""" + + name = "home_assistant_test_before_configure" + priority = -1 + msgs = { + "W7433": ( + ( + "Config flow should test the connection with the user-provided " + "data and show failures to the user before creating an entry " + "(https://developers.home-assistant.io/docs/core/" + "integration-quality-scale/rules/test-before-configure)" + ), + "home-assistant-missing-test-before-configure", + ( + "Used when an integration marks test-before-configure as done " + "but its config flow shows no evidence of surfacing connection " + "failures to the user: no errors passed to async_show_form and " + "no abort on a caught failure." + ), + ), + } + options = () + + _check_module: bool + _module_surfaces: bool + + def __init__(self, linter: PyLinter) -> None: + """Initialize the checker and its ancestor module evidence cache.""" + super().__init__(linter) + self._ancestor_surfaces: dict[str, bool] = {} + + def _ancestor_module_surfaces(self, module: nodes.Module) -> bool: + """Check an inherited flow class's module for evidence, cached.""" + if module.name not in self._ancestor_surfaces: + self._ancestor_surfaces[module.name] = _module_surfaces_failures(module) + return self._ancestor_surfaces[module.name] + + def visit_module(self, node: nodes.Module) -> None: + """Cache per-module gating and evidence scan results.""" + self._check_module = get_module_platform( + node.name + ) == Module.CONFIG_FLOW and quality_scale_rule_is_done( + node, QualityScaleRule.TEST_BEFORE_CONFIGURE + ) + self._module_surfaces = self._check_module and _module_surfaces_failures(node) + + def visit_classdef(self, node: nodes.ClassDef) -> None: + """Flag config flow classes that create entries without testing.""" + if not self._check_module or self._module_surfaces: + return + ancestor_qnames: set[str] = set() + integration_ancestors: list[nodes.ClassDef] = [] + for ancestor in extended_ancestors(node): + ancestor_qnames.add(ancestor.qname()) + if is_integration_module(ancestor.root().name): + integration_ancestors.append(ancestor) + if ( + _CONFIG_FLOW_QNAME not in ancestor_qnames + or _OAUTH_FLOW_QNAME in ancestor_qnames + ): + return + # Entry creation may be inherited from a shared base flow, so + # integration-module ancestors count as well. + if not _creates_entry(node) and not any( + _creates_entry(ancestor) for ancestor in integration_ancestors + ): + return + for ancestor in integration_ancestors: + ancestor_module = ancestor.root() + if ancestor_module.name != node.root().name and ( + self._ancestor_module_surfaces(ancestor_module) + ): + return + self.add_message("home-assistant-missing-test-before-configure", node=node) + + +def register(linter: PyLinter) -> None: + """Register the checker.""" + linter.register_checker(TestBeforeConfigureChecker(linter)) diff --git a/tests/pylint/quality_scale/test_test_before_configure.py b/tests/pylint/quality_scale/test_test_before_configure.py new file mode 100644 index 00000000000000..6485ac9528c424 --- /dev/null +++ b/tests/pylint/quality_scale/test_test_before_configure.py @@ -0,0 +1,386 @@ +"""Tests for the test-before-configure quality scale checker.""" + +import json +from pathlib import Path + +import astroid +from astroid import nodes +from pylint.testutils import MessageTest, UnittestLinter +from pylint_home_assistant.checkers.quality_scale.test_before_configure import ( + TestBeforeConfigureChecker, +) +from pylint_home_assistant.helpers.integration import clear_caches +from pylint_home_assistant.helpers.quality_scale import clear_quality_scale_cache +import pytest +import yaml + +from tests.pylint import assert_adds_messages, assert_no_messages, walk_checker + +_MODULE_NAME = "homeassistant.components.test_integration.config_flow" + +_FLOW_WITHOUT_TEST = """ +from homeassistant.config_entries import ConfigFlow + +class MyConfigFlow(ConfigFlow, domain="test_integration"): + async def async_step_user(self, user_input=None): + if user_input is not None: + return self.async_create_entry(title="Test", data=user_input) + return self.async_show_form(step_id="user") +""" + + +@pytest.fixture(name="configure_checker") +def configure_checker_fixture(linter: UnittestLinter) -> TestBeforeConfigureChecker: + """Fixture to provide a test before configure checker.""" + clear_quality_scale_cache() + clear_caches() + return TestBeforeConfigureChecker(linter) + + +def _make_integration( + tmp_path: Path, rules: dict | None = None, manifest: dict | None = None +) -> Path: + """Create a fake integration directory.""" + integration_dir = tmp_path / "homeassistant" / "components" / "test_integration" + integration_dir.mkdir(parents=True) + if rules is not None: + (integration_dir / "quality_scale.yaml").write_text(yaml.dump({"rules": rules})) + (integration_dir / "manifest.json").write_text( + json.dumps({"domain": "test_integration"} | (manifest or {})) + ) + return integration_dir + + +def _parse_config_flow( + integration_dir: Path, source: str, module_name: str = _MODULE_NAME +) -> astroid.Module: + """Parse the integration's config_flow module.""" + root_node = astroid.parse(source, module_name) + root_node.file = str(integration_dir / "config_flow.py") + return root_node + + +def _expect_missing(class_node: nodes.ClassDef) -> MessageTest: + """Build the expected MessageTest for a flagged config flow class.""" + pos = class_node.position + return MessageTest( + msg_id="home-assistant-missing-test-before-configure", + node=class_node, + line=pos.lineno, + col_offset=pos.col_offset, + end_line=pos.end_lineno, + end_col_offset=pos.end_col_offset, + ) + + +@pytest.mark.parametrize( + "flow_body", + [ + pytest.param( + """ + async def async_step_user(self, user_input=None): + errors: dict[str, str] = {} + if user_input is not None: + try: + await MyClient(user_input["host"]).get_data() + except MyException: + errors["base"] = "cannot_connect" + else: + return self.async_create_entry(title="Test", data=user_input) + return self.async_show_form(step_id="user", errors=errors) +""", + id="try_except_with_errors", + ), + pytest.param( + """ + async def async_step_user(self, user_input=None): + errors = None + if user_input is not None: + errors = await validate_input(self.hass, user_input) + if not errors: + return self.async_create_entry(title="Test", data=user_input) + return self.async_show_form(step_id="user", errors=errors) +""", + id="errors_from_helper_call", + ), + pytest.param( + """ + async def async_step_user(self, user_input=None): + errors: dict[str, str] = {} + if user_input is not None: + if not await MyClient(user_input["host"]).connect(): + errors["base"] = "cannot_connect" + else: + return self.async_create_entry(title="Test", data=user_input) + return self.async_show_form(step_id="user", errors=errors) +""", + id="errors_subscript_without_try", + ), + pytest.param( + """ + async def async_step_user(self, user_input=None): + errors = {} + if user_input is not None: + serial, errors = await self._validate_host(user_input["host"]) + if not errors: + return self.async_create_entry(title="Test", data=user_input) + return self.async_show_form(step_id="user", errors=errors) +""", + id="errors_from_tuple_unpacking", + ), + pytest.param( + """ + async def async_step_confirm(self, user_input=None): + try: + await self._probe_device() + except TimeoutError: + return self.async_abort(reason="cannot_connect") + return self.async_create_entry(title="Test", data={}) +""", + id="catch_and_abort", + ), + pytest.param( + """ + async def async_step_user(self, user_input=None): + if user_input is not None: + feed = await async_fetch_feed(self.hass, user_input["url"]) + if feed.bozo: + return self.async_show_form( + step_id="user", errors={"base": "url_error"} + ) + return self.async_create_entry(title="Test", data=user_input) + return self.async_show_form(step_id="user") +""", + id="errors_literal_kwarg", + ), + ], +) +def test_before_configure_evidence_present( + linter: UnittestLinter, + configure_checker: TestBeforeConfigureChecker, + tmp_path: Path, + flow_body: str, +) -> None: + """No warning when the config flow surfaces failures to the user.""" + integration_dir = _make_integration(tmp_path, {"test-before-configure": "done"}) + root_node = _parse_config_flow( + integration_dir, + "from homeassistant.config_entries import ConfigFlow\n" + f'class MyConfigFlow(ConfigFlow, domain="test_integration"):\n{flow_body}', + ) + + with assert_no_messages(linter): + walk_checker(linter, configure_checker, root_node) + + +_FLOW_DETECTED_NOT_SURFACED = """ +from homeassistant.config_entries import ConfigFlow + +class MyConfigFlow(ConfigFlow, domain="test_integration"): + async def async_step_user(self, user_input=None): + if user_input is not None: + serial, errors = await self._validate_host(user_input["host"]) + if not errors: + return self.async_create_entry(title="Test", data=user_input) + return self.async_show_form(step_id="user") +""" + +_FLOW_SWALLOWED_EXCEPTION = """ +from homeassistant.config_entries import ConfigFlow + +class MyConfigFlow(ConfigFlow, domain="test_integration"): + async def async_step_user(self, user_input=None): + if user_input is not None: + try: + await MyClient(user_input["host"]).get_data() + except MyException: + pass + return self.async_create_entry(title="Test", data=user_input) + return self.async_show_form(step_id="user") +""" + + +@pytest.mark.parametrize( + ("flow_source", "manifest"), + [ + pytest.param(_FLOW_WITHOUT_TEST, None, id="no_evidence"), + pytest.param( + _FLOW_WITHOUT_TEST, + {"bluetooth": [{"connectable": True}]}, + id="discovery_manifest_does_not_skip", + ), + pytest.param(_FLOW_DETECTED_NOT_SURFACED, None, id="detected_but_not_surfaced"), + pytest.param(_FLOW_SWALLOWED_EXCEPTION, None, id="swallowed_exception"), + ], +) +def test_before_configure_missing_fires( + linter: UnittestLinter, + configure_checker: TestBeforeConfigureChecker, + tmp_path: Path, + flow_source: str, + manifest: dict | None, +) -> None: + """Warning when the config flow never surfaces connection failures to the user.""" + integration_dir = _make_integration( + tmp_path, {"test-before-configure": "done"}, manifest + ) + root_node = _parse_config_flow(integration_dir, flow_source) + class_node = root_node.body[-1] + + with assert_adds_messages(linter, _expect_missing(class_node)): + walk_checker(linter, configure_checker, root_node) + + +def test_before_configure_oauth_flow_skipped( + linter: UnittestLinter, + configure_checker: TestBeforeConfigureChecker, + tmp_path: Path, +) -> None: + """No warning for OAuth flows; the token exchange is the connection test.""" + integration_dir = _make_integration(tmp_path, {"test-before-configure": "done"}) + root_node = _parse_config_flow( + integration_dir, + """ +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler + +class MyConfigFlow(AbstractOAuth2FlowHandler, domain="test_integration"): + async def async_oauth_create_entry(self, data): + return self.async_create_entry(title="Test", data=data) +""", + ) + + with assert_no_messages(linter): + walk_checker(linter, configure_checker, root_node) + + +def test_before_configure_inherited_evidence( + linter: UnittestLinter, + configure_checker: TestBeforeConfigureChecker, + tmp_path: Path, +) -> None: + """No warning when surfacing evidence lives in an inherited flow class's module.""" + astroid.parse( + """ +from homeassistant.config_entries import ConfigFlow + +class BaseHardwareFlow(ConfigFlow): + async def async_step_confirm(self, user_input=None): + try: + await self._probe_device() + except TimeoutError: + return self.async_abort(reason="cannot_connect") + return self._async_flow_finished() +""", + "homeassistant.components.hw_base.firmware_flow", + ) + integration_dir = _make_integration(tmp_path, {"test-before-configure": "done"}) + root_node = _parse_config_flow( + integration_dir, + """ +from homeassistant.components.hw_base.firmware_flow import BaseHardwareFlow + +class MyConfigFlow(BaseHardwareFlow, domain="test_integration"): + def _async_flow_finished(self): + return self.async_create_entry(title="Test", data={}) +""", + ) + + with assert_no_messages(linter): + walk_checker(linter, configure_checker, root_node) + + +def test_before_configure_inherited_entry_creation_fires( + linter: UnittestLinter, + configure_checker: TestBeforeConfigureChecker, + tmp_path: Path, +) -> None: + """Warning when entry creation is inherited and nothing surfaces failures.""" + astroid.parse( + """ +from homeassistant.config_entries import ConfigFlow + +class BaseSharedFlow(ConfigFlow): + async def async_step_user(self, user_input=None): + return self.async_create_entry(title="Test", data={}) +""", + "homeassistant.components.shared_base.config_flow", + ) + integration_dir = _make_integration(tmp_path, {"test-before-configure": "done"}) + root_node = _parse_config_flow( + integration_dir, + """ +from homeassistant.components.shared_base.config_flow import BaseSharedFlow + +class MyConfigFlow(BaseSharedFlow, domain="test_integration"): + VERSION = 1 +""", + ) + class_node = root_node.body[-1] + + with assert_adds_messages(linter, _expect_missing(class_node)): + walk_checker(linter, configure_checker, root_node) + + +def test_before_configure_non_config_flow_class( + linter: UnittestLinter, + configure_checker: TestBeforeConfigureChecker, + tmp_path: Path, +) -> None: + """No warning for classes that are not config flows.""" + integration_dir = _make_integration(tmp_path, {"test-before-configure": "done"}) + root_node = _parse_config_flow( + integration_dir, + """ +class MyHelper: + def make(self): + return self.async_create_entry(title="Test", data={}) +""", + ) + + with assert_no_messages(linter): + walk_checker(linter, configure_checker, root_node) + + +@pytest.mark.parametrize( + ("module_name", "rules"), + [ + pytest.param( + _MODULE_NAME, + None, + id="no_quality_scale_file", + ), + pytest.param( + _MODULE_NAME, + {"test-before-configure": "todo"}, + id="rule_todo", + ), + pytest.param( + _MODULE_NAME, + {"test-before-configure": {"status": "exempt", "comment": "reason"}}, + id="rule_exempt", + ), + pytest.param( + "homeassistant.components.test_integration.sensor", + {"test-before-configure": "done"}, + id="not_config_flow_module", + ), + pytest.param( + "not_homeassistant.something", + {"test-before-configure": "done"}, + id="not_an_integration", + ), + ], +) +def test_before_configure_not_fired( + linter: UnittestLinter, + configure_checker: TestBeforeConfigureChecker, + tmp_path: Path, + module_name: str, + rules: dict | None, +) -> None: + """No warning when the rule is not done or the module is not config_flow.""" + integration_dir = _make_integration(tmp_path, rules) + root_node = _parse_config_flow(integration_dir, _FLOW_WITHOUT_TEST, module_name) + + with assert_no_messages(linter): + walk_checker(linter, configure_checker, root_node) From 0111e539d38b9c7aeed375877806bcb716340ab5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Jul 2026 09:31:37 +0200 Subject: [PATCH 17/24] Migrate integrations to async_get_device_by_identifier (part 3) (#176905) --- homeassistant/components/lcn/__init__.py | 6 ++++-- homeassistant/components/lcn/helpers.py | 8 +++++--- homeassistant/components/lcn/websocket.py | 8 ++++---- .../components/libre_hardware_monitor/coordinator.py | 4 ++-- homeassistant/components/litterrobot/coordinator.py | 4 ++-- homeassistant/components/matter/adapter.py | 4 +++- homeassistant/components/music_assistant/__init__.py | 4 +++- homeassistant/components/nut/diagnostics.py | 4 ++-- homeassistant/components/ollama/__init__.py | 4 ++-- homeassistant/components/ondilo_ico/coordinator.py | 5 +++-- homeassistant/components/openai_conversation/__init__.py | 4 ++-- homeassistant/components/overkiz/coordinator.py | 4 ++-- .../components/playstation_network/media_player.py | 7 +++---- homeassistant/components/plugwise/coordinator.py | 8 ++++++-- homeassistant/components/powerwall/__init__.py | 4 +++- homeassistant/components/reolink/__init__.py | 4 +++- homeassistant/components/sensibo/coordinator.py | 4 +++- 17 files changed, 52 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index cc9e1d967635cf..2c158cf6b6fd0b 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -271,8 +271,10 @@ def async_host_input_received( logical_address.addr_id, logical_address.is_group, ) - identifiers = {(DOMAIN, generate_unique_id(config_entry.entry_id, address))} - device = device_registry.async_get_device(identifiers=identifiers) + device = device_registry.async_get_device_by_identifier( + (DOMAIN, generate_unique_id(config_entry.entry_id, address)), + config_entry.entry_id, + ) if isinstance(inp, pypck.inputs.ModStatusAccessControl): _async_fire_access_control_event(hass, device, address, inp) diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index 856bd1379b64cf..101984eae41058 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -159,7 +159,9 @@ def purge_device_registry( # Find device that references the host. references_host = set() - host_device = device_registry.async_get_device(identifiers={(DOMAIN, entry_id)}) + host_device = device_registry.async_get_device_by_identifier( + (DOMAIN, entry_id), entry_id + ) if host_device is not None: references_host.add(host_device.id) @@ -167,8 +169,8 @@ def purge_device_registry( references_entry_data = set() for device_data in imported_entry_data[CONF_DEVICES]: device_unique_id = generate_unique_id(entry_id, device_data[CONF_ADDRESS]) - device = device_registry.async_get_device( - identifiers={(DOMAIN, device_unique_id)} + device = device_registry.async_get_device_by_identifier( + (DOMAIN, device_unique_id), entry_id ) if device is not None: references_entry_data.add(device.id) diff --git a/homeassistant/components/lcn/websocket.py b/homeassistant/components/lcn/websocket.py index 0559b93ed1e272..1d182f07222d9c 100644 --- a/homeassistant/components/lcn/websocket.py +++ b/homeassistant/components/lcn/websocket.py @@ -261,10 +261,10 @@ async def websocket_delete_device( device_config = get_device_config(msg[CONF_ADDRESS], config_entry) device_registry = dr.async_get(hass) - identifiers = { - (DOMAIN, generate_unique_id(config_entry.entry_id, msg[CONF_ADDRESS])) - } - device = device_registry.async_get_device(identifiers, set()) + device = device_registry.async_get_device_by_identifier( + (DOMAIN, generate_unique_id(config_entry.entry_id, msg[CONF_ADDRESS])), + config_entry.entry_id, + ) if not (device and device_config): connection.send_result(msg["id"], False) diff --git a/homeassistant/components/libre_hardware_monitor/coordinator.py b/homeassistant/components/libre_hardware_monitor/coordinator.py index e4131f371ab812..79d8a590677325 100644 --- a/homeassistant/components/libre_hardware_monitor/coordinator.py +++ b/homeassistant/components/libre_hardware_monitor/coordinator.py @@ -130,8 +130,8 @@ async def _async_handle_changes_in_devices( ) device_registry = dr.async_get(self.hass) for device_id in orphaned_devices: - if device := device_registry.async_get_device( - identifiers={(DOMAIN, f"{self._entry_id}_{device_id}")} + if device := device_registry.async_get_device_by_identifier( + (DOMAIN, f"{self._entry_id}_{device_id}"), self._entry_id ): _LOGGER.debug( "Removing device: %s", self._previous_devices[device_id] diff --git a/homeassistant/components/litterrobot/coordinator.py b/homeassistant/components/litterrobot/coordinator.py index 91b14f83346dd2..ad9b1e2ef61099 100644 --- a/homeassistant/components/litterrobot/coordinator.py +++ b/homeassistant/components/litterrobot/coordinator.py @@ -81,8 +81,8 @@ async def _async_update_data(self) -> None: if stale_members := self.previous_members - current_members: device_registry = dr.async_get(self.hass) for device_id in stale_members: - device = device_registry.async_get_device( - identifiers={(DOMAIN, device_id)} + device = device_registry.async_get_device_by_identifier( + (DOMAIN, device_id), self.config_entry.entry_id ) if device: device_registry.async_update_device( diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py index c7955e58f077d6..95940a57216c76 100644 --- a/homeassistant/components/matter/adapter.py +++ b/homeassistant/components/matter/adapter.py @@ -88,7 +88,9 @@ def endpoint_removed_callback(event: EventType, data: dict[str, int]) -> None: node.endpoints[data["endpoint_id"]], ) identifier = (DOMAIN, f"{ID_TYPE_DEVICE_ID}_{node_device_id}") - if device := device_registry.async_get_device(identifiers={identifier}): + if device := device_registry.async_get_device_by_identifier( + identifier, self.config_entry.entry_id + ): device_registry.async_remove_device(device.id) def node_removed_callback(event: EventType, node_id: int) -> None: diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index 17f714a45ec2c3..d856e3cd737985 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -180,7 +180,9 @@ def remove_player(player_id: str) -> None: if player_id in entry.runtime_data.discovered_players: entry.runtime_data.discovered_players.remove(player_id) dev_reg = dr.async_get(hass) - if hass_device := dev_reg.async_get_device({(DOMAIN, player_id)}): + if hass_device := dev_reg.async_get_device_by_identifier( + (DOMAIN, player_id), entry.entry_id + ): dev_reg.async_update_device( hass_device.id, remove_config_entry_id=entry.entry_id ) diff --git a/homeassistant/components/nut/diagnostics.py b/homeassistant/components/nut/diagnostics.py index 06b965ae3cb681..a364d985d23761 100644 --- a/homeassistant/components/nut/diagnostics.py +++ b/homeassistant/components/nut/diagnostics.py @@ -36,8 +36,8 @@ async def async_get_config_entry_diagnostics( # Gather information how this Nut device is represented in Home Assistant device_registry = dr.async_get(hass) entity_registry = er.async_get(hass) - hass_device = device_registry.async_get_device( - identifiers={(DOMAIN, hass_data.unique_id)} + hass_device = device_registry.async_get_device_by_identifier( + (DOMAIN, hass_data.unique_id), entry.entry_id ) # Device is always created assert hass_device is not None diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index 7ee31e4e86624a..f97d67a9503c03 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -161,8 +161,8 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: DOMAIN, entry.entry_id, ) - device = device_registry.async_get_device( - identifiers={(DOMAIN, entry.entry_id)} + device = device_registry.async_get_device_by_identifier( + (DOMAIN, entry.entry_id), entry.entry_id ) if conversation_entity_id is not None: diff --git a/homeassistant/components/ondilo_ico/coordinator.py b/homeassistant/components/ondilo_ico/coordinator.py index 14323aed3fb89d..f29491ba8a5590 100644 --- a/homeassistant/components/ondilo_ico/coordinator.py +++ b/homeassistant/components/ondilo_ico/coordinator.py @@ -93,8 +93,9 @@ async def _async_update_data(self) -> dict[str, OndiloIcoPoolData]: for pool_id in removed_pools: pool_data = self.data.pop(pool_id) await pool_data.measures_coordinator.async_shutdown() - device_entry = self._device_registry.async_get_device( - identifiers={(DOMAIN, pool_data.ico["serial_number"])} + device_entry = self._device_registry.async_get_device_by_identifier( + (DOMAIN, pool_data.ico["serial_number"]), + self.config_entry.entry_id, ) if device_entry: self._device_registry.async_update_device( diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 77ccb98d9a35d3..5327599c9abe03 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -355,8 +355,8 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: DOMAIN, entry.entry_id, ) - device = device_registry.async_get_device( - identifiers={(DOMAIN, entry.entry_id)} + device = device_registry.async_get_device_by_identifier( + (DOMAIN, entry.entry_id), entry.entry_id ) if conversation_entity_id is not None: diff --git a/homeassistant/components/overkiz/coordinator.py b/homeassistant/components/overkiz/coordinator.py index 450d9d31c2e834..7d259cb2ef3751 100644 --- a/homeassistant/components/overkiz/coordinator.py +++ b/homeassistant/components/overkiz/coordinator.py @@ -213,8 +213,8 @@ async def on_device_removed( base_device_url = event.device_url.split("#")[0] registry = dr.async_get(coordinator.hass) - if registered_device := registry.async_get_device( - identifiers={(DOMAIN, base_device_url)} + if registered_device := registry.async_get_device_by_identifier( + (DOMAIN, base_device_url), coordinator.config_entry.entry_id ): registry.async_remove_device(registered_device.id) diff --git a/homeassistant/components/playstation_network/media_player.py b/homeassistant/components/playstation_network/media_player.py index c1166907ac4366..fa94e76c49ceb5 100644 --- a/homeassistant/components/playstation_network/media_player.py +++ b/homeassistant/components/playstation_network/media_player.py @@ -67,10 +67,9 @@ def add_entities() -> None: devices_added |= new_platforms for platform in SUPPORTED_PLATFORMS: - if device_reg.async_get_device( - identifiers={ - (DOMAIN, f"{coordinator.config_entry.unique_id}_{platform.value}") - } + if device_reg.async_get_device_by_identifier( + (DOMAIN, f"{coordinator.config_entry.unique_id}_{platform.value}"), + config_entry.entry_id, ): entities.append(PsnMediaPlayerEntity(coordinator, platform, trophy_titles)) devices_added.add(platform) diff --git a/homeassistant/components/plugwise/coordinator.py b/homeassistant/components/plugwise/coordinator.py index 01d3e39a1bd2df..2429529336f4ce 100644 --- a/homeassistant/components/plugwise/coordinator.py +++ b/homeassistant/components/plugwise/coordinator.py @@ -161,7 +161,9 @@ def _remove_devices(self, removed_devices: set[str]) -> None: device_reg = dr.async_get(self.hass) for device_id in removed_devices: if ( - device_entry := device_reg.async_get_device({(DOMAIN, device_id)}) + device_entry := device_reg.async_get_device_by_identifier( + (DOMAIN, device_id), self.config_entry.entry_id + ) ) is not None: device_reg.async_update_device( device_entry.id, remove_config_entry_id=self.config_entry.entry_id @@ -197,7 +199,9 @@ def _update_firmware_in_dr(self, device_id: str, firmware: str | None) -> bool: """Update device sw_version in device_registry.""" device_reg = dr.async_get(self.hass) if ( - device_entry := device_reg.async_get_device({(DOMAIN, device_id)}) + device_entry := device_reg.async_get_device_by_identifier( + (DOMAIN, device_id), self.config_entry.entry_id + ) ) is not None: device_reg.async_update_device(device_entry.id, sw_version=firmware) LOGGER.debug( diff --git a/homeassistant/components/powerwall/__init__.py b/homeassistant/components/powerwall/__init__.py index 6b921777a67e14..71b53573e7c3bf 100644 --- a/homeassistant/components/powerwall/__init__.py +++ b/homeassistant/components/powerwall/__init__.py @@ -253,7 +253,9 @@ async def async_migrate_entity_unique_ids( new_base_unique_id = base_info.gateway_din dev_reg = dr.async_get(hass) - if device := dev_reg.async_get_device(identifiers={(DOMAIN, old_base_unique_id)}): + if device := dev_reg.async_get_device_by_identifier( + (DOMAIN, old_base_unique_id), entry.entry_id + ): dev_reg.async_update_device( device.id, new_identifiers={(DOMAIN, new_base_unique_id)} ) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index a3caa09603d538..c83781f9868674 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -493,7 +493,9 @@ def migrate_entity_ids( new_device_id, ) new_identifiers = {(DOMAIN, new_device_id)} - existing_device = device_reg.async_get_device(identifiers=new_identifiers) + existing_device = device_reg.async_get_device_by_identifier( + (DOMAIN, new_device_id), config_entry_id + ) if existing_device is None: device_reg.async_update_device( device.id, new_identifiers=new_identifiers diff --git a/homeassistant/components/sensibo/coordinator.py b/homeassistant/components/sensibo/coordinator.py index b08ca2eb955348..8fdd867b577fa7 100644 --- a/homeassistant/components/sensibo/coordinator.py +++ b/homeassistant/components/sensibo/coordinator.py @@ -110,7 +110,9 @@ async def _async_update_data(self) -> SensiboData: LOGGER.debug("Removing stale devices: %s", stale_devices) device_registry = dr.async_get(self.hass) for _id in stale_devices: - device = device_registry.async_get_device(identifiers={(DOMAIN, _id)}) + device = device_registry.async_get_device_by_identifier( + (DOMAIN, _id), self.config_entry.entry_id + ) if device: device_registry.async_update_device( device_id=device.id, From 93ab89851fe2d3c7c8ee14c6362bfcfb07b561c0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:37:52 +0200 Subject: [PATCH 18/24] Add explicit methods for composite devices (#176923) --- homeassistant/helpers/device_registry.py | 11 +++++++++ homeassistant/helpers/entity_registry.py | 6 ++--- tests/helpers/test_device_registry.py | 29 ++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index bdafa8aa8f4f8c..7d5959388d7269 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -1457,6 +1457,17 @@ def async_get_devices_for_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: + """Return True if device_id is a pre-migration composite device id. + + A composite device was split into one device per config entry; the + composite device id no longer refers to a registered device. + """ + return device_id not in self.devices and bool( + self.devices.get_devices_for_composite_device_id(device_id) + ) + def _substitute_name_placeholders( self, domain: str, diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 5899764c36ae16..f9a1ab3c1f05b9 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -1760,10 +1760,8 @@ def _ignore_composite_device_id( if not device_id or device_id is UNDEFINED: return device_id device_registry = dr.async_get(self.hass) - if device_id in device_registry.devices: - return device_id - if not device_registry.async_get_devices_for_composite_device_id(device_id): - # Not a composite id, let _validate_item reject it + if not device_registry.async_is_composite_device_id(device_id): + # A real device or an unknown id; let _validate_item handle it return device_id report_issue = async_suggest_report_issue( self.hass, integration_domain=platform diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 3ad8bfd7a144bb..05d03d27eaf629 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -2946,6 +2946,35 @@ async def test_clear_config_subentry_clears_pending_move_targeting_it( assert device.id not in device_registry.devices +async def test_async_is_composite_device_id( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test asking the registry if a device id is a pre-migration composite id.""" + entry_1 = MockConfigEntry(domain="test") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "2")} + ) + old_id = "composite00000000000000000000ab" + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry.devices[device_1.id] = attr.evolve( + device_1, composite_device_id=old_id + ) + device_registry.devices[device_2.id] = attr.evolve( + device_2, composite_device_id=old_id + ) + + assert device_registry.async_is_composite_device_id(old_id) is True + assert device_registry.async_is_composite_device_id(device_1.id) is False + assert device_registry.async_is_composite_device_id(device_2.id) is False + assert device_registry.async_is_composite_device_id("unknown_id") is False + + @pytest.mark.parametrize("load_registries", [False]) async def test_async_get_device_composite_reuses_pre_migration_id( hass: HomeAssistant, hass_storage: dict[str, Any] From bed1390eb3d1acc1b746d990d7be4010ebd72ba7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Jul 2026 10:44:44 +0200 Subject: [PATCH 19/24] Add device registry method async_get_devices (#176931) --- homeassistant/components/heos/__init__.py | 6 +- homeassistant/components/heos/coordinator.py | 6 +- homeassistant/helpers/device_registry.py | 39 ++++++++-- tests/helpers/test_device_registry.py | 79 ++++++++++++++++++++ 4 files changed, 120 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index edda925bf8fe25..9a940aa565134e 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -43,12 +43,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool # Create set of identifiers excluding this integration identifiers = {ident for ident in device.identifiers if ident[0] != DOMAIN} - migrated_identifiers = {(DOMAIN, str(player_id))} + migrated_identifier = (DOMAIN, str(player_id)) # Add migrated if not already present in another # device, which occurs if the user downgraded and # then upgraded - if not device_registry.async_get_device(migrated_identifiers): - identifiers.update(migrated_identifiers) + if not device_registry.async_get_devices(identifiers={migrated_identifier}): + identifiers.add(migrated_identifier) if len(identifiers) > 0: device_registry.async_update_device( device.id, new_identifiers=identifiers diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index b71b85901d5eed..4457be755ec12b 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -42,6 +42,8 @@ class HeosCoordinator(DataUpdateCoordinator[None]): """Define the HEOS integration coordinator.""" + config_entry: HeosConfigEntry + def __init__(self, hass: HomeAssistant, config_entry: HeosConfigEntry) -> None: """Set up the coordinator and set in config_entry.""" credentials: Credentials | None = None @@ -208,8 +210,8 @@ def _async_update_player_ids(self, updated_player_ids: dict[int, int]) -> None: # updated_player_ids contains the mapped IDs in format old:new for old_id, new_id in updated_player_ids.items(): # update device registry - entry = device_registry.async_get_device( - identifiers={(DOMAIN, str(old_id))} + entry = device_registry.async_get_device_by_identifier( + (DOMAIN, str(old_id)), self.config_entry.entry_id ) if entry: new_identifiers = entry.identifiers.copy() diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 7d5959388d7269..07698e8a67c06c 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -1083,19 +1083,31 @@ def get_entries( self, identifiers: AbstractSet[tuple[str, str]] | None = None, connections: AbstractSet[tuple[str, str]] | None = None, + *, + config_entry_id: str | None = None, ) -> list[_EntryTypeT]: - """Get all entries matching identifiers or connections, across config entries.""" + """Get all entries matching identifiers or connections. + + Matches across all config entries, or only within one if config_entry_id + is given. + """ entries: dict[str, _EntryTypeT] = {} if identifiers: for identifier in identifiers: if (by_config_entry := self._identifiers.get(identifier)) is not None: - for entry in by_config_entry.values(): - entries[entry.id] = entry + if config_entry_id is None: + for entry in by_config_entry.values(): + entries[entry.id] = entry + elif (scoped := by_config_entry.get(config_entry_id)) is not None: + entries[scoped.id] = scoped if connections: for connection in _normalize_connections(connections): if (by_config_entry := self._connections.get(connection)) is not None: - for entry in by_config_entry.values(): - entries[entry.id] = entry + if config_entry_id is None: + for entry in by_config_entry.values(): + entries[entry.id] = entry + elif (scoped := by_config_entry.get(config_entry_id)) is not None: + entries[scoped.id] = scoped return list(entries.values()) @@ -1396,6 +1408,23 @@ def async_get_device_by_connection( connections={connection}, config_entry_id=config_entry_id ) + @callback + def async_get_devices( + self, + *, + identifiers: set[tuple[str, str]] | None = None, + connections: set[tuple[str, str]] | None = None, + config_entry_id: str | None = None, + ) -> list[DeviceEntry]: + """Get all devices matching any of the identifiers or connections. + + If config_entry_id is given, only devices owned by that config entry are + returned. + """ + return self.devices.get_entries( + identifiers, connections, config_entry_id=config_entry_id + ) + def _first_device_in_domain( self, devices: Iterable[DeviceEntry], domain: str ) -> DeviceEntry | None: diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 05d03d27eaf629..04f34972545219 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -2386,6 +2386,85 @@ async def test_async_get_device_by_connection_normalizes( ) +@pytest.mark.parametrize( + ("create_kwargs", "lookup_kwargs", "miss_kwargs"), + [ + pytest.param( + {"identifiers": {("test", "shared")}}, + {"identifiers": {("test", "shared")}}, + {"identifiers": {("test", "other")}}, + id="identifier", + ), + pytest.param( + {"connections": {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}}, + {"connections": {(dr.CONNECTION_NETWORK_MAC, "12-34-56-AB-CD-EF")}}, + {"connections": {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ff")}}, + id="connection", + ), + ], +) +async def test_async_get_devices( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + create_kwargs: dict[str, set[tuple[str, str]]], + lookup_kwargs: dict[str, set[tuple[str, str]]], + miss_kwargs: dict[str, set[tuple[str, str]]], +) -> None: + """A plural lookup returns all devices sharing the key across config entries.""" + entry_1 = MockConfigEntry(domain="test") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, **create_kwargs + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, **create_kwargs + ) + assert device_1.id != device_2.id + + assert { + device.id for device in device_registry.async_get_devices(**lookup_kwargs) + } == { + device_1.id, + device_2.id, + } + assert device_registry.async_get_devices(**miss_kwargs) == [] + assert [ + device.id + for device in device_registry.async_get_devices( + **lookup_kwargs, config_entry_id=entry_1.entry_id + ) + ] == [device_1.id] + assert ( + device_registry.async_get_devices(**lookup_kwargs, config_entry_id="unknown") + == [] + ) + + +async def test_async_get_devices_multiple_keys( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A plural lookup with several keys returns the union of matches, deduplicated.""" + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + mac = (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef") + device_1 = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={("test", "1")}, + connections={mac}, + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("test", "2")} + ) + + devices = device_registry.async_get_devices( + identifiers={("test", "1"), ("test", "2")}, connections={mac} + ) + assert {device.id for device in devices} == {device_1.id, device_2.id} + assert len(devices) == 2 + + async def test_async_remove_device_fans_out_to_migration_composite( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: From 257b10243b58ff825b1a5e93b8120c4393997f92 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Jul 2026 10:45:16 +0200 Subject: [PATCH 20/24] Adapt gios to new device registry API (#176943) --- homeassistant/components/gios/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py index 712dfc956138c0..5489fac739814c 100644 --- a/homeassistant/components/gios/__init__.py +++ b/homeassistant/components/gios/__init__.py @@ -32,8 +32,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: GiosConfigEntry) -> bool # We used to use int in device_entry identifiers, convert this to str. device_registry = dr.async_get(hass) old_ids = (DOMAIN, station_id) - device_entry = device_registry.async_get_device(identifiers={old_ids}) # type: ignore[arg-type] - if device_entry and entry.entry_id in device_entry.config_entries: + device_entry = device_registry.async_get_device_by_identifier( + old_ids, # type: ignore[arg-type] + entry.entry_id, + ) + if device_entry: new_ids = (DOMAIN, str(station_id)) device_registry.async_update_device(device_entry.id, new_identifiers={new_ids}) From dfa4d5378f987c93c5e8f6995adf751cb665492b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Jul 2026 10:45:54 +0200 Subject: [PATCH 21/24] Adapt acmeda to new device registry API (#176933) --- homeassistant/components/acmeda/helpers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/acmeda/helpers.py b/homeassistant/components/acmeda/helpers.py index e43bd6210a9439..06f6c048655e37 100644 --- a/homeassistant/components/acmeda/helpers.py +++ b/homeassistant/components/acmeda/helpers.py @@ -48,7 +48,9 @@ async def update_devices( for api_item in api.values(): # Update Device name - device = dev_registry.async_get_device(identifiers={(DOMAIN, api_item.id)}) + device = dev_registry.async_get_device_by_identifier( + (DOMAIN, api_item.id), config_entry.entry_id + ) if device is not None: dev_registry.async_update_device( device.id, From 811e1953d8759106a1057289d71376daf27e5deb Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Tue, 21 Jul 2026 10:47:04 +0200 Subject: [PATCH 22/24] Only detach own config entry on Overkiz device removal (#176898) --- .../components/overkiz/coordinator.py | 6 +- .../components/overkiz/quality_scale.yaml | 2 +- tests/components/overkiz/test_coordinator.py | 58 ++++++++++++++++++- 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/coordinator.py b/homeassistant/components/overkiz/coordinator.py index 7d259cb2ef3751..4de5324895776a 100644 --- a/homeassistant/components/overkiz/coordinator.py +++ b/homeassistant/components/overkiz/coordinator.py @@ -216,7 +216,11 @@ async def on_device_removed( if registered_device := registry.async_get_device_by_identifier( (DOMAIN, base_device_url), coordinator.config_entry.entry_id ): - registry.async_remove_device(registered_device.id) + # Detach only this entry; the registry deletes the device once none remain. + registry.async_update_device( + registered_device.id, + remove_config_entry_id=coordinator.config_entry.entry_id, + ) if event.device_url in coordinator.devices: del coordinator.devices[event.device_url] diff --git a/homeassistant/components/overkiz/quality_scale.yaml b/homeassistant/components/overkiz/quality_scale.yaml index 3ec0e8e96181cd..2a93686cf5fce1 100644 --- a/homeassistant/components/overkiz/quality_scale.yaml +++ b/homeassistant/components/overkiz/quality_scale.yaml @@ -52,7 +52,7 @@ rules: docs-supported-devices: done icon-translations: todo docs-known-limitations: done - stale-devices: todo + stale-devices: done docs-supported-functions: todo repair-issues: todo reconfiguration-flow: done diff --git a/tests/components/overkiz/test_coordinator.py b/tests/components/overkiz/test_coordinator.py index de5615bfdd5d5e..273a0ca5f531ff 100644 --- a/tests/components/overkiz/test_coordinator.py +++ b/tests/components/overkiz/test_coordinator.py @@ -16,10 +16,12 @@ from homeassistant.components.overkiz.const import UPDATE_INTERVAL from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from .conftest import FixtureDevice, MockOverkizClient, SetupOverkizIntegration +from .helpers import async_deliver_events, device_removed_event -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed TEMPERATURE_SENSOR = FixtureDevice( "setup/cloud_nexity_rail_din_europe.json", @@ -78,3 +80,57 @@ async def test_transient_error_is_retried( await hass.async_block_till_done() assert hass.states.get(TEMPERATURE_SENSOR.entity_id).state == initial_state.state + + +async def test_device_removed_deletes_device( + hass: HomeAssistant, + setup_overkiz_integration: SetupOverkizIntegration, + mock_client: MockOverkizClient, + freezer: FrozenDateTimeFactory, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """A DEVICE_REMOVED event deletes a device owned only by this config entry.""" + await setup_overkiz_integration(fixture=TEMPERATURE_SENSOR.fixture) + device_id = entity_registry.async_get(TEMPERATURE_SENSOR.entity_id).device_id + + await async_deliver_events( + hass, + freezer, + mock_client, + [device_removed_event(TEMPERATURE_SENSOR.device_url)], + ) + + assert device_registry.async_get(device_id) is None + + +async def test_device_removed_keeps_device_owned_by_other_entry( + hass: HomeAssistant, + setup_overkiz_integration: SetupOverkizIntegration, + mock_client: MockOverkizClient, + freezer: FrozenDateTimeFactory, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """A DEVICE_REMOVED event does not delete a device owned by another entry.""" + await setup_overkiz_integration(fixture=TEMPERATURE_SENSOR.fixture) + device_id = entity_registry.async_get(TEMPERATURE_SENSOR.entity_id).device_id + + # Move the device to another config entry; removing the Overkiz entry must then + # leave it in place instead of deleting a device it no longer owns. + other_entry = MockConfigEntry(domain="other") + other_entry.add_to_hass(hass) + device_registry.async_update_device( + device_id, new_config_entry_id=other_entry.entry_id + ) + + await async_deliver_events( + hass, + freezer, + mock_client, + [device_removed_event(TEMPERATURE_SENSOR.device_url)], + ) + + device = device_registry.async_get(device_id) + assert device is not None + assert device.config_entry_id == other_entry.entry_id From 589edd652348e535de541c3d9536d30f652125de Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:55:46 +0800 Subject: [PATCH 23/24] Fix template test to be cwd-independent (#176815) --- tests/components/template/test_blueprint.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/components/template/test_blueprint.py b/tests/components/template/test_blueprint.py index 342dde7afe8628..79e563202f4e4b 100644 --- a/tests/components/template/test_blueprint.py +++ b/tests/components/template/test_blueprint.py @@ -303,7 +303,9 @@ async def test_init_attribute_variables_from_blueprint(hass: HomeAssistant) -> N # Reload the templates without any change, but with updated blueprint blueprint_config = yaml_util.load_yaml( - pathlib.Path("tests/testing_config/blueprints/template/") / blueprint + pathlib.Path(__file__).resolve().parents[2] + / "testing_config/blueprints/template" + / blueprint ) blueprint_config["variables"]["extraa"] = "c" blueprint_config["sensor"]["variables"]["extrab"] = "d" From e562d7517f3cf2ab2cbdf75579ec06b860e9a7fd Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 21 Jul 2026 11:20:51 +0200 Subject: [PATCH 24/24] Bump gios to 7.1.1 (#176904) --- homeassistant/components/gios/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index c341f397da7e26..5264c1b4f2d702 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["dacite", "gios"], "quality_scale": "platinum", - "requirements": ["gios==7.1.0"] + "requirements": ["gios==7.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 882aa97a5c4efa..ff157744677030 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1112,7 +1112,7 @@ georss-qld-bushfire-alert-client==0.8 getmac==0.9.5 # homeassistant.components.gios -gios==7.1.0 +gios==7.1.1 # homeassistant.components.glances glances-api==0.10.0