diff --git a/custom_components/localthings/__init__.py b/custom_components/localthings/__init__.py index 0fa67f8..91ccf21 100644 --- a/custom_components/localthings/__init__.py +++ b/custom_components/localthings/__init__.py @@ -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 diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index 3b49039..d4ad8ac 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -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 = "" diff --git a/custom_components/localthings/manifest.json b/custom_components/localthings/manifest.json index 0a8f5ea..a54e712 100644 --- a/custom_components/localthings/manifest.json +++ b/custom_components/localthings/manifest.json @@ -1,6 +1,7 @@ { "domain": "localthings", "name": "LocalThings", + "after_dependencies": ["recorder"], "codeowners": ["@mbillow"], "config_flow": true, "dependencies": [], diff --git a/custom_components/localthings/registry/capabilities/air_monitor.py b/custom_components/localthings/registry/capabilities/air_monitor.py index beeed3c..71c382b 100644 --- a/custom_components/localthings/registry/capabilities/air_monitor.py +++ b/custom_components/localthings/registry/capabilities/air_monitor.py @@ -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", diff --git a/tests/localthings/test_migration.py b/tests/localthings/test_migration.py index e8997bd..49a9152 100644 --- a/tests/localthings/test_migration.py +++ b/tests/localthings/test_migration.py @@ -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 diff --git a/tests/localthings/test_statistics_migration.py b/tests/localthings/test_statistics_migration.py new file mode 100644 index 0000000..df1e3b0 --- /dev/null +++ b/tests/localthings/test_statistics_migration.py @@ -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 diff --git a/tests/test_air_purifier_air_quality_statistics.py b/tests/test_air_purifier_air_quality_statistics.py index fc9cadb..3bec9cb 100644 --- a/tests/test_air_purifier_air_quality_statistics.py +++ b/tests/test_air_purifier_air_quality_statistics.py @@ -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(): diff --git a/tests/test_statistics_migration_end_to_end.py b/tests/test_statistics_migration_end_to_end.py new file mode 100644 index 0000000..d383e41 --- /dev/null +++ b/tests/test_statistics_migration_end_to_end.py @@ -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