├── hacs.json ├── .github └── workflows │ ├── hassfest.yml │ └── validate.yml ├── custom_components └── actualbudget │ ├── const.py │ ├── manifest.json │ ├── __init__.py │ ├── strings.json │ ├── translations │ ├── da.json │ ├── en.json │ └── pt.json │ ├── config_flow.py │ ├── actualbudget.py │ └── sensor.py ├── README.md └── .gitignore /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "actualbudget", 3 | "homeassistant": "2025.1" 4 | } 5 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v4" 14 | - uses: "home-assistant/actions/hassfest@master" 15 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | validate-hacs: 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - name: HACS validation 15 | uses: "hacs/action@main" 16 | with: 17 | category: "integration" 18 | -------------------------------------------------------------------------------- /custom_components/actualbudget/const.py: -------------------------------------------------------------------------------- 1 | DOMAIN = "actualbudget" 2 | PLATFORM = "sensor" 3 | DOMAIN_DATA = f"{DOMAIN}_data" 4 | 5 | DEFAULT_ICON = "mdi:bank" 6 | 7 | CONFIG_ENDPOINT = "endpoint" 8 | CONFIG_PASSWORD = "password" 9 | CONFIG_FILE = "file" 10 | CONFIG_UNIT = "unit" 11 | CONFIG_PREFIX = "prefix" 12 | CONFIG_CERT = "cert" 13 | CONFIG_ENCRYPT_PASSWORD = "encrypt_password" 14 | -------------------------------------------------------------------------------- /custom_components/actualbudget/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "actualbudget", 3 | "name": "actualbudget Integration", 4 | "codeowners": ["@jlvcm"], 5 | "config_flow": true, 6 | "dependencies": [], 7 | "documentation": "https://github.com/jlvcm/ha-actualbudget/blob/main/README.md", 8 | "iot_class": "cloud_polling", 9 | "issue_tracker": "https://github.com/jlvcm/ha-actualbudget/issues", 10 | "requirements": ["actualpy==0.10.0"], 11 | "version": "2.0.1" 12 | } 13 | -------------------------------------------------------------------------------- /custom_components/actualbudget/__init__.py: -------------------------------------------------------------------------------- 1 | """The actualbudget integration.""" 2 | 3 | from __future__ import annotations 4 | import logging 5 | 6 | from homeassistant.config_entries import ConfigEntry 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.helpers.typing import ConfigType 9 | 10 | from .const import DOMAIN 11 | 12 | __version__ = "1.1.0" 13 | _LOGGER = logging.getLogger(__name__) 14 | _LOGGER.setLevel(logging.DEBUG) 15 | 16 | PLATFORMS: list[str] = ["sensor"] 17 | 18 | 19 | async def async_setup(hass: HomeAssistant, config: ConfigType): 20 | """Start configuring the API.""" 21 | _LOGGER.debug("Start 'async_setup'...") 22 | 23 | hass.data.setdefault(DOMAIN, {}) 24 | 25 | return True 26 | 27 | 28 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): 29 | """Set up the component from a config entry.""" 30 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 31 | return True 32 | 33 | 34 | async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: 35 | """Reload config entry.""" 36 | await async_setup_entry(hass, entry) 37 | -------------------------------------------------------------------------------- /custom_components/actualbudget/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "actualbudget", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "title": "Login", 7 | "description": "Login to actualbudget", 8 | "data": { 9 | "endpoint": "actualbudget endpoint", 10 | "password": "password", 11 | "encrypt_password": "encrypt password", 12 | "file": "file id", 13 | "cert": "certificate", 14 | "unit": "unit" 15 | }, 16 | "data_description": { 17 | "endpoint": "The endpoint of the actualbudget server", 18 | "password": "The password of the actualbudget server", 19 | "encrypt_password": "The password to encrypt the actualbudget file", 20 | "file": "The file id of the actualbudget server", 21 | "cert": "The certificate of the actualbudget server, write 'SKIP' to skip certificate validation", 22 | "unit": "The currency of the actualbudget server" 23 | } 24 | } 25 | }, 26 | "error": { 27 | "failed_to_connect": "Failed to connect", 28 | "failed_cert": "Failed to connect: certificate error", 29 | "failed_file": "Failed to connect: invalid file id", 30 | "failed_unknown": "Failed to connect: unknown error" 31 | }, 32 | "abort": { 33 | "already_configured": "Device is already configured" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /custom_components/actualbudget/translations/da.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "actualbudget", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "title": "Login", 7 | "description": "Login til actualbudget", 8 | "data": { 9 | "endpoint": "actualbudget adresse", 10 | "password": "password", 11 | "encrypt_password": "krypteringspassword", 12 | "file": "fil-id", 13 | "cert": "certifikat", 14 | "unit": "Enhed" 15 | }, 16 | "data_description": { 17 | "endpoint": "Adressen for actualbudget serveren", 18 | "password": "Password til actualbudget serveren", 19 | "encrypt_password": "Password til kryptering af actualbudget filen", 20 | "file": "Fil-id for actualbudget serveren", 21 | "cert": "Certifikat til actualbudget serveren. Brug 'SKIP' for at springe certifikatvalidering over", 22 | "unit": "Møntfod for actualbudget serveren" 23 | } 24 | } 25 | }, 26 | "error": { 27 | "failed_to_connect": "Forbindelsen fejlede", 28 | "failed_cert": "Forbindelsen fejlede: Fejl med certifikat", 29 | "failed_file": "Forbindelsen fejlede: Ugyldigt fil-id", 30 | "failed_unknown": "Forbindelsen fejlede: Ukendt fejl" 31 | }, 32 | "abort": { 33 | "already_configured": "Enheden er allerede sat op" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /custom_components/actualbudget/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "actualbudget", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "title": "Login", 7 | "description": "Login to actualbudget", 8 | "data": { 9 | "endpoint": "actualbudget endpoint", 10 | "password": "password", 11 | "encrypt_password": "encrypt password", 12 | "file": "file id", 13 | "cert": "certificate", 14 | "unit": "unit" 15 | }, 16 | "data_description": { 17 | "endpoint": "The endpoint of the actualbudget server", 18 | "password": "The password of the actualbudget server", 19 | "encrypt_password": "The password to encrypt the actualbudget file", 20 | "file": "The file id of the actualbudget server", 21 | "cert": "The certificate of the actualbudget server, write 'SKIP' to skip certificate validation", 22 | "unit": "The currency of the actualbudget server" 23 | } 24 | } 25 | }, 26 | "error": { 27 | "failed_to_connect": "Failed to connect", 28 | "failed_cert": "Failed to connect: certificate error", 29 | "failed_file": "Failed to connect: invalid file id", 30 | "failed_unknown": "Failed to connect: unknown error" 31 | }, 32 | "abort": { 33 | "already_configured": "Device is already configured" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /custom_components/actualbudget/translations/pt.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "actualbudget", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "title": "Login", 7 | "description": "Entrar com suas credenciais do actualbudget", 8 | "data": { 9 | "endpoint": "endpoint do actualbudget", 10 | "password": "palavra-passe", 11 | "encrypt_password": "palavra-passe de encriptação", 12 | "file": "id do ficheiro", 13 | "cert": "certificado", 14 | "unit": "moeda" 15 | }, 16 | "data_description": { 17 | "endpoint": "O endpoint do servidor actualbudget", 18 | "password": "A palavra-passe do servidor actualbudget", 19 | "encrypt_password": "A palavra-passe para encriptar o ficheiro actualbudget", 20 | "file": "O id do ficheiro do servidor actualbudget", 21 | "cert": "O certificado do servidor actualbudget, escreva 'SKIP' para ignorar a validação do certificado", 22 | "unit": "A moeda do servidor/ficheiro actualbudget" 23 | } 24 | } 25 | }, 26 | "error": { 27 | "failed_to_connect": "Ligação falhou", 28 | "failed_cert": "Certificado inválido", 29 | "failed_file": "Ficheiro inválido", 30 | "failed_unknown": "Falha desconhecida" 31 | }, 32 | "abort": { 33 | "already_configured": "Já configurado" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | :star: If you appreciate this integration, please consider giving it a star! Your support encourages me to continue improving and expanding this project. Thank you! :star: 2 | 3 | # Actual Budget integration for Home Assistant 4 | 5 | This is a custom integration for Home Assistant that allows you to track your actual budget. 6 | 7 | Note: It's a work in progress, it should work but it may have some bugs and breaking changes. 8 | 9 | # Features 10 | 11 | - Gets all accounts balance and set it as sensors 12 | - Gets all budgets and set the current month as sensors (last month and total are set as extra attributes) 13 | 14 | # Installation 15 | 16 | ## HACS 17 | 18 | 1. Go to HACS page 19 | 2. Search for `Actual Budget` 20 | 3. Install it 21 | 22 | Note: If this is not in HACS yet, you can add this repository manually. 23 | 24 | ## Add The Repository Manually 25 | 26 | 1. 27 | SCR-20240830-lpfj 28 | 29 | 2. 30 | SCR-20240830-lovt 31 | 32 | 3. restart home assistant, and add it in the Settings> Devices and services> Add integration> actualbudget 33 | 34 | # Configuration 35 | 36 | 1. Go to Configuration -> Integrations 37 | 2. Click on the "+" button 38 | 3. Search for `Actual Budget` 39 | 4. Enter the needed information 40 | 41 | | Setting | Required | Description | 42 | | ------------- | --------- | ----------- | 43 | | Endpoint | Yes | The endpoint of the Actual Budget API | 44 | | Password | Yes | The password of the Actual Budget API | 45 | | Encrypt Password | No | The password to decrypt the Actual Budget file (if set) | 46 | | File | Yes | The file id of the Actual Budget file | 47 | | Cert | No | The certificate to use for the connection, you can set it as 'SKIP' to ignore certificate validation| 48 | 49 | Example: 50 | 51 | ``` 52 | Endpoint: https://localhost:5001 53 | Password: password 54 | Encrypt Password: '' 55 | File: ab7c8d8e-048b-41b1-a9cf-13f0679edc0b 56 | Cert: 'SKIP' 57 | ``` 58 | -------------------------------------------------------------------------------- /custom_components/actualbudget/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for actualbudget integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import voluptuous as vol 7 | from urllib.parse import urlparse 8 | 9 | from homeassistant import config_entries 10 | from homeassistant.helpers.selector import selector 11 | 12 | from .actualbudget import ActualBudget 13 | from .const import ( 14 | DOMAIN, 15 | CONFIG_ENDPOINT, 16 | CONFIG_PASSWORD, 17 | CONFIG_FILE, 18 | CONFIG_CERT, 19 | CONFIG_ENCRYPT_PASSWORD, 20 | CONFIG_UNIT, 21 | CONFIG_PREFIX, 22 | ) 23 | 24 | _LOGGER = logging.getLogger(__name__) 25 | _LOGGER.setLevel(logging.DEBUG) 26 | 27 | DATA_SCHEMA = vol.Schema( 28 | { 29 | vol.Required(CONFIG_ENDPOINT): str, 30 | vol.Required(CONFIG_PASSWORD): str, 31 | vol.Required(CONFIG_FILE): str, 32 | vol.Required(CONFIG_UNIT, default="€"): str, 33 | vol.Optional(CONFIG_CERT): str, 34 | vol.Optional(CONFIG_ENCRYPT_PASSWORD): str, 35 | vol.Optional(CONFIG_PREFIX, default="actualbudget"): str, 36 | } 37 | ) 38 | 39 | 40 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 41 | """actualbudget config flow.""" 42 | 43 | VERSION = 1 44 | CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL 45 | 46 | async def async_step_user(self, user_input=None): 47 | """Handle a flow initialized by the user interface.""" 48 | _LOGGER.debug("Starting async_step_user...") 49 | 50 | if user_input is None: 51 | return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) 52 | 53 | unique_id = ( 54 | user_input[CONFIG_ENDPOINT].lower() + "_" + user_input[CONFIG_FILE].lower() 55 | ) 56 | endpoint = user_input[CONFIG_ENDPOINT] 57 | domain = urlparse(endpoint).hostname 58 | port = urlparse(endpoint).port 59 | password = user_input[CONFIG_PASSWORD] 60 | file = user_input[CONFIG_FILE] 61 | cert = user_input.get(CONFIG_CERT) 62 | encrypt_password = user_input.get(CONFIG_ENCRYPT_PASSWORD) 63 | if cert == "SKIP": 64 | cert = False 65 | 66 | await self.async_set_unique_id(unique_id) 67 | self._abort_if_unique_id_configured() 68 | 69 | error = await self._test_connection( 70 | endpoint, password, file, cert, encrypt_password 71 | ) 72 | if error: 73 | return self.async_show_form( 74 | step_id="user", data_schema=DATA_SCHEMA, errors={"base": error} 75 | ) 76 | else: 77 | return self.async_create_entry( 78 | title=f"{domain}:{port} {file}", 79 | data=user_input, 80 | ) 81 | 82 | async def _test_connection(self, endpoint, password, file, cert, encrypt_password): 83 | """Return true if gas station exists.""" 84 | api = ActualBudget(self.hass, endpoint, password, file, cert, encrypt_password) 85 | return await api.test_connection() 86 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | -------------------------------------------------------------------------------- /custom_components/actualbudget/actualbudget.py: -------------------------------------------------------------------------------- 1 | """API to ActualBudget.""" 2 | 3 | from decimal import Decimal 4 | import logging 5 | from dataclasses import dataclass 6 | from typing import Dict, List 7 | from actual import Actual 8 | from actual.exceptions import ( 9 | UnknownFileId, 10 | InvalidFile, 11 | InvalidZipFile, 12 | AuthorizationError, 13 | ) 14 | from actual.queries import get_accounts, get_account, get_budgets, get_category 15 | from requests.exceptions import ConnectionError, SSLError 16 | import datetime 17 | import threading 18 | 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | _LOGGER.setLevel(logging.DEBUG) 22 | 23 | SESSION_TIMEOUT = datetime.timedelta(minutes=30) 24 | 25 | 26 | @dataclass 27 | class BudgetAmount: 28 | month: str 29 | amount: float | None 30 | 31 | 32 | @dataclass 33 | class Budget: 34 | name: str 35 | amounts: List[BudgetAmount] 36 | balance: Decimal 37 | 38 | 39 | @dataclass 40 | class Account: 41 | name: str | None 42 | balance: Decimal 43 | 44 | 45 | class ActualBudget: 46 | """Interfaces to ActualBudget""" 47 | 48 | def __init__(self, hass, endpoint, password, file, cert, encrypt_password): 49 | self.hass = hass 50 | self.endpoint = endpoint 51 | self.password = password 52 | self.file = file 53 | self.cert = cert 54 | self.encrypt_password = encrypt_password 55 | self.actual = None 56 | self.sessionStartedAt = datetime.datetime.now() 57 | self._lock = threading.Lock() 58 | 59 | """ Get Actual session if it exists """ 60 | 61 | def get_session(self): 62 | """Get Actual session if it exists, or create a new one safely.""" 63 | with self._lock: # Ensure only one thread enters at a time 64 | # Invalidate session if it is too old 65 | if ( 66 | self.actual 67 | and self.sessionStartedAt + SESSION_TIMEOUT < datetime.datetime.now() 68 | ): 69 | try: 70 | self.actual.__exit__(None, None, None) 71 | except Exception as e: 72 | _LOGGER.error("Error closing session: %s", e) 73 | self.actual = None 74 | 75 | # Validate existing session 76 | if self.actual: 77 | try: 78 | result = self.actual.validate() 79 | if not result.data.validated: 80 | raise Exception("Session not validated") 81 | except Exception as e: 82 | _LOGGER.error("Error validating session: %s", e) 83 | self.actual = None 84 | 85 | # Create a new session if needed 86 | if not self.actual: 87 | self.actual = self.create_session() 88 | self.sessionStartedAt = datetime.datetime.now() 89 | 90 | return self.actual.session # Return session after lock is released 91 | 92 | def create_session(self): 93 | actual = Actual( 94 | base_url=self.endpoint, 95 | password=self.password, 96 | cert=self.cert, 97 | encryption_password=self.encrypt_password, 98 | file=self.file, 99 | data_dir=self.hass.config.path("actualbudget"), 100 | ) 101 | actual.__enter__() 102 | result = actual.validate() 103 | if not result.data.validated: 104 | raise Exception("Session not validated") 105 | return actual 106 | 107 | async def get_accounts(self) -> List[Account]: 108 | """Get accounts.""" 109 | return await self.hass.async_add_executor_job(self.get_accounts_sync) 110 | 111 | def get_accounts_sync(self) -> List[Account]: 112 | session = self.get_session() 113 | accounts = get_accounts(session) 114 | return [Account(name=a.name, balance=a.balance) for a in accounts] 115 | 116 | async def get_account(self, account_name) -> Account: 117 | return await self.hass.async_add_executor_job( 118 | self.get_account_sync, 119 | account_name, 120 | ) 121 | 122 | def get_account_sync( 123 | self, 124 | account_name, 125 | ) -> Account: 126 | session = self.get_session() 127 | account = get_account(session, account_name) 128 | if not account: 129 | raise Exception(f"Account {account_name} not found") 130 | return Account(name=account.name, balance=account.balance) 131 | 132 | async def get_budgets(self) -> List[Budget]: 133 | """Get budgets.""" 134 | return await self.hass.async_add_executor_job(self.get_budgets_sync) 135 | 136 | def get_budgets_sync(self) -> List[Budget]: 137 | session = self.get_session() 138 | budgets_raw = get_budgets(session) 139 | budgets: Dict[str, Budget] = {} 140 | for budget_raw in budgets_raw: 141 | if not budget_raw.category: 142 | continue 143 | category = str(budget_raw.category.name) 144 | amount = None if not budget_raw.amount else (float(budget_raw.amount) / 100) 145 | month = str(budget_raw.month) 146 | if category not in budgets: 147 | budgets[category] = Budget( 148 | name=category, amounts=[], balance=Decimal(0) 149 | ) 150 | budgets[category].amounts.append(BudgetAmount(month=month, amount=amount)) 151 | for category in budgets: 152 | budgets[category].amounts = sorted( 153 | budgets[category].amounts, key=lambda x: x.month 154 | ) 155 | category_data = get_category(session, category) 156 | budgets[category].balance = ( 157 | category_data.balance if category_data else Decimal(0) 158 | ) 159 | return list(budgets.values()) 160 | 161 | async def get_budget(self, budget_name) -> Budget: 162 | return await self.hass.async_add_executor_job( 163 | self.get_budget_sync, 164 | budget_name, 165 | ) 166 | 167 | def get_budget_sync( 168 | self, 169 | budget_name, 170 | ) -> Budget: 171 | session = self.get_session() 172 | budgets_raw = get_budgets(session, None, budget_name) 173 | if not budgets_raw or not budgets_raw[0]: 174 | raise Exception(f"budget {budget_name} not found") 175 | budget: Budget = Budget(name=budget_name, amounts=[], balance=Decimal(0)) 176 | for budget_raw in budgets_raw: 177 | amount = None if not budget_raw.amount else (float(budget_raw.amount) / 100) 178 | month = str(budget_raw.month) 179 | budget.amounts.append(BudgetAmount(month=month, amount=amount)) 180 | budget.amounts = sorted(budget.amounts, key=lambda x: x.month) 181 | category_data = get_category(session, budget_name) 182 | budget.balance = category_data.balance if category_data else Decimal(0) 183 | return budget 184 | 185 | async def test_connection(self): 186 | return await self.hass.async_add_executor_job(self.test_connection_sync) 187 | 188 | def test_connection_sync(self): 189 | try: 190 | actualSession = self.get_session() 191 | if not actualSession: 192 | return "failed_file" 193 | except SSLError: 194 | return "failed_ssl" 195 | except ConnectionError: 196 | return "failed_connection" 197 | except AuthorizationError: 198 | return "failed_auth" 199 | except UnknownFileId: 200 | return "failed_file" 201 | except InvalidFile: 202 | return "failed_file" 203 | except InvalidZipFile: 204 | return "failed_file" 205 | return None 206 | -------------------------------------------------------------------------------- /custom_components/actualbudget/sensor.py: -------------------------------------------------------------------------------- 1 | """Platform for sensor integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | from decimal import Decimal 6 | import logging 7 | 8 | from typing import List, Dict, Union 9 | from urllib.parse import urlparse 10 | import datetime 11 | 12 | from homeassistant.components.sensor import ( 13 | SensorEntity, 14 | ) 15 | from homeassistant.components.sensor.const import ( 16 | SensorDeviceClass, 17 | SensorStateClass, 18 | ) 19 | from homeassistant.config_entries import ConfigEntry 20 | from homeassistant.core import HomeAssistant 21 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 22 | 23 | from .const import ( 24 | CONFIG_PREFIX, 25 | DEFAULT_ICON, 26 | DOMAIN, 27 | CONFIG_ENDPOINT, 28 | CONFIG_PASSWORD, 29 | CONFIG_FILE, 30 | CONFIG_UNIT, 31 | CONFIG_CERT, 32 | CONFIG_ENCRYPT_PASSWORD, 33 | ) 34 | from .actualbudget import ActualBudget, BudgetAmount 35 | 36 | _LOGGER = logging.getLogger(__name__) 37 | _LOGGER.setLevel(logging.DEBUG) 38 | 39 | # Time between updating data from API 40 | SCAN_INTERVAL = datetime.timedelta(minutes=60) 41 | MINIMUM_INTERVAL = datetime.timedelta(minutes=1) 42 | 43 | 44 | async def async_setup_entry( 45 | hass: HomeAssistant, 46 | config_entry: ConfigEntry, 47 | async_add_entities: AddEntitiesCallback, 48 | ): 49 | """Setup sensor platform.""" 50 | config = config_entry.data 51 | endpoint = config[CONFIG_ENDPOINT] 52 | password = config[CONFIG_PASSWORD] 53 | file = config[CONFIG_FILE] 54 | cert = config.get(CONFIG_CERT) 55 | unit = config.get(CONFIG_UNIT, "€") 56 | prefix = config.get(CONFIG_PREFIX) 57 | 58 | if cert == "SKIP": 59 | cert = False 60 | encrypt_password = config.get(CONFIG_ENCRYPT_PASSWORD) 61 | api = ActualBudget(hass, endpoint, password, file, cert, encrypt_password) 62 | 63 | domain = urlparse(endpoint).hostname 64 | port = urlparse(endpoint).port 65 | unique_source_id = f"{domain}_{port}_{file}" 66 | 67 | accounts = await api.get_accounts() 68 | lastUpdate = datetime.datetime.now() 69 | accounts = [ 70 | actualbudgetAccountSensor( 71 | api, 72 | endpoint, 73 | password, 74 | file, 75 | unit, 76 | cert, 77 | encrypt_password, 78 | account.name, 79 | account.balance, 80 | unique_source_id, 81 | prefix, 82 | lastUpdate, 83 | ) 84 | for account in accounts 85 | ] 86 | async_add_entities(accounts, update_before_add=True) 87 | 88 | budgets = await api.get_budgets() 89 | lastUpdate = datetime.datetime.now() 90 | budgets = [ 91 | actualbudgetBudgetSensor( 92 | api, 93 | endpoint, 94 | password, 95 | file, 96 | unit, 97 | cert, 98 | encrypt_password, 99 | budget.name, 100 | budget.amounts, 101 | budget.balance, 102 | unique_source_id, 103 | prefix, 104 | lastUpdate, 105 | ) 106 | for budget in budgets 107 | ] 108 | async_add_entities(budgets, update_before_add=True) 109 | 110 | 111 | class actualbudgetAccountSensor(SensorEntity): 112 | """Representation of a actualbudget Sensor.""" 113 | 114 | def __init__( 115 | self, 116 | api: ActualBudget, 117 | endpoint: str, 118 | password: str, 119 | file: str, 120 | unit: str, 121 | cert: str, 122 | encrypt_password: str | None, 123 | name: str, 124 | balance: float, 125 | unique_source_id: str, 126 | prefix: str, 127 | balance_last_updated: datetime.datetime, 128 | ): 129 | super().__init__() 130 | self._api = api 131 | self._name = name 132 | self._balance = balance 133 | self._unique_source_id = unique_source_id 134 | self._endpoint = endpoint 135 | self._password = password 136 | self._file = file 137 | self._cert = cert 138 | self._encrypt_password = encrypt_password 139 | self._prefix = prefix 140 | 141 | self._icon = DEFAULT_ICON 142 | self._unit_of_measurement = unit 143 | self._device_class = SensorDeviceClass.MONETARY 144 | self._state_class = SensorStateClass.MEASUREMENT 145 | self._state = None 146 | self._available = True 147 | self._balance_last_updated = balance_last_updated 148 | 149 | @property 150 | def name(self) -> str: 151 | """Return the name of the entity.""" 152 | if self._prefix: 153 | return f"{self._prefix}_{self._name}" 154 | else: 155 | return self._name 156 | 157 | @property 158 | def unique_id(self) -> str: 159 | """Return the unique ID of the sensor.""" 160 | if self._prefix: 161 | return ( 162 | f"{DOMAIN}-{self._unique_source_id}-{self._prefix}-{self._name}".lower() 163 | ) 164 | else: 165 | return f"{DOMAIN}-{self._unique_source_id}-{self._name}".lower() 166 | 167 | @property 168 | def available(self) -> bool: 169 | """Return True if entity is available.""" 170 | return self._available 171 | 172 | @property 173 | def state(self) -> float: 174 | return self._balance 175 | 176 | @property 177 | def device_class(self): 178 | return self._device_class 179 | 180 | @property 181 | def state_class(self): 182 | return self._state_class 183 | 184 | @property 185 | def unit_of_measurement(self): 186 | """Return the unit the value is expressed in.""" 187 | return self._unit_of_measurement 188 | 189 | @property 190 | def icon(self): 191 | return self._icon 192 | 193 | async def async_update(self) -> None: 194 | if ( 195 | self._balance_last_updated 196 | and datetime.datetime.now() - self._balance_last_updated < MINIMUM_INTERVAL 197 | ): 198 | return 199 | """Fetch new state data for the sensor.""" 200 | try: 201 | api = self._api 202 | account = await api.get_account(self._name) 203 | if account: 204 | self._balance = account.balance 205 | self._balance_last_updated = datetime.datetime.now() 206 | except Exception as err: 207 | self._available = False 208 | _LOGGER.exception( 209 | "Unknown error updating data from ActualBudget API to account %s. %s", 210 | self._name, 211 | err, 212 | ) 213 | 214 | 215 | class actualbudgetBudgetSensor(SensorEntity): 216 | """Representation of a actualbudget Sensor.""" 217 | 218 | def __init__( 219 | self, 220 | api: ActualBudget, 221 | endpoint: str, 222 | password: str, 223 | file: str, 224 | unit: str, 225 | cert: str, 226 | encrypt_password: str | None, 227 | name: str, 228 | amounts: List[BudgetAmount], 229 | balance: float, 230 | unique_source_id: str, 231 | prefix: str, 232 | balance_last_updated: datetime.datetime, 233 | ): 234 | super().__init__() 235 | self._api = api 236 | self._name = name 237 | self._amounts = amounts 238 | self._balance = balance 239 | self._unique_source_id = unique_source_id 240 | self._endpoint = endpoint 241 | self._password = password 242 | self._file = file 243 | self._cert = cert 244 | self._encrypt_password = encrypt_password 245 | self._prefix = prefix 246 | 247 | self._icon = DEFAULT_ICON 248 | self._unit_of_measurement = unit 249 | self._device_class = SensorDeviceClass.MONETARY 250 | self._state_class = SensorStateClass.MEASUREMENT 251 | self._available = True 252 | self._balance_last_updated = balance_last_updated 253 | 254 | @property 255 | def name(self) -> str: 256 | """Return the name of the entity.""" 257 | budgetName = f"budget_{self._name}" 258 | if self._prefix: 259 | return f"{self._prefix}_{budgetName}" 260 | return budgetName 261 | 262 | @property 263 | def unique_id(self) -> str: 264 | """Return the unique ID of the sensor.""" 265 | if self._prefix: 266 | return f"{DOMAIN}-{self._unique_source_id}-{self._prefix}-budget-{self._name}".lower() 267 | else: 268 | return f"{DOMAIN}-{self._unique_source_id}-budget-{self._name}".lower() 269 | 270 | @property 271 | def available(self) -> bool: 272 | """Return True if entity is available.""" 273 | return self._available 274 | 275 | @property 276 | def device_class(self): 277 | return self._device_class 278 | 279 | @property 280 | def state_class(self): 281 | return self._state_class 282 | 283 | @property 284 | def unit_of_measurement(self): 285 | """Return the unit the value is expressed in.""" 286 | return self._unit_of_measurement 287 | 288 | @property 289 | def icon(self): 290 | return self._icon 291 | 292 | 293 | @property 294 | def state(self) -> float | None: 295 | total = 0 296 | for amount in self._amounts: 297 | if datetime.datetime.strptime(amount.month, '%Y%m') <= datetime.datetime.now(): 298 | total += amount.amount if amount.amount else 0 299 | return round(self._balance + Decimal(total), 2) 300 | 301 | @property 302 | def extra_state_attributes(self) -> Dict[str, Union[str, float]]: 303 | extra_state_attributes = {} 304 | amounts = [amount for amount in self._amounts if datetime.datetime.strptime(amount.month, '%Y%m') <= datetime.datetime.now()] 305 | current_month = amounts[-1].month 306 | if current_month: 307 | extra_state_attributes["current_month"] = current_month 308 | extra_state_attributes["current_amount"] = amounts[-1].amount 309 | if len(amounts) > 1: 310 | extra_state_attributes["previous_month"] = amounts[-2].month 311 | extra_state_attributes["previous_amount"] = amounts[-2].amount 312 | total = 0 313 | for amount in amounts: 314 | total += amount.amount if amount.amount else 0 315 | extra_state_attributes["total_amount"] = total 316 | 317 | return extra_state_attributes 318 | 319 | async def async_update(self) -> None: 320 | if ( 321 | self._balance_last_updated 322 | and datetime.datetime.now() - self._balance_last_updated < MINIMUM_INTERVAL 323 | ): 324 | return 325 | """Fetch new state data for the sensor.""" 326 | try: 327 | api = self._api 328 | budget = await api.get_budget(self._name) 329 | self._balance_last_updated = datetime.datetime.now() 330 | if budget: 331 | self._amounts = budget.amounts 332 | self._balance = budget.balance 333 | except Exception as err: 334 | self._available = False 335 | _LOGGER.exception( 336 | "Unknown error updating data from ActualBudget API to budget %s. %s", 337 | self._name, 338 | err, 339 | ) 340 | --------------------------------------------------------------------------------