mirror of
https://github.com/home-assistant/core.git
synced 2025-06-25 01:21:51 +02:00
Add openexchangerates config flow (#76390)
This commit is contained in:
@ -851,7 +851,9 @@ omit =
|
||||
homeassistant/components/open_meteo/weather.py
|
||||
homeassistant/components/opencv/*
|
||||
homeassistant/components/openevse/sensor.py
|
||||
homeassistant/components/openexchangerates/*
|
||||
homeassistant/components/openexchangerates/__init__.py
|
||||
homeassistant/components/openexchangerates/coordinator.py
|
||||
homeassistant/components/openexchangerates/sensor.py
|
||||
homeassistant/components/opengarage/__init__.py
|
||||
homeassistant/components/opengarage/binary_sensor.py
|
||||
homeassistant/components/opengarage/cover.py
|
||||
|
@ -767,6 +767,7 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/openerz/ @misialq
|
||||
/tests/components/openerz/ @misialq
|
||||
/homeassistant/components/openexchangerates/ @MartinHjelmare
|
||||
/tests/components/openexchangerates/ @MartinHjelmare
|
||||
/homeassistant/components/opengarage/ @danielhiversen
|
||||
/tests/components/opengarage/ @danielhiversen
|
||||
/homeassistant/components/openhome/ @bazwilliams
|
||||
|
@ -1 +1,61 @@
|
||||
"""The openexchangerates component."""
|
||||
"""The Open Exchange Rates integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY, CONF_BASE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import BASE_UPDATE_INTERVAL, DOMAIN, LOGGER
|
||||
from .coordinator import OpenexchangeratesCoordinator
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Open Exchange Rates from a config entry."""
|
||||
api_key: str = entry.data[CONF_API_KEY]
|
||||
base: str = entry.data[CONF_BASE]
|
||||
|
||||
# Create one coordinator per base currency per API key.
|
||||
existing_coordinators: dict[str, OpenexchangeratesCoordinator] = hass.data.get(
|
||||
DOMAIN, {}
|
||||
)
|
||||
existing_coordinator_for_api_key = {
|
||||
existing_coordinator
|
||||
for config_entry_id, existing_coordinator in existing_coordinators.items()
|
||||
if (config_entry := hass.config_entries.async_get_entry(config_entry_id))
|
||||
and config_entry.data[CONF_API_KEY] == api_key
|
||||
}
|
||||
|
||||
# Adjust update interval by coordinators per API key.
|
||||
update_interval = BASE_UPDATE_INTERVAL * (len(existing_coordinator_for_api_key) + 1)
|
||||
coordinator = OpenexchangeratesCoordinator(
|
||||
hass,
|
||||
async_get_clientsession(hass),
|
||||
api_key,
|
||||
base,
|
||||
update_interval,
|
||||
)
|
||||
|
||||
LOGGER.debug("Coordinator update interval set to: %s", update_interval)
|
||||
|
||||
# Set new interval on all coordinators for this API key.
|
||||
for existing_coordinator in existing_coordinator_for_api_key:
|
||||
existing_coordinator.update_interval = update_interval
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(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
|
||||
|
132
homeassistant/components/openexchangerates/config_flow.py
Normal file
132
homeassistant/components/openexchangerates/config_flow.py
Normal file
@ -0,0 +1,132 @@
|
||||
"""Config flow for Open Exchange Rates integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from aioopenexchangerates import (
|
||||
Client,
|
||||
OpenExchangeRatesAuthError,
|
||||
OpenExchangeRatesClientError,
|
||||
)
|
||||
import async_timeout
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_API_KEY, CONF_BASE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import AbortFlow, FlowResult
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import CLIENT_TIMEOUT, DEFAULT_BASE, DOMAIN, LOGGER
|
||||
|
||||
|
||||
def get_data_schema(
|
||||
currencies: dict[str, str], existing_data: Mapping[str, str]
|
||||
) -> vol.Schema:
|
||||
"""Return a form schema."""
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
vol.Optional(
|
||||
CONF_BASE, default=existing_data.get(CONF_BASE) or DEFAULT_BASE
|
||||
): vol.In(currencies),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, str]:
|
||||
"""Validate the user input allows us to connect."""
|
||||
client = Client(data[CONF_API_KEY], async_get_clientsession(hass))
|
||||
|
||||
async with async_timeout.timeout(CLIENT_TIMEOUT):
|
||||
await client.get_latest(base=data[CONF_BASE])
|
||||
|
||||
return {"title": data[CONF_BASE]}
|
||||
|
||||
|
||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Open Exchange Rates."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self.currencies: dict[str, str] = {}
|
||||
self._reauth_entry: config_entries.ConfigEntry | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle the initial step."""
|
||||
currencies = await self.async_get_currencies()
|
||||
|
||||
if user_input is None:
|
||||
existing_data: Mapping[str, str] | dict[str, str] = (
|
||||
self._reauth_entry.data if self._reauth_entry else {}
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=get_data_schema(currencies, existing_data)
|
||||
)
|
||||
|
||||
errors = {}
|
||||
|
||||
try:
|
||||
info = await validate_input(self.hass, user_input)
|
||||
except OpenExchangeRatesAuthError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except OpenExchangeRatesClientError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except asyncio.TimeoutError:
|
||||
errors["base"] = "timeout_connect"
|
||||
except Exception: # pylint: disable=broad-except
|
||||
LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
self._async_abort_entries_match(
|
||||
{
|
||||
CONF_API_KEY: user_input[CONF_API_KEY],
|
||||
CONF_BASE: user_input[CONF_BASE],
|
||||
}
|
||||
)
|
||||
|
||||
if self._reauth_entry is not None:
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self._reauth_entry, data=self._reauth_entry.data | user_input
|
||||
)
|
||||
await self.hass.config_entries.async_reload(self._reauth_entry.entry_id)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
return self.async_create_entry(title=info["title"], data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=get_data_schema(currencies, user_input),
|
||||
description_placeholders={"signup": "https://openexchangerates.org/signup"},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
|
||||
"""Handle reauth."""
|
||||
self._reauth_entry = self.hass.config_entries.async_get_entry(
|
||||
self.context["entry_id"]
|
||||
)
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_get_currencies(self) -> dict[str, str]:
|
||||
"""Get the available currencies."""
|
||||
if not self.currencies:
|
||||
client = Client("dummy-api-key", async_get_clientsession(self.hass))
|
||||
try:
|
||||
async with async_timeout.timeout(CLIENT_TIMEOUT):
|
||||
self.currencies = await client.get_currencies()
|
||||
except OpenExchangeRatesClientError as err:
|
||||
raise AbortFlow("cannot_connect") from err
|
||||
except asyncio.TimeoutError as err:
|
||||
raise AbortFlow("timeout_connect") from err
|
||||
return self.currencies
|
||||
|
||||
async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult:
|
||||
"""Handle import from yaml/configuration."""
|
||||
return await self.async_step_user(import_config)
|
@ -5,3 +5,5 @@ import logging
|
||||
DOMAIN = "openexchangerates"
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
BASE_UPDATE_INTERVAL = timedelta(hours=2)
|
||||
CLIENT_TIMEOUT = 10
|
||||
DEFAULT_BASE = "USD"
|
||||
|
@ -1,19 +1,22 @@
|
||||
"""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
|
||||
from aioopenexchangerates import (
|
||||
Client,
|
||||
Latest,
|
||||
OpenExchangeRatesAuthError,
|
||||
OpenExchangeRatesClientError,
|
||||
)
|
||||
import async_timeout
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
TIMEOUT = 10
|
||||
from .const import CLIENT_TIMEOUT, DOMAIN, LOGGER
|
||||
|
||||
|
||||
class OpenexchangeratesCoordinator(DataUpdateCoordinator[Latest]):
|
||||
@ -33,14 +36,15 @@ class OpenexchangeratesCoordinator(DataUpdateCoordinator[Latest]):
|
||||
)
|
||||
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):
|
||||
async with async_timeout.timeout(CLIENT_TIMEOUT):
|
||||
latest = await self.client.get_latest(base=self.base)
|
||||
except (OpenExchangeRatesClientError) as err:
|
||||
except OpenExchangeRatesAuthError as err:
|
||||
raise ConfigEntryAuthFailed(err) from err
|
||||
except OpenExchangeRatesClientError as err:
|
||||
raise UpdateFailed(err) from err
|
||||
|
||||
LOGGER.debug("Result: %s", latest)
|
||||
|
@ -3,6 +3,8 @@
|
||||
"name": "Open Exchange Rates",
|
||||
"documentation": "https://www.home-assistant.io/integrations/openexchangerates",
|
||||
"requirements": ["aioopenexchangerates==0.4.0"],
|
||||
"dependencies": ["repairs"],
|
||||
"codeowners": ["@MartinHjelmare"],
|
||||
"iot_class": "cloud_polling"
|
||||
"iot_class": "cloud_polling",
|
||||
"config_flow": true
|
||||
}
|
||||
|
@ -1,26 +1,26 @@
|
||||
"""Support for openexchangerates.org exchange rates service."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.repairs.issue_handler import async_create_issue
|
||||
from homeassistant.components.repairs.models import IssueSeverity
|
||||
from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
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.device_registry import DeviceEntryType
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import BASE_UPDATE_INTERVAL, DOMAIN, LOGGER
|
||||
from .const import DEFAULT_BASE, DOMAIN, LOGGER
|
||||
from .coordinator import OpenexchangeratesCoordinator
|
||||
|
||||
ATTRIBUTION = "Data provided by openexchangerates.org"
|
||||
|
||||
DEFAULT_BASE = "USD"
|
||||
DEFAULT_NAME = "Exchange Rate Sensor"
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
@ -33,15 +33,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
@ -49,56 +40,48 @@ async def async_setup_platform(
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Open Exchange Rates sensor."""
|
||||
name: str = config[CONF_NAME]
|
||||
api_key: str = config[CONF_API_KEY]
|
||||
base: str = config[CONF_BASE]
|
||||
quote: str = config[CONF_QUOTE]
|
||||
|
||||
integration_data: DomainData = hass.data.setdefault(DOMAIN, DomainData())
|
||||
coordinators = integration_data.coordinators
|
||||
|
||||
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
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"deprecated_yaml",
|
||||
breaks_in_ha_version="2022.11.0",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_yaml",
|
||||
)
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_IMPORT},
|
||||
data=config,
|
||||
)
|
||||
coordinator = coordinators[api_key, base] = OpenexchangeratesCoordinator(
|
||||
hass,
|
||||
async_get_clientsession(hass),
|
||||
api_key,
|
||||
base,
|
||||
update_interval,
|
||||
)
|
||||
|
||||
LOGGER.warning(
|
||||
"Configuration of Open Exchange Rates integration in YAML is deprecated and "
|
||||
"will be removed in Home Assistant 2022.11.; Your existing configuration "
|
||||
"has been imported into the UI automatically and can be safely removed from"
|
||||
" your configuration.yaml file"
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Open Exchange Rates sensor."""
|
||||
# Only YAML imported configs have name and quote in config entry data.
|
||||
name: str | None = config_entry.data.get(CONF_NAME)
|
||||
quote: str = config_entry.data.get(CONF_QUOTE, "EUR")
|
||||
coordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||
|
||||
async_add_entities(
|
||||
OpenexchangeratesSensor(
|
||||
config_entry, coordinator, name, rate_quote, rate_quote == quote
|
||||
)
|
||||
|
||||
LOGGER.debug(
|
||||
"Coordinator update interval set to: %s", coordinator.update_interval
|
||||
)
|
||||
|
||||
# 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)])
|
||||
for rate_quote in coordinator.data.rates
|
||||
)
|
||||
|
||||
|
||||
class OpenexchangeratesSensor(
|
||||
@ -109,20 +92,35 @@ class OpenexchangeratesSensor(
|
||||
_attr_attribution = ATTRIBUTION
|
||||
|
||||
def __init__(
|
||||
self, coordinator: OpenexchangeratesCoordinator, name: str, quote: str
|
||||
self,
|
||||
config_entry: ConfigEntry,
|
||||
coordinator: OpenexchangeratesCoordinator,
|
||||
name: str | None,
|
||||
quote: str,
|
||||
enabled: bool,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_name = name
|
||||
self._quote = quote
|
||||
self._attr_device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN, config_entry.entry_id)},
|
||||
manufacturer="Open Exchange Rates",
|
||||
name=f"Open Exchange Rates {coordinator.base}",
|
||||
)
|
||||
self._attr_entity_registry_enabled_default = enabled
|
||||
if name and enabled:
|
||||
# name is legacy imported from YAML config
|
||||
# this block can be removed when removing import from YAML
|
||||
self._attr_name = name
|
||||
self._attr_has_entity_name = False
|
||||
else:
|
||||
self._attr_name = quote
|
||||
self._attr_has_entity_name = True
|
||||
self._attr_native_unit_of_measurement = quote
|
||||
self._attr_unique_id = f"{config_entry.entry_id}_{quote}"
|
||||
self._quote = quote
|
||||
|
||||
@property
|
||||
def native_value(self) -> float:
|
||||
"""Return the state of the sensor."""
|
||||
return round(self.coordinator.data.rates[self._quote], 4)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, float]:
|
||||
"""Return other attributes of the sensor."""
|
||||
return self.coordinator.data.rates
|
||||
|
33
homeassistant/components/openexchangerates/strings.json
Normal file
33
homeassistant/components/openexchangerates/strings.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"base": "Base currency"
|
||||
},
|
||||
"data_description": {
|
||||
"base": "Using another base currency than USD requires a [paid plan]({signup})."
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml": {
|
||||
"title": "The Open Exchange Rates YAML configuration is being removed",
|
||||
"description": "Configuring Open Exchange Rates using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Open Exchange Rates YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Service is already configured",
|
||||
"cannot_connect": "Failed to connect",
|
||||
"reauth_successful": "Re-authentication was successful",
|
||||
"timeout_connect": "Timeout establishing connection"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect",
|
||||
"invalid_auth": "Invalid authentication",
|
||||
"timeout_connect": "Timeout establishing connection",
|
||||
"unknown": "Unexpected error"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"api_key": "API Key",
|
||||
"base": "Base currency"
|
||||
},
|
||||
"data_description": {
|
||||
"base": "Using another base currency than USD requires a [paid plan]({signup})."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml": {
|
||||
"description": "Configuring Open Exchange Rates using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Open Exchange Rates YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue.",
|
||||
"title": "The Open Exchange Rates YAML configuration is being removed"
|
||||
}
|
||||
}
|
||||
}
|
@ -258,6 +258,7 @@ FLOWS = {
|
||||
"onewire",
|
||||
"onvif",
|
||||
"open_meteo",
|
||||
"openexchangerates",
|
||||
"opengarage",
|
||||
"opentherm_gw",
|
||||
"openuv",
|
||||
|
@ -191,6 +191,9 @@ aionotion==3.0.2
|
||||
# homeassistant.components.oncue
|
||||
aiooncue==0.3.4
|
||||
|
||||
# homeassistant.components.openexchangerates
|
||||
aioopenexchangerates==0.4.0
|
||||
|
||||
# homeassistant.components.acmeda
|
||||
aiopulse==0.4.3
|
||||
|
||||
|
1
tests/components/openexchangerates/__init__.py
Normal file
1
tests/components/openexchangerates/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the Open Exchange Rates integration."""
|
39
tests/components/openexchangerates/conftest.py
Normal file
39
tests/components/openexchangerates/conftest.py
Normal file
@ -0,0 +1,39 @@
|
||||
"""Provide common fixtures for tests."""
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.openexchangerates.const import DOMAIN
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
"""Return the default mocked config entry."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN, data={"api_key": "test-api-key", "base": "USD"}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
|
||||
"""Mock setting up a config entry."""
|
||||
with patch(
|
||||
"homeassistant.components.openexchangerates.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup:
|
||||
yield mock_setup
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_latest_rates_config_flow(
|
||||
request: pytest.FixtureRequest,
|
||||
) -> Generator[AsyncMock, None, None]:
|
||||
"""Return a mocked WLED client."""
|
||||
with patch(
|
||||
"homeassistant.components.openexchangerates.config_flow.Client.get_latest",
|
||||
) as mock_latest:
|
||||
mock_latest.return_value = {"EUR": 1.0}
|
||||
yield mock_latest
|
268
tests/components/openexchangerates/test_config_flow.py
Normal file
268
tests/components/openexchangerates/test_config_flow.py
Normal file
@ -0,0 +1,268 @@
|
||||
"""Test the Open Exchange Rates config flow."""
|
||||
import asyncio
|
||||
from collections.abc import Generator
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from aioopenexchangerates import (
|
||||
OpenExchangeRatesAuthError,
|
||||
OpenExchangeRatesClientError,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.openexchangerates.const import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture(name="currencies", autouse=True)
|
||||
def currencies_fixture(hass: HomeAssistant) -> Generator[AsyncMock, None, None]:
|
||||
"""Mock currencies."""
|
||||
with patch(
|
||||
"homeassistant.components.openexchangerates.config_flow.Client.get_currencies",
|
||||
return_value={"USD": "United States Dollar", "EUR": "Euro"},
|
||||
) as mock_currencies:
|
||||
yield mock_currencies
|
||||
|
||||
|
||||
async def test_user_create_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_latest_rates_config_flow: AsyncMock,
|
||||
mock_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test we get the form."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["errors"] is None
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{"api_key": "test-api-key"},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "USD"
|
||||
assert result["data"] == {
|
||||
"api_key": "test-api-key",
|
||||
"base": "USD",
|
||||
}
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_form_invalid_auth(
|
||||
hass: HomeAssistant,
|
||||
mock_latest_rates_config_flow: AsyncMock,
|
||||
) -> None:
|
||||
"""Test we handle invalid auth."""
|
||||
mock_latest_rates_config_flow.side_effect = OpenExchangeRatesAuthError()
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{"api_key": "bad-api-key"},
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "invalid_auth"}
|
||||
|
||||
|
||||
async def test_form_cannot_connect(
|
||||
hass: HomeAssistant,
|
||||
mock_latest_rates_config_flow: AsyncMock,
|
||||
) -> None:
|
||||
"""Test we handle cannot connect error."""
|
||||
mock_latest_rates_config_flow.side_effect = OpenExchangeRatesClientError()
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{"api_key": "test-api-key"},
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
|
||||
async def test_form_unknown_error(
|
||||
hass: HomeAssistant,
|
||||
mock_latest_rates_config_flow: AsyncMock,
|
||||
) -> None:
|
||||
"""Test we handle unknown error."""
|
||||
mock_latest_rates_config_flow.side_effect = Exception()
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{"api_key": "test-api-key"},
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "unknown"}
|
||||
|
||||
|
||||
async def test_already_configured_service(
|
||||
hass: HomeAssistant,
|
||||
mock_latest_rates_config_flow: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test we abort if the service is already configured."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["errors"] is None
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{"api_key": "test-api-key"},
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_no_currencies(hass: HomeAssistant, currencies: AsyncMock) -> None:
|
||||
"""Test we abort if the service fails to retrieve currencies."""
|
||||
currencies.side_effect = OpenExchangeRatesClientError()
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "cannot_connect"
|
||||
|
||||
|
||||
async def test_currencies_timeout(hass: HomeAssistant, currencies: AsyncMock) -> None:
|
||||
"""Test we abort if the service times out retrieving currencies."""
|
||||
|
||||
async def currencies_side_effect():
|
||||
await asyncio.sleep(1)
|
||||
return {"USD": "United States Dollar", "EUR": "Euro"}
|
||||
|
||||
currencies.side_effect = currencies_side_effect
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.openexchangerates.config_flow.CLIENT_TIMEOUT", 0
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "timeout_connect"
|
||||
|
||||
|
||||
async def test_latest_rates_timeout(
|
||||
hass: HomeAssistant,
|
||||
mock_latest_rates_config_flow: AsyncMock,
|
||||
) -> None:
|
||||
"""Test we abort if the service times out retrieving latest rates."""
|
||||
|
||||
async def latest_rates_side_effect(*args: Any, **kwargs: Any) -> dict[str, float]:
|
||||
await asyncio.sleep(1)
|
||||
return {"EUR": 1.0}
|
||||
|
||||
mock_latest_rates_config_flow.side_effect = latest_rates_side_effect
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.openexchangerates.config_flow.CLIENT_TIMEOUT", 0
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{"api_key": "test-api-key"},
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "timeout_connect"}
|
||||
|
||||
|
||||
async def test_reauth(
|
||||
hass: HomeAssistant,
|
||||
mock_latest_rates_config_flow: AsyncMock,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test we can reauthenticate the config entry."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
flow_context = {
|
||||
"source": config_entries.SOURCE_REAUTH,
|
||||
"entry_id": mock_config_entry.entry_id,
|
||||
"title_placeholders": {"name": mock_config_entry.title},
|
||||
"unique_id": mock_config_entry.unique_id,
|
||||
}
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context=flow_context, data=mock_config_entry.data
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["errors"] is None
|
||||
|
||||
mock_latest_rates_config_flow.side_effect = OpenExchangeRatesAuthError()
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"api_key": "invalid-test-api-key",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "invalid_auth"}
|
||||
|
||||
mock_latest_rates_config_flow.side_effect = None
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"api_key": "new-test-api-key",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == "abort"
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_import_create_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_latest_rates_config_flow: AsyncMock,
|
||||
mock_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test we can import data from configuration.yaml."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_IMPORT},
|
||||
data={
|
||||
"api_key": "test-api-key",
|
||||
"base": "USD",
|
||||
"quote": "EUR",
|
||||
"name": "test",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "USD"
|
||||
assert result["data"] == {
|
||||
"api_key": "test-api-key",
|
||||
"base": "USD",
|
||||
"quote": "EUR",
|
||||
"name": "test",
|
||||
}
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
Reference in New Issue
Block a user