washer: add 0A/B0 cycle labels; purifier: PM device classes

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).
This commit is contained in:
JayChickenK
2026-08-15 00:17:09 +00:00
committed by Marc Billow
parent 3735d8b806
commit 627b761462
11 changed files with 74 additions and 44 deletions
@@ -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",
@@ -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
),
)
@@ -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
@@ -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"
}
},
@@ -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",
@@ -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": {
@@ -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",
@@ -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": {
@@ -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": "데님",
@@ -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"
}
},
@@ -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():