Compare commits

...
Author SHA1 Message Date
Marc Billow 6d73ac8694 Bump version to 0.21.1 2026-08-13 02:31:48 +00:00
Marc Billow 1cfe126313 Merge pull request #360 from mbillow/claude/issue-357-5bsr88
laundry: add Table_00 cycle labels for WF45R6300 washer and DVE45R6300 dryer
2026-08-12 11:28:59 -04:00
Marc Billow 8d1ecb4f2a laundry: add Table_00 cycle labels for WF45R6300 washer and DVE45R6300 dryer
Adds washer_cycle_table_00 and dryer_cycle_table_00 translation catalog
entries, confirmed by the issue #357 reporter selecting each cycle on a
WF45R6300AW/US washer and DVE45R6300W/A3 dryer and reading back the raw
course code. Table_00 is a separate, older course-code family from the
existing Table_02/Table_03 catalogs -- laundry.cycle_select's table_href
scoping already keeps them apart, so this is a translations-only change.

Table_00 was previously used only as an example of an unconfirmed table in
tests; those now use Table_99 for that role, and new tests assert the
confirmed codes translate and that the resolved key routes to the new
table-scoped catalog entries.

Mirrored to all shipped languages (cs/de/es/it/ko/nl) to keep
tests/test_translations.py's key-for-key invariant.
2026-08-12 15:25:54 +00:00
Marc Billow 0c1231794a Merge pull request #359 from mbillow/claude/pr-346-regression-debug-a3x3pj
laundry: a post-Finish running stage is the cycle ending, not a new one
2026-08-12 10:43:47 -04:00
Marc Billow 67b28ed10f laundry: cite the machine_state history confirming #358's tail
The reporter's machine_state history for the same two cycles flips to
idle on the exact second progress reads 'Drying' (12:08:51 and 14:01:26),
which settles what the previous commits had to infer: rep_fn returns
'Idle' whenever state isn't active, so it cannot have produced that
value, and the only remaining path was the ungated sticky_live_fn the
bypass returned in its place. Replaying the sequence against the pre-fix
path reproduces the reported Cooling, Finish, Drying, Idle exactly; the
fix holds Finish through it.

Comments only -- swap the inference for the observation that confirms it.
2026-08-12 14:39:18 +00:00
Marc Billow f12f67b2b3 laundry: one hold per cycle, so the sticky bound is actually a bound
Review of the previous commit caught that its docstring promised more
than the code did. Not restarting an *open* window still let a progress
that flapped out of and back into Finish re-arm a full fresh window once
the first had expired, so the value could be held well past
sticky_seconds from the first Finish. The new test passed only because
its final read left the sticky condition matching; ending the flap on a
non-matching read re-armed and would have failed it.

Make the guarantee real instead of weakening the claim: arming marks the
hold spent, and only sticky_bypass_fn -- a cycle actually running --
clears it. Expiry on its own no longer re-opens the door, because with no
cycle in between a second Finish is the same Finish, and re-arming on it
strobes the entity Finish -> Idle -> Finish once per window, re-firing
the announcements #345 and #358 are both about.

That subsumes the old _sticky_armed edge-trigger flag, which existed to
stop a stuck field extending the window; "spent until a new cycle" covers
that case and the flap case together, before or after expiry.

Also give the flap test real headroom -- it fitted 0.09s of sleeps into a
0.1s window and would have failed spuriously on a loaded runner.
2026-08-12 14:18:56 +00:00
Marc Billow 2f7170c448 laundry: a post-Finish running stage is the cycle ending, not a new one
Fixes #358, a regression from #346. That PR's sticky_bypass_fn released
the Finish/100 hold on any concrete non-Finish progress code, ungated on
machine_state, reasoning that a new cycle's own progress can appear
before state catches up. But the reporting DA_WM_TP1_21_COMMON dryer
replays a running stage on the way *out* of a cycle: the issue's history
shows Cooling -> +60s Finish -> +24s 'Drying' -> +4s settled, twice,
identically. The bypass read that tail as a new cycle, dropped the hold,
and republished 'Drying' -- so progress read Drying, Cooling, Finish,
Drying, Idle instead of ending at Finish, Idle.

The tail is not new: rep_fn has always masked progress while state isn't
active, which is why it was invisible before #346. What surfaced it was
sticky_live_fn, a second, ungated view of the same field that the bypass
returned in rep_fn's place -- letting the hold publish a value the entity
otherwise never shows.

Both halves are fixed:

