From 75466761a11767d22d0e9a27939492ae11db09f1 Mon Sep 17 00:00:00 2001 From: Lukas Knoeller Date: Sat, 15 Aug 2026 01:40:56 +0000 Subject: [PATCH 1/2] Normalize appliance enums for HA translations Translates status values the integration previously surfaced as raw Samsung strings: cycle progress ('Rinse' -> "Rinsing"), diagnosis state, buzzer volume options, and the drum-clean counter's unit. Progress and diagnosis become enum sensors so Home Assistant looks their state up in the catalog; progress keys come from lowercasing the device's own value rather than a hardcoded map, so adding a language is a catalog-only change (PR #341 review). Rebased onto main, which has since gained the issue #345 sticky hold, and fixed up for two problems that combination exposes: Home Assistant refuses an enum state that isn't in the sensor's options, which takes the entity out rather than degrading it. The sticky hold froze progress at the device's raw 'Finish' while rep_fn had been normalized to 'finish', so every completed cycle -- the exact path #345 exists to serve -- would have produced a rejected state. Separately, options built from the catalog can only ever list values we have a translation for, while this registry's rule is that an unrecognized device value renders raw. Every progress token the shipped fixtures advertise is covered today, but Samsung ships more devices than we have dumps for, so the sensor platform now admits the live value into its own options: known values translate, unknown ones display untranslated instead of breaking the entity. The drum-clean unit moves from a native unit to the catalog because Home Assistant rejects an entity declaring both. Note it resolves against the default language, so the localized unit strings are inert -- kept only because every catalog must mirror English key for key. Also moves the diagnosis normalizer to common.py, so dryer.py doesn't import a private symbol from dishwasher.py to get it. --- .../registry/capabilities/common.py | 8 ++ .../registry/capabilities/dishwasher.py | 4 + .../registry/capabilities/dryer.py | 9 +- .../registry/capabilities/operational.py | 11 +- .../registry/capabilities/washer.py | 1 - custom_components/localthings/sensor.py | 21 +++- .../localthings/translations/cs.json | 35 +++++- .../localthings/translations/de.json | 35 +++++- .../localthings/translations/en.json | 35 +++++- .../localthings/translations/es.json | 35 +++++- .../localthings/translations/it.json | 35 +++++- .../localthings/translations/ko.json | 35 +++++- .../localthings/translations/nl.json | 35 +++++- tests/test_dishwasher_capabilities.py | 13 +- tests/test_operational_capability.py | 17 ++- tests/test_select_options.py | 20 +++ tests/test_sensor_enum_options.py | 115 ++++++++++++++++++ tests/test_sensor_sticky.py | 84 ++++++------- 18 files changed, 461 insertions(+), 87 deletions(-) create mode 100644 tests/test_sensor_enum_options.py diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index 693420a..27b2503 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -249,6 +249,14 @@ def _power_sensor_exists(rep, resources): return not model_allows_power_on_off(resources) +def diagnosis_status(value): + """'Ready' -> the catalog's 'ready'; anything else is left raw. + + Shared by dishwasher and dryer, which report the same field. + """ + return "ready" if value == "Ready" else value + + def sensor_item_value(items, sensor_type, index=0): """Pull one reading out of a `/sensors/vs/0`-style items[] list -- each item is `{type, value: [...]}`; `index` picks which slot to read diff --git a/custom_components/localthings/registry/capabilities/dishwasher.py b/custom_components/localthings/registry/capabilities/dishwasher.py index e47a3f3..2b3e656 100644 --- a/custom_components/localthings/registry/capabilities/dishwasher.py +++ b/custom_components/localthings/registry/capabilities/dishwasher.py @@ -9,6 +9,7 @@ wash, auto release dry) are read locally here. from ..capability import Capability from ..entities import ButtonDesc, SelectDesc, SensorDesc, SwitchDesc +from .common import diagnosis_status from .laundry import bool_option_switch, cycle_select # --------------------------------------------------------------------------- @@ -76,6 +77,9 @@ DIAGNOSIS = Capability( field="x.com.samsung.da.diagnosisStart", icon="mdi:stethoscope", entity_category="diagnostic", + device_class="enum", + options=("ready",), + value_fn=diagnosis_status, ), ButtonDesc( key="diagnosis_start", diff --git a/custom_components/localthings/registry/capabilities/dryer.py b/custom_components/localthings/registry/capabilities/dryer.py index edcfd52..403395b 100644 --- a/custom_components/localthings/registry/capabilities/dryer.py +++ b/custom_components/localthings/registry/capabilities/dryer.py @@ -11,6 +11,7 @@ the /course/vs/0 cycle select -- lives in laundry.py. from ..capability import Capability from ..entities import SensorDesc, SwitchDesc +from .common import diagnosis_status from .laundry import cycle_select, drum_clean_cycles_remaining, drum_clean_last_cleaned @@ -69,7 +70,6 @@ DRYER_COURSE = Capability( ), SensorDesc( key="drum_clean_cycles_remaining", - unit="cycles", icon="mdi:tumble-dryer-alert", state_class="measurement", exists_fn=lambda rep, resources: drum_clean_cycles_remaining(rep) is not None, @@ -91,7 +91,12 @@ DRYER_DIAGNOSIS = Capability( poll_tier="warm", entities=( SensorDesc( - key="diagnosis", field="x.com.samsung.da.diagnosisStart", entity_category="diagnostic" + key="diagnosis", + field="x.com.samsung.da.diagnosisStart", + entity_category="diagnostic", + device_class="enum", + options=("ready",), + value_fn=diagnosis_status, ), ), ) diff --git a/custom_components/localthings/registry/capabilities/operational.py b/custom_components/localthings/registry/capabilities/operational.py index e54db0c..8738345 100644 --- a/custom_components/localthings/registry/capabilities/operational.py +++ b/custom_components/localthings/registry/capabilities/operational.py @@ -6,6 +6,7 @@ Shared by dryer/dishwasher/oven/washer families. import math from datetime import UTC, datetime, timedelta +from ...catalog import translated_states from ..capability import Capability from ..entities import BinarySensorDesc, ButtonDesc, NumberDesc, SensorDesc @@ -25,7 +26,7 @@ def _to_ocf(v): def _progress(v): - return "Idle" if v in (None, "None") else v + return "idle" if v in (None, "None") else str(v).lower() def _int(v): @@ -201,13 +202,17 @@ OPERATIONAL_STATE = Capability( SensorDesc( key="progress", icon="mdi:progress-wrench", + device_class="enum", + options=tuple(sorted(translated_states("sensor", "progress"))), rep_fn=lambda rep: ( - "Idle" + "idle" if not _state_is_active(rep) else _progress(rep.get("x.com.samsung.da.progress")) ), sticky_fn=_just_finished, - sticky_value_fn=lambda rep: "Finish", + # The catalog key, not the device's 'Finish': rep_fn is normalized + # now, and a held value outside `options` is what HA rejects. + sticky_value_fn=lambda rep: "finish", sticky_bypass_fn=_new_cycle_running, ), SensorDesc( diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index af289b8..98a7cf5 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -275,7 +275,6 @@ WASHER_COURSE = Capability( ), SensorDesc( key="drum_clean_cycles_remaining", - unit="cycles", icon="mdi:washing-machine-alert", state_class="measurement", exists_fn=lambda rep, resources: drum_clean_cycles_remaining(rep) is not None, diff --git a/custom_components/localthings/sensor.py b/custom_components/localthings/sensor.py index 7f6e58e..a7086d5 100644 --- a/custom_components/localthings/sensor.py +++ b/custom_components/localthings/sensor.py @@ -49,8 +49,8 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity): SensorDeviceClass(desc.device_class) if desc.device_class else None ) self._attr_state_class = SensorStateClass(desc.state_class) if desc.state_class else None - if desc.options: - self._attr_options = list(desc.options) + # Always set, so the `options` property below can read it unguarded. + self._attr_options = list(desc.options) if desc.options else None self._hysteresis_value = None self._sticky_value = None self._sticky_until: float | None = None @@ -63,6 +63,23 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity): return desc.unit_fn(self.coordinator.resource(self._bound.href)) return self._attr_native_unit_of_measurement + @property + def options(self): + """Declared options, plus whatever this device is actually reporting. + + HA raises for an enum state outside `options`, which would turn any + device value we don't have a translation for into a broken entity -- + the opposite of this registry's rule that an unrecognized value + renders raw. Admitting the live value keeps it displayable; it just + shows untranslated (PR #341 review). + """ + if self._attr_options is None: + return None + value = self.native_value + if not isinstance(value, str) or value in self._attr_options: + return self._attr_options + return [*self._attr_options, value] + @property def native_value(self): raw = (self.coordinator.data or {}).get(self._state_key) diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index 261a98c..82ba3e9 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -261,7 +261,11 @@ "name": "Zvuk bzučáku", "state": { "off": "Vypnuto", - "on": "Zapnuto" + "on": "Zapnuto", + "volume_off": "Vypnuto", + "volume_low": "Nízká", + "volume_med": "Střední", + "volume_high": "Vysoká" } }, "discharging_time": { @@ -930,13 +934,20 @@ "name": "Teplota" }, "diagnosis": { - "name": "Diagnostika" + "name": "Diagnostika", + "state": { + "ready": "Připraveno" + } }, "diagnosis_status": { - "name": "Stav diagnostiky" + "name": "Stav diagnostiky", + "state": { + "ready": "Připraveno" + } }, "drum_clean_cycles_remaining": { - "name": "Čištění bubnu za" + "name": "Čištění bubnu za", + "unit_of_measurement": "cyklů" }, "drum_clean_last_cleaned": { "name": "Poslední čištění bubnu" @@ -1077,7 +1088,21 @@ "name": "Teplota sondy" }, "progress": { - "name": "Průběh" + "name": "Průběh", + "state": { + "idle": "Nečinný", + "weightsensing": "Detekce náplně", + "wash": "Praní", + "rinse": "Máchání", + "spin": "Odstřeďování", + "finish": "Dokončeno", + "steaming": "Napařování", + "airwashing": "Osvěžení vzduchem", + "drying": "Sušení", + "cooling": "Chlazení", + "predrain": "Vypouštění", + "prewash": "Předpírka" + } }, "progress_percentage": { "name": "Průběh v procentech" diff --git a/custom_components/localthings/translations/de.json b/custom_components/localthings/translations/de.json index 6fee358..caf28c3 100644 --- a/custom_components/localthings/translations/de.json +++ b/custom_components/localthings/translations/de.json @@ -261,7 +261,11 @@ "name": "Signalton", "state": { "off": "Aus", - "on": "Ein" + "on": "Ein", + "volume_off": "Aus", + "volume_low": "Niedrig", + "volume_med": "Mittel", + "volume_high": "Hoch" } }, "discharging_time": { @@ -924,13 +928,20 @@ "name": "Temperatur" }, "diagnosis": { - "name": "Diagnose" + "name": "Diagnose", + "state": { + "ready": "Bereit" + } }, "diagnosis_status": { - "name": "Diagnosestatus" + "name": "Diagnosestatus", + "state": { + "ready": "Bereit" + } }, "drum_clean_cycles_remaining": { - "name": "Trommelreinigung fällig in" + "name": "Trommelreinigung fällig in", + "unit_of_measurement": "Zyklen" }, "drum_clean_last_cleaned": { "name": "Trommel zuletzt gereinigt" @@ -1071,7 +1082,21 @@ "name": "Fühlertemperatur" }, "progress": { - "name": "Fortschritt" + "name": "Fortschritt", + "state": { + "idle": "Leerlauf", + "weightsensing": "Beladungserkennung", + "wash": "Waschen", + "rinse": "Spülen", + "spin": "Schleudern", + "finish": "Fertig", + "steaming": "Dämpfen", + "airwashing": "Luftreinigung", + "drying": "Trocknen", + "cooling": "Abkühlen", + "predrain": "Abpumpen", + "prewash": "Vorwäsche" + } }, "progress_percentage": { "name": "Fortschritt in Prozent" diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 2b22310..6976e2e 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -261,7 +261,11 @@ "name": "Buzzer sound", "state": { "off": "Off", - "on": "On" + "on": "On", + "volume_off": "Off", + "volume_low": "Low", + "volume_med": "Medium", + "volume_high": "High" } }, "discharging_time": { @@ -930,13 +934,20 @@ "name": "Temperature" }, "diagnosis": { - "name": "Diagnosis" + "name": "Diagnosis", + "state": { + "ready": "Ready" + } }, "diagnosis_status": { - "name": "Diagnosis status" + "name": "Diagnosis status", + "state": { + "ready": "Ready" + } }, "drum_clean_cycles_remaining": { - "name": "Drum clean due in" + "name": "Drum clean due in", + "unit_of_measurement": "cycles" }, "drum_clean_last_cleaned": { "name": "Drum last cleaned" @@ -1077,7 +1088,21 @@ "name": "Probe temperature" }, "progress": { - "name": "Progress" + "name": "Progress", + "state": { + "idle": "Idle", + "weightsensing": "Weight sensing", + "wash": "Washing", + "rinse": "Rinsing", + "spin": "Spinning", + "finish": "Finished", + "steaming": "Steaming", + "airwashing": "Air washing", + "drying": "Drying", + "cooling": "Cooling", + "predrain": "Pre-drain", + "prewash": "Pre-wash" + } }, "progress_percentage": { "name": "Progress percent" diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index f9ff0c5..a886cb7 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -457,7 +457,11 @@ "name": "Volumen", "state": { "off": "Apagado", - "on": "Encendido" + "on": "Encendido", + "volume_off": "Apagado", + "volume_low": "Bajo", + "volume_med": "Medio", + "volume_high": "Alto" } }, "discharging_time": { @@ -1123,13 +1127,20 @@ "name": "Temperatura" }, "diagnosis": { - "name": "Diagnóstico" + "name": "Diagnóstico", + "state": { + "ready": "Listo" + } }, "diagnosis_status": { - "name": "Estado del diagnóstico" + "name": "Estado del diagnóstico", + "state": { + "ready": "Listo" + } }, "drum_clean_cycles_remaining": { - "name": "Limpieza de tambor en" + "name": "Limpieza de tambor en", + "unit_of_measurement": "ciclos" }, "drum_clean_last_cleaned": { "name": "Última limpieza del tambor" @@ -1270,7 +1281,21 @@ "name": "Temperatura de la sonda" }, "progress": { - "name": "Progreso" + "name": "Progreso", + "state": { + "idle": "Inactiva", + "weightsensing": "Detección de carga", + "wash": "Lavado", + "rinse": "Aclarado", + "spin": "Centrifugado", + "finish": "Finalizado", + "steaming": "Vaporización", + "airwashing": "Lavado con aire", + "drying": "Secado", + "cooling": "Enfriamiento", + "predrain": "Drenaje previo", + "prewash": "Prelavado" + } }, "progress_percentage": { "name": "Porcentaje de progreso" diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index 84afe6d..c98c4c1 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -261,7 +261,11 @@ "name": "Suono cicalino", "state": { "off": "Spento", - "on": "Acceso" + "on": "Acceso", + "volume_off": "Spento", + "volume_low": "Basso", + "volume_med": "Medio", + "volume_high": "Alto" } }, "discharging_time": { @@ -930,13 +934,20 @@ "name": "Temperatura" }, "diagnosis": { - "name": "Diagnosi" + "name": "Diagnosi", + "state": { + "ready": "Pronto" + } }, "diagnosis_status": { - "name": "Stato diagnosi" + "name": "Stato diagnosi", + "state": { + "ready": "Pronto" + } }, "drum_clean_cycles_remaining": { - "name": "Pulizia cestello fra" + "name": "Pulizia cestello fra", + "unit_of_measurement": "cicli" }, "drum_clean_last_cleaned": { "name": "Ultima pulizia cestello" @@ -1077,7 +1088,21 @@ "name": "Temperatura sonda" }, "progress": { - "name": "Avanzamento" + "name": "Avanzamento", + "state": { + "idle": "Inattivo", + "weightsensing": "Rilevamento del carico", + "wash": "Lavaggio", + "rinse": "Risciacquo", + "spin": "Centrifuga", + "finish": "Completato", + "steaming": "Vapore", + "airwashing": "Lavaggio ad aria", + "drying": "Asciugatura", + "cooling": "Raffreddamento", + "predrain": "Scarico preliminare", + "prewash": "Prelavaggio" + } }, "progress_percentage": { "name": "Avanzamento percentuale" diff --git a/custom_components/localthings/translations/ko.json b/custom_components/localthings/translations/ko.json index 51eeb3e..4bb87dd 100644 --- a/custom_components/localthings/translations/ko.json +++ b/custom_components/localthings/translations/ko.json @@ -261,7 +261,11 @@ "name": "부저음", "state": { "off": "끄기", - "on": "켜기" + "on": "켜기", + "volume_off": "끔", + "volume_low": "낮음", + "volume_med": "중간", + "volume_high": "높음" } }, "discharging_time": { @@ -930,13 +934,20 @@ "name": "온도" }, "diagnosis": { - "name": "진단" + "name": "진단", + "state": { + "ready": "준비됨" + } }, "diagnosis_status": { - "name": "진단 상태" + "name": "진단 상태", + "state": { + "ready": "준비됨" + } }, "drum_clean_cycles_remaining": { - "name": "통세척까지 남은 횟수" + "name": "통세척까지 남은 횟수", + "unit_of_measurement": "회" }, "drum_clean_last_cleaned": { "name": "마지막 통세척" @@ -1077,7 +1088,21 @@ "name": "탐침 온도계 현재 온도" }, "progress": { - "name": "진행률" + "name": "진행률", + "state": { + "idle": "대기", + "weightsensing": "세탁물 감지", + "wash": "세탁", + "rinse": "헹굼", + "spin": "탈수", + "finish": "완료", + "steaming": "스팀", + "airwashing": "에어워시", + "drying": "건조", + "cooling": "냉각", + "predrain": "사전 배수", + "prewash": "애벌빨래" + } }, "progress_percentage": { "name": "진행률" diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index f11b950..4448e60 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -261,7 +261,11 @@ "name": "Zoemergeluid", "state": { "off": "Uit", - "on": "Aan" + "on": "Aan", + "volume_off": "Uit", + "volume_low": "Laag", + "volume_med": "Gemiddeld", + "volume_high": "Hoog" } }, "discharging_time": { @@ -930,13 +934,20 @@ "name": "Temperatuur" }, "diagnosis": { - "name": "Diagnose" + "name": "Diagnose", + "state": { + "ready": "Gereed" + } }, "diagnosis_status": { - "name": "Diagnosestatus" + "name": "Diagnosestatus", + "state": { + "ready": "Gereed" + } }, "drum_clean_cycles_remaining": { - "name": "Trommelreiniging over" + "name": "Trommelreiniging over", + "unit_of_measurement": "cycli" }, "drum_clean_last_cleaned": { "name": "Trommel laatst gereinigd" @@ -1077,7 +1088,21 @@ "name": "Sondetemperatuur" }, "progress": { - "name": "Voortgang" + "name": "Voortgang", + "state": { + "idle": "Inactief", + "weightsensing": "Beladingsdetectie", + "wash": "Wassen", + "rinse": "Spoelen", + "spin": "Centrifugeren", + "finish": "Voltooid", + "steaming": "Stomen", + "airwashing": "Luchtreiniging", + "drying": "Drogen", + "cooling": "Koelen", + "predrain": "Vooraf afpompen", + "prewash": "Voorwas" + } }, "progress_percentage": { "name": "Voortgangspercentage" diff --git a/tests/test_dishwasher_capabilities.py b/tests/test_dishwasher_capabilities.py index 148a8ed..f836326 100644 --- a/tests/test_dishwasher_capabilities.py +++ b/tests/test_dishwasher_capabilities.py @@ -6,7 +6,7 @@ check the dishwasher wiring and its device-specific options. """ from custom_components.localthings.registry.capabilities import dishwasher -from custom_components.localthings.registry.entities import SwitchDesc +from custom_components.localthings.registry.entities import SensorDesc, SwitchDesc class TestCycleOptions: @@ -60,3 +60,14 @@ class TestDishwasherOptions: assert desc.exists_fn is not None assert desc.exists_fn({"x.com.samsung.da.options": []}, {}) is False assert desc.exists_fn({"x.com.samsung.da.options": ["AutoDoorRelease_On"]}, {}) is True + + +def test_diagnosis_status_is_a_translatable_enum(): + desc = next( + e + for e in dishwasher.DIAGNOSIS.entities + if e.key == "diagnosis_status" and isinstance(e, SensorDesc) + ) + assert desc.device_class == "enum" + assert desc.options == ("ready",) + assert desc.value_fn("Ready") == "ready" diff --git a/tests/test_operational_capability.py b/tests/test_operational_capability.py index 4afebca..7933ac6 100644 --- a/tests/test_operational_capability.py +++ b/tests/test_operational_capability.py @@ -5,7 +5,7 @@ from custom_components.localthings.registry.capabilities.operational import ( _just_finished, _new_cycle_running, ) -from custom_components.localthings.registry.entities import NumberDesc +from custom_components.localthings.registry.entities import NumberDesc, SensorDesc def test_machine_state_maps_samsung_to_ocf(): @@ -83,6 +83,21 @@ class TestNewCycleRunning: ) +def test_progress_is_a_translatable_enum(): + desc = next( + e for e in OPERATIONAL_STATE.entities if e.key == "progress" and isinstance(e, SensorDesc) + ) + assert desc.device_class == "enum" + assert desc.options is not None + assert "rinse" in desc.options + assert "Rinse" not in desc.options + assert desc.rep_fn is not None + assert ( + desc.rep_fn({"x.com.samsung.da.state": "Run", "x.com.samsung.da.progress": "Rinse"}) + == "rinse" + ) + + class TestProgressPercentage: """issue #9: device firmware leaves progressPercentage stale (e.g. '1') after a cycle ends instead of resetting it, so it must be gated on diff --git a/tests/test_select_options.py b/tests/test_select_options.py index 3ced893..1e4ba98 100644 --- a/tests/test_select_options.py +++ b/tests/test_select_options.py @@ -7,6 +7,7 @@ from typing import ClassVar, cast from custom_components.localthings.coordinator import LocalThingsCoordinator from custom_components.localthings.registry.capabilities.laundry import ( + BUZZER_SOUND, cycle_select, washer_cycle_fallback, ) @@ -47,6 +48,25 @@ def test_options_field_unaffected(): assert entity.options == ["Lo", "Hi"] +def test_buzzer_volume_options_normalize_to_translation_keys(): + desc = next(e for e in BUZZER_SOUND.entities if e.key == "buzzer_sound") + entity = _make_select( + desc, + "/buzzersound/vs/0", + { + "/buzzersound/vs/0": { + "supportedBuzzerSound": [ + "Volume_Off", + "Volume_Low", + "Volume_Med", + "Volume_High", + ] + } + }, + ) + assert entity.options == ["volume_off", "volume_low", "volume_med", "volume_high"] + + def test_callable_options_receives_full_resource_snapshot(): """A callable options is handed the coordinator's full href->rep snapshot, not just this entity's own href's rep -- needed for course diff --git a/tests/test_sensor_enum_options.py b/tests/test_sensor_enum_options.py new file mode 100644 index 0000000..5c8c652 --- /dev/null +++ b/tests/test_sensor_enum_options.py @@ -0,0 +1,115 @@ +"""An enum sensor's reported state must always be inside its options. + +Home Assistant raises for an enum sensor whose state isn't in `options` +(sensor/__init__.py: "provides state value ... which is not in the list of +options provided"), so a value outside the list isn't a cosmetic problem -- +it takes the entity out. + +Two ways that bites, both from PR #341 giving `progress` a `device_class` +of enum: + +- the sticky hold (issue #345) froze the entity at the device's raw + 'Finish' while `rep_fn` had been normalized to 'finish', so every + completed cycle -- the exact path #345 exists to serve -- produced a + state outside the options; +- any progress value not in the translation catalog. Every token the + shipped fixtures advertise is covered today, but this registry's rule is + that an unrecognized device value renders raw rather than breaking, and + Samsung ships more devices than we have dumps for. +""" + +from __future__ import annotations + +from typing import cast + +from custom_components.localthings.coordinator import LocalThingsCoordinator +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE +from custom_components.localthings.registry.discovery import BoundEntity +from custom_components.localthings.registry.entities import SensorDesc +from custom_components.localthings.sensor import LocalThingsSensor + +_HREF = "/operational/state/vs/0" +_PROGRESS = next( + e for e in OPERATIONAL_STATE.entities if e.key == "progress" and isinstance(e, SensorDesc) +) +_ALL_BOUND = [ + BoundEntity(href=_HREF, capability=OPERATIONAL_STATE, desc=desc) + for desc in OPERATIONAL_STATE.entities +] + + +class _FakeConfigEntry: + def __init__(self): + self.options: dict = {} + + +class _FakeCoordinator: + def __init__(self): + self.device_serial = "TEST-SERIAL" + self.config_entry = _FakeConfigEntry() + self.resources: dict[str, dict] = {} + + def resource(self, href: str) -> dict: + return self.resources.get(href) or {} + + @property + def data(self) -> dict: + return flatten(_ALL_BOUND, self.resources) + + +def _sensor(desc): + coordinator = _FakeCoordinator() + bound = BoundEntity(href=_HREF, capability=OPERATIONAL_STATE, desc=desc) + return LocalThingsSensor(cast(LocalThingsCoordinator, coordinator), bound), coordinator + + +def _set(coordinator, **fields): + coordinator.resources[_HREF] = {f"x.com.samsung.da.{k}": v for k, v in fields.items()} + + +def test_the_sticky_hold_freezes_at_a_value_inside_the_options(): + """Issue #345's grace window fires on every finished cycle, so a held + value outside the options would break the common path, not an edge.""" + sensor, coordinator = _sensor(_PROGRESS) + + _set(coordinator, state="Run", progress="Wash") + assert sensor.native_value == "wash" + + # Cycle finishes, then the device drops out of active -- the hold engages. + _set(coordinator, state="Run", progress="Finish") + assert sensor.native_value in sensor.options + _set(coordinator, state="Ready", progress="Finish") + held = sensor.native_value + assert held == "finish" + assert held in sensor.options + + +def test_a_progress_value_we_cannot_translate_still_reports(): + """An unrecognized device value renders raw rather than taking the + entity out -- the same rule the course tables follow.""" + sensor, coordinator = _sensor(_PROGRESS) + + _set(coordinator, state="Run", progress="SomeFutureStage") + value = sensor.native_value + assert value == "somefuturestage" + assert value in sensor.options + # ...and admitting it doesn't drop the translated ones. + assert "rinse" in sensor.options + + +def test_known_values_do_not_grow_the_options_list(): + assert _PROGRESS.options is not None + sensor, coordinator = _sensor(_PROGRESS) + + _set(coordinator, state="Run", progress="Rinse") + assert sensor.options == list(_PROGRESS.options) + + +def test_a_non_enum_sensor_has_no_options(): + percentage = next(e for e in OPERATIONAL_STATE.entities if e.key == "progress_percentage") + sensor, coordinator = _sensor(percentage) + + _set(coordinator, state="Run", progressPercentage="40") + assert sensor.options is None + assert sensor.native_value == 40 diff --git a/tests/test_sensor_sticky.py b/tests/test_sensor_sticky.py index 0627a9b..9790edf 100644 --- a/tests/test_sensor_sticky.py +++ b/tests/test_sensor_sticky.py @@ -92,10 +92,10 @@ def test_holds_finish_after_state_leaves_active(): sensor, coordinator = _sensor(_PROGRESS_DESC) _replace(coordinator, state="Run", progress="Finish", progressPercentage="100") - assert sensor.native_value == "Finish" + assert sensor.native_value == "finish" _replace(coordinator, state="Ready") # device has moved on - assert sensor.native_value == "Finish" + assert sensor.native_value == "finish" def test_holds_finish_even_when_state_already_idle_at_first_observation(): @@ -106,11 +106,11 @@ def test_holds_finish_even_when_state_already_idle_at_first_observation(): sensor, coordinator = _sensor(_PROGRESS_DESC) _replace(coordinator, state="Ready", progress="Finish", progressPercentage="100") - assert sensor.native_value == "Finish" + assert sensor.native_value == "finish" # Still held on a later poll, even once the device stops repeating it. _replace(coordinator, state="Ready") - assert sensor.native_value == "Finish" + assert sensor.native_value == "finish" def test_progress_percentage_holds_100_regardless_of_the_raw_field_at_finish(): @@ -132,10 +132,10 @@ def test_real_data_flows_through_unheld_while_active(): sensor, coordinator = _sensor(_PROGRESS_DESC) _replace(coordinator, state="Run", progress="Spin") - assert sensor.native_value == "Spin" + assert sensor.native_value == "spin" _replace(coordinator, state="Run", progress="Rinse") - assert sensor.native_value == "Rinse" + assert sensor.native_value == "rinse" def test_never_finished_stays_idle(): @@ -144,10 +144,10 @@ def test_never_finished_stays_idle(): sensor, coordinator = _sensor(_PROGRESS_DESC) _replace(coordinator, state="Run", progress="Spin") - assert sensor.native_value == "Spin" + assert sensor.native_value == "spin" _replace(coordinator, state="Ready") - assert sensor.native_value == "Idle" + assert sensor.native_value == "idle" def test_a_new_cycle_starting_overrides_the_hold(): @@ -156,13 +156,13 @@ def test_a_new_cycle_starting_overrides_the_hold(): sensor, coordinator = _sensor(_PROGRESS_DESC) _replace(coordinator, state="Run", progress="Finish", progressPercentage="100") - assert sensor.native_value == "Finish" + assert sensor.native_value == "finish" _replace(coordinator, state="Ready") - assert sensor.native_value == "Finish" # still held + assert sensor.native_value == "finish" # still held _replace(coordinator, state="Run", progress="Wash") - assert sensor.native_value == "Wash" + assert sensor.native_value == "wash" def test_a_running_stage_after_finish_does_not_break_the_hold(): @@ -177,24 +177,24 @@ def test_a_running_stage_after_finish_does_not_break_the_hold(): sensor, coordinator = _sensor(desc) _replace(coordinator, state="Run", progress="Drying", progressPercentage="40") - assert sensor.native_value == "Drying" + assert sensor.native_value == "drying" _replace(coordinator, state="Run", progress="Cooling", progressPercentage="95") - assert sensor.native_value == "Cooling" + assert sensor.native_value == "cooling" _replace(coordinator, state="Run", progress="Finish", progressPercentage="100") - assert sensor.native_value == "Finish" + assert sensor.native_value == "finish" # The tail: a running stage again, state already idle. _replace(coordinator, state="Ready", progress="Drying", progressPercentage="100") - assert sensor.native_value == "Finish" + assert sensor.native_value == "finish" # ...then the device settles, still inside the window. _replace(coordinator, state="Ready", progress="None") - assert sensor.native_value == "Finish" + assert sensor.native_value == "finish" time.sleep(0.25) - assert sensor.native_value == "Idle" + assert sensor.native_value == "idle" def test_progress_percentage_survives_the_same_tail(): @@ -223,16 +223,16 @@ def test_a_paused_new_cycle_is_left_to_the_window_rather_than_released(): sensor, coordinator = _sensor(desc) _replace(coordinator, state="Run", progress="Finish", progressPercentage="100") - assert sensor.native_value == "Finish" + assert sensor.native_value == "finish" _replace(coordinator, state="Pause", progress="Wash") - assert sensor.native_value == "Finish" # held out, not released + assert sensor.native_value == "finish" # held out, not released time.sleep(0.1) - assert sensor.native_value == "Idle" # what a paused appliance always shows + assert sensor.native_value == "idle" # what a paused appliance always shows _replace(coordinator, state="Run", progress="Wash") - assert sensor.native_value == "Wash" + assert sensor.native_value == "wash" def test_a_flapping_finish_cannot_ratchet_an_open_window_forward(): @@ -243,20 +243,20 @@ def test_a_flapping_finish_cannot_ratchet_an_open_window_forward(): sensor, coordinator = _sensor(desc) _replace(coordinator, state="Ready", progress="Finish") - assert sensor.native_value == "Finish" + assert sensor.native_value == "finish" for _ in range(3): time.sleep(0.05) _replace(coordinator, state="Ready", progress="None") - assert sensor.native_value == "Finish" + assert sensor.native_value == "finish" _replace(coordinator, state="Ready", progress="Finish") - assert sensor.native_value == "Finish" + assert sensor.native_value == "finish" # 0.15s of flapping so far -- the window still ends 0.3s after the # first Finish, not 0.3s after the most recent re-entry. time.sleep(0.2) _replace(coordinator, state="Ready", progress="Finish") - assert sensor.native_value == "Idle" + assert sensor.native_value == "idle" def test_a_finish_after_the_window_closes_does_not_re_arm_it(): @@ -274,23 +274,23 @@ def test_a_finish_after_the_window_closes_does_not_re_arm_it(): sensor, coordinator = _sensor(desc) _replace(coordinator, state="Ready", progress="Finish") - assert sensor.native_value == "Finish" + assert sensor.native_value == "finish" time.sleep(0.1) - assert sensor.native_value == "Idle" + assert sensor.native_value == "idle" _replace(coordinator, state="Ready", progress="None") - assert sensor.native_value == "Idle" + assert sensor.native_value == "idle" _replace(coordinator, state="Ready", progress="Finish") - assert sensor.native_value == "Idle" + assert sensor.native_value == "idle" # A real cycle in between is what makes it available again. _replace(coordinator, state="Run", progress="Drying") - assert sensor.native_value == "Drying" + assert sensor.native_value == "drying" _replace(coordinator, state="Run", progress="Finish") - assert sensor.native_value == "Finish" + assert sensor.native_value == "finish" _replace(coordinator, state="Ready", progress="None") - assert sensor.native_value == "Finish" + assert sensor.native_value == "finish" def test_hold_expires_after_sticky_seconds(): @@ -304,13 +304,13 @@ def test_hold_expires_after_sticky_seconds(): sensor, coordinator = _sensor(desc) _replace(coordinator, state="Run", progress="Finish", progressPercentage="100") - assert sensor.native_value == "Finish" + assert sensor.native_value == "finish" _replace(coordinator, state="Ready") - assert sensor.native_value == "Finish" # still within the window + assert sensor.native_value == "finish" # still within the window time.sleep(0.1) - assert sensor.native_value == "Idle" + assert sensor.native_value == "idle" def test_a_progress_stuck_at_finish_does_not_hold_open_the_window_forever(): @@ -325,17 +325,17 @@ def test_a_progress_stuck_at_finish_does_not_hold_open_the_window_forever(): sensor, coordinator = _sensor(desc) _replace(coordinator, state="Ready", progress="Finish") - assert sensor.native_value == "Finish" + assert sensor.native_value == "finish" time.sleep(0.03) # Device still (incorrectly) reports Finish on every subsequent poll -- # must not restart the window. _replace(coordinator, state="Ready", progress="Finish") - assert sensor.native_value == "Finish" + assert sensor.native_value == "finish" time.sleep(0.03) # 0.06s total since the first sighting -- past 0.05s _replace(coordinator, state="Ready", progress="Finish") - assert sensor.native_value == "Idle" + assert sensor.native_value == "idle" def test_non_sticky_sensor_is_unaffected(): @@ -361,11 +361,11 @@ def test_cycle_active_and_machine_state_are_never_held(): ) _replace(coordinator, state="Run", progress="Finish", progressPercentage="100") - assert progress_sensor.native_value == "Finish" + assert progress_sensor.native_value == "finish" assert machine_state_sensor.native_value == "active" _replace(coordinator, state="Ready") - assert progress_sensor.native_value == "Finish" # held + assert progress_sensor.native_value == "finish" # held assert machine_state_sensor.native_value == "idle" # real-time, unaffected @@ -377,8 +377,8 @@ def test_a_partial_update_that_omits_progress_does_not_erase_the_hold(): sensor, coordinator = _sensor(_PROGRESS_DESC) _replace(coordinator, state="Run", progress="Finish", progressPercentage="100") - assert sensor.native_value == "Finish" + assert sensor.native_value == "finish" _apply(coordinator, state="Ready") # partial merge, doesn't restate progress assert coordinator.resources[_HREF]["x.com.samsung.da.progress"] == "Finish" - assert sensor.native_value == "Finish" + assert sensor.native_value == "finish" From f6fbfc1f7fe7de2e636fad352527e810bda3b67c Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Sat, 15 Aug 2026 02:07:30 +0000 Subject: [PATCH 2/2] Keep the drum-clean unit in code rather than the catalog Home Assistant resolves a catalog `unit_of_measurement` against the default language, not the user's (entity_platform re-fetches 'en' for exactly this key), because a unit is part of the state's identity -- the recorder writes it into statistics metadata and compares it across restarts. Localizing it would make switching Home Assistant's language look like a unit change and suppress the sensor's statistics. So the six non-English entries were never read, and the English one only restated what `unit="cycles"` already said. Same displayed unit either way; this drops seven catalog keys that looked translatable but weren't. --- custom_components/localthings/registry/capabilities/dryer.py | 1 + custom_components/localthings/registry/capabilities/washer.py | 1 + custom_components/localthings/translations/cs.json | 3 +-- custom_components/localthings/translations/de.json | 3 +-- custom_components/localthings/translations/en.json | 3 +-- custom_components/localthings/translations/es.json | 3 +-- custom_components/localthings/translations/it.json | 3 +-- custom_components/localthings/translations/ko.json | 3 +-- custom_components/localthings/translations/nl.json | 3 +-- 9 files changed, 9 insertions(+), 14 deletions(-) diff --git a/custom_components/localthings/registry/capabilities/dryer.py b/custom_components/localthings/registry/capabilities/dryer.py index 403395b..f4d7b51 100644 --- a/custom_components/localthings/registry/capabilities/dryer.py +++ b/custom_components/localthings/registry/capabilities/dryer.py @@ -70,6 +70,7 @@ DRYER_COURSE = Capability( ), SensorDesc( key="drum_clean_cycles_remaining", + unit="cycles", icon="mdi:tumble-dryer-alert", state_class="measurement", exists_fn=lambda rep, resources: drum_clean_cycles_remaining(rep) is not None, diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index 98a7cf5..af289b8 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -275,6 +275,7 @@ WASHER_COURSE = Capability( ), SensorDesc( key="drum_clean_cycles_remaining", + unit="cycles", icon="mdi:washing-machine-alert", state_class="measurement", exists_fn=lambda rep, resources: drum_clean_cycles_remaining(rep) is not None, diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index 82ba3e9..8af70be 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -946,8 +946,7 @@ } }, "drum_clean_cycles_remaining": { - "name": "Čištění bubnu za", - "unit_of_measurement": "cyklů" + "name": "Čištění bubnu za" }, "drum_clean_last_cleaned": { "name": "Poslední čištění bubnu" diff --git a/custom_components/localthings/translations/de.json b/custom_components/localthings/translations/de.json index caf28c3..9e54ce5 100644 --- a/custom_components/localthings/translations/de.json +++ b/custom_components/localthings/translations/de.json @@ -940,8 +940,7 @@ } }, "drum_clean_cycles_remaining": { - "name": "Trommelreinigung fällig in", - "unit_of_measurement": "Zyklen" + "name": "Trommelreinigung fällig in" }, "drum_clean_last_cleaned": { "name": "Trommel zuletzt gereinigt" diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 6976e2e..98d21b1 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -946,8 +946,7 @@ } }, "drum_clean_cycles_remaining": { - "name": "Drum clean due in", - "unit_of_measurement": "cycles" + "name": "Drum clean due in" }, "drum_clean_last_cleaned": { "name": "Drum last cleaned" diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index a886cb7..399fbb2 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -1139,8 +1139,7 @@ } }, "drum_clean_cycles_remaining": { - "name": "Limpieza de tambor en", - "unit_of_measurement": "ciclos" + "name": "Limpieza de tambor en" }, "drum_clean_last_cleaned": { "name": "Última limpieza del tambor" diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index c98c4c1..8bcbd6b 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -946,8 +946,7 @@ } }, "drum_clean_cycles_remaining": { - "name": "Pulizia cestello fra", - "unit_of_measurement": "cicli" + "name": "Pulizia cestello fra" }, "drum_clean_last_cleaned": { "name": "Ultima pulizia cestello" diff --git a/custom_components/localthings/translations/ko.json b/custom_components/localthings/translations/ko.json index 4bb87dd..aab7879 100644 --- a/custom_components/localthings/translations/ko.json +++ b/custom_components/localthings/translations/ko.json @@ -946,8 +946,7 @@ } }, "drum_clean_cycles_remaining": { - "name": "통세척까지 남은 횟수", - "unit_of_measurement": "회" + "name": "통세척까지 남은 횟수" }, "drum_clean_last_cleaned": { "name": "마지막 통세척" diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 4448e60..ec45195 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -946,8 +946,7 @@ } }, "drum_clean_cycles_remaining": { - "name": "Trommelreiniging over", - "unit_of_measurement": "cycli" + "name": "Trommelreiniging over" }, "drum_clean_last_cleaned": { "name": "Trommel laatst gereinigd"