From 69844d9829b83a726983eb3c1dc0b510b6f9b679 Mon Sep 17 00:00:00 2001 From: kkqq9320 Date: Mon, 3 Aug 2026 14:34:57 +0900 Subject: [PATCH 1/2] fix(air_purifier): record long-term statistics for the particulate sensors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../registry/capabilities/air_purifier.py | 44 +++++++++++++ ...est_air_purifier_air_quality_statistics.py | 65 +++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 tests/test_air_purifier_air_quality_statistics.py 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) From 15279066b5b2ce6eda790beefb2a0b54ffffce10 Mon Sep 17 00:00:00 2001 From: kkqq9320 Date: Tue, 4 Aug 2026 15:25:12 +0900 Subject: [PATCH 2/2] review: move the state_class into the shared tuple's fourth column The frozenset was a parallel structure for a per-row fact, and the comment above the tuple already described it as a fourth column -- so the comment promised the right shape and the code did something else. Fixed to the shape the comment described: _AIR_QUALITY_SENSORS carries state_class per row and the comprehension unpacks it, with _RECORDED_AIR_QUALITY and its duplicated rationale block deleted. air_monitor imports the same rows and now unpacks four, but discards the fourth. That board (issue #210) has stamped all five readings as `measurement` since it was added; consuming the column would silently drop long-term statistics for Odor and CleanLevel on shipped devices, which is a behaviour change this branch has no evidence to make. The grade/concentration split stays scoped to the air purifier. test_shared_sensor_tuple_keeps_its_three_column_shape asserted the premise this replaces -- that widening the tuple breaks air_monitor's import -- so it is replaced rather than renumbered: one test that the rows carry their own state_class, and one that air_monitor still imports and still stamps all five. --- .../registry/capabilities/air_monitor.py | 8 +++- .../registry/capabilities/air_purifier.py | 38 ++++--------------- ...est_air_purifier_air_quality_statistics.py | 23 ++++++++--- 3 files changed, 31 insertions(+), 38 deletions(-) diff --git a/custom_components/localthings/registry/capabilities/air_monitor.py b/custom_components/localthings/registry/capabilities/air_monitor.py index 451ef43..0ed9aa8 100644 --- a/custom_components/localthings/registry/capabilities/air_monitor.py +++ b/custom_components/localthings/registry/capabilities/air_monitor.py @@ -39,6 +39,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", @@ -51,7 +57,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 b497628..8490d8c 100644 --- a/custom_components/localthings/registry/capabilities/air_purifier.py +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -107,37 +107,13 @@ def _has_top_level_modes(rep, resources): # 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), ) -# 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", @@ -146,10 +122,10 @@ AIR_QUALITY = Capability( key=key, field="x.com.samsung.da.items", icon=icon, - state_class="measurement" if key in _RECORDED_AIR_QUALITY else None, + 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 index 74376fa..48c97b0 100644 --- a/tests/test_air_purifier_air_quality_statistics.py +++ b/tests/test_air_purifier_air_quality_statistics.py @@ -7,6 +7,7 @@ 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") @@ -39,18 +40,28 @@ def test_no_unit_or_device_class_is_asserted(): 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.""" +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) == 3, row + assert len(row) == 4, row + assert row[3] in ("measurement", None), row -def test_air_monitor_still_imports(): - """Guard the coupling above end to end, not just by row width.""" +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():