Compare commits

...
22 Commits
Author SHA1 Message Date
Marc Billow b5e25d72d3 Merge pull request #401 from vmonkey/feature/add-wash
Add AddWash controls and sensors for washers
2026-08-20 21:22:20 -05:00
Marek Tyburec 6c980f927b Address review: make the AddWash master switch's On idempotent
Home Assistant calls turn_on regardless of current state, and applying a
scene re-asserts every captured state, so asserting the alarm on over a
rinse-only AddWashSet_1 rewrote it to _7 -- silently widening the moments
the user picked, with no state change on this switch to point at it. The
write is now refused when the mask is already non-zero.

Distinct from the off-then-on path documented in _add_wash_bit_switch,
where the appliance has no subset left to keep.

Also satisfies the ty check on the new tests: resolve(),
for_device_by_model() and exists_fn are all optional, asserted the way
the other capability tests do.
2026-08-20 06:56:47 +02:00
Marc Billow 207dd2f830 Bump version to 0.24.0 2026-08-20 03:10:28 +00:00
Marc Billow b0caebe14d Merge pull request #391 from JayChickenK/observe-silent-href-subpolls
Keep asking the appliance for readings that never send live updates
2026-08-19 21:59:34 -05:00
Marc Billow 513d44e8be Merge pull request #403 from mbillow/claude/agents-triage-fixes-7vc6xl
laundry: add DV6800N's Table_00 dryer courses (issue #394)
2026-08-19 21:56:43 -05:00
Marc Billow 2a52cd9cfa laundry: add DV6800N's Table_00 dryer courses (issue #394)
A DV6800N (DA_WM_A51_20_COMMON) reports the same /st/dryercourse/vs/0
courseTable 'Table_00' as issue #357's DVE45R6300W/A3 -- also
DA_WM_A51_20_COMMON -- and its reporter listed 14 selectable courses
read straight off the appliance's own menu, in the same order
/course/vs/0's supportedOptions enumerates them.

Initially treated this as a second, incompatible Table_00 code family
and added a device-model-keyed disambiguation layer to
laundry.cycle_select. That was an unproven assumption: the two
reporters' confirmed sets are mostly non-overlapping subsets (11 vs 14
codes), which is exactly what you'd expect from two models on a shared
board exposing different subsets of one course table via their own
/course/vs/0 supportedOptions -- not evidence of two different code
dictionaries. The one code both reporters confirmed, 'a5', means
Bedding on both, which is corroborating, not neutral. No confirmed
code conflicts between the two sets, so this folds #394's 14 codes
straight into the existing dryer_cycle_table_00 catalog entry
(mirrored to all shipped languages), same shape as #357's original
translations-only change.

Adds a scrubbed fixture + golden for the DV6800N dump and tests
confirming zero unbound hrefs and that its course codes resolve
through the shared, now-larger dryer_cycle_table_00 catalog.
2026-08-20 02:53:16 +00:00
Marc Billow 16dfcd4e50 Merge pull request #390 from JayChickenK/air-purifier-co2
air_purifier: expose CO2 when the device lists it
2026-08-19 20:35:00 -05:00
JayChickenK 4edd5b7866 observe: assign fallback_hrefs under the notify lock
on_notification discards on the DTLS reader thread. Snapshotting
_notified then assigning fallback_hrefs after releasing the lock
let a notify in that window land on the set object being replaced,
so a just-pushed href was classified silent until its next notify.
2026-08-19 22:22:11 +02:00
Marek Tyburec 51bce3c8cf Address review: gate the AddWash master write on a readable mask
The master alarm switch wrote AddWashSet_7/_0 without consulting
_add_wash_mask, so a device reporting a wider mask (AddWashSet_15) or a
non-numeric one had it truncated to three bits -- the write that mask's
own docstring rules out, while the per-moment switches already refused it.

