Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
66 changes: 43 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 @@ -299,31 +302,40 @@ async def async_setup_entry(
"""Set up the Glances sensors."""

coordinator = config_entry.runtime_data
entities: list[GlancesSensor] = []
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 +375,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
72 changes: 72 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,74 @@ 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
Loading