mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 13:17:32 +00:00
Add support for v3 Coinbase API (#116345)
* Add support for v3 Coinbase API * Add deps * Move tests
This commit is contained in:
parent
d08f4fbace
commit
baceb2a92a
@ -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
|
||||||
)
|
)
|
||||||
|
@ -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
|
||||||
|
@ -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",
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
@ -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({
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user