Also corrects the washer_ww6500 golden docstring: the fixture carries no
/oic/d and the test passes no device_types, so the device is typed solely
by the WW consumer prefix in its description. A51 is not a board token,
which makes that the fragile route worth naming.
2026-08-19 19:41:13 +02:00
JayChickenK 35f144a2d1 tests: cancel background subpolls before driving them directly
Setup already starts `_run_subpolls` as a background task. Patching
`_poll_hrefs_blocking` then captured its unfiltered hot/warm batches
alongside the silent-only ones the test asked for.
2026-08-19 18:37:54 +02:00
JayChickenK 43076352ae observe: drop fallback hrefs on late notify; skip empty subpolls
on_notification discards from fallback_hrefs so a late first push
(after the 80% quorum snapshot) self-corrects instead of staying on
the 3s GET cadence. Empty subpoll slots skip the session lock.
2026-08-19 18:33:22 +02:00
JayChickenK 171316bd95 air_purifier: share has_sensor_type and disable co2 by default
Hoist airconditioner._has_sensor_type to common.has_sensor_type next to
sensor_item_value, add the issue #127 stub-rep carve-out, and match the
AC family's enabled_default=False on the purifier CO2 entity.
2026-08-19 18:27:01 +02:00
Marc Billow 61d2b9e9ff Merge pull request #402 from mbillow/claude/offline-device-startup-64bjpt
Stop reconnecting a session a dark appliance never opened
2026-08-19 07:27:06 -05:00
Marc Billow 5ab0c9b5a8 Stop reconnecting a session a dark appliance never opened
A switched-off washer or dryer fails in the DTLS handshake, not in a
poll: `_poll_once` opens the session itself, so `_connect_session` runs
to its 12s timeout with nothing to show. The poll path treated that like
any other poll failure and ran its reconnect -- close the session, pause,
poll again -- but there is no session to close and no association for the
device to clean up, so the retry was the identical handshake five seconds
later. That cost 29s of every 30s interval, and the same again on every
setup attempt for an entry with no snapshot to load from.

`_poll_once` now records which of the two failed, and the poll path skips
the retry when the handshake is what never completed. A session that
opened and then broke still reconnects within the cycle.

The log was the half the reporters saw: an ERROR every cycle (plus a
WARNING once three "reconnects" piled up) for a state this integration is
built to sit through, which issue #269's reporter read as the integration
having failed. An outage now reports once, DEBUG for the cycles after it,
and INFO when the device answers again.

Fixes #269
2026-08-19 11:58:01 +00:00
Marek Tyburec 6003394cb2 Add AddWash controls and sensors for washers 2026-08-19 10:02:07 +02:00
Marc Billow 66b4f1400b Merge pull request #399 from mbillow/claude/issues-398-397-vkne4j
Dishwasher: translate Sanitizing progress stage, drop churning drum-clean sensor
2026-08-18 22:06:28 -05:00
Marc Billow c573a41483 Drop dishwasher's drum_clean_last_cleaned sensor (#398)
A live dump showed DrumCleanLog_'s newest entry moving every 30-90s on
its own, including well after a cycle had already finished -- it never
settles on a value worth showing. drum_clean_cycles_remaining, read from
separate WashingTimes_/DrumCleanProposal_ counters, is unaffected and
stays wired.

The washer/dryer readers this was originally built from (issues #9,
#258) aren't touched -- no report of the same churn there.
2026-08-19 02:58:46 +00:00
Marc Billow b5969773db Translate dishwasher's Sanitizing progress stage (#397)
The progress sensor's catalog had no entry for the "Sanitizing" stage
Samsung dishwashers report, so it fell back to the raw device code
instead of a translated label. Add "sanitizing" to the progress
state table in every shipped locale.
2026-08-19 02:58:32 +00:00
JayChickenK 9a45afe949 observe: keep silent hrefs on the sub-poll cadence
Issue #92: try_enter_observe_mode treated every subscribed href as
push-covered once SUCCESS_FRACTION cleared, including ones that never
notified. Those then skipped _run_subpolls for the rest of the session.
Record subscribed-but-silent hrefs on fallback_hrefs (idle in observe
mode) and keep just that set on the hot/warm cadence.
2026-08-18 11:19:29 +02:00
JayChickenK 296ba18cba tests: narrow exists_fn before calling it 2026-08-18 11:10:20 +02:00
JayChickenK 258cdc1647 tests: apply ruff format to CO2 assertions 2026-08-18 11:07:51 +02:00
JayChickenK 33a06b9ae8 air_purifier: expose CO2 when the device lists it
Issue #387's TP1X_DA-AC-AIR-class board reports a CO2 item on
/sensors/vs/0. Same field/shape air_monitor.SENSORS already models
(carbon_dioxide / ppm). Gated with exists_fn so boards that don't
list the type (every current fixture) don't grow an empty entity.
2026-08-18 11:04:25 +02:00
36 changed files with 1823 additions and 129 deletions
+83 -27
View File
@@ -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
+1 -1
View File
@@ -13,5 +13,5 @@
"pyOpenSSL>=23.0",
"smartthings-local>=0.1.8"
],
"version": "0.24.0-beta.1"
"version": "0.24.0"
}
+19 -1
View File
@@ -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"
},
+26
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
]
}
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
}
]
}
+106
View File
@@ -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:
+37
View File
@@ -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()
+106 -3
View File
@@ -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()
+2 -2
View File
@@ -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."""
+30
View File
@@ -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():
+12 -1
View File
@@ -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
+25
View File
@@ -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)
+10 -24
View File
@@ -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():
+61 -4
View File
@@ -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
+38
View File
@@ -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."""
+267
View File
@@ -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