From fd1d110b80bfd902683ce3648d82d4a67ba24e96 Mon Sep 17 00:00:00 2001 From: Tom Brien Date: Mon, 28 Jun 2021 15:38:12 +0100 Subject: [PATCH] Add config flow for Coinbase (#45354) Co-authored-by: Franck Nijhof --- .coveragerc | 2 +- CODEOWNERS | 1 + homeassistant/components/coinbase/__init__.py | 142 ++++---- .../components/coinbase/config_flow.py | 211 +++++++++++ homeassistant/components/coinbase/const.py | 290 +++++++++++++++ .../components/coinbase/manifest.json | 11 +- homeassistant/components/coinbase/sensor.py | 124 +++++-- .../components/coinbase/strings.json | 40 +++ .../components/coinbase/translations/en.json | 40 +++ homeassistant/generated/config_flows.py | 1 + requirements_test_all.txt | 3 + tests/components/coinbase/__init__.py | 1 + tests/components/coinbase/common.py | 84 +++++ tests/components/coinbase/const.py | 33 ++ tests/components/coinbase/test_config_flow.py | 332 ++++++++++++++++++ tests/components/coinbase/test_init.py | 80 +++++ 16 files changed, 1299 insertions(+), 96 deletions(-) create mode 100644 homeassistant/components/coinbase/config_flow.py create mode 100644 homeassistant/components/coinbase/const.py create mode 100644 homeassistant/components/coinbase/strings.json create mode 100644 homeassistant/components/coinbase/translations/en.json create mode 100644 tests/components/coinbase/__init__.py create mode 100644 tests/components/coinbase/common.py create mode 100644 tests/components/coinbase/const.py create mode 100644 tests/components/coinbase/test_config_flow.py create mode 100644 tests/components/coinbase/test_init.py diff --git a/.coveragerc b/.coveragerc index 8ba4b81a0b8..4dc3df0ba41 100644 --- a/.coveragerc +++ b/.coveragerc @@ -154,7 +154,7 @@ omit = homeassistant/components/clicksend_tts/notify.py homeassistant/components/cmus/media_player.py homeassistant/components/co2signal/* - homeassistant/components/coinbase/* + homeassistant/components/coinbase/sensor.py homeassistant/components/comed_hourly_pricing/sensor.py homeassistant/components/comfoconnect/fan.py homeassistant/components/concord232/alarm_control_panel.py diff --git a/CODEOWNERS b/CODEOWNERS index 5352cc8e675..f47cf29d6c8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -88,6 +88,7 @@ homeassistant/components/cisco_webex_teams/* @fbradyirl homeassistant/components/climacell/* @raman325 homeassistant/components/cloud/* @home-assistant/cloud homeassistant/components/cloudflare/* @ludeeus @ctalkington +homeassistant/components/coinbase/* @tombrien homeassistant/components/color_extractor/* @GenericStudent homeassistant/components/comfoconnect/* @michaelarnauts homeassistant/components/compensation/* @Petro31 diff --git a/homeassistant/components/coinbase/__init__.py b/homeassistant/components/coinbase/__init__.py index 5bcd330c9bb..0351ddf19d1 100644 --- a/homeassistant/components/coinbase/__init__.py +++ b/homeassistant/components/coinbase/__init__.py @@ -1,4 +1,6 @@ -"""Support for Coinbase.""" +"""The Coinbase integration.""" +from __future__ import annotations + from datetime import timedelta import logging @@ -6,105 +8,121 @@ from coinbase.wallet.client import Client from coinbase.wallet.error import AuthenticationError import voluptuous as vol -from homeassistant.const import CONF_API_KEY +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.discovery import load_platform +from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle +from .const import ( + API_ACCOUNT_ID, + API_ACCOUNTS_DATA, + CONF_CURRENCIES, + CONF_EXCHANGE_RATES, + CONF_YAML_API_TOKEN, + DOMAIN, +) + _LOGGER = logging.getLogger(__name__) -DOMAIN = "coinbase" - -CONF_API_SECRET = "api_secret" -CONF_ACCOUNT_CURRENCIES = "account_balance_currencies" -CONF_EXCHANGE_CURRENCIES = "exchange_rate_currencies" - +PLATFORMS = ["sensor"] MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) -DATA_COINBASE = "coinbase_cache" CONFIG_SCHEMA = vol.Schema( + cv.deprecated(DOMAIN), { DOMAIN: vol.Schema( { vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_API_SECRET): cv.string, - vol.Optional(CONF_ACCOUNT_CURRENCIES): vol.All( + vol.Required(CONF_YAML_API_TOKEN): cv.string, + vol.Optional(CONF_CURRENCIES): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_EXCHANGE_RATES, default=[]): vol.All( cv.ensure_list, [cv.string] ), - vol.Optional(CONF_EXCHANGE_CURRENCIES, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - } + }, ) }, extra=vol.ALLOW_EXTRA, ) -def setup(hass, config): - """Set up the Coinbase component. - - Will automatically setup sensors to support - wallets discovered on the network. - """ - api_key = config[DOMAIN][CONF_API_KEY] - api_secret = config[DOMAIN][CONF_API_SECRET] - account_currencies = config[DOMAIN].get(CONF_ACCOUNT_CURRENCIES) - exchange_currencies = config[DOMAIN][CONF_EXCHANGE_CURRENCIES] - - hass.data[DATA_COINBASE] = coinbase_data = CoinbaseData(api_key, api_secret) - - if not hasattr(coinbase_data, "accounts"): - return False - for account in coinbase_data.accounts: - if account_currencies is None or account.currency in account_currencies: - load_platform(hass, "sensor", DOMAIN, {"account": account}, config) - for currency in exchange_currencies: - if currency not in coinbase_data.exchange_rates.rates: - _LOGGER.warning("Currency %s not found", currency) - continue - native = coinbase_data.exchange_rates.currency - load_platform( - hass, - "sensor", +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Coinbase component.""" + if DOMAIN not in config: + return True + hass.async_create_task( + hass.config_entries.flow.async_init( DOMAIN, - {"native_currency": native, "exchange_currency": currency}, - config, + context={"source": SOURCE_IMPORT}, + data=config[DOMAIN], ) + ) return True +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Coinbase from a config entry.""" + + client = await hass.async_add_executor_job( + Client, + entry.data[CONF_API_KEY], + entry.data[CONF_API_TOKEN], + ) + + hass.data.setdefault(DOMAIN, {}) + + hass.data[DOMAIN][entry.entry_id] = await hass.async_add_executor_job( + CoinbaseData, client + ) + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +def get_accounts(client): + """Handle paginated accounts.""" + response = client.get_accounts() + accounts = response[API_ACCOUNTS_DATA] + next_starting_after = response.pagination.next_starting_after + + while next_starting_after: + response = client.get_accounts(starting_after=next_starting_after) + accounts += response[API_ACCOUNTS_DATA] + next_starting_after = response.pagination.next_starting_after + + return accounts + + class CoinbaseData: """Get the latest data and update the states.""" - def __init__(self, api_key, api_secret): + def __init__(self, client): """Init the coinbase data object.""" - self.client = Client(api_key, api_secret) - self.update() + self.client = client + self.accounts = get_accounts(self.client) + self.exchange_rates = self.client.get_exchange_rates() + self.user_id = self.client.get_current_user()[API_ACCOUNT_ID] @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data from coinbase.""" try: - response = self.client.get_accounts() - accounts = response["data"] - - # Most of Coinbase's API seems paginated now (25 items per page, but first page has 24). - # This API gives a 'next_starting_after' property to send back as a 'starting_after' param. - # Their API documentation is not up to date when writing these lines (2021-05-20) - next_starting_after = response.pagination.next_starting_after - - while next_starting_after: - response = self.client.get_accounts(starting_after=next_starting_after) - accounts = accounts + response["data"] - next_starting_after = response.pagination.next_starting_after - - self.accounts = accounts - + self.accounts = get_accounts(self.client) self.exchange_rates = self.client.get_exchange_rates() except AuthenticationError as coinbase_error: _LOGGER.error( diff --git a/homeassistant/components/coinbase/config_flow.py b/homeassistant/components/coinbase/config_flow.py new file mode 100644 index 00000000000..24cbaaa32e0 --- /dev/null +++ b/homeassistant/components/coinbase/config_flow.py @@ -0,0 +1,211 @@ +"""Config flow for Coinbase integration.""" +import logging + +from coinbase.wallet.client import Client +from coinbase.wallet.error import AuthenticationError +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +from . import get_accounts +from .const import ( + API_ACCOUNT_CURRENCY, + API_RATES, + CONF_CURRENCIES, + CONF_EXCHANGE_RATES, + CONF_OPTIONS, + CONF_YAML_API_TOKEN, + DOMAIN, + RATES, + WALLETS, +) + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Required(CONF_API_TOKEN): str, + } +) + + +async def validate_api(hass: core.HomeAssistant, data): + """Validate the credentials.""" + + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.data[CONF_API_KEY] == data[CONF_API_KEY]: + raise AlreadyConfigured + try: + client = await hass.async_add_executor_job( + Client, data[CONF_API_KEY], data[CONF_API_TOKEN] + ) + user = await hass.async_add_executor_job(client.get_current_user) + except AuthenticationError as error: + raise InvalidAuth from error + except ConnectionError as error: + raise CannotConnect from error + + return {"title": user["name"]} + + +async def validate_options( + hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry, options +): + """Validate the requested resources are provided by API.""" + + client = hass.data[DOMAIN][config_entry.entry_id].client + + accounts = await hass.async_add_executor_job(get_accounts, client) + + accounts_currencies = [account[API_ACCOUNT_CURRENCY] for account in accounts] + available_rates = await hass.async_add_executor_job(client.get_exchange_rates) + if CONF_CURRENCIES in options: + for currency in options[CONF_CURRENCIES]: + if currency not in accounts_currencies: + raise CurrencyUnavaliable + + if CONF_EXCHANGE_RATES in options: + for rate in options[CONF_EXCHANGE_RATES]: + if rate not in available_rates[API_RATES]: + raise ExchangeRateUnavaliable + + return True + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Coinbase.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + options = {} + + if CONF_OPTIONS in user_input: + options = user_input.pop(CONF_OPTIONS) + + try: + info = await validate_api(self.hass, user_input) + except AlreadyConfigured: + return self.async_abort(reason="already_configured") + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=info["title"], data=user_input, options=options + ) + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, config): + """Handle import of Coinbase config from YAML.""" + cleaned_data = { + CONF_API_KEY: config[CONF_API_KEY], + CONF_API_TOKEN: config[CONF_YAML_API_TOKEN], + } + cleaned_data[CONF_OPTIONS] = { + CONF_CURRENCIES: [], + CONF_EXCHANGE_RATES: [], + } + if CONF_CURRENCIES in config: + cleaned_data[CONF_OPTIONS][CONF_CURRENCIES] = config[CONF_CURRENCIES] + if CONF_EXCHANGE_RATES in config: + cleaned_data[CONF_OPTIONS][CONF_EXCHANGE_RATES] = config[ + CONF_EXCHANGE_RATES + ] + + return await self.async_step_user(user_input=cleaned_data) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow for Coinbase.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the options.""" + + errors = {} + default_currencies = self.config_entry.options.get(CONF_CURRENCIES) + default_exchange_rates = self.config_entry.options.get(CONF_EXCHANGE_RATES) + + if user_input is not None: + # Pass back user selected options, even if bad + if CONF_CURRENCIES in user_input: + default_currencies = user_input[CONF_CURRENCIES] + + if CONF_EXCHANGE_RATES in user_input: + default_exchange_rates = user_input[CONF_EXCHANGE_RATES] + + try: + await validate_options(self.hass, self.config_entry, user_input) + except CurrencyUnavaliable: + errors["base"] = "currency_unavaliable" + except ExchangeRateUnavaliable: + errors["base"] = "exchange_rate_unavaliable" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_CURRENCIES, + default=default_currencies, + ): cv.multi_select(WALLETS), + vol.Optional( + CONF_EXCHANGE_RATES, + default=default_exchange_rates, + ): cv.multi_select(RATES), + } + ), + errors=errors, + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class AlreadyConfigured(exceptions.HomeAssistantError): + """Error to indicate Coinbase API Key is already configured.""" + + +class CurrencyUnavaliable(exceptions.HomeAssistantError): + """Error to indicate the requested currency resource is not provided by the API.""" + + +class ExchangeRateUnavaliable(exceptions.HomeAssistantError): + """Error to indicate the requested exchange rate resource is not provided by the API.""" diff --git a/homeassistant/components/coinbase/const.py b/homeassistant/components/coinbase/const.py new file mode 100644 index 00000000000..1f86c8026ec --- /dev/null +++ b/homeassistant/components/coinbase/const.py @@ -0,0 +1,290 @@ +"""Constants used for Coinbase.""" + +CONF_CURRENCIES = "account_balance_currencies" +CONF_EXCHANGE_RATES = "exchange_rate_currencies" +CONF_OPTIONS = "options" +DOMAIN = "coinbase" + +# These are constants used by the previous YAML configuration +CONF_YAML_API_TOKEN = "api_secret" + +# Constants for data returned by Coinbase API +API_ACCOUNT_AMOUNT = "amount" +API_ACCOUNT_BALANCE = "balance" +API_ACCOUNT_CURRENCY = "currency" +API_ACCOUNT_ID = "id" +API_ACCOUNT_NATIVE_BALANCE = "native_balance" +API_ACCOUNT_NAME = "name" +API_ACCOUNTS_DATA = "data" +API_RATES = "rates" + +WALLETS = { + "AAVE": "AAVE", + "ALGO": "ALGO", + "ATOM": "ATOM", + "BAL": "BAL", + "BAND": "BAND", + "BAT": "BAT", + "BCH": "BCH", + "BNT": "BNT", + "BSV": "BSV", + "BTC": "BTC", + "CGLD": "CLGD", + "CVC": "CVC", + "COMP": "COMP", + "DAI": "DAI", + "DNT": "DNT", + "EOS": "EOS", + "ETC": "ETC", + "ETH": "ETH", + "EUR": "EUR", + "FIL": "FIL", + "GBP": "GBP", + "GRT": "GRT", + "KNC": "KNC", + "LINK": "LINK", + "LRC": "LRC", + "LTC": "LTC", + "MANA": "MANA", + "MKR": "MKR", + "NMR": "NMR", + "NU": "NU", + "OMG": "OMG", + "OXT": "OXT", + "REP": "REP", + "REPV2": "REPV2", + "SNX": "SNX", + "UMA": "UMA", + "UNI": "UNI", + "USDC": "USDC", + "WBTC": "WBTC", + "XLM": "XLM", + "XRP": "XRP", + "XTZ": "XTZ", + "YFI": "YFI", + "ZRX": "ZRX", +} + +RATES = { + "1INCH": "1INCH", + "AAVE": "AAVE", + "ADA": "ADA", + "AED": "AED", + "AFN": "AFN", + "ALGO": "ALGO", + "ALL": "ALL", + "AMD": "AMD", + "ANG": "ANG", + "ANKR": "ANKR", + "AOA": "AOA", + "ARS": "ARS", + "ATOM": "ATOM", + "AUD": "AUD", + "AWG": "AWG", + "AZN": "AZN", + "BAL": "BAL", + "BAM": "BAM", + "BAND": "BAND", + "BAT": "BAT", + "BBD": "BBD", + "BCH": "BCH", + "BDT": "BDT", + "BGN": "BGN", + "BHD": "BHD", + "BIF": "BIF", + "BMD": "BMD", + "BND": "BND", + "BNT": "BNT", + "BOB": "BOB", + "BRL": "BRL", + "BSD": "BSD", + "BSV": "BSV", + "BTC": "BTC", + "BTN": "BTN", + "BWP": "BWP", + "BYN": "BYN", + "BYR": "BYR", + "BZD": "BZD", + "CAD": "CAD", + "CDF": "CDF", + "CGLD": "CGLD", + "CHF": "CHF", + "CLF": "CLF", + "CLP": "CLP", + "CNH": "CNH", + "CNY": "CNY", + "COMP": "COMP", + "COP": "COP", + "CRC": "CRC", + "CRV": "CRV", + "CUC": "CUC", + "CVC": "CVC", + "CVE": "CVE", + "CZK": "CZK", + "DAI": "DAI", + "DASH": "DASH", + "DJF": "DJF", + "DKK": "DKK", + "DNT": "DNT", + "DOP": "DOP", + "DZD": "DZD", + "EGP": "EGP", + "ENJ": "ENJ", + "EOS": "EOS", + "ERN": "ERN", + "ETB": "ETB", + "ETC": "ETC", + "ETH": "ETH", + "ETH2": "ETH2", + "EUR": "EUR", + "FIL": "FIL", + "FJD": "FJD", + "FKP": "FKP", + "FORTH": "FORTH", + "GBP": "GBP", + "GBX": "GBX", + "GEL": "GEL", + "GGP": "GGP", + "GHS": "GHS", + "GIP": "GIP", + "GMD": "GMD", + "GNF": "GNF", + "GRT": "GRT", + "GTQ": "GTQ", + "GYD": "GYD", + "HKD": "HKD", + "HNL": "HNL", + "HRK": "HRK", + "HTG": "HTG", + "HUF": "HUF", + "IDR": "IDR", + "ILS": "ILS", + "IMP": "IMP", + "INR": "INR", + "IQD": "IQD", + "ISK": "ISK", + "JEP": "JEP", + "JMD": "JMD", + "JOD": "JOD", + "JPY": "JPY", + "KES": "KES", + "KGS": "KGS", + "KHR": "KHR", + "KMF": "KMF", + "KNC": "KNC", + "KRW": "KRW", + "KWD": "KWD", + "KYD": "KYD", + "KZT": "KZT", + "LAK": "LAK", + "LBP": "LBP", + "LINK": "LINK", + "LKR": "LKR", + "LRC": "LRC", + "LRD": "LRD", + "LSL": "LSL", + "LTC": "LTC", + "LYD": "LYD", + "MAD": "MAD", + "MANA": "MANA", + "MATIC": "MATIC", + "MDL": "MDL", + "MGA": "MGA", + "MKD": "MKD", + "MKR": "MKR", + "MMK": "MMK", + "MNT": "MNT", + "MOP": "MOP", + "MRO": "MRO", + "MTL": "MTL", + "MUR": "MUR", + "MVR": "MVR", + "MWK": "MWK", + "MXN": "MXN", + "MYR": "MYR", + "MZN": "MZN", + "NAD": "NAD", + "NGN": "NGN", + "NIO": "NIO", + "NKN": "NKN", + "NMR": "NMR", + "NOK": "NOK", + "NPR": "NPR", + "NU": "NU", + "NZD": "NZD", + "OGN": "OGN", + "OMG": "OMG", + "OMR": "OMR", + "OXT": "OXT", + "PAB": "PAB", + "PEN": "PEN", + "PGK": "PGK", + "PHP": "PHP", + "PKR": "PKR", + "PLN": "PLN", + "PYG": "PYG", + "QAR": "QAR", + "REN": "REN", + "REP": "REP", + "RON": "RON", + "RSD": "RSD", + "RUB": "RUB", + "RWF": "RWF", + "SAR": "SAR", + "SBD": "SBD", + "SCR": "SCR", + "SEK": "SEK", + "SGD": "SGD", + "SHP": "SHP", + "SKL": "SKL", + "SLL": "SLL", + "SNX": "SNX", + "SOS": "SOS", + "SRD": "SRD", + "SSP": "SSP", + "STD": "STD", + "STORJ": "STORJ", + "SUSHI": "SUSHI", + "SVC": "SVC", + "SZL": "SZL", + "THB": "THB", + "TJS": "TJS", + "TMT": "TMT", + "TND": "TND", + "TOP": "TOP", + "TRY": "TRY", + "TTD": "TTD", + "TWD": "TWD", + "TZS": "TZS", + "UAH": "UAH", + "UGX": "UGX", + "UMA": "UMA", + "UNI": "UNI", + "USD": "USD", + "USDC": "USDC", + "UYU": "UYU", + "UZS": "UZS", + "VES": "VES", + "VND": "VND", + "VUV": "VUV", + "WBTC": "WBTC", + "WST": "WST", + "XAF": "XAF", + "XAG": "XAG", + "XAU": "XAU", + "XCD": "XCD", + "XDR": "XDR", + "XLM": "XLM", + "XOF": "XOF", + "XPD": "XPD", + "XPF": "XPF", + "XPT": "XPT", + "XTZ": "XTZ", + "YER": "YER", + "YFI": "YFI", + "ZAR": "ZAR", + "ZEC": "ZEC", + "ZMW": "ZMW", + "ZRX": "ZRX", + "ZWL": "ZWL", +} diff --git a/homeassistant/components/coinbase/manifest.json b/homeassistant/components/coinbase/manifest.json index 4579aecdd5b..aa056409786 100644 --- a/homeassistant/components/coinbase/manifest.json +++ b/homeassistant/components/coinbase/manifest.json @@ -2,7 +2,12 @@ "domain": "coinbase", "name": "Coinbase", "documentation": "https://www.home-assistant.io/integrations/coinbase", - "requirements": ["coinbase==2.1.0"], - "codeowners": [], + "requirements": [ + "coinbase==2.1.0" + ], + "codeowners": [ + "@tombrien" + ], + "config_flow": true, "iot_class": "cloud_polling" -} +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py index 3a0e689862f..b090add2ddd 100644 --- a/homeassistant/components/coinbase/sensor.py +++ b/homeassistant/components/coinbase/sensor.py @@ -1,7 +1,23 @@ """Support for Coinbase sensors.""" +import logging + from homeassistant.components.sensor import SensorEntity from homeassistant.const import ATTR_ATTRIBUTION +from .const import ( + API_ACCOUNT_AMOUNT, + API_ACCOUNT_BALANCE, + API_ACCOUNT_CURRENCY, + API_ACCOUNT_ID, + API_ACCOUNT_NAME, + API_ACCOUNT_NATIVE_BALANCE, + CONF_CURRENCIES, + CONF_EXCHANGE_RATES, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + ATTR_NATIVE_BALANCE = "Balance in native currency" CURRENCY_ICONS = { @@ -16,45 +32,79 @@ DEFAULT_COIN_ICON = "mdi:currency-usd-circle" ATTRIBUTION = "Data provided by coinbase.com" -DATA_COINBASE = "coinbase_cache" +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Coinbase sensor platform.""" + instance = hass.data[DOMAIN][config_entry.entry_id] + hass.async_add_executor_job(instance.update) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Coinbase sensors.""" - if discovery_info is None: - return - if "account" in discovery_info: - account = discovery_info["account"] - sensor = AccountSensor( - hass.data[DATA_COINBASE], account["name"], account["balance"]["currency"] - ) - if "exchange_currency" in discovery_info: - sensor = ExchangeRateSensor( - hass.data[DATA_COINBASE], - discovery_info["exchange_currency"], - discovery_info["native_currency"], - ) + entities = [] - add_entities([sensor], True) + provided_currencies = [ + account[API_ACCOUNT_CURRENCY] for account in instance.accounts + ] + + if CONF_CURRENCIES in config_entry.options: + desired_currencies = config_entry.options[CONF_CURRENCIES] + else: + desired_currencies = provided_currencies + + exchange_native_currency = instance.exchange_rates.currency + + for currency in desired_currencies: + if currency not in provided_currencies: + _LOGGER.warning( + "The currency %s is no longer provided by your account, please check " + "your settings in Coinbase's developer tools", + currency, + ) + break + entities.append(AccountSensor(instance, currency)) + + if CONF_EXCHANGE_RATES in config_entry.options: + for rate in config_entry.options[CONF_EXCHANGE_RATES]: + entities.append( + ExchangeRateSensor( + instance, + rate, + exchange_native_currency, + ) + ) + + async_add_entities(entities) class AccountSensor(SensorEntity): """Representation of a Coinbase.com sensor.""" - def __init__(self, coinbase_data, name, currency): + def __init__(self, coinbase_data, currency): """Initialize the sensor.""" self._coinbase_data = coinbase_data - self._name = f"Coinbase {name}" - self._state = None - self._unit_of_measurement = currency - self._native_balance = None - self._native_currency = None + self._currency = currency + for account in coinbase_data.accounts: + if account.currency == currency: + self._name = f"Coinbase {account[API_ACCOUNT_NAME]}" + self._id = f"coinbase-{account[API_ACCOUNT_ID]}" + self._state = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT] + self._unit_of_measurement = account[API_ACCOUNT_CURRENCY] + self._native_balance = account[API_ACCOUNT_NATIVE_BALANCE][ + API_ACCOUNT_AMOUNT + ] + self._native_currency = account[API_ACCOUNT_NATIVE_BALANCE][ + API_ACCOUNT_CURRENCY + ] + break @property def name(self): """Return the name of the sensor.""" return self._name + @property + def unique_id(self): + """Return the Unique ID of the sensor.""" + return self._id + @property def state(self): """Return the state of the sensor.""" @@ -82,10 +132,15 @@ class AccountSensor(SensorEntity): """Get the latest state of the sensor.""" self._coinbase_data.update() for account in self._coinbase_data.accounts: - if self._name == f"Coinbase {account['name']}": - self._state = account["balance"]["amount"] - self._native_balance = account["native_balance"]["amount"] - self._native_currency = account["native_balance"]["currency"] + if account.currency == self._currency: + self._state = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT] + self._native_balance = account[API_ACCOUNT_NATIVE_BALANCE][ + API_ACCOUNT_AMOUNT + ] + self._native_currency = account[API_ACCOUNT_NATIVE_BALANCE][ + API_ACCOUNT_CURRENCY + ] + break class ExchangeRateSensor(SensorEntity): @@ -96,7 +151,10 @@ class ExchangeRateSensor(SensorEntity): self._coinbase_data = coinbase_data self.currency = exchange_currency self._name = f"{exchange_currency} Exchange Rate" - self._state = None + self._id = f"{coinbase_data.user_id}-xe-{exchange_currency}" + self._state = round( + 1 / float(self._coinbase_data.exchange_rates.rates[self.currency]), 2 + ) self._unit_of_measurement = native_currency @property @@ -104,6 +162,11 @@ class ExchangeRateSensor(SensorEntity): """Return the name of the sensor.""" return self._name + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return self._id + @property def state(self): """Return the state of the sensor.""" @@ -127,5 +190,6 @@ class ExchangeRateSensor(SensorEntity): def update(self): """Get the latest state of the sensor.""" self._coinbase_data.update() - rate = self._coinbase_data.exchange_rates.rates[self.currency] - self._state = round(1 / float(rate), 2) + self._state = round( + 1 / float(self._coinbase_data.exchange_rates.rates[self.currency]), 2 + ) diff --git a/homeassistant/components/coinbase/strings.json b/homeassistant/components/coinbase/strings.json new file mode 100644 index 00000000000..5988f2a49a9 --- /dev/null +++ b/homeassistant/components/coinbase/strings.json @@ -0,0 +1,40 @@ +{ + "config": { + "step": { + "user": { + "title": "Coinbase API Key Details", + "description": "Please enter the details of your API key as provided by Coinbase. Separate multiple currencies with a comma (e.g., \"BTC, EUR\")", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "api_token": "API Secret", + "currencies": "Account Balance Currencies", + "exchange_rates": "Exchange Rates" + } + } + }, + "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_device%]" + } + }, + "options": { + "step": { + "init": { + "description": "Adjust Coinbase Options", + "data": { + "account_balance_currencies": "Wallet balances to report.", + "exchange_rate_currencies": "Exchange rates to report." + } + } + }, + "error": { + "unknown": "[%key:common::config_flow::error::unknown%]", + "currency_unavaliable": "One or more of the requested currency balances is not provided by your Coinbase API.", + "exchange_rate_unavaliable": "One or more of the requested exchange rates is not provided by Coinbase." + } + } +} diff --git a/homeassistant/components/coinbase/translations/en.json b/homeassistant/components/coinbase/translations/en.json new file mode 100644 index 00000000000..b72cd236fa4 --- /dev/null +++ b/homeassistant/components/coinbase/translations/en.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "api_key": "API Key", + "api_token": "API Secret", + "currencies": "Account Balance Currencies", + "exchange_rates": "Exchange Rates" + }, + "description": "Please enter the details of your API key as provided by Coinbase. Separate multiple currencies with a comma (e.g., \"BTC, EUR\")", + "title": "Coinbase API Key Details" + } + } + }, + "options": { + "error": { + "currency_unavaliable": "One or more of the requested currency balances is not provided by your Coinbase API.", + "exchange_rate_unavaliable": "One or more of the requested exchange rates is not provided by Coinbase.", + "unknown": "Unexpected error" + }, + "step": { + "init": { + "data": { + "account_balance_currencies": "Wallet balances to report.", + "exchange_rate_currencies": "Exchange rates to report." + }, + "description": "Adjust Coinbase Options" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 48545ff0e06..2f12ebd7d74 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -45,6 +45,7 @@ FLOWS = [ "cert_expiry", "climacell", "cloudflare", + "coinbase", "control4", "coolmaster", "coronavirus", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0743c2a6ccf..e1fc86963eb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -249,6 +249,9 @@ buienradar==1.0.4 # homeassistant.components.caldav caldav==0.7.1 +# homeassistant.components.coinbase +coinbase==2.1.0 + # homeassistant.scripts.check_config colorlog==5.0.1 diff --git a/tests/components/coinbase/__init__.py b/tests/components/coinbase/__init__.py new file mode 100644 index 00000000000..d3629804954 --- /dev/null +++ b/tests/components/coinbase/__init__.py @@ -0,0 +1 @@ +"""Tests for the Coinbase integration.""" diff --git a/tests/components/coinbase/common.py b/tests/components/coinbase/common.py new file mode 100644 index 00000000000..5fcab6605bd --- /dev/null +++ b/tests/components/coinbase/common.py @@ -0,0 +1,84 @@ +"""Collection of helpers.""" +from homeassistant.components.coinbase.const import ( + CONF_CURRENCIES, + CONF_EXCHANGE_RATES, + DOMAIN, +) +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN + +from .const import GOOD_EXCHNAGE_RATE, GOOD_EXCHNAGE_RATE_2, MOCK_ACCOUNTS_RESPONSE + +from tests.common import MockConfigEntry + + +class MockPagination: + """Mock pagination result.""" + + def __init__(self, value=None): + """Load simple pagination for tests.""" + self.next_starting_after = value + + +class MockGetAccounts: + """Mock accounts with pagination.""" + + def __init__(self, starting_after=0): + """Init mocked object, forced to return two at a time.""" + if (target_end := starting_after + 2) >= ( + max_end := len(MOCK_ACCOUNTS_RESPONSE) + ): + end = max_end + self.pagination = MockPagination(value=None) + else: + end = target_end + self.pagination = MockPagination(value=target_end) + + self.accounts = { + "data": MOCK_ACCOUNTS_RESPONSE[starting_after:end], + } + self.started_at = starting_after + + def __getitem__(self, item): + """Handle subscript request.""" + return self.accounts[item] + + +def mocked_get_accounts(_, **kwargs): + """Return simplied accounts using mock.""" + return MockGetAccounts(**kwargs) + + +def mock_get_current_user(): + """Return a simplified mock user.""" + return { + "id": "123456-abcdef", + "name": "Test User", + } + + +def mock_get_exchange_rates(): + """Return a heavily reduced mock list of exchange rates for testing.""" + return { + "currency": "USD", + "rates": {GOOD_EXCHNAGE_RATE_2: "0.109", GOOD_EXCHNAGE_RATE: "0.00002"}, + } + + +async def init_mock_coinbase(hass): + """Init Coinbase integration for testing.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="abcde12345", + title="Test User", + data={CONF_API_KEY: "123456", CONF_API_TOKEN: "AbCDeF"}, + options={ + CONF_CURRENCIES: [], + CONF_EXCHANGE_RATES: [], + }, + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/coinbase/const.py b/tests/components/coinbase/const.py new file mode 100644 index 00000000000..52505be514e --- /dev/null +++ b/tests/components/coinbase/const.py @@ -0,0 +1,33 @@ +"""Constants for testing the Coinbase integration.""" + +GOOD_CURRENCY = "BTC" +GOOD_CURRENCY_2 = "USD" +GOOD_CURRENCY_3 = "EUR" +GOOD_EXCHNAGE_RATE = "BTC" +GOOD_EXCHNAGE_RATE_2 = "ATOM" +BAD_CURRENCY = "ETH" +BAD_EXCHANGE_RATE = "ETH" + +MOCK_ACCOUNTS_RESPONSE = [ + { + "balance": {"amount": "13.38", "currency": GOOD_CURRENCY_3}, + "currency": "BTC", + "id": "ABCDEF", + "name": "BTC Wallet", + "native_balance": {"amount": "15.02", "currency": GOOD_CURRENCY_2}, + }, + { + "balance": {"amount": "0.00001", "currency": GOOD_CURRENCY}, + "currency": "BTC", + "id": "123456789", + "name": "BTC Wallet", + "native_balance": {"amount": "100.12", "currency": GOOD_CURRENCY_2}, + }, + { + "balance": {"amount": "9.90", "currency": GOOD_CURRENCY_2}, + "currency": "USD", + "id": "987654321", + "name": "USD Wallet", + "native_balance": {"amount": "9.90", "currency": GOOD_CURRENCY_2}, + }, +] diff --git a/tests/components/coinbase/test_config_flow.py b/tests/components/coinbase/test_config_flow.py new file mode 100644 index 00000000000..a03f423d852 --- /dev/null +++ b/tests/components/coinbase/test_config_flow.py @@ -0,0 +1,332 @@ +"""Test the Coinbase config flow.""" +from unittest.mock import patch + +from coinbase.wallet.error import AuthenticationError +from requests.models import Response + +from homeassistant import config_entries, setup +from homeassistant.components.coinbase.const import ( + CONF_CURRENCIES, + CONF_EXCHANGE_RATES, + CONF_YAML_API_TOKEN, + DOMAIN, +) +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN + +from .common import ( + init_mock_coinbase, + mock_get_current_user, + mock_get_exchange_rates, + mocked_get_accounts, +) +from .const import BAD_CURRENCY, BAD_EXCHANGE_RATE, GOOD_CURRENCY, GOOD_EXCHNAGE_RATE + +from tests.common import MockConfigEntry + + +async def test_form(hass): + """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"] == "form" + assert result["errors"] == {} + + with patch( + "coinbase.wallet.client.Client.get_current_user", + return_value=mock_get_current_user(), + ), patch( + "coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts + ), patch( + "coinbase.wallet.client.Client.get_exchange_rates", + return_value=mock_get_exchange_rates(), + ), patch( + "homeassistant.components.coinbase.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.coinbase.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "123456", + CONF_API_TOKEN: "AbCDeF", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "Test User" + assert result2["data"] == {CONF_API_KEY: "123456", CONF_API_TOKEN: "AbCDeF"} + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + response = Response() + response.status_code = 401 + api_auth_error = AuthenticationError( + response, + "authentication_error", + "invalid signature", + [{"id": "authentication_error", "message": "invalid signature"}], + ) + with patch( + "coinbase.wallet.client.Client.get_current_user", + side_effect=api_auth_error, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "123456", + CONF_API_TOKEN: "AbCDeF", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "coinbase.wallet.client.Client.get_current_user", + side_effect=ConnectionError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "123456", + CONF_API_TOKEN: "AbCDeF", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_catch_all_exception(hass): + """Test we handle unknown exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "coinbase.wallet.client.Client.get_current_user", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "123456", + CONF_API_TOKEN: "AbCDeF", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + +async def test_option_good_account_currency(hass): + """Test we handle a good wallet currency option.""" + with patch( + "coinbase.wallet.client.Client.get_current_user", + return_value=mock_get_current_user(), + ), patch( + "coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts + ), patch( + "coinbase.wallet.client.Client.get_exchange_rates", + return_value=mock_get_exchange_rates(), + ): + config_entry = await init_mock_coinbase(hass) + result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.async_block_till_done() + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_CURRENCIES: [GOOD_CURRENCY], + CONF_EXCHANGE_RATES: [], + }, + ) + assert result2["type"] == "create_entry" + + +async def test_form_bad_account_currency(hass): + """Test we handle a bad currency option.""" + with patch( + "coinbase.wallet.client.Client.get_current_user", + return_value=mock_get_current_user(), + ), patch( + "coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts + ), patch( + "coinbase.wallet.client.Client.get_exchange_rates", + return_value=mock_get_exchange_rates(), + ): + config_entry = await init_mock_coinbase(hass) + result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.async_block_till_done() + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_CURRENCIES: [BAD_CURRENCY], + CONF_EXCHANGE_RATES: [], + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "currency_unavaliable"} + + +async def test_option_good_exchange_rate(hass): + """Test we handle a good exchange rate option.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="abcde12345", + title="Test User", + data={CONF_API_KEY: "123456", CONF_API_TOKEN: "AbCDeF"}, + options={ + CONF_CURRENCIES: [], + CONF_EXCHANGE_RATES: [], + }, + ) + config_entry.add_to_hass(hass) + + with patch( + "coinbase.wallet.client.Client.get_current_user", + return_value=mock_get_current_user(), + ), patch( + "coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts + ), patch( + "coinbase.wallet.client.Client.get_exchange_rates", + return_value=mock_get_exchange_rates(), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.async_block_till_done() + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_CURRENCIES: [], + CONF_EXCHANGE_RATES: [GOOD_EXCHNAGE_RATE], + }, + ) + assert result2["type"] == "create_entry" + + +async def test_form_bad_exchange_rate(hass): + """Test we handle a bad exchange rate.""" + with patch( + "coinbase.wallet.client.Client.get_current_user", + return_value=mock_get_current_user(), + ), patch( + "coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts + ), patch( + "coinbase.wallet.client.Client.get_exchange_rates", + return_value=mock_get_exchange_rates(), + ): + config_entry = await init_mock_coinbase(hass) + result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.async_block_till_done() + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_CURRENCIES: [], + CONF_EXCHANGE_RATES: [BAD_EXCHANGE_RATE], + }, + ) + assert result2["type"] == "form" + assert result2["errors"] == {"base": "exchange_rate_unavaliable"} + + +async def test_option_catch_all_exception(hass): + """Test we handle an unknown exception in the option flow.""" + with patch( + "coinbase.wallet.client.Client.get_current_user", + return_value=mock_get_current_user(), + ), patch( + "coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts + ), patch( + "coinbase.wallet.client.Client.get_exchange_rates", + return_value=mock_get_exchange_rates(), + ): + config_entry = await init_mock_coinbase(hass) + result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.async_block_till_done() + + with patch( + "coinbase.wallet.client.Client.get_accounts", + side_effect=Exception, + ): + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_CURRENCIES: [], + CONF_EXCHANGE_RATES: ["ETH"], + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + +async def test_yaml_import(hass): + """Test YAML import works.""" + conf = { + CONF_API_KEY: "123456", + CONF_YAML_API_TOKEN: "AbCDeF", + CONF_CURRENCIES: ["BTC", "USD"], + CONF_EXCHANGE_RATES: ["ATOM", "BTC"], + } + with patch( + "coinbase.wallet.client.Client.get_current_user", + return_value=mock_get_current_user(), + ), patch( + "coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts + ), patch( + "coinbase.wallet.client.Client.get_exchange_rates", + return_value=mock_get_exchange_rates(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=conf + ) + assert result["type"] == "create_entry" + assert result["title"] == "Test User" + assert result["data"] == {CONF_API_KEY: "123456", CONF_API_TOKEN: "AbCDeF"} + assert result["options"] == { + CONF_CURRENCIES: ["BTC", "USD"], + CONF_EXCHANGE_RATES: ["ATOM", "BTC"], + } + + +async def test_yaml_existing(hass): + """Test YAML ignored when already processed.""" + MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "123456", + CONF_API_TOKEN: "AbCDeF", + }, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_API_KEY: "123456", + CONF_YAML_API_TOKEN: "AbCDeF", + }, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" diff --git a/tests/components/coinbase/test_init.py b/tests/components/coinbase/test_init.py new file mode 100644 index 00000000000..612519b1cee --- /dev/null +++ b/tests/components/coinbase/test_init.py @@ -0,0 +1,80 @@ +"""Test the Coinbase integration.""" +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.coinbase.const import ( + CONF_CURRENCIES, + CONF_EXCHANGE_RATES, + CONF_YAML_API_TOKEN, + DOMAIN, +) +from homeassistant.const import CONF_API_KEY +from homeassistant.setup import async_setup_component + +from .common import ( + init_mock_coinbase, + mock_get_current_user, + mock_get_exchange_rates, + mocked_get_accounts, +) +from .const import ( + GOOD_CURRENCY, + GOOD_CURRENCY_2, + GOOD_EXCHNAGE_RATE, + GOOD_EXCHNAGE_RATE_2, +) + + +async def test_setup(hass): + """Test setting up from configuration.yaml.""" + conf = { + DOMAIN: { + CONF_API_KEY: "123456", + CONF_YAML_API_TOKEN: "AbCDeF", + CONF_CURRENCIES: [GOOD_CURRENCY, GOOD_CURRENCY_2], + CONF_EXCHANGE_RATES: [GOOD_EXCHNAGE_RATE, GOOD_EXCHNAGE_RATE_2], + } + } + with patch( + "coinbase.wallet.client.Client.get_current_user", + return_value=mock_get_current_user(), + ), patch( + "coinbase.wallet.client.Client.get_accounts", + new=mocked_get_accounts, + ), patch( + "coinbase.wallet.client.Client.get_exchange_rates", + return_value=mock_get_exchange_rates(), + ): + assert await async_setup_component(hass, DOMAIN, conf) + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].title == "Test User" + assert entries[0].source == config_entries.SOURCE_IMPORT + assert entries[0].options == { + CONF_CURRENCIES: [GOOD_CURRENCY, GOOD_CURRENCY_2], + CONF_EXCHANGE_RATES: [GOOD_EXCHNAGE_RATE, GOOD_EXCHNAGE_RATE_2], + } + + +async def test_unload_entry(hass): + """Test successful unload of entry.""" + with patch( + "coinbase.wallet.client.Client.get_current_user", + return_value=mock_get_current_user(), + ), patch( + "coinbase.wallet.client.Client.get_accounts", + new=mocked_get_accounts, + ), patch( + "coinbase.wallet.client.Client.get_exchange_rates", + return_value=mock_get_exchange_rates(), + ): + entry = await init_mock_coinbase(hass) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state == config_entries.ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == config_entries.ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN)