Adding a unit to a sensor that recorded long-term statistics without one is not cosmetic: Home Assistant raises a units_changed repair and then *suppresses statistics generation* for that entity until a human resolves it (sensor/recorder.py's compile path hits `continue`). Shipping the PM device classes on their own would therefore have silently frozen the very history the labels were meant to describe. A v2 -> v3 config-entry migration relabels the statistics metadata first. It rewrites only the metadata row, never the recorded values -- these readings were always µg/m³ and only the label was missing, so nothing needs converting, which is why this uses async_update_statistics_metadata and not change_statistics_unit. It reads entity_ids back off the entity registry rather than rebuilding them from descriptor keys, since a renamed entity's statistic_id no longer follows from its key, and it is scoped by device family: range_hood and airconditioner still declare no unit for their identically-named sensors, so relabelling theirs would create the exact mismatch this exists to prevent. With the migration in place there is no longer a reason to hold the labels back on air_monitor, so it takes them too. That board is one of the two whose fixtures pin the grade bands the mapping rests on -- typing the purifier and not the monitor was an inconsistency, not caution. It still declines the shared state_class column, unchanged. Recorder coupling is guarded: after_dependencies pulls it in when configured, the import is local to the migration, and a setup without it is a no-op. Freshly created entries mint v3 directly, having no history to relabel. Tested at both levels. The unit-level tests patch the recorder and assert which entities are relabelled with which arguments, covering the renamed entity, subdevice-prefixed and instanced keys, the near-miss keys (dustbag_/dustbin_), the skipped families and the recorder-absent path. Because a mock can only prove the call is made, not that it does what the migration needs, a second suite drives a real in-memory recorder end to end: statistics seeded unitless, migration run, metadata confirmed to read µg/m³ / concentration, recorded means confirmed byte-identical, and the units_changed issue confirmed present before and absent after.
162 lines
6.2 KiB
Python
162 lines
6.2 KiB
Python
"""The v2 -> v3 statistics relabel against a real recorder, not a mock.
|
|
|
|
tests/localthings/test_statistics_migration.py proves the migration calls
|
|
HA's API with the right arguments for the right entities. It cannot prove
|
|
that call does what the migration needs, because the recorder is patched
|
|
out. This drives an in-memory recorder end to end: statistics recorded
|
|
unitless, migration run, metadata inspected -- and, most importantly, the
|
|
recorded *values* checked to be untouched, which is the claim that makes
|
|
doing this automatically safe rather than something to ask each user about.
|
|
|
|
Lives here rather than under tests/localthings/ on purpose: that package's
|
|
autouse `enable_custom_integrations` fixture depends on `hass`, which
|
|
starts Home Assistant before `recorder_mock` can claim its database URL.
|
|
Nothing here loads the integration -- `async_migrate_entry` is called
|
|
directly -- so the entry only needs the one key the v2 -> v3 step reads.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import timedelta
|
|
from functools import partial
|
|
from typing import cast
|
|
|
|
import pytest
|
|
from homeassistant.components.recorder.models import StatisticMeanType, StatisticMetaData
|
|
from homeassistant.components.recorder.statistics import (
|
|
async_import_statistics,
|
|
get_metadata,
|
|
statistics_during_period,
|
|
)
|
|
from homeassistant.components.recorder.util import get_instance
|
|
from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.helpers import entity_registry as er
|
|
from homeassistant.util import dt as dt_util
|
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
|
from pytest_homeassistant_custom_component.components.recorder.common import (
|
|
async_wait_recording_done,
|
|
)
|
|
|
|
from custom_components.localthings import async_migrate_entry
|
|
from custom_components.localthings.const import CONF_DEVICE_TYPE, DOMAIN
|
|
from custom_components.localthings.registry.entities import SensorDesc
|
|
|
|
SERIAL = "TEST-SERIAL-0000"
|
|
RECORDED = [11.0, 9.0, 14.0]
|
|
|
|
|
|
async def _seed_unitless_statistics(hass: HomeAssistant, entity_id: str) -> None:
|
|
"""Record hourly statistics the way these sensors always have: numeric
|
|
means, no unit of measurement at all."""
|
|
start = dt_util.utcnow().replace(minute=0, second=0, microsecond=0) - timedelta(hours=4)
|
|
async_import_statistics(
|
|
hass,
|
|
{
|
|
"mean_type": StatisticMeanType.ARITHMETIC,
|
|
"has_sum": False,
|
|
"name": None,
|
|
"source": "recorder",
|
|
"statistic_id": entity_id,
|
|
"unit_class": None,
|
|
"unit_of_measurement": None,
|
|
},
|
|
[
|
|
{"start": start + timedelta(hours=i), "mean": value, "min": value, "max": value}
|
|
for i, value in enumerate(RECORDED)
|
|
],
|
|
)
|
|
await async_wait_recording_done(hass)
|
|
|
|
|
|
async def _metadata(hass: HomeAssistant, entity_id: str) -> StatisticMetaData:
|
|
result = await get_instance(hass).async_add_executor_job(
|
|
partial(get_metadata, hass, statistic_ids={entity_id})
|
|
)
|
|
return result[entity_id][1]
|
|
|
|
|
|
async def _means(hass: HomeAssistant, entity_id: str) -> list[float]:
|
|
rows = await get_instance(hass).async_add_executor_job(
|
|
statistics_during_period,
|
|
hass,
|
|
dt_util.utcnow() - timedelta(days=1),
|
|
None,
|
|
{entity_id},
|
|
"hour",
|
|
None,
|
|
{"mean"},
|
|
)
|
|
return [cast(float, row["mean"]) for row in rows.get(entity_id, [])]
|
|
|
|
|
|
@pytest.fixture
|
|
def purifier_entry(hass: HomeAssistant) -> MockConfigEntry:
|
|
entry = MockConfigEntry(
|
|
domain=DOMAIN,
|
|
data={CONF_DEVICE_TYPE: "air_purifier"},
|
|
unique_id=f"{DOMAIN}_{SERIAL}",
|
|
version=2,
|
|
)
|
|
entry.add_to_hass(hass)
|
|
return entry
|
|
|
|
|
|
def _dust_entity(hass: HomeAssistant, entry: MockConfigEntry):
|
|
return er.async_get(hass).async_get_or_create(
|
|
"sensor", DOMAIN, f"{DOMAIN}_{SERIAL}_dust", config_entry=entry
|
|
)
|
|
|
|
|
|
async def test_relabels_metadata_without_touching_recorded_values(
|
|
recorder_mock, hass: HomeAssistant, purifier_entry: MockConfigEntry
|
|
) -> None:
|
|
dust = _dust_entity(hass, purifier_entry)
|
|
await _seed_unitless_statistics(hass, dust.entity_id)
|
|
|
|
before = await _metadata(hass, dust.entity_id)
|
|
assert before["unit_of_measurement"] is None
|
|
assert await _means(hass, dust.entity_id) == RECORDED
|
|
|
|
assert await async_migrate_entry(hass, purifier_entry) is True
|
|
await async_wait_recording_done(hass)
|
|
|
|
after = await _metadata(hass, dust.entity_id)
|
|
assert after["unit_of_measurement"] == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
|
|
assert after["unit_class"] == "concentration"
|
|
# The readings were always µg/m³; only the label was missing. Nothing is
|
|
# converted, so the recorded history still says exactly what it said.
|
|
assert await _means(hass, dust.entity_id) == RECORDED
|
|
|
|
|
|
async def test_recorded_unit_ends_up_matching_what_the_descriptor_declares(
|
|
recorder_mock, hass: HomeAssistant, purifier_entry: MockConfigEntry
|
|
) -> None:
|
|
"""The invariant the migration exists to establish, stated directly.
|
|
|
|
HA raises units_changed -- and suppresses statistics generation -- when
|
|
an entity's unit disagrees with the unit recorded against its
|
|
statistic_id. Rather than drive HA's validation to observe that, this
|
|
asserts the condition that validation reads: after migrating, the
|
|
recorded metadata says exactly what the descriptor says. Asserting our
|
|
own invariant instead of Home Assistant's reaction to it keeps the test
|
|
off internals that change between versions (both `_update_issues` and
|
|
`validate_statistics` have gained parameters), and tests this repo
|
|
rather than that one."""
|
|
from custom_components.localthings.registry.capabilities import air_purifier
|
|
|
|
dust = _dust_entity(hass, purifier_entry)
|
|
await _seed_unitless_statistics(hass, dust.entity_id)
|
|
|
|
desc = next(
|
|
d
|
|
for d in air_purifier.AIR_QUALITY.entities
|
|
if d.key == "dust" and isinstance(d, SensorDesc)
|
|
)
|
|
assert (await _metadata(hass, dust.entity_id))["unit_of_measurement"] != desc.unit
|
|
|
|
assert await async_migrate_entry(hass, purifier_entry) is True
|
|
await async_wait_recording_done(hass)
|
|
|
|
assert (await _metadata(hass, dust.entity_id))["unit_of_measurement"] == desc.unit
|