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.
This commit is contained in:
Marc Billow
2026-08-15 00:23:53 +00:00
parent 627b761462
commit 21af5708cb
12 changed files with 360 additions and 37 deletions
@@ -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 device shares those capabilities' catalog entries. This board additionally
reports a CO2 reading the other two families don't. reports a CO2 reading the other two families don't.
A second `value` list element on the particulate-matter types (e.g. Dust's A second `value` list element on the particulate-matter types (Dust's
`['31', '2']`) reads like a coarse quality-grade code, but nothing on this `['31', '2']`) is the device's own graded air-quality level for that
board confirms what its scale means -- left unbound rather than guessed; reading -- see common.sensor_item_value. Still unbound here: the grade's
index 0 is the only slot any family has ever read. 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 Dust/FineDust/SuperFineDust nevertheless stay without an HA
this board. The three-tier mapping (Dust=PM10, FineDust=PM2.5, `device_class`/`unit` here, which is now a migration call rather than an
SuperFineDust=PM1, µg/m³) is confirmed for the air-purifier family evidence gap. The mapping confirmed for the purifier family (issue #325,
(issue #325) but this standalone monitor has no same-moment app Dust=PM10 / FineDust=PM2.5 / SuperFineDust=PM1 in μg/m³) rests on
correlation of its own, so it keeps the untyped measurement sensors device-side grading that this board shares, so it would carry over. But
rather than inheriting that label. 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 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 # graded indices on that family, while this board has stamped all five as
# `measurement` since it was added (issue #210). Consuming state_class would # `measurement` since it was added (issue #210). Consuming state_class would
# silently drop long-term statistics for two sensors on shipped devices, and # silently drop long-term statistics for two sensors on shipped devices, and
# the pm10/pm25/pm1 labels are confirmed only for the purifier family # the pm10/pm25/pm1 labels are held back pending the statistics migration
# (issue #325). The shared rows supply only the key/icon/type here. # the module docstring describes. The shared rows supply only the
# key/icon/type here.
SENSORS = Capability( SENSORS = Capability(
href="/sensors/vs/0", href="/sensors/vs/0",
poll_tier="warm", poll_tier="warm",
@@ -67,14 +67,38 @@ def _has_top_level_modes(rep, resources):
# indices instead, where the mean of a grade isn't obviously meaningful; left # indices instead, where the mean of a grade isn't obviously meaningful; left
# without a state_class rather than guessing. # without a state_class rather than guessing.
# #
# device_class/unit confirmed on a live ARTIK051_TVTL against the SmartThings # device_class/unit: Dust=PM10, FineDust=PM2.5, SuperFineDust=PM1, all
# app at the same moment (issue #325): Dust=PM10, FineDust=PM2.5, # μg/m³ (issue #325). Three independent lines, none of them naming order --
# SuperFineDust=PM1, all µg/m³. Dust matched PM10 exactly; the other two # which is what the earlier "plausible but unconfirmed" note rejected:
# were 1 µg/m³ off the app, same order. #
# 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 = ( _AIR_QUALITY_SENSORS = (
("dust", "mdi:blur", "Dust", "measurement", "pm10", "µg/m³"), ("dust", "mdi:blur", "Dust", "measurement", "pm10", "μg/m³"),
("fine_dust", "mdi:blur", "FineDust", "measurement", "pm25", "µg/m³"), ("fine_dust", "mdi:blur", "FineDust", "measurement", "pm25", "μg/m³"),
("super_fine_dust", "mdi:blur", "SuperFineDust", "measurement", "pm1", "µg/m³"), ("super_fine_dust", "mdi:blur", "SuperFineDust", "measurement", "pm1", "μg/m³"),
("odor", "mdi:scent", "Odor", None, None, None), ("odor", "mdi:scent", "Odor", None, None, None),
("clean_level", "mdi:air-filter", "CleanLevel", None, None, None), ("clean_level", "mdi:air-filter", "CleanLevel", None, None, None),
) )
@@ -101,8 +101,15 @@ def _threshold_write(payload, rep, href=None):
def _sensor_item_value(items, type_): def _sensor_item_value(items, type_):
"""First value of the /sensors/vs/0 item with the given """First value of the /sensors/vs/0 item with the given
x.com.samsung.da.type. Dust/FineDust/SuperFineDust report a 2-element 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 array; only v[0] is used. v[1] is the device's own graded air-quality
unconfirmed. No device_class is set: the resource exposes no unit.""" 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 []: for it in items or []:
if isinstance(it, dict) and it.get("x.com.samsung.da.type") == type_: if isinstance(it, dict) and it.get("x.com.samsung.da.type") == type_:
v = it.get("x.com.samsung.da.value") v = it.get("x.com.samsung.da.value")
@@ -254,7 +254,29 @@ def sensor_item_value(items, sensor_type, index=0):
item is `{type, value: [...]}`; `index` picks which slot to read item is `{type, value: [...]}`; `index` picks which slot to read
(index 0 is the raw measurement on every family seen so far). Shared (index 0 is the raw measurement on every family seen so far). Shared
by range_hood.AIR_QUALITY, air_purifier.AIR_QUALITY, and 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 (): for item in items or ():
if not isinstance(item, dict): if not isinstance(item, dict):
continue continue
@@ -40,10 +40,11 @@ from .laundry import (
# implying anything about a plain washer's '1F'); 3 more (Eco Cold, Towels, # 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 # 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 # code after selecting each cycle on the appliance (issue #80). 2 more
# ('0A' Towels, 'B0' Mixed load) reported for a WW90DG5G34ABLE on the same # ('0A' Towels, 'B0' Mixed Load) reported for a WW90DG5G34ABLE on the same
# Table_02 family (issue #363). Two code pairs ('21'/'65' Colors, # Table_02 family (issue #363). Several codes legitimately share a label
# '27'/'5E' Rinse+Spin, and '24'/'54' Towels) legitimately share a label # across different course tables -- '21'/'65' Colors, '27'/'5E'/'78'
# across different course tables -- not typos. # 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 # 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 # course sets, so hardcoding one device's list would show/hide the wrong
@@ -657,7 +657,7 @@
"8f": "Kaltwäsche Intensiv", "8f": "Kaltwäsche Intensiv",
"96": "Weniger Mikrofasern", "96": "Weniger Mikrofasern",
"a0": "Schnelle Wäsche 15'", "a0": "Schnelle Wäsche 15'",
"b0": "Mischwäsche", "b0": "Gemischte Beladung",
"17": "Heruntergeladen", "17": "Heruntergeladen",
"69": "KI-Wäsche", "69": "KI-Wäsche",
"6a": "Wolle", "6a": "Wolle",
@@ -714,7 +714,7 @@
"8f": "Intense Cold", "8f": "Intense Cold",
"96": "Less Microfiber", "96": "Less Microfiber",
"a0": "15' Quick Wash", "a0": "15' Quick Wash",
"b0": "Mixed load" "b0": "Mixed Load"
} }
}, },
"washer_dry_level": { "washer_dry_level": {
@@ -695,7 +695,7 @@
"8f": "강력 냉수 세탁", "8f": "강력 냉수 세탁",
"96": "미세플라스틱저감", "96": "미세플라스틱저감",
"a0": "15분 쾌속세탁", "a0": "15분 쾌속세탁",
"b0": "혼합", "b0": "혼합 세탁",
"69": "AI 맞춤세탁", "69": "AI 맞춤세탁",
"6a": "울", "6a": "울",
"6b": "데님", "6b": "데님",
@@ -31,12 +31,20 @@ def test_graded_sensors_are_left_without_a_state_class():
def test_particulate_sensors_declare_pm_device_class_and_unit(): def test_particulate_sensors_declare_pm_device_class_and_unit():
"""Live same-moment SmartThings correlation on ARTIK051_TVTL (issue #325) """Dust/FineDust/SuperFineDust map to PM10/PM2.5/PM1 (issue #325) -- see
maps Dust/FineDust/SuperFineDust to PM10/PM2.5/PM1 in µg/m³.""" 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 = { expected = {
"dust": ("pm10", "µg/m³"), "dust": ("pm10", UG_M3),
"fine_dust": ("pm25", "µg/m³"), "fine_dust": ("pm25", UG_M3),
"super_fine_dust": ("pm1", "µg/m³"), "super_fine_dust": ("pm1", UG_M3),
} }
for key, (device_class, unit) in expected.items(): for key, (device_class, unit) in expected.items():
desc = _desc(key) desc = _desc(key)
@@ -48,13 +56,17 @@ def test_particulate_sensors_declare_pm_device_class_and_unit():
assert desc.unit is None, key assert desc.unit is None, key
def test_state_class_comes_from_the_shared_tuples_fourth_column(): def test_metadata_comes_from_the_shared_tuples_own_columns():
"""The rows carry their own state_class rather than a parallel lookup, so """The rows carry their own state_class/device_class/unit rather than a
a new sensor can't be added here without deciding the question.""" 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: for row in air_purifier._AIR_QUALITY_SENSORS:
assert len(row) == 6, row assert len(row) == 6, row
assert row[3] in ("measurement", None), row assert row[3] in ("measurement", None), row
assert row[4] in ("pm10", "pm25", "pm1", 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(): def test_air_monitor_keeps_stamping_every_shared_sensor():
+135
View File
@@ -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
+94
View File
@@ -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
+21
View File
@@ -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(): def test_reported_washer_standard_courses_all_have_table_02_labels():
"""Every non-personal code in the reported washer's live course list """Every non-personal code in the reported washer's live course list
must resolve through the Table_02 catalog instead of appearing as raw must resolve through the Table_02 catalog instead of appearing as raw