- The bypass (now _new_cycle_running) requires state == 'active'
  alongside the progress code. The arm condition stays ungated -- failing
  to arm loses the Finish entirely (#345), while releasing late costs
  nothing, since the hold expires on its own.
- sticky_live_fn is gone. rep_fn is the only definition of a live value;
  the hold decides only whether to freeze, and the bypass returns rep_fn's
  own result.

Also stop an already-open window from being restarted by a progress that
flaps in and out of Finish, so sticky_seconds is measured from the first
Finish of a cycle and the documented bound actually holds.

A paused new cycle no longer cuts the hold short (it did under the old
ungated bypass). Nothing live is withheld by that: rep_fn shows Idle
while paused with or without a hold, so the only change is a stale Finish
expiring on schedule -- and 'paused' cannot be told apart from this tail.
2026-08-12 13:44:24 +00:00
Marc Billow b8ef430ed4 Merge pull request #356 from mbillow/claude/alert-read-action-guidance-2lwu5a
Fix stuck alarm_code: never merge /alarms/vs/0 onto stale cache
2026-08-11 22:43:23 -04:00
Marc Billow cd3a47f9a4 Fix stuck alarm_code: never merge /alarms/vs/0 onto stale cache (#348)
ObserveManager.apply() shallow-merges every incoming rep onto whatever's
already cached for that href (issue #27's fix for /mode/vs/0's partial
notifies). That assumes an absent key always means "unchanged, keep the
old value" -- true for /mode/vs/0's supportedOptions, but backwards for
/alarms/vs/0: entity.py already documents {} as this resource's
canonical no-alarm state, and a live read_resource GET on the reporter's
washer confirmed the board sends exactly that {} when an alarm clears.
Merging it onto the prior rep left the stale ErrorCode_DC entry in the
cache forever, surviving power cycles and only clearing on a full
integration reload (which rebuilds the cache from scratch instead of
merging).

Add _is_alarms_href() to recognize /alarms/vs/<index> across every
subdevice-translated shape (MAIN identity, indexed renumbering, prefixed
UUID -- Subdevice.to_actual never touches the 'alarms/vs' stem) and have
apply() fully replace the cache for that href instead of merging. This
is a global fix: every family with an alarm sensor shares this href
(common.ALARMS, range_hood's own copy), so they were all exposed.
2026-08-12 02:41:02 +00:00
Marc Billow ac995dcbd4 Revert version to 0.21.0
0.21.0 was already bumped by #334 but never released; 0.22.0 double-bumped
past it. This release covers everything merged since v0.20.0 and should
just be 0.21.0.
2026-08-10 16:44:23 +00:00
Marc Billow d80bd550fe Merge pull request #351 from mbillow/claude/triage-version-bump-x60dym
water_purifier, range: drop invalid device_class='lock' from switches
2026-08-10 12:37:38 -04:00
20 changed files with 561 additions and 93 deletions
+1 -1
View File
@@ -12,5 +12,5 @@
"pyOpenSSL>=23.0",
"smartthings-local>=0.1.2"
],
"version": "0.22.0"
"version": "0.21.1"
}
+35 -1
View File
@@ -56,6 +56,26 @@ SUCCESS_FRACTION = 0.8
PUSH_HEALTH_WINDOW_S = 60.0
def _is_alarms_href(href: str) -> bool:
"""True for /alarms/vs/<index> in any subdevice-translated shape --
the canonical MAIN form (/alarms/vs/0), an indexed subdevice's
renumbered instance (/alarms/vs/<key>), or a prefixed subdevice's
UUID-qualified form (/<uuid>/alarms/vs/0). `Subdevice.to_actual`
(registry/subdevices.py) only ever rewrites the trailing index
segment or prepends a prefix -- it never touches the 'alarms/vs'
stem -- so matching that fixed segment plus a wildcard tail catches
every shape without this module needing to be subdevice-aware.
See `ObserveManager.apply`'s use of this for why the href matters:
unlike most resources, /alarms/vs/0's `x.com.samsung.da.items` array
is a complete snapshot of every currently-active alarm, not a
possibly-partial field update -- so it must never be merged onto a
stale prior rep (issue #348).
"""
head, _, _ = href.rpartition("/")
return head.endswith("/alarms/vs")
class ObserveManager:
"""Per-device observe-mode state: mode, write-settle guard, and (later)
subscription/staleness tracking. Pure sync logic — safe to call from
@@ -122,6 +142,20 @@ class ObserveManager:
comes through, even though nothing about the device's actual
supported options changed.
`_is_alarms_href` is the one exception to that merge (issue #348):
/alarms/vs/0's `items` array is always sent as a complete
snapshot of every currently-active alarm, never a partial delta
-- confirmed by a live `read_resource` GET returning `{}` (no
`items` key at all) the moment a washer's board actually clears
an alarm, which entity.py already documents as this resource's
normal no-alarm shape. Merging that `{}` onto the prior rep the
same way as everywhere else silently kept the stale `items`
entry forever: an absent key merges as "unchanged" everywhere
else, but on this href absent specifically means "cleared".
Every family that exposes an alarm sensor shares this href
(common.ALARMS, range_hood's own copy), so this is a full
replace for all of them, not a washer-specific carve-out.
`apply()` is the sole path StateCache mutations flow through in
this component (poll, sweep, and OBSERVE notify all funnel here),
so `_cache_lock` serializes the read-then-write across those
@@ -149,7 +183,7 @@ class ObserveManager:
self.log.debug("dropping %s update for %s (settling)", source, href)
return False
with self._cache_lock:
merged = {**(self.cache.get(href) or {}), **rep}
merged = dict(rep) if _is_alarms_href(href) else {**(self.cache.get(href) or {}), **rep}
changed = self.cache.apply_rep(href, merged, source=source)
# Outside the cache lock -- the hook takes locks of its own and
# never reads the cache back. `source` is passed along rather than
@@ -46,6 +46,13 @@ DRYER_SETTINGS = Capability(
# (issue #244). /st/dryercourse/vs/0 re-encodes the same selected course
# and is ignored (ignored.py), mirroring /st/washercourse/vs/0 for washers.
#
# dryer_cycle_table_00 is a separate, older course-code family reported by
# a DVE45R6300W/A3 (issue #357), confirmed the same way: the reporter
# selected each cycle on the appliance and read back the resulting raw
# 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).
#
# Drum Clean+ maintenance tracking (issue #258) reuses washer.py's
# DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens on this same
# options[] array -- see laundry.drum_clean_cycles_remaining/
@@ -62,17 +62,21 @@ def _just_finished(rep):
return rep.get("x.com.samsung.da.progress") == "Finish"
def _live_progress_code(rep):
"""progress/progress_percentage's sticky_bypass_fn: a concrete,
non-Finish progress code being reported right now -- e.g. a new
cycle's own real 'Wash'/'Spin' -- must win over a still-open hold from
the previous cycle immediately. Not keyed on `state` (unlike
_is_active): _just_finished's whole premise is that `state` can't be
trusted to still say 'active' while a fresh, real progress value is
already there, and the same applies to recognizing when it's moved on
to a new one -- including while paused, e.g. adding a sock mid-hold."""
def _new_cycle_running(rep):
"""progress/progress_percentage's sticky_bypass_fn: drop the #345 hold
early once a new cycle is genuinely running.
Gated on `state == 'active'`, unlike _just_finished's arm condition
above: issue #358's dryer resets `progress` to its course's first
stage ('Drying') in the same moment `state` goes idle, ~4s before
settling to 'None' -- confirmed by the reporter's machine_state
history, which flips to idle on the exact second progress reads
'Drying', in both captured cycles. A bypass keyed on the progress
code alone read that as a new cycle and republished it. Releasing
late costs nothing -- an unreleased hold still expires on its own --
so this side takes the stronger signal."""
v = rep.get("x.com.samsung.da.progress")
return v is not None and v not in ("None", "Finish")
return _state_is_active(rep) and v is not None and v not in ("None", "Finish")
def _remaining_seconds(raw):
@@ -190,14 +194,10 @@ OPERATIONAL_STATE = Capability(
# sticky_* (issue #345): once progress reads 'Finish', keep
# showing Finish/100 for a grace window even after machine_state
# reverts, rather than falling to Idle/0 the instant it does --
# see sensor.py's _apply_sticky. rep_fn below is otherwise
# unchanged; the hold is entirely a read-side, per-entity concern,
# deliberately not gated on machine_state (_just_finished's
# docstring explains why). sticky_live_fn reads the raw field the
# same ungated way, for sticky_bypass_fn's benefit: a real
# progress value reported while paused (e.g. adding a sock
# mid-cycle) must win over a stale hold even though rep_fn itself
# would show "Idle"/0 there.
# see sensor.py's _apply_sticky. rep_fn below is unchanged and
# stays the only definition of a live value -- the hold decides
# only *whether* to freeze. A second, ungated one here is what
# let issue #358's post-Finish tail reach the entity.
SensorDesc(
key="progress",
icon="mdi:progress-wrench",
@@ -208,8 +208,7 @@ OPERATIONAL_STATE = Capability(
),
sticky_fn=_just_finished,
sticky_value_fn=lambda rep: "Finish",
sticky_live_fn=lambda rep: _progress(rep.get("x.com.samsung.da.progress")),
sticky_bypass_fn=_live_progress_code,
sticky_bypass_fn=_new_cycle_running,
),
SensorDesc(
key="progress_percentage",
@@ -222,8 +221,7 @@ OPERATIONAL_STATE = Capability(
),
sticky_fn=_just_finished,
sticky_value_fn=lambda rep: 100,
sticky_live_fn=lambda rep: _int(rep.get("x.com.samsung.da.progressPercentage")) or 0,
sticky_bypass_fn=_live_progress_code,
sticky_bypass_fn=_new_cycle_running,
),
# Only show finish time while actively running -- firmware leaves a
# stale remainingTime after a cycle ends, frozen at '00:01:00'.
@@ -58,6 +58,15 @@ from .laundry import (
# the owner or device metadata falls back to washer_cycle_fallback, which
# surfaces a personal-course name only -- no invented English label for an
# unrecognized standard code (PR #251 review).
#
# washer_cycle_table_00 (issue #357) is a separate, older course-code family
# reported by a WF45R6300AW/US -- confirmed by the reporter selecting each
# cycle on the appliance and reading back the raw code, the same method used
# for Table_02's WF50A8600AV/US codes above. A device reporting Table_00 with
# an unconfirmed code (FlexWash's washer_flexwash_device fixture, for
# instance) still renders that code raw rather than borrowing a Table_02
# label -- the two tables are unrelated code spaces despite a handful of
# overlapping hex values.
# ---------------------------------------------------------------------------
# /washer/vs/0 -- wash temperature, spin speed, rinse cycle count.
@@ -65,15 +65,15 @@ class SensorDesc(SamsungEntityDescription):
# device-side revisions -- not a general-purpose flag.
hysteresis: bool = False
# Opt-in, entity-instance-only hold -- see sensor.py's _apply_sticky
# for the full contract (arm/value/live/bypass semantics,
# edge-triggering, why this never touches the coordinator cache).
# for the full contract (arm/value/bypass semantics, one window per
# bypass, why this never touches the coordinator cache).
# sticky_fn arms it; sticky_value_fn picks what to freeze at that
# moment (defaults to rep_fn's own result); sticky_bypass_fn forces
# sticky_live_fn's result through and drops the hold; sticky_seconds
# bounds how long it can hold.
# moment (defaults to rep_fn's own result); sticky_bypass_fn drops the
# hold and lets rep_fn's own live result through; sticky_seconds
# bounds how long it can hold. There is deliberately no hook for
# computing a live value differently from rep_fn -- see issue #358.
sticky_fn: Callable[[dict], bool] | None = None
sticky_value_fn: Callable[[dict], Any] | None = None
sticky_live_fn: Callable[[dict], Any] | None = None
sticky_bypass_fn: Callable[[dict], bool] | None = None
sticky_seconds: float = 300.0
+32 -35
View File
@@ -54,7 +54,7 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
self._hysteresis_value = None
self._sticky_value = None
self._sticky_until: float | None = None
self._sticky_armed = False
self._sticky_spent = False
@property
def native_unit_of_measurement(self):
@@ -82,52 +82,49 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
cache, so write_fn, diagnostics, and the observe-mode sweep
comparison keep seeing real device data throughout.
Reads `sticky_fn` and friends against this href's own live rep,
not the already-computed `raw`, so they can be independent of
whatever rep_fn itself gates on -- notably `sticky_live_fn`, which
is *not* `raw`: `raw` is rep_fn's own (possibly differently gated)
result, e.g. progress's rep_fn shows "Idle" while paused, but a
real progress value reported while paused (adding a sock mid-
cycle) must still win over a stale hold, which means reading it
ungated here rather than through that gate.
`sticky_fn`/`sticky_bypass_fn` read this href's live rep rather
than the already-computed `raw`, so they can key on fields rep_fn
has collapsed away -- but they never compute a value. `raw` and
the frozen `sticky_value` are the only things returned here, so a
held entity and a free-running one agree on what "live" means; a
hook that broke that rule caused issue #358.
Edge-triggered, not level-triggered: the window only (re)starts on
a fresh False->True transition of `sticky_fn`, and -- this is the
part level-triggering alone misses -- expiry is still checked on
every call even while `sticky_fn` keeps matching. Without the
latter, a firmware that leaves the underlying field stuck matching
forever (the same class of quirk `_completion_minutes` already
works around) would show the frozen value forever too, defeating
"bounded, not indefinite".
At most one window per `sticky_bypass_fn` cycle: arming marks the
hold spent, and only the bypass clears that. So a `sticky_fn` that
keeps matching (firmware leaving the field stuck -- the quirk
`_completion_minutes` works around) can't extend the window, and
one flapping in and out can't restart it either, before or after
expiry. Expiry alone doesn't re-open the door: without something
the calibre of "a new cycle is actually running" in between, a
second Finish is the same Finish, and re-arming on it would strobe
the entity between held and live once per window -- exactly the
repeated announcements #345 and #358 are about.
`sticky_bypass_fn`, when it matches, always passes
`sticky_live_fn`'s result through and drops any hold -- for a
condition where "not sticky right now" is ambiguous between "went
idle, honor the hold" and "genuinely live, different data" (e.g. a
new cycle's own real progress), which "consult the hold whenever
sticky_fn is False" alone can't tell apart.
`sticky_bypass_fn` drops the hold and returns `raw`, for when "not
sticky right now" is ambiguous between "went idle, honor the hold"
and "genuinely moved on to new data". It is both the early release
and the only re-arm, so it should demand positive evidence of that
move; when unsure, letting the window run out is the cheaper
mistake.
"""
assert desc.sticky_fn is not None # native_value only calls this when set
rep = self.coordinator.resource(self._bound.href)
now = time.monotonic()
matches = desc.sticky_fn(rep)
if matches:
if not self._sticky_armed:
if desc.sticky_fn(rep):
if not self._sticky_spent:
self._sticky_value = (
desc.sticky_value_fn(rep) if desc.sticky_value_fn is not None else raw
)
self._sticky_until = now + desc.sticky_seconds
self._sticky_armed = True
else:
self._sticky_armed = False
if desc.sticky_bypass_fn is not None and desc.sticky_bypass_fn(rep):
self._sticky_until = None
return desc.sticky_live_fn(rep) if desc.sticky_live_fn is not None else raw
self._sticky_spent = True
elif desc.sticky_bypass_fn is not None and desc.sticky_bypass_fn(rep):
self._sticky_until = None
self._sticky_spent = False
return raw
if self._sticky_until is not None and now < self._sticky_until:
return self._sticky_value
return raw
holding = self._sticky_until is not None and now < self._sticky_until
return self._sticky_value if holding else raw
def _apply_hysteresis(self, raw):
"""Hold the last value this entity actually reported until a new one
@@ -324,6 +324,22 @@
"4": "Alarm 4"
}
},
"dryer_cycle_table_00": {
"name": "Cyklus",
"state": {
"01": "Normální",
"9c": "Intenzivní",
"a5": "Ložní prádlo",
"9e": "Nežehlivé prádlo",
"9b": "Parní dezinfekce+",
"27": "Osvěžení",
"a0": "Provětrání",
"a4": "Časové sušení",
"a6": "Rychlé sušení",
"a3": "Sportovní oblečení",
"a2": "Jemné prádlo"
}
},
"dryer_cycle_table_03": {
"name": "Cyklus",
"state": {
@@ -604,6 +620,22 @@
"extra_hot": "Extra horká"
}
},
"washer_cycle_table_00": {
"name": "Cyklus",
"state": {
"01": "Normální",
"70": "Intenzivní",
"55": "Bílé prádlo",
"71": "Ložní prádlo",
"72": "Dezinfekce",
"77": "Nežehlivé prádlo",
"57": "Samočištění+",
"73": "Máchání + odstřeďování",
"74": "Sportovní oblečení",
"75": "Jemné prádlo",
"78": "Rychlé praní"
}
},
"washer_cycle_table_02": {
"name": "Cyklus",
"state": {
@@ -324,6 +324,22 @@
"4": "Alarm 4"
}
},
"dryer_cycle_table_00": {
"name": "Programm",
"state": {
"01": "Normal",
"9c": "Intensiv",
"a5": "Bettwäsche",
"9e": "Pflegeleicht",
"9b": "Dampf-Hygiene+",
"27": "Auffrischen",
"a0": "Lüften",
"a4": "Zeittrocknen",
"a6": "Schnelltrocknen",
"a3": "Sportkleidung",
"a2": "Feinwäsche"
}
},
"dryer_cycle_table_03": {
"name": "Programm",
"state": {
@@ -567,6 +583,22 @@
"extra_hot": "Extra heiß"
}
},
"washer_cycle_table_00": {
"name": "Programm",
"state": {
"01": "Normal",
"70": "Intensiv",
"55": "Weißwäsche",
"71": "Bettwäsche",
"72": "Hygienespülung",
"77": "Pflegeleicht",
"57": "Selbstreinigung+",
"73": "Spülen + Schleudern",
"74": "Sportkleidung",
"75": "Feinwäsche",
"78": "Schnellwäsche"
}
},
"washer_cycle_table_02": {
"name": "Programm",
"state": {
@@ -324,6 +324,22 @@
"4": "Alarm 4"
}
},
"dryer_cycle_table_00": {
"name": "Cycle",
"state": {
"01": "Normal",
"9c": "Heavy Duty",
"a5": "Bedding",
"9e": "Perm Press",
"9b": "Steam Sanitize+",
"27": "Refresh",
"a0": "Air Fluff",
"a4": "Time Dry",
"a6": "Quick Dry",
"a3": "Active Wear",
"a2": "Delicates"
}
},
"dryer_cycle_table_03": {
"name": "Cycle",
"state": {
@@ -604,6 +620,22 @@
"extra_hot": "Extra hot"
}
},
"washer_cycle_table_00": {
"name": "Cycle",
"state": {
"01": "Normal",
"70": "Heavy Duty",
"55": "Whites",
"71": "Bedding",
"72": "Sanitize",
"77": "Perm Press",
"57": "Self Clean+",
"73": "Rinse + Spin",
"74": "Active Wear",
"75": "Delicates",
"78": "Quick Wash"
}
},
"washer_cycle_table_02": {
"name": "Cycle",
"state": {
@@ -517,6 +517,22 @@
"4": "Alarma 4"
}
},
"dryer_cycle_table_00": {
"name": "Ciclo",
"state": {
"01": "Normal",
"9c": "Servicio intensivo",
"a5": "Ropa de cama",
"9e": "Planchado fácil",
"9b": "Desinfección por vapor+",
"27": "Renovar",
"a0": "Aireación",
"a4": "Secado por tiempo",
"a6": "Secado rápido",
"a3": "Ropa deportiva",
"a2": "Delicados"
}
},
"dryer_cycle_table_03": {
"name": "Ciclo",
"state": {
@@ -797,6 +813,22 @@
"extra_hot": "Muy caliente"
}
},
"washer_cycle_table_00": {
"name": "Ciclo",
"state": {
"01": "Normal",
"70": "Servicio intensivo",
"55": "Blancos",
"71": "Ropa de cama",
"72": "Desinfección",
"77": "Planchado fácil",
"57": "Autolimpieza+",
"73": "Aclarar + Centrifugar",
"74": "Ropa deportiva",
"75": "Delicados",
"78": "Lavado rápido"
}
},
"washer_cycle_table_02": {
"name": "Ciclo",
"state": {
@@ -324,6 +324,22 @@
"4": "Allarme 4"
}
},
"dryer_cycle_table_00": {
"name": "Ciclo",
"state": {
"01": "Normale",
"9c": "Intenso",
"a5": "Biancheria da letto",
"9e": "Pronto da stirare",
"9b": "Igienizzante a vapore+",
"27": "Rinfresca",
"a0": "Arieggiatura",
"a4": "Asciugatura a tempo",
"a6": "Asciugatura rapida",
"a3": "Abbigliamento sportivo",
"a2": "Delicati"
}
},
"dryer_cycle_table_03": {
"name": "Ciclo",
"state": {
@@ -604,6 +620,22 @@
"extra_hot": "Extra caldo"
}
},
"washer_cycle_table_00": {
"name": "Ciclo",
"state": {
"01": "Normale",
"70": "Intenso",
"55": "Bianchi",
"71": "Biancheria da letto",
"72": "Igienizzante",
"77": "Pronto da stirare",
"57": "Self Clean+",
"73": "Risciacquo+Centrifuga",
"74": "Abbigliamento sportivo",
"75": "Delicati",
"78": "Lavaggio rapido"
}
},
"washer_cycle_table_02": {
"name": "Ciclo",
"state": {
@@ -324,6 +324,22 @@
"4": "알림음 4"
}
},
"dryer_cycle_table_00": {
"name": "코스",
"state": {
"01": "표준건조",
"9c": "강력건조",
"a5": "이불",
"9e": "구김방지",
"9b": "스팀살균+",
"27": "리프레시",
"a0": "송풍",
"a4": "시간건조",
"a6": "쾌속건조",
"a3": "운동복",
"a2": "섬세의류"
}
},
"dryer_cycle_table_03": {
"name": "코스",
"state": {
@@ -604,6 +620,22 @@
"extra_hot": "고온수"
}
},
"washer_cycle_table_00": {
"name": "코스",
"state": {
"01": "표준세탁",
"70": "강력세탁",
"55": "흰옷",
"71": "이불",
"72": "살균",
"77": "구김방지",
"57": "통세척+",
"73": "헹굼+탈수",
"74": "운동복",
"75": "섬세의류",
"78": "쾌속세탁"
}
},
"washer_cycle_table_02": {
"name": "코스",
"state": {
@@ -324,6 +324,22 @@
"4": "Alarm 4"
}
},
"dryer_cycle_table_00": {
"name": "Programma",
"state": {
"01": "Normaal",
"9c": "Intensief",
"a5": "Beddengoed",
"9e": "Strijkvrij",
"9b": "Stoomhygiëne+",
"27": "Opfrissen",
"a0": "Luchtdrogen",
"a4": "Tijdprogramma",
"a6": "Snel drogen",
"a3": "Sportkleding",
"a2": "Fijne was"
}
},
"dryer_cycle_table_03": {
"name": "Programma",
"state": {
@@ -604,6 +620,22 @@
"extra_hot": "Extra heet"
}
},
"washer_cycle_table_00": {
"name": "Programma",
"state": {
"01": "Normaal",
"70": "Intensief",
"55": "Witte was",
"71": "Beddengoed",
"72": "Hygiëne",
"77": "Strijkvrij",
"57": "Self Clean+",
"73": "Spoelen + centrifugeren",
"74": "Sportkleding",
"75": "Fijne was",
"78": "Snelle was"
}
},
"washer_cycle_table_02": {
"name": "Programma",
"state": {
+43
View File
@@ -58,6 +58,49 @@ def test_apply_merges_partial_update_onto_prior_rep():
assert cached["x.com.samsung.da.supportedOptions"] == ["CV_FDR_WINE", "CV_FDR_MEAT"]
def test_apply_fully_replaces_alarms_href_instead_of_merging():
"""Regression test for issue #348: /alarms/vs/0's `items` array is a
complete snapshot of every currently-active alarm, not a partial field
update like /mode/vs/0 (issue #27). A washer's board reports a cleared
alarm by omitting `items` entirely -- a live read_resource GET showed
`{}` -- so merging that onto the prior rep (as every other href does)
left the stale ErrorCode_DC entry in the cache forever. This must
instead behave like a full replace: the empty rep wins outright."""
mgr = _manager()
active = {
"x.com.samsung.da.items": [
{"x.com.samsung.da.code": "ErrorCode_DC", "x.com.samsung.da.state": "Created"}
]
}
mgr.apply("/alarms/vs/0", active, source="poll")
assert mgr.cache.get("/alarms/vs/0") == active
cleared = mgr.apply("/alarms/vs/0", {}, source="poll")
assert cleared is True
assert mgr.cache.get("/alarms/vs/0") == {}
def test_apply_fully_replaces_alarms_href_for_subdevice_shapes():
"""The same full-replace behavior must hold for both hrefs
`Subdevice.to_actual` can produce: an indexed subdevice renumbers only
the trailing '0' (/alarms/vs/1), and a prefixed one prepends a UUID
(/<uuid>/alarms/vs/0) -- neither ever touches the 'alarms/vs' stem
itself (registry/subdevices.py)."""
for href in ("/alarms/vs/1", "/6c2dff6d-ee5c-dad1-6a5e-000000000001/alarms/vs/0"):
mgr = _manager()
mgr.apply(
href,
{"x.com.samsung.da.items": [{"x.com.samsung.da.code": "ErrorCode_UB"}]},
source="poll",
)
cleared = mgr.apply(href, {}, source="poll")
assert cleared is True
assert mgr.cache.get(href) == {}
def test_apply_drops_update_during_settle_window():
mgr = _manager()
mgr.cache.apply_rep("/oven/vs/0", {"a": 1}, source="seed")
+14
View File
@@ -89,6 +89,20 @@ def test_course_bound_to_shared_course_vs_0():
assert desc.rep_fn(rep) == "16"
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)."""
from custom_components.localthings.catalog import translated_states
desc = next(
e for e in dryer.DRYER_COURSE.entities if e.key == "cycle" and isinstance(e, SelectDesc)
)
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"}
assert confirmed <= translated_states("select", "dryer_cycle_table_00")
def test_st_dryercourse_is_ignored():
"""/st/dryercourse/vs/0 re-encodes the course exposed via /course/vs/0 and
is globally ignored -- the mirror of /st/washercourse/vs/0."""
+1 -1
View File
@@ -369,7 +369,7 @@ class TestCycleSelectTableGating:
def test_untranslated_table_uses_generic_cycle_key(self):
"""An unknown table does not claim another board's state labels."""
desc = self._desc()
resources = {"/st/washercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_00"}}
resources = {"/st/washercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_99"}}
assert desc.translation_key(resources) == "cycle"
def test_resolves_to_generic_cycle_when_table_id_is_unknown(self):
+33 -14
View File
@@ -3,7 +3,7 @@
from custom_components.localthings.registry.capabilities.operational import (
OPERATIONAL_STATE,
_just_finished,
_live_progress_code,
_new_cycle_running,
)
from custom_components.localthings.registry.entities import NumberDesc
@@ -41,27 +41,46 @@ class TestJustFinished:
assert not _just_finished({"x.com.samsung.da.state": "Run"})
class TestLiveProgressCode:
"""`_live_progress_code` is progress/progress_percentage's
sticky_bypass_fn (issue #345) -- see sensor.py's _apply_sticky."""
class TestNewCycleRunning:
"""`_new_cycle_running` is progress/progress_percentage's
sticky_bypass_fn -- the early-release condition for the #345 hold.
See sensor.py's _apply_sticky."""
def test_true_for_a_concrete_non_finish_code(self):
assert _live_progress_code({"x.com.samsung.da.progress": "Wash"})
def test_true_for_a_concrete_non_finish_code_while_active(self):
assert _new_cycle_running(
{"x.com.samsung.da.state": "Run", "x.com.samsung.da.progress": "Wash"}
)
def test_true_regardless_of_state(self):
"""Not gated on machine_state -- a new cycle's own real progress
must win over a held hold even while paused (e.g. adding a sock
mid-hold), not just while actively running."""
assert _live_progress_code(
def test_false_for_a_running_stage_reported_after_state_left_active(self):
"""Issue #358, the whole reason for the `state` gate: the reporting
dryer replays a running stage ('Drying', its first supportedProgress
entry) for a few seconds after Finish while winding down. That is
the finished cycle's tail, not a new cycle -- releasing the hold on
it is what produced 'Drying, Cooling, Finish, Drying, Idle'."""
assert not _new_cycle_running(
{"x.com.samsung.da.state": "Ready", "x.com.samsung.da.progress": "Drying"}
)
def test_false_while_paused(self):
"""Paused is not evidence a new cycle is running, and the tail
above can't be told apart from it. rep_fn shows 'Idle' whenever
state isn't active anyway, so there is no live value being
withheld here -- only a hold that expires on its own instead of
being released early."""
assert not _new_cycle_running(
{"x.com.samsung.da.state": "Pause", "x.com.samsung.da.progress": "Wash"}
)
def test_false_for_finish(self):
assert not _live_progress_code({"x.com.samsung.da.progress": "Finish"})
assert not _new_cycle_running(
{"x.com.samsung.da.state": "Run", "x.com.samsung.da.progress": "Finish"}
)
def test_false_when_absent_or_none(self):
assert not _live_progress_code({})
assert not _live_progress_code({"x.com.samsung.da.progress": "None"})
assert not _new_cycle_running({})
assert not _new_cycle_running(
{"x.com.samsung.da.state": "Run", "x.com.samsung.da.progress": "None"}
)
class TestProgressPercentage:
+120 -8
View File
@@ -165,22 +165,134 @@ def test_a_new_cycle_starting_overrides_the_hold():
assert sensor.native_value == "Wash"
def test_a_paused_new_cycle_also_overrides_the_hold():
"""Not just an actively-running new cycle: adding a sock and pausing
mid-cycle must also show the real, current progress rather than a
stale hold from the previous cycle -- machine_state isn't 'active'
while paused, so a bypass keyed on that alone would miss this."""
sensor, coordinator = _sensor(_PROGRESS_DESC)
def test_a_running_stage_after_finish_does_not_break_the_hold():
"""Issue #358: the reporting dryer resets `progress` to its course's
first stage in the same moment `state` goes idle -- observed twice,
identically, as Cooling -> +60s Finish -> +24s 'Drying' -> +4s
settled, with the reporter's machine_state history flipping to idle on
the exact second progress reads 'Drying'. The hold must survive that,
so the cycle still reads Drying, Cooling, Finish, Idle rather than the
reported Drying, Cooling, Finish, Drying, Idle."""
desc = replace(_PROGRESS_DESC, sticky_seconds=0.2)
sensor, coordinator = _sensor(desc)
_replace(coordinator, state="Run", progress="Drying", progressPercentage="40")
assert sensor.native_value == "Drying"
_replace(coordinator, state="Run", progress="Cooling", progressPercentage="95")
assert sensor.native_value == "Cooling"
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
assert sensor.native_value == "Finish"
# The tail: a running stage again, state already idle.
_replace(coordinator, state="Ready", progress="Drying", progressPercentage="100")
assert sensor.native_value == "Finish"
# ...then the device settles, still inside the window.
_replace(coordinator, state="Ready", progress="None")
assert sensor.native_value == "Finish"
time.sleep(0.25)
assert sensor.native_value == "Idle"
def test_progress_percentage_survives_the_same_tail():
"""#358's tail hits progress_percentage through the identical bypass;
it must stay pinned at 100 rather than being released back to a raw
mid-cycle figure."""
desc = replace(_PROGRESS_PERCENTAGE_DESC, sticky_seconds=0.2)
sensor, coordinator = _sensor(desc)
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
assert sensor.native_value == 100
_replace(coordinator, state="Ready", progress="Drying", progressPercentage="40")
assert sensor.native_value == 100
time.sleep(0.25)
assert sensor.native_value == 0
def test_a_paused_new_cycle_is_left_to_the_window_rather_than_released():
"""'Paused' isn't positive evidence of a new cycle, and #358's tail is
indistinguishable from it. Nothing live is withheld by waiting --
rep_fn shows 'Idle' while paused with or without a hold -- so the
stale Finish just expires on schedule instead of being cut short."""
desc = replace(_PROGRESS_DESC, sticky_seconds=0.05)
sensor, coordinator = _sensor(desc)
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
assert sensor.native_value == "Finish"
_replace(coordinator, state="Ready")
assert sensor.native_value == "Finish" # still held
_replace(coordinator, state="Pause", progress="Wash")
assert sensor.native_value == "Finish" # held out, not released
time.sleep(0.1)
assert sensor.native_value == "Idle" # what a paused appliance always shows
_replace(coordinator, state="Run", progress="Wash")
assert sensor.native_value == "Wash"
def test_a_flapping_finish_cannot_ratchet_an_open_window_forward():
"""Edge-triggering stops a *stuck* Finish from extending the hold; a
progress that flaps out of and back into Finish must not restart it
either, or the bound stops being a bound."""
desc = replace(_PROGRESS_DESC, sticky_seconds=0.3)
sensor, coordinator = _sensor(desc)
_replace(coordinator, state="Ready", progress="Finish")
assert sensor.native_value == "Finish"
for _ in range(3):
time.sleep(0.05)
_replace(coordinator, state="Ready", progress="None")
assert sensor.native_value == "Finish"
_replace(coordinator, state="Ready", progress="Finish")
assert sensor.native_value == "Finish"
# 0.15s of flapping so far -- the window still ends 0.3s after the
# first Finish, not 0.3s after the most recent re-entry.
time.sleep(0.2)
_replace(coordinator, state="Ready", progress="Finish")
assert sensor.native_value == "Idle"
def test_a_finish_after_the_window_closes_does_not_re_arm_it():
"""Expiry doesn't re-open the door: with no new cycle in between, a
second Finish is the same Finish. Re-arming on it would strobe the
entity Finish -> Idle -> Finish once per window, re-firing exactly the
announcements #345 is about -- so it takes a bypass (a cycle actually
running) to make the hold available again.
Distinct from the flap test above: there the window is still open, and
the last read before expiry leaves the sticky condition *matching*.
Here it has already closed, and the flap ends on a non-matching read,
which is the state a spent-on-arm-only guard would let re-arm."""
desc = replace(_PROGRESS_DESC, sticky_seconds=0.05)
sensor, coordinator = _sensor(desc)
_replace(coordinator, state="Ready", progress="Finish")
assert sensor.native_value == "Finish"
time.sleep(0.1)
assert sensor.native_value == "Idle"
_replace(coordinator, state="Ready", progress="None")
assert sensor.native_value == "Idle"
_replace(coordinator, state="Ready", progress="Finish")
assert sensor.native_value == "Idle"
# A real cycle in between is what makes it available again.
_replace(coordinator, state="Run", progress="Drying")
assert sensor.native_value == "Drying"
_replace(coordinator, state="Run", progress="Finish")
assert sensor.native_value == "Finish"
_replace(coordinator, state="Ready", progress="None")
assert sensor.native_value == "Finish"
def test_hold_expires_after_sticky_seconds():
# A fresh desc (frozen dataclass -- replace(), not mutation, so the
# module-level _PROGRESS_DESC other tests share stays untouched) with
+16 -5
View File
@@ -99,16 +99,18 @@ class TestWasherCourse:
def test_translation_key(self):
"""Table-scoped (issue: course codes aren't guaranteed consistent
across board generations sharing /course/vs/0 -- FlexWash's older
board reports Table_00, not the Table_02 every washer_cycle_table_02
name was confirmed against) -- see laundry.cycle_select. Only a
verified table gets table-specific state translations."""
across board generations sharing /course/vs/0 -- a device reporting
an unrecognized table id must not borrow another board generation's
labels) -- see laundry.cycle_select. Only a verified table gets
table-specific state translations."""
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == "cycle")
assert callable(desc.translation_key)
table_02 = {"/st/washercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_02"}}
assert desc.translation_key(table_02) == "washer_cycle_table_02"
table_00 = {"/st/washercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_00"}}
assert desc.translation_key(table_00) == "cycle"
assert desc.translation_key(table_00) == "washer_cycle_table_00"
unrecognized = {"/st/washercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_99"}}
assert desc.translation_key(unrecognized) == "cycle"
assert desc.translation_key({}) == "cycle"
def test_reads_raw_course_code_from_options_array(self):
@@ -146,6 +148,15 @@ class TestWasherCourse:
}
assert confirmed <= translated_states("select", "washer_cycle_table_02")
def test_reported_table_00_course_codes_are_translated(self):
"""The reporter confirmed these codes on a WF45R6300AW/US by
selecting each cycle and reading back the raw course code
(issue #357)."""
from custom_components.localthings.catalog import translated_states
confirmed = {"01", "70", "55", "71", "72", "77", "57", "73", "74", "75", "78"}
assert confirmed <= translated_states("select", "washer_cycle_table_00")
def test_missing_course_option_returns_none(self):
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == "cycle")
assert desc.rep_fn is not None