diff --git a/custom_components/localthings/registry/capabilities/air_monitor.py b/custom_components/localthings/registry/capabilities/air_monitor.py index 0d7f7ad..92877ef 100644 --- a/custom_components/localthings/registry/capabilities/air_monitor.py +++ b/custom_components/localthings/registry/capabilities/air_monitor.py @@ -31,6 +31,12 @@ from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc, TimeDesc from .air_purifier import _AIR_QUALITY_SENSORS from .common import int_or_none, sensor_item_value +# _AIR_QUALITY_SENSORS' fourth column (state_class) is deliberately discarded +# here: air_purifier leaves Odor/CleanLevel unstamped because they read as +# graded indices on that family, while this board has stamped all five as +# `measurement` since it was added (issue #210). Consuming the column would +# silently drop long-term statistics for two sensors on shipped devices, so +# the shared rows supply only the key/icon/type here. SENSORS = Capability( href="/sensors/vs/0", poll_tier="warm", @@ -43,7 +49,7 @@ SENSORS = Capability( state_class="measurement", 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, _ in _AIR_QUALITY_SENSORS ), SensorDesc( key="co2", diff --git a/custom_components/localthings/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py index a123baa..ff2e4da 100644 --- a/custom_components/localthings/registry/capabilities/air_purifier.py +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -51,12 +51,31 @@ 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"), - ("super_fine_dust", "mdi:blur", "SuperFineDust"), - ("odor", "mdi:scent", "Odor"), - ("clean_level", "mdi:air-filter", "CleanLevel"), + ("dust", "mdi:blur", "Dust", "measurement"), + ("fine_dust", "mdi:blur", "FineDust", "measurement"), + ("super_fine_dust", "mdi:blur", "SuperFineDust", "measurement"), + ("odor", "mdi:scent", "Odor", None), + ("clean_level", "mdi:air-filter", "CleanLevel", None), ) AIR_QUALITY = Capability( @@ -67,9 +86,10 @@ AIR_QUALITY = Capability( key=key, field="x.com.samsung.da.items", icon=icon, + state_class=state_class, 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 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..48c97b0 --- /dev/null +++ b/tests/test_air_purifier_air_quality_statistics.py @@ -0,0 +1,76 @@ +"""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_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_state_class_comes_from_the_shared_tuples_fourth_column(): + """The rows carry their own state_class rather than a parallel lookup, so + a new sensor can't be added here without deciding the question.""" + for row in air_purifier._AIR_QUALITY_SENSORS: + assert len(row) == 4, row + assert row[3] in ("measurement", None), row + + +def test_air_monitor_keeps_stamping_every_shared_sensor(): + """air_monitor imports _AIR_QUALITY_SENSORS and discards the fourth column + on purpose: 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 + + +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)