diff --git a/CODEOWNERS b/CODEOWNERS index 8127c30d357..d47b267daaa 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -576,6 +576,7 @@ homeassistant/components/weather/* @fabaff homeassistant/components/webostv/* @bendavid @thecode homeassistant/components/websocket_api/* @home-assistant/core homeassistant/components/wemo/* @esev +homeassistant/components/whirlpool/* @abmantis homeassistant/components/wiffi/* @mampfes homeassistant/components/wilight/* @leofig-rj homeassistant/components/wirelesstag/* @sergeymaysak diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py new file mode 100644 index 00000000000..2c51ee07cc4 --- /dev/null +++ b/homeassistant/components/whirlpool/__init__.py @@ -0,0 +1,45 @@ +"""The Whirlpool Sixth Sense integration.""" +import logging + +import aiohttp +from whirlpool.auth import Auth + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import AUTH_INSTANCE_KEY, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["climate"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Whirlpool Sixth Sense from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + auth = Auth(entry.data["username"], entry.data["password"]) + try: + await auth.do_auth(store=False) + except aiohttp.ClientError as ex: + raise ConfigEntryNotReady("Cannot connect") from ex + + if not auth.is_access_token_valid(): + _LOGGER.error("Authentication failed") + return False + + hass.data[DOMAIN][entry.entry_id] = {AUTH_INSTANCE_KEY: auth} + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py new file mode 100644 index 00000000000..811b05435bd --- /dev/null +++ b/homeassistant/components/whirlpool/climate.py @@ -0,0 +1,189 @@ +"""Platform for climate integration.""" +import asyncio +import logging + +import aiohttp +from whirlpool.aircon import Aircon, FanSpeed as AirconFanSpeed, Mode as AirconMode +from whirlpool.auth import Auth + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_OFF, + HVAC_MODE_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SUPPORT_FAN_MODE, + SUPPORT_SWING_MODE, + SUPPORT_TARGET_TEMPERATURE, + SWING_HORIZONTAL, + SWING_OFF, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS + +from .const import AUTH_INSTANCE_KEY, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +AIRCON_MODE_MAP = { + AirconMode.Cool: HVAC_MODE_COOL, + AirconMode.Heat: HVAC_MODE_HEAT, + AirconMode.Fan: HVAC_MODE_FAN_ONLY, +} + +HVAC_MODE_TO_AIRCON_MODE = {v: k for k, v in AIRCON_MODE_MAP.items()} + +AIRCON_FANSPEED_MAP = { + AirconFanSpeed.Off: FAN_OFF, + AirconFanSpeed.Auto: FAN_AUTO, + AirconFanSpeed.Low: FAN_LOW, + AirconFanSpeed.Medium: FAN_MEDIUM, + AirconFanSpeed.High: FAN_HIGH, +} + +FAN_MODE_TO_AIRCON_FANSPEED = {v: k for k, v in AIRCON_FANSPEED_MAP.items()} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up entry.""" + auth: Auth = hass.data[DOMAIN][config_entry.entry_id][AUTH_INSTANCE_KEY] + said_list = auth.get_said_list() + if not said_list: + _LOGGER.debug("No appliances found") + return + + # the whirlpool library needs to be updated to be able to support more + # than one device, so we use only the first one for now + aircon = AirConEntity(said_list[0], auth) + async_add_entities([aircon], True) + + +class AirConEntity(ClimateEntity): + """Representation of an air conditioner.""" + + _attr_fan_modes = [FAN_AUTO, FAN_HIGH, FAN_MEDIUM, FAN_LOW, FAN_OFF] + _attr_hvac_modes = [ + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_OFF, + ] + _attr_max_temp = 30 + _attr_min_temp = 16 + _attr_supported_features = ( + SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE | SUPPORT_SWING_MODE + ) + _attr_swing_modes = [SWING_HORIZONTAL, SWING_OFF] + _attr_target_temperature_step = 1 + _attr_temperature_unit = TEMP_CELSIUS + _attr_should_poll = False + + def __init__(self, said, auth: Auth): + """Initialize the entity.""" + self._aircon = Aircon(auth, said, self.async_write_ha_state) + + self._attr_name = said + self._attr_unique_id = said + + async def async_added_to_hass(self) -> None: + """Connect aircon to the cloud.""" + await self._aircon.connect() + + try: + name = await self._aircon.fetch_name() + if name is not None: + self._attr_name = name + except (asyncio.TimeoutError, aiohttp.ClientError): + _LOGGER.exception("Failed to get name") + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._aircon.get_online() + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._aircon.get_current_temp() + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._aircon.get_temp() + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + await self._aircon.set_temp(kwargs.get(ATTR_TEMPERATURE)) + + @property + def current_humidity(self): + """Return the current humidity.""" + return self._aircon.get_current_humidity() + + @property + def target_humidity(self): + """Return the humidity we try to reach.""" + return self._aircon.get_humidity() + + async def async_set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + await self._aircon.set_humidity(humidity) + + @property + def hvac_mode(self): + """Return current operation ie. heat, cool, fan.""" + if not self._aircon.get_power_on(): + return HVAC_MODE_OFF + + mode: AirconMode = self._aircon.get_mode() + return AIRCON_MODE_MAP.get(mode, None) + + async def async_set_hvac_mode(self, hvac_mode): + """Set HVAC mode.""" + if hvac_mode == HVAC_MODE_OFF: + await self._aircon.set_power_on(False) + return + + mode = HVAC_MODE_TO_AIRCON_MODE.get(hvac_mode) + if not mode: + _LOGGER.warning("Unexpected hvac mode: %s", hvac_mode) + return + + await self._aircon.set_mode(mode) + if not self._aircon.get_power_on(): + await self._aircon.set_power_on(True) + + @property + def fan_mode(self): + """Return the fan setting.""" + fanspeed = self._aircon.get_fanspeed() + return AIRCON_FANSPEED_MAP.get(fanspeed, FAN_OFF) + + async def async_set_fan_mode(self, fan_mode): + """Set fan mode.""" + fanspeed = FAN_MODE_TO_AIRCON_FANSPEED.get(fan_mode) + if not fanspeed: + return + await self._aircon.set_fanspeed(fanspeed) + + @property + def swing_mode(self): + """Return the swing setting.""" + return SWING_HORIZONTAL if self._aircon.get_h_louver_swing() else SWING_OFF + + async def async_set_swing_mode(self, swing_mode): + """Set new target temperature.""" + await self._aircon.set_h_louver_swing(swing_mode == SWING_HORIZONTAL) + + async def async_turn_on(self): + """Turn device on.""" + await self._aircon.set_power_on(True) + + async def async_turn_off(self): + """Turn device off.""" + await self._aircon.set_power_on(False) diff --git a/homeassistant/components/whirlpool/config_flow.py b/homeassistant/components/whirlpool/config_flow.py new file mode 100644 index 00000000000..d5fdfd90568 --- /dev/null +++ b/homeassistant/components/whirlpool/config_flow.py @@ -0,0 +1,76 @@ +"""Config flow for Whirlpool Sixth Sense integration.""" +import asyncio +import logging + +import aiohttp +import voluptuous as vol +from whirlpool.auth import Auth + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from .const import DOMAIN # pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema({CONF_USERNAME: str, CONF_PASSWORD: str}) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + auth = Auth(data[CONF_USERNAME], data[CONF_PASSWORD]) + try: + await auth.do_auth() + except (asyncio.TimeoutError, aiohttp.ClientConnectionError) as exc: + raise CannotConnect from exc + + if not auth.is_access_token_valid(): + raise InvalidAuth + + return {"title": data[CONF_USERNAME]} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Whirlpool Sixth Sense.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id( + user_input[CONF_USERNAME].lower(), raise_on_progress=False + ) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/whirlpool/const.py b/homeassistant/components/whirlpool/const.py new file mode 100644 index 00000000000..16ba293e3b2 --- /dev/null +++ b/homeassistant/components/whirlpool/const.py @@ -0,0 +1,4 @@ +"""Constants for the Whirlpool Sixth Sense integration.""" + +DOMAIN = "whirlpool" +AUTH_INSTANCE_KEY = "auth" diff --git a/homeassistant/components/whirlpool/manifest.json b/homeassistant/components/whirlpool/manifest.json new file mode 100644 index 00000000000..9df10f32931 --- /dev/null +++ b/homeassistant/components/whirlpool/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "whirlpool", + "name": "Whirlpool Sixth Sense", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/whirlpool", + "requirements": [ + "whirlpool-sixth-sense==0.15.1" + ], + "codeowners": [ + "@abmantis" + ], + "iot_class": "cloud_push" +} diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json new file mode 100644 index 00000000000..4925d73e4c4 --- /dev/null +++ b/homeassistant/components/whirlpool/strings.json @@ -0,0 +1,17 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/en.json b/homeassistant/components/whirlpool/translations/en.json new file mode 100644 index 00000000000..407d41d6736 --- /dev/null +++ b/homeassistant/components/whirlpool/translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Password", + "username": "Username" + } + } + } + }, + "title": "Whirlpool Sixth Sense" +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 738a2d1172a..80baa455f9b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -302,6 +302,7 @@ FLOWS = [ "wallbox", "waze_travel_time", "wemo", + "whirlpool", "wiffi", "wilight", "withings", diff --git a/requirements_all.txt b/requirements_all.txt index 02f095c90ec..47068a6f72e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2390,6 +2390,9 @@ waterfurnace==1.1.0 # homeassistant.components.cisco_webex_teams webexteamssdk==1.1.1 +# homeassistant.components.whirlpool +whirlpool-sixth-sense==0.15.1 + # homeassistant.components.wiffi wiffi==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f08ecf68af3..cd63782d34b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1337,6 +1337,9 @@ wallbox==0.4.4 # homeassistant.components.folder_watcher watchdog==2.1.4 +# homeassistant.components.whirlpool +whirlpool-sixth-sense==0.15.1 + # homeassistant.components.wiffi wiffi==1.0.1 diff --git a/tests/components/whirlpool/__init__.py b/tests/components/whirlpool/__init__.py new file mode 100644 index 00000000000..3f50518b4ad --- /dev/null +++ b/tests/components/whirlpool/__init__.py @@ -0,0 +1,23 @@ +"""Tests for the Whirlpool Sixth Sense integration.""" +from homeassistant.components.whirlpool.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def init_integration(hass: HomeAssistant) -> MockConfigEntry: + """Set up the Whirlpool integration in Home Assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "nobody", + CONF_PASSWORD: "qwerty", + }, + ) + + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/whirlpool/conftest.py b/tests/components/whirlpool/conftest.py new file mode 100644 index 00000000000..e3919c118e2 --- /dev/null +++ b/tests/components/whirlpool/conftest.py @@ -0,0 +1,41 @@ +"""Fixtures for the Whirlpool Sixth Sense integration tests.""" +from unittest import mock +from unittest.mock import AsyncMock + +import pytest +import whirlpool + +MOCK_SAID = "said1" + + +@pytest.fixture(name="mock_auth_api") +def fixture_mock_auth_api(): + """Set up air conditioner Auth fixture.""" + with mock.patch("homeassistant.components.whirlpool.Auth") as mock_auth: + mock_auth.return_value.do_auth = AsyncMock() + mock_auth.return_value.is_access_token_valid.return_value = True + mock_auth.return_value.get_said_list.return_value = [MOCK_SAID] + yield mock_auth + + +@pytest.fixture(name="mock_aircon_api", autouse=True) +def fixture_mock_aircon_api(mock_auth_api): + """Set up air conditioner API fixture.""" + with mock.patch( + "homeassistant.components.whirlpool.climate.Aircon" + ) as mock_aircon_api: + mock_aircon_api.return_value.connect = AsyncMock() + mock_aircon_api.return_value.fetch_name = AsyncMock(return_value="TestZone") + mock_aircon_api.return_value.said = MOCK_SAID + mock_aircon_api.return_value.get_online.return_value = True + mock_aircon_api.return_value.get_power_on.return_value = True + mock_aircon_api.return_value.get_mode.return_value = whirlpool.aircon.Mode.Cool + mock_aircon_api.return_value.get_fanspeed.return_value = ( + whirlpool.aircon.FanSpeed.Auto + ) + mock_aircon_api.return_value.get_current_temp.return_value = 15 + mock_aircon_api.return_value.get_temp.return_value = 20 + mock_aircon_api.return_value.get_current_humidity.return_value = 80 + mock_aircon_api.return_value.get_humidity.return_value = 50 + mock_aircon_api.return_value.get_h_louver_swing.return_value = True + yield mock_aircon_api diff --git a/tests/components/whirlpool/test_climate.py b/tests/components/whirlpool/test_climate.py new file mode 100644 index 00000000000..77009607947 --- /dev/null +++ b/tests/components/whirlpool/test_climate.py @@ -0,0 +1,364 @@ +"""Test the Whirlpool Sixth Sense climate domain.""" +from unittest.mock import AsyncMock, MagicMock + +import aiohttp +import whirlpool + +from homeassistant.components.climate.const import ( + ATTR_CURRENT_HUMIDITY, + ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_FAN_MODES, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_SWING_MODE, + ATTR_SWING_MODES, + ATTR_TARGET_TEMP_STEP, + DOMAIN as CLIMATE_DOMAIN, + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_MIDDLE, + FAN_OFF, + HVAC_MODE_AUTO, + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_SWING_MODE, + SERVICE_SET_TEMPERATURE, + SUPPORT_FAN_MODE, + SUPPORT_SWING_MODE, + SUPPORT_TARGET_TEMPERATURE, + SWING_HORIZONTAL, + SWING_OFF, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_SUPPORTED_FEATURES, + ATTR_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + + +async def update_ac_state(hass: HomeAssistant, mock_aircon_api: MagicMock): + """Simulate an update trigger from the API.""" + update_ha_state_cb = mock_aircon_api.call_args.args[2] + update_ha_state_cb() + await hass.async_block_till_done() + return hass.states.get("climate.said1") + + +async def test_no_appliances(hass: HomeAssistant, mock_auth_api: MagicMock): + """Test the setup of the climate entities when there are no appliances available.""" + mock_auth_api.return_value.get_said_list.return_value = [] + await init_integration(hass) + assert len(hass.states.async_all()) == 0 + + +async def test_name_fallback_on_exception( + hass: HomeAssistant, mock_aircon_api: MagicMock +): + """Test name property.""" + mock_aircon_api.return_value.fetch_name = AsyncMock( + side_effect=aiohttp.ClientError() + ) + + await init_integration(hass) + state = hass.states.get("climate.said1") + assert state.attributes[ATTR_FRIENDLY_NAME] == "said1" + + +async def test_static_attributes(hass: HomeAssistant, mock_aircon_api: MagicMock): + """Test static climate attributes.""" + await init_integration(hass) + + entry = er.async_get(hass).async_get("climate.said1") + assert entry + assert entry.unique_id == "said1" + + state = hass.states.get("climate.said1") + assert state is not None + assert state.state != STATE_UNAVAILABLE + assert state.state == HVAC_MODE_COOL + + attributes = state.attributes + assert attributes[ATTR_FRIENDLY_NAME] == "TestZone" + + assert ( + attributes[ATTR_SUPPORTED_FEATURES] + == SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE | SUPPORT_SWING_MODE + ) + assert attributes[ATTR_HVAC_MODES] == [ + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_OFF, + ] + assert attributes[ATTR_FAN_MODES] == [ + FAN_AUTO, + FAN_HIGH, + FAN_MEDIUM, + FAN_LOW, + FAN_OFF, + ] + assert attributes[ATTR_SWING_MODES] == [SWING_HORIZONTAL, SWING_OFF] + assert attributes[ATTR_TARGET_TEMP_STEP] == 1 + assert attributes[ATTR_MIN_TEMP] == 16 + assert attributes[ATTR_MAX_TEMP] == 30 + + +async def test_dynamic_attributes(hass: HomeAssistant, mock_aircon_api: MagicMock): + """Test dynamic attributes.""" + await init_integration(hass) + + state = hass.states.get("climate.said1") + assert state is not None + assert state.state == HVAC_MODE_COOL + + mock_aircon_api.return_value.get_power_on.return_value = False + state = await update_ac_state(hass, mock_aircon_api) + assert state.state == HVAC_MODE_OFF + + mock_aircon_api.return_value.get_online.return_value = False + state = await update_ac_state(hass, mock_aircon_api) + assert state.state == STATE_UNAVAILABLE + + mock_aircon_api.return_value.get_power_on.return_value = True + mock_aircon_api.return_value.get_online.return_value = True + state = await update_ac_state(hass, mock_aircon_api) + assert state.state == HVAC_MODE_COOL + + mock_aircon_api.return_value.get_mode.return_value = whirlpool.aircon.Mode.Heat + state = await update_ac_state(hass, mock_aircon_api) + assert state.state == HVAC_MODE_HEAT + + mock_aircon_api.return_value.get_mode.return_value = whirlpool.aircon.Mode.Fan + state = await update_ac_state(hass, mock_aircon_api) + assert state.state == HVAC_MODE_FAN_ONLY + + mock_aircon_api.return_value.get_fanspeed.return_value = ( + whirlpool.aircon.FanSpeed.Auto + ) + state = await update_ac_state(hass, mock_aircon_api) + assert state.attributes[ATTR_FAN_MODE] == HVAC_MODE_AUTO + + mock_aircon_api.return_value.get_fanspeed.return_value = ( + whirlpool.aircon.FanSpeed.Low + ) + state = await update_ac_state(hass, mock_aircon_api) + assert state.attributes[ATTR_FAN_MODE] == FAN_LOW + + mock_aircon_api.return_value.get_fanspeed.return_value = ( + whirlpool.aircon.FanSpeed.Medium + ) + state = await update_ac_state(hass, mock_aircon_api) + assert state.attributes[ATTR_FAN_MODE] == FAN_MEDIUM + + mock_aircon_api.return_value.get_fanspeed.return_value = ( + whirlpool.aircon.FanSpeed.High + ) + state = await update_ac_state(hass, mock_aircon_api) + assert state.attributes[ATTR_FAN_MODE] == FAN_HIGH + + mock_aircon_api.return_value.get_fanspeed.return_value = ( + whirlpool.aircon.FanSpeed.Off + ) + state = await update_ac_state(hass, mock_aircon_api) + assert state.attributes[ATTR_FAN_MODE] == FAN_OFF + + mock_aircon_api.return_value.get_current_temp.return_value = 15 + mock_aircon_api.return_value.get_temp.return_value = 20 + mock_aircon_api.return_value.get_current_humidity.return_value = 80 + mock_aircon_api.return_value.get_h_louver_swing.return_value = True + attributes = (await update_ac_state(hass, mock_aircon_api)).attributes + assert attributes[ATTR_CURRENT_TEMPERATURE] == 15 + assert attributes[ATTR_TEMPERATURE] == 20 + assert attributes[ATTR_CURRENT_HUMIDITY] == 80 + assert attributes[ATTR_SWING_MODE] == SWING_HORIZONTAL + + mock_aircon_api.return_value.get_current_temp.return_value = 16 + mock_aircon_api.return_value.get_temp.return_value = 21 + mock_aircon_api.return_value.get_current_humidity.return_value = 70 + mock_aircon_api.return_value.get_h_louver_swing.return_value = False + attributes = (await update_ac_state(hass, mock_aircon_api)).attributes + assert attributes[ATTR_CURRENT_TEMPERATURE] == 16 + assert attributes[ATTR_TEMPERATURE] == 21 + assert attributes[ATTR_CURRENT_HUMIDITY] == 70 + assert attributes[ATTR_SWING_MODE] == SWING_OFF + + +async def test_service_calls(hass: HomeAssistant, mock_aircon_api: MagicMock): + """Test controlling the entity through service calls.""" + await init_integration(hass) + mock_aircon_api.return_value.set_power_on = AsyncMock() + mock_aircon_api.return_value.set_mode = AsyncMock() + mock_aircon_api.return_value.set_temp = AsyncMock() + mock_aircon_api.return_value.set_humidity = AsyncMock() + mock_aircon_api.return_value.set_mode = AsyncMock() + mock_aircon_api.return_value.set_fanspeed = AsyncMock() + mock_aircon_api.return_value.set_h_louver_swing = AsyncMock() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "climate.said1"}, + blocking=True, + ) + mock_aircon_api.return_value.set_power_on.assert_called_once_with(False) + + mock_aircon_api.return_value.set_power_on.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "climate.said1"}, + blocking=True, + ) + mock_aircon_api.return_value.set_power_on.assert_called_once_with(True) + + mock_aircon_api.return_value.set_power_on.reset_mock() + mock_aircon_api.return_value.get_power_on.return_value = False + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_HVAC_MODE: HVAC_MODE_COOL}, + blocking=True, + ) + mock_aircon_api.return_value.set_power_on.assert_called_once_with(True) + + mock_aircon_api.return_value.set_temp.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_TEMPERATURE: 15}, + blocking=True, + ) + mock_aircon_api.return_value.set_temp.assert_called_once_with(15) + + mock_aircon_api.return_value.set_mode.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_HVAC_MODE: HVAC_MODE_COOL}, + blocking=True, + ) + mock_aircon_api.return_value.set_mode.assert_called_once_with( + whirlpool.aircon.Mode.Cool + ) + + mock_aircon_api.return_value.set_mode.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_HVAC_MODE: HVAC_MODE_HEAT}, + blocking=True, + ) + mock_aircon_api.return_value.set_mode.assert_called_once_with( + whirlpool.aircon.Mode.Heat + ) + + mock_aircon_api.return_value.set_mode.reset_mock() + # HVAC_MODE_DRY should be ignored + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_HVAC_MODE: HVAC_MODE_DRY}, + blocking=True, + ) + mock_aircon_api.return_value.set_mode.assert_not_called() + + mock_aircon_api.return_value.set_mode.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_HVAC_MODE: HVAC_MODE_FAN_ONLY}, + blocking=True, + ) + mock_aircon_api.return_value.set_mode.assert_called_once_with( + whirlpool.aircon.Mode.Fan + ) + + mock_aircon_api.return_value.set_fanspeed.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_FAN_MODE: FAN_AUTO}, + blocking=True, + ) + mock_aircon_api.return_value.set_fanspeed.assert_called_once_with( + whirlpool.aircon.FanSpeed.Auto + ) + + mock_aircon_api.return_value.set_fanspeed.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_FAN_MODE: FAN_LOW}, + blocking=True, + ) + mock_aircon_api.return_value.set_fanspeed.assert_called_once_with( + whirlpool.aircon.FanSpeed.Low + ) + + mock_aircon_api.return_value.set_fanspeed.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_FAN_MODE: FAN_MEDIUM}, + blocking=True, + ) + mock_aircon_api.return_value.set_fanspeed.assert_called_once_with( + whirlpool.aircon.FanSpeed.Medium + ) + + mock_aircon_api.return_value.set_fanspeed.reset_mock() + # FAN_MIDDLE should be ignored + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_FAN_MODE: FAN_MIDDLE}, + blocking=True, + ) + mock_aircon_api.return_value.set_fanspeed.assert_not_called() + + mock_aircon_api.return_value.set_fanspeed.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_FAN_MODE: FAN_HIGH}, + blocking=True, + ) + mock_aircon_api.return_value.set_fanspeed.assert_called_once_with( + whirlpool.aircon.FanSpeed.High + ) + + mock_aircon_api.return_value.set_h_louver_swing.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_SWING_MODE: SWING_HORIZONTAL}, + blocking=True, + ) + mock_aircon_api.return_value.set_h_louver_swing.assert_called_with(True) + + mock_aircon_api.return_value.set_h_louver_swing.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_MODE, + {ATTR_ENTITY_ID: "climate.said1", ATTR_SWING_MODE: SWING_OFF}, + blocking=True, + ) + mock_aircon_api.return_value.set_h_louver_swing.assert_called_with(False) diff --git a/tests/components/whirlpool/test_config_flow.py b/tests/components/whirlpool/test_config_flow.py new file mode 100644 index 00000000000..6746e406a85 --- /dev/null +++ b/tests/components/whirlpool/test_config_flow.py @@ -0,0 +1,122 @@ +"""Test the Whirlpool Sixth Sense config flow.""" +import asyncio +from unittest.mock import patch + +import aiohttp + +from homeassistant import config_entries +from homeassistant.components.whirlpool.const import DOMAIN + + +async def test_form(hass): + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == config_entries.SOURCE_USER + + with patch("homeassistant.components.whirlpool.config_flow.Auth.do_auth"), patch( + "homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid", + return_value=True, + ), patch( + "homeassistant.components.whirlpool.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "test-username" + assert result2["data"] == { + "username": "test-username", + "password": "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch("homeassistant.components.whirlpool.config_flow.Auth.do_auth"), patch( + "homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid", + return_value=False, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.whirlpool.config_flow.Auth.do_auth", + side_effect=aiohttp.ClientConnectionError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_auth_timeout(hass): + """Test we handle auth timeout error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.whirlpool.config_flow.Auth.do_auth", + side_effect=asyncio.TimeoutError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_generic_auth_exception(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.whirlpool.config_flow.Auth.do_auth", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} diff --git a/tests/components/whirlpool/test_init.py b/tests/components/whirlpool/test_init.py new file mode 100644 index 00000000000..00fc27ddc63 --- /dev/null +++ b/tests/components/whirlpool/test_init.py @@ -0,0 +1,49 @@ +"""Test the Whirlpool Sixth Sense init.""" +from unittest.mock import AsyncMock, MagicMock + +import aiohttp + +from homeassistant.components.whirlpool.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.components.whirlpool import init_integration + + +async def test_setup(hass: HomeAssistant): + """Test setup.""" + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.LOADED + + +async def test_setup_http_exception(hass: HomeAssistant, mock_auth_api: MagicMock): + """Test setup with an http exception.""" + mock_auth_api.return_value.do_auth = AsyncMock( + side_effect=aiohttp.ClientConnectionError() + ) + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_auth_failed(hass: HomeAssistant, mock_auth_api: MagicMock): + """Test setup with failed auth.""" + mock_auth_api.return_value.do_auth = AsyncMock() + mock_auth_api.return_value.is_access_token_valid.return_value = False + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_unload_entry(hass: HomeAssistant): + """Test successful unload of entry.""" + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN)