Scope learning to the device, and simplify the store

The href alone was not a sufficient key. /mode/convenient/vs/0 is
declared by three family registries with three meanings: a real preset
resource on the AC, explicitly unmodeled on the dehumidifier (no live
current-value field), empty on the air purifier. Matching on the href
globally meant a dehumidifier reporting a mode there would learn it,
persist it, and show it in diagnostics for a resource nothing offers.
The coordinator now narrows LEARNABLE to the hrefs a climate entity is
actually bound to, at discovery -- which also retires the per-rep
subdevice walk, since those hrefs are already actual.

With that, LEARNABLE is a plain frozenset of hrefs and the per-href
LearnRule goes away: its two fields were the same module constants for
its only entry. observe() now returns the codes it learned rather than a
bool the caller re-reads the store to interpret, so the log names what
was new instead of everything ever learned.

learned.py also takes ownership of the entry key and persisted shape --
the options flow was the second module that knew both, and the shape has
already changed once.

Comment trims throughout, per CONTRIBUTING: the LEARNABLE entry no
longer recounts how many reporters there were, and three copies of the
same test-stub comment are gone.
This commit is contained in:
Marc Billow
2026-08-08 19:49:25 +00:00
parent 4f3bdde6e5
commit 3675d8087b
8 changed files with 113 additions and 105 deletions
+8 -15
View File
@@ -46,7 +46,6 @@ from .const import (
CONF_LEAF_CERT_PEM, CONF_LEAF_CERT_PEM,
CONF_LEAF_KEY_PEM, CONF_LEAF_KEY_PEM,
CONF_LEARN_MODES, CONF_LEARN_MODES,
CONF_LEARNED_MODES,
CONF_MANUFACTURER, CONF_MANUFACTURER,
CONF_MODEL, CONF_MODEL,
CONF_PORT, CONF_PORT,
@@ -61,7 +60,8 @@ from .const import (
PROBE_PORT_RANGE, PROBE_PORT_RANGE,
SERVICE_WRITE_RESOURCE, SERVICE_WRITE_RESOURCE,
) )
from .learned import LearnedModes from .learned import persist as learned_persist
from .learned import stored as learned_stored
_TEXT = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)) _TEXT = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
_MULTILINE = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT, multiline=True)) _MULTILINE = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT, multiline=True))
@@ -879,13 +879,12 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
form; the description lists what's about to be forgotten. form; the description lists what's about to be forgotten.
""" """
coord = self._coordinator() coord = self._coordinator()
# learned.py owns the entry key and the persisted shape, so this
# step never parses or writes it itself -- including on an unloaded
# entry, where a malformed record would otherwise abort the one
# screen that can clear it.
learned = ( learned = (
coord.learned_snapshot() coord.learned_snapshot() if coord is not None else learned_stored(self.config_entry)
if coord is not None
# Through LearnedModes, not the raw entry value: this step is
# the one screen that can clear a malformed persisted record,
# so it must not be the one screen that trips over it.
else LearnedModes(self.config_entry.data.get(CONF_LEARNED_MODES)).snapshot()
) )
codes = sorted({code for codes in learned.values() for code in codes}) codes = sorted({code for codes in learned.values() for code in codes})
@@ -893,13 +892,7 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
if coord is not None: if coord is not None:
coord.forget_learned_modes() coord.forget_learned_modes()
else: else:
# Not loaded, so there's no store to clear -- drop the learned_persist(self.hass, self.config_entry, {})
# persisted copy directly, which is all a reload would
# restore from anyway.
self.hass.config_entries.async_update_entry(
self.config_entry,
data={**self.config_entry.data, CONF_LEARNED_MODES: {}},
)
return self.async_create_entry(data=dict(self.config_entry.options)) return self.async_create_entry(data=dict(self.config_entry.options))
return self.async_show_form( return self.async_show_form(
+29 -15
View File
@@ -40,7 +40,7 @@ from .const import (
DTLS_LOCAL_PORT_BASE, DTLS_LOCAL_PORT_BASE,
SUMMARY_INTERVAL_S, SUMMARY_INTERVAL_S,
) )
from .learned import LearnedModes from .learned import LEARNABLE, LearnedModes, persist
from .observe import GRACE_PERIOD_S, MODE_OBSERVE, MODE_POLL, ObserveManager from .observe import GRACE_PERIOD_S, MODE_OBSERVE, MODE_POLL, ObserveManager
from .registry import CAPABILITIES from .registry import CAPABILITIES
from .registry.adapter import flatten from .registry.adapter import flatten
@@ -53,6 +53,7 @@ from .registry.capabilities.common import (
remote_control_required_for_write, remote_control_required_for_write,
) )
from .registry.discovery import BoundEntity from .registry.discovery import BoundEntity
from .registry.entities import ClimateDesc
from .registry.identity import ( from .registry.identity import (
DeviceIdentity, DeviceIdentity,
device_display_name, device_display_name,
@@ -247,6 +248,9 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# supported (issue #327), restored from the entry so one learned # supported (issue #327), restored from the entry so one learned
# last week is still offered today. See learned.py. # last week is still offered today. See learned.py.
self._learned = LearnedModes(entry.data.get(CONF_LEARNED_MODES)) self._learned = LearnedModes(entry.data.get(CONF_LEARNED_MODES))
# Narrowed to this device's own climate hrefs once discovery has
# run -- see _refresh_learnable_hrefs.
self._learnable_hrefs: set[str] = set()
self._observe.set_on_applied(self._on_rep_applied) self._observe.set_on_applied(self._on_rep_applied)
self._push_pending = False self._push_pending = False
self._push_pending_lock = threading.Lock() self._push_pending_lock = threading.Lock()
@@ -349,13 +353,23 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if self._entry.data.get(CONF_LEARNED_MODES): if self._entry.data.get(CONF_LEARNED_MODES):
self._persist_learned() self._persist_learned()
def _canonical_href(self, actual: str) -> str: def _refresh_learnable_hrefs(self) -> None:
"""`actual` in the namespace the registry -- and so learned.LEARNABLE """The actual hrefs learning applies to on this device: LEARNABLE's
-- is written against; identity for MAIN's own hrefs (issue #177).""" canonical set, narrowed to the ones a climate entity is bound to
for subdevice in self.subdevices: read back (climate._supported is the only consumer) and translated
if subdevice.owns(actual): through that entity's own subdevice (issue #177).
return subdevice.to_canonical(actual) or actual
return actual The href alone isn't a sufficient key here. `/mode/convenient/vs/0`
is also declared by the dehumidifier registry (explicitly
unmodeled) and the air purifier's (empty), so a global match would
persist a code for a resource that family will never offer.
"""
self._learnable_hrefs = {
bound.subdevice.to_actual(href)
for bound in self.bound
if isinstance(bound.desc, ClimateDesc)
for href in LEARNABLE
}
def _on_rep_applied(self, href: str, rep: dict, source: str) -> None: def _on_rep_applied(self, href: str, rep: dict, source: str) -> None:
"""ObserveManager.set_on_applied hook. Runs on whichever thread """ObserveManager.set_on_applied hook. Runs on whichever thread
@@ -364,23 +378,22 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
An 'optimistic' rep is the value this integration just wrote, not An 'optimistic' rep is the value this integration just wrote, not
one the device reported, so there is nothing to learn from it.""" one the device reported, so there is nothing to learn from it."""
if source == "optimistic" or not self.learning_enabled: if source == "optimistic" or href not in self._learnable_hrefs:
return return
if self._learned.observe(self._canonical_href(href), href, rep): if not self.learning_enabled:
return
if new := self._learned.observe(href, rep):
self._log.info( self._log.info(
"%s reported mode(s) it does not advertise as supported; " "%s reported mode(s) it does not advertise as supported; "
"remembering %s so they stay selectable (issue #327)", "remembering %s so they stay selectable (issue #327)",
href, href,
self._learned.codes(href), new,
) )
self.hass.add_job(self._persist_learned) self.hass.add_job(self._persist_learned)
@callback @callback
def _persist_learned(self) -> None: def _persist_learned(self) -> None:
self.hass.config_entries.async_update_entry( persist(self.hass, self._entry, self._learned.snapshot())
self._entry,
data={**self._entry.data, CONF_LEARNED_MODES: self._learned.snapshot()},
)
def device_info_for(self, subdevice: Subdevice) -> DeviceInfo: def device_info_for(self, subdevice: Subdevice) -> DeviceInfo:
"""DeviceInfo for one logical subdevice on this connection (issue """DeviceInfo for one logical subdevice on this connection (issue
@@ -802,6 +815,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.device_type_name = device_type_name self.device_type_name = device_type_name
self.bound = bound self.bound = bound
self._unbound_hrefs = unbound self._unbound_hrefs = unbound
self._refresh_learnable_hrefs()
# The entry's stored identity wins; this poll's answer is only # The entry's stored identity wins; this poll's answer is only
# adopted when nothing is stored (a legacy migration couldn't # adopted when nothing is stored (a legacy migration couldn't
+38 -42
View File
@@ -11,47 +11,37 @@ Learning is deliberately not global. A current value that isn't a
selectable option is common across this corpus -- an oven idling in selectable option is common across this corpus -- an oven idling in
'NoOperation', a fridge's /mode/vs/0 carrying capability tokens like 'NoOperation', a fridge's /mode/vs/0 carrying capability tokens like
'WATERFILTER_DISABLE' -- and remembering one of those permanently would 'WATERFILTER_DISABLE' -- and remembering one of those permanently would
put an option in the UI that the device can only reject. LEARNABLE is the put an option in the UI that the device can only reject. LEARNABLE names
allowlist of canonical hrefs where a device-reported current mode is known the canonical hrefs where a reported mode is known to be genuinely
to be a genuine selectable option; every consumer of it (climate's selectable, and the coordinator narrows it further to the hrefs this
_supported today) must read its supported list through the coordinator. device actually binds a climate entity to (see _refresh_learnable_hrefs):
the same href is declared explicitly unmodeled on a dehumidifier and
empty on an air purifier, and learning for those would persist a code
nothing ever offers.
This module also owns the entry key the store persists under, so the
shape lives in exactly one place.
""" """
from __future__ import annotations from __future__ import annotations
import threading import threading
from dataclasses import dataclass
from .const import CONF_LEARNED_MODES
from .registry.capabilities.airconditioner import HREF_CONVENIENT from .registry.capabilities.airconditioner import HREF_CONVENIENT
MODES_FIELD = "x.com.samsung.da.modes" MODES_FIELD = "x.com.samsung.da.modes"
SUPPORTED_FIELD = "x.com.samsung.da.supportedModes" SUPPORTED_FIELD = "x.com.samsung.da.supportedModes"
# Convenient (preset) mode: firmware omits an active preset (e.g. Quiet)
@dataclass(frozen=True) # from its own supportedModes -- a reporting gap, not a capability
class LearnRule: # difference (issue #327).
"""Which field of a rep names the current mode, and which lists the LEARNABLE: frozenset[str] = frozenset({HREF_CONVENIENT})
supported ones. Both are per-href because Samsung spells them
differently across resources (`supportedModes` on the vendor `/vs/`
ones, bare `modes`/`supportedModes` on a few OCF-shaped ones)."""
current_field: str
supported_field: str
LEARNABLE: dict[str, LearnRule] = {
# Convenient (preset) mode. Reported by two independent reporters on
# ARTIK051_PRAC_20K, one of whom has three identical units where only
# the two sharing an outdoor unit hide Quiet -- so this is a firmware
# reporting gap, not a real capability difference.
HREF_CONVENIENT: LearnRule(MODES_FIELD, SUPPORTED_FIELD),
}
def _codes(value) -> list[str]: def _codes(value) -> list[str]:
"""The mode codes in a `modes`-style field, which is an array on every """Mode codes from a `modes`-style field, which some firmwares send as
board seen but a bare string on none -- tolerated anyway, since this a bare string rather than an array."""
runs against whatever the device sends."""
if isinstance(value, str): if isinstance(value, str):
return [value] return [value]
if isinstance(value, (list, tuple)): if isinstance(value, (list, tuple)):
@@ -73,11 +63,21 @@ def _coerce(stored) -> dict[str, list[str]]:
return restored return restored
def stored(entry) -> dict[str, list[str]]:
"""What `entry` has persisted, coerced -- for a reader that can't go
through a coordinator (the options flow, on an unloaded entry)."""
return _coerce(entry.data.get(CONF_LEARNED_MODES))
def persist(hass, entry, codes: dict[str, list[str]]) -> None:
"""Write `codes` onto the entry. Runs on the event loop, which
async_update_entry requires."""
hass.config_entries.async_update_entry(entry, data={**entry.data, CONF_LEARNED_MODES: codes})
class LearnedModes: class LearnedModes:
"""Per-device store of learned codes, keyed by actual (on-the-wire) """Per-device store of learned codes, keyed by actual (on-the-wire)
href so two subdevices of one composite appliance learn separately. href so two subdevices of one composite appliance learn separately.
One href carries one LEARNABLE rule, so the rule's fields are how a rep
is read, never part of the key.
Mutated from whichever thread applied the update (the DTLS reader for Mutated from whichever thread applied the update (the DTLS reader for
an OBSERVE notify, an executor thread for a poll -- see an OBSERVE notify, an executor thread for a poll -- see
@@ -89,9 +89,9 @@ class LearnedModes:
self._lock = threading.Lock() self._lock = threading.Lock()
self._learned = _coerce(stored) self._learned = _coerce(stored)
def observe(self, canonical_href: str, actual_href: str, rep: dict) -> bool: def observe(self, actual_href: str, rep: dict) -> list[str]:
"""Learn from one applied rep; True when something new was learned """Learn from one applied rep; returns the codes newly learned, so
(i.e. the caller should persist). an empty list means there is nothing to persist.
A rep that carries no supported list teaches nothing: "missing from A rep that carries no supported list teaches nothing: "missing from
the list" is only meaningful against a list that exists, and the list" is only meaningful against a list that exists, and
@@ -101,23 +101,19 @@ class LearnedModes:
alone (issue #27) still sees the supported list from the last full alone (issue #27) still sees the supported list from the last full
poll. poll.
""" """
rule = LEARNABLE.get(canonical_href) supported = _codes(rep.get(SUPPORTED_FIELD))
if rule is None:
return False
supported = _codes(rep.get(rule.supported_field))
if not supported: if not supported:
return False return []
with self._lock: with self._lock:
known = self._learned.get(actual_href, []) known = self._learned.get(actual_href, [])
new = [ new = [
code code
for code in _codes(rep.get(rule.current_field)) for code in _codes(rep.get(MODES_FIELD))
if code and code not in supported and code not in known if code and code not in supported and code not in known
] ]
if not new: if new:
return False self._learned[actual_href] = [*known, *new]
self._learned[actual_href] = [*known, *new] return new
return True
def codes(self, actual_href: str) -> list[str]: def codes(self, actual_href: str) -> list[str]:
with self._lock: with self._lock:
+5 -9
View File
@@ -78,22 +78,18 @@ class ObserveManager:
# have notified. Guards only `_notified` mutations + the `wait_for`. # have notified. Guards only `_notified` mutations + the `wait_for`.
self._notify_cond = threading.Condition() self._notify_cond = threading.Condition()
self.fallback_hrefs: set[str] = set() self.fallback_hrefs: set[str] = set()
# Called with (href, merged_rep, source) after every accepted
# device update, on the applying thread -- see set_on_applied.
self._on_applied: Callable[[str, dict, str], None] | None = None self._on_applied: Callable[[str, dict, str], None] | None = None
self._refresh_task: ObserveRefreshTask | None = None self._refresh_task: ObserveRefreshTask | None = None
self._refresh_stop: threading.Event | None = None self._refresh_stop: threading.Event | None = None
self._refresh_thread: threading.Thread | None = None self._refresh_thread: threading.Thread | None = None
def set_on_applied(self, callback: Callable[[str, dict, str], None]) -> None: def set_on_applied(self, callback: Callable[[str, dict, str], None]) -> None:
"""Register a hook run after every rep this manager accepts, with """Hook run after every accepted rep, on the applying thread.
the merged rep that reached the cache.
Unlike StateCache.set_on_change, which reports only that Unlike StateCache.set_on_change it carries the href and rep, and
*something* changed, this hands over the href and rep -- and fires fires even when the rep is unchanged -- which learned.py needs, a
even when the rep matched what was already cached, which the device sitting in an unadvertised mode re-sending the same rep
learned-modes store (learned.py) depends on: a device sitting in every poll.
an unadvertised mode sends an unchanged rep every poll.
""" """
self._on_applied = callback self._on_applied = callback
@@ -65,8 +65,6 @@ class _FakeCoordinator:
self.commands.append((bound, payload)) self.commands.append((bound, payload))
def learned_modes(self, href): def learned_modes(self, href):
# Nothing learned in this stub -- issue #327's store lives on the
# real coordinator; climate._supported unions it in.
return [] return []
@@ -49,8 +49,6 @@ class _FakeCoordinator:
self.commands.append((bound, payload)) self.commands.append((bound, payload))
def learned_modes(self, href): def learned_modes(self, href):
# Nothing learned in this stub -- issue #327's store lives on the
# real coordinator; climate._supported unions it in.
return [] return []
-2
View File
@@ -120,8 +120,6 @@ def test_fac_bora_wind_strength_codes_fit_the_standard_scale():
return self.last_resources return self.last_resources
def learned_modes(self, href): def learned_modes(self, href):
# Nothing learned in this stub -- issue #327's store lives on
# the real coordinator; climate._supported unions it in.
return [] return []
resources = _load_device("airconditioner_fac_bora") resources = _load_device("airconditioner_fac_bora")
+33 -18
View File
@@ -31,7 +31,7 @@ from custom_components.localthings.learned import (
LearnedModes, LearnedModes,
) )
from custom_components.localthings.registry.entities import ClimateDesc from custom_components.localthings.registry.entities import ClimateDesc
from tests.test_subdevice_discovery import ENTRY_DATA, _discover from tests.test_subdevice_discovery import ENTRY_DATA, _climate_bound, _discover
FIXTURE = "airconditioner_tp1x_fac_time_23k" FIXTURE = "airconditioner_tp1x_fac_time_23k"
CONVENIENT = "/mode/convenient/vs/0" CONVENIENT = "/mode/convenient/vs/0"
@@ -50,7 +50,7 @@ QUIET_REP = {MODES_FIELD: "Quiet", SUPPORTED_FIELD: ADVERTISED}
def test_learns_a_current_mode_missing_from_the_supported_list(): def test_learns_a_current_mode_missing_from_the_supported_list():
learned = LearnedModes() learned = LearnedModes()
assert learned.observe(CONVENIENT, CONVENIENT, QUIET_REP) is True assert learned.observe(CONVENIENT, QUIET_REP) == ["Quiet"]
assert learned.codes(CONVENIENT) == ["Quiet"] assert learned.codes(CONVENIENT) == ["Quiet"]
@@ -58,15 +58,15 @@ def test_relearning_the_same_mode_is_not_a_change():
"""The device reports the same rep on every poll while it sits in the """The device reports the same rep on every poll while it sits in the
mode, so only the first one may report back as something to persist.""" mode, so only the first one may report back as something to persist."""
learned = LearnedModes() learned = LearnedModes()
assert learned.observe(CONVENIENT, CONVENIENT, QUIET_REP) is True assert learned.observe(CONVENIENT, QUIET_REP) == ["Quiet"]
assert learned.observe(CONVENIENT, CONVENIENT, QUIET_REP) is False assert learned.observe(CONVENIENT, QUIET_REP) == []
assert learned.codes(CONVENIENT) == ["Quiet"] assert learned.codes(CONVENIENT) == ["Quiet"]
def test_an_advertised_mode_is_never_learned(): def test_an_advertised_mode_is_never_learned():
learned = LearnedModes() learned = LearnedModes()
rep = {MODES_FIELD: "Sleep", SUPPORTED_FIELD: ADVERTISED} rep = {MODES_FIELD: "Sleep", SUPPORTED_FIELD: ADVERTISED}
assert learned.observe(CONVENIENT, CONVENIENT, rep) is False assert learned.observe(CONVENIENT, rep) == []
assert learned.codes(CONVENIENT) == [] assert learned.codes(CONVENIENT) == []
@@ -75,26 +75,23 @@ def test_a_rep_with_no_supported_list_teaches_nothing():
exists -- a board publishing none would otherwise get an option list exists -- a board publishing none would otherwise get an option list
invented out of whatever it happened to be doing.""" invented out of whatever it happened to be doing."""
learned = LearnedModes() learned = LearnedModes()
assert learned.observe(CONVENIENT, CONVENIENT, {MODES_FIELD: "Quiet"}) is False assert learned.observe(CONVENIENT, {MODES_FIELD: "Quiet"}) == []
assert learned.codes(CONVENIENT) == [] assert learned.codes(CONVENIENT) == []
def test_an_href_outside_the_allowlist_learns_nothing(): def test_the_allowlist_is_only_the_convenient_href():
"""The guard that keeps this feature off resources whose current value """The guard that keeps this feature off resources whose current value
isn't a selectable option: an oven idling in 'NoOperation' reports isn't a selectable option -- an oven idling in 'NoOperation' on
exactly this shape on /mode/vs/0, and remembering it would put a /mode/vs/0, whose codes would become permanent junk options in that
permanent junk option in that unit's cook-mode select.""" unit's cook-mode select."""
learned = LearnedModes() assert set(LEARNABLE) == {CONVENIENT}
rep = {MODES_FIELD: "NoOperation", SUPPORTED_FIELD: ["Bake", "Broil"]}
assert learned.observe("/mode/vs/0", "/mode/vs/0", rep) is False
assert learned.snapshot() == {}
def test_two_subdevices_learn_separately(): def test_two_subdevices_learn_separately():
"""Keyed by the actual on-the-wire href (issue #177), so a composite """Keyed by the actual on-the-wire href (issue #177), so a composite
appliance's second indoor unit doesn't inherit the first's gap.""" appliance's second indoor unit doesn't inherit the first's gap."""
learned = LearnedModes() learned = LearnedModes()
learned.observe(CONVENIENT, "/mode/convenient/vs/1", QUIET_REP) learned.observe("/mode/convenient/vs/1", QUIET_REP)
assert learned.codes("/mode/convenient/vs/1") == ["Quiet"] assert learned.codes("/mode/convenient/vs/1") == ["Quiet"]
assert learned.codes(CONVENIENT) == [] assert learned.codes(CONVENIENT) == []
@@ -130,7 +127,9 @@ def test_clear_forgets_everything():
def test_every_learnable_href_is_one_climate_resolves(): def test_every_learnable_href_is_one_climate_resolves():
"""climate._supported is the only consumer that unions learned codes """climate._supported is the only consumer that unions learned codes
in today, so an href added to LEARNABLE that climate never reads would in today, so an href added to LEARNABLE that climate never reads would
be learned, persisted, and never offered anywhere.""" be learned, persisted, and never offered anywhere. The coordinator
enforces the device half of this (only hrefs a climate entity binds
are learnable); this is the static half."""
from custom_components.localthings.climate import ( from custom_components.localthings.climate import (
CONVENIENT_HREF, CONVENIENT_HREF,
MODE_HREF, MODE_HREF,
@@ -171,8 +170,7 @@ async def _flush(hass: HomeAssistant) -> None:
async def _climate(hass: HomeAssistant, entry) -> tuple[LocalThingsCoordinator, Any]: async def _climate(hass: HomeAssistant, entry) -> tuple[LocalThingsCoordinator, Any]:
coordinator = LocalThingsCoordinator(hass, entry) coordinator = LocalThingsCoordinator(hass, entry)
await _discover(coordinator, FIXTURE) await _discover(coordinator, FIXTURE)
bound = next(b for b in coordinator.bound if isinstance(b.desc, ClimateDesc)) return coordinator, LocalThingsClimate(coordinator, _climate_bound(coordinator, None))
return coordinator, LocalThingsClimate(coordinator, bound)
async def test_the_fixture_really_does_not_advertise_quiet(hass: HomeAssistant): async def test_the_fixture_really_does_not_advertise_quiet(hass: HomeAssistant):
@@ -231,6 +229,23 @@ async def test_a_learned_preset_survives_a_restart(hass: HomeAssistant):
assert "quiet" in entity.preset_modes assert "quiet" in entity.preset_modes
async def test_a_device_with_no_climate_entity_learns_nothing(hass: HomeAssistant):
"""The href alone isn't a sufficient key: the dehumidifier registry
declares this same /mode/convenient/vs/0 explicitly unmodeled (no live
current-value field), so a code learned there would be persisted for a
resource nothing will ever offer."""
entry = _entry(hass)
coordinator = LocalThingsCoordinator(hass, entry)
await _discover(coordinator, "dehumidifier")
assert not any(isinstance(b.desc, ClimateDesc) for b in coordinator.bound)
coordinator._observe.apply(
CONVENIENT, {MODES_FIELD: "Quiet", SUPPORTED_FIELD: ADVERTISED}, source="poll"
)
assert coordinator.learned_snapshot() == {}
async def test_an_optimistic_write_teaches_nothing(hass: HomeAssistant): async def test_an_optimistic_write_teaches_nothing(hass: HomeAssistant):
"""An optimistic cache entry is the value this integration just wrote, """An optimistic cache entry is the value this integration just wrote,
not something the device reported.""" not something the device reported."""