Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d912bff5d3 | ||
|
|
330479d389 | ||
|
|
c0ef45c7c8 | ||
|
|
d5dc6421ba | ||
|
|
93cd6ae6fe | ||
|
|
ffc0eec322 | ||
|
|
f5d9f0e31b | ||
|
|
55486526cc | ||
|
|
a18663d5e0 | ||
|
|
322e436c22 | ||
|
|
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 | ||
|
|
2b85e20108 | ||
|
|
e5cd212a34 | ||
|
|
11c71a62e8 |
+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"
|
||||
|
||||
@@ -159,7 +159,7 @@ data:
|
||||
href: /mode/vs/0
|
||||
```
|
||||
|
||||
returning `{"href", "actual_href", "code", "raw_code", "rep"}` off a **live GET straight from the device**, not the cache — which can be up to a poll interval stale, exactly the staleness that would make `held` above meaningless. Omit `href` and you get `{"resources": {href: rep, ...}}`, the cached snapshot of everything this integration currently tracks on that device, with no GET at all — useful for seeing what's there before you start writing to it, without hammering the appliance.
|
||||
returning `{"href", "actual_href", "code", "raw_code", "rep"}` off a **live GET straight from the device**, not the cache — which can be up to a poll interval stale, exactly the staleness that would make `held` above meaningless. A sixth key, `body`, appears only when the response isn't a Property map: a Collection (`/device/0`, and the `x.com.samsung.devcol` siblings some boards expose) answers a CBOR list, which `rep` can't carry, and which would otherwise read as an accepted-but-empty resource. Omit `href` and you get `{"resources": {href: rep, ...}}`, the cached snapshot of everything this integration currently tracks on that device, with no GET at all — useful for seeing what's there before you start writing to it, without hammering the appliance.
|
||||
|
||||
The **Debug write** panel under a device's Configure menu (Part 4) is the friendlier single-write path over this same machinery — pick an href, type a payload, see the result — for when you don't need a sequence.
|
||||
|
||||
@@ -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,8 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from homeassistant import const as ha_const
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
@@ -12,13 +16,29 @@ 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
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# UnitOfDensity is the non-deprecated home for this value from whichever HA
|
||||
# release introduces it; CONCENTRATION_MICROGRAMS_PER_CUBIC_METER logs a
|
||||
# removal warning (2027.8) on those releases every time it's accessed.
|
||||
# hacs.json's floor (2025.1.0) predates UnitOfDensity existing at all, so
|
||||
# this is a runtime getattr rather than a static import ty could only ever
|
||||
# resolve against one HA generation -- see _relabel_particulate_statistics'
|
||||
# own new_unit_class feature-detection below for the same pattern. The
|
||||
# getattr short-circuits before the deprecated name is ever touched on a
|
||||
# release new enough to have UnitOfDensity.
|
||||
_unit_of_density = getattr(ha_const, "UnitOfDensity", None)
|
||||
PARTICULATE_UNIT = (
|
||||
_unit_of_density.MICROGRAMS_PER_CUBIC_METER
|
||||
if _unit_of_density is not None
|
||||
else ha_const.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
# Services are process-global, registered once here rather than per
|
||||
@@ -125,6 +145,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 +246,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 +264,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
|
||||
@@ -183,6 +317,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close_on_stop)
|
||||
)
|
||||
|
||||
# Nothing else reacts to an options-flow save (issue #364): the cloud-
|
||||
# courses Repair and canonical-view cache are only refreshed from
|
||||
# _on_cloud_courses_changed, which only runs when a poll/observe
|
||||
# actually reports new data. Without this, turning "Download cycles"
|
||||
# off would leave an already-open Repair sitting there -- and an
|
||||
# already-named program still offered as a cycle -- until the next
|
||||
# unrelated change happened to fire it, indistinguishable from the
|
||||
# toggle not working. Every other option (CONF_LEARN_MODES,
|
||||
# CONF_BYPASS_REMOTE_CONTROL, ...) is read live on its own next use and
|
||||
# has no comparable standing state to refresh, so this listener exists
|
||||
# for cloud courses specifically rather than as a general
|
||||
# options-changed hook. Must be a coroutine function -- HA wraps
|
||||
# whatever an update listener returns in async_create_task, which raises
|
||||
# on a plain callback's None.
|
||||
async def _async_options_updated(_hass: HomeAssistant, _entry: ConfigEntry) -> None:
|
||||
coordinator._on_cloud_courses_changed()
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(_async_options_updated))
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
@@ -220,6 +373,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:
|
||||
|
||||
@@ -42,6 +42,7 @@ from .const import (
|
||||
CONF_BYPASS_REMOTE_CONTROL,
|
||||
CONF_CA_CERT_PEM,
|
||||
CONF_CA_KEY_PEM,
|
||||
CONF_CLOUD_COURSES_ENABLED,
|
||||
CONF_DEVICE_TYPE,
|
||||
CONF_FINISH_TIME_HYSTERESIS_MINUTES,
|
||||
CONF_HOST,
|
||||
@@ -52,6 +53,7 @@ from .const import (
|
||||
CONF_MODEL,
|
||||
CONF_PORT,
|
||||
CONF_SERIAL,
|
||||
DEFAULT_CLOUD_COURSES_ENABLED,
|
||||
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
|
||||
DEFAULT_LEARN_MODES,
|
||||
DOMAIN,
|
||||
@@ -493,27 +495,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 +580,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 +693,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 +750,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 +795,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 = ""
|
||||
@@ -888,6 +993,12 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
|
||||
CONF_LEARN_MODES, DEFAULT_LEARN_MODES
|
||||
),
|
||||
): bool,
|
||||
vol.Required(
|
||||
CONF_CLOUD_COURSES_ENABLED,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_CLOUD_COURSES_ENABLED, DEFAULT_CLOUD_COURSES_ENABLED
|
||||
),
|
||||
): bool,
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -935,6 +1046,18 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
|
||||
program in the moment they select it, rather than about a list of hex
|
||||
ids some time later. The bulk form stays for renaming afterwards,
|
||||
which guided setup is bad at.
|
||||
|
||||
Setup still works with cloud_courses_enabled off (see
|
||||
CONF_CLOUD_COURSES_ENABLED's own comment for why) -- someone who
|
||||
wants to name one cycle without turning the feature fully on for
|
||||
everything else still can (issue #364). The catalog's own
|
||||
description covers that state directly rather than through a
|
||||
description_placeholder built here: a placeholder is a literal
|
||||
substitution HA never runs back through translation, so an
|
||||
English sentence assembled in Python would show untranslated text
|
||||
in every other locale -- unlike the SmartThings screen names
|
||||
quoted elsewhere in this catalog, which stay English everywhere
|
||||
because that's a third-party app's own label, not one of ours.
|
||||
"""
|
||||
return self.async_show_menu(
|
||||
step_id="cloud_courses",
|
||||
|
||||
@@ -57,6 +57,21 @@ DEFAULT_LEARN_MODES = True
|
||||
# {"download_course": "87"|null, "slots": {slot: {"blob": ..., "name": ...}}}
|
||||
CONF_CLOUD_COURSES = "cloud_courses"
|
||||
|
||||
# Options-flow key: whether downloaded programs are offered as selectable
|
||||
# cycles and nagged about via the "not set up yet" Repair (issue #364).
|
||||
# Defaults to on. Unlike CONF_LEARN_MODES this does not also stop passive
|
||||
# observation -- a device that merely *advertises* download slots without
|
||||
# the owner ever meaning to use them (SmartThings appears to seed one from
|
||||
# the cloud automatically, per #364's reporters) is exactly the case this
|
||||
# exists for, and turning it off is the fix. Guided/manual setup stay
|
||||
# reachable and still record what they see either way: they are a deliberate
|
||||
# per-session action, not the passive background behavior this silences, and
|
||||
# leaving them working means flipping the option back on immediately surfaces
|
||||
# anything set up in the meantime instead of asking the user to redo it. See
|
||||
# coordinator.cloud_courses_enabled for exactly what it gates.
|
||||
CONF_CLOUD_COURSES_ENABLED = "cloud_courses_enabled"
|
||||
DEFAULT_CLOUD_COURSES_ENABLED = True
|
||||
|
||||
# Options-flow key (entry.options, not entry.data): lets a user override
|
||||
# the device-wide remote-control-off write block for a specific device
|
||||
# (issue #54). Some devices accept certain writes even while reporting
|
||||
|
||||
@@ -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
|
||||
@@ -28,6 +30,7 @@ from .cloudcourse import persist as cloud_persist
|
||||
from .const import (
|
||||
CONF_BYPASS_REMOTE_CONTROL,
|
||||
CONF_CLOUD_COURSES,
|
||||
CONF_CLOUD_COURSES_ENABLED,
|
||||
CONF_DEVICE_TYPE,
|
||||
CONF_HOST,
|
||||
CONF_LEAF_CERT_PEM,
|
||||
@@ -38,6 +41,7 @@ from .const import (
|
||||
CONF_MODEL,
|
||||
CONF_PORT,
|
||||
CONF_SERIAL,
|
||||
DEFAULT_CLOUD_COURSES_ENABLED,
|
||||
DEFAULT_LEARN_MODES,
|
||||
DEVICE_SUPPORT_ISSUE_URL,
|
||||
DOMAIN,
|
||||
@@ -47,7 +51,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 +87,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 +121,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 +226,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 +266,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.
|
||||
@@ -350,12 +381,21 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
rep = snapshot.get(cloudcourse.COURSE_HREF)
|
||||
if rep is None:
|
||||
return snapshot
|
||||
view = self._cloud.view()
|
||||
view = self._cloud_view()
|
||||
if not view:
|
||||
return snapshot
|
||||
snapshot[cloudcourse.COURSE_HREF] = {**rep, cloudcourse.FIELD: view}
|
||||
return snapshot
|
||||
|
||||
def _cloud_view(self) -> dict:
|
||||
"""`self._cloud.view()`, or nothing while cloud_courses_enabled is
|
||||
off (issue #364) -- entity_resources/entity_rep's one gate so a
|
||||
previously-named program stops being offered the moment the option
|
||||
is turned off, symmetric with how it starts being offered again the
|
||||
moment it's turned back on. The store itself is untouched either
|
||||
way; only what these two hand to the registry changes."""
|
||||
return self._cloud.view() if self.cloud_courses_enabled else {}
|
||||
|
||||
def entity_rep(self, href: str) -> dict:
|
||||
"""One href's rep as descriptors see it -- `resource()` plus the
|
||||
merge `entity_resources` would have applied. Exists so the write path
|
||||
@@ -364,7 +404,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
rep = self.resource(href)
|
||||
if href != cloudcourse.COURSE_HREF or not rep:
|
||||
return rep
|
||||
view = self._cloud.view()
|
||||
view = self._cloud_view()
|
||||
return {**rep, cloudcourse.FIELD: view} if view else rep
|
||||
|
||||
def device_resources(self, subdevice: Subdevice) -> dict[str, dict]:
|
||||
@@ -397,6 +437,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)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -482,15 +551,29 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# Cloud "Download" programs (issue #342) -- see cloudcourse.py
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def cloud_courses_enabled(self) -> bool:
|
||||
"""Whether downloaded programs are offered as cycles and nagged
|
||||
about via the "not set up yet" Repair (issue #364).
|
||||
|
||||
Deliberately does NOT gate `_observe_cloud_courses` below -- see
|
||||
CONF_CLOUD_COURSES_ENABLED's own comment for why passive recording
|
||||
keeps running (cheap, and what guided/manual setup depend on)
|
||||
while only the Repair and the entity offering stop.
|
||||
"""
|
||||
return bool(
|
||||
self._entry.options.get(CONF_CLOUD_COURSES_ENABLED, DEFAULT_CLOUD_COURSES_ENABLED)
|
||||
)
|
||||
|
||||
def _observe_cloud_courses(self, rep: dict) -> None:
|
||||
"""Learn a downloaded program's replay payload from one applied
|
||||
/course/vs/0 rep. Runs on whichever thread applied the update, so
|
||||
both the persist and the Repairs refresh go through hass.add_job.
|
||||
|
||||
Unlike learned modes this has no opt-out option: it records only
|
||||
Always records, regardless of cloud_courses_enabled: it records only
|
||||
what the appliance itself reports about programs it itself
|
||||
advertises, and nothing is offered in the UI until the user names it.
|
||||
"""
|
||||
advertises, and nothing is offered in the UI until both the option
|
||||
is on and the user has named it."""
|
||||
if not self._cloud.observe(rep):
|
||||
return
|
||||
self.hass.add_job(self._persist_cloud_courses)
|
||||
@@ -498,9 +581,33 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
@callback
|
||||
def _persist_cloud_courses(self) -> None:
|
||||
cloud_persist(self.hass, self._entry, self._cloud.snapshot())
|
||||
# A newly learned program changes what entity_resources() hands out.
|
||||
self._on_cloud_courses_changed()
|
||||
|
||||
@callback
|
||||
def _on_cloud_courses_changed(self) -> None:
|
||||
"""Refresh everything that depends on the cloud-course store or the
|
||||
cloud_courses_enabled option: the canonical-view cache (a newly
|
||||
learned/named program, or the option flipping, changes what
|
||||
entity_resources() hands out), the live entities, and the Repair.
|
||||
|
||||
Also the __init__.py options-update listener's one hook (issue
|
||||
#364): toggling cloud_courses_enabled changes _cloud_view()'s
|
||||
answer without touching last_resources, so a canonical view cached
|
||||
from before the toggle would otherwise keep answering with it.
|
||||
|
||||
_push_cache_snapshot is what actually gets a change here in front
|
||||
of a user, not just correct on the next read: select.py's
|
||||
current_option comes from coordinator.data, which only moves on
|
||||
async_set_updated_data, and nothing prompts Home Assistant to
|
||||
re-read a live `options` property (cycle's callable options included)
|
||||
without the state-changed signal that call sends. Without it, both
|
||||
this and a name applied through apply_cloud_courses -- which has
|
||||
called this same method since before this option existed -- would
|
||||
sit stale until whatever poll or observe happened to run next.
|
||||
"""
|
||||
self._canonical_cache.clear()
|
||||
self._refresh_cloud_course_issue()
|
||||
self._push_cache_snapshot()
|
||||
|
||||
@property
|
||||
def cloud_courses(self) -> CloudCourses:
|
||||
@@ -530,7 +637,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
single missed read is not worth surfacing.
|
||||
"""
|
||||
try:
|
||||
code, rep = await self.async_raw_read(cloudcourse.COURSE_HREF)
|
||||
code, rep, _body = await self.async_raw_read(cloudcourse.COURSE_HREF)
|
||||
except Exception:
|
||||
# One missed probe; the caller is a retry loop.
|
||||
self._log.debug("cloud-course probe failed", exc_info=True)
|
||||
@@ -580,9 +687,33 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
nothing they do in Home Assistant could clear it. Running one
|
||||
downloaded program is what turns the nudge on, and naming them is
|
||||
what turns it off.
|
||||
|
||||
Also gated on cloud_courses_enabled (issue #364): a device can seed a
|
||||
slot's payload before the owner has ever meant to use the feature --
|
||||
reporters observed one auto-populate from a SmartThings-provided
|
||||
example -- so "at least one payload seen" alone isn't proof of
|
||||
intent to use it. Checked here rather than skipping
|
||||
_observe_cloud_courses' recording, so turning the option back on
|
||||
re-evaluates against everything already learned instead of only
|
||||
what arrives afterward.
|
||||
|
||||
Also a no-op with an empty cloud_course_rep (issue #364): this now
|
||||
runs from __init__.py's options-update listener on every entry
|
||||
save, including an unrelated one (CONF_BYPASS_REMOTE_CONTROL, say)
|
||||
made before this device's first poll or while it's rehydrated
|
||||
offline. /course/vs/0 unpolled reads as no advertised slots, which
|
||||
would otherwise delete a Repair a real poll had every reason to
|
||||
raise, on evidence that only means "haven't asked the device yet."
|
||||
Leaves whatever issue state already exists untouched rather than
|
||||
guess either way; the next real poll re-evaluates for real.
|
||||
"""
|
||||
issue_id = f"cloud_courses_{self._entry.entry_id}"
|
||||
if not self.cloud_courses_enabled:
|
||||
ir.async_delete_issue(self.hass, DOMAIN, issue_id)
|
||||
return
|
||||
rep = self.cloud_course_rep()
|
||||
if not rep:
|
||||
return
|
||||
record = self._cloud.snapshot()
|
||||
courses = cycle_options(self.canonical_resources(MAIN))
|
||||
pending = cloudcourse.undiscovered(rep, record, courses)
|
||||
@@ -717,6 +848,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 +1122,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 +1379,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 +1450,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 +1537,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 +1652,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 +1692,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:
|
||||
@@ -1576,12 +1940,24 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._log.debug("raw write follow-up read failed: %s", e)
|
||||
return code, new_rep
|
||||
|
||||
def _raw_read_blocking(self, path_segs: list[str], href: str) -> tuple[int, dict]:
|
||||
def _raw_read_blocking(self, path_segs: list[str], href: str) -> tuple[int, dict, Any]:
|
||||
"""Debug primitive: a live GET, deliberately bypassing the cache
|
||||
(issue #300) -- the cache can be up to a poll interval stale,
|
||||
exactly the staleness that makes testing whether a write held or
|
||||
got silently reverted by the board unreliable. Blocking -- runs in
|
||||
executor."""
|
||||
executor.
|
||||
|
||||
Returns `(code, rep, body)`. `rep` is the decoded body only when it
|
||||
is a Property map, since that's the shape the observe cache and
|
||||
every capability are written against. `body` is whatever CBOR
|
||||
actually decoded to, and exists because a Collection answers a
|
||||
*list*, not a map: `/device/0` and its `x.com.samsung.devcol`
|
||||
siblings return the `[devcol rep, {href, rep}, ...]` batch
|
||||
`parse_device0_batch` reads. Reporting only `rep` rendered those as
|
||||
an accepted-but-empty `2.05 {}`, which reads as "the resource is
|
||||
there and has nothing in it" -- the opposite of what a full batch
|
||||
means, and how issue #335's `/sec/devices` was nearly written off.
|
||||
"""
|
||||
if self._session is None:
|
||||
self._connect_session()
|
||||
sess = self._session
|
||||
@@ -1589,6 +1965,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
raise RuntimeError("no session")
|
||||
code, payload = sess.get(path_segs, timeout=10.0)
|
||||
rep: dict = {}
|
||||
body: Any = None
|
||||
if code == 0x45 and payload:
|
||||
try:
|
||||
body = cbor2.loads(payload)
|
||||
@@ -1598,11 +1975,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
if isinstance(body, dict):
|
||||
self._observe.apply(href, body, source="poll")
|
||||
rep = body
|
||||
return code, rep
|
||||
return code, rep, body
|
||||
|
||||
async def async_raw_read(self, href: str) -> tuple[int, dict]:
|
||||
async def async_raw_read(self, href: str) -> tuple[int, dict, Any]:
|
||||
"""Debug-only live GET (issue #300, backs the read_resource
|
||||
service). Same href validation as async_raw_write."""
|
||||
service). Same href validation as async_raw_write. Three-tuple --
|
||||
see `_raw_read_blocking` for why the raw body comes back alongside
|
||||
the Property-map `rep`."""
|
||||
path_segs = _href_to_path_segs(href)
|
||||
if not path_segs:
|
||||
raise ServiceValidationError(
|
||||
@@ -1611,9 +1990,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,
|
||||
@@ -1721,9 +2122,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 = 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
|
||||
@@ -1738,6 +2156,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.1"
|
||||
"version": "0.23.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)
|
||||
|
||||
@@ -179,7 +179,7 @@ async def _async_read_resource(hass: HomeAssistant, call: ServiceCall) -> Servic
|
||||
# Same normalize-before-translate order as the write path above.
|
||||
canonical = normalize_href(href)
|
||||
actual_href = subdevice.to_actual(canonical)
|
||||
code, rep = await coordinator.async_raw_read(actual_href)
|
||||
code, rep, body = await coordinator.async_raw_read(actual_href)
|
||||
read_result: dict[str, Any] = {
|
||||
"href": canonical,
|
||||
"actual_href": actual_href,
|
||||
@@ -187,6 +187,13 @@ async def _async_read_resource(hass: HomeAssistant, call: ServiceCall) -> Servic
|
||||
"raw_code": code,
|
||||
"rep": rep,
|
||||
}
|
||||
# `body` only when it isn't the Property map already in `rep` -- a
|
||||
# Collection (`/device/0`, `/sec/devices`) answers a CBOR list, which
|
||||
# `rep` can't carry and which used to vanish into an empty-looking
|
||||
# 2.05 (issue #335). Omitted for the ordinary map case rather than
|
||||
# duplicating every rep in every response.
|
||||
if body is not None and not isinstance(body, dict):
|
||||
read_result["body"] = body
|
||||
return cast(ServiceResponse, read_result)
|
||||
|
||||
|
||||
|
||||
@@ -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": {
|
||||
@@ -369,7 +376,25 @@
|
||||
"53": "AI sušení+",
|
||||
"4e": "Automatické sušení",
|
||||
"26": "Provětrání",
|
||||
"2a": "Hygienické sušení+"
|
||||
"2a": "Hygienické sušení+",
|
||||
"02": "AI sušení",
|
||||
"03": "Super Speed",
|
||||
"05": "Ložní prádlo",
|
||||
"07": "Jemné prádlo",
|
||||
"09": "Košile",
|
||||
"0b": "Péče o prošívané bundy",
|
||||
"0c": "Outdoor impregnace",
|
||||
"0e": "Ručníky",
|
||||
"0f": "Vlna",
|
||||
"11": "Studený vzduch",
|
||||
"28": "Vnitřní dezinfekce horkým vzduchem",
|
||||
"37": "Halenky",
|
||||
"38": "Žehlení",
|
||||
"39": "Odvlhčování místnosti",
|
||||
"3a": "Hygienické sušení",
|
||||
"3b": "Ložní prádlo/Odstranění prachu",
|
||||
"3c": "Sportovní oblečení",
|
||||
"3d": "Džíny"
|
||||
}
|
||||
},
|
||||
"favorite_capacity": {
|
||||
@@ -640,10 +665,31 @@
|
||||
"name": "Cyklus",
|
||||
"state": {
|
||||
"01": "Normální",
|
||||
"02": "Extra intenzivní",
|
||||
"03": "Super Eco",
|
||||
"04": "Rychlé praní",
|
||||
"06": "XXL prádlo",
|
||||
"05": "Vlna/Spodní prádlo",
|
||||
"06": "Ložní prádlo",
|
||||
"07": "Outdoor",
|
||||
"08": "Máchání+odstřeďování",
|
||||
"09": "Čištění bubnu",
|
||||
"0a": "Ručníky",
|
||||
"0b": "Praní varem",
|
||||
"0c": "Dětské potřeby",
|
||||
"0d": "Pouze odstřeďování",
|
||||
"0e": "Zataženo",
|
||||
"0f": "Čisté praní",
|
||||
"10": "Sušicí odstřeďování",
|
||||
"11": "Letní ložní prádlo",
|
||||
"12": "Bavlna",
|
||||
"13": "Černá bavlna",
|
||||
"14": "Jemné spodní prádlo",
|
||||
"15": "Sportovní oblečení",
|
||||
"16": "Halenky",
|
||||
"17": "Stažený program",
|
||||
"18": "Soft Bubble",
|
||||
"19": "AI praní",
|
||||
"1a": "Košile",
|
||||
"1b": "Bavlna",
|
||||
"1c": "Eco 40-60",
|
||||
"1d": "Super rychlé",
|
||||
@@ -668,6 +714,7 @@
|
||||
"32": "Košile",
|
||||
"33": "Ručníky",
|
||||
"34": "Smíšené",
|
||||
"35": "Eko bavlna",
|
||||
"36": "Praní+sušení",
|
||||
"37": "Sušení vzduchem",
|
||||
"38": "Sušení bavlny",
|
||||
@@ -709,7 +756,7 @@
|
||||
"8f": "Intenzivní studená",
|
||||
"96": "Méně mikrovláken",
|
||||
"a0": "15min rychlé praní",
|
||||
"35": "Eko bavlna"
|
||||
"b0": "Smíšená náplň"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
@@ -872,6 +919,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 +972,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 +996,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 +1128,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"
|
||||
@@ -1445,11 +1522,12 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Nastavení zápisu pro dálkové ovládání",
|
||||
"description": "Některá zařízení přijímají určité zápisy (např. výchozí dávkování pracího prostředku/aviváže v pračce) i když hlásí vypnuté dálkové ovládání. LocalThings ve výchozím nastavení blokuje každý zápis srozumitelnou chybou, kdykoli zařízení hlásí vypnuté dálkové ovládání, místo aby jej zařízení tiše odmítlo. Povolte toto pouze tehdy, pokud jste ověřili, že zápisy na tomto zařízení skutečně fungují i s vypnutým dálkovým ovládáním – jinak tuto srozumitelnou chybu vyměníte za tiché selhání.\n\nNěkterá zařízení hlásí režim, který nikdy neuvedou mezi podporovanými – například klimatizace běžící v režimu Quiet, která nabízí pouze Off/Sleep/Speed. LocalThings si každý takový režim zapamatuje a dál jej nabízí, takže zůstane volitelný, jakmile v něm zařízení alespoň jednou bylo. Vypnutím této volby se budou nabízet pouze režimy, které zařízení samo uvádí; k vymazání již zapamatovaných použijte „Zapomenout zapamatované režimy“ v předchozí nabídce.",
|
||||
"description": "Některá zařízení přijímají určité zápisy (např. výchozí dávkování pracího prostředku/aviváže v pračce) i když hlásí vypnuté dálkové ovládání. LocalThings ve výchozím nastavení blokuje každý zápis srozumitelnou chybou, kdykoli zařízení hlásí vypnuté dálkové ovládání, místo aby jej zařízení tiše odmítlo. Povolte toto pouze tehdy, pokud jste ověřili, že zápisy na tomto zařízení skutečně fungují i s vypnutým dálkovým ovládáním – jinak tuto srozumitelnou chybu vyměníte za tiché selhání.\n\nNěkterá zařízení hlásí režim, který nikdy neuvedou mezi podporovanými – například klimatizace běžící v režimu Quiet, která nabízí pouze Off/Sleep/Speed. LocalThings si každý takový režim zapamatuje a dál jej nabízí, takže zůstane volitelný, jakmile v něm zařízení alespoň jednou bylo. Vypnutím této volby se budou nabízet pouze režimy, které zařízení samo uvádí; k vymazání již zapamatovaných použijte „Zapomenout zapamatované režimy“ v předchozí nabídce. Zařízení pro praní se staženými cykly z cloudu zde dostane připomenutí, jakmile nahlásí cyklus, který Home Assistant zatím nemůže nabídnout -- některá zařízení jej nahlásí, i když jste obrazovku „Stažené cykly“ v aplikaci SmartThings sami nikdy neotevřeli. Vypněte tuto možnost, chcete-li připomenutí zastavit a skrýt už pojmenované stažené cykly ze seznamu cyklů; nic z toho, co je již nastaveno, se neztratí, a po opětovném zapnutí se to hned znovu objeví.",
|
||||
"data": {
|
||||
"bypass_remote_control_lock": "Povolit zápis i když je dálkové ovládání hlášeno jako vypnuté",
|
||||
"finish_time_hysteresis_minutes": "Odhadovaný konec -- minimální změna (minuty)",
|
||||
"learn_device_modes": "Pamatovat si režimy, které zařízení hlásí, ale neuvádí jako podporované"
|
||||
"learn_device_modes": "Pamatovat si režimy, které zařízení hlásí, ale neuvádí jako podporované",
|
||||
"cloud_courses_enabled": "Nabízet stažené cykly"
|
||||
}
|
||||
},
|
||||
"forget_learned_modes": {
|
||||
@@ -1480,14 +1558,14 @@
|
||||
},
|
||||
"cloud_manual": {
|
||||
"title": "Stažené cykly",
|
||||
"description": "Toto zařízení hlásí {total} stažených cyklů; {found} jich bylo dosud zjištěno.\n\nNastavení staženého cyklu jsou viditelná pouze tehdy, když je daný cyklus právě načtený, a zařízení nikdy nehlásí jejich názvy. Chcete-li doplnit chybějící ({pending}): na zařízení vyberte Stažený program, poté postupně projděte jednotlivé stažené programy, u každého se na pár sekund zastavte, a vraťte se sem.\n\nKaždému z nich zadejte název, který chcete vidět v Home Assistant. Necháte-li název prázdný, daný cyklus zůstane mimo seznam cyklů. Názvy musí být jedinečné.\n\nStažený program je program na tomto zařízení, který spouští stažený cyklus. Rozpoznává se automaticky, ale před použitím jej zde potvrďte: výběrem staženého cyklu se do zařízení zapíše tento kód programu.",
|
||||
"description": "Toto zařízení hlásí {total} stažených cyklů; {found} jich bylo dosud zjištěno.\n\nNastavení staženého cyklu jsou viditelná pouze tehdy, když je daný cyklus právě načtený, a zařízení nikdy nehlásí jejich názvy. Chcete-li doplnit chybějící ({pending}): v aplikaci SmartThings (ne na samotném zařízení) otevřete toto zařízení a klepněte na „Stažené cykly“ -- samostatný řádek odlišný od „Cyklus“, níže na obrazovce, a ten, na kterém zde záleží. Poté postupně projděte jednotlivé stažené programy, u každého se na pár sekund zastavte, aby jej LocalThings zaznamenal, a vraťte se sem.\n\nKaždému z nich zadejte název, který chcete vidět v Home Assistant. Necháte-li název prázdný, daný cyklus zůstane mimo seznam cyklů. Názvy musí být jedinečné.\n\nStažený program je program na tomto zařízení, který spouští stažený cyklus. Rozpoznává se automaticky, ale před použitím jej zde potvrďte: výběrem staženého cyklu se do zařízení zapíše tento kód programu.",
|
||||
"data": {
|
||||
"download_course": "Kód programu „Stažený program“"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "Stažené cykly",
|
||||
"description": "Průvodce nastavením vás provede zařízením: na zařízení vyberte stažený cyklus a jakmile je nalezen, pojmenujte ho – jeden po druhém.\n\nÚprava názvů zobrazí vše dosud nalezené najednou – použijte ji později k přejmenování nebo opravě.",
|
||||
"description": "Průvodce nastavením vás provede: v aplikaci SmartThings (ne na samotném zařízení) otevřete toto zařízení a klepněte na „Stažené cykly“ -- samostatný řádek odlišný od „Cyklus“, níže na obrazovce, a ten, na kterém zde záleží. Vyberte tam cyklus, vraťte se sem a jakmile je nalezen, pojmenujte ho – jeden po druhém.\n\nÚprava názvů zobrazí vše dosud nalezené najednou – použijte ji později k přejmenování nebo opravě. Pojmenování funguje v obou případech, ale pojmenovaný cyklus se nabízí jako vybratelný cyklus jen tehdy, když je v nastavení zařízení zapnuto „Nabízet stažené cykly“.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Průvodce nastavením",
|
||||
"cloud_manual": "Upravit názvy"
|
||||
@@ -1498,7 +1576,7 @@
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "Pojmenujte tento cyklus",
|
||||
"description": "Zařízení přepnulo na stažený cyklus (pozice {slot}) a hlásí zbývající čas {remaining}.\n\nZadejte název, který chcete vidět v Home Assistant, a poté na zařízení vyberte další cyklus. Necháte-li pole prázdné, tento cyklus zůstane mimo seznam.\n\nDosud pojmenováno ({named} z {total}): {named_list}\n\nNázvy musí být jedinečné. Toto okno můžete kdykoli zavřít – názvy se ukládají průběžně.",
|
||||
"description": "Pozice „Stažené cykly“ zařízení nyní obsahuje nový cyklus (pozice {slot}) a hlásí zbývající čas {remaining}.\n\nZadejte název, který chcete vidět v Home Assistant, a poté na obrazovce „Stažené cykly“ v aplikaci SmartThings vyberte další cyklus, který ještě není označen jako „Stažený“. Necháte-li pole prázdné, tento cyklus zůstane mimo seznam.\n\nDosud pojmenováno ({named} z {total}): {named_list}\n\nNázvy musí být jedinečné. Toto okno můžete kdykoli zavřít – názvy se ukládají průběžně.",
|
||||
"data": {
|
||||
"name": "Název",
|
||||
"download_course": "Kód programu „Stažený program“"
|
||||
@@ -1506,7 +1584,7 @@
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "Nebyl vybrán žádný cyklus",
|
||||
"description": "Na zařízení nebylo nic vybráno. Ujistěte se, že je nastaveno na Stažený program, a poté postupně procházejte stažené programy.\n\nDosud pojmenováno ({named} z {total}): {named_list}\n\nVše dosud pojmenované je již uloženo.",
|
||||
"description": "Na zařízení se nic nezměnilo. Ujistěte se, že jste v aplikaci SmartThings klepli na „Stažené cykly“ -- samostatný řádek odlišný od „Cyklus“, níže na obrazovce -- a vybrali cyklus, který ještě nebyl označen jako „Stažený“, a poté klepli na Hotovo.\n\nDosud pojmenováno ({named} z {total}): {named_list}\n\nVše dosud pojmenované je již uloženo.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Počkat znovu",
|
||||
"cloud_finish": "Dokončit"
|
||||
@@ -1523,7 +1601,7 @@
|
||||
"not_loaded": "Toto zařízení ještě není připojeno. Zkuste to znovu, až se načte."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "Nyní na zařízení vyberte stažený cyklus.\n\nDosud pojmenováno ({named} z {total}): {named_list}\n\nToto okno můžete kdykoli zavřít – názvy se ukládají průběžně."
|
||||
"cloud_wait": "V aplikaci SmartThings (ne na samotném zařízení) otevřete toto zařízení a klepněte na „Stažené cykly“ -- samostatný řádek odlišný od „Cyklus“, níže na obrazovce, který uvádí pouze cykly rozpoznatelné tímto způsobem. Vyberte cyklus, který ještě není označen jako „Stažený“, a klepněte na Hotovo.\n\nZařízení nemusí být poblíž ani spuštěné -- stačí, když je zapnuté a připojené.\n\nDosud pojmenováno ({named} z {total}): {named_list}\n\nToto okno můžete kdykoli zavřít – názvy se ukládají průběžně."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
@@ -1533,7 +1611,7 @@
|
||||
},
|
||||
"cloud_courses_undiscovered": {
|
||||
"title": "Stažené cykly nejsou nastaveny pro {device_name}",
|
||||
"description": "{device_name} má {pending} z {total} stažených cyklů, které Home Assistant zatím nemůže nabídnout. Stažený cyklus lze použít až poté, co byl na zařízení zjištěn načtený a co jste mu dali název.\n\nChcete-li je nastavit, přejděte do Nastavení > Zařízení a služby > LocalThings > {device_name} > Konfigurovat > Stažené cykly a postupujte podle pokynů."
|
||||
"description": "{device_name} má {pending} z {total} stažených cyklů, které Home Assistant zatím nemůže nabídnout. Stažený cyklus lze použít až poté, co byl na zařízení zjištěn načtený -- na obrazovce „Stažené cykly“ v aplikaci SmartThings, ne na obrazovce „Cyklus“ -- a co jste mu dali název.\n\nChcete-li je nastavit, přejděte do Nastavení > Zařízení a služby > LocalThings > {device_name} > Konfigurovat > Stažené cykly a postupujte podle pokynů.\n\nStažené cykly nepoužíváte? Přejděte místo toho do Konfigurovat > Nastavení zařízení a vypněte „Nabízet stažené cykly“, čímž toto připomenutí natrvalo umlčíte."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
@@ -1558,6 +1636,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": {
|
||||
@@ -369,7 +376,25 @@
|
||||
"4e": "Selbsttrocknung",
|
||||
"17": "Super Speed",
|
||||
"26": "Frischluft",
|
||||
"2a": "Hygienepflege+"
|
||||
"2a": "Hygienepflege+",
|
||||
"02": "KI-Trocknen",
|
||||
"03": "Super Speed",
|
||||
"05": "Bettwäsche",
|
||||
"07": "Feinwäsche",
|
||||
"09": "Hemden",
|
||||
"0b": "Paddingpflege",
|
||||
"0c": "Outdoor-Imprägnierpflege",
|
||||
"0e": "Handtücher",
|
||||
"0f": "Wolle",
|
||||
"11": "Kaltluft",
|
||||
"28": "Innenraum-Heißluftdesinfektion",
|
||||
"37": "Blusen",
|
||||
"38": "Bügeltrocken",
|
||||
"39": "Raumentfeuchtung",
|
||||
"3a": "Hygienepflege",
|
||||
"3b": "Bettwäsche/Entstauben",
|
||||
"3c": "Sportkleidung",
|
||||
"3d": "Jeansstoff"
|
||||
}
|
||||
},
|
||||
"favorite_capacity": {
|
||||
@@ -603,9 +628,31 @@
|
||||
"name": "Programm",
|
||||
"state": {
|
||||
"01": "Normal",
|
||||
"02": "Super Intensiv",
|
||||
"03": "Super Eco",
|
||||
"04": "Schnellwäsche",
|
||||
"06": "XXL-Wäsche",
|
||||
"05": "Wolle/Dessous",
|
||||
"06": "Bettwäsche",
|
||||
"07": "Outdoor",
|
||||
"08": "Spülen + Schleudern",
|
||||
"09": "Trommelreinigung",
|
||||
"0a": "Handtücher",
|
||||
"0b": "Kochwäsche",
|
||||
"0c": "Babypflege",
|
||||
"0d": "Nur Schleudern",
|
||||
"0e": "Bewölkter Tag",
|
||||
"0f": "Reinwäsche",
|
||||
"10": "Trockenschleudern",
|
||||
"11": "Sommer-Bettwäsche",
|
||||
"12": "Baumwolle",
|
||||
"13": "Schwarze Baumwolle",
|
||||
"14": "Feine Unterwäsche",
|
||||
"15": "Sportkleidung",
|
||||
"16": "Blusen",
|
||||
"17": "Heruntergeladen",
|
||||
"18": "Soft Bubble",
|
||||
"19": "KI-Wäsche",
|
||||
"1a": "Hemden",
|
||||
"1b": "Baumwolle",
|
||||
"1c": "Eco 40-60",
|
||||
"1d": "Super Speed",
|
||||
@@ -630,6 +677,7 @@
|
||||
"32": "Hemden",
|
||||
"33": "Handtücher",
|
||||
"34": "Mischwäsche",
|
||||
"35": "Eco Baumwolle",
|
||||
"36": "Waschen + Trocknen",
|
||||
"37": "Air Wash",
|
||||
"38": "Trocknen Baumwolle",
|
||||
@@ -644,16 +692,6 @@
|
||||
"60": "Selbstreinigung+",
|
||||
"65": "Buntwäsche",
|
||||
"66": "Jeansstoff",
|
||||
"7c": "Weißwäsche",
|
||||
"7d": "Bettwäsche/Wasserdicht",
|
||||
"7e": "Selbstreinigung",
|
||||
"7f": "Wolle/Feinwäsche",
|
||||
"86": "Tiefenreinigung",
|
||||
"87": "Download",
|
||||
"8f": "Kaltwäsche Intensiv",
|
||||
"96": "Weniger Mikrofasern",
|
||||
"a0": "Schnelle Wäsche 15'",
|
||||
"17": "Heruntergeladen",
|
||||
"69": "KI-Wäsche",
|
||||
"6a": "Wolle",
|
||||
"6b": "Jeansstoff",
|
||||
@@ -671,8 +709,17 @@
|
||||
"77": "Baumwolle",
|
||||
"78": "Spülen + Schleudern",
|
||||
"79": "Nur Schleudern",
|
||||
"7c": "Weißwäsche",
|
||||
"7d": "Bettwäsche/Wasserdicht",
|
||||
"7e": "Selbstreinigung",
|
||||
"7f": "Wolle/Feinwäsche",
|
||||
"86": "Tiefenreinigung",
|
||||
"87": "Download",
|
||||
"88": "Tierpflege",
|
||||
"35": "Eco Baumwolle"
|
||||
"8f": "Kaltwäsche Intensiv",
|
||||
"96": "Weniger Mikrofasern",
|
||||
"a0": "Schnelle Wäsche 15'",
|
||||
"b0": "Gemischte Beladung"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
@@ -872,6 +919,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 +966,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 +990,10 @@
|
||||
"name": "Trockenzeit"
|
||||
},
|
||||
"dryer_type": {
|
||||
"name": "Trocknertyp"
|
||||
"name": "Trocknertyp",
|
||||
"state": {
|
||||
"electricity": "Strom"
|
||||
}
|
||||
},
|
||||
"dust": {
|
||||
"name": "Staub"
|
||||
@@ -1060,7 +1122,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"
|
||||
@@ -1445,11 +1522,12 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Geräteeinstellungen",
|
||||
"description": "Manche Geräte akzeptieren bestimmte Schreibvorgänge (z. B. die Standard-Dosierung von Waschmittel/Weichspüler bei einer Waschmaschine) auch dann, wenn sie melden, dass die Fernsteuerung ausgeschaltet ist. Standardmäßig blockiert LocalThings jeden Schreibvorgang mit einer klaren Fehlermeldung, sobald ein Gerät meldet, dass die Fernsteuerung ausgeschaltet ist, statt das Gerät den Vorgang stillschweigend ablehnen zu lassen. Aktivieren Sie dies nur, wenn Sie bestätigt haben, dass Schreibvorgänge bei diesem Gerät auch mit ausgeschalteter Fernsteuerung tatsächlich funktionieren -- andernfalls tauschen Sie die klare Fehlermeldung gegen ein stilles Fehlschlagen ein.\n\nDie voraussichtliche Fertigstellungszeit wird bei jeder Abfrage aus der verbleibenden Zeitschätzung des Geräts neu berechnet, die zwischen den Aktualisierungen um ein bis zwei Minuten schwanken oder korrigiert werden kann. Erhöhen Sie den unten stehenden Mindeständerungswert, um den Sensor bis zu einer Änderung der Schätzung um mindestens diese Anzahl an Minuten auf seinem zuletzt gemeldeten Wert zu halten und so das Rauschen in Verlauf/Logbuch zu verringern. Setzen Sie ihn auf 0, um jede berechnete Änderung zu melden.\n\nManche Modelle melden einen Modus, den sie nie als unterstützt angeben -- etwa eine Klimaanlage im Modus „Leise“, die nur Aus/Schlaf/Schnell anbietet. LocalThings merkt sich jeden solchen gemeldeten Modus und bietet ihn weiterhin an, sodass er auswählbar bleibt, sobald das Gerät mindestens einmal darin war. Schalten Sie dies aus, um nur das anzubieten, was das Gerät selbst angibt; verwenden Sie „Gemerkte Modi vergessen“ auf dem vorherigen Bildschirm, um bereits Gemerktes zu löschen.",
|
||||
"description": "Manche Geräte akzeptieren bestimmte Schreibvorgänge (z. B. die Standard-Dosierung von Waschmittel/Weichspüler bei einer Waschmaschine) auch dann, wenn sie melden, dass die Fernsteuerung ausgeschaltet ist. Standardmäßig blockiert LocalThings jeden Schreibvorgang mit einer klaren Fehlermeldung, sobald ein Gerät meldet, dass die Fernsteuerung ausgeschaltet ist, statt das Gerät den Vorgang stillschweigend ablehnen zu lassen. Aktivieren Sie dies nur, wenn Sie bestätigt haben, dass Schreibvorgänge bei diesem Gerät auch mit ausgeschalteter Fernsteuerung tatsächlich funktionieren -- andernfalls tauschen Sie die klare Fehlermeldung gegen ein stilles Fehlschlagen ein.\n\nDie voraussichtliche Fertigstellungszeit wird bei jeder Abfrage aus der verbleibenden Zeitschätzung des Geräts neu berechnet, die zwischen den Aktualisierungen um ein bis zwei Minuten schwanken oder korrigiert werden kann. Erhöhen Sie den unten stehenden Mindeständerungswert, um den Sensor bis zu einer Änderung der Schätzung um mindestens diese Anzahl an Minuten auf seinem zuletzt gemeldeten Wert zu halten und so das Rauschen in Verlauf/Logbuch zu verringern. Setzen Sie ihn auf 0, um jede berechnete Änderung zu melden.\n\nManche Modelle melden einen Modus, den sie nie als unterstützt angeben -- etwa eine Klimaanlage im Modus „Leise“, die nur Aus/Schlaf/Schnell anbietet. LocalThings merkt sich jeden solchen gemeldeten Modus und bietet ihn weiterhin an, sodass er auswählbar bleibt, sobald das Gerät mindestens einmal darin war. Schalten Sie dies aus, um nur das anzubieten, was das Gerät selbst angibt; verwenden Sie „Gemerkte Modi vergessen“ auf dem vorherigen Bildschirm, um bereits Gemerktes zu löschen. Ein Wäschegerät mit aus der Cloud heruntergeladenen Programmen erhält hier eine Erinnerung, sobald es eines gemeldet hat, das Home Assistant noch nicht anbieten kann -- manche Geräte melden eines, auch wenn Sie den Bildschirm \"Download-Programme\" in der SmartThings-App nie selbst geöffnet haben. Schalten Sie dies aus, um diese Erinnerung zu stoppen und bereits benannte heruntergeladene Programme aus der Programmliste auszublenden; bereits Eingerichtetes geht dabei nicht verloren, und beim erneuten Einschalten erscheint es sofort wieder.",
|
||||
"data": {
|
||||
"bypass_remote_control_lock": "Schreibvorgänge auch bei ausgeschalteter Fernsteuerung zulassen",
|
||||
"finish_time_hysteresis_minutes": "Voraussichtliches Ende -- Mindeständerung (Minuten)",
|
||||
"learn_device_modes": "Vom Gerät gemeldete, aber nicht angegebene Modi merken"
|
||||
"learn_device_modes": "Vom Gerät gemeldete, aber nicht angegebene Modi merken",
|
||||
"cloud_courses_enabled": "Heruntergeladene Programme anbieten"
|
||||
}
|
||||
},
|
||||
"debug_write": {
|
||||
@@ -1480,14 +1558,14 @@
|
||||
},
|
||||
"cloud_manual": {
|
||||
"title": "Download-Programme",
|
||||
"description": "Dieses Gerät meldet {total} heruntergeladene Programme; {found} davon wurden bisher erkannt.\n\nDie Einstellungen eines heruntergeladenen Programms sind nur sichtbar, während dieses Programm geladen ist, und das Gerät meldet nie deren Namen. Um die fehlenden ({pending}) hinzuzufügen: Wählen Sie am Gerät das Download-Programm aus, gehen Sie dann nacheinander jedes heruntergeladene Programm durch, halten Sie bei jedem ein paar Sekunden inne, und kommen Sie danach hierher zurück.\n\nGeben Sie jedem den Namen, den Sie in Home Assistant sehen möchten. Lassen Sie einen Namen leer, um das Programm aus der Programmliste auszuschließen. Namen müssen eindeutig sein.\n\nDas Download-Programm ist das Programm auf diesem Gerät, das ein heruntergeladenes Programm ausführt. Es wird automatisch erkannt, bestätigen Sie es aber hier vor der Verwendung: Die Auswahl eines heruntergeladenen Programms schreibt diesen Programmcode auf das Gerät.",
|
||||
"description": "Dieses Gerät meldet {total} heruntergeladene Programme; {found} davon wurden bisher erkannt.\n\nDie Einstellungen eines heruntergeladenen Programms sind nur sichtbar, während dieses Programm geladen ist, und das Gerät meldet nie deren Namen. Um die fehlenden ({pending}) hinzuzufügen: Öffnen Sie in der SmartThings-App (nicht am Gerät selbst) dieses Gerät und tippen Sie auf \"Download-Programme\" -- eine eigene Zeile, getrennt von \"Programm\", weiter unten im Bildschirm, und die, auf die es hier ankommt. Gehen Sie dann nacheinander jedes heruntergeladene Programm durch, halten Sie bei jedem ein paar Sekunden inne, damit LocalThings es erkennen kann, und kommen Sie danach hierher zurück.\n\nGeben Sie jedem den Namen, den Sie in Home Assistant sehen möchten. Lassen Sie einen Namen leer, um das Programm aus der Programmliste auszuschließen. Namen müssen eindeutig sein.\n\nDas Download-Programm ist das Programm auf diesem Gerät, das ein heruntergeladenes Programm ausführt. Es wird automatisch erkannt, bestätigen Sie es aber hier vor der Verwendung: Die Auswahl eines heruntergeladenen Programms schreibt diesen Programmcode auf das Gerät.",
|
||||
"data": {
|
||||
"download_course": "Programmcode des Download-Programms"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "Download-Programme",
|
||||
"description": "Die geführte Einrichtung begleitet Sie am Gerät: Wählen Sie am Gerät ein heruntergeladenes Programm aus und benennen Sie es, sobald es gefunden wird – eines nach dem anderen.\n\nNamen bearbeiten zeigt alles bisher Gefundene auf einmal an – nutzen Sie es später, um etwas umzubenennen oder zu korrigieren.",
|
||||
"description": "Die geführte Einrichtung begleitet Sie dabei: Öffnen Sie in der SmartThings-App (nicht am Gerät selbst) dieses Gerät und tippen Sie auf \"Download-Programme\" -- eine eigene Zeile, getrennt von \"Programm\", weiter unten im Bildschirm, und die, auf die es hier ankommt. Wählen Sie dort ein Programm aus, kommen Sie dann zurück und benennen Sie es, sobald es gefunden wird – eines nach dem anderen.\n\nNamen bearbeiten zeigt alles bisher Gefundene auf einmal an – nutzen Sie es später, um etwas umzubenennen oder zu korrigieren. Benennen funktioniert so oder so, aber ein benanntes Programm wird nur dann als auswählbares Programm angeboten, wenn \"Heruntergeladene Programme anbieten\" in den Geräteeinstellungen aktiviert ist.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Geführte Einrichtung",
|
||||
"cloud_manual": "Namen bearbeiten"
|
||||
@@ -1498,7 +1576,7 @@
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "Dieses Programm benennen",
|
||||
"description": "Das Gerät ist zu einem heruntergeladenen Programm gewechselt (Platz {slot}) und meldet eine verbleibende Zeit von {remaining}.\n\nGeben Sie den Namen ein, den Sie in Home Assistant sehen möchten, und wählen Sie dann am Gerät das nächste aus. Lassen Sie das Feld leer, um dieses Programm von der Liste auszuschließen.\n\nBisher benannt ({named} von {total}): {named_list}\n\nNamen müssen eindeutig sein. Sie können dieses Fenster jederzeit schließen – die Namen werden fortlaufend gespeichert.",
|
||||
"description": "Der Platz \"Download-Programme\" des Geräts enthält jetzt ein neues Programm (Platz {slot}) und meldet eine verbleibende Zeit von {remaining}.\n\nGeben Sie den Namen ein, den Sie in Home Assistant sehen möchten, und wählen Sie dann im Bildschirm \"Download-Programme\" der SmartThings-App das nächste Programm aus, das noch nicht als \"Heruntergeladen\" markiert ist. Lassen Sie das Feld leer, um dieses Programm von der Liste auszuschließen.\n\nBisher benannt ({named} von {total}): {named_list}\n\nNamen müssen eindeutig sein. Sie können dieses Fenster jederzeit schließen – die Namen werden fortlaufend gespeichert.",
|
||||
"data": {
|
||||
"name": "Name",
|
||||
"download_course": "Programmcode des Download-Programms"
|
||||
@@ -1506,7 +1584,7 @@
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "Kein Programm ausgewählt",
|
||||
"description": "Am Gerät wurde nichts ausgewählt. Stellen Sie sicher, dass es auf das Download-Programm eingestellt ist, und gehen Sie dann die heruntergeladenen Programme durch.\n\nBisher benannt ({named} von {total}): {named_list}\n\nAlles bisher Benannte ist bereits gespeichert.",
|
||||
"description": "Am Gerät hat sich nichts geändert. Stellen Sie sicher, dass Sie in der SmartThings-App auf \"Download-Programme\" getippt haben -- eine eigene Zeile, getrennt von \"Programm\", weiter unten im Bildschirm -- und ein Programm ausgewählt haben, das noch nicht als \"Heruntergeladen\" markiert war, und dann auf Fertig getippt haben.\n\nBisher benannt ({named} von {total}): {named_list}\n\nAlles bisher Benannte ist bereits gespeichert.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Erneut warten",
|
||||
"cloud_finish": "Fertig"
|
||||
@@ -1523,7 +1601,7 @@
|
||||
"not_loaded": "Dieses Gerät ist noch nicht verbunden. Versuchen Sie es erneut, sobald es geladen wurde."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "Wählen Sie jetzt ein heruntergeladenes Programm am Gerät aus.\n\nBisher benannt ({named} von {total}): {named_list}\n\nSie können dieses Fenster jederzeit schließen – die Namen werden fortlaufend gespeichert."
|
||||
"cloud_wait": "Öffnen Sie in der SmartThings-App (nicht am Gerät selbst) dieses Gerät und tippen Sie auf \"Download-Programme\" -- eine eigene Zeile, getrennt von \"Programm\", weiter unten im Bildschirm, die nur die Programme auflistet, die auf diese Weise erkannt werden können. Wählen Sie eines aus, das noch nicht als \"Heruntergeladen\" markiert ist, und tippen Sie auf Fertig.\n\nDas Gerät muss dafür nicht in der Nähe sein oder das Programm ausführen -- es muss nur eingeschaltet und verbunden sein.\n\nBisher benannt ({named} von {total}): {named_list}\n\nSie können dieses Fenster jederzeit schließen – die Namen werden fortlaufend gespeichert."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
@@ -1533,7 +1611,7 @@
|
||||
},
|
||||
"cloud_courses_undiscovered": {
|
||||
"title": "Heruntergeladene Programme für {device_name} nicht eingerichtet",
|
||||
"description": "{device_name} hat {pending} von {total} heruntergeladenen Programmen, die Home Assistant noch nicht anbieten kann. Ein heruntergeladenes Programm kann erst verwendet werden, sobald das Gerät damit geladen gesehen wurde und Sie ihm einen Namen gegeben haben.\n\nUm sie einzurichten, gehen Sie zu Einstellungen > Geräte & Dienste > LocalThings > {device_name} > Konfigurieren > Download-Programme und folgen Sie den dortigen Anweisungen."
|
||||
"description": "{device_name} hat {pending} von {total} heruntergeladenen Programmen, die Home Assistant noch nicht anbieten kann. Ein heruntergeladenes Programm kann erst verwendet werden, sobald das Gerät damit geladen gesehen wurde -- im Bildschirm \"Download-Programme\" der SmartThings-App, nicht im Bildschirm \"Programm\" -- und Sie ihm einen Namen gegeben haben.\n\nUm sie einzurichten, gehen Sie zu Einstellungen > Geräte & Dienste > LocalThings > {device_name} > Konfigurieren > Download-Programme und folgen Sie den dortigen Anweisungen.\n\nNutzen Sie keine heruntergeladenen Programme? Gehen Sie stattdessen zu Konfigurieren > Geräteeinstellungen und schalten Sie \"Heruntergeladene Programme anbieten\" aus, um diese Erinnerung dauerhaft zu stoppen."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
@@ -1558,6 +1636,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": {
|
||||
@@ -369,7 +376,25 @@
|
||||
"4c": "Air Refresh",
|
||||
"51": "Eco Cotton",
|
||||
"53": "AI Dry+",
|
||||
"4e": "Self Dry"
|
||||
"4e": "Self Dry",
|
||||
"02": "AI Dry",
|
||||
"03": "Super Speed",
|
||||
"05": "Bedding",
|
||||
"07": "Delicates",
|
||||
"09": "Shirts",
|
||||
"0b": "Padding Care",
|
||||
"0c": "Outdoor Water-Repellent Care",
|
||||
"0e": "Towels",
|
||||
"0f": "Wool",
|
||||
"11": "Cool air",
|
||||
"28": "Interior Hot Air Sanitize",
|
||||
"37": "Blouses",
|
||||
"38": "Iron dry",
|
||||
"39": "Room Dehumidify",
|
||||
"3a": "Hygiene Care",
|
||||
"3b": "Bedding/Dust Off",
|
||||
"3c": "Activewear",
|
||||
"3d": "Denim"
|
||||
}
|
||||
},
|
||||
"favorite_capacity": {
|
||||
@@ -640,10 +665,31 @@
|
||||
"name": "Cycle",
|
||||
"state": {
|
||||
"01": "Normal",
|
||||
"02": "Extra Heavy Duty",
|
||||
"03": "Super Eco Wash",
|
||||
"04": "Quick Wash",
|
||||
"06": "XXL Laundry",
|
||||
"05": "Wool/Lingerie",
|
||||
"06": "Bedding",
|
||||
"07": "Outdoor",
|
||||
"08": "Rinse+Spin",
|
||||
"09": "Drum Clean",
|
||||
"0a": "Towels",
|
||||
"0b": "Boil Wash",
|
||||
"0c": "Baby Care",
|
||||
"0d": "Spin Only",
|
||||
"0e": "Cloudy Day",
|
||||
"0f": "Pure Wash",
|
||||
"10": "Spin Dry",
|
||||
"11": "Summer Bedding",
|
||||
"12": "Cottons",
|
||||
"13": "Black Cottons",
|
||||
"14": "Delicate Underwear",
|
||||
"15": "Activewear",
|
||||
"16": "Blouses",
|
||||
"17": "Downloaded",
|
||||
"18": "Soft Bubble",
|
||||
"19": "AI Wash",
|
||||
"1a": "Shirts",
|
||||
"1b": "Cotton",
|
||||
"1c": "Eco 40-60",
|
||||
"1d": "Super Speed",
|
||||
@@ -709,7 +755,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 +919,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 +972,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 +996,10 @@
|
||||
"name": "Dry time"
|
||||
},
|
||||
"dryer_type": {
|
||||
"name": "Dryer type"
|
||||
"name": "Dryer type",
|
||||
"state": {
|
||||
"electricity": "Electricity"
|
||||
}
|
||||
},
|
||||
"dust": {
|
||||
"name": "Dust"
|
||||
@@ -1066,7 +1128,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"
|
||||
@@ -1445,11 +1522,12 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Device settings",
|
||||
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.\n\nEstimated finish time is recomputed from the device's remaining-time estimate on every poll, which can drift or get revised by a minute or two between updates. Raise the minimum-change value below to hold the sensor at its last reported value until the estimate moves by at least that many minutes, cutting down on history/logbook noise. Set it to 0 to report every computed change.\n\nSome models report a mode they never list as supported -- an air conditioner sitting in Quiet that only offers Off/Sleep/Speed, for instance. LocalThings remembers any such mode it sees and keeps offering it, so it stays selectable once the device has been in it at least once. Turn this off to offer only what the device advertises; use \"Forget remembered modes\" on the previous screen to clear what has already been remembered.",
|
||||
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.\n\nEstimated finish time is recomputed from the device's remaining-time estimate on every poll, which can drift or get revised by a minute or two between updates. Raise the minimum-change value below to hold the sensor at its last reported value until the estimate moves by at least that many minutes, cutting down on history/logbook noise. Set it to 0 to report every computed change.\n\nSome models report a mode they never list as supported -- an air conditioner sitting in Quiet that only offers Off/Sleep/Speed, for instance. LocalThings remembers any such mode it sees and keeps offering it, so it stays selectable once the device has been in it at least once. Turn this off to offer only what the device advertises; use \"Forget remembered modes\" on the previous screen to clear what has already been remembered.\n\nA laundry device with cloud-downloaded cycles gets a reminder here once it's reported one Home Assistant can't offer yet -- some devices report one even if you've never opened the SmartThings app's \"Download cycles\" screen yourself. Turn this off to stop that reminder and hide any already-named downloaded cycles from the cycle list; nothing already set up is lost, and turning it back on brings it right back.",
|
||||
"data": {
|
||||
"bypass_remote_control_lock": "Allow writes even when remote control is reported off",
|
||||
"finish_time_hysteresis_minutes": "Estimated finish -- minimum change (minutes)",
|
||||
"learn_device_modes": "Remember modes the device reports but doesn't advertise"
|
||||
"learn_device_modes": "Remember modes the device reports but doesn't advertise",
|
||||
"cloud_courses_enabled": "Offer downloaded cycles"
|
||||
}
|
||||
},
|
||||
"forget_learned_modes": {
|
||||
@@ -1480,14 +1558,14 @@
|
||||
},
|
||||
"cloud_manual": {
|
||||
"title": "Download cycles",
|
||||
"description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle, then step through each downloaded program in turn, pausing a few seconds on each, and come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.",
|
||||
"description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): in the SmartThings app (not on the appliance itself), open this appliance and tap \"Download cycles\" -- a separate row from \"Cycle\", further down the screen, and the one that matters here. Step through each downloaded program in turn, pausing a few seconds on each so LocalThings can see it, then come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.",
|
||||
"data": {
|
||||
"download_course": "Download cycle course code"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "Download cycles",
|
||||
"description": "Guided setup walks the appliance with you: select a downloaded cycle on the appliance and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later.",
|
||||
"description": "Guided setup walks you through it: in the SmartThings app (not on the appliance itself), open this appliance and tap \"Download cycles\" -- a separate row from \"Cycle\", further down the screen, and the one that matters here. Pick a cycle there, then come back and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later. Naming works either way, but a named cycle is only offered as a selectable cycle while \"Offer downloaded cycles\" is on in Device settings.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Guided setup",
|
||||
"cloud_manual": "Edit names"
|
||||
@@ -1498,7 +1576,7 @@
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "Name this cycle",
|
||||
"description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\nNamed so far ({named} of {total}): {named_list}\n\nNames must be unique. You can close this dialog whenever you like — names are saved as you go.",
|
||||
"description": "The appliance's \"Download cycles\" slot now holds a new cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then in the SmartThings app's \"Download cycles\" screen pick the next one that isn't already marked \"Downloaded\". Leave it blank to keep this cycle out of the list.\n\nNamed so far ({named} of {total}): {named_list}\n\nNames must be unique. You can close this dialog whenever you like — names are saved as you go.",
|
||||
"data": {
|
||||
"name": "Name",
|
||||
"download_course": "Download cycle course code"
|
||||
@@ -1506,7 +1584,7 @@
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "No cycle selected",
|
||||
"description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\nNamed so far ({named} of {total}): {named_list}\n\nEverything named so far is already saved.",
|
||||
"description": "Nothing changed on the appliance. Make sure you tapped \"Download cycles\" in the SmartThings app -- a separate row from \"Cycle\", further down the screen -- and picked one that wasn't already marked \"Downloaded\", then tapped Done.\n\nNamed so far ({named} of {total}): {named_list}\n\nEverything named so far is already saved.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Wait again",
|
||||
"cloud_finish": "Finish"
|
||||
@@ -1523,7 +1601,7 @@
|
||||
"not_loaded": "This device isn't connected yet. Try again once it has loaded."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "Select a downloaded cycle on the appliance now.\n\nNamed so far ({named} of {total}): {named_list}\n\nYou can close this dialog at any time — names are saved as you go."
|
||||
"cloud_wait": "In the SmartThings app (not on the appliance itself), open this appliance and tap \"Download cycles\" -- a separate row from \"Cycle\", further down the screen, that lists only the cycles this can detect. Pick one that isn't already marked \"Downloaded\", then tap Done.\n\nThe appliance doesn't need to be nearby or running the cycle -- just powered on and connected.\n\nNamed so far ({named} of {total}): {named_list}\n\nYou can close this dialog at any time — names are saved as you go."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
@@ -1533,7 +1611,7 @@
|
||||
},
|
||||
"cloud_courses_undiscovered": {
|
||||
"title": "Downloaded cycles not set up for {device_name}",
|
||||
"description": "{device_name} has {pending} of {total} downloaded cycle(s) that Home Assistant can't offer yet. A downloaded cycle can only be used once the appliance has been seen with it loaded and you've given it a name.\n\nTo set them up, go to Settings > Devices & Services > LocalThings > {device_name} > Configure > Download cycles and follow the instructions there."
|
||||
"description": "{device_name} has {pending} of {total} downloaded cycle(s) that Home Assistant can't offer yet. A downloaded cycle can only be used once the appliance has been seen with it loaded -- in the SmartThings app's \"Download cycles\" screen, not the \"Cycle\" one -- and you've given it a name.\n\nTo set them up, go to Settings > Devices & Services > LocalThings > {device_name} > Configure > Download cycles and follow the instructions there.\n\nDon't use downloaded cycles? Go to Configure > Device settings instead and turn off \"Offer downloaded cycles\" to silence this reminder for good."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
@@ -1558,6 +1636,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."
|
||||
},
|
||||
|
||||
@@ -60,11 +60,12 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ajustes del dispositivo",
|
||||
"description": "Algunos dispositivos aceptan ciertas escrituras (p. ej. la dosificación por defecto de detergente/suavizante en una lavadora) incluso cuando informan de control remoto desactivado. Por defecto, LocalThings bloquea cada escritura con un error claro cuando un dispositivo informa de control remoto desactivado, en lugar de dejar que el dispositivo la rechace en silencio. Solo actívalo si has confirmado que las escrituras funcionan de verdad en este dispositivo con el control remoto desactivado; si no, cambiarás ese error claro por un fallo silencioso.\n\nLa hora estimada de finalización se recalcula a partir de la estimación de tiempo restante del dispositivo en cada sondeo, que puede variar o revisarse uno o dos minutos entre actualizaciones. Sube el valor de cambio mínimo siguiente para mantener el sensor en su último valor notificado hasta que la estimación varíe al menos esa cantidad de minutos, reduciendo el ruido en el historial/registro de actividad. Ponlo a 0 para notificar cada cambio calculado.\n\nAlgunos modelos notifican un modo que nunca incluyen entre los admitidos: por ejemplo, un aire acondicionado en modo Quiet que solo ofrece Off/Sleep/Speed. LocalThings recuerda cualquier modo así que detecte y lo sigue ofreciendo, de modo que quede seleccionable en cuanto el dispositivo haya estado en él al menos una vez. Desactívalo para ofrecer solo lo que el dispositivo anuncia; usa «Olvidar los modos recordados» en la pantalla anterior para borrar lo ya recordado.",
|
||||
"description": "Algunos dispositivos aceptan ciertas escrituras (p. ej. la dosificación por defecto de detergente/suavizante en una lavadora) incluso cuando informan de control remoto desactivado. Por defecto, LocalThings bloquea cada escritura con un error claro cuando un dispositivo informa de control remoto desactivado, en lugar de dejar que el dispositivo la rechace en silencio. Solo actívalo si has confirmado que las escrituras funcionan de verdad en este dispositivo con el control remoto desactivado; si no, cambiarás ese error claro por un fallo silencioso.\n\nLa hora estimada de finalización se recalcula a partir de la estimación de tiempo restante del dispositivo en cada sondeo, que puede variar o revisarse uno o dos minutos entre actualizaciones. Sube el valor de cambio mínimo siguiente para mantener el sensor en su último valor notificado hasta que la estimación varíe al menos esa cantidad de minutos, reduciendo el ruido en el historial/registro de actividad. Ponlo a 0 para notificar cada cambio calculado.\n\nAlgunos modelos notifican un modo que nunca incluyen entre los admitidos: por ejemplo, un aire acondicionado en modo Quiet que solo ofrece Off/Sleep/Speed. LocalThings recuerda cualquier modo así que detecte y lo sigue ofreciendo, de modo que quede seleccionable en cuanto el dispositivo haya estado en él al menos una vez. Desactívalo para ofrecer solo lo que el dispositivo anuncia; usa «Olvidar los modos recordados» en la pantalla anterior para borrar lo ya recordado. Un dispositivo de lavandería con ciclos descargados de la nube recibe aquí un aviso en cuanto informa de uno que Home Assistant aún no puede ofrecer -- algunos dispositivos informan de uno incluso si nunca has abierto tú mismo la pantalla \"Descarga de Programas\" de la app SmartThings. Desactiva esto para detener ese aviso y ocultar los ciclos descargados ya nombrados de la lista de ciclos; no se pierde nada de lo ya configurado, y al volver a activarlo reaparece de inmediato.",
|
||||
"data": {
|
||||
"bypass_remote_control_lock": "Permitir escrituras incluso cuando el control remoto se notifica como desactivado",
|
||||
"finish_time_hysteresis_minutes": "Finalización estimada -- cambio mínimo (minutos)",
|
||||
"learn_device_modes": "Recordar los modos que el dispositivo notifica pero no anuncia"
|
||||
"learn_device_modes": "Recordar los modos que el dispositivo notifica pero no anuncia",
|
||||
"cloud_courses_enabled": "Ofrecer ciclos descargados"
|
||||
}
|
||||
},
|
||||
"forget_learned_modes": {
|
||||
@@ -95,14 +96,14 @@
|
||||
},
|
||||
"cloud_manual": {
|
||||
"title": "Ciclos descargados",
|
||||
"description": "Este dispositivo informa de {total} ciclos descargados; se han detectado {found} hasta ahora.\n\nLos ajustes de un ciclo descargado solo son visibles mientras ese ciclo está cargado, y el dispositivo nunca informa de sus nombres. Para añadir los que faltan ({pending}): en el dispositivo, selecciona Descarga de Programas y ve pasando por cada programa descargado uno a uno, deteniéndote unos segundos en cada uno, y vuelve aquí.\n\nDa a cada uno el nombre que quieras ver en Home Assistant. Deja un nombre en blanco para mantener ese ciclo fuera de la lista de ciclos. Los nombres deben ser únicos.\n\nDescarga de Programas es el programa de este dispositivo que ejecuta un ciclo descargado. Se detecta automáticamente, pero confírmalo aquí antes de usarlo: seleccionar un ciclo descargado escribe este código de programa en el dispositivo.",
|
||||
"description": "Este dispositivo informa de {total} ciclos descargados; se han detectado {found} hasta ahora.\n\nLos ajustes de un ciclo descargado solo son visibles mientras ese ciclo está cargado, y el dispositivo nunca informa de sus nombres. Para añadir los que faltan ({pending}): en la app SmartThings (no en el dispositivo), abre este dispositivo y toca \"Descarga de Programas\" -- una fila aparte de \"Ciclo\", más abajo en la pantalla, y la que importa aquí. Ve pasando por cada programa descargado uno a uno, deteniéndote unos segundos en cada uno para que LocalThings pueda detectarlo, y vuelve aquí.\n\nDa a cada uno el nombre que quieras ver en Home Assistant. Deja un nombre en blanco para mantener ese ciclo fuera de la lista de ciclos. Los nombres deben ser únicos.\n\nDescarga de Programas es el programa de este dispositivo que ejecuta un ciclo descargado. Se detecta automáticamente, pero confírmalo aquí antes de usarlo: seleccionar un ciclo descargado escribe este código de programa en el dispositivo.",
|
||||
"data": {
|
||||
"download_course": "Código de programa de Descarga de Programas"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "Ciclos descargados",
|
||||
"description": "La configuración guiada te acompaña con el dispositivo: selecciona un ciclo descargado en el dispositivo y ponle nombre en cuanto se detecte, uno por uno.\n\nEditar nombres muestra todo lo encontrado hasta ahora de una vez — úsalo más adelante para renombrar o corregir algo.",
|
||||
"description": "La configuración guiada te acompaña: en la app SmartThings (no en el dispositivo), abre este dispositivo y toca \"Descarga de Programas\" -- una fila aparte de \"Ciclo\", más abajo en la pantalla, y la que importa aquí. Elige un ciclo allí, vuelve aquí y ponle nombre en cuanto se detecte, uno por uno.\n\nEditar nombres muestra todo lo encontrado hasta ahora de una vez — úsalo más adelante para renombrar o corregir algo. Nombrar funciona en cualquier caso, pero un ciclo con nombre solo se ofrece como ciclo seleccionable mientras \"Ofrecer ciclos descargados\" esté activado en los ajustes del dispositivo.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Configuración guiada",
|
||||
"cloud_manual": "Editar nombres"
|
||||
@@ -113,7 +114,7 @@
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "Nombra este ciclo",
|
||||
"description": "El dispositivo cambió a un ciclo descargado (ranura {slot}) e informa de {remaining} restantes.\n\nDale el nombre que quieras ver en Home Assistant y luego selecciona el siguiente en el dispositivo. Déjalo en blanco para mantener este ciclo fuera de la lista.\n\nNombrados hasta ahora ({named} de {total}): {named_list}\n\nLos nombres deben ser únicos. Puedes cerrar este cuadro de diálogo cuando quieras — los nombres se guardan sobre la marcha.",
|
||||
"description": "La ranura \"Descarga de Programas\" del dispositivo ahora tiene un ciclo nuevo (ranura {slot}) e informa de {remaining} restantes.\n\nDale el nombre que quieras ver en Home Assistant y luego, en la pantalla \"Descarga de Programas\" de la app SmartThings, elige el siguiente que no esté ya marcado como \"Descargado\". Déjalo en blanco para mantener este ciclo fuera de la lista.\n\nNombrados hasta ahora ({named} de {total}): {named_list}\n\nLos nombres deben ser únicos. Puedes cerrar este cuadro de diálogo cuando quieras — los nombres se guardan sobre la marcha.",
|
||||
"data": {
|
||||
"name": "Nombre",
|
||||
"download_course": "Código de programa de Descarga de Programas"
|
||||
@@ -121,7 +122,7 @@
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "No se seleccionó ningún ciclo",
|
||||
"description": "No se seleccionó nada en el dispositivo. Asegúrate de que esté puesto en Descarga de Programas y luego ve pasando por los programas descargados.\n\nNombrados hasta ahora ({named} de {total}): {named_list}\n\nTodo lo nombrado hasta ahora ya está guardado.",
|
||||
"description": "No ha cambiado nada en el dispositivo. Asegúrate de haber tocado \"Descarga de Programas\" en la app SmartThings -- una fila aparte de \"Ciclo\", más abajo en la pantalla -- y de haber elegido uno que no estuviera ya marcado como \"Descargado\", y luego tocado Hecho.\n\nNombrados hasta ahora ({named} de {total}): {named_list}\n\nTodo lo nombrado hasta ahora ya está guardado.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Esperar de nuevo",
|
||||
"cloud_finish": "Finalizar"
|
||||
@@ -138,7 +139,7 @@
|
||||
"not_loaded": "Este dispositivo aún no está conectado. Inténtalo de nuevo cuando se haya cargado."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "Selecciona ahora un ciclo descargado en el dispositivo.\n\nNombrados hasta ahora ({named} de {total}): {named_list}\n\nPuedes cerrar este cuadro de diálogo en cualquier momento — los nombres se guardan sobre la marcha."
|
||||
"cloud_wait": "En la app SmartThings (no en el dispositivo), abre este dispositivo y toca \"Descarga de Programas\" -- una fila aparte de \"Ciclo\", más abajo en la pantalla, que solo lista los ciclos que se pueden detectar así. Elige uno que no esté ya marcado como \"Descargado\" y toca Hecho.\n\nEl dispositivo no necesita estar cerca ni ejecutando el ciclo -- solo encendido y conectado.\n\nNombrados hasta ahora ({named} de {total}): {named_list}\n\nPuedes cerrar este cuadro de diálogo en cualquier momento — los nombres se guardan sobre la marcha."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
@@ -148,7 +149,7 @@
|
||||
},
|
||||
"cloud_courses_undiscovered": {
|
||||
"title": "Ciclos descargados sin configurar para {device_name}",
|
||||
"description": "{device_name} tiene {pending} de {total} ciclos descargados que Home Assistant todavía no puede ofrecer. Un ciclo descargado solo se puede usar una vez que el dispositivo se ha visto con él cargado y le has dado un nombre.\n\nPara configurarlos, ve a Ajustes > Dispositivos y servicios > LocalThings > {device_name} > Configurar > Ciclos descargados y sigue las instrucciones que aparecen allí."
|
||||
"description": "{device_name} tiene {pending} de {total} ciclos descargados que Home Assistant todavía no puede ofrecer. Un ciclo descargado solo se puede usar una vez que el dispositivo se ha visto con él cargado -- en la pantalla \"Descarga de Programas\" de la app SmartThings, no en la de \"Ciclo\" -- y le has dado un nombre.\n\nPara configurarlos, ve a Ajustes > Dispositivos y servicios > LocalThings > {device_name} > Configurar > Ciclos descargados y sigue las instrucciones que aparecen allí.\n\n¿No usas ciclos descargados? Ve a Configurar > Ajustes del dispositivo y desactiva \"Ofrecer ciclos descargados\" para silenciar este aviso definitivamente."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
@@ -173,6 +174,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 +305,9 @@
|
||||
},
|
||||
"auto_clean_running": {
|
||||
"name": "Limpieza automática en curso"
|
||||
},
|
||||
"stick_ble_connected": {
|
||||
"name": "Aspiradora conectada por BLE"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
@@ -451,7 +458,11 @@
|
||||
"name": "Volumen",
|
||||
"state": {
|
||||
"off": "Apagado",
|
||||
"on": "Encendido"
|
||||
"on": "Encendido",
|
||||
"volume_off": "Apagado",
|
||||
"volume_low": "Bajo",
|
||||
"volume_med": "Medio",
|
||||
"volume_high": "Alto"
|
||||
}
|
||||
},
|
||||
"discharging_time": {
|
||||
@@ -562,7 +573,25 @@
|
||||
"53": "Secado IA+",
|
||||
"4e": "Autosecado",
|
||||
"26": "Aireación",
|
||||
"2a": "Cuidado higiénico+"
|
||||
"2a": "Cuidado higiénico+",
|
||||
"02": "Secado IA",
|
||||
"03": "Súper velocidad",
|
||||
"05": "Ropa de cama",
|
||||
"07": "Delicados",
|
||||
"09": "Camisas",
|
||||
"0b": "Cuidado de acolchados",
|
||||
"0c": "Cuidado hidrófugo para exterior",
|
||||
"0e": "Toallas",
|
||||
"0f": "Lana",
|
||||
"11": "Aire frío",
|
||||
"28": "Desinfección interior con aire caliente",
|
||||
"37": "Blusas",
|
||||
"38": "Planchado fácil",
|
||||
"39": "Deshumidificación de habitación",
|
||||
"3a": "Cuidado higiénico",
|
||||
"3b": "Ropa de cama/Antipolvo",
|
||||
"3c": "Ropa deportiva",
|
||||
"3d": "Denim"
|
||||
}
|
||||
},
|
||||
"favorite_capacity": {
|
||||
@@ -833,10 +862,31 @@
|
||||
"name": "Ciclo",
|
||||
"state": {
|
||||
"01": "Normal",
|
||||
"02": "Servicio Extra Intensivo",
|
||||
"03": "Super Eco",
|
||||
"04": "Lavado rápido",
|
||||
"06": "Colada XXL",
|
||||
"05": "Lana/Lencería",
|
||||
"06": "Ropa de cama",
|
||||
"07": "Exterior",
|
||||
"08": "Aclarar + Centrifugar",
|
||||
"09": "Limpieza de Tambor",
|
||||
"0a": "Toallas",
|
||||
"0b": "Lavado a ebullición",
|
||||
"0c": "Cuidado del bebé",
|
||||
"0d": "Solo centrifugar",
|
||||
"0e": "Día nublado",
|
||||
"0f": "Lavado puro",
|
||||
"10": "Secado-centrifugado",
|
||||
"11": "Ropa de cama de verano",
|
||||
"12": "Algodón",
|
||||
"13": "Algodón negro",
|
||||
"14": "Ropa interior delicada",
|
||||
"15": "Ropa deportiva",
|
||||
"16": "Blusas",
|
||||
"17": "Descargado",
|
||||
"18": "Burbuja suave",
|
||||
"19": "Lavado IA",
|
||||
"1a": "Camisas",
|
||||
"1b": "Algodón",
|
||||
"1c": "Eco 40-60",
|
||||
"1d": "Súper velocidad",
|
||||
@@ -861,6 +911,7 @@
|
||||
"32": "Camisas",
|
||||
"33": "Toallas",
|
||||
"34": "Mezcla",
|
||||
"35": "Algodón E",
|
||||
"36": "Lavado + Secado",
|
||||
"37": "Lavado con aire",
|
||||
"38": "Secado de algodón",
|
||||
@@ -875,15 +926,6 @@
|
||||
"60": "Autolimpieza+",
|
||||
"65": "Color",
|
||||
"66": "Denim",
|
||||
"7c": "Blancos",
|
||||
"7d": "Ropa de cama / Impermeable",
|
||||
"7e": "Autolimpieza",
|
||||
"7f": "Lana / Delicados",
|
||||
"86": "Lavado profundo",
|
||||
"87": "Descarga de Programas",
|
||||
"8f": "Lavado en frío",
|
||||
"96": "Menos microfibras",
|
||||
"a0": "Lavado rápido 15'",
|
||||
"69": "Lavado IA",
|
||||
"6a": "Lana",
|
||||
"6b": "Denim",
|
||||
@@ -901,8 +943,17 @@
|
||||
"77": "Algodón",
|
||||
"78": "Aclarar + Centrifugar",
|
||||
"79": "Solo centrifugar",
|
||||
"7c": "Blancos",
|
||||
"7d": "Ropa de cama / Impermeable",
|
||||
"7e": "Autolimpieza",
|
||||
"7f": "Lana / Delicados",
|
||||
"86": "Lavado profundo",
|
||||
"87": "Descarga de Programas",
|
||||
"88": "Cuidado de mascotas",
|
||||
"35": "Algodón E"
|
||||
"8f": "Lavado en frío",
|
||||
"96": "Menos microfibras",
|
||||
"a0": "Lavado rápido 15'",
|
||||
"b0": "Carga mixta"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
@@ -1062,6 +1113,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 +1166,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 +1190,10 @@
|
||||
"name": "Tiempo de secado"
|
||||
},
|
||||
"dryer_type": {
|
||||
"name": "Tipo de secadora"
|
||||
"name": "Tipo de secadora",
|
||||
"state": {
|
||||
"electricity": "Electricidad"
|
||||
}
|
||||
},
|
||||
"dust": {
|
||||
"name": "Polvo"
|
||||
@@ -1256,7 +1322,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": {
|
||||
@@ -369,7 +376,25 @@
|
||||
"53": "Asciugatura AI+",
|
||||
"4e": "Autoasciugatura",
|
||||
"26": "Arieggiatura",
|
||||
"2a": "Cura igienica+"
|
||||
"2a": "Cura igienica+",
|
||||
"02": "Asciugatura AI",
|
||||
"03": "Super Speed",
|
||||
"05": "Biancheria da letto",
|
||||
"07": "Delicati",
|
||||
"09": "Camicie",
|
||||
"0b": "Cura piumini",
|
||||
"0c": "Cura idrorepellente outdoor",
|
||||
"0e": "Asciugamani",
|
||||
"0f": "Lana",
|
||||
"11": "Aria fredda",
|
||||
"28": "Sanificazione interna ad aria calda",
|
||||
"37": "Camicette",
|
||||
"38": "Pronto da stirare",
|
||||
"39": "Deumidificazione ambiente",
|
||||
"3a": "Cura igienica",
|
||||
"3b": "Biancheria da letto/Antipolvere",
|
||||
"3c": "Abbigliamento sportivo",
|
||||
"3d": "Jeans"
|
||||
}
|
||||
},
|
||||
"favorite_capacity": {
|
||||
@@ -640,10 +665,31 @@
|
||||
"name": "Ciclo",
|
||||
"state": {
|
||||
"01": "Normale",
|
||||
"02": "Extra Intenso",
|
||||
"03": "Super Eco",
|
||||
"04": "Lavaggio rapido",
|
||||
"06": "Bucato XXL",
|
||||
"05": "Lana/Lingerie",
|
||||
"06": "Biancheria da letto",
|
||||
"07": "Capi outdoor",
|
||||
"08": "Risciacquo+Centrifuga",
|
||||
"09": "Pulizia cestello",
|
||||
"0a": "Asciugamani",
|
||||
"0b": "Lavaggio a ebollizione",
|
||||
"0c": "Bambini",
|
||||
"0d": "Solo centrifuga",
|
||||
"0e": "Giornata nuvolosa",
|
||||
"0f": "Lavaggio puro",
|
||||
"10": "Asciugatura centrifuga",
|
||||
"11": "Biancheria da letto estiva",
|
||||
"12": "Cotone",
|
||||
"13": "Cotone nero",
|
||||
"14": "Biancheria intima delicata",
|
||||
"15": "Abbigliamento sportivo",
|
||||
"16": "Camicette",
|
||||
"17": "Scaricato",
|
||||
"18": "Soft Bubble",
|
||||
"19": "Lavaggio AI",
|
||||
"1a": "Camicie",
|
||||
"1b": "Cotone",
|
||||
"1c": "Eco 40-60",
|
||||
"1d": "Super Speed",
|
||||
@@ -668,6 +714,7 @@
|
||||
"32": "Camicie",
|
||||
"33": "Asciugamani",
|
||||
"34": "Misti",
|
||||
"35": "Cotone E",
|
||||
"36": "Lavaggio+Asciugatura",
|
||||
"37": "Lavaggio ad aria",
|
||||
"38": "Asciugatura cotone",
|
||||
@@ -682,14 +729,6 @@
|
||||
"60": "Self Clean+",
|
||||
"65": "Colorati",
|
||||
"66": "Jeans",
|
||||
"7c": "Bianchi",
|
||||
"7d": "Biancheria da letto/Impermeabile",
|
||||
"7e": "Pulizia cestello",
|
||||
"7f": "Lana/Delicati",
|
||||
"86": "Lavaggio profondo",
|
||||
"87": "Scaricato",
|
||||
"8f": "Intenso a freddo",
|
||||
"96": "Riduci microfibre",
|
||||
"69": "Lavaggio AI",
|
||||
"6a": "Lana",
|
||||
"6b": "Jeans",
|
||||
@@ -707,9 +746,17 @@
|
||||
"77": "Cotone",
|
||||
"78": "Risciacquo+Centrifuga",
|
||||
"79": "Solo centrifuga",
|
||||
"7c": "Bianchi",
|
||||
"7d": "Biancheria da letto/Impermeabile",
|
||||
"7e": "Pulizia cestello",
|
||||
"7f": "Lana/Delicati",
|
||||
"86": "Lavaggio profondo",
|
||||
"87": "Scaricato",
|
||||
"88": "Cura animali",
|
||||
"35": "Cotone E",
|
||||
"a0": "Rapido 15'"
|
||||
"8f": "Intenso a freddo",
|
||||
"96": "Riduci microfibre",
|
||||
"a0": "Rapido 15'",
|
||||
"b0": "Carico misto"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
@@ -872,6 +919,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 +972,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 +996,10 @@
|
||||
"name": "Tempo di asciugatura"
|
||||
},
|
||||
"dryer_type": {
|
||||
"name": "Tipo di asciugatrice"
|
||||
"name": "Tipo di asciugatrice",
|
||||
"state": {
|
||||
"electricity": "Elettricità"
|
||||
}
|
||||
},
|
||||
"dust": {
|
||||
"name": "Polvere"
|
||||
@@ -1066,7 +1128,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"
|
||||
@@ -1445,11 +1522,12 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni dispositivo",
|
||||
"description": "Alcuni dispositivi accettano determinate scritture (ad esempio, il dosaggio predefinito di detersivo/ammorbidente su una lavatrice) anche quando segnalano che il controllo remoto è disattivato. Per impostazione predefinita, LocalThings blocca ogni scrittura con un errore chiaro ogni volta che un dispositivo segnala che il controllo remoto è disattivato, invece di consentire al dispositivo di rifiutarla silenziosamente. Abilitare questa opzione solo se si è verificato che le scritture funzionano effettivamente su questo dispositivo con il controllo remoto disattivato; in caso contrario, si sostituirà l'errore chiaro con un errore silenzioso.\n\nIl tempo di fine stimato viene ricalcolato dalla stima del tempo rimanente del dispositivo a ogni interrogazione, che può variare o essere rivista di uno o due minuti tra un aggiornamento e l'altro. Aumentare il valore di variazione minima riportato di seguito per mantenere il sensore al suo ultimo valore segnalato finché la stima non si sposta di almeno quel numero di minuti, riducendo il rumore nella cronologia/registro. Impostarlo su 0 per segnalare ogni variazione calcolata.\n\nAlcuni modelli segnalano una modalità che non elencano mai tra quelle supportate: ad esempio un condizionatore in modalità Quiet che offre solo Off/Sleep/Speed. LocalThings memorizza ogni modalità di questo tipo che rileva e continua a proporla, così resta selezionabile una volta che il dispositivo vi è stato almeno una volta. Disattivare questa opzione per proporre solo ciò che il dispositivo dichiara; usare «Dimentica le modalità memorizzate» nella schermata precedente per cancellare quanto già memorizzato.",
|
||||
"description": "Alcuni dispositivi accettano determinate scritture (ad esempio, il dosaggio predefinito di detersivo/ammorbidente su una lavatrice) anche quando segnalano che il controllo remoto è disattivato. Per impostazione predefinita, LocalThings blocca ogni scrittura con un errore chiaro ogni volta che un dispositivo segnala che il controllo remoto è disattivato, invece di consentire al dispositivo di rifiutarla silenziosamente. Abilitare questa opzione solo se si è verificato che le scritture funzionano effettivamente su questo dispositivo con il controllo remoto disattivato; in caso contrario, si sostituirà l'errore chiaro con un errore silenzioso.\n\nIl tempo di fine stimato viene ricalcolato dalla stima del tempo rimanente del dispositivo a ogni interrogazione, che può variare o essere rivista di uno o due minuti tra un aggiornamento e l'altro. Aumentare il valore di variazione minima riportato di seguito per mantenere il sensore al suo ultimo valore segnalato finché la stima non si sposta di almeno quel numero di minuti, riducendo il rumore nella cronologia/registro. Impostarlo su 0 per segnalare ogni variazione calcolata.\n\nAlcuni modelli segnalano una modalità che non elencano mai tra quelle supportate: ad esempio un condizionatore in modalità Quiet che offre solo Off/Sleep/Speed. LocalThings memorizza ogni modalità di questo tipo che rileva e continua a proporla, così resta selezionabile una volta che il dispositivo vi è stato almeno una volta. Disattivare questa opzione per proporre solo ciò che il dispositivo dichiara; usare «Dimentica le modalità memorizzate» nella schermata precedente per cancellare quanto già memorizzato. Un dispositivo per il bucato con cicli scaricati dal cloud riceve qui un promemoria non appena ne segnala uno che Home Assistant non può ancora offrire -- alcuni dispositivi ne segnalano uno anche se non è mai stata aperta la schermata \"Cicli scaricati\" dell'app SmartThings. Disattivarlo per interrompere il promemoria e nascondere dall'elenco dei cicli quelli scaricati già nominati; nulla di quanto già configurato va perso, e riattivandolo riappare subito.",
|
||||
"data": {
|
||||
"bypass_remote_control_lock": "Consenti la scrittura anche quando il controllo remoto risulta disattivato.",
|
||||
"finish_time_hysteresis_minutes": "Tempo finale stimato - variazione minima (minuti)",
|
||||
"learn_device_modes": "Memorizza le modalità che il dispositivo segnala ma non dichiara supportate"
|
||||
"learn_device_modes": "Memorizza le modalità che il dispositivo segnala ma non dichiara supportate",
|
||||
"cloud_courses_enabled": "Offri cicli scaricati"
|
||||
}
|
||||
},
|
||||
"forget_learned_modes": {
|
||||
@@ -1480,14 +1558,14 @@
|
||||
},
|
||||
"cloud_manual": {
|
||||
"title": "Cicli scaricati",
|
||||
"description": "Questo dispositivo segnala {total} cicli scaricati; finora ne sono stati rilevati {found}.\n\nLe impostazioni di un ciclo scaricato sono visibili solo mentre quel ciclo è caricato, e il dispositivo non ne segnala mai il nome. Per aggiungere quelli mancanti ({pending}): sul dispositivo selezionare il ciclo Scaricato, quindi scorrere ciascun programma scaricato uno alla volta, sostando qualche secondo su ognuno, e tornare qui.\n\nAssegnare a ciascuno il nome che si desidera vedere in Home Assistant. Lasciare un nome vuoto per escludere quel ciclo dall'elenco dei cicli. I nomi devono essere univoci.\n\nIl ciclo Scaricato è il programma su questo dispositivo che esegue un programma scaricato. Viene rilevato automaticamente, ma confermarlo qui prima dell'uso: selezionare un ciclo scaricato scrive questo codice di programma sul dispositivo.",
|
||||
"description": "Questo dispositivo segnala {total} cicli scaricati; finora ne sono stati rilevati {found}.\n\nLe impostazioni di un ciclo scaricato sono visibili solo mentre quel ciclo è caricato, e il dispositivo non ne segnala mai il nome. Per aggiungere quelli mancanti ({pending}): nell'app SmartThings (non sul dispositivo), aprire questo dispositivo e toccare \"Cicli scaricati\" -- una riga separata da \"Ciclo\", più in basso nella schermata, ed è quella che conta qui. Scorrere ciascun programma scaricato uno alla volta, sostando qualche secondo su ognuno perché LocalThings possa rilevarlo, e tornare qui.\n\nAssegnare a ciascuno il nome che si desidera vedere in Home Assistant. Lasciare un nome vuoto per escludere quel ciclo dall'elenco dei cicli. I nomi devono essere univoci.\n\nIl ciclo Scaricato è il programma su questo dispositivo che esegue un programma scaricato. Viene rilevato automaticamente, ma confermarlo qui prima dell'uso: selezionare un ciclo scaricato scrive questo codice di programma sul dispositivo.",
|
||||
"data": {
|
||||
"download_course": "Codice programma del ciclo Scaricato"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "Cicli scaricati",
|
||||
"description": "La configurazione guidata accompagna l'utente al dispositivo: selezionare un ciclo scaricato sul dispositivo e assegnargli un nome non appena viene rilevato, uno alla volta.\n\nModifica nomi mostra tutto ciò che è stato trovato finora in una sola volta — usarla in seguito per rinominare o correggere qualcosa.",
|
||||
"description": "La configurazione guidata accompagna l'utente: nell'app SmartThings (non sul dispositivo), aprire questo dispositivo e toccare \"Cicli scaricati\" -- una riga separata da \"Ciclo\", più in basso nella schermata, ed è quella che conta qui. Scegliere un ciclo lì, poi tornare qui e assegnargli un nome non appena viene rilevato, uno alla volta.\n\nModifica nomi mostra tutto ciò che è stato trovato finora in una sola volta — usarla in seguito per rinominare o correggere qualcosa. Assegnare un nome funziona comunque, ma un ciclo con nome viene offerto come ciclo selezionabile solo quando \"Offri cicli scaricati\" è attivo nelle impostazioni del dispositivo.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Configurazione guidata",
|
||||
"cloud_manual": "Modifica nomi"
|
||||
@@ -1498,7 +1576,7 @@
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "Assegna un nome a questo ciclo",
|
||||
"description": "Il dispositivo è passato a un ciclo scaricato (slot {slot}) e segnala {remaining} rimanenti.\n\nAssegnargli il nome che si desidera vedere in Home Assistant, quindi selezionare il successivo sul dispositivo. Lasciare vuoto per escludere questo ciclo dall'elenco.\n\nAssegnati finora ({named} di {total}): {named_list}\n\nI nomi devono essere univoci. È possibile chiudere questa finestra in qualsiasi momento — i nomi vengono salvati man mano.",
|
||||
"description": "Lo slot \"Cicli scaricati\" del dispositivo ora contiene un nuovo ciclo (slot {slot}) e segnala {remaining} rimanenti.\n\nAssegnargli il nome che si desidera vedere in Home Assistant, quindi nella schermata \"Cicli scaricati\" dell'app SmartThings scegliere il successivo non ancora contrassegnato come \"Scaricato\". Lasciare vuoto per escludere questo ciclo dall'elenco.\n\nAssegnati finora ({named} di {total}): {named_list}\n\nI nomi devono essere univoci. È possibile chiudere questa finestra in qualsiasi momento — i nomi vengono salvati man mano.",
|
||||
"data": {
|
||||
"name": "Nome",
|
||||
"download_course": "Codice programma del ciclo Scaricato"
|
||||
@@ -1506,7 +1584,7 @@
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "Nessun ciclo selezionato",
|
||||
"description": "Non è stato selezionato nulla sul dispositivo. Assicurarsi che sia impostato sul ciclo Scaricato, quindi scorrere i programmi scaricati.\n\nAssegnati finora ({named} di {total}): {named_list}\n\nTutto ciò che è stato assegnato finora è già salvato.",
|
||||
"description": "Non è cambiato nulla sul dispositivo. Assicurarsi di aver toccato \"Cicli scaricati\" nell'app SmartThings -- una riga separata da \"Ciclo\", più in basso nella schermata -- e di aver scelto un ciclo non ancora contrassegnato come \"Scaricato\", quindi di aver toccato Fine.\n\nAssegnati finora ({named} di {total}): {named_list}\n\nTutto ciò che è stato assegnato finora è già salvato.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Attendi di nuovo",
|
||||
"cloud_finish": "Fine"
|
||||
@@ -1523,7 +1601,7 @@
|
||||
"not_loaded": "Questo dispositivo non è ancora connesso. Riprova dopo il caricamento."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "Selezionare ora un ciclo scaricato sul dispositivo.\n\nAssegnati finora ({named} di {total}): {named_list}\n\nÈ possibile chiudere questa finestra in qualsiasi momento — i nomi vengono salvati man mano."
|
||||
"cloud_wait": "Nell'app SmartThings (non sul dispositivo), aprire questo dispositivo e toccare \"Cicli scaricati\" -- una riga separata da \"Ciclo\", più in basso nella schermata, che elenca solo i cicli rilevabili in questo modo. Scegliere uno non ancora contrassegnato come \"Scaricato\", quindi toccare Fine.\n\nIl dispositivo non deve essere nelle vicinanze né in esecuzione -- deve solo essere acceso e connesso.\n\nAssegnati finora ({named} di {total}): {named_list}\n\nÈ possibile chiudere questa finestra in qualsiasi momento — i nomi vengono salvati man mano."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
@@ -1533,7 +1611,7 @@
|
||||
},
|
||||
"cloud_courses_undiscovered": {
|
||||
"title": "Cicli scaricati non configurati per {device_name}",
|
||||
"description": "{device_name} ha {pending} cicli scaricati su {total} che Home Assistant non può ancora offrire. Un ciclo scaricato può essere usato solo dopo che il dispositivo è stato rilevato con quel ciclo caricato ed è stato assegnato un nome.\n\nPer configurarli, andare su Impostazioni > Dispositivi e servizi > LocalThings > {device_name} > Configura > Cicli scaricati e seguire le istruzioni presenti lì."
|
||||
"description": "{device_name} ha {pending} cicli scaricati su {total} che Home Assistant non può ancora offrire. Un ciclo scaricato può essere usato solo dopo che il dispositivo è stato rilevato con quel ciclo caricato -- nella schermata \"Cicli scaricati\" dell'app SmartThings, non in quella \"Ciclo\" -- ed è stato assegnato un nome.\n\nPer configurarli, andare su Impostazioni > Dispositivi e servizi > LocalThings > {device_name} > Configura > Cicli scaricati e seguire le istruzioni presenti lì.\n\nNon si usano cicli scaricati? Andare invece su Configura > Impostazioni dispositivo e disattivare \"Offri cicli scaricati\" per silenziare questo promemoria definitivamente."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
@@ -1558,6 +1636,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": {
|
||||
@@ -369,7 +376,25 @@
|
||||
"53": "AI 맞춤건조+",
|
||||
"4e": "자체 건조",
|
||||
"26": "송풍",
|
||||
"2a": "살균건조+"
|
||||
"2a": "살균건조+",
|
||||
"02": "AI 맞춤건조",
|
||||
"03": "쾌속건조",
|
||||
"05": "이불",
|
||||
"07": "섬세의류",
|
||||
"09": "셔츠",
|
||||
"0b": "패딩케어",
|
||||
"0c": "아웃도어발수케어",
|
||||
"0e": "타월",
|
||||
"0f": "울",
|
||||
"11": "송풍건조",
|
||||
"28": "열풍내부살균",
|
||||
"37": "블라우스",
|
||||
"38": "다림질건조",
|
||||
"39": "공간제습",
|
||||
"3a": "살균건조",
|
||||
"3b": "이불/먼지털기",
|
||||
"3c": "피트니스",
|
||||
"3d": "데님"
|
||||
}
|
||||
},
|
||||
"favorite_capacity": {
|
||||
@@ -640,10 +665,31 @@
|
||||
"name": "코스",
|
||||
"state": {
|
||||
"01": "표준세탁",
|
||||
"02": "초강력세탁",
|
||||
"03": "초절약세탁",
|
||||
"04": "쾌속세탁",
|
||||
"06": "XXL 세탁",
|
||||
"05": "울/란제리",
|
||||
"06": "이불",
|
||||
"07": "아웃도어",
|
||||
"08": "헹굼+탈수",
|
||||
"09": "무세제통세척",
|
||||
"0a": "타월",
|
||||
"0b": "삶음세탁",
|
||||
"0c": "아기옷",
|
||||
"0d": "탈수단독",
|
||||
"0e": "흐린날세탁",
|
||||
"0f": "청정세탁",
|
||||
"10": "건조탈수",
|
||||
"11": "여름이불",
|
||||
"12": "면의류",
|
||||
"13": "검은면의류",
|
||||
"14": "섬세속옷",
|
||||
"15": "피트니스",
|
||||
"16": "블라우스",
|
||||
"17": "다운로드 코스",
|
||||
"18": "소프트버블",
|
||||
"19": "AI 맞춤세탁",
|
||||
"1a": "셔츠",
|
||||
"1b": "면",
|
||||
"1c": "에코 40-60",
|
||||
"1d": "쾌속세탁",
|
||||
@@ -668,6 +714,7 @@
|
||||
"32": "셔츠",
|
||||
"33": "타월",
|
||||
"34": "혼합",
|
||||
"35": "에코 면",
|
||||
"36": "세탁+건조",
|
||||
"37": "에어워시",
|
||||
"38": "면 건조",
|
||||
@@ -682,15 +729,6 @@
|
||||
"60": "통세척+",
|
||||
"65": "컬러 의류",
|
||||
"66": "데님",
|
||||
"7c": "흰옷",
|
||||
"7d": "이불/방수 의류",
|
||||
"7e": "통세척",
|
||||
"7f": "울/섬세",
|
||||
"86": "찌든 때 세탁",
|
||||
"87": "다운로드",
|
||||
"8f": "강력 냉수 세탁",
|
||||
"96": "미세플라스틱저감",
|
||||
"a0": "15분 쾌속세탁",
|
||||
"69": "AI 맞춤세탁",
|
||||
"6a": "울",
|
||||
"6b": "데님",
|
||||
@@ -708,8 +746,17 @@
|
||||
"77": "면",
|
||||
"78": "헹굼+탈수",
|
||||
"79": "탈수만",
|
||||
"7c": "흰옷",
|
||||
"7d": "이불/방수 의류",
|
||||
"7e": "통세척",
|
||||
"7f": "울/섬세",
|
||||
"86": "찌든 때 세탁",
|
||||
"87": "다운로드",
|
||||
"88": "펫케어",
|
||||
"35": "에코 면"
|
||||
"8f": "강력 냉수 세탁",
|
||||
"96": "미세플라스틱저감",
|
||||
"a0": "15분 쾌속세탁",
|
||||
"b0": "혼합 세탁"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
@@ -872,6 +919,12 @@
|
||||
"stick_status": {
|
||||
"name": "스틱 청소기 상태"
|
||||
},
|
||||
"stick_operation_mode": {
|
||||
"name": "스틱 작동 모드"
|
||||
},
|
||||
"stick_cleaning_status": {
|
||||
"name": "스틱 청소 상태"
|
||||
},
|
||||
"uvc_operation_time": {
|
||||
"name": "UV-C 작동 시간"
|
||||
},
|
||||
@@ -919,10 +972,16 @@
|
||||
"name": "온도"
|
||||
},
|
||||
"diagnosis": {
|
||||
"name": "진단"
|
||||
"name": "진단",
|
||||
"state": {
|
||||
"ready": "준비됨"
|
||||
}
|
||||
},
|
||||
"diagnosis_status": {
|
||||
"name": "진단 상태"
|
||||
"name": "진단 상태",
|
||||
"state": {
|
||||
"ready": "준비됨"
|
||||
}
|
||||
},
|
||||
"drum_clean_cycles_remaining": {
|
||||
"name": "통세척까지 남은 횟수"
|
||||
@@ -937,7 +996,10 @@
|
||||
"name": "건조 시간"
|
||||
},
|
||||
"dryer_type": {
|
||||
"name": "건조기 유형"
|
||||
"name": "건조기 유형",
|
||||
"state": {
|
||||
"electricity": "전기"
|
||||
}
|
||||
},
|
||||
"dust": {
|
||||
"name": "미세먼지"
|
||||
@@ -1066,7 +1128,22 @@
|
||||
"name": "탐침 온도계 현재 온도"
|
||||
},
|
||||
"progress": {
|
||||
"name": "진행률"
|
||||
"name": "진행률",
|
||||
"state": {
|
||||
"idle": "대기",
|
||||
"weightsensing": "세탁물 감지",
|
||||
"wash": "세탁",
|
||||
"rinse": "헹굼",
|
||||
"spin": "탈수",
|
||||
"finish": "완료",
|
||||
"steaming": "스팀",
|
||||
"airwashing": "에어워시",
|
||||
"drying": "건조",
|
||||
"dryingwithdooropen": "환기",
|
||||
"cooling": "냉각",
|
||||
"predrain": "사전 배수",
|
||||
"prewash": "애벌빨래"
|
||||
}
|
||||
},
|
||||
"progress_percentage": {
|
||||
"name": "진행률"
|
||||
@@ -1445,11 +1522,12 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "기기 설정",
|
||||
"description": "일부 기기는 스마트 컨트롤이 꺼져 있다고 보고하는 동안에도 특정 쓰기 요청(예: 세탁기의 기본 세제/섬유유연제 투입량)을 받아들입니다. 기본적으로 LocalThings는 기기가 스마트 컨트롤 꺼짐을 보고하면 기기가 쓰기 요청을 조용히 거부하도록 두지 않고, 명확한 오류와 함께 모든 쓰기를 차단합니다. 스마트 컨트롤이 꺼진 상태에서도 이 기기에 쓰기가 실제로 동작하는 것을 확인한 경우에만 이 옵션을 활성화하세요. 그렇지 않으면 명확한 오류 대신 아무 표시 없이 쓰기에 실패할 수 있습니다.\n\n예상 완료 시각은 기기가 보고하는 남은 시간 추정치를 사용하여 상태를 확인할 때마다 다시 계산합니다. 이 추정치는 업데이트 사이에 1~2분 정도 변하거나 수정될 수 있습니다. 아래의 최소 변경량을 늘리면 추정치가 설정한 시간(분) 이상 변할 때까지 센서가 마지막으로 보고된 값을 유지하므로 기록과 로그북에 불필요한 항목이 쌓이는 것을 줄일 수 있습니다. 계산된 모든 변경 사항을 보고하려면 0으로 설정하세요.\n\n일부 모델은 지원 목록에 없는 모드를 현재 모드로 보고합니다. 예를 들어 Off/Sleep/Speed만 제공하면서 Quiet 모드로 동작 중이라고 보고하는 에어컨이 그렇습니다. LocalThings는 이렇게 발견한 모드를 기억해 두고 계속 제공하므로, 기기가 한 번이라도 그 모드였다면 이후에도 선택할 수 있습니다. 이 옵션을 끄면 기기가 지원한다고 알린 모드만 제공합니다. 이미 기억된 항목을 지우려면 이전 화면의 '기억된 모드 지우기'를 사용하세요.",
|
||||
"description": "일부 기기는 스마트 컨트롤이 꺼져 있다고 보고하는 동안에도 특정 쓰기 요청(예: 세탁기의 기본 세제/섬유유연제 투입량)을 받아들입니다. 기본적으로 LocalThings는 기기가 스마트 컨트롤 꺼짐을 보고하면 기기가 쓰기 요청을 조용히 거부하도록 두지 않고, 명확한 오류와 함께 모든 쓰기를 차단합니다. 스마트 컨트롤이 꺼진 상태에서도 이 기기에 쓰기가 실제로 동작하는 것을 확인한 경우에만 이 옵션을 활성화하세요. 그렇지 않으면 명확한 오류 대신 아무 표시 없이 쓰기에 실패할 수 있습니다.\n\n예상 완료 시각은 기기가 보고하는 남은 시간 추정치를 사용하여 상태를 확인할 때마다 다시 계산합니다. 이 추정치는 업데이트 사이에 1~2분 정도 변하거나 수정될 수 있습니다. 아래의 최소 변경량을 늘리면 추정치가 설정한 시간(분) 이상 변할 때까지 센서가 마지막으로 보고된 값을 유지하므로 기록과 로그북에 불필요한 항목이 쌓이는 것을 줄일 수 있습니다. 계산된 모든 변경 사항을 보고하려면 0으로 설정하세요.\n\n일부 모델은 지원 목록에 없는 모드를 현재 모드로 보고합니다. 예를 들어 Off/Sleep/Speed만 제공하면서 Quiet 모드로 동작 중이라고 보고하는 에어컨이 그렇습니다. LocalThings는 이렇게 발견한 모드를 기억해 두고 계속 제공하므로, 기기가 한 번이라도 그 모드였다면 이후에도 선택할 수 있습니다. 이 옵션을 끄면 기기가 지원한다고 알린 모드만 제공합니다. 이미 기억된 항목을 지우려면 이전 화면의 '기억된 모드 지우기'를 사용하세요. 클라우드에서 다운로드한 코스가 있는 세탁기는 Home Assistant가 아직 제공할 수 없는 코스를 보고하는 즉시 여기에서 알림을 받습니다. 일부 기기는 SmartThings 앱의 \"다운로드 코스\" 화면을 직접 연 적이 없어도 이런 코스를 보고합니다. 이 옵션을 끄면 이 알림이 멈추고 이미 이름을 지정한 다운로드 코스도 코스 목록에서 숨겨집니다. 이미 설정한 내용은 사라지지 않으며, 다시 켜면 즉시 원래대로 돌아옵니다.",
|
||||
"data": {
|
||||
"bypass_remote_control_lock": "스마트 컨트롤이 꺼진 것으로 보고되어도 쓰기 허용",
|
||||
"finish_time_hysteresis_minutes": "예상 완료 시각 - 최소 변경량(분)",
|
||||
"learn_device_modes": "기기가 보고하지만 지원 목록에 없는 모드 기억하기"
|
||||
"learn_device_modes": "기기가 보고하지만 지원 목록에 없는 모드 기억하기",
|
||||
"cloud_courses_enabled": "다운로드 코스 제공"
|
||||
}
|
||||
},
|
||||
"forget_learned_modes": {
|
||||
@@ -1480,14 +1558,14 @@
|
||||
},
|
||||
"cloud_manual": {
|
||||
"title": "다운로드 코스",
|
||||
"description": "이 기기는 다운로드한 코스를 {total}개 보고하며, 지금까지 {found}개를 확인했습니다.\n\n다운로드한 코스의 설정은 해당 코스가 로드되어 있는 동안에만 확인할 수 있고, 기기는 그 이름을 전달하지 않습니다. 누락된 항목({pending}개)을 추가하려면 기기에서 다운로드 코스를 선택한 다음, 다운로드된 프로그램을 하나씩 차례로 실행하면서 몇 초씩 머무른 뒤 이 화면으로 돌아오세요.\n\n각 코스에 Home Assistant에서 보고 싶은 이름을 입력하세요. 이름을 비워 두면 해당 코스는 코스 목록에서 제외됩니다. 이름은 서로 달라야 합니다.\n\n다운로드 코스는 이 기기에서 다운로드된 프로그램을 실행하는 코스입니다. 자동으로 감지되지만 사용하기 전에 여기서 확인하세요. 다운로드한 코스를 선택하면 이 코스 코드가 기기에 기록됩니다.",
|
||||
"description": "이 기기는 다운로드한 코스를 {total}개 보고하며, 지금까지 {found}개를 확인했습니다.\n\n다운로드한 코스의 설정은 해당 코스가 로드되어 있는 동안에만 확인할 수 있고, 기기는 그 이름을 전달하지 않습니다. 누락된 항목({pending}개)을 추가하려면 기기가 아닌 SmartThings 앱에서 이 기기를 열고 \"다운로드 코스\"를 탭하세요. 이는 화면 아래쪽에 있는 \"코스\"와는 별도의 항목이며, 여기서 중요한 것은 이 항목입니다. 그런 다음 다운로드된 프로그램을 하나씩 차례로 실행하면서 LocalThings가 인식할 수 있도록 몇 초씩 머무른 뒤 이 화면으로 돌아오세요.\n\n각 코스에 Home Assistant에서 보고 싶은 이름을 입력하세요. 이름을 비워 두면 해당 코스는 코스 목록에서 제외됩니다. 이름은 서로 달라야 합니다.\n\n다운로드 코스는 이 기기에서 다운로드된 프로그램을 실행하는 코스입니다. 자동으로 감지되지만 사용하기 전에 여기서 확인하세요. 다운로드한 코스를 선택하면 이 코스 코드가 기기에 기록됩니다.",
|
||||
"data": {
|
||||
"download_course": "다운로드 코스 코드"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "다운로드 코스",
|
||||
"description": "안내 설정은 기기와 함께 진행됩니다. 기기에서 다운로드한 코스를 선택하고, 코스가 확인될 때마다 하나씩 이름을 지정하세요.\n\n이름 편집은 지금까지 찾은 모든 코스를 한 번에 보여줍니다. 나중에 이름을 바꾸거나 수정할 때 사용하세요.",
|
||||
"description": "안내 설정은 다음과 같이 진행됩니다. 기기가 아닌 SmartThings 앱에서 이 기기를 열고 \"다운로드 코스\"를 탭하세요. 이는 화면 아래쪽에 있는 \"코스\"와는 별도의 항목이며, 여기서 중요한 것은 이 항목입니다. 그곳에서 코스를 선택한 다음 돌아와서, 코스가 확인될 때마다 하나씩 이름을 지정하세요.\n\n이름 편집은 지금까지 찾은 모든 코스를 한 번에 보여줍니다. 나중에 이름을 바꾸거나 수정할 때 사용하세요. 이름 지정은 어느 경우든 가능하지만, 이름을 지정한 코스는 기기 설정에서 \"다운로드 코스 제공\"이 켜져 있을 때만 선택 가능한 코스로 제공됩니다.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "안내 설정",
|
||||
"cloud_manual": "이름 편집"
|
||||
@@ -1498,7 +1576,7 @@
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "이 코스 이름 지정",
|
||||
"description": "기기가 다운로드한 코스(슬롯 {slot})로 전환되었으며 {remaining} 남았다고 표시됩니다.\n\nHome Assistant에서 보고 싶은 이름을 입력한 다음, 기기에서 다음 코스를 선택하세요. 비워 두면 이 코스는 목록에서 제외됩니다.\n\n지금까지 이름 지정됨 ({named}/{total}개): {named_list}\n\n이름은 서로 달라야 합니다. 이 창은 언제든지 닫을 수 있습니다. 이름은 진행하는 대로 저장됩니다.",
|
||||
"description": "기기의 \"다운로드 코스\" 슬롯에 이제 새 코스(슬롯 {slot})가 들어 있으며 {remaining} 남았다고 표시됩니다.\n\nHome Assistant에서 보고 싶은 이름을 입력한 다음, SmartThings 앱의 \"다운로드 코스\" 화면에서 아직 \"다운로드됨\"으로 표시되지 않은 다음 코스를 선택하세요. 비워 두면 이 코스는 목록에서 제외됩니다.\n\n지금까지 이름 지정됨 ({named}/{total}개): {named_list}\n\n이름은 서로 달라야 합니다. 이 창은 언제든지 닫을 수 있습니다. 이름은 진행하는 대로 저장됩니다.",
|
||||
"data": {
|
||||
"name": "이름",
|
||||
"download_course": "다운로드 코스 코드"
|
||||
@@ -1506,7 +1584,7 @@
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "선택된 코스 없음",
|
||||
"description": "기기에서 아무것도 선택되지 않았습니다. 다운로드 코스로 설정되어 있는지 확인한 다음, 다운로드된 프로그램을 하나씩 실행해 보세요.\n\n지금까지 이름 지정됨 ({named}/{total}개): {named_list}\n\n지금까지 이름을 지정한 항목은 이미 저장되었습니다.",
|
||||
"description": "기기에서 변경된 내용이 없습니다. SmartThings 앱에서 \"다운로드 코스\"(화면 아래쪽에 있는 \"코스\"와는 별도의 항목)를 탭하고, 아직 \"다운로드됨\"으로 표시되지 않은 코스를 선택한 다음 완료를 탭했는지 확인하세요.\n\n지금까지 이름 지정됨 ({named}/{total}개): {named_list}\n\n지금까지 이름을 지정한 항목은 이미 저장되었습니다.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "다시 대기",
|
||||
"cloud_finish": "완료"
|
||||
@@ -1523,7 +1601,7 @@
|
||||
"not_loaded": "이 기기는 아직 연결되지 않았습니다. 기기를 불러온 후 다시 시도하세요."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "지금 기기에서 다운로드한 코스를 선택하세요.\n\n지금까지 이름 지정됨 ({named}/{total}개): {named_list}\n\n이 창은 언제든지 닫을 수 있습니다. 이름은 진행하는 대로 저장됩니다."
|
||||
"cloud_wait": "기기가 아닌 SmartThings 앱에서 이 기기를 열고 \"다운로드 코스\"를 탭하세요. 이는 화면 아래쪽에 있는 \"코스\"와는 별도의 항목이며, 이 방식으로 인식할 수 있는 코스만 나열됩니다. 아직 \"다운로드됨\"으로 표시되지 않은 코스를 선택한 다음 완료를 탭하세요.\n\n기기가 근처에 있거나 코스를 실행 중일 필요는 없습니다. 전원이 켜져 있고 연결되어 있기만 하면 됩니다.\n\n지금까지 이름 지정됨 ({named}/{total}개): {named_list}\n\n이 창은 언제든지 닫을 수 있습니다. 이름은 진행하는 대로 저장됩니다."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
@@ -1533,7 +1611,7 @@
|
||||
},
|
||||
"cloud_courses_undiscovered": {
|
||||
"title": "{device_name}의 다운로드 코스가 설정되지 않음",
|
||||
"description": "{device_name}에는 Home Assistant가 아직 제공할 수 없는 다운로드한 코스가 {total}개 중 {pending}개 있습니다. 다운로드한 코스는 기기에서 해당 코스가 로드된 상태로 확인되고 이름을 지정한 후에만 사용할 수 있습니다.\n\n설정하려면 설정 > 기기 및 서비스 > LocalThings > {device_name} > 구성 > 다운로드 코스로 이동하여 안내를 따르세요."
|
||||
"description": "{device_name}에는 Home Assistant가 아직 제공할 수 없는 다운로드한 코스가 {total}개 중 {pending}개 있습니다. 다운로드한 코스는 SmartThings 앱의 \"다운로드 코스\" 화면(\"코스\" 화면이 아님)에서 기기가 해당 코스로 로드된 상태로 확인되고 이름을 지정한 후에만 사용할 수 있습니다.\n\n설정하려면 설정 > 기기 및 서비스 > LocalThings > {device_name} > 구성 > 다운로드 코스로 이동하여 안내를 따르세요.\n\n다운로드 코스를 사용하지 않나요? 대신 구성 > 기기 설정으로 이동하여 \"다운로드 코스 제공\"을 꺼서 이 알림을 완전히 중지하세요."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
@@ -1558,6 +1636,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": {
|
||||
@@ -369,7 +376,25 @@
|
||||
"53": "AI drogen+",
|
||||
"4e": "Zelf drogen",
|
||||
"26": "Luchtverfrissing",
|
||||
"2a": "Hygiënische verzorging+"
|
||||
"2a": "Hygiënische verzorging+",
|
||||
"02": "AI drogen",
|
||||
"03": "Super Speed",
|
||||
"05": "Beddengoed",
|
||||
"07": "Fijne was",
|
||||
"09": "Overhemden",
|
||||
"0b": "Paddingverzorging",
|
||||
"0c": "Waterafstotende outdoorverzorging",
|
||||
"0e": "Handdoeken",
|
||||
"0f": "Wol",
|
||||
"11": "Koude lucht",
|
||||
"28": "Interne desinfectie met hete lucht",
|
||||
"37": "Blouses",
|
||||
"38": "Strijkdroog",
|
||||
"39": "Ruimte ontvochtigen",
|
||||
"3a": "Hygiënisch drogen",
|
||||
"3b": "Beddengoed/Stof verwijderen",
|
||||
"3c": "Sportkleding",
|
||||
"3d": "Spijkergoed"
|
||||
}
|
||||
},
|
||||
"favorite_capacity": {
|
||||
@@ -640,10 +665,31 @@
|
||||
"name": "Programma",
|
||||
"state": {
|
||||
"01": "Normaal",
|
||||
"02": "Extra intensief",
|
||||
"03": "Super Eco",
|
||||
"04": "Snelle was",
|
||||
"06": "XXL was",
|
||||
"05": "Wol/Lingerie",
|
||||
"06": "Beddengoed",
|
||||
"07": "Outdoor",
|
||||
"08": "Spoelen+centrifugeren",
|
||||
"09": "Trommel reinigen",
|
||||
"0a": "Handdoeken",
|
||||
"0b": "Kookwas",
|
||||
"0c": "Babyverzorging",
|
||||
"0d": "Alleen centrifugeren",
|
||||
"0e": "Bewolkte dag",
|
||||
"0f": "Zuivere was",
|
||||
"10": "Droogcentrifugeren",
|
||||
"11": "Zomer beddengoed",
|
||||
"12": "Katoen",
|
||||
"13": "Zwart katoen",
|
||||
"14": "Fijn ondergoed",
|
||||
"15": "Sportkleding",
|
||||
"16": "Blouses",
|
||||
"17": "Gedownload",
|
||||
"18": "Soft Bubble",
|
||||
"19": "AI Wash",
|
||||
"1a": "Overhemden",
|
||||
"1b": "Katoen",
|
||||
"1c": "Eco 40-60",
|
||||
"1d": "Super Speed",
|
||||
@@ -668,6 +714,7 @@
|
||||
"32": "Overhemden",
|
||||
"33": "Handdoeken",
|
||||
"34": "Gemengd",
|
||||
"35": "Eco katoen",
|
||||
"36": "Wassen+drogen",
|
||||
"37": "Air Wash",
|
||||
"38": "Katoen drogen",
|
||||
@@ -709,7 +756,7 @@
|
||||
"8f": "Intensief koud",
|
||||
"96": "Minder microvezels",
|
||||
"a0": "15' Snelle was",
|
||||
"35": "Eco katoen"
|
||||
"b0": "Gemengde was"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
@@ -872,6 +919,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 +972,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 +996,10 @@
|
||||
"name": "Droogtijd"
|
||||
},
|
||||
"dryer_type": {
|
||||
"name": "Type droger"
|
||||
"name": "Type droger",
|
||||
"state": {
|
||||
"electricity": "Elektriciteit"
|
||||
}
|
||||
},
|
||||
"dust": {
|
||||
"name": "Stof"
|
||||
@@ -1066,7 +1128,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"
|
||||
@@ -1445,11 +1522,12 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Apparaatinstellingen",
|
||||
"description": "Sommige apparaten accepteren bepaalde schrijfbewerkingen (bijvoorbeeld de standaarddosering van wasmiddel of wasverzachter op een wasmachine), ook als ze melden dat de afstandsbediening is uitgeschakeld. LocalThings blokkeert standaard elke schrijfbewerking met een duidelijke foutmelding wanneer een apparaat meldt dat de afstandsbediening is uitgeschakeld, in plaats van het apparaat de opdracht stilzwijgend te laten weigeren. Schakel deze optie alleen in als je hebt bevestigd dat schrijfbewerkingen op dit apparaat echt werken wanneer de afstandsbediening is uitgeschakeld. Anders verruil je de duidelijke foutmelding voor een stille mislukking.\n\nDe geschatte eindtijd wordt bij elke poll opnieuw berekend op basis van de resterende tijd die het apparaat opgeeft, wat kan afwijken of met een minuut of wat worden bijgesteld tussen updates. Verhoog de minimale wijziging hieronder om de sensor op zijn laatst gerapporteerde waarde te houden totdat de schatting met minstens dat aantal minuten verandert, wat de ruis in geschiedenis/logboek vermindert. Zet op 0 om elke berekende wijziging te rapporteren.\n\nSommige modellen melden een modus die ze nooit als ondersteund opgeven: bijvoorbeeld een airco die in Quiet staat maar alleen Off/Sleep/Speed aanbiedt. LocalThings onthoudt elke zo waargenomen modus en blijft die aanbieden, zodat hij selecteerbaar blijft zodra het apparaat er minstens één keer in heeft gestaan. Schakel dit uit om alleen aan te bieden wat het apparaat opgeeft; gebruik \"Onthouden modi vergeten\" in het vorige scherm om te wissen wat al is onthouden.",
|
||||
"description": "Sommige apparaten accepteren bepaalde schrijfbewerkingen (bijvoorbeeld de standaarddosering van wasmiddel of wasverzachter op een wasmachine), ook als ze melden dat de afstandsbediening is uitgeschakeld. LocalThings blokkeert standaard elke schrijfbewerking met een duidelijke foutmelding wanneer een apparaat meldt dat de afstandsbediening is uitgeschakeld, in plaats van het apparaat de opdracht stilzwijgend te laten weigeren. Schakel deze optie alleen in als je hebt bevestigd dat schrijfbewerkingen op dit apparaat echt werken wanneer de afstandsbediening is uitgeschakeld. Anders verruil je de duidelijke foutmelding voor een stille mislukking.\n\nDe geschatte eindtijd wordt bij elke poll opnieuw berekend op basis van de resterende tijd die het apparaat opgeeft, wat kan afwijken of met een minuut of wat worden bijgesteld tussen updates. Verhoog de minimale wijziging hieronder om de sensor op zijn laatst gerapporteerde waarde te houden totdat de schatting met minstens dat aantal minuten verandert, wat de ruis in geschiedenis/logboek vermindert. Zet op 0 om elke berekende wijziging te rapporteren.\n\nSommige modellen melden een modus die ze nooit als ondersteund opgeven: bijvoorbeeld een airco die in Quiet staat maar alleen Off/Sleep/Speed aanbiedt. LocalThings onthoudt elke zo waargenomen modus en blijft die aanbieden, zodat hij selecteerbaar blijft zodra het apparaat er minstens één keer in heeft gestaan. Schakel dit uit om alleen aan te bieden wat het apparaat opgeeft; gebruik \"Onthouden modi vergeten\" in het vorige scherm om te wissen wat al is onthouden. Een wasapparaat met uit de cloud gedownloade programma's krijgt hier een herinnering zodra het er een meldt die Home Assistant nog niet kan aanbieden -- sommige apparaten melden er een, ook als je het scherm \"Gedownloade programma's\" in de SmartThings-app zelf nooit hebt geopend. Zet dit uit om die herinnering te stoppen en al benoemde gedownloade programma's uit de programmalijst te verbergen; niets van wat al is ingesteld gaat verloren, en bij opnieuw inschakelen verschijnt het meteen weer.",
|
||||
"data": {
|
||||
"bypass_remote_control_lock": "Schrijfbewerkingen toestaan wanneer afstandsbediening als uitgeschakeld wordt gemeld",
|
||||
"finish_time_hysteresis_minutes": "Geschatte eindtijd -- minimale wijziging (minuten)",
|
||||
"learn_device_modes": "Modi onthouden die het apparaat meldt maar niet als ondersteund opgeeft"
|
||||
"learn_device_modes": "Modi onthouden die het apparaat meldt maar niet als ondersteund opgeeft",
|
||||
"cloud_courses_enabled": "Gedownloade programma's aanbieden"
|
||||
}
|
||||
},
|
||||
"forget_learned_modes": {
|
||||
@@ -1480,14 +1558,14 @@
|
||||
},
|
||||
"cloud_manual": {
|
||||
"title": "Gedownloade programma's",
|
||||
"description": "Dit apparaat meldt {total} gedownloade programma's; {found} daarvan zijn tot nu toe gezien.\n\nDe instellingen van een gedownload programma zijn alleen zichtbaar zolang dat programma geladen is, en het apparaat meldt nooit de naam ervan. Om de ontbrekende ({pending}) toe te voegen: selecteer op het apparaat het programma \"Gedownload\", doorloop dan elk gedownload programma na elkaar, pauzeer bij elk een paar seconden, en kom hierna terug.\n\nGeef elk programma de naam die je in Home Assistant wilt zien. Laat een naam leeg om dat programma buiten de programmalijst te houden. Namen moeten uniek zijn.\n\nHet programma \"Gedownload\" is het programma op dit apparaat dat een gedownload programma uitvoert. Het wordt automatisch gedetecteerd, maar bevestig dit hier voor gebruik: als je een gedownload programma selecteert, wordt deze programmacode naar het apparaat geschreven.",
|
||||
"description": "Dit apparaat meldt {total} gedownloade programma's; {found} daarvan zijn tot nu toe gezien.\n\nDe instellingen van een gedownload programma zijn alleen zichtbaar zolang dat programma geladen is, en het apparaat meldt nooit de naam ervan. Om de ontbrekende ({pending}) toe te voegen: open in de SmartThings-app (niet op het apparaat zelf) dit apparaat en tik op \"Gedownloade programma's\" -- een aparte regel, los van \"Programma\", verderop op het scherm, en degene die hier telt. Doorloop dan elk gedownload programma na elkaar, pauzeer bij elk een paar seconden zodat LocalThings het kan zien, en kom hierna terug.\n\nGeef elk programma de naam die je in Home Assistant wilt zien. Laat een naam leeg om dat programma buiten de programmalijst te houden. Namen moeten uniek zijn.\n\nHet programma \"Gedownload\" is het programma op dit apparaat dat een gedownload programma uitvoert. Het wordt automatisch gedetecteerd, maar bevestig dit hier voor gebruik: als je een gedownload programma selecteert, wordt deze programmacode naar het apparaat geschreven.",
|
||||
"data": {
|
||||
"download_course": "Programmacode van \"Gedownload\""
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "Gedownloade programma's",
|
||||
"description": "Begeleide installatie loodst je stap voor stap langs het apparaat: selecteer op het apparaat een gedownload programma en geef het een naam zodra het gevonden is, één voor één.\n\nNamen bewerken toont in één keer alles wat tot nu toe is gevonden — gebruik dit later om iets te hernoemen of te corrigeren.",
|
||||
"description": "Begeleide installatie loodst je stap voor stap: open in de SmartThings-app (niet op het apparaat zelf) dit apparaat en tik op \"Gedownloade programma's\" -- een aparte regel, los van \"Programma\", verderop op het scherm, en degene die hier telt. Kies daar een programma, kom terug en geef het een naam zodra het gevonden is, één voor één.\n\nNamen bewerken toont in één keer alles wat tot nu toe is gevonden — gebruik dit later om iets te hernoemen of te corrigeren. Namen geven werkt sowieso, maar een benoemd programma wordt alleen als selecteerbaar programma aangeboden zolang \"Gedownloade programma's aanbieden\" aan staat in de apparaatinstellingen.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Begeleide installatie",
|
||||
"cloud_manual": "Namen bewerken"
|
||||
@@ -1498,7 +1576,7 @@
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "Geef dit programma een naam",
|
||||
"description": "Het apparaat is overgeschakeld naar een gedownload programma (slot {slot}) en meldt nog {remaining} resterend.\n\nGeef het de naam die je in Home Assistant wilt zien en selecteer daarna het volgende op het apparaat. Laat het veld leeg om dit programma buiten de lijst te houden.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nNamen moeten uniek zijn. Je kunt dit venster op elk moment sluiten — namen worden onderweg opgeslagen.",
|
||||
"description": "Het vak \"Gedownloade programma's\" van het apparaat bevat nu een nieuw programma (slot {slot}) en meldt nog {remaining} resterend.\n\nGeef het de naam die je in Home Assistant wilt zien en kies daarna in het scherm \"Gedownloade programma's\" van de SmartThings-app het volgende programma dat nog niet als \"Gedownload\" is gemarkeerd. Laat het veld leeg om dit programma buiten de lijst te houden.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nNamen moeten uniek zijn. Je kunt dit venster op elk moment sluiten — namen worden onderweg opgeslagen.",
|
||||
"data": {
|
||||
"name": "Naam",
|
||||
"download_course": "Programmacode van \"Gedownload\""
|
||||
@@ -1506,7 +1584,7 @@
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "Geen programma geselecteerd",
|
||||
"description": "Er is niets geselecteerd op het apparaat. Zorg dat het is ingesteld op het programma \"Gedownload\" en doorloop dan de gedownloade programma's.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nAlles wat tot nu toe benoemd is, is al opgeslagen.",
|
||||
"description": "Er is niets veranderd op het apparaat. Zorg dat je in de SmartThings-app op \"Gedownloade programma's\" hebt getikt -- een aparte regel, los van \"Programma\", verderop op het scherm -- en een programma hebt gekozen dat nog niet als \"Gedownload\" was gemarkeerd, en daarna op Klaar hebt getikt.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nAlles wat tot nu toe benoemd is, is al opgeslagen.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Opnieuw wachten",
|
||||
"cloud_finish": "Voltooien"
|
||||
@@ -1523,7 +1601,7 @@
|
||||
"not_loaded": "Dit apparaat is nog niet verbonden. Probeer het opnieuw zodra het is geladen."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "Selecteer nu een gedownload programma op het apparaat.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nJe kunt dit venster op elk moment sluiten — namen worden onderweg opgeslagen."
|
||||
"cloud_wait": "Open in de SmartThings-app (niet op het apparaat zelf) dit apparaat en tik op \"Gedownloade programma's\" -- een aparte regel, los van \"Programma\", verderop op het scherm, die alleen de programma's toont die op deze manier te herkennen zijn. Kies er een die nog niet als \"Gedownload\" is gemarkeerd en tik op Klaar.\n\nHet apparaat hoeft niet in de buurt te zijn of het programma uit te voeren -- het moet alleen aan staan en verbonden zijn.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nJe kunt dit venster op elk moment sluiten — namen worden onderweg opgeslagen."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
@@ -1533,7 +1611,7 @@
|
||||
},
|
||||
"cloud_courses_undiscovered": {
|
||||
"title": "Gedownloade programma's niet ingesteld voor {device_name}",
|
||||
"description": "{device_name} heeft {pending} van de {total} gedownloade programma's die Home Assistant nog niet kan aanbieden. Een gedownload programma kan pas worden gebruikt zodra het apparaat ermee geladen is gezien en je het een naam hebt gegeven.\n\nGa om ze in te stellen naar Instellingen > Apparaten & diensten > LocalThings > {device_name} > Configureren > Gedownloade programma's en volg de instructies daar."
|
||||
"description": "{device_name} heeft {pending} van de {total} gedownloade programma's die Home Assistant nog niet kan aanbieden. Een gedownload programma kan pas worden gebruikt zodra het apparaat ermee geladen is gezien -- in het scherm \"Gedownloade programma's\" van de SmartThings-app, niet in \"Programma\" -- en je het een naam hebt gegeven.\n\nGa om ze in te stellen naar Instellingen > Apparaten & diensten > LocalThings > {device_name} > Configureren > Gedownloade programma's en volg de instructies daar.\n\nGebruik je geen gedownloade programma's? Ga dan naar Configureren > Apparaatinstellingen en zet \"Gedownloade programma's aanbieden\" uit om deze herinnering voorgoed te stoppen."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
@@ -1558,6 +1636,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,184 @@
|
||||
# Composite AC subdevices: where else a sibling's hrefs could live
|
||||
|
||||
Open question behind issue #335 (`ARTIK051_FAC_BORA_19K`, a 2-in-1 floor +
|
||||
wall AC): the board reports a sibling in `/subdevices/vs/0`'s
|
||||
`subdeviceIdList`, but every seed `registry/subdevices.enumerate_subdevices`
|
||||
tries comes back 4.04, so `subdevices` and `subdevices_skipped` are both
|
||||
empty and the wall unit never becomes an entity.
|
||||
|
||||
This file records what that actually rules out (less than it looks like),
|
||||
why, and which hrefs are worth reading next.
|
||||
|
||||
## `/oic/res` does not enumerate the resource tree on modern firmware
|
||||
|
||||
This is the finding that reopens the question. Across every fixture that
|
||||
carries a captured `/oic/res`:
|
||||
|
||||
| board | links | `sec:true` | lists `/device/0`? |
|
||||
| --- | --- | --- | --- |
|
||||
| `ARTIK051_DONGLE_FAC_18K` | 91 | 78 | yes |
|
||||
| `TP2X_FAC_BORA_21K` (2-in-1) | 17 | 6 | no |
|
||||
| `TP2X_FAC_BORA_21K` (#205 flat) | 17 | 6 | no |
|
||||
| `TP1X_DA_KS_RANGE_0101X` | 10 | 6 | no |
|
||||
| `AWM-WW-AID-26-ONEBODY` | 15 | 9 | no |
|
||||
| `ARTIK051_FAC_BORA_19K` (#335) | 18 | 6 | no |
|
||||
|
||||
The `ARTIK051_DONGLE_FAC_18K` board — the one Pattern A was built against —
|
||||
is the outlier, not the model. Everywhere else `/oic/res` lists the
|
||||
onboarding surface and nothing else: `/oic/d`, `/oic/p`, the security and
|
||||
EasySetup/WiFiConf/CoapCloudConf/DevConf resources, file transfer, and the
|
||||
`sec/*` pair. On issue #335's board the six `sec:true` links are exactly
|
||||
doxm, pstat and the four setup URIs; every other listed link is `sec:false`.
|
||||
The entire secure operational tree — `/device/0` included, which
|
||||
demonstrably answers, since the dump comes from it — is absent.
|
||||
|
||||
Two consequences, both load-bearing:
|
||||
|
||||
1. **Nothing is learned from an href's absence in `/oic/res`.** On the range
|
||||
board (issue #324) `/oic/res` lists ten onboarding links and no
|
||||
`/device/0`, yet `/device/1` answers a full indexed dual-cavity sibling.
|
||||
It was found only by `_SPECULATIVE_DEVICE_INDICES`, never by enumeration
|
||||
of the links.
|
||||
2. **Pattern A's `/oic/res` index scan is dead weight on these boards.** It
|
||||
contributes nothing anywhere except the dongle board, so in practice
|
||||
indexed siblings are found by the speculative `/device/1`, `/device/2`
|
||||
probe alone.
|
||||
|
||||
## What issue #335 has actually ruled out
|
||||
|
||||
All 26 probes in the report returned false. Twenty-three of them are the
|
||||
issue #205 flat fallback walking the master's own href list under the
|
||||
sibling's UUID prefix, plus `/<uuid>/device/0`, `/device/1`, `/device/2`
|
||||
and `/multidevice/vs/0`. Four more were read by hand from the issue thread
|
||||
(`/<uuid>/information/vs/{1,2}`, `/<uuid>/device/{1,2}`), all 4.04.
|
||||
|
||||
So what is ruled out is: the UUID-prefixed namespace (Pattern B/C), and the
|
||||
indexed **Collection** (`/device/<n>`). What has never been read on this
|
||||
board — or on any `FAC_BORA` board — is **a bare indexed leaf**:
|
||||
`/mode/vs/1`, `/temperatures/vs/1`, and friends. Every indexed href ever
|
||||
probed by this project arrived via a `/device/<n>` batch; none was ever
|
||||
GETed directly.
|
||||
|
||||
That gap matters because the "leaves exist, their Collection does not" shape
|
||||
is already confirmed on this exact product family, just in the other
|
||||
namespace: issue #205's `TP2X_FAC_BORA_21K` answers
|
||||
`/<uuid>/information/vs/0` while `/<uuid>/device/0` comes back empty. A
|
||||
board that mounts sibling leaves without mounting a sibling Collection is
|
||||
the documented BORA behavior, so `/device/1`'s 4.04 is evidence about the
|
||||
Collection and not about `/mode/vs/1`.
|
||||
|
||||
## What the OCF spec says about composite devices
|
||||
|
||||
The Core/Device specifications model this as a *Composite Device*: one
|
||||
Platform representing the whole appliance, `/oic/d` carrying the Device
|
||||
Types of every constituent Device, and — the relevant part — a **Collection
|
||||
per distinct Device in the composition**, each Collection's `rt` including
|
||||
the Device Type it represents.
|
||||
|
||||
Issue #335's `/oic/d` reports `["oic.wk.d", "oic.d.airconditioner"]`, which
|
||||
is consistent with a two-indoor-unit composite (both constituents are air
|
||||
conditioners, so the type appears once) and equally consistent with a single
|
||||
unit. It does not discriminate.
|
||||
|
||||
The Collection half does suggest something untried. `x.com.samsung.devcol`
|
||||
is Samsung's Collection type, carried by `/device/0` — and on the dongle
|
||||
board `/oic/res` advertises a second resource with the same
|
||||
`["x.com.samsung.devcol", "oic.wk.col"]` pair: **`/sec/devices`**. A
|
||||
collection of devices, sitting alongside `/device/0`, never read by this
|
||||
project or by any issue thread. If the composite enumeration is exposed
|
||||
anywhere as a first-class resource, that is the shape it would take.
|
||||
|
||||
## Results of the second probe round
|
||||
|
||||
The reporter ran these live. Three answers, all informative.
|
||||
|
||||
**Indexed leaves do not exist.** `/information/vs/1`, `/power/vs/1`,
|
||||
`/mode/vs/1` → 4.04. Pattern A is ruled out on this board properly now:
|
||||
not just the `/device/1` Collection, but the leaf namespace it would have
|
||||
carried.
|
||||
|
||||
**The UUID prefix routes, and is empty of operational resources.** The
|
||||
control pair settles it:
|
||||
|
||||
/c24e25e9-.../file/list/vs/0 → 2.05, two items
|
||||
/file/list/vs/0 → 2.05, the same two items
|
||||
(/opt/data/energy.db, /opt/data/hass.db)
|
||||
|
||||
So the sibling's prefix is a live, routed namespace — the 23 flat-fallback
|
||||
4.04s under it are the firmware answering "no such resource", not a dead
|
||||
prefix swallowing everything. Pattern B/C is ruled out on this board on
|
||||
positive evidence rather than on absence. That the two listings are
|
||||
identical is expected either way: one board, one flash, one filesystem.
|
||||
|
||||
**`/sec/devices` exists — and this project could not see what's in it.**
|
||||
It answered `2.05` with `rep: {}`, which reads as "the resource is there and
|
||||
has nothing in it". It is not. `coordinator._raw_read_blocking` decoded the
|
||||
CBOR body and then kept it *only if it was a Property map*:
|
||||
|
||||
```python
|
||||
if isinstance(body, dict):
|
||||
rep = body
|
||||
```
|
||||
|
||||
A Collection answers a **list** — the `[devcol rep, {href, rep}, ...]` batch
|
||||
`parse_device0_batch` reads. `/device/0` itself would have rendered exactly
|
||||
the same accepted-but-empty `2.05 {}` through `read_resource`. Fixed: the
|
||||
read path now returns the decoded body alongside `rep`, and the service
|
||||
response carries it as `body` whenever it isn't the map already in `rep`.
|
||||
|
||||
`/sec/devices` therefore remains the one open lead, and needs one re-read on
|
||||
a build carrying that fix.
|
||||
|
||||
## Still worth reading
|
||||
|
||||
**1 — `/sec/devices`, again.** Same `x.com.samsung.devcol` + `oic.wk.col`
|
||||
pair as `/device/0`, so its body should be a batch naming its members. If a
|
||||
composite enumeration is exposed anywhere, it is here.
|
||||
|
||||
**2 — the file-transfer pair.** `/oic/res` advertises
|
||||
`/c24e25e9-.../file/transfer/vs/0` alongside the master's, and the prefix is
|
||||
now known to route. Issue #301 documents the shape: a baseline GET returns
|
||||
one item, `x.com.samsung.name` plus `x.com.samsung.blob`, no write needed to
|
||||
see whatever it currently serves. If the prefixed endpoint serves *different
|
||||
bytes* than the master's, that is the first hard local evidence the wall
|
||||
unit exists as a data producer, and `/opt/data/energy.db` would be where its
|
||||
runtime history lives.
|
||||
|
||||
/file/transfer/vs/0
|
||||
/c24e25e9-55dd-ba18-d567-000000000001/file/transfer/vs/0
|
||||
|
||||
Mind the blob: #301 measured 2172 B on a `KRAC_18K`, and a raw `bytes` value
|
||||
in a service response is not guaranteed to survive rendering in Developer
|
||||
Tools. Ask for `x.com.samsung.name` and whether a blob field appears, not
|
||||
for the blob pasted into a comment.
|
||||
|
||||
## Dead ends, so they aren't re-tried
|
||||
|
||||
- `/hass/state/vs/0`, `/hass/command/vs/0` — advertised in `/oic/res` on
|
||||
every board here, and indexed per subdevice on the dongle board
|
||||
(`/hass/state/vs/{0,1,2}`), which makes them look like a subdevice-aware
|
||||
state channel. They are not: 4.04 on every interface on
|
||||
`ARTIK051_KRAC_18K` (see `ac-filter-reset.md`). Cheap enough to retry once
|
||||
on #335's newer build, but expect nothing.
|
||||
- `/multidevice/vs/0` — probed, 4.04. Absent on this board; only the dongle
|
||||
family exposes it.
|
||||
- `/actions/vs/0` — GET returns `{}` on baseline and `oic.if.a`; publishes
|
||||
no schema (`ac-filter-reset.md`).
|
||||
|
||||
## Where this lands if `/sec/devices` is empty too
|
||||
|
||||
Then the sibling is named in `subdeviceIdList` for the cloud's benefit and
|
||||
has no local operational surface at all on this firmware — every namespace
|
||||
it could occupy has now been read directly, and the UUID one was confirmed
|
||||
routable first, so the negatives mean what they say. That closes issue #335
|
||||
as a firmware limitation rather than leaving it open against a probe
|
||||
strategy that was never actually exercised.
|
||||
|
||||
Worth keeping in view for the enumeration code either way: both remaining
|
||||
patterns hinge on a Collection, and this board answers neither `/device/1`
|
||||
nor a prefixed `/device/0`. An indexed flat-probe fallback — the mirror of
|
||||
issue #205's prefixed one, gated on a board that claims a sibling but
|
||||
materialized nothing — would have cost 8 round trips here and returned the
|
||||
same 4.04s the reporter got by hand. It is worth building only if some
|
||||
other board turns out to serve indexed leaves without their Collection;
|
||||
this one does not.
|
||||
@@ -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",
|
||||
|
||||
@@ -15,6 +15,7 @@ from custom_components.localthings.const import (
|
||||
CONF_BYPASS_REMOTE_CONTROL,
|
||||
CONF_CA_CERT_PEM,
|
||||
CONF_CA_KEY_PEM,
|
||||
CONF_CLOUD_COURSES_ENABLED,
|
||||
CONF_HOST,
|
||||
CONF_LEAF_CERT_PEM,
|
||||
CONF_LEARN_MODES,
|
||||
@@ -238,6 +239,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 +251,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 +278,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 +491,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 +636,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."""
|
||||
@@ -1013,6 +1181,43 @@ async def test_learned_modes_option_can_be_turned_off(hass: HomeAssistant) -> No
|
||||
assert entry.options[CONF_LEARN_MODES] is False
|
||||
|
||||
|
||||
async def test_cloud_courses_enabled_option_defaults_to_on(hass: HomeAssistant) -> None:
|
||||
"""Issue #364's toggle starts on -- devices that already have a working
|
||||
downloaded-cycle setup keep it without having to find the option first."""
|
||||
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f"localthings_{MOCK_SERIAL}")
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"], user_input={"next_step_id": "settings"}
|
||||
)
|
||||
|
||||
data_schema = result["data_schema"]
|
||||
assert data_schema is not None
|
||||
assert data_schema({})[CONF_CLOUD_COURSES_ENABLED] is True
|
||||
|
||||
|
||||
async def test_cloud_courses_enabled_option_can_be_turned_off(hass: HomeAssistant) -> None:
|
||||
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f"localthings_{MOCK_SERIAL}")
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"], user_input={"next_step_id": "settings"}
|
||||
)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_BYPASS_REMOTE_CONTROL: False,
|
||||
CONF_LEARN_MODES: True,
|
||||
CONF_CLOUD_COURSES_ENABLED: False,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert entry.options[CONF_CLOUD_COURSES_ENABLED] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("stored", "listed"),
|
||||
[
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -22,7 +22,11 @@ from homeassistant.helpers import issue_registry as ir
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.localthings import cloudcourse
|
||||
from custom_components.localthings.const import CONF_CLOUD_COURSES, DOMAIN
|
||||
from custom_components.localthings.const import (
|
||||
CONF_CLOUD_COURSES,
|
||||
CONF_CLOUD_COURSES_ENABLED,
|
||||
DOMAIN,
|
||||
)
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
from custom_components.localthings.registry.entities import SelectDesc
|
||||
from custom_components.localthings.registry.subdevices import MAIN
|
||||
@@ -55,10 +59,11 @@ async def _flush(hass: HomeAssistant) -> None:
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
def _entry(hass: HomeAssistant, data=None) -> MockConfigEntry:
|
||||
def _entry(hass: HomeAssistant, data=None, options=None) -> MockConfigEntry:
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={**ENTRY_DATA, **(data or {})},
|
||||
options=options or {},
|
||||
unique_id="localthings_CLOUD-COURSE-TEST",
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
@@ -240,6 +245,138 @@ async def test_a_malformed_entry_record_does_not_block_setup(hass: HomeAssistant
|
||||
assert coordinator.cloud_courses.snapshot()["slots"]["55"]["blob"] == SPORTS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cloud_courses_enabled toggle (issue #364) -- some devices report a
|
||||
# downloaded program's payload before the owner has ever meant to use the
|
||||
# feature, so the Repair and the entity offering need a way to be turned
|
||||
# off entirely without discarding what was already learned.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cloud_courses_enabled_defaults_to_true(hass: HomeAssistant):
|
||||
coordinator = LocalThingsCoordinator(hass, _entry(hass))
|
||||
assert coordinator.cloud_courses_enabled is True
|
||||
|
||||
|
||||
async def test_disabling_suppresses_the_repair(hass: HomeAssistant):
|
||||
entry = _entry(hass, options={CONF_CLOUD_COURSES_ENABLED: False})
|
||||
coordinator = await _coordinator(hass, entry)
|
||||
coordinator._refresh_cloud_course_issue()
|
||||
await _flush(hass)
|
||||
|
||||
registry = ir.async_get(hass)
|
||||
assert registry.async_get_issue(DOMAIN, f"cloud_courses_{entry.entry_id}") is None
|
||||
|
||||
|
||||
async def test_disabling_clears_an_already_open_repair(hass: HomeAssistant):
|
||||
"""Turning the option off has to reach a Repair that's already showing,
|
||||
not just prevent a future one -- otherwise the toggle looks broken to
|
||||
anyone who flips it after already seeing the nudge."""
|
||||
entry = _entry(hass)
|
||||
coordinator = await _coordinator(hass, entry)
|
||||
coordinator._refresh_cloud_course_issue()
|
||||
await _flush(hass)
|
||||
registry = ir.async_get(hass)
|
||||
issue_id = f"cloud_courses_{entry.entry_id}"
|
||||
assert registry.async_get_issue(DOMAIN, issue_id) is not None
|
||||
|
||||
hass.config_entries.async_update_entry(entry, options={CONF_CLOUD_COURSES_ENABLED: False})
|
||||
coordinator._refresh_cloud_course_issue()
|
||||
await _flush(hass)
|
||||
|
||||
assert registry.async_get_issue(DOMAIN, issue_id) is None
|
||||
|
||||
|
||||
async def test_disabling_hides_an_already_named_program_from_the_cycle_select(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
"""`coordinator._on_cloud_courses_changed()` after the option write
|
||||
stands in for __init__.py's options-update listener, which this
|
||||
lightweight coordinator (no async_setup_entry) never registers --
|
||||
see test_disabling_clears_an_already_open_repair for the same shape."""
|
||||
entry = _entry(hass)
|
||||
coordinator = await _coordinator(hass, entry)
|
||||
coordinator.apply_cloud_courses({"55": "Sports"}, "87")
|
||||
await _flush(hass)
|
||||
assert "cloud:55" in _cycle_options(coordinator)
|
||||
|
||||
hass.config_entries.async_update_entry(entry, options={CONF_CLOUD_COURSES_ENABLED: False})
|
||||
coordinator._on_cloud_courses_changed()
|
||||
assert "cloud:55" not in _cycle_options(coordinator)
|
||||
# Not discarded -- the store itself is untouched, only what
|
||||
# entity_resources() hands the registry changes.
|
||||
assert coordinator.cloud_courses.named() == {"55": "Sports"}
|
||||
assert entry.data[CONF_CLOUD_COURSES]["slots"]["55"]["name"] == "Sports"
|
||||
|
||||
|
||||
async def test_re_enabling_immediately_restores_the_offering(hass: HomeAssistant):
|
||||
entry = _entry(hass, options={CONF_CLOUD_COURSES_ENABLED: False})
|
||||
coordinator = await _coordinator(hass, entry)
|
||||
coordinator.apply_cloud_courses({"55": "Sports"}, "87")
|
||||
await _flush(hass)
|
||||
assert "cloud:55" not in _cycle_options(coordinator)
|
||||
|
||||
hass.config_entries.async_update_entry(entry, options={CONF_CLOUD_COURSES_ENABLED: True})
|
||||
coordinator._on_cloud_courses_changed()
|
||||
assert "cloud:55" in _cycle_options(coordinator)
|
||||
|
||||
|
||||
async def test_disabling_does_not_stop_passive_observation(hass: HomeAssistant):
|
||||
"""Guided/manual setup depend on live observation to detect a newly
|
||||
selected program at all -- see CONF_CLOUD_COURSES_ENABLED's own comment
|
||||
for why the option leaves this running rather than gating it too."""
|
||||
entry = _entry(hass, options={CONF_CLOUD_COURSES_ENABLED: False})
|
||||
coordinator = await _coordinator(hass, entry)
|
||||
|
||||
assert coordinator.cloud_courses.snapshot()["slots"]["55"]["blob"] == SPORTS
|
||||
assert entry.data[CONF_CLOUD_COURSES]["slots"]["55"]["blob"] == SPORTS
|
||||
|
||||
|
||||
async def test_disabling_pushes_the_new_current_option_immediately(hass: HomeAssistant):
|
||||
"""select.py's current_option reads coordinator.data, not
|
||||
canonical_resources() -- clearing only the canonical-view cache left
|
||||
it stale until whatever poll happened to run next. The fixture is
|
||||
sitting on a one-time Jeans ('6B') override, so naming it is what
|
||||
makes the live selection actually depend on the cloud store rather
|
||||
than falling back to the raw course code."""
|
||||
entry = _entry(hass)
|
||||
coordinator = await _coordinator(hass, entry)
|
||||
coordinator.apply_cloud_courses({"55": "Sports", "6B": "Jeans"}, "87")
|
||||
await _flush(hass)
|
||||
assert coordinator.data["cycle"] == "cloud:6B"
|
||||
|
||||
hass.config_entries.async_update_entry(entry, options={CONF_CLOUD_COURSES_ENABLED: False})
|
||||
coordinator._on_cloud_courses_changed()
|
||||
|
||||
assert coordinator.data["cycle"] == "87"
|
||||
|
||||
|
||||
async def test_an_unrelated_option_save_before_first_poll_does_not_clear_a_real_repair(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
"""_on_cloud_courses_changed now runs from __init__.py's options-update
|
||||
listener on *every* entry save, not just a cloud-course-specific one --
|
||||
including one made before this device's first poll (a fresh restart,
|
||||
still rehydrating). /course/vs/0 unpolled reads as an empty rep, which
|
||||
must not be treated as evidence that nothing is pending: that would
|
||||
delete a Repair a real poll had every reason to raise."""
|
||||
entry = _entry(hass)
|
||||
coordinator = await _coordinator(hass, entry)
|
||||
coordinator._refresh_cloud_course_issue()
|
||||
await _flush(hass)
|
||||
registry = ir.async_get(hass)
|
||||
issue_id = f"cloud_courses_{entry.entry_id}"
|
||||
assert registry.async_get_issue(DOMAIN, issue_id) is not None
|
||||
|
||||
# A second coordinator against the same entry, standing in for a
|
||||
# restart that hasn't polled yet -- its own resource cache is empty.
|
||||
fresh = LocalThingsCoordinator(hass, entry)
|
||||
fresh._on_cloud_courses_changed()
|
||||
await _flush(hass)
|
||||
|
||||
assert registry.async_get_issue(DOMAIN, issue_id) is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Options flow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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"
|
||||
|
||||
+126
-3
@@ -54,13 +54,18 @@ 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) -> None:
|
||||
def queue_get(self, href: str, rep: dict | list) -> None:
|
||||
"""Queue one more canned rep for `href`'s next GET. Once an href's
|
||||
queue is down to one entry, that entry keeps answering every
|
||||
further GET -- a test only needs to queue the values that
|
||||
actually change across calls."""
|
||||
actually change across calls.
|
||||
|
||||
A list models a Collection's answer (the `[devcol rep, {href, rep},
|
||||
...]` batch), which is not a Property map and so is a shape the
|
||||
read path has to carry separately -- see the collection test below.
|
||||
"""
|
||||
self._get_reps.setdefault(href.strip("/"), []).append(rep)
|
||||
|
||||
def post(self, path_segs, payload, timeout=None):
|
||||
@@ -292,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):
|
||||
@@ -319,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
|
||||
):
|
||||
@@ -576,6 +618,87 @@ async def test_read_resource_with_href_does_live_get(hass, coordinator, device_i
|
||||
assert response["href"] == "/mode/vs/0"
|
||||
assert response["actual_href"] == "/mode/vs/0"
|
||||
assert response["rep"] == {"x.field": "live"}
|
||||
# No duplicate copy of a Property map that `rep` already carries.
|
||||
assert "body" not in response
|
||||
|
||||
|
||||
async def test_read_resource_surfaces_a_collections_list_body(hass, coordinator, device_id):
|
||||
"""A Collection answers a CBOR list, not a Property map, so `rep` can't
|
||||
hold it (issue #335: `/sec/devices` came back as an accepted-but-empty
|
||||
2.05, which reads as "exists, nothing in it" -- the opposite of what a
|
||||
populated batch means)."""
|
||||
batch = [
|
||||
{"rt": ["x.com.samsung.devcol", "oic.wk.col"]},
|
||||
{"href": "/mode/vs/0", "rep": {"x.field": "live"}},
|
||||
]
|
||||
fake = _FakeSession()
|
||||
fake.queue_get("sec/devices", batch)
|
||||
coordinator._session = fake
|
||||
|
||||
response = await _call_read(hass, device_id, href="/sec/devices")
|
||||
|
||||
assert response["code"] == "2.05"
|
||||
assert response["rep"] == {}
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
+188
-2
@@ -180,16 +180,202 @@ def test_confirmed_washer_table_02_towels_bedding_are_not_swapped():
|
||||
def test_confirmed_washer_table_02_missing_course_names():
|
||||
"""Issue #342: 06/08/a0 had no translation and fell back to the raw
|
||||
device code in the UI; 74 was already translated by the time this
|
||||
landed and is pinned here only as a "didn't regress" anchor."""
|
||||
landed and is pinned here only as a "didn't regress" anchor.
|
||||
|
||||
06's wording was corrected by issue #376 (originally 'XXL Laundry';
|
||||
that device's own '이불' report -- the same text as the confirmed
|
||||
Bedding codes 24/6f -- turned out to be the right one)."""
|
||||
states = _load("en")["entity"]["select"]["washer_cycle_table_02"]["state"]
|
||||
assert {code: states[code] for code in ("06", "08", "74", "a0")} == {
|
||||
"06": "XXL Laundry",
|
||||
"06": "Bedding",
|
||||
"08": "Rinse+Spin",
|
||||
"74": "Drum Clean",
|
||||
"a0": "15' Quick Wash",
|
||||
}
|
||||
|
||||
|
||||
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_confirmed_washer_table_02_wf21t6500kv_course_names():
|
||||
"""Issue #376: WF21T6500KV (DA_WM_A51_20_COMMON) reported Korean labels
|
||||
for 21 previously-untranslated Table_02 codes.
|
||||
|
||||
Several share their Korean text with an already-confirmed code
|
||||
(cross-checked against translations/ko.json, not guessed), so those
|
||||
reuse the established label rather than a fresh translation -- checked
|
||||
in every locale per issue #363's precedent, since a locale that
|
||||
translated the shared text differently would still pass the
|
||||
key-topology test alone. That includes '06': its Korean text ('이불')
|
||||
matches the confirmed Bedding codes 24/6f exactly, which superseded
|
||||
the wrong 'XXL Laundry' wording #342 had originally given it (see
|
||||
test_confirmed_washer_table_02_missing_course_names).
|
||||
"""
|
||||
english = _load("en")["entity"]["select"]["washer_cycle_table_02"]["state"]
|
||||
assert {
|
||||
code: english[code]
|
||||
for code in (
|
||||
"02",
|
||||
"03",
|
||||
"05",
|
||||
"06",
|
||||
"07",
|
||||
"09",
|
||||
"0b",
|
||||
"0c",
|
||||
"0d",
|
||||
"0e",
|
||||
"0f",
|
||||
"10",
|
||||
"11",
|
||||
"12",
|
||||
"13",
|
||||
"14",
|
||||
"15",
|
||||
"16",
|
||||
"18",
|
||||
"19",
|
||||
"1a",
|
||||
)
|
||||
} == {
|
||||
"02": "Extra Heavy Duty",
|
||||
"03": "Super Eco Wash",
|
||||
"05": "Wool/Lingerie",
|
||||
"06": "Bedding",
|
||||
"07": "Outdoor",
|
||||
"09": "Drum Clean",
|
||||
"0b": "Boil Wash",
|
||||
"0c": "Baby Care",
|
||||
"0d": "Spin Only",
|
||||
"0e": "Cloudy Day",
|
||||
"0f": "Pure Wash",
|
||||
"10": "Spin Dry",
|
||||
"11": "Summer Bedding",
|
||||
"12": "Cottons",
|
||||
"13": "Black Cottons",
|
||||
"14": "Delicate Underwear",
|
||||
"15": "Activewear",
|
||||
"16": "Blouses",
|
||||
"18": "Soft Bubble",
|
||||
"19": "AI Wash",
|
||||
"1a": "Shirts",
|
||||
}
|
||||
|
||||
# Anchors picked among the code's own duplicate-label siblings by
|
||||
# whichever this catalog already has translated consistently -- '2b'
|
||||
# over its "AI 맞춤세탁" twin '69', which nl.json alone translates
|
||||
# differently ("AI wassen" vs "AI Wash"), an existing inconsistency
|
||||
# unrelated to this issue and not one this PR resolves.
|
||||
reused_pairs = (
|
||||
("06", "24"),
|
||||
("07", "75"),
|
||||
("09", "3a"),
|
||||
("0c", "2e"),
|
||||
("15", "2f"),
|
||||
("16", "6c"),
|
||||
("19", "2b"),
|
||||
("1a", "32"),
|
||||
)
|
||||
for language in _languages():
|
||||
states = _load(language)["entity"]["select"]["washer_cycle_table_02"]["state"]
|
||||
for new_code, anchor_code in reused_pairs:
|
||||
assert states[new_code] == states[anchor_code], (language, new_code, anchor_code)
|
||||
|
||||
|
||||
def test_confirmed_dryer_table_03_dv19t8745bv_course_names():
|
||||
"""Issue #376: DV19T8745BV (DA_WM_TP1_21_COMMON) reported Korean labels
|
||||
for 18 previously-untranslated Table_03 codes -- none conflict with an
|
||||
existing entry. As with the washer table above, codes sharing Korean
|
||||
text with an already-confirmed code (including two, '3c'/'3d', whose
|
||||
text matches a washer_cycle_table_02 entry rather than one on this
|
||||
table) reuse that label instead of a fresh translation, checked in
|
||||
every locale.
|
||||
"""
|
||||
english = _load("en")["entity"]["select"]["dryer_cycle_table_03"]["state"]
|
||||
assert {
|
||||
code: english[code]
|
||||
for code in (
|
||||
"02",
|
||||
"03",
|
||||
"05",
|
||||
"07",
|
||||
"09",
|
||||
"0b",
|
||||
"0c",
|
||||
"0e",
|
||||
"0f",
|
||||
"11",
|
||||
"28",
|
||||
"37",
|
||||
"38",
|
||||
"39",
|
||||
"3a",
|
||||
"3b",
|
||||
"3c",
|
||||
"3d",
|
||||
)
|
||||
} == {
|
||||
"02": "AI Dry",
|
||||
"03": "Super Speed",
|
||||
"05": "Bedding",
|
||||
"07": "Delicates",
|
||||
"09": "Shirts",
|
||||
"0b": "Padding Care",
|
||||
"0c": "Outdoor Water-Repellent Care",
|
||||
"0e": "Towels",
|
||||
"0f": "Wool",
|
||||
"11": "Cool air",
|
||||
"28": "Interior Hot Air Sanitize",
|
||||
"37": "Blouses",
|
||||
"38": "Iron dry",
|
||||
"39": "Room Dehumidify",
|
||||
"3a": "Hygiene Care",
|
||||
"3b": "Bedding/Dust Off",
|
||||
"3c": "Activewear",
|
||||
"3d": "Denim",
|
||||
}
|
||||
|
||||
same_table_pairs = (
|
||||
("02", "29"),
|
||||
("03", "17"),
|
||||
("05", "1b"),
|
||||
("07", "19"),
|
||||
("09", "1c"),
|
||||
("0e", "1d"),
|
||||
("0f", "1a"),
|
||||
("11", "24"),
|
||||
("38", "20"),
|
||||
("3a", "21"),
|
||||
)
|
||||
for language in _languages():
|
||||
d_states = _load(language)["entity"]["select"]["dryer_cycle_table_03"]["state"]
|
||||
w_states = _load(language)["entity"]["select"]["washer_cycle_table_02"]["state"]
|
||||
for new_code, anchor_code in same_table_pairs:
|
||||
assert d_states[new_code] == d_states[anchor_code], (language, new_code, anchor_code)
|
||||
# Cross-table reuse: same Korean text as a washer_cycle_table_02 code.
|
||||
assert d_states["37"] == w_states["6c"], language # Blouses
|
||||
assert d_states["3c"] == w_states["2f"], language # Activewear
|
||||
assert d_states["3d"] == w_states["66"], language # Denim
|
||||
|
||||
|
||||
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