Files
localthings/tests/test_sensor_device_class_units.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

95 lines
3.8 KiB
Python

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