Initial Up Banking Home Assistant integration
Validate / syntax (push) Canceled after 0s

This commit is contained in:
git
2026-09-27 17:36:54 +10:00
commit 264a7dd42f
17 changed files with 486 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
name: Validate
on:
push:
pull_request:
jobs:
syntax:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- run: python -m compileall custom_components
- run: python -m json.tool hacs.json
- run: python -m json.tool custom_components/up_banking/manifest.json
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.py[cod]
.pytest_cache/
.coverage
.storage/
config/
+68
View File
@@ -0,0 +1,68 @@
# Up for Home Assistant
**Up Banking** is a small, read-only Home Assistant custom integration for the [Up Banking API](https://developer.up.com.au/). It is designed for balances in dashboards and transaction activity in automations; Up remains the source of truth for accounts, categories, budgets, and history.
## What it provides
- One AUD balance sensor for each account selected during setup, including Savers and 2Up (joint) accounts.
- Stable entity identity based on Up's account ID, so changing an account name does not create a new entity.
- The `up_banking_transaction` Home Assistant event for new activity. Its compact data includes `transaction_id`, `event_type` (`created`, `settled`, or `deleted`), `status` (`held`, `settled`, or `deleted`), `account_id`, `amount`, `currency`, `description`, and `created_at`.
- A five-minute coordinated poll. It reads changed activity since the last successful poll, so a Home Assistant restart or brief outage catches up. It retains at most 500 small de-duplication records in HA storage and does **not** put transaction history on entities or into Recorder attributes.
## Install with HACS
1. In HACS, open the three-dot menu, choose **Custom repositories**, then add `https://git.napper.au/klnap0/up-ha` as an **Integration**.
2. Search for **Up Banking**, install it, then restart Home Assistant.
3. Go to **Settings → Devices & services → Add integration**, select **Up Banking**, and enter an Up personal access token from the [Up developer portal](https://developer.up.com.au/).
4. Select the accounts to expose. Open the integration's **Configure** option later to change that selection.
The token is held in Home Assistant's config-entry storage; this integration deliberately never logs it. Do not paste it into YAML, automations, issues, or chat.
For a manual install, copy `custom_components/up_banking` into `/config/custom_components/`, restart Home Assistant, then follow steps 3–4.
## Automation example
```yaml
automation:
- alias: Tell me about newly held purchases
triggers:
- trigger: event
event_type: up_banking_transaction
event_data:
event_type: created
status: held
actions:
- action: notify.mobile_app_phone
data:
message: >-
{{ trigger.event.data.description }}:
{{ trigger.event.data.amount }} {{ trigger.event.data.currency }}
```
Amounts use Up's signed decimal representation: an outgoing purchase is normally negative.
## Webhooks and polling
Up webhooks are signed using `X-Up-Authenticity-Signature` with an HMAC-SHA256 secret returned only when the webhook is created. They are useful for faster events, but the target must be a public URL that Up can reach and respond to with HTTP 200. That requires exposing Home Assistant through a securely configured public URL or using a separate relay; it also needs secure secret lifecycle handling. This initial release deliberately does not create webhooks or expose an endpoint. The default five-minute polling is private, requires no inbound access, and catches up after downtime.
## Limits and behaviour
- Up is a cloud API, so values cannot update while the API or Internet connection is unavailable.
- Polling is not instant. A transaction can be held, settled, or (for an abandoned hold) deleted; those changes generate distinct events when observed. A very large backlog beyond the API pagination safety limit may need a later refresh; normal outages are handled.
- Home loans are displayed if selected because the current Up API lists them; the original focus is personal, 2Up, and Saver accounts.
- This integration is read-only. It does not create transactions, modify categories, sync budgets, or mirror full financial history into Home Assistant.
## Updating
Use HACS's update button when a release is available, then restart Home Assistant. For a manual install, replace the integration directory and restart. Before updating, make a Home Assistant backup as usual.
## Development
The implementation uses Home Assistant's built-in HTTP client and coordinator—there is no third-party API client to maintain. Run the checks with a Home Assistant development environment:
```powershell
python -m compileall custom_components
python -m pytest
```
`tests/test_models.py` covers the account UI label and the compact automation event contract. Integration behaviour should additionally be exercised against Up's demo API token in an isolated Home Assistant development instance.
+31
View File
@@ -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
+74
View File
@@ -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)}))
+12
View File
@@ -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": []
}
+28
View File
@@ -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"),
}
+54
View File
@@ -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"}}}}}
+5
View File
@@ -0,0 +1,5 @@
{
"name": "Up Banking",
"render_readme": true,
"homeassistant": "2025.1.0"
}
+7
View File
@@ -0,0 +1,7 @@
[project]
name = "up-ha"
version = "0.1.0"
requires-python = ">=3.13"
[tool.pytest.ini_options]
testpaths = ["tests"]
+16
View File
@@ -0,0 +1,16 @@
"""Tests for event payloads: no Home Assistant runtime is required."""
from custom_components.up_banking.models import account_label, transaction_event
def test_account_label_includes_saver_and_ownership():
account = {"attributes": {"displayName": "Rainy day", "accountType": "SAVER", "ownershipType": "JOINT"}}
assert account_label(account) == "Rainy day (Saver, Joint)"
def test_transaction_event_is_small_and_automation_ready():
transaction = {
"id": "tx-1",
"attributes": {"status": "HELD", "amount": {"value": "-12.34", "currencyCode": "AUD"}, "description": "Coffee", "createdAt": "2026-01-01T00:00:00Z", "rawText": "Cafe"},
"relationships": {"account": {"data": {"id": "account-1"}}},
}
assert transaction_event(transaction, "created") == {"transaction_id": "tx-1", "event_type": "created", "status": "held", "account_id": "account-1", "amount": "-12.34", "currency": "AUD", "description": "Coffee", "created_at": "2026-01-01T00:00:00Z", "raw_text": "Cafe"}