112 files

This commit is contained in:
Home Assistant Version Control committed 2026-09-18 15:36:46 +00:00
1 parent 94867222bd
commit b1fd0abc94
112 files changed
+11348 -2701

No files matched your search

+300 -104
View File
@@ -28,7 +28,7 @@ from homeassistant.components import persistent_notification
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ServiceValidationError
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import device_registry as dr
from .const import (
@@ -112,6 +112,21 @@ from .const import (
from .log_utils import DeviceLoggerAdapter
from .options_utils import strip_null_options
try: # pragma: no cover - present in every supported HA, guarded on principle
from homeassistant.setup import SetupPhases, async_pause_setup
except ImportError: # pragma: no cover
from contextlib import nullcontext
class SetupPhases: # type: ignore[no-redef]
"""Fallback so a missing helper degrades to plain (uncredited) waiting."""
WAIT_IMPORT_PACKAGES = "wait_import_packages"
def async_pause_setup(hass, phase): # type: ignore[misc]
"""No-op stand-in for HA's setup-time credit context manager."""
return nullcontext()
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [
@@ -121,6 +136,15 @@ PLATFORMS: list[Platform] = [
Platform.BUTTON,
]
# Shared future for the once-per-HA-instance ML module warm-up (issue #408).
ML_PRELOAD_FUTURE_KEY = "ha_washdata_ml_preload"
# Entry ids whose entity platforms are currently forwarded. HA leaves forwarded
# platforms in place when async_setup_entry raises, and it refuses to set the same
# platform up twice, so a retry after a part-way failure has to take them down
# first (issue #425).
FORWARDED_ENTRIES_KEY = "ha_washdata_forwarded_entries"
def _require_str(value: Any, name: str) -> str:
if not isinstance(value, str) or not value:
@@ -461,6 +485,20 @@ async def _async_preload_ml_modules(hass: HomeAssistant) -> None:
Warming the module cache once per setup in the import executor makes every
later resolution a ``sys.modules`` lookup. Best effort: a failure here only
means ML stays inert, so it must never block setup.
Two things matter for startup time here (issue #408), because
``hass.import_executor`` is ``max_workers=1`` and is shared with every other
integration importing during startup, so an awaited job on it costs however
deep that queue happens to be - measured at 35-95 s in real user
diagnostics, against ~1 ms of actual work:
1. The job is coalesced onto ONE shared future for the whole HA instance.
``preload_models()`` is idempotent, so a second job per config entry
would buy nothing but another full trip through that queue.
2. The wait is wrapped in ``async_pause_setup(WAIT_IMPORT_PACKAGES)``, which
is how HA core reports its own heavy imports (``workday``, ``holiday``,
``stream``, ``mqtt``, ...): the queue wait is credited back instead of
being billed to us as "Integration startup time".
"""
def _preload() -> None:
@@ -469,24 +507,216 @@ async def _async_preload_ml_modules(hass: HomeAssistant) -> None:
preload_models()
future = hass.data.get(ML_PRELOAD_FUTURE_KEY)
if future is None:
future = hass.data[ML_PRELOAD_FUTURE_KEY] = hass.async_add_import_executor_job(
_preload
)
try:
await hass.async_add_import_executor_job(_preload)
with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES):
# shield: a cancelled entry setup must not cancel the warm-up that
# the other entries are waiting on.
await asyncio.shield(future)
except Exception: # pylint: disable=broad-exception-caught
_LOGGER.debug("ML module preload failed", exc_info=True)
# Broken install: drop the memo so a later setup/reload retries instead
# of every entry re-raising the one cached failure forever. A cancelled
# setup is not caught here (CancelledError is not an Exception), so the
# memo correctly survives for the entries still awaiting the warm-up.
if hass.data.get(ML_PRELOAD_FUTURE_KEY) is future:
hass.data.pop(ML_PRELOAD_FUTURE_KEY, None)
async def _async_setup_shared(
hass: HomeAssistant, log: DeviceLoggerAdapter
) -> None:
"""Register everything that belongs to the HA instance, not to one appliance.
The custom card, the sidebar panel, the WebSocket API, the conversation
intents and the cached integration version are global: the first config
entry brings them up and every later entry is a guarded no-op.
Issue #425: this used to sit near the END of ``async_setup_entry``, so any
earlier per-appliance failure took the whole install's UI down with it - and
because the panel is where almost every setting is edited (including the
``via_device`` link that aborted setup in #418), losing it left the user with
no way to undo the setting that broke setup. Registering it first makes the
panel independent of whether any individual appliance sets up.
Failures inside are already handled per block (the card defers and retries,
the panel logs and retries on the next setup), so a missing UI never fails
the entry.
"""
# Register custom card via frontend.py - once per HA instance only.
if not hass.data.get("ha_washdata_card_registered") and not hass.data.get(
"ha_washdata_card_deferred"
) and not hass.data.get("ha_washdata_card_registering"):
# pylint: disable=import-outside-toplevel
from .frontend import (
CARD_REGISTERED,
CARD_DEFERRED,
WashDataCardRegistration,
)
card_reg = WashDataCardRegistration(hass)
hass.data["ha_washdata_card_registering"] = True
try:
register_result = await card_reg.async_register()
except Exception as err: # pylint: disable=broad-exception-caught
log.warning("Card registration failed, will retry on next setup: %s", err)
else:
if register_result == CARD_REGISTERED:
hass.data["ha_washdata_card_deferred"] = False
hass.data["ha_washdata_card_registered"] = True
elif register_result == CARD_DEFERRED:
hass.data["ha_washdata_card_deferred"] = True
hass.data["ha_washdata_card_registered"] = False
else:
hass.data["ha_washdata_card_deferred"] = False
hass.data["ha_washdata_card_registered"] = False
# The reason used to be debug-only inside frontend.py, so this
# warning told the user something was wrong but not what (#432).
log.warning(
"Card registration failed and was not deferred: %s",
getattr(card_reg, "last_failure_reason", "unknown"),
)
finally:
# finally, not one reset per branch: `except Exception` does not catch
# CancelledError, and HA cancels entry setup on timeout or on a reload
# racing it. A flag left True is never cleared again, so every later
# setup skips card registration until HA restarts.
hass.data["ha_washdata_card_registering"] = False
# Register full-screen sidebar panel - once per HA instance only.
# pylint: disable=import-outside-toplevel
from .frontend import async_register_panel, PANEL_REGISTERED_KEY
if not hass.data.get(PANEL_REGISTERED_KEY):
await async_register_panel(hass)
# Register WebSocket API commands for the panel. Re-run on every setup/reload:
# HA's async_register_command overwrites the handler per command type, so this
# is idempotent AND means NEW commands become available after an integration
# reload, not only after a full Home Assistant restart (previously the
# once-per-instance guard forced a full restart for any newly-added command).
from .ws_api import ( # pylint: disable=import-outside-toplevel
async_load_panel_config,
async_register_commands,
)
await async_load_panel_config(hass) # self-guards; safe to call repeatedly
from . import store_account # pylint: disable=import-outside-toplevel
await store_account.async_load(hass) # integration-wide online flag + account
# Cache the integration version from HA's already-loaded manifest (no file IO).
# ws_get_constants reads it from hass.data; we can't do a module-level read_text
# in ws_api.py because the module is imported lazily inside this coroutine and the
# IO runs on the event loop (#328/#335).
if "ha_washdata_version" not in hass.data:
try:
from homeassistant.loader import async_get_integration as _aget_integration # pylint: disable=import-outside-toplevel
_integ = await _aget_integration(hass, DOMAIN)
hass.data["ha_washdata_version"] = _integ.manifest.get("version", "") or ""
except Exception as err: # pylint: disable=broad-exception-caught
_LOGGER.debug("Could not load ha_washdata version from manifest: %s", err)
# Fall back to reading manifest.json off the event loop. Only
# write the key when the read succeeds so that a double failure
# (loader + file) leaves the key absent rather than storing ""
# (which would shadow _INTEGRATION_VERSION in ws_get_constants).
def _read_manifest_version() -> str:
try:
return json.loads(
(Path(__file__).parent / "manifest.json").read_text(encoding="utf-8")
).get("version") or ""
except Exception: # pylint: disable=broad-exception-caught
return ""
_fallback_version = await hass.async_add_executor_job(_read_manifest_version)
if _fallback_version:
hass.data["ha_washdata_version"] = _fallback_version
async_register_commands(hass)
hass.data["ha_washdata_ws_registered"] = True
# Register conversation intents (e.g. "is my washer done?") - once per HA
# instance. Intents are domain-global, so guard against re-registration when
# more than one device is configured.
if not hass.data.get("ha_washdata_intents_registered"):
from .intents import async_setup_intents # pylint: disable=import-outside-toplevel
async_setup_intents(hass)
hass.data["ha_washdata_intents_registered"] = True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up WashData from a config entry."""
_log = DeviceLoggerAdapter(_LOGGER, entry.title)
# Guard against duplicate setup during hot-reload
if entry.entry_id in hass.data.get(DOMAIN, {}):
_log.warning(
"Entry %s already set up, skipping duplicate setup", entry.entry_id
)
return True
hass.data.setdefault(DOMAIN, {})
# Panel / card / WebSocket API first: they belong to the HA instance, not to
# this appliance, so they must not depend on this entry getting through
# (#425). See _async_setup_shared.
await _async_setup_shared(hass, _log)
# Debris from an earlier setup that failed part-way. HA does NOT call
# async_unload_entry for an entry that never reached LOADED, so the manager
# this function stores before the failure point is still here - and before
# #425 we answered that by logging "already set up" and returning True,
# which told HA the entry was loaded while everything after the failure
# point (device link, half the services, the update listener) had never
# run. Every later reload repeated the lie, so the only way out was to
# delete and re-add the appliance. Throw the leftover away and set up for
# real instead.
stale = hass.data[DOMAIN].pop(entry.entry_id, None)
if stale is not None:
_log.warning(
"Entry %s still holds a manager from an earlier incomplete setup; "
"discarding it and setting up again",
entry.entry_id,
)
try:
await stale.async_shutdown()
except Exception: # pylint: disable=broad-exception-caught
_log.debug("Shutdown of the stale manager failed", exc_info=True)
# Entity platforms from an earlier attempt are still registered (HA leaves
# forwarded platforms in place when setup fails); forwarding them again raises
# "has already been setup", so take them down first.
#
# NOT gated on the stale manager: FORWARDED_ENTRIES_KEY is the independent
# record of a forward, and the two can part company. async_reload_entry's
# full-reload branch runs precisely when the manager is missing - it calls
# async_unload_entry, which leaves the marker in place when the platform unload
# is refused, and then calls this function with nothing to find. Gating the
# cleanup on `stale` skipped it in exactly the case it exists for.
if entry.entry_id in hass.data.get(FORWARDED_ENTRIES_KEY, set()):
unloaded = False
try:
unloaded = await hass.config_entries.async_unload_platforms(
entry, PLATFORMS
)
except Exception: # pylint: disable=broad-exception-caught
_log.debug("Unloading the stale platforms failed", exc_info=True)
# Only forget the platforms once they are actually gone. A platform that
# refuses to unload leaves them forwarded, and dropping the record here
# would make the next attempt skip the unload and hit "has already been
# setup" forever - the #425 loop this set exists to break.
#
# Setup deliberately CARRIES ON when the unload does not succeed, rather
# than returning False. `False` does not mean "a live platform refused":
# HA's ConfigEntry.async_unload catches a never-loaded platform's
# ValueError("Config entry was never loaded!"), logs "Error unloading entry
# X for sensor" and returns False (`config_entries.py:997-1015`). Since the
# marker is written BEFORE the forward on purpose, the common instance of
# False is "the forward set nothing up", where there is nothing registered
# to collide with and the retry succeeds. Aborting on False would abort
# every later attempt identically - a permanent brick, the exact #425 loop
# this set exists to break, and it is what
# `test_reload_after_a_failed_setup_really_sets_up` and
# `test_an_unloadable_stale_platform_does_not_abort_the_retry` pin.
if unloaded:
hass.data[FORWARDED_ENTRIES_KEY].discard(entry.entry_id)
# Warm the ML module cache before anything can score in the event loop.
await _async_preload_ml_modules(hass)
@@ -512,6 +742,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await manager.async_setup()
await _migrate_online_to_global(hass, entry, manager)
# Recorded BEFORE the await on purpose: async_forward_entry_setups gathers the
# platform setups, so one raising leaves the others set up, and a marker written
# afterwards would never be reached. The next attempt would then skip the unload
# and forward an already-registered platform (#425). Marking a forward that in
# fact set nothing up is harmless: the unload of a never-loaded platform raises,
# that is caught, and the retry forwards normally.
hass.data.setdefault(FORWARDED_ENTRIES_KEY, set()).add(entry.entry_id)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
_apply_device_link(hass, entry)
@@ -718,95 +955,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.services.async_register(DOMAIN, "trim_cycle", handle_trim_cycle)
# Register custom card via frontend.py - once per HA instance only.
if not hass.data.get("ha_washdata_card_registered") and not hass.data.get(
"ha_washdata_card_deferred"
) and not hass.data.get("ha_washdata_card_registering"):
# pylint: disable=import-outside-toplevel
from .frontend import (
CARD_REGISTERED,
CARD_DEFERRED,
WashDataCardRegistration,
)
card_reg = WashDataCardRegistration(hass)
hass.data["ha_washdata_card_registering"] = True
try:
register_result = await card_reg.async_register()
except Exception as err: # pylint: disable=broad-exception-caught
hass.data["ha_washdata_card_registering"] = False
_log.warning("Card registration failed, will retry on next setup: %s", err)
else:
hass.data["ha_washdata_card_registering"] = False
if register_result == CARD_REGISTERED:
hass.data["ha_washdata_card_deferred"] = False
hass.data["ha_washdata_card_registered"] = True
elif register_result == CARD_DEFERRED:
hass.data["ha_washdata_card_deferred"] = True
hass.data["ha_washdata_card_registered"] = False
else:
hass.data["ha_washdata_card_deferred"] = False
hass.data["ha_washdata_card_registered"] = False
_log.warning("Card registration failed and was not deferred")
# Register full-screen sidebar panel - once per HA instance only.
# pylint: disable=import-outside-toplevel
from .frontend import async_register_panel, PANEL_REGISTERED_KEY
if not hass.data.get(PANEL_REGISTERED_KEY):
await async_register_panel(hass)
# Register WebSocket API commands for the panel. Re-run on every setup/reload:
# HA's async_register_command overwrites the handler per command type, so this
# is idempotent AND means NEW commands become available after an integration
# reload, not only after a full Home Assistant restart (previously the
# once-per-instance guard forced a full restart for any newly-added command).
from .ws_api import ( # pylint: disable=import-outside-toplevel
async_load_panel_config,
async_register_commands,
)
await async_load_panel_config(hass) # self-guards; safe to call repeatedly
from . import store_account # pylint: disable=import-outside-toplevel
await store_account.async_load(hass) # integration-wide online flag + account
# Cache the integration version from HA's already-loaded manifest (no file IO).
# ws_get_constants reads it from hass.data; we can't do a module-level read_text
# in ws_api.py because the module is imported lazily inside this coroutine and the
# IO runs on the event loop (#328/#335).
if "ha_washdata_version" not in hass.data:
try:
from homeassistant.loader import async_get_integration as _aget_integration # pylint: disable=import-outside-toplevel
_integ = await _aget_integration(hass, DOMAIN)
hass.data["ha_washdata_version"] = _integ.manifest.get("version", "") or ""
except Exception as err: # pylint: disable=broad-exception-caught
_LOGGER.debug("Could not load ha_washdata version from manifest: %s", err)
# Fall back to reading manifest.json off the event loop. Only
# write the key when the read succeeds so that a double failure
# (loader + file) leaves the key absent rather than storing ""
# (which would shadow _INTEGRATION_VERSION in ws_get_constants).
def _read_manifest_version() -> str:
try:
return json.loads(
(Path(__file__).parent / "manifest.json").read_text(encoding="utf-8")
).get("version") or ""
except Exception: # pylint: disable=broad-exception-caught
return ""
_fallback_version = await hass.async_add_executor_job(_read_manifest_version)
if _fallback_version:
hass.data["ha_washdata_version"] = _fallback_version
async_register_commands(hass)
hass.data["ha_washdata_ws_registered"] = True
# Register conversation intents (e.g. "is my washer done?") - once per HA
# instance. Intents are domain-global, so guard against re-registration when
# more than one device is configured.
if not hass.data.get("ha_washdata_intents_registered"):
from .intents import async_setup_intents # pylint: disable=import-outside-toplevel
async_setup_intents(hass)
hass.data["ha_washdata_intents_registered"] = True
# Belt and braces for the hoist above: the panel's static routes need
# hass.http, which is an after_dependencies entry rather than a hard one, so
# it is guaranteed up before setup in a real HA but not in every harness.
# By here the entity platforms have pulled the whole frontend stack in, which
# is where this block used to live. Every step guards on its own "already
# done" flag, so this is a no-op whenever the early call did its job.
await _async_setup_shared(hass, _log)
# Register feedback service
if not hass.services.has_service(
@@ -1174,22 +1329,62 @@ def _apply_device_link(hass: HomeAssistant, entry: ConfigEntry) -> None:
When CONF_LINKED_DEVICE points at an existing device (e.g. the smart plug or
appliance), the WashData device is shown as "Connected via <device>" in the
HA device registry. Clearing the option removes the link. Stale targets that
no longer exist are treated as "no link" so the registry never references a
deleted device.
no longer exist - and a target that is this entry's own WashData device, which
HA rejects as a self-reference (#418) - are treated as "no link" so the
registry never references a deleted device or itself.
"""
_log = DeviceLoggerAdapter(_LOGGER, entry.title)
registry = dr.async_get(hass)
washdata_device = registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)})
identifier = (DOMAIN, entry.entry_id)
if hasattr(registry, "async_get_device_by_identifier"):
# HA 2026.9+ deprecated async_get_device because identifiers are no longer
# unique across config entries (issue #405). Our device's identifier is
# owned by this entry, so the by-identifier lookup is unambiguous. The
# attribute guard keeps us working on the older HA the manifest still
# supports, where the new method does not exist yet.
washdata_device = registry.async_get_device_by_identifier(
identifier, entry.entry_id
)
else:
washdata_device = registry.async_get_device(identifiers={identifier})
if washdata_device is None:
return
linked_device_id = entry.options.get(CONF_LINKED_DEVICE) or None
if linked_device_id and registry.async_get(linked_device_id) is None:
linked_device_id = None
if linked_device_id and linked_device_id == washdata_device.id:
# A device may not be its own via_device: HA 2026.9 raises
# HomeAssistantError instead of silently accepting it, and this runs inside
# async_setup_entry - so a self-link aborted setup for the whole entry
# (#418). The picker used to list this entry's own WashData device, whose
# name mirrors the entry title (and often the plug's), so it was easy to
# select by mistake. Treat it as "no link" and fall through to the update
# below: on an older HA the self-reference may already be stored in the
# registry, and clearing it is exactly the repair needed.
_log.warning(
"Ignoring 'Group Under Device': %s is this appliance's own WashData "
"device and a device cannot be linked to itself. Pick the smart plug "
"(or another device) instead, or clear the setting.",
linked_device_id,
)
linked_device_id = None
if washdata_device.via_device_id != linked_device_id:
registry.async_update_device(
washdata_device.id, via_device_id=linked_device_id
)
try:
registry.async_update_device(
washdata_device.id, via_device_id=linked_device_id
)
except (HomeAssistantError, ValueError) as err:
# The via_device link is cosmetic (it only nests the device in the HA
# registry UI). A registry rule we do not know about yet must never be
# able to take the whole entry down with it, as the self-link did in
# #418 - log it and leave the device standalone.
_log.warning(
"Could not link WashData device to %s: %s",
linked_device_id or "(none)",
err,
)
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
@@ -1210,6 +1405,7 @@ async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data.get(FORWARDED_ENTRIES_KEY, set()).discard(entry.entry_id)
manager = hass.data[DOMAIN].pop(entry.entry_id)
await manager.async_shutdown()