Files
localthings/tests/test_air_quality_grade_column.py
Marc Billow 21af5708cb 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.
2026-08-15 00:23:53 +00:00

136 lines
6.1 KiB
Python

"""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