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():