Type air_monitor's particulates and migrate the recorded statistics

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.
This commit is contained in:
Marc Billow
2026-08-15 01:01:49 +00:00
parent 21af5708cb
commit eeece4906c
8 changed files with 469 additions and 30 deletions
+89 -2
View File
@@ -3,8 +3,12 @@
from __future__ import annotations
import logging
import re
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER as PARTICULATE_UNIT,
)
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
@@ -12,7 +16,7 @@ from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.typing import ConfigType
from .const import CONF_HOST, CONF_PORT, CONF_SERIAL, DOMAIN, PLATFORMS
from .const import CONF_DEVICE_TYPE, CONF_HOST, CONF_PORT, CONF_SERIAL, DOMAIN, PLATFORMS
from .coordinator import LocalThingsCoordinator
from .registry.identity import resolve_serial
from .services import async_setup_services
@@ -125,6 +129,81 @@ def _repair_placeholder_keys(hass: HomeAssistant, entry: ConfigEntry, serial: st
)
# Registries whose Dust/FineDust/SuperFineDust sensors gained pm10/pm25/pm1
# and a unit in the release that introduced entry version 3. Deliberately
# not every family reading /sensors/vs/0: range_hood and airconditioner
# still declare no unit for their identically-named sensors, and relabelling
# their statistics to µg/m³ would assert a unit those entities don't report
# -- creating the very mismatch this migration exists to prevent.
_PARTICULATE_TYPED_IN_V3 = frozenset({"air_purifier", "air_monitor"})
# unique_id is f"{DOMAIN}_{serial}_{state_key}"; state_key is the descriptor
# key, optionally carrying a subdevice prefix and a trailing instance number
# (registry/adapter._key). Matching the tail rather than rebuilding the whole
# id keeps this working for a renamed entity, whose entity_id -- and so its
# statistic_id -- no longer follows from the key at all.
_PARTICULATE_KEY_RE = re.compile(r"_(?:super_fine_dust|fine_dust|dust)\d*$")
@callback
def _relabel_particulate_statistics(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Point existing particulate statistics at the unit they always were.
These sensors recorded long-term statistics with no unit, and the
release carrying this migration gives them µg/m³. Home Assistant treats
that as a unit change it can't convert and *suppresses statistics
generation entirely* for the entity until someone resolves the repair
(sensor.recorder._update_issues -> UNITS_CHANGED_ISSUE, and the matching
`continue` in its compile path). Silently freezing the history we just
finished labelling is the worst of both outcomes, so the metadata is
corrected up front instead.
Only the metadata row is rewritten, never the recorded values. The
readings were always µg/m³ concentrations (issue #325); what was missing
was the label, so there is nothing to convert and no way for this to
distort history. That is also why it uses
`async_update_statistics_metadata` and not `change_statistics_unit`,
which would scale every stored value.
A no-op when the recorder isn't loaded, when this device family isn't
one that gained the unit, or when the device never recorded any
statistics -- the underlying UPDATE simply matches no rows.
"""
if entry.data.get(CONF_DEVICE_TYPE) not in _PARTICULATE_TYPED_IN_V3:
return
if "recorder" not in hass.config.components:
# after_dependencies pulls the recorder in when it's configured at
# all; a setup running without it has no statistics to relabel.
_LOGGER.debug("recorder not loaded, skipping statistics relabel")
return
from homeassistant.components.recorder.statistics import (
STATISTIC_UNIT_TO_UNIT_CONVERTER,
async_update_statistics_metadata,
)
# µg/m³ has a converter (MassVolumeConcentrationConverter), so its class
# is 'concentration', not None. Read rather than hardcoded: passing a
# class that disagrees with HA's own table raises, and passing none at
# all is deprecated and stops working in HA Core 2026.11.
converter = STATISTIC_UNIT_TO_UNIT_CONVERTER.get(PARTICULATE_UNIT)
unit_class = converter.UNIT_CLASS if converter is not None else None
ent_reg = er.async_get(hass)
for registry_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id):
if registry_entry.domain != "sensor":
continue
if not _PARTICULATE_KEY_RE.search(registry_entry.unique_id):
continue
_LOGGER.debug("relabelling statistics unit for %s", registry_entry.entity_id)
async_update_statistics_metadata(
hass,
registry_entry.entity_id,
new_unit_class=unit_class,
new_unit_of_measurement=PARTICULATE_UNIT,
)
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate an entry to the current version.
@@ -132,8 +211,11 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
can key its registry entries before the first poll (issue #236), and
repairs whatever the old placeholder-keyed registration already
orphaned.
v2 -> v3 relabels the recorded statistics for the particulate sensors,
which gained a device_class/unit in the same release (issue #325).
"""
if entry.version > 2:
if entry.version > 3:
return False # downgrade: this release doesn't know the newer shape
if entry.version == 1:
@@ -147,6 +229,11 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_repair_placeholder_keys(hass, entry, serial)
_LOGGER.debug("migrated entry %s to version 2 (serial=%s)", entry.entry_id, serial)
if entry.version == 2:
_relabel_particulate_statistics(hass, entry)
hass.config_entries.async_update_entry(entry, version=3)
_LOGGER.debug("migrated entry %s to version 3", entry.entry_id)
return True
+4 -1
View File
@@ -793,7 +793,10 @@ def _probe_and_validate(
class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 2
# v3 relabels the particulate sensors' recorded statistics; a freshly
# created entry has none to relabel, so it starts at the migrated
# version rather than walking through v2 (see async_migrate_entry).
VERSION = 3
def __init__(self) -> None:
self._host: str = ""
@@ -1,6 +1,7 @@
{
"domain": "localthings",
"name": "LocalThings",
"after_dependencies": ["recorder"],
"codeowners": ["@mbillow"],
"config_flow": true,
"dependencies": [],
@@ -18,15 +18,17 @@ aggregate. This board's own readings are load-bearing evidence for the
PM mapping, though: 23 grading one step above the floor as FineDust is
what rules out a PM10-width band for that field.
Dust/FineDust/SuperFineDust nevertheless stay without an HA
`device_class`/`unit` here, which is now a migration call rather than an
evidence gap. The mapping confirmed for the purifier family (issue #325,
Dust=PM10 / FineDust=PM2.5 / SuperFineDust=PM1 in μg/m³) rests on
device-side grading that this board shares, so it would carry over. But
these five sensors have recorded unitless long-term statistics since
issue #210, and stamping a unit onto an existing statistic is what raises
Home Assistant's "units changed" repair -- a deliberate follow-up, not
something to fold into the purifier's first typed release.
Dust/FineDust/SuperFineDust carry the same HA `device_class`/`unit` as the
purifier family (issue #325, Dust=PM10 / FineDust=PM2.5 /
SuperFineDust=PM1 in μg/m³). The mapping rests on device-side grading this
board shares rather than on anything purifier-specific, so typing one
family and not the other would have been an inconsistency, not caution.
These sensors have recorded *unitless* long-term statistics since issue
#210, though, and Home Assistant suppresses statistics generation outright
for an entity whose unit no longer matches its recorded metadata -- so
stamping a unit on would have silently stopped the history it was meant to
label. __init__.py's v2->v3 entry migration relabels that metadata first.
"""
from datetime import time as dt_time
@@ -36,14 +38,15 @@ from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc, TimeDesc
from .air_purifier import _AIR_QUALITY_SENSORS
from .common import int_or_none, sensor_item_value
# Extra columns (state_class, device_class, unit) are deliberately discarded
# here: air_purifier leaves Odor/CleanLevel unstamped because they read as
# device_class/unit are taken from the shared rows; state_class deliberately
# is not. air_purifier leaves Odor/CleanLevel unstamped because they read as
# graded indices on that family, while this board has stamped all five as
# `measurement` since it was added (issue #210). Consuming state_class would
# silently drop long-term statistics for two sensors on shipped devices, and
# the pm10/pm25/pm1 labels are held back pending the statistics migration
# the module docstring describes. The shared rows supply only the
# key/icon/type here.
# `measurement` since it was added (issue #210) -- consuming that column
# would silently drop long-term statistics for two sensors on shipped
# devices. The pm10/pm25/pm1 labels carry over cleanly, though: they rest on
# device-side grading this board shares (see the module docstring), and
# __init__.py's v2->v3 entry migration relabels the unitless statistics
# these five have been recording so the new unit doesn't suppress them.
SENSORS = Capability(
href="/sensors/vs/0",
poll_tier="warm",
@@ -54,9 +57,11 @@ SENSORS = Capability(
field="x.com.samsung.da.items",
icon=icon,
state_class="measurement",
device_class=device_class,
unit=unit,
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
)
for key, icon, sensor_type, *_ in _AIR_QUALITY_SENSORS
for key, icon, sensor_type, _state_class, device_class, unit in _AIR_QUALITY_SENSORS
),
SensorDesc(
key="co2",
+4 -2
View File
@@ -38,7 +38,9 @@ async def test_migration_recovers_serial_from_unique_id(
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.version == 2
# Straight through to the current version: v2 -> v3 is a statistics
# relabel that no-ops for a family without particulate sensors.
assert entry.version == 3
assert entry.data[CONF_SERIAL] == MOCK_SERIAL
@@ -257,7 +259,7 @@ async def test_migration_rejects_a_future_entry_version(hass: HomeAssistant) ->
written by a newer release."""
from custom_components.localthings import async_migrate_entry
entry = MockConfigEntry(domain=DOMAIN, data=LEGACY_ENTRY_DATA, version=3)
entry = MockConfigEntry(domain=DOMAIN, data=LEGACY_ENTRY_DATA, version=4)
entry.add_to_hass(hass)
assert await async_migrate_entry(hass, entry) is False
@@ -0,0 +1,171 @@
"""The v2 -> v3 entry migration that relabels particulate statistics.
Dust/FineDust/SuperFineDust gained a pm10/pm25/pm1 device_class and a
µg/m³ unit (issue #325) after having recorded long-term statistics with no
unit at all. Home Assistant treats that as a unit change it cannot convert
and *suppresses statistics generation* for the entity until a human
resolves the repair, so the metadata is corrected during migration instead.
Only the metadata row is touched, never the recorded values -- the readings
were always µg/m³, so there is nothing to convert.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.localthings import async_migrate_entry
from custom_components.localthings.const import CONF_DEVICE_TYPE, DOMAIN
from .conftest import ENTRY_DATA, MOCK_SERIAL
RELABEL = "homeassistant.components.recorder.statistics.async_update_statistics_metadata"
@pytest.fixture(autouse=True)
def _recorder_loaded(hass: HomeAssistant):
"""Most tests here assume a normal install, where after_dependencies has
pulled the recorder in. test_is_a_no_op_without_the_recorder undoes it."""
hass.config.components.add("recorder")
return hass
def _entry(hass: HomeAssistant, device_type: str) -> MockConfigEntry:
entry = MockConfigEntry(
domain=DOMAIN,
data={**ENTRY_DATA, CONF_DEVICE_TYPE: device_type},
unique_id=f"{DOMAIN}_{MOCK_SERIAL}",
version=2,
)
entry.add_to_hass(hass)
return entry
def _add_sensor(hass: HomeAssistant, entry: MockConfigEntry, key: str, **kwargs):
return er.async_get(hass).async_get_or_create(
"sensor",
DOMAIN,
f"{DOMAIN}_{MOCK_SERIAL}_{key}",
config_entry=entry,
**kwargs,
)
async def test_relabels_every_particulate_sensor(hass: HomeAssistant) -> None:
entry = _entry(hass, "air_purifier")
expected = {
_add_sensor(hass, entry, key).entity_id for key in ("dust", "fine_dust", "super_fine_dust")
}
with patch(RELABEL) as relabel:
assert await async_migrate_entry(hass, entry) is True
assert {call.args[1] for call in relabel.call_args_list} == expected
for call in relabel.call_args_list:
assert call.kwargs["new_unit_of_measurement"] == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
# µg/m³ has a converter, so the class must be named, not None --
# passing neither is deprecated and breaks in HA Core 2026.11.
assert call.kwargs["new_unit_class"] == "concentration"
assert entry.version == 3
async def test_leaves_other_sensors_on_the_same_device_alone(hass: HomeAssistant) -> None:
"""Odor/CleanLevel/CO2 share the resource but keep the units they had."""
entry = _entry(hass, "air_monitor")
dust = _add_sensor(hass, entry, "dust")
for key in ("odor", "clean_level", "co2", "dustbag_usage", "dustbin_auto_close"):
_add_sensor(hass, entry, key)
with patch(RELABEL) as relabel:
assert await async_migrate_entry(hass, entry) is True
assert [call.args[1] for call in relabel.call_args_list] == [dust.entity_id]
async def test_skips_families_that_did_not_gain_the_unit(hass: HomeAssistant) -> None:
"""range_hood and airconditioner still declare no unit for their
identically-named sensors. Relabelling their statistics would assert a
unit those entities don't report -- creating the very mismatch this
migration exists to prevent."""
for device_type in ("range_hood", "airconditioner"):
entry = _entry(hass, device_type)
_add_sensor(hass, entry, "dust")
_add_sensor(hass, entry, "fine_dust")
with patch(RELABEL) as relabel:
assert await async_migrate_entry(hass, entry) is True
assert relabel.call_args_list == [], device_type
assert entry.version == 3
async def test_is_a_no_op_without_the_recorder(hass: HomeAssistant) -> None:
"""A setup running without the recorder has no statistics to relabel,
and importing recorder internals there would fail outright."""
entry = _entry(hass, "air_purifier")
_add_sensor(hass, entry, "dust")
hass.config.components.remove("recorder")
with patch(RELABEL) as relabel:
assert await async_migrate_entry(hass, entry) is True
assert relabel.call_args_list == []
assert entry.version == 3
async def test_follows_a_renamed_entity_rather_than_rebuilding_its_id(
hass: HomeAssistant,
) -> None:
"""statistic_id is the entity_id, which the user can rename. Matching the
unique_id tail and reading entity_id back off the registry is what keeps
this correct for a renamed sensor -- reconstructing an entity_id from the
descriptor key would relabel a statistic nobody is recording."""
entry = _entry(hass, "air_purifier")
renamed = _add_sensor(hass, entry, "dust", suggested_object_id="living_room_pm10")
assert renamed.entity_id == "sensor.living_room_pm10"
with patch(RELABEL) as relabel:
assert await async_migrate_entry(hass, entry) is True
assert [call.args[1] for call in relabel.call_args_list] == ["sensor.living_room_pm10"]
async def test_matches_subdevice_prefixed_and_instanced_keys(hass: HomeAssistant) -> None:
"""_key() can prefix a subdevice and append an instance number, so the
match is on the tail rather than the whole unique_id."""
entry = _entry(hass, "air_purifier")
ent_reg = er.async_get(hass)
for unique_suffix in ("indoor_0_dust", "fine_dust1", "super_fine_dust"):
ent_reg.async_get_or_create(
"sensor",
DOMAIN,
f"{DOMAIN}_{MOCK_SERIAL}_{unique_suffix}",
config_entry=entry,
)
# Near-misses that must not match.
for unique_suffix in ("dustbag_full", "dustbin_auto_close", "dust_filter_reset"):
ent_reg.async_get_or_create(
"sensor",
DOMAIN,
f"{DOMAIN}_{MOCK_SERIAL}_{unique_suffix}",
config_entry=entry,
)
with patch(RELABEL) as relabel:
assert await async_migrate_entry(hass, entry) is True
assert len(relabel.call_args_list) == 3
async def test_a_fresh_entry_starts_at_the_migrated_version(hass: HomeAssistant) -> None:
"""A newly created entry has no statistics to relabel, so the config flow
mints v3 directly rather than walking through the migration."""
from custom_components.localthings.config_flow import LocalThingsConfigFlow
assert LocalThingsConfigFlow.VERSION == 3
@@ -69,12 +69,15 @@ def test_metadata_comes_from_the_shared_tuples_own_columns():
assert (row[4] is None) == (row[5] is None), row
def test_air_monitor_keeps_stamping_every_shared_sensor():
"""air_monitor imports _AIR_QUALITY_SENSORS and discards the extra columns
on purpose: that board (issue #210) has stamped all five as `measurement`
since it was added, and consuming state_class would silently drop
long-term statistics for Odor/CleanLevel there. Guards the import end to
end and the deliberate divergence together."""
def test_air_monitor_takes_the_pm_labels_but_not_the_state_class():
"""air_monitor imports _AIR_QUALITY_SENSORS and consumes device_class and
unit -- the mapping rests on device-side grading that board shares (issue
#325), so typing one family and not the other would be an inconsistency.
state_class is still discarded: that board (issue #210) has stamped all
five as `measurement` since it was added, and consuming the column would
silently drop long-term statistics for Odor/CleanLevel there. Guards the
import end to end and the deliberate divergence together."""
from custom_components.localthings.registry.capabilities import air_monitor
assert air_monitor.SENSORS.href == "/sensors/vs/0"
@@ -83,8 +86,14 @@ def test_air_monitor_keeps_stamping_every_shared_sensor():
d for d in air_monitor.SENSORS.entities if d.key == key and isinstance(d, SensorDesc)
)
assert desc.state_class == "measurement", key
assert desc.device_class is None, key
assert desc.unit is None, key
assert desc.device_class == _desc(key).device_class, key
assert desc.unit == _desc(key).unit, key
# And the graded pair stays untyped on both families.
for key in GRADED:
desc = next(
d for d in air_monitor.SENSORS.entities if d.key == key and isinstance(d, SensorDesc)
)
assert (desc.device_class, desc.unit) == (None, None), key
def test_every_air_quality_sensor_still_reads_a_plain_int():
@@ -0,0 +1,161 @@
"""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