From b5d2c6e43aa42a39d10b65014222a793612aef89 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 22 Mar 2022 11:32:19 +0100 Subject: [PATCH] Add config flow for threshold binary sensor (#68238) Co-authored-by: Franck Nijhof --- .../components/threshold/__init__.py | 25 +++ .../components/threshold/binary_sensor.py | 54 ++++-- .../components/threshold/config_flow.py | 63 +++++++ homeassistant/components/threshold/const.py | 9 + .../components/threshold/manifest.json | 3 +- .../components/threshold/strings.json | 39 +++++ .../components/threshold/translations/en.json | 39 +++++ homeassistant/generated/config_flows.py | 1 + .../helpers/helper_config_entry_flow.py | 47 ++++- .../components/threshold/test_config_flow.py | 162 ++++++++++++++++++ tests/components/threshold/test_init.py | 61 +++++++ 11 files changed, 483 insertions(+), 20 deletions(-) create mode 100644 homeassistant/components/threshold/config_flow.py create mode 100644 homeassistant/components/threshold/const.py create mode 100644 homeassistant/components/threshold/strings.json create mode 100644 homeassistant/components/threshold/translations/en.json create mode 100644 tests/components/threshold/test_config_flow.py create mode 100644 tests/components/threshold/test_init.py diff --git a/homeassistant/components/threshold/__init__.py b/homeassistant/components/threshold/__init__.py index 98ebdcd8418..75f4c5d1abc 100644 --- a/homeassistant/components/threshold/__init__.py +++ b/homeassistant/components/threshold/__init__.py @@ -1 +1,26 @@ """The threshold component.""" + +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 Min/Max from a config entry.""" + hass.config_entries.async_setup_platforms(entry, (Platform.BINARY_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.BINARY_SENSOR,) + ) diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py index ccb998c7586..49a97d84f0e 100644 --- a/homeassistant/components/threshold/binary_sensor.py +++ b/homeassistant/components/threshold/binary_sensor.py @@ -10,6 +10,7 @@ from homeassistant.components.binary_sensor import ( PLATFORM_SCHEMA, BinarySensorEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_CLASS, @@ -19,11 +20,13 @@ from homeassistant.const import ( STATE_UNKNOWN, ) 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.typing import ConfigType, DiscoveryInfoType +from .const import CONF_HYSTERESIS, CONF_LOWER, CONF_UPPER + _LOGGER = logging.getLogger(__name__) ATTR_HYSTERESIS = "hysteresis" @@ -33,10 +36,6 @@ ATTR_SENSOR_VALUE = "sensor_value" ATTR_TYPE = "type" ATTR_UPPER = "upper" -CONF_HYSTERESIS = "hysteresis" -CONF_LOWER = "lower" -CONF_UPPER = "upper" - DEFAULT_NAME = "Threshold" DEFAULT_HYSTERESIS = 0.0 @@ -61,6 +60,32 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize threshold config entry.""" + registry = er.async_get(hass) + device_class = None + entity_id = er.async_validate_entity_id( + registry, config_entry.options[CONF_ENTITY_ID] + ) + hysteresis = config_entry.options[CONF_HYSTERESIS] + lower = config_entry.options[CONF_LOWER] + name = config_entry.title + unique_id = config_entry.entry_id + upper = config_entry.options[CONF_UPPER] + + async_add_entities( + [ + ThresholdSensor( + hass, entity_id, name, lower, upper, hysteresis, device_class, unique_id + ) + ] + ) + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -78,7 +103,7 @@ async def async_setup_platform( async_add_entities( [ ThresholdSensor( - hass, entity_id, name, lower, upper, hysteresis, device_class + hass, entity_id, name, lower, upper, hysteresis, device_class, None ) ], ) @@ -87,9 +112,11 @@ async def async_setup_platform( class ThresholdSensor(BinarySensorEntity): """Representation of a Threshold sensor.""" - def __init__(self, hass, entity_id, name, lower, upper, hysteresis, device_class): + def __init__( + self, hass, entity_id, name, lower, upper, hysteresis, device_class, unique_id + ): """Initialize the Threshold sensor.""" - self._hass = hass + self._attr_unique_id = unique_id self._entity_id = entity_id self._name = name self._threshold_lower = lower @@ -101,10 +128,9 @@ class ThresholdSensor(BinarySensorEntity): self._state = None self.sensor_value = None - @callback - def async_threshold_sensor_state_listener(event): + def _update_sensor_state(): """Handle sensor state changes.""" - if (new_state := event.data.get("new_state")) is None: + if (new_state := hass.states.get(self._entity_id)) is None: return try: @@ -118,11 +144,17 @@ class ThresholdSensor(BinarySensorEntity): _LOGGER.warning("State is not numerical") self._update_state() + + @callback + def async_threshold_sensor_state_listener(event): + """Handle sensor state changes.""" + _update_sensor_state() self.async_write_ha_state() async_track_state_change_event( hass, [entity_id], async_threshold_sensor_state_listener ) + _update_sensor_state() @property def name(self): diff --git a/homeassistant/components/threshold/config_flow.py b/homeassistant/components/threshold/config_flow.py new file mode 100644 index 00000000000..01e5364284a --- /dev/null +++ b/homeassistant/components/threshold/config_flow.py @@ -0,0 +1,63 @@ +"""Config flow for Threshold integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import voluptuous as vol + +from homeassistant.const import CONF_ENTITY_ID, CONF_NAME +from homeassistant.helpers import selector +from homeassistant.helpers.helper_config_entry_flow import ( + HelperConfigFlowHandler, + HelperFlowError, + HelperFlowStep, +) + +from .const import CONF_HYSTERESIS, CONF_LOWER, CONF_UPPER, DEFAULT_HYSTERESIS, DOMAIN + + +def _validate_mode(data: Any) -> Any: + """Validate the threshold mode, and set limits to None if not set.""" + if CONF_LOWER not in data and CONF_UPPER not in data: + raise HelperFlowError("need_lower_upper") + return {CONF_LOWER: None, CONF_UPPER: None, **data} + + +OPTIONS_SCHEMA = vol.Schema( + { + vol.Required(CONF_HYSTERESIS, default=DEFAULT_HYSTERESIS): selector.selector( + {"number": {"mode": "box"}} + ), + vol.Optional(CONF_LOWER): selector.selector({"number": {"mode": "box"}}), + vol.Optional(CONF_UPPER): selector.selector({"number": {"mode": "box"}}), + } +) + +CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): selector.selector({"text": {}}), + vol.Required(CONF_ENTITY_ID): selector.selector( + {"entity": {"domain": "sensor"}} + ), + } +).extend(OPTIONS_SCHEMA.schema) + +CONFIG_FLOW = { + "user": HelperFlowStep(CONFIG_SCHEMA, validate_user_input=_validate_mode) +} + +OPTIONS_FLOW = { + "init": HelperFlowStep(OPTIONS_SCHEMA, validate_user_input=_validate_mode) +} + + +class ConfigFlowHandler(HelperConfigFlowHandler, domain=DOMAIN): + """Handle a config or options flow for Threshold.""" + + config_flow = CONFIG_FLOW + options_flow = OPTIONS_FLOW + + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return options[CONF_NAME] diff --git a/homeassistant/components/threshold/const.py b/homeassistant/components/threshold/const.py new file mode 100644 index 00000000000..2cb9dc88f0f --- /dev/null +++ b/homeassistant/components/threshold/const.py @@ -0,0 +1,9 @@ +"""Constants for the Threshold integration.""" + +DOMAIN = "threshold" + +CONF_HYSTERESIS = "hysteresis" +CONF_LOWER = "lower" +CONF_UPPER = "upper" + +DEFAULT_HYSTERESIS = 0.0 diff --git a/homeassistant/components/threshold/manifest.json b/homeassistant/components/threshold/manifest.json index c4eabcfe6a5..84656ad4360 100644 --- a/homeassistant/components/threshold/manifest.json +++ b/homeassistant/components/threshold/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/threshold", "codeowners": ["@fabaff"], "quality_scale": "internal", - "iot_class": "local_polling" + "iot_class": "local_polling", + "config_flow": true } diff --git a/homeassistant/components/threshold/strings.json b/homeassistant/components/threshold/strings.json new file mode 100644 index 00000000000..dbe8bf39b1e --- /dev/null +++ b/homeassistant/components/threshold/strings.json @@ -0,0 +1,39 @@ +{ + "config": { + "step": { + "user": { + "title": "New Threshold Sensor", + "description": "Configure when the sensor should turn on and off.\n\nOnly lower limit configured - Turn on when the input sensor's value is less than the lower limit.\nOnly upper limit configured - Turn on when the input sensor's value is greater than the upper limit.\nBoth lower and upper limit configured - Turn on when the input sensor's value is in the range [lower limit .. upper limit].", + "data": { + "entity_id": "Input sensor", + "hysteresis": "Hysteresis", + "lower": "Lower limit", + "mode": "Threshold mode", + "name": "Name", + "upper": "Upper limit" + } + } + }, + "error": { + "need_lower_upper": "Lower and upper limits can't both be empty" + } + }, + "options": { + "step": { + "init": { + "description": "[%key:component::threshold::config::step::user::description%]", + "data": { + "entity_id": "[%key:component::threshold::config::step::user::data::entity_id%]", + "hysteresis": "[%key:component::threshold::config::step::user::data::hysteresis%]", + "lower": "[%key:component::threshold::config::step::user::data::lower%]", + "mode": "[%key:component::threshold::config::step::user::data::mode%]", + "name": "[%key:component::threshold::config::step::user::data::name%]", + "upper": "[%key:component::threshold::config::step::user::data::upper%]" + } + } + }, + "error": { + "need_lower_upper": "[%key:component::threshold::config::error::need_lower_upper%]" + } + } +} diff --git a/homeassistant/components/threshold/translations/en.json b/homeassistant/components/threshold/translations/en.json new file mode 100644 index 00000000000..ace0621ab02 --- /dev/null +++ b/homeassistant/components/threshold/translations/en.json @@ -0,0 +1,39 @@ +{ + "config": { + "error": { + "need_lower_upper": "Lower and upper limits can't both be empty" + }, + "step": { + "user": { + "data": { + "entity_id": "Input sensor", + "hysteresis": "Hysteresis", + "lower": "Lower limit", + "mode": "Threshold mode", + "name": "Name", + "upper": "Upper limit" + }, + "description": "Configure when the sensor should turn on and off.\n\nOnly lower limit configured - Turn on when the input sensor's value is less than the lower limit.\nOnly upper limit configured - Turn on when the input sensor's value is greater than the upper limit.\nBoth lower and upper limit configured - Turn on when the input sensor's value is in the range [lower limit .. upper limit].", + "title": "New Threshold Sensor" + } + } + }, + "options": { + "error": { + "need_lower_upper": "Lower and upper limits can't both be empty" + }, + "step": { + "init": { + "data": { + "entity_id": "Input sensor", + "hysteresis": "Hysteresis", + "lower": "Lower limit", + "mode": "Threshold mode", + "name": "Name", + "upper": "Upper limit" + }, + "description": "Configure when the sensor should turn on and off.\n\nOnly lower limit configured - Turn on when the input sensor's value is less than the lower limit.\nOnly upper limit configured - Turn on when the input sensor's value is greater than the upper limit.\nBoth lower and upper limit configured - Turn on when the input sensor's value is in the range [lower limit .. upper limit]." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 058bb936c7b..25d6ec2c807 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -337,6 +337,7 @@ FLOWS = { "tasmota", "tellduslive", "tesla_wall_connector", + "threshold", "tibber", "tile", "tolo", diff --git a/homeassistant/helpers/helper_config_entry_flow.py b/homeassistant/helpers/helper_config_entry_flow.py index 2e9c55baedd..2611841e1cf 100644 --- a/homeassistant/helpers/helper_config_entry_flow.py +++ b/homeassistant/helpers/helper_config_entry_flow.py @@ -16,6 +16,10 @@ from homeassistant.data_entry_flow import FlowResult, UnknownHandler from . import entity_registry as er +class HelperFlowError(Exception): + """Validation failed.""" + + @dataclass class HelperFlowStep: """Define a helper config or options flow step.""" @@ -24,6 +28,12 @@ class HelperFlowStep: # fails, the step will be retried. If the schema is None, no user input is requested. schema: vol.Schema | None + # Optional function to validate user input. + # The validate_user_input function is called if the schema validates successfully. + # The validate_user_input function is passed the user input from the current step. + # The validate_user_input should raise HelperFlowError is user input is invalid. + validate_user_input: Callable[[dict[str, Any]], dict[str, Any]] = lambda x: x + # Optional function to identify next step. # The next_step function is called if the schema validates successfully or if no # schema is defined. The next_step function is passed the union of config entry @@ -52,6 +62,13 @@ class HelperCommonFlowHandler: """Handle a step.""" next_step_id: str = step_id + if user_input is not None and self._flow[next_step_id].schema is not None: + # Do extra validation of user input + try: + user_input = self._flow[next_step_id].validate_user_input(user_input) + except HelperFlowError as exc: + return self._show_next_step(next_step_id, exc, user_input) + if user_input is not None: # User input was validated successfully, update options self._options.update(user_input) @@ -67,21 +84,35 @@ class HelperCommonFlowHandler: next_step_id = next_step_id_or_end_flow + return self._show_next_step(next_step_id) + + def _show_next_step( + self, + next_step_id: str, + error: HelperFlowError | None = None, + user_input: dict[str, Any] | None = None, + ) -> FlowResult: + """Show step for next step.""" + options = dict(self._options) + if user_input: + options.update(user_input) if (data_schema := self._flow[next_step_id].schema) and data_schema.schema: - # Copy the schema, then set suggested field values to saved options - schema = dict(data_schema.schema) - for key in list(schema): - if key in self._options and isinstance(key, vol.Marker): + # Make a copy of the schema with suggested values set to saved options + schema = {} + for key, val in data_schema.schema.items(): + new_key = key + if key in options and isinstance(key, vol.Marker): # Copy the marker to not modify the flow schema new_key = copy.copy(key) - new_key.description = {"suggested_value": self._options[key]} - val = schema.pop(key) - schema[new_key] = val + new_key.description = {"suggested_value": options[key]} + schema[new_key] = val data_schema = vol.Schema(schema) + errors = {"base": str(error)} if error else None + # Show form for next step return self._handler.async_show_form( - step_id=next_step_id, data_schema=data_schema + step_id=next_step_id, data_schema=data_schema, errors=errors ) diff --git a/tests/components/threshold/test_config_flow.py b/tests/components/threshold/test_config_flow.py new file mode 100644 index 00000000000..0a10c24a2f6 --- /dev/null +++ b/tests/components/threshold/test_config_flow.py @@ -0,0 +1,162 @@ +"""Test the Threshold config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.threshold.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 + + +async def test_config_flow(hass: HomeAssistant) -> None: + """Test the config flow.""" + input_sensor = "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.threshold.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "entity_id": input_sensor, + "lower": -2, + "upper": 0.0, + "name": "My threshold sensor", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "My threshold sensor" + assert result["data"] == {} + assert result["options"] == { + "entity_id": input_sensor, + "hysteresis": 0.0, + "lower": -2.0, + "name": "My threshold sensor", + "upper": 0.0, + } + 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_id": input_sensor, + "hysteresis": 0.0, + "lower": -2.0, + "name": "My threshold sensor", + "upper": 0.0, + } + assert config_entry.title == "My threshold sensor" + + +@pytest.mark.parametrize("extra_input_data,error", (({}, "need_lower_upper"),)) +async def test_fail(hass: HomeAssistant, extra_input_data, error) -> None: + """Test not providing lower or upper limit fails.""" + input_sensor = "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 + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "entity_id": input_sensor, + "name": "My threshold sensor", + **extra_input_data, + }, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": error} + + +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 + + +async def test_options(hass: HomeAssistant) -> None: + """Test reconfiguring.""" + input_sensor = "sensor.input" + hass.states.async_set(input_sensor, "10") + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "entity_id": input_sensor, + "hysteresis": 0.0, + "lower": -2.0, + "name": "My threshold", + "upper": None, + }, + title="My threshold", + ) + 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, "hysteresis") == 0.0 + assert get_suggested(schema, "lower") == -2.0 + assert get_suggested(schema, "upper") is None + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "hysteresis": 0.0, + "upper": 20.0, + }, + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + "entity_id": input_sensor, + "hysteresis": 0.0, + "lower": None, + "name": "My threshold", + "upper": 20.0, + } + assert config_entry.data == {} + assert config_entry.options == { + "entity_id": input_sensor, + "hysteresis": 0.0, + "lower": None, + "name": "My threshold", + "upper": 20.0, + } + assert config_entry.title == "My threshold" + + # 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()) == 2 + + # Check the state of the entity has changed as expected + state = hass.states.get("binary_sensor.my_threshold") + assert state.state == "off" + assert state.attributes["type"] == "upper" diff --git a/tests/components/threshold/test_init.py b/tests/components/threshold/test_init.py new file mode 100644 index 00000000000..82d183ad380 --- /dev/null +++ b/tests/components/threshold/test_init.py @@ -0,0 +1,61 @@ +"""Test the Min/Max integration.""" +import pytest + +from homeassistant.components.threshold.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", ("binary_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", "-10") + + input_sensor = "sensor.input" + + registry = er.async_get(hass) + threshold_entity_id = f"{platform}.input_threshold" + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "entity_id": input_sensor, + "hysteresis": 0.0, + "lower": -2.0, + "name": "Input threshold", + "upper": None, + }, + title="Input threshold", + ) + 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(threshold_entity_id) is not None + + # Check the platform is setup correctly + state = hass.states.get(threshold_entity_id) + assert state.state == "on" + assert state.attributes["entity_id"] == input_sensor + assert state.attributes["hysteresis"] == 0.0 + assert state.attributes["lower"] == -2.0 + assert state.attributes["position"] == "below" + assert state.attributes["sensor_value"] == -10.0 + assert state.attributes["type"] == "lower" + assert state.attributes["upper"] is None + + # 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(threshold_entity_id) is None + assert registry.async_get(threshold_entity_id) is None