diff --git a/homeassistant/components/derivative/__init__.py b/homeassistant/components/derivative/__init__.py index afee8d5d175..e3fe9d85f41 100644 --- a/homeassistant/components/derivative/__init__.py +++ b/homeassistant/components/derivative/__init__.py @@ -1 +1,23 @@ -"""The derivative component.""" +"""The Derivative 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 Derivative 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/derivative/config_flow.py b/homeassistant/components/derivative/config_flow.py new file mode 100644 index 00000000000..ccb44b1963b --- /dev/null +++ b/homeassistant/components/derivative/config_flow.py @@ -0,0 +1,93 @@ +"""Config flow for Derivative integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, cast + +import voluptuous as vol + +from homeassistant.const import ( + CONF_NAME, + CONF_SOURCE, + 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_TIME_WINDOW, + CONF_UNIT_PREFIX, + CONF_UNIT_TIME, + DOMAIN, +) + +UNIT_PREFIXES = [ + {"value": "none", "label": "none"}, + {"value": "n", "label": "n (nano)"}, + {"value": "µ", "label": "µ (micro)"}, + {"value": "m", "label": "m (milli)"}, + {"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)"}, +] + +OPTIONS_SCHEMA = vol.Schema( + { + vol.Required(CONF_ROUND_DIGITS, default=2): selector.selector( + { + "number": { + "min": 0, + "max": 6, + "mode": "box", + CONF_UNIT_OF_MEASUREMENT: "decimals", + } + } + ), + vol.Required(CONF_TIME_WINDOW): selector.selector({"duration": {}}), + 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_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): selector.selector({"text": {}}), + vol.Required(CONF_SOURCE): selector.selector( + {"entity": {"domain": "sensor"}}, + ), + } +).extend(OPTIONS_SCHEMA.schema) + +CONFIG_FLOW = {"user": HelperFlowStep(CONFIG_SCHEMA)} + +OPTIONS_FLOW = {"init": HelperFlowStep(OPTIONS_SCHEMA)} + + +class ConfigFlowHandler(HelperConfigFlowHandler, domain=DOMAIN): + """Handle a config or options flow for Derivative.""" + + 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/derivative/const.py b/homeassistant/components/derivative/const.py new file mode 100644 index 00000000000..32f2777dc80 --- /dev/null +++ b/homeassistant/components/derivative/const.py @@ -0,0 +1,9 @@ +"""Constants for the Derivative integration.""" + +DOMAIN = "derivative" + +CONF_ROUND_DIGITS = "round" +CONF_TIME_WINDOW = "time_window" +CONF_UNIT = "unit" +CONF_UNIT_PREFIX = "unit_prefix" +CONF_UNIT_TIME = "unit_time" diff --git a/homeassistant/components/derivative/manifest.json b/homeassistant/components/derivative/manifest.json index 2b86c07cfe4..bed23d33e15 100644 --- a/homeassistant/components/derivative/manifest.json +++ b/homeassistant/components/derivative/manifest.json @@ -2,6 +2,9 @@ "domain": "derivative", "name": "Derivative", "documentation": "https://www.home-assistant.io/integrations/derivative", - "codeowners": ["@afaucogney"], - "iot_class": "calculated" + "codeowners": [ + "@afaucogney" + ], + "iot_class": "calculated", + "config_flow": true } diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index 9be01f27e4d..25bc3450dcb 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -8,6 +8,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, @@ -20,24 +21,26 @@ 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_TIME_WINDOW, + CONF_UNIT, + CONF_UNIT_PREFIX, + CONF_UNIT_TIME, +) + # mypy: allow-untyped-defs, no-check-untyped-defs _LOGGER = logging.getLogger(__name__) ATTR_SOURCE_ID = "source" -CONF_ROUND_DIGITS = "round" -CONF_UNIT_PREFIX = "unit_prefix" -CONF_UNIT_TIME = "unit_time" -CONF_UNIT = "unit" -CONF_TIME_WINDOW = "time_window" - # SI Metric prefixes UNIT_PREFIXES = { None: 1, @@ -76,6 +79,36 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize Derivative 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] + ) + + unit_prefix = config_entry.options[CONF_UNIT_PREFIX] + if unit_prefix == "none": + unit_prefix = None + + derivative_sensor = DerivativeSensor( + name=config_entry.title, + round_digits=int(config_entry.options[CONF_ROUND_DIGITS]), + source_entity=source_entity_id, + time_window=cv.time_period_dict(config_entry.options[CONF_TIME_WINDOW]), + 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([derivative_sensor]) + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -84,13 +117,14 @@ async def async_setup_platform( ) -> None: """Set up the derivative sensor.""" derivative = DerivativeSensor( - source_entity=config[CONF_SOURCE], name=config.get(CONF_NAME), round_digits=config[CONF_ROUND_DIGITS], + source_entity=config[CONF_SOURCE], + time_window=config[CONF_TIME_WINDOW], + unit_of_measurement=config.get(CONF_UNIT), unit_prefix=config[CONF_UNIT_PREFIX], unit_time=config[CONF_UNIT_TIME], - unit_of_measurement=config.get(CONF_UNIT), - time_window=config[CONF_TIME_WINDOW], + unique_id=None, ) async_add_entities([derivative]) @@ -101,15 +135,18 @@ class DerivativeSensor(RestoreEntity, SensorEntity): def __init__( self, - source_entity, + *, name, round_digits, + source_entity, + time_window, + unit_of_measurement, unit_prefix, unit_time, - unit_of_measurement, - time_window, + unique_id, ): """Initialize the derivative sensor.""" + self._attr_unique_id = unique_id self._sensor_source_id = source_entity self._round_digits = round_digits self._state = 0 @@ -214,7 +251,7 @@ class DerivativeSensor(RestoreEntity, SensorEntity): self.async_write_ha_state() async_track_state_change_event( - self.hass, [self._sensor_source_id], calc_derivative + self.hass, self._sensor_source_id, calc_derivative ) @property diff --git a/homeassistant/components/derivative/strings.json b/homeassistant/components/derivative/strings.json new file mode 100644 index 00000000000..c21a486d039 --- /dev/null +++ b/homeassistant/components/derivative/strings.json @@ -0,0 +1,33 @@ +{ + "config": { + "step": { + "user": { + "title": "New Derivative sensor", + "description": "Precision controls the number of decimal digits in the output.\nIf the time window is not 0, the sensor's value is a time weighted moving average of derivatives within the window.\nThe derivative will be scaled according to the selected metric prefix and time unit of the derivative.", + "data": { + "name": "Name", + "round": "Precision", + "source": "Input sensor", + "time_window": "Time window", + "unit_prefix": "Metric prefix", + "unit_time": "Time unit" + } + } + } + }, + "options": { + "step": { + "options": { + "description": "[%key:component::derivative::config::step::user::description%]", + "data": { + "name": "[%key:component::derivative::config::step::user::data::name%]", + "round": "[%key:component::derivative::config::step::user::data::round%]", + "source": "[%key:component::derivative::config::step::user::data::source%]", + "time_window": "[%key:component::derivative::config::step::user::data::time_window%]", + "unit_prefix": "[%key:component::derivative::config::step::user::data::unit_prefix%]", + "unit_time": "[%key:component::derivative::config::step::user::data::unit_time%]" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/derivative/translations/en.json b/homeassistant/components/derivative/translations/en.json new file mode 100644 index 00000000000..b1fa702fe3c --- /dev/null +++ b/homeassistant/components/derivative/translations/en.json @@ -0,0 +1,33 @@ +{ + "config": { + "step": { + "user": { + "data": { + "name": "Name", + "round": "Precision", + "source": "Input sensor", + "time_window": "Time window", + "unit_prefix": "Metric prefix", + "unit_time": "Time unit" + }, + "description": "Precision controls the number of decimal digits in the output.\nIf the time window is not 0, the sensor's value is a time weighted moving average of derivatives within the window.\nThe derivative will be scaled according to the selected metric prefix and time unit of the derivative.", + "title": "New Derivative sensor" + } + } + }, + "options": { + "step": { + "options": { + "data": { + "name": "Name", + "round": "Precision", + "source": "Input sensor", + "time_window": "Time window", + "unit_prefix": "Metric prefix", + "unit_time": "Time unit" + }, + "description": "Precision controls the number of decimal digits in the output.\nIf the time window is not 0, the sensor's value is a time weighted moving average of derivatives within the window.\nThe derivative will be scaled according to the selected metric prefix and time unit of the derivative." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a6591cc693c..89cd4495326 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -67,6 +67,7 @@ FLOWS = [ "daikin", "deconz", "denonavr", + "derivative", "devolo_home_control", "devolo_home_network", "dexcom", diff --git a/tests/components/derivative/test_config_flow.py b/tests/components/derivative/test_config_flow.py new file mode 100644 index 00000000000..61ab7251f8a --- /dev/null +++ b/tests/components/derivative/test_config_flow.py @@ -0,0 +1,149 @@ +"""Test the Derivative config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.derivative.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.derivative.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "name": "My derivative", + "round": 1, + "source": input_sensor_entity_id, + "time_window": {"seconds": 0}, + "unit_prefix": "none", + "unit_time": "min", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "My derivative" + assert result["data"] == {} + assert result["options"] == { + "name": "My derivative", + "round": 1.0, + "source": "sensor.input", + "time_window": {"seconds": 0.0}, + "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 == { + "name": "My derivative", + "round": 1.0, + "source": "sensor.input", + "time_window": {"seconds": 0.0}, + "unit_prefix": "none", + "unit_time": "min", + } + assert config_entry.title == "My derivative" + + +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={ + "name": "My derivative", + "round": 1.0, + "source": "sensor.input", + "time_window": {"seconds": 0.0}, + "unit_prefix": "k", + "unit_time": "min", + }, + title="My derivative", + ) + 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 + assert get_suggested(schema, "time_window") == {"seconds": 0.0} + assert get_suggested(schema, "unit_prefix") == "k" + assert get_suggested(schema, "unit_time") == "min" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "round": 2.0, + "time_window": {"seconds": 10.0}, + "unit_prefix": "none", + "unit_time": "h", + }, + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + "name": "My derivative", + "round": 2.0, + "source": "sensor.input", + "time_window": {"seconds": 10.0}, + "unit_prefix": "none", + "unit_time": "h", + } + assert config_entry.data == {} + assert config_entry.options == { + "name": "My derivative", + "round": 2.0, + "source": "sensor.input", + "time_window": {"seconds": 10.0}, + "unit_prefix": "none", + "unit_time": "h", + } + assert config_entry.title == "My derivative" + + # 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": "cat"}) + hass.states.async_set("sensor.input", 11, {"unit_of_measurement": "cat"}) + await hass.async_block_till_done() + state = hass.states.get(f"{platform}.my_derivative") + assert state.attributes["unit_of_measurement"] == "cat/h" diff --git a/tests/components/derivative/test_init.py b/tests/components/derivative/test_init.py new file mode 100644 index 00000000000..fef13109007 --- /dev/null +++ b/tests/components/derivative/test_init.py @@ -0,0 +1,61 @@ +"""Test the Derivative integration.""" +import pytest + +from homeassistant.components.derivative.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) + derivative_entity_id = f"{platform}.my_derivative" + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My derivative", + "round": 1.0, + "source": "sensor.input", + "time_window": {"seconds": 0.0}, + "unit_prefix": "k", + "unit_time": "min", + }, + title="My derivative", + ) + 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(derivative_entity_id) is not None + + # Check the platform is setup correctly + state = hass.states.get(derivative_entity_id) + assert state.state == "0" + 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": "dog"}) + hass.states.async_set(input_sensor_entity_id, 11, {"unit_of_measurement": "dog"}) + await hass.async_block_till_done() + state = hass.states.get(derivative_entity_id) + assert state.state != "0" + assert state.attributes["unit_of_measurement"] == "kdog/min" + + # 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(derivative_entity_id) is None + assert registry.async_get(derivative_entity_id) is None