From 84d1d111385dd705fa38bbcde34a95d2d171afcb Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sun, 23 Jun 2024 12:56:41 +0200 Subject: [PATCH] Add config flow to generic hygrostat (#119017) * Add config flow to hygrostat Co-authored-by: Franck Nijhof --- .../components/generic_hygrostat/__init__.py | 20 ++++ .../generic_hygrostat/config_flow.py | 100 +++++++++++++++++ .../generic_hygrostat/humidifier.py | 50 +++++++-- .../generic_hygrostat/manifest.json | 2 + .../components/generic_hygrostat/strings.json | 56 +++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 12 +- .../snapshots/test_config_flow.ambr | 66 +++++++++++ .../generic_hygrostat/test_config_flow.py | 106 ++++++++++++++++++ 9 files changed, 400 insertions(+), 13 deletions(-) create mode 100644 homeassistant/components/generic_hygrostat/config_flow.py create mode 100644 homeassistant/components/generic_hygrostat/strings.json create mode 100644 tests/components/generic_hygrostat/snapshots/test_config_flow.ambr create mode 100644 tests/components/generic_hygrostat/test_config_flow.py diff --git a/homeassistant/components/generic_hygrostat/__init__.py b/homeassistant/components/generic_hygrostat/__init__.py index 467a9f0e0c5ce7..ef032da1ee23ef 100644 --- a/homeassistant/components/generic_hygrostat/__init__.py +++ b/homeassistant/components/generic_hygrostat/__init__.py @@ -3,6 +3,7 @@ import voluptuous as vol from homeassistant.components.humidifier import HumidifierDeviceClass +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, discovery @@ -73,3 +74,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, (Platform.HUMIDIFIER,)) + 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.HUMIDIFIER,) + ) diff --git a/homeassistant/components/generic_hygrostat/config_flow.py b/homeassistant/components/generic_hygrostat/config_flow.py new file mode 100644 index 00000000000000..cade566968d31b --- /dev/null +++ b/homeassistant/components/generic_hygrostat/config_flow.py @@ -0,0 +1,100 @@ +"""Config flow for Generic hygrostat.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, cast + +import voluptuous as vol + +from homeassistant.components.humidifier import HumidifierDeviceClass +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import CONF_NAME, PERCENTAGE +from homeassistant.helpers import selector +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaConfigFlowHandler, + SchemaFlowFormStep, +) + +from . import ( + CONF_DEVICE_CLASS, + CONF_DRY_TOLERANCE, + CONF_HUMIDIFIER, + CONF_MIN_DUR, + CONF_SENSOR, + CONF_WET_TOLERANCE, + DEFAULT_TOLERANCE, + DOMAIN, +) + +OPTIONS_SCHEMA = { + vol.Required(CONF_DEVICE_CLASS): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + HumidifierDeviceClass.HUMIDIFIER, + HumidifierDeviceClass.DEHUMIDIFIER, + ], + translation_key=CONF_DEVICE_CLASS, + mode=selector.SelectSelectorMode.DROPDOWN, + ), + ), + vol.Required(CONF_SENSOR): selector.EntitySelector( + selector.EntitySelectorConfig( + domain=SENSOR_DOMAIN, device_class=SensorDeviceClass.HUMIDITY + ) + ), + vol.Required(CONF_HUMIDIFIER): selector.EntitySelector( + selector.EntitySelectorConfig(domain=SWITCH_DOMAIN) + ), + vol.Required( + CONF_DRY_TOLERANCE, default=DEFAULT_TOLERANCE + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, + max=100, + step=0.5, + unit_of_measurement=PERCENTAGE, + mode=selector.NumberSelectorMode.BOX, + ) + ), + vol.Required( + CONF_WET_TOLERANCE, default=DEFAULT_TOLERANCE + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, + max=100, + step=0.5, + unit_of_measurement=PERCENTAGE, + mode=selector.NumberSelectorMode.BOX, + ) + ), + vol.Optional(CONF_MIN_DUR): selector.DurationSelector( + selector.DurationSelectorConfig(allow_negative=False) + ), +} + +CONFIG_SCHEMA = { + vol.Required(CONF_NAME): selector.TextSelector(), + **OPTIONS_SCHEMA, +} + + +CONFIG_FLOW = { + "user": SchemaFlowFormStep(vol.Schema(CONFIG_SCHEMA)), +} + +OPTIONS_FLOW = { + "init": SchemaFlowFormStep(vol.Schema(OPTIONS_SCHEMA)), +} + + +class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle a config or options flow.""" + + config_flow = CONFIG_FLOW + options_flow = OPTIONS_FLOW + + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return cast(str, options["name"]) diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index dea614d92f2742..c22904a4caa900 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -3,10 +3,10 @@ from __future__ import annotations import asyncio -from collections.abc import Callable +from collections.abc import Callable, Mapping from datetime import datetime, timedelta import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from homeassistant.components.humidifier import ( ATTR_HUMIDITY, @@ -18,6 +18,7 @@ HumidifierEntity, HumidifierEntityFeature, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_MODE, @@ -39,7 +40,7 @@ State, callback, ) -from homeassistant.helpers import condition +from homeassistant.helpers import condition, config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( async_track_state_change_event, @@ -83,6 +84,38 @@ async def async_setup_platform( """Set up the generic hygrostat platform.""" if discovery_info: config = discovery_info + await _async_setup_config( + hass, config, config.get(CONF_UNIQUE_ID), async_add_entities + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize config entry.""" + + await _async_setup_config( + hass, + config_entry.options, + config_entry.entry_id, + async_add_entities, + ) + + +def _time_period_or_none(value: Any) -> timedelta | None: + if value is None: + return None + return cast(timedelta, cv.time_period(value)) + + +async def _async_setup_config( + hass: HomeAssistant, + config: Mapping[str, Any], + unique_id: str | None, + async_add_entities: AddEntitiesCallback, +) -> None: name: str = config[CONF_NAME] switch_entity_id: str = config[CONF_HUMIDIFIER] sensor_entity_id: str = config[CONF_SENSOR] @@ -90,15 +123,18 @@ async def async_setup_platform( max_humidity: float | None = config.get(CONF_MAX_HUMIDITY) target_humidity: float | None = config.get(CONF_TARGET_HUMIDITY) device_class: HumidifierDeviceClass | None = config.get(CONF_DEVICE_CLASS) - min_cycle_duration: timedelta | None = config.get(CONF_MIN_DUR) - sensor_stale_duration: timedelta | None = config.get(CONF_STALE_DURATION) + min_cycle_duration: timedelta | None = _time_period_or_none( + config.get(CONF_MIN_DUR) + ) + sensor_stale_duration: timedelta | None = _time_period_or_none( + config.get(CONF_STALE_DURATION) + ) dry_tolerance: float = config[CONF_DRY_TOLERANCE] wet_tolerance: float = config[CONF_WET_TOLERANCE] - keep_alive: timedelta | None = config.get(CONF_KEEP_ALIVE) + keep_alive: timedelta | None = _time_period_or_none(config.get(CONF_KEEP_ALIVE)) initial_state: bool | None = config.get(CONF_INITIAL_STATE) away_humidity: int | None = config.get(CONF_AWAY_HUMIDITY) away_fixed: bool | None = config.get(CONF_AWAY_FIXED) - unique_id: str | None = config.get(CONF_UNIQUE_ID) async_add_entities( [ diff --git a/homeassistant/components/generic_hygrostat/manifest.json b/homeassistant/components/generic_hygrostat/manifest.json index cf0ace5e011146..20222fd3617df9 100644 --- a/homeassistant/components/generic_hygrostat/manifest.json +++ b/homeassistant/components/generic_hygrostat/manifest.json @@ -2,7 +2,9 @@ "domain": "generic_hygrostat", "name": "Generic hygrostat", "codeowners": ["@Shulyaka"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/generic_hygrostat", + "integration_type": "helper", "iot_class": "local_polling", "quality_scale": "internal" } diff --git a/homeassistant/components/generic_hygrostat/strings.json b/homeassistant/components/generic_hygrostat/strings.json new file mode 100644 index 00000000000000..a21ab68c62830a --- /dev/null +++ b/homeassistant/components/generic_hygrostat/strings.json @@ -0,0 +1,56 @@ +{ + "title": "Generic hygrostat", + "config": { + "step": { + "user": { + "title": "Add generic hygrostat", + "description": "Create a entity that control the humidity via a switch and sensor.", + "data": { + "device_class": "Device class", + "dry_tolerance": "Dry tolerance", + "humidifier": "Switch", + "min_cycle_duration": "Minimum cycle duration", + "name": "[%key:common::config_flow::data::name%]", + "target_sensor": "Humidity sensor", + "wet_tolerance": "Wet tolerance" + }, + "data_description": { + "dry_tolerance": "The minimum amount of difference between the humidity read by the sensor specified in the target sensor option and the target humidity that must change prior to being switched on.", + "humidifier": "Humidifier or dehumidifier switch; must be a toggle device.", + "min_cycle_duration": "Set a minimum amount of time that the switch specified in the humidifier option must be in its current state prior to being switched either off or on.", + "target_sensor": "Sensor with current humidity.", + "wet_tolerance": "The minimum amount of difference between the humidity read by the sensor specified in the target sensor option and the target humidity that must change prior to being switched off." + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "device_class": "[%key:component::generic_hygrostat::config::step::user::data::device_class%]", + "dry_tolerance": "[%key:component::generic_hygrostat::config::step::user::data::dry_tolerance%]", + "humidifier": "[%key:component::generic_hygrostat::config::step::user::data::humidifier%]", + "min_cycle_duration": "[%key:component::generic_hygrostat::config::step::user::data::min_cycle_duration%]", + "target_sensor": "[%key:component::generic_hygrostat::config::step::user::data::target_sensor%]", + "wet_tolerance": "[%key:component::generic_hygrostat::config::step::user::data::wet_tolerance%]" + }, + "data_description": { + "dry_tolerance": "[%key:component::generic_hygrostat::config::step::user::data_description::dry_tolerance%]", + "humidifier": "[%key:component::generic_hygrostat::config::step::user::data_description::humidifier%]", + "min_cycle_duration": "[%key:component::generic_hygrostat::config::step::user::data_description::min_cycle_duration%]", + "target_sensor": "[%key:component::generic_hygrostat::config::step::user::data_description::target_sensor%]", + "wet_tolerance": "[%key:component::generic_hygrostat::config::step::user::data_description::wet_tolerance%]" + } + } + } + }, + "selector": { + "device_class": { + "options": { + "humidifier": "Humidifier", + "dehumidifier": "Dehumidifier" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 631a5b6abb416b..e5eeeb29403ac1 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -6,6 +6,7 @@ FLOWS = { "helper": [ "derivative", + "generic_hygrostat", "generic_thermostat", "group", "integration", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index eef78c212c865a..d3380fdd17f65e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2127,12 +2127,6 @@ "config_flow": true, "iot_class": "local_push" }, - "generic_hygrostat": { - "name": "Generic hygrostat", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_polling" - }, "geniushub": { "name": "Genius Hub", "integration_type": "hub", @@ -7160,6 +7154,11 @@ "config_flow": true, "iot_class": "calculated" }, + "generic_hygrostat": { + "integration_type": "helper", + "config_flow": true, + "iot_class": "local_polling" + }, "generic_thermostat": { "integration_type": "helper", "config_flow": true, @@ -7265,6 +7264,7 @@ "filesize", "garages_amsterdam", "generic", + "generic_hygrostat", "generic_thermostat", "google_travel_time", "group", diff --git a/tests/components/generic_hygrostat/snapshots/test_config_flow.ambr b/tests/components/generic_hygrostat/snapshots/test_config_flow.ambr new file mode 100644 index 00000000000000..3527596c9b961f --- /dev/null +++ b/tests/components/generic_hygrostat/snapshots/test_config_flow.ambr @@ -0,0 +1,66 @@ +# serializer version: 1 +# name: test_config_flow[create] + FlowResultSnapshot({ + 'result': ConfigEntrySnapshot({ + 'title': 'My hygrostat', + }), + 'title': 'My hygrostat', + 'type': , + }) +# --- +# name: test_config_flow[init] + FlowResultSnapshot({ + 'type': , + }) +# --- +# name: test_options[create_entry] + FlowResultSnapshot({ + 'result': True, + 'type': , + }) +# --- +# name: test_options[dehumidifier] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'action': , + 'current_humidity': 10.0, + 'device_class': 'dehumidifier', + 'friendly_name': 'My hygrostat', + 'humidity': 100, + 'max_humidity': 100, + 'min_humidity': 0, + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.my_hygrostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_options[humidifier] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'action': , + 'current_humidity': 10.0, + 'device_class': 'humidifier', + 'friendly_name': 'My hygrostat', + 'humidity': 100, + 'max_humidity': 100, + 'min_humidity': 0, + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.my_hygrostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_options[init] + FlowResultSnapshot({ + 'type': , + }) +# --- diff --git a/tests/components/generic_hygrostat/test_config_flow.py b/tests/components/generic_hygrostat/test_config_flow.py new file mode 100644 index 00000000000000..49572e296e45f8 --- /dev/null +++ b/tests/components/generic_hygrostat/test_config_flow.py @@ -0,0 +1,106 @@ +"""Test the generic hygrostat config flow.""" + +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.generic_hygrostat import ( + CONF_DEVICE_CLASS, + CONF_DRY_TOLERANCE, + CONF_HUMIDIFIER, + CONF_NAME, + CONF_SENSOR, + CONF_WET_TOLERANCE, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +SNAPSHOT_FLOW_PROPS = props("type", "title", "result", "error") + + +async def test_config_flow(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: + """Test the config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result == snapshot(name="init", include=SNAPSHOT_FLOW_PROPS) + + with patch( + "homeassistant.components.generic_hygrostat.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "My hygrostat", + CONF_DRY_TOLERANCE: 2, + CONF_WET_TOLERANCE: 4, + CONF_HUMIDIFIER: "switch.run", + CONF_SENSOR: "sensor.humidity", + CONF_DEVICE_CLASS: "dehumidifier", + }, + ) + await hass.async_block_till_done() + + assert result == snapshot(name="create", include=SNAPSHOT_FLOW_PROPS) + assert len(mock_setup_entry.mock_calls) == 1 + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + assert config_entry.data == {} + assert config_entry.title == "My hygrostat" + + +async def test_options(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: + """Test reconfiguring.""" + + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_DEVICE_CLASS: "dehumidifier", + CONF_DRY_TOLERANCE: 2.0, + CONF_HUMIDIFIER: "switch.run", + CONF_NAME: "My hygrostat", + CONF_SENSOR: "sensor.humidity", + CONF_WET_TOLERANCE: 4.0, + }, + title="My hygrostat", + ) + config_entry.add_to_hass(hass) + + # set some initial values + hass.states.async_set( + "sensor.humidity", + "10", + {"unit_of_measurement": "%", "device_class": "humidity"}, + ) + hass.states.async_set("switch.run", "on") + + # check that it is setup + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get("humidifier.my_hygrostat") == snapshot(name="dehumidifier") + + # switch to humidifier + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result == snapshot(name="init", include=SNAPSHOT_FLOW_PROPS) + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_DRY_TOLERANCE: 2, + CONF_WET_TOLERANCE: 4, + CONF_HUMIDIFIER: "switch.run", + CONF_SENSOR: "sensor.humidity", + CONF_DEVICE_CLASS: "humidifier", + }, + ) + assert result == snapshot(name="create_entry", include=SNAPSHOT_FLOW_PROPS) + + # Check config entry is reloaded with new options + await hass.async_block_till_done() + assert hass.states.get("humidifier.my_hygrostat") == snapshot(name="humidifier")