Add new integration for Jandy iAqualink pool control ()

* Import new iaqualink component with climate platform.

* Style and unittest changes, fix async_step_import.

* Reorder imports.

* Fix stale doctstrings and add unittest.
pull/26485/head
Florent Thoumie 2019-09-06 13:21:56 -07:00 committed by Martin Hjelmare
parent a72d9da9f4
commit 0abb2f3eb8
15 changed files with 453 additions and 0 deletions

View File

@ -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/*

View File

@ -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

View File

@ -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."
}
}
}

View File

@ -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))

View File

@ -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]

View File

@ -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)

View File

@ -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]

View File

@ -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"
]
}

View File

@ -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."
}
}
}

View File

@ -24,6 +24,7 @@ FLOWS = [
"homekit_controller",
"homematicip_cloud",
"hue",
"iaqualink",
"ifttt",
"ios",
"ipma",

View File

@ -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

View File

@ -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

View File

@ -93,6 +93,7 @@ TEST_REQUIREMENTS = (
"homematicip",
"httplib2",
"huawei-lte-api",
"iaqualink",
"influxdb",
"jsonpath",
"libpurecool",

View File

@ -0,0 +1 @@
"""Tests for the iAqualink component."""

View File

@ -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