"""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 " 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