Files
HomeAssistantVS/custom_components/intelligent_heating_pilot/config_flow.py
T
2026-07-08 10:43:39 -04:00

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,
),
)