1080 lines
46 KiB
Python
1080 lines
46 KiB
Python
# WashData - Home Assistant integration for appliance cycle monitoring via smart plugs.
|
|
# Copyright (C) 2026 Lukas Bandura
|
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU Affero General Public License as published
|
|
# by the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU Affero General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
"""The WashData integration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
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.helpers import device_registry as dr
|
|
|
|
from .const import (
|
|
DOMAIN,
|
|
SERVICE_SUBMIT_FEEDBACK,
|
|
CONF_LINKED_DEVICE,
|
|
CONF_MIN_POWER,
|
|
CONF_OFF_DELAY,
|
|
CONF_DEVICE_TYPE,
|
|
CONF_POWER_SENSOR,
|
|
CONF_NOTIFY_SERVICE,
|
|
CONF_NOTIFY_EVENTS,
|
|
NOTIFY_EVENT_LIVE,
|
|
CONF_NOTIFY_START_SERVICES,
|
|
CONF_NOTIFY_FINISH_SERVICES,
|
|
CONF_NOTIFY_LIVE_SERVICES,
|
|
CONF_NOTIFY_ACTIONS,
|
|
CONF_NOTIFY_PEOPLE,
|
|
CONF_NOTIFY_ONLY_WHEN_HOME,
|
|
CONF_NOTIFY_FIRE_EVENTS,
|
|
CONF_NOTIFY_LIVE_INTERVAL_SECONDS,
|
|
CONF_NOTIFY_LIVE_OVERRUN_PERCENT,
|
|
CONF_NOTIFY_TIMEOUT_SECONDS,
|
|
CONF_NOTIFY_CHANNEL,
|
|
CONF_NOTIFY_FINISH_CHANNEL,
|
|
CONF_NOTIFY_REMINDER_MESSAGE,
|
|
DEFAULT_NOTIFY_ONLY_WHEN_HOME,
|
|
DEFAULT_NOTIFY_FIRE_EVENTS,
|
|
DEFAULT_NOTIFY_LIVE_INTERVAL_SECONDS,
|
|
DEFAULT_NOTIFY_LIVE_OVERRUN_PERCENT,
|
|
DEFAULT_NOTIFY_TIMEOUT_SECONDS,
|
|
DEFAULT_NOTIFY_CHANNEL,
|
|
DEFAULT_NOTIFY_FINISH_CHANNEL,
|
|
DEFAULT_NOTIFY_REMINDER_MESSAGE,
|
|
CONF_PROGRESS_RESET_DELAY,
|
|
CONF_LEARNING_CONFIDENCE,
|
|
CONF_DURATION_TOLERANCE,
|
|
CONF_AUTO_LABEL_CONFIDENCE,
|
|
DEFAULT_PROGRESS_RESET_DELAY,
|
|
DEFAULT_LEARNING_CONFIDENCE,
|
|
DEFAULT_DURATION_TOLERANCE,
|
|
DEFAULT_AUTO_LABEL_CONFIDENCE,
|
|
CONF_NO_UPDATE_ACTIVE_TIMEOUT,
|
|
DEFAULT_NO_UPDATE_ACTIVE_TIMEOUT,
|
|
CONF_SMOOTHING_WINDOW,
|
|
CONF_PROFILE_DURATION_TOLERANCE,
|
|
CONF_INTERRUPTED_MIN_SECONDS,
|
|
DEFAULT_SMOOTHING_WINDOW,
|
|
DEFAULT_PROFILE_DURATION_TOLERANCE,
|
|
DEFAULT_INTERRUPTED_MIN_SECONDS,
|
|
CONF_PROFILE_MATCH_INTERVAL,
|
|
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
|
|
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
|
|
CONF_MAX_PAST_CYCLES,
|
|
CONF_MAX_FULL_TRACES_PER_PROFILE,
|
|
CONF_MAX_FULL_TRACES_UNLABELED,
|
|
CONF_WATCHDOG_INTERVAL,
|
|
CONF_AUTO_TUNE_NOISE_EVENTS_THRESHOLD,
|
|
CONF_COMPLETION_MIN_SECONDS,
|
|
CONF_NOTIFY_BEFORE_END_MINUTES,
|
|
DEFAULT_PROFILE_MATCH_INTERVAL,
|
|
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO,
|
|
DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO,
|
|
DEFAULT_MAX_PAST_CYCLES,
|
|
DEFAULT_MAX_FULL_TRACES_PER_PROFILE,
|
|
DEFAULT_MAX_FULL_TRACES_UNLABELED,
|
|
DEFAULT_WATCHDOG_INTERVAL,
|
|
DEFAULT_AUTO_TUNE_NOISE_EVENTS_THRESHOLD,
|
|
DEFAULT_COMPLETION_MIN_SECONDS,
|
|
DEFAULT_NOTIFY_BEFORE_END_MINUTES,
|
|
DEFAULT_DEVICE_TYPE,
|
|
DEVICE_TYPE_OTHER,
|
|
DEFAULT_START_DURATION_THRESHOLD,
|
|
CONF_START_DURATION_THRESHOLD,
|
|
CONF_RUNNING_DEAD_ZONE,
|
|
)
|
|
from .log_utils import DeviceLoggerAdapter
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
PLATFORMS: list[Platform] = [
|
|
Platform.SENSOR,
|
|
Platform.BINARY_SENSOR,
|
|
Platform.SELECT,
|
|
Platform.BUTTON,
|
|
]
|
|
|
|
|
|
def _require_str(value: Any, name: str) -> str:
|
|
if not isinstance(value, str) or not value:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key=f"{name}_required",
|
|
)
|
|
return value
|
|
|
|
|
|
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)
|
|
version = entry.version or 1
|
|
minor_version = entry.minor_version or 1
|
|
|
|
if version > 3:
|
|
_log.error(
|
|
"Refusing to migrate unsupported future schema %s.%s", version, minor_version
|
|
)
|
|
return False
|
|
|
|
if version == 3 and minor_version >= 8:
|
|
return True
|
|
|
|
# 3.6 → 3.7: remove initial_profile stub key from entry.data.
|
|
if version == 3 and minor_version == 6:
|
|
new_data = {k: v for k, v in entry.data.items() if k != "initial_profile"}
|
|
hass.config_entries.async_update_entry(
|
|
entry, data=new_data, minor_version=7
|
|
)
|
|
minor_version = 7
|
|
_log.debug("Migrated WashData entry from 3.6 to 3.7")
|
|
|
|
# 3.7 → 3.8: drop running_dead_zone from options (the setting was never
|
|
# wired to any detection logic and has been retired in 0.5.3).
|
|
if version == 3 and minor_version == 7:
|
|
new_opts = {k: v for k, v in entry.options.items() if k != CONF_RUNNING_DEAD_ZONE}
|
|
hass.config_entries.async_update_entry(
|
|
entry, options=new_opts, minor_version=8
|
|
)
|
|
minor_version = 8
|
|
_log.debug("Migrated WashData entry from 3.7 to 3.8 (removed running_dead_zone)")
|
|
|
|
if version == 3 and minor_version >= 8:
|
|
return True
|
|
|
|
data: dict[str, Any] = dict(entry.data)
|
|
options: dict[str, Any] = dict(entry.options)
|
|
|
|
# Preserve core settings from data into options if missing
|
|
if CONF_MIN_POWER not in options and CONF_MIN_POWER in data:
|
|
options[CONF_MIN_POWER] = data[CONF_MIN_POWER]
|
|
if CONF_OFF_DELAY not in options and CONF_OFF_DELAY in data:
|
|
options[CONF_OFF_DELAY] = data[CONF_OFF_DELAY]
|
|
if CONF_DEVICE_TYPE not in options and CONF_DEVICE_TYPE in data:
|
|
options[CONF_DEVICE_TYPE] = data[CONF_DEVICE_TYPE]
|
|
if CONF_POWER_SENSOR not in options and CONF_POWER_SENSOR in data:
|
|
options[CONF_POWER_SENSOR] = data[CONF_POWER_SENSOR]
|
|
if CONF_NOTIFY_SERVICE not in options and CONF_NOTIFY_SERVICE in data:
|
|
options[CONF_NOTIFY_SERVICE] = data[CONF_NOTIFY_SERVICE]
|
|
|
|
# Migrate legacy single CONF_NOTIFY_SERVICE into per-event service lists.
|
|
# Users who configured a notify service before 0.3.x would otherwise lose
|
|
# their notification settings entirely on upgrade.
|
|
legacy_svc = options.get(CONF_NOTIFY_SERVICE) or data.get(CONF_NOTIFY_SERVICE)
|
|
if legacy_svc and isinstance(legacy_svc, str):
|
|
# CONF_NOTIFY_EVENTS is a deprecated list of enabled event types.
|
|
# Only migrate live services when live events were explicitly opted in.
|
|
legacy_events = options.get(CONF_NOTIFY_EVENTS) or data.get(CONF_NOTIFY_EVENTS) or []
|
|
if CONF_NOTIFY_START_SERVICES not in options:
|
|
options[CONF_NOTIFY_START_SERVICES] = [legacy_svc]
|
|
if CONF_NOTIFY_FINISH_SERVICES not in options:
|
|
options[CONF_NOTIFY_FINISH_SERVICES] = [legacy_svc]
|
|
if CONF_NOTIFY_LIVE_SERVICES not in options and NOTIFY_EVENT_LIVE in legacy_events:
|
|
options[CONF_NOTIFY_LIVE_SERVICES] = [legacy_svc]
|
|
|
|
options.setdefault(CONF_PROGRESS_RESET_DELAY, DEFAULT_PROGRESS_RESET_DELAY)
|
|
options.setdefault(CONF_LEARNING_CONFIDENCE, DEFAULT_LEARNING_CONFIDENCE)
|
|
options.setdefault(CONF_DURATION_TOLERANCE, DEFAULT_DURATION_TOLERANCE)
|
|
options.setdefault(CONF_AUTO_LABEL_CONFIDENCE, DEFAULT_AUTO_LABEL_CONFIDENCE)
|
|
options.setdefault(CONF_NO_UPDATE_ACTIVE_TIMEOUT, DEFAULT_NO_UPDATE_ACTIVE_TIMEOUT)
|
|
options.setdefault(CONF_SMOOTHING_WINDOW, DEFAULT_SMOOTHING_WINDOW)
|
|
options.setdefault(
|
|
CONF_PROFILE_DURATION_TOLERANCE, DEFAULT_PROFILE_DURATION_TOLERANCE
|
|
)
|
|
options.setdefault(CONF_INTERRUPTED_MIN_SECONDS, DEFAULT_INTERRUPTED_MIN_SECONDS)
|
|
|
|
options.setdefault(
|
|
CONF_DEVICE_TYPE, data.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE)
|
|
)
|
|
options.setdefault(CONF_START_DURATION_THRESHOLD, DEFAULT_START_DURATION_THRESHOLD)
|
|
|
|
options.setdefault(CONF_PROFILE_MATCH_INTERVAL, DEFAULT_PROFILE_MATCH_INTERVAL)
|
|
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
|
|
)
|
|
options.setdefault(CONF_MAX_PAST_CYCLES, DEFAULT_MAX_PAST_CYCLES)
|
|
options.setdefault(
|
|
CONF_MAX_FULL_TRACES_PER_PROFILE, DEFAULT_MAX_FULL_TRACES_PER_PROFILE
|
|
)
|
|
options.setdefault(
|
|
CONF_MAX_FULL_TRACES_UNLABELED, DEFAULT_MAX_FULL_TRACES_UNLABELED
|
|
)
|
|
options.setdefault(CONF_WATCHDOG_INTERVAL, DEFAULT_WATCHDOG_INTERVAL)
|
|
options.setdefault(
|
|
CONF_AUTO_TUNE_NOISE_EVENTS_THRESHOLD, DEFAULT_AUTO_TUNE_NOISE_EVENTS_THRESHOLD
|
|
)
|
|
options.setdefault(CONF_COMPLETION_MIN_SECONDS, DEFAULT_COMPLETION_MIN_SECONDS)
|
|
options.setdefault(
|
|
CONF_NOTIFY_BEFORE_END_MINUTES, DEFAULT_NOTIFY_BEFORE_END_MINUTES
|
|
)
|
|
|
|
# Normalize notification options (added in 0.3.2)
|
|
options.setdefault(CONF_NOTIFY_ACTIONS, [])
|
|
options.setdefault(CONF_NOTIFY_PEOPLE, [])
|
|
options.setdefault(CONF_NOTIFY_ONLY_WHEN_HOME, DEFAULT_NOTIFY_ONLY_WHEN_HOME)
|
|
options.setdefault(CONF_NOTIFY_FIRE_EVENTS, DEFAULT_NOTIFY_FIRE_EVENTS)
|
|
options.setdefault(
|
|
CONF_NOTIFY_LIVE_INTERVAL_SECONDS, DEFAULT_NOTIFY_LIVE_INTERVAL_SECONDS
|
|
)
|
|
options.setdefault(
|
|
CONF_NOTIFY_LIVE_OVERRUN_PERCENT, DEFAULT_NOTIFY_LIVE_OVERRUN_PERCENT
|
|
)
|
|
|
|
# 3.5: notification delivery overhaul (lifecycle tag, timeout, per-type channels,
|
|
# distinct reminder message).
|
|
options.setdefault(CONF_NOTIFY_TIMEOUT_SECONDS, DEFAULT_NOTIFY_TIMEOUT_SECONDS)
|
|
options.setdefault(CONF_NOTIFY_CHANNEL, DEFAULT_NOTIFY_CHANNEL)
|
|
options.setdefault(CONF_NOTIFY_FINISH_CHANNEL, DEFAULT_NOTIFY_FINISH_CHANNEL)
|
|
options.setdefault(CONF_NOTIFY_REMINDER_MESSAGE, DEFAULT_NOTIFY_REMINDER_MESSAGE)
|
|
|
|
keys_to_remove = [
|
|
CONF_MIN_POWER,
|
|
CONF_OFF_DELAY,
|
|
CONF_DEVICE_TYPE,
|
|
CONF_POWER_SENSOR,
|
|
CONF_NOTIFY_SERVICE,
|
|
]
|
|
for k in keys_to_remove:
|
|
data.pop(k, None)
|
|
|
|
# 3.4: drain-spike delayed-start model replaced by band-based DELAY_WAIT.
|
|
# Strip the obsolete drain knobs so they don't linger in options and
|
|
# confuse anyone inspecting entry.options.
|
|
for k in (
|
|
"delay_drain_min_power",
|
|
"delay_drain_max_power",
|
|
"delay_drain_max_duration",
|
|
):
|
|
options.pop(k, None)
|
|
|
|
# 3.6: the feedback/verify-cycle and ghost-cycle persistent notifications
|
|
# were removed (suggestions and pending reviews are surfaced in the panel),
|
|
# so the now-inert "suppress feedback notifications" toggle is stripped.
|
|
options.pop("suppress_feedback_notifications", None)
|
|
|
|
# 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.
|
|
_removed_device_types = {"coffee_machine", "ev", "heat_pump", "oven"}
|
|
if (options.get(CONF_DEVICE_TYPE) or data.get(CONF_DEVICE_TYPE)) in _removed_device_types:
|
|
_log.info(
|
|
"Device type %r is no longer supported; migrating to %r (options preserved)",
|
|
options.get(CONF_DEVICE_TYPE), DEVICE_TYPE_OTHER,
|
|
)
|
|
options[CONF_DEVICE_TYPE] = DEVICE_TYPE_OTHER
|
|
# NB: CONF_DEVICE_TYPE was already popped from ``data`` above (keys_to_remove),
|
|
# so no stale removed value can linger there; the flow/manager read it from
|
|
# options (options-first).
|
|
|
|
# Strip the now-retired running_dead_zone from options in the bulk path too
|
|
# (covers entries that migrate straight from v1/v2/early-v3 in one pass).
|
|
options.pop(CONF_RUNNING_DEAD_ZONE, None)
|
|
|
|
hass.config_entries.async_update_entry(
|
|
entry,
|
|
data=data,
|
|
options=options,
|
|
version=3,
|
|
minor_version=8,
|
|
)
|
|
_log.info(
|
|
"Migrated WashData entry from version %s.%s to 3.7", version, minor_version
|
|
)
|
|
return True
|
|
|
|
|
|
async def _migrate_online_to_global(hass: HomeAssistant, entry: ConfigEntry, manager: Any) -> None:
|
|
"""Hoist the (formerly per-device) online-features flag + store account to the
|
|
integration-wide store. Pre-release cleanup.
|
|
|
|
The enable flag is hoisted exactly ONCE (guarded by a marker in the global store):
|
|
the stale per-entry option is never cleared, so without the marker a user who later
|
|
turns online off would have it silently re-enabled on the next restart. The account
|
|
hoist stays idempotent (it clears the per-entry copy after moving it)."""
|
|
from . import store_account # pylint: disable=import-outside-toplevel
|
|
from .const import CONF_ENABLE_ONLINE_FEATURES # pylint: disable=import-outside-toplevel
|
|
|
|
# Best-effort, pre-release migration: a transient store write failure here must
|
|
# never propagate and abort async_setup_entry (it retries on the next restart).
|
|
try:
|
|
await store_account.async_load(hass)
|
|
if not store_account.migration_done(hass):
|
|
any_on = any(
|
|
e.options.get(CONF_ENABLE_ONLINE_FEATURES)
|
|
for e in hass.config_entries.async_entries(DOMAIN)
|
|
)
|
|
if any_on and not store_account.online_enabled(hass):
|
|
await store_account.async_set_online(hass, True)
|
|
await store_account.async_mark_migrated(hass)
|
|
except Exception: # pylint: disable=broad-exception-caught
|
|
_LOGGER.warning("Online-features migration to global store failed", exc_info=True)
|
|
|
|
try:
|
|
acct = manager.profile_store.get_store_account()
|
|
except Exception: # pylint: disable=broad-exception-caught
|
|
acct = {}
|
|
if acct:
|
|
_account_preserved = False
|
|
try:
|
|
if acct.get("refresh_token") and not store_account.get_account(hass).get("refresh_token"):
|
|
await store_account.async_set_account(hass, {
|
|
"refresh_token": acct.get("refresh_token"),
|
|
"uid": acct.get("uid"), "name": acct.get("name"),
|
|
})
|
|
_account_preserved = True
|
|
except Exception: # pylint: disable=broad-exception-caught
|
|
_LOGGER.warning("Store-account hoist to global store failed", exc_info=True)
|
|
if _account_preserved:
|
|
try:
|
|
await manager.profile_store.clear_store_account()
|
|
except Exception: # pylint: disable=broad-exception-caught
|
|
pass
|
|
|
|
|
|
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, {})
|
|
|
|
# Migration: Remove old auto_maintenance switch entity (now in settings)
|
|
# pylint: disable=import-outside-toplevel
|
|
from homeassistant.helpers import entity_registry as er
|
|
|
|
ent_reg = er.async_get(hass)
|
|
old_switch_id = f"{entry.entry_id}_auto_maintenance"
|
|
old_entity = ent_reg.async_get_entity_id("switch", DOMAIN, old_switch_id)
|
|
if old_entity:
|
|
_log.info(
|
|
"Removing deprecated auto_maintenance switch entity: %s", old_entity
|
|
)
|
|
ent_reg.async_remove(old_entity)
|
|
|
|
# pylint: disable=import-outside-toplevel
|
|
from .manager import WashDataManager
|
|
|
|
manager = WashDataManager(hass, entry)
|
|
hass.data[DOMAIN][entry.entry_id] = manager
|
|
|
|
await manager.async_setup()
|
|
await _migrate_online_to_global(hass, entry, manager)
|
|
|
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
|
|
|
_apply_device_link(hass, entry)
|
|
|
|
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
|
|
|
|
# Register service if not already
|
|
if not hass.services.has_service(DOMAIN, "label_cycle"):
|
|
|
|
async def handle_label_cycle(call: ServiceCall) -> None:
|
|
device_id = _require_str(call.data.get("device_id"), "device_id")
|
|
cycle_id = _require_str(call.data.get("cycle_id"), "cycle_id")
|
|
profile_name = call.data.get("profile_name", "").strip()
|
|
|
|
# Find the config entry for this device
|
|
registry = dr.async_get(hass)
|
|
device = registry.async_get(device_id)
|
|
if not device:
|
|
raise ValueError("Device not found")
|
|
|
|
entry_id = next(iter(device.config_entries), None)
|
|
if not entry_id:
|
|
raise ValueError("No config entry found for device")
|
|
if entry_id not in hass.data[DOMAIN]:
|
|
raise ValueError("Integration not loaded for this device")
|
|
|
|
manager = hass.data[DOMAIN][entry_id]
|
|
|
|
# Assign existing profile or remove label
|
|
target_profile = profile_name if profile_name else None
|
|
try:
|
|
await manager.profile_store.assign_profile_to_cycle(
|
|
cycle_id, target_profile
|
|
)
|
|
except ValueError as exc:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="assign_profile_failed",
|
|
translation_placeholders={"error": str(exc)},
|
|
) from exc
|
|
|
|
# A manual (re)label answers the "was this detected right?" question,
|
|
# so clear any pending feedback and drop it from the review queue (#331).
|
|
if hasattr(manager, "learning_manager"):
|
|
await manager.learning_manager.async_resolve_pending_from_label(
|
|
cycle_id, target_profile
|
|
)
|
|
|
|
manager.notify_update()
|
|
|
|
hass.services.async_register(DOMAIN, "label_cycle", handle_label_cycle)
|
|
|
|
# Register create_profile service
|
|
if not hass.services.has_service(DOMAIN, "create_profile"):
|
|
|
|
async def handle_create_profile(call: ServiceCall) -> None:
|
|
device_id = _require_str(call.data.get("device_id"), "device_id")
|
|
profile_name = _require_str(call.data.get("profile_name"), "profile_name")
|
|
reference_cycle_id = call.data.get("reference_cycle_id")
|
|
|
|
registry = dr.async_get(hass)
|
|
device = registry.async_get(device_id)
|
|
if not device:
|
|
raise ValueError("Device not found")
|
|
|
|
entry_id = next(iter(device.config_entries), None)
|
|
if not entry_id:
|
|
raise ValueError("No config entry found for device")
|
|
if entry_id not in hass.data[DOMAIN]:
|
|
raise ValueError("Integration not loaded for this device")
|
|
|
|
manager = hass.data[DOMAIN][entry_id]
|
|
try:
|
|
await manager.profile_store.create_profile_standalone(
|
|
profile_name, reference_cycle_id
|
|
)
|
|
except ValueError as exc:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="create_profile_failed",
|
|
translation_placeholders={"error": str(exc)},
|
|
) from exc
|
|
manager.notify_update()
|
|
|
|
hass.services.async_register(DOMAIN, "create_profile", handle_create_profile)
|
|
|
|
# Register delete_profile service
|
|
if not hass.services.has_service(DOMAIN, "delete_profile"):
|
|
|
|
async def handle_delete_profile(call: ServiceCall) -> None:
|
|
device_id = _require_str(call.data.get("device_id"), "device_id")
|
|
profile_name = _require_str(call.data.get("profile_name"), "profile_name")
|
|
unlabel_cycles = call.data.get("unlabel_cycles", True)
|
|
|
|
registry = dr.async_get(hass)
|
|
device = registry.async_get(device_id)
|
|
if not device:
|
|
raise ValueError("Device not found")
|
|
|
|
entry_id = next(iter(device.config_entries), None)
|
|
if not entry_id:
|
|
raise ValueError("No config entry found for device")
|
|
if entry_id not in hass.data[DOMAIN]:
|
|
raise ValueError("Integration not loaded for this device")
|
|
|
|
manager = hass.data[DOMAIN][entry_id]
|
|
await manager.profile_store.delete_profile(profile_name, unlabel_cycles)
|
|
manager.notify_update()
|
|
|
|
hass.services.async_register(DOMAIN, "delete_profile", handle_delete_profile)
|
|
|
|
# Register auto_label_cycles service
|
|
if not hass.services.has_service(DOMAIN, "auto_label_cycles"):
|
|
|
|
async def handle_auto_label_cycles(call: ServiceCall) -> None:
|
|
device_id = _require_str(call.data.get("device_id"), "device_id")
|
|
confidence_threshold = call.data.get("confidence_threshold", 0.75)
|
|
|
|
registry = dr.async_get(hass)
|
|
device = registry.async_get(device_id)
|
|
if not device:
|
|
raise ValueError("Device not found")
|
|
|
|
entry_id = next(iter(device.config_entries), None)
|
|
if not entry_id:
|
|
raise ValueError("No config entry found for device")
|
|
if entry_id not in hass.data[DOMAIN]:
|
|
raise ValueError("Integration not loaded for this device")
|
|
|
|
manager = hass.data[DOMAIN][entry_id]
|
|
stats = await manager.profile_store.auto_label_cycles(
|
|
confidence_threshold
|
|
)
|
|
manager.notify_update()
|
|
|
|
manager._logger.info(
|
|
"Auto-label complete: %s labeled, %s skipped",
|
|
stats["labeled"],
|
|
stats["skipped"],
|
|
)
|
|
|
|
hass.services.async_register(
|
|
DOMAIN, "auto_label_cycles", handle_auto_label_cycles
|
|
)
|
|
|
|
# Register trim_cycle service
|
|
if not hass.services.has_service(DOMAIN, "trim_cycle"):
|
|
|
|
async def handle_trim_cycle(call: ServiceCall) -> None:
|
|
device_id = _require_str(call.data.get("device_id"), "device_id")
|
|
cycle_id = _require_str(call.data.get("cycle_id"), "cycle_id")
|
|
trim_start_s = max(0.0, float(call.data.get("trim_start_s", 0)))
|
|
|
|
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(iter(device.config_entries), None)
|
|
if not entry_id:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="no_config_entry",
|
|
)
|
|
if entry_id not in hass.data[DOMAIN]:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="integration_not_loaded",
|
|
)
|
|
|
|
manager = hass.data[DOMAIN][entry_id]
|
|
store = manager.profile_store
|
|
|
|
# Determine trim end - default to full cycle duration if not supplied
|
|
raw_end = call.data.get("trim_end_s")
|
|
# Always check cycle existence first, regardless of which trim path is taken
|
|
p_data = store.get_cycle_power_data(cycle_id)
|
|
if not p_data:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="cycle_not_found_or_no_power",
|
|
)
|
|
if raw_end is not None:
|
|
trim_end_s = max(0.0, float(raw_end))
|
|
else:
|
|
trim_end_s = max(point[0] for point in p_data)
|
|
|
|
if trim_end_s <= trim_start_s:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="trim_invalid_range",
|
|
)
|
|
|
|
ok = await store.trim_cycle_power_data(cycle_id, trim_start_s, trim_end_s)
|
|
if not ok:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="trim_failed_empty_window",
|
|
)
|
|
manager.notify_update()
|
|
|
|
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
|
|
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
|
|
|
|
# Register feedback service
|
|
if not hass.services.has_service(
|
|
DOMAIN, SERVICE_SUBMIT_FEEDBACK.rsplit(".", maxsplit=1)[-1]
|
|
):
|
|
|
|
async def handle_submit_feedback(call: ServiceCall) -> None:
|
|
entry_id_raw = call.data.get("entry_id")
|
|
device_id_raw = call.data.get("device_id")
|
|
|
|
entry_id: str | None = (
|
|
entry_id_raw if isinstance(entry_id_raw, str) and entry_id_raw else None
|
|
)
|
|
if entry_id is None:
|
|
# Prefer device_id for user-facing workflows.
|
|
device_id = _require_str(device_id_raw, "device_id")
|
|
registry = dr.async_get(hass)
|
|
device = registry.async_get(device_id)
|
|
if not device:
|
|
raise ValueError("Device not found")
|
|
entry_id = next(iter(device.config_entries), None)
|
|
if not entry_id:
|
|
raise ValueError("No config entry found for device")
|
|
|
|
if not entry_id:
|
|
raise ValueError("entry_id or device_id is required")
|
|
|
|
cycle_id = _require_str(call.data.get("cycle_id"), "cycle_id")
|
|
user_confirmed = call.data.get("user_confirmed", False)
|
|
corrected_profile = call.data.get("corrected_profile")
|
|
corrected_duration = call.data.get("corrected_duration") # in seconds
|
|
notes = call.data.get("notes", "")
|
|
dismiss = call.data.get("dismiss", False)
|
|
|
|
if entry_id not in hass.data[DOMAIN]:
|
|
raise ValueError("Integration not loaded for this entry")
|
|
|
|
manager = hass.data[DOMAIN][entry_id]
|
|
success = await manager.learning_manager.async_submit_cycle_feedback(
|
|
cycle_id=cycle_id,
|
|
user_confirmed=user_confirmed,
|
|
corrected_profile=corrected_profile,
|
|
corrected_duration=corrected_duration,
|
|
notes=notes,
|
|
dismiss=dismiss,
|
|
)
|
|
manager.notify_update()
|
|
|
|
if success:
|
|
# Best-effort dismiss the feedback notification if it exists.
|
|
try:
|
|
notification_id = f"ha_washdata_feedback_{entry_id}_{cycle_id}"
|
|
persistent_notification.async_dismiss(hass, notification_id)
|
|
except Exception: # pylint: disable=broad-exception-caught
|
|
pass
|
|
|
|
manager._logger.info("Cycle feedback submitted for %s", cycle_id)
|
|
else:
|
|
manager._logger.warning("Failed to submit feedback for cycle %s", cycle_id)
|
|
|
|
hass.services.async_register(
|
|
DOMAIN,
|
|
SERVICE_SUBMIT_FEEDBACK.rsplit(".", maxsplit=1)[-1],
|
|
handle_submit_feedback,
|
|
)
|
|
|
|
# Export store to file (per entry/device)
|
|
if not hass.services.has_service(DOMAIN, "export_config"):
|
|
|
|
async def handle_export_config(call: ServiceCall) -> None:
|
|
device_id = _require_str(call.data.get("device_id"), "device_id")
|
|
file_path = call.data.get("path")
|
|
|
|
registry = dr.async_get(hass)
|
|
device = registry.async_get(device_id)
|
|
if not device:
|
|
raise ValueError("Device not found")
|
|
|
|
entry_id = next(iter(device.config_entries), None)
|
|
if not entry_id:
|
|
raise ValueError("No config entry found for device")
|
|
if entry_id not in hass.data[DOMAIN]:
|
|
raise ValueError("Integration not loaded for this device")
|
|
|
|
manager = hass.data[DOMAIN][entry_id]
|
|
entry = hass.config_entries.async_get_entry(entry_id)
|
|
if entry is None:
|
|
raise ValueError(f"Config entry not found: {entry_id}")
|
|
payload = manager.profile_store.export_data(
|
|
entry_data=dict(entry.data),
|
|
entry_options=dict(entry.options),
|
|
)
|
|
|
|
target = (
|
|
Path(file_path)
|
|
if file_path
|
|
else Path(hass.config.path(f"ha_washdata_export_{entry_id}.json"))
|
|
)
|
|
target = target.resolve()
|
|
|
|
# Restrict caller-supplied paths to HA-allowed dirs (path-traversal /
|
|
# arbitrary-write guard). The default (no path) lands in the config dir.
|
|
if file_path and not hass.config.is_allowed_path(str(target)):
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="path_not_allowed",
|
|
translation_placeholders={"path": str(target)},
|
|
)
|
|
|
|
# Write export (offloaded to executor to avoid blocking the event
|
|
# loop). A caller-supplied path must never silently overwrite an
|
|
# existing file even when is_allowed_path() accepts it; exclusive
|
|
# creation ("x") makes that no-overwrite check atomic (no TOCTOU
|
|
# window). The default generated path may be re-written freely.
|
|
def _dump_and_write():
|
|
text = json.dumps(payload, indent=2)
|
|
try:
|
|
if file_path:
|
|
# Exclusive creation ("x") makes the no-overwrite check
|
|
# atomic; the default generated path may be re-written.
|
|
with open(target, "x", encoding="utf-8") as handle:
|
|
handle.write(text)
|
|
else:
|
|
target.write_text(text, encoding="utf-8")
|
|
except FileExistsError as exc:
|
|
# Subclass of OSError -> must be caught first (no-overwrite).
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="export_path_exists",
|
|
translation_placeholders={"path": str(target)},
|
|
) from exc
|
|
except OSError as exc:
|
|
# Disk full / permission denied / bad path: surface a clean
|
|
# localized error instead of a raw OSError from the executor.
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="export_write_failed",
|
|
translation_placeholders={
|
|
"path": str(target), "error": str(exc)
|
|
},
|
|
) from exc
|
|
await hass.async_add_executor_job(_dump_and_write)
|
|
manager._logger.info("Exported ha_washdata entry %s to %s", entry_id, target)
|
|
|
|
hass.services.async_register(DOMAIN, "export_config", handle_export_config)
|
|
|
|
# Import store from file into the target entry/device
|
|
if not hass.services.has_service(DOMAIN, "import_config"):
|
|
|
|
async def handle_import_config(call: ServiceCall) -> None:
|
|
device_id = _require_str(call.data.get("device_id"), "device_id")
|
|
file_path = call.data.get("path")
|
|
|
|
if not file_path:
|
|
raise ValueError("path is required for import")
|
|
|
|
registry = dr.async_get(hass)
|
|
device = registry.async_get(device_id)
|
|
if not device:
|
|
raise ValueError("Device not found")
|
|
|
|
entry_id = next(iter(device.config_entries), None)
|
|
if not entry_id:
|
|
raise ValueError("No config entry found for device")
|
|
if entry_id not in hass.data[DOMAIN]:
|
|
raise ValueError("Integration not loaded for this device")
|
|
|
|
manager = hass.data[DOMAIN][entry_id]
|
|
entry = hass.config_entries.async_get_entry(entry_id)
|
|
if entry is None:
|
|
raise ValueError(f"Config entry not found: {entry_id}")
|
|
|
|
# resolve()/exists() hit the filesystem; offload so the event loop is not
|
|
# blocked on I/O during the import service call.
|
|
source = await hass.async_add_executor_job(
|
|
lambda: Path(file_path).resolve()
|
|
)
|
|
# Restrict reads to HA-allowed dirs (path-traversal / arbitrary-read guard).
|
|
if not hass.config.is_allowed_path(str(source)):
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="path_not_allowed",
|
|
translation_placeholders={"path": str(source)},
|
|
)
|
|
if not await hass.async_add_executor_job(source.exists):
|
|
raise ValueError(f"File not found: {source}")
|
|
|
|
try:
|
|
def _read_and_parse():
|
|
text = source.read_text(encoding="utf-8")
|
|
return json.loads(text)
|
|
payload = await hass.async_add_executor_job(_read_and_parse)
|
|
except Exception as err: # noqa: BLE001
|
|
raise ValueError(f"Failed to read import file: {err}") from err
|
|
|
|
config_updates = await manager.profile_store.async_import_data(payload)
|
|
|
|
# Apply imported settings to config entry if present
|
|
entry_data = config_updates.get("entry_data", {})
|
|
entry_options = config_updates.get("entry_options", {})
|
|
|
|
if entry_data or entry_options:
|
|
new_data: dict[str, Any] = dict(entry.data)
|
|
new_options: dict[str, Any] = dict(entry.options)
|
|
|
|
# Only update min_power/off_delay from data (don't overwrite power_sensor/name)
|
|
for key in [CONF_MIN_POWER, CONF_OFF_DELAY]:
|
|
if key in entry_data:
|
|
new_data[key] = entry_data[key]
|
|
|
|
# Update all options from import
|
|
new_options.update(entry_options)
|
|
|
|
hass.config_entries.async_update_entry(
|
|
entry,
|
|
data=new_data,
|
|
options=new_options,
|
|
)
|
|
manager._logger.info("Applied imported settings to config entry %s", entry_id)
|
|
|
|
manager._logger.info("Imported ha_washdata entry %s from %s", entry_id, source)
|
|
|
|
hass.services.async_register(DOMAIN, "import_config", handle_import_config)
|
|
|
|
# Register recorder services
|
|
if not hass.services.has_service(DOMAIN, "record_start"):
|
|
async def handle_record_start(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 ValueError("Device not found")
|
|
entry_id = next(iter(device.config_entries), None)
|
|
if not entry_id or entry_id not in hass.data[DOMAIN]:
|
|
raise ValueError("Integration not loaded")
|
|
|
|
manager = hass.data[DOMAIN][entry_id]
|
|
await manager.async_start_recording()
|
|
|
|
hass.services.async_register(DOMAIN, "record_start", handle_record_start)
|
|
|
|
if not hass.services.has_service(DOMAIN, "record_stop"):
|
|
async def handle_record_stop(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 ValueError("Device not found")
|
|
entry_id = next(iter(device.config_entries), None)
|
|
if not entry_id or entry_id not in hass.data[DOMAIN]:
|
|
raise ValueError("Integration not loaded")
|
|
|
|
manager = hass.data[DOMAIN][entry_id]
|
|
await manager.async_stop_recording()
|
|
|
|
hass.services.async_register(DOMAIN, "record_stop", handle_record_stop)
|
|
|
|
# Register on-device ML training trigger (Stage 4, gated by ENABLE_ML_TRAINING)
|
|
from .const import ENABLE_ML_TRAINING, SERVICE_TRIGGER_ML_TRAINING
|
|
|
|
if ENABLE_ML_TRAINING and not hass.services.has_service(
|
|
DOMAIN, SERVICE_TRIGGER_ML_TRAINING
|
|
):
|
|
async def handle_trigger_ml_training(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 ValueError("Device not found")
|
|
entry_id = next(iter(device.config_entries), None)
|
|
if not entry_id or entry_id not in hass.data[DOMAIN]:
|
|
raise ValueError("Integration not loaded")
|
|
|
|
manager = hass.data[DOMAIN][entry_id]
|
|
summary = await manager.async_run_ml_training(force=True)
|
|
manager._logger.info("Manual ML training: %s", summary)
|
|
|
|
hass.services.async_register(
|
|
DOMAIN, SERVICE_TRIGGER_ML_TRAINING, handle_trigger_ml_training
|
|
)
|
|
|
|
# Register pause/resume services
|
|
if not hass.services.has_service(DOMAIN, "pause_cycle"):
|
|
async def handle_pause_cycle(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",
|
|
)
|
|
|
|
manager = hass.data[DOMAIN][entry_id]
|
|
success = await manager.async_pause_cycle()
|
|
if not success:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="no_active_cycle",
|
|
)
|
|
|
|
hass.services.async_register(DOMAIN, "pause_cycle", handle_pause_cycle)
|
|
|
|
if not hass.services.has_service(DOMAIN, "resume_cycle"):
|
|
async def handle_resume_cycle(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",
|
|
)
|
|
|
|
manager = hass.data[DOMAIN][entry_id]
|
|
success = await manager.async_resume_cycle()
|
|
if not success:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="no_active_cycle",
|
|
)
|
|
|
|
hass.services.async_register(DOMAIN, "resume_cycle", handle_resume_cycle)
|
|
|
|
return True
|
|
|
|
|
|
def _apply_device_link(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
"""Sync the WashData device's via_device link with the configured option.
|
|
|
|
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.
|
|
"""
|
|
registry = dr.async_get(hass)
|
|
washdata_device = registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)})
|
|
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 washdata_device.via_device_id != linked_device_id:
|
|
registry.async_update_device(
|
|
washdata_device.id, via_device_id=linked_device_id
|
|
)
|
|
|
|
|
|
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
"""Reload config entry - update settings without interrupting running cycles."""
|
|
manager = hass.data[DOMAIN].get(entry.entry_id)
|
|
if manager:
|
|
# Update configuration without interrupting detector
|
|
await manager.async_reload_config(entry)
|
|
# Options changes (e.g. linked device) reload in place without
|
|
# recreating entities, so apply the device link explicitly here.
|
|
_apply_device_link(hass, entry)
|
|
else:
|
|
# Full reload if manager not found
|
|
await async_unload_entry(hass, entry)
|
|
await async_setup_entry(hass, entry)
|
|
|
|
|
|
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):
|
|
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):
|
|
from .frontend import async_unregister_panel
|
|
await async_unregister_panel(hass)
|
|
|
|
return unload_ok
|