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:
3 files changed
+114
-46
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user