From e9948100a7cbc8020fb78bda48155529dafa74e1 Mon Sep 17 00:00:00 2001 From: Shulyaka Date: Tue, 13 Jul 2021 15:25:29 +0400 Subject: [PATCH] Add generic hygrostat integration (#36759) * generic_hygrostat: new integration Co-authored-by: J. Nick Koston Co-authored-by: jan Iversen --- .coveragerc | 1 + CODEOWNERS | 1 + .../components/generic_hygrostat/__init__.py | 78 + .../generic_hygrostat/humidifier.py | 465 +++++ .../generic_hygrostat/manifest.json | 8 + .../generic_hygrostat/services.yaml | 0 .../components/generic_hygrostat/__init__.py | 1 + .../generic_hygrostat/test_humidifier.py | 1657 +++++++++++++++++ 8 files changed, 2211 insertions(+) create mode 100644 homeassistant/components/generic_hygrostat/__init__.py create mode 100644 homeassistant/components/generic_hygrostat/humidifier.py create mode 100644 homeassistant/components/generic_hygrostat/manifest.json create mode 100644 homeassistant/components/generic_hygrostat/services.yaml create mode 100644 tests/components/generic_hygrostat/__init__.py create mode 100644 tests/components/generic_hygrostat/test_humidifier.py diff --git a/.coveragerc b/.coveragerc index 8db2deee2e2..685642612fc 100644 --- a/.coveragerc +++ b/.coveragerc @@ -360,6 +360,7 @@ omit = homeassistant/components/garmin_connect/alarm_util.py homeassistant/components/gc100/* homeassistant/components/geniushub/* + homeassistant/components/generic_hygrostat/* homeassistant/components/github/sensor.py homeassistant/components/gitlab_ci/sensor.py homeassistant/components/gitter/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index ff7dcab2cb3..c269ad64888 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -177,6 +177,7 @@ homeassistant/components/frontend/* @home-assistant/frontend homeassistant/components/garages_amsterdam/* @klaasnicolaas homeassistant/components/garmin_connect/* @cyberjunky homeassistant/components/gdacs/* @exxamalte +homeassistant/components/generic_hygrostat/* @Shulyaka homeassistant/components/geniushub/* @zxdavb homeassistant/components/geo_json_events/* @exxamalte homeassistant/components/geo_rss_events/* @exxamalte diff --git a/homeassistant/components/generic_hygrostat/__init__.py b/homeassistant/components/generic_hygrostat/__init__.py new file mode 100644 index 00000000000..568863adb73 --- /dev/null +++ b/homeassistant/components/generic_hygrostat/__init__.py @@ -0,0 +1,78 @@ +"""The generic_hygrostat component.""" + +import logging + +import voluptuous as vol + +from homeassistant.components.humidifier.const import ( + DEVICE_CLASS_DEHUMIDIFIER, + DEVICE_CLASS_HUMIDIFIER, +) +from homeassistant.const import CONF_NAME +from homeassistant.helpers import config_validation as cv, discovery + +DOMAIN = "generic_hygrostat" + +_LOGGER = logging.getLogger(__name__) + +CONF_HUMIDIFIER = "humidifier" +CONF_SENSOR = "target_sensor" +CONF_MIN_HUMIDITY = "min_humidity" +CONF_MAX_HUMIDITY = "max_humidity" +CONF_TARGET_HUMIDITY = "target_humidity" +CONF_DEVICE_CLASS = "device_class" +CONF_MIN_DUR = "min_cycle_duration" +CONF_DRY_TOLERANCE = "dry_tolerance" +CONF_WET_TOLERANCE = "wet_tolerance" +CONF_KEEP_ALIVE = "keep_alive" +CONF_INITIAL_STATE = "initial_state" +CONF_AWAY_HUMIDITY = "away_humidity" +CONF_AWAY_FIXED = "away_fixed" +CONF_STALE_DURATION = "sensor_stale_duration" + +DEFAULT_TOLERANCE = 3 +DEFAULT_NAME = "Generic Hygrostat" + +HYGROSTAT_SCHEMA = vol.Schema( + { + vol.Required(CONF_HUMIDIFIER): cv.entity_id, + vol.Required(CONF_SENSOR): cv.entity_id, + vol.Optional(CONF_DEVICE_CLASS): vol.In( + [DEVICE_CLASS_HUMIDIFIER, DEVICE_CLASS_DEHUMIDIFIER] + ), + vol.Optional(CONF_MAX_HUMIDITY): vol.Coerce(int), + vol.Optional(CONF_MIN_DUR): vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_MIN_HUMIDITY): vol.Coerce(int), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_DRY_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(float), + vol.Optional(CONF_WET_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(float), + vol.Optional(CONF_TARGET_HUMIDITY): vol.Coerce(int), + vol.Optional(CONF_KEEP_ALIVE): vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_INITIAL_STATE): cv.boolean, + vol.Optional(CONF_AWAY_HUMIDITY): vol.Coerce(int), + vol.Optional(CONF_AWAY_FIXED): cv.boolean, + vol.Optional(CONF_STALE_DURATION): vol.All( + cv.time_period, cv.positive_timedelta + ), + } +) + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.All(cv.ensure_list, [HYGROSTAT_SCHEMA])}, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the Generic Hygrostat component.""" + if DOMAIN not in config: + return True + + for hygrostat_conf in config[DOMAIN]: + hass.async_create_task( + discovery.async_load_platform( + hass, "humidifier", DOMAIN, hygrostat_conf, config + ) + ) + + return True diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py new file mode 100644 index 00000000000..ee1c8f65d1a --- /dev/null +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -0,0 +1,465 @@ +"""Adds support for generic hygrostat units.""" +import asyncio +import logging + +from homeassistant.components.humidifier import PLATFORM_SCHEMA, HumidifierEntity +from homeassistant.components.humidifier.const import ( + ATTR_HUMIDITY, + DEVICE_CLASS_DEHUMIDIFIER, + DEVICE_CLASS_HUMIDIFIER, + MODE_AWAY, + MODE_NORMAL, + SUPPORT_MODES, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_MODE, + CONF_NAME, + EVENT_HOMEASSISTANT_START, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import DOMAIN as HA_DOMAIN, callback +from homeassistant.helpers import condition +from homeassistant.helpers.event import ( + async_track_state_change, + async_track_time_interval, +) +from homeassistant.helpers.restore_state import RestoreEntity + +from . import ( + CONF_AWAY_FIXED, + CONF_AWAY_HUMIDITY, + CONF_DEVICE_CLASS, + CONF_DRY_TOLERANCE, + CONF_HUMIDIFIER, + CONF_INITIAL_STATE, + CONF_KEEP_ALIVE, + CONF_MAX_HUMIDITY, + CONF_MIN_DUR, + CONF_MIN_HUMIDITY, + CONF_SENSOR, + CONF_STALE_DURATION, + CONF_TARGET_HUMIDITY, + CONF_WET_TOLERANCE, + HYGROSTAT_SCHEMA, +) + +_LOGGER = logging.getLogger(__name__) + +ATTR_SAVED_HUMIDITY = "saved_humidity" + +SUPPORT_FLAGS = 0 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(HYGROSTAT_SCHEMA.schema) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the generic hygrostat platform.""" + if discovery_info: + config = discovery_info + name = config[CONF_NAME] + switch_entity_id = config[CONF_HUMIDIFIER] + sensor_entity_id = config[CONF_SENSOR] + min_humidity = config.get(CONF_MIN_HUMIDITY) + max_humidity = config.get(CONF_MAX_HUMIDITY) + target_humidity = config.get(CONF_TARGET_HUMIDITY) + device_class = config.get(CONF_DEVICE_CLASS) + min_cycle_duration = config.get(CONF_MIN_DUR) + sensor_stale_duration = config.get(CONF_STALE_DURATION) + dry_tolerance = config[CONF_DRY_TOLERANCE] + wet_tolerance = config[CONF_WET_TOLERANCE] + keep_alive = config.get(CONF_KEEP_ALIVE) + initial_state = config.get(CONF_INITIAL_STATE) + away_humidity = config.get(CONF_AWAY_HUMIDITY) + away_fixed = config.get(CONF_AWAY_FIXED) + + async_add_entities( + [ + GenericHygrostat( + name, + switch_entity_id, + sensor_entity_id, + min_humidity, + max_humidity, + target_humidity, + device_class, + min_cycle_duration, + dry_tolerance, + wet_tolerance, + keep_alive, + initial_state, + away_humidity, + away_fixed, + sensor_stale_duration, + ) + ] + ) + + +class GenericHygrostat(HumidifierEntity, RestoreEntity): + """Representation of a Generic Hygrostat device.""" + + def __init__( + self, + name, + switch_entity_id, + sensor_entity_id, + min_humidity, + max_humidity, + target_humidity, + device_class, + min_cycle_duration, + dry_tolerance, + wet_tolerance, + keep_alive, + initial_state, + away_humidity, + away_fixed, + sensor_stale_duration, + ): + """Initialize the hygrostat.""" + self._name = name + self._switch_entity_id = switch_entity_id + self._sensor_entity_id = sensor_entity_id + self._device_class = device_class + self._min_cycle_duration = min_cycle_duration + self._dry_tolerance = dry_tolerance + self._wet_tolerance = wet_tolerance + self._keep_alive = keep_alive + self._state = initial_state + self._saved_target_humidity = away_humidity or target_humidity + self._active = False + self._cur_humidity = None + self._humidity_lock = asyncio.Lock() + self._min_humidity = min_humidity + self._max_humidity = max_humidity + self._target_humidity = target_humidity + self._support_flags = SUPPORT_FLAGS + if away_humidity: + self._support_flags = SUPPORT_FLAGS | SUPPORT_MODES + self._away_humidity = away_humidity + self._away_fixed = away_fixed + self._sensor_stale_duration = sensor_stale_duration + self._remove_stale_tracking = None + self._is_away = False + if not self._device_class: + self._device_class = DEVICE_CLASS_HUMIDIFIER + + async def async_added_to_hass(self): + """Run when entity about to be added.""" + await super().async_added_to_hass() + + # Add listener + async_track_state_change( + self.hass, self._sensor_entity_id, self._async_sensor_changed + ) + async_track_state_change( + self.hass, self._switch_entity_id, self._async_switch_changed + ) + + if self._keep_alive: + async_track_time_interval(self.hass, self._async_operate, self._keep_alive) + + @callback + async def _async_startup(event): + """Init on startup.""" + sensor_state = self.hass.states.get(self._sensor_entity_id) + await self._async_sensor_changed(self._sensor_entity_id, None, sensor_state) + + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_startup) + + old_state = await self.async_get_last_state() + if old_state is not None: + if old_state.attributes.get(ATTR_MODE) == MODE_AWAY: + self._is_away = True + self._saved_target_humidity = self._target_humidity + self._target_humidity = self._away_humidity or self._target_humidity + if old_state.attributes.get(ATTR_HUMIDITY): + self._target_humidity = int(old_state.attributes[ATTR_HUMIDITY]) + if old_state.attributes.get(ATTR_SAVED_HUMIDITY): + self._saved_target_humidity = int( + old_state.attributes[ATTR_SAVED_HUMIDITY] + ) + if old_state.state: + self._state = old_state.state == STATE_ON + if self._target_humidity is None: + if self._device_class == DEVICE_CLASS_HUMIDIFIER: + self._target_humidity = self.min_humidity + else: + self._target_humidity = self.max_humidity + _LOGGER.warning( + "No previously saved humidity, setting to %s", self._target_humidity + ) + if self._state is None: + self._state = False + + await _async_startup(None) # init the sensor + + @property + def available(self): + """Return True if entity is available.""" + return self._active + + @property + def state_attributes(self): + """Return the optional state attributes.""" + data = super().state_attributes + + if self._saved_target_humidity: + data[ATTR_SAVED_HUMIDITY] = self._saved_target_humidity + + return data + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def name(self): + """Return the name of the hygrostat.""" + return self._name + + @property + def is_on(self): + """Return true if the hygrostat is on.""" + return self._state + + @property + def target_humidity(self): + """Return the humidity we try to reach.""" + return self._target_humidity + + @property + def mode(self): + """Return the current mode.""" + if self._away_humidity is None: + return None + if self._is_away: + return MODE_AWAY + return MODE_NORMAL + + @property + def available_modes(self): + """Return a list of available modes.""" + if self._away_humidity: + return [MODE_NORMAL, MODE_AWAY] + return None + + @property + def device_class(self): + """Return the device class of the humidifier.""" + return self._device_class + + async def async_turn_on(self, **kwargs): + """Turn hygrostat on.""" + if not self._active: + return + self._state = True + await self._async_operate(force=True) + await self.async_update_ha_state() + + async def async_turn_off(self, **kwargs): + """Turn hygrostat off.""" + if not self._active: + return + self._state = False + if self._is_device_active: + await self._async_device_turn_off() + await self.async_update_ha_state() + + async def async_set_humidity(self, humidity: int): + """Set new target humidity.""" + if humidity is None: + return + + if self._is_away and self._away_fixed: + self._saved_target_humidity = humidity + await self.async_update_ha_state() + return + + self._target_humidity = humidity + await self._async_operate(force=True) + await self.async_update_ha_state() + + @property + def min_humidity(self): + """Return the minimum humidity.""" + if self._min_humidity: + return self._min_humidity + + # get default humidity from super class + return super().min_humidity + + @property + def max_humidity(self): + """Return the maximum humidity.""" + if self._max_humidity: + return self._max_humidity + + # Get default humidity from super class + return super().max_humidity + + @callback + async def _async_sensor_changed(self, entity_id, old_state, new_state): + """Handle ambient humidity changes.""" + if new_state is None: + return + + if self._sensor_stale_duration: + if self._remove_stale_tracking: + self._remove_stale_tracking() + self._remove_stale_tracking = async_track_time_interval( + self.hass, + self._async_sensor_not_responding, + self._sensor_stale_duration, + ) + + await self._async_update_humidity(new_state.state) + await self._async_operate() + await self.async_update_ha_state() + + @callback + async def _async_sensor_not_responding(self, now=None): + """Handle sensor stale event.""" + + _LOGGER.debug( + "Sensor has not been updated for %s", + now - self.hass.states.get(self._sensor_entity_id).last_updated, + ) + _LOGGER.warning("Sensor is stalled, call the emergency stop") + await self._async_update_humidity("Stalled") + + @callback + def _async_switch_changed(self, entity_id, old_state, new_state): + """Handle humidifier switch state changes.""" + if new_state is None: + return + self.async_schedule_update_ha_state() + + async def _async_update_humidity(self, humidity): + """Update hygrostat with latest state from sensor.""" + try: + self._cur_humidity = float(humidity) + except ValueError as ex: + _LOGGER.warning("Unable to update from sensor: %s", ex) + self._cur_humidity = None + self._active = False + if self._is_device_active: + await self._async_device_turn_off() + + async def _async_operate(self, time=None, force=False): + """Check if we need to turn humidifying on or off.""" + async with self._humidity_lock: + if not self._active and None not in ( + self._cur_humidity, + self._target_humidity, + ): + self._active = True + force = True + _LOGGER.info( + "Obtained current and target humidity. " + "Generic hygrostat active. %s, %s", + self._cur_humidity, + self._target_humidity, + ) + + if not self._active or not self._state: + return + + if not force and time is None: + # If the `force` argument is True, we + # ignore `min_cycle_duration`. + # If the `time` argument is not none, we were invoked for + # keep-alive purposes, and `min_cycle_duration` is irrelevant. + if self._min_cycle_duration: + if self._is_device_active: + current_state = STATE_ON + else: + current_state = STATE_OFF + long_enough = condition.state( + self.hass, + self._switch_entity_id, + current_state, + self._min_cycle_duration, + ) + if not long_enough: + return + + if force: + # Ignore the tolerance when switched on manually + dry_tolerance = 0 + wet_tolerance = 0 + else: + dry_tolerance = self._dry_tolerance + wet_tolerance = self._wet_tolerance + + too_dry = self._target_humidity - self._cur_humidity >= dry_tolerance + too_wet = self._cur_humidity - self._target_humidity >= wet_tolerance + if self._is_device_active: + if (self._device_class == DEVICE_CLASS_HUMIDIFIER and too_wet) or ( + self._device_class == DEVICE_CLASS_DEHUMIDIFIER and too_dry + ): + _LOGGER.info("Turning off humidifier %s", self._switch_entity_id) + await self._async_device_turn_off() + elif time is not None: + # The time argument is passed only in keep-alive case + await self._async_device_turn_on() + else: + if (self._device_class == DEVICE_CLASS_HUMIDIFIER and too_dry) or ( + self._device_class == DEVICE_CLASS_DEHUMIDIFIER and too_wet + ): + _LOGGER.info("Turning on humidifier %s", self._switch_entity_id) + await self._async_device_turn_on() + elif time is not None: + # The time argument is passed only in keep-alive case + await self._async_device_turn_off() + + @property + def _is_device_active(self): + """If the toggleable device is currently active.""" + return self.hass.states.is_state(self._switch_entity_id, STATE_ON) + + @property + def supported_features(self): + """Return the list of supported features.""" + return self._support_flags + + async def _async_device_turn_on(self): + """Turn humidifier toggleable device on.""" + data = {ATTR_ENTITY_ID: self._switch_entity_id} + await self.hass.services.async_call(HA_DOMAIN, SERVICE_TURN_ON, data) + + async def _async_device_turn_off(self): + """Turn humidifier toggleable device off.""" + data = {ATTR_ENTITY_ID: self._switch_entity_id} + await self.hass.services.async_call(HA_DOMAIN, SERVICE_TURN_OFF, data) + + async def async_set_mode(self, mode: str): + """Set new mode. + + This method must be run in the event loop and returns a coroutine. + """ + if self._away_humidity is None: + return + if mode == MODE_AWAY and not self._is_away: + self._is_away = True + if not self._saved_target_humidity: + self._saved_target_humidity = self._away_humidity + self._saved_target_humidity, self._target_humidity = ( + self._target_humidity, + self._saved_target_humidity, + ) + await self._async_operate(force=True) + elif mode == MODE_NORMAL and self._is_away: + self._is_away = False + self._saved_target_humidity, self._target_humidity = ( + self._target_humidity, + self._saved_target_humidity, + ) + await self._async_operate(force=True) + + await self.async_update_ha_state() diff --git a/homeassistant/components/generic_hygrostat/manifest.json b/homeassistant/components/generic_hygrostat/manifest.json new file mode 100644 index 00000000000..5874097dc84 --- /dev/null +++ b/homeassistant/components/generic_hygrostat/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "generic_hygrostat", + "name": "Generic hygrostat", + "documentation": "https://www.home-assistant.io/integrations/generic_hygrostat", + "codeowners": ["@Shulyaka"], + "quality_scale": "internal", + "iot_class": "local_polling" +} diff --git a/homeassistant/components/generic_hygrostat/services.yaml b/homeassistant/components/generic_hygrostat/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/components/generic_hygrostat/__init__.py b/tests/components/generic_hygrostat/__init__.py new file mode 100644 index 00000000000..6c3a131276c --- /dev/null +++ b/tests/components/generic_hygrostat/__init__.py @@ -0,0 +1 @@ +"""Tests for the generic_hygrostat component.""" diff --git a/tests/components/generic_hygrostat/test_humidifier.py b/tests/components/generic_hygrostat/test_humidifier.py new file mode 100644 index 00000000000..d0412731c78 --- /dev/null +++ b/tests/components/generic_hygrostat/test_humidifier.py @@ -0,0 +1,1657 @@ +"""The tests for the generic_hygrostat.""" +import datetime +from unittest.mock import patch + +import pytest +import voluptuous as vol + +from homeassistant.components import input_boolean, switch +from homeassistant.components.humidifier.const import ( + ATTR_HUMIDITY, + DOMAIN, + MODE_AWAY, + MODE_NORMAL, + SERVICE_SET_HUMIDITY, + SERVICE_SET_MODE, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_MODE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) +import homeassistant.core as ha +from homeassistant.core import DOMAIN as HASS_DOMAIN, CoreState, State, callback +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + +from tests.common import ( + assert_setup_component, + async_fire_time_changed, + mock_restore_cache, +) + +ENTITY = "humidifier.test" +ENT_SENSOR = "sensor.test" +ENT_SWITCH = "switch.test" +ATTR_SAVED_HUMIDITY = "saved_humidity" +MIN_HUMIDITY = 20 +MAX_HUMIDITY = 65 +TARGET_HUMIDITY = 42 + + +async def test_setup_missing_conf(hass): + """Test set up humidity_control with missing config values.""" + config = { + "platform": "generic_hygrostat", + "name": "test", + "target_sensor": ENT_SENSOR, + } + with assert_setup_component(0): + await async_setup_component(hass, "humidifier", {"humidifier": config}) + await hass.async_block_till_done() + + +async def test_valid_conf(hass): + """Test set up generic_hygrostat with valid config values.""" + assert await async_setup_component( + hass, + "humidifier", + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + } + }, + ) + await hass.async_block_till_done() + + +@pytest.fixture +async def setup_comp_1(hass): + """Initialize components.""" + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + +async def test_humidifier_input_boolean(hass, setup_comp_1): + """Test humidifier switching input_boolean.""" + humidifier_switch = "input_boolean.test" + assert await async_setup_component( + hass, input_boolean.DOMAIN, {"input_boolean": {"test": None}} + ) + await hass.async_block_till_done() + + assert await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "humidifier": humidifier_switch, + "target_sensor": ENT_SENSOR, + "initial_state": True, + } + }, + ) + await hass.async_block_till_done() + + assert hass.states.get(humidifier_switch).state == STATE_OFF + + _setup_sensor(hass, 23) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 32}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.get(humidifier_switch).state == STATE_ON + + +async def test_humidifier_switch(hass, setup_comp_1, enable_custom_integrations): + """Test humidifier switching test switch.""" + platform = getattr(hass.components, "test.switch") + platform.init() + switch_1 = platform.ENTITIES[1] + assert await async_setup_component( + hass, switch.DOMAIN, {"switch": {"platform": "test"}} + ) + await hass.async_block_till_done() + humidifier_switch = switch_1.entity_id + + assert await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "humidifier": humidifier_switch, + "target_sensor": ENT_SENSOR, + "initial_state": True, + } + }, + ) + + await hass.async_block_till_done() + assert hass.states.get(humidifier_switch).state == STATE_OFF + + _setup_sensor(hass, 23) + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 32}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.get(humidifier_switch).state == STATE_ON + + +def _setup_sensor(hass, humidity): + """Set up the test sensor.""" + hass.states.async_set(ENT_SENSOR, humidity) + + +@pytest.fixture +async def setup_comp_0(hass): + """Initialize components.""" + _setup_sensor(hass, 45) + hass.states.async_set(ENT_SWITCH, STATE_OFF) + await hass.async_block_till_done() + assert await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "dry_tolerance": 2, + "wet_tolerance": 4, + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "device_class": "dehumidifier", + "away_humidity": 35, + "initial_state": True, + } + }, + ) + await hass.async_block_till_done() + + +@pytest.fixture +async def setup_comp_2(hass): + """Initialize components.""" + _setup_sensor(hass, 45) + hass.states.async_set(ENT_SWITCH, STATE_OFF) + await hass.async_block_till_done() + assert await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "dry_tolerance": 2, + "wet_tolerance": 4, + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "away_humidity": 35, + "initial_state": True, + } + }, + ) + await hass.async_block_till_done() + + +async def test_unavailable_state(hass): + """Test the setting of defaults to unknown.""" + await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "dry_tolerance": 2, + "wet_tolerance": 4, + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "away_humidity": 35, + } + }, + ) + # The target sensor is unavailable, that should propagate to the humidifier entity: + await hass.async_block_till_done() + assert hass.states.get(ENTITY).state == STATE_UNAVAILABLE + + # Sensor online + _setup_sensor(hass, 30) + await hass.async_block_till_done() + assert hass.states.get(ENTITY).state == STATE_OFF + + +async def test_setup_defaults_to_unknown(hass): + """Test the setting of defaults to unknown.""" + await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "dry_tolerance": 2, + "wet_tolerance": 4, + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "away_humidity": 35, + } + }, + ) + await hass.async_block_till_done() + assert hass.states.get(ENTITY).state == STATE_UNAVAILABLE + + +async def test_default_setup_params(hass, setup_comp_2): + """Test the setup with default parameters.""" + state = hass.states.get(ENTITY) + assert state.attributes.get("min_humidity") == 0 + assert state.attributes.get("max_humidity") == 100 + assert state.attributes.get("humidity") == 0 + + +async def test_default_setup_params_dehumidifier(hass, setup_comp_0): + """Test the setup with default parameters for dehumidifier.""" + state = hass.states.get(ENTITY) + assert state.attributes.get("min_humidity") == 0 + assert state.attributes.get("max_humidity") == 100 + assert state.attributes.get("humidity") == 100 + + +async def test_get_modes(hass, setup_comp_2): + """Test that the attributes returns the correct modes.""" + state = hass.states.get(ENTITY) + modes = state.attributes.get("available_modes") + assert modes == [MODE_NORMAL, MODE_AWAY] + + +async def test_set_target_humidity(hass, setup_comp_2): + """Test the setting of the target humidity.""" + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 40}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get(ENTITY) + assert state.attributes.get("humidity") == 40 + with pytest.raises(vol.Invalid): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: None}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get(ENTITY) + assert state.attributes.get("humidity") == 40 + + +async def test_set_away_mode(hass, setup_comp_2): + """Test the setting away mode.""" + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 44}, + blocking=True, + ) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: ENTITY, ATTR_MODE: MODE_AWAY}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get(ENTITY) + assert state.attributes.get("humidity") == 35 + + +async def test_set_away_mode_and_restore_prev_humidity(hass, setup_comp_2): + """Test the setting and removing away mode. + + Verify original humidity is restored. + """ + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 44}, + blocking=True, + ) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: ENTITY, ATTR_MODE: MODE_AWAY}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get(ENTITY) + assert state.attributes.get("humidity") == 35 + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: ENTITY, ATTR_MODE: MODE_NORMAL}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get(ENTITY) + assert state.attributes.get("humidity") == 44 + + +async def test_set_away_mode_twice_and_restore_prev_humidity(hass, setup_comp_2): + """Test the setting away mode twice in a row. + + Verify original humidity is restored. + """ + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 44}, + blocking=True, + ) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: ENTITY, ATTR_MODE: MODE_AWAY}, + blocking=True, + ) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: ENTITY, ATTR_MODE: MODE_AWAY}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get(ENTITY) + assert state.attributes.get("humidity") == 35 + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: ENTITY, ATTR_MODE: MODE_NORMAL}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get(ENTITY) + assert state.attributes.get("humidity") == 44 + + +async def test_sensor_bad_value(hass, setup_comp_2): + """Test sensor that have None as state.""" + state = hass.states.get(ENTITY) + humidity = state.attributes.get("current_humidity") + + _setup_sensor(hass, None) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY) + assert humidity == state.attributes.get("current_humidity") + + +async def test_set_target_humidity_humidifier_on(hass, setup_comp_2): + """Test if target humidity turn humidifier on.""" + calls = await _setup_switch(hass, False) + _setup_sensor(hass, 36) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 45}, + blocking=True, + ) + await hass.async_block_till_done() + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_ON + assert call.data["entity_id"] == ENT_SWITCH + + +async def test_set_target_humidity_humidifier_off(hass, setup_comp_2): + """Test if target humidity turn humidifier off.""" + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 45) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 36}, + blocking=True, + ) + await hass.async_block_till_done() + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_OFF + assert call.data["entity_id"] == ENT_SWITCH + + +async def test_humidity_change_humidifier_on_within_tolerance(hass, setup_comp_2): + """Test if humidity change doesn't turn on within tolerance.""" + calls = await _setup_switch(hass, False) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 44}, + blocking=True, + ) + await hass.async_block_till_done() + _setup_sensor(hass, 43) + await hass.async_block_till_done() + assert len(calls) == 0 + + +async def test_humidity_change_humidifier_on_outside_tolerance(hass, setup_comp_2): + """Test if humidity change turn humidifier on outside dry tolerance.""" + calls = await _setup_switch(hass, False) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 44}, + blocking=True, + ) + await hass.async_block_till_done() + _setup_sensor(hass, 42) + await hass.async_block_till_done() + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_ON + assert call.data["entity_id"] == ENT_SWITCH + + +async def test_humidity_change_humidifier_off_within_tolerance(hass, setup_comp_2): + """Test if humidity change doesn't turn off within tolerance.""" + calls = await _setup_switch(hass, True) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 46}, + blocking=True, + ) + await hass.async_block_till_done() + _setup_sensor(hass, 48) + await hass.async_block_till_done() + assert len(calls) == 0 + + +async def test_humidity_change_humidifier_off_outside_tolerance(hass, setup_comp_2): + """Test if humidity change turn humidifier off outside wet tolerance.""" + calls = await _setup_switch(hass, True) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 46}, + blocking=True, + ) + await hass.async_block_till_done() + _setup_sensor(hass, 50) + await hass.async_block_till_done() + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_OFF + assert call.data["entity_id"] == ENT_SWITCH + + +async def test_operation_mode_humidify(hass, setup_comp_2): + """Test change mode from OFF to HUMIDIFY. + + Switch turns on when humidity below setpoint and mode changes. + """ + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY}, + blocking=True, + ) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 45}, + blocking=True, + ) + await hass.async_block_till_done() + _setup_sensor(hass, 40) + await hass.async_block_till_done() + calls = await _setup_switch(hass, False) + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY}, + blocking=True, + ) + await hass.async_block_till_done() + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_ON + assert call.data["entity_id"] == ENT_SWITCH + + +async def _setup_switch(hass, is_on): + """Set up the test switch.""" + hass.states.async_set(ENT_SWITCH, STATE_ON if is_on else STATE_OFF) + calls = [] + + @callback + def log_call(call): + """Log service calls.""" + calls.append(call) + + hass.services.async_register(ha.DOMAIN, SERVICE_TURN_ON, log_call) + hass.services.async_register(ha.DOMAIN, SERVICE_TURN_OFF, log_call) + + await hass.async_block_till_done() + return calls + + +@pytest.fixture +async def setup_comp_3(hass): + """Initialize components.""" + assert await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "dry_tolerance": 2, + "wet_tolerance": 4, + "away_humidity": 30, + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "device_class": "dehumidifier", + "initial_state": True, + "target_humidity": 40, + } + }, + ) + await hass.async_block_till_done() + + +async def test_set_target_humidity_dry_off(hass, setup_comp_3): + """Test if target humidity turn dry off.""" + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 50) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 55}, + blocking=True, + ) + await hass.async_block_till_done() + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_OFF + assert call.data["entity_id"] == ENT_SWITCH + + +async def test_turn_away_mode_on_drying(hass, setup_comp_3): + """Test the setting away mode when drying.""" + await _setup_switch(hass, True) + _setup_sensor(hass, 50) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 34}, + blocking=True, + ) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: ENTITY, ATTR_MODE: MODE_AWAY}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get(ENTITY) + assert state.attributes.get("humidity") == 30 + + +async def test_operation_mode_dry(hass, setup_comp_3): + """Test change mode from OFF to DRY. + + Switch turns on when humidity below setpoint and state changes. + """ + calls = await _setup_switch(hass, False) + _setup_sensor(hass, 30) + await hass.async_block_till_done() + assert len(calls) == 0 + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY}, + blocking=True, + ) + await hass.async_block_till_done() + _setup_sensor(hass, 45) + await hass.async_block_till_done() + assert len(calls) == 0 + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY}, + blocking=True, + ) + await hass.async_block_till_done() + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_ON + assert call.data["entity_id"] == ENT_SWITCH + + +async def test_set_target_humidity_dry_on(hass, setup_comp_3): + """Test if target humidity turn dry on.""" + calls = await _setup_switch(hass, False) + _setup_sensor(hass, 45) + await hass.async_block_till_done() + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_ON + assert call.data["entity_id"] == ENT_SWITCH + + +async def test_init_ignores_tolerance(hass, setup_comp_3): + """Test if tolerance is ignored on initialization.""" + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 39) + await hass.async_block_till_done() + assert 1 == len(calls) + call = calls[0] + assert HASS_DOMAIN == call.domain + assert SERVICE_TURN_OFF == call.service + assert ENT_SWITCH == call.data["entity_id"] + + +async def test_humidity_change_dry_off_within_tolerance(hass, setup_comp_3): + """Test if humidity change doesn't turn dry off within tolerance.""" + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 45) + _setup_sensor(hass, 39) + await hass.async_block_till_done() + assert len(calls) == 0 + + +async def test_set_humidity_change_dry_off_outside_tolerance(hass, setup_comp_3): + """Test if humidity change turn dry off.""" + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 36) + await hass.async_block_till_done() + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_OFF + assert call.data["entity_id"] == ENT_SWITCH + + +async def test_humidity_change_dry_on_within_tolerance(hass, setup_comp_3): + """Test if humidity change doesn't turn dry on within tolerance.""" + calls = await _setup_switch(hass, False) + _setup_sensor(hass, 37) + _setup_sensor(hass, 41) + await hass.async_block_till_done() + assert len(calls) == 0 + + +async def test_humidity_change_dry_on_outside_tolerance(hass, setup_comp_3): + """Test if humidity change turn dry on.""" + calls = await _setup_switch(hass, False) + _setup_sensor(hass, 45) + await hass.async_block_till_done() + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_ON + assert call.data["entity_id"] == ENT_SWITCH + + +async def test_running_when_operating_mode_is_off_2(hass, setup_comp_3): + """Test that the switch turns off when enabled is set False.""" + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 45) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY}, + blocking=True, + ) + await hass.async_block_till_done() + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_OFF + assert call.data["entity_id"] == ENT_SWITCH + + +async def test_no_state_change_when_operation_mode_off_2(hass, setup_comp_3): + """Test that the switch doesn't turn on when enabled is False.""" + calls = await _setup_switch(hass, False) + _setup_sensor(hass, 30) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY}, + blocking=True, + ) + await hass.async_block_till_done() + _setup_sensor(hass, 45) + await hass.async_block_till_done() + assert len(calls) == 0 + + +@pytest.fixture +async def setup_comp_4(hass): + """Initialize components.""" + assert await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "dry_tolerance": 3, + "wet_tolerance": 3, + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "device_class": "dehumidifier", + "min_cycle_duration": datetime.timedelta(minutes=10), + "initial_state": True, + "target_humidity": 40, + } + }, + ) + await hass.async_block_till_done() + + +async def test_humidity_change_dry_trigger_on_not_long_enough(hass, setup_comp_4): + """Test if humidity change turn dry on.""" + calls = await _setup_switch(hass, False) + _setup_sensor(hass, 35) + await hass.async_block_till_done() + assert len(calls) == 0 + + _setup_sensor(hass, 45) + await hass.async_block_till_done() + assert len(calls) == 0 + + +async def test_humidity_change_dry_trigger_on_long_enough(hass, setup_comp_4): + """Test if humidity change turn dry on.""" + fake_changed = datetime.datetime( + 1918, 11, 11, 11, 11, 11, tzinfo=datetime.timezone.utc + ) + with patch( + "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed + ): + calls = await _setup_switch(hass, False) + _setup_sensor(hass, 35) + await hass.async_block_till_done() + assert len(calls) == 0 + + _setup_sensor(hass, 45) + await hass.async_block_till_done() + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_ON + assert call.data["entity_id"] == ENT_SWITCH + + +async def test_humidity_change_dry_trigger_off_not_long_enough(hass, setup_comp_4): + """Test if humidity change turn dry on.""" + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 45) + await hass.async_block_till_done() + assert len(calls) == 0 + + _setup_sensor(hass, 35) + await hass.async_block_till_done() + assert len(calls) == 0 + + +async def test_humidity_change_dry_trigger_off_long_enough(hass, setup_comp_4): + """Test if humidity change turn dry on.""" + fake_changed = datetime.datetime( + 1918, 11, 11, 11, 11, 11, tzinfo=datetime.timezone.utc + ) + with patch( + "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed + ): + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 45) + await hass.async_block_till_done() + assert len(calls) == 0 + + _setup_sensor(hass, 35) + await hass.async_block_till_done() + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_OFF + assert call.data["entity_id"] == ENT_SWITCH + + +async def test_mode_change_dry_trigger_off_not_long_enough(hass, setup_comp_4): + """Test if mode change turns dry off despite minimum cycle.""" + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 45) + await hass.async_block_till_done() + assert len(calls) == 0 + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY}, + blocking=True, + ) + await hass.async_block_till_done() + assert len(calls) == 1 + call = calls[0] + assert call.domain == "homeassistant" + assert call.service == SERVICE_TURN_OFF + assert call.data["entity_id"] == ENT_SWITCH + + +async def test_mode_change_dry_trigger_on_not_long_enough(hass, setup_comp_4): + """Test if mode change turns dry on despite minimum cycle.""" + calls = await _setup_switch(hass, False) + _setup_sensor(hass, 35) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY}, + blocking=True, + ) + await hass.async_block_till_done() + _setup_sensor(hass, 45) + await hass.async_block_till_done() + assert len(calls) == 0 + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY}, + blocking=True, + ) + await hass.async_block_till_done() + assert len(calls) == 1 + call = calls[0] + assert call.domain == "homeassistant" + assert call.service == SERVICE_TURN_ON + assert call.data["entity_id"] == ENT_SWITCH + + +@pytest.fixture +async def setup_comp_6(hass): + """Initialize components.""" + assert await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "dry_tolerance": 3, + "wet_tolerance": 3, + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "min_cycle_duration": datetime.timedelta(minutes=10), + "initial_state": True, + "target_humidity": 40, + } + }, + ) + await hass.async_block_till_done() + + +async def test_humidity_change_humidifier_trigger_off_not_long_enough( + hass, setup_comp_6 +): + """Test if humidity change doesn't turn humidifier off because of time.""" + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 35) + await hass.async_block_till_done() + assert len(calls) == 0 + + _setup_sensor(hass, 45) + await hass.async_block_till_done() + assert len(calls) == 0 + + +async def test_humidity_change_humidifier_trigger_on_not_long_enough( + hass, setup_comp_6 +): + """Test if humidity change doesn't turn humidifier on because of time.""" + calls = await _setup_switch(hass, False) + _setup_sensor(hass, 45) + await hass.async_block_till_done() + assert len(calls) == 0 + + _setup_sensor(hass, 35) + await hass.async_block_till_done() + assert len(calls) == 0 + + +async def test_humidity_change_humidifier_trigger_on_long_enough(hass, setup_comp_6): + """Test if humidity change turn humidifier on after min cycle.""" + fake_changed = datetime.datetime( + 1918, 11, 11, 11, 11, 11, tzinfo=datetime.timezone.utc + ) + with patch( + "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed + ): + calls = await _setup_switch(hass, False) + _setup_sensor(hass, 45) + await hass.async_block_till_done() + assert len(calls) == 0 + + _setup_sensor(hass, 35) + await hass.async_block_till_done() + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_ON + assert call.data["entity_id"] == ENT_SWITCH + + +async def test_humidity_change_humidifier_trigger_off_long_enough(hass, setup_comp_6): + """Test if humidity change turn humidifier off after min cycle.""" + fake_changed = datetime.datetime( + 1918, 11, 11, 11, 11, 11, tzinfo=datetime.timezone.utc + ) + with patch( + "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed + ): + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 35) + await hass.async_block_till_done() + assert len(calls) == 0 + + _setup_sensor(hass, 45) + await hass.async_block_till_done() + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_OFF + assert call.data["entity_id"] == ENT_SWITCH + + +async def test_mode_change_humidifier_trigger_off_not_long_enough(hass, setup_comp_6): + """Test if mode change turns humidifier off despite minimum cycle.""" + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 35) + await hass.async_block_till_done() + assert len(calls) == 0 + + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY}, + blocking=True, + ) + await hass.async_block_till_done() + assert len(calls) == 1 + call = calls[0] + assert call.domain == "homeassistant" + assert call.service == SERVICE_TURN_OFF + assert call.data["entity_id"] == ENT_SWITCH + + +async def test_mode_change_humidifier_trigger_on_not_long_enough(hass, setup_comp_6): + """Test if mode change turns humidifier on despite minimum cycle.""" + calls = await _setup_switch(hass, False) + _setup_sensor(hass, 45) + await hass.async_block_till_done() + assert len(calls) == 0 + + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY}, + blocking=True, + ) + await hass.async_block_till_done() + assert len(calls) == 0 + + _setup_sensor(hass, 35) + await hass.async_block_till_done() + assert len(calls) == 0 + + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY}, + blocking=True, + ) + await hass.async_block_till_done() + assert len(calls) == 1 + call = calls[0] + assert call.domain == "homeassistant" + assert call.service == SERVICE_TURN_ON + assert call.data["entity_id"] == ENT_SWITCH + + +@pytest.fixture +async def setup_comp_7(hass): + """Initialize components.""" + assert await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "dry_tolerance": 3, + "wet_tolerance": 3, + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "device_class": "dehumidifier", + "min_cycle_duration": datetime.timedelta(minutes=15), + "keep_alive": datetime.timedelta(minutes=10), + "initial_state": True, + "target_humidity": 40, + } + }, + ) + await hass.async_block_till_done() + + +async def test_humidity_change_dry_trigger_on_long_enough_3(hass, setup_comp_7): + """Test if turn on signal is sent at keep-alive intervals.""" + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 45) + await hass.async_block_till_done() + assert len(calls) == 0 + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(minutes=5)) + await hass.async_block_till_done() + assert len(calls) == 0 + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(minutes=10)) + await hass.async_block_till_done() + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_ON + assert call.data["entity_id"] == ENT_SWITCH + + +async def test_humidity_change_dry_trigger_off_long_enough_3(hass, setup_comp_7): + """Test if turn on signal is sent at keep-alive intervals.""" + calls = await _setup_switch(hass, False) + _setup_sensor(hass, 35) + await hass.async_block_till_done() + assert len(calls) == 0 + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(minutes=5)) + await hass.async_block_till_done() + assert len(calls) == 0 + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(minutes=10)) + await hass.async_block_till_done() + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_OFF + assert call.data["entity_id"] == ENT_SWITCH + + +@pytest.fixture +async def setup_comp_8(hass): + """Initialize components.""" + assert await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "dry_tolerance": 3, + "wet_tolerance": 3, + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "min_cycle_duration": datetime.timedelta(minutes=15), + "keep_alive": datetime.timedelta(minutes=10), + "initial_state": True, + "target_humidity": 40, + } + }, + ) + await hass.async_block_till_done() + + +async def test_humidity_change_humidifier_trigger_on_long_enough_2(hass, setup_comp_8): + """Test if turn on signal is sent at keep-alive intervals.""" + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 35) + await hass.async_block_till_done() + assert len(calls) == 0 + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(minutes=5)) + await hass.async_block_till_done() + assert len(calls) == 0 + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(minutes=10)) + await hass.async_block_till_done() + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_ON + assert call.data["entity_id"] == ENT_SWITCH + + +async def test_humidity_change_humidifier_trigger_off_long_enough_2(hass, setup_comp_8): + """Test if turn on signal is sent at keep-alive intervals.""" + calls = await _setup_switch(hass, False) + _setup_sensor(hass, 45) + await hass.async_block_till_done() + assert len(calls) == 0 + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(minutes=5)) + await hass.async_block_till_done() + assert len(calls) == 0 + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(minutes=10)) + await hass.async_block_till_done() + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_OFF + assert call.data["entity_id"] == ENT_SWITCH + + +async def test_float_tolerance_values(hass): + """Test if dehumidifier does not turn on within floating point tolerance.""" + assert await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "dry_tolerance": 0.2, + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "device_class": "dehumidifier", + "initial_state": True, + "target_humidity": 40, + } + }, + ) + await hass.async_block_till_done() + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 45) + _setup_sensor(hass, 39.9) + await hass.async_block_till_done() + assert len(calls) == 0 + + +async def test_float_tolerance_values_2(hass): + """Test if dehumidifier turns off when oudside of floating point tolerance values.""" + assert await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "dry_tolerance": 0.2, + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "device_class": "dehumidifier", + "initial_state": True, + "target_humidity": 40, + } + }, + ) + await hass.async_block_till_done() + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 39.7) + await hass.async_block_till_done() + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == SERVICE_TURN_OFF + assert call.data["entity_id"] == ENT_SWITCH + + +async def test_custom_setup_params(hass): + """Test the setup with custom parameters.""" + _setup_sensor(hass, 45) + await hass.async_block_till_done() + result = await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "min_humidity": MIN_HUMIDITY, + "max_humidity": MAX_HUMIDITY, + "target_humidity": TARGET_HUMIDITY, + } + }, + ) + await hass.async_block_till_done() + assert result + state = hass.states.get(ENTITY) + assert state.attributes.get("min_humidity") == MIN_HUMIDITY + assert state.attributes.get("max_humidity") == MAX_HUMIDITY + assert state.attributes.get("humidity") == TARGET_HUMIDITY + + +async def test_restore_state(hass): + """Ensure states are restored on startup.""" + _setup_sensor(hass, 45) + await hass.async_block_till_done() + mock_restore_cache( + hass, + ( + State( + "humidifier.test_hygrostat", + STATE_OFF, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: "40", ATTR_MODE: MODE_AWAY}, + ), + ), + ) + + hass.state = CoreState.starting + + await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test_hygrostat", + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "away_humidity": 32, + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("humidifier.test_hygrostat") + assert state.attributes[ATTR_HUMIDITY] == 40 + assert state.attributes[ATTR_MODE] == MODE_AWAY + assert state.state == STATE_OFF + + +async def test_restore_state_target_humidity(hass): + """Ensure restore target humidity if available.""" + _setup_sensor(hass, 45) + await hass.async_block_till_done() + mock_restore_cache( + hass, + ( + State( + "humidifier.test_hygrostat", + STATE_OFF, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: "40"}, + ), + ), + ) + + hass.state = CoreState.starting + + await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test_hygrostat", + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "away_humidity": 32, + "target_humidity": 50, + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("humidifier.test_hygrostat") + assert state.attributes[ATTR_HUMIDITY] == 40 + assert state.state == STATE_OFF + + +async def test_restore_state_and_return_to_normal(hass): + """Ensure retain of target humidity for normal mode.""" + _setup_sensor(hass, 55) + await hass.async_block_till_done() + mock_restore_cache( + hass, + ( + State( + "humidifier.test_hygrostat", + STATE_OFF, + { + ATTR_ENTITY_ID: ENTITY, + ATTR_HUMIDITY: "40", + ATTR_MODE: MODE_AWAY, + ATTR_SAVED_HUMIDITY: "50", + }, + ), + ), + ) + + hass.state = CoreState.starting + + await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test_hygrostat", + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "away_humidity": 32, + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("humidifier.test_hygrostat") + assert state.attributes[ATTR_HUMIDITY] == 40 + assert state.attributes[ATTR_SAVED_HUMIDITY] == 50 + assert state.attributes[ATTR_MODE] == MODE_AWAY + assert state.state == STATE_OFF + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: "humidifier.test_hygrostat", ATTR_MODE: MODE_NORMAL}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("humidifier.test_hygrostat") + assert state.attributes[ATTR_HUMIDITY] == 50 + assert state.attributes[ATTR_MODE] == MODE_NORMAL + assert state.state == STATE_OFF + + +async def test_no_restore_state(hass): + """Ensure states are restored on startup if they exist. + + Allows for graceful reboot. + """ + _setup_sensor(hass, 45) + await hass.async_block_till_done() + mock_restore_cache( + hass, + ( + State( + "humidifier.test_hygrostat", + STATE_OFF, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: "40", ATTR_MODE: MODE_AWAY}, + ), + ), + ) + + hass.state = CoreState.starting + + await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test_hygrostat", + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "target_humidity": 42, + "away_humidity": 35, + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("humidifier.test_hygrostat") + assert state.attributes[ATTR_HUMIDITY] == 40 + assert state.state == STATE_OFF + + +async def test_restore_state_uncoherence_case(hass): + """ + Test restore from a strange state. + + - Turn the generic hygrostat off + - Restart HA and restore state from DB + """ + _mock_restore_cache(hass, humidity=40) + + calls = await _setup_switch(hass, False) + _setup_sensor(hass, 35) + await _setup_humidifier(hass) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY) + assert state.attributes[ATTR_HUMIDITY] == 40 + assert state.state == STATE_OFF + assert len(calls) == 0 + + calls = await _setup_switch(hass, False) + await hass.async_block_till_done() + state = hass.states.get(ENTITY) + assert state.state == STATE_OFF + + +async def _setup_humidifier(hass): + assert await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "dry_tolerance": 2, + "wet_tolerance": 4, + "away_humidity": 32, + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "device_class": "dehumidifier", + } + }, + ) + await hass.async_block_till_done() + + +def _mock_restore_cache(hass, humidity=40, state=STATE_OFF): + mock_restore_cache( + hass, + ( + State( + ENTITY, + state, + { + ATTR_ENTITY_ID: ENTITY, + ATTR_HUMIDITY: str(humidity), + ATTR_MODE: MODE_AWAY, + }, + ), + ), + ) + + +async def test_away_fixed_humidity_mode(hass): + """Ensure retain of target humidity for normal mode.""" + _setup_sensor(hass, 45) + await hass.async_block_till_done() + await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test_hygrostat", + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "away_humidity": 32, + "target_humidity": 40, + "away_fixed": True, + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("humidifier.test_hygrostat") + assert state.attributes[ATTR_HUMIDITY] == 40 + assert state.attributes[ATTR_MODE] == MODE_NORMAL + assert state.state == STATE_OFF + + # Switch to Away mode + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: "humidifier.test_hygrostat", ATTR_MODE: MODE_AWAY}, + blocking=True, + ) + await hass.async_block_till_done() + + # Target humidity changed to away_humidity + state = hass.states.get("humidifier.test_hygrostat") + assert state.attributes[ATTR_MODE] == MODE_AWAY + assert state.attributes[ATTR_HUMIDITY] == 32 + assert state.attributes[ATTR_SAVED_HUMIDITY] == 40 + assert state.state == STATE_OFF + + # Change target humidity + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: "humidifier.test_hygrostat", ATTR_HUMIDITY: 42}, + blocking=True, + ) + await hass.async_block_till_done() + + # Current target humidity not changed + state = hass.states.get("humidifier.test_hygrostat") + assert state.attributes[ATTR_HUMIDITY] == 32 + assert state.attributes[ATTR_SAVED_HUMIDITY] == 42 + assert state.attributes[ATTR_MODE] == MODE_AWAY + assert state.state == STATE_OFF + + # Return to Normal mode + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: "humidifier.test_hygrostat", ATTR_MODE: MODE_NORMAL}, + blocking=True, + ) + await hass.async_block_till_done() + + # Target humidity changed to away_humidity + state = hass.states.get("humidifier.test_hygrostat") + assert state.attributes[ATTR_HUMIDITY] == 42 + assert state.attributes[ATTR_SAVED_HUMIDITY] == 32 + assert state.attributes[ATTR_MODE] == MODE_NORMAL + assert state.state == STATE_OFF + + +async def test_sensor_stale_duration(hass, setup_comp_1, caplog): + """Test turn off on sensor stale.""" + + humidifier_switch = "input_boolean.test" + assert await async_setup_component( + hass, input_boolean.DOMAIN, {"input_boolean": {"test": None}} + ) + await hass.async_block_till_done() + + assert await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "humidifier": humidifier_switch, + "target_sensor": ENT_SENSOR, + "initial_state": True, + "sensor_stale_duration": {"minutes": 10}, + } + }, + ) + await hass.async_block_till_done() + + _setup_sensor(hass, 23) + await hass.async_block_till_done() + + assert hass.states.get(humidifier_switch).state == STATE_OFF + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 32}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.get(humidifier_switch).state == STATE_ON + + # Wait 11 minutes + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(minutes=11)) + await hass.async_block_till_done() + + # 11 minutes later, no news from the sensor : emergency cut off + assert hass.states.get(humidifier_switch).state == STATE_OFF + assert "emergency" in caplog.text + + # Updated value from sensor received + _setup_sensor(hass, 24) + await hass.async_block_till_done() + + # A new value has arrived, the humidifier should go ON + assert hass.states.get(humidifier_switch).state == STATE_ON + + # Manual turn off + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY}, + blocking=True, + ) + await hass.async_block_till_done() + assert hass.states.get(humidifier_switch).state == STATE_OFF + + # Wait another 11 minutes + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(minutes=22)) + await hass.async_block_till_done() + + # Still off + assert hass.states.get(humidifier_switch).state == STATE_OFF + + # Updated value from sensor received + _setup_sensor(hass, 22) + await hass.async_block_till_done() + + # Not turning on by itself + assert hass.states.get(humidifier_switch).state == STATE_OFF