From 07d739c14e21d09b157ff0fcc7418308dd8ad5a8 Mon Sep 17 00:00:00 2001 From: Markus Ressel Date: Thu, 4 Apr 2019 19:18:54 +0200 Subject: [PATCH] Add N26 component (#22684) * upgraded n26 dependency removed card_id config parameter (unnecessary) get_token is now a public method inside n26 dependency * Add manifest.json --- .coveragerc | 1 + homeassistant/components/n26/__init__.py | 153 +++++++++++++ homeassistant/components/n26/const.py | 7 + homeassistant/components/n26/manifest.json | 10 + homeassistant/components/n26/sensor.py | 248 +++++++++++++++++++++ homeassistant/components/n26/switch.py | 64 ++++++ requirements_all.txt | 3 + 7 files changed, 486 insertions(+) create mode 100644 homeassistant/components/n26/__init__.py create mode 100644 homeassistant/components/n26/const.py create mode 100644 homeassistant/components/n26/manifest.json create mode 100644 homeassistant/components/n26/sensor.py create mode 100644 homeassistant/components/n26/switch.py diff --git a/.coveragerc b/.coveragerc index f8d8b0fc521..957b3402c46 100644 --- a/.coveragerc +++ b/.coveragerc @@ -366,6 +366,7 @@ omit = homeassistant/components/mystrom/binary_sensor.py homeassistant/components/mystrom/light.py homeassistant/components/mystrom/switch.py + homeassistant/components/n26/* homeassistant/components/nad/media_player.py homeassistant/components/nadtcp/media_player.py homeassistant/components/nanoleaf/light.py diff --git a/homeassistant/components/n26/__init__.py b/homeassistant/components/n26/__init__.py new file mode 100644 index 00000000000..8f4ade9c87f --- /dev/null +++ b/homeassistant/components/n26/__init__.py @@ -0,0 +1,153 @@ +"""Support for N26 bank accounts.""" +from datetime import datetime, timedelta, timezone +import logging + +import voluptuous as vol + +from homeassistant.const import ( + CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import load_platform +from homeassistant.util import Throttle + +from .const import DATA, DOMAIN + +REQUIREMENTS = ['n26==0.2.7'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_SCAN_INTERVAL = timedelta(minutes=30) + +# define configuration parameters +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, + default=DEFAULT_SCAN_INTERVAL): cv.time_period, + }), +}, extra=vol.ALLOW_EXTRA) + +N26_COMPONENTS = [ + 'sensor', + 'switch' +] + + +def setup(hass, config): + """Set up N26 Component.""" + user = config[DOMAIN][CONF_USERNAME] + password = config[DOMAIN][CONF_PASSWORD] + + from n26 import api, config as api_config + api = api.Api(api_config.Config(user, password)) + + from requests import HTTPError + try: + api.get_token() + except HTTPError as err: + _LOGGER.error(str(err)) + return False + + api_data = N26Data(api) + api_data.update() + + hass.data[DOMAIN] = {} + hass.data[DOMAIN][DATA] = api_data + + # Load components for supported devices + for component in N26_COMPONENTS: + load_platform(hass, component, DOMAIN, {}, config) + + return True + + +def timestamp_ms_to_date(epoch_ms) -> datetime or None: + """Convert millisecond timestamp to datetime.""" + if epoch_ms: + return datetime.fromtimestamp(epoch_ms / 1000, timezone.utc) + + +class N26Data: + """Handle N26 API object and limit updates.""" + + def __init__(self, api): + """Initialize the data object.""" + self._api = api + + self._account_info = {} + self._balance = {} + self._limits = {} + self._account_statuses = {} + + self._cards = {} + self._spaces = {} + + @property + def api(self): + """Return N26 api client.""" + return self._api + + @property + def account_info(self): + """Return N26 account info.""" + return self._account_info + + @property + def balance(self): + """Return N26 account balance.""" + return self._balance + + @property + def limits(self): + """Return N26 account limits.""" + return self._limits + + @property + def account_statuses(self): + """Return N26 account statuses.""" + return self._account_statuses + + @property + def cards(self): + """Return N26 cards.""" + return self._cards + + def card(self, card_id: str, default: dict = None): + """Return a card by its id or the given default.""" + return next((card for card in self.cards if card["id"] == card_id), + default) + + @property + def spaces(self): + """Return N26 spaces.""" + return self._spaces + + def space(self, space_id: str, default: dict = None): + """Return a space by its id or the given default.""" + return next((space for space in self.spaces["spaces"] + if space["id"] == space_id), default) + + @Throttle(min_time=DEFAULT_SCAN_INTERVAL * 0.8) + def update_account(self): + """Get the latest account data from N26.""" + self._account_info = self._api.get_account_info() + self._balance = self._api.get_balance() + self._limits = self._api.get_account_limits() + self._account_statuses = self._api.get_account_statuses() + + @Throttle(min_time=DEFAULT_SCAN_INTERVAL * 0.8) + def update_cards(self): + """Get the latest cards data from N26.""" + self._cards = self._api.get_cards() + + @Throttle(min_time=DEFAULT_SCAN_INTERVAL * 0.8) + def update_spaces(self): + """Get the latest spaces data from N26.""" + self._spaces = self._api.get_spaces() + + def update(self): + """Get the latest data from N26.""" + self.update_account() + self.update_cards() + self.update_spaces() diff --git a/homeassistant/components/n26/const.py b/homeassistant/components/n26/const.py new file mode 100644 index 00000000000..0a640d0f34e --- /dev/null +++ b/homeassistant/components/n26/const.py @@ -0,0 +1,7 @@ +"""Provides the constants needed for component.""" +DOMAIN = "n26" + +DATA = "data" + +CARD_STATE_ACTIVE = "M_ACTIVE" +CARD_STATE_BLOCKED = "M_DISABLED" diff --git a/homeassistant/components/n26/manifest.json b/homeassistant/components/n26/manifest.json new file mode 100644 index 00000000000..b49932887d5 --- /dev/null +++ b/homeassistant/components/n26/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "n26", + "name": "N26", + "documentation": "https://www.home-assistant.io/components/n26", + "requirements": [ + "n26==0.2.7" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/n26/sensor.py b/homeassistant/components/n26/sensor.py new file mode 100644 index 00000000000..682cd5dae68 --- /dev/null +++ b/homeassistant/components/n26/sensor.py @@ -0,0 +1,248 @@ +"""Support for N26 bank account sensors.""" +import logging + +from homeassistant.helpers.entity import Entity + +from . import DEFAULT_SCAN_INTERVAL, DOMAIN, timestamp_ms_to_date +from .const import DATA + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['n26'] + +SCAN_INTERVAL = DEFAULT_SCAN_INTERVAL + +ATTR_IBAN = "account" +ATTR_USABLE_BALANCE = "usable_balance" +ATTR_BANK_BALANCE = "bank_balance" + +ATTR_ACC_OWNER_TITLE = "owner_title" +ATTR_ACC_OWNER_FIRST_NAME = "owner_first_name" +ATTR_ACC_OWNER_LAST_NAME = "owner_last_name" +ATTR_ACC_OWNER_GENDER = "owner_gender" +ATTR_ACC_OWNER_BIRTH_DATE = "owner_birth_date" +ATTR_ACC_OWNER_EMAIL = "owner_email" +ATTR_ACC_OWNER_PHONE_NUMBER = "owner_phone_number" + +ICON_ACCOUNT = 'mdi:currency-eur' +ICON_CARD = 'mdi:credit-card' +ICON_SPACE = 'mdi:crop-square' + + +def setup_platform( + hass, config, add_entities, discovery_info=None): + """Set up the N26 sensor platform.""" + api_data = hass.data[DOMAIN][DATA] + + sensor_entities = [N26Account(api_data)] + + for card in api_data.cards: + sensor_entities.append(N26Card(api_data, card)) + + for space in api_data.spaces["spaces"]: + sensor_entities.append(N26Space(api_data, space)) + + add_entities(sensor_entities) + + +class N26Account(Entity): + """Sensor for a N26 balance account. + + A balance account contains an amount of money (=balance). The amount may + also be negative. + """ + + def __init__(self, api_data) -> None: + """Initialize a N26 balance account.""" + self._data = api_data + self._iban = self._data.balance["iban"] + + def update(self) -> None: + """Get the current balance and currency for the account.""" + self._data.update_account() + + @property + def unique_id(self): + """Return the unique ID of the entity.""" + return self._iban[-4:] + + @property + def name(self) -> str: + """Friendly name of the sensor.""" + return "n26_{}".format(self._iban[-4:]) + + @property + def state(self) -> float: + """Return the balance of the account as state.""" + if self._data.balance is None: + return None + + return self._data.balance.get("availableBalance") + + @property + def unit_of_measurement(self) -> str: + """Use the currency as unit of measurement.""" + if self._data.balance is None: + return None + + return self._data.balance.get("currency") + + @property + def device_state_attributes(self) -> dict: + """Additional attributes of the sensor.""" + attributes = { + ATTR_IBAN: self._data.balance.get("iban"), + ATTR_BANK_BALANCE: self._data.balance.get("bankBalance"), + ATTR_USABLE_BALANCE: self._data.balance.get("usableBalance"), + ATTR_ACC_OWNER_TITLE: self._data.account_info.get("title"), + ATTR_ACC_OWNER_FIRST_NAME: + self._data.account_info.get("kycFirstName"), + ATTR_ACC_OWNER_LAST_NAME: + self._data.account_info.get("kycLastName"), + ATTR_ACC_OWNER_GENDER: self._data.account_info.get("gender"), + ATTR_ACC_OWNER_BIRTH_DATE: timestamp_ms_to_date( + self._data.account_info.get("birthDate")), + ATTR_ACC_OWNER_EMAIL: self._data.account_info.get("email"), + ATTR_ACC_OWNER_PHONE_NUMBER: + self._data.account_info.get("mobilePhoneNumber"), + } + + for limit in self._data.limits: + limit_attr_name = "limit_{}".format(limit["limit"].lower()) + attributes[limit_attr_name] = limit["amount"] + + return attributes + + @property + def icon(self) -> str: + """Set the icon for the sensor.""" + return ICON_ACCOUNT + + +class N26Card(Entity): + """Sensor for a N26 card.""" + + def __init__(self, api_data, card) -> None: + """Initialize a N26 card.""" + self._data = api_data + self._account_name = api_data.balance["iban"][-4:] + self._card = card + + def update(self) -> None: + """Get the current balance and currency for the account.""" + self._data.update_cards() + self._card = self._data.card(self._card["id"], self._card) + + @property + def unique_id(self): + """Return the unique ID of the entity.""" + return self._card["id"] + + @property + def name(self) -> str: + """Friendly name of the sensor.""" + return "{}_card_{}".format( + self._account_name.lower(), self._card["id"]) + + @property + def state(self) -> float: + """Return the balance of the account as state.""" + return self._card["status"] + + @property + def device_state_attributes(self) -> dict: + """Additional attributes of the sensor.""" + attributes = { + "apple_pay_eligible": self._card.get("applePayEligible"), + "card_activated": timestamp_ms_to_date( + self._card.get("cardActivated")), + "card_product": self._card.get("cardProduct"), + "card_product_type": self._card.get("cardProductType"), + "card_settings_id": self._card.get("cardSettingsId"), + "card_Type": self._card.get("cardType"), + "design": self._card.get("design"), + "exceet_actual_delivery_date": + self._card.get("exceetActualDeliveryDate"), + "exceet_card_status": self._card.get("exceetCardStatus"), + "exceet_expected_delivery_date": + self._card.get("exceetExpectedDeliveryDate"), + "exceet_express_card_delivery": + self._card.get("exceetExpressCardDelivery"), + "exceet_express_card_delivery_email_sent": + self._card.get("exceetExpressCardDeliveryEmailSent"), + "exceet_express_card_delivery_tracking_id": + self._card.get("exceetExpressCardDeliveryTrackingId"), + "expiration_date": timestamp_ms_to_date( + self._card.get("expirationDate")), + "google_pay_eligible": self._card.get("googlePayEligible"), + "masked_pan": self._card.get("maskedPan"), + "membership": self._card.get("membership"), + "mpts_card": self._card.get("mptsCard"), + "pan": self._card.get("pan"), + "pin_defined": timestamp_ms_to_date(self._card.get("pinDefined")), + "username_on_card": self._card.get("usernameOnCard"), + } + return attributes + + @property + def icon(self) -> str: + """Set the icon for the sensor.""" + return ICON_CARD + + +class N26Space(Entity): + """Sensor for a N26 space.""" + + def __init__(self, api_data, space) -> None: + """Initialize a N26 space.""" + self._data = api_data + self._space = space + + def update(self) -> None: + """Get the current balance and currency for the account.""" + self._data.update_spaces() + self._space = self._data.space(self._space["id"], self._space) + + @property + def unique_id(self): + """Return the unique ID of the entity.""" + return "space_{}".format(self._space["name"].lower()) + + @property + def name(self) -> str: + """Friendly name of the sensor.""" + return self._space["name"] + + @property + def state(self) -> float: + """Return the balance of the account as state.""" + return self._space["balance"]["availableBalance"] + + @property + def unit_of_measurement(self) -> str: + """Use the currency as unit of measurement.""" + return self._space["balance"]["currency"] + + @property + def device_state_attributes(self) -> dict: + """Additional attributes of the sensor.""" + goal_value = "" + if "goal" in self._space: + goal_value = self._space.get("goal").get("amount") + + attributes = { + "name": self._space.get("name"), + "goal": goal_value, + "background_image_url": self._space.get("backgroundImageUrl"), + "image_url": self._space.get("imageUrl"), + "is_card_attached": self._space.get("isCardAttached"), + "is_hidden_from_balance": self._space.get("isHiddenFromBalance"), + "is_locked": self._space.get("isLocked"), + "is_primary": self._space.get("isPrimary"), + } + return attributes + + @property + def icon(self) -> str: + """Set the icon for the sensor.""" + return ICON_SPACE diff --git a/homeassistant/components/n26/switch.py b/homeassistant/components/n26/switch.py new file mode 100644 index 00000000000..0e7455ea703 --- /dev/null +++ b/homeassistant/components/n26/switch.py @@ -0,0 +1,64 @@ +"""Support for N26 switches.""" +import logging + +from homeassistant.components.switch import SwitchDevice + +from . import DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import CARD_STATE_ACTIVE, CARD_STATE_BLOCKED, DATA + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['n26'] + +SCAN_INTERVAL = DEFAULT_SCAN_INTERVAL + + +def setup_platform( + hass, config, add_entities, discovery_info=None): + """Set up the N26 switch platform.""" + api_data = hass.data[DOMAIN][DATA] + + switch_entities = [] + for card in api_data.cards: + switch_entities.append(N26CardSwitch(api_data, card)) + + add_entities(switch_entities) + + +class N26CardSwitch(SwitchDevice): + """Representation of a N26 card block/unblock switch.""" + + def __init__(self, api_data, card: dict): + """Initialize the N26 card block/unblock switch.""" + self._data = api_data + self._card = card + + @property + def unique_id(self): + """Return the unique ID of the entity.""" + return self._card["id"] + + @property + def name(self) -> str: + """Friendly name of the sensor.""" + return "card_{}".format(self._card["id"]) + + @property + def is_on(self): + """Return true if switch is on.""" + return self._card["status"] == CARD_STATE_ACTIVE + + def turn_on(self, **kwargs): + """Block the card.""" + self._data.api.unblock_card(self._card["id"]) + self._card["status"] = CARD_STATE_ACTIVE + + def turn_off(self, **kwargs): + """Unblock the card.""" + self._data.api.block_card(self._card["id"]) + self._card["status"] = CARD_STATE_BLOCKED + + def update(self): + """Update the switch state.""" + self._data.update_cards() + self._card = self._data.card(self._card["id"], self._card) diff --git a/requirements_all.txt b/requirements_all.txt index 69430c8cf98..052f8990c20 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -725,6 +725,9 @@ mycroftapi==2.0 # homeassistant.components.usps myusps==1.3.2 +# homeassistant.components.n26 +n26==0.2.7 + # homeassistant.components.nad.media_player nad_receiver==0.0.11