This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
.storage/
|
||||||
|
config/
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -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"}}}}}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "Up Banking",
|
||||||
|
"render_readme": true,
|
||||||
|
"homeassistant": "2025.1.0"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[project]
|
||||||
|
name = "up-ha"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
@@ -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"}
|
||||||
Reference in New Issue
Block a user