Merge pull request #371 from mbillow/claude/pr-341-review

Normalize appliance enums for HA translations
This commit is contained in:
Marc Billow
2026-08-14 21:19:33 -05:00
committed by GitHub
17 changed files with 447 additions and 78 deletions
@@ -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
@@ -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",
@@ -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,
),
),
)
@@ -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(
+19 -2
View File
@@ -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)
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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": "진행률"
@@ -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"
+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.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"
+16 -1
View File
@@ -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
+20
View File
@@ -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
+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)
_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"