Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5e25d72d3 | ||
|
|
6c980f927b | ||
|
|
207dd2f830 | ||
|
|
b0caebe14d | ||
|
|
513d44e8be | ||
|
|
2a52cd9cfa | ||
|
|
16dfcd4e50 | ||
|
|
4edd5b7866 | ||
|
|
51bce3c8cf | ||
|
|
35f144a2d1 | ||
|
|
43076352ae | ||
|
|
171316bd95 | ||
|
|
61d2b9e9ff | ||
|
|
5ab0c9b5a8 | ||
|
|
6003394cb2 | ||
|
|
66b4f1400b | ||
|
|
c573a41483 | ||
|
|
b5969773db | ||
|
|
9a45afe949 | ||
|
|
296ba18cba | ||
|
|
258cdc1647 | ||
|
|
33a06b9ae8 | ||
|
|
01c9dc9801 | ||
|
|
f780cc6069 | ||
|
|
d8ebc17808 | ||
|
|
367017cc4c | ||
|
|
ccb4a09c65 | ||
|
|
1b8e8368ca | ||
|
|
c7aa66ef6e | ||
|
|
1e9fbd4ec5 | ||
|
|
81dbc6fa03 | ||
|
|
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 | ||
|
|
6d73ac8694 | ||
|
|
1cfe126313 | ||
|
|
8d1ecb4f2a | ||
|
|
0c1231794a | ||
|
|
67b28ed10f | ||
|
|
f12f67b2b3 | ||
|
|
2f7170c448 | ||
|
|
b8ef430ed4 | ||
|
|
cd3a47f9a4 |
+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"
|
||||
|
||||
@@ -85,7 +85,7 @@ This repo doesn't include the needed CA bundle. For an example of how to obtain
|
||||
5. The flow sends a DTLS `ClientHello` to every port in the `49152-49160` range at once and keeps the one that answers -- a real DTLS server identifies itself in about one round trip, and the probe stops there, so nothing is left behind on the appliance. Only that port is then given a real certificate handshake: it fetches the current UUID from Samsung's cloud gateway, mints a leaf cert signed by your CA, and reads the device's identity and `/device/0`. On success it creates the config entry, already knowing the appliance's serial, model, and type.
|
||||
6. Every subsequent device only asks for the host IP. The stored CA credentials are reused, and so is the leaf cert itself -- every appliance accepts the same one -- so adding a second appliance doesn't depend on Samsung's cloud being reachable at all. If a device rejects the reused cert (the UUID behind it does rotate), the flow mints a fresh one and retries by itself.
|
||||
|
||||
Entities appear under one HA device per appliance, named for the appliance's type and model. Rename freely: the device is keyed on its serial, not its name.
|
||||
Entities appear under one HA device per appliance, named for the appliance's type and model. Rename freely: the device is keyed on the appliance's own OCF device ID, not its name. (Some Samsung models ship the same serial number on every unit of a model, so the serial can't tell two of them apart -- the OCF device ID can.)
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -237,7 +237,8 @@ docker-compose.yml / ha_config/ Local HA dev environment
|
||||
If your appliance's type isn't recognized, or it exposes resources this integration doesn't model yet, a Repairs
|
||||
issue appears under Settings > System > Repairs pointing you at Settings > Devices & Services > this device >
|
||||
the menu > Download diagnostics. That download is already redacted of account/network identifiers (Bixby login
|
||||
email, access tokens, device IDs, MAC addresses, serial numbers) before it's generated, so it's safe to attach
|
||||
email, access tokens, hashed device IDs, MAC addresses, serial numbers, and the owner-set device name) before it's
|
||||
generated, so it's safe to attach
|
||||
directly to a new issue using the linked device-support template. This is the fastest way to help add or expand
|
||||
support for hardware the maintainers don't have.
|
||||
|
||||
@@ -270,6 +271,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,30 @@ 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 .rekey import rekey_entry
|
||||
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
|
||||
@@ -62,67 +83,114 @@ def _repair_placeholder_keys(hass: HomeAssistant, entry: ConfigEntry, serial: st
|
||||
"""Re-key registry entries this entry minted from the placeholder identity.
|
||||
|
||||
Before the identity moved onto the config entry, the coordinator seeded
|
||||
`device_serial` with the host and only replaced it after the first poll.
|
||||
its device key with the host and only replaced it after the first poll.
|
||||
Anything that registered in between -- the connection-mode sensor
|
||||
especially, added unconditionally rather than from `bound` -- was
|
||||
written into the registry keyed on the IP permanently, orphaned the
|
||||
moment the serial-keyed identity appeared (issue #236). Deleting the
|
||||
orphans by hand didn't help: the next restart that lost the same race
|
||||
recreated them.
|
||||
|
||||
Rewriting beats deleting where possible -- an entity keeps its
|
||||
entity_id, name, area and automations. Only possible when the
|
||||
serial-keyed key is still free; where both exist the placeholder-keyed
|
||||
one is the dead duplicate (unavailable since the restart that created
|
||||
it), so it goes.
|
||||
"""
|
||||
host = entry.data[CONF_HOST]
|
||||
if serial == host:
|
||||
# A board with no usable serial resolves to the host, so its keys
|
||||
# were never placeholders.
|
||||
return
|
||||
rekey_entry(hass, entry, host, serial)
|
||||
|
||||
|
||||
# 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)
|
||||
stale_prefix = f"{DOMAIN}_{host}_"
|
||||
for entity in list(er.async_entries_for_config_entry(ent_reg, entry.entry_id)):
|
||||
if not entity.unique_id.startswith(stale_prefix):
|
||||
for registry_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id):
|
||||
if registry_entry.domain != "sensor":
|
||||
continue
|
||||
new_unique_id = f"{DOMAIN}_{serial}_{entity.unique_id[len(stale_prefix) :]}"
|
||||
if ent_reg.async_get_entity_id(entity.domain, DOMAIN, new_unique_id):
|
||||
_LOGGER.debug("removing orphaned entity %s", entity.entity_id)
|
||||
ent_reg.async_remove(entity.entity_id)
|
||||
else:
|
||||
_LOGGER.debug("re-keying entity %s to %s", entity.entity_id, new_unique_id)
|
||||
ent_reg.async_update_entity(entity.entity_id, new_unique_id=new_unique_id)
|
||||
|
||||
dev_reg = dr.async_get(hass)
|
||||
for device in list(dr.async_entries_for_config_entry(dev_reg, entry.entry_id)):
|
||||
# `host` for the master, `host_<key>` for a subdevice (device_info_for).
|
||||
stale = {
|
||||
ident
|
||||
for ident in device.identifiers
|
||||
if ident[0] == DOMAIN and (ident[1] == host or ident[1].startswith(f"{host}_"))
|
||||
}
|
||||
if not stale:
|
||||
if not _PARTICULATE_KEY_RE.search(registry_entry.unique_id):
|
||||
continue
|
||||
fresh = {(DOMAIN, f"{serial}{ident[1][len(host) :]}") for ident in stale}
|
||||
existing = dev_reg.async_get_device(identifiers=fresh)
|
||||
if existing is not None and existing.id != device.id:
|
||||
# Removing a device takes its entities with it. Anything still
|
||||
# attached here was re-keyed rather than removed above -- the
|
||||
# surviving copy, not a duplicate -- so move it onto the device
|
||||
# it now belongs to before the removal destroys it too.
|
||||
for entity in er.async_entries_for_device(
|
||||
ent_reg, device.id, include_disabled_entities=True
|
||||
):
|
||||
ent_reg.async_update_entity(entity.entity_id, device_id=existing.id)
|
||||
_LOGGER.debug("removing orphaned device %s", device.id)
|
||||
dev_reg.async_remove_device(device.id)
|
||||
else:
|
||||
_LOGGER.debug("re-keying device %s to %s", device.id, fresh)
|
||||
dev_reg.async_update_device(
|
||||
device.id, new_identifiers=(device.identifiers - stale) | fresh
|
||||
_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:
|
||||
@@ -132,8 +200,25 @@ 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).
|
||||
|
||||
v3 -> v4 moves the entry off the serialNum as its identity and onto the
|
||||
OCF device UUID (issue #381). Deliberately almost a no-op here: the
|
||||
UUID lives on the device, and this runs before any I/O -- and before
|
||||
the device is even known to be reachable, since an entry can load
|
||||
entirely from its snapshot while the appliance is off (issue #295).
|
||||
So the migration only guarantees CONF_SERIAL is populated, which is
|
||||
what the coordinator rewrites *from* once a live poll finally hands it
|
||||
a UUID to rewrite *to*.
|
||||
|
||||
The version is still bumped now rather than at that point, because the
|
||||
bump's real job is the `entry.version > 4` downgrade guard below: an
|
||||
entry re-keyed onto a UUID and then loaded by a release that reads
|
||||
CONF_SERIAL as the key would silently orphan every entity it has.
|
||||
"""
|
||||
if entry.version > 2:
|
||||
if entry.version > 4:
|
||||
return False # downgrade: this release doesn't know the newer shape
|
||||
|
||||
if entry.version == 1:
|
||||
@@ -147,23 +232,62 @@ 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)
|
||||
|
||||
if entry.version == 3:
|
||||
# `or host` mirrors what the coordinator has always fallen back to,
|
||||
# so the recorded legacy key is the one this entry's registry
|
||||
# entries were actually minted under even if CONF_SERIAL never got
|
||||
# written (a v1 entry whose migration predates it). Both being
|
||||
# absent shouldn't happen for an entry the config flow created, but
|
||||
# an exception raised here fails the whole entry -- so it bumps the
|
||||
# version and leaves the data alone rather than taking that risk
|
||||
# for a value the coordinator re-derives on its next poll anyway.
|
||||
legacy_key = entry.data.get(CONF_SERIAL) or entry.data.get(CONF_HOST)
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data={**entry.data, CONF_SERIAL: legacy_key} if legacy_key else entry.data,
|
||||
version=4,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"migrated entry %s to version 4 (legacy key=%s, awaiting a poll to adopt "
|
||||
"the OCF device id)",
|
||||
entry.entry_id,
|
||||
legacy_key,
|
||||
)
|
||||
|
||||
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 +307,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 +363,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,8 @@ from .const import (
|
||||
CONF_BYPASS_REMOTE_CONTROL,
|
||||
CONF_CA_CERT_PEM,
|
||||
CONF_CA_KEY_PEM,
|
||||
CONF_CLOUD_COURSES_ENABLED,
|
||||
CONF_DEVICE_KEY,
|
||||
CONF_DEVICE_TYPE,
|
||||
CONF_FINISH_TIME_HYSTERESIS_MINUTES,
|
||||
CONF_HOST,
|
||||
@@ -52,6 +54,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 +496,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 +581,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"
|
||||
@@ -595,7 +663,12 @@ def _read_device(sess, host: str, port: int) -> dict:
|
||||
|
||||
from .registry.batch import parse_device0_batch
|
||||
from .registry.by_type import resolve as resolve_registry
|
||||
from .registry.identity import read_identity, resolve_model, resolve_serial
|
||||
from .registry.identity import (
|
||||
read_identity,
|
||||
resolve_device_key,
|
||||
resolve_model,
|
||||
resolve_serial,
|
||||
)
|
||||
|
||||
identity = read_identity(sess, None)
|
||||
|
||||
@@ -613,12 +686,19 @@ def _read_device(sess, host: str, port: int) -> dict:
|
||||
|
||||
info = resources.get("/information/vs/0", {})
|
||||
registry = resolve_registry(resources, device_types=identity.device_types)
|
||||
raw_serial = info.get("x.com.samsung.da.serialNum")
|
||||
return {
|
||||
"port": port,
|
||||
# Resolved through the same helpers _run_discovery uses, so the device
|
||||
# the coordinator registers up front is the one discovery would have
|
||||
# produced -- no rename, and no re-key, once the first poll lands.
|
||||
"serial": resolve_serial(info.get("x.com.samsung.da.serialNum"), host),
|
||||
#
|
||||
# `device_key` is what the entry is actually keyed on; the serial is
|
||||
# kept alongside it because the coordinator corroborates a later
|
||||
# change of key against it (issue #381). read_identity has already
|
||||
# fetched /oic/p and /oic/d above, so this costs no extra round trip.
|
||||
"device_key": resolve_device_key(identity, raw_serial, host),
|
||||
"serial": resolve_serial(raw_serial, host),
|
||||
"model": resolve_model(info.get("x.com.samsung.da.modelNum", ""), identity),
|
||||
"manufacturer": identity.manufacturer or "Samsung",
|
||||
"device_type_name": registry.name if registry is not None else None,
|
||||
@@ -626,6 +706,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 +763,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 +808,13 @@ 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).
|
||||
# v4 keys the entry on the OCF device UUID (issue #381), which the probe
|
||||
# below resolves up front -- so a new entry is already on the v4 shape
|
||||
# and has nothing to re-key either.
|
||||
VERSION = 4
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._host: str = ""
|
||||
@@ -712,7 +833,7 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Persist everything the probe resolved, identity included.
|
||||
|
||||
The identity fields aren't decoration: the coordinator seeds
|
||||
`device_serial` and its DeviceInfo from them at construction time,
|
||||
`device_key` and its DeviceInfo from them at construction time,
|
||||
so entity unique_ids are correct from the first entity that
|
||||
registers, even if the first poll is slow or fails (issue #236).
|
||||
"""
|
||||
@@ -727,6 +848,7 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
CONF_CA_KEY_PEM: self._ca_key_pem,
|
||||
CONF_LEAF_CERT_PEM: info["leaf_cert_pem"],
|
||||
CONF_LEAF_KEY_PEM: info["leaf_key_pem"],
|
||||
CONF_DEVICE_KEY: info["device_key"],
|
||||
CONF_SERIAL: info["serial"],
|
||||
CONF_MODEL: info["model"],
|
||||
CONF_MANUFACTURER: info["manufacturer"],
|
||||
@@ -773,7 +895,33 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.exception("Unexpected error during device probe")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(f"localthings_{info['serial']}")
|
||||
# An entry created before v4 still carries the serial-keyed
|
||||
# unique_id until its first *live* poll adopts the UUID
|
||||
# (coordinator._resolve_identity) -- which can be a long
|
||||
# while for an appliance that is off, since an entry loads
|
||||
# from its snapshot in the meantime (issue #295). The UUID
|
||||
# check below can't see such an entry, so re-adding this
|
||||
# very appliance during that window would be waved through
|
||||
# as a second entry; the two would then collide the moment
|
||||
# the older one re-keyed, and rekey_entry resolves a
|
||||
# collision by *deleting* the duplicate rows -- taking the
|
||||
# original entry's entity_ids, history and automations with
|
||||
# them. Matched on the legacy key together with the host, so
|
||||
# issue #381's two units (same serial, different addresses)
|
||||
# stay separable.
|
||||
legacy_unique_id = f"localthings_{info['serial']}"
|
||||
if any(
|
||||
other.unique_id == legacy_unique_id
|
||||
and other.data.get(CONF_HOST) == self._host
|
||||
and CONF_DEVICE_KEY not in other.data
|
||||
for other in existing
|
||||
):
|
||||
return self.async_abort(reason="already_configured")
|
||||
# Keyed on the OCF device UUID rather than the serialNum
|
||||
# (issue #381): two units of a model that ship the same
|
||||
# well-formed serial are indistinguishable here otherwise,
|
||||
# and the second one is turned away as already configured.
|
||||
await self.async_set_unique_id(f"localthings_{info['device_key']}")
|
||||
self._abort_if_unique_id_configured()
|
||||
if info["device_type_recognized"]:
|
||||
return self._create_entry(info)
|
||||
@@ -888,6 +1036,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 +1089,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",
|
||||
|
||||
@@ -30,6 +30,11 @@ CONF_LEAF_KEY_PEM = "leaf_key_pem"
|
||||
# output, the host itself for a placeholder-serial board -- issues
|
||||
# #83/#189), so it matches what _run_discovery computes on the first poll.
|
||||
CONF_SERIAL = "serial"
|
||||
# What this entry's devices and entities are keyed on -- normally the OCF
|
||||
# device UUID (issue #381). CONF_SERIAL stays alongside it as the pre-v4
|
||||
# key to re-key from, and as what corroborates a later change of UUID.
|
||||
# Absent until the first live poll, since only the device can report it.
|
||||
CONF_DEVICE_KEY = "device_key"
|
||||
CONF_MODEL = "model"
|
||||
CONF_MANUFACTURER = "manufacturer"
|
||||
CONF_DEVICE_TYPE = "device_type"
|
||||
@@ -57,6 +62,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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -84,7 +88,7 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
|
||||
super().__init__(coordinator)
|
||||
self._bound = bound
|
||||
self._state_key = _key(bound)
|
||||
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_serial}_{self._state_key}"
|
||||
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_key}_{self._state_key}"
|
||||
if bound.desc.translation_placeholders is not None:
|
||||
self._attr_translation_placeholders = dict(bound.desc.translation_placeholders)
|
||||
elif bound.desc.use_instance_name:
|
||||
|
||||
@@ -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.0"
|
||||
"version": "0.24.0"
|
||||
}
|
||||
|
||||
@@ -56,6 +56,26 @@ SUCCESS_FRACTION = 0.8
|
||||
PUSH_HEALTH_WINDOW_S = 60.0
|
||||
|
||||
|
||||
def _is_alarms_href(href: str) -> bool:
|
||||
"""True for /alarms/vs/<index> in any subdevice-translated shape --
|
||||
the canonical MAIN form (/alarms/vs/0), an indexed subdevice's
|
||||
renumbered instance (/alarms/vs/<key>), or a prefixed subdevice's
|
||||
UUID-qualified form (/<uuid>/alarms/vs/0). `Subdevice.to_actual`
|
||||
(registry/subdevices.py) only ever rewrites the trailing index
|
||||
segment or prepends a prefix -- it never touches the 'alarms/vs'
|
||||
stem -- so matching that fixed segment plus a wildcard tail catches
|
||||
every shape without this module needing to be subdevice-aware.
|
||||
|
||||
See `ObserveManager.apply`'s use of this for why the href matters:
|
||||
unlike most resources, /alarms/vs/0's `x.com.samsung.da.items` array
|
||||
is a complete snapshot of every currently-active alarm, not a
|
||||
possibly-partial field update -- so it must never be merged onto a
|
||||
stale prior rep (issue #348).
|
||||
"""
|
||||
head, _, _ = href.rpartition("/")
|
||||
return head.endswith("/alarms/vs")
|
||||
|
||||
|
||||
class ObserveManager:
|
||||
"""Per-device observe-mode state: mode, write-settle guard, and (later)
|
||||
subscription/staleness tracking. Pure sync logic — safe to call from
|
||||
@@ -75,8 +95,15 @@ class ObserveManager:
|
||||
self._notified: set[str] = set()
|
||||
self._last_notify_ts: float | None = None
|
||||
# Wakes try_enter_observe_mode's grace wait early once enough hrefs
|
||||
# have notified. Guards only `_notified` mutations + the `wait_for`.
|
||||
# have notified. Guards `_notified` mutations, the `wait_for`, and
|
||||
# fallback_hrefs (enter_observe_mode assignment, on_notification
|
||||
# discard).
|
||||
self._notify_cond = threading.Condition()
|
||||
# Idle while polling, except after downgrade_to_poll (every href
|
||||
# that was subscribed). While in observe mode this is the set of
|
||||
# subscribed hrefs that have not yet notified (issue #92) -- they
|
||||
# stay on the hot/warm sub-poll cadence. on_notification discards
|
||||
# an href once it pushes, so a late first notify self-corrects.
|
||||
self.fallback_hrefs: set[str] = set()
|
||||
self._on_applied: Callable[[str, dict, str], None] | None = None
|
||||
self._refresh_task: ObserveRefreshTask | None = None
|
||||
@@ -122,6 +149,20 @@ class ObserveManager:
|
||||
comes through, even though nothing about the device's actual
|
||||
supported options changed.
|
||||
|
||||
`_is_alarms_href` is the one exception to that merge (issue #348):
|
||||
/alarms/vs/0's `items` array is always sent as a complete
|
||||
snapshot of every currently-active alarm, never a partial delta
|
||||
-- confirmed by a live `read_resource` GET returning `{}` (no
|
||||
`items` key at all) the moment a washer's board actually clears
|
||||
an alarm, which entity.py already documents as this resource's
|
||||
normal no-alarm shape. Merging that `{}` onto the prior rep the
|
||||
same way as everywhere else silently kept the stale `items`
|
||||
entry forever: an absent key merges as "unchanged" everywhere
|
||||
else, but on this href absent specifically means "cleared".
|
||||
Every family that exposes an alarm sensor shares this href
|
||||
(common.ALARMS, range_hood's own copy), so this is a full
|
||||
replace for all of them, not a washer-specific carve-out.
|
||||
|
||||
`apply()` is the sole path StateCache mutations flow through in
|
||||
this component (poll, sweep, and OBSERVE notify all funnel here),
|
||||
so `_cache_lock` serializes the read-then-write across those
|
||||
@@ -149,7 +190,7 @@ class ObserveManager:
|
||||
self.log.debug("dropping %s update for %s (settling)", source, href)
|
||||
return False
|
||||
with self._cache_lock:
|
||||
merged = {**(self.cache.get(href) or {}), **rep}
|
||||
merged = dict(rep) if _is_alarms_href(href) else {**(self.cache.get(href) or {}), **rep}
|
||||
changed = self.cache.apply_rep(href, merged, source=source)
|
||||
# Outside the cache lock -- the hook takes locks of its own and
|
||||
# never reads the cache back. `source` is passed along rather than
|
||||
@@ -171,6 +212,10 @@ class ObserveManager:
|
||||
return
|
||||
with self._notify_cond:
|
||||
self._notified.add(href)
|
||||
# Snapshot in enter_observe_mode is at the 80% quorum, not the
|
||||
# full grace period -- a late first push (blockwise refetch)
|
||||
# must drop the href so we don't keep GET-polling a live one.
|
||||
self.fallback_hrefs.discard(href)
|
||||
self._last_notify_ts = time.monotonic()
|
||||
self._notify_cond.notify_all()
|
||||
self.log.debug("observe notify: %s", href)
|
||||
@@ -236,6 +281,13 @@ class ObserveManager:
|
||||
#294) -- committing against a session a reconnect already replaced
|
||||
would claim observe mode with nothing left to notice it's dead."""
|
||||
self.subscribed_hrefs = set(subscribed)
|
||||
# Issue #92: subscribed-but-silent hrefs are counted as covered by
|
||||
# push if we drop this, but they never emit a notify. Keep them on
|
||||
# the poll cadence via fallback_hrefs (otherwise idle in observe).
|
||||
# Same lock as on_notification's discard so a notify in this window
|
||||
# cannot land on a set object that is about to be replaced.
|
||||
with self._notify_cond:
|
||||
self.fallback_hrefs = set(subscribed) - self._notified
|
||||
self._set_mode(MODE_OBSERVE)
|
||||
self.start_refresh_task(session)
|
||||
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -7,21 +7,29 @@ only); this registry deliberately doesn't include common.POWER.
|
||||
air_purifier.AIR_QUALITY and range_hood.AIR_QUALITY already read via
|
||||
common.sensor_item_value -- reused here rather than re-decoded, including
|
||||
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.
|
||||
device shares those capabilities' catalog entries. This board reports a
|
||||
CO2 reading; air_purifier.AIR_QUALITY now models the same type when a
|
||||
purifier lists it (issue #387).
|
||||
|
||||
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 +39,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 +58,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",
|
||||
|
||||
@@ -34,7 +34,13 @@ from ..entities import (
|
||||
SwitchDesc,
|
||||
TimeDesc,
|
||||
)
|
||||
from .common import epoch_to_utc, filter_usage_percent, int_or_none, sensor_item_value
|
||||
from .common import (
|
||||
epoch_to_utc,
|
||||
filter_usage_percent,
|
||||
has_sensor_type,
|
||||
int_or_none,
|
||||
sensor_item_value,
|
||||
)
|
||||
from .laundry import bool_option_exists, bool_option_value, option_value, option_write
|
||||
|
||||
# Newer TP1X_DA-AC-AIR-class boards (issue #130) report fan modes directly
|
||||
@@ -51,13 +57,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,29 +73,74 @@ 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(
|
||||
href="/sensors/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=tuple(
|
||||
entities=(
|
||||
*(
|
||||
SensorDesc(
|
||||
key=key,
|
||||
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, device_class, unit in _AIR_QUALITY_SENSORS
|
||||
),
|
||||
# CO2 (issue #387) -- same field/shape air_monitor.SENSORS already
|
||||
# models with device_class='carbon_dioxide'/unit='ppm'. Gated on the
|
||||
# type being listed so boards that don't report it (every current
|
||||
# fixture) don't grow an empty entity. Disabled by default for the
|
||||
# same reason as airconditioner.AIR_QUALITY (issue #166).
|
||||
SensorDesc(
|
||||
key=key,
|
||||
key="co2",
|
||||
field="x.com.samsung.da.items",
|
||||
icon=icon,
|
||||
state_class=state_class,
|
||||
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
|
||||
)
|
||||
for key, icon, sensor_type, state_class in _AIR_QUALITY_SENSORS
|
||||
icon="mdi:molecule-co2",
|
||||
device_class="carbon_dioxide",
|
||||
state_class="measurement",
|
||||
unit="ppm",
|
||||
exists_fn=has_sensor_type("CO2"),
|
||||
enabled_default=False,
|
||||
value_fn=lambda items: sensor_item_value(items, "CO2"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ from ..entities import (
|
||||
SwitchDesc,
|
||||
)
|
||||
from . import common
|
||||
from .common import filter_usage_hours, filter_usage_percent, normalize_temp_unit
|
||||
from .common import filter_usage_hours, filter_usage_percent, has_sensor_type, normalize_temp_unit
|
||||
from .laundry import option_write
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -112,26 +119,6 @@ def _sensor_item_value(items, type_):
|
||||
return None
|
||||
|
||||
|
||||
def _has_sensor_type(type_):
|
||||
"""True when /sensors/vs/0's items[] lists an item of this type.
|
||||
|
||||
This only proves the type is *listed*, not that the reading is real:
|
||||
issue #166 (ARTIK051_PRAC_20K) lists all five types with permanent-zero
|
||||
values on units the reporter confirmed don't have the hardware. So
|
||||
entities gated on this stay disabled by default (see AIR_QUALITY) rather
|
||||
than existence-gated further, to avoid silently dropping real readings
|
||||
on hardware not yet seen.
|
||||
"""
|
||||
|
||||
def fn(rep, resources):
|
||||
return any(
|
||||
isinstance(i, dict) and i.get("x.com.samsung.da.type") == type_
|
||||
for i in (rep.get("x.com.samsung.da.items") or [])
|
||||
)
|
||||
|
||||
return fn
|
||||
|
||||
|
||||
# Canonical AC resource hrefs. climate.py binds HREF_MODE and reads the
|
||||
# CLIMATE_CONSUMED_HREFS siblings off the coordinator snapshot; declared once
|
||||
# here so climate.py and the coverage list below can't drift out of sync.
|
||||
@@ -365,6 +352,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 +789,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
|
||||
@@ -1515,7 +1550,7 @@ WINDSLEEP = Capability(
|
||||
# /sensors/vs/0 items[] carry live air-quality readings. CleanLevel is
|
||||
# corroborated as numeric by a top-level x.com.samsung.da.cleanLevel scalar,
|
||||
# so it's a measurement; the others stay string diagnostics (see
|
||||
# _sensor_item_value). All disabled by default: _has_sensor_type only proves
|
||||
# _sensor_item_value). All disabled by default: has_sensor_type only proves
|
||||
# the item type is listed, not that the sensor is real (see its docstring).
|
||||
AIR_QUALITY = Capability(
|
||||
href="/sensors/vs/0",
|
||||
@@ -1527,7 +1562,7 @@ AIR_QUALITY = Capability(
|
||||
icon="mdi:broom",
|
||||
entity_category="diagnostic",
|
||||
state_class="measurement",
|
||||
exists_fn=_has_sensor_type("CleanLevel"),
|
||||
exists_fn=has_sensor_type("CleanLevel"),
|
||||
enabled_default=False,
|
||||
value_fn=lambda items: _int(_sensor_item_value(items, "CleanLevel")),
|
||||
),
|
||||
@@ -1537,7 +1572,7 @@ AIR_QUALITY = Capability(
|
||||
field="x.com.samsung.da.items",
|
||||
icon=icon,
|
||||
entity_category="diagnostic",
|
||||
exists_fn=_has_sensor_type(type_),
|
||||
exists_fn=has_sensor_type(type_),
|
||||
enabled_default=False,
|
||||
value_fn=lambda items, t=type_: _sensor_item_value(items, t),
|
||||
)
|
||||
@@ -1551,12 +1586,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",
|
||||
@@ -1565,7 +1598,7 @@ AIR_QUALITY = Capability(
|
||||
device_class="carbon_dioxide",
|
||||
state_class="measurement",
|
||||
unit="ppm",
|
||||
exists_fn=_has_sensor_type("CO2"),
|
||||
exists_fn=has_sensor_type("CO2"),
|
||||
enabled_default=False,
|
||||
value_fn=lambda items: _int(_sensor_item_value(items, "CO2")),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
@@ -269,6 +299,30 @@ def sensor_item_value(items, sensor_type, index=0):
|
||||
return None
|
||||
|
||||
|
||||
def has_sensor_type(type_):
|
||||
"""True when /sensors/vs/0's items[] lists an item of this type.
|
||||
|
||||
This only proves the type is *listed*, not that the reading is real:
|
||||
issue #166 (ARTIK051_PRAC_20K) lists all five types with permanent-zero
|
||||
values on units the reporter confirmed don't have the hardware. So
|
||||
entities gated on this stay disabled by default rather than
|
||||
existence-gated further, to avoid silently dropping real readings on
|
||||
hardware not yet seen.
|
||||
|
||||
is_stub_rep(rep) keeps the stub carve-out (see entity._is_included /
|
||||
ENERGY_METER, issue #127): an explicit exists_fn otherwise bypasses it
|
||||
and would drop the entity when /device/0 returns a not-yet-fetched stub.
|
||||
"""
|
||||
|
||||
def fn(rep, resources):
|
||||
return is_stub_rep(rep) or any(
|
||||
isinstance(i, dict) and i.get("x.com.samsung.da.type") == type_
|
||||
for i in (rep.get("x.com.samsung.da.items") or [])
|
||||
)
|
||||
|
||||
return fn
|
||||
|
||||
|
||||
# OCF-native / vendor '-vs' fallback pairs for power, kids-lock, remote
|
||||
# control: each exists as both a standard OCF resource (/power/0,
|
||||
# oic.r.switch.binary, plain boolean 'value') and a Samsung vendor
|
||||
|
||||
@@ -9,7 +9,12 @@ wash, auto release dry) are read locally here.
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import ButtonDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||
from .laundry import bool_option_switch, cycle_select
|
||||
from .common import diagnosis_status
|
||||
from .laundry import (
|
||||
bool_option_switch,
|
||||
cycle_select,
|
||||
drum_clean_cycles_remaining,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /dishwasher/vs/0 — cycle wash/dry settings
|
||||
@@ -51,6 +56,19 @@ DISHWASHER_SETTINGS = Capability(
|
||||
# kind of adjacent pair a manual screenshot transcription slips on. The
|
||||
# reporter's live confirmation (selecting 'Normal' ran the physical Express
|
||||
# 60 program and vice versa) settled it: '86' is Express 60, '83' is Normal.
|
||||
#
|
||||
# Drum Clean+ maintenance tracking reuses washer.py/dryer.py's (issues #9,
|
||||
# #258) DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens riding on this
|
||||
# same options[] array -- a live dump confirmed the dishwasher reports the
|
||||
# identical trio (WashingTimes_18/DrumCleanProposal_20, plus a '|'-joined
|
||||
# DrumCleanLog_ history matching the dryer's multi-entry shape), so
|
||||
# laundry.drum_clean_cycles_remaining applies unchanged.
|
||||
#
|
||||
# laundry.drum_clean_last_cleaned (DrumCleanLog_'s own newest entry) is
|
||||
# deliberately NOT wired up here (issue #398): a live dishwasher dump
|
||||
# showed it moving every 30-90s on its own, including well after a cycle
|
||||
# had already finished -- unlike the washer/dryer reports this reader was
|
||||
# built from (issues #9, #258), it never settles on a value worth showing.
|
||||
|
||||
CYCLE_OPTIONS = Capability(
|
||||
href="/course/vs/0",
|
||||
@@ -60,6 +78,14 @@ CYCLE_OPTIONS = Capability(
|
||||
bool_option_switch(
|
||||
"auto_release_dry", "mdi:door-open", "AutoDoorRelease", gate_on_presence=True
|
||||
),
|
||||
SensorDesc(
|
||||
key="drum_clean_cycles_remaining",
|
||||
unit="cycles",
|
||||
icon="mdi:dishwasher-alert",
|
||||
state_class="measurement",
|
||||
exists_fn=lambda rep, resources: drum_clean_cycles_remaining(rep) is not None,
|
||||
rep_fn=drum_clean_cycles_remaining,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -76,6 +102,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",
|
||||
@@ -46,6 +57,19 @@ DRYER_SETTINGS = Capability(
|
||||
# (issue #244). /st/dryercourse/vs/0 re-encodes the same selected course
|
||||
# and is ignored (ignored.py), mirroring /st/washercourse/vs/0 for washers.
|
||||
#
|
||||
# dryer_cycle_table_00 is a separate, older course-code family reported by
|
||||
# a DVE45R6300W/A3 (issue #357), confirmed the same way: the reporter
|
||||
# selected each cycle on the appliance and read back the resulting raw
|
||||
# code. It shares no codes with Table_03 above -- 'a5' Bedding here and
|
||||
# '01' Normal are both table-scoped, so a Table_03 dryer never picks up a
|
||||
# Table_00 label or vice versa (see laundry.cycle_select's table_href).
|
||||
# A DV6800N -- same DA_WM_A51_20_COMMON board, also Table_00 -- confirmed
|
||||
# 14 more courses the same way (issue #394); its /course/vs/0 supportedOptions
|
||||
# only advertises a different subset of this same table (each model exposes
|
||||
# whichever courses its hardware supports), not a conflicting code family --
|
||||
# the one code both reporters confirmed, 'a5', means Bedding on both. Folded
|
||||
# into the same catalog entry below rather than a new one.
|
||||
#
|
||||
# Drum Clean+ maintenance tracking (issue #258) reuses washer.py's
|
||||
# DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens on this same
|
||||
# options[] array -- see laundry.drum_clean_cycles_remaining/
|
||||
@@ -84,7 +108,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):
|
||||
@@ -62,17 +63,21 @@ def _just_finished(rep):
|
||||
return rep.get("x.com.samsung.da.progress") == "Finish"
|
||||
|
||||
|
||||
def _live_progress_code(rep):
|
||||
"""progress/progress_percentage's sticky_bypass_fn: a concrete,
|
||||
non-Finish progress code being reported right now -- e.g. a new
|
||||
cycle's own real 'Wash'/'Spin' -- must win over a still-open hold from
|
||||
the previous cycle immediately. Not keyed on `state` (unlike
|
||||
_is_active): _just_finished's whole premise is that `state` can't be
|
||||
trusted to still say 'active' while a fresh, real progress value is
|
||||
already there, and the same applies to recognizing when it's moved on
|
||||
to a new one -- including while paused, e.g. adding a sock mid-hold."""
|
||||
def _new_cycle_running(rep):
|
||||
"""progress/progress_percentage's sticky_bypass_fn: drop the #345 hold
|
||||
early once a new cycle is genuinely running.
|
||||
|
||||
Gated on `state == 'active'`, unlike _just_finished's arm condition
|
||||
above: issue #358's dryer resets `progress` to its course's first
|
||||
stage ('Drying') in the same moment `state` goes idle, ~4s before
|
||||
settling to 'None' -- confirmed by the reporter's machine_state
|
||||
history, which flips to idle on the exact second progress reads
|
||||
'Drying', in both captured cycles. A bypass keyed on the progress
|
||||
code alone read that as a new cycle and republished it. Releasing
|
||||
late costs nothing -- an unreleased hold still expires on its own --
|
||||
so this side takes the stronger signal."""
|
||||
v = rep.get("x.com.samsung.da.progress")
|
||||
return v is not None and v not in ("None", "Finish")
|
||||
return _state_is_active(rep) and v is not None and v not in ("None", "Finish")
|
||||
|
||||
|
||||
def _remaining_seconds(raw):
|
||||
@@ -190,26 +195,25 @@ OPERATIONAL_STATE = Capability(
|
||||
# sticky_* (issue #345): once progress reads 'Finish', keep
|
||||
# showing Finish/100 for a grace window even after machine_state
|
||||
# reverts, rather than falling to Idle/0 the instant it does --
|
||||
# see sensor.py's _apply_sticky. rep_fn below is otherwise
|
||||
# unchanged; the hold is entirely a read-side, per-entity concern,
|
||||
# deliberately not gated on machine_state (_just_finished's
|
||||
# docstring explains why). sticky_live_fn reads the raw field the
|
||||
# same ungated way, for sticky_bypass_fn's benefit: a real
|
||||
# progress value reported while paused (e.g. adding a sock
|
||||
# mid-cycle) must win over a stale hold even though rep_fn itself
|
||||
# would show "Idle"/0 there.
|
||||
# see sensor.py's _apply_sticky. rep_fn below is unchanged and
|
||||
# stays the only definition of a live value -- the hold decides
|
||||
# only *whether* to freeze. A second, ungated one here is what
|
||||
# let issue #358's post-Finish tail reach the entity.
|
||||
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",
|
||||
sticky_live_fn=lambda rep: _progress(rep.get("x.com.samsung.da.progress")),
|
||||
sticky_bypass_fn=_live_progress_code,
|
||||
# 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(
|
||||
key="progress_percentage",
|
||||
@@ -222,8 +226,7 @@ OPERATIONAL_STATE = Capability(
|
||||
),
|
||||
sticky_fn=_just_finished,
|
||||
sticky_value_fn=lambda rep: 100,
|
||||
sticky_live_fn=lambda rep: _int(rep.get("x.com.samsung.da.progressPercentage")) or 0,
|
||||
sticky_bypass_fn=_live_progress_code,
|
||||
sticky_bypass_fn=_new_cycle_running,
|
||||
),
|
||||
# Only show finish time while actively running -- firmware leaves a
|
||||
# stale remainingTime after a cycle ends, frozen at '00:01:00'.
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@ array.
|
||||
"""
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc
|
||||
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||
from .laundry import (
|
||||
bool_option_exists,
|
||||
bool_option_switch,
|
||||
@@ -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
|
||||
@@ -58,6 +61,15 @@ from .laundry import (
|
||||
# the owner or device metadata falls back to washer_cycle_fallback, which
|
||||
# surfaces a personal-course name only -- no invented English label for an
|
||||
# unrecognized standard code (PR #251 review).
|
||||
#
|
||||
# washer_cycle_table_00 (issue #357) is a separate, older course-code family
|
||||
# reported by a WF45R6300AW/US -- confirmed by the reporter selecting each
|
||||
# cycle on the appliance and reading back the raw code, the same method used
|
||||
# for Table_02's WF50A8600AV/US codes above. A device reporting Table_00 with
|
||||
# an unconfirmed code (FlexWash's washer_flexwash_device fixture, for
|
||||
# instance) still renders that code raw rather than borrowing a Table_02
|
||||
# label -- the two tables are unrelated code spaces despite a handful of
|
||||
# overlapping hex values.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# /washer/vs/0 -- wash temperature, spin speed, rinse cycle count.
|
||||
@@ -252,6 +264,105 @@ def _bool_option_switch(key, icon, prefix, availability_field):
|
||||
)
|
||||
|
||||
|
||||
# AddWash -- the little door for adding a forgotten sock mid-cycle -- rides
|
||||
# three independent tokens on the same options[] array:
|
||||
#
|
||||
# AddWashSet_<0-7> the alarm setting, and the only writable one:
|
||||
# a 3-bit mask over the moments it fires, bit 0
|
||||
# rinse, bit 1 final rinse, bit 2 spin.
|
||||
# AddWashAvailable_<0-7> the same three bits, but what the running
|
||||
# course still permits.
|
||||
# AddWashIndicator_On/Off the panel lamp: laundry may go in right now.
|
||||
#
|
||||
# Bit order confirmed by watching a WW6500 run a cycle: AddWashAvailable
|
||||
# shed one bit as each moment passed (7 through Rinse, then 6, 4, and 0 as
|
||||
# Spin began) and reset to 7 at the end, while the lamp tracked the phase
|
||||
# with the alarm switched off throughout.
|
||||
|
||||
|
||||
def _add_wash_mask(rep, prefix):
|
||||
"""One of the 3-bit AddWash masks, or None when its token is absent,
|
||||
malformed, or outside 0-7. Never 0 for a missing token: 0 is a real
|
||||
value, and a mask this model can't represent is a wrong model rather
|
||||
than something to write back."""
|
||||
raw = option_value(rep.get("x.com.samsung.da.options"), prefix)
|
||||
try:
|
||||
mask = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return mask if 0 <= mask <= 0b111 else None
|
||||
|
||||
|
||||
def _add_wash_any(prefix):
|
||||
"""Whether any of the three moments is set in `prefix`'s mask."""
|
||||
|
||||
def read(rep):
|
||||
mask = _add_wash_mask(rep, prefix)
|
||||
return None if mask is None else mask != 0
|
||||
|
||||
return read
|
||||
|
||||
|
||||
def _add_wash_set_write(mask):
|
||||
return ["course", "vs", "0"], {
|
||||
"x.com.samsung.da.options": option_write("AddWashSet", str(mask)),
|
||||
}
|
||||
|
||||
|
||||
def _add_wash_alarm_write(p, rep, href=None):
|
||||
# Gated on the mask being readable, like the per-moment writes: a device
|
||||
# reporting a wider mask than these three bits would otherwise have it
|
||||
# truncated to 7 here, silently dropping a moment it supports.
|
||||
mask = _add_wash_mask(rep, "AddWashSet")
|
||||
if p not in ("On", "Off") or mask is None:
|
||||
return None
|
||||
if p == "On" and mask:
|
||||
# Already on, so "on" is a no-op rather than a rewrite to 7. Home
|
||||
# Assistant calls turn_on regardless of current state, so an
|
||||
# automation asserting the alarm on over a rinse-only mask would
|
||||
# otherwise widen it to all three moments with no state change on
|
||||
# this switch to point at. Distinct from the off-then-on case in
|
||||
# _add_wash_bit_switch, where there is no subset left to keep.
|
||||
return None
|
||||
return _add_wash_set_write(0b111 if p == "On" else 0)
|
||||
|
||||
|
||||
def _add_wash_bit_switch(key, icon, bit):
|
||||
"""One moment the alarm fires at, as its own bit of the mask.
|
||||
|
||||
The mask is the only state, so switching the last moment off lands on 0
|
||||
and takes the alarm with it, and switching one on from 0 turns the alarm
|
||||
back on. The corollary is that switching the master off and on again
|
||||
writes 7, resetting a rinse-only selection to all three moments -- the
|
||||
appliance remembers no previous subset either, so there is nothing to
|
||||
restore.
|
||||
"""
|
||||
|
||||
def read(rep):
|
||||
mask = _add_wash_mask(rep, "AddWashSet")
|
||||
return None if mask is None else bool(mask >> bit & 1)
|
||||
|
||||
def write(p, rep, href=None):
|
||||
mask = _add_wash_mask(rep, "AddWashSet")
|
||||
if p not in ("On", "Off") or mask is None:
|
||||
return None
|
||||
return _add_wash_set_write(mask | 1 << bit if p == "On" else mask & ~(1 << bit))
|
||||
|
||||
return SwitchDesc(
|
||||
key=key,
|
||||
icon=icon,
|
||||
entity_category="config",
|
||||
exists_fn=bool_option_exists("AddWashSet"),
|
||||
rep_fn=read,
|
||||
write_fn=write,
|
||||
)
|
||||
|
||||
|
||||
def _add_wash_indicator(rep):
|
||||
raw = option_value(rep.get("x.com.samsung.da.options"), "AddWashIndicator")
|
||||
return raw.lower() == "on" if isinstance(raw, str) else None
|
||||
|
||||
|
||||
WASHER_COURSE = Capability(
|
||||
href="/course/vs/0",
|
||||
entities=(
|
||||
@@ -338,5 +449,33 @@ WASHER_COURSE = Capability(
|
||||
_bool_option_switch(
|
||||
"intensive", "mdi:washing-machine", "IntensiveSetting", "IntensiveAvailableSet"
|
||||
),
|
||||
SwitchDesc(
|
||||
key="add_wash_alarm",
|
||||
icon="mdi:bell-ring",
|
||||
entity_category="config",
|
||||
exists_fn=bool_option_exists("AddWashSet"),
|
||||
rep_fn=_add_wash_any("AddWashSet"),
|
||||
write_fn=_add_wash_alarm_write,
|
||||
),
|
||||
_add_wash_bit_switch("add_wash_alarm_rinse", "mdi:water", 0),
|
||||
_add_wash_bit_switch("add_wash_alarm_final_rinse", "mdi:water-check", 1),
|
||||
_add_wash_bit_switch("add_wash_alarm_spin", "mdi:sync", 2),
|
||||
# On at rest: an idle washer reports AddWashAvailable_7 and the mask
|
||||
# only empties as the cycle consumes each moment. This says the cycle
|
||||
# permits AddWash, not that laundry can go in now -- that is
|
||||
# add_wash_indicator.
|
||||
BinarySensorDesc(
|
||||
key="add_wash_available",
|
||||
icon="mdi:tshirt-crew-outline",
|
||||
entity_category="diagnostic",
|
||||
exists_fn=bool_option_exists("AddWashAvailable"),
|
||||
rep_fn=_add_wash_any("AddWashAvailable"),
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key="add_wash_indicator",
|
||||
icon="mdi:door-open",
|
||||
exists_fn=bool_option_exists("AddWashIndicator"),
|
||||
rep_fn=_add_wash_indicator,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -65,15 +65,15 @@ class SensorDesc(SamsungEntityDescription):
|
||||
# device-side revisions -- not a general-purpose flag.
|
||||
hysteresis: bool = False
|
||||
# Opt-in, entity-instance-only hold -- see sensor.py's _apply_sticky
|
||||
# for the full contract (arm/value/live/bypass semantics,
|
||||
# edge-triggering, why this never touches the coordinator cache).
|
||||
# for the full contract (arm/value/bypass semantics, one window per
|
||||
# bypass, why this never touches the coordinator cache).
|
||||
# sticky_fn arms it; sticky_value_fn picks what to freeze at that
|
||||
# moment (defaults to rep_fn's own result); sticky_bypass_fn forces
|
||||
# sticky_live_fn's result through and drops the hold; sticky_seconds
|
||||
# bounds how long it can hold.
|
||||
# moment (defaults to rep_fn's own result); sticky_bypass_fn drops the
|
||||
# hold and lets rep_fn's own live result through; sticky_seconds
|
||||
# bounds how long it can hold. There is deliberately no hook for
|
||||
# computing a live value differently from rep_fn -- see issue #358.
|
||||
sticky_fn: Callable[[dict], bool] | None = None
|
||||
sticky_value_fn: Callable[[dict], Any] | None = None
|
||||
sticky_live_fn: Callable[[dict], Any] | None = None
|
||||
sticky_bypass_fn: Callable[[dict], bool] | None = None
|
||||
sticky_seconds: float = 300.0
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@ class DeviceIdentity:
|
||||
model: str
|
||||
name: str
|
||||
serial: str | None
|
||||
# /oic/d's `di` and /oic/p's `pi` -- OCF's own device and platform
|
||||
# UUIDs. Promoted out of `raw` into named fields because
|
||||
# resolve_device_key mints permanent registry keys from them; see its
|
||||
# docstring for why `di` leads.
|
||||
device_id: str | None = None
|
||||
platform_id: str | None = None
|
||||
device_types: tuple[str, ...] = ()
|
||||
raw: dict[str, dict | list] = field(default_factory=dict)
|
||||
|
||||
@@ -57,6 +63,63 @@ def resolve_serial(raw_serial: str | None, host: str) -> str:
|
||||
return s
|
||||
|
||||
|
||||
def is_usable_device_id(value: str | None) -> bool:
|
||||
"""True for an OCF `di`/`pi` that actually identifies one unit.
|
||||
|
||||
Rejects OCF's nil UUID, which firmware that never had one assigned
|
||||
reports on every unit of the family -- the #189 failure mode on a new
|
||||
field, and one `is_placeholder_serial`'s repeated-digit rule misses
|
||||
because the dashes make more than one distinct character. Past that the
|
||||
same known-junk rules apply: a board flashed with 'Nothing(SVC)' in one
|
||||
identity field is not one to trust in another.
|
||||
"""
|
||||
s = (value or "").strip()
|
||||
if not s:
|
||||
return False
|
||||
if not set(s) - {"0", "-"}:
|
||||
return False
|
||||
return not is_placeholder_serial(s)
|
||||
|
||||
|
||||
def ocf_device_key(identity: DeviceIdentity | None) -> str | None:
|
||||
"""The OCF-derived half of resolve_device_key's chain, or None when the
|
||||
device reported no usable UUID.
|
||||
|
||||
Split out because "no UUID" and "this UUID" are different answers to a
|
||||
caller holding an existing key: the coordinator must never demote an
|
||||
entry off its UUID just because one poll couldn't read /oic/d.
|
||||
Normalized so firmware that changes case between reads doesn't look
|
||||
like a different appliance.
|
||||
"""
|
||||
if identity is None:
|
||||
return None
|
||||
for candidate in (identity.device_id, identity.platform_id):
|
||||
if candidate is not None and is_usable_device_id(candidate):
|
||||
return candidate.strip().lower()
|
||||
return None
|
||||
|
||||
|
||||
def resolve_device_key(identity: DeviceIdentity | None, raw_serial: str | None, host: str) -> str:
|
||||
"""The identity to mint this device's permanent registry keys from.
|
||||
|
||||
Tried in order: /oic/d's `di`, /oic/p's `pi`, the serialNum, the host.
|
||||
|
||||
`di` leads because it is what the protocol already uses to address this
|
||||
endpoint: if it were wrong or shared, OCF discovery and the DTLS
|
||||
association would not work at all. serialNum is a vendor-populated
|
||||
string nothing depends on, which is why three firmware families have
|
||||
shipped it unusable -- 'Nothing(SVC)' (#83), a flash-unset sentinel
|
||||
(#189), and a well-formed serial duplicated across every unit (#381).
|
||||
|
||||
`pi` is only the fallback despite the spec calling it immutable: it is
|
||||
*platform*-scoped, so a board hosting several logical OCF devices
|
||||
shares one across all of them. `di` is device-scoped, the granularity
|
||||
of a config entry. The serial and host stay below both so a board
|
||||
answering neither resource lands where it always did.
|
||||
"""
|
||||
return ocf_device_key(identity) or resolve_serial(raw_serial, host)
|
||||
|
||||
|
||||
def resolve_model(model_num: str, identity: DeviceIdentity | None) -> str:
|
||||
"""The model string to name and register a device under.
|
||||
|
||||
@@ -143,6 +206,8 @@ def read_identity(sess, serial: str | None) -> DeviceIdentity:
|
||||
model=p.get("mnmo") or "",
|
||||
name=d.get("n") or "",
|
||||
serial=serial,
|
||||
device_id=d.get("di") if isinstance(d.get("di"), str) else None,
|
||||
platform_id=p.get("pi") if isinstance(p.get("pi"), str) else None,
|
||||
device_types=_device_types(d),
|
||||
# Kept whole rather than field-by-field: outside the /device/0 dump
|
||||
# diagnostics already captures, and we don't yet know which fields
|
||||
|
||||
@@ -30,13 +30,18 @@ _SENSITIVE_SUBSTRINGS = (
|
||||
"secret",
|
||||
)
|
||||
|
||||
# Matched whole, not as substrings: OCF's /oic/d and /oic/p identify the
|
||||
# unit with bare one/two-letter keys too short for the substring rules above
|
||||
# ('di' is a substring of 'condition', 'display', ...). 'di'/'pi' are the
|
||||
# device/platform UUIDs; 'n' is /oic/d's free-text device name, which may
|
||||
# carry a person's name -- the device-type signal we actually want from
|
||||
# that resource is `rt`, which is not redacted.
|
||||
_SENSITIVE_EXACT = frozenset({"di", "pi", "n"})
|
||||
# Matched whole, not as substrings: these are bare one/two-letter keys too
|
||||
# short for the substring rules above ('n' is a substring of very nearly
|
||||
# everything). 'n' is /oic/d's free-text device name, which the owner sets
|
||||
# from the SmartThings app and can carry a person's name -- the device-type
|
||||
# signal we actually want from that resource is `rt`, which is not redacted.
|
||||
#
|
||||
# /oic/d's `di` and /oic/p's `pi` are deliberately not redacted: they're
|
||||
# randomly-assigned per-unit UUIDs rather than account data, and they are
|
||||
# what registry keys are minted from (issue #381), so blanking them hides
|
||||
# the identity every entity in a report is named after -- which is exactly
|
||||
# what made #381's first diagnostics download unable to answer it.
|
||||
_SENSITIVE_EXACT = frozenset({"n"})
|
||||
|
||||
|
||||
def _is_sensitive_key(key: str) -> bool:
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Move an entry's registry entries from one device key to another.
|
||||
|
||||
The key (registry.identity.resolve_device_key) is permanent in three
|
||||
places, so changing it means rewriting the registries rather than storing
|
||||
a new value -- anything left behind is orphaned. Rewriting rather than
|
||||
recreating is what keeps an entity's entity_id, and with it its history,
|
||||
area and automations.
|
||||
|
||||
Its own module because both callers need it: the v1 -> v2 migration in
|
||||
__init__.py and the coordinator's first-poll adoption (issue #381).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@callback
|
||||
def rekey_entry(hass: HomeAssistant, entry: ConfigEntry, old_key: str, new_key: str) -> None:
|
||||
"""Rewrite everything this entry registered under `old_key` to `new_key`.
|
||||
|
||||
All three permanent places move together: entity unique_ids
|
||||
(f"{DOMAIN}_{key}_{state_key}"), device identifiers ((DOMAIN, key), plus
|
||||
(DOMAIN, f"{key}_{subdevice}") per subdevice -- see device_info_for),
|
||||
and the entry's own unique_id. Leaving that last one behind would let
|
||||
the config flow's duplicate check wave through a re-add of this very
|
||||
appliance.
|
||||
|
||||
Idempotent, so it is safe to attempt on every poll rather than tracking
|
||||
whether it has run. Where both keys already exist the `old_key` copy is
|
||||
the dead one, so it is removed rather than rewritten over the live entry.
|
||||
|
||||
Must run on the event loop; the registry helpers require it.
|
||||
"""
|
||||
if old_key == new_key:
|
||||
return
|
||||
|
||||
new_entry_unique_id = f"{DOMAIN}_{new_key}"
|
||||
if entry.unique_id != new_entry_unique_id:
|
||||
hass.config_entries.async_update_entry(entry, unique_id=new_entry_unique_id)
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
stale_prefix = f"{DOMAIN}_{old_key}_"
|
||||
for entity in list(er.async_entries_for_config_entry(ent_reg, entry.entry_id)):
|
||||
if not entity.unique_id.startswith(stale_prefix):
|
||||
continue
|
||||
new_unique_id = f"{DOMAIN}_{new_key}_{entity.unique_id[len(stale_prefix) :]}"
|
||||
if ent_reg.async_get_entity_id(entity.domain, DOMAIN, new_unique_id):
|
||||
_LOGGER.debug("removing orphaned entity %s", entity.entity_id)
|
||||
ent_reg.async_remove(entity.entity_id)
|
||||
else:
|
||||
_LOGGER.debug("re-keying entity %s to %s", entity.entity_id, new_unique_id)
|
||||
ent_reg.async_update_entity(entity.entity_id, new_unique_id=new_unique_id)
|
||||
|
||||
dev_reg = dr.async_get(hass)
|
||||
for device in list(dr.async_entries_for_config_entry(dev_reg, entry.entry_id)):
|
||||
stale = {
|
||||
ident
|
||||
for ident in device.identifiers
|
||||
if ident[0] == DOMAIN and (ident[1] == old_key or ident[1].startswith(f"{old_key}_"))
|
||||
}
|
||||
if not stale:
|
||||
continue
|
||||
fresh = {(DOMAIN, f"{new_key}{ident[1][len(old_key) :]}") for ident in stale}
|
||||
existing = dev_reg.async_get_device(identifiers=fresh)
|
||||
if existing is not None and existing.id != device.id:
|
||||
# Removing a device takes its entities with it. Anything still
|
||||
# attached here was re-keyed rather than removed above -- the
|
||||
# surviving copy, not a duplicate -- so move it onto the device
|
||||
# it now belongs to before the removal destroys it too.
|
||||
for entity in er.async_entries_for_device(
|
||||
ent_reg, device.id, include_disabled_entities=True
|
||||
):
|
||||
ent_reg.async_update_entity(entity.entity_id, device_id=existing.id)
|
||||
_LOGGER.debug("removing orphaned device %s", device.id)
|
||||
dev_reg.async_remove_device(device.id)
|
||||
else:
|
||||
_LOGGER.debug("re-keying device %s to %s", device.id, fresh)
|
||||
dev_reg.async_update_device(
|
||||
device.id, new_identifiers=(device.identifiers - stale) | fresh
|
||||
)
|
||||
@@ -49,12 +49,12 @@ 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
|
||||
self._sticky_armed = False
|
||||
self._sticky_spent = False
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self):
|
||||
@@ -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)
|
||||
@@ -82,52 +99,49 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
|
||||
cache, so write_fn, diagnostics, and the observe-mode sweep
|
||||
comparison keep seeing real device data throughout.
|
||||
|
||||
Reads `sticky_fn` and friends against this href's own live rep,
|
||||
not the already-computed `raw`, so they can be independent of
|
||||
whatever rep_fn itself gates on -- notably `sticky_live_fn`, which
|
||||
is *not* `raw`: `raw` is rep_fn's own (possibly differently gated)
|
||||
result, e.g. progress's rep_fn shows "Idle" while paused, but a
|
||||
real progress value reported while paused (adding a sock mid-
|
||||
cycle) must still win over a stale hold, which means reading it
|
||||
ungated here rather than through that gate.
|
||||
`sticky_fn`/`sticky_bypass_fn` read this href's live rep rather
|
||||
than the already-computed `raw`, so they can key on fields rep_fn
|
||||
has collapsed away -- but they never compute a value. `raw` and
|
||||
the frozen `sticky_value` are the only things returned here, so a
|
||||
held entity and a free-running one agree on what "live" means; a
|
||||
hook that broke that rule caused issue #358.
|
||||
|
||||
Edge-triggered, not level-triggered: the window only (re)starts on
|
||||
a fresh False->True transition of `sticky_fn`, and -- this is the
|
||||
part level-triggering alone misses -- expiry is still checked on
|
||||
every call even while `sticky_fn` keeps matching. Without the
|
||||
latter, a firmware that leaves the underlying field stuck matching
|
||||
forever (the same class of quirk `_completion_minutes` already
|
||||
works around) would show the frozen value forever too, defeating
|
||||
"bounded, not indefinite".
|
||||
At most one window per `sticky_bypass_fn` cycle: arming marks the
|
||||
hold spent, and only the bypass clears that. So a `sticky_fn` that
|
||||
keeps matching (firmware leaving the field stuck -- the quirk
|
||||
`_completion_minutes` works around) can't extend the window, and
|
||||
one flapping in and out can't restart it either, before or after
|
||||
expiry. Expiry alone doesn't re-open the door: without something
|
||||
the calibre of "a new cycle is actually running" in between, a
|
||||
second Finish is the same Finish, and re-arming on it would strobe
|
||||
the entity between held and live once per window -- exactly the
|
||||
repeated announcements #345 and #358 are about.
|
||||
|
||||
`sticky_bypass_fn`, when it matches, always passes
|
||||
`sticky_live_fn`'s result through and drops any hold -- for a
|
||||
condition where "not sticky right now" is ambiguous between "went
|
||||
idle, honor the hold" and "genuinely live, different data" (e.g. a
|
||||
new cycle's own real progress), which "consult the hold whenever
|
||||
sticky_fn is False" alone can't tell apart.
|
||||
`sticky_bypass_fn` drops the hold and returns `raw`, for when "not
|
||||
sticky right now" is ambiguous between "went idle, honor the hold"
|
||||
and "genuinely moved on to new data". It is both the early release
|
||||
and the only re-arm, so it should demand positive evidence of that
|
||||
move; when unsure, letting the window run out is the cheaper
|
||||
mistake.
|
||||
"""
|
||||
assert desc.sticky_fn is not None # native_value only calls this when set
|
||||
rep = self.coordinator.resource(self._bound.href)
|
||||
now = time.monotonic()
|
||||
matches = desc.sticky_fn(rep)
|
||||
|
||||
if matches:
|
||||
if not self._sticky_armed:
|
||||
if desc.sticky_fn(rep):
|
||||
if not self._sticky_spent:
|
||||
self._sticky_value = (
|
||||
desc.sticky_value_fn(rep) if desc.sticky_value_fn is not None else raw
|
||||
)
|
||||
self._sticky_until = now + desc.sticky_seconds
|
||||
self._sticky_armed = True
|
||||
else:
|
||||
self._sticky_armed = False
|
||||
if desc.sticky_bypass_fn is not None and desc.sticky_bypass_fn(rep):
|
||||
self._sticky_until = None
|
||||
return desc.sticky_live_fn(rep) if desc.sticky_live_fn is not None else raw
|
||||
self._sticky_spent = True
|
||||
elif desc.sticky_bypass_fn is not None and desc.sticky_bypass_fn(rep):
|
||||
self._sticky_until = None
|
||||
self._sticky_spent = False
|
||||
return raw
|
||||
|
||||
if self._sticky_until is not None and now < self._sticky_until:
|
||||
return self._sticky_value
|
||||
return raw
|
||||
holding = self._sticky_until is not None and now < self._sticky_until
|
||||
return self._sticky_value if holding else raw
|
||||
|
||||
def _apply_hysteresis(self, raw):
|
||||
"""Hold the last value this entity actually reported until a new one
|
||||
@@ -173,7 +187,7 @@ class LocalThingsConnectionModeSensor(CoordinatorEntity[LocalThingsCoordinator],
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_serial}_connection_mode"
|
||||
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_key}_connection_mode"
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
|
||||
@@ -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,15 @@
|
||||
},
|
||||
"softener_low": {
|
||||
"name": "Málo aviváže"
|
||||
},
|
||||
"add_wash_available": {
|
||||
"name": "AddWash povoleno"
|
||||
},
|
||||
"add_wash_indicator": {
|
||||
"name": "AddWash připraveno"
|
||||
},
|
||||
"stick_ble_connected": {
|
||||
"name": "Tyč připojena přes BLE"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
@@ -258,7 +267,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": {
|
||||
@@ -324,6 +337,35 @@
|
||||
"4": "Alarm 4"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_00": {
|
||||
"name": "Cyklus",
|
||||
"state": {
|
||||
"01": "Normální",
|
||||
"9c": "Intenzivní",
|
||||
"a5": "Ložní prádlo",
|
||||
"9e": "Nežehlivé prádlo",
|
||||
"9b": "Parní dezinfekce+",
|
||||
"27": "Osvěžení",
|
||||
"a0": "Provětrání",
|
||||
"a4": "Časové sušení",
|
||||
"a6": "Rychlé sušení",
|
||||
"a3": "Sportovní oblečení",
|
||||
"a2": "Jemné prádlo",
|
||||
"9a": "Bavlna",
|
||||
"ca": "Provětrání",
|
||||
"db": "Super Speed",
|
||||
"99": "Smíšená náplň",
|
||||
"93": "Žehlení",
|
||||
"b5": "Vlna",
|
||||
"d7": "Outdoor péče",
|
||||
"96": "Studený vzduch",
|
||||
"97": "Teplý vzduch",
|
||||
"7f": "Časové sušení",
|
||||
"98": "Rychlé sušení 35",
|
||||
"eb": "Jemné prádlo",
|
||||
"b6": "Syntetika"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_03": {
|
||||
"name": "Cyklus",
|
||||
"state": {
|
||||
@@ -353,7 +395,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": {
|
||||
@@ -604,14 +664,51 @@
|
||||
"extra_hot": "Extra horká"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_00": {
|
||||
"name": "Cyklus",
|
||||
"state": {
|
||||
"01": "Normální",
|
||||
"70": "Intenzivní",
|
||||
"55": "Bílé prádlo",
|
||||
"71": "Ložní prádlo",
|
||||
"72": "Dezinfekce",
|
||||
"77": "Nežehlivé prádlo",
|
||||
"57": "Samočištění+",
|
||||
"73": "Máchání + odstřeďování",
|
||||
"74": "Sportovní oblečení",
|
||||
"75": "Jemné prádlo",
|
||||
"78": "Rychlé praní"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_02": {
|
||||
"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é",
|
||||
@@ -636,6 +733,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",
|
||||
@@ -677,7 +775,7 @@
|
||||
"8f": "Intenzivní studená",
|
||||
"96": "Méně mikrovláken",
|
||||
"a0": "15min rychlé praní",
|
||||
"35": "Eko bavlna"
|
||||
"b0": "Smíšená náplň"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
@@ -840,6 +938,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"
|
||||
},
|
||||
@@ -887,10 +991,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"
|
||||
@@ -905,7 +1015,10 @@
|
||||
"name": "Doba sušení"
|
||||
},
|
||||
"dryer_type": {
|
||||
"name": "Typ sušičky"
|
||||
"name": "Typ sušičky",
|
||||
"state": {
|
||||
"electricity": "Elektřina"
|
||||
}
|
||||
},
|
||||
"dust": {
|
||||
"name": "Prach"
|
||||
@@ -1034,7 +1147,23 @@
|
||||
"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",
|
||||
"sanitizing": "Dezinfekce"
|
||||
}
|
||||
},
|
||||
"progress_percentage": {
|
||||
"name": "Průběh v procentech"
|
||||
@@ -1241,6 +1370,18 @@
|
||||
"intensive": {
|
||||
"name": "Intenzivní"
|
||||
},
|
||||
"add_wash_alarm": {
|
||||
"name": "Alarm AddWash"
|
||||
},
|
||||
"add_wash_alarm_rinse": {
|
||||
"name": "AddWash při máchání"
|
||||
},
|
||||
"add_wash_alarm_final_rinse": {
|
||||
"name": "AddWash při posledním máchání"
|
||||
},
|
||||
"add_wash_alarm_spin": {
|
||||
"name": "AddWash při odstřeďování"
|
||||
},
|
||||
"lamp": {
|
||||
"name": "Lampa"
|
||||
},
|
||||
@@ -1413,11 +1554,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": {
|
||||
@@ -1448,14 +1590,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"
|
||||
@@ -1466,7 +1608,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“"
|
||||
@@ -1474,7 +1616,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"
|
||||
@@ -1491,7 +1633,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": {
|
||||
@@ -1501,7 +1643,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": {
|
||||
@@ -1526,6 +1668,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ů."
|
||||
},
|
||||
|
||||
@@ -103,11 +103,20 @@
|
||||
"softener_low": {
|
||||
"name": "Weichspüler niedrig"
|
||||
},
|
||||
"add_wash_available": {
|
||||
"name": "AddWash erlaubt"
|
||||
},
|
||||
"add_wash_indicator": {
|
||||
"name": "AddWash bereit"
|
||||
},
|
||||
"pouring": {
|
||||
"name": "Ausgabe läuft"
|
||||
},
|
||||
"remote_control": {
|
||||
"name": "Intelligente Steuerung"
|
||||
},
|
||||
"stick_ble_connected": {
|
||||
"name": "Stick per BLE verbunden"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
@@ -258,7 +267,11 @@
|
||||
"name": "Signalton",
|
||||
"state": {
|
||||
"off": "Aus",
|
||||
"on": "Ein"
|
||||
"on": "Ein",
|
||||
"volume_off": "Aus",
|
||||
"volume_low": "Niedrig",
|
||||
"volume_med": "Mittel",
|
||||
"volume_high": "Hoch"
|
||||
}
|
||||
},
|
||||
"discharging_time": {
|
||||
@@ -324,6 +337,35 @@
|
||||
"4": "Alarm 4"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_00": {
|
||||
"name": "Programm",
|
||||
"state": {
|
||||
"01": "Normal",
|
||||
"9c": "Intensiv",
|
||||
"a5": "Bettwäsche",
|
||||
"9e": "Pflegeleicht",
|
||||
"9b": "Dampf-Hygiene+",
|
||||
"27": "Auffrischen",
|
||||
"a0": "Lüften",
|
||||
"a4": "Zeittrocknen",
|
||||
"a6": "Schnelltrocknen",
|
||||
"a3": "Sportkleidung",
|
||||
"a2": "Feinwäsche",
|
||||
"9a": "Baumwolle",
|
||||
"ca": "Frischluft",
|
||||
"db": "Super Speed",
|
||||
"99": "Mischwäsche",
|
||||
"93": "Bügeltrocken",
|
||||
"b5": "Wolle",
|
||||
"d7": "Outdoor-Pflege",
|
||||
"96": "Kaltluft",
|
||||
"97": "Warmluft",
|
||||
"7f": "Zeittrocknen",
|
||||
"98": "Schnelltrocknen 35",
|
||||
"eb": "Feinwäsche",
|
||||
"b6": "Synthetik"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_03": {
|
||||
"name": "Programm",
|
||||
"state": {
|
||||
@@ -353,7 +395,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": {
|
||||
@@ -567,13 +627,51 @@
|
||||
"extra_hot": "Extra heiß"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_00": {
|
||||
"name": "Programm",
|
||||
"state": {
|
||||
"01": "Normal",
|
||||
"70": "Intensiv",
|
||||
"55": "Weißwäsche",
|
||||
"71": "Bettwäsche",
|
||||
"72": "Hygienespülung",
|
||||
"77": "Pflegeleicht",
|
||||
"57": "Selbstreinigung+",
|
||||
"73": "Spülen + Schleudern",
|
||||
"74": "Sportkleidung",
|
||||
"75": "Feinwäsche",
|
||||
"78": "Schnellwäsche"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_02": {
|
||||
"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",
|
||||
@@ -598,6 +696,7 @@
|
||||
"32": "Hemden",
|
||||
"33": "Handtücher",
|
||||
"34": "Mischwäsche",
|
||||
"35": "Eco Baumwolle",
|
||||
"36": "Waschen + Trocknen",
|
||||
"37": "Air Wash",
|
||||
"38": "Trocknen Baumwolle",
|
||||
@@ -612,16 +711,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",
|
||||
@@ -639,8 +728,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": {
|
||||
@@ -840,6 +938,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"
|
||||
},
|
||||
@@ -881,10 +985,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"
|
||||
@@ -899,7 +1009,10 @@
|
||||
"name": "Trockenzeit"
|
||||
},
|
||||
"dryer_type": {
|
||||
"name": "Trocknertyp"
|
||||
"name": "Trocknertyp",
|
||||
"state": {
|
||||
"electricity": "Strom"
|
||||
}
|
||||
},
|
||||
"dust": {
|
||||
"name": "Staub"
|
||||
@@ -1028,7 +1141,23 @@
|
||||
"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",
|
||||
"sanitizing": "Hygienespülung"
|
||||
}
|
||||
},
|
||||
"progress_percentage": {
|
||||
"name": "Fortschritt in Prozent"
|
||||
@@ -1235,6 +1364,18 @@
|
||||
"intensive": {
|
||||
"name": "Intensiv"
|
||||
},
|
||||
"add_wash_alarm": {
|
||||
"name": "AddWash-Hinweis"
|
||||
},
|
||||
"add_wash_alarm_rinse": {
|
||||
"name": "AddWash Spülen"
|
||||
},
|
||||
"add_wash_alarm_final_rinse": {
|
||||
"name": "AddWash letztes Spülen"
|
||||
},
|
||||
"add_wash_alarm_spin": {
|
||||
"name": "AddWash Schleudern"
|
||||
},
|
||||
"lamp": {
|
||||
"name": "Lampe"
|
||||
},
|
||||
@@ -1413,11 +1554,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": {
|
||||
@@ -1448,14 +1590,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"
|
||||
@@ -1466,7 +1608,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"
|
||||
@@ -1474,7 +1616,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"
|
||||
@@ -1491,7 +1633,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": {
|
||||
@@ -1501,7 +1643,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": {
|
||||
@@ -1526,6 +1668,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,15 @@
|
||||
},
|
||||
"softener_low": {
|
||||
"name": "Softener low"
|
||||
},
|
||||
"add_wash_available": {
|
||||
"name": "AddWash allowed"
|
||||
},
|
||||
"add_wash_indicator": {
|
||||
"name": "AddWash ready"
|
||||
},
|
||||
"stick_ble_connected": {
|
||||
"name": "Stick BLE connected"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
@@ -258,7 +267,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": {
|
||||
@@ -324,6 +337,35 @@
|
||||
"4": "Alarm 4"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_00": {
|
||||
"name": "Cycle",
|
||||
"state": {
|
||||
"01": "Normal",
|
||||
"9c": "Heavy Duty",
|
||||
"a5": "Bedding",
|
||||
"9e": "Perm Press",
|
||||
"9b": "Steam Sanitize+",
|
||||
"27": "Refresh",
|
||||
"a0": "Air Fluff",
|
||||
"a4": "Time Dry",
|
||||
"a6": "Quick Dry",
|
||||
"a3": "Active Wear",
|
||||
"a2": "Delicates",
|
||||
"9a": "Cotton",
|
||||
"ca": "Air Wash",
|
||||
"db": "Super Speed",
|
||||
"99": "Mixed Load",
|
||||
"93": "Iron Dry",
|
||||
"b5": "Wool",
|
||||
"d7": "Outdoor Care",
|
||||
"96": "Cool Air",
|
||||
"97": "Warm Air",
|
||||
"7f": "Time Dry",
|
||||
"98": "Quick Dry 35",
|
||||
"eb": "Delicates",
|
||||
"b6": "Synthetics"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_03": {
|
||||
"name": "Cycle",
|
||||
"state": {
|
||||
@@ -353,7 +395,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": {
|
||||
@@ -604,14 +664,51 @@
|
||||
"extra_hot": "Extra hot"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_00": {
|
||||
"name": "Cycle",
|
||||
"state": {
|
||||
"01": "Normal",
|
||||
"70": "Heavy Duty",
|
||||
"55": "Whites",
|
||||
"71": "Bedding",
|
||||
"72": "Sanitize",
|
||||
"77": "Perm Press",
|
||||
"57": "Self Clean+",
|
||||
"73": "Rinse + Spin",
|
||||
"74": "Active Wear",
|
||||
"75": "Delicates",
|
||||
"78": "Quick Wash"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_02": {
|
||||
"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",
|
||||
@@ -677,7 +774,8 @@
|
||||
"88": "Pet Care",
|
||||
"8f": "Intense Cold",
|
||||
"96": "Less Microfiber",
|
||||
"a0": "15' Quick Wash"
|
||||
"a0": "15' Quick Wash",
|
||||
"b0": "Mixed Load"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
@@ -840,6 +938,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"
|
||||
},
|
||||
@@ -887,10 +991,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"
|
||||
@@ -905,7 +1015,10 @@
|
||||
"name": "Dry time"
|
||||
},
|
||||
"dryer_type": {
|
||||
"name": "Dryer type"
|
||||
"name": "Dryer type",
|
||||
"state": {
|
||||
"electricity": "Electricity"
|
||||
}
|
||||
},
|
||||
"dust": {
|
||||
"name": "Dust"
|
||||
@@ -1034,7 +1147,23 @@
|
||||
"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",
|
||||
"sanitizing": "Sanitizing"
|
||||
}
|
||||
},
|
||||
"progress_percentage": {
|
||||
"name": "Progress percent"
|
||||
@@ -1241,6 +1370,18 @@
|
||||
"intensive": {
|
||||
"name": "Intensive"
|
||||
},
|
||||
"add_wash_alarm": {
|
||||
"name": "AddWash alarm"
|
||||
},
|
||||
"add_wash_alarm_rinse": {
|
||||
"name": "AddWash rinse"
|
||||
},
|
||||
"add_wash_alarm_final_rinse": {
|
||||
"name": "AddWash final rinse"
|
||||
},
|
||||
"add_wash_alarm_spin": {
|
||||
"name": "AddWash spin"
|
||||
},
|
||||
"lamp": {
|
||||
"name": "Lamp"
|
||||
},
|
||||
@@ -1413,11 +1554,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": {
|
||||
@@ -1448,14 +1590,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"
|
||||
@@ -1466,7 +1608,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"
|
||||
@@ -1474,7 +1616,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"
|
||||
@@ -1491,7 +1633,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": {
|
||||
@@ -1501,7 +1643,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": {
|
||||
@@ -1526,6 +1668,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."
|
||||
},
|
||||
@@ -299,8 +303,17 @@
|
||||
"softener_low": {
|
||||
"name": "Poco suavizante"
|
||||
},
|
||||
"add_wash_available": {
|
||||
"name": "AddWash permitido"
|
||||
},
|
||||
"add_wash_indicator": {
|
||||
"name": "AddWash disponible"
|
||||
},
|
||||
"auto_clean_running": {
|
||||
"name": "Limpieza automática en curso"
|
||||
},
|
||||
"stick_ble_connected": {
|
||||
"name": "Aspiradora conectada por BLE"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
@@ -451,7 +464,11 @@
|
||||
"name": "Volumen",
|
||||
"state": {
|
||||
"off": "Apagado",
|
||||
"on": "Encendido"
|
||||
"on": "Encendido",
|
||||
"volume_off": "Apagado",
|
||||
"volume_low": "Bajo",
|
||||
"volume_med": "Medio",
|
||||
"volume_high": "Alto"
|
||||
}
|
||||
},
|
||||
"discharging_time": {
|
||||
@@ -517,6 +534,35 @@
|
||||
"4": "Alarma 4"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_00": {
|
||||
"name": "Ciclo",
|
||||
"state": {
|
||||
"01": "Normal",
|
||||
"9c": "Servicio intensivo",
|
||||
"a5": "Ropa de cama",
|
||||
"9e": "Planchado fácil",
|
||||
"9b": "Desinfección por vapor+",
|
||||
"27": "Renovar",
|
||||
"a0": "Aireación",
|
||||
"a4": "Secado por tiempo",
|
||||
"a6": "Secado rápido",
|
||||
"a3": "Ropa deportiva",
|
||||
"a2": "Delicados",
|
||||
"9a": "Algodón",
|
||||
"ca": "Aireación",
|
||||
"db": "Súper velocidad",
|
||||
"99": "Carga mixta",
|
||||
"93": "Planchado fácil",
|
||||
"b5": "Lana",
|
||||
"d7": "Cuidado para exterior",
|
||||
"96": "Aire frío",
|
||||
"97": "Aire caliente",
|
||||
"7f": "Secado por tiempo",
|
||||
"98": "Secado rápido 35",
|
||||
"eb": "Delicados",
|
||||
"b6": "Sintéticos"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_03": {
|
||||
"name": "Ciclo",
|
||||
"state": {
|
||||
@@ -546,7 +592,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": {
|
||||
@@ -797,14 +861,51 @@
|
||||
"extra_hot": "Muy caliente"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_00": {
|
||||
"name": "Ciclo",
|
||||
"state": {
|
||||
"01": "Normal",
|
||||
"70": "Servicio intensivo",
|
||||
"55": "Blancos",
|
||||
"71": "Ropa de cama",
|
||||
"72": "Desinfección",
|
||||
"77": "Planchado fácil",
|
||||
"57": "Autolimpieza+",
|
||||
"73": "Aclarar + Centrifugar",
|
||||
"74": "Ropa deportiva",
|
||||
"75": "Delicados",
|
||||
"78": "Lavado rápido"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_02": {
|
||||
"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",
|
||||
@@ -829,6 +930,7 @@
|
||||
"32": "Camisas",
|
||||
"33": "Toallas",
|
||||
"34": "Mezcla",
|
||||
"35": "Algodón E",
|
||||
"36": "Lavado + Secado",
|
||||
"37": "Lavado con aire",
|
||||
"38": "Secado de algodón",
|
||||
@@ -843,15 +945,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",
|
||||
@@ -869,8 +962,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": {
|
||||
@@ -1030,6 +1132,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"
|
||||
},
|
||||
@@ -1077,10 +1185,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"
|
||||
@@ -1095,7 +1209,10 @@
|
||||
"name": "Tiempo de secado"
|
||||
},
|
||||
"dryer_type": {
|
||||
"name": "Tipo de secadora"
|
||||
"name": "Tipo de secadora",
|
||||
"state": {
|
||||
"electricity": "Electricidad"
|
||||
}
|
||||
},
|
||||
"dust": {
|
||||
"name": "Polvo"
|
||||
@@ -1224,7 +1341,23 @@
|
||||
"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",
|
||||
"sanitizing": "Desinfección"
|
||||
}
|
||||
},
|
||||
"progress_percentage": {
|
||||
"name": "Porcentaje de progreso"
|
||||
@@ -1434,6 +1567,18 @@
|
||||
"intensive": {
|
||||
"name": "Intensivo"
|
||||
},
|
||||
"add_wash_alarm": {
|
||||
"name": "Aviso AddWash"
|
||||
},
|
||||
"add_wash_alarm_rinse": {
|
||||
"name": "AddWash en aclarado"
|
||||
},
|
||||
"add_wash_alarm_final_rinse": {
|
||||
"name": "AddWash en último aclarado"
|
||||
},
|
||||
"add_wash_alarm_spin": {
|
||||
"name": "AddWash en centrifugado"
|
||||
},
|
||||
"lamp": {
|
||||
"name": "Lámpara"
|
||||
},
|
||||
|
||||
@@ -108,6 +108,15 @@
|
||||
},
|
||||
"softener_low": {
|
||||
"name": "Aggiungi ammorbidente"
|
||||
},
|
||||
"add_wash_available": {
|
||||
"name": "AddWash consentito"
|
||||
},
|
||||
"add_wash_indicator": {
|
||||
"name": "AddWash disponibile"
|
||||
},
|
||||
"stick_ble_connected": {
|
||||
"name": "Scopa elettrica connessa via BLE"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
@@ -258,7 +267,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": {
|
||||
@@ -324,6 +337,35 @@
|
||||
"4": "Allarme 4"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_00": {
|
||||
"name": "Ciclo",
|
||||
"state": {
|
||||
"01": "Normale",
|
||||
"9c": "Intenso",
|
||||
"a5": "Biancheria da letto",
|
||||
"9e": "Pronto da stirare",
|
||||
"9b": "Igienizzante a vapore+",
|
||||
"27": "Rinfresca",
|
||||
"a0": "Arieggiatura",
|
||||
"a4": "Asciugatura a tempo",
|
||||
"a6": "Asciugatura rapida",
|
||||
"a3": "Abbigliamento sportivo",
|
||||
"a2": "Delicati",
|
||||
"9a": "Cotone",
|
||||
"ca": "Arieggiatura",
|
||||
"db": "Super Speed",
|
||||
"99": "Carico misto",
|
||||
"93": "Pronto da stirare",
|
||||
"b5": "Lana",
|
||||
"d7": "Cura outdoor",
|
||||
"96": "Aria fredda",
|
||||
"97": "Aria calda",
|
||||
"7f": "Asciugatura a tempo",
|
||||
"98": "Asciugatura rapida 35",
|
||||
"eb": "Delicati",
|
||||
"b6": "Sintetici"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_03": {
|
||||
"name": "Ciclo",
|
||||
"state": {
|
||||
@@ -353,7 +395,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": {
|
||||
@@ -604,14 +664,51 @@
|
||||
"extra_hot": "Extra caldo"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_00": {
|
||||
"name": "Ciclo",
|
||||
"state": {
|
||||
"01": "Normale",
|
||||
"70": "Intenso",
|
||||
"55": "Bianchi",
|
||||
"71": "Biancheria da letto",
|
||||
"72": "Igienizzante",
|
||||
"77": "Pronto da stirare",
|
||||
"57": "Self Clean+",
|
||||
"73": "Risciacquo+Centrifuga",
|
||||
"74": "Abbigliamento sportivo",
|
||||
"75": "Delicati",
|
||||
"78": "Lavaggio rapido"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_02": {
|
||||
"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",
|
||||
@@ -636,6 +733,7 @@
|
||||
"32": "Camicie",
|
||||
"33": "Asciugamani",
|
||||
"34": "Misti",
|
||||
"35": "Cotone E",
|
||||
"36": "Lavaggio+Asciugatura",
|
||||
"37": "Lavaggio ad aria",
|
||||
"38": "Asciugatura cotone",
|
||||
@@ -650,14 +748,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",
|
||||
@@ -675,9 +765,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": {
|
||||
@@ -840,6 +938,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"
|
||||
},
|
||||
@@ -887,10 +991,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"
|
||||
@@ -905,7 +1015,10 @@
|
||||
"name": "Tempo di asciugatura"
|
||||
},
|
||||
"dryer_type": {
|
||||
"name": "Tipo di asciugatrice"
|
||||
"name": "Tipo di asciugatrice",
|
||||
"state": {
|
||||
"electricity": "Elettricità"
|
||||
}
|
||||
},
|
||||
"dust": {
|
||||
"name": "Polvere"
|
||||
@@ -1034,7 +1147,23 @@
|
||||
"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",
|
||||
"sanitizing": "Igienizzazione"
|
||||
}
|
||||
},
|
||||
"progress_percentage": {
|
||||
"name": "Avanzamento percentuale"
|
||||
@@ -1241,6 +1370,18 @@
|
||||
"intensive": {
|
||||
"name": "Intensivo"
|
||||
},
|
||||
"add_wash_alarm": {
|
||||
"name": "Avviso AddWash"
|
||||
},
|
||||
"add_wash_alarm_rinse": {
|
||||
"name": "AddWash al risciacquo"
|
||||
},
|
||||
"add_wash_alarm_final_rinse": {
|
||||
"name": "AddWash all'ultimo risciacquo"
|
||||
},
|
||||
"add_wash_alarm_spin": {
|
||||
"name": "AddWash alla centrifuga"
|
||||
},
|
||||
"lamp": {
|
||||
"name": "Lampada"
|
||||
},
|
||||
@@ -1413,11 +1554,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": {
|
||||
@@ -1448,14 +1590,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"
|
||||
@@ -1466,7 +1608,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"
|
||||
@@ -1474,7 +1616,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"
|
||||
@@ -1491,7 +1633,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": {
|
||||
@@ -1501,7 +1643,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": {
|
||||
@@ -1526,6 +1668,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,15 @@
|
||||
},
|
||||
"softener_low": {
|
||||
"name": "섬유유연제 부족"
|
||||
},
|
||||
"add_wash_available": {
|
||||
"name": "애드워시 허용"
|
||||
},
|
||||
"add_wash_indicator": {
|
||||
"name": "애드워시 가능"
|
||||
},
|
||||
"stick_ble_connected": {
|
||||
"name": "스틱 BLE 연결됨"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
@@ -258,7 +267,11 @@
|
||||
"name": "부저음",
|
||||
"state": {
|
||||
"off": "끄기",
|
||||
"on": "켜기"
|
||||
"on": "켜기",
|
||||
"volume_off": "끔",
|
||||
"volume_low": "낮음",
|
||||
"volume_med": "중간",
|
||||
"volume_high": "높음"
|
||||
}
|
||||
},
|
||||
"discharging_time": {
|
||||
@@ -324,6 +337,35 @@
|
||||
"4": "알림음 4"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_00": {
|
||||
"name": "코스",
|
||||
"state": {
|
||||
"01": "표준건조",
|
||||
"9c": "강력건조",
|
||||
"a5": "이불",
|
||||
"9e": "구김방지",
|
||||
"9b": "스팀살균+",
|
||||
"27": "리프레시",
|
||||
"a0": "송풍",
|
||||
"a4": "시간건조",
|
||||
"a6": "쾌속건조",
|
||||
"a3": "운동복",
|
||||
"a2": "섬세의류",
|
||||
"9a": "면의류",
|
||||
"ca": "송풍",
|
||||
"db": "쾌속건조",
|
||||
"99": "혼합",
|
||||
"93": "다림질건조",
|
||||
"b5": "울",
|
||||
"d7": "아웃도어케어",
|
||||
"96": "송풍건조",
|
||||
"97": "온풍건조",
|
||||
"7f": "시간건조",
|
||||
"98": "쾌속건조 35분",
|
||||
"eb": "섬세의류",
|
||||
"b6": "합성섬유"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_03": {
|
||||
"name": "코스",
|
||||
"state": {
|
||||
@@ -353,7 +395,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": {
|
||||
@@ -604,14 +664,51 @@
|
||||
"extra_hot": "고온수"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_00": {
|
||||
"name": "코스",
|
||||
"state": {
|
||||
"01": "표준세탁",
|
||||
"70": "강력세탁",
|
||||
"55": "흰옷",
|
||||
"71": "이불",
|
||||
"72": "살균",
|
||||
"77": "구김방지",
|
||||
"57": "통세척+",
|
||||
"73": "헹굼+탈수",
|
||||
"74": "운동복",
|
||||
"75": "섬세의류",
|
||||
"78": "쾌속세탁"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_02": {
|
||||
"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": "쾌속세탁",
|
||||
@@ -636,6 +733,7 @@
|
||||
"32": "셔츠",
|
||||
"33": "타월",
|
||||
"34": "혼합",
|
||||
"35": "에코 면",
|
||||
"36": "세탁+건조",
|
||||
"37": "에어워시",
|
||||
"38": "면 건조",
|
||||
@@ -650,15 +748,6 @@
|
||||
"60": "통세척+",
|
||||
"65": "컬러 의류",
|
||||
"66": "데님",
|
||||
"7c": "흰옷",
|
||||
"7d": "이불/방수 의류",
|
||||
"7e": "통세척",
|
||||
"7f": "울/섬세",
|
||||
"86": "찌든 때 세탁",
|
||||
"87": "다운로드",
|
||||
"8f": "강력 냉수 세탁",
|
||||
"96": "미세플라스틱저감",
|
||||
"a0": "15분 쾌속세탁",
|
||||
"69": "AI 맞춤세탁",
|
||||
"6a": "울",
|
||||
"6b": "데님",
|
||||
@@ -676,8 +765,17 @@
|
||||
"77": "면",
|
||||
"78": "헹굼+탈수",
|
||||
"79": "탈수만",
|
||||
"7c": "흰옷",
|
||||
"7d": "이불/방수 의류",
|
||||
"7e": "통세척",
|
||||
"7f": "울/섬세",
|
||||
"86": "찌든 때 세탁",
|
||||
"87": "다운로드",
|
||||
"88": "펫케어",
|
||||
"35": "에코 면"
|
||||
"8f": "강력 냉수 세탁",
|
||||
"96": "미세플라스틱저감",
|
||||
"a0": "15분 쾌속세탁",
|
||||
"b0": "혼합 세탁"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
@@ -840,6 +938,12 @@
|
||||
"stick_status": {
|
||||
"name": "스틱 청소기 상태"
|
||||
},
|
||||
"stick_operation_mode": {
|
||||
"name": "스틱 작동 모드"
|
||||
},
|
||||
"stick_cleaning_status": {
|
||||
"name": "스틱 청소 상태"
|
||||
},
|
||||
"uvc_operation_time": {
|
||||
"name": "UV-C 작동 시간"
|
||||
},
|
||||
@@ -887,10 +991,16 @@
|
||||
"name": "온도"
|
||||
},
|
||||
"diagnosis": {
|
||||
"name": "진단"
|
||||
"name": "진단",
|
||||
"state": {
|
||||
"ready": "준비됨"
|
||||
}
|
||||
},
|
||||
"diagnosis_status": {
|
||||
"name": "진단 상태"
|
||||
"name": "진단 상태",
|
||||
"state": {
|
||||
"ready": "준비됨"
|
||||
}
|
||||
},
|
||||
"drum_clean_cycles_remaining": {
|
||||
"name": "통세척까지 남은 횟수"
|
||||
@@ -905,7 +1015,10 @@
|
||||
"name": "건조 시간"
|
||||
},
|
||||
"dryer_type": {
|
||||
"name": "건조기 유형"
|
||||
"name": "건조기 유형",
|
||||
"state": {
|
||||
"electricity": "전기"
|
||||
}
|
||||
},
|
||||
"dust": {
|
||||
"name": "미세먼지"
|
||||
@@ -1034,7 +1147,23 @@
|
||||
"name": "탐침 온도계 현재 온도"
|
||||
},
|
||||
"progress": {
|
||||
"name": "진행률"
|
||||
"name": "진행률",
|
||||
"state": {
|
||||
"idle": "대기",
|
||||
"weightsensing": "세탁물 감지",
|
||||
"wash": "세탁",
|
||||
"rinse": "헹굼",
|
||||
"spin": "탈수",
|
||||
"finish": "완료",
|
||||
"steaming": "스팀",
|
||||
"airwashing": "에어워시",
|
||||
"drying": "건조",
|
||||
"dryingwithdooropen": "환기",
|
||||
"cooling": "냉각",
|
||||
"predrain": "사전 배수",
|
||||
"prewash": "애벌빨래",
|
||||
"sanitizing": "살균"
|
||||
}
|
||||
},
|
||||
"progress_percentage": {
|
||||
"name": "진행률"
|
||||
@@ -1241,6 +1370,18 @@
|
||||
"intensive": {
|
||||
"name": "강력"
|
||||
},
|
||||
"add_wash_alarm": {
|
||||
"name": "애드워시 알림"
|
||||
},
|
||||
"add_wash_alarm_rinse": {
|
||||
"name": "애드워시 헹굼"
|
||||
},
|
||||
"add_wash_alarm_final_rinse": {
|
||||
"name": "애드워시 마지막 헹굼"
|
||||
},
|
||||
"add_wash_alarm_spin": {
|
||||
"name": "애드워시 탈수"
|
||||
},
|
||||
"lamp": {
|
||||
"name": "램프"
|
||||
},
|
||||
@@ -1413,11 +1554,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": {
|
||||
@@ -1448,14 +1590,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": "이름 편집"
|
||||
@@ -1466,7 +1608,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": "다운로드 코스 코드"
|
||||
@@ -1474,7 +1616,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": "완료"
|
||||
@@ -1491,7 +1633,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": {
|
||||
@@ -1501,7 +1643,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": {
|
||||
@@ -1526,6 +1668,9 @@
|
||||
"command_failed": {
|
||||
"message": "재연결 후에도 {href} 명령이 실패했습니다: {error}"
|
||||
},
|
||||
"debug_read_failed": {
|
||||
"message": "{href}에서 읽기가 실패했습니다: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "1~10개의 쓰기를 지정하세요."
|
||||
},
|
||||
|
||||
@@ -108,6 +108,15 @@
|
||||
},
|
||||
"softener_low": {
|
||||
"name": "Wasverzachter bijna op"
|
||||
},
|
||||
"add_wash_available": {
|
||||
"name": "AddWash toegestaan"
|
||||
},
|
||||
"add_wash_indicator": {
|
||||
"name": "AddWash beschikbaar"
|
||||
},
|
||||
"stick_ble_connected": {
|
||||
"name": "Steel via BLE verbonden"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
@@ -258,7 +267,11 @@
|
||||
"name": "Zoemergeluid",
|
||||
"state": {
|
||||
"off": "Uit",
|
||||
"on": "Aan"
|
||||
"on": "Aan",
|
||||
"volume_off": "Uit",
|
||||
"volume_low": "Laag",
|
||||
"volume_med": "Gemiddeld",
|
||||
"volume_high": "Hoog"
|
||||
}
|
||||
},
|
||||
"discharging_time": {
|
||||
@@ -324,6 +337,35 @@
|
||||
"4": "Alarm 4"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_00": {
|
||||
"name": "Programma",
|
||||
"state": {
|
||||
"01": "Normaal",
|
||||
"9c": "Intensief",
|
||||
"a5": "Beddengoed",
|
||||
"9e": "Strijkvrij",
|
||||
"9b": "Stoomhygiëne+",
|
||||
"27": "Opfrissen",
|
||||
"a0": "Luchtdrogen",
|
||||
"a4": "Tijdprogramma",
|
||||
"a6": "Snel drogen",
|
||||
"a3": "Sportkleding",
|
||||
"a2": "Fijne was",
|
||||
"9a": "Katoen",
|
||||
"ca": "Luchtverfrissing",
|
||||
"db": "Super Speed",
|
||||
"99": "Gemengde was",
|
||||
"93": "Strijkdroog",
|
||||
"b5": "Wol",
|
||||
"d7": "Outdoorverzorging",
|
||||
"96": "Koude lucht",
|
||||
"97": "Warme lucht",
|
||||
"7f": "Tijdprogramma",
|
||||
"98": "Snel drogen 35",
|
||||
"eb": "Fijne was",
|
||||
"b6": "Synthetisch"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_03": {
|
||||
"name": "Programma",
|
||||
"state": {
|
||||
@@ -353,7 +395,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": {
|
||||
@@ -604,14 +664,51 @@
|
||||
"extra_hot": "Extra heet"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_00": {
|
||||
"name": "Programma",
|
||||
"state": {
|
||||
"01": "Normaal",
|
||||
"70": "Intensief",
|
||||
"55": "Witte was",
|
||||
"71": "Beddengoed",
|
||||
"72": "Hygiëne",
|
||||
"77": "Strijkvrij",
|
||||
"57": "Self Clean+",
|
||||
"73": "Spoelen + centrifugeren",
|
||||
"74": "Sportkleding",
|
||||
"75": "Fijne was",
|
||||
"78": "Snelle was"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_02": {
|
||||
"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",
|
||||
@@ -636,6 +733,7 @@
|
||||
"32": "Overhemden",
|
||||
"33": "Handdoeken",
|
||||
"34": "Gemengd",
|
||||
"35": "Eco katoen",
|
||||
"36": "Wassen+drogen",
|
||||
"37": "Air Wash",
|
||||
"38": "Katoen drogen",
|
||||
@@ -677,7 +775,7 @@
|
||||
"8f": "Intensief koud",
|
||||
"96": "Minder microvezels",
|
||||
"a0": "15' Snelle was",
|
||||
"35": "Eco katoen"
|
||||
"b0": "Gemengde was"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
@@ -840,6 +938,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"
|
||||
},
|
||||
@@ -887,10 +991,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"
|
||||
@@ -905,7 +1015,10 @@
|
||||
"name": "Droogtijd"
|
||||
},
|
||||
"dryer_type": {
|
||||
"name": "Type droger"
|
||||
"name": "Type droger",
|
||||
"state": {
|
||||
"electricity": "Elektriciteit"
|
||||
}
|
||||
},
|
||||
"dust": {
|
||||
"name": "Stof"
|
||||
@@ -1034,7 +1147,23 @@
|
||||
"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",
|
||||
"sanitizing": "Ontsmetten"
|
||||
}
|
||||
},
|
||||
"progress_percentage": {
|
||||
"name": "Voortgangspercentage"
|
||||
@@ -1241,6 +1370,18 @@
|
||||
"intensive": {
|
||||
"name": "Intensief"
|
||||
},
|
||||
"add_wash_alarm": {
|
||||
"name": "AddWash-melding"
|
||||
},
|
||||
"add_wash_alarm_rinse": {
|
||||
"name": "AddWash bij spoelen"
|
||||
},
|
||||
"add_wash_alarm_final_rinse": {
|
||||
"name": "AddWash laatste spoeling"
|
||||
},
|
||||
"add_wash_alarm_spin": {
|
||||
"name": "AddWash centrifugeren"
|
||||
},
|
||||
"lamp": {
|
||||
"name": "Lamp"
|
||||
},
|
||||
@@ -1413,11 +1554,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": {
|
||||
@@ -1448,14 +1590,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"
|
||||
@@ -1466,7 +1608,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\""
|
||||
@@ -1474,7 +1616,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"
|
||||
@@ -1491,7 +1633,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": {
|
||||
@@ -1501,7 +1643,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": {
|
||||
@@ -1526,6 +1668,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,213 @@
|
||||
# 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 a cycle costs while the appliance stays dark (issue #269)
|
||||
|
||||
An appliance switched off at the wall isn't a one-cycle blip: it fails the
|
||||
same way every 30s for hours, and both halves of that failure were being paid
|
||||
twice.
|
||||
|
||||
`_poll_once` opens the session itself when there isn't one, so a switched-off
|
||||
appliance fails *in the handshake* — 12s (`DtlsCoapSession.HANDSHAKE_TIMEOUT_S`)
|
||||
with nothing to show for it. The poll path then treated that like any other
|
||||
poll failure and ran its reconnect: close the session, pause
|
||||
`_RECONNECT_PAUSE_S`, poll again. There is no session to close and no
|
||||
association for the device to clean up, so the "reconnect" was the identical
|
||||
handshake five seconds later — 29s of the 30s interval spent proving the
|
||||
appliance is off, twice over, and the same again on every `SETUP_RETRY`
|
||||
attempt for an entry with no snapshot to load from. `_handshake_failed` marks
|
||||
that case in `_poll_once` so the poll path can skip the retry; a session that
|
||||
opened and *then* broke still reconnects within the cycle.
|
||||
|
||||
The log was the half the reporters actually saw: `poll failed after
|
||||
reconnect` at ERROR every cycle, plus a `reconnect_is_frequent` WARNING once
|
||||
three piled up, for a state this integration is specifically built to sit
|
||||
through. Issue #269's reporter read that repetition as the integration having
|
||||
failed. It's one ERROR per outage now, DEBUG for the cycles after it, and one
|
||||
INFO when the device answers again — HA's own coordinator already logs the
|
||||
transition into and out of a failed update.
|
||||
|
||||
## 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
|
||||
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000",
|
||||
"x.com.samsung.da.countryCode": "US\u0001"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/course/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"HOMECARE_WIZARD_V2"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"DeviceType_0165",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"Course_9A",
|
||||
"MixedLoadBell_Disable",
|
||||
"MixedLoadBellNoti_Nothing",
|
||||
"LaundryOutTime_0",
|
||||
"SeamlessControl_Disable",
|
||||
"KidsLockBypass_On",
|
||||
"DetergentOnce_0",
|
||||
"DetergentLeft_0",
|
||||
"DetergentBase_0",
|
||||
"DetergentAlarm_Off",
|
||||
"DetergentType_0",
|
||||
"DetergentTotal_0",
|
||||
"SpecialFunction_4",
|
||||
"AvailableDelayTime_188",
|
||||
"LaundryPlannerUserSetTime_0",
|
||||
"ProgressTimeSet_B22D00B4003C",
|
||||
"WrinklePreventRunning_Off",
|
||||
"EnergyLevelSet_050502050503020304010204030203",
|
||||
"MostUsed_9AD20EE000",
|
||||
"MixedLoadBellSet_02FFFF02FFFFFFFFFFFFFFFFFF02",
|
||||
"UsagesDB_ok",
|
||||
"EnergyKW_396",
|
||||
"TimeSync_NotSupported"
|
||||
],
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"29AD20EE000CAD10EE000DBD204E00099D20EE00093D102E000B5D102E000D7D204E000A5D204E00096D000E10E97D000E10E7FD000E33E98D000E000EBD204E000B6D20EE000"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/cycleinterface/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.instantaneousPower": "-500",
|
||||
"x.com.samsung.da.cumulativePower": "692600",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.cumulativeDate": "1786964400",
|
||||
"x.com.samsung.da.cumulativeDateUTC": "1786960800"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+01:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000",
|
||||
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON_DV6800N/DC92-01967B_0404",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02198A230708(E257)",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "17111305,17122616",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.kidsLock": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/0",
|
||||
"rep": {
|
||||
"currentMachineState": "**REDACTED**",
|
||||
"machineStates": "**REDACTED**",
|
||||
"jobStates": [
|
||||
"None",
|
||||
"Drying",
|
||||
"Cooling",
|
||||
"Finish"
|
||||
],
|
||||
"currentJobState": "None",
|
||||
"remainingTime": "03:08:00",
|
||||
"progressPercentage": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.state": "Ready",
|
||||
"x.com.samsung.da.remainingTime": "03:08:00",
|
||||
"x.com.samsung.da.progressPercentage": "1",
|
||||
"x.com.samsung.da.progress": "None",
|
||||
"x.com.samsung.da.delayEndTime": "00:00:00",
|
||||
"x.com.samsung.da.supportedProgress": [
|
||||
"None",
|
||||
"Drying",
|
||||
"Cooling",
|
||||
"Finish"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.remoteControlEnabled": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/setting/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/st/dryercourse/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.st.dryerMode": "Table_00_Course_9A",
|
||||
"x.com.samsung.da.st.courseTable": "Table_00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/washer/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.wrinklePrevent": "Off",
|
||||
"x.com.samsung.da.dryLevel": "2",
|
||||
"x.com.samsung.da.supportedDryLevel": [
|
||||
"None",
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"x.com.samsung.da.dryTime": "00:00:00",
|
||||
"x.com.samsung.da.supportedDryTime": [
|
||||
"00:00:00",
|
||||
"00:30:00",
|
||||
"01:00:00",
|
||||
"01:30:00",
|
||||
"02:00:00",
|
||||
"02:30:00"
|
||||
],
|
||||
"x.com.samsung.da.dryerType": "Electricity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wm/editcourse/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/wm/jobbeginingstatus/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/wm/setinfo/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.isModelSettingWithoutSC": "false",
|
||||
"x.com.samsung.da.isModelSettingPowerOnOff": "false"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+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",
|
||||
|
||||
+2
@@ -8,6 +8,7 @@
|
||||
"cycle_active",
|
||||
"delay_start_hours",
|
||||
"diagnosis_status",
|
||||
"drum_clean_cycles_remaining",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"finish_time",
|
||||
@@ -40,6 +41,7 @@
|
||||
"samsung_dishwasher_delay_start_hours",
|
||||
"samsung_dishwasher_diagnosis_start",
|
||||
"samsung_dishwasher_diagnosis_status",
|
||||
"samsung_dishwasher_drum_clean_cycles_remaining",
|
||||
"samsung_dishwasher_energy_kwh",
|
||||
"samsung_dishwasher_energy_saved_kwh",
|
||||
"samsung_dishwasher_finish_time",
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"child_lock",
|
||||
"completion_minutes",
|
||||
"cycle",
|
||||
"cycle_active",
|
||||
"delay_start_hours",
|
||||
"diagnosis",
|
||||
"dry_level",
|
||||
"dry_time",
|
||||
"dryer_type",
|
||||
"energy_kwh",
|
||||
"finish_time",
|
||||
"job_beginning_status",
|
||||
"machine_state",
|
||||
"power_switch",
|
||||
"progress",
|
||||
"progress_percentage",
|
||||
"remote_control",
|
||||
"wrinkle_prevent"
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"add_wash_alarm",
|
||||
"add_wash_alarm_final_rinse",
|
||||
"add_wash_alarm_rinse",
|
||||
"add_wash_alarm_spin",
|
||||
"add_wash_available",
|
||||
"add_wash_indicator",
|
||||
"alarm_code",
|
||||
"bubble_soak",
|
||||
"child_lock",
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"add_wash_alarm",
|
||||
"add_wash_alarm_final_rinse",
|
||||
"add_wash_alarm_rinse",
|
||||
"add_wash_alarm_spin",
|
||||
"add_wash_available",
|
||||
"add_wash_indicator",
|
||||
"alarm_code",
|
||||
"child_lock",
|
||||
"completion_minutes",
|
||||
"cycle",
|
||||
"cycle_active",
|
||||
"delay_start_hours",
|
||||
"diagnosis_status",
|
||||
"energy_kwh",
|
||||
"finish_time",
|
||||
"job_beginning_status",
|
||||
"machine_state",
|
||||
"power_switch",
|
||||
"progress",
|
||||
"progress_percentage",
|
||||
"remote_control",
|
||||
"rinse_cycles",
|
||||
"spin_speed",
|
||||
"wash_temperature"
|
||||
]
|
||||
}
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
{
|
||||
"device0": [
|
||||
{},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.instantaneousPower": "-500",
|
||||
"x.com.samsung.da.cumulativePower": "2016700",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.cumulativeDate": "1787050800",
|
||||
"x.com.samsung.da.cumulativeDateUTC": "1787050800"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/course/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.options": [
|
||||
"DeviceType_0167",
|
||||
"Course_5C",
|
||||
"LaundryOutTime_0",
|
||||
"AddWashSet_0",
|
||||
"AddWashAvailable_7",
|
||||
"AddWashIndicator_Off",
|
||||
"QuickWash_Not_Used",
|
||||
"QuickWashSet_5B847E933FA53F",
|
||||
"UsagesDB_ok",
|
||||
"EnergyKW_396",
|
||||
"DrumCleanLog_Empty",
|
||||
"TimeSync_NotSupported"
|
||||
],
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"35B847E933FA53F5C841E923FA53F5D8102923FA43F66841E930FA30F5E831E920FA2075F867E943FA53F60831E930FA43F61841E943FA43F6385209204A204648000913FA53F6B80009000A53E65841E920FA30F67843E923FA43F688430923FA53F"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/cycleinterface/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.kidsLock": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.state": "Ready",
|
||||
"x.com.samsung.da.remainingTime": "01:07:00",
|
||||
"x.com.samsung.da.progressPercentage": "1",
|
||||
"x.com.samsung.da.progress": "None",
|
||||
"x.com.samsung.da.supportedProgress": [
|
||||
"None",
|
||||
"Wash",
|
||||
"Rinse",
|
||||
"Spin",
|
||||
"Finish"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/0",
|
||||
"rep": {
|
||||
"currentMachineState": "idle",
|
||||
"machineStates": [
|
||||
"pause",
|
||||
"active",
|
||||
"idle"
|
||||
],
|
||||
"jobStates": [
|
||||
"None",
|
||||
"Wash",
|
||||
"Rinse",
|
||||
"Spin",
|
||||
"Finish"
|
||||
],
|
||||
"currentJobState": "None",
|
||||
"remainingTime": "01:07:00",
|
||||
"progressPercentage": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "DA_WM_A51_20_COMMON|FFFFFFFF|20010102001011070000000000000000",
|
||||
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON_WW6500",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON|FFFFFFFF|20010102001011070000000000000000",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02198A230708(E257)",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "Unknown",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+00:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/washer/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.waterTemperature": "30",
|
||||
"x.com.samsung.da.supportedWaterTemperature": [
|
||||
"None",
|
||||
"Cold",
|
||||
"20",
|
||||
"30",
|
||||
"40",
|
||||
"60",
|
||||
"95"
|
||||
],
|
||||
"x.com.samsung.da.spinLevel": "1400",
|
||||
"x.com.samsung.da.supportedSpinLevel": [
|
||||
"RinseHold",
|
||||
"NoSpin",
|
||||
"400",
|
||||
"800",
|
||||
"1200",
|
||||
"1400"
|
||||
],
|
||||
"x.com.samsung.da.rinseCycles": "3",
|
||||
"x.com.samsung.da.supportedRinseCycles": [
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/st/washercourse/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.st.washerMode": "Table_00_Course_5C",
|
||||
"x.com.samsung.da.st.courseTable": "Table_00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/setting/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/wm/editcourse/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/wm/setinfo/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.isModelSettingWithoutSC": "false",
|
||||
"x.com.samsung.da.isModelSettingPowerOnOff": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wm/jobbeginingstatus/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.remoteControlEnabled": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/0",
|
||||
"rep": {
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000",
|
||||
"x.com.samsung.da.countryCode": "CZ"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -14,6 +14,7 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
from custom_components.localthings.const import (
|
||||
CONF_CA_CERT_PEM,
|
||||
CONF_CA_KEY_PEM,
|
||||
CONF_DEVICE_KEY,
|
||||
CONF_DEVICE_TYPE,
|
||||
CONF_HOST,
|
||||
CONF_LEAF_CERT_PEM,
|
||||
@@ -82,6 +83,10 @@ MOCK_PORT = 49154
|
||||
# what mock_coordinator_session polls -- so an entry built from ENTRY_DATA and
|
||||
# the device it "reaches" agree on who they are, the same as in production.
|
||||
MOCK_SERIAL = "TEST-SERIAL-0000"
|
||||
# The OCF device UUID (/oic/d's `di`) the probe resolves the entry's key from
|
||||
# (issue #381). Distinct from MOCK_SERIAL so a test that confuses the two
|
||||
# fails rather than passing by coincidence.
|
||||
MOCK_DEVICE_KEY = "7b1f0c9e-2a44-4d6b-9f10-4c8e2b5a0d31"
|
||||
MOCK_MODEL = "TEST-MODEL"
|
||||
MOCK_DEVICE_TYPE = "refrigerator"
|
||||
MOCK_CA_CERT_PEM = "-----BEGIN CERTIFICATE-----\nTEST-CA\n-----END CERTIFICATE-----"
|
||||
@@ -98,6 +103,7 @@ ENTRY_DATA = {
|
||||
CONF_LEAF_KEY_PEM: MOCK_LEAF_KEY_PEM,
|
||||
# Identity the config flow's probe resolved (issue #236) -- what the
|
||||
# coordinator keys its devices and entities on from construction.
|
||||
CONF_DEVICE_KEY: MOCK_DEVICE_KEY,
|
||||
CONF_SERIAL: MOCK_SERIAL,
|
||||
CONF_MODEL: MOCK_MODEL,
|
||||
CONF_MANUFACTURER: "Samsung",
|
||||
@@ -131,6 +137,7 @@ def fridge_resources():
|
||||
def _probe_result(*, recognized: bool) -> dict:
|
||||
return {
|
||||
"port": MOCK_PORT,
|
||||
"device_key": MOCK_DEVICE_KEY,
|
||||
"serial": MOCK_SERIAL,
|
||||
"model": MOCK_MODEL,
|
||||
"manufacturer": "Samsung",
|
||||
@@ -244,8 +251,8 @@ def mock_entry(hass):
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data=ENTRY_DATA,
|
||||
unique_id=f"localthings_{MOCK_SERIAL}",
|
||||
version=2,
|
||||
unique_id=f"localthings_{MOCK_DEVICE_KEY}",
|
||||
version=4,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
return entry
|
||||
|
||||
@@ -15,11 +15,14 @@ from custom_components.localthings.const import (
|
||||
CONF_BYPASS_REMOTE_CONTROL,
|
||||
CONF_CA_CERT_PEM,
|
||||
CONF_CA_KEY_PEM,
|
||||
CONF_CLOUD_COURSES_ENABLED,
|
||||
CONF_DEVICE_KEY,
|
||||
CONF_HOST,
|
||||
CONF_LEAF_CERT_PEM,
|
||||
CONF_LEARN_MODES,
|
||||
CONF_LEARNED_MODES,
|
||||
CONF_PORT,
|
||||
CONF_SERIAL,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
@@ -27,11 +30,13 @@ from .conftest import (
|
||||
ENTRY_DATA,
|
||||
MOCK_CA_CERT_PEM,
|
||||
MOCK_CA_KEY_PEM,
|
||||
MOCK_DEVICE_KEY,
|
||||
MOCK_HOST,
|
||||
MOCK_LEAF_CERT_PEM,
|
||||
MOCK_MODEL,
|
||||
MOCK_PORT,
|
||||
MOCK_SERIAL,
|
||||
_probe_result,
|
||||
)
|
||||
|
||||
|
||||
@@ -238,6 +243,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 +255,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 +282,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 +495,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 +640,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."""
|
||||
@@ -879,7 +1051,11 @@ async def test_unknown_type_step_description_makes_no_version_claim(
|
||||
|
||||
|
||||
async def test_duplicate_device_aborted(hass: HomeAssistant, mock_probe) -> None:
|
||||
"""Second add of same serial: flow aborts.
|
||||
"""Second add of the same *device key*: flow aborts.
|
||||
|
||||
Keyed on the OCF device UUID rather than the serialNum (issue #381), so
|
||||
this is now the check that two genuinely distinct units can no longer
|
||||
trip -- see test_same_serial_on_two_units_is_not_a_duplicate.
|
||||
|
||||
When a device already exists the form only asks for host (CA creds are
|
||||
reused), so we only submit CONF_HOST in the second configure call.
|
||||
@@ -887,7 +1063,7 @@ async def test_duplicate_device_aborted(hass: HomeAssistant, mock_probe) -> None
|
||||
existing = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data=ENTRY_DATA,
|
||||
unique_id=f"localthings_{MOCK_SERIAL}",
|
||||
unique_id=f"localthings_{MOCK_DEVICE_KEY}",
|
||||
)
|
||||
existing.add_to_hass(hass)
|
||||
|
||||
@@ -901,6 +1077,154 @@ async def test_duplicate_device_aborted(hass: HomeAssistant, mock_probe) -> None
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_same_serial_on_two_units_is_not_a_duplicate(hass: HomeAssistant, mock_probe) -> None:
|
||||
"""Issue #381: two Samsung air purifiers of one model ship the identical,
|
||||
well-formed serialNum, so keying the entry on it turned the second one
|
||||
away as already configured. The OCF device UUID differs between them,
|
||||
and it is what the entry is keyed on now, so both can be added.
|
||||
|
||||
Deliberately holds the serial *constant* across the two probes and varies
|
||||
only the device key -- the exact shape of the bug report.
|
||||
"""
|
||||
first = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={**ENTRY_DATA, CONF_HOST: "192.168.0.3"},
|
||||
unique_id=f"localthings_{MOCK_DEVICE_KEY}",
|
||||
)
|
||||
first.add_to_hass(hass)
|
||||
|
||||
second_probe = {
|
||||
**_probe_result(recognized=True),
|
||||
"device_key": "3771f8bf-c184-3a2d-d885-e4c9818736d2",
|
||||
"serial": MOCK_SERIAL,
|
||||
}
|
||||
with patch(
|
||||
"custom_components.localthings.config_flow._probe_and_validate",
|
||||
return_value=second_probe,
|
||||
):
|
||||
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: "192.168.0.14"}
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result["data"][CONF_DEVICE_KEY] == "3771f8bf-c184-3a2d-d885-e4c9818736d2"
|
||||
# Both entries exist, and the shared serial is still recorded on each --
|
||||
# it is what corroborates a later change of key.
|
||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 2
|
||||
assert result["data"][CONF_SERIAL] == first.data[CONF_SERIAL]
|
||||
|
||||
|
||||
async def test_re_adding_during_the_migration_window_is_still_a_duplicate(
|
||||
hass: HomeAssistant, mock_probe
|
||||
) -> None:
|
||||
"""An entry created before v4 keeps its serial-keyed unique_id until its
|
||||
first *live* poll adopts the UUID, which can be a long while for an
|
||||
appliance that is off (it loads from its snapshot meanwhile, issue
|
||||
#295). The UUID check can't see such an entry, so without a second
|
||||
check on the legacy key, re-adding this very appliance in that window
|
||||
would be waved through -- and the two entries would collide the moment
|
||||
the older one re-keyed, with rekey_entry resolving the collision by
|
||||
deleting the duplicate rows and taking the original's entity_ids,
|
||||
history and automations with them.
|
||||
"""
|
||||
existing = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={k: v for k, v in ENTRY_DATA.items() if k != CONF_DEVICE_KEY},
|
||||
unique_id=f"localthings_{MOCK_SERIAL}",
|
||||
version=3,
|
||||
)
|
||||
existing.add_to_hass(hass)
|
||||
|
||||
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: ENTRY_DATA[CONF_HOST]}
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_the_migration_window_check_still_separates_two_same_serial_units(
|
||||
hass: HomeAssistant, mock_probe
|
||||
) -> None:
|
||||
"""The legacy-key check above matches on the host as well as the serial,
|
||||
so it cannot undo the fix: issue #381's two units share a serial but sit
|
||||
at different addresses, and the second must still be addable while the
|
||||
first is mid-migration."""
|
||||
existing = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
**{k: v for k, v in ENTRY_DATA.items() if k != CONF_DEVICE_KEY},
|
||||
CONF_HOST: "192.168.0.3",
|
||||
},
|
||||
unique_id=f"localthings_{MOCK_SERIAL}",
|
||||
version=3,
|
||||
)
|
||||
existing.add_to_hass(hass)
|
||||
|
||||
second_probe = {
|
||||
**_probe_result(recognized=True),
|
||||
"device_key": "3771f8bf-c184-3a2d-d885-e4c9818736d2",
|
||||
"serial": MOCK_SERIAL,
|
||||
}
|
||||
with patch(
|
||||
"custom_components.localthings.config_flow._probe_and_validate",
|
||||
return_value=second_probe,
|
||||
):
|
||||
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: "192.168.0.14"}
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result["data"][CONF_DEVICE_KEY] == "3771f8bf-c184-3a2d-d885-e4c9818736d2"
|
||||
|
||||
|
||||
def test_probe_reads_the_device_key_from_oic_d_without_an_extra_round_trip(monkeypatch):
|
||||
"""`_read_device` already fetches /oic/p and /oic/d for the device-type
|
||||
signal, so keying on the OCF UUID costs no additional GET -- it reads
|
||||
the identity that call already returned."""
|
||||
from custom_components.localthings.config_flow import _read_device
|
||||
|
||||
device0 = [
|
||||
{"rt": ["x.com.samsung.devcol"]},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "AVT-WW-TP1-23-AXX500|10251941",
|
||||
"x.com.samsung.da.serialNum": "BS7SP9AW400114A",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
class _Session:
|
||||
def __init__(self):
|
||||
self.paths = []
|
||||
|
||||
def get(self, path, timeout=10.0):
|
||||
self.paths.append(tuple(path))
|
||||
table = {
|
||||
("oic", "p"): {"mnmn": "Samsung Electronics", "pi": "PLATFORM-UUID"},
|
||||
("oic", "d"): {"di": "CCFD73B3-AEB4-792A-1100-68F06F5D603B"},
|
||||
("device", "0"): device0,
|
||||
}
|
||||
body = table.get(tuple(path))
|
||||
if body is None:
|
||||
return 0x84, b""
|
||||
import cbor2
|
||||
|
||||
return 0x45, cbor2.dumps(body)
|
||||
|
||||
sess = _Session()
|
||||
info = _read_device(sess, "192.168.0.3", MOCK_PORT)
|
||||
|
||||
assert info["device_key"] == "ccfd73b3-aeb4-792a-1100-68f06f5d603b"
|
||||
assert info["serial"] == "BS7SP9AW400114A"
|
||||
# Exactly the three reads the probe already made before this change.
|
||||
assert sess.paths == [("oic", "p"), ("oic", "d"), ("oic", "res"), ("device", "0")]
|
||||
|
||||
|
||||
def test_probe_marks_washer_as_recognized(monkeypatch):
|
||||
"""A washer reports no oneUiVersion at all -- its consumer-model code
|
||||
must still resolve so setup doesn't warn about an unrecognized type."""
|
||||
@@ -1013,6 +1337,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"),
|
||||
[
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock, patch
|
||||
@@ -13,6 +15,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,
|
||||
@@ -32,7 +35,13 @@ from custom_components.localthings.registry.capabilities.common import (
|
||||
remote_control_required_for_write,
|
||||
)
|
||||
|
||||
from .conftest import ENTRY_DATA, MOCK_MODEL, MOCK_SERIAL, FakeObserveSession
|
||||
from .conftest import (
|
||||
ENTRY_DATA,
|
||||
MOCK_DEVICE_KEY,
|
||||
MOCK_MODEL,
|
||||
MOCK_SERIAL,
|
||||
FakeObserveSession,
|
||||
)
|
||||
from .conftest import _load_fridge_resources as _load_fridge
|
||||
|
||||
|
||||
@@ -70,7 +79,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"),
|
||||
@@ -144,7 +158,7 @@ def test_run_discovery_falls_back_to_host_for_placeholder_serial(
|
||||
) -> None:
|
||||
"""Issue #83: the ARTIK051_DONGLE_REF firmware family reports the
|
||||
literal string 'Nothing(SVC)' as serialNum on every unit. Left as-is,
|
||||
two such units get the same device_serial (which feeds both the HA
|
||||
two such units get the same device_key (which feeds both the HA
|
||||
device-registry identifier and every entity's unique_id), so the
|
||||
second one's entities silently collide and get dropped. It must be
|
||||
treated the same as an empty serial and fall back to the host."""
|
||||
@@ -158,7 +172,7 @@ def test_run_discovery_falls_back_to_host_for_placeholder_serial(
|
||||
}
|
||||
coordinator = LocalThingsCoordinator(hass, legacy_entry)
|
||||
coordinator._run_discovery(resources)
|
||||
assert coordinator.device_serial == legacy_entry.data[CONF_HOST]
|
||||
assert coordinator.device_key == legacy_entry.data[CONF_HOST]
|
||||
|
||||
|
||||
def test_run_discovery_falls_back_to_host_for_all_f_placeholder_serial(
|
||||
@@ -169,7 +183,7 @@ def test_run_discovery_falls_back_to_host_for_all_f_placeholder_serial(
|
||||
character the same repeated hex digit. A washer and a dryer, two
|
||||
different physical units, both reported the literal serialNum
|
||||
'FFFFFFFFFFFFFFF', so without this fallback they'd collide on
|
||||
device_serial exactly like the #83 case above."""
|
||||
device_key exactly like the #83 case above."""
|
||||
resources = {
|
||||
"/information/vs/0": {
|
||||
"x.com.samsung.da.modelNum": "DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000", # noqa: E501
|
||||
@@ -180,7 +194,7 @@ def test_run_discovery_falls_back_to_host_for_all_f_placeholder_serial(
|
||||
}
|
||||
coordinator = LocalThingsCoordinator(hass, legacy_entry)
|
||||
coordinator._run_discovery(resources)
|
||||
assert coordinator.device_serial == legacy_entry.data[CONF_HOST]
|
||||
assert coordinator.device_key == legacy_entry.data[CONF_HOST]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -192,7 +206,7 @@ def test_identity_is_resolved_before_any_poll(hass: HomeAssistant, mock_entry) -
|
||||
"""The coordinator mints registry keys from the entry's stored identity at
|
||||
construction time.
|
||||
|
||||
`device_serial` is what entity unique_ids and device identifiers are built
|
||||
`device_key` is what entity unique_ids and device identifiers are built
|
||||
from, and those are permanent. Seeding it with the host meant anything that
|
||||
registered before the first poll returned -- the connection-mode sensor
|
||||
especially, added unconditionally rather than from `bound` -- was written
|
||||
@@ -201,8 +215,8 @@ def test_identity_is_resolved_before_any_poll(hass: HomeAssistant, mock_entry) -
|
||||
"""
|
||||
coordinator = LocalThingsCoordinator(hass, mock_entry)
|
||||
|
||||
assert coordinator.device_serial == MOCK_SERIAL
|
||||
assert coordinator.device_info["identifiers"] == {(DOMAIN, MOCK_SERIAL)}
|
||||
assert coordinator.device_key == MOCK_DEVICE_KEY
|
||||
assert coordinator.device_info["identifiers"] == {(DOMAIN, MOCK_DEVICE_KEY)}
|
||||
assert coordinator.device_info["model"] == MOCK_MODEL
|
||||
assert coordinator.device_info["name"] == f"Samsung Refrigerator ({MOCK_MODEL})"
|
||||
assert mock_entry.data[CONF_HOST] not in str(coordinator.device_info["identifiers"])
|
||||
@@ -233,7 +247,7 @@ def test_discovery_keeps_the_registered_identity(hass: HomeAssistant, mock_entry
|
||||
coordinator = LocalThingsCoordinator(hass, mock_entry)
|
||||
coordinator._run_discovery(resources)
|
||||
|
||||
assert coordinator.device_serial == MOCK_SERIAL
|
||||
assert coordinator.device_key == MOCK_DEVICE_KEY
|
||||
|
||||
|
||||
def test_discovery_backfills_a_legacy_entry_identity(hass: HomeAssistant, legacy_entry) -> None:
|
||||
@@ -531,6 +545,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 +830,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:
|
||||
@@ -976,6 +1052,110 @@ async def test_sweep_mismatch_forces_subpolls_on_a_live_observe_session(
|
||||
mock_subpolls.assert_called_once_with(force=True)
|
||||
|
||||
|
||||
async def _cancel_background_subpolls(coordinator: LocalThingsCoordinator) -> None:
|
||||
"""Setup starts `_run_subpolls` as a background task. Tests that drive
|
||||
it directly have to cancel that one first, or a patched
|
||||
`_poll_hrefs_blocking` also captures its batches."""
|
||||
task = coordinator._subpoll_task
|
||||
if task is None:
|
||||
return
|
||||
task.cancel()
|
||||
coordinator._subpoll_task = None
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
async def test_observe_mode_subpolls_only_silent_hrefs(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""Issue #92: subscribed-but-silent hrefs keep the hot/warm cadence
|
||||
instead of waiting for the 30s sweep."""
|
||||
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]
|
||||
await _cancel_background_subpolls(coordinator)
|
||||
assert coordinator._hot_hrefs
|
||||
silent = coordinator._hot_hrefs[0]
|
||||
coordinator._observe.mode = MODE_OBSERVE
|
||||
coordinator._observe.fallback_hrefs = {silent}
|
||||
|
||||
polled: list[list[str]] = []
|
||||
|
||||
def _capture(hrefs):
|
||||
polled.append(list(hrefs))
|
||||
|
||||
with (
|
||||
patch.object(coordinator, "_poll_hrefs_blocking", side_effect=_capture),
|
||||
patch(
|
||||
"custom_components.localthings.coordinator.asyncio.sleep",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
):
|
||||
await coordinator._run_subpolls()
|
||||
|
||||
assert polled
|
||||
for batch in polled:
|
||||
assert set(batch) == {silent}
|
||||
|
||||
|
||||
async def test_observe_mode_skips_subpolls_when_nothing_is_silent(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""The observe-mode no-op stays in place when every subscribed href
|
||||
actually notified -- issue #92 only keeps the silent ones on poll."""
|
||||
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]
|
||||
await _cancel_background_subpolls(coordinator)
|
||||
coordinator._observe.mode = MODE_OBSERVE
|
||||
coordinator._observe.fallback_hrefs = set()
|
||||
|
||||
with (
|
||||
patch.object(coordinator, "_poll_hrefs_blocking") as mock_poll,
|
||||
patch(
|
||||
"custom_components.localthings.coordinator.asyncio.sleep",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_sleep,
|
||||
):
|
||||
await coordinator._run_subpolls()
|
||||
|
||||
mock_poll.assert_not_called()
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
|
||||
async def test_observe_mode_skips_empty_subpoll_slots(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""Once hot is empty, odd slots would otherwise take the session lock
|
||||
and dispatch a no-op executor job. Skip those."""
|
||||
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]
|
||||
await _cancel_background_subpolls(coordinator)
|
||||
coordinator._observe.mode = MODE_OBSERVE
|
||||
coordinator._hot_hrefs = []
|
||||
coordinator._warm_hrefs = ["/warm/vs/0"]
|
||||
coordinator._observe.fallback_hrefs = {"/warm/vs/0"}
|
||||
|
||||
polled: list[list[str]] = []
|
||||
|
||||
def _capture(hrefs):
|
||||
polled.append(list(hrefs))
|
||||
|
||||
with (
|
||||
patch.object(coordinator, "_poll_hrefs_blocking", side_effect=_capture),
|
||||
patch(
|
||||
"custom_components.localthings.coordinator.asyncio.sleep",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
):
|
||||
await coordinator._run_subpolls()
|
||||
|
||||
assert polled
|
||||
for batch in polled:
|
||||
assert batch == ["/warm/vs/0"]
|
||||
|
||||
|
||||
async def test_write_marks_href_pending_before_post(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
@@ -1761,10 +1941,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(
|
||||
|
||||
@@ -39,7 +39,7 @@ async def test_stale_device_can_be_removed(
|
||||
stale = _device(
|
||||
hass,
|
||||
mock_entry,
|
||||
{(DOMAIN, f"{coordinator.device_serial}_1")},
|
||||
{(DOMAIN, f"{coordinator.device_key}_1")},
|
||||
)
|
||||
|
||||
assert await async_remove_config_entry_device(hass, mock_entry, stale) is True
|
||||
|
||||
@@ -76,8 +76,11 @@ async def test_diagnostics_include_ocf_identity(
|
||||
# fields identify a device type, so nothing is dropped up front beyond
|
||||
# what redaction takes out.
|
||||
assert identity["resources"]["/oic/p"]["mnmn"] == "Samsung Electronics"
|
||||
assert identity["resources"]["/oic/d"]["di"] == REDACTED
|
||||
assert identity["resources"]["/oic/p"]["pi"] == REDACTED
|
||||
# The OCF UUIDs are reported rather than redacted: they are what this
|
||||
# entry is keyed on (issue #381), and blanking them is what made the
|
||||
# first duplicate-serial report unanswerable.
|
||||
assert identity["resources"]["/oic/d"]["di"] == "ab-cd-ef"
|
||||
assert identity["resources"]["/oic/p"]["pi"] == "12-34-56"
|
||||
# The owner-settable device name is redacted; `rt` -- the reason this
|
||||
# block exists -- is not.
|
||||
assert identity["resources"]["/oic/d"]["n"] == REDACTED
|
||||
@@ -125,9 +128,9 @@ async def test_diagnostics_include_oic_res_links(
|
||||
|
||||
links = diag["identity"]["resources"]["/oic/res"]
|
||||
assert len(links) == 2
|
||||
assert links[0]["di"] == REDACTED
|
||||
assert links[0]["di"] == "aaaa-1111"
|
||||
assert links[0]["href"] == "/device/0"
|
||||
assert links[1]["di"] == REDACTED
|
||||
assert links[1]["di"] == "bbbb-2222"
|
||||
assert links[1]["href"] == "/device/1"
|
||||
assert links[1]["rt"] == ["x.com.samsung.devcol", "oic.wk.col"]
|
||||
|
||||
|
||||
@@ -0,0 +1,773 @@
|
||||
"""Moving an existing install onto the OCF device UUID (issue #381).
|
||||
|
||||
This migration can't finish inside `async_migrate_entry` -- the UUID is
|
||||
only readable from the appliance, and an entry can load from its snapshot
|
||||
while that appliance is off (issue #295) -- so the coordinator adopts it
|
||||
on the first live poll, rewriting both registries and the entry's
|
||||
unique_id together.
|
||||
|
||||
That makes it the riskiest migration here: it rewrites the identity of
|
||||
rows a user's automations, history and areas hang off. These tests are
|
||||
organised around what must not break rather than around the functions
|
||||
involved. Statistics are covered separately, against a real recorder, in
|
||||
tests/test_rekey_statistics_end_to_end.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.localthings.const import (
|
||||
CONF_DEVICE_KEY,
|
||||
CONF_HOST,
|
||||
CONF_SERIAL,
|
||||
DOMAIN,
|
||||
)
|
||||
from custom_components.localthings.registry.identity import DeviceIdentity
|
||||
|
||||
from .conftest import LEGACY_ENTRY_DATA, MOCK_HOST, MOCK_SERIAL
|
||||
|
||||
_COORD = "custom_components.localthings.coordinator.LocalThingsCoordinator"
|
||||
|
||||
# The two purifiers from issue #381: one serialNum, two device UUIDs.
|
||||
SHARED_SERIAL = "BS7SP9AW400114A"
|
||||
UUID_A = "ccfd73b3-aeb4-792a-1100-68f06f5d603b"
|
||||
UUID_B = "3771f8bf-c184-3a2d-d885-e4c9818736d2"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _reachable(resources: dict, device_id: str | None):
|
||||
"""A device that answers a poll, reporting `device_id` as its /oic/d
|
||||
`di` -- which `_connect_session` is what normally reads, so a test that
|
||||
patches it out otherwise leaves `_identity` None (indistinguishable
|
||||
from firmware that reports no UUID at all)."""
|
||||
|
||||
def _connect(self) -> None:
|
||||
self._identity = (
|
||||
None
|
||||
if device_id is None
|
||||
else DeviceIdentity(
|
||||
manufacturer="Samsung Electronics",
|
||||
model="AVT-WW-TP1-23-AXX500",
|
||||
name="Samsung AirPurifier",
|
||||
serial=None,
|
||||
device_id=device_id,
|
||||
)
|
||||
)
|
||||
|
||||
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 _entry(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
version: int,
|
||||
key: str,
|
||||
serial: str | None = None,
|
||||
device_key: str | None = None,
|
||||
host: str = MOCK_HOST,
|
||||
) -> MockConfigEntry:
|
||||
"""An entry as it sits on disk at `version`. Pre-v4 entries carry no
|
||||
CONF_DEVICE_KEY at all -- that absence is what tells the coordinator it
|
||||
is looking at an entry that has never adopted a UUID."""
|
||||
data = {**LEGACY_ENTRY_DATA, CONF_HOST: host}
|
||||
if version >= 2:
|
||||
data[CONF_SERIAL] = serial if serial is not None else key
|
||||
if device_key is not None:
|
||||
data[CONF_DEVICE_KEY] = device_key
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data=data,
|
||||
unique_id=f"{DOMAIN}_{key}",
|
||||
version=version,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
return entry
|
||||
|
||||
|
||||
def _reporting_serial(resources: dict, serial: str) -> dict:
|
||||
"""`resources` with the serialNum the device reports swapped out, so a
|
||||
test can pair a fixture with the identity its scenario implies."""
|
||||
info = dict(resources["/information/vs/0"])
|
||||
info["x.com.samsung.da.serialNum"] = serial
|
||||
return {**resources, "/information/vs/0": info}
|
||||
|
||||
|
||||
def _device_identifiers(hass: HomeAssistant, device_id: str) -> set[tuple[str, str]]:
|
||||
"""This device row's identifiers, asserting the row still exists.
|
||||
|
||||
`dev_reg.async_get` returns `DeviceEntry | None`, so reading through it
|
||||
directly would crash with an AttributeError on a row the re-key
|
||||
wrongly removed instead of failing the assertion that says so.
|
||||
"""
|
||||
row = dr.async_get(hass).async_get(device_id)
|
||||
assert row is not None
|
||||
return row.identifiers
|
||||
|
||||
|
||||
def _entity_unique_id(hass: HomeAssistant, entity_id: str) -> str:
|
||||
"""This entity row's unique_id, asserting the row still exists."""
|
||||
row = er.async_get(hass).async_get(entity_id)
|
||||
assert row is not None
|
||||
return row.unique_id
|
||||
|
||||
|
||||
def _seed_registry(hass: HomeAssistant, entry: MockConfigEntry, key: str, **entity_kwargs):
|
||||
"""A device and one entity keyed on `key`, as a running install has."""
|
||||
dev_reg = dr.async_get(hass)
|
||||
ent_reg = er.async_get(hass)
|
||||
device = dev_reg.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, key)},
|
||||
name=f"Samsung Air Purifier ({key})",
|
||||
)
|
||||
entity = ent_reg.async_get_or_create(
|
||||
"sensor",
|
||||
DOMAIN,
|
||||
f"{DOMAIN}_{key}_connection_mode",
|
||||
config_entry=entry,
|
||||
device_id=device.id,
|
||||
**entity_kwargs,
|
||||
)
|
||||
return device, entity
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The upgrade itself
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_v3_entry_moves_onto_the_device_uuid_keeping_its_entity_ids(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""The migration promise for an existing user, asserted end to end: all
|
||||
three permanent places move together, and the entity_id doesn't."""
|
||||
entry = _entry(hass, version=3, key=MOCK_SERIAL)
|
||||
device, existing = _seed_registry(
|
||||
hass, entry, MOCK_SERIAL, suggested_object_id="kitchen_purifier_connection"
|
||||
)
|
||||
|
||||
with _reachable(fridge_resources, UUID_A):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.version == 4
|
||||
assert entry.data[CONF_DEVICE_KEY] == UUID_A
|
||||
# The serial is kept alongside the key, not replaced by it: it is what
|
||||
# corroborates a later change of UUID.
|
||||
assert entry.data[CONF_SERIAL] == MOCK_SERIAL
|
||||
assert entry.unique_id == f"{DOMAIN}_{UUID_A}"
|
||||
|
||||
rekeyed_device = dr.async_get(hass).async_get(device.id)
|
||||
assert rekeyed_device is not None
|
||||
assert rekeyed_device.identifiers == {(DOMAIN, UUID_A)}
|
||||
kept = er.async_get(hass).async_get(existing.entity_id)
|
||||
assert kept is not None
|
||||
assert kept.entity_id == "sensor.kitchen_purifier_connection"
|
||||
assert kept.unique_id == f"{DOMAIN}_{UUID_A}_connection_mode"
|
||||
# Nothing left behind on the old key.
|
||||
assert dr.async_get(hass).async_get_device(identifiers={(DOMAIN, MOCK_SERIAL)}) is None
|
||||
|
||||
|
||||
async def test_the_oldest_install_walks_all_the_way_from_v1(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""A v1 entry -- no stored identity at all, from before issue #236 --
|
||||
walks v1 -> v2 -> v3 -> v4 and then adopts the UUID on its first poll.
|
||||
|
||||
The oldest installs take the longest path, and each step rewrites what
|
||||
the next one reads, so the chain is worth pinning as one journey rather
|
||||
than trusting the individual steps to compose.
|
||||
"""
|
||||
entry = _entry(hass, version=1, key=MOCK_SERIAL)
|
||||
device, existing = _seed_registry(
|
||||
hass, entry, MOCK_SERIAL, suggested_object_id="old_install_connection"
|
||||
)
|
||||
|
||||
with _reachable(fridge_resources, UUID_A):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.version == 4
|
||||
assert entry.data[CONF_DEVICE_KEY] == UUID_A
|
||||
assert entry.unique_id == f"{DOMAIN}_{UUID_A}"
|
||||
kept = er.async_get(hass).async_get(existing.entity_id)
|
||||
assert kept is not None
|
||||
assert kept.entity_id == "sensor.old_install_connection"
|
||||
assert kept.unique_id == f"{DOMAIN}_{UUID_A}_connection_mode"
|
||||
assert _device_identifiers(hass, device.id) == {(DOMAIN, UUID_A)}
|
||||
|
||||
|
||||
async def test_a_host_keyed_entry_adopts_a_real_identity(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""A placeholder-serial board (issues #83/#189) was keyed on its IP,
|
||||
which is an address rather than an identity -- a new DHCP lease silently
|
||||
makes it someone else's. Such an entry never made an identity claim to
|
||||
defend, so a real UUID is adopted without needing the serial to
|
||||
corroborate it; requiring corroboration would strand exactly these
|
||||
boards, since their serial resolves to the host and can never match."""
|
||||
entry = _entry(hass, version=3, key=MOCK_HOST)
|
||||
device, _ = _seed_registry(hass, entry, MOCK_HOST)
|
||||
|
||||
with _reachable(fridge_resources, UUID_B):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.data[CONF_DEVICE_KEY] == UUID_B
|
||||
assert _device_identifiers(hass, device.id) == {(DOMAIN, UUID_B)}
|
||||
|
||||
|
||||
async def test_the_two_units_from_the_issue_migrate_to_separate_identities(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""Issue #381's actual install: two entries whose stored identity is the
|
||||
same shared serial. Before this, they could not both exist. Migrating
|
||||
them must give each its own key rather than collapsing them again --
|
||||
including their entity unique_ids, which is where issue #83's Bug 4
|
||||
silently dropped the second unit's entities even once its entry existed.
|
||||
"""
|
||||
# Both units report the shared serial, as the real ones do -- so the
|
||||
# entry's stored identity still matches what the device says, and only
|
||||
# the UUID separates them.
|
||||
resources = _reporting_serial(fridge_resources, SHARED_SERIAL)
|
||||
first = _entry(hass, version=3, key=SHARED_SERIAL, host="192.168.0.3")
|
||||
_, first_entity = _seed_registry(hass, first, SHARED_SERIAL, suggested_object_id="purifier_a")
|
||||
|
||||
with _reachable(resources, UUID_A):
|
||||
await hass.config_entries.async_setup(first.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Added only now: setting up the first entry loads the integration, which
|
||||
# brings up every entry already registered -- so a second one created up
|
||||
# front would come up inside the first one's patched identity and adopt
|
||||
# its UUID, which is the collision this test exists to disprove.
|
||||
second = _entry(hass, version=3, key=SHARED_SERIAL, host="192.168.0.14")
|
||||
_, second_entity = _seed_registry(hass, second, SHARED_SERIAL, suggested_object_id="purifier_b")
|
||||
|
||||
with _reachable(resources, UUID_B):
|
||||
await hass.config_entries.async_setup(second.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert first.data[CONF_DEVICE_KEY] == UUID_A
|
||||
assert second.data[CONF_DEVICE_KEY] == UUID_B
|
||||
assert first.unique_id != second.unique_id
|
||||
|
||||
assert _entity_unique_id(hass, first_entity.entity_id) == (f"{DOMAIN}_{UUID_A}_connection_mode")
|
||||
assert _entity_unique_id(hass, second_entity.entity_id) == (
|
||||
f"{DOMAIN}_{UUID_B}_connection_mode"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# What the user must not lose
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_every_user_customization_on_the_row_survives(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""Re-keying rewrites the registry row in place rather than replacing
|
||||
it, which is the whole reason to do it this way -- so everything the
|
||||
user attached to that row rides along. A rename, an area, an icon
|
||||
override and a deliberate hide are each things they would have to redo
|
||||
by hand if the row were recreated instead.
|
||||
"""
|
||||
entry = _entry(hass, version=3, key=MOCK_SERIAL)
|
||||
ent_reg = er.async_get(hass)
|
||||
device, existing = _seed_registry(
|
||||
hass, entry, MOCK_SERIAL, suggested_object_id="kitchen_purifier_connection"
|
||||
)
|
||||
dr.async_get(hass).async_update_device(device.id, area_id="kitchen")
|
||||
ent_reg.async_update_entity(
|
||||
existing.entity_id,
|
||||
name="Purifier link",
|
||||
icon="mdi:air-filter",
|
||||
area_id="kitchen",
|
||||
hidden_by=er.RegistryEntryHider.USER,
|
||||
)
|
||||
|
||||
with _reachable(fridge_resources, UUID_A):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
kept = ent_reg.async_get(existing.entity_id)
|
||||
assert kept is not None
|
||||
assert kept.unique_id == f"{DOMAIN}_{UUID_A}_connection_mode"
|
||||
assert kept.name == "Purifier link"
|
||||
assert kept.icon == "mdi:air-filter"
|
||||
assert kept.area_id == "kitchen"
|
||||
assert kept.hidden_by is er.RegistryEntryHider.USER
|
||||
rekeyed_device = dr.async_get(hass).async_get(device.id)
|
||||
assert rekeyed_device is not None
|
||||
assert rekeyed_device.area_id == "kitchen"
|
||||
|
||||
|
||||
async def test_a_composite_appliance_keeps_its_subdevice_links(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""A composite appliance (issue #177) registers one device per logical
|
||||
subdevice, keyed f"{key}_{subdevice}" and linked via_device to the
|
||||
master's bare key. Rewriting only the exact-match identifier would
|
||||
strand every sibling under a via_device pointing at a device that no
|
||||
longer exists, collapsing the user's device tree."""
|
||||
entry = _entry(hass, version=3, key=MOCK_SERIAL)
|
||||
dev_reg = dr.async_get(hass)
|
||||
master, _ = _seed_registry(hass, entry, MOCK_SERIAL)
|
||||
sub = dev_reg.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, f"{MOCK_SERIAL}_subdevice_1")},
|
||||
via_device=(DOMAIN, MOCK_SERIAL),
|
||||
)
|
||||
assert sub.via_device_id == master.id
|
||||
|
||||
with _reachable(fridge_resources, UUID_A):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert _device_identifiers(hass, master.id) == {(DOMAIN, UUID_A)}
|
||||
rekeyed_sub = dev_reg.async_get(sub.id)
|
||||
assert rekeyed_sub is not None
|
||||
assert rekeyed_sub.identifiers == {(DOMAIN, f"{UUID_A}_subdevice_1")}
|
||||
# Still the same parent row, so the device tree the user sees is intact.
|
||||
assert rekeyed_sub.via_device_id == master.id
|
||||
|
||||
|
||||
async def test_a_re_key_never_touches_another_entrys_rows(hass: HomeAssistant) -> None:
|
||||
"""The rewrite is scoped to one config entry's own registry rows.
|
||||
|
||||
Issue #381's install is the case that makes this sharp: *both* entries
|
||||
are keyed on the same shared serial, so both have registry rows under
|
||||
the identical old key, and they migrate one at a time. An unscoped
|
||||
rewrite -- matching on the key prefix alone -- would sweep up the other
|
||||
appliance's device and entities and hand them to the first one to
|
||||
migrate, which is the worst outcome this change could have.
|
||||
|
||||
The two entries hold *different* entities under that one shared prefix
|
||||
(HA's registry won't let two rows share a unique_id, which is issue
|
||||
#83's Bug 4 in the first place), so only the config-entry scoping can
|
||||
tell them apart -- prefix matching alone cannot.
|
||||
|
||||
Calls rekey_entry directly so the scoping is what's under test, rather
|
||||
than the coordinator's decision about whether to call it at all.
|
||||
"""
|
||||
from custom_components.localthings.rekey import rekey_entry
|
||||
|
||||
migrating = _entry(hass, version=3, key=SHARED_SERIAL, host="192.168.0.3")
|
||||
bystander = _entry(hass, version=3, key=SHARED_SERIAL, host="192.168.0.14")
|
||||
ent_reg = er.async_get(hass)
|
||||
moved = ent_reg.async_get_or_create(
|
||||
"sensor", DOMAIN, f"{DOMAIN}_{SHARED_SERIAL}_connection_mode", config_entry=migrating
|
||||
)
|
||||
stays = ent_reg.async_get_or_create(
|
||||
"sensor", DOMAIN, f"{DOMAIN}_{SHARED_SERIAL}_power", config_entry=bystander
|
||||
)
|
||||
|
||||
rekey_entry(hass, migrating, SHARED_SERIAL, UUID_A)
|
||||
|
||||
assert _entity_unique_id(hass, moved.entity_id) == f"{DOMAIN}_{UUID_A}_connection_mode"
|
||||
# The other appliance is still on the shared serial, waiting its turn.
|
||||
assert _entity_unique_id(hass, stays.entity_id) == f"{DOMAIN}_{SHARED_SERIAL}_power"
|
||||
assert bystander.unique_id == f"{DOMAIN}_{SHARED_SERIAL}"
|
||||
|
||||
|
||||
async def test_a_re_key_stops_at_the_key_boundary(hass: HomeAssistant) -> None:
|
||||
"""Matching is on the whole key or the key plus a separator, never a
|
||||
bare prefix. Serial numbers of one model are routinely prefixes of each
|
||||
other, so a naive startswith would drag a *different* appliance's rows
|
||||
along -- and the identifiers it would rewrite them to are nonsense."""
|
||||
from custom_components.localthings.rekey import rekey_entry
|
||||
|
||||
entry = _entry(hass, version=3, key="TEST-SERIAL")
|
||||
dev_reg = dr.async_get(hass)
|
||||
ent_reg = er.async_get(hass)
|
||||
target = dev_reg.async_get_or_create(
|
||||
config_entry_id=entry.entry_id, identifiers={(DOMAIN, "TEST-SERIAL")}
|
||||
)
|
||||
# Same config entry, so scoping can't save this one -- only the boundary.
|
||||
neighbour = dev_reg.async_get_or_create(
|
||||
config_entry_id=entry.entry_id, identifiers={(DOMAIN, "TEST-SERIAL-0000")}
|
||||
)
|
||||
neighbour_entity = ent_reg.async_get_or_create(
|
||||
"sensor", DOMAIN, f"{DOMAIN}_TEST-SERIAL-0000_connection_mode", config_entry=entry
|
||||
)
|
||||
|
||||
rekey_entry(hass, entry, "TEST-SERIAL", UUID_A)
|
||||
|
||||
assert _device_identifiers(hass, target.id) == {(DOMAIN, UUID_A)}
|
||||
assert _device_identifiers(hass, neighbour.id) == {(DOMAIN, "TEST-SERIAL-0000")}
|
||||
assert _entity_unique_id(hass, neighbour_entity.entity_id) == (
|
||||
f"{DOMAIN}_TEST-SERIAL-0000_connection_mode"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stability: upgrading twice, or offline, must not churn
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_upgrading_while_the_appliance_is_off_changes_nothing(
|
||||
hass: HomeAssistant, fridge_resources, hass_storage
|
||||
) -> None:
|
||||
"""The case a large share of users will actually hit: HA restarts onto
|
||||
the new release while the appliance is unplugged or asleep.
|
||||
|
||||
The entry comes up from its snapshot (issue #295), which never reached
|
||||
the device -- so it has no standing to claim an identity. It must load
|
||||
under the key its registry rows already carry and write nothing, or the
|
||||
real UUID would later look like a *changed* identity to defend against
|
||||
rather than the one-time adoption it is.
|
||||
"""
|
||||
entry = _entry(hass, version=3, key=MOCK_SERIAL)
|
||||
_, existing = _seed_registry(hass, entry, MOCK_SERIAL)
|
||||
|
||||
# Bank a snapshot from a run on the old release, then take it down.
|
||||
with _reachable(fridge_resources, None):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert entry.data[CONF_DEVICE_KEY] == MOCK_SERIAL
|
||||
|
||||
# Now the appliance is off. Simulate the pre-v4 shape the upgrade finds.
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, data={k: v for k, v in entry.data.items() if k != CONF_DEVICE_KEY}, version=3
|
||||
)
|
||||
|
||||
with _unreachable():
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
assert coordinator.device_key == MOCK_SERIAL
|
||||
# No key claimed, and the registry is exactly as it was.
|
||||
assert CONF_DEVICE_KEY not in entry.data
|
||||
assert entry.unique_id == f"{DOMAIN}_{MOCK_SERIAL}"
|
||||
assert _entity_unique_id(hass, existing.entity_id) == (
|
||||
f"{DOMAIN}_{MOCK_SERIAL}_connection_mode"
|
||||
)
|
||||
|
||||
|
||||
async def test_an_offline_load_never_rewrites_the_registry(
|
||||
hass: HomeAssistant, fridge_resources, hass_storage
|
||||
) -> None:
|
||||
"""A snapshot replay must not re-key on the strength of what the
|
||||
snapshot says, because that is last run's answer rather than the
|
||||
device's.
|
||||
|
||||
Modelled on a placeholder-serial board (issues #83/#189), which is
|
||||
where the two can genuinely disagree: the entry is keyed on its address
|
||||
because the board reports no usable serial, while the snapshot banked
|
||||
whatever serial the polled resources carried. A replay that trusted the
|
||||
snapshot would rewrite every registry row onto that serial -- without
|
||||
the appliance having been reachable at any point -- and would then
|
||||
freeze the answer into CONF_DEVICE_KEY, so the real UUID could never be
|
||||
adopted afterwards.
|
||||
"""
|
||||
entry = _entry(hass, version=3, key=MOCK_HOST)
|
||||
|
||||
with _reachable(fridge_resources, None):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Back to the pre-v4 shape the upgrade finds, still keyed on the address.
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data={
|
||||
**{k: v for k, v in entry.data.items() if k != CONF_DEVICE_KEY},
|
||||
CONF_SERIAL: MOCK_HOST,
|
||||
},
|
||||
unique_id=f"{DOMAIN}_{MOCK_HOST}",
|
||||
version=3,
|
||||
)
|
||||
device, existing = _seed_registry(hass, entry, MOCK_HOST)
|
||||
|
||||
with _unreachable():
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
assert coordinator.device_key == MOCK_HOST
|
||||
assert CONF_DEVICE_KEY not in entry.data
|
||||
assert entry.unique_id == f"{DOMAIN}_{MOCK_HOST}"
|
||||
assert _device_identifiers(hass, device.id) == {(DOMAIN, MOCK_HOST)}
|
||||
assert _entity_unique_id(hass, existing.entity_id) == (f"{DOMAIN}_{MOCK_HOST}_connection_mode")
|
||||
|
||||
# And the deferred adoption still works once the appliance answers --
|
||||
# the offline load left nothing frozen behind it.
|
||||
with _reachable(fridge_resources, UUID_A):
|
||||
coordinator._connect_session()
|
||||
coordinator._run_discovery(fridge_resources)
|
||||
|
||||
assert coordinator.device_key == UUID_A
|
||||
assert entry.data[CONF_DEVICE_KEY] == UUID_A
|
||||
assert _entity_unique_id(hass, existing.entity_id) == (f"{DOMAIN}_{UUID_A}_connection_mode")
|
||||
|
||||
|
||||
async def test_the_appliance_coming_back_completes_the_upgrade(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""The other half of the offline case: the deferred adoption is not
|
||||
abandoned, it just waits. Once the device answers, the same re-key runs
|
||||
and the entry finishes its upgrade."""
|
||||
entry = _entry(hass, version=3, key=MOCK_SERIAL)
|
||||
_, existing = _seed_registry(hass, entry, MOCK_SERIAL)
|
||||
|
||||
with _reachable(fridge_resources, None):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert entry.data[CONF_DEVICE_KEY] == MOCK_SERIAL
|
||||
|
||||
# The device is reachable again, now reporting its UUID.
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
with _reachable(fridge_resources, UUID_A):
|
||||
coordinator._connect_session()
|
||||
coordinator._run_discovery(fridge_resources)
|
||||
|
||||
assert coordinator.device_key == UUID_A
|
||||
assert entry.data[CONF_DEVICE_KEY] == UUID_A
|
||||
assert entry.unique_id == f"{DOMAIN}_{UUID_A}"
|
||||
assert _entity_unique_id(hass, existing.entity_id) == (f"{DOMAIN}_{UUID_A}_connection_mode")
|
||||
|
||||
|
||||
async def test_restarting_after_the_upgrade_is_a_no_op(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""Every restart re-runs discovery, so the adoption path runs again on
|
||||
an entry that has already moved. It must recognise its own work and do
|
||||
nothing -- a re-key that fired every boot would churn the registry
|
||||
forever."""
|
||||
entry = _entry(hass, version=3, key=MOCK_SERIAL)
|
||||
_, existing = _seed_registry(hass, entry, MOCK_SERIAL)
|
||||
|
||||
with _reachable(fridge_resources, UUID_A):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
after_first = dict(entry.data)
|
||||
await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert dict(entry.data) == after_first
|
||||
assert entry.unique_id == f"{DOMAIN}_{UUID_A}"
|
||||
kept = er.async_get(hass).async_get(existing.entity_id)
|
||||
assert kept is not None
|
||||
assert kept.unique_id == f"{DOMAIN}_{UUID_A}_connection_mode"
|
||||
# Exactly one device, not a duplicate alongside it.
|
||||
assert len(dr.async_entries_for_config_entry(dr.async_get(hass), entry.entry_id)) == 1
|
||||
|
||||
|
||||
async def test_an_already_migrated_entry_is_left_alone(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""A v4 entry created by the current config flow has nothing to migrate
|
||||
and nothing to re-key -- it was minted on its UUID."""
|
||||
entry = _entry(hass, version=4, key=UUID_A, serial=MOCK_SERIAL, device_key=UUID_A)
|
||||
device, existing = _seed_registry(hass, entry, UUID_A)
|
||||
|
||||
with _reachable(fridge_resources, UUID_A):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.version == 4
|
||||
assert entry.data[CONF_DEVICE_KEY] == UUID_A
|
||||
assert _device_identifiers(hass, device.id) == {(DOMAIN, UUID_A)}
|
||||
assert _entity_unique_id(hass, existing.entity_id) == (f"{DOMAIN}_{UUID_A}_connection_mode")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Guarding the identity once it has moved
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_a_poll_that_reads_no_uuid_does_not_demote_a_keyed_entry(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""The device saying nothing is not the device saying something
|
||||
different. A reconnect that can't read /oic/d (a timeout, a firmware
|
||||
hiccup) must leave the key alone -- demoting back onto the serial would
|
||||
re-key every entity the user has for the duration of an outage, and
|
||||
re-key them all back afterwards."""
|
||||
entry = _entry(hass, version=4, key=UUID_A, serial=MOCK_SERIAL, device_key=UUID_A)
|
||||
|
||||
with _reachable(fridge_resources, None):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
assert coordinator.device_key == UUID_A
|
||||
assert entry.data[CONF_DEVICE_KEY] == UUID_A
|
||||
|
||||
|
||||
async def test_a_rotated_uuid_on_the_same_serial_is_followed(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""OCF permits a hard factory reset to regenerate `di`. The serialNum is
|
||||
what tells that apart from a different appliance moving onto the
|
||||
address, and following it keeps the user's history rather than stranding
|
||||
it on a UUID the device will never report again."""
|
||||
entry = _entry(hass, version=4, key=UUID_A, serial=MOCK_SERIAL, device_key=UUID_A)
|
||||
device, existing = _seed_registry(hass, entry, UUID_A)
|
||||
|
||||
# fridge_resources reports MOCK_SERIAL, matching what the entry stored.
|
||||
with _reachable(fridge_resources, UUID_B):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.data[CONF_DEVICE_KEY] == UUID_B
|
||||
assert _device_identifiers(hass, device.id) == {(DOMAIN, UUID_B)}
|
||||
assert _entity_unique_id(hass, existing.entity_id) == (f"{DOMAIN}_{UUID_B}_connection_mode")
|
||||
|
||||
|
||||
async def test_a_different_appliance_on_the_same_address_keeps_the_registered_identity(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""Neither the UUID nor the serial matches what this entry was
|
||||
registered with, so this is a different appliance answering at this
|
||||
address -- not a reset of the registered one. Re-keying here would hand
|
||||
one appliance's entities, history and automations to another; re-adding
|
||||
is the user's call."""
|
||||
entry = _entry(hass, version=4, key=UUID_A, serial="SOME-OTHER-APPLIANCE", device_key=UUID_A)
|
||||
device, _ = _seed_registry(hass, entry, UUID_A)
|
||||
|
||||
with _reachable(fridge_resources, UUID_B):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.data[CONF_DEVICE_KEY] == UUID_A
|
||||
assert _device_identifiers(hass, device.id) == {(DOMAIN, UUID_A)}
|
||||
# The rejected appliance's serial is not written either. The serial
|
||||
# is what corroborates a later change of key, so adopting it here
|
||||
# would hand the intruder exactly the corroboration it needs to win
|
||||
# the *next* poll -- defending the identity once and then
|
||||
# surrendering it on the following cycle.
|
||||
assert entry.data[CONF_SERIAL] == "SOME-OTHER-APPLIANCE"
|
||||
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
coordinator._run_discovery(fridge_resources)
|
||||
|
||||
assert coordinator.device_key == UUID_A
|
||||
assert entry.data[CONF_DEVICE_KEY] == UUID_A
|
||||
assert entry.data[CONF_SERIAL] == "SOME-OTHER-APPLIANCE"
|
||||
|
||||
|
||||
async def test_a_pre_v4_entry_defends_itself_against_a_different_appliance(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""The "same IP, different appliance" guard applies to an entry that has
|
||||
not migrated yet, exactly as it does to one that has.
|
||||
|
||||
A pre-v4 entry is the population this change exists to move, but it is
|
||||
also the population that has been running longest -- so it is the last
|
||||
one that should hand its entity_ids, history and automations to an
|
||||
appliance that merely happens to have taken over its address. Adoption
|
||||
is the migration's job only when the identity is corroborated.
|
||||
"""
|
||||
entry = _entry(hass, version=3, key=MOCK_SERIAL)
|
||||
device, existing = _seed_registry(hass, entry, MOCK_SERIAL)
|
||||
|
||||
# Neither the serial nor (therefore) the UUID belongs to the registered
|
||||
# appliance.
|
||||
resources = _reporting_serial(fridge_resources, "SOME-OTHER-APPLIANCE")
|
||||
with _reachable(resources, UUID_B):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
assert coordinator.device_key == MOCK_SERIAL
|
||||
assert entry.data[CONF_DEVICE_KEY] == MOCK_SERIAL
|
||||
assert entry.data[CONF_SERIAL] == MOCK_SERIAL
|
||||
assert entry.unique_id == f"{DOMAIN}_{MOCK_SERIAL}"
|
||||
assert _device_identifiers(hass, device.id) == {(DOMAIN, MOCK_SERIAL)}
|
||||
assert _entity_unique_id(hass, existing.entity_id) == (
|
||||
f"{DOMAIN}_{MOCK_SERIAL}_connection_mode"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rekey_entry's own contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_rekey_is_idempotent(hass: HomeAssistant) -> None:
|
||||
"""Safe to attempt on every poll rather than having to track whether it
|
||||
has already run -- a second call finds nothing under the old key.
|
||||
|
||||
Calls rekey_entry directly: what it leaves behind is the contract, and
|
||||
going through a setup would let the platforms re-adding their entities
|
||||
hide a row that had in fact been orphaned.
|
||||
"""
|
||||
from custom_components.localthings.rekey import rekey_entry
|
||||
|
||||
entry = _entry(hass, version=3, key=MOCK_SERIAL)
|
||||
_, existing = _seed_registry(hass, entry, MOCK_SERIAL)
|
||||
|
||||
rekey_entry(hass, entry, MOCK_SERIAL, UUID_A)
|
||||
rekey_entry(hass, entry, MOCK_SERIAL, UUID_A)
|
||||
|
||||
kept = er.async_get(hass).async_get(existing.entity_id)
|
||||
assert kept is not None
|
||||
assert kept.unique_id == f"{DOMAIN}_{UUID_A}_connection_mode"
|
||||
assert entry.unique_id == f"{DOMAIN}_{UUID_A}"
|
||||
|
||||
|
||||
async def test_rekey_removes_a_stale_row_rather_than_colliding(hass: HomeAssistant) -> None:
|
||||
"""Where the destination key is already taken, the old-key row is the
|
||||
dead one -- unavailable since whichever restart created the split -- so
|
||||
it goes rather than being rewritten onto a key that exists. Same rule
|
||||
the #236 repair has always applied, now on the identity move."""
|
||||
from custom_components.localthings.rekey import rekey_entry
|
||||
|
||||
entry = _entry(hass, version=3, key=MOCK_SERIAL)
|
||||
ent_reg = er.async_get(hass)
|
||||
live = ent_reg.async_get_or_create(
|
||||
"sensor", DOMAIN, f"{DOMAIN}_{UUID_A}_connection_mode", config_entry=entry
|
||||
)
|
||||
stale = ent_reg.async_get_or_create(
|
||||
"sensor", DOMAIN, f"{DOMAIN}_{MOCK_SERIAL}_connection_mode", config_entry=entry
|
||||
)
|
||||
assert stale.entity_id != live.entity_id
|
||||
|
||||
rekey_entry(hass, entry, MOCK_SERIAL, UUID_A)
|
||||
|
||||
assert ent_reg.async_get(stale.entity_id) is None
|
||||
assert ent_reg.async_get(live.entity_id) is not None
|
||||
|
||||
|
||||
async def test_rekey_to_the_same_key_does_nothing(hass: HomeAssistant) -> None:
|
||||
"""The no-op guard that lets callers pass whatever they resolved without
|
||||
checking first."""
|
||||
from custom_components.localthings.rekey import rekey_entry
|
||||
|
||||
entry = _entry(hass, version=4, key=UUID_A, device_key=UUID_A)
|
||||
_, existing = _seed_registry(hass, entry, UUID_A)
|
||||
|
||||
rekey_entry(hass, entry, UUID_A, UUID_A)
|
||||
|
||||
assert _entity_unique_id(hass, existing.entity_id) == (f"{DOMAIN}_{UUID_A}_connection_mode")
|
||||
assert entry.unique_id == f"{DOMAIN}_{UUID_A}"
|
||||
@@ -1,4 +1,9 @@
|
||||
"""Config-entry migration and the placeholder-identity repair (issue #236)."""
|
||||
"""Config-entry migration and the placeholder-identity repair (issue #236).
|
||||
|
||||
The move onto the OCF device UUID (issue #381) has its own suite in
|
||||
test_identity_migration.py -- it is the one migration step that can't
|
||||
finish inside async_migrate_entry, so it needs a device to talk to.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -38,35 +43,38 @@ 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, and
|
||||
# v3 -> v4 only records the legacy key for the coordinator to re-key
|
||||
# from once a poll produces an OCF device id.
|
||||
assert entry.version == 4
|
||||
assert entry.data[CONF_SERIAL] == MOCK_SERIAL
|
||||
|
||||
|
||||
async def test_migration_collapses_the_host_port_unique_id(
|
||||
hass: HomeAssistant, mock_coordinator_session
|
||||
) -> None:
|
||||
async def test_migration_collapses_the_host_port_unique_id(hass: HomeAssistant) -> None:
|
||||
"""A board with no usable serial (issues #83/#189) used to be keyed two
|
||||
different ways at once: `host:port` on the config entry, `host` in the
|
||||
device and entity registries. Migration collapses the entry onto the
|
||||
registry's form, so the two finally name the same thing."""
|
||||
from custom_components.localthings import async_migrate_entry
|
||||
|
||||
entry = _legacy_entry(hass, f"{DOMAIN}_{MOCK_HOST}:{MOCK_PORT}")
|
||||
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert await async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert entry.data[CONF_SERIAL] == MOCK_HOST
|
||||
assert entry.unique_id == f"{DOMAIN}_{MOCK_HOST}"
|
||||
|
||||
|
||||
async def test_migration_resolves_a_placeholder_serial_unique_id(
|
||||
hass: HomeAssistant, mock_coordinator_session
|
||||
) -> None:
|
||||
async def test_migration_resolves_a_placeholder_serial_unique_id(hass: HomeAssistant) -> None:
|
||||
"""An entry created before the placeholder rules landed was keyed on the
|
||||
placeholder itself (issues #83/#189), while the coordinator has been
|
||||
resolving those boards to the host ever since. The unique_id records what
|
||||
the flow believed then, not what the registry holds -- taking it at face
|
||||
value would re-key working devices back onto a string every unit of the
|
||||
family reports, which is the collision those issues are about."""
|
||||
from custom_components.localthings import async_migrate_entry
|
||||
|
||||
entry = _legacy_entry(hass, f"{DOMAIN}_Nothing(SVC)")
|
||||
dev_reg = dr.async_get(hass)
|
||||
device = dev_reg.async_get_or_create(
|
||||
@@ -74,8 +82,7 @@ async def test_migration_resolves_a_placeholder_serial_unique_id(
|
||||
identifiers={(DOMAIN, MOCK_HOST)},
|
||||
)
|
||||
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert await async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert entry.data[CONF_SERIAL] == MOCK_HOST
|
||||
assert entry.unique_id == f"{DOMAIN}_{MOCK_HOST}"
|
||||
@@ -84,14 +91,13 @@ async def test_migration_resolves_a_placeholder_serial_unique_id(
|
||||
assert unchanged.identifiers == {(DOMAIN, MOCK_HOST)}
|
||||
|
||||
|
||||
async def test_migration_resolves_an_all_hex_placeholder_unique_id(
|
||||
hass: HomeAssistant, mock_coordinator_session
|
||||
) -> None:
|
||||
async def test_migration_resolves_an_all_hex_placeholder_unique_id(hass: HomeAssistant) -> None:
|
||||
"""The issue #189 flash-unset sentinel, same reasoning."""
|
||||
from custom_components.localthings import async_migrate_entry
|
||||
|
||||
entry = _legacy_entry(hass, f"{DOMAIN}_FFFFFFFFFFFFFFF")
|
||||
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert await async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert entry.data[CONF_SERIAL] == MOCK_HOST
|
||||
|
||||
@@ -229,13 +235,13 @@ async def test_migration_removes_an_orphan_that_is_already_duplicated(
|
||||
assert dev_reg.async_get(real_device.id) is not None
|
||||
|
||||
|
||||
async def test_migration_leaves_a_host_identity_device_alone(
|
||||
hass: HomeAssistant, mock_coordinator_session
|
||||
) -> None:
|
||||
async def test_migration_leaves_a_host_identity_device_alone(hass: HomeAssistant) -> None:
|
||||
"""A board whose serial resolves *to* the host was never keyed on a
|
||||
placeholder -- its host-keyed device is the real one, and re-keying or
|
||||
removing it would orphan a working device to fix a problem it doesn't
|
||||
have."""
|
||||
from custom_components.localthings import async_migrate_entry
|
||||
|
||||
entry = _legacy_entry(hass, f"{DOMAIN}_{MOCK_HOST}")
|
||||
dev_reg = dr.async_get(hass)
|
||||
device = dev_reg.async_get_or_create(
|
||||
@@ -243,8 +249,7 @@ async def test_migration_leaves_a_host_identity_device_alone(
|
||||
identifiers={(DOMAIN, MOCK_HOST)},
|
||||
)
|
||||
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert await async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert entry.data[CONF_SERIAL] == MOCK_HOST
|
||||
unchanged = dev_reg.async_get(device.id)
|
||||
@@ -257,7 +262,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=5)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
assert await async_migrate_entry(hass, entry) is False
|
||||
|
||||
@@ -58,6 +58,49 @@ def test_apply_merges_partial_update_onto_prior_rep():
|
||||
assert cached["x.com.samsung.da.supportedOptions"] == ["CV_FDR_WINE", "CV_FDR_MEAT"]
|
||||
|
||||
|
||||
def test_apply_fully_replaces_alarms_href_instead_of_merging():
|
||||
"""Regression test for issue #348: /alarms/vs/0's `items` array is a
|
||||
complete snapshot of every currently-active alarm, not a partial field
|
||||
update like /mode/vs/0 (issue #27). A washer's board reports a cleared
|
||||
alarm by omitting `items` entirely -- a live read_resource GET showed
|
||||
`{}` -- so merging that onto the prior rep (as every other href does)
|
||||
left the stale ErrorCode_DC entry in the cache forever. This must
|
||||
instead behave like a full replace: the empty rep wins outright."""
|
||||
mgr = _manager()
|
||||
active = {
|
||||
"x.com.samsung.da.items": [
|
||||
{"x.com.samsung.da.code": "ErrorCode_DC", "x.com.samsung.da.state": "Created"}
|
||||
]
|
||||
}
|
||||
mgr.apply("/alarms/vs/0", active, source="poll")
|
||||
assert mgr.cache.get("/alarms/vs/0") == active
|
||||
|
||||
cleared = mgr.apply("/alarms/vs/0", {}, source="poll")
|
||||
|
||||
assert cleared is True
|
||||
assert mgr.cache.get("/alarms/vs/0") == {}
|
||||
|
||||
|
||||
def test_apply_fully_replaces_alarms_href_for_subdevice_shapes():
|
||||
"""The same full-replace behavior must hold for both hrefs
|
||||
`Subdevice.to_actual` can produce: an indexed subdevice renumbers only
|
||||
the trailing '0' (/alarms/vs/1), and a prefixed one prepends a UUID
|
||||
(/<uuid>/alarms/vs/0) -- neither ever touches the 'alarms/vs' stem
|
||||
itself (registry/subdevices.py)."""
|
||||
for href in ("/alarms/vs/1", "/6c2dff6d-ee5c-dad1-6a5e-000000000001/alarms/vs/0"):
|
||||
mgr = _manager()
|
||||
mgr.apply(
|
||||
href,
|
||||
{"x.com.samsung.da.items": [{"x.com.samsung.da.code": "ErrorCode_UB"}]},
|
||||
source="poll",
|
||||
)
|
||||
|
||||
cleared = mgr.apply(href, {}, source="poll")
|
||||
|
||||
assert cleared is True
|
||||
assert mgr.cache.get(href) == {}
|
||||
|
||||
|
||||
def test_apply_drops_update_during_settle_window():
|
||||
mgr = _manager()
|
||||
mgr.cache.apply_rep("/oven/vs/0", {"a": 1}, source="seed")
|
||||
@@ -147,6 +190,7 @@ def test_try_enter_observe_mode_succeeds_when_all_hrefs_notify():
|
||||
assert entered is True
|
||||
assert mgr.mode == "observe"
|
||||
assert mgr.subscribed_hrefs == set(hrefs)
|
||||
assert mgr.fallback_hrefs == set()
|
||||
finally:
|
||||
mgr.close()
|
||||
|
||||
@@ -175,6 +219,41 @@ def test_try_enter_observe_mode_falls_back_when_subscribe_fails_for_all():
|
||||
assert mgr.mode == "poll"
|
||||
|
||||
|
||||
def test_enter_observe_mode_keeps_silent_hrefs_on_fallback():
|
||||
"""Issue #92: subscribed hrefs that never notified stay on the poll
|
||||
cadence via fallback_hrefs, rather than being treated as push-covered."""
|
||||
mgr = _manager()
|
||||
session = _FakeSession()
|
||||
subscribed = {"/a/vs/0", "/b/vs/0", "/c/vs/0"}
|
||||
mgr.on_notification("/a/vs/0", cbor2.dumps({"x": 1}))
|
||||
mgr.on_notification("/b/vs/0", cbor2.dumps({"x": 1}))
|
||||
try:
|
||||
mgr.enter_observe_mode(session, subscribed)
|
||||
assert mgr.mode == "observe"
|
||||
assert mgr.subscribed_hrefs == subscribed
|
||||
assert mgr.fallback_hrefs == {"/c/vs/0"}
|
||||
finally:
|
||||
mgr.close()
|
||||
|
||||
|
||||
def test_on_notification_drops_href_from_fallback():
|
||||
"""A late first notify (after the 80% snapshot) self-corrects
|
||||
fallback_hrefs so a slow-but-pushing href is not polled for the rest
|
||||
of the session -- multi-block resources being the reliable victim."""
|
||||
mgr = _manager()
|
||||
session = _FakeSession()
|
||||
subscribed = {"/a/vs/0", "/b/vs/0", "/c/vs/0"}
|
||||
mgr.on_notification("/a/vs/0", cbor2.dumps({"x": 1}))
|
||||
mgr.on_notification("/b/vs/0", cbor2.dumps({"x": 1}))
|
||||
try:
|
||||
mgr.enter_observe_mode(session, subscribed)
|
||||
assert mgr.fallback_hrefs == {"/c/vs/0"}
|
||||
mgr.on_notification("/c/vs/0", cbor2.dumps({"x": 1}))
|
||||
assert mgr.fallback_hrefs == set()
|
||||
finally:
|
||||
mgr.close()
|
||||
|
||||
|
||||
def test_try_enter_observe_mode_meets_success_fraction_with_partial_notifies():
|
||||
mgr = _manager()
|
||||
session = _FakeSession()
|
||||
@@ -200,6 +279,7 @@ def test_try_enter_observe_mode_meets_success_fraction_with_partial_notifies():
|
||||
|
||||
assert entered is True
|
||||
assert mgr.mode == "observe"
|
||||
assert mgr.fallback_hrefs == {hrefs[3]}
|
||||
finally:
|
||||
mgr.close()
|
||||
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
"""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 five things that makes load-bearing: the
|
||||
snapshot gets written, it produces the same entity set offline, the
|
||||
coordinator keeps polling until the device answers, a live discovery that
|
||||
disagrees with the snapshot reloads the entry rather than silently keeping a
|
||||
stale set, and each of those retries costs one handshake and one log line
|
||||
rather than repeating both every cycle (issue #269).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
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.helpers.update_coordinator import UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
from pytest_homeassistant_custom_component.common import async_fire_time_changed
|
||||
from smartthings_local.errors import SessionTimeoutError
|
||||
|
||||
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"
|
||||
_COORD_LOGGER = "custom_components.localthings.coordinator"
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _dark(handshakes: list[float]):
|
||||
"""A switched-off appliance: the DTLS handshake itself times out, which
|
||||
is what `_poll_once` really hits -- `_unreachable` above stands in one
|
||||
step later, after a session it never gets. Records every attempt."""
|
||||
|
||||
def _connect(self) -> None:
|
||||
handshakes.append(time.monotonic())
|
||||
raise SessionTimeoutError()
|
||||
|
||||
with patch(f"{_COORD}._connect_session", _connect), 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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# What a cycle against a dark appliance costs (issue #269)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_dark_appliance_costs_one_handshake_per_cycle(
|
||||
hass: HomeAssistant, mock_entry
|
||||
) -> None:
|
||||
"""A washer or dryer is switched off most of the day, so every one of
|
||||
these cycles is paid for real: a handshake against a device that isn't
|
||||
there runs to its full 12s timeout, and the reconnect retry used to add a
|
||||
second one plus its pause to every cycle -- and to every setup attempt
|
||||
while the appliance stayed dark. There is no session to reconnect when
|
||||
the handshake is what failed, so the retry only repeated it."""
|
||||
resources = _load_fridge()
|
||||
await _setup_online_then_unload(hass, mock_entry, resources)
|
||||
|
||||
handshakes: list[float] = []
|
||||
with _dark(handshakes):
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert len(handshakes) == 1
|
||||
|
||||
for expected in (2, 3, 4):
|
||||
await _tick(hass)
|
||||
assert len(handshakes) == expected
|
||||
|
||||
|
||||
async def test_dark_appliance_reports_its_outage_once(
|
||||
hass: HomeAssistant, mock_entry, caplog
|
||||
) -> None:
|
||||
"""Sitting through an outage is what this integration is built to do
|
||||
(issue #295), so it must not log an error every 30s for as long as the
|
||||
appliance is off -- the reporter on issue #269 read exactly that repeated
|
||||
line as the integration having failed. One line per outage, and one when
|
||||
the device comes back."""
|
||||
resources = _load_fridge()
|
||||
await _setup_online_then_unload(hass, mock_entry, resources)
|
||||
|
||||
caplog.clear()
|
||||
caplog.set_level(logging.INFO)
|
||||
with _dark([]):
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
for _ in range(3):
|
||||
await _tick(hass)
|
||||
|
||||
ours = [r for r in caplog.records if r.name.startswith(f"{_COORD_LOGGER}.")]
|
||||
errors = [r for r in ours if r.levelno >= logging.ERROR]
|
||||
assert len(errors) == 1
|
||||
assert "device unreachable" in errors[0].getMessage()
|
||||
|
||||
with _reachable(resources):
|
||||
await _tick(hass)
|
||||
|
||||
recovered = [r for r in caplog.records if "device answered again" in r.getMessage()]
|
||||
assert len(recovered) == 1
|
||||
assert recovered[0].levelno == logging.INFO
|
||||
|
||||
|
||||
async def test_broken_session_still_reconnects_within_the_cycle(
|
||||
hass: HomeAssistant, mock_entry
|
||||
) -> None:
|
||||
"""The counterpart guard: skipping the reconnect is only right when the
|
||||
handshake never completed. A session that opened and then failed mid-poll
|
||||
still gets torn down and re-established without waiting a whole cycle."""
|
||||
coordinator = LocalThingsCoordinator(hass, mock_entry)
|
||||
coordinator._discovered = True
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
LocalThingsCoordinator, "_poll_once", side_effect=RuntimeError("poll GET failed")
|
||||
) as poll,
|
||||
patch.object(LocalThingsCoordinator, "_close_session") as close,
|
||||
patch("custom_components.localthings.coordinator.asyncio.sleep"),
|
||||
pytest.raises(UpdateFailed),
|
||||
):
|
||||
await coordinator._async_update_data()
|
||||
|
||||
assert poll.call_count == 2
|
||||
close.assert_called_once()
|
||||
@@ -0,0 +1,229 @@
|
||||
"""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 == 4
|
||||
|
||||
|
||||
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 == 4
|
||||
|
||||
|
||||
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 == 4
|
||||
|
||||
|
||||
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 == 4
|
||||
|
||||
|
||||
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 == 4
|
||||
|
||||
|
||||
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 nothing either migration step needs to do --
|
||||
no statistics to relabel, and the probe already resolved its device key
|
||||
(issue #381) -- so the config flow mints the current version directly
|
||||
rather than walking through them.
|
||||
|
||||
Pinned rather than compared to a constant on purpose: the two must be
|
||||
bumped together, and a migration step added without moving the flow's
|
||||
VERSION never runs at all, because Home Assistant only calls
|
||||
async_migrate_entry for an entry *behind* the flow's version.
|
||||
"""
|
||||
from custom_components.localthings.config_flow import LocalThingsConfigFlow
|
||||
|
||||
assert LocalThingsConfigFlow.VERSION == 4
|
||||
@@ -54,8 +54,8 @@ def test_no_unbound_hrefs():
|
||||
|
||||
def test_air_quality_sensors_read_from_shared_items_decode():
|
||||
"""Same /sensors/vs/0 {type, value} shape and common.sensor_item_value
|
||||
decode air_purifier.AIR_QUALITY already uses -- this board adds CO2,
|
||||
which neither air_purifier nor range_hood report."""
|
||||
decode air_purifier.AIR_QUALITY already uses -- this board lists CO2
|
||||
unconditionally, unlike the exists_fn-gated purifier descriptor."""
|
||||
state = _state()
|
||||
assert state["dust"] == 31
|
||||
assert state["fine_dust"] == 23
|
||||
|
||||
@@ -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,34 @@ 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_co2_is_not_in_the_shared_tuple():
|
||||
"""air_monitor already has its own CO2 SensorDesc. Putting CO2 in
|
||||
_AIR_QUALITY_SENSORS would create a second entity with the same key."""
|
||||
assert all(row[0] != "co2" for row in air_purifier._AIR_QUALITY_SENSORS)
|
||||
|
||||
|
||||
def test_co2_matches_air_monitor_mapping():
|
||||
"""ppm / carbon_dioxide is the same contract air_monitor.SENSORS already
|
||||
ships for this field (issue #387), not a unit guess."""
|
||||
desc = _desc("co2")
|
||||
assert desc.device_class == "carbon_dioxide"
|
||||
assert desc.unit == "ppm"
|
||||
assert desc.state_class == "measurement"
|
||||
assert desc.exists_fn is not None
|
||||
assert desc.enabled_default is False
|
||||
assert (
|
||||
desc.value_fn([{"x.com.samsung.da.type": "CO2", "x.com.samsung.da.value": ["498"]}]) == 498
|
||||
)
|
||||
|
||||
|
||||
def test_every_air_quality_sensor_still_reads_a_plain_int():
|
||||
|
||||
@@ -12,7 +12,7 @@ from tests.conftest import _load_device
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = "TEST-AIRFLOW-SERIAL"
|
||||
device_key = "TEST-AIRFLOW-SERIAL"
|
||||
device_info: ClassVar[dict] = {}
|
||||
data: ClassVar[dict] = {}
|
||||
|
||||
|
||||
@@ -69,6 +69,36 @@ def test_air_quality_sensor_values():
|
||||
assert state["super_fine_dust"] == 5
|
||||
assert state["odor"] == 0
|
||||
assert state["clean_level"] == 0
|
||||
assert "co2" not in state
|
||||
|
||||
|
||||
def test_co2_reads_when_the_items_list_includes_the_type():
|
||||
"""Issue #387 -- same {type, value} shape air_monitor.SENSORS already
|
||||
models. Gated so boards that don't list CO2 don't grow an empty entity."""
|
||||
reg, resources = _purifier()
|
||||
resources = {
|
||||
**resources,
|
||||
"/sensors/vs/0": {
|
||||
**resources["/sensors/vs/0"],
|
||||
"x.com.samsung.da.items": [
|
||||
*resources["/sensors/vs/0"]["x.com.samsung.da.items"],
|
||||
{"x.com.samsung.da.type": "CO2", "x.com.samsung.da.value": ["612"]},
|
||||
],
|
||||
},
|
||||
}
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||
assert flatten(bound, resources)["co2"] == 612
|
||||
|
||||
desc = next(e for e in air_purifier.AIR_QUALITY.entities if e.key == "co2")
|
||||
assert desc.exists_fn is not None
|
||||
assert desc.enabled_default is False
|
||||
assert desc.exists_fn({"x.com.samsung.da.items": []}, {}) is False
|
||||
assert (
|
||||
desc.exists_fn({"x.com.samsung.da.items": [{"x.com.samsung.da.type": "CO2"}]}, {}) is True
|
||||
)
|
||||
# /device/0 stub must not drop co2 while its field-gated siblings still
|
||||
# register -- platforms enumerate bound once (issue #127).
|
||||
assert desc.exists_fn({"href": "/sensors/vs/0"}, {}) is True
|
||||
|
||||
|
||||
def test_filter_progress_reads_named_consumable_item():
|
||||
|
||||
@@ -21,7 +21,7 @@ from tests.conftest import _load_device
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = "TEST-VTWW-SERIAL"
|
||||
device_key = "TEST-VTWW-SERIAL"
|
||||
device_info: ClassVar[dict] = {}
|
||||
data: ClassVar[dict] = {}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -44,7 +44,7 @@ MODEL = "ARTIK051_KRAC_18K|10193441|60010119001111010100000000000000"
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = "TEST-KRAC-SERIAL"
|
||||
device_key = "TEST-KRAC-SERIAL"
|
||||
device_info: ClassVar[dict] = {}
|
||||
data: ClassVar[dict] = {}
|
||||
|
||||
@@ -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)
|
||||
@@ -970,7 +986,7 @@ def test_air_quality_disabled_by_default():
|
||||
SuperFineDust readings with no such scalar, so requiring it would
|
||||
silently drop real readings on hardware this repo hasn't seen yet on an
|
||||
AC. These stay bound whenever the item type is listed (see
|
||||
_has_sensor_type) and disabled by default instead, same precedent as
|
||||
has_sensor_type) and disabled by default instead, same precedent as
|
||||
fridge.rack_count / cooktop.paired_hood_model / tropical_night_mode --
|
||||
units that do have the sensor can enable it themselves."""
|
||||
for key in ("clean_level", "odor", "dust", "fine_dust", "super_fine_dust"):
|
||||
@@ -978,6 +994,17 @@ def test_air_quality_disabled_by_default():
|
||||
assert desc.enabled_default is False, key
|
||||
|
||||
|
||||
def test_air_quality_included_on_stub_rep():
|
||||
"""exists_fn would otherwise drop every gated sensor when /device/0
|
||||
returns a not-yet-fetched stub, while field-gated siblings still
|
||||
register (issue #127)."""
|
||||
stub = {"href": "/sensors/vs/0"}
|
||||
for key in ("clean_level", "odor", "dust", "fine_dust", "super_fine_dust", "co2"):
|
||||
desc = next(e for e in airconditioner.AIR_QUALITY.entities if e.key == key)
|
||||
assert desc.exists_fn is not None
|
||||
assert desc.exists_fn(stub, {}) is True, key
|
||||
|
||||
|
||||
def test_air_quality_absent_when_no_sensor_items():
|
||||
"""A board whose /sensors/vs/0 carries an empty items[] (the cool-only
|
||||
RAC variant) binds no air-quality entities -- exists_fn gates each on its
|
||||
|
||||
@@ -28,7 +28,7 @@ FIXTURE = "airconditioner_tp1x_rac_01001"
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = "TEST-RAC-01001-SERIAL"
|
||||
device_key = "TEST-RAC-01001-SERIAL"
|
||||
device_info: ClassVar[dict] = {}
|
||||
data: ClassVar[dict] = {}
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ def test_fac_bora_wind_strength_codes_fit_the_standard_scale():
|
||||
from tests.conftest import _load_device
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = "TEST-FAC-BORA-SERIAL"
|
||||
device_key = "TEST-FAC-BORA-SERIAL"
|
||||
device_info: ClassVar[dict] = {}
|
||||
data: ClassVar[dict] = {}
|
||||
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -417,6 +417,31 @@ class TestEnergyMeter:
|
||||
assert pw.exists_fn({"x.com.samsung.da.cumulativePower": "5"}, {}) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# has_sensor_type. Presence gate for /sensors/vs/0 items[] types, shared by
|
||||
# airconditioner.AIR_QUALITY and air_purifier.AIR_QUALITY. The stub carve-out
|
||||
# is the same contract ENERGY_METER documents for issue #127.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHasSensorType:
|
||||
def test_true_when_type_is_listed(self):
|
||||
fn = common.has_sensor_type("CO2")
|
||||
assert fn({"x.com.samsung.da.items": [{"x.com.samsung.da.type": "CO2"}]}, {}) is True
|
||||
|
||||
def test_false_when_type_is_absent(self):
|
||||
fn = common.has_sensor_type("CO2")
|
||||
assert fn({"x.com.samsung.da.items": [{"x.com.samsung.da.type": "Dust"}]}, {}) is False
|
||||
assert fn({"x.com.samsung.da.items": []}, {}) is False
|
||||
assert fn({}, {}) is False
|
||||
|
||||
def test_true_on_stub_rep(self):
|
||||
"""A true stub -- /device/0's {"href": "..."} "not fetched yet"
|
||||
marker -- must keep the entity so sub-polls can populate it."""
|
||||
fn = common.has_sensor_type("CO2")
|
||||
assert fn({"href": "/sensors/vs/0"}, {}) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AI energy-saving level. '0' is off; supportedAiLevel lists the additional
|
||||
# level(s) on offer. A single-entry list (issue #21 fridge, issue #40 washer)
|
||||
|
||||
@@ -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,49 @@ 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
|
||||
|
||||
|
||||
class TestDrumClean:
|
||||
"""Drum Clean+ maintenance tracking shares washer.py's (issue #9) /
|
||||
dryer.py's (issue #258) options[]-array readers -- a live dishwasher
|
||||
dump confirmed the same WashingTimes_/DrumCleanProposal_/DrumCleanLog_
|
||||
trio, so drum_clean_cycles_remaining is wired the same way here.
|
||||
|
||||
drum_clean_last_cleaned is deliberately not (issue #398): a live dump
|
||||
showed DrumCleanLog_'s newest entry moving every 30-90s on its own,
|
||||
including well after a cycle had finished -- unlike the washer/dryer
|
||||
reports this reader was built from (issues #9, #258), it never settles
|
||||
on a value worth showing."""
|
||||
|
||||
def test_cycles_remaining(self):
|
||||
desc = next(
|
||||
e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == "drum_clean_cycles_remaining"
|
||||
)
|
||||
assert desc.rep_fn is not None
|
||||
rep = {"x.com.samsung.da.options": ["WashingTimes_18", "DrumCleanProposal_20"]}
|
||||
assert desc.rep_fn(rep) == 2
|
||||
|
||||
def test_cycles_remaining_exists_only_when_computable(self):
|
||||
desc = next(
|
||||
e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == "drum_clean_cycles_remaining"
|
||||
)
|
||||
assert desc.exists_fn is not None
|
||||
assert desc.exists_fn({"x.com.samsung.da.options": []}, {}) is False
|
||||
rep = {"x.com.samsung.da.options": ["WashingTimes_18", "DrumCleanProposal_20"]}
|
||||
assert desc.exists_fn(rep, {}) is True
|
||||
|
||||
def test_last_cleaned_is_not_wired(self):
|
||||
assert not any(
|
||||
e.key == "drum_clean_last_cleaned" for e in dishwasher.CYCLE_OPTIONS.entities
|
||||
)
|
||||
|
||||
|
||||
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"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for dryer support and washer/dryer consistency (issue #14)."""
|
||||
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model, resolve
|
||||
from custom_components.localthings.registry.capabilities import dryer, ignored
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from custom_components.localthings.registry.entities import SelectDesc
|
||||
@@ -89,9 +89,80 @@ def test_course_bound_to_shared_course_vs_0():
|
||||
assert desc.rep_fn(rep) == "16"
|
||||
|
||||
|
||||
def test_reported_table_00_course_codes_are_translated():
|
||||
"""The DVE45R6300W/A3 reporter confirmed these codes by selecting each
|
||||
cycle and reading back the raw course code (issue #357). A DV6800N --
|
||||
same DA_WM_A51_20_COMMON board, also Table_00 -- later confirmed 14 more
|
||||
(issue #394): a different subset of the same table, not a conflicting
|
||||
code family (its one code in common with #357, 'a5', means Bedding on
|
||||
both), so both sets share the one dryer_cycle_table_00 catalog entry."""
|
||||
from custom_components.localthings.catalog import translated_states
|
||||
|
||||
desc = next(
|
||||
e for e in dryer.DRYER_COURSE.entities if e.key == "cycle" and isinstance(e, SelectDesc)
|
||||
)
|
||||
table_00 = {"/st/dryercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_00"}}
|
||||
assert desc.translation_key(table_00) == "dryer_cycle_table_00"
|
||||
confirmed = {
|
||||
"01",
|
||||
"9c",
|
||||
"a5",
|
||||
"9e",
|
||||
"9b",
|
||||
"27",
|
||||
"a0",
|
||||
"a4",
|
||||
"a6",
|
||||
"a3",
|
||||
"a2", # issue #357
|
||||
"9a",
|
||||
"ca",
|
||||
"db",
|
||||
"99",
|
||||
"93",
|
||||
"b5",
|
||||
"d7",
|
||||
"96",
|
||||
"97",
|
||||
"7f",
|
||||
"98",
|
||||
"eb",
|
||||
"b6", # issue #394
|
||||
}
|
||||
assert confirmed <= translated_states("select", "dryer_cycle_table_00")
|
||||
|
||||
|
||||
def test_st_dryercourse_is_ignored():
|
||||
"""/st/dryercourse/vs/0 re-encodes the course exposed via /course/vs/0 and
|
||||
is globally ignored -- the mirror of /st/washercourse/vs/0."""
|
||||
ignored_hrefs = {c.href for c in ignored.IGNORED}
|
||||
assert "/st/dryercourse/vs/0" in ignored_hrefs
|
||||
assert "/st/washercourse/vs/0" in ignored_hrefs
|
||||
|
||||
|
||||
def _dv6800n():
|
||||
resources = _load_device("dryer_dv6800n")
|
||||
reg = resolve(resources, device_types=("oic.wk.d", "oic.d.dryer"))
|
||||
return reg, resources
|
||||
|
||||
|
||||
def test_dv6800n_no_unbound_hrefs():
|
||||
"""Every resource in the issue #394 dump binds or is ignored."""
|
||||
reg, resources = _dv6800n()
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_dv6800n_course_codes_read_from_dump():
|
||||
"""A DV6800N (DA_WM_A51_20_COMMON, issue #394) reports 'Table_00' same
|
||||
as #357's DVE45R6300W/A3, so it resolves to the same catalog entry --
|
||||
its /course/vs/0 supportedOptions just advertises a different subset of
|
||||
the same table (see test_reported_table_00_course_codes_are_translated)."""
|
||||
_, resources = _dv6800n()
|
||||
desc = next(
|
||||
e for e in dryer.DRYER_COURSE.entities if e.key == "cycle" and isinstance(e, SelectDesc)
|
||||
)
|
||||
assert desc.translation_key(resources) == "dryer_cycle_table_00"
|
||||
confirmed = ["9A", "CA", "DB", "99", "93", "B5", "D7", "A5", "96", "97", "7F", "98", "EB", "B6"]
|
||||
assert desc.options(resources) == confirmed
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -10,7 +10,7 @@ from custom_components.localthings.registry.entities import BinarySensorDesc
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = "TEST-SERIAL"
|
||||
device_key = "TEST-SERIAL"
|
||||
|
||||
def __init__(self, last_resources=None):
|
||||
self.last_resources = last_resources or {}
|
||||
|
||||
@@ -463,7 +463,7 @@ class TestKimchiZone:
|
||||
)
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = "TEST-SERIAL"
|
||||
device_key = "TEST-SERIAL"
|
||||
|
||||
def __init__(self, resources, data):
|
||||
self.last_resources = resources
|
||||
|
||||
@@ -121,6 +121,25 @@ def test_registry_reproduces_golden_state_keys_for_dryer_dve50a8600():
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_dryer_dv6800n():
|
||||
"""DA_WM_A51_20_COMMON/DV6800N (issue #394) resolves via /oic/d's
|
||||
'oic.d.dryer' device type, not board-token guessing -- its modelNum's
|
||||
board tokens ('DA', 'WM', 'COMMON') are all deliberately excluded from
|
||||
_BOARD_TOKEN_TO_KEY (see registry/by_type's module docstring)."""
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device("dryer_dv6800n")
|
||||
golden = json.loads((GOLDEN / "dryer_dv6800n.json").read_text())
|
||||
state_keys = _new_state_keys(
|
||||
"dryer_dv6800n", resources, device_types=("oic.wk.d", "oic.d.dryer")
|
||||
)
|
||||
assert set(state_keys) == set(golden["state_keys"]), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_airconditioner():
|
||||
from tests.conftest import _load_device
|
||||
|
||||
@@ -389,6 +408,25 @@ def test_registry_reproduces_golden_state_keys_for_washer_dryer_combo():
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_washer_ww6500():
|
||||
"""DA_WM_A51_20 front-loader, typed solely by the WW consumer prefix in
|
||||
its /information/vs/0 description: A51 is not a board token, so with the
|
||||
description blanked this device resolves to nothing and drops to the
|
||||
unknown-device fallback. That is the fragile route this test pins.
|
||||
Reports the AddWash tokens and an empty /wm/editcourse/vs/0, so its cycle
|
||||
list comes from supportedOptions."""
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device("washer_ww6500")
|
||||
golden = json.loads((GOLDEN / "washer_ww6500.json").read_text())
|
||||
state_keys = _new_state_keys("washer_ww6500", resources)
|
||||
assert set(state_keys) == set(golden["state_keys"]), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_artik051_ref_17k():
|
||||
"""ARTIK051_REF_17K's Cool Select Zone pantry compartment
|
||||
(/status/pantry/one/vs/0) -- issue #20."""
|
||||
|
||||
+129
-1
@@ -1,6 +1,12 @@
|
||||
import cbor2
|
||||
|
||||
from custom_components.localthings.registry.identity import read_identity
|
||||
from custom_components.localthings.registry.identity import (
|
||||
DeviceIdentity,
|
||||
is_usable_device_id,
|
||||
ocf_device_key,
|
||||
read_identity,
|
||||
resolve_device_key,
|
||||
)
|
||||
|
||||
|
||||
class FakeSession:
|
||||
@@ -124,3 +130,125 @@ def test_read_identity_tolerates_malformed_oic_res():
|
||||
_device_types' handling of a malformed /oic/d rt."""
|
||||
ident = read_identity(FakeSession({("oic", "res"): {"not": "a list"}}), serial=None)
|
||||
assert ident.raw["/oic/res"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The device key: which identity field registry keys are minted from (#381)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _identity(
|
||||
*,
|
||||
serial: str | None = None,
|
||||
device_id: str | None = None,
|
||||
platform_id: str | None = None,
|
||||
) -> DeviceIdentity:
|
||||
"""A DeviceIdentity carrying only the fields the key chain reads."""
|
||||
return DeviceIdentity(
|
||||
manufacturer="Samsung",
|
||||
model="M",
|
||||
name="N",
|
||||
serial=serial,
|
||||
device_id=device_id,
|
||||
platform_id=platform_id,
|
||||
)
|
||||
|
||||
|
||||
def test_read_identity_captures_the_ocf_uuids_as_named_fields():
|
||||
"""`di`/`pi` are what resolve_device_key mints keys from, so they are
|
||||
lifted out of `raw` rather than dug back out of it at every call site."""
|
||||
sess = FakeSession(
|
||||
{
|
||||
("oic", "p"): {"pi": "ccfd73b3-aeb4-792a-1100-68f06f5d603b"},
|
||||
("oic", "d"): {"di": "3771f8bf-c184-3a2d-d885-e4c9818736d2"},
|
||||
}
|
||||
)
|
||||
ident = read_identity(sess, serial=None)
|
||||
assert ident.device_id == "3771f8bf-c184-3a2d-d885-e4c9818736d2"
|
||||
assert ident.platform_id == "ccfd73b3-aeb4-792a-1100-68f06f5d603b"
|
||||
|
||||
|
||||
def test_read_identity_ignores_non_string_uuids():
|
||||
"""Firmware answering with a number or a map must not put a non-string
|
||||
into a field that goes on to be string-formatted into a unique_id."""
|
||||
ident = read_identity(FakeSession({("oic", "d"): {"di": 42}, ("oic", "p"): {"pi": {}}}), None)
|
||||
assert ident.device_id is None
|
||||
assert ident.platform_id is None
|
||||
|
||||
|
||||
def test_two_units_sharing_a_serial_get_distinct_keys():
|
||||
"""Issue #381 exactly: two Samsung air purifiers of the same model ship
|
||||
the identical, well-formed serialNum 'BS7SP9AW400114A', so keying on it
|
||||
collapsed them onto one identity and the second was refused as already
|
||||
configured. Their `di` differs, which is what makes them separable."""
|
||||
shared_serial = "BS7SP9AW400114A"
|
||||
first = resolve_device_key(
|
||||
_identity(device_id="ccfd73b3-aeb4-792a-1100-68f06f5d603b"), shared_serial, "192.168.0.3"
|
||||
)
|
||||
second = resolve_device_key(
|
||||
_identity(device_id="3771f8bf-c184-3a2d-d885-e4c9818736d2"), shared_serial, "192.168.0.14"
|
||||
)
|
||||
assert first != second
|
||||
assert shared_serial not in (first, second)
|
||||
|
||||
|
||||
def test_platform_id_is_the_fallback_when_oic_d_is_unreadable():
|
||||
ident = _identity(device_id=None, platform_id="ccfd73b3-aeb4-792a-1100-68f06f5d603b")
|
||||
assert resolve_device_key(ident, "REAL-SERIAL", "10.0.0.1") == (
|
||||
"ccfd73b3-aeb4-792a-1100-68f06f5d603b"
|
||||
)
|
||||
|
||||
|
||||
def test_device_id_wins_over_platform_id():
|
||||
"""`pi` is platform-scoped, so a board hosting more than one logical OCF
|
||||
device shares it -- the collision this exists to prevent."""
|
||||
ident = _identity(device_id="dddddddd-0000-1111-2222-333333333333", platform_id="shared-plat")
|
||||
assert resolve_device_key(ident, "REAL-SERIAL", "10.0.0.1") == (
|
||||
"dddddddd-0000-1111-2222-333333333333"
|
||||
)
|
||||
|
||||
|
||||
def test_falls_back_to_the_serial_then_the_host():
|
||||
"""A board that answers neither OCF resource lands exactly where it did
|
||||
before any of this existed -- no regression for existing hardware."""
|
||||
assert resolve_device_key(None, "REAL-SERIAL", "10.0.0.1") == "REAL-SERIAL"
|
||||
assert resolve_device_key(_identity(), "REAL-SERIAL", "10.0.0.1") == "REAL-SERIAL"
|
||||
# ...and a placeholder serial still resolves to the host (#83/#189).
|
||||
assert resolve_device_key(_identity(), "Nothing(SVC)", "10.0.0.1") == "10.0.0.1"
|
||||
|
||||
|
||||
def test_the_key_is_case_normalized():
|
||||
"""The stored key is compared against a freshly polled one on every
|
||||
poll; firmware that changed case between reads would otherwise look
|
||||
like a different appliance every time."""
|
||||
ident = _identity(device_id="CCFD73B3-AEB4-792A-1100-68F06F5D603B")
|
||||
assert resolve_device_key(ident, None, "10.0.0.1") == "ccfd73b3-aeb4-792a-1100-68f06f5d603b"
|
||||
|
||||
|
||||
def test_the_nil_uuid_is_not_an_identity():
|
||||
"""OCF's unset UUID is identical on every unit that never had one
|
||||
assigned -- the #189 failure mode on a new field. Its dashes stop
|
||||
is_placeholder_serial's repeated-digit rule from seeing it, so it needs
|
||||
its own check; falling through to the serial is the right answer."""
|
||||
ident = _identity(device_id="00000000-0000-0000-0000-000000000000")
|
||||
assert resolve_device_key(ident, "REAL-SERIAL", "10.0.0.1") == "REAL-SERIAL"
|
||||
assert not is_usable_device_id("00000000-0000-0000-0000-000000000000")
|
||||
|
||||
|
||||
def test_known_junk_disqualifies_a_uuid_the_same_way_it_does_a_serial():
|
||||
"""A board firmware-flashed with 'Nothing(SVC)' in one identity field is
|
||||
not a board to trust in another."""
|
||||
assert not is_usable_device_id("Nothing(SVC)")
|
||||
assert not is_usable_device_id("FFFFFFFFFFFFFFF")
|
||||
assert not is_usable_device_id("")
|
||||
assert not is_usable_device_id(None)
|
||||
assert is_usable_device_id("3771f8bf-c184-3a2d-d885-e4c9818736d2")
|
||||
|
||||
|
||||
def test_ocf_device_key_reports_absence_rather_than_collapsing_to_the_serial():
|
||||
"""The coordinator needs "the device said nothing" and "the device said
|
||||
this" to be different answers, so it never demotes a UUID-keyed entry
|
||||
onto a serial because one poll couldn't read /oic/d."""
|
||||
assert ocf_device_key(None) is None
|
||||
assert ocf_device_key(_identity(serial="REAL-SERIAL")) is None
|
||||
assert ocf_device_key(_identity(device_id="abc-123")) == "abc-123"
|
||||
|
||||
@@ -369,7 +369,7 @@ class TestCycleSelectTableGating:
|
||||
def test_untranslated_table_uses_generic_cycle_key(self):
|
||||
"""An unknown table does not claim another board's state labels."""
|
||||
desc = self._desc()
|
||||
resources = {"/st/washercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_00"}}
|
||||
resources = {"/st/washercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_99"}}
|
||||
assert desc.translation_key(resources) == "cycle"
|
||||
|
||||
def test_resolves_to_generic_cycle_when_table_id_is_unknown(self):
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
from custom_components.localthings.registry.capabilities.operational import (
|
||||
OPERATIONAL_STATE,
|
||||
_just_finished,
|
||||
_live_progress_code,
|
||||
_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():
|
||||
@@ -41,27 +41,61 @@ class TestJustFinished:
|
||||
assert not _just_finished({"x.com.samsung.da.state": "Run"})
|
||||
|
||||
|
||||
class TestLiveProgressCode:
|
||||
"""`_live_progress_code` is progress/progress_percentage's
|
||||
sticky_bypass_fn (issue #345) -- see sensor.py's _apply_sticky."""
|
||||
class TestNewCycleRunning:
|
||||
"""`_new_cycle_running` is progress/progress_percentage's
|
||||
sticky_bypass_fn -- the early-release condition for the #345 hold.
|
||||
See sensor.py's _apply_sticky."""
|
||||
|
||||
def test_true_for_a_concrete_non_finish_code(self):
|
||||
assert _live_progress_code({"x.com.samsung.da.progress": "Wash"})
|
||||
def test_true_for_a_concrete_non_finish_code_while_active(self):
|
||||
assert _new_cycle_running(
|
||||
{"x.com.samsung.da.state": "Run", "x.com.samsung.da.progress": "Wash"}
|
||||
)
|
||||
|
||||
def test_true_regardless_of_state(self):
|
||||
"""Not gated on machine_state -- a new cycle's own real progress
|
||||
must win over a held hold even while paused (e.g. adding a sock
|
||||
mid-hold), not just while actively running."""
|
||||
assert _live_progress_code(
|
||||
def test_false_for_a_running_stage_reported_after_state_left_active(self):
|
||||
"""Issue #358, the whole reason for the `state` gate: the reporting
|
||||
dryer replays a running stage ('Drying', its first supportedProgress
|
||||
entry) for a few seconds after Finish while winding down. That is
|
||||
the finished cycle's tail, not a new cycle -- releasing the hold on
|
||||
it is what produced 'Drying, Cooling, Finish, Drying, Idle'."""
|
||||
assert not _new_cycle_running(
|
||||
{"x.com.samsung.da.state": "Ready", "x.com.samsung.da.progress": "Drying"}
|
||||
)
|
||||
|
||||
def test_false_while_paused(self):
|
||||
"""Paused is not evidence a new cycle is running, and the tail
|
||||
above can't be told apart from it. rep_fn shows 'Idle' whenever
|
||||
state isn't active anyway, so there is no live value being
|
||||
withheld here -- only a hold that expires on its own instead of
|
||||
being released early."""
|
||||
assert not _new_cycle_running(
|
||||
{"x.com.samsung.da.state": "Pause", "x.com.samsung.da.progress": "Wash"}
|
||||
)
|
||||
|
||||
def test_false_for_finish(self):
|
||||
assert not _live_progress_code({"x.com.samsung.da.progress": "Finish"})
|
||||
assert not _new_cycle_running(
|
||||
{"x.com.samsung.da.state": "Run", "x.com.samsung.da.progress": "Finish"}
|
||||
)
|
||||
|
||||
def test_false_when_absent_or_none(self):
|
||||
assert not _live_progress_code({})
|
||||
assert not _live_progress_code({"x.com.samsung.da.progress": "None"})
|
||||
assert not _new_cycle_running({})
|
||||
assert not _new_cycle_running(
|
||||
{"x.com.samsung.da.state": "Run", "x.com.samsung.da.progress": "None"}
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
|
||||
@@ -11,7 +11,7 @@ from tests.conftest import _load_device
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = "TEST-HOOD-SERIAL"
|
||||
device_key = "TEST-HOOD-SERIAL"
|
||||
device_info: ClassVar[dict] = {}
|
||||
data: ClassVar[dict] = {}
|
||||
|
||||
|
||||
+15
-8
@@ -75,9 +75,18 @@ def test_redact_resources_does_not_mutate_input():
|
||||
assert resources["/information/vs/0"]["x.com.samsung.da.serialNum"] == original_serial
|
||||
|
||||
|
||||
def test_redacts_bare_ocf_identity_keys():
|
||||
"""/oic/d and /oic/p identify the unit with two-letter keys ('di', 'pi')
|
||||
that the substring rules can't see."""
|
||||
def test_keeps_ocf_identity_uuids_but_redacts_the_owner_set_name():
|
||||
"""/oic/d's `di` and /oic/p's `pi` survive redaction.
|
||||
|
||||
They are randomly-assigned per-unit UUIDs, not account data, and they
|
||||
are what the entry's registry keys are minted from (issue #381) -- a
|
||||
report that blanks them hides the identity every entity in it is named
|
||||
after, and can't answer the one question a duplicate-serial report
|
||||
exists to ask: whether two units differ here at all.
|
||||
|
||||
`n` is the opposite case and stays redacted: free text the owner sets
|
||||
from the SmartThings app, so it can carry a person's name.
|
||||
"""
|
||||
redacted = redact_resources(
|
||||
{
|
||||
"/oic/d": {
|
||||
@@ -89,12 +98,10 @@ def test_redacts_bare_ocf_identity_keys():
|
||||
}
|
||||
)
|
||||
|
||||
assert redacted["/oic/d"]["di"] == REDACTED
|
||||
assert redacted["/oic/p"]["pi"] == REDACTED
|
||||
# 'n' is free text the owner sets from the SmartThings app, so it can
|
||||
# carry a person's name -- redacted too. `rt`, the device-type signal
|
||||
# we actually want out of /oic/d, is not.
|
||||
assert redacted["/oic/d"]["di"] == "ab-cd-ef"
|
||||
assert redacted["/oic/p"]["pi"] == "12-34-56"
|
||||
assert redacted["/oic/d"]["n"] == REDACTED
|
||||
# `rt`, the device-type signal we actually want out of /oic/d, is kept.
|
||||
assert redacted["/oic/d"]["rt"] == ["oic.wk.d", "oic.d.refrigerator"]
|
||||
assert redacted["/oic/p"]["mnmo"] == "RF9000B"
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Long-term statistics survive the move onto the OCF device UUID (#381).
|
||||
|
||||
The identity migration rewrites entity registry rows in place rather than
|
||||
letting Home Assistant create replacements. The reason that matters most to
|
||||
an existing user is history: statistics are keyed by `statistic_id`, which
|
||||
for a sensor *is* its entity_id, so an entity that came back as
|
||||
`sensor.foo_2` would leave years of recorded data stranded under a name
|
||||
nothing writes to any more -- silently, with no error and no repair.
|
||||
|
||||
tests/localthings/test_identity_migration.py proves the entity_id is
|
||||
preserved. It cannot prove that preserving it preserves the history,
|
||||
because no recorder is running there. This drives a real in-memory recorder
|
||||
end to end: statistics recorded, entry re-keyed, values read back.
|
||||
|
||||
Lives here rather than under tests/localthings/ for the same reason
|
||||
test_statistics_migration_end_to_end.py does: 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 -- `rekey_entry` is called directly.
|
||||
"""
|
||||
|
||||
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
|
||||
from homeassistant.components.recorder.statistics import (
|
||||
async_import_statistics,
|
||||
get_metadata,
|
||||
statistics_during_period,
|
||||
)
|
||||
from homeassistant.components.recorder.util import get_instance
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
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.const import CONF_HOST, CONF_SERIAL, DOMAIN
|
||||
from custom_components.localthings.rekey import rekey_entry
|
||||
|
||||
OLD_KEY = "BS7SP9AW400114A" # the shared serial from issue #381
|
||||
NEW_KEY = "ccfd73b3-aeb4-792a-1100-68f06f5d603b" # that unit's own /oic/d `di`
|
||||
RECORDED = [11.0, 9.0, 14.0]
|
||||
|
||||
|
||||
async def _seed_statistics(hass: HomeAssistant, entity_id: str) -> None:
|
||||
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 _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_HOST: "192.168.0.3", CONF_SERIAL: OLD_KEY},
|
||||
unique_id=f"{DOMAIN}_{OLD_KEY}",
|
||||
version=3,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
return entry
|
||||
|
||||
|
||||
async def test_recorded_history_survives_the_re_key(
|
||||
recorder_mock, hass: HomeAssistant, purifier_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""The claim the whole in-place rewrite exists to make good on."""
|
||||
dev_reg = dr.async_get(hass)
|
||||
ent_reg = er.async_get(hass)
|
||||
device = dev_reg.async_get_or_create(
|
||||
config_entry_id=purifier_entry.entry_id, identifiers={(DOMAIN, OLD_KEY)}
|
||||
)
|
||||
dust = ent_reg.async_get_or_create(
|
||||
"sensor",
|
||||
DOMAIN,
|
||||
f"{DOMAIN}_{OLD_KEY}_dust",
|
||||
config_entry=purifier_entry,
|
||||
device_id=device.id,
|
||||
suggested_object_id="bedroom_purifier_dust",
|
||||
)
|
||||
await _seed_statistics(hass, dust.entity_id)
|
||||
assert await _means(hass, dust.entity_id) == RECORDED
|
||||
|
||||
rekey_entry(hass, purifier_entry, OLD_KEY, NEW_KEY)
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
# The row moved to the new identity...
|
||||
moved = ent_reg.async_get(dust.entity_id)
|
||||
assert moved is not None
|
||||
assert moved.unique_id == f"{DOMAIN}_{NEW_KEY}_dust"
|
||||
# ...without moving the entity_id, which is what the statistics are
|
||||
# filed under -- so the history is still there, unchanged and still
|
||||
# attached to the entity the user sees.
|
||||
assert moved.entity_id == "sensor.bedroom_purifier_dust"
|
||||
assert await _means(hass, dust.entity_id) == RECORDED
|
||||
|
||||
# The metadata row is still filed under this entity_id too, so the
|
||||
# recorder has not quietly started a second series alongside it.
|
||||
metadata = await get_instance(hass).async_add_executor_job(
|
||||
partial(get_metadata, hass, statistic_ids={dust.entity_id})
|
||||
)
|
||||
assert set(metadata) == {dust.entity_id}
|
||||
|
||||
|
||||
async def test_a_removed_duplicate_does_not_take_the_surviving_history_with_it(
|
||||
recorder_mock, hass: HomeAssistant, purifier_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Where the destination key is already taken, the old-key row is deleted
|
||||
rather than rewritten. The history belongs to whichever entity_id the
|
||||
user has been looking at all along -- the surviving row -- so deleting
|
||||
the dead duplicate must not disturb it.
|
||||
|
||||
This is the one path in the re-key that destroys a registry row, so it
|
||||
is the one worth proving keeps its hands off the recorder.
|
||||
"""
|
||||
ent_reg = er.async_get(hass)
|
||||
live = ent_reg.async_get_or_create(
|
||||
"sensor",
|
||||
DOMAIN,
|
||||
f"{DOMAIN}_{NEW_KEY}_dust",
|
||||
config_entry=purifier_entry,
|
||||
suggested_object_id="bedroom_purifier_dust",
|
||||
)
|
||||
stale = ent_reg.async_get_or_create(
|
||||
"sensor", DOMAIN, f"{DOMAIN}_{OLD_KEY}_dust", config_entry=purifier_entry
|
||||
)
|
||||
assert stale.entity_id != live.entity_id
|
||||
await _seed_statistics(hass, live.entity_id)
|
||||
|
||||
rekey_entry(hass, purifier_entry, OLD_KEY, NEW_KEY)
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
assert ent_reg.async_get(stale.entity_id) is None
|
||||
assert ent_reg.async_get(live.entity_id) is not None
|
||||
assert await _means(hass, live.entity_id) == RECORDED
|
||||
@@ -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,
|
||||
)
|
||||
@@ -17,7 +18,7 @@ from custom_components.localthings.select import LocalThingsSelect
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = "TEST-SERIAL"
|
||||
device_key = "TEST-SERIAL"
|
||||
|
||||
def __init__(self, last_resources):
|
||||
self.last_resources = last_resources
|
||||
@@ -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_key = "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
|
||||
@@ -23,7 +23,7 @@ class _FakeCoordinator:
|
||||
"""Just enough surface for LocalThingsEntity/LocalThingsSensor."""
|
||||
|
||||
def __init__(self, threshold_minutes):
|
||||
self.device_serial = "TEST-SERIAL"
|
||||
self.device_key = "TEST-SERIAL"
|
||||
self.config_entry = _FakeConfigEntry(
|
||||
{
|
||||
CONF_FINISH_TIME_HYSTERESIS_MINUTES: threshold_minutes,
|
||||
|
||||
+144
-32
@@ -50,7 +50,7 @@ class _FakeCoordinator:
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.device_serial = "TEST-SERIAL"
|
||||
self.device_key = "TEST-SERIAL"
|
||||
self.config_entry = _FakeConfigEntry()
|
||||
self.resources: dict[str, dict] = {}
|
||||
|
||||
@@ -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,29 +156,141 @@ 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_paused_new_cycle_also_overrides_the_hold():
|
||||
"""Not just an actively-running new cycle: adding a sock and pausing
|
||||
mid-cycle must also show the real, current progress rather than a
|
||||
stale hold from the previous cycle -- machine_state isn't 'active'
|
||||
while paused, so a bypass keyed on that alone would miss this."""
|
||||
sensor, coordinator = _sensor(_PROGRESS_DESC)
|
||||
def test_a_running_stage_after_finish_does_not_break_the_hold():
|
||||
"""Issue #358: the reporting dryer resets `progress` to its course's
|
||||
first stage in the same moment `state` goes idle -- observed twice,
|
||||
identically, as Cooling -> +60s Finish -> +24s 'Drying' -> +4s
|
||||
settled, with the reporter's machine_state history flipping to idle on
|
||||
the exact second progress reads 'Drying'. The hold must survive that,
|
||||
so the cycle still reads Drying, Cooling, Finish, Idle rather than the
|
||||
reported Drying, Cooling, Finish, Drying, Idle."""
|
||||
desc = replace(_PROGRESS_DESC, sticky_seconds=0.2)
|
||||
sensor, coordinator = _sensor(desc)
|
||||
|
||||
_replace(coordinator, state="Run", progress="Drying", progressPercentage="40")
|
||||
assert sensor.native_value == "drying"
|
||||
|
||||
_replace(coordinator, state="Run", progress="Cooling", progressPercentage="95")
|
||||
assert sensor.native_value == "cooling"
|
||||
|
||||
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
|
||||
assert sensor.native_value == "Finish"
|
||||
_replace(coordinator, state="Ready")
|
||||
assert sensor.native_value == "Finish" # still held
|
||||
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"
|
||||
|
||||
# ...then the device settles, still inside the window.
|
||||
_replace(coordinator, state="Ready", progress="None")
|
||||
assert sensor.native_value == "finish"
|
||||
|
||||
time.sleep(0.25)
|
||||
assert sensor.native_value == "idle"
|
||||
|
||||
|
||||
def test_progress_percentage_survives_the_same_tail():
|
||||
"""#358's tail hits progress_percentage through the identical bypass;
|
||||
it must stay pinned at 100 rather than being released back to a raw
|
||||
mid-cycle figure."""
|
||||
desc = replace(_PROGRESS_PERCENTAGE_DESC, sticky_seconds=0.2)
|
||||
sensor, coordinator = _sensor(desc)
|
||||
|
||||
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
|
||||
assert sensor.native_value == 100
|
||||
|
||||
_replace(coordinator, state="Ready", progress="Drying", progressPercentage="40")
|
||||
assert sensor.native_value == 100
|
||||
|
||||
time.sleep(0.25)
|
||||
assert sensor.native_value == 0
|
||||
|
||||
|
||||
def test_a_paused_new_cycle_is_left_to_the_window_rather_than_released():
|
||||
"""'Paused' isn't positive evidence of a new cycle, and #358's tail is
|
||||
indistinguishable from it. Nothing live is withheld by waiting --
|
||||
rep_fn shows 'Idle' while paused with or without a hold -- so the
|
||||
stale Finish just expires on schedule instead of being cut short."""
|
||||
desc = replace(_PROGRESS_DESC, sticky_seconds=0.05)
|
||||
sensor, coordinator = _sensor(desc)
|
||||
|
||||
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
|
||||
assert sensor.native_value == "finish"
|
||||
|
||||
_replace(coordinator, state="Pause", progress="Wash")
|
||||
assert sensor.native_value == "Wash"
|
||||
assert sensor.native_value == "finish" # held out, not released
|
||||
|
||||
time.sleep(0.1)
|
||||
assert sensor.native_value == "idle" # what a paused appliance always shows
|
||||
|
||||
_replace(coordinator, state="Run", progress="Wash")
|
||||
assert sensor.native_value == "wash"
|
||||
|
||||
|
||||
def test_a_flapping_finish_cannot_ratchet_an_open_window_forward():
|
||||
"""Edge-triggering stops a *stuck* Finish from extending the hold; a
|
||||
progress that flaps out of and back into Finish must not restart it
|
||||
either, or the bound stops being a bound."""
|
||||
desc = replace(_PROGRESS_DESC, sticky_seconds=0.3)
|
||||
sensor, coordinator = _sensor(desc)
|
||||
|
||||
_replace(coordinator, state="Ready", progress="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"
|
||||
_replace(coordinator, state="Ready", progress="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"
|
||||
|
||||
|
||||
def test_a_finish_after_the_window_closes_does_not_re_arm_it():
|
||||
"""Expiry doesn't re-open the door: with no new cycle in between, a
|
||||
second Finish is the same Finish. Re-arming on it would strobe the
|
||||
entity Finish -> Idle -> Finish once per window, re-firing exactly the
|
||||
announcements #345 is about -- so it takes a bypass (a cycle actually
|
||||
running) to make the hold available again.
|
||||
|
||||
Distinct from the flap test above: there the window is still open, and
|
||||
the last read before expiry leaves the sticky condition *matching*.
|
||||
Here it has already closed, and the flap ends on a non-matching read,
|
||||
which is the state a spent-on-arm-only guard would let re-arm."""
|
||||
desc = replace(_PROGRESS_DESC, sticky_seconds=0.05)
|
||||
sensor, coordinator = _sensor(desc)
|
||||
|
||||
_replace(coordinator, state="Ready", progress="Finish")
|
||||
assert sensor.native_value == "finish"
|
||||
|
||||
time.sleep(0.1)
|
||||
assert sensor.native_value == "idle"
|
||||
|
||||
_replace(coordinator, state="Ready", progress="None")
|
||||
assert sensor.native_value == "idle"
|
||||
_replace(coordinator, state="Ready", progress="Finish")
|
||||
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"
|
||||
_replace(coordinator, state="Run", progress="Finish")
|
||||
assert sensor.native_value == "finish"
|
||||
_replace(coordinator, state="Ready", progress="None")
|
||||
assert sensor.native_value == "finish"
|
||||
|
||||
|
||||
def test_hold_expires_after_sticky_seconds():
|
||||
@@ -192,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():
|
||||
@@ -213,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():
|
||||
@@ -249,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
|
||||
|
||||
|
||||
@@ -265,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
|
||||
@@ -147,7 +147,7 @@ async def test_pattern_a_sub1_device_info_links_via_device_to_master(hass: HomeA
|
||||
sub1 = next(su for su in coordinator.subdevices if su.key == "1")
|
||||
info = coordinator.device_info_for(sub1)
|
||||
|
||||
master_serial = coordinator.device_serial
|
||||
master_serial = coordinator.device_key
|
||||
assert info["identifiers"] == {(DOMAIN, f"{master_serial}_1")}
|
||||
assert info["via_device"] == (DOMAIN, master_serial)
|
||||
# The subdevice's own /information/vs/1 (real, ARTIK051_DONGLE_FAC_RAC_18K)
|
||||
@@ -245,7 +245,7 @@ async def test_fac_bora_2in1_subdevice_device_info(hass: HomeAssistant):
|
||||
subdevice = coordinator.subdevices[0]
|
||||
info = coordinator.device_info_for(subdevice)
|
||||
|
||||
master_serial = coordinator.device_serial
|
||||
master_serial = coordinator.device_key
|
||||
assert info["identifiers"] == {(DOMAIN, f"{master_serial}_{_SUB_UUID}")}
|
||||
assert info["via_device"] == (DOMAIN, master_serial)
|
||||
# Confirmed live by the reporter (DESIGN-177.md section 1): the wall
|
||||
@@ -266,7 +266,7 @@ async def test_fac_bora_2in1_unique_ids_include_subdevice_prefix(hass: HomeAssis
|
||||
entity = LocalThingsEntity(coordinator, sub_climate)
|
||||
expected_slug = _SUB_UUID.replace("-", "")
|
||||
assert entity._attr_unique_id == (
|
||||
f"{DOMAIN}_{coordinator.device_serial}_subdevice_{expected_slug}_climate"
|
||||
f"{DOMAIN}_{coordinator.device_key}_subdevice_{expected_slug}_climate"
|
||||
)
|
||||
|
||||
|
||||
|
||||
+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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user