├── 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 |
28 |
29 | 2.
30 |
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 |
--------------------------------------------------------------------------------