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 checkpull/44139/head
parent
c92353088c
commit
de780c6d35
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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"}
|
|
@ -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 = ""
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -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"
|
|
@ -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"],
|
||||
}
|
|
@ -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%]"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -140,6 +140,7 @@ FLOWS = [
|
|||
"nws",
|
||||
"nzbget",
|
||||
"omnilogic",
|
||||
"ondilo_ico",
|
||||
"onewire",
|
||||
"onvif",
|
||||
"opentherm_gw",
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
"""Tests for the Ondilo ICO integration."""
|
|
@ -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
|
Loading…
Reference in New Issue