From cfe6c8939c889f702df2dadeb881ccfe822a1fb5 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 2 Aug 2022 14:49:46 +0200 Subject: [PATCH] Add Open Exchange Rates coordinator (#76017) * Add Open Exchange Rates coordinator * Move debug log * Fix update interval calculation --- .coveragerc | 2 +- CODEOWNERS | 1 + .../components/openexchangerates/const.py | 7 + .../openexchangerates/coordinator.py | 47 ++++++ .../openexchangerates/manifest.json | 3 +- .../components/openexchangerates/sensor.py | 139 +++++++++--------- requirements_all.txt | 3 + 7 files changed, 133 insertions(+), 69 deletions(-) create mode 100644 homeassistant/components/openexchangerates/const.py create mode 100644 homeassistant/components/openexchangerates/coordinator.py diff --git a/.coveragerc b/.coveragerc index d529cdbd9ca..3c587e11cf6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -850,7 +850,7 @@ omit = homeassistant/components/open_meteo/weather.py homeassistant/components/opencv/* homeassistant/components/openevse/sensor.py - homeassistant/components/openexchangerates/sensor.py + homeassistant/components/openexchangerates/* homeassistant/components/opengarage/__init__.py homeassistant/components/opengarage/binary_sensor.py homeassistant/components/opengarage/cover.py diff --git a/CODEOWNERS b/CODEOWNERS index ce31d6e8dd3..16523cafa81 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -766,6 +766,7 @@ build.json @home-assistant/supervisor /tests/components/open_meteo/ @frenck /homeassistant/components/openerz/ @misialq /tests/components/openerz/ @misialq +/homeassistant/components/openexchangerates/ @MartinHjelmare /homeassistant/components/opengarage/ @danielhiversen /tests/components/opengarage/ @danielhiversen /homeassistant/components/openhome/ @bazwilliams diff --git a/homeassistant/components/openexchangerates/const.py b/homeassistant/components/openexchangerates/const.py new file mode 100644 index 00000000000..2c037887489 --- /dev/null +++ b/homeassistant/components/openexchangerates/const.py @@ -0,0 +1,7 @@ +"""Provide common constants for Open Exchange Rates.""" +from datetime import timedelta +import logging + +DOMAIN = "openexchangerates" +LOGGER = logging.getLogger(__package__) +BASE_UPDATE_INTERVAL = timedelta(hours=2) diff --git a/homeassistant/components/openexchangerates/coordinator.py b/homeassistant/components/openexchangerates/coordinator.py new file mode 100644 index 00000000000..0106edcd751 --- /dev/null +++ b/homeassistant/components/openexchangerates/coordinator.py @@ -0,0 +1,47 @@ +"""Provide an OpenExchangeRates data coordinator.""" +from __future__ import annotations + +import asyncio +from datetime import timedelta + +from aiohttp import ClientSession +from aioopenexchangerates import Client, Latest, OpenExchangeRatesClientError +import async_timeout + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + +TIMEOUT = 10 + + +class OpenexchangeratesCoordinator(DataUpdateCoordinator[Latest]): + """Represent a coordinator for Open Exchange Rates API.""" + + def __init__( + self, + hass: HomeAssistant, + session: ClientSession, + api_key: str, + base: str, + update_interval: timedelta, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, LOGGER, name=f"{DOMAIN} base {base}", update_interval=update_interval + ) + self.base = base + self.client = Client(api_key, session) + self.setup_lock = asyncio.Lock() + + async def _async_update_data(self) -> Latest: + """Update data from Open Exchange Rates.""" + try: + async with async_timeout.timeout(TIMEOUT): + latest = await self.client.get_latest(base=self.base) + except (OpenExchangeRatesClientError) as err: + raise UpdateFailed(err) from err + + LOGGER.debug("Result: %s", latest) + return latest diff --git a/homeassistant/components/openexchangerates/manifest.json b/homeassistant/components/openexchangerates/manifest.json index 43c45b6b665..a795eaf8d5e 100644 --- a/homeassistant/components/openexchangerates/manifest.json +++ b/homeassistant/components/openexchangerates/manifest.json @@ -2,6 +2,7 @@ "domain": "openexchangerates", "name": "Open Exchange Rates", "documentation": "https://www.home-assistant.io/integrations/openexchangerates", - "codeowners": [], + "requirements": ["aioopenexchangerates==0.3.0"], + "codeowners": ["@MartinHjelmare"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/openexchangerates/sensor.py b/homeassistant/components/openexchangerates/sensor.py index 318cae4ae0e..337cd3050ac 100644 --- a/homeassistant/components/openexchangerates/sensor.py +++ b/homeassistant/components/openexchangerates/sensor.py @@ -1,32 +1,28 @@ """Support for openexchangerates.org exchange rates service.""" from __future__ import annotations -from datetime import timedelta -from http import HTTPStatus -import logging -from typing import Any +from dataclasses import dataclass, field -import requests import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import CONF_API_KEY, CONF_BASE, CONF_NAME, CONF_QUOTE from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle +from homeassistant.helpers.update_coordinator import CoordinatorEntity -_LOGGER = logging.getLogger(__name__) -_RESOURCE = "https://openexchangerates.org/api/latest.json" +from .const import BASE_UPDATE_INTERVAL, DOMAIN, LOGGER +from .coordinator import OpenexchangeratesCoordinator ATTRIBUTION = "Data provided by openexchangerates.org" DEFAULT_BASE = "USD" DEFAULT_NAME = "Exchange Rate Sensor" -MIN_TIME_BETWEEN_UPDATES = timedelta(hours=2) - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_API_KEY): cv.string, @@ -37,10 +33,19 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +@dataclass +class DomainData: + """Data structure to hold data for this domain.""" + + coordinators: dict[tuple[str, str], OpenexchangeratesCoordinator] = field( + default_factory=dict, init=False + ) + + +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Open Exchange Rates sensor.""" @@ -49,75 +54,75 @@ def setup_platform( base: str = config[CONF_BASE] quote: str = config[CONF_QUOTE] - parameters = {"base": base, "app_id": api_key} + integration_data: DomainData = hass.data.setdefault(DOMAIN, DomainData()) + coordinators = integration_data.coordinators - rest = OpenexchangeratesData(_RESOURCE, parameters, quote) - response = requests.get(_RESOURCE, params=parameters, timeout=10) + if (api_key, base) not in coordinators: + # Create one coordinator per base currency per API key. + update_interval = BASE_UPDATE_INTERVAL * ( + len( + { + coordinator_base + for coordinator_api_key, coordinator_base in coordinators + if coordinator_api_key == api_key + } + ) + + 1 + ) + coordinator = coordinators[api_key, base] = OpenexchangeratesCoordinator( + hass, + async_get_clientsession(hass), + api_key, + base, + update_interval, + ) - if response.status_code != HTTPStatus.OK: - _LOGGER.error("Check your OpenExchangeRates API key") - return + LOGGER.debug( + "Coordinator update interval set to: %s", coordinator.update_interval + ) - rest.update() - add_entities([OpenexchangeratesSensor(rest, name, quote)], True) + # Set new interval on all coordinators for this API key. + for ( + coordinator_api_key, + _, + ), coordinator in coordinators.items(): + if coordinator_api_key == api_key: + coordinator.update_interval = update_interval + + coordinator = coordinators[api_key, base] + async with coordinator.setup_lock: + # We need to make sure that the coordinator data is ready. + if not coordinator.data: + await coordinator.async_refresh() + + if not coordinator.last_update_success: + raise PlatformNotReady + + async_add_entities([OpenexchangeratesSensor(coordinator, name, quote)]) -class OpenexchangeratesSensor(SensorEntity): +class OpenexchangeratesSensor( + CoordinatorEntity[OpenexchangeratesCoordinator], SensorEntity +): """Representation of an Open Exchange Rates sensor.""" _attr_attribution = ATTRIBUTION - def __init__(self, rest: OpenexchangeratesData, name: str, quote: str) -> None: + def __init__( + self, coordinator: OpenexchangeratesCoordinator, name: str, quote: str + ) -> None: """Initialize the sensor.""" - self.rest = rest - self._name = name + super().__init__(coordinator) + self._attr_name = name self._quote = quote - self._state: float | None = None + self._attr_native_unit_of_measurement = quote @property - def name(self) -> str: - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self) -> float | None: + def native_value(self) -> float: """Return the state of the sensor.""" - return self._state + return round(self.coordinator.data.rates[self._quote], 4) @property - def extra_state_attributes(self) -> dict[str, Any] | None: + def extra_state_attributes(self) -> dict[str, float]: """Return other attributes of the sensor.""" - attr = self.rest.data - - return attr - - def update(self) -> None: - """Update current conditions.""" - self.rest.update() - if (value := self.rest.data) is None: - self._attr_available = False - return - - self._attr_available = True - self._state = round(value[self._quote], 4) - - -class OpenexchangeratesData: - """Get data from Openexchangerates.org.""" - - def __init__(self, resource: str, parameters: dict[str, str], quote: str) -> None: - """Initialize the data object.""" - self._resource = resource - self._parameters = parameters - self._quote = quote - self.data: dict[str, Any] | None = None - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self) -> None: - """Get the latest data from openexchangerates.org.""" - try: - result = requests.get(self._resource, params=self._parameters, timeout=10) - self.data = result.json()["rates"] - except requests.exceptions.HTTPError: - _LOGGER.error("Check the Openexchangerates API key") - self.data = None + return self.coordinator.data.rates diff --git a/requirements_all.txt b/requirements_all.txt index df7bb65f9da..11136a0702b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -219,6 +219,9 @@ aionotion==3.0.2 # homeassistant.components.oncue aiooncue==0.3.4 +# homeassistant.components.openexchangerates +aioopenexchangerates==0.3.0 + # homeassistant.components.acmeda aiopulse==0.4.3