348 files

This commit is contained in:
Home Assistant Version Control
2026-07-23 19:02:12 +00:00
parent 0ed1687503
commit 016af2e013
348 changed files with 12368 additions and 11689 deletions
+15
View File
@@ -18,6 +18,7 @@
from __future__ import annotations
import asyncio
import json
import logging
from pathlib import Path
@@ -1035,6 +1036,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
manager = hass.data[DOMAIN].pop(entry.entry_id)
await manager.async_shutdown()
# Settle registry: mark any still-RUNNING tasks for this entry as
# cancelled so they don't appear as zombies after reload.
from . import task_registry as _task_registry
_cancelled_tasks = _task_registry.get_registry(hass).cancel_entry_tasks(entry.entry_id)
# Drain cancelled WS-spawned tasks so their finally blocks (lock releases)
# complete before we remove the write lock below.
if _cancelled_tasks:
await asyncio.gather(*_cancelled_tasks, return_exceptions=True)
# Release the per-entry write lock so it doesn't block the next setup.
from .ws_api import _WS_WRITE_LOCKS_KEY
hass.data.get(_WS_WRITE_LOCKS_KEY, {}).pop(entry.entry_id, None)
# When the last WashData entry is removed, tear down the shared panel/sidebar
# so no stale registration flags or sidebar entry linger.
if not hass.data.get(DOMAIN):
+11 -4
View File
@@ -67,6 +67,10 @@ def find_best_alignment(
n_curr = len(curr)
n_ref = len(ref)
# Guard: cross-correlation crashes on empty or single-element arrays.
if n_curr < 2 or n_ref < 2:
return 0.0, {"corr": 0.0, "mae_score": 0.0}, 0
# 1. Coarse Alignment (Cross-Correlation)
# Downsample for speed if arrays are large
ds_factor = 1
@@ -251,11 +255,12 @@ def _dtw_component_score(
band: float,
derivative: bool,
scale: float,
curr_resampled: np.ndarray | None = None,
) -> float:
"""DTW similarity in [0,1] for one candidate: resample both series to a
common grid, warp (level or derivative), and express the distance relative
to the current peak (behaviour-neutral at MATCH_MAE_REF_PEAK)."""
a = _resample_to(curr_arr, MATCH_DTW_RESAMPLE_N)
a = curr_resampled if curr_resampled is not None else _resample_to(curr_arr, MATCH_DTW_RESAMPLE_N)
b = _resample_to(sample_arr, MATCH_DTW_RESAMPLE_N)
dtw_dist = compute_dtw_lite(a, b, band_width_ratio=band, derivative=derivative)
norm_dist = dtw_dist / MATCH_DTW_RESAMPLE_N
@@ -326,6 +331,8 @@ def compute_matches_worker(
l1_scale = float(config.get("dtw_l1_scale", MATCH_DTW_DIST_SCALE))
ddtw_scale = float(config.get("dtw_ddtw_scale", MATCH_DDTW_DIST_SCALE))
ensemble_w = float(config.get("dtw_ensemble_w", MATCH_DTW_ENSEMBLE_W))
# Resample the current trace once — it's the same for every candidate.
curr_resampled = _resample_to(curr_arr, MATCH_DTW_RESAMPLE_N)
for cand in to_refine:
sample_arr = np.array(cand["sample"])
@@ -340,8 +347,8 @@ def compute_matches_worker(
elif dtw_mode == "ensemble":
# Blend the level-based (L1) and shape-based (derivative) DTW
# scores; they are complementary signals.
s_l1 = _dtw_component_score(curr_arr, sample_arr, current_peak, dtw_bandwidth, False, l1_scale)
s_dd = _dtw_component_score(curr_arr, sample_arr, current_peak, dtw_bandwidth, True, ddtw_scale)
s_l1 = _dtw_component_score(curr_arr, sample_arr, current_peak, dtw_bandwidth, False, l1_scale, curr_resampled=curr_resampled)
s_dd = _dtw_component_score(curr_arr, sample_arr, current_peak, dtw_bandwidth, True, ddtw_scale, curr_resampled=curr_resampled)
dtw_score = ensemble_w * s_l1 + (1.0 - ensemble_w) * s_dd
norm_dist = 0.0 # composite; per-component distance not meaningful
else:
@@ -352,7 +359,7 @@ def compute_matches_worker(
use_deriv = dtw_mode == "ddtw"
scale = ddtw_scale if use_deriv else l1_scale
dtw_score = _dtw_component_score(
curr_arr, sample_arr, current_peak, dtw_bandwidth, use_deriv, scale
curr_arr, sample_arr, current_peak, dtw_bandwidth, use_deriv, scale, curr_resampled=curr_resampled
)
norm_dist = 0.0
+103
View File
@@ -297,6 +297,27 @@ MATCH_RANKING_HISTORY_MAX = 500
# hard limit: this only surfaces "running longer than usual" for the UI. Kept
# below the zombie threshold so it lights up well before any termination.
CYCLE_OVERRUN_ANOMALY_RATIO = 1.5
# Duration-anchored hard-finalize backstop in the ENDING state. Smart Termination
# is (deliberately) gated on a confident, non-ambiguous match; an ambiguous /
# prefix-ambiguous match therefore skips it and relies on the power+energy fallback
# timeout, which a low standby baseline (below stop_threshold but energetic enough
# to trip the energy gate) can hold open until the 8 h cap / zombie-kill — the
# #296/#311 "finishes hours/1000+ min late" reports. This SEPARATE backstop
# finalizes a matched cycle that has sat in ENDING well past its expected duration
# AND been genuinely quiet (below stop_threshold) for a sustained span. It is
# asymmetric (can only ever SHORTEN a stuck wait, never end a cycle early) and the
# sustained-quiet guard is what keeps it from truncating a longer program that was
# mismatched to a shorter profile — a real longer program has high-power phases
# that keep resetting the below-threshold timer, so it never accumulates the
# required continuous quiet. Sits between CYCLE_OVERRUN_ANOMALY_RATIO (1.5, soft
# visible signal) and the manager's 3x zombie-kill, so it fires well after any
# legitimate overrun but well before the hard kill.
ENDING_HARD_FINALIZE_RATIO = 2.0
ENDING_HARD_FINALIZE_MIN_QUIET_S = 600.0 # continuous sub-threshold span floor
# NOTE: STANDBY_BAND_* constants live further down, after the DEVICE_TYPE_*
# definitions they reference (search "Standby-band stuck-in-RUNNING finalize").
# Underrun anomaly: a cycle that finishes in less than this fraction of its
# matched profile's median duration is flagged "underrun" (post-cycle only,
# never a live signal — computed in _async_process_cycle_end after the cycle
@@ -536,6 +557,68 @@ DEVICE_TYPES = {
DEVICE_TYPE_OTHER: "Threshold Device",
}
# Standby-band stuck-in-RUNNING finalize (#296). Some appliances finish but hold
# a small, flat "anti-crease" / display standby draw that sits ABOVE stop_threshold_w
# (e.g. a ~2.5-3.2 W baseline while stop_threshold is ~1.2 W). Because power never
# drops below the stop threshold, _time_below_threshold never accumulates, so the
# cycle never reaches PAUSED/ENDING and runs until the 8 h RUNNING cap — and the
# anti-wrinkle handler can't help because entering it requires a completed
# TIMEOUT/SMART finish that never happens (chicken-and-egg). This detector spots a
# sustained, FLAT, tiny-fraction-of-peak plateau *past* the expected duration and
# finalizes the cycle (as a normal TIMEOUT completion, so an anti-wrinkle-enabled
# washer/dryer still routes into ANTI_WRINKLE afterwards). Heavily gated so it can
# never end an active low-power phase: it needs a matched profile, elapsed >= 2x
# expected, and the whole recent window flat + below a small fraction of the cycle's
# own peak. Restricted to wet appliances where a stuck baseline is unambiguously
# anomalous (bread-maker keep-warm, pump, air-fryer etc. have legitimate holds and
# are excluded).
STANDBY_BAND_FINALIZE_DEVICE_TYPES = (
DEVICE_TYPE_WASHING_MACHINE,
DEVICE_TYPE_WASHER_DRYER,
DEVICE_TYPE_DRYER,
)
STANDBY_BAND_MIN_RATIO = 2.0 # only past 2x the expected duration
STANDBY_BAND_WINDOW_S = 600.0 # require a >=10 min flat plateau
STANDBY_BAND_MAX_FRACTION = 0.10 # plateau level <= 10% of the cycle's peak
STANDBY_BAND_FLATNESS_FRACTION = 0.03 # window (max-min) <= 3% of the cycle's peak
STANDBY_BAND_FLATNESS_FLOOR_W = 2.0 # absolute flatness floor for low-peak devices
# Issue #296 follow-up: anti-crease ("Knitterschutz") back-to-back handling.
#
# After a wash finishes, some machines (e.g. Miele) hold a low-power tumble tail -
# a constant baseline plus periodic sub-``anti_wrinkle_max_power`` bursts, NO
# heating - until the door is opened. To the power-off detector this looks like
# continued RUNNING (the bursts recur faster than off_delay and keep reviving the
# cycle out of ENDING), so the cycle never finalises into STATE_ANTI_WRINKLE - the
# state that is designed to absorb the tail and split off the next wash. If a
# second load is started before the door is opened, the whole sequence
# (wash -> tail -> wash -> tail) merges into one multi-hour "cycle".
#
# Two coordinated mechanisms fix it, BOTH opt-in via ``anti_wrinkle_enabled`` and
# gated to the anti-wrinkle device types (see ``anti_wrinkle_active`` in
# cycle_detector):
#
# * a proactive finalise (``_is_anticrease_tail`` -> Smart Termination into
# ANTI_WRINKLE): once a *matched* cycle is past its expected duration AND the
# recent window shows only the low-power tail, finalise without waiting to reach
# ENDING through the burst-defeated off_delay path;
# * a match freeze (``_try_profile_match`` guard) under the SAME condition, so
# re-matching on the growing flat tail cannot drift the label to a longer
# near-duplicate profile (which would push expected_duration out and break the
# finalise gate) - the field failure that breaks Smart Termination.
#
# Gated on ``elapsed >= expected * ratio`` so a legitimate mid-wash low-power phase
# can NEVER trigger it: a washer spends most of its cycle below
# ``anti_wrinkle_max_power`` (only brief heating spikes exceed it), but every
# observed clean cycle's mid-cycle sub-max_power gap ENDS well before its expected
# duration, while the anti-crease tail BEGINS after it. Also requires a genuinely
# energetic cycle (peak above ``anti_wrinkle_max_power``) so a low-power program
# that never heats is left alone. Asymmetric (finalise-only, can only shorten the
# wait) and self-correcting (a new wash's heating burst leaves the regime and
# re-arms matching).
ANTI_CREASE_FINALIZE_RATIO = 0.98 # elapsed must reach 98% of expected duration
ANTI_CREASE_CONFIRM_WINDOW_S = 180.0 # recent window that must hold no reading > max_power
# Device Type Defaults
# Device Type Defaults (Maps)
@@ -618,6 +701,26 @@ DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS = 600.0
# a shorter window can never fire it mid-cycle.
DISHWASHER_SMART_TERMINATION_DEBOUNCE_SECONDS = 300.0
# Sustained "true off" (power below stop_threshold_w) window that cancels a
# user-paused STARTING state back to OFF. A user pause during STARTING is held
# indefinitely (issue #306) waiting for Resume Cycle, but a genuinely paused
# appliance keeps standby power above the stop threshold; sustained power below it
# means the machine was switched off, so without this the detector could stay
# pinned in STARTING forever. Generous enough not to abort a real pause whose
# standby briefly dips (and well above the issue-#306 test's 10 s hold), while
# bounding the pinned-forever case.
STARTING_PAUSED_TRUE_OFF_TIMEOUT_SECONDS = 300.0
# Upper bound on the washer / washer-dryer Smart-Termination debounce, which is
# otherwise derived as max(180, min_off_gap * 0.5). At the shipped defaults this
# is 240 s (washing machine, min_off_gap 480) / 300 s (washer-dryer, min_off_gap
# 600) and the cap never bites. It only bounds the case where a suggested or
# hand-set min_off_gap (e.g. 1800 s) would inflate the quiet-time requirement to
# 15 min, starving end-detection for confident non-ambiguous matches the same way
# the old dishwasher off_delay*0.25 coupling did. 600 s leaves ample headroom for
# a washer's longest legitimate mid-cycle soak trough while capping the pathology.
WASHER_SMART_TERMINATION_DEBOUNCE_MAX_SECONDS = 600.0
DEFAULT_OFF_DELAY_BY_DEVICE = {
DEVICE_TYPE_DISHWASHER: 1800, # 30 min (Drying)
DEVICE_TYPE_BREAD_MAKER: 300, # 5 min (Keep-warm phase after baking)
+517 -9
View File
@@ -18,6 +18,7 @@
from __future__ import annotations
import itertools
import logging
import math
from dataclasses import dataclass
@@ -51,9 +52,21 @@ from .const import (
DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS,
DISHWASHER_END_SPIKE_WAIT_SECONDS,
DISHWASHER_SMART_TERMINATION_DEBOUNCE_SECONDS,
WASHER_SMART_TERMINATION_DEBOUNCE_MAX_SECONDS,
STARTING_PAUSED_TRUE_OFF_TIMEOUT_SECONDS,
DISHWASHER_MATCH_FREEZE_QUIET_SECONDS,
DISHWASHER_MIN_CYCLE_DURATION_S,
TERMINAL_DROP_OFF_DELAY_SECONDS,
ENDING_HARD_FINALIZE_RATIO,
ENDING_HARD_FINALIZE_MIN_QUIET_S,
STANDBY_BAND_FINALIZE_DEVICE_TYPES,
STANDBY_BAND_MIN_RATIO,
STANDBY_BAND_WINDOW_S,
STANDBY_BAND_MAX_FRACTION,
STANDBY_BAND_FLATNESS_FRACTION,
STANDBY_BAND_FLATNESS_FLOOR_W,
ANTI_CREASE_FINALIZE_RATIO,
ANTI_CREASE_CONFIRM_WINDOW_S,
)
# The dishwasher end-spike wait window is shared between two code paths
@@ -312,6 +325,11 @@ class CycleDetector:
# OFF only when the machine has clearly been switched off rather
# than briefly dipped.
self._delay_wait_true_off_seconds: float = 0.0
# _starting_paused_off_since anchors the first below-stop reading while
# a user-paused STARTING state is held (issue #306). Elapsed time is
# measured from this anchor, not accumulated per-dt, so a single large
# (but sub-outage) interval cannot prematurely credit minutes of quiet time.
self._starting_paused_off_since: datetime | None = None
# _delay_wait_high_start anchors the first high-power reading
# observed inside DELAY_WAIT. We only transition to STARTING
# when the high-power streak has lasted at least
@@ -383,6 +401,18 @@ class CycleDetector:
):
return
# Terminal-tail match freeze (anti-crease, #296): once a washer/dryer with
# anti-wrinkle enabled is past its expected duration and has settled into
# the low-power tumble tail, re-matching on the growing flat tail drifts the
# label toward a LONGER near-duplicate (its expected duration grows), which
# pushes the anti-crease finalize gate out and breaks Smart Termination -
# the field failure that merges back-to-back washes. Keep the good
# pre-tail match instead. Self-correcting: a new wash's heating burst above
# anti_wrinkle_max_power leaves the tail regime, so this stops applying and
# matching re-arms for the next cycle.
if self._matched_profile and self._in_anticrease_freeze(timestamp):
return
# Rate limiting
if not force and self._last_match_time:
elapsed = (timestamp - self._last_match_time).total_seconds()
@@ -459,6 +489,23 @@ class CycleDetector:
Can be called by the matcher callback directly or asynchronously.
"""
# Terminal-tail match freeze (anti-crease, #296). This is the single sink
# for ALL match updates - the detector's own _try_profile_match AND the
# manager's async 5-min matcher (manager.py calls update_match directly).
# Once a washer/dryer with anti-wrinkle enabled is past its expected
# duration and has settled into the low-power tumble tail, re-matching on
# the growing flat tail drifts the label toward a LONGER near-duplicate (or
# flips it ambiguous), which pushes out expected_duration and would block
# the anti-crease finalize - the field failure that merges back-to-back
# washes. Keep the good pre-tail match instead. Self-correcting: a new
# wash's heating burst above anti_wrinkle_max_power leaves the tail regime,
# so this stops applying and matching re-arms for the next cycle.
if (
self._matched_profile
and self._power_readings
and self._in_anticrease_freeze(self._power_readings[-1][0])
):
return
# Unpack 5 elements (or 4 for backward compatibility if needed, but wrapper is updated)
# wrapper returns (name, confidence, duration, phase, is_mismatch)
# Or MatchResult object if refactored, but currently wrapper returns tuple.
@@ -582,6 +629,13 @@ class CycleDetector:
self._time_below_threshold = 0.0
self._last_match_time = None
self._matched_profile = None
# Clear stale match state so the next cycle starts with clean defaults.
# _expected_duration left at 0 tells the dishwasher end-spike gate that
# no profile is matched yet; stale non-zero would mis-gate the spike check.
self._expected_duration = 0.0
self._last_match_confidence = 0.0
self._match_ambiguous = False
self._match_prefix_ambiguous = False
self._ignore_power_until_idle = False # Reset lockout
self._lockout_high_seconds = 0.0
# Clear the verified-pause flag so it can't leak into the next cycle (B6):
@@ -597,6 +651,7 @@ class CycleDetector:
self._delay_band_seconds = 0.0
self._delay_band_peak = 0.0
self._delay_wait_true_off_seconds = 0.0
self._starting_paused_off_since = None
self._delay_wait_high_start = None
@property
@@ -972,6 +1027,10 @@ class CycleDetector:
self._power_readings.append((timestamp, power))
self._cycle_max_power = max(self._cycle_max_power, power)
if is_high:
# Power back up - clear any accumulated "true off" hold time.
self._starting_paused_off_since = None
if self._time_above_threshold >= self._config.start_duration_threshold:
if self._energy_since_idle_wh >= self._config.start_energy_threshold:
self._transition_to(STATE_RUNNING, timestamp)
@@ -982,7 +1041,41 @@ class CycleDetector:
# that the low power is intentional, not a false start.
if not is_high and self._time_below_threshold > 1.0: # 1s grace period
if getattr(self, "_verified_pause", False):
pass # user pause holds; wait for Resume Cycle
# User pause holds; wait for Resume Cycle (issue #306). But a
# genuinely paused appliance keeps standby power above the stop
# threshold - sustained power *below* it means the machine was
# switched off, so fall back to OFF rather than pinning STARTING
# forever.
if power < self._config.stop_threshold_w:
# An outage-sized gap since the last reading is NOT observed
# quiet (the machine may still be paused, we just lost
# telemetry): reset anchor so only genuinely-sampled
# sustained-off time can cancel a paused STARTING.
if dt > self._outage_threshold_s():
self._starting_paused_off_since = None
elif self._starting_paused_off_since is None:
# First below-stop reading: anchor the timestamp.
# Don't credit the preceding dt interval — we only
# know the device is off *now*, not how long before
# this sample it went quiet.
self._starting_paused_off_since = timestamp
observed_off_s = (
(timestamp - self._starting_paused_off_since).total_seconds()
if self._starting_paused_off_since is not None
else 0.0
)
if observed_off_s >= STARTING_PAUSED_TRUE_OFF_TIMEOUT_SECONDS:
self._logger.info(
"Paused STARTING cancelled: power off (%.1fW) for "
"%.0fs → OFF",
power,
observed_off_s,
)
self._transition_to(STATE_OFF, timestamp)
return
else:
# Power recovered — clear the off anchor.
self._starting_paused_off_since = None
else:
# False start
self._logger.debug(
@@ -999,6 +1092,13 @@ class CycleDetector:
self._power_readings.append((timestamp, power))
self._cycle_max_power = max(self._cycle_max_power, power)
# Anti-crease finalize (#296): a matched cycle past its expected
# duration that has settled into the low-power tumble tail is done -
# finalize into anti-wrinkle now instead of letting the periodic
# bursts keep reviving RUNNING until a second wash merges in.
if self._maybe_finalize_anticrease_tail(timestamp):
return
# Use dynamic threshold
thresh = self._dynamic_pause_threshold
if self._time_below_threshold >= thresh:
@@ -1008,16 +1108,71 @@ class CycleDetector:
# Periodic profile matching
self._try_profile_match(timestamp)
# Standby-band stuck finalize (#296): an appliance holding a flat
# low standby draw ABOVE stop_threshold never accumulates
# _time_below_threshold, so it never reaches PAUSED/ENDING. Detect
# the plateau and finalize as a normal completion (so anti-wrinkle
# still engages). Cheaply gated on being well past expected before
# the window scan runs.
if self._is_standby_band_stuck(timestamp):
start_time = self._current_cycle_start or timestamp
current_duration = (timestamp - start_time).total_seconds()
# The plateau sits ABOVE stop_threshold, so it keeps advancing
# _last_active_time and the default keep_tail=False trim would NOT
# remove it - inflating the stored duration/energy with minutes of
# standby. Snap the end back to the last real activity (the last
# reading above the plateau ceiling) and drop the trailing plateau.
level_ceiling = float(self._cycle_max_power) * STANDBY_BAND_MAX_FRACTION
plateau_start_idx = None
for i in range(len(self._power_readings) - 1, -1, -1):
if float(self._power_readings[i][1]) > level_ceiling:
plateau_start_idx = i
break
if (
plateau_start_idx is not None
and plateau_start_idx < len(self._power_readings) - 1
):
self._power_readings = self._power_readings[: plateau_start_idx + 1]
self._last_active_time = self._power_readings[-1][0]
current_duration = (
self._last_active_time - start_time
).total_seconds()
self._logger.info(
"Standby-band finalize: flat plateau ~%.1fW (peak %.0fW) held "
"past expected %.0fs — appliance finished but holds a standby "
"draw above stop_threshold; finalizing (plateau trimmed, "
"duration %.0fs).",
power,
self._cycle_max_power,
self._expected_duration,
current_duration,
)
self._finish_cycle(
timestamp,
status="completed",
termination_reason=TerminationReason.TIMEOUT,
keep_tail=False,
)
return
# Max duration safety
if (
self._current_cycle_start
and (timestamp - self._current_cycle_start).total_seconds() > 28800
): # 8h safety
self._finish_cycle(timestamp, status="force_stopped")
self._finish_cycle(
timestamp,
status="force_stopped",
termination_reason=TerminationReason.FORCE_STOPPED,
)
elif self._state == STATE_PAUSED:
self._power_readings.append((timestamp, power))
# Anti-crease finalize (#296) - see the RUNNING branch.
if self._maybe_finalize_anticrease_tail(timestamp):
return
if is_high:
# Resume to RUNNING
self._transition_to(STATE_RUNNING, timestamp)
@@ -1032,6 +1187,25 @@ class CycleDetector:
elif self._state == STATE_ENDING:
self._power_readings.append((timestamp, power))
# Hard cap: ENDING must not run longer than RUNNING's 8 h safety limit.
# Without this a standby baseline can hold the state open indefinitely.
if (
self._current_cycle_start
and (timestamp - self._current_cycle_start).total_seconds() > 28800
):
self._finish_cycle(
timestamp,
status="force_stopped",
termination_reason=TerminationReason.FORCE_STOPPED,
)
return
# Anti-crease finalize (#296) - see the RUNNING branch. Fires ahead of
# the is_high end-spike handling so a sub-max_power tail burst finalizes
# into anti-wrinkle instead of reviving RUNNING.
if self._maybe_finalize_anticrease_tail(timestamp):
return
if is_high:
start_time = self._current_cycle_start or timestamp
current_duration = (timestamp - start_time).total_seconds()
@@ -1196,8 +1370,14 @@ class CycleDetector:
# the soak-bridging min_off_gap before committing
# Smart Termination, so a near-duplicate profile
# doesn't cut a long cycle short during a mid-cycle
# power trough.
smart_debounce = max(180.0, self._config.min_off_gap * 0.5)
# power trough. Bounded above so a large suggested /
# hand-set min_off_gap can't inflate the quiet-time
# requirement and starve end-detection (see
# WASHER_SMART_TERMINATION_DEBOUNCE_MAX_SECONDS).
smart_debounce = min(
WASHER_SMART_TERMINATION_DEBOUNCE_MAX_SECONDS,
max(180.0, self._config.min_off_gap * 0.5),
)
else:
smart_debounce = 120.0
@@ -1276,6 +1456,64 @@ class CycleDetector:
)
return
# --- DURATION-ANCHORED HARD FINALIZE (backstop) ---
# Separate safety net for a matched cycle whose Smart
# Termination is blocked (ambiguous / prefix-ambiguous match)
# and whose fallback energy gate is held open by a low standby
# baseline: without this it sits in ENDING until the 8 h cap /
# zombie-kill (#296/#311). Fires only well past the expected
# duration AND after a long *continuous* sub-threshold span, so
# it can never truncate a longer program mismatched to a shorter
# profile (a real longer program has high-power phases that keep
# resetting the quiet timer) — asymmetric, shorten-only. Not
# for user-paused cycles.
required_quiet = max(
ENDING_HARD_FINALIZE_MIN_QUIET_S,
float(max(self._config.off_delay, self._config.min_off_gap)),
)
# Require the required_quiet tail to be actually SAMPLED (no
# outage-sized gap): otherwise a telemetry outage that inflated
# _time_below_threshold could finalize an active cycle early.
# Walk in reverse so we can capture the boundary reading (the
# first reading outside the window) — an outage right before the
# window would be invisible if we only passed in-window timestamps.
quiet_ts: list[datetime] = []
_boundary_q: datetime | None = None
for _ts, _ in reversed(self._power_readings):
if (timestamp - _ts).total_seconds() <= required_quiet:
quiet_ts.append(_ts)
elif quiet_ts:
_boundary_q = _ts
break
if _boundary_q is not None:
quiet_ts.append(_boundary_q)
if (
self._expected_duration > 0
and current_duration
>= self._expected_duration * ENDING_HARD_FINALIZE_RATIO
and self._time_below_threshold >= required_quiet
and not self._verified_pause
and not self._window_has_outage_gap(quiet_ts)
):
self._logger.info(
"Duration-anchored finalize: cycle in ENDING at %.0fs "
"(%.1fx expected %.0fs), quiet %.0fs — Smart Termination "
"was blocked (ambiguous=%s prefix=%s); finalizing.",
current_duration,
current_duration / self._expected_duration,
self._expected_duration,
self._time_below_threshold,
self._match_ambiguous,
self._match_prefix_ambiguous,
)
self._finish_cycle(
timestamp,
status="completed",
termination_reason=TerminationReason.TIMEOUT,
keep_tail=False,
)
return
# --- FALLBACK TIMEOUT CHECK ---
# Rule: To separate cycles, we must wait at least min_off_gap.
effective_off_delay = max(self._config.off_delay, self._config.min_off_gap)
@@ -1333,11 +1571,15 @@ class CycleDetector:
if self._time_below_threshold >= effective_off_delay:
recent_window = [
r
for r in self._power_readings
if (timestamp - r[0]).total_seconds() <= gate_window
]
# Walk from the tail — readings are chronological so we can
# break as soon as we exceed the gate window (O(window) not O(n)).
recent_window = []
for r in reversed(self._power_readings):
if (timestamp - r[0]).total_seconds() <= gate_window:
recent_window.append(r)
else:
break
recent_window.reverse()
if not recent_window:
# Check deferred finish for matched profiles
@@ -1408,6 +1650,10 @@ class CycleDetector:
self._delay_wait_high_start = None
self._delay_wait_high_power = None
self._preserve_delay_band_on_off = False
# Clear the paused-STARTING true-off accumulator so a later STARTING
# cycle cannot inherit stale hold time and finalize to OFF prematurely
# (this path is also reached via the paused-STARTING cancellation).
self._starting_paused_off_since = None
# Reset end spike tracker when entering ENDING state
if new_state == STATE_ENDING:
@@ -1433,6 +1679,8 @@ class CycleDetector:
elif new_state == STATE_STARTING:
# Reset idle time if exiting ANTI_WRINKLE to STARTING (high-power burst resumed)
self._anti_wrinkle_idle_time = 0.0
# Fresh STARTING cycle: never inherit a prior cycle's true-off hold.
self._starting_paused_off_since = None
elif new_state == STATE_RUNNING:
self._delay_band_start = None
self._delay_band_seconds = 0.0
@@ -1478,6 +1726,266 @@ class CycleDetector:
self._ml_end_cache = (now_ts, exp, start, result)
return result
def _window_has_outage_gap(self, window_ts: list[datetime]) -> bool:
"""Whether a 'sustained window' contains a data-outage-sized hole.
The span + coverage checks in the standby / anti-crease window scans accept
e.g. three readings spanning the window even if a long unobserved gap sits
between them (a sensor dropout, or a sparse burst next to one old reading).
Finalizing on such a window could wrongly cut an active cycle, so reject it.
The gap ceiling is the sensor's own data-driven outage threshold
(``energy_gap_threshold_s`` over the full trace), so a change-only sensor's
legitimately-sparse stable stretches (tens of seconds between reports) are
NOT rejected while a genuine dropout is.
"""
if len(window_ts) < 2:
return True # too few points to trust as a sustained window
max_gap = self._outage_threshold_s()
ordered = sorted(t.timestamp() for t in window_ts)
return any((b - a) > max_gap for a, b in itertools.pairwise(ordered))
def _outage_threshold_s(self) -> float:
"""Sensor-adaptive gap ceiling (seconds): intervals longer than this are
treated as telemetry outages, not observed quiet. Data-driven from the
trace's own cadence (`energy_gap_threshold_s`), so a change-only sensor's
sparse-but-real stable stretches are not mistaken for a dropout.
"""
all_ts = np.array(
[r[0].timestamp() for r in self._power_readings], dtype=float
)
return energy_gap_threshold_s(all_ts)
def _is_standby_band_stuck(self, timestamp: datetime) -> bool:
"""Whether a RUNNING cycle is stuck on a flat standby plateau (#296).
Returns True only when ALL of the following hold, so this can never end
an active low-power phase:
* the device is a wet appliance where a stuck baseline is unambiguously
anomalous (``STANDBY_BAND_FINALIZE_DEVICE_TYPES``);
* a profile is matched and elapsed >= ``STANDBY_BAND_MIN_RATIO`` x the
expected duration (well past when it should have ended);
* not user-paused;
* the most recent >= ``STANDBY_BAND_WINDOW_S`` of readings are ALL at or
below ``STANDBY_BAND_MAX_FRACTION`` of the cycle's own peak power AND
span no more than ``STANDBY_BAND_FLATNESS_FRACTION`` of the peak (a flat
plateau, not fluctuating activity).
The expensive window scan runs only after the cheap duration gate passes,
so normal cycles never pay for it.
"""
if self._config.device_type not in STANDBY_BAND_FINALIZE_DEVICE_TYPES:
return False
if getattr(self, "_verified_pause", False):
return False
if not (self._matched_profile and self._expected_duration > 0):
return False
start = self._current_cycle_start
if start is None:
return False
current_duration = (timestamp - start).total_seconds()
if current_duration < self._expected_duration * STANDBY_BAND_MIN_RATIO:
return False
peak = float(self._cycle_max_power)
if peak <= 0:
return False
level_ceiling = peak * STANDBY_BAND_MAX_FRACTION
# Walk the tail; readings are chronological so we can break once outside
# the window (O(window), not O(n)).
window: list[float] = []
window_ts: list[datetime] = []
oldest_in_window: datetime | None = None
saw_older = False # a reading older than the window exists -> full coverage
_standby_boundary_ts: datetime | None = None
for ts, p in reversed(self._power_readings):
if (timestamp - ts).total_seconds() <= STANDBY_BAND_WINDOW_S:
window.append(float(p))
window_ts.append(ts)
oldest_in_window = ts
else:
saw_older = True
_standby_boundary_ts = ts # boundary: last reading before the window
break
# The plateau must actually SPAN the required window (data exists from
# before it), not just a couple of recent samples, and have enough points
# to judge. `saw_older` (rather than an exact span >= WINDOW check) is
# robust to sample phase/granularity: with e.g. 30 s sampling the oldest
# in-window reading is typically only ~570-599 s old, which an exact check
# would wrongly reject. A coverage sanity (oldest >= 90% of the window)
# plus an adjacent-gap check (``_window_has_outage_gap``) guard against a
# sparse burst of samples sitting next to one old reading across a dropout.
if (
oldest_in_window is None
or not saw_older
or len(window) < 3
or (timestamp - oldest_in_window).total_seconds()
< STANDBY_BAND_WINDOW_S * 0.9
or self._window_has_outage_gap(
[_standby_boundary_ts, *window_ts]
if _standby_boundary_ts is not None
else window_ts
)
):
return False
hi = max(window)
lo = min(window)
if hi > level_ceiling:
return False # a real active reading in the window - not standby
flatness_limit = max(
STANDBY_BAND_FLATNESS_FLOOR_W, peak * STANDBY_BAND_FLATNESS_FRACTION
)
if (hi - lo) > flatness_limit:
return False # fluctuating - still doing work
return True
def _anticrease_gate_open(self, timestamp: datetime) -> bool:
"""Core anti-crease gate (#296): everything except the current power level
and the low-power-window check. Shared by the match freeze
(``_in_anticrease_freeze``) and the finalise (``_is_anticrease_tail``).
True only when a genuinely energetic, confidently-matched cycle for an
anti-wrinkle device is PAST its expected duration - the discriminator that
separates the post-wash anti-crease tail from a mid-wash low-power trough
(a washer spends most of its cycle below ``anti_wrinkle_max_power``, but a
mid-wash trough is always BEFORE the expected duration, the tail after it).
"""
if not self._config.anti_wrinkle_enabled:
return False
if self._config.device_type not in (
DEVICE_TYPE_WASHING_MACHINE,
DEVICE_TYPE_DRYER,
DEVICE_TYPE_WASHER_DRYER,
):
return False
if getattr(self, "_verified_pause", False):
return False
if not (self._matched_profile and self._expected_duration > 0):
return False
if self._last_match_confidence < 0.4:
return False
if self._match_ambiguous or self._match_prefix_ambiguous:
return False
if self._cycle_max_power <= float(self._config.anti_wrinkle_max_power):
return False # never a hot/energetic cycle - leave low-power programs alone
start = self._current_cycle_start
if start is None:
return False
current_duration = (timestamp - start).total_seconds()
if current_duration < self._expected_duration * ANTI_CREASE_FINALIZE_RATIO:
return False
return True
def _in_anticrease_freeze(self, timestamp: datetime) -> bool:
"""Whether match updates should be frozen (#296): the anti-crease gate is
open AND the most recent reading is in the low-power regime (at or below
``anti_wrinkle_max_power``).
Deliberately lighter than ``_is_anticrease_tail`` - it does NOT wait for the
full ``ANTI_CREASE_CONFIRM_WINDOW_S``, so the confident pre-tail match is
preserved from the instant the cycle crosses its expected duration in a
low-power state, before the window accrues. Without this a match that
degrades to ambiguous within the first window's worth of tail would
deadlock both the freeze and the finalise (both require an unambiguous
match). Self-correcting: a heating burst above ``anti_wrinkle_max_power``
leaves the regime and re-arms matching.
"""
if not self._power_readings:
return False
if float(self._power_readings[-1][1]) > float(
self._config.anti_wrinkle_max_power
):
return False
return self._anticrease_gate_open(timestamp)
def _is_anticrease_tail(self, timestamp: datetime) -> bool:
"""Whether a matched, past-expected cycle has settled into the anti-crease
tumble tail (#296) - the trigger for the finalise into STATE_ANTI_WRINKLE.
Miele-style "Knitterschutz": after the wash proper ends, the machine holds
a constant baseline plus periodic sub-``anti_wrinkle_max_power`` tumble
bursts (no heating) until the door is opened. Because those bursts recur
faster than off_delay they keep reviving the cycle out of ENDING, so the
normal power-off path never finalises it and STATE_ANTI_WRINKLE - which is
built to absorb the tail and split off the next wash - never engages.
Recognising the tail lets us finalise into anti-wrinkle directly.
Requires the core gate (``_anticrease_gate_open``) AND that the most recent
>= ``ANTI_CREASE_CONFIRM_WINDOW_S`` of readings are ALL at or below
``anti_wrinkle_max_power`` (we are in the low-power tail, clear of the final
spin and not mid-heating). The expensive window scan runs only after the
cheap gate passes, so normal cycles never pay for it.
"""
if not self._anticrease_gate_open(timestamp):
return False
max_power = float(self._config.anti_wrinkle_max_power)
# Walk the tail; readings are chronological so we can break once outside the
# window (O(window), not O(n)).
window: list[float] = []
window_ts: list[datetime] = []
oldest_in_window: datetime | None = None
saw_older = False
_ac_boundary_ts: datetime | None = None
for ts, p in reversed(self._power_readings):
if (timestamp - ts).total_seconds() <= ANTI_CREASE_CONFIRM_WINDOW_S:
window.append(float(p))
window_ts.append(ts)
oldest_in_window = ts
else:
saw_older = True
_ac_boundary_ts = ts # boundary: last reading before the window
break
# The low-power tail must actually SPAN the window (data exists from before
# it) and have enough points to judge - not just a couple of recent samples.
# ``saw_older`` plus a coverage sanity and an adjacent-gap check
# (``_window_has_outage_gap``) is robust to sample phase/granularity while
# rejecting a dropout-sized hole (mirrors _is_standby_band_stuck).
if (
oldest_in_window is None
or not saw_older
or len(window) < 3
or (timestamp - oldest_in_window).total_seconds()
< ANTI_CREASE_CONFIRM_WINDOW_S * 0.9
or self._window_has_outage_gap(
[_ac_boundary_ts, *window_ts]
if _ac_boundary_ts is not None
else window_ts
)
):
return False
if max(window) > max_power:
return False # a heating / high-spin reading in the window - still washing
return True
def _maybe_finalize_anticrease_tail(self, timestamp: datetime) -> bool:
"""Finalise a cycle that has entered the anti-crease tail into
STATE_ANTI_WRINKLE (#296). Returns True if the cycle was finalised.
Shared by the RUNNING / PAUSED / ENDING branches so the finalise fires no
matter which state a burst left the detector in. Uses Smart Termination
(in ``ANTI_WRINKLE_ELIGIBLE_REASONS``) so ``_finish_cycle`` routes into
STATE_ANTI_WRINKLE, which then absorbs the tail and splits off any next
wash on its first heating burst above ``anti_wrinkle_max_power``.
"""
if not self._is_anticrease_tail(timestamp):
return False
start_time = self._current_cycle_start or timestamp
current_duration = (timestamp - start_time).total_seconds()
self._logger.info(
"Anti-crease finalize: matched '%s' past expected %.0fs (elapsed %.0fs), "
"settled into the low-power tumble tail — finalizing into anti-wrinkle.",
self._matched_profile,
self._expected_duration,
current_duration,
)
self._finish_cycle(
timestamp,
status="completed",
termination_reason=TerminationReason.SMART,
keep_tail=True,
)
return True
def _is_terminal_drop(self) -> bool:
"""Whether the current low-power event is an anomalously-early hard drop.
+17 -3
View File
@@ -56,10 +56,24 @@ class LovelaceResourceItem(TypedDict, total=False):
def get_cache_buster(filename: str = CARD_NAME) -> str:
"""Generate a stable cache buster based on a www asset's mtime."""
"""Generate a stable cache buster based on a www asset's mtime.
Also considers the translations/panel/ directory mtime so that
translation-only releases (e.g. GitLocalize merges) still bust the
browser cache for both the panel JS and the per-language JSON files.
"""
try:
src = Path(__file__).parent / "www" / filename
return str(int(os.path.getmtime(src)))
base = Path(__file__).parent
src_mtime = os.path.getmtime(base / "www" / filename)
try:
panel_dir = base / "translations" / "panel"
trans_mtime = max(
(os.path.getmtime(f) for f in panel_dir.iterdir() if f.is_file()),
default=0.0,
)
except OSError:
trans_mtime = 0.0
return str(int(max(src_mtime, trans_mtime)))
except OSError:
# Deterministic fallback when file is unavailable.
return "1"
+43 -16
View File
@@ -140,6 +140,7 @@ class LearningManager:
self._sample_interval_model = StatisticalModel(max_samples=200)
self._last_suggestion_update: datetime | None = None
self._last_batch_simulation_count: int = 0 # track when to re-run batch
self._last_suggestions_labeled_count: int = 0 # gate model/detection passes
def _apply_suggestions_and_notify(self, suggestions: dict[str, Any]) -> None:
"""Apply suggestions that pass quality gates."""
@@ -232,9 +233,17 @@ class LearningManager:
predicted_duration: Expected duration in seconds
match_result: MatchResult from profile_store.async_match_profile() (optional)
"""
# 1. Trigger background simulation to find optimal parameters for this cycle
if cycle_data.get("power_data"):
# Offload to executor since simulation can be heavy
# 1. Trigger single-cycle simulation — only for cleanly-completed, labeled,
# non-noise cycles. Skipping force_stopped/unlabeled/noise avoids deriving
# start/stop thresholds from mis-detected or truncated cycles.
_profile = detected_profile or cycle_data.get("profile_name")
_is_clean = (
cycle_data.get("power_data")
and _profile
and _profile != "noise"
and cycle_data.get("status") == "completed"
)
if _is_clean:
self.hass.async_create_task(self._async_run_simulation(cycle_data))
# 2. Check if we should request feedback
@@ -242,11 +251,20 @@ class LearningManager:
cycle_data, detected_profile, confidence, predicted_duration, match_result
)
# 3. Update model-based suggestions (durations etc)
self._update_model_suggestions()
# 3b. Update statistical detection suggestions (thresholds, gates, etc.)
self._update_detection_suggestions()
# 3+3b. Heavy per-profile suggestion passes — only run when the labeled
# cycle count has grown since the last update (skips passes for unlabeled /
# noise / duplicate ends with no new data).
labeled_count = sum(
1 for c in self.profile_store.get_past_cycles()
if isinstance(c, dict)
and c.get("profile_name")
and c.get("profile_name") != "noise"
and c.get("status") in ("completed", "force_stopped")
)
if labeled_count > self._last_suggestions_labeled_count:
self._last_suggestions_labeled_count = labeled_count
self._update_model_suggestions()
self._update_detection_suggestions()
# 4. Run multi-cycle batch simulation when enough new labeled cycles have accumulated
self._maybe_run_batch_simulation()
@@ -517,18 +535,27 @@ class LearningManager:
# even if the matcher was confident. Downgrade to a feedback request so
# the user can verify the match; this catches confident but wrong labels.
ml_quality = cycle_data.get("ml_quality_score")
ml_suspicious = (
isinstance(ml_quality, float)
and ml_quality >= ML_QUALITY_SUSPICIOUS_THRESHOLD
)
# Use float() so numpy scalars (float32/float64) returned by resolve_scorer
# are accepted — isinstance(numpy_float, float) is False in NumPy ≥ 2.0.
# Wrap in try/except so non-numeric sentinel values are silently ignored.
try:
ml_suspicious = (
ml_quality is not None
and float(ml_quality) >= ML_QUALITY_SUSPICIOUS_THRESHOLD
)
except (TypeError, ValueError):
ml_suspicious = False
# Also downgrade when the cycle's power trace is mostly outside the
# profile envelope band (low conformance = the shape matched but the
# actual power levels are inconsistent with the profile).
_conformance = cycle_data.get("envelope_conformance")
envelope_suspicious = (
isinstance(_conformance, float)
and _conformance < 0.40
)
try:
envelope_suspicious = (
_conformance is not None
and float(_conformance) < 0.40
)
except (TypeError, ValueError):
envelope_suspicious = False
if route_conf >= auto_label_conf:
if ml_suspicious or envelope_suspicious:
if ml_suspicious:
+184 -39
View File
@@ -25,7 +25,9 @@ import hashlib
import inspect
import math
import uuid
import asyncio
from asyncio import Task
from collections.abc import Coroutine
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any, cast
import numpy as np
@@ -380,6 +382,7 @@ class WashDataManager:
self._notify_finish_services: list[str] = []
self._notify_live_services: list[str] = []
self._notify_actions: list[dict[str, Any]] = []
self._notify_script: Any = None # cached Script; invalidated on options reload
self._notify_people: list[str] = []
self._notify_cycle_timers: list[dict[str, Any]] = []
self._fired_cycle_timers: set[int] = set()
@@ -485,6 +488,12 @@ class WashDataManager:
self._sample_intervals: list[float] = []
self._sample_interval_stats: dict[str, Any] = {}
self._matching_task: Task[Any] | None = None
self._cycle_end_task: Task[Any] | None = None
# Detached store-touching tasks (matching trigger, active-cycle clear,
# post-cycle processing) tracked so async_shutdown can cancel them before a
# reload/unload swaps the ProfileStore out from under them.
self._background_tasks: set[Task[Any]] = set()
self._is_shutdown: bool = False
self._last_state_save = 0.0
self._last_cycle_end_time: datetime | None = None
self._remove_state_expiry_timer = None
@@ -503,7 +512,9 @@ class WashDataManager:
self.entry_id,
min_duration_ratio=config_entry.options.get(
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO,
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO_BY_DEVICE.get(
self.device_type, DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO
),
),
max_duration_ratio=config_entry.options.get(
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
@@ -769,7 +780,7 @@ class WashDataManager:
# Snapshotted for thread safety indirectly by task logic
# We don't need a wrapper task if we unify with _update_estimates matching
# but for now let's keep the detector callback as a trigger
self.hass.async_create_task(self._async_perform_combined_matching(readings))
self._spawn_tracked(self._async_perform_combined_matching(readings))
return (None, 0.0, 0.0, None)
self.detector = CycleDetector(
@@ -2060,7 +2071,9 @@ class WashDataManager:
new_min_ratio = float(
config_entry.options.get(
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO,
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO_BY_DEVICE.get(
self.device_type, DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO
),
)
)
new_max_ratio = float(
@@ -2074,6 +2087,10 @@ class WashDataManager:
self.profile_store.set_duration_ratio_limits(
min_ratio=new_min_ratio, max_ratio=new_max_ratio
)
# Keep the detector's copy in sync: _should_defer_finish() reads
# detector.config.min_duration_ratio, which otherwise keeps the
# construction-time value until a restart (diverging from the matcher).
self.detector.config.min_duration_ratio = new_min_ratio
self._logger.info(
"Updated duration ratios: min %.2f%.2f, max %.2f%.2f",
old_min_ratio,
@@ -2106,6 +2123,7 @@ class WashDataManager:
self._notify_actions = list(
cast(list[dict[str, Any]], config_entry.options.get(CONF_NOTIFY_ACTIONS, []) or [])
)
self._notify_script = None
self._notify_people = list(
config_entry.options.get(CONF_NOTIFY_PEOPLE, []) or []
)
@@ -2200,8 +2218,43 @@ class WashDataManager:
self._logger.info("Configuration reloaded successfully")
def _spawn_tracked(self, coro: Coroutine[Any, Any, Any]) -> Task[Any]:
"""Create a detached task and track it so shutdown can cancel it.
Use for fire-and-forget tasks that touch the ProfileStore (matching
trigger, active-cycle clear, post-cycle processing): if a reload/unload
swaps the store out mid-flight, an untracked task would keep writing to the
stale store. The task auto-removes itself from the set when it finishes.
"""
task = self.hass.async_create_task(coro)
# Real HA always returns a Task; guard for degenerate returns (e.g. a
# mocked hass in tests) so tracking never breaks the caller.
if task is not None and hasattr(task, "add_done_callback"):
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
return task
async def async_shutdown(self) -> None:
"""Shutdown."""
self._is_shutdown = True
# Cancel in-flight matching and cycle-end tasks so they don't race a
# freshly-loaded ProfileStore on reload_config_entry.
_to_await: list[Task[Any]] = []
if self._matching_task and not self._matching_task.done():
self._matching_task.cancel()
_to_await.append(self._matching_task)
if self._cycle_end_task and not self._cycle_end_task.done():
self._cycle_end_task.cancel()
_to_await.append(self._cycle_end_task)
# Cancel every other tracked detached task (matching trigger, active-cycle
# clear, post-cycle processing) for the same reason.
for task in list(self._background_tasks):
if not task.done():
task.cancel()
_to_await.append(task)
# Drain cancelled tasks so they don't race the freshly-reloaded ProfileStore.
if _to_await:
await asyncio.gather(*_to_await, return_exceptions=True)
if self._remove_listener:
self._remove_listener()
if self._remove_external_trigger_listener:
@@ -2759,10 +2812,32 @@ class WashDataManager:
now = dt_util.now()
# Throttle updates to avoid CPU overload on noisy sensors
# BUT always allow updates if power is below min_power (critical end-of-cycle signal).
# Throttle updates to avoid CPU overload on noisy sensors.
# Low-power readings bypass throttling when:
# (a) a cycle is active (RUNNING/ENDING/PAUSED) — critical end-of-cycle signal, or
# (b) this is a genuine power DROP from above min_power — captures power-off events
# that occur before the detector has processed the previous above-threshold reading.
# Without the guard, an idle device at 0W fires an update on every sensor poll (typically
# every 15 s), flooding the detector with zero-value no-ops.
min_p = float(self.detector.config.min_power)
is_low_power = power < min_p
# For the "genuine drop" bypass, compare against the previous RAW sensor
# value (old_state), not _current_power: the latter is only updated after a
# reading passes the throttle, so a suppressed high reading would leave it
# low and the following low reading would be throttled too, missing a short
# high->low transition. old_state reflects the plug's actual prior value.
prev_raw_power = self._current_power
old_state = cast(State | None, event_data.get("old_state"))
if old_state is not None and old_state.state not in (
STATE_UNKNOWN, STATE_UNAVAILABLE
):
try:
prev_raw_power = float(old_state.state)
except ValueError:
pass
is_low_power = power < min_p and (
self.detector.state in (STATE_RUNNING, STATE_PAUSED, STATE_ENDING)
or prev_raw_power >= min_p # genuine drop from active power
)
if (
not is_low_power
@@ -3191,6 +3266,31 @@ class WashDataManager:
self._notify_update()
return
# Secondary zombie guard for unmatched cycles (expected == 0 when no profile
# has been matched). The detector hard-caps RUNNING at 8h but a chatty sensor
# that never goes silent can keep the watchdog from reaching the end state.
# Kill here after 4h so a stuck false-start doesn't run indefinitely.
elif (
expected == 0
and not self._is_user_paused
and not _verified_pause_zombie
and elapsed > 14400
# Gate on the active detector state, not _current_program: the latter is
# only set to "detecting..." on the RUNNING transition, so a cycle stuck
# in STARTING keeps a stale program and would never hit this 4h guard.
and self.detector.state in (
STATE_STARTING, STATE_RUNNING, STATE_PAUSED, STATE_ENDING
)
):
self._logger.warning(
"Watchdog: Unmatched cycle exceeded 4h (%.0fs). Force-ending.",
elapsed,
)
self.detector.force_end(now)
self._current_power = 0.0
self._notify_update()
return
# 1. GHOST CYCLE SUPPRESSOR
# If we are "detecting" for more than 10 minutes and haven't seen an update for 5 minutes,
# it's likely a pump-out spike or an accidental start (ghost cycle).
@@ -3457,6 +3557,14 @@ class WashDataManager:
},
)
self._start_event_fired = True
# Mark the start fully handled ONLY when there is no push to send, so
# the restart-recovery fallback does not re-enter for event-only
# configs. When a push service/action IS configured, leave
# _notified_start False so the push block below still fires: a config
# with both events and push must get both (event delivery is tracked
# separately by _start_event_fired).
if not (self._notify_start_services or self._notify_actions):
self._notified_start = True
# Fire push notification immediately - do not wait for profile matching.
if not self._notified_start and (self._notify_start_services or self._notify_actions):
@@ -3512,7 +3620,7 @@ class WashDataManager:
stranded in a terminal state with a stale active snapshot until the next
cycle. Deliberately does NOT persist, notify, or run the learning pipeline.
"""
self.hass.async_create_task(self.profile_store.async_clear_active_cycle())
self._spawn_tracked(self.profile_store.async_clear_active_cycle())
# Anchor the terminal state so _handle_state_expiry (and power-off) can act,
# then arm the expiry timer that resets terminal -> Off after the reset delay.
self._cycle_completed_time = dt_util.now()
@@ -3606,9 +3714,10 @@ class WashDataManager:
# detector into a fresh RUNNING before post-processing completes). See B1 in
# _async_process_cycle_end.
end_token = self._ranking_snapshot_cycle_id
self.hass.async_create_task(
self._async_process_cycle_end(cycle_data, cycle_token=end_token)
)
if not self._is_shutdown:
self._cycle_end_task = self._spawn_tracked(
self._async_process_cycle_end(cycle_data, cycle_token=end_token)
)
def _ml_end_confidence(
self, points: list[tuple[float, float]], expected_duration: float
@@ -3811,7 +3920,11 @@ class WashDataManager:
self._logger,
)
def _compute_cycle_quality_score(self, cycle_data: dict[str, Any]) -> None:
def _compute_cycle_quality_score(
self,
cycle_data: dict[str, Any],
past_cycles_snapshot: list[dict[str, Any]] | None = None,
) -> None:
"""Score a just-finished cycle with the hybrid_curve_quality model (opt-in).
When ML models are enabled for this device, computes P(cycle is a problem)
@@ -3842,7 +3955,16 @@ class WashDataManager:
durations: list[float] = []
energies: list[float] = []
peaks: list[float] = []
for c in self.profile_store.get_past_cycles():
# This function runs in an executor thread and the event loop may
# append cycles concurrently; iterating (or even copying) the live list
# here is a data race. Prefer the snapshot taken on the event loop at
# the call site; only fall back to a local copy for direct callers.
cycles = (
past_cycles_snapshot
if past_cycles_snapshot is not None
else list(self.profile_store.get_past_cycles())
)
for c in cycles:
if c.get("profile_name") != profile_name:
continue
if c.get("duration") is not None:
@@ -4265,8 +4387,12 @@ class WashDataManager:
from .ml.engine import ml_models_enabled # noqa: PLC0415
if ml_models_enabled(self.config_entry.options):
# Snapshot past_cycles on the event loop before offloading: iterating
# (or copying) the live list inside the executor races the loop
# appending this just-finished cycle.
past_cycles_snapshot = list(self.profile_store.get_past_cycles())
await self.hass.async_add_executor_job(
self._compute_cycle_quality_score, cycle_data
self._compute_cycle_quality_score, cycle_data, past_cycles_snapshot
)
# Add cycle to store immediately (still sync but offloadable parts optimized
@@ -4331,10 +4457,10 @@ class WashDataManager:
# If a new cycle started during the awaits above, it now owns the active
# snapshot; clearing it here would strip the new cycle's restart-resilience.
if cycle_token is None or self._ranking_snapshot_cycle_id == cycle_token:
self.hass.async_create_task(self.profile_store.async_clear_active_cycle())
self._spawn_tracked(self.profile_store.async_clear_active_cycle())
# Auto post-process: merge fragmented cycles from last 3 hours
self.hass.async_create_task(self._run_post_cycle_processing())
self._spawn_tracked(self._run_post_cycle_processing())
# Prepare cycle data for event (enrich if needed)
# IMPORTANT: Exclude large fields to prevent exceeding HA's 32KB event data limit
@@ -5079,33 +5205,52 @@ class WashDataManager:
if not actions:
return False
try:
script = script_helper.Script(
self.hass,
actions,
name=f"{self.config_entry.title} notification",
domain=DOMAIN,
logger=_LOGGER,
)
except (ValueError, TypeError, HomeAssistantError) as err:
self._logger.error(
"Invalid notification action configuration for %s: %s",
self.config_entry.title,
err,
)
return False
except Exception as err:
self._logger.exception(
"Unexpected error while building notification actions for %s: %s",
self.config_entry.title,
err,
)
return False
if self._notify_script is None:
try:
self._notify_script = script_helper.Script(
self.hass,
actions,
name=f"{self.config_entry.title} notification",
domain=DOMAIN,
logger=_LOGGER,
)
except (ValueError, TypeError, HomeAssistantError) as err:
self._logger.error(
"Invalid notification action configuration for %s: %s",
self.config_entry.title,
err,
)
return False
except Exception as err:
self._logger.exception(
"Unexpected error while building notification actions for %s: %s",
self.config_entry.title,
err,
)
return False
script = self._notify_script
try:
self.hass.async_create_task(
action_task = self.hass.async_create_task(
script.async_run(variables, context=Context())
)
# This method is synchronous, so the script runs fire-and-forget: a
# failure inside async_run() would otherwise land after we return True
# and be swallowed. Surface it via a done-callback so an action-only
# setup at least logs the drop. (A user-visible fallback would require
# awaiting, i.e. making the whole notification-dispatch chain async.)
def _log_action_failure(task: Task[Any]) -> None:
if task.cancelled():
return
exc = task.exception()
if exc is not None:
self._logger.warning(
"Notification action execution failed for %s: %s",
self.config_entry.title,
exc,
)
action_task.add_done_callback(_log_action_failure)
return True
except HomeAssistantError as err:
self._logger.warning(
@@ -6118,7 +6263,7 @@ class WashDataManager:
@property
def samples_recorded(self):
"""Return the number of power samples recorded in current cycle."""
return len(self.detector.get_power_trace())
return self.detector.samples_recorded
@property
def sample_interval_stats(self):
+1 -1
View File
@@ -21,5 +21,5 @@
"requirements": [
"numpy"
],
"version": "0.5.1"
"version": "0.5.2"
}
@@ -116,6 +116,28 @@ def resolve_scorer(capability: str, store: object | None):
# classifier and regression capability keys are disjoint today, but this
# guard keeps it safe if a key were ever reused.
if isinstance(spec, dict) and spec.get("kind") != "standardized_linear":
# Feature-column schema guard: a spec promoted under an older
# FEATURE_COLUMNS must be dropped rather than silently scoring on a
# stale/neutral-filled schema. The call-time guard already catches
# shape mismatches, but this catches them at load time and logs
# clearly, so users see a single warm-up warning instead of a
# per-inference warning storm.
module_name = _MODEL_MODULES.get(capability)
if module_name is not None:
try:
_bm = importlib.import_module(f"{__package__}.{module_name}")
_expected = list(getattr(_bm, "FEATURE_COLUMNS", []))
_stored = list(spec.get("feature_columns") or [])
if _expected and _stored and _stored != _expected:
_LOGGER.warning(
"Promoted spec for %r has stale feature schema "
"(%d cols vs current %d); reverting to baseline.",
capability, len(_stored), len(_expected),
)
return _baseline()
except Exception: # noqa: BLE001 - schema check must not break inference
pass
from .trainer import score_spec
def _on_device_score(feats, _s=spec):
@@ -165,6 +187,21 @@ def resolve_regressor(capability: str, store: object | None):
record = versions.get(capability)
spec = record.get("spec") if isinstance(record, dict) else None
if isinstance(spec, dict) and spec.get("kind") == "standardized_linear":
# Feature-column schema guard for regression specs.
try:
from .feature_extraction import PROGRESS_FEATURE_COLUMNS
_expected_r = list(PROGRESS_FEATURE_COLUMNS)
_stored_r = list(spec.get("feature_columns") or [])
if _expected_r and _stored_r and _stored_r != _expected_r:
_LOGGER.warning(
"Promoted regression spec for %r has stale feature schema "
"(%d cols vs current %d); reverting to inert.",
capability, len(_stored_r), len(_expected_r),
)
return (None, None)
except Exception: # noqa: BLE001 - schema check must not break inference
pass
from .trainer import predict_value_spec
def _on_device_predict(feats, _s=spec):
@@ -469,7 +469,9 @@ def _train_regression_capability(
}
preds = np.clip(T.predict_matrix_spec(spec_probe, X_te), 0.0, 1.0)
metrics = T.regression_metrics(y_te, preds)
model_mae = float(metrics.get("mae") or 1.0)
# Explicit None check — `or 1.0` would coerce MAE=0.0 to 1.0, rejecting a
# perfect regressor and blocking promotion.
model_mae = float(metrics.get("mae") if metrics.get("mae") is not None else 1.0)
naive_col = columns.index("elapsed_over_expected") if "elapsed_over_expected" in columns else 0
naive = np.clip(X_te[:, naive_col], 0.0, 1.0)
+24 -7
View File
@@ -107,6 +107,10 @@ DEFAULT_RECENT_CYCLES = 20
MAX_BATCH_CYCLES = 50
# Cap the per-cycle event log so a pathological trace cannot bloat the payload.
MAX_EVENTS_PER_CYCLE = 300
# Cap the per-cycle timeline series so a very long cycle (4h dishwasher = ~2800 pts)
# does not bloat the task result. Points are thinned at finalize time — evenly-spaced,
# so the shape is preserved rather than truncated.
MAX_SERIES_PER_CYCLE = 600
# Override keys the Playground honours, mapped to CycleDetectorConfig fields.
# Only detection-relevant knobs matter; everything else in settings_override is
@@ -232,7 +236,7 @@ def _build_match_snapshots(
) -> tuple[list[dict[str, Any]], dict[str, Any], dict[str, list[str]], dict[str, Any]]:
"""Prepare the matcher snapshots + config once from the store.
Mirrors :meth:`ProfileStore.match_profile`: one snapshot per profile using
Mirrors the store's async matching path: one snapshot per profile using
its sample cycle's decompressed trace, plus the store's live matching config
(with any on-device tuned weight overrides merged in).
@@ -357,7 +361,7 @@ def _readings_from_cycle(
_PROGRESS_STATES = (STATE_RUNNING, STATE_PAUSED, STATE_ENDING)
# States in which no progress estimate is shown (mirrors _update_remaining_only).
_DEAD_STATES = (STATE_OFF, STATE_UNKNOWN, STATE_IDLE)
_SIM_SERIES_THROTTLE_S = 5.0 # production recomputes progress every 5s
_SIM_SERIES_THROTTLE_S = 30.0 # cap estimator calls; 5s matched cadence made this a no-op
def simulate_cycle_detail(
@@ -954,12 +958,20 @@ class _DetailSim:
alerts.append({"code": "underrun", "severity": "warn",
"detail": f"Finished at {ratio:.0%} of typical duration."})
series = self.series
if len(series) > MAX_SERIES_PER_CYCLE:
# Thin evenly so the shape is preserved (first + last always kept).
# Span (len-1)/(N-1) so the final index lands on the true last point
# (a plain len/N tops out below it and drops the terminal sample).
step = (len(series) - 1) / (MAX_SERIES_PER_CYCLE - 1)
series = [series[round(i * step)] for i in range(MAX_SERIES_PER_CYCLE)]
return {
"cycle_id": self.cycle.get("id"),
"label": self.label,
"duration_s": self.stored_duration,
"config_summary": _sim_config_summary(self.config),
"series": self.series,
"series": series,
"events": self.events,
"alerts": alerts,
"outcome": outcome,
@@ -1030,10 +1042,13 @@ def _run_rows(
settings_override: dict[str, Any] | None,
options: dict[str, Any],
price: float | None,
prebuilt: tuple[Any, Any, Any, Any] | None = None,
) -> list[dict[str, Any]]:
# Snapshots are store-derived (independent of the cycle and the detector-level
# settings_override), so build them ONCE and reuse across all cycles/values.
prebuilt = _build_match_snapshots(store)
# Callers that drive many chunks should pass prebuilt= to avoid rebuilding per chunk.
if prebuilt is None:
prebuilt = _build_match_snapshots(store)
rows: list[dict[str, Any]] = []
for cycle in cycles:
detail = simulate_cycle_detail(
@@ -1054,6 +1069,7 @@ def run_playground_history(
options: dict[str, Any] | None,
price: float | None,
concurrency: int,
prebuilt: tuple[Any, Any, Any, Any] | None = None,
) -> dict[str, Any]:
"""Per-cycle rows for the Test-on-history table, plus a before/after diff when
``settings_override`` is set. Executor-safe; never raises."""
@@ -1076,11 +1092,11 @@ def run_playground_history(
selected = selected[:concurrency]
override = settings_override or None
rows = _run_rows(store, selected, base_config, override, options, price)
rows = _run_rows(store, selected, base_config, override, options, price, prebuilt)
payload: dict[str, Any] = {"rows": rows, "summary": _rows_summary(rows)}
if override:
base_rows = _run_rows(store, selected, base_config, None, options, price)
base_rows = _run_rows(store, selected, base_config, None, options, price, prebuilt)
payload["baseline_rows"] = base_rows
payload["baseline_summary"] = _rows_summary(base_rows)
payload["diff"] = _diff_rows(base_rows, rows)
@@ -1272,6 +1288,7 @@ def run_playground_sweep(
concurrency: int,
param_y: str | None = None,
values_y: list[float] | None = None,
prebuilt: tuple[Any, Any, Any, Any] | None = None,
) -> dict[str, Any]:
"""Sweep one param (1D curve) or two params (2D heatmap) and score each point
by ``objective`` computed from the per-cycle rows. Executor-safe; never raises.
@@ -1296,7 +1313,7 @@ def run_playground_sweep(
selected = selected[:concurrency]
def _metric_for(override: dict[str, Any]) -> tuple[float | None, dict[str, Any]]:
rows = _run_rows(store, selected, base_config, override, options, price)
rows = _run_rows(store, selected, base_config, override, options, price, prebuilt)
return objective_metric(rows, objective), _rows_summary(rows)
current_x = _sim_config_summary(base_config).get(
+89 -86
View File
@@ -3081,9 +3081,16 @@ class ProfileStore:
separate ``reference_cycles`` list so imported cycles never enter usage stats.
"""
dest = self._data["past_cycles"] if target is None else target
# Generate SHA256 ID
# Generate SHA256 ID — dedup suffix avoids collisions when two cycles share
# an identical raw start_time + duration (e.g. bulk reference-cycle imports).
existing_ids = {c.get("id") for c in dest if isinstance(c, dict)}
unique_str = f"{cycle_data['start_time']}_{cycle_data['duration']}"
cycle_data["id"] = hashlib.sha256(unique_str.encode()).hexdigest()[:12]
candidate = hashlib.sha256(unique_str.encode()).hexdigest()[:12]
suffix = 0
while candidate in existing_ids:
suffix += 1
candidate = hashlib.sha256(f"{unique_str}_{suffix}".encode()).hexdigest()[:12]
cycle_data["id"] = candidate
# Preserve profile_name if already set by manager; default to None otherwise
if "profile_name" not in cycle_data:
@@ -4814,63 +4821,6 @@ class ProfileStore:
is_prefix_ambiguous=is_prefix_ambiguous,
)
def match_profile(
self, power_data: list[tuple[str, float]], duration: float
) -> MatchResult:
"""Synchronous wrapper for matching (for use in executor tasks)."""
# Convert to list for worker
p_list = [p[1] for p in power_data]
# Prepare snapshots safely
snapshots: list[dict[str, Any]] = []
# Accessing self._data in thread is generally safe for reads if not modifying
for name, profile in self._data["profiles"].items():
sample_id = profile.get("sample_cycle_id")
sample_cycle = next((c for c in self._data["past_cycles"] if c["id"] == sample_id), None)
if not sample_cycle:
continue
# Decompress sample data
sample_p_data = decompress_power_data(sample_cycle)
if not sample_p_data:
continue
snapshots.append({
"name": name,
"avg_duration": profile.get("avg_duration", sample_cycle.get("duration", 0)),
"sample_power": [x[1] for x in sample_p_data],
})
config = {
"min_duration_ratio": self._min_duration_ratio,
"max_duration_ratio": self._max_duration_ratio,
"dtw_bandwidth": self.dtw_bandwidth,
# On-device tuned scoring weights (opt-in); empty = shipped defaults.
**self._matching_overrides(),
}
candidates = analysis.compute_matches_worker(
p_list, duration, cast(Any, snapshots), config
)
if not candidates:
return MatchResult(None, 0.0, 0.0, None, [], False, 0.0)
best = candidates[0]
margin, is_ambiguous = _ambiguity_from_candidates(candidates)
return MatchResult(
best["name"],
best["score"],
best["profile_duration"],
None,
candidates,
is_ambiguous,
margin,
ranking=candidates,
)
async def async_verify_alignment(
self,
profile_name: str,
@@ -4952,23 +4902,34 @@ class ProfileStore:
if not phases:
return None
# Guard against non-dict entries (legacy/malformed storage) and
# None/non-numeric start values before sorting.
phases_sorted = sorted(
phases,
key=lambda p: float(p.get("start", 0)),
[p for p in phases if isinstance(p, dict)],
key=lambda p: float(p.get("start") or 0),
)
last_matched: str | None = None
for phase in phases_sorted:
p_start = phase.get("start", 0)
p_end = phase.get("end", 0)
p_start = float(phase.get("start") or 0)
p_end = float(phase.get("end") or 0)
if p_end <= p_start:
# Missing or zero end — phase range is invalid; skip silently.
continue
if p_start <= duration <= p_end:
return str(phase.get("name", "Unknown"))
if p_start <= duration:
# Track the last phase whose start we have passed; used for gap fallback.
last_matched = str(phase.get("name", "Unknown"))
# If duration is outside explicit bounds, keep a phase label anyway so
# entities avoid falling back to generic running/starting states.
# Duration is outside explicit bounds keep a label so entities avoid
# falling back to generic running/starting states.
if phases_sorted:
if duration < float(phases_sorted[0].get("start", 0)):
if duration < float(phases_sorted[0].get("start") or 0):
return str(phases_sorted[0].get("name", "Unknown"))
return str(phases_sorted[-1].get("name", "Unknown"))
# Return the phase that just ended (last_matched) when in a gap, or the
# final phase when past all ranges, rather than always the absolute last.
return last_matched or str(phases_sorted[-1].get("name", "Unknown"))
return None
@@ -5835,6 +5796,19 @@ class ProfileStore:
cycle["sampling_interval"] = round(sampling_interval, 1)
cycle["duration"] = new_duration
# Recompute energy and max_power from the trimmed trace so stored stats
# reflect the kept window (not the pre-trim full cycle).
ts_s = np.array([r[0] for r in renorm], dtype=float)
p_s = np.array([r[1] for r in renorm], dtype=float)
if len(ts_s) > 1:
cycle["energy_wh"] = round(
integrate_wh(ts_s, p_s, max_gap_s=energy_gap_threshold_s(ts_s)), 3
)
cycle["max_power"] = round(float(np.max(p_s)), 1)
elif len(ts_s) == 1:
cycle["energy_wh"] = 0.0
cycle["max_power"] = round(float(p_s[0]), 1)
# Keep end_time consistent with the updated start_time and duration
new_start_ts = _value_to_timestamp(cycle.get("start_time"))
if new_start_ts is not None:
@@ -5986,38 +5960,55 @@ class ProfileStore:
return []
cycle = cycles[idx]
cycles.pop(idx) # Remove original
new_ids: list[str] = []
original_profile = cycle.get("profile_name")
# Validate before mutating — early returns below this point would otherwise
# permanently delete the source cycle from the live list.
start_dt_base_parsed = _parse_start_dt(cycle["start_time"])
if not start_dt_base_parsed:
return []
start_ts = start_dt_base_parsed.timestamp()
# Decompress original data
p_data_tuples = decompress_power_data(cycle)
if not p_data_tuples:
return []
# Pre-materialise all segment bounds before touching history; a malformed
# segment that raises after the pop would leave the source cycle permanently
# deleted with only partial children created.
try:
materialized_segs: list[tuple[float, float, str | None]] = []
for seg in segments:
if isinstance(seg, (list, tuple)):
seg_s = float(seg[0])
seg_e = float(seg[1])
seg_p: str | None = None
else:
seg_s = float(seg["start"])
seg_e = float(seg["end"])
seg_p = seg.get("profile")
if seg_e <= seg_s:
return []
materialized_segs.append((seg_s, seg_e, seg_p))
except (TypeError, ValueError, KeyError, IndexError):
return []
# A split into fewer than two pieces is not a real split; guard here so
# an empty or single-element segments list can't pop the source cycle and
# leave zero or one orphaned children.
if len(materialized_segs) < 2:
return []
cycles.pop(idx) # Remove original — all segments validated, safe to mutate
new_ids: list[str] = []
original_profile = cycle.get("profile_name")
start_ts = start_dt_base_parsed.timestamp()
# Prepare points (relative seconds)
points: list[tuple[float, float]] = []
for offset_seconds, val in p_data_tuples:
points.append((float(offset_seconds), float(val)))
# Create new cycles
for seg in segments:
if isinstance(seg, (list, tuple)):
seg_tuple = cast(tuple[Any, ...] | list[Any], seg)
seg_start = float(seg_tuple[0])
seg_end = float(seg_tuple[1])
seg_profile = None
else:
seg_start = float(seg["start"])
seg_end = float(seg["end"])
seg_profile = seg.get("profile")
for seg_start, seg_end, seg_profile in materialized_segs:
seg_dur = seg_end - seg_start
new_cycle_start = start_dt_base_parsed + timedelta(seconds=seg_start)
new_cycle_start_ts = new_cycle_start.timestamp()
@@ -6051,14 +6042,14 @@ class ProfileStore:
"power_data": p_data_abs,
"profile_name": seg_profile
}
self.add_cycle(new_cycle)
await self.async_add_cycle(new_cycle)
new_ids.append(new_cycle["id"])
# Fix profile refs (handle original sample cycle logic)
original_sample_id = cycle.get("id")
best_replacement_id = None
longest_dur = 0
new_cycles_objs = [c for c in cycles if c["id"] in new_ids] # 'cycles' is mutated by add_cycle
new_cycles_objs = [c for c in cycles if c["id"] in new_ids]
for c in new_cycles_objs:
d = c.get("duration", 0)
@@ -6239,6 +6230,18 @@ class ProfileStore:
c1["power_data"] = new_power_data
# Recompute energy from the merged trace (merge keeps only c1's original
# energy_wh which under-represents the combined cycle).
try:
ts_s = np.array([pt[0] for pt in new_power_data], dtype=float)
p_s = np.array([pt[1] for pt in new_power_data], dtype=float)
if len(ts_s) > 1:
c1["energy_wh"] = round(
integrate_wh(ts_s, p_s, max_gap_s=energy_gap_threshold_s(ts_s)), 3
)
except Exception: # pylint: disable=broad-exception-caught
self._logger.warning("Energy recompute failed after cycle merge", exc_info=True)
# New Hash ID
new_id = hashlib.sha256(f"{c1['start_time']}_{c1['duration']}".encode()).hexdigest()[:12]
old_c1_id = c1["id"]
+12 -1
View File
@@ -127,7 +127,14 @@ def compute_setup_phase(
# None is also accepted (function signature allows it) and treated as no gap.
# Also skipped once the user has permanently dismissed ("never") or snoozed the
# Phase 1 guidance via setup_skip_phase1.
if has_real and not (coverage_gap and coverage_gap.get("suggest_create")) and not _is_step_suppressed("setup_skip_phase1", skipped_steps, now):
# Auto-graduated when the device is clearly established: ≥2 real profiles, or
# ≥5 cycles assigned to real profiles. At that point the "record your first
# cycle" nudge is stale and misleading regardless of whether the user ever
# clicked Skip.
_established = len(real) >= 2 or _real_cycle_count(past_cycles, real) >= 5
_coverage_gap_actionable = bool(coverage_gap and coverage_gap.get("suggest_create"))
_phase1_suppressed = _is_step_suppressed("setup_skip_phase1", skipped_steps, now)
if has_real and not _established and not _coverage_gap_actionable and not _phase1_suppressed:
if has_recorded:
first_recorded_profile = _first_recorded_profile_name(past_cycles, real)
return SetupPhaseResult(
@@ -171,6 +178,10 @@ def _real_profile_names(profile_names: list[str], past_cycles: list[dict]) -> se
return {n for n in profile_names if n in named}
def _real_cycle_count(past_cycles: list[dict], real: set[str]) -> int:
return sum(1 for c in past_cycles if c.get("profile_name") in real)
def _has_recorded_cycles(past_cycles: list[dict], real: set[str]) -> bool:
return any(
(c.get("meta") or {}).get("source") == "recorder"
@@ -481,15 +481,17 @@ def reconcile_suggestions(
adjust(CONF_MIN_POWER, round(stop * 0.8, 1), "the stop threshold")
# ── Rule 2: min_off_gap >= off_delay ──────────────────────────────────
# Prefer raising the gap (derived); lower off_delay only when the gap is
# a fixed current value.
# Always cascade-RAISE the gap to the off delay; never lower off_delay.
# Lowering off_delay makes end/pause detection more aggressive and can
# split a genuine multi-minute soak pause into two separate cycles. The
# detector already takes max(off_delay, min_off_gap) at runtime, so
# raising the gap is the safe (and no-op-at-runtime) direction; the
# smart_debounce coupling that a larger gap would otherwise inflate is
# bounded in cycle_detector.py.
min_gap = eff(CONF_MIN_OFF_GAP)
off_delay = eff(CONF_OFF_DELAY)
if off_delay is not None and min_gap is not None and min_gap < off_delay and in_out(CONF_MIN_OFF_GAP, CONF_OFF_DELAY):
if is_original(CONF_MIN_OFF_GAP):
adjust(CONF_MIN_OFF_GAP, off_delay, "the off delay")
else:
adjust(CONF_OFF_DELAY, min_gap, "the minimum off gap")
adjust(CONF_MIN_OFF_GAP, off_delay, "the off delay")
# ── Rule 3a: watchdog_interval >= 2 × sampling_interval ───────────────
sampling = eff(CONF_SAMPLING_INTERVAL)
+50 -6
View File
@@ -28,6 +28,7 @@ listener to push updates and calls :func:`get_registry` to read/kick/cancel.
"""
from __future__ import annotations
import asyncio
import logging
import uuid
from collections import OrderedDict
@@ -125,6 +126,9 @@ class TaskRegistry:
def __init__(self) -> None:
self._tasks: OrderedDict[str, Task] = OrderedDict()
self._listeners: set[Callable[[dict[str, Any]], None]] = set()
# Raw asyncio Task handles linked by ws_api so cancel_entry_tasks can
# actually inject CancelledError, not just set the polling flag.
self._asyncio_tasks: dict[str, asyncio.Task[Any]] = {}
# -- listeners -----------------------------------------------------------
def add_listener(self, cb: Callable[[dict[str, Any]], None]) -> Callable[[], None]:
@@ -165,6 +169,15 @@ class TaskRegistry:
self._evict()
return task
def link_asyncio_task(self, task_id: str, asyncio_task: asyncio.Task[Any]) -> None:
"""Associate the raw asyncio Task with a registry entry.
Call this immediately after hass.async_create_task() so that
cancel_entry_tasks() can inject CancelledError rather than only
setting the polling flag.
"""
self._asyncio_tasks[task_id] = asyncio_task
def update(
self,
task: Task,
@@ -197,6 +210,11 @@ class TaskRegistry:
result: Any = None,
error: str | None = None,
) -> None:
# A cancellation that races a late-arriving normal completion must win:
# once STATE_CANCELLED is set, no subsequent finish() call can downgrade it.
if task.state == STATE_CANCELLED and state != STATE_CANCELLED:
return
self._asyncio_tasks.pop(task.id, None)
task.state = state
task.error = error
if result is not None:
@@ -215,6 +233,28 @@ class TaskRegistry:
return True
return False
def cancel_entry_tasks(self, entry_id: str) -> list[asyncio.Task[Any]]:
"""Cancel all running tasks for an entry and mark them finished.
Sets the polling flag and when a raw asyncio Task was linked via
link_asyncio_task injects CancelledError so the coroutine stops
promptly rather than waiting for the next cancel_requested poll.
Returns the list of raw asyncio Tasks that were cancelled so the caller
can await their completion (ensuring finally blocks run and locks are
released) before tearing down shared state.
Called during async_unload_entry.
"""
cancelled: list[asyncio.Task[Any]] = []
for task in list(self._tasks.values()):
if task.entry_id == entry_id and task.state == STATE_RUNNING:
task._cancelled = True # noqa: SLF001
raw = self._asyncio_tasks.pop(task.id, None)
if raw is not None and not raw.done():
raw.cancel()
cancelled.append(raw)
self.finish(task, state=STATE_CANCELLED)
return cancelled
# -- reads ---------------------------------------------------------------
def get(self, task_id: str) -> Task | None:
return self._tasks.get(task_id)
@@ -227,12 +267,16 @@ class TaskRegistry:
]
def _evict(self) -> None:
finished = sorted(
[t for t in self._tasks.values() if t.state != STATE_RUNNING],
key=lambda t: t.finished_at or 0.0,
)
while len(finished) > _MAX_FINISHED:
self._tasks.pop(finished.pop(0).id, None)
# Evict per entry_id so that a busy entry cannot displace another entry's
# finished results before the panel reads them via get_task_result.
by_entry: dict[str, list[Task]] = {}
for t in self._tasks.values():
if t.state != STATE_RUNNING:
by_entry.setdefault(t.entry_id, []).append(t)
for tasks in by_entry.values():
tasks.sort(key=lambda t: t.finished_at or 0.0)
while len(tasks) > _MAX_FINISHED:
self._tasks.pop(tasks.pop(0).id, None)
def get_registry(hass: HomeAssistant) -> TaskRegistry:
@@ -954,14 +954,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "Спад, по-голям от тази част от текущата мощност, също се третира като рязък (0,6 = 60% спад). Допълва прага на ватовете за всички размери на уреда.",
"label": "Съотношение на рязко падане"
},
"abrupt_drop_watts": {
"doc": "Спад на мощността, по-голям от този, означава цикъла като прекъснат (ръчно отмяна), а не като естествен завършек.",
"label": "Рязко падане"
},
"anti_wrinkle_enabled": {
"doc": "Разпознайте кратките импулси на сушене с ниска мощност, които сушилнята излъчва след основната топлинна фаза, и ги запазете прикрепени към завършения цикъл, вместо да ги четете като нови цикли.",
"label": "Активиране на антибръчково засичане"
@@ -1714,7 +1706,6 @@
"other": "Друго"
},
"pg_desc": {
"abrupt_drop_watts": "Рязък спад се третира като незабавен край",
"completion_min_seconds": "Най-краткото изпълнение, което се счита за истински цикъл",
"end_repeat_count": "Ниски отчитания поред преди приключване",
"interrupted_min_seconds": "Кратки цикли се отбелязват като прекъснати",
@@ -954,14 +954,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "Pad veći od ovog udjela struje također se tretira kao nagli (0,6 = pad od 60%). Dopunjuje prag u vatima za različite veličine uređaja.",
"label": "Omjer naglog opadanja"
},
"abrupt_drop_watts": {
"doc": "Pad snage veći od ovog označava ciklus kao Prekinut (ručno otkazivanje), a ne kao prirodni završetak.",
"label": "Naglo opadanje"
},
"anti_wrinkle_enabled": {
"doc": "Prepoznajte kratke impulse male snage koje sušilica emituje nakon glavne faze zagrijavanja i držite ih priključene na završeni ciklus umjesto da ih čitate kao nove cikluse.",
"label": "Omogućiti detekciju anti-gužvanja"
@@ -1714,7 +1706,6 @@
"other": "Ostalo"
},
"pg_desc": {
"abrupt_drop_watts": "Nagli pad se tretira kao trenutni završetak",
"completion_min_seconds": "Najkraće pokretanje koje se računa kao pravi ciklus",
"end_repeat_count": "Niska očitanja zaredom prije završetka",
"interrupted_min_seconds": "Kratki ciklusi označeni kao prekinuti",
@@ -954,14 +954,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "Pokles větší než tento zlomek aktuálního příkonu je také považován za náhlý (0,6 = pokles o 60 %). Doplňuje prahovou hodnotu ve wattech pro spotřebiče různých velikostí.",
"label": "Poměr náhlého poklesu"
},
"abrupt_drop_watts": {
"doc": "Pokles příkonu větší než tato hodnota označuje cyklus jako Přerušený (ručně zrušeno) spíše než přirozené ukončení.",
"label": "Náhlý pokles"
},
"anti_wrinkle_enabled": {
"doc": "Rozpoznejte krátké impulzy s nízkým příkonem, které sušič emituje po hlavní tepelné fázi, a udržte je připojené k dokončenému cyklu místo čtení jako nové cykly.",
"label": "Povolit detekci ochrany před zmačkáním"
@@ -1714,7 +1706,6 @@
"other": "Jiné"
},
"pg_desc": {
"abrupt_drop_watts": "Náhlý pokles považovaný za okamžitý konec",
"completion_min_seconds": "Nejkratší běh, který se počítá jako skutečný cyklus",
"end_repeat_count": "Nízká čtení za sebou před ukončením",
"interrupted_min_seconds": "Krátké cykly označené jako přerušené",
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ Muligvis blandede programmer"
},
"pg_desc": {
"abrupt_drop_watts": "Pludseligt fald behandlet som øjeblikkelig afslutning",
"completion_min_seconds": "Korteste kørsel, der tæller som en rigtig cyklus",
"end_repeat_count": "Lave målinger i træk før afslutning",
"interrupted_min_seconds": "Korte cyklusser markeres som afbrudt",
@@ -978,14 +977,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "Et fald, der er større end denne del af den nuværende effekt, behandles også som brat (0,6 = et fald på 60%). Komplementerer watt-tærsklen på tværs af apparatstørrelser.",
"label": "Ratio for pludseligt fald"
},
"abrupt_drop_watts": {
"doc": "Et krafttab større end dette markerer cyklussen som afbrudt (manuel annullering) snarere end en naturlig finish.",
"label": "Pludseligt fald"
},
"anti_wrinkle_enabled": {
"doc": "Genkend de korte tørretumblerimpulser med lav effekt, som en tørretumbler udsender efter hovedvarmefasen, og hold dem fastgjort til den færdige cyklus i stedet for at læse dem som nye cyklusser.",
"label": "Aktivér anti-rynke registrering"
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ Möglicherweise gemischte Programme"
},
"pg_desc": {
"abrupt_drop_watts": "Plötzlicher Abfall gilt als sofortiges Ende",
"completion_min_seconds": "Kürzester Lauf, der als echter Zyklus zählt",
"end_repeat_count": "Aufeinanderfolgende niedrige Messwerte vor dem Ende",
"interrupted_min_seconds": "Kurze Zyklen werden als unterbrochen markiert",
@@ -978,14 +977,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "Ein Abfall, der größer als dieser Bruchteil der aktuellen Leistung ist, wird ebenfalls als abrupt behandelt (0,6 = ein Abfall von 60 %). Ergänzt den Watt-Grenzwert für alle Gerätegrößen.",
"label": "Abruptes Abfallverhältnis"
},
"abrupt_drop_watts": {
"doc": "Bei einem größeren Leistungsabfall wird der Zyklus als unterbrochen (manueller Abbruch) und nicht als natürliches Ende gekennzeichnet.",
"label": "Abrupter Abfall"
},
"anti_wrinkle_enabled": {
"doc": "Erkennen Sie die kurzen, stromsparenden Wäscheimpulse, die ein Trockner nach der Hauptheizphase abgibt, und behalten Sie im Zusammenhang mit dem abgeschlossenen Zyklus bei, anstatt sie als neue Zyklen zu lesen.",
"label": "Knitterschutzerkennung aktivieren"
@@ -954,14 +954,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "Μια πτώση μεγαλύτερη από αυτό το κλάσμα της τρέχουσας ισχύος αντιμετωπίζεται επίσης ως απότομη (0,6 = πτώση 60%). Συμπληρώνει το όριο watt σε όλα τα μεγέθη συσκευών.",
"label": "Αναλογία Απότομης Πτώσης"
},
"abrupt_drop_watts": {
"doc": "Μια πτώση ισχύος μεγαλύτερη από αυτήν επισημαίνει τον κύκλο ως Διακοπή (μη αυτόματη ακύρωση) και όχι ως φυσικό φινίρισμα.",
"label": "Απότομη Πτώση"
},
"anti_wrinkle_enabled": {
"doc": "Αναγνωρίστε τους σύντομους παλμούς χαμηλής ισχύος που εκπέμπει ένα στεγνωτήριο μετά την κύρια φάση θερμότητας και κρατήστε τους συνδεδεμένους στον τελικό κύκλο αντί να τους διαβάζετε ως νέους κύκλους.",
"label": "Ενεργοποίηση Ανίχνευσης Κατά Τσακίσματος"
@@ -1714,7 +1706,6 @@
"other": "Άλλο"
},
"pg_desc": {
"abrupt_drop_watts": "Απότομη πτώση αντιμετωπίζεται ως άμεση λήξη",
"completion_min_seconds": "Η συντομότερη εκτέλεση που μετρά ως πραγματικός κύκλος",
"end_repeat_count": "Χαμηλές μετρήσεις στη σειρά πριν τη λήξη",
"interrupted_min_seconds": "Οι σύντομοι κύκλοι επισημαίνονται ως διακοπή",
@@ -337,6 +337,7 @@
"default_tab": "Default tab when opening the panel",
"delta": "Δ",
"description": "Description",
"drag_to_resize": "Drag to resize",
"dtw_blend_w": "Blend weight",
"dtw_blended": "Blended score",
"dtw_ddtw": "DDTW score",
@@ -780,6 +781,8 @@
"pg_analysis_hint": "Select a cycle and click ↺ to load analysis.",
"diagnostics_load_failed": "Could not load diagnostics: {error}",
"manual_duration_ref_hint": "Only used when no reference cycle is selected a reference cycle sets the duration from its own length.",
"head_trim_hint": "Remove this many seconds from the start",
"tail_trim_hint": "Remove this many seconds from the end",
"advisory_poor_health": "'{name}' has a low fit score - its recent cycles vary a lot or match weakly. Review its cycles or re-record the profile so matching and time estimates stay accurate.",
"advisory_shape_drift": "'{name}' has drifted significantly from its original power shape. The appliance may have changed behaviour over time (e.g. limescale, wear). Consider re-recording this profile with recent cycles.",
"advisory_shape_drift_corr": "'{name}' has drifted significantly from its original power shape (correlation {corr}). The appliance may have changed behaviour over time (e.g. limescale, wear). Consider re-recording this profile with recent cycles.",
@@ -820,7 +823,6 @@
"share_profile_no_cycles": "No reference cycles mark a cycle as ⭐ in the Cycles tab to include this profile"
},
"pg_desc": {
"abrupt_drop_watts": "Sudden drop treated as immediate end",
"completion_min_seconds": "Shortest run that counts as a real cycle",
"end_repeat_count": "Low readings in a row before ending",
"interrupted_min_seconds": "Short cycles flagged as interrupted",
@@ -931,14 +933,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "A drop larger than this fraction of current power is also treated as abrupt (0.6 = a 60% drop). Complements the watts threshold across appliance sizes.",
"label": "Abrupt Drop Ratio"
},
"abrupt_drop_watts": {
"doc": "A power drop larger than this flags the cycle as Interrupted (manual cancel) rather than a natural finish.",
"label": "Abrupt Drop"
},
"anti_wrinkle_enabled": {
"doc": "Recognise the short low-power tumble pulses a dryer emits after the main heat phase and keep them attached to the finished cycle instead of reading them as new cycles.",
"label": "Enable Anti-Wrinkle Detection"
@@ -1831,4 +1825,4 @@
"show_guidance": "Show guidance"
}
}
}
}
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ Posibles programas mezclados"
},
"pg_desc": {
"abrupt_drop_watts": "Caída repentina tratada como fin inmediato",
"completion_min_seconds": "Ejecución más corta que cuenta como ciclo real",
"end_repeat_count": "Lecturas bajas consecutivas antes de terminar",
"interrupted_min_seconds": "Ciclos cortos marcados como interrumpidos",
@@ -978,14 +977,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "Una caída mayor que esta fracción de la potencia actual también se trata como abrupta (0,6 = una caída del 60%). Complementa el umbral de vatios en todos los tamaños de electrodomésticos.",
"label": "Proporción de caída abrupta"
},
"abrupt_drop_watts": {
"doc": "Una caída de energía mayor que esto indica que el ciclo está interrumpido (cancelación manual) en lugar de un final natural.",
"label": "Caída abrupta"
},
"anti_wrinkle_enabled": {
"doc": "Reconozca los impulsos cortos de baja potencia que emite una secadora después de la fase de calentamiento principal y manténgalos adjuntos al ciclo finalizado en lugar de leerlos como ciclos nuevos.",
"label": "Habilitar detección antiarrugas"
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ Võimalik programmide segu"
},
"pg_desc": {
"abrupt_drop_watts": "Järsk langus käsitletakse kohese lõpuna",
"completion_min_seconds": "Lühim käivitus, mis loetakse päris tsükliks",
"end_repeat_count": "Madalate näitude arv järjest enne lõpetamist",
"interrupted_min_seconds": "Lühikesed tsüklid märgitakse katkestatuks",
@@ -978,14 +977,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "Sellest vooluvõimsuse osast suuremat langust käsitletakse samuti järsuna (0,6 = 60% langus). Täiendab vattide künnist seadme suuruste lõikes.",
"label": "Järsu Languse Suhe"
},
"abrupt_drop_watts": {
"doc": "Sellest suurem võimsuslangus märgib tsükli pigem Katkestatud (käsitsi tühistamine) kui loomuliku viimistlusena.",
"label": "Järsk Langus"
},
"anti_wrinkle_enabled": {
"doc": "Tuvastage lühikesed väikese võimsusega trummelimpulsid, mida kuivati pärast peamist kuumutusfaasi kiirgab, ja hoidke need valmis tsükliga ühendatud, selle asemel, et lugeda neid uute tsüklitena.",
"label": "Luba Kortsudevastane Tuvastamine"
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ Mahdollisesti sekoittuneet ohjelmat"
},
"pg_desc": {
"abrupt_drop_watts": "Äkillinen pudotus tulkitaan välittömäksi lopuksi",
"completion_min_seconds": "Lyhin ajo, joka lasketaan oikeaksi sykliksi",
"end_repeat_count": "Peräkkäisten matalien lukemien määrä ennen päättymistä",
"interrupted_min_seconds": "Lyhyet syklit merkitään keskeytyneiksi",
@@ -978,14 +977,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "Tätä suurempaa pudotusta virran tehosta käsitellään myös äkillisenä (0,6 = 60 % pudotus). Täydentää wattirajaa eri laitekokojen välillä.",
"label": "Äkillisen pudotuksen suhde"
},
"abrupt_drop_watts": {
"doc": "Tätä suurempi tehohäviö merkitsee syklin keskeytetyksi (manuaalinen peruutus) luonnollisen viimeistelyn sijaan.",
"label": "Äkillinen pudotus"
},
"anti_wrinkle_enabled": {
"doc": "Tunnista lyhyet vähätehoiset rumpupulssit, joita kuivausrumpu lähettää päälämpövaiheen jälkeen, ja pidä ne kiinni valmiissa jaksossa sen sijaan, että luet ne uusina jaksoina.",
"label": "Ota ryppyjenesto käyttöön"
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ Programmes peut-être mélangés"
},
"pg_desc": {
"abrupt_drop_watts": "Chute soudaine traitée comme une fin immédiate",
"completion_min_seconds": "Plus courte exécution comptant comme un vrai cycle",
"end_repeat_count": "Lectures basses consécutives avant la fin",
"interrupted_min_seconds": "Cycles courts signalés comme interrompus",
@@ -978,14 +977,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "Une chute supérieure à cette fraction de puissance actuelle est également traitée comme brusque (0,6 = une chute de 60 %). Complète le seuil de watts pour toutes les tailles dappareils.",
"label": "Ratio de chute brusque"
},
"abrupt_drop_watts": {
"doc": "Une chute de puissance plus importante signale le cycle comme étant interrompu (annulation manuelle) plutôt que comme une finition naturelle.",
"label": "Chute brusque"
},
"anti_wrinkle_enabled": {
"doc": "Reconnaissez les courtes impulsions de culbutage de faible puissance qu'un sèche-linge émet après la phase de chauffage principale et conservez-les attachées au cycle terminé au lieu de les lire comme de nouveaux cycles.",
"label": "Activer la détection anti-froissement"
@@ -954,14 +954,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "Pad veći od ovog udjela trenutne snage također se tretira kao nagli (0,6 = pad od 60%). Nadopunjuje prag u vatima za sve veličine uređaja.",
"label": "Omjer naglog pada"
},
"abrupt_drop_watts": {
"doc": "Pad snage veći od ovoga označava ciklus kao prekinut (ručno otkazivanje), a ne prirodni završetak.",
"label": "Nagli pad (W)"
},
"anti_wrinkle_enabled": {
"doc": "Prepoznajte kratke impulse male snage koje sušilica emitira nakon glavne faze zagrijavanja i držite ih vezanima uz završeni ciklus umjesto da ih čitate kao nove cikluse.",
"label": "Omogući otkrivanje anti-bora"
@@ -1714,7 +1706,6 @@
"other": "Ostalo"
},
"pg_desc": {
"abrupt_drop_watts": "Nagli pad tretiran kao trenutni završetak",
"completion_min_seconds": "Najkraći rad koji se broji kao pravi ciklus",
"end_repeat_count": "Uzastopna niska očitanja prije završetka",
"interrupted_min_seconds": "Kratki ciklusi označeni kao prekinuti",
@@ -954,14 +954,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "Az áramerősség ezen hányadánál nagyobb esést is hirtelennek kell tekinteni (0,6 = 60%-os csökkenés). Kiegészíti a watt-küszöböt a készülékek méretében.",
"label": "Hirtelen esés aránya"
},
"abrupt_drop_watts": {
"doc": "Az ennél nagyobb teljesítménycsökkenés a ciklust Megszakítottként (kézi törlés) jelzi, nem pedig természetes befejezésként.",
"label": "Hirtelen esés"
},
"anti_wrinkle_enabled": {
"doc": "Ismerje fel a szárítógép által a fő fűtési fázis után kibocsátott rövid, kis teljesítményű szárítópulzusokat, és tartsa őket a kész ciklushoz kapcsolva, ahelyett, hogy új ciklusként olvasná őket.",
"label": "Ránctalanítás érzékelés engedélyezése"
@@ -1714,7 +1706,6 @@
"other": "Egyéb"
},
"pg_desc": {
"abrupt_drop_watts": "A hirtelen esést azonnali befejezésként kezeli",
"completion_min_seconds": "A legrövidebb futás, ami valódi ciklusnak számít",
"end_repeat_count": "Egymást követő alacsony leolvasások a befejezés előtt",
"interrupted_min_seconds": "A rövid ciklusokat megszakítottként jelöli",
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ Hugsanlega blönduð forrit"
},
"pg_desc": {
"abrupt_drop_watts": "Skyndilegt fall meðhöndlað sem tafarlaus endir",
"completion_min_seconds": "Stysta keyrsla sem telst raunveruleg lota",
"end_repeat_count": "Lágir álestrar í röð áður en lýkur",
"interrupted_min_seconds": "Stuttar lotur merktar sem truflaðar",
@@ -978,14 +977,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "Fall sem er stærra en þetta brot af núverandi afli er einnig meðhöndlað sem snöggt (0,6 = 60% lækkun). Bætir wöttaþröskuldinum yfir stærðum tækja.",
"label": "Hlutfall snöggrar niðurfellingar"
},
"abrupt_drop_watts": {
"doc": "Aflfall sem er stærra en þetta merkir lotuna sem truflað (handvirkt hætta við) frekar en náttúrulega frágang.",
"label": "Snögg niðurfelling"
},
"anti_wrinkle_enabled": {
"doc": "Viðurkenndu stuttu lág-afls þurrkarapúlsana sem þurrkari gefur frá sér eftir aðalhitafasann og haltu þeim tengdum við lokið lotu í stað þess að lesa þá sem nýjar lotur.",
"label": "Virkja hrukkuleysi-greiningu"
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ Possibili programmi misti"
},
"pg_desc": {
"abrupt_drop_watts": "Calo improvviso trattato come fine immediata",
"completion_min_seconds": "Esecuzione più breve che conta come ciclo reale",
"end_repeat_count": "Letture basse consecutive prima della fine",
"interrupted_min_seconds": "Cicli brevi contrassegnati come interrotti",
@@ -978,14 +977,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "Anche un calo maggiore di questa frazione della potenza attuale viene considerato brusco (0,6 = calo del 60%). Completa la soglia dei watt per tutte le dimensioni degli elettrodomestici.",
"label": "Rapporto calo brusco"
},
"abrupt_drop_watts": {
"doc": "Un calo di potenza maggiore segnala il ciclo come Interrotto (annullamento manuale) piuttosto che come completamento naturale.",
"label": "Calo brusco"
},
"anti_wrinkle_enabled": {
"doc": "Riconosci i brevi impulsi di asciugatrice a bassa potenza emessi da un'asciugatrice dopo la fase di riscaldamento principale e mantienili collegati al ciclo finito invece di leggerli come nuovi cicli.",
"label": "Abilita rilevamento antirughe"
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ プログラム混在の可能性"
},
"pg_desc": {
"abrupt_drop_watts": "急激な低下は即時終了として扱う",
"completion_min_seconds": "実サイクルとみなす最短の稼働時間",
"end_repeat_count": "終了前に連続する低い測定値の回数",
"interrupted_min_seconds": "短いサイクルは中断としてフラグ付け",
@@ -978,14 +977,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "現在の電力のこの割合よりも大きな低下も突然として扱われます (0.6 = 60% の低下)。アプライアンスのサイズ全体でワットしきい値を補完します。",
"label": "急激な降下比率"
},
"abrupt_drop_watts": {
"doc": "これよりも大きな電力低下は、自然な終了ではなく、サイクルに中断 (手動キャンセル) としてフラグを立てます。",
"label": "急激な降下"
},
"anti_wrinkle_enabled": {
"doc": "乾燥機が主加熱フェーズの後に発する短い低電力タンブル パルスを認識し、新しいサイクルとして読み取るのではなく、終了したサイクルに接続したままにします。",
"label": "しわ防止検出を有効化"
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ 프로그램이 섞였을 수 있음"
},
"pg_desc": {
"abrupt_drop_watts": "급격한 강하는 즉시 종료로 처리",
"completion_min_seconds": "실제 사이클로 인정되는 최단 실행",
"end_repeat_count": "종료 전 연속되는 낮은 측정값 횟수",
"interrupted_min_seconds": "짧은 사이클은 중단으로 표시",
@@ -978,14 +977,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "현재 전력의 이 부분보다 큰 하락도 갑작스러운 것으로 간주됩니다(0.6 = 60% 하락). 어플라이언스 크기 전반에 걸쳐 와트 임계값을 보완합니다.",
"label": "급격한 강하 비율"
},
"abrupt_drop_watts": {
"doc": "이보다 큰 전력 감소는 주기를 자연스러운 종료가 아닌 중단됨(수동 취소)으로 표시합니다.",
"label": "급격한 강하"
},
"anti_wrinkle_enabled": {
"doc": "건조기가 주요 가열 단계 후에 방출하는 짧은 저전력 텀블 펄스를 인식하고 이를 새 주기로 읽는 대신 완료된 주기에 연결해 두십시오.",
"label": "구김 방지 감지 활성화"
@@ -954,14 +954,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "Didesnis nei ši srovės galios sumažėjimas taip pat laikomas staigiu (0,6 = 60 % kritimas). Papildo vatų slenkstį įvairiems prietaisų dydžiams.",
"label": "Staigaus kritimo santykis"
},
"abrupt_drop_watts": {
"doc": "Jei galios sumažėjimas didesnis nei šis, ciklas pažymimas kaip Pertrauktas (rankinis atšaukimas), o ne kaip natūrali apdaila.",
"label": "Staigus kritimas"
},
"anti_wrinkle_enabled": {
"doc": "Atpažinkite trumpus mažos galios būgninius impulsus, kuriuos džiovyklė skleidžia po pagrindinės šildymo fazės, ir laikykite juos prijungtus prie baigto ciklo, o ne skaitykite juos kaip naujus ciklus.",
"label": "Įjungti apsaugą nuo raukšlių"
@@ -1714,7 +1706,6 @@
"other": "Kita"
},
"pg_desc": {
"abrupt_drop_watts": "Staigus kritimas laikomas nedelsiama pabaiga",
"completion_min_seconds": "Trumpiausias veikimas, laikomas tikru ciklu",
"end_repeat_count": "Kiek žemų rodmenų iš eilės prieš pabaigą",
"interrupted_min_seconds": "Trumpi ciklai pažymimi kaip pertraukti",
@@ -954,14 +954,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "Kritums, kas ir lielāks par šo strāvas jaudas daļu, arī tiek uzskatīts par pēkšņu (0,6 = 60% kritums). Papildina vatu slieksni dažādiem ierīču izmēriem.",
"label": "Pēkšņa krituma attiecība"
},
"abrupt_drop_watts": {
"doc": "Jaudas kritums, kas ir lielāks par šo, norāda ciklu kā Pārtraukts (manuāla atcelšana), nevis dabisku apdari.",
"label": "Pēkšņs kritums"
},
"anti_wrinkle_enabled": {
"doc": "Atpazīstiet īsos mazjaudas veļas impulsus, ko žāvētājs izstaro pēc galvenās karsēšanas fāzes, un turiet tos pievienotus gatavajam ciklam, nevis nolasiet tos kā jaunus ciklus.",
"label": "Iespējot pretgrumbu noteikšanu"
@@ -1714,7 +1706,6 @@
"other": "Cits"
},
"pg_desc": {
"abrupt_drop_watts": "Pēkšņs kritums tiek uzskatīts par tūlītējām beigām",
"completion_min_seconds": "Īsākais darbības laiks, kas skaitās par īstu ciklu",
"end_repeat_count": "Zemi rādījumi pēc kārtas pirms beigām",
"interrupted_min_seconds": "Īsi cikli tiek atzīmēti kā pārtraukti",
@@ -954,14 +954,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "Пад поголем од овој дел од моменталната моќност исто така се третира како нагло (0,6 = пад од 60%). Го надополнува прагот на вати низ големини на апаратот.",
"label": "Сооднос на нагол пад"
},
"abrupt_drop_watts": {
"doc": "Пад на енергија поголем од ова го означува циклусот како Прекинат (рачно откажување) наместо како природен финиш.",
"label": "Нагол пад"
},
"anti_wrinkle_enabled": {
"doc": "Препознајте ги кратките пулсирања со мала моќност што ги испушта машината за сушење по главната топлинска фаза и држете ги прикачени на завршениот циклус наместо да ги читате како нови циклуси.",
"label": "Овозможи откривање против брчки"
@@ -1714,7 +1706,6 @@
"other": "Друго"
},
"pg_desc": {
"abrupt_drop_watts": "Ненадеен пад се третира како непосреден крај",
"completion_min_seconds": "Најкратко извршување што се смета за вистински циклус",
"end_repeat_count": "Ниски отчитувања по ред пред завршување",
"interrupted_min_seconds": "Кратки циклуси означени како прекинати",
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ Muligens blandede programmer"
},
"pg_desc": {
"abrupt_drop_watts": "Plutselig fall behandlet som umiddelbar slutt",
"completion_min_seconds": "Korteste kjøring som teller som en ekte syklus",
"end_repeat_count": "Lave avlesninger på rad før avslutning",
"interrupted_min_seconds": "Korte sykluser merkes som avbrutt",
@@ -978,14 +977,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "Et fall som er større enn denne brøkdelen av gjeldende effekt, blir også behandlet som brå (0,6 = et fall på 60 %). Utfyller watt-terskelen på tvers av apparatstørrelser.",
"label": "Bratt fall-forhold"
},
"abrupt_drop_watts": {
"doc": "Et kraftfall større enn dette flagger syklusen som avbrutt (manuell kansellering) i stedet for en naturlig finish.",
"label": "Bratt fall"
},
"anti_wrinkle_enabled": {
"doc": "Gjenkjenn de korte laveffekttrommelpulsene en tørketrommel sender ut etter hovedvarmefasen, og hold dem festet til den ferdige syklusen i stedet for å lese dem som nye sykluser.",
"label": "Aktiver anti-rynke-deteksjon"
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ Mogelijk gemengde programma's"
},
"pg_desc": {
"abrupt_drop_watts": "Plotselinge daling behandeld als onmiddellijk einde",
"completion_min_seconds": "Kortste run die als een echte cyclus telt",
"end_repeat_count": "Opeenvolgende lage metingen voor het einde",
"interrupted_min_seconds": "Korte cycli gemarkeerd als onderbroken",
@@ -978,14 +977,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "Een daling groter dan deze fractie van het huidige vermogen wordt ook als abrupt beschouwd (0,6 = een daling van 60%). Vult de wattdrempel aan voor alle apparaatformaten.",
"label": "Ratio plotse daling"
},
"abrupt_drop_watts": {
"doc": "Een stroomdaling groter dan dit markeert de cyclus als onderbroken (handmatige annulering) in plaats van als een natuurlijke afwerking.",
"label": "Plotse daling"
},
"anti_wrinkle_enabled": {
"doc": "Herken de korte tuimelpulsen met laag vermogen die een droger afgeeft na de hoofdverwarmingsfase en houd ze vast aan het voltooide programma in plaats van ze als nieuwe cycli te lezen.",
"label": "Anti-kreuk detectie inschakelen"
@@ -954,14 +954,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "Spadek większy niż ten ułamek mocy prądu jest również traktowany jako gwałtowny (0,6 = spadek o 60%). Uzupełnia próg watów dla różnych rozmiarów urządzeń.",
"label": "Współczynnik Nagłego Spadku"
},
"abrupt_drop_watts": {
"doc": "Większy spadek mocy oznacza cykl jako przerwany (ręczne anulowanie), a nie jako naturalne zakończenie.",
"label": "Nagły Spadek"
},
"anti_wrinkle_enabled": {
"doc": "Rozpoznawaj krótkie impulsy suszenia o małej mocy, które suszarka emituje po głównej fazie nagrzewania, i pamiętaj o ich dołączeniu do zakończonego cyklu, zamiast odczytywać je jako nowe cykle.",
"label": "Włącz Wykrywanie Antygniotowe"
@@ -1714,7 +1706,6 @@
"other": "Inne"
},
"pg_desc": {
"abrupt_drop_watts": "Nagły spadek traktowany jako natychmiastowy koniec",
"completion_min_seconds": "Najkrótszy przebieg liczony jako prawdziwy cykl",
"end_repeat_count": "Kolejne niskie odczyty przed zakończeniem",
"interrupted_min_seconds": "Krótkie cykle oznaczane jako przerwane",
@@ -954,14 +954,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "Uma queda maior que esta fração da potência atual também é tratada como abrupta (0,6 = queda de 60%). Complementa o limite de watts em todos os tamanhos de aparelhos.",
"label": "Proporção de Queda Abrupta"
},
"abrupt_drop_watts": {
"doc": "Uma queda de energia maior que isso sinaliza o ciclo como Interrompido (cancelamento manual) em vez de um término natural.",
"label": "Queda Abrupta"
},
"anti_wrinkle_enabled": {
"doc": "Reconheça os pulsos curtos de baixa potência que uma secadora emite após a fase de aquecimento principal e mantenha-os anexados ao ciclo finalizado, em vez de lê-los como novos ciclos.",
"label": "Ativar Detecção Antiamasso"
@@ -1714,7 +1706,6 @@
"other": "Outro"
},
"pg_desc": {
"abrupt_drop_watts": "Queda súbita tratada como fim imediato",
"completion_min_seconds": "Menor duração que conta como ciclo real",
"end_repeat_count": "Leituras baixas seguidas antes de terminar",
"interrupted_min_seconds": "Ciclos curtos marcados como interrompidos",
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ Possíveis programas misturados"
},
"pg_desc": {
"abrupt_drop_watts": "Queda repentina tratada como fim imediato",
"completion_min_seconds": "Execução mais curta que conta como um ciclo real",
"end_repeat_count": "Leituras baixas consecutivas antes de terminar",
"interrupted_min_seconds": "Ciclos curtos marcados como interrompidos",
@@ -978,14 +977,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "Uma queda maior que esta fração da potência atual também é tratada como abrupta (0,6 = queda de 60%). Complementa o limite de watts em todos os tamanhos de aparelhos.",
"label": "Rácio de Queda Abrupta"
},
"abrupt_drop_watts": {
"doc": "Uma queda de energia maior que isso sinaliza o ciclo como Interrompido (cancelamento manual) em vez de um término natural.",
"label": "Queda Abrupta"
},
"anti_wrinkle_enabled": {
"doc": "Reconheça os pulsos curtos de baixa potência que uma secadora emite após a fase de aquecimento principal e mantenha-os anexados ao ciclo finalizado, em vez de lê-los como novos ciclos.",
"label": "Activar Detecção Anti-Vincos"
@@ -954,14 +954,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "O scădere mai mare decât această fracțiune a puterii curente este, de asemenea, tratată ca fiind bruscă (0,6 = o scădere de 60%). Completează pragul de wați în funcție de dimensiunile aparatului.",
"label": "Raport Scădere Bruscă"
},
"abrupt_drop_watts": {
"doc": "O scădere de putere mai mare decât aceasta indică ciclul ca întrerupt (anulare manuală), mai degrabă decât un finisaj natural.",
"label": "Scădere Bruscă"
},
"anti_wrinkle_enabled": {
"doc": "Recunoașteți impulsurile scurte de rufe de putere redusă pe care le emite un uscător după faza principală de căldură și mențineți-le atașate la ciclul terminat în loc să le citiți ca cicluri noi.",
"label": "Activați Detectarea Anti-Șifonare"
@@ -1714,7 +1706,6 @@
"other": "Altele"
},
"pg_desc": {
"abrupt_drop_watts": "Scădere bruscă tratată ca sfârșit imediat",
"completion_min_seconds": "Cea mai scurtă rulare care contează ca ciclu real",
"end_repeat_count": "Citiri scăzute consecutive înainte de încheiere",
"interrupted_min_seconds": "Ciclurile scurte sunt marcate ca întrerupte",
@@ -954,14 +954,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "Падение, превышающее эту долю текущей мощности, также считается резким (0,6 = падение на 60%). Дополняет пороговое значение мощности для устройств разных размеров.",
"label": "Коэффициент Резкого Падения"
},
"abrupt_drop_watts": {
"doc": "Падение мощности, превышающее это значение, помечает цикл как прерванный (отмена вручную), а не как естественное завершение.",
"label": "Резкое Падение"
},
"anti_wrinkle_enabled": {
"doc": "Распознавайте короткие импульсы низкой мощности, которые сушильная машина излучает после основной фазы нагрева, и сохраняйте их привязанными к завершенному циклу, а не воспринимайте их как новые циклы.",
"label": "Включить Защиту от Морщин"
@@ -1714,7 +1706,6 @@
"other": "Другое"
},
"pg_desc": {
"abrupt_drop_watts": "Резкое падение считается немедленным завершением",
"completion_min_seconds": "Кратчайший запуск, считающийся настоящим циклом",
"end_repeat_count": "Сколько низких показаний подряд до завершения",
"interrupted_min_seconds": "Короткие циклы помечаются как прерванные",
@@ -954,14 +954,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "Pokles väčší ako tento zlomok prúdu sa tiež považuje za náhly (0,6 = 60 % pokles). Dopĺňa prahovú hodnotu vo wattoch naprieč veľkosťami spotrebičov.",
"label": "Pomer náhleho poklesu"
},
"abrupt_drop_watts": {
"doc": "Pokles výkonu väčší ako tento označí cyklus ako prerušený (manuálne zrušenie), a nie ako prirodzený koniec.",
"label": "Náhly pokles"
},
"anti_wrinkle_enabled": {
"doc": "Rozpoznajte krátke pulzy bubna s nízkym výkonom, ktoré sušička vydáva po hlavnej fáze ohrevu, a nechajte ich pripojené k dokončenému cyklu namiesto toho, aby ste ich čítali ako nové cykly.",
"label": "Povoliť detekciu ochrany pred krčením"
@@ -1714,7 +1706,6 @@
"other": "Iné"
},
"pg_desc": {
"abrupt_drop_watts": "Náhly pokles považovaný za okamžitý koniec",
"completion_min_seconds": "Najkratší beh, ktorý sa počíta ako skutočný cyklus",
"end_repeat_count": "Nízke čítania za sebou pred ukončením",
"interrupted_min_seconds": "Krátke cykly označené ako prerušené",
@@ -954,14 +954,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "Padec, večji od tega deleža trenutne moči, se prav tako obravnava kot nenaden (0,6 = 60-odstotni padec). Dopolnjuje mejno vrednost v vatih za vse velikosti aparatov.",
"label": "Razmerje nenadnega padca"
},
"abrupt_drop_watts": {
"doc": "Večji padec moči od tega označi cikel kot prekinjen (ročni preklic) in ne kot naravni zaključek.",
"label": "Nenaden padec"
},
"anti_wrinkle_enabled": {
"doc": "Prepoznajte kratke vrtilne impulze nizke moči, ki jih oddaja sušilni stroj po glavni fazi segrevanja, in jih ohranite povezane s končanim ciklom, namesto da jih berete kot nove cikle.",
"label": "Omogoči zaznavanje zaščite pred gubami"
@@ -1714,7 +1706,6 @@
"other": "Drugo"
},
"pg_desc": {
"abrupt_drop_watts": "Nenaden padec, obravnavan kot takojšen konec",
"completion_min_seconds": "Najkrajši zagon, ki šteje kot pravi cikel",
"end_repeat_count": "Zaporedni nizki odčitki pred koncem",
"interrupted_min_seconds": "Kratki cikli, označeni kot prekinjeni",
@@ -954,14 +954,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "Një rënie më e madhe se kjo pjesë e fuqisë aktuale trajtohet gjithashtu si e papritur (0.6 = një rënie prej 60%). Plotëson pragun e vateve në përmasat e pajisjes.",
"label": "Raporti i rënies së papritur"
},
"abrupt_drop_watts": {
"doc": "Një rënie energjie më e madhe se kjo e shënon ciklin si të ndërprerë (anulim manual) dhe jo si përfundim natyral.",
"label": "Rënie e papritur"
},
"anti_wrinkle_enabled": {
"doc": "Njihni pulset e shkurtra me fuqi të ulët që lëshon një tharëse pas fazës kryesore të nxehtësisë dhe mbajini të lidhura me ciklin e përfunduar në vend që t'i lexoni si cikle të reja.",
"label": "Aktivizo zbulimin kundër rrudhave"
@@ -1714,7 +1706,6 @@
"other": "Tjetër"
},
"pg_desc": {
"abrupt_drop_watts": "Rënia e papritur trajtohet si përfundim i menjëhershëm",
"completion_min_seconds": "Ekzekutimi më i shkurtër që llogaritet si cikël i vërtetë",
"end_repeat_count": "Lexime të ulëta radhazi para përfundimit",
"interrupted_min_seconds": "Ciklet e shkurtra shënohen si të ndërprera",
@@ -954,14 +954,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "Pad veći od ovog dela struje takođe se tretira kao nagli (0,6 = pad od 60%). Dopunjuje prag u vatima za različite veličine uređaja.",
"label": "Odnos naglog pada"
},
"abrupt_drop_watts": {
"doc": "Pad snage veći od ovog označava ciklus kao Prekinut (ručno otkazivanje), a ne kao prirodni završetak.",
"label": "Nagli pad"
},
"anti_wrinkle_enabled": {
"doc": "Prepoznajte kratke impulse male snage koje mašina za sušenje emituje nakon glavne faze grejanja i držite ih povezanim sa završenim ciklusom umesto da ih čitate kao nove cikluse.",
"label": "Omogući detekciju zaštite od gužvanja"
@@ -1714,7 +1706,6 @@
"other": "Ostalo"
},
"pg_desc": {
"abrupt_drop_watts": "Nagli pad se tretira kao trenutni završetak",
"completion_min_seconds": "Najkraće pokretanje koje se računa kao pravi ciklus",
"end_repeat_count": "Niska očitavanja zaredom pre završetka",
"interrupted_min_seconds": "Kratki ciklusi označeni kao prekinuti",
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ Möjligen blandade program"
},
"pg_desc": {
"abrupt_drop_watts": "Plötsligt fall som tolkas som omedelbart slut",
"completion_min_seconds": "Kortaste körning som räknas som en riktig cykel",
"end_repeat_count": "Antal låga avläsningar i rad innan avslut",
"interrupted_min_seconds": "Korta cykler markeras som avbrutna",
@@ -978,14 +977,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "Ett fall som är större än denna andel av strömeffekten behandlas också som abrupt (0,6 = 60 % minskning). Kompletterar watt-tröskeln över apparatstorlekar.",
"label": "Abrupt fallförhållande"
},
"abrupt_drop_watts": {
"doc": "Ett effektfall större än detta flaggar cykeln som avbruten (manuell avbrytning) snarare än en naturlig finish.",
"label": "Abrupt fall"
},
"anti_wrinkle_enabled": {
"doc": "Känn igen de korta torktumlarpulser med låg effekt en torktumlare avger efter huvuduppvärmningsfasen och håll dem kopplade till den färdiga cykeln istället för att läsa dem som nya cykler.",
"label": "Aktivera skrynkelskyddsdetektering"
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ Karışık olabilecek programlar"
},
"pg_desc": {
"abrupt_drop_watts": "Ani düşüş anında bitiş sayılır",
"completion_min_seconds": "Gerçek döngü sayılan en kısa çalışma",
"end_repeat_count": "Bitmeden önce arka arkaya düşük okuma",
"interrupted_min_seconds": "Kısa döngüler kesintiye uğramış olarak işaretlenir",
@@ -978,14 +977,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "Akım gücünün bu kısmından daha büyük bir düşüş de ani olarak değerlendirilir (0,6 = %60'lık bir düşüş). Cihaz boyutlarındaki watt eşiğini tamamlar.",
"label": "Ani Düşüş Oranı"
},
"abrupt_drop_watts": {
"doc": "Bundan daha büyük bir güç düşüşü, döngüyü doğal bir son yerine Kesintili (manuel iptal) olarak işaretler.",
"label": "Ani Düşüş"
},
"anti_wrinkle_enabled": {
"doc": "Kurutucunun ana ısıtma aşamasından sonra yaydığı kısa, düşük güçlü tambur darbelerini tanıyın ve bunları yeni döngüler olarak okumak yerine bitmiş döngüye bağlı tutun.",
"label": "Kırışıklık Önleme Algılamayı Etkinleştir"
@@ -954,14 +954,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "Падіння, яке перевищує цю частку поточної потужності, також вважається різким (0,6 = падіння на 60%). Доповнює порогове значення потужності для різних розмірів приладів.",
"label": "Коефіцієнт різкого падіння"
},
"abrupt_drop_watts": {
"doc": "Більше падіння потужності позначає цикл як перерваний (ручне скасування), а не як природне завершення.",
"label": "Різке падіння"
},
"anti_wrinkle_enabled": {
"doc": "Розпізнавайте короткі малопотужні імпульси обертання, які видає сушильна машина після основної фази нагрівання, і зберігайте їх прив’язаними до завершеного циклу замість того, щоб зчитувати їх як нові цикли.",
"label": "Увімкнути виявлення захисту від зминання"
@@ -1714,7 +1706,6 @@
"other": "Інше"
},
"pg_desc": {
"abrupt_drop_watts": "Різке падіння вважається негайним завершенням",
"completion_min_seconds": "Найкоротший запуск, що вважається справжнім циклом",
"end_repeat_count": "Скільки низьких показань поспіль до завершення",
"interrupted_min_seconds": "Короткі цикли позначаються як перервані",
@@ -867,7 +867,6 @@
"advisory_phase_inconsistent_title": "⚠ 可能混合了不同程序"
},
"pg_desc": {
"abrupt_drop_watts": "骤降视为立即结束",
"completion_min_seconds": "计为真实周期的最短运行时间",
"end_repeat_count": "结束前连续的低读数次数",
"interrupted_min_seconds": "短周期标记为中断",
@@ -978,14 +977,6 @@
}
},
"setting": {
"abrupt_drop_ratio": {
"doc": "大于电流功率这一部分的下降也被视为突然下降(0.6 = 60% 下降)。补充不同设备尺寸的瓦特阈值。",
"label": "突降比例"
},
"abrupt_drop_watts": {
"doc": "功率下降大于此值会将循环标记为已中断(手动取消)而不是自然结束。",
"label": "突降功率"
},
"anti_wrinkle_enabled": {
"doc": "识别烘干机在主要加热阶段后发出的短的低功率翻滚脉冲,并将它们附加到完成的循环,而不是将它们视为新循环。",
"label": "启用防皱检测"
+122 -24
View File
@@ -73,8 +73,10 @@ from .const import (
CONF_WATCHDOG_INTERVAL,
DEFAULT_DEVICE_TYPE,
DEFAULT_MAINTENANCE_REMINDER_CYCLES,
DEFAULT_MIN_POWER,
DEFAULT_OFF_DELAY,
DEFAULT_OFF_DELAY_BY_DEVICE,
DEFAULT_RUNNING_DEAD_ZONE,
DEVICE_TYPE_PUMP,
MAINTENANCE_EVENT_TYPES,
DEVICE_TYPES,
@@ -1838,7 +1840,9 @@ def ws_rebuild_envelopes(
entry_id, "rebuild", "Rebuilding envelopes",
label_key="task.rebuild.envelopes", label_params={},
)
hass.async_create_task(_rebuild_envelopes_task(hass, task, entry_id))
_raw = hass.async_create_task(_rebuild_envelopes_task(hass, task, entry_id))
if _raw is not None:
reg.link_asyncio_task(task.id, _raw)
_send_result(connection, msg["id"], "rebuild_envelopes", {"task_id": task.id})
@@ -1852,8 +1856,10 @@ async def _rebuild_envelopes_task(hass: HomeAssistant, task: Any, entry_id: str)
return
store = manager.profile_store
lock = _entry_write_lock(hass, entry_id)
await lock.acquire()
acquired = False
try:
await lock.acquire()
acquired = True
names = list(store.get_profiles().keys())
reg.update(task, total=len(names), done=0)
rebuilt = 0
@@ -1873,11 +1879,15 @@ async def _rebuild_envelopes_task(hass: HomeAssistant, task: Any, entry_id: str)
state=task_registry.STATE_CANCELLED if task.cancel_requested else task_registry.STATE_DONE,
result={"success": True, "rebuilt": rebuilt},
)
except asyncio.CancelledError:
reg.finish(task, state=task_registry.STATE_CANCELLED)
raise
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.warning("Rebuild-envelopes task failed for %s: %s", entry_id, exc)
reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))
finally:
lock.release()
if acquired:
lock.release()
@websocket_api.websocket_command(
@@ -2703,12 +2713,22 @@ async def _reprocess_task(hass: HomeAssistant, task: Any, entry_id: str) -> None
# retrains and rewrites the store, and must not interleave with a concurrent
# import / recording persist for the same entry.
lock = _entry_write_lock(hass, entry_id)
await lock.acquire()
acquired = False
try:
await lock.acquire()
acquired = True
if task.cancel_requested:
reg.finish(task, state=task_registry.STATE_CANCELLED)
return
reg.update(task, total=5, done=0, label="Reprocessing: matching cycles",
label_key="task.reprocess.matching")
summary["count"] = await store.async_reprocess_all_data()
if task.cancel_requested:
reg.finish(task, state=task_registry.STATE_CANCELLED, result=summary)
return
reg.update(task, done=1, label="Reprocessing: backfilling golden",
label_key="task.reprocess.golden")
try:
@@ -2716,6 +2736,10 @@ async def _reprocess_task(hass: HomeAssistant, task: Any, entry_id: str) -> None
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.debug("golden backfill failed for %s: %s", entry_id, exc)
if task.cancel_requested:
reg.finish(task, state=task_registry.STATE_CANCELLED, result=summary)
return
reg.update(task, done=2, label="Reprocessing: suggestions",
label_key="task.reprocess.suggestions")
learning = getattr(manager, "learning_manager", None)
@@ -2739,6 +2763,10 @@ async def _reprocess_task(hass: HomeAssistant, task: Any, entry_id: str) -> None
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.debug("ML training failed for %s: %s", entry_id, exc)
if task.cancel_requested:
reg.finish(task, state=task_registry.STATE_CANCELLED, result=summary)
return
reg.update(task, done=4, label="Reprocessing: cycle health",
label_key="task.reprocess.health")
# Recompute per-cycle health against the (possibly retrained) model.
@@ -2755,13 +2783,17 @@ async def _reprocess_task(hass: HomeAssistant, task: Any, entry_id: str) -> None
if _get_manager(hass, entry_id) is manager:
manager.notify_update()
reg.finish(task, state=task_registry.STATE_DONE, result=summary)
except asyncio.CancelledError:
reg.finish(task, state=task_registry.STATE_CANCELLED)
raise
except Exception as exc: # pylint: disable=broad-exception-caught
# Task-level failure: log at WARNING so it is visible in the default HA log
# and the panel Logs view (sub-step failures above stay at debug on purpose).
_LOGGER.warning("Reprocess task failed for %s: %s", entry_id, exc)
reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))
finally:
lock.release()
if acquired:
lock.release()
@websocket_api.websocket_command(
@@ -2783,7 +2815,9 @@ def ws_reprocess_history(
return
reg = task_registry.get_registry(hass)
task = reg.create(entry_id, "reprocess", "Reprocessing")
hass.async_create_task(_reprocess_task(hass, task, entry_id))
_raw = hass.async_create_task(_reprocess_task(hass, task, entry_id))
if _raw is not None:
reg.link_asyncio_task(task.id, _raw)
_send_result(connection, msg["id"], "reprocess_history", {"task_id": task.id})
@@ -3274,9 +3308,11 @@ def ws_trim_cycle(
task = reg.create(
entry_id, "trim", "Trimming cycle", label_key="task.trim.apply", label_params={},
)
hass.async_create_task(_trim_task(
_raw = hass.async_create_task(_trim_task(
hass, task, entry_id, msg["cycle_id"], float(msg["start_s"]), float(msg["end_s"]),
))
if _raw is not None:
reg.link_asyncio_task(task.id, _raw)
_send_result(connection, msg["id"], "trim_cycle", {"task_id": task.id})
@@ -3293,8 +3329,13 @@ async def _trim_task(
return
store = manager.profile_store
lock = _entry_write_lock(hass, entry_id)
await lock.acquire()
acquired = False
try:
await lock.acquire()
acquired = True
if task.cancel_requested:
reg.finish(task, state=task_registry.STATE_CANCELLED)
return
reg.update(task, total=1, done=0)
ok = await store.trim_cycle_power_data(cycle_id, start_s, end_s)
reg.update(task, done=1)
@@ -3304,11 +3345,15 @@ async def _trim_task(
if _get_manager(hass, entry_id) is manager:
manager.notify_update()
reg.finish(task, state=task_registry.STATE_DONE, result={"success": True})
except asyncio.CancelledError:
reg.finish(task, state=task_registry.STATE_CANCELLED)
raise
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.warning("Trim task failed for %s: %s", entry_id, exc)
reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))
finally:
lock.release()
if acquired:
lock.release()
@websocket_api.websocket_command(
@@ -3426,7 +3471,9 @@ def ws_apply_split(
task = reg.create(
entry_id, "split", "Splitting cycle", label_key="task.split.apply", label_params={},
)
hass.async_create_task(_apply_split_task(hass, task, entry_id, cycle_id, segments))
_raw = hass.async_create_task(_apply_split_task(hass, task, entry_id, cycle_id, segments))
if _raw is not None:
reg.link_asyncio_task(task.id, _raw)
_send_result(connection, msg["id"], "apply_split", {"task_id": task.id})
@@ -3444,8 +3491,13 @@ async def _apply_split_task(
return
store = manager.profile_store
lock = _entry_write_lock(hass, entry_id)
await lock.acquire()
acquired = False
try:
await lock.acquire()
acquired = True
if task.cancel_requested:
reg.finish(task, state=task_registry.STATE_CANCELLED)
return
reg.update(task, total=1, done=0)
new_ids = await store.apply_split_interactive(cycle_id, segments)
reg.update(task, done=1)
@@ -3455,9 +3507,15 @@ async def _apply_split_task(
task, state=task_registry.STATE_DONE,
result={"success": True, "new_ids": new_ids},
)
except asyncio.CancelledError:
reg.finish(task, state=task_registry.STATE_CANCELLED)
raise
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.warning("Apply-split task failed for %s: %s", entry_id, exc)
reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))
finally:
if acquired:
lock.release()
@websocket_api.websocket_command(
@@ -3508,7 +3566,9 @@ def ws_apply_merge(
task = reg.create(
entry_id, "merge", "Merging cycles", label_key="task.merge.apply", label_params={},
)
hass.async_create_task(_apply_merge_task(hass, task, entry_id, ids, target, new_name))
_raw = hass.async_create_task(_apply_merge_task(hass, task, entry_id, ids, target, new_name))
if _raw is not None:
reg.link_asyncio_task(task.id, _raw)
_send_result(connection, msg["id"], "apply_merge", {"task_id": task.id})
@@ -3526,8 +3586,13 @@ async def _apply_merge_task(
return
store = manager.profile_store
lock = _entry_write_lock(hass, entry_id)
await lock.acquire()
acquired = False
try:
await lock.acquire()
acquired = True
if task.cancel_requested:
reg.finish(task, state=task_registry.STATE_CANCELLED)
return
reg.update(task, total=1, done=0)
created_new = False
if new_name:
@@ -3553,11 +3618,15 @@ async def _apply_merge_task(
task, state=task_registry.STATE_DONE,
result={"success": True, "new_id": new_id},
)
except asyncio.CancelledError:
reg.finish(task, state=task_registry.STATE_CANCELLED)
raise
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.warning("Apply-merge task failed for %s: %s", entry_id, exc)
reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))
finally:
lock.release()
if acquired:
lock.release()
# ─── Profile envelope / member cycles ──────────────────────────────────────────
@@ -4620,17 +4689,23 @@ async def _ml_training_task(hass: HomeAssistant, task: Any, entry_id: str) -> No
# reprocess itself runs ML training): training rewrites the store, so two runs
# for the same entry must not interleave.
lock = _entry_write_lock(hass, entry_id)
await lock.acquire()
acquired = False
try:
await lock.acquire()
acquired = True
summary = await manager.async_run_ml_training(force=True)
reg.finish(task, state=task_registry.STATE_DONE, result=summary)
except asyncio.CancelledError:
reg.finish(task, state=task_registry.STATE_CANCELLED)
raise
except Exception as exc: # pylint: disable=broad-exception-caught
# Task-level failure: log at WARNING so it surfaces in the default HA log and
# the panel Logs view (not swallowed at debug like a routine sub-step miss).
_LOGGER.warning("ML training task failed for %s: %s", entry_id, exc)
reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))
finally:
lock.release()
if acquired:
lock.release()
@websocket_api.websocket_command(
@@ -4656,7 +4731,9 @@ def ws_trigger_ml_training(
return
reg = task_registry.get_registry(hass)
task = reg.create(entry_id, "ml_training", "Learning")
hass.async_create_task(_ml_training_task(hass, task, entry_id))
_raw = hass.async_create_task(_ml_training_task(hass, task, entry_id))
if _raw is not None:
reg.link_asyncio_task(task.id, _raw)
_send_result(connection, msg["id"], "trigger_ml_training", {"task_id": task.id})
@@ -4838,7 +4915,7 @@ def _playground_base_config(manager: Any, entry: Any) -> CycleDetectorConfig:
opts: dict[str, Any] = {}
if entry is not None:
opts = {**getattr(entry, "data", {}), **getattr(entry, "options", {})}
min_power = float(opts.get(CONF_MIN_POWER, 5.0) or 5.0)
min_power = float(opts.get(CONF_MIN_POWER, DEFAULT_MIN_POWER) or DEFAULT_MIN_POWER)
return CycleDetectorConfig(
min_power=min_power,
off_delay=int(opts.get(CONF_OFF_DELAY, DEFAULT_OFF_DELAY)),
@@ -4846,7 +4923,7 @@ def _playground_base_config(manager: Any, entry: Any) -> CycleDetectorConfig:
completion_min_seconds=int(opts.get(CONF_COMPLETION_MIN_SECONDS, 600)),
end_repeat_count=int(opts.get(CONF_END_REPEAT_COUNT, 1)),
min_off_gap=int(opts.get(CONF_MIN_OFF_GAP, 60)),
running_dead_zone=int(opts.get(CONF_RUNNING_DEAD_ZONE, 0)),
running_dead_zone=int(opts.get(CONF_RUNNING_DEAD_ZONE, DEFAULT_RUNNING_DEAD_ZONE)),
start_threshold_w=float(opts.get(CONF_START_THRESHOLD_W, min_power)),
stop_threshold_w=float(
opts.get(CONF_STOP_THRESHOLD_W, min_power * 0.6 if min_power else 2.0)
@@ -5186,6 +5263,9 @@ async def _pg_history_task(
else:
ids = [c.get("id") for c in past[-playground.DEFAULT_RECENT_CYCLES:]]
ids = [i for i in ids[:playground.MAX_BATCH_CYCLES] if i]
# Build match snapshots once — they are store-derived and identical for
# every chunk; rebuilding per chunk is O(n_profiles) wasted work.
prebuilt = await hass.async_add_executor_job(playground._build_match_snapshots, store)
reg.update(task, total=len(ids))
rows: list[dict[str, Any]] = []
base_rows: list[dict[str, Any]] = []
@@ -5195,7 +5275,7 @@ async def _pg_history_task(
chunk = ids[i:i + _PG_HISTORY_CHUNK]
r = await hass.async_add_executor_job(
playground.run_playground_history,
store, chunk, base_config, override, options, price, len(chunk),
store, chunk, base_config, override, options, price, len(chunk), prebuilt,
)
rows.extend(r.get("rows") or [])
base_rows.extend(r.get("baseline_rows") or [])
@@ -5207,6 +5287,9 @@ async def _pg_history_task(
state=task_registry.STATE_CANCELLED if task.cancel_requested else task_registry.STATE_DONE,
result=payload,
)
except asyncio.CancelledError:
reg.finish(task, state=task_registry.STATE_CANCELLED)
raise
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.debug("Playground history task failed for %s: %s", entry_id, exc)
reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))
@@ -5228,6 +5311,8 @@ async def _pg_sweep_task(
ids = [c.get("id") for c in past[-playground.DEFAULT_RECENT_CYCLES:] if isinstance(c, dict)]
ids = [i for i in ids[:playground.MAX_BATCH_CYCLES] if i]
n = max(1, len(ids))
# Build match snapshots once — identical for every sweep value/cell.
prebuilt = await hass.async_add_executor_job(playground._build_match_snapshots, store)
if param_y and values_y:
reg.update(task, total=len(values) * len(values_y))
grid: list[list[float | None]] = [[None] * len(values) for _ in values_y]
@@ -5242,7 +5327,7 @@ async def _pg_sweep_task(
r = await hass.async_add_executor_job(
playground.run_playground_sweep,
store, ids, base_config, param, [vx], objective,
options, price, n, param_y, [vy],
options, price, n, param_y, [vy], prebuilt,
)
cell = (r.get("grid") or [[None]])[0]
grid[j][i] = cell[0] if cell else None
@@ -5263,6 +5348,7 @@ async def _pg_sweep_task(
r = await hass.async_add_executor_job(
playground.run_playground_sweep,
store, ids, base_config, param, [vx], objective, options, price, n,
None, None, prebuilt,
)
points.extend(r.get("points") or [])
if r.get("current_value") is not None:
@@ -5275,6 +5361,9 @@ async def _pg_sweep_task(
state=task_registry.STATE_CANCELLED if task.cancel_requested else task_registry.STATE_DONE,
result=payload,
)
except asyncio.CancelledError:
reg.finish(task, state=task_registry.STATE_CANCELLED)
raise
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.debug("Playground sweep task failed for %s: %s", entry_id, exc)
reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))
@@ -5304,7 +5393,9 @@ def ws_start_playground_history(
task = reg.create(entry_id, "pg_history", "Test on history")
override = dict(msg.get("settings_override") or {}) or None
cycle_ids = list(msg.get("cycle_ids") or [])
hass.async_create_task(_pg_history_task(hass, task, entry_id, cycle_ids, override))
_raw = hass.async_create_task(_pg_history_task(hass, task, entry_id, cycle_ids, override))
if _raw is not None:
reg.link_asyncio_task(task.id, _raw)
_send_result(connection, msg["id"], "start_playground_history", {"task_id": task.id})
@@ -5340,10 +5431,12 @@ def ws_start_playground_sweep(
entry_id, "pg_sweep", f"Optimize: {msg['param']}",
label_key="task.pg_sweep.optimize", label_params={"param": msg["param"]},
)
hass.async_create_task(_pg_sweep_task(
_raw = hass.async_create_task(_pg_sweep_task(
hass, task, entry_id, msg["param"], list(msg.get("values") or []),
msg["objective"], param_y, list(values_y) if values_y else None,
))
if _raw is not None:
reg.link_asyncio_task(task.id, _raw)
_send_result(connection, msg["id"], "start_playground_sweep", {"task_id": task.id})
@@ -5393,6 +5486,9 @@ async def _pg_detail_task(
state=task_registry.STATE_CANCELLED if task.cancel_requested else task_registry.STATE_DONE,
result=payload,
)
except asyncio.CancelledError:
reg.finish(task, state=task_registry.STATE_CANCELLED)
raise
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.debug("Playground detail task failed for %s: %s", entry_id, exc)
reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))
@@ -5426,7 +5522,9 @@ def ws_start_playground_cycle_detail(
label_key="task.pg_detail.simulate", label_params={},
)
override = dict(msg.get("settings_override") or {})
hass.async_create_task(
_raw = hass.async_create_task(
_pg_detail_task(hass, task, entry_id, msg["cycle_id"], override)
)
if _raw is not None:
reg.link_asyncio_task(task.id, _raw)
_send_result(connection, msg["id"], "start_playground_cycle_detail", {"task_id": task.id})
@@ -129,10 +129,6 @@ const _SETTINGS_SECTIONS = [
doc: 'Expected time between sensor readings - used to size the smoothing window and start debounce correctly. Every sensor update is captured regardless of this value; it only calibrates the downstream calculations. The suggestion engine measures your sensor\'s actual cadence from past cycles and sets this automatically.' },
{ key: 'smoothing_window', label: 'Smoothing Window', type: 'number', min: 1, def: 2,
doc: 'How much the raw power signal is smoothed. Low (2) is responsive but noisy; high (5) smooths spikes but adds lag.' },
{ key: 'abrupt_drop_watts', label: 'Abrupt Drop', unit: 'W', type: 'number', min: 0, def: 500,
doc: 'A power drop larger than this flags the cycle as Interrupted (manual cancel) rather than a natural finish.' },
{ key: 'abrupt_drop_ratio', label: 'Abrupt Drop Ratio', type: 'number', step: 0.05, min: 0, max: 1, def: 0.6,
doc: 'A drop larger than this fraction of current power is also treated as abrupt (0.6 = a 60% drop). Complements the watts threshold across appliance sizes.' },
] },
] },
{ id: 'matching', label: 'Matching', intro: 'How finished cycles are matched to learned profiles and labelled.', notDeviceTypes: ['other'], groups: [
@@ -1375,7 +1371,7 @@ function _field(f, value, extra) {
// Chip/pill multi-picker: existing values as removable pills + a combobox
// add-input. Managed by DOM (no re-render) and collected on save.
const vals = Array.isArray(value) ? value : (value ? [value] : []);
const pills = vals.map(x => `<span class="wd-pill" data-val="${_esc(x)}">${_esc(x)}<button type="button" class="wd-pill-x" aria-label="Remove">×</button></span>`).join('');
const pills = vals.map(x => `<span class="wd-pill" data-val="${_esc(x)}">${_esc(x)}<button type="button" class="wd-pill-x" aria-label="${_esc(extra.t ? extra.t('btn.remove', {}, 'Remove') : 'Remove')}">×</button></span>`).join('');
input = `<div class="wd-pillbox" data-opt="${key}" data-ftype="entitylist">${pills}` +
`<div class="wd-combo wd-combo-pill">` +
`<input type="text" class="wd-pill-add" autocomplete="off" spellcheck="false" placeholder="${_esc(extra.t('placeholder.' + (f.domain || 'add'), {}, f.placeholder || 'add…'))}">` +
@@ -1500,7 +1496,6 @@ const _DIAGRAM_BY_KEY = {
min_power: 'min_power', off_delay: 'off_delay', smoothing_window: 'smoothing',
start_threshold_w: 'hysteresis', stop_threshold_w: 'hysteresis',
start_energy_threshold: 'start_energy', running_dead_zone: 'dead_zone',
abrupt_drop_watts: 'abrupt_drop', abrupt_drop_ratio: 'abrupt_drop',
profile_duration_tolerance: 'duration_tolerance',
profile_match_min_duration_ratio: 'match_ratios', profile_match_max_duration_ratio: 'match_ratios',
progress_reset_delay: 'progress_reset', completion_min_seconds: 'min_duration',
@@ -1578,11 +1573,6 @@ function _diagram(id) {
<line class="ax" x1="60" y1="47" x2="140" y2="47"/>
<line class="ln" x1="100" y1="30" x2="100" y2="64"/>
<text x="62" y="30">-tol</text><text x="120" y="30">+tol</text><text x="84" y="74">profile</text>`);
case 'abrupt_drop':
return wrap(`${base}
<polyline class="ln" points="8,72 30,34 110,34 111,74 190,74"/>
<line class="bad dash" x1="111" y1="34" x2="111" y2="74"/>
<text x="116" y="56">abrupt</text>`);
case 'match_ratios':
return wrap(`${base}
<line class="ax" x1="20" y1="47" x2="180" y2="47"/>
@@ -1701,6 +1691,8 @@ class HaWashdataPanel extends HTMLElement {
this._toastTimer = null;
this._hassUpdateThrottle = null;
this._evtUnsubs = [];
this._hoverRafId = null; // rAF handle for chart-hover coalescing
this._hoverPending = null; // last pending hover coords {px, py, id}
// Data
this._constants = { stateColors: {}, deviceTypes: [], mlLabEnabled: false, mlSuggestionsEnabled: false, mlTrainingAvailable: false, storeOnlineAvailable: false, storeOnlineEnabled: false, storeWebOrigin: '', storePrefs: {}, pgMatchDefaults: {} };
this._constantsLoaded = false;
@@ -1866,7 +1858,22 @@ class HaWashdataPanel extends HTMLElement {
set narrow(n) { this._narrow = n; }
connectedCallback() {
if (this._initialized) this._startPoll();
if (this._initialized) {
this._startPoll();
// Restore WS push subscriptions (cycle events + task registry) that
// disconnectedCallback tore down. Without this, navigate-away/back leaves
// the panel relying only on the 30s fallback poll — live cycle transitions
// and task progress no longer update, and modal Escape/Tab are dead.
this._setupSubscriptions();
// Immediately refresh state so a navigate-away/back shows current data
// instead of waiting up to 30s for the next poll tick.
this._fetchAll();
// Re-attach the modal keyboard handler (removed on disconnect).
if (this.shadowRoot && !this._kbdHandler) {
this._kbdHandler = (e) => this._onKeydown(e);
this.shadowRoot.addEventListener('keydown', this._kbdHandler);
}
}
this._onResize = () => this._resizeLogsPage();
window.addEventListener('resize', this._onResize);
}
@@ -1907,6 +1914,14 @@ class HaWashdataPanel extends HTMLElement {
this._fetchAll();
this._startPoll();
});
this._setupSubscriptions();
}
_setupSubscriptions() {
// Tear down any existing subscriptions first so reconnect is idempotent.
this._evtUnsubs.forEach(u => { try { u(); } catch (_) {} });
this._evtUnsubs = [];
this._tasksSubscribed = false;
// Subscribe to WashData cycle events for immediate push-refresh.
// These fire when a cycle starts/ends so the UI updates instantly
// instead of waiting for the 30s fallback poll.
@@ -2176,6 +2191,11 @@ class HaWashdataPanel extends HTMLElement {
// Header activity cluster: one pill per running task (device · action · % · ✕).
_htmlTaskPills() {
const running = Object.values(this._tasks || {}).filter(t => t.state === 'running');
// Prune stale cancelling-task IDs: if a task is no longer running (evicted from
// the registry, finished while our WS subscription was down, etc.), its
// "Cancelling…" pill would never clear on its own — clean it up here.
const runningIds = new Set(running.map(t => t.id));
this._cancellingTasks.forEach(id => { if (!runningIds.has(id)) this._cancellingTasks.delete(id); });
if (!running.length) return '';
return running.map(t => {
const cancelling = this._cancellingTasks.has(t.id);
@@ -2394,6 +2414,13 @@ class HaWashdataPanel extends HTMLElement {
await this._fetchCycles(dev.entry_id);
await this._fetchSuggestions(dev.entry_id);
await this._fetchProfiles(dev.entry_id);
// Prime the Setup Card on the very first paint so it appears immediately
// without requiring a tab click or device switch.
if (this._tab === 'status') {
try {
this._setupStatus = await this._ws({ type: `${_DOMAIN}/get_setup_status`, entry_id: dev.entry_id });
} catch (_) { this._setupStatus = null; }
}
// The Store tab's visibility depends on this._onlineEnabled(),
// which is normally loaded per-tab. Prime it at boot ONLY when the backend
// exposes online features, so the tab can appear without visiting Settings.
@@ -2813,6 +2840,7 @@ class HaWashdataPanel extends HTMLElement {
this._profiles = []; this._profileHealth = {}; this._profileTrends = {}; this._coverageGaps = {}; this._profileAdvisories = []; this._opts = {}; this._suggestions = [];
this._cycles = []; this._refCycles = []; this._recState = null; this._diag = null; this._maintenance = null; this._phases = [];
this._mlTrainingStatus = null; // per-device; re-fetched by _fetchTabData
this._setupStatus = null; // per-device; re-fetched by _fetchTabData
this._deviceAutomations = []; // per-device; re-fetched on the settings tab
this._selectMode = false; this._cycleSel = new Set();
this._cycleFilter = { text: '', status: '' };
@@ -3445,7 +3473,7 @@ class HaWashdataPanel extends HTMLElement {
<circle cx="12" cy="14" r="5"/>
<circle cx="12" cy="14" r="2"/>
</svg>`;
const burger = `<button class="wd-burger" id="wd-burger" aria-label="Toggle sidebar" title="${_esc(this._t('hdr.toggle_sidebar', {}, 'Toggle Home Assistant sidebar'))}">
const burger = `<button class="wd-burger" id="wd-burger" aria-label="${_esc(this._t('hdr.toggle_sidebar', {}, 'Toggle Home Assistant sidebar'))}" title="${_esc(this._t('hdr.toggle_sidebar', {}, 'Toggle Home Assistant sidebar'))}">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><line x1="4" y1="7" x2="20" y2="7"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="17" x2="20" y2="17"/></svg>
</button>`;
return `
@@ -5190,7 +5218,6 @@ class HaWashdataPanel extends HTMLElement {
['completion_min_seconds', 'Min Cycle Duration', 's', 'Shortest run that counts as a real cycle', 'timing'],
['start_duration_threshold','Start Duration', 's', 'Seconds above threshold to confirm start', 'timing'],
['end_repeat_count', 'End Repeat Count', '', 'Low readings in a row before ending', 'advanced'],
['abrupt_drop_watts', 'Abrupt Drop Threshold', 'W', 'Sudden drop treated as immediate end', 'advanced'],
['interrupted_min_seconds', 'Interrupted Min', 's', 'Short cycles flagged as interrupted', 'advanced'],
['profile_match_min_duration_ratio', 'Min Duration Ratio', '', 'Stage 1: shortest run (vs the profile) still allowed to match', 'matching'],
['profile_match_max_duration_ratio', 'Max Duration Ratio', '', 'Stage 1: longest run (vs the profile) still allowed to match', 'matching'],
@@ -7021,7 +7048,7 @@ class HaWashdataPanel extends HTMLElement {
_htmlLogDrawer() {
return `<div class="wd-log-drawer open" style="width:${this._logDrawerWidth}px">
<div class="wd-log-resize" title="Drag to resize"></div>
<div class="wd-log-resize" title="${_esc(this._t('lbl.drag_to_resize', {}, 'Drag to resize'))}"></div>
<div class="wd-log-drawer-head">
<span>${this._t('hdr.logs', {}, 'Logs')}</span>
<div style="display:flex;align-items:center;gap:6px">
@@ -7048,7 +7075,10 @@ class HaWashdataPanel extends HTMLElement {
const dpr = window.devicePixelRatio || 1;
const cw = Math.max(1, Math.round(rect.width * dpr));
const ch = Math.max(1, Math.round((rect.height || 240) * dpr));
canvas.width = cw; canvas.height = ch;
// Only reallocate the canvas backing store when dimensions actually changed;
// reassigning canvas.width/height unconditionally clears the canvas AND triggers
// a GPU-side realloc on every hover redraw.
if (canvas.width !== cw || canvas.height !== ch) { canvas.width = cw; canvas.height = ch; }
const ctx = canvas.getContext('2d');
const cs = getComputedStyle(this);
const primary = (cs.getPropertyValue('--primary-color') || '#03a9f4').trim() || '#03a9f4';
@@ -7255,12 +7285,25 @@ class HaWashdataPanel extends HTMLElement {
}
_onGraphHover(e, id) {
const canvas = this.shadowRoot.getElementById(id);
// rAF-coalesce: many pointermove events fire per frame; only do one redraw+overlay
// per animation frame. Capture coordinates immediately (they may be stale by rAF).
const px = e.clientX, py = e.clientY;
if (this._hoverRafId) { this._hoverPending = { px, py, id }; return; }
this._hoverPending = { px, py, id };
this._hoverRafId = requestAnimationFrame(() => {
this._hoverRafId = null;
const { px: cx, py: cy, id: cid } = this._hoverPending || {};
if (!cid) return;
this._onGraphHoverInner(cx, cy, cid);
});
}
_onGraphHoverInner(px, py, id) {
const canvas = this.shadowRoot && this.shadowRoot.getElementById(id);
const wd = canvas && canvas._wd;
if (!wd) return;
const rect = canvas.getBoundingClientRect();
const x = wd.cssToX(e.clientX - rect.left);
const cursorYdev = (e.clientY - rect.top) * wd.dpr;
const x = wd.cssToX(px - rect.left);
const cursorYdev = (py - rect.top) * wd.dpr;
this._redrawCanvas(id);
const ctx = canvas.getContext('2d');
const xp = wd.Xpx(x);
@@ -7304,7 +7347,7 @@ class HaWashdataPanel extends HTMLElement {
});
if (this._canvasZoom[id]) lines.push(`<span style="opacity:.45">${this._t('lbl.zoom_hint', {}, 'scroll to zoom · dblclick to reset')}</span>`);
ctx.restore();
this._showGraphTip(e.clientX, e.clientY, lines);
this._showGraphTip(px, py, lines);
this._syncSpagRowHighlight(this._hoverNearest ? this._hoverNearest.cid : null);
}
@@ -7321,7 +7364,7 @@ class HaWashdataPanel extends HTMLElement {
tip.style.top = Math.max(6, top) + 'px';
}
_hideGraphTip() { if (this._gtip) this._gtip.style.display = 'none'; this._syncSpagRowHighlight(null); }
_hideGraphTip() { if (this._hoverRafId) { cancelAnimationFrame(this._hoverRafId); this._hoverRafId = null; this._hoverPending = null; } if (this._gtip) this._gtip.style.display = 'none'; this._syncSpagRowHighlight(null); }
_syncSpagRowHighlight(cid) {
if (cid === this._spagHoverCid) return;
@@ -7399,8 +7442,8 @@ class HaWashdataPanel extends HTMLElement {
<div class="wd-field"><label>${this._t('lbl.save_mode', {}, 'Save Mode')}</label><select id="wd-pr-mode"><option value="new_profile">${this._t('lbl.mode_new_profile', {}, 'Create New Profile')}</option><option value="existing_profile">${this._t('lbl.mode_existing_profile', {}, 'Add to Existing Profile')}</option></select></div>
<div class="wd-field"><label>${this._t('lbl.profile_name', {}, 'Profile Name')}</label><input type="text" id="wd-pr-profile" placeholder="${_esc(this._t('placeholder.profile_name', {}, 'e.g. Cotton 40°C'))}">
<div id="wd-pr-existing" style="display:none;margin-top:4px"><select id="wd-pr-profile-sel">${this._profileOptions()}</select></div></div>
<div class="wd-field"><label>${this._t('lbl.head_trim', {}, 'Head Trim (s)')}</label><input type="number" id="wd-pr-head" min="0" value="0" step="1"><div class="wd-field-hint">Remove this many seconds from the start</div></div>
<div class="wd-field"><label>${this._t('lbl.tail_trim', {}, 'Tail Trim (s)')}</label><input type="number" id="wd-pr-tail" min="0" value="0" step="1"><div class="wd-field-hint">Remove this many seconds from the end</div></div>
<div class="wd-field"><label>${this._t('lbl.head_trim', {}, 'Head Trim (s)')}</label><input type="number" id="wd-pr-head" min="0" value="0" step="1"><div class="wd-field-hint">${_esc(this._t('msg.head_trim_hint', {}, 'Remove this many seconds from the start'))}</div></div>
<div class="wd-field"><label>${this._t('lbl.tail_trim', {}, 'Tail Trim (s)')}</label><input type="number" id="wd-pr-tail" min="0" value="0" step="1"><div class="wd-field-hint">${_esc(this._t('msg.tail_trim_hint', {}, 'Remove this many seconds from the end'))}</div></div>
<div class="wd-modal-actions"><button class="wd-btn wd-btn-secondary" data-maction="cancel">${this._t('btn.cancel', {}, 'Cancel')}</button>
<button class="wd-btn wd-btn-primary" data-maction="process-rec-ok">${this._t('btn.process_recording', {}, 'Save Recording')}</button></div>`;
} else if (m.type === 'correct-feedback') {
@@ -7427,7 +7470,7 @@ class HaWashdataPanel extends HTMLElement {
body = `<h2>${this._t('modal.merge_cycles', {n: m.ids.length}, `Merge ${m.ids.length} Cycles`)}</h2>
<p class="wd-info" style="margin-bottom:12px">${this._t('msg.merge_intro', {}, 'The selected cycles are combined into one (chronological order; gaps filled with 0 W). Pick the resulting profile.')}</p>
<div class="wd-field"><label>${this._t('lbl.resulting_profile', {}, 'Resulting profile')}</label>
<select id="wd-merge-prof"><option value="">${this._t('lbl.unlabelled_paren', {}, '(unlabelled)')}</option><option value="__create_new__">+ Create new profile</option>${this._profileOptions()}</select></div>
<select id="wd-merge-prof"><option value="">${this._t('lbl.unlabelled_paren', {}, '(unlabelled)')}</option><option value="__create_new__">${this._t('lbl.create_new_profile', {}, '+ Create new profile')}</option>${this._profileOptions()}</select></div>
<div id="wd-merge-new" class="wd-field" style="display:none"><label>${this._t('lbl.new_profile_name', {}, 'New profile name')}</label><input type="text" id="wd-merge-newname" placeholder="${_esc(this._t('placeholder.profile_name', {}, 'e.g. Cotton 40°C'))}"></div>
<div class="wd-modal-actions"><button class="wd-btn wd-btn-secondary" data-maction="cancel">${this._t('btn.cancel', {}, 'Cancel')}</button>
<button class="wd-btn wd-btn-primary" data-maction="merge-ok">${this._t('btn.merge', {}, 'Merge')}</button></div>`;
@@ -9048,7 +9091,9 @@ class HaWashdataPanel extends HTMLElement {
// Click the highlighted curve on the graph to toggle that cycle's selection.
const spag = sr.getElementById('wd-spag-canvas');
if (spag) spag.addEventListener('pointerdown', e => {
this._onGraphHover(e, 'wd-spag-canvas');
// Hit-test synchronously: _onGraphHover defers via rAF, so _hoverNearest
// would still be stale/null on this same tick and the click would no-op.
this._onGraphHoverInner(e.clientX, e.clientY, 'wd-spag-canvas');
const hn = this._hoverNearest;
if (hn && hn.cid) {
const sel = m.cleanup.selected;