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.
This commit is contained in:
Lukas Knoeller
2026-08-15 01:40:56 +00:00
committed by Marc Billow
parent 02d009380d
commit 75466761a1
18 changed files with 461 additions and 87 deletions
@@ -249,6 +249,14 @@ def _power_sensor_exists(rep, resources):
return not model_allows_power_on_off(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): def sensor_item_value(items, sensor_type, index=0):
"""Pull one reading out of a `/sensors/vs/0`-style items[] list -- each """Pull one reading out of a `/sensors/vs/0`-style items[] list -- each
item is `{type, value: [...]}`; `index` picks which slot to read item is `{type, value: [...]}`; `index` picks which slot to read
@@ -9,6 +9,7 @@ wash, auto release dry) are read locally here.
from ..capability import Capability from ..capability import Capability
from ..entities import ButtonDesc, SelectDesc, SensorDesc, SwitchDesc from ..entities import ButtonDesc, SelectDesc, SensorDesc, SwitchDesc
from .common import diagnosis_status
from .laundry import bool_option_switch, cycle_select from .laundry import bool_option_switch, cycle_select
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -76,6 +77,9 @@ DIAGNOSIS = Capability(
field="x.com.samsung.da.diagnosisStart", field="x.com.samsung.da.diagnosisStart",
icon="mdi:stethoscope", icon="mdi:stethoscope",
entity_category="diagnostic", entity_category="diagnostic",
device_class="enum",
options=("ready",),
value_fn=diagnosis_status,
), ),
ButtonDesc( ButtonDesc(
key="diagnosis_start", key="diagnosis_start",
@@ -11,6 +11,7 @@ the /course/vs/0 cycle select -- lives in laundry.py.
from ..capability import Capability from ..capability import Capability
from ..entities import SensorDesc, SwitchDesc from ..entities import SensorDesc, SwitchDesc
from .common import diagnosis_status
from .laundry import cycle_select, drum_clean_cycles_remaining, drum_clean_last_cleaned from .laundry import cycle_select, drum_clean_cycles_remaining, drum_clean_last_cleaned
@@ -69,7 +70,6 @@ DRYER_COURSE = Capability(
), ),
SensorDesc( SensorDesc(
key="drum_clean_cycles_remaining", key="drum_clean_cycles_remaining",
unit="cycles",
icon="mdi:tumble-dryer-alert", icon="mdi:tumble-dryer-alert",
state_class="measurement", state_class="measurement",
exists_fn=lambda rep, resources: drum_clean_cycles_remaining(rep) is not None, exists_fn=lambda rep, resources: drum_clean_cycles_remaining(rep) is not None,
@@ -91,7 +91,12 @@ DRYER_DIAGNOSIS = Capability(
poll_tier="warm", poll_tier="warm",
entities=( entities=(
SensorDesc( 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,
), ),
), ),
) )
@@ -6,6 +6,7 @@ Shared by dryer/dishwasher/oven/washer families.
import math import math
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from ...catalog import translated_states
from ..capability import Capability from ..capability import Capability
from ..entities import BinarySensorDesc, ButtonDesc, NumberDesc, SensorDesc from ..entities import BinarySensorDesc, ButtonDesc, NumberDesc, SensorDesc
@@ -25,7 +26,7 @@ def _to_ocf(v):
def _progress(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): def _int(v):
@@ -201,13 +202,17 @@ OPERATIONAL_STATE = Capability(
SensorDesc( SensorDesc(
key="progress", key="progress",
icon="mdi:progress-wrench", icon="mdi:progress-wrench",
device_class="enum",
options=tuple(sorted(translated_states("sensor", "progress"))),
rep_fn=lambda rep: ( rep_fn=lambda rep: (
"Idle" "idle"
if not _state_is_active(rep) if not _state_is_active(rep)
else _progress(rep.get("x.com.samsung.da.progress")) else _progress(rep.get("x.com.samsung.da.progress"))
), ),
sticky_fn=_just_finished, 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, sticky_bypass_fn=_new_cycle_running,
), ),
SensorDesc( SensorDesc(
@@ -275,7 +275,6 @@ WASHER_COURSE = Capability(
), ),
SensorDesc( SensorDesc(
key="drum_clean_cycles_remaining", key="drum_clean_cycles_remaining",
unit="cycles",
icon="mdi:washing-machine-alert", icon="mdi:washing-machine-alert",
state_class="measurement", state_class="measurement",
exists_fn=lambda rep, resources: drum_clean_cycles_remaining(rep) is not None, exists_fn=lambda rep, resources: drum_clean_cycles_remaining(rep) is not None,
+19 -2
View File
@@ -49,8 +49,8 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
SensorDeviceClass(desc.device_class) if desc.device_class else None SensorDeviceClass(desc.device_class) if desc.device_class else None
) )
self._attr_state_class = SensorStateClass(desc.state_class) if desc.state_class else None self._attr_state_class = SensorStateClass(desc.state_class) if desc.state_class else None
if desc.options: # Always set, so the `options` property below can read it unguarded.
self._attr_options = list(desc.options) self._attr_options = list(desc.options) if desc.options else None
self._hysteresis_value = None self._hysteresis_value = None
self._sticky_value = None self._sticky_value = None
self._sticky_until: float | None = 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 desc.unit_fn(self.coordinator.resource(self._bound.href))
return self._attr_native_unit_of_measurement 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 @property
def native_value(self): def native_value(self):
raw = (self.coordinator.data or {}).get(self._state_key) raw = (self.coordinator.data or {}).get(self._state_key)
@@ -261,7 +261,11 @@
"name": "Zvuk bzučáku", "name": "Zvuk bzučáku",
"state": { "state": {
"off": "Vypnuto", "off": "Vypnuto",
"on": "Zapnuto" "on": "Zapnuto",
"volume_off": "Vypnuto",
"volume_low": "Nízká",
"volume_med": "Střední",
"volume_high": "Vysoká"
} }
}, },
"discharging_time": { "discharging_time": {
@@ -930,13 +934,20 @@
"name": "Teplota" "name": "Teplota"
}, },
"diagnosis": { "diagnosis": {
"name": "Diagnostika" "name": "Diagnostika",
"state": {
"ready": "Připraveno"
}
}, },
"diagnosis_status": { "diagnosis_status": {
"name": "Stav diagnostiky" "name": "Stav diagnostiky",
"state": {
"ready": "Připraveno"
}
}, },
"drum_clean_cycles_remaining": { "drum_clean_cycles_remaining": {
"name": "Čištění bubnu za" "name": "Čištění bubnu za",
"unit_of_measurement": "cyklů"
}, },
"drum_clean_last_cleaned": { "drum_clean_last_cleaned": {
"name": "Poslední čištění bubnu" "name": "Poslední čištění bubnu"
@@ -1077,7 +1088,21 @@
"name": "Teplota sondy" "name": "Teplota sondy"
}, },
"progress": { "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": { "progress_percentage": {
"name": "Průběh v procentech" "name": "Průběh v procentech"
@@ -261,7 +261,11 @@
"name": "Signalton", "name": "Signalton",
"state": { "state": {
"off": "Aus", "off": "Aus",
"on": "Ein" "on": "Ein",
"volume_off": "Aus",
"volume_low": "Niedrig",
"volume_med": "Mittel",
"volume_high": "Hoch"
} }
}, },
"discharging_time": { "discharging_time": {
@@ -924,13 +928,20 @@
"name": "Temperatur" "name": "Temperatur"
}, },
"diagnosis": { "diagnosis": {
"name": "Diagnose" "name": "Diagnose",
"state": {
"ready": "Bereit"
}
}, },
"diagnosis_status": { "diagnosis_status": {
"name": "Diagnosestatus" "name": "Diagnosestatus",
"state": {
"ready": "Bereit"
}
}, },
"drum_clean_cycles_remaining": { "drum_clean_cycles_remaining": {
"name": "Trommelreinigung fällig in" "name": "Trommelreinigung fällig in",
"unit_of_measurement": "Zyklen"
}, },
"drum_clean_last_cleaned": { "drum_clean_last_cleaned": {
"name": "Trommel zuletzt gereinigt" "name": "Trommel zuletzt gereinigt"
@@ -1071,7 +1082,21 @@
"name": "Fühlertemperatur" "name": "Fühlertemperatur"
}, },
"progress": { "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": { "progress_percentage": {
"name": "Fortschritt in Prozent" "name": "Fortschritt in Prozent"
@@ -261,7 +261,11 @@
"name": "Buzzer sound", "name": "Buzzer sound",
"state": { "state": {
"off": "Off", "off": "Off",
"on": "On" "on": "On",
"volume_off": "Off",
"volume_low": "Low",
"volume_med": "Medium",
"volume_high": "High"
} }
}, },
"discharging_time": { "discharging_time": {
@@ -930,13 +934,20 @@
"name": "Temperature" "name": "Temperature"
}, },
"diagnosis": { "diagnosis": {
"name": "Diagnosis" "name": "Diagnosis",
"state": {
"ready": "Ready"
}
}, },
"diagnosis_status": { "diagnosis_status": {
"name": "Diagnosis status" "name": "Diagnosis status",
"state": {
"ready": "Ready"
}
}, },
"drum_clean_cycles_remaining": { "drum_clean_cycles_remaining": {
"name": "Drum clean due in" "name": "Drum clean due in",
"unit_of_measurement": "cycles"
}, },
"drum_clean_last_cleaned": { "drum_clean_last_cleaned": {
"name": "Drum last cleaned" "name": "Drum last cleaned"
@@ -1077,7 +1088,21 @@
"name": "Probe temperature" "name": "Probe temperature"
}, },
"progress": { "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": { "progress_percentage": {
"name": "Progress percent" "name": "Progress percent"
@@ -457,7 +457,11 @@
"name": "Volumen", "name": "Volumen",
"state": { "state": {
"off": "Apagado", "off": "Apagado",
"on": "Encendido" "on": "Encendido",
"volume_off": "Apagado",
"volume_low": "Bajo",
"volume_med": "Medio",
"volume_high": "Alto"
} }
}, },
"discharging_time": { "discharging_time": {
@@ -1123,13 +1127,20 @@
"name": "Temperatura" "name": "Temperatura"
}, },
"diagnosis": { "diagnosis": {
"name": "Diagnóstico" "name": "Diagnóstico",
"state": {
"ready": "Listo"
}
}, },
"diagnosis_status": { "diagnosis_status": {
"name": "Estado del diagnóstico" "name": "Estado del diagnóstico",
"state": {
"ready": "Listo"
}
}, },
"drum_clean_cycles_remaining": { "drum_clean_cycles_remaining": {
"name": "Limpieza de tambor en" "name": "Limpieza de tambor en",
"unit_of_measurement": "ciclos"
}, },
"drum_clean_last_cleaned": { "drum_clean_last_cleaned": {
"name": "Última limpieza del tambor" "name": "Última limpieza del tambor"
@@ -1270,7 +1281,21 @@
"name": "Temperatura de la sonda" "name": "Temperatura de la sonda"
}, },
"progress": { "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": { "progress_percentage": {
"name": "Porcentaje de progreso" "name": "Porcentaje de progreso"
@@ -261,7 +261,11 @@
"name": "Suono cicalino", "name": "Suono cicalino",
"state": { "state": {
"off": "Spento", "off": "Spento",
"on": "Acceso" "on": "Acceso",
"volume_off": "Spento",
"volume_low": "Basso",
"volume_med": "Medio",
"volume_high": "Alto"
} }
}, },
"discharging_time": { "discharging_time": {
@@ -930,13 +934,20 @@
"name": "Temperatura" "name": "Temperatura"
}, },
"diagnosis": { "diagnosis": {
"name": "Diagnosi" "name": "Diagnosi",
"state": {
"ready": "Pronto"
}
}, },
"diagnosis_status": { "diagnosis_status": {
"name": "Stato diagnosi" "name": "Stato diagnosi",
"state": {
"ready": "Pronto"
}
}, },
"drum_clean_cycles_remaining": { "drum_clean_cycles_remaining": {
"name": "Pulizia cestello fra" "name": "Pulizia cestello fra",
"unit_of_measurement": "cicli"
}, },
"drum_clean_last_cleaned": { "drum_clean_last_cleaned": {
"name": "Ultima pulizia cestello" "name": "Ultima pulizia cestello"
@@ -1077,7 +1088,21 @@
"name": "Temperatura sonda" "name": "Temperatura sonda"
}, },
"progress": { "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": { "progress_percentage": {
"name": "Avanzamento percentuale" "name": "Avanzamento percentuale"
@@ -261,7 +261,11 @@
"name": "부저음", "name": "부저음",
"state": { "state": {
"off": "끄기", "off": "끄기",
"on": "켜기" "on": "켜기",
"volume_off": "끔",
"volume_low": "낮음",
"volume_med": "중간",
"volume_high": "높음"
} }
}, },
"discharging_time": { "discharging_time": {
@@ -930,13 +934,20 @@
"name": "온도" "name": "온도"
}, },
"diagnosis": { "diagnosis": {
"name": "진단" "name": "진단",
"state": {
"ready": "준비됨"
}
}, },
"diagnosis_status": { "diagnosis_status": {
"name": "진단 상태" "name": "진단 상태",
"state": {
"ready": "준비됨"
}
}, },
"drum_clean_cycles_remaining": { "drum_clean_cycles_remaining": {
"name": "통세척까지 남은 횟수" "name": "통세척까지 남은 횟수",
"unit_of_measurement": "회"
}, },
"drum_clean_last_cleaned": { "drum_clean_last_cleaned": {
"name": "마지막 통세척" "name": "마지막 통세척"
@@ -1077,7 +1088,21 @@
"name": "탐침 온도계 현재 온도" "name": "탐침 온도계 현재 온도"
}, },
"progress": { "progress": {
"name": "진행률" "name": "진행률",
"state": {
"idle": "대기",
"weightsensing": "세탁물 감지",
"wash": "세탁",
"rinse": "헹굼",
"spin": "탈수",
"finish": "완료",
"steaming": "스팀",
"airwashing": "에어워시",
"drying": "건조",
"cooling": "냉각",
"predrain": "사전 배수",
"prewash": "애벌빨래"
}
}, },
"progress_percentage": { "progress_percentage": {
"name": "진행률" "name": "진행률"
@@ -261,7 +261,11 @@
"name": "Zoemergeluid", "name": "Zoemergeluid",
"state": { "state": {
"off": "Uit", "off": "Uit",
"on": "Aan" "on": "Aan",
"volume_off": "Uit",
"volume_low": "Laag",
"volume_med": "Gemiddeld",
"volume_high": "Hoog"
} }
}, },
"discharging_time": { "discharging_time": {
@@ -930,13 +934,20 @@
"name": "Temperatuur" "name": "Temperatuur"
}, },
"diagnosis": { "diagnosis": {
"name": "Diagnose" "name": "Diagnose",
"state": {
"ready": "Gereed"
}
}, },
"diagnosis_status": { "diagnosis_status": {
"name": "Diagnosestatus" "name": "Diagnosestatus",
"state": {
"ready": "Gereed"
}
}, },
"drum_clean_cycles_remaining": { "drum_clean_cycles_remaining": {
"name": "Trommelreiniging over" "name": "Trommelreiniging over",
"unit_of_measurement": "cycli"
}, },
"drum_clean_last_cleaned": { "drum_clean_last_cleaned": {
"name": "Trommel laatst gereinigd" "name": "Trommel laatst gereinigd"
@@ -1077,7 +1088,21 @@
"name": "Sondetemperatuur" "name": "Sondetemperatuur"
}, },
"progress": { "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": { "progress_percentage": {
"name": "Voortgangspercentage" "name": "Voortgangspercentage"
+12 -1
View File
@@ -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.capabilities import dishwasher
from custom_components.localthings.registry.entities import SwitchDesc from custom_components.localthings.registry.entities import SensorDesc, SwitchDesc
class TestCycleOptions: class TestCycleOptions:
@@ -60,3 +60,14 @@ class TestDishwasherOptions:
assert desc.exists_fn is not None 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": []}, {}) is False
assert desc.exists_fn({"x.com.samsung.da.options": ["AutoDoorRelease_On"]}, {}) is True 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"
+16 -1
View File
@@ -5,7 +5,7 @@ from custom_components.localthings.registry.capabilities.operational import (
_just_finished, _just_finished,
_new_cycle_running, _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(): 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: class TestProgressPercentage:
"""issue #9: device firmware leaves progressPercentage stale (e.g. '1') """issue #9: device firmware leaves progressPercentage stale (e.g. '1')
after a cycle ends instead of resetting it, so it must be gated on after a cycle ends instead of resetting it, so it must be gated on
+20
View File
@@ -7,6 +7,7 @@ from typing import ClassVar, cast
from custom_components.localthings.coordinator import LocalThingsCoordinator from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry.capabilities.laundry import ( from custom_components.localthings.registry.capabilities.laundry import (
BUZZER_SOUND,
cycle_select, cycle_select,
washer_cycle_fallback, washer_cycle_fallback,
) )
@@ -47,6 +48,25 @@ def test_options_field_unaffected():
assert entity.options == ["Lo", "Hi"] 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(): def test_callable_options_receives_full_resource_snapshot():
"""A callable options is handed the coordinator's full href->rep """A callable options is handed the coordinator's full href->rep
snapshot, not just this entity's own href's rep -- needed for course snapshot, not just this entity's own href's rep -- needed for course
+115
View File
@@ -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
+42 -42
View File
@@ -92,10 +92,10 @@ def test_holds_finish_after_state_leaves_active():
sensor, coordinator = _sensor(_PROGRESS_DESC) sensor, coordinator = _sensor(_PROGRESS_DESC)
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100") _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 _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(): 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) sensor, coordinator = _sensor(_PROGRESS_DESC)
_replace(coordinator, state="Ready", progress="Finish", progressPercentage="100") _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. # Still held on a later poll, even once the device stops repeating it.
_replace(coordinator, state="Ready") _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(): 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) sensor, coordinator = _sensor(_PROGRESS_DESC)
_replace(coordinator, state="Run", progress="Spin") _replace(coordinator, state="Run", progress="Spin")
assert sensor.native_value == "Spin" assert sensor.native_value == "spin"
_replace(coordinator, state="Run", progress="Rinse") _replace(coordinator, state="Run", progress="Rinse")
assert sensor.native_value == "Rinse" assert sensor.native_value == "rinse"
def test_never_finished_stays_idle(): def test_never_finished_stays_idle():
@@ -144,10 +144,10 @@ def test_never_finished_stays_idle():
sensor, coordinator = _sensor(_PROGRESS_DESC) sensor, coordinator = _sensor(_PROGRESS_DESC)
_replace(coordinator, state="Run", progress="Spin") _replace(coordinator, state="Run", progress="Spin")
assert sensor.native_value == "Spin" assert sensor.native_value == "spin"
_replace(coordinator, state="Ready") _replace(coordinator, state="Ready")
assert sensor.native_value == "Idle" assert sensor.native_value == "idle"
def test_a_new_cycle_starting_overrides_the_hold(): 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) sensor, coordinator = _sensor(_PROGRESS_DESC)
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100") _replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
assert sensor.native_value == "Finish" assert sensor.native_value == "finish"
_replace(coordinator, state="Ready") _replace(coordinator, state="Ready")
assert sensor.native_value == "Finish" # still held assert sensor.native_value == "finish" # still held
_replace(coordinator, state="Run", progress="Wash") _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(): 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) sensor, coordinator = _sensor(desc)
_replace(coordinator, state="Run", progress="Drying", progressPercentage="40") _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") _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") _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. # The tail: a running stage again, state already idle.
_replace(coordinator, state="Ready", progress="Drying", progressPercentage="100") _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. # ...then the device settles, still inside the window.
_replace(coordinator, state="Ready", progress="None") _replace(coordinator, state="Ready", progress="None")
assert sensor.native_value == "Finish" assert sensor.native_value == "finish"
time.sleep(0.25) time.sleep(0.25)
assert sensor.native_value == "Idle" assert sensor.native_value == "idle"
def test_progress_percentage_survives_the_same_tail(): 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) sensor, coordinator = _sensor(desc)
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100") _replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
assert sensor.native_value == "Finish" assert sensor.native_value == "finish"
_replace(coordinator, state="Pause", progress="Wash") _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) 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") _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(): 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) sensor, coordinator = _sensor(desc)
_replace(coordinator, state="Ready", progress="Finish") _replace(coordinator, state="Ready", progress="Finish")
assert sensor.native_value == "Finish" assert sensor.native_value == "finish"
for _ in range(3): for _ in range(3):
time.sleep(0.05) time.sleep(0.05)
_replace(coordinator, state="Ready", progress="None") _replace(coordinator, state="Ready", progress="None")
assert sensor.native_value == "Finish" assert sensor.native_value == "finish"
_replace(coordinator, state="Ready", progress="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 # 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. # first Finish, not 0.3s after the most recent re-entry.
time.sleep(0.2) time.sleep(0.2)
_replace(coordinator, state="Ready", progress="Finish") _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(): 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) sensor, coordinator = _sensor(desc)
_replace(coordinator, state="Ready", progress="Finish") _replace(coordinator, state="Ready", progress="Finish")
assert sensor.native_value == "Finish" assert sensor.native_value == "finish"
time.sleep(0.1) time.sleep(0.1)
assert sensor.native_value == "Idle" assert sensor.native_value == "idle"
_replace(coordinator, state="Ready", progress="None") _replace(coordinator, state="Ready", progress="None")
assert sensor.native_value == "Idle" assert sensor.native_value == "idle"
_replace(coordinator, state="Ready", progress="Finish") _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. # A real cycle in between is what makes it available again.
_replace(coordinator, state="Run", progress="Drying") _replace(coordinator, state="Run", progress="Drying")
assert sensor.native_value == "Drying" assert sensor.native_value == "drying"
_replace(coordinator, state="Run", progress="Finish") _replace(coordinator, state="Run", progress="Finish")
assert sensor.native_value == "Finish" assert sensor.native_value == "finish"
_replace(coordinator, state="Ready", progress="None") _replace(coordinator, state="Ready", progress="None")
assert sensor.native_value == "Finish" assert sensor.native_value == "finish"
def test_hold_expires_after_sticky_seconds(): def test_hold_expires_after_sticky_seconds():
@@ -304,13 +304,13 @@ def test_hold_expires_after_sticky_seconds():
sensor, coordinator = _sensor(desc) sensor, coordinator = _sensor(desc)
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100") _replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
assert sensor.native_value == "Finish" assert sensor.native_value == "finish"
_replace(coordinator, state="Ready") _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) 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(): 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) sensor, coordinator = _sensor(desc)
_replace(coordinator, state="Ready", progress="Finish") _replace(coordinator, state="Ready", progress="Finish")
assert sensor.native_value == "Finish" assert sensor.native_value == "finish"
time.sleep(0.03) time.sleep(0.03)
# Device still (incorrectly) reports Finish on every subsequent poll -- # Device still (incorrectly) reports Finish on every subsequent poll --
# must not restart the window. # must not restart the window.
_replace(coordinator, state="Ready", progress="Finish") _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 time.sleep(0.03) # 0.06s total since the first sighting -- past 0.05s
_replace(coordinator, state="Ready", progress="Finish") _replace(coordinator, state="Ready", progress="Finish")
assert sensor.native_value == "Idle" assert sensor.native_value == "idle"
def test_non_sticky_sensor_is_unaffected(): 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") _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" assert machine_state_sensor.native_value == "active"
_replace(coordinator, state="Ready") _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 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) sensor, coordinator = _sensor(_PROGRESS_DESC)
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100") _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 _apply(coordinator, state="Ready") # partial merge, doesn't restate progress
assert coordinator.resources[_HREF]["x.com.samsung.da.progress"] == "Finish" assert coordinator.resources[_HREF]["x.com.samsung.da.progress"] == "Finish"
assert sensor.native_value == "Finish" assert sensor.native_value == "finish"