Publish area and floor metrics to Prometheus (#159322)

This commit is contained in:
Eduardo Tsen
2025-12-29 17:08:55 +01:00
committed by GitHub
parent e7176c4919
commit 8a3c0edb59
2 changed files with 528 additions and 8 deletions

View File

@@ -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

View File

@@ -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)