"""Config flow for the Bayesian integration.""" # pylint: disable=home-assistant-config-flow-name-field # Name field is no longer allowed in config flow schemas from collections.abc import Mapping from enum import StrEnum import logging from typing import Any, override import voluptuous as vol from homeassistant.components.alarm_control_panel import DOMAIN as ALARM_DOMAIN from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.calendar import DOMAIN as CALENDAR_DOMAIN from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.cover import DOMAIN as COVER_DOMAIN from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN from homeassistant.components.input_boolean import DOMAIN as INPUT_BOOLEAN_DOMAIN from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.components.input_text import DOMAIN as INPUT_TEXT_DOMAIN from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN from homeassistant.components.person import DOMAIN as PERSON_DOMAIN from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.sun import DOMAIN as SUN_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.todo import DOMAIN as TODO_DOMAIN from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN from homeassistant.config_entries import ( SOURCE_USER, ConfigEntry, ConfigFlowResult, ConfigSubentry, ConfigSubentryFlow, FlowType, SubentryFlowContext, SubentryFlowResult, ) from homeassistant.const import ( CONF_ABOVE, CONF_BELOW, CONF_DEVICE_CLASS, CONF_ENTITY_ID, CONF_NAME, CONF_PLATFORM, CONF_STATE, CONF_VALUE_TEMPLATE, Platform, ) from homeassistant.core import callback from homeassistant.helpers import selector, translation from homeassistant.helpers.schema_config_entry_flow import ( SchemaCommonFlowHandler, SchemaConfigFlowHandler, SchemaFlowError, SchemaFlowFormStep, SchemaFlowMenuStep, ) from .binary_sensor import above_greater_than_below, no_overlapping from .const import ( CONF_P_GIVEN_F, CONF_P_GIVEN_T, CONF_PRIOR, CONF_PROBABILITY_THRESHOLD, CONF_TEMPLATE, CONF_TO_STATE, DEFAULT_NAME, DEFAULT_PROBABILITY_THRESHOLD, DOMAIN, ) _LOGGER = logging.getLogger(__name__) USER = "user" OBSERVATION_SELECTOR = "observation_selector" ALLOWED_STATE_DOMAINS = [ ALARM_DOMAIN, BINARY_SENSOR_DOMAIN, CALENDAR_DOMAIN, CLIMATE_DOMAIN, COVER_DOMAIN, DEVICE_TRACKER_DOMAIN, INPUT_BOOLEAN_DOMAIN, INPUT_NUMBER_DOMAIN, INPUT_TEXT_DOMAIN, LIGHT_DOMAIN, MEDIA_PLAYER_DOMAIN, NOTIFY_DOMAIN, NUMBER_DOMAIN, PERSON_DOMAIN, "schedule", # Avoids an import that would introduce a dependency. SELECT_DOMAIN, SENSOR_DOMAIN, SUN_DOMAIN, SWITCH_DOMAIN, TODO_DOMAIN, UPDATE_DOMAIN, WEATHER_DOMAIN, ] ALLOWED_NUMERIC_DOMAINS = [ SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN, NUMBER_DOMAIN, TODO_DOMAIN, ZONE_DOMAIN, ] class ObservationTypes(StrEnum): """StrEnum for all the different observation types.""" STATE = CONF_STATE NUMERIC_STATE = "numeric_state" TEMPLATE = CONF_TEMPLATE class OptionsFlowSteps(StrEnum): """StrEnum for all the different options flow steps.""" INIT = "init" ADD_OBSERVATION = OBSERVATION_SELECTOR OPTIONS_SCHEMA = vol.Schema( { vol.Required( CONF_PROBABILITY_THRESHOLD, default=DEFAULT_PROBABILITY_THRESHOLD * 100 ): vol.All( selector.NumberSelector( selector.NumberSelectorConfig( mode=selector.NumberSelectorMode.SLIDER, step=1.0, min=0, max=100, unit_of_measurement="%", ), ), vol.Range( min=0, max=100, min_included=False, max_included=False, msg="extreme_threshold_error", ), ), vol.Required(CONF_PRIOR, default=DEFAULT_PROBABILITY_THRESHOLD * 100): vol.All( selector.NumberSelector( selector.NumberSelectorConfig( mode=selector.NumberSelectorMode.SLIDER, step=1.0, min=0, max=100, unit_of_measurement="%", ), ), vol.Range( min=0, max=100, min_included=False, max_included=False, msg="extreme_prior_error", ), ), vol.Optional(CONF_DEVICE_CLASS): selector.DeviceClassSelector( selector.DeviceClassSelectorConfig(domain=Platform.BINARY_SENSOR) ), } ) CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_NAME, default=DEFAULT_NAME): selector.TextSelector(), } ).extend(OPTIONS_SCHEMA.schema) OBSERVATION_BOILERPLATE = vol.Schema( { vol.Required(CONF_P_GIVEN_T): vol.All( selector.NumberSelector( selector.NumberSelectorConfig( mode=selector.NumberSelectorMode.SLIDER, step=1.0, min=0, max=100, unit_of_measurement="%", ), ), vol.Range( min=0, max=100, min_included=False, max_included=False, msg="extreme_prob_given_error", ), ), vol.Required(CONF_P_GIVEN_F): vol.All( selector.NumberSelector( selector.NumberSelectorConfig( mode=selector.NumberSelectorMode.SLIDER, step=1.0, min=0, max=100, unit_of_measurement="%", ), ), vol.Range( min=0, max=100, min_included=False, max_included=False, msg="extreme_prob_given_error", ), ), vol.Required(CONF_NAME): selector.TextSelector(), } ) STATE_SUBSCHEMA = vol.Schema( { vol.Required(CONF_ENTITY_ID): selector.EntitySelector( selector.EntitySelectorConfig(domain=ALLOWED_STATE_DOMAINS) ), vol.Required(CONF_TO_STATE): selector.TextSelector( selector.TextSelectorConfig( multiline=False, type=selector.TextSelectorType.TEXT, multiple=False ) # ideally this would be a state selector # context-linked to the above entity. ), }, ).extend(OBSERVATION_BOILERPLATE.schema) NUMERIC_STATE_SUBSCHEMA = vol.Schema( { vol.Required(CONF_ENTITY_ID): selector.EntitySelector( selector.EntitySelectorConfig(domain=ALLOWED_NUMERIC_DOMAINS) ), vol.Optional(CONF_ABOVE): selector.NumberSelector( selector.NumberSelectorConfig( mode=selector.NumberSelectorMode.BOX, step="any" ), ), vol.Optional(CONF_BELOW): selector.NumberSelector( selector.NumberSelectorConfig( mode=selector.NumberSelectorMode.BOX, step="any" ), ), }, ).extend(OBSERVATION_BOILERPLATE.schema) TEMPLATE_SUBSCHEMA = vol.Schema( { vol.Required(CONF_VALUE_TEMPLATE): selector.TemplateSelector( selector.TemplateSelectorConfig(), ), }, ).extend(OBSERVATION_BOILERPLATE.schema) def _convert_percentages_to_fractions( data: dict[str, str | float | int], ) -> dict[str, str | float]: """Convert percentage probability values to fractions for storage.""" probabilities = [ CONF_P_GIVEN_T, CONF_P_GIVEN_F, CONF_PRIOR, CONF_PROBABILITY_THRESHOLD, ] return { key: ( value / 100 if isinstance(value, (int, float)) and key in probabilities else value ) for key, value in data.items() } def _convert_fractions_to_percentages( data: dict[str, str | float], ) -> dict[str, str | float]: """Convert fraction probability values to percentages for the UI.""" probabilities = [ CONF_P_GIVEN_T, CONF_P_GIVEN_F, CONF_PRIOR, CONF_PROBABILITY_THRESHOLD, ] return { key: ( value * 100 if isinstance(value, (int, float)) and key in probabilities else value ) for key, value in data.items() } def _select_observation_schema( obs_type: ObservationTypes, ) -> vol.Schema: """Return the schema for editing the correct observation (SubEntry) type.""" if obs_type == str(ObservationTypes.STATE): return STATE_SUBSCHEMA if obs_type == str(ObservationTypes.NUMERIC_STATE): return NUMERIC_STATE_SUBSCHEMA return TEMPLATE_SUBSCHEMA async def _get_base_suggested_values( handler: SchemaCommonFlowHandler, ) -> dict[str, Any]: """Return suggested values for the base sensor options.""" return _convert_fractions_to_percentages(dict(handler.options)) def _get_observation_values_for_editing( subentry: ConfigSubentry, ) -> dict[str, Any]: """Return the values for editing in the observation subentry.""" return _convert_fractions_to_percentages(dict(subentry.data)) async def _validate_user( handler: SchemaCommonFlowHandler, user_input: dict[str, Any] ) -> dict[str, Any]: """Convert user input to fractions for storage. Validation is done entirely by the schemas. """ user_input = _convert_percentages_to_fractions(user_input) return {**user_input} def _validate_observation_subentry( obs_type: ObservationTypes, user_input: dict[str, Any], other_subentries: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: """Validate an observation input and update options. Observations are nested items and need manual updates. """ if user_input[CONF_P_GIVEN_T] == user_input[CONF_P_GIVEN_F]: raise SchemaFlowError("equal_probabilities") user_input = _convert_percentages_to_fractions(user_input) # Save the observation type in the user input as it is needed in binary_sensor.py user_input[CONF_PLATFORM] = str(obs_type) # Additional validation for multiple numeric state observations if ( user_input[CONF_PLATFORM] == ObservationTypes.NUMERIC_STATE and other_subentries is not None ): _LOGGER.debug( "Comparing with other subentries: %s", [*other_subentries, user_input] ) try: above_greater_than_below(user_input) no_overlapping([*other_subentries, user_input]) except vol.Invalid as err: raise SchemaFlowError(err) from err _LOGGER.debug("Processed observation with settings: %s", user_input) return user_input async def _get_description_placeholders( handler: SchemaCommonFlowHandler, ) -> dict[str, str]: # Current step is None when were are about to start the first step if handler.parent_handler.cur_step is None: return {"url": "https://www.home-assistant.io/integrations/bayesian/"} return { "parent_sensor_name": handler.options[CONF_NAME], "device_class_on": translation.async_translate_state( handler.parent_handler.hass, "on", BINARY_SENSOR_DOMAIN, platform=None, translation_key=None, device_class=handler.options.get(CONF_DEVICE_CLASS, None), ), "device_class_off": translation.async_translate_state( handler.parent_handler.hass, "off", BINARY_SENSOR_DOMAIN, platform=None, translation_key=None, device_class=handler.options.get(CONF_DEVICE_CLASS, None), ), } CONFIG_FLOW: dict[str, SchemaFlowMenuStep | SchemaFlowFormStep] = { str(USER): SchemaFlowFormStep( CONFIG_SCHEMA, validate_user_input=_validate_user, description_placeholders=_get_description_placeholders, ) } OPTIONS_FLOW: dict[str, SchemaFlowMenuStep | SchemaFlowFormStep] = { str(OptionsFlowSteps.INIT): SchemaFlowFormStep( OPTIONS_SCHEMA, suggested_values=_get_base_suggested_values, validate_user_input=_validate_user, description_placeholders=_get_description_placeholders, ), } class BayesianConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Bayesian config flow.""" VERSION = 1 MINOR_VERSION = 1 config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW @classmethod @callback @override def async_get_supported_subentry_types( cls, config_entry: ConfigEntry ) -> dict[str, type[ConfigSubentryFlow]]: """Return subentries supported by this integration.""" return {"observation": ObservationSubentryFlowHandler} @override def async_config_entry_title(self, options: Mapping[str, str]) -> str: """Return config entry title.""" name: str = options[CONF_NAME] return name @override async def async_on_create_entry(self, result: ConfigFlowResult) -> ConfigFlowResult: """Start subentry flow when config entry has been created.""" subentry_result = await self.hass.config_entries.subentries.async_init( (result["result"].entry_id, "observation"), context=SubentryFlowContext(source=SOURCE_USER), ) result["next_flow"] = ( FlowType.CONFIG_SUBENTRIES_FLOW, subentry_result["flow_id"], ) return result class ObservationSubentryFlowHandler(ConfigSubentryFlow): """Handle subentry flow for adding and modifying a topic.""" async def step_common( self, user_input: dict[str, Any] | None, obs_type: ObservationTypes, reconfiguring: bool = False, ) -> SubentryFlowResult: """Use common logic within the named steps.""" errors: dict[str, str] = {} other_subentries = None if obs_type == str(ObservationTypes.NUMERIC_STATE): other_subentries = [ dict(se.data) for se in self._get_entry().subentries.values() ] # If we are reconfiguring a subentry we don't want to compare with self if reconfiguring: sub_entry = self._get_reconfigure_subentry() if other_subentries is not None: other_subentries.remove(dict(sub_entry.data)) if user_input is not None: try: user_input = _validate_observation_subentry( obs_type, user_input, other_subentries=other_subentries, ) if reconfiguring: return self.async_update_and_abort( self._get_entry(), sub_entry, title=user_input.get(CONF_NAME, sub_entry.data[CONF_NAME]), data_updates=user_input, ) return self.async_create_entry( title=user_input.get(CONF_NAME), data=user_input, ) except SchemaFlowError as err: errors["base"] = str(err) return self.async_show_form( step_id="reconfigure" if reconfiguring else str(obs_type), data_schema=self.add_suggested_values_to_schema( data_schema=_select_observation_schema(obs_type), suggested_values=_get_observation_values_for_editing(sub_entry) if reconfiguring else None, ), errors=errors, description_placeholders={ "parent_sensor_name": self._get_entry().title, "device_class_on": translation.async_translate_state( self.hass, "on", BINARY_SENSOR_DOMAIN, platform=None, translation_key=None, device_class=self._get_entry().options.get(CONF_DEVICE_CLASS, None), ), "device_class_off": translation.async_translate_state( self.hass, "off", BINARY_SENSOR_DOMAIN, platform=None, translation_key=None, device_class=self._get_entry().options.get(CONF_DEVICE_CLASS, None), ), }, ) async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: """User flow to add a new observation.""" return self.async_show_menu( step_id="user", menu_options=[typ.value for typ in ObservationTypes], ) async def async_step_state( self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: """User flow to add a state observation. Function name must be async_step_{observation_type}. """ return await self.step_common( user_input=user_input, obs_type=ObservationTypes.STATE ) async def async_step_numeric_state( self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: """User flow to add a new numeric state observation. Function name must be async_step_{observation_type}. """ return await self.step_common( user_input=user_input, obs_type=ObservationTypes.NUMERIC_STATE ) async def async_step_template( self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: """User flow to add a new template observation. Function name must be async_step_{observation_type}. """ return await self.step_common( user_input=user_input, obs_type=ObservationTypes.TEMPLATE ) async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: """Enable the reconfigure button for observations. Function name must be async_step_reconfigure. """ sub_entry = self._get_reconfigure_subentry() return await self.step_common( user_input=user_input, obs_type=ObservationTypes(sub_entry.data[CONF_PLATFORM]), reconfiguring=True, )