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