5688 lines
234 KiB
Python
5688 lines
234 KiB
Python
"""Config flow for WashData integration."""
|
||
# pylint: disable=too-many-lines
|
||
# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportUnknownParameterType=false, reportMissingParameterType=false, reportUnknownLambdaType=false, reportOptionalMemberAccess=false, reportOperatorIssue=false, reportReturnType=false, reportIncompatibleMethodOverride=false
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import time
|
||
import base64
|
||
import html
|
||
from datetime import datetime, timedelta
|
||
from typing import Any
|
||
|
||
import voluptuous as vol
|
||
|
||
from homeassistant import config_entries
|
||
from homeassistant.const import CONF_NAME
|
||
from homeassistant.data_entry_flow import FlowResult, section
|
||
from homeassistant.helpers import selector, translation
|
||
from homeassistant.util import slugify
|
||
from homeassistant.util import dt as dt_util
|
||
|
||
from .const import (
|
||
DOMAIN,
|
||
CONF_POWER_SENSOR,
|
||
CONF_MIN_POWER,
|
||
CONF_OFF_DELAY,
|
||
CONF_START_THRESHOLD_W,
|
||
CONF_STOP_THRESHOLD_W,
|
||
CONF_SAMPLING_INTERVAL,
|
||
CONF_NOTIFY_SERVICE,
|
||
CONF_NOTIFY_ACTIONS,
|
||
CONF_NOTIFY_PEOPLE,
|
||
CONF_NOTIFY_ONLY_WHEN_HOME,
|
||
CONF_NOTIFY_FIRE_EVENTS,
|
||
CONF_NOTIFY_EVENTS,
|
||
CONF_NOTIFY_START_SERVICES,
|
||
CONF_NOTIFY_FINISH_SERVICES,
|
||
CONF_NOTIFY_LIVE_SERVICES,
|
||
CONF_NO_UPDATE_ACTIVE_TIMEOUT,
|
||
CONF_SMOOTHING_WINDOW,
|
||
CONF_START_DURATION_THRESHOLD,
|
||
CONF_DEVICE_TYPE,
|
||
CONF_PROFILE_DURATION_TOLERANCE,
|
||
CONF_APPLY_SUGGESTIONS,
|
||
CONF_SHOW_ADVANCED,
|
||
CONF_PROGRESS_RESET_DELAY,
|
||
CONF_DURATION_TOLERANCE,
|
||
CONF_AUTO_LABEL_CONFIDENCE,
|
||
DEFAULT_AUTO_LABEL_CONFIDENCE,
|
||
CONF_LEARNING_CONFIDENCE,
|
||
DEFAULT_LEARNING_CONFIDENCE,
|
||
CONF_SUPPRESS_FEEDBACK_NOTIFICATIONS,
|
||
DEFAULT_SUPPRESS_FEEDBACK_NOTIFICATIONS,
|
||
CONF_EXPOSE_DEBUG_ENTITIES,
|
||
CONF_SAVE_DEBUG_TRACES,
|
||
CONF_PROFILE_MATCH_INTERVAL,
|
||
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
|
||
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
|
||
CONF_AUTO_MAINTENANCE,
|
||
CONF_WATCHDOG_INTERVAL,
|
||
CONF_COMPLETION_MIN_SECONDS,
|
||
CONF_NOTIFY_BEFORE_END_MINUTES,
|
||
CONF_RUNNING_DEAD_ZONE,
|
||
CONF_END_REPEAT_COUNT,
|
||
CONF_START_ENERGY_THRESHOLD,
|
||
CONF_END_ENERGY_THRESHOLD,
|
||
CONF_EXTERNAL_END_TRIGGER_ENABLED,
|
||
CONF_EXTERNAL_END_TRIGGER,
|
||
CONF_EXTERNAL_END_TRIGGER_INVERTED,
|
||
CONF_ANTI_WRINKLE_ENABLED,
|
||
CONF_ANTI_WRINKLE_MAX_POWER,
|
||
CONF_ANTI_WRINKLE_MAX_DURATION,
|
||
CONF_ANTI_WRINKLE_EXIT_POWER,
|
||
CONF_DELAY_START_DETECT_ENABLED,
|
||
CONF_DELAY_CONFIRM_SECONDS,
|
||
CONF_DELAY_TIMEOUT_HOURS,
|
||
NOTIFY_EVENT_START,
|
||
NOTIFY_EVENT_FINISH,
|
||
NOTIFY_EVENT_LIVE,
|
||
DEFAULT_NAME,
|
||
DEFAULT_MIN_POWER,
|
||
DEFAULT_OFF_DELAY,
|
||
DEFAULT_NO_UPDATE_ACTIVE_TIMEOUT,
|
||
DEFAULT_SMOOTHING_WINDOW,
|
||
DEFAULT_START_DURATION_THRESHOLD,
|
||
DEFAULT_START_ENERGY_THRESHOLD,
|
||
DEFAULT_END_ENERGY_THRESHOLD,
|
||
DEFAULT_DEVICE_TYPE,
|
||
DEFAULT_PROFILE_DURATION_TOLERANCE,
|
||
DEVICE_TYPES,
|
||
DEPRECATED_DEVICE_TYPES,
|
||
DEFAULT_PROGRESS_RESET_DELAY,
|
||
DEFAULT_DURATION_TOLERANCE,
|
||
DEFAULT_PROFILE_MATCH_INTERVAL,
|
||
DEFAULT_AUTO_MAINTENANCE,
|
||
DEFAULT_WATCHDOG_INTERVAL,
|
||
DEFAULT_COMPLETION_MIN_SECONDS,
|
||
DEFAULT_NOTIFY_BEFORE_END_MINUTES,
|
||
DEFAULT_RUNNING_DEAD_ZONE,
|
||
DEFAULT_END_REPEAT_COUNT,
|
||
DEFAULT_MIN_OFF_GAP_BY_DEVICE,
|
||
DEFAULT_MIN_OFF_GAP,
|
||
CONF_MIN_OFF_GAP,
|
||
DEFAULT_START_ENERGY_THRESHOLDS_BY_DEVICE,
|
||
DEVICE_COMPLETION_THRESHOLDS,
|
||
CONF_PROFILE_MATCH_THRESHOLD,
|
||
CONF_PROFILE_UNMATCH_THRESHOLD,
|
||
DEFAULT_PROFILE_MATCH_THRESHOLD,
|
||
DEFAULT_PROFILE_UNMATCH_THRESHOLD,
|
||
DEFAULT_SAMPLING_INTERVAL,
|
||
CONF_NOTIFY_TITLE,
|
||
CONF_NOTIFY_ICON,
|
||
CONF_NOTIFY_START_MESSAGE,
|
||
CONF_NOTIFY_FINISH_MESSAGE,
|
||
CONF_NOTIFY_PRE_COMPLETE_MESSAGE,
|
||
CONF_NOTIFY_LIVE_INTERVAL_SECONDS,
|
||
CONF_NOTIFY_LIVE_OVERRUN_PERCENT,
|
||
CONF_NOTIFY_LIVE_CHRONOMETER,
|
||
CONF_NOTIFY_REMINDER_MESSAGE,
|
||
CONF_NOTIFY_TIMEOUT_SECONDS,
|
||
CONF_NOTIFY_CHANNEL,
|
||
CONF_NOTIFY_FINISH_CHANNEL,
|
||
CONF_ENERGY_PRICE_STATIC,
|
||
CONF_ENERGY_PRICE_ENTITY,
|
||
DEFAULT_NOTIFY_TITLE,
|
||
DEFAULT_NOTIFY_START_MESSAGE,
|
||
DEFAULT_NOTIFY_FINISH_MESSAGE,
|
||
DEFAULT_NOTIFY_PRE_COMPLETE_MESSAGE,
|
||
DEFAULT_NOTIFY_REMINDER_MESSAGE,
|
||
DEFAULT_NOTIFY_TIMEOUT_SECONDS,
|
||
DEFAULT_NOTIFY_ONLY_WHEN_HOME,
|
||
DEFAULT_NOTIFY_FIRE_EVENTS,
|
||
DEFAULT_NOTIFY_LIVE_INTERVAL_SECONDS,
|
||
DEFAULT_NOTIFY_LIVE_OVERRUN_PERCENT,
|
||
DEFAULT_NOTIFY_LIVE_CHRONOMETER,
|
||
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO,
|
||
DEFAULT_OFF_DELAY_BY_DEVICE,
|
||
DEFAULT_SAMPLING_INTERVAL_BY_DEVICE,
|
||
DEFAULT_NO_UPDATE_ACTIVE_TIMEOUT_BY_DEVICE,
|
||
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO_BY_DEVICE,
|
||
DEFAULT_ANTI_WRINKLE_ENABLED,
|
||
DEFAULT_ANTI_WRINKLE_MAX_POWER,
|
||
DEFAULT_ANTI_WRINKLE_MAX_DURATION,
|
||
DEFAULT_ANTI_WRINKLE_EXIT_POWER,
|
||
DEFAULT_DELAY_START_DETECT_ENABLED,
|
||
DEFAULT_DELAY_CONFIRM_SECONDS,
|
||
DEFAULT_DELAY_TIMEOUT_HOURS,
|
||
CONF_PUMP_STUCK_DURATION,
|
||
DEFAULT_PUMP_STUCK_DURATION,
|
||
DEVICE_TYPE_PUMP,
|
||
CONF_DOOR_SENSOR_ENTITY,
|
||
CONF_PAUSE_CUTS_POWER,
|
||
CONF_SWITCH_ENTITY,
|
||
CONF_LINKED_DEVICE,
|
||
CONF_NOTIFY_UNLOAD_DELAY_MINUTES,
|
||
DEFAULT_NOTIFY_UNLOAD_DELAY_MINUTES,
|
||
)
|
||
from .profile_store import profile_sort_key
|
||
|
||
|
||
_LOGGER = logging.getLogger(__name__)
|
||
|
||
|
||
def _format_duration_label(seconds: int) -> str:
|
||
"""Render a duration in seconds as '1h 25m' or '42m'."""
|
||
minutes = max(0, int(seconds) // 60)
|
||
if minutes < 60:
|
||
return f"{minutes}m"
|
||
return f"{minutes // 60}h {minutes % 60:02d}m"
|
||
|
||
|
||
def _device_type_options(
|
||
current: str | None = None,
|
||
) -> list[str]:
|
||
"""Build the device-type dropdown option keys.
|
||
|
||
Returns plain option keys so the frontend resolves the labels per user from
|
||
the ``selector.device_type`` translations (passing pre-built labels would
|
||
lock them to the server language). Deprecated types are hidden for new
|
||
entries; for an existing entry whose saved device_type is deprecated, that
|
||
type is kept in the list so the user can either keep it or switch without
|
||
losing it from the dropdown. The "(deprecated)" wording lives in the
|
||
translated labels for the deprecated keys.
|
||
"""
|
||
return [
|
||
key
|
||
for key in DEVICE_TYPES
|
||
if key not in DEPRECATED_DEVICE_TYPES or key == current
|
||
]
|
||
|
||
|
||
def _escape_markdown(text: Any) -> str:
|
||
"""Make a user-supplied label safe to embed in markdown descriptions.
|
||
|
||
Profile and phase names are free text, so collapse any whitespace runs
|
||
(including newlines, which would otherwise break list rendering) into single
|
||
spaces and escape the markdown metacharacters that would otherwise inject
|
||
emphasis, code spans, links, or table cells.
|
||
"""
|
||
collapsed = " ".join(str(text).split())
|
||
# Backslash must be escaped first so the others are not double-escaped.
|
||
for char in ("\\", "`", "*", "_", "[", "]", "~", "|"):
|
||
collapsed = collapsed.replace(char, f"\\{char}")
|
||
return collapsed
|
||
|
||
|
||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||
{
|
||
vol.Required(CONF_NAME, default=DEFAULT_NAME): str,
|
||
vol.Required(
|
||
CONF_DEVICE_TYPE, default=DEFAULT_DEVICE_TYPE
|
||
): selector.SelectSelector(
|
||
selector.SelectSelectorConfig(
|
||
options=_device_type_options(),
|
||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||
translation_key="device_type",
|
||
)
|
||
),
|
||
vol.Required(CONF_POWER_SENSOR): selector.EntitySelector(
|
||
selector.EntitySelectorConfig(domain="sensor"),
|
||
),
|
||
vol.Optional(CONF_MIN_POWER, default=DEFAULT_MIN_POWER): vol.Coerce(float),
|
||
}
|
||
)
|
||
|
||
|
||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # pylint: disable=abstract-method
|
||
"""Handle a config flow for WashData."""
|
||
|
||
VERSION = 3
|
||
|
||
def __init__(self) -> None:
|
||
"""Initialize the config flow."""
|
||
self._user_input: dict[str, Any] = {}
|
||
|
||
def _get_schema(
|
||
self, user_input: dict[str, Any] | None = None # pylint: disable=unused-argument
|
||
) -> vol.Schema:
|
||
"""Get the configuration schema."""
|
||
return STEP_USER_DATA_SCHEMA
|
||
|
||
async def async_step_user(
|
||
self, user_input: dict[str, Any] | None = None # pylint: disable=unused-argument
|
||
) -> FlowResult:
|
||
"""Handle the initial step."""
|
||
errors: dict[str, str] = {}
|
||
if user_input is None:
|
||
return self.async_show_form(
|
||
step_id="user", data_schema=self._get_schema(), errors=errors
|
||
)
|
||
|
||
# Validate input
|
||
try:
|
||
# Basic validation
|
||
if user_input[CONF_MIN_POWER] <= 0:
|
||
errors[CONF_MIN_POWER] = "invalid_power"
|
||
except Exception: # pylint: disable=broad-exception-caught
|
||
_LOGGER.exception("Unexpected exception")
|
||
errors["base"] = "unknown"
|
||
|
||
if errors:
|
||
return self.async_show_form(
|
||
step_id="user", data_schema=self._get_schema(user_input), errors=errors
|
||
)
|
||
|
||
# Store user input and proceed to profile creation
|
||
self._user_input = user_input
|
||
return await self.async_step_first_profile()
|
||
|
||
async def async_step_first_profile(
|
||
self, user_input: dict[str, Any] | None = None # pylint: disable=unused-argument
|
||
) -> FlowResult:
|
||
"""Step to optionally create the first profile."""
|
||
|
||
if user_input is not None:
|
||
# Check if user wants to create a profile (if name is provided)
|
||
profile_name = user_input.get("profile_name", "").strip()
|
||
|
||
# Combine initial setup data with profile data if present
|
||
data = dict(self._user_input)
|
||
|
||
if profile_name:
|
||
duration_mins = user_input.get("manual_duration")
|
||
duration_sec = (duration_mins * 60.0) if duration_mins else None
|
||
|
||
# Pass as special key to be handled in async_setup_entry
|
||
data["initial_profile"] = {
|
||
"name": profile_name,
|
||
"avg_duration": duration_sec,
|
||
}
|
||
|
||
return self.async_create_entry(title=data[CONF_NAME], data=data)
|
||
|
||
# Schema for first profile
|
||
schema = vol.Schema(
|
||
{
|
||
vol.Optional("profile_name"): str,
|
||
vol.Optional("manual_duration", default=120): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=0,
|
||
max=480,
|
||
unit_of_measurement="min",
|
||
mode=selector.NumberSelectorMode.BOX,
|
||
)
|
||
),
|
||
}
|
||
)
|
||
|
||
return self.async_show_form(step_id="first_profile", data_schema=schema)
|
||
|
||
@staticmethod
|
||
def async_get_options_flow(
|
||
config_entry: config_entries.ConfigEntry,
|
||
) -> config_entries.OptionsFlow:
|
||
"""Create the options flow."""
|
||
return OptionsFlowHandler(config_entry)
|
||
|
||
|
||
class OptionsFlowHandler(config_entries.OptionsFlow):
|
||
"""Handle a options flow for WashData."""
|
||
|
||
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
|
||
"""Initialize options flow."""
|
||
self._config_entry = config_entry
|
||
self._selected_cycle_id: str | None = None
|
||
self._selected_profile: str | None = None
|
||
self._suggested_values: dict[str, Any] | None = None
|
||
self._pending_suggestion_diffs_md: str = ""
|
||
self._pending_suggestion_count: int = 0
|
||
self._basic_options: dict[str, Any] = {}
|
||
self._editor_action: str | None = None
|
||
self._editor_selected_ids: list[str] = []
|
||
self._editor_split_gap: int = 900
|
||
self._editor_split_mode: str = "auto"
|
||
self._editor_split_manual_segments: list[tuple[float, float]] = []
|
||
self._selected_phase_name: str | None = None
|
||
self._selected_phase_device_type: str | None = None
|
||
self._selected_phase_id: str | None = None
|
||
self._phase_assign_profile: str | None = None
|
||
self._phase_assign_mode: str = "offset_mode"
|
||
self._phase_assign_cycle_id: str | None = None
|
||
self._phase_assign_draft: list[dict[str, Any]] = []
|
||
self._phase_assign_edit_index: int | None = None
|
||
self._phase_assign_auto_detected: list[dict[str, Any]] = []
|
||
self._trim_cycle_id: str | None = None
|
||
self._trim_cycle_start_dt: datetime | None = None
|
||
self._trim_start_s: float = 0.0
|
||
self._trim_end_s: float = 0.0
|
||
self._selector_translations: dict[str, str] | None = None
|
||
self._menu_stack: list[str] = []
|
||
|
||
def _push_menu(self, step_id: str) -> None:
|
||
"""Track entry into a menu so Back can pop to the previous one."""
|
||
if not self._menu_stack or self._menu_stack[-1] != step_id:
|
||
self._menu_stack.append(step_id)
|
||
|
||
async def async_step_menu_back(
|
||
self, _user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Pop the menu stack and re-show the previous menu (or init)."""
|
||
if self._menu_stack:
|
||
self._menu_stack.pop()
|
||
target = self._menu_stack[-1] if self._menu_stack else "init"
|
||
# Clear so the parent re-pushes itself cleanly
|
||
if self._menu_stack:
|
||
self._menu_stack.pop()
|
||
return await getattr(self, f"async_step_{target}")()
|
||
|
||
@staticmethod
|
||
def _translated_select(
|
||
options: list[str],
|
||
translation_key: str,
|
||
mode: selector.SelectSelectorMode = selector.SelectSelectorMode.DROPDOWN,
|
||
multiple: bool = False,
|
||
custom_value: bool = False,
|
||
sort: bool = False,
|
||
) -> Any:
|
||
"""Build a select selector whose labels come from translations."""
|
||
return selector.SelectSelector(
|
||
selector.SelectSelectorConfig(
|
||
options=options,
|
||
mode=mode,
|
||
multiple=multiple,
|
||
custom_value=custom_value,
|
||
sort=sort,
|
||
translation_key=translation_key,
|
||
)
|
||
)
|
||
|
||
async def _selector_text(self, text_key: str, default: str) -> str:
|
||
"""Resolve selector-localized text by key."""
|
||
lang = self.context.get("language") or self.hass.config.language
|
||
if self._selector_translations is None:
|
||
self._selector_translations = await translation.async_get_translations(
|
||
self.hass, lang, "selector", {DOMAIN}
|
||
)
|
||
|
||
return self._selector_translations.get(
|
||
f"component.{DOMAIN}.selector.common_text.options.{text_key}",
|
||
default,
|
||
)
|
||
|
||
@staticmethod
|
||
def _suggestion_keys_to_apply() -> list[str]:
|
||
"""Return settings keys that can be populated from suggestions."""
|
||
return [
|
||
CONF_MIN_POWER,
|
||
CONF_OFF_DELAY,
|
||
CONF_WATCHDOG_INTERVAL,
|
||
CONF_NO_UPDATE_ACTIVE_TIMEOUT,
|
||
CONF_SAMPLING_INTERVAL,
|
||
CONF_PROFILE_MATCH_INTERVAL,
|
||
CONF_AUTO_LABEL_CONFIDENCE,
|
||
CONF_DURATION_TOLERANCE,
|
||
CONF_PROFILE_DURATION_TOLERANCE,
|
||
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
|
||
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
|
||
CONF_MIN_OFF_GAP,
|
||
CONF_START_THRESHOLD_W,
|
||
CONF_STOP_THRESHOLD_W,
|
||
CONF_END_ENERGY_THRESHOLD,
|
||
CONF_RUNNING_DEAD_ZONE,
|
||
]
|
||
|
||
@staticmethod
|
||
def _format_preview_value(value: Any) -> str:
|
||
"""Format values for change preview output."""
|
||
if value is None:
|
||
return "-"
|
||
if isinstance(value, bool):
|
||
return "true" if value else "false"
|
||
if isinstance(value, int):
|
||
return str(value)
|
||
if isinstance(value, float):
|
||
return str(int(value)) if value.is_integer() else f"{value:.2f}"
|
||
return str(value)
|
||
|
||
def _count_applicable_suggestions(self, suggestions: dict[str, Any]) -> int:
|
||
"""Count suggestions that are actually applicable in this options flow."""
|
||
count = 0
|
||
for key in self._suggestion_keys_to_apply():
|
||
entry = suggestions.get(key)
|
||
if isinstance(entry, dict) and entry.get("value") is not None:
|
||
count += 1
|
||
return count
|
||
|
||
async def _options_text(self, text_key: str, default: str) -> str:
|
||
"""Resolve shared localized text by key from selector namespace."""
|
||
lang = self.context.get("language") or self.hass.config.language
|
||
if self._selector_translations is None:
|
||
self._selector_translations = await translation.async_get_translations(
|
||
self.hass, lang, "selector", {DOMAIN}
|
||
)
|
||
|
||
return self._selector_translations.get(
|
||
f"component.{DOMAIN}.selector.common_text.options.{text_key}",
|
||
default,
|
||
)
|
||
|
||
async def async_step_init(
|
||
self, user_input: dict[str, Any] | None = None # pylint: disable=unused-argument
|
||
) -> FlowResult:
|
||
"""Manage the options."""
|
||
self._menu_stack = ["init"]
|
||
# Pass menu_options as a list of step ids (not a {step_id: label} dict)
|
||
# so the frontend resolves each label in the user's profile language. A
|
||
# dict would lock the labels to the server language for every user. The
|
||
# pending-feedback count is surfaced inside the learning_feedbacks step
|
||
# description rather than on the menu label.
|
||
return self.async_show_menu(
|
||
step_id="init",
|
||
menu_options=[
|
||
"settings",
|
||
"notifications",
|
||
"manage_cycles",
|
||
"manage_profiles",
|
||
"manage_phase_catalog",
|
||
"record_cycle",
|
||
"learning_feedbacks",
|
||
"diagnostics",
|
||
],
|
||
)
|
||
|
||
async def async_step_settings(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Manage configuration settings (Basic Step)."""
|
||
# Initialize or clear stored basic options
|
||
if not hasattr(self, "_basic_options"):
|
||
self._basic_options = {}
|
||
|
||
if user_input is not None:
|
||
# Check if user wants to edit advanced settings
|
||
if user_input.get(CONF_SHOW_ADVANCED):
|
||
# Store basic input to merge later
|
||
self._basic_options = user_input
|
||
# Remove the navigation flag from data storage
|
||
self._basic_options.pop(CONF_SHOW_ADVANCED, None)
|
||
return await self.async_step_advanced_settings()
|
||
|
||
# Save Basic Settings Only
|
||
# Merge with existing options to preserve settings not shown in this form
|
||
user_input.pop(CONF_SHOW_ADVANCED, None)
|
||
# Safe merge: Start with data (legacy), override with options, then user input
|
||
# This prevents losing settings if options was empty (legacy migration)
|
||
merged_options = {
|
||
**self.config_entry.data,
|
||
**self.config_entry.options,
|
||
**user_input,
|
||
}
|
||
# Drop pump-only keys when the device type is not pump
|
||
if merged_options.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE) != DEVICE_TYPE_PUMP:
|
||
merged_options.pop(CONF_PUMP_STUCK_DURATION, None)
|
||
return self.async_create_entry(title="", data=merged_options)
|
||
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
suggestions = manager.suggestions if manager else {}
|
||
suggestions_count = (
|
||
self._count_applicable_suggestions(suggestions)
|
||
if isinstance(suggestions, dict)
|
||
else 0
|
||
)
|
||
|
||
current_sensor = self.config_entry.options.get(
|
||
CONF_POWER_SENSOR, self.config_entry.data.get(CONF_POWER_SENSOR, "")
|
||
)
|
||
|
||
def get_val(key, default):
|
||
return self.config_entry.options.get(
|
||
key, self.config_entry.data.get(key, default)
|
||
)
|
||
|
||
# Resolve Device Type for Defaults
|
||
current_device_type = self.config_entry.options.get(
|
||
CONF_DEVICE_TYPE,
|
||
self.config_entry.data.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE),
|
||
)
|
||
|
||
# Specialized defaults for Device Type
|
||
default_off_delay = DEFAULT_OFF_DELAY_BY_DEVICE.get(
|
||
current_device_type, DEFAULT_OFF_DELAY
|
||
)
|
||
|
||
# Base schema with essential options
|
||
schema = {
|
||
# --- Device Configuration (Top Priority) ---
|
||
vol.Required(
|
||
CONF_DEVICE_TYPE,
|
||
default=get_val(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE),
|
||
): selector.SelectSelector(
|
||
selector.SelectSelectorConfig(
|
||
options=_device_type_options(current=current_device_type),
|
||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||
translation_key="device_type",
|
||
)
|
||
),
|
||
vol.Optional(
|
||
CONF_POWER_SENSOR,
|
||
default=current_sensor,
|
||
): selector.EntitySelector(selector.EntitySelectorConfig(domain="sensor")),
|
||
# --- Power Thresholds ---
|
||
vol.Optional(
|
||
CONF_MIN_POWER,
|
||
default=get_val(CONF_MIN_POWER, DEFAULT_MIN_POWER),
|
||
): vol.Coerce(float),
|
||
|
||
vol.Optional(
|
||
CONF_OFF_DELAY,
|
||
default=get_val(CONF_OFF_DELAY, default_off_delay),
|
||
): vol.Coerce(int),
|
||
vol.Optional(CONF_SHOW_ADVANCED, default=False): selector.BooleanSelector(),
|
||
}
|
||
|
||
if current_device_type in DEPRECATED_DEVICE_TYPES:
|
||
# Conditional flow text cannot be resolved per-user by the frontend
|
||
# (it only translates static step descriptions), so this falls back
|
||
# to the instance language via _options_text. Deprecated device
|
||
# types are removed in 0.4.6 regardless.
|
||
warning_template = await self._options_text(
|
||
"settings_deprecation_warning",
|
||
"⚠️ **Deprecated device type:** {device_type} is scheduled "
|
||
"for removal in a future release. WashData's matching pipeline "
|
||
"does not produce reliable results for this appliance class. "
|
||
"Your integration keeps working through the deprecation period; "
|
||
"to silence this warning, switch **Device Type** below to one "
|
||
"of the supported types (Washing Machine, Dryer, Washer-Dryer "
|
||
"Combo, Dishwasher, Air Fryer, Bread Maker, or Pump), or to "
|
||
"**Other (Advanced)** if your appliance does not match any of "
|
||
"the supported types. **Other (Advanced)** ships intentionally "
|
||
"generic defaults that are not tuned for any specific "
|
||
"appliance, so you will need to configure thresholds, "
|
||
"timeouts, and matching parameters yourself; all your existing "
|
||
"settings are preserved when you switch.",
|
||
)
|
||
# Resolve the interpolated device label through the same selector
|
||
# translations the rest of the warning uses (_options_text populated
|
||
# self._selector_translations above), so the name is localized to
|
||
# match instead of falling back to the raw English DEVICE_TYPES value.
|
||
current_label = (self._selector_translations or {}).get(
|
||
f"component.{DOMAIN}.selector.device_type.options.{current_device_type}",
|
||
DEVICE_TYPES.get(current_device_type, current_device_type),
|
||
)
|
||
try:
|
||
warning = warning_template.format(device_type=current_label)
|
||
except (KeyError, IndexError, ValueError):
|
||
warning = warning_template
|
||
# Trailing blank line lives in code, not the translation string: HA
|
||
# rejects translation values with leading/trailing whitespace.
|
||
deprecation_warning = f"{warning}\n\n"
|
||
else:
|
||
deprecation_warning = ""
|
||
|
||
return self.async_show_form(
|
||
step_id="settings",
|
||
data_schema=vol.Schema(schema),
|
||
description_placeholders={
|
||
"error": "",
|
||
"suggestions_count": str(suggestions_count),
|
||
"deprecation_warning": deprecation_warning,
|
||
"device": "{device}",
|
||
"duration": "{duration}",
|
||
"program": "{program}",
|
||
"minutes": "{minutes}",
|
||
},
|
||
)
|
||
|
||
async def async_step_apply_suggestions_confirm(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Review and confirm suggested values before applying them."""
|
||
if not self._suggested_values:
|
||
return self.async_abort(reason="no_suggestions")
|
||
|
||
if user_input is not None:
|
||
if user_input.get("confirm_apply_suggestions"):
|
||
# Pass staged values directly as form input — saves immediately
|
||
# without bouncing back to Advanced Settings for a second submit.
|
||
return await self.async_step_advanced_settings(
|
||
user_input=self._suggested_values
|
||
)
|
||
|
||
# User declined, clear staged values and return to advanced form.
|
||
self._suggested_values = None
|
||
self._pending_suggestion_diffs_md = ""
|
||
self._pending_suggestion_count = 0
|
||
return await self.async_step_advanced_settings(user_input=None)
|
||
|
||
changes_md = self._pending_suggestion_diffs_md or "- No value changes detected."
|
||
return self.async_show_form(
|
||
step_id="apply_suggestions_confirm",
|
||
data_schema=vol.Schema(
|
||
{
|
||
vol.Required("confirm_apply_suggestions", default=False): selector.BooleanSelector(),
|
||
}
|
||
),
|
||
description_placeholders={
|
||
"pending_count": str(self._pending_suggestion_count),
|
||
"changes": changes_md,
|
||
},
|
||
)
|
||
|
||
async def async_step_notifications(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Manage notification settings."""
|
||
if user_input is not None:
|
||
if user_input.pop("go_back", False):
|
||
return await self.async_step_init()
|
||
# Normalize icon: if the user cleared the field it may be absent or
|
||
# empty. Set it explicitly so the merge below overwrites any
|
||
# previously-saved value rather than keeping the stale one.
|
||
user_input[CONF_NOTIFY_ICON] = user_input.get(CONF_NOTIFY_ICON) or ""
|
||
# Normalize channel text fields the same way: a cleared field must
|
||
# overwrite any previously-saved channel rather than leaving a stale one.
|
||
user_input[CONF_NOTIFY_CHANNEL] = user_input.get(CONF_NOTIFY_CHANNEL) or ""
|
||
user_input[CONF_NOTIFY_FINISH_CHANNEL] = (
|
||
user_input.get(CONF_NOTIFY_FINISH_CHANNEL) or ""
|
||
)
|
||
# Normalize energy price fields: selectors may omit the key entirely
|
||
# when the user clears them. Explicitly writing None ensures the
|
||
# merged options dict overrides any previously-stored value.
|
||
if user_input.get(CONF_ENERGY_PRICE_ENTITY) in (None, ""):
|
||
user_input[CONF_ENERGY_PRICE_ENTITY] = None
|
||
if user_input.get(CONF_ENERGY_PRICE_STATIC) in (None, ""):
|
||
user_input[CONF_ENERGY_PRICE_STATIC] = None
|
||
# Normalize per-event notification service lists: cleared multi-selects
|
||
# omit the key entirely, which would leave stale recipients in the merged
|
||
# options. Explicitly setting [] ensures the merge overwrites old values.
|
||
for _svc_key in (
|
||
CONF_NOTIFY_START_SERVICES,
|
||
CONF_NOTIFY_FINISH_SERVICES,
|
||
CONF_NOTIFY_LIVE_SERVICES,
|
||
):
|
||
if user_input.get(_svc_key) in (None, ""):
|
||
user_input[_svc_key] = []
|
||
merged_options = {
|
||
**self.config_entry.data,
|
||
**self.config_entry.options,
|
||
**user_input,
|
||
}
|
||
# Remove deprecated single-service keys now that per-event lists are saved.
|
||
merged_options.pop(CONF_NOTIFY_SERVICE, None)
|
||
merged_options.pop(CONF_NOTIFY_EVENTS, None)
|
||
# Also remove deprecated keys from entry.data so the manager doesn't fall back to them.
|
||
if CONF_NOTIFY_SERVICE in self.config_entry.data or CONF_NOTIFY_EVENTS in self.config_entry.data:
|
||
new_data = {
|
||
k: v for k, v in self.config_entry.data.items()
|
||
if k not in (CONF_NOTIFY_SERVICE, CONF_NOTIFY_EVENTS)
|
||
}
|
||
self.hass.config_entries.async_update_entry(self.config_entry, data=new_data)
|
||
return self.async_create_entry(title="", data=merged_options)
|
||
|
||
notify_services: list[str] = sorted(
|
||
f"notify.{s}"
|
||
for s in self.hass.services.async_services().get("notify", {})
|
||
if s != "send_message"
|
||
)
|
||
notify_entities: list[str] = [
|
||
state.entity_id for state in self.hass.states.async_all("notify")
|
||
]
|
||
notify_services = sorted(set(notify_services) | set(notify_entities))
|
||
|
||
def get_val(key: str, default: Any) -> Any:
|
||
return self.config_entry.options.get(
|
||
key, self.config_entry.data.get(key, default)
|
||
)
|
||
|
||
# Migrate old single notify_service + notify_events to per-event service lists.
|
||
_old_svc: str = get_val(CONF_NOTIFY_SERVICE, "")
|
||
_old_events: list[str] = list(get_val(CONF_NOTIFY_EVENTS, []) or [])
|
||
|
||
def _migrate_or_load(new_key: str, event_type: str) -> list[str]:
|
||
existing = list(get_val(new_key, []) or [])
|
||
if existing:
|
||
return existing
|
||
if _old_svc and (not _old_events or event_type in _old_events):
|
||
return [_old_svc]
|
||
return []
|
||
|
||
start_services = _migrate_or_load(CONF_NOTIFY_START_SERVICES, NOTIFY_EVENT_START)
|
||
finish_services = _migrate_or_load(CONF_NOTIFY_FINISH_SERVICES, NOTIFY_EVENT_FINISH)
|
||
live_services = _migrate_or_load(CONF_NOTIFY_LIVE_SERVICES, NOTIFY_EVENT_LIVE)
|
||
|
||
# Ensure any already-saved custom service values appear in the dropdown.
|
||
known = set(notify_services)
|
||
for svc in start_services + finish_services + live_services:
|
||
if svc and svc not in known:
|
||
notify_services.append(svc)
|
||
known.add(svc)
|
||
|
||
schema = {
|
||
vol.Optional(
|
||
CONF_NOTIFY_ACTIONS,
|
||
default=get_val(CONF_NOTIFY_ACTIONS, []),
|
||
): selector.ActionSelector(),
|
||
vol.Optional(
|
||
CONF_NOTIFY_PEOPLE,
|
||
default=list(get_val(CONF_NOTIFY_PEOPLE, [])),
|
||
): selector.EntitySelector(
|
||
selector.EntitySelectorConfig(domain="person", multiple=True)
|
||
),
|
||
vol.Optional(
|
||
CONF_NOTIFY_ONLY_WHEN_HOME,
|
||
default=get_val(CONF_NOTIFY_ONLY_WHEN_HOME, DEFAULT_NOTIFY_ONLY_WHEN_HOME),
|
||
): selector.BooleanSelector(),
|
||
vol.Optional(
|
||
CONF_NOTIFY_FIRE_EVENTS,
|
||
default=get_val(CONF_NOTIFY_FIRE_EVENTS, DEFAULT_NOTIFY_FIRE_EVENTS),
|
||
): selector.BooleanSelector(),
|
||
vol.Optional(
|
||
CONF_NOTIFY_START_SERVICES,
|
||
default=start_services,
|
||
): selector.SelectSelector(
|
||
selector.SelectSelectorConfig(
|
||
options=notify_services,
|
||
multiple=True,
|
||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||
custom_value=True,
|
||
)
|
||
),
|
||
vol.Optional(
|
||
CONF_NOTIFY_FINISH_SERVICES,
|
||
default=finish_services,
|
||
): selector.SelectSelector(
|
||
selector.SelectSelectorConfig(
|
||
options=notify_services,
|
||
multiple=True,
|
||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||
custom_value=True,
|
||
)
|
||
),
|
||
vol.Optional(
|
||
CONF_NOTIFY_LIVE_SERVICES,
|
||
default=live_services,
|
||
): selector.SelectSelector(
|
||
selector.SelectSelectorConfig(
|
||
options=notify_services,
|
||
multiple=True,
|
||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||
custom_value=True,
|
||
)
|
||
),
|
||
vol.Optional(
|
||
CONF_NOTIFY_BEFORE_END_MINUTES,
|
||
default=get_val(
|
||
CONF_NOTIFY_BEFORE_END_MINUTES, DEFAULT_NOTIFY_BEFORE_END_MINUTES
|
||
),
|
||
): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=0, max=60, mode=selector.NumberSelectorMode.BOX
|
||
)
|
||
),
|
||
vol.Optional(
|
||
CONF_NOTIFY_LIVE_INTERVAL_SECONDS,
|
||
default=get_val(
|
||
CONF_NOTIFY_LIVE_INTERVAL_SECONDS,
|
||
DEFAULT_NOTIFY_LIVE_INTERVAL_SECONDS,
|
||
),
|
||
): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=30,
|
||
max=1800,
|
||
step=30,
|
||
unit_of_measurement="s",
|
||
mode=selector.NumberSelectorMode.BOX,
|
||
)
|
||
),
|
||
vol.Optional(
|
||
CONF_NOTIFY_LIVE_OVERRUN_PERCENT,
|
||
default=get_val(
|
||
CONF_NOTIFY_LIVE_OVERRUN_PERCENT,
|
||
DEFAULT_NOTIFY_LIVE_OVERRUN_PERCENT,
|
||
),
|
||
): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=0,
|
||
max=200,
|
||
step=5,
|
||
unit_of_measurement="%",
|
||
mode=selector.NumberSelectorMode.BOX,
|
||
)
|
||
),
|
||
vol.Optional(
|
||
CONF_NOTIFY_LIVE_CHRONOMETER,
|
||
default=get_val(
|
||
CONF_NOTIFY_LIVE_CHRONOMETER,
|
||
DEFAULT_NOTIFY_LIVE_CHRONOMETER,
|
||
),
|
||
): selector.BooleanSelector(),
|
||
vol.Optional(
|
||
CONF_NOTIFY_TITLE,
|
||
default=get_val(CONF_NOTIFY_TITLE, DEFAULT_NOTIFY_TITLE),
|
||
): selector.TextSelector(),
|
||
vol.Optional(
|
||
CONF_NOTIFY_ICON,
|
||
description={"suggested_value": get_val(CONF_NOTIFY_ICON, "")},
|
||
): selector.IconSelector(),
|
||
vol.Optional(
|
||
CONF_NOTIFY_START_MESSAGE,
|
||
default=get_val(CONF_NOTIFY_START_MESSAGE, DEFAULT_NOTIFY_START_MESSAGE),
|
||
): selector.TextSelector(selector.TextSelectorConfig(multiline=True)),
|
||
vol.Optional(
|
||
CONF_NOTIFY_FINISH_MESSAGE,
|
||
default=get_val(CONF_NOTIFY_FINISH_MESSAGE, DEFAULT_NOTIFY_FINISH_MESSAGE),
|
||
): selector.TextSelector(selector.TextSelectorConfig(multiline=True)),
|
||
vol.Optional(
|
||
CONF_NOTIFY_PRE_COMPLETE_MESSAGE,
|
||
default=get_val(
|
||
CONF_NOTIFY_PRE_COMPLETE_MESSAGE,
|
||
DEFAULT_NOTIFY_PRE_COMPLETE_MESSAGE,
|
||
),
|
||
): selector.TextSelector(selector.TextSelectorConfig(multiline=True)),
|
||
vol.Optional(
|
||
CONF_NOTIFY_REMINDER_MESSAGE,
|
||
default=get_val(
|
||
CONF_NOTIFY_REMINDER_MESSAGE,
|
||
DEFAULT_NOTIFY_REMINDER_MESSAGE,
|
||
),
|
||
): selector.TextSelector(selector.TextSelectorConfig(multiline=True)),
|
||
vol.Optional(
|
||
CONF_NOTIFY_TIMEOUT_SECONDS,
|
||
default=get_val(
|
||
CONF_NOTIFY_TIMEOUT_SECONDS,
|
||
DEFAULT_NOTIFY_TIMEOUT_SECONDS,
|
||
),
|
||
): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=0,
|
||
max=86400,
|
||
step=1,
|
||
unit_of_measurement="s",
|
||
mode=selector.NumberSelectorMode.BOX,
|
||
)
|
||
),
|
||
vol.Optional(
|
||
CONF_NOTIFY_CHANNEL,
|
||
description={"suggested_value": get_val(CONF_NOTIFY_CHANNEL, "")},
|
||
): selector.TextSelector(),
|
||
vol.Optional(
|
||
CONF_NOTIFY_FINISH_CHANNEL,
|
||
description={"suggested_value": get_val(CONF_NOTIFY_FINISH_CHANNEL, "")},
|
||
): selector.TextSelector(),
|
||
vol.Optional(
|
||
CONF_ENERGY_PRICE_ENTITY,
|
||
description={"suggested_value": get_val(CONF_ENERGY_PRICE_ENTITY, None)},
|
||
): selector.EntitySelector(
|
||
selector.EntitySelectorConfig(
|
||
domain=["sensor", "input_number", "number"],
|
||
multiple=False,
|
||
)
|
||
),
|
||
vol.Optional(
|
||
CONF_ENERGY_PRICE_STATIC,
|
||
description={"suggested_value": get_val(CONF_ENERGY_PRICE_STATIC, None)},
|
||
): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=0,
|
||
max=10,
|
||
step=0.001,
|
||
mode=selector.NumberSelectorMode.BOX,
|
||
)
|
||
),
|
||
vol.Optional("go_back", default=False): selector.BooleanSelector(),
|
||
}
|
||
|
||
return self.async_show_form(
|
||
step_id="notifications",
|
||
data_schema=vol.Schema(schema),
|
||
description_placeholders={
|
||
"device": "{device}",
|
||
"duration": "{duration}",
|
||
"program": "{program}",
|
||
"minutes": "{minutes}",
|
||
"energy_kwh": "{energy_kwh}",
|
||
"cost": "{cost}",
|
||
},
|
||
)
|
||
|
||
async def async_step_advanced_settings(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Manage advanced configuration settings (Step 2)."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
suggestions = manager.suggestions if manager else {}
|
||
|
||
def get_existing_value(key: str, default: Any) -> Any:
|
||
if key in self._basic_options:
|
||
return self._basic_options[key]
|
||
return self.config_entry.options.get(
|
||
key, self.config_entry.data.get(key, default)
|
||
)
|
||
|
||
if user_input is not None:
|
||
# Flatten section-wrapped fields back to a flat dict so the rest of
|
||
# the handler can keep treating user_input as {CONF_X: value, ...}.
|
||
flat_input: dict[str, Any] = {}
|
||
for _k, _v in user_input.items():
|
||
if isinstance(_v, dict):
|
||
flat_input.update(_v)
|
||
else:
|
||
flat_input[_k] = _v
|
||
user_input = flat_input
|
||
# If "Apply Suggestions" checkbox was checked, merge suggested values into the input
|
||
if user_input.get(CONF_APPLY_SUGGESTIONS):
|
||
keys_to_apply = self._suggestion_keys_to_apply()
|
||
|
||
updated_input = {**user_input}
|
||
updated_input[CONF_APPLY_SUGGESTIONS] = False
|
||
|
||
applied_count = 0
|
||
diff_lines: list[str] = []
|
||
for key in keys_to_apply:
|
||
entry = (
|
||
suggestions.get(key) if isinstance(suggestions, dict) else None
|
||
)
|
||
if isinstance(entry, dict) and "value" in entry:
|
||
val = entry.get("value")
|
||
if val is None:
|
||
continue
|
||
if key in (
|
||
CONF_OFF_DELAY,
|
||
CONF_WATCHDOG_INTERVAL,
|
||
CONF_NO_UPDATE_ACTIVE_TIMEOUT,
|
||
CONF_PROFILE_MATCH_INTERVAL,
|
||
CONF_MIN_OFF_GAP,
|
||
CONF_RUNNING_DEAD_ZONE,
|
||
):
|
||
suggested_val: Any = int(float(val))
|
||
else:
|
||
suggested_val = float(val)
|
||
|
||
current_val = user_input.get(
|
||
key,
|
||
get_existing_value(key, suggested_val),
|
||
)
|
||
if current_val != suggested_val:
|
||
diff_lines.append(
|
||
(
|
||
f"- `{key}`: "
|
||
f"`{self._format_preview_value(current_val)}` -> "
|
||
f"`{self._format_preview_value(suggested_val)}`"
|
||
)
|
||
)
|
||
|
||
updated_input[key] = suggested_val
|
||
applied_count += 1
|
||
|
||
if applied_count > 0:
|
||
self._suggested_values = updated_input
|
||
self._pending_suggestion_count = applied_count
|
||
self._pending_suggestion_diffs_md = (
|
||
"\n".join(diff_lines) if diff_lines else "- No value changes detected."
|
||
)
|
||
return await self.async_step_apply_suggestions_confirm()
|
||
|
||
return self.async_abort(reason="no_suggestions")
|
||
|
||
# Ensure clearing CONF_EXTERNAL_END_TRIGGER translates to None/Empty
|
||
# Handle missing key, empty list [], empty string "", or None
|
||
_trigger_val = user_input.get(CONF_EXTERNAL_END_TRIGGER)
|
||
if not _trigger_val:
|
||
user_input[CONF_EXTERNAL_END_TRIGGER] = None
|
||
|
||
# Same treatment for door sensor entity
|
||
_door_val = user_input.get(CONF_DOOR_SENSOR_ENTITY)
|
||
if not _door_val:
|
||
user_input[CONF_DOOR_SENSOR_ENTITY] = None
|
||
|
||
# Normalize a cleared linked device (empty selection) to None so the
|
||
# via_device link is removed rather than left dangling.
|
||
if CONF_LINKED_DEVICE in user_input and not user_input[CONF_LINKED_DEVICE]:
|
||
user_input[CONF_LINKED_DEVICE] = None
|
||
|
||
# Same treatment for switch entity
|
||
# Only normalize to None when key is present but empty; if the key
|
||
# is missing entirely (e.g. the step didn't expose the field) fall
|
||
# back to the already-configured value so we don't accidentally clear it.
|
||
_switch_val = user_input.get(CONF_SWITCH_ENTITY)
|
||
if CONF_SWITCH_ENTITY in user_input and not _switch_val:
|
||
user_input[CONF_SWITCH_ENTITY] = None
|
||
elif CONF_SWITCH_ENTITY not in user_input:
|
||
_existing_switch = self.config_entry.options.get(
|
||
CONF_SWITCH_ENTITY,
|
||
self.config_entry.data.get(CONF_SWITCH_ENTITY),
|
||
)
|
||
if _existing_switch:
|
||
user_input[CONF_SWITCH_ENTITY] = _existing_switch
|
||
|
||
# Final Save
|
||
final_options = {
|
||
**self.config_entry.data,
|
||
**self.config_entry.options,
|
||
**self._basic_options,
|
||
**user_input,
|
||
}
|
||
final_options.pop(CONF_APPLY_SUGGESTIONS, None)
|
||
# Drop pump-only keys when the device type is not pump
|
||
if final_options.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE) != DEVICE_TYPE_PUMP:
|
||
final_options.pop(CONF_PUMP_STUCK_DURATION, None)
|
||
if self._suggested_values:
|
||
await manager.profile_store.clear_suggestions()
|
||
self._suggested_values = None
|
||
self._pending_suggestion_diffs_md = ""
|
||
self._pending_suggestion_count = 0
|
||
manager.notify_update()
|
||
return self.async_create_entry(title="", data=final_options)
|
||
|
||
# Helper to get current value
|
||
def get_val(key, default):
|
||
# Prioritize suggested values (if "Apply Suggestions" triggered a reload)
|
||
if self._suggested_values and key in self._suggested_values:
|
||
return self._suggested_values[key]
|
||
# Fallback to basic options (if coming from basic step)
|
||
if key in self._basic_options:
|
||
return self._basic_options[key]
|
||
# Fallback to config options
|
||
return self.config_entry.options.get(
|
||
key, self.config_entry.data.get(key, default)
|
||
)
|
||
|
||
# Format suggestions for description
|
||
def _fmt_suggested(key: str) -> str:
|
||
val = (
|
||
(suggestions.get(key) or {}).get("value")
|
||
if isinstance(suggestions, dict)
|
||
else None
|
||
)
|
||
if val is None:
|
||
return "-"
|
||
try:
|
||
return str(int(val)) if float(val).is_integer() else f"{float(val):.2f}"
|
||
except Exception: # pylint: disable=broad-exception-caught
|
||
return str(val)
|
||
|
||
# Issue #257: high-power appliances (well pumps, EV chargers, ovens,
|
||
# heat pumps) legitimately need start/stop thresholds far above the
|
||
# friendly defaults below. Expand the selector ceiling so it always
|
||
# admits the currently-saved value and any pending suggestion -
|
||
# otherwise the form rejects a value the integration itself produced
|
||
# ("Value <n> is too large for dictionary value").
|
||
def _threshold_cap(base: float, key: str, current_default: float) -> float:
|
||
cap = base
|
||
candidates: list[Any] = [get_val(key, current_default)]
|
||
if isinstance(suggestions, dict):
|
||
candidates.append((suggestions.get(key) or {}).get("value"))
|
||
for cand in candidates:
|
||
try:
|
||
cval = float(cand)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if cval > cap:
|
||
# Round up to the next clean 100 W so the BOX control keeps
|
||
# a tidy bound with a little headroom above the value.
|
||
cap = float((int(cval) // 100 + 1) * 100)
|
||
return cap
|
||
|
||
_min_power_val = float(get_val(CONF_MIN_POWER, DEFAULT_MIN_POWER))
|
||
_start_threshold_cap = _threshold_cap(
|
||
500.0, CONF_START_THRESHOLD_W, _min_power_val + 1.0
|
||
)
|
||
_stop_threshold_cap = _threshold_cap(
|
||
100.0, CONF_STOP_THRESHOLD_W, max(0.0, _min_power_val - 0.5)
|
||
)
|
||
|
||
reason_lines: list[str] = []
|
||
for key in [
|
||
CONF_MIN_POWER,
|
||
CONF_WATCHDOG_INTERVAL,
|
||
CONF_NO_UPDATE_ACTIVE_TIMEOUT,
|
||
CONF_SAMPLING_INTERVAL,
|
||
CONF_PROFILE_MATCH_INTERVAL,
|
||
CONF_DURATION_TOLERANCE,
|
||
CONF_PROFILE_DURATION_TOLERANCE,
|
||
]:
|
||
entry = suggestions.get(key) if isinstance(suggestions, dict) else None
|
||
if isinstance(entry, dict) and entry.get("reason"):
|
||
reason_lines.append(f"- {key}: {entry['reason']}")
|
||
suggested_reason = "\n".join(reason_lines) if reason_lines else ""
|
||
|
||
# Resolve Device Type for Defaults
|
||
current_device_type = self.config_entry.options.get(
|
||
CONF_DEVICE_TYPE,
|
||
self.config_entry.data.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE),
|
||
)
|
||
if CONF_DEVICE_TYPE in self._basic_options:
|
||
current_device_type = self._basic_options[CONF_DEVICE_TYPE]
|
||
|
||
# Device-specific defaults
|
||
_default_min_off_gap = DEFAULT_MIN_OFF_GAP_BY_DEVICE.get(
|
||
current_device_type, DEFAULT_MIN_OFF_GAP
|
||
)
|
||
default_start_energy = DEFAULT_START_ENERGY_THRESHOLDS_BY_DEVICE.get(
|
||
current_device_type, DEFAULT_START_ENERGY_THRESHOLD
|
||
)
|
||
default_completion_min = DEVICE_COMPLETION_THRESHOLDS.get(
|
||
current_device_type, DEFAULT_COMPLETION_MIN_SECONDS
|
||
)
|
||
|
||
default_sampling = DEFAULT_SAMPLING_INTERVAL_BY_DEVICE.get(
|
||
current_device_type, DEFAULT_SAMPLING_INTERVAL
|
||
)
|
||
|
||
# Specialized defaults for Device Type
|
||
default_no_update_timeout = DEFAULT_NO_UPDATE_ACTIVE_TIMEOUT_BY_DEVICE.get(
|
||
current_device_type, DEFAULT_NO_UPDATE_ACTIVE_TIMEOUT
|
||
)
|
||
|
||
default_min_duration_ratio = DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO_BY_DEVICE.get(
|
||
current_device_type, DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO
|
||
)
|
||
|
||
detection_schema = {
|
||
vol.Optional(
|
||
CONF_START_DURATION_THRESHOLD,
|
||
default=get_val(
|
||
CONF_START_DURATION_THRESHOLD, DEFAULT_START_DURATION_THRESHOLD
|
||
),
|
||
): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=0.0,
|
||
max=60.0,
|
||
step=0.5,
|
||
unit_of_measurement="s",
|
||
mode=selector.NumberSelectorMode.BOX,
|
||
)
|
||
),
|
||
vol.Optional(
|
||
CONF_START_ENERGY_THRESHOLD,
|
||
default=get_val(CONF_START_ENERGY_THRESHOLD, default_start_energy),
|
||
): vol.Coerce(float),
|
||
vol.Optional(
|
||
CONF_COMPLETION_MIN_SECONDS,
|
||
default=get_val(CONF_COMPLETION_MIN_SECONDS, default_completion_min),
|
||
): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=0, max=3600, mode=selector.NumberSelectorMode.BOX
|
||
)
|
||
),
|
||
vol.Optional(
|
||
CONF_START_THRESHOLD_W,
|
||
default=get_val(
|
||
CONF_START_THRESHOLD_W,
|
||
float(get_val(CONF_MIN_POWER, DEFAULT_MIN_POWER)) + 1.0,
|
||
),
|
||
): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=0.0,
|
||
# Issue #238: dryers with anti-damp/anti-crease tumbling
|
||
# can sit at 200-300 W during a delayed-start window.
|
||
# Issue #257: ceiling expands for high-power devices so a
|
||
# suggested/saved value above the default is never rejected.
|
||
max=_start_threshold_cap,
|
||
step=0.5,
|
||
unit_of_measurement="W",
|
||
mode=selector.NumberSelectorMode.BOX,
|
||
)
|
||
),
|
||
vol.Optional(
|
||
CONF_STOP_THRESHOLD_W,
|
||
default=get_val(
|
||
CONF_STOP_THRESHOLD_W,
|
||
max(0.0, float(get_val(CONF_MIN_POWER, DEFAULT_MIN_POWER)) - 0.5),
|
||
),
|
||
): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=0.0,
|
||
# Issue #257: ceiling expands for high-power devices so a
|
||
# suggested/saved value above the default is never rejected.
|
||
max=_stop_threshold_cap,
|
||
step=0.5,
|
||
unit_of_measurement="W",
|
||
mode=selector.NumberSelectorMode.BOX,
|
||
)
|
||
),
|
||
vol.Optional(
|
||
CONF_END_ENERGY_THRESHOLD,
|
||
default=get_val(
|
||
CONF_END_ENERGY_THRESHOLD, DEFAULT_END_ENERGY_THRESHOLD
|
||
),
|
||
): vol.Coerce(float),
|
||
vol.Optional(
|
||
CONF_RUNNING_DEAD_ZONE,
|
||
default=get_val(CONF_RUNNING_DEAD_ZONE, DEFAULT_RUNNING_DEAD_ZONE),
|
||
): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=0,
|
||
max=600,
|
||
step=10,
|
||
unit_of_measurement="s",
|
||
mode=selector.NumberSelectorMode.BOX,
|
||
)
|
||
),
|
||
vol.Optional(
|
||
CONF_END_REPEAT_COUNT,
|
||
default=get_val(CONF_END_REPEAT_COUNT, DEFAULT_END_REPEAT_COUNT),
|
||
): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=1, max=10, mode=selector.NumberSelectorMode.BOX
|
||
)
|
||
),
|
||
vol.Optional(
|
||
CONF_MIN_OFF_GAP,
|
||
default=get_val(CONF_MIN_OFF_GAP, _default_min_off_gap),
|
||
): vol.Coerce(int),
|
||
vol.Optional(
|
||
CONF_SAMPLING_INTERVAL,
|
||
default=get_val(CONF_SAMPLING_INTERVAL, default_sampling),
|
||
): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=1.0,
|
||
max=60.0,
|
||
step=0.5,
|
||
unit_of_measurement="s",
|
||
mode=selector.NumberSelectorMode.BOX,
|
||
)
|
||
),
|
||
}
|
||
|
||
matching_schema = {
|
||
vol.Optional(
|
||
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
|
||
default=get_val(
|
||
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
|
||
default_min_duration_ratio,
|
||
),
|
||
): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=0.1, max=1.0, step=0.01, mode=selector.NumberSelectorMode.BOX,
|
||
)
|
||
),
|
||
vol.Optional(
|
||
CONF_PROFILE_MATCH_INTERVAL,
|
||
default=get_val(
|
||
CONF_PROFILE_MATCH_INTERVAL, DEFAULT_PROFILE_MATCH_INTERVAL
|
||
),
|
||
): vol.Coerce(int),
|
||
vol.Optional(
|
||
CONF_PROFILE_MATCH_THRESHOLD,
|
||
default=get_val(
|
||
CONF_PROFILE_MATCH_THRESHOLD, DEFAULT_PROFILE_MATCH_THRESHOLD
|
||
),
|
||
): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=0.1, max=1.0, step=0.05, mode=selector.NumberSelectorMode.BOX
|
||
)
|
||
),
|
||
vol.Optional(
|
||
CONF_PROFILE_UNMATCH_THRESHOLD,
|
||
default=get_val(
|
||
CONF_PROFILE_UNMATCH_THRESHOLD, DEFAULT_PROFILE_UNMATCH_THRESHOLD
|
||
),
|
||
): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=0.1, max=1.0, step=0.05, mode=selector.NumberSelectorMode.BOX
|
||
)
|
||
),
|
||
vol.Optional(
|
||
CONF_AUTO_LABEL_CONFIDENCE,
|
||
default=get_val(CONF_AUTO_LABEL_CONFIDENCE, DEFAULT_AUTO_LABEL_CONFIDENCE),
|
||
): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=0.5, max=1.0, step=0.05, mode=selector.NumberSelectorMode.BOX
|
||
)
|
||
),
|
||
vol.Optional(
|
||
CONF_LEARNING_CONFIDENCE,
|
||
default=get_val(CONF_LEARNING_CONFIDENCE, DEFAULT_LEARNING_CONFIDENCE),
|
||
): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=0.0, max=1.0, step=0.05, mode=selector.NumberSelectorMode.BOX
|
||
)
|
||
),
|
||
vol.Optional(
|
||
CONF_SUPPRESS_FEEDBACK_NOTIFICATIONS,
|
||
default=get_val(
|
||
CONF_SUPPRESS_FEEDBACK_NOTIFICATIONS,
|
||
DEFAULT_SUPPRESS_FEEDBACK_NOTIFICATIONS,
|
||
),
|
||
): selector.BooleanSelector(),
|
||
vol.Optional(
|
||
CONF_DURATION_TOLERANCE,
|
||
default=get_val(CONF_DURATION_TOLERANCE, DEFAULT_DURATION_TOLERANCE),
|
||
): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=0.0, max=0.5, step=0.01, mode=selector.NumberSelectorMode.BOX
|
||
)
|
||
),
|
||
vol.Optional(
|
||
CONF_SMOOTHING_WINDOW,
|
||
default=get_val(CONF_SMOOTHING_WINDOW, DEFAULT_SMOOTHING_WINDOW),
|
||
): vol.Coerce(int),
|
||
vol.Optional(
|
||
CONF_PROFILE_DURATION_TOLERANCE,
|
||
default=get_val(
|
||
CONF_PROFILE_DURATION_TOLERANCE, DEFAULT_PROFILE_DURATION_TOLERANCE
|
||
),
|
||
): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=0.0, max=0.5, step=0.01, mode=selector.NumberSelectorMode.BOX
|
||
)
|
||
),
|
||
}
|
||
|
||
timing_schema = {
|
||
vol.Optional(
|
||
CONF_WATCHDOG_INTERVAL,
|
||
default=get_val(CONF_WATCHDOG_INTERVAL, DEFAULT_WATCHDOG_INTERVAL),
|
||
): vol.Coerce(int),
|
||
vol.Optional(
|
||
CONF_NO_UPDATE_ACTIVE_TIMEOUT,
|
||
default=get_val(CONF_NO_UPDATE_ACTIVE_TIMEOUT, default_no_update_timeout),
|
||
): vol.Coerce(int),
|
||
vol.Optional(
|
||
CONF_PROGRESS_RESET_DELAY,
|
||
default=get_val(
|
||
CONF_PROGRESS_RESET_DELAY, DEFAULT_PROGRESS_RESET_DELAY
|
||
),
|
||
): vol.Coerce(int),
|
||
vol.Optional(
|
||
CONF_AUTO_MAINTENANCE,
|
||
default=get_val(CONF_AUTO_MAINTENANCE, DEFAULT_AUTO_MAINTENANCE),
|
||
): selector.BooleanSelector(),
|
||
vol.Optional(
|
||
CONF_EXPOSE_DEBUG_ENTITIES,
|
||
default=get_val(CONF_EXPOSE_DEBUG_ENTITIES, False),
|
||
): selector.BooleanSelector(),
|
||
vol.Optional(
|
||
CONF_SAVE_DEBUG_TRACES, default=get_val(CONF_SAVE_DEBUG_TRACES, False)
|
||
): selector.BooleanSelector(),
|
||
}
|
||
|
||
anti_wrinkle_schema = {
|
||
vol.Optional(
|
||
CONF_ANTI_WRINKLE_ENABLED,
|
||
default=get_val(
|
||
CONF_ANTI_WRINKLE_ENABLED, DEFAULT_ANTI_WRINKLE_ENABLED
|
||
),
|
||
): selector.BooleanSelector(),
|
||
vol.Optional(
|
||
CONF_ANTI_WRINKLE_MAX_POWER,
|
||
default=get_val(
|
||
CONF_ANTI_WRINKLE_MAX_POWER, DEFAULT_ANTI_WRINKLE_MAX_POWER
|
||
),
|
||
): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=10.0,
|
||
max=2000.0,
|
||
step=10.0,
|
||
unit_of_measurement="W",
|
||
mode=selector.NumberSelectorMode.BOX,
|
||
)
|
||
),
|
||
vol.Optional(
|
||
CONF_ANTI_WRINKLE_MAX_DURATION,
|
||
default=get_val(
|
||
CONF_ANTI_WRINKLE_MAX_DURATION, DEFAULT_ANTI_WRINKLE_MAX_DURATION
|
||
),
|
||
): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=5.0,
|
||
max=600.0,
|
||
step=1.0,
|
||
unit_of_measurement="s",
|
||
mode=selector.NumberSelectorMode.BOX,
|
||
)
|
||
),
|
||
vol.Optional(
|
||
CONF_ANTI_WRINKLE_EXIT_POWER,
|
||
default=get_val(
|
||
CONF_ANTI_WRINKLE_EXIT_POWER, DEFAULT_ANTI_WRINKLE_EXIT_POWER
|
||
),
|
||
): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=0.0,
|
||
max=10.0,
|
||
step=0.1,
|
||
unit_of_measurement="W",
|
||
mode=selector.NumberSelectorMode.BOX,
|
||
)
|
||
),
|
||
}
|
||
|
||
delay_start_schema = {
|
||
vol.Optional(
|
||
CONF_DELAY_START_DETECT_ENABLED,
|
||
default=get_val(
|
||
CONF_DELAY_START_DETECT_ENABLED, DEFAULT_DELAY_START_DETECT_ENABLED
|
||
),
|
||
): selector.BooleanSelector(),
|
||
vol.Optional(
|
||
CONF_DELAY_CONFIRM_SECONDS,
|
||
default=get_val(
|
||
CONF_DELAY_CONFIRM_SECONDS, DEFAULT_DELAY_CONFIRM_SECONDS
|
||
),
|
||
): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=10.0,
|
||
max=600.0,
|
||
step=5.0,
|
||
unit_of_measurement="s",
|
||
mode=selector.NumberSelectorMode.BOX,
|
||
)
|
||
),
|
||
vol.Optional(
|
||
CONF_DELAY_TIMEOUT_HOURS,
|
||
default=get_val(
|
||
CONF_DELAY_TIMEOUT_HOURS, DEFAULT_DELAY_TIMEOUT_HOURS
|
||
),
|
||
): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=0.5,
|
||
max=24.0,
|
||
step=0.5,
|
||
unit_of_measurement="h",
|
||
mode=selector.NumberSelectorMode.BOX,
|
||
)
|
||
),
|
||
}
|
||
|
||
external_triggers_schema = {
|
||
vol.Optional(
|
||
CONF_EXTERNAL_END_TRIGGER_ENABLED,
|
||
default=get_val(CONF_EXTERNAL_END_TRIGGER_ENABLED, False),
|
||
): selector.BooleanSelector(),
|
||
vol.Optional(
|
||
CONF_EXTERNAL_END_TRIGGER,
|
||
description={"suggested_value": get_val(CONF_EXTERNAL_END_TRIGGER, None)},
|
||
): selector.EntitySelector(
|
||
selector.EntitySelectorConfig(
|
||
domain="binary_sensor",
|
||
multiple=False,
|
||
)
|
||
),
|
||
vol.Optional(
|
||
CONF_EXTERNAL_END_TRIGGER_INVERTED,
|
||
default=get_val(CONF_EXTERNAL_END_TRIGGER_INVERTED, False),
|
||
): selector.BooleanSelector(),
|
||
vol.Optional(
|
||
CONF_DOOR_SENSOR_ENTITY,
|
||
description={"suggested_value": get_val(CONF_DOOR_SENSOR_ENTITY, None)},
|
||
): selector.EntitySelector(
|
||
selector.EntitySelectorConfig(
|
||
domain="binary_sensor",
|
||
multiple=False,
|
||
)
|
||
),
|
||
vol.Optional(
|
||
CONF_PAUSE_CUTS_POWER,
|
||
default=get_val(CONF_PAUSE_CUTS_POWER, False),
|
||
): selector.BooleanSelector(),
|
||
vol.Optional(
|
||
CONF_SWITCH_ENTITY,
|
||
description={"suggested_value": get_val(CONF_SWITCH_ENTITY, None)},
|
||
): selector.EntitySelector(
|
||
selector.EntitySelectorConfig(
|
||
domain="switch",
|
||
multiple=False,
|
||
)
|
||
),
|
||
vol.Optional(
|
||
CONF_NOTIFY_UNLOAD_DELAY_MINUTES,
|
||
default=get_val(
|
||
CONF_NOTIFY_UNLOAD_DELAY_MINUTES, DEFAULT_NOTIFY_UNLOAD_DELAY_MINUTES
|
||
),
|
||
): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=0,
|
||
max=480,
|
||
step=5,
|
||
unit_of_measurement="min",
|
||
mode=selector.NumberSelectorMode.BOX,
|
||
)
|
||
),
|
||
}
|
||
|
||
suggestions_schema = {
|
||
vol.Optional(CONF_APPLY_SUGGESTIONS, default=False): selector.BooleanSelector(),
|
||
}
|
||
|
||
device_link_schema = {
|
||
vol.Optional(
|
||
CONF_LINKED_DEVICE,
|
||
description={"suggested_value": get_val(CONF_LINKED_DEVICE, None)},
|
||
): selector.DeviceSelector(selector.DeviceSelectorConfig()),
|
||
}
|
||
|
||
schema: dict[Any, Any] = {
|
||
vol.Required("suggestions_section"): section(
|
||
vol.Schema(suggestions_schema), {"collapsed": False}
|
||
),
|
||
vol.Required("detection_section"): section(
|
||
vol.Schema(detection_schema), {"collapsed": False}
|
||
),
|
||
vol.Required("matching_section"): section(
|
||
vol.Schema(matching_schema), {"collapsed": True}
|
||
),
|
||
vol.Required("timing_section"): section(
|
||
vol.Schema(timing_schema), {"collapsed": True}
|
||
),
|
||
vol.Required("anti_wrinkle_section"): section(
|
||
vol.Schema(anti_wrinkle_schema), {"collapsed": True}
|
||
),
|
||
vol.Required("delay_start_section"): section(
|
||
vol.Schema(delay_start_schema), {"collapsed": True}
|
||
),
|
||
vol.Required("external_triggers_section"): section(
|
||
vol.Schema(external_triggers_schema), {"collapsed": True}
|
||
),
|
||
vol.Required("device_link_section"): section(
|
||
vol.Schema(device_link_schema), {"collapsed": True}
|
||
),
|
||
}
|
||
|
||
# --- Pump Monitor (Pump / Sump Pump only) ---
|
||
if current_device_type == DEVICE_TYPE_PUMP:
|
||
pump_schema = {
|
||
vol.Optional(
|
||
CONF_PUMP_STUCK_DURATION,
|
||
default=get_val(CONF_PUMP_STUCK_DURATION, DEFAULT_PUMP_STUCK_DURATION),
|
||
): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=60,
|
||
max=86400,
|
||
step=60,
|
||
unit_of_measurement="s",
|
||
mode=selector.NumberSelectorMode.BOX,
|
||
)
|
||
)
|
||
}
|
||
schema[vol.Required("pump_section")] = section(
|
||
vol.Schema(pump_schema), {"collapsed": True}
|
||
)
|
||
|
||
data_schema = vol.Schema(schema)
|
||
|
||
return self.async_show_form(
|
||
step_id="advanced_settings",
|
||
data_schema=data_schema,
|
||
description_placeholders={
|
||
"error": "",
|
||
"suggestions_count": str(
|
||
self._count_applicable_suggestions(suggestions)
|
||
if isinstance(suggestions, dict)
|
||
else 0
|
||
),
|
||
"suggested": suggested_reason or "No suggestions available yet.",
|
||
"suggested_min_power": _fmt_suggested(CONF_MIN_POWER),
|
||
"suggested_off_delay": _fmt_suggested(CONF_OFF_DELAY),
|
||
"suggested_watchdog_interval": _fmt_suggested(CONF_WATCHDOG_INTERVAL),
|
||
"suggested_no_update_active_timeout": _fmt_suggested(
|
||
CONF_NO_UPDATE_ACTIVE_TIMEOUT
|
||
),
|
||
"suggested_sampling_interval": _fmt_suggested(
|
||
CONF_SAMPLING_INTERVAL
|
||
),
|
||
"suggested_profile_match_interval": _fmt_suggested(
|
||
CONF_PROFILE_MATCH_INTERVAL
|
||
),
|
||
"suggested_auto_label_confidence": _fmt_suggested(
|
||
CONF_AUTO_LABEL_CONFIDENCE
|
||
),
|
||
"suggested_duration_tolerance": _fmt_suggested(CONF_DURATION_TOLERANCE),
|
||
"suggested_profile_duration_tolerance": _fmt_suggested(
|
||
CONF_PROFILE_DURATION_TOLERANCE
|
||
),
|
||
"suggested_profile_match_min_duration_ratio": _fmt_suggested(
|
||
CONF_PROFILE_MATCH_MIN_DURATION_RATIO
|
||
),
|
||
"suggested_profile_match_max_duration_ratio": _fmt_suggested(
|
||
CONF_PROFILE_MATCH_MAX_DURATION_RATIO
|
||
),
|
||
"suggested_start_threshold_w": _fmt_suggested(CONF_START_THRESHOLD_W),
|
||
"suggested_stop_threshold_w": _fmt_suggested(CONF_STOP_THRESHOLD_W),
|
||
"suggested_end_energy_threshold": _fmt_suggested(CONF_END_ENERGY_THRESHOLD),
|
||
"suggested_running_dead_zone": _fmt_suggested(CONF_RUNNING_DEAD_ZONE),
|
||
|
||
"suggested_reason": suggested_reason,
|
||
# Placeholders for keys in data_description
|
||
"device": "{device}",
|
||
"duration": "{duration}",
|
||
"program": "{program}",
|
||
"minutes": "{minutes}",
|
||
},
|
||
)
|
||
|
||
|
||
|
||
|
||
# --- Interactive Editor Steps ---
|
||
|
||
async def async_step_interactive_editor(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Step 1: Select Action (Merge or Split) as a button menu."""
|
||
self._editor_action = None
|
||
self._editor_selected_ids = []
|
||
self._editor_split_gap = 900
|
||
self._editor_split_mode = "auto"
|
||
self._editor_split_manual_segments = []
|
||
self._push_menu("interactive_editor")
|
||
return self.async_show_menu(
|
||
step_id="interactive_editor",
|
||
menu_options=[
|
||
"editor_split",
|
||
"editor_merge",
|
||
"editor_delete",
|
||
"menu_back",
|
||
],
|
||
)
|
||
|
||
async def async_step_editor_split(
|
||
self, _user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Menu wrapper: split action."""
|
||
self._editor_action = "split"
|
||
return await self.async_step_editor_select()
|
||
|
||
async def async_step_editor_merge(
|
||
self, _user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Menu wrapper: merge action."""
|
||
self._editor_action = "merge"
|
||
return await self.async_step_editor_select()
|
||
|
||
async def async_step_editor_delete(
|
||
self, _user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Menu wrapper: delete action."""
|
||
self._editor_action = "delete"
|
||
return await self.async_step_editor_select()
|
||
|
||
async def async_step_editor_select(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Step 2: Select Cycles."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
|
||
errors = {}
|
||
|
||
if user_input is not None:
|
||
selected = user_input.get("selected_cycles", [])
|
||
self._editor_selected_ids = selected
|
||
|
||
# Validation
|
||
if self._editor_action == "split":
|
||
if len(selected) != 1:
|
||
errors["base"] = "select_exactly_one"
|
||
else:
|
||
return await self.async_step_editor_split_params()
|
||
|
||
elif self._editor_action == "merge":
|
||
if len(selected) < 2:
|
||
errors["base"] = "select_at_least_two"
|
||
else:
|
||
return await self.async_step_editor_configure()
|
||
|
||
elif self._editor_action == "delete":
|
||
if len(selected) < 1:
|
||
errors["base"] = "select_at_least_one"
|
||
else:
|
||
return await self.async_step_editor_configure()
|
||
|
||
# Build options (Recent 50 cycles)
|
||
cycles = store.get_past_cycles()[-50:]
|
||
cycles.sort(key=lambda x: x["start_time"], reverse=True)
|
||
|
||
unlabeled_text = await self._selector_text("unlabeled", "(Unlabeled)")
|
||
options = []
|
||
for c in cycles:
|
||
dt = dt_util.parse_datetime(c["start_time"])
|
||
start = dt_util.as_local(dt).strftime("%Y-%m-%d %H:%M") if dt else c["start_time"]
|
||
duration_min = int(c.get("manual_duration", c["duration"]) / 60)
|
||
prof = c.get("profile_name") or unlabeled_text
|
||
label = f"{start} - {duration_min}m - {prof}"
|
||
options.append(selector.SelectOptionDict(value=c["id"], label=label))
|
||
|
||
info_text_key = {
|
||
"split": "editor_select_info_split",
|
||
"merge": "editor_select_info_merge",
|
||
"delete": "editor_select_info_delete",
|
||
}.get(self._editor_action or "", "editor_select_info")
|
||
|
||
return self.async_show_form(
|
||
step_id="editor_select",
|
||
data_schema=vol.Schema(
|
||
{
|
||
vol.Required("selected_cycles"): selector.SelectSelector(
|
||
selector.SelectSelectorConfig(
|
||
options=options,
|
||
mode=selector.SelectSelectorMode.LIST,
|
||
multiple=True
|
||
)
|
||
)
|
||
}
|
||
),
|
||
errors=errors,
|
||
description_placeholders={
|
||
"info_text": await self._options_text(info_text_key, "")
|
||
}
|
||
)
|
||
|
||
async def async_step_editor_split_params(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Step 2.5: Choose split method (auto-detect gaps or manual timestamps)."""
|
||
if user_input is not None:
|
||
mode = user_input.get("split_mode", "auto")
|
||
self._editor_split_mode = mode
|
||
self._editor_split_manual_segments = []
|
||
if mode == "manual":
|
||
return await self.async_step_editor_split_manual_params()
|
||
return await self.async_step_editor_split_auto_params()
|
||
|
||
return self.async_show_form(
|
||
step_id="editor_split_params",
|
||
data_schema=vol.Schema({
|
||
vol.Required("split_mode", default=self._editor_split_mode): self._translated_select(
|
||
options=["auto", "manual"],
|
||
translation_key="split_mode",
|
||
)
|
||
}),
|
||
)
|
||
|
||
async def async_step_editor_split_auto_params(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Configure auto-detect gap threshold."""
|
||
if user_input is not None:
|
||
self._editor_split_gap = int(user_input["min_gap_seconds"])
|
||
return await self.async_step_editor_configure()
|
||
|
||
return self.async_show_form(
|
||
step_id="editor_split_auto_params",
|
||
data_schema=vol.Schema({
|
||
vol.Required("min_gap_seconds", default=self._editor_split_gap): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=60, max=3600, mode=selector.NumberSelectorMode.BOX, unit_of_measurement="s"
|
||
)
|
||
)
|
||
}),
|
||
)
|
||
|
||
async def async_step_editor_split_manual_params(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Configure manual split timestamps (wall-clock HH:MM[:SS], one per line)."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
|
||
errors: dict[str, str] = {}
|
||
cid = self._editor_selected_ids[0] if self._editor_selected_ids else None
|
||
cycle = next((c for c in store.get_past_cycles() if c["id"] == cid), None) if cid else None
|
||
if not cycle:
|
||
return self.async_abort(reason="cycle_not_found")
|
||
|
||
cycle_start_dt = dt_util.parse_datetime(cycle["start_time"])
|
||
cycle_end_dt = dt_util.parse_datetime(cycle["end_time"])
|
||
if not cycle_start_dt or not cycle_end_dt:
|
||
return self.async_abort(reason="cycle_not_found")
|
||
|
||
if user_input is not None:
|
||
raw = user_input.get("split_timestamps", "") or ""
|
||
offsets: list[float] = []
|
||
for token in raw.replace(",", "\n").splitlines():
|
||
token = token.strip()
|
||
if not token:
|
||
continue
|
||
off = self._wallclock_to_offset(token, cycle_start_dt, cycle_end_dt)
|
||
if off is None:
|
||
errors["base"] = "invalid_split_timestamp"
|
||
break
|
||
offsets.append(off)
|
||
|
||
if not errors:
|
||
segments = store.build_split_segments_from_offsets(cycle, offsets)
|
||
if not segments:
|
||
errors["base"] = "no_split_segments_found"
|
||
else:
|
||
self._editor_split_manual_segments = segments
|
||
return await self.async_step_editor_configure()
|
||
|
||
local_start = dt_util.as_local(cycle_start_dt)
|
||
local_end = dt_util.as_local(cycle_end_dt)
|
||
preview_md = ""
|
||
svg = store.generate_interactive_split_svg(
|
||
cycle["id"],
|
||
[(0.0, (cycle_end_dt - cycle_start_dt).total_seconds())],
|
||
title_prefix=await self._options_text("split_preview_title", "Split Preview"),
|
||
unlabeled_text=await self._selector_text("unlabeled", "(Unlabeled)"),
|
||
)
|
||
if svg:
|
||
b64 = base64.b64encode(svg.encode("utf-8")).decode("utf-8")
|
||
preview_md = f"\n\n"
|
||
|
||
return self.async_show_form(
|
||
step_id="editor_split_manual_params",
|
||
data_schema=vol.Schema({
|
||
vol.Required("split_timestamps", default=""): selector.TextSelector(
|
||
selector.TextSelectorConfig(multiline=True)
|
||
)
|
||
}),
|
||
errors=errors,
|
||
description_placeholders={
|
||
"preview_md": preview_md,
|
||
"cycle_start_wallclock": local_start.strftime("%H:%M:%S"),
|
||
"cycle_end_wallclock": local_end.strftime("%H:%M:%S"),
|
||
"cycle_date": local_start.strftime("%Y-%m-%d"),
|
||
},
|
||
)
|
||
|
||
async def async_step_editor_configure(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""
|
||
Render the interactive editor preview and apply split or merge actions for selected cycles.
|
||
|
||
If called with `user_input`, executes the chosen editor action ("split" or "merge") when the user confirms:
|
||
- For "split": analyzes the selected cycle into segments, optionally assigns profiles from per-segment inputs, applies the split, and triggers background envelope rebuild.
|
||
- For "merge": optionally creates a new profile, applies the merge with the chosen profile (or unlabeled), and triggers background envelope rebuild.
|
||
|
||
When called without `user_input`, prepares a preview form:
|
||
- For "split": runs split analysis, generates an SVG preview, and presents per-segment profile selectors plus a confirmation checkbox (`confirm_commit`).
|
||
- For "merge": generates a merge preview (SVG or fallback message), presents a profile selector (`merged_profile` or `create_new`), an optional `new_profile_name`, and a confirmation checkbox (`confirm_commit`).
|
||
|
||
Parameters:
|
||
user_input (dict[str, Any] | None): Submitted form values when committing an action. Expected keys vary by action:
|
||
- Split preview commit:
|
||
- "confirm_commit" (bool): required to execute the split.
|
||
- "segment_{i}_profile" (str): optional per-segment profile name or "none".
|
||
- Merge preview commit:
|
||
- "confirm_commit" (bool): required to execute the merge.
|
||
- "merged_profile" (str): one of profile names, "none", or "create_new".
|
||
- "new_profile_name" (str): when "create_new" is selected, name for the new profile.
|
||
|
||
Returns:
|
||
FlowResult: A flow result that either shows the preview form, creates an entry after applying changes, or aborts with an error if required data (e.g., cycles or segments) is missing.
|
||
"""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
|
||
if user_input is not None:
|
||
# Execute
|
||
if self._editor_action == "split":
|
||
# User selected action "apply"
|
||
if user_input.get("confirm_commit"):
|
||
# We need the segments config.
|
||
# For simplicity, we auto-apply the analysis results for now,
|
||
# as complex per-segment assignment in one form is hard in HA config flow.
|
||
# We'll rely on auto-labeling or user can label later.
|
||
|
||
# Re-run analysis (auto) or reuse manual segments
|
||
cycle = next((c for c in store.get_past_cycles() if c["id"] in self._editor_selected_ids), None)
|
||
if cycle:
|
||
if self._editor_split_mode == "manual":
|
||
segments = list(self._editor_split_manual_segments)
|
||
else:
|
||
segments = await self.hass.async_add_executor_job(
|
||
store.analyze_split_sync, cycle, self._editor_split_gap, 2.0
|
||
)
|
||
if segments:
|
||
# Apply with profiles from user input
|
||
final_segments = []
|
||
for i, seg in enumerate(segments):
|
||
start_t, end_t = seg
|
||
prof = user_input.get(f"segment_{i}_profile")
|
||
if prof == "none":
|
||
prof = None
|
||
final_segments.append({"start": start_t, "end": end_t, "profile": prof})
|
||
|
||
await store.apply_split_interactive(cycle["id"], final_segments)
|
||
# Maintenance: Reprocess envelopes in background to avoid blocking UI
|
||
self.hass.async_create_task(store.async_rebuild_all_envelopes())
|
||
|
||
return self.async_create_entry(title="", data=dict(self.config_entry.options))
|
||
|
||
elif self._editor_action == "merge":
|
||
if user_input.get("confirm_commit"):
|
||
target_profile = user_input.get("merged_profile")
|
||
|
||
if target_profile == "create_new":
|
||
new_name = user_input.get("new_profile_name", "").strip()
|
||
if new_name:
|
||
# Create profile
|
||
try:
|
||
await store.create_profile_standalone(new_name)
|
||
target_profile = new_name
|
||
except ValueError:
|
||
# Profile exists or other error, fallback to unlabel/existing behavior
|
||
pass
|
||
|
||
if target_profile in ("none", "create_new"):
|
||
target_profile = None
|
||
|
||
await store.apply_merge_interactive(self._editor_selected_ids, target_profile)
|
||
# Maintenance: Reprocess envelopes in background
|
||
self.hass.async_create_task(store.async_rebuild_all_envelopes())
|
||
return self.async_create_entry(title="", data=dict(self.config_entry.options))
|
||
|
||
elif self._editor_action == "delete":
|
||
if user_input.get("confirm_commit"):
|
||
deleted_any = False
|
||
for cycle_id in self._editor_selected_ids:
|
||
deleted_any = await store.delete_cycle(cycle_id) or deleted_any
|
||
|
||
if deleted_any:
|
||
manager.notify_update()
|
||
self.hass.async_create_task(store.async_rebuild_all_envelopes())
|
||
|
||
return self.async_create_entry(title="", data=dict(self.config_entry.options))
|
||
|
||
# Generate Preview
|
||
preview_md = ""
|
||
schema = {}
|
||
|
||
if self._editor_action == "split":
|
||
cid = self._editor_selected_ids[0]
|
||
cycle = next((c for c in store.get_past_cycles() if c["id"] == cid), None)
|
||
if not cycle:
|
||
return self.async_abort(reason="cycle_not_found")
|
||
|
||
# Run analysis (auto) or reuse manual segments
|
||
if self._editor_split_mode == "manual":
|
||
segments = list(self._editor_split_manual_segments)
|
||
else:
|
||
segments = await self.hass.async_add_executor_job(
|
||
store.analyze_split_sync, cycle, self._editor_split_gap, 2.0
|
||
)
|
||
|
||
if not segments:
|
||
return self.async_abort(reason="no_split_segments_found")
|
||
|
||
# Generate SVG
|
||
split_title = await self._options_text("split_preview_title", "Split Preview")
|
||
unlabeled_text = await self._selector_text("unlabeled", "(Unlabeled)")
|
||
svg = store.generate_interactive_split_svg(
|
||
cycle["id"],
|
||
segments,
|
||
title_prefix=split_title,
|
||
unlabeled_text=unlabeled_text,
|
||
)
|
||
b64 = base64.b64encode(svg.encode("utf-8")).decode("utf-8")
|
||
|
||
split_found_fmt = await self._options_text(
|
||
"split_preview_found_fmt", "Found {count} segments."
|
||
)
|
||
split_confirm_fmt = await self._options_text(
|
||
"split_preview_confirm_fmt",
|
||
"Click Confirm to split this cycle into {count} separate cycles.",
|
||
)
|
||
preview_md = f"""
|
||
### {split_title}
|
||
{split_found_fmt.format(count=len(segments))}
|
||

|
||
|
||
{split_confirm_fmt.format(count=len(segments))}
|
||
"""
|
||
schema: dict[Any, Any] = {vol.Required("confirm_commit"): bool}
|
||
|
||
# Add profile pickers for each segment
|
||
profiles = store.list_profiles()
|
||
prof_options = [
|
||
selector.SelectOptionDict(
|
||
value="none",
|
||
label=await self._selector_text("unlabeled", "(Unlabeled)"),
|
||
)
|
||
]
|
||
for p in profiles:
|
||
prof_options.append(selector.SelectOptionDict(value=p["name"], label=p["name"]))
|
||
|
||
for i, seg in enumerate(segments):
|
||
# seg_dur = int(seg[1] - seg[0])
|
||
# label = f"New Cycle {i+1} ({seg_dur}s)" # Unused currently
|
||
schema[vol.Optional(f"segment_{i}_profile", default="none")] = selector.SelectSelector(
|
||
selector.SelectSelectorConfig(options=prof_options, mode=selector.SelectSelectorMode.DROPDOWN, custom_value=False)
|
||
)
|
||
|
||
elif self._editor_action == "merge":
|
||
# Get cycles
|
||
cycles_to_merge = [c for c in store.get_past_cycles() if c["id"] in self._editor_selected_ids]
|
||
cycles_to_merge.sort(key=lambda x: x["start_time"])
|
||
|
||
# Generate SVG
|
||
merge_title = await self._options_text("merge_preview_title", "Merge Preview")
|
||
no_power_label = await self._options_text(
|
||
"no_power_preview", "No power data available for preview"
|
||
)
|
||
svg = store.generate_interactive_merge_svg(
|
||
[c["id"] for c in cycles_to_merge],
|
||
title=merge_title,
|
||
no_data_label=no_power_label,
|
||
)
|
||
|
||
# Profile Selector
|
||
profiles = store.list_profiles()
|
||
prof_options = [
|
||
selector.SelectOptionDict(
|
||
value="create_new",
|
||
label=await self._selector_text(
|
||
"create_new_profile", "Create New Profile..."
|
||
),
|
||
),
|
||
selector.SelectOptionDict(
|
||
value="none",
|
||
label=await self._selector_text("unlabeled", "(Unlabeled)"),
|
||
),
|
||
]
|
||
for p in profiles:
|
||
prof_options.append(selector.SelectOptionDict(value=p["name"], label=p["name"]))
|
||
|
||
# Guess best profile?
|
||
default_prof = cycles_to_merge[0].get("profile_name") or "none"
|
||
|
||
merge_joining_fmt = await self._options_text(
|
||
"merge_preview_joining_fmt",
|
||
"Joining {count} cycles. Gaps will be filled with 0W readings.",
|
||
)
|
||
if svg:
|
||
b64 = base64.b64encode(svg.encode("utf-8")).decode("utf-8")
|
||
graph_line = f""
|
||
else:
|
||
graph_line = await self._options_text(
|
||
"no_power_preview", "*No power data available for preview.*"
|
||
)
|
||
preview_md = f"""
|
||
### {merge_title}
|
||
{merge_joining_fmt.format(count=len(cycles_to_merge))}
|
||
{graph_line}
|
||
"""
|
||
schema = {
|
||
vol.Optional("merged_profile", default=default_prof): selector.SelectSelector(
|
||
selector.SelectSelectorConfig(options=prof_options, mode=selector.SelectSelectorMode.DROPDOWN)
|
||
),
|
||
vol.Optional("new_profile_name"): str,
|
||
vol.Required("confirm_commit"): selector.BooleanSelector()
|
||
}
|
||
|
||
elif self._editor_action == "delete":
|
||
cycles_to_delete = [
|
||
c for c in store.get_past_cycles() if c["id"] in self._editor_selected_ids
|
||
]
|
||
cycles_to_delete.sort(key=lambda x: x["start_time"], reverse=True)
|
||
|
||
delete_title = await self._options_text(
|
||
"editor_delete_preview_title", "Delete Preview"
|
||
)
|
||
delete_intro = await self._options_text(
|
||
"editor_delete_preview_intro",
|
||
"The selected cycles will be permanently deleted:",
|
||
)
|
||
delete_confirm = await self._options_text(
|
||
"editor_delete_preview_confirm",
|
||
"Click Confirm to permanently delete these cycle records.",
|
||
)
|
||
unlabeled_text = await self._selector_text("unlabeled", "(Unlabeled)")
|
||
|
||
delete_rows: list[str] = []
|
||
for cycle in cycles_to_delete:
|
||
dt = dt_util.parse_datetime(cycle["start_time"])
|
||
when = (
|
||
dt_util.as_local(dt).strftime("%b %d, %H:%M")
|
||
if dt
|
||
else str(cycle["start_time"])
|
||
)
|
||
duration = _format_duration_label(
|
||
int(cycle.get("manual_duration", cycle["duration"]))
|
||
)
|
||
prof = cycle.get("profile_name") or unlabeled_text
|
||
delete_rows.append(
|
||
"- "
|
||
+ f"{html.escape(str(when))} | {html.escape(duration)} | {html.escape(str(prof))}"
|
||
)
|
||
|
||
preview_md = "\n".join(
|
||
[
|
||
f"### {delete_title}",
|
||
delete_intro,
|
||
*delete_rows,
|
||
"",
|
||
delete_confirm,
|
||
]
|
||
)
|
||
schema = {vol.Required("confirm_commit"): selector.BooleanSelector()}
|
||
|
||
return self.async_show_form(
|
||
step_id="editor_configure",
|
||
data_schema=vol.Schema(schema),
|
||
description_placeholders={"preview_md": preview_md}
|
||
)
|
||
|
||
|
||
|
||
async def async_step_diagnostics(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Diagnostics submenu as a button menu with storage stats."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
stats = await manager.profile_store.get_storage_stats()
|
||
|
||
self._push_menu("diagnostics")
|
||
return self.async_show_menu(
|
||
step_id="diagnostics",
|
||
menu_options=[
|
||
"reprocess_history",
|
||
"clear_debug_data",
|
||
"wipe_history",
|
||
"export_import",
|
||
"menu_back",
|
||
],
|
||
description_placeholders={
|
||
"file_size_kb": f"{stats.get('file_size_kb', 0):.1f}",
|
||
"cycle_count": str(stats.get('total_cycles', 0)),
|
||
"profile_count": str(stats.get('total_profiles', 0)),
|
||
"debug_count": str(stats.get('debug_traces_count', 0)),
|
||
},
|
||
)
|
||
|
||
async def async_step_clear_debug_data(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Confirm clearing debug data."""
|
||
if user_input is not None:
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
count = await manager.profile_store.async_clear_debug_data()
|
||
return self.async_abort(
|
||
reason="debug_data_cleared",
|
||
description_placeholders={"count": str(count)},
|
||
)
|
||
|
||
return self.async_show_form(
|
||
step_id="clear_debug_data", description_placeholders={}
|
||
)
|
||
|
||
async def async_step_reprocess_history(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Handle request to reprocess all history."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
|
||
if user_input is not None:
|
||
# Execute
|
||
count = await manager.profile_store.async_reprocess_all_data()
|
||
return self.async_abort(
|
||
reason="reprocess_success",
|
||
description_placeholders={"count": str(count)},
|
||
)
|
||
|
||
return self.async_show_form(
|
||
step_id="reprocess_history",
|
||
data_schema=vol.Schema({}),
|
||
)
|
||
|
||
async def async_step_export_import(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Export or import profile/cycle data via JSON copy/paste."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
export_payload = manager.profile_store.export_data(
|
||
entry_data=dict(self.config_entry.data),
|
||
entry_options=dict(self.config_entry.options),
|
||
)
|
||
export_str = json.dumps(export_payload, indent=2)
|
||
|
||
errors: dict[str, str] = {}
|
||
|
||
if user_input is not None:
|
||
mode = user_input.get("mode", "export")
|
||
payload_str = user_input.get("json_payload", "")
|
||
|
||
# Always preserve existing options unless we explicitly update them
|
||
options_to_return = dict(self.config_entry.options)
|
||
|
||
if mode == "import":
|
||
try:
|
||
payload = json.loads(payload_str)
|
||
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:
|
||
# Merge imported options with current data/options
|
||
new_data = {**self.config_entry.data}
|
||
new_options = {**self.config_entry.options}
|
||
|
||
# Only update settings that exist in the import
|
||
# (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)
|
||
|
||
self.hass.config_entries.async_update_entry(
|
||
self.config_entry,
|
||
data=new_data,
|
||
options=new_options,
|
||
)
|
||
_LOGGER.info("Applied imported settings to config entry")
|
||
|
||
# Return the merged options so the options flow itself doesn't revert them
|
||
options_to_return = dict(new_options)
|
||
|
||
except Exception: # noqa: BLE001, pylint: disable=broad-exception-caught
|
||
errors["base"] = "import_failed"
|
||
# Re-show form with error
|
||
return self.async_show_form(
|
||
step_id="export_import",
|
||
data_schema=vol.Schema(
|
||
{
|
||
vol.Required(
|
||
"mode", default=mode
|
||
): self._translated_select(
|
||
options=["export", "import"],
|
||
translation_key="export_import_mode",
|
||
),
|
||
vol.Optional(
|
||
"json_payload", default=payload_str
|
||
): selector.TextSelector(
|
||
selector.TextSelectorConfig(multiline=True)
|
||
),
|
||
}
|
||
),
|
||
errors=errors,
|
||
)
|
||
|
||
return self.async_create_entry(title="", data=options_to_return)
|
||
|
||
return self.async_show_form(
|
||
step_id="export_import",
|
||
data_schema=vol.Schema(
|
||
{
|
||
vol.Required("mode", default="export"): self._translated_select(
|
||
options=["export", "import"],
|
||
translation_key="export_import_mode",
|
||
),
|
||
vol.Optional(
|
||
"json_payload", default=export_str
|
||
): selector.TextSelector(
|
||
selector.TextSelectorConfig(multiline=True)
|
||
),
|
||
}
|
||
),
|
||
)
|
||
|
||
async def async_step_manage_cycles(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Manage cycles submenu (button menu with card-style preview)."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
|
||
unlabeled_text = await self._selector_text("unlabeled", "(Unlabeled)")
|
||
|
||
recent_cycles = store.get_past_cycles()[-8:]
|
||
rows: list[str] = []
|
||
for c in reversed(recent_cycles):
|
||
dt = dt_util.parse_datetime(c["start_time"])
|
||
when = (
|
||
dt_util.as_local(dt).strftime("%b %d, %H:%M")
|
||
if dt
|
||
else str(c["start_time"])
|
||
)
|
||
duration_s = int(c.get("manual_duration", c["duration"]))
|
||
duration_str = _format_duration_label(duration_s)
|
||
prof = c.get("profile_name") or unlabeled_text
|
||
safe_prof = html.escape(str(prof))
|
||
safe_when = html.escape(str(when))
|
||
safe_duration = html.escape(duration_str)
|
||
status = c.get("status", "completed")
|
||
status_icon = (
|
||
"✓"
|
||
if status in ("completed", "force_stopped")
|
||
else "⚠" if status == "resumed" else "✗"
|
||
)
|
||
conf = c.get("match_confidence")
|
||
if isinstance(conf, (int, float)) and conf > 0:
|
||
conf_text = f"{int(round(float(conf) * 100))}%"
|
||
else:
|
||
conf_text = "—"
|
||
rows.append(
|
||
f'<tr><td align="center">{status_icon}</td>'
|
||
f'<td><b>{safe_prof}</b></td>'
|
||
f'<td>{safe_when}</td>'
|
||
f'<td>{safe_duration}</td>'
|
||
f'<td align="center">{conf_text}</td></tr>'
|
||
)
|
||
|
||
recent_program = await self._options_text("table_program", "Program")
|
||
recent_when = await self._options_text("table_when", "When")
|
||
recent_length = await self._options_text("table_length", "Length")
|
||
recent_match = await self._options_text("table_match", "Match")
|
||
|
||
if not rows:
|
||
return await self.async_step_manage_cycles_empty()
|
||
|
||
recent_text = (
|
||
'<table width="100%">'
|
||
'<tr>'
|
||
'<th width="5%" align="center"></th>'
|
||
f'<th align="left">{html.escape(recent_program)}</th>'
|
||
f'<th width="22%" align="left">{html.escape(recent_when)}</th>'
|
||
f'<th width="14%" align="left">{html.escape(recent_length)}</th>'
|
||
f'<th width="10%" align="center">{html.escape(recent_match)}</th>'
|
||
'</tr>'
|
||
+ "".join(rows)
|
||
+ '</table>'
|
||
)
|
||
|
||
self._push_menu("manage_cycles")
|
||
return self.async_show_menu(
|
||
step_id="manage_cycles",
|
||
menu_options=[
|
||
"auto_label_cycles",
|
||
"select_cycle_to_label",
|
||
"select_cycle_to_delete",
|
||
"interactive_editor",
|
||
"trim_cycle_select",
|
||
"menu_back",
|
||
],
|
||
description_placeholders={"recent_cycles": recent_text},
|
||
)
|
||
|
||
async def async_step_manage_cycles_empty(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Manage cycles submenu when no cycles are present."""
|
||
self._push_menu("manage_cycles_empty")
|
||
return self.async_show_menu(
|
||
step_id="manage_cycles_empty",
|
||
menu_options=["auto_label_cycles", "menu_back"],
|
||
)
|
||
|
||
|
||
|
||
async def async_step_manage_profiles(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Manage profiles submenu as a button menu with profile summary."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
profiles = store.list_profiles()
|
||
|
||
if not profiles:
|
||
return await self.async_step_manage_profiles_empty()
|
||
|
||
rows: list[str] = []
|
||
for p in profiles:
|
||
avg_s = int(p.get("avg_duration") or 0)
|
||
avg_str = _format_duration_label(avg_s) if avg_s > 0 else "—"
|
||
count = p.get("cycle_count", 0)
|
||
last_run_raw = p.get("last_run")
|
||
last_run_str = "—"
|
||
if last_run_raw:
|
||
dt = dt_util.parse_datetime(str(last_run_raw))
|
||
if dt:
|
||
last_run_str = dt_util.as_local(dt).strftime("%b %d")
|
||
avg_energy = p.get("avg_energy")
|
||
if isinstance(avg_energy, (int, float)) and avg_energy > 0:
|
||
if avg_energy >= 1.0:
|
||
energy_str = f"{avg_energy:.2f} kWh"
|
||
else:
|
||
energy_str = f"{int(round(avg_energy * 1000))} Wh"
|
||
else:
|
||
energy_str = "—"
|
||
safe_name = html.escape(str(p["name"]))
|
||
safe_avg = html.escape(avg_str)
|
||
safe_last_run = html.escape(last_run_str)
|
||
safe_energy = html.escape(energy_str)
|
||
rows.append(
|
||
f'<tr><td><b>{safe_name}</b></td>'
|
||
f'<td align="center">{count}</td>'
|
||
f'<td align="center">{safe_avg}</td>'
|
||
f'<td align="center">{safe_last_run}</td>'
|
||
f'<td align="center">{safe_energy}</td></tr>'
|
||
)
|
||
|
||
profile_header = await self._options_text("table_profile", "Profile")
|
||
cycles_header = await self._options_text("table_cycles", "Cycles")
|
||
avg_length_header = await self._options_text(
|
||
"table_avg_length", "Avg Length"
|
||
)
|
||
last_run_header = await self._options_text("table_last_run", "Last Run")
|
||
avg_energy_header = await self._options_text(
|
||
"table_avg_energy", "Avg Energy"
|
||
)
|
||
|
||
summary_text = (
|
||
'<table width="100%">'
|
||
'<tr>'
|
||
f'<th align="left">{html.escape(profile_header)}</th>'
|
||
f'<th width="12%" align="center">{html.escape(cycles_header)}</th>'
|
||
f'<th width="18%" align="center">{html.escape(avg_length_header)}</th>'
|
||
f'<th width="14%" align="center">{html.escape(last_run_header)}</th>'
|
||
f'<th width="18%" align="center">{html.escape(avg_energy_header)}</th>'
|
||
'</tr>'
|
||
+ "".join(rows)
|
||
+ '</table>'
|
||
)
|
||
|
||
self._push_menu("manage_profiles")
|
||
return self.async_show_menu(
|
||
step_id="manage_profiles",
|
||
menu_options=[
|
||
"create_profile",
|
||
"edit_profile",
|
||
"delete_profile_select",
|
||
"profile_stats",
|
||
"cleanup_profile",
|
||
"assign_profile_phases_select",
|
||
"menu_back",
|
||
],
|
||
description_placeholders={"profile_summary": summary_text},
|
||
)
|
||
|
||
async def async_step_manage_profiles_empty(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Manage profiles submenu when no profiles are present."""
|
||
self._push_menu("manage_profiles_empty")
|
||
return self.async_show_menu(
|
||
step_id="manage_profiles_empty",
|
||
menu_options=["create_profile", "menu_back"],
|
||
)
|
||
|
||
async def async_step_manage_phase_catalog(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Manage phase catalog for all device types."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
|
||
# Phase names/descriptions are runtime catalog data, but the static
|
||
# wording around them is resolved via _options_text (instance language).
|
||
builtin_suffix = await self._options_text("phase_builtin_suffix", "(Built-in)")
|
||
no_phases = await self._options_text(
|
||
"phase_none_available", "No phases available."
|
||
)
|
||
other_types_label = await self._options_text(
|
||
"phase_other_device_types", "Other device types:"
|
||
)
|
||
|
||
def _device_label(dtype: str) -> str:
|
||
# Reuse the (now translated) device-type selector labels.
|
||
return self._selector_translations.get(
|
||
f"component.{DOMAIN}.selector.device_type.options.{dtype}",
|
||
DEVICE_TYPES.get(dtype, dtype),
|
||
)
|
||
|
||
# Show this device's phases in full; summarise every other device type
|
||
# as a one-line count so the landing page stays short. The create / edit
|
||
# / delete steps still operate on every device type's phases.
|
||
current_type = manager.device_type
|
||
summary_lines: list[str] = []
|
||
current_phases = store.list_phase_catalog(current_type)
|
||
if current_phases:
|
||
summary_lines.append(f"**{_device_label(current_type)}**")
|
||
for phase in current_phases:
|
||
icon = "📌 " if phase.get("is_default") else "✏️ "
|
||
desc = str(phase.get("description", "")).strip()
|
||
short = desc if len(desc) <= 80 else f"{desc[:77]}..."
|
||
phase_type = f" {builtin_suffix}" if phase.get("is_default") else ""
|
||
name = _escape_markdown(phase.get("name", ""))
|
||
summary_lines.append(
|
||
f"{icon}**{name}{phase_type}** - {_escape_markdown(short)}"
|
||
)
|
||
|
||
other_counts = []
|
||
for device_type in DEVICE_TYPES:
|
||
if device_type == current_type:
|
||
continue
|
||
count = len(store.list_phase_catalog(device_type))
|
||
if count:
|
||
other_counts.append(f"{_device_label(device_type)} ({count})")
|
||
if other_counts:
|
||
if summary_lines:
|
||
summary_lines.append("")
|
||
summary_lines.append(f"{other_types_label} " + ", ".join(other_counts))
|
||
|
||
summary = "\n".join(summary_lines) if summary_lines else no_phases
|
||
|
||
self._push_menu("manage_phase_catalog")
|
||
return self.async_show_menu(
|
||
step_id="manage_phase_catalog",
|
||
menu_options=[
|
||
"phase_catalog_create",
|
||
"phase_catalog_edit_select",
|
||
"phase_catalog_delete",
|
||
"menu_back",
|
||
],
|
||
description_placeholders={"phase_summary": summary},
|
||
)
|
||
|
||
async def async_step_phase_catalog_create(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Create custom phase for a selected device type."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
errors: dict[str, str] = {}
|
||
all_device_value = "all_devices"
|
||
|
||
if user_input is not None:
|
||
try:
|
||
target_device_type = str(user_input.get("target_device_type", manager.device_type))
|
||
if target_device_type == all_device_value:
|
||
target_device_type = ""
|
||
await store.async_create_custom_phase(
|
||
target_device_type,
|
||
user_input["phase_name"],
|
||
user_input.get("phase_description", ""),
|
||
)
|
||
manager.notify_update()
|
||
return await self.async_step_manage_phase_catalog()
|
||
except ValueError as err:
|
||
errors["base"] = str(err)
|
||
|
||
target_options = [
|
||
selector.SelectOptionDict(
|
||
value=manager.device_type,
|
||
label=(
|
||
f"{DEVICE_TYPES.get(manager.device_type, manager.device_type)} "
|
||
f"{await self._selector_text('current_suffix', '(current)')}"
|
||
),
|
||
),
|
||
selector.SelectOptionDict(
|
||
value=all_device_value,
|
||
label=await self._selector_text(
|
||
"all_device_types", "All Device Types"
|
||
),
|
||
),
|
||
]
|
||
for key, label in DEVICE_TYPES.items():
|
||
if key == manager.device_type:
|
||
continue
|
||
target_options.append(selector.SelectOptionDict(value=key, label=label))
|
||
|
||
return self.async_show_form(
|
||
step_id="phase_catalog_create",
|
||
data_schema=vol.Schema(
|
||
{
|
||
vol.Required("target_device_type", default=manager.device_type): selector.SelectSelector(
|
||
selector.SelectSelectorConfig(
|
||
options=target_options,
|
||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||
)
|
||
),
|
||
vol.Required("phase_name"): str,
|
||
vol.Optional("phase_description", default=""): selector.TextSelector(
|
||
selector.TextSelectorConfig(multiline=True)
|
||
),
|
||
}
|
||
),
|
||
errors=errors,
|
||
)
|
||
|
||
async def async_step_phase_catalog_edit_select(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Select phase to edit from all device types."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
|
||
# Gather unique phases from all device types.
|
||
# Built-in phases have no device_type field; use the iteration device_type
|
||
# as the effective scope for display and deduplication only.
|
||
# The actual_scope stored in phase_index is the real phase device_type
|
||
# (empty string for built-ins), so that editing a built-in always produces
|
||
# a universal override (device_type="") rather than a device-specific one.
|
||
all_phases: list[tuple[dict[str, Any], str, str]] = []
|
||
seen: set[tuple[str, str]] = set()
|
||
for device_type in DEVICE_TYPES.keys():
|
||
for phase in store.list_phase_catalog(device_type):
|
||
phase_name = str(phase.get("name", "")).strip()
|
||
phase_scope = str(phase.get("device_type", "")).strip()
|
||
is_default = bool(phase.get("is_default"))
|
||
# Built-in phases carry no device_type; use iteration device_type
|
||
# for display label so they don't all read "[All Devices]".
|
||
effective_scope = phase_scope if (phase_scope or not is_default) else device_type
|
||
dedupe_key = (phase_name.casefold(), effective_scope)
|
||
if not phase_name or dedupe_key in seen:
|
||
continue
|
||
seen.add(dedupe_key)
|
||
all_phases.append((phase, effective_scope, phase_scope))
|
||
|
||
if not all_phases:
|
||
return self.async_abort(reason="no_phases_available")
|
||
|
||
options: list[selector.SelectOptionDict] = []
|
||
phase_index: dict[str, tuple[str, str, str]] = {}
|
||
for phase, effective_scope, actual_scope in all_phases:
|
||
phase_name = str(phase.get("name", "")).strip()
|
||
phase_id = str(phase.get("id", "")).strip()
|
||
scope_label = DEVICE_TYPES.get(effective_scope, "All Devices") if effective_scope else "All Devices"
|
||
option_key = phase_id if phase_id else f"{effective_scope}::{phase_name}"
|
||
phase_index[option_key] = (phase_name, actual_scope, phase_id)
|
||
options.append(
|
||
selector.SelectOptionDict(
|
||
value=option_key,
|
||
label=f"[{scope_label}] {phase_name} - {str(phase.get('description', ''))[:60]}",
|
||
)
|
||
)
|
||
|
||
if user_input is not None:
|
||
selected_key = user_input["phase_name"]
|
||
selected_meta = phase_index.get(selected_key)
|
||
if selected_meta is None:
|
||
return await self.async_step_phase_catalog_edit_select()
|
||
self._selected_phase_name = selected_meta[0]
|
||
self._selected_phase_device_type = selected_meta[1]
|
||
self._selected_phase_id = selected_meta[2] or None
|
||
return await self.async_step_phase_catalog_edit()
|
||
|
||
return self.async_show_form(
|
||
step_id="phase_catalog_edit_select",
|
||
data_schema=vol.Schema(
|
||
{
|
||
vol.Required("phase_name"): selector.SelectSelector(
|
||
selector.SelectSelectorConfig(
|
||
options=options,
|
||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||
)
|
||
)
|
||
}
|
||
),
|
||
)
|
||
|
||
async def async_step_phase_catalog_edit(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Edit selected phase from all device types."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
if not self._selected_phase_id and not self._selected_phase_name:
|
||
return await self.async_step_phase_catalog_edit_select()
|
||
|
||
catalog = store.list_phase_catalog(self._selected_phase_device_type or "")
|
||
if self._selected_phase_id:
|
||
selected = next(
|
||
(p for p in catalog if str(p.get("id", "")) == self._selected_phase_id),
|
||
None,
|
||
)
|
||
else:
|
||
selected = next(
|
||
(
|
||
p for p in catalog
|
||
if str(p.get("name", "")).strip() == self._selected_phase_name
|
||
and str(p.get("device_type", "")).strip() == self._selected_phase_device_type
|
||
),
|
||
None,
|
||
)
|
||
|
||
if not selected:
|
||
return await self.async_step_phase_catalog_edit_select()
|
||
|
||
errors: dict[str, str] = {}
|
||
if user_input is not None:
|
||
try:
|
||
phase_id = self._selected_phase_id or str(selected.get("id", ""))
|
||
await store.async_update_custom_phase(
|
||
phase_id,
|
||
user_input["phase_name"],
|
||
user_input.get("phase_description", ""),
|
||
)
|
||
manager.notify_update()
|
||
return await self.async_step_manage_phase_catalog()
|
||
except ValueError as err:
|
||
errors["base"] = str(err)
|
||
|
||
return self.async_show_form(
|
||
step_id="phase_catalog_edit",
|
||
data_schema=vol.Schema(
|
||
{
|
||
vol.Required("phase_name", default=selected["name"]): str,
|
||
vol.Optional(
|
||
"phase_description",
|
||
default=selected.get("description", ""),
|
||
): selector.TextSelector(selector.TextSelectorConfig(multiline=True)),
|
||
}
|
||
),
|
||
errors=errors,
|
||
description_placeholders={"phase_name": self._selected_phase_name},
|
||
)
|
||
|
||
async def async_step_phase_catalog_delete(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Delete custom phase from any device type."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
|
||
# Gather unique custom phases from all device types.
|
||
# Global phases (device_type="") appear in every per-device list,
|
||
# so dedupe by (name, device_type).
|
||
# Collect unique user-added phases (not built-in overrides) across all device types.
|
||
all_custom_phases: dict[str, dict[str, str]] = {} # phase_id -> {name, scope}
|
||
seen_ids: set[str] = set()
|
||
for device_type in DEVICE_TYPES.keys():
|
||
custom_phases = store.list_custom_phases(device_type)
|
||
for p in custom_phases:
|
||
phase_name = str(p.get("name", "")).strip()
|
||
phase_scope = str(p.get("device_type", "")).strip()
|
||
phase_id = str(p.get("id", "")).strip()
|
||
if not phase_name or not phase_id or phase_id in seen_ids:
|
||
continue
|
||
seen_ids.add(phase_id)
|
||
all_custom_phases[phase_id] = {"name": phase_name, "scope": phase_scope}
|
||
|
||
if not all_custom_phases:
|
||
return self.async_abort(reason="no_custom_phases")
|
||
|
||
errors: dict[str, str] = {}
|
||
if user_input is not None:
|
||
selected_key = user_input["phase_name"]
|
||
if selected_key in all_custom_phases:
|
||
phase_name = all_custom_phases[selected_key]["name"]
|
||
try:
|
||
removed = await store.async_delete_custom_phase(selected_key)
|
||
manager.notify_update()
|
||
_LOGGER.info(
|
||
"Deleted custom phase '%s' and removed %s phase assignments",
|
||
phase_name,
|
||
removed,
|
||
)
|
||
return await self.async_step_manage_phase_catalog()
|
||
except ValueError as err:
|
||
errors["base"] = str(err)
|
||
else:
|
||
return await self.async_step_phase_catalog_delete()
|
||
|
||
options = []
|
||
for phase_id, phase_meta in all_custom_phases.items():
|
||
phase_name = phase_meta["name"]
|
||
phase_scope = phase_meta["scope"]
|
||
scope_label = DEVICE_TYPES.get(phase_scope, "All Devices") if phase_scope else "All Devices"
|
||
usage = store.count_phase_usage(phase_name)
|
||
label = f"[{scope_label}] {phase_name} ({usage} assignments)"
|
||
options.append(selector.SelectOptionDict(value=phase_id, label=label))
|
||
|
||
return self.async_show_form(
|
||
step_id="phase_catalog_delete",
|
||
data_schema=vol.Schema(
|
||
{
|
||
vol.Required("phase_name"): selector.SelectSelector(
|
||
selector.SelectSelectorConfig(
|
||
options=options,
|
||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||
)
|
||
)
|
||
}
|
||
),
|
||
errors=errors,
|
||
)
|
||
|
||
def _phase_assignment_summary(self, ranges: list[dict[str, Any]]) -> str:
|
||
"""Build compact summary lines for assigned phase ranges."""
|
||
if not ranges:
|
||
return "No ranges assigned yet."
|
||
|
||
lines = []
|
||
for idx, row in enumerate(ranges, start=1):
|
||
start_min = int(float(row.get("start", 0.0)) / 60)
|
||
end_min = int(float(row.get("end", 0.0)) / 60)
|
||
duration_min = max(1, end_min - start_min)
|
||
lines.append(f"{idx}. {row.get('name', '')}: {start_min}-{end_min} min ({duration_min}m)")
|
||
return "\n".join(lines)
|
||
|
||
def _phase_assignment_timeline(self, ranges: list[dict[str, Any]]) -> str:
|
||
"""Render a simple text timeline (48 slots) for phase ranges."""
|
||
if not ranges:
|
||
return "[................................................]"
|
||
|
||
max_end = max(float(r.get("end", 0.0)) for r in ranges)
|
||
if max_end <= 0:
|
||
return "[................................................]"
|
||
|
||
slots = 48
|
||
chars = ["."] * slots
|
||
for slot in range(slots):
|
||
t = (slot + 0.5) * (max_end / slots)
|
||
symbol = "."
|
||
for idx, row in enumerate(ranges, start=1):
|
||
start = float(row.get("start", 0.0))
|
||
end = float(row.get("end", 0.0))
|
||
if start <= t < end:
|
||
symbol = str(idx % 10)
|
||
break
|
||
chars[slot] = symbol
|
||
|
||
total_min = int(max_end / 60)
|
||
return f"[{''.join(chars)}] 0..{total_min} min"
|
||
|
||
def _phase_assignment_svg_markdown(
|
||
self,
|
||
profile_name: str,
|
||
ranges: list[dict[str, Any]],
|
||
envelope: dict[str, Any] | None,
|
||
labels: dict[str, str] | None = None,
|
||
) -> str:
|
||
"""Render phase assignment preview as average power curve + gating lines."""
|
||
|
||
txt = {
|
||
"phase_preview": "Phase Preview",
|
||
"no_curve": "Average profile curve is not available yet. Run/label more cycles for this profile.",
|
||
"min": "min",
|
||
"avg_curve": "Average Power Curve",
|
||
}
|
||
if labels:
|
||
txt.update(labels)
|
||
|
||
def esc(text: Any) -> str:
|
||
return (
|
||
str(text)
|
||
.replace("&", "&")
|
||
.replace("<", "<")
|
||
.replace(">", ">")
|
||
)
|
||
|
||
width = 1360
|
||
plot_left = 12
|
||
plot_right = 12
|
||
plot_top = 64
|
||
plot_height = 320
|
||
plot_width = width - plot_left - plot_right
|
||
|
||
if not envelope or not isinstance(envelope.get("avg"), list):
|
||
empty_svg = (
|
||
"<svg xmlns='http://www.w3.org/2000/svg' width='1360' height='150' viewBox='0 0 1360 150' style='background-color: #1c1c1c;'>"
|
||
"<rect x='0' y='0' width='1360' height='150' fill='#1c1c1c'/>"
|
||
f"<text x='22' y='42' font-family='sans-serif' font-size='24' fill='#f3f4f6' font-weight='bold'>{esc(txt['phase_preview'])}: {esc(profile_name)}</text>"
|
||
"<text x='22' y='96' font-family='sans-serif' font-size='18' fill='#cbd5e1'>"
|
||
f"{esc(txt['no_curve'])}"
|
||
"</text>"
|
||
"</svg>"
|
||
)
|
||
encoded_empty = base64.b64encode(empty_svg.encode("utf-8")).decode("ascii")
|
||
return f""
|
||
|
||
avg_points_raw = envelope.get("avg", [])
|
||
avg_points: list[tuple[float, float]] = []
|
||
for point in avg_points_raw:
|
||
if not isinstance(point, (list, tuple)) or len(point) < 2:
|
||
continue
|
||
try:
|
||
avg_points.append((float(point[0]), float(point[1])))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
|
||
if len(avg_points) < 2:
|
||
return self._phase_assignment_svg_markdown(profile_name, ranges, None)
|
||
|
||
max_time = max(p[0] for p in avg_points)
|
||
max_power_curve = max((p[1] for p in avg_points), default=0.0)
|
||
max_power_envelope = 0.0
|
||
max_curve = envelope.get("max", [])
|
||
if isinstance(max_curve, list):
|
||
for point in max_curve:
|
||
if isinstance(point, (list, tuple)) and len(point) >= 2:
|
||
try:
|
||
max_power_envelope = max(max_power_envelope, float(point[1]))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
max_power = max(10.0, max_power_curve, max_power_envelope) * 1.08
|
||
max_time = max(1.0, max_time)
|
||
|
||
def to_x(t_sec: float) -> float:
|
||
return plot_left + (max(0.0, min(max_time, t_sec)) / max_time) * plot_width
|
||
|
||
def to_y(power_w: float) -> float:
|
||
clamped = max(0.0, min(max_power, power_w))
|
||
return plot_top + plot_height - (clamped / max_power) * plot_height
|
||
|
||
def pick_grid_interval(total_min_val: int) -> int:
|
||
label_w_px = 91 # conservative width of widest label at font-size 22
|
||
min_iv = (label_w_px / plot_width) * total_min_val
|
||
for candidate in (1, 2, 5, 10, 15, 20, 30, 60):
|
||
if candidate > min_iv:
|
||
return candidate
|
||
return 60
|
||
|
||
ordered = sorted(
|
||
ranges,
|
||
key=lambda x: (float(x.get("start", 0.0)), float(x.get("end", 0.0))),
|
||
)
|
||
palette = [
|
||
"#2563eb",
|
||
"#059669",
|
||
"#d97706",
|
||
"#dc2626",
|
||
"#7c3aed",
|
||
"#0891b2",
|
||
"#4f46e5",
|
||
"#16a34a",
|
||
"#ea580c",
|
||
"#be123c",
|
||
]
|
||
|
||
legend_item_height = 30
|
||
legend_item_width = 340
|
||
legend_columns = max(1, int((plot_width - 10) // legend_item_width))
|
||
legend_rows = max(1, (max(1, len(ordered) + 1) + legend_columns - 1) // legend_columns)
|
||
marker_y = plot_top + plot_height + 34
|
||
legend_top = marker_y + 52
|
||
height = legend_top + legend_rows * legend_item_height + 24
|
||
|
||
parts = [
|
||
f"<svg xmlns='http://www.w3.org/2000/svg' width='{width}' height='{height}' viewBox='0 0 {width} {height}'>",
|
||
"<rect x='0' y='0' width='100%' height='100%' fill='#1c1c1c'/>",
|
||
f"<rect x='{plot_left}' y='{plot_top}' width='{plot_width}' height='{plot_height}' fill='#111111' stroke='#444' stroke-width='2' rx='8'/>",
|
||
]
|
||
|
||
# Adaptive time grid - vertical lines drawn first so they sit behind everything.
|
||
total_min = int(max_time / 60)
|
||
grid_interval = pick_grid_interval(total_min)
|
||
grid_ticks_min = list(range(0, total_min + 1, grid_interval))
|
||
for tick_min in grid_ticks_min:
|
||
tx = to_x(tick_min * 60.0)
|
||
parts.append(
|
||
f"<line x1='{tx:.2f}' y1='{plot_top}' x2='{tx:.2f}' y2='{plot_top + plot_height}' stroke='#2a2a2a' stroke-width='1' stroke-opacity='0.9'/>",
|
||
)
|
||
|
||
# Phase background spans + gating lines.
|
||
for idx, row in enumerate(ordered):
|
||
start = max(0.0, float(row.get("start", 0.0)))
|
||
end = max(start, float(row.get("end", 0.0)))
|
||
if start > max_time:
|
||
continue
|
||
x1 = to_x(start)
|
||
x2 = to_x(min(end, max_time))
|
||
color = palette[idx % len(palette)]
|
||
if x2 > x1:
|
||
parts.append(
|
||
f"<rect x='{x1:.2f}' y='{plot_top + 1}' width='{(x2 - x1):.2f}' height='{plot_height - 2}' fill='{color}' fill-opacity='0.22' stroke='none'/>",
|
||
)
|
||
# Gating lines at phase boundaries.
|
||
parts.append(
|
||
f"<line x1='{x1:.2f}' y1='{plot_top}' x2='{x1:.2f}' y2='{plot_top + plot_height}' stroke='{color}' stroke-width='2.2' stroke-dasharray='7 5' stroke-opacity='0.95'/>",
|
||
)
|
||
if end <= max_time:
|
||
parts.append(
|
||
f"<line x1='{x2:.2f}' y1='{plot_top}' x2='{x2:.2f}' y2='{plot_top + plot_height}' stroke='{color}' stroke-width='2.2' stroke-dasharray='7 5' stroke-opacity='0.95'/>",
|
||
)
|
||
|
||
# Minute labels on each phase boundary gating line, placed above the plot.
|
||
# Two-row stagger handles closely-packed boundaries without increasing SVG height.
|
||
_gate_label_w = 72
|
||
_gate_row1_y = plot_top - 10 # y=54 for default plot_top=64
|
||
_gate_row2_y = plot_top - 28 # y=36
|
||
_placed: list[tuple[float, float, int]] = []
|
||
for idx, row in enumerate(ordered):
|
||
start = max(0.0, float(row.get("start", 0.0)))
|
||
end = max(start, float(row.get("end", 0.0)))
|
||
if start > max_time:
|
||
continue
|
||
color = palette[idx % len(palette)]
|
||
for t_sec, skip_if_over in ((start, False), (end, True)):
|
||
if skip_if_over and end > max_time:
|
||
continue
|
||
t_min = int(t_sec / 60)
|
||
tx = to_x(t_sec)
|
||
xl, xr = tx - _gate_label_w / 2, tx + _gate_label_w / 2
|
||
chosen_y = _gate_row1_y
|
||
for y_cand in (_gate_row1_y, _gate_row2_y):
|
||
if not any(xl < pr and xr > pl for pl, pr, py in _placed if py == y_cand):
|
||
chosen_y = y_cand
|
||
break
|
||
_placed.append((xl, xr, chosen_y))
|
||
if tx - _gate_label_w / 2 < plot_left:
|
||
anchor, label_x = "start", float(plot_left)
|
||
elif tx + _gate_label_w / 2 > plot_left + plot_width:
|
||
anchor, label_x = "end", float(plot_left + plot_width)
|
||
else:
|
||
anchor, label_x = "middle", tx
|
||
parts.append(
|
||
f"<text x='{label_x:.2f}' y='{chosen_y}' font-family='sans-serif' font-size='22'"
|
||
f" fill='{color}' fill-opacity='0.9' text-anchor='{anchor}'>{t_min} {esc(txt['min'])}</text>",
|
||
)
|
||
|
||
# Axis helper lines.
|
||
for frac in (0.25, 0.5, 0.75):
|
||
y = plot_top + plot_height * frac
|
||
parts.append(
|
||
f"<line x1='{plot_left}' y1='{y:.2f}' x2='{plot_left + plot_width}' y2='{y:.2f}' stroke='#323232' stroke-width='1.2'/>",
|
||
)
|
||
|
||
# Average power curve.
|
||
avg_poly = " ".join(f"{to_x(t):.2f},{to_y(p):.2f}" for t, p in avg_points)
|
||
parts.append(
|
||
f"<polyline points='{avg_poly}' fill='none' stroke='#3498db' stroke-width='4.2' stroke-linecap='round' stroke-linejoin='round'/>",
|
||
)
|
||
|
||
# Adaptive x-axis tick labels (total_min / grid_interval / grid_ticks_min already computed above).
|
||
for tick_min in grid_ticks_min:
|
||
tx = to_x(tick_min * 60.0)
|
||
half_w = 45
|
||
if tx - half_w < plot_left:
|
||
anchor, label_x = "start", float(plot_left)
|
||
elif tx + half_w > plot_left + plot_width:
|
||
anchor, label_x = "end", float(plot_left + plot_width)
|
||
else:
|
||
anchor, label_x = "middle", tx
|
||
parts.append(
|
||
f"<text x='{label_x:.2f}' y='{marker_y}' font-family='sans-serif' font-size='22'"
|
||
f" fill='#9ca3af' text-anchor='{anchor}'>{tick_min} {esc(txt['min'])}</text>",
|
||
)
|
||
|
||
# Legend: average curve + each phase color.
|
||
legend_entries: list[tuple[str, str]] = [("#3498db", txt["avg_curve"])]
|
||
for idx, row in enumerate(ordered):
|
||
start_min = int(float(row.get("start", 0.0)) / 60)
|
||
end_min = int(float(row.get("end", 0.0)) / 60)
|
||
legend_entries.append(
|
||
(
|
||
palette[idx % len(palette)],
|
||
f"{esc(row.get('name', ''))} ({start_min}-{end_min}{esc(txt['min'])})",
|
||
)
|
||
)
|
||
|
||
for idx, (color, label) in enumerate(legend_entries):
|
||
col = idx % legend_columns
|
||
row = idx // legend_columns
|
||
lx = plot_left + col * legend_item_width
|
||
ly = legend_top + row * legend_item_height
|
||
parts.append(
|
||
f"<rect x='{lx}' y='{ly - 14}' width='14' height='14' rx='3' fill='{color}'/>",
|
||
)
|
||
parts.append(
|
||
f"<text x='{lx + 24}' y='{ly - 2}' font-family='sans-serif' font-size='24' fill='#e5e7eb'>{label}</text>",
|
||
)
|
||
|
||
parts.append("</svg>")
|
||
svg = "".join(parts)
|
||
encoded = base64.b64encode(svg.encode("utf-8")).decode("ascii")
|
||
return f""
|
||
|
||
def _parse_phase_range_input(
|
||
self,
|
||
user_input: dict[str, Any],
|
||
) -> tuple[float | None, float | None, str | None]:
|
||
"""Parse offset fields from user input into start/end seconds."""
|
||
start_min = user_input.get("start_min")
|
||
end_min = user_input.get("end_min")
|
||
if start_min is None or end_min is None:
|
||
return None, None, "incomplete_phase_row"
|
||
return float(start_min) * 60.0, float(end_min) * 60.0, None
|
||
|
||
def _auto_detect_phase_ranges(
|
||
self,
|
||
avg_points: list[tuple[float, float]],
|
||
catalog: list[dict[str, Any]],
|
||
power_threshold_frac: float = 0.08,
|
||
min_gap_sec: float = 120.0,
|
||
min_phase_sec: float = 60.0,
|
||
) -> list[dict[str, Any]]:
|
||
"""Detect phase ranges from an average power curve using step-change segmentation."""
|
||
if len(avg_points) < 2:
|
||
return []
|
||
max_power = max(p[1] for p in avg_points)
|
||
if max_power < 1.0:
|
||
return []
|
||
# Box-smooth to suppress noise (the avg curve is already relatively clean,
|
||
# but individual extremes can still trigger false segment boundaries).
|
||
window = max(1, min(15, len(avg_points) // 8))
|
||
smoothed: list[tuple[float, float]] = []
|
||
for i, (t, _p) in enumerate(avg_points):
|
||
lo = max(0, i - window // 2)
|
||
hi = min(len(avg_points), i + window // 2 + 1)
|
||
smoothed.append((t, sum(avg_points[j][1] for j in range(lo, hi)) / (hi - lo)))
|
||
threshold = max_power * power_threshold_frac
|
||
# Find contiguous active segments.
|
||
segments: list[list[float]] = []
|
||
in_seg = False
|
||
seg_start = 0.0
|
||
for t, p in smoothed:
|
||
if p >= threshold and not in_seg:
|
||
seg_start = t
|
||
in_seg = True
|
||
elif p < threshold and in_seg:
|
||
segments.append([seg_start, t])
|
||
in_seg = False
|
||
if in_seg:
|
||
segments.append([seg_start, smoothed[-1][0]])
|
||
# Merge gaps shorter than min_gap_sec (handles brief dips within a phase).
|
||
merged: list[list[float]] = []
|
||
for seg in segments:
|
||
if merged and seg[0] - merged[-1][1] < min_gap_sec:
|
||
merged[-1][1] = seg[1]
|
||
else:
|
||
merged.append([seg[0], seg[1]])
|
||
# Drop phases that are too short to be meaningful.
|
||
phases = [s for s in merged if s[1] - s[0] >= min_phase_sec]
|
||
# Assign names from the phase catalog in order (cycle if more phases than names).
|
||
names = [p["name"] for p in catalog] if catalog else []
|
||
result: list[dict[str, Any]] = []
|
||
for i, (start, end) in enumerate(phases):
|
||
name = names[i % len(names)] if names else f"phase_{i + 1}"
|
||
result.append({"name": name, "start": float(start), "end": float(end)})
|
||
return result
|
||
|
||
def _validate_phase_ranges(
|
||
self,
|
||
ranges: list[dict[str, Any]],
|
||
) -> str | None:
|
||
"""Validate non-overlapping, strictly increasing phase ranges."""
|
||
normalized = sorted(ranges, key=lambda x: (float(x["start"]), float(x["end"])))
|
||
prev_end: float | None = None
|
||
for row in normalized:
|
||
start = float(row["start"])
|
||
end = float(row["end"])
|
||
if end <= start:
|
||
return "invalid_phase_range"
|
||
if prev_end is not None and start < prev_end:
|
||
return "overlapping_phase_ranges"
|
||
prev_end = end
|
||
return None
|
||
|
||
async def async_step_assign_profile_phases_select(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Select profile before opening the phase editor."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
profiles = [
|
||
p
|
||
for p in store.list_profiles()
|
||
if str(p.get("device_type", "")).strip() in ("", manager.device_type)
|
||
]
|
||
if not profiles:
|
||
return self.async_abort(reason="no_profiles_found")
|
||
|
||
errors: dict[str, str] = {}
|
||
if user_input is not None:
|
||
self._phase_assign_profile = user_input["profile"]
|
||
self._phase_assign_mode = "offset_mode"
|
||
self._phase_assign_cycle_id = None
|
||
self._phase_assign_draft = store.get_profile_phase_ranges_for_device(
|
||
self._phase_assign_profile,
|
||
manager.device_type,
|
||
)
|
||
self._phase_assign_edit_index = None
|
||
return await self.async_step_assign_profile_phases()
|
||
|
||
profile_options = [
|
||
selector.SelectOptionDict(value=p["name"], label=p["name"])
|
||
for p in profiles
|
||
]
|
||
|
||
return self.async_show_form(
|
||
step_id="assign_profile_phases_select",
|
||
data_schema=vol.Schema(
|
||
{
|
||
vol.Required("profile"): selector.SelectSelector(
|
||
selector.SelectSelectorConfig(
|
||
options=profile_options,
|
||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||
)
|
||
),
|
||
}
|
||
),
|
||
errors=errors,
|
||
)
|
||
|
||
async def async_step_assign_profile_phases(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Phase assignment editor as a button menu with visualization."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
|
||
profile_name = self._phase_assign_profile
|
||
if not profile_name:
|
||
return await self.async_step_assign_profile_phases_select()
|
||
|
||
draft_sorted = sorted(self._phase_assign_draft, key=lambda x: (float(x["start"]), float(x["end"])))
|
||
summary = self._phase_assignment_summary(draft_sorted)
|
||
envelope = store.get_envelope(profile_name)
|
||
if envelope is None:
|
||
try:
|
||
await store.async_rebuild_envelope(profile_name)
|
||
envelope = store.get_envelope(profile_name)
|
||
except Exception: # pylint: disable=broad-exception-caught
|
||
envelope = None
|
||
timeline_svg = self._phase_assignment_svg_markdown(
|
||
profile_name,
|
||
draft_sorted,
|
||
envelope,
|
||
labels={
|
||
"phase_preview": await self._options_text("phase_preview", "Phase Preview"),
|
||
"no_curve": await self._options_text(
|
||
"phase_preview_no_curve",
|
||
"Average profile curve is not available yet. Run/label more cycles for this profile.",
|
||
),
|
||
"min": await self._options_text("unit_min", "min"),
|
||
"avg_curve": await self._options_text(
|
||
"average_power_curve", "Average Power Curve"
|
||
),
|
||
},
|
||
)
|
||
|
||
has_draft = bool(self._phase_assign_draft)
|
||
menu_options = ["assign_profile_phases_add"]
|
||
if has_draft:
|
||
menu_options.append("assign_profile_phases_edit_select")
|
||
menu_options.append("assign_profile_phases_delete")
|
||
menu_options.append("phase_ranges_clear")
|
||
menu_options.append("assign_profile_phases_auto_detect")
|
||
menu_options.append("phase_ranges_save")
|
||
menu_options.append("menu_back")
|
||
|
||
self._push_menu("assign_profile_phases")
|
||
return self.async_show_menu(
|
||
step_id="assign_profile_phases",
|
||
menu_options=menu_options,
|
||
description_placeholders={
|
||
"profile_name": profile_name,
|
||
"current_ranges": summary,
|
||
"timeline_svg": timeline_svg,
|
||
},
|
||
)
|
||
|
||
async def async_step_phase_ranges_clear(
|
||
self, _user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Menu wrapper: clear draft phase ranges and re-show editor."""
|
||
self._phase_assign_draft = []
|
||
return await self.async_step_assign_profile_phases()
|
||
|
||
async def async_step_phase_ranges_save(
|
||
self, _user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Menu wrapper: commit draft phase ranges; aborts on validation error."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
profile_name = self._phase_assign_profile
|
||
if not profile_name:
|
||
return await self.async_step_assign_profile_phases_select()
|
||
try:
|
||
await store.async_set_profile_phase_ranges(profile_name, self._phase_assign_draft)
|
||
except ValueError:
|
||
return self.async_abort(reason="phase_ranges_invalid")
|
||
manager.notify_update()
|
||
self._phase_assign_profile = None
|
||
self._phase_assign_mode = "offset_mode"
|
||
self._phase_assign_cycle_id = None
|
||
self._phase_assign_draft = []
|
||
self._phase_assign_edit_index = None
|
||
return await self.async_step_manage_profiles()
|
||
|
||
async def async_step_assign_profile_phases_auto_detect(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Show auto-detected phase ranges with an action choice (name & apply, or go back)."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
profile_name = self._phase_assign_profile
|
||
if not profile_name:
|
||
return await self.async_step_assign_profile_phases_select()
|
||
|
||
envelope = store.get_envelope(profile_name)
|
||
if envelope is None:
|
||
try:
|
||
await store.async_rebuild_envelope(profile_name)
|
||
envelope = store.get_envelope(profile_name)
|
||
except Exception: # pylint: disable=broad-exception-caught
|
||
envelope = None
|
||
|
||
avg_points: list[tuple[float, float]] = []
|
||
if envelope and isinstance(envelope.get("avg"), list):
|
||
for pt in envelope["avg"]:
|
||
if isinstance(pt, (list, tuple)) and len(pt) >= 2:
|
||
try:
|
||
avg_points.append((float(pt[0]), float(pt[1])))
|
||
except (TypeError, ValueError):
|
||
pass
|
||
|
||
catalog = store.list_phase_catalog(manager.device_type)
|
||
# Detect once and cache so the naming step can use the same result.
|
||
self._phase_assign_auto_detected = self._auto_detect_phase_ranges(avg_points, catalog)
|
||
detected = self._phase_assign_auto_detected
|
||
|
||
if user_input is not None:
|
||
action = user_input.get("action")
|
||
if action == "name_phases" and detected:
|
||
return await self.async_step_assign_profile_phases_auto_detect_name()
|
||
# "cancel" or no phases found - go back without changes.
|
||
return await self.async_step_assign_profile_phases()
|
||
|
||
svg_labels = {
|
||
"phase_preview": await self._options_text("phase_preview", "Phase Preview"),
|
||
"no_curve": await self._options_text(
|
||
"phase_preview_no_curve",
|
||
"Average profile curve is not available yet. Run/label more cycles for this profile.",
|
||
),
|
||
"min": await self._options_text("unit_min", "min"),
|
||
"avg_curve": await self._options_text("average_power_curve", "Average Power Curve"),
|
||
}
|
||
timeline_svg = self._phase_assignment_svg_markdown(profile_name, detected, envelope, labels=svg_labels)
|
||
|
||
actions = ["name_phases", "cancel"] if detected else ["cancel"]
|
||
return self.async_show_form(
|
||
step_id="assign_profile_phases_auto_detect",
|
||
data_schema=vol.Schema(
|
||
{
|
||
vol.Required("action"): self._translated_select(
|
||
options=actions,
|
||
translation_key="assign_profile_phases_auto_detect_action",
|
||
)
|
||
}
|
||
),
|
||
description_placeholders={
|
||
"profile_name": profile_name,
|
||
"detected_count": str(len(detected)),
|
||
"timeline_svg": timeline_svg,
|
||
},
|
||
)
|
||
|
||
async def async_step_assign_profile_phases_auto_detect_name(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Assign a name to each auto-detected phase range."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
profile_name = self._phase_assign_profile
|
||
if not profile_name or not self._phase_assign_auto_detected:
|
||
return await self.async_step_assign_profile_phases()
|
||
|
||
detected = self._phase_assign_auto_detected
|
||
|
||
catalog = store.list_phase_catalog(manager.device_type)
|
||
phase_options = [
|
||
selector.SelectOptionDict(
|
||
value=p["name"],
|
||
label=f"{p['name']} – {str(p.get('description', ''))[:52]}",
|
||
)
|
||
for p in catalog
|
||
]
|
||
|
||
if user_input is not None:
|
||
named = []
|
||
for i, phase in enumerate(detected):
|
||
name = user_input.get(f"name_{i}", phase["name"])
|
||
named.append({"name": name, "start": phase["start"], "end": phase["end"]})
|
||
self._phase_assign_draft = named
|
||
self._phase_assign_auto_detected = []
|
||
return await self.async_step_assign_profile_phases()
|
||
|
||
schema_dict: dict[Any, Any] = {}
|
||
for i, phase in enumerate(detected):
|
||
start_min = int(phase["start"] / 60)
|
||
end_min = int(phase["end"] / 60)
|
||
schema_dict[
|
||
vol.Required(f"name_{i}", default=phase["name"],
|
||
description={"suggested_value": phase["name"]})
|
||
] = selector.SelectSelector(
|
||
selector.SelectSelectorConfig(
|
||
options=phase_options,
|
||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||
)
|
||
)
|
||
|
||
# Build a summary of time ranges for the description.
|
||
ranges_summary = "\n".join(
|
||
f"- **Phase {i + 1}**: {int(ph['start'] / 60)}–{int(ph['end'] / 60)} min"
|
||
for i, ph in enumerate(detected)
|
||
)
|
||
return self.async_show_form(
|
||
step_id="assign_profile_phases_auto_detect_name",
|
||
data_schema=vol.Schema(schema_dict),
|
||
description_placeholders={
|
||
"profile_name": profile_name,
|
||
"detected_count": str(len(detected)),
|
||
"ranges_summary": ranges_summary,
|
||
},
|
||
)
|
||
|
||
async def async_step_assign_profile_phases_add(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Add a phase range to the current draft."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
|
||
catalog = store.list_phase_catalog(manager.device_type)
|
||
phase_options = [
|
||
selector.SelectOptionDict(
|
||
value=p["name"],
|
||
label=f"{p['name']} - {str(p.get('description', ''))[:52]}",
|
||
)
|
||
for p in catalog
|
||
]
|
||
|
||
default_start = 0
|
||
default_end = 10
|
||
if self._phase_assign_draft:
|
||
draft_sorted = sorted(self._phase_assign_draft, key=lambda x: float(x["end"]))
|
||
default_start = int(float(draft_sorted[-1]["end"]) / 60)
|
||
default_end = default_start + 10
|
||
|
||
errors: dict[str, str] = {}
|
||
if user_input is not None:
|
||
phase_name = user_input.get("phase_name")
|
||
if not phase_name:
|
||
errors["base"] = "incomplete_phase_row"
|
||
else:
|
||
start_sec, end_sec, parse_error = self._parse_phase_range_input(
|
||
user_input,
|
||
)
|
||
if parse_error is not None or start_sec is None or end_sec is None:
|
||
errors["base"] = parse_error or "invalid_phase_range"
|
||
elif end_sec <= start_sec:
|
||
errors["base"] = "invalid_phase_range"
|
||
else:
|
||
updated = [
|
||
*self._phase_assign_draft,
|
||
{"name": phase_name, "start": start_sec, "end": end_sec},
|
||
]
|
||
validation_error = self._validate_phase_ranges(updated)
|
||
if validation_error is not None:
|
||
errors["base"] = validation_error
|
||
else:
|
||
self._phase_assign_draft = sorted(
|
||
updated, key=lambda x: (float(x["start"]), float(x["end"]))
|
||
)
|
||
return await self.async_step_assign_profile_phases()
|
||
|
||
schema_dict: dict[Any, Any] = {
|
||
vol.Required("phase_name"): selector.SelectSelector(
|
||
selector.SelectSelectorConfig(
|
||
options=phase_options,
|
||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||
)
|
||
),
|
||
vol.Required("start_min", default=default_start): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(min=0, max=1440, step=1, mode=selector.NumberSelectorMode.BOX)
|
||
),
|
||
vol.Required("end_min", default=default_end): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(min=0, max=1440, step=1, mode=selector.NumberSelectorMode.BOX)
|
||
),
|
||
}
|
||
|
||
return self.async_show_form(
|
||
step_id="assign_profile_phases_add",
|
||
data_schema=vol.Schema(schema_dict),
|
||
errors=errors,
|
||
)
|
||
|
||
async def async_step_assign_profile_phases_edit_select(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Select a phase range index to edit."""
|
||
if not self._phase_assign_draft:
|
||
return await self.async_step_assign_profile_phases()
|
||
|
||
ranges = sorted(self._phase_assign_draft, key=lambda x: (float(x["start"]), float(x["end"])))
|
||
if user_input is not None:
|
||
self._phase_assign_edit_index = int(user_input["range_index"])
|
||
return await self.async_step_assign_profile_phases_edit()
|
||
|
||
options = []
|
||
for idx, row in enumerate(ranges):
|
||
start_min = int(float(row.get("start", 0.0)) / 60)
|
||
end_min = int(float(row.get("end", 0.0)) / 60)
|
||
options.append(
|
||
selector.SelectOptionDict(
|
||
value=str(idx),
|
||
label=f"{idx + 1}. {row.get('name', '')} ({start_min}-{end_min} min)",
|
||
)
|
||
)
|
||
|
||
return self.async_show_form(
|
||
step_id="assign_profile_phases_edit_select",
|
||
data_schema=vol.Schema(
|
||
{
|
||
vol.Required("range_index"): selector.SelectSelector(
|
||
selector.SelectSelectorConfig(
|
||
options=options,
|
||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||
)
|
||
)
|
||
}
|
||
),
|
||
)
|
||
|
||
async def async_step_assign_profile_phases_edit(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Edit the selected phase range in the current draft."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
|
||
ranges = sorted(self._phase_assign_draft, key=lambda x: (float(x["start"]), float(x["end"])))
|
||
if self._phase_assign_edit_index is None or self._phase_assign_edit_index >= len(ranges):
|
||
return await self.async_step_assign_profile_phases_edit_select()
|
||
|
||
current = ranges[self._phase_assign_edit_index]
|
||
|
||
catalog = store.list_phase_catalog(manager.device_type)
|
||
phase_options = [
|
||
selector.SelectOptionDict(
|
||
value=p["name"],
|
||
label=f"{p['name']} - {str(p.get('description', ''))[:52]}",
|
||
)
|
||
for p in catalog
|
||
]
|
||
|
||
errors: dict[str, str] = {}
|
||
if user_input is not None:
|
||
phase_name = user_input.get("phase_name")
|
||
if not phase_name:
|
||
errors["base"] = "incomplete_phase_row"
|
||
else:
|
||
start_sec, end_sec, parse_error = self._parse_phase_range_input(
|
||
user_input,
|
||
)
|
||
if parse_error is not None or start_sec is None or end_sec is None:
|
||
errors["base"] = parse_error or "invalid_phase_range"
|
||
elif end_sec <= start_sec:
|
||
errors["base"] = "invalid_phase_range"
|
||
else:
|
||
updated = list(ranges)
|
||
updated[self._phase_assign_edit_index] = {
|
||
"name": phase_name,
|
||
"start": start_sec,
|
||
"end": end_sec,
|
||
}
|
||
validation_error = self._validate_phase_ranges(updated)
|
||
if validation_error is not None:
|
||
errors["base"] = validation_error
|
||
else:
|
||
self._phase_assign_draft = sorted(
|
||
updated, key=lambda x: (float(x["start"]), float(x["end"]))
|
||
)
|
||
self._phase_assign_edit_index = None
|
||
return await self.async_step_assign_profile_phases()
|
||
|
||
schema_dict: dict[Any, Any] = {
|
||
vol.Required("phase_name", default=current.get("name", "")): selector.SelectSelector(
|
||
selector.SelectSelectorConfig(
|
||
options=phase_options,
|
||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||
)
|
||
),
|
||
vol.Required("start_min", default=int(float(current.get("start", 0.0)) / 60)): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(min=0, max=1440, step=1, mode=selector.NumberSelectorMode.BOX)
|
||
),
|
||
vol.Required("end_min", default=int(float(current.get("end", 0.0)) / 60)): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(min=0, max=1440, step=1, mode=selector.NumberSelectorMode.BOX)
|
||
),
|
||
}
|
||
|
||
return self.async_show_form(
|
||
step_id="assign_profile_phases_edit",
|
||
data_schema=vol.Schema(schema_dict),
|
||
errors=errors,
|
||
)
|
||
|
||
async def async_step_assign_profile_phases_delete(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Delete one phase range from the current draft."""
|
||
ranges = sorted(self._phase_assign_draft, key=lambda x: (float(x["start"]), float(x["end"])))
|
||
if not ranges:
|
||
return await self.async_step_assign_profile_phases()
|
||
|
||
if user_input is not None:
|
||
idx = int(user_input["range_index"])
|
||
if 0 <= idx < len(ranges):
|
||
del ranges[idx]
|
||
self._phase_assign_draft = ranges
|
||
return await self.async_step_assign_profile_phases()
|
||
|
||
options = []
|
||
for idx, row in enumerate(ranges):
|
||
start_min = int(float(row.get("start", 0.0)) / 60)
|
||
end_min = int(float(row.get("end", 0.0)) / 60)
|
||
options.append(
|
||
selector.SelectOptionDict(
|
||
value=str(idx),
|
||
label=f"{idx + 1}. {row.get('name', '')} ({start_min}-{end_min} min)",
|
||
)
|
||
)
|
||
|
||
return self.async_show_form(
|
||
step_id="assign_profile_phases_delete",
|
||
data_schema=vol.Schema(
|
||
{
|
||
vol.Required("range_index"): selector.SelectSelector(
|
||
selector.SelectSelectorConfig(
|
||
options=options,
|
||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||
)
|
||
)
|
||
}
|
||
),
|
||
)
|
||
|
||
async def async_step_cleanup_profile(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Select a profile to clean up (via graph)."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
profiles = store.list_profiles()
|
||
|
||
if not profiles:
|
||
return self.async_abort(reason="no_profiles_found")
|
||
|
||
if user_input is not None:
|
||
self._selected_profile = user_input["profile"]
|
||
return await self.async_step_cleanup_select()
|
||
|
||
options = []
|
||
profile_option_fmt = await self._options_text(
|
||
"profile_option_fmt", "{name} ({count} cycles, ~{duration}m avg)"
|
||
)
|
||
for p in profiles:
|
||
count = p["cycle_count"]
|
||
duration_min = int(p["avg_duration"] / 60) if p["avg_duration"] else 0
|
||
label = profile_option_fmt.format(
|
||
name=p["name"], count=count, duration=duration_min
|
||
)
|
||
options.append(selector.SelectOptionDict(value=p["name"], label=label))
|
||
|
||
return self.async_show_form(
|
||
step_id="cleanup_profile",
|
||
data_schema=vol.Schema(
|
||
{
|
||
vol.Required("profile"): selector.SelectSelector(
|
||
selector.SelectSelectorConfig(
|
||
options=options, mode=selector.SelectSelectorMode.DROPDOWN
|
||
)
|
||
)
|
||
}
|
||
),
|
||
)
|
||
|
||
async def async_step_cleanup_select(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Show graph and select cycles to delete."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
|
||
if user_input is not None:
|
||
# Delete selected cycles
|
||
cycles_to_delete = user_input.get("cycles_to_delete", [])
|
||
count = 0
|
||
for cycle_id in cycles_to_delete:
|
||
await store.delete_cycle(cycle_id)
|
||
count += 1
|
||
|
||
manager.notify_update()
|
||
# Return to manage profiles
|
||
return self.async_create_entry(
|
||
title="",
|
||
data=dict(self.config_entry.options),
|
||
description_placeholders={
|
||
"info": (
|
||
await self._options_text(
|
||
"deleted_cycles_from_profile",
|
||
"Deleted {count} cycles from {profile}.",
|
||
)
|
||
).format(count=count, profile=self._selected_profile),
|
||
},
|
||
)
|
||
|
||
# Generate SVG
|
||
ts = int(time.time())
|
||
stats_dir = self.hass.config.path("www", "ha_washdata", "profiles")
|
||
await self.hass.async_add_executor_job(
|
||
lambda: os.makedirs(stats_dir, exist_ok=True)
|
||
)
|
||
|
||
safe_name = slugify(self._selected_profile)
|
||
# Generate SVG with ALL cycles for this profile (outliers included)
|
||
# Returns (svg_string, cycle_metadata_map) where metadata contains colors
|
||
# Run in executor to avoid blocking loop
|
||
svg_content, cycle_colors = await self.hass.async_add_executor_job(
|
||
store.generate_profile_spaghetti_svg,
|
||
self._selected_profile,
|
||
await self._options_text("overview_suffix", "Overview"),
|
||
)
|
||
|
||
if not svg_content:
|
||
return self.async_abort(
|
||
reason="no_cycles_found",
|
||
description_placeholders={
|
||
"info": await self._options_text(
|
||
"not_enough_data_graph", "Not enough data to generate graph."
|
||
)
|
||
},
|
||
)
|
||
|
||
file_path = f"{stats_dir}/cleanup_{safe_name}.svg"
|
||
|
||
def write_svg(path=file_path, content=svg_content):
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
f.write(content)
|
||
|
||
await self.hass.async_add_executor_job(write_svg)
|
||
graph_url = f"/local/ha_washdata/profiles/cleanup_{safe_name}.svg?v={ts}"
|
||
|
||
# Get cycles for selection
|
||
# We need to list ALL cycles for this profile
|
||
all_cycles = store.get_past_cycles()
|
||
profile_cycles = [
|
||
c for c in all_cycles if c.get("profile_name") == self._selected_profile
|
||
]
|
||
|
||
#Sort by start time descending
|
||
profile_cycles.sort(key=lambda x: x["start_time"], reverse=True)
|
||
|
||
# Map hex colors to emojis for easier identification
|
||
hex_to_emoji = {
|
||
"#e6194b": "🔴", # Red
|
||
"#3cb44b": "🟢", # Green
|
||
"#ffe119": "🟡", # Yellow
|
||
"#4363d8": "🔵", # Blue
|
||
"#f58231": "🟠", # Orange
|
||
"#911eb4": "🟣", # Purple
|
||
"#42d4f4": "🔵", # Cyan
|
||
"#f032e6": "🟣", # Magenta
|
||
"#bfef45": "🟢", # Lime
|
||
"#fabed4": "🌸", # Pink
|
||
"#469990": "teal", # Teal
|
||
"#dcbeff": "🟣", # Lavender
|
||
"#9A6324": "🟤", # Brown
|
||
"#fffac8": "⚪", # Beige
|
||
"#800000": "🔴", # Maroon
|
||
"#aaffc3": "🟢", # Mint
|
||
"#808000": "🟤", # Olive
|
||
"#ffd8b1": "🟠", # Apricot
|
||
"#000075": "🔵", # Navy
|
||
"#a9a9a9": "⚪", # Grey
|
||
}
|
||
|
||
options = []
|
||
for c in profile_cycles:
|
||
dt = dt_util.parse_datetime(c["start_time"])
|
||
start = dt_util.as_local(dt).strftime("%Y-%m-%d %H:%M") if dt else c["start_time"]
|
||
duration_min = int(c.get("manual_duration", c["duration"]) / 60)
|
||
status = c.get("status", "completed")
|
||
|
||
# Status icon
|
||
status_icon = (
|
||
"✓"
|
||
if status in ("completed", "force_stopped")
|
||
else "⚠" if status == "resumed" else "✗"
|
||
)
|
||
|
||
# Graph Color
|
||
color_hex = cycle_colors.get(c["id"])
|
||
color_emoji = hex_to_emoji.get(color_hex, "⚫") if color_hex else ""
|
||
|
||
# Add energy if available to help identify
|
||
energy = ""
|
||
if "total_energy_kwh" in c:
|
||
energy = f" | {c['total_energy_kwh']:.3f} kWh"
|
||
|
||
label = f"{color_emoji} {status_icon} {start} - {duration_min}m{energy}"
|
||
options.append(selector.SelectOptionDict(value=c["id"], label=label))
|
||
|
||
return self.async_show_form(
|
||
step_id="cleanup_select",
|
||
data_schema=vol.Schema(
|
||
{
|
||
vol.Optional("cycles_to_delete"): selector.SelectSelector(
|
||
selector.SelectSelectorConfig(
|
||
options=options,
|
||
mode=selector.SelectSelectorMode.LIST,
|
||
multiple=True,
|
||
)
|
||
)
|
||
}
|
||
),
|
||
description_placeholders={
|
||
"graph_url": graph_url,
|
||
"profile_name": self._selected_profile or "",
|
||
},
|
||
)
|
||
|
||
async def async_step_profile_stats(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Show detailed profile statistics with graphs."""
|
||
if user_input is not None:
|
||
# Back to manage profiles
|
||
return await self.async_step_manage_profiles()
|
||
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
|
||
# Ensure stats directory exists
|
||
stats_dir = self.hass.config.path("www", "ha_washdata", "profiles")
|
||
await self.hass.async_add_executor_job(
|
||
lambda: os.makedirs(stats_dir, exist_ok=True)
|
||
)
|
||
|
||
profiles = store.list_profiles()
|
||
sections = []
|
||
ts = int(time.time())
|
||
|
||
# Get all cycles to find last run
|
||
cycles = store.get_past_cycles()
|
||
|
||
for p in profiles:
|
||
name = p["name"]
|
||
|
||
# FORCE REFRESH: Rebuild envelope to ensure data is fresh
|
||
# This calculates energy, consistency, etc.
|
||
await store.async_rebuild_envelope(name)
|
||
|
||
# Read profile again after rebuild so table values match refreshed stats.
|
||
refreshed = store.get_profile(name) or p
|
||
|
||
safe_name = slugify(name)
|
||
count = int(refreshed.get("cycle_count", p.get("cycle_count", 0)))
|
||
avg_raw = float(refreshed.get("avg_duration") or 0)
|
||
min_raw = float(refreshed.get("min_duration") or 0)
|
||
max_raw = float(refreshed.get("max_duration") or 0)
|
||
avg = int(avg_raw / 60) if avg_raw else 0
|
||
mn = int(min_raw / 60) if min_raw else 0
|
||
mx = int(max_raw / 60) if max_raw else 0
|
||
|
||
# Get envelope for advanced stats
|
||
envelope = store.get_envelope(name)
|
||
# Retrieve scalar stats
|
||
kwh = f"{envelope.get('avg_energy', 0):.2f}" if envelope else "-"
|
||
# Calculate Total Energy (Avg * Count)
|
||
total_kwh = "-"
|
||
if envelope and envelope.get('avg_energy') is not None:
|
||
t_kwh = envelope.get('avg_energy', 0) * count
|
||
total_kwh = f"{t_kwh:.2f}"
|
||
|
||
std_dev = envelope.get("duration_std_dev", 0) if envelope else 0
|
||
consistency = f"±{int(std_dev / 60)}m" if std_dev > 0 else "-"
|
||
|
||
# Find last run
|
||
last_run = "-"
|
||
p_cycles = [c for c in cycles if c.get("profile_name") == name]
|
||
if p_cycles:
|
||
last_c = max(p_cycles, key=lambda x: x["start_time"])
|
||
dt = last_c["start_time"].split("T")[0]
|
||
last_run = dt
|
||
|
||
# Generate and Write SVG
|
||
# Offload to executor to prevent blocking
|
||
svg_content = await self.hass.async_add_executor_job(
|
||
store.generate_profile_svg, name
|
||
)
|
||
graph_markdown = ""
|
||
if svg_content:
|
||
file_path = f"{stats_dir}/profile_{safe_name}.svg"
|
||
|
||
def write_svg(path=file_path, content=svg_content):
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
f.write(content)
|
||
|
||
await self.hass.async_add_executor_job(write_svg)
|
||
graph_markdown = (
|
||
f""
|
||
)
|
||
|
||
# Build Per-Profile Section
|
||
# Headers: Count | Avg | Min | Max | Energy | Consistency | Last Run
|
||
# New: Energy (Avg) | Energy (Total)
|
||
table_header = (
|
||
"| "
|
||
+ await self._options_text("tbl_count", "Count")
|
||
+ " | "
|
||
+ await self._options_text("tbl_avg", "Avg")
|
||
+ " | "
|
||
+ await self._options_text("tbl_min", "Min")
|
||
+ " | "
|
||
+ await self._options_text("tbl_max", "Max")
|
||
+ " | "
|
||
+ await self._options_text("tbl_energy_avg", "Energy (Avg)")
|
||
+ " | "
|
||
+ await self._options_text("tbl_energy_total", "Energy (Total)")
|
||
+ " | "
|
||
+ await self._options_text("tbl_consistency", "Consist.")
|
||
+ " | "
|
||
+ await self._options_text("tbl_last_run", "Last Run")
|
||
+ " |"
|
||
)
|
||
table_sep = "| --- | --- | --- | --- | --- | --- | --- | --- |"
|
||
table_row = (
|
||
f"| {count} | {avg}m | {mn}m | {mx}m | {kwh} kWh | {total_kwh} kWh "
|
||
f"| {consistency} | {last_run} |"
|
||
)
|
||
|
||
legend = (
|
||
"> **"
|
||
+ await self._options_text("graph_legend_title", "Graph Legend")
|
||
+ "**: "
|
||
+ await self._options_text(
|
||
"graph_legend_body",
|
||
"The blue band represents the minimum and maximum power draw range observed. The line shows the average power curve.",
|
||
)
|
||
)
|
||
|
||
section = (
|
||
f"## {name}\n{table_header}\n{table_sep}\n{table_row}\n\n"
|
||
f"{graph_markdown}\n\n{legend}"
|
||
)
|
||
sections.append(section)
|
||
|
||
content = "\n\n---\n\n".join(sections) if sections else "No profiles found."
|
||
if not sections:
|
||
content = await self._options_text("no_profiles_found", "No profiles found.")
|
||
|
||
return self.async_show_form(
|
||
step_id="profile_stats",
|
||
data_schema=vol.Schema({}),
|
||
# Key 'stats_table' must match the key in translations/strings (description)
|
||
description_placeholders={"stats_table": content},
|
||
)
|
||
|
||
async def async_step_create_profile(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Create a new profile."""
|
||
errors = {}
|
||
|
||
if user_input is not None:
|
||
name = user_input["profile_name"].strip()
|
||
reference_cycle = user_input.get("reference_cycle")
|
||
|
||
if not name:
|
||
errors["profile_name"] = "empty_name"
|
||
else:
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
manual_duration_mins = user_input.get("manual_duration")
|
||
avg_duration = None
|
||
if manual_duration_mins and float(manual_duration_mins) > 0:
|
||
avg_duration = float(manual_duration_mins) * 60.0
|
||
|
||
try:
|
||
await manager.profile_store.create_profile_standalone(
|
||
name,
|
||
reference_cycle if reference_cycle != "none" else None,
|
||
avg_duration=avg_duration,
|
||
)
|
||
manager.notify_update()
|
||
return self.async_create_entry(
|
||
title="", data=dict(self.config_entry.options)
|
||
)
|
||
except ValueError:
|
||
errors["base"] = "profile_exists"
|
||
|
||
# Build cycle options for reference
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
cycles = store.get_past_cycles()[-20:]
|
||
|
||
cycle_options = [
|
||
selector.SelectOptionDict(
|
||
value="none",
|
||
label=await self._selector_text(
|
||
"no_reference_cycle", "(No reference cycle)"
|
||
),
|
||
)
|
||
]
|
||
for c in reversed(cycles):
|
||
dt = dt_util.parse_datetime(c["start_time"])
|
||
start = dt_util.as_local(dt).strftime("%Y-%m-%d %H:%M") if dt else c["start_time"]
|
||
duration_min = int(c.get("manual_duration", c["duration"]) / 60)
|
||
prof = c.get("profile_name") or await self._selector_text("unlabeled", "(Unlabeled)")
|
||
label = f"{start} - {duration_min}m - {prof}"
|
||
cycle_options.append(selector.SelectOptionDict(value=c["id"], label=label))
|
||
|
||
return self.async_show_form(
|
||
step_id="create_profile",
|
||
data_schema=vol.Schema(
|
||
{
|
||
vol.Required("profile_name"): str,
|
||
vol.Optional(
|
||
"reference_cycle", default="none"
|
||
): selector.SelectSelector(
|
||
selector.SelectSelectorConfig(
|
||
options=cycle_options,
|
||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||
)
|
||
),
|
||
vol.Optional("manual_duration"): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=0,
|
||
max=480,
|
||
mode=selector.NumberSelectorMode.BOX,
|
||
unit_of_measurement="min",
|
||
)
|
||
),
|
||
}
|
||
),
|
||
errors=errors,
|
||
)
|
||
|
||
async def async_step_edit_profile(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Select profile to edit/rename."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
profiles = store.list_profiles()
|
||
|
||
if not profiles:
|
||
return self.async_abort(reason="no_profiles_found")
|
||
|
||
if user_input is not None:
|
||
self._selected_profile = user_input["profile"]
|
||
return await self.async_step_rename_profile()
|
||
|
||
# Build profile options
|
||
options = []
|
||
for p in profiles:
|
||
count = p["cycle_count"]
|
||
duration_min = int(p["avg_duration"] / 60) if p["avg_duration"] else 0
|
||
label = f"{p['name']} ({count} cycles, ~{duration_min}m avg)"
|
||
options.append(selector.SelectOptionDict(value=p["name"], label=label))
|
||
|
||
return self.async_show_form(
|
||
step_id="edit_profile",
|
||
data_schema=vol.Schema(
|
||
{
|
||
vol.Required("profile"): selector.SelectSelector(
|
||
selector.SelectSelectorConfig(
|
||
options=options, mode=selector.SelectSelectorMode.DROPDOWN
|
||
)
|
||
)
|
||
}
|
||
),
|
||
)
|
||
|
||
async def async_step_rename_profile(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Edit profile settings (Name and Duration)."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
errors = {}
|
||
|
||
# Get current profile data
|
||
profiles = manager.profile_store.list_profiles()
|
||
current_data = next(
|
||
(p for p in profiles if p["name"] == self._selected_profile), None
|
||
)
|
||
current_duration_mins = (
|
||
int(current_data["avg_duration"] / 60)
|
||
if current_data and current_data["avg_duration"]
|
||
else 0
|
||
)
|
||
|
||
if user_input is not None:
|
||
new_name = user_input["new_name"].strip()
|
||
manual_duration_mins = user_input.get("manual_duration")
|
||
|
||
if not new_name:
|
||
errors["new_name"] = "empty_name"
|
||
else:
|
||
avg_duration = None
|
||
if manual_duration_mins is not None and manual_duration_mins > 0:
|
||
avg_duration = float(manual_duration_mins) * 60.0
|
||
|
||
try:
|
||
await manager.profile_store.update_profile(
|
||
self._selected_profile, new_name, avg_duration=avg_duration
|
||
)
|
||
manager.notify_update()
|
||
return self.async_create_entry(
|
||
title="", data=dict(self.config_entry.options)
|
||
)
|
||
except ValueError:
|
||
errors["base"] = "rename_failed"
|
||
|
||
return self.async_show_form(
|
||
step_id="rename_profile",
|
||
data_schema=vol.Schema(
|
||
{
|
||
vol.Required("new_name", default=self._selected_profile): str,
|
||
vol.Optional(
|
||
"manual_duration", default=current_duration_mins
|
||
): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=0,
|
||
max=480,
|
||
unit_of_measurement="min",
|
||
mode=selector.NumberSelectorMode.BOX,
|
||
)
|
||
),
|
||
}
|
||
),
|
||
errors=errors,
|
||
description_placeholders={"current_name": self._selected_profile or ""},
|
||
)
|
||
|
||
async def async_step_delete_profile_select(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Select profile to delete."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
profiles = store.list_profiles()
|
||
|
||
if not profiles:
|
||
return self.async_abort(reason="no_profiles_found")
|
||
|
||
if user_input is not None:
|
||
self._selected_profile = user_input["profile"]
|
||
return await self.async_step_delete_profile_confirm()
|
||
|
||
# Build profile options
|
||
options = []
|
||
for p in profiles:
|
||
count = p["cycle_count"]
|
||
duration_min = int(p["avg_duration"] / 60) if p["avg_duration"] else 0
|
||
label = f"{p['name']} ({count} cycles, ~{duration_min}m avg)"
|
||
options.append(selector.SelectOptionDict(value=p["name"], label=label))
|
||
|
||
return self.async_show_form(
|
||
step_id="delete_profile_select",
|
||
data_schema=vol.Schema(
|
||
{
|
||
vol.Required("profile"): selector.SelectSelector(
|
||
selector.SelectSelectorConfig(
|
||
options=options, mode=selector.SelectSelectorMode.DROPDOWN
|
||
)
|
||
)
|
||
}
|
||
),
|
||
)
|
||
|
||
async def async_step_delete_profile_confirm(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Confirm profile deletion."""
|
||
if user_input is not None:
|
||
unlabel = user_input["unlabel_cycles"]
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
await manager.profile_store.delete_profile(
|
||
self._selected_profile, unlabel
|
||
)
|
||
manager.notify_update()
|
||
return self.async_create_entry(
|
||
title="", data=dict(self.config_entry.options)
|
||
)
|
||
|
||
return self.async_show_form(
|
||
step_id="delete_profile_confirm",
|
||
data_schema=vol.Schema(
|
||
{vol.Required("unlabel_cycles", default=True): bool}
|
||
),
|
||
description_placeholders={
|
||
"profile_name": self._selected_profile or "",
|
||
},
|
||
)
|
||
|
||
async def async_step_auto_label_cycles(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Auto-label all cycles retroactively."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
|
||
# Check if there are any profiles to match against
|
||
profiles = store.list_profiles()
|
||
if not profiles:
|
||
return self.async_abort(reason="no_profiles_for_matching")
|
||
|
||
total_count = len(store.get_past_cycles())
|
||
|
||
if total_count == 0:
|
||
return self.async_abort(reason="no_cycles_found")
|
||
|
||
if user_input is not None:
|
||
threshold = user_input["confidence_threshold"]
|
||
# Always pass overwrite=True as per user request to relabel everything
|
||
await store.auto_label_cycles(threshold, overwrite=True)
|
||
manager.notify_update()
|
||
return self.async_create_entry(
|
||
title="",
|
||
data=dict(self.config_entry.options),
|
||
)
|
||
|
||
return self.async_show_form(
|
||
step_id="auto_label_cycles",
|
||
data_schema=vol.Schema(
|
||
{
|
||
vol.Required(
|
||
"confidence_threshold", default=0.75
|
||
): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=0.50,
|
||
max=0.95,
|
||
step=0.05,
|
||
mode=selector.NumberSelectorMode.BOX,
|
||
)
|
||
)
|
||
}
|
||
),
|
||
description_placeholders={
|
||
"total_count": str(total_count),
|
||
# Leading blank line so markdown renders the bulleted list
|
||
# instead of gluing the profile names onto the "Profiles:" label.
|
||
"profiles": "\n\n"
|
||
+ "\n".join(f"- {_escape_markdown(p['name'])}" for p in profiles),
|
||
},
|
||
)
|
||
|
||
async def async_step_select_cycle_to_label(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Select a cycle to label."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
|
||
# Get last 20 cycles
|
||
cycles = store.get_past_cycles()[-20:]
|
||
|
||
# Build readable options with status
|
||
options = []
|
||
for c in reversed(cycles):
|
||
dt = dt_util.parse_datetime(c["start_time"])
|
||
start = dt_util.as_local(dt).strftime("%Y-%m-%d %H:%M") if dt else c["start_time"]
|
||
duration_min = int(c.get("manual_duration", c["duration"]) / 60)
|
||
prof = c.get("profile_name") or "Unlabeled"
|
||
status = c.get("status", "completed")
|
||
# ✓ = completed/force_stopped (natural end), ⚠ = resumed, ✗ = interrupted (user stopped)
|
||
status_icon = (
|
||
"✓"
|
||
if status in ("completed", "force_stopped")
|
||
else "⚠" if status == "resumed" else "✗"
|
||
)
|
||
label = f"[{status_icon}] {start} - {duration_min}m - {prof}"
|
||
options.append(selector.SelectOptionDict(value=c["id"], label=label))
|
||
|
||
if not options:
|
||
return self.async_abort(reason="no_cycles_found")
|
||
|
||
if user_input is not None:
|
||
self._selected_cycle_id = user_input["cycle_id"]
|
||
return await self.async_step_label_cycle()
|
||
|
||
return self.async_show_form(
|
||
step_id="select_cycle_to_label",
|
||
data_schema=vol.Schema(
|
||
{
|
||
vol.Required("cycle_id"): selector.SelectSelector(
|
||
selector.SelectSelectorConfig(
|
||
options=options, mode=selector.SelectSelectorMode.DROPDOWN
|
||
)
|
||
)
|
||
}
|
||
),
|
||
)
|
||
|
||
async def async_step_select_cycle_to_delete(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Select a cycle to delete."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
|
||
# Get last 20 cycles
|
||
cycles = store.get_past_cycles()[-20:]
|
||
|
||
# Build readable options with status
|
||
options = []
|
||
for c in reversed(cycles):
|
||
dt = dt_util.parse_datetime(c["start_time"])
|
||
start = dt_util.as_local(dt).strftime("%Y-%m-%d %H:%M") if dt else c["start_time"]
|
||
duration_min = int(c.get("manual_duration", c["duration"]) / 60)
|
||
prof = c.get("profile_name") or "Unlabeled"
|
||
status = c.get("status", "completed")
|
||
# ✓ = completed/force_stopped (natural end), ⚠ = resumed, ✗ = interrupted (user stopped)
|
||
status_icon = (
|
||
"✓"
|
||
if status in ("completed", "force_stopped")
|
||
else "⚠" if status == "resumed" else "✗"
|
||
)
|
||
label = f"[{status_icon}] {start} - {duration_min}m - {prof}"
|
||
options.append(selector.SelectOptionDict(value=c["id"], label=label))
|
||
|
||
if not options:
|
||
return self.async_abort(reason="no_cycles_found")
|
||
|
||
if user_input is not None:
|
||
cycle_id = user_input["cycle_id"]
|
||
await manager.profile_store.delete_cycle(cycle_id)
|
||
# await manager.profile_store.async_save() # Handled inside delete_cycle now
|
||
manager.notify_update()
|
||
return self.async_create_entry(
|
||
title="", data=dict(self.config_entry.options)
|
||
)
|
||
|
||
return self.async_show_form(
|
||
step_id="select_cycle_to_delete",
|
||
data_schema=vol.Schema(
|
||
{
|
||
vol.Required("cycle_id"): selector.SelectSelector(
|
||
selector.SelectSelectorConfig(
|
||
options=options, mode=selector.SelectSelectorMode.DROPDOWN
|
||
)
|
||
)
|
||
}
|
||
),
|
||
)
|
||
|
||
async def async_step_label_cycle(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Assign profile to the selected cycle."""
|
||
errors = {}
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
|
||
if user_input is not None:
|
||
profile_choice = user_input["profile_name"]
|
||
|
||
# Handle "create new" option
|
||
if profile_choice == "__create_new__":
|
||
new_name = user_input.get("new_profile_name", "").strip()
|
||
if not new_name:
|
||
errors["new_profile_name"] = "empty_name"
|
||
else:
|
||
try:
|
||
# Create profile from this cycle
|
||
await store.create_profile(new_name, self._selected_cycle_id)
|
||
manager.notify_update()
|
||
return self.async_create_entry(
|
||
title="", data=dict(self.config_entry.options)
|
||
)
|
||
except ValueError:
|
||
errors["base"] = "profile_exists"
|
||
elif profile_choice == "__remove_label__":
|
||
# Remove label from cycle
|
||
await store.assign_profile_to_cycle(self._selected_cycle_id, None)
|
||
manager.notify_update()
|
||
return self.async_create_entry(
|
||
title="", data=dict(self.config_entry.options)
|
||
)
|
||
else:
|
||
# Assign existing profile
|
||
try:
|
||
await store.assign_profile_to_cycle(
|
||
self._selected_cycle_id, profile_choice
|
||
)
|
||
manager.notify_update()
|
||
return self.async_create_entry(
|
||
title="", data=dict(self.config_entry.options)
|
||
)
|
||
except ValueError:
|
||
errors["base"] = "assignment_failed"
|
||
|
||
# Build profile dropdown options
|
||
profiles = store.list_profiles()
|
||
profile_options = [
|
||
selector.SelectOptionDict(
|
||
value="__create_new__",
|
||
label=await self._selector_text(
|
||
"create_new_profile", "Create New Profile"
|
||
),
|
||
),
|
||
selector.SelectOptionDict(
|
||
value="__remove_label__",
|
||
label=await self._selector_text("remove_label", "Remove Label"),
|
||
),
|
||
]
|
||
for p in profiles:
|
||
count = p["cycle_count"]
|
||
duration_min = int(p["avg_duration"] / 60) if p["avg_duration"] else 0
|
||
label = (
|
||
await self._options_text(
|
||
"profile_option_short_fmt", "{name} ({count} cycles, ~{duration}m)"
|
||
)
|
||
).format(name=p["name"], count=count, duration=duration_min)
|
||
profile_options.append(
|
||
selector.SelectOptionDict(value=p["name"], label=label)
|
||
)
|
||
|
||
# Get cycle info for display
|
||
cycle = next(
|
||
(
|
||
c
|
||
for c in store.get_past_cycles()
|
||
if c["id"] == self._selected_cycle_id
|
||
),
|
||
None,
|
||
)
|
||
cycle_info = ""
|
||
if cycle:
|
||
dt = dt_util.parse_datetime(cycle["start_time"])
|
||
start = dt_util.as_local(dt).strftime("%Y-%m-%d %H:%M") if dt else cycle["start_time"]
|
||
duration_min = int(cycle["duration"] / 60)
|
||
current_label = cycle.get("profile") or await self._selector_text(
|
||
"unlabeled", "(Unlabeled)"
|
||
)
|
||
cycle_info = (
|
||
await self._options_text(
|
||
"cycle_info_fmt",
|
||
"Cycle: {start}, {duration}m, Current: {label}",
|
||
)
|
||
).format(start=start, duration=duration_min, label=current_label)
|
||
|
||
schema: dict[Any, Any] = {
|
||
vol.Required(
|
||
"profile_name",
|
||
default="__create_new__" if not profiles else profiles[0]["name"],
|
||
): selector.SelectSelector(
|
||
selector.SelectSelectorConfig(
|
||
options=profile_options, mode=selector.SelectSelectorMode.DROPDOWN
|
||
)
|
||
)
|
||
}
|
||
|
||
# Add new profile name field (shown when "__create_new__" selected)
|
||
schema[vol.Optional("new_profile_name")] = str
|
||
|
||
return self.async_show_form(
|
||
step_id="label_cycle",
|
||
data_schema=vol.Schema(schema),
|
||
errors=errors,
|
||
description_placeholders={"cycle_info": cycle_info},
|
||
)
|
||
|
||
async def async_step_post_process(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Handle post-processing options."""
|
||
if user_input is not None:
|
||
choice = user_input["time_range"]
|
||
user_gap = user_input["gap_seconds"]
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
|
||
hours = 999999 if choice >= 999999 else int(choice)
|
||
|
||
# Use async_run_maintenance to ensure envelopes are rebuilt after merging
|
||
stats = await manager.profile_store.async_run_maintenance(
|
||
lookback_hours=hours, gap_seconds=user_gap
|
||
)
|
||
count_merged = stats.get("merged_cycles", 0)
|
||
count_split = stats.get("split_cycles", 0)
|
||
msg = f"Merged: {count_merged}, Split: {count_split}"
|
||
|
||
return self.async_create_entry(
|
||
title="",
|
||
data=dict(self.config_entry.options),
|
||
description_placeholders={"count": msg},
|
||
)
|
||
|
||
|
||
|
||
return self.async_show_form(
|
||
step_id="post_process",
|
||
data_schema=vol.Schema(
|
||
{
|
||
vol.Required("time_range", default=24): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=1,
|
||
max=9999,
|
||
unit_of_measurement="h",
|
||
mode=selector.NumberSelectorMode.BOX,
|
||
)
|
||
),
|
||
}
|
||
),
|
||
)
|
||
|
||
|
||
|
||
async def async_step_wipe_history(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Wipe all stored cycles and profiles for this device (for testing)."""
|
||
if user_input is not None:
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
|
||
# Clear all cycles and profiles
|
||
await manager.profile_store.clear_all_data()
|
||
manager.notify_update()
|
||
|
||
return self.async_create_entry(
|
||
title="",
|
||
data=dict(self.config_entry.options),
|
||
description_placeholders={"info": "History cleared"},
|
||
)
|
||
|
||
return self.async_show_form(
|
||
step_id="wipe_history",
|
||
data_schema=vol.Schema({}),
|
||
)
|
||
|
||
async def async_step_record_cycle(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Handle Record Mode as a button menu with state-aware options."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
|
||
is_recording = manager.recorder.is_recording
|
||
has_last_run = manager.recorder.last_run is not None
|
||
|
||
menu_options: list[str] = []
|
||
if is_recording:
|
||
menu_options.append("record_refresh")
|
||
menu_options.append("record_stop")
|
||
status = "ACTIVE"
|
||
duration = int(manager.recorder.current_duration)
|
||
samples = len(getattr(manager.recorder, "_buffer", []))
|
||
else:
|
||
menu_options.append("record_start")
|
||
status = "STOPPED"
|
||
duration = 0
|
||
samples = 0
|
||
|
||
if has_last_run:
|
||
menu_options.append("record_process")
|
||
menu_options.append("record_discard")
|
||
|
||
last_run = manager.recorder.last_run
|
||
samples = len(last_run.get("data", []))
|
||
try:
|
||
start = dt_util.parse_datetime(last_run["start_time"])
|
||
end = dt_util.parse_datetime(last_run["end_time"])
|
||
duration = int((end - start).total_seconds())
|
||
status = "READY TO PROCESS"
|
||
except Exception: # pylint: disable=broad-exception-caught
|
||
pass
|
||
|
||
menu_options.append("menu_back")
|
||
self._push_menu("record_cycle")
|
||
return self.async_show_menu(
|
||
step_id="record_cycle",
|
||
menu_options=menu_options,
|
||
description_placeholders={
|
||
"status": status,
|
||
"duration": str(duration),
|
||
"samples": str(samples),
|
||
},
|
||
)
|
||
|
||
async def async_step_record_refresh(
|
||
self, _user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Menu wrapper: re-display recorder status."""
|
||
return await self.async_step_record_cycle()
|
||
|
||
async def async_step_record_discard(
|
||
self, _user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Menu wrapper: discard the last recording and re-display status."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
await manager.recorder.clear_last_run()
|
||
return await self.async_step_record_cycle()
|
||
|
||
async def async_step_record_start(
|
||
self, _user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Start a recording."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
await manager.async_start_recording()
|
||
return await self.async_step_record_cycle()
|
||
|
||
async def async_step_record_stop(
|
||
self, _user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Stop a recording."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
await manager.async_stop_recording()
|
||
# Automatically go to process step? Or back to menu?
|
||
# User might want to immediately process.
|
||
return await self.async_step_record_process()
|
||
|
||
async def async_step_record_process(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Process (Trim & Save) the recorded cycle."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
last_run = manager.recorder.last_run
|
||
|
||
if not last_run:
|
||
return self.async_abort(reason="no_recording_found")
|
||
|
||
data = last_run.get("data", [])
|
||
|
||
if user_input is not None:
|
||
save_mode = user_input["save_mode"]
|
||
|
||
if save_mode == "discard":
|
||
await manager.recorder.clear_last_run()
|
||
return self.async_create_entry(title="", data=dict(self.config_entry.options))
|
||
|
||
head_trim = user_input["head_trim"]
|
||
tail_trim = user_input["tail_trim"]
|
||
profile_name = user_input["profile_name"].strip()
|
||
|
||
# GET ACTUAL RECORDING BOUNDS
|
||
rec_start_str = manager.recorder.last_run.get("start_time")
|
||
rec_end_str = manager.recorder.last_run.get("end_time")
|
||
|
||
# APPLY TRIMS
|
||
# Convert data to timestamps
|
||
parsed = []
|
||
for t_str, p in data:
|
||
t = dt_util.parse_datetime(t_str)
|
||
if t:
|
||
parsed.append((t.timestamp(), p))
|
||
|
||
if not parsed and not rec_start_str:
|
||
return self.async_abort(reason="empty_recording")
|
||
|
||
# Use recording bounds if available, fallback to data bounds
|
||
data_start_ts = parsed[0][0] if parsed else 0
|
||
data_end_ts = parsed[-1][0] if parsed else 0
|
||
|
||
start_ts = dt_util.parse_datetime(rec_start_str).timestamp() if rec_start_str else data_start_ts
|
||
end_ts = dt_util.parse_datetime(rec_end_str).timestamp() if rec_end_str else data_end_ts
|
||
|
||
# Ensure bounds cover data
|
||
start_ts = min(start_ts, data_start_ts) if parsed else start_ts
|
||
end_ts = max(end_ts, data_end_ts) if parsed else end_ts
|
||
|
||
# Calculate cut points
|
||
keep_start = start_ts + head_trim
|
||
keep_end = end_ts - tail_trim
|
||
|
||
trimmed_data = []
|
||
for t, p in parsed:
|
||
if t >= keep_start and t <= keep_end:
|
||
t_iso = dt_util.utc_from_timestamp(t).isoformat()
|
||
trimmed_data.append((t_iso, p))
|
||
|
||
duration = max(0.0, (keep_end - keep_start))
|
||
|
||
cycle_data = {
|
||
"id": f"rec_{int(time.time())}",
|
||
"start_time": dt_util.utc_from_timestamp(keep_start).isoformat(),
|
||
"end_time": dt_util.utc_from_timestamp(keep_end).isoformat(),
|
||
"duration": duration,
|
||
"profile_name": profile_name,
|
||
"power_data": trimmed_data,
|
||
"status": "completed",
|
||
"meta": {"source": "recorder", "original_samples": len(data)}
|
||
}
|
||
|
||
if save_mode == "new_profile":
|
||
await manager.profile_store.create_profile_standalone(profile_name)
|
||
await manager.profile_store.async_add_cycle(cycle_data)
|
||
await manager.profile_store.async_rebuild_envelope(profile_name)
|
||
else:
|
||
# Add to existing
|
||
await manager.profile_store.async_add_cycle(cycle_data)
|
||
await manager.profile_store.async_rebuild_envelope(profile_name)
|
||
|
||
await manager.profile_store.async_save()
|
||
await manager.recorder.clear_last_run()
|
||
|
||
return self.async_create_entry(title="", data=dict(self.config_entry.options))
|
||
|
||
# Calculate suggestions
|
||
rec_start_str = manager.recorder.last_run.get("start_time")
|
||
rec_end_str = manager.recorder.last_run.get("end_time")
|
||
|
||
rec_start = dt_util.parse_datetime(rec_start_str) if rec_start_str else None
|
||
rec_end = dt_util.parse_datetime(rec_end_str) if rec_end_str else None
|
||
|
||
head_suggest, tail_suggest, sampling_rate = manager.recorder.get_trim_suggestions(
|
||
data, recording_start=rec_start, recording_end=rec_end
|
||
)
|
||
|
||
# Generate Preview Graph
|
||
ts = int(time.time())
|
||
stats_dir = self.hass.config.path("www", "ha_washdata", "preview")
|
||
await self.hass.async_add_executor_job(
|
||
lambda: os.makedirs(stats_dir, exist_ok=True)
|
||
)
|
||
|
||
svg_content = await self.hass.async_add_executor_job(
|
||
manager.profile_store.generate_preview_svg,
|
||
data,
|
||
head_suggest,
|
||
tail_suggest,
|
||
await self._options_text("recording_preview", "Recording Preview"),
|
||
await self._options_text("trim_start", "Trim Start"),
|
||
await self._options_text("trim_end", "Trim End"),
|
||
)
|
||
|
||
graph_url = ""
|
||
if svg_content:
|
||
fname = f"preview_{ts}.svg"
|
||
path = f"{stats_dir}/{fname}"
|
||
|
||
def write_svg():
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
f.write(svg_content)
|
||
|
||
await self.hass.async_add_executor_job(write_svg)
|
||
graph_url = f"/local/ha_washdata/preview/{fname}?v={ts}"
|
||
|
||
# Profile options
|
||
profiles = list(manager.profile_store.get_profiles().keys())
|
||
profiles.sort(key=profile_sort_key)
|
||
|
||
schema = {
|
||
vol.Required("head_trim", default=head_suggest): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(min=0, max=300000, step=0.1, mode=selector.NumberSelectorMode.BOX, unit_of_measurement="s")
|
||
),
|
||
vol.Required("tail_trim", default=tail_suggest): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(min=0, max=300000, step=0.1, mode=selector.NumberSelectorMode.BOX, unit_of_measurement="s")
|
||
),
|
||
vol.Required("save_mode", default="existing_profile"): selector.SelectSelector(
|
||
selector.SelectSelectorConfig(
|
||
options=["new_profile", "existing_profile", "discard"],
|
||
mode=selector.SelectSelectorMode.LIST,
|
||
translation_key="record_process_save_mode",
|
||
)
|
||
),
|
||
# Profile name optional if discarding? No dynamic update, so required logic applies.
|
||
# We can't easily make it conditional. User has to pick something or we allow empty?
|
||
# If user picks "Create New", they need a name. "Existing", they pick one.
|
||
# "Discard", name is ignored.
|
||
# Providing a text input that doubles as selector is tricky.
|
||
# We used SelectSelector with custom_value=True.
|
||
vol.Required("profile_name"): selector.SelectSelector(
|
||
selector.SelectSelectorConfig(
|
||
options=profiles,
|
||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||
custom_value=True
|
||
)
|
||
)
|
||
}
|
||
|
||
# Calculate duration for display
|
||
duration_val = 0.0
|
||
if rec_start and rec_end:
|
||
duration_val = (rec_end - rec_start).total_seconds()
|
||
|
||
return self.async_show_form(
|
||
step_id="record_process",
|
||
data_schema=vol.Schema(schema),
|
||
description_placeholders={
|
||
"samples": str(len(data)),
|
||
"duration": f"{duration_val:.1f}",
|
||
"graph_url": graph_url,
|
||
"sampling_rate": str(sampling_rate)
|
||
}
|
||
)
|
||
|
||
async def async_step_learning_feedbacks(
|
||
self, _user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Landing menu for pending feedback: Review / Dismiss All / Back."""
|
||
manager = self.hass.data[DOMAIN][self._config_entry.entry_id]
|
||
profile_store = manager.profile_store
|
||
pending = profile_store.get_pending_feedback()
|
||
|
||
if not pending:
|
||
self._push_menu("learning_feedbacks_empty")
|
||
return self.async_show_menu(
|
||
step_id="learning_feedbacks_empty",
|
||
menu_options=["menu_back"],
|
||
)
|
||
|
||
sorted_pending = sorted(
|
||
pending.values(),
|
||
key=lambda x: x.get("created_at", ""),
|
||
reverse=True,
|
||
)
|
||
preview_rows: list[str] = []
|
||
for item in sorted_pending:
|
||
prof = item.get("detected_profile", "Unknown")
|
||
safe_prof = html.escape(str(prof))
|
||
conf = item.get("confidence", 0.0)
|
||
created_raw = item.get("created_at", "")
|
||
t_str = str(created_raw)
|
||
if created_raw:
|
||
try:
|
||
dt = dt_util.parse_datetime(str(created_raw))
|
||
if dt:
|
||
t_str = dt_util.as_local(dt).strftime("%d %b %H:%M")
|
||
except Exception: # pylint: disable=broad-exception-caught
|
||
pass
|
||
preview_rows.append(
|
||
f"<tr><td><b>{safe_prof}</b></td>"
|
||
f'<td align="center">{int(conf * 100)}%</td>'
|
||
f'<td align="center">{html.escape(t_str)}</td></tr>'
|
||
)
|
||
|
||
detected_program = await self._options_text(
|
||
"table_detected_program", "Detected Program"
|
||
)
|
||
confidence_label = await self._options_text(
|
||
"table_confidence", "Confidence"
|
||
)
|
||
reported_label = await self._options_text("table_reported", "Reported")
|
||
preview = (
|
||
'<table width="100%">'
|
||
'<tr>'
|
||
f'<th align="left">{html.escape(detected_program)}</th>'
|
||
f'<th width="20%" align="center">{html.escape(confidence_label)}</th>'
|
||
f'<th width="30%" align="center">{html.escape(reported_label)}</th>'
|
||
'</tr>'
|
||
+ "".join(preview_rows)
|
||
+ '</table>'
|
||
)
|
||
|
||
self._push_menu("learning_feedbacks")
|
||
return self.async_show_menu(
|
||
step_id="learning_feedbacks",
|
||
menu_options=[
|
||
"learning_feedbacks_pick",
|
||
"learning_feedbacks_dismiss_all",
|
||
"menu_back",
|
||
],
|
||
description_placeholders={
|
||
"count": str(len(pending)),
|
||
"pending_table": preview,
|
||
},
|
||
)
|
||
|
||
async def async_step_learning_feedbacks_pick(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Step: pick a specific pending feedback to review."""
|
||
if user_input is not None:
|
||
cycle_id = user_input.get("selected_feedback")
|
||
if cycle_id:
|
||
self._selected_cycle_id = cycle_id
|
||
return await self.async_step_resolve_feedback()
|
||
return await self.async_step_learning_feedbacks()
|
||
|
||
manager = self.hass.data[DOMAIN][self._config_entry.entry_id]
|
||
profile_store = manager.profile_store
|
||
pending = profile_store.get_pending_feedback()
|
||
if not pending:
|
||
return await self.async_step_learning_feedbacks()
|
||
|
||
sorted_pending = sorted(
|
||
pending.values(),
|
||
key=lambda x: x.get("created_at", ""),
|
||
reverse=True,
|
||
)
|
||
options = []
|
||
for item in sorted_pending:
|
||
cid = item.get("cycle_id", "unknown")
|
||
prof = item.get("detected_profile", "Unknown")
|
||
conf = item.get("confidence", 0.0)
|
||
created_raw = item.get("created_at", "")
|
||
t_str = str(created_raw)
|
||
if created_raw:
|
||
try:
|
||
dt = dt_util.parse_datetime(str(created_raw))
|
||
if dt:
|
||
t_str = dt_util.as_local(dt).strftime("%d %b %H:%M")
|
||
except Exception: # pylint: disable=broad-exception-caught
|
||
pass
|
||
label = f"{prof} ({int(conf * 100)}%) - {t_str}"
|
||
options.append(selector.SelectOptionDict(value=cid, label=label))
|
||
|
||
return self.async_show_form(
|
||
step_id="learning_feedbacks_pick",
|
||
data_schema=vol.Schema(
|
||
{
|
||
vol.Required("selected_feedback"): selector.SelectSelector(
|
||
selector.SelectSelectorConfig(
|
||
options=options,
|
||
mode=selector.SelectSelectorMode.LIST,
|
||
)
|
||
),
|
||
}
|
||
),
|
||
)
|
||
|
||
async def async_step_learning_feedbacks_dismiss_all(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Step: Confirm bulk dismissal of all pending learning feedbacks."""
|
||
manager = self.hass.data[DOMAIN][self._config_entry.entry_id]
|
||
profile_store = manager.profile_store
|
||
pending = profile_store.get_pending_feedback()
|
||
count = len(pending)
|
||
|
||
if user_input is not None:
|
||
if (
|
||
user_input.get("confirm_dismiss_all")
|
||
and count > 0
|
||
and hasattr(manager, "learning_manager")
|
||
):
|
||
# Snapshot IDs first since submit mutates the pending dict
|
||
cycle_ids = list(pending.keys())
|
||
for cid in cycle_ids:
|
||
await manager.learning_manager.async_submit_cycle_feedback(
|
||
cycle_id=cid,
|
||
user_confirmed=False,
|
||
corrected_profile=None,
|
||
corrected_duration=None,
|
||
dismiss=True,
|
||
)
|
||
return await self.async_step_init()
|
||
|
||
return self.async_show_form(
|
||
step_id="learning_feedbacks_dismiss_all",
|
||
data_schema=vol.Schema(
|
||
{
|
||
vol.Optional(
|
||
"confirm_dismiss_all", default=False
|
||
): selector.BooleanSelector(),
|
||
}
|
||
),
|
||
description_placeholders={"count": str(count)},
|
||
)
|
||
|
||
async def async_step_learning_feedbacks_empty(
|
||
self, _user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Step: Handle empty feedback list (go back)."""
|
||
return await self.async_step_init()
|
||
|
||
async def async_step_resolve_feedback(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Step: Resolve a specific feedback request."""
|
||
manager = self.hass.data[DOMAIN][self._config_entry.entry_id]
|
||
profile_store = manager.profile_store
|
||
pending = profile_store.get_pending_feedback()
|
||
|
||
cycle_id = self._selected_cycle_id
|
||
if not cycle_id or cycle_id not in pending:
|
||
return self.async_abort(reason="feedback_not_found")
|
||
|
||
item = pending[cycle_id]
|
||
|
||
if user_input is not None:
|
||
# Process submission based on action
|
||
action = user_input.get("action", "confirm")
|
||
|
||
if action == "confirm":
|
||
# User confirms the detection was correct
|
||
if hasattr(manager, "learning_manager"):
|
||
await manager.learning_manager.async_submit_cycle_feedback(
|
||
cycle_id=cycle_id,
|
||
user_confirmed=True,
|
||
corrected_profile=None,
|
||
corrected_duration=None,
|
||
dismiss=False,
|
||
)
|
||
elif action == "correct":
|
||
# User wants to correct the profile/duration
|
||
new_profile = user_input.get("corrected_profile")
|
||
new_duration = user_input.get("corrected_duration")
|
||
if hasattr(manager, "learning_manager"):
|
||
await manager.learning_manager.async_submit_cycle_feedback(
|
||
cycle_id=cycle_id,
|
||
user_confirmed=False,
|
||
corrected_profile=new_profile,
|
||
corrected_duration=int(new_duration * 60) if new_duration else None,
|
||
dismiss=False,
|
||
)
|
||
elif action == "ignore":
|
||
# User wants to dismiss/ignore this feedback request
|
||
if hasattr(manager, "learning_manager"):
|
||
await manager.learning_manager.async_submit_cycle_feedback(
|
||
cycle_id=cycle_id,
|
||
user_confirmed=False,
|
||
corrected_profile=None,
|
||
corrected_duration=None,
|
||
dismiss=True,
|
||
)
|
||
elif action == "delete":
|
||
# User wants to delete the cycle
|
||
await manager.profile_store.delete_cycle(cycle_id)
|
||
|
||
# Return to main menu
|
||
return await self.async_step_init()
|
||
|
||
# Prepare form data
|
||
detected_profile = item.get("detected_profile", "Unknown")
|
||
confidence = item.get("confidence", 0.0)
|
||
est = item.get("estimated_duration", 0)
|
||
act = item.get("actual_duration", 0)
|
||
|
||
# Get the actual cycle to access power data for visualization
|
||
cycles = profile_store.get_past_cycles()
|
||
actual_cycle = next((c for c in cycles if c.get("id") == cycle_id), None)
|
||
|
||
# Build description with visualization
|
||
comparison_img = ""
|
||
candidates_table = ""
|
||
safe_cycle_id = slugify(cycle_id)
|
||
ts = int(time.time())
|
||
|
||
# 1. Generate a single combined SVG overlaying all profiles vs. actual cycle
|
||
if actual_cycle and actual_cycle.get("power_data"):
|
||
try:
|
||
stats_dir = self.hass.config.path("www", "ha_washdata", "feedback")
|
||
await self.hass.async_add_executor_job(
|
||
lambda: os.makedirs(stats_dir, exist_ok=True)
|
||
)
|
||
|
||
all_profile_names = list(profile_store.get_profiles().keys())
|
||
all_profile_names.sort(key=profile_sort_key)
|
||
if detected_profile in all_profile_names:
|
||
all_profile_names.remove(detected_profile)
|
||
all_profile_names.insert(0, detected_profile)
|
||
|
||
svg_content = await self.hass.async_add_executor_job(
|
||
profile_store.generate_feedback_multi_profile_svg,
|
||
all_profile_names,
|
||
detected_profile,
|
||
actual_cycle,
|
||
"Profile Comparison",
|
||
"This cycle (actual)",
|
||
)
|
||
|
||
if svg_content:
|
||
fpath = f"{stats_dir}/comparison_{safe_cycle_id}.svg"
|
||
|
||
def _write(p=fpath, c=svg_content):
|
||
with open(p, "w", encoding="utf-8") as f:
|
||
f.write(c)
|
||
|
||
await self.hass.async_add_executor_job(_write)
|
||
url = f"/local/ha_washdata/feedback/comparison_{safe_cycle_id}.svg?v={ts}"
|
||
comparison_img = f"\n\n"
|
||
|
||
except Exception as e: # pylint: disable=broad-exception-caught
|
||
_LOGGER.warning("Failed to generate multi-profile SVG: %s", e)
|
||
|
||
# 2. Add candidates table if ranking data available
|
||
ranking = item.get("ranking", [])
|
||
if ranking:
|
||
# Reconstruct MatchResult-like object for the summary method
|
||
match_result_data = {
|
||
"ranking": ranking,
|
||
"expected_duration": est,
|
||
}
|
||
|
||
# Create a minimal object that has the expected attributes
|
||
class _MatchResultLike:
|
||
def __init__(self, data):
|
||
self.ranking = data.get("ranking", [])
|
||
self.expected_duration = data.get("expected_duration", 0)
|
||
|
||
match_like = _MatchResultLike(match_result_data)
|
||
|
||
candidates = await self.hass.async_add_executor_job(
|
||
profile_store.get_match_candidates_summary, match_like, 3
|
||
)
|
||
|
||
if candidates:
|
||
candidates_table = (
|
||
"### Top Candidates\n"
|
||
"| Profile | Confidence | MAE | Correlation | Duration Match |\n"
|
||
"| --- | --- | --- | --- | --- |\n"
|
||
)
|
||
|
||
for cand in candidates:
|
||
name = cand.get("profile_name", "Unknown")
|
||
conf = cand.get("confidence_pct", 0)
|
||
mae = cand.get("mae", 0)
|
||
corr = cand.get("correlation", 0)
|
||
dur_ratio = cand.get("duration_ratio", 0)
|
||
dur_sign = "+" if dur_ratio >= 0 else ""
|
||
|
||
candidates_table += (
|
||
f"| {name} | {conf}% | {mae} | {corr} | {dur_sign}{dur_ratio}% |\n"
|
||
)
|
||
|
||
candidates_table += "\n"
|
||
|
||
profiles = list(profile_store.get_profiles().keys())
|
||
profiles.sort(key=profile_sort_key)
|
||
|
||
return self.async_show_form(
|
||
step_id="resolve_feedback",
|
||
description_placeholders={
|
||
"comparison_data": (
|
||
comparison_img
|
||
+ candidates_table
|
||
+ f"\n**Detected Profile**: {detected_profile} ({int(confidence * 100)}%)\n"
|
||
f"**Estimated Duration**: {int(est / 60)} min\n"
|
||
f"**Actual Duration**: {int(act / 60)} min"
|
||
),
|
||
"comparison_img": comparison_img,
|
||
"candidates_table": candidates_table,
|
||
"detected_profile": detected_profile,
|
||
"confidence_pct": str(int(confidence * 100)),
|
||
"est_duration_min": str(int(est / 60)),
|
||
"act_duration_min": str(int(act / 60)),
|
||
},
|
||
data_schema=vol.Schema(
|
||
{
|
||
vol.Required("action", default="confirm"): self._translated_select(
|
||
options=["confirm", "correct", "ignore", "delete"],
|
||
translation_key="resolve_feedback_action",
|
||
mode=selector.SelectSelectorMode.LIST,
|
||
),
|
||
vol.Optional("corrected_profile", default=detected_profile): selector.SelectSelector(
|
||
selector.SelectSelectorConfig(
|
||
options=profiles,
|
||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||
)
|
||
),
|
||
vol.Optional("corrected_duration", default=int(act/60)): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=0, max=600, unit_of_measurement="min", mode=selector.NumberSelectorMode.BOX
|
||
)
|
||
),
|
||
}
|
||
),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Cycle Trimmer
|
||
# ------------------------------------------------------------------
|
||
|
||
def _wallclock_to_offset(
|
||
self, time_str: str, cycle_start_dt: datetime, cycle_end_dt: datetime
|
||
) -> float | None:
|
||
"""Parse an HH:MM:SS time string and return offset seconds from cycle_start_dt.
|
||
|
||
Returns None if the time cannot be parsed or falls outside the recorded cycle.
|
||
"""
|
||
try:
|
||
local_start = dt_util.as_local(cycle_start_dt)
|
||
parts = time_str.split(":")
|
||
h = int(parts[0])
|
||
m = int(parts[1])
|
||
s = int(parts[2]) if len(parts) > 2 else 0
|
||
candidate = local_start.replace(hour=h, minute=m, second=s, microsecond=0)
|
||
if candidate < local_start:
|
||
candidate += timedelta(days=1)
|
||
offset_seconds = (candidate - local_start).total_seconds()
|
||
cycle_duration = (cycle_end_dt - cycle_start_dt).total_seconds()
|
||
if offset_seconds < 0 or offset_seconds > cycle_duration:
|
||
return None
|
||
return offset_seconds
|
||
except (ValueError, IndexError, AttributeError):
|
||
return None
|
||
|
||
def _trim_cycle_svg_markdown(
|
||
self,
|
||
p_data: list[tuple[float, float]],
|
||
trim_start_s: float,
|
||
trim_end_s: float,
|
||
title: str = "Cycle Trim Preview",
|
||
label_min: str = "min",
|
||
no_data_text: str = "No power data available for this cycle.",
|
||
cycle_start_dt: datetime | None = None,
|
||
) -> str:
|
||
"""Render the cycle power curve with trim-region overlays as a base64 SVG."""
|
||
|
||
def esc(text: object) -> str:
|
||
return (
|
||
str(text)
|
||
.replace("&", "&")
|
||
.replace("<", "<")
|
||
.replace(">", ">")
|
||
)
|
||
|
||
width = 1360
|
||
plot_left = 60
|
||
plot_right = 60
|
||
plot_top = 110
|
||
plot_height = 300
|
||
plot_width = width - plot_left - plot_right
|
||
axis_y = plot_top + plot_height + 52
|
||
axis_y2 = axis_y + 36
|
||
total_height = (axis_y2 + 32) if cycle_start_dt is not None else (axis_y + 40)
|
||
|
||
if not p_data or len(p_data) < 2:
|
||
empty_svg = (
|
||
f"<svg xmlns='http://www.w3.org/2000/svg' width='{width}' height='140'"
|
||
f" viewBox='0 0 {width} 140' style='background-color:#1c1c1c;'>"
|
||
"<rect x='0' y='0' width='100%' height='100%' fill='#1c1c1c'/>"
|
||
f"<text x='22' y='42' font-family='sans-serif' font-size='24'"
|
||
f" fill='#f3f4f6' font-weight='bold'>{esc(title)}</text>"
|
||
f"<text x='22' y='90' font-family='sans-serif' font-size='18' fill='#94a3b8'>"
|
||
f"{esc(no_data_text)}"
|
||
"</text></svg>"
|
||
)
|
||
encoded = base64.b64encode(empty_svg.encode("utf-8")).decode("ascii")
|
||
return f""
|
||
|
||
max_time_s = p_data[-1][0]
|
||
max_power = max((p for _, p in p_data), default=0.0)
|
||
max_power = max(10.0, max_power) * 1.08
|
||
max_time_s = max(1.0, max_time_s)
|
||
|
||
def to_x(t: float) -> float:
|
||
return plot_left + (max(0.0, min(max_time_s, t)) / max_time_s) * plot_width
|
||
|
||
def to_y(p: float) -> float:
|
||
return plot_top + plot_height - (max(0.0, min(max_power, p)) / max_power) * plot_height
|
||
|
||
def pick_grid_interval(total_min_val: int) -> int:
|
||
label_w_px = 130
|
||
min_iv = (label_w_px / plot_width) * total_min_val
|
||
for candidate in (1, 2, 5, 10, 15, 20, 30, 60):
|
||
if candidate > min_iv:
|
||
return candidate
|
||
return 60
|
||
|
||
parts: list[str] = [
|
||
f"<svg xmlns='http://www.w3.org/2000/svg' width='{width}' height='{total_height}'"
|
||
f" viewBox='0 0 {width} {total_height}'>",
|
||
"<rect x='0' y='0' width='100%' height='100%' fill='#1c1c1c'/>",
|
||
f"<rect x='{plot_left}' y='{plot_top}' width='{plot_width}' height='{plot_height}'"
|
||
" fill='#111111' stroke='#444' stroke-width='2' rx='8'/>",
|
||
]
|
||
|
||
# Title
|
||
parts.append(
|
||
f"<text x='22' y='52' font-family='sans-serif' font-size='32'"
|
||
f" fill='#f3f4f6' font-weight='bold'>{esc(title)}</text>"
|
||
)
|
||
|
||
# Time grid
|
||
total_min = int(max_time_s / 60)
|
||
grid_interval = pick_grid_interval(total_min)
|
||
for tick_min in range(0, total_min + 1, grid_interval):
|
||
tx = to_x(tick_min * 60.0)
|
||
parts.append(
|
||
f"<line x1='{tx:.2f}' y1='{plot_top}' x2='{tx:.2f}'"
|
||
f" y2='{plot_top + plot_height}' stroke='#2a2a2a' stroke-width='1'/>"
|
||
)
|
||
if tx <= plot_left + 50:
|
||
t_anchor, t_x = "start", plot_left
|
||
elif tx >= width - plot_right - 50:
|
||
t_anchor, t_x = "end", width - plot_right
|
||
else:
|
||
t_anchor, t_x = "middle", tx
|
||
if cycle_start_dt is not None:
|
||
tick_dt = dt_util.as_local(cycle_start_dt + timedelta(seconds=tick_min * 60))
|
||
parts.append(
|
||
f"<text x='{t_x:.2f}' y='{axis_y}' font-family='sans-serif'"
|
||
f" font-size='30' fill='#94a3b8' text-anchor='{t_anchor}'>"
|
||
f"{esc(tick_dt.strftime('%H:%M'))}</text>"
|
||
)
|
||
parts.append(
|
||
f"<text x='{t_x:.2f}' y='{axis_y2}' font-family='sans-serif'"
|
||
f" font-size='24' fill='#64748b' text-anchor='{t_anchor}'>"
|
||
f"+{tick_min} {esc(label_min)}</text>"
|
||
)
|
||
else:
|
||
parts.append(
|
||
f"<text x='{t_x:.2f}' y='{axis_y}' font-family='sans-serif'"
|
||
f" font-size='30' fill='#64748b' text-anchor='{t_anchor}'>"
|
||
f"{tick_min} {esc(label_min)}</text>"
|
||
)
|
||
|
||
# Trimmed-region shading (before start and after end)
|
||
x_start = to_x(trim_start_s)
|
||
x_end = to_x(trim_end_s)
|
||
if x_start > plot_left:
|
||
parts.append(
|
||
f"<rect x='{plot_left}' y='{plot_top}' width='{x_start - plot_left:.2f}'"
|
||
f" height='{plot_height}' fill='#000000' fill-opacity='0.55' rx='8'/>"
|
||
)
|
||
if x_end < plot_left + plot_width:
|
||
parts.append(
|
||
f"<rect x='{x_end:.2f}' y='{plot_top}'"
|
||
f" width='{plot_left + plot_width - x_end:.2f}' height='{plot_height}'"
|
||
" fill='#000000' fill-opacity='0.55'/>"
|
||
)
|
||
|
||
# Power curve polyline
|
||
pts = " ".join(f"{to_x(t):.2f},{to_y(p):.2f}" for t, p in p_data)
|
||
parts.append(
|
||
f"<polyline points='{pts}' fill='none'"
|
||
" stroke='#60a5fa' stroke-width='2' stroke-linejoin='round' stroke-linecap='round'/>"
|
||
)
|
||
|
||
# Trim marker lines and labels
|
||
_label_offset_y = plot_top - 10
|
||
_label_half_w = 105
|
||
for t_s, color, key in (
|
||
(trim_start_s, "#22c55e", "S"),
|
||
(trim_end_s, "#ef4444", "E"),
|
||
):
|
||
tx = to_x(t_s)
|
||
if cycle_start_dt is not None:
|
||
t_dt = dt_util.as_local(cycle_start_dt + timedelta(seconds=t_s))
|
||
t_label = t_dt.strftime("%H:%M:%S")
|
||
else:
|
||
t_label = f"{int(t_s / 60)}:{int(t_s % 60):02d}"
|
||
label_x = max(plot_left + _label_half_w, min(width - plot_right - _label_half_w, tx))
|
||
parts.append(
|
||
f"<line x1='{tx:.2f}' y1='{plot_top}' x2='{tx:.2f}'"
|
||
f" y2='{plot_top + plot_height}' stroke='{color}'"
|
||
" stroke-width='2.5' stroke-dasharray='8 4'/>"
|
||
)
|
||
parts.append(
|
||
f"<text x='{label_x:.2f}' y='{_label_offset_y}' font-family='sans-serif'"
|
||
f" font-size='28' fill='{color}' text-anchor='middle'"
|
||
f" font-weight='bold'>{key}: {esc(t_label)}</text>"
|
||
)
|
||
|
||
parts.append("</svg>")
|
||
svg_str = "\n".join(parts)
|
||
encoded = base64.b64encode(svg_str.encode("utf-8")).decode("ascii")
|
||
return f""
|
||
|
||
async def async_step_trim_cycle_select(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Select a cycle to trim."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
|
||
cycles = store.get_past_cycles()[-20:]
|
||
options = []
|
||
for c in reversed(cycles):
|
||
dt = dt_util.parse_datetime(c["start_time"])
|
||
start = dt_util.as_local(dt).strftime("%Y-%m-%d %H:%M") if dt else c["start_time"]
|
||
duration_min = int(c.get("manual_duration", c["duration"]) / 60)
|
||
prof = c.get("profile_name") or await self._selector_text("unlabeled", "Unlabeled")
|
||
status = c.get("status", "completed")
|
||
status_icon = (
|
||
"✓" if status in ("completed", "force_stopped")
|
||
else "⚠" if status == "resumed"
|
||
else "✗"
|
||
)
|
||
label = f"[{status_icon}] {start} - {duration_min}m - {prof}"
|
||
options.append(selector.SelectOptionDict(value=c["id"], label=label))
|
||
|
||
if not options:
|
||
return self.async_abort(reason="no_cycles_found")
|
||
|
||
if user_input is not None:
|
||
cycle_id = user_input["cycle_id"]
|
||
p_data = store.get_cycle_power_data(cycle_id)
|
||
if not p_data:
|
||
return self.async_abort(reason="no_power_data")
|
||
self._trim_cycle_id = cycle_id
|
||
self._trim_start_s = 0.0
|
||
self._trim_end_s = p_data[-1][0]
|
||
cycle_obj = next((c for c in store.get_past_cycles() if c["id"] == cycle_id), None)
|
||
if cycle_obj and cycle_obj.get("start_time"):
|
||
self._trim_cycle_start_dt = dt_util.parse_datetime(cycle_obj["start_time"])
|
||
else:
|
||
self._trim_cycle_start_dt = None
|
||
return await self.async_step_trim_cycle()
|
||
|
||
return self.async_show_form(
|
||
step_id="trim_cycle_select",
|
||
data_schema=vol.Schema(
|
||
{
|
||
vol.Required("cycle_id"): selector.SelectSelector(
|
||
selector.SelectSelectorConfig(
|
||
options=options,
|
||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||
)
|
||
)
|
||
}
|
||
),
|
||
)
|
||
|
||
async def async_step_trim_cycle(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Main cycle trimmer editor."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
|
||
if not self._trim_cycle_id:
|
||
return await self.async_step_trim_cycle_select()
|
||
|
||
p_data = store.get_cycle_power_data(self._trim_cycle_id)
|
||
if not p_data:
|
||
return self.async_abort(reason="no_power_data")
|
||
|
||
full_end_s = p_data[-1][0]
|
||
errors: dict[str, str] = {}
|
||
|
||
if user_input is not None:
|
||
action = user_input.get("action")
|
||
if action == "set_start":
|
||
return await self.async_step_trim_cycle_start()
|
||
if action == "set_end":
|
||
return await self.async_step_trim_cycle_end()
|
||
if action == "reset":
|
||
self._trim_start_s = 0.0
|
||
self._trim_end_s = full_end_s
|
||
if action == "apply":
|
||
if self._trim_start_s >= self._trim_end_s:
|
||
errors["base"] = "trim_range_invalid"
|
||
else:
|
||
ok = await store.trim_cycle_power_data(
|
||
self._trim_cycle_id,
|
||
self._trim_start_s,
|
||
self._trim_end_s,
|
||
)
|
||
if ok:
|
||
manager.notify_update()
|
||
self._trim_cycle_id = None
|
||
return await self.async_step_manage_cycles()
|
||
errors["base"] = "trim_failed"
|
||
if action == "cancel":
|
||
self._trim_cycle_id = None
|
||
return await self.async_step_manage_cycles()
|
||
|
||
label_min = await self._options_text("unit_min", "min")
|
||
svg = self._trim_cycle_svg_markdown(
|
||
p_data,
|
||
self._trim_start_s,
|
||
self._trim_end_s,
|
||
title=await self._options_text("trim_cycle_preview_title", "Cycle Trim Preview"),
|
||
label_min=label_min,
|
||
no_data_text=await self._options_text(
|
||
"trim_cycle_preview_no_data", "No power data available for this cycle."
|
||
),
|
||
cycle_start_dt=self._trim_cycle_start_dt,
|
||
)
|
||
|
||
kept_s = max(0.0, self._trim_end_s - self._trim_start_s)
|
||
kept_min = int(kept_s / 60)
|
||
kept_sec = int(kept_s % 60)
|
||
kept_suffix = await self._options_text("trim_cycle_preview_kept_suffix", "kept")
|
||
if self._trim_cycle_start_dt is not None:
|
||
start_dt = dt_util.as_local(
|
||
self._trim_cycle_start_dt + timedelta(seconds=self._trim_start_s)
|
||
)
|
||
end_dt = dt_util.as_local(
|
||
self._trim_cycle_start_dt + timedelta(seconds=self._trim_end_s)
|
||
)
|
||
summary = (
|
||
f"{start_dt.strftime('%H:%M:%S')} - {end_dt.strftime('%H:%M:%S')}"
|
||
f" ({kept_min}:{kept_sec:02d} {kept_suffix})"
|
||
)
|
||
else:
|
||
start_min = int(self._trim_start_s / 60)
|
||
start_sec = int(self._trim_start_s % 60)
|
||
end_min = int(self._trim_end_s / 60)
|
||
end_sec = int(self._trim_end_s % 60)
|
||
summary = (
|
||
f"{start_min}:{start_sec:02d} - {end_min}:{end_sec:02d}"
|
||
f" ({kept_min}:{kept_sec:02d} {kept_suffix})"
|
||
)
|
||
|
||
return self.async_show_form(
|
||
step_id="trim_cycle",
|
||
data_schema=vol.Schema(
|
||
{
|
||
vol.Required("action"): self._translated_select(
|
||
options=["set_start", "set_end", "reset", "apply", "cancel"],
|
||
translation_key="trim_cycle_action",
|
||
)
|
||
}
|
||
),
|
||
errors=errors,
|
||
description_placeholders={
|
||
"trim_summary": summary,
|
||
"trim_svg": svg,
|
||
},
|
||
)
|
||
|
||
async def async_step_trim_cycle_start(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Set the trim start point."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
|
||
if not self._trim_cycle_id:
|
||
return await self.async_step_trim_cycle_select()
|
||
|
||
p_data = store.get_cycle_power_data(self._trim_cycle_id)
|
||
if not p_data:
|
||
return self.async_abort(reason="no_power_data")
|
||
|
||
full_end_s = p_data[-1][0]
|
||
cycle_start_dt = self._trim_cycle_start_dt
|
||
errors: dict[str, str] = {}
|
||
|
||
if user_input is not None:
|
||
time_str = (user_input.get("trim_start_time") or "").strip()
|
||
if time_str and cycle_start_dt is not None:
|
||
cycle_end_dt = cycle_start_dt + timedelta(seconds=full_end_s)
|
||
new_start_s = self._wallclock_to_offset(time_str, cycle_start_dt, cycle_end_dt)
|
||
if new_start_s is None:
|
||
errors["trim_start_time"] = "trim_range_invalid"
|
||
elif new_start_s >= self._trim_end_s:
|
||
errors["trim_start_time"] = "trim_range_invalid"
|
||
else:
|
||
self._trim_start_s = new_start_s
|
||
return await self.async_step_trim_cycle()
|
||
else:
|
||
min_val = user_input.get("trim_start_min")
|
||
if min_val is not None:
|
||
new_start_s = float(min_val) * 60.0
|
||
if new_start_s >= self._trim_end_s:
|
||
errors["trim_start_min"] = "trim_range_invalid"
|
||
else:
|
||
self._trim_start_s = new_start_s
|
||
return await self.async_step_trim_cycle()
|
||
|
||
if cycle_start_dt is not None:
|
||
local_start = dt_util.as_local(cycle_start_dt)
|
||
local_end = dt_util.as_local(cycle_start_dt + timedelta(seconds=full_end_s))
|
||
current_wallclock = dt_util.as_local(
|
||
cycle_start_dt + timedelta(seconds=self._trim_start_s)
|
||
).strftime("%H:%M:%S")
|
||
current_offset_min = int(self._trim_start_s / 60)
|
||
data_schema = vol.Schema({
|
||
vol.Required("trim_start_time", default=current_wallclock): selector.TimeSelector(),
|
||
})
|
||
desc_placeholders = {
|
||
"cycle_start_wallclock": local_start.strftime("%H:%M:%S"),
|
||
"cycle_end_wallclock": local_end.strftime("%H:%M:%S"),
|
||
"current_wallclock": current_wallclock,
|
||
"current_offset_min": str(current_offset_min),
|
||
}
|
||
else:
|
||
total_min = max(1, int(full_end_s / 60))
|
||
default_min = int(self._trim_start_s / 60)
|
||
data_schema = vol.Schema({
|
||
vol.Required("trim_start_min", default=default_min): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=0,
|
||
max=total_min - 1,
|
||
step=1,
|
||
unit_of_measurement="min",
|
||
mode=selector.NumberSelectorMode.BOX,
|
||
)
|
||
),
|
||
})
|
||
desc_placeholders = {
|
||
"cycle_start_wallclock": "unknown",
|
||
"cycle_end_wallclock": "unknown",
|
||
"current_wallclock": f"{int(self._trim_start_s / 60)} min",
|
||
"current_offset_min": str(int(self._trim_start_s / 60)),
|
||
}
|
||
|
||
return self.async_show_form(
|
||
step_id="trim_cycle_start",
|
||
data_schema=data_schema,
|
||
errors=errors,
|
||
description_placeholders=desc_placeholders,
|
||
)
|
||
|
||
async def async_step_trim_cycle_end(
|
||
self, user_input: dict[str, Any] | None = None
|
||
) -> FlowResult:
|
||
"""Set the trim end point."""
|
||
manager = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||
store = manager.profile_store
|
||
|
||
if not self._trim_cycle_id:
|
||
return await self.async_step_trim_cycle_select()
|
||
|
||
p_data = store.get_cycle_power_data(self._trim_cycle_id)
|
||
if not p_data:
|
||
return self.async_abort(reason="no_power_data")
|
||
|
||
full_end_s = p_data[-1][0]
|
||
cycle_start_dt = self._trim_cycle_start_dt
|
||
errors: dict[str, str] = {}
|
||
|
||
if user_input is not None:
|
||
time_str = (user_input.get("trim_end_time") or "").strip()
|
||
if time_str and cycle_start_dt is not None:
|
||
cycle_end_dt = cycle_start_dt + timedelta(seconds=full_end_s)
|
||
new_end_s = self._wallclock_to_offset(time_str, cycle_start_dt, cycle_end_dt)
|
||
if new_end_s is None:
|
||
errors["trim_end_time"] = "trim_range_invalid"
|
||
elif new_end_s <= self._trim_start_s:
|
||
errors["trim_end_time"] = "trim_range_invalid"
|
||
else:
|
||
self._trim_end_s = new_end_s
|
||
return await self.async_step_trim_cycle()
|
||
else:
|
||
min_val = user_input.get("trim_end_min")
|
||
if min_val is not None:
|
||
new_end_s = float(min_val) * 60.0
|
||
if new_end_s <= self._trim_start_s:
|
||
errors["trim_end_min"] = "trim_range_invalid"
|
||
else:
|
||
self._trim_end_s = new_end_s
|
||
return await self.async_step_trim_cycle()
|
||
|
||
if cycle_start_dt is not None:
|
||
local_start = dt_util.as_local(cycle_start_dt)
|
||
local_end = dt_util.as_local(cycle_start_dt + timedelta(seconds=full_end_s))
|
||
current_wallclock = dt_util.as_local(
|
||
cycle_start_dt + timedelta(seconds=self._trim_end_s)
|
||
).strftime("%H:%M:%S")
|
||
current_offset_min = int(self._trim_end_s / 60)
|
||
data_schema = vol.Schema({
|
||
vol.Required("trim_end_time", default=current_wallclock): selector.TimeSelector(),
|
||
})
|
||
desc_placeholders = {
|
||
"cycle_start_wallclock": local_start.strftime("%H:%M:%S"),
|
||
"cycle_end_wallclock": local_end.strftime("%H:%M:%S"),
|
||
"current_wallclock": current_wallclock,
|
||
"current_offset_min": str(current_offset_min),
|
||
}
|
||
else:
|
||
total_min = max(1, int(full_end_s / 60) + 1)
|
||
default_min = int(self._trim_end_s / 60)
|
||
data_schema = vol.Schema({
|
||
vol.Required("trim_end_min", default=default_min): selector.NumberSelector(
|
||
selector.NumberSelectorConfig(
|
||
min=0,
|
||
max=total_min,
|
||
step=1,
|
||
unit_of_measurement="min",
|
||
mode=selector.NumberSelectorMode.BOX,
|
||
)
|
||
),
|
||
})
|
||
desc_placeholders = {
|
||
"cycle_start_wallclock": "unknown",
|
||
"cycle_end_wallclock": "unknown",
|
||
"current_wallclock": f"{int(self._trim_end_s / 60)} min",
|
||
"current_offset_min": str(int(self._trim_end_s / 60)),
|
||
}
|
||
|
||
return self.async_show_form(
|
||
step_id="trim_cycle_end",
|
||
data_schema=data_schema,
|
||
errors=errors,
|
||
description_placeholders=desc_placeholders,
|
||
) |