Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5e25d72d3 | ||
|
|
6c980f927b | ||
|
|
207dd2f830 | ||
|
|
b0caebe14d | ||
|
|
513d44e8be | ||
|
|
2a52cd9cfa | ||
|
|
16dfcd4e50 | ||
|
|
4edd5b7866 | ||
|
|
51bce3c8cf | ||
|
|
35f144a2d1 | ||
|
|
43076352ae | ||
|
|
171316bd95 | ||
|
|
61d2b9e9ff | ||
|
|
5ab0c9b5a8 | ||
|
|
6003394cb2 | ||
|
|
66b4f1400b | ||
|
|
c573a41483 | ||
|
|
b5969773db | ||
|
|
9a45afe949 | ||
|
|
296ba18cba | ||
|
|
258cdc1647 | ||
|
|
33a06b9ae8 | ||
|
|
01c9dc9801 | ||
|
|
f780cc6069 | ||
|
|
d8ebc17808 | ||
|
|
367017cc4c | ||
|
|
ccb4a09c65 | ||
|
|
1b8e8368ca | ||
|
|
c7aa66ef6e | ||
|
|
1e9fbd4ec5 | ||
|
|
81dbc6fa03 |
@@ -85,7 +85,7 @@ This repo doesn't include the needed CA bundle. For an example of how to obtain
|
||||
5. The flow sends a DTLS `ClientHello` to every port in the `49152-49160` range at once and keeps the one that answers -- a real DTLS server identifies itself in about one round trip, and the probe stops there, so nothing is left behind on the appliance. Only that port is then given a real certificate handshake: it fetches the current UUID from Samsung's cloud gateway, mints a leaf cert signed by your CA, and reads the device's identity and `/device/0`. On success it creates the config entry, already knowing the appliance's serial, model, and type.
|
||||
6. Every subsequent device only asks for the host IP. The stored CA credentials are reused, and so is the leaf cert itself -- every appliance accepts the same one -- so adding a second appliance doesn't depend on Samsung's cloud being reachable at all. If a device rejects the reused cert (the UUID behind it does rotate), the flow mints a fresh one and retries by itself.
|
||||
|
||||
Entities appear under one HA device per appliance, named for the appliance's type and model. Rename freely: the device is keyed on its serial, not its name.
|
||||
Entities appear under one HA device per appliance, named for the appliance's type and model. Rename freely: the device is keyed on the appliance's own OCF device ID, not its name. (Some Samsung models ship the same serial number on every unit of a model, so the serial can't tell two of them apart -- the OCF device ID can.)
|
||||
|
||||
---
|
||||
|
||||
@@ -237,7 +237,8 @@ docker-compose.yml / ha_config/ Local HA dev environment
|
||||
If your appliance's type isn't recognized, or it exposes resources this integration doesn't model yet, a Repairs
|
||||
issue appears under Settings > System > Repairs pointing you at Settings > Devices & Services > this device >
|
||||
the menu > Download diagnostics. That download is already redacted of account/network identifiers (Bixby login
|
||||
email, access tokens, device IDs, MAC addresses, serial numbers) before it's generated, so it's safe to attach
|
||||
email, access tokens, hashed device IDs, MAC addresses, serial numbers, and the owner-set device name) before it's
|
||||
generated, so it's safe to attach
|
||||
directly to a new issue using the linked device-support template. This is the fastest way to help add or expand
|
||||
support for hardware the maintainers don't have.
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ 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__)
|
||||
@@ -82,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
|
||||
@@ -249,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:
|
||||
@@ -268,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
|
||||
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ from .const import (
|
||||
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,
|
||||
@@ -662,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)
|
||||
|
||||
@@ -680,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,
|
||||
@@ -798,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 = ""
|
||||
@@ -817,7 +833,7 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Persist everything the probe resolved, identity included.
|
||||
|
||||
The identity fields aren't decoration: the coordinator seeds
|
||||
`device_serial` and its DeviceInfo from them at construction time,
|
||||
`device_key` and its DeviceInfo from them at construction time,
|
||||
so entity unique_ids are correct from the first entity that
|
||||
registers, even if the first poll is slow or fails (issue #236).
|
||||
"""
|
||||
@@ -832,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"],
|
||||
@@ -878,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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -31,6 +31,7 @@ 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,
|
||||
@@ -66,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,
|
||||
@@ -78,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".
|
||||
@@ -205,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.
|
||||
@@ -310,15 +313,17 @@ 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)},
|
||||
identifiers={(DOMAIN, self.device_key)},
|
||||
name=device_display_name(
|
||||
entry.data.get(CONF_DEVICE_TYPE), entry.data.get(CONF_MODEL) or ""
|
||||
),
|
||||
@@ -332,6 +337,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self.device_type_name: str | None = None
|
||||
self.one_ui_version: str = ""
|
||||
self._consecutive_poll_timeouts = 0
|
||||
# Set by _poll_once when the failure was the DTLS handshake itself.
|
||||
# A switched-off appliance fails there every cycle, and there is no
|
||||
# session to tear down and re-establish -- see _async_update_data.
|
||||
self._handshake_failed = False
|
||||
# Consecutive cycles that ended with no data from the device, so an
|
||||
# outage is reported once rather than once per poll (issue #269).
|
||||
self._failed_cycles = 0
|
||||
self._unbound_hrefs: list[str] = []
|
||||
self._reconnect_times: list[float] = []
|
||||
# See _maybe_retry_observe_mode: last_mode_change_ts alone doesn't
|
||||
@@ -768,8 +780,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
else "Secondary Subdevice"
|
||||
)
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{self.device_serial}_{subdevice.key}")},
|
||||
via_device=(DOMAIN, self.device_serial),
|
||||
identifiers={(DOMAIN, f"{self.device_key}_{subdevice.key}")},
|
||||
via_device=(DOMAIN, self.device_key),
|
||||
name=f"{base_name} {label}",
|
||||
manufacturer=self.device_info.get("manufacturer") or "Samsung",
|
||||
model=model or None,
|
||||
@@ -856,9 +868,18 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
call's own timeout and surfacing as an ambiguous `TimeoutError`.
|
||||
See `_defer_reconnect_for` for what that changes about how soon a
|
||||
confirmed-dead session gets reconnected.
|
||||
|
||||
Sets `_handshake_failed` so `_async_update_data` can tell a broken
|
||||
session from one that never opened -- a switched-off appliance fails
|
||||
in `_connect_session` every cycle, with nothing to reconnect.
|
||||
"""
|
||||
if self._session is None:
|
||||
self._connect_session()
|
||||
try:
|
||||
self._connect_session()
|
||||
except Exception:
|
||||
self._handshake_failed = True
|
||||
raise
|
||||
self._handshake_failed = False
|
||||
sess = self._session
|
||||
assert sess is not None
|
||||
try:
|
||||
@@ -970,22 +991,31 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _run_subpolls(self, force: bool = False) -> None:
|
||||
"""Poll hot/warm hrefs in the gaps between summary polls. No-op in
|
||||
observe-primary mode (those hrefs are already covered by push)
|
||||
unless `force` is set -- set when this cycle's sweep found the
|
||||
cache disagreeing with a still-live observe session (see
|
||||
log_sweep_discrepancies): a bounded fallback for a channel gone
|
||||
silent without a reconnect."""
|
||||
"""Poll hot/warm hrefs in the gaps between summary polls.
|
||||
|
||||
In observe-primary mode this is a no-op for hrefs the device is
|
||||
actually pushing, unless `force` is set (sweep disagreed with the
|
||||
cache -- see log_sweep_discrepancies). Hrefs that were subscribed
|
||||
but stayed silent through the grace period (issue #92) stay on
|
||||
the hot/warm cadence via `fallback_hrefs`.
|
||||
"""
|
||||
if self._observe.mode == MODE_OBSERVE and not force:
|
||||
return
|
||||
hot = self._hot_hrefs
|
||||
warm = self._warm_hrefs
|
||||
silent = self._observe.fallback_hrefs
|
||||
if not silent:
|
||||
return
|
||||
hot = [h for h in self._hot_hrefs if h in silent]
|
||||
warm = [h for h in self._warm_hrefs if h in silent]
|
||||
else:
|
||||
hot = self._hot_hrefs
|
||||
warm = self._warm_hrefs
|
||||
if not hot and not warm:
|
||||
return
|
||||
step = self._SUBPOLL_STEP_S
|
||||
for i in range(1, 10): # slots 1..9 (T+3 s … T+27 s)
|
||||
await asyncio.sleep(step)
|
||||
hrefs = list(hot) + (list(warm) if i % 2 == 0 else [])
|
||||
if not hrefs:
|
||||
continue
|
||||
async with self._session_lock:
|
||||
try:
|
||||
await self.hass.async_add_executor_job(self._poll_hrefs_blocking, hrefs)
|
||||
@@ -1093,8 +1123,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,
|
||||
@@ -1108,9 +1217,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,
|
||||
@@ -1195,6 +1312,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 {},
|
||||
)
|
||||
@@ -1346,26 +1469,11 @@ 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)
|
||||
@@ -1373,12 +1481,16 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
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
|
||||
@@ -1392,9 +1504,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],
|
||||
@@ -1578,6 +1690,42 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._reconnect_times.append(now)
|
||||
return len(self._reconnect_times) >= self._RECONNECT_WARN_THRESHOLD
|
||||
|
||||
def _mark_device_answered(self) -> None:
|
||||
"""Clear the bookkeeping a poll getting through invalidates."""
|
||||
self._consecutive_poll_timeouts = 0
|
||||
if self._failed_cycles:
|
||||
self._log.info("device answered again after %d failed cycles", self._failed_cycles)
|
||||
self._failed_cycles = 0
|
||||
|
||||
def _device_unreachable(self, what: str, e: Exception) -> dict[str, Any]:
|
||||
"""End a cycle that got no data, either degraded or as a failure.
|
||||
|
||||
Reported once per outage rather than once per cycle: an appliance
|
||||
that is switched off fails identically every 30s for as long as it
|
||||
stays off (issue #269), and this integration is built to sit through
|
||||
exactly that (issue #295). Home Assistant logs the transition into
|
||||
and out of a failed update on its own.
|
||||
|
||||
Raises `UpdateFailed` unless there are bound entities and cached
|
||||
state to carry the last-known values on -- same precondition as
|
||||
`_defer_reconnect_for` (issue #254).
|
||||
"""
|
||||
self._failed_cycles += 1
|
||||
if self._failed_cycles == 1:
|
||||
self._log.error("%s: %s", what, e)
|
||||
else:
|
||||
self._log.debug("%s (%d cycles): %s", what, self._failed_cycles, e)
|
||||
# Without this, a fully unreachable device left the connection-mode
|
||||
# sensor stuck on "Push" forever -- only a successful poll ever
|
||||
# downgraded it (issue #287). No just_downgraded_from_observe here:
|
||||
# there's no live session this cycle to resubscribe on.
|
||||
if self._observe.mode == MODE_OBSERVE:
|
||||
self._observe.downgrade_to_poll()
|
||||
if self._discovered and self._cache.snapshot():
|
||||
self._log.debug("Full error:", exc_info=e)
|
||||
return flatten(self.bound, self.entity_resources())
|
||||
raise UpdateFailed(f"{what}: {e}") from e
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DataUpdateCoordinator hook
|
||||
# ------------------------------------------------------------------
|
||||
@@ -1591,7 +1739,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
async with self._session_lock:
|
||||
try:
|
||||
resources = await self.hass.async_add_executor_job(self._poll_once)
|
||||
self._consecutive_poll_timeouts = 0
|
||||
self._mark_device_answered()
|
||||
except Exception as e:
|
||||
if self._defer_reconnect_for(e):
|
||||
self._log.debug(
|
||||
@@ -1602,6 +1750,15 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
)
|
||||
return flatten(self.bound, self.entity_resources())
|
||||
self._consecutive_poll_timeouts = 0
|
||||
if self._handshake_failed:
|
||||
# The handshake never completed, so there is no session
|
||||
# to close and no association for the device to clean
|
||||
# up: reconnecting would just repeat the same doomed
|
||||
# handshake five seconds later. That doubled what a
|
||||
# switched-off appliance costs -- two handshake timeouts
|
||||
# per cycle, and the same wait again on every setup
|
||||
# attempt while it stays dark (issue #269).
|
||||
return self._device_unreachable("device unreachable", e)
|
||||
# A lone reconnect is routine (README's "Known device
|
||||
# behavior"); only warn once they pile up. Pause first so
|
||||
# the device can clean up its DTLS state before we knock
|
||||
@@ -1615,23 +1772,9 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
try:
|
||||
resources = await self.hass.async_add_executor_job(self._poll_once)
|
||||
except Exception as e2:
|
||||
self._log.error("poll failed after reconnect: %s", e2)
|
||||
# Without this, a fully unreachable device left the
|
||||
# connection-mode sensor stuck on "Push" forever -- only
|
||||
# the success branch below ever downgraded it (issue
|
||||
# #287). No just_downgraded_from_observe here: there's no
|
||||
# live session this cycle to resubscribe on.
|
||||
if self._observe.mode == MODE_OBSERVE:
|
||||
self._observe.downgrade_to_poll()
|
||||
snapshot = self._cache.snapshot()
|
||||
# Same precondition as _defer_reconnect_for (issue #254):
|
||||
# degraded-but-successful data only makes sense once
|
||||
# there are bound entities to carry it.
|
||||
if self._discovered and snapshot:
|
||||
self._log.debug("Full error:", exc_info=e2)
|
||||
return flatten(self.bound, self.entity_resources())
|
||||
raise UpdateFailed(f"poll failed after reconnect: {e2}") from e2
|
||||
return self._device_unreachable("poll failed after reconnect", e2)
|
||||
else:
|
||||
self._mark_device_answered()
|
||||
# A fresh session has zero OBSERVE registrations; if we
|
||||
# were in observe mode that state is now stale. Tear it
|
||||
# down and resubscribe immediately below instead of
|
||||
|
||||
@@ -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.23.0"
|
||||
"version": "0.24.0"
|
||||
}
|
||||
|
||||
@@ -95,8 +95,15 @@ class ObserveManager:
|
||||
self._notified: set[str] = set()
|
||||
self._last_notify_ts: float | None = None
|
||||
# Wakes try_enter_observe_mode's grace wait early once enough hrefs
|
||||
# have notified. Guards only `_notified` mutations + the `wait_for`.
|
||||
# have notified. Guards `_notified` mutations, the `wait_for`, and
|
||||
# fallback_hrefs (enter_observe_mode assignment, on_notification
|
||||
# discard).
|
||||
self._notify_cond = threading.Condition()
|
||||
# Idle while polling, except after downgrade_to_poll (every href
|
||||
# that was subscribed). While in observe mode this is the set of
|
||||
# subscribed hrefs that have not yet notified (issue #92) -- they
|
||||
# stay on the hot/warm sub-poll cadence. on_notification discards
|
||||
# an href once it pushes, so a late first notify self-corrects.
|
||||
self.fallback_hrefs: set[str] = set()
|
||||
self._on_applied: Callable[[str, dict, str], None] | None = None
|
||||
self._refresh_task: ObserveRefreshTask | None = None
|
||||
@@ -205,6 +212,10 @@ class ObserveManager:
|
||||
return
|
||||
with self._notify_cond:
|
||||
self._notified.add(href)
|
||||
# Snapshot in enter_observe_mode is at the 80% quorum, not the
|
||||
# full grace period -- a late first push (blockwise refetch)
|
||||
# must drop the href so we don't keep GET-polling a live one.
|
||||
self.fallback_hrefs.discard(href)
|
||||
self._last_notify_ts = time.monotonic()
|
||||
self._notify_cond.notify_all()
|
||||
self.log.debug("observe notify: %s", href)
|
||||
@@ -270,6 +281,13 @@ class ObserveManager:
|
||||
#294) -- committing against a session a reconnect already replaced
|
||||
would claim observe mode with nothing left to notice it's dead."""
|
||||
self.subscribed_hrefs = set(subscribed)
|
||||
# Issue #92: subscribed-but-silent hrefs are counted as covered by
|
||||
# push if we drop this, but they never emit a notify. Keep them on
|
||||
# the poll cadence via fallback_hrefs (otherwise idle in observe).
|
||||
# Same lock as on_notification's discard so a notify in this window
|
||||
# cannot land on a set object that is about to be replaced.
|
||||
with self._notify_cond:
|
||||
self.fallback_hrefs = set(subscribed) - self._notified
|
||||
self._set_mode(MODE_OBSERVE)
|
||||
self.start_refresh_task(session)
|
||||
|
||||
|
||||
@@ -7,8 +7,9 @@ only); this registry deliberately doesn't include common.POWER.
|
||||
air_purifier.AIR_QUALITY and range_hood.AIR_QUALITY already read via
|
||||
common.sensor_item_value -- reused here rather than re-decoded, including
|
||||
the same dust/fine_dust/super_fine_dust/odor/clean_level keys so this
|
||||
device shares those capabilities' catalog entries. This board additionally
|
||||
reports a CO2 reading the other two families don't.
|
||||
device shares those capabilities' catalog entries. This board reports a
|
||||
CO2 reading; air_purifier.AIR_QUALITY now models the same type when a
|
||||
purifier lists it (issue #387).
|
||||
|
||||
A second `value` list element on the particulate-matter types (Dust's
|
||||
`['31', '2']`) is the device's own graded air-quality level for that
|
||||
|
||||
@@ -34,7 +34,13 @@ from ..entities import (
|
||||
SwitchDesc,
|
||||
TimeDesc,
|
||||
)
|
||||
from .common import epoch_to_utc, filter_usage_percent, int_or_none, sensor_item_value
|
||||
from .common import (
|
||||
epoch_to_utc,
|
||||
filter_usage_percent,
|
||||
has_sensor_type,
|
||||
int_or_none,
|
||||
sensor_item_value,
|
||||
)
|
||||
from .laundry import bool_option_exists, bool_option_value, option_value, option_write
|
||||
|
||||
# Newer TP1X_DA-AC-AIR-class boards (issue #130) report fan modes directly
|
||||
@@ -106,17 +112,35 @@ _AIR_QUALITY_SENSORS = (
|
||||
AIR_QUALITY = Capability(
|
||||
href="/sensors/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=tuple(
|
||||
entities=(
|
||||
*(
|
||||
SensorDesc(
|
||||
key=key,
|
||||
field="x.com.samsung.da.items",
|
||||
icon=icon,
|
||||
state_class=state_class,
|
||||
device_class=device_class,
|
||||
unit=unit,
|
||||
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
|
||||
)
|
||||
for key, icon, sensor_type, state_class, device_class, unit in _AIR_QUALITY_SENSORS
|
||||
),
|
||||
# CO2 (issue #387) -- same field/shape air_monitor.SENSORS already
|
||||
# models with device_class='carbon_dioxide'/unit='ppm'. Gated on the
|
||||
# type being listed so boards that don't report it (every current
|
||||
# fixture) don't grow an empty entity. Disabled by default for the
|
||||
# same reason as airconditioner.AIR_QUALITY (issue #166).
|
||||
SensorDesc(
|
||||
key=key,
|
||||
key="co2",
|
||||
field="x.com.samsung.da.items",
|
||||
icon=icon,
|
||||
state_class=state_class,
|
||||
device_class=device_class,
|
||||
unit=unit,
|
||||
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
|
||||
)
|
||||
for key, icon, sensor_type, state_class, device_class, unit in _AIR_QUALITY_SENSORS
|
||||
icon="mdi:molecule-co2",
|
||||
device_class="carbon_dioxide",
|
||||
state_class="measurement",
|
||||
unit="ppm",
|
||||
exists_fn=has_sensor_type("CO2"),
|
||||
enabled_default=False,
|
||||
value_fn=lambda items: sensor_item_value(items, "CO2"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ from ..entities import (
|
||||
SwitchDesc,
|
||||
)
|
||||
from . import common
|
||||
from .common import filter_usage_hours, filter_usage_percent, normalize_temp_unit
|
||||
from .common import filter_usage_hours, filter_usage_percent, has_sensor_type, normalize_temp_unit
|
||||
from .laundry import option_write
|
||||
|
||||
|
||||
@@ -119,26 +119,6 @@ def _sensor_item_value(items, type_):
|
||||
return None
|
||||
|
||||
|
||||
def _has_sensor_type(type_):
|
||||
"""True when /sensors/vs/0's items[] lists an item of this type.
|
||||
|
||||
This only proves the type is *listed*, not that the reading is real:
|
||||
issue #166 (ARTIK051_PRAC_20K) lists all five types with permanent-zero
|
||||
values on units the reporter confirmed don't have the hardware. So
|
||||
entities gated on this stay disabled by default (see AIR_QUALITY) rather
|
||||
than existence-gated further, to avoid silently dropping real readings
|
||||
on hardware not yet seen.
|
||||
"""
|
||||
|
||||
def fn(rep, resources):
|
||||
return any(
|
||||
isinstance(i, dict) and i.get("x.com.samsung.da.type") == type_
|
||||
for i in (rep.get("x.com.samsung.da.items") or [])
|
||||
)
|
||||
|
||||
return fn
|
||||
|
||||
|
||||
# Canonical AC resource hrefs. climate.py binds HREF_MODE and reads the
|
||||
# CLIMATE_CONSUMED_HREFS siblings off the coordinator snapshot; declared once
|
||||
# here so climate.py and the coverage list below can't drift out of sync.
|
||||
@@ -1570,7 +1550,7 @@ WINDSLEEP = Capability(
|
||||
# /sensors/vs/0 items[] carry live air-quality readings. CleanLevel is
|
||||
# corroborated as numeric by a top-level x.com.samsung.da.cleanLevel scalar,
|
||||
# so it's a measurement; the others stay string diagnostics (see
|
||||
# _sensor_item_value). All disabled by default: _has_sensor_type only proves
|
||||
# _sensor_item_value). All disabled by default: has_sensor_type only proves
|
||||
# the item type is listed, not that the sensor is real (see its docstring).
|
||||
AIR_QUALITY = Capability(
|
||||
href="/sensors/vs/0",
|
||||
@@ -1582,7 +1562,7 @@ AIR_QUALITY = Capability(
|
||||
icon="mdi:broom",
|
||||
entity_category="diagnostic",
|
||||
state_class="measurement",
|
||||
exists_fn=_has_sensor_type("CleanLevel"),
|
||||
exists_fn=has_sensor_type("CleanLevel"),
|
||||
enabled_default=False,
|
||||
value_fn=lambda items: _int(_sensor_item_value(items, "CleanLevel")),
|
||||
),
|
||||
@@ -1592,7 +1572,7 @@ AIR_QUALITY = Capability(
|
||||
field="x.com.samsung.da.items",
|
||||
icon=icon,
|
||||
entity_category="diagnostic",
|
||||
exists_fn=_has_sensor_type(type_),
|
||||
exists_fn=has_sensor_type(type_),
|
||||
enabled_default=False,
|
||||
value_fn=lambda items, t=type_: _sensor_item_value(items, t),
|
||||
)
|
||||
@@ -1618,7 +1598,7 @@ AIR_QUALITY = Capability(
|
||||
device_class="carbon_dioxide",
|
||||
state_class="measurement",
|
||||
unit="ppm",
|
||||
exists_fn=_has_sensor_type("CO2"),
|
||||
exists_fn=has_sensor_type("CO2"),
|
||||
enabled_default=False,
|
||||
value_fn=lambda items: _int(_sensor_item_value(items, "CO2")),
|
||||
),
|
||||
|
||||
@@ -299,6 +299,30 @@ def sensor_item_value(items, sensor_type, index=0):
|
||||
return None
|
||||
|
||||
|
||||
def has_sensor_type(type_):
|
||||
"""True when /sensors/vs/0's items[] lists an item of this type.
|
||||
|
||||
This only proves the type is *listed*, not that the reading is real:
|
||||
issue #166 (ARTIK051_PRAC_20K) lists all five types with permanent-zero
|
||||
values on units the reporter confirmed don't have the hardware. So
|
||||
entities gated on this stay disabled by default rather than
|
||||
existence-gated further, to avoid silently dropping real readings on
|
||||
hardware not yet seen.
|
||||
|
||||
is_stub_rep(rep) keeps the stub carve-out (see entity._is_included /
|
||||
ENERGY_METER, issue #127): an explicit exists_fn otherwise bypasses it
|
||||
and would drop the entity when /device/0 returns a not-yet-fetched stub.
|
||||
"""
|
||||
|
||||
def fn(rep, resources):
|
||||
return is_stub_rep(rep) or any(
|
||||
isinstance(i, dict) and i.get("x.com.samsung.da.type") == type_
|
||||
for i in (rep.get("x.com.samsung.da.items") or [])
|
||||
)
|
||||
|
||||
return fn
|
||||
|
||||
|
||||
# OCF-native / vendor '-vs' fallback pairs for power, kids-lock, remote
|
||||
# control: each exists as both a standard OCF resource (/power/0,
|
||||
# oic.r.switch.binary, plain boolean 'value') and a Samsung vendor
|
||||
|
||||
@@ -10,7 +10,11 @@ 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,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /dishwasher/vs/0 — cycle wash/dry settings
|
||||
@@ -52,6 +56,19 @@ DISHWASHER_SETTINGS = Capability(
|
||||
# kind of adjacent pair a manual screenshot transcription slips on. The
|
||||
# reporter's live confirmation (selecting 'Normal' ran the physical Express
|
||||
# 60 program and vice versa) settled it: '86' is Express 60, '83' is Normal.
|
||||
#
|
||||
# Drum Clean+ maintenance tracking reuses washer.py/dryer.py's (issues #9,
|
||||
# #258) DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens riding on this
|
||||
# same options[] array -- a live dump confirmed the dishwasher reports the
|
||||
# identical trio (WashingTimes_18/DrumCleanProposal_20, plus a '|'-joined
|
||||
# DrumCleanLog_ history matching the dryer's multi-entry shape), so
|
||||
# laundry.drum_clean_cycles_remaining applies unchanged.
|
||||
#
|
||||
# laundry.drum_clean_last_cleaned (DrumCleanLog_'s own newest entry) is
|
||||
# deliberately NOT wired up here (issue #398): a live dishwasher dump
|
||||
# showed it moving every 30-90s on its own, including well after a cycle
|
||||
# had already finished -- unlike the washer/dryer reports this reader was
|
||||
# built from (issues #9, #258), it never settles on a value worth showing.
|
||||
|
||||
CYCLE_OPTIONS = Capability(
|
||||
href="/course/vs/0",
|
||||
@@ -61,6 +78,14 @@ CYCLE_OPTIONS = Capability(
|
||||
bool_option_switch(
|
||||
"auto_release_dry", "mdi:door-open", "AutoDoorRelease", gate_on_presence=True
|
||||
),
|
||||
SensorDesc(
|
||||
key="drum_clean_cycles_remaining",
|
||||
unit="cycles",
|
||||
icon="mdi:dishwasher-alert",
|
||||
state_class="measurement",
|
||||
exists_fn=lambda rep, resources: drum_clean_cycles_remaining(rep) is not None,
|
||||
rep_fn=drum_clean_cycles_remaining,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -63,6 +63,12 @@ DRYER_SETTINGS = Capability(
|
||||
# code. It shares no codes with Table_03 above -- 'a5' Bedding here and
|
||||
# '01' Normal are both table-scoped, so a Table_03 dryer never picks up a
|
||||
# Table_00 label or vice versa (see laundry.cycle_select's table_href).
|
||||
# A DV6800N -- same DA_WM_A51_20_COMMON board, also Table_00 -- confirmed
|
||||
# 14 more courses the same way (issue #394); its /course/vs/0 supportedOptions
|
||||
# only advertises a different subset of this same table (each model exposes
|
||||
# whichever courses its hardware supports), not a conflicting code family --
|
||||
# the one code both reporters confirmed, 'a5', means Bedding on both. Folded
|
||||
# into the same catalog entry below rather than a new one.
|
||||
#
|
||||
# Drum Clean+ maintenance tracking (issue #258) reuses washer.py's
|
||||
# DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens on this same
|
||||
|
||||
@@ -16,7 +16,7 @@ array.
|
||||
"""
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc
|
||||
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||
from .laundry import (
|
||||
bool_option_exists,
|
||||
bool_option_switch,
|
||||
@@ -264,6 +264,105 @@ def _bool_option_switch(key, icon, prefix, availability_field):
|
||||
)
|
||||
|
||||
|
||||
# AddWash -- the little door for adding a forgotten sock mid-cycle -- rides
|
||||
# three independent tokens on the same options[] array:
|
||||
#
|
||||
# AddWashSet_<0-7> the alarm setting, and the only writable one:
|
||||
# a 3-bit mask over the moments it fires, bit 0
|
||||
# rinse, bit 1 final rinse, bit 2 spin.
|
||||
# AddWashAvailable_<0-7> the same three bits, but what the running
|
||||
# course still permits.
|
||||
# AddWashIndicator_On/Off the panel lamp: laundry may go in right now.
|
||||
#
|
||||
# Bit order confirmed by watching a WW6500 run a cycle: AddWashAvailable
|
||||
# shed one bit as each moment passed (7 through Rinse, then 6, 4, and 0 as
|
||||
# Spin began) and reset to 7 at the end, while the lamp tracked the phase
|
||||
# with the alarm switched off throughout.
|
||||
|
||||
|
||||
def _add_wash_mask(rep, prefix):
|
||||
"""One of the 3-bit AddWash masks, or None when its token is absent,
|
||||
malformed, or outside 0-7. Never 0 for a missing token: 0 is a real
|
||||
value, and a mask this model can't represent is a wrong model rather
|
||||
than something to write back."""
|
||||
raw = option_value(rep.get("x.com.samsung.da.options"), prefix)
|
||||
try:
|
||||
mask = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return mask if 0 <= mask <= 0b111 else None
|
||||
|
||||
|
||||
def _add_wash_any(prefix):
|
||||
"""Whether any of the three moments is set in `prefix`'s mask."""
|
||||
|
||||
def read(rep):
|
||||
mask = _add_wash_mask(rep, prefix)
|
||||
return None if mask is None else mask != 0
|
||||
|
||||
return read
|
||||
|
||||
|
||||
def _add_wash_set_write(mask):
|
||||
return ["course", "vs", "0"], {
|
||||
"x.com.samsung.da.options": option_write("AddWashSet", str(mask)),
|
||||
}
|
||||
|
||||
|
||||
def _add_wash_alarm_write(p, rep, href=None):
|
||||
# Gated on the mask being readable, like the per-moment writes: a device
|
||||
# reporting a wider mask than these three bits would otherwise have it
|
||||
# truncated to 7 here, silently dropping a moment it supports.
|
||||
mask = _add_wash_mask(rep, "AddWashSet")
|
||||
if p not in ("On", "Off") or mask is None:
|
||||
return None
|
||||
if p == "On" and mask:
|
||||
# Already on, so "on" is a no-op rather than a rewrite to 7. Home
|
||||
# Assistant calls turn_on regardless of current state, so an
|
||||
# automation asserting the alarm on over a rinse-only mask would
|
||||
# otherwise widen it to all three moments with no state change on
|
||||
# this switch to point at. Distinct from the off-then-on case in
|
||||
# _add_wash_bit_switch, where there is no subset left to keep.
|
||||
return None
|
||||
return _add_wash_set_write(0b111 if p == "On" else 0)
|
||||
|
||||
|
||||
def _add_wash_bit_switch(key, icon, bit):
|
||||
"""One moment the alarm fires at, as its own bit of the mask.
|
||||
|
||||
The mask is the only state, so switching the last moment off lands on 0
|
||||
and takes the alarm with it, and switching one on from 0 turns the alarm
|
||||
back on. The corollary is that switching the master off and on again
|
||||
writes 7, resetting a rinse-only selection to all three moments -- the
|
||||
appliance remembers no previous subset either, so there is nothing to
|
||||
restore.
|
||||
"""
|
||||
|
||||
def read(rep):
|
||||
mask = _add_wash_mask(rep, "AddWashSet")
|
||||
return None if mask is None else bool(mask >> bit & 1)
|
||||
|
||||
def write(p, rep, href=None):
|
||||
mask = _add_wash_mask(rep, "AddWashSet")
|
||||
if p not in ("On", "Off") or mask is None:
|
||||
return None
|
||||
return _add_wash_set_write(mask | 1 << bit if p == "On" else mask & ~(1 << bit))
|
||||
|
||||
return SwitchDesc(
|
||||
key=key,
|
||||
icon=icon,
|
||||
entity_category="config",
|
||||
exists_fn=bool_option_exists("AddWashSet"),
|
||||
rep_fn=read,
|
||||
write_fn=write,
|
||||
)
|
||||
|
||||
|
||||
def _add_wash_indicator(rep):
|
||||
raw = option_value(rep.get("x.com.samsung.da.options"), "AddWashIndicator")
|
||||
return raw.lower() == "on" if isinstance(raw, str) else None
|
||||
|
||||
|
||||
WASHER_COURSE = Capability(
|
||||
href="/course/vs/0",
|
||||
entities=(
|
||||
@@ -350,5 +449,33 @@ WASHER_COURSE = Capability(
|
||||
_bool_option_switch(
|
||||
"intensive", "mdi:washing-machine", "IntensiveSetting", "IntensiveAvailableSet"
|
||||
),
|
||||
SwitchDesc(
|
||||
key="add_wash_alarm",
|
||||
icon="mdi:bell-ring",
|
||||
entity_category="config",
|
||||
exists_fn=bool_option_exists("AddWashSet"),
|
||||
rep_fn=_add_wash_any("AddWashSet"),
|
||||
write_fn=_add_wash_alarm_write,
|
||||
),
|
||||
_add_wash_bit_switch("add_wash_alarm_rinse", "mdi:water", 0),
|
||||
_add_wash_bit_switch("add_wash_alarm_final_rinse", "mdi:water-check", 1),
|
||||
_add_wash_bit_switch("add_wash_alarm_spin", "mdi:sync", 2),
|
||||
# On at rest: an idle washer reports AddWashAvailable_7 and the mask
|
||||
# only empties as the cycle consumes each moment. This says the cycle
|
||||
# permits AddWash, not that laundry can go in now -- that is
|
||||
# add_wash_indicator.
|
||||
BinarySensorDesc(
|
||||
key="add_wash_available",
|
||||
icon="mdi:tshirt-crew-outline",
|
||||
entity_category="diagnostic",
|
||||
exists_fn=bool_option_exists("AddWashAvailable"),
|
||||
rep_fn=_add_wash_any("AddWashAvailable"),
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key="add_wash_indicator",
|
||||
icon="mdi:door-open",
|
||||
exists_fn=bool_option_exists("AddWashIndicator"),
|
||||
rep_fn=_add_wash_indicator,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -13,6 +13,12 @@ class DeviceIdentity:
|
||||
model: str
|
||||
name: str
|
||||
serial: str | None
|
||||
# /oic/d's `di` and /oic/p's `pi` -- OCF's own device and platform
|
||||
# UUIDs. Promoted out of `raw` into named fields because
|
||||
# resolve_device_key mints permanent registry keys from them; see its
|
||||
# docstring for why `di` leads.
|
||||
device_id: str | None = None
|
||||
platform_id: str | None = None
|
||||
device_types: tuple[str, ...] = ()
|
||||
raw: dict[str, dict | list] = field(default_factory=dict)
|
||||
|
||||
@@ -57,6 +63,63 @@ def resolve_serial(raw_serial: str | None, host: str) -> str:
|
||||
return s
|
||||
|
||||
|
||||
def is_usable_device_id(value: str | None) -> bool:
|
||||
"""True for an OCF `di`/`pi` that actually identifies one unit.
|
||||
|
||||
Rejects OCF's nil UUID, which firmware that never had one assigned
|
||||
reports on every unit of the family -- the #189 failure mode on a new
|
||||
field, and one `is_placeholder_serial`'s repeated-digit rule misses
|
||||
because the dashes make more than one distinct character. Past that the
|
||||
same known-junk rules apply: a board flashed with 'Nothing(SVC)' in one
|
||||
identity field is not one to trust in another.
|
||||
"""
|
||||
s = (value or "").strip()
|
||||
if not s:
|
||||
return False
|
||||
if not set(s) - {"0", "-"}:
|
||||
return False
|
||||
return not is_placeholder_serial(s)
|
||||
|
||||
|
||||
def ocf_device_key(identity: DeviceIdentity | None) -> str | None:
|
||||
"""The OCF-derived half of resolve_device_key's chain, or None when the
|
||||
device reported no usable UUID.
|
||||
|
||||
Split out because "no UUID" and "this UUID" are different answers to a
|
||||
caller holding an existing key: the coordinator must never demote an
|
||||
entry off its UUID just because one poll couldn't read /oic/d.
|
||||
Normalized so firmware that changes case between reads doesn't look
|
||||
like a different appliance.
|
||||
"""
|
||||
if identity is None:
|
||||
return None
|
||||
for candidate in (identity.device_id, identity.platform_id):
|
||||
if candidate is not None and is_usable_device_id(candidate):
|
||||
return candidate.strip().lower()
|
||||
return None
|
||||
|
||||
|
||||
def resolve_device_key(identity: DeviceIdentity | None, raw_serial: str | None, host: str) -> str:
|
||||
"""The identity to mint this device's permanent registry keys from.
|
||||
|
||||
Tried in order: /oic/d's `di`, /oic/p's `pi`, the serialNum, the host.
|
||||
|
||||
`di` leads because it is what the protocol already uses to address this
|
||||
endpoint: if it were wrong or shared, OCF discovery and the DTLS
|
||||
association would not work at all. serialNum is a vendor-populated
|
||||
string nothing depends on, which is why three firmware families have
|
||||
shipped it unusable -- 'Nothing(SVC)' (#83), a flash-unset sentinel
|
||||
(#189), and a well-formed serial duplicated across every unit (#381).
|
||||
|
||||
`pi` is only the fallback despite the spec calling it immutable: it is
|
||||
*platform*-scoped, so a board hosting several logical OCF devices
|
||||
shares one across all of them. `di` is device-scoped, the granularity
|
||||
of a config entry. The serial and host stay below both so a board
|
||||
answering neither resource lands where it always did.
|
||||
"""
|
||||
return ocf_device_key(identity) or resolve_serial(raw_serial, host)
|
||||
|
||||
|
||||
def resolve_model(model_num: str, identity: DeviceIdentity | None) -> str:
|
||||
"""The model string to name and register a device under.
|
||||
|
||||
@@ -143,6 +206,8 @@ def read_identity(sess, serial: str | None) -> DeviceIdentity:
|
||||
model=p.get("mnmo") or "",
|
||||
name=d.get("n") or "",
|
||||
serial=serial,
|
||||
device_id=d.get("di") if isinstance(d.get("di"), str) else None,
|
||||
platform_id=p.get("pi") if isinstance(p.get("pi"), str) else None,
|
||||
device_types=_device_types(d),
|
||||
# Kept whole rather than field-by-field: outside the /device/0 dump
|
||||
# diagnostics already captures, and we don't yet know which fields
|
||||
|
||||
@@ -30,13 +30,18 @@ _SENSITIVE_SUBSTRINGS = (
|
||||
"secret",
|
||||
)
|
||||
|
||||
# Matched whole, not as substrings: OCF's /oic/d and /oic/p identify the
|
||||
# unit with bare one/two-letter keys too short for the substring rules above
|
||||
# ('di' is a substring of 'condition', 'display', ...). 'di'/'pi' are the
|
||||
# device/platform UUIDs; 'n' is /oic/d's free-text device name, which may
|
||||
# carry a person's name -- the device-type signal we actually want from
|
||||
# that resource is `rt`, which is not redacted.
|
||||
_SENSITIVE_EXACT = frozenset({"di", "pi", "n"})
|
||||
# Matched whole, not as substrings: these are bare one/two-letter keys too
|
||||
# short for the substring rules above ('n' is a substring of very nearly
|
||||
# everything). 'n' is /oic/d's free-text device name, which the owner sets
|
||||
# from the SmartThings app and can carry a person's name -- the device-type
|
||||
# signal we actually want from that resource is `rt`, which is not redacted.
|
||||
#
|
||||
# /oic/d's `di` and /oic/p's `pi` are deliberately not redacted: they're
|
||||
# randomly-assigned per-unit UUIDs rather than account data, and they are
|
||||
# what registry keys are minted from (issue #381), so blanking them hides
|
||||
# the identity every entity in a report is named after -- which is exactly
|
||||
# what made #381's first diagnostics download unable to answer it.
|
||||
_SENSITIVE_EXACT = frozenset({"n"})
|
||||
|
||||
|
||||
def _is_sensitive_key(key: str) -> bool:
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Move an entry's registry entries from one device key to another.
|
||||
|
||||
The key (registry.identity.resolve_device_key) is permanent in three
|
||||
places, so changing it means rewriting the registries rather than storing
|
||||
a new value -- anything left behind is orphaned. Rewriting rather than
|
||||
recreating is what keeps an entity's entity_id, and with it its history,
|
||||
area and automations.
|
||||
|
||||
Its own module because both callers need it: the v1 -> v2 migration in
|
||||
__init__.py and the coordinator's first-poll adoption (issue #381).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@callback
|
||||
def rekey_entry(hass: HomeAssistant, entry: ConfigEntry, old_key: str, new_key: str) -> None:
|
||||
"""Rewrite everything this entry registered under `old_key` to `new_key`.
|
||||
|
||||
All three permanent places move together: entity unique_ids
|
||||
(f"{DOMAIN}_{key}_{state_key}"), device identifiers ((DOMAIN, key), plus
|
||||
(DOMAIN, f"{key}_{subdevice}") per subdevice -- see device_info_for),
|
||||
and the entry's own unique_id. Leaving that last one behind would let
|
||||
the config flow's duplicate check wave through a re-add of this very
|
||||
appliance.
|
||||
|
||||
Idempotent, so it is safe to attempt on every poll rather than tracking
|
||||
whether it has run. Where both keys already exist the `old_key` copy is
|
||||
the dead one, so it is removed rather than rewritten over the live entry.
|
||||
|
||||
Must run on the event loop; the registry helpers require it.
|
||||
"""
|
||||
if old_key == new_key:
|
||||
return
|
||||
|
||||
new_entry_unique_id = f"{DOMAIN}_{new_key}"
|
||||
if entry.unique_id != new_entry_unique_id:
|
||||
hass.config_entries.async_update_entry(entry, unique_id=new_entry_unique_id)
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
stale_prefix = f"{DOMAIN}_{old_key}_"
|
||||
for entity in list(er.async_entries_for_config_entry(ent_reg, entry.entry_id)):
|
||||
if not entity.unique_id.startswith(stale_prefix):
|
||||
continue
|
||||
new_unique_id = f"{DOMAIN}_{new_key}_{entity.unique_id[len(stale_prefix) :]}"
|
||||
if ent_reg.async_get_entity_id(entity.domain, DOMAIN, new_unique_id):
|
||||
_LOGGER.debug("removing orphaned entity %s", entity.entity_id)
|
||||
ent_reg.async_remove(entity.entity_id)
|
||||
else:
|
||||
_LOGGER.debug("re-keying entity %s to %s", entity.entity_id, new_unique_id)
|
||||
ent_reg.async_update_entity(entity.entity_id, new_unique_id=new_unique_id)
|
||||
|
||||
dev_reg = dr.async_get(hass)
|
||||
for device in list(dr.async_entries_for_config_entry(dev_reg, entry.entry_id)):
|
||||
stale = {
|
||||
ident
|
||||
for ident in device.identifiers
|
||||
if ident[0] == DOMAIN and (ident[1] == old_key or ident[1].startswith(f"{old_key}_"))
|
||||
}
|
||||
if not stale:
|
||||
continue
|
||||
fresh = {(DOMAIN, f"{new_key}{ident[1][len(old_key) :]}") for ident in stale}
|
||||
existing = dev_reg.async_get_device(identifiers=fresh)
|
||||
if existing is not None and existing.id != device.id:
|
||||
# Removing a device takes its entities with it. Anything still
|
||||
# attached here was re-keyed rather than removed above -- the
|
||||
# surviving copy, not a duplicate -- so move it onto the device
|
||||
# it now belongs to before the removal destroys it too.
|
||||
for entity in er.async_entries_for_device(
|
||||
ent_reg, device.id, include_disabled_entities=True
|
||||
):
|
||||
ent_reg.async_update_entity(entity.entity_id, device_id=existing.id)
|
||||
_LOGGER.debug("removing orphaned device %s", device.id)
|
||||
dev_reg.async_remove_device(device.id)
|
||||
else:
|
||||
_LOGGER.debug("re-keying device %s to %s", device.id, fresh)
|
||||
dev_reg.async_update_device(
|
||||
device.id, new_identifiers=(device.identifiers - stale) | fresh
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -109,6 +109,12 @@
|
||||
"softener_low": {
|
||||
"name": "Málo aviváže"
|
||||
},
|
||||
"add_wash_available": {
|
||||
"name": "AddWash povoleno"
|
||||
},
|
||||
"add_wash_indicator": {
|
||||
"name": "AddWash připraveno"
|
||||
},
|
||||
"stick_ble_connected": {
|
||||
"name": "Tyč připojena přes BLE"
|
||||
}
|
||||
@@ -344,7 +350,20 @@
|
||||
"a4": "Časové sušení",
|
||||
"a6": "Rychlé sušení",
|
||||
"a3": "Sportovní oblečení",
|
||||
"a2": "Jemné prádlo"
|
||||
"a2": "Jemné prádlo",
|
||||
"9a": "Bavlna",
|
||||
"ca": "Provětrání",
|
||||
"db": "Super Speed",
|
||||
"99": "Smíšená náplň",
|
||||
"93": "Žehlení",
|
||||
"b5": "Vlna",
|
||||
"d7": "Outdoor péče",
|
||||
"96": "Studený vzduch",
|
||||
"97": "Teplý vzduch",
|
||||
"7f": "Časové sušení",
|
||||
"98": "Rychlé sušení 35",
|
||||
"eb": "Jemné prádlo",
|
||||
"b6": "Syntetika"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_03": {
|
||||
@@ -1142,7 +1161,8 @@
|
||||
"dryingwithdooropen": "Větrání",
|
||||
"cooling": "Chlazení",
|
||||
"predrain": "Vypouštění",
|
||||
"prewash": "Předpírka"
|
||||
"prewash": "Předpírka",
|
||||
"sanitizing": "Dezinfekce"
|
||||
}
|
||||
},
|
||||
"progress_percentage": {
|
||||
@@ -1350,6 +1370,18 @@
|
||||
"intensive": {
|
||||
"name": "Intenzivní"
|
||||
},
|
||||
"add_wash_alarm": {
|
||||
"name": "Alarm AddWash"
|
||||
},
|
||||
"add_wash_alarm_rinse": {
|
||||
"name": "AddWash při máchání"
|
||||
},
|
||||
"add_wash_alarm_final_rinse": {
|
||||
"name": "AddWash při posledním máchání"
|
||||
},
|
||||
"add_wash_alarm_spin": {
|
||||
"name": "AddWash při odstřeďování"
|
||||
},
|
||||
"lamp": {
|
||||
"name": "Lampa"
|
||||
},
|
||||
|
||||
@@ -103,6 +103,12 @@
|
||||
"softener_low": {
|
||||
"name": "Weichspüler niedrig"
|
||||
},
|
||||
"add_wash_available": {
|
||||
"name": "AddWash erlaubt"
|
||||
},
|
||||
"add_wash_indicator": {
|
||||
"name": "AddWash bereit"
|
||||
},
|
||||
"pouring": {
|
||||
"name": "Ausgabe läuft"
|
||||
},
|
||||
@@ -344,7 +350,20 @@
|
||||
"a4": "Zeittrocknen",
|
||||
"a6": "Schnelltrocknen",
|
||||
"a3": "Sportkleidung",
|
||||
"a2": "Feinwäsche"
|
||||
"a2": "Feinwäsche",
|
||||
"9a": "Baumwolle",
|
||||
"ca": "Frischluft",
|
||||
"db": "Super Speed",
|
||||
"99": "Mischwäsche",
|
||||
"93": "Bügeltrocken",
|
||||
"b5": "Wolle",
|
||||
"d7": "Outdoor-Pflege",
|
||||
"96": "Kaltluft",
|
||||
"97": "Warmluft",
|
||||
"7f": "Zeittrocknen",
|
||||
"98": "Schnelltrocknen 35",
|
||||
"eb": "Feinwäsche",
|
||||
"b6": "Synthetik"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_03": {
|
||||
@@ -1136,7 +1155,8 @@
|
||||
"dryingwithdooropen": "Lüften",
|
||||
"cooling": "Abkühlen",
|
||||
"predrain": "Abpumpen",
|
||||
"prewash": "Vorwäsche"
|
||||
"prewash": "Vorwäsche",
|
||||
"sanitizing": "Hygienespülung"
|
||||
}
|
||||
},
|
||||
"progress_percentage": {
|
||||
@@ -1344,6 +1364,18 @@
|
||||
"intensive": {
|
||||
"name": "Intensiv"
|
||||
},
|
||||
"add_wash_alarm": {
|
||||
"name": "AddWash-Hinweis"
|
||||
},
|
||||
"add_wash_alarm_rinse": {
|
||||
"name": "AddWash Spülen"
|
||||
},
|
||||
"add_wash_alarm_final_rinse": {
|
||||
"name": "AddWash letztes Spülen"
|
||||
},
|
||||
"add_wash_alarm_spin": {
|
||||
"name": "AddWash Schleudern"
|
||||
},
|
||||
"lamp": {
|
||||
"name": "Lampe"
|
||||
},
|
||||
|
||||
@@ -109,6 +109,12 @@
|
||||
"softener_low": {
|
||||
"name": "Softener low"
|
||||
},
|
||||
"add_wash_available": {
|
||||
"name": "AddWash allowed"
|
||||
},
|
||||
"add_wash_indicator": {
|
||||
"name": "AddWash ready"
|
||||
},
|
||||
"stick_ble_connected": {
|
||||
"name": "Stick BLE connected"
|
||||
}
|
||||
@@ -344,7 +350,20 @@
|
||||
"a4": "Time Dry",
|
||||
"a6": "Quick Dry",
|
||||
"a3": "Active Wear",
|
||||
"a2": "Delicates"
|
||||
"a2": "Delicates",
|
||||
"9a": "Cotton",
|
||||
"ca": "Air Wash",
|
||||
"db": "Super Speed",
|
||||
"99": "Mixed Load",
|
||||
"93": "Iron Dry",
|
||||
"b5": "Wool",
|
||||
"d7": "Outdoor Care",
|
||||
"96": "Cool Air",
|
||||
"97": "Warm Air",
|
||||
"7f": "Time Dry",
|
||||
"98": "Quick Dry 35",
|
||||
"eb": "Delicates",
|
||||
"b6": "Synthetics"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_03": {
|
||||
@@ -1142,7 +1161,8 @@
|
||||
"dryingwithdooropen": "Venting",
|
||||
"cooling": "Cooling",
|
||||
"predrain": "Pre-drain",
|
||||
"prewash": "Pre-wash"
|
||||
"prewash": "Pre-wash",
|
||||
"sanitizing": "Sanitizing"
|
||||
}
|
||||
},
|
||||
"progress_percentage": {
|
||||
@@ -1350,6 +1370,18 @@
|
||||
"intensive": {
|
||||
"name": "Intensive"
|
||||
},
|
||||
"add_wash_alarm": {
|
||||
"name": "AddWash alarm"
|
||||
},
|
||||
"add_wash_alarm_rinse": {
|
||||
"name": "AddWash rinse"
|
||||
},
|
||||
"add_wash_alarm_final_rinse": {
|
||||
"name": "AddWash final rinse"
|
||||
},
|
||||
"add_wash_alarm_spin": {
|
||||
"name": "AddWash spin"
|
||||
},
|
||||
"lamp": {
|
||||
"name": "Lamp"
|
||||
},
|
||||
|
||||
@@ -303,6 +303,12 @@
|
||||
"softener_low": {
|
||||
"name": "Poco suavizante"
|
||||
},
|
||||
"add_wash_available": {
|
||||
"name": "AddWash permitido"
|
||||
},
|
||||
"add_wash_indicator": {
|
||||
"name": "AddWash disponible"
|
||||
},
|
||||
"auto_clean_running": {
|
||||
"name": "Limpieza automática en curso"
|
||||
},
|
||||
@@ -541,7 +547,20 @@
|
||||
"a4": "Secado por tiempo",
|
||||
"a6": "Secado rápido",
|
||||
"a3": "Ropa deportiva",
|
||||
"a2": "Delicados"
|
||||
"a2": "Delicados",
|
||||
"9a": "Algodón",
|
||||
"ca": "Aireación",
|
||||
"db": "Súper velocidad",
|
||||
"99": "Carga mixta",
|
||||
"93": "Planchado fácil",
|
||||
"b5": "Lana",
|
||||
"d7": "Cuidado para exterior",
|
||||
"96": "Aire frío",
|
||||
"97": "Aire caliente",
|
||||
"7f": "Secado por tiempo",
|
||||
"98": "Secado rápido 35",
|
||||
"eb": "Delicados",
|
||||
"b6": "Sintéticos"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_03": {
|
||||
@@ -1336,7 +1355,8 @@
|
||||
"dryingwithdooropen": "Ventilación",
|
||||
"cooling": "Enfriamiento",
|
||||
"predrain": "Drenaje previo",
|
||||
"prewash": "Prelavado"
|
||||
"prewash": "Prelavado",
|
||||
"sanitizing": "Desinfección"
|
||||
}
|
||||
},
|
||||
"progress_percentage": {
|
||||
@@ -1547,6 +1567,18 @@
|
||||
"intensive": {
|
||||
"name": "Intensivo"
|
||||
},
|
||||
"add_wash_alarm": {
|
||||
"name": "Aviso AddWash"
|
||||
},
|
||||
"add_wash_alarm_rinse": {
|
||||
"name": "AddWash en aclarado"
|
||||
},
|
||||
"add_wash_alarm_final_rinse": {
|
||||
"name": "AddWash en último aclarado"
|
||||
},
|
||||
"add_wash_alarm_spin": {
|
||||
"name": "AddWash en centrifugado"
|
||||
},
|
||||
"lamp": {
|
||||
"name": "Lámpara"
|
||||
},
|
||||
|
||||
@@ -109,6 +109,12 @@
|
||||
"softener_low": {
|
||||
"name": "Aggiungi ammorbidente"
|
||||
},
|
||||
"add_wash_available": {
|
||||
"name": "AddWash consentito"
|
||||
},
|
||||
"add_wash_indicator": {
|
||||
"name": "AddWash disponibile"
|
||||
},
|
||||
"stick_ble_connected": {
|
||||
"name": "Scopa elettrica connessa via BLE"
|
||||
}
|
||||
@@ -344,7 +350,20 @@
|
||||
"a4": "Asciugatura a tempo",
|
||||
"a6": "Asciugatura rapida",
|
||||
"a3": "Abbigliamento sportivo",
|
||||
"a2": "Delicati"
|
||||
"a2": "Delicati",
|
||||
"9a": "Cotone",
|
||||
"ca": "Arieggiatura",
|
||||
"db": "Super Speed",
|
||||
"99": "Carico misto",
|
||||
"93": "Pronto da stirare",
|
||||
"b5": "Lana",
|
||||
"d7": "Cura outdoor",
|
||||
"96": "Aria fredda",
|
||||
"97": "Aria calda",
|
||||
"7f": "Asciugatura a tempo",
|
||||
"98": "Asciugatura rapida 35",
|
||||
"eb": "Delicati",
|
||||
"b6": "Sintetici"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_03": {
|
||||
@@ -1142,7 +1161,8 @@
|
||||
"dryingwithdooropen": "Ventilazione",
|
||||
"cooling": "Raffreddamento",
|
||||
"predrain": "Scarico preliminare",
|
||||
"prewash": "Prelavaggio"
|
||||
"prewash": "Prelavaggio",
|
||||
"sanitizing": "Igienizzazione"
|
||||
}
|
||||
},
|
||||
"progress_percentage": {
|
||||
@@ -1350,6 +1370,18 @@
|
||||
"intensive": {
|
||||
"name": "Intensivo"
|
||||
},
|
||||
"add_wash_alarm": {
|
||||
"name": "Avviso AddWash"
|
||||
},
|
||||
"add_wash_alarm_rinse": {
|
||||
"name": "AddWash al risciacquo"
|
||||
},
|
||||
"add_wash_alarm_final_rinse": {
|
||||
"name": "AddWash all'ultimo risciacquo"
|
||||
},
|
||||
"add_wash_alarm_spin": {
|
||||
"name": "AddWash alla centrifuga"
|
||||
},
|
||||
"lamp": {
|
||||
"name": "Lampada"
|
||||
},
|
||||
|
||||
@@ -109,6 +109,12 @@
|
||||
"softener_low": {
|
||||
"name": "섬유유연제 부족"
|
||||
},
|
||||
"add_wash_available": {
|
||||
"name": "애드워시 허용"
|
||||
},
|
||||
"add_wash_indicator": {
|
||||
"name": "애드워시 가능"
|
||||
},
|
||||
"stick_ble_connected": {
|
||||
"name": "스틱 BLE 연결됨"
|
||||
}
|
||||
@@ -344,7 +350,20 @@
|
||||
"a4": "시간건조",
|
||||
"a6": "쾌속건조",
|
||||
"a3": "운동복",
|
||||
"a2": "섬세의류"
|
||||
"a2": "섬세의류",
|
||||
"9a": "면의류",
|
||||
"ca": "송풍",
|
||||
"db": "쾌속건조",
|
||||
"99": "혼합",
|
||||
"93": "다림질건조",
|
||||
"b5": "울",
|
||||
"d7": "아웃도어케어",
|
||||
"96": "송풍건조",
|
||||
"97": "온풍건조",
|
||||
"7f": "시간건조",
|
||||
"98": "쾌속건조 35분",
|
||||
"eb": "섬세의류",
|
||||
"b6": "합성섬유"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_03": {
|
||||
@@ -1142,7 +1161,8 @@
|
||||
"dryingwithdooropen": "환기",
|
||||
"cooling": "냉각",
|
||||
"predrain": "사전 배수",
|
||||
"prewash": "애벌빨래"
|
||||
"prewash": "애벌빨래",
|
||||
"sanitizing": "살균"
|
||||
}
|
||||
},
|
||||
"progress_percentage": {
|
||||
@@ -1350,6 +1370,18 @@
|
||||
"intensive": {
|
||||
"name": "강력"
|
||||
},
|
||||
"add_wash_alarm": {
|
||||
"name": "애드워시 알림"
|
||||
},
|
||||
"add_wash_alarm_rinse": {
|
||||
"name": "애드워시 헹굼"
|
||||
},
|
||||
"add_wash_alarm_final_rinse": {
|
||||
"name": "애드워시 마지막 헹굼"
|
||||
},
|
||||
"add_wash_alarm_spin": {
|
||||
"name": "애드워시 탈수"
|
||||
},
|
||||
"lamp": {
|
||||
"name": "램프"
|
||||
},
|
||||
|
||||
@@ -109,6 +109,12 @@
|
||||
"softener_low": {
|
||||
"name": "Wasverzachter bijna op"
|
||||
},
|
||||
"add_wash_available": {
|
||||
"name": "AddWash toegestaan"
|
||||
},
|
||||
"add_wash_indicator": {
|
||||
"name": "AddWash beschikbaar"
|
||||
},
|
||||
"stick_ble_connected": {
|
||||
"name": "Steel via BLE verbonden"
|
||||
}
|
||||
@@ -344,7 +350,20 @@
|
||||
"a4": "Tijdprogramma",
|
||||
"a6": "Snel drogen",
|
||||
"a3": "Sportkleding",
|
||||
"a2": "Fijne was"
|
||||
"a2": "Fijne was",
|
||||
"9a": "Katoen",
|
||||
"ca": "Luchtverfrissing",
|
||||
"db": "Super Speed",
|
||||
"99": "Gemengde was",
|
||||
"93": "Strijkdroog",
|
||||
"b5": "Wol",
|
||||
"d7": "Outdoorverzorging",
|
||||
"96": "Koude lucht",
|
||||
"97": "Warme lucht",
|
||||
"7f": "Tijdprogramma",
|
||||
"98": "Snel drogen 35",
|
||||
"eb": "Fijne was",
|
||||
"b6": "Synthetisch"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_03": {
|
||||
@@ -1142,7 +1161,8 @@
|
||||
"dryingwithdooropen": "Ventileren",
|
||||
"cooling": "Koelen",
|
||||
"predrain": "Vooraf afpompen",
|
||||
"prewash": "Voorwas"
|
||||
"prewash": "Voorwas",
|
||||
"sanitizing": "Ontsmetten"
|
||||
}
|
||||
},
|
||||
"progress_percentage": {
|
||||
@@ -1350,6 +1370,18 @@
|
||||
"intensive": {
|
||||
"name": "Intensief"
|
||||
},
|
||||
"add_wash_alarm": {
|
||||
"name": "AddWash-melding"
|
||||
},
|
||||
"add_wash_alarm_rinse": {
|
||||
"name": "AddWash bij spoelen"
|
||||
},
|
||||
"add_wash_alarm_final_rinse": {
|
||||
"name": "AddWash laatste spoeling"
|
||||
},
|
||||
"add_wash_alarm_spin": {
|
||||
"name": "AddWash centrifugeren"
|
||||
},
|
||||
"lamp": {
|
||||
"name": "Lamp"
|
||||
},
|
||||
|
||||
@@ -152,6 +152,32 @@ is keyed on `(domain, issue_id)`, `dataclasses.replace` in
|
||||
reloads non-persistent issues with their dismissal intact, so one row per
|
||||
entry survives restarts and an "Ignore" sticks.
|
||||
|
||||
### What a cycle costs while the appliance stays dark (issue #269)
|
||||
|
||||
An appliance switched off at the wall isn't a one-cycle blip: it fails the
|
||||
same way every 30s for hours, and both halves of that failure were being paid
|
||||
twice.
|
||||
|
||||
`_poll_once` opens the session itself when there isn't one, so a switched-off
|
||||
appliance fails *in the handshake* — 12s (`DtlsCoapSession.HANDSHAKE_TIMEOUT_S`)
|
||||
with nothing to show for it. The poll path then treated that like any other
|
||||
poll failure and ran its reconnect: close the session, pause
|
||||
`_RECONNECT_PAUSE_S`, poll again. There is no session to close and no
|
||||
association for the device to clean up, so the "reconnect" was the identical
|
||||
handshake five seconds later — 29s of the 30s interval spent proving the
|
||||
appliance is off, twice over, and the same again on every `SETUP_RETRY`
|
||||
attempt for an entry with no snapshot to load from. `_handshake_failed` marks
|
||||
that case in `_poll_once` so the poll path can skip the retry; a session that
|
||||
opened and *then* broke still reconnects within the cycle.
|
||||
|
||||
The log was the half the reporters actually saw: `poll failed after
|
||||
reconnect` at ERROR every cycle, plus a `reconnect_is_frequent` WARNING once
|
||||
three piled up, for a state this integration is specifically built to sit
|
||||
through. Issue #269's reporter read that repetition as the integration having
|
||||
failed. It's one ERROR per outage now, DEBUG for the cycles after it, and one
|
||||
INFO when the device answers again — HA's own coordinator already logs the
|
||||
transition into and out of a failed update.
|
||||
|
||||
## What this still won't do
|
||||
|
||||
Entities will be present and `unavailable` — not showing their last values.
|
||||
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000",
|
||||
"x.com.samsung.da.countryCode": "US\u0001"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/course/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"HOMECARE_WIZARD_V2"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"DeviceType_0165",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"Course_9A",
|
||||
"MixedLoadBell_Disable",
|
||||
"MixedLoadBellNoti_Nothing",
|
||||
"LaundryOutTime_0",
|
||||
"SeamlessControl_Disable",
|
||||
"KidsLockBypass_On",
|
||||
"DetergentOnce_0",
|
||||
"DetergentLeft_0",
|
||||
"DetergentBase_0",
|
||||
"DetergentAlarm_Off",
|
||||
"DetergentType_0",
|
||||
"DetergentTotal_0",
|
||||
"SpecialFunction_4",
|
||||
"AvailableDelayTime_188",
|
||||
"LaundryPlannerUserSetTime_0",
|
||||
"ProgressTimeSet_B22D00B4003C",
|
||||
"WrinklePreventRunning_Off",
|
||||
"EnergyLevelSet_050502050503020304010204030203",
|
||||
"MostUsed_9AD20EE000",
|
||||
"MixedLoadBellSet_02FFFF02FFFFFFFFFFFFFFFFFF02",
|
||||
"UsagesDB_ok",
|
||||
"EnergyKW_396",
|
||||
"TimeSync_NotSupported"
|
||||
],
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"29AD20EE000CAD10EE000DBD204E00099D20EE00093D102E000B5D102E000D7D204E000A5D204E00096D000E10E97D000E10E7FD000E33E98D000E000EBD204E000B6D20EE000"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/cycleinterface/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.instantaneousPower": "-500",
|
||||
"x.com.samsung.da.cumulativePower": "692600",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.cumulativeDate": "1786964400",
|
||||
"x.com.samsung.da.cumulativeDateUTC": "1786960800"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+01:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000",
|
||||
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON_DV6800N/DC92-01967B_0404",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02198A230708(E257)",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "17111305,17122616",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.kidsLock": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/0",
|
||||
"rep": {
|
||||
"currentMachineState": "**REDACTED**",
|
||||
"machineStates": "**REDACTED**",
|
||||
"jobStates": [
|
||||
"None",
|
||||
"Drying",
|
||||
"Cooling",
|
||||
"Finish"
|
||||
],
|
||||
"currentJobState": "None",
|
||||
"remainingTime": "03:08:00",
|
||||
"progressPercentage": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.state": "Ready",
|
||||
"x.com.samsung.da.remainingTime": "03:08:00",
|
||||
"x.com.samsung.da.progressPercentage": "1",
|
||||
"x.com.samsung.da.progress": "None",
|
||||
"x.com.samsung.da.delayEndTime": "00:00:00",
|
||||
"x.com.samsung.da.supportedProgress": [
|
||||
"None",
|
||||
"Drying",
|
||||
"Cooling",
|
||||
"Finish"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.remoteControlEnabled": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/setting/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/st/dryercourse/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.st.dryerMode": "Table_00_Course_9A",
|
||||
"x.com.samsung.da.st.courseTable": "Table_00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/washer/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.wrinklePrevent": "Off",
|
||||
"x.com.samsung.da.dryLevel": "2",
|
||||
"x.com.samsung.da.supportedDryLevel": [
|
||||
"None",
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"x.com.samsung.da.dryTime": "00:00:00",
|
||||
"x.com.samsung.da.supportedDryTime": [
|
||||
"00:00:00",
|
||||
"00:30:00",
|
||||
"01:00:00",
|
||||
"01:30:00",
|
||||
"02:00:00",
|
||||
"02:30:00"
|
||||
],
|
||||
"x.com.samsung.da.dryerType": "Electricity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wm/editcourse/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/wm/jobbeginingstatus/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/wm/setinfo/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.isModelSettingWithoutSC": "false",
|
||||
"x.com.samsung.da.isModelSettingPowerOnOff": "false"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+2
@@ -8,6 +8,7 @@
|
||||
"cycle_active",
|
||||
"delay_start_hours",
|
||||
"diagnosis_status",
|
||||
"drum_clean_cycles_remaining",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"finish_time",
|
||||
@@ -40,6 +41,7 @@
|
||||
"samsung_dishwasher_delay_start_hours",
|
||||
"samsung_dishwasher_diagnosis_start",
|
||||
"samsung_dishwasher_diagnosis_status",
|
||||
"samsung_dishwasher_drum_clean_cycles_remaining",
|
||||
"samsung_dishwasher_energy_kwh",
|
||||
"samsung_dishwasher_energy_saved_kwh",
|
||||
"samsung_dishwasher_finish_time",
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"child_lock",
|
||||
"completion_minutes",
|
||||
"cycle",
|
||||
"cycle_active",
|
||||
"delay_start_hours",
|
||||
"diagnosis",
|
||||
"dry_level",
|
||||
"dry_time",
|
||||
"dryer_type",
|
||||
"energy_kwh",
|
||||
"finish_time",
|
||||
"job_beginning_status",
|
||||
"machine_state",
|
||||
"power_switch",
|
||||
"progress",
|
||||
"progress_percentage",
|
||||
"remote_control",
|
||||
"wrinkle_prevent"
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"add_wash_alarm",
|
||||
"add_wash_alarm_final_rinse",
|
||||
"add_wash_alarm_rinse",
|
||||
"add_wash_alarm_spin",
|
||||
"add_wash_available",
|
||||
"add_wash_indicator",
|
||||
"alarm_code",
|
||||
"bubble_soak",
|
||||
"child_lock",
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"add_wash_alarm",
|
||||
"add_wash_alarm_final_rinse",
|
||||
"add_wash_alarm_rinse",
|
||||
"add_wash_alarm_spin",
|
||||
"add_wash_available",
|
||||
"add_wash_indicator",
|
||||
"alarm_code",
|
||||
"child_lock",
|
||||
"completion_minutes",
|
||||
"cycle",
|
||||
"cycle_active",
|
||||
"delay_start_hours",
|
||||
"diagnosis_status",
|
||||
"energy_kwh",
|
||||
"finish_time",
|
||||
"job_beginning_status",
|
||||
"machine_state",
|
||||
"power_switch",
|
||||
"progress",
|
||||
"progress_percentage",
|
||||
"remote_control",
|
||||
"rinse_cycles",
|
||||
"spin_speed",
|
||||
"wash_temperature"
|
||||
]
|
||||
}
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
{
|
||||
"device0": [
|
||||
{},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.instantaneousPower": "-500",
|
||||
"x.com.samsung.da.cumulativePower": "2016700",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.cumulativeDate": "1787050800",
|
||||
"x.com.samsung.da.cumulativeDateUTC": "1787050800"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/course/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.options": [
|
||||
"DeviceType_0167",
|
||||
"Course_5C",
|
||||
"LaundryOutTime_0",
|
||||
"AddWashSet_0",
|
||||
"AddWashAvailable_7",
|
||||
"AddWashIndicator_Off",
|
||||
"QuickWash_Not_Used",
|
||||
"QuickWashSet_5B847E933FA53F",
|
||||
"UsagesDB_ok",
|
||||
"EnergyKW_396",
|
||||
"DrumCleanLog_Empty",
|
||||
"TimeSync_NotSupported"
|
||||
],
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"35B847E933FA53F5C841E923FA53F5D8102923FA43F66841E930FA30F5E831E920FA2075F867E943FA53F60831E930FA43F61841E943FA43F6385209204A204648000913FA53F6B80009000A53E65841E920FA30F67843E923FA43F688430923FA53F"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/cycleinterface/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.kidsLock": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.state": "Ready",
|
||||
"x.com.samsung.da.remainingTime": "01:07:00",
|
||||
"x.com.samsung.da.progressPercentage": "1",
|
||||
"x.com.samsung.da.progress": "None",
|
||||
"x.com.samsung.da.supportedProgress": [
|
||||
"None",
|
||||
"Wash",
|
||||
"Rinse",
|
||||
"Spin",
|
||||
"Finish"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/0",
|
||||
"rep": {
|
||||
"currentMachineState": "idle",
|
||||
"machineStates": [
|
||||
"pause",
|
||||
"active",
|
||||
"idle"
|
||||
],
|
||||
"jobStates": [
|
||||
"None",
|
||||
"Wash",
|
||||
"Rinse",
|
||||
"Spin",
|
||||
"Finish"
|
||||
],
|
||||
"currentJobState": "None",
|
||||
"remainingTime": "01:07:00",
|
||||
"progressPercentage": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "DA_WM_A51_20_COMMON|FFFFFFFF|20010102001011070000000000000000",
|
||||
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON_WW6500",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON|FFFFFFFF|20010102001011070000000000000000",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02198A230708(E257)",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "Unknown",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+00:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/washer/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.waterTemperature": "30",
|
||||
"x.com.samsung.da.supportedWaterTemperature": [
|
||||
"None",
|
||||
"Cold",
|
||||
"20",
|
||||
"30",
|
||||
"40",
|
||||
"60",
|
||||
"95"
|
||||
],
|
||||
"x.com.samsung.da.spinLevel": "1400",
|
||||
"x.com.samsung.da.supportedSpinLevel": [
|
||||
"RinseHold",
|
||||
"NoSpin",
|
||||
"400",
|
||||
"800",
|
||||
"1200",
|
||||
"1400"
|
||||
],
|
||||
"x.com.samsung.da.rinseCycles": "3",
|
||||
"x.com.samsung.da.supportedRinseCycles": [
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/st/washercourse/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.st.washerMode": "Table_00_Course_5C",
|
||||
"x.com.samsung.da.st.courseTable": "Table_00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/setting/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/wm/editcourse/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/wm/setinfo/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.isModelSettingWithoutSC": "false",
|
||||
"x.com.samsung.da.isModelSettingPowerOnOff": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wm/jobbeginingstatus/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.remoteControlEnabled": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/0",
|
||||
"rep": {
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000",
|
||||
"x.com.samsung.da.countryCode": "CZ"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -14,6 +14,7 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
from custom_components.localthings.const import (
|
||||
CONF_CA_CERT_PEM,
|
||||
CONF_CA_KEY_PEM,
|
||||
CONF_DEVICE_KEY,
|
||||
CONF_DEVICE_TYPE,
|
||||
CONF_HOST,
|
||||
CONF_LEAF_CERT_PEM,
|
||||
@@ -82,6 +83,10 @@ MOCK_PORT = 49154
|
||||
# what mock_coordinator_session polls -- so an entry built from ENTRY_DATA and
|
||||
# the device it "reaches" agree on who they are, the same as in production.
|
||||
MOCK_SERIAL = "TEST-SERIAL-0000"
|
||||
# The OCF device UUID (/oic/d's `di`) the probe resolves the entry's key from
|
||||
# (issue #381). Distinct from MOCK_SERIAL so a test that confuses the two
|
||||
# fails rather than passing by coincidence.
|
||||
MOCK_DEVICE_KEY = "7b1f0c9e-2a44-4d6b-9f10-4c8e2b5a0d31"
|
||||
MOCK_MODEL = "TEST-MODEL"
|
||||
MOCK_DEVICE_TYPE = "refrigerator"
|
||||
MOCK_CA_CERT_PEM = "-----BEGIN CERTIFICATE-----\nTEST-CA\n-----END CERTIFICATE-----"
|
||||
@@ -98,6 +103,7 @@ ENTRY_DATA = {
|
||||
CONF_LEAF_KEY_PEM: MOCK_LEAF_KEY_PEM,
|
||||
# Identity the config flow's probe resolved (issue #236) -- what the
|
||||
# coordinator keys its devices and entities on from construction.
|
||||
CONF_DEVICE_KEY: MOCK_DEVICE_KEY,
|
||||
CONF_SERIAL: MOCK_SERIAL,
|
||||
CONF_MODEL: MOCK_MODEL,
|
||||
CONF_MANUFACTURER: "Samsung",
|
||||
@@ -131,6 +137,7 @@ def fridge_resources():
|
||||
def _probe_result(*, recognized: bool) -> dict:
|
||||
return {
|
||||
"port": MOCK_PORT,
|
||||
"device_key": MOCK_DEVICE_KEY,
|
||||
"serial": MOCK_SERIAL,
|
||||
"model": MOCK_MODEL,
|
||||
"manufacturer": "Samsung",
|
||||
@@ -244,8 +251,8 @@ def mock_entry(hass):
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data=ENTRY_DATA,
|
||||
unique_id=f"localthings_{MOCK_SERIAL}",
|
||||
version=2,
|
||||
unique_id=f"localthings_{MOCK_DEVICE_KEY}",
|
||||
version=4,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
return entry
|
||||
|
||||
@@ -16,11 +16,13 @@ from custom_components.localthings.const import (
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -28,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,
|
||||
)
|
||||
|
||||
|
||||
@@ -1047,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.
|
||||
@@ -1055,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)
|
||||
|
||||
@@ -1069,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."""
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock, patch
|
||||
@@ -33,7 +35,13 @@ from custom_components.localthings.registry.capabilities.common import (
|
||||
remote_control_required_for_write,
|
||||
)
|
||||
|
||||
from .conftest import ENTRY_DATA, MOCK_MODEL, MOCK_SERIAL, FakeObserveSession
|
||||
from .conftest import (
|
||||
ENTRY_DATA,
|
||||
MOCK_DEVICE_KEY,
|
||||
MOCK_MODEL,
|
||||
MOCK_SERIAL,
|
||||
FakeObserveSession,
|
||||
)
|
||||
from .conftest import _load_fridge_resources as _load_fridge
|
||||
|
||||
|
||||
@@ -150,7 +158,7 @@ def test_run_discovery_falls_back_to_host_for_placeholder_serial(
|
||||
) -> None:
|
||||
"""Issue #83: the ARTIK051_DONGLE_REF firmware family reports the
|
||||
literal string 'Nothing(SVC)' as serialNum on every unit. Left as-is,
|
||||
two such units get the same device_serial (which feeds both the HA
|
||||
two such units get the same device_key (which feeds both the HA
|
||||
device-registry identifier and every entity's unique_id), so the
|
||||
second one's entities silently collide and get dropped. It must be
|
||||
treated the same as an empty serial and fall back to the host."""
|
||||
@@ -164,7 +172,7 @@ def test_run_discovery_falls_back_to_host_for_placeholder_serial(
|
||||
}
|
||||
coordinator = LocalThingsCoordinator(hass, legacy_entry)
|
||||
coordinator._run_discovery(resources)
|
||||
assert coordinator.device_serial == legacy_entry.data[CONF_HOST]
|
||||
assert coordinator.device_key == legacy_entry.data[CONF_HOST]
|
||||
|
||||
|
||||
def test_run_discovery_falls_back_to_host_for_all_f_placeholder_serial(
|
||||
@@ -175,7 +183,7 @@ def test_run_discovery_falls_back_to_host_for_all_f_placeholder_serial(
|
||||
character the same repeated hex digit. A washer and a dryer, two
|
||||
different physical units, both reported the literal serialNum
|
||||
'FFFFFFFFFFFFFFF', so without this fallback they'd collide on
|
||||
device_serial exactly like the #83 case above."""
|
||||
device_key exactly like the #83 case above."""
|
||||
resources = {
|
||||
"/information/vs/0": {
|
||||
"x.com.samsung.da.modelNum": "DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000", # noqa: E501
|
||||
@@ -186,7 +194,7 @@ def test_run_discovery_falls_back_to_host_for_all_f_placeholder_serial(
|
||||
}
|
||||
coordinator = LocalThingsCoordinator(hass, legacy_entry)
|
||||
coordinator._run_discovery(resources)
|
||||
assert coordinator.device_serial == legacy_entry.data[CONF_HOST]
|
||||
assert coordinator.device_key == legacy_entry.data[CONF_HOST]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -198,7 +206,7 @@ def test_identity_is_resolved_before_any_poll(hass: HomeAssistant, mock_entry) -
|
||||
"""The coordinator mints registry keys from the entry's stored identity at
|
||||
construction time.
|
||||
|
||||
`device_serial` is what entity unique_ids and device identifiers are built
|
||||
`device_key` is what entity unique_ids and device identifiers are built
|
||||
from, and those are permanent. Seeding it with the host meant anything that
|
||||
registered before the first poll returned -- the connection-mode sensor
|
||||
especially, added unconditionally rather than from `bound` -- was written
|
||||
@@ -207,8 +215,8 @@ def test_identity_is_resolved_before_any_poll(hass: HomeAssistant, mock_entry) -
|
||||
"""
|
||||
coordinator = LocalThingsCoordinator(hass, mock_entry)
|
||||
|
||||
assert coordinator.device_serial == MOCK_SERIAL
|
||||
assert coordinator.device_info["identifiers"] == {(DOMAIN, MOCK_SERIAL)}
|
||||
assert coordinator.device_key == MOCK_DEVICE_KEY
|
||||
assert coordinator.device_info["identifiers"] == {(DOMAIN, MOCK_DEVICE_KEY)}
|
||||
assert coordinator.device_info["model"] == MOCK_MODEL
|
||||
assert coordinator.device_info["name"] == f"Samsung Refrigerator ({MOCK_MODEL})"
|
||||
assert mock_entry.data[CONF_HOST] not in str(coordinator.device_info["identifiers"])
|
||||
@@ -239,7 +247,7 @@ def test_discovery_keeps_the_registered_identity(hass: HomeAssistant, mock_entry
|
||||
coordinator = LocalThingsCoordinator(hass, mock_entry)
|
||||
coordinator._run_discovery(resources)
|
||||
|
||||
assert coordinator.device_serial == MOCK_SERIAL
|
||||
assert coordinator.device_key == MOCK_DEVICE_KEY
|
||||
|
||||
|
||||
def test_discovery_backfills_a_legacy_entry_identity(hass: HomeAssistant, legacy_entry) -> None:
|
||||
@@ -1044,6 +1052,110 @@ async def test_sweep_mismatch_forces_subpolls_on_a_live_observe_session(
|
||||
mock_subpolls.assert_called_once_with(force=True)
|
||||
|
||||
|
||||
async def _cancel_background_subpolls(coordinator: LocalThingsCoordinator) -> None:
|
||||
"""Setup starts `_run_subpolls` as a background task. Tests that drive
|
||||
it directly have to cancel that one first, or a patched
|
||||
`_poll_hrefs_blocking` also captures its batches."""
|
||||
task = coordinator._subpoll_task
|
||||
if task is None:
|
||||
return
|
||||
task.cancel()
|
||||
coordinator._subpoll_task = None
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
async def test_observe_mode_subpolls_only_silent_hrefs(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""Issue #92: subscribed-but-silent hrefs keep the hot/warm cadence
|
||||
instead of waiting for the 30s sweep."""
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
await _cancel_background_subpolls(coordinator)
|
||||
assert coordinator._hot_hrefs
|
||||
silent = coordinator._hot_hrefs[0]
|
||||
coordinator._observe.mode = MODE_OBSERVE
|
||||
coordinator._observe.fallback_hrefs = {silent}
|
||||
|
||||
polled: list[list[str]] = []
|
||||
|
||||
def _capture(hrefs):
|
||||
polled.append(list(hrefs))
|
||||
|
||||
with (
|
||||
patch.object(coordinator, "_poll_hrefs_blocking", side_effect=_capture),
|
||||
patch(
|
||||
"custom_components.localthings.coordinator.asyncio.sleep",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
):
|
||||
await coordinator._run_subpolls()
|
||||
|
||||
assert polled
|
||||
for batch in polled:
|
||||
assert set(batch) == {silent}
|
||||
|
||||
|
||||
async def test_observe_mode_skips_subpolls_when_nothing_is_silent(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""The observe-mode no-op stays in place when every subscribed href
|
||||
actually notified -- issue #92 only keeps the silent ones on poll."""
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
await _cancel_background_subpolls(coordinator)
|
||||
coordinator._observe.mode = MODE_OBSERVE
|
||||
coordinator._observe.fallback_hrefs = set()
|
||||
|
||||
with (
|
||||
patch.object(coordinator, "_poll_hrefs_blocking") as mock_poll,
|
||||
patch(
|
||||
"custom_components.localthings.coordinator.asyncio.sleep",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_sleep,
|
||||
):
|
||||
await coordinator._run_subpolls()
|
||||
|
||||
mock_poll.assert_not_called()
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
|
||||
async def test_observe_mode_skips_empty_subpoll_slots(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""Once hot is empty, odd slots would otherwise take the session lock
|
||||
and dispatch a no-op executor job. Skip those."""
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
await _cancel_background_subpolls(coordinator)
|
||||
coordinator._observe.mode = MODE_OBSERVE
|
||||
coordinator._hot_hrefs = []
|
||||
coordinator._warm_hrefs = ["/warm/vs/0"]
|
||||
coordinator._observe.fallback_hrefs = {"/warm/vs/0"}
|
||||
|
||||
polled: list[list[str]] = []
|
||||
|
||||
def _capture(hrefs):
|
||||
polled.append(list(hrefs))
|
||||
|
||||
with (
|
||||
patch.object(coordinator, "_poll_hrefs_blocking", side_effect=_capture),
|
||||
patch(
|
||||
"custom_components.localthings.coordinator.asyncio.sleep",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
):
|
||||
await coordinator._run_subpolls()
|
||||
|
||||
assert polled
|
||||
for batch in polled:
|
||||
assert batch == ["/warm/vs/0"]
|
||||
|
||||
|
||||
async def test_write_marks_href_pending_before_post(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
|
||||
@@ -39,7 +39,7 @@ async def test_stale_device_can_be_removed(
|
||||
stale = _device(
|
||||
hass,
|
||||
mock_entry,
|
||||
{(DOMAIN, f"{coordinator.device_serial}_1")},
|
||||
{(DOMAIN, f"{coordinator.device_key}_1")},
|
||||
)
|
||||
|
||||
assert await async_remove_config_entry_device(hass, mock_entry, stale) is True
|
||||
|
||||
@@ -76,8 +76,11 @@ async def test_diagnostics_include_ocf_identity(
|
||||
# fields identify a device type, so nothing is dropped up front beyond
|
||||
# what redaction takes out.
|
||||
assert identity["resources"]["/oic/p"]["mnmn"] == "Samsung Electronics"
|
||||
assert identity["resources"]["/oic/d"]["di"] == REDACTED
|
||||
assert identity["resources"]["/oic/p"]["pi"] == REDACTED
|
||||
# The OCF UUIDs are reported rather than redacted: they are what this
|
||||
# entry is keyed on (issue #381), and blanking them is what made the
|
||||
# first duplicate-serial report unanswerable.
|
||||
assert identity["resources"]["/oic/d"]["di"] == "ab-cd-ef"
|
||||
assert identity["resources"]["/oic/p"]["pi"] == "12-34-56"
|
||||
# The owner-settable device name is redacted; `rt` -- the reason this
|
||||
# block exists -- is not.
|
||||
assert identity["resources"]["/oic/d"]["n"] == REDACTED
|
||||
@@ -125,9 +128,9 @@ async def test_diagnostics_include_oic_res_links(
|
||||
|
||||
links = diag["identity"]["resources"]["/oic/res"]
|
||||
assert len(links) == 2
|
||||
assert links[0]["di"] == REDACTED
|
||||
assert links[0]["di"] == "aaaa-1111"
|
||||
assert links[0]["href"] == "/device/0"
|
||||
assert links[1]["di"] == REDACTED
|
||||
assert links[1]["di"] == "bbbb-2222"
|
||||
assert links[1]["href"] == "/device/1"
|
||||
assert links[1]["rt"] == ["x.com.samsung.devcol", "oic.wk.col"]
|
||||
|
||||
|
||||
@@ -0,0 +1,773 @@
|
||||
"""Moving an existing install onto the OCF device UUID (issue #381).
|
||||
|
||||
This migration can't finish inside `async_migrate_entry` -- the UUID is
|
||||
only readable from the appliance, and an entry can load from its snapshot
|
||||
while that appliance is off (issue #295) -- so the coordinator adopts it
|
||||
on the first live poll, rewriting both registries and the entry's
|
||||
unique_id together.
|
||||
|
||||
That makes it the riskiest migration here: it rewrites the identity of
|
||||
rows a user's automations, history and areas hang off. These tests are
|
||||
organised around what must not break rather than around the functions
|
||||
involved. Statistics are covered separately, against a real recorder, in
|
||||
tests/test_rekey_statistics_end_to_end.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.localthings.const import (
|
||||
CONF_DEVICE_KEY,
|
||||
CONF_HOST,
|
||||
CONF_SERIAL,
|
||||
DOMAIN,
|
||||
)
|
||||
from custom_components.localthings.registry.identity import DeviceIdentity
|
||||
|
||||
from .conftest import LEGACY_ENTRY_DATA, MOCK_HOST, MOCK_SERIAL
|
||||
|
||||
_COORD = "custom_components.localthings.coordinator.LocalThingsCoordinator"
|
||||
|
||||
# The two purifiers from issue #381: one serialNum, two device UUIDs.
|
||||
SHARED_SERIAL = "BS7SP9AW400114A"
|
||||
UUID_A = "ccfd73b3-aeb4-792a-1100-68f06f5d603b"
|
||||
UUID_B = "3771f8bf-c184-3a2d-d885-e4c9818736d2"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _reachable(resources: dict, device_id: str | None):
|
||||
"""A device that answers a poll, reporting `device_id` as its /oic/d
|
||||
`di` -- which `_connect_session` is what normally reads, so a test that
|
||||
patches it out otherwise leaves `_identity` None (indistinguishable
|
||||
from firmware that reports no UUID at all)."""
|
||||
|
||||
def _connect(self) -> None:
|
||||
self._identity = (
|
||||
None
|
||||
if device_id is None
|
||||
else DeviceIdentity(
|
||||
manufacturer="Samsung Electronics",
|
||||
model="AVT-WW-TP1-23-AXX500",
|
||||
name="Samsung AirPurifier",
|
||||
serial=None,
|
||||
device_id=device_id,
|
||||
)
|
||||
)
|
||||
|
||||
with (
|
||||
patch(f"{_COORD}._connect_session", _connect),
|
||||
patch(f"{_COORD}._poll_once", return_value=resources),
|
||||
patch(f"{_COORD}._close_session"),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _unreachable():
|
||||
with (
|
||||
patch(f"{_COORD}._connect_session"),
|
||||
patch(f"{_COORD}._poll_once", side_effect=OSError("device offline")),
|
||||
patch(f"{_COORD}._close_session"),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
def _entry(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
version: int,
|
||||
key: str,
|
||||
serial: str | None = None,
|
||||
device_key: str | None = None,
|
||||
host: str = MOCK_HOST,
|
||||
) -> MockConfigEntry:
|
||||
"""An entry as it sits on disk at `version`. Pre-v4 entries carry no
|
||||
CONF_DEVICE_KEY at all -- that absence is what tells the coordinator it
|
||||
is looking at an entry that has never adopted a UUID."""
|
||||
data = {**LEGACY_ENTRY_DATA, CONF_HOST: host}
|
||||
if version >= 2:
|
||||
data[CONF_SERIAL] = serial if serial is not None else key
|
||||
if device_key is not None:
|
||||
data[CONF_DEVICE_KEY] = device_key
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data=data,
|
||||
unique_id=f"{DOMAIN}_{key}",
|
||||
version=version,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
return entry
|
||||
|
||||
|
||||
def _reporting_serial(resources: dict, serial: str) -> dict:
|
||||
"""`resources` with the serialNum the device reports swapped out, so a
|
||||
test can pair a fixture with the identity its scenario implies."""
|
||||
info = dict(resources["/information/vs/0"])
|
||||
info["x.com.samsung.da.serialNum"] = serial
|
||||
return {**resources, "/information/vs/0": info}
|
||||
|
||||
|
||||
def _device_identifiers(hass: HomeAssistant, device_id: str) -> set[tuple[str, str]]:
|
||||
"""This device row's identifiers, asserting the row still exists.
|
||||
|
||||
`dev_reg.async_get` returns `DeviceEntry | None`, so reading through it
|
||||
directly would crash with an AttributeError on a row the re-key
|
||||
wrongly removed instead of failing the assertion that says so.
|
||||
"""
|
||||
row = dr.async_get(hass).async_get(device_id)
|
||||
assert row is not None
|
||||
return row.identifiers
|
||||
|
||||
|
||||
def _entity_unique_id(hass: HomeAssistant, entity_id: str) -> str:
|
||||
"""This entity row's unique_id, asserting the row still exists."""
|
||||
row = er.async_get(hass).async_get(entity_id)
|
||||
assert row is not None
|
||||
return row.unique_id
|
||||
|
||||
|
||||
def _seed_registry(hass: HomeAssistant, entry: MockConfigEntry, key: str, **entity_kwargs):
|
||||
"""A device and one entity keyed on `key`, as a running install has."""
|
||||
dev_reg = dr.async_get(hass)
|
||||
ent_reg = er.async_get(hass)
|
||||
device = dev_reg.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, key)},
|
||||
name=f"Samsung Air Purifier ({key})",
|
||||
)
|
||||
entity = ent_reg.async_get_or_create(
|
||||
"sensor",
|
||||
DOMAIN,
|
||||
f"{DOMAIN}_{key}_connection_mode",
|
||||
config_entry=entry,
|
||||
device_id=device.id,
|
||||
**entity_kwargs,
|
||||
)
|
||||
return device, entity
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The upgrade itself
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_v3_entry_moves_onto_the_device_uuid_keeping_its_entity_ids(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""The migration promise for an existing user, asserted end to end: all
|
||||
three permanent places move together, and the entity_id doesn't."""
|
||||
entry = _entry(hass, version=3, key=MOCK_SERIAL)
|
||||
device, existing = _seed_registry(
|
||||
hass, entry, MOCK_SERIAL, suggested_object_id="kitchen_purifier_connection"
|
||||
)
|
||||
|
||||
with _reachable(fridge_resources, UUID_A):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.version == 4
|
||||
assert entry.data[CONF_DEVICE_KEY] == UUID_A
|
||||
# The serial is kept alongside the key, not replaced by it: it is what
|
||||
# corroborates a later change of UUID.
|
||||
assert entry.data[CONF_SERIAL] == MOCK_SERIAL
|
||||
assert entry.unique_id == f"{DOMAIN}_{UUID_A}"
|
||||
|
||||
rekeyed_device = dr.async_get(hass).async_get(device.id)
|
||||
assert rekeyed_device is not None
|
||||
assert rekeyed_device.identifiers == {(DOMAIN, UUID_A)}
|
||||
kept = er.async_get(hass).async_get(existing.entity_id)
|
||||
assert kept is not None
|
||||
assert kept.entity_id == "sensor.kitchen_purifier_connection"
|
||||
assert kept.unique_id == f"{DOMAIN}_{UUID_A}_connection_mode"
|
||||
# Nothing left behind on the old key.
|
||||
assert dr.async_get(hass).async_get_device(identifiers={(DOMAIN, MOCK_SERIAL)}) is None
|
||||
|
||||
|
||||
async def test_the_oldest_install_walks_all_the_way_from_v1(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""A v1 entry -- no stored identity at all, from before issue #236 --
|
||||
walks v1 -> v2 -> v3 -> v4 and then adopts the UUID on its first poll.
|
||||
|
||||
The oldest installs take the longest path, and each step rewrites what
|
||||
the next one reads, so the chain is worth pinning as one journey rather
|
||||
than trusting the individual steps to compose.
|
||||
"""
|
||||
entry = _entry(hass, version=1, key=MOCK_SERIAL)
|
||||
device, existing = _seed_registry(
|
||||
hass, entry, MOCK_SERIAL, suggested_object_id="old_install_connection"
|
||||
)
|
||||
|
||||
with _reachable(fridge_resources, UUID_A):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.version == 4
|
||||
assert entry.data[CONF_DEVICE_KEY] == UUID_A
|
||||
assert entry.unique_id == f"{DOMAIN}_{UUID_A}"
|
||||
kept = er.async_get(hass).async_get(existing.entity_id)
|
||||
assert kept is not None
|
||||
assert kept.entity_id == "sensor.old_install_connection"
|
||||
assert kept.unique_id == f"{DOMAIN}_{UUID_A}_connection_mode"
|
||||
assert _device_identifiers(hass, device.id) == {(DOMAIN, UUID_A)}
|
||||
|
||||
|
||||
async def test_a_host_keyed_entry_adopts_a_real_identity(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""A placeholder-serial board (issues #83/#189) was keyed on its IP,
|
||||
which is an address rather than an identity -- a new DHCP lease silently
|
||||
makes it someone else's. Such an entry never made an identity claim to
|
||||
defend, so a real UUID is adopted without needing the serial to
|
||||
corroborate it; requiring corroboration would strand exactly these
|
||||
boards, since their serial resolves to the host and can never match."""
|
||||
entry = _entry(hass, version=3, key=MOCK_HOST)
|
||||
device, _ = _seed_registry(hass, entry, MOCK_HOST)
|
||||
|
||||
with _reachable(fridge_resources, UUID_B):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.data[CONF_DEVICE_KEY] == UUID_B
|
||||
assert _device_identifiers(hass, device.id) == {(DOMAIN, UUID_B)}
|
||||
|
||||
|
||||
async def test_the_two_units_from_the_issue_migrate_to_separate_identities(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""Issue #381's actual install: two entries whose stored identity is the
|
||||
same shared serial. Before this, they could not both exist. Migrating
|
||||
them must give each its own key rather than collapsing them again --
|
||||
including their entity unique_ids, which is where issue #83's Bug 4
|
||||
silently dropped the second unit's entities even once its entry existed.
|
||||
"""
|
||||
# Both units report the shared serial, as the real ones do -- so the
|
||||
# entry's stored identity still matches what the device says, and only
|
||||
# the UUID separates them.
|
||||
resources = _reporting_serial(fridge_resources, SHARED_SERIAL)
|
||||
first = _entry(hass, version=3, key=SHARED_SERIAL, host="192.168.0.3")
|
||||
_, first_entity = _seed_registry(hass, first, SHARED_SERIAL, suggested_object_id="purifier_a")
|
||||
|
||||
with _reachable(resources, UUID_A):
|
||||
await hass.config_entries.async_setup(first.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Added only now: setting up the first entry loads the integration, which
|
||||
# brings up every entry already registered -- so a second one created up
|
||||
# front would come up inside the first one's patched identity and adopt
|
||||
# its UUID, which is the collision this test exists to disprove.
|
||||
second = _entry(hass, version=3, key=SHARED_SERIAL, host="192.168.0.14")
|
||||
_, second_entity = _seed_registry(hass, second, SHARED_SERIAL, suggested_object_id="purifier_b")
|
||||
|
||||
with _reachable(resources, UUID_B):
|
||||
await hass.config_entries.async_setup(second.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert first.data[CONF_DEVICE_KEY] == UUID_A
|
||||
assert second.data[CONF_DEVICE_KEY] == UUID_B
|
||||
assert first.unique_id != second.unique_id
|
||||
|
||||
assert _entity_unique_id(hass, first_entity.entity_id) == (f"{DOMAIN}_{UUID_A}_connection_mode")
|
||||
assert _entity_unique_id(hass, second_entity.entity_id) == (
|
||||
f"{DOMAIN}_{UUID_B}_connection_mode"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# What the user must not lose
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_every_user_customization_on_the_row_survives(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""Re-keying rewrites the registry row in place rather than replacing
|
||||
it, which is the whole reason to do it this way -- so everything the
|
||||
user attached to that row rides along. A rename, an area, an icon
|
||||
override and a deliberate hide are each things they would have to redo
|
||||
by hand if the row were recreated instead.
|
||||
"""
|
||||
entry = _entry(hass, version=3, key=MOCK_SERIAL)
|
||||
ent_reg = er.async_get(hass)
|
||||
device, existing = _seed_registry(
|
||||
hass, entry, MOCK_SERIAL, suggested_object_id="kitchen_purifier_connection"
|
||||
)
|
||||
dr.async_get(hass).async_update_device(device.id, area_id="kitchen")
|
||||
ent_reg.async_update_entity(
|
||||
existing.entity_id,
|
||||
name="Purifier link",
|
||||
icon="mdi:air-filter",
|
||||
area_id="kitchen",
|
||||
hidden_by=er.RegistryEntryHider.USER,
|
||||
)
|
||||
|
||||
with _reachable(fridge_resources, UUID_A):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
kept = ent_reg.async_get(existing.entity_id)
|
||||
assert kept is not None
|
||||
assert kept.unique_id == f"{DOMAIN}_{UUID_A}_connection_mode"
|
||||
assert kept.name == "Purifier link"
|
||||
assert kept.icon == "mdi:air-filter"
|
||||
assert kept.area_id == "kitchen"
|
||||
assert kept.hidden_by is er.RegistryEntryHider.USER
|
||||
rekeyed_device = dr.async_get(hass).async_get(device.id)
|
||||
assert rekeyed_device is not None
|
||||
assert rekeyed_device.area_id == "kitchen"
|
||||
|
||||
|
||||
async def test_a_composite_appliance_keeps_its_subdevice_links(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""A composite appliance (issue #177) registers one device per logical
|
||||
subdevice, keyed f"{key}_{subdevice}" and linked via_device to the
|
||||
master's bare key. Rewriting only the exact-match identifier would
|
||||
strand every sibling under a via_device pointing at a device that no
|
||||
longer exists, collapsing the user's device tree."""
|
||||
entry = _entry(hass, version=3, key=MOCK_SERIAL)
|
||||
dev_reg = dr.async_get(hass)
|
||||
master, _ = _seed_registry(hass, entry, MOCK_SERIAL)
|
||||
sub = dev_reg.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, f"{MOCK_SERIAL}_subdevice_1")},
|
||||
via_device=(DOMAIN, MOCK_SERIAL),
|
||||
)
|
||||
assert sub.via_device_id == master.id
|
||||
|
||||
with _reachable(fridge_resources, UUID_A):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert _device_identifiers(hass, master.id) == {(DOMAIN, UUID_A)}
|
||||
rekeyed_sub = dev_reg.async_get(sub.id)
|
||||
assert rekeyed_sub is not None
|
||||
assert rekeyed_sub.identifiers == {(DOMAIN, f"{UUID_A}_subdevice_1")}
|
||||
# Still the same parent row, so the device tree the user sees is intact.
|
||||
assert rekeyed_sub.via_device_id == master.id
|
||||
|
||||
|
||||
async def test_a_re_key_never_touches_another_entrys_rows(hass: HomeAssistant) -> None:
|
||||
"""The rewrite is scoped to one config entry's own registry rows.
|
||||
|
||||
Issue #381's install is the case that makes this sharp: *both* entries
|
||||
are keyed on the same shared serial, so both have registry rows under
|
||||
the identical old key, and they migrate one at a time. An unscoped
|
||||
rewrite -- matching on the key prefix alone -- would sweep up the other
|
||||
appliance's device and entities and hand them to the first one to
|
||||
migrate, which is the worst outcome this change could have.
|
||||
|
||||
The two entries hold *different* entities under that one shared prefix
|
||||
(HA's registry won't let two rows share a unique_id, which is issue
|
||||
#83's Bug 4 in the first place), so only the config-entry scoping can
|
||||
tell them apart -- prefix matching alone cannot.
|
||||
|
||||
Calls rekey_entry directly so the scoping is what's under test, rather
|
||||
than the coordinator's decision about whether to call it at all.
|
||||
"""
|
||||
from custom_components.localthings.rekey import rekey_entry
|
||||
|
||||
migrating = _entry(hass, version=3, key=SHARED_SERIAL, host="192.168.0.3")
|
||||
bystander = _entry(hass, version=3, key=SHARED_SERIAL, host="192.168.0.14")
|
||||
ent_reg = er.async_get(hass)
|
||||
moved = ent_reg.async_get_or_create(
|
||||
"sensor", DOMAIN, f"{DOMAIN}_{SHARED_SERIAL}_connection_mode", config_entry=migrating
|
||||
)
|
||||
stays = ent_reg.async_get_or_create(
|
||||
"sensor", DOMAIN, f"{DOMAIN}_{SHARED_SERIAL}_power", config_entry=bystander
|
||||
)
|
||||
|
||||
rekey_entry(hass, migrating, SHARED_SERIAL, UUID_A)
|
||||
|
||||
assert _entity_unique_id(hass, moved.entity_id) == f"{DOMAIN}_{UUID_A}_connection_mode"
|
||||
# The other appliance is still on the shared serial, waiting its turn.
|
||||
assert _entity_unique_id(hass, stays.entity_id) == f"{DOMAIN}_{SHARED_SERIAL}_power"
|
||||
assert bystander.unique_id == f"{DOMAIN}_{SHARED_SERIAL}"
|
||||
|
||||
|
||||
async def test_a_re_key_stops_at_the_key_boundary(hass: HomeAssistant) -> None:
|
||||
"""Matching is on the whole key or the key plus a separator, never a
|
||||
bare prefix. Serial numbers of one model are routinely prefixes of each
|
||||
other, so a naive startswith would drag a *different* appliance's rows
|
||||
along -- and the identifiers it would rewrite them to are nonsense."""
|
||||
from custom_components.localthings.rekey import rekey_entry
|
||||
|
||||
entry = _entry(hass, version=3, key="TEST-SERIAL")
|
||||
dev_reg = dr.async_get(hass)
|
||||
ent_reg = er.async_get(hass)
|
||||
target = dev_reg.async_get_or_create(
|
||||
config_entry_id=entry.entry_id, identifiers={(DOMAIN, "TEST-SERIAL")}
|
||||
)
|
||||
# Same config entry, so scoping can't save this one -- only the boundary.
|
||||
neighbour = dev_reg.async_get_or_create(
|
||||
config_entry_id=entry.entry_id, identifiers={(DOMAIN, "TEST-SERIAL-0000")}
|
||||
)
|
||||
neighbour_entity = ent_reg.async_get_or_create(
|
||||
"sensor", DOMAIN, f"{DOMAIN}_TEST-SERIAL-0000_connection_mode", config_entry=entry
|
||||
)
|
||||
|
||||
rekey_entry(hass, entry, "TEST-SERIAL", UUID_A)
|
||||
|
||||
assert _device_identifiers(hass, target.id) == {(DOMAIN, UUID_A)}
|
||||
assert _device_identifiers(hass, neighbour.id) == {(DOMAIN, "TEST-SERIAL-0000")}
|
||||
assert _entity_unique_id(hass, neighbour_entity.entity_id) == (
|
||||
f"{DOMAIN}_TEST-SERIAL-0000_connection_mode"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stability: upgrading twice, or offline, must not churn
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_upgrading_while_the_appliance_is_off_changes_nothing(
|
||||
hass: HomeAssistant, fridge_resources, hass_storage
|
||||
) -> None:
|
||||
"""The case a large share of users will actually hit: HA restarts onto
|
||||
the new release while the appliance is unplugged or asleep.
|
||||
|
||||
The entry comes up from its snapshot (issue #295), which never reached
|
||||
the device -- so it has no standing to claim an identity. It must load
|
||||
under the key its registry rows already carry and write nothing, or the
|
||||
real UUID would later look like a *changed* identity to defend against
|
||||
rather than the one-time adoption it is.
|
||||
"""
|
||||
entry = _entry(hass, version=3, key=MOCK_SERIAL)
|
||||
_, existing = _seed_registry(hass, entry, MOCK_SERIAL)
|
||||
|
||||
# Bank a snapshot from a run on the old release, then take it down.
|
||||
with _reachable(fridge_resources, None):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert entry.data[CONF_DEVICE_KEY] == MOCK_SERIAL
|
||||
|
||||
# Now the appliance is off. Simulate the pre-v4 shape the upgrade finds.
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, data={k: v for k, v in entry.data.items() if k != CONF_DEVICE_KEY}, version=3
|
||||
)
|
||||
|
||||
with _unreachable():
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
assert coordinator.device_key == MOCK_SERIAL
|
||||
# No key claimed, and the registry is exactly as it was.
|
||||
assert CONF_DEVICE_KEY not in entry.data
|
||||
assert entry.unique_id == f"{DOMAIN}_{MOCK_SERIAL}"
|
||||
assert _entity_unique_id(hass, existing.entity_id) == (
|
||||
f"{DOMAIN}_{MOCK_SERIAL}_connection_mode"
|
||||
)
|
||||
|
||||
|
||||
async def test_an_offline_load_never_rewrites_the_registry(
|
||||
hass: HomeAssistant, fridge_resources, hass_storage
|
||||
) -> None:
|
||||
"""A snapshot replay must not re-key on the strength of what the
|
||||
snapshot says, because that is last run's answer rather than the
|
||||
device's.
|
||||
|
||||
Modelled on a placeholder-serial board (issues #83/#189), which is
|
||||
where the two can genuinely disagree: the entry is keyed on its address
|
||||
because the board reports no usable serial, while the snapshot banked
|
||||
whatever serial the polled resources carried. A replay that trusted the
|
||||
snapshot would rewrite every registry row onto that serial -- without
|
||||
the appliance having been reachable at any point -- and would then
|
||||
freeze the answer into CONF_DEVICE_KEY, so the real UUID could never be
|
||||
adopted afterwards.
|
||||
"""
|
||||
entry = _entry(hass, version=3, key=MOCK_HOST)
|
||||
|
||||
with _reachable(fridge_resources, None):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Back to the pre-v4 shape the upgrade finds, still keyed on the address.
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data={
|
||||
**{k: v for k, v in entry.data.items() if k != CONF_DEVICE_KEY},
|
||||
CONF_SERIAL: MOCK_HOST,
|
||||
},
|
||||
unique_id=f"{DOMAIN}_{MOCK_HOST}",
|
||||
version=3,
|
||||
)
|
||||
device, existing = _seed_registry(hass, entry, MOCK_HOST)
|
||||
|
||||
with _unreachable():
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
assert coordinator.device_key == MOCK_HOST
|
||||
assert CONF_DEVICE_KEY not in entry.data
|
||||
assert entry.unique_id == f"{DOMAIN}_{MOCK_HOST}"
|
||||
assert _device_identifiers(hass, device.id) == {(DOMAIN, MOCK_HOST)}
|
||||
assert _entity_unique_id(hass, existing.entity_id) == (f"{DOMAIN}_{MOCK_HOST}_connection_mode")
|
||||
|
||||
# And the deferred adoption still works once the appliance answers --
|
||||
# the offline load left nothing frozen behind it.
|
||||
with _reachable(fridge_resources, UUID_A):
|
||||
coordinator._connect_session()
|
||||
coordinator._run_discovery(fridge_resources)
|
||||
|
||||
assert coordinator.device_key == UUID_A
|
||||
assert entry.data[CONF_DEVICE_KEY] == UUID_A
|
||||
assert _entity_unique_id(hass, existing.entity_id) == (f"{DOMAIN}_{UUID_A}_connection_mode")
|
||||
|
||||
|
||||
async def test_the_appliance_coming_back_completes_the_upgrade(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""The other half of the offline case: the deferred adoption is not
|
||||
abandoned, it just waits. Once the device answers, the same re-key runs
|
||||
and the entry finishes its upgrade."""
|
||||
entry = _entry(hass, version=3, key=MOCK_SERIAL)
|
||||
_, existing = _seed_registry(hass, entry, MOCK_SERIAL)
|
||||
|
||||
with _reachable(fridge_resources, None):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert entry.data[CONF_DEVICE_KEY] == MOCK_SERIAL
|
||||
|
||||
# The device is reachable again, now reporting its UUID.
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
with _reachable(fridge_resources, UUID_A):
|
||||
coordinator._connect_session()
|
||||
coordinator._run_discovery(fridge_resources)
|
||||
|
||||
assert coordinator.device_key == UUID_A
|
||||
assert entry.data[CONF_DEVICE_KEY] == UUID_A
|
||||
assert entry.unique_id == f"{DOMAIN}_{UUID_A}"
|
||||
assert _entity_unique_id(hass, existing.entity_id) == (f"{DOMAIN}_{UUID_A}_connection_mode")
|
||||
|
||||
|
||||
async def test_restarting_after_the_upgrade_is_a_no_op(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""Every restart re-runs discovery, so the adoption path runs again on
|
||||
an entry that has already moved. It must recognise its own work and do
|
||||
nothing -- a re-key that fired every boot would churn the registry
|
||||
forever."""
|
||||
entry = _entry(hass, version=3, key=MOCK_SERIAL)
|
||||
_, existing = _seed_registry(hass, entry, MOCK_SERIAL)
|
||||
|
||||
with _reachable(fridge_resources, UUID_A):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
after_first = dict(entry.data)
|
||||
await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert dict(entry.data) == after_first
|
||||
assert entry.unique_id == f"{DOMAIN}_{UUID_A}"
|
||||
kept = er.async_get(hass).async_get(existing.entity_id)
|
||||
assert kept is not None
|
||||
assert kept.unique_id == f"{DOMAIN}_{UUID_A}_connection_mode"
|
||||
# Exactly one device, not a duplicate alongside it.
|
||||
assert len(dr.async_entries_for_config_entry(dr.async_get(hass), entry.entry_id)) == 1
|
||||
|
||||
|
||||
async def test_an_already_migrated_entry_is_left_alone(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""A v4 entry created by the current config flow has nothing to migrate
|
||||
and nothing to re-key -- it was minted on its UUID."""
|
||||
entry = _entry(hass, version=4, key=UUID_A, serial=MOCK_SERIAL, device_key=UUID_A)
|
||||
device, existing = _seed_registry(hass, entry, UUID_A)
|
||||
|
||||
with _reachable(fridge_resources, UUID_A):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.version == 4
|
||||
assert entry.data[CONF_DEVICE_KEY] == UUID_A
|
||||
assert _device_identifiers(hass, device.id) == {(DOMAIN, UUID_A)}
|
||||
assert _entity_unique_id(hass, existing.entity_id) == (f"{DOMAIN}_{UUID_A}_connection_mode")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Guarding the identity once it has moved
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_a_poll_that_reads_no_uuid_does_not_demote_a_keyed_entry(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""The device saying nothing is not the device saying something
|
||||
different. A reconnect that can't read /oic/d (a timeout, a firmware
|
||||
hiccup) must leave the key alone -- demoting back onto the serial would
|
||||
re-key every entity the user has for the duration of an outage, and
|
||||
re-key them all back afterwards."""
|
||||
entry = _entry(hass, version=4, key=UUID_A, serial=MOCK_SERIAL, device_key=UUID_A)
|
||||
|
||||
with _reachable(fridge_resources, None):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
assert coordinator.device_key == UUID_A
|
||||
assert entry.data[CONF_DEVICE_KEY] == UUID_A
|
||||
|
||||
|
||||
async def test_a_rotated_uuid_on_the_same_serial_is_followed(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""OCF permits a hard factory reset to regenerate `di`. The serialNum is
|
||||
what tells that apart from a different appliance moving onto the
|
||||
address, and following it keeps the user's history rather than stranding
|
||||
it on a UUID the device will never report again."""
|
||||
entry = _entry(hass, version=4, key=UUID_A, serial=MOCK_SERIAL, device_key=UUID_A)
|
||||
device, existing = _seed_registry(hass, entry, UUID_A)
|
||||
|
||||
# fridge_resources reports MOCK_SERIAL, matching what the entry stored.
|
||||
with _reachable(fridge_resources, UUID_B):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.data[CONF_DEVICE_KEY] == UUID_B
|
||||
assert _device_identifiers(hass, device.id) == {(DOMAIN, UUID_B)}
|
||||
assert _entity_unique_id(hass, existing.entity_id) == (f"{DOMAIN}_{UUID_B}_connection_mode")
|
||||
|
||||
|
||||
async def test_a_different_appliance_on_the_same_address_keeps_the_registered_identity(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""Neither the UUID nor the serial matches what this entry was
|
||||
registered with, so this is a different appliance answering at this
|
||||
address -- not a reset of the registered one. Re-keying here would hand
|
||||
one appliance's entities, history and automations to another; re-adding
|
||||
is the user's call."""
|
||||
entry = _entry(hass, version=4, key=UUID_A, serial="SOME-OTHER-APPLIANCE", device_key=UUID_A)
|
||||
device, _ = _seed_registry(hass, entry, UUID_A)
|
||||
|
||||
with _reachable(fridge_resources, UUID_B):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.data[CONF_DEVICE_KEY] == UUID_A
|
||||
assert _device_identifiers(hass, device.id) == {(DOMAIN, UUID_A)}
|
||||
# The rejected appliance's serial is not written either. The serial
|
||||
# is what corroborates a later change of key, so adopting it here
|
||||
# would hand the intruder exactly the corroboration it needs to win
|
||||
# the *next* poll -- defending the identity once and then
|
||||
# surrendering it on the following cycle.
|
||||
assert entry.data[CONF_SERIAL] == "SOME-OTHER-APPLIANCE"
|
||||
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
coordinator._run_discovery(fridge_resources)
|
||||
|
||||
assert coordinator.device_key == UUID_A
|
||||
assert entry.data[CONF_DEVICE_KEY] == UUID_A
|
||||
assert entry.data[CONF_SERIAL] == "SOME-OTHER-APPLIANCE"
|
||||
|
||||
|
||||
async def test_a_pre_v4_entry_defends_itself_against_a_different_appliance(
|
||||
hass: HomeAssistant, fridge_resources
|
||||
) -> None:
|
||||
"""The "same IP, different appliance" guard applies to an entry that has
|
||||
not migrated yet, exactly as it does to one that has.
|
||||
|
||||
A pre-v4 entry is the population this change exists to move, but it is
|
||||
also the population that has been running longest -- so it is the last
|
||||
one that should hand its entity_ids, history and automations to an
|
||||
appliance that merely happens to have taken over its address. Adoption
|
||||
is the migration's job only when the identity is corroborated.
|
||||
"""
|
||||
entry = _entry(hass, version=3, key=MOCK_SERIAL)
|
||||
device, existing = _seed_registry(hass, entry, MOCK_SERIAL)
|
||||
|
||||
# Neither the serial nor (therefore) the UUID belongs to the registered
|
||||
# appliance.
|
||||
resources = _reporting_serial(fridge_resources, "SOME-OTHER-APPLIANCE")
|
||||
with _reachable(resources, UUID_B):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
assert coordinator.device_key == MOCK_SERIAL
|
||||
assert entry.data[CONF_DEVICE_KEY] == MOCK_SERIAL
|
||||
assert entry.data[CONF_SERIAL] == MOCK_SERIAL
|
||||
assert entry.unique_id == f"{DOMAIN}_{MOCK_SERIAL}"
|
||||
assert _device_identifiers(hass, device.id) == {(DOMAIN, MOCK_SERIAL)}
|
||||
assert _entity_unique_id(hass, existing.entity_id) == (
|
||||
f"{DOMAIN}_{MOCK_SERIAL}_connection_mode"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rekey_entry's own contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_rekey_is_idempotent(hass: HomeAssistant) -> None:
|
||||
"""Safe to attempt on every poll rather than having to track whether it
|
||||
has already run -- a second call finds nothing under the old key.
|
||||
|
||||
Calls rekey_entry directly: what it leaves behind is the contract, and
|
||||
going through a setup would let the platforms re-adding their entities
|
||||
hide a row that had in fact been orphaned.
|
||||
"""
|
||||
from custom_components.localthings.rekey import rekey_entry
|
||||
|
||||
entry = _entry(hass, version=3, key=MOCK_SERIAL)
|
||||
_, existing = _seed_registry(hass, entry, MOCK_SERIAL)
|
||||
|
||||
rekey_entry(hass, entry, MOCK_SERIAL, UUID_A)
|
||||
rekey_entry(hass, entry, MOCK_SERIAL, UUID_A)
|
||||
|
||||
kept = er.async_get(hass).async_get(existing.entity_id)
|
||||
assert kept is not None
|
||||
assert kept.unique_id == f"{DOMAIN}_{UUID_A}_connection_mode"
|
||||
assert entry.unique_id == f"{DOMAIN}_{UUID_A}"
|
||||
|
||||
|
||||
async def test_rekey_removes_a_stale_row_rather_than_colliding(hass: HomeAssistant) -> None:
|
||||
"""Where the destination key is already taken, the old-key row is the
|
||||
dead one -- unavailable since whichever restart created the split -- so
|
||||
it goes rather than being rewritten onto a key that exists. Same rule
|
||||
the #236 repair has always applied, now on the identity move."""
|
||||
from custom_components.localthings.rekey import rekey_entry
|
||||
|
||||
entry = _entry(hass, version=3, key=MOCK_SERIAL)
|
||||
ent_reg = er.async_get(hass)
|
||||
live = ent_reg.async_get_or_create(
|
||||
"sensor", DOMAIN, f"{DOMAIN}_{UUID_A}_connection_mode", config_entry=entry
|
||||
)
|
||||
stale = ent_reg.async_get_or_create(
|
||||
"sensor", DOMAIN, f"{DOMAIN}_{MOCK_SERIAL}_connection_mode", config_entry=entry
|
||||
)
|
||||
assert stale.entity_id != live.entity_id
|
||||
|
||||
rekey_entry(hass, entry, MOCK_SERIAL, UUID_A)
|
||||
|
||||
assert ent_reg.async_get(stale.entity_id) is None
|
||||
assert ent_reg.async_get(live.entity_id) is not None
|
||||
|
||||
|
||||
async def test_rekey_to_the_same_key_does_nothing(hass: HomeAssistant) -> None:
|
||||
"""The no-op guard that lets callers pass whatever they resolved without
|
||||
checking first."""
|
||||
from custom_components.localthings.rekey import rekey_entry
|
||||
|
||||
entry = _entry(hass, version=4, key=UUID_A, device_key=UUID_A)
|
||||
_, existing = _seed_registry(hass, entry, UUID_A)
|
||||
|
||||
rekey_entry(hass, entry, UUID_A, UUID_A)
|
||||
|
||||
assert _entity_unique_id(hass, existing.entity_id) == (f"{DOMAIN}_{UUID_A}_connection_mode")
|
||||
assert entry.unique_id == f"{DOMAIN}_{UUID_A}"
|
||||
@@ -1,4 +1,9 @@
|
||||
"""Config-entry migration and the placeholder-identity repair (issue #236)."""
|
||||
"""Config-entry migration and the placeholder-identity repair (issue #236).
|
||||
|
||||
The move onto the OCF device UUID (issue #381) has its own suite in
|
||||
test_identity_migration.py -- it is the one migration step that can't
|
||||
finish inside async_migrate_entry, so it needs a device to talk to.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -190,6 +190,7 @@ def test_try_enter_observe_mode_succeeds_when_all_hrefs_notify():
|
||||
assert entered is True
|
||||
assert mgr.mode == "observe"
|
||||
assert mgr.subscribed_hrefs == set(hrefs)
|
||||
assert mgr.fallback_hrefs == set()
|
||||
finally:
|
||||
mgr.close()
|
||||
|
||||
@@ -218,6 +219,41 @@ def test_try_enter_observe_mode_falls_back_when_subscribe_fails_for_all():
|
||||
assert mgr.mode == "poll"
|
||||
|
||||
|
||||
def test_enter_observe_mode_keeps_silent_hrefs_on_fallback():
|
||||
"""Issue #92: subscribed hrefs that never notified stay on the poll
|
||||
cadence via fallback_hrefs, rather than being treated as push-covered."""
|
||||
mgr = _manager()
|
||||
session = _FakeSession()
|
||||
subscribed = {"/a/vs/0", "/b/vs/0", "/c/vs/0"}
|
||||
mgr.on_notification("/a/vs/0", cbor2.dumps({"x": 1}))
|
||||
mgr.on_notification("/b/vs/0", cbor2.dumps({"x": 1}))
|
||||
try:
|
||||
mgr.enter_observe_mode(session, subscribed)
|
||||
assert mgr.mode == "observe"
|
||||
assert mgr.subscribed_hrefs == subscribed
|
||||
assert mgr.fallback_hrefs == {"/c/vs/0"}
|
||||
finally:
|
||||
mgr.close()
|
||||
|
||||
|
||||
def test_on_notification_drops_href_from_fallback():
|
||||
"""A late first notify (after the 80% snapshot) self-corrects
|
||||
fallback_hrefs so a slow-but-pushing href is not polled for the rest
|
||||
of the session -- multi-block resources being the reliable victim."""
|
||||
mgr = _manager()
|
||||
session = _FakeSession()
|
||||
subscribed = {"/a/vs/0", "/b/vs/0", "/c/vs/0"}
|
||||
mgr.on_notification("/a/vs/0", cbor2.dumps({"x": 1}))
|
||||
mgr.on_notification("/b/vs/0", cbor2.dumps({"x": 1}))
|
||||
try:
|
||||
mgr.enter_observe_mode(session, subscribed)
|
||||
assert mgr.fallback_hrefs == {"/c/vs/0"}
|
||||
mgr.on_notification("/c/vs/0", cbor2.dumps({"x": 1}))
|
||||
assert mgr.fallback_hrefs == set()
|
||||
finally:
|
||||
mgr.close()
|
||||
|
||||
|
||||
def test_try_enter_observe_mode_meets_success_fraction_with_partial_notifies():
|
||||
mgr = _manager()
|
||||
session = _FakeSession()
|
||||
@@ -243,6 +279,7 @@ def test_try_enter_observe_mode_meets_success_fraction_with_partial_notifies():
|
||||
|
||||
assert entered is True
|
||||
assert mgr.mode == "observe"
|
||||
assert mgr.fallback_hrefs == {hrefs[3]}
|
||||
finally:
|
||||
mgr.close()
|
||||
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
|
||||
The device's entity set only exists as the output of a live poll, so coming
|
||||
up offline means replaying the last successful discovery from a stored
|
||||
snapshot. These tests pin the four things that makes load-bearing: the
|
||||
snapshot. These tests pin the five things that makes load-bearing: the
|
||||
snapshot gets written, it produces the same entity set offline, the
|
||||
coordinator keeps polling until the device answers, and a live discovery that
|
||||
coordinator keeps polling until the device answers, a live discovery that
|
||||
disagrees with the snapshot reloads the entry rather than silently keeping a
|
||||
stale set.
|
||||
stale set, and each of those retries costs one handshake and one log line
|
||||
rather than repeating both every cycle (issue #269).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
@@ -19,8 +22,10 @@ import pytest
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.update_coordinator import UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
from pytest_homeassistant_custom_component.common import async_fire_time_changed
|
||||
from smartthings_local.errors import SessionTimeoutError
|
||||
|
||||
from custom_components.localthings.const import DOMAIN, SUMMARY_INTERVAL_S
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
@@ -29,6 +34,7 @@ from custom_components.localthings.registry.identity import DeviceIdentity
|
||||
from .conftest import _load_fridge_resources as _load_fridge
|
||||
|
||||
_COORD = "custom_components.localthings.coordinator.LocalThingsCoordinator"
|
||||
_COORD_LOGGER = "custom_components.localthings.coordinator"
|
||||
|
||||
|
||||
@contextmanager
|
||||
@@ -58,6 +64,20 @@ def _unreachable():
|
||||
yield
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _dark(handshakes: list[float]):
|
||||
"""A switched-off appliance: the DTLS handshake itself times out, which
|
||||
is what `_poll_once` really hits -- `_unreachable` above stands in one
|
||||
step later, after a session it never gets. Records every attempt."""
|
||||
|
||||
def _connect(self) -> None:
|
||||
handshakes.append(time.monotonic())
|
||||
raise SessionTimeoutError()
|
||||
|
||||
with patch(f"{_COORD}._connect_session", _connect), patch(f"{_COORD}._close_session"):
|
||||
yield
|
||||
|
||||
|
||||
def _store_key(entry) -> str:
|
||||
return f"{DOMAIN}.{entry.entry_id}.discovery"
|
||||
|
||||
@@ -416,3 +436,86 @@ async def test_offline_load_survives_repeated_poll_failures(
|
||||
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
assert coordinator.last_update_success
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# What a cycle against a dark appliance costs (issue #269)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_dark_appliance_costs_one_handshake_per_cycle(
|
||||
hass: HomeAssistant, mock_entry
|
||||
) -> None:
|
||||
"""A washer or dryer is switched off most of the day, so every one of
|
||||
these cycles is paid for real: a handshake against a device that isn't
|
||||
there runs to its full 12s timeout, and the reconnect retry used to add a
|
||||
second one plus its pause to every cycle -- and to every setup attempt
|
||||
while the appliance stayed dark. There is no session to reconnect when
|
||||
the handshake is what failed, so the retry only repeated it."""
|
||||
resources = _load_fridge()
|
||||
await _setup_online_then_unload(hass, mock_entry, resources)
|
||||
|
||||
handshakes: list[float] = []
|
||||
with _dark(handshakes):
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert len(handshakes) == 1
|
||||
|
||||
for expected in (2, 3, 4):
|
||||
await _tick(hass)
|
||||
assert len(handshakes) == expected
|
||||
|
||||
|
||||
async def test_dark_appliance_reports_its_outage_once(
|
||||
hass: HomeAssistant, mock_entry, caplog
|
||||
) -> None:
|
||||
"""Sitting through an outage is what this integration is built to do
|
||||
(issue #295), so it must not log an error every 30s for as long as the
|
||||
appliance is off -- the reporter on issue #269 read exactly that repeated
|
||||
line as the integration having failed. One line per outage, and one when
|
||||
the device comes back."""
|
||||
resources = _load_fridge()
|
||||
await _setup_online_then_unload(hass, mock_entry, resources)
|
||||
|
||||
caplog.clear()
|
||||
caplog.set_level(logging.INFO)
|
||||
with _dark([]):
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
for _ in range(3):
|
||||
await _tick(hass)
|
||||
|
||||
ours = [r for r in caplog.records if r.name.startswith(f"{_COORD_LOGGER}.")]
|
||||
errors = [r for r in ours if r.levelno >= logging.ERROR]
|
||||
assert len(errors) == 1
|
||||
assert "device unreachable" in errors[0].getMessage()
|
||||
|
||||
with _reachable(resources):
|
||||
await _tick(hass)
|
||||
|
||||
recovered = [r for r in caplog.records if "device answered again" in r.getMessage()]
|
||||
assert len(recovered) == 1
|
||||
assert recovered[0].levelno == logging.INFO
|
||||
|
||||
|
||||
async def test_broken_session_still_reconnects_within_the_cycle(
|
||||
hass: HomeAssistant, mock_entry
|
||||
) -> None:
|
||||
"""The counterpart guard: skipping the reconnect is only right when the
|
||||
handshake never completed. A session that opened and then failed mid-poll
|
||||
still gets torn down and re-established without waiting a whole cycle."""
|
||||
coordinator = LocalThingsCoordinator(hass, mock_entry)
|
||||
coordinator._discovered = True
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
LocalThingsCoordinator, "_poll_once", side_effect=RuntimeError("poll GET failed")
|
||||
) as poll,
|
||||
patch.object(LocalThingsCoordinator, "_close_session") as close,
|
||||
patch("custom_components.localthings.coordinator.asyncio.sleep"),
|
||||
pytest.raises(UpdateFailed),
|
||||
):
|
||||
await coordinator._async_update_data()
|
||||
|
||||
assert poll.call_count == 2
|
||||
close.assert_called_once()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -54,8 +54,8 @@ def test_no_unbound_hrefs():
|
||||
|
||||
def test_air_quality_sensors_read_from_shared_items_decode():
|
||||
"""Same /sensors/vs/0 {type, value} shape and common.sensor_item_value
|
||||
decode air_purifier.AIR_QUALITY already uses -- this board adds CO2,
|
||||
which neither air_purifier nor range_hood report."""
|
||||
decode air_purifier.AIR_QUALITY already uses -- this board lists CO2
|
||||
unconditionally, unlike the exists_fn-gated purifier descriptor."""
|
||||
state = _state()
|
||||
assert state["dust"] == 31
|
||||
assert state["fine_dust"] == 23
|
||||
|
||||
@@ -96,6 +96,26 @@ def test_air_monitor_takes_the_pm_labels_but_not_the_state_class():
|
||||
assert (desc.device_class, desc.unit) == (None, None), key
|
||||
|
||||
|
||||
def test_co2_is_not_in_the_shared_tuple():
|
||||
"""air_monitor already has its own CO2 SensorDesc. Putting CO2 in
|
||||
_AIR_QUALITY_SENSORS would create a second entity with the same key."""
|
||||
assert all(row[0] != "co2" for row in air_purifier._AIR_QUALITY_SENSORS)
|
||||
|
||||
|
||||
def test_co2_matches_air_monitor_mapping():
|
||||
"""ppm / carbon_dioxide is the same contract air_monitor.SENSORS already
|
||||
ships for this field (issue #387), not a unit guess."""
|
||||
desc = _desc("co2")
|
||||
assert desc.device_class == "carbon_dioxide"
|
||||
assert desc.unit == "ppm"
|
||||
assert desc.state_class == "measurement"
|
||||
assert desc.exists_fn is not None
|
||||
assert desc.enabled_default is False
|
||||
assert (
|
||||
desc.value_fn([{"x.com.samsung.da.type": "CO2", "x.com.samsung.da.value": ["498"]}]) == 498
|
||||
)
|
||||
|
||||
|
||||
def test_every_air_quality_sensor_still_reads_a_plain_int():
|
||||
"""A state_class is only honoured for a numeric state, so the value
|
||||
contract this depends on is asserted here too."""
|
||||
|
||||
@@ -12,7 +12,7 @@ from tests.conftest import _load_device
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = "TEST-AIRFLOW-SERIAL"
|
||||
device_key = "TEST-AIRFLOW-SERIAL"
|
||||
device_info: ClassVar[dict] = {}
|
||||
data: ClassVar[dict] = {}
|
||||
|
||||
|
||||
@@ -69,6 +69,36 @@ def test_air_quality_sensor_values():
|
||||
assert state["super_fine_dust"] == 5
|
||||
assert state["odor"] == 0
|
||||
assert state["clean_level"] == 0
|
||||
assert "co2" not in state
|
||||
|
||||
|
||||
def test_co2_reads_when_the_items_list_includes_the_type():
|
||||
"""Issue #387 -- same {type, value} shape air_monitor.SENSORS already
|
||||
models. Gated so boards that don't list CO2 don't grow an empty entity."""
|
||||
reg, resources = _purifier()
|
||||
resources = {
|
||||
**resources,
|
||||
"/sensors/vs/0": {
|
||||
**resources["/sensors/vs/0"],
|
||||
"x.com.samsung.da.items": [
|
||||
*resources["/sensors/vs/0"]["x.com.samsung.da.items"],
|
||||
{"x.com.samsung.da.type": "CO2", "x.com.samsung.da.value": ["612"]},
|
||||
],
|
||||
},
|
||||
}
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||
assert flatten(bound, resources)["co2"] == 612
|
||||
|
||||
desc = next(e for e in air_purifier.AIR_QUALITY.entities if e.key == "co2")
|
||||
assert desc.exists_fn is not None
|
||||
assert desc.enabled_default is False
|
||||
assert desc.exists_fn({"x.com.samsung.da.items": []}, {}) is False
|
||||
assert (
|
||||
desc.exists_fn({"x.com.samsung.da.items": [{"x.com.samsung.da.type": "CO2"}]}, {}) is True
|
||||
)
|
||||
# /device/0 stub must not drop co2 while its field-gated siblings still
|
||||
# register -- platforms enumerate bound once (issue #127).
|
||||
assert desc.exists_fn({"href": "/sensors/vs/0"}, {}) is True
|
||||
|
||||
|
||||
def test_filter_progress_reads_named_consumable_item():
|
||||
|
||||
@@ -21,7 +21,7 @@ from tests.conftest import _load_device
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = "TEST-VTWW-SERIAL"
|
||||
device_key = "TEST-VTWW-SERIAL"
|
||||
device_info: ClassVar[dict] = {}
|
||||
data: ClassVar[dict] = {}
|
||||
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
|
||||
@@ -986,7 +986,7 @@ def test_air_quality_disabled_by_default():
|
||||
SuperFineDust readings with no such scalar, so requiring it would
|
||||
silently drop real readings on hardware this repo hasn't seen yet on an
|
||||
AC. These stay bound whenever the item type is listed (see
|
||||
_has_sensor_type) and disabled by default instead, same precedent as
|
||||
has_sensor_type) and disabled by default instead, same precedent as
|
||||
fridge.rack_count / cooktop.paired_hood_model / tropical_night_mode --
|
||||
units that do have the sensor can enable it themselves."""
|
||||
for key in ("clean_level", "odor", "dust", "fine_dust", "super_fine_dust"):
|
||||
@@ -994,6 +994,17 @@ def test_air_quality_disabled_by_default():
|
||||
assert desc.enabled_default is False, key
|
||||
|
||||
|
||||
def test_air_quality_included_on_stub_rep():
|
||||
"""exists_fn would otherwise drop every gated sensor when /device/0
|
||||
returns a not-yet-fetched stub, while field-gated siblings still
|
||||
register (issue #127)."""
|
||||
stub = {"href": "/sensors/vs/0"}
|
||||
for key in ("clean_level", "odor", "dust", "fine_dust", "super_fine_dust", "co2"):
|
||||
desc = next(e for e in airconditioner.AIR_QUALITY.entities if e.key == key)
|
||||
assert desc.exists_fn is not None
|
||||
assert desc.exists_fn(stub, {}) is True, key
|
||||
|
||||
|
||||
def test_air_quality_absent_when_no_sensor_items():
|
||||
"""A board whose /sensors/vs/0 carries an empty items[] (the cool-only
|
||||
RAC variant) binds no air-quality entities -- exists_fn gates each on its
|
||||
|
||||
@@ -28,7 +28,7 @@ FIXTURE = "airconditioner_tp1x_rac_01001"
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = "TEST-RAC-01001-SERIAL"
|
||||
device_key = "TEST-RAC-01001-SERIAL"
|
||||
device_info: ClassVar[dict] = {}
|
||||
data: ClassVar[dict] = {}
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ def test_fac_bora_wind_strength_codes_fit_the_standard_scale():
|
||||
from tests.conftest import _load_device
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = "TEST-FAC-BORA-SERIAL"
|
||||
device_key = "TEST-FAC-BORA-SERIAL"
|
||||
device_info: ClassVar[dict] = {}
|
||||
data: ClassVar[dict] = {}
|
||||
|
||||
|
||||
@@ -417,6 +417,31 @@ class TestEnergyMeter:
|
||||
assert pw.exists_fn({"x.com.samsung.da.cumulativePower": "5"}, {}) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# has_sensor_type. Presence gate for /sensors/vs/0 items[] types, shared by
|
||||
# airconditioner.AIR_QUALITY and air_purifier.AIR_QUALITY. The stub carve-out
|
||||
# is the same contract ENERGY_METER documents for issue #127.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHasSensorType:
|
||||
def test_true_when_type_is_listed(self):
|
||||
fn = common.has_sensor_type("CO2")
|
||||
assert fn({"x.com.samsung.da.items": [{"x.com.samsung.da.type": "CO2"}]}, {}) is True
|
||||
|
||||
def test_false_when_type_is_absent(self):
|
||||
fn = common.has_sensor_type("CO2")
|
||||
assert fn({"x.com.samsung.da.items": [{"x.com.samsung.da.type": "Dust"}]}, {}) is False
|
||||
assert fn({"x.com.samsung.da.items": []}, {}) is False
|
||||
assert fn({}, {}) is False
|
||||
|
||||
def test_true_on_stub_rep(self):
|
||||
"""A true stub -- /device/0's {"href": "..."} "not fetched yet"
|
||||
marker -- must keep the entity so sub-polls can populate it."""
|
||||
fn = common.has_sensor_type("CO2")
|
||||
assert fn({"href": "/sensors/vs/0"}, {}) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AI energy-saving level. '0' is off; supportedAiLevel lists the additional
|
||||
# level(s) on offer. A single-entry list (issue #21 fridge, issue #40 washer)
|
||||
|
||||
@@ -62,6 +62,41 @@ 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 drum_clean_cycles_remaining is wired the same way here.
|
||||
|
||||
drum_clean_last_cleaned is deliberately not (issue #398): a live dump
|
||||
showed DrumCleanLog_'s newest entry moving every 30-90s on its own,
|
||||
including well after a cycle had finished -- unlike the washer/dryer
|
||||
reports this reader was built from (issues #9, #258), it never settles
|
||||
on a value worth showing."""
|
||||
|
||||
def test_cycles_remaining(self):
|
||||
desc = next(
|
||||
e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == "drum_clean_cycles_remaining"
|
||||
)
|
||||
assert desc.rep_fn is not None
|
||||
rep = {"x.com.samsung.da.options": ["WashingTimes_18", "DrumCleanProposal_20"]}
|
||||
assert desc.rep_fn(rep) == 2
|
||||
|
||||
def test_cycles_remaining_exists_only_when_computable(self):
|
||||
desc = next(
|
||||
e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == "drum_clean_cycles_remaining"
|
||||
)
|
||||
assert desc.exists_fn is not None
|
||||
assert desc.exists_fn({"x.com.samsung.da.options": []}, {}) is False
|
||||
rep = {"x.com.samsung.da.options": ["WashingTimes_18", "DrumCleanProposal_20"]}
|
||||
assert desc.exists_fn(rep, {}) is True
|
||||
|
||||
def test_last_cleaned_is_not_wired(self):
|
||||
assert not any(
|
||||
e.key == "drum_clean_last_cleaned" for e in dishwasher.CYCLE_OPTIONS.entities
|
||||
)
|
||||
|
||||
|
||||
def test_diagnosis_status_is_a_translatable_enum():
|
||||
desc = next(
|
||||
e
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for dryer support and washer/dryer consistency (issue #14)."""
|
||||
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model, resolve
|
||||
from custom_components.localthings.registry.capabilities import dryer, ignored
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from custom_components.localthings.registry.entities import SelectDesc
|
||||
@@ -90,8 +90,12 @@ def test_course_bound_to_shared_course_vs_0():
|
||||
|
||||
|
||||
def test_reported_table_00_course_codes_are_translated():
|
||||
"""The reporter confirmed these codes on a DVE45R6300W/A3 by selecting
|
||||
each cycle and reading back the raw course code (issue #357)."""
|
||||
"""The DVE45R6300W/A3 reporter confirmed these codes by selecting each
|
||||
cycle and reading back the raw course code (issue #357). A DV6800N --
|
||||
same DA_WM_A51_20_COMMON board, also Table_00 -- later confirmed 14 more
|
||||
(issue #394): a different subset of the same table, not a conflicting
|
||||
code family (its one code in common with #357, 'a5', means Bedding on
|
||||
both), so both sets share the one dryer_cycle_table_00 catalog entry."""
|
||||
from custom_components.localthings.catalog import translated_states
|
||||
|
||||
desc = next(
|
||||
@@ -99,7 +103,32 @@ def test_reported_table_00_course_codes_are_translated():
|
||||
)
|
||||
table_00 = {"/st/dryercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_00"}}
|
||||
assert desc.translation_key(table_00) == "dryer_cycle_table_00"
|
||||
confirmed = {"01", "9c", "a5", "9e", "9b", "27", "a0", "a4", "a6", "a3", "a2"}
|
||||
confirmed = {
|
||||
"01",
|
||||
"9c",
|
||||
"a5",
|
||||
"9e",
|
||||
"9b",
|
||||
"27",
|
||||
"a0",
|
||||
"a4",
|
||||
"a6",
|
||||
"a3",
|
||||
"a2", # issue #357
|
||||
"9a",
|
||||
"ca",
|
||||
"db",
|
||||
"99",
|
||||
"93",
|
||||
"b5",
|
||||
"d7",
|
||||
"96",
|
||||
"97",
|
||||
"7f",
|
||||
"98",
|
||||
"eb",
|
||||
"b6", # issue #394
|
||||
}
|
||||
assert confirmed <= translated_states("select", "dryer_cycle_table_00")
|
||||
|
||||
|
||||
@@ -109,3 +138,31 @@ def test_st_dryercourse_is_ignored():
|
||||
ignored_hrefs = {c.href for c in ignored.IGNORED}
|
||||
assert "/st/dryercourse/vs/0" in ignored_hrefs
|
||||
assert "/st/washercourse/vs/0" in ignored_hrefs
|
||||
|
||||
|
||||
def _dv6800n():
|
||||
resources = _load_device("dryer_dv6800n")
|
||||
reg = resolve(resources, device_types=("oic.wk.d", "oic.d.dryer"))
|
||||
return reg, resources
|
||||
|
||||
|
||||
def test_dv6800n_no_unbound_hrefs():
|
||||
"""Every resource in the issue #394 dump binds or is ignored."""
|
||||
reg, resources = _dv6800n()
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_dv6800n_course_codes_read_from_dump():
|
||||
"""A DV6800N (DA_WM_A51_20_COMMON, issue #394) reports 'Table_00' same
|
||||
as #357's DVE45R6300W/A3, so it resolves to the same catalog entry --
|
||||
its /course/vs/0 supportedOptions just advertises a different subset of
|
||||
the same table (see test_reported_table_00_course_codes_are_translated)."""
|
||||
_, resources = _dv6800n()
|
||||
desc = next(
|
||||
e for e in dryer.DRYER_COURSE.entities if e.key == "cycle" and isinstance(e, SelectDesc)
|
||||
)
|
||||
assert desc.translation_key(resources) == "dryer_cycle_table_00"
|
||||
confirmed = ["9A", "CA", "DB", "99", "93", "B5", "D7", "A5", "96", "97", "7F", "98", "EB", "B6"]
|
||||
assert desc.options(resources) == confirmed
|
||||
|
||||
@@ -10,7 +10,7 @@ from custom_components.localthings.registry.entities import BinarySensorDesc
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = "TEST-SERIAL"
|
||||
device_key = "TEST-SERIAL"
|
||||
|
||||
def __init__(self, last_resources=None):
|
||||
self.last_resources = last_resources or {}
|
||||
|
||||
@@ -463,7 +463,7 @@ class TestKimchiZone:
|
||||
)
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = "TEST-SERIAL"
|
||||
device_key = "TEST-SERIAL"
|
||||
|
||||
def __init__(self, resources, data):
|
||||
self.last_resources = resources
|
||||
|
||||
@@ -121,6 +121,25 @@ def test_registry_reproduces_golden_state_keys_for_dryer_dve50a8600():
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_dryer_dv6800n():
|
||||
"""DA_WM_A51_20_COMMON/DV6800N (issue #394) resolves via /oic/d's
|
||||
'oic.d.dryer' device type, not board-token guessing -- its modelNum's
|
||||
board tokens ('DA', 'WM', 'COMMON') are all deliberately excluded from
|
||||
_BOARD_TOKEN_TO_KEY (see registry/by_type's module docstring)."""
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device("dryer_dv6800n")
|
||||
golden = json.loads((GOLDEN / "dryer_dv6800n.json").read_text())
|
||||
state_keys = _new_state_keys(
|
||||
"dryer_dv6800n", resources, device_types=("oic.wk.d", "oic.d.dryer")
|
||||
)
|
||||
assert set(state_keys) == set(golden["state_keys"]), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_airconditioner():
|
||||
from tests.conftest import _load_device
|
||||
|
||||
@@ -389,6 +408,25 @@ def test_registry_reproduces_golden_state_keys_for_washer_dryer_combo():
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_washer_ww6500():
|
||||
"""DA_WM_A51_20 front-loader, typed solely by the WW consumer prefix in
|
||||
its /information/vs/0 description: A51 is not a board token, so with the
|
||||
description blanked this device resolves to nothing and drops to the
|
||||
unknown-device fallback. That is the fragile route this test pins.
|
||||
Reports the AddWash tokens and an empty /wm/editcourse/vs/0, so its cycle
|
||||
list comes from supportedOptions."""
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device("washer_ww6500")
|
||||
golden = json.loads((GOLDEN / "washer_ww6500.json").read_text())
|
||||
state_keys = _new_state_keys("washer_ww6500", resources)
|
||||
assert set(state_keys) == set(golden["state_keys"]), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_artik051_ref_17k():
|
||||
"""ARTIK051_REF_17K's Cool Select Zone pantry compartment
|
||||
(/status/pantry/one/vs/0) -- issue #20."""
|
||||
|
||||
+129
-1
@@ -1,6 +1,12 @@
|
||||
import cbor2
|
||||
|
||||
from custom_components.localthings.registry.identity import read_identity
|
||||
from custom_components.localthings.registry.identity import (
|
||||
DeviceIdentity,
|
||||
is_usable_device_id,
|
||||
ocf_device_key,
|
||||
read_identity,
|
||||
resolve_device_key,
|
||||
)
|
||||
|
||||
|
||||
class FakeSession:
|
||||
@@ -124,3 +130,125 @@ def test_read_identity_tolerates_malformed_oic_res():
|
||||
_device_types' handling of a malformed /oic/d rt."""
|
||||
ident = read_identity(FakeSession({("oic", "res"): {"not": "a list"}}), serial=None)
|
||||
assert ident.raw["/oic/res"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The device key: which identity field registry keys are minted from (#381)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _identity(
|
||||
*,
|
||||
serial: str | None = None,
|
||||
device_id: str | None = None,
|
||||
platform_id: str | None = None,
|
||||
) -> DeviceIdentity:
|
||||
"""A DeviceIdentity carrying only the fields the key chain reads."""
|
||||
return DeviceIdentity(
|
||||
manufacturer="Samsung",
|
||||
model="M",
|
||||
name="N",
|
||||
serial=serial,
|
||||
device_id=device_id,
|
||||
platform_id=platform_id,
|
||||
)
|
||||
|
||||
|
||||
def test_read_identity_captures_the_ocf_uuids_as_named_fields():
|
||||
"""`di`/`pi` are what resolve_device_key mints keys from, so they are
|
||||
lifted out of `raw` rather than dug back out of it at every call site."""
|
||||
sess = FakeSession(
|
||||
{
|
||||
("oic", "p"): {"pi": "ccfd73b3-aeb4-792a-1100-68f06f5d603b"},
|
||||
("oic", "d"): {"di": "3771f8bf-c184-3a2d-d885-e4c9818736d2"},
|
||||
}
|
||||
)
|
||||
ident = read_identity(sess, serial=None)
|
||||
assert ident.device_id == "3771f8bf-c184-3a2d-d885-e4c9818736d2"
|
||||
assert ident.platform_id == "ccfd73b3-aeb4-792a-1100-68f06f5d603b"
|
||||
|
||||
|
||||
def test_read_identity_ignores_non_string_uuids():
|
||||
"""Firmware answering with a number or a map must not put a non-string
|
||||
into a field that goes on to be string-formatted into a unique_id."""
|
||||
ident = read_identity(FakeSession({("oic", "d"): {"di": 42}, ("oic", "p"): {"pi": {}}}), None)
|
||||
assert ident.device_id is None
|
||||
assert ident.platform_id is None
|
||||
|
||||
|
||||
def test_two_units_sharing_a_serial_get_distinct_keys():
|
||||
"""Issue #381 exactly: two Samsung air purifiers of the same model ship
|
||||
the identical, well-formed serialNum 'BS7SP9AW400114A', so keying on it
|
||||
collapsed them onto one identity and the second was refused as already
|
||||
configured. Their `di` differs, which is what makes them separable."""
|
||||
shared_serial = "BS7SP9AW400114A"
|
||||
first = resolve_device_key(
|
||||
_identity(device_id="ccfd73b3-aeb4-792a-1100-68f06f5d603b"), shared_serial, "192.168.0.3"
|
||||
)
|
||||
second = resolve_device_key(
|
||||
_identity(device_id="3771f8bf-c184-3a2d-d885-e4c9818736d2"), shared_serial, "192.168.0.14"
|
||||
)
|
||||
assert first != second
|
||||
assert shared_serial not in (first, second)
|
||||
|
||||
|
||||
def test_platform_id_is_the_fallback_when_oic_d_is_unreadable():
|
||||
ident = _identity(device_id=None, platform_id="ccfd73b3-aeb4-792a-1100-68f06f5d603b")
|
||||
assert resolve_device_key(ident, "REAL-SERIAL", "10.0.0.1") == (
|
||||
"ccfd73b3-aeb4-792a-1100-68f06f5d603b"
|
||||
)
|
||||
|
||||
|
||||
def test_device_id_wins_over_platform_id():
|
||||
"""`pi` is platform-scoped, so a board hosting more than one logical OCF
|
||||
device shares it -- the collision this exists to prevent."""
|
||||
ident = _identity(device_id="dddddddd-0000-1111-2222-333333333333", platform_id="shared-plat")
|
||||
assert resolve_device_key(ident, "REAL-SERIAL", "10.0.0.1") == (
|
||||
"dddddddd-0000-1111-2222-333333333333"
|
||||
)
|
||||
|
||||
|
||||
def test_falls_back_to_the_serial_then_the_host():
|
||||
"""A board that answers neither OCF resource lands exactly where it did
|
||||
before any of this existed -- no regression for existing hardware."""
|
||||
assert resolve_device_key(None, "REAL-SERIAL", "10.0.0.1") == "REAL-SERIAL"
|
||||
assert resolve_device_key(_identity(), "REAL-SERIAL", "10.0.0.1") == "REAL-SERIAL"
|
||||
# ...and a placeholder serial still resolves to the host (#83/#189).
|
||||
assert resolve_device_key(_identity(), "Nothing(SVC)", "10.0.0.1") == "10.0.0.1"
|
||||
|
||||
|
||||
def test_the_key_is_case_normalized():
|
||||
"""The stored key is compared against a freshly polled one on every
|
||||
poll; firmware that changed case between reads would otherwise look
|
||||
like a different appliance every time."""
|
||||
ident = _identity(device_id="CCFD73B3-AEB4-792A-1100-68F06F5D603B")
|
||||
assert resolve_device_key(ident, None, "10.0.0.1") == "ccfd73b3-aeb4-792a-1100-68f06f5d603b"
|
||||
|
||||
|
||||
def test_the_nil_uuid_is_not_an_identity():
|
||||
"""OCF's unset UUID is identical on every unit that never had one
|
||||
assigned -- the #189 failure mode on a new field. Its dashes stop
|
||||
is_placeholder_serial's repeated-digit rule from seeing it, so it needs
|
||||
its own check; falling through to the serial is the right answer."""
|
||||
ident = _identity(device_id="00000000-0000-0000-0000-000000000000")
|
||||
assert resolve_device_key(ident, "REAL-SERIAL", "10.0.0.1") == "REAL-SERIAL"
|
||||
assert not is_usable_device_id("00000000-0000-0000-0000-000000000000")
|
||||
|
||||
|
||||
def test_known_junk_disqualifies_a_uuid_the_same_way_it_does_a_serial():
|
||||
"""A board firmware-flashed with 'Nothing(SVC)' in one identity field is
|
||||
not a board to trust in another."""
|
||||
assert not is_usable_device_id("Nothing(SVC)")
|
||||
assert not is_usable_device_id("FFFFFFFFFFFFFFF")
|
||||
assert not is_usable_device_id("")
|
||||
assert not is_usable_device_id(None)
|
||||
assert is_usable_device_id("3771f8bf-c184-3a2d-d885-e4c9818736d2")
|
||||
|
||||
|
||||
def test_ocf_device_key_reports_absence_rather_than_collapsing_to_the_serial():
|
||||
"""The coordinator needs "the device said nothing" and "the device said
|
||||
this" to be different answers, so it never demotes a UUID-keyed entry
|
||||
onto a serial because one poll couldn't read /oic/d."""
|
||||
assert ocf_device_key(None) is None
|
||||
assert ocf_device_key(_identity(serial="REAL-SERIAL")) is None
|
||||
assert ocf_device_key(_identity(device_id="abc-123")) == "abc-123"
|
||||
|
||||
@@ -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,7 +147,7 @@ async def test_pattern_a_sub1_device_info_links_via_device_to_master(hass: HomeA
|
||||
sub1 = next(su for su in coordinator.subdevices if su.key == "1")
|
||||
info = coordinator.device_info_for(sub1)
|
||||
|
||||
master_serial = coordinator.device_serial
|
||||
master_serial = coordinator.device_key
|
||||
assert info["identifiers"] == {(DOMAIN, f"{master_serial}_1")}
|
||||
assert info["via_device"] == (DOMAIN, master_serial)
|
||||
# The subdevice's own /information/vs/1 (real, ARTIK051_DONGLE_FAC_RAC_18K)
|
||||
@@ -245,7 +245,7 @@ async def test_fac_bora_2in1_subdevice_device_info(hass: HomeAssistant):
|
||||
subdevice = coordinator.subdevices[0]
|
||||
info = coordinator.device_info_for(subdevice)
|
||||
|
||||
master_serial = coordinator.device_serial
|
||||
master_serial = coordinator.device_key
|
||||
assert info["identifiers"] == {(DOMAIN, f"{master_serial}_{_SUB_UUID}")}
|
||||
assert info["via_device"] == (DOMAIN, master_serial)
|
||||
# Confirmed live by the reporter (DESIGN-177.md section 1): the wall
|
||||
@@ -266,7 +266,7 @@ async def test_fac_bora_2in1_unique_ids_include_subdevice_prefix(hass: HomeAssis
|
||||
entity = LocalThingsEntity(coordinator, sub_climate)
|
||||
expected_slug = _SUB_UUID.replace("-", "")
|
||||
assert entity._attr_unique_id == (
|
||||
f"{DOMAIN}_{coordinator.device_serial}_subdevice_{expected_slug}_climate"
|
||||
f"{DOMAIN}_{coordinator.device_key}_subdevice_{expected_slug}_climate"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Tests for the AddWash entities on /course/vs/0.
|
||||
|
||||
Three independent tokens: AddWashSet (the alarm's 3-bit mask, the only
|
||||
writable one), AddWashAvailable (what the loaded course permits) and
|
||||
AddWashIndicator (the live panel lamp). See washer.py for where the bit
|
||||
meanings and the write contract come from.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import resolve
|
||||
from custom_components.localthings.registry.capabilities import washer
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from custom_components.localthings.registry.entities import BinarySensorDesc, SwitchDesc
|
||||
from tests.conftest import _load_device
|
||||
|
||||
COURSE = ["course", "vs", "0"]
|
||||
BITS = {
|
||||
"add_wash_alarm_rinse": 0,
|
||||
"add_wash_alarm_final_rinse": 1,
|
||||
"add_wash_alarm_spin": 2,
|
||||
}
|
||||
ENTITY_TOKENS = {
|
||||
"add_wash_alarm": "AddWashSet",
|
||||
"add_wash_alarm_rinse": "AddWashSet",
|
||||
"add_wash_alarm_final_rinse": "AddWashSet",
|
||||
"add_wash_alarm_spin": "AddWashSet",
|
||||
"add_wash_available": "AddWashAvailable",
|
||||
"add_wash_indicator": "AddWashIndicator",
|
||||
}
|
||||
# A value each token really carries, so presence gating is exercised against
|
||||
# what a washer reports rather than a synthetic one.
|
||||
SAMPLE = {"AddWashSet": "0", "AddWashAvailable": "7", "AddWashIndicator": "Off"}
|
||||
|
||||
|
||||
def _desc(key, kind):
|
||||
return next(e for e in washer.WASHER_COURSE.entities if e.key == key and isinstance(e, kind))
|
||||
|
||||
|
||||
def _rep(*tokens):
|
||||
return {"x.com.samsung.da.options": list(tokens)}
|
||||
|
||||
|
||||
def _write(desc, payload, rep):
|
||||
return desc.write_fn(payload, rep)
|
||||
|
||||
|
||||
def _options(result):
|
||||
"""The tokens a write_fn result carries, asserting it targets /course/vs/0."""
|
||||
path, body = result
|
||||
assert path == COURSE
|
||||
return body["x.com.samsung.da.options"]
|
||||
|
||||
|
||||
def _exists(key, rep):
|
||||
"""Whether `key`'s descriptor gates itself in for `rep`."""
|
||||
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == key)
|
||||
assert desc.exists_fn is not None
|
||||
return desc.exists_fn(rep, {})
|
||||
|
||||
|
||||
def _flatten(fixture):
|
||||
resources = _load_device(fixture)
|
||||
reg = resolve(resources)
|
||||
assert reg is not None
|
||||
return flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
|
||||
|
||||
|
||||
class TestAlarmMasterSwitch:
|
||||
def test_zero_reads_off_and_seven_reads_on(self):
|
||||
desc = _desc("add_wash_alarm", SwitchDesc)
|
||||
assert desc.rep_fn(_rep("AddWashSet_0")) is False
|
||||
assert desc.rep_fn(_rep("AddWashSet_7")) is True
|
||||
|
||||
@pytest.mark.parametrize("mask", range(8))
|
||||
def test_any_non_zero_mask_reads_on(self, mask):
|
||||
desc = _desc("add_wash_alarm", SwitchDesc)
|
||||
assert desc.rep_fn(_rep(f"AddWashSet_{mask}")) is (mask != 0)
|
||||
|
||||
def test_turning_on_enables_every_moment(self):
|
||||
desc = _desc("add_wash_alarm", SwitchDesc)
|
||||
assert _options(_write(desc, "On", _rep("AddWashSet_0"))) == ["AddWashSet_7"]
|
||||
|
||||
def test_turning_off_clears_the_mask(self):
|
||||
desc = _desc("add_wash_alarm", SwitchDesc)
|
||||
assert _options(_write(desc, "Off", _rep("AddWashSet_5"))) == ["AddWashSet_0"]
|
||||
|
||||
@pytest.mark.parametrize("mask", range(1, 8))
|
||||
def test_on_over_an_alarm_already_on_keeps_the_chosen_moments(self, mask):
|
||||
"""Home Assistant calls turn_on regardless of current state, so
|
||||
re-asserting "on" over a rinse-only mask must not widen it to all
|
||||
three -- this switch reads on either way, so no state change would
|
||||
point at the loss. Reaching 7 from a subset still means off, then
|
||||
on."""
|
||||
desc = _desc("add_wash_alarm", SwitchDesc)
|
||||
rep = _rep(f"AddWashSet_{mask}")
|
||||
assert desc.rep_fn(rep) is True
|
||||
assert _write(desc, "On", rep) is None
|
||||
assert _options(_write(desc, "Off", rep)) == ["AddWashSet_0"]
|
||||
assert _options(_write(desc, "On", _rep("AddWashSet_0"))) == ["AddWashSet_7"]
|
||||
|
||||
def test_rejects_a_payload_that_is_not_on_or_off(self):
|
||||
desc = _desc("add_wash_alarm", SwitchDesc)
|
||||
assert _write(desc, "7", _rep("AddWashSet_0")) is None
|
||||
|
||||
def test_rejects_a_write_against_an_empty_options_array(self):
|
||||
desc = _desc("add_wash_alarm", SwitchDesc)
|
||||
assert _write(desc, "On", {}) is None
|
||||
|
||||
@pytest.mark.parametrize("raw", ["15", "On", "-1"])
|
||||
def test_refuses_to_write_over_a_mask_it_cannot_read(self, raw):
|
||||
"""A device reporting a wider mask must not have it truncated to 7.
|
||||
The master is gated exactly like the per-moment writes, so an
|
||||
unrecognized mask leaves every AddWash switch read-only."""
|
||||
desc = _desc("add_wash_alarm", SwitchDesc)
|
||||
rep = _rep(f"AddWashSet_{raw}")
|
||||
assert desc.rep_fn(rep) is None
|
||||
assert _write(desc, "On", rep) is None
|
||||
assert _write(desc, "Off", rep) is None
|
||||
|
||||
|
||||
class TestAlarmMomentSwitches:
|
||||
@pytest.mark.parametrize("key,bit", BITS.items())
|
||||
@pytest.mark.parametrize("mask", range(8))
|
||||
def test_every_mask_decodes_to_the_right_bits(self, key, bit, mask):
|
||||
desc = _desc(key, SwitchDesc)
|
||||
assert desc.rep_fn(_rep(f"AddWashSet_{mask}")) is bool(mask >> bit & 1)
|
||||
|
||||
def test_setting_one_moment_leaves_the_others_alone(self):
|
||||
desc = _desc("add_wash_alarm_final_rinse", SwitchDesc)
|
||||
# 5 is rinse + spin; adding the final rinse must reach 7, not 2.
|
||||
assert _options(_write(desc, "On", _rep("AddWashSet_5"))) == ["AddWashSet_7"]
|
||||
|
||||
def test_clearing_one_moment_leaves_the_others_alone(self):
|
||||
desc = _desc("add_wash_alarm_rinse", SwitchDesc)
|
||||
assert _options(_write(desc, "Off", _rep("AddWashSet_7"))) == ["AddWashSet_6"]
|
||||
|
||||
def test_clearing_the_last_moment_yields_zero(self):
|
||||
desc = _desc("add_wash_alarm_spin", SwitchDesc)
|
||||
assert _options(_write(desc, "Off", _rep("AddWashSet_4"))) == ["AddWashSet_0"]
|
||||
|
||||
def test_enabling_a_moment_from_zero_turns_the_alarm_on(self):
|
||||
"""The mask is the only state, so this is the intended outcome --
|
||||
there is no remembered combination to restore."""
|
||||
moment = _desc("add_wash_alarm_spin", SwitchDesc)
|
||||
written = _options(_write(moment, "On", _rep("AddWashSet_0")))
|
||||
assert written == ["AddWashSet_4"]
|
||||
assert _desc("add_wash_alarm", SwitchDesc).rep_fn(_rep(*written)) is True
|
||||
|
||||
@pytest.mark.parametrize("rep", [{}, _rep("AddWashSet_x"), _rep("Course_5C")])
|
||||
def test_refuses_to_write_when_the_mask_is_unreadable(self, rep):
|
||||
desc = _desc("add_wash_alarm_rinse", SwitchDesc)
|
||||
assert _write(desc, "On", rep) is None
|
||||
|
||||
|
||||
class TestMaskParsing:
|
||||
def test_a_missing_token_is_unavailable_not_zero(self):
|
||||
assert washer._add_wash_mask(_rep("Course_5C"), "AddWashSet") is None
|
||||
assert _desc("add_wash_alarm", SwitchDesc).rep_fn(_rep("Course_5C")) is None
|
||||
|
||||
def test_a_malformed_token_is_unavailable(self):
|
||||
assert washer._add_wash_mask(_rep("AddWashSet_On"), "AddWashSet") is None
|
||||
|
||||
def test_zero_is_a_real_value(self):
|
||||
assert washer._add_wash_mask(_rep("AddWashSet_0"), "AddWashSet") == 0
|
||||
|
||||
@pytest.mark.parametrize("raw", ["8", "255", "-1"])
|
||||
def test_a_mask_outside_three_bits_is_unavailable(self, raw):
|
||||
"""This models exactly three moments, so a wider value means the
|
||||
model is wrong -- refuse it rather than read-modify-write it back."""
|
||||
assert washer._add_wash_mask(_rep(f"AddWashSet_{raw}"), "AddWashSet") is None
|
||||
assert _desc("add_wash_alarm_rinse", SwitchDesc).rep_fn(_rep(f"AddWashSet_{raw}")) is None
|
||||
assert (
|
||||
_write(_desc("add_wash_alarm_spin", SwitchDesc), "On", _rep(f"AddWashSet_{raw}"))
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
class TestReadOnlySensors:
|
||||
@pytest.mark.parametrize("raw,expected", [("0", False), ("6", True), ("7", True)])
|
||||
def test_available_is_true_for_any_permitted_moment(self, raw, expected):
|
||||
desc = _desc("add_wash_available", BinarySensorDesc)
|
||||
assert desc.rep_fn(_rep(f"AddWashAvailable_{raw}")) is expected
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", [("On", True), ("Off", False)])
|
||||
def test_indicator_maps_on_off(self, raw, expected):
|
||||
desc = _desc("add_wash_indicator", BinarySensorDesc)
|
||||
assert desc.rep_fn(_rep(f"AddWashIndicator_{raw}")) is expected
|
||||
|
||||
def test_indicator_ships_enabled_and_uncategorised(self):
|
||||
desc = _desc("add_wash_indicator", BinarySensorDesc)
|
||||
assert desc.enabled_default is True
|
||||
assert desc.entity_category is None
|
||||
|
||||
def test_missing_tokens_are_unavailable(self):
|
||||
assert _desc("add_wash_available", BinarySensorDesc).rep_fn(_rep()) is None
|
||||
assert _desc("add_wash_indicator", BinarySensorDesc).rep_fn(_rep()) is None
|
||||
|
||||
|
||||
class TestCapabilityDetection:
|
||||
"""Each entity self-gates on its own token, so a washer advertising only
|
||||
a subset gets only that subset."""
|
||||
|
||||
@pytest.mark.parametrize("key,token", ENTITY_TOKENS.items())
|
||||
def test_absent_on_a_washer_that_never_reports_the_token(self, key, token):
|
||||
assert _exists(key, _rep("Course_5C")) is False
|
||||
|
||||
@pytest.mark.parametrize("key,token", ENTITY_TOKENS.items())
|
||||
def test_present_once_the_token_appears(self, key, token):
|
||||
assert _exists(key, _rep(f"{token}_{SAMPLE[token]}")) is True
|
||||
|
||||
def test_a_washer_with_only_the_indicator_gets_only_that_entity(self):
|
||||
present = {
|
||||
e.key
|
||||
for e in washer.WASHER_COURSE.entities
|
||||
if e.key in ENTITY_TOKENS and _exists(e.key, _rep("AddWashIndicator_On"))
|
||||
}
|
||||
assert present == {"add_wash_indicator"}
|
||||
|
||||
def test_pre_add_wash_washers_gain_nothing(self):
|
||||
"""washer_device is a DA_WM_TP1_21 dump with no AddWash tokens."""
|
||||
state = _flatten("washer")
|
||||
assert not [key for key in state if key.startswith("add_wash")]
|
||||
|
||||
|
||||
class TestAgainstTheWW6500Dump:
|
||||
"""DA_WM_A51_20_COMMON_WW6500, captured with the alarm off, the course
|
||||
permitting all three moments and the lamp dark."""
|
||||
|
||||
def test_types_only_by_the_description_consumer_prefix(self):
|
||||
"""A51 is not a board token, so the WW prefix in the description is
|
||||
the only thing routing this device -- see the golden test's docstring.
|
||||
Pinned here so the claim can't quietly stop being true."""
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
|
||||
info = _load_device("washer_ww6500")["/information/vs/0"]
|
||||
model = info["x.com.samsung.da.modelNum"]
|
||||
reg = for_device_by_model(model, info["x.com.samsung.da.description"])
|
||||
assert reg is not None and reg.name == "washer"
|
||||
assert for_device_by_model(model, "") is None
|
||||
|
||||
def test_no_unbound_hrefs(self):
|
||||
resources = _load_device("washer_ww6500")
|
||||
reg = resolve(resources)
|
||||
assert reg is not None
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
def test_reports_every_add_wash_entity(self):
|
||||
state = _flatten("washer_ww6500")
|
||||
assert {key for key in state if key.startswith("add_wash")} == {
|
||||
"add_wash_alarm",
|
||||
"add_wash_alarm_rinse",
|
||||
"add_wash_alarm_final_rinse",
|
||||
"add_wash_alarm_spin",
|
||||
"add_wash_available",
|
||||
"add_wash_indicator",
|
||||
}
|
||||
|
||||
def test_alarm_off_course_permits_lamp_dark(self):
|
||||
state = _flatten("washer_ww6500")
|
||||
assert state["add_wash_alarm"] is False
|
||||
assert all(state[f"add_wash_alarm_{m}"] is False for m in ("rinse", "final_rinse", "spin"))
|
||||
assert state["add_wash_available"] is True
|
||||
assert state["add_wash_indicator"] is False
|
||||
@@ -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