Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba089dbb5c | ||
|
|
0d0ffb57dd | ||
|
|
fe0db8586f | ||
|
|
8814fffa4f | ||
|
|
12922909c0 | ||
|
|
9d3782a29a | ||
|
|
edff7385c6 | ||
|
|
e684146f61 | ||
|
|
bc03ada208 | ||
|
|
67012c57d7 | ||
|
|
dbc5c55a97 | ||
|
|
19a2c03609 | ||
|
|
76f2c3c0cd | ||
|
|
a59ef6cca9 |
+1
-1
@@ -6,4 +6,4 @@ FROM ghcr.io/home-assistant/home-assistant:stable
|
||||
# repeats the install attempt on every container recreate. Baking
|
||||
# smartthings-local into the image keeps the dev container usable
|
||||
# offline and avoids relying on that runtime install path.
|
||||
RUN pip3 install --no-cache-dir "smartthings-local>=0.1.6"
|
||||
RUN pip3 install --no-cache-dir "smartthings-local>=0.1.8"
|
||||
|
||||
@@ -270,6 +270,12 @@ If reconnects become persistent (more than a handful per minute), something's ac
|
||||
|
||||
Deregistering a device in SmartThings causes a reset of its network settings as soon as it accesses Samsung's servers, dropping it off Wi-Fi until it's re-onboarded through the SmartThings app. As such, consider keeping devices registered even if egress-blocked, to avoid them resetting upon brief internet access.
|
||||
|
||||
### Restarting while an appliance is powered off
|
||||
|
||||
If Home Assistant restarts while an appliance is unplugged or switched off at the wall, its device and entities still load — restored from the last successful discovery, showing `unavailable` until the appliance answers again. Automations and dashboards keep referring to entities that exist, and the integration retries in the background, so the device comes back on its own within a poll cycle of being powered on. Entities read `unavailable` rather than their last known values on purpose: the integration can't verify what a disconnected appliance is doing, and recorded history is kept by the recorder either way.
|
||||
|
||||
This only applies to an appliance the integration has reached at least once. A brand-new device that has never answered has nothing to restore from, so setting it up still requires it to be reachable.
|
||||
|
||||
### Multi-subdevice ("2-in-1") air conditioner systems
|
||||
|
||||
Some Samsung installs run more than one indoor subdevice off a single outdoor unit, all reachable over the *one* IP/DTLS session your config entry connects to (a floor-standing + wall-mounted 2-in-1 is a common shape). The integration discovers any sibling subdevices automatically, once, right after the first successful poll — there's nothing to configure. Each discovered subdevice gets its own HA device (linked to the main one via "via device") and its own `climate` card, so it lands in its own room in the dashboard instead of being invisible or mixed into the master's state.
|
||||
|
||||
@@ -19,7 +19,7 @@ from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_DEVICE_TYPE, CONF_HOST, CONF_PORT, CONF_SERIAL, DOMAIN, PLATFORMS
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .coordinator import LocalThingsCoordinator, snapshot_store
|
||||
from .registry.identity import resolve_serial
|
||||
from .services import async_setup_services
|
||||
|
||||
@@ -260,17 +260,30 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
coordinator = LocalThingsCoordinator(hass, entry)
|
||||
|
||||
# Before the first refresh, so the coordinator keeps rescheduling even
|
||||
# when that refresh fails and leaves nothing subscribed: the base class
|
||||
# only re-arms its timer while it has listeners, and an offline load can
|
||||
# legitimately have zero live entities (every one of them disabled, say).
|
||||
# Without this the entry loads once and never polls again (issue #295).
|
||||
entry.async_on_unload(coordinator.async_add_listener(lambda: None))
|
||||
|
||||
try:
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
except Exception as err:
|
||||
# `_poll_once` deliberately leaves the session up on a TimeoutError
|
||||
# (see its docstring), so a refresh failing that way leaves a live,
|
||||
# bound UDP socket nothing would ever close. HA retries setup with a
|
||||
# new coordinator, and the source port is fixed by design
|
||||
# (`_local_source_port`), so an abandoned socket would squat the
|
||||
# exact port the next attempt binds.
|
||||
await coordinator.async_close()
|
||||
raise ConfigEntryNotReady(f"Cannot connect to device: {err}") from err
|
||||
except ConfigEntryNotReady:
|
||||
# An entry that has polled successfully before comes up on its last
|
||||
# known entity set and keeps retrying on the normal poll interval,
|
||||
# rather than sitting in setup-retry with a device that reads as
|
||||
# broken and entities that exist only as registry rows (issue #295).
|
||||
#
|
||||
# An entry that has never reached the device has no snapshot, so
|
||||
# there is nothing to show and no device metadata to name it with --
|
||||
# that case still fails, which is also what keeps the door open for
|
||||
# setup flows that need to interact with the device (issue #168).
|
||||
if not await coordinator.async_rehydrate():
|
||||
await coordinator.async_close()
|
||||
raise
|
||||
|
||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||
|
||||
# Send the DTLS close_notify on Core shutdown, not just on unload (issue
|
||||
@@ -327,6 +340,16 @@ async def async_remove_config_entry_device(
|
||||
return not (device.identifiers & live)
|
||||
|
||||
|
||||
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Delete the discovery snapshot this entry accumulated (issue #295).
|
||||
|
||||
Nothing else would: the store is keyed on entry_id, so re-adding the same
|
||||
appliance mints a new one and the old file would linger in .storage
|
||||
forever.
|
||||
"""
|
||||
await snapshot_store(hass, entry).async_remove()
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unloaded:
|
||||
|
||||
@@ -9,6 +9,7 @@ import logging
|
||||
import threading
|
||||
import time
|
||||
import zlib
|
||||
from dataclasses import asdict
|
||||
from datetime import timedelta
|
||||
from typing import Any, cast
|
||||
|
||||
@@ -18,6 +19,7 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from smartthings_local.ocf.state_cache import StateCache
|
||||
from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
||||
@@ -47,7 +49,7 @@ from .const import (
|
||||
from .learned import LEARNABLE, LearnedModes, persist
|
||||
from .observe import GRACE_PERIOD_S, MODE_OBSERVE, MODE_POLL, ObserveManager
|
||||
from .registry import CAPABILITIES
|
||||
from .registry.adapter import flatten
|
||||
from .registry.adapter import _key, flatten
|
||||
from .registry.batch import parse_device0_batch
|
||||
from .registry.by_type import resolve as resolve_registry
|
||||
from .registry.capabilities.common import (
|
||||
@@ -83,6 +85,20 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_SEED_PATH = ["device", "0"]
|
||||
|
||||
# Discovery snapshot (issue #295): exactly what the last successful first
|
||||
# cycle fed _run_discovery, so a restart can register the same entities
|
||||
# while the appliance is unreachable. Kept in .storage rather than on the
|
||||
# config entry -- it's device state, not configuration, and runs to tens of
|
||||
# kilobytes.
|
||||
_SNAPSHOT_VERSION = 1
|
||||
|
||||
|
||||
def snapshot_store(hass: HomeAssistant, entry: ConfigEntry) -> Store[dict[str, Any]]:
|
||||
"""This entry's discovery-snapshot store. A free function so
|
||||
`async_remove_entry` can delete the file without standing up a whole
|
||||
coordinator to reach it."""
|
||||
return Store(hass, _SNAPSHOT_VERSION, f"{DOMAIN}.{entry.entry_id}.discovery")
|
||||
|
||||
|
||||
class _NoOpDescriptor:
|
||||
"""No-op: StateCache requires an on_observation hook; this integration
|
||||
@@ -248,6 +264,11 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._identity: DeviceIdentity | None = None
|
||||
self._discovered = False
|
||||
self.bound = []
|
||||
self._snapshot_store = snapshot_store(hass, entry)
|
||||
# Both set only when this entry loaded from a snapshot instead of a
|
||||
# live poll -- see async_rehydrate.
|
||||
self._rehydrate_resources: dict[str, dict] | None = None
|
||||
self._rehydrated_keys: frozenset[tuple[str, str]] | None = None
|
||||
# Sibling indoor subdevices on this connection (issue #177); set
|
||||
# once at first discovery, narrowed to the ones with live state (see
|
||||
# subdevices.discover_partitioned). Never includes MAIN itself.
|
||||
@@ -405,6 +426,35 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._canonical_cache[view_key] = view
|
||||
return view
|
||||
|
||||
@property
|
||||
def rehydrated(self) -> bool:
|
||||
"""True while this entry's entities came from a snapshot rather than
|
||||
a live poll (issue #295)."""
|
||||
return self._rehydrate_resources is not None
|
||||
|
||||
@property
|
||||
def discovery_resources(self) -> dict[str, dict]:
|
||||
"""What entity._is_included should judge an entity's existence
|
||||
against: the rehydration snapshot on an offline load, the live cache
|
||||
otherwise.
|
||||
|
||||
Deliberately separate from `last_resources`, which stays empty until
|
||||
the device answers -- that emptiness is what keeps a rehydrated
|
||||
entity `unavailable` instead of rendering a snapshot's stale value.
|
||||
Only read while platforms are being forwarded; nothing consults it
|
||||
once the entities exist.
|
||||
"""
|
||||
if self._rehydrate_resources is None:
|
||||
return self.last_resources
|
||||
return self._rehydrate_resources
|
||||
|
||||
def discovery_canonical(self, subdevice: Subdevice) -> dict[str, dict]:
|
||||
"""`discovery_resources` in `subdevice`'s canonical view -- the
|
||||
exists_fn counterpart to canonical_resources."""
|
||||
if self._rehydrate_resources is None:
|
||||
return self.canonical_resources(subdevice)
|
||||
return canonical_view(subdevice, self._rehydrate_resources, self.subdevices)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Learned modes (issue #327)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -999,7 +1049,148 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._entry, data={**self._entry.data, **identity}
|
||||
)
|
||||
|
||||
def _run_discovery(self, resources: dict[str, dict]) -> None:
|
||||
# ------------------------------------------------------------------
|
||||
# Discovery snapshot (issue #295)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _bound_keys(self) -> frozenset[tuple[str, str]]:
|
||||
"""This entity set's identity: one (subdevice, state key) pair per
|
||||
bound entity. `_key` is the unique_id suffix, so two discoveries that
|
||||
agree here would register byte-identical entities."""
|
||||
return frozenset((b.subdevice.key, _key(b)) for b in self.bound)
|
||||
|
||||
async def _async_save_snapshot(
|
||||
self, resources: dict[str, dict], candidates: list[Subdevice]
|
||||
) -> None:
|
||||
"""Record what this first cycle handed `_run_discovery`, so the next
|
||||
restart can replay it while the appliance is unreachable.
|
||||
|
||||
`candidates` is the pre-narrowing subdevice list (issue #177):
|
||||
`discover_partitioned` takes candidates and returns the live ones, so
|
||||
replaying against the narrowed list would rediscover nothing for a
|
||||
composite appliance's siblings.
|
||||
|
||||
Written now rather than through `async_delay_save`, because a pending
|
||||
delayed write outlives whatever queued it: it lands after
|
||||
`async_remove_entry` has deleted the file and recreates it orphaned,
|
||||
and a reload scheduled by `_reconcile_rehydrated` would read the
|
||||
pre-reload snapshot back off disk. This runs once per entry load, so
|
||||
the immediate write costs nothing worth deferring.
|
||||
"""
|
||||
ident = self._identity
|
||||
try:
|
||||
await self._snapshot_store.async_save(
|
||||
{
|
||||
"resources": dict(resources),
|
||||
"subdevice_candidates": [asdict(su) for su in candidates],
|
||||
"identity": asdict(ident) if ident is not None else None,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
# Never fail a poll over the snapshot -- a board reporting
|
||||
# something the JSON encoder rejects would otherwise break
|
||||
# polling outright. Worst case this entry can't load offline,
|
||||
# which is where it was before any of this existed.
|
||||
self._log.warning("could not write discovery snapshot: %s", e)
|
||||
|
||||
async def async_rehydrate(self) -> bool:
|
||||
"""Register the last known entity set without reaching the device.
|
||||
|
||||
Replays the stored snapshot through `_run_discovery`, which is what
|
||||
makes this faithful: same code path, same registry resolution, so an
|
||||
offline load produces the entity set the device last actually
|
||||
reported rather than a guess reconstructed from a parallel format.
|
||||
|
||||
Returns False when there's nothing stored (an entry that has never
|
||||
polled successfully) or the replay produced nothing usable -- the
|
||||
caller raises ConfigEntryNotReady in that case, exactly as before.
|
||||
"""
|
||||
try:
|
||||
stored = await self._snapshot_store.async_load()
|
||||
except Exception as e: # corrupt or unreadable store
|
||||
self._log.warning("could not read discovery snapshot: %s", e)
|
||||
return False
|
||||
if not stored or not stored.get("resources"):
|
||||
return False
|
||||
|
||||
resources = stored["resources"]
|
||||
try:
|
||||
ident = stored.get("identity")
|
||||
if ident is not None:
|
||||
self._identity = DeviceIdentity(
|
||||
manufacturer=ident.get("manufacturer") or "",
|
||||
model=ident.get("model") or "",
|
||||
name=ident.get("name") or "",
|
||||
serial=ident.get("serial"),
|
||||
device_types=tuple(ident.get("device_types") or ()),
|
||||
raw=ident.get("raw") or {},
|
||||
)
|
||||
# JSON gives lists back where Subdevice declares tuples, and it's
|
||||
# a frozen (hashable) dataclass used as a dict key in flatten().
|
||||
self.subdevices = [
|
||||
Subdevice(
|
||||
kind=su["kind"],
|
||||
key=su["key"],
|
||||
seed_path=tuple(su.get("seed_path") or ()),
|
||||
flat_hrefs=tuple(su.get("flat_hrefs") or ()),
|
||||
)
|
||||
for su in stored.get("subdevice_candidates") or ()
|
||||
]
|
||||
self._run_discovery(resources, from_snapshot=True)
|
||||
except Exception as e:
|
||||
# A snapshot written by an older release can outlive both the
|
||||
# stored shape and the registry it was discovered against. This
|
||||
# has to catch the rebuild as well as the replay: an exception
|
||||
# escaping here reaches async_setup_entry, which only handles
|
||||
# ConfigEntryNotReady, so the entry would land in SETUP_ERROR --
|
||||
# never retried, and with its session left open.
|
||||
self._log.warning("discovery snapshot could not be replayed: %s", e, exc_info=True)
|
||||
self.bound = []
|
||||
self.subdevices = []
|
||||
return False
|
||||
|
||||
# _run_discovery sets _discovered; put it back. The snapshot only
|
||||
# supplied an entity set to register -- the first live poll must
|
||||
# still enumerate subdevices and rediscover for real.
|
||||
self._discovered = False
|
||||
self._rehydrate_resources = resources
|
||||
self._rehydrated_keys = self._bound_keys()
|
||||
if not self.bound:
|
||||
return False
|
||||
self._log.info(
|
||||
"device unreachable; restored %d entities from the last discovery "
|
||||
"snapshot and will keep retrying every %ds",
|
||||
len(self.bound),
|
||||
SUMMARY_INTERVAL_S,
|
||||
)
|
||||
return True
|
||||
|
||||
@callback
|
||||
def _reconcile_rehydrated(self) -> None:
|
||||
"""Reload the entry when a live discovery disagrees with the snapshot
|
||||
this load registered from.
|
||||
|
||||
Platforms enumerate `bound` exactly once, at forward time, so a
|
||||
firmware update, a newly-answering sibling subdevice or a different
|
||||
appliance at the same IP can't be picked up in place -- the entry has
|
||||
to come back up against the live set.
|
||||
"""
|
||||
if self._rehydrated_keys is None:
|
||||
return
|
||||
stale = self._rehydrated_keys
|
||||
self._rehydrated_keys = None
|
||||
live = self._bound_keys()
|
||||
if live == stale:
|
||||
return
|
||||
self._log.info(
|
||||
"live discovery differs from the snapshot this entry loaded from "
|
||||
"(%d entities gone, %d new); reloading",
|
||||
len(stale - live),
|
||||
len(live - stale),
|
||||
)
|
||||
self.hass.config_entries.async_schedule_reload(self._entry.entry_id)
|
||||
|
||||
def _run_discovery(self, resources: dict[str, dict], from_snapshot: bool = False) -> None:
|
||||
# Diagnostics only -- names the firmware generation (e.g. '7.0 Air
|
||||
# conditioner' is Tizen Lite); doesn't route, since every device
|
||||
# that reports it is already typed by modelNum.
|
||||
@@ -1115,7 +1306,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
model=model,
|
||||
)
|
||||
self._persist_identity(serial, model, mfr, device_type_name)
|
||||
self._update_coverage_gap_issue(device_type_name is None, unbound, name)
|
||||
if not from_snapshot:
|
||||
# A coverage gap is a claim about what the device reports, so only
|
||||
# a live poll gets to make it. Replaying a snapshot would restate
|
||||
# last run's conclusion while pointing the user at a diagnostics
|
||||
# download that is empty until the appliance answers, and any
|
||||
# drift in the device name between the two would churn the issue.
|
||||
self._update_coverage_gap_issue(device_type_name is None, unbound, name)
|
||||
|
||||
self._hot_hrefs = sorted(hot)
|
||||
self._warm_hrefs = sorted(warm)
|
||||
@@ -1422,7 +1619,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# StateCache has no eviction, so the only way to keep them out
|
||||
# is to not put them in. Safe to reorder: _run_discovery reads
|
||||
# the passed dict, never the cache.
|
||||
candidates = list(self.subdevices)
|
||||
self._run_discovery(resources)
|
||||
# Banked before the reconcile below, so a reload it schedules
|
||||
# comes up against this discovery rather than the one that is
|
||||
# being replaced.
|
||||
await self._async_save_snapshot(resources, candidates)
|
||||
self._reconcile_rehydrated()
|
||||
resources = self._live_subdevice_resources(resources)
|
||||
sweep_mismatch = False
|
||||
if self._observe.mode == MODE_OBSERVE:
|
||||
|
||||
@@ -35,12 +35,16 @@ def _is_included(bound: BoundEntity, coordinator: LocalThingsCoordinator) -> boo
|
||||
`bound`'s own subdevice's canonical view instead of the raw snapshot,
|
||||
same rule as everywhere else a whole-resources-dict scan happens --
|
||||
this is a free function, so it can't use self._resources.
|
||||
|
||||
Reads `discovery_resources`, not `last_resources`: on an offline load
|
||||
(issue #295) the live cache is still empty, and judging existence
|
||||
against it would filter every rehydrated entity away.
|
||||
"""
|
||||
rep = coordinator.last_resources.get(bound.href)
|
||||
rep = coordinator.discovery_resources.get(bound.href)
|
||||
if rep is None:
|
||||
return False
|
||||
if bound.desc.exists_fn is not None:
|
||||
return bound.desc.exists_fn(rep, coordinator.canonical_resources(bound.subdevice))
|
||||
return bound.desc.exists_fn(rep, coordinator.discovery_canonical(bound.subdevice))
|
||||
if bound.desc.field:
|
||||
if not rep or is_stub_rep(rep):
|
||||
return True
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"requirements": [
|
||||
"cbor2>=5.4.6",
|
||||
"pyOpenSSL>=23.0",
|
||||
"smartthings-local>=0.1.6"
|
||||
"smartthings-local>=0.1.8"
|
||||
],
|
||||
"version": "0.22.0"
|
||||
}
|
||||
|
||||
@@ -372,6 +372,33 @@ def _has_option_token(prefix):
|
||||
)
|
||||
|
||||
|
||||
def _has_option_token_any_board(prefix):
|
||||
"""Token-presence test with no board-generation gate.
|
||||
|
||||
`_has_option_token` above requires `is_legacy_board`, which was right for
|
||||
the settings it guards but wrong for a token whose presence is itself the
|
||||
only signal that needs checking -- issue #367 found `OutdoorTemp_` live
|
||||
on 14 of 23 recorded fixtures despite none of them being legacy boards.
|
||||
Same shape as `beep`'s Volume_ test and `_has_display_light_option`,
|
||||
which already treat token presence as sufficient across generations."""
|
||||
return lambda rep, resources: _option_token(rep, prefix) is not None
|
||||
|
||||
|
||||
def _reports_celsius(resources):
|
||||
"""Whether this subdevice's own /temperatures/vs/0 declares Celsius (or
|
||||
says nothing at all, `_temps_vs_unit`'s default).
|
||||
|
||||
Guards `OutdoorTemp_`'s -55 offset below: issue #367's field validation
|
||||
(48h against weather.forecast_home, r=0.92) ran on Celsius-locale boards
|
||||
only -- 13 of the 14 non-legacy fixtures that carry the token declare
|
||||
Celsius, and the offset's own calibration comment is a Celsius reading
|
||||
too. The one Fahrenheit-locale exception on record
|
||||
(`airconditioner_lnx_rac_heatpump`) has nothing to confirm the same
|
||||
additive constant, or degrees C rather than F, still hold -- so this
|
||||
stays off boards that declare anything else, rather than guess."""
|
||||
return _temps_vs_unit(resources.get(HREF_TEMPS_VS) or {}) == "°C"
|
||||
|
||||
|
||||
def _option_token_on(prefix):
|
||||
return lambda rep: _option_token(rep, prefix) == "On"
|
||||
|
||||
@@ -782,13 +809,34 @@ CLIMATE = Capability(
|
||||
),
|
||||
# Outdoor temperature, offset by 55 -- calibrated against an
|
||||
# independent thermometer (token 75 while it read 20.3°C).
|
||||
#
|
||||
# exists_fn is token-presence-only (issue #367), not is_legacy_board:
|
||||
# gating it there dropped the sensor on every non-legacy board that
|
||||
# reports it -- 14 of 17 fixtures carrying the token in the issue's
|
||||
# own survey. A 48h/289-sample field capture correlated it against
|
||||
# weather.forecast_home at r=0.92, ruling out a firmware constant.
|
||||
# `_reports_celsius` keeps that presence check from also claiming
|
||||
# the -55 Celsius offset for board generations it was never
|
||||
# validated on -- see its own docstring.
|
||||
#
|
||||
# enabled_default=False: multi-split installs (multiple indoor heads
|
||||
# on one outdoor condenser) report the same token on every head, so
|
||||
# a fix here creates one identical sensor per head rather than one
|
||||
# per physical unit (same duplication as the shared energy/power
|
||||
# counters in issue #329). Left disabled so a user with several
|
||||
# heads can enable just one instead of getting N duplicates active
|
||||
# by default.
|
||||
SensorDesc(
|
||||
key="outdoor_temperature",
|
||||
rep_fn=_option_token_num("OutdoorTemp", offset=55),
|
||||
exists_fn=_has_option_token("OutdoorTemp"),
|
||||
exists_fn=lambda rep, resources: (
|
||||
_has_option_token_any_board("OutdoorTemp")(rep, resources)
|
||||
and _reports_celsius(resources)
|
||||
),
|
||||
device_class="temperature",
|
||||
state_class="measurement",
|
||||
unit="°C",
|
||||
enabled_default=False,
|
||||
icon="mdi:home-thermometer-outline",
|
||||
),
|
||||
# Filter time in tenths of an hour, counting UP since last filter
|
||||
|
||||
@@ -27,7 +27,17 @@ DRYER_SETTINGS = Capability(
|
||||
entities=(
|
||||
SensorDesc(key="dry_level", field="x.com.samsung.da.dryLevel", icon="mdi:water-percent"),
|
||||
SensorDesc(key="dry_time", field="x.com.samsung.da.dryTime", icon="mdi:timer"),
|
||||
SensorDesc(key="dryer_type", field="x.com.samsung.da.dryerType", icon="mdi:tumble-dryer"),
|
||||
SensorDesc(
|
||||
key="dryer_type",
|
||||
field="x.com.samsung.da.dryerType",
|
||||
icon="mdi:tumble-dryer",
|
||||
device_class="enum",
|
||||
# Only 'Electricity' confirmed across shipped fixtures (#366); an
|
||||
# unrecognized value still passes through raw via sensor.py's
|
||||
# options property rather than breaking the entity.
|
||||
options=("electricity",),
|
||||
value_fn=lambda v: v.lower() if isinstance(v, str) else v,
|
||||
),
|
||||
SwitchDesc(
|
||||
key="wrinkle_prevent",
|
||||
field="x.com.samsung.da.wrinklePrevent",
|
||||
|
||||
@@ -958,7 +958,10 @@
|
||||
"name": "Doba sušení"
|
||||
},
|
||||
"dryer_type": {
|
||||
"name": "Typ sušičky"
|
||||
"name": "Typ sušičky",
|
||||
"state": {
|
||||
"electricity": "Elektřina"
|
||||
}
|
||||
},
|
||||
"dust": {
|
||||
"name": "Prach"
|
||||
@@ -1098,6 +1101,7 @@
|
||||
"steaming": "Napařování",
|
||||
"airwashing": "Osvěžení vzduchem",
|
||||
"drying": "Sušení",
|
||||
"dryingwithdooropen": "Větrání",
|
||||
"cooling": "Chlazení",
|
||||
"predrain": "Vypouštění",
|
||||
"prewash": "Předpírka"
|
||||
|
||||
@@ -952,7 +952,10 @@
|
||||
"name": "Trockenzeit"
|
||||
},
|
||||
"dryer_type": {
|
||||
"name": "Trocknertyp"
|
||||
"name": "Trocknertyp",
|
||||
"state": {
|
||||
"electricity": "Strom"
|
||||
}
|
||||
},
|
||||
"dust": {
|
||||
"name": "Staub"
|
||||
@@ -1092,6 +1095,7 @@
|
||||
"steaming": "Dämpfen",
|
||||
"airwashing": "Luftreinigung",
|
||||
"drying": "Trocknen",
|
||||
"dryingwithdooropen": "Lüften",
|
||||
"cooling": "Abkühlen",
|
||||
"predrain": "Abpumpen",
|
||||
"prewash": "Vorwäsche"
|
||||
|
||||
@@ -958,7 +958,10 @@
|
||||
"name": "Dry time"
|
||||
},
|
||||
"dryer_type": {
|
||||
"name": "Dryer type"
|
||||
"name": "Dryer type",
|
||||
"state": {
|
||||
"electricity": "Electricity"
|
||||
}
|
||||
},
|
||||
"dust": {
|
||||
"name": "Dust"
|
||||
@@ -1098,6 +1101,7 @@
|
||||
"steaming": "Steaming",
|
||||
"airwashing": "Air washing",
|
||||
"drying": "Drying",
|
||||
"dryingwithdooropen": "Venting",
|
||||
"cooling": "Cooling",
|
||||
"predrain": "Pre-drain",
|
||||
"prewash": "Pre-wash"
|
||||
|
||||
@@ -1151,7 +1151,10 @@
|
||||
"name": "Tiempo de secado"
|
||||
},
|
||||
"dryer_type": {
|
||||
"name": "Tipo de secadora"
|
||||
"name": "Tipo de secadora",
|
||||
"state": {
|
||||
"electricity": "Electricidad"
|
||||
}
|
||||
},
|
||||
"dust": {
|
||||
"name": "Polvo"
|
||||
@@ -1291,6 +1294,7 @@
|
||||
"steaming": "Vaporización",
|
||||
"airwashing": "Lavado con aire",
|
||||
"drying": "Secado",
|
||||
"dryingwithdooropen": "Ventilación",
|
||||
"cooling": "Enfriamiento",
|
||||
"predrain": "Drenaje previo",
|
||||
"prewash": "Prelavado"
|
||||
|
||||
@@ -958,7 +958,10 @@
|
||||
"name": "Tempo di asciugatura"
|
||||
},
|
||||
"dryer_type": {
|
||||
"name": "Tipo di asciugatrice"
|
||||
"name": "Tipo di asciugatrice",
|
||||
"state": {
|
||||
"electricity": "Elettricità"
|
||||
}
|
||||
},
|
||||
"dust": {
|
||||
"name": "Polvere"
|
||||
@@ -1098,6 +1101,7 @@
|
||||
"steaming": "Vapore",
|
||||
"airwashing": "Lavaggio ad aria",
|
||||
"drying": "Asciugatura",
|
||||
"dryingwithdooropen": "Ventilazione",
|
||||
"cooling": "Raffreddamento",
|
||||
"predrain": "Scarico preliminare",
|
||||
"prewash": "Prelavaggio"
|
||||
|
||||
@@ -958,7 +958,10 @@
|
||||
"name": "건조 시간"
|
||||
},
|
||||
"dryer_type": {
|
||||
"name": "건조기 유형"
|
||||
"name": "건조기 유형",
|
||||
"state": {
|
||||
"electricity": "전기"
|
||||
}
|
||||
},
|
||||
"dust": {
|
||||
"name": "미세먼지"
|
||||
@@ -1098,6 +1101,7 @@
|
||||
"steaming": "스팀",
|
||||
"airwashing": "에어워시",
|
||||
"drying": "건조",
|
||||
"dryingwithdooropen": "환기",
|
||||
"cooling": "냉각",
|
||||
"predrain": "사전 배수",
|
||||
"prewash": "애벌빨래"
|
||||
|
||||
@@ -958,7 +958,10 @@
|
||||
"name": "Droogtijd"
|
||||
},
|
||||
"dryer_type": {
|
||||
"name": "Type droger"
|
||||
"name": "Type droger",
|
||||
"state": {
|
||||
"electricity": "Elektriciteit"
|
||||
}
|
||||
},
|
||||
"dust": {
|
||||
"name": "Stof"
|
||||
@@ -1098,6 +1101,7 @@
|
||||
"steaming": "Stomen",
|
||||
"airwashing": "Luchtreiniging",
|
||||
"drying": "Drogen",
|
||||
"dryingwithdooropen": "Ventileren",
|
||||
"cooling": "Koelen",
|
||||
"predrain": "Vooraf afpompen",
|
||||
"prewash": "Voorwas"
|
||||
|
||||
@@ -15,6 +15,12 @@ that follows them explains why the reset looked cloud-only for as long as it
|
||||
did — a genuine trap worth knowing about before the next reset-adjacent
|
||||
mystery on this board family.
|
||||
|
||||
Scope: all of the above applies to boards that carry the counter as a
|
||||
`FilterTime_<N>` option token on `/mode/vs/0`. Not every AC does. See
|
||||
"Boards with no `FilterTime` token" at the end for an `ARTIK051_PRAC_20K`
|
||||
that keeps the counter in its own resource, rejects both the token and a
|
||||
direct write, and has no local reset at all.
|
||||
|
||||
## What the reset actually is
|
||||
|
||||
A **command**, not a value write. Samsung's cloud models it as capability
|
||||
@@ -92,3 +98,72 @@ on every unit on record).
|
||||
The entity key stays `filter_time` rather than `filter_time_elapsed`:
|
||||
renaming it would change every existing unit's `entity_id`/`unique_id` for a
|
||||
wording improvement only.
|
||||
|
||||
## Boards with no `FilterTime` token (`ARTIK051_PRAC_20K`)
|
||||
|
||||
Negative result, measured 2026-08-11 on integration v0.21.0 / HA 2026.8.1,
|
||||
against one head of a three-head multi-split (`OptionCode_35880`,
|
||||
`ExtendOptionCode_199181`). **There is no local reset on this board**, by
|
||||
either route. Reset appears to be panel-only.
|
||||
|
||||
This generation does not put the counter in `/mode/vs/0` at all. Its
|
||||
options blob carries no `FilterTime`, no `FilterAlarmTime` and no
|
||||
`FilterCleanAlarm`:
|
||||
|
||||
```json
|
||||
["Sleep_0", "ArtificialWorking_Off", "ComfortAICooling_Off",
|
||||
"AiTempChanged_Off", "AiTemp_240", "OutdoorTemp_77", "CoolCapa_25",
|
||||
"WarmCapa_32", "Light_Off", "Volume_100", "OptionCode_35880",
|
||||
"ExtendOptionCode_199181", "RacInfo_None", "UpdateAllow_NotAllowed",
|
||||
"DurationOn_0", "WelcomeCoolingState_Off"]
|
||||
```
|
||||
|
||||
The counter lives in its own resource instead, as a **percentage** of a
|
||||
500-hour interval rather than tenths of an hour —
|
||||
`/filter/airdustfilter/vs/0`:
|
||||
|
||||
```json
|
||||
{
|
||||
"x.com.samsung.da.filterUsage": "96",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterDesiredUsage": "500",
|
||||
"x.com.samsung.da.filterStatus": "normal",
|
||||
"x.com.samsung.da.filterCapacity": "500",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": ["replaceable", "washable"]
|
||||
}
|
||||
```
|
||||
|
||||
Three attempts, all against a live unit deliberately put in `fan_only`
|
||||
first — writes to a powered-off head on this board are dropped silently
|
||||
with no error, which would otherwise be indistinguishable from a rejected
|
||||
write:
|
||||
|
||||
| Target | Payload | Result |
|
||||
|---|---|---|
|
||||
| `/mode/vs/0` | `{"x.com.samsung.da.options": ["FilterCleanAlarm_Clear"]}` | 4.00, options blob byte-identical |
|
||||
| `/filter/airdustfilter/vs/0` | `{"x.com.samsung.da.filterUsage": "0"}` | 4.00 |
|
||||
| `/filter/airdustfilter/vs/0` | `{"x.com.samsung.da.filterUsage": 0}` (integer) | 5.00 |
|
||||
|
||||
`filterUsage` stayed at `96` throughout, verified by a live re-read after
|
||||
each write rather than by the integration's optimistic state.
|
||||
|
||||
The last two rows are the informative pair. They differ only in JSON type
|
||||
and return *different* codes, which rules out both boring explanations: an
|
||||
unresolved href or an unrecognised field name would fail identically. The
|
||||
board parses the field, faults on the wrong type, and still refuses the
|
||||
value when typed as the string its own rep uses. The resource is
|
||||
**read-only**, not mis-addressed.
|
||||
|
||||
One trap worth stating plainly: `filterResetType:
|
||||
["replaceable","washable"]` describes what the filter *is*, not a reset
|
||||
command that exists. It reads like a hint that a reset write is available
|
||||
somewhere. It is not.
|
||||
|
||||
Since the integration cannot perform the reset here, it can still observe
|
||||
it. The counter only climbs in normal use, so a downward crossing is
|
||||
unambiguous: a `numeric_state` trigger with `below: 10` on
|
||||
`sensor.<name>_filter_usage`, stamping an `input_datetime`, keeps an
|
||||
honest "last cleaned" date without pretending a reset entity exists. The
|
||||
blind spot is a reset performed while HA is down or the entry is
|
||||
unloaded — no state transition, so that stamp has to be set by hand.
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
# Loading a config entry while the appliance is offline
|
||||
|
||||
Issue #295 asks for faster recovery when a powered-off appliance comes back,
|
||||
instead of waiting out HA's `ConfigEntryNotReady` backoff. PR #303 tried to
|
||||
get there by catching the first-refresh failure in `async_setup_entry` and
|
||||
loading the entry anyway.
|
||||
|
||||
That doesn't work here, and the reason is worth writing down: this
|
||||
integration has no static entity list. Every entity comes from discovery,
|
||||
and discovery only happens inside a successful poll.
|
||||
|
||||
(The issue's "up to 15 minutes" is out of date, incidentally. Current HA
|
||||
retries on `2 ** min(tries, 4) * 5` seconds — capped at 80s, not 900. The
|
||||
backoff was never the worst part; a device card reading "Retrying setup" with
|
||||
no entities behind it is.)
|
||||
|
||||
## What PR #303 produces today
|
||||
|
||||
Measured on the PR's branch — set up with `_poll_once` raising, then advance
|
||||
the clock four summary intervals:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| entry state | `LOADED` |
|
||||
| `coordinator.bound` | 0 |
|
||||
| entities in the state machine | 0 |
|
||||
| registry entries | 1 (the disabled connection-mode sensor) |
|
||||
| coordinator listeners | 0 |
|
||||
| `_unsub_refresh` | `None` |
|
||||
| poll attempts over the next 4 intervals | **0** |
|
||||
|
||||
The entry loads and then never polls again. `DataUpdateCoordinator._async_refresh`
|
||||
reschedules only `if not auth_failed and self._listeners and not
|
||||
self.hass.is_stopping`; with no bound entities the only unconditional entity
|
||||
is `LocalThingsConnectionModeSensor`, which is
|
||||
`entity_registry_enabled_default = False` and so never added and never
|
||||
subscribes. Nothing reloads the entry either. The device comes back online to
|
||||
an entry that is permanently empty until a manual reload — strictly worse
|
||||
than the backoff it replaces, which did recover on its own within 15 minutes.
|
||||
|
||||
## Why entities can't just be created offline
|
||||
|
||||
Four independent gates, all of which need live device data:
|
||||
|
||||
1. `bound` is only ever assigned in `_run_discovery` (`coordinator.py:1081`),
|
||||
which runs on a poll's `resources` dict.
|
||||
2. All ten platforms enumerate `coordinator.bound` exactly once, at forward
|
||||
time (`sensor.py:34` and siblings). Nothing adds entities later — the
|
||||
invariant is already documented at `coordinator.py:1283-1286`.
|
||||
3. `_is_included` (`entity.py:39`) returns False whenever `last_resources`
|
||||
has no rep for the href. Even a fully reconstructed `bound` filters to
|
||||
nothing while `StateCache` is empty.
|
||||
4. `LocalThingsEntity` is a bare `CoordinatorEntity` with no `available`
|
||||
override, no `RestoreEntity`, and no `Store` anywhere in the component. An
|
||||
entity that did exist offline would be `unavailable` with no state.
|
||||
|
||||
The issue cites ESPHome, Shelly, LIFX and WLED as precedent for setup that
|
||||
never fails. Those integrations can do it because each one has a *persisted
|
||||
device description* to build entities from — ESPHome keeps its entity list in
|
||||
`.storage`, Shelly caches device info. The pattern is portable; the mechanism
|
||||
underneath it is the part PR #303 is missing.
|
||||
|
||||
## How the implemented version works
|
||||
|
||||
Three pieces, plus a gating rule.
|
||||
|
||||
### 1. A persisted discovery snapshot
|
||||
|
||||
After each successful first cycle, `_save_snapshot` banks exactly the
|
||||
`resources` dict that cycle handed `_run_discovery`, along with the
|
||||
pre-narrowing subdevice candidate list and the `DeviceIdentity` read from
|
||||
`/oic/*`.
|
||||
|
||||
Storing the poll input rather than a rendered entity list is the decision
|
||||
that keeps this honest. `BoundEntity` holds live
|
||||
`Capability`/`SamsungEntityDescription` objects and isn't serializable, so
|
||||
the alternative was a parallel format plus a re-resolution path — a second
|
||||
implementation of discovery that could drift from the real one. Replaying the
|
||||
input through `_run_discovery` means the same code, the same registry
|
||||
resolution, and no second source of truth.
|
||||
|
||||
Three things ride along because `_run_discovery` reads them off `self`
|
||||
rather than out of `resources`, and getting them wrong would silently resolve
|
||||
a *different* registry offline than online — which reconciliation below would
|
||||
then see as a real change and reload on every restart:
|
||||
|
||||
- `_identity.device_types` routes `resolve_registry`.
|
||||
- `self.subdevices` is the candidate list `discover_partitioned` narrows;
|
||||
replaying against the already-narrowed list finds no siblings at all.
|
||||
- `_identity.manufacturer`/`model` feed `device_info`.
|
||||
|
||||
It lives in `.storage` (`Store`, keyed on entry_id) rather than on the config
|
||||
entry: it's device state, not configuration, and runs to tens of kilobytes.
|
||||
`async_remove_entry` deletes it with the entry.
|
||||
|
||||
The write is awaited, not `async_delay_save`d. A deferred write outlives
|
||||
whatever queued it: it lands after `async_remove_entry` has deleted the file
|
||||
and recreates it orphaned, and a reload scheduled by the reconcile below
|
||||
would read the pre-reload snapshot back off disk. It runs once per entry
|
||||
load, so there's nothing worth deferring. A write that fails is logged and
|
||||
swallowed — a board reporting something the JSON encoder rejects must not
|
||||
break polling.
|
||||
|
||||
### 2. Reconcile on reconnect
|
||||
|
||||
The snapshot is a claim about a device we haven't talked to yet. When the
|
||||
first live poll lands, `_reconcile_rehydrated` compares the live entity set
|
||||
against the rehydrated one — as `(subdevice key, _key(bound))` pairs, which
|
||||
is the unique_id identity — and calls `async_schedule_reload` if they differ.
|
||||
|
||||
Gate 2 above is why this has to be a reload rather than an in-place fixup.
|
||||
It's what makes the feature safe against a firmware update, a sibling
|
||||
subdevice that starts answering, or a different appliance at the same IP.
|
||||
|
||||
### 3. Keep polling with no listeners
|
||||
|
||||
`async_setup_entry` holds one listener for the entry's lifetime:
|
||||
|
||||
```python
|
||||
entry.async_on_unload(coordinator.async_add_listener(lambda: None))
|
||||
```
|
||||
|
||||
Registered *before* the first refresh, so scheduling survives a refresh that
|
||||
fails. This alone fixes the measured "never polls again" bug, and covers a
|
||||
rehydrated set whose entities are all registry-disabled. Removing the last
|
||||
listener unschedules the timer, and HA runs `async_on_unload` callbacks when
|
||||
setup raises, so the setup-retry path doesn't leak a polling coordinator.
|
||||
|
||||
### Gating rule: only load offline when there's a snapshot
|
||||
|
||||
An entry that has never successfully polled has nothing to restore and keeps
|
||||
raising `ConfigEntryNotReady`. This is what answers the objection in the PR
|
||||
thread — with a snapshot we *do* have metadata to build a device from, and
|
||||
without one HA's backoff is still the right behavior. It also leaves room for
|
||||
the #168-style flows that need to interact with the device during setup: a
|
||||
device that never completed setup still blocks.
|
||||
|
||||
It also means `async_remove_config_entry_device` is no longer reachable with
|
||||
an empty `coordinator.subdevices`, so an offline load can't offer to delete a
|
||||
real-but-unreachable subdevice.
|
||||
|
||||
### The coverage-gap Repair stays live-only
|
||||
|
||||
`_run_discovery(..., from_snapshot=True)` skips `_update_coverage_gap_issue`.
|
||||
The Repair points the user at a diagnostics download, which is empty until
|
||||
the appliance answers, and a device name that drifts between the snapshot and
|
||||
the live poll would churn the issue for no reason.
|
||||
|
||||
Not a de-duplication measure — HA already handles that. `async_create_issue`
|
||||
is keyed on `(domain, issue_id)`, `dataclasses.replace` in
|
||||
`async_get_or_create` leaves `dismissed_version` alone, and the registry
|
||||
reloads non-persistent issues with their dismissal intact, so one row per
|
||||
entry survives restarts and an "Ignore" sticks.
|
||||
|
||||
## What this still won't do
|
||||
|
||||
Entities will be present and `unavailable` — not showing their last values.
|
||||
Gate 4 means last-known values require either `RestoreEntity` per platform or
|
||||
persisting `StateCache`, and both mean asserting state the integration cannot
|
||||
verify: a washer unplugged for a week would read "Running". HA's convention
|
||||
is that unreachable means unavailable, and the recorder keeps the history
|
||||
either way, so long-term statistics and history graphs are unaffected by this
|
||||
choice.
|
||||
|
||||
Worth being explicit about, because it is the gap between what PR #303
|
||||
promises in the thread ("load their previously recorded states") and what any
|
||||
correct version can deliver.
|
||||
|
||||
## Rejected: zeroconf
|
||||
|
||||
The issue's other suggestion — wire zeroconf so the device's own boot
|
||||
announcement triggers a retry, which is the genuinely idiomatic HA answer —
|
||||
is a non-starter as things stand: there is no `zeroconf` or `dhcp` key in
|
||||
`manifest.json` and the config flow is user-driven only, so HA has no
|
||||
discovery signal for this integration to hang a retry on. It would first need
|
||||
a confirmed mDNS service on the appliance. Worth revisiting if one turns up;
|
||||
it would make recovery near-instant instead of within one poll interval.
|
||||
|
||||
## Rejected: the cheap version
|
||||
|
||||
Keeping `ConfigEntryNotReady` and adding a probe that calls
|
||||
`async_schedule_reload` on first success would have fixed the recovery *time*
|
||||
in about twenty lines, with no persistence and no reconcile. It was rejected
|
||||
because it leaves the device reading as broken for as long as the appliance
|
||||
is off, which is the half of issue #295 that actually bites — an appliance
|
||||
switched off at the wall is offline for days, not seconds, and a whole
|
||||
integration that looks failed for that entire window is the complaint.
|
||||
@@ -6,7 +6,7 @@ pytest-homeassistant-custom-component>=0.13.316
|
||||
|
||||
# Integration runtime deps, needed to import the component under test
|
||||
# (also declared in custom_components/localthings/manifest.json).
|
||||
smartthings-local>=0.1.6
|
||||
smartthings-local>=0.1.8
|
||||
cbor2>=5.4.6
|
||||
pyOpenSSL>=23.0
|
||||
cryptography>=41.0
|
||||
|
||||
+1
@@ -20,6 +20,7 @@
|
||||
"fine_dust",
|
||||
"humidity",
|
||||
"odor",
|
||||
"outdoor_temperature",
|
||||
"power_watts",
|
||||
"super_fine_dust",
|
||||
"tropical_night_mode"
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"mute_once",
|
||||
"odor_controller_active",
|
||||
"odor_controller_progress",
|
||||
"outdoor_temperature",
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"selfcheck_error",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"mute_once",
|
||||
"odor_controller_active",
|
||||
"odor_controller_progress",
|
||||
"outdoor_temperature",
|
||||
"power_watts",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"mute_once",
|
||||
"odor_controller_active",
|
||||
"odor_controller_progress",
|
||||
"outdoor_temperature",
|
||||
"periodic_air_sensing",
|
||||
"periodic_sensing_skip_status",
|
||||
"power_watts",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"mute_once",
|
||||
"outdoor_temperature",
|
||||
"power_watts",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"last_air_sensing_level",
|
||||
"last_air_sensing_time",
|
||||
"mute_once",
|
||||
"outdoor_temperature",
|
||||
"periodic_air_sensing",
|
||||
"periodic_sensing_skip_status",
|
||||
"selfcheck_error",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"energy_saved_kwh",
|
||||
"firmware_update",
|
||||
"mute_once",
|
||||
"outdoor_temperature",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"mute_once",
|
||||
"odor_controller_active",
|
||||
"odor_controller_progress",
|
||||
"outdoor_temperature",
|
||||
"power_watts",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"mute_once",
|
||||
"outdoor_temperature",
|
||||
"power_watts",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"mute_once",
|
||||
"odor_controller_active",
|
||||
"odor_controller_progress",
|
||||
"outdoor_temperature",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"mute_once",
|
||||
"outdoor_temperature",
|
||||
"power_watts",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"fine_dust",
|
||||
"humidity",
|
||||
"odor",
|
||||
"outdoor_temperature",
|
||||
"power_watts",
|
||||
"super_fine_dust",
|
||||
"tropical_night_mode"
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"mute_once",
|
||||
"outdoor_temperature",
|
||||
"power_watts",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
|
||||
@@ -71,7 +71,12 @@ async def test_summary_interval(hass: HomeAssistant, mock_entry, mock_coordinato
|
||||
|
||||
|
||||
async def test_update_failed_on_persistent_poll_error(hass: HomeAssistant, mock_entry) -> None:
|
||||
"""ConfigEntryNotReady raised when poll fails even after reconnect."""
|
||||
"""ConfigEntryNotReady raised when poll fails even after reconnect.
|
||||
|
||||
An entry with no stored discovery snapshot has never reached this device,
|
||||
so there is nothing to load offline from (issue #295) -- it stays on HA's
|
||||
backoff rather than loading empty.
|
||||
"""
|
||||
|
||||
with (
|
||||
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._connect_session"),
|
||||
@@ -1824,10 +1829,11 @@ async def test_first_refresh_timeout_recovers_via_reconnect(
|
||||
async def test_first_refresh_persistent_timeout_fails_setup(
|
||||
hass: HomeAssistant, mock_entry
|
||||
) -> None:
|
||||
"""When the reconnect times out too, the first refresh must fail so HA
|
||||
retries on its backoff -- not load an entity-less entry. The session it
|
||||
left open is closed on the way out (`_poll_once` keeps it up on a
|
||||
`TimeoutError`, and the source port is fixed per device)."""
|
||||
"""With no snapshot to load from, a reconnect that times out too must
|
||||
fail the first refresh so HA retries on its backoff -- not load an
|
||||
entity-less entry. The session it left open is closed on the way out
|
||||
(`_poll_once` keeps it up on a `TimeoutError`, and the source port is
|
||||
fixed per device)."""
|
||||
with (
|
||||
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._connect_session"),
|
||||
patch(
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
"""Loading a config entry while the appliance is unreachable (issue #295).
|
||||
|
||||
The device's entity set only exists as the output of a live poll, so coming
|
||||
up offline means replaying the last successful discovery from a stored
|
||||
snapshot. These tests pin the four things that makes load-bearing: the
|
||||
snapshot gets written, it produces the same entity set offline, the
|
||||
coordinator keeps polling until the device answers, and a live discovery that
|
||||
disagrees with the snapshot reloads the entry rather than silently keeping a
|
||||
stale set.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.util import dt as dt_util
|
||||
from pytest_homeassistant_custom_component.common import async_fire_time_changed
|
||||
|
||||
from custom_components.localthings.const import DOMAIN, SUMMARY_INTERVAL_S
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
from custom_components.localthings.registry.identity import DeviceIdentity
|
||||
|
||||
from .conftest import _load_fridge_resources as _load_fridge
|
||||
|
||||
_COORD = "custom_components.localthings.coordinator.LocalThingsCoordinator"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _reachable(resources: dict, identity: DeviceIdentity | None = None):
|
||||
"""A device that answers, optionally with an /oic/* identity -- which
|
||||
`_connect_session` is what normally reads, so a test that patches it out
|
||||
otherwise leaves `_identity` None."""
|
||||
|
||||
def _connect(self) -> None:
|
||||
self._identity = identity
|
||||
|
||||
with (
|
||||
patch(f"{_COORD}._connect_session", _connect),
|
||||
patch(f"{_COORD}._poll_once", return_value=resources),
|
||||
patch(f"{_COORD}._close_session"),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _unreachable():
|
||||
with (
|
||||
patch(f"{_COORD}._connect_session"),
|
||||
patch(f"{_COORD}._poll_once", side_effect=OSError("device offline")),
|
||||
patch(f"{_COORD}._close_session"),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
def _store_key(entry) -> str:
|
||||
return f"{DOMAIN}.{entry.entry_id}.discovery"
|
||||
|
||||
|
||||
async def _tick(hass: HomeAssistant) -> None:
|
||||
"""Advance past one summary interval so the coordinator polls again.
|
||||
|
||||
`wait_background_tasks` is load-bearing: DataUpdateCoordinator runs its
|
||||
interval refresh as a background task, which a plain block_till_done
|
||||
doesn't await -- the poll would still be in flight at the assertion.
|
||||
"""
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=SUMMARY_INTERVAL_S + 1))
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
|
||||
async def _setup_online_then_unload(hass: HomeAssistant, entry, resources: dict) -> set[str]:
|
||||
"""Bring the entry up against a live device, bank the snapshot, and take
|
||||
it back down. Returns the entity_ids that run produced."""
|
||||
with _reachable(resources):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
entity_ids = {s.entity_id for s in hass.states.async_all()}
|
||||
await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
return entity_ids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Writing the snapshot
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_snapshot_written_after_first_discovery(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_session, hass_storage
|
||||
) -> None:
|
||||
"""A successful first cycle banks what it handed _run_discovery."""
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
stored = hass_storage[_store_key(mock_entry)]["data"]
|
||||
assert stored["resources"]
|
||||
assert "/information/vs/0" in stored["resources"]
|
||||
assert "subdevice_candidates" in stored
|
||||
|
||||
|
||||
async def test_snapshot_not_written_when_device_never_answers(
|
||||
hass: HomeAssistant, mock_entry, hass_storage
|
||||
) -> None:
|
||||
"""Nothing to bank, so nothing is -- this is what keeps the no-snapshot
|
||||
gate meaningful on a brand-new entry."""
|
||||
with _unreachable():
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
assert _store_key(mock_entry) not in hass_storage
|
||||
|
||||
|
||||
async def test_snapshot_removed_when_entry_removed(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_session, hass_storage
|
||||
) -> None:
|
||||
"""The store is keyed on entry_id, so re-adding the appliance mints a new
|
||||
one -- the old file has to go with the entry that wrote it.
|
||||
|
||||
The clock is run on afterwards because a deferred write would land here:
|
||||
with `async_delay_save` the removal was undone a few seconds later by the
|
||||
save the last poll had queued, leaving the file orphaned for good.
|
||||
"""
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert _store_key(mock_entry) in hass_storage
|
||||
|
||||
await hass.config_entries.async_remove(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert hass_storage.get(_store_key(mock_entry), {}).get("data") is None
|
||||
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=60))
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
assert hass_storage.get(_store_key(mock_entry), {}).get("data") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Loading from it
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_offline_load_restores_the_same_entity_set(
|
||||
hass: HomeAssistant, mock_entry, hass_storage
|
||||
) -> None:
|
||||
"""The whole point: a restart with the appliance powered off comes up on
|
||||
the entity set the device last actually reported."""
|
||||
resources = _load_fridge()
|
||||
online_ids = await _setup_online_then_unload(hass, mock_entry, resources)
|
||||
assert online_ids # guard: the online run must actually produce entities
|
||||
|
||||
with _unreachable():
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_entry.state is ConfigEntryState.LOADED
|
||||
assert {s.entity_id for s in hass.states.async_all()} == online_ids
|
||||
|
||||
|
||||
async def test_offline_entities_are_unavailable_not_stale(hass: HomeAssistant, mock_entry) -> None:
|
||||
"""Restored entities must not render the snapshot's values -- the
|
||||
appliance is unreachable, so `unavailable` is the honest state and the
|
||||
live cache stays empty to enforce it."""
|
||||
resources = _load_fridge()
|
||||
await _setup_online_then_unload(hass, mock_entry, resources)
|
||||
|
||||
with _unreachable():
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
states = hass.states.async_all()
|
||||
assert states
|
||||
assert all(s.state == "unavailable" for s in states)
|
||||
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
assert coordinator.rehydrated
|
||||
assert not coordinator.last_resources
|
||||
|
||||
|
||||
async def test_offline_load_without_snapshot_still_fails(hass: HomeAssistant, mock_entry) -> None:
|
||||
"""No snapshot means no device metadata to build anything from, so the
|
||||
entry stays on HA's backoff rather than loading empty."""
|
||||
with _unreachable():
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
assert not hass.states.async_all()
|
||||
|
||||
|
||||
async def test_malformed_snapshot_falls_back_to_setup_retry(
|
||||
hass: HomeAssistant, mock_entry, hass_storage
|
||||
) -> None:
|
||||
"""A stored row missing a field the current dataclass declares must fail
|
||||
the same way an unreachable device does.
|
||||
|
||||
Anything escaping async_rehydrate reaches async_setup_entry, which only
|
||||
handles ConfigEntryNotReady -- so the entry would land in SETUP_ERROR,
|
||||
which HA never retries, with its DTLS session left open on the fixed
|
||||
source port the next attempt binds.
|
||||
"""
|
||||
key = _store_key(mock_entry)
|
||||
hass_storage[key] = {
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": key,
|
||||
"data": {
|
||||
"resources": _load_fridge(),
|
||||
"subdevice_candidates": [{"key": "1"}], # no "kind"
|
||||
},
|
||||
}
|
||||
|
||||
with (
|
||||
_unreachable(),
|
||||
patch.object(LocalThingsCoordinator, "async_close", autospec=True) as close,
|
||||
):
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
close.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_corrupt_snapshot_falls_back_to_setup_retry(
|
||||
hass: HomeAssistant, mock_entry, hass_storage
|
||||
) -> None:
|
||||
"""A snapshot whose resources no longer replay cleanly must not take the
|
||||
entry down with it."""
|
||||
key = _store_key(mock_entry)
|
||||
hass_storage[key] = {
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": key,
|
||||
"data": {"resources": {"/information/vs/0": "not-a-rep"}, "subdevice_candidates": []},
|
||||
}
|
||||
|
||||
with _unreachable():
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_snapshot_restores_identity(hass: HomeAssistant, mock_entry) -> None:
|
||||
"""`/oic/d`'s device types route the registry, so an offline load that
|
||||
lost them could resolve a different one than the live poll did -- which
|
||||
would show up as a spurious reconcile reload every restart."""
|
||||
resources = _load_fridge()
|
||||
identity = DeviceIdentity(
|
||||
manufacturer="Samsung",
|
||||
model="TEST-MODEL",
|
||||
name="Fridge",
|
||||
serial=None,
|
||||
device_types=("oic.d.refrigerator",),
|
||||
raw={"/oic/p": {}, "/oic/d": {}, "/oic/res": []},
|
||||
)
|
||||
with _reachable(resources, identity):
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
await hass.config_entries.async_unload(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
with _unreachable():
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
assert coordinator._identity is not None
|
||||
assert coordinator._identity.device_types == ("oic.d.refrigerator",)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Recovery and reconciliation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_offline_load_keeps_polling_and_recovers(hass: HomeAssistant, mock_entry) -> None:
|
||||
"""The failure the PR this replaces actually shipped: with zero live
|
||||
listeners the base coordinator stops rescheduling, and the entry never
|
||||
polls again. Entities must go available on the next interval once the
|
||||
appliance answers."""
|
||||
resources = _load_fridge()
|
||||
await _setup_online_then_unload(hass, mock_entry, resources)
|
||||
|
||||
with _unreachable():
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
assert coordinator._unsub_refresh is not None # a poll is actually queued
|
||||
assert not coordinator.last_update_success
|
||||
|
||||
with _reachable(resources):
|
||||
await _tick(hass)
|
||||
|
||||
assert coordinator.last_update_success
|
||||
assert any(s.state != "unavailable" for s in hass.states.async_all())
|
||||
|
||||
|
||||
async def test_reconcile_reloads_when_live_discovery_differs(
|
||||
hass: HomeAssistant, mock_entry, hass_storage
|
||||
) -> None:
|
||||
"""Platforms enumerate `bound` once, so a live set that disagrees with the
|
||||
snapshot can only be adopted by bringing the entry back up."""
|
||||
resources = _load_fridge()
|
||||
|
||||
with _reachable(resources):
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
victim = coordinator.bound[0].href
|
||||
await hass.config_entries.async_unload(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
with _unreachable():
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
reduced = {href: rep for href, rep in resources.items() if href != victim}
|
||||
with (
|
||||
_reachable(reduced),
|
||||
patch.object(hass.config_entries, "async_schedule_reload") as reload,
|
||||
):
|
||||
await _tick(hass)
|
||||
|
||||
reload.assert_called_once_with(mock_entry.entry_id)
|
||||
# Banked before the reload is scheduled, so the entry that comes back up
|
||||
# replays this discovery rather than the one it is replacing -- otherwise
|
||||
# a device that goes quiet again mid-reload rehydrates the stale set and
|
||||
# reconciles all over again.
|
||||
assert victim not in hass_storage[_store_key(mock_entry)]["data"]["resources"]
|
||||
|
||||
|
||||
async def test_reconcile_is_quiet_when_live_discovery_agrees(
|
||||
hass: HomeAssistant, mock_entry
|
||||
) -> None:
|
||||
"""The common case -- same appliance, same firmware -- must not reload,
|
||||
or every offline restart would cost a second setup cycle."""
|
||||
resources = _load_fridge()
|
||||
await _setup_online_then_unload(hass, mock_entry, resources)
|
||||
|
||||
with _unreachable():
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
with (
|
||||
_reachable(resources),
|
||||
patch.object(hass.config_entries, "async_schedule_reload") as reload,
|
||||
):
|
||||
await _tick(hass)
|
||||
|
||||
reload.assert_not_called()
|
||||
|
||||
|
||||
def test_coverage_gap_repair_is_live_only(hass: HomeAssistant, mock_entry) -> None:
|
||||
"""A coverage gap is a claim about what the device reports, so replaying
|
||||
a snapshot must not raise the Repair -- it would restate last run's
|
||||
conclusion while the diagnostics download it points at is still empty."""
|
||||
gappy = {
|
||||
"/information/vs/0": {
|
||||
"x.com.samsung.da.modelNum": "TOTALLY_UNKNOWN_BOARD",
|
||||
"x.com.samsung.da.serialNum": "TEST-SERIAL-0000",
|
||||
},
|
||||
"/nothing/maps/this/vs/0": {"someField": 1},
|
||||
}
|
||||
issue_id = f"device_gap_{mock_entry.entry_id}"
|
||||
coordinator = LocalThingsCoordinator(hass, mock_entry)
|
||||
|
||||
coordinator._run_discovery(gappy, from_snapshot=True)
|
||||
assert coordinator._unbound_hrefs # the gap is real, it just stays quiet
|
||||
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None
|
||||
|
||||
coordinator._run_discovery(gappy)
|
||||
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is not None
|
||||
|
||||
|
||||
async def test_live_load_never_reconciles(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_session
|
||||
) -> None:
|
||||
"""An entry that came up against a live device has nothing to reconcile
|
||||
against; the reload path must stay out of the normal startup entirely."""
|
||||
with patch.object(hass.config_entries, "async_schedule_reload") as reload:
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
assert not coordinator.rehydrated
|
||||
reload.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failures", [1, 3])
|
||||
async def test_offline_load_survives_repeated_poll_failures(
|
||||
hass: HomeAssistant, mock_entry, failures: int
|
||||
) -> None:
|
||||
"""Recovery isn't one-shot: the entry keeps its entities and keeps
|
||||
retrying across however many intervals the appliance stays dark."""
|
||||
resources = _load_fridge()
|
||||
online_ids = await _setup_online_then_unload(hass, mock_entry, resources)
|
||||
|
||||
with _unreachable():
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
for _ in range(failures):
|
||||
await _tick(hass)
|
||||
|
||||
assert mock_entry.state is ConfigEntryState.LOADED
|
||||
assert {s.entity_id for s in hass.states.async_all()} == online_ids
|
||||
|
||||
with _reachable(resources):
|
||||
await _tick(hass)
|
||||
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
assert coordinator.last_update_success
|
||||
@@ -36,6 +36,15 @@ class _FakeCoordinator:
|
||||
def canonical_resources(self, subdevice):
|
||||
return self.last_resources
|
||||
|
||||
# _is_included judges existence against the discovery view, which is the
|
||||
# live cache for everything but an offline load (issue #295).
|
||||
@property
|
||||
def discovery_resources(self):
|
||||
return self.last_resources
|
||||
|
||||
def discovery_canonical(self, subdevice):
|
||||
return self.canonical_resources(subdevice)
|
||||
|
||||
|
||||
def _resources():
|
||||
return _load_device("airconditioner_ailp_fac")
|
||||
|
||||
@@ -129,22 +129,35 @@ def test_token_entities_present_with_calibrated_values():
|
||||
|
||||
|
||||
def test_token_entities_stay_off_newer_boards():
|
||||
"""Newer families carry Volume/Sleep/OutdoorTemp/Autoclean tokens too,
|
||||
while also exposing those settings as dedicated resources -- ungated, the
|
||||
token entities would duplicate them (auto clean) or apply a scale
|
||||
calibrated on another board generation (outdoor temperature)."""
|
||||
"""Newer families carry Volume/Sleep/Autoclean tokens too, while also
|
||||
exposing those settings as dedicated resources -- ungated, the token
|
||||
entities would duplicate them."""
|
||||
state = _state("airconditioner_tp1x_rac")
|
||||
for key in (
|
||||
"spi",
|
||||
"auto_clean_legacy",
|
||||
"air_monitoring",
|
||||
"good_sleep",
|
||||
"outdoor_temperature",
|
||||
"filter_time",
|
||||
):
|
||||
assert key not in state, key
|
||||
|
||||
|
||||
def test_outdoor_temperature_is_not_gated_to_legacy_boards():
|
||||
"""issue #367: OutdoorTemp_ is not paired with any dedicated resource on
|
||||
newer boards -- /temperatures/vs/0 carries indoor temperature only, no
|
||||
outdoor equivalent exists anywhere in this fixture's 23 hrefs -- so unlike
|
||||
auto_clean_legacy et al. above, gating it to is_legacy_board only dropped
|
||||
a real reading. A 48h field capture correlated the token against
|
||||
weather.forecast_home at r=0.92 across several non-legacy boards,
|
||||
confirming it is live per-site data rather than a firmware constant."""
|
||||
resources = _load_device("airconditioner_tp1x_rac")
|
||||
assert is_legacy_board(resources) is False
|
||||
bound, _ = _discover(resources)
|
||||
state = flatten(bound, resources)
|
||||
assert state["outdoor_temperature"] == 37.0 # OutdoorTemp_92, the fixture's own value
|
||||
|
||||
|
||||
def test_climate_legacy_airflow_gate_agrees_with_is_legacy_board():
|
||||
"""issue #161: climate.py's _legacy_airflow() delegates to
|
||||
capabilities/airconditioner.py's is_legacy_board() instead of
|
||||
|
||||
@@ -654,6 +654,22 @@ def test_lnx_rac_heatpump_no_unbound_hrefs():
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_lnx_rac_heatpump_outdoor_temperature_stays_off_fahrenheit_boards():
|
||||
"""issue #367's -55 offset was field-validated on Celsius-locale boards
|
||||
only; this fixture's own /temperatures/vs/0 declares Fahrenheit despite
|
||||
carrying an OutdoorTemp_ token, so the sensor stays off rather than
|
||||
apply an unvalidated offset/unit to it (see _reports_celsius)."""
|
||||
reg, resources = _ac_lnx_rac_heatpump()
|
||||
temps_item = resources["/temperatures/vs/0"]["x.com.samsung.da.items"][0]
|
||||
assert temps_item["x.com.samsung.da.unit"] == "Fahrenheit"
|
||||
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
|
||||
assert any(o.startswith("OutdoorTemp_") for o in options)
|
||||
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||
state = flatten(bound, resources)
|
||||
assert "outdoor_temperature" not in state
|
||||
|
||||
|
||||
def test_lnx_rac_heatpump_absence_power_saving_state():
|
||||
reg, resources = _ac_lnx_rac_heatpump()
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||
|
||||
@@ -29,6 +29,15 @@ class _FakeCoordinator:
|
||||
# subdevices (issue #177).
|
||||
return self.last_resources
|
||||
|
||||
# _is_included judges existence against the discovery view, which is the
|
||||
# live cache for everything but an offline load (issue #295).
|
||||
@property
|
||||
def discovery_resources(self):
|
||||
return self.last_resources
|
||||
|
||||
def discovery_canonical(self, subdevice):
|
||||
return self.canonical_resources(subdevice)
|
||||
|
||||
|
||||
def _coord(last_resources) -> LocalThingsCoordinator:
|
||||
return cast(LocalThingsCoordinator, _FakeCoordinator(last_resources))
|
||||
|
||||
@@ -34,6 +34,15 @@ class _FakeCoordinator:
|
||||
|
||||
return canonical_view(subdevice, self.last_resources, self._subdevices)
|
||||
|
||||
# _is_included judges existence against the discovery view, which is the
|
||||
# live cache for everything but an offline load (issue #295).
|
||||
@property
|
||||
def discovery_resources(self):
|
||||
return self.last_resources
|
||||
|
||||
def discovery_canonical(self, subdevice):
|
||||
return self.canonical_resources(subdevice)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", _FIXTURE_NAMES)
|
||||
def test_key_is_unique_across_all_bound_entities(name):
|
||||
|
||||
Reference in New Issue
Block a user