diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index f86befe762dc37..39fcfa614ca91e 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -16,6 +16,7 @@ UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -23,6 +24,8 @@ from .const import CPU_ICON, DOMAIN from .coordinator import GlancesConfigEntry, GlancesDataUpdateCoordinator +DYNAMIC_TYPES = {"fs", "diskio", "sensors", "raid", "gpu", "network"} + @dataclass(frozen=True, kw_only=True) class GlancesSensorEntityDescription(SensorEntityDescription): @@ -291,6 +294,58 @@ class GlancesSensorEntityDescription(SensorEntityDescription): } +def _cleanup_orphan_entities( + hass: HomeAssistant, + config_entry: GlancesConfigEntry, + coordinator: GlancesDataUpdateCoordinator, +) -> None: + """Remove registry entries for dynamic devices no longer in the API data. + + Runs once at setup so entities registered before the dynamic-removal + behavior was added (or while Home Assistant was offline) get cleaned up + instead of lingering as STATE_UNAVAILABLE. + """ + if not coordinator.data: + return + + # Map description.key -> set of dynamic sensor_types that use it. Used to + # locate which top-level data dict a registry entry belonged to, given + # only its unique_id suffix. + key_to_types: dict[str, set[str]] = {} + for (sensor_type, _param), description in SENSOR_TYPES.items(): + if sensor_type in DYNAMIC_TYPES: + key_to_types.setdefault(description.key, set()).add(sensor_type) + + entry_id = config_entry.entry_id + prefix = f"{entry_id}-" + ent_reg = er.async_get(hass) + + for entry in er.async_entries_for_config_entry(ent_reg, entry_id): + if entry.domain != "sensor" or not entry.unique_id.startswith(prefix): + continue + rest = entry.unique_id.removeprefix(prefix) + # Static singleton entities have an empty sensor_label, producing a + # "--key" suffix; skip them so we don't remove them during a + # transient API gap. + if rest.startswith("-"): + continue + for desc_key, types in key_to_types.items(): + if not rest.endswith(f"-{desc_key}"): + continue + label = rest[: -(len(desc_key) + 1)] + present_parents = [ + coordinator.data[t] for t in types if t in coordinator.data + ] + # Only remove when at least one candidate parent dict is present + # and the label is missing from every present parent — mirrors the + # guard in GlancesSensor._handle_coordinator_update. + if present_parents and all( + label not in parent for parent in present_parents + ): + ent_reg.async_remove(entry.entity_id) + break + + async def async_setup_entry( hass: HomeAssistant, config_entry: GlancesConfigEntry, @@ -299,31 +354,41 @@ async def async_setup_entry( """Set up the Glances sensors.""" coordinator = config_entry.runtime_data - entities: list[GlancesSensor] = [] + _cleanup_orphan_entities(hass, config_entry, coordinator) + created: set[tuple[str, str, str]] = set() - for sensor_type, sensors in coordinator.data.items(): - if sensor_type in ["fs", "diskio", "sensors", "raid", "gpu", "network"]: - entities.extend( - GlancesSensor( - coordinator, - sensor_description, - sensor_label, - ) - for sensor_label, params in sensors.items() - for param in params - if (sensor_description := SENSOR_TYPES.get((sensor_type, param))) - ) - else: - entities.extend( - GlancesSensor( - coordinator, - sensor_description, - ) - for sensor in sensors - if (sensor_description := SENSOR_TYPES.get((sensor_type, sensor))) - ) + @callback + def _add_new_entities() -> None: + new_entities: list[GlancesSensor] = [] + for sensor_type, sensors in coordinator.data.items(): + if sensor_type in DYNAMIC_TYPES: + for sensor_label, params in sensors.items(): + for param in params: + key = (sensor_type, sensor_label, param) + if key in created: + continue + if ( + description := SENSOR_TYPES.get((sensor_type, param)) + ) is None: + continue + created.add(key) + new_entities.append( + GlancesSensor(coordinator, description, sensor_label) + ) + else: + for sensor in sensors: + key = (sensor_type, "", sensor) + if key in created: + continue + if (description := SENSOR_TYPES.get((sensor_type, sensor))) is None: + continue + created.add(key) + new_entities.append(GlancesSensor(coordinator, description)) + if new_entities: + async_add_entities(new_entities) - async_add_entities(entities) + _add_new_entities() + config_entry.async_on_unload(coordinator.async_add_listener(_add_new_entities)) class GlancesSensor(CoordinatorEntity[GlancesDataUpdateCoordinator], SensorEntity): @@ -363,6 +428,14 @@ def available(self) -> bool: @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" + if self.entity_description.type in DYNAMIC_TYPES: + parent = self.coordinator.data.get(self.entity_description.type) + # Only auto-remove when the parent type is present but the label is + # missing — a missing parent type is treated as a transient API + # gap and leaves the entity unavailable instead. + if parent is not None and self._sensor_label not in parent: + er.async_get(self.hass).async_remove(self.entity_id) + return self._update_native_value() super()._handle_coordinator_update() diff --git a/tests/components/glances/test_sensor.py b/tests/components/glances/test_sensor.py index 292009b30b118c..83df5aaaf8e411 100644 --- a/tests/components/glances/test_sensor.py +++ b/tests/components/glances/test_sensor.py @@ -1,5 +1,6 @@ """Tests for Glances sensors.""" +import copy from datetime import timedelta from unittest.mock import AsyncMock @@ -109,3 +110,130 @@ async def test_sensor_removed( assert hass.states.get("sensor.0_0_0_0_ssl_disk_used").state == STATE_UNAVAILABLE assert hass.states.get("sensor.0_0_0_0_memory_use").state == STATE_UNAVAILABLE assert hass.states.get("sensor.0_0_0_0_uptime").state == STATE_UNAVAILABLE + + +async def test_dynamic_sensor_auto_removed( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_api: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Dynamic entities are removed from the registry when their device disappears.""" + + freezer.move_to(MOCK_REFERENCE_DATE) + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT, entry_id="test") + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entity_registry.async_get_entity_id("sensor", DOMAIN, "test-eth0-rx") + assert entity_registry.async_get_entity_id("sensor", DOMAIN, "test-eth0-tx") + assert entity_registry.async_get_entity_id("sensor", DOMAIN, "test-lo-rx") + + # eth0 disappears (e.g. a Docker bridge network is removed) but the + # `network` block itself is still populated. + mock_data = copy.deepcopy(HA_SENSOR_DATA) + mock_data["network"].pop("eth0") + mock_api.return_value.get_ha_sensor_data = AsyncMock(return_value=mock_data) + + freezer.tick(delta=timedelta(seconds=120)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert entity_registry.async_get_entity_id("sensor", DOMAIN, "test-eth0-rx") is None + assert entity_registry.async_get_entity_id("sensor", DOMAIN, "test-eth0-tx") is None + # Other interfaces remain registered. + assert entity_registry.async_get_entity_id("sensor", DOMAIN, "test-lo-rx") + + +async def test_dynamic_sensor_auto_added( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_api: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Dynamic entities are added when a new device appears in the API response.""" + + freezer.move_to(MOCK_REFERENCE_DATE) + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT, entry_id="test") + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entity_registry.async_get_entity_id("sensor", DOMAIN, "test-eth1-rx") is None + + # A new interface appears (e.g. a Docker bridge network is created). + mock_data = copy.deepcopy(HA_SENSOR_DATA) + mock_data["network"]["eth1"] = { + "is_up": True, + "rx": 1234, + "tx": 5678, + "speed": 1000.0, + } + mock_api.return_value.get_ha_sensor_data = AsyncMock(return_value=mock_data) + + freezer.tick(delta=timedelta(seconds=120)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert entity_registry.async_get_entity_id("sensor", DOMAIN, "test-eth1-rx") + assert entity_registry.async_get_entity_id("sensor", DOMAIN, "test-eth1-tx") + eth1_rx_state = hass.states.get("sensor.0_0_0_0_eth1_rx") + assert eth1_rx_state is not None + assert eth1_rx_state.state != STATE_UNAVAILABLE + + +async def test_orphan_entities_cleaned_at_setup( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_api: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Stale registry entries from removed devices are cleaned up at setup.""" + + freezer.move_to(MOCK_REFERENCE_DATE) + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT, entry_id="test") + entry.add_to_hass(hass) + + # Pre-register orphans for devices that no longer appear in the API data: + # a removed Docker bridge network and a removed mount point. + entity_registry.async_get_or_create( + domain="sensor", + platform=DOMAIN, + unique_id="test-veth1234abc-rx", + config_entry=entry, + ) + entity_registry.async_get_or_create( + domain="sensor", + platform=DOMAIN, + unique_id="test-veth1234abc-tx", + config_entry=entry, + ) + entity_registry.async_get_or_create( + domain="sensor", + platform=DOMAIN, + unique_id="test-/old/mount-disk_use", + config_entry=entry, + ) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Orphans are gone. + assert ( + entity_registry.async_get_entity_id("sensor", DOMAIN, "test-veth1234abc-rx") + is None + ) + assert ( + entity_registry.async_get_entity_id("sensor", DOMAIN, "test-veth1234abc-tx") + is None + ) + assert ( + entity_registry.async_get_entity_id( + "sensor", DOMAIN, "test-/old/mount-disk_use" + ) + is None + ) + # Live entities are still registered. + assert entity_registry.async_get_entity_id("sensor", DOMAIN, "test-eth0-rx") + assert entity_registry.async_get_entity_id("sensor", DOMAIN, "test-/ssl-disk_use")