diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py index 61f2c906c17..73aa6f18867 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -6,16 +6,16 @@ from functools import wraps import logging import aiohttp.client_exceptions -from iaqualink import ( +from iaqualink.client import AqualinkClient +from iaqualink.device import ( AqualinkBinarySensor, - AqualinkClient, AqualinkDevice, AqualinkLight, - AqualinkLoginException, AqualinkSensor, AqualinkThermostat, AqualinkToggle, ) +from iaqualink.exception import AqualinkServiceException import voluptuous as vol from homeassistant import config_entries @@ -73,12 +73,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Aqualink component.""" conf = config.get(DOMAIN) - hass.data[DOMAIN] = {} - if conf is not None: hass.async_create_task( hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=conf + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=conf, ) ) @@ -90,6 +90,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: username = entry.data[CONF_USERNAME] password = entry.data[CONF_PASSWORD] + hass.data.setdefault(DOMAIN, {}) + # These will contain the initialized devices binary_sensors = hass.data[DOMAIN][BINARY_SENSOR_DOMAIN] = [] climates = hass.data[DOMAIN][CLIMATE_DOMAIN] = [] @@ -101,24 +103,36 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: aqualink = AqualinkClient(username, password, session) try: await aqualink.login() - except AqualinkLoginException as login_exception: + except AqualinkServiceException as login_exception: _LOGGER.error("Failed to login: %s", login_exception) return False except ( asyncio.TimeoutError, aiohttp.client_exceptions.ClientConnectorError, ) as aio_exception: - _LOGGER.warning("Exception raised while attempting to login: %s", aio_exception) - raise ConfigEntryNotReady from aio_exception + raise ConfigEntryNotReady( + f"Error while attempting login: {aio_exception}" + ) from aio_exception + + try: + systems = await aqualink.get_systems() + except AqualinkServiceException as svc_exception: + raise ConfigEntryNotReady( + f"Error while attempting to retrieve systems list: {svc_exception}" + ) from svc_exception - systems = await aqualink.get_systems() systems = list(systems.values()) if not systems: _LOGGER.error("No systems detected or supported") return False # Only supporting the first system for now. - devices = await systems[0].get_devices() + try: + devices = await systems[0].get_devices() + except AqualinkServiceException as svc_exception: + raise ConfigEntryNotReady( + f"Error while attempting to retrieve devices list: {svc_exception}" + ) from svc_exception for dev in devices.values(): if isinstance(dev, AqualinkThermostat): @@ -151,15 +165,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_systems_update(now): """Refresh internal state for all systems.""" - prev = systems[0].last_run_success + prev = systems[0].online - await systems[0].update() - success = systems[0].last_run_success - - if not success and prev: - _LOGGER.warning("Failed to refresh iAqualink state") - elif success and not prev: - _LOGGER.warning("Reconnected to iAqualink") + try: + await systems[0].update() + except AqualinkServiceException as svc_exception: + if prev is not None: + _LOGGER.warning("Failed to refresh iAqualink state: %s", svc_exception) + else: + cur = systems[0].online + if cur is True and prev is not True: + _LOGGER.warning("Reconnected to iAqualink") async_dispatcher_send(hass, DOMAIN) @@ -174,7 +190,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: platform for platform in PLATFORMS if platform in hass.data[DOMAIN] ] - hass.data[DOMAIN].clear() + del hass.data[DOMAIN] return await hass.config_entries.async_unload_platforms(entry, platforms_to_unload) @@ -228,12 +244,12 @@ class AqualinkEntity(Entity): @property def assumed_state(self) -> bool: """Return whether the state is based on actual reading from the device.""" - return not self.dev.system.last_run_success + return self.dev.system.online in [False, None] @property def available(self) -> bool: """Return whether the device is available or not.""" - return self.dev.system.online + return self.dev.system.online is True @property def device_info(self) -> DeviceInfo: diff --git a/homeassistant/components/iaqualink/climate.py b/homeassistant/components/iaqualink/climate.py index 13245429c0a..2986bea2dc7 100644 --- a/homeassistant/components/iaqualink/climate.py +++ b/homeassistant/components/iaqualink/climate.py @@ -3,13 +3,13 @@ from __future__ import annotations import logging -from iaqualink import AqualinkHeater, AqualinkPump, AqualinkSensor, AqualinkState from iaqualink.const import ( AQUALINK_TEMP_CELSIUS_HIGH, AQUALINK_TEMP_CELSIUS_LOW, AQUALINK_TEMP_FAHRENHEIT_HIGH, AQUALINK_TEMP_FAHRENHEIT_LOW, ) +from iaqualink.device import AqualinkHeater, AqualinkPump, AqualinkSensor, AqualinkState from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( @@ -24,6 +24,7 @@ from homeassistant.core import HomeAssistant from . import AqualinkEntity, refresh_system from .const import CLIMATE_SUPPORTED_MODES, DOMAIN as AQUALINK_DOMAIN +from .utils import await_or_reraise _LOGGER = logging.getLogger(__name__) @@ -76,9 +77,9 @@ class HassAqualinkThermostat(AqualinkEntity, ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: str) -> None: """Turn the underlying heater switch on or off.""" if hvac_mode == HVAC_MODE_HEAT: - await self.heater.turn_on() + await await_or_reraise(self.heater.turn_on()) elif hvac_mode == HVAC_MODE_OFF: - await self.heater.turn_off() + await await_or_reraise(self.heater.turn_off()) else: _LOGGER.warning("Unknown operation mode: %s", hvac_mode) @@ -111,7 +112,7 @@ class HassAqualinkThermostat(AqualinkEntity, ClimateEntity): @refresh_system async def async_set_temperature(self, **kwargs) -> None: """Set new target temperature.""" - await self.dev.set_temperature(int(kwargs[ATTR_TEMPERATURE])) + await await_or_reraise(self.dev.set_temperature(int(kwargs[ATTR_TEMPERATURE]))) @property def sensor(self) -> AqualinkSensor: diff --git a/homeassistant/components/iaqualink/config_flow.py b/homeassistant/components/iaqualink/config_flow.py index 5380a97901e..a91964ba3bc 100644 --- a/homeassistant/components/iaqualink/config_flow.py +++ b/homeassistant/components/iaqualink/config_flow.py @@ -3,7 +3,11 @@ from __future__ import annotations from typing import Any -from iaqualink import AqualinkClient, AqualinkLoginException +from iaqualink.client import AqualinkClient +from iaqualink.exception import ( + AqualinkServiceException, + AqualinkServiceUnauthorizedException, +) import voluptuous as vol from homeassistant import config_entries @@ -32,16 +36,22 @@ class AqualinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): password = user_input[CONF_PASSWORD] try: - aqualink = AqualinkClient(username, password) - await aqualink.login() - return self.async_create_entry(title=username, data=user_input) - except AqualinkLoginException: + async with AqualinkClient(username, password): + pass + except AqualinkServiceUnauthorizedException: + errors["base"] = "invalid_auth" + except AqualinkServiceException: errors["base"] = "cannot_connect" + else: + return self.async_create_entry(title=username, data=user_input) return self.async_show_form( step_id="user", data_schema=vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } ), errors=errors, ) diff --git a/homeassistant/components/iaqualink/light.py b/homeassistant/components/iaqualink/light.py index 17b686947ce..9cc5f5c2194 100644 --- a/homeassistant/components/iaqualink/light.py +++ b/homeassistant/components/iaqualink/light.py @@ -1,5 +1,5 @@ """Support for Aqualink pool lights.""" -from iaqualink import AqualinkLightEffect +import logging from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -14,6 +14,9 @@ from homeassistant.core import HomeAssistant from . import AqualinkEntity, refresh_system from .const import DOMAIN as AQUALINK_DOMAIN +from .utils import await_or_reraise + +_LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 @@ -49,20 +52,19 @@ class HassAqualinkLight(AqualinkEntity, LightEntity): them. """ # For now I'm assuming lights support either effects or brightness. - if effect := kwargs.get(ATTR_EFFECT): - effect = AqualinkLightEffect[effect].value - await self.dev.set_effect(effect) + if effect_name := kwargs.get(ATTR_EFFECT): + await await_or_reraise(self.dev.set_effect_by_name(effect_name)) elif brightness := kwargs.get(ATTR_BRIGHTNESS): # Aqualink supports percentages in 25% increments. pct = int(round(brightness * 4.0 / 255)) * 25 - await self.dev.set_brightness(pct) + await await_or_reraise(self.dev.set_brightness(pct)) else: - await self.dev.turn_on() + await await_or_reraise(self.dev.turn_on()) @refresh_system async def async_turn_off(self, **kwargs) -> None: """Turn off the light.""" - await self.dev.turn_off() + await await_or_reraise(self.dev.turn_off()) @property def brightness(self) -> int: @@ -75,12 +77,12 @@ class HassAqualinkLight(AqualinkEntity, LightEntity): @property def effect(self) -> str: """Return the current light effect if supported.""" - return AqualinkLightEffect(self.dev.effect).name + return self.dev.effect @property def effect_list(self) -> list: """Return supported light effects.""" - return list(AqualinkLightEffect.__members__) + return list(self.dev.supported_light_effects) @property def supported_features(self) -> int: diff --git a/homeassistant/components/iaqualink/manifest.json b/homeassistant/components/iaqualink/manifest.json index 26c6e0b4bfd..8061163943d 100644 --- a/homeassistant/components/iaqualink/manifest.json +++ b/homeassistant/components/iaqualink/manifest.json @@ -4,6 +4,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/iaqualink/", "codeowners": ["@flz"], - "requirements": ["iaqualink==0.3.90"], + "requirements": ["iaqualink==0.4.1"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/iaqualink/strings.json b/homeassistant/components/iaqualink/strings.json index 5e7fcf6aa7a..85b49996f51 100644 --- a/homeassistant/components/iaqualink/strings.json +++ b/homeassistant/components/iaqualink/strings.json @@ -11,7 +11,8 @@ } }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" diff --git a/homeassistant/components/iaqualink/switch.py b/homeassistant/components/iaqualink/switch.py index a9fde150af3..fffd03add1a 100644 --- a/homeassistant/components/iaqualink/switch.py +++ b/homeassistant/components/iaqualink/switch.py @@ -5,6 +5,7 @@ from homeassistant.core import HomeAssistant from . import AqualinkEntity, refresh_system from .const import DOMAIN as AQUALINK_DOMAIN +from .utils import await_or_reraise PARALLEL_UPDATES = 0 @@ -47,9 +48,9 @@ class HassAqualinkSwitch(AqualinkEntity, SwitchEntity): @refresh_system async def async_turn_on(self, **kwargs) -> None: """Turn on the switch.""" - await self.dev.turn_on() + await await_or_reraise(self.dev.turn_on()) @refresh_system async def async_turn_off(self, **kwargs) -> None: """Turn off the switch.""" - await self.dev.turn_off() + await await_or_reraise(self.dev.turn_off()) diff --git a/homeassistant/components/iaqualink/utils.py b/homeassistant/components/iaqualink/utils.py new file mode 100644 index 00000000000..b047af5869c --- /dev/null +++ b/homeassistant/components/iaqualink/utils.py @@ -0,0 +1,16 @@ +"""Utility functions for Aqualink devices.""" +from __future__ import annotations + +from collections.abc import Awaitable + +from iaqualink.exception import AqualinkServiceException + +from homeassistant.exceptions import HomeAssistantError + + +async def await_or_reraise(awaitable: Awaitable) -> None: + """Execute API call while catching service exceptions.""" + try: + await awaitable + except AqualinkServiceException as svc_exception: + raise HomeAssistantError(f"Aqualink error: {svc_exception}") from svc_exception diff --git a/requirements_all.txt b/requirements_all.txt index c7f3650e4c6..49acf2268e8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -869,7 +869,7 @@ hyperion-py==0.7.4 iammeter==0.1.7 # homeassistant.components.iaqualink -iaqualink==0.3.90 +iaqualink==0.4.1 # homeassistant.components.watson_tts ibm-watson==5.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7abff39ae50..8fc576d3430 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -550,7 +550,7 @@ huisbaasje-client==0.1.0 hyperion-py==0.7.4 # homeassistant.components.iaqualink -iaqualink==0.3.90 +iaqualink==0.4.1 # homeassistant.components.ping icmplib==3.0 diff --git a/tests/components/iaqualink/conftest.py b/tests/components/iaqualink/conftest.py new file mode 100644 index 00000000000..01fc78691b3 --- /dev/null +++ b/tests/components/iaqualink/conftest.py @@ -0,0 +1,82 @@ +"""Configuration for iAqualink tests.""" +import random +from unittest.mock import AsyncMock + +from iaqualink.client import AqualinkClient +from iaqualink.device import AqualinkDevice +from iaqualink.system import AqualinkSystem +import pytest + +from homeassistant.components.iaqualink import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + +MOCK_USERNAME = "test@example.com" +MOCK_PASSWORD = "password" +MOCK_DATA = {CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD} + + +def async_returns(x): + """Return value-returning async mock.""" + return AsyncMock(return_value=x) + + +def async_raises(x): + """Return exception-raising async mock.""" + return AsyncMock(side_effect=x) + + +@pytest.fixture(name="client") +def client_fixture(): + """Create client fixture.""" + return AqualinkClient(username=MOCK_USERNAME, password=MOCK_PASSWORD) + + +def get_aqualink_system(aqualink, cls=None, data=None): + """Create aqualink system.""" + if cls is None: + cls = AqualinkSystem + + if data is None: + data = {} + + num = random.randint(0, 99999) + data["serial_number"] = f"SN{num:05}" + + return cls(aqualink=aqualink, data=data) + + +def get_aqualink_device(system, cls=None, data=None): + """Create aqualink device.""" + if cls is None: + cls = AqualinkDevice + + if data is None: + data = {} + + num = random.randint(0, 999) + data["name"] = f"name_{num:03}" + + return cls(system=system, data=data) + + +@pytest.fixture(name="config_data") +def config_data_fixture(): + """Create hass config fixture.""" + return MOCK_DATA + + +@pytest.fixture(name="config") +def config_fixture(): + """Create hass config fixture.""" + return {DOMAIN: MOCK_DATA} + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(): + """Create a mock HEOS config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data=MOCK_DATA, + ) diff --git a/tests/components/iaqualink/test_config_flow.py b/tests/components/iaqualink/test_config_flow.py index 38dd2ec1a3a..2d00284775d 100644 --- a/tests/components/iaqualink/test_config_flow.py +++ b/tests/components/iaqualink/test_config_flow.py @@ -1,20 +1,19 @@ """Tests for iAqualink config flow.""" from unittest.mock import patch -import iaqualink +from iaqualink.exception import ( + AqualinkServiceException, + AqualinkServiceUnauthorizedException, +) import pytest from homeassistant.components.iaqualink import config_flow -from tests.common import MockConfigEntry, mock_coro - -DATA = {"username": "test@example.com", "password": "pass"} - @pytest.mark.parametrize("step", ["import", "user"]) -async def test_already_configured(hass, step): +async def test_already_configured(hass, config_entry, config_data, step): """Test config flow when iaqualink component is already setup.""" - MockConfigEntry(domain="iaqualink", data=DATA).add_to_hass(hass) + config_entry.add_to_hass(hass) flow = config_flow.AqualinkFlowHandler() flow.hass = hass @@ -22,14 +21,14 @@ async def test_already_configured(hass, step): fname = f"async_step_{step}" func = getattr(flow, fname) - result = await func(DATA) + result = await func(config_data) assert result["type"] == "abort" @pytest.mark.parametrize("step", ["import", "user"]) async def test_without_config(hass, step): - """Test with no configuration.""" + """Test config flow with no configuration.""" flow = config_flow.AqualinkFlowHandler() flow.hass = hass flow.context = {} @@ -44,7 +43,7 @@ async def test_without_config(hass, step): @pytest.mark.parametrize("step", ["import", "user"]) -async def test_with_invalid_credentials(hass, step): +async def test_with_invalid_credentials(hass, config_data, step): """Test config flow with invalid username and/or password.""" flow = config_flow.AqualinkFlowHandler() flow.hass = hass @@ -52,9 +51,29 @@ async def test_with_invalid_credentials(hass, step): fname = f"async_step_{step}" func = getattr(flow, fname) with patch( - "iaqualink.AqualinkClient.login", side_effect=iaqualink.AqualinkLoginException + "homeassistant.components.iaqualink.config_flow.AqualinkClient.login", + side_effect=AqualinkServiceUnauthorizedException, ): - result = await func(DATA) + result = await func(config_data) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + +@pytest.mark.parametrize("step", ["import", "user"]) +async def test_service_exception(hass, config_data, step): + """Test config flow encountering service exception.""" + flow = config_flow.AqualinkFlowHandler() + flow.hass = hass + + fname = f"async_step_{step}" + func = getattr(flow, fname) + with patch( + "homeassistant.components.iaqualink.config_flow.AqualinkClient.login", + side_effect=AqualinkServiceException, + ): + result = await func(config_data) assert result["type"] == "form" assert result["step_id"] == "user" @@ -62,17 +81,20 @@ async def test_with_invalid_credentials(hass, step): @pytest.mark.parametrize("step", ["import", "user"]) -async def test_with_existing_config(hass, step): - """Test with existing configuration.""" +async def test_with_existing_config(hass, config_data, step): + """Test config flow with existing configuration.""" flow = config_flow.AqualinkFlowHandler() flow.hass = hass flow.context = {} fname = f"async_step_{step}" func = getattr(flow, fname) - with patch("iaqualink.AqualinkClient.login", return_value=mock_coro(None)): - result = await func(DATA) + with patch( + "homeassistant.components.iaqualink.config_flow.AqualinkClient.login", + return_value=None, + ): + result = await func(config_data) assert result["type"] == "create_entry" - assert result["title"] == DATA["username"] - assert result["data"] == DATA + assert result["title"] == config_data["username"] + assert result["data"] == config_data diff --git a/tests/components/iaqualink/test_init.py b/tests/components/iaqualink/test_init.py new file mode 100644 index 00000000000..ea7dad86908 --- /dev/null +++ b/tests/components/iaqualink/test_init.py @@ -0,0 +1,341 @@ +"""Tests for iAqualink integration.""" + +import asyncio +import logging +from unittest.mock import AsyncMock, patch + +from iaqualink.device import ( + AqualinkAuxToggle, + AqualinkBinarySensor, + AqualinkDevice, + AqualinkLightToggle, + AqualinkSensor, + AqualinkThermostat, +) +from iaqualink.exception import AqualinkServiceException + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.components.iaqualink.const import UPDATE_INTERVAL +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ASSUMED_STATE, STATE_ON, STATE_UNAVAILABLE +from homeassistant.util import dt as dt_util + +from tests.common import async_fire_time_changed +from tests.components.iaqualink.conftest import get_aqualink_device, get_aqualink_system + + +async def _ffwd_next_update_interval(hass): + now = dt_util.utcnow() + async_fire_time_changed(hass, now + UPDATE_INTERVAL) + await hass.async_block_till_done() + + +async def test_setup_login_exception(hass, config_entry): + """Test setup encountering a login exception.""" + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.iaqualink.AqualinkClient.login", + side_effect=AqualinkServiceException, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_setup_login_timeout(hass, config_entry): + """Test setup encountering a timeout while logging in.""" + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.iaqualink.AqualinkClient.login", + side_effect=asyncio.TimeoutError, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_systems_exception(hass, config_entry): + """Test setup encountering an exception while retrieving systems.""" + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.iaqualink.AqualinkClient.login", return_value=None + ), patch( + "homeassistant.components.iaqualink.AqualinkClient.get_systems", + side_effect=AqualinkServiceException, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_no_systems_recognized(hass, config_entry): + """Test setup ending in no systems recognized.""" + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.iaqualink.AqualinkClient.login", return_value=None + ), patch( + "homeassistant.components.iaqualink.AqualinkClient.get_systems", + return_value={}, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_setup_devices_exception(hass, config_entry, client): + """Test setup encountering an exception while retrieving devices.""" + config_entry.add_to_hass(hass) + + system = get_aqualink_system(client) + systems = {system.serial: system} + + with patch( + "homeassistant.components.iaqualink.AqualinkClient.login", return_value=None + ), patch( + "homeassistant.components.iaqualink.AqualinkClient.get_systems", + return_value=systems, + ), patch.object( + system, "get_devices" + ) as mock_get_devices: + mock_get_devices.side_effect = AqualinkServiceException + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_all_good_no_recognized_devices(hass, config_entry, client): + """Test setup ending in no devices recognized.""" + config_entry.add_to_hass(hass) + + system = get_aqualink_system(client) + systems = {system.serial: system} + + device = get_aqualink_device(system, AqualinkDevice) + devices = {device.name: device} + + with patch( + "homeassistant.components.iaqualink.AqualinkClient.login", return_value=None + ), patch( + "homeassistant.components.iaqualink.AqualinkClient.get_systems", + return_value=systems, + ), patch.object( + system, "get_devices" + ) as mock_get_devices: + mock_get_devices.return_value = devices + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert len(hass.states.async_entity_ids(BINARY_SENSOR_DOMAIN)) == 0 + assert len(hass.states.async_entity_ids(CLIMATE_DOMAIN)) == 0 + assert len(hass.states.async_entity_ids(LIGHT_DOMAIN)) == 0 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 0 + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_setup_all_good_all_device_types(hass, config_entry, client): + """Test setup ending in one device of each type recognized.""" + config_entry.add_to_hass(hass) + + system = get_aqualink_system(client) + systems = {system.serial: system} + + devices = [ + get_aqualink_device(system, AqualinkAuxToggle), + get_aqualink_device(system, AqualinkBinarySensor), + get_aqualink_device(system, AqualinkLightToggle), + get_aqualink_device(system, AqualinkSensor), + get_aqualink_device(system, AqualinkThermostat), + ] + devices = {d.name: d for d in devices} + + system.get_devices = AsyncMock(return_value=devices) + + with patch( + "homeassistant.components.iaqualink.AqualinkClient.login", return_value=None + ), patch( + "homeassistant.components.iaqualink.AqualinkClient.get_systems", + return_value=systems, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert len(hass.states.async_entity_ids(BINARY_SENSOR_DOMAIN)) == 1 + assert len(hass.states.async_entity_ids(CLIMATE_DOMAIN)) == 1 + assert len(hass.states.async_entity_ids(LIGHT_DOMAIN)) == 1 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_multiple_updates(hass, config_entry, caplog, client): + """Test all possible results of online status transition after update.""" + config_entry.add_to_hass(hass) + + system = get_aqualink_system(client) + systems = {system.serial: system} + + system.get_devices = AsyncMock(return_value={}) + + caplog.set_level(logging.WARNING) + + with patch( + "homeassistant.components.iaqualink.AqualinkClient.login", return_value=None + ), patch( + "homeassistant.components.iaqualink.AqualinkClient.get_systems", + return_value=systems, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + def set_online_to_true(): + system.online = True + + def set_online_to_false(): + system.online = False + + system.update = AsyncMock() + + # True -> True + system.online = True + caplog.clear() + system.update.side_effect = set_online_to_true + await _ffwd_next_update_interval(hass) + assert len(caplog.records) == 0 + + # True -> False + system.online = True + caplog.clear() + system.update.side_effect = set_online_to_false + await _ffwd_next_update_interval(hass) + assert len(caplog.records) == 0 + + # True -> None / ServiceException + system.online = True + caplog.clear() + system.update.side_effect = AqualinkServiceException + await _ffwd_next_update_interval(hass) + assert len(caplog.records) == 1 + assert "Failed" in caplog.text + + # False -> False + system.online = False + caplog.clear() + system.update.side_effect = set_online_to_false + await _ffwd_next_update_interval(hass) + assert len(caplog.records) == 0 + + # False -> True + system.online = False + caplog.clear() + system.update.side_effect = set_online_to_true + await _ffwd_next_update_interval(hass) + assert len(caplog.records) == 1 + assert "Reconnected" in caplog.text + + # False -> None / ServiceException + system.online = False + caplog.clear() + system.update.side_effect = AqualinkServiceException + await _ffwd_next_update_interval(hass) + assert len(caplog.records) == 1 + assert "Failed" in caplog.text + + # None -> None / ServiceException + system.online = None + caplog.clear() + system.update.side_effect = AqualinkServiceException + await _ffwd_next_update_interval(hass) + assert len(caplog.records) == 0 + + # None -> True + system.online = None + caplog.clear() + system.update.side_effect = set_online_to_true + await _ffwd_next_update_interval(hass) + assert len(caplog.records) == 1 + assert "Reconnected" in caplog.text + + # None -> False + system.online = None + caplog.clear() + system.update.side_effect = set_online_to_false + await _ffwd_next_update_interval(hass) + assert len(caplog.records) == 0 + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_entity_assumed_and_available(hass, config_entry, client): + """Test assumed_state and_available properties for all values of online.""" + config_entry.add_to_hass(hass) + + system = get_aqualink_system(client) + systems = {system.serial: system} + + light = get_aqualink_device(system, AqualinkLightToggle, data={"state": "1"}) + devices = {d.name: d for d in [light]} + system.get_devices = AsyncMock(return_value=devices) + system.update = AsyncMock() + + with patch( + "homeassistant.components.iaqualink.AqualinkClient.login", return_value=None + ), patch( + "homeassistant.components.iaqualink.AqualinkClient.get_systems", + return_value=systems, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids(LIGHT_DOMAIN)) == 1 + + name = f"{LIGHT_DOMAIN}.{light.name}" + + # None means maybe. + light.system.online = None + await _ffwd_next_update_interval(hass) + state = hass.states.get(name) + assert state.state == STATE_UNAVAILABLE + assert state.attributes.get(ATTR_ASSUMED_STATE) is True + + light.system.online = False + await _ffwd_next_update_interval(hass) + state = hass.states.get(name) + assert state.state == STATE_UNAVAILABLE + assert state.attributes.get(ATTR_ASSUMED_STATE) is True + + light.system.online = True + await _ffwd_next_update_interval(hass) + state = hass.states.get(name) + assert state.state == STATE_ON + assert state.attributes.get(ATTR_ASSUMED_STATE) is None diff --git a/tests/components/iaqualink/test_utils.py b/tests/components/iaqualink/test_utils.py new file mode 100644 index 00000000000..56b239c0d9f --- /dev/null +++ b/tests/components/iaqualink/test_utils.py @@ -0,0 +1,23 @@ +"""Tests for iAqualink integration utility functions.""" + +from iaqualink.exception import AqualinkServiceException +import pytest + +from homeassistant.components.iaqualink.utils import await_or_reraise +from homeassistant.exceptions import HomeAssistantError + +from tests.components.iaqualink.conftest import async_raises, async_returns + + +async def test_await_or_reraise(hass): + """Test await_or_reraise for all values of awaitable.""" + async_noop = async_returns(None) + await await_or_reraise(async_noop()) + + with pytest.raises(Exception): + async_ex = async_raises(Exception) + await await_or_reraise(async_ex()) + + with pytest.raises(HomeAssistantError): + async_ex = async_raises(AqualinkServiceException) + await await_or_reraise(async_ex())