From 66d892237d2577d74d03702e9e44b75109c2d785 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 28 Mar 2022 09:39:54 +0200 Subject: [PATCH] Add config flow for min_max sensor (#67807) Co-authored-by: Paulus Schoutsen Co-authored-by: Franck Nijhof --- homeassistant/components/group/config_flow.py | 6 +- homeassistant/components/min_max/__init__.py | 22 ++- .../components/min_max/config_flow.py | 58 ++++++++ homeassistant/components/min_max/const.py | 6 + .../components/min_max/manifest.json | 8 +- homeassistant/components/min_max/sensor.py | 71 +++++++-- homeassistant/components/min_max/strings.json | 28 ++++ .../components/min_max/translations/en.json | 28 ++++ homeassistant/generated/config_flows.py | 1 + tests/components/min_max/test_config_flow.py | 135 ++++++++++++++++++ tests/components/min_max/test_init.py | 55 +++++++ tests/components/min_max/test_sensor.py | 2 +- 12 files changed, 402 insertions(+), 18 deletions(-) create mode 100644 homeassistant/components/min_max/config_flow.py create mode 100644 homeassistant/components/min_max/const.py create mode 100644 homeassistant/components/min_max/strings.json create mode 100644 homeassistant/components/min_max/translations/en.json create mode 100644 tests/components/min_max/test_config_flow.py create mode 100644 tests/components/min_max/test_init.py diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index 23394c0ee59..ed10391cb1d 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -115,7 +115,11 @@ class GroupConfigFlowHandler(HelperConfigFlowHandler, domain=DOMAIN): @callback def async_config_entry_title(self, options: Mapping[str, Any]) -> str: - """Return config entry title.""" + """Return config entry title. + + The options parameter contains config entry options, which is the union of user + input from the config flow steps. + """ return cast(str, options["name"]) if "name" in options else "" @callback diff --git a/homeassistant/components/min_max/__init__.py b/homeassistant/components/min_max/__init__.py index 84b7c0a2cf5..db80473f90a 100644 --- a/homeassistant/components/min_max/__init__.py +++ b/homeassistant/components/min_max/__init__.py @@ -1,6 +1,26 @@ """The min_max component.""" +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform +from homeassistant.core import HomeAssistant -DOMAIN = "min_max" PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Min/Max from a config entry.""" + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + 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, PLATFORMS) diff --git a/homeassistant/components/min_max/config_flow.py b/homeassistant/components/min_max/config_flow.py new file mode 100644 index 00000000000..353a90cbf6c --- /dev/null +++ b/homeassistant/components/min_max/config_flow.py @@ -0,0 +1,58 @@ +"""Config flow for Min/Max integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, cast + +import voluptuous as vol + +from homeassistant.const import CONF_TYPE +from homeassistant.helpers import selector +from homeassistant.helpers.helper_config_entry_flow import ( + HelperConfigFlowHandler, + HelperFlowFormStep, + HelperFlowMenuStep, +) + +from .const import CONF_ENTITY_IDS, CONF_ROUND_DIGITS, DOMAIN + +_STATISTIC_MEASURES = ["last", "max", "mean", "min", "median"] + +OPTIONS_SCHEMA = vol.Schema( + { + vol.Required(CONF_ENTITY_IDS): selector.selector( + {"entity": {"domain": "sensor", "multiple": True}} + ), + vol.Required(CONF_TYPE): selector.selector( + {"select": {"options": _STATISTIC_MEASURES}} + ), + vol.Required(CONF_ROUND_DIGITS, default=2): selector.selector( + {"number": {"min": 0, "max": 6, "mode": "box"}} + ), + } +) + +CONFIG_SCHEMA = vol.Schema( + { + vol.Required("name"): selector.selector({"text": {}}), + } +).extend(OPTIONS_SCHEMA.schema) + +CONFIG_FLOW: dict[str, HelperFlowFormStep | HelperFlowMenuStep] = { + "user": HelperFlowFormStep(CONFIG_SCHEMA) +} + +OPTIONS_FLOW: dict[str, HelperFlowFormStep | HelperFlowMenuStep] = { + "init": HelperFlowFormStep(OPTIONS_SCHEMA) +} + + +class ConfigFlowHandler(HelperConfigFlowHandler, domain=DOMAIN): + """Handle a config or options flow for Min/Max.""" + + 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"]) if "name" in options else "" diff --git a/homeassistant/components/min_max/const.py b/homeassistant/components/min_max/const.py new file mode 100644 index 00000000000..d738eff4774 --- /dev/null +++ b/homeassistant/components/min_max/const.py @@ -0,0 +1,6 @@ +"""Constants for the Min/Max integration.""" + +DOMAIN = "min_max" + +CONF_ENTITY_IDS = "entity_ids" +CONF_ROUND_DIGITS = "round_digits" diff --git a/homeassistant/components/min_max/manifest.json b/homeassistant/components/min_max/manifest.json index cf8c78d46ac..45098f943d1 100644 --- a/homeassistant/components/min_max/manifest.json +++ b/homeassistant/components/min_max/manifest.json @@ -1,8 +1,12 @@ { "domain": "min_max", + "integration_type": "helper", "name": "Min/Max", "documentation": "https://www.home-assistant.io/integrations/min_max", - "codeowners": ["@fabaff"], + "codeowners": [ + "@fabaff" + ], "quality_scale": "internal", - "iot_class": "local_push" + "iot_class": "local_push", + "config_flow": true } diff --git a/homeassistant/components/min_max/sensor.py b/homeassistant/components/min_max/sensor.py index cb6463ba5c9..bfc6b99d150 100644 --- a/homeassistant/components/min_max/sensor.py +++ b/homeassistant/components/min_max/sensor.py @@ -6,6 +6,7 @@ import logging import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, @@ -13,14 +14,15 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.core import Event, HomeAssistant, callback +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.reload import async_setup_reload_service from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN, PLATFORMS +from . import PLATFORMS +from .const import CONF_ENTITY_IDS, CONF_ROUND_DIGITS, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -46,9 +48,6 @@ ATTR_TO_PROPERTY = [ ATTR_LAST_ENTITY_ID, ] -CONF_ENTITY_IDS = "entity_ids" -CONF_ROUND_DIGITS = "round_digits" - ICON = "mdi:calculator" SENSOR_TYPES = { @@ -71,6 +70,32 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize min/max/mean config entry.""" + registry = er.async_get(hass) + entity_ids = er.async_validate_entity_ids( + registry, config_entry.options[CONF_ENTITY_IDS] + ) + sensor_type = config_entry.options[CONF_TYPE] + round_digits = int(config_entry.options[CONF_ROUND_DIGITS]) + + async_add_entities( + [ + MinMaxSensor( + entity_ids, + config_entry.title, + sensor_type, + round_digits, + config_entry.entry_id, + ) + ] + ) + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -85,7 +110,9 @@ async def async_setup_platform( await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - async_add_entities([MinMaxSensor(entity_ids, name, sensor_type, round_digits)]) + async_add_entities( + [MinMaxSensor(entity_ids, name, sensor_type, round_digits, None)] + ) def calc_min(sensor_values): @@ -148,8 +175,9 @@ def calc_median(sensor_values, round_digits): class MinMaxSensor(SensorEntity): """Representation of a min/max sensor.""" - def __init__(self, entity_ids, name, sensor_type, round_digits): + def __init__(self, entity_ids, name, sensor_type, round_digits, unique_id): """Initialize the min/max sensor.""" + self._attr_unique_id = unique_id self._entity_ids = entity_ids self._sensor_type = sensor_type self._round_digits = round_digits @@ -173,6 +201,12 @@ class MinMaxSensor(SensorEntity): ) ) + # Replay current state of source entities + for entity_id in self._entity_ids: + state = self.hass.states.get(entity_id) + state_event = Event("", {"entity_id": entity_id, "new_state": state}) + self._async_min_max_sensor_state_listener(state_event, update_state=False) + self._calc_values() @property @@ -216,16 +250,24 @@ class MinMaxSensor(SensorEntity): return ICON @callback - def _async_min_max_sensor_state_listener(self, event): + def _async_min_max_sensor_state_listener(self, event, update_state=True): """Handle the sensor state changes.""" new_state = event.data.get("new_state") entity = event.data.get("entity_id") - if new_state.state is None or new_state.state in [ - STATE_UNKNOWN, - STATE_UNAVAILABLE, - ]: + if ( + new_state is None + or new_state.state is None + or new_state.state + in [ + STATE_UNKNOWN, + STATE_UNAVAILABLE, + ] + ): self.states[entity] = STATE_UNKNOWN + if not update_state: + return + self._calc_values() self.async_write_ha_state() return @@ -252,6 +294,9 @@ class MinMaxSensor(SensorEntity): "Unable to store state. Only numerical states are supported" ) + if not update_state: + return + self._calc_values() self.async_write_ha_state() diff --git a/homeassistant/components/min_max/strings.json b/homeassistant/components/min_max/strings.json new file mode 100644 index 00000000000..13f81365a25 --- /dev/null +++ b/homeassistant/components/min_max/strings.json @@ -0,0 +1,28 @@ +{ + "title": "Min / max / mean / median sensor", + "config": { + "step": { + "user": { + "description": "Precision controls the number of decimal digits when the statistics characteristic is mean or median.", + "data": { + "entity_ids": "Input entities", + "name": "Name", + "round_digits": "Precision", + "type": "Statistic characteristic" + } + } + } + }, + "options": { + "step": { + "options": { + "description": "[%key:component::min_max::config::step::user::description%]", + "data": { + "entity_ids": "[%key:component::min_max::config::step::user::data::entity_ids%]", + "round_digits": "[%key:component::min_max::config::step::user::data::round_digits%]", + "type": "[%key:component::min_max::config::step::user::data::type%]" + } + } + } + } +} diff --git a/homeassistant/components/min_max/translations/en.json b/homeassistant/components/min_max/translations/en.json new file mode 100644 index 00000000000..65cdce569a8 --- /dev/null +++ b/homeassistant/components/min_max/translations/en.json @@ -0,0 +1,28 @@ +{ + "config": { + "step": { + "user": { + "data": { + "entity_ids": "Input entities", + "name": "Name", + "round_digits": "Precision", + "type": "Statistic characteristic" + }, + "description": "Precision controls the number of decimal digits when the statistics characteristic is mean or median." + } + } + }, + "options": { + "step": { + "options": { + "data": { + "entity_ids": "Input entities", + "round_digits": "Precision", + "type": "Statistic characteristic" + }, + "description": "Precision controls the number of decimal digits when the statistics characteristic is mean or median." + } + } + }, + "title": "Min / max / mean / median sensor" +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a8a5f492e9f..c659f78805e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -403,6 +403,7 @@ FLOWS = { ], "helper": [ "derivative", + "min_max", "tod" ] } diff --git a/tests/components/min_max/test_config_flow.py b/tests/components/min_max/test_config_flow.py new file mode 100644 index 00000000000..8e66f1a4711 --- /dev/null +++ b/tests/components/min_max/test_config_flow.py @@ -0,0 +1,135 @@ +"""Test the Min/Max config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.min_max.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_sensors = ["sensor.input_one", "sensor.input_two"] + + 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.min_max.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"name": "My min_max", "entity_ids": input_sensors, "type": "max"}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "My min_max" + assert result["data"] == {} + assert result["options"] == { + "entity_ids": input_sensors, + "name": "My min_max", + "round_digits": 2.0, + "type": "max", + } + 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 == { + "entity_ids": input_sensors, + "name": "My min_max", + "round_digits": 2.0, + "type": "max", + } + assert config_entry.title == "My min_max" + + +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.""" + hass.states.async_set("sensor.input_one", "10") + hass.states.async_set("sensor.input_two", "20") + hass.states.async_set("sensor.input_three", "33.33") + + input_sensors1 = ["sensor.input_one", "sensor.input_two"] + input_sensors2 = ["sensor.input_one", "sensor.input_two", "sensor.input_three"] + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "entity_ids": input_sensors1, + "name": "My min_max", + "round_digits": 0, + "type": "min", + }, + title="My min_max", + ) + 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, "entity_ids") == input_sensors1 + assert get_suggested(schema, "round_digits") == 0 + assert get_suggested(schema, "type") == "min" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "entity_ids": input_sensors2, + "round_digits": 1, + "type": "mean", + }, + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + "entity_ids": input_sensors2, + "name": "My min_max", + "round_digits": 1, + "type": "mean", + } + assert config_entry.data == {} + assert config_entry.options == { + "entity_ids": input_sensors2, + "name": "My min_max", + "round_digits": 1, + "type": "mean", + } + assert config_entry.title == "My min_max" + + # 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()) == 4 + + # Check the state of the entity has changed as expected + state = hass.states.get(f"{platform}.my_min_max") + assert state.state == "21.1" + assert state.attributes["count_sensors"] == 3 diff --git a/tests/components/min_max/test_init.py b/tests/components/min_max/test_init.py new file mode 100644 index 00000000000..4df543c2565 --- /dev/null +++ b/tests/components/min_max/test_init.py @@ -0,0 +1,55 @@ +"""Test the Min/Max integration.""" +import pytest + +from homeassistant.components.min_max.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.""" + hass.states.async_set("sensor.input_one", "10") + hass.states.async_set("sensor.input_two", "20") + + input_sensors = ["sensor.input_one", "sensor.input_two"] + + registry = er.async_get(hass) + min_max_entity_id = f"{platform}.my_min_max" + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "entity_ids": input_sensors, + "name": "My min_max", + "round_digits": 2.0, + "type": "max", + }, + title="My min_max", + ) + 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(min_max_entity_id) is not None + + # Check the platform is setup correctly + state = hass.states.get(min_max_entity_id) + assert state.state == "20.0" + assert state.attributes["count_sensors"] == 2 + + # 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(min_max_entity_id) is None + assert registry.async_get(min_max_entity_id) is None diff --git a/tests/components/min_max/test_sensor.py b/tests/components/min_max/test_sensor.py index 1712a9027ca..ae6a14893f8 100644 --- a/tests/components/min_max/test_sensor.py +++ b/tests/components/min_max/test_sensor.py @@ -3,7 +3,7 @@ import statistics from unittest.mock import patch from homeassistant import config as hass_config -from homeassistant.components.min_max import DOMAIN +from homeassistant.components.min_max.const import DOMAIN from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE,