From 627b761462b4c76dc5161a875e0fbbe87df503d5 Mon Sep 17 00:00:00 2001 From: JayChickenK <19538983+JayChickenK@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:32:07 +0200 Subject: [PATCH 1/4] washer: add 0A/B0 cycle labels; purifier: PM device classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Table_02 codes 0A (Towels) and B0 (Mixed load) were reported for a WW90DG5G34ABLE (issue #363). Air-purifier Dust/FineDust/SuperFineDust map to PM10/PM2.5/PM1 in µg/m³ from a same-moment SmartThings correlation (issue #325). --- .../registry/capabilities/air_monitor.py | 24 ++++++------- .../registry/capabilities/air_purifier.py | 35 ++++++++++--------- .../registry/capabilities/washer.py | 8 +++-- .../localthings/translations/cs.json | 2 ++ .../localthings/translations/de.json | 2 ++ .../localthings/translations/en.json | 4 ++- .../localthings/translations/es.json | 2 ++ .../localthings/translations/it.json | 4 ++- .../localthings/translations/ko.json | 2 ++ .../localthings/translations/nl.json | 2 ++ ...est_air_purifier_air_quality_statistics.py | 33 +++++++++++------ 11 files changed, 74 insertions(+), 44 deletions(-) diff --git a/custom_components/localthings/registry/capabilities/air_monitor.py b/custom_components/localthings/registry/capabilities/air_monitor.py index 92877ef..e6dd719 100644 --- a/custom_components/localthings/registry/capabilities/air_monitor.py +++ b/custom_components/localthings/registry/capabilities/air_monitor.py @@ -15,13 +15,12 @@ A second `value` list element on the particulate-matter types (e.g. Dust's board confirms what its scale means -- left unbound rather than guessed; index 0 is the only slot any family has ever read. -Dust/FineDust/SuperFineDust aren't assigned an HA `device_class` -(pm10/pm25/pm1) or `unit` despite reading like plausible ug/m3 particulate -values: Samsung's own two-tier Korean convention maps only to a PM10/PM2.5 -pair, and this board's three-tier naming doesn't confirm where the extra -tier or a PM1 reading fits. A wrong guess would silently mislabel every -reading forever, so they're plain `measurement` sensors named after the -device's own field instead, matching air_purifier.AIR_QUALITY's precedent. +Dust/FineDust/SuperFineDust stay without an HA `device_class`/`unit` on +this board. The three-tier mapping (Dust=PM10, FineDust=PM2.5, +SuperFineDust=PM1, µg/m³) is confirmed for the air-purifier family +(issue #325) but this standalone monitor has no same-moment app +correlation of its own, so it keeps the untyped measurement sensors +rather than inheriting that label. """ from datetime import time as dt_time @@ -31,12 +30,13 @@ 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 +# Extra columns (state_class, device_class, unit) are 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. +# `measurement` since it was added (issue #210). Consuming state_class would +# silently drop long-term statistics for two sensors on shipped devices, and +# the pm10/pm25/pm1 labels are confirmed only for the purifier family +# (issue #325). The shared rows supply only the key/icon/type here. SENSORS = Capability( href="/sensors/vs/0", poll_tier="warm", @@ -49,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 6949d2b..ada0709 100644 --- a/custom_components/localthings/registry/capabilities/air_purifier.py +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -51,13 +51,13 @@ 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. +# Columns: key, icon, device item type, state_class, device_class, unit. +# state_class is what makes Home Assistant keep long-term statistics -- +# 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); 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 @@ -67,15 +67,16 @@ def _has_top_level_modes(rep, resources): # 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. +# device_class/unit confirmed on a live ARTIK051_TVTL against the SmartThings +# app at the same moment (issue #325): Dust=PM10, FineDust=PM2.5, +# SuperFineDust=PM1, all µg/m³. Dust matched PM10 exactly; the other two +# were 1 µg/m³ off the app, same order. _AIR_QUALITY_SENSORS = ( - ("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), + ("dust", "mdi:blur", "Dust", "measurement", "pm10", "µg/m³"), + ("fine_dust", "mdi:blur", "FineDust", "measurement", "pm25", "µg/m³"), + ("super_fine_dust", "mdi:blur", "SuperFineDust", "measurement", "pm1", "µg/m³"), + ("odor", "mdi:scent", "Odor", None, None, None), + ("clean_level", "mdi:air-filter", "CleanLevel", None, None, None), ) AIR_QUALITY = Capability( @@ -87,9 +88,11 @@ AIR_QUALITY = Capability( field="x.com.samsung.da.items", icon=icon, state_class=state_class, + device_class=device_class, + unit=unit, value_fn=lambda items, t=sensor_type: sensor_item_value(items, t), ) - for key, icon, sensor_type, state_class in _AIR_QUALITY_SENSORS + for key, icon, sensor_type, state_class, device_class, unit in _AIR_QUALITY_SENSORS ), ) diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index 81be9d8..cf479d2 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -39,9 +39,11 @@ from .laundry import ( # editCourseList and screenshots (issue #22, a combo's own course set, not # implying anything about a plain washer's '1F'); 3 more (Eco Cold, Towels, # Self Clean+) verified directly on a WF50A8600AV/US by reading back the raw -# code after selecting each cycle on the appliance (issue #80). Two code -# pairs ('21'/'65' Colors, '27'/'5E' Rinse+Spin, and '24'/'54' Towels) -# legitimately share a label across different course tables -- not typos. +# code after selecting each cycle on the appliance (issue #80). 2 more +# ('0A' Towels, 'B0' Mixed load) reported for a WW90DG5G34ABLE on the same +# Table_02 family (issue #363). Two code pairs ('21'/'65' Colors, +# '27'/'5E' Rinse+Spin, and '24'/'54' Towels) legitimately share a label +# across different course tables -- not typos. # # No static fallback list is kept here: other models have different actual # course sets, so hardcoding one device's list would show/hide the wrong diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index 49b8ad4..261a98c 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -646,6 +646,7 @@ "04": "Rychlé praní", "06": "XXL prádlo", "08": "Máchání+odstřeďování", + "0a": "Ručníky", "17": "Stažený program", "1b": "Bavlna", "1c": "Eco 40-60", @@ -712,6 +713,7 @@ "8f": "Intenzivní studená", "96": "Méně mikrovláken", "a0": "15min rychlé praní", + "b0": "Smíšená náplň", "35": "Eko bavlna" } }, diff --git a/custom_components/localthings/translations/de.json b/custom_components/localthings/translations/de.json index 92f50be..a14396a 100644 --- a/custom_components/localthings/translations/de.json +++ b/custom_components/localthings/translations/de.json @@ -609,6 +609,7 @@ "04": "Schnellwäsche", "06": "XXL-Wäsche", "08": "Spülen + Schleudern", + "0a": "Handtücher", "1b": "Baumwolle", "1c": "Eco 40-60", "1d": "Super Speed", @@ -656,6 +657,7 @@ "8f": "Kaltwäsche Intensiv", "96": "Weniger Mikrofasern", "a0": "Schnelle Wäsche 15'", + "b0": "Mischwäsche", "17": "Heruntergeladen", "69": "KI-Wäsche", "6a": "Wolle", diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 0b3405a..7c76b55 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -646,6 +646,7 @@ "04": "Quick Wash", "06": "XXL Laundry", "08": "Rinse+Spin", + "0a": "Towels", "17": "Downloaded", "1b": "Cotton", "1c": "Eco 40-60", @@ -712,7 +713,8 @@ "88": "Pet Care", "8f": "Intense Cold", "96": "Less Microfiber", - "a0": "15' Quick Wash" + "a0": "15' Quick Wash", + "b0": "Mixed load" } }, "washer_dry_level": { diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index dba8a3b..f9ff0c5 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -842,6 +842,7 @@ "04": "Lavado rápido", "06": "Colada XXL", "08": "Aclarar + Centrifugar", + "0a": "Toallas", "17": "Descargado", "1b": "Algodón", "1c": "Eco 40-60", @@ -890,6 +891,7 @@ "8f": "Lavado en frío", "96": "Menos microfibras", "a0": "Lavado rápido 15'", + "b0": "Carga mixta", "69": "Lavado IA", "6a": "Lana", "6b": "Denim", diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index 45116c7..84afe6d 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -646,6 +646,7 @@ "04": "Lavaggio rapido", "06": "Bucato XXL", "08": "Risciacquo+Centrifuga", + "0a": "Asciugamani", "17": "Scaricato", "1b": "Cotone", "1c": "Eco 40-60", @@ -712,7 +713,8 @@ "79": "Solo centrifuga", "88": "Cura animali", "35": "Cotone E", - "a0": "Rapido 15'" + "a0": "Rapido 15'", + "b0": "Carico misto" } }, "washer_dry_level": { diff --git a/custom_components/localthings/translations/ko.json b/custom_components/localthings/translations/ko.json index f051c67..b419826 100644 --- a/custom_components/localthings/translations/ko.json +++ b/custom_components/localthings/translations/ko.json @@ -646,6 +646,7 @@ "04": "쾌속세탁", "06": "XXL 세탁", "08": "헹굼+탈수", + "0a": "타월", "17": "다운로드 코스", "1b": "면", "1c": "에코 40-60", @@ -694,6 +695,7 @@ "8f": "강력 냉수 세탁", "96": "미세플라스틱저감", "a0": "15분 쾌속세탁", + "b0": "혼합", "69": "AI 맞춤세탁", "6a": "울", "6b": "데님", diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index c570311..f11b950 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -646,6 +646,7 @@ "04": "Snelle was", "06": "XXL was", "08": "Spoelen+centrifugeren", + "0a": "Handdoeken", "17": "Gedownload", "1b": "Katoen", "1c": "Eco 40-60", @@ -712,6 +713,7 @@ "8f": "Intensief koud", "96": "Minder microvezels", "a0": "15' Snelle was", + "b0": "Gemengde was", "35": "Eco katoen" } }, diff --git a/tests/test_air_purifier_air_quality_statistics.py b/tests/test_air_purifier_air_quality_statistics.py index 48c97b0..2669d99 100644 --- a/tests/test_air_purifier_air_quality_statistics.py +++ b/tests/test_air_purifier_air_quality_statistics.py @@ -30,30 +30,39 @@ def test_graded_sensors_are_left_without_a_state_class(): 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: +def test_particulate_sensors_declare_pm_device_class_and_unit(): + """Live same-moment SmartThings correlation on ARTIK051_TVTL (issue #325) + maps Dust/FineDust/SuperFineDust to PM10/PM2.5/PM1 in µg/m³.""" + expected = { + "dust": ("pm10", "µg/m³"), + "fine_dust": ("pm25", "µg/m³"), + "super_fine_dust": ("pm1", "µg/m³"), + } + 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.unit is None, key assert desc.device_class is None, key + assert desc.unit 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 len(row) == 6, row assert row[3] in ("measurement", None), row + assert row[4] in ("pm10", "pm25", "pm1", None), row def test_air_monitor_keeps_stamping_every_shared_sensor(): - """air_monitor imports _AIR_QUALITY_SENSORS and discards the fourth column + """air_monitor imports _AIR_QUALITY_SENSORS and discards the extra columns 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.""" + since it was added, and consuming state_class 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" @@ -62,6 +71,8 @@ def test_air_monitor_keeps_stamping_every_shared_sensor(): 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 is None, key + assert desc.unit is None, key def test_every_air_quality_sensor_still_reads_a_plain_int(): From 21af5708cb687584e7ab6dda023af25ce2ad5b33 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Sat, 15 Aug 2026 00:23:53 +0000 Subject: [PATCH 2/4] Fix PM unit codepoint and document the /sensors/vs/0 grade column Review follow-up to PR #365, which landed the washer 0A/B0 labels and the air-purifier PM device classes. The three particulate units were spelled with U+00B5 MICRO SIGN. Home Assistant's DEVICE_CLASS_UNITS holds only the U+03BC GREEK SMALL LETTER MU spelling, so every purifier logged a per-entity "not a valid unit for the device class" warning asking the user to file a bug against us. The two characters render identically, and the PR's own test hardcoded the wrong one, so the test agreed with the bug. That test now takes the expected unit from HA's own constant, and a new registry-wide guard (test_sensor_device_class_units.py, mirroring the SwitchDesc guard from issue #349) checks every SensorDesc unit against HA -- these were the only three invalid pairs among 29. Getting this in before release matters more than usual: the recorder writes unit_of_measurement into long-term statistics, so correcting it afterwards would raise a "units changed" repair for anyone who had run the released version. Also settles what the second element of a dust reading's value[] is, which was the open question behind issue #325's request for another dump. It is the device's own graded air-quality level: it appears only on the fields carrying a magnitude (Dust/FineDust/SuperFineDust/CO2) and not on Odor/CleanLevel, which are grades already; it reads 0-2 against index 0's 0-31; and CleanLevel equals the highest per-field grade on 9 of the 11 fixtures reporting the resource. It stays unbound -- ARTIK051_TVTL grades good air as 0 while every other family uses 1, so a shared descriptor would need a per-family offset -- but it is what confirms the PM mapping without relying on field names: 18 grades one step above the floor as SuperFineDust yet sits at the floor as Dust, on two families that both floor at 1, so the firmware itself treats the three fields as different scales ordered coarse-to-fine. Each field's floor boundary also brackets the Korean CAI band for its tier (PM10 at 30/31, PM2.5 at 15/16). Pinned against the shipped fixtures in test_air_quality_grade_column.py. air_monitor keeps its untyped sensors, but the docstring now gives the real reason: the evidence carries over, and what is deliberately deferred is the statistics migration for entities shipped unitless since issue #210. Smaller fixes: en.json's "Mixed load" -> "Mixed Load" to match the catalog's title casing and issue #363's own wording; de/ko gave B0 the same string as the existing "34" Mixed, so a machine exposing both showed two identical options; washer.py's shared-label list still said "'24' Towels", which went stale when issue #343 found 24/33 transposed; and 0A/B0 now have a locale-wide translation guard like every other confirmed code batch. --- .../registry/capabilities/air_monitor.py | 31 ++-- .../registry/capabilities/air_purifier.py | 38 ++++- .../registry/capabilities/airconditioner.py | 11 +- .../registry/capabilities/common.py | 24 +++- .../registry/capabilities/washer.py | 9 +- .../localthings/translations/de.json | 2 +- .../localthings/translations/en.json | 2 +- .../localthings/translations/ko.json | 2 +- ...est_air_purifier_air_quality_statistics.py | 28 ++-- tests/test_air_quality_grade_column.py | 135 ++++++++++++++++++ tests/test_sensor_device_class_units.py | 94 ++++++++++++ tests/test_translations.py | 21 +++ 12 files changed, 360 insertions(+), 37 deletions(-) create mode 100644 tests/test_air_quality_grade_column.py create mode 100644 tests/test_sensor_device_class_units.py diff --git a/custom_components/localthings/registry/capabilities/air_monitor.py b/custom_components/localthings/registry/capabilities/air_monitor.py index e6dd719..beeed3c 100644 --- a/custom_components/localthings/registry/capabilities/air_monitor.py +++ b/custom_components/localthings/registry/capabilities/air_monitor.py @@ -10,17 +10,23 @@ the same dust/fine_dust/super_fine_dust/odor/clean_level keys so this device shares those capabilities' catalog entries. This board additionally reports a CO2 reading the other two families don't. -A second `value` list element on the particulate-matter types (e.g. Dust's -`['31', '2']`) reads like a coarse quality-grade code, but nothing on this -board confirms what its scale means -- left unbound rather than guessed; -index 0 is the only slot any family has ever read. +A second `value` list element on the particulate-matter types (Dust's +`['31', '2']`) is the device's own graded air-quality level for that +reading -- see common.sensor_item_value. Still unbound here: the grade's +floor differs by board family, and CleanLevel already carries the +aggregate. This board's own readings are load-bearing evidence for the +PM mapping, though: 23 grading one step above the floor as FineDust is +what rules out a PM10-width band for that field. -Dust/FineDust/SuperFineDust stay without an HA `device_class`/`unit` on -this board. The three-tier mapping (Dust=PM10, FineDust=PM2.5, -SuperFineDust=PM1, µg/m³) is confirmed for the air-purifier family -(issue #325) but this standalone monitor has no same-moment app -correlation of its own, so it keeps the untyped measurement sensors -rather than inheriting that label. +Dust/FineDust/SuperFineDust nevertheless stay without an HA +`device_class`/`unit` here, which is now a migration call rather than an +evidence gap. The mapping confirmed for the purifier family (issue #325, +Dust=PM10 / FineDust=PM2.5 / SuperFineDust=PM1 in μg/m³) rests on +device-side grading that this board shares, so it would carry over. But +these five sensors have recorded unitless long-term statistics since +issue #210, and stamping a unit onto an existing statistic is what raises +Home Assistant's "units changed" repair -- a deliberate follow-up, not +something to fold into the purifier's first typed release. """ from datetime import time as dt_time @@ -35,8 +41,9 @@ from .common import int_or_none, sensor_item_value # graded indices on that family, while this board has stamped all five as # `measurement` since it was added (issue #210). Consuming state_class would # silently drop long-term statistics for two sensors on shipped devices, and -# the pm10/pm25/pm1 labels are confirmed only for the purifier family -# (issue #325). The shared rows supply only the key/icon/type here. +# the pm10/pm25/pm1 labels are held back pending the statistics migration +# the module docstring describes. The shared rows supply only the +# key/icon/type here. SENSORS = Capability( href="/sensors/vs/0", poll_tier="warm", diff --git a/custom_components/localthings/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py index ada0709..f479970 100644 --- a/custom_components/localthings/registry/capabilities/air_purifier.py +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -67,14 +67,38 @@ def _has_top_level_modes(rep, resources): # indices instead, where the mean of a grade isn't obviously meaningful; left # without a state_class rather than guessing. # -# device_class/unit confirmed on a live ARTIK051_TVTL against the SmartThings -# app at the same moment (issue #325): Dust=PM10, FineDust=PM2.5, -# SuperFineDust=PM1, all µg/m³. Dust matched PM10 exactly; the other two -# were 1 µg/m³ off the app, same order. +# device_class/unit: Dust=PM10, FineDust=PM2.5, SuperFineDust=PM1, all +# μg/m³ (issue #325). Three independent lines, none of them naming order -- +# which is what the earlier "plausible but unconfirmed" note rejected: +# +# 1. The device grades its own readings. Each dust item's value[] is +# [concentration, grade] (see common.sensor_item_value); the grade band +# is not shared across the three fields -- a reading of 18 grades one +# step *above* the floor as SuperFineDust (air_monitor fixture) but *at* +# the floor as Dust (range_hood fixture), both 1-based families. So the +# firmware itself treats them as three different scales ordered +# coarse-to-fine, rather than one repeated measurement. +# 2. Where each field's floor/second-band boundary falls brackets the +# Korean CAI bands: Dust good at 18, graded up at 31 (CAI PM10 breaks +# at 30/31); FineDust good at 14, graded up at 23 (CAI PM2.5 breaks at +# 15/16); SuperFineDust good at 9, graded up at 18 (PM2.5-style, which +# is what a PM1 reading gets -- there is no standard PM1 index). +# 3. A live ARTIK051_TVTL read against the SmartThings app at the same +# moment: Dust matched the app's PM10 exactly, the other two were 1 +# μg/m³ off in the same order, and the app shows exactly these three +# tiers, so there is no fourth candidate to assign. +# +# Dust >= FineDust >= SuperFineDust holds on all 11 fixtures that report +# this resource, which is the cumulative-mass ordering PM10 >= PM2.5 >= PM1 +# requires by definition. The unit literal must stay HA's own spelling of +# μg/m³ (U+03BC GREEK SMALL LETTER MU, not U+00B5 MICRO SIGN) -- they render +# alike but only U+03BC is in DEVICE_CLASS_UNITS, and the mismatch is a +# runtime warning per entity, not a test failure. Pinned by +# tests/test_sensor_device_class_units.py. _AIR_QUALITY_SENSORS = ( - ("dust", "mdi:blur", "Dust", "measurement", "pm10", "µg/m³"), - ("fine_dust", "mdi:blur", "FineDust", "measurement", "pm25", "µg/m³"), - ("super_fine_dust", "mdi:blur", "SuperFineDust", "measurement", "pm1", "µg/m³"), + ("dust", "mdi:blur", "Dust", "measurement", "pm10", "μg/m³"), + ("fine_dust", "mdi:blur", "FineDust", "measurement", "pm25", "μg/m³"), + ("super_fine_dust", "mdi:blur", "SuperFineDust", "measurement", "pm1", "μg/m³"), ("odor", "mdi:scent", "Odor", None, None, None), ("clean_level", "mdi:air-filter", "CleanLevel", None, None, None), ) diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 5c96473..ebcc5b9 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -101,8 +101,15 @@ def _threshold_write(payload, rep, href=None): def _sensor_item_value(items, type_): """First value of the /sensors/vs/0 item with the given x.com.samsung.da.type. Dust/FineDust/SuperFineDust report a 2-element - array; only v[0] is used, since the second element's meaning is - unconfirmed. No device_class is set: the resource exposes no unit.""" + array; only v[0] is used. v[1] is the device's own graded air-quality + level for that reading, left unbound because its floor differs by + family -- see common.sensor_item_value for the full note. + + No device_class here: unlike air_purifier (issue #325), no AC family + has had its dust readings correlated against the app, and every AC + fixture reports permanent zeros or ties, so this file's own dumps + supply no grade-band evidence either. Returns a string rather than an + int, which these diagnostic entities have always done.""" for it in items or []: if isinstance(it, dict) and it.get("x.com.samsung.da.type") == type_: v = it.get("x.com.samsung.da.value") diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index dfab158..693420a 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -254,7 +254,29 @@ def sensor_item_value(items, sensor_type, index=0): item is `{type, value: [...]}`; `index` picks which slot to read (index 0 is the raw measurement on every family seen so far). Shared by range_hood.AIR_QUALITY, air_purifier.AIR_QUALITY, and - air_monitor.SENSORS, which all read the same resource shape.""" + air_monitor.SENSORS, which all read the same resource shape. + + value[] is 2-element on the fields that carry a magnitude + (Dust/FineDust/SuperFineDust/CO2) and 1-element on Odor/CleanLevel. + That asymmetry is what index 1 means: it is the device's own graded + air-quality level for that reading -- the same kind of value Odor and + CleanLevel already *are*, which is why those two have no second slot. + It reads 0-2 against index 0's observed 0-31, tracks index 0 within a + device, and CleanLevel equals the highest per-field grade on 9 of the + 11 fixtures reporting this resource (the range hood and one RAC report + a higher CleanLevel than any dust grade, so they fold in something + else). + + Index 1 is deliberately left unbound rather than exposed as an entity: + its floor is not portable. ARTIK051_TVTL grades good air as 0, while + AVT-WW-TP1 / A-VTWW-TP2 / TP1X / ASM-KR-TP1 / AHD-WW-TP1 all grade it + as 1, so a shared descriptor would need a per-family offset to mean + anything, and CleanLevel already carries the aggregate. The grade is + still load-bearing as *evidence*: it is what confirms the three dust + fields are three different scales rather than one repeated reading -- + see air_purifier._AIR_QUALITY_SENSORS and + tests/test_air_quality_grade_column.py. + """ for item in items or (): if not isinstance(item, dict): continue diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index cf479d2..af289b8 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -40,10 +40,11 @@ from .laundry import ( # implying anything about a plain washer's '1F'); 3 more (Eco Cold, Towels, # Self Clean+) verified directly on a WF50A8600AV/US by reading back the raw # code after selecting each cycle on the appliance (issue #80). 2 more -# ('0A' Towels, 'B0' Mixed load) reported for a WW90DG5G34ABLE on the same -# Table_02 family (issue #363). Two code pairs ('21'/'65' Colors, -# '27'/'5E' Rinse+Spin, and '24'/'54' Towels) legitimately share a label -# across different course tables -- not typos. +# ('0A' Towels, 'B0' Mixed Load) reported for a WW90DG5G34ABLE on the same +# Table_02 family (issue #363). Several codes legitimately share a label +# across different course tables -- '21'/'65' Colors, '27'/'5E'/'78' +# Rinse+Spin, '0A'/'33'/'54'/'70' Towels -- not typos. (This list said +# "'24' Towels" until issue #343 found 24/33 transposed; 24 is Bedding.) # # No static fallback list is kept here: other models have different actual # course sets, so hardcoding one device's list would show/hide the wrong diff --git a/custom_components/localthings/translations/de.json b/custom_components/localthings/translations/de.json index a14396a..6fee358 100644 --- a/custom_components/localthings/translations/de.json +++ b/custom_components/localthings/translations/de.json @@ -657,7 +657,7 @@ "8f": "Kaltwäsche Intensiv", "96": "Weniger Mikrofasern", "a0": "Schnelle Wäsche 15'", - "b0": "Mischwäsche", + "b0": "Gemischte Beladung", "17": "Heruntergeladen", "69": "KI-Wäsche", "6a": "Wolle", diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 7c76b55..2b22310 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -714,7 +714,7 @@ "8f": "Intense Cold", "96": "Less Microfiber", "a0": "15' Quick Wash", - "b0": "Mixed load" + "b0": "Mixed Load" } }, "washer_dry_level": { diff --git a/custom_components/localthings/translations/ko.json b/custom_components/localthings/translations/ko.json index b419826..51eeb3e 100644 --- a/custom_components/localthings/translations/ko.json +++ b/custom_components/localthings/translations/ko.json @@ -695,7 +695,7 @@ "8f": "강력 냉수 세탁", "96": "미세플라스틱저감", "a0": "15분 쾌속세탁", - "b0": "혼합", + "b0": "혼합 세탁", "69": "AI 맞춤세탁", "6a": "울", "6b": "데님", diff --git a/tests/test_air_purifier_air_quality_statistics.py b/tests/test_air_purifier_air_quality_statistics.py index 2669d99..fc9cadb 100644 --- a/tests/test_air_purifier_air_quality_statistics.py +++ b/tests/test_air_purifier_air_quality_statistics.py @@ -31,12 +31,20 @@ def test_graded_sensors_are_left_without_a_state_class(): def test_particulate_sensors_declare_pm_device_class_and_unit(): - """Live same-moment SmartThings correlation on ARTIK051_TVTL (issue #325) - maps Dust/FineDust/SuperFineDust to PM10/PM2.5/PM1 in µg/m³.""" + """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", "µg/m³"), - "fine_dust": ("pm25", "µg/m³"), - "super_fine_dust": ("pm1", "µg/m³"), + "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) @@ -48,13 +56,17 @@ def test_particulate_sensors_declare_pm_device_class_and_unit(): assert desc.unit 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.""" +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_keeps_stamping_every_shared_sensor(): diff --git a/tests/test_air_quality_grade_column.py b/tests/test_air_quality_grade_column.py new file mode 100644 index 0000000..ba93d42 --- /dev/null +++ b/tests/test_air_quality_grade_column.py @@ -0,0 +1,135 @@ +"""What the second element of a /sensors/vs/0 dust reading means, and why +it is what confirms Dust/FineDust/SuperFineDust are PM10/PM2.5/PM1. + +`x.com.samsung.da.value` is `[concentration, grade]` on the fields that +carry a magnitude and `[grade]` on Odor/CleanLevel, which are grades +already. Index 1 is never bound to an entity (its floor differs by board +family), but it is the device's own opinion about its own readings, and +that makes it the one piece of evidence for the PM mapping that doesn't +depend on Samsung's field names or on a user's screenshot. + +These assertions read the shipped fixtures rather than restating numbers, +so a re-captured dump that contradicts the mapping fails here instead of +silently weakening the argument in air_purifier.py's comment. +""" + +import json +import pathlib + +FIXTURES = pathlib.Path(__file__).parent / "fixtures" +DUST_TYPES = ("Dust", "FineDust", "SuperFineDust") + + +def _items(fixture: str): + dump = json.loads((FIXTURES / f"{fixture}_device.json").read_text(encoding="utf-8")) + for entry in dump["device0"]: + if entry.get("href") == "/sensors/vs/0": + return { + item.get("x.com.samsung.da.type"): item.get("x.com.samsung.da.value") + for item in entry.get("rep", {}).get("x.com.samsung.da.items") or [] + } + raise AssertionError(f"{fixture} has no /sensors/vs/0") + + +def _fixtures_reporting_sensors(): + for path in sorted(FIXTURES.glob("*_device.json")): + dump = json.loads(path.read_text(encoding="utf-8")) + entries = dump.get("device0") + if not isinstance(entries, list): + continue + if any(e.get("href") == "/sensors/vs/0" for e in entries): + name = path.name.removesuffix("_device.json") + if any(t in _items(name) for t in DUST_TYPES): + yield name + + +def test_magnitude_fields_carry_a_grade_and_graded_fields_do_not(): + """The shape asymmetry is the whole argument for what index 1 is: the + fields that already *are* grades have no second slot.""" + checked = 0 + for fixture in _fixtures_reporting_sensors(): + items = _items(fixture) + for type_ in (*DUST_TYPES, "CO2"): + if type_ in items: + assert len(items[type_]) == 2, (fixture, type_, items[type_]) + checked += 1 + for type_ in ("Odor", "CleanLevel"): + if type_ in items: + assert len(items[type_]) == 1, (fixture, type_, items[type_]) + assert checked >= 30 + + +def test_concentration_falls_with_particle_size_on_every_fixture(): + """PM10 >= PM2.5 >= PM1 by definition -- they are cumulative masses, so + a violation would mean the three fields aren't nested size tiers at + all.""" + for fixture in _fixtures_reporting_sensors(): + items = _items(fixture) + if not all(t in items for t in DUST_TYPES): + continue + coarse, fine, finest = (int(items[t][0]) for t in DUST_TYPES) + assert coarse >= fine >= finest, (fixture, coarse, fine, finest) + + +def test_the_same_reading_grades_differently_as_dust_than_as_superfinedust(): + """18 is one step above the grade floor as SuperFineDust but sits *at* + the floor as Dust, on two families that both grade good air as 1. + + One shared threshold cannot produce both, so the firmware treats the + coarse field as tolerating more than the fine one -- three scales + ordered coarse-to-fine, which is what PM10/PM2.5/PM1 requires and what + "all three are the same kind of reading" cannot explain. + """ + monitor, hood = _items("air_monitor"), _items("range_hood") + assert monitor["SuperFineDust"] == ["18", "2"] + assert hood["Dust"] == ["18", "1"] + # Both families put good air at grade 1, so the two grades are + # comparable -- ARTIK051_TVTL's 0-based floor is the reason this + # comparison is drawn between these two fixtures and not against it. + assert monitor["Odor"] == ["1"] + assert hood["CleanLevel"] == ["2"] + assert _items("air_purifier")["Dust"] == ["11", "0"] + + +def test_grade_boundaries_bracket_the_korean_cai_bands(): + """Where each field crosses from its floor to the next grade lines up + with the band that field's PM tier is graded on in Korea's CAI: + PM10 breaks at 30/31, PM2.5 at 15/16. A PM1 reading has no standard + index and is graded on PM2.5-like widths. + """ + monitor, hood = _items("air_monitor"), _items("range_hood") + # Dust: still at the floor at 18, above it at 31 -> boundary in (18, 31]. + assert (hood["Dust"], monitor["Dust"]) == (["18", "1"], ["31", "2"]) + # FineDust: at the floor at 14, above it at 23 -> boundary in (14, 23]. + assert (hood["FineDust"], monitor["FineDust"]) == (["14", "1"], ["23", "2"]) + # SuperFineDust: at the floor at 9, above it at 18 -> boundary in (9, 18], + # strictly below where Dust's sits. + assert (hood["SuperFineDust"], monitor["SuperFineDust"]) == (["9", "1"], ["18", "2"]) + + +def test_clean_level_aggregates_the_per_field_grades(): + """CleanLevel is the highest per-field grade on every family except the + range hood and one RAC, which report a higher CleanLevel than any dust + grade -- those two fold in something this resource doesn't expose, so + CleanLevel is never derived from the dust grades in code.""" + exceptions = {"range_hood", "airconditioner_tp1x_da_ac_rac_01011"} + for fixture in _fixtures_reporting_sensors(): + items = _items(fixture) + if "CleanLevel" not in items: + continue + grades = [int(v[1]) for v in items.values() if len(v) == 2] + if not grades: + continue + aggregate = int(items["CleanLevel"][0]) + if fixture in exceptions: + assert aggregate > max(grades), (fixture, aggregate, grades) + else: + assert aggregate == max(grades), (fixture, aggregate, grades) + + +def test_grade_floor_is_zero_based_on_artik051_tvtl_and_one_based_elsewhere(): + """Why index 1 stays unbound: a shared descriptor would need a + per-family offset to mean anything.""" + assert _items("air_purifier")["CleanLevel"] == ["0"] + for fixture in ("air_monitor", "air_purifier_avt_ww", "air_purifier_vtww", "range_hood"): + assert int(_items(fixture)["CleanLevel"][0]) >= 1, fixture diff --git a/tests/test_sensor_device_class_units.py b/tests/test_sensor_device_class_units.py new file mode 100644 index 0000000..86873db --- /dev/null +++ b/tests/test_sensor_device_class_units.py @@ -0,0 +1,94 @@ +"""Guards against a SensorDesc unit Home Assistant won't accept for the +device_class it's paired with. + +Unlike the SwitchDesc case (issue #349), a bad sensor unit doesn't raise -- +sensor.py hands `unit` to `_attr_native_unit_of_measurement` and HA only +logs a warning per entity, once, telling the user to report a bug against +this integration. So the failure mode is a quiet stream of "not a valid +unit for the device class" warnings plus a support burden, with nothing in +the UI to hint anything is wrong. + +The specific trap this exists for: HA spells its micrograms-per-cubic-metre +unit with U+03BC GREEK SMALL LETTER MU, and DEVICE_CLASS_UNITS holds only +that spelling. U+00B5 MICRO SIGN renders identically in an editor, in a +terminal, and in a code review diff, but is a different string and fails +the membership test. PR #365 shipped all three particulate units with +U+00B5. + +Mirrors test_switch_device_class.py: scans every by_type registry rather +than a fixture, so a new capability making the same mistake fails here. +""" + +import importlib +import pkgutil + +from homeassistant.components.sensor.const import DEVICE_CLASS_UNITS, SensorDeviceClass + +from custom_components.localthings.registry import by_type +from custom_components.localthings.registry.entities import SensorDesc + + +def _all_registries(): + for mod_info in pkgutil.iter_modules(by_type.__path__): + if mod_info.name.startswith("_"): + continue + mod = importlib.import_module( + f"custom_components.localthings.registry.by_type.{mod_info.name}" + ) + reg = getattr(mod, "REGISTRY", None) + if reg is not None: + yield reg + + +def _sensor_descs(): + seen = set() + for reg in _all_registries(): + caps = [c for cs in reg.capabilities.values() for c in cs] + list(reg.pattern_capabilities) + for cap in caps: + for entity in cap.entities: + if isinstance(entity, SensorDesc) and (reg.name, entity.key) not in seen: + seen.add((reg.name, entity.key)) + yield reg.name, entity + + +def test_every_sensordesc_device_class_is_valid_for_ha(): + bad = [] + for reg_name, desc in _sensor_descs(): + if desc.device_class is None: + continue + try: + SensorDeviceClass(desc.device_class) + except ValueError: + bad.append((reg_name, desc.key, desc.device_class)) + assert bad == [] + + +def test_every_declared_unit_is_valid_for_its_device_class(): + """Descriptors carrying a `unit_fn` are exempt: those resolve their unit + from the live rep (a device reporting Celsius vs Fahrenheit), so there + is no static value to check here.""" + bad = [] + for reg_name, desc in _sensor_descs(): + if desc.device_class is None or desc.unit_fn is not None: + continue + units = DEVICE_CLASS_UNITS.get(SensorDeviceClass(desc.device_class)) + if units is not None and desc.unit not in units: + bad.append( + (reg_name, desc.key, desc.device_class, desc.unit, sorted(str(u) for u in units)) + ) + assert bad == [] + + +def test_particulate_units_use_has_own_mu_codepoint(): + """The membership test above already fails on U+00B5, but only while a + PM device_class is attached. Asserting the codepoint directly keeps the + reason legible when someone re-types the literal.""" + from custom_components.localthings.registry.capabilities import air_purifier + + micro_sign, greek_mu = chr(0x00B5), chr(0x03BC) + for _key, _icon, _type, _state_class, device_class, unit in air_purifier._AIR_QUALITY_SENSORS: + if device_class is None: + continue + assert unit is not None, device_class + assert unit == f"{greek_mu}g/m³", (device_class, [hex(ord(c)) for c in unit]) + assert micro_sign not in unit, device_class diff --git a/tests/test_translations.py b/tests/test_translations.py index 2c9b7cd..8697510 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -190,6 +190,27 @@ def test_confirmed_washer_table_02_missing_course_names(): } +def test_confirmed_washer_table_02_ww90dg5g34able_course_names(): + """Issue #363: 0A/B0 rendered as raw hex on a WW90DG5G34ABLE + (DA_WM_TP1_21_COMMON), whose other Table_02 labels the reporter + confirmed were already correct. + + 0A joins 33/54/70 as a Towels code -- checked in every locale, since a + locale that translated 0A differently from the Towels codes it shares a + meaning with would still pass the key-topology test, the same gap + issue #343 fell through. + """ + for language in _languages(): + states = _load(language)["entity"]["select"]["washer_cycle_table_02"]["state"] + assert states["0a"] == states["33"], language + assert states["b0"] != states["34"], language + english = _load("en")["entity"]["select"]["washer_cycle_table_02"]["state"] + assert {code: english[code] for code in ("0a", "b0")} == { + "0a": "Towels", + "b0": "Mixed Load", + } + + def test_reported_washer_standard_courses_all_have_table_02_labels(): """Every non-personal code in the reported washer's live course list must resolve through the Table_02 catalog instead of appearing as raw From eeece4906c163a08fbe1b1f110e4b1295e602462 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Sat, 15 Aug 2026 00:47:37 +0000 Subject: [PATCH 3/4] Type air_monitor's particulates and migrate the recorded statistics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- custom_components/localthings/__init__.py | 91 +++++++++- custom_components/localthings/config_flow.py | 5 +- custom_components/localthings/manifest.json | 1 + .../registry/capabilities/air_monitor.py | 39 ++-- tests/localthings/test_migration.py | 6 +- .../localthings/test_statistics_migration.py | 171 ++++++++++++++++++ ...est_air_purifier_air_quality_statistics.py | 25 ++- tests/test_statistics_migration_end_to_end.py | 161 +++++++++++++++++ 8 files changed, 469 insertions(+), 30 deletions(-) create mode 100644 tests/localthings/test_statistics_migration.py create mode 100644 tests/test_statistics_migration_end_to_end.py diff --git a/custom_components/localthings/__init__.py b/custom_components/localthings/__init__.py index 0fa67f8..91ccf21 100644 --- a/custom_components/localthings/__init__.py +++ b/custom_components/localthings/__init__.py @@ -3,8 +3,12 @@ from __future__ import annotations import logging +import re from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER as PARTICULATE_UNIT, +) from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady @@ -12,7 +16,7 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers import entity_registry as er from homeassistant.helpers.typing import ConfigType -from .const import CONF_HOST, CONF_PORT, CONF_SERIAL, DOMAIN, PLATFORMS +from .const import CONF_DEVICE_TYPE, CONF_HOST, CONF_PORT, CONF_SERIAL, DOMAIN, PLATFORMS from .coordinator import LocalThingsCoordinator from .registry.identity import resolve_serial from .services import async_setup_services @@ -125,6 +129,81 @@ def _repair_placeholder_keys(hass: HomeAssistant, entry: ConfigEntry, serial: st ) +# Registries whose Dust/FineDust/SuperFineDust sensors gained pm10/pm25/pm1 +# and a unit in the release that introduced entry version 3. Deliberately +# not every family reading /sensors/vs/0: range_hood and airconditioner +# still declare no unit for their identically-named sensors, and relabelling +# their statistics to µg/m³ would assert a unit those entities don't report +# -- creating the very mismatch this migration exists to prevent. +_PARTICULATE_TYPED_IN_V3 = frozenset({"air_purifier", "air_monitor"}) + +# unique_id is f"{DOMAIN}_{serial}_{state_key}"; state_key is the descriptor +# key, optionally carrying a subdevice prefix and a trailing instance number +# (registry/adapter._key). Matching the tail rather than rebuilding the whole +# id keeps this working for a renamed entity, whose entity_id -- and so its +# statistic_id -- no longer follows from the key at all. +_PARTICULATE_KEY_RE = re.compile(r"_(?:super_fine_dust|fine_dust|dust)\d*$") + + +@callback +def _relabel_particulate_statistics(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Point existing particulate statistics at the unit they always were. + + These sensors recorded long-term statistics with no unit, and the + release carrying this migration gives them µg/m³. Home Assistant treats + that as a unit change it can't convert and *suppresses statistics + generation entirely* for the entity until someone resolves the repair + (sensor.recorder._update_issues -> UNITS_CHANGED_ISSUE, and the matching + `continue` in its compile path). Silently freezing the history we just + finished labelling is the worst of both outcomes, so the metadata is + corrected up front instead. + + Only the metadata row is rewritten, never the recorded values. The + readings were always µg/m³ concentrations (issue #325); what was missing + was the label, so there is nothing to convert and no way for this to + distort history. That is also why it uses + `async_update_statistics_metadata` and not `change_statistics_unit`, + which would scale every stored value. + + A no-op when the recorder isn't loaded, when this device family isn't + one that gained the unit, or when the device never recorded any + statistics -- the underlying UPDATE simply matches no rows. + """ + if entry.data.get(CONF_DEVICE_TYPE) not in _PARTICULATE_TYPED_IN_V3: + return + if "recorder" not in hass.config.components: + # after_dependencies pulls the recorder in when it's configured at + # all; a setup running without it has no statistics to relabel. + _LOGGER.debug("recorder not loaded, skipping statistics relabel") + return + + from homeassistant.components.recorder.statistics import ( + STATISTIC_UNIT_TO_UNIT_CONVERTER, + async_update_statistics_metadata, + ) + + # µg/m³ has a converter (MassVolumeConcentrationConverter), so its class + # is 'concentration', not None. Read rather than hardcoded: passing a + # class that disagrees with HA's own table raises, and passing none at + # all is deprecated and stops working in HA Core 2026.11. + converter = STATISTIC_UNIT_TO_UNIT_CONVERTER.get(PARTICULATE_UNIT) + unit_class = converter.UNIT_CLASS if converter is not None else None + + ent_reg = er.async_get(hass) + for registry_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id): + if registry_entry.domain != "sensor": + continue + if not _PARTICULATE_KEY_RE.search(registry_entry.unique_id): + continue + _LOGGER.debug("relabelling statistics unit for %s", registry_entry.entity_id) + async_update_statistics_metadata( + hass, + registry_entry.entity_id, + new_unit_class=unit_class, + new_unit_of_measurement=PARTICULATE_UNIT, + ) + + async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate an entry to the current version. @@ -132,8 +211,11 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: can key its registry entries before the first poll (issue #236), and repairs whatever the old placeholder-keyed registration already orphaned. + + v2 -> v3 relabels the recorded statistics for the particulate sensors, + which gained a device_class/unit in the same release (issue #325). """ - if entry.version > 2: + if entry.version > 3: return False # downgrade: this release doesn't know the newer shape if entry.version == 1: @@ -147,6 +229,11 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _repair_placeholder_keys(hass, entry, serial) _LOGGER.debug("migrated entry %s to version 2 (serial=%s)", entry.entry_id, serial) + if entry.version == 2: + _relabel_particulate_statistics(hass, entry) + hass.config_entries.async_update_entry(entry, version=3) + _LOGGER.debug("migrated entry %s to version 3", entry.entry_id) + return True diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index 3b49039..d4ad8ac 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -793,7 +793,10 @@ def _probe_and_validate( class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - VERSION = 2 + # v3 relabels the particulate sensors' recorded statistics; a freshly + # created entry has none to relabel, so it starts at the migrated + # version rather than walking through v2 (see async_migrate_entry). + VERSION = 3 def __init__(self) -> None: self._host: str = "" diff --git a/custom_components/localthings/manifest.json b/custom_components/localthings/manifest.json index 0a8f5ea..a54e712 100644 --- a/custom_components/localthings/manifest.json +++ b/custom_components/localthings/manifest.json @@ -1,6 +1,7 @@ { "domain": "localthings", "name": "LocalThings", + "after_dependencies": ["recorder"], "codeowners": ["@mbillow"], "config_flow": true, "dependencies": [], diff --git a/custom_components/localthings/registry/capabilities/air_monitor.py b/custom_components/localthings/registry/capabilities/air_monitor.py index beeed3c..71c382b 100644 --- a/custom_components/localthings/registry/capabilities/air_monitor.py +++ b/custom_components/localthings/registry/capabilities/air_monitor.py @@ -18,15 +18,17 @@ aggregate. This board's own readings are load-bearing evidence for the PM mapping, though: 23 grading one step above the floor as FineDust is what rules out a PM10-width band for that field. -Dust/FineDust/SuperFineDust nevertheless stay without an HA -`device_class`/`unit` here, which is now a migration call rather than an -evidence gap. The mapping confirmed for the purifier family (issue #325, -Dust=PM10 / FineDust=PM2.5 / SuperFineDust=PM1 in μg/m³) rests on -device-side grading that this board shares, so it would carry over. But -these five sensors have recorded unitless long-term statistics since -issue #210, and stamping a unit onto an existing statistic is what raises -Home Assistant's "units changed" repair -- a deliberate follow-up, not -something to fold into the purifier's first typed release. +Dust/FineDust/SuperFineDust carry the same HA `device_class`/`unit` as the +purifier family (issue #325, Dust=PM10 / FineDust=PM2.5 / +SuperFineDust=PM1 in μg/m³). The mapping rests on device-side grading this +board shares rather than on anything purifier-specific, so typing one +family and not the other would have been an inconsistency, not caution. + +These sensors have recorded *unitless* long-term statistics since issue +#210, though, and Home Assistant suppresses statistics generation outright +for an entity whose unit no longer matches its recorded metadata -- so +stamping a unit on would have silently stopped the history it was meant to +label. __init__.py's v2->v3 entry migration relabels that metadata first. """ from datetime import time as dt_time @@ -36,14 +38,15 @@ from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc, TimeDesc from .air_purifier import _AIR_QUALITY_SENSORS from .common import int_or_none, sensor_item_value -# Extra columns (state_class, device_class, unit) are deliberately discarded -# here: air_purifier leaves Odor/CleanLevel unstamped because they read as +# device_class/unit are taken from the shared rows; state_class deliberately +# is not. 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 state_class would -# silently drop long-term statistics for two sensors on shipped devices, and -# the pm10/pm25/pm1 labels are held back pending the statistics migration -# the module docstring describes. The shared rows supply only the -# key/icon/type here. +# `measurement` since it was added (issue #210) -- consuming that column +# would silently drop long-term statistics for two sensors on shipped +# devices. The pm10/pm25/pm1 labels carry over cleanly, though: they rest on +# device-side grading this board shares (see the module docstring), and +# __init__.py's v2->v3 entry migration relabels the unitless statistics +# these five have been recording so the new unit doesn't suppress them. SENSORS = Capability( href="/sensors/vs/0", poll_tier="warm", @@ -54,9 +57,11 @@ SENSORS = Capability( field="x.com.samsung.da.items", icon=icon, state_class="measurement", + device_class=device_class, + unit=unit, 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, device_class, unit in _AIR_QUALITY_SENSORS ), SensorDesc( key="co2", diff --git a/tests/localthings/test_migration.py b/tests/localthings/test_migration.py index e8997bd..49a9152 100644 --- a/tests/localthings/test_migration.py +++ b/tests/localthings/test_migration.py @@ -38,7 +38,9 @@ async def test_migration_recovers_serial_from_unique_id( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert entry.version == 2 + # Straight through to the current version: v2 -> v3 is a statistics + # relabel that no-ops for a family without particulate sensors. + assert entry.version == 3 assert entry.data[CONF_SERIAL] == MOCK_SERIAL @@ -257,7 +259,7 @@ async def test_migration_rejects_a_future_entry_version(hass: HomeAssistant) -> written by a newer release.""" from custom_components.localthings import async_migrate_entry - entry = MockConfigEntry(domain=DOMAIN, data=LEGACY_ENTRY_DATA, version=3) + entry = MockConfigEntry(domain=DOMAIN, data=LEGACY_ENTRY_DATA, version=4) entry.add_to_hass(hass) assert await async_migrate_entry(hass, entry) is False diff --git a/tests/localthings/test_statistics_migration.py b/tests/localthings/test_statistics_migration.py new file mode 100644 index 0000000..df1e3b0 --- /dev/null +++ b/tests/localthings/test_statistics_migration.py @@ -0,0 +1,171 @@ +"""The v2 -> v3 entry migration that relabels particulate statistics. + +Dust/FineDust/SuperFineDust gained a pm10/pm25/pm1 device_class and a +µg/m³ unit (issue #325) after having recorded long-term statistics with no +unit at all. Home Assistant treats that as a unit change it cannot convert +and *suppresses statistics generation* for the entity until a human +resolves the repair, so the metadata is corrected during migration instead. + +Only the metadata row is touched, never the recorded values -- the readings +were always µg/m³, so there is nothing to convert. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.localthings import async_migrate_entry +from custom_components.localthings.const import CONF_DEVICE_TYPE, DOMAIN + +from .conftest import ENTRY_DATA, MOCK_SERIAL + +RELABEL = "homeassistant.components.recorder.statistics.async_update_statistics_metadata" + + +@pytest.fixture(autouse=True) +def _recorder_loaded(hass: HomeAssistant): + """Most tests here assume a normal install, where after_dependencies has + pulled the recorder in. test_is_a_no_op_without_the_recorder undoes it.""" + hass.config.components.add("recorder") + return hass + + +def _entry(hass: HomeAssistant, device_type: str) -> MockConfigEntry: + entry = MockConfigEntry( + domain=DOMAIN, + data={**ENTRY_DATA, CONF_DEVICE_TYPE: device_type}, + unique_id=f"{DOMAIN}_{MOCK_SERIAL}", + version=2, + ) + entry.add_to_hass(hass) + return entry + + +def _add_sensor(hass: HomeAssistant, entry: MockConfigEntry, key: str, **kwargs): + return er.async_get(hass).async_get_or_create( + "sensor", + DOMAIN, + f"{DOMAIN}_{MOCK_SERIAL}_{key}", + config_entry=entry, + **kwargs, + ) + + +async def test_relabels_every_particulate_sensor(hass: HomeAssistant) -> None: + entry = _entry(hass, "air_purifier") + expected = { + _add_sensor(hass, entry, key).entity_id for key in ("dust", "fine_dust", "super_fine_dust") + } + + with patch(RELABEL) as relabel: + assert await async_migrate_entry(hass, entry) is True + + assert {call.args[1] for call in relabel.call_args_list} == expected + for call in relabel.call_args_list: + assert call.kwargs["new_unit_of_measurement"] == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + # µg/m³ has a converter, so the class must be named, not None -- + # passing neither is deprecated and breaks in HA Core 2026.11. + assert call.kwargs["new_unit_class"] == "concentration" + assert entry.version == 3 + + +async def test_leaves_other_sensors_on_the_same_device_alone(hass: HomeAssistant) -> None: + """Odor/CleanLevel/CO2 share the resource but keep the units they had.""" + entry = _entry(hass, "air_monitor") + dust = _add_sensor(hass, entry, "dust") + for key in ("odor", "clean_level", "co2", "dustbag_usage", "dustbin_auto_close"): + _add_sensor(hass, entry, key) + + with patch(RELABEL) as relabel: + assert await async_migrate_entry(hass, entry) is True + + assert [call.args[1] for call in relabel.call_args_list] == [dust.entity_id] + + +async def test_skips_families_that_did_not_gain_the_unit(hass: HomeAssistant) -> None: + """range_hood and airconditioner still declare no unit for their + identically-named sensors. Relabelling their statistics would assert a + unit those entities don't report -- creating the very mismatch this + migration exists to prevent.""" + for device_type in ("range_hood", "airconditioner"): + entry = _entry(hass, device_type) + _add_sensor(hass, entry, "dust") + _add_sensor(hass, entry, "fine_dust") + + with patch(RELABEL) as relabel: + assert await async_migrate_entry(hass, entry) is True + + assert relabel.call_args_list == [], device_type + assert entry.version == 3 + + +async def test_is_a_no_op_without_the_recorder(hass: HomeAssistant) -> None: + """A setup running without the recorder has no statistics to relabel, + and importing recorder internals there would fail outright.""" + entry = _entry(hass, "air_purifier") + _add_sensor(hass, entry, "dust") + + hass.config.components.remove("recorder") + with patch(RELABEL) as relabel: + assert await async_migrate_entry(hass, entry) is True + + assert relabel.call_args_list == [] + assert entry.version == 3 + + +async def test_follows_a_renamed_entity_rather_than_rebuilding_its_id( + hass: HomeAssistant, +) -> None: + """statistic_id is the entity_id, which the user can rename. Matching the + unique_id tail and reading entity_id back off the registry is what keeps + this correct for a renamed sensor -- reconstructing an entity_id from the + descriptor key would relabel a statistic nobody is recording.""" + entry = _entry(hass, "air_purifier") + renamed = _add_sensor(hass, entry, "dust", suggested_object_id="living_room_pm10") + assert renamed.entity_id == "sensor.living_room_pm10" + + with patch(RELABEL) as relabel: + assert await async_migrate_entry(hass, entry) is True + + assert [call.args[1] for call in relabel.call_args_list] == ["sensor.living_room_pm10"] + + +async def test_matches_subdevice_prefixed_and_instanced_keys(hass: HomeAssistant) -> None: + """_key() can prefix a subdevice and append an instance number, so the + match is on the tail rather than the whole unique_id.""" + entry = _entry(hass, "air_purifier") + ent_reg = er.async_get(hass) + for unique_suffix in ("indoor_0_dust", "fine_dust1", "super_fine_dust"): + ent_reg.async_get_or_create( + "sensor", + DOMAIN, + f"{DOMAIN}_{MOCK_SERIAL}_{unique_suffix}", + config_entry=entry, + ) + # Near-misses that must not match. + for unique_suffix in ("dustbag_full", "dustbin_auto_close", "dust_filter_reset"): + ent_reg.async_get_or_create( + "sensor", + DOMAIN, + f"{DOMAIN}_{MOCK_SERIAL}_{unique_suffix}", + config_entry=entry, + ) + + with patch(RELABEL) as relabel: + assert await async_migrate_entry(hass, entry) is True + + assert len(relabel.call_args_list) == 3 + + +async def test_a_fresh_entry_starts_at_the_migrated_version(hass: HomeAssistant) -> None: + """A newly created entry has no statistics to relabel, so the config flow + mints v3 directly rather than walking through the migration.""" + from custom_components.localthings.config_flow import LocalThingsConfigFlow + + assert LocalThingsConfigFlow.VERSION == 3 diff --git a/tests/test_air_purifier_air_quality_statistics.py b/tests/test_air_purifier_air_quality_statistics.py index fc9cadb..3bec9cb 100644 --- a/tests/test_air_purifier_air_quality_statistics.py +++ b/tests/test_air_purifier_air_quality_statistics.py @@ -69,12 +69,15 @@ def test_metadata_comes_from_the_shared_tuples_own_columns(): assert (row[4] is None) == (row[5] is None), row -def test_air_monitor_keeps_stamping_every_shared_sensor(): - """air_monitor imports _AIR_QUALITY_SENSORS and discards the extra columns - on purpose: that board (issue #210) has stamped all five as `measurement` - since it was added, and consuming state_class would silently drop - long-term statistics for Odor/CleanLevel there. Guards the import end to - end and the deliberate divergence together.""" +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" @@ -83,8 +86,14 @@ def test_air_monitor_keeps_stamping_every_shared_sensor(): 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 is None, key - assert desc.unit is None, 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(): diff --git a/tests/test_statistics_migration_end_to_end.py b/tests/test_statistics_migration_end_to_end.py new file mode 100644 index 0000000..d383e41 --- /dev/null +++ b/tests/test_statistics_migration_end_to_end.py @@ -0,0 +1,161 @@ +"""The v2 -> v3 statistics relabel against a real recorder, not a mock. + +tests/localthings/test_statistics_migration.py proves the migration calls +HA's API with the right arguments for the right entities. It cannot prove +that call does what the migration needs, because the recorder is patched +out. This drives an in-memory recorder end to end: statistics recorded +unitless, migration run, metadata inspected -- and, most importantly, the +recorded *values* checked to be untouched, which is the claim that makes +doing this automatically safe rather than something to ask each user about. + +Lives here rather than under tests/localthings/ on purpose: that package's +autouse `enable_custom_integrations` fixture depends on `hass`, which +starts Home Assistant before `recorder_mock` can claim its database URL. +Nothing here loads the integration -- `async_migrate_entry` is called +directly -- so the entry only needs the one key the v2 -> v3 step reads. +""" + +from __future__ import annotations + +from datetime import timedelta +from functools import partial +from typing import cast + +import pytest +from homeassistant.components.recorder.models import StatisticMeanType, StatisticMetaData +from homeassistant.components.recorder.statistics import ( + async_import_statistics, + get_metadata, + statistics_during_period, +) +from homeassistant.components.recorder.util import get_instance +from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util +from pytest_homeassistant_custom_component.common import MockConfigEntry +from pytest_homeassistant_custom_component.components.recorder.common import ( + async_wait_recording_done, +) + +from custom_components.localthings import async_migrate_entry +from custom_components.localthings.const import CONF_DEVICE_TYPE, DOMAIN +from custom_components.localthings.registry.entities import SensorDesc + +SERIAL = "TEST-SERIAL-0000" +RECORDED = [11.0, 9.0, 14.0] + + +async def _seed_unitless_statistics(hass: HomeAssistant, entity_id: str) -> None: + """Record hourly statistics the way these sensors always have: numeric + means, no unit of measurement at all.""" + start = dt_util.utcnow().replace(minute=0, second=0, microsecond=0) - timedelta(hours=4) + async_import_statistics( + hass, + { + "mean_type": StatisticMeanType.ARITHMETIC, + "has_sum": False, + "name": None, + "source": "recorder", + "statistic_id": entity_id, + "unit_class": None, + "unit_of_measurement": None, + }, + [ + {"start": start + timedelta(hours=i), "mean": value, "min": value, "max": value} + for i, value in enumerate(RECORDED) + ], + ) + await async_wait_recording_done(hass) + + +async def _metadata(hass: HomeAssistant, entity_id: str) -> StatisticMetaData: + result = await get_instance(hass).async_add_executor_job( + partial(get_metadata, hass, statistic_ids={entity_id}) + ) + return result[entity_id][1] + + +async def _means(hass: HomeAssistant, entity_id: str) -> list[float]: + rows = await get_instance(hass).async_add_executor_job( + statistics_during_period, + hass, + dt_util.utcnow() - timedelta(days=1), + None, + {entity_id}, + "hour", + None, + {"mean"}, + ) + return [cast(float, row["mean"]) for row in rows.get(entity_id, [])] + + +@pytest.fixture +def purifier_entry(hass: HomeAssistant) -> MockConfigEntry: + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_DEVICE_TYPE: "air_purifier"}, + unique_id=f"{DOMAIN}_{SERIAL}", + version=2, + ) + entry.add_to_hass(hass) + return entry + + +def _dust_entity(hass: HomeAssistant, entry: MockConfigEntry): + return er.async_get(hass).async_get_or_create( + "sensor", DOMAIN, f"{DOMAIN}_{SERIAL}_dust", config_entry=entry + ) + + +async def test_relabels_metadata_without_touching_recorded_values( + recorder_mock, hass: HomeAssistant, purifier_entry: MockConfigEntry +) -> None: + dust = _dust_entity(hass, purifier_entry) + await _seed_unitless_statistics(hass, dust.entity_id) + + before = await _metadata(hass, dust.entity_id) + assert before["unit_of_measurement"] is None + assert await _means(hass, dust.entity_id) == RECORDED + + assert await async_migrate_entry(hass, purifier_entry) is True + await async_wait_recording_done(hass) + + after = await _metadata(hass, dust.entity_id) + assert after["unit_of_measurement"] == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + assert after["unit_class"] == "concentration" + # The readings were always µg/m³; only the label was missing. Nothing is + # converted, so the recorded history still says exactly what it said. + assert await _means(hass, dust.entity_id) == RECORDED + + +async def test_recorded_unit_ends_up_matching_what_the_descriptor_declares( + recorder_mock, hass: HomeAssistant, purifier_entry: MockConfigEntry +) -> None: + """The invariant the migration exists to establish, stated directly. + + HA raises units_changed -- and suppresses statistics generation -- when + an entity's unit disagrees with the unit recorded against its + statistic_id. Rather than drive HA's validation to observe that, this + asserts the condition that validation reads: after migrating, the + recorded metadata says exactly what the descriptor says. Asserting our + own invariant instead of Home Assistant's reaction to it keeps the test + off internals that change between versions (both `_update_issues` and + `validate_statistics` have gained parameters), and tests this repo + rather than that one.""" + from custom_components.localthings.registry.capabilities import air_purifier + + dust = _dust_entity(hass, purifier_entry) + await _seed_unitless_statistics(hass, dust.entity_id) + + desc = next( + d + for d in air_purifier.AIR_QUALITY.entities + if d.key == "dust" and isinstance(d, SensorDesc) + ) + assert (await _metadata(hass, dust.entity_id))["unit_of_measurement"] != desc.unit + + assert await async_migrate_entry(hass, purifier_entry) is True + await async_wait_recording_done(hass) + + assert (await _metadata(hass, dust.entity_id))["unit_of_measurement"] == desc.unit From 9f1bcad3ec23dde34652d1b5c8f601757b78d4a6 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Sat, 15 Aug 2026 01:23:33 +0000 Subject: [PATCH 4/4] Harden the statistics relabel against older Home Assistant and lost boots Review follow-up on the v2 -> v3 migration. `new_unit_class` only exists from HA 2025.11, but hacs.json still declares 2025.1 as the minimum. On anything in between, naming that keyword is a TypeError raised out of async_migrate_entry, which fails the config entry outright -- the integration would not load at all for those users. The keyword is now feature-detected, and the relabel is wrapped so that no recorder-side surprise can cost anyone the integration: it is a convenience, and without it they simply get Home Assistant's own units_changed repair, which is where they were before this existed. The version bump also no longer happens when the recorder wasn't loaded. That case isn't distinguishable from an install without the recorder, but burning the one-shot migration on a boot where it merely failed to come up would leave the statistics suppressed permanently, so the entry stays on v2 and the next start retries. The instance-suffix regex was wrong: discovery.instance_suffix yields `_`, so keys are `dust_1`, not `dust1`. Unreachable today because AIR_QUALITY binds an exact href, but the comment claimed a guarantee the pattern didn't provide and the test pinned a form that can't occur. Also drops a stale claim in airconditioner.py that air_monitor rejects the pm10/pm25/pm1 mapping, which is no longer true as of this branch. Tests cover both signatures, the deferral and its retry, and a relabel that raises. The older-HA guard is mutation-checked: removing the feature detection fails it. --- custom_components/localthings/__init__.py | 76 ++++++++++++------- .../registry/capabilities/airconditioner.py | 10 +-- .../localthings/test_statistics_migration.py | 74 +++++++++++++++--- 3 files changed, 114 insertions(+), 46 deletions(-) diff --git a/custom_components/localthings/__init__.py b/custom_components/localthings/__init__.py index 91ccf21..3b0803f 100644 --- a/custom_components/localthings/__init__.py +++ b/custom_components/localthings/__init__.py @@ -2,8 +2,10 @@ from __future__ import annotations +import inspect import logging import re +from typing import Any from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -138,15 +140,15 @@ def _repair_placeholder_keys(hass: HomeAssistant, entry: ConfigEntry, serial: st _PARTICULATE_TYPED_IN_V3 = frozenset({"air_purifier", "air_monitor"}) # unique_id is f"{DOMAIN}_{serial}_{state_key}"; state_key is the descriptor -# key, optionally carrying a subdevice prefix and a trailing instance number -# (registry/adapter._key). Matching the tail rather than rebuilding the whole -# id keeps this working for a renamed entity, whose entity_id -- and so its -# statistic_id -- no longer follows from the key at all. -_PARTICULATE_KEY_RE = re.compile(r"_(?:super_fine_dust|fine_dust|dust)\d*$") +# key, optionally carrying a subdevice prefix and a trailing `_` instance +# (registry/adapter._key, discovery.instance_suffix). Matching the tail rather +# than rebuilding the whole id keeps this working for a renamed entity, whose +# entity_id -- and so its statistic_id -- no longer follows from the key. +_PARTICULATE_KEY_RE = re.compile(r"_(?:super_fine_dust|fine_dust|dust)(?:_\d+)?$") @callback -def _relabel_particulate_statistics(hass: HomeAssistant, entry: ConfigEntry) -> None: +def _relabel_particulate_statistics(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Point existing particulate statistics at the unit they always were. These sensors recorded long-term statistics with no unit, and the @@ -165,29 +167,39 @@ def _relabel_particulate_statistics(hass: HomeAssistant, entry: ConfigEntry) -> `async_update_statistics_metadata` and not `change_statistics_unit`, which would scale every stored value. - A no-op when the recorder isn't loaded, when this device family isn't - one that gained the unit, or when the device never recorded any - statistics -- the underlying UPDATE simply matches no rows. + A no-op when this device family isn't one that gained the unit, or when + the device never recorded any statistics -- the underlying UPDATE simply + matches no rows. + + Returns False only when the recorder wasn't loaded, meaning the caller + should leave the entry on its old version and try again next start. """ if entry.data.get(CONF_DEVICE_TYPE) not in _PARTICULATE_TYPED_IN_V3: - return + return True if "recorder" not in hass.config.components: - # after_dependencies pulls the recorder in when it's configured at - # all; a setup running without it has no statistics to relabel. - _LOGGER.debug("recorder not loaded, skipping statistics relabel") - return + # after_dependencies orders the recorder ahead of us when it's + # configured, so this is either an install without it (nothing to + # relabel, and the retry costs one set lookup per start) or a boot + # where it failed to come up. Not distinguishable here, and burning + # the one-shot migration on the second case would leave the + # statistics suppressed for good. + _LOGGER.debug("recorder not loaded, deferring statistics relabel") + return False from homeassistant.components.recorder.statistics import ( STATISTIC_UNIT_TO_UNIT_CONVERTER, async_update_statistics_metadata, ) - # µg/m³ has a converter (MassVolumeConcentrationConverter), so its class - # is 'concentration', not None. Read rather than hardcoded: passing a - # class that disagrees with HA's own table raises, and passing none at - # all is deprecated and stops working in HA Core 2026.11. - converter = STATISTIC_UNIT_TO_UNIT_CONVERTER.get(PARTICULATE_UNIT) - unit_class = converter.UNIT_CLASS if converter is not None else None + kwargs: dict[str, Any] = {"new_unit_of_measurement": PARTICULATE_UNIT} + # `new_unit_class` only exists from HA 2025.11; hacs.json still supports + # 2025.1, where passing it is a TypeError -- which would propagate out of + # async_migrate_entry and fail the whole entry. Where it is supported it + # must be named, since omitting it is deprecated from HA 2026.11. µg/m³ + # has a converter, so the value is 'concentration' rather than None. + if "new_unit_class" in inspect.signature(async_update_statistics_metadata).parameters: + converter = STATISTIC_UNIT_TO_UNIT_CONVERTER.get(PARTICULATE_UNIT) + kwargs["new_unit_class"] = converter.UNIT_CLASS if converter is not None else None ent_reg = er.async_get(hass) for registry_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id): @@ -196,12 +208,21 @@ def _relabel_particulate_statistics(hass: HomeAssistant, entry: ConfigEntry) -> if not _PARTICULATE_KEY_RE.search(registry_entry.unique_id): continue _LOGGER.debug("relabelling statistics unit for %s", registry_entry.entity_id) - async_update_statistics_metadata( - hass, - registry_entry.entity_id, - new_unit_class=unit_class, - new_unit_of_measurement=PARTICULATE_UNIT, - ) + try: + async_update_statistics_metadata(hass, registry_entry.entity_id, **kwargs) + except Exception: + # Relabelling is a convenience: without it the user gets Home + # Assistant's own units_changed repair, which is where they were + # before this migration existed. Never worth failing setup over, + # so no recorder-side surprise can cost them the integration. + _LOGGER.warning( + "Could not relabel statistics unit for %s; Home Assistant will " + "offer a units-changed repair for it instead", + registry_entry.entity_id, + exc_info=True, + ) + return True + return True async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -229,8 +250,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _repair_placeholder_keys(hass, entry, serial) _LOGGER.debug("migrated entry %s to version 2 (serial=%s)", entry.entry_id, serial) - if entry.version == 2: - _relabel_particulate_statistics(hass, entry) + if entry.version == 2 and _relabel_particulate_statistics(hass, entry): hass.config_entries.async_update_entry(entry, version=3) _LOGGER.debug("migrated entry %s to version 3", entry.entry_id) diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index ebcc5b9..a44fc2d 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -1558,12 +1558,10 @@ AIR_QUALITY = Capability( # CO2 (PR #316, ACA-KR-TP2-21-AN9000) -- a type this file's other AC # families don't report. Same field/shape air_monitor.SENSORS # already models with device_class='carbon_dioxide'/unit='ppm', so - # this matches that descriptor rather than guessing fresh -- unlike - # the pm10/pm25/pm1 mapping air_monitor.py's own docstring - # deliberately rejects for the three dust-type keys above (Samsung's - # two-tier PM10/PM2.5 convention doesn't confirm where a third tier - # or PM1 fits), ppm for a field literally named CO2 isn't a guess of - # that kind. + # this matches that descriptor rather than guessing fresh. The dust + # keys above stay untyped for the reason in _sensor_item_value: the + # pm10/pm25/pm1 mapping is confirmed for the purifier and monitor + # families (issue #325), but no AC family has evidence of its own. SensorDesc( key="co2", field="x.com.samsung.da.items", diff --git a/tests/localthings/test_statistics_migration.py b/tests/localthings/test_statistics_migration.py index df1e3b0..27fd7cd 100644 --- a/tests/localthings/test_statistics_migration.py +++ b/tests/localthings/test_statistics_migration.py @@ -31,7 +31,7 @@ RELABEL = "homeassistant.components.recorder.statistics.async_update_statistics_ @pytest.fixture(autouse=True) def _recorder_loaded(hass: HomeAssistant): """Most tests here assume a normal install, where after_dependencies has - pulled the recorder in. test_is_a_no_op_without_the_recorder undoes it.""" + pulled the recorder in. The deferral test below undoes it.""" hass.config.components.add("recorder") return hass @@ -63,7 +63,7 @@ async def test_relabels_every_particulate_sensor(hass: HomeAssistant) -> None: _add_sensor(hass, entry, key).entity_id for key in ("dust", "fine_dust", "super_fine_dust") } - with patch(RELABEL) as relabel: + with patch(RELABEL, autospec=True) as relabel: assert await async_migrate_entry(hass, entry) is True assert {call.args[1] for call in relabel.call_args_list} == expected @@ -82,7 +82,7 @@ async def test_leaves_other_sensors_on_the_same_device_alone(hass: HomeAssistant for key in ("odor", "clean_level", "co2", "dustbag_usage", "dustbin_auto_close"): _add_sensor(hass, entry, key) - with patch(RELABEL) as relabel: + with patch(RELABEL, autospec=True) as relabel: assert await async_migrate_entry(hass, entry) is True assert [call.args[1] for call in relabel.call_args_list] == [dust.entity_id] @@ -98,24 +98,73 @@ async def test_skips_families_that_did_not_gain_the_unit(hass: HomeAssistant) -> _add_sensor(hass, entry, "dust") _add_sensor(hass, entry, "fine_dust") - with patch(RELABEL) as relabel: + with patch(RELABEL, autospec=True) as relabel: assert await async_migrate_entry(hass, entry) is True assert relabel.call_args_list == [], device_type assert entry.version == 3 -async def test_is_a_no_op_without_the_recorder(hass: HomeAssistant) -> None: - """A setup running without the recorder has no statistics to relabel, - and importing recorder internals there would fail outright.""" +async def test_defers_rather_than_consuming_the_migration_without_the_recorder( + hass: HomeAssistant, +) -> None: + """A boot where the recorder didn't come up must not burn the one-shot + migration -- doing so would leave the statistics suppressed for good. + The entry stays on v2 so the next start retries.""" entry = _entry(hass, "air_purifier") _add_sensor(hass, entry, "dust") hass.config.components.remove("recorder") - with patch(RELABEL) as relabel: + with patch(RELABEL, autospec=True) as relabel: assert await async_migrate_entry(hass, entry) is True assert relabel.call_args_list == [] + assert entry.version == 2 + + # ...and the retry lands once the recorder is there. + hass.config.components.add("recorder") + with patch(RELABEL, autospec=True) as relabel: + assert await async_migrate_entry(hass, entry) is True + + assert len(relabel.call_args_list) == 1 + assert entry.version == 3 + + +async def test_omits_unit_class_on_an_older_home_assistant(hass: HomeAssistant) -> None: + """`new_unit_class` only exists from HA 2025.11, and hacs.json still + declares 2025.1 as the minimum. Passing it to the older signature is a + TypeError out of async_migrate_entry, which fails the whole entry -- so + the kwarg is feature-detected rather than assumed. + + Stands in for an older HA by patching in that exact signature; the + autospec'd tests above cover the modern one. + """ + entry = _entry(hass, "air_purifier") + _add_sensor(hass, entry, "dust") + + seen: list[dict] = [] + + def old_signature(hass, statistic_id, *, new_statistic_id=None, **kwargs): + seen.append(kwargs) + + with patch(RELABEL, old_signature): + assert await async_migrate_entry(hass, entry) is True + + assert seen == [{"new_unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}] + assert entry.version == 3 + + +async def test_a_relabel_failure_never_fails_the_entry(hass: HomeAssistant) -> None: + """Relabelling is a convenience -- without it the user gets HA's own + units_changed repair, which is where they were before. An older HA whose + async_update_statistics_metadata has a different signature, or any other + recorder-side surprise, must not cost them the integration.""" + entry = _entry(hass, "air_purifier") + _add_sensor(hass, entry, "dust") + + with patch(RELABEL, autospec=True, side_effect=TypeError("older HA signature")): + assert await async_migrate_entry(hass, entry) is True + assert entry.version == 3 @@ -130,7 +179,7 @@ async def test_follows_a_renamed_entity_rather_than_rebuilding_its_id( renamed = _add_sensor(hass, entry, "dust", suggested_object_id="living_room_pm10") assert renamed.entity_id == "sensor.living_room_pm10" - with patch(RELABEL) as relabel: + with patch(RELABEL, autospec=True) as relabel: assert await async_migrate_entry(hass, entry) is True assert [call.args[1] for call in relabel.call_args_list] == ["sensor.living_room_pm10"] @@ -138,10 +187,11 @@ async def test_follows_a_renamed_entity_rather_than_rebuilding_its_id( async def test_matches_subdevice_prefixed_and_instanced_keys(hass: HomeAssistant) -> None: """_key() can prefix a subdevice and append an instance number, so the - match is on the tail rather than the whole unique_id.""" + match is on the tail rather than the whole unique_id. The instance form + is `_` (discovery.instance_suffix), not a bare digit.""" entry = _entry(hass, "air_purifier") ent_reg = er.async_get(hass) - for unique_suffix in ("indoor_0_dust", "fine_dust1", "super_fine_dust"): + for unique_suffix in ("indoor_0_dust", "fine_dust_1", "super_fine_dust"): ent_reg.async_get_or_create( "sensor", DOMAIN, @@ -157,7 +207,7 @@ async def test_matches_subdevice_prefixed_and_instanced_keys(hass: HomeAssistant config_entry=entry, ) - with patch(RELABEL) as relabel: + with patch(RELABEL, autospec=True) as relabel: assert await async_migrate_entry(hass, entry) is True assert len(relabel.call_args_list) == 3