292 lines
8.9 KiB
Python
292 lines
8.9 KiB
Python
"""Read the balance of your bank accounts via FinTS."""
|
|
|
|
from collections import namedtuple
|
|
from datetime import timedelta
|
|
import logging
|
|
|
|
from fints.client import FinTS3PinTanClient
|
|
from fints.dialog import FinTSDialogError
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.components.sensor import PLATFORM_SCHEMA
|
|
from homeassistant.const import CONF_NAME, CONF_PIN, CONF_URL, CONF_USERNAME
|
|
import homeassistant.helpers.config_validation as cv
|
|
from homeassistant.helpers.entity import Entity
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
SCAN_INTERVAL = timedelta(hours=4)
|
|
|
|
ICON = "mdi:currency-eur"
|
|
|
|
BankCredentials = namedtuple("BankCredentials", "blz login pin url")
|
|
|
|
CONF_BIN = "bank_identification_number"
|
|
CONF_ACCOUNTS = "accounts"
|
|
CONF_HOLDINGS = "holdings"
|
|
CONF_ACCOUNT = "account"
|
|
|
|
ATTR_ACCOUNT = CONF_ACCOUNT
|
|
ATTR_BANK = "bank"
|
|
ATTR_ACCOUNT_TYPE = "account_type"
|
|
|
|
SCHEMA_ACCOUNTS = vol.Schema(
|
|
{
|
|
vol.Required(CONF_ACCOUNT): cv.string,
|
|
vol.Optional(CONF_NAME, default=None): vol.Any(None, cv.string),
|
|
}
|
|
)
|
|
|
|
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
|
{
|
|
vol.Required(CONF_BIN): cv.string,
|
|
vol.Required(CONF_USERNAME): cv.string,
|
|
vol.Required(CONF_PIN): cv.string,
|
|
vol.Required(CONF_URL): cv.string,
|
|
vol.Optional(CONF_NAME): cv.string,
|
|
vol.Optional(CONF_ACCOUNTS, default=[]): cv.ensure_list(SCHEMA_ACCOUNTS),
|
|
vol.Optional(CONF_HOLDINGS, default=[]): cv.ensure_list(SCHEMA_ACCOUNTS),
|
|
}
|
|
)
|
|
|
|
|
|
def setup_platform(hass, config, add_entities, discovery_info=None):
|
|
"""Set up the sensors.
|
|
|
|
Login to the bank and get a list of existing accounts. Create a
|
|
sensor for each account.
|
|
"""
|
|
credentials = BankCredentials(
|
|
config[CONF_BIN], config[CONF_USERNAME], config[CONF_PIN], config[CONF_URL]
|
|
)
|
|
fints_name = config.get(CONF_NAME, config[CONF_BIN])
|
|
|
|
account_config = {
|
|
acc[CONF_ACCOUNT]: acc[CONF_NAME] for acc in config[CONF_ACCOUNTS]
|
|
}
|
|
|
|
holdings_config = {
|
|
acc[CONF_ACCOUNT]: acc[CONF_NAME] for acc in config[CONF_HOLDINGS]
|
|
}
|
|
|
|
client = FinTsClient(credentials, fints_name)
|
|
balance_accounts, holdings_accounts = client.detect_accounts()
|
|
accounts = []
|
|
|
|
for account in balance_accounts:
|
|
if config[CONF_ACCOUNTS] and account.iban not in account_config:
|
|
_LOGGER.info("skipping account %s for bank %s", account.iban, fints_name)
|
|
continue
|
|
|
|
account_name = account_config.get(account.iban)
|
|
if not account_name:
|
|
account_name = f"{fints_name} - {account.iban}"
|
|
accounts.append(FinTsAccount(client, account, account_name))
|
|
_LOGGER.debug("Creating account %s for bank %s", account.iban, fints_name)
|
|
|
|
for account in holdings_accounts:
|
|
if config[CONF_HOLDINGS] and account.accountnumber not in holdings_config:
|
|
_LOGGER.info(
|
|
"skipping holdings %s for bank %s", account.accountnumber, fints_name
|
|
)
|
|
continue
|
|
|
|
account_name = holdings_config.get(account.accountnumber)
|
|
if not account_name:
|
|
account_name = f"{fints_name} - {account.accountnumber}"
|
|
accounts.append(FinTsHoldingsAccount(client, account, account_name))
|
|
_LOGGER.debug(
|
|
"Creating holdings %s for bank %s", account.accountnumber, fints_name
|
|
)
|
|
|
|
add_entities(accounts, True)
|
|
|
|
|
|
class FinTsClient:
|
|
"""Wrapper around the FinTS3PinTanClient.
|
|
|
|
Use this class as Context Manager to get the FinTS3Client object.
|
|
"""
|
|
|
|
def __init__(self, credentials: BankCredentials, name: str):
|
|
"""Initialize a FinTsClient."""
|
|
self._credentials = credentials
|
|
self.name = name
|
|
|
|
@property
|
|
def client(self):
|
|
"""Get the client object.
|
|
|
|
As the fints library is stateless, there is not benefit in caching
|
|
the client objects. If that ever changes, consider caching the client
|
|
object and also think about potential concurrency problems.
|
|
"""
|
|
|
|
return FinTS3PinTanClient(
|
|
self._credentials.blz,
|
|
self._credentials.login,
|
|
self._credentials.pin,
|
|
self._credentials.url,
|
|
)
|
|
|
|
def detect_accounts(self):
|
|
"""Identify the accounts of the bank."""
|
|
|
|
balance_accounts = []
|
|
holdings_accounts = []
|
|
for account in self.client.get_sepa_accounts():
|
|
try:
|
|
self.client.get_balance(account)
|
|
balance_accounts.append(account)
|
|
except IndexError:
|
|
# account is not a balance account.
|
|
pass
|
|
except FinTSDialogError:
|
|
# account is not a balance account.
|
|
pass
|
|
try:
|
|
self.client.get_holdings(account)
|
|
holdings_accounts.append(account)
|
|
except FinTSDialogError:
|
|
# account is not a holdings account.
|
|
pass
|
|
|
|
return balance_accounts, holdings_accounts
|
|
|
|
|
|
class FinTsAccount(Entity):
|
|
"""Sensor for a FinTS balance account.
|
|
|
|
A balance account contains an amount of money (=balance). The amount may
|
|
also be negative.
|
|
"""
|
|
|
|
def __init__(self, client: FinTsClient, account, name: str) -> None:
|
|
"""Initialize a FinTs balance account."""
|
|
self._client = client
|
|
self._account = account
|
|
self._name = name
|
|
self._balance: float = None
|
|
self._currency: str = None
|
|
|
|
@property
|
|
def should_poll(self) -> bool:
|
|
"""Return True.
|
|
|
|
Data needs to be polled from the bank servers.
|
|
"""
|
|
return True
|
|
|
|
def update(self) -> None:
|
|
"""Get the current balance and currency for the account."""
|
|
bank = self._client.client
|
|
balance = bank.get_balance(self._account)
|
|
self._balance = balance.amount.amount
|
|
self._currency = balance.amount.currency
|
|
_LOGGER.debug("updated balance of account %s", self.name)
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
"""Friendly name of the sensor."""
|
|
return self._name
|
|
|
|
@property
|
|
def state(self) -> float:
|
|
"""Return the balance of the account as state."""
|
|
return self._balance
|
|
|
|
@property
|
|
def unit_of_measurement(self) -> str:
|
|
"""Use the currency as unit of measurement."""
|
|
return self._currency
|
|
|
|
@property
|
|
def device_state_attributes(self) -> dict:
|
|
"""Additional attributes of the sensor."""
|
|
attributes = {ATTR_ACCOUNT: self._account.iban, ATTR_ACCOUNT_TYPE: "balance"}
|
|
if self._client.name:
|
|
attributes[ATTR_BANK] = self._client.name
|
|
return attributes
|
|
|
|
@property
|
|
def icon(self) -> str:
|
|
"""Set the icon for the sensor."""
|
|
return ICON
|
|
|
|
|
|
class FinTsHoldingsAccount(Entity):
|
|
"""Sensor for a FinTS holdings account.
|
|
|
|
A holdings account does not contain money but rather some financial
|
|
instruments, e.g. stocks.
|
|
"""
|
|
|
|
def __init__(self, client: FinTsClient, account, name: str) -> None:
|
|
"""Initialize a FinTs holdings account."""
|
|
self._client = client
|
|
self._name = name
|
|
self._account = account
|
|
self._holdings = []
|
|
self._total: float = None
|
|
|
|
@property
|
|
def should_poll(self) -> bool:
|
|
"""Return True.
|
|
|
|
Data needs to be polled from the bank servers.
|
|
"""
|
|
return True
|
|
|
|
def update(self) -> None:
|
|
"""Get the current holdings for the account."""
|
|
bank = self._client.client
|
|
self._holdings = bank.get_holdings(self._account)
|
|
self._total = sum(h.total_value for h in self._holdings)
|
|
|
|
@property
|
|
def state(self) -> float:
|
|
"""Return total market value as state."""
|
|
return self._total
|
|
|
|
@property
|
|
def icon(self) -> str:
|
|
"""Set the icon for the sensor."""
|
|
return ICON
|
|
|
|
@property
|
|
def device_state_attributes(self) -> dict:
|
|
"""Additional attributes of the sensor.
|
|
|
|
Lists each holding of the account with the current value.
|
|
"""
|
|
attributes = {
|
|
ATTR_ACCOUNT: self._account.accountnumber,
|
|
ATTR_ACCOUNT_TYPE: "holdings",
|
|
}
|
|
if self._client.name:
|
|
attributes[ATTR_BANK] = self._client.name
|
|
for holding in self._holdings:
|
|
total_name = f"{holding.name} total"
|
|
attributes[total_name] = holding.total_value
|
|
pieces_name = f"{holding.name} pieces"
|
|
attributes[pieces_name] = holding.pieces
|
|
price_name = f"{holding.name} price"
|
|
attributes[price_name] = holding.market_value
|
|
|
|
return attributes
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
"""Friendly name of the sensor."""
|
|
return self._name
|
|
|
|
@property
|
|
def unit_of_measurement(self) -> str:
|
|
"""Get the unit of measurement.
|
|
|
|
Hardcoded to EUR, as the library does not provide the currency for the
|
|
holdings. And as FinTS is only used in Germany, most accounts will be
|
|
in EUR anyways.
|
|
"""
|
|
return "EUR"
|