diff --git a/.coveragerc b/.coveragerc index e7f155f8923..03ee2e4038c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -287,6 +287,7 @@ omit = homeassistant/components/hydrawise/* homeassistant/components/hyperion/light.py homeassistant/components/ialarm/alarm_control_panel.py + homeassistant/components/iaqualink/climate.py homeassistant/components/icloud/device_tracker.py homeassistant/components/idteck_prox/* homeassistant/components/ifttt/* diff --git a/CODEOWNERS b/CODEOWNERS index 5c9673b0838..386deb10729 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -129,6 +129,7 @@ homeassistant/components/http/* @home-assistant/core homeassistant/components/huawei_lte/* @scop homeassistant/components/huawei_router/* @abmantis homeassistant/components/hue/* @balloob +homeassistant/components/iaqualink/* @flz homeassistant/components/ign_sismologia/* @exxamalte homeassistant/components/incomfort/* @zxdavb homeassistant/components/influxdb/* @fabaff diff --git a/homeassistant/components/iaqualink/.translations/en.json b/homeassistant/components/iaqualink/.translations/en.json new file mode 100644 index 00000000000..4c706522198 --- /dev/null +++ b/homeassistant/components/iaqualink/.translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "title": "Jandy iAqualink", + "step": { + "user": { + "title": "Connect to iAqualink", + "description": "Please enter the username and password for your iAqualink account.", + "data": { + "username": "Username / Email Address", + "password": "Password" + } + } + }, + "error": { + "connection_failure": "Unable to connect to iAqualink. Check your username and password." + }, + "abort": { + "already_setup": "You can only configure a single iAqualink connection." + } + } +} diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py new file mode 100644 index 00000000000..3f171715c57 --- /dev/null +++ b/homeassistant/components/iaqualink/__init__.py @@ -0,0 +1,103 @@ +"""Component to embed Aqualink devices.""" +import asyncio +import logging + +from aiohttp import CookieJar +import voluptuous as vol + +from iaqualink import AqualinkClient, AqualinkLoginException, AqualinkThermostat + +from homeassistant import config_entries +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.aiohttp_client import async_create_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +from .const import DOMAIN + + +_LOGGER = logging.getLogger(__name__) + +ATTR_CONFIG = "config" +PARALLEL_UPDATES = 0 + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> None: + """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 + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> None: + """Set up Aqualink from a config entry.""" + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + + # These will contain the initialized devices + climates = hass.data[DOMAIN][CLIMATE_DOMAIN] = [] + + session = async_create_clientsession(hass, cookie_jar=CookieJar(unsafe=True)) + aqualink = AqualinkClient(username, password, session) + try: + await aqualink.login() + except AqualinkLoginException as login_exception: + _LOGGER.error("Exception raised while attempting to login: %s", login_exception) + return False + + 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() + + for dev in devices.values(): + if isinstance(dev, AqualinkThermostat): + climates += [dev] + + forward_setup = hass.config_entries.async_forward_entry_setup + if climates: + _LOGGER.debug("Got %s climates: %s", len(climates), climates) + hass.async_create_task(forward_setup(entry, CLIMATE_DOMAIN)) + + return True + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + forward_unload = hass.config_entries.async_forward_entry_unload + + tasks = [] + + if hass.data[DOMAIN][CLIMATE_DOMAIN]: + tasks += [forward_unload(entry, CLIMATE_DOMAIN)] + + hass.data[DOMAIN].clear() + + return all(await asyncio.gather(*tasks)) diff --git a/homeassistant/components/iaqualink/climate.py b/homeassistant/components/iaqualink/climate.py new file mode 100644 index 00000000000..0a30517623d --- /dev/null +++ b/homeassistant/components/iaqualink/climate.py @@ -0,0 +1,150 @@ +"""Support for Aqualink Thermostats.""" +import logging +from typing import List, Optional + +from iaqualink import ( + AqualinkState, + AqualinkHeater, + AqualinkPump, + AqualinkSensor, + AqualinkThermostat, +) +from iaqualink.const import ( + AQUALINK_TEMP_CELSIUS_HIGH, + AQUALINK_TEMP_CELSIUS_LOW, + AQUALINK_TEMP_FAHRENHEIT_HIGH, + AQUALINK_TEMP_FAHRENHEIT_LOW, +) + +from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate.const import ( + DOMAIN, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.helpers.typing import HomeAssistantType + +from .const import DOMAIN as AQUALINK_DOMAIN, CLIMATE_SUPPORTED_MODES + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities +) -> None: + """Set up discovered switches.""" + devs = [] + for dev in hass.data[AQUALINK_DOMAIN][DOMAIN]: + devs.append(HassAqualinkThermostat(dev)) + async_add_entities(devs, True) + + +class HassAqualinkThermostat(ClimateDevice): + """Representation of a thermostat.""" + + def __init__(self, dev: AqualinkThermostat): + """Initialize the thermostat.""" + self.dev = dev + + @property + def name(self) -> str: + """Return the name of the thermostat.""" + return self.dev.label.split(" ")[0] + + async def async_update(self) -> None: + """Update the internal state of the thermostat. + + The API update() command refreshes the state of all devices so we + only update if this is the main thermostat to avoid unnecessary + calls. + """ + if self.name != "Pool": + return + await self.dev.system.update() + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_TARGET_TEMPERATURE + + @property + def hvac_modes(self) -> List[str]: + """Return the list of supported HVAC modes.""" + return CLIMATE_SUPPORTED_MODES + + @property + def pump(self) -> AqualinkPump: + """Return the pump device for the current thermostat.""" + pump = f"{self.name.lower()}_pump" + return self.dev.system.devices[pump] + + @property + def hvac_mode(self) -> str: + """Return the current HVAC mode.""" + state = AqualinkState(self.heater.state) + if state == AqualinkState.ON: + return HVAC_MODE_HEAT + return HVAC_MODE_OFF + + 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() + elif hvac_mode == HVAC_MODE_OFF: + await self.heater.turn_off() + else: + _LOGGER.warning("Unknown operation mode: %s", hvac_mode) + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement.""" + if self.dev.system.temp_unit == "F": + return TEMP_FAHRENHEIT + return TEMP_CELSIUS + + @property + def min_temp(self) -> int: + """Return the minimum temperature supported by the thermostat.""" + if self.temperature_unit == TEMP_FAHRENHEIT: + return AQUALINK_TEMP_FAHRENHEIT_LOW + return AQUALINK_TEMP_CELSIUS_LOW + + @property + def max_temp(self) -> int: + """Return the minimum temperature supported by the thermostat.""" + if self.temperature_unit == TEMP_FAHRENHEIT: + return AQUALINK_TEMP_FAHRENHEIT_HIGH + return AQUALINK_TEMP_CELSIUS_HIGH + + @property + def target_temperature(self) -> float: + """Return the current target temperature.""" + return float(self.dev.state) + + async def async_set_temperature(self, **kwargs) -> None: + """Set new target temperature.""" + await self.dev.set_temperature(int(kwargs[ATTR_TEMPERATURE])) + + @property + def sensor(self) -> AqualinkSensor: + """Return the sensor device for the current thermostat.""" + sensor = f"{self.name.lower()}_temp" + return self.dev.system.devices[sensor] + + @property + def current_temperature(self) -> Optional[float]: + """Return the current temperature.""" + if self.sensor.state != "": + return float(self.sensor.state) + return None + + @property + def heater(self) -> AqualinkHeater: + """Return the heater device for the current thermostat.""" + heater = f"{self.name.lower()}_heater" + return self.dev.system.devices[heater] diff --git a/homeassistant/components/iaqualink/config_flow.py b/homeassistant/components/iaqualink/config_flow.py new file mode 100644 index 00000000000..ec83477d253 --- /dev/null +++ b/homeassistant/components/iaqualink/config_flow.py @@ -0,0 +1,52 @@ +"""Config flow to configure zone component.""" +from typing import Optional + +import voluptuous as vol + +from iaqualink import AqualinkClient, AqualinkLoginException + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers import ConfigType + +from .const import DOMAIN + + +@config_entries.HANDLERS.register(DOMAIN) +class AqualinkFlowHandler(config_entries.ConfigFlow): + """Aqualink config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input: Optional[ConfigType] = None): + """Handle a flow start.""" + # Supporting a single account. + entries = self.hass.config_entries.async_entries(DOMAIN) + if entries: + return self.async_abort(reason="already_setup") + + errors = {} + + if user_input is not None: + username = user_input[CONF_USERNAME] + 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: + errors["base"] = "connection_failure" + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + ), + errors=errors, + ) + + async def async_step_import(self, user_input: Optional[ConfigType] = None): + """Occurs when an entry is setup through config.""" + return await self.async_step_user(user_input) diff --git a/homeassistant/components/iaqualink/const.py b/homeassistant/components/iaqualink/const.py new file mode 100644 index 00000000000..ebdcd365194 --- /dev/null +++ b/homeassistant/components/iaqualink/const.py @@ -0,0 +1,5 @@ +"""Constants for the the iaqualink component.""" +from homeassistant.components.climate.const import HVAC_MODE_HEAT, HVAC_MODE_OFF + +DOMAIN = "iaqualink" +CLIMATE_SUPPORTED_MODES = [HVAC_MODE_HEAT, HVAC_MODE_OFF] diff --git a/homeassistant/components/iaqualink/manifest.json b/homeassistant/components/iaqualink/manifest.json new file mode 100644 index 00000000000..25e02536897 --- /dev/null +++ b/homeassistant/components/iaqualink/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "iaqualink", + "name": "Jandy iAqualink", + "config_flow": true, + "documentation": "https://www.home-assistant.io/components/iaqualink/", + "dependencies": [], + "codeowners": [ + "@flz" + ], + "requirements": [ + "iaqualink==0.2.9" + ] +} diff --git a/homeassistant/components/iaqualink/strings.json b/homeassistant/components/iaqualink/strings.json new file mode 100644 index 00000000000..4c706522198 --- /dev/null +++ b/homeassistant/components/iaqualink/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "title": "Jandy iAqualink", + "step": { + "user": { + "title": "Connect to iAqualink", + "description": "Please enter the username and password for your iAqualink account.", + "data": { + "username": "Username / Email Address", + "password": "Password" + } + } + }, + "error": { + "connection_failure": "Unable to connect to iAqualink. Check your username and password." + }, + "abort": { + "already_setup": "You can only configure a single iAqualink connection." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 32690153221..1dffe2d8e6b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -24,6 +24,7 @@ FLOWS = [ "homekit_controller", "homematicip_cloud", "hue", + "iaqualink", "ifttt", "ios", "ipma", diff --git a/requirements_all.txt b/requirements_all.txt index fc07e144b3d..08641c38b8e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -663,6 +663,9 @@ hydrawiser==0.1.1 # homeassistant.components.htu21d # i2csense==0.0.4 +# homeassistant.components.iaqualink +iaqualink==0.2.9 + # homeassistant.components.watson_tts ibm-watson==3.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5e0e0d2a3ea..de5cdaa9da4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -194,6 +194,9 @@ httplib2==0.10.3 # homeassistant.components.huawei_lte huawei-lte-api==1.3.0 +# homeassistant.components.iaqualink +iaqualink==0.2.9 + # homeassistant.components.influxdb influxdb==5.2.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 1468969d9dd..7c49055131b 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -93,6 +93,7 @@ TEST_REQUIREMENTS = ( "homematicip", "httplib2", "huawei-lte-api", + "iaqualink", "influxdb", "jsonpath", "libpurecool", diff --git a/tests/components/iaqualink/__init__.py b/tests/components/iaqualink/__init__.py new file mode 100644 index 00000000000..c4e3b75d0ae --- /dev/null +++ b/tests/components/iaqualink/__init__.py @@ -0,0 +1 @@ +"""Tests for the iAqualink component.""" diff --git a/tests/components/iaqualink/test_config_flow.py b/tests/components/iaqualink/test_config_flow.py new file mode 100644 index 00000000000..5c4d75ee3c1 --- /dev/null +++ b/tests/components/iaqualink/test_config_flow.py @@ -0,0 +1,77 @@ +"""Tests for iAqualink config flow.""" +from unittest.mock import patch + +import iaqualink +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): + """Test config flow when iaqualink component is already setup.""" + MockConfigEntry(domain="iaqualink", data=DATA).add_to_hass(hass) + + flow = config_flow.AqualinkFlowHandler() + flow.hass = hass + flow.context = {} + + fname = f"async_step_{step}" + func = getattr(flow, fname) + result = await func(DATA) + + assert result["type"] == "abort" + + +@pytest.mark.parametrize("step", ["import", "user"]) +async def test_without_config(hass, step): + """Test with no configuration.""" + flow = config_flow.AqualinkFlowHandler() + flow.hass = hass + flow.context = {} + + fname = f"async_step_{step}" + func = getattr(flow, fname) + result = await func() + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {} + + +@pytest.mark.parametrize("step", ["import", "user"]) +async def test_with_invalid_credentials(hass, step): + """Test config flow with invalid username and/or password.""" + flow = config_flow.AqualinkFlowHandler() + flow.hass = hass + + fname = f"async_step_{step}" + func = getattr(flow, fname) + with patch( + "iaqualink.AqualinkClient.login", side_effect=iaqualink.AqualinkLoginException + ): + result = await func(DATA) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {"base": "connection_failure"} + + +@pytest.mark.parametrize("step", ["import", "user"]) +async def test_with_existing_config(hass, step): + """Test 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) + + assert result["type"] == "create_entry" + assert result["title"] == DATA["username"] + assert result["data"] == DATA