diff --git a/custom_components/localthings/__init__.py b/custom_components/localthings/__init__.py index 0fa67f8..3b0803f 100644 --- a/custom_components/localthings/__init__.py +++ b/custom_components/localthings/__init__.py @@ -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 `_` 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 diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index 3b49039..d4ad8ac 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -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 = "" diff --git a/custom_components/localthings/manifest.json b/custom_components/localthings/manifest.json index 0a8f5ea..a54e712 100644 --- a/custom_components/localthings/manifest.json +++ b/custom_components/localthings/manifest.json @@ -1,6 +1,7 @@ { "domain": "localthings", "name": "LocalThings", + "after_dependencies": ["recorder"], "codeowners": ["@mbillow"], "config_flow": true, "dependencies": [], diff --git a/custom_components/localthings/registry/capabilities/air_monitor.py b/custom_components/localthings/registry/capabilities/air_monitor.py index 92877ef..71c382b 100644 --- a/custom_components/localthings/registry/capabilities/air_monitor.py +++ b/custom_components/localthings/registry/capabilities/air_monitor.py @@ -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", diff --git a/custom_components/localthings/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py index 6949d2b..f479970 100644 --- a/custom_components/localthings/registry/capabilities/air_purifier.py +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -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 ), ) diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 5c96473..a44fc2d 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -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", diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index dfab158..693420a 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -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 diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index 81be9d8..af289b8 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -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 diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index 49b8ad4..261a98c 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -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" } }, diff --git a/custom_components/localthings/translations/de.json b/custom_components/localthings/translations/de.json index 92f50be..6fee358 100644 --- a/custom_components/localthings/translations/de.json +++ b/custom_components/localthings/translations/de.json @@ -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", diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 0b3405a..2b22310 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -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": { diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index dba8a3b..f9ff0c5 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -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", diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index 45116c7..84afe6d 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -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": { diff --git a/custom_components/localthings/translations/ko.json b/custom_components/localthings/translations/ko.json index f051c67..51eeb3e 100644 --- a/custom_components/localthings/translations/ko.json +++ b/custom_components/localthings/translations/ko.json @@ -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": "데님", diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index c570311..f11b950 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -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" } }, diff --git a/tests/localthings/test_migration.py b/tests/localthings/test_migration.py index e8997bd..49a9152 100644 --- a/tests/localthings/test_migration.py +++ b/tests/localthings/test_migration.py @@ -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 diff --git a/tests/localthings/test_statistics_migration.py b/tests/localthings/test_statistics_migration.py new file mode 100644 index 0000000..27fd7cd --- /dev/null +++ b/tests/localthings/test_statistics_migration.py @@ -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 `_` (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 diff --git a/tests/test_air_purifier_air_quality_statistics.py b/tests/test_air_purifier_air_quality_statistics.py index 48c97b0..3bec9cb 100644 --- a/tests/test_air_purifier_air_quality_statistics.py +++ b/tests/test_air_purifier_air_quality_statistics.py @@ -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(): diff --git a/tests/test_air_quality_grade_column.py b/tests/test_air_quality_grade_column.py new file mode 100644 index 0000000..ba93d42 --- /dev/null +++ b/tests/test_air_quality_grade_column.py @@ -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 diff --git a/tests/test_sensor_device_class_units.py b/tests/test_sensor_device_class_units.py new file mode 100644 index 0000000..86873db --- /dev/null +++ b/tests/test_sensor_device_class_units.py @@ -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 diff --git a/tests/test_statistics_migration_end_to_end.py b/tests/test_statistics_migration_end_to_end.py new file mode 100644 index 0000000..d383e41 --- /dev/null +++ b/tests/test_statistics_migration_end_to_end.py @@ -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 diff --git a/tests/test_translations.py b/tests/test_translations.py index 2c9b7cd..8697510 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -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