Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2e0485b03 | ||
|
|
01c9dc9801 | ||
|
|
f780cc6069 | ||
|
|
d8ebc17808 | ||
|
|
367017cc4c | ||
|
|
ccb4a09c65 | ||
|
|
1b8e8368ca | ||
|
|
c7aa66ef6e | ||
|
|
1e9fbd4ec5 | ||
|
|
81dbc6fa03 | ||
|
|
d912bff5d3 | ||
|
|
330479d389 | ||
|
|
c0ef45c7c8 | ||
|
|
d5dc6421ba | ||
|
|
93cd6ae6fe | ||
|
|
ffc0eec322 | ||
|
|
f5d9f0e31b | ||
|
|
55486526cc | ||
|
|
a18663d5e0 | ||
|
|
322e436c22 |
@@ -85,7 +85,9 @@ 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 alone -- `Samsung Refrigerator`, `Samsung Air Conditioner` -- since Home Assistant builds every entity_id on the device out of that name, giving you `sensor.samsung_refrigerator_energy`. The model is registered on the device itself rather than folded into the name, so a board string like `ARTIK051_DONGLE_REF` shows up on the device page instead of in 46 entity_ids. 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.)
|
||||
|
||||
Upgrading from a release that put the model in the name renames the device, but Home Assistant assigns an entity_id once, when the entity is first registered -- existing entity_ids, and every automation referencing them, are untouched. Rename the entities yourself if you want the shorter ids retroactively.
|
||||
|
||||
---
|
||||
|
||||
@@ -237,7 +239,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.
|
||||
|
||||
@@ -278,7 +281,7 @@ This only applies to an appliance the integration has reached at least once. A b
|
||||
|
||||
### 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.
|
||||
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 (named `... Unit 2`, `Unit 3`, and 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.
|
||||
|
||||
Two on-the-wire shapes are supported, both keyed off what the appliance itself reports:
|
||||
|
||||
|
||||
@@ -7,10 +7,8 @@ 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 (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER as PARTICULATE_UNIT,
|
||||
)
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
@@ -21,10 +19,27 @@ from homeassistant.helpers.typing import ConfigType
|
||||
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
|
||||
@@ -68,67 +83,20 @@ 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
|
||||
|
||||
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):
|
||||
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:
|
||||
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
|
||||
)
|
||||
rekey_entry(hass, entry, host, serial)
|
||||
|
||||
|
||||
# Registries whose Dust/FineDust/SuperFineDust sensors gained pm10/pm25/pm1
|
||||
@@ -235,8 +203,22 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
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 > 3:
|
||||
if entry.version > 4:
|
||||
return False # downgrade: this release doesn't know the newer shape
|
||||
|
||||
if entry.version == 1:
|
||||
@@ -254,6 +236,28 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
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
|
||||
|
||||
|
||||
@@ -303,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
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -660,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)
|
||||
|
||||
@@ -678,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,
|
||||
@@ -796,7 +811,10 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
# v3 relabels the particulate sensors' recorded statistics; a freshly
|
||||
# created entry has none to relabel, so it starts at the migrated
|
||||
# version rather than walking through v2 (see async_migrate_entry).
|
||||
VERSION = 3
|
||||
# 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 = ""
|
||||
@@ -815,14 +833,14 @@ 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).
|
||||
"""
|
||||
from .registry.identity import device_display_name
|
||||
|
||||
return self.async_create_entry(
|
||||
title=f"{device_display_name(info['device_type_name'], '')} ({self._host})",
|
||||
title=f"{device_display_name(info['device_type_name'])} ({self._host})",
|
||||
data={
|
||||
CONF_HOST: self._host,
|
||||
CONF_PORT: info["port"],
|
||||
@@ -830,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"],
|
||||
@@ -876,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)
|
||||
@@ -991,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,
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -1038,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
|
||||
|
||||
@@ -30,6 +30,8 @@ from .cloudcourse import persist as cloud_persist
|
||||
from .const import (
|
||||
CONF_BYPASS_REMOTE_CONTROL,
|
||||
CONF_CLOUD_COURSES,
|
||||
CONF_CLOUD_COURSES_ENABLED,
|
||||
CONF_DEVICE_KEY,
|
||||
CONF_DEVICE_TYPE,
|
||||
CONF_HOST,
|
||||
CONF_LEAF_CERT_PEM,
|
||||
@@ -40,6 +42,7 @@ from .const import (
|
||||
CONF_MODEL,
|
||||
CONF_PORT,
|
||||
CONF_SERIAL,
|
||||
DEFAULT_CLOUD_COURSES_ENABLED,
|
||||
DEFAULT_LEARN_MODES,
|
||||
DEVICE_SUPPORT_ISSUE_URL,
|
||||
DOMAIN,
|
||||
@@ -64,6 +67,7 @@ from .registry.entities import ClimateDesc
|
||||
from .registry.identity import (
|
||||
DeviceIdentity,
|
||||
device_display_name,
|
||||
ocf_device_key,
|
||||
read_identity,
|
||||
resolve_model,
|
||||
resolve_serial,
|
||||
@@ -76,6 +80,7 @@ from .registry.subdevices import (
|
||||
enumerate_subdevices,
|
||||
normalize_seed_batch,
|
||||
)
|
||||
from .rekey import rekey_entry
|
||||
|
||||
# Sentinel for apply_cloud_courses: "leave this field as it is",
|
||||
# distinct from None which means "clear it".
|
||||
@@ -203,7 +208,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
bound: list[BoundEntity]
|
||||
device_info: DeviceInfo
|
||||
device_serial: str
|
||||
device_key: str
|
||||
|
||||
# Class-level so tests can shrink these via patch.object() without
|
||||
# touching the production defaults.
|
||||
@@ -308,18 +313,18 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._push_pending = False
|
||||
self._push_pending_lock = threading.Lock()
|
||||
# Identity is resolved once by the config flow's probe (issue #236).
|
||||
# device_serial mints permanent registry keys, so it must be correct
|
||||
# device_key mints permanent registry keys, so it must be correct
|
||||
# before the first entity registers -- a placeholder corrected once
|
||||
# the first poll lands orphans the first device/entity pair instead.
|
||||
# The host fallback covers a pre-migration entry and matches what
|
||||
# resolve_serial itself returns for a placeholder-serial board
|
||||
# (issues #83/#189).
|
||||
self.device_serial = entry.data.get(CONF_SERIAL) or entry.data[CONF_HOST]
|
||||
# Ordered so an entry that has not polled since upgrading still
|
||||
# loads under the key its registry rows already carry: the v4 UUID
|
||||
# (issue #381), else the pre-v4 serial, else the host (#83/#189).
|
||||
self.device_key = (
|
||||
entry.data.get(CONF_DEVICE_KEY) or entry.data.get(CONF_SERIAL) or entry.data[CONF_HOST]
|
||||
)
|
||||
self.device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self.device_serial)},
|
||||
name=device_display_name(
|
||||
entry.data.get(CONF_DEVICE_TYPE), entry.data.get(CONF_MODEL) or ""
|
||||
),
|
||||
identifiers={(DOMAIN, self.device_key)},
|
||||
name=device_display_name(entry.data.get(CONF_DEVICE_TYPE)),
|
||||
manufacturer=entry.data.get(CONF_MANUFACTURER) or "Samsung",
|
||||
model=entry.data.get(CONF_MODEL) or None,
|
||||
)
|
||||
@@ -379,12 +384,21 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
rep = snapshot.get(cloudcourse.COURSE_HREF)
|
||||
if rep is None:
|
||||
return snapshot
|
||||
view = self._cloud.view()
|
||||
view = self._cloud_view()
|
||||
if not view:
|
||||
return snapshot
|
||||
snapshot[cloudcourse.COURSE_HREF] = {**rep, cloudcourse.FIELD: view}
|
||||
return snapshot
|
||||
|
||||
def _cloud_view(self) -> dict:
|
||||
"""`self._cloud.view()`, or nothing while cloud_courses_enabled is
|
||||
off (issue #364) -- entity_resources/entity_rep's one gate so a
|
||||
previously-named program stops being offered the moment the option
|
||||
is turned off, symmetric with how it starts being offered again the
|
||||
moment it's turned back on. The store itself is untouched either
|
||||
way; only what these two hand to the registry changes."""
|
||||
return self._cloud.view() if self.cloud_courses_enabled else {}
|
||||
|
||||
def entity_rep(self, href: str) -> dict:
|
||||
"""One href's rep as descriptors see it -- `resource()` plus the
|
||||
merge `entity_resources` would have applied. Exists so the write path
|
||||
@@ -393,7 +407,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
rep = self.resource(href)
|
||||
if href != cloudcourse.COURSE_HREF or not rep:
|
||||
return rep
|
||||
view = self._cloud.view()
|
||||
view = self._cloud_view()
|
||||
return {**rep, cloudcourse.FIELD: view} if view else rep
|
||||
|
||||
def device_resources(self, subdevice: Subdevice) -> dict[str, dict]:
|
||||
@@ -540,15 +554,29 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# Cloud "Download" programs (issue #342) -- see cloudcourse.py
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def cloud_courses_enabled(self) -> bool:
|
||||
"""Whether downloaded programs are offered as cycles and nagged
|
||||
about via the "not set up yet" Repair (issue #364).
|
||||
|
||||
Deliberately does NOT gate `_observe_cloud_courses` below -- see
|
||||
CONF_CLOUD_COURSES_ENABLED's own comment for why passive recording
|
||||
keeps running (cheap, and what guided/manual setup depend on)
|
||||
while only the Repair and the entity offering stop.
|
||||
"""
|
||||
return bool(
|
||||
self._entry.options.get(CONF_CLOUD_COURSES_ENABLED, DEFAULT_CLOUD_COURSES_ENABLED)
|
||||
)
|
||||
|
||||
def _observe_cloud_courses(self, rep: dict) -> None:
|
||||
"""Learn a downloaded program's replay payload from one applied
|
||||
/course/vs/0 rep. Runs on whichever thread applied the update, so
|
||||
both the persist and the Repairs refresh go through hass.add_job.
|
||||
|
||||
Unlike learned modes this has no opt-out option: it records only
|
||||
Always records, regardless of cloud_courses_enabled: it records only
|
||||
what the appliance itself reports about programs it itself
|
||||
advertises, and nothing is offered in the UI until the user names it.
|
||||
"""
|
||||
advertises, and nothing is offered in the UI until both the option
|
||||
is on and the user has named it."""
|
||||
if not self._cloud.observe(rep):
|
||||
return
|
||||
self.hass.add_job(self._persist_cloud_courses)
|
||||
@@ -556,9 +584,33 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
@callback
|
||||
def _persist_cloud_courses(self) -> None:
|
||||
cloud_persist(self.hass, self._entry, self._cloud.snapshot())
|
||||
# A newly learned program changes what entity_resources() hands out.
|
||||
self._on_cloud_courses_changed()
|
||||
|
||||
@callback
|
||||
def _on_cloud_courses_changed(self) -> None:
|
||||
"""Refresh everything that depends on the cloud-course store or the
|
||||
cloud_courses_enabled option: the canonical-view cache (a newly
|
||||
learned/named program, or the option flipping, changes what
|
||||
entity_resources() hands out), the live entities, and the Repair.
|
||||
|
||||
Also the __init__.py options-update listener's one hook (issue
|
||||
#364): toggling cloud_courses_enabled changes _cloud_view()'s
|
||||
answer without touching last_resources, so a canonical view cached
|
||||
from before the toggle would otherwise keep answering with it.
|
||||
|
||||
_push_cache_snapshot is what actually gets a change here in front
|
||||
of a user, not just correct on the next read: select.py's
|
||||
current_option comes from coordinator.data, which only moves on
|
||||
async_set_updated_data, and nothing prompts Home Assistant to
|
||||
re-read a live `options` property (cycle's callable options included)
|
||||
without the state-changed signal that call sends. Without it, both
|
||||
this and a name applied through apply_cloud_courses -- which has
|
||||
called this same method since before this option existed -- would
|
||||
sit stale until whatever poll or observe happened to run next.
|
||||
"""
|
||||
self._canonical_cache.clear()
|
||||
self._refresh_cloud_course_issue()
|
||||
self._push_cache_snapshot()
|
||||
|
||||
@property
|
||||
def cloud_courses(self) -> CloudCourses:
|
||||
@@ -638,9 +690,33 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
nothing they do in Home Assistant could clear it. Running one
|
||||
downloaded program is what turns the nudge on, and naming them is
|
||||
what turns it off.
|
||||
|
||||
Also gated on cloud_courses_enabled (issue #364): a device can seed a
|
||||
slot's payload before the owner has ever meant to use the feature --
|
||||
reporters observed one auto-populate from a SmartThings-provided
|
||||
example -- so "at least one payload seen" alone isn't proof of
|
||||
intent to use it. Checked here rather than skipping
|
||||
_observe_cloud_courses' recording, so turning the option back on
|
||||
re-evaluates against everything already learned instead of only
|
||||
what arrives afterward.
|
||||
|
||||
Also a no-op with an empty cloud_course_rep (issue #364): this now
|
||||
runs from __init__.py's options-update listener on every entry
|
||||
save, including an unrelated one (CONF_BYPASS_REMOTE_CONTROL, say)
|
||||
made before this device's first poll or while it's rehydrated
|
||||
offline. /course/vs/0 unpolled reads as no advertised slots, which
|
||||
would otherwise delete a Repair a real poll had every reason to
|
||||
raise, on evidence that only means "haven't asked the device yet."
|
||||
Leaves whatever issue state already exists untouched rather than
|
||||
guess either way; the next real poll re-evaluates for real.
|
||||
"""
|
||||
issue_id = f"cloud_courses_{self._entry.entry_id}"
|
||||
if not self.cloud_courses_enabled:
|
||||
ir.async_delete_issue(self.hass, DOMAIN, issue_id)
|
||||
return
|
||||
rep = self.cloud_course_rep()
|
||||
if not rep:
|
||||
return
|
||||
record = self._cloud.snapshot()
|
||||
courses = cycle_options(self.canonical_resources(MAIN))
|
||||
pending = cloudcourse.undiscovered(rep, record, courses)
|
||||
@@ -682,22 +758,16 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
model = model_num.split("|", 1)[0] if model_num else ""
|
||||
serial = info.get("x.com.samsung.da.serialNum") or None
|
||||
base_name = self.device_info.get("name") or "Samsung Appliance"
|
||||
if model:
|
||||
label = model.replace("_", " ").title()
|
||||
else:
|
||||
# No identity resource yet (or ever) for this subdevice -- fall
|
||||
# back to a generic label. 'Subdevice <n>' only applies to an
|
||||
# indexed subdevice; UUID-prefixed ones are never more than one
|
||||
# per connection today.
|
||||
label = (
|
||||
f"Subdevice {subdevice.key}"
|
||||
if subdevice.kind == "indexed"
|
||||
else "Secondary Subdevice"
|
||||
)
|
||||
# Positional, not this subdevice's own modelNum: that is a board
|
||||
# string ('TP2X_FAC_BORA_RAC_21K') and this name is what HA
|
||||
# slugifies into the subdevice's entity_ids, same rule as
|
||||
# device_display_name. The master is unit 1 -- `subdevices` never
|
||||
# includes it -- so the first sibling is unit 2.
|
||||
ordinal = self.subdevices.index(subdevice) + 2 if subdevice in self.subdevices else 2
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{self.device_serial}_{subdevice.key}")},
|
||||
via_device=(DOMAIN, self.device_serial),
|
||||
name=f"{base_name} {label}",
|
||||
identifiers={(DOMAIN, f"{self.device_key}_{subdevice.key}")},
|
||||
via_device=(DOMAIN, self.device_key),
|
||||
name=f"{base_name} Unit {ordinal}",
|
||||
manufacturer=self.device_info.get("manufacturer") or "Samsung",
|
||||
model=model or None,
|
||||
serial_number=serial,
|
||||
@@ -1020,8 +1090,87 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._skipped_subdevice_resources = skipped
|
||||
return kept
|
||||
|
||||
def _resolve_identity(self, polled_serial: str, *, from_snapshot: bool) -> tuple[str, str]:
|
||||
"""The (key, serial) this entry should be registered under, re-keying
|
||||
the registries if the key has moved.
|
||||
|
||||
Key and serial are returned together because they have to agree: the
|
||||
serial corroborates a later change of key, so adopting it while
|
||||
*defending* the stored key would hand a different appliance the
|
||||
corroboration it needs to win the next poll.
|
||||
|
||||
Nothing changes a key without calling `rekey_entry` in the same
|
||||
breath, or the existing registry rows are orphaned (issue #236).
|
||||
Two things are therefore never treated as a change of identity: a
|
||||
snapshot replay (issue #295), which never reached the device, and a
|
||||
poll that read no UUID, since the device saying nothing is not the
|
||||
device saying something different.
|
||||
|
||||
A genuine difference is either the registered appliance under a new
|
||||
identity -- the move onto the OCF device UUID (issue #381), or a
|
||||
`di` regenerated by a hard reset -- or a different appliance on this
|
||||
address, and the serialNum is what tells them apart. That test is
|
||||
deliberately the same before and after an entry has adopted a UUID:
|
||||
a pre-v4 entry has been running longest, so it is the last one that
|
||||
should lose its rows to whatever now answers at its address.
|
||||
"""
|
||||
host = self._entry.data[CONF_HOST]
|
||||
stored_serial = self._entry.data.get(CONF_SERIAL)
|
||||
if from_snapshot:
|
||||
return self.device_key, stored_serial or polled_serial
|
||||
|
||||
polled_ocf = ocf_device_key(self._identity)
|
||||
stored_key = self._entry.data.get(CONF_DEVICE_KEY)
|
||||
# What this entry's rows carry today: a pre-v4 entry has no
|
||||
# CONF_DEVICE_KEY, so it is whatever __init__ fell back to.
|
||||
current_key = stored_key if stored_key is not None else (stored_serial or host)
|
||||
polled_key = polled_ocf or polled_serial
|
||||
|
||||
if polled_key == current_key:
|
||||
# Returned rather than short-circuited so a pre-v4 entry records
|
||||
# the key it has always had, and the next poll sees no change.
|
||||
return current_key, polled_serial
|
||||
|
||||
if stored_key is not None and polled_ocf is None:
|
||||
return stored_key, stored_serial or polled_serial
|
||||
|
||||
# Excluding the host answer keeps this from firing on two *different*
|
||||
# placeholder-serial units: an address is not an identity and can't
|
||||
# corroborate anything.
|
||||
same_unit = polled_serial == stored_serial and polled_serial != host
|
||||
# A host-keyed entry (issues #83/#189) is registered against whatever
|
||||
# answers at this address, so it has no claim to defend and needs no
|
||||
# corroboration -- demanding it would strand exactly the
|
||||
# placeholder-serial boards this exists to rescue. Same for an entry
|
||||
# with no stored serial to compare against.
|
||||
if not (current_key == host or same_unit or stored_serial is None):
|
||||
self._log.warning(
|
||||
"device at %s identifies as %r but this entry is registered as %r "
|
||||
"(serial %r, registered %r); keeping the registered identity",
|
||||
host,
|
||||
polled_key,
|
||||
current_key,
|
||||
polled_serial,
|
||||
stored_serial,
|
||||
)
|
||||
return current_key, stored_serial or polled_serial
|
||||
|
||||
# Corroborated: a pre-v4 entry moving onto its UUID, the same unit
|
||||
# with a regenerated `di`, or a host-keyed entry learning a real
|
||||
# identity. Following it keeps the user's entity_ids and history.
|
||||
self._log.info(
|
||||
"device %s (serial %r) changed key from %r to %r; following it",
|
||||
host,
|
||||
polled_serial,
|
||||
current_key,
|
||||
polled_key,
|
||||
)
|
||||
rekey_entry(self.hass, self._entry, current_key, polled_key)
|
||||
return polled_key, polled_serial
|
||||
|
||||
def _persist_identity(
|
||||
self,
|
||||
device_key: str | None,
|
||||
serial: str,
|
||||
model: str,
|
||||
manufacturer: str,
|
||||
@@ -1035,9 +1184,17 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
means the next restart names the device fully instead of renaming it
|
||||
again once a poll lands.
|
||||
|
||||
`device_key` is what the registries are keyed on; `serial` is stored
|
||||
alongside it rather than replaced by it, because it is what
|
||||
`_resolve_identity` corroborates a changed UUID against on a later
|
||||
poll.
|
||||
None leaves whatever key is already stored untouched -- see the
|
||||
caller for why a snapshot replay must not write one.
|
||||
|
||||
Runs on the event loop, which async_update_entry requires.
|
||||
"""
|
||||
identity = {
|
||||
**({CONF_DEVICE_KEY: device_key} if device_key is not None else {}),
|
||||
CONF_SERIAL: serial,
|
||||
CONF_MODEL: model,
|
||||
CONF_MANUFACTURER: manufacturer,
|
||||
@@ -1122,6 +1279,12 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
model=ident.get("model") or "",
|
||||
name=ident.get("name") or "",
|
||||
serial=ident.get("serial"),
|
||||
# Absent from a snapshot written before these fields
|
||||
# existed; _resolve_identity never re-keys from a
|
||||
# replay, so a missing UUID here costs nothing beyond
|
||||
# diagnostics.
|
||||
device_id=ident.get("device_id"),
|
||||
platform_id=ident.get("platform_id"),
|
||||
device_types=tuple(ident.get("device_types") or ()),
|
||||
raw=ident.get("raw") or {},
|
||||
)
|
||||
@@ -1273,39 +1436,28 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._unbound_hrefs = unbound
|
||||
self._refresh_learnable_hrefs()
|
||||
|
||||
# The entry's stored identity wins; this poll's answer is only
|
||||
# adopted when nothing is stored (a legacy migration couldn't
|
||||
# recover it), then written back. Re-keying an entry with existing
|
||||
# registry entries orphans them (issue #236).
|
||||
polled_serial = resolve_serial(
|
||||
info.get("x.com.samsung.da.serialNum"), self._entry.data[CONF_HOST]
|
||||
)
|
||||
serial = self._entry.data.get(CONF_SERIAL) or polled_serial
|
||||
if serial != polled_serial:
|
||||
# Same IP, different appliance (or firmware that changed what it
|
||||
# reports) -- keep the registered identity; re-adding is the
|
||||
# user's call.
|
||||
self._log.warning(
|
||||
"device at %s reports serial %r but this entry is registered "
|
||||
"as %r; keeping the registered identity",
|
||||
self._entry.data[CONF_HOST],
|
||||
polled_serial,
|
||||
serial,
|
||||
)
|
||||
self.device_serial = serial
|
||||
key, serial = self._resolve_identity(polled_serial, from_snapshot=from_snapshot)
|
||||
self.device_key = key
|
||||
|
||||
ident = self._identity
|
||||
model = resolve_model(model_num, ident)
|
||||
name = device_display_name(device_type_name, model)
|
||||
name = device_display_name(device_type_name)
|
||||
mfr = (ident.manufacturer if ident else "") or "Samsung"
|
||||
|
||||
self.device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, serial)},
|
||||
identifiers={(DOMAIN, key)},
|
||||
name=name,
|
||||
manufacturer=mfr,
|
||||
model=model,
|
||||
)
|
||||
self._persist_identity(serial, model, mfr, device_type_name)
|
||||
# A snapshot replay passes None: it never reached the device, so
|
||||
# writing a key here would freeze a pre-v4 entry's legacy key in as
|
||||
# if a poll had confirmed it, and the real UUID would later look
|
||||
# like an identity to defend against rather than one to adopt.
|
||||
self._persist_identity(None if from_snapshot else key, serial, model, mfr, device_type_name)
|
||||
if not from_snapshot:
|
||||
# A coverage gap is a claim about what the device reports, so only
|
||||
# a live poll gets to make it. Replaying a snapshot would restate
|
||||
@@ -1319,9 +1471,9 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
self._discovered = True
|
||||
self._log.info(
|
||||
"discovered %d entities (serial=%s) hot=%s warm=%s subdevices=%s",
|
||||
"discovered %d entities (key=%s) hot=%s warm=%s subdevices=%s",
|
||||
len(bound),
|
||||
serial,
|
||||
key,
|
||||
self._hot_hrefs,
|
||||
self._warm_hrefs,
|
||||
[su.key for su in self.subdevices],
|
||||
|
||||
@@ -88,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:
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"pyOpenSSL>=23.0",
|
||||
"smartthings-local>=0.1.8"
|
||||
],
|
||||
"version": "0.22.0"
|
||||
"version": "0.24.0-beta.1"
|
||||
}
|
||||
|
||||
@@ -10,7 +10,12 @@ wash, auto release dry) are read locally here.
|
||||
from ..capability import Capability
|
||||
from ..entities import ButtonDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||
from .common import diagnosis_status
|
||||
from .laundry import bool_option_switch, cycle_select
|
||||
from .laundry import (
|
||||
bool_option_switch,
|
||||
cycle_select,
|
||||
drum_clean_cycles_remaining,
|
||||
drum_clean_last_cleaned,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /dishwasher/vs/0 — cycle wash/dry settings
|
||||
@@ -52,6 +57,14 @@ 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 the
|
||||
# shared laundry.drum_clean_cycles_remaining/drum_clean_last_cleaned readers
|
||||
# apply unchanged; see laundry.py for the field semantics.
|
||||
|
||||
CYCLE_OPTIONS = Capability(
|
||||
href="/course/vs/0",
|
||||
@@ -61,6 +74,22 @@ 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,
|
||||
),
|
||||
SensorDesc(
|
||||
key="drum_clean_last_cleaned",
|
||||
device_class="timestamp",
|
||||
icon="mdi:calendar-clock",
|
||||
entity_category="diagnostic",
|
||||
exists_fn=lambda rep, resources: drum_clean_last_cleaned(rep) is not None,
|
||||
rep_fn=drum_clean_last_cleaned,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -72,14 +135,37 @@ def resolve_model(model_num: str, identity: DeviceIdentity | None) -> str:
|
||||
return identity.model if identity else ""
|
||||
|
||||
|
||||
def device_display_name(device_type_name: str | None, model: str) -> str:
|
||||
"""The HA device name for a resolved device type + model. Shared by the
|
||||
config flow and the coordinator's post-discovery rebuild, so the name a
|
||||
device is first registered under matches what discovery produces later
|
||||
-- otherwise every setup would rename the device once the first poll
|
||||
landed."""
|
||||
device_type = device_type_name.replace("_", " ").title() if device_type_name else "Appliance"
|
||||
return f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}"
|
||||
# Registry type names whose title-cased form isn't what the appliance is
|
||||
# called: 'airconditioner' is one word only because SmartThings' own type
|
||||
# string is, and 'ehs' is Samsung's internal name for the air-to-water
|
||||
# heat pump (see by_type/ehs.py).
|
||||
_DISPLAY_TYPE_NAMES = {
|
||||
"airconditioner": "Air Conditioner",
|
||||
"ehs": "Heat Pump",
|
||||
}
|
||||
|
||||
|
||||
def device_display_name(device_type_name: str | None) -> str:
|
||||
"""The HA device name for a resolved device type.
|
||||
|
||||
Deliberately carries no model: HA slugifies this name into the
|
||||
entity_id of every entity the device registers, and modelNum is a
|
||||
board string, so folding it in produced ids like
|
||||
`sensor.samsung_refrigerator_artik051_dongle_ref_energy`. The model
|
||||
still reaches the UI through DeviceInfo's own `model` field, which
|
||||
nothing derives an entity_id from.
|
||||
|
||||
Shared by the config flow and the coordinator's post-discovery
|
||||
rebuild, so the name a device is first registered under matches what
|
||||
discovery produces later -- otherwise every setup would rename the
|
||||
device once the first poll landed.
|
||||
"""
|
||||
if not device_type_name:
|
||||
return "Samsung Appliance"
|
||||
device_type = _DISPLAY_TYPE_NAMES.get(
|
||||
device_type_name, device_type_name.replace("_", " ").title()
|
||||
)
|
||||
return f"Samsung {device_type}"
|
||||
|
||||
|
||||
def _get(sess, path) -> dict:
|
||||
@@ -143,6 +229,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
|
||||
)
|
||||
@@ -187,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:
|
||||
|
||||
@@ -376,7 +376,25 @@
|
||||
"53": "AI sušení+",
|
||||
"4e": "Automatické sušení",
|
||||
"26": "Provětrání",
|
||||
"2a": "Hygienické sušení+"
|
||||
"2a": "Hygienické sušení+",
|
||||
"02": "AI sušení",
|
||||
"03": "Super Speed",
|
||||
"05": "Ložní prádlo",
|
||||
"07": "Jemné prádlo",
|
||||
"09": "Košile",
|
||||
"0b": "Péče o prošívané bundy",
|
||||
"0c": "Outdoor impregnace",
|
||||
"0e": "Ručníky",
|
||||
"0f": "Vlna",
|
||||
"11": "Studený vzduch",
|
||||
"28": "Vnitřní dezinfekce horkým vzduchem",
|
||||
"37": "Halenky",
|
||||
"38": "Žehlení",
|
||||
"39": "Odvlhčování místnosti",
|
||||
"3a": "Hygienické sušení",
|
||||
"3b": "Ložní prádlo/Odstranění prachu",
|
||||
"3c": "Sportovní oblečení",
|
||||
"3d": "Džíny"
|
||||
}
|
||||
},
|
||||
"favorite_capacity": {
|
||||
@@ -647,11 +665,31 @@
|
||||
"name": "Cyklus",
|
||||
"state": {
|
||||
"01": "Normální",
|
||||
"02": "Extra intenzivní",
|
||||
"03": "Super Eco",
|
||||
"04": "Rychlé praní",
|
||||
"06": "XXL prádlo",
|
||||
"05": "Vlna/Spodní prádlo",
|
||||
"06": "Ložní prádlo",
|
||||
"07": "Outdoor",
|
||||
"08": "Máchání+odstřeďování",
|
||||
"09": "Čištění bubnu",
|
||||
"0a": "Ručníky",
|
||||
"0b": "Praní varem",
|
||||
"0c": "Dětské potřeby",
|
||||
"0d": "Pouze odstřeďování",
|
||||
"0e": "Zataženo",
|
||||
"0f": "Čisté praní",
|
||||
"10": "Sušicí odstřeďování",
|
||||
"11": "Letní ložní prádlo",
|
||||
"12": "Bavlna",
|
||||
"13": "Černá bavlna",
|
||||
"14": "Jemné spodní prádlo",
|
||||
"15": "Sportovní oblečení",
|
||||
"16": "Halenky",
|
||||
"17": "Stažený program",
|
||||
"18": "Soft Bubble",
|
||||
"19": "AI praní",
|
||||
"1a": "Košile",
|
||||
"1b": "Bavlna",
|
||||
"1c": "Eco 40-60",
|
||||
"1d": "Super rychlé",
|
||||
@@ -676,6 +714,7 @@
|
||||
"32": "Košile",
|
||||
"33": "Ručníky",
|
||||
"34": "Smíšené",
|
||||
"35": "Eko bavlna",
|
||||
"36": "Praní+sušení",
|
||||
"37": "Sušení vzduchem",
|
||||
"38": "Sušení bavlny",
|
||||
@@ -717,8 +756,7 @@
|
||||
"8f": "Intenzivní studená",
|
||||
"96": "Méně mikrovláken",
|
||||
"a0": "15min rychlé praní",
|
||||
"b0": "Smíšená náplň",
|
||||
"35": "Eko bavlna"
|
||||
"b0": "Smíšená náplň"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
@@ -1484,11 +1522,12 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Nastavení zápisu pro dálkové ovládání",
|
||||
"description": "Některá zařízení přijímají určité zápisy (např. výchozí dávkování pracího prostředku/aviváže v pračce) i když hlásí vypnuté dálkové ovládání. LocalThings ve výchozím nastavení blokuje každý zápis srozumitelnou chybou, kdykoli zařízení hlásí vypnuté dálkové ovládání, místo aby jej zařízení tiše odmítlo. Povolte toto pouze tehdy, pokud jste ověřili, že zápisy na tomto zařízení skutečně fungují i s vypnutým dálkovým ovládáním – jinak tuto srozumitelnou chybu vyměníte za tiché selhání.\n\nNěkterá zařízení hlásí režim, který nikdy neuvedou mezi podporovanými – například klimatizace běžící v režimu Quiet, která nabízí pouze Off/Sleep/Speed. LocalThings si každý takový režim zapamatuje a dál jej nabízí, takže zůstane volitelný, jakmile v něm zařízení alespoň jednou bylo. Vypnutím této volby se budou nabízet pouze režimy, které zařízení samo uvádí; k vymazání již zapamatovaných použijte „Zapomenout zapamatované režimy“ v předchozí nabídce.",
|
||||
"description": "Některá zařízení přijímají určité zápisy (např. výchozí dávkování pracího prostředku/aviváže v pračce) i když hlásí vypnuté dálkové ovládání. LocalThings ve výchozím nastavení blokuje každý zápis srozumitelnou chybou, kdykoli zařízení hlásí vypnuté dálkové ovládání, místo aby jej zařízení tiše odmítlo. Povolte toto pouze tehdy, pokud jste ověřili, že zápisy na tomto zařízení skutečně fungují i s vypnutým dálkovým ovládáním – jinak tuto srozumitelnou chybu vyměníte za tiché selhání.\n\nNěkterá zařízení hlásí režim, který nikdy neuvedou mezi podporovanými – například klimatizace běžící v režimu Quiet, která nabízí pouze Off/Sleep/Speed. LocalThings si každý takový režim zapamatuje a dál jej nabízí, takže zůstane volitelný, jakmile v něm zařízení alespoň jednou bylo. Vypnutím této volby se budou nabízet pouze režimy, které zařízení samo uvádí; k vymazání již zapamatovaných použijte „Zapomenout zapamatované režimy“ v předchozí nabídce. Zařízení pro praní se staženými cykly z cloudu zde dostane připomenutí, jakmile nahlásí cyklus, který Home Assistant zatím nemůže nabídnout -- některá zařízení jej nahlásí, i když jste obrazovku „Stažené cykly“ v aplikaci SmartThings sami nikdy neotevřeli. Vypněte tuto možnost, chcete-li připomenutí zastavit a skrýt už pojmenované stažené cykly ze seznamu cyklů; nic z toho, co je již nastaveno, se neztratí, a po opětovném zapnutí se to hned znovu objeví.",
|
||||
"data": {
|
||||
"bypass_remote_control_lock": "Povolit zápis i když je dálkové ovládání hlášeno jako vypnuté",
|
||||
"finish_time_hysteresis_minutes": "Odhadovaný konec -- minimální změna (minuty)",
|
||||
"learn_device_modes": "Pamatovat si režimy, které zařízení hlásí, ale neuvádí jako podporované"
|
||||
"learn_device_modes": "Pamatovat si režimy, které zařízení hlásí, ale neuvádí jako podporované",
|
||||
"cloud_courses_enabled": "Nabízet stažené cykly"
|
||||
}
|
||||
},
|
||||
"forget_learned_modes": {
|
||||
@@ -1519,14 +1558,14 @@
|
||||
},
|
||||
"cloud_manual": {
|
||||
"title": "Stažené cykly",
|
||||
"description": "Toto zařízení hlásí {total} stažených cyklů; {found} jich bylo dosud zjištěno.\n\nNastavení staženého cyklu jsou viditelná pouze tehdy, když je daný cyklus právě načtený, a zařízení nikdy nehlásí jejich názvy. Chcete-li doplnit chybějící ({pending}): na zařízení vyberte Stažený program, poté postupně projděte jednotlivé stažené programy, u každého se na pár sekund zastavte, a vraťte se sem.\n\nKaždému z nich zadejte název, který chcete vidět v Home Assistant. Necháte-li název prázdný, daný cyklus zůstane mimo seznam cyklů. Názvy musí být jedinečné.\n\nStažený program je program na tomto zařízení, který spouští stažený cyklus. Rozpoznává se automaticky, ale před použitím jej zde potvrďte: výběrem staženého cyklu se do zařízení zapíše tento kód programu.",
|
||||
"description": "Toto zařízení hlásí {total} stažených cyklů; {found} jich bylo dosud zjištěno.\n\nNastavení staženého cyklu jsou viditelná pouze tehdy, když je daný cyklus právě načtený, a zařízení nikdy nehlásí jejich názvy. Chcete-li doplnit chybějící ({pending}): v aplikaci SmartThings (ne na samotném zařízení) otevřete toto zařízení a klepněte na „Stažené cykly“ -- samostatný řádek odlišný od „Cyklus“, níže na obrazovce, a ten, na kterém zde záleží. Poté postupně projděte jednotlivé stažené programy, u každého se na pár sekund zastavte, aby jej LocalThings zaznamenal, a vraťte se sem.\n\nKaždému z nich zadejte název, který chcete vidět v Home Assistant. Necháte-li název prázdný, daný cyklus zůstane mimo seznam cyklů. Názvy musí být jedinečné.\n\nStažený program je program na tomto zařízení, který spouští stažený cyklus. Rozpoznává se automaticky, ale před použitím jej zde potvrďte: výběrem staženého cyklu se do zařízení zapíše tento kód programu.",
|
||||
"data": {
|
||||
"download_course": "Kód programu „Stažený program“"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "Stažené cykly",
|
||||
"description": "Průvodce nastavením vás provede zařízením: na zařízení vyberte stažený cyklus a jakmile je nalezen, pojmenujte ho – jeden po druhém.\n\nÚprava názvů zobrazí vše dosud nalezené najednou – použijte ji později k přejmenování nebo opravě.",
|
||||
"description": "Průvodce nastavením vás provede: v aplikaci SmartThings (ne na samotném zařízení) otevřete toto zařízení a klepněte na „Stažené cykly“ -- samostatný řádek odlišný od „Cyklus“, níže na obrazovce, a ten, na kterém zde záleží. Vyberte tam cyklus, vraťte se sem a jakmile je nalezen, pojmenujte ho – jeden po druhém.\n\nÚprava názvů zobrazí vše dosud nalezené najednou – použijte ji později k přejmenování nebo opravě. Pojmenování funguje v obou případech, ale pojmenovaný cyklus se nabízí jako vybratelný cyklus jen tehdy, když je v nastavení zařízení zapnuto „Nabízet stažené cykly“.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Průvodce nastavením",
|
||||
"cloud_manual": "Upravit názvy"
|
||||
@@ -1537,7 +1576,7 @@
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "Pojmenujte tento cyklus",
|
||||
"description": "Zařízení přepnulo na stažený cyklus (pozice {slot}) a hlásí zbývající čas {remaining}.\n\nZadejte název, který chcete vidět v Home Assistant, a poté na zařízení vyberte další cyklus. Necháte-li pole prázdné, tento cyklus zůstane mimo seznam.\n\nDosud pojmenováno ({named} z {total}): {named_list}\n\nNázvy musí být jedinečné. Toto okno můžete kdykoli zavřít – názvy se ukládají průběžně.",
|
||||
"description": "Pozice „Stažené cykly“ zařízení nyní obsahuje nový cyklus (pozice {slot}) a hlásí zbývající čas {remaining}.\n\nZadejte název, který chcete vidět v Home Assistant, a poté na obrazovce „Stažené cykly“ v aplikaci SmartThings vyberte další cyklus, který ještě není označen jako „Stažený“. Necháte-li pole prázdné, tento cyklus zůstane mimo seznam.\n\nDosud pojmenováno ({named} z {total}): {named_list}\n\nNázvy musí být jedinečné. Toto okno můžete kdykoli zavřít – názvy se ukládají průběžně.",
|
||||
"data": {
|
||||
"name": "Název",
|
||||
"download_course": "Kód programu „Stažený program“"
|
||||
@@ -1545,7 +1584,7 @@
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "Nebyl vybrán žádný cyklus",
|
||||
"description": "Na zařízení nebylo nic vybráno. Ujistěte se, že je nastaveno na Stažený program, a poté postupně procházejte stažené programy.\n\nDosud pojmenováno ({named} z {total}): {named_list}\n\nVše dosud pojmenované je již uloženo.",
|
||||
"description": "Na zařízení se nic nezměnilo. Ujistěte se, že jste v aplikaci SmartThings klepli na „Stažené cykly“ -- samostatný řádek odlišný od „Cyklus“, níže na obrazovce -- a vybrali cyklus, který ještě nebyl označen jako „Stažený“, a poté klepli na Hotovo.\n\nDosud pojmenováno ({named} z {total}): {named_list}\n\nVše dosud pojmenované je již uloženo.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Počkat znovu",
|
||||
"cloud_finish": "Dokončit"
|
||||
@@ -1562,7 +1601,7 @@
|
||||
"not_loaded": "Toto zařízení ještě není připojeno. Zkuste to znovu, až se načte."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "Nyní na zařízení vyberte stažený cyklus.\n\nDosud pojmenováno ({named} z {total}): {named_list}\n\nToto okno můžete kdykoli zavřít – názvy se ukládají průběžně."
|
||||
"cloud_wait": "V aplikaci SmartThings (ne na samotném zařízení) otevřete toto zařízení a klepněte na „Stažené cykly“ -- samostatný řádek odlišný od „Cyklus“, níže na obrazovce, který uvádí pouze cykly rozpoznatelné tímto způsobem. Vyberte cyklus, který ještě není označen jako „Stažený“, a klepněte na Hotovo.\n\nZařízení nemusí být poblíž ani spuštěné -- stačí, když je zapnuté a připojené.\n\nDosud pojmenováno ({named} z {total}): {named_list}\n\nToto okno můžete kdykoli zavřít – názvy se ukládají průběžně."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
@@ -1572,7 +1611,7 @@
|
||||
},
|
||||
"cloud_courses_undiscovered": {
|
||||
"title": "Stažené cykly nejsou nastaveny pro {device_name}",
|
||||
"description": "{device_name} má {pending} z {total} stažených cyklů, které Home Assistant zatím nemůže nabídnout. Stažený cyklus lze použít až poté, co byl na zařízení zjištěn načtený a co jste mu dali název.\n\nChcete-li je nastavit, přejděte do Nastavení > Zařízení a služby > LocalThings > {device_name} > Konfigurovat > Stažené cykly a postupujte podle pokynů."
|
||||
"description": "{device_name} má {pending} z {total} stažených cyklů, které Home Assistant zatím nemůže nabídnout. Stažený cyklus lze použít až poté, co byl na zařízení zjištěn načtený -- na obrazovce „Stažené cykly“ v aplikaci SmartThings, ne na obrazovce „Cyklus“ -- a co jste mu dali název.\n\nChcete-li je nastavit, přejděte do Nastavení > Zařízení a služby > LocalThings > {device_name} > Konfigurovat > Stažené cykly a postupujte podle pokynů.\n\nStažené cykly nepoužíváte? Přejděte místo toho do Konfigurovat > Nastavení zařízení a vypněte „Nabízet stažené cykly“, čímž toto připomenutí natrvalo umlčíte."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -376,7 +376,25 @@
|
||||
"4e": "Selbsttrocknung",
|
||||
"17": "Super Speed",
|
||||
"26": "Frischluft",
|
||||
"2a": "Hygienepflege+"
|
||||
"2a": "Hygienepflege+",
|
||||
"02": "KI-Trocknen",
|
||||
"03": "Super Speed",
|
||||
"05": "Bettwäsche",
|
||||
"07": "Feinwäsche",
|
||||
"09": "Hemden",
|
||||
"0b": "Paddingpflege",
|
||||
"0c": "Outdoor-Imprägnierpflege",
|
||||
"0e": "Handtücher",
|
||||
"0f": "Wolle",
|
||||
"11": "Kaltluft",
|
||||
"28": "Innenraum-Heißluftdesinfektion",
|
||||
"37": "Blusen",
|
||||
"38": "Bügeltrocken",
|
||||
"39": "Raumentfeuchtung",
|
||||
"3a": "Hygienepflege",
|
||||
"3b": "Bettwäsche/Entstauben",
|
||||
"3c": "Sportkleidung",
|
||||
"3d": "Jeansstoff"
|
||||
}
|
||||
},
|
||||
"favorite_capacity": {
|
||||
@@ -610,10 +628,31 @@
|
||||
"name": "Programm",
|
||||
"state": {
|
||||
"01": "Normal",
|
||||
"02": "Super Intensiv",
|
||||
"03": "Super Eco",
|
||||
"04": "Schnellwäsche",
|
||||
"06": "XXL-Wäsche",
|
||||
"05": "Wolle/Dessous",
|
||||
"06": "Bettwäsche",
|
||||
"07": "Outdoor",
|
||||
"08": "Spülen + Schleudern",
|
||||
"09": "Trommelreinigung",
|
||||
"0a": "Handtücher",
|
||||
"0b": "Kochwäsche",
|
||||
"0c": "Babypflege",
|
||||
"0d": "Nur Schleudern",
|
||||
"0e": "Bewölkter Tag",
|
||||
"0f": "Reinwäsche",
|
||||
"10": "Trockenschleudern",
|
||||
"11": "Sommer-Bettwäsche",
|
||||
"12": "Baumwolle",
|
||||
"13": "Schwarze Baumwolle",
|
||||
"14": "Feine Unterwäsche",
|
||||
"15": "Sportkleidung",
|
||||
"16": "Blusen",
|
||||
"17": "Heruntergeladen",
|
||||
"18": "Soft Bubble",
|
||||
"19": "KI-Wäsche",
|
||||
"1a": "Hemden",
|
||||
"1b": "Baumwolle",
|
||||
"1c": "Eco 40-60",
|
||||
"1d": "Super Speed",
|
||||
@@ -638,6 +677,7 @@
|
||||
"32": "Hemden",
|
||||
"33": "Handtücher",
|
||||
"34": "Mischwäsche",
|
||||
"35": "Eco Baumwolle",
|
||||
"36": "Waschen + Trocknen",
|
||||
"37": "Air Wash",
|
||||
"38": "Trocknen Baumwolle",
|
||||
@@ -652,17 +692,6 @@
|
||||
"60": "Selbstreinigung+",
|
||||
"65": "Buntwäsche",
|
||||
"66": "Jeansstoff",
|
||||
"7c": "Weißwäsche",
|
||||
"7d": "Bettwäsche/Wasserdicht",
|
||||
"7e": "Selbstreinigung",
|
||||
"7f": "Wolle/Feinwäsche",
|
||||
"86": "Tiefenreinigung",
|
||||
"87": "Download",
|
||||
"8f": "Kaltwäsche Intensiv",
|
||||
"96": "Weniger Mikrofasern",
|
||||
"a0": "Schnelle Wäsche 15'",
|
||||
"b0": "Gemischte Beladung",
|
||||
"17": "Heruntergeladen",
|
||||
"69": "KI-Wäsche",
|
||||
"6a": "Wolle",
|
||||
"6b": "Jeansstoff",
|
||||
@@ -680,8 +709,17 @@
|
||||
"77": "Baumwolle",
|
||||
"78": "Spülen + Schleudern",
|
||||
"79": "Nur Schleudern",
|
||||
"7c": "Weißwäsche",
|
||||
"7d": "Bettwäsche/Wasserdicht",
|
||||
"7e": "Selbstreinigung",
|
||||
"7f": "Wolle/Feinwäsche",
|
||||
"86": "Tiefenreinigung",
|
||||
"87": "Download",
|
||||
"88": "Tierpflege",
|
||||
"35": "Eco Baumwolle"
|
||||
"8f": "Kaltwäsche Intensiv",
|
||||
"96": "Weniger Mikrofasern",
|
||||
"a0": "Schnelle Wäsche 15'",
|
||||
"b0": "Gemischte Beladung"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
@@ -1484,11 +1522,12 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Geräteeinstellungen",
|
||||
"description": "Manche Geräte akzeptieren bestimmte Schreibvorgänge (z. B. die Standard-Dosierung von Waschmittel/Weichspüler bei einer Waschmaschine) auch dann, wenn sie melden, dass die Fernsteuerung ausgeschaltet ist. Standardmäßig blockiert LocalThings jeden Schreibvorgang mit einer klaren Fehlermeldung, sobald ein Gerät meldet, dass die Fernsteuerung ausgeschaltet ist, statt das Gerät den Vorgang stillschweigend ablehnen zu lassen. Aktivieren Sie dies nur, wenn Sie bestätigt haben, dass Schreibvorgänge bei diesem Gerät auch mit ausgeschalteter Fernsteuerung tatsächlich funktionieren -- andernfalls tauschen Sie die klare Fehlermeldung gegen ein stilles Fehlschlagen ein.\n\nDie voraussichtliche Fertigstellungszeit wird bei jeder Abfrage aus der verbleibenden Zeitschätzung des Geräts neu berechnet, die zwischen den Aktualisierungen um ein bis zwei Minuten schwanken oder korrigiert werden kann. Erhöhen Sie den unten stehenden Mindeständerungswert, um den Sensor bis zu einer Änderung der Schätzung um mindestens diese Anzahl an Minuten auf seinem zuletzt gemeldeten Wert zu halten und so das Rauschen in Verlauf/Logbuch zu verringern. Setzen Sie ihn auf 0, um jede berechnete Änderung zu melden.\n\nManche Modelle melden einen Modus, den sie nie als unterstützt angeben -- etwa eine Klimaanlage im Modus „Leise“, die nur Aus/Schlaf/Schnell anbietet. LocalThings merkt sich jeden solchen gemeldeten Modus und bietet ihn weiterhin an, sodass er auswählbar bleibt, sobald das Gerät mindestens einmal darin war. Schalten Sie dies aus, um nur das anzubieten, was das Gerät selbst angibt; verwenden Sie „Gemerkte Modi vergessen“ auf dem vorherigen Bildschirm, um bereits Gemerktes zu löschen.",
|
||||
"description": "Manche Geräte akzeptieren bestimmte Schreibvorgänge (z. B. die Standard-Dosierung von Waschmittel/Weichspüler bei einer Waschmaschine) auch dann, wenn sie melden, dass die Fernsteuerung ausgeschaltet ist. Standardmäßig blockiert LocalThings jeden Schreibvorgang mit einer klaren Fehlermeldung, sobald ein Gerät meldet, dass die Fernsteuerung ausgeschaltet ist, statt das Gerät den Vorgang stillschweigend ablehnen zu lassen. Aktivieren Sie dies nur, wenn Sie bestätigt haben, dass Schreibvorgänge bei diesem Gerät auch mit ausgeschalteter Fernsteuerung tatsächlich funktionieren -- andernfalls tauschen Sie die klare Fehlermeldung gegen ein stilles Fehlschlagen ein.\n\nDie voraussichtliche Fertigstellungszeit wird bei jeder Abfrage aus der verbleibenden Zeitschätzung des Geräts neu berechnet, die zwischen den Aktualisierungen um ein bis zwei Minuten schwanken oder korrigiert werden kann. Erhöhen Sie den unten stehenden Mindeständerungswert, um den Sensor bis zu einer Änderung der Schätzung um mindestens diese Anzahl an Minuten auf seinem zuletzt gemeldeten Wert zu halten und so das Rauschen in Verlauf/Logbuch zu verringern. Setzen Sie ihn auf 0, um jede berechnete Änderung zu melden.\n\nManche Modelle melden einen Modus, den sie nie als unterstützt angeben -- etwa eine Klimaanlage im Modus „Leise“, die nur Aus/Schlaf/Schnell anbietet. LocalThings merkt sich jeden solchen gemeldeten Modus und bietet ihn weiterhin an, sodass er auswählbar bleibt, sobald das Gerät mindestens einmal darin war. Schalten Sie dies aus, um nur das anzubieten, was das Gerät selbst angibt; verwenden Sie „Gemerkte Modi vergessen“ auf dem vorherigen Bildschirm, um bereits Gemerktes zu löschen. Ein Wäschegerät mit aus der Cloud heruntergeladenen Programmen erhält hier eine Erinnerung, sobald es eines gemeldet hat, das Home Assistant noch nicht anbieten kann -- manche Geräte melden eines, auch wenn Sie den Bildschirm \"Download-Programme\" in der SmartThings-App nie selbst geöffnet haben. Schalten Sie dies aus, um diese Erinnerung zu stoppen und bereits benannte heruntergeladene Programme aus der Programmliste auszublenden; bereits Eingerichtetes geht dabei nicht verloren, und beim erneuten Einschalten erscheint es sofort wieder.",
|
||||
"data": {
|
||||
"bypass_remote_control_lock": "Schreibvorgänge auch bei ausgeschalteter Fernsteuerung zulassen",
|
||||
"finish_time_hysteresis_minutes": "Voraussichtliches Ende -- Mindeständerung (Minuten)",
|
||||
"learn_device_modes": "Vom Gerät gemeldete, aber nicht angegebene Modi merken"
|
||||
"learn_device_modes": "Vom Gerät gemeldete, aber nicht angegebene Modi merken",
|
||||
"cloud_courses_enabled": "Heruntergeladene Programme anbieten"
|
||||
}
|
||||
},
|
||||
"debug_write": {
|
||||
@@ -1519,14 +1558,14 @@
|
||||
},
|
||||
"cloud_manual": {
|
||||
"title": "Download-Programme",
|
||||
"description": "Dieses Gerät meldet {total} heruntergeladene Programme; {found} davon wurden bisher erkannt.\n\nDie Einstellungen eines heruntergeladenen Programms sind nur sichtbar, während dieses Programm geladen ist, und das Gerät meldet nie deren Namen. Um die fehlenden ({pending}) hinzuzufügen: Wählen Sie am Gerät das Download-Programm aus, gehen Sie dann nacheinander jedes heruntergeladene Programm durch, halten Sie bei jedem ein paar Sekunden inne, und kommen Sie danach hierher zurück.\n\nGeben Sie jedem den Namen, den Sie in Home Assistant sehen möchten. Lassen Sie einen Namen leer, um das Programm aus der Programmliste auszuschließen. Namen müssen eindeutig sein.\n\nDas Download-Programm ist das Programm auf diesem Gerät, das ein heruntergeladenes Programm ausführt. Es wird automatisch erkannt, bestätigen Sie es aber hier vor der Verwendung: Die Auswahl eines heruntergeladenen Programms schreibt diesen Programmcode auf das Gerät.",
|
||||
"description": "Dieses Gerät meldet {total} heruntergeladene Programme; {found} davon wurden bisher erkannt.\n\nDie Einstellungen eines heruntergeladenen Programms sind nur sichtbar, während dieses Programm geladen ist, und das Gerät meldet nie deren Namen. Um die fehlenden ({pending}) hinzuzufügen: Öffnen Sie in der SmartThings-App (nicht am Gerät selbst) dieses Gerät und tippen Sie auf \"Download-Programme\" -- eine eigene Zeile, getrennt von \"Programm\", weiter unten im Bildschirm, und die, auf die es hier ankommt. Gehen Sie dann nacheinander jedes heruntergeladene Programm durch, halten Sie bei jedem ein paar Sekunden inne, damit LocalThings es erkennen kann, und kommen Sie danach hierher zurück.\n\nGeben Sie jedem den Namen, den Sie in Home Assistant sehen möchten. Lassen Sie einen Namen leer, um das Programm aus der Programmliste auszuschließen. Namen müssen eindeutig sein.\n\nDas Download-Programm ist das Programm auf diesem Gerät, das ein heruntergeladenes Programm ausführt. Es wird automatisch erkannt, bestätigen Sie es aber hier vor der Verwendung: Die Auswahl eines heruntergeladenen Programms schreibt diesen Programmcode auf das Gerät.",
|
||||
"data": {
|
||||
"download_course": "Programmcode des Download-Programms"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "Download-Programme",
|
||||
"description": "Die geführte Einrichtung begleitet Sie am Gerät: Wählen Sie am Gerät ein heruntergeladenes Programm aus und benennen Sie es, sobald es gefunden wird – eines nach dem anderen.\n\nNamen bearbeiten zeigt alles bisher Gefundene auf einmal an – nutzen Sie es später, um etwas umzubenennen oder zu korrigieren.",
|
||||
"description": "Die geführte Einrichtung begleitet Sie dabei: Öffnen Sie in der SmartThings-App (nicht am Gerät selbst) dieses Gerät und tippen Sie auf \"Download-Programme\" -- eine eigene Zeile, getrennt von \"Programm\", weiter unten im Bildschirm, und die, auf die es hier ankommt. Wählen Sie dort ein Programm aus, kommen Sie dann zurück und benennen Sie es, sobald es gefunden wird – eines nach dem anderen.\n\nNamen bearbeiten zeigt alles bisher Gefundene auf einmal an – nutzen Sie es später, um etwas umzubenennen oder zu korrigieren. Benennen funktioniert so oder so, aber ein benanntes Programm wird nur dann als auswählbares Programm angeboten, wenn \"Heruntergeladene Programme anbieten\" in den Geräteeinstellungen aktiviert ist.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Geführte Einrichtung",
|
||||
"cloud_manual": "Namen bearbeiten"
|
||||
@@ -1537,7 +1576,7 @@
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "Dieses Programm benennen",
|
||||
"description": "Das Gerät ist zu einem heruntergeladenen Programm gewechselt (Platz {slot}) und meldet eine verbleibende Zeit von {remaining}.\n\nGeben Sie den Namen ein, den Sie in Home Assistant sehen möchten, und wählen Sie dann am Gerät das nächste aus. Lassen Sie das Feld leer, um dieses Programm von der Liste auszuschließen.\n\nBisher benannt ({named} von {total}): {named_list}\n\nNamen müssen eindeutig sein. Sie können dieses Fenster jederzeit schließen – die Namen werden fortlaufend gespeichert.",
|
||||
"description": "Der Platz \"Download-Programme\" des Geräts enthält jetzt ein neues Programm (Platz {slot}) und meldet eine verbleibende Zeit von {remaining}.\n\nGeben Sie den Namen ein, den Sie in Home Assistant sehen möchten, und wählen Sie dann im Bildschirm \"Download-Programme\" der SmartThings-App das nächste Programm aus, das noch nicht als \"Heruntergeladen\" markiert ist. Lassen Sie das Feld leer, um dieses Programm von der Liste auszuschließen.\n\nBisher benannt ({named} von {total}): {named_list}\n\nNamen müssen eindeutig sein. Sie können dieses Fenster jederzeit schließen – die Namen werden fortlaufend gespeichert.",
|
||||
"data": {
|
||||
"name": "Name",
|
||||
"download_course": "Programmcode des Download-Programms"
|
||||
@@ -1545,7 +1584,7 @@
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "Kein Programm ausgewählt",
|
||||
"description": "Am Gerät wurde nichts ausgewählt. Stellen Sie sicher, dass es auf das Download-Programm eingestellt ist, und gehen Sie dann die heruntergeladenen Programme durch.\n\nBisher benannt ({named} von {total}): {named_list}\n\nAlles bisher Benannte ist bereits gespeichert.",
|
||||
"description": "Am Gerät hat sich nichts geändert. Stellen Sie sicher, dass Sie in der SmartThings-App auf \"Download-Programme\" getippt haben -- eine eigene Zeile, getrennt von \"Programm\", weiter unten im Bildschirm -- und ein Programm ausgewählt haben, das noch nicht als \"Heruntergeladen\" markiert war, und dann auf Fertig getippt haben.\n\nBisher benannt ({named} von {total}): {named_list}\n\nAlles bisher Benannte ist bereits gespeichert.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Erneut warten",
|
||||
"cloud_finish": "Fertig"
|
||||
@@ -1562,7 +1601,7 @@
|
||||
"not_loaded": "Dieses Gerät ist noch nicht verbunden. Versuchen Sie es erneut, sobald es geladen wurde."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "Wählen Sie jetzt ein heruntergeladenes Programm am Gerät aus.\n\nBisher benannt ({named} von {total}): {named_list}\n\nSie können dieses Fenster jederzeit schließen – die Namen werden fortlaufend gespeichert."
|
||||
"cloud_wait": "Öffnen Sie in der SmartThings-App (nicht am Gerät selbst) dieses Gerät und tippen Sie auf \"Download-Programme\" -- eine eigene Zeile, getrennt von \"Programm\", weiter unten im Bildschirm, die nur die Programme auflistet, die auf diese Weise erkannt werden können. Wählen Sie eines aus, das noch nicht als \"Heruntergeladen\" markiert ist, und tippen Sie auf Fertig.\n\nDas Gerät muss dafür nicht in der Nähe sein oder das Programm ausführen -- es muss nur eingeschaltet und verbunden sein.\n\nBisher benannt ({named} von {total}): {named_list}\n\nSie können dieses Fenster jederzeit schließen – die Namen werden fortlaufend gespeichert."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
@@ -1572,7 +1611,7 @@
|
||||
},
|
||||
"cloud_courses_undiscovered": {
|
||||
"title": "Heruntergeladene Programme für {device_name} nicht eingerichtet",
|
||||
"description": "{device_name} hat {pending} von {total} heruntergeladenen Programmen, die Home Assistant noch nicht anbieten kann. Ein heruntergeladenes Programm kann erst verwendet werden, sobald das Gerät damit geladen gesehen wurde und Sie ihm einen Namen gegeben haben.\n\nUm sie einzurichten, gehen Sie zu Einstellungen > Geräte & Dienste > LocalThings > {device_name} > Konfigurieren > Download-Programme und folgen Sie den dortigen Anweisungen."
|
||||
"description": "{device_name} hat {pending} von {total} heruntergeladenen Programmen, die Home Assistant noch nicht anbieten kann. Ein heruntergeladenes Programm kann erst verwendet werden, sobald das Gerät damit geladen gesehen wurde -- im Bildschirm \"Download-Programme\" der SmartThings-App, nicht im Bildschirm \"Programm\" -- und Sie ihm einen Namen gegeben haben.\n\nUm sie einzurichten, gehen Sie zu Einstellungen > Geräte & Dienste > LocalThings > {device_name} > Konfigurieren > Download-Programme und folgen Sie den dortigen Anweisungen.\n\nNutzen Sie keine heruntergeladenen Programme? Gehen Sie stattdessen zu Konfigurieren > Geräteeinstellungen und schalten Sie \"Heruntergeladene Programme anbieten\" aus, um diese Erinnerung dauerhaft zu stoppen."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -376,7 +376,25 @@
|
||||
"4c": "Air Refresh",
|
||||
"51": "Eco Cotton",
|
||||
"53": "AI Dry+",
|
||||
"4e": "Self Dry"
|
||||
"4e": "Self Dry",
|
||||
"02": "AI Dry",
|
||||
"03": "Super Speed",
|
||||
"05": "Bedding",
|
||||
"07": "Delicates",
|
||||
"09": "Shirts",
|
||||
"0b": "Padding Care",
|
||||
"0c": "Outdoor Water-Repellent Care",
|
||||
"0e": "Towels",
|
||||
"0f": "Wool",
|
||||
"11": "Cool air",
|
||||
"28": "Interior Hot Air Sanitize",
|
||||
"37": "Blouses",
|
||||
"38": "Iron dry",
|
||||
"39": "Room Dehumidify",
|
||||
"3a": "Hygiene Care",
|
||||
"3b": "Bedding/Dust Off",
|
||||
"3c": "Activewear",
|
||||
"3d": "Denim"
|
||||
}
|
||||
},
|
||||
"favorite_capacity": {
|
||||
@@ -647,11 +665,31 @@
|
||||
"name": "Cycle",
|
||||
"state": {
|
||||
"01": "Normal",
|
||||
"02": "Extra Heavy Duty",
|
||||
"03": "Super Eco Wash",
|
||||
"04": "Quick Wash",
|
||||
"06": "XXL Laundry",
|
||||
"05": "Wool/Lingerie",
|
||||
"06": "Bedding",
|
||||
"07": "Outdoor",
|
||||
"08": "Rinse+Spin",
|
||||
"09": "Drum Clean",
|
||||
"0a": "Towels",
|
||||
"0b": "Boil Wash",
|
||||
"0c": "Baby Care",
|
||||
"0d": "Spin Only",
|
||||
"0e": "Cloudy Day",
|
||||
"0f": "Pure Wash",
|
||||
"10": "Spin Dry",
|
||||
"11": "Summer Bedding",
|
||||
"12": "Cottons",
|
||||
"13": "Black Cottons",
|
||||
"14": "Delicate Underwear",
|
||||
"15": "Activewear",
|
||||
"16": "Blouses",
|
||||
"17": "Downloaded",
|
||||
"18": "Soft Bubble",
|
||||
"19": "AI Wash",
|
||||
"1a": "Shirts",
|
||||
"1b": "Cotton",
|
||||
"1c": "Eco 40-60",
|
||||
"1d": "Super Speed",
|
||||
@@ -1484,11 +1522,12 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Device settings",
|
||||
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.\n\nEstimated finish time is recomputed from the device's remaining-time estimate on every poll, which can drift or get revised by a minute or two between updates. Raise the minimum-change value below to hold the sensor at its last reported value until the estimate moves by at least that many minutes, cutting down on history/logbook noise. Set it to 0 to report every computed change.\n\nSome models report a mode they never list as supported -- an air conditioner sitting in Quiet that only offers Off/Sleep/Speed, for instance. LocalThings remembers any such mode it sees and keeps offering it, so it stays selectable once the device has been in it at least once. Turn this off to offer only what the device advertises; use \"Forget remembered modes\" on the previous screen to clear what has already been remembered.",
|
||||
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.\n\nEstimated finish time is recomputed from the device's remaining-time estimate on every poll, which can drift or get revised by a minute or two between updates. Raise the minimum-change value below to hold the sensor at its last reported value until the estimate moves by at least that many minutes, cutting down on history/logbook noise. Set it to 0 to report every computed change.\n\nSome models report a mode they never list as supported -- an air conditioner sitting in Quiet that only offers Off/Sleep/Speed, for instance. LocalThings remembers any such mode it sees and keeps offering it, so it stays selectable once the device has been in it at least once. Turn this off to offer only what the device advertises; use \"Forget remembered modes\" on the previous screen to clear what has already been remembered.\n\nA laundry device with cloud-downloaded cycles gets a reminder here once it's reported one Home Assistant can't offer yet -- some devices report one even if you've never opened the SmartThings app's \"Download cycles\" screen yourself. Turn this off to stop that reminder and hide any already-named downloaded cycles from the cycle list; nothing already set up is lost, and turning it back on brings it right back.",
|
||||
"data": {
|
||||
"bypass_remote_control_lock": "Allow writes even when remote control is reported off",
|
||||
"finish_time_hysteresis_minutes": "Estimated finish -- minimum change (minutes)",
|
||||
"learn_device_modes": "Remember modes the device reports but doesn't advertise"
|
||||
"learn_device_modes": "Remember modes the device reports but doesn't advertise",
|
||||
"cloud_courses_enabled": "Offer downloaded cycles"
|
||||
}
|
||||
},
|
||||
"forget_learned_modes": {
|
||||
@@ -1519,14 +1558,14 @@
|
||||
},
|
||||
"cloud_manual": {
|
||||
"title": "Download cycles",
|
||||
"description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle, then step through each downloaded program in turn, pausing a few seconds on each, and come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.",
|
||||
"description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): in the SmartThings app (not on the appliance itself), open this appliance and tap \"Download cycles\" -- a separate row from \"Cycle\", further down the screen, and the one that matters here. Step through each downloaded program in turn, pausing a few seconds on each so LocalThings can see it, then come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.",
|
||||
"data": {
|
||||
"download_course": "Download cycle course code"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "Download cycles",
|
||||
"description": "Guided setup walks the appliance with you: select a downloaded cycle on the appliance and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later.",
|
||||
"description": "Guided setup walks you through it: in the SmartThings app (not on the appliance itself), open this appliance and tap \"Download cycles\" -- a separate row from \"Cycle\", further down the screen, and the one that matters here. Pick a cycle there, then come back and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later. Naming works either way, but a named cycle is only offered as a selectable cycle while \"Offer downloaded cycles\" is on in Device settings.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Guided setup",
|
||||
"cloud_manual": "Edit names"
|
||||
@@ -1537,7 +1576,7 @@
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "Name this cycle",
|
||||
"description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\nNamed so far ({named} of {total}): {named_list}\n\nNames must be unique. You can close this dialog whenever you like — names are saved as you go.",
|
||||
"description": "The appliance's \"Download cycles\" slot now holds a new cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then in the SmartThings app's \"Download cycles\" screen pick the next one that isn't already marked \"Downloaded\". Leave it blank to keep this cycle out of the list.\n\nNamed so far ({named} of {total}): {named_list}\n\nNames must be unique. You can close this dialog whenever you like — names are saved as you go.",
|
||||
"data": {
|
||||
"name": "Name",
|
||||
"download_course": "Download cycle course code"
|
||||
@@ -1545,7 +1584,7 @@
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "No cycle selected",
|
||||
"description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\nNamed so far ({named} of {total}): {named_list}\n\nEverything named so far is already saved.",
|
||||
"description": "Nothing changed on the appliance. Make sure you tapped \"Download cycles\" in the SmartThings app -- a separate row from \"Cycle\", further down the screen -- and picked one that wasn't already marked \"Downloaded\", then tapped Done.\n\nNamed so far ({named} of {total}): {named_list}\n\nEverything named so far is already saved.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Wait again",
|
||||
"cloud_finish": "Finish"
|
||||
@@ -1562,7 +1601,7 @@
|
||||
"not_loaded": "This device isn't connected yet. Try again once it has loaded."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "Select a downloaded cycle on the appliance now.\n\nNamed so far ({named} of {total}): {named_list}\n\nYou can close this dialog at any time — names are saved as you go."
|
||||
"cloud_wait": "In the SmartThings app (not on the appliance itself), open this appliance and tap \"Download cycles\" -- a separate row from \"Cycle\", further down the screen, that lists only the cycles this can detect. Pick one that isn't already marked \"Downloaded\", then tap Done.\n\nThe appliance doesn't need to be nearby or running the cycle -- just powered on and connected.\n\nNamed so far ({named} of {total}): {named_list}\n\nYou can close this dialog at any time — names are saved as you go."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
@@ -1572,7 +1611,7 @@
|
||||
},
|
||||
"cloud_courses_undiscovered": {
|
||||
"title": "Downloaded cycles not set up for {device_name}",
|
||||
"description": "{device_name} has {pending} of {total} downloaded cycle(s) that Home Assistant can't offer yet. A downloaded cycle can only be used once the appliance has been seen with it loaded and you've given it a name.\n\nTo set them up, go to Settings > Devices & Services > LocalThings > {device_name} > Configure > Download cycles and follow the instructions there."
|
||||
"description": "{device_name} has {pending} of {total} downloaded cycle(s) that Home Assistant can't offer yet. A downloaded cycle can only be used once the appliance has been seen with it loaded -- in the SmartThings app's \"Download cycles\" screen, not the \"Cycle\" one -- and you've given it a name.\n\nTo set them up, go to Settings > Devices & Services > LocalThings > {device_name} > Configure > Download cycles and follow the instructions there.\n\nDon't use downloaded cycles? Go to Configure > Device settings instead and turn off \"Offer downloaded cycles\" to silence this reminder for good."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -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": {
|
||||
@@ -572,7 +573,25 @@
|
||||
"53": "Secado IA+",
|
||||
"4e": "Autosecado",
|
||||
"26": "Aireación",
|
||||
"2a": "Cuidado higiénico+"
|
||||
"2a": "Cuidado higiénico+",
|
||||
"02": "Secado IA",
|
||||
"03": "Súper velocidad",
|
||||
"05": "Ropa de cama",
|
||||
"07": "Delicados",
|
||||
"09": "Camisas",
|
||||
"0b": "Cuidado de acolchados",
|
||||
"0c": "Cuidado hidrófugo para exterior",
|
||||
"0e": "Toallas",
|
||||
"0f": "Lana",
|
||||
"11": "Aire frío",
|
||||
"28": "Desinfección interior con aire caliente",
|
||||
"37": "Blusas",
|
||||
"38": "Planchado fácil",
|
||||
"39": "Deshumidificación de habitación",
|
||||
"3a": "Cuidado higiénico",
|
||||
"3b": "Ropa de cama/Antipolvo",
|
||||
"3c": "Ropa deportiva",
|
||||
"3d": "Denim"
|
||||
}
|
||||
},
|
||||
"favorite_capacity": {
|
||||
@@ -843,11 +862,31 @@
|
||||
"name": "Ciclo",
|
||||
"state": {
|
||||
"01": "Normal",
|
||||
"02": "Servicio Extra Intensivo",
|
||||
"03": "Super Eco",
|
||||
"04": "Lavado rápido",
|
||||
"06": "Colada XXL",
|
||||
"05": "Lana/Lencería",
|
||||
"06": "Ropa de cama",
|
||||
"07": "Exterior",
|
||||
"08": "Aclarar + Centrifugar",
|
||||
"09": "Limpieza de Tambor",
|
||||
"0a": "Toallas",
|
||||
"0b": "Lavado a ebullición",
|
||||
"0c": "Cuidado del bebé",
|
||||
"0d": "Solo centrifugar",
|
||||
"0e": "Día nublado",
|
||||
"0f": "Lavado puro",
|
||||
"10": "Secado-centrifugado",
|
||||
"11": "Ropa de cama de verano",
|
||||
"12": "Algodón",
|
||||
"13": "Algodón negro",
|
||||
"14": "Ropa interior delicada",
|
||||
"15": "Ropa deportiva",
|
||||
"16": "Blusas",
|
||||
"17": "Descargado",
|
||||
"18": "Burbuja suave",
|
||||
"19": "Lavado IA",
|
||||
"1a": "Camisas",
|
||||
"1b": "Algodón",
|
||||
"1c": "Eco 40-60",
|
||||
"1d": "Súper velocidad",
|
||||
@@ -872,6 +911,7 @@
|
||||
"32": "Camisas",
|
||||
"33": "Toallas",
|
||||
"34": "Mezcla",
|
||||
"35": "Algodón E",
|
||||
"36": "Lavado + Secado",
|
||||
"37": "Lavado con aire",
|
||||
"38": "Secado de algodón",
|
||||
@@ -886,16 +926,6 @@
|
||||
"60": "Autolimpieza+",
|
||||
"65": "Color",
|
||||
"66": "Denim",
|
||||
"7c": "Blancos",
|
||||
"7d": "Ropa de cama / Impermeable",
|
||||
"7e": "Autolimpieza",
|
||||
"7f": "Lana / Delicados",
|
||||
"86": "Lavado profundo",
|
||||
"87": "Descarga de Programas",
|
||||
"8f": "Lavado en frío",
|
||||
"96": "Menos microfibras",
|
||||
"a0": "Lavado rápido 15'",
|
||||
"b0": "Carga mixta",
|
||||
"69": "Lavado IA",
|
||||
"6a": "Lana",
|
||||
"6b": "Denim",
|
||||
@@ -913,8 +943,17 @@
|
||||
"77": "Algodón",
|
||||
"78": "Aclarar + Centrifugar",
|
||||
"79": "Solo centrifugar",
|
||||
"7c": "Blancos",
|
||||
"7d": "Ropa de cama / Impermeable",
|
||||
"7e": "Autolimpieza",
|
||||
"7f": "Lana / Delicados",
|
||||
"86": "Lavado profundo",
|
||||
"87": "Descarga de Programas",
|
||||
"88": "Cuidado de mascotas",
|
||||
"35": "Algodón E"
|
||||
"8f": "Lavado en frío",
|
||||
"96": "Menos microfibras",
|
||||
"a0": "Lavado rápido 15'",
|
||||
"b0": "Carga mixta"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
|
||||
@@ -376,7 +376,25 @@
|
||||
"53": "Asciugatura AI+",
|
||||
"4e": "Autoasciugatura",
|
||||
"26": "Arieggiatura",
|
||||
"2a": "Cura igienica+"
|
||||
"2a": "Cura igienica+",
|
||||
"02": "Asciugatura AI",
|
||||
"03": "Super Speed",
|
||||
"05": "Biancheria da letto",
|
||||
"07": "Delicati",
|
||||
"09": "Camicie",
|
||||
"0b": "Cura piumini",
|
||||
"0c": "Cura idrorepellente outdoor",
|
||||
"0e": "Asciugamani",
|
||||
"0f": "Lana",
|
||||
"11": "Aria fredda",
|
||||
"28": "Sanificazione interna ad aria calda",
|
||||
"37": "Camicette",
|
||||
"38": "Pronto da stirare",
|
||||
"39": "Deumidificazione ambiente",
|
||||
"3a": "Cura igienica",
|
||||
"3b": "Biancheria da letto/Antipolvere",
|
||||
"3c": "Abbigliamento sportivo",
|
||||
"3d": "Jeans"
|
||||
}
|
||||
},
|
||||
"favorite_capacity": {
|
||||
@@ -647,11 +665,31 @@
|
||||
"name": "Ciclo",
|
||||
"state": {
|
||||
"01": "Normale",
|
||||
"02": "Extra Intenso",
|
||||
"03": "Super Eco",
|
||||
"04": "Lavaggio rapido",
|
||||
"06": "Bucato XXL",
|
||||
"05": "Lana/Lingerie",
|
||||
"06": "Biancheria da letto",
|
||||
"07": "Capi outdoor",
|
||||
"08": "Risciacquo+Centrifuga",
|
||||
"09": "Pulizia cestello",
|
||||
"0a": "Asciugamani",
|
||||
"0b": "Lavaggio a ebollizione",
|
||||
"0c": "Bambini",
|
||||
"0d": "Solo centrifuga",
|
||||
"0e": "Giornata nuvolosa",
|
||||
"0f": "Lavaggio puro",
|
||||
"10": "Asciugatura centrifuga",
|
||||
"11": "Biancheria da letto estiva",
|
||||
"12": "Cotone",
|
||||
"13": "Cotone nero",
|
||||
"14": "Biancheria intima delicata",
|
||||
"15": "Abbigliamento sportivo",
|
||||
"16": "Camicette",
|
||||
"17": "Scaricato",
|
||||
"18": "Soft Bubble",
|
||||
"19": "Lavaggio AI",
|
||||
"1a": "Camicie",
|
||||
"1b": "Cotone",
|
||||
"1c": "Eco 40-60",
|
||||
"1d": "Super Speed",
|
||||
@@ -676,6 +714,7 @@
|
||||
"32": "Camicie",
|
||||
"33": "Asciugamani",
|
||||
"34": "Misti",
|
||||
"35": "Cotone E",
|
||||
"36": "Lavaggio+Asciugatura",
|
||||
"37": "Lavaggio ad aria",
|
||||
"38": "Asciugatura cotone",
|
||||
@@ -690,14 +729,6 @@
|
||||
"60": "Self Clean+",
|
||||
"65": "Colorati",
|
||||
"66": "Jeans",
|
||||
"7c": "Bianchi",
|
||||
"7d": "Biancheria da letto/Impermeabile",
|
||||
"7e": "Pulizia cestello",
|
||||
"7f": "Lana/Delicati",
|
||||
"86": "Lavaggio profondo",
|
||||
"87": "Scaricato",
|
||||
"8f": "Intenso a freddo",
|
||||
"96": "Riduci microfibre",
|
||||
"69": "Lavaggio AI",
|
||||
"6a": "Lana",
|
||||
"6b": "Jeans",
|
||||
@@ -715,8 +746,15 @@
|
||||
"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",
|
||||
"8f": "Intenso a freddo",
|
||||
"96": "Riduci microfibre",
|
||||
"a0": "Rapido 15'",
|
||||
"b0": "Carico misto"
|
||||
}
|
||||
@@ -1484,11 +1522,12 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni dispositivo",
|
||||
"description": "Alcuni dispositivi accettano determinate scritture (ad esempio, il dosaggio predefinito di detersivo/ammorbidente su una lavatrice) anche quando segnalano che il controllo remoto è disattivato. Per impostazione predefinita, LocalThings blocca ogni scrittura con un errore chiaro ogni volta che un dispositivo segnala che il controllo remoto è disattivato, invece di consentire al dispositivo di rifiutarla silenziosamente. Abilitare questa opzione solo se si è verificato che le scritture funzionano effettivamente su questo dispositivo con il controllo remoto disattivato; in caso contrario, si sostituirà l'errore chiaro con un errore silenzioso.\n\nIl tempo di fine stimato viene ricalcolato dalla stima del tempo rimanente del dispositivo a ogni interrogazione, che può variare o essere rivista di uno o due minuti tra un aggiornamento e l'altro. Aumentare il valore di variazione minima riportato di seguito per mantenere il sensore al suo ultimo valore segnalato finché la stima non si sposta di almeno quel numero di minuti, riducendo il rumore nella cronologia/registro. Impostarlo su 0 per segnalare ogni variazione calcolata.\n\nAlcuni modelli segnalano una modalità che non elencano mai tra quelle supportate: ad esempio un condizionatore in modalità Quiet che offre solo Off/Sleep/Speed. LocalThings memorizza ogni modalità di questo tipo che rileva e continua a proporla, così resta selezionabile una volta che il dispositivo vi è stato almeno una volta. Disattivare questa opzione per proporre solo ciò che il dispositivo dichiara; usare «Dimentica le modalità memorizzate» nella schermata precedente per cancellare quanto già memorizzato.",
|
||||
"description": "Alcuni dispositivi accettano determinate scritture (ad esempio, il dosaggio predefinito di detersivo/ammorbidente su una lavatrice) anche quando segnalano che il controllo remoto è disattivato. Per impostazione predefinita, LocalThings blocca ogni scrittura con un errore chiaro ogni volta che un dispositivo segnala che il controllo remoto è disattivato, invece di consentire al dispositivo di rifiutarla silenziosamente. Abilitare questa opzione solo se si è verificato che le scritture funzionano effettivamente su questo dispositivo con il controllo remoto disattivato; in caso contrario, si sostituirà l'errore chiaro con un errore silenzioso.\n\nIl tempo di fine stimato viene ricalcolato dalla stima del tempo rimanente del dispositivo a ogni interrogazione, che può variare o essere rivista di uno o due minuti tra un aggiornamento e l'altro. Aumentare il valore di variazione minima riportato di seguito per mantenere il sensore al suo ultimo valore segnalato finché la stima non si sposta di almeno quel numero di minuti, riducendo il rumore nella cronologia/registro. Impostarlo su 0 per segnalare ogni variazione calcolata.\n\nAlcuni modelli segnalano una modalità che non elencano mai tra quelle supportate: ad esempio un condizionatore in modalità Quiet che offre solo Off/Sleep/Speed. LocalThings memorizza ogni modalità di questo tipo che rileva e continua a proporla, così resta selezionabile una volta che il dispositivo vi è stato almeno una volta. Disattivare questa opzione per proporre solo ciò che il dispositivo dichiara; usare «Dimentica le modalità memorizzate» nella schermata precedente per cancellare quanto già memorizzato. Un dispositivo per il bucato con cicli scaricati dal cloud riceve qui un promemoria non appena ne segnala uno che Home Assistant non può ancora offrire -- alcuni dispositivi ne segnalano uno anche se non è mai stata aperta la schermata \"Cicli scaricati\" dell'app SmartThings. Disattivarlo per interrompere il promemoria e nascondere dall'elenco dei cicli quelli scaricati già nominati; nulla di quanto già configurato va perso, e riattivandolo riappare subito.",
|
||||
"data": {
|
||||
"bypass_remote_control_lock": "Consenti la scrittura anche quando il controllo remoto risulta disattivato.",
|
||||
"finish_time_hysteresis_minutes": "Tempo finale stimato - variazione minima (minuti)",
|
||||
"learn_device_modes": "Memorizza le modalità che il dispositivo segnala ma non dichiara supportate"
|
||||
"learn_device_modes": "Memorizza le modalità che il dispositivo segnala ma non dichiara supportate",
|
||||
"cloud_courses_enabled": "Offri cicli scaricati"
|
||||
}
|
||||
},
|
||||
"forget_learned_modes": {
|
||||
@@ -1519,14 +1558,14 @@
|
||||
},
|
||||
"cloud_manual": {
|
||||
"title": "Cicli scaricati",
|
||||
"description": "Questo dispositivo segnala {total} cicli scaricati; finora ne sono stati rilevati {found}.\n\nLe impostazioni di un ciclo scaricato sono visibili solo mentre quel ciclo è caricato, e il dispositivo non ne segnala mai il nome. Per aggiungere quelli mancanti ({pending}): sul dispositivo selezionare il ciclo Scaricato, quindi scorrere ciascun programma scaricato uno alla volta, sostando qualche secondo su ognuno, e tornare qui.\n\nAssegnare a ciascuno il nome che si desidera vedere in Home Assistant. Lasciare un nome vuoto per escludere quel ciclo dall'elenco dei cicli. I nomi devono essere univoci.\n\nIl ciclo Scaricato è il programma su questo dispositivo che esegue un programma scaricato. Viene rilevato automaticamente, ma confermarlo qui prima dell'uso: selezionare un ciclo scaricato scrive questo codice di programma sul dispositivo.",
|
||||
"description": "Questo dispositivo segnala {total} cicli scaricati; finora ne sono stati rilevati {found}.\n\nLe impostazioni di un ciclo scaricato sono visibili solo mentre quel ciclo è caricato, e il dispositivo non ne segnala mai il nome. Per aggiungere quelli mancanti ({pending}): nell'app SmartThings (non sul dispositivo), aprire questo dispositivo e toccare \"Cicli scaricati\" -- una riga separata da \"Ciclo\", più in basso nella schermata, ed è quella che conta qui. Scorrere ciascun programma scaricato uno alla volta, sostando qualche secondo su ognuno perché LocalThings possa rilevarlo, e tornare qui.\n\nAssegnare a ciascuno il nome che si desidera vedere in Home Assistant. Lasciare un nome vuoto per escludere quel ciclo dall'elenco dei cicli. I nomi devono essere univoci.\n\nIl ciclo Scaricato è il programma su questo dispositivo che esegue un programma scaricato. Viene rilevato automaticamente, ma confermarlo qui prima dell'uso: selezionare un ciclo scaricato scrive questo codice di programma sul dispositivo.",
|
||||
"data": {
|
||||
"download_course": "Codice programma del ciclo Scaricato"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "Cicli scaricati",
|
||||
"description": "La configurazione guidata accompagna l'utente al dispositivo: selezionare un ciclo scaricato sul dispositivo e assegnargli un nome non appena viene rilevato, uno alla volta.\n\nModifica nomi mostra tutto ciò che è stato trovato finora in una sola volta — usarla in seguito per rinominare o correggere qualcosa.",
|
||||
"description": "La configurazione guidata accompagna l'utente: nell'app SmartThings (non sul dispositivo), aprire questo dispositivo e toccare \"Cicli scaricati\" -- una riga separata da \"Ciclo\", più in basso nella schermata, ed è quella che conta qui. Scegliere un ciclo lì, poi tornare qui e assegnargli un nome non appena viene rilevato, uno alla volta.\n\nModifica nomi mostra tutto ciò che è stato trovato finora in una sola volta — usarla in seguito per rinominare o correggere qualcosa. Assegnare un nome funziona comunque, ma un ciclo con nome viene offerto come ciclo selezionabile solo quando \"Offri cicli scaricati\" è attivo nelle impostazioni del dispositivo.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Configurazione guidata",
|
||||
"cloud_manual": "Modifica nomi"
|
||||
@@ -1537,7 +1576,7 @@
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "Assegna un nome a questo ciclo",
|
||||
"description": "Il dispositivo è passato a un ciclo scaricato (slot {slot}) e segnala {remaining} rimanenti.\n\nAssegnargli il nome che si desidera vedere in Home Assistant, quindi selezionare il successivo sul dispositivo. Lasciare vuoto per escludere questo ciclo dall'elenco.\n\nAssegnati finora ({named} di {total}): {named_list}\n\nI nomi devono essere univoci. È possibile chiudere questa finestra in qualsiasi momento — i nomi vengono salvati man mano.",
|
||||
"description": "Lo slot \"Cicli scaricati\" del dispositivo ora contiene un nuovo ciclo (slot {slot}) e segnala {remaining} rimanenti.\n\nAssegnargli il nome che si desidera vedere in Home Assistant, quindi nella schermata \"Cicli scaricati\" dell'app SmartThings scegliere il successivo non ancora contrassegnato come \"Scaricato\". Lasciare vuoto per escludere questo ciclo dall'elenco.\n\nAssegnati finora ({named} di {total}): {named_list}\n\nI nomi devono essere univoci. È possibile chiudere questa finestra in qualsiasi momento — i nomi vengono salvati man mano.",
|
||||
"data": {
|
||||
"name": "Nome",
|
||||
"download_course": "Codice programma del ciclo Scaricato"
|
||||
@@ -1545,7 +1584,7 @@
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "Nessun ciclo selezionato",
|
||||
"description": "Non è stato selezionato nulla sul dispositivo. Assicurarsi che sia impostato sul ciclo Scaricato, quindi scorrere i programmi scaricati.\n\nAssegnati finora ({named} di {total}): {named_list}\n\nTutto ciò che è stato assegnato finora è già salvato.",
|
||||
"description": "Non è cambiato nulla sul dispositivo. Assicurarsi di aver toccato \"Cicli scaricati\" nell'app SmartThings -- una riga separata da \"Ciclo\", più in basso nella schermata -- e di aver scelto un ciclo non ancora contrassegnato come \"Scaricato\", quindi di aver toccato Fine.\n\nAssegnati finora ({named} di {total}): {named_list}\n\nTutto ciò che è stato assegnato finora è già salvato.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Attendi di nuovo",
|
||||
"cloud_finish": "Fine"
|
||||
@@ -1562,7 +1601,7 @@
|
||||
"not_loaded": "Questo dispositivo non è ancora connesso. Riprova dopo il caricamento."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "Selezionare ora un ciclo scaricato sul dispositivo.\n\nAssegnati finora ({named} di {total}): {named_list}\n\nÈ possibile chiudere questa finestra in qualsiasi momento — i nomi vengono salvati man mano."
|
||||
"cloud_wait": "Nell'app SmartThings (non sul dispositivo), aprire questo dispositivo e toccare \"Cicli scaricati\" -- una riga separata da \"Ciclo\", più in basso nella schermata, che elenca solo i cicli rilevabili in questo modo. Scegliere uno non ancora contrassegnato come \"Scaricato\", quindi toccare Fine.\n\nIl dispositivo non deve essere nelle vicinanze né in esecuzione -- deve solo essere acceso e connesso.\n\nAssegnati finora ({named} di {total}): {named_list}\n\nÈ possibile chiudere questa finestra in qualsiasi momento — i nomi vengono salvati man mano."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
@@ -1572,7 +1611,7 @@
|
||||
},
|
||||
"cloud_courses_undiscovered": {
|
||||
"title": "Cicli scaricati non configurati per {device_name}",
|
||||
"description": "{device_name} ha {pending} cicli scaricati su {total} che Home Assistant non può ancora offrire. Un ciclo scaricato può essere usato solo dopo che il dispositivo è stato rilevato con quel ciclo caricato ed è stato assegnato un nome.\n\nPer configurarli, andare su Impostazioni > Dispositivi e servizi > LocalThings > {device_name} > Configura > Cicli scaricati e seguire le istruzioni presenti lì."
|
||||
"description": "{device_name} ha {pending} cicli scaricati su {total} che Home Assistant non può ancora offrire. Un ciclo scaricato può essere usato solo dopo che il dispositivo è stato rilevato con quel ciclo caricato -- nella schermata \"Cicli scaricati\" dell'app SmartThings, non in quella \"Ciclo\" -- ed è stato assegnato un nome.\n\nPer configurarli, andare su Impostazioni > Dispositivi e servizi > LocalThings > {device_name} > Configura > Cicli scaricati e seguire le istruzioni presenti lì.\n\nNon si usano cicli scaricati? Andare invece su Configura > Impostazioni dispositivo e disattivare \"Offri cicli scaricati\" per silenziare questo promemoria definitivamente."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -376,7 +376,25 @@
|
||||
"53": "AI 맞춤건조+",
|
||||
"4e": "자체 건조",
|
||||
"26": "송풍",
|
||||
"2a": "살균건조+"
|
||||
"2a": "살균건조+",
|
||||
"02": "AI 맞춤건조",
|
||||
"03": "쾌속건조",
|
||||
"05": "이불",
|
||||
"07": "섬세의류",
|
||||
"09": "셔츠",
|
||||
"0b": "패딩케어",
|
||||
"0c": "아웃도어발수케어",
|
||||
"0e": "타월",
|
||||
"0f": "울",
|
||||
"11": "송풍건조",
|
||||
"28": "열풍내부살균",
|
||||
"37": "블라우스",
|
||||
"38": "다림질건조",
|
||||
"39": "공간제습",
|
||||
"3a": "살균건조",
|
||||
"3b": "이불/먼지털기",
|
||||
"3c": "피트니스",
|
||||
"3d": "데님"
|
||||
}
|
||||
},
|
||||
"favorite_capacity": {
|
||||
@@ -647,11 +665,31 @@
|
||||
"name": "코스",
|
||||
"state": {
|
||||
"01": "표준세탁",
|
||||
"02": "초강력세탁",
|
||||
"03": "초절약세탁",
|
||||
"04": "쾌속세탁",
|
||||
"06": "XXL 세탁",
|
||||
"05": "울/란제리",
|
||||
"06": "이불",
|
||||
"07": "아웃도어",
|
||||
"08": "헹굼+탈수",
|
||||
"09": "무세제통세척",
|
||||
"0a": "타월",
|
||||
"0b": "삶음세탁",
|
||||
"0c": "아기옷",
|
||||
"0d": "탈수단독",
|
||||
"0e": "흐린날세탁",
|
||||
"0f": "청정세탁",
|
||||
"10": "건조탈수",
|
||||
"11": "여름이불",
|
||||
"12": "면의류",
|
||||
"13": "검은면의류",
|
||||
"14": "섬세속옷",
|
||||
"15": "피트니스",
|
||||
"16": "블라우스",
|
||||
"17": "다운로드 코스",
|
||||
"18": "소프트버블",
|
||||
"19": "AI 맞춤세탁",
|
||||
"1a": "셔츠",
|
||||
"1b": "면",
|
||||
"1c": "에코 40-60",
|
||||
"1d": "쾌속세탁",
|
||||
@@ -676,6 +714,7 @@
|
||||
"32": "셔츠",
|
||||
"33": "타월",
|
||||
"34": "혼합",
|
||||
"35": "에코 면",
|
||||
"36": "세탁+건조",
|
||||
"37": "에어워시",
|
||||
"38": "면 건조",
|
||||
@@ -690,16 +729,6 @@
|
||||
"60": "통세척+",
|
||||
"65": "컬러 의류",
|
||||
"66": "데님",
|
||||
"7c": "흰옷",
|
||||
"7d": "이불/방수 의류",
|
||||
"7e": "통세척",
|
||||
"7f": "울/섬세",
|
||||
"86": "찌든 때 세탁",
|
||||
"87": "다운로드",
|
||||
"8f": "강력 냉수 세탁",
|
||||
"96": "미세플라스틱저감",
|
||||
"a0": "15분 쾌속세탁",
|
||||
"b0": "혼합 세탁",
|
||||
"69": "AI 맞춤세탁",
|
||||
"6a": "울",
|
||||
"6b": "데님",
|
||||
@@ -717,8 +746,17 @@
|
||||
"77": "면",
|
||||
"78": "헹굼+탈수",
|
||||
"79": "탈수만",
|
||||
"7c": "흰옷",
|
||||
"7d": "이불/방수 의류",
|
||||
"7e": "통세척",
|
||||
"7f": "울/섬세",
|
||||
"86": "찌든 때 세탁",
|
||||
"87": "다운로드",
|
||||
"88": "펫케어",
|
||||
"35": "에코 면"
|
||||
"8f": "강력 냉수 세탁",
|
||||
"96": "미세플라스틱저감",
|
||||
"a0": "15분 쾌속세탁",
|
||||
"b0": "혼합 세탁"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
@@ -1484,11 +1522,12 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "기기 설정",
|
||||
"description": "일부 기기는 스마트 컨트롤이 꺼져 있다고 보고하는 동안에도 특정 쓰기 요청(예: 세탁기의 기본 세제/섬유유연제 투입량)을 받아들입니다. 기본적으로 LocalThings는 기기가 스마트 컨트롤 꺼짐을 보고하면 기기가 쓰기 요청을 조용히 거부하도록 두지 않고, 명확한 오류와 함께 모든 쓰기를 차단합니다. 스마트 컨트롤이 꺼진 상태에서도 이 기기에 쓰기가 실제로 동작하는 것을 확인한 경우에만 이 옵션을 활성화하세요. 그렇지 않으면 명확한 오류 대신 아무 표시 없이 쓰기에 실패할 수 있습니다.\n\n예상 완료 시각은 기기가 보고하는 남은 시간 추정치를 사용하여 상태를 확인할 때마다 다시 계산합니다. 이 추정치는 업데이트 사이에 1~2분 정도 변하거나 수정될 수 있습니다. 아래의 최소 변경량을 늘리면 추정치가 설정한 시간(분) 이상 변할 때까지 센서가 마지막으로 보고된 값을 유지하므로 기록과 로그북에 불필요한 항목이 쌓이는 것을 줄일 수 있습니다. 계산된 모든 변경 사항을 보고하려면 0으로 설정하세요.\n\n일부 모델은 지원 목록에 없는 모드를 현재 모드로 보고합니다. 예를 들어 Off/Sleep/Speed만 제공하면서 Quiet 모드로 동작 중이라고 보고하는 에어컨이 그렇습니다. LocalThings는 이렇게 발견한 모드를 기억해 두고 계속 제공하므로, 기기가 한 번이라도 그 모드였다면 이후에도 선택할 수 있습니다. 이 옵션을 끄면 기기가 지원한다고 알린 모드만 제공합니다. 이미 기억된 항목을 지우려면 이전 화면의 '기억된 모드 지우기'를 사용하세요.",
|
||||
"description": "일부 기기는 스마트 컨트롤이 꺼져 있다고 보고하는 동안에도 특정 쓰기 요청(예: 세탁기의 기본 세제/섬유유연제 투입량)을 받아들입니다. 기본적으로 LocalThings는 기기가 스마트 컨트롤 꺼짐을 보고하면 기기가 쓰기 요청을 조용히 거부하도록 두지 않고, 명확한 오류와 함께 모든 쓰기를 차단합니다. 스마트 컨트롤이 꺼진 상태에서도 이 기기에 쓰기가 실제로 동작하는 것을 확인한 경우에만 이 옵션을 활성화하세요. 그렇지 않으면 명확한 오류 대신 아무 표시 없이 쓰기에 실패할 수 있습니다.\n\n예상 완료 시각은 기기가 보고하는 남은 시간 추정치를 사용하여 상태를 확인할 때마다 다시 계산합니다. 이 추정치는 업데이트 사이에 1~2분 정도 변하거나 수정될 수 있습니다. 아래의 최소 변경량을 늘리면 추정치가 설정한 시간(분) 이상 변할 때까지 센서가 마지막으로 보고된 값을 유지하므로 기록과 로그북에 불필요한 항목이 쌓이는 것을 줄일 수 있습니다. 계산된 모든 변경 사항을 보고하려면 0으로 설정하세요.\n\n일부 모델은 지원 목록에 없는 모드를 현재 모드로 보고합니다. 예를 들어 Off/Sleep/Speed만 제공하면서 Quiet 모드로 동작 중이라고 보고하는 에어컨이 그렇습니다. LocalThings는 이렇게 발견한 모드를 기억해 두고 계속 제공하므로, 기기가 한 번이라도 그 모드였다면 이후에도 선택할 수 있습니다. 이 옵션을 끄면 기기가 지원한다고 알린 모드만 제공합니다. 이미 기억된 항목을 지우려면 이전 화면의 '기억된 모드 지우기'를 사용하세요. 클라우드에서 다운로드한 코스가 있는 세탁기는 Home Assistant가 아직 제공할 수 없는 코스를 보고하는 즉시 여기에서 알림을 받습니다. 일부 기기는 SmartThings 앱의 \"다운로드 코스\" 화면을 직접 연 적이 없어도 이런 코스를 보고합니다. 이 옵션을 끄면 이 알림이 멈추고 이미 이름을 지정한 다운로드 코스도 코스 목록에서 숨겨집니다. 이미 설정한 내용은 사라지지 않으며, 다시 켜면 즉시 원래대로 돌아옵니다.",
|
||||
"data": {
|
||||
"bypass_remote_control_lock": "스마트 컨트롤이 꺼진 것으로 보고되어도 쓰기 허용",
|
||||
"finish_time_hysteresis_minutes": "예상 완료 시각 - 최소 변경량(분)",
|
||||
"learn_device_modes": "기기가 보고하지만 지원 목록에 없는 모드 기억하기"
|
||||
"learn_device_modes": "기기가 보고하지만 지원 목록에 없는 모드 기억하기",
|
||||
"cloud_courses_enabled": "다운로드 코스 제공"
|
||||
}
|
||||
},
|
||||
"forget_learned_modes": {
|
||||
@@ -1519,14 +1558,14 @@
|
||||
},
|
||||
"cloud_manual": {
|
||||
"title": "다운로드 코스",
|
||||
"description": "이 기기는 다운로드한 코스를 {total}개 보고하며, 지금까지 {found}개를 확인했습니다.\n\n다운로드한 코스의 설정은 해당 코스가 로드되어 있는 동안에만 확인할 수 있고, 기기는 그 이름을 전달하지 않습니다. 누락된 항목({pending}개)을 추가하려면 기기에서 다운로드 코스를 선택한 다음, 다운로드된 프로그램을 하나씩 차례로 실행하면서 몇 초씩 머무른 뒤 이 화면으로 돌아오세요.\n\n각 코스에 Home Assistant에서 보고 싶은 이름을 입력하세요. 이름을 비워 두면 해당 코스는 코스 목록에서 제외됩니다. 이름은 서로 달라야 합니다.\n\n다운로드 코스는 이 기기에서 다운로드된 프로그램을 실행하는 코스입니다. 자동으로 감지되지만 사용하기 전에 여기서 확인하세요. 다운로드한 코스를 선택하면 이 코스 코드가 기기에 기록됩니다.",
|
||||
"description": "이 기기는 다운로드한 코스를 {total}개 보고하며, 지금까지 {found}개를 확인했습니다.\n\n다운로드한 코스의 설정은 해당 코스가 로드되어 있는 동안에만 확인할 수 있고, 기기는 그 이름을 전달하지 않습니다. 누락된 항목({pending}개)을 추가하려면 기기가 아닌 SmartThings 앱에서 이 기기를 열고 \"다운로드 코스\"를 탭하세요. 이는 화면 아래쪽에 있는 \"코스\"와는 별도의 항목이며, 여기서 중요한 것은 이 항목입니다. 그런 다음 다운로드된 프로그램을 하나씩 차례로 실행하면서 LocalThings가 인식할 수 있도록 몇 초씩 머무른 뒤 이 화면으로 돌아오세요.\n\n각 코스에 Home Assistant에서 보고 싶은 이름을 입력하세요. 이름을 비워 두면 해당 코스는 코스 목록에서 제외됩니다. 이름은 서로 달라야 합니다.\n\n다운로드 코스는 이 기기에서 다운로드된 프로그램을 실행하는 코스입니다. 자동으로 감지되지만 사용하기 전에 여기서 확인하세요. 다운로드한 코스를 선택하면 이 코스 코드가 기기에 기록됩니다.",
|
||||
"data": {
|
||||
"download_course": "다운로드 코스 코드"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "다운로드 코스",
|
||||
"description": "안내 설정은 기기와 함께 진행됩니다. 기기에서 다운로드한 코스를 선택하고, 코스가 확인될 때마다 하나씩 이름을 지정하세요.\n\n이름 편집은 지금까지 찾은 모든 코스를 한 번에 보여줍니다. 나중에 이름을 바꾸거나 수정할 때 사용하세요.",
|
||||
"description": "안내 설정은 다음과 같이 진행됩니다. 기기가 아닌 SmartThings 앱에서 이 기기를 열고 \"다운로드 코스\"를 탭하세요. 이는 화면 아래쪽에 있는 \"코스\"와는 별도의 항목이며, 여기서 중요한 것은 이 항목입니다. 그곳에서 코스를 선택한 다음 돌아와서, 코스가 확인될 때마다 하나씩 이름을 지정하세요.\n\n이름 편집은 지금까지 찾은 모든 코스를 한 번에 보여줍니다. 나중에 이름을 바꾸거나 수정할 때 사용하세요. 이름 지정은 어느 경우든 가능하지만, 이름을 지정한 코스는 기기 설정에서 \"다운로드 코스 제공\"이 켜져 있을 때만 선택 가능한 코스로 제공됩니다.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "안내 설정",
|
||||
"cloud_manual": "이름 편집"
|
||||
@@ -1537,7 +1576,7 @@
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "이 코스 이름 지정",
|
||||
"description": "기기가 다운로드한 코스(슬롯 {slot})로 전환되었으며 {remaining} 남았다고 표시됩니다.\n\nHome Assistant에서 보고 싶은 이름을 입력한 다음, 기기에서 다음 코스를 선택하세요. 비워 두면 이 코스는 목록에서 제외됩니다.\n\n지금까지 이름 지정됨 ({named}/{total}개): {named_list}\n\n이름은 서로 달라야 합니다. 이 창은 언제든지 닫을 수 있습니다. 이름은 진행하는 대로 저장됩니다.",
|
||||
"description": "기기의 \"다운로드 코스\" 슬롯에 이제 새 코스(슬롯 {slot})가 들어 있으며 {remaining} 남았다고 표시됩니다.\n\nHome Assistant에서 보고 싶은 이름을 입력한 다음, SmartThings 앱의 \"다운로드 코스\" 화면에서 아직 \"다운로드됨\"으로 표시되지 않은 다음 코스를 선택하세요. 비워 두면 이 코스는 목록에서 제외됩니다.\n\n지금까지 이름 지정됨 ({named}/{total}개): {named_list}\n\n이름은 서로 달라야 합니다. 이 창은 언제든지 닫을 수 있습니다. 이름은 진행하는 대로 저장됩니다.",
|
||||
"data": {
|
||||
"name": "이름",
|
||||
"download_course": "다운로드 코스 코드"
|
||||
@@ -1545,7 +1584,7 @@
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "선택된 코스 없음",
|
||||
"description": "기기에서 아무것도 선택되지 않았습니다. 다운로드 코스로 설정되어 있는지 확인한 다음, 다운로드된 프로그램을 하나씩 실행해 보세요.\n\n지금까지 이름 지정됨 ({named}/{total}개): {named_list}\n\n지금까지 이름을 지정한 항목은 이미 저장되었습니다.",
|
||||
"description": "기기에서 변경된 내용이 없습니다. SmartThings 앱에서 \"다운로드 코스\"(화면 아래쪽에 있는 \"코스\"와는 별도의 항목)를 탭하고, 아직 \"다운로드됨\"으로 표시되지 않은 코스를 선택한 다음 완료를 탭했는지 확인하세요.\n\n지금까지 이름 지정됨 ({named}/{total}개): {named_list}\n\n지금까지 이름을 지정한 항목은 이미 저장되었습니다.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "다시 대기",
|
||||
"cloud_finish": "완료"
|
||||
@@ -1562,7 +1601,7 @@
|
||||
"not_loaded": "이 기기는 아직 연결되지 않았습니다. 기기를 불러온 후 다시 시도하세요."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "지금 기기에서 다운로드한 코스를 선택하세요.\n\n지금까지 이름 지정됨 ({named}/{total}개): {named_list}\n\n이 창은 언제든지 닫을 수 있습니다. 이름은 진행하는 대로 저장됩니다."
|
||||
"cloud_wait": "기기가 아닌 SmartThings 앱에서 이 기기를 열고 \"다운로드 코스\"를 탭하세요. 이는 화면 아래쪽에 있는 \"코스\"와는 별도의 항목이며, 이 방식으로 인식할 수 있는 코스만 나열됩니다. 아직 \"다운로드됨\"으로 표시되지 않은 코스를 선택한 다음 완료를 탭하세요.\n\n기기가 근처에 있거나 코스를 실행 중일 필요는 없습니다. 전원이 켜져 있고 연결되어 있기만 하면 됩니다.\n\n지금까지 이름 지정됨 ({named}/{total}개): {named_list}\n\n이 창은 언제든지 닫을 수 있습니다. 이름은 진행하는 대로 저장됩니다."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
@@ -1572,7 +1611,7 @@
|
||||
},
|
||||
"cloud_courses_undiscovered": {
|
||||
"title": "{device_name}의 다운로드 코스가 설정되지 않음",
|
||||
"description": "{device_name}에는 Home Assistant가 아직 제공할 수 없는 다운로드한 코스가 {total}개 중 {pending}개 있습니다. 다운로드한 코스는 기기에서 해당 코스가 로드된 상태로 확인되고 이름을 지정한 후에만 사용할 수 있습니다.\n\n설정하려면 설정 > 기기 및 서비스 > LocalThings > {device_name} > 구성 > 다운로드 코스로 이동하여 안내를 따르세요."
|
||||
"description": "{device_name}에는 Home Assistant가 아직 제공할 수 없는 다운로드한 코스가 {total}개 중 {pending}개 있습니다. 다운로드한 코스는 SmartThings 앱의 \"다운로드 코스\" 화면(\"코스\" 화면이 아님)에서 기기가 해당 코스로 로드된 상태로 확인되고 이름을 지정한 후에만 사용할 수 있습니다.\n\n설정하려면 설정 > 기기 및 서비스 > LocalThings > {device_name} > 구성 > 다운로드 코스로 이동하여 안내를 따르세요.\n\n다운로드 코스를 사용하지 않나요? 대신 구성 > 기기 설정으로 이동하여 \"다운로드 코스 제공\"을 꺼서 이 알림을 완전히 중지하세요."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -376,7 +376,25 @@
|
||||
"53": "AI drogen+",
|
||||
"4e": "Zelf drogen",
|
||||
"26": "Luchtverfrissing",
|
||||
"2a": "Hygiënische verzorging+"
|
||||
"2a": "Hygiënische verzorging+",
|
||||
"02": "AI drogen",
|
||||
"03": "Super Speed",
|
||||
"05": "Beddengoed",
|
||||
"07": "Fijne was",
|
||||
"09": "Overhemden",
|
||||
"0b": "Paddingverzorging",
|
||||
"0c": "Waterafstotende outdoorverzorging",
|
||||
"0e": "Handdoeken",
|
||||
"0f": "Wol",
|
||||
"11": "Koude lucht",
|
||||
"28": "Interne desinfectie met hete lucht",
|
||||
"37": "Blouses",
|
||||
"38": "Strijkdroog",
|
||||
"39": "Ruimte ontvochtigen",
|
||||
"3a": "Hygiënisch drogen",
|
||||
"3b": "Beddengoed/Stof verwijderen",
|
||||
"3c": "Sportkleding",
|
||||
"3d": "Spijkergoed"
|
||||
}
|
||||
},
|
||||
"favorite_capacity": {
|
||||
@@ -647,11 +665,31 @@
|
||||
"name": "Programma",
|
||||
"state": {
|
||||
"01": "Normaal",
|
||||
"02": "Extra intensief",
|
||||
"03": "Super Eco",
|
||||
"04": "Snelle was",
|
||||
"06": "XXL was",
|
||||
"05": "Wol/Lingerie",
|
||||
"06": "Beddengoed",
|
||||
"07": "Outdoor",
|
||||
"08": "Spoelen+centrifugeren",
|
||||
"09": "Trommel reinigen",
|
||||
"0a": "Handdoeken",
|
||||
"0b": "Kookwas",
|
||||
"0c": "Babyverzorging",
|
||||
"0d": "Alleen centrifugeren",
|
||||
"0e": "Bewolkte dag",
|
||||
"0f": "Zuivere was",
|
||||
"10": "Droogcentrifugeren",
|
||||
"11": "Zomer beddengoed",
|
||||
"12": "Katoen",
|
||||
"13": "Zwart katoen",
|
||||
"14": "Fijn ondergoed",
|
||||
"15": "Sportkleding",
|
||||
"16": "Blouses",
|
||||
"17": "Gedownload",
|
||||
"18": "Soft Bubble",
|
||||
"19": "AI Wash",
|
||||
"1a": "Overhemden",
|
||||
"1b": "Katoen",
|
||||
"1c": "Eco 40-60",
|
||||
"1d": "Super Speed",
|
||||
@@ -676,6 +714,7 @@
|
||||
"32": "Overhemden",
|
||||
"33": "Handdoeken",
|
||||
"34": "Gemengd",
|
||||
"35": "Eco katoen",
|
||||
"36": "Wassen+drogen",
|
||||
"37": "Air Wash",
|
||||
"38": "Katoen drogen",
|
||||
@@ -717,8 +756,7 @@
|
||||
"8f": "Intensief koud",
|
||||
"96": "Minder microvezels",
|
||||
"a0": "15' Snelle was",
|
||||
"b0": "Gemengde was",
|
||||
"35": "Eco katoen"
|
||||
"b0": "Gemengde was"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
@@ -1484,11 +1522,12 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Apparaatinstellingen",
|
||||
"description": "Sommige apparaten accepteren bepaalde schrijfbewerkingen (bijvoorbeeld de standaarddosering van wasmiddel of wasverzachter op een wasmachine), ook als ze melden dat de afstandsbediening is uitgeschakeld. LocalThings blokkeert standaard elke schrijfbewerking met een duidelijke foutmelding wanneer een apparaat meldt dat de afstandsbediening is uitgeschakeld, in plaats van het apparaat de opdracht stilzwijgend te laten weigeren. Schakel deze optie alleen in als je hebt bevestigd dat schrijfbewerkingen op dit apparaat echt werken wanneer de afstandsbediening is uitgeschakeld. Anders verruil je de duidelijke foutmelding voor een stille mislukking.\n\nDe geschatte eindtijd wordt bij elke poll opnieuw berekend op basis van de resterende tijd die het apparaat opgeeft, wat kan afwijken of met een minuut of wat worden bijgesteld tussen updates. Verhoog de minimale wijziging hieronder om de sensor op zijn laatst gerapporteerde waarde te houden totdat de schatting met minstens dat aantal minuten verandert, wat de ruis in geschiedenis/logboek vermindert. Zet op 0 om elke berekende wijziging te rapporteren.\n\nSommige modellen melden een modus die ze nooit als ondersteund opgeven: bijvoorbeeld een airco die in Quiet staat maar alleen Off/Sleep/Speed aanbiedt. LocalThings onthoudt elke zo waargenomen modus en blijft die aanbieden, zodat hij selecteerbaar blijft zodra het apparaat er minstens één keer in heeft gestaan. Schakel dit uit om alleen aan te bieden wat het apparaat opgeeft; gebruik \"Onthouden modi vergeten\" in het vorige scherm om te wissen wat al is onthouden.",
|
||||
"description": "Sommige apparaten accepteren bepaalde schrijfbewerkingen (bijvoorbeeld de standaarddosering van wasmiddel of wasverzachter op een wasmachine), ook als ze melden dat de afstandsbediening is uitgeschakeld. LocalThings blokkeert standaard elke schrijfbewerking met een duidelijke foutmelding wanneer een apparaat meldt dat de afstandsbediening is uitgeschakeld, in plaats van het apparaat de opdracht stilzwijgend te laten weigeren. Schakel deze optie alleen in als je hebt bevestigd dat schrijfbewerkingen op dit apparaat echt werken wanneer de afstandsbediening is uitgeschakeld. Anders verruil je de duidelijke foutmelding voor een stille mislukking.\n\nDe geschatte eindtijd wordt bij elke poll opnieuw berekend op basis van de resterende tijd die het apparaat opgeeft, wat kan afwijken of met een minuut of wat worden bijgesteld tussen updates. Verhoog de minimale wijziging hieronder om de sensor op zijn laatst gerapporteerde waarde te houden totdat de schatting met minstens dat aantal minuten verandert, wat de ruis in geschiedenis/logboek vermindert. Zet op 0 om elke berekende wijziging te rapporteren.\n\nSommige modellen melden een modus die ze nooit als ondersteund opgeven: bijvoorbeeld een airco die in Quiet staat maar alleen Off/Sleep/Speed aanbiedt. LocalThings onthoudt elke zo waargenomen modus en blijft die aanbieden, zodat hij selecteerbaar blijft zodra het apparaat er minstens één keer in heeft gestaan. Schakel dit uit om alleen aan te bieden wat het apparaat opgeeft; gebruik \"Onthouden modi vergeten\" in het vorige scherm om te wissen wat al is onthouden. Een wasapparaat met uit de cloud gedownloade programma's krijgt hier een herinnering zodra het er een meldt die Home Assistant nog niet kan aanbieden -- sommige apparaten melden er een, ook als je het scherm \"Gedownloade programma's\" in de SmartThings-app zelf nooit hebt geopend. Zet dit uit om die herinnering te stoppen en al benoemde gedownloade programma's uit de programmalijst te verbergen; niets van wat al is ingesteld gaat verloren, en bij opnieuw inschakelen verschijnt het meteen weer.",
|
||||
"data": {
|
||||
"bypass_remote_control_lock": "Schrijfbewerkingen toestaan wanneer afstandsbediening als uitgeschakeld wordt gemeld",
|
||||
"finish_time_hysteresis_minutes": "Geschatte eindtijd -- minimale wijziging (minuten)",
|
||||
"learn_device_modes": "Modi onthouden die het apparaat meldt maar niet als ondersteund opgeeft"
|
||||
"learn_device_modes": "Modi onthouden die het apparaat meldt maar niet als ondersteund opgeeft",
|
||||
"cloud_courses_enabled": "Gedownloade programma's aanbieden"
|
||||
}
|
||||
},
|
||||
"forget_learned_modes": {
|
||||
@@ -1519,14 +1558,14 @@
|
||||
},
|
||||
"cloud_manual": {
|
||||
"title": "Gedownloade programma's",
|
||||
"description": "Dit apparaat meldt {total} gedownloade programma's; {found} daarvan zijn tot nu toe gezien.\n\nDe instellingen van een gedownload programma zijn alleen zichtbaar zolang dat programma geladen is, en het apparaat meldt nooit de naam ervan. Om de ontbrekende ({pending}) toe te voegen: selecteer op het apparaat het programma \"Gedownload\", doorloop dan elk gedownload programma na elkaar, pauzeer bij elk een paar seconden, en kom hierna terug.\n\nGeef elk programma de naam die je in Home Assistant wilt zien. Laat een naam leeg om dat programma buiten de programmalijst te houden. Namen moeten uniek zijn.\n\nHet programma \"Gedownload\" is het programma op dit apparaat dat een gedownload programma uitvoert. Het wordt automatisch gedetecteerd, maar bevestig dit hier voor gebruik: als je een gedownload programma selecteert, wordt deze programmacode naar het apparaat geschreven.",
|
||||
"description": "Dit apparaat meldt {total} gedownloade programma's; {found} daarvan zijn tot nu toe gezien.\n\nDe instellingen van een gedownload programma zijn alleen zichtbaar zolang dat programma geladen is, en het apparaat meldt nooit de naam ervan. Om de ontbrekende ({pending}) toe te voegen: open in de SmartThings-app (niet op het apparaat zelf) dit apparaat en tik op \"Gedownloade programma's\" -- een aparte regel, los van \"Programma\", verderop op het scherm, en degene die hier telt. Doorloop dan elk gedownload programma na elkaar, pauzeer bij elk een paar seconden zodat LocalThings het kan zien, en kom hierna terug.\n\nGeef elk programma de naam die je in Home Assistant wilt zien. Laat een naam leeg om dat programma buiten de programmalijst te houden. Namen moeten uniek zijn.\n\nHet programma \"Gedownload\" is het programma op dit apparaat dat een gedownload programma uitvoert. Het wordt automatisch gedetecteerd, maar bevestig dit hier voor gebruik: als je een gedownload programma selecteert, wordt deze programmacode naar het apparaat geschreven.",
|
||||
"data": {
|
||||
"download_course": "Programmacode van \"Gedownload\""
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "Gedownloade programma's",
|
||||
"description": "Begeleide installatie loodst je stap voor stap langs het apparaat: selecteer op het apparaat een gedownload programma en geef het een naam zodra het gevonden is, één voor één.\n\nNamen bewerken toont in één keer alles wat tot nu toe is gevonden — gebruik dit later om iets te hernoemen of te corrigeren.",
|
||||
"description": "Begeleide installatie loodst je stap voor stap: open in de SmartThings-app (niet op het apparaat zelf) dit apparaat en tik op \"Gedownloade programma's\" -- een aparte regel, los van \"Programma\", verderop op het scherm, en degene die hier telt. Kies daar een programma, kom terug en geef het een naam zodra het gevonden is, één voor één.\n\nNamen bewerken toont in één keer alles wat tot nu toe is gevonden — gebruik dit later om iets te hernoemen of te corrigeren. Namen geven werkt sowieso, maar een benoemd programma wordt alleen als selecteerbaar programma aangeboden zolang \"Gedownloade programma's aanbieden\" aan staat in de apparaatinstellingen.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Begeleide installatie",
|
||||
"cloud_manual": "Namen bewerken"
|
||||
@@ -1537,7 +1576,7 @@
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "Geef dit programma een naam",
|
||||
"description": "Het apparaat is overgeschakeld naar een gedownload programma (slot {slot}) en meldt nog {remaining} resterend.\n\nGeef het de naam die je in Home Assistant wilt zien en selecteer daarna het volgende op het apparaat. Laat het veld leeg om dit programma buiten de lijst te houden.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nNamen moeten uniek zijn. Je kunt dit venster op elk moment sluiten — namen worden onderweg opgeslagen.",
|
||||
"description": "Het vak \"Gedownloade programma's\" van het apparaat bevat nu een nieuw programma (slot {slot}) en meldt nog {remaining} resterend.\n\nGeef het de naam die je in Home Assistant wilt zien en kies daarna in het scherm \"Gedownloade programma's\" van de SmartThings-app het volgende programma dat nog niet als \"Gedownload\" is gemarkeerd. Laat het veld leeg om dit programma buiten de lijst te houden.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nNamen moeten uniek zijn. Je kunt dit venster op elk moment sluiten — namen worden onderweg opgeslagen.",
|
||||
"data": {
|
||||
"name": "Naam",
|
||||
"download_course": "Programmacode van \"Gedownload\""
|
||||
@@ -1545,7 +1584,7 @@
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "Geen programma geselecteerd",
|
||||
"description": "Er is niets geselecteerd op het apparaat. Zorg dat het is ingesteld op het programma \"Gedownload\" en doorloop dan de gedownloade programma's.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nAlles wat tot nu toe benoemd is, is al opgeslagen.",
|
||||
"description": "Er is niets veranderd op het apparaat. Zorg dat je in de SmartThings-app op \"Gedownloade programma's\" hebt getikt -- een aparte regel, los van \"Programma\", verderop op het scherm -- en een programma hebt gekozen dat nog niet als \"Gedownload\" was gemarkeerd, en daarna op Klaar hebt getikt.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nAlles wat tot nu toe benoemd is, is al opgeslagen.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Opnieuw wachten",
|
||||
"cloud_finish": "Voltooien"
|
||||
@@ -1562,7 +1601,7 @@
|
||||
"not_loaded": "Dit apparaat is nog niet verbonden. Probeer het opnieuw zodra het is geladen."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "Selecteer nu een gedownload programma op het apparaat.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nJe kunt dit venster op elk moment sluiten — namen worden onderweg opgeslagen."
|
||||
"cloud_wait": "Open in de SmartThings-app (niet op het apparaat zelf) dit apparaat en tik op \"Gedownloade programma's\" -- een aparte regel, los van \"Programma\", verderop op het scherm, die alleen de programma's toont die op deze manier te herkennen zijn. Kies er een die nog niet als \"Gedownload\" is gemarkeerd en tik op Klaar.\n\nHet apparaat hoeft niet in de buurt te zijn of het programma uit te voeren -- het moet alleen aan staan en verbonden zijn.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nJe kunt dit venster op elk moment sluiten — namen worden onderweg opgeslagen."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
@@ -1572,7 +1611,7 @@
|
||||
},
|
||||
"cloud_courses_undiscovered": {
|
||||
"title": "Gedownloade programma's niet ingesteld voor {device_name}",
|
||||
"description": "{device_name} heeft {pending} van de {total} gedownloade programma's die Home Assistant nog niet kan aanbieden. Een gedownload programma kan pas worden gebruikt zodra het apparaat ermee geladen is gezien en je het een naam hebt gegeven.\n\nGa om ze in te stellen naar Instellingen > Apparaten & diensten > LocalThings > {device_name} > Configureren > Gedownloade programma's en volg de instructies daar."
|
||||
"description": "{device_name} heeft {pending} van de {total} gedownloade programma's die Home Assistant nog niet kan aanbieden. Een gedownload programma kan pas worden gebruikt zodra het apparaat ermee geladen is gezien -- in het scherm \"Gedownloade programma's\" van de SmartThings-app, niet in \"Programma\" -- en je het een naam hebt gegeven.\n\nGa om ze in te stellen naar Instellingen > Apparaten & diensten > LocalThings > {device_name} > Configureren > Gedownloade programma's en volg de instructies daar.\n\nGebruik je geen gedownloade programma's? Ga dan naar Configureren > Apparaatinstellingen en zet \"Gedownloade programma's aanbieden\" uit om deze herinnering voorgoed te stoppen."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
+4
@@ -8,6 +8,8 @@
|
||||
"cycle_active",
|
||||
"delay_start_hours",
|
||||
"diagnosis_status",
|
||||
"drum_clean_cycles_remaining",
|
||||
"drum_clean_last_cleaned",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"finish_time",
|
||||
@@ -40,6 +42,8 @@
|
||||
"samsung_dishwasher_delay_start_hours",
|
||||
"samsung_dishwasher_diagnosis_start",
|
||||
"samsung_dishwasher_diagnosis_status",
|
||||
"samsung_dishwasher_drum_clean_cycles_remaining",
|
||||
"samsung_dishwasher_drum_clean_last_cleaned",
|
||||
"samsung_dishwasher_energy_kwh",
|
||||
"samsung_dishwasher_energy_saved_kwh",
|
||||
"samsung_dishwasher_finish_time",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -1046,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.
|
||||
@@ -1054,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)
|
||||
|
||||
@@ -1068,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."""
|
||||
@@ -1180,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"),
|
||||
[
|
||||
|
||||
@@ -33,7 +33,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
|
||||
|
||||
|
||||
@@ -150,7 +156,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."""
|
||||
@@ -164,7 +170,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(
|
||||
@@ -175,7 +181,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
|
||||
@@ -186,7 +192,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]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -198,7 +204,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
|
||||
@@ -207,10 +213,10 @@ 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 coordinator.device_info["name"] == "Samsung Refrigerator"
|
||||
assert mock_entry.data[CONF_HOST] not in str(coordinator.device_info["identifiers"])
|
||||
|
||||
|
||||
@@ -239,7 +245,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:
|
||||
|
||||
@@ -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,61 @@
|
||||
"""What HA actually names the entities this integration registers.
|
||||
|
||||
Every other naming test checks an input to that -- a translation key, a
|
||||
device name, a unique_id. This one checks the output: it sets an entry up
|
||||
for real and reads the entity_ids out of the registry, which is the only
|
||||
place the two halves (device name + translated entity name) are combined.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .conftest import MOCK_DEVICE_KEY, MOCK_MODEL
|
||||
|
||||
|
||||
async def _entity_ids(hass: HomeAssistant, entry) -> list[str]:
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
registry = er.async_get(hass)
|
||||
return sorted(e.entity_id for e in er.async_entries_for_config_entry(registry, entry.entry_id))
|
||||
|
||||
|
||||
async def test_entity_ids_read_as_device_type_plus_entity(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_session
|
||||
) -> None:
|
||||
ids = await _entity_ids(hass, mock_entry)
|
||||
|
||||
assert "sensor.samsung_refrigerator_energy" in ids
|
||||
assert "binary_sensor.samsung_refrigerator_door_freezer_open" in ids
|
||||
|
||||
|
||||
async def test_no_entity_id_carries_the_device_key(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_session
|
||||
) -> None:
|
||||
"""The device key is a 36-character UUID since issue #381, and every
|
||||
entity's unique_id is minted from it -- but HA builds entity_ids from
|
||||
the device and entity names, so it stays internal. An entity that
|
||||
registers with no name is the one way it leaks: HA appends a literal
|
||||
'None' to the device name, and falls back to `<platform>_<unique_id>`
|
||||
outright for an entity with no device. test_translations stops that at
|
||||
the source by requiring a catalog entry per descriptor; this is the
|
||||
same check at the far end of the pipeline.
|
||||
"""
|
||||
ids = await _entity_ids(hass, mock_entry)
|
||||
|
||||
assert ids
|
||||
key_fragment = MOCK_DEVICE_KEY.split("-")[0]
|
||||
assert not [entity_id for entity_id in ids if key_fragment in entity_id]
|
||||
assert not [entity_id for entity_id in ids if "none" in entity_id.split(".")[1].split("_")]
|
||||
|
||||
|
||||
async def test_the_board_model_stays_off_the_entity_id(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_session
|
||||
) -> None:
|
||||
"""modelNum is a board string ('ARTIK051_DONGLE_REF'), so it belongs on
|
||||
the device's `model` field, not slugified into 46 entity_ids."""
|
||||
ids = await _entity_ids(hass, mock_entry)
|
||||
|
||||
slug = MOCK_MODEL.lower().replace("-", "_")
|
||||
assert not [entity_id for entity_id in ids if slug in entity_id]
|
||||
@@ -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
|
||||
|
||||
@@ -39,36 +44,37 @@ async def test_migration_recovers_serial_from_unique_id(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Straight through to the current version: v2 -> v3 is a statistics
|
||||
# relabel that no-ops for a family without particulate sensors.
|
||||
assert entry.version == 3
|
||||
# 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(
|
||||
@@ -76,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}"
|
||||
@@ -86,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
|
||||
|
||||
@@ -231,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(
|
||||
@@ -245,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)
|
||||
@@ -259,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=4)
|
||||
entry = MockConfigEntry(domain=DOMAIN, data=LEGACY_ENTRY_DATA, version=5)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
assert await async_migrate_entry(hass, entry) is False
|
||||
|
||||
@@ -72,7 +72,7 @@ async def test_relabels_every_particulate_sensor(hass: HomeAssistant) -> None:
|
||||
# µg/m³ has a converter, so the class must be named, not None --
|
||||
# passing neither is deprecated and breaks in HA Core 2026.11.
|
||||
assert call.kwargs["new_unit_class"] == "concentration"
|
||||
assert entry.version == 3
|
||||
assert entry.version == 4
|
||||
|
||||
|
||||
async def test_leaves_other_sensors_on_the_same_device_alone(hass: HomeAssistant) -> None:
|
||||
@@ -102,7 +102,7 @@ async def test_skips_families_that_did_not_gain_the_unit(hass: HomeAssistant) ->
|
||||
assert await async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert relabel.call_args_list == [], device_type
|
||||
assert entry.version == 3
|
||||
assert entry.version == 4
|
||||
|
||||
|
||||
async def test_defers_rather_than_consuming_the_migration_without_the_recorder(
|
||||
@@ -127,7 +127,7 @@ async def test_defers_rather_than_consuming_the_migration_without_the_recorder(
|
||||
assert await async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert len(relabel.call_args_list) == 1
|
||||
assert entry.version == 3
|
||||
assert entry.version == 4
|
||||
|
||||
|
||||
async def test_omits_unit_class_on_an_older_home_assistant(hass: HomeAssistant) -> None:
|
||||
@@ -151,7 +151,7 @@ async def test_omits_unit_class_on_an_older_home_assistant(hass: HomeAssistant)
|
||||
assert await async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert seen == [{"new_unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}]
|
||||
assert entry.version == 3
|
||||
assert entry.version == 4
|
||||
|
||||
|
||||
async def test_a_relabel_failure_never_fails_the_entry(hass: HomeAssistant) -> None:
|
||||
@@ -165,7 +165,7 @@ async def test_a_relabel_failure_never_fails_the_entry(hass: HomeAssistant) -> N
|
||||
with patch(RELABEL, autospec=True, side_effect=TypeError("older HA signature")):
|
||||
assert await async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert entry.version == 3
|
||||
assert entry.version == 4
|
||||
|
||||
|
||||
async def test_follows_a_renamed_entity_rather_than_rebuilding_its_id(
|
||||
@@ -214,8 +214,16 @@ async def test_matches_subdevice_prefixed_and_instanced_keys(hass: HomeAssistant
|
||||
|
||||
|
||||
async def test_a_fresh_entry_starts_at_the_migrated_version(hass: HomeAssistant) -> None:
|
||||
"""A newly created entry has no statistics to relabel, so the config flow
|
||||
mints v3 directly rather than walking through the migration."""
|
||||
"""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 == 3
|
||||
assert LocalThingsConfigFlow.VERSION == 4
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -5,6 +5,8 @@ cycle_options, cycle_write) is tested in test_laundry_capabilities.py; here we
|
||||
check the dishwasher wiring and its device-specific options.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from custom_components.localthings.registry.capabilities import dishwasher
|
||||
from custom_components.localthings.registry.entities import SensorDesc, SwitchDesc
|
||||
|
||||
@@ -62,6 +64,53 @@ class TestDishwasherOptions:
|
||||
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 these entities are wired the same way here."""
|
||||
|
||||
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(self):
|
||||
"""A live dump's DrumCleanLog_ is a '|'-joined history (the dryer
|
||||
shape, not the washer's single-entry one) -- the last entry wins."""
|
||||
desc = next(
|
||||
e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == "drum_clean_last_cleaned"
|
||||
)
|
||||
assert desc.rep_fn is not None
|
||||
rep = {
|
||||
"x.com.samsung.da.options": [
|
||||
"DrumCleanLog_2026-06-26T04:18:58|2026-06-28T00:36:34",
|
||||
]
|
||||
}
|
||||
assert desc.rep_fn(rep) == datetime(2026, 6, 28, 0, 36, 34, tzinfo=UTC)
|
||||
|
||||
def test_last_cleaned_missing(self):
|
||||
desc = next(
|
||||
e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == "drum_clean_last_cleaned"
|
||||
)
|
||||
assert desc.rep_fn is not None
|
||||
assert desc.rep_fn({"x.com.samsung.da.options": []}) is None
|
||||
assert desc.exists_fn is not None
|
||||
assert desc.exists_fn({"x.com.samsung.da.options": []}, {}) is False
|
||||
|
||||
|
||||
def test_diagnosis_status_is_a_translatable_enum():
|
||||
desc = next(
|
||||
e
|
||||
|
||||
@@ -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
|
||||
|
||||
+146
-1
@@ -1,6 +1,13 @@
|
||||
import cbor2
|
||||
|
||||
from custom_components.localthings.registry.identity import read_identity
|
||||
from custom_components.localthings.registry.identity import (
|
||||
DeviceIdentity,
|
||||
device_display_name,
|
||||
is_usable_device_id,
|
||||
ocf_device_key,
|
||||
read_identity,
|
||||
resolve_device_key,
|
||||
)
|
||||
|
||||
|
||||
class FakeSession:
|
||||
@@ -124,3 +131,141 @@ 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"
|
||||
|
||||
|
||||
def test_the_device_name_is_the_device_type_alone():
|
||||
"""The name is what HA slugifies into every entity_id on the device, so
|
||||
the board string that modelNum reports stays out of it -- it is still
|
||||
registered as the device's `model` (see coordinator.device_info)."""
|
||||
assert device_display_name("refrigerator") == "Samsung Refrigerator"
|
||||
assert device_display_name("range_hood") == "Samsung Range Hood"
|
||||
assert device_display_name(None) == "Samsung Appliance"
|
||||
|
||||
|
||||
def test_registry_type_names_that_dont_title_case_into_english():
|
||||
"""Two registry names are SmartThings/Samsung spellings, not what the
|
||||
appliance is called -- and this one is the user-visible half."""
|
||||
assert device_display_name("airconditioner") == "Samsung Air Conditioner"
|
||||
assert device_display_name("ehs") == "Samsung Heat Pump"
|
||||
|
||||
@@ -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
|
||||
@@ -18,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
|
||||
|
||||
@@ -46,7 +46,7 @@ class _FakeConfigEntry:
|
||||
|
||||
class _FakeCoordinator:
|
||||
def __init__(self):
|
||||
self.device_serial = "TEST-SERIAL"
|
||||
self.device_key = "TEST-SERIAL"
|
||||
self.config_entry = _FakeConfigEntry()
|
||||
self.resources: dict[str, dict] = {}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
|
||||
@@ -147,12 +147,14 @@ 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)
|
||||
# is what names/models this device, not the master's.
|
||||
# is what models this device, not the master's -- but it never reaches
|
||||
# the name, which HA slugifies into this subdevice's entity_ids.
|
||||
assert info["model"] == "ARTIK051_DONGLE_FAC_RAC_18K"
|
||||
assert info["name"] == "Samsung Air Conditioner Unit 2"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -245,12 +247,15 @@ 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
|
||||
# subdevice's own identity, distinct from the master's TP2X_FAC_BORA_21K.
|
||||
assert info["model"] == "TP2X_FAC_BORA_RAC_21K"
|
||||
# Neither the board string nor the subdevice UUID is a name a user
|
||||
# wants slugified into `climate.<this>_...`.
|
||||
assert info["name"] == "Samsung Air Conditioner Unit 2"
|
||||
|
||||
|
||||
async def test_fac_bora_2in1_unique_ids_include_subdevice_prefix(hass: HomeAssistant):
|
||||
@@ -266,7 +271,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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
+167
-2
@@ -180,10 +180,14 @@ 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",
|
||||
@@ -211,6 +215,167 @@ def test_confirmed_washer_table_02_ww90dg5g34able_course_names():
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -20,7 +20,7 @@ from tests.conftest import _load_device
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = "TEST-EHS-SERIAL"
|
||||
device_key = "TEST-EHS-SERIAL"
|
||||
device_info: ClassVar[dict] = {}
|
||||
data: ClassVar[dict] = {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user