Merge pull request #370 from mbillow/claude/pr-365-review-4o0f94

washer: 0A/B0 cycle labels; air quality: PM device classes + statistics migration
This commit is contained in:
Marc Billow authored and GitHub committed 2026-08-14 20:25:39 -05:00
commit 02d009380d
22 files changed
+928 -68

No files matched your search

+109 -2
View File
@@ -2,9 +2,15 @@
from __future__ import annotations
import inspect
import logging
import re
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER as PARTICULATE_UNIT,
)
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
@@ -12,7 +18,7 @@ from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.typing import ConfigType
from .const import CONF_HOST, CONF_PORT, CONF_SERIAL, DOMAIN, PLATFORMS
from .const import CONF_DEVICE_TYPE, CONF_HOST, CONF_PORT, CONF_SERIAL, DOMAIN, PLATFORMS
from .coordinator import LocalThingsCoordinator
from .registry.identity import resolve_serial
from .services import async_setup_services
@@ -125,6 +131,100 @@ def _repair_placeholder_keys(hass: HomeAssistant, entry: ConfigEntry, serial: st
)
# Registries whose Dust/FineDust/SuperFineDust sensors gained pm10/pm25/pm1
# and a unit in the release that introduced entry version 3. Deliberately
# not every family reading /sensors/vs/0: range_hood and airconditioner
# still declare no unit for their identically-named sensors, and relabelling
# their statistics to µg/m³ would assert a unit those entities don't report
# -- creating the very mismatch this migration exists to prevent.
_PARTICULATE_TYPED_IN_V3 = frozenset({"air_purifier", "air_monitor"})
# unique_id is f"{DOMAIN}_{serial}_{state_key}"; state_key is the descriptor
# key, optionally carrying a subdevice prefix and a trailing `_<n>` instance
# (registry/adapter._key, discovery.instance_suffix). Matching the tail rather
# than rebuilding the whole id keeps this working for a renamed entity, whose
# entity_id -- and so its statistic_id -- no longer follows from the key.
_PARTICULATE_KEY_RE = re.compile(r"_(?:super_fine_dust|fine_dust|dust)(?:_\d+)?$")
@callback
def _relabel_particulate_statistics(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Point existing particulate statistics at the unit they always were.
These sensors recorded long-term statistics with no unit, and the
release carrying this migration gives them µg/m³. Home Assistant treats
that as a unit change it can't convert and *suppresses statistics
generation entirely* for the entity until someone resolves the repair
(sensor.recorder._update_issues -> UNITS_CHANGED_ISSUE, and the matching
`continue` in its compile path). Silently freezing the history we just
finished labelling is the worst of both outcomes, so the metadata is
corrected up front instead.
Only the metadata row is rewritten, never the recorded values. The
readings were always µg/m³ concentrations (issue #325); what was missing
was the label, so there is nothing to convert and no way for this to
distort history. That is also why it uses
`async_update_statistics_metadata` and not `change_statistics_unit`,
which would scale every stored value.
A no-op when this device family isn't one that gained the unit, or when
the device never recorded any statistics -- the underlying UPDATE simply
matches no rows.
Returns False only when the recorder wasn't loaded, meaning the caller
should leave the entry on its old version and try again next start.
"""
if entry.data.get(CONF_DEVICE_TYPE) not in _PARTICULATE_TYPED_IN_V3:
return True
if "recorder" not in hass.config.components:
# after_dependencies orders the recorder ahead of us when it's
# configured, so this is either an install without it (nothing to
# relabel, and the retry costs one set lookup per start) or a boot
# where it failed to come up. Not distinguishable here, and burning
# the one-shot migration on the second case would leave the
# statistics suppressed for good.
_LOGGER.debug("recorder not loaded, deferring statistics relabel")
return False
from homeassistant.components.recorder.statistics import (
STATISTIC_UNIT_TO_UNIT_CONVERTER,
async_update_statistics_metadata,
)
kwargs: dict[str, Any] = {"new_unit_of_measurement": PARTICULATE_UNIT}
# `new_unit_class` only exists from HA 2025.11; hacs.json still supports
# 2025.1, where passing it is a TypeError -- which would propagate out of
# async_migrate_entry and fail the whole entry. Where it is supported it
# must be named, since omitting it is deprecated from HA 2026.11. µg/m³
# has a converter, so the value is 'concentration' rather than None.
if "new_unit_class" in inspect.signature(async_update_statistics_metadata).parameters:
converter = STATISTIC_UNIT_TO_UNIT_CONVERTER.get(PARTICULATE_UNIT)
kwargs["new_unit_class"] = converter.UNIT_CLASS if converter is not None else None
ent_reg = er.async_get(hass)
for registry_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id):
if registry_entry.domain != "sensor":
continue
if not _PARTICULATE_KEY_RE.search(registry_entry.unique_id):
continue
_LOGGER.debug("relabelling statistics unit for %s", registry_entry.entity_id)
try:
async_update_statistics_metadata(hass, registry_entry.entity_id, **kwargs)
except Exception:
# Relabelling is a convenience: without it the user gets Home
# Assistant's own units_changed repair, which is where they were
# before this migration existed. Never worth failing setup over,
# so no recorder-side surprise can cost them the integration.
_LOGGER.warning(
"Could not relabel statistics unit for %s; Home Assistant will "
"offer a units-changed repair for it instead",
registry_entry.entity_id,
exc_info=True,
)
return True
return True
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate an entry to the current version.
@@ -132,8 +232,11 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
can key its registry entries before the first poll (issue #236), and
repairs whatever the old placeholder-keyed registration already
orphaned.
v2 -> v3 relabels the recorded statistics for the particulate sensors,
which gained a device_class/unit in the same release (issue #325).
"""
if entry.version > 2:
if entry.version > 3:
return False # downgrade: this release doesn't know the newer shape
if entry.version == 1:
@@ -147,6 +250,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_repair_placeholder_keys(hass, entry, serial)
_LOGGER.debug("migrated entry %s to version 2 (serial=%s)", entry.entry_id, serial)
if entry.version == 2 and _relabel_particulate_statistics(hass, entry):
hass.config_entries.async_update_entry(entry, version=3)
_LOGGER.debug("migrated entry %s to version 3", entry.entry_id)
return True
+4 -1
View File
@@ -793,7 +793,10 @@ def _probe_and_validate(
class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 2
# v3 relabels the particulate sensors' recorded statistics; a freshly
# created entry has none to relabel, so it starts at the migrated
# version rather than walking through v2 (see async_migrate_entry).
VERSION = 3
def __init__(self) -> None:
self._host: str = ""
@@ -1,6 +1,7 @@
{
"domain": "localthings",
"name": "LocalThings",
"after_dependencies": ["recorder"],
"codeowners": ["@mbillow"],
"config_flow": true,
"dependencies": [],
@@ -10,18 +10,25 @@ the same dust/fine_dust/super_fine_dust/odor/clean_level keys so this
device shares those capabilities' catalog entries. This board additionally
reports a CO2 reading the other two families don't.
A second `value` list element on the particulate-matter types (e.g. Dust's
`['31', '2']`) reads like a coarse quality-grade code, but nothing on this
board confirms what its scale means -- left unbound rather than guessed;
index 0 is the only slot any family has ever read.
A second `value` list element on the particulate-matter types (Dust's
`['31', '2']`) is the device's own graded air-quality level for that
reading -- see common.sensor_item_value. Still unbound here: the grade's
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 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 carry the same HA `device_class`/`unit` as the
purifier family (issue #325, Dust=PM10 / FineDust=PM2.5 /
SuperFineDust=PM1 in μg/m³). The mapping rests on device-side grading this
board shares rather than on anything purifier-specific, so typing one
family and not the other would have been an inconsistency, not caution.
These sensors have recorded *unitless* long-term statistics since issue
#210, though, and Home Assistant suppresses statistics generation outright
for an entity whose unit no longer matches its recorded metadata -- so
stamping a unit on would have silently stopped the history it was meant to
label. __init__.py's v2->v3 entry migration relabels that metadata first.
"""
from datetime import time as dt_time
@@ -31,12 +38,15 @@ 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
# here: air_purifier leaves Odor/CleanLevel unstamped because they read as
# device_class/unit are taken from the shared rows; state_class deliberately
# is not. 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 that column
# would silently drop long-term statistics for two sensors on shipped
# devices. The pm10/pm25/pm1 labels carry over cleanly, though: they rest on
# device-side grading this board shares (see the module docstring), and
# __init__.py's v2->v3 entry migration relabels the unitless statistics
# these five have been recording so the new unit doesn't suppress them.
SENSORS = Capability(
href="/sensors/vs/0",
poll_tier="warm",
@@ -47,9 +57,11 @@ SENSORS = Capability(
field="x.com.samsung.da.items",
icon=icon,
state_class="measurement",
device_class=device_class,
unit=unit,
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, _state_class, device_class, unit 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,40 @@ 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: Dust=PM10, FineDust=PM2.5, SuperFineDust=PM1, all
# μg/m³ (issue #325). Three independent lines, none of them naming order --
# which is what the earlier "plausible but unconfirmed" note rejected:
#
# 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 = (
("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 +112,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
),
)
@@ -101,8 +101,15 @@ def _threshold_write(payload, rep, href=None):
def _sensor_item_value(items, type_):
"""First value of the /sensors/vs/0 item with the given
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
unconfirmed. No device_class is set: the resource exposes no unit."""
array; only v[0] is used. v[1] is the device's own graded air-quality
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 []:
if isinstance(it, dict) and it.get("x.com.samsung.da.type") == type_:
v = it.get("x.com.samsung.da.value")
@@ -1551,12 +1558,10 @@ AIR_QUALITY = Capability(
# CO2 (PR #316, ACA-KR-TP2-21-AN9000) -- a type this file's other AC
# families don't report. Same field/shape air_monitor.SENSORS
# already models with device_class='carbon_dioxide'/unit='ppm', so
# this matches that descriptor rather than guessing fresh -- unlike
# the pm10/pm25/pm1 mapping air_monitor.py's own docstring
# deliberately rejects for the three dust-type keys above (Samsung's
# two-tier PM10/PM2.5 convention doesn't confirm where a third tier
# or PM1 fits), ppm for a field literally named CO2 isn't a guess of
# that kind.
# this matches that descriptor rather than guessing fresh. The dust
# keys above stay untyped for the reason in _sensor_item_value: the
# pm10/pm25/pm1 mapping is confirmed for the purifier and monitor
# families (issue #325), but no AC family has evidence of its own.
SensorDesc(
key="co2",
field="x.com.samsung.da.items",
@@ -254,7 +254,29 @@ def sensor_item_value(items, sensor_type, index=0):
item is `{type, value: [...]}`; `index` picks which slot to read
(index 0 is the raw measurement on every family seen so far). Shared
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 ():
if not isinstance(item, dict):
continue
@@ -39,9 +39,12 @@ 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). Several codes legitimately share a label
# across different course tables -- '21'/'65' Colors, '27'/'5E'/'78'
# 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
# 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": "Gemischte Beladung",
"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"
}
},
+4 -2
View File
@@ -38,7 +38,9 @@ async def test_migration_recovers_serial_from_unique_id(
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.version == 2
# Straight through to the current version: v2 -> v3 is a statistics
# relabel that no-ops for a family without particulate sensors.
assert entry.version == 3
assert entry.data[CONF_SERIAL] == MOCK_SERIAL
@@ -257,7 +259,7 @@ async def test_migration_rejects_a_future_entry_version(hass: HomeAssistant) ->
written by a newer release."""
from custom_components.localthings import async_migrate_entry
entry = MockConfigEntry(domain=DOMAIN, data=LEGACY_ENTRY_DATA, version=3)
entry = MockConfigEntry(domain=DOMAIN, data=LEGACY_ENTRY_DATA, version=4)
entry.add_to_hass(hass)
assert await async_migrate_entry(hass, entry) is False
@@ -0,0 +1,221 @@
"""The v2 -> v3 entry migration that relabels particulate statistics.
Dust/FineDust/SuperFineDust gained a pm10/pm25/pm1 device_class and a
µg/m³ unit (issue #325) after having recorded long-term statistics with no
unit at all. Home Assistant treats that as a unit change it cannot convert
and *suppresses statistics generation* for the entity until a human
resolves the repair, so the metadata is corrected during migration instead.
Only the metadata row is touched, never the recorded values -- the readings
were always µg/m³, so there is nothing to convert.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.localthings import async_migrate_entry
from custom_components.localthings.const import CONF_DEVICE_TYPE, DOMAIN
from .conftest import ENTRY_DATA, MOCK_SERIAL
RELABEL = "homeassistant.components.recorder.statistics.async_update_statistics_metadata"
@pytest.fixture(autouse=True)
def _recorder_loaded(hass: HomeAssistant):
"""Most tests here assume a normal install, where after_dependencies has
pulled the recorder in. The deferral test below undoes it."""
hass.config.components.add("recorder")
return hass
def _entry(hass: HomeAssistant, device_type: str) -> MockConfigEntry:
entry = MockConfigEntry(
domain=DOMAIN,
data={**ENTRY_DATA, CONF_DEVICE_TYPE: device_type},
unique_id=f"{DOMAIN}_{MOCK_SERIAL}",
version=2,
)
entry.add_to_hass(hass)
return entry
def _add_sensor(hass: HomeAssistant, entry: MockConfigEntry, key: str, **kwargs):
return er.async_get(hass).async_get_or_create(
"sensor",
DOMAIN,
f"{DOMAIN}_{MOCK_SERIAL}_{key}",
config_entry=entry,
**kwargs,
)
async def test_relabels_every_particulate_sensor(hass: HomeAssistant) -> None:
entry = _entry(hass, "air_purifier")
expected = {
_add_sensor(hass, entry, key).entity_id for key in ("dust", "fine_dust", "super_fine_dust")
}
with patch(RELABEL, autospec=True) as relabel:
assert await async_migrate_entry(hass, entry) is True
assert {call.args[1] for call in relabel.call_args_list} == expected
for call in relabel.call_args_list:
assert call.kwargs["new_unit_of_measurement"] == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
# µg/m³ has a converter, so the class must be named, not None --
# passing neither is deprecated and breaks in HA Core 2026.11.
assert call.kwargs["new_unit_class"] == "concentration"
assert entry.version == 3
async def test_leaves_other_sensors_on_the_same_device_alone(hass: HomeAssistant) -> None:
"""Odor/CleanLevel/CO2 share the resource but keep the units they had."""
entry = _entry(hass, "air_monitor")
dust = _add_sensor(hass, entry, "dust")
for key in ("odor", "clean_level", "co2", "dustbag_usage", "dustbin_auto_close"):
_add_sensor(hass, entry, key)
with patch(RELABEL, autospec=True) as relabel:
assert await async_migrate_entry(hass, entry) is True
assert [call.args[1] for call in relabel.call_args_list] == [dust.entity_id]
async def test_skips_families_that_did_not_gain_the_unit(hass: HomeAssistant) -> None:
"""range_hood and airconditioner still declare no unit for their
identically-named sensors. Relabelling their statistics would assert a
unit those entities don't report -- creating the very mismatch this
migration exists to prevent."""
for device_type in ("range_hood", "airconditioner"):
entry = _entry(hass, device_type)
_add_sensor(hass, entry, "dust")
_add_sensor(hass, entry, "fine_dust")
with patch(RELABEL, autospec=True) as relabel:
assert await async_migrate_entry(hass, entry) is True
assert relabel.call_args_list == [], device_type
assert entry.version == 3
async def test_defers_rather_than_consuming_the_migration_without_the_recorder(
hass: HomeAssistant,
) -> None:
"""A boot where the recorder didn't come up must not burn the one-shot
migration -- doing so would leave the statistics suppressed for good.
The entry stays on v2 so the next start retries."""
entry = _entry(hass, "air_purifier")
_add_sensor(hass, entry, "dust")
hass.config.components.remove("recorder")
with patch(RELABEL, autospec=True) as relabel:
assert await async_migrate_entry(hass, entry) is True
assert relabel.call_args_list == []
assert entry.version == 2
# ...and the retry lands once the recorder is there.
hass.config.components.add("recorder")
with patch(RELABEL, autospec=True) as relabel:
assert await async_migrate_entry(hass, entry) is True
assert len(relabel.call_args_list) == 1
assert entry.version == 3
async def test_omits_unit_class_on_an_older_home_assistant(hass: HomeAssistant) -> None:
"""`new_unit_class` only exists from HA 2025.11, and hacs.json still
declares 2025.1 as the minimum. Passing it to the older signature is a
TypeError out of async_migrate_entry, which fails the whole entry -- so
the kwarg is feature-detected rather than assumed.
Stands in for an older HA by patching in that exact signature; the
autospec'd tests above cover the modern one.
"""
entry = _entry(hass, "air_purifier")
_add_sensor(hass, entry, "dust")
seen: list[dict] = []
def old_signature(hass, statistic_id, *, new_statistic_id=None, **kwargs):
seen.append(kwargs)
with patch(RELABEL, old_signature):
assert await async_migrate_entry(hass, entry) is True
assert seen == [{"new_unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}]
assert entry.version == 3
async def test_a_relabel_failure_never_fails_the_entry(hass: HomeAssistant) -> None:
"""Relabelling is a convenience -- without it the user gets HA's own
units_changed repair, which is where they were before. An older HA whose
async_update_statistics_metadata has a different signature, or any other
recorder-side surprise, must not cost them the integration."""
entry = _entry(hass, "air_purifier")
_add_sensor(hass, entry, "dust")
with patch(RELABEL, autospec=True, side_effect=TypeError("older HA signature")):
assert await async_migrate_entry(hass, entry) is True
assert entry.version == 3
async def test_follows_a_renamed_entity_rather_than_rebuilding_its_id(
hass: HomeAssistant,
) -> None:
"""statistic_id is the entity_id, which the user can rename. Matching the
unique_id tail and reading entity_id back off the registry is what keeps
this correct for a renamed sensor -- reconstructing an entity_id from the
descriptor key would relabel a statistic nobody is recording."""
entry = _entry(hass, "air_purifier")
renamed = _add_sensor(hass, entry, "dust", suggested_object_id="living_room_pm10")
assert renamed.entity_id == "sensor.living_room_pm10"
with patch(RELABEL, autospec=True) as relabel:
assert await async_migrate_entry(hass, entry) is True
assert [call.args[1] for call in relabel.call_args_list] == ["sensor.living_room_pm10"]
async def test_matches_subdevice_prefixed_and_instanced_keys(hass: HomeAssistant) -> None:
"""_key() can prefix a subdevice and append an instance number, so the
match is on the tail rather than the whole unique_id. The instance form
is `_<n>` (discovery.instance_suffix), not a bare digit."""
entry = _entry(hass, "air_purifier")
ent_reg = er.async_get(hass)
for unique_suffix in ("indoor_0_dust", "fine_dust_1", "super_fine_dust"):
ent_reg.async_get_or_create(
"sensor",
DOMAIN,
f"{DOMAIN}_{MOCK_SERIAL}_{unique_suffix}",
config_entry=entry,
)
# Near-misses that must not match.
for unique_suffix in ("dustbag_full", "dustbin_auto_close", "dust_filter_reset"):
ent_reg.async_get_or_create(
"sensor",
DOMAIN,
f"{DOMAIN}_{MOCK_SERIAL}_{unique_suffix}",
config_entry=entry,
)
with patch(RELABEL, autospec=True) as relabel:
assert await async_migrate_entry(hass, entry) is True
assert len(relabel.call_args_list) == 3
async def test_a_fresh_entry_starts_at_the_migrated_version(hass: HomeAssistant) -> None:
"""A newly created entry has no statistics to relabel, so the config flow
mints v3 directly rather than walking through the migration."""
from custom_components.localthings.config_flow import LocalThingsConfigFlow
assert LocalThingsConfigFlow.VERSION == 3
@@ -30,30 +30,54 @@ 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():
"""Dust/FineDust/SuperFineDust map to PM10/PM2.5/PM1 (issue #325) -- see
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 = {
"dust": ("pm10", UG_M3),
"fine_dust": ("pm25", UG_M3),
"super_fine_dust": ("pm1", UG_M3),
}
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."""
def test_metadata_comes_from_the_shared_tuples_own_columns():
"""The rows carry their own state_class/device_class/unit rather than a
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:
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
# 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():
"""air_monitor imports _AIR_QUALITY_SENSORS and discards the fourth column
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."""
def test_air_monitor_takes_the_pm_labels_but_not_the_state_class():
"""air_monitor imports _AIR_QUALITY_SENSORS and consumes device_class and
unit -- the mapping rests on device-side grading that board shares (issue
#325), so typing one family and not the other would be an inconsistency.
state_class is still discarded: 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."""
from custom_components.localthings.registry.capabilities import air_monitor
assert air_monitor.SENSORS.href == "/sensors/vs/0"
@@ -62,6 +86,14 @@ 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 == _desc(key).device_class, key
assert desc.unit == _desc(key).unit, key
# And the graded pair stays untyped on both families.
for key in GRADED:
desc = next(
d for d in air_monitor.SENSORS.entities if d.key == key and isinstance(d, SensorDesc)
)
assert (desc.device_class, desc.unit) == (None, None), key
def test_every_air_quality_sensor_still_reads_a_plain_int():
+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
@@ -0,0 +1,161 @@
"""The v2 -> v3 statistics relabel against a real recorder, not a mock.
tests/localthings/test_statistics_migration.py proves the migration calls
HA's API with the right arguments for the right entities. It cannot prove
that call does what the migration needs, because the recorder is patched
out. This drives an in-memory recorder end to end: statistics recorded
unitless, migration run, metadata inspected -- and, most importantly, the
recorded *values* checked to be untouched, which is the claim that makes
doing this automatically safe rather than something to ask each user about.
Lives here rather than under tests/localthings/ on purpose: that package's
autouse `enable_custom_integrations` fixture depends on `hass`, which
starts Home Assistant before `recorder_mock` can claim its database URL.
Nothing here loads the integration -- `async_migrate_entry` is called
directly -- so the entry only needs the one key the v2 -> v3 step reads.
"""
from __future__ import annotations
from datetime import timedelta
from functools import partial
from typing import cast
import pytest
from homeassistant.components.recorder.models import StatisticMeanType, StatisticMetaData
from homeassistant.components.recorder.statistics import (
async_import_statistics,
get_metadata,
statistics_during_period,
)
from homeassistant.components.recorder.util import get_instance
from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.util import dt as dt_util
from pytest_homeassistant_custom_component.common import MockConfigEntry
from pytest_homeassistant_custom_component.components.recorder.common import (
async_wait_recording_done,
)
from custom_components.localthings import async_migrate_entry
from custom_components.localthings.const import CONF_DEVICE_TYPE, DOMAIN
from custom_components.localthings.registry.entities import SensorDesc
SERIAL = "TEST-SERIAL-0000"
RECORDED = [11.0, 9.0, 14.0]
async def _seed_unitless_statistics(hass: HomeAssistant, entity_id: str) -> None:
"""Record hourly statistics the way these sensors always have: numeric
means, no unit of measurement at all."""
start = dt_util.utcnow().replace(minute=0, second=0, microsecond=0) - timedelta(hours=4)
async_import_statistics(
hass,
{
"mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
"name": None,
"source": "recorder",
"statistic_id": entity_id,
"unit_class": None,
"unit_of_measurement": None,
},
[
{"start": start + timedelta(hours=i), "mean": value, "min": value, "max": value}
for i, value in enumerate(RECORDED)
],
)
await async_wait_recording_done(hass)
async def _metadata(hass: HomeAssistant, entity_id: str) -> StatisticMetaData:
result = await get_instance(hass).async_add_executor_job(
partial(get_metadata, hass, statistic_ids={entity_id})
)
return result[entity_id][1]
async def _means(hass: HomeAssistant, entity_id: str) -> list[float]:
rows = await get_instance(hass).async_add_executor_job(
statistics_during_period,
hass,
dt_util.utcnow() - timedelta(days=1),
None,
{entity_id},
"hour",
None,
{"mean"},
)
return [cast(float, row["mean"]) for row in rows.get(entity_id, [])]
@pytest.fixture
def purifier_entry(hass: HomeAssistant) -> MockConfigEntry:
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_DEVICE_TYPE: "air_purifier"},
unique_id=f"{DOMAIN}_{SERIAL}",
version=2,
)
entry.add_to_hass(hass)
return entry
def _dust_entity(hass: HomeAssistant, entry: MockConfigEntry):
return er.async_get(hass).async_get_or_create(
"sensor", DOMAIN, f"{DOMAIN}_{SERIAL}_dust", config_entry=entry
)
async def test_relabels_metadata_without_touching_recorded_values(
recorder_mock, hass: HomeAssistant, purifier_entry: MockConfigEntry
) -> None:
dust = _dust_entity(hass, purifier_entry)
await _seed_unitless_statistics(hass, dust.entity_id)
before = await _metadata(hass, dust.entity_id)
assert before["unit_of_measurement"] is None
assert await _means(hass, dust.entity_id) == RECORDED
assert await async_migrate_entry(hass, purifier_entry) is True
await async_wait_recording_done(hass)
after = await _metadata(hass, dust.entity_id)
assert after["unit_of_measurement"] == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
assert after["unit_class"] == "concentration"
# The readings were always µg/m³; only the label was missing. Nothing is
# converted, so the recorded history still says exactly what it said.
assert await _means(hass, dust.entity_id) == RECORDED
async def test_recorded_unit_ends_up_matching_what_the_descriptor_declares(
recorder_mock, hass: HomeAssistant, purifier_entry: MockConfigEntry
) -> None:
"""The invariant the migration exists to establish, stated directly.
HA raises units_changed -- and suppresses statistics generation -- when
an entity's unit disagrees with the unit recorded against its
statistic_id. Rather than drive HA's validation to observe that, this
asserts the condition that validation reads: after migrating, the
recorded metadata says exactly what the descriptor says. Asserting our
own invariant instead of Home Assistant's reaction to it keeps the test
off internals that change between versions (both `_update_issues` and
`validate_statistics` have gained parameters), and tests this repo
rather than that one."""
from custom_components.localthings.registry.capabilities import air_purifier
dust = _dust_entity(hass, purifier_entry)
await _seed_unitless_statistics(hass, dust.entity_id)
desc = next(
d
for d in air_purifier.AIR_QUALITY.entities
if d.key == "dust" and isinstance(d, SensorDesc)
)
assert (await _metadata(hass, dust.entity_id))["unit_of_measurement"] != desc.unit
assert await async_migrate_entry(hass, purifier_entry) is True
await async_wait_recording_done(hass)
assert (await _metadata(hass, dust.entity_id))["unit_of_measurement"] == desc.unit
+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():
"""Every non-personal code in the reported washer's live course list
must resolve through the Table_02 catalog instead of appearing as raw