Add config flow for Coinbase (#45354)

Co-authored-by: Franck Nijhof <frenck@frenck.nl>
This commit is contained in:
Tom Brien 2021-06-28 15:38:12 +01:00 committed by GitHub
parent efee36a176
commit fd1d110b80
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 1299 additions and 96 deletions

View file

@ -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

View file

@ -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

View file

@ -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(

View file

@ -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."""

View file

@ -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",
}

View file

@ -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"
}

View file

@ -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"]
entities = []
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,
)
if "exchange_currency" in discovery_info:
sensor = ExchangeRateSensor(
hass.data[DATA_COINBASE],
discovery_info["exchange_currency"],
discovery_info["native_currency"],
)
add_entities([sensor], True)
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
)

View file

@ -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."
}
}
}

View file

@ -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"
}
}
}
}

View file

@ -45,6 +45,7 @@ FLOWS = [
"cert_expiry",
"climacell",
"cloudflare",
"coinbase",
"control4",
"coolmaster",
"coronavirus",

View file

@ -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

View file

@ -0,0 +1 @@
"""Tests for the Coinbase integration."""

View file

@ -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

View file

@ -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},
},
]

View file

@ -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"

View file

@ -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)