diff --git a/CODEOWNERS b/CODEOWNERS index 04711d45db72..0b8eafa86b31 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -185,6 +185,7 @@ homeassistant/components/http/* @home-assistant/core homeassistant/components/huawei_lte/* @scop @fphammerle homeassistant/components/huawei_router/* @abmantis homeassistant/components/hue/* @balloob +homeassistant/components/humidifier/* @home-assistant/core @Shulyaka homeassistant/components/hunterdouglas_powerview/* @bdraco homeassistant/components/hvv_departures/* @vigonotion homeassistant/components/hydrawise/* @ptcryan diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index 344ffbd9fd30..8121d4933154 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -18,6 +18,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [ "climate", "cover", "fan", + "humidifier", "light", "lock", "media_player", diff --git a/homeassistant/components/demo/humidifier.py b/homeassistant/components/demo/humidifier.py new file mode 100644 index 000000000000..35eb6e185375 --- /dev/null +++ b/homeassistant/components/demo/humidifier.py @@ -0,0 +1,124 @@ +"""Demo platform that offers a fake humidifier device.""" +from homeassistant.components.humidifier import HumidifierEntity +from homeassistant.components.humidifier.const import ( + DEVICE_CLASS_DEHUMIDIFIER, + DEVICE_CLASS_HUMIDIFIER, + SUPPORT_MODES, +) + +SUPPORT_FLAGS = 0 + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Demo humidifier devices.""" + async_add_entities( + [ + DemoHumidifier( + name="Humidifier", + mode=None, + target_humidity=68, + device_class=DEVICE_CLASS_HUMIDIFIER, + ), + DemoHumidifier( + name="Dehumidifier", + mode=None, + target_humidity=54, + device_class=DEVICE_CLASS_DEHUMIDIFIER, + ), + DemoHumidifier( + name="Hygrostat", + mode="home", + available_modes=["home", "eco"], + target_humidity=50, + ), + ] + ) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo humidifier devices config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoHumidifier(HumidifierEntity): + """Representation of a demo humidifier device.""" + + def __init__( + self, + name, + mode, + target_humidity, + available_modes=None, + is_on=True, + device_class=None, + ): + """Initialize the humidifier device.""" + self._name = name + self._state = is_on + self._support_flags = SUPPORT_FLAGS + if mode is not None: + self._support_flags = self._support_flags | SUPPORT_MODES + self._target_humidity = target_humidity + self._mode = mode + self._available_modes = available_modes + self._device_class = device_class + + @property + def supported_features(self): + """Return the list of supported features.""" + return self._support_flags + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def name(self): + """Return the name of the humidity device.""" + return self._name + + @property + def target_humidity(self): + """Return the humidity we try to reach.""" + return self._target_humidity + + @property + def mode(self): + """Return current mode.""" + return self._mode + + @property + def available_modes(self): + """Return available modes.""" + return self._available_modes + + @property + def is_on(self): + """Return true if the humidifier is on.""" + return self._state + + @property + def device_class(self): + """Return the device class of the humidifier.""" + return self._device_class + + async def async_turn_on(self, **kwargs): + """Turn the device on.""" + self._state = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs): + """Turn the device off.""" + self._state = False + self.async_write_ha_state() + + async def async_set_humidity(self, humidity): + """Set new humidity level.""" + self._target_humidity = humidity + self.async_write_ha_state() + + async def async_set_mode(self, mode): + """Update mode.""" + self._mode = mode + self.async_write_ha_state() diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 52788317378d..72f0fac481b1 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -50,9 +50,21 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -SIGNIFICANT_DOMAINS = ("climate", "device_tracker", "thermostat", "water_heater") +SIGNIFICANT_DOMAINS = ( + "climate", + "device_tracker", + "humidifier", + "thermostat", + "water_heater", +) IGNORE_DOMAINS = ("zone", "scene") -NEED_ATTRIBUTE_DOMAINS = {"climate", "water_heater", "thermostat", "script"} +NEED_ATTRIBUTE_DOMAINS = { + "climate", + "humidifier", + "script", + "thermostat", + "water_heater", +} SCRIPT_DOMAIN = "script" ATTR_CAN_CANCEL = "can_cancel" diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py new file mode 100644 index 000000000000..fc455feb477a --- /dev/null +++ b/homeassistant/components/humidifier/__init__.py @@ -0,0 +1,175 @@ +"""Provides functionality to interact with humidifier devices.""" +from datetime import timedelta +import logging +from typing import Any, Dict, List, Optional + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_ON, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import ConfigType, HomeAssistantType +from homeassistant.loader import bind_hass + +from .const import ( + ATTR_AVAILABLE_MODES, + ATTR_HUMIDITY, + ATTR_MAX_HUMIDITY, + ATTR_MIN_HUMIDITY, + ATTR_MODE, + DEFAULT_MAX_HUMIDITY, + DEFAULT_MIN_HUMIDITY, + DEVICE_CLASS_DEHUMIDIFIER, + DEVICE_CLASS_HUMIDIFIER, + DOMAIN, + SERVICE_SET_HUMIDITY, + SERVICE_SET_MODE, + SUPPORT_MODES, +) + +_LOGGER = logging.getLogger(__name__) + + +SCAN_INTERVAL = timedelta(seconds=60) + +DEVICE_CLASSES = [DEVICE_CLASS_HUMIDIFIER, DEVICE_CLASS_DEHUMIDIFIER] + +DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) + + +@bind_hass +def is_on(hass, entity_id): + """Return if the humidifier is on based on the statemachine. + + Async friendly. + """ + return hass.states.is_state(entity_id, STATE_ON) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Set up humidifier devices.""" + component = hass.data[DOMAIN] = EntityComponent( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + await component.async_setup(config) + + component.async_register_entity_service(SERVICE_TURN_ON, {}, "async_turn_on") + component.async_register_entity_service(SERVICE_TURN_OFF, {}, "async_turn_off") + component.async_register_entity_service(SERVICE_TOGGLE, {}, "async_toggle") + component.async_register_entity_service( + SERVICE_SET_MODE, + {vol.Required(ATTR_MODE): cv.string}, + "async_set_mode", + [SUPPORT_MODES], + ) + component.async_register_entity_service( + SERVICE_SET_HUMIDITY, + { + vol.Required(ATTR_HUMIDITY): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ) + }, + "async_set_humidity", + ) + + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + return await hass.data[DOMAIN].async_setup_entry(entry) + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.data[DOMAIN].async_unload_entry(entry) + + +class HumidifierEntity(ToggleEntity): + """Representation of a humidifier device.""" + + @property + def capability_attributes(self) -> Dict[str, Any]: + """Return capability attributes.""" + supported_features = self.supported_features or 0 + data = { + ATTR_MIN_HUMIDITY: self.min_humidity, + ATTR_MAX_HUMIDITY: self.max_humidity, + } + + if supported_features & SUPPORT_MODES: + data[ATTR_AVAILABLE_MODES] = self.available_modes + + return data + + @property + def state_attributes(self) -> Dict[str, Any]: + """Return the optional state attributes.""" + supported_features = self.supported_features or 0 + data = {} + + if self.target_humidity is not None: + data[ATTR_HUMIDITY] = self.target_humidity + + if supported_features & SUPPORT_MODES: + data[ATTR_MODE] = self.mode + + return data + + @property + def target_humidity(self) -> Optional[int]: + """Return the humidity we try to reach.""" + return None + + @property + def mode(self) -> Optional[str]: + """Return the current mode, e.g., home, auto, baby. + + Requires SUPPORT_MODES. + """ + raise NotImplementedError + + @property + def available_modes(self) -> Optional[List[str]]: + """Return a list of available modes. + + Requires SUPPORT_MODES. + """ + raise NotImplementedError + + def set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + raise NotImplementedError() + + async def async_set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + await self.hass.async_add_executor_job(self.set_humidity, humidity) + + def set_mode(self, mode: str) -> None: + """Set new mode.""" + raise NotImplementedError() + + async def async_set_mode(self, mode: str) -> None: + """Set new mode.""" + await self.hass.async_add_executor_job(self.set_mode, mode) + + @property + def min_humidity(self) -> int: + """Return the minimum humidity.""" + return DEFAULT_MIN_HUMIDITY + + @property + def max_humidity(self) -> int: + """Return the maximum humidity.""" + return DEFAULT_MAX_HUMIDITY diff --git a/homeassistant/components/humidifier/const.py b/homeassistant/components/humidifier/const.py new file mode 100644 index 000000000000..82e87ae5c31d --- /dev/null +++ b/homeassistant/components/humidifier/const.py @@ -0,0 +1,30 @@ +"""Provides the constants needed for component.""" + +MODE_NORMAL = "normal" +MODE_ECO = "eco" +MODE_AWAY = "away" +MODE_BOOST = "boost" +MODE_COMFORT = "comfort" +MODE_HOME = "home" +MODE_SLEEP = "sleep" +MODE_AUTO = "auto" +MODE_BABY = "baby" + +ATTR_MODE = "mode" +ATTR_AVAILABLE_MODES = "available_modes" +ATTR_HUMIDITY = "humidity" +ATTR_MAX_HUMIDITY = "max_humidity" +ATTR_MIN_HUMIDITY = "min_humidity" + +DEFAULT_MIN_HUMIDITY = 0 +DEFAULT_MAX_HUMIDITY = 100 + +DOMAIN = "humidifier" + +DEVICE_CLASS_HUMIDIFIER = "humidifier" +DEVICE_CLASS_DEHUMIDIFIER = "dehumidifier" + +SERVICE_SET_MODE = "set_mode" +SERVICE_SET_HUMIDITY = "set_humidity" + +SUPPORT_MODES = 1 diff --git a/homeassistant/components/humidifier/manifest.json b/homeassistant/components/humidifier/manifest.json new file mode 100644 index 000000000000..b64065a2583a --- /dev/null +++ b/homeassistant/components/humidifier/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "humidifier", + "name": "Humidifier", + "documentation": "https://www.home-assistant.io/integrations/humidifier", + "codeowners": ["@home-assistant/core", "@Shulyaka"], + "quality_scale": "internal" +} diff --git a/homeassistant/components/humidifier/services.yaml b/homeassistant/components/humidifier/services.yaml new file mode 100644 index 000000000000..d10f2fb604b0 --- /dev/null +++ b/homeassistant/components/humidifier/services.yaml @@ -0,0 +1,42 @@ +# Describes the format for available humidifier services + +set_mode: + description: Set mode for humidifier device. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'humidifier.bedroom' + mode: + description: New mode + example: 'away' + +set_humidity: + description: Set target humidity of humidifier device. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'humidifier.bedroom' + humidity: + description: New target humidity for humidifier device. + example: 50 + +turn_on: + description: Turn humidifier device on. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'humidifier.bedroom' + +turn_off: + description: Turn humidifier device off. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'humidifier.bedroom' + +toggle: + description: Toggles a humidifier device. + fields: + entity_id: + description: Name(s) of entities to toggle. + example: 'humidifier.bedroom' diff --git a/tests/components/demo/test_humidifier.py b/tests/components/demo/test_humidifier.py new file mode 100644 index 000000000000..ba2bd60f8f22 --- /dev/null +++ b/tests/components/demo/test_humidifier.py @@ -0,0 +1,166 @@ +"""The tests for the demo humidifier component.""" + +import pytest +import voluptuous as vol + +from homeassistant.components.humidifier.const import ( + ATTR_HUMIDITY, + ATTR_MAX_HUMIDITY, + ATTR_MIN_HUMIDITY, + ATTR_MODE, + DOMAIN, + MODE_AWAY, + MODE_ECO, + SERVICE_SET_HUMIDITY, + SERVICE_SET_MODE, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.setup import async_setup_component + +ENTITY_DEHUMIDIFIER = "humidifier.dehumidifier" +ENTITY_HYGROSTAT = "humidifier.hygrostat" +ENTITY_HUMIDIFIER = "humidifier.humidifier" + + +@pytest.fixture(autouse=True) +async def setup_demo_humidifier(hass): + """Initialize setup demo humidifier.""" + assert await async_setup_component( + hass, DOMAIN, {"humidifier": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + +def test_setup_params(hass): + """Test the initial parameters.""" + state = hass.states.get(ENTITY_DEHUMIDIFIER) + assert state.state == STATE_ON + assert state.attributes.get(ATTR_HUMIDITY) == 54 + + +def test_default_setup_params(hass): + """Test the setup with default parameters.""" + state = hass.states.get(ENTITY_DEHUMIDIFIER) + assert state.attributes.get(ATTR_MIN_HUMIDITY) == 0 + assert state.attributes.get(ATTR_MAX_HUMIDITY) == 100 + + +async def test_set_target_humidity_bad_attr(hass): + """Test setting the target humidity without required attribute.""" + state = hass.states.get(ENTITY_DEHUMIDIFIER) + assert state.attributes.get(ATTR_HUMIDITY) == 54 + + with pytest.raises(vol.Invalid): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_HUMIDITY: None, ATTR_ENTITY_ID: ENTITY_DEHUMIDIFIER}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_DEHUMIDIFIER) + assert state.attributes.get(ATTR_HUMIDITY) == 54 + + +async def test_set_target_humidity(hass): + """Test the setting of the target humidity.""" + state = hass.states.get(ENTITY_DEHUMIDIFIER) + assert state.attributes.get(ATTR_HUMIDITY) == 54 + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_HUMIDITY: 64, ATTR_ENTITY_ID: ENTITY_DEHUMIDIFIER}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_DEHUMIDIFIER) + assert state.attributes.get(ATTR_HUMIDITY) == 64 + + +async def test_set_hold_mode_away(hass): + """Test setting the hold mode away.""" + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MODE, + {ATTR_MODE: MODE_AWAY, ATTR_ENTITY_ID: ENTITY_HYGROSTAT}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_HYGROSTAT) + assert state.attributes.get(ATTR_MODE) == MODE_AWAY + + +async def test_set_hold_mode_eco(hass): + """Test setting the hold mode eco.""" + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MODE, + {ATTR_MODE: MODE_ECO, ATTR_ENTITY_ID: ENTITY_HYGROSTAT}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_HYGROSTAT) + assert state.attributes.get(ATTR_MODE) == MODE_ECO + + +async def test_turn_on(hass): + """Test turn on device.""" + await hass.services.async_call( + DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_DEHUMIDIFIER}, blocking=True + ) + state = hass.states.get(ENTITY_DEHUMIDIFIER) + assert state.state == STATE_OFF + + await hass.services.async_call( + DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_DEHUMIDIFIER}, blocking=True + ) + state = hass.states.get(ENTITY_DEHUMIDIFIER) + assert state.state == STATE_ON + + +async def test_turn_off(hass): + """Test turn off device.""" + await hass.services.async_call( + DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_DEHUMIDIFIER}, blocking=True + ) + state = hass.states.get(ENTITY_DEHUMIDIFIER) + assert state.state == STATE_ON + + await hass.services.async_call( + DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_DEHUMIDIFIER}, blocking=True + ) + state = hass.states.get(ENTITY_DEHUMIDIFIER) + assert state.state == STATE_OFF + + +async def test_toggle(hass): + """Test toggle device.""" + await hass.services.async_call( + DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_DEHUMIDIFIER}, blocking=True + ) + state = hass.states.get(ENTITY_DEHUMIDIFIER) + assert state.state == STATE_ON + + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: ENTITY_DEHUMIDIFIER}, blocking=True + ) + state = hass.states.get(ENTITY_DEHUMIDIFIER) + assert state.state == STATE_OFF + + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: ENTITY_DEHUMIDIFIER}, blocking=True + ) + state = hass.states.get(ENTITY_DEHUMIDIFIER) + assert state.state == STATE_ON diff --git a/tests/components/humidifier/__init__.py b/tests/components/humidifier/__init__.py new file mode 100644 index 000000000000..1ef3f5b72164 --- /dev/null +++ b/tests/components/humidifier/__init__.py @@ -0,0 +1 @@ +"""The tests for humidifier component.""" diff --git a/tests/components/humidifier/test_init.py b/tests/components/humidifier/test_init.py new file mode 100644 index 000000000000..22af30d484a9 --- /dev/null +++ b/tests/components/humidifier/test_init.py @@ -0,0 +1,35 @@ +"""The tests for the humidifier component.""" +from unittest.mock import MagicMock + +from homeassistant.components.humidifier import HumidifierEntity + + +class MockHumidifierEntity(HumidifierEntity): + """Mock Humidifier device to use in tests.""" + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return 0 + + +async def test_sync_turn_on(hass): + """Test if async turn_on calls sync turn_on.""" + humidifier = MockHumidifierEntity() + humidifier.hass = hass + + humidifier.turn_on = MagicMock() + await humidifier.async_turn_on() + + assert humidifier.turn_on.called + + +async def test_sync_turn_off(hass): + """Test if async turn_off calls sync turn_off.""" + humidifier = MockHumidifierEntity() + humidifier.hass = hass + + humidifier.turn_off = MagicMock() + await humidifier.async_turn_off() + + assert humidifier.turn_off.called