├── .gitignore ├── .github ├── scripts │ ├── __init__.py │ └── update_hacs_manifest.py └── workflows │ ├── hassfest.yml │ ├── validate.yml │ ├── ci.yml │ └── release.yml ├── hacs.json ├── Pipfile ├── custom_components └── dijnet │ ├── const.py │ ├── manifest.json │ ├── strings.json │ ├── translations │ ├── en.json │ └── hu.json │ ├── __init__.py │ ├── sensor.py │ ├── config_flow.py │ ├── dijnet_session.py │ └── controller.py ├── LICENSE ├── README.md ├── pyproject.toml └── Pipfile.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ -------------------------------------------------------------------------------- /.github/scripts/__init__.py: -------------------------------------------------------------------------------- 1 | """Module of scripts.""" 2 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Dijnet integration", 3 | "country": "HU", 4 | "render_readme": true, 5 | "zip_release": true, 6 | "filename": "homeassistant-dijnet.zip" 7 | } -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | ruff = "*" 8 | 9 | [dev-packages] 10 | ruff = "*" 11 | 12 | [requires] 13 | python_version = "3.12" 14 | -------------------------------------------------------------------------------- /custom_components/dijnet/const.py: -------------------------------------------------------------------------------- 1 | """Constants for Dijnet integration.""" 2 | 3 | DOMAIN = "dijnet" 4 | 5 | CONF_DOWNLOAD_DIR = "download_dir" 6 | CONF_ENCASHMENT_REPORTED_AS_PAID_AFTER_DEADLINE = "encashment_reported_as_paid_after_deadline" 7 | 8 | DATA_CONTROLLER = "controller" 9 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | release: 7 | schedule: 8 | - cron: "0 0 * * *" 9 | 10 | jobs: 11 | validate: 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - uses: "actions/checkout@v2" 15 | - uses: home-assistant/actions/hassfest@master 16 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate 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@v2" 14 | - name: HACS validation 15 | uses: "hacs/action@main" 16 | with: 17 | category: "integration" -------------------------------------------------------------------------------- /custom_components/dijnet/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "dijnet", 3 | "name": "Dijnet", 4 | "iot_class": "cloud_polling", 5 | "documentation": "https://github.com/laszlojakab/homeassistant-dijnet#usage", 6 | "issue_tracker": "https://github.com/laszlojakab/homeassistant-dijnet/issues", 7 | "dependencies": [], 8 | "config_flow": true, 9 | "version": "0.6.6", 10 | "codeowners": ["@laszlojakab"], 11 | "requirements": ["PyQuery==1.4.3", "anyio==4.*"] 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | pull_request: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | linters: 9 | name: Linters 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set up Python 14 | id: setup-python 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: '3.12' 18 | - run: pip3 install homeassistant 19 | - run: pip3 install ruff 20 | - run: ruff check 21 | - run: ruff format -------------------------------------------------------------------------------- /.github/scripts/update_hacs_manifest.py: -------------------------------------------------------------------------------- 1 | """Update the manifest file.""" 2 | 3 | import json 4 | import os 5 | import sys 6 | 7 | 8 | def update_manifest() -> None: 9 | """Update the manifest file.""" 10 | version = "0.0.0" 11 | for index, value in enumerate(sys.argv): 12 | if value in ["--version", "-V"]: 13 | version = sys.argv[index + 1] 14 | 15 | with open(f"{os.getcwd()}/custom_components/dijnet/manifest.json") as manifestfile: 16 | manifest = json.load(manifestfile) 17 | 18 | manifest["version"] = version 19 | 20 | with open(f"{os.getcwd()}/custom_components/dijnet/manifest.json", "w") as manifestfile: 21 | manifestfile.write(json.dumps(manifest, indent=4, sort_keys=True)) 22 | 23 | 24 | update_manifest() 25 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | release_zip_file: 9 | name: Prepare release asset 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check out repository 13 | uses: actions/checkout@v2 14 | 15 | - name: Get version 16 | id: version 17 | uses: home-assistant/actions/helpers/version@master 18 | 19 | - name: "Set manifest version number" 20 | run: | 21 | python3 ${{ github.workspace }}/.github/scripts/update_hacs_manifest.py --version ${{ steps.version.outputs.version }} 22 | 23 | - name: Create zip 24 | run: | 25 | cd custom_components/dijnet 26 | zip homeassistant-dijnet.zip -r ./ 27 | 28 | - name: Upload zip to release 29 | uses: svenstaro/upload-release-action@v1-release 30 | with: 31 | repo_token: ${{ secrets.GITHUB_TOKEN }} 32 | file: ./custom_components/dijnet/homeassistant-dijnet.zip 33 | asset_name: homeassistant-dijnet.zip 34 | tag: ${{ github.ref }} 35 | overwrite: true -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Laszlo Jakab 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /custom_components/dijnet/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "data": { 6 | "username": "Username", 7 | "password": "Password", 8 | "download_dir": "Download directory", 9 | "encashment_reported_as_paid_after_deadline": "Encashment payments reported as paid after deadline" 10 | }, 11 | "description": "Set username and password. Optionally set download directory where downloaded invoices should be stored.", 12 | "title": "Configure Dijnet integration." 13 | } 14 | }, 15 | "abort": { 16 | "already_configured": "This account has already been configured." 17 | }, 18 | "error": { 19 | "invalid_username_or_password": "Invalid username or password." 20 | } 21 | }, 22 | "options": { 23 | "step": { 24 | "init": { 25 | "data": { 26 | "password": "Password", 27 | "download_dir": "Download directory" 28 | }, 29 | "description": "Set password. Optionally set download directory where downloaded invoices should be stored. To set username remove the integration and readd.", 30 | "title": "Reconfigure Dijnet integration." 31 | } 32 | }, 33 | "abort": { 34 | "reconfigure_successful": "Reconfiguration completed. Please restart Home Assistant." 35 | }, 36 | "error": { 37 | "invalid_username_or_password": "Invalid username or password." 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /custom_components/dijnet/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "data": { 6 | "username": "Username", 7 | "password": "Password", 8 | "download_dir": "Download directory", 9 | "encashment_reported_as_paid_after_deadline": "Encashment payments reported as paid after deadline" 10 | }, 11 | "description": "Set username and password. Optionally set download directory where downloaded invoices should be stored.", 12 | "title": "Configure Dijnet integration." 13 | } 14 | }, 15 | "abort": { 16 | "already_configured": "This account has already been configured." 17 | }, 18 | "error": { 19 | "invalid_username_or_password": "Invalid username or password." 20 | } 21 | }, 22 | "options": { 23 | "step": { 24 | "init": { 25 | "data": { 26 | "password": "Password", 27 | "download_dir": "Download directory", 28 | "encashment_reported_as_paid_after_deadline": "Encashment payments reported as paid after deadline" 29 | }, 30 | "description": "Set password. Optionally set download directory where downloaded invoices should be stored. To set username remove the integration and readd.", 31 | "title": "Reconfigure Dijnet integration." 32 | } 33 | }, 34 | "abort": { 35 | "reconfigure_successful": "Reconfiguration completed. Please restart Home Assistant." 36 | }, 37 | "error": { 38 | "invalid_username_or_password": "Invalid username or password." 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /custom_components/dijnet/translations/hu.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "data": { 6 | "username": "Felhasználó név", 7 | "password": "Jelszó", 8 | "download_dir": "Letöltési könyvtár", 9 | "encashment_reported_as_paid_after_deadline": "Csoportos beszedéseket fizetettnek tekint számla lejártakor" 10 | }, 11 | "description": "Állítsd be a Dijnet-es felhasználónevedet és jelszavadat. Ha szeretnéd, hogy a számlák letöltésre kerüljenek adj meg egy könyvtárnevet is a letöltés helyének!", 12 | "title": "Dijnet integráció beállítása." 13 | } 14 | }, 15 | "abort": { 16 | "already_configured": "Ez a Dijnet felhasználó már beállításra került a rendszerben." 17 | }, 18 | "error": { 19 | "invalid_username_or_password": "Hibás felhasználónév vagy jelszó." 20 | } 21 | }, 22 | "options": { 23 | "step": { 24 | "init": { 25 | "data": { 26 | "password": "Jelszó", 27 | "download_dir": "Letöltési könyvtár", 28 | "encashment_reported_as_paid_after_deadline": "Csoportos beszedéseket fizetettnek tekint számla lejártakor" 29 | }, 30 | "description": "Állítsd be a Dijnet-es jelszavadat. Ha szeretnéd, hogy a számlák letöltésre kerüljenek adj meg egy könyvtárnevet is a letöltés helyének! Ha felhasználó nevet szeretnél váltani, akkor távolítsd el az integrációt, majd add hozzá újra!", 31 | "title": "Dijnet integráció beállítása." 32 | } 33 | }, 34 | "abort": { 35 | "reconfigure_successful": "Beállítás elkészült. Kérlek indítsd újra a Home Assistant-ot!" 36 | }, 37 | "error": { 38 | "invalid_username_or_password": "Hibás felhasználónév vagy jelszó." 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /custom_components/dijnet/__init__.py: -------------------------------------------------------------------------------- 1 | """Dijnet component.""" 2 | 3 | import logging 4 | 5 | from homeassistant.config_entries import ConfigEntry 6 | from homeassistant.const import CONF_PASSWORD, CONF_USERNAME 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.helpers.typing import ConfigType 9 | 10 | from .const import ( 11 | CONF_DOWNLOAD_DIR, 12 | CONF_ENCASHMENT_REPORTED_AS_PAID_AFTER_DEADLINE, 13 | DATA_CONTROLLER, 14 | DOMAIN, 15 | ) 16 | from .controller import DijnetController, is_controller_exists, set_controller 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | 21 | async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ARG001 22 | """ 23 | Sets up the Dijnet component. 24 | 25 | Args: 26 | hass: 27 | The Home Assistant instance. 28 | config: 29 | The configuration. 30 | 31 | Returns: 32 | The value indicates whether the setup succeeded. 33 | 34 | """ 35 | hass.data[DOMAIN] = {DATA_CONTROLLER: {}} 36 | return True 37 | 38 | 39 | async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 40 | """ 41 | Initializes the sensors based on the config entry. 42 | 43 | Args: 44 | hass: 45 | The Home Assistant instance. 46 | config_entry: 47 | The config entry which contains information gathered by the config flow. 48 | 49 | Returns: 50 | The value indicates whether the setup succeeded. 51 | 52 | """ 53 | if not is_controller_exists(hass, config_entry.data[CONF_USERNAME]): 54 | set_controller( 55 | hass, 56 | config_entry.data[CONF_USERNAME], 57 | DijnetController( 58 | config_entry.data[CONF_USERNAME], 59 | config_entry.data[CONF_PASSWORD], 60 | config_entry.data[CONF_DOWNLOAD_DIR], 61 | config_entry.data[CONF_ENCASHMENT_REPORTED_AS_PAID_AFTER_DEADLINE], 62 | ), 63 | ) 64 | 65 | hass.async_create_task( 66 | hass.config_entries.async_forward_entry_setups(config_entry, ("sensor",)) 67 | ) 68 | 69 | return True 70 | 71 | 72 | async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 73 | """ 74 | Migrates old entry. 75 | 76 | Args: 77 | hass: 78 | The Home Assistant instance. 79 | config_entry: 80 | The config entry to migrate. 81 | 82 | Returns: 83 | The value indicates whether the migration succeeded. 84 | """ 85 | _LOGGER.debug("Migrating from version %s", config_entry.version) 86 | 87 | if config_entry.version == 1: 88 | new_config_entry = {**config_entry.data} 89 | new_config_entry[CONF_ENCASHMENT_REPORTED_AS_PAID_AFTER_DEADLINE] = False 90 | 91 | config_entry.version = 2 92 | hass.config_entries.async_update_entry(config_entry, data=new_config_entry) 93 | 94 | _LOGGER.info("Migration to version %s successful", config_entry.version) 95 | 96 | return True 97 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # homeassistant-dijnet 2 | 3 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-41BDF5.svg)](https://github.com/hacs/integration) 4 | ![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/v/release/laszlojakab/homeassistant-dijnet?include_prereleases) 5 | ![GitHub](https://img.shields.io/github/license/laszlojakab/homeassistant-dijnet?) 6 | ![GitHub all releases](https://img.shields.io/github/downloads/laszlojakab/homeassistant-dijnet/total) 7 | [![HA integration usage](https://img.shields.io/badge/dynamic/json?color=41BDF5&logo=home-assistant&label=integration%20usage&suffix=%20installs&cacheSeconds=15600&url=https://analytics.home-assistant.io/custom_integrations.json&query=$.dijnet.total)](https://analytics.home-assistant.io/custom_integrations.json) 8 | [![Donate](https://img.shields.io/badge/donate-Coffee-yellow.svg)](https://www.buymeacoffee.com/laszlojakab) 9 | 10 | [Dijnet](https://www.dijnet.hu/) integration for [Home Assistant](https://www.home-assistant.io/) 11 | 12 | ## Installation 13 | 14 | You can install this integration via [HACS](#hacs) or [manually](#manual). 15 | 16 | ### HACS 17 | 18 | This integration is included in HACS. Search for the `Dijnet` integration and choose install. Reboot Home Assistant and configure the 'Dijnet' integration via the integrations page or press the blue button below. 19 | 20 | [![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=dijnet) 21 | 22 | ### Manual 23 | 24 | Copy the `custom_components/dijnet` to your `custom_components` folder. Reboot Home Assistant and configure the 'Dijnet' integration via the integrations page or press the blue button below. 25 | 26 | [![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=dijnet) 27 | 28 | ## Features 29 | 30 | - The integration provides services for every invoice issuer. Every invoice issuer could have multiple providers. For example DBH Zrt. invoice issuer handles invoices for FV Zrt. and FCSM Zrt. In that case the integration creates separate sensors for these providers. 31 | - For all providers an invoice amount sensor is created. It contains the sum of unpaid amount for a provider. The details of the unpaid invoices can be read out from `unpaid_invoices` attribute of the sensor. 32 | 33 | 34 | ## Enable debug logging 35 | 36 | The [logger](https://www.home-assistant.io/integrations/logger/) integration lets you define the level of logging activities in Home Assistant. Turning on debug mode will show more information about the running of the integration in the homeassistant.log file. 37 | 38 | ```yaml 39 | logger: 40 | default: error 41 | logs: 42 | custom_components.dijnet: debug 43 | ``` 44 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | line-length = 100 3 | target-version = "py312" 4 | 5 | lint.select = [ 6 | "F", # pyflakes 7 | "E", # pycodestyle-errors 8 | "W", # pycodestyle-warnings 9 | "C90", # mccabe 10 | "I", # isort 11 | "N", # pep8-naming 12 | "D", # pydocstyle 13 | "UP", # pyupgrade 14 | "YTT", # flake8-2020 15 | "ANN", # flake8-annotations 16 | "ASYNC", # flake8-async 17 | "S", # flake8-bandit 18 | "BLE", # flake8-blind-except 19 | # "FBT", # flake8-boolean-trap (completely forbids bools in signatures) 20 | "B", # flake8-bugbear 21 | "A", # flake8-builtins 22 | # "COM", # flake8-commas (trailing comma related rules) 23 | # "CPY", # copyright-related rules (each file must have copyright info at top) 24 | "C4", # flake8-comprehensions 25 | "DTZ", # flake8-datetimez 26 | "T10", # flake8-debugger 27 | # "DJ", # flake8-django (rules for Django which we don't use) 28 | # "EM", # flake8-errmsg (error messages must have preassigned variable names) 29 | "EXE", # flake8-executable 30 | "FA", # flake8-future-annotations 31 | "ISC", # flake8-implicit-str-concat 32 | "ICN", # flake8-import-conventions 33 | "G", # flake8-logging-format 34 | "INP", # flake8-no-pep420 35 | "PIE", # flake8-pie 36 | "T20", # flake8-print 37 | "PYI", # flake8-pyi 38 | "PT", # flake8-pytest-style 39 | "Q", # flake8-quotes 40 | "RSE", # flake8-raise 41 | "RET", # flake8-return 42 | "SLF", # flake8-self 43 | "SLOT", # flake8-slots 44 | "SIM", # flake8-simplify 45 | "TID", # flake8-tidy-imports 46 | "TCH", # flake8-type-checking 47 | "INT", # flake8-gettext 48 | "ARG", # flake8-unused-arguments 49 | # "PTH", # flake8-use-pathlib (forbids the use of os in favor of pathlib) 50 | "TD", # flake8-todos 51 | "FIX", # flake8-fixme (forbids the use of TODO items) 52 | "ERA", # eradicate 53 | "PD", # pandas-vet 54 | "PGH", # pygrep-hooks 55 | "PL", # pylint 56 | "TRY", # tryceratops 57 | "FLY", # flynt 58 | "NPY", # numpy-specific rules 59 | # "AIR", # airflow-specific rules (rules for Airflow which we don't use) 60 | "PERF", # perflint 61 | "RUF", # ruff-specific rules 62 | ] 63 | 64 | lint.ignore = [ 65 | "D203", # 1 blank line required before class docstring (opposite of D211) 66 | "D205", # 1 blank line required between summary line and description 67 | "D212", # Multi-line docstring summary should start at the first line (opposite of D213) 68 | "D400", # First line should end with a period 69 | "D415", # First line should end with a period, question mark, or exclamation point 70 | "D401", # First line of docstring should be in imperative mood 71 | "D406", # Section name should end with a newline 72 | "D407", # Missing dashed underline after section Args/Returns/Raises 73 | "D413", # Missing blank line after last section 74 | "TRY003", # Avoid specifying long messages outside the exception class 75 | "B028", # No explicit `stacklevel` keyword argument found for a `warnings.warn()` call 76 | "D418", # Function decorated with `@overload` shouldn't contain a docstring 77 | "PD011", # Use `.to_numpy()` instead of `.values` 78 | "PT006", # Wrong name(s) type in `@pytest.mark.parametrize`, expected `tuple` 79 | "TD002", # Missing author in TODO; try: `# TODO @: ...` 80 | "TD003", # Missing issue link on the line following this TODO 81 | "ANN002", # Missing type annotation for `*args` 82 | "ANN003", # Missing type annotation for `**kwargs` 83 | "ANN204", # Missing return type annotation for special method `__init__` 84 | "ISC001", # single-line-implicit-string-concatenation 85 | ] 86 | 87 | [lint.tool.ruff.pydocstyle] 88 | convention = "google" -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "d961d513b604304175de537fbb0e493559f04083d2302ab9c47fb510a5531cc6" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.12" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "ruff": { 20 | "hashes": [ 21 | "sha256:19aa200ec824c0f36d0c9114c8ec0087082021732979a359d6f3c390a6ff2a37", 22 | "sha256:27c1c52a8d199a257ff1e5582d078eab7145129aa02721815ca8fa4f9612dc35", 23 | "sha256:32f1e8a192e261366c702c5fb2ece9f68d26625f198a25c408861c16dc2dea9c", 24 | "sha256:344cc2b0814047dc8c3a8ff2cd1f3d808bb23c6658db830d25147339d9bf9ea7", 25 | "sha256:4316bbf69d5a859cc937890c7ac7a6551252b6a01b1d2c97e8fc96e45a7c8b4a", 26 | "sha256:56aad830af8a9db644e80098fe4984a948e2b6fc2e73891538f43bbe478461b8", 27 | "sha256:588a34e1ef2ea55b4ddfec26bbe76bc866e92523d8c6cdec5e8aceefeff02d99", 28 | "sha256:658304f02f68d3a83c998ad8bf91f9b4f53e93e5412b8f2388359d55869727fd", 29 | "sha256:699085bf05819588551b11751eff33e9ca58b1b86a6843e1b082a7de40da1565", 30 | "sha256:79d3af9dca4c56043e738a4d6dd1e9444b6d6c10598ac52d146e331eb155a8ad", 31 | "sha256:8422104078324ea250886954e48f1373a8fe7de59283d747c3a7eca050b4e378", 32 | "sha256:94fc32f9cdf72dc75c451e5f072758b118ab8100727168a3df58502b43a599ca", 33 | "sha256:985818742b833bffa543a84d1cc11b5e6871de1b4e0ac3060a59a2bae3969250", 34 | "sha256:9d8a41d4aa2dad1575adb98a82870cf5db5f76b2938cf2206c22c940034a36f4", 35 | "sha256:b517a2011333eb7ce2d402652ecaa0ac1a30c114fbbd55c6b8ee466a7f600ee9", 36 | "sha256:c5c121b46abde94a505175524e51891f829414e093cd8326d6e741ecfc0a9112", 37 | "sha256:cb1bc5ed9403daa7da05475d615739cc0212e861b7306f314379d958592aaa89", 38 | "sha256:f38c41fcde1728736b4eb2b18850f6d1e3eedd9678c914dede554a70d5241307" 39 | ], 40 | "index": "pypi", 41 | "markers": "python_version >= '3.7'", 42 | "version": "==0.7.1" 43 | } 44 | }, 45 | "develop": { 46 | "ruff": { 47 | "hashes": [ 48 | "sha256:19aa200ec824c0f36d0c9114c8ec0087082021732979a359d6f3c390a6ff2a37", 49 | "sha256:27c1c52a8d199a257ff1e5582d078eab7145129aa02721815ca8fa4f9612dc35", 50 | "sha256:32f1e8a192e261366c702c5fb2ece9f68d26625f198a25c408861c16dc2dea9c", 51 | "sha256:344cc2b0814047dc8c3a8ff2cd1f3d808bb23c6658db830d25147339d9bf9ea7", 52 | "sha256:4316bbf69d5a859cc937890c7ac7a6551252b6a01b1d2c97e8fc96e45a7c8b4a", 53 | "sha256:56aad830af8a9db644e80098fe4984a948e2b6fc2e73891538f43bbe478461b8", 54 | "sha256:588a34e1ef2ea55b4ddfec26bbe76bc866e92523d8c6cdec5e8aceefeff02d99", 55 | "sha256:658304f02f68d3a83c998ad8bf91f9b4f53e93e5412b8f2388359d55869727fd", 56 | "sha256:699085bf05819588551b11751eff33e9ca58b1b86a6843e1b082a7de40da1565", 57 | "sha256:79d3af9dca4c56043e738a4d6dd1e9444b6d6c10598ac52d146e331eb155a8ad", 58 | "sha256:8422104078324ea250886954e48f1373a8fe7de59283d747c3a7eca050b4e378", 59 | "sha256:94fc32f9cdf72dc75c451e5f072758b118ab8100727168a3df58502b43a599ca", 60 | "sha256:985818742b833bffa543a84d1cc11b5e6871de1b4e0ac3060a59a2bae3969250", 61 | "sha256:9d8a41d4aa2dad1575adb98a82870cf5db5f76b2938cf2206c22c940034a36f4", 62 | "sha256:b517a2011333eb7ce2d402652ecaa0ac1a30c114fbbd55c6b8ee466a7f600ee9", 63 | "sha256:c5c121b46abde94a505175524e51891f829414e093cd8326d6e741ecfc0a9112", 64 | "sha256:cb1bc5ed9403daa7da05475d615739cc0212e861b7306f314379d958592aaa89", 65 | "sha256:f38c41fcde1728736b4eb2b18850f6d1e3eedd9678c914dede554a70d5241307" 66 | ], 67 | "index": "pypi", 68 | "markers": "python_version >= '3.7'", 69 | "version": "==0.7.1" 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /custom_components/dijnet/sensor.py: -------------------------------------------------------------------------------- 1 | """Support for Dijnet.""" 2 | 3 | import logging 4 | from typing import Self 5 | 6 | import homeassistant.helpers.config_validation as cv 7 | import voluptuous as vol 8 | from homeassistant.components.sensor import ( 9 | PLATFORM_SCHEMA, 10 | SensorDeviceClass, 11 | SensorEntity, 12 | SensorEntityDescription, 13 | ) 14 | from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry 15 | from homeassistant.const import CONF_PASSWORD, CONF_USERNAME 16 | from homeassistant.core import HomeAssistant 17 | from homeassistant.helpers.device_registry import DeviceEntryType 18 | from homeassistant.helpers.entity import DeviceInfo 19 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 20 | from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType 21 | 22 | from .const import CONF_DOWNLOAD_DIR, DOMAIN 23 | from .controller import DijnetController, InvoiceIssuer, get_controller 24 | 25 | _LOGGER = logging.getLogger(__name__) 26 | 27 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 28 | { 29 | vol.Required(CONF_USERNAME): cv.string, 30 | vol.Required(CONF_PASSWORD): cv.string, 31 | vol.Optional(CONF_DOWNLOAD_DIR, default=""): cv.string, 32 | } 33 | ) 34 | 35 | 36 | async def async_setup_platform( 37 | hass: HomeAssistant, 38 | config: ConfigType, 39 | async_add_entities: AddEntitiesCallback, # noqa: ARG001 40 | discovery_info: DiscoveryInfoType = None, # noqa: ARG001 41 | ) -> None: 42 | """Import yaml config and initiates config flow for Dijnet integration.""" 43 | # Check if entry config exists and skips import if it does. 44 | if hass.config_entries.async_entries(DOMAIN): 45 | _LOGGER.warning( 46 | "Setting up Dijnet integration from yaml is deprecated." 47 | "Please remove configuration from yaml." 48 | ) 49 | return 50 | 51 | hass.async_create_task( 52 | hass.config_entries.flow.async_init( 53 | DOMAIN, 54 | context={"source": SOURCE_IMPORT}, 55 | data=config, 56 | ) 57 | ) 58 | 59 | 60 | async def async_setup_entry( 61 | hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback 62 | ) -> bool: 63 | """ 64 | Setup of Dijnet sensors for the specified config_entry. 65 | 66 | Args: 67 | hass: 68 | The Home Assistant instance. 69 | config_entry: 70 | The config entry which is used to create sensors. 71 | async_add_entities: 72 | The callback which can be used to add new entities to Home Assistant. 73 | 74 | Returns: 75 | The value indicates whether the setup succeeded. 76 | """ 77 | _LOGGER.info("Setting up Dijnet sensors.") 78 | 79 | controller = get_controller(hass, config_entry.data[CONF_USERNAME]) 80 | 81 | for registered_invoice_issuer in await controller.get_issuers(): 82 | async_add_entities( 83 | [ 84 | InvoiceAmountSensor( 85 | controller, config_entry.entry_id, registered_invoice_issuer, provider 86 | ) 87 | for provider in registered_invoice_issuer.providers 88 | ] 89 | ) 90 | _LOGGER.debug("Sensor added (%s)", registered_invoice_issuer) 91 | 92 | _LOGGER.info("Setting up Dijnet sensors completed.") 93 | return True 94 | 95 | 96 | class InvoiceAmountSensor(SensorEntity): 97 | """Represents an invoice amount sensor.""" 98 | 99 | def __init__( 100 | self: Self, 101 | controller: DijnetController, 102 | config_entry_id: str, 103 | invoice_issuer: InvoiceIssuer, 104 | provider: str, 105 | ) -> None: 106 | """ 107 | Initializes a new instance of `InvoiceAmountSensor` class. 108 | 109 | Args: 110 | controller: 111 | The Dijnet controller. 112 | config_entry_id: 113 | The unique id of the config entry. 114 | invoice_issuer: 115 | The invoice issuer. 116 | provider: 117 | The invoice provider. 118 | """ 119 | self._controller = controller 120 | self._invoice_issuer = invoice_issuer 121 | self._state = None 122 | self._attr_unique_id = ( 123 | f"{config_entry_id}_{invoice_issuer.issuer}_" 124 | f"{invoice_issuer.issuer_id}_{provider}_amount" 125 | ) 126 | self._provider = provider 127 | self.entity_description = SensorEntityDescription( 128 | key="invoice_amount", 129 | device_class=SensorDeviceClass.MONETARY, 130 | native_unit_of_measurement="Ft", 131 | name=f"Dijnet - {provider} fizetendő összeg", 132 | ) 133 | 134 | @property 135 | def device_info(self: Self) -> DeviceInfo: 136 | """Returns the device information.""" 137 | return DeviceInfo( 138 | entry_type=DeviceEntryType.SERVICE, 139 | configuration_url="https://dijnet.hu/", 140 | manufacturer="Dijnet Zrt", 141 | identifiers={ 142 | (DOMAIN, self._invoice_issuer.issuer + "|" + self._invoice_issuer.issuer_id) 143 | }, 144 | name=self._invoice_issuer.display_name, 145 | ) 146 | 147 | async def async_update(self: Self) -> None: 148 | """Called when the entity should update its state.""" 149 | invoices = [ 150 | invoice 151 | for invoice in await self._controller.get_unpaid_invoices() 152 | if invoice.display_name == self._invoice_issuer.display_name 153 | and invoice.provider == self._provider 154 | ] 155 | self._attr_native_value = sum([invoice.amount for invoice in invoices]) 156 | self._attr_extra_state_attributes = { 157 | "unpaid_invoices": [invoice.to_dictionary() for invoice in invoices] 158 | } 159 | -------------------------------------------------------------------------------- /custom_components/dijnet/config_flow.py: -------------------------------------------------------------------------------- 1 | """The configuration flow module for Dijnet integration.""" 2 | 3 | import logging 4 | from typing import Any, Self 5 | 6 | import voluptuous as vol 7 | from homeassistant.config_entries import HANDLERS, ConfigEntry, ConfigFlow, OptionsFlow 8 | from homeassistant.const import CONF_PASSWORD, CONF_USERNAME 9 | from homeassistant.core import callback 10 | from homeassistant.data_entry_flow import FlowResult 11 | 12 | from .const import CONF_DOWNLOAD_DIR, CONF_ENCASHMENT_REPORTED_AS_PAID_AFTER_DEADLINE, DOMAIN 13 | from .dijnet_session import DijnetSession 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | class DijnetOptionsFlowHandler(OptionsFlow): 19 | """Handle Dijnet options.""" 20 | 21 | def __init__(self: Self, config_entry: ConfigEntry) -> None: 22 | """ 23 | Initialize a new instance of DijnetOptionsFlowHandler class. 24 | 25 | Args: 26 | config_entry: 27 | The config entry of the integration. 28 | 29 | """ 30 | # Don't assign to self.config_entry (read-only on base class). 31 | self._config_entry = config_entry 32 | 33 | async def async_step_init(self: Self, user_input: dict[str, Any] | None = None) -> FlowResult: 34 | """ 35 | Handles Dijnet configuration init step. 36 | 37 | Args: 38 | user_input: 39 | The dictionary contains the settings entered by the user 40 | on the configuration screen. 41 | 42 | """ 43 | data_schema = vol.Schema( 44 | { 45 | vol.Required(CONF_PASSWORD, default=self._config_entry.data[CONF_PASSWORD]): str, 46 | vol.Optional( 47 | CONF_DOWNLOAD_DIR, default=self._config_entry.data.get(CONF_DOWNLOAD_DIR) 48 | ): str, 49 | vol.Required( 50 | CONF_ENCASHMENT_REPORTED_AS_PAID_AFTER_DEADLINE, 51 | default=self._config_entry.data[CONF_ENCASHMENT_REPORTED_AS_PAID_AFTER_DEADLINE], 52 | ): bool, 53 | } 54 | ) 55 | 56 | if user_input is not None: 57 | async with DijnetSession() as session: 58 | if not await session.post_login( 59 | self._config_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD] 60 | ): 61 | return self.async_show_form( 62 | step_id="init", 63 | data_schema=data_schema, 64 | errors={"base": "invalid_username_or_password"}, 65 | ) 66 | 67 | self.hass.config_entries.async_update_entry( 68 | self._config_entry, data=self._config_entry.data | user_input 69 | ) 70 | 71 | return self.async_abort(reason="reconfigure_successful") 72 | 73 | return self.async_show_form(step_id="init", data_schema=data_schema) 74 | 75 | 76 | @HANDLERS.register(DOMAIN) 77 | class DijnetConfigFlow(ConfigFlow, domain=DOMAIN): 78 | """Configuration flow handler for Dijnet integration.""" 79 | 80 | VERSION = 2 81 | 82 | @staticmethod 83 | @callback 84 | def async_get_options_flow(config_entry: ConfigEntry) -> DijnetOptionsFlowHandler: 85 | """ 86 | Gets the options flow handler for the integration. 87 | 88 | Args: 89 | config_entry: 90 | The config entry of the integration. 91 | 92 | Returns: 93 | The options flow handler for the integration. 94 | 95 | """ 96 | return DijnetOptionsFlowHandler(config_entry) 97 | 98 | async def async_step_user(self: Self, user_input: dict[str, Any]) -> FlowResult: 99 | """ 100 | Handles the step when integration added from the UI. 101 | 102 | Args: 103 | user_input: 104 | The dictionary contains the settings entered by the user 105 | on the configuration screen. 106 | 107 | Returns: 108 | The result of the flow step. 109 | """ 110 | data_schema = vol.Schema( 111 | { 112 | vol.Required(CONF_USERNAME): str, 113 | vol.Required(CONF_PASSWORD): str, 114 | vol.Optional(CONF_DOWNLOAD_DIR): str, 115 | vol.Required(CONF_ENCASHMENT_REPORTED_AS_PAID_AFTER_DEADLINE): bool, 116 | } 117 | ) 118 | 119 | if user_input is not None: 120 | async with DijnetSession() as session: 121 | if not await session.post_login( 122 | user_input[CONF_USERNAME], user_input[CONF_PASSWORD] 123 | ): 124 | return self.async_show_form( 125 | step_id="user", 126 | data_schema=data_schema, 127 | errors={CONF_USERNAME: "invalid_username_or_password"}, 128 | ) 129 | 130 | await self.async_set_unique_id(user_input[CONF_USERNAME]) 131 | self._abort_if_unique_id_configured() 132 | 133 | data = { 134 | CONF_USERNAME: user_input[CONF_USERNAME], 135 | CONF_PASSWORD: user_input[CONF_PASSWORD], 136 | CONF_DOWNLOAD_DIR: user_input.get(CONF_DOWNLOAD_DIR, ""), 137 | CONF_ENCASHMENT_REPORTED_AS_PAID_AFTER_DEADLINE: user_input[ 138 | CONF_ENCASHMENT_REPORTED_AS_PAID_AFTER_DEADLINE 139 | ], 140 | } 141 | 142 | return self.async_create_entry(title=f"Dijnet ({user_input[CONF_USERNAME]})", data=data) 143 | 144 | return self.async_show_form( 145 | step_id="user", 146 | data_schema=data_schema, 147 | ) 148 | 149 | async def async_step_import(self: Self, import_config: dict[str, Any]) -> FlowResult: 150 | """Handles the yaml configuration import step.""" 151 | _LOGGER.debug("Importing Dijnet config from yaml.") 152 | 153 | await self.async_set_unique_id(import_config[CONF_USERNAME]) 154 | self._abort_if_unique_id_configured() 155 | 156 | return self.async_create_entry( 157 | title=f"Dijnet ({import_config[CONF_USERNAME]})", data=import_config 158 | ) 159 | -------------------------------------------------------------------------------- /custom_components/dijnet/dijnet_session.py: -------------------------------------------------------------------------------- 1 | """Module for a Dijnet session.""" 2 | 3 | import logging 4 | from datetime import datetime 5 | from types import TracebackType 6 | from typing import Self 7 | 8 | import aiohttp 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | DATE_FORMAT = "%Y-%m-%d" 13 | ROOT_URL = "https://www.dijnet.hu" 14 | 15 | 16 | class DijnetSession: 17 | """DijnetSession class represents a session at Dijnet.""" 18 | 19 | def __init__(self: Self): 20 | """Initialize a new instance of DijnetSession class.""" 21 | self._session = None 22 | 23 | async def __aenter__(self: Self): 24 | """Enter the async context manager.""" 25 | self._session = aiohttp.ClientSession() 26 | return self 27 | 28 | async def __aexit__( 29 | self: Self, 30 | exc_type: type[BaseException] | None, 31 | exc_val: BaseException | None, 32 | exc_tb: TracebackType | None, 33 | ): 34 | """ 35 | Exit the async context manager. 36 | 37 | Args: 38 | exc_type: 39 | The exception type. 40 | exc_val: 41 | The exception value. 42 | exc_tb: 43 | The exception traceback. 44 | """ 45 | await self._session.__aexit__(exc_type, exc_val, exc_tb) 46 | 47 | async def get_root_page(self: Self) -> bytes: 48 | """ 49 | Loads the root page content of Dijnet. 50 | 51 | Returns 52 | The root page content. 53 | """ 54 | async with self._session.get(ROOT_URL) as response: 55 | return await response.read() 56 | 57 | async def get_main_page(self: Self) -> bytes: 58 | """ 59 | Loads the main page content of Dijnet after login. 60 | 61 | Returns: 62 | The main page content. 63 | """ 64 | _LOGGER.debug("Getting main page.") 65 | async with self._session.get(f"{ROOT_URL}/ekonto/control/main") as response: 66 | return await response.read() 67 | 68 | async def get_new_providers_page(self: Self) -> bytes: 69 | """ 70 | Loads the new providers page content of Dijnet after login. 71 | 72 | Returns: 73 | The new providers page content. 74 | 75 | """ 76 | _LOGGER.debug("Getting regszolg_new page.") 77 | async with self._session.get(f"{ROOT_URL}/ekonto/control/regszolg_new") as response: 78 | return await response.read() 79 | 80 | async def get_registered_providers_page(self: Self) -> bytes: 81 | """ 82 | Loads the registered providers page content of Dijnet after login. 83 | 84 | Returns 85 | The registered providers page content. 86 | 87 | """ 88 | _LOGGER.debug("Getting regszolg_list page.") 89 | async with self._session.get(f"{ROOT_URL}/ekonto/control/regszolg_list") as response: 90 | return await response.read() 91 | 92 | async def get_invoice_page(self: Self, index: int) -> bytes: 93 | """ 94 | Loads the invoice page content for the specified invoice index. 95 | 96 | Args: 97 | index: 98 | The index of the invoice. 99 | 100 | Returns: 101 | The invoice page content. 102 | """ 103 | _LOGGER.debug("Getting szamla_select page.") 104 | async with self._session.get( 105 | f"{ROOT_URL}/ekonto/control/szamla_select?vfw_coll=szamla_list&vfw_rowid={index}&exp=K" 106 | ) as response: 107 | return await response.read() 108 | 109 | async def get_invoice_history_page(self: Self) -> bytes: 110 | """ 111 | Loads the invoice history page content. 112 | 113 | Returns: 114 | The invoice history page content. 115 | """ 116 | _LOGGER.debug("Getting szamla_hist page.") 117 | async with self._session.get(f"{ROOT_URL}/ekonto/control/szamla_hist") as response: 118 | return await response.read() 119 | 120 | async def get_invoice_list_page(self: Self) -> bytes: 121 | """ 122 | Loads the invoice list page content. 123 | 124 | Returns: 125 | The invoice list page content. 126 | """ 127 | _LOGGER.debug("Getting szamla_list page.") 128 | async with self._session.get(f"{ROOT_URL}/ekonto/control/szamla_list") as response: 129 | return await response.read() 130 | 131 | async def get_invoice_download_page(self: Self) -> bytes: 132 | """ 133 | Loads the invoice download page content. 134 | 135 | Returns: 136 | The invoice download page content. 137 | """ 138 | _LOGGER.debug("Getting szamla_letolt page.") 139 | async with self._session.get(f"{ROOT_URL}/ekonto/control/szamla_letolt") as response: 140 | return await response.read() 141 | 142 | async def get_invoice_search_page(self: Self) -> bytes: 143 | """ 144 | Loads the invoice search page content. 145 | 146 | Returns: 147 | The invoice search page content. 148 | """ 149 | _LOGGER.debug("Getting szamla_search page.") 150 | async with self._session.get(f"{ROOT_URL}/ekonto/control/szamla_search") as response: 151 | return await response.read() 152 | 153 | async def post_search_invoice( 154 | self: Self, provider_name: str, reg_id: str, vfw_token: str, from_date: str, to_date: str 155 | ) -> bytes: 156 | """ 157 | Executes an invoice search with the specified parameters 158 | 159 | Args: 160 | provider_name: 161 | The name of the provider. 162 | reg_id: 163 | The reg id. 164 | vfw_token: 165 | The vfw_token hidden input parameter. 166 | from_date: 167 | The search date interval start as date iso string. 168 | to_date: 169 | The search date interval end as date iso string. 170 | 171 | Returns: 172 | The search result. 173 | """ 174 | _LOGGER.debug("Posting search to szamla_search_submit.") 175 | async with self._session.post( 176 | f"{ROOT_URL}/ekonto/control/szamla_search_submit", 177 | data={ 178 | "vfw_form": "szamla_search_submit", 179 | "vfw_coll": "szamla_search_params", 180 | "vfw_token": vfw_token, 181 | "szlaszolgnev": provider_name, 182 | "regszolgid": reg_id, 183 | "datumtol": datetime.fromisoformat(from_date).strftime(DATE_FORMAT), 184 | "datumig": datetime.fromisoformat(to_date).strftime(DATE_FORMAT), 185 | }, 186 | ) as response: 187 | return await response.read() 188 | 189 | async def download(self: Self, url: str) -> bytes: 190 | """ 191 | Downloads the content of the specified url. 192 | 193 | Args: 194 | url: 195 | The url to download 196 | 197 | Returns: 198 | The downloaded content as bytes. 199 | """ 200 | _LOGGER.debug("Downloading file: %s", url) 201 | async with self._session.get(url) as response: 202 | return await response.read() 203 | 204 | async def post_login(self: Self, username: str, password: str) -> bool: 205 | """ 206 | Posts the login information to Dijnet. 207 | 208 | Args: 209 | username: 210 | The username. 211 | password: 212 | The password. 213 | 214 | Returns: 215 | The value indicates whether the login was successful. 216 | """ 217 | _LOGGER.debug("Posting login information to login_check_ajax.") 218 | async with self._session.post( 219 | "https://www.dijnet.hu/ekonto/login/login_check_ajax", 220 | data={"username": username, "password": password}, 221 | ) as response: 222 | json = await response.json(content_type=None) 223 | if not json["success"]: 224 | _LOGGER.warning(json) 225 | return json["success"] 226 | -------------------------------------------------------------------------------- /custom_components/dijnet/controller.py: -------------------------------------------------------------------------------- 1 | """Module for Dijnet controller.""" 2 | 3 | from __future__ import annotations 4 | 5 | import json 6 | import logging 7 | import re 8 | from datetime import date, datetime, timedelta 9 | from os import makedirs, path, remove 10 | from typing import TYPE_CHECKING, Any, Self 11 | 12 | import anyio 13 | import pytz 14 | import yaml 15 | from homeassistant.util import Throttle, slugify 16 | from pyquery import PyQuery 17 | 18 | from .const import DATA_CONTROLLER, DOMAIN 19 | from .dijnet_session import DijnetSession 20 | 21 | if TYPE_CHECKING: 22 | from homeassistant.core import HomeAssistant 23 | 24 | _LOGGER = logging.getLogger(__name__) 25 | 26 | MIN_DATE = "1990-01-01" 27 | DATE_FORMAT = "%Y.%m.%d" 28 | MIN_TIME_BETWEEN_UPDATES = timedelta(hours=3) 29 | MIN_TIME_BETWEEN_ISSUER_UPDATES = timedelta(days=1) 30 | PAID_INVOICES_FILENAME = ".dijnet_paid_invoices_{0}.yaml" 31 | REGISTRY_FILENAME = ".dijnet_registry_{0}.yaml" 32 | ATTR_REGISTRY_NEXT_QUERY_DATE = "next_query_date" 33 | 34 | ATTR_PROVIDER = "provider" 35 | ATTR_DISPLAY_NAME = "display_name" 36 | ATTR_INVOICE_NO = "invoice_no" 37 | ATTR_ISSUANCE_DATE = "issuance_date" 38 | ATTR_DEADLINE = "deadline" 39 | ATTR_AMOUNT = "amount" 40 | PAID_KEY = "paid" 41 | ATTR_PAID_AT = "paid_at" 42 | 43 | TZ = pytz.timezone("Europe/Budapest") 44 | 45 | 46 | class InvoiceIssuer: 47 | """Represents an invoice issuer.""" 48 | 49 | def __init__( 50 | self: Self, issuer_id: str, issuer_name: str, display_name: str, providers: list[str] 51 | ) -> None: 52 | """ 53 | Initialize a new instance of InvoiceIssuer class. 54 | 55 | Args: 56 | issuer_id: 57 | The registration ID at the invoice issuer. 58 | issuer_name: 59 | The name of the invoice issuer. 60 | display_name: 61 | The display name of the registration. 62 | providers: 63 | The list of providers belongs to issuer. 64 | """ 65 | self._issuer_id = issuer_id 66 | self._issuer_name = issuer_name 67 | self._display_name = display_name 68 | self._providers = providers 69 | 70 | def __str__(self: Self) -> str: 71 | """Returns the string representation of the class.""" 72 | return f"{self.issuer} - {self.issuer_id} - {self.display_name} - {self.providers}" 73 | 74 | @property 75 | def issuer_id(self: Self) -> str: 76 | """Gets the invoice issuer id.""" 77 | return self._issuer_id 78 | 79 | @property 80 | def display_name(self: Self) -> str: 81 | """Gets the display name.""" 82 | return self._display_name 83 | 84 | @property 85 | def issuer(self: Self) -> str: 86 | """Gets the invoice issuer name.""" 87 | return self._issuer_name 88 | 89 | @property 90 | def providers(self: Self) -> list[str]: 91 | """Gets the list of providers belongs to the issuer""" 92 | return self._providers 93 | 94 | 95 | class Invoice: 96 | """Represents an invoice.""" 97 | 98 | def __init__( # noqa: PLR0913 99 | self: Self, 100 | provider: str, 101 | display_name: str, 102 | invoice_no: str, 103 | issuance_date: datetime, 104 | amount: int, 105 | deadline: datetime, 106 | ): 107 | """ 108 | Initialize a new instance of Invoice class. 109 | 110 | Args: 111 | provider: 112 | The provider. 113 | display_name: 114 | The display name. 115 | invoice_no: 116 | The invoice number. 117 | issuance_date: 118 | The issuance date. 119 | amount: 120 | The invoice amount. 121 | deadline: 122 | The deadline. 123 | """ 124 | self._provider = provider 125 | self._display_name = display_name 126 | self._invoice_no = invoice_no 127 | self._issuance_date = issuance_date 128 | self._amount = amount 129 | self._deadline = deadline 130 | 131 | @property 132 | def provider(self: Self) -> str: 133 | """Gets the provider.""" 134 | return self._provider 135 | 136 | @property 137 | def display_name(self: Self) -> str: 138 | """Gets the display name.""" 139 | return self._display_name 140 | 141 | @property 142 | def invoice_no(self: Self) -> str: 143 | """Gets the invoice number.""" 144 | return self._invoice_no 145 | 146 | @property 147 | def issuance_date(self: Self) -> datetime: 148 | """Gets the issuance date.""" 149 | return self._issuance_date 150 | 151 | @property 152 | def amount(self: Self) -> int: 153 | """Gets the issuance date.""" 154 | return self._amount 155 | 156 | @property 157 | def deadline(self: Self) -> datetime: 158 | """Gets the deadline.""" 159 | return self._deadline 160 | 161 | def __eq__(self: Self, obj: object): 162 | """Implements the equality operator.""" 163 | return ( 164 | isinstance(obj, Invoice) 165 | and obj.provider == self.provider 166 | and obj.invoice_no == self.invoice_no 167 | ) 168 | 169 | def __hash__(self: Self) -> int: 170 | """Implements hash so Invoice is hashable (based on provider and invoice_no).""" 171 | return hash((self.provider, self.invoice_no)) 172 | 173 | def to_dictionary(self: Self) -> dict[str, Any]: 174 | """ 175 | Converts the paid invoice to a dictionary. 176 | 177 | Returns: 178 | The dictionary contains information of paid invoice. 179 | """ 180 | return { 181 | ATTR_PROVIDER: self._provider, 182 | ATTR_DISPLAY_NAME: self.display_name, 183 | ATTR_INVOICE_NO: self.invoice_no, 184 | ATTR_ISSUANCE_DATE: self.issuance_date, 185 | ATTR_AMOUNT: self.amount, 186 | ATTR_DEADLINE: self.deadline, 187 | } 188 | 189 | def __str__(self: Self): 190 | """Returns the string representation of the class.""" 191 | return self.to_dictionary().__str__() 192 | 193 | 194 | class PaidInvoice(Invoice): 195 | """Represents a paid invoice.""" 196 | 197 | def __init__( # noqa: PLR0913 198 | self: Self, 199 | provider: str, 200 | display_name: str, 201 | invoice_no: str, 202 | issuance_date: datetime, 203 | amount: int, 204 | deadline: datetime, 205 | paid_at: datetime, 206 | ) -> None: 207 | """ 208 | Initialize a new instance of Invoice class. 209 | 210 | Args: 211 | provider: 212 | The provider. 213 | display_name: 214 | The display name. 215 | invoice_no: 216 | The invoice number. 217 | issuance_date: 218 | The issuance date. 219 | amount: 220 | The invoice amount. 221 | deadline: 222 | The deadline. 223 | paid_at: 224 | The date of payment. 225 | """ 226 | super().__init__(provider, display_name, invoice_no, issuance_date, amount, deadline) 227 | self._paid_at = paid_at 228 | 229 | @property 230 | def paid_at(self: Self) -> datetime: 231 | """Gets the date of payment.""" 232 | return self._paid_at 233 | 234 | @staticmethod 235 | def from_dictionary(dictionary: dict[str, Any]) -> PaidInvoice: 236 | """ 237 | Converts a dictionary to PaidInvoice instance. 238 | 239 | Args: 240 | dictionary: 241 | The dictionary to convert. 242 | 243 | Returns: 244 | The converted paid invoice. 245 | """ 246 | return PaidInvoice( 247 | dictionary[ATTR_PROVIDER], 248 | dictionary[ATTR_DISPLAY_NAME], 249 | dictionary[ATTR_INVOICE_NO], 250 | dictionary[ATTR_ISSUANCE_DATE].date().isoformat() 251 | if isinstance(dictionary[ATTR_ISSUANCE_DATE], datetime) 252 | else dictionary[ATTR_ISSUANCE_DATE], 253 | dictionary[ATTR_AMOUNT], 254 | dictionary[ATTR_DEADLINE].date().isoformat() 255 | if isinstance(dictionary[ATTR_DEADLINE], datetime) 256 | else dictionary[ATTR_DEADLINE], 257 | dictionary[ATTR_PAID_AT], 258 | ) 259 | 260 | def to_dictionary(self: Self) -> dict[str, Any]: 261 | """ 262 | Converts the paid invoice to a dictionary. 263 | 264 | Returns: 265 | The dictionary contains information of paid invoice. 266 | """ 267 | res = super().to_dictionary() 268 | res[ATTR_PAID_AT] = self.paid_at 269 | 270 | return res 271 | 272 | 273 | class DijnetController: 274 | """Responsible for providing data from Dijnet website.""" 275 | 276 | def __init__( 277 | self: Self, 278 | username: str, 279 | password: str, 280 | download_dir: str | None = None, 281 | encashment_reported_as_paid_after_deadline: bool = False, 282 | ) -> None: 283 | """ 284 | Initialize a new instance of DijnetController class. 285 | 286 | Args: 287 | username: 288 | The registered username. 289 | password: 290 | The password for user. 291 | download_dir: 292 | Optional download directory. If set then the invoice 293 | files are downloaded to that location. 294 | encashment_reported_as_paid_after_deadline: 295 | The value indicates whether the encashment 296 | should be reported as paid after deadline, 297 | """ 298 | self._username = username 299 | self._password = password 300 | self._download_dir = download_dir 301 | self._encashment_reported_as_paid_after_deadline = ( 302 | encashment_reported_as_paid_after_deadline 303 | ) 304 | self._registry: dict[str, str] = None 305 | self._unpaid_invoices: list[Invoice] = [] 306 | self._paid_invoices: list[Invoice] = [] 307 | self._issuers: list[InvoiceIssuer] = [] 308 | self._remove_old_files() 309 | 310 | def _remove_old_files(self: Self) -> None: 311 | """ 312 | Removes the old registry and paid invoices files, 313 | because they could be corrupted if multiple accounts handled. 314 | """ 315 | # remove old registry and paid invoice files (they might be corrupted) 316 | if path.exists(".dijnet_paid_invoices.yaml"): 317 | try: 318 | remove(".dijnet_paid_invoices.yaml") 319 | except Exception: # noqa: BLE001 320 | _LOGGER.warning("Failed to remove .dijnet_paid_invoices.yaml file") 321 | 322 | if path.exists(".dijnet_registry.yaml"): 323 | try: 324 | remove(".dijnet_registry.yaml") 325 | except Exception: # noqa: BLE001 326 | _LOGGER.warning("Failed to remove .dijnet_registry.yaml file") 327 | 328 | async def get_unpaid_invoices(self: Self) -> list[Invoice]: 329 | """ 330 | Gets the list of unpaid invoices. 331 | 332 | Returns: 333 | The list of unpaid invoices. 334 | """ 335 | await self.update_invoices() 336 | return self._unpaid_invoices 337 | 338 | async def get_paid_invoices(self: Self) -> list[Invoice]: 339 | """ 340 | Gets the list of paid invoices. 341 | 342 | Returns: 343 | The list of paid invoices. 344 | """ 345 | await self.update_invoices() 346 | return self._paid_invoices 347 | 348 | async def get_issuers(self: Self) -> list[InvoiceIssuer]: 349 | """ 350 | Gets the list of registered invoice issuers. 351 | 352 | Returns: 353 | The list of registered invoice issuers. 354 | """ 355 | await self.update_registered_issuers() 356 | return self._issuers 357 | 358 | @Throttle(MIN_TIME_BETWEEN_ISSUER_UPDATES) 359 | async def update_registered_issuers(self: Self) -> None: 360 | """Updates the registered issuers list.""" 361 | issuers: list[InvoiceIssuer] = [] 362 | 363 | _LOGGER.debug("Updating issuers.") 364 | 365 | async with DijnetSession() as session: 366 | await session.get_root_page() 367 | 368 | if not await session.post_login(self._username, self._password): 369 | return 370 | 371 | await session.get_main_page() 372 | 373 | search_page = await session.get_invoice_search_page() 374 | 375 | providers_json = re.search( 376 | r"var ropts = (.*);", search_page.decode("iso-8859-2") 377 | ).groups(1)[0] 378 | 379 | raw_providers: list[Any] = json.loads(providers_json) 380 | 381 | await session.get_new_providers_page() 382 | 383 | invoice_providers_response = await session.get_registered_providers_page() 384 | 385 | invoice_providers_response_pquery = PyQuery( 386 | invoice_providers_response.decode("iso-8859-2").encode("utf-8") 387 | ) 388 | for row in invoice_providers_response_pquery.find(".table > tbody > tr").items(): 389 | issuer_name = row.children("td:nth-child(1)").text() 390 | issuer_id = row.children("td:nth-child(2)").text() 391 | display_name = row.children("td:nth-child(3)").text() or issuer_id 392 | providers = [ 393 | raw_provider["szlaszolgnev"] 394 | for raw_provider in raw_providers 395 | if (raw_provider["alias"] or raw_provider["aliasnev"]) == display_name 396 | ] 397 | issuer = InvoiceIssuer(issuer_id, issuer_name, display_name, providers) 398 | issuers.append(issuer) 399 | _LOGGER.debug("Issuer found (%s)", issuer) 400 | 401 | self._issuers = issuers 402 | 403 | @Throttle(MIN_TIME_BETWEEN_UPDATES) 404 | async def update_invoices(self: Self) -> None: # noqa: PLR0912, PLR0915, C901 405 | """Updates the invoice lists.""" 406 | _LOGGER.debug("Updating invoices.") 407 | 408 | if self._registry is None: 409 | await self._initialize_registry_and_unpaid_invoices() 410 | 411 | async with DijnetSession() as session: 412 | await session.get_root_page() 413 | 414 | if not await session.post_login(self._username, self._password): 415 | return 416 | 417 | from_date = self._registry[ATTR_REGISTRY_NEXT_QUERY_DATE] 418 | to_date = datetime.now(TZ).date().isoformat() 419 | 420 | await session.get_main_page() 421 | 422 | search_page = await session.get_invoice_search_page() 423 | search_page_pyquery = PyQuery(search_page.decode("iso-8859-2").encode("utf-8")) 424 | 425 | vfw_token = next( 426 | search_page_pyquery.find( 427 | "form[action=szamla_search_submit] input[name=vfw_token]" 428 | ).items() 429 | ).val() 430 | 431 | vfw_token = next( 432 | search_page_pyquery.find( 433 | "form[action=szamla_search_submit] input[name=vfw_token]" 434 | ).items() 435 | ).val() 436 | 437 | search_result = await session.post_search_invoice("", "", vfw_token, from_date, to_date) 438 | 439 | invoices_pyquery = PyQuery(search_result.decode("iso-8859-2").encode("utf-8")) 440 | possible_new_paid_invoices: list[PaidInvoice] = [] 441 | possible_new_unpaid_invoices: list[Invoice] = [] 442 | index = 0 443 | for row in invoices_pyquery.find(".table > tbody > tr").items(): 444 | invoice: Invoice = None 445 | is_paid: bool | None = self._is_invoice_paid(row) 446 | if is_paid is None: 447 | _LOGGER.error( 448 | "Failed to determine invoice state. State column text: %s", 449 | row.children("td:nth-child(8)").text(), 450 | ) 451 | continue 452 | 453 | if is_paid: 454 | await session.get_invoice_page(index) 455 | invoice_history_page = await session.get_invoice_history_page() 456 | invoice_history_page_response_pyquery = PyQuery( 457 | invoice_history_page.decode("iso-8859-2").encode("utf-8") 458 | ) 459 | for history_row in invoice_history_page_response_pyquery.find( 460 | ".table tr" 461 | ).items(): 462 | if history_row.children("td:nth-child(4)").text() == "**Sikeres fizetés**": 463 | paid_at = ( 464 | datetime.strptime( 465 | history_row.children("td:nth-child(1)").text(), DATE_FORMAT 466 | ) 467 | .replace(tzinfo=TZ) 468 | .date() 469 | .isoformat() 470 | ) 471 | invoice = self._create_invoice_from_row(row, paid_at) 472 | possible_new_paid_invoices.append(invoice) 473 | else: 474 | # payment info not found, but invoice paid 475 | paid_at = ( 476 | datetime.strptime( 477 | row.children("td:nth-child(6)").text(), DATE_FORMAT 478 | ) 479 | .replace(tzinfo=TZ) 480 | .date() 481 | .isoformat() 482 | ) 483 | invoice = self._create_invoice_from_row(row, paid_at) 484 | possible_new_paid_invoices.append(invoice) 485 | 486 | else: 487 | invoice = self._create_invoice_from_row(row) 488 | possible_new_unpaid_invoices.append(invoice) 489 | 490 | if self._download_dir != "": 491 | directory = path.join(self._download_dir, slugify(invoice.provider)) 492 | makedirs(directory, exist_ok=True) 493 | if invoice is not PaidInvoice: 494 | await session.get_invoice_page(index) 495 | 496 | invoice_download_page = await session.get_invoice_download_page() 497 | 498 | unpaid_invoice_download_page_response_pyquery = PyQuery( 499 | invoice_download_page.decode("iso-8859-2").encode("utf-8") 500 | ) 501 | 502 | for downloadable_link in unpaid_invoice_download_page_response_pyquery.find( 503 | "#content_bs a[href*=szamla_pdf], a[href*=szamla_xml]" 504 | ).items(): 505 | href = downloadable_link.attr("href") 506 | extension = href.split("?")[0].split("_")[-1] 507 | name = href.split("?")[0][:-4] 508 | filename = ( 509 | slugify( 510 | f"{datetime.fromisoformat(invoice.issuance_date).strftime('%Y%m%d')}_{invoice.invoice_no}_{name}" 511 | ) 512 | + f".{extension}" 513 | ) 514 | download_url = f"https://www.dijnet.hu/ekonto/control/{href}" 515 | _LOGGER.debug("Downloadable file found (%s).", download_url) 516 | 517 | full_path = path.join(directory, filename) 518 | 519 | if path.exists(full_path): 520 | _LOGGER.debug("File already downloaded (%s)", full_path) 521 | else: 522 | _LOGGER.info("Downloading file (%s -> %s).", download_url, full_path) 523 | file_content = await session.download(download_url) 524 | async with await anyio.open_file(full_path, "wb") as file: 525 | await file.write(file_content) 526 | 527 | index += 1 528 | await session.get_invoice_list_page() 529 | 530 | paid_invoices = self._paid_invoices.copy() 531 | unpaid_invoices = self._unpaid_invoices.copy() 532 | new_paid_invoices: list[PaidInvoice] = [] 533 | for possible_new_paid_invoice in possible_new_paid_invoices: 534 | already_exists = False 535 | for paid_invoice in paid_invoices: 536 | if paid_invoice == possible_new_paid_invoice: 537 | already_exists = True 538 | break 539 | 540 | if not already_exists: 541 | paid_invoices.append(possible_new_paid_invoice) 542 | new_paid_invoices.append(possible_new_paid_invoice) 543 | for unpaid_invoice in unpaid_invoices: 544 | if unpaid_invoice == possible_new_paid_invoice: 545 | unpaid_invoices.remove(unpaid_invoice) 546 | break 547 | 548 | for possible_new_unpaid_invoice in possible_new_unpaid_invoices: 549 | already_exists = False 550 | for unpaid_invoice in unpaid_invoices: 551 | if unpaid_invoice == possible_new_unpaid_invoice: 552 | already_exists = True 553 | break 554 | 555 | if not already_exists: 556 | unpaid_invoices.append(possible_new_unpaid_invoice) 557 | 558 | if len(new_paid_invoices) > 0: 559 | async with await anyio.open_file( 560 | get_paid_invoices_filename(self._username), "a" 561 | ) as file: 562 | await file.write("\n") 563 | await file.write( 564 | yaml.dump( 565 | [x.to_dictionary() for x in new_paid_invoices], 566 | default_flow_style=False, 567 | ) 568 | ) 569 | 570 | next_query_date = ( 571 | (datetime.fromisoformat(to_date) - timedelta(days=31)).date().isoformat() 572 | ) 573 | 574 | for unpaid_invoice in unpaid_invoices: 575 | next_query_date = min(next_query_date, unpaid_invoice.issuance_date) 576 | 577 | registry = {ATTR_REGISTRY_NEXT_QUERY_DATE: next_query_date} 578 | 579 | async with await anyio.open_file(get_registry_filename(self._username), "w") as file: 580 | await file.write(yaml.dump(registry, default_flow_style=False)) 581 | 582 | self._registry = registry 583 | self._unpaid_invoices = unpaid_invoices 584 | self._paid_invoices = paid_invoices 585 | 586 | def _create_invoice_from_row( 587 | self: Self, row: PyQuery, paid_at: datetime | None = None 588 | ) -> Invoice: 589 | provider = row.children("td:nth-child(1)").text() 590 | display_name = row.children("td:nth-child(2)").text() 591 | invoice_no = row.children("td:nth-child(3)").text() 592 | issuance_date = ( 593 | datetime.strptime(row.children("td:nth-child(4)").text(), DATE_FORMAT) 594 | .replace(tzinfo=TZ) 595 | .date() 596 | .isoformat() 597 | ) 598 | amount = float(re.sub(r"[^0-9\-]+", "", row.children("td:nth-child(7)").text())) 599 | deadline = ( 600 | datetime.strptime(row.children("td:nth-child(6)").text(), DATE_FORMAT) 601 | .replace(tzinfo=TZ) 602 | .date() 603 | .isoformat() 604 | ) 605 | 606 | invoice: Invoice = None 607 | if paid_at: 608 | invoice = PaidInvoice( 609 | provider, display_name, invoice_no, issuance_date, amount, deadline, paid_at 610 | ) 611 | else: 612 | invoice = Invoice(provider, display_name, invoice_no, issuance_date, amount, deadline) 613 | 614 | _LOGGER.info("Invoice created. %s", invoice) 615 | 616 | return invoice 617 | 618 | def _is_invoice_paid(self: Self, row: PyQuery) -> bool | None: 619 | state_text = row.children("td:nth-child(8)").text() 620 | 621 | paid_states: list[str] = ["Rendezett", "Fizetve"] 622 | unpaid_states: list[str] = [ 623 | "Tovább a fizetéshez", 624 | "Rendezetlen", 625 | "Mobiltelefonra küldve", 626 | "Internetbanknak átadva", 627 | ] 628 | 629 | if any(state in state_text for state in paid_states): 630 | return True 631 | 632 | if any(state in state_text for state in unpaid_states): 633 | return False 634 | 635 | collection: bool = "Csoportos beszedés" in state_text or "Beszedés alatt" in state_text 636 | if collection: 637 | if self._encashment_reported_as_paid_after_deadline: 638 | deadline = ( 639 | datetime.strptime(row.children("td:nth-child(6)").text(), DATE_FORMAT) 640 | .replace(tzinfo=TZ) 641 | .date() 642 | ) 643 | return deadline < datetime.now(tz=TZ).date() 644 | return False 645 | return None 646 | 647 | async def _initialize_registry_and_unpaid_invoices(self: Self) -> None: 648 | paid_invoices = None 649 | registry = None 650 | paid_invoices_filename = get_paid_invoices_filename(self._username) 651 | registry_filename = get_registry_filename(self._username) 652 | try: 653 | _LOGGER.debug('Loading registry from "%s"', registry_filename) 654 | async with await anyio.open_file(registry_filename) as file: 655 | registry_file_content = await file.read() 656 | registry = yaml.safe_load(registry_file_content) 657 | 658 | if isinstance(registry[ATTR_REGISTRY_NEXT_QUERY_DATE], datetime): 659 | registry[ATTR_REGISTRY_NEXT_QUERY_DATE] = ( 660 | registry[ATTR_REGISTRY_NEXT_QUERY_DATE].date().isoformat() 661 | ) 662 | elif isinstance(registry[ATTR_REGISTRY_NEXT_QUERY_DATE], date): 663 | registry[ATTR_REGISTRY_NEXT_QUERY_DATE] = registry[ 664 | ATTR_REGISTRY_NEXT_QUERY_DATE 665 | ].isoformat() 666 | 667 | paid_invoices = [] 668 | _LOGGER.debug('Loading invoices from "%s"', paid_invoices_filename) 669 | async with await anyio.open_file(paid_invoices_filename) as file: 670 | paid_invoices_file_content = await file.read() 671 | data = yaml.safe_load(paid_invoices_file_content) 672 | for paid_invoice_dict in data: 673 | try: 674 | paid_invoices.append(PaidInvoice.from_dictionary(paid_invoice_dict)) 675 | except Exception as exception: # noqa: BLE001 676 | _LOGGER.warning("Invalid paid invoice data: %s", exception) 677 | except FileNotFoundError: 678 | _LOGGER.debug('"%s" or "%s" not found.', paid_invoices_filename, registry_filename) 679 | paid_invoices = [] 680 | registry = {ATTR_REGISTRY_NEXT_QUERY_DATE: MIN_DATE} 681 | 682 | self._paid_invoices = paid_invoices 683 | self._registry = registry 684 | 685 | 686 | def set_controller(hass: HomeAssistant, user_name: str, controller: DijnetController) -> None: 687 | """ 688 | Sets the controller instance for the specified username in Home Assistant data container. 689 | 690 | Args: 691 | hass: 692 | The Home Assistant instance. 693 | user_name: 694 | The registered username. 695 | controller: 696 | The controller instance to set. 697 | """ 698 | hass.data[DOMAIN][DATA_CONTROLLER][user_name] = controller 699 | 700 | 701 | def get_controller(hass: HomeAssistant, user_name: str) -> DijnetController: 702 | """ 703 | Gets the controller instance for the specified username from Home Assistant data container. 704 | 705 | Args: 706 | hass: 707 | The Home Assistant instance. 708 | user_name: 709 | The registered username. 710 | 711 | Returns: 712 | The controller associated to the specified username. 713 | """ 714 | return hass.data[DOMAIN][DATA_CONTROLLER].get(user_name) 715 | 716 | 717 | def is_controller_exists(hass: HomeAssistant, user_name: str) -> bool: 718 | """ 719 | Gets the value indicates whether a controller associated to the specified 720 | username in Home Assistant data container. 721 | 722 | Args: 723 | hass: 724 | The Home Assistant instance. 725 | user_name: 726 | The registered username. 727 | 728 | Returns: 729 | The value indicates whether a controller associated to the specified 730 | username in Home Assistant data container. 731 | 732 | """ 733 | return user_name in hass.data[DOMAIN][DATA_CONTROLLER] 734 | 735 | 736 | def get_paid_invoices_filename(username: str) -> str: 737 | """Gets the paid invoices filename.""" 738 | return PAID_INVOICES_FILENAME.format(slugify(username)) 739 | 740 | 741 | def get_registry_filename(username: str) -> str: 742 | """Gets the registry filename.""" 743 | return REGISTRY_FILENAME.format(slugify(username)) 744 | --------------------------------------------------------------------------------