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:
22 files changed
+928
-68
No files matched your search
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user