393 files

This commit is contained in:
Home Assistant Version Control
2026-09-27 17:16:54 +00:00
parent b05b7a897e
commit 39d97ac2db
404 changed files with 54006 additions and 11034 deletions
+222 -5
View File
@@ -155,6 +155,10 @@ NOTIFY_EVENT_TIMER = "cycle_timer" # User-configured mid-cycle countdown timer
CONF_NOTIFY_TITLE = "notify_title"
CONF_NOTIFY_ICON = "notify_icon"
# Per-device accent colour (#454). One setting, three companion-app keys: `color`
# (Android notification accent), `notification_icon_color` (iOS icon glyph tint) and
# `progress_bar_color` (iOS Live Activity bar). Mobile-only, blank = platform default.
CONF_NOTIFY_ICON_COLOR = "notify_icon_color"
CONF_NOTIFY_START_MESSAGE = "notify_start_message"
CONF_NOTIFY_FINISH_MESSAGE = "notify_finish_message"
CONF_NOTIFY_PRE_COMPLETE_MESSAGE = "notify_pre_complete_message"
@@ -225,6 +229,47 @@ CONF_NOTIFY_UNLOAD_MESSAGE = "notify_unload_message" # Template for the clean-l
# taps the notification's "stop reminding" action (opt-in, #374).
CONF_NOTIFY_UNLOAD_REPEAT = "notify_unload_repeat"
# Unload confirmation without a door sensor (#451). Not every machine can have a
# contact sensor on its door (rented flat, steel door, no approval), and the Clean
# state - plus the unload reminder that hangs off it - was reachable only through
# one. Two additions give the same "the load has been taken out" signal:
# - an entity whose activation means unloaded (a Zigbee button as event.* /
# sensor.*, an input_button helper, a motion sensor, a scene), and
# - a plain opt-in for the Mark Unloaded button / ha_washdata.mark_unloaded
# service, for a setup that confirms from its own automation.
# Either one on its own also enables the Clean state on a device with no door
# sensor; with neither set, behaviour is exactly as before.
CONF_UNLOAD_CONFIRM_ENTITY = "unload_confirm_entity"
CONF_UNLOAD_TRACK_WITHOUT_DOOR = "unload_track_without_door"
DEFAULT_UNLOAD_TRACK_WITHOUT_DOOR = False
# How long after the confirmation entity is first seen - or comes back from
# unavailable/unknown - a transition out of `unknown` is still treated as a replay
# rather than a press (register items 367, 368).
#
# A fresh `event.*` / `button.*` / `input_button.*` sits at `unknown` until it is
# first pressed, so excluding `unknown -> value` outright swallowed the first ever
# press. Accepting it outright is not safe either: a z2m action sensor publishes
# its action as a RETAINED MQTT message, which the broker replays on reconnect,
# and that arrives as exactly the same transition. A restart is already covered by
# the separate `old_state is None` guard, so this window only has to cover the gap
# between our subscription and a late-arriving retained value.
#
# Measured against the reference point rather than guessed: the replay lands within
# seconds of the MQTT connection, and the only cost of the window is a genuine press
# in the first two minutes after the entity appears - which is nearly always
# harmless, because `mark_unloaded` is a no-op unless a Clean state is waiting.
#
# **The residual is real and deliberate.** An entity that sits at `unknown` for
# longer than this window and only then receives its first retained value is
# indistinguishable from a first press: a generic HA state change carries no
# "this was retained" marker, and the whole point of the option is that it accepts
# any entity the user already owns (event, button, input_button, binary_sensor,
# sensor, switch, scene, tag), most of which are not MQTT and have no reconnection
# signal to consult. The guards narrow it to that one shape; the cost of being
# wrong is a cleared unload reminder, never lost data.
UNLOAD_CONFIRM_REPLAY_GRACE_S = 120.0
# Quiet hours (do-not-disturb window). Both hours 0-23; unset/None (or start==end)
# = feature off. When configured, finish-type notifications (finish, clean-laundry
# nag, pre-complete/reminder, milestone) that would fire inside the window are held
@@ -325,7 +370,16 @@ DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO = 0.10 # Allow match after 10% of expe
# harness in devtools/dtw_ab_eval.py: widening 1.3->1.5 lifts commit-recall
# 71.6%->73.4% for a negligible false-positive change; 1.3 was rejecting normal
# longer-than-average runs (extended/anti-wrinkle variants).
DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO = 1.5
# Stage-1 upper duration gate (register item 311). Raised 1.5 -> 1.8 because at
# 1.5 the gate deleted the TRUE candidate on 14 of 606 corpus folds (2.3%) - a
# cycle that legitimately overran its programme was refused the chance to match
# it at all. Measured against the item-307 kernel, which already penalises
# far-off durations smoothly so the hard gate has less to do: +0.66pp top-1,
# 4 devices better and 0 worse, cluster bootstrap [+0.15,+1.32] P(delta<=0)=0.017.
# Costs ~8% more candidates to score (4.10 -> 4.44 per match, 37 -> 38 ms).
# 2.5 measures slightly higher (+0.83pp) but regresses one device; 1.8 is the
# point at which nothing gets worse.
DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO = 1.8
DEFAULT_MAX_PAST_CYCLES = 200
DEFAULT_MAX_FULL_TRACES_PER_PROFILE = 20
DEFAULT_MAX_FULL_TRACES_UNLABELED = 20
@@ -441,7 +495,25 @@ ENDING_HARD_FINALIZE_MIN_QUIET_S = 600.0 # continuous sub-threshold span floor
# plausible jitter ratio for a regularly-reporting sensor (whose median equals
# its p95, so the cap never binds and slow meters keep their wide gates) while
# still rejecting the isolated multi-minute holes that caused both reports.
GATE_CADENCE_MEDIAN_FACTOR = 5.0
# Caps the pause/end gate cadence at this multiple of the MEDIAN interval
# (items 213/215). Was 5.0; lowered to 2.0 on measurement (register item 300).
#
# The cap only ever binds when p95 >> median - a fast plug that went quiet -
# which is exactly the case it was introduced for. A uniformly slow meter has
# p95 ~= median, so its gate is set by p95 and this value is irrelevant to it:
# a 300 s-cadence meter keeps its 900 s gate at 2.0 exactly as at 5.0, so the
# original rationale is preserved intact. At 5.0 it was under-correcting the
# case it existed for: on #424's dishwasher (median 38.5 s, p95 970 s) it still
# yielded a 578 s pause gate, which was the single largest remaining component
# of that cycle's 17.9 min late finish.
#
# Measured over 246 clean cycles from the whole corpus at 5.0 vs 2.0: 16 cycles
# that never closed within their stored trace now close, ZERO went the other
# way, and exactly one stored duration changed - 302 s shorter, away from the
# un-evidenced expected-duration fallback it had been pinned to. #424 finishes
# 17.9 -> 10.8 min after the appliance; #427 is unaffected (its gate never
# reached the cap).
GATE_CADENCE_MEDIAN_FACTOR = 2.0
# NOTE: STANDBY_BAND_* constants live further down, after the DEVICE_TYPE_*
# definitions they reference (search "Standby-band stuck-in-RUNNING finalize").
@@ -598,7 +670,51 @@ MATCH_DTW_ENSEMBLE_W = 0.7 # weight on L1 vs DDTW in "ensemble" mode
# in a single np.full, which OOM-kills Home Assistant (issue #388).
MAX_ALIGN_GRID_POINTS = 2000
# Ambiguity: top1-top2 score gap below this flags the match as ambiguous.
# Once a matched cycle has run past its programme's own expected length by this
# ratio, the fallback end gate stops waiting out the full `min_off_gap` and waits
# only END_GATE_LATE_SECONDS of quiet (register item 306). `min_off_gap` exists to
# bridge mid-cycle soak periods; past the end of the programme there is nothing
# left to bridge, and its default is a blind per-device prior (480 s washer /
# 600 s washer-dryer / 3600 s dishwasher) that almost nobody tunes - 12 of the 27
# real user exports carry an effective end wait of 30-60 min because of it.
# Shorten-only and bounded: the user's explicit `off_delay` stays the floor, so
# only the blind prior shrinks, and it is inert on unmatched cycles.
END_GATE_LATE_RATIO = 1.05
END_GATE_LATE_SECONDS = 300.0
# Device-resolved on a washing machine: see END_GATE_LATE_RATIO_BY_DEVICE
# and resolve_end_gate_late_ratio below, next to the device-type constants.
MATCH_AMBIGUITY_MARGIN = 0.05
# Separate, WIDER margin required before a finished cycle is auto-labelled
# (register item 310). Deliberately NOT the same constant as
# MATCH_AMBIGUITY_MARGIN: that one also gates Smart Termination via the
# detector's `_match_ambiguous`, so widening it there would defer terminations
# and undo the end-lag work of item 306. This one is consulted only where a
# label is recorded.
#
# Auto-labelling is the asymmetric decision - a wrong label silently reshapes the
# profile's avg_duration and therefore every future estimate, while a missed one
# only asks the user. Measured over 606 completed folds (post-item-307):
# margin coverage precision wrong labels
# 0.05 84.3% 86.3% 69 <- confidence-only, the old behaviour
# 0.08 77.3% 88.7% 52
# 0.10 72.2% 90.0% 43
# 0.08 is the argmax of right - 2 x wrong (a wrong label costing twice a missed
# one) and was independently selected by grouped cross-validation in all five
# held-out device groups. The absolute score is a much weaker guide here:
# AUC 0.625 against the margin's 0.792.
MATCH_LABEL_MIN_MARGIN = 0.08
# Gap between the best and second-best candidate at which a mid-cycle switch may
# skip the persistence wait (register item 305). The absolute score is close to
# useless for this mid-run (AUC 0.535, because a prefix of a long programme looks
# like a finished short one); the top1-top2 margin reaches AUC 0.773. Replayed over
# 594 cycles x 10 checkpoints: 70.4% -> 72.6% end-of-cycle correctness, 16 better /
# 3 worse, McNemar p = 0.0044, at 0.14 displayed switches per cycle against 0.07.
# The sweep is monotone (0.05 -> +6.7pp at 0.27 flips, 0.08 -> +5.1, 0.10 -> +3.0),
# so this is the conservative end of an accuracy/stability trade. Do not retune it
# in isolation: any Stage-2 scoring change rescales the margin along with it.
MATCH_DECISIVE_MARGIN = 0.12
# Smart Termination landscape guard: when a non-winning candidate is at least this
# much longer than the matched profile AND has a decent shape score (before Stage-4
# duration penalty), the current trace may be a *prefix* of that longer program
@@ -827,7 +943,43 @@ STANDBY_BAND_FINALIZE_DEVICE_TYPES = (
DEVICE_TYPE_WASHER_DRYER,
DEVICE_TYPE_DRYER,
)
STANDBY_BAND_MIN_RATIO = 2.0 # only past 2x the expected duration
# Only past the programme's own expected duration (#445). Was 2.0, which on the
# reporter's 91 min Miele meant 91.5 minutes of idle before the cycle closed:
# that machine sits at 3.2-3.5 W after a programme ends, above its 2.56 W stop
# threshold, so _time_below_threshold never accumulates and this is the ONLY
# path that can end the cycle at all. Measured over 111 clean cycles from the
# whole corpus at 2.0 vs 1.0: no cycle that finished under 2.0 finished any
# differently, and two that never closed within their stored trace now close at
# 103% and 94% of expected. The three plateau conditions below are what make it
# safe - past expected AND >=10 min flat AND <=10% of the cycle's own peak is
# an appliance that has finished, not one still working.
STANDBY_BAND_MIN_RATIO = 1.0 # only past the expected duration
# Ceiling on the measured post-activity quiet span a stored cycle may bank
# (register item 297). `profile_terminal_quiet_seconds` is a median over that profile's own
# cycles, so it is already self-limiting; this is the guard against a corrupted
# or hand-edited value licensing an unbounded tail - the one thing the field
# exists to prevent. 30 min comfortably covers a dishwasher's passive drying
# phase, measured at a median 11% of the cycle and reaching 43%.
TERMINAL_QUIET_CAP_S = 1800.0
# A measured quiet span is only trusted as a tail allowance when the profile has
# actually shown it repeatedly (register item 297). Measured over 20 real profiles: the two
# dishwashers, which genuinely end in a passive drying phase, scored 20/20 and
# 17/17; every washing-machine profile that produced a value at all did so from
# 1-4 cycles out of 4-12, one of them 2400 s. Below these floors the accessor
# reports None and the caller keeps its previous behaviour.
TERMINAL_QUIET_MIN_OBSERVATIONS = 3
TERMINAL_QUIET_MIN_CONSISTENCY = 0.6
# Store key set by the v12->v13 migration and cleared once the one-time repair
# of banked cycle tails has run (register item 297). The repair itself cannot
# live in the storage migration, which sees only the store payload: deciding
# where a cycle's real activity ended needs `stop_threshold_w`, and that lives
# in entry.options. Same split as the v10->v11 marker-only bump.
BANKED_TAIL_REPAIR_KEY = "_banked_tail_repair_pending"
# Don't churn a cycle for a few seconds of tail: only rewrite one whose banked
# span is worth correcting. Measured median banking was 12.6 min, so this only
# skips noise.
BANKED_TAIL_REPAIR_MIN_S = 60.0
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
@@ -1156,6 +1308,33 @@ def resolve_watchdog_interval_default(device_type: str) -> int:
return int(max(DEFAULT_WATCHDOG_INTERVAL, 2.0 * sampling + 1.0))
def resolve_min_off_gap_default(device_type: str) -> int:
"""Device-resolved minimum off gap (#445).
Published to the panel so the number that actually governs the end of a cycle
is visible. ``CycleDetector`` waits
``effective_off_delay = max(off_delay, min_off_gap)``, so an unset
``min_off_gap`` silently raises a hand-lowered ``off_delay`` to this blind
per-device prior - 480 s on a washing machine, 3600 s on a dishwasher. The
#445 reporter set ``off_delay`` to 180 s, waited 6 minutes, and force-stopped
three cycles because nothing told them the real wait was 480 s.
Deliberately published rather than lowered. The prior is there because a long
quiet stretch inside a cycle must not split it in two, and that is real: a
corpus sweep over 15 devices' stored cycles found dishwasher quiet-and-resumed
stretches up to **6791 s**. Only 2 of those 15 devices left ``min_off_gap``
unset at all, so honouring the lowered ``off_delay`` would be a no-op almost
everywhere and a cycle-splitting risk on exactly the device class that needs
the bridge. Showing the number lets the user make that call per appliance.
"""
return int(DEFAULT_MIN_OFF_GAP_BY_DEVICE.get(device_type, DEFAULT_MIN_OFF_GAP))
def resolve_off_delay_default(device_type: str) -> int:
"""Device-resolved off delay (#445), published for the same reason."""
return int(DEFAULT_OFF_DELAY_BY_DEVICE.get(device_type, DEFAULT_OFF_DELAY))
def resolve_start_duration_default(device_type: str) -> float:
"""Device-resolved start-debounce default (#396).
@@ -1183,6 +1362,40 @@ DEFAULT_SMART_TERMINATION_DURATION_RATIO_BY_DEVICE = {
}
# ...except on a washing machine, where 1.05 is structurally out of reach
# (register item 355). A washer's programme length is load-adaptive, so a run is
# BELOW its profile's mean duration about half the time by definition: measured
# over the `end_gate_eval.py` corpus the median washer reaches only 0.83 of the
# bar before it stops, against 1.01 for a dishwasher, and the shortening fired on
# 6.8% of washer cycles. That is what left washing machines waiting out the full
# `min_off_gap` - a median 24.74 min after the last activity, against 6.89 for a
# dishwasher. NOT an ambiguity problem, which is what the register used to say;
# item 330 had already fixed that half.
#
# Scoped to the device class rather than lowered globally, because every early
# end in the global sweep was a DISHWASHER: at 0.95 one dishwasher closed 5.58
# min before its last activity (a 30 s 73 W blip after five dead-zero minutes),
# while washing machines showed 0.00% early ends at every ratio tried. Measured
# at 0.90, washers only: median end lag 24.74 -> 16.92 min, early ends 0.00%,
# splits unchanged at 1.90%, dishwashers byte-identical. The cost is 2 cycles of
# 158 losing their auto-label (92.4% -> 91.1%) because ending ~11 min sooner can
# skip a final match tick; they are still stored and offered for confirmation,
# which this codebase already treats as much cheaper than a wrong label (#325).
END_GATE_LATE_RATIO_BY_DEVICE = {
DEVICE_TYPE_WASHING_MACHINE: 0.90,
DEVICE_TYPE_WASHER_DRYER: 0.90,
}
def resolve_end_gate_late_ratio(device_type: str) -> float:
"""Device-resolved bar for the item-306 ENDING shortening.
Single source of truth for the detector and the Playground replay, so the
sim cannot diverge from live the way it did in item 352.
"""
return END_GATE_LATE_RATIO_BY_DEVICE.get(device_type, END_GATE_LATE_RATIO)
def resolve_smart_termination_duration_ratio_default(device_type: str) -> float:
"""Device-resolved Smart-Termination duration ratio default (#393).
@@ -1218,6 +1431,10 @@ TERMINAL_EVENT_PEAK_FRAC = 0.004
TERMINAL_QUIET_MIN_S = 120.0
# Below this many evidence cycles the medians describe noise, not the programme.
TERMINAL_SIGNATURE_MIN_CYCLES = 3
# Cycles a profile needs before one of them can be called a duration outlier
# (register item 304). Below this there is no established 'usual length' to be
# an outlier from, and calling a program's second cycle an error is nonsense.
SELF_UNMATCHABLE_MIN_CYCLES = 3
# Storage
# v6: backfill ml_review.golden=True for manually-recorded cycles (recorded ==
@@ -1240,7 +1457,7 @@ TERMINAL_SIGNATURE_MIN_CYCLES = 3
# training labels and the feedback queue, and is retention-evicted oldest-first) nor
# `reference_cycles` (curated community-store templates, golden by construction).
# Additive `setdefault`, so it is idempotent and loses nothing.
STORAGE_VERSION = 12
STORAGE_VERSION = 13
STORAGE_KEY = "ha_washdata"
# ─── Config-entry schema version (NOT the storage version above) ───────────────
@@ -1250,7 +1467,7 @@ STORAGE_KEY = "ha_washdata"
# leaves an entry a version short, which then re-migrates on every start - and repeating
# the literals in three places is what made that easy to do.
CONFIG_ENTRY_VERSION = 3
CONFIG_ENTRY_MINOR_VERSION = 10
CONFIG_ENTRY_MINOR_VERSION = 11
# Notification events
EVENT_CYCLE_STARTED = "ha_washdata_cycle_started"