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.
109 lines
4.6 KiB
Python
109 lines
4.6 KiB
Python
"""The particulate sensors must declare a state_class so Home Assistant keeps
|
|
long-term statistics for them; the graded readings must not.
|
|
|
|
Without a state_class a sensor only lives in the short-term recorder history
|
|
and is dropped at the next purge, so a long-range air-quality graph is not
|
|
possible -- that is the bug this guards against reappearing.
|
|
"""
|
|
|
|
from custom_components.localthings.registry.capabilities import air_purifier
|
|
from custom_components.localthings.registry.entities import SensorDesc
|
|
|
|
PARTICULATE = ("dust", "fine_dust", "super_fine_dust")
|
|
GRADED = ("odor", "clean_level")
|
|
|
|
|
|
def _desc(key):
|
|
return next(d for d in air_purifier.AIR_QUALITY.entities if d.key == key)
|
|
|
|
|
|
def test_particulate_sensors_record_long_term_statistics():
|
|
for key in PARTICULATE:
|
|
assert _desc(key).state_class == "measurement", key
|
|
|
|
|
|
def test_graded_sensors_are_left_without_a_state_class():
|
|
"""Odor and CleanLevel read 0-2 on every fixture -- graded indices, not
|
|
concentrations. Whether averaging a grade is meaningful is a separate
|
|
call, so they stay unstamped rather than being guessed into statistics."""
|
|
for key in GRADED:
|
|
assert _desc(key).state_class is None, key
|
|
|
|
|
|
def test_particulate_sensors_declare_pm_device_class_and_unit():
|
|
"""Dust/FineDust/SuperFineDust map to PM10/PM2.5/PM1 (issue #325) -- see
|
|
air_purifier._AIR_QUALITY_SENSORS for the three lines of evidence and
|
|
tests/test_air_quality_grade_column.py for the device-side ones.
|
|
|
|
The expected unit comes from HA's own constant rather than a literal:
|
|
typing it out is how PR #365 landed U+00B5 MICRO SIGN where HA uses
|
|
U+03BC, which renders identically and would make this test agree with
|
|
the bug."""
|
|
from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER as UG_M3
|
|
|
|
expected = {
|
|
"dust": ("pm10", UG_M3),
|
|
"fine_dust": ("pm25", UG_M3),
|
|
"super_fine_dust": ("pm1", UG_M3),
|
|
}
|
|
for key, (device_class, unit) in expected.items():
|
|
desc = _desc(key)
|
|
assert desc.device_class == device_class, key
|
|
assert desc.unit == unit, key
|
|
for key in GRADED:
|
|
desc = _desc(key)
|
|
assert desc.device_class is None, key
|
|
assert desc.unit is None, key
|
|
|
|
|
|
def test_metadata_comes_from_the_shared_tuples_own_columns():
|
|
"""The rows carry their own state_class/device_class/unit rather than a
|
|
parallel lookup, so a new sensor can't be added here without deciding
|
|
each question. Unit validity against HA is a separate guard --
|
|
tests/test_sensor_device_class_units.py."""
|
|
for row in air_purifier._AIR_QUALITY_SENSORS:
|
|
assert len(row) == 6, row
|
|
assert row[3] in ("measurement", None), row
|
|
assert row[4] in ("pm10", "pm25", "pm1", None), row
|
|
# A device_class without a unit would leave HA inferring one.
|
|
assert (row[4] is None) == (row[5] is None), row
|
|
|
|
|
|
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"
|
|
for key in PARTICULATE + GRADED:
|
|
desc = next(
|
|
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 == _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():
|
|
"""A state_class is only honoured for a numeric state, so the value
|
|
contract this depends on is asserted here too."""
|
|
from tests.conftest import _load_device
|
|
|
|
resources = _load_device("air_purifier")
|
|
rep = resources["/sensors/vs/0"]
|
|
for key in PARTICULATE + GRADED:
|
|
value = _desc(key).value_fn(rep["x.com.samsung.da.items"])
|
|
assert isinstance(value, int), (key, value)
|