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..f4d7b51 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 @@ -91,7 +92,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/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..8af70be 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,10 +934,16 @@ "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" @@ -1077,7 +1087,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..9e54ce5 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,10 +928,16 @@ "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" @@ -1071,7 +1081,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..98d21b1 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,10 +934,16 @@ "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" @@ -1077,7 +1087,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..399fbb2 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,10 +1127,16 @@ "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" @@ -1270,7 +1280,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..8bcbd6b 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,10 +934,16 @@ "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" @@ -1077,7 +1087,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..aab7879 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,10 +934,16 @@ "name": "온도" }, "diagnosis": { - "name": "진단" + "name": "진단", + "state": { + "ready": "준비됨" + } }, "diagnosis_status": { - "name": "진단 상태" + "name": "진단 상태", + "state": { + "ready": "준비됨" + } }, "drum_clean_cycles_remaining": { "name": "통세척까지 남은 횟수" @@ -1077,7 +1087,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..ec45195 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,10 +934,16 @@ "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" @@ -1077,7 +1087,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"