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
+129 -5
View File
@@ -155,6 +155,18 @@ def _require_str(value: Any, name: str) -> str:
return value
# Options keys that no code reads any more, stripped by the 3.10 -> 3.11 step and
# again by the one-pass legacy migration. Named once so the two cannot drift.
# The value `DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO` held before item 311
# widened it. A stored option equal to this is the migration's own seed, not a
# user's choice, and the 3.10 -> 3.11 step heals it.
_OLD_SEEDED_MAX_DURATION_RATIO = 1.5
_DEAD_ABRUPT_KEYS = frozenset(
{"abrupt_drop_ratio", "abrupt_drop_watts", "abrupt_high_load_factor"}
)
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate config entry to the latest version while preserving settings."""
_log = DeviceLoggerAdapter(_LOGGER, entry.title)
@@ -263,6 +275,45 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_healed or "none",
)
# 3.10 -> 3.11: drop the abrupt-drop end-detection knobs from options. The bulk
# strip below reaches only entries still BELOW the current schema, because of the
# early return right under this block - so without a step of its own an entry
# created before 558e71e and since migrated to 3.10 keeps the dead keys forever.
# Same shape as the 3.7 -> 3.8 running_dead_zone retirement, for the same reason.
if version == 3 and minor_version == 10:
# Computed BEFORE the update, like the 3.8 -> 3.9 step: async_update_entry
# replaces entry.options synchronously, so an intersection taken after it
# is always empty and every migration would log "removed nothing".
removed = sorted(set(entry.options) & _DEAD_ABRUPT_KEYS)
new_opts = {k: v for k, v in entry.options.items() if k not in _DEAD_ABRUPT_KEYS}
# Heal the Stage-1 upper gate that item 311 widened 1.5 -> 1.8. The
# legacy migration SEEDS this key with `options.setdefault(...,
# DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO)`, so every entry migrated
# before that change has a literal 1.5 frozen in its options and the
# manager reads it in preference to the new default - i.e. the widening
# reaches nobody who already had WashData installed. It is not rare:
# 13 of the 33 real exports in `cycle_data/` carry exactly 1.5.
#
# Replaced ONLY where it still equals the old seeded default, exactly as
# the 3.9 -> 3.10 cadence heal is scoped: a user who tuned this produced
# a value like 1.44 or 1.51, not the old constant on the nose. Item 311
# measured the wider gate as 4 appliances better and none worse.
healed: list[str] = []
if new_opts.get(CONF_PROFILE_MATCH_MAX_DURATION_RATIO) == _OLD_SEEDED_MAX_DURATION_RATIO:
new_opts[CONF_PROFILE_MATCH_MAX_DURATION_RATIO] = (
DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO
)
healed.append(CONF_PROFILE_MATCH_MAX_DURATION_RATIO)
hass.config_entries.async_update_entry(
entry, options=new_opts, minor_version=11
)
minor_version = 11
_log.debug(
"Migrated WashData entry from 3.10 to 3.11 (removed %s; healed %s)",
removed or "nothing",
healed or "nothing",
)
if version == CONFIG_ENTRY_VERSION and minor_version >= CONFIG_ENTRY_MINOR_VERSION:
return True
@@ -326,9 +377,16 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
options.setdefault(
CONF_PROFILE_MATCH_MIN_DURATION_RATIO, DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO
)
options.setdefault(
CONF_PROFILE_MATCH_MAX_DURATION_RATIO, DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO
)
# CONF_PROFILE_MATCH_MAX_DURATION_RATIO is deliberately NOT seeded here.
# Seeding it is what produced `_OLD_SEEDED_MAX_DURATION_RATIO` and the two
# heal sites above: entries took the then-default 1.5 as an explicit value,
# so item 311's widening to 1.8 reached none of them. Every reader resolves
# the key with `DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO` as its fallback
# and `ws_get_options` ships `defaults` alongside `options`, so an absent
# key behaves identically today and the next change to the default actually
# lands. The min ratio above keeps its seed because that gate is inert for
# ranking (it has never removed a true candidate on the corpus), so freezing
# it costs nothing.
options.setdefault(CONF_MAX_PAST_CYCLES, DEFAULT_MAX_PAST_CYCLES)
options.setdefault(
CONF_MAX_FULL_TRACES_PER_PROFILE, DEFAULT_MAX_FULL_TRACES_PER_PROFILE
@@ -392,6 +450,27 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# so the now-inert "suppress feedback notifications" toggle is stripped.
options.pop("suppress_feedback_notifications", None)
# The abrupt-drop end-detection knobs were removed in 558e71e (the state
# machine reached the same decision from the energy gates), but nothing ever
# stripped them, so they still sit in the options of entries created before
# that. Measured across the real exports in cycle_data/: present in 5 of the
# 7 full user configurations and read by no Python or panel code. The
# surviving "abrupt end" in suggestion_engine is the cycle-artifact
# classifier, which is unrelated hardcoded logic, not these tunables.
# Entries already on the current schema are handled by the 3.10 -> 3.11 step
# above; this covers a one-pass legacy migration.
for k in _DEAD_ABRUPT_KEYS:
options.pop(k, None)
# Same heal as the 3.10 -> 3.11 step, for the cohort that never reaches it:
# an entry below 3.6 (including v1/v2) takes this one-pass path instead of
# the chain, and the `setdefault` above preserves a stored 1.5 rather than
# replacing it. Without this those entries keep the pre-item-311 gate.
if options.get(CONF_PROFILE_MATCH_MAX_DURATION_RATIO) == _OLD_SEEDED_MAX_DURATION_RATIO:
options[CONF_PROFILE_MATCH_MAX_DURATION_RATIO] = (
DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO
)
# 3.6: coffee_machine / ev / heat_pump / oven device types were removed.
# Remap any entry still on one of them to DEVICE_TYPE_OTHER (Threshold Device),
# preserving all tuned options so no user data is lost.
@@ -1188,6 +1267,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
manager._logger.info("Applied imported settings to config entry %s", entry_id)
# An old payload re-arms the one-time banked-tail repair. Nothing here
# reloads the entry unless the payload brought settings with it, so
# schedule it directly rather than leaving it for the next restart.
manager.async_schedule_banked_tail_repair()
manager._logger.info("Imported ha_washdata entry %s from %s", entry_id, source)
hass.services.async_register(DOMAIN, "import_config", handle_import_config)
@@ -1320,6 +1404,38 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.services.async_register(DOMAIN, "resume_cycle", handle_resume_cycle)
# Unload confirmation for a device with no door sensor (#451). Deliberately
# not an error when nothing is waiting: an automation wired to a physical
# button fires on every press, and "already emptied" is not a failure.
if not hass.services.has_service(DOMAIN, "mark_unloaded"):
async def handle_mark_unloaded(call: ServiceCall) -> None:
device_id = _require_str(call.data.get("device_id"), "device_id")
registry = dr.async_get(hass)
device = registry.async_get(device_id)
if not device:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="device_not_found",
)
entry_id = next(
(eid for eid in device.config_entries if eid in hass.data.get(DOMAIN, {})),
None,
)
if not entry_id:
if any(eid for eid in device.config_entries):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="integration_not_loaded",
)
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="no_config_entry",
)
hass.data[DOMAIN][entry_id].mark_unloaded("mark_unloaded service")
hass.services.async_register(DOMAIN, "mark_unloaded", handle_mark_unloaded)
return True
@@ -1418,9 +1534,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
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, async_clear_history_import
# Release the per-entry locks so they don't block the next setup. BOTH of
# them: `_entry_options_lock` is a second per-entry lock created the same
# way, and dropping only the write lock left one asyncio.Lock per removed
# entry in hass.data for the lifetime of the process.
from .ws_api import (
_WS_OPTIONS_LOCKS_KEY,
_WS_WRITE_LOCKS_KEY,
async_clear_history_import,
)
hass.data.get(_WS_WRITE_LOCKS_KEY, {}).pop(entry.entry_id, None)
hass.data.get(_WS_OPTIONS_LOCKS_KEY, {}).pop(entry.entry_id, None)
# Drop any staged history import (uploaded CSV text or a finished scan's
# traces). Nothing else owns that memory, so without this an abandoned upload