Home Assistant Unofficial Reference 2024.12.1
sensor.py
Go to the documentation of this file.
1 """Read the balance of your bank accounts via FinTS."""
2 
3 from __future__ import annotations
4 
5 from collections import namedtuple
6 from datetime import timedelta
7 import logging
8 from typing import Any
9 
10 from fints.client import FinTS3PinTanClient
11 from fints.models import SEPAAccount
12 from propcache import cached_property
13 import voluptuous as vol
14 
16  PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
17  SensorEntity,
18 )
19 from homeassistant.const import CONF_NAME, CONF_PIN, CONF_URL, CONF_USERNAME
20 from homeassistant.core import HomeAssistant
22 from homeassistant.helpers.entity_platform import AddEntitiesCallback
23 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
24 
25 _LOGGER = logging.getLogger(__name__)
26 
27 SCAN_INTERVAL = timedelta(hours=4)
28 
29 ICON = "mdi:currency-eur"
30 
31 BankCredentials = namedtuple("BankCredentials", "blz login pin url") # noqa: PYI024
32 
33 CONF_BIN = "bank_identification_number"
34 CONF_ACCOUNTS = "accounts"
35 CONF_HOLDINGS = "holdings"
36 CONF_ACCOUNT = "account"
37 
38 ATTR_ACCOUNT = CONF_ACCOUNT
39 ATTR_BANK = "bank"
40 ATTR_ACCOUNT_TYPE = "account_type"
41 
42 SCHEMA_ACCOUNTS = vol.Schema(
43  {
44  vol.Required(CONF_ACCOUNT): cv.string,
45  vol.Optional(CONF_NAME, default=None): vol.Any(None, cv.string),
46  }
47 )
48 
49 PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
50  {
51  vol.Required(CONF_BIN): cv.string,
52  vol.Required(CONF_USERNAME): cv.string,
53  vol.Required(CONF_PIN): cv.string,
54  vol.Required(CONF_URL): cv.string,
55  vol.Optional(CONF_NAME): cv.string,
56  vol.Optional(CONF_ACCOUNTS, default=[]): cv.ensure_list(SCHEMA_ACCOUNTS),
57  vol.Optional(CONF_HOLDINGS, default=[]): cv.ensure_list(SCHEMA_ACCOUNTS),
58  }
59 )
60 
61 
63  hass: HomeAssistant,
64  config: ConfigType,
65  add_entities: AddEntitiesCallback,
66  discovery_info: DiscoveryInfoType | None = None,
67 ) -> None:
68  """Set up the sensors.
69 
70  Login to the bank and get a list of existing accounts. Create a
71  sensor for each account.
72  """
73  credentials = BankCredentials(
74  config[CONF_BIN], config[CONF_USERNAME], config[CONF_PIN], config[CONF_URL]
75  )
76  fints_name = config.get(CONF_NAME, config[CONF_BIN])
77 
78  account_config = {
79  acc[CONF_ACCOUNT]: acc[CONF_NAME] for acc in config[CONF_ACCOUNTS]
80  }
81 
82  holdings_config = {
83  acc[CONF_ACCOUNT]: acc[CONF_NAME] for acc in config[CONF_HOLDINGS]
84  }
85 
86  client = FinTsClient(credentials, fints_name, account_config, holdings_config)
87  balance_accounts, holdings_accounts = client.detect_accounts()
88  accounts: list[SensorEntity] = []
89 
90  for account in balance_accounts:
91  if config[CONF_ACCOUNTS] and account.iban not in account_config:
92  _LOGGER.debug("Skipping account %s for bank %s", account.iban, fints_name)
93  continue
94 
95  if not (account_name := account_config.get(account.iban)):
96  account_name = f"{fints_name} - {account.iban}"
97  accounts.append(FinTsAccount(client, account, account_name))
98  _LOGGER.debug("Creating account %s for bank %s", account.iban, fints_name)
99 
100  for account in holdings_accounts:
101  if config[CONF_HOLDINGS] and account.accountnumber not in holdings_config:
102  _LOGGER.debug(
103  "Skipping holdings %s for bank %s", account.accountnumber, fints_name
104  )
105  continue
106 
107  account_name = holdings_config.get(account.accountnumber)
108  if not account_name:
109  account_name = f"{fints_name} - {account.accountnumber}"
110  accounts.append(FinTsHoldingsAccount(client, account, account_name))
111  _LOGGER.debug(
112  "Creating holdings %s for bank %s", account.accountnumber, fints_name
113  )
114 
115  add_entities(accounts, True)
116 
117 
119  """Wrapper around the FinTS3PinTanClient.
120 
121  Use this class as Context Manager to get the FinTS3Client object.
122  """
123 
124  def __init__(
125  self,
126  credentials: BankCredentials,
127  name: str,
128  account_config: dict,
129  holdings_config: dict,
130  ) -> None:
131  """Initialize a FinTsClient."""
132  self._credentials_credentials = credentials
133  self._account_information_account_information: dict[str, dict] = {}
134  self._account_information_fetched_account_information_fetched = False
135  self.namename = name
136  self.account_configaccount_config = account_config
137  self.holdings_configholdings_config = holdings_config
138 
139  @cached_property
140  def client(self) -> FinTS3PinTanClient:
141  """Get the FinTS client object.
142 
143  The FinTS library persists the current dialog with the bank
144  and stores bank capabilities. So caching the client is beneficial.
145  """
146 
147  return FinTS3PinTanClient(
148  self._credentials_credentials.blz,
149  self._credentials_credentials.login,
150  self._credentials_credentials.pin,
151  self._credentials_credentials.url,
152  )
153 
154  def get_account_information(self, iban: str) -> dict | None:
155  """Get a dictionary of account IBANs as key and account information as value."""
156 
157  if not self._account_information_fetched_account_information_fetched:
158  self._account_information_account_information = {
159  account["iban"]: account
160  for account in self.clientclient.get_information()["accounts"]
161  }
162  self._account_information_fetched_account_information_fetched = True
163 
164  return self._account_information_account_information.get(iban, None)
165 
166  def is_balance_account(self, account: SEPAAccount) -> bool:
167  """Determine if the given account is of type balance account."""
168  if not account.iban:
169  return False
170 
171  account_information = self.get_account_informationget_account_information(account.iban)
172  if not account_information:
173  return False
174 
175  if account_type := account_information.get("type"):
176  return 1 <= account_type <= 9
177 
178  if (
179  account_information["iban"] in self.account_configaccount_config
180  or account_information["account_number"] in self.account_configaccount_config
181  ):
182  return True
183 
184  return False
185 
186  def is_holdings_account(self, account: SEPAAccount) -> bool:
187  """Determine if the given account of type holdings account."""
188  if not account.iban:
189  return False
190 
191  account_information = self.get_account_informationget_account_information(account.iban)
192  if not account_information:
193  return False
194 
195  if account_type := account_information.get("type"):
196  return 30 <= account_type <= 39
197 
198  if (
199  account_information["iban"] in self.holdings_configholdings_config
200  or account_information["account_number"] in self.holdings_configholdings_config
201  ):
202  return True
203 
204  return False
205 
206  def detect_accounts(self) -> tuple[list, list]:
207  """Identify the accounts of the bank."""
208 
209  balance_accounts = []
210  holdings_accounts = []
211 
212  for account in self.clientclient.get_sepa_accounts():
213  if self.is_balance_accountis_balance_account(account):
214  balance_accounts.append(account)
215 
216  elif self.is_holdings_accountis_holdings_account(account):
217  holdings_accounts.append(account)
218 
219  else:
220  _LOGGER.warning(
221  "Could not determine type of account %s from %s",
222  account.iban,
223  self.clientclient.user_id,
224  )
225 
226  return balance_accounts, holdings_accounts
227 
228 
230  """Sensor for a FinTS balance account.
231 
232  A balance account contains an amount of money (=balance). The amount may
233  also be negative.
234  """
235 
236  def __init__(self, client: FinTsClient, account, name: str) -> None:
237  """Initialize a FinTs balance account."""
238  self._client_client = client
239  self._account_account = account
240  self._attr_name_attr_name = name
241  self._attr_icon_attr_icon = ICON
242  self._attr_extra_state_attributes_attr_extra_state_attributes = {
243  ATTR_ACCOUNT: self._account_account.iban,
244  ATTR_ACCOUNT_TYPE: "balance",
245  }
246  if self._client_client.name:
247  self._attr_extra_state_attributes_attr_extra_state_attributes[ATTR_BANK] = self._client_client.name
248 
249  def update(self) -> None:
250  """Get the current balance and currency for the account."""
251  bank = self._client_client.client
252  balance = bank.get_balance(self._account_account)
253  self._attr_native_value_attr_native_value = balance.amount.amount
254  self._attr_native_unit_of_measurement_attr_native_unit_of_measurement = balance.amount.currency
255  _LOGGER.debug("updated balance of account %s", self.namename)
256 
257 
259  """Sensor for a FinTS holdings account.
260 
261  A holdings account does not contain money but rather some financial
262  instruments, e.g. stocks.
263  """
264 
265  def __init__(self, client: FinTsClient, account, name: str) -> None:
266  """Initialize a FinTs holdings account."""
267  self._client_client = client
268  self._attr_name_attr_name = name
269  self._account_account = account
270  self._holdings_holdings: list[Any] = []
271  self._attr_icon_attr_icon = ICON
272  self._attr_native_unit_of_measurement_attr_native_unit_of_measurement = "EUR"
273 
274  def update(self) -> None:
275  """Get the current holdings for the account."""
276  bank = self._client_client.client
277  self._holdings_holdings = bank.get_holdings(self._account_account)
278  self._attr_native_value_attr_native_value = sum(h.total_value for h in self._holdings_holdings)
279 
280  @property
281  def extra_state_attributes(self) -> dict[str, Any]:
282  """Additional attributes of the sensor.
283 
284  Lists each holding of the account with the current value.
285  """
286  attributes = {
287  ATTR_ACCOUNT: self._account_account.accountnumber,
288  ATTR_ACCOUNT_TYPE: "holdings",
289  }
290  if self._client_client.name:
291  attributes[ATTR_BANK] = self._client_client.name
292  for holding in self._holdings_holdings:
293  total_name = f"{holding.name} total"
294  attributes[total_name] = holding.total_value
295  pieces_name = f"{holding.name} pieces"
296  attributes[pieces_name] = holding.pieces
297  price_name = f"{holding.name} price"
298  attributes[price_name] = holding.market_value
299 
300  return attributes
None __init__(self, FinTsClient client, account, str name)
Definition: sensor.py:236
bool is_balance_account(self, SEPAAccount account)
Definition: sensor.py:166
None __init__(self, BankCredentials credentials, str name, dict account_config, dict holdings_config)
Definition: sensor.py:130
bool is_holdings_account(self, SEPAAccount account)
Definition: sensor.py:186
dict|None get_account_information(self, str iban)
Definition: sensor.py:154
None __init__(self, FinTsClient client, account, str name)
Definition: sensor.py:265
str|UndefinedType|None name(self)
Definition: entity.py:738
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
None setup_platform(HomeAssistant hass, ConfigType config, AddEntitiesCallback add_entities, DiscoveryInfoType|None discovery_info=None)
Definition: sensor.py:67
def add_entities(account, async_add_entities, tracked)
Definition: sensor.py:40