From 8a3c0edb5926cc7b6483dc51fa815b41b4cc3532 Mon Sep 17 00:00:00 2001 From: Eduardo Tsen Date: Mon, 29 Dec 2025 17:08:55 +0100 Subject: [PATCH] Publish area and floor metrics to Prometheus (#159322) --- .../components/prometheus/__init__.py | 228 ++++++++++++- tests/components/prometheus/test_init.py | 308 +++++++++++++++++- 2 files changed, 528 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index de9d87f5abe..559193a0015 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -76,15 +76,33 @@ from homeassistant.const import ( ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, State from homeassistant.helpers import ( + area_registry as ar, config_validation as cv, + device_registry as dr, + entity_registry as er, entityfilter, + floor_registry as fr, state as state_helper, ) +from homeassistant.helpers.area_registry import ( + EVENT_AREA_REGISTRY_UPDATED, + AreaEntry, + EventAreaRegistryUpdatedData, +) +from homeassistant.helpers.device_registry import ( + EVENT_DEVICE_REGISTRY_UPDATED, + EventDeviceRegistryUpdatedData, +) from homeassistant.helpers.entity_registry import ( EVENT_ENTITY_REGISTRY_UPDATED, EventEntityRegistryUpdatedData, ) from homeassistant.helpers.entity_values import EntityValues +from homeassistant.helpers.floor_registry import ( + EVENT_FLOOR_REGISTRY_UPDATED, + EventFloorRegistryUpdatedData, + FloorEntry, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util.dt import as_timestamp from homeassistant.util.unit_conversion import TemperatureConverter @@ -152,6 +170,11 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: conf[CONF_COMPONENT_CONFIG_GLOB], ) + area_registry = ar.async_get(hass) + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + floor_registry = fr.async_get(hass) + metrics = PrometheusMetrics( entity_filter, namespace, @@ -159,6 +182,10 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: component_config, override_metric, default_metric, + area_registry, + device_registry, + entity_registry, + floor_registry, ) hass.bus.listen(EVENT_STATE_CHANGED, metrics.handle_state_changed_event) @@ -166,6 +193,18 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: EVENT_ENTITY_REGISTRY_UPDATED, metrics.handle_entity_registry_updated, ) + hass.bus.listen( + EVENT_DEVICE_REGISTRY_UPDATED, + metrics.handle_device_registry_updated, + ) + hass.bus.listen(EVENT_AREA_REGISTRY_UPDATED, metrics.handle_area_registry_updated) + hass.bus.listen(EVENT_FLOOR_REGISTRY_UPDATED, metrics.handle_floor_registry_updated) + + for floor in floor_registry.async_list_floors(): + metrics.handle_floor(floor) + + for area in area_registry.async_list_areas(): + metrics.handle_area(area) for state in hass.states.all(): if entity_filter(state.entity_id): @@ -201,6 +240,10 @@ class PrometheusMetrics: component_config: EntityValues, override_metric: str | None, default_metric: str | None, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + floor_registry: fr.FloorRegistry, ) -> None: """Initialize Prometheus Metrics.""" self._component_config = component_config @@ -228,6 +271,14 @@ class PrometheusMetrics: ) self._climate_units = climate_units + self._area_info_metrics: dict[str, MetricNameWithLabelValues] = {} + self._floor_info_metrics: dict[str, MetricNameWithLabelValues] = {} + + self.area_registry = area_registry + self.device_registry = device_registry + self.entity_registry = entity_registry + self.floor_registry = floor_registry + def handle_state_changed_event(self, event: Event[EventStateChangedData]) -> None: """Handle new messages from the bus.""" if (state := event.data.get("new_state")) is None: @@ -251,6 +302,11 @@ class PrometheusMetrics: entity_id = state.entity_id _LOGGER.debug("Handling state update for %s", entity_id) + if not self._metrics_by_entity_id[state.entity_id]: + area_id = self._find_area_id(state.entity_id) + if area_id is not None: + self._add_entity_info(state.entity_id, area_id) + labels = self._labels(state) self._metric( @@ -277,7 +333,12 @@ class PrometheusMetrics: if state.state in IGNORED_STATES: self._remove_labelsets( entity_id, - {"state_change", "entity_available", "last_updated_time_seconds"}, + { + "state_change", + "entity_available", + "last_updated_time_seconds", + "entity_info", + }, ) else: domain, _ = hacore.split_entity_id(entity_id) @@ -306,10 +367,129 @@ class PrometheusMetrics: metrics_entity_id = changes["entity_id"] elif "disabled_by" in changes: metrics_entity_id = entity_id + elif "area_id" in changes or "device_id" in changes: + if entity_id is not None: + self._remove_entity_info(entity_id) + area_id = self._find_area_id(entity_id) + if area_id is not None: + self._add_entity_info(entity_id, area_id) if metrics_entity_id: self._remove_labelsets(metrics_entity_id) + def handle_device_registry_updated( + self, event: Event[EventDeviceRegistryUpdatedData] + ) -> None: + """Listen for changes of devices' area_id.""" + if event.data["action"] != "update" or "area_id" not in event.data["changes"]: + return + + device_id = event.data.get("device_id") + + if device_id is None: + return + + _LOGGER.debug("Handling device update for %s", device_id) + + device = self.device_registry.async_get(device_id) + if device is None: + return + + area_id = device.area_id + + for entity_id in ( + entity.entity_id + for entity in er.async_entries_for_device(self.entity_registry, device_id) + if entity.area_id is None and entity.entity_id in self._metrics_by_entity_id + ): + self._remove_entity_info(entity_id) + if area_id is not None: + self._add_entity_info(entity_id, area_id) + + def handle_area_registry_updated( + self, event: Event[EventAreaRegistryUpdatedData] + ) -> None: + """Listen for changes to areas.""" + + area_id = event.data.get("area_id") + + if area_id is None: + return + + action = event.data["action"] + + _LOGGER.debug("Handling area update for %s (%s)", area_id, action) + + if action in {"update", "remove"}: + metric = self._area_info_metrics.pop(area_id, None) + if metric is not None: + metric_name, label_values = astuple(metric) + self._metrics[metric_name].remove(*label_values) + if action in {"update", "create"}: + area = self.area_registry.async_get_area(area_id) + if area is not None: + self.handle_area(area) + + def handle_area(self, area: AreaEntry) -> None: + """Add/update an area in Prometheus.""" + metric_name = "area_info" + labels = { + "area": area.id, + "area_name": area.name, + "floor": area.floor_id if area.floor_id is not None else "", + } + self._area_info_metrics[labels["area"]] = MetricNameWithLabelValues( + metric_name, tuple(labels.values()) + ) + self._metric( + metric_name, + prometheus_client.Gauge, + "Area information", + labels, + ).set(1.0) + + def handle_floor_registry_updated( + self, event: Event[EventFloorRegistryUpdatedData] + ) -> None: + """Listen for changes to floors.""" + + floor_id = event.data.get("floor_id") + + if floor_id is None: + return + + action = event.data["action"] + + _LOGGER.debug("Handling floor update for %s (%s)", floor_id, action) + + if action in {"update", "remove"}: + metric = self._floor_info_metrics.pop(str(floor_id), None) + if metric is not None: + metric_name, label_values = astuple(metric) + self._metrics[metric_name].remove(*label_values) + if action in {"update", "create"}: + floor = self.floor_registry.async_get_floor(str(floor_id)) + if floor is not None: + self.handle_floor(floor) + + def handle_floor(self, floor: FloorEntry) -> None: + """Add/update a floor in Prometheus.""" + metric_name = "floor_info" + labels = { + "floor": floor.floor_id, + "floor_name": floor.name, + "floor_level": str(floor.level) if floor.level is not None else "", + } + self._floor_info_metrics[labels["floor"]] = MetricNameWithLabelValues( + metric_name, tuple(labels.values()) + ) + self._metric( + metric_name, + prometheus_client.Gauge, + "Floor information", + labels, + ).set(1.0) + def _remove_labelsets( self, entity_id: str, @@ -371,9 +551,10 @@ class PrometheusMetrics: registry=prometheus_client.REGISTRY, ) metric = cast(_MetricBaseT, self._metrics[metric_name]) - self._metrics_by_entity_id[labels["entity"]].add( - MetricNameWithLabelValues(metric_name, tuple(labels.values())) - ) + if "entity" in labels: + self._metrics_by_entity_id[labels["entity"]].add( + MetricNameWithLabelValues(metric_name, tuple(labels.values())) + ) return metric.labels(**labels) @staticmethod @@ -415,6 +596,45 @@ class PrometheusMetrics: ) return labels | extra_labels + def _remove_entity_info(self, entity_id: str) -> None: + """Remove an entity-area-relation in Prometheus.""" + self._remove_labelsets( + entity_id, + { + metric_set.metric_name + for metric_set in self._metrics_by_entity_id[entity_id] + if metric_set.metric_name != "entity_info" + }, + ) + + def _add_entity_info(self, entity_id: str, area_id: str) -> None: + """Add/update an entity-area-relation in Prometheus.""" + self._metric( + "entity_info", + prometheus_client.Gauge, + "The area of an entity", + { + "entity": entity_id, + "area": area_id, + }, + ).set(1.0) + + def _find_area_id(self, entity_id: str) -> str | None: + """Find area of entity or parent device.""" + entity = self.entity_registry.async_get(entity_id) + + if entity is None: + return None + + area_id = entity.area_id + + if area_id is None and entity.device_id is not None: + device = self.device_registry.async_get(entity.device_id) + if device is not None: + area_id = device.area_id + + return area_id + def _battery_metric(self, state: State) -> None: if (battery_level := state.attributes.get(ATTR_BATTERY_LEVEL)) is None: return diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index d58a44ff0f8..11f2e027e42 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -1,9 +1,11 @@ """The tests for the Prometheus exporter.""" +from __future__ import annotations + from dataclasses import dataclass import datetime from http import HTTPStatus -from typing import Any, Self +from typing import Any from unittest import mock from freezegun import freeze_time @@ -92,10 +94,16 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, + floor_registry as fr, +) from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator PROMETHEUS_PATH = "homeassistant.components.prometheus" @@ -126,7 +134,7 @@ class EntityMetric: assert labelname in self.labels assert self.labels[labelname] != "" - def withValue(self, value: float) -> Self: + def withValue(self, value: float) -> EntityMetricWithValue: """Return a metric with value.""" return EntityMetricWithValue(self, value) @@ -847,7 +855,7 @@ async def test_climate_mode( climate_entities: dict[str, er.RegistryEntry | dict[str, Any]], ) -> None: """Test prometheus metrics for climate mode enum.""" - data = {**climate_entities} + data: dict[str, Any] = {**climate_entities} # Set climate_2 to a specific HVAC mode from its available modes set_state_with_entry( @@ -2961,3 +2969,295 @@ async def test_filtered_denylist( was_called = mock_client.labels.call_count == 1 assert test.should_pass == was_called mock_client.labels.reset_mock() + + +class InfoMetric(EntityMetric): + """Represents a Prometheus info metric.""" + + @classmethod + def required_labels(cls) -> list[str]: + """No required labels for info metrics.""" + return [] + + +@pytest.mark.parametrize("namespace", [""]) +async def test_floor_metric( + hass: HomeAssistant, + floor_registry: fr.FloorRegistry, + client: ClientSessionGenerator, +) -> None: + """Test floor metric.""" + + # create a floor + floor = floor_registry.async_create("Floor", level=1) + floor_metric = InfoMetric( + metric_name="floor_info", + floor="floor", + floor_level="1", + floor_name="Floor", + ) + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + floor_metric.assert_in_metrics(body) + + # update floor + floor_registry.async_update(floor.floor_id, level=99, name="Updated") + updated_metric = InfoMetric( + metric_name="floor_info", + floor="floor", + floor_level="99", + floor_name="Updated", + ) + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + floor_metric.assert_not_in_metrics(body) + updated_metric.assert_in_metrics(body) + + # delete floor + floor_registry.async_delete(floor.floor_id) + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + floor_metric.assert_not_in_metrics(body) + updated_metric.assert_not_in_metrics(body) + + +@pytest.mark.parametrize("namespace", [""]) +async def test_area_metric( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + client: ClientSessionGenerator, +) -> None: + """Test area metric.""" + # create an area + area = area_registry.async_create("Area") + area_metric = InfoMetric( + metric_name="area_info", + area="area", + area_name="Area", + floor="", + ) + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + area_metric.assert_in_metrics(body) + + # update area + area_registry.async_update(area.id, name="Updated") + updated_metric = InfoMetric( + metric_name="area_info", + area="area", + area_name="Updated", + floor="", + ) + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + area_metric.assert_not_in_metrics(body) + updated_metric.assert_in_metrics(body) + + # delete area + area_registry.async_delete(area.id) + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + area_metric.assert_not_in_metrics(body) + updated_metric.assert_not_in_metrics(body) + + +@pytest.mark.parametrize("namespace", [""]) +async def test_delete_floor_of_area( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + floor_registry: fr.FloorRegistry, + client: ClientSessionGenerator, +) -> None: + """Test entity/area correlation.""" + + # create floor and area + floor = floor_registry.async_create("Floor", level=1) + area = area_registry.async_create("Area", floor_id=floor.floor_id) + metric = InfoMetric( + metric_name="area_info", + area="area", + area_name="Area", + floor="floor", + ) + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + metric.assert_in_metrics(body) + + # delete floor + floor_registry.async_delete(floor.floor_id) + updated = area_registry.async_get_area(area.id) + assert updated is not None + updated_metric = InfoMetric( + metric_name="area_info", + area="area", + area_name="Area", + floor="", + ) + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + metric.assert_not_in_metrics(body) + updated_metric.assert_in_metrics(body) + + +@pytest.mark.parametrize("namespace", [""]) +async def test_area_in_entity( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, + client: ClientSessionGenerator, + sensor_entities: dict[str, er.RegistryEntry], +) -> None: + """Test entity/area correlation.""" + + # link an entity to an area + sensor = sensor_entities["sensor_1"] + area_1 = area_registry.async_create("Area 1") + metric_1 = InfoMetric( + metric_name="entity_info", entity="sensor.outside_temperature", area="area_1" + ) + entity_registry.async_update_entity(sensor.entity_id, area_id=area_1.id) + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + metric_1.assert_in_metrics(body) + + # link entity to another area + area_2 = area_registry.async_create("Area 2") + metric_2 = InfoMetric( + metric_name="entity_info", entity="sensor.outside_temperature", area="area_2" + ) + entity_registry.async_update_entity(sensor.entity_id, area_id=area_2.id) + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + metric_1.assert_not_in_metrics(body) + metric_2.assert_in_metrics(body) + + # delete current area + area_registry.async_delete(area_2.id) + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + metric_1.assert_not_in_metrics(body) + metric_2.assert_not_in_metrics(body) + + +@pytest.mark.parametrize("namespace", [""]) +async def test_area_in_device( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + client: ClientSessionGenerator, + sensor_entities: dict[str, er.RegistryEntry], +) -> None: + """Test entity/device/area correlation.""" + + # create a device + config_entry = MockConfigEntry() + config_entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("prometheus", "test-device")}, + ) + + # link entity to device + sensor = sensor_entities["sensor_1"] + entity_registry.async_update_entity(sensor.entity_id, device_id=device.id) + + # create areas + entity_area = area_registry.async_create("Entity Area") + entity_area_metric = InfoMetric( + metric_name="entity_info", + entity="sensor.outside_temperature", + area="entity_area", + ) + device_area = area_registry.async_create("Device Area") + device_area_metric = InfoMetric( + metric_name="entity_info", + entity="sensor.outside_temperature", + area="device_area", + ) + + # no area yet + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + entity_area_metric.assert_not_in_metrics(body) + device_area_metric.assert_not_in_metrics(body) + + # set device area + device_registry.async_update_device(device.id, area_id=device_area.id) + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + entity_area_metric.assert_not_in_metrics(body) + device_area_metric.assert_in_metrics(body) + + # set entity area + entity_registry.async_update_entity(sensor.entity_id, area_id=entity_area.id) + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + entity_area_metric.assert_in_metrics(body) + device_area_metric.assert_not_in_metrics(body) + + # unset entity area + entity_registry.async_update_entity(sensor.entity_id, area_id=None) + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + entity_area_metric.assert_not_in_metrics(body) + device_area_metric.assert_in_metrics(body) + + # remove device + device_registry.async_remove_device(device.id) + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + entity_area_metric.assert_not_in_metrics(body) + device_area_metric.assert_not_in_metrics(body) + + +@pytest.mark.parametrize("namespace", [""]) +async def test_area_in_entity_on_entity_id_update( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, + client: ClientSessionGenerator, + sensor_entities: dict[str, er.RegistryEntry], +) -> None: + """Test simultaneous update of entity_id and area_id.""" + + # link an entity to an area + sensor = sensor_entities["sensor_1"] + area_1 = area_registry.async_create("Area 1") + original_metric = InfoMetric( + metric_name="entity_info", entity="sensor.outside_temperature", area="area_1" + ) + entity_registry.async_update_entity(sensor.entity_id, area_id=area_1.id) + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + original_metric.assert_in_metrics(body) + + # link entity to another area and update entity_id + area_2 = area_registry.async_create("Area 2") + updated_metric_with_old_entity_id = InfoMetric( + metric_name="entity_info", + entity="sensor.outside_temperature", + area="area_2", + ) + updated_metric_with_new_entity_id = InfoMetric( + metric_name="entity_info", + entity="sensor.outside_temperature_updated", + area="area_2", + ) + updated_sensor = entity_registry.async_update_entity( + sensor.entity_id, + area_id=area_2.id, + new_entity_id="sensor.outside_temperature_updated", + ) + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + original_metric.assert_not_in_metrics(body) + updated_metric_with_old_entity_id.assert_not_in_metrics(body) + updated_metric_with_new_entity_id.assert_not_in_metrics(body) + + set_state_with_entry(hass, updated_sensor, 10) + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + original_metric.assert_not_in_metrics(body) + updated_metric_with_old_entity_id.assert_not_in_metrics(body) + updated_metric_with_new_entity_id.assert_in_metrics(body)