From d8ce17aaa3490500ea9bff4cb671b5e50c38eff9 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 11 Jun 2026 10:11:52 +0200 Subject: [PATCH] Allow MQTT entities to be hidden by default (#168832) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/mqtt/abbreviations.py | 1 + homeassistant/components/mqtt/const.py | 1 + homeassistant/components/mqtt/entity.py | 42 +++- homeassistant/components/mqtt/schemas.py | 2 + tests/components/mqtt/test_mixins.py | 179 +++++++++++++++++- 5 files changed, 216 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 986aa46515c1..b0abaebdce34 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -273,6 +273,7 @@ ABBREVIATIONS = { "l_ver_t": "latest_version_topic", "l_ver_tpl": "latest_version_template", "pl_inst": "payload_install", + "vis": "visible_by_default", } DEVICE_ABBREVIATIONS = { diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index a045a0e96042..2e6d00371118 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -246,6 +246,7 @@ CONF_TILT_STATE_OPTIMISTIC = "tilt_optimistic" CONF_TRANSITION = "transition" CONF_URL_TEMPLATE = "url_template" CONF_URL_TOPIC = "url_topic" +CONF_VISIBLE_BY_DEFAULT = "visible_by_default" CONF_XY_COMMAND_TEMPLATE = "xy_command_template" CONF_XY_COMMAND_TOPIC = "xy_command_topic" CONF_XY_STATE_TOPIC = "xy_state_topic" diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index 36f63b025345..0c297fcec82e 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -95,6 +95,7 @@ from .const import ( CONF_SW_VERSION, CONF_TOPIC, CONF_VIA_DEVICE, + CONF_VISIBLE_BY_DEFAULT, DOMAIN, MQTT_CONNECTION_STATE, ) @@ -1428,20 +1429,44 @@ class MqttEntity( # Plan to update the entity_id based on `default_entity_id` # if a deleted entity was found self._update_registry_entity_id = self.entity_id - if ( - self._config[CONF_ENABLED_BY_DEFAULT] - and deleted_entry - and deleted_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + reenable_condition := ( + deleted_entry + and self._config[CONF_ENABLED_BY_DEFAULT] + and deleted_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + ) + ) or ( + deleted_entry + and self._config[CONF_VISIBLE_BY_DEFAULT] + and deleted_entry.hidden_by is not None ): - # Enable previous deleted entity and enable it, - # if it was not disabled by the user + # Enable previous deleted entity, + # if it was not disabled by the user. + # Only reset hidden by flag if it was not hidden by the user. + if ( + deleted_entry.hidden_by is er.RegistryEntryHider.USER + and self._config[CONF_VISIBLE_BY_DEFAULT] + ): + _LOGGER.info( + "Restored entity %s was configured as visible by default, " + "but was hidden by the user before, and will remain hidden", + self.entity_id, + ) + if deleted_entry.hidden_by is er.RegistryEntryHider.USER: + hidden_by: er.RegistryEntryHider | None = er.RegistryEntryHider.USER + else: + hidden_by = ( + None + if self._config[CONF_VISIBLE_BY_DEFAULT] + else er.RegistryEntryHider.INTEGRATION + ) recreated_entry = entity_registry.async_get_or_create( entity_platform, DOMAIN, self.unique_id ) entity_registry.async_update_entity( recreated_entry.entity_id, - disabled_by=None, + disabled_by=None if reenable_condition else UNDEFINED, + hidden_by=hidden_by, ) if discovery_data is None: @@ -1590,6 +1615,9 @@ class MqttEntity( self._attr_entity_registry_enabled_default = bool( config.get(CONF_ENABLED_BY_DEFAULT, True) ) + self._attr_entity_registry_visible_default = bool( + config.get(CONF_VISIBLE_BY_DEFAULT, True) + ) self._attr_icon = config.get(CONF_ICON) self._attr_entity_picture = config.get(CONF_ENTITY_PICTURE) # Set the entity name if needed diff --git a/homeassistant/components/mqtt/schemas.py b/homeassistant/components/mqtt/schemas.py index 9cf204ede6e0..b1284f6f1c5b 100644 --- a/homeassistant/components/mqtt/schemas.py +++ b/homeassistant/components/mqtt/schemas.py @@ -52,6 +52,7 @@ from .const import ( CONF_SW_VERSION, CONF_TOPIC, CONF_VIA_DEVICE, + CONF_VISIBLE_BY_DEFAULT, DEFAULT_PAYLOAD_AVAILABLE, DEFAULT_PAYLOAD_NOT_AVAILABLE, ENTITY_PLATFORMS, @@ -184,6 +185,7 @@ MQTT_ENTITY_COMMON_SCHEMA = _MQTT_AVAILABILITY_SCHEMA.extend( vol.Optional(CONF_DEFAULT_ENTITY_ID): cv.string, vol.Optional(CONF_MESSAGE_EXPIRY_INTERVAL): valid_message_expiry_interval, vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_VISIBLE_BY_DEFAULT, default=True): cv.boolean, } ) diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index f23481731d6a..bc8a0040328e 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -527,13 +527,13 @@ async def test_registry_not_enabled_by_default( assert entry.disabled -async def test_registry_enable_not_enabled_by_default_entity( +async def test_registry_enable_not_enabled_or_visible_by_default_entity( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, ) -> None: - """Test enabling an entity that was not enabled by default.""" + """Test enabling an entity that was not enabled or not visible by default.""" await mqtt_mock_entry() discovery_topic = "homeassistant/sensor/bla/config" @@ -542,6 +542,7 @@ async def test_registry_enable_not_enabled_by_default_entity( "name": None, "state_topic": "state-topic", "enabled_by_default": False, + "visible_by_default": True, "unique_id": "very_unique", "default_entity_id": "sensor.test", "device": {"identifiers": "very_unique_device", "name": "test"}, @@ -552,6 +553,7 @@ async def test_registry_enable_not_enabled_by_default_entity( "name": None, "state_topic": "state-topic", "enabled_by_default": True, + "visible_by_default": False, "unique_id": "very_unique", "default_entity_id": "sensor.test", "device": {"identifiers": "very_unique_device", "name": "test"}, @@ -562,6 +564,7 @@ async def test_registry_enable_not_enabled_by_default_entity( "name": None, "state_topic": "state-topic", "enabled_by_default": True, + "visible_by_default": True, "unique_id": "very_unique", "default_entity_id": "sensor.test_new", "device": {"identifiers": "very_unique_device", "name": "test"}, @@ -575,6 +578,7 @@ async def test_registry_enable_not_enabled_by_default_entity( entry = entity_registry.async_get("sensor.test") assert entry is not None assert entry.disabled + assert entry.hidden is False assert (device_id := entry.device_id) assert device_registry.async_get(device_id) is not None @@ -588,6 +592,7 @@ async def test_registry_enable_not_enabled_by_default_entity( assert device_registry.async_get(device_id) is None # Rediscover the previous deleted entity and allow it to be enabled + # but not visible by default async_fire_mqtt_message(hass, discovery_topic, config_enabled) await hass.async_block_till_done() state = hass.states.get("sensor.test") @@ -595,10 +600,12 @@ async def test_registry_enable_not_enabled_by_default_entity( entry = entity_registry.async_get("sensor.test") assert entry is not None assert not entry.disabled + assert entry.hidden is True assert device_registry.async_get(device_id) is not None # Update entity to not be enabled by default # The entity should stay available as it was enabled before + # Also it should remain hidden async_fire_mqtt_message(hass, discovery_topic, config_disabled) await hass.async_block_till_done() state = hass.states.get("sensor.test") @@ -606,6 +613,7 @@ async def test_registry_enable_not_enabled_by_default_entity( entry = entity_registry.async_get("sensor.test") assert entry is not None assert not entry.disabled + assert entry.hidden is True assert device_registry.async_get(device_id) is not None # Delete the entity again @@ -617,12 +625,14 @@ async def test_registry_enable_not_enabled_by_default_entity( assert device_registry.async_get(device_id) is None # Repeat the re-discovery, with a new entity name + # The entity should be enabled and visible by default now async_fire_mqtt_message(hass, discovery_topic, config_enabled_new_entity_name) await hass.async_block_till_done() state = hass.states.get("sensor.test_new") assert state is not None entry = entity_registry.async_get("sensor.test_new") assert entry is not None + assert entry.hidden is False assert not entry.disabled assert device_registry.async_get(device_id) is not None @@ -645,9 +655,174 @@ async def test_registry_enable_not_enabled_by_default_entity( entry = entity_registry.async_get("sensor.test_new") assert entry is not None assert entry.disabled + assert entry.hidden is False assert device_registry.async_get(device_id) is not None +async def test_visible_by_default_with_user_override( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test visible_by_default is respected but user hidden_by override is preserved.""" + await mqtt_mock_entry() + + discovery_topic = "homeassistant/sensor/bla/config" + config_visible = json.json_dumps( + { + "name": None, + "state_topic": "state-topic", + "visible_by_default": True, + "unique_id": "very_unique", + "default_entity_id": "sensor.test", + "device": {"identifiers": "very_unique_device", "name": "test"}, + } + ) + config_hidden = json.json_dumps( + { + "name": None, + "state_topic": "state-topic", + "visible_by_default": False, + "unique_id": "very_unique", + "default_entity_id": "sensor.test", + "device": {"identifiers": "very_unique_device", "name": "test"}, + } + ) + + async_fire_mqtt_message(hass, discovery_topic, config_hidden) + await hass.async_block_till_done() + state = hass.states.get("sensor.test") + assert state is not None + entry = entity_registry.async_get("sensor.test") + assert entry is not None + assert not entry.disabled + assert entry.hidden + assert entry.hidden_by is er.RegistryEntryHider.INTEGRATION + assert (device_id := entry.device_id) + assert device_registry.async_get(device_id) is not None + + # Remove the entity and device + # At this stage no entry existed during the initialization + async_fire_mqtt_message(hass, discovery_topic, "") + await hass.async_block_till_done(wait_background_tasks=True) + entry = entity_registry.async_get("sensor.test") + assert entry is None + # Assert device is cleaned up + assert device_registry.async_get(device_id) is None + + # Rediscover the previous deleted entity, now visible by default + async_fire_mqtt_message(hass, discovery_topic, config_visible) + await hass.async_block_till_done() + state = hass.states.get("sensor.test") + assert state is not None + entry = entity_registry.async_get("sensor.test") + assert entry is not None + assert not entry.hidden + assert entry.hidden_by is None + assert device_registry.async_get(device_id) is not None + + # Mock the user hides the entity + entity_registry.async_update_entity( + "sensor.test", hidden_by=er.RegistryEntryHider.USER + ) + await hass.async_block_till_done(wait_background_tasks=True) + + # Remove the entity and device, + # the hidden by user flag should be preserved in the deleted entities registry + async_fire_mqtt_message(hass, discovery_topic, "") + await hass.async_block_till_done(wait_background_tasks=True) + entry = entity_registry.async_get("sensor.test") + assert entry is None + # Assert device is cleaned up + assert device_registry.async_get(device_id) is None + + # Rediscover again and assert the entity remains hidden + # because it was hidden by the user, even though visible_by_default is True + async_fire_mqtt_message(hass, discovery_topic, config_visible) + await hass.async_block_till_done() + state = hass.states.get("sensor.test") + assert state is not None + entry = entity_registry.async_get("sensor.test") + assert entry is not None + assert entry.hidden + assert entry.hidden_by is er.RegistryEntryHider.USER + assert device_registry.async_get(device_id) is not None + assert ( + "Restored entity sensor.test was configured as visible by default, " + "but was hidden by the user before, and will remain hidden" in caplog.text + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "unique_id": "very_unique", + } + } + }, + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "visible_by_default": True, + "unique_id": "very_unique", + } + } + }, + ], +) +async def test_registry_visible_by_default( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + entity_registry: er.EntityRegistry, +) -> None: + """Test an entity is visible with visible_by_default set or without.""" + await mqtt_mock_entry() + state = hass.states.get("sensor.test") + assert state is not None + entry = entity_registry.async_get("sensor.test") + assert not entry.disabled + assert entry.hidden is False + assert entry.hidden_by is None + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "visible_by_default": False, + "unique_id": "very_unique", + } + } + }, + ], +) +async def test_registry_not_visible_by_default( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + entity_registry: er.EntityRegistry, +) -> None: + """Test an entity that is not visible by default.""" + await mqtt_mock_entry() + state = hass.states.get("sensor.test") + assert state is not None + entry = entity_registry.async_get("sensor.test") + assert entry.hidden is True + assert entry.hidden_by is er.RegistryEntryHider.INTEGRATION + + @pytest.mark.parametrize( "mqtt_config_subentries_data", [