From 0655ebbd843a0845c779d2a25b5bd2991dd7544f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 18 Mar 2022 09:52:17 +0100 Subject: [PATCH] Add config flow for integration sensor (#68288) --- .../components/integration/__init__.py | 24 ++- .../components/integration/config_flow.py | 103 +++++++++++++ homeassistant/components/integration/const.py | 14 ++ .../components/integration/manifest.json | 7 +- .../components/integration/sensor.py | 90 +++++++---- .../components/integration/strings.json | 28 ++++ .../integration/translations/en.json | 28 ++++ homeassistant/generated/config_flows.py | 1 + .../integration/test_config_flow.py | 145 ++++++++++++++++++ tests/components/integration/test_init.py | 61 ++++++++ 10 files changed, 471 insertions(+), 30 deletions(-) create mode 100644 homeassistant/components/integration/config_flow.py create mode 100644 homeassistant/components/integration/const.py create mode 100644 homeassistant/components/integration/strings.json create mode 100644 homeassistant/components/integration/translations/en.json create mode 100644 tests/components/integration/test_config_flow.py create mode 100644 tests/components/integration/test_init.py diff --git a/homeassistant/components/integration/__init__.py b/homeassistant/components/integration/__init__.py index 4eea25fefe1..84bc28e3d2f 100644 --- a/homeassistant/components/integration/__init__.py +++ b/homeassistant/components/integration/__init__.py @@ -1 +1,23 @@ -"""The integration component.""" +"""The Integration integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Integration from a config entry.""" + hass.config_entries.async_setup_platforms(entry, (Platform.SENSOR,)) + entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) + return True + + +async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update listener, called when the config entry options are changed.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, (Platform.SENSOR,)) diff --git a/homeassistant/components/integration/config_flow.py b/homeassistant/components/integration/config_flow.py new file mode 100644 index 00000000000..76379a89002 --- /dev/null +++ b/homeassistant/components/integration/config_flow.py @@ -0,0 +1,103 @@ +"""Config flow for Integration - Riemann sum integral integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, cast + +import voluptuous as vol + +from homeassistant.const import ( + CONF_METHOD, + CONF_NAME, + CONF_UNIT_OF_MEASUREMENT, + TIME_DAYS, + TIME_HOURS, + TIME_MINUTES, + TIME_SECONDS, +) +from homeassistant.helpers import selector +from homeassistant.helpers.helper_config_entry_flow import ( + HelperConfigFlowHandler, + HelperFlowStep, +) + +from .const import ( + CONF_ROUND_DIGITS, + CONF_SOURCE_SENSOR, + CONF_UNIT_PREFIX, + CONF_UNIT_TIME, + DOMAIN, + METHOD_LEFT, + METHOD_RIGHT, + METHOD_TRAPEZOIDAL, +) + +UNIT_PREFIXES = [ + {"value": "none", "label": "none"}, + {"value": "k", "label": "k (kilo)"}, + {"value": "M", "label": "M (mega)"}, + {"value": "G", "label": "T (tera)"}, + {"value": "T", "label": "P (peta)"}, +] +TIME_UNITS = [ + {"value": TIME_SECONDS, "label": "s (seconds)"}, + {"value": TIME_MINUTES, "label": "min (minutes)"}, + {"value": TIME_HOURS, "label": "h (hours)"}, + {"value": TIME_DAYS, "label": "d (days)"}, +] +INTEGRATION_METHODS = [ + {"value": METHOD_TRAPEZOIDAL, "label": "Trapezoidal rule"}, + {"value": METHOD_LEFT, "label": "Left Riemann sum"}, + {"value": METHOD_RIGHT, "label": "Right Riemann sum"}, +] + +OPTIONS_SCHEMA = vol.Schema( + { + vol.Required(CONF_ROUND_DIGITS, default=2): selector.selector( + {"number": {"min": 0, "max": 6, "mode": "box"}} + ), + } +) + +CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): selector.selector({"text": {}}), + vol.Required(CONF_SOURCE_SENSOR): selector.selector( + {"entity": {"domain": "sensor"}}, + ), + vol.Required(CONF_METHOD, default=METHOD_TRAPEZOIDAL): selector.selector( + {"select": {"options": INTEGRATION_METHODS}} + ), + vol.Required(CONF_ROUND_DIGITS, default=2): selector.selector( + { + "number": { + "min": 0, + "max": 6, + "mode": "box", + CONF_UNIT_OF_MEASUREMENT: "decimals", + } + } + ), + vol.Required(CONF_UNIT_PREFIX, default="none"): selector.selector( + {"select": {"options": UNIT_PREFIXES}} + ), + vol.Required(CONF_UNIT_TIME, default=TIME_HOURS): selector.selector( + {"select": {"options": TIME_UNITS}} + ), + } +) + +CONFIG_FLOW = {"user": HelperFlowStep(CONFIG_SCHEMA)} + +OPTIONS_FLOW = {"init": HelperFlowStep(OPTIONS_SCHEMA)} + + +class ConfigFlowHandler(HelperConfigFlowHandler, domain=DOMAIN): + """Handle a config or options flow for Integration.""" + + 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["name"]) diff --git a/homeassistant/components/integration/const.py b/homeassistant/components/integration/const.py new file mode 100644 index 00000000000..b05e4e8f80b --- /dev/null +++ b/homeassistant/components/integration/const.py @@ -0,0 +1,14 @@ +"""Constants for the Integration - Riemann sum integral integration.""" + +DOMAIN = "integration" + +CONF_ROUND_DIGITS = "round" +CONF_SOURCE_SENSOR = "source" +CONF_UNIT_OF_MEASUREMENT = "unit" +CONF_UNIT_PREFIX = "unit_prefix" +CONF_UNIT_TIME = "unit_time" + +METHOD_TRAPEZOIDAL = "trapezoidal" +METHOD_LEFT = "left" +METHOD_RIGHT = "right" +INTEGRATION_METHODS = [METHOD_TRAPEZOIDAL, METHOD_LEFT, METHOD_RIGHT] diff --git a/homeassistant/components/integration/manifest.json b/homeassistant/components/integration/manifest.json index afec4dbe9ec..a36dc51555f 100644 --- a/homeassistant/components/integration/manifest.json +++ b/homeassistant/components/integration/manifest.json @@ -2,7 +2,10 @@ "domain": "integration", "name": "Integration - Riemann sum integral", "documentation": "https://www.home-assistant.io/integrations/integration", - "codeowners": ["@dgomes"], + "codeowners": [ + "@dgomes" + ], "quality_scale": "internal", - "iot_class": "local_push" + "iot_class": "local_push", + "config_flow": true } diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 7a6248254d8..837886bbf56 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, @@ -25,29 +26,30 @@ from homeassistant.const import ( TIME_SECONDS, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import ( + CONF_ROUND_DIGITS, + CONF_SOURCE_SENSOR, + CONF_UNIT_OF_MEASUREMENT, + CONF_UNIT_PREFIX, + CONF_UNIT_TIME, + INTEGRATION_METHODS, + METHOD_LEFT, + METHOD_RIGHT, + METHOD_TRAPEZOIDAL, +) + # mypy: allow-untyped-defs, no-check-untyped-defs _LOGGER = logging.getLogger(__name__) ATTR_SOURCE_ID = "source" -CONF_SOURCE_SENSOR = "source" -CONF_ROUND_DIGITS = "round" -CONF_UNIT_PREFIX = "unit_prefix" -CONF_UNIT_TIME = "unit_time" -CONF_UNIT_OF_MEASUREMENT = "unit" - -TRAPEZOIDAL_METHOD = "trapezoidal" -LEFT_METHOD = "left" -RIGHT_METHOD = "right" -INTEGRATION_METHOD = [TRAPEZOIDAL_METHOD, LEFT_METHOD, RIGHT_METHOD] - # SI Metric prefixes UNIT_PREFIXES = {None: 1, "k": 10**3, "M": 10**6, "G": 10**9, "T": 10**12} @@ -73,14 +75,44 @@ PLATFORM_SCHEMA = vol.All( vol.Optional(CONF_UNIT_PREFIX, default=None): vol.In(UNIT_PREFIXES), vol.Optional(CONF_UNIT_TIME, default=TIME_HOURS): vol.In(UNIT_TIME), vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_METHOD, default=TRAPEZOIDAL_METHOD): vol.In( - INTEGRATION_METHOD + vol.Optional(CONF_METHOD, default=METHOD_TRAPEZOIDAL): vol.In( + INTEGRATION_METHODS ), } ), ) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize Integration - Riemann sum integral config entry.""" + registry = er.async_get(hass) + # Validate + resolve entity registry id to entity_id + source_entity_id = er.async_validate_entity_id( + registry, config_entry.options[CONF_SOURCE_SENSOR] + ) + + unit_prefix = config_entry.options[CONF_UNIT_PREFIX] + if unit_prefix == "none": + unit_prefix = None + + integral = IntegrationSensor( + integration_method=config_entry.options[CONF_METHOD], + name=config_entry.title, + round_digits=int(config_entry.options[CONF_ROUND_DIGITS]), + source_entity=source_entity_id, + unique_id=config_entry.entry_id, + unit_of_measurement=None, + unit_prefix=unit_prefix, + unit_time=config_entry.options[CONF_UNIT_TIME], + ) + + async_add_entities([integral]) + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -89,13 +121,14 @@ async def async_setup_platform( ) -> None: """Set up the integration sensor.""" integral = IntegrationSensor( - config[CONF_SOURCE_SENSOR], - config.get(CONF_NAME), - config[CONF_ROUND_DIGITS], - config[CONF_UNIT_PREFIX], - config[CONF_UNIT_TIME], - config.get(CONF_UNIT_OF_MEASUREMENT), - config[CONF_METHOD], + integration_method=config[CONF_METHOD], + name=config.get(CONF_NAME), + round_digits=config[CONF_ROUND_DIGITS], + source_entity=config[CONF_SOURCE_SENSOR], + unique_id=None, + unit_of_measurement=config.get(CONF_UNIT_OF_MEASUREMENT), + unit_prefix=config[CONF_UNIT_PREFIX], + unit_time=config[CONF_UNIT_TIME], ) async_add_entities([integral]) @@ -106,15 +139,18 @@ class IntegrationSensor(RestoreEntity, SensorEntity): def __init__( self, - source_entity: str, + *, + integration_method: str, name: str | None, round_digits: int, + source_entity: str, + unique_id: str | None, + unit_of_measurement: str | None, unit_prefix: str | None, unit_time: str, - unit_of_measurement: str | None, - integration_method: str, ) -> None: """Initialize the integration sensor.""" + self._attr_unique_id = unique_id self._sensor_source_id = source_entity self._round_digits = round_digits self._state = None @@ -187,15 +223,15 @@ class IntegrationSensor(RestoreEntity, SensorEntity): new_state.last_updated - old_state.last_updated ).total_seconds() - if self._method == TRAPEZOIDAL_METHOD: + if self._method == METHOD_TRAPEZOIDAL: area = ( (Decimal(new_state.state) + Decimal(old_state.state)) * Decimal(elapsed_time) / 2 ) - elif self._method == LEFT_METHOD: + elif self._method == METHOD_LEFT: area = Decimal(old_state.state) * Decimal(elapsed_time) - elif self._method == RIGHT_METHOD: + elif self._method == METHOD_RIGHT: area = Decimal(new_state.state) * Decimal(elapsed_time) integral = area / (self._unit_prefix * self._unit_time) diff --git a/homeassistant/components/integration/strings.json b/homeassistant/components/integration/strings.json new file mode 100644 index 00000000000..9566ca686d6 --- /dev/null +++ b/homeassistant/components/integration/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "step": { + "user": { + "title": "New Integration sensor", + "description": "Precision controls the number of decimal digits in the output.\nThe sum will be scaled according to the selected metric prefix and integration time.", + "data": { + "method": "Integration method", + "name": "Name", + "round": "Precision", + "source": "Input sensor", + "unit_prefix": "Metric prefix", + "unit_time": "Integration time" + } + } + } + }, + "options": { + "step": { + "options": { + "description": "Precision controls the number of decimal digits in the output.", + "data": { + "round": "[%key:component::integration::config::step::user::data::round%]" + } + } + } + } +} diff --git a/homeassistant/components/integration/translations/en.json b/homeassistant/components/integration/translations/en.json new file mode 100644 index 00000000000..31223f01842 --- /dev/null +++ b/homeassistant/components/integration/translations/en.json @@ -0,0 +1,28 @@ +{ + "config": { + "step": { + "user": { + "data": { + "method": "Integration method", + "name": "Name", + "round": "Precision", + "source": "Input sensor", + "unit_prefix": "Metric prefix", + "unit_time": "Integration time" + }, + "description": "Precision controls the number of decimal digits in the output.\nThe sum will be scaled according to the selected metric prefix and integration time.", + "title": "New Integration sensor" + } + } + }, + "options": { + "step": { + "options": { + "data": { + "round": "Precision" + }, + "description": "Precision controls the number of decimal digits in the output." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6d572efb0c7..d3c6e4816e0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -157,6 +157,7 @@ FLOWS = [ "icloud", "ifttt", "insteon", + "integration", "intellifire", "ios", "iotawatt", diff --git a/tests/components/integration/test_config_flow.py b/tests/components/integration/test_config_flow.py new file mode 100644 index 00000000000..5992d480f80 --- /dev/null +++ b/tests/components/integration/test_config_flow.py @@ -0,0 +1,145 @@ +"""Test the Integration - Riemann sum integral config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.integration.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize("platform", ("sensor",)) +async def test_config_flow(hass: HomeAssistant, platform) -> None: + """Test the config flow.""" + input_sensor_entity_id = "sensor.input" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.integration.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "method": "left", + "name": "My integration", + "round": 1, + "source": input_sensor_entity_id, + "unit_prefix": "none", + "unit_time": "min", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "My integration" + assert result["data"] == {} + assert result["options"] == { + "method": "left", + "name": "My integration", + "round": 1.0, + "source": "sensor.input", + "unit_prefix": "none", + "unit_time": "min", + } + assert len(mock_setup_entry.mock_calls) == 1 + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + assert config_entry.data == {} + assert config_entry.options == { + "method": "left", + "name": "My integration", + "round": 1.0, + "source": "sensor.input", + "unit_prefix": "none", + "unit_time": "min", + } + assert config_entry.title == "My integration" + + +def get_suggested(schema, key): + """Get suggested value for key in voluptuous schema.""" + for k in schema.keys(): + if k == key: + if k.description is None or "suggested_value" not in k.description: + return None + return k.description["suggested_value"] + # Wanted key absent from schema + raise Exception + + +@pytest.mark.parametrize("platform", ("sensor",)) +async def test_options(hass: HomeAssistant, platform) -> None: + """Test reconfiguring.""" + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "method": "left", + "name": "My integration", + "round": 1.0, + "source": "sensor.input", + "unit_prefix": "k", + "unit_time": "min", + }, + title="My integration", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "init" + schema = result["data_schema"].schema + assert get_suggested(schema, "round") == 1.0 + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "round": 2.0, + }, + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + "method": "left", + "name": "My integration", + "round": 2.0, + "source": "sensor.input", + "unit_prefix": "k", + "unit_time": "min", + } + assert config_entry.data == {} + assert config_entry.options == { + "method": "left", + "name": "My integration", + "round": 2.0, + "source": "sensor.input", + "unit_prefix": "k", + "unit_time": "min", + } + assert config_entry.title == "My integration" + + # Check config entry is reloaded with new options + await hass.async_block_till_done() + + # Check the entity was updated, no new entity was created + assert len(hass.states.async_all()) == 1 + + # Check the state of the entity has changed as expected + hass.states.async_set("sensor.input", 10, {"unit_of_measurement": "dog"}) + hass.states.async_set("sensor.input", 11, {"unit_of_measurement": "dog"}) + await hass.async_block_till_done() + + state = hass.states.get(f"{platform}.my_integration") + assert state.state != "unknown" + assert state.attributes["unit_of_measurement"] == "kdogmin" diff --git a/tests/components/integration/test_init.py b/tests/components/integration/test_init.py new file mode 100644 index 00000000000..b68e3cdb1eb --- /dev/null +++ b/tests/components/integration/test_init.py @@ -0,0 +1,61 @@ +"""Test the Integration - Riemann sum integral integration.""" +import pytest + +from homeassistant.components.integration.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize("platform", ("sensor",)) +async def test_setup_and_remove_config_entry( + hass: HomeAssistant, + platform: str, +) -> None: + """Test setting up and removing a config entry.""" + input_sensor_entity_id = "sensor.input" + registry = er.async_get(hass) + integration_entity_id = f"{platform}.my_integration" + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "method": "trapezoidal", + "name": "My integration", + "round": 1.0, + "source": "sensor.input", + "unit_prefix": "k", + "unit_time": "min", + }, + title="My integration", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Check the entity is registered in the entity registry + assert registry.async_get(integration_entity_id) is not None + + # Check the platform is setup correctly + state = hass.states.get(integration_entity_id) + assert state.state == "unknown" + assert "unit_of_measurement" not in state.attributes + assert state.attributes["source"] == "sensor.input" + + hass.states.async_set(input_sensor_entity_id, 10, {"unit_of_measurement": "cat"}) + hass.states.async_set(input_sensor_entity_id, 11, {"unit_of_measurement": "cat"}) + await hass.async_block_till_done() + state = hass.states.get(integration_entity_id) + assert state.state != "unknown" + assert state.attributes["unit_of_measurement"] == "kcatmin" + + # Remove the config entry + assert await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + + # Check the state and entity registry entry are removed + assert hass.states.get(integration_entity_id) is None + assert registry.async_get(integration_entity_id) is None