diff --git a/custom_components/localthings/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py index 5b86e91..b497628 100644 --- a/custom_components/localthings/registry/capabilities/air_purifier.py +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -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 diff --git a/tests/test_air_purifier_air_quality_statistics.py b/tests/test_air_purifier_air_quality_statistics.py new file mode 100644 index 0000000..74376fa --- /dev/null +++ b/tests/test_air_purifier_air_quality_statistics.py @@ -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)