Skip to content

Commit 94fa228

Browse files
sharkyyclaude
andcommitted
fix: keep anti-wrinkle alive-then-close it when the power sensor goes silent
The anti-wrinkle idle-timeout (anti_wrinkle_idle_timeout) and the 2 h safety cap both live in the detector and only advance from within process_reading, so they need incoming readings to fire. A publish-on-change power sensor goes completely silent once power flatlines at standby / 0 W after the last tumble pulse, which freezes the idle timer mid-count and pins the state in anti_wrinkle until the next real reading (typically the next cycle). The manager watchdog injects synthetic 0 W keepalives to advance the accumulator during silence, but its state guard excluded STATE_ANTI_WRINKLE, so it never fed that state. Add a dedicated anti-wrinkle branch that injects a 0 W keepalive once the sensor has been silent longer than off_delay, so the detector's own idle-timeout (or the 2 h safety cap) elapses on schedule and the tail closes into OFF instead of showing anti-wrinkle for hours after the appliance physically finished. The synthetic keepalive bumps _last_reading_time, which is also the sampling-throttle clock in _async_power_changed. A real tumble pulse (>= min_power, therefore not is_low_power) arriving within one sampling interval of a keepalive would be discarded by that throttle before reaching the detector, so it could not reset the idle timer. Exempt high-power readings from the throttle while in anti_wrinkle; sub-min baseline readings stay throttled as before. Adds tests/test_anti_wrinkle_watchdog_keepalive.py: watchdog injection during anti-wrinkle silence, no injection before off_delay, no-op before first reading, end-to-end close into OFF under injected keepalives, a pulse resets the idle timer, the throttle bypass for real pulses, and that RUNNING high readings / anti-wrinkle baselines are still throttled. Closes #339 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 11dc122 commit 94fa228

3 files changed

Lines changed: 383 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313

1414
- **Notifications to `notify.*` entity targets no longer crash with "extra keys not allowed"** (`manager.py`): When a notify target was configured as a `notify.*` entity (e.g. `notify.mobile_app_phone`), WashData routed the call through HA's newer `notify.send_message` entity service, which only accepts `message`, `title`, and `entity_id`. Mobile-app enrichment keys (tag, channel, priority, iOS Live Activity fields, icon, progress) were still bundled into a `data` sub-dict and appended to that call, causing a voluptuous schema rejection (`extra keys not allowed @ data['data']`) and a "Task exception was never retrieved" error in the HA log -- meaning the notification was silently dropped. When `svc_data` is non-empty the call now falls through to the legacy `notify.mobile_app_*` domain service, which uses `NOTIFY_SERVICE_SCHEMA` and accepts `data: dict`, so all enrichment keys arrive correctly.
1515

