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