From de780c6d35869515cd85a82db7130ed6354e8736 Mon Sep 17 00:00:00 2001 From: JeromeHXP Date: Mon, 4 Jan 2021 17:09:01 +0100 Subject: [PATCH] Add Ondilo ico integration (#44728) * First implementationof Ondilo component support * Update manifest toadd pypi pkg dependency * Update entities name and corrected refresh issue * Changed percentage unit name * Corrected merge issues * Updated coveragerc * cleaned up code and corrected config flow tests * Code cleanup and added test for exisitng entry * Changes following PR comments: - Inherit CoordinatorEntity instead of Entity - Merged pools blocking calls into one - Renamed devices vars to sensors - Check supported sensor types - Stop relying on array index position for pools - Stop relying on attribute position in dict for sensors * Corrected unit test * Reformat sensor type check --- .coveragerc | 5 + CODEOWNERS | 1 + .../components/ondilo_ico/__init__.py | 59 ++++++ homeassistant/components/ondilo_ico/api.py | 33 ++++ .../components/ondilo_ico/config_flow.py | 43 ++++ homeassistant/components/ondilo_ico/const.py | 8 + .../components/ondilo_ico/manifest.json | 18 ++ .../components/ondilo_ico/oauth_impl.py | 32 +++ homeassistant/components/ondilo_ico/sensor.py | 185 ++++++++++++++++++ .../components/ondilo_ico/strings.json | 17 ++ .../ondilo_ico/translations/en.json | 17 ++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/ondilo_ico/__init__.py | 1 + .../components/ondilo_ico/test_config_flow.py | 88 +++++++++ 16 files changed, 514 insertions(+) create mode 100644 homeassistant/components/ondilo_ico/__init__.py create mode 100644 homeassistant/components/ondilo_ico/api.py create mode 100644 homeassistant/components/ondilo_ico/config_flow.py create mode 100644 homeassistant/components/ondilo_ico/const.py create mode 100644 homeassistant/components/ondilo_ico/manifest.json create mode 100644 homeassistant/components/ondilo_ico/oauth_impl.py create mode 100644 homeassistant/components/ondilo_ico/sensor.py create mode 100644 homeassistant/components/ondilo_ico/strings.json create mode 100644 homeassistant/components/ondilo_ico/translations/en.json create mode 100644 tests/components/ondilo_ico/__init__.py create mode 100644 tests/components/ondilo_ico/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 5c85b2553d1..d14bf41195e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -625,6 +625,11 @@ omit = homeassistant/components/omnilogic/__init__.py homeassistant/components/omnilogic/common.py homeassistant/components/omnilogic/sensor.py + homeassistant/components/ondilo_ico/__init__.py + homeassistant/components/ondilo_ico/api.py + homeassistant/components/ondilo_ico/const.py + homeassistant/components/ondilo_ico/oauth_impl.py + homeassistant/components/ondilo_ico/sensor.py homeassistant/components/onkyo/media_player.py homeassistant/components/onvif/__init__.py homeassistant/components/onvif/base.py diff --git a/CODEOWNERS b/CODEOWNERS index 924fe98b46a..8c9ffeea599 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -319,6 +319,7 @@ homeassistant/components/ohmconnect/* @robbiet480 homeassistant/components/ombi/* @larssont homeassistant/components/omnilogic/* @oliver84 @djtimca @gentoosu homeassistant/components/onboarding/* @home-assistant/core +homeassistant/components/ondilo_ico/* @JeromeHXP homeassistant/components/onewire/* @garbled1 @epenet homeassistant/components/onvif/* @hunterjm homeassistant/components/openerz/* @misialq diff --git a/homeassistant/components/ondilo_ico/__init__.py b/homeassistant/components/ondilo_ico/__init__.py new file mode 100644 index 00000000000..69538c5e8b3 --- /dev/null +++ b/homeassistant/components/ondilo_ico/__init__.py @@ -0,0 +1,59 @@ +"""The Ondilo ICO integration.""" +import asyncio + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow + +from . import api, config_flow +from .const import DOMAIN +from .oauth_impl import OndiloOauth2Implementation + +PLATFORMS = ["sensor"] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Ondilo ICO component.""" + hass.data.setdefault(DOMAIN, {}) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Ondilo ICO from a config entry.""" + + config_flow.OAuth2FlowHandler.async_register_implementation( + hass, + OndiloOauth2Implementation(hass), + ) + + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + ) + + hass.data[DOMAIN][entry.entry_id] = api.OndiloClient(hass, entry, implementation) + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/ondilo_ico/api.py b/homeassistant/components/ondilo_ico/api.py new file mode 100644 index 00000000000..3de10403211 --- /dev/null +++ b/homeassistant/components/ondilo_ico/api.py @@ -0,0 +1,33 @@ +"""API for Ondilo ICO bound to Home Assistant OAuth.""" +from asyncio import run_coroutine_threadsafe + +from ondilo import Ondilo + +from homeassistant import config_entries, core +from homeassistant.helpers import config_entry_oauth2_flow + + +class OndiloClient(Ondilo): + """Provide Ondilo ICO authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + hass: core.HomeAssistant, + config_entry: config_entries.ConfigEntry, + implementation: config_entry_oauth2_flow.AbstractOAuth2Implementation, + ): + """Initialize Ondilo ICO Auth.""" + self.hass = hass + self.config_entry = config_entry + self.session = config_entry_oauth2_flow.OAuth2Session( + hass, config_entry, implementation + ) + super().__init__(self.session.token) + + def refresh_tokens(self) -> dict: + """Refresh and return new Ondilo ICO tokens using Home Assistant OAuth2 session.""" + run_coroutine_threadsafe( + self.session.async_ensure_token_valid(), self.hass.loop + ).result() + + return self.session.token diff --git a/homeassistant/components/ondilo_ico/config_flow.py b/homeassistant/components/ondilo_ico/config_flow.py new file mode 100644 index 00000000000..c6a164e913b --- /dev/null +++ b/homeassistant/components/ondilo_ico/config_flow.py @@ -0,0 +1,43 @@ +"""Config flow for Ondilo ICO.""" +import logging + +from homeassistant import config_entries +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import DOMAIN +from .oauth_impl import OndiloOauth2Implementation + +_LOGGER = logging.getLogger(__name__) + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Ondilo ICO OAuth2 authentication.""" + + DOMAIN = DOMAIN + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + await self.async_set_unique_id(DOMAIN) + + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + self.async_register_implementation( + self.hass, + OndiloOauth2Implementation(self.hass), + ) + + return await super().async_step_user(user_input) + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict: + """Extra data that needs to be appended to the authorize url.""" + return {"scope": "api"} diff --git a/homeassistant/components/ondilo_ico/const.py b/homeassistant/components/ondilo_ico/const.py new file mode 100644 index 00000000000..3c947776857 --- /dev/null +++ b/homeassistant/components/ondilo_ico/const.py @@ -0,0 +1,8 @@ +"""Constants for the Ondilo ICO integration.""" + +DOMAIN = "ondilo_ico" + +OAUTH2_AUTHORIZE = "https://interop.ondilo.com/oauth2/authorize" +OAUTH2_TOKEN = "https://interop.ondilo.com/oauth2/token" +OAUTH2_CLIENTID = "customer_api" +OAUTH2_CLIENTSECRET = "" diff --git a/homeassistant/components/ondilo_ico/manifest.json b/homeassistant/components/ondilo_ico/manifest.json new file mode 100644 index 00000000000..55585b2c766 --- /dev/null +++ b/homeassistant/components/ondilo_ico/manifest.json @@ -0,0 +1,18 @@ +{ + "domain": "ondilo_ico", + "name": "Ondilo ICO", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ondilo_ico", + "requirements": [ + "ondilo==0.2.0" + ], + "ssdp": [], + "zeroconf": [], + "homekit": {}, + "dependencies": [ + "http" + ], + "codeowners": [ + "@JeromeHXP" + ] +} \ No newline at end of file diff --git a/homeassistant/components/ondilo_ico/oauth_impl.py b/homeassistant/components/ondilo_ico/oauth_impl.py new file mode 100644 index 00000000000..d6072cd6f6f --- /dev/null +++ b/homeassistant/components/ondilo_ico/oauth_impl.py @@ -0,0 +1,32 @@ +"""Local implementation of OAuth2 specific to Ondilo to hard code client id and secret and return a proper name.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import LocalOAuth2Implementation + +from .const import ( + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_CLIENTID, + OAUTH2_CLIENTSECRET, + OAUTH2_TOKEN, +) + + +class OndiloOauth2Implementation(LocalOAuth2Implementation): + """Local implementation of OAuth2 specific to Ondilo to hard code client id and secret and return a proper name.""" + + def __init__(self, hass: HomeAssistant): + """Just init default class with default values.""" + super().__init__( + hass, + DOMAIN, + OAUTH2_CLIENTID, + OAUTH2_CLIENTSECRET, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, + ) + + @property + def name(self) -> str: + """Name of the implementation.""" + return "Ondilo" diff --git a/homeassistant/components/ondilo_ico/sensor.py b/homeassistant/components/ondilo_ico/sensor.py new file mode 100644 index 00000000000..4ed8656c456 --- /dev/null +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -0,0 +1,185 @@ +"""Platform for sensor integration.""" +import asyncio +from datetime import timedelta +import logging + +from ondilo import OndiloError + +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_SIGNAL_STRENGTH, + DEVICE_CLASS_TEMPERATURE, + PERCENTAGE, + TEMP_CELSIUS, +) +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) + +from .const import DOMAIN + +SENSOR_TYPES = { + "temperature": [ + "Temperature", + TEMP_CELSIUS, + "mdi:thermometer", + DEVICE_CLASS_TEMPERATURE, + ], + "orp": ["Oxydo Reduction Potential", "mV", "mdi:pool", None], + "ph": ["pH", "", "mdi:pool", None], + "tds": ["TDS", CONCENTRATION_PARTS_PER_MILLION, "mdi:pool", None], + "battery": ["Battery", PERCENTAGE, "mdi:battery", DEVICE_CLASS_BATTERY], + "rssi": [ + "RSSI", + PERCENTAGE, + "mdi:wifi-strength-2", + DEVICE_CLASS_SIGNAL_STRENGTH, + ], + "salt": ["Salt", "mg/L", "mdi:pool", None], +} + +SCAN_INTERVAL = timedelta(hours=1) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the Ondilo ICO sensors.""" + + api = hass.data[DOMAIN][entry.entry_id] + + def get_all_pool_data(pool): + """Add pool details and last measures to pool data.""" + pool["ICO"] = api.get_ICO_details(pool["id"]) + pool["sensors"] = api.get_last_pool_measures(pool["id"]) + + return pool + + async def async_update_data(): + """Fetch data from API endpoint. + + This is the place to pre-process the data to lookup tables + so entities can quickly look up their data. + """ + try: + pools = await hass.async_add_executor_job(api.get_pools) + + return await asyncio.gather( + *[ + hass.async_add_executor_job(get_all_pool_data, pool) + for pool in pools + ] + ) + + except OndiloError as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name="sensor", + update_method=async_update_data, + # Polling interval. Will only be polled if there are subscribers. + update_interval=SCAN_INTERVAL, + ) + + # Fetch initial data so we have data when entities subscribe + await coordinator.async_refresh() + + entities = [] + for poolidx, pool in enumerate(coordinator.data): + for sensor_idx, sensor in enumerate(pool["sensors"]): + if sensor["data_type"] in SENSOR_TYPES: + entities.append(OndiloICO(coordinator, poolidx, sensor_idx)) + + async_add_entities(entities) + + +class OndiloICO(CoordinatorEntity): + """Representation of a Sensor.""" + + def __init__( + self, coordinator: DataUpdateCoordinator, poolidx: int, sensor_idx: int + ): + """Initialize sensor entity with data from coordinator.""" + super().__init__(coordinator) + + self._poolid = self.coordinator.data[poolidx]["id"] + + pooldata = self._pooldata() + self._data_type = pooldata["sensors"][sensor_idx]["data_type"] + self._unique_id = f"{pooldata['ICO']['serial_number']}-{self._data_type}" + self._device_name = pooldata["name"] + self._name = f"{self._device_name} {SENSOR_TYPES[self._data_type][0]}" + self._device_class = SENSOR_TYPES[self._data_type][3] + self._icon = SENSOR_TYPES[self._data_type][2] + self._unit = SENSOR_TYPES[self._data_type][1] + + def _pooldata(self): + """Get pool data dict.""" + return next( + (pool for pool in self.coordinator.data if pool["id"] == self._poolid), + None, + ) + + def _devdata(self): + """Get device data dict.""" + return next( + ( + data_type + for data_type in self._pooldata()["sensors"] + if data_type["data_type"] == self._data_type + ), + None, + ) + + @property + def name(self): + """Name of the sensor.""" + return self._name + + @property + def state(self): + """Last value of the sensor.""" + _LOGGER.debug( + "Retrieving Ondilo sensor %s state value: %s", + self._name, + self._devdata()["value"], + ) + return self._devdata()["value"] + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + @property + def device_class(self): + """Return the device class of the sensor.""" + return self._device_class + + @property + def unit_of_measurement(self): + """Return the Unit of the sensor's measurement.""" + return self._unit + + @property + def unique_id(self): + """Return the unique ID of this entity.""" + return self._unique_id + + @property + def device_info(self): + """Return the device info for the sensor.""" + pooldata = self._pooldata() + return { + "identifiers": {(DOMAIN, pooldata["ICO"]["serial_number"])}, + "name": self._device_name, + "manufacturer": "Ondilo", + "model": "ICO", + "sw_version": pooldata["ICO"]["sw_version"], + } diff --git a/homeassistant/components/ondilo_ico/strings.json b/homeassistant/components/ondilo_ico/strings.json new file mode 100644 index 00000000000..3606bfec5ef --- /dev/null +++ b/homeassistant/components/ondilo_ico/strings.json @@ -0,0 +1,17 @@ +{ + "title": "Ondilo ICO", + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + } + }, + "abort": { + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ondilo_ico/translations/en.json b/homeassistant/components/ondilo_ico/translations/en.json new file mode 100644 index 00000000000..c88a152ef81 --- /dev/null +++ b/homeassistant/components/ondilo_ico/translations/en.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorize URL.", + "missing_configuration": "The component is not configured. Please follow the documentation." + }, + "create_entry": { + "default": "Successfully authenticated" + }, + "step": { + "pick_implementation": { + "title": "Pick Authentication Method" + } + } + }, + "title": "Ondilo ICO" +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b94b4deee94..43e0647a258 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -140,6 +140,7 @@ FLOWS = [ "nws", "nzbget", "omnilogic", + "ondilo_ico", "onewire", "onvif", "opentherm_gw", diff --git a/requirements_all.txt b/requirements_all.txt index 43f36695a51..316f7f91a55 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1033,6 +1033,9 @@ oemthermostat==1.1.1 # homeassistant.components.omnilogic omnilogic==0.4.2 +# homeassistant.components.ondilo_ico +ondilo==0.2.0 + # homeassistant.components.onkyo onkyo-eiscp==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9e0f02eeaa3..4e8606308d8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -513,6 +513,9 @@ objgraph==3.4.1 # homeassistant.components.omnilogic omnilogic==0.4.2 +# homeassistant.components.ondilo_ico +ondilo==0.2.0 + # homeassistant.components.onvif onvif-zeep-async==1.0.0 diff --git a/tests/components/ondilo_ico/__init__.py b/tests/components/ondilo_ico/__init__.py new file mode 100644 index 00000000000..12d8d3e2b9f --- /dev/null +++ b/tests/components/ondilo_ico/__init__.py @@ -0,0 +1 @@ +"""Tests for the Ondilo ICO integration.""" diff --git a/tests/components/ondilo_ico/test_config_flow.py b/tests/components/ondilo_ico/test_config_flow.py new file mode 100644 index 00000000000..b7505a85b3d --- /dev/null +++ b/tests/components/ondilo_ico/test_config_flow.py @@ -0,0 +1,88 @@ +"""Test the Ondilo ICO config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.ondilo_ico import config_flow +from homeassistant.components.ondilo_ico.const import ( + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_CLIENTID, + OAUTH2_CLIENTSECRET, + OAUTH2_TOKEN, +) +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.helpers import config_entry_oauth2_flow + +from tests.common import MockConfigEntry + +CLIENT_ID = OAUTH2_CLIENTID +CLIENT_SECRET = OAUTH2_CLIENTSECRET + + +async def test_abort_if_existing_entry(hass): + """Check flow abort when an entry already exist.""" + MockConfigEntry(domain=DOMAIN).add_to_hass(hass) + + flow = config_flow.OAuth2FlowHandler() + flow.hass = hass + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_full_flow( + hass, aiohttp_client, aioclient_mock, current_request_with_host +): + """Check full flow.""" + assert await setup.async_setup_component( + hass, + DOMAIN, + { + DOMAIN: {CONF_CLIENT_ID: CLIENT_ID, CONF_CLIENT_SECRET: CLIENT_SECRET}, + "http": {"base_url": "https://example.com"}, + }, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=api" + ) + + client = await aiohttp_client(hass.http.app) + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.ondilo_ico.async_setup_entry", return_value=True + ) as mock_setup: + await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1