from __future__ import annotations from datetime import timedelta from typing import TYPE_CHECKING, Any from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_ENABLED, CONF_SENSORS, UnitOfTime from homeassistant.data_entry_flow import section from homeassistant.helpers import selector from homeassistant.helpers.schema_config_entry_flow import SchemaFlowError from homeassistant.helpers.typing import ConfigType import voluptuous as vol from custom_components.powercalc.const import ( CONF_APPLY_TO_ALL, CONF_CREATE_COST_SENSOR, CONF_CREATE_COST_SENSORS, CONF_CREATE_ENERGY_SENSORS, CONF_CREATE_STANDBY_GROUP, CONF_CREATE_UTILITY_METERS, CONF_DISABLE_EXTENDED_ATTRIBUTES, CONF_DISABLE_LIBRARY_DOWNLOAD, CONF_DISCOVERY, CONF_ENABLE_ANALYTICS, CONF_ENERGY_PRICE, CONF_ENERGY_PRICE_SENSOR, CONF_ENERGY_SENSOR_CATEGORY, CONF_ENERGY_SENSOR_FRIENDLY_NAMING, CONF_ENERGY_SENSOR_NAMING, CONF_ENERGY_SENSOR_PRECISION, CONF_ENERGY_UPDATE_INTERVAL, CONF_EXCLUDE_DEVICE_TYPES, CONF_EXCLUDE_SELF_USAGE, CONF_GROUP_ENERGY_UPDATE_INTERVAL, CONF_GROUP_POWER_UPDATE_INTERVAL, CONF_IGNORE_UNAVAILABLE_STATE, CONF_INCLUDE_NON_POWERCALC_SENSORS, CONF_POWER_SENSOR_CATEGORY, CONF_POWER_SENSOR_FRIENDLY_NAMING, CONF_POWER_SENSOR_NAMING, CONF_POWER_SENSOR_PRECISION, CONF_POWER_UPDATE_INTERVAL, CONF_UTILITY_METER_OFFSET, DEFAULT_ENERGY_UPDATE_INTERVAL, DEFAULT_GROUP_ENERGY_UPDATE_INTERVAL, DEFAULT_GROUP_POWER_UPDATE_INTERVAL, DOCS_URI, DOMAIN, DOMAIN_CONFIG, ENTITY_CATEGORIES, ENTRY_GLOBAL_CONFIG_UNIQUE_ID, ) from custom_components.powercalc.flow_helper.common import PowercalcFormStep, Step, flatten_sections from custom_components.powercalc.flow_helper.schema import ( COST_DOCS_URI, SCHEMA_COST_APPLY, SCHEMA_ENERGY_OPTIONS, SCHEMA_GLOBAL_COST, SCHEMA_GLOBAL_COST_FLAT, SCHEMA_UTILITY_METER_OPTIONS, SCHEMA_UTILITY_METER_TOGGLE, SECTION_COST_NAMING, SECTION_COST_PRICING, ) from custom_components.powercalc.power_profile.power_profile import DeviceType from custom_components.powercalc.service.gui_configuration import apply_field_to_config_entries if TYPE_CHECKING: from custom_components.powercalc.config_flow import PowercalcCommonFlow, PowercalcConfigFlow, PowercalcOptionsFlow SECTION_GLOBAL_POWER = "power_options" SECTION_GLOBAL_FEATURES = "features" SECTION_GLOBAL_ADVANCED = "advanced" SCHEMA_GLOBAL_CONFIGURATION_POWER = vol.Schema( { vol.Optional(CONF_POWER_SENSOR_NAMING): selector.TextSelector(), vol.Optional(CONF_POWER_SENSOR_FRIENDLY_NAMING): selector.TextSelector(), vol.Optional(CONF_POWER_SENSOR_CATEGORY): selector.SelectSelector( selector.SelectSelectorConfig( options=list(filter(lambda item: item is not None, ENTITY_CATEGORIES)), # type: ignore mode=selector.SelectSelectorMode.DROPDOWN, ), ), vol.Optional(CONF_POWER_SENSOR_PRECISION): selector.NumberSelector( selector.NumberSelectorConfig(min=0, max=6, mode=selector.NumberSelectorMode.BOX, step=1), ), }, ) SCHEMA_GLOBAL_CONFIGURATION_FEATURES = vol.Schema( { vol.Optional(CONF_CREATE_ENERGY_SENSORS, default=True): selector.BooleanSelector(), vol.Optional(CONF_CREATE_COST_SENSORS, default=False): selector.BooleanSelector(), vol.Optional(CONF_CREATE_STANDBY_GROUP, default=True): selector.BooleanSelector(), **SCHEMA_UTILITY_METER_TOGGLE.schema, }, ) SCHEMA_GLOBAL_CONFIGURATION_ADVANCED = vol.Schema( { vol.Optional(CONF_ENABLE_ANALYTICS, default=True): selector.BooleanSelector(), vol.Optional(CONF_IGNORE_UNAVAILABLE_STATE, default=False): selector.BooleanSelector(), vol.Optional(CONF_INCLUDE_NON_POWERCALC_SENSORS, default=True): selector.BooleanSelector(), vol.Optional(CONF_DISABLE_EXTENDED_ATTRIBUTES, default=False): selector.BooleanSelector(), vol.Optional(CONF_DISABLE_LIBRARY_DOWNLOAD, default=False): selector.BooleanSelector(), }, ) # Presented in the GUI as three collapsible sections (power sensor, features, advanced). SCHEMA_GLOBAL_CONFIGURATION = vol.Schema( { vol.Required(SECTION_GLOBAL_POWER): section(SCHEMA_GLOBAL_CONFIGURATION_POWER), vol.Required(SECTION_GLOBAL_FEATURES): section(SCHEMA_GLOBAL_CONFIGURATION_FEATURES), vol.Required(SECTION_GLOBAL_ADVANCED): section(SCHEMA_GLOBAL_CONFIGURATION_ADVANCED, {"collapsed": True}), }, ) SCHEMA_GLOBAL_CONFIGURATION_DISCOVERY = vol.Schema( { vol.Optional(CONF_ENABLED, default=True): selector.BooleanSelector(), vol.Optional(CONF_EXCLUDE_SELF_USAGE, default=False): selector.BooleanSelector(), vol.Optional(CONF_EXCLUDE_DEVICE_TYPES): selector.SelectSelector( selector.SelectSelectorConfig( options=[cls.value for cls in DeviceType], mode=selector.SelectSelectorMode.DROPDOWN, multiple=True, ), ), }, ) SCHEMA_GLOBAL_CONFIGURATION_THROTTLING = vol.Schema( { vol.Optional(CONF_POWER_UPDATE_INTERVAL, default=0): selector.NumberSelector( selector.NumberSelectorConfig(unit_of_measurement=UnitOfTime.SECONDS, mode=selector.NumberSelectorMode.BOX), ), vol.Optional(CONF_ENERGY_UPDATE_INTERVAL, default=DEFAULT_ENERGY_UPDATE_INTERVAL): selector.NumberSelector( selector.NumberSelectorConfig(unit_of_measurement=UnitOfTime.SECONDS, mode=selector.NumberSelectorMode.BOX), ), vol.Optional( CONF_GROUP_POWER_UPDATE_INTERVAL, default=DEFAULT_GROUP_POWER_UPDATE_INTERVAL, ): selector.NumberSelector( selector.NumberSelectorConfig(unit_of_measurement=UnitOfTime.SECONDS, mode=selector.NumberSelectorMode.BOX), ), vol.Optional( CONF_GROUP_ENERGY_UPDATE_INTERVAL, default=DEFAULT_GROUP_ENERGY_UPDATE_INTERVAL, ): selector.NumberSelector( selector.NumberSelectorConfig(unit_of_measurement=UnitOfTime.SECONDS, mode=selector.NumberSelectorMode.BOX), ), }, ) SCHEMA_GLOBAL_CONFIGURATION_ENERGY_SENSOR = vol.Schema( { vol.Optional(CONF_ENERGY_SENSOR_NAMING): selector.TextSelector(), vol.Optional(CONF_ENERGY_SENSOR_FRIENDLY_NAMING): selector.TextSelector(), vol.Optional(CONF_ENERGY_SENSOR_CATEGORY): selector.SelectSelector( selector.SelectSelectorConfig( options=list(filter(lambda item: item is not None, ENTITY_CATEGORIES)), # type: ignore mode=selector.SelectSelectorMode.DROPDOWN, ), ), **SCHEMA_ENERGY_OPTIONS.schema, vol.Optional(CONF_ENERGY_SENSOR_PRECISION): selector.NumberSelector( selector.NumberSelectorConfig(min=0, max=6, mode=selector.NumberSelectorMode.BOX, step=1), ), }, ) def merge_global_config(global_config: ConfigType, user_input: dict[str, Any], schema: vol.Schema) -> None: """Merge submitted user input into the global config. Keys present in the schema but absent from the user input were cleared in the form and must be removed, otherwise a previously saved value would incorrectly persist. """ for key, val in schema.schema.items(): base_key = key.schema if isinstance(key, vol.Marker) else key if isinstance(val, section): # Recurse into collapsible sections, whose values are nested under the section key. merge_global_config(global_config, user_input.get(base_key) or {}, val.schema) continue if base_key in user_input: global_config[base_key] = user_input[base_key] elif base_key in global_config: global_config.pop(base_key) def get_global_powercalc_config(flow: PowercalcCommonFlow) -> ConfigType: """Get the global powercalc config.""" if flow.global_config: return flow.global_config powercalc = flow.hass.data.get(DOMAIN) or {} global_config = dict.copy(powercalc.get(DOMAIN_CONFIG) or {}) utility_meter_offset = global_config.get(CONF_UTILITY_METER_OFFSET) if isinstance(utility_meter_offset, timedelta): global_config[CONF_UTILITY_METER_OFFSET] = utility_meter_offset.days if CONF_SENSORS in global_config: global_config.pop(CONF_SENSORS) flow.global_config = global_config return global_config class GlobalConfigurationFlow: def __init__(self, flow: PowercalcCommonFlow) -> None: self.flow = flow def is_energy_price_configured(self) -> bool: """Check whether an energy price (fixed or sensor) has been configured globally.""" config = self.flow.global_config return bool(config.get(CONF_ENERGY_PRICE) or config.get(CONF_ENERGY_PRICE_SENSOR)) async def async_step_global_configuration_discovery( self, user_input: dict[str, Any] | None = None, ) -> ConfigFlowResult: """Handle the discovery configuration step.""" if user_input is not None: self.flow.global_config.update({CONF_DISCOVERY: user_input}) if self.flow.is_options_flow: return self.flow.persist_config_entry() global_config = get_global_powercalc_config(self.flow) discovery_options: dict[str, Any] = global_config.get(CONF_DISCOVERY, {}) return await self.flow.handle_form_step( PowercalcFormStep( step=Step.GLOBAL_CONFIGURATION_DISCOVERY, schema=SCHEMA_GLOBAL_CONFIGURATION_DISCOVERY, next_step=Step.GLOBAL_CONFIGURATION_THROTTLING, form_data=discovery_options, form_kwarg={"description_placeholders": {"docs_uri": "https://docs.powercalc.nl/library/discovery/"}}, ), user_input, ) async def async_step_global_configuration_throttling( self, user_input: dict[str, Any] | None = None, ) -> ConfigFlowResult: """Handle the throttling related options.""" if user_input is not None: if self.flow.is_options_flow: merge_global_config(self.flow.global_config, user_input, SCHEMA_GLOBAL_CONFIGURATION_THROTTLING) return self.flow.persist_config_entry() self.flow.global_config.update(user_input) return await self.flow.handle_form_step( PowercalcFormStep( step=Step.GLOBAL_CONFIGURATION_THROTTLING, schema=SCHEMA_GLOBAL_CONFIGURATION_THROTTLING, next_step=Step.GLOBAL_CONFIGURATION_ENERGY, form_kwarg={ "description_placeholders": { "docs_uri": "https://docs.powercalc.nl/configuration/update-frequency/", }, }, ), user_input, ) async def async_step_global_configuration_energy( self, user_input: dict[str, Any] | None = None, ) -> ConfigFlowResult: """Handle the global configuration step.""" if user_input is not None: if self.flow.is_options_flow: merge_global_config(self.flow.global_config, user_input, SCHEMA_GLOBAL_CONFIGURATION_ENERGY_SENSOR) return self.flow.persist_config_entry() self.flow.global_config.update(user_input) if not bool(self.flow.global_config.get(CONF_CREATE_ENERGY_SENSORS)) or user_input is not None: return await self.async_step_global_configuration_utility_meter() return await self.flow.handle_form_step( PowercalcFormStep( step=Step.GLOBAL_CONFIGURATION_ENERGY, schema=SCHEMA_GLOBAL_CONFIGURATION_ENERGY_SENSOR, form_kwarg={ "description_placeholders": { "docs_uri": DOCS_URI, }, }, ), ) async def async_step_global_configuration_utility_meter( self, user_input: dict[str, Any] | None = None, ) -> ConfigFlowResult: """Handle the global configuration step.""" if user_input is not None: if self.flow.is_options_flow: merge_global_config(self.flow.global_config, user_input, SCHEMA_UTILITY_METER_OPTIONS) return self.flow.persist_config_entry() self.flow.global_config.update(user_input) if not bool(self.flow.global_config.get(CONF_CREATE_UTILITY_METERS)) or user_input is not None: return await self.async_step_global_configuration_cost() return await self.flow.handle_form_step( PowercalcFormStep( step=Step.GLOBAL_CONFIGURATION_UTILITY_METER, schema=SCHEMA_UTILITY_METER_OPTIONS, form_kwarg={ "description_placeholders": { "docs_uri": DOCS_URI, }, }, ), ) async def async_step_global_configuration_cost( self, user_input: dict[str, Any] | None = None, ) -> ConfigFlowResult: """Handle the global cost sensor configuration step (energy price).""" form_step = PowercalcFormStep( step=Step.GLOBAL_CONFIGURATION_COST, schema=SCHEMA_GLOBAL_COST, form_kwarg={ "description_placeholders": { "docs_uri": COST_DOCS_URI, }, }, ) if user_input is not None: # The form presents pricing and naming as two sections, flatten them back to plain keys. user_input = {**user_input.get(SECTION_COST_PRICING, {}), **user_input.get(SECTION_COST_NAMING, {})} if not user_input.get(CONF_ENERGY_PRICE) and not user_input.get(CONF_ENERGY_PRICE_SENSOR): return await self.flow._show_form(form_step, SchemaFlowError("cost_price_mandatory")) # noqa: SLF001 if self.flow.is_options_flow: merge_global_config(self.flow.global_config, user_input, SCHEMA_GLOBAL_COST_FLAT) return self.flow.persist_config_entry() self.flow.global_config.update(user_input) if not bool(self.flow.global_config.get(CONF_CREATE_COST_SENSORS)) or user_input is not None: return self.flow.async_create_entry( title="Global Configuration", data=self.flow.global_config, ) return await self.flow.handle_form_step(form_step) async def async_step_global_configuration_cost_apply( self, user_input: dict[str, Any] | None = None, ) -> ConfigFlowResult: """Ask whether to apply the changed create_cost_sensors setting to existing GUI sensors.""" if user_input is not None: if user_input.get(CONF_APPLY_TO_ALL): apply_field_to_config_entries( self.flow.hass, CONF_CREATE_COST_SENSOR, bool(self.flow.global_config.get(CONF_CREATE_COST_SENSORS)), ) # When cost sensors were just enabled but no price is configured yet, continue to the price step. if self.flow.global_config.get(CONF_CREATE_COST_SENSORS) and not self.is_energy_price_configured(): return await self.async_step_global_configuration_cost() return self.flow.persist_config_entry() return await self.flow.handle_form_step( PowercalcFormStep( step=Step.GLOBAL_CONFIGURATION_COST_APPLY, schema=SCHEMA_COST_APPLY, ), ) class GlobalConfigurationConfigFlow(GlobalConfigurationFlow): def __init__(self, flow: PowercalcConfigFlow) -> None: super().__init__(flow) self.flow: PowercalcConfigFlow = flow async def async_step_global_configuration(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Handle the global configuration step.""" get_global_powercalc_config(self.flow) await self.flow.async_set_unique_id(ENTRY_GLOBAL_CONFIG_UNIQUE_ID) self.flow.abort_if_unique_id_configured() if user_input is not None: self.flow.global_config.update(flatten_sections(user_input, SCHEMA_GLOBAL_CONFIGURATION)) return await self.async_step_global_configuration_discovery() return await self.flow.handle_form_step( PowercalcFormStep( step=Step.GLOBAL_CONFIGURATION, schema=SCHEMA_GLOBAL_CONFIGURATION, form_kwarg={ "description_placeholders": { "docs_uri": DOCS_URI, }, }, ), ) class GlobalConfigurationOptionsFlow(GlobalConfigurationFlow): def __init__(self, flow: PowercalcOptionsFlow) -> None: super().__init__(flow) self.flow = flow def build_global_config_menu(self) -> dict[Step, str]: """Build menu for global configuration""" menu = { Step.GLOBAL_CONFIGURATION: "Basic options", Step.GLOBAL_CONFIGURATION_DISCOVERY: "Discovery options", Step.GLOBAL_CONFIGURATION_THROTTLING: "Throttling options", Step.GLOBAL_CONFIGURATION_COST: "Cost options", } if self.flow.global_config.get(CONF_CREATE_ENERGY_SENSORS): menu[Step.GLOBAL_CONFIGURATION_ENERGY] = "Energy options" if self.flow.global_config.get(CONF_CREATE_UTILITY_METERS): menu[Step.GLOBAL_CONFIGURATION_UTILITY_METER] = "Utility meter options" return menu async def async_step_global_configuration(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Handle the global configuration step.""" if user_input is not None: cost_sensors_before = bool(self.flow.global_config.get(CONF_CREATE_COST_SENSORS)) merge_global_config(self.flow.global_config, user_input, SCHEMA_GLOBAL_CONFIGURATION) # When the create_cost_sensors toggle is flipped (either direction), offer to apply # the change to all existing GUI sensors in a dedicated step. if bool(self.flow.global_config.get(CONF_CREATE_COST_SENSORS)) != cost_sensors_before: return await self.async_step_global_configuration_cost_apply() return self.flow.persist_config_entry() return await self.flow.handle_form_step( PowercalcFormStep( step=Step.GLOBAL_CONFIGURATION, schema=SCHEMA_GLOBAL_CONFIGURATION, ), )