Merge pull request #268 from kkqq9320/feat/avt-ai-purify

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