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:
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user