Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba089dbb5c | ||
|
|
0d0ffb57dd | ||
|
|
fe0db8586f | ||
|
|
8814fffa4f | ||
|
|
12922909c0 | ||
|
|
9d3782a29a | ||
|
|
edff7385c6 | ||
|
|
e684146f61 | ||
|
|
bc03ada208 | ||
|
|
67012c57d7 | ||
|
|
dbc5c55a97 | ||
|
|
19a2c03609 | ||
|
|
76f2c3c0cd | ||
|
|
a59ef6cca9 | ||
|
|
bc21f5f8f5 | ||
|
|
ab035a94af | ||
|
|
f6fbfc1f7f | ||
|
|
75466761a1 | ||
|
|
02d009380d | ||
|
|
9f1bcad3ec | ||
|
|
eeece4906c | ||
|
|
21af5708cb | ||
|
|
627b761462 | ||
|
|
3735d8b806 | ||
|
|
65fa80c71c | ||
|
|
1abaad7f40 | ||
|
|
389c65adbc | ||
|
|
74bdc04f56 | ||
|
|
f949ff04c2 | ||
|
|
3f9789512d | ||
|
|
cbe881818b |
+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.2"
|
||||
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.
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER as PARTICULATE_UNIT,
|
||||
)
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
@@ -12,8 +18,8 @@ from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_HOST, CONF_PORT, CONF_SERIAL, DOMAIN, PLATFORMS
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .const import CONF_DEVICE_TYPE, CONF_HOST, CONF_PORT, CONF_SERIAL, DOMAIN, PLATFORMS
|
||||
from .coordinator import LocalThingsCoordinator, snapshot_store
|
||||
from .registry.identity import resolve_serial
|
||||
from .services import async_setup_services
|
||||
|
||||
@@ -125,6 +131,100 @@ def _repair_placeholder_keys(hass: HomeAssistant, entry: ConfigEntry, serial: st
|
||||
)
|
||||
|
||||
|
||||
# Registries whose Dust/FineDust/SuperFineDust sensors gained pm10/pm25/pm1
|
||||
# and a unit in the release that introduced entry version 3. Deliberately
|
||||
# not every family reading /sensors/vs/0: range_hood and airconditioner
|
||||
# still declare no unit for their identically-named sensors, and relabelling
|
||||
# their statistics to µg/m³ would assert a unit those entities don't report
|
||||
# -- creating the very mismatch this migration exists to prevent.
|
||||
_PARTICULATE_TYPED_IN_V3 = frozenset({"air_purifier", "air_monitor"})
|
||||
|
||||
# unique_id is f"{DOMAIN}_{serial}_{state_key}"; state_key is the descriptor
|
||||
# key, optionally carrying a subdevice prefix and a trailing `_<n>` instance
|
||||
# (registry/adapter._key, discovery.instance_suffix). Matching the tail rather
|
||||
# than rebuilding the whole id keeps this working for a renamed entity, whose
|
||||
# entity_id -- and so its statistic_id -- no longer follows from the key.
|
||||
_PARTICULATE_KEY_RE = re.compile(r"_(?:super_fine_dust|fine_dust|dust)(?:_\d+)?$")
|
||||
|
||||
|
||||
@callback
|
||||
def _relabel_particulate_statistics(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Point existing particulate statistics at the unit they always were.
|
||||
|
||||
These sensors recorded long-term statistics with no unit, and the
|
||||
release carrying this migration gives them µg/m³. Home Assistant treats
|
||||
that as a unit change it can't convert and *suppresses statistics
|
||||
generation entirely* for the entity until someone resolves the repair
|
||||
(sensor.recorder._update_issues -> UNITS_CHANGED_ISSUE, and the matching
|
||||
`continue` in its compile path). Silently freezing the history we just
|
||||
finished labelling is the worst of both outcomes, so the metadata is
|
||||
corrected up front instead.
|
||||
|
||||
Only the metadata row is rewritten, never the recorded values. The
|
||||
readings were always µg/m³ concentrations (issue #325); what was missing
|
||||
was the label, so there is nothing to convert and no way for this to
|
||||
distort history. That is also why it uses
|
||||
`async_update_statistics_metadata` and not `change_statistics_unit`,
|
||||
which would scale every stored value.
|
||||
|
||||
A no-op when this device family isn't one that gained the unit, or when
|
||||
the device never recorded any statistics -- the underlying UPDATE simply
|
||||
matches no rows.
|
||||
|
||||
Returns False only when the recorder wasn't loaded, meaning the caller
|
||||
should leave the entry on its old version and try again next start.
|
||||
"""
|
||||
if entry.data.get(CONF_DEVICE_TYPE) not in _PARTICULATE_TYPED_IN_V3:
|
||||
return True
|
||||
if "recorder" not in hass.config.components:
|
||||
# after_dependencies orders the recorder ahead of us when it's
|
||||
# configured, so this is either an install without it (nothing to
|
||||
# relabel, and the retry costs one set lookup per start) or a boot
|
||||
# where it failed to come up. Not distinguishable here, and burning
|
||||
# the one-shot migration on the second case would leave the
|
||||
# statistics suppressed for good.
|
||||
_LOGGER.debug("recorder not loaded, deferring statistics relabel")
|
||||
return False
|
||||
|
||||
from homeassistant.components.recorder.statistics import (
|
||||
STATISTIC_UNIT_TO_UNIT_CONVERTER,
|
||||
async_update_statistics_metadata,
|
||||
)
|
||||
|
||||
kwargs: dict[str, Any] = {"new_unit_of_measurement": PARTICULATE_UNIT}
|
||||
# `new_unit_class` only exists from HA 2025.11; hacs.json still supports
|
||||
# 2025.1, where passing it is a TypeError -- which would propagate out of
|
||||
# async_migrate_entry and fail the whole entry. Where it is supported it
|
||||
# must be named, since omitting it is deprecated from HA 2026.11. µg/m³
|
||||
# has a converter, so the value is 'concentration' rather than None.
|
||||
if "new_unit_class" in inspect.signature(async_update_statistics_metadata).parameters:
|
||||
converter = STATISTIC_UNIT_TO_UNIT_CONVERTER.get(PARTICULATE_UNIT)
|
||||
kwargs["new_unit_class"] = converter.UNIT_CLASS if converter is not None else None
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
for registry_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id):
|
||||
if registry_entry.domain != "sensor":
|
||||
continue
|
||||
if not _PARTICULATE_KEY_RE.search(registry_entry.unique_id):
|
||||
continue
|
||||
_LOGGER.debug("relabelling statistics unit for %s", registry_entry.entity_id)
|
||||
try:
|
||||
async_update_statistics_metadata(hass, registry_entry.entity_id, **kwargs)
|
||||
except Exception:
|
||||
# Relabelling is a convenience: without it the user gets Home
|
||||
# Assistant's own units_changed repair, which is where they were
|
||||
# before this migration existed. Never worth failing setup over,
|
||||
# so no recorder-side surprise can cost them the integration.
|
||||
_LOGGER.warning(
|
||||
"Could not relabel statistics unit for %s; Home Assistant will "
|
||||
"offer a units-changed repair for it instead",
|
||||
registry_entry.entity_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Migrate an entry to the current version.
|
||||
|
||||
@@ -132,8 +232,11 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
can key its registry entries before the first poll (issue #236), and
|
||||
repairs whatever the old placeholder-keyed registration already
|
||||
orphaned.
|
||||
|
||||
v2 -> v3 relabels the recorded statistics for the particulate sensors,
|
||||
which gained a device_class/unit in the same release (issue #325).
|
||||
"""
|
||||
if entry.version > 2:
|
||||
if entry.version > 3:
|
||||
return False # downgrade: this release doesn't know the newer shape
|
||||
|
||||
if entry.version == 1:
|
||||
@@ -147,23 +250,40 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
_repair_placeholder_keys(hass, entry, serial)
|
||||
_LOGGER.debug("migrated entry %s to version 2 (serial=%s)", entry.entry_id, serial)
|
||||
|
||||
if entry.version == 2 and _relabel_particulate_statistics(hass, entry):
|
||||
hass.config_entries.async_update_entry(entry, version=3)
|
||||
_LOGGER.debug("migrated entry %s to version 3", entry.entry_id)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
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
|
||||
@@ -220,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:
|
||||
|
||||
@@ -493,27 +493,84 @@ _CERT_ALERTS = frozenset(
|
||||
}
|
||||
)
|
||||
|
||||
# OpenSSL renders a received fatal alert into its error text as e.g.
|
||||
# "tlsv1 alert unknown ca", which DtlsCoapSession.connect() wraps in a
|
||||
# ConnectionError. Reading it back tells us what the appliance objected to.
|
||||
#
|
||||
# Deliberately not the library's diagnostic probe (stateless=False): that
|
||||
# mode commits association state on the device, and an orphaned association
|
||||
# makes the next attempt time out (RFC 6347 §4.2.8) -- a bad trade on a
|
||||
# path the user is about to retry.
|
||||
# Older smartthings-local (< 0.1.3) rendered a received fatal alert straight
|
||||
# into the handshake exception's text, e.g. "tlsv1 alert unknown ca" wrapped
|
||||
# in a ConnectionError -- reading it back told us what the appliance
|
||||
# objected to. 0.1.3's "redacted typed failures" removed that: connect()'s
|
||||
# exceptions now carry a fixed, non-sensitive message with the real OpenSSL
|
||||
# text neither included nor chained (see smartthings_local.errors --
|
||||
# "backend errors can contain remote endpoints, local paths, or credential
|
||||
# metadata"). _alert_name is kept as a harmless fallback for exception text
|
||||
# that does carry it; _resolve_alert below is what actually classifies a
|
||||
# failure against a current library.
|
||||
_ALERT_RE = re.compile(r"alert ([a-z0-9 ]+)")
|
||||
|
||||
|
||||
def _alert_name(exc: Exception) -> str | None:
|
||||
"""The TLS alert an appliance sent, if this failure carried one."""
|
||||
"""The TLS alert an appliance sent, if this failure's exception text
|
||||
carried one (only ever true against smartthings-local < 0.1.3)."""
|
||||
match = _ALERT_RE.search(str(exc).lower())
|
||||
return match.group(1).strip().replace(" ", "_") if match else None
|
||||
|
||||
|
||||
def _diagnostic_alert(host: str, port: int, cert_pem: str, key_pem: str):
|
||||
"""One opt-in stateful handshake against `port`, using our real
|
||||
credentials, so a fatal Alert can be classified from the raw record
|
||||
itself rather than parsed out of an exception's text.
|
||||
|
||||
This is the library's diagnose_dtls_handshake -- deliberately not used
|
||||
for the primary candidate scan (it commits association state on the
|
||||
device, and an orphaned association makes the *next* attempt time out
|
||||
per RFC 6347 §4.2.8). Here it only runs once every real candidate has
|
||||
already failed, to explain a failure that's happening either way --
|
||||
one more orphaned association is a fair trade for a message that says
|
||||
why, on a path the user is about to retry regardless.
|
||||
|
||||
Imported lazily, like `_clienthello_probe`, so an install whose
|
||||
smartthings-local predates this API degrades to a generic message
|
||||
instead of failing to load the config flow at all.
|
||||
"""
|
||||
from smartthings_local.protocol.dtls_probe import diagnose_dtls_handshake
|
||||
|
||||
return diagnose_dtls_handshake(
|
||||
host,
|
||||
port,
|
||||
cert_pem=cert_pem,
|
||||
key_pem=key_pem,
|
||||
timeout=CLIENTHELLO_PROBE_TIMEOUT_S,
|
||||
retries=CLIENTHELLO_PROBE_RETRIES,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_alert(exc: Exception, host: str, port: int, cert_pem: str, key_pem: str) -> str | None:
|
||||
"""The TLS alert `port`'s failed handshake carried, if any -- the
|
||||
exception's own text first (cheap, and all an older library ever
|
||||
offers), then one bounded diagnostic handshake against a current one
|
||||
that redacts it (see _diagnostic_alert)."""
|
||||
name = _alert_name(exc)
|
||||
if name is not None:
|
||||
return name
|
||||
try:
|
||||
result = _diagnostic_alert(host, port, cert_pem, key_pem)
|
||||
except Exception:
|
||||
return None
|
||||
if result.alert is None:
|
||||
return None
|
||||
level, name = result.alert
|
||||
# ProbeResult.alert is set for a *received* alert record of either
|
||||
# level -- fatal (2) means the appliance actually broke off the
|
||||
# handshake over it; a warning (1, e.g. close_notify on an otherwise
|
||||
# ordinary close) is not evidence of a rejection and must not be read
|
||||
# as one. The old exception-text path never had this ambiguity: an
|
||||
# OpenSSL exception only ever rendered for a fatal alert.
|
||||
return name if level == 2 else None
|
||||
|
||||
|
||||
def _classify_handshake_failure(
|
||||
host: str,
|
||||
scan: _PortScan,
|
||||
failures: list[tuple[int, Exception]],
|
||||
alerts: dict[int, str] | None = None,
|
||||
) -> CannotConnect:
|
||||
"""Turn "no port worked" into the most specific thing we can honestly
|
||||
say, in rough order of how much the evidence tells us: an alert means
|
||||
@@ -521,13 +578,21 @@ def _classify_handshake_failure(
|
||||
certificate); a confirmed DTLS port that then timed out is likely still
|
||||
holding a session from a previous attempt; otherwise the sweep's own
|
||||
shape is the evidence.
|
||||
|
||||
`alerts` is the per-port classification `_handshake_and_read` already
|
||||
resolved (exception text, or a diagnostic handshake -- see
|
||||
_resolve_alert); a caller with only raw failures (or an older library)
|
||||
still gets `_alert_name`'s exception-text reading as a fallback.
|
||||
"""
|
||||
alerts = [name for name in (_alert_name(exc) for _, exc in failures) if name]
|
||||
cert_alerts = [name for name in alerts if name in _CERT_ALERTS]
|
||||
resolved = dict(alerts or {})
|
||||
for port, exc in failures:
|
||||
resolved.setdefault(port, _alert_name(exc))
|
||||
alert_names = [name for name in resolved.values() if name]
|
||||
cert_alerts = [name for name in alert_names if name in _CERT_ALERTS]
|
||||
if cert_alerts:
|
||||
return CertRejected(f"{host} rejected our certificate (alert {cert_alerts[0]})")
|
||||
if alerts:
|
||||
return HandshakeFailed(f"{host} refused the DTLS handshake (alert {alerts[0]})")
|
||||
if alert_names:
|
||||
return HandshakeFailed(f"{host} refused the DTLS handshake (alert {alert_names[0]})")
|
||||
if scan.confirmed:
|
||||
return HandshakeTimeout(
|
||||
f"DTLS server confirmed on {host}:{scan.confirmed} but the handshake never completed"
|
||||
@@ -626,6 +691,40 @@ def _read_device(sess, host: str, port: int) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _diagnose_failures(
|
||||
host: str,
|
||||
scan: _PortScan,
|
||||
failures: list[tuple[int, Exception]],
|
||||
cert_pem: str,
|
||||
key_pem: str,
|
||||
) -> dict[int, str]:
|
||||
"""At most one diagnostic handshake (see _diagnostic_alert) across every
|
||||
port `_handshake_and_read` just gave up on -- not one per port.
|
||||
|
||||
Called only after that loop has fully exhausted `scan.candidates`, never
|
||||
interleaved with it: `_diagnostic_alert`'s own docstring says the extra
|
||||
orphaned association it costs is a fair trade "on a path the user is
|
||||
about to retry regardless" -- true for the retry `_probe_and_validate`
|
||||
itself makes on a CertRejected (a fresh `_handshake_and_read` call
|
||||
against this same `scan`), but only if that retry's real handshake
|
||||
attempts are the ones landing on a clean slate. Running the diagnostic
|
||||
per candidate mid-loop would pollute exactly the port(s) that retry is
|
||||
about to reattempt; running several of them multiplies both the latency
|
||||
(each is its own bounded handshake) and the pollution for no extra
|
||||
classification value, since _classify_handshake_failure only ever needs
|
||||
one alert to decide.
|
||||
|
||||
Targets a confirmed-live port over an unconfirmed sweep candidate --
|
||||
the one actually worth spending the extra handshake on.
|
||||
"""
|
||||
if not failures:
|
||||
return {}
|
||||
by_port = dict(failures)
|
||||
port = next((p for p in scan.confirmed if p in by_port), next(iter(by_port)))
|
||||
alert = _resolve_alert(by_port[port], host, port, cert_pem, key_pem)
|
||||
return {port: alert} if alert is not None else {}
|
||||
|
||||
|
||||
def _handshake_and_read(host: str, scan: _PortScan, cert_pem: str, key_pem: str) -> dict:
|
||||
"""Handshake each candidate in turn, returning the first device that answers."""
|
||||
from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
||||
@@ -649,7 +748,8 @@ def _handshake_and_read(host: str, scan: _PortScan, cert_pem: str, key_pem: str)
|
||||
if sess is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
sess.close()
|
||||
raise _classify_handshake_failure(host, scan, failures)
|
||||
alerts = _diagnose_failures(host, scan, failures, cert_pem, key_pem)
|
||||
raise _classify_handshake_failure(host, scan, failures, alerts)
|
||||
|
||||
|
||||
def _probe_and_validate(
|
||||
@@ -693,7 +793,10 @@ def _probe_and_validate(
|
||||
|
||||
|
||||
class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
VERSION = 2
|
||||
# v3 relabels the particulate sensors' recorded statistics; a freshly
|
||||
# created entry has none to relabel, so it starts at the migrated
|
||||
# version rather than walking through v2 (see async_migrate_entry).
|
||||
VERSION = 3
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._host: str = ""
|
||||
|
||||
@@ -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
|
||||
@@ -103,10 +119,15 @@ def _local_source_port(host: str) -> int:
|
||||
time per RFC 6347 §4.2.8, instead of holding it 5-15 min. See
|
||||
DTLS_LOCAL_PORT_BASE. Requires smartthings-local >= 0.1.1.
|
||||
|
||||
Must stay unique per device on this host too: the library's socket is
|
||||
unconnected, so two devices sharing a port would mis-demux each other's
|
||||
datagrams. Last IPv4 octet as offset for the common case; a stable
|
||||
CRC32 fold otherwise.
|
||||
Must stay unique per device on this host too. That used to be load-
|
||||
bearing for demuxing: an unconnected socket handed every device's
|
||||
datagrams to whichever recvfrom() happened to be listening on their
|
||||
shared port. smartthings-local >= 0.1.3 connect()s its UDP socket
|
||||
instead (see endpoint.py's open_connected_udp_socket), so the kernel
|
||||
already filters incoming datagrams to each session's own resolved peer
|
||||
-- but a distinct port per device keeps that guarantee from ever
|
||||
depending on it, and keeps captures/logs unambiguous. Last IPv4 octet
|
||||
as offset for the common case; a stable CRC32 fold otherwise.
|
||||
"""
|
||||
try:
|
||||
offset = int(ipaddress.IPv4Address(host)) & 0xFF
|
||||
@@ -203,7 +224,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# A block-level ACK timeout on the summary GET doesn't prove the session
|
||||
# is dead (see _poll_once) -- require this many in a row before treating
|
||||
# it as one, so one slow transfer doesn't tear down a working OBSERVE
|
||||
# subscription.
|
||||
# subscription. Only covers that ambiguous case: smartthings-local
|
||||
# >= 0.1.6 raises a distinct SessionClosedError, not a TimeoutError, the
|
||||
# moment a dead reader thread is confirmed, and _defer_reconnect_for
|
||||
# never defers that -- see its docstring for what changed there.
|
||||
_POLL_TIMEOUT_LIMIT: int = 3
|
||||
|
||||
# Named (not inline literals) so the write-settle window in
|
||||
@@ -240,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.
|
||||
@@ -397,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)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -717,6 +775,14 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
not that the session is dead (earlier blocks succeeded). Left open;
|
||||
`_async_update_data` decides whether repeated timeouts warrant a
|
||||
reconnect. Any other exception is unambiguous -- close immediately.
|
||||
|
||||
smartthings-local >= 0.1.6 tells those two cases apart itself now:
|
||||
a reader thread that has actually died raises `SessionClosedError`
|
||||
(a ConnectionError, not a TimeoutError) the moment the next request
|
||||
notices, instead of the old behavior of quietly hanging out to this
|
||||
call's own timeout and surfacing as an ambiguous `TimeoutError`.
|
||||
See `_defer_reconnect_for` for what that changes about how soon a
|
||||
confirmed-dead session gets reconnected.
|
||||
"""
|
||||
if self._session is None:
|
||||
self._connect_session()
|
||||
@@ -983,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.
|
||||
@@ -1099,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)
|
||||
@@ -1164,7 +1377,38 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
if self._session is None:
|
||||
# _poll_once already connects on a real poll; only fires if
|
||||
# the session was closed out from under us concurrently.
|
||||
await self.hass.async_add_executor_job(self._connect_session)
|
||||
try:
|
||||
await self.hass.async_add_executor_job(self._connect_session)
|
||||
except Exception as e:
|
||||
# Not a poll failure -- the poll that reached this line
|
||||
# already succeeded, and observe mode is only an
|
||||
# optimization on top of it. Give up on push this cycle
|
||||
# the same way the two branches below do, rather than
|
||||
# letting this escape _async_update_data uncaught: none
|
||||
# of this integration's reconnect bookkeeping would run,
|
||||
# and the base coordinator logs an ERROR traceback in
|
||||
# place of the deliberately quiet "poll failed,
|
||||
# reconnecting" voice used everywhere else in this file.
|
||||
#
|
||||
# Not counted by _reconnect_is_frequent() (that window
|
||||
# records reconnects the poll path itself performed --
|
||||
# feeding it a secondary path's failure would push the
|
||||
# next routine poll reconnect over the warn threshold),
|
||||
# and nothing to _close_session(): _connect_session only
|
||||
# publishes self._session once connect() and
|
||||
# start_reader() have both already succeeded, so it's
|
||||
# still None here. No _resubscribe_due either -- that
|
||||
# flag means "a live session nothing has tried yet",
|
||||
# and setting it would re-enter this doomed handshake
|
||||
# every cycle; _last_observe_attempt_ts (stamped above)
|
||||
# already paces the retry to _RECOVERY_RETRY_S.
|
||||
self._log.info(
|
||||
"observe-mode reconnect failed (%s), staying on polling: %s",
|
||||
type(e).__name__,
|
||||
e,
|
||||
)
|
||||
self._observe.abandon_observe_attempt()
|
||||
return
|
||||
sess = self._session
|
||||
if sess is None:
|
||||
return
|
||||
@@ -1220,6 +1464,19 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
`_POLL_TIMEOUT_LIMIT` consecutive timeouts pile up. Any other
|
||||
exception reconnects immediately.
|
||||
|
||||
That includes `SessionClosedError`, which is the point: before
|
||||
smartthings-local 0.1.6, a reader thread that had actually died was
|
||||
indistinguishable from a slow transfer -- both surfaced here only as
|
||||
a `TimeoutError`, so this tolerance was the only thing standing
|
||||
between a truly dead session and a reconnect, worst case about
|
||||
`_POLL_TIMEOUT_LIMIT` poll cycles (~2 minutes at the default 30s
|
||||
interval). 0.1.6 confirms reader death directly and raises a
|
||||
ConnectionError subclass for it instead, which isn't a TimeoutError
|
||||
and so skips this tolerance entirely -- a genuinely dead session now
|
||||
reconnects on the very first occurrence. Intended (see this repo's
|
||||
README, "Known device behavior"), not a regression, but a real
|
||||
change in observed reconnect timing for that one failure mode.
|
||||
|
||||
Never defers before first discovery (issue #254): deferring returns
|
||||
an empty dict, which the base coordinator treats as a successful
|
||||
first refresh -- and since platforms enumerate `bound` once, the
|
||||
@@ -1322,9 +1579,37 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# cycle's snapshot so discovery sees every subdevice on the
|
||||
# first poll rather than waiting a cycle.
|
||||
async with self._session_lock:
|
||||
resources = await self.hass.async_add_executor_job(
|
||||
self._enumerate_subdevices_blocking, resources
|
||||
)
|
||||
try:
|
||||
resources = await self.hass.async_add_executor_job(
|
||||
self._enumerate_subdevices_blocking, resources
|
||||
)
|
||||
except Exception as e:
|
||||
# _connect_session() inside here only runs at all if the
|
||||
# session the poll above just used got closed out from
|
||||
# under us within this same cycle -- rare, but not
|
||||
# impossible, and unguarded before this. Losing the
|
||||
# subdevice probe isn't losing first discovery: `resources`
|
||||
# keeps the value _poll_once already returned, so
|
||||
# discovery below still runs on the master's own data,
|
||||
# same posture _poll_subdevice_seed takes for one sibling
|
||||
# going quiet.
|
||||
#
|
||||
# Not a one-cycle blip, though: `_run_discovery` a few
|
||||
# lines below sets `self._discovered = True`
|
||||
# unconditionally this same cycle, which is what gates
|
||||
# this whole block -- there is no next cycle where this
|
||||
# is retried. A composite appliance whose enumeration
|
||||
# fails here loses its sibling subdevices' entities for
|
||||
# this config entry's lifetime (a reload probes again).
|
||||
# warning, not debug, because of that: it's silent and
|
||||
# permanent otherwise, with nothing in the log pointing
|
||||
# at why a device is missing entities it should have.
|
||||
self._log.warning(
|
||||
"subdevice enumeration failed on first discovery; "
|
||||
"any sibling subdevices will be missing until this "
|
||||
"config entry is reloaded: %s",
|
||||
e,
|
||||
)
|
||||
|
||||
source = "sweep" if self._discovered else "poll"
|
||||
first_cycle = not self._discovered
|
||||
@@ -1334,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:
|
||||
@@ -1626,9 +1917,31 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
)
|
||||
norm_href = "/" + "/".join(path_segs)
|
||||
async with self._session_lock:
|
||||
return await self.hass.async_add_executor_job(
|
||||
self._raw_read_blocking, path_segs, norm_href
|
||||
)
|
||||
try:
|
||||
return await self.hass.async_add_executor_job(
|
||||
self._raw_read_blocking, path_segs, norm_href
|
||||
)
|
||||
except Exception as e:
|
||||
# Unlike async_raw_write_sequence, there's nothing to
|
||||
# reconnect-and-retry here -- a live debug read either lands
|
||||
# or it doesn't, and a service call is the one place on this
|
||||
# path a raw session exception would otherwise reach a user
|
||||
# untranslated (write_resource already goes through
|
||||
# HomeAssistantError; this brings read_resource in line).
|
||||
if not isinstance(e, TimeoutError):
|
||||
# Same TimeoutError-vs-anything-else split as
|
||||
# _poll_once: a block-ACK timeout alone doesn't prove
|
||||
# the session is dead, but anything else does -- and
|
||||
# leaving a confirmed-dead one installed would fail
|
||||
# every read/write identically until the next real
|
||||
# poll cycle's own reconnect notices.
|
||||
await self.hass.async_add_executor_job(self._close_session)
|
||||
self._log.warning("debug read failed for %s: %s", norm_href, e)
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="debug_read_failed",
|
||||
translation_placeholders={"href": norm_href, "error": str(e)},
|
||||
) from e
|
||||
|
||||
async def async_raw_write_sequence(
|
||||
self,
|
||||
@@ -1736,9 +2049,26 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
verified: dict[str, Any] = {}
|
||||
async with self._session_lock:
|
||||
for href in dict.fromkeys(r["href"] for r in results):
|
||||
vcode, vrep, _vbody = await self.hass.async_add_executor_job(
|
||||
self._raw_read_blocking, _href_to_path_segs(href), href
|
||||
)
|
||||
read_error: str | None = None
|
||||
try:
|
||||
vcode, vrep, _vbody = await self.hass.async_add_executor_job(
|
||||
self._raw_read_blocking, _href_to_path_segs(href), href
|
||||
)
|
||||
except Exception as e:
|
||||
# The write already landed -- see `results` above,
|
||||
# built before this wait ever started. A failed
|
||||
# confirmation read (the session dying in the gap
|
||||
# verify_after just waited out, say) must not lose
|
||||
# that outcome behind a raised exception here, and
|
||||
# one href's failure shouldn't stop the rest of the
|
||||
# batch from being checked. Same "couldn't verify"
|
||||
# posture as a 4.04/empty read below: held stays
|
||||
# None, not False -- but raw_code 0 alone is also
|
||||
# what a 4.04 produces, so read_error is what tells
|
||||
# the two apart for a caller inspecting the response.
|
||||
self._log.debug("raw write verification read failed for %s: %s", href, e)
|
||||
vcode, vrep = 0, {}
|
||||
read_error = str(e)
|
||||
# None, not False, when the re-read brought back nothing
|
||||
# to compare: every comparison against an empty rep is
|
||||
# False, which would report a 4.04 as a revert -- the one
|
||||
@@ -1753,6 +2083,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
if read_ok
|
||||
else None
|
||||
),
|
||||
"read_error": read_error,
|
||||
}
|
||||
response["verified"] = verified
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"domain": "localthings",
|
||||
"name": "LocalThings",
|
||||
"after_dependencies": ["recorder"],
|
||||
"codeowners": ["@mbillow"],
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
@@ -10,7 +11,7 @@
|
||||
"requirements": [
|
||||
"cbor2>=5.4.6",
|
||||
"pyOpenSSL>=23.0",
|
||||
"smartthings-local>=0.1.2"
|
||||
"smartthings-local>=0.1.8"
|
||||
],
|
||||
"version": "0.21.2"
|
||||
"version": "0.22.0"
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"""Stick-vacuum clean/auto-empty station device registry (issue #131).
|
||||
"""Stick-vacuum clean/auto-empty station device registry (issues #131 / #219).
|
||||
|
||||
See capabilities/vacuum_station.py's module docstring for why this only
|
||||
covers the station's own dustbag/dustbin/UV-sanitize state and not any
|
||||
vacuum-body control (suction, battery, cleaning mode) -- the diagnostics
|
||||
dump this was built from reports none of that.
|
||||
Station dustbag/dustbin/UV-sanitize state plus, when present (VS9700),
|
||||
wand battery/charging via `/status/stick/vs/0`. No suction/room-map control.
|
||||
"""
|
||||
|
||||
from ..capabilities import common, ignored, vacuum_station
|
||||
@@ -20,6 +18,7 @@ REGISTRY = DeviceRegistry(
|
||||
vacuum_station.DUSTBAG_USAGE,
|
||||
vacuum_station.DUSTBIN_SETTING,
|
||||
vacuum_station.CLEANSTATION_STATUS,
|
||||
vacuum_station.STICK_BODY,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -10,18 +10,25 @@ the same dust/fine_dust/super_fine_dust/odor/clean_level keys so this
|
||||
device shares those capabilities' catalog entries. This board additionally
|
||||
reports a CO2 reading the other two families don't.
|
||||
|
||||
A second `value` list element on the particulate-matter types (e.g. Dust's
|
||||
`['31', '2']`) reads like a coarse quality-grade code, but nothing on this
|
||||
board confirms what its scale means -- left unbound rather than guessed;
|
||||
index 0 is the only slot any family has ever read.
|
||||
A second `value` list element on the particulate-matter types (Dust's
|
||||
`['31', '2']`) is the device's own graded air-quality level for that
|
||||
reading -- see common.sensor_item_value. Still unbound here: the grade's
|
||||
floor differs by board family, and CleanLevel already carries the
|
||||
aggregate. This board's own readings are load-bearing evidence for the
|
||||
PM mapping, though: 23 grading one step above the floor as FineDust is
|
||||
what rules out a PM10-width band for that field.
|
||||
|
||||
Dust/FineDust/SuperFineDust aren't assigned an HA `device_class`
|
||||
(pm10/pm25/pm1) or `unit` despite reading like plausible ug/m3 particulate
|
||||
values: Samsung's own two-tier Korean convention maps only to a PM10/PM2.5
|
||||
pair, and this board's three-tier naming doesn't confirm where the extra
|
||||
tier or a PM1 reading fits. A wrong guess would silently mislabel every
|
||||
reading forever, so they're plain `measurement` sensors named after the
|
||||
device's own field instead, matching air_purifier.AIR_QUALITY's precedent.
|
||||
Dust/FineDust/SuperFineDust carry the same HA `device_class`/`unit` as the
|
||||
purifier family (issue #325, Dust=PM10 / FineDust=PM2.5 /
|
||||
SuperFineDust=PM1 in μg/m³). The mapping rests on device-side grading this
|
||||
board shares rather than on anything purifier-specific, so typing one
|
||||
family and not the other would have been an inconsistency, not caution.
|
||||
|
||||
These sensors have recorded *unitless* long-term statistics since issue
|
||||
#210, though, and Home Assistant suppresses statistics generation outright
|
||||
for an entity whose unit no longer matches its recorded metadata -- so
|
||||
stamping a unit on would have silently stopped the history it was meant to
|
||||
label. __init__.py's v2->v3 entry migration relabels that metadata first.
|
||||
"""
|
||||
|
||||
from datetime import time as dt_time
|
||||
@@ -31,12 +38,15 @@ from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc, TimeDesc
|
||||
from .air_purifier import _AIR_QUALITY_SENSORS
|
||||
from .common import int_or_none, sensor_item_value
|
||||
|
||||
# _AIR_QUALITY_SENSORS' fourth column (state_class) is deliberately discarded
|
||||
# here: air_purifier leaves Odor/CleanLevel unstamped because they read as
|
||||
# device_class/unit are taken from the shared rows; state_class deliberately
|
||||
# is not. air_purifier leaves Odor/CleanLevel unstamped because they read as
|
||||
# graded indices on that family, while this board has stamped all five as
|
||||
# `measurement` since it was added (issue #210). Consuming the column would
|
||||
# silently drop long-term statistics for two sensors on shipped devices, so
|
||||
# the shared rows supply only the key/icon/type here.
|
||||
# `measurement` since it was added (issue #210) -- consuming that column
|
||||
# would silently drop long-term statistics for two sensors on shipped
|
||||
# devices. The pm10/pm25/pm1 labels carry over cleanly, though: they rest on
|
||||
# device-side grading this board shares (see the module docstring), and
|
||||
# __init__.py's v2->v3 entry migration relabels the unitless statistics
|
||||
# these five have been recording so the new unit doesn't suppress them.
|
||||
SENSORS = Capability(
|
||||
href="/sensors/vs/0",
|
||||
poll_tier="warm",
|
||||
@@ -47,9 +57,11 @@ SENSORS = Capability(
|
||||
field="x.com.samsung.da.items",
|
||||
icon=icon,
|
||||
state_class="measurement",
|
||||
device_class=device_class,
|
||||
unit=unit,
|
||||
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
|
||||
)
|
||||
for key, icon, sensor_type, _ in _AIR_QUALITY_SENSORS
|
||||
for key, icon, sensor_type, _state_class, device_class, unit in _AIR_QUALITY_SENSORS
|
||||
),
|
||||
SensorDesc(
|
||||
key="co2",
|
||||
|
||||
@@ -51,13 +51,13 @@ def _has_top_level_modes(rep, resources):
|
||||
return isinstance(rep.get("x.com.samsung.da.supportedModes"), (list, tuple))
|
||||
|
||||
|
||||
# The fourth column is state_class, which is what makes Home Assistant keep
|
||||
# long-term statistics for a sensor -- without one, a reading is only in the
|
||||
# short-term recorder history and disappears with the next purge (10 days by
|
||||
# default), so it can't back a long-range air-quality graph. The values are
|
||||
# already numeric (sensor_item_value returns int), so nothing else was in the
|
||||
# way; three sensors in this same module (filter_progress, fan_speed_level,
|
||||
# hepa_filter_usage) already declare one.
|
||||
# Columns: key, icon, device item type, state_class, device_class, unit.
|
||||
# state_class is what makes Home Assistant keep long-term statistics --
|
||||
# without one, a reading is only in the short-term recorder history and
|
||||
# disappears with the next purge (10 days by default), so it can't back a
|
||||
# long-range air-quality graph. The values are already numeric
|
||||
# (sensor_item_value returns int); three sensors in this same module
|
||||
# (filter_progress, fan_speed_level, hepa_filter_usage) already declare one.
|
||||
#
|
||||
# Only the three particulate readings get it. They fall monotonically with
|
||||
# particle size on three independent board families -- 11/9/5 on ARTIK051_TVTL
|
||||
@@ -67,15 +67,40 @@ def _has_top_level_modes(rep, resources):
|
||||
# indices instead, where the mean of a grade isn't obviously meaningful; left
|
||||
# without a state_class rather than guessing.
|
||||
#
|
||||
# Deliberately no device_class/unit here: pm1/pm25/pm10 would assert the
|
||||
# reading is a µg/m³ concentration, and the dumps never say so. That's a
|
||||
# separate call from making the series recordable at all.
|
||||
# device_class/unit: Dust=PM10, FineDust=PM2.5, SuperFineDust=PM1, all
|
||||
# μg/m³ (issue #325). Three independent lines, none of them naming order --
|
||||
# which is what the earlier "plausible but unconfirmed" note rejected:
|
||||
#
|
||||
# 1. The device grades its own readings. Each dust item's value[] is
|
||||
# [concentration, grade] (see common.sensor_item_value); the grade band
|
||||
# is not shared across the three fields -- a reading of 18 grades one
|
||||
# step *above* the floor as SuperFineDust (air_monitor fixture) but *at*
|
||||
# the floor as Dust (range_hood fixture), both 1-based families. So the
|
||||
# firmware itself treats them as three different scales ordered
|
||||
# coarse-to-fine, rather than one repeated measurement.
|
||||
# 2. Where each field's floor/second-band boundary falls brackets the
|
||||
# Korean CAI bands: Dust good at 18, graded up at 31 (CAI PM10 breaks
|
||||
# at 30/31); FineDust good at 14, graded up at 23 (CAI PM2.5 breaks at
|
||||
# 15/16); SuperFineDust good at 9, graded up at 18 (PM2.5-style, which
|
||||
# is what a PM1 reading gets -- there is no standard PM1 index).
|
||||
# 3. A live ARTIK051_TVTL read against the SmartThings app at the same
|
||||
# moment: Dust matched the app's PM10 exactly, the other two were 1
|
||||
# μg/m³ off in the same order, and the app shows exactly these three
|
||||
# tiers, so there is no fourth candidate to assign.
|
||||
#
|
||||
# Dust >= FineDust >= SuperFineDust holds on all 11 fixtures that report
|
||||
# this resource, which is the cumulative-mass ordering PM10 >= PM2.5 >= PM1
|
||||
# requires by definition. The unit literal must stay HA's own spelling of
|
||||
# μg/m³ (U+03BC GREEK SMALL LETTER MU, not U+00B5 MICRO SIGN) -- they render
|
||||
# alike but only U+03BC is in DEVICE_CLASS_UNITS, and the mismatch is a
|
||||
# runtime warning per entity, not a test failure. Pinned by
|
||||
# tests/test_sensor_device_class_units.py.
|
||||
_AIR_QUALITY_SENSORS = (
|
||||
("dust", "mdi:blur", "Dust", "measurement"),
|
||||
("fine_dust", "mdi:blur", "FineDust", "measurement"),
|
||||
("super_fine_dust", "mdi:blur", "SuperFineDust", "measurement"),
|
||||
("odor", "mdi:scent", "Odor", None),
|
||||
("clean_level", "mdi:air-filter", "CleanLevel", None),
|
||||
("dust", "mdi:blur", "Dust", "measurement", "pm10", "μg/m³"),
|
||||
("fine_dust", "mdi:blur", "FineDust", "measurement", "pm25", "μg/m³"),
|
||||
("super_fine_dust", "mdi:blur", "SuperFineDust", "measurement", "pm1", "μg/m³"),
|
||||
("odor", "mdi:scent", "Odor", None, None, None),
|
||||
("clean_level", "mdi:air-filter", "CleanLevel", None, None, None),
|
||||
)
|
||||
|
||||
AIR_QUALITY = Capability(
|
||||
@@ -87,9 +112,11 @@ AIR_QUALITY = Capability(
|
||||
field="x.com.samsung.da.items",
|
||||
icon=icon,
|
||||
state_class=state_class,
|
||||
device_class=device_class,
|
||||
unit=unit,
|
||||
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
|
||||
)
|
||||
for key, icon, sensor_type, state_class in _AIR_QUALITY_SENSORS
|
||||
for key, icon, sensor_type, state_class, device_class, unit in _AIR_QUALITY_SENSORS
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -101,8 +101,15 @@ def _threshold_write(payload, rep, href=None):
|
||||
def _sensor_item_value(items, type_):
|
||||
"""First value of the /sensors/vs/0 item with the given
|
||||
x.com.samsung.da.type. Dust/FineDust/SuperFineDust report a 2-element
|
||||
array; only v[0] is used, since the second element's meaning is
|
||||
unconfirmed. No device_class is set: the resource exposes no unit."""
|
||||
array; only v[0] is used. v[1] is the device's own graded air-quality
|
||||
level for that reading, left unbound because its floor differs by
|
||||
family -- see common.sensor_item_value for the full note.
|
||||
|
||||
No device_class here: unlike air_purifier (issue #325), no AC family
|
||||
has had its dust readings correlated against the app, and every AC
|
||||
fixture reports permanent zeros or ties, so this file's own dumps
|
||||
supply no grade-band evidence either. Returns a string rather than an
|
||||
int, which these diagnostic entities have always done."""
|
||||
for it in items or []:
|
||||
if isinstance(it, dict) and it.get("x.com.samsung.da.type") == type_:
|
||||
v = it.get("x.com.samsung.da.value")
|
||||
@@ -365,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"
|
||||
|
||||
@@ -775,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
|
||||
@@ -1551,12 +1606,10 @@ AIR_QUALITY = Capability(
|
||||
# CO2 (PR #316, ACA-KR-TP2-21-AN9000) -- a type this file's other AC
|
||||
# families don't report. Same field/shape air_monitor.SENSORS
|
||||
# already models with device_class='carbon_dioxide'/unit='ppm', so
|
||||
# this matches that descriptor rather than guessing fresh -- unlike
|
||||
# the pm10/pm25/pm1 mapping air_monitor.py's own docstring
|
||||
# deliberately rejects for the three dust-type keys above (Samsung's
|
||||
# two-tier PM10/PM2.5 convention doesn't confirm where a third tier
|
||||
# or PM1 fits), ppm for a field literally named CO2 isn't a guess of
|
||||
# that kind.
|
||||
# this matches that descriptor rather than guessing fresh. The dust
|
||||
# keys above stay untyped for the reason in _sensor_item_value: the
|
||||
# pm10/pm25/pm1 mapping is confirmed for the purifier and monitor
|
||||
# families (issue #325), but no AC family has evidence of its own.
|
||||
SensorDesc(
|
||||
key="co2",
|
||||
field="x.com.samsung.da.items",
|
||||
|
||||
@@ -249,12 +249,42 @@ def _power_sensor_exists(rep, resources):
|
||||
return not model_allows_power_on_off(resources)
|
||||
|
||||
|
||||
def diagnosis_status(value):
|
||||
"""'Ready' -> the catalog's 'ready'; anything else is left raw.
|
||||
|
||||
Shared by dishwasher and dryer, which report the same field.
|
||||
"""
|
||||
return "ready" if value == "Ready" else value
|
||||
|
||||
|
||||
def sensor_item_value(items, sensor_type, index=0):
|
||||
"""Pull one reading out of a `/sensors/vs/0`-style items[] list -- each
|
||||
item is `{type, value: [...]}`; `index` picks which slot to read
|
||||
(index 0 is the raw measurement on every family seen so far). Shared
|
||||
by range_hood.AIR_QUALITY, air_purifier.AIR_QUALITY, and
|
||||
air_monitor.SENSORS, which all read the same resource shape."""
|
||||
air_monitor.SENSORS, which all read the same resource shape.
|
||||
|
||||
value[] is 2-element on the fields that carry a magnitude
|
||||
(Dust/FineDust/SuperFineDust/CO2) and 1-element on Odor/CleanLevel.
|
||||
That asymmetry is what index 1 means: it is the device's own graded
|
||||
air-quality level for that reading -- the same kind of value Odor and
|
||||
CleanLevel already *are*, which is why those two have no second slot.
|
||||
It reads 0-2 against index 0's observed 0-31, tracks index 0 within a
|
||||
device, and CleanLevel equals the highest per-field grade on 9 of the
|
||||
11 fixtures reporting this resource (the range hood and one RAC report
|
||||
a higher CleanLevel than any dust grade, so they fold in something
|
||||
else).
|
||||
|
||||
Index 1 is deliberately left unbound rather than exposed as an entity:
|
||||
its floor is not portable. ARTIK051_TVTL grades good air as 0, while
|
||||
AVT-WW-TP1 / A-VTWW-TP2 / TP1X / ASM-KR-TP1 / AHD-WW-TP1 all grade it
|
||||
as 1, so a shared descriptor would need a per-family offset to mean
|
||||
anything, and CleanLevel already carries the aggregate. The grade is
|
||||
still load-bearing as *evidence*: it is what confirms the three dust
|
||||
fields are three different scales rather than one repeated reading --
|
||||
see air_purifier._AIR_QUALITY_SENSORS and
|
||||
tests/test_air_quality_grade_column.py.
|
||||
"""
|
||||
for item in items or ():
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
@@ -9,6 +9,7 @@ wash, auto release dry) are read locally here.
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import ButtonDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||
from .common import diagnosis_status
|
||||
from .laundry import bool_option_switch, cycle_select
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -76,6 +77,9 @@ DIAGNOSIS = Capability(
|
||||
field="x.com.samsung.da.diagnosisStart",
|
||||
icon="mdi:stethoscope",
|
||||
entity_category="diagnostic",
|
||||
device_class="enum",
|
||||
options=("ready",),
|
||||
value_fn=diagnosis_status,
|
||||
),
|
||||
ButtonDesc(
|
||||
key="diagnosis_start",
|
||||
|
||||
@@ -11,6 +11,7 @@ the /course/vs/0 cycle select -- lives in laundry.py.
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import SensorDesc, SwitchDesc
|
||||
from .common import diagnosis_status
|
||||
from .laundry import cycle_select, drum_clean_cycles_remaining, drum_clean_last_cleaned
|
||||
|
||||
|
||||
@@ -26,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",
|
||||
@@ -91,7 +102,12 @@ DRYER_DIAGNOSIS = Capability(
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SensorDesc(
|
||||
key="diagnosis", field="x.com.samsung.da.diagnosisStart", entity_category="diagnostic"
|
||||
key="diagnosis",
|
||||
field="x.com.samsung.da.diagnosisStart",
|
||||
entity_category="diagnostic",
|
||||
device_class="enum",
|
||||
options=("ready",),
|
||||
value_fn=diagnosis_status,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ Shared by dryer/dishwasher/oven/washer families.
|
||||
import math
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from ...catalog import translated_states
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, ButtonDesc, NumberDesc, SensorDesc
|
||||
|
||||
@@ -25,7 +26,7 @@ def _to_ocf(v):
|
||||
|
||||
|
||||
def _progress(v):
|
||||
return "Idle" if v in (None, "None") else v
|
||||
return "idle" if v in (None, "None") else str(v).lower()
|
||||
|
||||
|
||||
def _int(v):
|
||||
@@ -201,13 +202,17 @@ OPERATIONAL_STATE = Capability(
|
||||
SensorDesc(
|
||||
key="progress",
|
||||
icon="mdi:progress-wrench",
|
||||
device_class="enum",
|
||||
options=tuple(sorted(translated_states("sensor", "progress"))),
|
||||
rep_fn=lambda rep: (
|
||||
"Idle"
|
||||
"idle"
|
||||
if not _state_is_active(rep)
|
||||
else _progress(rep.get("x.com.samsung.da.progress"))
|
||||
),
|
||||
sticky_fn=_just_finished,
|
||||
sticky_value_fn=lambda rep: "Finish",
|
||||
# The catalog key, not the device's 'Finish': rep_fn is normalized
|
||||
# now, and a held value outside `options` is what HA rejects.
|
||||
sticky_value_fn=lambda rep: "finish",
|
||||
sticky_bypass_fn=_new_cycle_running,
|
||||
),
|
||||
SensorDesc(
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
"""Capabilities for the Samsung stick-vacuum clean/auto-empty station
|
||||
(model A-VSKR-TP1-22-VS9500AL, "Bespoke Jet" clean station, issue #131).
|
||||
(models A-VSKR-TP1-22-VS9500AL / A-VSWW-TP1-23-VS9700, issues #131 / #219).
|
||||
|
||||
The reporter's diagnostics dump shows no vacuum-body state at all (no
|
||||
suction level, no battery, no cleaning-mode/room-mapping control, no
|
||||
docked/undocked status even) -- only the clean station's own dustbag,
|
||||
dustbin auto-empty settings, and UV-C sanitizing-cycle status. This
|
||||
strongly suggests the WiFi/DTLS module lives in the station, not the
|
||||
handheld stick: the station is the only "device" this integration's local
|
||||
API can see, and the wand's own controls are unrelated hardware not
|
||||
reachable this way. Modeled as its own device type -- these hrefs don't
|
||||
overlap with any existing family, so there's no shared-href ambiguity to
|
||||
resolve against another type (see registry/by_type/__init__.py's
|
||||
docstring for that rule).
|
||||
The WiFi/DTLS module lives in the clean station. Older VS9500 dumps
|
||||
(#131) exposed only dustbag/dustbin/UV-C station state. VS9700 dumps
|
||||
(#219) additionally expose `/status/stick/vs/0` with the wand's battery
|
||||
%, cleaning/charging status, and BLE link -- still no suction/room-map
|
||||
control. Modeled as its own device type -- these hrefs don't overlap with
|
||||
any existing family (see registry/by_type/__init__.py's docstring).
|
||||
|
||||
Resources verified against the issue #131 diagnostics dump.
|
||||
Resources verified against issue #131 and #219 diagnostics dumps.
|
||||
"""
|
||||
|
||||
from ..capability import Capability
|
||||
@@ -156,3 +151,43 @@ CLEANSTATION_STATUS = Capability(
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Wand body state reported through the station (VS9700 / issue #219). Absent
|
||||
# on the older VS9500 dump (#131) -- discovery drops entities when the href
|
||||
# is missing.
|
||||
STICK_BODY = Capability(
|
||||
href="/status/stick/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SensorDesc(
|
||||
key="battery",
|
||||
field="x.com.samsung.da.stickbattery",
|
||||
device_class="battery",
|
||||
state_class="measurement",
|
||||
unit="%",
|
||||
value_fn=int_or_none,
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key="battery_charging",
|
||||
field="x.com.samsung.da.stickcleaningstatus",
|
||||
device_class="battery_charging",
|
||||
value_fn=lambda v: v == "Charging",
|
||||
),
|
||||
SensorDesc(
|
||||
key="stick_cleaning_status",
|
||||
field="x.com.samsung.da.stickcleaningstatus",
|
||||
icon="mdi:vacuum",
|
||||
),
|
||||
SensorDesc(
|
||||
key="stick_operation_mode",
|
||||
field="x.com.samsung.da.stickoperationmode",
|
||||
icon="mdi:broom",
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key="stick_ble_connected",
|
||||
field="x.com.samsung.da.stickbleconnection",
|
||||
device_class="connectivity",
|
||||
value_fn=lambda v: v == "On",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -39,9 +39,12 @@ from .laundry import (
|
||||
# editCourseList and screenshots (issue #22, a combo's own course set, not
|
||||
# implying anything about a plain washer's '1F'); 3 more (Eco Cold, Towels,
|
||||
# Self Clean+) verified directly on a WF50A8600AV/US by reading back the raw
|
||||
# code after selecting each cycle on the appliance (issue #80). Two code
|
||||
# pairs ('21'/'65' Colors, '27'/'5E' Rinse+Spin, and '24'/'54' Towels)
|
||||
# legitimately share a label across different course tables -- not typos.
|
||||
# code after selecting each cycle on the appliance (issue #80). 2 more
|
||||
# ('0A' Towels, 'B0' Mixed Load) reported for a WW90DG5G34ABLE on the same
|
||||
# Table_02 family (issue #363). Several codes legitimately share a label
|
||||
# across different course tables -- '21'/'65' Colors, '27'/'5E'/'78'
|
||||
# Rinse+Spin, '0A'/'33'/'54'/'70' Towels -- not typos. (This list said
|
||||
# "'24' Towels" until issue #343 found 24/33 transposed; 24 is Bedding.)
|
||||
#
|
||||
# No static fallback list is kept here: other models have different actual
|
||||
# course sets, so hardcoding one device's list would show/hide the wrong
|
||||
|
||||
@@ -49,8 +49,8 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
|
||||
SensorDeviceClass(desc.device_class) if desc.device_class else None
|
||||
)
|
||||
self._attr_state_class = SensorStateClass(desc.state_class) if desc.state_class else None
|
||||
if desc.options:
|
||||
self._attr_options = list(desc.options)
|
||||
# Always set, so the `options` property below can read it unguarded.
|
||||
self._attr_options = list(desc.options) if desc.options else None
|
||||
self._hysteresis_value = None
|
||||
self._sticky_value = None
|
||||
self._sticky_until: float | None = None
|
||||
@@ -63,6 +63,23 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
|
||||
return desc.unit_fn(self.coordinator.resource(self._bound.href))
|
||||
return self._attr_native_unit_of_measurement
|
||||
|
||||
@property
|
||||
def options(self):
|
||||
"""Declared options, plus whatever this device is actually reporting.
|
||||
|
||||
HA raises for an enum state outside `options`, which would turn any
|
||||
device value we don't have a translation for into a broken entity --
|
||||
the opposite of this registry's rule that an unrecognized value
|
||||
renders raw. Admitting the live value keeps it displayable; it just
|
||||
shows untranslated (PR #341 review).
|
||||
"""
|
||||
if self._attr_options is None:
|
||||
return None
|
||||
value = self.native_value
|
||||
if not isinstance(value, str) or value in self._attr_options:
|
||||
return self._attr_options
|
||||
return [*self._attr_options, value]
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
raw = (self.coordinator.data or {}).get(self._state_key)
|
||||
|
||||
@@ -108,6 +108,9 @@
|
||||
},
|
||||
"softener_low": {
|
||||
"name": "Málo aviváže"
|
||||
},
|
||||
"stick_ble_connected": {
|
||||
"name": "Tyč připojena přes BLE"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
@@ -258,7 +261,11 @@
|
||||
"name": "Zvuk bzučáku",
|
||||
"state": {
|
||||
"off": "Vypnuto",
|
||||
"on": "Zapnuto"
|
||||
"on": "Zapnuto",
|
||||
"volume_off": "Vypnuto",
|
||||
"volume_low": "Nízká",
|
||||
"volume_med": "Střední",
|
||||
"volume_high": "Vysoká"
|
||||
}
|
||||
},
|
||||
"discharging_time": {
|
||||
@@ -643,6 +650,7 @@
|
||||
"04": "Rychlé praní",
|
||||
"06": "XXL prádlo",
|
||||
"08": "Máchání+odstřeďování",
|
||||
"0a": "Ručníky",
|
||||
"17": "Stažený program",
|
||||
"1b": "Bavlna",
|
||||
"1c": "Eco 40-60",
|
||||
@@ -709,6 +717,7 @@
|
||||
"8f": "Intenzivní studená",
|
||||
"96": "Méně mikrovláken",
|
||||
"a0": "15min rychlé praní",
|
||||
"b0": "Smíšená náplň",
|
||||
"35": "Eko bavlna"
|
||||
}
|
||||
},
|
||||
@@ -872,6 +881,12 @@
|
||||
"stick_status": {
|
||||
"name": "Stav tyče"
|
||||
},
|
||||
"stick_operation_mode": {
|
||||
"name": "Provozní režim tyče"
|
||||
},
|
||||
"stick_cleaning_status": {
|
||||
"name": "Stav čištění tyče"
|
||||
},
|
||||
"uvc_operation_time": {
|
||||
"name": "Doba provozu UV-C"
|
||||
},
|
||||
@@ -919,10 +934,16 @@
|
||||
"name": "Teplota"
|
||||
},
|
||||
"diagnosis": {
|
||||
"name": "Diagnostika"
|
||||
"name": "Diagnostika",
|
||||
"state": {
|
||||
"ready": "Připraveno"
|
||||
}
|
||||
},
|
||||
"diagnosis_status": {
|
||||
"name": "Stav diagnostiky"
|
||||
"name": "Stav diagnostiky",
|
||||
"state": {
|
||||
"ready": "Připraveno"
|
||||
}
|
||||
},
|
||||
"drum_clean_cycles_remaining": {
|
||||
"name": "Čištění bubnu za"
|
||||
@@ -937,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"
|
||||
@@ -1066,7 +1090,22 @@
|
||||
"name": "Teplota sondy"
|
||||
},
|
||||
"progress": {
|
||||
"name": "Průběh"
|
||||
"name": "Průběh",
|
||||
"state": {
|
||||
"idle": "Nečinný",
|
||||
"weightsensing": "Detekce náplně",
|
||||
"wash": "Praní",
|
||||
"rinse": "Máchání",
|
||||
"spin": "Odstřeďování",
|
||||
"finish": "Dokončeno",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"progress_percentage": {
|
||||
"name": "Průběh v procentech"
|
||||
@@ -1558,6 +1597,9 @@
|
||||
"command_failed": {
|
||||
"message": "Příkaz pro {href} selhal i po opětovném připojení: {error}"
|
||||
},
|
||||
"debug_read_failed": {
|
||||
"message": "Čtení z {href} selhalo: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "Zadejte 1 až 10 zápisů."
|
||||
},
|
||||
|
||||
@@ -108,6 +108,9 @@
|
||||
},
|
||||
"remote_control": {
|
||||
"name": "Intelligente Steuerung"
|
||||
},
|
||||
"stick_ble_connected": {
|
||||
"name": "Stick per BLE verbunden"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
@@ -258,7 +261,11 @@
|
||||
"name": "Signalton",
|
||||
"state": {
|
||||
"off": "Aus",
|
||||
"on": "Ein"
|
||||
"on": "Ein",
|
||||
"volume_off": "Aus",
|
||||
"volume_low": "Niedrig",
|
||||
"volume_med": "Mittel",
|
||||
"volume_high": "Hoch"
|
||||
}
|
||||
},
|
||||
"discharging_time": {
|
||||
@@ -606,6 +613,7 @@
|
||||
"04": "Schnellwäsche",
|
||||
"06": "XXL-Wäsche",
|
||||
"08": "Spülen + Schleudern",
|
||||
"0a": "Handtücher",
|
||||
"1b": "Baumwolle",
|
||||
"1c": "Eco 40-60",
|
||||
"1d": "Super Speed",
|
||||
@@ -653,6 +661,7 @@
|
||||
"8f": "Kaltwäsche Intensiv",
|
||||
"96": "Weniger Mikrofasern",
|
||||
"a0": "Schnelle Wäsche 15'",
|
||||
"b0": "Gemischte Beladung",
|
||||
"17": "Heruntergeladen",
|
||||
"69": "KI-Wäsche",
|
||||
"6a": "Wolle",
|
||||
@@ -872,6 +881,12 @@
|
||||
"stick_status": {
|
||||
"name": "Stick-Status"
|
||||
},
|
||||
"stick_operation_mode": {
|
||||
"name": "Stick-Betriebsmodus"
|
||||
},
|
||||
"stick_cleaning_status": {
|
||||
"name": "Stick-Reinigungsstatus"
|
||||
},
|
||||
"uvc_operation_time": {
|
||||
"name": "UV-C Betriebszeit"
|
||||
},
|
||||
@@ -913,10 +928,16 @@
|
||||
"name": "Temperatur"
|
||||
},
|
||||
"diagnosis": {
|
||||
"name": "Diagnose"
|
||||
"name": "Diagnose",
|
||||
"state": {
|
||||
"ready": "Bereit"
|
||||
}
|
||||
},
|
||||
"diagnosis_status": {
|
||||
"name": "Diagnosestatus"
|
||||
"name": "Diagnosestatus",
|
||||
"state": {
|
||||
"ready": "Bereit"
|
||||
}
|
||||
},
|
||||
"drum_clean_cycles_remaining": {
|
||||
"name": "Trommelreinigung fällig in"
|
||||
@@ -931,7 +952,10 @@
|
||||
"name": "Trockenzeit"
|
||||
},
|
||||
"dryer_type": {
|
||||
"name": "Trocknertyp"
|
||||
"name": "Trocknertyp",
|
||||
"state": {
|
||||
"electricity": "Strom"
|
||||
}
|
||||
},
|
||||
"dust": {
|
||||
"name": "Staub"
|
||||
@@ -1060,7 +1084,22 @@
|
||||
"name": "Fühlertemperatur"
|
||||
},
|
||||
"progress": {
|
||||
"name": "Fortschritt"
|
||||
"name": "Fortschritt",
|
||||
"state": {
|
||||
"idle": "Leerlauf",
|
||||
"weightsensing": "Beladungserkennung",
|
||||
"wash": "Waschen",
|
||||
"rinse": "Spülen",
|
||||
"spin": "Schleudern",
|
||||
"finish": "Fertig",
|
||||
"steaming": "Dämpfen",
|
||||
"airwashing": "Luftreinigung",
|
||||
"drying": "Trocknen",
|
||||
"dryingwithdooropen": "Lüften",
|
||||
"cooling": "Abkühlen",
|
||||
"predrain": "Abpumpen",
|
||||
"prewash": "Vorwäsche"
|
||||
}
|
||||
},
|
||||
"progress_percentage": {
|
||||
"name": "Fortschritt in Prozent"
|
||||
@@ -1558,6 +1597,9 @@
|
||||
"command_failed": {
|
||||
"message": "Der Befehl an {href} ist auch nach erneutem Verbinden fehlgeschlagen: {error}"
|
||||
},
|
||||
"debug_read_failed": {
|
||||
"message": "Das Lesen von {href} ist fehlgeschlagen: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "Geben Sie zwischen 1 und 10 Schreibvorgänge an."
|
||||
},
|
||||
|
||||
@@ -108,6 +108,9 @@
|
||||
},
|
||||
"softener_low": {
|
||||
"name": "Softener low"
|
||||
},
|
||||
"stick_ble_connected": {
|
||||
"name": "Stick BLE connected"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
@@ -258,7 +261,11 @@
|
||||
"name": "Buzzer sound",
|
||||
"state": {
|
||||
"off": "Off",
|
||||
"on": "On"
|
||||
"on": "On",
|
||||
"volume_off": "Off",
|
||||
"volume_low": "Low",
|
||||
"volume_med": "Medium",
|
||||
"volume_high": "High"
|
||||
}
|
||||
},
|
||||
"discharging_time": {
|
||||
@@ -643,6 +650,7 @@
|
||||
"04": "Quick Wash",
|
||||
"06": "XXL Laundry",
|
||||
"08": "Rinse+Spin",
|
||||
"0a": "Towels",
|
||||
"17": "Downloaded",
|
||||
"1b": "Cotton",
|
||||
"1c": "Eco 40-60",
|
||||
@@ -709,7 +717,8 @@
|
||||
"88": "Pet Care",
|
||||
"8f": "Intense Cold",
|
||||
"96": "Less Microfiber",
|
||||
"a0": "15' Quick Wash"
|
||||
"a0": "15' Quick Wash",
|
||||
"b0": "Mixed Load"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
@@ -872,6 +881,12 @@
|
||||
"stick_status": {
|
||||
"name": "Stick status"
|
||||
},
|
||||
"stick_operation_mode": {
|
||||
"name": "Stick operation mode"
|
||||
},
|
||||
"stick_cleaning_status": {
|
||||
"name": "Stick cleaning status"
|
||||
},
|
||||
"uvc_operation_time": {
|
||||
"name": "UV-C operation time"
|
||||
},
|
||||
@@ -919,10 +934,16 @@
|
||||
"name": "Temperature"
|
||||
},
|
||||
"diagnosis": {
|
||||
"name": "Diagnosis"
|
||||
"name": "Diagnosis",
|
||||
"state": {
|
||||
"ready": "Ready"
|
||||
}
|
||||
},
|
||||
"diagnosis_status": {
|
||||
"name": "Diagnosis status"
|
||||
"name": "Diagnosis status",
|
||||
"state": {
|
||||
"ready": "Ready"
|
||||
}
|
||||
},
|
||||
"drum_clean_cycles_remaining": {
|
||||
"name": "Drum clean due in"
|
||||
@@ -937,7 +958,10 @@
|
||||
"name": "Dry time"
|
||||
},
|
||||
"dryer_type": {
|
||||
"name": "Dryer type"
|
||||
"name": "Dryer type",
|
||||
"state": {
|
||||
"electricity": "Electricity"
|
||||
}
|
||||
},
|
||||
"dust": {
|
||||
"name": "Dust"
|
||||
@@ -1066,7 +1090,22 @@
|
||||
"name": "Probe temperature"
|
||||
},
|
||||
"progress": {
|
||||
"name": "Progress"
|
||||
"name": "Progress",
|
||||
"state": {
|
||||
"idle": "Idle",
|
||||
"weightsensing": "Weight sensing",
|
||||
"wash": "Washing",
|
||||
"rinse": "Rinsing",
|
||||
"spin": "Spinning",
|
||||
"finish": "Finished",
|
||||
"steaming": "Steaming",
|
||||
"airwashing": "Air washing",
|
||||
"drying": "Drying",
|
||||
"dryingwithdooropen": "Venting",
|
||||
"cooling": "Cooling",
|
||||
"predrain": "Pre-drain",
|
||||
"prewash": "Pre-wash"
|
||||
}
|
||||
},
|
||||
"progress_percentage": {
|
||||
"name": "Progress percent"
|
||||
@@ -1558,6 +1597,9 @@
|
||||
"command_failed": {
|
||||
"message": "The command to {href} failed even after reconnecting: {error}"
|
||||
},
|
||||
"debug_read_failed": {
|
||||
"message": "The read from {href} failed: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "Provide between 1 and 10 writes."
|
||||
},
|
||||
|
||||
@@ -173,6 +173,9 @@
|
||||
"command_failed": {
|
||||
"message": "El comando para {href} falló incluso después de reconectar: {error}"
|
||||
},
|
||||
"debug_read_failed": {
|
||||
"message": "La lectura desde {href} falló: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "Proporciona entre 1 y 10 escrituras."
|
||||
},
|
||||
@@ -301,6 +304,9 @@
|
||||
},
|
||||
"auto_clean_running": {
|
||||
"name": "Limpieza automática en curso"
|
||||
},
|
||||
"stick_ble_connected": {
|
||||
"name": "Aspiradora conectada por BLE"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
@@ -451,7 +457,11 @@
|
||||
"name": "Volumen",
|
||||
"state": {
|
||||
"off": "Apagado",
|
||||
"on": "Encendido"
|
||||
"on": "Encendido",
|
||||
"volume_off": "Apagado",
|
||||
"volume_low": "Bajo",
|
||||
"volume_med": "Medio",
|
||||
"volume_high": "Alto"
|
||||
}
|
||||
},
|
||||
"discharging_time": {
|
||||
@@ -836,6 +846,7 @@
|
||||
"04": "Lavado rápido",
|
||||
"06": "Colada XXL",
|
||||
"08": "Aclarar + Centrifugar",
|
||||
"0a": "Toallas",
|
||||
"17": "Descargado",
|
||||
"1b": "Algodón",
|
||||
"1c": "Eco 40-60",
|
||||
@@ -884,6 +895,7 @@
|
||||
"8f": "Lavado en frío",
|
||||
"96": "Menos microfibras",
|
||||
"a0": "Lavado rápido 15'",
|
||||
"b0": "Carga mixta",
|
||||
"69": "Lavado IA",
|
||||
"6a": "Lana",
|
||||
"6b": "Denim",
|
||||
@@ -1062,6 +1074,12 @@
|
||||
"stick_status": {
|
||||
"name": "Estado de la aspiradora"
|
||||
},
|
||||
"stick_operation_mode": {
|
||||
"name": "Modo de funcionamiento de la aspiradora"
|
||||
},
|
||||
"stick_cleaning_status": {
|
||||
"name": "Estado de limpieza de la aspiradora"
|
||||
},
|
||||
"uvc_operation_time": {
|
||||
"name": "Tiempo de funcionamiento UV-C"
|
||||
},
|
||||
@@ -1109,10 +1127,16 @@
|
||||
"name": "Temperatura"
|
||||
},
|
||||
"diagnosis": {
|
||||
"name": "Diagnóstico"
|
||||
"name": "Diagnóstico",
|
||||
"state": {
|
||||
"ready": "Listo"
|
||||
}
|
||||
},
|
||||
"diagnosis_status": {
|
||||
"name": "Estado del diagnóstico"
|
||||
"name": "Estado del diagnóstico",
|
||||
"state": {
|
||||
"ready": "Listo"
|
||||
}
|
||||
},
|
||||
"drum_clean_cycles_remaining": {
|
||||
"name": "Limpieza de tambor en"
|
||||
@@ -1127,7 +1151,10 @@
|
||||
"name": "Tiempo de secado"
|
||||
},
|
||||
"dryer_type": {
|
||||
"name": "Tipo de secadora"
|
||||
"name": "Tipo de secadora",
|
||||
"state": {
|
||||
"electricity": "Electricidad"
|
||||
}
|
||||
},
|
||||
"dust": {
|
||||
"name": "Polvo"
|
||||
@@ -1256,7 +1283,22 @@
|
||||
"name": "Temperatura de la sonda"
|
||||
},
|
||||
"progress": {
|
||||
"name": "Progreso"
|
||||
"name": "Progreso",
|
||||
"state": {
|
||||
"idle": "Inactiva",
|
||||
"weightsensing": "Detección de carga",
|
||||
"wash": "Lavado",
|
||||
"rinse": "Aclarado",
|
||||
"spin": "Centrifugado",
|
||||
"finish": "Finalizado",
|
||||
"steaming": "Vaporización",
|
||||
"airwashing": "Lavado con aire",
|
||||
"drying": "Secado",
|
||||
"dryingwithdooropen": "Ventilación",
|
||||
"cooling": "Enfriamiento",
|
||||
"predrain": "Drenaje previo",
|
||||
"prewash": "Prelavado"
|
||||
}
|
||||
},
|
||||
"progress_percentage": {
|
||||
"name": "Porcentaje de progreso"
|
||||
|
||||
@@ -108,6 +108,9 @@
|
||||
},
|
||||
"softener_low": {
|
||||
"name": "Aggiungi ammorbidente"
|
||||
},
|
||||
"stick_ble_connected": {
|
||||
"name": "Scopa elettrica connessa via BLE"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
@@ -258,7 +261,11 @@
|
||||
"name": "Suono cicalino",
|
||||
"state": {
|
||||
"off": "Spento",
|
||||
"on": "Acceso"
|
||||
"on": "Acceso",
|
||||
"volume_off": "Spento",
|
||||
"volume_low": "Basso",
|
||||
"volume_med": "Medio",
|
||||
"volume_high": "Alto"
|
||||
}
|
||||
},
|
||||
"discharging_time": {
|
||||
@@ -643,6 +650,7 @@
|
||||
"04": "Lavaggio rapido",
|
||||
"06": "Bucato XXL",
|
||||
"08": "Risciacquo+Centrifuga",
|
||||
"0a": "Asciugamani",
|
||||
"17": "Scaricato",
|
||||
"1b": "Cotone",
|
||||
"1c": "Eco 40-60",
|
||||
@@ -709,7 +717,8 @@
|
||||
"79": "Solo centrifuga",
|
||||
"88": "Cura animali",
|
||||
"35": "Cotone E",
|
||||
"a0": "Rapido 15'"
|
||||
"a0": "Rapido 15'",
|
||||
"b0": "Carico misto"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
@@ -872,6 +881,12 @@
|
||||
"stick_status": {
|
||||
"name": "Stato scopa elettrica"
|
||||
},
|
||||
"stick_operation_mode": {
|
||||
"name": "Modalità operativa scopa elettrica"
|
||||
},
|
||||
"stick_cleaning_status": {
|
||||
"name": "Stato pulizia scopa elettrica"
|
||||
},
|
||||
"uvc_operation_time": {
|
||||
"name": "Tempo funzionamento UV-C"
|
||||
},
|
||||
@@ -919,10 +934,16 @@
|
||||
"name": "Temperatura"
|
||||
},
|
||||
"diagnosis": {
|
||||
"name": "Diagnosi"
|
||||
"name": "Diagnosi",
|
||||
"state": {
|
||||
"ready": "Pronto"
|
||||
}
|
||||
},
|
||||
"diagnosis_status": {
|
||||
"name": "Stato diagnosi"
|
||||
"name": "Stato diagnosi",
|
||||
"state": {
|
||||
"ready": "Pronto"
|
||||
}
|
||||
},
|
||||
"drum_clean_cycles_remaining": {
|
||||
"name": "Pulizia cestello fra"
|
||||
@@ -937,7 +958,10 @@
|
||||
"name": "Tempo di asciugatura"
|
||||
},
|
||||
"dryer_type": {
|
||||
"name": "Tipo di asciugatrice"
|
||||
"name": "Tipo di asciugatrice",
|
||||
"state": {
|
||||
"electricity": "Elettricità"
|
||||
}
|
||||
},
|
||||
"dust": {
|
||||
"name": "Polvere"
|
||||
@@ -1066,7 +1090,22 @@
|
||||
"name": "Temperatura sonda"
|
||||
},
|
||||
"progress": {
|
||||
"name": "Avanzamento"
|
||||
"name": "Avanzamento",
|
||||
"state": {
|
||||
"idle": "Inattivo",
|
||||
"weightsensing": "Rilevamento del carico",
|
||||
"wash": "Lavaggio",
|
||||
"rinse": "Risciacquo",
|
||||
"spin": "Centrifuga",
|
||||
"finish": "Completato",
|
||||
"steaming": "Vapore",
|
||||
"airwashing": "Lavaggio ad aria",
|
||||
"drying": "Asciugatura",
|
||||
"dryingwithdooropen": "Ventilazione",
|
||||
"cooling": "Raffreddamento",
|
||||
"predrain": "Scarico preliminare",
|
||||
"prewash": "Prelavaggio"
|
||||
}
|
||||
},
|
||||
"progress_percentage": {
|
||||
"name": "Avanzamento percentuale"
|
||||
@@ -1558,6 +1597,9 @@
|
||||
"command_failed": {
|
||||
"message": "Il comando per {href} è fallito anche dopo la riconnessione: {error}"
|
||||
},
|
||||
"debug_read_failed": {
|
||||
"message": "La lettura da {href} è fallita: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "Specificare da 1 a 10 scritture."
|
||||
},
|
||||
|
||||
@@ -108,6 +108,9 @@
|
||||
},
|
||||
"softener_low": {
|
||||
"name": "섬유유연제 부족"
|
||||
},
|
||||
"stick_ble_connected": {
|
||||
"name": "스틱 BLE 연결됨"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
@@ -258,7 +261,11 @@
|
||||
"name": "부저음",
|
||||
"state": {
|
||||
"off": "끄기",
|
||||
"on": "켜기"
|
||||
"on": "켜기",
|
||||
"volume_off": "끔",
|
||||
"volume_low": "낮음",
|
||||
"volume_med": "중간",
|
||||
"volume_high": "높음"
|
||||
}
|
||||
},
|
||||
"discharging_time": {
|
||||
@@ -643,6 +650,7 @@
|
||||
"04": "쾌속세탁",
|
||||
"06": "XXL 세탁",
|
||||
"08": "헹굼+탈수",
|
||||
"0a": "타월",
|
||||
"17": "다운로드 코스",
|
||||
"1b": "면",
|
||||
"1c": "에코 40-60",
|
||||
@@ -691,6 +699,7 @@
|
||||
"8f": "강력 냉수 세탁",
|
||||
"96": "미세플라스틱저감",
|
||||
"a0": "15분 쾌속세탁",
|
||||
"b0": "혼합 세탁",
|
||||
"69": "AI 맞춤세탁",
|
||||
"6a": "울",
|
||||
"6b": "데님",
|
||||
@@ -872,6 +881,12 @@
|
||||
"stick_status": {
|
||||
"name": "스틱 청소기 상태"
|
||||
},
|
||||
"stick_operation_mode": {
|
||||
"name": "스틱 작동 모드"
|
||||
},
|
||||
"stick_cleaning_status": {
|
||||
"name": "스틱 청소 상태"
|
||||
},
|
||||
"uvc_operation_time": {
|
||||
"name": "UV-C 작동 시간"
|
||||
},
|
||||
@@ -919,10 +934,16 @@
|
||||
"name": "온도"
|
||||
},
|
||||
"diagnosis": {
|
||||
"name": "진단"
|
||||
"name": "진단",
|
||||
"state": {
|
||||
"ready": "준비됨"
|
||||
}
|
||||
},
|
||||
"diagnosis_status": {
|
||||
"name": "진단 상태"
|
||||
"name": "진단 상태",
|
||||
"state": {
|
||||
"ready": "준비됨"
|
||||
}
|
||||
},
|
||||
"drum_clean_cycles_remaining": {
|
||||
"name": "통세척까지 남은 횟수"
|
||||
@@ -937,7 +958,10 @@
|
||||
"name": "건조 시간"
|
||||
},
|
||||
"dryer_type": {
|
||||
"name": "건조기 유형"
|
||||
"name": "건조기 유형",
|
||||
"state": {
|
||||
"electricity": "전기"
|
||||
}
|
||||
},
|
||||
"dust": {
|
||||
"name": "미세먼지"
|
||||
@@ -1066,7 +1090,22 @@
|
||||
"name": "탐침 온도계 현재 온도"
|
||||
},
|
||||
"progress": {
|
||||
"name": "진행률"
|
||||
"name": "진행률",
|
||||
"state": {
|
||||
"idle": "대기",
|
||||
"weightsensing": "세탁물 감지",
|
||||
"wash": "세탁",
|
||||
"rinse": "헹굼",
|
||||
"spin": "탈수",
|
||||
"finish": "완료",
|
||||
"steaming": "스팀",
|
||||
"airwashing": "에어워시",
|
||||
"drying": "건조",
|
||||
"dryingwithdooropen": "환기",
|
||||
"cooling": "냉각",
|
||||
"predrain": "사전 배수",
|
||||
"prewash": "애벌빨래"
|
||||
}
|
||||
},
|
||||
"progress_percentage": {
|
||||
"name": "진행률"
|
||||
@@ -1558,6 +1597,9 @@
|
||||
"command_failed": {
|
||||
"message": "재연결 후에도 {href} 명령이 실패했습니다: {error}"
|
||||
},
|
||||
"debug_read_failed": {
|
||||
"message": "{href}에서 읽기가 실패했습니다: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "1~10개의 쓰기를 지정하세요."
|
||||
},
|
||||
|
||||
@@ -108,6 +108,9 @@
|
||||
},
|
||||
"softener_low": {
|
||||
"name": "Wasverzachter bijna op"
|
||||
},
|
||||
"stick_ble_connected": {
|
||||
"name": "Steel via BLE verbonden"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
@@ -258,7 +261,11 @@
|
||||
"name": "Zoemergeluid",
|
||||
"state": {
|
||||
"off": "Uit",
|
||||
"on": "Aan"
|
||||
"on": "Aan",
|
||||
"volume_off": "Uit",
|
||||
"volume_low": "Laag",
|
||||
"volume_med": "Gemiddeld",
|
||||
"volume_high": "Hoog"
|
||||
}
|
||||
},
|
||||
"discharging_time": {
|
||||
@@ -643,6 +650,7 @@
|
||||
"04": "Snelle was",
|
||||
"06": "XXL was",
|
||||
"08": "Spoelen+centrifugeren",
|
||||
"0a": "Handdoeken",
|
||||
"17": "Gedownload",
|
||||
"1b": "Katoen",
|
||||
"1c": "Eco 40-60",
|
||||
@@ -709,6 +717,7 @@
|
||||
"8f": "Intensief koud",
|
||||
"96": "Minder microvezels",
|
||||
"a0": "15' Snelle was",
|
||||
"b0": "Gemengde was",
|
||||
"35": "Eco katoen"
|
||||
}
|
||||
},
|
||||
@@ -872,6 +881,12 @@
|
||||
"stick_status": {
|
||||
"name": "Status steel"
|
||||
},
|
||||
"stick_operation_mode": {
|
||||
"name": "Bedieningsmodus steel"
|
||||
},
|
||||
"stick_cleaning_status": {
|
||||
"name": "Schoonmaakstatus steel"
|
||||
},
|
||||
"uvc_operation_time": {
|
||||
"name": "UV-C-bedrijfstijd"
|
||||
},
|
||||
@@ -919,10 +934,16 @@
|
||||
"name": "Temperatuur"
|
||||
},
|
||||
"diagnosis": {
|
||||
"name": "Diagnose"
|
||||
"name": "Diagnose",
|
||||
"state": {
|
||||
"ready": "Gereed"
|
||||
}
|
||||
},
|
||||
"diagnosis_status": {
|
||||
"name": "Diagnosestatus"
|
||||
"name": "Diagnosestatus",
|
||||
"state": {
|
||||
"ready": "Gereed"
|
||||
}
|
||||
},
|
||||
"drum_clean_cycles_remaining": {
|
||||
"name": "Trommelreiniging over"
|
||||
@@ -937,7 +958,10 @@
|
||||
"name": "Droogtijd"
|
||||
},
|
||||
"dryer_type": {
|
||||
"name": "Type droger"
|
||||
"name": "Type droger",
|
||||
"state": {
|
||||
"electricity": "Elektriciteit"
|
||||
}
|
||||
},
|
||||
"dust": {
|
||||
"name": "Stof"
|
||||
@@ -1066,7 +1090,22 @@
|
||||
"name": "Sondetemperatuur"
|
||||
},
|
||||
"progress": {
|
||||
"name": "Voortgang"
|
||||
"name": "Voortgang",
|
||||
"state": {
|
||||
"idle": "Inactief",
|
||||
"weightsensing": "Beladingsdetectie",
|
||||
"wash": "Wassen",
|
||||
"rinse": "Spoelen",
|
||||
"spin": "Centrifugeren",
|
||||
"finish": "Voltooid",
|
||||
"steaming": "Stomen",
|
||||
"airwashing": "Luchtreiniging",
|
||||
"drying": "Drogen",
|
||||
"dryingwithdooropen": "Ventileren",
|
||||
"cooling": "Koelen",
|
||||
"predrain": "Vooraf afpompen",
|
||||
"prewash": "Voorwas"
|
||||
}
|
||||
},
|
||||
"progress_percentage": {
|
||||
"name": "Voortgangspercentage"
|
||||
@@ -1558,6 +1597,9 @@
|
||||
"command_failed": {
|
||||
"message": "Het commando naar {href} is ook na opnieuw verbinden mislukt: {error}"
|
||||
},
|
||||
"debug_read_failed": {
|
||||
"message": "Het lezen van {href} is mislukt: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "Geef tussen de 1 en 10 schrijfacties op."
|
||||
},
|
||||
|
||||
@@ -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.2
|
||||
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",
|
||||
|
||||
@@ -238,6 +238,11 @@ class FakeSession:
|
||||
|
||||
instances: ClassVar[list[FakeSession]] = []
|
||||
reject_certs: ClassVar[set[str]] = set()
|
||||
# smartthings-local >= 0.1.3 ("redacted typed failures") no longer puts
|
||||
# the alert in connect()'s exception text -- set True to model that, so
|
||||
# a test can check the diagnostic-handshake fallback (_resolve_alert)
|
||||
# instead of the legacy _alert_name text-parsing path.
|
||||
redact_rejection: ClassVar[bool] = False
|
||||
|
||||
def __init__(self, host, port, cert_pem=None, key_pem=None, **kwargs):
|
||||
self.host, self.port, self.cert_pem = host, port, cert_pem
|
||||
@@ -245,6 +250,10 @@ class FakeSession:
|
||||
|
||||
def connect(self):
|
||||
if self.cert_pem in FakeSession.reject_certs:
|
||||
if FakeSession.redact_rejection:
|
||||
from smartthings_local.errors import SessionError
|
||||
|
||||
raise SessionError()
|
||||
raise ConnectionError(
|
||||
"DTLS handshake error: [('SSL routines', '', 'sslv3 alert bad certificate')]"
|
||||
)
|
||||
@@ -268,6 +277,7 @@ def fake_dtls(monkeypatch):
|
||||
|
||||
FakeSession.instances = []
|
||||
FakeSession.reject_certs = set()
|
||||
FakeSession.redact_rejection = False
|
||||
monkeypatch.setattr(config_flow, "_fetch_samsung_uuid", lambda: "test-uuid")
|
||||
monkeypatch.setattr(
|
||||
config_flow,
|
||||
@@ -480,6 +490,77 @@ async def test_rejected_reused_leaf_is_reminted(
|
||||
assert [s.cert_pem for s in FakeSession.instances] == [MOCK_LEAF_CERT_PEM, "FULLCHAIN"]
|
||||
|
||||
|
||||
async def test_rejected_reused_leaf_is_reminted_against_a_redacted_library(
|
||||
hass: HomeAssistant, monkeypatch, fake_dtls
|
||||
) -> None:
|
||||
"""Same flow as test_rejected_reused_leaf_is_reminted, but against a
|
||||
connect() failure shaped like smartthings-local >= 0.1.3 -- a fixed,
|
||||
redacted exception with no alert text at all (see errors.py's
|
||||
"Classified errors"). The re-mint decision has to come from
|
||||
_resolve_alert's diagnostic-handshake fallback instead of _alert_name."""
|
||||
from custom_components.localthings import config_flow
|
||||
|
||||
existing = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id="localthings_other")
|
||||
existing.add_to_hass(hass)
|
||||
_patch_clienthello(monkeypatch, {49154})
|
||||
FakeSession.reject_certs = {MOCK_LEAF_CERT_PEM}
|
||||
FakeSession.redact_rejection = True
|
||||
|
||||
class _DiagnosticResult:
|
||||
alert = (2, "bad_certificate")
|
||||
|
||||
diagnosed: list[int] = []
|
||||
|
||||
def _diagnostic_alert(host, port, cert_pem, key_pem):
|
||||
diagnosed.append(port)
|
||||
return _DiagnosticResult()
|
||||
|
||||
monkeypatch.setattr(config_flow, "_diagnostic_alert", _diagnostic_alert)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_HOST: MOCK_HOST}
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result["data"][CONF_LEAF_CERT_PEM] == "FULLCHAIN"
|
||||
assert [s.cert_pem for s in FakeSession.instances] == [MOCK_LEAF_CERT_PEM, "FULLCHAIN"]
|
||||
assert diagnosed == [49154]
|
||||
|
||||
|
||||
def test_diagnostic_handshake_runs_once_not_once_per_failing_port(monkeypatch) -> None:
|
||||
"""_diagnostic_alert commits association state on the device (see its
|
||||
own docstring) -- running it once per failing candidate instead of once
|
||||
overall would both add latency (each is its own bounded handshake) and
|
||||
multiply that pollution right before _probe_and_validate might retry a
|
||||
real handshake against these very same ports. Three candidates fail
|
||||
here; the diagnostic must run exactly once, against the confirmed-live
|
||||
port, not three times against every candidate in scan order."""
|
||||
from custom_components.localthings import config_flow
|
||||
|
||||
FakeSession.instances = []
|
||||
FakeSession.reject_certs = {MOCK_LEAF_CERT_PEM}
|
||||
FakeSession.redact_rejection = True
|
||||
monkeypatch.setattr("smartthings_local.protocol.dtls_session.DtlsCoapSession", FakeSession)
|
||||
|
||||
diagnosed: list[int] = []
|
||||
|
||||
class _DiagnosticResult:
|
||||
alert = (2, "bad_certificate")
|
||||
|
||||
def _diagnostic_alert(host, port, cert_pem, key_pem):
|
||||
diagnosed.append(port)
|
||||
return _DiagnosticResult()
|
||||
|
||||
monkeypatch.setattr(config_flow, "_diagnostic_alert", _diagnostic_alert)
|
||||
|
||||
scan = _scan(confirmed=[49153, 49154], candidates=[49153, 49154, 49155])
|
||||
with pytest.raises(config_flow.CertRejected):
|
||||
config_flow._handshake_and_read(MOCK_HOST, scan, MOCK_LEAF_CERT_PEM, "KEY")
|
||||
|
||||
assert diagnosed == [49153] # confirmed-live, and only once
|
||||
|
||||
|
||||
async def test_unconfirmed_port_failure_is_not_reminted(
|
||||
hass: HomeAssistant, monkeypatch, fake_dtls
|
||||
) -> None:
|
||||
@@ -554,6 +635,92 @@ def test_cert_alert_is_reported_as_a_certificate_problem() -> None:
|
||||
assert err.error_key == "cert_rejected"
|
||||
|
||||
|
||||
def test_classify_handshake_failure_uses_a_resolved_alert_over_exception_text() -> None:
|
||||
"""_handshake_and_read passes its own resolved `alerts` mapping (built
|
||||
via _resolve_alert, which is what actually classifies a failure against
|
||||
smartthings-local >= 0.1.3's redacted exceptions) -- it must win even
|
||||
when the exception text itself says nothing."""
|
||||
from custom_components.localthings.config_flow import (
|
||||
CertRejected,
|
||||
_classify_handshake_failure,
|
||||
)
|
||||
|
||||
err = _classify_handshake_failure(
|
||||
MOCK_HOST,
|
||||
_scan(confirmed=[49154]),
|
||||
[(49154, RuntimeError("session operation failed"))],
|
||||
{49154: "bad_certificate"},
|
||||
)
|
||||
assert isinstance(err, CertRejected)
|
||||
assert err.error_key == "cert_rejected"
|
||||
|
||||
|
||||
def test_resolve_alert_prefers_exception_text_over_the_diagnostic_handshake() -> None:
|
||||
"""A library still stamping the alert into its exception text (< 0.1.3)
|
||||
answers for free; the diagnostic handshake must not run at all then."""
|
||||
from custom_components.localthings.config_flow import _resolve_alert
|
||||
|
||||
def _must_not_run(*args, **kwargs):
|
||||
raise AssertionError("must not run the diagnostic handshake")
|
||||
|
||||
with patch("custom_components.localthings.config_flow._diagnostic_alert", _must_not_run):
|
||||
name = _resolve_alert(
|
||||
_openssl_alert("tlsv1 alert unknown ca"), MOCK_HOST, 49154, "CERT", "KEY"
|
||||
)
|
||||
assert name == "unknown_ca"
|
||||
|
||||
|
||||
def test_resolve_alert_falls_back_to_the_diagnostic_handshake() -> None:
|
||||
"""smartthings-local >= 0.1.3 redacts the exception text (see errors.py's
|
||||
"Classified errors"), so the only way left to learn *why* a handshake
|
||||
failed is the library's own classification of the raw alert record."""
|
||||
from smartthings_local.errors import SessionError
|
||||
|
||||
from custom_components.localthings import config_flow
|
||||
|
||||
class _Result:
|
||||
alert = (2, "bad_certificate")
|
||||
|
||||
with patch.object(config_flow, "_diagnostic_alert", lambda *a, **k: _Result()):
|
||||
name = config_flow._resolve_alert(SessionError(), MOCK_HOST, 49154, "CERT", "KEY")
|
||||
assert name == "bad_certificate"
|
||||
|
||||
|
||||
def test_resolve_alert_ignores_a_non_fatal_alert() -> None:
|
||||
"""ProbeResult.alert is set for a *received* alert of either level, but
|
||||
only a fatal one (2) means the appliance actually broke off the
|
||||
handshake over it -- a warning-level alert (e.g. close_notify on an
|
||||
otherwise ordinary close) is not evidence of a rejection. The old
|
||||
exception-text path never had this ambiguity: OpenSSL's exception only
|
||||
ever rendered for a fatal alert, so nothing pre-0.1.3 could confuse the
|
||||
two -- the diagnostic-handshake fallback must not introduce the mix-up."""
|
||||
from smartthings_local.errors import SessionError
|
||||
|
||||
from custom_components.localthings import config_flow
|
||||
|
||||
class _Result:
|
||||
alert = (1, "close_notify") # warning level, not fatal
|
||||
|
||||
with patch.object(config_flow, "_diagnostic_alert", lambda *a, **k: _Result()):
|
||||
name = config_flow._resolve_alert(SessionError(), MOCK_HOST, 49154, "CERT", "KEY")
|
||||
assert name is None
|
||||
|
||||
|
||||
def test_resolve_alert_is_none_when_the_diagnostic_handshake_also_fails() -> None:
|
||||
"""A best-effort extra probe: its own failure must not raise out of
|
||||
_resolve_alert, it just leaves the caller with no alert to report."""
|
||||
from smartthings_local.errors import SessionError, SessionTimeoutError
|
||||
|
||||
from custom_components.localthings import config_flow
|
||||
|
||||
def _boom(*args, **kwargs):
|
||||
raise SessionTimeoutError()
|
||||
|
||||
with patch.object(config_flow, "_diagnostic_alert", _boom):
|
||||
name = config_flow._resolve_alert(SessionError(), MOCK_HOST, 49154, "CERT", "KEY")
|
||||
assert name is None
|
||||
|
||||
|
||||
def test_non_cert_alert_is_kept_distinct_from_a_cert_problem() -> None:
|
||||
"""A cipher or version mismatch is also a deliberate refusal, but no
|
||||
amount of fiddling with CA credentials will fix it."""
|
||||
|
||||
@@ -13,6 +13,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from smartthings_local.errors import SessionClosedError, SessionError, SessionTimeoutError
|
||||
|
||||
from custom_components.localthings.const import (
|
||||
CONF_BYPASS_REMOTE_CONTROL,
|
||||
@@ -70,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"),
|
||||
@@ -531,6 +537,36 @@ async def test_total_poll_failure_downgrades_observe_mode_to_poll(
|
||||
assert coordinator.last_update_success is True
|
||||
|
||||
|
||||
def test_defer_reconnect_for_reconnects_immediately_on_a_confirmed_dead_session(
|
||||
hass: HomeAssistant, mock_entry
|
||||
) -> None:
|
||||
"""smartthings-local >= 0.1.6 raises SessionClosedError -- a
|
||||
ConnectionError, not a TimeoutError -- the moment a dead reader thread
|
||||
is confirmed, instead of the old behavior of letting the request hang
|
||||
out to its own timeout and surface as an ambiguous TimeoutError.
|
||||
_defer_reconnect_for must never extend the block-ACK tolerance to a
|
||||
failure this unambiguous; see its docstring for the full reasoning."""
|
||||
coordinator = LocalThingsCoordinator(hass, mock_entry)
|
||||
coordinator._discovered = True
|
||||
|
||||
assert coordinator._defer_reconnect_for(SessionClosedError()) is False
|
||||
|
||||
|
||||
def test_defer_reconnect_for_still_tolerates_an_ambiguous_timeout(
|
||||
hass: HomeAssistant, mock_entry
|
||||
) -> None:
|
||||
"""The other half of the same distinction: a plain block-ACK timeout --
|
||||
still a TimeoutError, including smartthings-local's own
|
||||
SessionTimeoutError subclass -- keeps its multi-cycle tolerance rather
|
||||
than being swept into the immediate-reconnect path above."""
|
||||
coordinator = LocalThingsCoordinator(hass, mock_entry)
|
||||
coordinator._discovered = True
|
||||
|
||||
for _ in range(coordinator._POLL_TIMEOUT_LIMIT - 1):
|
||||
assert coordinator._defer_reconnect_for(SessionTimeoutError()) is True
|
||||
assert coordinator._defer_reconnect_for(SessionTimeoutError()) is False
|
||||
|
||||
|
||||
async def test_poll_timeout_skips_reconnect_when_push_is_healthy(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
@@ -786,6 +822,38 @@ async def test_attempt_observe_mode_discards_stale_commit_after_session_swap(
|
||||
assert coordinator._resubscribe_due is True
|
||||
|
||||
|
||||
async def test_attempt_observe_mode_survives_a_failed_reconnect(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""The session was closed out from under this attempt concurrently
|
||||
(rare, but real -- see the docstring above), and the reconnect it tries
|
||||
on the way back in fails too (smartthings-local >= 0.1.3's redacted
|
||||
SessionError, or any other exception). That must not escape
|
||||
_async_update_data uncaught: it should land in the same "give up on
|
||||
push this cycle" state the subscribe-failed and stale-session branches
|
||||
already produce, not skip this integration's own logging/state handling
|
||||
entirely."""
|
||||
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]
|
||||
coordinator._session = None
|
||||
coordinator._reconnect_times = []
|
||||
|
||||
with patch.object(
|
||||
coordinator,
|
||||
"_connect_session",
|
||||
side_effect=SessionError(),
|
||||
):
|
||||
await coordinator._attempt_observe_mode() # must not raise
|
||||
|
||||
assert coordinator.observe_mode == MODE_POLL
|
||||
assert coordinator._observe.subscribed_hrefs == set()
|
||||
assert coordinator._resubscribe_due is False
|
||||
# Not the poll path's own reconnect-frequency window (see the fix's
|
||||
# comment) -- this failure must not count toward it.
|
||||
assert coordinator._reconnect_times == []
|
||||
|
||||
|
||||
async def test_maybe_retry_observe_mode_uses_most_recent_attempt_not_just_mode_change(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
@@ -1761,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(
|
||||
|
||||
@@ -38,7 +38,9 @@ async def test_migration_recovers_serial_from_unique_id(
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.version == 2
|
||||
# Straight through to the current version: v2 -> v3 is a statistics
|
||||
# relabel that no-ops for a family without particulate sensors.
|
||||
assert entry.version == 3
|
||||
assert entry.data[CONF_SERIAL] == MOCK_SERIAL
|
||||
|
||||
|
||||
@@ -257,7 +259,7 @@ async def test_migration_rejects_a_future_entry_version(hass: HomeAssistant) ->
|
||||
written by a newer release."""
|
||||
from custom_components.localthings import async_migrate_entry
|
||||
|
||||
entry = MockConfigEntry(domain=DOMAIN, data=LEGACY_ENTRY_DATA, version=3)
|
||||
entry = MockConfigEntry(domain=DOMAIN, data=LEGACY_ENTRY_DATA, version=4)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
assert await async_migrate_entry(hass, entry) is False
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,221 @@
|
||||
"""The v2 -> v3 entry migration that relabels particulate statistics.
|
||||
|
||||
Dust/FineDust/SuperFineDust gained a pm10/pm25/pm1 device_class and a
|
||||
µg/m³ unit (issue #325) after having recorded long-term statistics with no
|
||||
unit at all. Home Assistant treats that as a unit change it cannot convert
|
||||
and *suppresses statistics generation* for the entity until a human
|
||||
resolves the repair, so the metadata is corrected during migration instead.
|
||||
|
||||
Only the metadata row is touched, never the recorded values -- the readings
|
||||
were always µg/m³, so there is nothing to convert.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.localthings import async_migrate_entry
|
||||
from custom_components.localthings.const import CONF_DEVICE_TYPE, DOMAIN
|
||||
|
||||
from .conftest import ENTRY_DATA, MOCK_SERIAL
|
||||
|
||||
RELABEL = "homeassistant.components.recorder.statistics.async_update_statistics_metadata"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _recorder_loaded(hass: HomeAssistant):
|
||||
"""Most tests here assume a normal install, where after_dependencies has
|
||||
pulled the recorder in. The deferral test below undoes it."""
|
||||
hass.config.components.add("recorder")
|
||||
return hass
|
||||
|
||||
|
||||
def _entry(hass: HomeAssistant, device_type: str) -> MockConfigEntry:
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={**ENTRY_DATA, CONF_DEVICE_TYPE: device_type},
|
||||
unique_id=f"{DOMAIN}_{MOCK_SERIAL}",
|
||||
version=2,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
return entry
|
||||
|
||||
|
||||
def _add_sensor(hass: HomeAssistant, entry: MockConfigEntry, key: str, **kwargs):
|
||||
return er.async_get(hass).async_get_or_create(
|
||||
"sensor",
|
||||
DOMAIN,
|
||||
f"{DOMAIN}_{MOCK_SERIAL}_{key}",
|
||||
config_entry=entry,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
async def test_relabels_every_particulate_sensor(hass: HomeAssistant) -> None:
|
||||
entry = _entry(hass, "air_purifier")
|
||||
expected = {
|
||||
_add_sensor(hass, entry, key).entity_id for key in ("dust", "fine_dust", "super_fine_dust")
|
||||
}
|
||||
|
||||
with patch(RELABEL, autospec=True) as relabel:
|
||||
assert await async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert {call.args[1] for call in relabel.call_args_list} == expected
|
||||
for call in relabel.call_args_list:
|
||||
assert call.kwargs["new_unit_of_measurement"] == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
|
||||
# µg/m³ has a converter, so the class must be named, not None --
|
||||
# passing neither is deprecated and breaks in HA Core 2026.11.
|
||||
assert call.kwargs["new_unit_class"] == "concentration"
|
||||
assert entry.version == 3
|
||||
|
||||
|
||||
async def test_leaves_other_sensors_on_the_same_device_alone(hass: HomeAssistant) -> None:
|
||||
"""Odor/CleanLevel/CO2 share the resource but keep the units they had."""
|
||||
entry = _entry(hass, "air_monitor")
|
||||
dust = _add_sensor(hass, entry, "dust")
|
||||
for key in ("odor", "clean_level", "co2", "dustbag_usage", "dustbin_auto_close"):
|
||||
_add_sensor(hass, entry, key)
|
||||
|
||||
with patch(RELABEL, autospec=True) as relabel:
|
||||
assert await async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert [call.args[1] for call in relabel.call_args_list] == [dust.entity_id]
|
||||
|
||||
|
||||
async def test_skips_families_that_did_not_gain_the_unit(hass: HomeAssistant) -> None:
|
||||
"""range_hood and airconditioner still declare no unit for their
|
||||
identically-named sensors. Relabelling their statistics would assert a
|
||||
unit those entities don't report -- creating the very mismatch this
|
||||
migration exists to prevent."""
|
||||
for device_type in ("range_hood", "airconditioner"):
|
||||
entry = _entry(hass, device_type)
|
||||
_add_sensor(hass, entry, "dust")
|
||||
_add_sensor(hass, entry, "fine_dust")
|
||||
|
||||
with patch(RELABEL, autospec=True) as relabel:
|
||||
assert await async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert relabel.call_args_list == [], device_type
|
||||
assert entry.version == 3
|
||||
|
||||
|
||||
async def test_defers_rather_than_consuming_the_migration_without_the_recorder(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""A boot where the recorder didn't come up must not burn the one-shot
|
||||
migration -- doing so would leave the statistics suppressed for good.
|
||||
The entry stays on v2 so the next start retries."""
|
||||
entry = _entry(hass, "air_purifier")
|
||||
_add_sensor(hass, entry, "dust")
|
||||
|
||||
hass.config.components.remove("recorder")
|
||||
with patch(RELABEL, autospec=True) as relabel:
|
||||
assert await async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert relabel.call_args_list == []
|
||||
assert entry.version == 2
|
||||
|
||||
# ...and the retry lands once the recorder is there.
|
||||
hass.config.components.add("recorder")
|
||||
with patch(RELABEL, autospec=True) as relabel:
|
||||
assert await async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert len(relabel.call_args_list) == 1
|
||||
assert entry.version == 3
|
||||
|
||||
|
||||
async def test_omits_unit_class_on_an_older_home_assistant(hass: HomeAssistant) -> None:
|
||||
"""`new_unit_class` only exists from HA 2025.11, and hacs.json still
|
||||
declares 2025.1 as the minimum. Passing it to the older signature is a
|
||||
TypeError out of async_migrate_entry, which fails the whole entry -- so
|
||||
the kwarg is feature-detected rather than assumed.
|
||||
|
||||
Stands in for an older HA by patching in that exact signature; the
|
||||
autospec'd tests above cover the modern one.
|
||||
"""
|
||||
entry = _entry(hass, "air_purifier")
|
||||
_add_sensor(hass, entry, "dust")
|
||||
|
||||
seen: list[dict] = []
|
||||
|
||||
def old_signature(hass, statistic_id, *, new_statistic_id=None, **kwargs):
|
||||
seen.append(kwargs)
|
||||
|
||||
with patch(RELABEL, old_signature):
|
||||
assert await async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert seen == [{"new_unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}]
|
||||
assert entry.version == 3
|
||||
|
||||
|
||||
async def test_a_relabel_failure_never_fails_the_entry(hass: HomeAssistant) -> None:
|
||||
"""Relabelling is a convenience -- without it the user gets HA's own
|
||||
units_changed repair, which is where they were before. An older HA whose
|
||||
async_update_statistics_metadata has a different signature, or any other
|
||||
recorder-side surprise, must not cost them the integration."""
|
||||
entry = _entry(hass, "air_purifier")
|
||||
_add_sensor(hass, entry, "dust")
|
||||
|
||||
with patch(RELABEL, autospec=True, side_effect=TypeError("older HA signature")):
|
||||
assert await async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert entry.version == 3
|
||||
|
||||
|
||||
async def test_follows_a_renamed_entity_rather_than_rebuilding_its_id(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""statistic_id is the entity_id, which the user can rename. Matching the
|
||||
unique_id tail and reading entity_id back off the registry is what keeps
|
||||
this correct for a renamed sensor -- reconstructing an entity_id from the
|
||||
descriptor key would relabel a statistic nobody is recording."""
|
||||
entry = _entry(hass, "air_purifier")
|
||||
renamed = _add_sensor(hass, entry, "dust", suggested_object_id="living_room_pm10")
|
||||
assert renamed.entity_id == "sensor.living_room_pm10"
|
||||
|
||||
with patch(RELABEL, autospec=True) as relabel:
|
||||
assert await async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert [call.args[1] for call in relabel.call_args_list] == ["sensor.living_room_pm10"]
|
||||
|
||||
|
||||
async def test_matches_subdevice_prefixed_and_instanced_keys(hass: HomeAssistant) -> None:
|
||||
"""_key() can prefix a subdevice and append an instance number, so the
|
||||
match is on the tail rather than the whole unique_id. The instance form
|
||||
is `_<n>` (discovery.instance_suffix), not a bare digit."""
|
||||
entry = _entry(hass, "air_purifier")
|
||||
ent_reg = er.async_get(hass)
|
||||
for unique_suffix in ("indoor_0_dust", "fine_dust_1", "super_fine_dust"):
|
||||
ent_reg.async_get_or_create(
|
||||
"sensor",
|
||||
DOMAIN,
|
||||
f"{DOMAIN}_{MOCK_SERIAL}_{unique_suffix}",
|
||||
config_entry=entry,
|
||||
)
|
||||
# Near-misses that must not match.
|
||||
for unique_suffix in ("dustbag_full", "dustbin_auto_close", "dust_filter_reset"):
|
||||
ent_reg.async_get_or_create(
|
||||
"sensor",
|
||||
DOMAIN,
|
||||
f"{DOMAIN}_{MOCK_SERIAL}_{unique_suffix}",
|
||||
config_entry=entry,
|
||||
)
|
||||
|
||||
with patch(RELABEL, autospec=True) as relabel:
|
||||
assert await async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert len(relabel.call_args_list) == 3
|
||||
|
||||
|
||||
async def test_a_fresh_entry_starts_at_the_migrated_version(hass: HomeAssistant) -> None:
|
||||
"""A newly created entry has no statistics to relabel, so the config flow
|
||||
mints v3 directly rather than walking through the migration."""
|
||||
from custom_components.localthings.config_flow import LocalThingsConfigFlow
|
||||
|
||||
assert LocalThingsConfigFlow.VERSION == 3
|
||||
@@ -30,30 +30,54 @@ def test_graded_sensors_are_left_without_a_state_class():
|
||||
assert _desc(key).state_class is None, key
|
||||
|
||||
|
||||
def test_no_unit_or_device_class_is_asserted():
|
||||
"""state_class alone makes the series recordable. pm1/pm25/pm10 with
|
||||
µg/m³ would additionally assert the reading is a mass concentration,
|
||||
which no dump states."""
|
||||
for key in PARTICULATE + GRADED:
|
||||
def test_particulate_sensors_declare_pm_device_class_and_unit():
|
||||
"""Dust/FineDust/SuperFineDust map to PM10/PM2.5/PM1 (issue #325) -- see
|
||||
air_purifier._AIR_QUALITY_SENSORS for the three lines of evidence and
|
||||
tests/test_air_quality_grade_column.py for the device-side ones.
|
||||
|
||||
The expected unit comes from HA's own constant rather than a literal:
|
||||
typing it out is how PR #365 landed U+00B5 MICRO SIGN where HA uses
|
||||
U+03BC, which renders identically and would make this test agree with
|
||||
the bug."""
|
||||
from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER as UG_M3
|
||||
|
||||
expected = {
|
||||
"dust": ("pm10", UG_M3),
|
||||
"fine_dust": ("pm25", UG_M3),
|
||||
"super_fine_dust": ("pm1", UG_M3),
|
||||
}
|
||||
for key, (device_class, unit) in expected.items():
|
||||
desc = _desc(key)
|
||||
assert desc.device_class == device_class, key
|
||||
assert desc.unit == unit, key
|
||||
for key in GRADED:
|
||||
desc = _desc(key)
|
||||
assert desc.unit is None, key
|
||||
assert desc.device_class is None, key
|
||||
assert desc.unit is None, key
|
||||
|
||||
|
||||
def test_state_class_comes_from_the_shared_tuples_fourth_column():
|
||||
"""The rows carry their own state_class rather than a parallel lookup, so
|
||||
a new sensor can't be added here without deciding the question."""
|
||||
def test_metadata_comes_from_the_shared_tuples_own_columns():
|
||||
"""The rows carry their own state_class/device_class/unit rather than a
|
||||
parallel lookup, so a new sensor can't be added here without deciding
|
||||
each question. Unit validity against HA is a separate guard --
|
||||
tests/test_sensor_device_class_units.py."""
|
||||
for row in air_purifier._AIR_QUALITY_SENSORS:
|
||||
assert len(row) == 4, row
|
||||
assert len(row) == 6, row
|
||||
assert row[3] in ("measurement", None), row
|
||||
assert row[4] in ("pm10", "pm25", "pm1", None), row
|
||||
# A device_class without a unit would leave HA inferring one.
|
||||
assert (row[4] is None) == (row[5] is None), row
|
||||
|
||||
|
||||
def test_air_monitor_keeps_stamping_every_shared_sensor():
|
||||
"""air_monitor imports _AIR_QUALITY_SENSORS and discards the fourth column
|
||||
on purpose: that board (issue #210) has stamped all five as `measurement`
|
||||
since it was added, and consuming the column would silently drop long-term
|
||||
statistics for Odor/CleanLevel there. Guards the import end to end and the
|
||||
deliberate divergence together."""
|
||||
def test_air_monitor_takes_the_pm_labels_but_not_the_state_class():
|
||||
"""air_monitor imports _AIR_QUALITY_SENSORS and consumes device_class and
|
||||
unit -- the mapping rests on device-side grading that board shares (issue
|
||||
#325), so typing one family and not the other would be an inconsistency.
|
||||
|
||||
state_class is still discarded: that board (issue #210) has stamped all
|
||||
five as `measurement` since it was added, and consuming the column would
|
||||
silently drop long-term statistics for Odor/CleanLevel there. Guards the
|
||||
import end to end and the deliberate divergence together."""
|
||||
from custom_components.localthings.registry.capabilities import air_monitor
|
||||
|
||||
assert air_monitor.SENSORS.href == "/sensors/vs/0"
|
||||
@@ -62,6 +86,14 @@ def test_air_monitor_keeps_stamping_every_shared_sensor():
|
||||
d for d in air_monitor.SENSORS.entities if d.key == key and isinstance(d, SensorDesc)
|
||||
)
|
||||
assert desc.state_class == "measurement", key
|
||||
assert desc.device_class == _desc(key).device_class, key
|
||||
assert desc.unit == _desc(key).unit, key
|
||||
# And the graded pair stays untyped on both families.
|
||||
for key in GRADED:
|
||||
desc = next(
|
||||
d for d in air_monitor.SENSORS.entities if d.key == key and isinstance(d, SensorDesc)
|
||||
)
|
||||
assert (desc.device_class, desc.unit) == (None, None), key
|
||||
|
||||
|
||||
def test_every_air_quality_sensor_still_reads_a_plain_int():
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""What the second element of a /sensors/vs/0 dust reading means, and why
|
||||
it is what confirms Dust/FineDust/SuperFineDust are PM10/PM2.5/PM1.
|
||||
|
||||
`x.com.samsung.da.value` is `[concentration, grade]` on the fields that
|
||||
carry a magnitude and `[grade]` on Odor/CleanLevel, which are grades
|
||||
already. Index 1 is never bound to an entity (its floor differs by board
|
||||
family), but it is the device's own opinion about its own readings, and
|
||||
that makes it the one piece of evidence for the PM mapping that doesn't
|
||||
depend on Samsung's field names or on a user's screenshot.
|
||||
|
||||
These assertions read the shipped fixtures rather than restating numbers,
|
||||
so a re-captured dump that contradicts the mapping fails here instead of
|
||||
silently weakening the argument in air_purifier.py's comment.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
FIXTURES = pathlib.Path(__file__).parent / "fixtures"
|
||||
DUST_TYPES = ("Dust", "FineDust", "SuperFineDust")
|
||||
|
||||
|
||||
def _items(fixture: str):
|
||||
dump = json.loads((FIXTURES / f"{fixture}_device.json").read_text(encoding="utf-8"))
|
||||
for entry in dump["device0"]:
|
||||
if entry.get("href") == "/sensors/vs/0":
|
||||
return {
|
||||
item.get("x.com.samsung.da.type"): item.get("x.com.samsung.da.value")
|
||||
for item in entry.get("rep", {}).get("x.com.samsung.da.items") or []
|
||||
}
|
||||
raise AssertionError(f"{fixture} has no /sensors/vs/0")
|
||||
|
||||
|
||||
def _fixtures_reporting_sensors():
|
||||
for path in sorted(FIXTURES.glob("*_device.json")):
|
||||
dump = json.loads(path.read_text(encoding="utf-8"))
|
||||
entries = dump.get("device0")
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
if any(e.get("href") == "/sensors/vs/0" for e in entries):
|
||||
name = path.name.removesuffix("_device.json")
|
||||
if any(t in _items(name) for t in DUST_TYPES):
|
||||
yield name
|
||||
|
||||
|
||||
def test_magnitude_fields_carry_a_grade_and_graded_fields_do_not():
|
||||
"""The shape asymmetry is the whole argument for what index 1 is: the
|
||||
fields that already *are* grades have no second slot."""
|
||||
checked = 0
|
||||
for fixture in _fixtures_reporting_sensors():
|
||||
items = _items(fixture)
|
||||
for type_ in (*DUST_TYPES, "CO2"):
|
||||
if type_ in items:
|
||||
assert len(items[type_]) == 2, (fixture, type_, items[type_])
|
||||
checked += 1
|
||||
for type_ in ("Odor", "CleanLevel"):
|
||||
if type_ in items:
|
||||
assert len(items[type_]) == 1, (fixture, type_, items[type_])
|
||||
assert checked >= 30
|
||||
|
||||
|
||||
def test_concentration_falls_with_particle_size_on_every_fixture():
|
||||
"""PM10 >= PM2.5 >= PM1 by definition -- they are cumulative masses, so
|
||||
a violation would mean the three fields aren't nested size tiers at
|
||||
all."""
|
||||
for fixture in _fixtures_reporting_sensors():
|
||||
items = _items(fixture)
|
||||
if not all(t in items for t in DUST_TYPES):
|
||||
continue
|
||||
coarse, fine, finest = (int(items[t][0]) for t in DUST_TYPES)
|
||||
assert coarse >= fine >= finest, (fixture, coarse, fine, finest)
|
||||
|
||||
|
||||
def test_the_same_reading_grades_differently_as_dust_than_as_superfinedust():
|
||||
"""18 is one step above the grade floor as SuperFineDust but sits *at*
|
||||
the floor as Dust, on two families that both grade good air as 1.
|
||||
|
||||
One shared threshold cannot produce both, so the firmware treats the
|
||||
coarse field as tolerating more than the fine one -- three scales
|
||||
ordered coarse-to-fine, which is what PM10/PM2.5/PM1 requires and what
|
||||
"all three are the same kind of reading" cannot explain.
|
||||
"""
|
||||
monitor, hood = _items("air_monitor"), _items("range_hood")
|
||||
assert monitor["SuperFineDust"] == ["18", "2"]
|
||||
assert hood["Dust"] == ["18", "1"]
|
||||
# Both families put good air at grade 1, so the two grades are
|
||||
# comparable -- ARTIK051_TVTL's 0-based floor is the reason this
|
||||
# comparison is drawn between these two fixtures and not against it.
|
||||
assert monitor["Odor"] == ["1"]
|
||||
assert hood["CleanLevel"] == ["2"]
|
||||
assert _items("air_purifier")["Dust"] == ["11", "0"]
|
||||
|
||||
|
||||
def test_grade_boundaries_bracket_the_korean_cai_bands():
|
||||
"""Where each field crosses from its floor to the next grade lines up
|
||||
with the band that field's PM tier is graded on in Korea's CAI:
|
||||
PM10 breaks at 30/31, PM2.5 at 15/16. A PM1 reading has no standard
|
||||
index and is graded on PM2.5-like widths.
|
||||
"""
|
||||
monitor, hood = _items("air_monitor"), _items("range_hood")
|
||||
# Dust: still at the floor at 18, above it at 31 -> boundary in (18, 31].
|
||||
assert (hood["Dust"], monitor["Dust"]) == (["18", "1"], ["31", "2"])
|
||||
# FineDust: at the floor at 14, above it at 23 -> boundary in (14, 23].
|
||||
assert (hood["FineDust"], monitor["FineDust"]) == (["14", "1"], ["23", "2"])
|
||||
# SuperFineDust: at the floor at 9, above it at 18 -> boundary in (9, 18],
|
||||
# strictly below where Dust's sits.
|
||||
assert (hood["SuperFineDust"], monitor["SuperFineDust"]) == (["9", "1"], ["18", "2"])
|
||||
|
||||
|
||||
def test_clean_level_aggregates_the_per_field_grades():
|
||||
"""CleanLevel is the highest per-field grade on every family except the
|
||||
range hood and one RAC, which report a higher CleanLevel than any dust
|
||||
grade -- those two fold in something this resource doesn't expose, so
|
||||
CleanLevel is never derived from the dust grades in code."""
|
||||
exceptions = {"range_hood", "airconditioner_tp1x_da_ac_rac_01011"}
|
||||
for fixture in _fixtures_reporting_sensors():
|
||||
items = _items(fixture)
|
||||
if "CleanLevel" not in items:
|
||||
continue
|
||||
grades = [int(v[1]) for v in items.values() if len(v) == 2]
|
||||
if not grades:
|
||||
continue
|
||||
aggregate = int(items["CleanLevel"][0])
|
||||
if fixture in exceptions:
|
||||
assert aggregate > max(grades), (fixture, aggregate, grades)
|
||||
else:
|
||||
assert aggregate == max(grades), (fixture, aggregate, grades)
|
||||
|
||||
|
||||
def test_grade_floor_is_zero_based_on_artik051_tvtl_and_one_based_elsewhere():
|
||||
"""Why index 1 stays unbound: a shared descriptor would need a
|
||||
per-family offset to mean anything."""
|
||||
assert _items("air_purifier")["CleanLevel"] == ["0"]
|
||||
for fixture in ("air_monitor", "air_purifier_avt_ww", "air_purifier_vtww", "range_hood"):
|
||||
assert int(_items(fixture)["CleanLevel"][0]) >= 1, fixture
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Regression tests for a handful of call sites in coordinator.py that used
|
||||
to let a smartthings-local exception (EndpointError, SessionError,
|
||||
SessionTimeoutError, SessionClosedError, ... -- or the equivalent bare
|
||||
ConnectionError/TimeoutError/OSError an older library version raised) escape
|
||||
uncaught instead of going through this integration's own reconnect/logging
|
||||
or getting translated into a HomeAssistantError for a service caller.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from homeassistant.core import HomeAssistant
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.localthings.const import (
|
||||
CONF_HOST,
|
||||
CONF_LEAF_CERT_PEM,
|
||||
CONF_LEAF_KEY_PEM,
|
||||
CONF_PORT,
|
||||
DOMAIN,
|
||||
)
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
|
||||
ENTRY_DATA = {
|
||||
CONF_HOST: "10.0.0.198",
|
||||
CONF_PORT: 49154,
|
||||
CONF_LEAF_CERT_PEM: "-----BEGIN CERTIFICATE-----\nTEST-LEAF\n-----END CERTIFICATE-----",
|
||||
CONF_LEAF_KEY_PEM: "-----BEGIN PRIVATE KEY-----\nTEST-LEAF-KEY\n-----END PRIVATE KEY-----",
|
||||
}
|
||||
|
||||
|
||||
def _coordinator(hass: HomeAssistant) -> LocalThingsCoordinator:
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data=ENTRY_DATA,
|
||||
unique_id="localthings_ERRHANDLING-TEST",
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
return LocalThingsCoordinator(hass, entry)
|
||||
|
||||
|
||||
async def test_subdevice_enumeration_failure_does_not_abort_first_discovery(
|
||||
hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""_enumerate_subdevices_blocking's own _connect_session() call only
|
||||
fires if the session the poll above just used got closed out from under
|
||||
it within the same cycle -- rare, but until this fix, unguarded: an
|
||||
exception there escaped _async_update_data entirely instead of going
|
||||
through this integration's own logging, matching what already happens
|
||||
for the main poll's own reconnect.
|
||||
|
||||
Not a one-cycle blip once caught, though: `_discovered` flips True this
|
||||
same cycle regardless (gating first discovery, not subdevice success),
|
||||
so this is the *only* attempt a composite appliance's siblings ever get
|
||||
without a config-entry reload -- logged at warning for exactly that
|
||||
reason, not debug.
|
||||
|
||||
Empty resources keep _run_discovery from binding anything (hot/warm
|
||||
hrefs stay empty), so _attempt_observe_mode's own session touch never
|
||||
runs either -- this test is purely about the enumeration failure not
|
||||
escaping _async_update_data.
|
||||
"""
|
||||
coordinator = _coordinator(hass)
|
||||
monkeypatch.setattr(coordinator, "_poll_once", dict)
|
||||
|
||||
def _boom(_resources):
|
||||
raise ConnectionError("session closed")
|
||||
|
||||
monkeypatch.setattr(coordinator, "_enumerate_subdevices_blocking", _boom)
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
result = await coordinator._async_update_data()
|
||||
|
||||
assert coordinator._discovered is True
|
||||
assert result == {}
|
||||
assert "subdevice enumeration failed" in caplog.text
|
||||
@@ -6,7 +6,7 @@ check the dishwasher wiring and its device-specific options.
|
||||
"""
|
||||
|
||||
from custom_components.localthings.registry.capabilities import dishwasher
|
||||
from custom_components.localthings.registry.entities import SwitchDesc
|
||||
from custom_components.localthings.registry.entities import SensorDesc, SwitchDesc
|
||||
|
||||
|
||||
class TestCycleOptions:
|
||||
@@ -60,3 +60,14 @@ class TestDishwasherOptions:
|
||||
assert desc.exists_fn is not None
|
||||
assert desc.exists_fn({"x.com.samsung.da.options": []}, {}) is False
|
||||
assert desc.exists_fn({"x.com.samsung.da.options": ["AutoDoorRelease_On"]}, {}) is True
|
||||
|
||||
|
||||
def test_diagnosis_status_is_a_translatable_enum():
|
||||
desc = next(
|
||||
e
|
||||
for e in dishwasher.DIAGNOSIS.entities
|
||||
if e.key == "diagnosis_status" and isinstance(e, SensorDesc)
|
||||
)
|
||||
assert desc.device_class == "enum"
|
||||
assert desc.options == ("ready",)
|
||||
assert desc.value_fn("Ready") == "ready"
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -5,7 +5,7 @@ from custom_components.localthings.registry.capabilities.operational import (
|
||||
_just_finished,
|
||||
_new_cycle_running,
|
||||
)
|
||||
from custom_components.localthings.registry.entities import NumberDesc
|
||||
from custom_components.localthings.registry.entities import NumberDesc, SensorDesc
|
||||
|
||||
|
||||
def test_machine_state_maps_samsung_to_ocf():
|
||||
@@ -83,6 +83,21 @@ class TestNewCycleRunning:
|
||||
)
|
||||
|
||||
|
||||
def test_progress_is_a_translatable_enum():
|
||||
desc = next(
|
||||
e for e in OPERATIONAL_STATE.entities if e.key == "progress" and isinstance(e, SensorDesc)
|
||||
)
|
||||
assert desc.device_class == "enum"
|
||||
assert desc.options is not None
|
||||
assert "rinse" in desc.options
|
||||
assert "Rinse" not in desc.options
|
||||
assert desc.rep_fn is not None
|
||||
assert (
|
||||
desc.rep_fn({"x.com.samsung.da.state": "Run", "x.com.samsung.da.progress": "Rinse"})
|
||||
== "rinse"
|
||||
)
|
||||
|
||||
|
||||
class TestProgressPercentage:
|
||||
"""issue #9: device firmware leaves progressPercentage stale (e.g. '1')
|
||||
after a cycle ends instead of resetting it, so it must be gated on
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import ClassVar, cast
|
||||
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
from custom_components.localthings.registry.capabilities.laundry import (
|
||||
BUZZER_SOUND,
|
||||
cycle_select,
|
||||
washer_cycle_fallback,
|
||||
)
|
||||
@@ -47,6 +48,25 @@ def test_options_field_unaffected():
|
||||
assert entity.options == ["Lo", "Hi"]
|
||||
|
||||
|
||||
def test_buzzer_volume_options_normalize_to_translation_keys():
|
||||
desc = next(e for e in BUZZER_SOUND.entities if e.key == "buzzer_sound")
|
||||
entity = _make_select(
|
||||
desc,
|
||||
"/buzzersound/vs/0",
|
||||
{
|
||||
"/buzzersound/vs/0": {
|
||||
"supportedBuzzerSound": [
|
||||
"Volume_Off",
|
||||
"Volume_Low",
|
||||
"Volume_Med",
|
||||
"Volume_High",
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
assert entity.options == ["volume_off", "volume_low", "volume_med", "volume_high"]
|
||||
|
||||
|
||||
def test_callable_options_receives_full_resource_snapshot():
|
||||
"""A callable options is handed the coordinator's full href->rep
|
||||
snapshot, not just this entity's own href's rep -- needed for course
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Guards against a SensorDesc unit Home Assistant won't accept for the
|
||||
device_class it's paired with.
|
||||
|
||||
Unlike the SwitchDesc case (issue #349), a bad sensor unit doesn't raise --
|
||||
sensor.py hands `unit` to `_attr_native_unit_of_measurement` and HA only
|
||||
logs a warning per entity, once, telling the user to report a bug against
|
||||
this integration. So the failure mode is a quiet stream of "not a valid
|
||||
unit for the device class" warnings plus a support burden, with nothing in
|
||||
the UI to hint anything is wrong.
|
||||
|
||||
The specific trap this exists for: HA spells its micrograms-per-cubic-metre
|
||||
unit with U+03BC GREEK SMALL LETTER MU, and DEVICE_CLASS_UNITS holds only
|
||||
that spelling. U+00B5 MICRO SIGN renders identically in an editor, in a
|
||||
terminal, and in a code review diff, but is a different string and fails
|
||||
the membership test. PR #365 shipped all three particulate units with
|
||||
U+00B5.
|
||||
|
||||
Mirrors test_switch_device_class.py: scans every by_type registry rather
|
||||
than a fixture, so a new capability making the same mistake fails here.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import pkgutil
|
||||
|
||||
from homeassistant.components.sensor.const import DEVICE_CLASS_UNITS, SensorDeviceClass
|
||||
|
||||
from custom_components.localthings.registry import by_type
|
||||
from custom_components.localthings.registry.entities import SensorDesc
|
||||
|
||||
|
||||
def _all_registries():
|
||||
for mod_info in pkgutil.iter_modules(by_type.__path__):
|
||||
if mod_info.name.startswith("_"):
|
||||
continue
|
||||
mod = importlib.import_module(
|
||||
f"custom_components.localthings.registry.by_type.{mod_info.name}"
|
||||
)
|
||||
reg = getattr(mod, "REGISTRY", None)
|
||||
if reg is not None:
|
||||
yield reg
|
||||
|
||||
|
||||
def _sensor_descs():
|
||||
seen = set()
|
||||
for reg in _all_registries():
|
||||
caps = [c for cs in reg.capabilities.values() for c in cs] + list(reg.pattern_capabilities)
|
||||
for cap in caps:
|
||||
for entity in cap.entities:
|
||||
if isinstance(entity, SensorDesc) and (reg.name, entity.key) not in seen:
|
||||
seen.add((reg.name, entity.key))
|
||||
yield reg.name, entity
|
||||
|
||||
|
||||
def test_every_sensordesc_device_class_is_valid_for_ha():
|
||||
bad = []
|
||||
for reg_name, desc in _sensor_descs():
|
||||
if desc.device_class is None:
|
||||
continue
|
||||
try:
|
||||
SensorDeviceClass(desc.device_class)
|
||||
except ValueError:
|
||||
bad.append((reg_name, desc.key, desc.device_class))
|
||||
assert bad == []
|
||||
|
||||
|
||||
def test_every_declared_unit_is_valid_for_its_device_class():
|
||||
"""Descriptors carrying a `unit_fn` are exempt: those resolve their unit
|
||||
from the live rep (a device reporting Celsius vs Fahrenheit), so there
|
||||
is no static value to check here."""
|
||||
bad = []
|
||||
for reg_name, desc in _sensor_descs():
|
||||
if desc.device_class is None or desc.unit_fn is not None:
|
||||
continue
|
||||
units = DEVICE_CLASS_UNITS.get(SensorDeviceClass(desc.device_class))
|
||||
if units is not None and desc.unit not in units:
|
||||
bad.append(
|
||||
(reg_name, desc.key, desc.device_class, desc.unit, sorted(str(u) for u in units))
|
||||
)
|
||||
assert bad == []
|
||||
|
||||
|
||||
def test_particulate_units_use_has_own_mu_codepoint():
|
||||
"""The membership test above already fails on U+00B5, but only while a
|
||||
PM device_class is attached. Asserting the codepoint directly keeps the
|
||||
reason legible when someone re-types the literal."""
|
||||
from custom_components.localthings.registry.capabilities import air_purifier
|
||||
|
||||
micro_sign, greek_mu = chr(0x00B5), chr(0x03BC)
|
||||
for _key, _icon, _type, _state_class, device_class, unit in air_purifier._AIR_QUALITY_SENSORS:
|
||||
if device_class is None:
|
||||
continue
|
||||
assert unit is not None, device_class
|
||||
assert unit == f"{greek_mu}g/m³", (device_class, [hex(ord(c)) for c in unit])
|
||||
assert micro_sign not in unit, device_class
|
||||
@@ -0,0 +1,115 @@
|
||||
"""An enum sensor's reported state must always be inside its options.
|
||||
|
||||
Home Assistant raises for an enum sensor whose state isn't in `options`
|
||||
(sensor/__init__.py: "provides state value ... which is not in the list of
|
||||
options provided"), so a value outside the list isn't a cosmetic problem --
|
||||
it takes the entity out.
|
||||
|
||||
Two ways that bites, both from PR #341 giving `progress` a `device_class`
|
||||
of enum:
|
||||
|
||||
- the sticky hold (issue #345) froze the entity at the device's raw
|
||||
'Finish' while `rep_fn` had been normalized to 'finish', so every
|
||||
completed cycle -- the exact path #345 exists to serve -- produced a
|
||||
state outside the options;
|
||||
- any progress value not in the translation catalog. Every token the
|
||||
shipped fixtures advertise is covered today, but this registry's rule is
|
||||
that an unrecognized device value renders raw rather than breaking, and
|
||||
Samsung ships more devices than we have dumps for.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import cast
|
||||
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
from custom_components.localthings.registry.entities import SensorDesc
|
||||
from custom_components.localthings.sensor import LocalThingsSensor
|
||||
|
||||
_HREF = "/operational/state/vs/0"
|
||||
_PROGRESS = next(
|
||||
e for e in OPERATIONAL_STATE.entities if e.key == "progress" and isinstance(e, SensorDesc)
|
||||
)
|
||||
_ALL_BOUND = [
|
||||
BoundEntity(href=_HREF, capability=OPERATIONAL_STATE, desc=desc)
|
||||
for desc in OPERATIONAL_STATE.entities
|
||||
]
|
||||
|
||||
|
||||
class _FakeConfigEntry:
|
||||
def __init__(self):
|
||||
self.options: dict = {}
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
def __init__(self):
|
||||
self.device_serial = "TEST-SERIAL"
|
||||
self.config_entry = _FakeConfigEntry()
|
||||
self.resources: dict[str, dict] = {}
|
||||
|
||||
def resource(self, href: str) -> dict:
|
||||
return self.resources.get(href) or {}
|
||||
|
||||
@property
|
||||
def data(self) -> dict:
|
||||
return flatten(_ALL_BOUND, self.resources)
|
||||
|
||||
|
||||
def _sensor(desc):
|
||||
coordinator = _FakeCoordinator()
|
||||
bound = BoundEntity(href=_HREF, capability=OPERATIONAL_STATE, desc=desc)
|
||||
return LocalThingsSensor(cast(LocalThingsCoordinator, coordinator), bound), coordinator
|
||||
|
||||
|
||||
def _set(coordinator, **fields):
|
||||
coordinator.resources[_HREF] = {f"x.com.samsung.da.{k}": v for k, v in fields.items()}
|
||||
|
||||
|
||||
def test_the_sticky_hold_freezes_at_a_value_inside_the_options():
|
||||
"""Issue #345's grace window fires on every finished cycle, so a held
|
||||
value outside the options would break the common path, not an edge."""
|
||||
sensor, coordinator = _sensor(_PROGRESS)
|
||||
|
||||
_set(coordinator, state="Run", progress="Wash")
|
||||
assert sensor.native_value == "wash"
|
||||
|
||||
# Cycle finishes, then the device drops out of active -- the hold engages.
|
||||
_set(coordinator, state="Run", progress="Finish")
|
||||
assert sensor.native_value in sensor.options
|
||||
_set(coordinator, state="Ready", progress="Finish")
|
||||
held = sensor.native_value
|
||||
assert held == "finish"
|
||||
assert held in sensor.options
|
||||
|
||||
|
||||
def test_a_progress_value_we_cannot_translate_still_reports():
|
||||
"""An unrecognized device value renders raw rather than taking the
|
||||
entity out -- the same rule the course tables follow."""
|
||||
sensor, coordinator = _sensor(_PROGRESS)
|
||||
|
||||
_set(coordinator, state="Run", progress="SomeFutureStage")
|
||||
value = sensor.native_value
|
||||
assert value == "somefuturestage"
|
||||
assert value in sensor.options
|
||||
# ...and admitting it doesn't drop the translated ones.
|
||||
assert "rinse" in sensor.options
|
||||
|
||||
|
||||
def test_known_values_do_not_grow_the_options_list():
|
||||
assert _PROGRESS.options is not None
|
||||
sensor, coordinator = _sensor(_PROGRESS)
|
||||
|
||||
_set(coordinator, state="Run", progress="Rinse")
|
||||
assert sensor.options == list(_PROGRESS.options)
|
||||
|
||||
|
||||
def test_a_non_enum_sensor_has_no_options():
|
||||
percentage = next(e for e in OPERATIONAL_STATE.entities if e.key == "progress_percentage")
|
||||
sensor, coordinator = _sensor(percentage)
|
||||
|
||||
_set(coordinator, state="Run", progressPercentage="40")
|
||||
assert sensor.options is None
|
||||
assert sensor.native_value == 40
|
||||
+42
-42
@@ -92,10 +92,10 @@ def test_holds_finish_after_state_leaves_active():
|
||||
sensor, coordinator = _sensor(_PROGRESS_DESC)
|
||||
|
||||
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
|
||||
assert sensor.native_value == "Finish"
|
||||
assert sensor.native_value == "finish"
|
||||
|
||||
_replace(coordinator, state="Ready") # device has moved on
|
||||
assert sensor.native_value == "Finish"
|
||||
assert sensor.native_value == "finish"
|
||||
|
||||
|
||||
def test_holds_finish_even_when_state_already_idle_at_first_observation():
|
||||
@@ -106,11 +106,11 @@ def test_holds_finish_even_when_state_already_idle_at_first_observation():
|
||||
sensor, coordinator = _sensor(_PROGRESS_DESC)
|
||||
|
||||
_replace(coordinator, state="Ready", progress="Finish", progressPercentage="100")
|
||||
assert sensor.native_value == "Finish"
|
||||
assert sensor.native_value == "finish"
|
||||
|
||||
# Still held on a later poll, even once the device stops repeating it.
|
||||
_replace(coordinator, state="Ready")
|
||||
assert sensor.native_value == "Finish"
|
||||
assert sensor.native_value == "finish"
|
||||
|
||||
|
||||
def test_progress_percentage_holds_100_regardless_of_the_raw_field_at_finish():
|
||||
@@ -132,10 +132,10 @@ def test_real_data_flows_through_unheld_while_active():
|
||||
sensor, coordinator = _sensor(_PROGRESS_DESC)
|
||||
|
||||
_replace(coordinator, state="Run", progress="Spin")
|
||||
assert sensor.native_value == "Spin"
|
||||
assert sensor.native_value == "spin"
|
||||
|
||||
_replace(coordinator, state="Run", progress="Rinse")
|
||||
assert sensor.native_value == "Rinse"
|
||||
assert sensor.native_value == "rinse"
|
||||
|
||||
|
||||
def test_never_finished_stays_idle():
|
||||
@@ -144,10 +144,10 @@ def test_never_finished_stays_idle():
|
||||
sensor, coordinator = _sensor(_PROGRESS_DESC)
|
||||
|
||||
_replace(coordinator, state="Run", progress="Spin")
|
||||
assert sensor.native_value == "Spin"
|
||||
assert sensor.native_value == "spin"
|
||||
|
||||
_replace(coordinator, state="Ready")
|
||||
assert sensor.native_value == "Idle"
|
||||
assert sensor.native_value == "idle"
|
||||
|
||||
|
||||
def test_a_new_cycle_starting_overrides_the_hold():
|
||||
@@ -156,13 +156,13 @@ def test_a_new_cycle_starting_overrides_the_hold():
|
||||
sensor, coordinator = _sensor(_PROGRESS_DESC)
|
||||
|
||||
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
|
||||
assert sensor.native_value == "Finish"
|
||||
assert sensor.native_value == "finish"
|
||||
|
||||
_replace(coordinator, state="Ready")
|
||||
assert sensor.native_value == "Finish" # still held
|
||||
assert sensor.native_value == "finish" # still held
|
||||
|
||||
_replace(coordinator, state="Run", progress="Wash")
|
||||
assert sensor.native_value == "Wash"
|
||||
assert sensor.native_value == "wash"
|
||||
|
||||
|
||||
def test_a_running_stage_after_finish_does_not_break_the_hold():
|
||||
@@ -177,24 +177,24 @@ def test_a_running_stage_after_finish_does_not_break_the_hold():
|
||||
sensor, coordinator = _sensor(desc)
|
||||
|
||||
_replace(coordinator, state="Run", progress="Drying", progressPercentage="40")
|
||||
assert sensor.native_value == "Drying"
|
||||
assert sensor.native_value == "drying"
|
||||
|
||||
_replace(coordinator, state="Run", progress="Cooling", progressPercentage="95")
|
||||
assert sensor.native_value == "Cooling"
|
||||
assert sensor.native_value == "cooling"
|
||||
|
||||
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
|
||||
assert sensor.native_value == "Finish"
|
||||
assert sensor.native_value == "finish"
|
||||
|
||||
# The tail: a running stage again, state already idle.
|
||||
_replace(coordinator, state="Ready", progress="Drying", progressPercentage="100")
|
||||
assert sensor.native_value == "Finish"
|
||||
assert sensor.native_value == "finish"
|
||||
|
||||
# ...then the device settles, still inside the window.
|
||||
_replace(coordinator, state="Ready", progress="None")
|
||||
assert sensor.native_value == "Finish"
|
||||
assert sensor.native_value == "finish"
|
||||
|
||||
time.sleep(0.25)
|
||||
assert sensor.native_value == "Idle"
|
||||
assert sensor.native_value == "idle"
|
||||
|
||||
|
||||
def test_progress_percentage_survives_the_same_tail():
|
||||
@@ -223,16 +223,16 @@ def test_a_paused_new_cycle_is_left_to_the_window_rather_than_released():
|
||||
sensor, coordinator = _sensor(desc)
|
||||
|
||||
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
|
||||
assert sensor.native_value == "Finish"
|
||||
assert sensor.native_value == "finish"
|
||||
|
||||
_replace(coordinator, state="Pause", progress="Wash")
|
||||
assert sensor.native_value == "Finish" # held out, not released
|
||||
assert sensor.native_value == "finish" # held out, not released
|
||||
|
||||
time.sleep(0.1)
|
||||
assert sensor.native_value == "Idle" # what a paused appliance always shows
|
||||
assert sensor.native_value == "idle" # what a paused appliance always shows
|
||||
|
||||
_replace(coordinator, state="Run", progress="Wash")
|
||||
assert sensor.native_value == "Wash"
|
||||
assert sensor.native_value == "wash"
|
||||
|
||||
|
||||
def test_a_flapping_finish_cannot_ratchet_an_open_window_forward():
|
||||
@@ -243,20 +243,20 @@ def test_a_flapping_finish_cannot_ratchet_an_open_window_forward():
|
||||
sensor, coordinator = _sensor(desc)
|
||||
|
||||
_replace(coordinator, state="Ready", progress="Finish")
|
||||
assert sensor.native_value == "Finish"
|
||||
assert sensor.native_value == "finish"
|
||||
|
||||
for _ in range(3):
|
||||
time.sleep(0.05)
|
||||
_replace(coordinator, state="Ready", progress="None")
|
||||
assert sensor.native_value == "Finish"
|
||||
assert sensor.native_value == "finish"
|
||||
_replace(coordinator, state="Ready", progress="Finish")
|
||||
assert sensor.native_value == "Finish"
|
||||
assert sensor.native_value == "finish"
|
||||
|
||||
# 0.15s of flapping so far -- the window still ends 0.3s after the
|
||||
# first Finish, not 0.3s after the most recent re-entry.
|
||||
time.sleep(0.2)
|
||||
_replace(coordinator, state="Ready", progress="Finish")
|
||||
assert sensor.native_value == "Idle"
|
||||
assert sensor.native_value == "idle"
|
||||
|
||||
|
||||
def test_a_finish_after_the_window_closes_does_not_re_arm_it():
|
||||
@@ -274,23 +274,23 @@ def test_a_finish_after_the_window_closes_does_not_re_arm_it():
|
||||
sensor, coordinator = _sensor(desc)
|
||||
|
||||
_replace(coordinator, state="Ready", progress="Finish")
|
||||
assert sensor.native_value == "Finish"
|
||||
assert sensor.native_value == "finish"
|
||||
|
||||
time.sleep(0.1)
|
||||
assert sensor.native_value == "Idle"
|
||||
assert sensor.native_value == "idle"
|
||||
|
||||
_replace(coordinator, state="Ready", progress="None")
|
||||
assert sensor.native_value == "Idle"
|
||||
assert sensor.native_value == "idle"
|
||||
_replace(coordinator, state="Ready", progress="Finish")
|
||||
assert sensor.native_value == "Idle"
|
||||
assert sensor.native_value == "idle"
|
||||
|
||||
# A real cycle in between is what makes it available again.
|
||||
_replace(coordinator, state="Run", progress="Drying")
|
||||
assert sensor.native_value == "Drying"
|
||||
assert sensor.native_value == "drying"
|
||||
_replace(coordinator, state="Run", progress="Finish")
|
||||
assert sensor.native_value == "Finish"
|
||||
assert sensor.native_value == "finish"
|
||||
_replace(coordinator, state="Ready", progress="None")
|
||||
assert sensor.native_value == "Finish"
|
||||
assert sensor.native_value == "finish"
|
||||
|
||||
|
||||
def test_hold_expires_after_sticky_seconds():
|
||||
@@ -304,13 +304,13 @@ def test_hold_expires_after_sticky_seconds():
|
||||
sensor, coordinator = _sensor(desc)
|
||||
|
||||
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
|
||||
assert sensor.native_value == "Finish"
|
||||
assert sensor.native_value == "finish"
|
||||
|
||||
_replace(coordinator, state="Ready")
|
||||
assert sensor.native_value == "Finish" # still within the window
|
||||
assert sensor.native_value == "finish" # still within the window
|
||||
|
||||
time.sleep(0.1)
|
||||
assert sensor.native_value == "Idle"
|
||||
assert sensor.native_value == "idle"
|
||||
|
||||
|
||||
def test_a_progress_stuck_at_finish_does_not_hold_open_the_window_forever():
|
||||
@@ -325,17 +325,17 @@ def test_a_progress_stuck_at_finish_does_not_hold_open_the_window_forever():
|
||||
sensor, coordinator = _sensor(desc)
|
||||
|
||||
_replace(coordinator, state="Ready", progress="Finish")
|
||||
assert sensor.native_value == "Finish"
|
||||
assert sensor.native_value == "finish"
|
||||
|
||||
time.sleep(0.03)
|
||||
# Device still (incorrectly) reports Finish on every subsequent poll --
|
||||
# must not restart the window.
|
||||
_replace(coordinator, state="Ready", progress="Finish")
|
||||
assert sensor.native_value == "Finish"
|
||||
assert sensor.native_value == "finish"
|
||||
|
||||
time.sleep(0.03) # 0.06s total since the first sighting -- past 0.05s
|
||||
_replace(coordinator, state="Ready", progress="Finish")
|
||||
assert sensor.native_value == "Idle"
|
||||
assert sensor.native_value == "idle"
|
||||
|
||||
|
||||
def test_non_sticky_sensor_is_unaffected():
|
||||
@@ -361,11 +361,11 @@ def test_cycle_active_and_machine_state_are_never_held():
|
||||
)
|
||||
|
||||
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
|
||||
assert progress_sensor.native_value == "Finish"
|
||||
assert progress_sensor.native_value == "finish"
|
||||
assert machine_state_sensor.native_value == "active"
|
||||
|
||||
_replace(coordinator, state="Ready")
|
||||
assert progress_sensor.native_value == "Finish" # held
|
||||
assert progress_sensor.native_value == "finish" # held
|
||||
assert machine_state_sensor.native_value == "idle" # real-time, unaffected
|
||||
|
||||
|
||||
@@ -377,8 +377,8 @@ def test_a_partial_update_that_omits_progress_does_not_erase_the_hold():
|
||||
sensor, coordinator = _sensor(_PROGRESS_DESC)
|
||||
|
||||
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
|
||||
assert sensor.native_value == "Finish"
|
||||
assert sensor.native_value == "finish"
|
||||
|
||||
_apply(coordinator, state="Ready") # partial merge, doesn't restate progress
|
||||
assert coordinator.resources[_HREF]["x.com.samsung.da.progress"] == "Finish"
|
||||
assert sensor.native_value == "Finish"
|
||||
assert sensor.native_value == "finish"
|
||||
|
||||
+97
-1
@@ -54,7 +54,7 @@ class _FakeSession:
|
||||
self.post_calls: list[tuple[list[str], bytes]] = []
|
||||
self.get_calls: list[list[str]] = []
|
||||
self._post_code = post_code
|
||||
self._get_reps: dict[str, list[dict]] = {}
|
||||
self._get_reps: dict[str, list[dict | list]] = {}
|
||||
|
||||
def queue_get(self, href: str, rep: dict | list) -> None:
|
||||
"""Queue one more canned rep for `href`'s next GET. Once an href's
|
||||
@@ -297,6 +297,7 @@ async def test_write_resource_verify_after_reports_held(hass, coordinator, devic
|
||||
verified = response["verified"]["/mode/vs/0"]
|
||||
assert verified["held"] is True
|
||||
assert verified["rep"] == {"x.field": "target"}
|
||||
assert verified["read_error"] is None
|
||||
|
||||
|
||||
async def test_write_resource_verify_after_reports_reverted(hass, coordinator, device_id):
|
||||
@@ -324,6 +325,42 @@ async def test_write_resource_verify_after_reports_reverted(hass, coordinator, d
|
||||
assert verified["rep"] == {"x.field": "original"}
|
||||
|
||||
|
||||
async def test_write_resource_verify_after_survives_a_failed_confirmation_read(
|
||||
hass, coordinator, device_id, monkeypatch
|
||||
):
|
||||
"""The write itself already landed (see `results`, built before
|
||||
verify_after's wait even starts) by the time the confirmation read runs
|
||||
-- a session dying in the gap verify_after waits out (smartthings-local's
|
||||
redacted SessionClosedError/SessionTimeoutError, or any other exception)
|
||||
must not lose that outcome behind a raised exception. Same "couldn't
|
||||
verify" posture as a 4.04/empty read: `held` stays None, not False."""
|
||||
fake = _FakeSession()
|
||||
fake.queue_get("mode/vs/0", {"x.field": "target"}) # write's own follow-up read
|
||||
coordinator._session = fake
|
||||
|
||||
def _boom(path_segs, href):
|
||||
raise ConnectionError("session closed")
|
||||
|
||||
monkeypatch.setattr(coordinator, "_raw_read_blocking", _boom)
|
||||
|
||||
with patch(_SLEEP_TARGET, new_callable=AsyncMock):
|
||||
response = await _call_write(
|
||||
hass,
|
||||
device_id,
|
||||
writes=[{"href": "/mode/vs/0", "payload": {"x.field": "target"}}],
|
||||
verify_after=30,
|
||||
)
|
||||
|
||||
# The write's own results survive even though verification blew up.
|
||||
assert response["results"][0]["accepted"] is True
|
||||
verified = response["verified"]["/mode/vs/0"]
|
||||
assert verified["held"] is None
|
||||
assert verified["rep"] == {}
|
||||
# raw_code 0 alone is indistinguishable from a real 4.04 -- read_error
|
||||
# is what tells a caller this was an unreachable session, not a reply.
|
||||
assert verified["read_error"] == "session closed"
|
||||
|
||||
|
||||
async def test_write_resource_no_verified_key_when_verify_after_is_zero(
|
||||
hass, coordinator, device_id
|
||||
):
|
||||
@@ -605,6 +642,65 @@ async def test_read_resource_surfaces_a_collections_list_body(hass, coordinator,
|
||||
assert response["body"] == batch
|
||||
|
||||
|
||||
async def test_read_resource_failure_is_surfaced_as_a_home_assistant_error(
|
||||
hass, coordinator, device_id, monkeypatch
|
||||
):
|
||||
"""A session/network failure during a live debug read (e.g.
|
||||
smartthings-local's redacted SessionError, or any other exception) must
|
||||
not reach the service caller raw and untranslated -- write_resource
|
||||
already goes through HomeAssistantError on failure, and async_raw_read
|
||||
must match that instead of letting the exception escape uncaught."""
|
||||
|
||||
def _boom(path_segs, href):
|
||||
raise ConnectionError("session closed")
|
||||
|
||||
monkeypatch.setattr(coordinator, "_raw_read_blocking", _boom)
|
||||
|
||||
with pytest.raises(HomeAssistantError):
|
||||
await _call_read(hass, device_id, href="/mode/vs/0")
|
||||
|
||||
|
||||
async def test_read_resource_failure_closes_a_confirmed_dead_session(
|
||||
hass, coordinator, device_id, monkeypatch
|
||||
):
|
||||
"""A non-timeout failure is unambiguous (same TimeoutError-vs-anything-
|
||||
else split as _poll_once) -- leaving a confirmed-dead session installed
|
||||
would fail every subsequent read/write identically until the next real
|
||||
poll cycle's own reconnect notices, up to a full update_interval later."""
|
||||
coordinator._session = _FakeSession()
|
||||
|
||||
def _boom(path_segs, href):
|
||||
raise ConnectionError("session closed")
|
||||
|
||||
monkeypatch.setattr(coordinator, "_raw_read_blocking", _boom)
|
||||
|
||||
with pytest.raises(HomeAssistantError):
|
||||
await _call_read(hass, device_id, href="/mode/vs/0")
|
||||
|
||||
assert coordinator._session is None
|
||||
|
||||
|
||||
async def test_read_resource_timeout_does_not_close_the_session(
|
||||
hass, coordinator, device_id, monkeypatch
|
||||
):
|
||||
"""The other half of the same distinction: a block-ACK TimeoutError
|
||||
alone doesn't prove the session is dead (see _poll_once), so unlike
|
||||
any other failure it must not tear down a session that might still be
|
||||
perfectly fine."""
|
||||
fake = _FakeSession()
|
||||
coordinator._session = fake
|
||||
|
||||
def _boom(path_segs, href):
|
||||
raise TimeoutError("GET timeout")
|
||||
|
||||
monkeypatch.setattr(coordinator, "_raw_read_blocking", _boom)
|
||||
|
||||
with pytest.raises(HomeAssistantError):
|
||||
await _call_read(hass, device_id, href="/mode/vs/0")
|
||||
|
||||
assert coordinator._session is fake
|
||||
|
||||
|
||||
async def test_read_resource_without_href_returns_cached_snapshot_and_does_not_get(
|
||||
hass, coordinator, device_id
|
||||
):
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""The v2 -> v3 statistics relabel against a real recorder, not a mock.
|
||||
|
||||
tests/localthings/test_statistics_migration.py proves the migration calls
|
||||
HA's API with the right arguments for the right entities. It cannot prove
|
||||
that call does what the migration needs, because the recorder is patched
|
||||
out. This drives an in-memory recorder end to end: statistics recorded
|
||||
unitless, migration run, metadata inspected -- and, most importantly, the
|
||||
recorded *values* checked to be untouched, which is the claim that makes
|
||||
doing this automatically safe rather than something to ask each user about.
|
||||
|
||||
Lives here rather than under tests/localthings/ on purpose: that package's
|
||||
autouse `enable_custom_integrations` fixture depends on `hass`, which
|
||||
starts Home Assistant before `recorder_mock` can claim its database URL.
|
||||
Nothing here loads the integration -- `async_migrate_entry` is called
|
||||
directly -- so the entry only needs the one key the v2 -> v3 step reads.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from functools import partial
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from homeassistant.components.recorder.models import StatisticMeanType, StatisticMetaData
|
||||
from homeassistant.components.recorder.statistics import (
|
||||
async_import_statistics,
|
||||
get_metadata,
|
||||
statistics_during_period,
|
||||
)
|
||||
from homeassistant.components.recorder.util import get_instance
|
||||
from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.util import dt as dt_util
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
from pytest_homeassistant_custom_component.components.recorder.common import (
|
||||
async_wait_recording_done,
|
||||
)
|
||||
|
||||
from custom_components.localthings import async_migrate_entry
|
||||
from custom_components.localthings.const import CONF_DEVICE_TYPE, DOMAIN
|
||||
from custom_components.localthings.registry.entities import SensorDesc
|
||||
|
||||
SERIAL = "TEST-SERIAL-0000"
|
||||
RECORDED = [11.0, 9.0, 14.0]
|
||||
|
||||
|
||||
async def _seed_unitless_statistics(hass: HomeAssistant, entity_id: str) -> None:
|
||||
"""Record hourly statistics the way these sensors always have: numeric
|
||||
means, no unit of measurement at all."""
|
||||
start = dt_util.utcnow().replace(minute=0, second=0, microsecond=0) - timedelta(hours=4)
|
||||
async_import_statistics(
|
||||
hass,
|
||||
{
|
||||
"mean_type": StatisticMeanType.ARITHMETIC,
|
||||
"has_sum": False,
|
||||
"name": None,
|
||||
"source": "recorder",
|
||||
"statistic_id": entity_id,
|
||||
"unit_class": None,
|
||||
"unit_of_measurement": None,
|
||||
},
|
||||
[
|
||||
{"start": start + timedelta(hours=i), "mean": value, "min": value, "max": value}
|
||||
for i, value in enumerate(RECORDED)
|
||||
],
|
||||
)
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
|
||||
async def _metadata(hass: HomeAssistant, entity_id: str) -> StatisticMetaData:
|
||||
result = await get_instance(hass).async_add_executor_job(
|
||||
partial(get_metadata, hass, statistic_ids={entity_id})
|
||||
)
|
||||
return result[entity_id][1]
|
||||
|
||||
|
||||
async def _means(hass: HomeAssistant, entity_id: str) -> list[float]:
|
||||
rows = await get_instance(hass).async_add_executor_job(
|
||||
statistics_during_period,
|
||||
hass,
|
||||
dt_util.utcnow() - timedelta(days=1),
|
||||
None,
|
||||
{entity_id},
|
||||
"hour",
|
||||
None,
|
||||
{"mean"},
|
||||
)
|
||||
return [cast(float, row["mean"]) for row in rows.get(entity_id, [])]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def purifier_entry(hass: HomeAssistant) -> MockConfigEntry:
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_DEVICE_TYPE: "air_purifier"},
|
||||
unique_id=f"{DOMAIN}_{SERIAL}",
|
||||
version=2,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
return entry
|
||||
|
||||
|
||||
def _dust_entity(hass: HomeAssistant, entry: MockConfigEntry):
|
||||
return er.async_get(hass).async_get_or_create(
|
||||
"sensor", DOMAIN, f"{DOMAIN}_{SERIAL}_dust", config_entry=entry
|
||||
)
|
||||
|
||||
|
||||
async def test_relabels_metadata_without_touching_recorded_values(
|
||||
recorder_mock, hass: HomeAssistant, purifier_entry: MockConfigEntry
|
||||
) -> None:
|
||||
dust = _dust_entity(hass, purifier_entry)
|
||||
await _seed_unitless_statistics(hass, dust.entity_id)
|
||||
|
||||
before = await _metadata(hass, dust.entity_id)
|
||||
assert before["unit_of_measurement"] is None
|
||||
assert await _means(hass, dust.entity_id) == RECORDED
|
||||
|
||||
assert await async_migrate_entry(hass, purifier_entry) is True
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
after = await _metadata(hass, dust.entity_id)
|
||||
assert after["unit_of_measurement"] == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
|
||||
assert after["unit_class"] == "concentration"
|
||||
# The readings were always µg/m³; only the label was missing. Nothing is
|
||||
# converted, so the recorded history still says exactly what it said.
|
||||
assert await _means(hass, dust.entity_id) == RECORDED
|
||||
|
||||
|
||||
async def test_recorded_unit_ends_up_matching_what_the_descriptor_declares(
|
||||
recorder_mock, hass: HomeAssistant, purifier_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""The invariant the migration exists to establish, stated directly.
|
||||
|
||||
HA raises units_changed -- and suppresses statistics generation -- when
|
||||
an entity's unit disagrees with the unit recorded against its
|
||||
statistic_id. Rather than drive HA's validation to observe that, this
|
||||
asserts the condition that validation reads: after migrating, the
|
||||
recorded metadata says exactly what the descriptor says. Asserting our
|
||||
own invariant instead of Home Assistant's reaction to it keeps the test
|
||||
off internals that change between versions (both `_update_issues` and
|
||||
`validate_statistics` have gained parameters), and tests this repo
|
||||
rather than that one."""
|
||||
from custom_components.localthings.registry.capabilities import air_purifier
|
||||
|
||||
dust = _dust_entity(hass, purifier_entry)
|
||||
await _seed_unitless_statistics(hass, dust.entity_id)
|
||||
|
||||
desc = next(
|
||||
d
|
||||
for d in air_purifier.AIR_QUALITY.entities
|
||||
if d.key == "dust" and isinstance(d, SensorDesc)
|
||||
)
|
||||
assert (await _metadata(hass, dust.entity_id))["unit_of_measurement"] != desc.unit
|
||||
|
||||
assert await async_migrate_entry(hass, purifier_entry) is True
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
assert (await _metadata(hass, dust.entity_id))["unit_of_measurement"] == desc.unit
|
||||
@@ -190,6 +190,27 @@ def test_confirmed_washer_table_02_missing_course_names():
|
||||
}
|
||||
|
||||
|
||||
def test_confirmed_washer_table_02_ww90dg5g34able_course_names():
|
||||
"""Issue #363: 0A/B0 rendered as raw hex on a WW90DG5G34ABLE
|
||||
(DA_WM_TP1_21_COMMON), whose other Table_02 labels the reporter
|
||||
confirmed were already correct.
|
||||
|
||||
0A joins 33/54/70 as a Towels code -- checked in every locale, since a
|
||||
locale that translated 0A differently from the Towels codes it shares a
|
||||
meaning with would still pass the key-topology test, the same gap
|
||||
issue #343 fell through.
|
||||
"""
|
||||
for language in _languages():
|
||||
states = _load(language)["entity"]["select"]["washer_cycle_table_02"]["state"]
|
||||
assert states["0a"] == states["33"], language
|
||||
assert states["b0"] != states["34"], language
|
||||
english = _load("en")["entity"]["select"]["washer_cycle_table_02"]["state"]
|
||||
assert {code: english[code] for code in ("0a", "b0")} == {
|
||||
"0a": "Towels",
|
||||
"b0": "Mixed Load",
|
||||
}
|
||||
|
||||
|
||||
def test_reported_washer_standard_courses_all_have_table_02_labels():
|
||||
"""Every non-personal code in the reported washer's live course list
|
||||
must resolve through the Table_02 catalog instead of appearing as raw
|
||||
|
||||
@@ -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