From 020912117b6bbc2b5c4f08da2ecb2526feaa3141 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Tue, 18 Aug 2026 08:44:51 +0300 Subject: [PATCH 01/14] Bump zwave-js-server-python to 0.73.1 (#179459) --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 0720ff978154ad..faf008468dc806 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["zwave-js-server-python==0.73.0"], + "requirements": ["zwave-js-server-python==0.73.1"], "usb": [ { "known_devices": ["Aeotec Z-Stick Gen5+", "Z-WaveMe UZB"], diff --git a/requirements_all.txt b/requirements_all.txt index 1832b980f6d66f..4c47aa81035b0a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3497,7 +3497,7 @@ zinvolt==1.0.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.73.0 +zwave-js-server-python==0.73.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 From efa676ceeb3ede4178260d59d2260df67e6c9b73 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 18 Aug 2026 08:10:46 +0200 Subject: [PATCH 02/14] Fix onedrive oauth errors during setup (#179398) --- homeassistant/components/onedrive/__init__.py | 19 ++++++- .../onedrive_for_business/__init__.py | 19 ++++++- .../onedrive_for_business/strings.json | 3 + tests/components/onedrive/test_init.py | 51 +++++++++++++++++ .../onedrive_for_business/test_init.py | 56 +++++++++++++++++++ 5 files changed, 146 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 01f048fb2f8ded..b998f5f2ce722b 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -15,7 +15,12 @@ from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + OAuth2TokenRequestError, + OAuth2TokenRequestReauthError, +) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( @@ -175,6 +180,18 @@ async def _get_onedrive_client( ) from err session = OAuth2Session(hass, entry, implementation) + # Refresh up front, so a failure surfaces here instead of from inside the client + try: + await session.async_ensure_token_valid() + except OAuth2TokenRequestReauthError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from err + except OAuth2TokenRequestError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, translation_key="connection_error" + ) from err + async def get_access_token() -> str: await session.async_ensure_token_valid() return cast(str, session.token[CONF_ACCESS_TOKEN]) diff --git a/homeassistant/components/onedrive_for_business/__init__.py b/homeassistant/components/onedrive_for_business/__init__.py index 4144da4c152750..6f36120c8737da 100644 --- a/homeassistant/components/onedrive_for_business/__init__.py +++ b/homeassistant/components/onedrive_for_business/__init__.py @@ -13,7 +13,12 @@ from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + OAuth2TokenRequestError, + OAuth2TokenRequestReauthError, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, @@ -100,6 +105,18 @@ async def _get_onedrive_client( ) from err session = OAuth2Session(hass, entry, implementation) + # Refresh up front, so a failure surfaces here instead of from inside the client + try: + await session.async_ensure_token_valid() + except OAuth2TokenRequestReauthError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from err + except OAuth2TokenRequestError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, translation_key="connection_error" + ) from err + async def get_access_token() -> str: await session.async_ensure_token_valid() return cast(str, session.token[CONF_ACCESS_TOKEN]) diff --git a/homeassistant/components/onedrive_for_business/strings.json b/homeassistant/components/onedrive_for_business/strings.json index dce713a746239e..deaddbbb371d29 100644 --- a/homeassistant/components/onedrive_for_business/strings.json +++ b/homeassistant/components/onedrive_for_business/strings.json @@ -96,6 +96,9 @@ "authentication_failed": { "message": "[%key:component::onedrive::exceptions::authentication_failed::message%]" }, + "connection_error": { + "message": "[%key:component::onedrive::config::abort::connection_error%]" + }, "failed_to_get_folder": { "message": "[%key:component::onedrive::exceptions::failed_to_get_folder::message%]" }, diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py index d2a18e186168df..a48c1b4bb705b1 100644 --- a/tests/components/onedrive/test_init.py +++ b/tests/components/onedrive/test_init.py @@ -2,6 +2,7 @@ from copy import copy from html import escape +from http import HTTPStatus from json import dumps from unittest.mock import MagicMock, patch @@ -19,6 +20,7 @@ CONF_FOLDER_ID, CONF_FOLDER_NAME, DOMAIN, + OAUTH2_TOKEN, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -31,6 +33,7 @@ from .const import BACKUP_METADATA from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker async def test_load_unload_config_entry( @@ -58,6 +61,54 @@ async def test_load_unload_config_entry( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED +@pytest.mark.parametrize( + ("status", "state", "reason", "reauth_expected"), + [ + pytest.param( + HTTPStatus.BAD_REQUEST, + ConfigEntryState.SETUP_ERROR, + "Authentication failed", + True, + id="reauth", + ), + pytest.param( + HTTPStatus.TOO_MANY_REQUESTS, + ConfigEntryState.SETUP_RETRY, + "Failed to connect to OneDrive", + False, + id="transient", + ), + pytest.param( + HTTPStatus.INTERNAL_SERVER_ERROR, + ConfigEntryState.SETUP_RETRY, + "Failed to connect to OneDrive", + False, + id="server_error", + ), + ], +) +@pytest.mark.parametrize("expires_at", [0], ids=["expired"]) +async def test_token_refresh_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + status: HTTPStatus, + state: ConfigEntryState, + reason: str, + reauth_expected: bool, +) -> None: + """Test a failing token refresh during setup.""" + aioclient_mock.post(OAUTH2_TOKEN, status=status, json={}) + mock_config_entry.add_to_hass(hass) + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is state + assert mock_config_entry.reason == reason + assert bool(hass.config_entries.flow.async_progress()) is reauth_expected + + @pytest.mark.parametrize( ("side_effect", "state"), [ diff --git a/tests/components/onedrive_for_business/test_init.py b/tests/components/onedrive_for_business/test_init.py index 7df7bf8b307493..31329c94d9d683 100644 --- a/tests/components/onedrive_for_business/test_init.py +++ b/tests/components/onedrive_for_business/test_init.py @@ -1,6 +1,7 @@ """Test the OneDrive setup.""" from copy import copy +from http import HTTPStatus from unittest.mock import MagicMock, patch from onedrive_personal_sdk.exceptions import ( @@ -14,6 +15,8 @@ from homeassistant.components.onedrive_for_business.const import ( CONF_FOLDER_ID, CONF_FOLDER_PATH, + CONF_TENANT_ID, + OAUTH2_TOKEN, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -24,6 +27,7 @@ from . import setup_integration from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker async def test_load_unload_config_entry( @@ -51,6 +55,58 @@ async def test_load_unload_config_entry( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED +@pytest.mark.parametrize( + ("status", "state", "reason", "reauth_expected"), + [ + pytest.param( + HTTPStatus.BAD_REQUEST, + ConfigEntryState.SETUP_ERROR, + "Authentication failed", + True, + id="reauth", + ), + pytest.param( + HTTPStatus.TOO_MANY_REQUESTS, + ConfigEntryState.SETUP_RETRY, + "Failed to connect to OneDrive", + False, + id="transient", + ), + pytest.param( + HTTPStatus.INTERNAL_SERVER_ERROR, + ConfigEntryState.SETUP_RETRY, + "Failed to connect to OneDrive", + False, + id="server_error", + ), + ], +) +@pytest.mark.parametrize("expires_at", [0], ids=["expired"]) +async def test_token_refresh_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + status: HTTPStatus, + state: ConfigEntryState, + reason: str, + reauth_expected: bool, +) -> None: + """Test a failing token refresh during setup.""" + aioclient_mock.post( + OAUTH2_TOKEN.format(tenant_id=mock_config_entry.data[CONF_TENANT_ID]), + status=status, + json={}, + ) + mock_config_entry.add_to_hass(hass) + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is state + assert mock_config_entry.reason == reason + assert bool(hass.config_entries.flow.async_progress()) is reauth_expected + + @pytest.mark.parametrize( ("side_effect", "state"), [ From 73a07ecd18feda7bb28cd2bd1c3cd21ed01d85ed Mon Sep 17 00:00:00 2001 From: Manu Date: Tue, 18 Aug 2026 08:11:21 +0200 Subject: [PATCH 03/14] Fix TypeError for unconfigured accounts in Steam integration (#179443) --- .../components/steam_online/coordinator.py | 5 +- .../components/steam_online/sensor.py | 14 +- tests/components/steam_online/conftest.py | 6 + .../fixtures/GetPlayerSummaries.json | 14 + .../steam_online/snapshots/test_image.ambr | 511 ++++++++++++++++++ .../steam_online/snapshots/test_sensor.ambr | 235 ++++++++ 6 files changed, 781 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/steam_online/coordinator.py b/homeassistant/components/steam_online/coordinator.py index 4734ffe28c1081..9404ec1670bc03 100644 --- a/homeassistant/components/steam_online/coordinator.py +++ b/homeassistant/components/steam_online/coordinator.py @@ -26,7 +26,7 @@ class PlayerData: steamid: str communityvisibilitystate: int - profilestate: int + profilestate: int | None = None personaname: str commentpermission: int | None = None profileurl: str @@ -34,7 +34,7 @@ class PlayerData: avatarmedium: str avatarfull: str avatarhash: str - lastlogoff: int + lastlogoff: int | None = None personastate: int realname: str | None = None primaryclanid: str | None = None @@ -47,6 +47,7 @@ class PlayerData: gameid: str | None = None lobbysteamid: str | None = None gameserverip: str | None = None + gameserversteamid: str | None = None level: int | None = None diff --git a/homeassistant/components/steam_online/sensor.py b/homeassistant/components/steam_online/sensor.py index a0d8b19fa24c7c..653e23bd9a6e4d 100644 --- a/homeassistant/components/steam_online/sensor.py +++ b/homeassistant/components/steam_online/sensor.py @@ -86,14 +86,24 @@ class SteamSensorEntityDescription(SensorEntityDescription): if x.gameid is not None and (info := icons.get(x.gameid)) is not None else None ), - "last_online": dt_util.utc_from_timestamp(x.lastlogoff), + "last_online": ( + dt_util.utc_from_timestamp(x.lastlogoff) + if x.lastlogoff is not None + else None + ), "level": x.level, }, ), SteamSensorEntityDescription( key=SteamSensor.LAST_ONLINE, translation_key=SteamSensor.LAST_ONLINE, - value_fn=(lambda x: dt_util.utc_from_timestamp(x.lastlogoff)), + value_fn=( + lambda x: ( + dt_util.utc_from_timestamp(x.lastlogoff) + if x.lastlogoff is not None + else None + ) + ), device_class=SensorDeviceClass.TIMESTAMP, ), SteamSensorEntityDescription( diff --git a/tests/components/steam_online/conftest.py b/tests/components/steam_online/conftest.py index 44630ada87c4f1..3cdd78b4061398 100644 --- a/tests/components/steam_online/conftest.py +++ b/tests/components/steam_online/conftest.py @@ -27,6 +27,12 @@ def mock_config_entry() -> MockConfigEntry: title=ACCOUNT_NAME_2, unique_id=ACCOUNT_2, ), + ConfigSubentryData( + data={}, + subentry_type=SUBENTRY_TYPE_FRIEND, + title="unconfigured_account", + unique_id="1234567890", + ), ], version=3, ) diff --git a/tests/components/steam_online/fixtures/GetPlayerSummaries.json b/tests/components/steam_online/fixtures/GetPlayerSummaries.json index b6878b604d03fd..d7ed207a26fabe 100644 --- a/tests/components/steam_online/fixtures/GetPlayerSummaries.json +++ b/tests/components/steam_online/fixtures/GetPlayerSummaries.json @@ -37,6 +37,20 @@ "personastate": 2, "timecreated": 1303243041, "personastateflags": 0 + }, + { + "steamid": "1234567890", + "communityvisibilitystate": 3, + "personaname": "unconfigured_account", + "profileurl": "https://steamcommunity.com/profiles/1234567890/", + "avatar": "https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb.jpg", + "avatarmedium": "https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_medium.jpg", + "avatarfull": "https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg", + "avatarhash": "fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb", + "personastate": 0, + "primaryclanid": "1234567890", + "timecreated": 1786993711, + "personastateflags": 0 } ] } diff --git a/tests/components/steam_online/snapshots/test_image.ambr b/tests/components/steam_online/snapshots/test_image.ambr index 0213bd4dab8dc6..6f28ec3919c3ac 100644 --- a/tests/components/steam_online/snapshots/test_image.ambr +++ b/tests/components/steam_online/snapshots/test_image.ambr @@ -1030,3 +1030,514 @@ 'state': 'unavailable', }) # --- +# name: test_images[image.unconfigured_account_app_icon-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'image', + 'entity_category': None, + 'entity_id': 'image.unconfigured_account_app_icon', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'App icon', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'App icon', + 'platform': 'steam_online', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234567890_app_icon', + 'unit_of_measurement': None, + }) +# --- +# name: test_images[image.unconfigured_account_app_icon-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '/api/image_proxy/image.unconfigured_account_app_icon?token=520', + : 'unconfigured_account App icon', + }), + 'context': , + 'entity_id': 'image.unconfigured_account_app_icon', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_images[image.unconfigured_account_avatar-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'image', + 'entity_category': None, + 'entity_id': 'image.unconfigured_account_avatar', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Avatar', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Avatar', + 'platform': 'steam_online', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234567890_avatar', + 'unit_of_measurement': None, + }) +# --- +# name: test_images[image.unconfigured_account_avatar-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '520', + : '/api/image_proxy/image.unconfigured_account_avatar?token=520', + : 'unconfigured_account Avatar', + }), + 'context': , + 'entity_id': 'image.unconfigured_account_avatar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2013-12-13T12:13:12+00:00', + }) +# --- +# name: test_images[image.unconfigured_account_header_capsule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'image', + 'entity_category': None, + 'entity_id': 'image.unconfigured_account_header_capsule', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Header capsule', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Header capsule', + 'platform': 'steam_online', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234567890_header_capsule', + 'unit_of_measurement': None, + }) +# --- +# name: test_images[image.unconfigured_account_header_capsule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '/api/image_proxy/image.unconfigured_account_header_capsule?token=520', + : 'unconfigured_account Header capsule', + }), + 'context': , + 'entity_id': 'image.unconfigured_account_header_capsule', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_images[image.unconfigured_account_library_capsule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'image', + 'entity_category': None, + 'entity_id': 'image.unconfigured_account_library_capsule', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Library capsule', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Library capsule', + 'platform': 'steam_online', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234567890_library_capsule', + 'unit_of_measurement': None, + }) +# --- +# name: test_images[image.unconfigured_account_library_capsule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '/api/image_proxy/image.unconfigured_account_library_capsule?token=520', + : 'unconfigured_account Library capsule', + }), + 'context': , + 'entity_id': 'image.unconfigured_account_library_capsule', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_images[image.unconfigured_account_library_hero_capsule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'image', + 'entity_category': None, + 'entity_id': 'image.unconfigured_account_library_hero_capsule', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Library hero capsule', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Library hero capsule', + 'platform': 'steam_online', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234567890_library_hero', + 'unit_of_measurement': None, + }) +# --- +# name: test_images[image.unconfigured_account_library_hero_capsule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '/api/image_proxy/image.unconfigured_account_library_hero_capsule?token=520', + : 'unconfigured_account Library hero capsule', + }), + 'context': , + 'entity_id': 'image.unconfigured_account_library_hero_capsule', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_images[image.unconfigured_account_library_logo-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'image', + 'entity_category': None, + 'entity_id': 'image.unconfigured_account_library_logo', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Library logo', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Library logo', + 'platform': 'steam_online', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234567890_library_logo', + 'unit_of_measurement': None, + }) +# --- +# name: test_images[image.unconfigured_account_library_logo-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '/api/image_proxy/image.unconfigured_account_library_logo?token=520', + : 'unconfigured_account Library logo', + }), + 'context': , + 'entity_id': 'image.unconfigured_account_library_logo', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_images[image.unconfigured_account_main_capsule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'image', + 'entity_category': None, + 'entity_id': 'image.unconfigured_account_main_capsule', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Main capsule', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Main capsule', + 'platform': 'steam_online', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234567890_main_capsule', + 'unit_of_measurement': None, + }) +# --- +# name: test_images[image.unconfigured_account_main_capsule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '/api/image_proxy/image.unconfigured_account_main_capsule?token=520', + : 'unconfigured_account Main capsule', + }), + 'context': , + 'entity_id': 'image.unconfigured_account_main_capsule', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_images[image.unconfigured_account_page_background-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'image', + 'entity_category': None, + 'entity_id': 'image.unconfigured_account_page_background', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Page background', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Page background', + 'platform': 'steam_online', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234567890_page_background', + 'unit_of_measurement': None, + }) +# --- +# name: test_images[image.unconfigured_account_page_background-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '/api/image_proxy/image.unconfigured_account_page_background?token=520', + : 'unconfigured_account Page background', + }), + 'context': , + 'entity_id': 'image.unconfigured_account_page_background', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_images[image.unconfigured_account_small_capsule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'image', + 'entity_category': None, + 'entity_id': 'image.unconfigured_account_small_capsule', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Small capsule', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Small capsule', + 'platform': 'steam_online', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234567890_small_capsule', + 'unit_of_measurement': None, + }) +# --- +# name: test_images[image.unconfigured_account_small_capsule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '/api/image_proxy/image.unconfigured_account_small_capsule?token=520', + : 'unconfigured_account Small capsule', + }), + 'context': , + 'entity_id': 'image.unconfigured_account_small_capsule', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_images[image.unconfigured_account_vertical_capsule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'image', + 'entity_category': None, + 'entity_id': 'image.unconfigured_account_vertical_capsule', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Vertical capsule', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Vertical capsule', + 'platform': 'steam_online', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234567890_vertical_capsule', + 'unit_of_measurement': None, + }) +# --- +# name: test_images[image.unconfigured_account_vertical_capsule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '/api/image_proxy/image.unconfigured_account_vertical_capsule?token=520', + : 'unconfigured_account Vertical capsule', + }), + 'context': , + 'entity_id': 'image.unconfigured_account_vertical_capsule', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/steam_online/snapshots/test_sensor.ambr b/tests/components/steam_online/snapshots/test_sensor.ambr index ecedc94d93b619..bcece3c4cf7f8d 100644 --- a/tests/components/steam_online/snapshots/test_sensor.ambr +++ b/tests/components/steam_online/snapshots/test_sensor.ambr @@ -470,3 +470,238 @@ 'state': 'unknown', }) # --- +# name: test_sensors[sensor.unconfigured_account-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'offline', + 'online', + 'busy', + 'away', + 'snooze', + 'looking_to_trade', + 'looking_to_play', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.unconfigured_account', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'steam_online', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234567890_account', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.unconfigured_account-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'created': datetime.datetime(2026, 8, 17, 12, 8, 31, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + : 'enum', + : 'https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg', + : 'unconfigured_account', + 'game': None, + 'game_icon': None, + 'game_id': None, + 'game_image_header': None, + 'game_image_main': None, + 'last_online': None, + 'level': 10, + : list([ + 'offline', + 'online', + 'busy', + 'away', + 'snooze', + 'looking_to_trade', + 'looking_to_play', + ]), + 'real_name': None, + }), + 'context': , + 'entity_id': 'sensor.unconfigured_account', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'offline', + }) +# --- +# name: test_sensors[sensor.unconfigured_account_last_online-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.unconfigured_account_last_online', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Last online', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last online', + 'platform': 'steam_online', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234567890_last_online', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.unconfigured_account_last_online-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'unconfigured_account Last online', + }), + 'context': , + 'entity_id': 'sensor.unconfigured_account_last_online', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.unconfigured_account_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.unconfigured_account_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Level', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Level', + 'platform': 'steam_online', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234567890_level', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.unconfigured_account_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'unconfigured_account Level', + : , + }), + 'context': , + 'entity_id': 'sensor.unconfigured_account_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensors[sensor.unconfigured_account_now_playing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.unconfigured_account_now_playing', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Now playing', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Now playing', + 'platform': 'steam_online', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234567890_now_playing', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.unconfigured_account_now_playing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'app_id': None, + : 'unconfigured_account Now playing', + }), + 'context': , + 'entity_id': 'sensor.unconfigured_account_now_playing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- From c6d34dd31539aa2eff48033ff74c026e5da89db0 Mon Sep 17 00:00:00 2001 From: Ryan Ray Date: Tue, 18 Aug 2026 01:25:30 -0500 Subject: [PATCH 04/14] Expose UniFi Policy Engine rules as switches (#169675) --- .../components/unifi/hub/entity_loader.py | 3 + homeassistant/components/unifi/switch.py | 46 +++++++++ tests/components/unifi/conftest.py | 11 +++ tests/components/unifi/test_switch.py | 96 +++++++++++++++++++ 4 files changed, 156 insertions(+) diff --git a/homeassistant/components/unifi/hub/entity_loader.py b/homeassistant/components/unifi/hub/entity_loader.py index abc0ece041d8b4..9e70d51cc70b96 100644 --- a/homeassistant/components/unifi/hub/entity_loader.py +++ b/homeassistant/components/unifi/hub/entity_loader.py @@ -51,6 +51,9 @@ def __init__(self, hub: UnifiHub) -> None: self.wireless_clients = hub.hass.data[UNIFI_WIRELESS_CLIENTS] self._polling_coordinators: dict[int, UnifiDataUpdateCoordinator] = { + id(hub.api.object_oriented_network_configs): UnifiDataUpdateCoordinator( + hub, hub.api.object_oriented_network_configs + ), id(hub.api.traffic_rules): UnifiDataUpdateCoordinator( hub, hub.api.traffic_rules ), diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 4677c023c347b4..0173a396fd1529 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -5,6 +5,7 @@ Support for controlling deep packet inspection (DPI) restriction groups. Support for controlling WLAN availability. Support for controlling zone based traffic rules. +Support for controlling Policy Engine rules. """ import asyncio @@ -17,6 +18,9 @@ from aiounifi.interfaces.clients import Clients from aiounifi.interfaces.dpi_restriction_groups import DPIRestrictionGroups from aiounifi.interfaces.firewall_policies import FirewallPolicies +from aiounifi.interfaces.object_oriented_network_configs import ( + ObjectOrientedNetworkConfigs, +) from aiounifi.interfaces.outlets import Outlets from aiounifi.interfaces.port_forwarding import PortForwarding from aiounifi.interfaces.ports import Ports @@ -33,6 +37,10 @@ from aiounifi.models.dpi_restriction_group import DPIRestrictionGroup from aiounifi.models.event import Event, EventKey from aiounifi.models.firewall_policy import FirewallPolicy, FirewallPolicyUpdateRequest +from aiounifi.models.object_oriented_network_config import ( + ObjectOrientedNetworkConfig, + ObjectOrientedNetworkInternetMode, +) from aiounifi.models.outlet import Outlet from aiounifi.models.port import Port from aiounifi.models.port_forward import PortForward, PortForwardEnableRequest @@ -152,6 +160,29 @@ def async_firewall_policy_supported_fn(hub: UnifiHub, obj_id: str) -> bool: return not policy.predefined +async def async_object_oriented_network_config_control_fn( + hub: UnifiHub, obj_id: str, target: bool +) -> None: + """Control Policy Engine rule state.""" + config = hub.api.object_oriented_network_configs[obj_id] + await hub.api.object_oriented_network_configs.save(config, target) + + +@callback +def async_object_oriented_network_config_supported_fn( + hub: UnifiHub, obj_id: str +) -> bool: + """Check if Policy Engine rule can be controlled as a switch.""" + config = hub.api.object_oriented_network_configs[obj_id] + secure = config.secure + return ( + secure.available + and secure.enabled + and secure.internet is not None + and secure.internet.mode is ObjectOrientedNetworkInternetMode.TURN_OFF_INTERNET + ) + + @callback def async_outlet_switching_supported_fn(hub: UnifiHub, obj_id: str) -> bool: """Determine if an outlet supports switching.""" @@ -283,6 +314,21 @@ class UnifiSwitchEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( unique_id_fn=lambda hub, obj_id: f"firewall_policy-{obj_id}", supported_fn=async_firewall_policy_supported_fn, ), + UnifiSwitchEntityDescription[ + ObjectOrientedNetworkConfigs, ObjectOrientedNetworkConfig + ]( + key="Policy Engine rule control", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + api_handler_fn=lambda api: api.object_oriented_network_configs, + control_fn=async_object_oriented_network_config_control_fn, + device_info_fn=async_unifi_network_device_info_fn, + is_on_fn=lambda hub, config: config.enabled, + name_fn=lambda config: config.name, + object_fn=lambda api, obj_id: api.object_oriented_network_configs[obj_id], + supported_fn=async_object_oriented_network_config_supported_fn, + unique_id_fn=lambda hub, obj_id: f"object_oriented_network_config-{obj_id}", + ), UnifiSwitchEntityDescription[Outlets, Outlet]( key="Outlet control", device_class=SwitchDeviceClass.OUTLET, diff --git a/tests/components/unifi/conftest.py b/tests/components/unifi/conftest.py index dc08dedf11717e..a33d3b536effd7 100644 --- a/tests/components/unifi/conftest.py +++ b/tests/components/unifi/conftest.py @@ -179,6 +179,7 @@ def fixture_request( dpi_app_payload: list[dict[str, Any]], dpi_group_payload: list[dict[str, Any]], firewall_policy_payload: list[dict[str, Any]], + object_oriented_network_config_payload: list[dict[str, Any]], port_forward_payload: list[dict[str, Any]], traffic_rule_payload: list[dict[str, Any]], traffic_route_payload: list[dict[str, Any]], @@ -221,6 +222,10 @@ def mock_get_request(path: str, payload: list[dict[str, Any]]) -> None: mock_get_request( f"/v2/api/site/{site_id}/firewall-policies", firewall_policy_payload ) + mock_get_request( + f"/v2/api/site/{site_id}/object-oriented-network-configs", + object_oriented_network_config_payload, + ) mock_get_request(f"/api/s/{site_id}/rest/portforward", port_forward_payload) mock_get_request(f"/api/s/{site_id}/stat/sysinfo", system_information_payload) mock_get_request(f"/api/s/{site_id}/rest/wlanconf", wlan_payload) @@ -269,6 +274,12 @@ def firewall_policy_payload_data() -> list[dict[str, Any]]: return [] +@pytest.fixture(name="object_oriented_network_config_payload") +def object_oriented_network_config_payload_data() -> list[dict[str, Any]]: + """Object-oriented network config data.""" + return [] + + @pytest.fixture(name="port_forward_payload") def fixture_port_forward_data() -> list[dict[str, Any]]: """Port forward data.""" diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index 05b9db5ddb5897..e1c980a0e21096 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -872,6 +872,33 @@ }, } +OBJECT_ORIENTED_NETWORK_CONFIG = { + "id": "69f6b0a5e0e3ee2d4614cb5c", + "enabled": True, + "name": "Nintendo Switch - Block Internet", + "target_type": "CLIENTS", + "targets": [CLIENT_1["mac"]], + "qos": {"enabled": False}, + "route": {"enabled": False}, + "secure": { + "enabled": True, + "internet": { + "mode": "TURN_OFF_INTERNET", + "schedule": {"mode": "ALWAYS"}, + }, + }, +} + +OBJECT_ORIENTED_NETWORK_ROUTE_CONFIG = { + "id": "69f6b0eae0e3ee2d4614cb91", + "enabled": True, + "name": "VPN traffic route", + "target_type": "NETWORKS", + "targets": ["6060b00f45de3905133cea14"], + "route": {"enabled": True}, + "secure": None, +} + @pytest.mark.parametrize( "config_entry_options", [{CONF_BLOCK_CLIENT: [BLOCKED["mac"]]}] @@ -1353,6 +1380,75 @@ async def test_firewall_policies( assert aioclient_mock.mock_calls[call_count][2] == expected_enable_call +@pytest.mark.parametrize( + ("object_oriented_network_config_payload"), + [([OBJECT_ORIENTED_NETWORK_CONFIG, OBJECT_ORIENTED_NETWORK_ROUTE_CONFIG])], +) +async def test_object_oriented_network_configs( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry_setup: MockConfigEntry, + object_oriented_network_config_payload: list[dict[str, Any]], +) -> None: + """Test control of UniFi Policy Engine rules.""" + entity_id = "switch.unifi_network_nintendo_switch_block_internet" + assert hass.states.get("switch.unifi_network_vpn_traffic_route") is None + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + config = deepcopy(object_oriented_network_config_payload[0]) + config_url = ( + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/v2/api/site/{config_entry_setup.data[CONF_SITE_ID]}" + f"/object-oriented-network-config/{config['id']}" + ) + + aioclient_mock.put(config_url) + + call_count = aioclient_mock.call_count + + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_off", + {"entity_id": entity_id}, + blocking=True, + ) + expected_disable_call = deepcopy(config) + expected_disable_call["enabled"] = False + + assert ( + "put", + config_url, + expected_disable_call, + ) in ( + (method, str(url), data) + for method, url, data, _headers in aioclient_mock.mock_calls[call_count:] + ) + + call_count = aioclient_mock.call_count + + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_on", + {"entity_id": entity_id}, + blocking=True, + ) + + expected_enable_call = deepcopy(config) + expected_enable_call["enabled"] = True + + assert ( + "put", + config_url, + expected_enable_call, + ) in ( + (method, str(url), data) + for method, url, data, _headers in aioclient_mock.mock_calls[call_count:] + ) + + @pytest.mark.parametrize( ("device_payload", "entity_id", "outlet_index", "expected_switches"), [ From 7886a262dbe7504970c643ba5de50a47ab01d05b Mon Sep 17 00:00:00 2001 From: Alex Fishlock Date: Tue, 18 Aug 2026 07:29:51 +0100 Subject: [PATCH 05/14] Make Lyngdorf entity actions async (#179409) Co-authored-by: Erwin Douna --- .../components/lyngdorf/media_player.py | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/lyngdorf/media_player.py b/homeassistant/components/lyngdorf/media_player.py index aad57f617815f6..fa0315329d4702 100644 --- a/homeassistant/components/lyngdorf/media_player.py +++ b/homeassistant/components/lyngdorf/media_player.py @@ -142,30 +142,32 @@ def volume_level(self) -> float | None: return _to_ha_volume(self._receiver.zone_b_volume) @override - def turn_on(self) -> None: + async def async_turn_on(self) -> None: """Turn on media player.""" self._receiver.zone_b_power_on = True @override - def turn_off(self) -> None: + async def async_turn_off(self) -> None: """Turn off media player.""" self._receiver.zone_b_power_on = False - def volume_up(self) -> None: + @override + async def async_volume_up(self) -> None: """Volume up the media player.""" self._receiver.zone_b_volume_up() - def volume_down(self) -> None: + @override + async def async_volume_down(self) -> None: """Volume down the media player.""" self._receiver.zone_b_volume_down() @override - def set_volume_level(self, volume: float) -> None: + async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" self._receiver.zone_b_volume = _to_lyngdorf_volume(volume) @override - def mute_volume(self, mute: bool) -> None: + async def async_mute_volume(self, mute: bool) -> None: """Send mute command.""" self._receiver.zone_b_mute_enabled = mute @@ -182,7 +184,7 @@ def source_list(self) -> list[str] | None: return self._receiver.zone_b_available_sources @override - def select_source(self, source: str) -> None: + async def async_select_source(self, source: str) -> None: """Select input source.""" self._receiver.zone_b_source = source @@ -253,39 +255,41 @@ def sound_mode(self) -> str | None: return self._receiver.sound_mode @override - def turn_on(self) -> None: + async def async_turn_on(self) -> None: """Turn on media player.""" self._receiver.power_on = True @override - def turn_off(self) -> None: + async def async_turn_off(self) -> None: """Turn off media player.""" self._receiver.power_on = False - def volume_up(self) -> None: + @override + async def async_volume_up(self) -> None: """Volume up the media player.""" self._receiver.volume_up() - def volume_down(self) -> None: + @override + async def async_volume_down(self) -> None: """Volume down the media player.""" self._receiver.volume_down() @override - def set_volume_level(self, volume: float) -> None: + async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" self._receiver.volume = _to_lyngdorf_volume(volume) @override - def mute_volume(self, mute: bool) -> None: + async def async_mute_volume(self, mute: bool) -> None: """Send mute command.""" self._receiver.mute_enabled = mute @override - def select_sound_mode(self, sound_mode: str) -> None: + async def async_select_sound_mode(self, sound_mode: str) -> None: """Select sound mode.""" self._receiver.sound_mode = sound_mode @override - def select_source(self, source: str) -> None: + async def async_select_source(self, source: str) -> None: """Select input source.""" self._receiver.source = source From 8f956e93dd92ad66f976e8b0610a0d3a3a33d1d3 Mon Sep 17 00:00:00 2001 From: Christophe Gagnier Date: Tue, 18 Aug 2026 02:43:53 -0400 Subject: [PATCH 06/14] Request coordinator refresh after TechnoVE control actions (#179451) Co-authored-by: Moustachauve <2206577+Moustachauve@users.noreply.github.com> --- homeassistant/components/technove/number.py | 1 + homeassistant/components/technove/switch.py | 6 ++-- tests/components/technove/test_number.py | 1 + tests/components/technove/test_switch.py | 37 +++++++++++---------- 4 files changed, 25 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/technove/number.py b/homeassistant/components/technove/number.py index 17edf4704fc913..8d90367014fddd 100644 --- a/homeassistant/components/technove/number.py +++ b/homeassistant/components/technove/number.py @@ -44,6 +44,7 @@ async def _set_max_current( translation_domain=DOMAIN, translation_key="max_current_in_sharing_mode" ) await coordinator.technove.set_max_current(int(value)) + await coordinator.async_request_refresh() NUMBERS = [ diff --git a/homeassistant/components/technove/switch.py b/homeassistant/components/technove/switch.py index 947cd290367d2b..cd442e6a0f6df0 100644 --- a/homeassistant/components/technove/switch.py +++ b/homeassistant/components/technove/switch.py @@ -1,7 +1,7 @@ """Support for TechnoVE switches.""" from collections.abc import Callable, Coroutine -from dataclasses import dataclass, replace +from dataclasses import dataclass from typing import Any, override from technove import Station as TechnoVEStation @@ -29,8 +29,7 @@ async def _set_charging_enabled( translation_key="set_charging_enabled_on_auto_charge", ) await coordinator.technove.set_charging_enabled(enabled=enabled) - coordinator.data.info = replace(coordinator.data.info, is_session_active=enabled) - coordinator.async_set_updated_data(coordinator.data) + await coordinator.async_request_refresh() async def _enable_charging(coordinator: TechnoVEDataUpdateCoordinator) -> None: @@ -45,6 +44,7 @@ async def _set_auto_charge( coordinator: TechnoVEDataUpdateCoordinator, enabled: bool ) -> None: await coordinator.technove.set_auto_charge(enabled=enabled) + await coordinator.async_request_refresh() @dataclass(frozen=True, kw_only=True) diff --git a/tests/components/technove/test_number.py b/tests/components/technove/test_number.py index 850299b5fb0c0f..08cb79ad994f8a 100644 --- a/tests/components/technove/test_number.py +++ b/tests/components/technove/test_number.py @@ -65,6 +65,7 @@ async def test_number_expected_value( assert method_mock.call_count == 1 method_mock.assert_called_with(**called_with_value) + assert mock_technove.update.call_count == 2 @pytest.mark.parametrize( diff --git a/tests/components/technove/test_switch.py b/tests/components/technove/test_switch.py index 54d711ed75fc0f..9d281f8b35e87c 100644 --- a/tests/components/technove/test_switch.py +++ b/tests/components/technove/test_switch.py @@ -46,54 +46,57 @@ async def test_switches( @pytest.mark.parametrize( - ("entity_id", "method", "called_with_on", "called_with_off"), + ("entity_id", "service", "method", "called_with"), [ ( "switch.technove_station_auto_charge", + SERVICE_TURN_ON, "set_auto_charge", {"enabled": True}, + ), + ( + "switch.technove_station_auto_charge", + SERVICE_TURN_OFF, + "set_auto_charge", {"enabled": False}, ), ( "switch.technove_station_charging_enabled", + SERVICE_TURN_ON, "set_charging_enabled", {"enabled": True}, + ), + ( + "switch.technove_station_charging_enabled", + SERVICE_TURN_OFF, + "set_charging_enabled", {"enabled": False}, ), ], ) @pytest.mark.usefixtures("init_integration") -async def test_switch_on_off( +async def test_switch_services( hass: HomeAssistant, mock_technove: MagicMock, entity_id: str, + service: str, method: str, - called_with_on: dict[str, bool | int], - called_with_off: dict[str, bool | int], + called_with: dict[str, bool | int], ) -> None: - """Test on/off services.""" + """Test switch services.""" state = hass.states.get(entity_id) method_mock = getattr(mock_technove, method) await hass.services.async_call( SWITCH_DOMAIN, - SERVICE_TURN_ON, + service, {ATTR_ENTITY_ID: state.entity_id}, blocking=True, ) assert method_mock.call_count == 1 - method_mock.assert_called_with(**called_with_on) - - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: state.entity_id}, - blocking=True, - ) - - assert method_mock.call_count == 2 - method_mock.assert_called_with(**called_with_off) + method_mock.assert_called_with(**called_with) + assert mock_technove.update.call_count == 2 @pytest.mark.parametrize( From d82f2e206662cf1488f3cd842d2c93c1c9b1b2fa Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 18 Aug 2026 09:35:39 +0200 Subject: [PATCH 07/14] Update home-assistant-parameter-type for async_remove_config_entry_device (#179371) --- homeassistant/components/august/__init__.py | 4 +- homeassistant/components/bond/__init__.py | 2 +- homeassistant/components/cast/__init__.py | 2 +- .../components/coolmaster/__init__.py | 2 +- .../devolo_home_control/__init__.py | 4 +- .../components/eheimdigital/__init__.py | 4 +- .../components/enphase_envoy/__init__.py | 4 +- homeassistant/components/fibaro/__init__.py | 4 +- .../components/fjaraskupan/__init__.py | 2 +- homeassistant/components/fritzbox/__init__.py | 4 +- homeassistant/components/fronius/__init__.py | 4 +- homeassistant/components/heos/__init__.py | 5 +- homeassistant/components/hive/__init__.py | 2 +- .../components/homekit_controller/__init__.py | 2 +- homeassistant/components/ibeacon/__init__.py | 4 +- homeassistant/components/isy994/__init__.py | 2 +- homeassistant/components/jellyfin/__init__.py | 4 +- .../components/kitchen_sink/__init__.py | 4 +- homeassistant/components/knx/__init__.py | 4 +- .../components/litterrobot/__init__.py | 4 +- homeassistant/components/lookin/__init__.py | 2 +- .../components/lutron_caseta/__init__.py | 2 +- homeassistant/components/matter/__init__.py | 7 ++- homeassistant/components/miele/__init__.py | 2 +- homeassistant/components/mqtt/__init__.py | 4 +- .../components/music_assistant/__init__.py | 2 +- .../components/mysensors/__init__.py | 7 ++- homeassistant/components/myuplink/__init__.py | 4 +- homeassistant/components/netatmo/__init__.py | 4 +- homeassistant/components/netgear/__init__.py | 7 ++- homeassistant/components/nexia/__init__.py | 2 +- homeassistant/components/nut/__init__.py | 2 +- homeassistant/components/onewire/__init__.py | 4 +- homeassistant/components/openrgb/__init__.py | 4 +- .../components/portainer/__init__.py | 4 +- homeassistant/components/renault/__init__.py | 4 +- homeassistant/components/reolink/__init__.py | 5 +- homeassistant/components/rfxtrx/__init__.py | 2 +- homeassistant/components/ring/__init__.py | 2 +- homeassistant/components/scrape/__init__.py | 2 +- homeassistant/components/sensibo/__init__.py | 4 +- .../components/slimproto/__init__.py | 2 +- homeassistant/components/sonos/__init__.py | 2 +- .../components/squeezebox/__init__.py | 7 ++- .../components/switcher_kis/__init__.py | 4 +- .../components/synology_dsm/__init__.py | 2 +- homeassistant/components/tasmota/__init__.py | 5 +- homeassistant/components/unifi/__init__.py | 7 ++- .../components/unifiprotect/__init__.py | 5 +- .../components/uptime_kuma/__init__.py | 2 +- homeassistant/components/velbus/__init__.py | 2 +- homeassistant/components/vesync/__init__.py | 4 +- .../components/victron_gx/__init__.py | 2 +- homeassistant/components/voip/__init__.py | 2 +- .../components/weatherflow/__init__.py | 4 +- homeassistant/components/yale/__init__.py | 2 +- .../checkers/type_hints/const.py | 2 +- .../components/config/test_device_registry.py | 12 ++-- tests/components/heos/test_init.py | 32 +++++++++++ tests/components/matter/test_init.py | 28 +++++++++ tests/components/mysensors/test_init.py | 30 ++++++++++ tests/components/netgear/test_init.py | 57 +++++++++++++++++++ tests/components/reolink/test_init.py | 34 +++++++++++ tests/components/squeezebox/test_init.py | 14 +++++ tests/components/tasmota/test_init.py | 33 ++++++++++- tests/components/unifi/test_init.py | 30 ++++++++++ tests/components/unifiprotect/test_init.py | 30 ++++++++++ 67 files changed, 414 insertions(+), 83 deletions(-) diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index ccd1cebea592f6..27d7e71bf5341f 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -92,7 +92,9 @@ async def async_setup_august( async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: AugustConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, + config_entry: AugustConfigEntry, + device_entry: dr.AnyDeviceEntry, ) -> bool: """Remove august config entry from a device if its no longer present.""" return not any( diff --git a/homeassistant/components/bond/__init__.py b/homeassistant/components/bond/__init__.py index fa4fd49f86cf78..bc90c5c8161a0e 100644 --- a/homeassistant/components/bond/__init__.py +++ b/homeassistant/components/bond/__init__.py @@ -128,7 +128,7 @@ def _async_remove_old_device_identifiers( async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: BondConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, config_entry: BondConfigEntry, device_entry: dr.AnyDeviceEntry ) -> bool: """Remove bond config entry from a device.""" data = config_entry.runtime_data diff --git a/homeassistant/components/cast/__init__.py b/homeassistant/components/cast/__init__.py index d61b44c8eb9003..044e923a242663 100644 --- a/homeassistant/components/cast/__init__.py +++ b/homeassistant/components/cast/__init__.py @@ -102,7 +102,7 @@ async def async_remove_entry(hass: HomeAssistant, entry: CastConfigEntry) -> Non async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: CastConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, config_entry: CastConfigEntry, device_entry: dr.AnyDeviceEntry ) -> bool: """Remove cast config entry from a device. diff --git a/homeassistant/components/coolmaster/__init__.py b/homeassistant/components/coolmaster/__init__.py index d2dd940a4437a6..99beb8cefe1b65 100644 --- a/homeassistant/components/coolmaster/__init__.py +++ b/homeassistant/components/coolmaster/__init__.py @@ -57,7 +57,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: CoolmasterConfigEntry) async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: CoolmasterConfigEntry, - device_entry: dr.DeviceEntry, + device_entry: dr.AnyDeviceEntry, ) -> bool: """Remove a config entry from a device.""" return not device_entry.identifiers.intersection( diff --git a/homeassistant/components/devolo_home_control/__init__.py b/homeassistant/components/devolo_home_control/__init__.py index bb7d9f962bf11b..04dfee131ff7d4 100644 --- a/homeassistant/components/devolo_home_control/__init__.py +++ b/homeassistant/components/devolo_home_control/__init__.py @@ -15,7 +15,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from .const import DOMAIN, PLATFORMS @@ -91,7 +91,7 @@ async def async_unload_entry( async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: DevoloHomeControlConfigEntry, - device_entry: DeviceEntry, + device_entry: AnyDeviceEntry, ) -> bool: """Remove a config entry from a device.""" return True diff --git a/homeassistant/components/eheimdigital/__init__.py b/homeassistant/components/eheimdigital/__init__.py index 4a1efdd2f65d2a..87f19a1b1ba761 100644 --- a/homeassistant/components/eheimdigital/__init__.py +++ b/homeassistant/components/eheimdigital/__init__.py @@ -7,7 +7,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from .const import DOMAIN from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator @@ -61,7 +61,7 @@ async def async_unload_entry( async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: EheimDigitalConfigEntry, - device_entry: DeviceEntry, + device_entry: AnyDeviceEntry, ) -> bool: """Remove a config entry from a device.""" return not any( diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py index 90eeaf559323a9..d9be4969e12943 100644 --- a/homeassistant/components/enphase_envoy/__init__.py +++ b/homeassistant/components/enphase_envoy/__init__.py @@ -106,7 +106,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) -> async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: EnphaseConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, + config_entry: EnphaseConfigEntry, + device_entry: dr.AnyDeviceEntry, ) -> bool: """Remove an enphase_envoy config entry from a device.""" dev_ids = {dev_id[1] for dev_id in device_entry.identifiers if dev_id[0] == DOMAIN} diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index b57d98b2d6a95a..edd78912cba534 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -22,7 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import DeviceEntry, DeviceInfo +from homeassistant.helpers.device_registry import AnyDeviceEntry, DeviceInfo from homeassistant.util import slugify from .const import CONF_IMPORT_PLUGINS, DOMAIN @@ -353,7 +353,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> b async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: FibaroConfigEntry, device_entry: DeviceEntry + hass: HomeAssistant, config_entry: FibaroConfigEntry, device_entry: AnyDeviceEntry ) -> bool: """Remove a device entry from fibaro integration. diff --git a/homeassistant/components/fjaraskupan/__init__.py b/homeassistant/components/fjaraskupan/__init__.py index bbabd3a4764d0f..c63e65968bf3d0 100644 --- a/homeassistant/components/fjaraskupan/__init__.py +++ b/homeassistant/components/fjaraskupan/__init__.py @@ -154,7 +154,7 @@ async def async_unload_entry( async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: FjaraskupanConfigEntry, - device_entry: dr.DeviceEntry, + device_entry: dr.AnyDeviceEntry, ) -> bool: """Remove a config entry from a device.""" for service_info in async_discovered_service_info(hass, False): diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index 75336ea35e967a..6974b27da3c7ac 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -5,7 +5,7 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.const import EVENT_HOMEASSISTANT_STOP, UnitOfTemperature from homeassistant.core import Event, HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries from .const import DOMAIN, LOGGER, PLATFORMS @@ -66,7 +66,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: FritzboxConfigEntry) -> async def async_remove_config_entry_device( - hass: HomeAssistant, entry: FritzboxConfigEntry, device: DeviceEntry + hass: HomeAssistant, entry: FritzboxConfigEntry, device: AnyDeviceEntry ) -> bool: """Remove Fritzbox config entry from a device.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/fronius/__init__.py b/homeassistant/components/fronius/__init__.py index 287bb2c1d037f3..0c82e398b6cee8 100644 --- a/homeassistant/components/fronius/__init__.py +++ b/homeassistant/components/fronius/__init__.py @@ -66,7 +66,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: FroniusConfigEntry) -> async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: FroniusConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, + config_entry: FroniusConfigEntry, + device_entry: dr.AnyDeviceEntry, ) -> bool: """Remove a config entry from a device.""" return True diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 9a940aa565134e..c2d24e79968dfa 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -72,9 +72,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> boo async def async_remove_config_entry_device( - hass: HomeAssistant, entry: HeosConfigEntry, device: dr.DeviceEntry + hass: HomeAssistant, entry: HeosConfigEntry, device: dr.AnyDeviceEntry ) -> bool: """Remove config entry from device if no longer present.""" + if not isinstance(device, dr.DeviceEntry): + # This integration does not create child devices. + return False return not any( (domain, key) for domain, key in device.identifiers diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py index 7bc513a101677f..f9c4cdd88c7e7f 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -92,7 +92,7 @@ async def async_remove_entry(hass: HomeAssistant, entry: HiveConfigEntry) -> Non async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: HiveConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, config_entry: HiveConfigEntry, device_entry: dr.AnyDeviceEntry ) -> bool: """Remove a config entry from a device.""" return True diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 1fca676571b52f..86d749bcea24e8 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -123,7 +123,7 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.AnyDeviceEntry ) -> bool: """Remove homekit_controller config entry from a device.""" hkid = config_entry.data["AccessoryPairingID"] diff --git a/homeassistant/components/ibeacon/__init__.py b/homeassistant/components/ibeacon/__init__.py index 8440758a0804dc..2ca6625e12c406 100644 --- a/homeassistant/components/ibeacon/__init__.py +++ b/homeassistant/components/ibeacon/__init__.py @@ -3,7 +3,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from .const import DOMAIN, PLATFORMS from .coordinator import IBeaconCoordinator @@ -27,7 +27,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: IBeaconConfigEntry, device_entry: DeviceEntry + hass: HomeAssistant, config_entry: IBeaconConfigEntry, device_entry: AnyDeviceEntry ) -> bool: """Remove iBeacon config entry from a device.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index b7cdb9c6f9252b..fde36f3b930a86 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -248,7 +248,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: IsyConfigEntry, - device_entry: dr.DeviceEntry, + device_entry: dr.AnyDeviceEntry, ) -> bool: """Remove ISY config entry from a device.""" return not device_entry.identifiers.intersection( diff --git a/homeassistant/components/jellyfin/__init__.py b/homeassistant/components/jellyfin/__init__.py index 6f9ed10d5bab85..80425b32862d58 100644 --- a/homeassistant/components/jellyfin/__init__.py +++ b/homeassistant/components/jellyfin/__init__.py @@ -81,7 +81,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: JellyfinConfigEntry) -> async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: JellyfinConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, + config_entry: JellyfinConfigEntry, + device_entry: dr.AnyDeviceEntry, ) -> bool: """Remove device from a config entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 7f6c928e5616e6..9781f60c2a70c1 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -34,7 +34,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from homeassistant.helpers.issue_registry import ( IssueSeverity, async_create_issue, @@ -142,7 +142,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: AnyDeviceEntry ) -> bool: """Remove a config entry from a device.""" diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index bec555e7e6d2ac..f2b85cb97f0a1b 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import ConfigType @@ -278,7 +278,7 @@ def remove_files(storage_dir: Path, knxkeys_filename: str | None) -> None: async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: AnyDeviceEntry ) -> bool: """Remove a config entry from a device.""" knx_module = hass.data[KNX_MODULE_KEY] diff --git a/homeassistant/components/litterrobot/__init__.py b/homeassistant/components/litterrobot/__init__.py index aa11441668988a..03435b3001919b 100644 --- a/homeassistant/components/litterrobot/__init__.py +++ b/homeassistant/components/litterrobot/__init__.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from homeassistant.helpers.typing import ConfigType from .const import DOMAIN @@ -97,7 +97,7 @@ async def async_unload_entry( async def async_remove_config_entry_device( - hass: HomeAssistant, entry: LitterRobotConfigEntry, device_entry: DeviceEntry + hass: HomeAssistant, entry: LitterRobotConfigEntry, device_entry: AnyDeviceEntry ) -> bool: """Remove a config entry from a device.""" return not any( diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py index 8594a0b20ca515..a7296fee15c805 100644 --- a/homeassistant/components/lookin/__init__.py +++ b/homeassistant/components/lookin/__init__.py @@ -206,7 +206,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: LookinConfigEntry) -> b async def async_remove_config_entry_device( - hass: HomeAssistant, entry: LookinConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, entry: LookinConfigEntry, device_entry: dr.AnyDeviceEntry ) -> bool: """Remove lookin config entry from a device.""" data = entry.runtime_data diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index 2ad7a7c17264a6..fb996fe6001485 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -522,7 +522,7 @@ def _id_to_identifier(lutron_id: str) -> tuple[str, str]: async def async_remove_config_entry_device( - hass: HomeAssistant, entry: LutronCasetaConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, entry: LutronCasetaConfigEntry, device_entry: dr.AnyDeviceEntry ) -> bool: """Remove lutron_caseta config entry from a device.""" data = entry.runtime_data diff --git a/homeassistant/components/matter/__init__.py b/homeassistant/components/matter/__init__.py index 304b65315bfddf..ee1819509a0454 100644 --- a/homeassistant/components/matter/__init__.py +++ b/homeassistant/components/matter/__init__.py @@ -398,9 +398,14 @@ def _remove_via_devices( async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: MatterConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, + config_entry: MatterConfigEntry, + device_entry: dr.AnyDeviceEntry, ) -> bool: """Remove a config entry from a device.""" + if not isinstance(device_entry, dr.DeviceEntry): + # This integration does not create child devices. + return False node = get_node_from_device_entry(hass, device_entry) if node is None: diff --git a/homeassistant/components/miele/__init__.py b/homeassistant/components/miele/__init__.py index 029cd601562d57..e2740308f6556f 100644 --- a/homeassistant/components/miele/__init__.py +++ b/homeassistant/components/miele/__init__.py @@ -105,7 +105,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: MieleConfigEntry) -> bo async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: MieleConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, config_entry: MieleConfigEntry, device_entry: dr.AnyDeviceEntry ) -> bool: """Remove a config entry from a device.""" return not any( diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index ec7fe933bf2a76..7d5cea73e0307e 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -30,7 +30,7 @@ event as ev, issue_registry as ir, ) -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -689,7 +689,7 @@ def is_connected(hass: HomeAssistant) -> bool: async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: AnyDeviceEntry ) -> bool: """Remove MQTT config entry from a device.""" from . import device_automation # noqa: PLC0415 diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index 788d05f9745425..c23d5c59ed86be 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -294,7 +294,7 @@ async def async_unload_entry( async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: MusicAssistantConfigEntry, - device_entry: dr.DeviceEntry, + device_entry: dr.AnyDeviceEntry, ) -> bool: """Remove a config entry from a device.""" player_id = next( diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index e713ff60645461..e9c17bf5a286ea 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry, DeviceEntry from .const import ( ATTR_DEVICES, @@ -68,9 +68,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: AnyDeviceEntry ) -> bool: """Remove a MySensors config entry from a device.""" + if not isinstance(device_entry, DeviceEntry): + # This integration does not create child devices. + return False gateway: BaseAsyncGateway = hass.data[DOMAIN][MYSENSORS_GATEWAYS][ config_entry.entry_id ] diff --git a/homeassistant/components/myuplink/__init__.py b/homeassistant/components/myuplink/__init__.py index d579c6a971e750..7eac3776ce1d18 100644 --- a/homeassistant/components/myuplink/__init__.py +++ b/homeassistant/components/myuplink/__init__.py @@ -17,7 +17,7 @@ OAuth2Session, async_get_config_entry_implementation, ) -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from .api import AsyncConfigEntryAuth from .const import DOMAIN, OAUTH2_SCOPES @@ -119,7 +119,7 @@ def create_devices( async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: MyUplinkConfigEntry, device_entry: DeviceEntry + hass: HomeAssistant, config_entry: MyUplinkConfigEntry, device_entry: AnyDeviceEntry ) -> bool: """Remove myuplink config entry from a device.""" diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index 387e35d645f97f..187ae43b819dcb 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -22,7 +22,7 @@ OAuth2Session, async_get_config_entry_implementation, ) -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later from homeassistant.helpers.start import async_at_started @@ -147,7 +147,7 @@ async def async_remove_entry(hass: HomeAssistant, entry: NetatmoConfigEntry) -> async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: NetatmoConfigEntry, device_entry: DeviceEntry + hass: HomeAssistant, config_entry: NetatmoConfigEntry, device_entry: AnyDeviceEntry ) -> bool: """Remove a config entry from a device.""" homes = config_entry.runtime_data.account.homes.values() diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index 4f59f85b237235..fa722c2e7a3cf4 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -112,9 +112,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: NetgearConfigEntry) -> async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: NetgearConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, + config_entry: NetgearConfigEntry, + device_entry: dr.AnyDeviceEntry, ) -> bool: """Remove a device from a config entry.""" + if not isinstance(device_entry, dr.DeviceEntry): + # This integration does not create child devices. + return False router = config_entry.runtime_data.router device_mac = None diff --git a/homeassistant/components/nexia/__init__.py b/homeassistant/components/nexia/__init__.py index ace24d65d8bc3d..873fccf57d02e1 100644 --- a/homeassistant/components/nexia/__init__.py +++ b/homeassistant/components/nexia/__init__.py @@ -82,7 +82,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: NexiaConfigEntry) -> bo async def async_remove_config_entry_device( - hass: HomeAssistant, entry: NexiaConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, entry: NexiaConfigEntry, device_entry: dr.AnyDeviceEntry ) -> bool: """Remove a nexia config entry from a device.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index cea069e8501f99..04d85a10aeea63 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -168,7 +168,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: NutConfigEntry, - device_entry: dr.DeviceEntry, + device_entry: dr.AnyDeviceEntry, ) -> bool: """Remove NUT config entry from a device.""" return not any( diff --git a/homeassistant/components/onewire/__init__.py b/homeassistant/components/onewire/__init__.py index 72c520d5a7f88c..73d1205f1245a7 100644 --- a/homeassistant/components/onewire/__init__.py +++ b/homeassistant/components/onewire/__init__.py @@ -43,7 +43,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneWireConfigEntry) -> b async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: OneWireConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, + config_entry: OneWireConfigEntry, + device_entry: dr.AnyDeviceEntry, ) -> bool: """Remove a config entry from a device.""" onewire_hub = config_entry.runtime_data diff --git a/homeassistant/components/openrgb/__init__.py b/homeassistant/components/openrgb/__init__.py index 82eb9fafe8d635..0a22251269dd07 100644 --- a/homeassistant/components/openrgb/__init__.py +++ b/homeassistant/components/openrgb/__init__.py @@ -3,7 +3,7 @@ from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from .const import DOMAIN from .coordinator import OpenRGBConfigEntry, OpenRGBCoordinator @@ -51,7 +51,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: OpenRGBConfigEntry) -> async def async_remove_config_entry_device( - hass: HomeAssistant, entry: OpenRGBConfigEntry, device_entry: DeviceEntry + hass: HomeAssistant, entry: OpenRGBConfigEntry, device_entry: AnyDeviceEntry ) -> bool: """Allows removal of device if it is no longer connected.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/portainer/__init__.py b/homeassistant/components/portainer/__init__.py index e5dc60f45295c1..10fca41d7654d9 100644 --- a/homeassistant/components/portainer/__init__.py +++ b/homeassistant/components/portainer/__init__.py @@ -21,7 +21,7 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession import homeassistant.helpers.config_validation as cv import homeassistant.helpers.device_registry as dr -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry import homeassistant.helpers.entity_registry as er from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType @@ -229,7 +229,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: PortainerConfigEntry) async def async_remove_config_entry_device( hass: HomeAssistant, entry: PortainerConfigEntry, - device: DeviceEntry, + device: AnyDeviceEntry, ) -> bool: """Remove a config entry from a device.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/renault/__init__.py b/homeassistant/components/renault/__init__.py index c8f30aab7a6c4f..34c7ca49e9be50 100644 --- a/homeassistant/components/renault/__init__.py +++ b/homeassistant/components/renault/__init__.py @@ -51,7 +51,9 @@ async def async_unload_entry( async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: RenaultConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, + config_entry: RenaultConfigEntry, + device_entry: dr.AnyDeviceEntry, ) -> bool: """Remove a config entry from a device.""" return not device_entry.identifiers.intersection( diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index c0ed34cfaf444d..58c5f251af7aed 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -348,9 +348,12 @@ async def async_remove_entry( async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ReolinkConfigEntry, device: dr.DeviceEntry + hass: HomeAssistant, config_entry: ReolinkConfigEntry, device: dr.AnyDeviceEntry ) -> bool: """Remove a device from a config entry.""" + if not isinstance(device, dr.DeviceEntry): + # This integration does not create child devices. + return False host: ReolinkHost = config_entry.runtime_data.host (_device_uid, ch, is_chime) = get_device_uid_and_ch(device, host) diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index 051848885366f3..d51bf3e75c40e3 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -447,7 +447,7 @@ def get_device_tuple_from_identifiers( async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.AnyDeviceEntry ) -> bool: """Remove config entry from a device. diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index 89d28dcc4c507c..7205481a6000a6 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -85,7 +85,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: RingConfigEntry) -> boo async def async_remove_config_entry_device( - hass: HomeAssistant, entry: RingConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, entry: RingConfigEntry, device_entry: dr.AnyDeviceEntry ) -> bool: """Remove a config entry from a device.""" return True diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py index 1fb19d2663ca4a..4accb581987ae8 100644 --- a/homeassistant/components/scrape/__init__.py +++ b/homeassistant/components/scrape/__init__.py @@ -299,7 +299,7 @@ async def update_listener(hass: HomeAssistant, entry: ScrapeConfigEntry) -> None async def async_remove_config_entry_device( - hass: HomeAssistant, entry: ConfigEntry, device: dr.DeviceEntry + hass: HomeAssistant, entry: ConfigEntry, device: dr.AnyDeviceEntry ) -> bool: """Remove Scrape config entry from a device.""" entity_registry = er.async_get(hass) diff --git a/homeassistant/components/sensibo/__init__.py b/homeassistant/components/sensibo/__init__.py index de2653a29bad62..a975132d537fef 100644 --- a/homeassistant/components/sensibo/__init__.py +++ b/homeassistant/components/sensibo/__init__.py @@ -11,7 +11,7 @@ device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, LOGGER, PLATFORMS @@ -88,7 +88,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: SensiboConfigEntry) -> async def async_remove_config_entry_device( - hass: HomeAssistant, entry: SensiboConfigEntry, device: DeviceEntry + hass: HomeAssistant, entry: SensiboConfigEntry, device: AnyDeviceEntry ) -> bool: """Remove Sensibo config entry from a device.""" entity_registry = er.async_get(hass) diff --git a/homeassistant/components/slimproto/__init__.py b/homeassistant/components/slimproto/__init__.py index 66813772e5a7f3..14aa54d444e7ae 100644 --- a/homeassistant/components/slimproto/__init__.py +++ b/homeassistant/components/slimproto/__init__.py @@ -37,7 +37,7 @@ async def on_hass_stop(event: Event) -> None: async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: SlimProtoConfigEntry, - device_entry: dr.DeviceEntry, + device_entry: dr.AnyDeviceEntry, ) -> bool: """Remove a config entry from a device.""" return True diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index de9f0b6b14e0f6..e17f7c8abf2e2e 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -724,7 +724,7 @@ async def setup_platforms_and_discovery(self) -> None: async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: SonosConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, config_entry: SonosConfigEntry, device_entry: dr.AnyDeviceEntry ) -> bool: """Remove Sonos config entry from a device.""" known_devices = config_entry.runtime_data.discovered.keys() diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index 10348ab32a5f5a..b25f3874ee8e4c 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -28,7 +28,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, - DeviceEntry, + AnyDeviceEntry, DeviceEntryType, ) from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -272,9 +272,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: SqueezeboxConfigEntry, - device_entry: DeviceEntry, + device_entry: AnyDeviceEntry, ) -> bool: """Allow removal of a Squeezebox player only if its coordinator is unavailable.""" + if not isinstance(device_entry, dr.DeviceEntry): + # This integration does not create child devices. + return False if device_entry.entry_type is DeviceEntryType.SERVICE: raise HomeAssistantError( f"Cannot remove Lyrion Music Server '{device_entry.name}' directly. " diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py index 52618bc834e2be..68daa96a61f724 100644 --- a/homeassistant/components/switcher_kis/__init__.py +++ b/homeassistant/components/switcher_kis/__init__.py @@ -96,7 +96,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: SwitcherConfigEntry) -> async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: SwitcherConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, + config_entry: SwitcherConfigEntry, + device_entry: dr.AnyDeviceEntry, ) -> bool: """Remove a config entry from a device.""" return not device_entry.identifiers.intersection( diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 2658325a881146..6bf957745ceb9a 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -204,7 +204,7 @@ async def async_unload_entry( async def async_remove_config_entry_device( - hass: HomeAssistant, entry: SynologyDSMConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, entry: SynologyDSMConfigEntry, device_entry: dr.AnyDeviceEntry ) -> bool: """Remove synology_dsm config entry from a device.""" data = entry.runtime_data diff --git a/homeassistant/components/tasmota/__init__.py b/homeassistant/components/tasmota/__init__.py index 9e6153c996f4d7..1ff8f78f2cc163 100644 --- a/homeassistant/components/tasmota/__init__.py +++ b/homeassistant/components/tasmota/__init__.py @@ -160,9 +160,12 @@ async def async_setup_device( async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.AnyDeviceEntry ) -> bool: """Remove Tasmota config entry from a device.""" + if not isinstance(device_entry, dr.DeviceEntry): + # This integration does not create child devices. + return False connections = device_entry.connections macs = [c[1] for c in connections if c[0] == CONNECTION_NETWORK_MAC] diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index 042da2b61c1558..96b03acbd898a1 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType @@ -84,9 +84,12 @@ async def async_unload_entry( async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: UnifiConfigEntry, device_entry: DeviceEntry + hass: HomeAssistant, config_entry: UnifiConfigEntry, device_entry: AnyDeviceEntry ) -> bool: """Remove config entry from a device.""" + if not isinstance(device_entry, dr.DeviceEntry): + # This integration does not create child devices. + return False hub = config_entry.runtime_data return not any( identifier in hub.api.devices for _, identifier in device_entry.connections diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index 1c1c40c5f0730e..d428b57763091d 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -237,9 +237,12 @@ async def async_remove_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> None async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: UFPConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, config_entry: UFPConfigEntry, device_entry: dr.AnyDeviceEntry ) -> bool: """Remove ufp config entry from a device.""" + if not isinstance(device_entry, dr.DeviceEntry): + # This integration does not create child devices. + return False unifi_macs = { _async_unifi_mac_from_hass(connection[1]) for connection in device_entry.connections diff --git a/homeassistant/components/uptime_kuma/__init__.py b/homeassistant/components/uptime_kuma/__init__.py index 270490c9726cfe..95726dfe8f06fd 100644 --- a/homeassistant/components/uptime_kuma/__init__.py +++ b/homeassistant/components/uptime_kuma/__init__.py @@ -43,7 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: UptimeKumaConfigEntry) - async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: UptimeKumaConfigEntry, - device_entry: dr.DeviceEntry, + device_entry: dr.AnyDeviceEntry, ) -> bool: """Remove a stale device from a config entry.""" diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index 5cc742925995dc..56dcd2282c576a 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -136,7 +136,7 @@ async def async_remove_entry(hass: HomeAssistant, entry: VelbusConfigEntry) -> N async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: VelbusConfigEntry, - device_entry: dr.DeviceEntry, + device_entry: dr.AnyDeviceEntry, ) -> bool: """Allow removing a Velbus device and its sub-devices. diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index 511e5a458de466..aded01fad26d01 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -14,7 +14,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from homeassistant.helpers.typing import ConfigType from .const import DOMAIN @@ -136,7 +136,7 @@ async def async_migrate_entry( async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: VesyncConfigEntry, device_entry: DeviceEntry + hass: HomeAssistant, config_entry: VesyncConfigEntry, device_entry: AnyDeviceEntry ) -> bool: """Remove a config entry from a device.""" manager = config_entry.runtime_data.manager diff --git a/homeassistant/components/victron_gx/__init__.py b/homeassistant/components/victron_gx/__init__.py index d4b33a081c2bc2..18d51a8e301d0f 100644 --- a/homeassistant/components/victron_gx/__init__.py +++ b/homeassistant/components/victron_gx/__init__.py @@ -70,7 +70,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: VictronGxConfigEntry) - async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: VictronGxConfigEntry, - device_entry: dr.DeviceEntry, + device_entry: dr.AnyDeviceEntry, ) -> bool: """Remove a device from the config entry if the device is no longer known.""" hub: Hub = config_entry.runtime_data diff --git a/homeassistant/components/voip/__init__.py b/homeassistant/components/voip/__init__.py index aaf9b7033dfc07..0443b1b7e6a70c 100644 --- a/homeassistant/components/voip/__init__.py +++ b/homeassistant/components/voip/__init__.py @@ -123,7 +123,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: VoipConfigEntry) -> boo async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.AnyDeviceEntry ) -> bool: """Remove device from a config entry.""" return True diff --git a/homeassistant/components/weatherflow/__init__.py b/homeassistant/components/weatherflow/__init__.py index c4c52d2f6796c1..c4930200880b2c 100644 --- a/homeassistant/components/weatherflow/__init__.py +++ b/homeassistant/components/weatherflow/__init__.py @@ -8,7 +8,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import AnyDeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.start import async_at_started @@ -83,7 +83,7 @@ async def async_unload_entry( async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: WeatherFlowConfigEntry, - device_entry: DeviceEntry, + device_entry: AnyDeviceEntry, ) -> bool: """Remove a config entry from a device.""" client = config_entry.runtime_data diff --git a/homeassistant/components/yale/__init__.py b/homeassistant/components/yale/__init__.py index 364de2390b841a..49bc3850c24c45 100644 --- a/homeassistant/components/yale/__init__.py +++ b/homeassistant/components/yale/__init__.py @@ -84,7 +84,7 @@ async def async_setup_yale( async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: YaleConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, config_entry: YaleConfigEntry, device_entry: dr.AnyDeviceEntry ) -> bool: """Remove yale config entry from a device if its no longer present.""" return not any( diff --git a/pylint/plugins/pylint_home_assistant/checkers/type_hints/const.py b/pylint/plugins/pylint_home_assistant/checkers/type_hints/const.py index f7d9a353e60929..9a83ff33379c1b 100644 --- a/pylint/plugins/pylint_home_assistant/checkers/type_hints/const.py +++ b/pylint/plugins/pylint_home_assistant/checkers/type_hints/const.py @@ -142,7 +142,7 @@ arg_types={ 0: "HomeAssistant", 1: "ConfigEntry", - 2: "DeviceEntry", + 2: "AnyDeviceEntry", }, return_type="bool", mandatory=True, diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index 21f0fa92837b4a..02040d1b49ea34 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -435,7 +435,7 @@ async def test_remove_device( can_remove = False async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.AnyDeviceEntry ) -> bool: return can_remove @@ -521,7 +521,7 @@ async def test_remove_device_fails( ws_client = await hass_ws_client(hass) async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.AnyDeviceEntry ) -> bool: return True @@ -626,7 +626,7 @@ async def test_remove_device_if_integration_removes( can_remove = False async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.AnyDeviceEntry ) -> bool: if can_remove: device_registry.async_remove_device(device_entry.id) @@ -759,7 +759,7 @@ async def test_remove_config_entry_from_device_deprecated_config_entry_mismatch( ws_client = await hass_ws_client(hass) async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.AnyDeviceEntry ) -> bool: return True @@ -1070,7 +1070,7 @@ async def test_remove_config_entry_from_child_device( async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: ConfigEntry, - device_entry: dr.DeviceEntry | dr.ChildDeviceEntry, + device_entry: dr.AnyDeviceEntry, ) -> bool: removed_devices.append(device_entry.id) return can_remove @@ -1117,7 +1117,7 @@ async def test_remove_config_entry_from_parent_with_children( async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: ConfigEntry, - device_entry: dr.DeviceEntry | dr.ChildDeviceEntry, + device_entry: dr.AnyDeviceEntry, ) -> bool: consulted_devices.append(device_entry.id) return True diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index 9644419b095880..9660451faffcdf 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -200,6 +200,38 @@ async def test_device_info( assert device.model == "Speaker" +async def test_remove_config_entry_device_rejects_child_device( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + controller: MockHeos, +) -> None: + """Test removing an unexpected child device is rejected.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert await async_setup_component(hass, "config", {}) + + parent_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "1"), config_entry.entry_id + ) + assert parent_device is not None + child_device = device_registry.async_get_or_create_child( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "test_child_device")}, + parent_device_id=parent_device.id, + ) + + client = await hass_ws_client(hass) + response = await client.remove_device(child_device.id) + assert not response["success"] + assert ( + response["error"]["message"] + == "Failed to remove device entry, rejected by integration" + ) + assert device_registry.async_get(child_device.id) + + async def test_device_id_migration( hass: HomeAssistant, device_registry: dr.DeviceRegistry, diff --git a/tests/components/matter/test_init.py b/tests/components/matter/test_init.py index 01efdcbd8d5ac1..d235673776ae21 100644 --- a/tests/components/matter/test_init.py +++ b/tests/components/matter/test_init.py @@ -960,6 +960,34 @@ async def test_remove_config_entry_device_no_node( assert not device_registry.async_get(device_entry.id) +async def test_remove_config_entry_device_rejects_child_device( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, + integration: MockConfigEntry, +) -> None: + """Test that removing an unexpected child device is rejected.""" + assert await async_setup_component(hass, "config", {}) + parent_device = device_registry.async_get_or_create( + config_entry_id=integration.entry_id, + identifiers={(DOMAIN, "test_parent_device")}, + ) + child_device = device_registry.async_get_or_create_child( + config_entry_id=integration.entry_id, + identifiers={(DOMAIN, "test_child_device")}, + parent_device_id=parent_device.id, + ) + + client = await hass_ws_client(hass) + response = await client.remove_device(child_device.id) + assert not response["success"] + assert ( + response["error"]["message"] + == "Failed to remove device entry, rejected by integration" + ) + assert device_registry.async_get(child_device.id) + + @pytest.mark.parametrize( ("matter_ws_url", "expected"), [ diff --git a/tests/components/mysensors/test_init.py b/tests/components/mysensors/test_init.py index 2a53e99ff6fe2e..5f1b5889aac2f9 100644 --- a/tests/components/mysensors/test_init.py +++ b/tests/components/mysensors/test_init.py @@ -99,3 +99,33 @@ async def test_remove_config_entry_device( ) assert not entity_registry.async_get(entity_id) assert not hass.states.get(entity_id) + + +async def test_remove_config_entry_device_rejects_child_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + integration: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test that removing an unexpected child device is rejected.""" + config_entry = integration + assert await async_setup_component(hass, "config", {}) + + parent_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "test_parent_device")}, + ) + child_device = device_registry.async_get_or_create_child( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "test_child_device")}, + parent_device_id=parent_device.id, + ) + + client = await hass_ws_client(hass) + response = await client.remove_device(child_device.id) + assert not response["success"] + assert ( + response["error"]["message"] + == "Failed to remove device entry, rejected by integration" + ) + assert device_registry.async_get(child_device.id) diff --git a/tests/components/netgear/test_init.py b/tests/components/netgear/test_init.py index 5c11b020466625..1731309cd0542b 100644 --- a/tests/components/netgear/test_init.py +++ b/tests/components/netgear/test_init.py @@ -14,8 +14,10 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator SERIAL = "5ER1AL0000001" HOST = "10.0.0.1" @@ -87,3 +89,58 @@ async def test_tracked_device_links_to_router( ) assert tracked_device is not None assert tracked_device.via_device_id == router_device.id + + +async def test_remove_config_entry_device_rejects_child_device( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, +) -> None: + """Test removing an unexpected child device is rejected.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: 80, + CONF_SSL: False, + CONF_USERNAME: "admin", + CONF_PASSWORD: "password", + }, + unique_id=SERIAL, + ) + entry.add_to_hass(hass) + + with patch("homeassistant.components.netgear.router.Netgear") as netgear_mock: + api = netgear_mock.return_value + api.login_try_port = Mock(return_value=True) + api.get_info = Mock(return_value=ROUTER_INFOS) + api.port = 80 + api.ssl = False + api.get_attached_devices_2 = Mock(return_value=[TRACKED_DEVICE]) + api.get_traffic_meter = Mock(return_value=None) + api.get_new_speed_test_result = Mock(return_value=None) + api.check_new_firmware = Mock(return_value=None) + api.get_system_info = Mock(return_value=None) + api.check_ethernet_link = Mock(return_value=None) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert await async_setup_component(hass, "config", {}) + router_device = device_registry.async_get_device_by_identifier( + (DOMAIN, SERIAL), entry.entry_id + ) + child_device = device_registry.async_get_or_create_child( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "test_child_device")}, + parent_device_id=router_device.id, + ) + + client = await hass_ws_client(hass) + response = await client.remove_device(child_device.id) + assert not response["success"] + assert ( + response["error"]["message"] + == "Failed to remove device entry, rejected by integration" + ) + assert device_registry.async_get(child_device.id) diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index c5cf556a74393f..16c38e34746c31 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -339,6 +339,40 @@ async def test_remove_chime(*args, **key_args): assert sorted(device_models) == sorted(expected_models) +async def test_remove_config_entry_device_rejects_child_device( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + config_entry: MockConfigEntry, + reolink_host: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test removing an unexpected child device is rejected.""" + reolink_host.channels = [0] + assert await async_setup_component(hass, "config", {}) + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + parent_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "test_parent_device")}, + ) + child_device = device_registry.async_get_or_create_child( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "test_child_device")}, + parent_device_id=parent_device.id, + ) + + client = await hass_ws_client(hass) + response = await client.remove_device(child_device.id) + assert not response["success"] + assert ( + response["error"]["message"] + == "Failed to remove device entry, rejected by integration" + ) + assert device_registry.async_get(child_device.id) + + async def test_via_device_id_chain( hass: HomeAssistant, config_entry: MockConfigEntry, diff --git a/tests/components/squeezebox/test_init.py b/tests/components/squeezebox/test_init.py index a9de13620ca5ce..e5150d84d9d2b9 100644 --- a/tests/components/squeezebox/test_init.py +++ b/tests/components/squeezebox/test_init.py @@ -236,3 +236,17 @@ async def test_remove_device_allowed_stale_player( ) result = await async_remove_config_entry_device(hass, entry, device) assert result is True + + +async def test_remove_device_rejects_child_device( + hass: HomeAssistant, + setup_squeezebox: MockConfigEntry, +) -> None: + """Test that removal is rejected for an unexpected child device.""" + entry = setup_squeezebox + child_device = dr.ChildDeviceEntry( + config_entry_id=entry.entry_id, + parent_device_id="mock-parent-device", + ) + result = await async_remove_config_entry_device(hass, entry, child_device) + assert result is False diff --git a/tests/components/tasmota/test_init.py b/tests/components/tasmota/test_init.py index ad367896de9a0e..00ccb9257f8feb 100644 --- a/tests/components/tasmota/test_init.py +++ b/tests/components/tasmota/test_init.py @@ -4,6 +4,8 @@ import json from unittest.mock import call +import pytest + from homeassistant.components.tasmota.const import DEFAULT_PREFIX, DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -92,7 +94,7 @@ async def test_device_remove_non_tasmota_device( assert await async_setup_component(hass, "config", {}) async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.AnyDeviceEntry ) -> bool: return True @@ -193,3 +195,32 @@ async def test_tasmota_ws_remove_discovered_device( ) is None ) + + +@pytest.mark.usefixtures("mqtt_mock", "setup_tasmota") +async def test_remove_config_entry_device_rejects_child_device( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, +) -> None: + """Test removing an unexpected child device is rejected.""" + assert await async_setup_component(hass, "config", {}) + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + parent_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "test_parent_device")}, + ) + child_device = device_registry.async_get_or_create_child( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "test_child_device")}, + parent_device_id=parent_device.id, + ) + + client = await hass_ws_client(hass) + response = await client.remove_device(child_device.id) + assert not response["success"] + assert ( + response["error"]["message"] + == "Failed to remove device entry, rejected by integration" + ) + assert device_registry.async_get(child_device.id) diff --git a/tests/components/unifi/test_init.py b/tests/components/unifi/test_init.py index cc85a769eff5e7..4b548bd3196135 100644 --- a/tests/components/unifi/test_init.py +++ b/tests/components/unifi/test_init.py @@ -12,6 +12,7 @@ CONF_ALLOW_UPTIME_SENSORS, CONF_TRACK_CLIENTS, CONF_TRACK_DEVICES, + DOMAIN, ) from homeassistant.components.unifi.errors import AuthenticationRequired, CannotConnect from homeassistant.config_entries import ConfigEntryState @@ -211,3 +212,32 @@ async def test_remove_config_entry_device( assert not device_registry.async_get_device_by_connection( (dr.CONNECTION_NETWORK_MAC, client_payload[1]["mac"]), config_entry.entry_id ) + + +async def test_remove_config_entry_device_rejects_child_device( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, + config_entry_factory: ConfigEntryFactoryType, +) -> None: + """Test removing an unexpected child device is rejected.""" + config_entry = await config_entry_factory() + assert await async_setup_component(hass, "config", {}) + parent_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "test_parent_device")}, + ) + child_device = device_registry.async_get_or_create_child( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "test_child_device")}, + parent_device_id=parent_device.id, + ) + + client = await hass_ws_client(hass) + response = await client.remove_device(child_device.id) + assert not response["success"] + assert ( + response["error"]["message"] + == "Failed to remove device entry, rejected by integration" + ) + assert device_registry.async_get(child_device.id) diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index 10defbecc32c20..be6e8ee13fabe6 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -423,6 +423,36 @@ async def test_device_remove_devices_nvr( assert not response["success"] +async def test_remove_config_entry_device_rejects_child_device( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, + ufp: MockUFPFixture, + light: Light, +) -> None: + """Test removing an unexpected child device is rejected.""" + await init_entry(hass, ufp, [light]) + assert await async_setup_component(hass, "config", {}) + parent_device = device_registry.async_get_or_create( + config_entry_id=ufp.entry.entry_id, + identifiers={(DOMAIN, "test_parent_device")}, + ) + child_device = device_registry.async_get_or_create_child( + config_entry_id=ufp.entry.entry_id, + identifiers={(DOMAIN, "test_child_device")}, + parent_device_id=parent_device.id, + ) + + client = await hass_ws_client(hass) + response = await client.remove_device(child_device.id) + assert not response["success"] + assert ( + response["error"]["message"] + == "Failed to remove device entry, rejected by integration" + ) + assert device_registry.async_get(child_device.id) + + @pytest.mark.parametrize( ("mock_entries", "expected_result"), [ From df59ac7d63a68e605074cfaac2e86a6ad43d3a15 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 18 Aug 2026 09:36:15 +0200 Subject: [PATCH 08/14] Add device_registry.async_get_device_and_config_entry_for_domain (#178991) Co-authored-by: Martin Hjelmare --- .../components/blue_current/services.py | 32 ++++------ homeassistant/helpers/device_registry.py | 27 ++++++++ tests/helpers/test_device_registry.py | 62 +++++++++++++++++++ 3 files changed, 100 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/blue_current/services.py b/homeassistant/components/blue_current/services.py index 149b1384716a28..937a4dcb6f721d 100644 --- a/homeassistant/components/blue_current/services.py +++ b/homeassistant/components/blue_current/services.py @@ -2,7 +2,7 @@ import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_DEVICE_ID from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError @@ -27,37 +27,27 @@ async def start_charge_session(service_call: ServiceCall) -> None: charging_card_id = service_call.data[CHARGING_CARD_ID] device_id = service_call.data[CONF_DEVICE_ID] - # Get the device based on the given device ID. - device = dr.async_get(service_call.hass).devices.get(device_id) + device, config_entry = dr.async_get_device_and_config_entry_for_domain( + service_call.hass, device_id, domain=DOMAIN + ) if device is None: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_device_id" ) - blue_current_config_entry: ConfigEntry | None = None - - for config_entry_id in device.config_entries: - config_entry = service_call.hass.config_entries.async_get_entry(config_entry_id) - if not config_entry or config_entry.domain != DOMAIN: - # Not the blue_current config entry. - continue - - if config_entry.state is not ConfigEntryState.LOADED: - raise ServiceValidationError( - translation_domain=DOMAIN, translation_key="config_entry_not_loaded" - ) - - blue_current_config_entry = config_entry - break - - if not blue_current_config_entry: + if not config_entry: # The device is not connected to a valid blue_current config entry. raise ServiceValidationError( translation_domain=DOMAIN, translation_key="no_config_entry" ) - connector = blue_current_config_entry.runtime_data + if config_entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="config_entry_not_loaded" + ) + + connector = config_entry.runtime_data # Get the evse_id from the identifier of the device. evse_id = next( diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index ecdb4106347111..73917c3a624bb4 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -4439,6 +4439,33 @@ def async_get_device_id_by_identifier( return device.id +@callback +def async_get_device_and_config_entry_for_domain( + hass: HomeAssistant, device_id: str, *, domain: str +) -> tuple[DeviceEntry | None, ConfigEntry | None]: + """Get the device and the config entry of the domain owning it. + + Returns (None, None) for an unknown device id or if the device is a child + device, and (device, None) when no config entry of the domain owns the + device. A returned pair is consistent: for a pre-migration composite + device id, the device is the domain's split device, not the composite; if + several splits belong to config entries of the domain, which pair is + returned is undefined. When no split matches the domain, the restored + composite is returned as the device. + """ + registry = async_get(hass) + if (device := registry.devices.get(device_id)) is not None: + config_entry = hass.config_entries.async_get_entry(device.config_entry_id) + if config_entry is not None and config_entry.domain == domain: + return device, config_entry + return device, None + for split in registry.async_get_devices_for_composite_device_id(device_id): + config_entry = hass.config_entries.async_get_entry(split.config_entry_id) + if config_entry is not None and config_entry.domain == domain: + return split, config_entry + return registry.async_get(device_id, include_child_devices=False), None + + def async_setup(hass: HomeAssistant) -> None: """Set up device registry.""" if DATA_REGISTRY in hass.data: diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 9cf1cd34e4be6e..e633732f885751 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -9596,6 +9596,68 @@ async def test_get_composite_splits( assert device_registry.devices.get_composite_splits() == {} +async def test_async_get_device_and_config_entry_for_domain( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test getting the device and config entry of a domain owning a device.""" + entry = MockConfigEntry(domain="domain_a") + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("domain_a", "1")} + ) + + assert dr.async_get_device_and_config_entry_for_domain( + hass, device.id, domain="domain_a" + ) == (device, entry) + # A domain not owning the device still gets the device + assert dr.async_get_device_and_config_entry_for_domain( + hass, device.id, domain="domain_b" + ) == (device, None) + # An unknown device id + assert dr.async_get_device_and_config_entry_for_domain( + hass, "unknown_id", domain="domain_a" + ) == (None, None) + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_async_get_device_and_config_entry_for_domain_composite( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test getting the device and config entry via a composite device id.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b) + + dr.async_setup(hass) + await dr.async_load(hass) + device_registry = dr.async_get(hass) + + split_a = _get_device_for_config_entry( + device_registry, entry_a.entry_id, identifiers={("domain_a", "1")} + ) + split_b = _get_device_for_config_entry( + device_registry, entry_b.entry_id, identifiers={("domain_b", "1")} + ) + + # The returned pair is consistent: the domain's split device, not the composite + assert dr.async_get_device_and_config_entry_for_domain( + hass, COMPOSITE_ID, domain="domain_a" + ) == (split_a, entry_a) + assert dr.async_get_device_and_config_entry_for_domain( + hass, COMPOSITE_ID, domain="domain_b" + ) == (split_b, entry_b) + # A domain owning none of the splits gets the restored composite and no entry + device, config_entry = dr.async_get_device_and_config_entry_for_domain( + hass, COMPOSITE_ID, domain="domain_c" + ) + assert config_entry is None + assert device is not None + assert device.id == COMPOSITE_ID + assert device.config_entries == {entry_a.entry_id, entry_b.entry_id} + + @pytest.mark.parametrize("load_registries", [False]) async def test_clear_config_entry_clears_composite_primary_config_entry( hass: HomeAssistant, hass_storage: dict[str, Any] From 7bc91e4df4f40ae2cb7367663e199164db45b860 Mon Sep 17 00:00:00 2001 From: Niklas Date: Tue, 18 Aug 2026 09:41:55 +0200 Subject: [PATCH 09/14] Bump pyswitchbot to 2.4.1 (#179439) --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 1ceb9bb8492f7f..f488c10471026a 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -42,5 +42,5 @@ "iot_class": "local_push", "loggers": ["switchbot"], "quality_scale": "gold", - "requirements": ["PySwitchbot==2.3.0"] + "requirements": ["PySwitchbot==2.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4c47aa81035b0a..40cadd0da205ca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -83,7 +83,7 @@ PyRMVtransport==0.3.3 PySrDaliGateway==0.21.0 # homeassistant.components.switchbot -PySwitchbot==2.3.0 +PySwitchbot==2.4.1 # homeassistant.components.switchmate PySwitchmate==0.5.1 From d7c0297c370673fff5099d7a281e022b942ffde5 Mon Sep 17 00:00:00 2001 From: Alex Fishlock Date: Tue, 18 Aug 2026 08:49:41 +0100 Subject: [PATCH 10/14] Bump lyngdorf to 1.6.0 (#179410) Co-authored-by: Josef Zweck --- homeassistant/components/lyngdorf/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lyngdorf/manifest.json b/homeassistant/components/lyngdorf/manifest.json index 038a918b4d7e5d..ba3aa2baf8e344 100644 --- a/homeassistant/components/lyngdorf/manifest.json +++ b/homeassistant/components/lyngdorf/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["lyngdorf", "async_upnp_client"], "quality_scale": "silver", - "requirements": ["lyngdorf==1.4.9"], + "requirements": ["lyngdorf==1.6.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2", diff --git a/requirements_all.txt b/requirements_all.txt index 40cadd0da205ca..e0a3e296f6224e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1537,7 +1537,7 @@ lw12==0.9.2 lxml==6.1.1 # homeassistant.components.lyngdorf -lyngdorf==1.4.9 +lyngdorf==1.6.0 # homeassistant.components.matrix matrix-nio==0.26.0 From 6a76c3d594196d5fcd8971149d5fccc8559b5de6 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Tue, 18 Aug 2026 11:55:18 +0300 Subject: [PATCH 11/14] Bump matter-python-client to 1.4.0 (#179465) --- homeassistant/components/matter/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index c007c8ee8eeffd..5b26a584cb30a3 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -8,6 +8,6 @@ "documentation": "https://www.home-assistant.io/integrations/matter", "integration_type": "hub", "iot_class": "local_push", - "requirements": ["matter-python-client==1.3.0", "matter-ble-proxy==0.7.1"], + "requirements": ["matter-python-client==1.4.0", "matter-ble-proxy==0.7.1"], "zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index e0a3e296f6224e..18667c582e1fc7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1546,7 +1546,7 @@ matrix-nio==0.26.0 matter-ble-proxy==0.7.1 # homeassistant.components.matter -matter-python-client==1.3.0 +matter-python-client==1.4.0 # homeassistant.components.maxcube maxcube-api==0.4.3 From 22085918142408320cd158e9953b87c4d0e781e0 Mon Sep 17 00:00:00 2001 From: Alex Fishlock Date: Tue, 18 Aug 2026 09:59:49 +0100 Subject: [PATCH 12/14] Add sensor platform to Lyngdorf (#179447) --- homeassistant/components/lyngdorf/const.py | 1 + homeassistant/components/lyngdorf/icons.json | 27 ++ .../components/lyngdorf/quality_scale.yaml | 14 +- homeassistant/components/lyngdorf/sensor.py | 158 +++++++ .../components/lyngdorf/strings.json | 23 + tests/components/lyngdorf/conftest.py | 37 +- .../lyngdorf/snapshots/test_sensor.ambr | 399 ++++++++++++++++++ .../components/lyngdorf/test_media_player.py | 7 + tests/components/lyngdorf/test_sensor.py | 83 ++++ 9 files changed, 738 insertions(+), 11 deletions(-) create mode 100644 homeassistant/components/lyngdorf/icons.json create mode 100644 homeassistant/components/lyngdorf/sensor.py create mode 100644 tests/components/lyngdorf/snapshots/test_sensor.ambr create mode 100644 tests/components/lyngdorf/test_sensor.py diff --git a/homeassistant/components/lyngdorf/const.py b/homeassistant/components/lyngdorf/const.py index d34961be3d5f52..3c019793409c88 100644 --- a/homeassistant/components/lyngdorf/const.py +++ b/homeassistant/components/lyngdorf/const.py @@ -7,5 +7,6 @@ PLATFORMS: list[Platform] = [ Platform.MEDIA_PLAYER, + Platform.SENSOR, ] CONF_SERIAL_NUMBER = "serial_number" diff --git a/homeassistant/components/lyngdorf/icons.json b/homeassistant/components/lyngdorf/icons.json new file mode 100644 index 00000000000000..4120b60053eb49 --- /dev/null +++ b/homeassistant/components/lyngdorf/icons.json @@ -0,0 +1,27 @@ +{ + "entity": { + "sensor": { + "audio_information": { + "default": "mdi:surround-sound" + }, + "audio_input": { + "default": "mdi:audio-input-stereo-minijack" + }, + "streaming_source": { + "default": "mdi:cast-audio" + }, + "video_information": { + "default": "mdi:television" + }, + "video_input": { + "default": "mdi:video-input-hdmi" + }, + "zone_b_audio_input": { + "default": "mdi:audio-input-stereo-minijack" + }, + "zone_b_streaming_source": { + "default": "mdi:cast-audio" + } + } + } +} diff --git a/homeassistant/components/lyngdorf/quality_scale.yaml b/homeassistant/components/lyngdorf/quality_scale.yaml index a9a72c80f4d9a0..215d6c074bbe65 100644 --- a/homeassistant/components/lyngdorf/quality_scale.yaml +++ b/homeassistant/components/lyngdorf/quality_scale.yaml @@ -64,18 +64,16 @@ rules: dynamic-devices: status: exempt comment: Single device per config entry. - entity-category: - status: exempt - comment: Media player entities do not need entity categories. + entity-category: done entity-device-class: done entity-disabled-by-default: - status: exempt - comment: All entities are useful by default. + status: done + comment: >- + All entities are useful by default; the diagnostic sensors change only on + source or content changes. entity-translations: done exception-translations: done - icon-translations: - status: exempt - comment: Media player uses default platform icons. + icon-translations: done reconfiguration-flow: todo repair-issues: status: exempt diff --git a/homeassistant/components/lyngdorf/sensor.py b/homeassistant/components/lyngdorf/sensor.py new file mode 100644 index 00000000000000..45a45c6042fa90 --- /dev/null +++ b/homeassistant/components/lyngdorf/sensor.py @@ -0,0 +1,158 @@ +"""Sensor platform for Lyngdorf integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, override + +from lyngdorf.device import Receiver + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import LyngdorfEntity +from .models import LyngdorfConfigEntry + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class LyngdorfSensorEntityDescription(SensorEntityDescription): + """Describe a Lyngdorf sensor entity.""" + + value_fn: Callable[[Receiver], str | None] + options_fn: Callable[[Receiver], list[str]] | None = None + + +def _known(value: str | None, options: list[str]) -> str | None: + """Return the value only if it is one of the device's known names.""" + return value if value in options else None + + +MAIN_ZONE_SENSORS: tuple[LyngdorfSensorEntityDescription, ...] = ( + LyngdorfSensorEntityDescription( + key="audio_information", + translation_key="audio_information", + value_fn=lambda r: r.audio_information, + entity_category=EntityCategory.DIAGNOSTIC, + ), + LyngdorfSensorEntityDescription( + key="video_information", + translation_key="video_information", + value_fn=lambda r: r.video_information, + entity_category=EntityCategory.DIAGNOSTIC, + ), + LyngdorfSensorEntityDescription( + key="audio_input", + translation_key="audio_input", + device_class=SensorDeviceClass.ENUM, + value_fn=lambda r: _known(r.audio_input, r.available_audio_inputs), + options_fn=lambda r: r.available_audio_inputs, + entity_category=EntityCategory.DIAGNOSTIC, + ), + LyngdorfSensorEntityDescription( + key="video_input", + translation_key="video_input", + device_class=SensorDeviceClass.ENUM, + value_fn=lambda r: _known(r.video_input, r.available_video_inputs), + options_fn=lambda r: r.available_video_inputs, + entity_category=EntityCategory.DIAGNOSTIC, + ), + LyngdorfSensorEntityDescription( + key="streaming_source", + translation_key="streaming_source", + device_class=SensorDeviceClass.ENUM, + value_fn=lambda r: _known(r.streaming_source, r.available_stream_types), + options_fn=lambda r: r.available_stream_types, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + +ZONE_B_SENSORS: tuple[LyngdorfSensorEntityDescription, ...] = ( + LyngdorfSensorEntityDescription( + key="zone_b_audio_input", + translation_key="zone_b_audio_input", + device_class=SensorDeviceClass.ENUM, + value_fn=lambda r: _known(r.zone_b_audio_input, r.available_audio_inputs), + options_fn=lambda r: r.available_audio_inputs, + entity_category=EntityCategory.DIAGNOSTIC, + ), + LyngdorfSensorEntityDescription( + key="zone_b_streaming_source", + translation_key="zone_b_streaming_source", + device_class=SensorDeviceClass.ENUM, + value_fn=lambda r: _known(r.zone_b_streaming_source, r.available_stream_types), + options_fn=lambda r: r.available_stream_types, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: LyngdorfConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Lyngdorf sensors from a config entry.""" + runtime_data = config_entry.runtime_data + + entities: list[LyngdorfSensor] = [ + LyngdorfSensor( + runtime_data.receiver, config_entry, runtime_data.device_info, description + ) + for description in MAIN_ZONE_SENSORS + ] + # Zone B sensors stay on the main device so they read "Zone B audio input" + # rather than repeating the zone in the Zone B device's own name. + if runtime_data.zone_b_device_info is not None: + entities.extend( + LyngdorfSensor( + runtime_data.receiver, + config_entry, + runtime_data.device_info, + description, + ) + for description in ZONE_B_SENSORS + ) + + async_add_entities(entities) + + +class LyngdorfSensor(LyngdorfEntity, SensorEntity): + """Lyngdorf sensor entity.""" + + entity_description: LyngdorfSensorEntityDescription + + def __init__( + self, + receiver: Receiver, + config_entry: LyngdorfConfigEntry, + device_info: DeviceInfo, + description: LyngdorfSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(receiver, device_info) + if TYPE_CHECKING: + assert config_entry.unique_id + self.entity_description = description + self._attr_unique_id = f"{config_entry.unique_id}_{description.key}" + + @override + @property + def options(self) -> list[str] | None: + """Return the device-reported options for enum sensors.""" + if (options_fn := self.entity_description.options_fn) is None: + return None + return options_fn(self._receiver) + + @override + @property + def native_value(self) -> str | None: + """Return the current sensor value.""" + return self.entity_description.value_fn(self._receiver) diff --git a/homeassistant/components/lyngdorf/strings.json b/homeassistant/components/lyngdorf/strings.json index c47d256d32b802..f28652c7fe23f4 100644 --- a/homeassistant/components/lyngdorf/strings.json +++ b/homeassistant/components/lyngdorf/strings.json @@ -40,6 +40,29 @@ "main_zone": { "name": "Main zone" } + }, + "sensor": { + "audio_information": { + "name": "Audio information" + }, + "audio_input": { + "name": "Audio input" + }, + "streaming_source": { + "name": "Streaming source" + }, + "video_information": { + "name": "Video information" + }, + "video_input": { + "name": "Video input" + }, + "zone_b_audio_input": { + "name": "Zone B audio input" + }, + "zone_b_streaming_source": { + "name": "Zone B streaming source" + } } }, "exceptions": { diff --git a/tests/components/lyngdorf/conftest.py b/tests/components/lyngdorf/conftest.py index 1cb548a3d220b3..a0918a8c6e0ce9 100644 --- a/tests/components/lyngdorf/conftest.py +++ b/tests/components/lyngdorf/conftest.py @@ -9,8 +9,12 @@ from lyngdorf.device import Receiver import pytest -from homeassistant.components.lyngdorf.const import CONF_SERIAL_NUMBER, DOMAIN -from homeassistant.const import CONF_HOST, CONF_MODEL +from homeassistant.components.lyngdorf.const import ( + CONF_SERIAL_NUMBER, + DOMAIN, + PLATFORMS, +) +from homeassistant.const import CONF_HOST, CONF_MODEL, Platform from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -67,11 +71,22 @@ def mock_receiver() -> Generator[MagicMock]: receiver.sound_mode = None receiver.available_sound_modes = [] + receiver.audio_information = "Stereo" + receiver.video_information = "4K HDR" + receiver.audio_input = "optical" + receiver.video_input = "hdmi" + receiver.streaming_source = "AirPlay" + receiver.available_audio_inputs = ["optical", "aux"] + receiver.available_video_inputs = ["hdmi"] + receiver.available_stream_types = ["AirPlay", "DLNA"] + receiver.zone_b_power_on = False receiver.zone_b_volume = -40.0 receiver.zone_b_mute_enabled = False receiver.zone_b_source = None receiver.zone_b_available_sources = [] + receiver.zone_b_audio_input = "aux" + receiver.zone_b_streaming_source = "DLNA" create_mock.return_value = receiver yield receiver @@ -97,16 +112,32 @@ def mock_find_receiver_model() -> Generator[AsyncMock]: yield find_mock +def notify_receiver_update(receiver: MagicMock) -> None: + """Fire every notification callback the entities registered.""" + for call in receiver.register_notification_callback.call_args_list: + call.args[0]() + + +@pytest.fixture +def platforms() -> list[Platform]: + """Platforms to load; override per module to isolate a single platform.""" + return list(PLATFORMS) + + @pytest.fixture async def init_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_receiver: MagicMock, + platforms: list[Platform], ) -> MockConfigEntry: """Set up the Lyngdorf integration for testing.""" mock_config_entry.add_to_hass(hass) - with patch("homeassistant.components.lyngdorf.lookup_receiver_model") as lookup: + with ( + patch("homeassistant.components.lyngdorf.lookup_receiver_model") as lookup, + patch("homeassistant.components.lyngdorf.PLATFORMS", platforms), + ): lookup.return_value = LyngdorfModel.MP_60 await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/lyngdorf/snapshots/test_sensor.ambr b/tests/components/lyngdorf/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..dd417716be64c1 --- /dev/null +++ b/tests/components/lyngdorf/snapshots/test_sensor.ambr @@ -0,0 +1,399 @@ +# serializer version: 1 +# name: test_entities[sensor.mock_lyngdorf_audio_information-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_lyngdorf_audio_information', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Audio information', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Audio information', + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'audio_information', + 'unique_id': '0050c27c76b2_audio_information', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.mock_lyngdorf_audio_information-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mock Lyngdorf Audio information', + }), + 'context': , + 'entity_id': 'sensor.mock_lyngdorf_audio_information', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Stereo', + }) +# --- +# name: test_entities[sensor.mock_lyngdorf_audio_input-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'optical', + 'aux', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_lyngdorf_audio_input', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Audio input', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Audio input', + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'audio_input', + 'unique_id': '0050c27c76b2_audio_input', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.mock_lyngdorf_audio_input-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Mock Lyngdorf Audio input', + : list([ + 'optical', + 'aux', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_lyngdorf_audio_input', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'optical', + }) +# --- +# name: test_entities[sensor.mock_lyngdorf_streaming_source-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'AirPlay', + 'DLNA', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_lyngdorf_streaming_source', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Streaming source', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Streaming source', + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'streaming_source', + 'unique_id': '0050c27c76b2_streaming_source', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.mock_lyngdorf_streaming_source-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Mock Lyngdorf Streaming source', + : list([ + 'AirPlay', + 'DLNA', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_lyngdorf_streaming_source', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'AirPlay', + }) +# --- +# name: test_entities[sensor.mock_lyngdorf_video_information-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_lyngdorf_video_information', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Video information', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Video information', + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'video_information', + 'unique_id': '0050c27c76b2_video_information', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.mock_lyngdorf_video_information-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mock Lyngdorf Video information', + }), + 'context': , + 'entity_id': 'sensor.mock_lyngdorf_video_information', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4K HDR', + }) +# --- +# name: test_entities[sensor.mock_lyngdorf_video_input-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'hdmi', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_lyngdorf_video_input', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Video input', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Video input', + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'video_input', + 'unique_id': '0050c27c76b2_video_input', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.mock_lyngdorf_video_input-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Mock Lyngdorf Video input', + : list([ + 'hdmi', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_lyngdorf_video_input', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'hdmi', + }) +# --- +# name: test_entities[sensor.mock_lyngdorf_zone_b_audio_input-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'optical', + 'aux', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_lyngdorf_zone_b_audio_input', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Zone B audio input', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Zone B audio input', + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'zone_b_audio_input', + 'unique_id': '0050c27c76b2_zone_b_audio_input', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.mock_lyngdorf_zone_b_audio_input-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Mock Lyngdorf Zone B audio input', + : list([ + 'optical', + 'aux', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_lyngdorf_zone_b_audio_input', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'aux', + }) +# --- +# name: test_entities[sensor.mock_lyngdorf_zone_b_streaming_source-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'AirPlay', + 'DLNA', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_lyngdorf_zone_b_streaming_source', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Zone B streaming source', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Zone B streaming source', + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'zone_b_streaming_source', + 'unique_id': '0050c27c76b2_zone_b_streaming_source', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.mock_lyngdorf_zone_b_streaming_source-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Mock Lyngdorf Zone B streaming source', + : list([ + 'AirPlay', + 'DLNA', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_lyngdorf_zone_b_streaming_source', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'DLNA', + }) +# --- diff --git a/tests/components/lyngdorf/test_media_player.py b/tests/components/lyngdorf/test_media_player.py index 2915f55483edc1..658db3733ca6a3 100644 --- a/tests/components/lyngdorf/test_media_player.py +++ b/tests/components/lyngdorf/test_media_player.py @@ -27,6 +27,7 @@ SERVICE_VOLUME_SET, SERVICE_VOLUME_UP, STATE_UNAVAILABLE, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -37,6 +38,12 @@ ZONE_B = "media_player.mock_lyngdorf_zone_b" +@pytest.fixture +def platforms() -> list[Platform]: + """Only load the media player platform.""" + return [Platform.MEDIA_PLAYER] + + async def test_entities( hass: HomeAssistant, init_integration: MockConfigEntry, diff --git a/tests/components/lyngdorf/test_sensor.py b/tests/components/lyngdorf/test_sensor.py new file mode 100644 index 00000000000000..1ad2e09b847df9 --- /dev/null +++ b/tests/components/lyngdorf/test_sensor.py @@ -0,0 +1,83 @@ +"""Tests for the Lyngdorf sensor platform.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import notify_receiver_update + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture +def platforms() -> list[Platform]: + """Only load the sensor platform.""" + return [Platform.SENSOR] + + +@pytest.mark.usefixtures("mock_receiver") +async def test_entities( + hass: HomeAssistant, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the sensor entities.""" + await snapshot_platform(hass, entity_registry, snapshot, init_integration.entry_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_sensor_none_values( + hass: HomeAssistant, + mock_receiver: MagicMock, +) -> None: + """Test a sensor shows unknown when the device reports nothing.""" + mock_receiver.audio_information = None + notify_receiver_update(mock_receiver) + await hass.async_block_till_done() + + assert ( + hass.states.get("sensor.mock_lyngdorf_audio_information").state == STATE_UNKNOWN + ) + + +async def test_enum_sensor_ignores_unknown_device_value( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test an input the library could not name is reported as unknown.""" + mock_receiver.available_audio_inputs = ["optical"] + mock_receiver.audio_input = "audio-37" + + notify_receiver_update(mock_receiver) + await hass.async_block_till_done() + + assert hass.states.get("sensor.mock_lyngdorf_audio_input").state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("init_integration") +async def test_enum_options_follow_the_device( + hass: HomeAssistant, + mock_receiver: MagicMock, +) -> None: + """Test enum options track the lists the device reports.""" + mock_receiver.available_audio_inputs = ["HDMI", "optical"] + mock_receiver.audio_input = "HDMI" + notify_receiver_update(mock_receiver) + await hass.async_block_till_done() + + state = hass.states.get("sensor.mock_lyngdorf_audio_input") + assert state.attributes["options"] == ["HDMI", "optical"] + + mock_receiver.available_audio_inputs = ["HDMI", "optical", "ARC"] + notify_receiver_update(mock_receiver) + await hass.async_block_till_done() + + state = hass.states.get("sensor.mock_lyngdorf_audio_input") + assert state.attributes["options"] == ["HDMI", "optical", "ARC"] From 16709afe6f27ed38efe41ee819eaf50cb8bade32 Mon Sep 17 00:00:00 2001 From: wollew Date: Tue, 18 Aug 2026 11:01:32 +0200 Subject: [PATCH 13/14] Fix copy/paste error in comment in demo integration (#179470) --- tests/components/demo/test_cover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/demo/test_cover.py b/tests/components/demo/test_cover.py index ca159292b44a29..74ee6e6b11ee90 100644 --- a/tests/components/demo/test_cover.py +++ b/tests/components/demo/test_cover.py @@ -41,7 +41,7 @@ @pytest.fixture def cover_only() -> Generator[None]: - """Enable only the climate platform.""" + """Enable only the cover platform.""" with patch( "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", [Platform.COVER], From 57e6340737ec4f776edb035efd9abb9d6634ce74 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Tue, 18 Aug 2026 18:02:21 +0900 Subject: [PATCH 14/14] Split user flow init and data in lg_thinq config flow tests (#179468) Co-authored-by: YunseonPark-LGE --- tests/components/lg_thinq/test_config_flow.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/components/lg_thinq/test_config_flow.py b/tests/components/lg_thinq/test_config_flow.py index d68c1efeb55db1..f5d0d84bac8edc 100644 --- a/tests/components/lg_thinq/test_config_flow.py +++ b/tests/components/lg_thinq/test_config_flow.py @@ -55,7 +55,13 @@ async def test_config_flow_invalid_pat( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data={CONF_ACCESS_TOKEN: MOCK_PAT, CONF_COUNTRY: MOCK_COUNTRY}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_ACCESS_TOKEN: MOCK_PAT, CONF_COUNTRY: MOCK_COUNTRY}, ) assert result["type"] is FlowResultType.FORM assert result["errors"] @@ -73,7 +79,13 @@ async def test_config_flow_already_configured( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data={CONF_ACCESS_TOKEN: MOCK_PAT, CONF_COUNTRY: MOCK_COUNTRY}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_ACCESS_TOKEN: MOCK_PAT, CONF_COUNTRY: MOCK_COUNTRY}, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured"