From 23b60e156c701ce50a7bb2b47c8c5423c21999c5 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 8 Jul 2024 12:13:21 +0000 Subject: [PATCH 1/9] Add config flow to filter helper --- homeassistant/components/filter/__init__.py | 25 ++- .../components/filter/config_flow.py | 211 ++++++++++++++++++ homeassistant/components/filter/const.py | 36 +++ homeassistant/components/filter/manifest.json | 1 + homeassistant/components/filter/sensor.py | 75 ++++--- homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- 7 files changed, 319 insertions(+), 32 deletions(-) create mode 100644 homeassistant/components/filter/config_flow.py create mode 100644 homeassistant/components/filter/const.py diff --git a/homeassistant/components/filter/__init__.py b/homeassistant/components/filter/__init__.py index 7f3f6cbfffc..9a4f4913c9f 100644 --- a/homeassistant/components/filter/__init__.py +++ b/homeassistant/components/filter/__init__.py @@ -1,6 +1,25 @@ """The filter component.""" -from homeassistant.const import Platform +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant -DOMAIN = "filter" -PLATFORMS = [Platform.SENSOR] +from .const import PLATFORMS + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Filter from a config entry.""" + + 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 Filter 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/filter/config_flow.py b/homeassistant/components/filter/config_flow.py new file mode 100644 index 00000000000..2a339056f5c --- /dev/null +++ b/homeassistant/components/filter/config_flow.py @@ -0,0 +1,211 @@ +"""Config flow for filter.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, cast + +import voluptuous as vol + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import CONF_ENTITY_ID, CONF_NAME +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, + SchemaConfigFlowHandler, + SchemaFlowFormStep, +) +from homeassistant.helpers.selector import ( + DurationSelector, + DurationSelectorConfig, + EntitySelector, + EntitySelectorConfig, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + Selector, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, +) + +from .const import ( + CONF_FILTER_LOWER_BOUND, + CONF_FILTER_NAME, + CONF_FILTER_PRECISION, + CONF_FILTER_RADIUS, + CONF_FILTER_TIME_CONSTANT, + CONF_FILTER_UPPER_BOUND, + CONF_FILTER_WINDOW_SIZE, + CONF_TIME_SMA_TYPE, + DEFAULT_FILTER_RADIUS, + DEFAULT_FILTER_TIME_CONSTANT, + DEFAULT_NAME, + DEFAULT_PRECISION, + DEFAULT_WINDOW_SIZE, + DOMAIN, + FILTER_NAME_LOWPASS, + FILTER_NAME_OUTLIER, + FILTER_NAME_RANGE, + FILTER_NAME_THROTTLE, + FILTER_NAME_TIME_SMA, + FILTER_NAME_TIME_THROTTLE, + TIME_SMA_LAST, +) + +FILTERS = [ + FILTER_NAME_LOWPASS, + FILTER_NAME_OUTLIER, + FILTER_NAME_RANGE, + FILTER_NAME_THROTTLE, + FILTER_NAME_TIME_SMA, + FILTER_NAME_TIME_THROTTLE, +] + + +async def get_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: + """Return schema for options.""" + filter_schema: dict[vol.Marker, Selector] = {} + + if handler.options[CONF_FILTER_NAME] == FILTER_NAME_OUTLIER: + filter_schema = { + vol.Optional( + CONF_FILTER_WINDOW_SIZE, default=DEFAULT_WINDOW_SIZE + ): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + vol.Optional( + CONF_FILTER_RADIUS, default=DEFAULT_FILTER_RADIUS + ): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + } + + elif handler.options[CONF_FILTER_NAME] == FILTER_NAME_LOWPASS: + filter_schema = { + vol.Optional( + CONF_FILTER_WINDOW_SIZE, default=DEFAULT_WINDOW_SIZE + ): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + vol.Optional( + CONF_FILTER_TIME_CONSTANT, default=DEFAULT_FILTER_TIME_CONSTANT + ): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + } + + elif handler.options[CONF_FILTER_NAME] == FILTER_NAME_RANGE: + filter_schema = { + vol.Optional(CONF_FILTER_LOWER_BOUND): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + vol.Optional(CONF_FILTER_UPPER_BOUND): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + } + + elif handler.options[CONF_FILTER_NAME] == FILTER_NAME_TIME_SMA: + filter_schema = { + vol.Optional(CONF_TIME_SMA_TYPE, default=TIME_SMA_LAST): SelectSelector( + SelectSelectorConfig( + options=[TIME_SMA_LAST], + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_TIME_SMA_TYPE, + ) + ), + vol.Required(CONF_FILTER_WINDOW_SIZE): DurationSelector( + DurationSelectorConfig(enable_day=False, allow_negative=False) + ), + } + + elif handler.options[CONF_FILTER_NAME] == FILTER_NAME_THROTTLE: + filter_schema = { + vol.Optional( + CONF_FILTER_WINDOW_SIZE, default=DEFAULT_WINDOW_SIZE + ): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + } + + elif handler.options[CONF_FILTER_NAME] == FILTER_NAME_TIME_THROTTLE: + filter_schema = { + vol.Required(CONF_FILTER_WINDOW_SIZE): DurationSelector( + DurationSelectorConfig(enable_day=False, allow_negative=False) + ), + } + + base_schema: dict[vol.Marker, Selector] = { + vol.Optional(CONF_FILTER_PRECISION, default=DEFAULT_PRECISION): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ) + } + + return vol.Schema({**filter_schema, **base_schema}) + + +async def validate_options( + handler: SchemaCommonFlowHandler, user_input: dict[str, Any] +) -> dict[str, Any]: + """Validate options selected.""" + + if CONF_FILTER_WINDOW_SIZE in user_input and isinstance( + user_input[CONF_FILTER_WINDOW_SIZE], float + ): + user_input[CONF_FILTER_WINDOW_SIZE] = int(user_input[CONF_FILTER_WINDOW_SIZE]) + if CONF_FILTER_TIME_CONSTANT in user_input: + user_input[CONF_FILTER_TIME_CONSTANT] = int( + user_input[CONF_FILTER_TIME_CONSTANT] + ) + if CONF_FILTER_PRECISION in user_input: + user_input[CONF_FILTER_PRECISION] = int(user_input[CONF_FILTER_PRECISION]) + + 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( + EntitySelectorConfig(domain=[SENSOR_DOMAIN]) + ), + vol.Required(CONF_FILTER_NAME): SelectSelector( + SelectSelectorConfig( + options=FILTERS, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_FILTER_NAME, + ) + ), + } +) + + +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 FilterConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle a config flow for Filter.""" + + 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/filter/const.py b/homeassistant/components/filter/const.py new file mode 100644 index 00000000000..92d2498528e --- /dev/null +++ b/homeassistant/components/filter/const.py @@ -0,0 +1,36 @@ +"""The filter component constants.""" + +from homeassistant.const import Platform + +DOMAIN = "filter" +PLATFORMS = [Platform.SENSOR] + +CONF_INDEX = "index" + +FILTER_NAME_RANGE = "range" +FILTER_NAME_LOWPASS = "lowpass" +FILTER_NAME_OUTLIER = "outlier" +FILTER_NAME_THROTTLE = "throttle" +FILTER_NAME_TIME_THROTTLE = "time_throttle" +FILTER_NAME_TIME_SMA = "time_simple_moving_average" + +CONF_FILTERS = "filters" +CONF_FILTER_NAME = "filter" +CONF_FILTER_WINDOW_SIZE = "window_size" +CONF_FILTER_PRECISION = "precision" +CONF_FILTER_RADIUS = "radius" +CONF_FILTER_TIME_CONSTANT = "time_constant" +CONF_FILTER_LOWER_BOUND = "lower_bound" +CONF_FILTER_UPPER_BOUND = "upper_bound" +CONF_TIME_SMA_TYPE = "type" + +TIME_SMA_LAST = "last" + +WINDOW_SIZE_UNIT_NUMBER_EVENTS = 1 +WINDOW_SIZE_UNIT_TIME = 2 + +DEFAULT_NAME = "Filtered sensor" +DEFAULT_WINDOW_SIZE = 1 +DEFAULT_PRECISION = 2 +DEFAULT_FILTER_RADIUS = 2.0 +DEFAULT_FILTER_TIME_CONSTANT = 10 diff --git a/homeassistant/components/filter/manifest.json b/homeassistant/components/filter/manifest.json index 4d9a8992036..392351a235d 100644 --- a/homeassistant/components/filter/manifest.json +++ b/homeassistant/components/filter/manifest.json @@ -2,6 +2,7 @@ "domain": "filter", "name": "Filter", "codeowners": ["@dgomes"], + "config_flow": true, "dependencies": ["recorder"], "documentation": "https://www.home-assistant.io/integrations/filter", "integration_type": "helper", diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index 549d74ffd09..d95d168b174 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -24,6 +24,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, @@ -51,39 +52,37 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateTyp from homeassistant.util.decorator import Registry import homeassistant.util.dt as dt_util -from . import DOMAIN, PLATFORMS +from .const import ( + CONF_FILTER_LOWER_BOUND, + CONF_FILTER_NAME, + CONF_FILTER_PRECISION, + CONF_FILTER_RADIUS, + CONF_FILTER_TIME_CONSTANT, + CONF_FILTER_UPPER_BOUND, + CONF_FILTER_WINDOW_SIZE, + CONF_FILTERS, + CONF_TIME_SMA_TYPE, + DEFAULT_FILTER_RADIUS, + DEFAULT_FILTER_TIME_CONSTANT, + DEFAULT_PRECISION, + DEFAULT_WINDOW_SIZE, + DOMAIN, + FILTER_NAME_LOWPASS, + FILTER_NAME_OUTLIER, + FILTER_NAME_RANGE, + FILTER_NAME_THROTTLE, + FILTER_NAME_TIME_SMA, + FILTER_NAME_TIME_THROTTLE, + PLATFORMS, + TIME_SMA_LAST, + WINDOW_SIZE_UNIT_NUMBER_EVENTS, + WINDOW_SIZE_UNIT_TIME, +) _LOGGER = logging.getLogger(__name__) -FILTER_NAME_RANGE = "range" -FILTER_NAME_LOWPASS = "lowpass" -FILTER_NAME_OUTLIER = "outlier" -FILTER_NAME_THROTTLE = "throttle" -FILTER_NAME_TIME_THROTTLE = "time_throttle" -FILTER_NAME_TIME_SMA = "time_simple_moving_average" FILTERS: Registry[str, type[Filter]] = Registry() -CONF_FILTERS = "filters" -CONF_FILTER_NAME = "filter" -CONF_FILTER_WINDOW_SIZE = "window_size" -CONF_FILTER_PRECISION = "precision" -CONF_FILTER_RADIUS = "radius" -CONF_FILTER_TIME_CONSTANT = "time_constant" -CONF_FILTER_LOWER_BOUND = "lower_bound" -CONF_FILTER_UPPER_BOUND = "upper_bound" -CONF_TIME_SMA_TYPE = "type" - -TIME_SMA_LAST = "last" - -WINDOW_SIZE_UNIT_NUMBER_EVENTS = 1 -WINDOW_SIZE_UNIT_TIME = 2 - -DEFAULT_WINDOW_SIZE = 1 -DEFAULT_PRECISION = 2 -DEFAULT_FILTER_RADIUS = 2.0 -DEFAULT_FILTER_TIME_CONSTANT = 10 - -NAME_TEMPLATE = "{} filter" ICON = "mdi:chart-line-variant" FILTER_SCHEMA = vol.Schema({vol.Optional(CONF_FILTER_PRECISION): vol.Coerce(int)}) @@ -199,6 +198,26 @@ async def async_setup_platform( async_add_entities([SensorFilter(name, unique_id, entity_id, filters)]) +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Filter sensor entry.""" + name: str | None = entry.options[CONF_NAME] + entity_id: str = entry.options[CONF_ENTITY_ID] + + filter_config = { + k: v for k, v in entry.options.items() if k not in (CONF_NAME, CONF_ENTITY_ID) + } + + filters = [ + FILTERS[filter_config.pop(CONF_FILTER_NAME)](entity=entity_id, **filter_config) + ] + + async_add_entities([SensorFilter(name, entry.entry_id, entity_id, filters)]) + + class SensorFilter(SensorEntity): """Representation of a Filter Sensor.""" diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index f399b0922f1..a379bb19967 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -6,6 +6,7 @@ To update, run python3 -m script.hassfest FLOWS = { "helper": [ "derivative", + "filter", "generic_hygrostat", "generic_thermostat", "group", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 3cde3573ff7..cd5a8430b94 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7284,7 +7284,7 @@ "filter": { "name": "Filter", "integration_type": "helper", - "config_flow": false, + "config_flow": true, "iot_class": "local_push" }, "generic_hygrostat": { From 9a2d6b4be3913d7561f841160e4105ebf12b15ae Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 8 Jul 2024 14:32:41 +0000 Subject: [PATCH 2/9] Add tests --- homeassistant/components/filter/sensor.py | 4 + tests/components/filter/conftest.py | 93 ++++++++ tests/components/filter/test_config_flow.py | 227 ++++++++++++++++++++ tests/components/filter/test_init.py | 20 ++ tests/components/filter/test_sensor.py | 51 ++++- 5 files changed, 393 insertions(+), 2 deletions(-) create mode 100644 tests/components/filter/conftest.py create mode 100644 tests/components/filter/test_config_flow.py create mode 100644 tests/components/filter/test_init.py diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index d95d168b174..11b7cbc6a59 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -210,6 +210,10 @@ async def async_setup_entry( filter_config = { k: v for k, v in entry.options.items() if k not in (CONF_NAME, CONF_ENTITY_ID) } + if isinstance(filter_config[CONF_FILTER_WINDOW_SIZE], dict): + filter_config[CONF_FILTER_WINDOW_SIZE] = timedelta( + **filter_config[CONF_FILTER_WINDOW_SIZE] + ) filters = [ FILTERS[filter_config.pop(CONF_FILTER_NAME)](entity=entity_id, **filter_config) diff --git a/tests/components/filter/conftest.py b/tests/components/filter/conftest.py new file mode 100644 index 00000000000..e703430446c --- /dev/null +++ b/tests/components/filter/conftest.py @@ -0,0 +1,93 @@ +"""Fixtures for the Filter integration.""" + +from __future__ import annotations + +from collections.abc import Generator +from datetime import timedelta +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.filter.const import ( + CONF_FILTER_NAME, + CONF_FILTER_PRECISION, + CONF_FILTER_RADIUS, + CONF_FILTER_WINDOW_SIZE, + DEFAULT_FILTER_RADIUS, + DEFAULT_NAME, + DEFAULT_PRECISION, + DEFAULT_WINDOW_SIZE, + DOMAIN, + FILTER_NAME_OUTLIER, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_ENTITY_ID, CONF_NAME +from homeassistant.core import HomeAssistant, State +import homeassistant.util.dt as dt_util + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="values") +def values_fixture() -> list[State]: + """Fixture for a list of test States.""" + values = [] + raw_values = [20, 19, 18, 21, 22, 0] + timestamp = dt_util.utcnow() + for val in raw_values: + values.append(State("sensor.test_monitored", str(val), last_updated=timestamp)) + timestamp += timedelta(minutes=1) + return values + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Automatically patch setup_entry.""" + with patch( + "homeassistant.components.filter.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.test_monitored", + CONF_FILTER_NAME: FILTER_NAME_OUTLIER, + CONF_FILTER_WINDOW_SIZE: DEFAULT_WINDOW_SIZE, + CONF_FILTER_RADIUS: DEFAULT_FILTER_RADIUS, + CONF_FILTER_PRECISION: DEFAULT_PRECISION, + } + + +@pytest.fixture(name="loaded_entry") +async def load_integration( + hass: HomeAssistant, get_config: dict[str, Any], values: list[State] +) -> MockConfigEntry: + """Set up the Filter integration in Home Assistant.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + options=get_config, + entry_id="1", + ) + + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + for value in values: + hass.states.async_set(get_config["entity_id"], value.state) + await hass.async_block_till_done() + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/filter/test_config_flow.py b/tests/components/filter/test_config_flow.py new file mode 100644 index 00000000000..13c0a5b4db9 --- /dev/null +++ b/tests/components/filter/test_config_flow.py @@ -0,0 +1,227 @@ +"""Test the Filter config flow.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from homeassistant import config_entries +from homeassistant.components.filter.const import ( + CONF_FILTER_LOWER_BOUND, + CONF_FILTER_NAME, + CONF_FILTER_PRECISION, + CONF_FILTER_RADIUS, + CONF_FILTER_TIME_CONSTANT, + CONF_FILTER_UPPER_BOUND, + CONF_FILTER_WINDOW_SIZE, + CONF_TIME_SMA_TYPE, + DEFAULT_FILTER_RADIUS, + DEFAULT_NAME, + DEFAULT_PRECISION, + DEFAULT_WINDOW_SIZE, + DOMAIN, + FILTER_NAME_LOWPASS, + FILTER_NAME_OUTLIER, + FILTER_NAME_RANGE, + FILTER_NAME_THROTTLE, + FILTER_NAME_TIME_SMA, + FILTER_NAME_TIME_THROTTLE, + TIME_SMA_LAST, +) +from homeassistant.components.recorder import Recorder +from homeassistant.const import CONF_ENTITY_ID, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("entry_config", "options", "result_options"), + [ + ( + {CONF_FILTER_NAME: FILTER_NAME_OUTLIER}, + { + CONF_FILTER_WINDOW_SIZE: 1.0, + CONF_FILTER_RADIUS: 2.0, + }, + { + CONF_FILTER_NAME: FILTER_NAME_OUTLIER, + CONF_FILTER_WINDOW_SIZE: 1, + CONF_FILTER_RADIUS: 2.0, + }, + ), + ( + {CONF_FILTER_NAME: FILTER_NAME_LOWPASS}, + { + CONF_FILTER_WINDOW_SIZE: 1.0, + CONF_FILTER_TIME_CONSTANT: 10.0, + }, + { + CONF_FILTER_NAME: FILTER_NAME_LOWPASS, + CONF_FILTER_WINDOW_SIZE: 1, + CONF_FILTER_TIME_CONSTANT: 10, + }, + ), + ( + {CONF_FILTER_NAME: FILTER_NAME_RANGE}, + { + CONF_FILTER_LOWER_BOUND: 1.0, + CONF_FILTER_UPPER_BOUND: 10.0, + }, + { + CONF_FILTER_NAME: FILTER_NAME_RANGE, + CONF_FILTER_LOWER_BOUND: 1.0, + CONF_FILTER_UPPER_BOUND: 10.0, + }, + ), + ( + {CONF_FILTER_NAME: FILTER_NAME_TIME_SMA}, + { + CONF_TIME_SMA_TYPE: TIME_SMA_LAST, + CONF_FILTER_WINDOW_SIZE: {"hours": 40, "minutes": 5, "seconds": 5}, + }, + { + CONF_FILTER_NAME: FILTER_NAME_TIME_SMA, + CONF_TIME_SMA_TYPE: TIME_SMA_LAST, + CONF_FILTER_WINDOW_SIZE: {"hours": 40, "minutes": 5, "seconds": 5}, + }, + ), + ( + {CONF_FILTER_NAME: FILTER_NAME_THROTTLE}, + { + CONF_FILTER_WINDOW_SIZE: 1.0, + }, + { + CONF_FILTER_NAME: FILTER_NAME_THROTTLE, + CONF_FILTER_WINDOW_SIZE: 1, + }, + ), + ( + {CONF_FILTER_NAME: FILTER_NAME_TIME_THROTTLE}, + { + CONF_FILTER_WINDOW_SIZE: {"hours": 40, "minutes": 5, "seconds": 5}, + }, + { + CONF_FILTER_NAME: FILTER_NAME_TIME_THROTTLE, + CONF_FILTER_WINDOW_SIZE: {"hours": 40, "minutes": 5, "seconds": 5}, + }, + ), + ], +) +async def test_form( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + entry_config: dict[str, Any], + options: dict[str, Any], + result_options: dict[str, Any], +) -> 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", + **entry_config, + }, + ) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_FILTER_PRECISION: DEFAULT_PRECISION, **options}, + ) + 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_FILTER_PRECISION: DEFAULT_PRECISION, + **result_options, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_options_flow( + recorder_mock: Recorder, 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_FILTER_WINDOW_SIZE: 2.0, + CONF_FILTER_RADIUS: 3.0, + CONF_FILTER_PRECISION: DEFAULT_PRECISION, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_FILTER_NAME: FILTER_NAME_OUTLIER, + CONF_FILTER_WINDOW_SIZE: 2, + CONF_FILTER_RADIUS: 3.0, + CONF_FILTER_PRECISION: DEFAULT_PRECISION, + } + + 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.filtered_sensor") + assert state is not None + + +async def test_entry_already_exist( + recorder_mock: Recorder, 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.test_monitored", + CONF_FILTER_NAME: FILTER_NAME_OUTLIER, + }, + ) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_FILTER_WINDOW_SIZE: DEFAULT_WINDOW_SIZE, + CONF_FILTER_RADIUS: DEFAULT_FILTER_RADIUS, + CONF_FILTER_PRECISION: DEFAULT_PRECISION, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/filter/test_init.py b/tests/components/filter/test_init.py new file mode 100644 index 00000000000..a5d5cf84a67 --- /dev/null +++ b/tests/components/filter/test_init.py @@ -0,0 +1,20 @@ +"""Test Filter component setup process.""" + +from __future__ import annotations + +from homeassistant.components.recorder import Recorder +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_unload_entry( + recorder_mock: Recorder, 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 diff --git a/tests/components/filter/test_sensor.py b/tests/components/filter/test_sensor.py index a3e0e58908a..04c3ab9599f 100644 --- a/tests/components/filter/test_sensor.py +++ b/tests/components/filter/test_sensor.py @@ -6,8 +6,18 @@ from unittest.mock import patch import pytest from homeassistant import config as hass_config -from homeassistant.components.filter.sensor import ( +from homeassistant.components.filter.const import ( + CONF_FILTER_NAME, + CONF_FILTER_PRECISION, + CONF_FILTER_WINDOW_SIZE, + CONF_TIME_SMA_TYPE, + DEFAULT_NAME, + DEFAULT_PRECISION, DOMAIN, + FILTER_NAME_TIME_SMA, + TIME_SMA_LAST, +) +from homeassistant.components.filter.sensor import ( LowPassFilter, OutlierFilter, RangeFilter, @@ -24,6 +34,8 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, + CONF_ENTITY_ID, + CONF_NAME, SERVICE_RELOAD, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -34,7 +46,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import assert_setup_component, get_fixture_path +from tests.common import MockConfigEntry, assert_setup_component, get_fixture_path @pytest.fixture(autouse=True, name="stub_blueprint_populate") @@ -97,6 +109,41 @@ async def test_chain( assert state.state == "18.05" +async def test_from_config_entry( + recorder_mock: Recorder, + hass: HomeAssistant, + loaded_entry: MockConfigEntry, +) -> None: + """Test if filter works loaded from config entry.""" + + state = hass.states.get("sensor.filtered_sensor") + assert state.state == "22.0" + + +@pytest.mark.parametrize( + "get_config", + [ + { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_FILTER_NAME: FILTER_NAME_TIME_SMA, + CONF_TIME_SMA_TYPE: TIME_SMA_LAST, + CONF_FILTER_WINDOW_SIZE: {"hours": 40, "minutes": 5, "seconds": 5}, + CONF_FILTER_PRECISION: DEFAULT_PRECISION, + } + ], +) +async def test_from_config_entry_duration( + recorder_mock: Recorder, + hass: HomeAssistant, + loaded_entry: MockConfigEntry, +) -> None: + """Test if filter works loaded from config entry with duration.""" + + state = hass.states.get("sensor.filtered_sensor") + assert state.state == "20.0" + + @pytest.mark.parametrize("missing", [True, False]) async def test_chain_history( recorder_mock: Recorder, From 277f6e4f51bb2f350c53d5b8bfe8719715c5fdb0 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 8 Jul 2024 15:31:15 +0000 Subject: [PATCH 3/9] Add strings --- homeassistant/components/filter/strings.json | 86 ++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/homeassistant/components/filter/strings.json b/homeassistant/components/filter/strings.json index 461eed9aefa..28f3f02bb55 100644 --- a/homeassistant/components/filter/strings.json +++ b/homeassistant/components/filter/strings.json @@ -1,4 +1,90 @@ { + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "step": { + "user": { + "description": "Add a filter sensor", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "entity_id": "Entity", + "filter": "Filter" + }, + "data_description": { + "name": "Name for the created entity.", + "entity_id": "Entity to filter from.", + "filter": "Select filter to configure." + } + }, + "options": { + "description": "Read the documention for further details on how to configure the filter sensor using these options.", + "data": { + "window_size": "Window size", + "precision": "Precision", + "radius": "Radius", + "time_constant": "Time constant", + "lower_bound": "Lower bound", + "upper_bound": "Upper bound", + "type": "Type" + }, + "data_description": { + "window_size": "Size of the window of previous states.", + "precision": "Defines the number of decimal places of the calculated sensor value.", + "radius": "Band radius from median of previous states.", + "time_constant": "Loosely relates to the amount of time it takes for a state to influence the output.", + "lower_bound": "Lower bound for filter range.", + "upper_bound": "Upper bound for filter range.", + "type": "Defines the type of Simple Moving Average." + } + } + } + }, + "options": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "step": { + "init": { + "description": "[%key:component::filter::config::step::options::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::options::data::window_size%]", + "precision": "[%key:component::filter::config::step::options::data::precision%]", + "radius": "[%key:component::filter::config::step::options::data::radius%]", + "time_constant": "[%key:component::filter::config::step::options::data::time_constant%]", + "lower_bound": "[%key:component::filter::config::step::options::data::lower_bound%]", + "upper_bound": "[%key:component::filter::config::step::options::data::upper_bound%]", + "type": "[%key:component::filter::config::step::options::data::type%]" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::options::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::options::data_description::precision%]", + "radius": "[%key:component::filter::config::step::options::data_description::radius%]", + "time_constant": "[%key:component::filter::config::step::options::data_description::time_constant%]", + "lower_bound": "[%key:component::filter::config::step::options::data_description::lower_bound%]", + "upper_bound": "[%key:component::filter::config::step::options::data_description::upper_bound%]", + "type": "[%key:component::filter::config::step::options::data_description::type%]" + } + } + } + }, + "selector": { + "filter": { + "options": { + "range": "Range", + "lowpass": "Lowpass", + "outlier": "Outlier", + "throttle": "Throttle", + "time_throttle": "Time throttle", + "time_simple_moving_average": "Time simple moving average" + } + }, + "type": { + "options": { + "last": "Last" + } + } + }, "services": { "reload": { "name": "[%key:common::action::reload%]", From 139b0c253685630c1df9879c448ed1401a5fd323 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 8 Jul 2024 15:38:47 +0000 Subject: [PATCH 4/9] Add to description --- homeassistant/components/filter/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/filter/strings.json b/homeassistant/components/filter/strings.json index 28f3f02bb55..e1ddd001cef 100644 --- a/homeassistant/components/filter/strings.json +++ b/homeassistant/components/filter/strings.json @@ -5,7 +5,7 @@ }, "step": { "user": { - "description": "Add a filter sensor", + "description": "Add a filter sensor. Only one filter can be used with the UI configuration.", "data": { "name": "[%key:common::config_flow::data::name%]", "entity_id": "Entity", From 66753dc0928a9ead1c3831002c2de67bb8ea6b79 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 8 Jul 2024 17:12:53 +0000 Subject: [PATCH 5/9] Review comments --- homeassistant/components/filter/config_flow.py | 6 +++--- homeassistant/components/filter/strings.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/filter/config_flow.py b/homeassistant/components/filter/config_flow.py index 2a339056f5c..e3fca089d40 100644 --- a/homeassistant/components/filter/config_flow.py +++ b/homeassistant/components/filter/config_flow.py @@ -77,7 +77,7 @@ async def get_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: vol.Optional( CONF_FILTER_RADIUS, default=DEFAULT_FILTER_RADIUS ): NumberSelector( - NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + NumberSelectorConfig(min=0, step=0.1, mode=NumberSelectorMode.BOX) ), } @@ -98,10 +98,10 @@ async def get_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: elif handler.options[CONF_FILTER_NAME] == FILTER_NAME_RANGE: filter_schema = { vol.Optional(CONF_FILTER_LOWER_BOUND): NumberSelector( - NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + NumberSelectorConfig(min=0, step=0.1, mode=NumberSelectorMode.BOX) ), vol.Optional(CONF_FILTER_UPPER_BOUND): NumberSelector( - NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + NumberSelectorConfig(min=0, step=0.1, mode=NumberSelectorMode.BOX) ), } diff --git a/homeassistant/components/filter/strings.json b/homeassistant/components/filter/strings.json index e1ddd001cef..0c72fcd366f 100644 --- a/homeassistant/components/filter/strings.json +++ b/homeassistant/components/filter/strings.json @@ -5,7 +5,7 @@ }, "step": { "user": { - "description": "Add a filter sensor. Only one filter can be used with the UI configuration.", + "description": "Add a filter sensor. UI configuration is limited to a single filter, use YAML for filter chain.", "data": { "name": "[%key:common::config_flow::data::name%]", "entity_id": "Entity", @@ -18,7 +18,7 @@ } }, "options": { - "description": "Read the documention for further details on how to configure the filter sensor using these options.", + "description": "Read the documentation for further details on how to configure the filter sensor using these options.", "data": { "window_size": "Window size", "precision": "Precision", @@ -76,7 +76,7 @@ "outlier": "Outlier", "throttle": "Throttle", "time_throttle": "Time throttle", - "time_simple_moving_average": "Time simple moving average" + "time_simple_moving_average": "Moving Average (Time based)" } }, "type": { From 92594727029c7f861d252485fe56fd0fee27bdf1 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 9 Jul 2024 08:37:01 +0000 Subject: [PATCH 6/9] floats to step any --- homeassistant/components/filter/config_flow.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/filter/config_flow.py b/homeassistant/components/filter/config_flow.py index e3fca089d40..9e94b57d808 100644 --- a/homeassistant/components/filter/config_flow.py +++ b/homeassistant/components/filter/config_flow.py @@ -77,7 +77,7 @@ async def get_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: vol.Optional( CONF_FILTER_RADIUS, default=DEFAULT_FILTER_RADIUS ): NumberSelector( - NumberSelectorConfig(min=0, step=0.1, mode=NumberSelectorMode.BOX) + NumberSelectorConfig(min=0, step="any", mode=NumberSelectorMode.BOX) ), } @@ -98,10 +98,10 @@ async def get_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: elif handler.options[CONF_FILTER_NAME] == FILTER_NAME_RANGE: filter_schema = { vol.Optional(CONF_FILTER_LOWER_BOUND): NumberSelector( - NumberSelectorConfig(min=0, step=0.1, mode=NumberSelectorMode.BOX) + NumberSelectorConfig(min=0, step="any", mode=NumberSelectorMode.BOX) ), vol.Optional(CONF_FILTER_UPPER_BOUND): NumberSelector( - NumberSelectorConfig(min=0, step=0.1, mode=NumberSelectorMode.BOX) + NumberSelectorConfig(min=0, step="any", mode=NumberSelectorMode.BOX) ), } From 2e8f7c199837d0a2588c025c9989ac7aec135e5c Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 10 Jul 2024 07:51:41 +0000 Subject: [PATCH 7/9] Fix sensor when not including window size --- homeassistant/components/filter/sensor.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index 11b7cbc6a59..5295f46eb4a 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -210,7 +210,9 @@ async def async_setup_entry( filter_config = { k: v for k, v in entry.options.items() if k not in (CONF_NAME, CONF_ENTITY_ID) } - if isinstance(filter_config[CONF_FILTER_WINDOW_SIZE], dict): + if CONF_FILTER_WINDOW_SIZE in filter_config and isinstance( + filter_config[CONF_FILTER_WINDOW_SIZE], dict + ): filter_config[CONF_FILTER_WINDOW_SIZE] = timedelta( **filter_config[CONF_FILTER_WINDOW_SIZE] ) From cb04224b3f798fcab34250cf765c5c0f080396df Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 10 Jul 2024 07:55:00 +0000 Subject: [PATCH 8/9] Adapt next step based on filter type --- .../components/filter/config_flow.py | 204 ++++++++++-------- homeassistant/components/filter/strings.json | 156 +++++++++++--- tests/components/filter/test_config_flow.py | 2 +- 3 files changed, 250 insertions(+), 112 deletions(-) diff --git a/homeassistant/components/filter/config_flow.py b/homeassistant/components/filter/config_flow.py index 9e94b57d808..dac2d8995bf 100644 --- a/homeassistant/components/filter/config_flow.py +++ b/homeassistant/components/filter/config_flow.py @@ -22,7 +22,6 @@ from homeassistant.helpers.selector import ( NumberSelector, NumberSelectorConfig, NumberSelectorMode, - Selector, SelectSelector, SelectSelectorConfig, SelectSelectorMode, @@ -63,85 +62,9 @@ FILTERS = [ ] -async def get_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: - """Return schema for options.""" - filter_schema: dict[vol.Marker, Selector] = {} - - if handler.options[CONF_FILTER_NAME] == FILTER_NAME_OUTLIER: - filter_schema = { - vol.Optional( - CONF_FILTER_WINDOW_SIZE, default=DEFAULT_WINDOW_SIZE - ): NumberSelector( - NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) - ), - vol.Optional( - CONF_FILTER_RADIUS, default=DEFAULT_FILTER_RADIUS - ): NumberSelector( - NumberSelectorConfig(min=0, step="any", mode=NumberSelectorMode.BOX) - ), - } - - elif handler.options[CONF_FILTER_NAME] == FILTER_NAME_LOWPASS: - filter_schema = { - vol.Optional( - CONF_FILTER_WINDOW_SIZE, default=DEFAULT_WINDOW_SIZE - ): NumberSelector( - NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) - ), - vol.Optional( - CONF_FILTER_TIME_CONSTANT, default=DEFAULT_FILTER_TIME_CONSTANT - ): NumberSelector( - NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) - ), - } - - elif handler.options[CONF_FILTER_NAME] == FILTER_NAME_RANGE: - filter_schema = { - vol.Optional(CONF_FILTER_LOWER_BOUND): NumberSelector( - NumberSelectorConfig(min=0, step="any", mode=NumberSelectorMode.BOX) - ), - vol.Optional(CONF_FILTER_UPPER_BOUND): NumberSelector( - NumberSelectorConfig(min=0, step="any", mode=NumberSelectorMode.BOX) - ), - } - - elif handler.options[CONF_FILTER_NAME] == FILTER_NAME_TIME_SMA: - filter_schema = { - vol.Optional(CONF_TIME_SMA_TYPE, default=TIME_SMA_LAST): SelectSelector( - SelectSelectorConfig( - options=[TIME_SMA_LAST], - mode=SelectSelectorMode.DROPDOWN, - translation_key=CONF_TIME_SMA_TYPE, - ) - ), - vol.Required(CONF_FILTER_WINDOW_SIZE): DurationSelector( - DurationSelectorConfig(enable_day=False, allow_negative=False) - ), - } - - elif handler.options[CONF_FILTER_NAME] == FILTER_NAME_THROTTLE: - filter_schema = { - vol.Optional( - CONF_FILTER_WINDOW_SIZE, default=DEFAULT_WINDOW_SIZE - ): NumberSelector( - NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) - ), - } - - elif handler.options[CONF_FILTER_NAME] == FILTER_NAME_TIME_THROTTLE: - filter_schema = { - vol.Required(CONF_FILTER_WINDOW_SIZE): DurationSelector( - DurationSelectorConfig(enable_day=False, allow_negative=False) - ), - } - - base_schema: dict[vol.Marker, Selector] = { - vol.Optional(CONF_FILTER_PRECISION, default=DEFAULT_PRECISION): NumberSelector( - NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) - ) - } - - return vol.Schema({**filter_schema, **base_schema}) +async def get_next_step(user_input: dict[str, Any]) -> str: + """Return next step for options.""" + return cast(str, user_input[CONF_FILTER_NAME]) async def validate_options( @@ -181,21 +104,130 @@ DATA_SCHEMA_SETUP = vol.Schema( } ) +BASE_OPTIONS_SCHEMA = { + vol.Optional(CONF_FILTER_PRECISION, default=DEFAULT_PRECISION): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ) +} + +OUTLIER_SCHEMA = vol.Schema( + { + vol.Optional( + CONF_FILTER_WINDOW_SIZE, default=DEFAULT_WINDOW_SIZE + ): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + vol.Optional(CONF_FILTER_RADIUS, default=DEFAULT_FILTER_RADIUS): NumberSelector( + NumberSelectorConfig(min=0, step="any", mode=NumberSelectorMode.BOX) + ), + } +).extend(BASE_OPTIONS_SCHEMA) + +LOWPASS_SCHEMA = vol.Schema( + { + vol.Optional( + CONF_FILTER_WINDOW_SIZE, default=DEFAULT_WINDOW_SIZE + ): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + vol.Optional( + CONF_FILTER_TIME_CONSTANT, default=DEFAULT_FILTER_TIME_CONSTANT + ): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + } +).extend(BASE_OPTIONS_SCHEMA) + +RANGE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_FILTER_LOWER_BOUND): NumberSelector( + NumberSelectorConfig(min=0, step="any", mode=NumberSelectorMode.BOX) + ), + vol.Optional(CONF_FILTER_UPPER_BOUND): NumberSelector( + NumberSelectorConfig(min=0, step="any", mode=NumberSelectorMode.BOX) + ), + } +).extend(BASE_OPTIONS_SCHEMA) + +TIME_SMA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_TIME_SMA_TYPE, default=TIME_SMA_LAST): SelectSelector( + SelectSelectorConfig( + options=[TIME_SMA_LAST], + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_TIME_SMA_TYPE, + ) + ), + vol.Required(CONF_FILTER_WINDOW_SIZE): DurationSelector( + DurationSelectorConfig(enable_day=False, allow_negative=False) + ), + } +).extend(BASE_OPTIONS_SCHEMA) + +THROTTLE_SCHEMA = vol.Schema( + { + vol.Optional( + CONF_FILTER_WINDOW_SIZE, default=DEFAULT_WINDOW_SIZE + ): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + } +).extend(BASE_OPTIONS_SCHEMA) + +TIME_THROTTLE_SCHEMA = vol.Schema( + { + vol.Required(CONF_FILTER_WINDOW_SIZE): DurationSelector( + DurationSelectorConfig(enable_day=False, allow_negative=False) + ), + } +).extend(BASE_OPTIONS_SCHEMA) CONFIG_FLOW = { "user": SchemaFlowFormStep( schema=DATA_SCHEMA_SETUP, - next_step="options", + next_step=get_next_step, ), - "options": SchemaFlowFormStep( - schema=get_options_schema, - validate_user_input=validate_options, + "lowpass": SchemaFlowFormStep( + schema=LOWPASS_SCHEMA, validate_user_input=validate_options + ), + "outlier": SchemaFlowFormStep( + schema=OUTLIER_SCHEMA, validate_user_input=validate_options + ), + "range": SchemaFlowFormStep( + schema=RANGE_SCHEMA, validate_user_input=validate_options + ), + "time_simple_moving_average": SchemaFlowFormStep( + schema=TIME_SMA_SCHEMA, validate_user_input=validate_options + ), + "throttle": SchemaFlowFormStep( + schema=THROTTLE_SCHEMA, validate_user_input=validate_options + ), + "time_throttle": SchemaFlowFormStep( + schema=TIME_THROTTLE_SCHEMA, validate_user_input=validate_options ), } OPTIONS_FLOW = { "init": SchemaFlowFormStep( - get_options_schema, - validate_user_input=validate_options, + schema=None, + next_step=get_next_step, + ), + "lowpass": SchemaFlowFormStep( + schema=LOWPASS_SCHEMA, validate_user_input=validate_options + ), + "outlier": SchemaFlowFormStep( + schema=OUTLIER_SCHEMA, validate_user_input=validate_options + ), + "range": SchemaFlowFormStep( + schema=RANGE_SCHEMA, validate_user_input=validate_options + ), + "time_simple_moving_average": SchemaFlowFormStep( + schema=TIME_SMA_SCHEMA, validate_user_input=validate_options + ), + "throttle": SchemaFlowFormStep( + schema=THROTTLE_SCHEMA, validate_user_input=validate_options + ), + "time_throttle": SchemaFlowFormStep( + schema=TIME_THROTTLE_SCHEMA, validate_user_input=validate_options ), } diff --git a/homeassistant/components/filter/strings.json b/homeassistant/components/filter/strings.json index 0c72fcd366f..44e7f741118 100644 --- a/homeassistant/components/filter/strings.json +++ b/homeassistant/components/filter/strings.json @@ -17,26 +17,79 @@ "filter": "Select filter to configure." } }, - "options": { + "outlier": { "description": "Read the documentation for further details on how to configure the filter sensor using these options.", "data": { "window_size": "Window size", "precision": "Precision", - "radius": "Radius", - "time_constant": "Time constant", - "lower_bound": "Lower bound", - "upper_bound": "Upper bound", - "type": "Type" + "radius": "Radius" }, "data_description": { "window_size": "Size of the window of previous states.", "precision": "Defines the number of decimal places of the calculated sensor value.", - "radius": "Band radius from median of previous states.", - "time_constant": "Loosely relates to the amount of time it takes for a state to influence the output.", + "radius": "Band radius from median of previous states." + } + }, + "lowpass": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "time_constant": "Time constant" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", + "time_constant": "Loosely relates to the amount of time it takes for a state to influence the output." + } + }, + "range": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "lower_bound": "Lower bound", + "upper_bound": "Upper bound" + }, + "data_description": { + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", "lower_bound": "Lower bound for filter range.", - "upper_bound": "Upper bound for filter range.", + "upper_bound": "Upper bound for filter range." + } + }, + "time_simple_moving_average": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "type": "Type" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", "type": "Defines the type of Simple Moving Average." } + }, + "throttle": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]" + } + }, + "time_throttle": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]" + } } } }, @@ -45,25 +98,78 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" }, "step": { - "init": { - "description": "[%key:component::filter::config::step::options::description%]", + "outlier": { + "description": "[%key:component::filter::config::step::outlier::description%]", "data": { - "window_size": "[%key:component::filter::config::step::options::data::window_size%]", - "precision": "[%key:component::filter::config::step::options::data::precision%]", - "radius": "[%key:component::filter::config::step::options::data::radius%]", - "time_constant": "[%key:component::filter::config::step::options::data::time_constant%]", - "lower_bound": "[%key:component::filter::config::step::options::data::lower_bound%]", - "upper_bound": "[%key:component::filter::config::step::options::data::upper_bound%]", - "type": "[%key:component::filter::config::step::options::data::type%]" + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "radius": "[%key:component::filter::config::step::outlier::data::radius%]" }, "data_description": { - "window_size": "[%key:component::filter::config::step::options::data_description::window_size%]", - "precision": "[%key:component::filter::config::step::options::data_description::precision%]", - "radius": "[%key:component::filter::config::step::options::data_description::radius%]", - "time_constant": "[%key:component::filter::config::step::options::data_description::time_constant%]", - "lower_bound": "[%key:component::filter::config::step::options::data_description::lower_bound%]", - "upper_bound": "[%key:component::filter::config::step::options::data_description::upper_bound%]", - "type": "[%key:component::filter::config::step::options::data_description::type%]" + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", + "radius": "[%key:component::filter::config::step::outlier::data_description::radius%]" + } + }, + "lowpass": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "time_constant": "[%key:component::filter::config::step::lowpass::data::time_constant%]" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", + "time_constant": "[%key:component::filter::config::step::lowpass::data_description::time_constant%]" + } + }, + "range": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "lower_bound": "[%key:component::filter::config::step::range::data::lower_bound%]", + "upper_bound": "[%key:component::filter::config::step::range::data::upper_bound%]" + }, + "data_description": { + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", + "lower_bound": "[%key:component::filter::config::step::range::data_description::lower_bound%]", + "upper_bound": "[%key:component::filter::config::step::range::data_description::upper_bound%]" + } + }, + "time_simple_moving_average": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "type": "[%key:component::filter::config::step::time_simple_moving_average::data::type%]" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", + "type": "[%key:component::filter::config::step::time_simple_moving_average::data_description::type%]" + } + }, + "throttle": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]" + } + }, + "time_throttle": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]" } } } diff --git a/tests/components/filter/test_config_flow.py b/tests/components/filter/test_config_flow.py index 13c0a5b4db9..d4a7f7a854f 100644 --- a/tests/components/filter/test_config_flow.py +++ b/tests/components/filter/test_config_flow.py @@ -162,7 +162,7 @@ async def 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" + assert result["step_id"] == "outlier" result = await hass.config_entries.options.async_configure( result["flow_id"], From e296e792832bcb396e6669220012d97919ca6b80 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Oct 2024 16:21:07 +0000 Subject: [PATCH 9/9] Fix typing --- homeassistant/components/filter/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index 5295f46eb4a..5bb6cadabc7 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -204,7 +204,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Filter sensor entry.""" - name: str | None = entry.options[CONF_NAME] + name: str = entry.options[CONF_NAME] entity_id: str = entry.options[CONF_ENTITY_ID] filter_config = {