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.
This commit is contained in:
kkqq9320
2026-08-04 14:20:58 +09:00
parent 412fff9b99
commit a5484f746a
7 changed files with 217 additions and 193 deletions
@@ -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,
),
),
)
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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"
+114 -87
View File
@@ -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