From 412fff9b99d7d3230782d0ec56f414b5056898d3 Mon Sep 17 00:00:00 2001 From: kkqq9320 Date: Mon, 3 Aug 2026 11:20:13 +0900 Subject: [PATCH 1/3] feat(air_purifier): expose the AI Purify sensing engine on /airlevelcheck/vs/0 /airlevelcheck/vs/0 has been covered as "periodic air-quality sensing scheduler plumbing" since the registry gained a coverage stub for it. Two AVT-WW-TP1-23-AXX500 dumps (issues #84 and #190) show 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. The select's three on-states are the app's own options rather than an invented grouping -- it offers exactly "Sensing only" (sample, take no action), "Auto clean" (purify while the air reads bad, stop once it improves) and "Get notified" (raise a SmartThings notification). Labels were transcribed from the Korean app and rendered in English; the auto-stop half of "Auto clean" is the app's own description and is not otherwise visible in the dump, which reports only the selected autoExeState. The remaining entity names follow their raw fields rather than inventing a concept -- the skip window is "sensing skip", after periodicSensingSkipStatus/Time. 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, and its golden is unchanged. Bound unconditionally rather than behind a match_fn; the one field that genuinely varies (periodicSensingInterval, absent on the #130 board) is gated per-entity, so that board gets eight entities instead of nine rather than a broken one. range_hood.AIR_LEVEL_CHECK already models this same href, and its read-only keys are reused verbatim here 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 key, so reusing the hood's capability would migrate every hood user's entity to a different platform. Every write was exercised on AVT-WW-TP1-23-AXX500 hardware. This board hands out 2.04 for writes it silently discards (see HEPA_FILTER's filter-reset note), so an echo proves nothing -- each was judged by whether the value survived a reconnect, which forces a new DTLS session, fresh discovery and a fresh observe of the href, leaving no cached state to read back: * sensing_mode's combined two-field PUT lands both fields, both ways: sensing_only -> auto_purify raises autoExeState with activation still On, and back again lowers it. * The sensing-skip switch holds Off -> On and back. * The half-preserving time writes hold: from 13:00-23:00, writing start=07:30 then end=22:00 left the device on '07302200' -- each write kept the half it wasn't given. * periodic_air_sensing and sensing_interval: writing 60 s drove an observed ~60 s sensing cycle. * The read side of the skip window is separately cross-confirmed on two units: #84's sits at the inert '00000000', #190's carries a real '03002300' (03:00-23:00), which is what pins the HHMMHHMM split. * 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. range_hood._timestamp moves to common.epoch_to_utc so both callers share it, matching how filter_usage_percent was shared. No behaviour change. Every existing entity is untouched: the three golden updates are purely additive, no renames, no unit or device_class changes. --- .../registry/by_type/air_purifier.py | 7 + .../registry/capabilities/air_purifier.py | 246 +++++++++++++++++- .../registry/capabilities/common.py | 12 + .../registry/capabilities/range_hood.py | 13 +- .../localthings/translations/cs.json | 24 ++ .../localthings/translations/en.json | 24 ++ .../localthings/translations/es.json | 24 ++ .../localthings/translations/it.json | 24 ++ .../localthings/translations/nl.json | 24 ++ .../fixtures/golden/air_purifier_avt_ww.json | 9 + .../golden/air_purifier_tp1x_da_ac_air.json | 8 + tests/fixtures/golden/air_purifier_vtww.json | 9 + tests/test_air_purifier_airlevelcheck.py | 224 ++++++++++++++++ 13 files changed, 635 insertions(+), 13 deletions(-) create mode 100644 tests/test_air_purifier_airlevelcheck.py 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 From a5484f746a2232e1945d981563216634414f408b Mon Sep 17 00:00:00 2001 From: kkqq9320 Date: Tue, 4 Aug 2026 14:20:58 +0900 Subject: [PATCH 2/3] review: unfold AI Purify into one entity per field Review feedback on #268. The largest change is that the sensing-mode select no longer folds two device fields into one control. periodicSensingActivationState and autoExeState are independent knobs, and the appliance presents them that way -- its own UI has an on/off for AI Purify separately from the three mode choices. Folding them lost two things: a configured action was invisible while the feature was off, and no select option could toggle the feature without also overwriting the action. The switch was not the duplicate it looked like. So the switch now owns periodicSensingActivationState alone, and the select owns autoExeState alone. That resolves the hardcoded-options finding at the source rather than working around it: the select reads supportedAutoExeState via options_field -- the same shape SOUND_MODE already uses for supportedModes -- instead of carrying a typed-in tuple, so a board advertising a fourth action is accepted on both the options list and the write path. _sensing_mode, _sensing_mode_write and _SENSING_MODE_BODIES are all gone with the fold. Option slugs are now the advertised values lowercased (off / airpurify / alarm) rather than invented names. The catalog carries the labels, so the two 'off's stay distinguishable in the UI: the switch's means the unit isn't sampling, the select's means it samples and doesn't act on the reading -- what the app calls "sensing only". Also from the review: * _interval_minutes checks `is None` so a reported 0 stays 0, and native_min drops to 0 since sub-30s values round there. oven.cook_time and operational's delay hours are the precedent -- both convert a device time value and floor at zero. The Number-rather-than-Select choice is now stated in the write helper: the app offers three fixed intervals, but this resource advertises no supported-values or range field (supportedAutoExeState sits right beside it, so the board does advertise constraints where it has them) and it accepted 60 s, six times finer than the app's smallest choice. * _skip_time_write no longer splices a malformed half back onto the wire. The read side already refuses one it can't parse; the write side now zeroes it to match. * air_sensing_state and last_air_sensing_level lose enabled_default=False, matching range_hood.AIR_LEVEL_CHECK. Hiding two of three read-only keys while claiming key parity with that capability -- and leaving the third visible -- had no justification behind it. * The catalog-parity test drops periodic_air_sensing from its key set: that key is a SwitchDesc here and a BinarySensorDesc on the hood, so the two live in different platform catalogs and are worded differently. The claim now covers only the three read-only sensor keys, where it holds. * Tests route through the descriptors (_desc(key).write_fn / .value_fn) rather than module-private helpers, matching test_air_monitor_capabilities. startSensingOnce stays unbound, now explicitly rather than by omission -- the module comment records it as deferred. It looks like a one-shot "sense now" button, but this board acknowledges writes it discards, and nothing has confirmed the side effect yet. Goldens are untouched: the key set is unchanged, only sensing_mode's value moves from the folded slug to the raw autoExeState. --- .../registry/capabilities/air_purifier.py | 154 +++++++------- .../localthings/translations/cs.json | 11 +- .../localthings/translations/en.json | 11 +- .../localthings/translations/es.json | 11 +- .../localthings/translations/it.json | 11 +- .../localthings/translations/nl.json | 11 +- tests/test_air_purifier_airlevelcheck.py | 201 ++++++++++-------- 7 files changed, 217 insertions(+), 193 deletions(-) diff --git a/custom_components/localthings/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py index a7a67ed..8297c0d 100644 --- a/custom_components/localthings/registry/capabilities/air_purifier.py +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -525,6 +525,29 @@ SOUND_VOLUME = Capability( # 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 @@ -541,87 +564,49 @@ SOUND_VOLUME = Capability( # 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.""" + """Device stores the interval in seconds; the entity is in minutes. + + `is None` rather than a falsy check so a reported 0 stays 0 instead of + reading as unknown. Sub-30s values round to 0, which is why native_min is + 0 rather than 1 -- matching oven.cook_time and operational's delay hours, + both of which convert a device time value and floor at zero. + """ secs = int_or_none(seconds) - return round(secs / 60) if secs else None + return round(secs / 60) if secs is not None 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. + # 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. 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. + # 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") } -# 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") @@ -650,11 +635,23 @@ def _skip_time_read(part): 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): - cur = (str(rep.get("x.com.samsung.da.periodicSensingSkipTime", "") or "") + "00000000")[:8] + raw = rep.get("x.com.samsung.da.periodicSensingSkipTime", "") hhmm = f"{value.hour:02d}{value.minute:02d}" - new = hhmm + cur[4:8] if part == "start" else cur[0:4] + hhmm + 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 @@ -664,15 +661,6 @@ 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", @@ -681,6 +669,22 @@ AIR_LEVEL_CHECK = Capability( 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 @@ -690,7 +694,7 @@ AIR_LEVEL_CHECK = Capability( field="x.com.samsung.da.periodicSensingInterval", icon="mdi:timer-cog", entity_category="config", - native_min=1, + native_min=0, native_max=60, step=1, unit="min", @@ -728,7 +732,6 @@ AIR_LEVEL_CHECK = Capability( field="x.com.samsung.da.sensingState", icon="mdi:radar", entity_category="diagnostic", - enabled_default=False, ), SensorDesc( key="last_air_sensing_time", @@ -745,7 +748,6 @@ AIR_LEVEL_CHECK = Capability( field="x.com.samsung.da.lastSensingLevel", icon="mdi:air-filter", entity_category="diagnostic", - enabled_default=False, ), ), ) diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index 7072da0..dc3fcbd 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -498,12 +498,11 @@ "name": "Počet máchání" }, "sensing_mode": { - "name": "AI Purify", + "name": "Akce AI Purify", "state": { - "off": "Vypnuto", - "sensing_only": "Pouze měření", - "auto_purify": "Automatické čištění", - "st_alarm": "Dostat upozornění" + "off": "Pouze měření", + "airpurify": "Automatické čištění", + "alarm": "Dostat upozornění" } }, "softener_concentration": { @@ -1043,7 +1042,7 @@ "name": "Displej" }, "periodic_air_sensing": { - "name": "Měření AI Purify" + "name": "AI Purify" }, "periodic_sensing_skip_status": { "name": "Přeskočení měření AI Purify" diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 4d633b1..591019d 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -498,12 +498,11 @@ "name": "Rinse cycles" }, "sensing_mode": { - "name": "AI Purify", + "name": "AI Purify action", "state": { - "off": "Off", - "sensing_only": "Sensing only", - "auto_purify": "Auto clean", - "st_alarm": "Get notified" + "off": "Sensing only", + "airpurify": "Auto clean", + "alarm": "Get notified" } }, "softener_concentration": { @@ -1043,7 +1042,7 @@ "name": "Display" }, "periodic_air_sensing": { - "name": "AI Purify sensing" + "name": "AI Purify" }, "periodic_sensing_skip_status": { "name": "AI Purify sensing skip" diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index 35c1712..b7728e3 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -620,12 +620,11 @@ "name": "Aclarados" }, "sensing_mode": { - "name": "AI Purify", + "name": "Acción de AI Purify", "state": { - "off": "Apagado", - "sensing_only": "Solo medición", - "auto_purify": "Limpieza automática", - "st_alarm": "Recibir notificación" + "off": "Solo medición", + "airpurify": "Limpieza automática", + "alarm": "Recibir notificación" } }, "softener_concentration": { @@ -1165,7 +1164,7 @@ "name": "Pantalla" }, "periodic_air_sensing": { - "name": "Medición de AI Purify" + "name": "AI Purify" }, "periodic_sensing_skip_status": { "name": "Omisión de medición de AI Purify" diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index 1c66f83..e904333 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -498,12 +498,11 @@ "name": "Risciacquo" }, "sensing_mode": { - "name": "AI Purify", + "name": "Azione AI Purify", "state": { - "off": "Spento", - "sensing_only": "Solo rilevamento", - "auto_purify": "Pulizia automatica", - "st_alarm": "Ricevi notifica" + "off": "Solo rilevamento", + "airpurify": "Pulizia automatica", + "alarm": "Ricevi notifica" } }, "softener_concentration": { @@ -1043,7 +1042,7 @@ "name": "Display" }, "periodic_air_sensing": { - "name": "Rilevamento AI Purify" + "name": "AI Purify" }, "periodic_sensing_skip_status": { "name": "Esclusione rilevamento AI Purify" diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 65e3db6..45e1a68 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -498,12 +498,11 @@ "name": "Spoelcycli" }, "sensing_mode": { - "name": "AI Purify", + "name": "AI Purify-actie", "state": { - "off": "Uit", - "sensing_only": "Alleen meten", - "auto_purify": "Automatisch reinigen", - "st_alarm": "Melding ontvangen" + "off": "Alleen meten", + "airpurify": "Automatisch reinigen", + "alarm": "Melding ontvangen" } }, "softener_concentration": { @@ -1043,7 +1042,7 @@ "name": "Display" }, "periodic_air_sensing": { - "name": "AI Purify-meting" + "name": "AI Purify" }, "periodic_sensing_skip_status": { "name": "AI Purify meting overslaan" diff --git a/tests/test_air_purifier_airlevelcheck.py b/tests/test_air_purifier_airlevelcheck.py index 858569b..72b50f4 100644 --- a/tests/test_air_purifier_airlevelcheck.py +++ b/tests/test_air_purifier_airlevelcheck.py @@ -3,8 +3,8 @@ 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. +were exercised on AVT-WW-TP1-23-AXX500 hardware and are asserted here through +the descriptors that carry them. """ import datetime @@ -19,6 +19,12 @@ from tests.conftest import _load_device # 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) @@ -63,14 +69,6 @@ def test_tvtl_family_is_untouched(): 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) @@ -81,78 +79,87 @@ def test_no_unbound_hrefs_on_any_reporting_family(): 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" - ) +# --- the two knobs are separate entities, not one folded control ------------- -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_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_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_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(): - 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" - } + 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; sub-30s values round to 0, + which is why native_min is 0 rather than 1.""" + desc = _desc("sensing_interval") + assert desc.value_fn("0") == 0 + assert desc.value_fn("20") == 0 + assert desc.value_fn(None) is None + assert desc.native_min == 0 + + +# --- skip window ------------------------------------------------------------- def test_skip_time_splits_the_hhmmhhmm_window(): - read_start = air_purifier._skip_time_read("start") - read_end = air_purifier._skip_time_read("end") + 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) @@ -166,27 +173,36 @@ def test_skip_time_splits_the_hhmmhhmm_window(): 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) + _, body = _desc("sensing_skip_start").write_fn(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) + _, 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 = air_purifier._skip_time_write("end")(datetime.time(1, 2), {}) + _, body = _desc("sensing_skip_end").write_fn(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] == { +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 @@ -194,9 +210,14 @@ def test_last_sensing_time_reads_as_utc(): 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.""" + """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} @@ -205,7 +226,6 @@ def test_read_only_keys_match_the_range_hood_capability(): "air_sensing_state", "last_air_sensing_time", "last_air_sensing_level", - "periodic_air_sensing", } <= hood & ours @@ -222,3 +242,10 @@ def test_periodic_air_sensing_is_writable_here_and_read_only_on_hoods(): 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 From 96d06369bcf20e874660cbea47f0570e5d60477d Mon Sep 17 00:00:00 2001 From: kkqq9320 Date: Tue, 4 Aug 2026 14:36:40 +0900 Subject: [PATCH 3/3] review: floor the sensing interval at one minute Dropping native_min to 0 fixed the read range and quietly opened a write: native_min governs what the user can enter, not just what renders, so 0 became enterable and would have gone out as periodicSensingInterval "0". Nothing establishes what that does to this board -- both fixtures report 600, the app's smallest choice is 10 min, and 60 s is the lowest value confirmed accepted. The two precedents leaned on differ in exactly the way that matters: oven.cook_time and operational.delay_start_hours sit at a zero floor under a value where 0 is a real setting ("no timer", "no delay"). One minute is also 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, plus eleven consecutive live readings -- where the TP1X/AC/hood boards report arbitrary seconds. A sub-minute interval is unobservable here whether or not the board honours it. So native_min goes to 1 rather than 0, and the read rounds up instead of to nearest so a sub-minute reading renders as 1 rather than falling below the entity's own floor. The write still refuses anything under a minute -- a None return, the silent no-op range_hood._lamp_level_write uses for a level the device didn't advertise -- since native_min only guards the UI path, not a service call. --- .../registry/capabilities/air_purifier.py | 32 +++++++++++++++---- tests/test_air_purifier_airlevelcheck.py | 20 +++++++++--- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/custom_components/localthings/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py index 8297c0d..887b6be 100644 --- a/custom_components/localthings/registry/capabilities/air_purifier.py +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -575,13 +575,15 @@ SOUND_VOLUME = Capability( 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 stays 0 instead of - reading as unknown. Sub-30s values round to 0, which is why native_min is - 0 rather than 1 -- matching oven.cook_time and operational's delay hours, - both of which convert a device time value and floor at zero. + `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) - return round(secs / 60) if secs is not None else None + if secs is None: + return None + return -(-secs // 60) if secs > 0 else 0 def _interval_write(payload, rep, href=None): @@ -592,8 +594,24 @@ def _interval_write(payload, rep, href=None): # 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(round(float(payload) * 60)) + "x.com.samsung.da.periodicSensingInterval": str(minutes * 60) } @@ -694,7 +712,7 @@ AIR_LEVEL_CHECK = Capability( field="x.com.samsung.da.periodicSensingInterval", icon="mdi:timer-cog", entity_category="config", - native_min=0, + native_min=1, native_max=60, step=1, unit="min", diff --git a/tests/test_air_purifier_airlevelcheck.py b/tests/test_air_purifier_airlevelcheck.py index 72b50f4..8410025 100644 --- a/tests/test_air_purifier_airlevelcheck.py +++ b/tests/test_air_purifier_airlevelcheck.py @@ -145,13 +145,25 @@ def test_interval_is_minutes_in_the_ui_and_seconds_on_the_wire(): def test_interval_keeps_zero_distinct_from_unknown(): - """`if secs` would fold a reported 0 into None; sub-30s values round to 0, - which is why native_min is 0 rather than 1.""" + """`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") == 0 + assert desc.value_fn("20") == 1 + assert desc.value_fn("61") == 2 assert desc.value_fn(None) is None - assert desc.native_min == 0 + + +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 -------------------------------------------------------------