From 314175135fbab619d3c542b2d1022a9f150e8d88 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Mar 2022 19:39:07 +0100 Subject: [PATCH] Hide switch_as_x tracked entity (#67949) * Hide switch_as_x tracked entity * Hide wrapped switch during config flow * Allow setting/getting entity disabled by via WS * Adjust tests * Improve test coverage * Improve tests --- .../components/config/entity_registry.py | 13 ++- .../components/switch_as_x/__init__.py | 17 ++++ .../components/switch_as_x/config_flow.py | 16 +++- homeassistant/helpers/entity_registry.py | 28 ++++++- .../components/config/test_entity_registry.py | 38 ++++++++- .../switch_as_x/test_config_flow.py | 61 ++++++++++++++ tests/components/switch_as_x/test_init.py | 80 +++++++++++++++++++ 7 files changed, 244 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index f5ffc574b86..719e028cab1 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -78,6 +78,14 @@ def websocket_get_entity(hass, connection, msg): er.RegistryEntryDisabler.USER.value, ), ), + # We only allow setting hidden_by user via API. + vol.Optional("hidden_by"): vol.Any( + None, + vol.All( + vol.Coerce(er.RegistryEntryHider), + er.RegistryEntryHider.USER.value, + ), + ), } ) @callback @@ -96,7 +104,7 @@ def websocket_update_entity(hass, connection, msg): changes = {} - for key in ("area_id", "device_class", "disabled_by", "icon", "name"): + for key in ("area_id", "device_class", "disabled_by", "hidden_by", "icon", "name"): if key in msg: changes[key] = msg[key] @@ -113,6 +121,7 @@ def websocket_update_entity(hass, connection, msg): return if "disabled_by" in msg and msg["disabled_by"] is None: + # Don't allow enabling an entity of a disabled device entity = registry.entities[msg["entity_id"]] if entity.device_id: device_registry = dr.async_get(hass) @@ -135,6 +144,7 @@ def websocket_update_entity(hass, connection, msg): return result = {"entity_entry": _entry_ext_dict(entry)} if "disabled_by" in changes and changes["disabled_by"] is None: + # Enabling an entity requires a config entry reload, or HA restart config_entry = hass.config_entries.async_get_entry(entry.config_entry_id) if config_entry and not config_entry.supports_unload: result["require_restart"] = True @@ -178,6 +188,7 @@ def _entry_dict(entry): "disabled_by": entry.disabled_by, "entity_category": entry.entity_category, "entity_id": entry.entity_id, + "hidden_by": entry.hidden_by, "icon": entry.icon, "name": entry.name, "platform": entry.platform, diff --git a/homeassistant/components/switch_as_x/__init__.py b/homeassistant/components/switch_as_x/__init__.py index 1da70f8029f..1adeace7a96 100644 --- a/homeassistant/components/switch_as_x/__init__.py +++ b/homeassistant/components/switch_as_x/__init__.py @@ -101,3 +101,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.config_entries.async_unload_platforms( entry, (entry.options[CONF_TARGET_DOMAIN],) ) + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Unload a config entry.""" + # Unhide the wrapped entry if registered + registry = er.async_get(hass) + try: + entity_id = er.async_validate_entity_id(registry, entry.options[CONF_ENTITY_ID]) + except vol.Invalid: + # The source entity has been removed from the entity registry + return + + if not (entity_entry := registry.async_get(entity_id)): + return + + if entity_entry.hidden_by == er.RegistryEntryHider.INTEGRATION: + registry.async_update_entity(entity_id, hidden_by=None) diff --git a/homeassistant/components/switch_as_x/config_flow.py b/homeassistant/components/switch_as_x/config_flow.py index 5c3c68e9353..800e056cd26 100644 --- a/homeassistant/components/switch_as_x/config_flow.py +++ b/homeassistant/components/switch_as_x/config_flow.py @@ -7,7 +7,11 @@ from typing import Any import voluptuous as vol from homeassistant.const import CONF_ENTITY_ID, Platform -from homeassistant.helpers import helper_config_entry_flow, selector +from homeassistant.helpers import ( + entity_registry as er, + helper_config_entry_flow, + selector, +) from .const import CONF_TARGET_DOMAIN, DOMAIN @@ -45,7 +49,15 @@ class SwitchAsXConfigFlowHandler( config_flow = CONFIG_FLOW def async_config_entry_title(self, options: Mapping[str, Any]) -> str: - """Return config entry title.""" + """Return config entry title and hide the wrapped entity if registered.""" + # Hide the wrapped entry if registered + registry = er.async_get(self.hass) + entity_entry = registry.async_get(options[CONF_ENTITY_ID]) + if entity_entry is not None and not entity_entry.hidden: + registry.async_update_entity( + options[CONF_ENTITY_ID], hidden_by=er.RegistryEntryHider.INTEGRATION + ) + return helper_config_entry_flow.wrapped_entity_config_entry_title( self.hass, options[CONF_ENTITY_ID] ) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 7c86bfaa501..5f91911044e 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -59,7 +59,7 @@ SAVE_DELAY = 10 _LOGGER = logging.getLogger(__name__) STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 5 +STORAGE_VERSION_MINOR = 6 STORAGE_KEY = "core.entity_registry" # Attributes relevant to describing entity @@ -85,6 +85,13 @@ class RegistryEntryDisabler(StrEnum): USER = "user" +class RegistryEntryHider(StrEnum): + """What hid a registry entry.""" + + INTEGRATION = "integration" + USER = "user" + + # DISABLED_* are deprecated, to be removed in 2022.3 DISABLED_CONFIG_ENTRY = RegistryEntryDisabler.CONFIG_ENTRY.value DISABLED_DEVICE = RegistryEntryDisabler.DEVICE.value @@ -120,6 +127,7 @@ class RegistryEntry: entity_category: EntityCategory | None = attr.ib( default=None, converter=_convert_to_entity_category ) + hidden_by: RegistryEntryHider | None = attr.ib(default=None) icon: str | None = attr.ib(default=None) id: str = attr.ib(factory=uuid_util.random_uuid_hex) name: str | None = attr.ib(default=None) @@ -143,6 +151,11 @@ class RegistryEntry: """Return if entry is disabled.""" return self.disabled_by is not None + @property + def hidden(self) -> bool: + """Return if entry is hidden.""" + return self.hidden_by is not None + @callback def write_unavailable_state(self, hass: HomeAssistant) -> None: """Write the unavailable state to the state machine.""" @@ -327,8 +340,9 @@ class EntityRegistry: # To influence entity ID generation known_object_ids: Iterable[str] | None = None, suggested_object_id: str | None = None, - # To disable an entity if it gets created + # To disable or hide an entity if it gets created disabled_by: RegistryEntryDisabler | None = None, + hidden_by: RegistryEntryHider | None = None, # Data that we want entry to have area_id: str | None = None, capabilities: Mapping[str, Any] | None = None, @@ -400,6 +414,7 @@ class EntityRegistry: disabled_by=disabled_by, entity_category=_convert_to_entity_category(entity_category), entity_id=entity_id, + hidden_by=hidden_by, original_device_class=original_device_class, original_icon=original_icon, original_name=original_name, @@ -505,6 +520,7 @@ class EntityRegistry: disabled_by: RegistryEntryDisabler | None | UndefinedType = UNDEFINED, # Type str (ENTITY_CATEG*) is deprecated as of 2021.12, use EntityCategory entity_category: EntityCategory | str | None | UndefinedType = UNDEFINED, + hidden_by: RegistryEntryHider | None | UndefinedType = UNDEFINED, icon: str | None | UndefinedType = UNDEFINED, name: str | None | UndefinedType = UNDEFINED, new_entity_id: str | UndefinedType = UNDEFINED, @@ -540,6 +556,7 @@ class EntityRegistry: ("device_id", device_id), ("disabled_by", disabled_by), ("entity_category", entity_category), + ("hidden_by", hidden_by), ("icon", icon), ("name", name), ("original_device_class", original_device_class), @@ -651,6 +668,7 @@ class EntityRegistry: entity["entity_category"], raise_report=False ), entity_id=entity["entity_id"], + hidden_by=entity["hidden_by"], icon=entity["icon"], id=entity["id"], name=entity["name"], @@ -686,6 +704,7 @@ class EntityRegistry: "disabled_by": entry.disabled_by, "entity_category": entry.entity_category, "entity_id": entry.entity_id, + "hidden_by": entry.hidden_by, "icon": entry.icon, "id": entry.id, "name": entry.name, @@ -846,6 +865,11 @@ async def _async_migrate( for entity in data["entities"]: entity["options"] = {} + if old_major_version == 1 and old_minor_version < 6: + # Version 1.6 adds hidden_by + for entity in data["entities"]: + entity["hidden_by"] = None + if old_major_version > 1: raise NotImplementedError return data diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index b4065d855ff..fae4c1bbc6f 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -4,7 +4,11 @@ import pytest from homeassistant.components.config import entity_registry from homeassistant.const import ATTR_ICON from homeassistant.helpers.device_registry import DeviceEntryDisabler -from homeassistant.helpers.entity_registry import RegistryEntry, RegistryEntryDisabler +from homeassistant.helpers.entity_registry import ( + RegistryEntry, + RegistryEntryDisabler, + RegistryEntryHider, +) from tests.common import ( MockConfigEntry, @@ -57,6 +61,7 @@ async def test_list_entities(hass, client): "area_id": None, "disabled_by": None, "entity_id": "test_domain.name", + "hidden_by": None, "name": "Hello World", "icon": None, "platform": "test_platform", @@ -68,6 +73,7 @@ async def test_list_entities(hass, client): "area_id": None, "disabled_by": None, "entity_id": "test_domain.no_name", + "hidden_by": None, "name": None, "icon": None, "platform": "test_platform", @@ -109,6 +115,7 @@ async def test_get_entity(hass, client): "disabled_by": None, "entity_category": None, "entity_id": "test_domain.name", + "hidden_by": None, "icon": None, "name": "Hello World", "original_device_class": None, @@ -136,6 +143,7 @@ async def test_get_entity(hass, client): "disabled_by": None, "entity_category": None, "entity_id": "test_domain.no_name", + "hidden_by": None, "icon": None, "name": None, "original_device_class": None, @@ -170,7 +178,7 @@ async def test_update_entity(hass, client): assert state.name == "before update" assert state.attributes[ATTR_ICON] == "icon:before update" - # UPDATE AREA, DEVICE_CLASS, ICON AND NAME + # UPDATE AREA, DEVICE_CLASS, HIDDEN_BY, ICON AND NAME await client.send_json( { "id": 6, @@ -178,6 +186,7 @@ async def test_update_entity(hass, client): "entity_id": "test_domain.world", "area_id": "mock-area-id", "device_class": "custom_device_class", + "hidden_by": "user", # We exchange strings over the WS API, not enums "icon": "icon:after update", "name": "after update", } @@ -195,6 +204,7 @@ async def test_update_entity(hass, client): "disabled_by": None, "entity_category": None, "entity_id": "test_domain.world", + "hidden_by": "user", # We exchange strings over the WS API, not enums "icon": "icon:after update", "name": "after update", "original_device_class": None, @@ -209,17 +219,33 @@ async def test_update_entity(hass, client): assert state.name == "after update" assert state.attributes[ATTR_ICON] == "icon:after update" - # UPDATE DISABLED_BY TO USER + # UPDATE HIDDEN_BY TO ILLEGAL VALUE await client.send_json( { "id": 7, "type": "config/entity_registry/update", "entity_id": "test_domain.world", + "hidden_by": "ivy", + } + ) + + msg = await client.receive_json() + assert not msg["success"] + + assert registry.entities["test_domain.world"].hidden_by is RegistryEntryHider.USER + + # UPDATE DISABLED_BY TO USER + await client.send_json( + { + "id": 8, + "type": "config/entity_registry/update", + "entity_id": "test_domain.world", "disabled_by": RegistryEntryDisabler.USER, } ) msg = await client.receive_json() + assert msg["success"] assert hass.states.get("test_domain.world") is None assert ( @@ -229,7 +255,7 @@ async def test_update_entity(hass, client): # UPDATE DISABLED_BY TO NONE await client.send_json( { - "id": 8, + "id": 9, "type": "config/entity_registry/update", "entity_id": "test_domain.world", "disabled_by": None, @@ -248,6 +274,7 @@ async def test_update_entity(hass, client): "disabled_by": None, "entity_category": None, "entity_id": "test_domain.world", + "hidden_by": "user", # We exchange strings over the WS API, not enums "icon": "icon:after update", "name": "after update", "original_device_class": None, @@ -306,6 +333,7 @@ async def test_update_entity_require_restart(hass, client): "entity_category": None, "entity_id": "test_domain.world", "icon": None, + "hidden_by": None, "name": None, "original_device_class": None, "original_icon": None, @@ -409,6 +437,7 @@ async def test_update_entity_no_changes(hass, client): "disabled_by": None, "entity_category": None, "entity_id": "test_domain.world", + "hidden_by": None, "icon": None, "name": "name of entity", "original_device_class": None, @@ -492,6 +521,7 @@ async def test_update_entity_id(hass, client): "disabled_by": None, "entity_category": None, "entity_id": "test_domain.planet", + "hidden_by": None, "icon": None, "name": None, "original_device_class": None, diff --git a/tests/components/switch_as_x/test_config_flow.py b/tests/components/switch_as_x/test_config_flow.py index b2a05faa998..6859dda9073 100644 --- a/tests/components/switch_as_x/test_config_flow.py +++ b/tests/components/switch_as_x/test_config_flow.py @@ -1,4 +1,6 @@ """Test the Switch as X config flow.""" +from __future__ import annotations + from unittest.mock import AsyncMock import pytest @@ -8,6 +10,7 @@ from homeassistant.components.switch_as_x.const import CONF_TARGET_DOMAIN, DOMAI from homeassistant.const import CONF_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM +from homeassistant.helpers import entity_registry as er @pytest.mark.parametrize("target_domain", (Platform.LIGHT,)) @@ -49,6 +52,64 @@ async def test_config_flow( } +@pytest.mark.parametrize( + "hidden_by_before,hidden_by_after", + ( + (er.RegistryEntryHider.USER.value, er.RegistryEntryHider.USER.value), + (None, er.RegistryEntryHider.INTEGRATION.value), + ), +) +@pytest.mark.parametrize("target_domain", (Platform.LIGHT,)) +async def test_config_flow_registered_entity( + hass: HomeAssistant, + target_domain: Platform, + mock_setup_entry: AsyncMock, + hidden_by_before: er.RegistryEntryHider | None, + hidden_by_after: er.RegistryEntryHider, +) -> None: + """Test the config flow hides a registered entity.""" + registry = er.async_get(hass) + switch_entity_entry = registry.async_get_or_create( + "switch", "test", "unique", suggested_object_id="ceiling" + ) + assert switch_entity_entry.entity_id == "switch.ceiling" + registry.async_update_entity("switch.ceiling", hidden_by=hidden_by_before) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ENTITY_ID: "switch.ceiling", + CONF_TARGET_DOMAIN: target_domain, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "ceiling" + assert result["data"] == {} + assert result["options"] == { + CONF_ENTITY_ID: "switch.ceiling", + CONF_TARGET_DOMAIN: target_domain, + } + assert len(mock_setup_entry.mock_calls) == 1 + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + assert config_entry.data == {} + assert config_entry.options == { + CONF_ENTITY_ID: "switch.ceiling", + CONF_TARGET_DOMAIN: target_domain, + } + + switch_entity_entry = registry.async_get("switch.ceiling") + assert switch_entity_entry.hidden_by == hidden_by_after + + @pytest.mark.parametrize("target_domain", (Platform.LIGHT,)) async def test_options( hass: HomeAssistant, diff --git a/tests/components/switch_as_x/test_init.py b/tests/components/switch_as_x/test_init.py index b36ce64e593..f5c3e7c5653 100644 --- a/tests/components/switch_as_x/test_init.py +++ b/tests/components/switch_as_x/test_init.py @@ -1,4 +1,6 @@ """Tests for the Switch as X.""" +from __future__ import annotations + from unittest.mock import patch import pytest @@ -356,3 +358,81 @@ async def test_device(hass: HomeAssistant, target_domain: Platform) -> None: entity_entry = entity_registry.async_get(f"{target_domain}.abc") assert entity_entry assert entity_entry.device_id == switch_entity_entry.device_id + + +@pytest.mark.parametrize("target_domain", (Platform.LIGHT,)) +async def test_setup_and_remove_config_entry( + hass: HomeAssistant, + target_domain: Platform, +) -> None: + """Test removing a config entry.""" + registry = er.async_get(hass) + + # Setup the config entry + switch_as_x_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: "switch.test", + CONF_TARGET_DOMAIN: target_domain, + }, + title="ABC", + ) + switch_as_x_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(switch_as_x_config_entry.entry_id) + await hass.async_block_till_done() + + # Check the state and entity registry entry are present + assert hass.states.get(f"{target_domain}.abc") is not None + assert registry.async_get(f"{target_domain}.abc") is not None + + # Remove the config entry + assert await hass.config_entries.async_remove(switch_as_x_config_entry.entry_id) + await hass.async_block_till_done() + + # Check the state and entity registry entry are removed + assert hass.states.get(f"{target_domain}.my_min_max") is None + assert registry.async_get(f"{target_domain}.my_min_max") is None + + +@pytest.mark.parametrize( + "hidden_by_before,hidden_by_after", + ( + (er.RegistryEntryHider.USER.value, er.RegistryEntryHider.USER.value), + (er.RegistryEntryHider.INTEGRATION.value, None), + ), +) +@pytest.mark.parametrize("target_domain", (Platform.LIGHT,)) +async def test_reset_hidden_by( + hass: HomeAssistant, + target_domain: Platform, + hidden_by_before: er.RegistryEntryHider | None, + hidden_by_after: er.RegistryEntryHider, +) -> None: + """Test removing a config entry resets hidden by.""" + registry = er.async_get(hass) + + switch_entity_entry = registry.async_get_or_create("switch", "test", "unique") + registry.async_update_entity( + switch_entity_entry.entity_id, hidden_by=hidden_by_before + ) + + # Add the config entry + switch_as_x_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: switch_entity_entry.id, + CONF_TARGET_DOMAIN: target_domain, + }, + title="ABC", + ) + switch_as_x_config_entry.add_to_hass(hass) + + # Remove the config entry + assert await hass.config_entries.async_remove(switch_as_x_config_entry.entry_id) + await hass.async_block_till_done() + + # Check hidden by is reset + switch_entity_entry = registry.async_get(switch_entity_entry.entity_id) + assert switch_entity_entry.hidden_by == hidden_by_after