Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 96 additions & 23 deletions homeassistant/components/glances/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@
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

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):
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Comment on lines +358 to +374
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):
Expand Down Expand Up @@ -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()

Expand Down
128 changes: 128 additions & 0 deletions tests/components/glances/test_sensor.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tests for Glances sensors."""

import copy
from datetime import timedelta
from unittest.mock import AsyncMock

Expand Down Expand Up @@ -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")