From d73e12df938e4f9ef37ddbab2f8602a29f61c79d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 12 Jul 2024 14:35:49 +0000 Subject: [PATCH 1/5] 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 fae416e7fc24c..5e09cf2600304 100644 --- a/homeassistant/components/compensation/__init__.py +++ b/homeassistant/components/compensation/__init__.py @@ -7,15 +7,18 @@ 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 @@ DEFAULT_DEGREE, DEFAULT_PRECISION, DOMAIN, + PLATFORMS, ) _LOGGER = logging.getLogger(__name__) @@ -77,59 +81,96 @@ def datapoints_greater_than_degree(value: dict) -> dict: ) +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 0000000000000..75cb56e8edb78 --- /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 ce9594697006a..13b9740afa5f2 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 e166ca716cb76..0aeb1d8abe19b 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 956959325400e..b8836f8dfa6bd 100644 --- a/homeassistant/components/compensation/sensor.py +++ b/homeassistant/components/compensation/sensor.py @@ -8,6 +8,7 @@ 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 0000000000000..568144b66c614 --- /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 0a1b5e96516a1..e22e53ffdbae5 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -5,6 +5,7 @@ FLOWS = { "helper": [ + "compensation", "derivative", "generic_hygrostat", "generic_thermostat", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 90895c45cbdb8..08c6972a820fe 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 From ebb450db4892d1aac0c625a69aa4ad531747e0f2 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 12 Jul 2024 15:05:09 +0000 Subject: [PATCH 2/5] Fixes --- .../components/compensation/__init__.py | 10 +++++++++- .../components/compensation/config_flow.py | 19 ++++++++++++++++--- .../components/compensation/sensor.py | 5 +++-- .../components/compensation/strings.json | 6 +++--- 4 files changed, 31 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/compensation/__init__.py b/homeassistant/components/compensation/__init__.py index 5e09cf2600304..cb3e5e165846f 100644 --- a/homeassistant/components/compensation/__init__.py +++ b/homeassistant/components/compensation/__init__.py @@ -159,7 +159,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 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) + config = dict(entry.options) + data_points = config[CONF_DATAPOINTS] + new_data_points = [] + for data_point in data_points: + values = data_point.split(",", maxsplit=1) + new_data_points.append([float(values[0]), float(values[1])]) + config[CONF_DATAPOINTS] = new_data_points + + await create_compensation_data(hass, entry.entry_id, config, True) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) diff --git a/homeassistant/components/compensation/config_flow.py b/homeassistant/components/compensation/config_flow.py index 75cb56e8edb78..09009002abe91 100644 --- a/homeassistant/components/compensation/config_flow.py +++ b/homeassistant/components/compensation/config_flow.py @@ -76,6 +76,20 @@ async def get_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: ) +def _is_valid_data_points(check_data_points: list[str]) -> bool: + """Validate data points.""" + for data_point in check_data_points: + if data_point.find(",") > 0: + values = data_point.split(",", maxsplit=1) + for value in values: + try: + float(value) + except ValueError: + return False + return True + return False + + async def validate_options( handler: SchemaCommonFlowHandler, user_input: dict[str, Any] ) -> dict[str, Any]: @@ -84,9 +98,8 @@ async def validate_options( 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 not _is_valid_data_points(user_input[CONF_DATAPOINTS]): + raise SchemaFlowError("incorrect_datapoints") if len(user_input[CONF_DATAPOINTS]) <= user_input[CONF_DEGREE]: raise SchemaFlowError("not_enough_datapoints") diff --git a/homeassistant/components/compensation/sensor.py b/homeassistant/components/compensation/sensor.py index b8836f8dfa6bd..b13b75fc42b65 100644 --- a/homeassistant/components/compensation/sensor.py +++ b/homeassistant/components/compensation/sensor.py @@ -12,6 +12,7 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_ATTRIBUTE, + CONF_ENTITY_ID, CONF_MAXIMUM, CONF_MINIMUM, CONF_SOURCE, @@ -90,14 +91,14 @@ async def async_setup_entry( compensation = entry.entry_id conf: dict[str, Any] = hass.data[DATA_COMPENSATION][compensation] - source: str = conf[CONF_SOURCE] + source: str = conf[CONF_ENTITY_ID] attribute: str | None = conf.get(CONF_ATTRIBUTE) name = entry.title async_add_entities( [ CompensationSensor( - conf.get(CONF_UNIQUE_ID), + entry.entry_id, name, source, attribute, diff --git a/homeassistant/components/compensation/strings.json b/homeassistant/components/compensation/strings.json index 568144b66c614..f7e428272faba 100644 --- a/homeassistant/components/compensation/strings.json +++ b/homeassistant/components/compensation/strings.json @@ -4,8 +4,8 @@ "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." + "incorrect_datapoints": "Datapoints needs to be provided in the right format, ex. '1.0, 0.0'.", + "not_enough_datapoints": "The number of datapoints needs to be more than the configured degree." }, "step": { "user": { @@ -31,7 +31,7 @@ "unit_of_measurement": "Unit of measurement" }, "data_description": { - "data_points": "The collection of data point conversions with the format '[uncompensated_value, compensated_value]'", + "data_points": "The collection of data point conversions with the format 'uncompensated_value, compensated_value', ex. '1.0, 0.0'", "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.", From 42fe1d6097e73eb9137f63de54b113c956e54dd5 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 12 Jul 2024 17:14:55 +0000 Subject: [PATCH 3/5] Fixes --- .../components/compensation/config_flow.py | 20 ++++++++++--------- .../components/compensation/strings.json | 5 +++++ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/compensation/config_flow.py b/homeassistant/components/compensation/config_flow.py index 09009002abe91..f00b90d978cf2 100644 --- a/homeassistant/components/compensation/config_flow.py +++ b/homeassistant/components/compensation/config_flow.py @@ -78,16 +78,18 @@ async def get_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: def _is_valid_data_points(check_data_points: list[str]) -> bool: """Validate data points.""" + result = False for data_point in check_data_points: - if data_point.find(",") > 0: - values = data_point.split(",", maxsplit=1) - for value in values: - try: - float(value) - except ValueError: - return False - return True - return False + if not data_point.find(",") > 0: + return False + values = data_point.split(",", maxsplit=1) + for value in values: + try: + float(value) + except ValueError: + return False + result = True + return result async def validate_options( diff --git a/homeassistant/components/compensation/strings.json b/homeassistant/components/compensation/strings.json index f7e428272faba..45753c5f6d364 100644 --- a/homeassistant/components/compensation/strings.json +++ b/homeassistant/components/compensation/strings.json @@ -73,5 +73,10 @@ } } } + }, + "exceptions": { + "setup_error": { + "message": "Setup of {title} could not be setup due to {error}" + } } } From 22bb68d6107f1b9b251f5edb76fc6dc0ffec8195 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 12 Jul 2024 17:15:18 +0000 Subject: [PATCH 4/5] Add tests --- tests/components/compensation/conftest.py | 81 ++++++ .../compensation/test_config_flow.py | 266 ++++++++++++++++++ tests/components/compensation/test_init.py | 44 +++ tests/components/compensation/test_sensor.py | 33 +++ 4 files changed, 424 insertions(+) create mode 100644 tests/components/compensation/conftest.py create mode 100644 tests/components/compensation/test_config_flow.py create mode 100644 tests/components/compensation/test_init.py diff --git a/tests/components/compensation/conftest.py b/tests/components/compensation/conftest.py new file mode 100644 index 0000000000000..20bceadb35dd2 --- /dev/null +++ b/tests/components/compensation/conftest.py @@ -0,0 +1,81 @@ +"""Fixtures for the Compensation integration.""" + +from __future__ import annotations + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.compensation.const import ( + CONF_DATAPOINTS, + CONF_DEGREE, + CONF_LOWER_LIMIT, + CONF_PRECISION, + CONF_UPPER_LIMIT, + DEFAULT_DEGREE, + DEFAULT_NAME, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_UNIT_OF_MEASUREMENT +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Automatically patch compensation setup_entry.""" + with patch( + "homeassistant.components.compensation.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="get_config") +async def get_config_to_integration_load() -> dict[str, Any]: + """Return configuration. + + To override the config, tests can be marked with: + @pytest.mark.parametrize("get_config", [{...}]) + """ + return { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.uncompensated", + CONF_DATAPOINTS: [ + "1.0, 2.0", + "2.0, 3.0", + ], + CONF_UPPER_LIMIT: False, + CONF_LOWER_LIMIT: False, + CONF_PRECISION: 2, + CONF_DEGREE: DEFAULT_DEGREE, + CONF_UNIT_OF_MEASUREMENT: "mm", + } + + +@pytest.fixture(name="loaded_entry") +async def load_integration( + hass: HomeAssistant, get_config: dict[str, Any] +) -> MockConfigEntry: + """Set up the Compensation integration in Home Assistant.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="Compensation sensor", + source=SOURCE_USER, + options=get_config, + entry_id="1", + ) + config_entry.add_to_hass(hass) + + entity_id = get_config[CONF_ENTITY_ID] + hass.states.async_set(entity_id, 4, {}) + await hass.async_block_till_done() + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/compensation/test_config_flow.py b/tests/components/compensation/test_config_flow.py new file mode 100644 index 0000000000000..2639a6c0d0320 --- /dev/null +++ b/tests/components/compensation/test_config_flow.py @@ -0,0 +1,266 @@ +"""Test the Compensation config flow.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from homeassistant import config_entries +from homeassistant.components.compensation.const import ( + CONF_DATAPOINTS, + CONF_DEGREE, + CONF_LOWER_LIMIT, + CONF_PRECISION, + CONF_UPPER_LIMIT, + DEFAULT_NAME, + DOMAIN, +) +from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_UNIT_OF_MEASUREMENT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + }, + ) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_DATAPOINTS: [ + "1.0, 2.0", + "2.0, 3.0", + ], + CONF_UPPER_LIMIT: False, + CONF_LOWER_LIMIT: False, + CONF_PRECISION: 2, + CONF_DEGREE: 1, + CONF_UNIT_OF_MEASUREMENT: "mm", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["version"] == 1 + assert result["options"] == { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_DATAPOINTS: [ + "1.0, 2.0", + "2.0, 3.0", + ], + CONF_UPPER_LIMIT: False, + CONF_LOWER_LIMIT: False, + CONF_PRECISION: 2, + CONF_DEGREE: 1, + CONF_UNIT_OF_MEASUREMENT: "mm", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_options_flow(hass: HomeAssistant, loaded_entry: MockConfigEntry) -> None: + """Test options flow.""" + + result = await hass.config_entries.options.async_init(loaded_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_DATAPOINTS: [ + "1.0, 2.0", + "2.0, 3.0", + ], + CONF_UPPER_LIMIT: False, + CONF_LOWER_LIMIT: False, + CONF_PRECISION: 2, + CONF_DEGREE: 1, + CONF_UNIT_OF_MEASUREMENT: "km", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.uncompensated", + CONF_DATAPOINTS: [ + "1.0, 2.0", + "2.0, 3.0", + ], + CONF_UPPER_LIMIT: False, + CONF_LOWER_LIMIT: False, + CONF_PRECISION: 2, + CONF_DEGREE: 1, + CONF_UNIT_OF_MEASUREMENT: "km", + } + + await hass.async_block_till_done() + + # Check the entity was updated, no new entity was created + assert len(hass.states.async_all()) == 2 + + state = hass.states.get("sensor.compensation_sensor") + assert state is not None + + +async def test_validation_options( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test validation.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + }, + ) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_DATAPOINTS: [ + "1.0, 2.0", + "2.0, 3.0", + ], + CONF_UPPER_LIMIT: False, + CONF_LOWER_LIMIT: False, + CONF_PRECISION: 2, + CONF_DEGREE: 2, + CONF_UNIT_OF_MEASUREMENT: "km", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "not_enough_datapoints"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_DATAPOINTS: [ + "1.0, 2.0", + "2.0 3.0", + ], + CONF_UPPER_LIMIT: False, + CONF_LOWER_LIMIT: False, + CONF_PRECISION: 2, + CONF_DEGREE: 1, + CONF_UNIT_OF_MEASUREMENT: "km", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "incorrect_datapoints"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_DATAPOINTS: [ + "1.0, 2.0", + "2,0, 3.0", + ], + CONF_UPPER_LIMIT: False, + CONF_LOWER_LIMIT: False, + CONF_PRECISION: 2, + CONF_DEGREE: 1, + CONF_UNIT_OF_MEASUREMENT: "km", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "incorrect_datapoints"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_DATAPOINTS: ["1.0, 2.0", "2.0, 3.0", "3.0, 4.0"], + CONF_UPPER_LIMIT: False, + CONF_LOWER_LIMIT: False, + CONF_PRECISION: 2, + CONF_DEGREE: 2, + CONF_UNIT_OF_MEASUREMENT: "km", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["version"] == 1 + assert result["options"] == { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_DATAPOINTS: ["1.0, 2.0", "2.0, 3.0", "3.0, 4.0"], + CONF_UPPER_LIMIT: False, + CONF_LOWER_LIMIT: False, + CONF_PRECISION: 2, + CONF_DEGREE: 2, + CONF_UNIT_OF_MEASUREMENT: "km", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_entry_already_exist( + hass: HomeAssistant, loaded_entry: MockConfigEntry +) -> None: + """Test abort when entry already exist.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.uncompensated", + }, + ) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_DATAPOINTS: [ + "1.0, 2.0", + "2.0, 3.0", + ], + CONF_UPPER_LIMIT: False, + CONF_LOWER_LIMIT: False, + CONF_PRECISION: 2, + CONF_DEGREE: 1, + CONF_UNIT_OF_MEASUREMENT: "mm", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/compensation/test_init.py b/tests/components/compensation/test_init.py new file mode 100644 index 0000000000000..369f72f6aee89 --- /dev/null +++ b/tests/components/compensation/test_init.py @@ -0,0 +1,44 @@ +"""Test Statistics component setup process.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import patch + +from homeassistant.components.compensation.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_unload_entry(hass: HomeAssistant, loaded_entry: MockConfigEntry) -> None: + """Test unload an entry.""" + + assert loaded_entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(loaded_entry.entry_id) + await hass.async_block_till_done() + assert loaded_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_could_not_setup(hass: HomeAssistant, get_config: dict[str, Any]) -> None: + """Test exception.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + title="Compensation sensor", + source=SOURCE_USER, + options=get_config, + entry_id="1", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.compensation.np.polyfit", + side_effect=FloatingPointError, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_ERROR + assert config_entry.error_reason_translation_key == "setup_error" diff --git a/tests/components/compensation/test_sensor.py b/tests/components/compensation/test_sensor.py index 877a4f972a9d4..88bd1ccb47d2f 100644 --- a/tests/components/compensation/test_sensor.py +++ b/tests/components/compensation/test_sensor.py @@ -1,5 +1,7 @@ """The tests for the integration sensor platform.""" +from typing import Any + import pytest from homeassistant.components.compensation.const import CONF_PRECISION, DOMAIN @@ -7,6 +9,7 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, + CONF_ENTITY_ID, EVENT_HOMEASSISTANT_START, EVENT_STATE_CHANGED, STATE_UNKNOWN, @@ -14,6 +17,8 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry + async def test_linear_state(hass: HomeAssistant) -> None: """Test compensation sensor state.""" @@ -60,6 +65,34 @@ async def test_linear_state(hass: HomeAssistant) -> None: assert state.state == STATE_UNKNOWN +async def test_linear_state_from_config_entry( + hass: HomeAssistant, loaded_entry: MockConfigEntry, get_config: dict[str, Any] +) -> None: + """Test compensation sensor state loaded from config entry.""" + expected_entity_id = "sensor.compensation_sensor" + entity_id = get_config[CONF_ENTITY_ID] + + hass.states.async_set(entity_id, 5, {}) + await hass.async_block_till_done() + + state = hass.states.get(expected_entity_id) + assert state is not None + assert round(float(state.state), get_config[CONF_PRECISION]) == 6.0 + + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "mm" + + coefs = [round(v, 1) for v in state.attributes.get(ATTR_COEFFICIENTS)] + assert coefs == [1.0, 1.0] + + hass.states.async_set(entity_id, "foo", {}) + await hass.async_block_till_done() + + state = hass.states.get(expected_entity_id) + assert state is not None + + assert state.state == STATE_UNKNOWN + + async def test_linear_state_from_attribute(hass: HomeAssistant) -> None: """Test compensation sensor state that pulls from attribute.""" config = { From 02c0cfd680ea31067da69333f908659272d81eed Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 12 Jul 2024 17:20:04 +0000 Subject: [PATCH 5/5] Don't load from platform yaml --- tests/components/compensation/test_sensor.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/components/compensation/test_sensor.py b/tests/components/compensation/test_sensor.py index 88bd1ccb47d2f..4d9362bc64682 100644 --- a/tests/components/compensation/test_sensor.py +++ b/tests/components/compensation/test_sensor.py @@ -10,6 +10,7 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_ENTITY_ID, + CONF_PLATFORM, EVENT_HOMEASSISTANT_START, EVENT_STATE_CHANGED, STATE_UNKNOWN, @@ -20,6 +21,22 @@ from tests.common import MockConfigEntry +async def test_not_loading_from_platform_yaml(hass: HomeAssistant) -> None: + """Test compensation sensor not loaded from platform YAML.""" + config = { + "sensor": [ + { + CONF_PLATFORM: DOMAIN, + } + ] + } + + assert await async_setup_component(hass, SENSOR_DOMAIN, config) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + + async def test_linear_state(hass: HomeAssistant) -> None: """Test compensation sensor state.""" config = {