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:
@@ -3,8 +3,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
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.const import EVENT_HOMEASSISTANT_STOP
|
||||||
from homeassistant.core import Event, HomeAssistant, callback
|
from homeassistant.core import Event, HomeAssistant, callback
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
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 import entity_registry as er
|
||||||
from homeassistant.helpers.typing import ConfigType
|
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 .coordinator import LocalThingsCoordinator
|
||||||
from .registry.identity import resolve_serial
|
from .registry.identity import resolve_serial
|
||||||
from .services import async_setup_services
|
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:
|
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Migrate an entry to the current version.
|
"""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
|
can key its registry entries before the first poll (issue #236), and
|
||||||
repairs whatever the old placeholder-keyed registration already
|
repairs whatever the old placeholder-keyed registration already
|
||||||
orphaned.
|
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
|
return False # downgrade: this release doesn't know the newer shape
|
||||||
|
|
||||||
if entry.version == 1:
|
if entry.version == 1:
|
||||||
@@ -147,6 +229,11 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
_repair_placeholder_keys(hass, entry, serial)
|
_repair_placeholder_keys(hass, entry, serial)
|
||||||
_LOGGER.debug("migrated entry %s to version 2 (serial=%s)", entry.entry_id, 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
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -793,7 +793,10 @@ def _probe_and_validate(
|
|||||||
|
|
||||||
|
|
||||||
class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
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:
|
def __init__(self) -> None:
|
||||||
self._host: str = ""
|
self._host: str = ""
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "localthings",
|
"domain": "localthings",
|
||||||
"name": "LocalThings",
|
"name": "LocalThings",
|
||||||
|
"after_dependencies": ["recorder"],
|
||||||
"codeowners": ["@mbillow"],
|
"codeowners": ["@mbillow"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"dependencies": [],
|
"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
|
PM mapping, though: 23 grading one step above the floor as FineDust is
|
||||||
what rules out a PM10-width band for that field.
|
what rules out a PM10-width band for that field.
|
||||||
|
|
||||||
Dust/FineDust/SuperFineDust nevertheless stay without an HA
|
Dust/FineDust/SuperFineDust carry the same HA `device_class`/`unit` as the
|
||||||
`device_class`/`unit` here, which is now a migration call rather than an
|
purifier family (issue #325, Dust=PM10 / FineDust=PM2.5 /
|
||||||
evidence gap. The mapping confirmed for the purifier family (issue #325,
|
SuperFineDust=PM1 in μg/m³). The mapping rests on device-side grading this
|
||||||
Dust=PM10 / FineDust=PM2.5 / SuperFineDust=PM1 in μg/m³) rests on
|
board shares rather than on anything purifier-specific, so typing one
|
||||||
device-side grading that this board shares, so it would carry over. But
|
family and not the other would have been an inconsistency, not caution.
|
||||||
these five sensors have recorded unitless long-term statistics since
|
|
||||||
issue #210, and stamping a unit onto an existing statistic is what raises
|
These sensors have recorded *unitless* long-term statistics since issue
|
||||||
Home Assistant's "units changed" repair -- a deliberate follow-up, not
|
#210, though, and Home Assistant suppresses statistics generation outright
|
||||||
something to fold into the purifier's first typed release.
|
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
|
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 .air_purifier import _AIR_QUALITY_SENSORS
|
||||||
from .common import int_or_none, sensor_item_value
|
from .common import int_or_none, sensor_item_value
|
||||||
|
|
||||||
# Extra columns (state_class, device_class, unit) are deliberately discarded
|
# device_class/unit are taken from the shared rows; state_class deliberately
|
||||||
# here: air_purifier leaves Odor/CleanLevel unstamped because they read as
|
# 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
|
# graded indices on that family, while this board has stamped all five as
|
||||||
# `measurement` since it was added (issue #210). Consuming state_class would
|
# `measurement` since it was added (issue #210) -- consuming that column
|
||||||
# silently drop long-term statistics for two sensors on shipped devices, and
|
# would silently drop long-term statistics for two sensors on shipped
|
||||||
# the pm10/pm25/pm1 labels are held back pending the statistics migration
|
# devices. The pm10/pm25/pm1 labels carry over cleanly, though: they rest on
|
||||||
# the module docstring describes. The shared rows supply only the
|
# device-side grading this board shares (see the module docstring), and
|
||||||
# key/icon/type here.
|
# __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(
|
SENSORS = Capability(
|
||||||
href="/sensors/vs/0",
|
href="/sensors/vs/0",
|
||||||
poll_tier="warm",
|
poll_tier="warm",
|
||||||
@@ -54,9 +57,11 @@ SENSORS = Capability(
|
|||||||
field="x.com.samsung.da.items",
|
field="x.com.samsung.da.items",
|
||||||
icon=icon,
|
icon=icon,
|
||||||
state_class="measurement",
|
state_class="measurement",
|
||||||
|
device_class=device_class,
|
||||||
|
unit=unit,
|
||||||
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
|
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(
|
SensorDesc(
|
||||||
key="co2",
|
key="co2",
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ async def test_migration_recovers_serial_from_unique_id(
|
|||||||
await hass.config_entries.async_setup(entry.entry_id)
|
await hass.config_entries.async_setup(entry.entry_id)
|
||||||
await hass.async_block_till_done()
|
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
|
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."""
|
written by a newer release."""
|
||||||
from custom_components.localthings import async_migrate_entry
|
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)
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
assert await async_migrate_entry(hass, entry) is False
|
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
|
assert (row[4] is None) == (row[5] is None), row
|
||||||
|
|
||||||
|
|
||||||
def test_air_monitor_keeps_stamping_every_shared_sensor():
|
def test_air_monitor_takes_the_pm_labels_but_not_the_state_class():
|
||||||
"""air_monitor imports _AIR_QUALITY_SENSORS and discards the extra columns
|
"""air_monitor imports _AIR_QUALITY_SENSORS and consumes device_class and
|
||||||
on purpose: that board (issue #210) has stamped all five as `measurement`
|
unit -- the mapping rests on device-side grading that board shares (issue
|
||||||
since it was added, and consuming state_class would silently drop
|
#325), so typing one family and not the other would be an inconsistency.
|
||||||
long-term statistics for Odor/CleanLevel there. Guards the import end to
|
|
||||||
end and the deliberate divergence together."""
|
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
|
from custom_components.localthings.registry.capabilities import air_monitor
|
||||||
|
|
||||||
assert air_monitor.SENSORS.href == "/sensors/vs/0"
|
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)
|
d for d in air_monitor.SENSORS.entities if d.key == key and isinstance(d, SensorDesc)
|
||||||
)
|
)
|
||||||
assert desc.state_class == "measurement", key
|
assert desc.state_class == "measurement", key
|
||||||
assert desc.device_class is None, key
|
assert desc.device_class == _desc(key).device_class, key
|
||||||
assert desc.unit is None, 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():
|
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
|
||||||
Reference in New Issue
Block a user