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