diff --git a/custom_components/localthings/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py index a7a67ed..8297c0d 100644 --- a/custom_components/localthings/registry/capabilities/air_purifier.py +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -525,6 +525,29 @@ SOUND_VOLUME = Capability( # rather than behind a match_fn so any board reporting it is covered; the one # field that genuinely varies is gated per-entity below. # +# The resource carries two independent knobs and they get one entity each, +# rather than being folded into a single control: +# +# periodicSensingActivationState On/Off -- is AI Purify running +# autoExeState Off/Airpurify/Alarm -- what it does with a +# bad reading +# +# The appliance itself presents them that way: its own UI has an on/off for AI +# Purify separately from the three mode choices. Folding them into one select +# was tried first and lost two things -- a configured action became invisible +# while the feature was off, and no option could toggle the feature without +# also overwriting the action. +# +# Note the two 'off's mean opposite things and are not interchangeable. The +# switch's off stops the unit sampling at all; the select's off is the +# advertised autoExeState "Off", where the unit keeps sampling and simply +# doesn't act on what it measures -- the app calls that choice "sensing only". +# +# The select reads its options straight off supportedAutoExeState rather than +# a typed-in tuple, the same shape SOUND_MODE below uses for supportedModes: a +# board advertising a fourth action gets it accepted on both the options list +# and the write path. +# # range_hood.AIR_LEVEL_CHECK already models this same href, and its read-only # keys (air_sensing_state / last_air_sensing_time / last_air_sensing_level) are # reused verbatim so both families share one catalog entry. It is deliberately @@ -541,87 +564,49 @@ SOUND_VOLUME = Capability( # read back. The other two families get the writes on field-shape grounds, the # same basis on which they already share MODE, HEPA_FILTER and the air-quality # sensors. +# +# Deferred: startSensingOnce (On/Off on all three dumps) looks like a one-shot +# "sense now" trigger and would be a ButtonDesc, but nothing here writes it yet +# and this board is known to acknowledge writes it discards -- so it stays +# unbound until someone can confirm the side effect rather than the echo. # --------------------------------------------------------------------------- def _interval_minutes(seconds): - """Device stores the interval in seconds; the entity is in minutes.""" + """Device stores the interval in seconds; the entity is in minutes. + + `is None` rather than a falsy check so a reported 0 stays 0 instead of + reading as unknown. Sub-30s values round to 0, which is why native_min is + 0 rather than 1 -- matching oven.cook_time and operational's delay hours, + both of which convert a device time value and floor at zero. + """ secs = int_or_none(seconds) - return round(secs / 60) if secs else None + return round(secs / 60) if secs is not None else None def _interval_write(payload, rep, href=None): - # Minutes in the UI -> seconds on the wire (scalar string). Arbitrary - # values are honoured: confirmed on hardware, where writing 60 s drove an - # observed sensing cycle every ~60 s. The 1-60 min range on the entity is a - # UI guard, not a device-advertised limit -- the resource carries no - # supported-range field. + # Minutes in the UI -> seconds on the wire (scalar string). Modelled as a + # free Number rather than the app's three fixed choices (10 min / 30 min / + # 1 hour): this resource advertises no supported-values or range field for + # the interval -- supportedAutoExeState sits right beside it, so the board + # does advertise constraints where it has them -- and it accepts values the + # app never offers. Writing 60 s, six times finer than the app's smallest + # choice, drove an observed ~60 s sensing cycle on hardware. return ["airlevelcheck", "vs", "0"], { "x.com.samsung.da.periodicSensingInterval": str(round(float(payload) * 60)) } -def _sensing_mode(rep): - """Fold the periodic-sensing toggle and the auto-action into one status. - - The three on-states are the SmartThings app's own options for this - feature, not an invented grouping -- the app offers exactly: - - sensing_only "Sensing only" -- sample the air, take no action - auto_purify "Auto clean" -- run purification while the air reads - bad and stop again once it improves - st_alarm "Get notified" -- send a SmartThings notification when - the air reads bad - - (Labels transcribed from the Korean app -- 감지만 하기 / 자동 청정 / - 알림받기 -- and rendered here in English; the parenthetical behaviour is - the app's own description of each. The auto-stop half of 'Auto clean' is - not visible in the dump, which reports only the selected autoExeState.) - """ - on = str(rep.get("x.com.samsung.da.periodicSensingActivationState", "")).lower() == "on" - if not on: - return "off" - return {"Airpurify": "auto_purify", "Alarm": "st_alarm"}.get( - str(rep.get("x.com.samsung.da.autoExeState", "")), "sensing_only" - ) - - def _periodic_sensing_write(payload, rep, href=None): - # The master on/off for periodic sensing. Off holds any auto-action - # pending; On arms the selected one. + # The master on/off for AI Purify. Leaves autoExeState alone, so the + # configured action survives the feature being switched off and comes back + # with it -- the thing the select cannot do, since every option it writes + # sets an action. return ["airlevelcheck", "vs", "0"], { "x.com.samsung.da.periodicSensingActivationState": ("On" if payload == "On" else "Off") } -# One-control version of the same two fields: a single PUT sets the sensing -# toggle and the auto-action together, so 'sensing_only' arms sensing with no -# action in one step instead of two writes that briefly pass through a state -# the user didn't ask for. Confirmed to land both fields in both directions -- -# sensing_only -> auto_purify raises autoExeState while activation stays On, -# and back again lowers it. -_SENSING_MODE_BODIES = { - "off": {"x.com.samsung.da.periodicSensingActivationState": "Off"}, - "sensing_only": { - "x.com.samsung.da.periodicSensingActivationState": "On", - "x.com.samsung.da.autoExeState": "Off", - }, - "auto_purify": { - "x.com.samsung.da.periodicSensingActivationState": "On", - "x.com.samsung.da.autoExeState": "Airpurify", - }, - "st_alarm": { - "x.com.samsung.da.periodicSensingActivationState": "On", - "x.com.samsung.da.autoExeState": "Alarm", - }, -} - - -def _sensing_mode_write(payload, rep, href=None): - body = _SENSING_MODE_BODIES.get(payload) - return (["airlevelcheck", "vs", "0"], dict(body)) if body else None - - def _skip_status_write(payload, rep, href=None): return ["airlevelcheck", "vs", "0"], { "x.com.samsung.da.periodicSensingSkipStatus": ("On" if payload == "On" else "Off") @@ -650,11 +635,23 @@ def _skip_time_read(part): return _read +def _skip_half(raw, part): + """The half this write isn't setting, normalized. Padding alone would carry + a malformed value straight back to the device -- writing start over a junk + skip time would send '0730' + junk. The read side already refuses a half it + can't parse, so an unparseable one becomes '0000' here and the pair + round-trips honestly in the same cases.""" + chunk = (str(raw or "") + "00000000")[:8] + other = chunk[4:8] if part == "start" else chunk[0:4] + return other if _skip_time_read("end" if part == "start" else "start")(chunk) else "0000" + + def _skip_time_write(part): def _write(value, rep, href=None): - cur = (str(rep.get("x.com.samsung.da.periodicSensingSkipTime", "") or "") + "00000000")[:8] + raw = rep.get("x.com.samsung.da.periodicSensingSkipTime", "") hhmm = f"{value.hour:02d}{value.minute:02d}" - new = hhmm + cur[4:8] if part == "start" else cur[0:4] + hhmm + other = _skip_half(raw, part) + new = hhmm + other if part == "start" else other + hhmm return ["airlevelcheck", "vs", "0"], {"x.com.samsung.da.periodicSensingSkipTime": new} return _write @@ -664,15 +661,6 @@ AIR_LEVEL_CHECK = Capability( href="/airlevelcheck/vs/0", poll_tier="warm", entities=( - SelectDesc( - key="sensing_mode", - rep_fn=_sensing_mode, - options=("off", "sensing_only", "auto_purify", "st_alarm"), - translation_key="sensing_mode", - icon="mdi:radar", - entity_category="config", - write_fn=_sensing_mode_write, - ), SwitchDesc( key="periodic_air_sensing", field="x.com.samsung.da.periodicSensingActivationState", @@ -681,6 +669,22 @@ AIR_LEVEL_CHECK = Capability( value_fn=lambda v: str(v).lower() == "on", write_fn=_periodic_sensing_write, ), + # Options come off supportedAutoExeState, not a table here -- the + # catalog carries the labels for the three values seen so far, and an + # unrecognized fourth still reaches the user (select.py falls back to + # the device's own token when the catalog doesn't know it). + SelectDesc( + key="sensing_mode", + field="x.com.samsung.da.autoExeState", + options_field="x.com.samsung.da.supportedAutoExeState", + translation_key="sensing_mode", + icon="mdi:radar", + entity_category="config", + write_fn=lambda p, rep, href=None: ( + ["airlevelcheck", "vs", "0"], + {"x.com.samsung.da.autoExeState": p}, + ), + ), # The one field that varies across the three families reporting this # resource: the TP1X_DA-AC-AIR dump (#130) omits it while both # AVT-WW-TP1 dumps and the A-VTWW-TP2 dump carry it, so that board runs @@ -690,7 +694,7 @@ AIR_LEVEL_CHECK = Capability( field="x.com.samsung.da.periodicSensingInterval", icon="mdi:timer-cog", entity_category="config", - native_min=1, + native_min=0, native_max=60, step=1, unit="min", @@ -728,7 +732,6 @@ AIR_LEVEL_CHECK = Capability( field="x.com.samsung.da.sensingState", icon="mdi:radar", entity_category="diagnostic", - enabled_default=False, ), SensorDesc( key="last_air_sensing_time", @@ -745,7 +748,6 @@ AIR_LEVEL_CHECK = Capability( field="x.com.samsung.da.lastSensingLevel", icon="mdi:air-filter", entity_category="diagnostic", - enabled_default=False, ), ), ) diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index 7072da0..dc3fcbd 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -498,12 +498,11 @@ "name": "Počet máchání" }, "sensing_mode": { - "name": "AI Purify", + "name": "Akce AI Purify", "state": { - "off": "Vypnuto", - "sensing_only": "Pouze měření", - "auto_purify": "Automatické čištění", - "st_alarm": "Dostat upozornění" + "off": "Pouze měření", + "airpurify": "Automatické čištění", + "alarm": "Dostat upozornění" } }, "softener_concentration": { @@ -1043,7 +1042,7 @@ "name": "Displej" }, "periodic_air_sensing": { - "name": "Měření AI Purify" + "name": "AI Purify" }, "periodic_sensing_skip_status": { "name": "Přeskočení měření AI Purify" diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 4d633b1..591019d 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -498,12 +498,11 @@ "name": "Rinse cycles" }, "sensing_mode": { - "name": "AI Purify", + "name": "AI Purify action", "state": { - "off": "Off", - "sensing_only": "Sensing only", - "auto_purify": "Auto clean", - "st_alarm": "Get notified" + "off": "Sensing only", + "airpurify": "Auto clean", + "alarm": "Get notified" } }, "softener_concentration": { @@ -1043,7 +1042,7 @@ "name": "Display" }, "periodic_air_sensing": { - "name": "AI Purify sensing" + "name": "AI Purify" }, "periodic_sensing_skip_status": { "name": "AI Purify sensing skip" diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index 35c1712..b7728e3 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -620,12 +620,11 @@ "name": "Aclarados" }, "sensing_mode": { - "name": "AI Purify", + "name": "Acción de AI Purify", "state": { - "off": "Apagado", - "sensing_only": "Solo medición", - "auto_purify": "Limpieza automática", - "st_alarm": "Recibir notificación" + "off": "Solo medición", + "airpurify": "Limpieza automática", + "alarm": "Recibir notificación" } }, "softener_concentration": { @@ -1165,7 +1164,7 @@ "name": "Pantalla" }, "periodic_air_sensing": { - "name": "Medición de AI Purify" + "name": "AI Purify" }, "periodic_sensing_skip_status": { "name": "Omisión de medición de AI Purify" diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index 1c66f83..e904333 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -498,12 +498,11 @@ "name": "Risciacquo" }, "sensing_mode": { - "name": "AI Purify", + "name": "Azione AI Purify", "state": { - "off": "Spento", - "sensing_only": "Solo rilevamento", - "auto_purify": "Pulizia automatica", - "st_alarm": "Ricevi notifica" + "off": "Solo rilevamento", + "airpurify": "Pulizia automatica", + "alarm": "Ricevi notifica" } }, "softener_concentration": { @@ -1043,7 +1042,7 @@ "name": "Display" }, "periodic_air_sensing": { - "name": "Rilevamento AI Purify" + "name": "AI Purify" }, "periodic_sensing_skip_status": { "name": "Esclusione rilevamento AI Purify" diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 65e3db6..45e1a68 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -498,12 +498,11 @@ "name": "Spoelcycli" }, "sensing_mode": { - "name": "AI Purify", + "name": "AI Purify-actie", "state": { - "off": "Uit", - "sensing_only": "Alleen meten", - "auto_purify": "Automatisch reinigen", - "st_alarm": "Melding ontvangen" + "off": "Alleen meten", + "airpurify": "Automatisch reinigen", + "alarm": "Melding ontvangen" } }, "softener_concentration": { @@ -1043,7 +1042,7 @@ "name": "Display" }, "periodic_air_sensing": { - "name": "AI Purify-meting" + "name": "AI Purify" }, "periodic_sensing_skip_status": { "name": "AI Purify meting overslaan" diff --git a/tests/test_air_purifier_airlevelcheck.py b/tests/test_air_purifier_airlevelcheck.py index 858569b..72b50f4 100644 --- a/tests/test_air_purifier_airlevelcheck.py +++ b/tests/test_air_purifier_airlevelcheck.py @@ -3,8 +3,8 @@ sensing engine (issues #84 and #190). The resource is reported by three of this registry's four board families, so the read assertions run against each family's own fixture; the write contracts -were exercised on AVT-WW-TP1-23-AXX500 hardware and are asserted here at the -body level. +were exercised on AVT-WW-TP1-23-AXX500 hardware and are asserted here through +the descriptors that carry them. """ import datetime @@ -19,6 +19,12 @@ from tests.conftest import _load_device # ARTIK051_TVTL family, issue #56) has no such href and is deliberately absent. FAMILIES = ("air_purifier_avt_ww", "air_purifier_vtww", "air_purifier_tp1x_da_ac_air") +HREF = ["airlevelcheck", "vs", "0"] + + +def _rep(fixture): + return _load_device(fixture)["/airlevelcheck/vs/0"] + def _state(fixture): resources = _load_device(fixture) @@ -63,14 +69,6 @@ def test_tvtl_family_is_untouched(): assert key not in state, key -def test_sensing_interval_only_where_the_field_exists(): - """TP1X_DA-AC-AIR (issue #130) omits periodicSensingInterval; the other two - report it. The entity must follow the field, not the href.""" - assert "sensing_interval" in _state("air_purifier_avt_ww") - assert "sensing_interval" in _state("air_purifier_vtww") - assert "sensing_interval" not in _state("air_purifier_tp1x_da_ac_air") - - def test_no_unbound_hrefs_on_any_reporting_family(): for fixture in FAMILIES: resources = _load_device(fixture) @@ -81,78 +79,87 @@ def test_no_unbound_hrefs_on_any_reporting_family(): assert unbound == [], f"{fixture}: {unbound}" -def test_sensing_mode_folds_toggle_and_action(): - """Off beats any pending auto-action; On with no action is 'sensing only'.""" - assert ( - air_purifier._sensing_mode( - { - "x.com.samsung.da.periodicSensingActivationState": "Off", - "x.com.samsung.da.autoExeState": "Airpurify", - } - ) - == "off" - ) - assert ( - air_purifier._sensing_mode( - { - "x.com.samsung.da.periodicSensingActivationState": "On", - "x.com.samsung.da.autoExeState": "Off", - } - ) - == "sensing_only" - ) - assert ( - air_purifier._sensing_mode( - { - "x.com.samsung.da.periodicSensingActivationState": "On", - "x.com.samsung.da.autoExeState": "Airpurify", - } - ) - == "auto_purify" - ) - assert ( - air_purifier._sensing_mode( - { - "x.com.samsung.da.periodicSensingActivationState": "On", - "x.com.samsung.da.autoExeState": "Alarm", - } - ) - == "st_alarm" - ) +# --- the two knobs are separate entities, not one folded control ------------- -def test_sensing_mode_write_sets_both_fields_in_one_body(): - href, body = air_purifier._sensing_mode_write("auto_purify", {}) - assert href == ["airlevelcheck", "vs", "0"] - assert body == { - "x.com.samsung.da.periodicSensingActivationState": "On", - "x.com.samsung.da.autoExeState": "Airpurify", - } - # 'off' only needs the toggle -- the pending action is preserved. - assert air_purifier._sensing_mode_write("off", {})[1] == { - "x.com.samsung.da.periodicSensingActivationState": "Off" - } - assert air_purifier._sensing_mode_write("nonsense", {}) is None +def test_activation_and_action_are_separate_entities(): + """The resource carries an on/off and an action as independent fields, and + the appliance's own UI presents them that way. Folding them into one + control would make a configured action invisible while the feature is off, + and would leave no way to toggle the feature without overwriting it.""" + assert _desc("periodic_air_sensing").field == ( + "x.com.samsung.da.periodicSensingActivationState" + ) + assert _desc("sensing_mode").field == "x.com.samsung.da.autoExeState" -def test_sensing_mode_write_does_not_mutate_the_shared_body_table(): - before = dict(air_purifier._SENSING_MODE_BODIES["auto_purify"]) - _, body = air_purifier._sensing_mode_write("auto_purify", {}) - body["x.com.samsung.da.autoExeState"] = "clobbered" - assert air_purifier._SENSING_MODE_BODIES["auto_purify"] == before +def test_action_options_come_from_the_device_not_a_table(): + """supportedAutoExeState is advertised on every reporting fixture, so the + select reads it rather than carrying a typed-in tuple -- a board adding a + fourth action is then accepted rather than rejected.""" + desc = _desc("sensing_mode") + assert desc.options_field == "x.com.samsung.da.supportedAutoExeState" + assert not desc.options, "options must come from the device, not a static tuple" + for fixture in FAMILIES: + assert _rep(fixture)["x.com.samsung.da.supportedAutoExeState"] == [ + "Off", + "Airpurify", + "Alarm", + ], fixture + + +def test_action_write_sends_the_raw_advertised_value(): + """select.py maps the chosen option back to the device's own casing before + calling write_fn, so the body is the advertised token verbatim.""" + for raw in ("Off", "Airpurify", "Alarm"): + href, body = _desc("sensing_mode").write_fn(raw, {}) + assert href == HREF + assert body == {"x.com.samsung.da.autoExeState": raw} + + +def test_activation_write_leaves_the_action_alone(): + """Toggling the feature must not disturb autoExeState -- that's what makes + the switch able to do something the action select can't.""" + for payload, expected in (("On", "On"), ("Off", "Off")): + href, body = _desc("periodic_air_sensing").write_fn(payload, {}) + assert href == HREF + assert body == {"x.com.samsung.da.periodicSensingActivationState": expected} + assert "x.com.samsung.da.autoExeState" not in body + + +# --- interval ---------------------------------------------------------------- + + +def test_sensing_interval_only_where_the_field_exists(): + """TP1X_DA-AC-AIR (issue #130) omits periodicSensingInterval; the other two + report it. The entity must follow the field, not the href.""" + assert "sensing_interval" in _state("air_purifier_avt_ww") + assert "sensing_interval" in _state("air_purifier_vtww") + assert "sensing_interval" not in _state("air_purifier_tp1x_da_ac_air") def test_interval_is_minutes_in_the_ui_and_seconds_on_the_wire(): - assert air_purifier._interval_minutes("600") == 10 - assert air_purifier._interval_minutes(None) is None - assert air_purifier._interval_write(10, {})[1] == { - "x.com.samsung.da.periodicSensingInterval": "600" - } + desc = _desc("sensing_interval") + assert desc.value_fn("600") == 10 + assert desc.write_fn(10, {})[1] == {"x.com.samsung.da.periodicSensingInterval": "600"} + + +def test_interval_keeps_zero_distinct_from_unknown(): + """`if secs` would fold a reported 0 into None; sub-30s values round to 0, + which is why native_min is 0 rather than 1.""" + desc = _desc("sensing_interval") + assert desc.value_fn("0") == 0 + assert desc.value_fn("20") == 0 + assert desc.value_fn(None) is None + assert desc.native_min == 0 + + +# --- skip window ------------------------------------------------------------- def test_skip_time_splits_the_hhmmhhmm_window(): - read_start = air_purifier._skip_time_read("start") - read_end = air_purifier._skip_time_read("end") + read_start = _desc("sensing_skip_start").value_fn + read_end = _desc("sensing_skip_end").value_fn # Issue #190's unit ships a real window: 03:00-23:00. assert read_start("03002300") == datetime.time(3, 0) assert read_end("03002300") == datetime.time(23, 0) @@ -166,27 +173,36 @@ def test_skip_time_splits_the_hhmmhhmm_window(): def test_skip_time_write_preserves_the_other_half(): rep = {"x.com.samsung.da.periodicSensingSkipTime": "03002300"} - _, body = air_purifier._skip_time_write("start")(datetime.time(7, 30), rep) + _, body = _desc("sensing_skip_start").write_fn(datetime.time(7, 30), rep) assert body == {"x.com.samsung.da.periodicSensingSkipTime": "07302300"} - _, body = air_purifier._skip_time_write("end")(datetime.time(22, 5), rep) + _, body = _desc("sensing_skip_end").write_fn(datetime.time(22, 5), rep) assert body == {"x.com.samsung.da.periodicSensingSkipTime": "03002205"} # A board that has never had a window set still round-trips. - _, body = air_purifier._skip_time_write("end")(datetime.time(1, 2), {}) + _, body = _desc("sensing_skip_end").write_fn(datetime.time(1, 2), {}) assert body == {"x.com.samsung.da.periodicSensingSkipTime": "00000102"} -def test_periodic_sensing_and_skip_switch_bodies(): - assert air_purifier._periodic_sensing_write("On", {})[1] == { - "x.com.samsung.da.periodicSensingActivationState": "On" - } - assert air_purifier._periodic_sensing_write("Off", {})[1] == { - "x.com.samsung.da.periodicSensingActivationState": "Off" - } - assert air_purifier._skip_status_write("On", {})[1] == { +def test_skip_time_write_normalizes_a_half_it_cannot_parse(): + """Padding alone would splice a malformed half straight back onto the wire. + The read side already refuses one, so the write side zeroes it instead of + echoing junk to the device.""" + junk = {"x.com.samsung.da.periodicSensingSkipTime": "0730zzzz"} + _, body = _desc("sensing_skip_start").write_fn(datetime.time(8, 0), junk) + assert body == {"x.com.samsung.da.periodicSensingSkipTime": "08000000"} + junk = {"x.com.samsung.da.periodicSensingSkipTime": "zzzz2200"} + _, body = _desc("sensing_skip_end").write_fn(datetime.time(23, 0), junk) + assert body == {"x.com.samsung.da.periodicSensingSkipTime": "00002300"} + + +def test_skip_status_switch_body(): + assert _desc("periodic_sensing_skip_status").write_fn("On", {})[1] == { "x.com.samsung.da.periodicSensingSkipStatus": "On" } +# --- read-only diagnostics --------------------------------------------------- + + def test_last_sensing_time_reads_as_utc(): state = _state("air_purifier_avt_ww") assert state["last_air_sensing_time"].tzinfo is not None @@ -194,9 +210,14 @@ def test_last_sensing_time_reads_as_utc(): def test_read_only_keys_match_the_range_hood_capability(): - """Same href, same fields -- the keys are shared deliberately so both - families read from one translation catalog entry. If either side renames - one, this catches the drift.""" + """The three read-only sensor keys are shared with the hood deliberately, + so both families read from one translation catalog entry each. If either + side renames one, this catches the drift. + + periodic_air_sensing is excluded: it's a SwitchDesc here and a + BinarySensorDesc on the hood, so the two sit in different platform + catalogs and are worded differently -- see the next test. + """ from custom_components.localthings.registry.capabilities import range_hood hood = {d.key for d in range_hood.AIR_LEVEL_CHECK.entities} @@ -205,7 +226,6 @@ def test_read_only_keys_match_the_range_hood_capability(): "air_sensing_state", "last_air_sensing_time", "last_air_sensing_level", - "periodic_air_sensing", } <= hood & ours @@ -222,3 +242,10 @@ def test_periodic_air_sensing_is_writable_here_and_read_only_on_hoods(): ours = _desc("periodic_air_sensing") assert isinstance(ours, SwitchDesc) assert ours.write_fn is not None + + +def test_read_only_diagnostics_match_the_hood_on_visibility(): + """The hood leaves all three enabled; asserting key parity with it while + hiding two of them would be a quiet divergence.""" + for key in ("air_sensing_state", "last_air_sensing_time", "last_air_sensing_level"): + assert _desc(key).enabled_default is True, key