112 files

This commit is contained in:
Home Assistant Version Control
2026-09-18 15:36:46 +00:00
parent 94867222bd
commit b1fd0abc94
112 changed files with 11348 additions and 2701 deletions
+265 -10
View File
@@ -43,6 +43,10 @@ from .const import (
CONF_DEVICE_TYPE,
CONF_DISHWASHER_END_SPIKE_QUIET_RELEASE,
CONF_SMART_TERMINATION_DURATION_RATIO,
CONF_ANTI_CREASE_FINALIZE_RATIO,
ANTI_CREASE_FINALIZE_RATIO_MIN,
ANTI_CREASE_FINALIZE_RATIO_MAX,
CONF_CURVE_PREROLL_SECONDS,
CONF_DOOR_SENSOR_ENTITY,
CONF_ANTI_WRINKLE_EXIT_POWER,
CONF_ANTI_WRINKLE_MAX_POWER,
@@ -78,6 +82,9 @@ from .const import (
DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS,
DEFAULT_SMART_TERMINATION_DURATION_RATIO,
DEFAULT_SMART_TERMINATION_DURATION_RATIO_BY_DEVICE,
DEFAULT_ANTI_CREASE_FINALIZE_RATIO,
DEFAULT_CURVE_PREROLL_SECONDS,
CURVE_PREROLL_MAX_SECONDS,
resolve_sampling_interval_default,
resolve_watchdog_interval_default,
resolve_start_duration_default,
@@ -108,7 +115,11 @@ from .const import (
from . import history_import
from . import playground
from . import task_registry
from .cycle_detector import CycleDetectorConfig
from .cycle_detector import (
CycleDetectorConfig,
effective_anticrease_finalize_ratio,
effective_curve_preroll_seconds,
)
from .options_utils import strip_null_options
from .setup_advisor import compute_setup_phase
from .ws_schema import WS_OPEN_RESPONSES, WS_RESPONSE_TYPES
@@ -540,6 +551,8 @@ _FULL_COMMANDS = frozenset({
"get_export_inventory", "analyze_import", "export_config_selective", "import_config_selective",
# Reverting on-device models / matcher tuning discards learned state -> full access.
"revert_matching_config", "revert_ml_models",
# Rewrites the appliance's lifetime odometer, which drives maintenance schedules.
"set_lifetime_cycle_count",
# Historical power-data import: ingests a whole power history and writes cycles.
"history_import_begin", "history_import_chunk", "history_import_recorder",
"start_history_import_scan", "apply_history_import",
@@ -1222,6 +1235,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
ws_get_profile_groups, ws_save_profile_group, ws_rename_profile_group, ws_delete_profile_group,
# Maintenance log (Group E)
ws_get_maintenance_log, ws_add_maintenance_event, ws_delete_maintenance_event,
ws_set_lifetime_cycle_count,
# Cycles
ws_label_cycle, ws_delete_cycle, ws_auto_label_cycles,
# Phase catalog
@@ -1340,6 +1354,12 @@ def ws_get_devices(
"recording": False,
"is_user_paused": False,
"manual_program": False,
"armed_program": None,
# Defaulted here rather than only in the manager branch below: both are
# declared non-optional by DeviceInfo (ws_schema.py) and ws-types.d.ts,
# and the branch is skipped for a manager-less entry (mid-setup, stale,
# or a setup that failed) as well as short-circuited by its own except.
"envelope_position": None,
"options": dict(entry.options),
# Device-resolved defaults for the cadence/ratio fields (#396/#393) so the
# device-list conflict/suggestion badges score an unset field against the
@@ -1361,17 +1381,29 @@ def ws_get_devices(
program = None
info["current_program"] = program
info["manual_program"] = bool(getattr(manager, "manual_program_active", False))
# A program pinned for the NEXT cycle (#411). Kept separate from
# current_program so an idle device does not claim to be running one.
info["armed_program"] = getattr(manager, "armed_program", None)
info["time_remaining_s"] = getattr(manager, "_time_remaining", None)
info["total_duration_s"] = getattr(manager, "_total_duration", None)
power = getattr(manager, "_current_power", None)
# Read through the property, not the raw cache (#409): it falls back
# to the sensor's live state so the panel can never show a power the
# sensor never reported.
power = getattr(manager, "current_power", None)
info["current_power_w"] = round(float(power), 2) if power is not None else None
progress = getattr(manager, "_cycle_progress", None)
if progress is not None:
info["cycle_progress_pct"] = round(float(progress), 1)
# Where the run maps onto the matched profile's own curve (item 269).
# Independent of elapsed time, so it stays meaningful when a cycle
# over- or under-runs; refreshed only during low-power phases, which
# the panel's tooltip says.
info["envelope_position"] = getattr(manager, "envelope_position", None)
store = getattr(manager, "profile_store", None)
if store is not None:
try:
@@ -1518,6 +1550,10 @@ def _resolved_option_defaults(device_type: str) -> dict[str, Any]:
CONF_SMART_TERMINATION_DURATION_RATIO: (
resolve_smart_termination_duration_ratio_default(device_type)
),
# #429: deliberately NOT device-resolved (the safe value is a property of
# the individual machine), but the panel pre-populates it from the same
# payload, so it is published here rather than left to the JS default.
CONF_ANTI_CREASE_FINALIZE_RATIO: DEFAULT_ANTI_CREASE_FINALIZE_RATIO,
}
@@ -1535,7 +1571,14 @@ def ws_get_options(
if not entry:
connection.send_error(msg["id"], "not_found", f"Entry {msg['entry_id']!r} not found")
return
options = {**entry.data, **entry.options}
# #422: the display name is carried by ``entry.title``, never by options -
# ``_merge_structural_options`` and ``_OPTIONS_IDENTITY_KEYS`` strip CONF_NAME
# back out on every save. ``entry.data[CONF_NAME]`` is therefore a fossil from
# ``async_create_entry`` that no rename path updates, and being data-only it is
# the one key nothing in options can shadow. Pin it to the title so Settings ->
# Basic shows the same name as the device list (which serves entry.title) and a
# save can never echo the creation-time name back over the current one.
options = {**entry.data, **entry.options, CONF_NAME: entry.title}
# Device-resolved defaults for the cadence settings whose defaults vary by
# device type (#396). The panel uses these as the render/conflict-check
# fallback for an unset field so it shows (and validates against) the value the
@@ -1639,6 +1682,48 @@ async def ws_set_options(
except (TypeError, ValueError):
new_options.pop(CONF_SMART_TERMINATION_DURATION_RATIO, None)
# #429: the anti-crease finalise ratio is the same kind of value against the
# same kind of mean, and equally meaningless outside [0.50, 1.00]. An empty or
# non-numeric submission drops the key so DEFAULT_ANTI_CREASE_FINALIZE_RATIO
# applies again.
if CONF_ANTI_CREASE_FINALIZE_RATIO in new_options:
_raw_ac = new_options[CONF_ANTI_CREASE_FINALIZE_RATIO]
if _raw_ac in (None, ""):
new_options.pop(CONF_ANTI_CREASE_FINALIZE_RATIO, None)
else:
try:
_ac = float(_raw_ac)
if not math.isfinite(_ac):
raise ValueError("non-finite")
new_options[CONF_ANTI_CREASE_FINALIZE_RATIO] = min(
ANTI_CREASE_FINALIZE_RATIO_MAX,
max(ANTI_CREASE_FINALIZE_RATIO_MIN, _ac),
)
# OverflowError too (register item 194): json parses an integer literal
# of any length into an unbounded int, and float() on one of those raises
# rather than returning inf. Uncaught it becomes ERR_UNKNOWN_ERROR and
# the whole save fails, instead of this key falling back to its default.
except (TypeError, ValueError, OverflowError):
new_options.pop(CONF_ANTI_CREASE_FINALIZE_RATIO, None)
# #430: seconds, 0 = off. Clamped to [0, CURVE_PREROLL_MAX_SECONDS] so a
# mistyped value cannot drag minutes of unrelated standby into a curve; empty
# or non-numeric drops the key and restores the default (off).
if CONF_CURVE_PREROLL_SECONDS in new_options:
_raw_pr = new_options[CONF_CURVE_PREROLL_SECONDS]
if _raw_pr in (None, ""):
new_options.pop(CONF_CURVE_PREROLL_SECONDS, None)
else:
try:
_pr = float(_raw_pr)
if not math.isfinite(_pr):
raise ValueError("non-finite")
new_options[CONF_CURVE_PREROLL_SECONDS] = min(
CURVE_PREROLL_MAX_SECONDS, max(0.0, _pr)
)
except (TypeError, ValueError, OverflowError):
new_options.pop(CONF_CURVE_PREROLL_SECONDS, None)
# A None outside the clearable selectors means "not set", not a value: the
# per-setting Revert sends the changelog's `old`, which is null for a setting
# never saved before. Stored, it would survive options.get(key, DEFAULT) and
@@ -1856,12 +1941,33 @@ async def ws_get_profiles(
except Exception: # pylint: disable=broad-exception-caught
pass
# How each programme ENDS, measured from its own cycles. Read-only: no
# detection path consults it, and `consistency` is there to be read first,
# because the event it describes is present in only some runs of the same
# programme (register item 238).
terminal: dict[str, Any] = {}
try:
for _profile in profiles:
# `profiles` is list_profiles(): a list of profile DICTS, not names.
# Passing the dict straight in compared it against each cycle's
# `profile_name` string, which can never match, so the feature
# returned {} for every device while raising nothing.
_name = _profile.get("name") if isinstance(_profile, dict) else None
if not _name:
continue
_sig = manager.profile_store.compute_profile_terminal_signature(_name)
if _sig is not None:
terminal[_name] = _sig
except Exception: # pylint: disable=broad-exception-caught
terminal = {}
return {
"profiles": profiles,
"profile_health": health,
"profile_trends": trends,
"coverage_gaps": coverage_gaps,
"profile_advisories": advisories,
"profile_terminal": terminal,
}
stats = await hass.async_add_executor_job(_compute_stats)
@@ -2266,14 +2372,126 @@ async def ws_get_maintenance_log(
cfg = manager.config_entry.options.get(CONF_MAINTENANCE_REMINDER_CYCLES)
if isinstance(cfg, dict):
reminders.update(cfg)
store = manager.profile_store
_send_result(connection, msg["id"], "get_maintenance_log", {
"log": manager.profile_store.get_maintenance_log(),
"log": store.get_maintenance_log(),
"due": manager.maintenance_due,
"event_types": list(MAINTENANCE_EVENT_TYPES),
"reminders": reminders,
# Cycles run since each task was last done, and the odometer they are
# measured against (#414). Computed backend-side before this and never
# sent, so the panel could show "due" but never "how close".
"cycles_since": {
evt: store.cycles_since_maintenance(evt) for evt in MAINTENANCE_EVENT_TYPES
},
"lifetime_cycle_count": manager.lifetime_cycle_count,
})
@websocket_api.websocket_command(
{
vol.Required("type"): "ha_washdata/set_lifetime_cycle_count",
vol.Required("entry_id"): str,
vol.Required("count"): vol.All(vol.Coerce(int), vol.Range(min=0, max=1000000)),
}
)
@websocket_api.async_response
async def ws_set_lifetime_cycle_count(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Correct the appliance's lifetime cycle odometer (requires full access).
The one sanctioned way the count moves other than a cycle completing (#414).
It exists because WashData cannot always tell a real short run from an
artefact, and because an appliance may have run for years before WashData was
installed - rather than asking "was that a real cycle?" on every deletion, the
user corrects the total once. Recorded in the settings changelog.
"""
entry_id: str = msg["entry_id"]
manager = _get_manager(hass, entry_id)
if manager is None:
_err_not_found(connection, msg["id"], entry_id)
return
try:
store = manager.profile_store
count = int(msg["count"])
# Serialize the whole read-check-mutate-save-rollback under the per-entry
# write lock. The rollback below restores the value read at the start, so two
# corrections that interleave across the save could see the earlier one's
# failure undo the later one's success (A reads 10, B reads and writes 4000
# successfully, A's save fails and A restores 10). Holding the lock across the
# await is the point; taking it only around the mutation would not help.
async with _entry_write_lock(hass, msg["entry_id"]):
previous = store.get_lifetime_cycle_count()
# A stored record is evidence of a run, so the odometer can never read below
# the number of records on hand - that floor is what get_lifetime_cycle_count
# applies. Without this check the setter accepted a lower value, the getter
# masked it while the history was long, and it then surfaced later once
# records were deleted: a correction that appeared to do nothing and took
# effect retroactively, i.e. the odometer regression #414 exists to prevent.
# Refused with the floor named rather than silently clamped, so the user is
# told why their number was not taken.
floor = len(store.get_past_cycles())
if count < floor:
connection.send_error(
msg["id"],
"invalid_format",
f"Cannot set the lifetime count below the {floor} cycle records "
f"currently stored; delete records first or choose {floor} or more",
)
return
store.set_lifetime_cycle_count(count, force=True)
try:
# ONE save for both mutations: async_record_settings_changes persists the
# store, and the new count is already staged in memory. Saving separately
# beforehand meant a changelog failure reported unknown_error on a
# correction that had in fact been written, and left `previous` reading
# the new value on a retry - recording old == new.
await store.async_record_settings_changes(
[{"key": "lifetime_cycle_count", "old": previous, "new": count}]
)
except Exception:
# Undo only OUR write. A cycle completing during the await bumps the
# same counter (manager._async_process_cycle_end -> lifetime energy
# save), and that path deliberately does not take this lock: it lives
# in the manager, on the hot cycle-end path, and reaching into the WS
# layer's lock from there would invert the layering for a window this
# narrow. Compare-and-swap keeps the rollback honest without it - if
# the value is no longer what we wrote, someone else owns it now and
# restoring `previous` would discard their increment.
if store.get_lifetime_cycle_count() == count:
store.set_lifetime_cycle_count(previous, force=True)
raise
# Re-validate the manager is still live after the awaited save: a reload
# during it detaches this manager (and its per-entry lock, which
# async_unload_entry pops), so notifying it would target stale state and
# reporting its store's count would show the panel a number from a store
# nothing reads any more. Mirrors the guard the recording-persist and import
# handlers use. Reported as success because the save itself did happen; the
# count comes from whatever store is live now.
current_manager = _get_manager(hass, entry_id)
if current_manager is not manager:
_LOGGER.warning(
"Manager replaced during lifetime-count correction for %s; "
"skipping notify", entry_id,
)
else:
manager.notify_update()
live_store = (
current_manager.profile_store if current_manager is not None else store
)
_send_result(
connection,
msg["id"],
"set_lifetime_cycle_count",
{"success": True, "lifetime_cycle_count": live_store.get_lifetime_cycle_count()},
)
except Exception as exc: # pylint: disable=broad-exception-caught
connection.send_error(msg["id"], "unknown_error", str(exc))
@websocket_api.websocket_command(
{
vol.Required("type"): "ha_washdata/add_maintenance_event",
@@ -3025,7 +3243,7 @@ async def _reprocess_task(hass: HomeAssistant, task: Any, entry_id: str) -> None
reg.finish(task, state=task_registry.STATE_CANCELLED)
return
reg.update(task, total=5, done=0, label="Reprocessing: matching cycles",
reg.update(task, total=6, done=0, label="Reprocessing: matching cycles",
label_key="task.reprocess.matching")
summary["count"] = await store.async_reprocess_all_data()
@@ -3071,7 +3289,21 @@ async def _reprocess_task(hass: HomeAssistant, task: Any, entry_id: str) -> None
reg.finish(task, state=task_registry.STATE_CANCELLED, result=summary)
return
reg.update(task, done=4, label="Reprocessing: cycle health",
reg.update(task, done=4, label="Reprocessing: energy costs",
label_key="task.reprocess.costs")
# Recost stored cycles against the recorder's price history (#426). Cheap
# and a no-op unless a dynamic price entity is configured, so it runs
# unconditionally rather than behind yet another switch.
try:
summary["costs_recomputed"] = await manager.async_recompute_cycle_costs()
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.debug("cost recompute 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=5, label="Reprocessing: cycle health",
label_key="task.reprocess.health")
# Recompute per-cycle health against the (possibly retrained) model.
# Skip when training already recomputed it (a promotion refreshes health).
@@ -3081,7 +3313,7 @@ async def _reprocess_task(hass: HomeAssistant, task: Any, entry_id: str) -> None
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.debug("health recompute failed for %s: %s", entry_id, exc)
reg.update(task, done=5)
reg.update(task, done=6)
# Re-validate the manager is still live after a long chain of awaits; if the
# entry was reloaded, the original manager is detached and must not notify.
if _get_manager(hass, entry_id) is manager:
@@ -3112,7 +3344,8 @@ def ws_reprocess_history(
"""Kick off the full "Process history" pass as a detached, registry-tracked
task; returns its id immediately. Runs, in order: reprocess (rematch + rebuild
envelopes) -> backfill golden -> refresh suggestions -> on-device ML training
(when enabled) -> recompute cycle health. Progress + result via the registry."""
(when enabled) -> recost cycles from recorder price history -> recompute cycle
health. Progress + result via the registry."""
entry_id: str = msg["entry_id"]
if _get_manager(hass, entry_id) is None:
_err_not_found(connection, msg["id"], entry_id)
@@ -3166,6 +3399,10 @@ async def ws_wipe_history(
try:
await manager.profile_store.clear_all_data()
# clear_all_data pops the persisted arm, but the manager's in-memory field is
# the authoritative one, so the pin has to be retired there too or the next
# cycle re-applies a program from before the wipe.
manager.clear_armed_program()
manager.notify_update()
_send_result(connection, msg["id"], "wipe_history", {"success": True})
except Exception as exc: # pylint: disable=broad-exception-caught
@@ -4481,8 +4718,16 @@ def ws_set_program(
prog = msg.get("program")
if not prog or prog in ("auto_detect", "__auto__", "none"):
manager.clear_manual_program()
else:
manager.set_manual_program(prog)
elif not manager.set_manual_program(prog):
# Only a program that does not exist can fail now (#411). It used to
# fail for the far more common reason of no cycle being under way, and
# this line reported success anyway because the manager returned None
# either way, so the panel showed a confirmation and then quietly
# reverted the dropdown.
connection.send_error(
msg["id"], "not_found", f"No such program: {prog}"
)
return
manager.notify_update()
_send_result(connection, msg["id"], "set_program", {"success": True})
except Exception as exc: # pylint: disable=broad-exception-caught
@@ -5539,6 +5784,16 @@ def _playground_base_config(manager: Any, entry: Any) -> CycleDetectorConfig:
str(opts.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE))
),
),
# The same helpers the detector reads these through, not a bare float:
# this branch builds the sim config when the live detector is unavailable,
# and an imported or stale option would otherwise give the Playground a
# value production would have clamped (register items 244, 249).
anti_crease_finalize_ratio=effective_anticrease_finalize_ratio(
opts.get(CONF_ANTI_CREASE_FINALIZE_RATIO, DEFAULT_ANTI_CREASE_FINALIZE_RATIO)
),
curve_preroll_seconds=effective_curve_preroll_seconds(
opts.get(CONF_CURVE_PREROLL_SECONDS, DEFAULT_CURVE_PREROLL_SECONDS)
),
# Match the live detector's tuned gate, not the dataclass 0.4, so the sim's
# Smart-Termination / anti-crease confidence checks reproduce production.
match_confidence_threshold=_safe_float_finite(