From a7e129a952ea7c7fac22e3a8e2400b0c26b35f21 Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Mon, 6 Dec 2021 04:02:46 +0100 Subject: [PATCH] Add Aseko Pool Live integration (#56299) --- .coveragerc | 3 + .strict-typing | 1 + CODEOWNERS | 1 + .../components/aseko_pool_live/__init__.py | 77 +++++++++++++++++ .../components/aseko_pool_live/config_flow.py | 81 +++++++++++++++++ .../components/aseko_pool_live/const.py | 3 + .../components/aseko_pool_live/entity.py | 29 +++++++ .../components/aseko_pool_live/manifest.json | 11 +++ .../components/aseko_pool_live/sensor.py | 72 ++++++++++++++++ .../components/aseko_pool_live/strings.json | 20 +++++ .../aseko_pool_live/translations/en.json | 20 +++++ homeassistant/generated/config_flows.py | 1 + mypy.ini | 11 +++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/aseko_pool_live/__init__.py | 1 + .../aseko_pool_live/test_config_flow.py | 86 +++++++++++++++++++ 17 files changed, 423 insertions(+) create mode 100644 homeassistant/components/aseko_pool_live/__init__.py create mode 100644 homeassistant/components/aseko_pool_live/config_flow.py create mode 100644 homeassistant/components/aseko_pool_live/const.py create mode 100644 homeassistant/components/aseko_pool_live/entity.py create mode 100644 homeassistant/components/aseko_pool_live/manifest.json create mode 100644 homeassistant/components/aseko_pool_live/sensor.py create mode 100644 homeassistant/components/aseko_pool_live/strings.json create mode 100644 homeassistant/components/aseko_pool_live/translations/en.json create mode 100644 tests/components/aseko_pool_live/__init__.py create mode 100644 tests/components/aseko_pool_live/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index fc033c77369..92a2638dee5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -73,6 +73,9 @@ omit = homeassistant/components/arris_tg2492lg/* homeassistant/components/aruba/device_tracker.py homeassistant/components/arwn/sensor.py + homeassistant/components/aseko_pool_live/__init__.py + homeassistant/components/aseko_pool_live/entity.py + homeassistant/components/aseko_pool_live/sensor.py homeassistant/components/asterisk_cdr/mailbox.py homeassistant/components/asterisk_mbox/* homeassistant/components/asuswrt/__init__.py diff --git a/.strict-typing b/.strict-typing index caaef80fe38..5546086a456 100644 --- a/.strict-typing +++ b/.strict-typing @@ -17,6 +17,7 @@ homeassistant.components.ambee.* homeassistant.components.ambient_station.* homeassistant.components.amcrest.* homeassistant.components.ampio.* +homeassistant.components.aseko_pool_live.* homeassistant.components.automation.* homeassistant.components.binary_sensor.* homeassistant.components.bluetooth_tracker.* diff --git a/CODEOWNERS b/CODEOWNERS index 8ebf28d249e..a349954bf65 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -52,6 +52,7 @@ homeassistant/components/arcam_fmj/* @elupus homeassistant/components/arduino/* @fabaff homeassistant/components/arest/* @fabaff homeassistant/components/arris_tg2492lg/* @vanbalken +homeassistant/components/aseko_pool_live/* @milanmeu homeassistant/components/asuswrt/* @kennedyshead @ollo69 homeassistant/components/atag/* @MatsNL homeassistant/components/aten_pe/* @mtdcr diff --git a/homeassistant/components/aseko_pool_live/__init__.py b/homeassistant/components/aseko_pool_live/__init__.py new file mode 100644 index 00000000000..7c7e8cc009f --- /dev/null +++ b/homeassistant/components/aseko_pool_live/__init__.py @@ -0,0 +1,77 @@ +"""The Aseko Pool Live integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Dict + +from aioaseko import APIUnavailable, MobileAccount, Unit, Variable + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[str] = ["sensor"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Aseko Pool Live from a config entry.""" + account = MobileAccount( + async_get_clientsession(hass), access_token=entry.data[CONF_ACCESS_TOKEN] + ) + + try: + units = await account.get_units() + except APIUnavailable as err: + raise ConfigEntryNotReady from err + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = [] + + for unit in units: + coordinator = AsekoDataUpdateCoordinator(hass, unit) + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id].append((unit, coordinator)) + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class AsekoDataUpdateCoordinator(DataUpdateCoordinator[Dict[str, Variable]]): + """Class to manage fetching Aseko unit data from single endpoint.""" + + def __init__(self, hass: HomeAssistant, unit: Unit) -> None: + """Initialize global Aseko unit data updater.""" + self._unit = unit + + if self._unit.name: + name = self._unit.name + else: + name = f"{self._unit.type}-{self._unit.serial_number}" + + super().__init__( + hass, + _LOGGER, + name=name, + update_interval=timedelta(minutes=2), + ) + + async def _async_update_data(self) -> dict[str, Variable]: + """Fetch unit data.""" + await self._unit.get_state() + return {variable.type: variable for variable in self._unit.variables} diff --git a/homeassistant/components/aseko_pool_live/config_flow.py b/homeassistant/components/aseko_pool_live/config_flow.py new file mode 100644 index 00000000000..c8f96db3bc8 --- /dev/null +++ b/homeassistant/components/aseko_pool_live/config_flow.py @@ -0,0 +1,81 @@ +"""Config flow for Aseko Pool Live integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from aioaseko import APIUnavailable, InvalidAuthCredentials, MobileAccount, WebAccount +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_EMAIL, + CONF_PASSWORD, + CONF_UNIQUE_ID, +) +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Aseko Pool Live.""" + + VERSION = 1 + + async def get_account_info(self, email: str, password: str) -> dict: + """Get account info from the mobile API and the web API.""" + session = async_get_clientsession(self.hass) + + web_account = WebAccount(session, email, password) + web_account_info = await web_account.login() + + mobile_account = MobileAccount(session, email, password) + await mobile_account.login() + + return { + CONF_ACCESS_TOKEN: mobile_account.access_token, + CONF_EMAIL: web_account_info.email, + CONF_UNIQUE_ID: web_account_info.user_id, + } + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + info = await self.get_account_info( + user_input[CONF_EMAIL], user_input[CONF_PASSWORD] + ) + except APIUnavailable: + errors["base"] = "cannot_connect" + except InvalidAuthCredentials: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(info[CONF_UNIQUE_ID]) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=info[CONF_EMAIL], + data={CONF_ACCESS_TOKEN: info[CONF_ACCESS_TOKEN]}, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/aseko_pool_live/const.py b/homeassistant/components/aseko_pool_live/const.py new file mode 100644 index 00000000000..41701e09754 --- /dev/null +++ b/homeassistant/components/aseko_pool_live/const.py @@ -0,0 +1,3 @@ +"""Constants for the Aseko Pool Live integration.""" + +DOMAIN = "aseko_pool_live" diff --git a/homeassistant/components/aseko_pool_live/entity.py b/homeassistant/components/aseko_pool_live/entity.py new file mode 100644 index 00000000000..963bb536671 --- /dev/null +++ b/homeassistant/components/aseko_pool_live/entity.py @@ -0,0 +1,29 @@ +"""Aseko entity.""" +from aioaseko import Unit + +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import AsekoDataUpdateCoordinator +from .const import DOMAIN + + +class AsekoEntity(CoordinatorEntity): + """Representation of an aseko entity.""" + + coordinator: AsekoDataUpdateCoordinator + + def __init__(self, unit: Unit, coordinator: AsekoDataUpdateCoordinator) -> None: + """Initialize the aseko entity.""" + super().__init__(coordinator) + self._unit = unit + + self._device_model = f"ASIN AQUA {self._unit.type}" + self._device_name = self._unit.name if self._unit.name else self._device_model + + self._attr_device_info = DeviceInfo( + name=self._device_name, + identifiers={(DOMAIN, str(self._unit.serial_number))}, + manufacturer="Aseko", + model=self._device_model, + ) diff --git a/homeassistant/components/aseko_pool_live/manifest.json b/homeassistant/components/aseko_pool_live/manifest.json new file mode 100644 index 00000000000..f6323b49354 --- /dev/null +++ b/homeassistant/components/aseko_pool_live/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "aseko_pool_live", + "name": "Aseko Pool Live", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/aseko_pool_live", + "requirements": ["aioaseko==0.0.1"], + "codeowners": [ + "@milanmeu" + ], + "iot_class": "cloud_polling" +} \ No newline at end of file diff --git a/homeassistant/components/aseko_pool_live/sensor.py b/homeassistant/components/aseko_pool_live/sensor.py new file mode 100644 index 00000000000..41036b582a7 --- /dev/null +++ b/homeassistant/components/aseko_pool_live/sensor.py @@ -0,0 +1,72 @@ +"""Support for Aseko Pool Live sensors.""" +from __future__ import annotations + +from aioaseko import Unit, Variable + +from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEVICE_CLASS_TEMPERATURE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import AsekoDataUpdateCoordinator +from .const import DOMAIN +from .entity import AsekoEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Aseko Pool Live sensors.""" + data: list[tuple[Unit, AsekoDataUpdateCoordinator]] = hass.data[DOMAIN][ + config_entry.entry_id + ] + entities = [] + for unit, coordinator in data: + for variable in unit.variables: + entities.append(VariableSensorEntity(unit, variable, coordinator)) + async_add_entities(entities) + + +class VariableSensorEntity(AsekoEntity, SensorEntity): + """Representation of a unit variable sensor entity.""" + + attr_state_class = STATE_CLASS_MEASUREMENT + + def __init__( + self, unit: Unit, variable: Variable, coordinator: AsekoDataUpdateCoordinator + ) -> None: + """Initialize the variable sensor.""" + super().__init__(unit, coordinator) + self._variable = variable + + variable_name = { + "Air temp.": "Air Temperature", + "Cl free": "Free Chlorine", + "Water temp.": "Water Temperature", + }.get(self._variable.name, self._variable.name) + + self._attr_name = f"{self._device_name} {variable_name}" + self._attr_unique_id = f"{self._unit.serial_number}{self._variable.type}" + self._attr_native_unit_of_measurement = self._variable.unit + + self._attr_icon = { + "clf": "mdi:flask", + "ph": "mdi:ph", + "rx": "mdi:test-tube", + "waterLevel": "mdi:waves", + "waterTemp": "mdi:coolant-temperature", + }.get(self._variable.type) + + self._attr_device_class = { + "airTemp": DEVICE_CLASS_TEMPERATURE, + "waterTemp": DEVICE_CLASS_TEMPERATURE, + }.get(self._variable.type) + + @property + def native_value(self) -> int | None: + """Return the state of the sensor.""" + variable = self.coordinator.data[self._variable.type] + return variable.current_value diff --git a/homeassistant/components/aseko_pool_live/strings.json b/homeassistant/components/aseko_pool_live/strings.json new file mode 100644 index 00000000000..4c3813220b6 --- /dev/null +++ b/homeassistant/components/aseko_pool_live/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "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%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aseko_pool_live/translations/en.json b/homeassistant/components/aseko_pool_live/translations/en.json new file mode 100644 index 00000000000..399b4650695 --- /dev/null +++ b/homeassistant/components/aseko_pool_live/translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "Password" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b4ca5fb2d5b..596cdf03fb7 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -27,6 +27,7 @@ FLOWS = [ "ambient_station", "apple_tv", "arcam_fmj", + "aseko_pool_live", "asuswrt", "atag", "august", diff --git a/mypy.ini b/mypy.ini index 7346cc83ba9..47450bab8ed 100644 --- a/mypy.ini +++ b/mypy.ini @@ -198,6 +198,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.aseko_pool_live.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.automation.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index e399a734ab8..9c40c80c07d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -135,6 +135,9 @@ aio_georss_gdacs==0.5 # homeassistant.components.ambient_station aioambient==2021.11.0 +# homeassistant.components.aseko_pool_live +aioaseko==0.0.1 + # homeassistant.components.asuswrt aioasuswrt==1.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 075dd7e61cb..eeb755b74ab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -86,6 +86,9 @@ aio_georss_gdacs==0.5 # homeassistant.components.ambient_station aioambient==2021.11.0 +# homeassistant.components.aseko_pool_live +aioaseko==0.0.1 + # homeassistant.components.asuswrt aioasuswrt==1.4.0 diff --git a/tests/components/aseko_pool_live/__init__.py b/tests/components/aseko_pool_live/__init__.py new file mode 100644 index 00000000000..6a63e0e585f --- /dev/null +++ b/tests/components/aseko_pool_live/__init__.py @@ -0,0 +1 @@ +"""Tests for the Aseko Pool Live integration.""" diff --git a/tests/components/aseko_pool_live/test_config_flow.py b/tests/components/aseko_pool_live/test_config_flow.py new file mode 100644 index 00000000000..5ab85c61a8b --- /dev/null +++ b/tests/components/aseko_pool_live/test_config_flow.py @@ -0,0 +1,86 @@ +"""Test the Aseko Pool Live config flow.""" +from unittest.mock import AsyncMock, patch + +from aioaseko import AccountInfo, APIUnavailable, InvalidAuthCredentials +import pytest + +from homeassistant import config_entries, setup +from homeassistant.components.aseko_pool_live.const import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.aseko_pool_live.config_flow.WebAccount.login", + return_value=AccountInfo("aseko@example.com", "a_user_id", "any_language"), + ), patch( + "homeassistant.components.aseko_pool_live.config_flow.MobileAccount", + ) as mock_mobile_account, patch( + "homeassistant.components.aseko_pool_live.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + mobile_account = mock_mobile_account.return_value + mobile_account.login = AsyncMock() + mobile_account.access_token = "any_access_token" + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "aseko@example.com", + CONF_PASSWORD: "passw0rd", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "aseko@example.com" + assert result2["data"] == {CONF_ACCESS_TOKEN: "any_access_token"} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + "error_web, error_mobile, reason", + [ + (APIUnavailable, None, "cannot_connect"), + (InvalidAuthCredentials, None, "invalid_auth"), + (Exception, None, "unknown"), + (None, APIUnavailable, "cannot_connect"), + (None, InvalidAuthCredentials, "invalid_auth"), + (None, Exception, "unknown"), + ], +) +async def test_get_account_info_exceptions( + hass: HomeAssistant, error_web: Exception, error_mobile: Exception, reason: str +) -> None: + """Test we handle config flow exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.aseko_pool_live.config_flow.WebAccount.login", + return_value=AccountInfo("aseko@example.com", "a_user_id", "any_language"), + side_effect=error_web, + ), patch( + "homeassistant.components.aseko_pool_live.config_flow.MobileAccount.login", + side_effect=error_mobile, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "aseko@example.com", + CONF_PASSWORD: "passw0rd", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": reason}