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.
95 lines
3.8 KiB
Python
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
|