From d73e12df938e4f9ef37ddbab2f8602a29f61c79d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 12 Jul 2024 14:35:49 +0000 Subject: [PATCH] Add config flow to compensation helper --- .../components/compensation/__init__.py | 137 ++++++++++++------ .../components/compensation/config_flow.py | 132 +++++++++++++++++ .../components/compensation/const.py | 3 + .../components/compensation/manifest.json | 2 + .../components/compensation/sensor.py | 31 ++++ .../components/compensation/strings.json | 77 ++++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 12 +- 8 files changed, 341 insertions(+), 54 deletions(-) create mode 100644 homeassistant/components/compensation/config_flow.py create mode 100644 homeassistant/components/compensation/strings.json diff --git a/homeassistant/components/compensation/__init__.py b/homeassistant/components/compensation/__init__.py index fae416e7fc2..5e09cf26003 100644 --- a/homeassistant/components/compensation/__init__.py +++ b/homeassistant/components/compensation/__init__.py @@ -7,15 +7,18 @@ import numpy as np import voluptuous as vol from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ATTRIBUTE, CONF_MAXIMUM, CONF_MINIMUM, + CONF_NAME, CONF_SOURCE, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType @@ -32,6 +35,7 @@ from .const import ( DEFAULT_DEGREE, DEFAULT_PRECISION, DOMAIN, + PLATFORMS, ) _LOGGER = logging.getLogger(__name__) @@ -77,59 +81,96 @@ CONFIG_SCHEMA = vol.Schema( ) +async def create_compensation_data( + hass: HomeAssistant, compensation: str, conf: ConfigType, should_raise: bool = False +) -> None: + """Create compensation data.""" + _LOGGER.debug("Setup %s.%s", DOMAIN, compensation) + + degree = conf[CONF_DEGREE] + + initial_coefficients: list[tuple[float, float]] = conf[CONF_DATAPOINTS] + sorted_coefficients = sorted(initial_coefficients, key=itemgetter(0)) + + # get x values and y values from the x,y point pairs + x_values, y_values = zip(*initial_coefficients, strict=False) + + # try to get valid coefficients for a polynomial + coefficients = None + with np.errstate(all="raise"): + try: + coefficients = np.polyfit(x_values, y_values, degree) + except FloatingPointError as error: + _LOGGER.error( + "Setup of %s encountered an error, %s", + compensation, + error, + ) + if should_raise: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="setup_error", + translation_placeholders={ + "title": conf[CONF_NAME], + "error": str(error), + }, + ) from error + + if coefficients is not None: + data = { + k: v for k, v in conf.items() if k not in [CONF_DEGREE, CONF_DATAPOINTS] + } + data[CONF_POLYNOMIAL] = np.poly1d(coefficients) + + if data[CONF_LOWER_LIMIT]: + data[CONF_MINIMUM] = sorted_coefficients[0] + else: + data[CONF_MINIMUM] = None + + if data[CONF_UPPER_LIMIT]: + data[CONF_MAXIMUM] = sorted_coefficients[-1] + else: + data[CONF_MAXIMUM] = None + + hass.data[DATA_COMPENSATION][compensation] = data + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Compensation sensor.""" hass.data[DATA_COMPENSATION] = {} + if DOMAIN not in config: + return True + for compensation, conf in config[DOMAIN].items(): - _LOGGER.debug("Setup %s.%s", DOMAIN, compensation) - - degree = conf[CONF_DEGREE] - - initial_coefficients: list[tuple[float, float]] = conf[CONF_DATAPOINTS] - sorted_coefficients = sorted(initial_coefficients, key=itemgetter(0)) - - # get x values and y values from the x,y point pairs - x_values, y_values = zip(*initial_coefficients, strict=False) - - # try to get valid coefficients for a polynomial - coefficients = None - with np.errstate(all="raise"): - try: - coefficients = np.polyfit(x_values, y_values, degree) - except FloatingPointError as error: - _LOGGER.error( - "Setup of %s encountered an error, %s", - compensation, - error, - ) - - if coefficients is not None: - data = { - k: v for k, v in conf.items() if k not in [CONF_DEGREE, CONF_DATAPOINTS] - } - data[CONF_POLYNOMIAL] = np.poly1d(coefficients) - - if data[CONF_LOWER_LIMIT]: - data[CONF_MINIMUM] = sorted_coefficients[0] - else: - data[CONF_MINIMUM] = None - - if data[CONF_UPPER_LIMIT]: - data[CONF_MAXIMUM] = sorted_coefficients[-1] - else: - data[CONF_MAXIMUM] = None - - hass.data[DATA_COMPENSATION][compensation] = data - - hass.async_create_task( - async_load_platform( - hass, - SENSOR_DOMAIN, - DOMAIN, - {CONF_COMPENSATION: compensation}, - config, - ) + await create_compensation_data(hass, compensation, conf) + hass.async_create_task( + async_load_platform( + hass, + SENSOR_DOMAIN, + DOMAIN, + {CONF_COMPENSATION: compensation}, + config, ) + ) return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Compensation from a config entry.""" + await create_compensation_data(hass, entry.entry_id, dict(entry.options), True) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Compensation config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/compensation/config_flow.py b/homeassistant/components/compensation/config_flow.py new file mode 100644 index 00000000000..75cb56e8edb --- /dev/null +++ b/homeassistant/components/compensation/config_flow.py @@ -0,0 +1,132 @@ +"""Config flow for statistics.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, cast + +import voluptuous as vol + +from homeassistant.const import ( + CONF_ATTRIBUTE, + CONF_ENTITY_ID, + CONF_NAME, + CONF_UNIT_OF_MEASUREMENT, +) +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, + SchemaConfigFlowHandler, + SchemaFlowError, + SchemaFlowFormStep, +) +from homeassistant.helpers.selector import ( + AttributeSelector, + AttributeSelectorConfig, + BooleanSelector, + EntitySelector, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, +) + +from .const import ( + CONF_DATAPOINTS, + CONF_DEGREE, + CONF_LOWER_LIMIT, + CONF_PRECISION, + CONF_UPPER_LIMIT, + DEFAULT_DEGREE, + DEFAULT_NAME, + DEFAULT_PRECISION, + DOMAIN, +) + + +async def get_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: + """Get options schema.""" + entity_id = handler.options[CONF_ENTITY_ID] + + return vol.Schema( + { + vol.Required(CONF_DATAPOINTS): SelectSelector( + SelectSelectorConfig( + options=[], + multiple=True, + custom_value=True, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_ATTRIBUTE): AttributeSelector( + AttributeSelectorConfig(entity_id=entity_id) + ), + vol.Optional(CONF_UPPER_LIMIT, default=False): BooleanSelector(), + vol.Optional(CONF_LOWER_LIMIT, default=False): BooleanSelector(), + vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + vol.Optional(CONF_DEGREE, default=DEFAULT_DEGREE): NumberSelector( + NumberSelectorConfig(min=0, max=7, step=1, mode=NumberSelectorMode.BOX) + ), + vol.Optional(CONF_UNIT_OF_MEASUREMENT): TextSelector(), + } + ) + + +async def validate_options( + handler: SchemaCommonFlowHandler, user_input: dict[str, Any] +) -> dict[str, Any]: + """Validate options selected.""" + + user_input[CONF_PRECISION] = int(user_input[CONF_PRECISION]) + user_input[CONF_DEGREE] = int(user_input[CONF_DEGREE]) + + for datapoint in user_input[CONF_DATAPOINTS]: + if not isinstance(datapoint, list): + raise SchemaFlowError("incorrect_datapoints") + + if len(user_input[CONF_DATAPOINTS]) <= user_input[CONF_DEGREE]: + raise SchemaFlowError("not_enough_datapoints") + + handler.parent_handler._async_abort_entries_match({**handler.options, **user_input}) # noqa: SLF001 + + return user_input + + +DATA_SCHEMA_SETUP = vol.Schema( + { + vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(), + vol.Required(CONF_ENTITY_ID): EntitySelector(), + } +) + +CONFIG_FLOW = { + "user": SchemaFlowFormStep( + schema=DATA_SCHEMA_SETUP, + next_step="options", + ), + "options": SchemaFlowFormStep( + schema=get_options_schema, + validate_user_input=validate_options, + ), +} +OPTIONS_FLOW = { + "init": SchemaFlowFormStep( + get_options_schema, + validate_user_input=validate_options, + ), +} + + +class CompensationConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle a config flow for Compensation.""" + + config_flow = CONFIG_FLOW + options_flow = OPTIONS_FLOW + + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return cast(str, options[CONF_NAME]) diff --git a/homeassistant/components/compensation/const.py b/homeassistant/components/compensation/const.py index ce959469700..13b9740afa5 100644 --- a/homeassistant/components/compensation/const.py +++ b/homeassistant/components/compensation/const.py @@ -1,6 +1,9 @@ """Compensation constants.""" +from homeassistant.const import Platform + DOMAIN = "compensation" +PLATFORMS = [Platform.SENSOR] SENSOR = "compensation" diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json index e166ca716cb..0aeb1d8abe1 100644 --- a/homeassistant/components/compensation/manifest.json +++ b/homeassistant/components/compensation/manifest.json @@ -2,7 +2,9 @@ "domain": "compensation", "name": "Compensation", "codeowners": ["@Petro31"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/compensation", + "integration_type": "helper", "iot_class": "calculated", "requirements": ["numpy==1.26.0"] } diff --git a/homeassistant/components/compensation/sensor.py b/homeassistant/components/compensation/sensor.py index 95695932540..b8836f8dfa6 100644 --- a/homeassistant/components/compensation/sensor.py +++ b/homeassistant/components/compensation/sensor.py @@ -8,6 +8,7 @@ from typing import Any import numpy as np from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_ATTRIBUTE, @@ -80,6 +81,36 @@ async def async_setup_platform( ) +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Compensation sensor entry.""" + compensation = entry.entry_id + conf: dict[str, Any] = hass.data[DATA_COMPENSATION][compensation] + + source: str = conf[CONF_SOURCE] + attribute: str | None = conf.get(CONF_ATTRIBUTE) + name = entry.title + + async_add_entities( + [ + CompensationSensor( + conf.get(CONF_UNIQUE_ID), + name, + source, + attribute, + conf[CONF_PRECISION], + conf[CONF_POLYNOMIAL], + conf.get(CONF_UNIT_OF_MEASUREMENT), + conf[CONF_MINIMUM], + conf[CONF_MAXIMUM], + ) + ] + ) + + class CompensationSensor(SensorEntity): """Representation of a Compensation sensor.""" diff --git a/homeassistant/components/compensation/strings.json b/homeassistant/components/compensation/strings.json new file mode 100644 index 00000000000..568144b66c6 --- /dev/null +++ b/homeassistant/components/compensation/strings.json @@ -0,0 +1,77 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "incorrect_datapoints": "Datapoints needs to be provided in list-format, ex. '[1.0, 0.0]'.", + "not_enough_datapoints": "The number of datapoints needs to be less or equal to configured degree." + }, + "step": { + "user": { + "description": "Add a compensation sensor", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "entity_id": "Entity" + }, + "data_description": { + "name": "Name for the created entity.", + "entity_id": "Entity to use as source." + } + }, + "options": { + "description": "Read the documention for further details on how to configure the statistics sensor using these options.", + "data": { + "data_points": "Data points", + "attribute": "Attribute", + "upper_limit": "Upper limit", + "lower_limit": "Lower limit", + "precision": "Precision", + "degree": "Degree", + "unit_of_measurement": "Unit of measurement" + }, + "data_description": { + "data_points": "The collection of data point conversions with the format '[uncompensated_value, compensated_value]'", + "attribute": "Attribute from the source to monitor/compensate.", + "upper_limit": "Enables an upper limit for the sensor. The upper limit is defined by the data collections (data_points) greatest uncompensated value.", + "lower_limit": "Enables a lower limit for the sensor. The lower limit is defined by the data collections (data_points) lowest uncompensated value.", + "precision": "Defines the precision of the calculated values, through the argument of round().", + "degree": "The degree of a polynomial.", + "unit_of_measurement": "Defines the units of measurement of the sensor, if any." + } + } + } + }, + "options": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "error": { + "incorrect_datapoints": "[%key:component::compensation::config::error::incorrect_datapoints%]", + "not_enough_datapoints": "[%key:component::compensation::config::error::not_enough_datapoints%]" + }, + "step": { + "init": { + "description": "[%key:component::compensation::config::step::options::description%]", + "data": { + "data_points": "[%key:component::compensation::config::step::options::data::data_points%]", + "attribute": "[%key:component::compensation::config::step::options::data::attribute%]", + "upper_limit": "[%key:component::compensation::config::step::options::data::upper_limit%]", + "lower_limit": "[%key:component::compensation::config::step::options::data::lower_limit%]", + "precision": "[%key:component::compensation::config::step::options::data::precision%]", + "degree": "[%key:component::compensation::config::step::options::data::degree%]", + "unit_of_measurement": "[%key:component::compensation::config::step::options::data::unit_of_measurement%]" + }, + "data_description": { + "data_points": "[%key:component::compensation::config::step::options::data_description::data_points%]", + "attribute": "[%key:component::compensation::config::step::options::data_description::attribute%]", + "upper_limit": "[%key:component::compensation::config::step::options::data_description::upper_limit%]", + "lower_limit": "[%key:component::compensation::config::step::options::data_description::lower_limit%]", + "precision": "[%key:component::compensation::config::step::options::data_description::precision%]", + "degree": "[%key:component::compensation::config::step::options::data_description::degree%]", + "unit_of_measurement": "[%key:component::compensation::config::step::options::data_description::unit_of_measurement%]" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 0a1b5e96516..e22e53ffdba 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -5,6 +5,7 @@ To update, run python3 -m script.hassfest FLOWS = { "helper": [ + "compensation", "derivative", "generic_hygrostat", "generic_thermostat", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 90895c45cbd..08c6972a820 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1018,12 +1018,6 @@ "config_flow": false, "iot_class": "local_polling" }, - "compensation": { - "name": "Compensation", - "integration_type": "hub", - "config_flow": false, - "iot_class": "calculated" - }, "concord232": { "name": "Concord232", "integration_type": "hub", @@ -7162,6 +7156,12 @@ } }, "helper": { + "compensation": { + "name": "Compensation", + "integration_type": "helper", + "config_flow": true, + "iot_class": "calculated" + }, "counter": { "integration_type": "helper", "config_flow": false