diff --git a/custom_components/localthings/registry/by_type/air_purifier.py b/custom_components/localthings/registry/by_type/air_purifier.py index ca67d32..605b2a4 100644 --- a/custom_components/localthings/registry/by_type/air_purifier.py +++ b/custom_components/localthings/registry/by_type/air_purifier.py @@ -22,6 +22,12 @@ match_fn discriminators that keep them from colliding): resource surface as A-VTWW-TP2-21-COMMON above; no new capabilities needed. +AIR_LEVEL_CHECK ("AI Purify" -- the periodic air-quality sensing engine on +/airlevelcheck/vs/0) is shared by the last three of those: their dumps all +carry the resource with the same field names, and only the TVTL family has no +such href. It was covered as opaque plumbing until two AVT-WW-TP1 dumps +(issues #84 and #190) showed it drives a real user-facing feature. + Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0 (identical field/write contract). """ @@ -38,6 +44,7 @@ REGISTRY = DeviceRegistry( *common.POWER, dishwasher.DIAGNOSIS, air_purifier.AIR_QUALITY, + air_purifier.AIR_LEVEL_CHECK, air_purifier.FILTER, air_purifier.DEVICE_ACTIVE, air_purifier.AIRFLOW_GENERIC, diff --git a/custom_components/localthings/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py index 5b86e91..887b6be 100644 --- a/custom_components/localthings/registry/capabilities/air_purifier.py +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -58,6 +58,8 @@ one unit, stuck at 0 throughout on the other), so AIRFLOW_VS_FALLBACK below stays a plain read-only diagnostic even after this change. """ +import datetime + from ..capability import Capability from ..entities import ( BinarySensorDesc, @@ -66,8 +68,9 @@ from ..entities import ( SelectDesc, SensorDesc, SwitchDesc, + TimeDesc, ) -from .common import filter_usage_percent, int_or_none, sensor_item_value +from .common import epoch_to_utc, filter_usage_percent, int_or_none, sensor_item_value from .laundry import bool_option_exists, bool_option_value, option_value, option_write # Newer TP1X_DA-AC-AIR-class boards (e.g. TP1X_DA-AC-AIR-01031_0000, issue @@ -507,6 +510,266 @@ SOUND_VOLUME = Capability( ), ) +# --------------------------------------------------------------------------- +# AI Purify -- /airlevelcheck/vs/0 (issues #84 and #190) +# +# Covered as "periodic air-quality sensing scheduler plumbing" until two dumps +# of the AVT-WW-TP1-23 board showed it is not plumbing: it drives the feature +# the SmartThings app calls AI Purify, where the unit wakes on a timer, samples +# the air, and optionally acts on the result. Every field is named, none are +# opaque, and two of them are already user-set on the reported units. +# +# Three of this registry's four board families report the resource with the +# same field names -- TP1X_DA-AC-AIR (#130), A-VTWW-TP2 (#151) and AVT-WW-TP1 +# (#84, #190); only ARTIK051_TVTL (#56) has no such href. Bound unconditionally +# rather than behind a match_fn so any board reporting it is covered; the one +# field that genuinely varies is gated per-entity below. +# +# The resource carries two independent knobs and they get one entity each, +# rather than being folded into a single control: +# +# periodicSensingActivationState On/Off -- is AI Purify running +# autoExeState Off/Airpurify/Alarm -- what it does with a +# bad reading +# +# The appliance itself presents them that way: its own UI has an on/off for AI +# Purify separately from the three mode choices. Folding them into one select +# was tried first and lost two things -- a configured action became invisible +# while the feature was off, and no option could toggle the feature without +# also overwriting the action. +# +# Note the two 'off's mean opposite things and are not interchangeable. The +# switch's off stops the unit sampling at all; the select's off is the +# advertised autoExeState "Off", where the unit keeps sampling and simply +# doesn't act on what it measures -- the app calls that choice "sensing only". +# +# The select reads its options straight off supportedAutoExeState rather than +# a typed-in tuple, the same shape SOUND_MODE below uses for supportedModes: a +# board advertising a fourth action gets it accepted on both the options list +# and the write path. +# +# range_hood.AIR_LEVEL_CHECK already models this same href, and its read-only +# keys (air_sensing_state / last_air_sensing_time / last_air_sensing_level) are +# reused verbatim so both families share one catalog entry. It is deliberately +# NOT imported: the hood exposes periodic_air_sensing as a read-only +# BinarySensorDesc and this board needs a writable SwitchDesc on that same key, +# so reusing the hood's capability would migrate every hood user's entity to a +# different platform. +# +# Verification: every write below was exercised on AVT-WW-TP1-23-AXX500 +# hardware. This board returns 2.04 for writes it silently discards (see +# HEPA_FILTER's filter-reset note), so an echo proves nothing -- each was +# judged by the value surviving a reconnect, which forces a new DTLS session, +# fresh discovery and a fresh observe of this href, leaving no cached state to +# read back. The other two families get the writes on field-shape grounds, the +# same basis on which they already share MODE, HEPA_FILTER and the air-quality +# sensors. +# +# Deferred: startSensingOnce (On/Off on all three dumps) looks like a one-shot +# "sense now" trigger and would be a ButtonDesc, but nothing here writes it yet +# and this board is known to acknowledge writes it discards -- so it stays +# unbound until someone can confirm the side effect rather than the echo. +# --------------------------------------------------------------------------- + + +def _interval_minutes(seconds): + """Device stores the interval in seconds; the entity is in minutes. + + `is None` rather than a falsy check so a reported 0 is distinguishable + from a missing one. Anything else nonzero rounds up rather than to + nearest, so a sub-minute value can't render as 0 and fall below the + entity's own floor. + """ + secs = int_or_none(seconds) + if secs is None: + return None + return -(-secs // 60) if secs > 0 else 0 + + +def _interval_write(payload, rep, href=None): + # Minutes in the UI -> seconds on the wire (scalar string). Modelled as a + # free Number rather than the app's three fixed choices (10 min / 30 min / + # 1 hour): this resource advertises no supported-values or range field for + # the interval -- supportedAutoExeState sits right beside it, so the board + # does advertise constraints where it has them -- and it accepts values the + # app never offers. Writing 60 s, six times finer than the app's smallest + # choice, drove an observed ~60 s sensing cycle on hardware. + # + # One minute is the floor because that's the resolution this board reports + # results at: lastSensingTime lands on an exact minute on every sample from + # the AVT-WW-TP1 and A-VTWW-TP2 boards (both fixtures, and eleven + # consecutive live readings), where the TP1X/AC/hood boards report arbitrary + # seconds. A sub-minute interval is therefore unobservable here whether or + # not the board honours it. Zero is refused for a separate reason: unlike + # oven.cook_time or operational's delay hours, where 0 is a real setting + # ("no timer", "no delay"), nothing establishes what a 0 interval does to + # this board -- so native_min stops the UI offering it, and this guard + # covers the service-call path. Silent no-op via a None return, the same + # shape range_hood._lamp_level_write uses for a level the device didn't + # advertise. + minutes = round(float(payload)) + if minutes < 1: + return None + return ["airlevelcheck", "vs", "0"], { + "x.com.samsung.da.periodicSensingInterval": str(minutes * 60) + } + + +def _periodic_sensing_write(payload, rep, href=None): + # The master on/off for AI Purify. Leaves autoExeState alone, so the + # configured action survives the feature being switched off and comes back + # with it -- the thing the select cannot do, since every option it writes + # sets an action. + return ["airlevelcheck", "vs", "0"], { + "x.com.samsung.da.periodicSensingActivationState": ("On" if payload == "On" else "Off") + } + + +def _skip_status_write(payload, rep, href=None): + return ["airlevelcheck", "vs", "0"], { + "x.com.samsung.da.periodicSensingSkipStatus": ("On" if payload == "On" else "Off") + } + + +# The daily window during which periodic sensing is skipped, stored as one +# HHMMHHMM string (start+end) on periodicSensingSkipTime. The read side is +# cross-confirmed on two units: issue #84's sits at the inert '00000000', while +# issue #190's carries a real user-set '03002300' -> 03:00-23:00. Split into +# two HA time entities; each write reads the other half back out of the live +# rep so the pair round-trips. Confirmed in both directions on hardware: from +# 13:00-23:00, writing start=07:30 then end=22:00 left the device holding +# '07302200' -- each write kept the half it wasn't given. +def _skip_time_read(part): + def _read(value): + raw = str(value or "") + chunk = raw[0:4] if part == "start" else raw[4:8] + if len(chunk) == 4 and chunk.isdigit(): + try: + return datetime.time(int(chunk[:2]), int(chunk[2:])) + except ValueError: + return None + return None + + return _read + + +def _skip_half(raw, part): + """The half this write isn't setting, normalized. Padding alone would carry + a malformed value straight back to the device -- writing start over a junk + skip time would send '0730' + junk. The read side already refuses a half it + can't parse, so an unparseable one becomes '0000' here and the pair + round-trips honestly in the same cases.""" + chunk = (str(raw or "") + "00000000")[:8] + other = chunk[4:8] if part == "start" else chunk[0:4] + return other if _skip_time_read("end" if part == "start" else "start")(chunk) else "0000" + + +def _skip_time_write(part): + def _write(value, rep, href=None): + raw = rep.get("x.com.samsung.da.periodicSensingSkipTime", "") + hhmm = f"{value.hour:02d}{value.minute:02d}" + other = _skip_half(raw, part) + new = hhmm + other if part == "start" else other + hhmm + return ["airlevelcheck", "vs", "0"], {"x.com.samsung.da.periodicSensingSkipTime": new} + + return _write + + +AIR_LEVEL_CHECK = Capability( + href="/airlevelcheck/vs/0", + poll_tier="warm", + entities=( + SwitchDesc( + key="periodic_air_sensing", + field="x.com.samsung.da.periodicSensingActivationState", + icon="mdi:radar", + entity_category="config", + value_fn=lambda v: str(v).lower() == "on", + write_fn=_periodic_sensing_write, + ), + # Options come off supportedAutoExeState, not a table here -- the + # catalog carries the labels for the three values seen so far, and an + # unrecognized fourth still reaches the user (select.py falls back to + # the device's own token when the catalog doesn't know it). + SelectDesc( + key="sensing_mode", + field="x.com.samsung.da.autoExeState", + options_field="x.com.samsung.da.supportedAutoExeState", + translation_key="sensing_mode", + icon="mdi:radar", + entity_category="config", + write_fn=lambda p, rep, href=None: ( + ["airlevelcheck", "vs", "0"], + {"x.com.samsung.da.autoExeState": p}, + ), + ), + # The one field that varies across the three families reporting this + # resource: the TP1X_DA-AC-AIR dump (#130) omits it while both + # AVT-WW-TP1 dumps and the A-VTWW-TP2 dump carry it, so that board runs + # the sensing engine on a fixed interval it doesn't expose. + NumberDesc( + key="sensing_interval", + field="x.com.samsung.da.periodicSensingInterval", + icon="mdi:timer-cog", + entity_category="config", + native_min=1, + native_max=60, + step=1, + unit="min", + exists_fn=lambda rep, resources: "x.com.samsung.da.periodicSensingInterval" in rep, + value_fn=_interval_minutes, + write_fn=_interval_write, + ), + SwitchDesc( + key="periodic_sensing_skip_status", + field="x.com.samsung.da.periodicSensingSkipStatus", + icon="mdi:sleep", + entity_category="config", + value_fn=lambda v: str(v).lower() == "on", + write_fn=_skip_status_write, + ), + TimeDesc( + key="sensing_skip_start", + field="x.com.samsung.da.periodicSensingSkipTime", + icon="mdi:clock-start", + entity_category="config", + value_fn=_skip_time_read("start"), + write_fn=_skip_time_write("start"), + ), + TimeDesc( + key="sensing_skip_end", + field="x.com.samsung.da.periodicSensingSkipTime", + icon="mdi:clock-end", + entity_category="config", + value_fn=_skip_time_read("end"), + write_fn=_skip_time_write("end"), + ), + # Read-only status, same keys as range_hood.AIR_LEVEL_CHECK. + SensorDesc( + key="air_sensing_state", + field="x.com.samsung.da.sensingState", + icon="mdi:radar", + entity_category="diagnostic", + ), + SensorDesc( + key="last_air_sensing_time", + field="x.com.samsung.da.lastSensingTime", + device_class="timestamp", + entity_category="diagnostic", + value_fn=epoch_to_utc, + ), + # 'Kr1' on both dumps -- a national air-quality grade whose scale is + # region-prefixed and undocumented here, so it stays a raw diagnostic + # rather than being mapped to an asserted enum. + SensorDesc( + key="last_air_sensing_level", + field="x.com.samsung.da.lastSensingLevel", + icon="mdi:air-filter", + entity_category="diagnostic", + ), + ), +) + # /humidity/0 and /humidity/vs/0 are empty {} on both dumps this family has # been verified against -- covered here (not globally, per ignored.py's # module docstring) since those hrefs collide with fridge/AC schemas @@ -522,7 +785,6 @@ SOUND_VOLUME = Capability( COVERAGE = [ Capability(href="/humidity/0"), Capability(href="/humidity/vs/0"), - Capability(href="/airlevelcheck/vs/0"), # periodic air-quality sensing scheduler plumbing Capability(href="/availablecontrolsets/vs/0"), # opaque hex-encoded control-set bitmap Capability(href="/da/softreset/vs/0"), # soft-reset trigger plumbing Capability(href="/keepnormalstate/vs/0"), # internal keep-normal flag diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index b9e4855..fc78a02 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -64,6 +64,18 @@ def parse_iso_utc(raw): return dt if dt.tzinfo is not None else dt.replace(tzinfo=UTC) +def epoch_to_utc(value): + """Unix epoch seconds -> aware UTC datetime, for the boards that report a + bare epoch rather than the ISO string parse_iso_utc handles. Lived in + range_hood.py as `_timestamp` until air_purifier.py needed the same reading + for /airlevelcheck/vs/0's lastSensingTime -- promoted here rather than + cross-imported, matching how filter_usage_percent was shared.""" + try: + return datetime.fromtimestamp(float(value), tz=UTC) + except (TypeError, ValueError, OSError): + return None + + def filter_usage_percent(rep): """Filter usage as a percentage of rated capacity. Several families (AC, air purifier) report `filterUsage` as a raw count in diff --git a/custom_components/localthings/registry/capabilities/range_hood.py b/custom_components/localthings/registry/capabilities/range_hood.py index 6277859..fb2e215 100644 --- a/custom_components/localthings/registry/capabilities/range_hood.py +++ b/custom_components/localthings/registry/capabilities/range_hood.py @@ -7,8 +7,6 @@ brightness remain separate controls because the device advertises them as two independent fields. """ -from datetime import UTC, datetime - from ..batch import is_stub_rep from ..capability import Capability from ..entities import ( @@ -19,14 +17,7 @@ from ..entities import ( SensorDesc, SwitchDesc, ) -from .common import int_or_none, sensor_item_value - - -def _timestamp(value): - try: - return datetime.fromtimestamp(float(value), tz=UTC) - except (TypeError, ValueError, OSError): - return None +from .common import epoch_to_utc, int_or_none, sensor_item_value def _active_alarm_codes(items): @@ -286,7 +277,7 @@ AIR_LEVEL_CHECK = Capability( field="x.com.samsung.da.lastSensingTime", device_class="timestamp", entity_category="diagnostic", - value_fn=_timestamp, + value_fn=epoch_to_utc, ), SensorDesc( key="last_air_sensing_level", diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index 0d7c11b..dc3fcbd 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -191,6 +191,9 @@ "oven_setpoint": { "name": "Požadovaná teplota" }, + "sensing_interval": { + "name": "Interval AI Purify" + }, "setpoint": { "name": "Požadovaná teplota" }, @@ -494,6 +497,14 @@ "rinse_cycles": { "name": "Počet máchání" }, + "sensing_mode": { + "name": "Akce AI Purify", + "state": { + "off": "Pouze měření", + "airpurify": "Automatické čištění", + "alarm": "Dostat upozornění" + } + }, "softener_concentration": { "name": "Koncentrace aviváže", "state": { @@ -1030,6 +1041,12 @@ "display": { "name": "Displej" }, + "periodic_air_sensing": { + "name": "AI Purify" + }, + "periodic_sensing_skip_status": { + "name": "Přeskočení měření AI Purify" + }, "pet_filter_activation": { "name": "Aktivace filtru pro domácí mazlíčky" }, @@ -1199,6 +1216,12 @@ }, "night_start": { "name": "Začátek nočního osvětlení" + }, + "sensing_skip_end": { + "name": "Konec přeskočení měření AI Purify" + }, + "sensing_skip_start": { + "name": "Začátek přeskočení měření AI Purify" } }, "water_heater": { diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index efea0a9..591019d 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -191,6 +191,9 @@ "oven_setpoint": { "name": "Setpoint" }, + "sensing_interval": { + "name": "AI Purify interval" + }, "setpoint": { "name": "Setpoint" }, @@ -494,6 +497,14 @@ "rinse_cycles": { "name": "Rinse cycles" }, + "sensing_mode": { + "name": "AI Purify action", + "state": { + "off": "Sensing only", + "airpurify": "Auto clean", + "alarm": "Get notified" + } + }, "softener_concentration": { "name": "Softener concentration", "state": { @@ -1030,6 +1041,12 @@ "display": { "name": "Display" }, + "periodic_air_sensing": { + "name": "AI Purify" + }, + "periodic_sensing_skip_status": { + "name": "AI Purify sensing skip" + }, "pet_filter_activation": { "name": "Pet filter activation" }, @@ -1199,6 +1216,12 @@ }, "night_start": { "name": "Night light start" + }, + "sensing_skip_end": { + "name": "AI Purify sensing skip end" + }, + "sensing_skip_start": { + "name": "AI Purify sensing skip start" } }, "water_heater": { diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index c45af0b..b7728e3 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -313,6 +313,9 @@ "oven_setpoint": { "name": "Punto de ajuste" }, + "sensing_interval": { + "name": "Intervalo de AI Purify" + }, "setpoint": { "name": "Punto de ajuste" }, @@ -616,6 +619,14 @@ "rinse_cycles": { "name": "Aclarados" }, + "sensing_mode": { + "name": "Acción de AI Purify", + "state": { + "off": "Solo medición", + "airpurify": "Limpieza automática", + "alarm": "Recibir notificación" + } + }, "softener_concentration": { "name": "Concentración del suavizante", "state": { @@ -1152,6 +1163,12 @@ "display": { "name": "Pantalla" }, + "periodic_air_sensing": { + "name": "AI Purify" + }, + "periodic_sensing_skip_status": { + "name": "Omisión de medición de AI Purify" + }, "pet_filter_activation": { "name": "Activación de filtro para mascotas" }, @@ -1321,6 +1338,12 @@ }, "night_start": { "name": "Inicio de la luz nocturna" + }, + "sensing_skip_end": { + "name": "Fin de la omisión de medición de AI Purify" + }, + "sensing_skip_start": { + "name": "Inicio de la omisión de medición de AI Purify" } }, "water_heater": { diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index d8939cb..e904333 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -191,6 +191,9 @@ "oven_setpoint": { "name": "Valore impostato" }, + "sensing_interval": { + "name": "Intervallo AI Purify" + }, "setpoint": { "name": "Valore impostato" }, @@ -494,6 +497,14 @@ "rinse_cycles": { "name": "Risciacquo" }, + "sensing_mode": { + "name": "Azione AI Purify", + "state": { + "off": "Solo rilevamento", + "airpurify": "Pulizia automatica", + "alarm": "Ricevi notifica" + } + }, "softener_concentration": { "name": "Concentrazione ammorbidente", "state": { @@ -1030,6 +1041,12 @@ "display": { "name": "Display" }, + "periodic_air_sensing": { + "name": "AI Purify" + }, + "periodic_sensing_skip_status": { + "name": "Esclusione rilevamento AI Purify" + }, "pet_filter_activation": { "name": "Attivazione filtro animali" }, @@ -1199,6 +1216,12 @@ }, "night_start": { "name": "Inizio luce notturna" + }, + "sensing_skip_end": { + "name": "Fine esclusione rilevamento AI Purify" + }, + "sensing_skip_start": { + "name": "Inizio esclusione rilevamento AI Purify" } }, "water_heater": { diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 63614a1..45e1a68 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -191,6 +191,9 @@ "oven_setpoint": { "name": "Instelpunt" }, + "sensing_interval": { + "name": "AI Purify-interval" + }, "setpoint": { "name": "Instelpunt" }, @@ -494,6 +497,14 @@ "rinse_cycles": { "name": "Spoelcycli" }, + "sensing_mode": { + "name": "AI Purify-actie", + "state": { + "off": "Alleen meten", + "airpurify": "Automatisch reinigen", + "alarm": "Melding ontvangen" + } + }, "softener_concentration": { "name": "Concentratie wasverzachter", "state": { @@ -1030,6 +1041,12 @@ "display": { "name": "Display" }, + "periodic_air_sensing": { + "name": "AI Purify" + }, + "periodic_sensing_skip_status": { + "name": "AI Purify meting overslaan" + }, "pet_filter_activation": { "name": "Huisdierfilter actief" }, @@ -1199,6 +1216,12 @@ }, "night_start": { "name": "Start nachtverlichting" + }, + "sensing_skip_end": { + "name": "Einde AI Purify meting overslaan" + }, + "sensing_skip_start": { + "name": "Start AI Purify meting overslaan" } }, "water_heater": { diff --git a/tests/fixtures/golden/air_purifier_avt_ww.json b/tests/fixtures/golden/air_purifier_avt_ww.json index 8f13b0e..cc98a76 100644 --- a/tests/fixtures/golden/air_purifier_avt_ww.json +++ b/tests/fixtures/golden/air_purifier_avt_ww.json @@ -1,5 +1,6 @@ { "state_keys": [ + "air_sensing_state", "alarm_code", "clean_level", "device_active", @@ -10,9 +11,17 @@ "firmware_update", "hepa_filter_status", "hepa_filter_usage", + "last_air_sensing_level", + "last_air_sensing_time", "mute_once", "odor", + "periodic_air_sensing", + "periodic_sensing_skip_status", "power_switch", + "sensing_interval", + "sensing_mode", + "sensing_skip_end", + "sensing_skip_start", "super_fine_dust", "wind_strength_fan" ] diff --git a/tests/fixtures/golden/air_purifier_tp1x_da_ac_air.json b/tests/fixtures/golden/air_purifier_tp1x_da_ac_air.json index b72b83a..5337d36 100644 --- a/tests/fixtures/golden/air_purifier_tp1x_da_ac_air.json +++ b/tests/fixtures/golden/air_purifier_tp1x_da_ac_air.json @@ -1,5 +1,6 @@ { "state_keys": [ + "air_sensing_state", "alarm_code", "child_lock", "clean_level", @@ -14,11 +15,18 @@ "firmware_update", "hepa_filter_status", "hepa_filter_usage", + "last_air_sensing_level", + "last_air_sensing_time", "mute_once", "odor", "panel_status", + "periodic_air_sensing", + "periodic_sensing_skip_status", "pet_filter_activation", "power_switch", + "sensing_mode", + "sensing_skip_end", + "sensing_skip_start", "sound_mode", "sound_output", "sound_volume", diff --git a/tests/fixtures/golden/air_purifier_vtww.json b/tests/fixtures/golden/air_purifier_vtww.json index 9970ed3..d2949b8 100644 --- a/tests/fixtures/golden/air_purifier_vtww.json +++ b/tests/fixtures/golden/air_purifier_vtww.json @@ -1,5 +1,6 @@ { "state_keys": [ + "air_sensing_state", "alarm_code", "clean_level", "device_active", @@ -9,8 +10,16 @@ "firmware_update", "hepa_filter_status", "hepa_filter_usage", + "last_air_sensing_level", + "last_air_sensing_time", "odor", + "periodic_air_sensing", + "periodic_sensing_skip_status", "power_switch", + "sensing_interval", + "sensing_mode", + "sensing_skip_end", + "sensing_skip_start", "super_fine_dust", "wind_strength_fan" ] diff --git a/tests/test_air_purifier_airlevelcheck.py b/tests/test_air_purifier_airlevelcheck.py new file mode 100644 index 0000000..8410025 --- /dev/null +++ b/tests/test_air_purifier_airlevelcheck.py @@ -0,0 +1,263 @@ +"""Tests for /airlevelcheck/vs/0 -- the "AI Purify" periodic air-quality +sensing engine (issues #84 and #190). + +The resource is reported by three of this registry's four board families, so +the read assertions run against each family's own fixture; the write contracts +were exercised on AVT-WW-TP1-23-AXX500 hardware and are asserted here through +the descriptors that carry them. +""" + +import datetime + +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import resolve +from custom_components.localthings.registry.capabilities import air_purifier +from custom_components.localthings.registry.discovery import discover +from tests.conftest import _load_device + +# The three fixtures whose dumps carry this resource. air_purifier (the +# ARTIK051_TVTL family, issue #56) has no such href and is deliberately absent. +FAMILIES = ("air_purifier_avt_ww", "air_purifier_vtww", "air_purifier_tp1x_da_ac_air") + +HREF = ["airlevelcheck", "vs", "0"] + + +def _rep(fixture): + return _load_device(fixture)["/airlevelcheck/vs/0"] + + +def _state(fixture): + resources = _load_device(fixture) + reg = resolve(resources) + assert reg is not None and reg.name == "air_purifier", fixture + return flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + + +def _desc(key): + return next(d for d in air_purifier.AIR_LEVEL_CHECK.entities if d.key == key) + + +def test_air_level_check_is_bound_not_covered(): + """The href used to sit in COVERAGE as opaque scheduler plumbing. Guard + against it being covered again, which would silently drop every entity + below while still reporting zero unbound hrefs.""" + covered = {cap.href for cap in air_purifier.COVERAGE} + assert "/airlevelcheck/vs/0" not in covered + + +def test_every_reporting_family_binds_the_cluster(): + for fixture in FAMILIES: + state = _state(fixture) + for key in ( + "sensing_mode", + "periodic_air_sensing", + "periodic_sensing_skip_status", + "sensing_skip_start", + "sensing_skip_end", + "air_sensing_state", + "last_air_sensing_time", + "last_air_sensing_level", + ): + assert key in state, f"{fixture}: {key}" + + +def test_tvtl_family_is_untouched(): + """Issue #56's board has no /airlevelcheck href at all -- nothing this + change adds may appear on it.""" + state = _state("air_purifier") + for key in ("sensing_mode", "periodic_air_sensing", "sensing_interval", "sensing_skip_start"): + assert key not in state, key + + +def test_no_unbound_hrefs_on_any_reporting_family(): + for fixture in FAMILIES: + resources = _load_device(fixture) + reg = resolve(resources) + assert reg is not None, fixture + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [], f"{fixture}: {unbound}" + + +# --- the two knobs are separate entities, not one folded control ------------- + + +def test_activation_and_action_are_separate_entities(): + """The resource carries an on/off and an action as independent fields, and + the appliance's own UI presents them that way. Folding them into one + control would make a configured action invisible while the feature is off, + and would leave no way to toggle the feature without overwriting it.""" + assert _desc("periodic_air_sensing").field == ( + "x.com.samsung.da.periodicSensingActivationState" + ) + assert _desc("sensing_mode").field == "x.com.samsung.da.autoExeState" + + +def test_action_options_come_from_the_device_not_a_table(): + """supportedAutoExeState is advertised on every reporting fixture, so the + select reads it rather than carrying a typed-in tuple -- a board adding a + fourth action is then accepted rather than rejected.""" + desc = _desc("sensing_mode") + assert desc.options_field == "x.com.samsung.da.supportedAutoExeState" + assert not desc.options, "options must come from the device, not a static tuple" + for fixture in FAMILIES: + assert _rep(fixture)["x.com.samsung.da.supportedAutoExeState"] == [ + "Off", + "Airpurify", + "Alarm", + ], fixture + + +def test_action_write_sends_the_raw_advertised_value(): + """select.py maps the chosen option back to the device's own casing before + calling write_fn, so the body is the advertised token verbatim.""" + for raw in ("Off", "Airpurify", "Alarm"): + href, body = _desc("sensing_mode").write_fn(raw, {}) + assert href == HREF + assert body == {"x.com.samsung.da.autoExeState": raw} + + +def test_activation_write_leaves_the_action_alone(): + """Toggling the feature must not disturb autoExeState -- that's what makes + the switch able to do something the action select can't.""" + for payload, expected in (("On", "On"), ("Off", "Off")): + href, body = _desc("periodic_air_sensing").write_fn(payload, {}) + assert href == HREF + assert body == {"x.com.samsung.da.periodicSensingActivationState": expected} + assert "x.com.samsung.da.autoExeState" not in body + + +# --- interval ---------------------------------------------------------------- + + +def test_sensing_interval_only_where_the_field_exists(): + """TP1X_DA-AC-AIR (issue #130) omits periodicSensingInterval; the other two + report it. The entity must follow the field, not the href.""" + assert "sensing_interval" in _state("air_purifier_avt_ww") + assert "sensing_interval" in _state("air_purifier_vtww") + assert "sensing_interval" not in _state("air_purifier_tp1x_da_ac_air") + + +def test_interval_is_minutes_in_the_ui_and_seconds_on_the_wire(): + desc = _desc("sensing_interval") + assert desc.value_fn("600") == 10 + assert desc.write_fn(10, {})[1] == {"x.com.samsung.da.periodicSensingInterval": "600"} + + +def test_interval_keeps_zero_distinct_from_unknown(): + """`if secs` would fold a reported 0 into None. Anything else nonzero + rounds up, so a sub-minute reading can't render as 0 and fall below the + entity's own floor.""" + desc = _desc("sensing_interval") + assert desc.value_fn("0") == 0 + assert desc.value_fn("20") == 1 + assert desc.value_fn("61") == 2 + assert desc.value_fn(None) is None + + +def test_interval_floor_is_one_minute(): + """lastSensingTime lands on an exact minute on this board family, so a + sub-minute interval is unobservable; and 0 has no established meaning + here, unlike the zero floors on oven.cook_time / delay_start_hours.""" + desc = _desc("sensing_interval") + assert desc.native_min == 1 + assert desc.write_fn(0, {}) is None + assert desc.write_fn(0.4, {}) is None + assert desc.write_fn(1, {})[1] == {"x.com.samsung.da.periodicSensingInterval": "60"} + + +# --- skip window ------------------------------------------------------------- + + +def test_skip_time_splits_the_hhmmhhmm_window(): + read_start = _desc("sensing_skip_start").value_fn + read_end = _desc("sensing_skip_end").value_fn + # Issue #190's unit ships a real window: 03:00-23:00. + assert read_start("03002300") == datetime.time(3, 0) + assert read_end("03002300") == datetime.time(23, 0) + # Issue #84's unit sits at the inert default. + assert read_start("00000000") == datetime.time(0, 0) + # Junk and short strings read as unknown rather than raising. + assert read_start("") is None + assert read_start("99999999") is None + assert read_end("0300") is None + + +def test_skip_time_write_preserves_the_other_half(): + rep = {"x.com.samsung.da.periodicSensingSkipTime": "03002300"} + _, body = _desc("sensing_skip_start").write_fn(datetime.time(7, 30), rep) + assert body == {"x.com.samsung.da.periodicSensingSkipTime": "07302300"} + _, body = _desc("sensing_skip_end").write_fn(datetime.time(22, 5), rep) + assert body == {"x.com.samsung.da.periodicSensingSkipTime": "03002205"} + # A board that has never had a window set still round-trips. + _, body = _desc("sensing_skip_end").write_fn(datetime.time(1, 2), {}) + assert body == {"x.com.samsung.da.periodicSensingSkipTime": "00000102"} + + +def test_skip_time_write_normalizes_a_half_it_cannot_parse(): + """Padding alone would splice a malformed half straight back onto the wire. + The read side already refuses one, so the write side zeroes it instead of + echoing junk to the device.""" + junk = {"x.com.samsung.da.periodicSensingSkipTime": "0730zzzz"} + _, body = _desc("sensing_skip_start").write_fn(datetime.time(8, 0), junk) + assert body == {"x.com.samsung.da.periodicSensingSkipTime": "08000000"} + junk = {"x.com.samsung.da.periodicSensingSkipTime": "zzzz2200"} + _, body = _desc("sensing_skip_end").write_fn(datetime.time(23, 0), junk) + assert body == {"x.com.samsung.da.periodicSensingSkipTime": "00002300"} + + +def test_skip_status_switch_body(): + assert _desc("periodic_sensing_skip_status").write_fn("On", {})[1] == { + "x.com.samsung.da.periodicSensingSkipStatus": "On" + } + + +# --- read-only diagnostics --------------------------------------------------- + + +def test_last_sensing_time_reads_as_utc(): + state = _state("air_purifier_avt_ww") + assert state["last_air_sensing_time"].tzinfo is not None + assert state["last_air_sensing_time"].year >= 2020 + + +def test_read_only_keys_match_the_range_hood_capability(): + """The three read-only sensor keys are shared with the hood deliberately, + so both families read from one translation catalog entry each. If either + side renames one, this catches the drift. + + periodic_air_sensing is excluded: it's a SwitchDesc here and a + BinarySensorDesc on the hood, so the two sit in different platform + catalogs and are worded differently -- see the next test. + """ + from custom_components.localthings.registry.capabilities import range_hood + + hood = {d.key for d in range_hood.AIR_LEVEL_CHECK.entities} + ours = {d.key for d in air_purifier.AIR_LEVEL_CHECK.entities} + assert { + "air_sensing_state", + "last_air_sensing_time", + "last_air_sensing_level", + } <= hood & ours + + +def test_periodic_air_sensing_is_writable_here_and_read_only_on_hoods(): + """The reason range_hood.AIR_LEVEL_CHECK is not imported directly: the hood + models this key as a read-only BinarySensorDesc, this board needs a + writable SwitchDesc. Reusing the hood's capability would migrate every hood + user's entity to a different platform.""" + from custom_components.localthings.registry.capabilities import range_hood + from custom_components.localthings.registry.entities import BinarySensorDesc, SwitchDesc + + hood = next(d for d in range_hood.AIR_LEVEL_CHECK.entities if d.key == "periodic_air_sensing") + assert isinstance(hood, BinarySensorDesc) + ours = _desc("periodic_air_sensing") + assert isinstance(ours, SwitchDesc) + assert ours.write_fn is not None + + +def test_read_only_diagnostics_match_the_hood_on_visibility(): + """The hood leaves all three enabled; asserting key parity with it while + hiding two of them would be a quiet divergence.""" + for key in ("air_sensing_state", "last_air_sensing_time", "last_air_sensing_level"): + assert _desc(key).enabled_default is True, key