584 lines
22 KiB
Python
584 lines
22 KiB
Python
"""Config flow for Intelligent Heating Pilot integration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any, cast
|
|
|
|
import voluptuous as vol
|
|
from homeassistant import config_entries
|
|
from homeassistant.core import callback
|
|
from homeassistant.data_entry_flow import FlowResult
|
|
from homeassistant.helpers import selector
|
|
|
|
from .const import (
|
|
CONF_ANTICIPATION_RECALC_TOLERANCE_MINUTES,
|
|
CONF_AUTO_LEARNING,
|
|
CONF_CLOUD_COVER_ENTITY,
|
|
CONF_CYCLE_SPLIT_DURATION_MINUTES,
|
|
CONF_DEAD_TIME_MINUTES,
|
|
CONF_HUMIDITY_IN_ENTITY,
|
|
CONF_HUMIDITY_OUT_ENTITY,
|
|
CONF_LHS_RETENTION_DAYS,
|
|
CONF_MAX_CYCLE_DURATION_MINUTES,
|
|
CONF_MIN_CYCLE_DURATION_MINUTES,
|
|
CONF_NAME,
|
|
CONF_SAFETY_SHUTOFF_GRACE_MINUTES,
|
|
CONF_SCHEDULER_ENTITIES,
|
|
CONF_TASK_RANGE_DAYS,
|
|
CONF_TEMP_DELTA_THRESHOLD,
|
|
CONF_VTHERM_ENTITY,
|
|
DEFAULT_ANTICIPATION_RECALC_TOLERANCE_MINUTES,
|
|
DEFAULT_AUTO_LEARNING,
|
|
DEFAULT_CYCLE_SPLIT_DURATION_MINUTES,
|
|
DEFAULT_DEAD_TIME_MINUTES,
|
|
DEFAULT_LHS_RETENTION_DAYS,
|
|
DEFAULT_MAX_CYCLE_DURATION_MINUTES,
|
|
DEFAULT_MIN_CYCLE_DURATION_MINUTES,
|
|
DEFAULT_NAME,
|
|
DEFAULT_SAFETY_SHUTOFF_GRACE_MINUTES,
|
|
DEFAULT_TASK_RANGE_DAYS,
|
|
DEFAULT_TEMP_DELTA_THRESHOLD,
|
|
DOMAIN,
|
|
)
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
class IntelligentHeatingPilotConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # type: ignore[call-arg]
|
|
"""Handle a config flow for Intelligent Heating Pilot."""
|
|
|
|
VERSION = 1
|
|
|
|
@staticmethod
|
|
@callback
|
|
def async_get_options_flow(
|
|
config_entry: config_entries.ConfigEntry,
|
|
) -> IntelligentHeatingPilotOptionsFlow:
|
|
"""Get the options flow for this handler."""
|
|
return IntelligentHeatingPilotOptionsFlow()
|
|
|
|
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult:
|
|
"""Handle the initial step."""
|
|
errors: dict[str, str] = {}
|
|
|
|
if user_input is not None:
|
|
processed: dict[str, Any] = {**user_input}
|
|
|
|
_LOGGER.debug("Received user_input in async_step_user: %s", user_input)
|
|
|
|
# Schedulers already a list from SelectSelector (multiple=True)
|
|
# Just ensure it's always a list
|
|
sched_in = processed.get(CONF_SCHEDULER_ENTITIES, [])
|
|
if not isinstance(sched_in, list):
|
|
sched_in = [sched_in] if sched_in else []
|
|
processed[CONF_SCHEDULER_ENTITIES] = sched_in
|
|
|
|
# Note: EntitySelector optional fields are simply not present in user_input if not filled
|
|
# No need to remove them as they won't exist in the dict
|
|
|
|
_LOGGER.debug("Processed data before validation: %s", processed)
|
|
|
|
# Required validations
|
|
vtherm_val = processed.get(CONF_VTHERM_ENTITY)
|
|
if not vtherm_val or (isinstance(vtherm_val, str) and vtherm_val.strip() == ""):
|
|
errors[CONF_VTHERM_ENTITY] = "required"
|
|
|
|
# Scheduler is now optional - no validation needed
|
|
|
|
if not errors:
|
|
_LOGGER.info("Creating entry with data: %s", processed)
|
|
await self.async_set_unique_id(processed[CONF_NAME])
|
|
self._abort_if_unique_id_configured()
|
|
return cast(
|
|
FlowResult,
|
|
self.async_create_entry(
|
|
title=processed[CONF_NAME],
|
|
data=processed,
|
|
),
|
|
)
|
|
|
|
# Get all scheduler entities with their friendly names
|
|
scheduler_options = []
|
|
for state in self.hass.states.async_all():
|
|
if state.domain not in ("switch", "schedule"):
|
|
continue
|
|
# Filter for scheduler entities (they typically have "schedule_" prefix or scheduler attributes)
|
|
if (
|
|
"schedule" in state.entity_id.lower()
|
|
or state.attributes.get("next_trigger")
|
|
or state.attributes.get("next_event")
|
|
):
|
|
friendly_name = state.attributes.get("friendly_name", state.entity_id)
|
|
scheduler_options.append(
|
|
{"value": state.entity_id, "label": f"{friendly_name} ({state.entity_id})"}
|
|
)
|
|
|
|
# Sort by label for easier selection
|
|
scheduler_options.sort(key=lambda x: x["label"])
|
|
|
|
# Build the schema for the configuration form using Entity and Select selectors (same as options flow)
|
|
# Scheduler selector: use SelectSelector if options available, else EntitySelector
|
|
scheduler_selector = (
|
|
selector.SelectSelector(
|
|
selector.SelectSelectorConfig(
|
|
options=scheduler_options,
|
|
multiple=True,
|
|
mode=selector.SelectSelectorMode.DROPDOWN,
|
|
)
|
|
)
|
|
if scheduler_options
|
|
else selector.EntitySelector(
|
|
selector.EntitySelectorConfig(domain=["switch", "schedule"], multiple=True)
|
|
)
|
|
)
|
|
|
|
data_schema = vol.Schema(
|
|
{
|
|
vol.Required(CONF_NAME, default=DEFAULT_NAME): str,
|
|
vol.Required(CONF_VTHERM_ENTITY): selector.EntitySelector(
|
|
selector.EntitySelectorConfig(
|
|
domain="climate", integration="versatile_thermostat"
|
|
)
|
|
),
|
|
vol.Optional(CONF_SCHEDULER_ENTITIES): scheduler_selector,
|
|
vol.Optional(CONF_HUMIDITY_IN_ENTITY): selector.EntitySelector(
|
|
selector.EntitySelectorConfig(domain="sensor", device_class="humidity")
|
|
),
|
|
vol.Optional(CONF_HUMIDITY_OUT_ENTITY): selector.EntitySelector(
|
|
selector.EntitySelectorConfig(domain="sensor", device_class="humidity")
|
|
),
|
|
vol.Optional(CONF_CLOUD_COVER_ENTITY): selector.EntitySelector(
|
|
selector.EntitySelectorConfig(domain="sensor")
|
|
),
|
|
vol.Optional(
|
|
CONF_LHS_RETENTION_DAYS, default=DEFAULT_LHS_RETENTION_DAYS
|
|
): selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
min=7,
|
|
max=90,
|
|
step=1,
|
|
unit_of_measurement="days",
|
|
mode=selector.NumberSelectorMode.BOX,
|
|
)
|
|
),
|
|
vol.Optional(
|
|
CONF_DEAD_TIME_MINUTES, default=DEFAULT_DEAD_TIME_MINUTES
|
|
): selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
min=0.0,
|
|
max=60.0,
|
|
step=1.0,
|
|
unit_of_measurement="minutes",
|
|
mode=selector.NumberSelectorMode.BOX,
|
|
)
|
|
),
|
|
vol.Optional(
|
|
CONF_AUTO_LEARNING, default=DEFAULT_AUTO_LEARNING
|
|
): selector.BooleanSelector(),
|
|
vol.Optional(
|
|
CONF_TEMP_DELTA_THRESHOLD, default=DEFAULT_TEMP_DELTA_THRESHOLD
|
|
): selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
min=0.1,
|
|
max=1.0,
|
|
step=0.1,
|
|
unit_of_measurement="°C",
|
|
mode=selector.NumberSelectorMode.BOX,
|
|
)
|
|
),
|
|
vol.Optional(
|
|
CONF_CYCLE_SPLIT_DURATION_MINUTES, default=DEFAULT_CYCLE_SPLIT_DURATION_MINUTES
|
|
): selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
min=0,
|
|
max=120,
|
|
step=5,
|
|
unit_of_measurement="minutes",
|
|
mode=selector.NumberSelectorMode.BOX,
|
|
)
|
|
),
|
|
vol.Optional(
|
|
CONF_MIN_CYCLE_DURATION_MINUTES, default=DEFAULT_MIN_CYCLE_DURATION_MINUTES
|
|
): selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
min=1,
|
|
max=30,
|
|
step=1,
|
|
unit_of_measurement="minutes",
|
|
mode=selector.NumberSelectorMode.BOX,
|
|
)
|
|
),
|
|
vol.Optional(
|
|
CONF_MAX_CYCLE_DURATION_MINUTES, default=DEFAULT_MAX_CYCLE_DURATION_MINUTES
|
|
): selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
min=15,
|
|
max=360,
|
|
step=5,
|
|
unit_of_measurement="minutes",
|
|
mode=selector.NumberSelectorMode.BOX,
|
|
)
|
|
),
|
|
vol.Optional(
|
|
CONF_TASK_RANGE_DAYS, default=DEFAULT_TASK_RANGE_DAYS
|
|
): selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
min=1,
|
|
max=30,
|
|
step=1,
|
|
unit_of_measurement="days",
|
|
mode=selector.NumberSelectorMode.BOX,
|
|
)
|
|
),
|
|
vol.Optional(
|
|
CONF_ANTICIPATION_RECALC_TOLERANCE_MINUTES,
|
|
default=DEFAULT_ANTICIPATION_RECALC_TOLERANCE_MINUTES,
|
|
): selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
min=1,
|
|
max=60,
|
|
step=1,
|
|
unit_of_measurement="minutes",
|
|
mode=selector.NumberSelectorMode.BOX,
|
|
)
|
|
),
|
|
vol.Optional(
|
|
CONF_SAFETY_SHUTOFF_GRACE_MINUTES,
|
|
default=DEFAULT_SAFETY_SHUTOFF_GRACE_MINUTES,
|
|
): selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
min=0,
|
|
max=30,
|
|
step=1,
|
|
mode=selector.NumberSelectorMode.BOX,
|
|
)
|
|
),
|
|
}
|
|
)
|
|
|
|
return cast(
|
|
FlowResult,
|
|
self.async_show_form(
|
|
step_id="user",
|
|
data_schema=data_schema,
|
|
errors=errors,
|
|
),
|
|
)
|
|
|
|
|
|
class IntelligentHeatingPilotOptionsFlow(config_entries.OptionsFlow):
|
|
"""Handle options flow for Intelligent Heating Pilot."""
|
|
|
|
async def async_step_init(self, user_input: dict[str, Any] | None = None) -> FlowResult:
|
|
"""Manage the options."""
|
|
errors: dict[str, str] = {}
|
|
normalized_input: dict[str, Any] = {}
|
|
|
|
if user_input is not None:
|
|
# Start with user input
|
|
normalized_input = {**user_input}
|
|
# Schedulers already a list from SelectSelector (multiple=True)
|
|
# Just ensure it's always a list
|
|
sched_in = normalized_input.get(CONF_SCHEDULER_ENTITIES, [])
|
|
if not isinstance(sched_in, list):
|
|
sched_in = [sched_in] if sched_in else []
|
|
normalized_input[CONF_SCHEDULER_ENTITIES] = sched_in
|
|
|
|
# For optional fields: if not in user_input OR empty, explicitly mark for deletion
|
|
optional_entity_fields = [
|
|
CONF_HUMIDITY_IN_ENTITY,
|
|
CONF_HUMIDITY_OUT_ENTITY,
|
|
CONF_CLOUD_COVER_ENTITY,
|
|
]
|
|
fields_to_delete = []
|
|
for field in optional_entity_fields:
|
|
if field not in user_input:
|
|
# Field not submitted = user cleared it
|
|
fields_to_delete.append(field)
|
|
elif not user_input[field] or (
|
|
isinstance(user_input[field], str) and user_input[field].strip() == ""
|
|
):
|
|
# Field submitted but empty
|
|
fields_to_delete.append(field)
|
|
|
|
# Validate required fields
|
|
if not normalized_input.get(CONF_VTHERM_ENTITY):
|
|
errors[CONF_VTHERM_ENTITY] = "required"
|
|
|
|
# Scheduler is now optional - no validation needed
|
|
|
|
if not errors:
|
|
# Merge with existing options
|
|
merged_options: dict[str, Any] = {**self.config_entry.options}
|
|
merged_options.update(normalized_input)
|
|
# For optional fields that were cleared: explicitly set to None to override data
|
|
for field in fields_to_delete:
|
|
merged_options[field] = None
|
|
return cast(
|
|
FlowResult,
|
|
self.async_create_entry(title="", data=merged_options),
|
|
)
|
|
|
|
# Prepare data sources for defaults
|
|
current_options = getattr(self.config_entry, "options", {})
|
|
if user_input is not None and errors:
|
|
# Re-display user's attempted values on validation errors
|
|
current_data = {**self.config_entry.data, **current_options, **normalized_input}
|
|
else:
|
|
current_data = {**self.config_entry.data, **current_options}
|
|
|
|
def _opt_or_data(key: str, default: Any = None) -> Any:
|
|
# First check options (overrides data)
|
|
if key in current_options:
|
|
val = current_options.get(key)
|
|
# None means explicitly deleted, don't fallback to data
|
|
if val is None:
|
|
return default
|
|
return val
|
|
# Fallback to data
|
|
return current_data.get(key, default)
|
|
|
|
# Get all scheduler entities for SelectSelector
|
|
scheduler_options = []
|
|
for state in self.hass.states.async_all():
|
|
if state.domain not in ("switch", "schedule"):
|
|
continue
|
|
if (
|
|
"schedule" in state.entity_id.lower()
|
|
or state.attributes.get("next_trigger")
|
|
or state.attributes.get("next_event")
|
|
):
|
|
friendly_name = state.attributes.get("friendly_name", state.entity_id)
|
|
scheduler_options.append(
|
|
{"value": state.entity_id, "label": f"{friendly_name} ({state.entity_id})"}
|
|
)
|
|
scheduler_options.sort(key=lambda x: x["label"])
|
|
|
|
# Compute defaults for entity selectors
|
|
default_schedulers_list = _opt_or_data(CONF_SCHEDULER_ENTITIES, [])
|
|
if isinstance(default_schedulers_list, str):
|
|
default_schedulers_list = [default_schedulers_list]
|
|
vtherm_val = _opt_or_data(CONF_VTHERM_ENTITY)
|
|
hum_in_val = _opt_or_data(CONF_HUMIDITY_IN_ENTITY)
|
|
hum_out_val = _opt_or_data(CONF_HUMIDITY_OUT_ENTITY)
|
|
cloud_val = _opt_or_data(CONF_CLOUD_COVER_ENTITY)
|
|
|
|
# Build schema dynamically: only set default if value exists and is non-empty
|
|
schema_dict: dict[Any, Any] = {}
|
|
|
|
# Required: VTherm (only set default if exists and non-empty)
|
|
vtherm_field = (
|
|
vol.Required(CONF_VTHERM_ENTITY, default=vtherm_val)
|
|
if vtherm_val and vtherm_val != ""
|
|
else vol.Required(CONF_VTHERM_ENTITY)
|
|
)
|
|
schema_dict[vtherm_field] = selector.EntitySelector(
|
|
selector.EntitySelectorConfig(domain="climate", integration="versatile_thermostat")
|
|
)
|
|
|
|
# Optional: Schedulers (multiple, only set default if non-empty list)
|
|
scheduler_selector = (
|
|
selector.SelectSelector(
|
|
selector.SelectSelectorConfig(
|
|
options=scheduler_options,
|
|
multiple=True,
|
|
mode=selector.SelectSelectorMode.DROPDOWN,
|
|
)
|
|
)
|
|
if scheduler_options
|
|
else selector.EntitySelector(
|
|
selector.EntitySelectorConfig(domain=["switch", "schedule"], multiple=True)
|
|
)
|
|
)
|
|
schedulers_field = (
|
|
vol.Optional(CONF_SCHEDULER_ENTITIES, default=default_schedulers_list)
|
|
if default_schedulers_list and len(default_schedulers_list) > 0
|
|
else vol.Optional(CONF_SCHEDULER_ENTITIES)
|
|
)
|
|
schema_dict[schedulers_field] = scheduler_selector
|
|
|
|
# Optional humidity/cloud fields: Use suggested_value to pre-fill while allowing clearing
|
|
schema_dict[
|
|
vol.Optional(
|
|
CONF_HUMIDITY_IN_ENTITY,
|
|
description={"suggested_value": hum_in_val}
|
|
if hum_in_val and hum_in_val != ""
|
|
else {},
|
|
)
|
|
] = selector.EntitySelector(
|
|
selector.EntitySelectorConfig(domain="sensor", device_class="humidity")
|
|
)
|
|
|
|
schema_dict[
|
|
vol.Optional(
|
|
CONF_HUMIDITY_OUT_ENTITY,
|
|
description={"suggested_value": hum_out_val}
|
|
if hum_out_val and hum_out_val != ""
|
|
else {},
|
|
)
|
|
] = selector.EntitySelector(
|
|
selector.EntitySelectorConfig(domain="sensor", device_class="humidity")
|
|
)
|
|
|
|
schema_dict[
|
|
vol.Optional(
|
|
CONF_CLOUD_COVER_ENTITY,
|
|
description={"suggested_value": cloud_val} if cloud_val and cloud_val != "" else {},
|
|
)
|
|
] = selector.EntitySelector(selector.EntitySelectorConfig(domain="sensor"))
|
|
|
|
# Numeric fields
|
|
schema_dict[
|
|
vol.Optional(
|
|
CONF_LHS_RETENTION_DAYS,
|
|
default=_opt_or_data(CONF_LHS_RETENTION_DAYS, DEFAULT_LHS_RETENTION_DAYS),
|
|
)
|
|
] = selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
min=7,
|
|
max=90,
|
|
step=1,
|
|
unit_of_measurement="days",
|
|
mode=selector.NumberSelectorMode.BOX,
|
|
)
|
|
)
|
|
schema_dict[
|
|
vol.Optional(
|
|
CONF_DEAD_TIME_MINUTES,
|
|
default=_opt_or_data(CONF_DEAD_TIME_MINUTES, DEFAULT_DEAD_TIME_MINUTES),
|
|
)
|
|
] = selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
min=0.0,
|
|
max=60.0,
|
|
step=1.0,
|
|
unit_of_measurement="minutes",
|
|
mode=selector.NumberSelectorMode.BOX,
|
|
)
|
|
)
|
|
schema_dict[
|
|
vol.Optional(
|
|
CONF_AUTO_LEARNING, default=_opt_or_data(CONF_AUTO_LEARNING, DEFAULT_AUTO_LEARNING)
|
|
)
|
|
] = selector.BooleanSelector()
|
|
schema_dict[
|
|
vol.Optional(
|
|
CONF_TEMP_DELTA_THRESHOLD,
|
|
default=_opt_or_data(CONF_TEMP_DELTA_THRESHOLD, DEFAULT_TEMP_DELTA_THRESHOLD),
|
|
)
|
|
] = selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
min=0.1,
|
|
max=1.0,
|
|
step=0.1,
|
|
unit_of_measurement="°C",
|
|
mode=selector.NumberSelectorMode.BOX,
|
|
)
|
|
)
|
|
schema_dict[
|
|
vol.Optional(
|
|
CONF_CYCLE_SPLIT_DURATION_MINUTES,
|
|
default=_opt_or_data(
|
|
CONF_CYCLE_SPLIT_DURATION_MINUTES, DEFAULT_CYCLE_SPLIT_DURATION_MINUTES
|
|
),
|
|
)
|
|
] = selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
min=0,
|
|
max=120,
|
|
step=5,
|
|
unit_of_measurement="minutes",
|
|
mode=selector.NumberSelectorMode.BOX,
|
|
)
|
|
)
|
|
schema_dict[
|
|
vol.Optional(
|
|
CONF_MIN_CYCLE_DURATION_MINUTES,
|
|
default=_opt_or_data(
|
|
CONF_MIN_CYCLE_DURATION_MINUTES, DEFAULT_MIN_CYCLE_DURATION_MINUTES
|
|
),
|
|
)
|
|
] = selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
min=1,
|
|
max=30,
|
|
step=1,
|
|
unit_of_measurement="minutes",
|
|
mode=selector.NumberSelectorMode.BOX,
|
|
)
|
|
)
|
|
schema_dict[
|
|
vol.Optional(
|
|
CONF_MAX_CYCLE_DURATION_MINUTES,
|
|
default=_opt_or_data(
|
|
CONF_MAX_CYCLE_DURATION_MINUTES, DEFAULT_MAX_CYCLE_DURATION_MINUTES
|
|
),
|
|
)
|
|
] = selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
min=15,
|
|
max=360,
|
|
step=5,
|
|
unit_of_measurement="minutes",
|
|
mode=selector.NumberSelectorMode.BOX,
|
|
)
|
|
)
|
|
schema_dict[
|
|
vol.Optional(
|
|
CONF_TASK_RANGE_DAYS,
|
|
default=_opt_or_data(CONF_TASK_RANGE_DAYS, DEFAULT_TASK_RANGE_DAYS),
|
|
)
|
|
] = selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
min=1,
|
|
max=30,
|
|
step=1,
|
|
unit_of_measurement="days",
|
|
mode=selector.NumberSelectorMode.BOX,
|
|
)
|
|
)
|
|
schema_dict[
|
|
vol.Optional(
|
|
CONF_ANTICIPATION_RECALC_TOLERANCE_MINUTES,
|
|
default=_opt_or_data(
|
|
CONF_ANTICIPATION_RECALC_TOLERANCE_MINUTES,
|
|
DEFAULT_ANTICIPATION_RECALC_TOLERANCE_MINUTES,
|
|
),
|
|
)
|
|
] = selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
min=1,
|
|
max=60,
|
|
step=1,
|
|
unit_of_measurement="minutes",
|
|
mode=selector.NumberSelectorMode.BOX,
|
|
)
|
|
)
|
|
schema_dict[
|
|
vol.Optional(
|
|
CONF_SAFETY_SHUTOFF_GRACE_MINUTES,
|
|
default=_opt_or_data(
|
|
CONF_SAFETY_SHUTOFF_GRACE_MINUTES, DEFAULT_SAFETY_SHUTOFF_GRACE_MINUTES
|
|
),
|
|
)
|
|
] = selector.NumberSelector(
|
|
selector.NumberSelectorConfig(
|
|
min=0,
|
|
max=30,
|
|
step=1,
|
|
mode=selector.NumberSelectorMode.BOX,
|
|
)
|
|
)
|
|
|
|
data_schema = vol.Schema(schema_dict)
|
|
|
|
return cast(
|
|
FlowResult,
|
|
self.async_show_form(
|
|
step_id="init",
|
|
data_schema=data_schema,
|
|
errors=errors,
|
|
),
|
|
)
|