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..a7a67ed 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,246 @@ 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. +# +# 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. +# --------------------------------------------------------------------------- + + +def _interval_minutes(seconds): + """Device stores the interval in seconds; the entity is in minutes.""" + secs = int_or_none(seconds) + return round(secs / 60) if secs else None + + +def _interval_write(payload, rep, href=None): + # Minutes in the UI -> seconds on the wire (scalar string). Arbitrary + # values are honoured: confirmed on hardware, where writing 60 s drove an + # observed sensing cycle every ~60 s. The 1-60 min range on the entity is a + # UI guard, not a device-advertised limit -- the resource carries no + # supported-range field. + return ["airlevelcheck", "vs", "0"], { + "x.com.samsung.da.periodicSensingInterval": str(round(float(payload) * 60)) + } + + +def _sensing_mode(rep): + """Fold the periodic-sensing toggle and the auto-action into one status. + + The three on-states are the SmartThings app's own options for this + feature, not an invented grouping -- the app offers exactly: + + sensing_only "Sensing only" -- sample the air, take no action + auto_purify "Auto clean" -- run purification while the air reads + bad and stop again once it improves + st_alarm "Get notified" -- send a SmartThings notification when + the air reads bad + + (Labels transcribed from the Korean app -- 감지만 하기 / 자동 청정 / + 알림받기 -- and rendered here in English; the parenthetical behaviour is + the app's own description of each. The auto-stop half of 'Auto clean' is + not visible in the dump, which reports only the selected autoExeState.) + """ + on = str(rep.get("x.com.samsung.da.periodicSensingActivationState", "")).lower() == "on" + if not on: + return "off" + return {"Airpurify": "auto_purify", "Alarm": "st_alarm"}.get( + str(rep.get("x.com.samsung.da.autoExeState", "")), "sensing_only" + ) + + +def _periodic_sensing_write(payload, rep, href=None): + # The master on/off for periodic sensing. Off holds any auto-action + # pending; On arms the selected one. + return ["airlevelcheck", "vs", "0"], { + "x.com.samsung.da.periodicSensingActivationState": ("On" if payload == "On" else "Off") + } + + +# One-control version of the same two fields: a single PUT sets the sensing +# toggle and the auto-action together, so 'sensing_only' arms sensing with no +# action in one step instead of two writes that briefly pass through a state +# the user didn't ask for. Confirmed to land both fields in both directions -- +# sensing_only -> auto_purify raises autoExeState while activation stays On, +# and back again lowers it. +_SENSING_MODE_BODIES = { + "off": {"x.com.samsung.da.periodicSensingActivationState": "Off"}, + "sensing_only": { + "x.com.samsung.da.periodicSensingActivationState": "On", + "x.com.samsung.da.autoExeState": "Off", + }, + "auto_purify": { + "x.com.samsung.da.periodicSensingActivationState": "On", + "x.com.samsung.da.autoExeState": "Airpurify", + }, + "st_alarm": { + "x.com.samsung.da.periodicSensingActivationState": "On", + "x.com.samsung.da.autoExeState": "Alarm", + }, +} + + +def _sensing_mode_write(payload, rep, href=None): + body = _SENSING_MODE_BODIES.get(payload) + return (["airlevelcheck", "vs", "0"], dict(body)) if body else None + + +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_time_write(part): + def _write(value, rep, href=None): + cur = (str(rep.get("x.com.samsung.da.periodicSensingSkipTime", "") or "") + "00000000")[:8] + hhmm = f"{value.hour:02d}{value.minute:02d}" + new = hhmm + cur[4:8] if part == "start" else cur[0:4] + 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=( + SelectDesc( + key="sensing_mode", + rep_fn=_sensing_mode, + options=("off", "sensing_only", "auto_purify", "st_alarm"), + translation_key="sensing_mode", + icon="mdi:radar", + entity_category="config", + write_fn=_sensing_mode_write, + ), + 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, + ), + # 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", + enabled_default=False, + ), + 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", + enabled_default=False, + ), + ), +) + # /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 +765,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..7072da0 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,15 @@ "rinse_cycles": { "name": "Počet máchání" }, + "sensing_mode": { + "name": "AI Purify", + "state": { + "off": "Vypnuto", + "sensing_only": "Pouze měření", + "auto_purify": "Automatické čištění", + "st_alarm": "Dostat upozornění" + } + }, "softener_concentration": { "name": "Koncentrace aviváže", "state": { @@ -1030,6 +1042,12 @@ "display": { "name": "Displej" }, + "periodic_air_sensing": { + "name": "Měření 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 +1217,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..4d633b1 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,15 @@ "rinse_cycles": { "name": "Rinse cycles" }, + "sensing_mode": { + "name": "AI Purify", + "state": { + "off": "Off", + "sensing_only": "Sensing only", + "auto_purify": "Auto clean", + "st_alarm": "Get notified" + } + }, "softener_concentration": { "name": "Softener concentration", "state": { @@ -1030,6 +1042,12 @@ "display": { "name": "Display" }, + "periodic_air_sensing": { + "name": "AI Purify sensing" + }, + "periodic_sensing_skip_status": { + "name": "AI Purify sensing skip" + }, "pet_filter_activation": { "name": "Pet filter activation" }, @@ -1199,6 +1217,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..35c1712 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,15 @@ "rinse_cycles": { "name": "Aclarados" }, + "sensing_mode": { + "name": "AI Purify", + "state": { + "off": "Apagado", + "sensing_only": "Solo medición", + "auto_purify": "Limpieza automática", + "st_alarm": "Recibir notificación" + } + }, "softener_concentration": { "name": "Concentración del suavizante", "state": { @@ -1152,6 +1164,12 @@ "display": { "name": "Pantalla" }, + "periodic_air_sensing": { + "name": "Medición de 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 +1339,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..1c66f83 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,15 @@ "rinse_cycles": { "name": "Risciacquo" }, + "sensing_mode": { + "name": "AI Purify", + "state": { + "off": "Spento", + "sensing_only": "Solo rilevamento", + "auto_purify": "Pulizia automatica", + "st_alarm": "Ricevi notifica" + } + }, "softener_concentration": { "name": "Concentrazione ammorbidente", "state": { @@ -1030,6 +1042,12 @@ "display": { "name": "Display" }, + "periodic_air_sensing": { + "name": "Rilevamento AI Purify" + }, + "periodic_sensing_skip_status": { + "name": "Esclusione rilevamento AI Purify" + }, "pet_filter_activation": { "name": "Attivazione filtro animali" }, @@ -1199,6 +1217,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..65e3db6 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,15 @@ "rinse_cycles": { "name": "Spoelcycli" }, + "sensing_mode": { + "name": "AI Purify", + "state": { + "off": "Uit", + "sensing_only": "Alleen meten", + "auto_purify": "Automatisch reinigen", + "st_alarm": "Melding ontvangen" + } + }, "softener_concentration": { "name": "Concentratie wasverzachter", "state": { @@ -1030,6 +1042,12 @@ "display": { "name": "Display" }, + "periodic_air_sensing": { + "name": "AI Purify-meting" + }, + "periodic_sensing_skip_status": { + "name": "AI Purify meting overslaan" + }, "pet_filter_activation": { "name": "Huisdierfilter actief" }, @@ -1199,6 +1217,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..858569b --- /dev/null +++ b/tests/test_air_purifier_airlevelcheck.py @@ -0,0 +1,224 @@ +"""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 at the +body level. +""" + +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") + + +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_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_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}" + + +def test_sensing_mode_folds_toggle_and_action(): + """Off beats any pending auto-action; On with no action is 'sensing only'.""" + assert ( + air_purifier._sensing_mode( + { + "x.com.samsung.da.periodicSensingActivationState": "Off", + "x.com.samsung.da.autoExeState": "Airpurify", + } + ) + == "off" + ) + assert ( + air_purifier._sensing_mode( + { + "x.com.samsung.da.periodicSensingActivationState": "On", + "x.com.samsung.da.autoExeState": "Off", + } + ) + == "sensing_only" + ) + assert ( + air_purifier._sensing_mode( + { + "x.com.samsung.da.periodicSensingActivationState": "On", + "x.com.samsung.da.autoExeState": "Airpurify", + } + ) + == "auto_purify" + ) + assert ( + air_purifier._sensing_mode( + { + "x.com.samsung.da.periodicSensingActivationState": "On", + "x.com.samsung.da.autoExeState": "Alarm", + } + ) + == "st_alarm" + ) + + +def test_sensing_mode_write_sets_both_fields_in_one_body(): + href, body = air_purifier._sensing_mode_write("auto_purify", {}) + assert href == ["airlevelcheck", "vs", "0"] + assert body == { + "x.com.samsung.da.periodicSensingActivationState": "On", + "x.com.samsung.da.autoExeState": "Airpurify", + } + # 'off' only needs the toggle -- the pending action is preserved. + assert air_purifier._sensing_mode_write("off", {})[1] == { + "x.com.samsung.da.periodicSensingActivationState": "Off" + } + assert air_purifier._sensing_mode_write("nonsense", {}) is None + + +def test_sensing_mode_write_does_not_mutate_the_shared_body_table(): + before = dict(air_purifier._SENSING_MODE_BODIES["auto_purify"]) + _, body = air_purifier._sensing_mode_write("auto_purify", {}) + body["x.com.samsung.da.autoExeState"] = "clobbered" + assert air_purifier._SENSING_MODE_BODIES["auto_purify"] == before + + +def test_interval_is_minutes_in_the_ui_and_seconds_on_the_wire(): + assert air_purifier._interval_minutes("600") == 10 + assert air_purifier._interval_minutes(None) is None + assert air_purifier._interval_write(10, {})[1] == { + "x.com.samsung.da.periodicSensingInterval": "600" + } + + +def test_skip_time_splits_the_hhmmhhmm_window(): + read_start = air_purifier._skip_time_read("start") + read_end = air_purifier._skip_time_read("end") + # 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 = air_purifier._skip_time_write("start")(datetime.time(7, 30), rep) + assert body == {"x.com.samsung.da.periodicSensingSkipTime": "07302300"} + _, body = air_purifier._skip_time_write("end")(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 = air_purifier._skip_time_write("end")(datetime.time(1, 2), {}) + assert body == {"x.com.samsung.da.periodicSensingSkipTime": "00000102"} + + +def test_periodic_sensing_and_skip_switch_bodies(): + assert air_purifier._periodic_sensing_write("On", {})[1] == { + "x.com.samsung.da.periodicSensingActivationState": "On" + } + assert air_purifier._periodic_sensing_write("Off", {})[1] == { + "x.com.samsung.da.periodicSensingActivationState": "Off" + } + assert air_purifier._skip_status_write("On", {})[1] == { + "x.com.samsung.da.periodicSensingSkipStatus": "On" + } + + +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(): + """Same href, same fields -- the keys are shared deliberately so both + families read from one translation catalog entry. If either side renames + one, this catches the drift.""" + 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", + "periodic_air_sensing", + } <= 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