Harden the statistics relabel against older Home Assistant and lost boots

Review follow-up on the v2 -> v3 migration.

`new_unit_class` only exists from HA 2025.11, but hacs.json still declares
2025.1 as the minimum. On anything in between, naming that keyword is a
TypeError raised out of async_migrate_entry, which fails the config entry
outright -- the integration would not load at all for those users. The
keyword is now feature-detected, and the relabel is wrapped so that no
recorder-side surprise can cost anyone the integration: it is a
convenience, and without it they simply get Home Assistant's own
units_changed repair, which is where they were before this existed.

The version bump also no longer happens when the recorder wasn't loaded.
That case isn't distinguishable from an install without the recorder, but
burning the one-shot migration on a boot where it merely failed to come up
would leave the statistics suppressed permanently, so the entry stays on
v2 and the next start retries.

The instance-suffix regex was wrong: discovery.instance_suffix yields
`_<n>`, so keys are `dust_1`, not `dust1`. Unreachable today because
AIR_QUALITY binds an exact href, but the comment claimed a guarantee the
pattern didn't provide and the test pinned a form that can't occur.

Also drops a stale claim in airconditioner.py that air_monitor rejects the
pm10/pm25/pm1 mapping, which is no longer true as of this branch.

Tests cover both signatures, the deferral and its retry, and a relabel
that raises. The older-HA guard is mutation-checked: removing the feature
detection fails it.
This commit is contained in:
Marc Billow committed 2026-08-15 01:23:33 +00:00
1 parent eeece4906c
commit 9f1bcad3ec
3 files changed
+114 -46

No files matched your search

+62 -12
View File
@@ -31,7 +31,7 @@ RELABEL = "homeassistant.components.recorder.statistics.async_update_statistics_
@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."""
pulled the recorder in. The deferral test below undoes it."""
hass.config.components.add("recorder")
return hass
@@ -63,7 +63,7 @@ async def test_relabels_every_particulate_sensor(hass: HomeAssistant) -> None:
_add_sensor(hass, entry, key).entity_id for key in ("dust", "fine_dust", "super_fine_dust")
}
with patch(RELABEL) as relabel:
with patch(RELABEL, autospec=True) as relabel:
assert await async_migrate_entry(hass, entry) is True
assert {call.args[1] for call in relabel.call_args_list} == expected
@@ -82,7 +82,7 @@ async def test_leaves_other_sensors_on_the_same_device_alone(hass: HomeAssistant
for key in ("odor", "clean_level", "co2", "dustbag_usage", "dustbin_auto_close"):
_add_sensor(hass, entry, key)
with patch(RELABEL) as relabel:
with patch(RELABEL, autospec=True) 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]
@@ -98,24 +98,73 @@ async def test_skips_families_that_did_not_gain_the_unit(hass: HomeAssistant) ->
_add_sensor(hass, entry, "dust")
_add_sensor(hass, entry, "fine_dust")
with patch(RELABEL) as relabel:
with patch(RELABEL, autospec=True) 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."""
async def test_defers_rather_than_consuming_the_migration_without_the_recorder(
hass: HomeAssistant,
) -> None:
"""A boot where the recorder didn't come up must not burn the one-shot
migration -- doing so would leave the statistics suppressed for good.
The entry stays on v2 so the next start retries."""
entry = _entry(hass, "air_purifier")
_add_sensor(hass, entry, "dust")
hass.config.components.remove("recorder")
with patch(RELABEL) as relabel:
with patch(RELABEL, autospec=True) as relabel:
assert await async_migrate_entry(hass, entry) is True
assert relabel.call_args_list == []
assert entry.version == 2
# ...and the retry lands once the recorder is there.
hass.config.components.add("recorder")
with patch(RELABEL, autospec=True) as relabel:
assert await async_migrate_entry(hass, entry) is True
assert len(relabel.call_args_list) == 1
assert entry.version == 3
async def test_omits_unit_class_on_an_older_home_assistant(hass: HomeAssistant) -> None:
"""`new_unit_class` only exists from HA 2025.11, and hacs.json still
declares 2025.1 as the minimum. Passing it to the older signature is a
TypeError out of async_migrate_entry, which fails the whole entry -- so
the kwarg is feature-detected rather than assumed.
Stands in for an older HA by patching in that exact signature; the
autospec'd tests above cover the modern one.
"""
entry = _entry(hass, "air_purifier")
_add_sensor(hass, entry, "dust")
seen: list[dict] = []
def old_signature(hass, statistic_id, *, new_statistic_id=None, **kwargs):
seen.append(kwargs)
with patch(RELABEL, old_signature):
assert await async_migrate_entry(hass, entry) is True
assert seen == [{"new_unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}]
assert entry.version == 3
async def test_a_relabel_failure_never_fails_the_entry(hass: HomeAssistant) -> None:
"""Relabelling is a convenience -- without it the user gets HA's own
units_changed repair, which is where they were before. An older HA whose
async_update_statistics_metadata has a different signature, or any other
recorder-side surprise, must not cost them the integration."""
entry = _entry(hass, "air_purifier")
_add_sensor(hass, entry, "dust")
with patch(RELABEL, autospec=True, side_effect=TypeError("older HA signature")):
assert await async_migrate_entry(hass, entry) is True
assert entry.version == 3
@@ -130,7 +179,7 @@ async def test_follows_a_renamed_entity_rather_than_rebuilding_its_id(
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:
with patch(RELABEL, autospec=True) 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"]
@@ -138,10 +187,11 @@ async def test_follows_a_renamed_entity_rather_than_rebuilding_its_id(
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."""
match is on the tail rather than the whole unique_id. The instance form
is `_<n>` (discovery.instance_suffix), not a bare digit."""
entry = _entry(hass, "air_purifier")
ent_reg = er.async_get(hass)
for unique_suffix in ("indoor_0_dust", "fine_dust1", "super_fine_dust"):
for unique_suffix in ("indoor_0_dust", "fine_dust_1", "super_fine_dust"):
ent_reg.async_get_or_create(
"sensor",
DOMAIN,
@@ -157,7 +207,7 @@ async def test_matches_subdevice_prefixed_and_instanced_keys(hass: HomeAssistant
config_entry=entry,
)
with patch(RELABEL) as relabel:
with patch(RELABEL, autospec=True) as relabel:
assert await async_migrate_entry(hass, entry) is True
assert len(relabel.call_args_list) == 3