Add support for v3 Coinbase API (#116345)

* Add support for v3 Coinbase API

* Add deps

* Move tests
This commit is contained in:
Tom Brien 2024-08-08 11:26:03 +01:00 committed by GitHub
parent d08f4fbace
commit baceb2a92a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 359 additions and 96 deletions

View File

@ -5,7 +5,9 @@ from __future__ import annotations
from datetime import timedelta from datetime import timedelta
import logging import logging
from coinbase.wallet.client import Client from coinbase.rest import RESTClient
from coinbase.rest.rest_base import HTTPError
from coinbase.wallet.client import Client as LegacyClient
from coinbase.wallet.error import AuthenticationError from coinbase.wallet.error import AuthenticationError
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -15,8 +17,23 @@ from homeassistant.helpers import entity_registry as er
from homeassistant.util import Throttle from homeassistant.util import Throttle
from .const import ( from .const import (
ACCOUNT_IS_VAULT,
API_ACCOUNT_AMOUNT,
API_ACCOUNT_AVALIABLE,
API_ACCOUNT_BALANCE,
API_ACCOUNT_CURRENCY,
API_ACCOUNT_CURRENCY_CODE,
API_ACCOUNT_HOLD,
API_ACCOUNT_ID, API_ACCOUNT_ID,
API_ACCOUNTS_DATA, API_ACCOUNT_NAME,
API_ACCOUNT_VALUE,
API_ACCOUNTS,
API_DATA,
API_RATES_CURRENCY,
API_RESOURCE_TYPE,
API_TYPE_VAULT,
API_V3_ACCOUNT_ID,
API_V3_TYPE_VAULT,
CONF_CURRENCIES, CONF_CURRENCIES,
CONF_EXCHANGE_BASE, CONF_EXCHANGE_BASE,
CONF_EXCHANGE_RATES, CONF_EXCHANGE_RATES,
@ -56,9 +73,16 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
def create_and_update_instance(entry: ConfigEntry) -> CoinbaseData: def create_and_update_instance(entry: ConfigEntry) -> CoinbaseData:
"""Create and update a Coinbase Data instance.""" """Create and update a Coinbase Data instance."""
client = Client(entry.data[CONF_API_KEY], entry.data[CONF_API_TOKEN]) if "organizations" not in entry.data[CONF_API_KEY]:
client = LegacyClient(entry.data[CONF_API_KEY], entry.data[CONF_API_TOKEN])
version = "v2"
else:
client = RESTClient(
api_key=entry.data[CONF_API_KEY], api_secret=entry.data[CONF_API_TOKEN]
)
version = "v3"
base_rate = entry.options.get(CONF_EXCHANGE_BASE, "USD") base_rate = entry.options.get(CONF_EXCHANGE_BASE, "USD")
instance = CoinbaseData(client, base_rate) instance = CoinbaseData(client, base_rate, version)
instance.update() instance.update()
return instance return instance
@ -83,42 +107,83 @@ async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> Non
registry.async_remove(entity.entity_id) registry.async_remove(entity.entity_id)
def get_accounts(client): def get_accounts(client, version):
"""Handle paginated accounts.""" """Handle paginated accounts."""
response = client.get_accounts() response = client.get_accounts()
accounts = response[API_ACCOUNTS_DATA] if version == "v2":
next_starting_after = response.pagination.next_starting_after accounts = response[API_DATA]
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 next_starting_after = response.pagination.next_starting_after
return accounts while next_starting_after:
response = client.get_accounts(starting_after=next_starting_after)
accounts += response[API_DATA]
next_starting_after = response.pagination.next_starting_after
return [
{
API_ACCOUNT_ID: account[API_ACCOUNT_ID],
API_ACCOUNT_NAME: account[API_ACCOUNT_NAME],
API_ACCOUNT_CURRENCY: account[API_ACCOUNT_CURRENCY][
API_ACCOUNT_CURRENCY_CODE
],
API_ACCOUNT_AMOUNT: account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT],
ACCOUNT_IS_VAULT: account[API_RESOURCE_TYPE] == API_TYPE_VAULT,
}
for account in accounts
]
accounts = response[API_ACCOUNTS]
while response["has_next"]:
response = client.get_accounts(cursor=response["cursor"])
accounts += response["accounts"]
return [
{
API_ACCOUNT_ID: account[API_V3_ACCOUNT_ID],
API_ACCOUNT_NAME: account[API_ACCOUNT_NAME],
API_ACCOUNT_CURRENCY: account[API_ACCOUNT_CURRENCY],
API_ACCOUNT_AMOUNT: account[API_ACCOUNT_AVALIABLE][API_ACCOUNT_VALUE]
+ account[API_ACCOUNT_HOLD][API_ACCOUNT_VALUE],
ACCOUNT_IS_VAULT: account[API_RESOURCE_TYPE] == API_V3_TYPE_VAULT,
}
for account in accounts
]
class CoinbaseData: class CoinbaseData:
"""Get the latest data and update the states.""" """Get the latest data and update the states."""
def __init__(self, client, exchange_base): def __init__(self, client, exchange_base, version):
"""Init the coinbase data object.""" """Init the coinbase data object."""
self.client = client self.client = client
self.accounts = None self.accounts = None
self.exchange_base = exchange_base self.exchange_base = exchange_base
self.exchange_rates = None self.exchange_rates = None
self.user_id = self.client.get_current_user()[API_ACCOUNT_ID] if version == "v2":
self.user_id = self.client.get_current_user()[API_ACCOUNT_ID]
else:
self.user_id = (
"v3_" + client.get_portfolios()["portfolios"][0][API_V3_ACCOUNT_ID]
)
self.api_version = version
@Throttle(MIN_TIME_BETWEEN_UPDATES) @Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self): def update(self):
"""Get the latest data from coinbase.""" """Get the latest data from coinbase."""
try: try:
self.accounts = get_accounts(self.client) self.accounts = get_accounts(self.client, self.api_version)
self.exchange_rates = self.client.get_exchange_rates( if self.api_version == "v2":
currency=self.exchange_base self.exchange_rates = self.client.get_exchange_rates(
) currency=self.exchange_base
except AuthenticationError as coinbase_error: )
else:
self.exchange_rates = self.client.get(
"/v2/exchange-rates",
params={API_RATES_CURRENCY: self.exchange_base},
)[API_DATA]
except (AuthenticationError, HTTPError) as coinbase_error:
_LOGGER.error( _LOGGER.error(
"Authentication error connecting to coinbase: %s", coinbase_error "Authentication error connecting to coinbase: %s", coinbase_error
) )

View File

@ -5,7 +5,9 @@ from __future__ import annotations
import logging import logging
from typing import Any from typing import Any
from coinbase.wallet.client import Client from coinbase.rest import RESTClient
from coinbase.rest.rest_base import HTTPError
from coinbase.wallet.client import Client as LegacyClient
from coinbase.wallet.error import AuthenticationError from coinbase.wallet.error import AuthenticationError
import voluptuous as vol import voluptuous as vol
@ -15,18 +17,17 @@ from homeassistant.config_entries import (
ConfigFlowResult, ConfigFlowResult,
OptionsFlow, OptionsFlow,
) )
from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_API_VERSION
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from . import get_accounts from . import get_accounts
from .const import ( from .const import (
ACCOUNT_IS_VAULT,
API_ACCOUNT_CURRENCY, API_ACCOUNT_CURRENCY,
API_ACCOUNT_CURRENCY_CODE, API_DATA,
API_RATES, API_RATES,
API_RESOURCE_TYPE,
API_TYPE_VAULT,
CONF_CURRENCIES, CONF_CURRENCIES,
CONF_EXCHANGE_BASE, CONF_EXCHANGE_BASE,
CONF_EXCHANGE_PRECISION, CONF_EXCHANGE_PRECISION,
@ -49,8 +50,11 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
def get_user_from_client(api_key, api_token): def get_user_from_client(api_key, api_token):
"""Get the user name from Coinbase API credentials.""" """Get the user name from Coinbase API credentials."""
client = Client(api_key, api_token) if "organizations" not in api_key:
return client.get_current_user() client = LegacyClient(api_key, api_token)
return client.get_current_user()["name"]
client = RESTClient(api_key=api_key, api_secret=api_token)
return client.get_portfolios()["portfolios"][0]["name"]
async def validate_api(hass: HomeAssistant, data): async def validate_api(hass: HomeAssistant, data):
@ -60,11 +64,13 @@ async def validate_api(hass: HomeAssistant, data):
user = await hass.async_add_executor_job( user = await hass.async_add_executor_job(
get_user_from_client, data[CONF_API_KEY], data[CONF_API_TOKEN] get_user_from_client, data[CONF_API_KEY], data[CONF_API_TOKEN]
) )
except AuthenticationError as error: except (AuthenticationError, HTTPError) as error:
if "api key" in str(error): if "api key" in str(error) or " 401 Client Error" in str(error):
_LOGGER.debug("Coinbase rejected API credentials due to an invalid API key") _LOGGER.debug("Coinbase rejected API credentials due to an invalid API key")
raise InvalidKey from error raise InvalidKey from error
if "invalid signature" in str(error): if "invalid signature" in str(
error
) or "'Could not deserialize key data" in str(error):
_LOGGER.debug( _LOGGER.debug(
"Coinbase rejected API credentials due to an invalid API secret" "Coinbase rejected API credentials due to an invalid API secret"
) )
@ -73,8 +79,8 @@ async def validate_api(hass: HomeAssistant, data):
raise InvalidAuth from error raise InvalidAuth from error
except ConnectionError as error: except ConnectionError as error:
raise CannotConnect from error raise CannotConnect from error
api_version = "v3" if "organizations" in data[CONF_API_KEY] else "v2"
return {"title": user["name"]} return {"title": user, "api_version": api_version}
async def validate_options(hass: HomeAssistant, config_entry: ConfigEntry, options): async def validate_options(hass: HomeAssistant, config_entry: ConfigEntry, options):
@ -82,14 +88,20 @@ async def validate_options(hass: HomeAssistant, config_entry: ConfigEntry, optio
client = hass.data[DOMAIN][config_entry.entry_id].client client = hass.data[DOMAIN][config_entry.entry_id].client
accounts = await hass.async_add_executor_job(get_accounts, client) accounts = await hass.async_add_executor_job(
get_accounts, client, config_entry.data.get("api_version", "v2")
)
accounts_currencies = [ accounts_currencies = [
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE] account[API_ACCOUNT_CURRENCY]
for account in accounts for account in accounts
if account[API_RESOURCE_TYPE] != API_TYPE_VAULT if not account[ACCOUNT_IS_VAULT]
] ]
available_rates = await hass.async_add_executor_job(client.get_exchange_rates) if config_entry.data.get("api_version", "v2") == "v2":
available_rates = await hass.async_add_executor_job(client.get_exchange_rates)
else:
resp = await hass.async_add_executor_job(client.get, "/v2/exchange-rates")
available_rates = resp[API_DATA]
if CONF_CURRENCIES in options: if CONF_CURRENCIES in options:
for currency in options[CONF_CURRENCIES]: for currency in options[CONF_CURRENCIES]:
if currency not in accounts_currencies: if currency not in accounts_currencies:
@ -134,6 +146,7 @@ class CoinbaseConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception") _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown" errors["base"] = "unknown"
else: else:
user_input[CONF_API_VERSION] = info["api_version"]
return self.async_create_entry(title=info["title"], data=user_input) return self.async_create_entry(title=info["title"], data=user_input)
return self.async_show_form( return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors

View File

@ -1,5 +1,7 @@
"""Constants used for Coinbase.""" """Constants used for Coinbase."""
ACCOUNT_IS_VAULT = "is_vault"
CONF_CURRENCIES = "account_balance_currencies" CONF_CURRENCIES = "account_balance_currencies"
CONF_EXCHANGE_BASE = "exchange_base" CONF_EXCHANGE_BASE = "exchange_base"
CONF_EXCHANGE_RATES = "exchange_rate_currencies" CONF_EXCHANGE_RATES = "exchange_rate_currencies"
@ -10,18 +12,25 @@ DOMAIN = "coinbase"
# Constants for data returned by Coinbase API # Constants for data returned by Coinbase API
API_ACCOUNT_AMOUNT = "amount" API_ACCOUNT_AMOUNT = "amount"
API_ACCOUNT_AVALIABLE = "available_balance"
API_ACCOUNT_BALANCE = "balance" API_ACCOUNT_BALANCE = "balance"
API_ACCOUNT_CURRENCY = "currency" API_ACCOUNT_CURRENCY = "currency"
API_ACCOUNT_CURRENCY_CODE = "code" API_ACCOUNT_CURRENCY_CODE = "code"
API_ACCOUNT_HOLD = "hold"
API_ACCOUNT_ID = "id" API_ACCOUNT_ID = "id"
API_ACCOUNT_NATIVE_BALANCE = "balance" API_ACCOUNT_NATIVE_BALANCE = "balance"
API_ACCOUNT_NAME = "name" API_ACCOUNT_NAME = "name"
API_ACCOUNTS_DATA = "data" API_ACCOUNT_VALUE = "value"
API_ACCOUNTS = "accounts"
API_DATA = "data"
API_RATES = "rates" API_RATES = "rates"
API_RATES_CURRENCY = "currency"
API_RESOURCE_PATH = "resource_path" API_RESOURCE_PATH = "resource_path"
API_RESOURCE_TYPE = "type" API_RESOURCE_TYPE = "type"
API_TYPE_VAULT = "vault" API_TYPE_VAULT = "vault"
API_USD = "USD" API_USD = "USD"
API_V3_ACCOUNT_ID = "uuid"
API_V3_TYPE_VAULT = "ACCOUNT_TYPE_VAULT"
WALLETS = { WALLETS = {
"1INCH": "1INCH", "1INCH": "1INCH",

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/coinbase", "documentation": "https://www.home-assistant.io/integrations/coinbase",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["coinbase"], "loggers": ["coinbase"],
"requirements": ["coinbase==2.1.0"] "requirements": ["coinbase==2.1.0", "coinbase-advanced-py==1.2.2"]
} }

View File

@ -12,15 +12,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import CoinbaseData from . import CoinbaseData
from .const import ( from .const import (
ACCOUNT_IS_VAULT,
API_ACCOUNT_AMOUNT, API_ACCOUNT_AMOUNT,
API_ACCOUNT_BALANCE,
API_ACCOUNT_CURRENCY, API_ACCOUNT_CURRENCY,
API_ACCOUNT_CURRENCY_CODE,
API_ACCOUNT_ID, API_ACCOUNT_ID,
API_ACCOUNT_NAME, API_ACCOUNT_NAME,
API_RATES, API_RATES,
API_RESOURCE_TYPE,
API_TYPE_VAULT,
CONF_CURRENCIES, CONF_CURRENCIES,
CONF_EXCHANGE_PRECISION, CONF_EXCHANGE_PRECISION,
CONF_EXCHANGE_PRECISION_DEFAULT, CONF_EXCHANGE_PRECISION_DEFAULT,
@ -31,6 +28,7 @@ from .const import (
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
ATTR_NATIVE_BALANCE = "Balance in native currency" ATTR_NATIVE_BALANCE = "Balance in native currency"
ATTR_API_VERSION = "API Version"
CURRENCY_ICONS = { CURRENCY_ICONS = {
"BTC": "mdi:currency-btc", "BTC": "mdi:currency-btc",
@ -56,9 +54,9 @@ async def async_setup_entry(
entities: list[SensorEntity] = [] entities: list[SensorEntity] = []
provided_currencies: list[str] = [ provided_currencies: list[str] = [
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE] account[API_ACCOUNT_CURRENCY]
for account in instance.accounts for account in instance.accounts
if account[API_RESOURCE_TYPE] != API_TYPE_VAULT if not account[ACCOUNT_IS_VAULT]
] ]
desired_currencies: list[str] = [] desired_currencies: list[str] = []
@ -73,6 +71,11 @@ async def async_setup_entry(
) )
for currency in desired_currencies: for currency in desired_currencies:
_LOGGER.debug(
"Attempting to set up %s account sensor with %s API",
currency,
instance.api_version,
)
if currency not in provided_currencies: if currency not in provided_currencies:
_LOGGER.warning( _LOGGER.warning(
( (
@ -85,12 +88,17 @@ async def async_setup_entry(
entities.append(AccountSensor(instance, currency)) entities.append(AccountSensor(instance, currency))
if CONF_EXCHANGE_RATES in config_entry.options: if CONF_EXCHANGE_RATES in config_entry.options:
entities.extend( for rate in config_entry.options[CONF_EXCHANGE_RATES]:
ExchangeRateSensor( _LOGGER.debug(
instance, rate, exchange_base_currency, exchange_precision "Attempting to set up %s account sensor with %s API",
rate,
instance.api_version,
)
entities.append(
ExchangeRateSensor(
instance, rate, exchange_base_currency, exchange_precision
)
) )
for rate in config_entry.options[CONF_EXCHANGE_RATES]
)
async_add_entities(entities) async_add_entities(entities)
@ -105,26 +113,21 @@ class AccountSensor(SensorEntity):
self._coinbase_data = coinbase_data self._coinbase_data = coinbase_data
self._currency = currency self._currency = currency
for account in coinbase_data.accounts: for account in coinbase_data.accounts:
if ( if account[API_ACCOUNT_CURRENCY] != currency or account[ACCOUNT_IS_VAULT]:
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE] != currency
or account[API_RESOURCE_TYPE] == API_TYPE_VAULT
):
continue continue
self._attr_name = f"Coinbase {account[API_ACCOUNT_NAME]}" self._attr_name = f"Coinbase {account[API_ACCOUNT_NAME]}"
self._attr_unique_id = ( self._attr_unique_id = (
f"coinbase-{account[API_ACCOUNT_ID]}-wallet-" f"coinbase-{account[API_ACCOUNT_ID]}-wallet-"
f"{account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]}" f"{account[API_ACCOUNT_CURRENCY]}"
) )
self._attr_native_value = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT] self._attr_native_value = account[API_ACCOUNT_AMOUNT]
self._attr_native_unit_of_measurement = account[API_ACCOUNT_CURRENCY][ self._attr_native_unit_of_measurement = account[API_ACCOUNT_CURRENCY]
API_ACCOUNT_CURRENCY_CODE
]
self._attr_icon = CURRENCY_ICONS.get( self._attr_icon = CURRENCY_ICONS.get(
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE], account[API_ACCOUNT_CURRENCY],
DEFAULT_COIN_ICON, DEFAULT_COIN_ICON,
) )
self._native_balance = round( self._native_balance = round(
float(account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT]) float(account[API_ACCOUNT_AMOUNT])
/ float(coinbase_data.exchange_rates[API_RATES][currency]), / float(coinbase_data.exchange_rates[API_RATES][currency]),
2, 2,
) )
@ -144,21 +147,26 @@ class AccountSensor(SensorEntity):
"""Return the state attributes of the sensor.""" """Return the state attributes of the sensor."""
return { return {
ATTR_NATIVE_BALANCE: f"{self._native_balance} {self._coinbase_data.exchange_base}", ATTR_NATIVE_BALANCE: f"{self._native_balance} {self._coinbase_data.exchange_base}",
ATTR_API_VERSION: self._coinbase_data.api_version,
} }
def update(self) -> None: def update(self) -> None:
"""Get the latest state of the sensor.""" """Get the latest state of the sensor."""
_LOGGER.debug(
"Updating %s account sensor with %s API",
self._currency,
self._coinbase_data.api_version,
)
self._coinbase_data.update() self._coinbase_data.update()
for account in self._coinbase_data.accounts: for account in self._coinbase_data.accounts:
if ( if (
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE] account[API_ACCOUNT_CURRENCY] != self._currency
!= self._currency or account[ACCOUNT_IS_VAULT]
or account[API_RESOURCE_TYPE] == API_TYPE_VAULT
): ):
continue continue
self._attr_native_value = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT] self._attr_native_value = account[API_ACCOUNT_AMOUNT]
self._native_balance = round( self._native_balance = round(
float(account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT]) float(account[API_ACCOUNT_AMOUNT])
/ float(self._coinbase_data.exchange_rates[API_RATES][self._currency]), / float(self._coinbase_data.exchange_rates[API_RATES][self._currency]),
2, 2,
) )
@ -202,8 +210,13 @@ class ExchangeRateSensor(SensorEntity):
def update(self) -> None: def update(self) -> None:
"""Get the latest state of the sensor.""" """Get the latest state of the sensor."""
_LOGGER.debug(
"Updating %s rate sensor with %s API",
self._currency,
self._coinbase_data.api_version,
)
self._coinbase_data.update() self._coinbase_data.update()
self._attr_native_value = round( self._attr_native_value = round(
1 / float(self._coinbase_data.exchange_rates.rates[self._currency]), 1 / float(self._coinbase_data.exchange_rates[API_RATES][self._currency]),
self._precision, self._precision,
) )

View File

@ -660,6 +660,9 @@ clearpasspy==1.0.2
# homeassistant.components.sinch # homeassistant.components.sinch
clx-sdk-xms==1.0.0 clx-sdk-xms==1.0.0
# homeassistant.components.coinbase
coinbase-advanced-py==1.2.2
# homeassistant.components.coinbase # homeassistant.components.coinbase
coinbase==2.1.0 coinbase==2.1.0

View File

@ -562,6 +562,9 @@ cached_ipaddress==0.3.0
# homeassistant.components.caldav # homeassistant.components.caldav
caldav==1.3.9 caldav==1.3.9
# homeassistant.components.coinbase
coinbase-advanced-py==1.2.2
# homeassistant.components.coinbase # homeassistant.components.coinbase
coinbase==2.1.0 coinbase==2.1.0

View File

@ -5,13 +5,14 @@ from homeassistant.components.coinbase.const import (
CONF_EXCHANGE_RATES, CONF_EXCHANGE_RATES,
DOMAIN, DOMAIN,
) )
from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_API_VERSION
from .const import ( from .const import (
GOOD_CURRENCY_2, GOOD_CURRENCY_2,
GOOD_EXCHANGE_RATE, GOOD_EXCHANGE_RATE,
GOOD_EXCHANGE_RATE_2, GOOD_EXCHANGE_RATE_2,
MOCK_ACCOUNTS_RESPONSE, MOCK_ACCOUNTS_RESPONSE,
MOCK_ACCOUNTS_RESPONSE_V3,
) )
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@ -54,6 +55,33 @@ def mocked_get_accounts(_, **kwargs):
return MockGetAccounts(**kwargs) return MockGetAccounts(**kwargs)
class MockGetAccountsV3:
"""Mock accounts with pagination."""
def __init__(self, cursor=""):
"""Init mocked object, forced to return two at a time."""
ids = [account["uuid"] for account in MOCK_ACCOUNTS_RESPONSE_V3]
start = ids.index(cursor) if cursor else 0
has_next = (target_end := start + 2) < len(MOCK_ACCOUNTS_RESPONSE_V3)
end = target_end if has_next else -1
next_cursor = ids[end] if has_next else ids[-1]
self.accounts = {
"accounts": MOCK_ACCOUNTS_RESPONSE_V3[start:end],
"has_next": has_next,
"cursor": next_cursor,
}
def __getitem__(self, item):
"""Handle subscript request."""
return self.accounts[item]
def mocked_get_accounts_v3(_, **kwargs):
"""Return simplified accounts using mock."""
return MockGetAccountsV3(**kwargs)
def mock_get_current_user(): def mock_get_current_user():
"""Return a simplified mock user.""" """Return a simplified mock user."""
return { return {
@ -74,6 +102,19 @@ def mock_get_exchange_rates():
} }
def mock_get_portfolios():
"""Return a mocked list of Coinbase portfolios."""
return {
"portfolios": [
{
"name": "Default",
"uuid": "123456",
"type": "DEFAULT",
}
]
}
async def init_mock_coinbase(hass, currencies=None, rates=None): async def init_mock_coinbase(hass, currencies=None, rates=None):
"""Init Coinbase integration for testing.""" """Init Coinbase integration for testing."""
config_entry = MockConfigEntry( config_entry = MockConfigEntry(
@ -93,3 +134,28 @@ async def init_mock_coinbase(hass, currencies=None, rates=None):
await hass.async_block_till_done() await hass.async_block_till_done()
return config_entry return config_entry
async def init_mock_coinbase_v3(hass, currencies=None, rates=None):
"""Init Coinbase integration for testing."""
config_entry = MockConfigEntry(
domain=DOMAIN,
entry_id="080272b77a4f80c41b94d7cdc86fd826",
unique_id=None,
title="Test User v3",
data={
CONF_API_KEY: "organizations/123456",
CONF_API_TOKEN: "AbCDeF",
CONF_API_VERSION: "v3",
},
options={
CONF_CURRENCIES: currencies or [],
CONF_EXCHANGE_RATES: rates or [],
},
)
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

@ -31,3 +31,31 @@ MOCK_ACCOUNTS_RESPONSE = [
"type": "fiat", "type": "fiat",
}, },
] ]
MOCK_ACCOUNTS_RESPONSE_V3 = [
{
"uuid": "123456789",
"name": "BTC Wallet",
"currency": GOOD_CURRENCY,
"available_balance": {"value": "0.00001", "currency": GOOD_CURRENCY},
"type": "ACCOUNT_TYPE_CRYPTO",
"hold": {"value": "0", "currency": GOOD_CURRENCY},
},
{
"uuid": "abcdefg",
"name": "BTC Vault",
"currency": GOOD_CURRENCY,
"available_balance": {"value": "100.00", "currency": GOOD_CURRENCY},
"type": "ACCOUNT_TYPE_VAULT",
"hold": {"value": "0", "currency": GOOD_CURRENCY},
},
{
"uuid": "987654321",
"name": "USD Wallet",
"currency": GOOD_CURRENCY_2,
"available_balance": {"value": "9.90", "currency": GOOD_CURRENCY_2},
"type": "ACCOUNT_TYPE_FIAT",
"ready": True,
"hold": {"value": "0", "currency": GOOD_CURRENCY_2},
},
]

View File

@ -3,40 +3,25 @@
dict({ dict({
'accounts': list([ 'accounts': list([
dict({ dict({
'balance': dict({ 'amount': '**REDACTED**',
'amount': '**REDACTED**', 'currency': 'BTC',
'currency': 'BTC',
}),
'currency': dict({
'code': 'BTC',
}),
'id': '**REDACTED**', 'id': '**REDACTED**',
'is_vault': False,
'name': 'BTC Wallet', 'name': 'BTC Wallet',
'type': 'wallet',
}), }),
dict({ dict({
'balance': dict({ 'amount': '**REDACTED**',
'amount': '**REDACTED**', 'currency': 'BTC',
'currency': 'BTC',
}),
'currency': dict({
'code': 'BTC',
}),
'id': '**REDACTED**', 'id': '**REDACTED**',
'is_vault': True,
'name': 'BTC Vault', 'name': 'BTC Vault',
'type': 'vault',
}), }),
dict({ dict({
'balance': dict({ 'amount': '**REDACTED**',
'amount': '**REDACTED**', 'currency': 'USD',
'currency': 'USD',
}),
'currency': dict({
'code': 'USD',
}),
'id': '**REDACTED**', 'id': '**REDACTED**',
'is_vault': False,
'name': 'USD Wallet', 'name': 'USD Wallet',
'type': 'fiat',
}), }),
]), ]),
'entry': dict({ 'entry': dict({

View File

@ -14,15 +14,18 @@ from homeassistant.components.coinbase.const import (
CONF_EXCHANGE_RATES, CONF_EXCHANGE_RATES,
DOMAIN, DOMAIN,
) )
from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_API_VERSION
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from .common import ( from .common import (
init_mock_coinbase, init_mock_coinbase,
init_mock_coinbase_v3,
mock_get_current_user, mock_get_current_user,
mock_get_exchange_rates, mock_get_exchange_rates,
mock_get_portfolios,
mocked_get_accounts, mocked_get_accounts,
mocked_get_accounts_v3,
) )
from .const import BAD_CURRENCY, BAD_EXCHANGE_RATE, GOOD_CURRENCY, GOOD_EXCHANGE_RATE from .const import BAD_CURRENCY, BAD_EXCHANGE_RATE, GOOD_CURRENCY, GOOD_EXCHANGE_RATE
@ -53,16 +56,17 @@ async def test_form(hass: HomeAssistant) -> None:
): ):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{ {CONF_API_KEY: "123456", CONF_API_TOKEN: "AbCDeF"},
CONF_API_KEY: "123456",
CONF_API_TOKEN: "AbCDeF",
},
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "Test User" assert result2["title"] == "Test User"
assert result2["data"] == {CONF_API_KEY: "123456", CONF_API_TOKEN: "AbCDeF"} assert result2["data"] == {
CONF_API_KEY: "123456",
CONF_API_TOKEN: "AbCDeF",
CONF_API_VERSION: "v2",
}
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
@ -314,3 +318,77 @@ async def test_option_catch_all_exception(hass: HomeAssistant) -> None:
assert result2["type"] is FlowResultType.FORM assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "unknown"} assert result2["errors"] == {"base": "unknown"}
async def test_form_v3(hass: HomeAssistant) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
with (
patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3),
patch(
"coinbase.rest.RESTClient.get_portfolios",
return_value=mock_get_portfolios(),
),
patch(
"coinbase.rest.RESTBase.get",
return_value={"data": mock_get_exchange_rates()},
),
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: "organizations/123456", CONF_API_TOKEN: "AbCDeF"},
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "Default"
assert result2["data"] == {
CONF_API_KEY: "organizations/123456",
CONF_API_TOKEN: "AbCDeF",
CONF_API_VERSION: "v3",
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_option_form_v3(hass: HomeAssistant) -> None:
"""Test we handle a good wallet currency option."""
with (
patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3),
patch(
"coinbase.rest.RESTClient.get_portfolios",
return_value=mock_get_portfolios(),
),
patch(
"coinbase.rest.RESTBase.get",
return_value={"data": mock_get_exchange_rates()},
),
patch(
"homeassistant.components.coinbase.update_listener"
) as mock_update_listener,
):
config_entry = await init_mock_coinbase_v3(hass)
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: [GOOD_CURRENCY],
CONF_EXCHANGE_RATES: [GOOD_EXCHANGE_RATE],
CONF_EXCHANGE_PRECISION: 5,
},
)
assert result2["type"] is FlowResultType.CREATE_ENTRY
await hass.async_block_till_done()
assert len(mock_update_listener.mock_calls) == 1