16+
- **Anti-wrinkle no longer stays stuck forever when the power sensor goes silent** ([#339](https://github.com/3dg1luk43/ha_washdata/issues/339)) (`manager.py`): The anti-wrinkle idle-timeout (`anti_wrinkle_idle_timeout`) and the 2-hour safety cap both live inside the detector and only advance from within `process_reading`, so they need incoming power readings to fire. A publish-on-change power sensor (most Zigbee/WiFi smart plugs) stops emitting updates entirely once power flatlines at standby / 0 W after the last tumble pulse — which freezes the idle timer partway through its count and pins the state in `anti_wrinkle` until the next real reading, typically the start of the *following* cycle. The manager watchdog, which injects synthetic 0 W keepalives to advance the accumulator during silence, excluded `anti_wrinkle` from its state guard and so never fed it. The watchdog now injects a 0 W keepalive during `anti_wrinkle` once the sensor has been silent longer than `off_delay`, so the detector's own idle-timeout (or the 2-hour safety cap) elapses on schedule and the tail closes cleanly into OFF instead of the state showing "anti-wrinkle" for hours after the dryer has physically finished. Injecting 0 W (rather than the last standby draw) matches the existing low-power keepalive path and is safe: a steady sub-pulse baseline carries no tumble activity, so it correctly counts toward the idle timeout, while a real tumble pulse resets it as before. The synthetic keepalive shares the sampling-throttle clock (`_last_reading_time`), so a real tumble pulse (`>= min_power`) arriving within one sampling interval of a keepalive is now explicitly exempted from that throttle while in `anti_wrinkle` — otherwise the pulse would have been discarded before reaching the detector and could not reset the idle timer.
17+
1618
## 0.5.3 - 2026-07-26
1719

1820
### Features

custom_components/ha_washdata/manager.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2859,8 +2859,21 @@ def _async_power_changed(self, event: Any) -> None:
28592859
or prev_raw_power >= min_p # genuine drop from active power
28602860
)
28612861

2862+
# Anti-wrinkle tumble pulses must always reach the detector so they can
2863+
# reset the idle timer. In anti_wrinkle the watchdog injects synthetic 0 W
2864+
# keepalives (see _watchdog_check_stuck_cycle) whose timestamp bumps
2865+
# _last_reading_time — the very clock this throttle uses. Without this
2866+
# bypass a real pulse (>= min_power, so not is_low_power) arriving within
2867+
# _sampling_interval of a keepalive would be discarded, defeating the reset
2868+
# and letting the mode time out mid-tumble. Pulses are sparse and brief, so
2869+
# exempting them here cannot flood the detector.
2870+
is_anti_wrinkle_pulse = (
2871+
self.detector.state == STATE_ANTI_WRINKLE and power >= min_p
2872+
)
2873+
28622874
if (
28632875
not is_low_power
2876+
and not is_anti_wrinkle_pulse
28642877
and self._last_reading_time
28652878
and (now - self._last_reading_time).total_seconds() < self._sampling_interval
28662879
):
@@ -3223,6 +3236,40 @@ def _power_off_timer_check(self) -> None:
32233236

32243237
async def _watchdog_check_stuck_cycle(self, now: datetime) -> None:
32253238
"""Watchdog: check if cycle is stuck (no updates for too long)."""
3239+
# ANTI_WRINKLE keepalive. The anti-wrinkle idle-timeout
3240+
# (anti_wrinkle_idle_timeout) and the 2 h safety cap both live inside the
3241+
# detector and only advance from within process_reading, so they need
3242+
# incoming readings to fire. A publish-on-change power sensor goes
3243+
# completely silent once power flatlines at standby / 0 W after the last
3244+
# tumble pulse - which freezes the idle timer mid-count and pins the
3245+
# state in anti_wrinkle until the next real reading (typically the start
3246+
# of the following cycle). The main state guard below excludes
3247+
# ANTI_WRINKLE, so without this branch no keepalive is ever injected and
3248+
# the mode never closes on its own. Inject a 0 W keepalive on silence so
3249+
# the detector's own idle-timeout / safety cap advances and the tail
3250+
# closes into OFF on schedule. Injecting 0 W (rather than the last known
3251+
# standby draw) is consistent with the low-power keepalive path below and
3252+
# is safe: a steady sub-pulse baseline carries no tumble activity, so it
3253+
# should count toward the idle timeout.
3254+
if self.detector.state == STATE_ANTI_WRINKLE:
3255+
if not self._last_reading_time:
3256+
return
3257+
time_since_any_update = (now - self._last_reading_time).total_seconds()
3258+
if time_since_any_update > self._config.off_delay:
3259+
self._logger.debug(
3260+
"Watchdog: Anti-wrinkle silence (%.0fs > off_delay %ds). "
3261+
"Injecting 0W keepalive to advance the idle timer.",
3262+
time_since_any_update,
3263+
self._config.off_delay,
3264+
)
3265+
self.detector.process_reading(0.0, now)
3266+
# Reset only the 'any-update' clock so the next keepalive is one
3267+
# off_delay away; leave _last_real_reading_time untouched.
3268+
self._last_reading_time = now
3269+
self._current_power = 0.0
3270+
self._notify_update()
3271+
return
3272+
32263273
if self.detector.state not in (STATE_RUNNING, STATE_STARTING, STATE_PAUSED, STATE_ENDING):
32273274
return
32283275

0 commit comments

Comments
 (0)