diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 16958be96631..5d7b12bb5951 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -28,8 +28,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall, callback, split_entity_id -from homeassistant.helpers import start -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er, start from homeassistant.helpers.entity import Entity, async_generate_entity_id from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_state_change_event @@ -40,6 +39,8 @@ from homeassistant.helpers.reload import async_reload_integration_platforms from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass +from .const import CONF_HIDE_MEMBERS + # mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs DOMAIN = "group" @@ -238,6 +239,25 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove a config entry.""" + # Unhide the group members + registry = er.async_get(hass) + + if not entry.options[CONF_HIDE_MEMBERS]: + return + + for member in entry.options[CONF_ENTITIES]: + if not (entity_id := er.async_resolve_entity_id(registry, member)): + continue + if (entity_entry := registry.async_get(entity_id)) is None: + continue + if entity_entry.hidden_by != er.RegistryEntryHider.INTEGRATION: + continue + + registry.async_update_entity(entity_id, hidden_by=None) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up all groups found defined in the configuration.""" if DOMAIN not in hass.data: diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index d1cd258b7b88..38c08692fe4e 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -7,8 +7,8 @@ from typing import Any, cast import voluptuous as vol from homeassistant.const import CONF_ENTITIES -from homeassistant.core import callback -from homeassistant.helpers import selector +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er, selector from homeassistant.helpers.helper_config_entry_flow import ( HelperConfigFlowHandler, HelperFlowStep, @@ -16,6 +16,7 @@ from homeassistant.helpers.helper_config_entry_flow import ( from . import DOMAIN from .binary_sensor import CONF_ALL +from .const import CONF_HIDE_MEMBERS def basic_group_options_schema(domain: str) -> vol.Schema: @@ -25,6 +26,9 @@ def basic_group_options_schema(domain: str) -> vol.Schema: vol.Required(CONF_ENTITIES): selector.selector( {"entity": {"domain": domain, "multiple": True}} ), + vol.Required(CONF_HIDE_MEMBERS, default=False): selector.selector( + {"boolean": {}} + ), } ) @@ -98,6 +102,39 @@ class GroupConfigFlowHandler(HelperConfigFlowHandler, domain=DOMAIN): config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW + @callback def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title.""" return cast(str, options["name"]) if "name" in options else "" + + @callback + def async_config_flow_finished(self, options: Mapping[str, Any]) -> None: + """Hide the group members if requested.""" + if options[CONF_HIDE_MEMBERS]: + _async_hide_members( + self.hass, options[CONF_ENTITIES], er.RegistryEntryHider.INTEGRATION + ) + + @callback + @staticmethod + def async_options_flow_finished( + hass: HomeAssistant, options: Mapping[str, Any] + ) -> None: + """Hide or unhide the group members as requested.""" + hidden_by = ( + er.RegistryEntryHider.INTEGRATION if options[CONF_HIDE_MEMBERS] else None + ) + _async_hide_members(hass, options[CONF_ENTITIES], hidden_by) + + +def _async_hide_members( + hass: HomeAssistant, members: list[str], hidden_by: er.RegistryEntryHider | None +) -> None: + """Hide or unhide group members.""" + registry = er.async_get(hass) + for member in members: + if not (entity_id := er.async_resolve_entity_id(registry, member)): + continue + if entity_id not in registry.entities: + continue + registry.async_update_entity(entity_id, hidden_by=hidden_by) diff --git a/homeassistant/components/group/const.py b/homeassistant/components/group/const.py new file mode 100644 index 000000000000..82817e71add3 --- /dev/null +++ b/homeassistant/components/group/const.py @@ -0,0 +1,3 @@ +"""Constants for the Group integration.""" + +CONF_HIDE_MEMBERS = "hide_members" diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 5f91911044e3..90ca1e55cf27 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -961,6 +961,22 @@ def async_validate_entity_id(registry: EntityRegistry, entity_id_or_uuid: str) - return entry.entity_id +@callback +def async_resolve_entity_id( + registry: EntityRegistry, entity_id_or_uuid: str +) -> str | None: + """Validate and resolve an entity id or UUID to an entity id. + + Returns None if the entity or UUID is invalid, or if the UUID is not + associated with an entity registry item. + """ + if valid_entity_id(entity_id_or_uuid): + return entity_id_or_uuid + if (entry := registry.entities.get_entry(entity_id_or_uuid)) is None: + return None + return entry.entity_id + + @callback def async_validate_entity_ids( registry: EntityRegistry, entity_ids_or_uuids: list[str] diff --git a/homeassistant/helpers/helper_config_entry_flow.py b/homeassistant/helpers/helper_config_entry_flow.py index 2611841e1cfe..b9e35d9d3364 100644 --- a/homeassistant/helpers/helper_config_entry_flow.py +++ b/homeassistant/helpers/helper_config_entry_flow.py @@ -137,7 +137,9 @@ class HelperConfigFlowHandler(config_entries.ConfigFlow): if cls.options_flow is None: raise UnknownHandler - return HelperOptionsFlowHandler(config_entry, cls.options_flow) + return HelperOptionsFlowHandler( + config_entry, cls.options_flow, cls.async_options_flow_finished + ) # Create an async_get_options_flow method cls.async_get_options_flow = _async_get_options_flow # type: ignore[assignment] @@ -167,6 +169,7 @@ class HelperConfigFlowHandler(config_entries.ConfigFlow): # pylint: disable-next=no-self-use @abstractmethod + @callback def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title. @@ -174,6 +177,25 @@ class HelperConfigFlowHandler(config_entries.ConfigFlow): input from the config flow steps. """ + @callback + def async_config_flow_finished(self, options: Mapping[str, Any]) -> None: + """Take necessary actions after the config flow is finished, if needed. + + The options parameter contains config entry options, which is the union of user + input from the config flow steps. + """ + + @callback + @staticmethod + def async_options_flow_finished( + hass: HomeAssistant, options: Mapping[str, Any] + ) -> None: + """Take necessary actions after the options flow is finished, if needed. + + The options parameter contains config entry options, which is the union of stored + options and user input from the options flow steps. + """ + @callback def async_create_entry( # pylint: disable=arguments-differ self, @@ -181,6 +203,7 @@ class HelperConfigFlowHandler(config_entries.ConfigFlow): **kwargs: Any, ) -> FlowResult: """Finish config flow and create a config entry.""" + self.async_config_flow_finished(data) return super().async_create_entry( data={}, options=data, title=self.async_config_entry_title(data), **kwargs ) @@ -193,10 +216,12 @@ class HelperOptionsFlowHandler(config_entries.OptionsFlow): self, config_entry: config_entries.ConfigEntry, options_flow: dict[str, vol.Schema], + async_options_flow_finished: Callable[[HomeAssistant, Mapping[str, Any]], None], ) -> None: """Initialize options flow.""" self._common_handler = HelperCommonFlowHandler(self, options_flow, config_entry) self._config_entry = config_entry + self._async_options_flow_finished = async_options_flow_finished for step in options_flow: setattr(self, f"async_step_{step}", self._async_step) @@ -210,10 +235,12 @@ class HelperOptionsFlowHandler(config_entries.OptionsFlow): @callback def async_create_entry( # pylint: disable=arguments-differ self, + data: Mapping[str, Any], **kwargs: Any, ) -> FlowResult: """Finish config flow and create a config entry.""" - return super().async_create_entry(title="", **kwargs) + self._async_options_flow_finished(self.hass, data) + return super().async_create_entry(title="", data=data, **kwargs) @callback diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index cf0254768a3f..16fa2ad69334 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -7,6 +7,7 @@ from homeassistant import config_entries from homeassistant.components.group import DOMAIN, async_setup_entry 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 from tests.common import MockConfigEntry @@ -68,8 +69,9 @@ async def test_config_flow( assert result["title"] == "Living Room" assert result["data"] == {} assert result["options"] == { - "group_type": group_type, "entities": members, + "group_type": group_type, + "hide_members": False, "name": "Living Room", **extra_options, } @@ -78,9 +80,10 @@ async def test_config_flow( config_entry = hass.config_entries.async_entries(DOMAIN)[0] assert config_entry.data == {} assert config_entry.options == { - "group_type": group_type, - "name": "Living Room", "entities": members, + "group_type": group_type, + "hide_members": False, + "name": "Living Room", **extra_options, } @@ -91,6 +94,69 @@ async def test_config_flow( assert state.attributes[key] == extra_attrs[key] +@pytest.mark.parametrize( + "hide_members,hidden_by", ((False, None), (True, "integration")) +) +@pytest.mark.parametrize( + "group_type,extra_input", + ( + ("binary_sensor", {"all": False}), + ("cover", {}), + ("fan", {}), + ("light", {}), + ("media_player", {}), + ), +) +async def test_config_flow_hides_members( + hass: HomeAssistant, group_type, extra_input, hide_members, hidden_by +) -> None: + """Test the config flow hides members if requested.""" + fake_uuid = "a266a680b608c32770e6c45bfe6b8411" + registry = er.async_get(hass) + entry = registry.async_get_or_create( + group_type, "test", "unique", suggested_object_id="one" + ) + assert entry.entity_id == f"{group_type}.one" + assert entry.hidden_by is None + + entry = registry.async_get_or_create( + group_type, "test", "unique3", suggested_object_id="three" + ) + assert entry.entity_id == f"{group_type}.three" + assert entry.hidden_by is None + + members = [f"{group_type}.one", f"{group_type}.two", fake_uuid, entry.id] + 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"], + {"group_type": group_type}, + ) + await hass.async_block_till_done() + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == group_type + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "name": "Living Room", + "entities": members, + "hide_members": hide_members, + **extra_input, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + + assert registry.async_get(f"{group_type}.one").hidden_by == hidden_by + assert registry.async_get(f"{group_type}.three").hidden_by == hidden_by + + def get_suggested(schema, key): """Get suggested value for key in voluptuous schema.""" for k in schema.keys(): @@ -124,7 +190,7 @@ async def test_options( for member in members2: hass.states.async_set(member, member_state, {}) - switch_as_x_config_entry = MockConfigEntry( + group_config_entry = MockConfigEntry( data={}, domain=DOMAIN, options={ @@ -135,9 +201,9 @@ async def test_options( }, title="Bed Room", ) - switch_as_x_config_entry.add_to_hass(hass) + group_config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(switch_as_x_config_entry.entry_id) + assert await hass.config_entries.async_setup(group_config_entry.entry_id) await hass.async_block_till_done() state = hass.states.get(f"{group_type}.bed_room") @@ -159,15 +225,17 @@ async def test_options( ) assert result["type"] == RESULT_TYPE_CREATE_ENTRY assert result["data"] == { - "group_type": group_type, "entities": members2, + "group_type": group_type, + "hide_members": False, "name": "Bed Room", **extra_options, } assert config_entry.data == {} assert config_entry.options == { - "group_type": group_type, "entities": members2, + "group_type": group_type, + "hide_members": False, "name": "Bed Room", **extra_options, } @@ -196,3 +264,83 @@ async def test_options( assert get_suggested(result["data_schema"].schema, "entities") is None assert get_suggested(result["data_schema"].schema, "name") is None + + +@pytest.mark.parametrize( + "hide_members,hidden_by_initial,hidden_by", + ((False, "integration", None), (True, None, "integration")), +) +@pytest.mark.parametrize( + "group_type,extra_input", + ( + ("binary_sensor", {"all": False}), + ("cover", {}), + ("fan", {}), + ("light", {}), + ("media_player", {}), + ), +) +async def test_options_flow_hides_members( + hass: HomeAssistant, + group_type, + extra_input, + hide_members, + hidden_by_initial, + hidden_by, +) -> None: + """Test the options flow hides or unhides members if requested.""" + fake_uuid = "a266a680b608c32770e6c45bfe6b8411" + registry = er.async_get(hass) + entry = registry.async_get_or_create( + group_type, + "test", + "unique1", + suggested_object_id="one", + hidden_by=hidden_by_initial, + ) + assert entry.entity_id == f"{group_type}.one" + + entry = registry.async_get_or_create( + group_type, + "test", + "unique3", + suggested_object_id="three", + hidden_by=hidden_by_initial, + ) + assert entry.entity_id == f"{group_type}.three" + + members = [f"{group_type}.one", f"{group_type}.two", fake_uuid, entry.id] + + group_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "entities": members, + "group_type": group_type, + "hide_members": False, + "name": "Bed Room", + **extra_input, + }, + title="Bed Room", + ) + group_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(group_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(group_config_entry.entry_id) + assert result["type"] == RESULT_TYPE_FORM + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "entities": members, + "hide_members": hide_members, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + + assert registry.async_get(f"{group_type}.one").hidden_by == hidden_by + assert registry.async_get(f"{group_type}.three").hidden_by == hidden_by diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index 19b9245dc7f4..ba91b9dbbbc8 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -1416,3 +1416,97 @@ async def test_setup_and_remove_config_entry( # Check the state and entity registry entry are removed assert hass.states.get(f"{group_type}.bed_room") is None assert registry.async_get(f"{group_type}.bed_room") is None + + +@pytest.mark.parametrize( + "hide_members,hidden_by_initial,hidden_by", + ( + (False, "integration", "integration"), + (False, None, None), + (False, "user", "user"), + (True, "integration", None), + (True, None, None), + (True, "user", "user"), + ), +) +@pytest.mark.parametrize( + "group_type,extra_options", + ( + ("binary_sensor", {"all": False}), + ("cover", {}), + ("fan", {}), + ("light", {}), + ("media_player", {}), + ), +) +async def test_unhide_members_on_remove( + hass: HomeAssistant, + group_type: str, + extra_options: dict[str, Any], + hide_members: bool, + hidden_by_initial: str, + hidden_by: str, +) -> None: + """Test removing a config entry.""" + registry = er.async_get(hass) + + registry = er.async_get(hass) + entry1 = registry.async_get_or_create( + group_type, + "test", + "unique1", + suggested_object_id="one", + hidden_by=hidden_by_initial, + ) + assert entry1.entity_id == f"{group_type}.one" + + entry3 = registry.async_get_or_create( + group_type, + "test", + "unique3", + suggested_object_id="three", + hidden_by=hidden_by_initial, + ) + assert entry3.entity_id == f"{group_type}.three" + + entry4 = registry.async_get_or_create( + group_type, + "test", + "unique4", + suggested_object_id="four", + ) + assert entry4.entity_id == f"{group_type}.four" + + members = [f"{group_type}.one", f"{group_type}.two", entry3.id, entry4.id] + + # Setup the config entry + group_config_entry = MockConfigEntry( + data={}, + domain=group.DOMAIN, + options={ + "entities": members, + "group_type": group_type, + "hide_members": hide_members, + "name": "Bed Room", + **extra_options, + }, + title="Bed Room", + ) + group_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(group_config_entry.entry_id) + await hass.async_block_till_done() + + # Check the state is present + assert hass.states.get(f"{group_type}.bed_room") + + # Remove one entity registry entry, to make sure this does not trip up config entry + # removal + registry.async_remove(entry4.entity_id) + + # Remove the config entry + assert await hass.config_entries.async_remove(group_config_entry.entry_id) + await hass.async_block_till_done() + + # Check the group members are unhidden + assert registry.async_get(f"{group_type}.one").hidden_by == hidden_by + assert registry.async_get(f"{group_type}.three").hidden_by == hidden_by