Compare commits
22
Commits
v0.24.0-beta.1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5e25d72d3 | ||
|
|
6c980f927b | ||
|
|
207dd2f830 | ||
|
|
b0caebe14d | ||
|
|
513d44e8be | ||
|
|
2a52cd9cfa | ||
|
|
16dfcd4e50 | ||
|
|
4edd5b7866 | ||
|
|
51bce3c8cf | ||
|
|
35f144a2d1 | ||
|
|
43076352ae | ||
|
|
171316bd95 | ||
|
|
61d2b9e9ff | ||
|
|
5ab0c9b5a8 | ||
|
|
6003394cb2 | ||
|
|
66b4f1400b | ||
|
|
c573a41483 | ||
|
|
b5969773db | ||
|
|
9a45afe949 | ||
|
|
296ba18cba | ||
|
|
258cdc1647 | ||
|
|
33a06b9ae8 |
No files matched your search
@@ -337,6 +337,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
self.device_type_name: str | None = None
|
self.device_type_name: str | None = None
|
||||||
self.one_ui_version: str = ""
|
self.one_ui_version: str = ""
|
||||||
self._consecutive_poll_timeouts = 0
|
self._consecutive_poll_timeouts = 0
|
||||||
|
# Set by _poll_once when the failure was the DTLS handshake itself.
|
||||||
|
# A switched-off appliance fails there every cycle, and there is no
|
||||||
|
# session to tear down and re-establish -- see _async_update_data.
|
||||||
|
self._handshake_failed = False
|
||||||
|
# Consecutive cycles that ended with no data from the device, so an
|
||||||
|
# outage is reported once rather than once per poll (issue #269).
|
||||||
|
self._failed_cycles = 0
|
||||||
self._unbound_hrefs: list[str] = []
|
self._unbound_hrefs: list[str] = []
|
||||||
self._reconnect_times: list[float] = []
|
self._reconnect_times: list[float] = []
|
||||||
# See _maybe_retry_observe_mode: last_mode_change_ts alone doesn't
|
# See _maybe_retry_observe_mode: last_mode_change_ts alone doesn't
|
||||||
@@ -861,9 +868,18 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
call's own timeout and surfacing as an ambiguous `TimeoutError`.
|
call's own timeout and surfacing as an ambiguous `TimeoutError`.
|
||||||
See `_defer_reconnect_for` for what that changes about how soon a
|
See `_defer_reconnect_for` for what that changes about how soon a
|
||||||
confirmed-dead session gets reconnected.
|
confirmed-dead session gets reconnected.
|
||||||
|
|
||||||
|
Sets `_handshake_failed` so `_async_update_data` can tell a broken
|
||||||
|
session from one that never opened -- a switched-off appliance fails
|
||||||
|
in `_connect_session` every cycle, with nothing to reconnect.
|
||||||
"""
|
"""
|
||||||
if self._session is None:
|
if self._session is None:
|
||||||
self._connect_session()
|
try:
|
||||||
|
self._connect_session()
|
||||||
|
except Exception:
|
||||||
|
self._handshake_failed = True
|
||||||
|
raise
|
||||||
|
self._handshake_failed = False
|
||||||
sess = self._session
|
sess = self._session
|
||||||
assert sess is not None
|
assert sess is not None
|
||||||
try:
|
try:
|
||||||
@@ -975,22 +991,31 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def _run_subpolls(self, force: bool = False) -> None:
|
async def _run_subpolls(self, force: bool = False) -> None:
|
||||||
"""Poll hot/warm hrefs in the gaps between summary polls. No-op in
|
"""Poll hot/warm hrefs in the gaps between summary polls.
|
||||||
observe-primary mode (those hrefs are already covered by push)
|
|
||||||
unless `force` is set -- set when this cycle's sweep found the
|
In observe-primary mode this is a no-op for hrefs the device is
|
||||||
cache disagreeing with a still-live observe session (see
|
actually pushing, unless `force` is set (sweep disagreed with the
|
||||||
log_sweep_discrepancies): a bounded fallback for a channel gone
|
cache -- see log_sweep_discrepancies). Hrefs that were subscribed
|
||||||
silent without a reconnect."""
|
but stayed silent through the grace period (issue #92) stay on
|
||||||
|
the hot/warm cadence via `fallback_hrefs`.
|
||||||
|
"""
|
||||||
if self._observe.mode == MODE_OBSERVE and not force:
|
if self._observe.mode == MODE_OBSERVE and not force:
|
||||||
return
|
silent = self._observe.fallback_hrefs
|
||||||
hot = self._hot_hrefs
|
if not silent:
|
||||||
warm = self._warm_hrefs
|
return
|
||||||
|
hot = [h for h in self._hot_hrefs if h in silent]
|
||||||
|
warm = [h for h in self._warm_hrefs if h in silent]
|
||||||
|
else:
|
||||||
|
hot = self._hot_hrefs
|
||||||
|
warm = self._warm_hrefs
|
||||||
if not hot and not warm:
|
if not hot and not warm:
|
||||||
return
|
return
|
||||||
step = self._SUBPOLL_STEP_S
|
step = self._SUBPOLL_STEP_S
|
||||||
for i in range(1, 10): # slots 1..9 (T+3 s … T+27 s)
|
for i in range(1, 10): # slots 1..9 (T+3 s … T+27 s)
|
||||||
await asyncio.sleep(step)
|
await asyncio.sleep(step)
|
||||||
hrefs = list(hot) + (list(warm) if i % 2 == 0 else [])
|
hrefs = list(hot) + (list(warm) if i % 2 == 0 else [])
|
||||||
|
if not hrefs:
|
||||||
|
continue
|
||||||
async with self._session_lock:
|
async with self._session_lock:
|
||||||
try:
|
try:
|
||||||
await self.hass.async_add_executor_job(self._poll_hrefs_blocking, hrefs)
|
await self.hass.async_add_executor_job(self._poll_hrefs_blocking, hrefs)
|
||||||
@@ -1665,6 +1690,42 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
self._reconnect_times.append(now)
|
self._reconnect_times.append(now)
|
||||||
return len(self._reconnect_times) >= self._RECONNECT_WARN_THRESHOLD
|
return len(self._reconnect_times) >= self._RECONNECT_WARN_THRESHOLD
|
||||||
|
|
||||||
|
def _mark_device_answered(self) -> None:
|
||||||
|
"""Clear the bookkeeping a poll getting through invalidates."""
|
||||||
|
self._consecutive_poll_timeouts = 0
|
||||||
|
if self._failed_cycles:
|
||||||
|
self._log.info("device answered again after %d failed cycles", self._failed_cycles)
|
||||||
|
self._failed_cycles = 0
|
||||||
|
|
||||||
|
def _device_unreachable(self, what: str, e: Exception) -> dict[str, Any]:
|
||||||
|
"""End a cycle that got no data, either degraded or as a failure.
|
||||||
|
|
||||||
|
Reported once per outage rather than once per cycle: an appliance
|
||||||
|
that is switched off fails identically every 30s for as long as it
|
||||||
|
stays off (issue #269), and this integration is built to sit through
|
||||||
|
exactly that (issue #295). Home Assistant logs the transition into
|
||||||
|
and out of a failed update on its own.
|
||||||
|
|
||||||
|
Raises `UpdateFailed` unless there are bound entities and cached
|
||||||
|
state to carry the last-known values on -- same precondition as
|
||||||
|
`_defer_reconnect_for` (issue #254).
|
||||||
|
"""
|
||||||
|
self._failed_cycles += 1
|
||||||
|
if self._failed_cycles == 1:
|
||||||
|
self._log.error("%s: %s", what, e)
|
||||||
|
else:
|
||||||
|
self._log.debug("%s (%d cycles): %s", what, self._failed_cycles, e)
|
||||||
|
# Without this, a fully unreachable device left the connection-mode
|
||||||
|
# sensor stuck on "Push" forever -- only a successful poll ever
|
||||||
|
# downgraded it (issue #287). No just_downgraded_from_observe here:
|
||||||
|
# there's no live session this cycle to resubscribe on.
|
||||||
|
if self._observe.mode == MODE_OBSERVE:
|
||||||
|
self._observe.downgrade_to_poll()
|
||||||
|
if self._discovered and self._cache.snapshot():
|
||||||
|
self._log.debug("Full error:", exc_info=e)
|
||||||
|
return flatten(self.bound, self.entity_resources())
|
||||||
|
raise UpdateFailed(f"{what}: {e}") from e
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# DataUpdateCoordinator hook
|
# DataUpdateCoordinator hook
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -1678,7 +1739,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
async with self._session_lock:
|
async with self._session_lock:
|
||||||
try:
|
try:
|
||||||
resources = await self.hass.async_add_executor_job(self._poll_once)
|
resources = await self.hass.async_add_executor_job(self._poll_once)
|
||||||
self._consecutive_poll_timeouts = 0
|
self._mark_device_answered()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if self._defer_reconnect_for(e):
|
if self._defer_reconnect_for(e):
|
||||||
self._log.debug(
|
self._log.debug(
|
||||||
@@ -1689,6 +1750,15 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
)
|
)
|
||||||
return flatten(self.bound, self.entity_resources())
|
return flatten(self.bound, self.entity_resources())
|
||||||
self._consecutive_poll_timeouts = 0
|
self._consecutive_poll_timeouts = 0
|
||||||
|
if self._handshake_failed:
|
||||||
|
# The handshake never completed, so there is no session
|
||||||
|
# to close and no association for the device to clean
|
||||||
|
# up: reconnecting would just repeat the same doomed
|
||||||
|
# handshake five seconds later. That doubled what a
|
||||||
|
# switched-off appliance costs -- two handshake timeouts
|
||||||
|
# per cycle, and the same wait again on every setup
|
||||||
|
# attempt while it stays dark (issue #269).
|
||||||
|
return self._device_unreachable("device unreachable", e)
|
||||||
# A lone reconnect is routine (README's "Known device
|
# A lone reconnect is routine (README's "Known device
|
||||||
# behavior"); only warn once they pile up. Pause first so
|
# behavior"); only warn once they pile up. Pause first so
|
||||||
# the device can clean up its DTLS state before we knock
|
# the device can clean up its DTLS state before we knock
|
||||||
@@ -1702,23 +1772,9 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
try:
|
try:
|
||||||
resources = await self.hass.async_add_executor_job(self._poll_once)
|
resources = await self.hass.async_add_executor_job(self._poll_once)
|
||||||
except Exception as e2:
|
except Exception as e2:
|
||||||
self._log.error("poll failed after reconnect: %s", e2)
|
return self._device_unreachable("poll failed after reconnect", e2)
|
||||||
# Without this, a fully unreachable device left the
|
|
||||||
# connection-mode sensor stuck on "Push" forever -- only
|
|
||||||
# the success branch below ever downgraded it (issue
|
|
||||||
# #287). No just_downgraded_from_observe here: there's no
|
|
||||||
# live session this cycle to resubscribe on.
|
|
||||||
if self._observe.mode == MODE_OBSERVE:
|
|
||||||
self._observe.downgrade_to_poll()
|
|
||||||
snapshot = self._cache.snapshot()
|
|
||||||
# Same precondition as _defer_reconnect_for (issue #254):
|
|
||||||
# degraded-but-successful data only makes sense once
|
|
||||||
# there are bound entities to carry it.
|
|
||||||
if self._discovered and snapshot:
|
|
||||||
self._log.debug("Full error:", exc_info=e2)
|
|
||||||
return flatten(self.bound, self.entity_resources())
|
|
||||||
raise UpdateFailed(f"poll failed after reconnect: {e2}") from e2
|
|
||||||
else:
|
else:
|
||||||
|
self._mark_device_answered()
|
||||||
# A fresh session has zero OBSERVE registrations; if we
|
# A fresh session has zero OBSERVE registrations; if we
|
||||||
# were in observe mode that state is now stale. Tear it
|
# were in observe mode that state is now stale. Tear it
|
||||||
# down and resubscribe immediately below instead of
|
# down and resubscribe immediately below instead of
|
||||||
|
|||||||
@@ -13,5 +13,5 @@
|
|||||||
"pyOpenSSL>=23.0",
|
"pyOpenSSL>=23.0",
|
||||||
"smartthings-local>=0.1.8"
|
"smartthings-local>=0.1.8"
|
||||||
],
|
],
|
||||||
"version": "0.24.0-beta.1"
|
"version": "0.24.0"
|
||||||
}
|
}
|
||||||
@@ -95,8 +95,15 @@ class ObserveManager:
|
|||||||
self._notified: set[str] = set()
|
self._notified: set[str] = set()
|
||||||
self._last_notify_ts: float | None = None
|
self._last_notify_ts: float | None = None
|
||||||
# Wakes try_enter_observe_mode's grace wait early once enough hrefs
|
# Wakes try_enter_observe_mode's grace wait early once enough hrefs
|
||||||
# have notified. Guards only `_notified` mutations + the `wait_for`.
|
# have notified. Guards `_notified` mutations, the `wait_for`, and
|
||||||
|
# fallback_hrefs (enter_observe_mode assignment, on_notification
|
||||||
|
# discard).
|
||||||
self._notify_cond = threading.Condition()
|
self._notify_cond = threading.Condition()
|
||||||
|
# Idle while polling, except after downgrade_to_poll (every href
|
||||||
|
# that was subscribed). While in observe mode this is the set of
|
||||||
|
# subscribed hrefs that have not yet notified (issue #92) -- they
|
||||||
|
# stay on the hot/warm sub-poll cadence. on_notification discards
|
||||||
|
# an href once it pushes, so a late first notify self-corrects.
|
||||||
self.fallback_hrefs: set[str] = set()
|
self.fallback_hrefs: set[str] = set()
|
||||||
self._on_applied: Callable[[str, dict, str], None] | None = None
|
self._on_applied: Callable[[str, dict, str], None] | None = None
|
||||||
self._refresh_task: ObserveRefreshTask | None = None
|
self._refresh_task: ObserveRefreshTask | None = None
|
||||||
@@ -205,6 +212,10 @@ class ObserveManager:
|
|||||||
return
|
return
|
||||||
with self._notify_cond:
|
with self._notify_cond:
|
||||||
self._notified.add(href)
|
self._notified.add(href)
|
||||||
|
# Snapshot in enter_observe_mode is at the 80% quorum, not the
|
||||||
|
# full grace period -- a late first push (blockwise refetch)
|
||||||
|
# must drop the href so we don't keep GET-polling a live one.
|
||||||
|
self.fallback_hrefs.discard(href)
|
||||||
self._last_notify_ts = time.monotonic()
|
self._last_notify_ts = time.monotonic()
|
||||||
self._notify_cond.notify_all()
|
self._notify_cond.notify_all()
|
||||||
self.log.debug("observe notify: %s", href)
|
self.log.debug("observe notify: %s", href)
|
||||||
@@ -270,6 +281,13 @@ class ObserveManager:
|
|||||||
#294) -- committing against a session a reconnect already replaced
|
#294) -- committing against a session a reconnect already replaced
|
||||||
would claim observe mode with nothing left to notice it's dead."""
|
would claim observe mode with nothing left to notice it's dead."""
|
||||||
self.subscribed_hrefs = set(subscribed)
|
self.subscribed_hrefs = set(subscribed)
|
||||||
|
# Issue #92: subscribed-but-silent hrefs are counted as covered by
|
||||||
|
# push if we drop this, but they never emit a notify. Keep them on
|
||||||
|
# the poll cadence via fallback_hrefs (otherwise idle in observe).
|
||||||
|
# Same lock as on_notification's discard so a notify in this window
|
||||||
|
# cannot land on a set object that is about to be replaced.
|
||||||
|
with self._notify_cond:
|
||||||
|
self.fallback_hrefs = set(subscribed) - self._notified
|
||||||
self._set_mode(MODE_OBSERVE)
|
self._set_mode(MODE_OBSERVE)
|
||||||
self.start_refresh_task(session)
|
self.start_refresh_task(session)
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ only); this registry deliberately doesn't include common.POWER.
|
|||||||
air_purifier.AIR_QUALITY and range_hood.AIR_QUALITY already read via
|
air_purifier.AIR_QUALITY and range_hood.AIR_QUALITY already read via
|
||||||
common.sensor_item_value -- reused here rather than re-decoded, including
|
common.sensor_item_value -- reused here rather than re-decoded, including
|
||||||
the same dust/fine_dust/super_fine_dust/odor/clean_level keys so this
|
the same dust/fine_dust/super_fine_dust/odor/clean_level keys so this
|
||||||
device shares those capabilities' catalog entries. This board additionally
|
device shares those capabilities' catalog entries. This board reports a
|
||||||
reports a CO2 reading the other two families don't.
|
CO2 reading; air_purifier.AIR_QUALITY now models the same type when a
|
||||||
|
purifier lists it (issue #387).
|
||||||
|
|
||||||
A second `value` list element on the particulate-matter types (Dust's
|
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
|
`['31', '2']`) is the device's own graded air-quality level for that
|
||||||
|
|||||||
@@ -34,7 +34,13 @@ from ..entities import (
|
|||||||
SwitchDesc,
|
SwitchDesc,
|
||||||
TimeDesc,
|
TimeDesc,
|
||||||
)
|
)
|
||||||
from .common import epoch_to_utc, filter_usage_percent, int_or_none, sensor_item_value
|
from .common import (
|
||||||
|
epoch_to_utc,
|
||||||
|
filter_usage_percent,
|
||||||
|
has_sensor_type,
|
||||||
|
int_or_none,
|
||||||
|
sensor_item_value,
|
||||||
|
)
|
||||||
from .laundry import bool_option_exists, bool_option_value, option_value, option_write
|
from .laundry import bool_option_exists, bool_option_value, option_value, option_write
|
||||||
|
|
||||||
# Newer TP1X_DA-AC-AIR-class boards (issue #130) report fan modes directly
|
# Newer TP1X_DA-AC-AIR-class boards (issue #130) report fan modes directly
|
||||||
@@ -106,17 +112,35 @@ _AIR_QUALITY_SENSORS = (
|
|||||||
AIR_QUALITY = Capability(
|
AIR_QUALITY = Capability(
|
||||||
href="/sensors/vs/0",
|
href="/sensors/vs/0",
|
||||||
poll_tier="warm",
|
poll_tier="warm",
|
||||||
entities=tuple(
|
entities=(
|
||||||
|
*(
|
||||||
|
SensorDesc(
|
||||||
|
key=key,
|
||||||
|
field="x.com.samsung.da.items",
|
||||||
|
icon=icon,
|
||||||
|
state_class=state_class,
|
||||||
|
device_class=device_class,
|
||||||
|
unit=unit,
|
||||||
|
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
|
||||||
|
)
|
||||||
|
for key, icon, sensor_type, state_class, device_class, unit in _AIR_QUALITY_SENSORS
|
||||||
|
),
|
||||||
|
# CO2 (issue #387) -- same field/shape air_monitor.SENSORS already
|
||||||
|
# models with device_class='carbon_dioxide'/unit='ppm'. Gated on the
|
||||||
|
# type being listed so boards that don't report it (every current
|
||||||
|
# fixture) don't grow an empty entity. Disabled by default for the
|
||||||
|
# same reason as airconditioner.AIR_QUALITY (issue #166).
|
||||||
SensorDesc(
|
SensorDesc(
|
||||||
key=key,
|
key="co2",
|
||||||
field="x.com.samsung.da.items",
|
field="x.com.samsung.da.items",
|
||||||
icon=icon,
|
icon="mdi:molecule-co2",
|
||||||
state_class=state_class,
|
device_class="carbon_dioxide",
|
||||||
device_class=device_class,
|
state_class="measurement",
|
||||||
unit=unit,
|
unit="ppm",
|
||||||
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
|
exists_fn=has_sensor_type("CO2"),
|
||||||
)
|
enabled_default=False,
|
||||||
for key, icon, sensor_type, state_class, device_class, unit in _AIR_QUALITY_SENSORS
|
value_fn=lambda items: sensor_item_value(items, "CO2"),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from ..entities import (
|
|||||||
SwitchDesc,
|
SwitchDesc,
|
||||||
)
|
)
|
||||||
from . import common
|
from . import common
|
||||||
from .common import filter_usage_hours, filter_usage_percent, normalize_temp_unit
|
from .common import filter_usage_hours, filter_usage_percent, has_sensor_type, normalize_temp_unit
|
||||||
from .laundry import option_write
|
from .laundry import option_write
|
||||||
|
|
||||||
|
|
||||||
@@ -119,26 +119,6 @@ def _sensor_item_value(items, type_):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _has_sensor_type(type_):
|
|
||||||
"""True when /sensors/vs/0's items[] lists an item of this type.
|
|
||||||
|
|
||||||
This only proves the type is *listed*, not that the reading is real:
|
|
||||||
issue #166 (ARTIK051_PRAC_20K) lists all five types with permanent-zero
|
|
||||||
values on units the reporter confirmed don't have the hardware. So
|
|
||||||
entities gated on this stay disabled by default (see AIR_QUALITY) rather
|
|
||||||
than existence-gated further, to avoid silently dropping real readings
|
|
||||||
on hardware not yet seen.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def fn(rep, resources):
|
|
||||||
return any(
|
|
||||||
isinstance(i, dict) and i.get("x.com.samsung.da.type") == type_
|
|
||||||
for i in (rep.get("x.com.samsung.da.items") or [])
|
|
||||||
)
|
|
||||||
|
|
||||||
return fn
|
|
||||||
|
|
||||||
|
|
||||||
# Canonical AC resource hrefs. climate.py binds HREF_MODE and reads the
|
# Canonical AC resource hrefs. climate.py binds HREF_MODE and reads the
|
||||||
# CLIMATE_CONSUMED_HREFS siblings off the coordinator snapshot; declared once
|
# CLIMATE_CONSUMED_HREFS siblings off the coordinator snapshot; declared once
|
||||||
# here so climate.py and the coverage list below can't drift out of sync.
|
# here so climate.py and the coverage list below can't drift out of sync.
|
||||||
@@ -1570,7 +1550,7 @@ WINDSLEEP = Capability(
|
|||||||
# /sensors/vs/0 items[] carry live air-quality readings. CleanLevel is
|
# /sensors/vs/0 items[] carry live air-quality readings. CleanLevel is
|
||||||
# corroborated as numeric by a top-level x.com.samsung.da.cleanLevel scalar,
|
# corroborated as numeric by a top-level x.com.samsung.da.cleanLevel scalar,
|
||||||
# so it's a measurement; the others stay string diagnostics (see
|
# so it's a measurement; the others stay string diagnostics (see
|
||||||
# _sensor_item_value). All disabled by default: _has_sensor_type only proves
|
# _sensor_item_value). All disabled by default: has_sensor_type only proves
|
||||||
# the item type is listed, not that the sensor is real (see its docstring).
|
# the item type is listed, not that the sensor is real (see its docstring).
|
||||||
AIR_QUALITY = Capability(
|
AIR_QUALITY = Capability(
|
||||||
href="/sensors/vs/0",
|
href="/sensors/vs/0",
|
||||||
@@ -1582,7 +1562,7 @@ AIR_QUALITY = Capability(
|
|||||||
icon="mdi:broom",
|
icon="mdi:broom",
|
||||||
entity_category="diagnostic",
|
entity_category="diagnostic",
|
||||||
state_class="measurement",
|
state_class="measurement",
|
||||||
exists_fn=_has_sensor_type("CleanLevel"),
|
exists_fn=has_sensor_type("CleanLevel"),
|
||||||
enabled_default=False,
|
enabled_default=False,
|
||||||
value_fn=lambda items: _int(_sensor_item_value(items, "CleanLevel")),
|
value_fn=lambda items: _int(_sensor_item_value(items, "CleanLevel")),
|
||||||
),
|
),
|
||||||
@@ -1592,7 +1572,7 @@ AIR_QUALITY = Capability(
|
|||||||
field="x.com.samsung.da.items",
|
field="x.com.samsung.da.items",
|
||||||
icon=icon,
|
icon=icon,
|
||||||
entity_category="diagnostic",
|
entity_category="diagnostic",
|
||||||
exists_fn=_has_sensor_type(type_),
|
exists_fn=has_sensor_type(type_),
|
||||||
enabled_default=False,
|
enabled_default=False,
|
||||||
value_fn=lambda items, t=type_: _sensor_item_value(items, t),
|
value_fn=lambda items, t=type_: _sensor_item_value(items, t),
|
||||||
)
|
)
|
||||||
@@ -1618,7 +1598,7 @@ AIR_QUALITY = Capability(
|
|||||||
device_class="carbon_dioxide",
|
device_class="carbon_dioxide",
|
||||||
state_class="measurement",
|
state_class="measurement",
|
||||||
unit="ppm",
|
unit="ppm",
|
||||||
exists_fn=_has_sensor_type("CO2"),
|
exists_fn=has_sensor_type("CO2"),
|
||||||
enabled_default=False,
|
enabled_default=False,
|
||||||
value_fn=lambda items: _int(_sensor_item_value(items, "CO2")),
|
value_fn=lambda items: _int(_sensor_item_value(items, "CO2")),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -299,6 +299,30 @@ def sensor_item_value(items, sensor_type, index=0):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def has_sensor_type(type_):
|
||||||
|
"""True when /sensors/vs/0's items[] lists an item of this type.
|
||||||
|
|
||||||
|
This only proves the type is *listed*, not that the reading is real:
|
||||||
|
issue #166 (ARTIK051_PRAC_20K) lists all five types with permanent-zero
|
||||||
|
values on units the reporter confirmed don't have the hardware. So
|
||||||
|
entities gated on this stay disabled by default rather than
|
||||||
|
existence-gated further, to avoid silently dropping real readings on
|
||||||
|
hardware not yet seen.
|
||||||
|
|
||||||
|
is_stub_rep(rep) keeps the stub carve-out (see entity._is_included /
|
||||||
|
ENERGY_METER, issue #127): an explicit exists_fn otherwise bypasses it
|
||||||
|
and would drop the entity when /device/0 returns a not-yet-fetched stub.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def fn(rep, resources):
|
||||||
|
return is_stub_rep(rep) or any(
|
||||||
|
isinstance(i, dict) and i.get("x.com.samsung.da.type") == type_
|
||||||
|
for i in (rep.get("x.com.samsung.da.items") or [])
|
||||||
|
)
|
||||||
|
|
||||||
|
return fn
|
||||||
|
|
||||||
|
|
||||||
# OCF-native / vendor '-vs' fallback pairs for power, kids-lock, remote
|
# OCF-native / vendor '-vs' fallback pairs for power, kids-lock, remote
|
||||||
# control: each exists as both a standard OCF resource (/power/0,
|
# control: each exists as both a standard OCF resource (/power/0,
|
||||||
# oic.r.switch.binary, plain boolean 'value') and a Samsung vendor
|
# oic.r.switch.binary, plain boolean 'value') and a Samsung vendor
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from .laundry import (
|
|||||||
bool_option_switch,
|
bool_option_switch,
|
||||||
cycle_select,
|
cycle_select,
|
||||||
drum_clean_cycles_remaining,
|
drum_clean_cycles_remaining,
|
||||||
drum_clean_last_cleaned,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -62,9 +61,14 @@ DISHWASHER_SETTINGS = Capability(
|
|||||||
# #258) DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens riding on this
|
# #258) DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens riding on this
|
||||||
# same options[] array -- a live dump confirmed the dishwasher reports the
|
# same options[] array -- a live dump confirmed the dishwasher reports the
|
||||||
# identical trio (WashingTimes_18/DrumCleanProposal_20, plus a '|'-joined
|
# identical trio (WashingTimes_18/DrumCleanProposal_20, plus a '|'-joined
|
||||||
# DrumCleanLog_ history matching the dryer's multi-entry shape), so the
|
# DrumCleanLog_ history matching the dryer's multi-entry shape), so
|
||||||
# shared laundry.drum_clean_cycles_remaining/drum_clean_last_cleaned readers
|
# laundry.drum_clean_cycles_remaining applies unchanged.
|
||||||
# apply unchanged; see laundry.py for the field semantics.
|
#
|
||||||
|
# laundry.drum_clean_last_cleaned (DrumCleanLog_'s own newest entry) is
|
||||||
|
# deliberately NOT wired up here (issue #398): a live dishwasher dump
|
||||||
|
# showed it moving every 30-90s on its own, including well after a cycle
|
||||||
|
# had already finished -- unlike the washer/dryer reports this reader was
|
||||||
|
# built from (issues #9, #258), it never settles on a value worth showing.
|
||||||
|
|
||||||
CYCLE_OPTIONS = Capability(
|
CYCLE_OPTIONS = Capability(
|
||||||
href="/course/vs/0",
|
href="/course/vs/0",
|
||||||
@@ -82,14 +86,6 @@ CYCLE_OPTIONS = Capability(
|
|||||||
exists_fn=lambda rep, resources: drum_clean_cycles_remaining(rep) is not None,
|
exists_fn=lambda rep, resources: drum_clean_cycles_remaining(rep) is not None,
|
||||||
rep_fn=drum_clean_cycles_remaining,
|
rep_fn=drum_clean_cycles_remaining,
|
||||||
),
|
),
|
||||||
SensorDesc(
|
|
||||||
key="drum_clean_last_cleaned",
|
|
||||||
device_class="timestamp",
|
|
||||||
icon="mdi:calendar-clock",
|
|
||||||
entity_category="diagnostic",
|
|
||||||
exists_fn=lambda rep, resources: drum_clean_last_cleaned(rep) is not None,
|
|
||||||
rep_fn=drum_clean_last_cleaned,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ DRYER_SETTINGS = Capability(
|
|||||||
# code. It shares no codes with Table_03 above -- 'a5' Bedding here and
|
# code. It shares no codes with Table_03 above -- 'a5' Bedding here and
|
||||||
# '01' Normal are both table-scoped, so a Table_03 dryer never picks up a
|
# '01' Normal are both table-scoped, so a Table_03 dryer never picks up a
|
||||||
# Table_00 label or vice versa (see laundry.cycle_select's table_href).
|
# Table_00 label or vice versa (see laundry.cycle_select's table_href).
|
||||||
|
# A DV6800N -- same DA_WM_A51_20_COMMON board, also Table_00 -- confirmed
|
||||||
|
# 14 more courses the same way (issue #394); its /course/vs/0 supportedOptions
|
||||||
|
# only advertises a different subset of this same table (each model exposes
|
||||||
|
# whichever courses its hardware supports), not a conflicting code family --
|
||||||
|
# the one code both reporters confirmed, 'a5', means Bedding on both. Folded
|
||||||
|
# into the same catalog entry below rather than a new one.
|
||||||
#
|
#
|
||||||
# Drum Clean+ maintenance tracking (issue #258) reuses washer.py's
|
# Drum Clean+ maintenance tracking (issue #258) reuses washer.py's
|
||||||
# DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens on this same
|
# DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens on this same
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ array.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from ..capability import Capability
|
from ..capability import Capability
|
||||||
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc
|
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||||
from .laundry import (
|
from .laundry import (
|
||||||
bool_option_exists,
|
bool_option_exists,
|
||||||
bool_option_switch,
|
bool_option_switch,
|
||||||
@@ -264,6 +264,105 @@ def _bool_option_switch(key, icon, prefix, availability_field):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# AddWash -- the little door for adding a forgotten sock mid-cycle -- rides
|
||||||
|
# three independent tokens on the same options[] array:
|
||||||
|
#
|
||||||
|
# AddWashSet_<0-7> the alarm setting, and the only writable one:
|
||||||
|
# a 3-bit mask over the moments it fires, bit 0
|
||||||
|
# rinse, bit 1 final rinse, bit 2 spin.
|
||||||
|
# AddWashAvailable_<0-7> the same three bits, but what the running
|
||||||
|
# course still permits.
|
||||||
|
# AddWashIndicator_On/Off the panel lamp: laundry may go in right now.
|
||||||
|
#
|
||||||
|
# Bit order confirmed by watching a WW6500 run a cycle: AddWashAvailable
|
||||||
|
# shed one bit as each moment passed (7 through Rinse, then 6, 4, and 0 as
|
||||||
|
# Spin began) and reset to 7 at the end, while the lamp tracked the phase
|
||||||
|
# with the alarm switched off throughout.
|
||||||
|
|
||||||
|
|
||||||
|
def _add_wash_mask(rep, prefix):
|
||||||
|
"""One of the 3-bit AddWash masks, or None when its token is absent,
|
||||||
|
malformed, or outside 0-7. Never 0 for a missing token: 0 is a real
|
||||||
|
value, and a mask this model can't represent is a wrong model rather
|
||||||
|
than something to write back."""
|
||||||
|
raw = option_value(rep.get("x.com.samsung.da.options"), prefix)
|
||||||
|
try:
|
||||||
|
mask = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return mask if 0 <= mask <= 0b111 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _add_wash_any(prefix):
|
||||||
|
"""Whether any of the three moments is set in `prefix`'s mask."""
|
||||||
|
|
||||||
|
def read(rep):
|
||||||
|
mask = _add_wash_mask(rep, prefix)
|
||||||
|
return None if mask is None else mask != 0
|
||||||
|
|
||||||
|
return read
|
||||||
|
|
||||||
|
|
||||||
|
def _add_wash_set_write(mask):
|
||||||
|
return ["course", "vs", "0"], {
|
||||||
|
"x.com.samsung.da.options": option_write("AddWashSet", str(mask)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _add_wash_alarm_write(p, rep, href=None):
|
||||||
|
# Gated on the mask being readable, like the per-moment writes: a device
|
||||||
|
# reporting a wider mask than these three bits would otherwise have it
|
||||||
|
# truncated to 7 here, silently dropping a moment it supports.
|
||||||
|
mask = _add_wash_mask(rep, "AddWashSet")
|
||||||
|
if p not in ("On", "Off") or mask is None:
|
||||||
|
return None
|
||||||
|
if p == "On" and mask:
|
||||||
|
# Already on, so "on" is a no-op rather than a rewrite to 7. Home
|
||||||
|
# Assistant calls turn_on regardless of current state, so an
|
||||||
|
# automation asserting the alarm on over a rinse-only mask would
|
||||||
|
# otherwise widen it to all three moments with no state change on
|
||||||
|
# this switch to point at. Distinct from the off-then-on case in
|
||||||
|
# _add_wash_bit_switch, where there is no subset left to keep.
|
||||||
|
return None
|
||||||
|
return _add_wash_set_write(0b111 if p == "On" else 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_wash_bit_switch(key, icon, bit):
|
||||||
|
"""One moment the alarm fires at, as its own bit of the mask.
|
||||||
|
|
||||||
|
The mask is the only state, so switching the last moment off lands on 0
|
||||||
|
and takes the alarm with it, and switching one on from 0 turns the alarm
|
||||||
|
back on. The corollary is that switching the master off and on again
|
||||||
|
writes 7, resetting a rinse-only selection to all three moments -- the
|
||||||
|
appliance remembers no previous subset either, so there is nothing to
|
||||||
|
restore.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def read(rep):
|
||||||
|
mask = _add_wash_mask(rep, "AddWashSet")
|
||||||
|
return None if mask is None else bool(mask >> bit & 1)
|
||||||
|
|
||||||
|
def write(p, rep, href=None):
|
||||||
|
mask = _add_wash_mask(rep, "AddWashSet")
|
||||||
|
if p not in ("On", "Off") or mask is None:
|
||||||
|
return None
|
||||||
|
return _add_wash_set_write(mask | 1 << bit if p == "On" else mask & ~(1 << bit))
|
||||||
|
|
||||||
|
return SwitchDesc(
|
||||||
|
key=key,
|
||||||
|
icon=icon,
|
||||||
|
entity_category="config",
|
||||||
|
exists_fn=bool_option_exists("AddWashSet"),
|
||||||
|
rep_fn=read,
|
||||||
|
write_fn=write,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_wash_indicator(rep):
|
||||||
|
raw = option_value(rep.get("x.com.samsung.da.options"), "AddWashIndicator")
|
||||||
|
return raw.lower() == "on" if isinstance(raw, str) else None
|
||||||
|
|
||||||
|
|
||||||
WASHER_COURSE = Capability(
|
WASHER_COURSE = Capability(
|
||||||
href="/course/vs/0",
|
href="/course/vs/0",
|
||||||
entities=(
|
entities=(
|
||||||
@@ -350,5 +449,33 @@ WASHER_COURSE = Capability(
|
|||||||
_bool_option_switch(
|
_bool_option_switch(
|
||||||
"intensive", "mdi:washing-machine", "IntensiveSetting", "IntensiveAvailableSet"
|
"intensive", "mdi:washing-machine", "IntensiveSetting", "IntensiveAvailableSet"
|
||||||
),
|
),
|
||||||
|
SwitchDesc(
|
||||||
|
key="add_wash_alarm",
|
||||||
|
icon="mdi:bell-ring",
|
||||||
|
entity_category="config",
|
||||||
|
exists_fn=bool_option_exists("AddWashSet"),
|
||||||
|
rep_fn=_add_wash_any("AddWashSet"),
|
||||||
|
write_fn=_add_wash_alarm_write,
|
||||||
|
),
|
||||||
|
_add_wash_bit_switch("add_wash_alarm_rinse", "mdi:water", 0),
|
||||||
|
_add_wash_bit_switch("add_wash_alarm_final_rinse", "mdi:water-check", 1),
|
||||||
|
_add_wash_bit_switch("add_wash_alarm_spin", "mdi:sync", 2),
|
||||||
|
# On at rest: an idle washer reports AddWashAvailable_7 and the mask
|
||||||
|
# only empties as the cycle consumes each moment. This says the cycle
|
||||||
|
# permits AddWash, not that laundry can go in now -- that is
|
||||||
|
# add_wash_indicator.
|
||||||
|
BinarySensorDesc(
|
||||||
|
key="add_wash_available",
|
||||||
|
icon="mdi:tshirt-crew-outline",
|
||||||
|
entity_category="diagnostic",
|
||||||
|
exists_fn=bool_option_exists("AddWashAvailable"),
|
||||||
|
rep_fn=_add_wash_any("AddWashAvailable"),
|
||||||
|
),
|
||||||
|
BinarySensorDesc(
|
||||||
|
key="add_wash_indicator",
|
||||||
|
icon="mdi:door-open",
|
||||||
|
exists_fn=bool_option_exists("AddWashIndicator"),
|
||||||
|
rep_fn=_add_wash_indicator,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -109,6 +109,12 @@
|
|||||||
"softener_low": {
|
"softener_low": {
|
||||||
"name": "Málo aviváže"
|
"name": "Málo aviváže"
|
||||||
},
|
},
|
||||||
|
"add_wash_available": {
|
||||||
|
"name": "AddWash povoleno"
|
||||||
|
},
|
||||||
|
"add_wash_indicator": {
|
||||||
|
"name": "AddWash připraveno"
|
||||||
|
},
|
||||||
"stick_ble_connected": {
|
"stick_ble_connected": {
|
||||||
"name": "Tyč připojena přes BLE"
|
"name": "Tyč připojena přes BLE"
|
||||||
}
|
}
|
||||||
@@ -344,7 +350,20 @@
|
|||||||
"a4": "Časové sušení",
|
"a4": "Časové sušení",
|
||||||
"a6": "Rychlé sušení",
|
"a6": "Rychlé sušení",
|
||||||
"a3": "Sportovní oblečení",
|
"a3": "Sportovní oblečení",
|
||||||
"a2": "Jemné prádlo"
|
"a2": "Jemné prádlo",
|
||||||
|
"9a": "Bavlna",
|
||||||
|
"ca": "Provětrání",
|
||||||
|
"db": "Super Speed",
|
||||||
|
"99": "Smíšená náplň",
|
||||||
|
"93": "Žehlení",
|
||||||
|
"b5": "Vlna",
|
||||||
|
"d7": "Outdoor péče",
|
||||||
|
"96": "Studený vzduch",
|
||||||
|
"97": "Teplý vzduch",
|
||||||
|
"7f": "Časové sušení",
|
||||||
|
"98": "Rychlé sušení 35",
|
||||||
|
"eb": "Jemné prádlo",
|
||||||
|
"b6": "Syntetika"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dryer_cycle_table_03": {
|
"dryer_cycle_table_03": {
|
||||||
@@ -1142,7 +1161,8 @@
|
|||||||
"dryingwithdooropen": "Větrání",
|
"dryingwithdooropen": "Větrání",
|
||||||
"cooling": "Chlazení",
|
"cooling": "Chlazení",
|
||||||
"predrain": "Vypouštění",
|
"predrain": "Vypouštění",
|
||||||
"prewash": "Předpírka"
|
"prewash": "Předpírka",
|
||||||
|
"sanitizing": "Dezinfekce"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"progress_percentage": {
|
"progress_percentage": {
|
||||||
@@ -1350,6 +1370,18 @@
|
|||||||
"intensive": {
|
"intensive": {
|
||||||
"name": "Intenzivní"
|
"name": "Intenzivní"
|
||||||
},
|
},
|
||||||
|
"add_wash_alarm": {
|
||||||
|
"name": "Alarm AddWash"
|
||||||
|
},
|
||||||
|
"add_wash_alarm_rinse": {
|
||||||
|
"name": "AddWash při máchání"
|
||||||
|
},
|
||||||
|
"add_wash_alarm_final_rinse": {
|
||||||
|
"name": "AddWash při posledním máchání"
|
||||||
|
},
|
||||||
|
"add_wash_alarm_spin": {
|
||||||
|
"name": "AddWash při odstřeďování"
|
||||||
|
},
|
||||||
"lamp": {
|
"lamp": {
|
||||||
"name": "Lampa"
|
"name": "Lampa"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -103,6 +103,12 @@
|
|||||||
"softener_low": {
|
"softener_low": {
|
||||||
"name": "Weichspüler niedrig"
|
"name": "Weichspüler niedrig"
|
||||||
},
|
},
|
||||||
|
"add_wash_available": {
|
||||||
|
"name": "AddWash erlaubt"
|
||||||
|
},
|
||||||
|
"add_wash_indicator": {
|
||||||
|
"name": "AddWash bereit"
|
||||||
|
},
|
||||||
"pouring": {
|
"pouring": {
|
||||||
"name": "Ausgabe läuft"
|
"name": "Ausgabe läuft"
|
||||||
},
|
},
|
||||||
@@ -344,7 +350,20 @@
|
|||||||
"a4": "Zeittrocknen",
|
"a4": "Zeittrocknen",
|
||||||
"a6": "Schnelltrocknen",
|
"a6": "Schnelltrocknen",
|
||||||
"a3": "Sportkleidung",
|
"a3": "Sportkleidung",
|
||||||
"a2": "Feinwäsche"
|
"a2": "Feinwäsche",
|
||||||
|
"9a": "Baumwolle",
|
||||||
|
"ca": "Frischluft",
|
||||||
|
"db": "Super Speed",
|
||||||
|
"99": "Mischwäsche",
|
||||||
|
"93": "Bügeltrocken",
|
||||||
|
"b5": "Wolle",
|
||||||
|
"d7": "Outdoor-Pflege",
|
||||||
|
"96": "Kaltluft",
|
||||||
|
"97": "Warmluft",
|
||||||
|
"7f": "Zeittrocknen",
|
||||||
|
"98": "Schnelltrocknen 35",
|
||||||
|
"eb": "Feinwäsche",
|
||||||
|
"b6": "Synthetik"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dryer_cycle_table_03": {
|
"dryer_cycle_table_03": {
|
||||||
@@ -1136,7 +1155,8 @@
|
|||||||
"dryingwithdooropen": "Lüften",
|
"dryingwithdooropen": "Lüften",
|
||||||
"cooling": "Abkühlen",
|
"cooling": "Abkühlen",
|
||||||
"predrain": "Abpumpen",
|
"predrain": "Abpumpen",
|
||||||
"prewash": "Vorwäsche"
|
"prewash": "Vorwäsche",
|
||||||
|
"sanitizing": "Hygienespülung"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"progress_percentage": {
|
"progress_percentage": {
|
||||||
@@ -1344,6 +1364,18 @@
|
|||||||
"intensive": {
|
"intensive": {
|
||||||
"name": "Intensiv"
|
"name": "Intensiv"
|
||||||
},
|
},
|
||||||
|
"add_wash_alarm": {
|
||||||
|
"name": "AddWash-Hinweis"
|
||||||
|
},
|
||||||
|
"add_wash_alarm_rinse": {
|
||||||
|
"name": "AddWash Spülen"
|
||||||
|
},
|
||||||
|
"add_wash_alarm_final_rinse": {
|
||||||
|
"name": "AddWash letztes Spülen"
|
||||||
|
},
|
||||||
|
"add_wash_alarm_spin": {
|
||||||
|
"name": "AddWash Schleudern"
|
||||||
|
},
|
||||||
"lamp": {
|
"lamp": {
|
||||||
"name": "Lampe"
|
"name": "Lampe"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -109,6 +109,12 @@
|
|||||||
"softener_low": {
|
"softener_low": {
|
||||||
"name": "Softener low"
|
"name": "Softener low"
|
||||||
},
|
},
|
||||||
|
"add_wash_available": {
|
||||||
|
"name": "AddWash allowed"
|
||||||
|
},
|
||||||
|
"add_wash_indicator": {
|
||||||
|
"name": "AddWash ready"
|
||||||
|
},
|
||||||
"stick_ble_connected": {
|
"stick_ble_connected": {
|
||||||
"name": "Stick BLE connected"
|
"name": "Stick BLE connected"
|
||||||
}
|
}
|
||||||
@@ -344,7 +350,20 @@
|
|||||||
"a4": "Time Dry",
|
"a4": "Time Dry",
|
||||||
"a6": "Quick Dry",
|
"a6": "Quick Dry",
|
||||||
"a3": "Active Wear",
|
"a3": "Active Wear",
|
||||||
"a2": "Delicates"
|
"a2": "Delicates",
|
||||||
|
"9a": "Cotton",
|
||||||
|
"ca": "Air Wash",
|
||||||
|
"db": "Super Speed",
|
||||||
|
"99": "Mixed Load",
|
||||||
|
"93": "Iron Dry",
|
||||||
|
"b5": "Wool",
|
||||||
|
"d7": "Outdoor Care",
|
||||||
|
"96": "Cool Air",
|
||||||
|
"97": "Warm Air",
|
||||||
|
"7f": "Time Dry",
|
||||||
|
"98": "Quick Dry 35",
|
||||||
|
"eb": "Delicates",
|
||||||
|
"b6": "Synthetics"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dryer_cycle_table_03": {
|
"dryer_cycle_table_03": {
|
||||||
@@ -1142,7 +1161,8 @@
|
|||||||
"dryingwithdooropen": "Venting",
|
"dryingwithdooropen": "Venting",
|
||||||
"cooling": "Cooling",
|
"cooling": "Cooling",
|
||||||
"predrain": "Pre-drain",
|
"predrain": "Pre-drain",
|
||||||
"prewash": "Pre-wash"
|
"prewash": "Pre-wash",
|
||||||
|
"sanitizing": "Sanitizing"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"progress_percentage": {
|
"progress_percentage": {
|
||||||
@@ -1350,6 +1370,18 @@
|
|||||||
"intensive": {
|
"intensive": {
|
||||||
"name": "Intensive"
|
"name": "Intensive"
|
||||||
},
|
},
|
||||||
|
"add_wash_alarm": {
|
||||||
|
"name": "AddWash alarm"
|
||||||
|
},
|
||||||
|
"add_wash_alarm_rinse": {
|
||||||
|
"name": "AddWash rinse"
|
||||||
|
},
|
||||||
|
"add_wash_alarm_final_rinse": {
|
||||||
|
"name": "AddWash final rinse"
|
||||||
|
},
|
||||||
|
"add_wash_alarm_spin": {
|
||||||
|
"name": "AddWash spin"
|
||||||
|
},
|
||||||
"lamp": {
|
"lamp": {
|
||||||
"name": "Lamp"
|
"name": "Lamp"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -303,6 +303,12 @@
|
|||||||
"softener_low": {
|
"softener_low": {
|
||||||
"name": "Poco suavizante"
|
"name": "Poco suavizante"
|
||||||
},
|
},
|
||||||
|
"add_wash_available": {
|
||||||
|
"name": "AddWash permitido"
|
||||||
|
},
|
||||||
|
"add_wash_indicator": {
|
||||||
|
"name": "AddWash disponible"
|
||||||
|
},
|
||||||
"auto_clean_running": {
|
"auto_clean_running": {
|
||||||
"name": "Limpieza automática en curso"
|
"name": "Limpieza automática en curso"
|
||||||
},
|
},
|
||||||
@@ -541,7 +547,20 @@
|
|||||||
"a4": "Secado por tiempo",
|
"a4": "Secado por tiempo",
|
||||||
"a6": "Secado rápido",
|
"a6": "Secado rápido",
|
||||||
"a3": "Ropa deportiva",
|
"a3": "Ropa deportiva",
|
||||||
"a2": "Delicados"
|
"a2": "Delicados",
|
||||||
|
"9a": "Algodón",
|
||||||
|
"ca": "Aireación",
|
||||||
|
"db": "Súper velocidad",
|
||||||
|
"99": "Carga mixta",
|
||||||
|
"93": "Planchado fácil",
|
||||||
|
"b5": "Lana",
|
||||||
|
"d7": "Cuidado para exterior",
|
||||||
|
"96": "Aire frío",
|
||||||
|
"97": "Aire caliente",
|
||||||
|
"7f": "Secado por tiempo",
|
||||||
|
"98": "Secado rápido 35",
|
||||||
|
"eb": "Delicados",
|
||||||
|
"b6": "Sintéticos"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dryer_cycle_table_03": {
|
"dryer_cycle_table_03": {
|
||||||
@@ -1336,7 +1355,8 @@
|
|||||||
"dryingwithdooropen": "Ventilación",
|
"dryingwithdooropen": "Ventilación",
|
||||||
"cooling": "Enfriamiento",
|
"cooling": "Enfriamiento",
|
||||||
"predrain": "Drenaje previo",
|
"predrain": "Drenaje previo",
|
||||||
"prewash": "Prelavado"
|
"prewash": "Prelavado",
|
||||||
|
"sanitizing": "Desinfección"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"progress_percentage": {
|
"progress_percentage": {
|
||||||
@@ -1547,6 +1567,18 @@
|
|||||||
"intensive": {
|
"intensive": {
|
||||||
"name": "Intensivo"
|
"name": "Intensivo"
|
||||||
},
|
},
|
||||||
|
"add_wash_alarm": {
|
||||||
|
"name": "Aviso AddWash"
|
||||||
|
},
|
||||||
|
"add_wash_alarm_rinse": {
|
||||||
|
"name": "AddWash en aclarado"
|
||||||
|
},
|
||||||
|
"add_wash_alarm_final_rinse": {
|
||||||
|
"name": "AddWash en último aclarado"
|
||||||
|
},
|
||||||
|
"add_wash_alarm_spin": {
|
||||||
|
"name": "AddWash en centrifugado"
|
||||||
|
},
|
||||||
"lamp": {
|
"lamp": {
|
||||||
"name": "Lámpara"
|
"name": "Lámpara"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -109,6 +109,12 @@
|
|||||||
"softener_low": {
|
"softener_low": {
|
||||||
"name": "Aggiungi ammorbidente"
|
"name": "Aggiungi ammorbidente"
|
||||||
},
|
},
|
||||||
|
"add_wash_available": {
|
||||||
|
"name": "AddWash consentito"
|
||||||
|
},
|
||||||
|
"add_wash_indicator": {
|
||||||
|
"name": "AddWash disponibile"
|
||||||
|
},
|
||||||
"stick_ble_connected": {
|
"stick_ble_connected": {
|
||||||
"name": "Scopa elettrica connessa via BLE"
|
"name": "Scopa elettrica connessa via BLE"
|
||||||
}
|
}
|
||||||
@@ -344,7 +350,20 @@
|
|||||||
"a4": "Asciugatura a tempo",
|
"a4": "Asciugatura a tempo",
|
||||||
"a6": "Asciugatura rapida",
|
"a6": "Asciugatura rapida",
|
||||||
"a3": "Abbigliamento sportivo",
|
"a3": "Abbigliamento sportivo",
|
||||||
"a2": "Delicati"
|
"a2": "Delicati",
|
||||||
|
"9a": "Cotone",
|
||||||
|
"ca": "Arieggiatura",
|
||||||
|
"db": "Super Speed",
|
||||||
|
"99": "Carico misto",
|
||||||
|
"93": "Pronto da stirare",
|
||||||
|
"b5": "Lana",
|
||||||
|
"d7": "Cura outdoor",
|
||||||
|
"96": "Aria fredda",
|
||||||
|
"97": "Aria calda",
|
||||||
|
"7f": "Asciugatura a tempo",
|
||||||
|
"98": "Asciugatura rapida 35",
|
||||||
|
"eb": "Delicati",
|
||||||
|
"b6": "Sintetici"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dryer_cycle_table_03": {
|
"dryer_cycle_table_03": {
|
||||||
@@ -1142,7 +1161,8 @@
|
|||||||
"dryingwithdooropen": "Ventilazione",
|
"dryingwithdooropen": "Ventilazione",
|
||||||
"cooling": "Raffreddamento",
|
"cooling": "Raffreddamento",
|
||||||
"predrain": "Scarico preliminare",
|
"predrain": "Scarico preliminare",
|
||||||
"prewash": "Prelavaggio"
|
"prewash": "Prelavaggio",
|
||||||
|
"sanitizing": "Igienizzazione"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"progress_percentage": {
|
"progress_percentage": {
|
||||||
@@ -1350,6 +1370,18 @@
|
|||||||
"intensive": {
|
"intensive": {
|
||||||
"name": "Intensivo"
|
"name": "Intensivo"
|
||||||
},
|
},
|
||||||
|
"add_wash_alarm": {
|
||||||
|
"name": "Avviso AddWash"
|
||||||
|
},
|
||||||
|
"add_wash_alarm_rinse": {
|
||||||
|
"name": "AddWash al risciacquo"
|
||||||
|
},
|
||||||
|
"add_wash_alarm_final_rinse": {
|
||||||
|
"name": "AddWash all'ultimo risciacquo"
|
||||||
|
},
|
||||||
|
"add_wash_alarm_spin": {
|
||||||
|
"name": "AddWash alla centrifuga"
|
||||||
|
},
|
||||||
"lamp": {
|
"lamp": {
|
||||||
"name": "Lampada"
|
"name": "Lampada"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -109,6 +109,12 @@
|
|||||||
"softener_low": {
|
"softener_low": {
|
||||||
"name": "섬유유연제 부족"
|
"name": "섬유유연제 부족"
|
||||||
},
|
},
|
||||||
|
"add_wash_available": {
|
||||||
|
"name": "애드워시 허용"
|
||||||
|
},
|
||||||
|
"add_wash_indicator": {
|
||||||
|
"name": "애드워시 가능"
|
||||||
|
},
|
||||||
"stick_ble_connected": {
|
"stick_ble_connected": {
|
||||||
"name": "스틱 BLE 연결됨"
|
"name": "스틱 BLE 연결됨"
|
||||||
}
|
}
|
||||||
@@ -344,7 +350,20 @@
|
|||||||
"a4": "시간건조",
|
"a4": "시간건조",
|
||||||
"a6": "쾌속건조",
|
"a6": "쾌속건조",
|
||||||
"a3": "운동복",
|
"a3": "운동복",
|
||||||
"a2": "섬세의류"
|
"a2": "섬세의류",
|
||||||
|
"9a": "면의류",
|
||||||
|
"ca": "송풍",
|
||||||
|
"db": "쾌속건조",
|
||||||
|
"99": "혼합",
|
||||||
|
"93": "다림질건조",
|
||||||
|
"b5": "울",
|
||||||
|
"d7": "아웃도어케어",
|
||||||
|
"96": "송풍건조",
|
||||||
|
"97": "온풍건조",
|
||||||
|
"7f": "시간건조",
|
||||||
|
"98": "쾌속건조 35분",
|
||||||
|
"eb": "섬세의류",
|
||||||
|
"b6": "합성섬유"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dryer_cycle_table_03": {
|
"dryer_cycle_table_03": {
|
||||||
@@ -1142,7 +1161,8 @@
|
|||||||
"dryingwithdooropen": "환기",
|
"dryingwithdooropen": "환기",
|
||||||
"cooling": "냉각",
|
"cooling": "냉각",
|
||||||
"predrain": "사전 배수",
|
"predrain": "사전 배수",
|
||||||
"prewash": "애벌빨래"
|
"prewash": "애벌빨래",
|
||||||
|
"sanitizing": "살균"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"progress_percentage": {
|
"progress_percentage": {
|
||||||
@@ -1350,6 +1370,18 @@
|
|||||||
"intensive": {
|
"intensive": {
|
||||||
"name": "강력"
|
"name": "강력"
|
||||||
},
|
},
|
||||||
|
"add_wash_alarm": {
|
||||||
|
"name": "애드워시 알림"
|
||||||
|
},
|
||||||
|
"add_wash_alarm_rinse": {
|
||||||
|
"name": "애드워시 헹굼"
|
||||||
|
},
|
||||||
|
"add_wash_alarm_final_rinse": {
|
||||||
|
"name": "애드워시 마지막 헹굼"
|
||||||
|
},
|
||||||
|
"add_wash_alarm_spin": {
|
||||||
|
"name": "애드워시 탈수"
|
||||||
|
},
|
||||||
"lamp": {
|
"lamp": {
|
||||||
"name": "램프"
|
"name": "램프"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -109,6 +109,12 @@
|
|||||||
"softener_low": {
|
"softener_low": {
|
||||||
"name": "Wasverzachter bijna op"
|
"name": "Wasverzachter bijna op"
|
||||||
},
|
},
|
||||||
|
"add_wash_available": {
|
||||||
|
"name": "AddWash toegestaan"
|
||||||
|
},
|
||||||
|
"add_wash_indicator": {
|
||||||
|
"name": "AddWash beschikbaar"
|
||||||
|
},
|
||||||
"stick_ble_connected": {
|
"stick_ble_connected": {
|
||||||
"name": "Steel via BLE verbonden"
|
"name": "Steel via BLE verbonden"
|
||||||
}
|
}
|
||||||
@@ -344,7 +350,20 @@
|
|||||||
"a4": "Tijdprogramma",
|
"a4": "Tijdprogramma",
|
||||||
"a6": "Snel drogen",
|
"a6": "Snel drogen",
|
||||||
"a3": "Sportkleding",
|
"a3": "Sportkleding",
|
||||||
"a2": "Fijne was"
|
"a2": "Fijne was",
|
||||||
|
"9a": "Katoen",
|
||||||
|
"ca": "Luchtverfrissing",
|
||||||
|
"db": "Super Speed",
|
||||||
|
"99": "Gemengde was",
|
||||||
|
"93": "Strijkdroog",
|
||||||
|
"b5": "Wol",
|
||||||
|
"d7": "Outdoorverzorging",
|
||||||
|
"96": "Koude lucht",
|
||||||
|
"97": "Warme lucht",
|
||||||
|
"7f": "Tijdprogramma",
|
||||||
|
"98": "Snel drogen 35",
|
||||||
|
"eb": "Fijne was",
|
||||||
|
"b6": "Synthetisch"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dryer_cycle_table_03": {
|
"dryer_cycle_table_03": {
|
||||||
@@ -1142,7 +1161,8 @@
|
|||||||
"dryingwithdooropen": "Ventileren",
|
"dryingwithdooropen": "Ventileren",
|
||||||
"cooling": "Koelen",
|
"cooling": "Koelen",
|
||||||
"predrain": "Vooraf afpompen",
|
"predrain": "Vooraf afpompen",
|
||||||
"prewash": "Voorwas"
|
"prewash": "Voorwas",
|
||||||
|
"sanitizing": "Ontsmetten"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"progress_percentage": {
|
"progress_percentage": {
|
||||||
@@ -1350,6 +1370,18 @@
|
|||||||
"intensive": {
|
"intensive": {
|
||||||
"name": "Intensief"
|
"name": "Intensief"
|
||||||
},
|
},
|
||||||
|
"add_wash_alarm": {
|
||||||
|
"name": "AddWash-melding"
|
||||||
|
},
|
||||||
|
"add_wash_alarm_rinse": {
|
||||||
|
"name": "AddWash bij spoelen"
|
||||||
|
},
|
||||||
|
"add_wash_alarm_final_rinse": {
|
||||||
|
"name": "AddWash laatste spoeling"
|
||||||
|
},
|
||||||
|
"add_wash_alarm_spin": {
|
||||||
|
"name": "AddWash centrifugeren"
|
||||||
|
},
|
||||||
"lamp": {
|
"lamp": {
|
||||||
"name": "Lamp"
|
"name": "Lamp"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -152,6 +152,32 @@ is keyed on `(domain, issue_id)`, `dataclasses.replace` in
|
|||||||
reloads non-persistent issues with their dismissal intact, so one row per
|
reloads non-persistent issues with their dismissal intact, so one row per
|
||||||
entry survives restarts and an "Ignore" sticks.
|
entry survives restarts and an "Ignore" sticks.
|
||||||
|
|
||||||
|
### What a cycle costs while the appliance stays dark (issue #269)
|
||||||
|
|
||||||
|
An appliance switched off at the wall isn't a one-cycle blip: it fails the
|
||||||
|
same way every 30s for hours, and both halves of that failure were being paid
|
||||||
|
twice.
|
||||||
|
|
||||||
|
`_poll_once` opens the session itself when there isn't one, so a switched-off
|
||||||
|
appliance fails *in the handshake* — 12s (`DtlsCoapSession.HANDSHAKE_TIMEOUT_S`)
|
||||||
|
with nothing to show for it. The poll path then treated that like any other
|
||||||
|
poll failure and ran its reconnect: close the session, pause
|
||||||
|
`_RECONNECT_PAUSE_S`, poll again. There is no session to close and no
|
||||||
|
association for the device to clean up, so the "reconnect" was the identical
|
||||||
|
handshake five seconds later — 29s of the 30s interval spent proving the
|
||||||
|
appliance is off, twice over, and the same again on every `SETUP_RETRY`
|
||||||
|
attempt for an entry with no snapshot to load from. `_handshake_failed` marks
|
||||||
|
that case in `_poll_once` so the poll path can skip the retry; a session that
|
||||||
|
opened and *then* broke still reconnects within the cycle.
|
||||||
|
|
||||||
|
The log was the half the reporters actually saw: `poll failed after
|
||||||
|
reconnect` at ERROR every cycle, plus a `reconnect_is_frequent` WARNING once
|
||||||
|
three piled up, for a state this integration is specifically built to sit
|
||||||
|
through. Issue #269's reporter read that repetition as the integration having
|
||||||
|
failed. It's one ERROR per outage now, DEBUG for the cycles after it, and one
|
||||||
|
INFO when the device answers again — HA's own coordinator already logs the
|
||||||
|
transition into and out of a failed update.
|
||||||
|
|
||||||
## What this still won't do
|
## What this still won't do
|
||||||
|
|
||||||
Entities will be present and `unavailable` — not showing their last values.
|
Entities will be present and `unavailable` — not showing their last values.
|
||||||
|
|||||||
+244
@@ -0,0 +1,244 @@
|
|||||||
|
{
|
||||||
|
"device0": [
|
||||||
|
{
|
||||||
|
"rt": [
|
||||||
|
"x.com.samsung.devcol",
|
||||||
|
"oic.wk.col"
|
||||||
|
],
|
||||||
|
"if": [
|
||||||
|
"oic.if.baseline",
|
||||||
|
"oic.if.ll",
|
||||||
|
"oic.if.b"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/alarms/vs/0",
|
||||||
|
"rep": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/configuration/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.region": "0000000000",
|
||||||
|
"x.com.samsung.da.countryCode": "US\u0001"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/course/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.supportedModes": [
|
||||||
|
"HOMECARE_WIZARD_V2"
|
||||||
|
],
|
||||||
|
"x.com.samsung.da.options": [
|
||||||
|
"DeviceType_0165",
|
||||||
|
"UpdateAllow_NotAllowed",
|
||||||
|
"Course_9A",
|
||||||
|
"MixedLoadBell_Disable",
|
||||||
|
"MixedLoadBellNoti_Nothing",
|
||||||
|
"LaundryOutTime_0",
|
||||||
|
"SeamlessControl_Disable",
|
||||||
|
"KidsLockBypass_On",
|
||||||
|
"DetergentOnce_0",
|
||||||
|
"DetergentLeft_0",
|
||||||
|
"DetergentBase_0",
|
||||||
|
"DetergentAlarm_Off",
|
||||||
|
"DetergentType_0",
|
||||||
|
"DetergentTotal_0",
|
||||||
|
"SpecialFunction_4",
|
||||||
|
"AvailableDelayTime_188",
|
||||||
|
"LaundryPlannerUserSetTime_0",
|
||||||
|
"ProgressTimeSet_B22D00B4003C",
|
||||||
|
"WrinklePreventRunning_Off",
|
||||||
|
"EnergyLevelSet_050502050503020304010204030203",
|
||||||
|
"MostUsed_9AD20EE000",
|
||||||
|
"MixedLoadBellSet_02FFFF02FFFFFFFFFFFFFFFFFF02",
|
||||||
|
"UsagesDB_ok",
|
||||||
|
"EnergyKW_396",
|
||||||
|
"TimeSync_NotSupported"
|
||||||
|
],
|
||||||
|
"x.com.samsung.da.supportedOptions": [
|
||||||
|
"29AD20EE000CAD10EE000DBD204E00099D20EE00093D102E000B5D102E000D7D204E000A5D204E00096D000E10E97D000E10E7FD000E33E98D000E000EBD204E000B6D20EE000"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/cycleinterface/vs/0",
|
||||||
|
"rep": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/diagnosis/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/energy/consumption/0",
|
||||||
|
"rep": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/energy/consumption/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||||
|
"x.com.samsung.da.instantaneousPower": "-500",
|
||||||
|
"x.com.samsung.da.cumulativePower": "692600",
|
||||||
|
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||||
|
"x.com.samsung.da.cumulativeDate": "1786964400",
|
||||||
|
"x.com.samsung.da.cumulativeDateUTC": "1786960800"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/file/information/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.timeoffset": "+01:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/information/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.modelNum": "DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000",
|
||||||
|
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON_DV6800N/DC92-01967B_0404",
|
||||||
|
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||||
|
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||||
|
"x.com.samsung.da.items": [
|
||||||
|
{
|
||||||
|
"x.com.samsung.da.id": "0",
|
||||||
|
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000",
|
||||||
|
"x.com.samsung.da.type": "Software",
|
||||||
|
"x.com.samsung.da.number": "02198A230708(E257)",
|
||||||
|
"x.com.samsung.da.newVersionAvailable": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x.com.samsung.da.id": "1",
|
||||||
|
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON",
|
||||||
|
"x.com.samsung.da.type": "Firmware",
|
||||||
|
"x.com.samsung.da.number": "17111305,17122616",
|
||||||
|
"x.com.samsung.da.newVersionAvailable": "0"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/kidslock/0",
|
||||||
|
"rep": {
|
||||||
|
"value": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/kidslock/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.kidsLock": "Ready"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/operational/state/0",
|
||||||
|
"rep": {
|
||||||
|
"currentMachineState": "**REDACTED**",
|
||||||
|
"machineStates": "**REDACTED**",
|
||||||
|
"jobStates": [
|
||||||
|
"None",
|
||||||
|
"Drying",
|
||||||
|
"Cooling",
|
||||||
|
"Finish"
|
||||||
|
],
|
||||||
|
"currentJobState": "None",
|
||||||
|
"remainingTime": "03:08:00",
|
||||||
|
"progressPercentage": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/operational/state/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.state": "Ready",
|
||||||
|
"x.com.samsung.da.remainingTime": "03:08:00",
|
||||||
|
"x.com.samsung.da.progressPercentage": "1",
|
||||||
|
"x.com.samsung.da.progress": "None",
|
||||||
|
"x.com.samsung.da.delayEndTime": "00:00:00",
|
||||||
|
"x.com.samsung.da.supportedProgress": [
|
||||||
|
"None",
|
||||||
|
"Drying",
|
||||||
|
"Cooling",
|
||||||
|
"Finish"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/power/0",
|
||||||
|
"rep": {
|
||||||
|
"value": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/power/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.power": "Off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/realtimenotiforclient/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.timeforshortnoti": "0",
|
||||||
|
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/remotectrl/0",
|
||||||
|
"rep": {
|
||||||
|
"value": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/remotectrl/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.remoteControlEnabled": "false"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/setting/vs/0",
|
||||||
|
"rep": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/st/dryercourse/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.st.dryerMode": "Table_00_Course_9A",
|
||||||
|
"x.com.samsung.da.st.courseTable": "Table_00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/washer/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.wrinklePrevent": "Off",
|
||||||
|
"x.com.samsung.da.dryLevel": "2",
|
||||||
|
"x.com.samsung.da.supportedDryLevel": [
|
||||||
|
"None",
|
||||||
|
"1",
|
||||||
|
"2",
|
||||||
|
"3"
|
||||||
|
],
|
||||||
|
"x.com.samsung.da.dryTime": "00:00:00",
|
||||||
|
"x.com.samsung.da.supportedDryTime": [
|
||||||
|
"00:00:00",
|
||||||
|
"00:30:00",
|
||||||
|
"01:00:00",
|
||||||
|
"01:30:00",
|
||||||
|
"02:00:00",
|
||||||
|
"02:30:00"
|
||||||
|
],
|
||||||
|
"x.com.samsung.da.dryerType": "Electricity"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/wm/editcourse/vs/0",
|
||||||
|
"rep": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/wm/jobbeginingstatus/vs/0",
|
||||||
|
"rep": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/wm/setinfo/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.isModelSettingWithoutSC": "false",
|
||||||
|
"x.com.samsung.da.isModelSettingPowerOnOff": "false"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
-2
@@ -9,7 +9,6 @@
|
|||||||
"delay_start_hours",
|
"delay_start_hours",
|
||||||
"diagnosis_status",
|
"diagnosis_status",
|
||||||
"drum_clean_cycles_remaining",
|
"drum_clean_cycles_remaining",
|
||||||
"drum_clean_last_cleaned",
|
|
||||||
"energy_kwh",
|
"energy_kwh",
|
||||||
"energy_saved_kwh",
|
"energy_saved_kwh",
|
||||||
"finish_time",
|
"finish_time",
|
||||||
@@ -43,7 +42,6 @@
|
|||||||
"samsung_dishwasher_diagnosis_start",
|
"samsung_dishwasher_diagnosis_start",
|
||||||
"samsung_dishwasher_diagnosis_status",
|
"samsung_dishwasher_diagnosis_status",
|
||||||
"samsung_dishwasher_drum_clean_cycles_remaining",
|
"samsung_dishwasher_drum_clean_cycles_remaining",
|
||||||
"samsung_dishwasher_drum_clean_last_cleaned",
|
|
||||||
"samsung_dishwasher_energy_kwh",
|
"samsung_dishwasher_energy_kwh",
|
||||||
"samsung_dishwasher_energy_saved_kwh",
|
"samsung_dishwasher_energy_saved_kwh",
|
||||||
"samsung_dishwasher_finish_time",
|
"samsung_dishwasher_finish_time",
|
||||||
|
|||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"state_keys": [
|
||||||
|
"alarm_code",
|
||||||
|
"child_lock",
|
||||||
|
"completion_minutes",
|
||||||
|
"cycle",
|
||||||
|
"cycle_active",
|
||||||
|
"delay_start_hours",
|
||||||
|
"diagnosis",
|
||||||
|
"dry_level",
|
||||||
|
"dry_time",
|
||||||
|
"dryer_type",
|
||||||
|
"energy_kwh",
|
||||||
|
"finish_time",
|
||||||
|
"job_beginning_status",
|
||||||
|
"machine_state",
|
||||||
|
"power_switch",
|
||||||
|
"progress",
|
||||||
|
"progress_percentage",
|
||||||
|
"remote_control",
|
||||||
|
"wrinkle_prevent"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
{
|
{
|
||||||
"state_keys": [
|
"state_keys": [
|
||||||
|
"add_wash_alarm",
|
||||||
|
"add_wash_alarm_final_rinse",
|
||||||
|
"add_wash_alarm_rinse",
|
||||||
|
"add_wash_alarm_spin",
|
||||||
|
"add_wash_available",
|
||||||
|
"add_wash_indicator",
|
||||||
"alarm_code",
|
"alarm_code",
|
||||||
"bubble_soak",
|
"bubble_soak",
|
||||||
"child_lock",
|
"child_lock",
|
||||||
|
|||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"state_keys": [
|
||||||
|
"add_wash_alarm",
|
||||||
|
"add_wash_alarm_final_rinse",
|
||||||
|
"add_wash_alarm_rinse",
|
||||||
|
"add_wash_alarm_spin",
|
||||||
|
"add_wash_available",
|
||||||
|
"add_wash_indicator",
|
||||||
|
"alarm_code",
|
||||||
|
"child_lock",
|
||||||
|
"completion_minutes",
|
||||||
|
"cycle",
|
||||||
|
"cycle_active",
|
||||||
|
"delay_start_hours",
|
||||||
|
"diagnosis_status",
|
||||||
|
"energy_kwh",
|
||||||
|
"finish_time",
|
||||||
|
"job_beginning_status",
|
||||||
|
"machine_state",
|
||||||
|
"power_switch",
|
||||||
|
"progress",
|
||||||
|
"progress_percentage",
|
||||||
|
"remote_control",
|
||||||
|
"rinse_cycles",
|
||||||
|
"spin_speed",
|
||||||
|
"wash_temperature"
|
||||||
|
]
|
||||||
|
}
|
||||||
+233
@@ -0,0 +1,233 @@
|
|||||||
|
{
|
||||||
|
"device0": [
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
"href": "/realtimenotiforclient/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.timeforshortnoti": "0",
|
||||||
|
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/alarms/vs/0",
|
||||||
|
"rep": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/diagnosis/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/energy/consumption/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||||
|
"x.com.samsung.da.instantaneousPower": "-500",
|
||||||
|
"x.com.samsung.da.cumulativePower": "2016700",
|
||||||
|
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||||
|
"x.com.samsung.da.cumulativeDate": "1787050800",
|
||||||
|
"x.com.samsung.da.cumulativeDateUTC": "1787050800"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/energy/consumption/0",
|
||||||
|
"rep": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/course/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.options": [
|
||||||
|
"DeviceType_0167",
|
||||||
|
"Course_5C",
|
||||||
|
"LaundryOutTime_0",
|
||||||
|
"AddWashSet_0",
|
||||||
|
"AddWashAvailable_7",
|
||||||
|
"AddWashIndicator_Off",
|
||||||
|
"QuickWash_Not_Used",
|
||||||
|
"QuickWashSet_5B847E933FA53F",
|
||||||
|
"UsagesDB_ok",
|
||||||
|
"EnergyKW_396",
|
||||||
|
"DrumCleanLog_Empty",
|
||||||
|
"TimeSync_NotSupported"
|
||||||
|
],
|
||||||
|
"x.com.samsung.da.supportedOptions": [
|
||||||
|
"35B847E933FA53F5C841E923FA53F5D8102923FA43F66841E930FA30F5E831E920FA2075F867E943FA53F60831E930FA43F61841E943FA43F6385209204A204648000913FA53F6B80009000A53E65841E920FA30F67843E923FA43F688430923FA53F"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/power/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.power": "On"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/power/0",
|
||||||
|
"rep": {
|
||||||
|
"value": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/cycleinterface/vs/0",
|
||||||
|
"rep": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/kidslock/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.kidsLock": "Ready"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/kidslock/0",
|
||||||
|
"rep": {
|
||||||
|
"value": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/operational/state/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.state": "Ready",
|
||||||
|
"x.com.samsung.da.remainingTime": "01:07:00",
|
||||||
|
"x.com.samsung.da.progressPercentage": "1",
|
||||||
|
"x.com.samsung.da.progress": "None",
|
||||||
|
"x.com.samsung.da.supportedProgress": [
|
||||||
|
"None",
|
||||||
|
"Wash",
|
||||||
|
"Rinse",
|
||||||
|
"Spin",
|
||||||
|
"Finish"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/operational/state/0",
|
||||||
|
"rep": {
|
||||||
|
"currentMachineState": "idle",
|
||||||
|
"machineStates": [
|
||||||
|
"pause",
|
||||||
|
"active",
|
||||||
|
"idle"
|
||||||
|
],
|
||||||
|
"jobStates": [
|
||||||
|
"None",
|
||||||
|
"Wash",
|
||||||
|
"Rinse",
|
||||||
|
"Spin",
|
||||||
|
"Finish"
|
||||||
|
],
|
||||||
|
"currentJobState": "None",
|
||||||
|
"remainingTime": "01:07:00",
|
||||||
|
"progressPercentage": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/information/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.modelNum": "DA_WM_A51_20_COMMON|FFFFFFFF|20010102001011070000000000000000",
|
||||||
|
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON_WW6500",
|
||||||
|
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||||
|
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||||
|
"x.com.samsung.da.items": [
|
||||||
|
{
|
||||||
|
"x.com.samsung.da.id": "0",
|
||||||
|
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON|FFFFFFFF|20010102001011070000000000000000",
|
||||||
|
"x.com.samsung.da.type": "Software",
|
||||||
|
"x.com.samsung.da.number": "02198A230708(E257)",
|
||||||
|
"x.com.samsung.da.newVersionAvailable": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x.com.samsung.da.id": "1",
|
||||||
|
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON",
|
||||||
|
"x.com.samsung.da.type": "Firmware",
|
||||||
|
"x.com.samsung.da.number": "Unknown",
|
||||||
|
"x.com.samsung.da.newVersionAvailable": "0"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/file/information/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.timeoffset": "+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/washer/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.waterTemperature": "30",
|
||||||
|
"x.com.samsung.da.supportedWaterTemperature": [
|
||||||
|
"None",
|
||||||
|
"Cold",
|
||||||
|
"20",
|
||||||
|
"30",
|
||||||
|
"40",
|
||||||
|
"60",
|
||||||
|
"95"
|
||||||
|
],
|
||||||
|
"x.com.samsung.da.spinLevel": "1400",
|
||||||
|
"x.com.samsung.da.supportedSpinLevel": [
|
||||||
|
"RinseHold",
|
||||||
|
"NoSpin",
|
||||||
|
"400",
|
||||||
|
"800",
|
||||||
|
"1200",
|
||||||
|
"1400"
|
||||||
|
],
|
||||||
|
"x.com.samsung.da.rinseCycles": "3",
|
||||||
|
"x.com.samsung.da.supportedRinseCycles": [
|
||||||
|
"0",
|
||||||
|
"1",
|
||||||
|
"2",
|
||||||
|
"3",
|
||||||
|
"4",
|
||||||
|
"5"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/st/washercourse/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.st.washerMode": "Table_00_Course_5C",
|
||||||
|
"x.com.samsung.da.st.courseTable": "Table_00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/setting/vs/0",
|
||||||
|
"rep": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/wm/editcourse/vs/0",
|
||||||
|
"rep": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/wm/setinfo/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.isModelSettingWithoutSC": "false",
|
||||||
|
"x.com.samsung.da.isModelSettingPowerOnOff": "false"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/wm/jobbeginingstatus/vs/0",
|
||||||
|
"rep": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/remotectrl/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.remoteControlEnabled": "true"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/remotectrl/0",
|
||||||
|
"rep": {
|
||||||
|
"value": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/configuration/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"x.com.samsung.da.region": "0000000000",
|
||||||
|
"x.com.samsung.da.countryCode": "CZ"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import time
|
import time
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
@@ -1050,6 +1052,110 @@ async def test_sweep_mismatch_forces_subpolls_on_a_live_observe_session(
|
|||||||
mock_subpolls.assert_called_once_with(force=True)
|
mock_subpolls.assert_called_once_with(force=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def _cancel_background_subpolls(coordinator: LocalThingsCoordinator) -> None:
|
||||||
|
"""Setup starts `_run_subpolls` as a background task. Tests that drive
|
||||||
|
it directly have to cancel that one first, or a patched
|
||||||
|
`_poll_hrefs_blocking` also captures its batches."""
|
||||||
|
task = coordinator._subpoll_task
|
||||||
|
if task is None:
|
||||||
|
return
|
||||||
|
task.cancel()
|
||||||
|
coordinator._subpoll_task = None
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
|
||||||
|
|
||||||
|
async def test_observe_mode_subpolls_only_silent_hrefs(
|
||||||
|
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||||
|
) -> None:
|
||||||
|
"""Issue #92: subscribed-but-silent hrefs keep the hot/warm cadence
|
||||||
|
instead of waiting for the 30s sweep."""
|
||||||
|
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||||
|
await _cancel_background_subpolls(coordinator)
|
||||||
|
assert coordinator._hot_hrefs
|
||||||
|
silent = coordinator._hot_hrefs[0]
|
||||||
|
coordinator._observe.mode = MODE_OBSERVE
|
||||||
|
coordinator._observe.fallback_hrefs = {silent}
|
||||||
|
|
||||||
|
polled: list[list[str]] = []
|
||||||
|
|
||||||
|
def _capture(hrefs):
|
||||||
|
polled.append(list(hrefs))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(coordinator, "_poll_hrefs_blocking", side_effect=_capture),
|
||||||
|
patch(
|
||||||
|
"custom_components.localthings.coordinator.asyncio.sleep",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await coordinator._run_subpolls()
|
||||||
|
|
||||||
|
assert polled
|
||||||
|
for batch in polled:
|
||||||
|
assert set(batch) == {silent}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_observe_mode_skips_subpolls_when_nothing_is_silent(
|
||||||
|
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||||
|
) -> None:
|
||||||
|
"""The observe-mode no-op stays in place when every subscribed href
|
||||||
|
actually notified -- issue #92 only keeps the silent ones on poll."""
|
||||||
|
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||||
|
await _cancel_background_subpolls(coordinator)
|
||||||
|
coordinator._observe.mode = MODE_OBSERVE
|
||||||
|
coordinator._observe.fallback_hrefs = set()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(coordinator, "_poll_hrefs_blocking") as mock_poll,
|
||||||
|
patch(
|
||||||
|
"custom_components.localthings.coordinator.asyncio.sleep",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_sleep,
|
||||||
|
):
|
||||||
|
await coordinator._run_subpolls()
|
||||||
|
|
||||||
|
mock_poll.assert_not_called()
|
||||||
|
mock_sleep.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_observe_mode_skips_empty_subpoll_slots(
|
||||||
|
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||||
|
) -> None:
|
||||||
|
"""Once hot is empty, odd slots would otherwise take the session lock
|
||||||
|
and dispatch a no-op executor job. Skip those."""
|
||||||
|
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||||
|
await _cancel_background_subpolls(coordinator)
|
||||||
|
coordinator._observe.mode = MODE_OBSERVE
|
||||||
|
coordinator._hot_hrefs = []
|
||||||
|
coordinator._warm_hrefs = ["/warm/vs/0"]
|
||||||
|
coordinator._observe.fallback_hrefs = {"/warm/vs/0"}
|
||||||
|
|
||||||
|
polled: list[list[str]] = []
|
||||||
|
|
||||||
|
def _capture(hrefs):
|
||||||
|
polled.append(list(hrefs))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(coordinator, "_poll_hrefs_blocking", side_effect=_capture),
|
||||||
|
patch(
|
||||||
|
"custom_components.localthings.coordinator.asyncio.sleep",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await coordinator._run_subpolls()
|
||||||
|
|
||||||
|
assert polled
|
||||||
|
for batch in polled:
|
||||||
|
assert batch == ["/warm/vs/0"]
|
||||||
|
|
||||||
|
|
||||||
async def test_write_marks_href_pending_before_post(
|
async def test_write_marks_href_pending_before_post(
|
||||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ def test_try_enter_observe_mode_succeeds_when_all_hrefs_notify():
|
|||||||
assert entered is True
|
assert entered is True
|
||||||
assert mgr.mode == "observe"
|
assert mgr.mode == "observe"
|
||||||
assert mgr.subscribed_hrefs == set(hrefs)
|
assert mgr.subscribed_hrefs == set(hrefs)
|
||||||
|
assert mgr.fallback_hrefs == set()
|
||||||
finally:
|
finally:
|
||||||
mgr.close()
|
mgr.close()
|
||||||
|
|
||||||
@@ -218,6 +219,41 @@ def test_try_enter_observe_mode_falls_back_when_subscribe_fails_for_all():
|
|||||||
assert mgr.mode == "poll"
|
assert mgr.mode == "poll"
|
||||||
|
|
||||||
|
|
||||||
|
def test_enter_observe_mode_keeps_silent_hrefs_on_fallback():
|
||||||
|
"""Issue #92: subscribed hrefs that never notified stay on the poll
|
||||||
|
cadence via fallback_hrefs, rather than being treated as push-covered."""
|
||||||
|
mgr = _manager()
|
||||||
|
session = _FakeSession()
|
||||||
|
subscribed = {"/a/vs/0", "/b/vs/0", "/c/vs/0"}
|
||||||
|
mgr.on_notification("/a/vs/0", cbor2.dumps({"x": 1}))
|
||||||
|
mgr.on_notification("/b/vs/0", cbor2.dumps({"x": 1}))
|
||||||
|
try:
|
||||||
|
mgr.enter_observe_mode(session, subscribed)
|
||||||
|
assert mgr.mode == "observe"
|
||||||
|
assert mgr.subscribed_hrefs == subscribed
|
||||||
|
assert mgr.fallback_hrefs == {"/c/vs/0"}
|
||||||
|
finally:
|
||||||
|
mgr.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_notification_drops_href_from_fallback():
|
||||||
|
"""A late first notify (after the 80% snapshot) self-corrects
|
||||||
|
fallback_hrefs so a slow-but-pushing href is not polled for the rest
|
||||||
|
of the session -- multi-block resources being the reliable victim."""
|
||||||
|
mgr = _manager()
|
||||||
|
session = _FakeSession()
|
||||||
|
subscribed = {"/a/vs/0", "/b/vs/0", "/c/vs/0"}
|
||||||
|
mgr.on_notification("/a/vs/0", cbor2.dumps({"x": 1}))
|
||||||
|
mgr.on_notification("/b/vs/0", cbor2.dumps({"x": 1}))
|
||||||
|
try:
|
||||||
|
mgr.enter_observe_mode(session, subscribed)
|
||||||
|
assert mgr.fallback_hrefs == {"/c/vs/0"}
|
||||||
|
mgr.on_notification("/c/vs/0", cbor2.dumps({"x": 1}))
|
||||||
|
assert mgr.fallback_hrefs == set()
|
||||||
|
finally:
|
||||||
|
mgr.close()
|
||||||
|
|
||||||
|
|
||||||
def test_try_enter_observe_mode_meets_success_fraction_with_partial_notifies():
|
def test_try_enter_observe_mode_meets_success_fraction_with_partial_notifies():
|
||||||
mgr = _manager()
|
mgr = _manager()
|
||||||
session = _FakeSession()
|
session = _FakeSession()
|
||||||
@@ -243,6 +279,7 @@ def test_try_enter_observe_mode_meets_success_fraction_with_partial_notifies():
|
|||||||
|
|
||||||
assert entered is True
|
assert entered is True
|
||||||
assert mgr.mode == "observe"
|
assert mgr.mode == "observe"
|
||||||
|
assert mgr.fallback_hrefs == {hrefs[3]}
|
||||||
finally:
|
finally:
|
||||||
mgr.close()
|
mgr.close()
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,18 @@
|
|||||||
|
|
||||||
The device's entity set only exists as the output of a live poll, so coming
|
The device's entity set only exists as the output of a live poll, so coming
|
||||||
up offline means replaying the last successful discovery from a stored
|
up offline means replaying the last successful discovery from a stored
|
||||||
snapshot. These tests pin the four things that makes load-bearing: the
|
snapshot. These tests pin the five things that makes load-bearing: the
|
||||||
snapshot gets written, it produces the same entity set offline, the
|
snapshot gets written, it produces the same entity set offline, the
|
||||||
coordinator keeps polling until the device answers, and a live discovery that
|
coordinator keeps polling until the device answers, a live discovery that
|
||||||
disagrees with the snapshot reloads the entry rather than silently keeping a
|
disagrees with the snapshot reloads the entry rather than silently keeping a
|
||||||
stale set.
|
stale set, and each of those retries costs one handshake and one log line
|
||||||
|
rather than repeating both every cycle (issue #269).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
@@ -19,8 +22,10 @@ import pytest
|
|||||||
from homeassistant.config_entries import ConfigEntryState
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import issue_registry as ir
|
from homeassistant.helpers import issue_registry as ir
|
||||||
|
from homeassistant.helpers.update_coordinator import UpdateFailed
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
from pytest_homeassistant_custom_component.common import async_fire_time_changed
|
from pytest_homeassistant_custom_component.common import async_fire_time_changed
|
||||||
|
from smartthings_local.errors import SessionTimeoutError
|
||||||
|
|
||||||
from custom_components.localthings.const import DOMAIN, SUMMARY_INTERVAL_S
|
from custom_components.localthings.const import DOMAIN, SUMMARY_INTERVAL_S
|
||||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||||
@@ -29,6 +34,7 @@ from custom_components.localthings.registry.identity import DeviceIdentity
|
|||||||
from .conftest import _load_fridge_resources as _load_fridge
|
from .conftest import _load_fridge_resources as _load_fridge
|
||||||
|
|
||||||
_COORD = "custom_components.localthings.coordinator.LocalThingsCoordinator"
|
_COORD = "custom_components.localthings.coordinator.LocalThingsCoordinator"
|
||||||
|
_COORD_LOGGER = "custom_components.localthings.coordinator"
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
@@ -58,6 +64,20 @@ def _unreachable():
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _dark(handshakes: list[float]):
|
||||||
|
"""A switched-off appliance: the DTLS handshake itself times out, which
|
||||||
|
is what `_poll_once` really hits -- `_unreachable` above stands in one
|
||||||
|
step later, after a session it never gets. Records every attempt."""
|
||||||
|
|
||||||
|
def _connect(self) -> None:
|
||||||
|
handshakes.append(time.monotonic())
|
||||||
|
raise SessionTimeoutError()
|
||||||
|
|
||||||
|
with patch(f"{_COORD}._connect_session", _connect), patch(f"{_COORD}._close_session"):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
def _store_key(entry) -> str:
|
def _store_key(entry) -> str:
|
||||||
return f"{DOMAIN}.{entry.entry_id}.discovery"
|
return f"{DOMAIN}.{entry.entry_id}.discovery"
|
||||||
|
|
||||||
@@ -416,3 +436,86 @@ async def test_offline_load_survives_repeated_poll_failures(
|
|||||||
|
|
||||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||||
assert coordinator.last_update_success
|
assert coordinator.last_update_success
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# What a cycle against a dark appliance costs (issue #269)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_dark_appliance_costs_one_handshake_per_cycle(
|
||||||
|
hass: HomeAssistant, mock_entry
|
||||||
|
) -> None:
|
||||||
|
"""A washer or dryer is switched off most of the day, so every one of
|
||||||
|
these cycles is paid for real: a handshake against a device that isn't
|
||||||
|
there runs to its full 12s timeout, and the reconnect retry used to add a
|
||||||
|
second one plus its pause to every cycle -- and to every setup attempt
|
||||||
|
while the appliance stayed dark. There is no session to reconnect when
|
||||||
|
the handshake is what failed, so the retry only repeated it."""
|
||||||
|
resources = _load_fridge()
|
||||||
|
await _setup_online_then_unload(hass, mock_entry, resources)
|
||||||
|
|
||||||
|
handshakes: list[float] = []
|
||||||
|
with _dark(handshakes):
|
||||||
|
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert len(handshakes) == 1
|
||||||
|
|
||||||
|
for expected in (2, 3, 4):
|
||||||
|
await _tick(hass)
|
||||||
|
assert len(handshakes) == expected
|
||||||
|
|
||||||
|
|
||||||
|
async def test_dark_appliance_reports_its_outage_once(
|
||||||
|
hass: HomeAssistant, mock_entry, caplog
|
||||||
|
) -> None:
|
||||||
|
"""Sitting through an outage is what this integration is built to do
|
||||||
|
(issue #295), so it must not log an error every 30s for as long as the
|
||||||
|
appliance is off -- the reporter on issue #269 read exactly that repeated
|
||||||
|
line as the integration having failed. One line per outage, and one when
|
||||||
|
the device comes back."""
|
||||||
|
resources = _load_fridge()
|
||||||
|
await _setup_online_then_unload(hass, mock_entry, resources)
|
||||||
|
|
||||||
|
caplog.clear()
|
||||||
|
caplog.set_level(logging.INFO)
|
||||||
|
with _dark([]):
|
||||||
|
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
for _ in range(3):
|
||||||
|
await _tick(hass)
|
||||||
|
|
||||||
|
ours = [r for r in caplog.records if r.name.startswith(f"{_COORD_LOGGER}.")]
|
||||||
|
errors = [r for r in ours if r.levelno >= logging.ERROR]
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "device unreachable" in errors[0].getMessage()
|
||||||
|
|
||||||
|
with _reachable(resources):
|
||||||
|
await _tick(hass)
|
||||||
|
|
||||||
|
recovered = [r for r in caplog.records if "device answered again" in r.getMessage()]
|
||||||
|
assert len(recovered) == 1
|
||||||
|
assert recovered[0].levelno == logging.INFO
|
||||||
|
|
||||||
|
|
||||||
|
async def test_broken_session_still_reconnects_within_the_cycle(
|
||||||
|
hass: HomeAssistant, mock_entry
|
||||||
|
) -> None:
|
||||||
|
"""The counterpart guard: skipping the reconnect is only right when the
|
||||||
|
handshake never completed. A session that opened and then failed mid-poll
|
||||||
|
still gets torn down and re-established without waiting a whole cycle."""
|
||||||
|
coordinator = LocalThingsCoordinator(hass, mock_entry)
|
||||||
|
coordinator._discovered = True
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
LocalThingsCoordinator, "_poll_once", side_effect=RuntimeError("poll GET failed")
|
||||||
|
) as poll,
|
||||||
|
patch.object(LocalThingsCoordinator, "_close_session") as close,
|
||||||
|
patch("custom_components.localthings.coordinator.asyncio.sleep"),
|
||||||
|
pytest.raises(UpdateFailed),
|
||||||
|
):
|
||||||
|
await coordinator._async_update_data()
|
||||||
|
|
||||||
|
assert poll.call_count == 2
|
||||||
|
close.assert_called_once()
|
||||||
@@ -54,8 +54,8 @@ def test_no_unbound_hrefs():
|
|||||||
|
|
||||||
def test_air_quality_sensors_read_from_shared_items_decode():
|
def test_air_quality_sensors_read_from_shared_items_decode():
|
||||||
"""Same /sensors/vs/0 {type, value} shape and common.sensor_item_value
|
"""Same /sensors/vs/0 {type, value} shape and common.sensor_item_value
|
||||||
decode air_purifier.AIR_QUALITY already uses -- this board adds CO2,
|
decode air_purifier.AIR_QUALITY already uses -- this board lists CO2
|
||||||
which neither air_purifier nor range_hood report."""
|
unconditionally, unlike the exists_fn-gated purifier descriptor."""
|
||||||
state = _state()
|
state = _state()
|
||||||
assert state["dust"] == 31
|
assert state["dust"] == 31
|
||||||
assert state["fine_dust"] == 23
|
assert state["fine_dust"] == 23
|
||||||
|
|||||||
@@ -96,6 +96,26 @@ def test_air_monitor_takes_the_pm_labels_but_not_the_state_class():
|
|||||||
assert (desc.device_class, desc.unit) == (None, None), key
|
assert (desc.device_class, desc.unit) == (None, None), key
|
||||||
|
|
||||||
|
|
||||||
|
def test_co2_is_not_in_the_shared_tuple():
|
||||||
|
"""air_monitor already has its own CO2 SensorDesc. Putting CO2 in
|
||||||
|
_AIR_QUALITY_SENSORS would create a second entity with the same key."""
|
||||||
|
assert all(row[0] != "co2" for row in air_purifier._AIR_QUALITY_SENSORS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_co2_matches_air_monitor_mapping():
|
||||||
|
"""ppm / carbon_dioxide is the same contract air_monitor.SENSORS already
|
||||||
|
ships for this field (issue #387), not a unit guess."""
|
||||||
|
desc = _desc("co2")
|
||||||
|
assert desc.device_class == "carbon_dioxide"
|
||||||
|
assert desc.unit == "ppm"
|
||||||
|
assert desc.state_class == "measurement"
|
||||||
|
assert desc.exists_fn is not None
|
||||||
|
assert desc.enabled_default is False
|
||||||
|
assert (
|
||||||
|
desc.value_fn([{"x.com.samsung.da.type": "CO2", "x.com.samsung.da.value": ["498"]}]) == 498
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_every_air_quality_sensor_still_reads_a_plain_int():
|
def test_every_air_quality_sensor_still_reads_a_plain_int():
|
||||||
"""A state_class is only honoured for a numeric state, so the value
|
"""A state_class is only honoured for a numeric state, so the value
|
||||||
contract this depends on is asserted here too."""
|
contract this depends on is asserted here too."""
|
||||||
|
|||||||
@@ -69,6 +69,36 @@ def test_air_quality_sensor_values():
|
|||||||
assert state["super_fine_dust"] == 5
|
assert state["super_fine_dust"] == 5
|
||||||
assert state["odor"] == 0
|
assert state["odor"] == 0
|
||||||
assert state["clean_level"] == 0
|
assert state["clean_level"] == 0
|
||||||
|
assert "co2" not in state
|
||||||
|
|
||||||
|
|
||||||
|
def test_co2_reads_when_the_items_list_includes_the_type():
|
||||||
|
"""Issue #387 -- same {type, value} shape air_monitor.SENSORS already
|
||||||
|
models. Gated so boards that don't list CO2 don't grow an empty entity."""
|
||||||
|
reg, resources = _purifier()
|
||||||
|
resources = {
|
||||||
|
**resources,
|
||||||
|
"/sensors/vs/0": {
|
||||||
|
**resources["/sensors/vs/0"],
|
||||||
|
"x.com.samsung.da.items": [
|
||||||
|
*resources["/sensors/vs/0"]["x.com.samsung.da.items"],
|
||||||
|
{"x.com.samsung.da.type": "CO2", "x.com.samsung.da.value": ["612"]},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||||
|
assert flatten(bound, resources)["co2"] == 612
|
||||||
|
|
||||||
|
desc = next(e for e in air_purifier.AIR_QUALITY.entities if e.key == "co2")
|
||||||
|
assert desc.exists_fn is not None
|
||||||
|
assert desc.enabled_default is False
|
||||||
|
assert desc.exists_fn({"x.com.samsung.da.items": []}, {}) is False
|
||||||
|
assert (
|
||||||
|
desc.exists_fn({"x.com.samsung.da.items": [{"x.com.samsung.da.type": "CO2"}]}, {}) is True
|
||||||
|
)
|
||||||
|
# /device/0 stub must not drop co2 while its field-gated siblings still
|
||||||
|
# register -- platforms enumerate bound once (issue #127).
|
||||||
|
assert desc.exists_fn({"href": "/sensors/vs/0"}, {}) is True
|
||||||
|
|
||||||
|
|
||||||
def test_filter_progress_reads_named_consumable_item():
|
def test_filter_progress_reads_named_consumable_item():
|
||||||
|
|||||||
@@ -986,7 +986,7 @@ def test_air_quality_disabled_by_default():
|
|||||||
SuperFineDust readings with no such scalar, so requiring it would
|
SuperFineDust readings with no such scalar, so requiring it would
|
||||||
silently drop real readings on hardware this repo hasn't seen yet on an
|
silently drop real readings on hardware this repo hasn't seen yet on an
|
||||||
AC. These stay bound whenever the item type is listed (see
|
AC. These stay bound whenever the item type is listed (see
|
||||||
_has_sensor_type) and disabled by default instead, same precedent as
|
has_sensor_type) and disabled by default instead, same precedent as
|
||||||
fridge.rack_count / cooktop.paired_hood_model / tropical_night_mode --
|
fridge.rack_count / cooktop.paired_hood_model / tropical_night_mode --
|
||||||
units that do have the sensor can enable it themselves."""
|
units that do have the sensor can enable it themselves."""
|
||||||
for key in ("clean_level", "odor", "dust", "fine_dust", "super_fine_dust"):
|
for key in ("clean_level", "odor", "dust", "fine_dust", "super_fine_dust"):
|
||||||
@@ -994,6 +994,17 @@ def test_air_quality_disabled_by_default():
|
|||||||
assert desc.enabled_default is False, key
|
assert desc.enabled_default is False, key
|
||||||
|
|
||||||
|
|
||||||
|
def test_air_quality_included_on_stub_rep():
|
||||||
|
"""exists_fn would otherwise drop every gated sensor when /device/0
|
||||||
|
returns a not-yet-fetched stub, while field-gated siblings still
|
||||||
|
register (issue #127)."""
|
||||||
|
stub = {"href": "/sensors/vs/0"}
|
||||||
|
for key in ("clean_level", "odor", "dust", "fine_dust", "super_fine_dust", "co2"):
|
||||||
|
desc = next(e for e in airconditioner.AIR_QUALITY.entities if e.key == key)
|
||||||
|
assert desc.exists_fn is not None
|
||||||
|
assert desc.exists_fn(stub, {}) is True, key
|
||||||
|
|
||||||
|
|
||||||
def test_air_quality_absent_when_no_sensor_items():
|
def test_air_quality_absent_when_no_sensor_items():
|
||||||
"""A board whose /sensors/vs/0 carries an empty items[] (the cool-only
|
"""A board whose /sensors/vs/0 carries an empty items[] (the cool-only
|
||||||
RAC variant) binds no air-quality entities -- exists_fn gates each on its
|
RAC variant) binds no air-quality entities -- exists_fn gates each on its
|
||||||
|
|||||||
@@ -417,6 +417,31 @@ class TestEnergyMeter:
|
|||||||
assert pw.exists_fn({"x.com.samsung.da.cumulativePower": "5"}, {}) is False
|
assert pw.exists_fn({"x.com.samsung.da.cumulativePower": "5"}, {}) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# has_sensor_type. Presence gate for /sensors/vs/0 items[] types, shared by
|
||||||
|
# airconditioner.AIR_QUALITY and air_purifier.AIR_QUALITY. The stub carve-out
|
||||||
|
# is the same contract ENERGY_METER documents for issue #127.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestHasSensorType:
|
||||||
|
def test_true_when_type_is_listed(self):
|
||||||
|
fn = common.has_sensor_type("CO2")
|
||||||
|
assert fn({"x.com.samsung.da.items": [{"x.com.samsung.da.type": "CO2"}]}, {}) is True
|
||||||
|
|
||||||
|
def test_false_when_type_is_absent(self):
|
||||||
|
fn = common.has_sensor_type("CO2")
|
||||||
|
assert fn({"x.com.samsung.da.items": [{"x.com.samsung.da.type": "Dust"}]}, {}) is False
|
||||||
|
assert fn({"x.com.samsung.da.items": []}, {}) is False
|
||||||
|
assert fn({}, {}) is False
|
||||||
|
|
||||||
|
def test_true_on_stub_rep(self):
|
||||||
|
"""A true stub -- /device/0's {"href": "..."} "not fetched yet"
|
||||||
|
marker -- must keep the entity so sub-polls can populate it."""
|
||||||
|
fn = common.has_sensor_type("CO2")
|
||||||
|
assert fn({"href": "/sensors/vs/0"}, {}) is True
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# AI energy-saving level. '0' is off; supportedAiLevel lists the additional
|
# AI energy-saving level. '0' is off; supportedAiLevel lists the additional
|
||||||
# level(s) on offer. A single-entry list (issue #21 fridge, issue #40 washer)
|
# level(s) on offer. A single-entry list (issue #21 fridge, issue #40 washer)
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ cycle_options, cycle_write) is tested in test_laundry_capabilities.py; here we
|
|||||||
check the dishwasher wiring and its device-specific options.
|
check the dishwasher wiring and its device-specific options.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
|
|
||||||
from custom_components.localthings.registry.capabilities import dishwasher
|
from custom_components.localthings.registry.capabilities import dishwasher
|
||||||
from custom_components.localthings.registry.entities import SensorDesc, SwitchDesc
|
from custom_components.localthings.registry.entities import SensorDesc, SwitchDesc
|
||||||
|
|
||||||
@@ -68,7 +66,13 @@ class TestDrumClean:
|
|||||||
"""Drum Clean+ maintenance tracking shares washer.py's (issue #9) /
|
"""Drum Clean+ maintenance tracking shares washer.py's (issue #9) /
|
||||||
dryer.py's (issue #258) options[]-array readers -- a live dishwasher
|
dryer.py's (issue #258) options[]-array readers -- a live dishwasher
|
||||||
dump confirmed the same WashingTimes_/DrumCleanProposal_/DrumCleanLog_
|
dump confirmed the same WashingTimes_/DrumCleanProposal_/DrumCleanLog_
|
||||||
trio, so these entities are wired the same way here."""
|
trio, so drum_clean_cycles_remaining is wired the same way here.
|
||||||
|
|
||||||
|
drum_clean_last_cleaned is deliberately not (issue #398): a live dump
|
||||||
|
showed DrumCleanLog_'s newest entry moving every 30-90s on its own,
|
||||||
|
including well after a cycle had finished -- unlike the washer/dryer
|
||||||
|
reports this reader was built from (issues #9, #258), it never settles
|
||||||
|
on a value worth showing."""
|
||||||
|
|
||||||
def test_cycles_remaining(self):
|
def test_cycles_remaining(self):
|
||||||
desc = next(
|
desc = next(
|
||||||
@@ -87,28 +91,10 @@ class TestDrumClean:
|
|||||||
rep = {"x.com.samsung.da.options": ["WashingTimes_18", "DrumCleanProposal_20"]}
|
rep = {"x.com.samsung.da.options": ["WashingTimes_18", "DrumCleanProposal_20"]}
|
||||||
assert desc.exists_fn(rep, {}) is True
|
assert desc.exists_fn(rep, {}) is True
|
||||||
|
|
||||||
def test_last_cleaned(self):
|
def test_last_cleaned_is_not_wired(self):
|
||||||
"""A live dump's DrumCleanLog_ is a '|'-joined history (the dryer
|
assert not any(
|
||||||
shape, not the washer's single-entry one) -- the last entry wins."""
|
e.key == "drum_clean_last_cleaned" for e in dishwasher.CYCLE_OPTIONS.entities
|
||||||
desc = next(
|
|
||||||
e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == "drum_clean_last_cleaned"
|
|
||||||
)
|
)
|
||||||
assert desc.rep_fn is not None
|
|
||||||
rep = {
|
|
||||||
"x.com.samsung.da.options": [
|
|
||||||
"DrumCleanLog_2026-06-26T04:18:58|2026-06-28T00:36:34",
|
|
||||||
]
|
|
||||||
}
|
|
||||||
assert desc.rep_fn(rep) == datetime(2026, 6, 28, 0, 36, 34, tzinfo=UTC)
|
|
||||||
|
|
||||||
def test_last_cleaned_missing(self):
|
|
||||||
desc = next(
|
|
||||||
e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == "drum_clean_last_cleaned"
|
|
||||||
)
|
|
||||||
assert desc.rep_fn is not None
|
|
||||||
assert desc.rep_fn({"x.com.samsung.da.options": []}) is None
|
|
||||||
assert desc.exists_fn is not None
|
|
||||||
assert desc.exists_fn({"x.com.samsung.da.options": []}, {}) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_diagnosis_status_is_a_translatable_enum():
|
def test_diagnosis_status_is_a_translatable_enum():
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Tests for dryer support and washer/dryer consistency (issue #14)."""
|
"""Tests for dryer support and washer/dryer consistency (issue #14)."""
|
||||||
|
|
||||||
from custom_components.localthings.registry.adapter import flatten
|
from custom_components.localthings.registry.adapter import flatten
|
||||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
from custom_components.localthings.registry.by_type import for_device_by_model, resolve
|
||||||
from custom_components.localthings.registry.capabilities import dryer, ignored
|
from custom_components.localthings.registry.capabilities import dryer, ignored
|
||||||
from custom_components.localthings.registry.discovery import discover
|
from custom_components.localthings.registry.discovery import discover
|
||||||
from custom_components.localthings.registry.entities import SelectDesc
|
from custom_components.localthings.registry.entities import SelectDesc
|
||||||
@@ -90,8 +90,12 @@ def test_course_bound_to_shared_course_vs_0():
|
|||||||
|
|
||||||
|
|
||||||
def test_reported_table_00_course_codes_are_translated():
|
def test_reported_table_00_course_codes_are_translated():
|
||||||
"""The reporter confirmed these codes on a DVE45R6300W/A3 by selecting
|
"""The DVE45R6300W/A3 reporter confirmed these codes by selecting each
|
||||||
each cycle and reading back the raw course code (issue #357)."""
|
cycle and reading back the raw course code (issue #357). A DV6800N --
|
||||||
|
same DA_WM_A51_20_COMMON board, also Table_00 -- later confirmed 14 more
|
||||||
|
(issue #394): a different subset of the same table, not a conflicting
|
||||||
|
code family (its one code in common with #357, 'a5', means Bedding on
|
||||||
|
both), so both sets share the one dryer_cycle_table_00 catalog entry."""
|
||||||
from custom_components.localthings.catalog import translated_states
|
from custom_components.localthings.catalog import translated_states
|
||||||
|
|
||||||
desc = next(
|
desc = next(
|
||||||
@@ -99,7 +103,32 @@ def test_reported_table_00_course_codes_are_translated():
|
|||||||
)
|
)
|
||||||
table_00 = {"/st/dryercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_00"}}
|
table_00 = {"/st/dryercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_00"}}
|
||||||
assert desc.translation_key(table_00) == "dryer_cycle_table_00"
|
assert desc.translation_key(table_00) == "dryer_cycle_table_00"
|
||||||
confirmed = {"01", "9c", "a5", "9e", "9b", "27", "a0", "a4", "a6", "a3", "a2"}
|
confirmed = {
|
||||||
|
"01",
|
||||||
|
"9c",
|
||||||
|
"a5",
|
||||||
|
"9e",
|
||||||
|
"9b",
|
||||||
|
"27",
|
||||||
|
"a0",
|
||||||
|
"a4",
|
||||||
|
"a6",
|
||||||
|
"a3",
|
||||||
|
"a2", # issue #357
|
||||||
|
"9a",
|
||||||
|
"ca",
|
||||||
|
"db",
|
||||||
|
"99",
|
||||||
|
"93",
|
||||||
|
"b5",
|
||||||
|
"d7",
|
||||||
|
"96",
|
||||||
|
"97",
|
||||||
|
"7f",
|
||||||
|
"98",
|
||||||
|
"eb",
|
||||||
|
"b6", # issue #394
|
||||||
|
}
|
||||||
assert confirmed <= translated_states("select", "dryer_cycle_table_00")
|
assert confirmed <= translated_states("select", "dryer_cycle_table_00")
|
||||||
|
|
||||||
|
|
||||||
@@ -109,3 +138,31 @@ def test_st_dryercourse_is_ignored():
|
|||||||
ignored_hrefs = {c.href for c in ignored.IGNORED}
|
ignored_hrefs = {c.href for c in ignored.IGNORED}
|
||||||
assert "/st/dryercourse/vs/0" in ignored_hrefs
|
assert "/st/dryercourse/vs/0" in ignored_hrefs
|
||||||
assert "/st/washercourse/vs/0" in ignored_hrefs
|
assert "/st/washercourse/vs/0" in ignored_hrefs
|
||||||
|
|
||||||
|
|
||||||
|
def _dv6800n():
|
||||||
|
resources = _load_device("dryer_dv6800n")
|
||||||
|
reg = resolve(resources, device_types=("oic.wk.d", "oic.d.dryer"))
|
||||||
|
return reg, resources
|
||||||
|
|
||||||
|
|
||||||
|
def test_dv6800n_no_unbound_hrefs():
|
||||||
|
"""Every resource in the issue #394 dump binds or is ignored."""
|
||||||
|
reg, resources = _dv6800n()
|
||||||
|
unbound = []
|
||||||
|
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||||
|
assert unbound == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_dv6800n_course_codes_read_from_dump():
|
||||||
|
"""A DV6800N (DA_WM_A51_20_COMMON, issue #394) reports 'Table_00' same
|
||||||
|
as #357's DVE45R6300W/A3, so it resolves to the same catalog entry --
|
||||||
|
its /course/vs/0 supportedOptions just advertises a different subset of
|
||||||
|
the same table (see test_reported_table_00_course_codes_are_translated)."""
|
||||||
|
_, resources = _dv6800n()
|
||||||
|
desc = next(
|
||||||
|
e for e in dryer.DRYER_COURSE.entities if e.key == "cycle" and isinstance(e, SelectDesc)
|
||||||
|
)
|
||||||
|
assert desc.translation_key(resources) == "dryer_cycle_table_00"
|
||||||
|
confirmed = ["9A", "CA", "DB", "99", "93", "B5", "D7", "A5", "96", "97", "7F", "98", "EB", "B6"]
|
||||||
|
assert desc.options(resources) == confirmed
|
||||||
@@ -121,6 +121,25 @@ def test_registry_reproduces_golden_state_keys_for_dryer_dve50a8600():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_reproduces_golden_state_keys_for_dryer_dv6800n():
|
||||||
|
"""DA_WM_A51_20_COMMON/DV6800N (issue #394) resolves via /oic/d's
|
||||||
|
'oic.d.dryer' device type, not board-token guessing -- its modelNum's
|
||||||
|
board tokens ('DA', 'WM', 'COMMON') are all deliberately excluded from
|
||||||
|
_BOARD_TOKEN_TO_KEY (see registry/by_type's module docstring)."""
|
||||||
|
from tests.conftest import _load_device
|
||||||
|
|
||||||
|
resources = _load_device("dryer_dv6800n")
|
||||||
|
golden = json.loads((GOLDEN / "dryer_dv6800n.json").read_text())
|
||||||
|
state_keys = _new_state_keys(
|
||||||
|
"dryer_dv6800n", resources, device_types=("oic.wk.d", "oic.d.dryer")
|
||||||
|
)
|
||||||
|
assert set(state_keys) == set(golden["state_keys"]), (
|
||||||
|
f"state_keys mismatch:\n"
|
||||||
|
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||||
|
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_registry_reproduces_golden_state_keys_for_airconditioner():
|
def test_registry_reproduces_golden_state_keys_for_airconditioner():
|
||||||
from tests.conftest import _load_device
|
from tests.conftest import _load_device
|
||||||
|
|
||||||
@@ -389,6 +408,25 @@ def test_registry_reproduces_golden_state_keys_for_washer_dryer_combo():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_reproduces_golden_state_keys_for_washer_ww6500():
|
||||||
|
"""DA_WM_A51_20 front-loader, typed solely by the WW consumer prefix in
|
||||||
|
its /information/vs/0 description: A51 is not a board token, so with the
|
||||||
|
description blanked this device resolves to nothing and drops to the
|
||||||
|
unknown-device fallback. That is the fragile route this test pins.
|
||||||
|
Reports the AddWash tokens and an empty /wm/editcourse/vs/0, so its cycle
|
||||||
|
list comes from supportedOptions."""
|
||||||
|
from tests.conftest import _load_device
|
||||||
|
|
||||||
|
resources = _load_device("washer_ww6500")
|
||||||
|
golden = json.loads((GOLDEN / "washer_ww6500.json").read_text())
|
||||||
|
state_keys = _new_state_keys("washer_ww6500", resources)
|
||||||
|
assert set(state_keys) == set(golden["state_keys"]), (
|
||||||
|
f"state_keys mismatch:\n"
|
||||||
|
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||||
|
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_registry_reproduces_golden_state_keys_for_artik051_ref_17k():
|
def test_registry_reproduces_golden_state_keys_for_artik051_ref_17k():
|
||||||
"""ARTIK051_REF_17K's Cool Select Zone pantry compartment
|
"""ARTIK051_REF_17K's Cool Select Zone pantry compartment
|
||||||
(/status/pantry/one/vs/0) -- issue #20."""
|
(/status/pantry/one/vs/0) -- issue #20."""
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
"""Tests for the AddWash entities on /course/vs/0.
|
||||||
|
|
||||||
|
Three independent tokens: AddWashSet (the alarm's 3-bit mask, the only
|
||||||
|
writable one), AddWashAvailable (what the loaded course permits) and
|
||||||
|
AddWashIndicator (the live panel lamp). See washer.py for where the bit
|
||||||
|
meanings and the write contract come from.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from custom_components.localthings.registry.adapter import flatten
|
||||||
|
from custom_components.localthings.registry.by_type import resolve
|
||||||
|
from custom_components.localthings.registry.capabilities import washer
|
||||||
|
from custom_components.localthings.registry.discovery import discover
|
||||||
|
from custom_components.localthings.registry.entities import BinarySensorDesc, SwitchDesc
|
||||||
|
from tests.conftest import _load_device
|
||||||
|
|
||||||
|
COURSE = ["course", "vs", "0"]
|
||||||
|
BITS = {
|
||||||
|
"add_wash_alarm_rinse": 0,
|
||||||
|
"add_wash_alarm_final_rinse": 1,
|
||||||
|
"add_wash_alarm_spin": 2,
|
||||||
|
}
|
||||||
|
ENTITY_TOKENS = {
|
||||||
|
"add_wash_alarm": "AddWashSet",
|
||||||
|
"add_wash_alarm_rinse": "AddWashSet",
|
||||||
|
"add_wash_alarm_final_rinse": "AddWashSet",
|
||||||
|
"add_wash_alarm_spin": "AddWashSet",
|
||||||
|
"add_wash_available": "AddWashAvailable",
|
||||||
|
"add_wash_indicator": "AddWashIndicator",
|
||||||
|
}
|
||||||
|
# A value each token really carries, so presence gating is exercised against
|
||||||
|
# what a washer reports rather than a synthetic one.
|
||||||
|
SAMPLE = {"AddWashSet": "0", "AddWashAvailable": "7", "AddWashIndicator": "Off"}
|
||||||
|
|
||||||
|
|
||||||
|
def _desc(key, kind):
|
||||||
|
return next(e for e in washer.WASHER_COURSE.entities if e.key == key and isinstance(e, kind))
|
||||||
|
|
||||||
|
|
||||||
|
def _rep(*tokens):
|
||||||
|
return {"x.com.samsung.da.options": list(tokens)}
|
||||||
|
|
||||||
|
|
||||||
|
def _write(desc, payload, rep):
|
||||||
|
return desc.write_fn(payload, rep)
|
||||||
|
|
||||||
|
|
||||||
|
def _options(result):
|
||||||
|
"""The tokens a write_fn result carries, asserting it targets /course/vs/0."""
|
||||||
|
path, body = result
|
||||||
|
assert path == COURSE
|
||||||
|
return body["x.com.samsung.da.options"]
|
||||||
|
|
||||||
|
|
||||||
|
def _exists(key, rep):
|
||||||
|
"""Whether `key`'s descriptor gates itself in for `rep`."""
|
||||||
|
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == key)
|
||||||
|
assert desc.exists_fn is not None
|
||||||
|
return desc.exists_fn(rep, {})
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten(fixture):
|
||||||
|
resources = _load_device(fixture)
|
||||||
|
reg = resolve(resources)
|
||||||
|
assert reg is not None
|
||||||
|
return flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAlarmMasterSwitch:
|
||||||
|
def test_zero_reads_off_and_seven_reads_on(self):
|
||||||
|
desc = _desc("add_wash_alarm", SwitchDesc)
|
||||||
|
assert desc.rep_fn(_rep("AddWashSet_0")) is False
|
||||||
|
assert desc.rep_fn(_rep("AddWashSet_7")) is True
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mask", range(8))
|
||||||
|
def test_any_non_zero_mask_reads_on(self, mask):
|
||||||
|
desc = _desc("add_wash_alarm", SwitchDesc)
|
||||||
|
assert desc.rep_fn(_rep(f"AddWashSet_{mask}")) is (mask != 0)
|
||||||
|
|
||||||
|
def test_turning_on_enables_every_moment(self):
|
||||||
|
desc = _desc("add_wash_alarm", SwitchDesc)
|
||||||
|
assert _options(_write(desc, "On", _rep("AddWashSet_0"))) == ["AddWashSet_7"]
|
||||||
|
|
||||||
|
def test_turning_off_clears_the_mask(self):
|
||||||
|
desc = _desc("add_wash_alarm", SwitchDesc)
|
||||||
|
assert _options(_write(desc, "Off", _rep("AddWashSet_5"))) == ["AddWashSet_0"]
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mask", range(1, 8))
|
||||||
|
def test_on_over_an_alarm_already_on_keeps_the_chosen_moments(self, mask):
|
||||||
|
"""Home Assistant calls turn_on regardless of current state, so
|
||||||
|
re-asserting "on" over a rinse-only mask must not widen it to all
|
||||||
|
three -- this switch reads on either way, so no state change would
|
||||||
|
point at the loss. Reaching 7 from a subset still means off, then
|
||||||
|
on."""
|
||||||
|
desc = _desc("add_wash_alarm", SwitchDesc)
|
||||||
|
rep = _rep(f"AddWashSet_{mask}")
|
||||||
|
assert desc.rep_fn(rep) is True
|
||||||
|
assert _write(desc, "On", rep) is None
|
||||||
|
assert _options(_write(desc, "Off", rep)) == ["AddWashSet_0"]
|
||||||
|
assert _options(_write(desc, "On", _rep("AddWashSet_0"))) == ["AddWashSet_7"]
|
||||||
|
|
||||||
|
def test_rejects_a_payload_that_is_not_on_or_off(self):
|
||||||
|
desc = _desc("add_wash_alarm", SwitchDesc)
|
||||||
|
assert _write(desc, "7", _rep("AddWashSet_0")) is None
|
||||||
|
|
||||||
|
def test_rejects_a_write_against_an_empty_options_array(self):
|
||||||
|
desc = _desc("add_wash_alarm", SwitchDesc)
|
||||||
|
assert _write(desc, "On", {}) is None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw", ["15", "On", "-1"])
|
||||||
|
def test_refuses_to_write_over_a_mask_it_cannot_read(self, raw):
|
||||||
|
"""A device reporting a wider mask must not have it truncated to 7.
|
||||||
|
The master is gated exactly like the per-moment writes, so an
|
||||||
|
unrecognized mask leaves every AddWash switch read-only."""
|
||||||
|
desc = _desc("add_wash_alarm", SwitchDesc)
|
||||||
|
rep = _rep(f"AddWashSet_{raw}")
|
||||||
|
assert desc.rep_fn(rep) is None
|
||||||
|
assert _write(desc, "On", rep) is None
|
||||||
|
assert _write(desc, "Off", rep) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestAlarmMomentSwitches:
|
||||||
|
@pytest.mark.parametrize("key,bit", BITS.items())
|
||||||
|
@pytest.mark.parametrize("mask", range(8))
|
||||||
|
def test_every_mask_decodes_to_the_right_bits(self, key, bit, mask):
|
||||||
|
desc = _desc(key, SwitchDesc)
|
||||||
|
assert desc.rep_fn(_rep(f"AddWashSet_{mask}")) is bool(mask >> bit & 1)
|
||||||
|
|
||||||
|
def test_setting_one_moment_leaves_the_others_alone(self):
|
||||||
|
desc = _desc("add_wash_alarm_final_rinse", SwitchDesc)
|
||||||
|
# 5 is rinse + spin; adding the final rinse must reach 7, not 2.
|
||||||
|
assert _options(_write(desc, "On", _rep("AddWashSet_5"))) == ["AddWashSet_7"]
|
||||||
|
|
||||||
|
def test_clearing_one_moment_leaves_the_others_alone(self):
|
||||||
|
desc = _desc("add_wash_alarm_rinse", SwitchDesc)
|
||||||
|
assert _options(_write(desc, "Off", _rep("AddWashSet_7"))) == ["AddWashSet_6"]
|
||||||
|
|
||||||
|
def test_clearing_the_last_moment_yields_zero(self):
|
||||||
|
desc = _desc("add_wash_alarm_spin", SwitchDesc)
|
||||||
|
assert _options(_write(desc, "Off", _rep("AddWashSet_4"))) == ["AddWashSet_0"]
|
||||||
|
|
||||||
|
def test_enabling_a_moment_from_zero_turns_the_alarm_on(self):
|
||||||
|
"""The mask is the only state, so this is the intended outcome --
|
||||||
|
there is no remembered combination to restore."""
|
||||||
|
moment = _desc("add_wash_alarm_spin", SwitchDesc)
|
||||||
|
written = _options(_write(moment, "On", _rep("AddWashSet_0")))
|
||||||
|
assert written == ["AddWashSet_4"]
|
||||||
|
assert _desc("add_wash_alarm", SwitchDesc).rep_fn(_rep(*written)) is True
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rep", [{}, _rep("AddWashSet_x"), _rep("Course_5C")])
|
||||||
|
def test_refuses_to_write_when_the_mask_is_unreadable(self, rep):
|
||||||
|
desc = _desc("add_wash_alarm_rinse", SwitchDesc)
|
||||||
|
assert _write(desc, "On", rep) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestMaskParsing:
|
||||||
|
def test_a_missing_token_is_unavailable_not_zero(self):
|
||||||
|
assert washer._add_wash_mask(_rep("Course_5C"), "AddWashSet") is None
|
||||||
|
assert _desc("add_wash_alarm", SwitchDesc).rep_fn(_rep("Course_5C")) is None
|
||||||
|
|
||||||
|
def test_a_malformed_token_is_unavailable(self):
|
||||||
|
assert washer._add_wash_mask(_rep("AddWashSet_On"), "AddWashSet") is None
|
||||||
|
|
||||||
|
def test_zero_is_a_real_value(self):
|
||||||
|
assert washer._add_wash_mask(_rep("AddWashSet_0"), "AddWashSet") == 0
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw", ["8", "255", "-1"])
|
||||||
|
def test_a_mask_outside_three_bits_is_unavailable(self, raw):
|
||||||
|
"""This models exactly three moments, so a wider value means the
|
||||||
|
model is wrong -- refuse it rather than read-modify-write it back."""
|
||||||
|
assert washer._add_wash_mask(_rep(f"AddWashSet_{raw}"), "AddWashSet") is None
|
||||||
|
assert _desc("add_wash_alarm_rinse", SwitchDesc).rep_fn(_rep(f"AddWashSet_{raw}")) is None
|
||||||
|
assert (
|
||||||
|
_write(_desc("add_wash_alarm_spin", SwitchDesc), "On", _rep(f"AddWashSet_{raw}"))
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadOnlySensors:
|
||||||
|
@pytest.mark.parametrize("raw,expected", [("0", False), ("6", True), ("7", True)])
|
||||||
|
def test_available_is_true_for_any_permitted_moment(self, raw, expected):
|
||||||
|
desc = _desc("add_wash_available", BinarySensorDesc)
|
||||||
|
assert desc.rep_fn(_rep(f"AddWashAvailable_{raw}")) is expected
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw,expected", [("On", True), ("Off", False)])
|
||||||
|
def test_indicator_maps_on_off(self, raw, expected):
|
||||||
|
desc = _desc("add_wash_indicator", BinarySensorDesc)
|
||||||
|
assert desc.rep_fn(_rep(f"AddWashIndicator_{raw}")) is expected
|
||||||
|
|
||||||
|
def test_indicator_ships_enabled_and_uncategorised(self):
|
||||||
|
desc = _desc("add_wash_indicator", BinarySensorDesc)
|
||||||
|
assert desc.enabled_default is True
|
||||||
|
assert desc.entity_category is None
|
||||||
|
|
||||||
|
def test_missing_tokens_are_unavailable(self):
|
||||||
|
assert _desc("add_wash_available", BinarySensorDesc).rep_fn(_rep()) is None
|
||||||
|
assert _desc("add_wash_indicator", BinarySensorDesc).rep_fn(_rep()) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestCapabilityDetection:
|
||||||
|
"""Each entity self-gates on its own token, so a washer advertising only
|
||||||
|
a subset gets only that subset."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key,token", ENTITY_TOKENS.items())
|
||||||
|
def test_absent_on_a_washer_that_never_reports_the_token(self, key, token):
|
||||||
|
assert _exists(key, _rep("Course_5C")) is False
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key,token", ENTITY_TOKENS.items())
|
||||||
|
def test_present_once_the_token_appears(self, key, token):
|
||||||
|
assert _exists(key, _rep(f"{token}_{SAMPLE[token]}")) is True
|
||||||
|
|
||||||
|
def test_a_washer_with_only_the_indicator_gets_only_that_entity(self):
|
||||||
|
present = {
|
||||||
|
e.key
|
||||||
|
for e in washer.WASHER_COURSE.entities
|
||||||
|
if e.key in ENTITY_TOKENS and _exists(e.key, _rep("AddWashIndicator_On"))
|
||||||
|
}
|
||||||
|
assert present == {"add_wash_indicator"}
|
||||||
|
|
||||||
|
def test_pre_add_wash_washers_gain_nothing(self):
|
||||||
|
"""washer_device is a DA_WM_TP1_21 dump with no AddWash tokens."""
|
||||||
|
state = _flatten("washer")
|
||||||
|
assert not [key for key in state if key.startswith("add_wash")]
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgainstTheWW6500Dump:
|
||||||
|
"""DA_WM_A51_20_COMMON_WW6500, captured with the alarm off, the course
|
||||||
|
permitting all three moments and the lamp dark."""
|
||||||
|
|
||||||
|
def test_types_only_by_the_description_consumer_prefix(self):
|
||||||
|
"""A51 is not a board token, so the WW prefix in the description is
|
||||||
|
the only thing routing this device -- see the golden test's docstring.
|
||||||
|
Pinned here so the claim can't quietly stop being true."""
|
||||||
|
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||||
|
|
||||||
|
info = _load_device("washer_ww6500")["/information/vs/0"]
|
||||||
|
model = info["x.com.samsung.da.modelNum"]
|
||||||
|
reg = for_device_by_model(model, info["x.com.samsung.da.description"])
|
||||||
|
assert reg is not None and reg.name == "washer"
|
||||||
|
assert for_device_by_model(model, "") is None
|
||||||
|
|
||||||
|
def test_no_unbound_hrefs(self):
|
||||||
|
resources = _load_device("washer_ww6500")
|
||||||
|
reg = resolve(resources)
|
||||||
|
assert reg is not None
|
||||||
|
unbound = []
|
||||||
|
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||||
|
assert unbound == []
|
||||||
|
|
||||||
|
def test_reports_every_add_wash_entity(self):
|
||||||
|
state = _flatten("washer_ww6500")
|
||||||
|
assert {key for key in state if key.startswith("add_wash")} == {
|
||||||
|
"add_wash_alarm",
|
||||||
|
"add_wash_alarm_rinse",
|
||||||
|
"add_wash_alarm_final_rinse",
|
||||||
|
"add_wash_alarm_spin",
|
||||||
|
"add_wash_available",
|
||||||
|
"add_wash_indicator",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_alarm_off_course_permits_lamp_dark(self):
|
||||||
|
state = _flatten("washer_ww6500")
|
||||||
|
assert state["add_wash_alarm"] is False
|
||||||
|
assert all(state[f"add_wash_alarm_{m}"] is False for m in ("rinse", "final_rinse", "spin"))
|
||||||
|
assert state["add_wash_available"] is True
|
||||||
|
assert state["add_wash_indicator"] is False
|
||||||
Reference in new issue
Block a user