fix(air_purifier): record long-term statistics for the particulate sensors

dust / fine_dust / super_fine_dust show live values fine but Home Assistant
keeps no long-term statistics for them, so once recorder's purge window passes
(10 days by default) the history is gone and they can't back a long-range
air-quality graph.

HA only writes long-term statistics for sensors that declare a state_class,
and AIR_QUALITY's descriptors set none -- the entities come up carrying just
an icon. The values were never the problem: common.sensor_item_value already
returns int. Three sensors in this same module (filter_progress,
fan_speed_level, hepa_filter_usage) already declare one, so this reads as an
oversight rather than a decision.

Only the three particulate readings are stamped. They fall monotonically with
particle size on three independent board families -- 11/9/5 on ARTIK051_TVTL
(issue #56), 10/9/6 on AVT-WW-TP1 (issue #190), 18/14/9 on the range hood --
which is concentration behaviour, and averaging it over time is meaningful.
Odor and CleanLevel read 0-2 on every fixture and look like graded indices,
where the mean of a grade isn't obviously meaningful, so they are left alone
rather than guessed into statistics.

Worth flagging for the review: air_monitor.SENSORS already stamps all five of
these, and its module docstring describes that as "matching
air_purifier.AIR_QUALITY's existing precedent" -- a precedent this module did
not actually set. Extending to all five here is a one-line change if
consistency is preferred over the grade/concentration split.

The state_class is carried in a separate key set rather than a fourth tuple
column because air_monitor.py imports _AIR_QUALITY_SENSORS and unpacks it as a
triple; widening it breaks that module's import outright. Two of the new tests
guard exactly that coupling.

No device_class or unit is asserted: pm1/pm25/pm10 with µg/m³ would claim the
reading is a mass concentration, which no dump states. That is a separate call
from making the series recordable at all.

Metadata only -- no key, name, value or unit changes, so no entity changes
identity and every golden is untouched. Statistics start accumulating from the
upgrade onward; existing short-term history is unaffected.
This commit is contained in:
kkqq9320 committed 2026-08-03 14:34:57 +09:00
1 parent 51103fa341
commit 69844d9829
2 files changed
+109

No files matched your search

@@ -87,6 +87,25 @@ def _has_top_level_modes(rep, resources):
return isinstance(rep.get("x.com.samsung.da.supportedModes"), (list, tuple))
# The fourth column is state_class, which is what makes Home Assistant keep
# long-term statistics for a sensor -- without one, a reading is only in the
# short-term recorder history and disappears with the next purge (10 days by
# default), so it can't back a long-range air-quality graph. The values are
# already numeric (sensor_item_value returns int), so nothing else was in the
# way; three sensors in this same module (filter_progress, fan_speed_level,
# hepa_filter_usage) already declare one.
#
# Only the three particulate readings get it. They fall monotonically with
# particle size on three independent board families -- 11/9/5 on ARTIK051_TVTL
# (issue #56), 10/9/6 on AVT-WW-TP1 (issue #190), 18/14/9 on the range hood --
# which is concentration behaviour, and an average over time is meaningful for
# it. Odor and CleanLevel read 0-2 on every fixture and look like graded
# indices instead, where the mean of a grade isn't obviously meaningful; left
# without a state_class rather than guessing.
#
# Deliberately no device_class/unit here: pm1/pm25/pm10 would assert the
# reading is a µg/m³ concentration, and the dumps never say so. That's a
# separate call from making the series recordable at all.
_AIR_QUALITY_SENSORS = (
("dust", "mdi:blur", "Dust"),
("fine_dust", "mdi:blur", "FineDust"),
@@ -95,6 +114,30 @@ _AIR_QUALITY_SENSORS = (
("clean_level", "mdi:air-filter", "CleanLevel"),
)
# Sensors that get a state_class, which is what makes Home Assistant keep
# long-term statistics -- without one a reading only lives in the short-term
# recorder history and is dropped at the next purge (10 days by default), so it
# can't back a long-range air-quality graph. Kept as a key set rather than a
# fourth column because air_monitor.py imports the tuple above and unpacks it
# as a triple.
#
# Only the three particulate readings are listed. They fall monotonically with
# particle size on three independent board families -- 11/9/5 on ARTIK051_TVTL
# (issue #56), 10/9/6 on AVT-WW-TP1 (issue #190), 18/14/9 on the range hood --
# which is concentration behaviour, and an average over time is meaningful for
# it. Odor and CleanLevel read 0-2 on every fixture and look like graded
# indices instead, where the mean of a grade isn't obviously meaningful.
#
# Note air_monitor.SENSORS does stamp all five, and its docstring says it is
# "matching air_purifier.AIR_QUALITY's existing precedent" -- a precedent this
# module did not actually set. Extending to all five here is a one-line change
# if consistency is preferred over the grade/concentration split.
#
# Deliberately no device_class/unit: pm1/pm25/pm10 would assert the reading is
# a µg/m³ concentration, and no dump says so. That is a separate call from
# making the series recordable at all.
_RECORDED_AIR_QUALITY = frozenset({"dust", "fine_dust", "super_fine_dust"})
AIR_QUALITY = Capability(
href="/sensors/vs/0",
poll_tier="warm",
@@ -103,6 +146,7 @@ AIR_QUALITY = Capability(
key=key,
field="x.com.samsung.da.items",
icon=icon,
state_class="measurement" if key in _RECORDED_AIR_QUALITY else None,
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
)
for key, icon, sensor_type in _AIR_QUALITY_SENSORS
@@ -0,0 +1,65 @@
"""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
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_no_unit_or_device_class_is_asserted():
"""state_class alone makes the series recordable. pm1/pm25/pm10 with
µg/m³ would additionally assert the reading is a mass concentration,
which no dump states."""
for key in PARTICULATE + GRADED:
desc = _desc(key)
assert desc.unit is None, key
assert desc.device_class is None, key
def test_shared_sensor_tuple_keeps_its_three_column_shape():
"""air_monitor.py imports _AIR_QUALITY_SENSORS and unpacks it as a triple,
so widening the tuple here breaks that module's import outright."""
for row in air_purifier._AIR_QUALITY_SENSORS:
assert len(row) == 3, row
def test_air_monitor_still_imports():
"""Guard the coupling above end to end, not just by row width."""
from custom_components.localthings.registry.capabilities import air_monitor
assert air_monitor.SENSORS.href == "/sensors/vs/0"
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)