Merge pull request #371 from mbillow/claude/pr-341-review
Normalize appliance enums for HA translations
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user