839 lines
34 KiB
Python
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
|