This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
"""Up Banking integration setup."""
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .api import UpApi
|
||||
from .const import CONF_TOKEN, DOMAIN, PLATFORMS
|
||||
from .coordinator import UpCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
coordinator = UpCoordinator(hass, entry, UpApi(async_get_clientsession(hass), entry.data[CONF_TOKEN]))
|
||||
await coordinator.async_load()
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
||||
return True
|
||||
|
||||
|
||||
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unloaded:
|
||||
hass.data[DOMAIN].pop(entry.entry_id, None)
|
||||
return unloaded
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Small, dependency-free asynchronous client for the Up API."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .const import API_URL
|
||||
from .exceptions import UpApiError, UpAuthError
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UpApi:
|
||||
"""Read-only Up API client. It deliberately never logs request headers."""
|
||||
|
||||
def __init__(self, session: aiohttp.ClientSession, token: str) -> None:
|
||||
self._session = session
|
||||
self._headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
||||
|
||||
async def accounts(self) -> list[dict[str, Any]]:
|
||||
return await self._list("/accounts", {"page[size]": "100"})
|
||||
|
||||
async def transactions_since(self, since: str | None) -> list[dict[str, Any]]:
|
||||
params = {"page[size]": "100"}
|
||||
if since:
|
||||
params["filter[since]"] = since
|
||||
return await self._list("/transactions", params, max_pages=10)
|
||||
|
||||
async def held_transactions(self) -> list[dict[str, Any]]:
|
||||
"""Return the small current set of pending transactions for deletion detection."""
|
||||
return await self._list("/transactions", {"page[size]": "100", "filter[status]": "HELD"})
|
||||
|
||||
async def transaction(self, transaction_id: str) -> dict[str, Any] | None:
|
||||
"""Retrieve a known held transaction; ``None`` means Up deleted it."""
|
||||
url = f"{API_URL}/transactions/{transaction_id}"
|
||||
try:
|
||||
async with self._session.get(url, headers=self._headers, timeout=20) as response:
|
||||
if response.status == 404:
|
||||
return None
|
||||
if response.status in (401, 403):
|
||||
raise UpAuthError("Personal access token was rejected")
|
||||
if response.status >= 400:
|
||||
raise UpApiError(f"Up API request failed ({response.status})")
|
||||
return (await response.json())["data"]
|
||||
except aiohttp.ClientError as err:
|
||||
raise UpApiError("Unable to reach the Up API") from err
|
||||
|
||||
async def _list(
|
||||
self, path: str, params: dict[str, str], max_pages: int = 2
|
||||
) -> list[dict[str, Any]]:
|
||||
url: str | None = f"{API_URL}{path}"
|
||||
result: list[dict[str, Any]] = []
|
||||
for page in range(max_pages):
|
||||
if not url:
|
||||
break
|
||||
payload = await self._get(url, params if page == 0 else None)
|
||||
result.extend(payload.get("data", []))
|
||||
url = payload.get("links", {}).get("next")
|
||||
return result
|
||||
|
||||
async def _get(self, url: str, params: dict[str, str] | None) -> dict[str, Any]:
|
||||
try:
|
||||
async with self._session.get(url, headers=self._headers, params=params, timeout=20) as response:
|
||||
if response.status in (401, 403):
|
||||
raise UpAuthError("Personal access token was rejected")
|
||||
if response.status == 429:
|
||||
raise UpApiError("Up API rate limit reached; will retry on the next update")
|
||||
if response.status >= 400:
|
||||
raise UpApiError(f"Up API request failed ({response.status})")
|
||||
return await response.json()
|
||||
except aiohttp.ClientError as err:
|
||||
raise UpApiError("Unable to reach the Up API") from err
|
||||
@@ -0,0 +1,58 @@
|
||||
"""UI configuration and options flow for Up Banking."""
|
||||
from __future__ import annotations
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .api import UpApi
|
||||
from .const import CONF_ACCOUNTS, CONF_TOKEN, DOMAIN
|
||||
from .exceptions import UpApiError
|
||||
from .models import account_label
|
||||
|
||||
|
||||
class UpBankingConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Configure a single Up personal access token."""
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._token: str | None = None
|
||||
self._accounts: list[dict] = []
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
errors = {}
|
||||
if user_input:
|
||||
self._token = user_input[CONF_TOKEN].strip()
|
||||
try:
|
||||
self._accounts = await UpApi(async_get_clientsession(self.hass), self._token).accounts()
|
||||
except UpApiError:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
await self.async_set_unique_id("up_banking")
|
||||
self._abort_if_unique_id_configured()
|
||||
return await self.async_step_accounts()
|
||||
return self.async_show_form(step_id="user", data_schema=vol.Schema({vol.Required(CONF_TOKEN): str}), errors=errors)
|
||||
|
||||
async def async_step_accounts(self, user_input=None):
|
||||
choices = {item["id"]: account_label(item) for item in self._accounts}
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="Up Banking", data={CONF_TOKEN: self._token, CONF_ACCOUNTS: user_input[CONF_ACCOUNTS]})
|
||||
return self.async_show_form(step_id="accounts", data_schema=vol.Schema({vol.Required(CONF_ACCOUNTS, default=list(choices)): cv.multi_select(choices)}))
|
||||
|
||||
@staticmethod
|
||||
def async_get_options_flow(config_entry):
|
||||
return UpBankingOptionsFlow()
|
||||
|
||||
|
||||
class UpBankingOptionsFlow(config_entries.OptionsFlow):
|
||||
async def async_step_init(self, user_input=None):
|
||||
try:
|
||||
accounts = await UpApi(async_get_clientsession(self.hass), self.config_entry.data[CONF_TOKEN]).accounts()
|
||||
except UpApiError:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
choices = {item["id"]: account_label(item) for item in accounts}
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
selected = self.config_entry.options.get(CONF_ACCOUNTS, self.config_entry.data.get(CONF_ACCOUNTS, []))
|
||||
return self.async_show_form(step_id="init", data_schema=vol.Schema({vol.Required(CONF_ACCOUNTS, default=selected): cv.multi_select(choices)}))
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Constants for Up Banking."""
|
||||
from datetime import timedelta
|
||||
|
||||
DOMAIN = "up_banking"
|
||||
PLATFORMS = ["sensor"]
|
||||
CONF_TOKEN = "token"
|
||||
CONF_ACCOUNTS = "accounts"
|
||||
EVENT_TRANSACTION = "up_banking_transaction"
|
||||
DEFAULT_SCAN_INTERVAL = timedelta(minutes=5)
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_KEY = f"{DOMAIN}.seen_transactions"
|
||||
API_URL = "https://api.up.com.au/api/v1"
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Data coordinator and bounded transaction de-duplication."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .api import UpApi
|
||||
from .const import DEFAULT_SCAN_INTERVAL, EVENT_TRANSACTION, STORAGE_KEY, STORAGE_VERSION
|
||||
from .exceptions import UpApiError
|
||||
from .models import transaction_event
|
||||
|
||||
|
||||
class UpCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]):
|
||||
"""Fetch account balances and emit only newly observed transaction events."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, api: UpApi) -> None:
|
||||
super().__init__(hass, __import__("logging").getLogger(__name__), name="Up Banking", update_interval=DEFAULT_SCAN_INTERVAL)
|
||||
self.entry = entry
|
||||
self.api = api
|
||||
self._store = Store[dict[str, Any]](hass, STORAGE_VERSION, f"{STORAGE_KEY}.{entry.entry_id}")
|
||||
self._seen: dict[str, dict[str, Any]] = {}
|
||||
self._last_success: str | None = None
|
||||
self._primed = False
|
||||
|
||||
async def async_load(self) -> None:
|
||||
saved = await self._store.async_load() or {}
|
||||
self._seen = saved.get("seen", {})
|
||||
self._last_success = saved.get("last_success")
|
||||
self._primed = bool(self._seen)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, dict[str, Any]]:
|
||||
try:
|
||||
accounts = await self.api.accounts()
|
||||
enabled = set(self.entry.options.get("accounts", self.entry.data.get("accounts", [])))
|
||||
selected = {item["id"]: item for item in accounts if item["id"] in enabled}
|
||||
# On first ever setup, establish a baseline rather than replaying banking history.
|
||||
since = self._last_success or (datetime.now(UTC) - timedelta(minutes=10)).isoformat()
|
||||
transactions = await self.api.transactions_since(since)
|
||||
except UpApiError as err:
|
||||
raise UpdateFailed(str(err)) from err
|
||||
|
||||
for transaction in reversed(transactions):
|
||||
tx_id = transaction["id"]
|
||||
status = transaction["attributes"]["status"]
|
||||
previous = self._seen.get(tx_id)
|
||||
account_id = transaction.get("relationships", {}).get("account", {}).get("data", {}).get("id")
|
||||
if self._primed and previous and previous["status"] != status and account_id in selected:
|
||||
kind = "settled" if previous["status"] == "HELD" and status == "SETTLED" else "created"
|
||||
self.hass.bus.async_fire(EVENT_TRANSACTION, transaction_event(transaction, kind))
|
||||
elif self._primed and not previous and account_id in selected:
|
||||
self.hass.bus.async_fire(EVENT_TRANSACTION, transaction_event(transaction, "created"))
|
||||
self._seen[tx_id] = {"status": status, "event": transaction_event(transaction, "created")}
|
||||
|
||||
for tx_id, saved in list(self._seen.items()):
|
||||
if saved["status"] != "HELD":
|
||||
continue
|
||||
current = await self.api.transaction(tx_id)
|
||||
if current is None:
|
||||
# A held item that no longer exists was deleted by Up.
|
||||
payload = dict(saved["event"])
|
||||
payload.update({"event_type": "deleted", "status": "deleted"})
|
||||
if self._primed and payload.get("account_id") in selected:
|
||||
self.hass.bus.async_fire(EVENT_TRANSACTION, payload)
|
||||
self._seen.pop(tx_id)
|
||||
elif current["attributes"]["status"] == "SETTLED":
|
||||
account_id = current.get("relationships", {}).get("account", {}).get("data", {}).get("id")
|
||||
if self._primed and account_id in selected:
|
||||
self.hass.bus.async_fire(EVENT_TRANSACTION, transaction_event(current, "settled"))
|
||||
self._seen[tx_id] = {"status": "SETTLED", "event": transaction_event(current, "created")}
|
||||
|
||||
self._primed = True
|
||||
self._last_success = datetime.now(UTC).isoformat()
|
||||
# Retain only a bounded de-duplication window; account/transaction history stays in Up.
|
||||
self._seen = dict(list(self._seen.items())[-500:])
|
||||
self._store.async_delay_save(lambda: {"seen": self._seen, "last_success": self._last_success}, 10)
|
||||
return selected
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Up Banking integration exceptions."""
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
|
||||
class UpApiError(HomeAssistantError):
|
||||
"""Base error returned by the Up API."""
|
||||
|
||||
|
||||
class UpAuthError(UpApiError):
|
||||
"""The personal access token was rejected."""
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "up_banking",
|
||||
"name": "Up Banking",
|
||||
"config_flow": true,
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"version": "0.1.0",
|
||||
"documentation": "https://git.napper.au/klnap0/up-ha",
|
||||
"issue_tracker": "https://git.napper.au/klnap0/up-ha/issues",
|
||||
"codeowners": ["@klnap0"],
|
||||
"requirements": []
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Pure data conversion helpers kept independent of Home Assistant."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def account_label(account: dict[str, Any]) -> str:
|
||||
"""Human friendly account label used only in UI, never as an identifier."""
|
||||
attrs = account["attributes"]
|
||||
return f"{attrs['displayName']} ({attrs['accountType'].title()}, {attrs['ownershipType'].title()})"
|
||||
|
||||
|
||||
def transaction_event(transaction: dict[str, Any], event_type: str) -> dict[str, Any]:
|
||||
"""Return the deliberately small, automation-oriented event payload."""
|
||||
attrs = transaction["attributes"]
|
||||
money = attrs["amount"]
|
||||
account = transaction.get("relationships", {}).get("account", {}).get("data", {})
|
||||
return {
|
||||
"transaction_id": transaction["id"],
|
||||
"event_type": event_type,
|
||||
"status": attrs["status"].lower(),
|
||||
"account_id": account.get("id"),
|
||||
"amount": money["value"],
|
||||
"currency": money["currencyCode"],
|
||||
"description": attrs.get("description", ""),
|
||||
"created_at": attrs.get("createdAt"),
|
||||
"raw_text": attrs.get("rawText"),
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Balance sensors for selected Up accounts."""
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CURRENCY_AUSTRALIAN_DOLLAR
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import UpCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
|
||||
coordinator: UpCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities(UpBalanceSensor(coordinator, account_id) for account_id in coordinator.data)
|
||||
|
||||
|
||||
class UpBalanceSensor(CoordinatorEntity[UpCoordinator], SensorEntity):
|
||||
_attr_device_class = SensorDeviceClass.MONETARY
|
||||
_attr_state_class = SensorStateClass.TOTAL
|
||||
_attr_native_unit_of_measurement = CURRENCY_AUSTRALIAN_DOLLAR
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: UpCoordinator, account_id: str) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._account_id = account_id
|
||||
self._attr_unique_id = f"{account_id}_balance"
|
||||
|
||||
@property
|
||||
def _account(self) -> dict[str, Any]:
|
||||
return self.coordinator.data[self._account_id]
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return f"{self._account['attributes']['displayName']} balance"
|
||||
|
||||
@property
|
||||
def native_value(self) -> Decimal:
|
||||
return Decimal(self._account["attributes"]["balance"]["value"])
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, str]:
|
||||
attrs = self._account["attributes"]
|
||||
return {"account_type": attrs["accountType"].lower(), "ownership_type": attrs["ownershipType"].lower()}
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
return DeviceInfo(identifiers={(DOMAIN, self.coordinator.entry.entry_id)}, name="Up Banking", manufacturer="Up Banking")
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"config": {"step": {"user": {"title": "Connect to Up Banking", "description": "Enter an Up personal access token. It is stored by Home Assistant and never written to logs.", "data": {"token": "Personal access token"}}, "accounts": {"title": "Choose accounts", "description": "Only selected accounts receive balance entities.", "data": {"accounts": "Accounts"}}}, "error": {"cannot_connect": "Could not connect or the token was rejected."}},
|
||||
"options": {"step": {"init": {"title": "Choose accounts", "data": {"accounts": "Accounts"}}}}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"config":{"step":{"user":{"title":"Connect to Up Banking","description":"Enter an Up personal access token. It is stored by Home Assistant and never written to logs.","data":{"token":"Personal access token"}},"accounts":{"title":"Choose accounts","description":"Only selected accounts receive balance entities.","data":{"accounts":"Accounts"}}},"error":{"cannot_connect":"Could not connect or the token was rejected."}},"options":{"step":{"init":{"title":"Choose accounts","data":{"accounts":"Accounts"}}}}}
|
||||
Reference in New Issue
Block a user