Files
HomeAssistantVS/custom_components/ha_washdata/__init__.py
T
2026-06-14 02:01:49 -04:00

839 lines
34 KiB
Python

"""The WashData integration."""
from __future__ import annotations
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,
CONF_ABRUPT_DROP_WATTS,
CONF_ABRUPT_DROP_RATIO,
CONF_ABRUPT_HIGH_LOAD_FACTOR,
DEFAULT_SMOOTHING_WINDOW,
DEFAULT_PROFILE_DURATION_TOLERANCE,
DEFAULT_INTERRUPTED_MIN_SECONDS,
DEFAULT_ABRUPT_DROP_WATTS,
DEFAULT_ABRUPT_DROP_RATIO,
DEFAULT_ABRUPT_HIGH_LOAD_FACTOR,
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,
DEFAULT_START_DURATION_THRESHOLD,
CONF_START_DURATION_THRESHOLD,
)
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 >= 5:
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_ABRUPT_DROP_WATTS, DEFAULT_ABRUPT_DROP_WATTS)
options.setdefault(CONF_ABRUPT_DROP_RATIO, DEFAULT_ABRUPT_DROP_RATIO)
options.setdefault(CONF_ABRUPT_HIGH_LOAD_FACTOR, DEFAULT_ABRUPT_HIGH_LOAD_FACTOR)
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)
hass.config_entries.async_update_entry(
entry,
data=data,
options=options,
version=3,
minor_version=5,
)
_log.info(
"Migrated WashData entry from version %s.%s to 3.5", version, minor_version
)
return 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, {})
# 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()
# Check for initial profile from onboarding
if "initial_profile" in entry.data:
init_prof = entry.data["initial_profile"]
name = init_prof.get("name")
duration = init_prof.get("avg_duration")
if name:
try:
# Create the profile immediately
await manager.profile_store.create_profile_standalone(
name, avg_duration=duration
)
manager._logger.info("Created initial profile '%s' from onboarding", name)
# Clean up config entry (remove initial_profile to avoid re-creation or cruft)
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)
except Exception as e: # pylint: disable=broad-exception-caught
manager._logger.error("Failed to create initial profile: %s", e)
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
if profile_name:
await manager.profile_store.assign_profile_to_cycle(
cycle_id, profile_name
)
else:
await manager.profile_store.assign_profile_to_cycle(cycle_id, None)
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]
await manager.profile_store.create_profile_standalone(
profile_name, reference_cycle_id
)
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")
if raw_end is not None:
trim_end_s = max(0.0, float(raw_end))
else:
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",
)
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 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()
# Write export
target.write_text(json.dumps(payload, indent=2), encoding="utf-8")
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}")
source = Path(file_path).resolve()
if not source.exists():
raise ValueError(f"File not found: {source}")
try:
payload = json.loads(source.read_text(encoding="utf-8"))
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 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]
await manager.async_pause_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]
await manager.async_resume_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()
return unload_ok