├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug.yml ├── dependabot.yml ├── workflows │ ├── lint.yml │ ├── automerge.yml │ ├── validate.yml │ └── release.yml └── FUNDING.yml ├── example.png ├── assets ├── summary.png ├── is_paid_true.png ├── noy-summary.png └── is_paid_false.png ├── scripts ├── lint ├── setup └── develop ├── requirements.txt ├── custom_components └── iec │ ├── services.yaml │ ├── manifest.json │ ├── iec_entity.py │ ├── __init__.py │ ├── const.py │ ├── commons.py │ ├── translations │ ├── en.json │ └── he.json │ ├── binary_sensor.py │ ├── config_flow.py │ ├── sensor.py │ └── coordinator.py ├── hacs.json ├── config └── configuration.yaml ├── .gitignore ├── .vscode └── tasks.json ├── renovate.json ├── LICENSE ├── .devcontainer.json ├── .ruff.toml ├── CONTRIBUTING.md ├── README.md └── examples.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false -------------------------------------------------------------------------------- /example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GuyKh/iec-custom-component/HEAD/example.png -------------------------------------------------------------------------------- /assets/summary.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GuyKh/iec-custom-component/HEAD/assets/summary.png -------------------------------------------------------------------------------- /assets/is_paid_true.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GuyKh/iec-custom-component/HEAD/assets/is_paid_true.png -------------------------------------------------------------------------------- /assets/noy-summary.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GuyKh/iec-custom-component/HEAD/assets/noy-summary.png -------------------------------------------------------------------------------- /assets/is_paid_false.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GuyKh/iec-custom-component/HEAD/assets/is_paid_false.png -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | ruff check . --fix 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | colorlog>=6.8.2 2 | homeassistant==2024.2.0 3 | iec-api==0.4.12 4 | pip>=21.0 5 | ruff>=0.5.6 6 | -------------------------------------------------------------------------------- /scripts/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | python3 -m pip install --requirement requirements.txt 8 | -------------------------------------------------------------------------------- /custom_components/iec/services.yaml: -------------------------------------------------------------------------------- 1 | debug_get_coordinator_data: 2 | description: "Fetch and return the coordinator data for debugging purposes." 3 | fields: {} -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Israel Electric Corporation (IEC)", 3 | "filename": "iec.zip", 4 | "hide_default_branch": true, 5 | "homeassistant": "2024.2.0", 6 | "render_readme": true, 7 | "zip_release": true 8 | } 9 | -------------------------------------------------------------------------------- /config/configuration.yaml: -------------------------------------------------------------------------------- 1 | # https://www.home-assistant.io/integrations/default_config/ 2 | default_config: 3 | 4 | # https://www.home-assistant.io/integrations/logger/ 5 | logger: 6 | default: info 7 | logs: 8 | custom_components.iec: debug 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # artifacts 2 | __pycache__ 3 | .pytest* 4 | *.egg-info 5 | */build/* 6 | */dist/* 7 | 8 | 9 | # misc 10 | .coverage 11 | .vscode 12 | .idea 13 | .zed 14 | coverage.xml 15 | 16 | 17 | # Home Assistant configuration 18 | config/* 19 | !config/configuration.yaml 20 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Run Home Assistant on port 8123", 6 | "type": "shell", 7 | "command": "scripts/develop", 8 | "problemMatcher": [] 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /custom_components/iec/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "iec", 3 | "name": "Israel Electric Corporation (IEC)", 4 | "codeowners": ["@guykh"], 5 | "config_flow": true, 6 | "dependencies": ["recorder"], 7 | "documentation": "https://github.com/guykh/iec-custom-component", 8 | "iot_class": "cloud_polling", 9 | "issue_tracker": "https://github.com/guykh/iec-custom-component/issues", 10 | "loggers": ["iec_api"], 11 | "requirements": ["iec-api==0.4.12"], 12 | "version": "0.0.1" 13 | } 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | 9 | - package-ecosystem: "pip" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | ignore: 14 | # Dependabot should not update Home Assistant as that should match the homeassistant key in hacs.json 15 | - dependency-name: "homeassistant" -------------------------------------------------------------------------------- /scripts/develop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | # Create config dir if not present 8 | if [[ ! -d "${PWD}/config" ]]; then 9 | mkdir -p "${PWD}/config" 10 | hass --config "${PWD}/config" --script ensure_config 11 | fi 12 | 13 | # Set the path to custom_components 14 | ## This let's us have the structure we want /custom_components/iec 15 | ## while at the same time have Home Assistant configuration inside /config 16 | ## without resulting to symlinks. 17 | export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" 18 | 19 | # Start Home Assistant 20 | hass --config "${PWD}/config" --debug 21 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: "Lint" 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | pull_request: 8 | branches: 9 | - "main" 10 | 11 | jobs: 12 | ruff: 13 | name: "Ruff" 14 | runs-on: "ubuntu-latest" 15 | steps: 16 | - name: "Checkout the repository" 17 | uses: "actions/checkout@v6.0.1" 18 | 19 | - name: "Set up Python" 20 | uses: actions/setup-python@v6.1.0 21 | with: 22 | python-version: "3.11" 23 | cache: "pip" 24 | 25 | - name: "Install requirements" 26 | run: python3 -m pip install -r requirements.txt 27 | 28 | - name: "Run" 29 | run: python3 -m ruff check . 30 | -------------------------------------------------------------------------------- /.github/workflows/automerge.yml: -------------------------------------------------------------------------------- 1 | name: automerge 2 | on: 3 | pull_request: 4 | types: 5 | - labeled 6 | - unlabeled 7 | - synchronize 8 | - opened 9 | - edited 10 | - ready_for_review 11 | - reopened 12 | - unlocked 13 | pull_request_review: 14 | types: 15 | - submitted 16 | check_suite: 17 | types: 18 | - completed 19 | status: {} 20 | 21 | permissions: 22 | contents: write 23 | 24 | jobs: 25 | automerge: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - id: automerge 29 | name: automerge 30 | uses: "pascalgn/automerge-action@v0.16.4" 31 | env: 32 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 33 | MERGE_METHOD: "squash" 34 | UPDATE_RETRY_SLEEP: "30000" 35 | 36 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ], 6 | "automerge": true, 7 | "packageRules": [ 8 | { 9 | "matchDatasources": ["pypi"], 10 | "matchPackageNames": ["homeassistant"], 11 | "enabled": false 12 | }, 13 | { 14 | "matchDatasources": ["pypi"], 15 | "matchPaths": ["requirements.txt"], 16 | "automerge": true 17 | } 18 | ], 19 | "customManagers": [ 20 | { 21 | "customType": "regex", 22 | "fileMatch": ["^custom_components/.+/manifest\\.json$"], 23 | "matchStrings": ["\"requirements\":\\s*\\[(\\s*\"(?[^\"]+)(?(==|>=|<=)[0-9\\.]+)\"[,\\s]?)*\\]"], 24 | "versioningTemplate": "python", 25 | "depNameTemplate": "{{depName}}", 26 | "datasourceTemplate": "pypi" 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: guykh # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: guykh # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /custom_components/iec/iec_entity.py: -------------------------------------------------------------------------------- 1 | """Support for IEC base entities.""" 2 | 3 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 4 | 5 | from custom_components.iec import IecApiCoordinator 6 | from custom_components.iec.commons import get_device_info, IecEntityType 7 | 8 | 9 | class IecEntity(CoordinatorEntity[IecApiCoordinator]): 10 | """Class describing IEC base-class entities.""" 11 | 12 | _attr_has_entity_name = True 13 | 14 | def __init__( 15 | self, 16 | coordinator: IecApiCoordinator, 17 | contract_id: str, 18 | meter_id: str | None, 19 | iec_entity_type: IecEntityType, 20 | ): 21 | """Set up a IEC entity.""" 22 | super().__init__(coordinator) 23 | self.contract_id = contract_id 24 | self.meter_id = meter_id 25 | self.iec_entity_type = iec_entity_type 26 | self._attr_device_info = get_device_info( 27 | self.contract_id if iec_entity_type != IecEntityType.GENERIC else "Generic", 28 | self.meter_id, 29 | self.iec_entity_type, 30 | ) 31 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: "Validate" 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 0 * * *" 7 | push: 8 | branches: 9 | - "main" 10 | pull_request: 11 | branches: 12 | - "main" 13 | 14 | jobs: 15 | hassfest: # https://developers.home-assistant.io/blog/2020/04/16/hassfest 16 | name: "Hassfest Validation" 17 | runs-on: "ubuntu-latest" 18 | steps: 19 | - name: "Checkout the repository" 20 | uses: "actions/checkout@v6.0.1" 21 | 22 | - name: "Run hassfest validation" 23 | uses: "home-assistant/actions/hassfest@master" 24 | 25 | hacs: # https://github.com/hacs/action 26 | name: "HACS Validation" 27 | runs-on: "ubuntu-latest" 28 | steps: 29 | - name: "Checkout the repository" 30 | uses: "actions/checkout@v6.0.1" 31 | 32 | - name: "Run HACS validation" 33 | uses: "hacs/action@main" 34 | with: 35 | category: "integration" 36 | # Remove this 'ignore' key when you have added brand images for your integration to https://github.com/home-assistant/brands 37 | #ignore: "brands" 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 - 2023 Joakim Sørensen @ludeeus 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. -------------------------------------------------------------------------------- /.devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "GuyKh/iec-custom-component", 3 | "image": "mcr.microsoft.com/devcontainers/python:3.13-bullseye", 4 | "postCreateCommand": "scripts/setup", 5 | "forwardPorts": [ 6 | 8123 7 | ], 8 | "portsAttributes": { 9 | "8123": { 10 | "label": "Home Assistant", 11 | "onAutoForward": "notify" 12 | } 13 | }, 14 | "customizations": { 15 | "vscode": { 16 | "extensions": [ 17 | "ms-python.python", 18 | "github.vscode-pull-request-github", 19 | "ryanluker.vscode-coverage-gutters", 20 | "ms-python.vscode-pylance", 21 | "ms-python.pylint", 22 | "charliermarsh.ruff" 23 | ], 24 | "settings": { 25 | "files.eol": "\n", 26 | "editor.tabSize": 4, 27 | "python.pythonPath": "/usr/bin/python3", 28 | "python.analysis.autoSearchPaths": false, 29 | "pylintEnabled": true, 30 | "python.formatting.provider": "black", 31 | "python.formatting.blackPath": "/usr/local/py-utils/bin/black", 32 | "editor.formatOnPaste": false, 33 | "editor.formatOnSave": true, 34 | "editor.formatOnType": true, 35 | "files.trimTrailingWhitespace": true 36 | } 37 | } 38 | }, 39 | "remoteUser": "vscode", 40 | "features": { 41 | "ghcr.io/devcontainers/features/rust:1": {} 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Feature request" 3 | description: "Suggest an idea for this project" 4 | labels: "Feature+Request" 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Before you open a new feature request, search through the existing feature requests to see if others have had the same idea. 9 | - type: checkboxes 10 | attributes: 11 | label: Checklist 12 | options: 13 | - label: I have filled out the template to the best of my ability. 14 | required: true 15 | - label: This only contains 1 feature request (if you have multiple feature requests, open one feature request for each feature request). 16 | required: true 17 | - label: This issue is not a duplicate feature request of [previous feature requests](https://github.com/GuyKh/iec-custom-component/issues?q=is%3Aissue+label%3A%22Feature+Request%22+). 18 | required: true 19 | 20 | - type: textarea 21 | attributes: 22 | label: "Is your feature request related to a problem? Please describe." 23 | description: "A clear and concise description of what the problem is." 24 | placeholder: "I'm always frustrated when [...]" 25 | validations: 26 | required: true 27 | 28 | - type: textarea 29 | attributes: 30 | label: "Describe the solution you'd like" 31 | description: "A clear and concise description of what you want to happen." 32 | validations: 33 | required: true 34 | 35 | - type: textarea 36 | attributes: 37 | label: "Describe alternatives you've considered" 38 | description: "A clear and concise description of any alternative solutions or features you've considered." 39 | validations: 40 | required: true 41 | 42 | - type: textarea 43 | attributes: 44 | label: "Additional context" 45 | description: "Add any other context or screenshots about the feature request here." 46 | validations: 47 | required: true 48 | -------------------------------------------------------------------------------- /custom_components/iec/__init__.py: -------------------------------------------------------------------------------- 1 | """The IEC integration.""" 2 | 3 | from __future__ import annotations 4 | import logging 5 | 6 | from homeassistant.config_entries import ConfigEntry 7 | from homeassistant.const import Platform 8 | from homeassistant.core import HomeAssistant 9 | 10 | from .const import DOMAIN 11 | from .coordinator import IecApiCoordinator 12 | 13 | PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR] 14 | _LOGGER = logging.getLogger(__name__) 15 | 16 | 17 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 18 | """Set up IEC from a config entry.""" 19 | if DOMAIN not in hass.data: 20 | hass.data[DOMAIN] = {} 21 | 22 | iec_coordinator = IecApiCoordinator(hass, entry) 23 | hass.data.setdefault(DOMAIN, {})[entry.entry_id] = iec_coordinator 24 | try: 25 | await hass.data[DOMAIN][entry.entry_id].async_config_entry_first_refresh() 26 | except Exception as err: 27 | # Log the error but don't fail the setup 28 | _LOGGER.error("Failed to fetch initial data: %s", err) 29 | 30 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 31 | 32 | # Register the debug service 33 | async def handle_debug_get_coordinator_data(call) -> None: # noqa: ANN001 ARG001 34 | # Log or return coordinator data 35 | data = iec_coordinator.data 36 | _LOGGER.info("Coordinator data: %s", data) 37 | hass.bus.async_fire("custom_component_debug_event", {"data": data}) 38 | 39 | hass.services.async_register( 40 | DOMAIN, "debug_get_coordinator_data", handle_debug_get_coordinator_data 41 | ) 42 | 43 | return True 44 | 45 | 46 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 47 | """Unload a config entry.""" 48 | if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): 49 | coordinator = hass.data[DOMAIN].pop(entry.entry_id, None) 50 | if coordinator: 51 | await coordinator.async_unload() 52 | 53 | return unload_ok 54 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | # The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml 2 | 3 | target-version = "py310" 4 | 5 | lint.select = [ 6 | "B007", # Loop control variable {name} not used within loop body 7 | "B014", # Exception handler with duplicate exception 8 | "C", # complexity 9 | "D", # docstrings 10 | "E", # pycodestyle 11 | "F", # pyflakes/autoflake 12 | "ICN001", # import concentions; {name} should be imported as {asname} 13 | "PGH004", # Use specific rule codes when using noqa 14 | "PLC0414", # Useless import alias. Import alias does not rename original package. 15 | "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass 16 | "SIM117", # Merge with-statements that use the same scope 17 | "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() 18 | "SIM201", # Use {left} != {right} instead of not {left} == {right} 19 | "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} 20 | "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. 21 | "SIM401", # Use get from dict with default instead of an if block 22 | "T20", # flake8-print 23 | "TRY004", # Prefer TypeError exception for invalid type 24 | "RUF006", # Store a reference to the return value of asyncio.create_task 25 | "UP", # pyupgrade 26 | "W", # pycodestyle 27 | ] 28 | 29 | lint.ignore = [ 30 | "D202", # No blank lines allowed after function docstring 31 | "D203", # 1 blank line required before class docstring 32 | "D213", # Multi-line docstring summary should start at the second line 33 | "D404", # First word of the docstring should not be This 34 | "D406", # Section name should end with a newline 35 | "D407", # Section name underlining 36 | "D411", # Missing blank line before section 37 | "E501", # line too long 38 | "E731", # do not assign a lambda expression, use a def 39 | ] 40 | 41 | [lint.flake8-pytest-style] 42 | fixture-parentheses = false 43 | 44 | [lint.pyupgrade] 45 | keep-runtime-typing = true 46 | 47 | [lint.mccabe] 48 | max-complexity = 25 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Bug report" 3 | description: "Report a bug with the integration" 4 | labels: "Bug" 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Before you open a new issue, search through the existing issues to see if others have had the same problem. 9 | - type: textarea 10 | attributes: 11 | label: "System Health details" 12 | description: "Paste the data from the System Health card in Home Assistant (https://www.home-assistant.io/more-info/system-health#github-issues)" 13 | validations: 14 | required: true 15 | - type: checkboxes 16 | attributes: 17 | label: Checklist 18 | options: 19 | - label: I have enabled debug logging for my installation. 20 | required: true 21 | - label: I have filled out the issue template to the best of my ability. 22 | required: true 23 | - label: This issue only contains 1 issue (if you have multiple issues, open one issue for each issue). 24 | required: true 25 | - label: This issue is not a duplicate issue of any [previous issues](https://github.com/GuyKh/iec-custom-component/issues?q=is%3Aissue+label%3A%22Bug%22+).. 26 | required: true 27 | - type: textarea 28 | attributes: 29 | label: "Describe the issue" 30 | description: "A clear and concise description of what the issue is." 31 | validations: 32 | required: true 33 | - type: textarea 34 | attributes: 35 | label: Reproduction steps 36 | description: "Without steps to reproduce, it will be hard to fix. It is very important that you fill out this part. Issues without it will be closed." 37 | value: | 38 | 1. 39 | 2. 40 | 3. 41 | ... 42 | validations: 43 | required: true 44 | - type: textarea 45 | attributes: 46 | label: "Debug logs" 47 | description: "To enable debug logs check this https://www.home-assistant.io/integrations/logger/, this **needs** to include _everything_ from startup of Home Assistant to the point where you encounter the issue." 48 | render: text 49 | validations: 50 | required: true 51 | 52 | - type: textarea 53 | attributes: 54 | label: "Diagnostics dump" 55 | description: "Drag the diagnostics dump file here. (see https://www.home-assistant.io/integrations/diagnostics/ for info)" 56 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | 3 | Contributing to this project should be as easy and transparent as possible, whether it's: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | 10 | ## Github is used for everything 11 | 12 | Github is used to host code, to track issues and feature requests, as well as accept pull requests. 13 | 14 | Pull requests are the best way to propose changes to the codebase. 15 | 16 | 1. Fork the repo and create your branch from `main`. 17 | 2. If you've changed something, update the documentation. 18 | 3. Make sure your code lints (using `scripts/lint`). 19 | 4. Test you contribution. 20 | 5. Issue that pull request! 21 | 22 | ## Any contributions you make will be under the MIT Software License 23 | 24 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 25 | 26 | ## Report bugs using Github's [issues](../../issues) 27 | 28 | GitHub issues are used to track public bugs. 29 | Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! 30 | 31 | ## Write bug reports with detail, background, and sample code 32 | 33 | **Great Bug Reports** tend to have: 34 | 35 | - A quick summary and/or background 36 | - Steps to reproduce 37 | - Be specific! 38 | - Give sample code if you can. 39 | - What you expected would happen 40 | - What actually happens 41 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 42 | 43 | People *love* thorough bug reports. I'm not even kidding. 44 | 45 | ## Use a Consistent Coding Style 46 | 47 | Use [black](https://github.com/ambv/black) to make sure the code follows the style. 48 | 49 | ## Test your code modification 50 | 51 | This custom component is based on [iec custom component template](https://github.com/guykh/iec-custom-component). 52 | 53 | It comes with development environment in a container, easy to launch 54 | if you use Visual Studio Code. With this container you will have a stand alone 55 | Home Assistant instance running and already configured with the included 56 | [`configuration.yaml`](./config/configuration.yaml) 57 | file. 58 | 59 | ## License 60 | 61 | By contributing, you agree that your contributions will be licensed under its MIT License. 62 | -------------------------------------------------------------------------------- /custom_components/iec/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the IEC integration.""" 2 | 3 | from datetime import datetime 4 | 5 | from iec_api.models.invoice import Invoice 6 | from iec_api.models.meter_reading import MeterReading 7 | from iec_api.models.remote_reading import RemoteReading 8 | 9 | DOMAIN = "iec" 10 | 11 | ILS = "₪" 12 | ILS_PER_KWH = "ILS/kWh" 13 | 14 | EMPTY_DATETIME = datetime.fromordinal(1) 15 | EMPTY_REMOTE_READING = RemoteReading(0, datetime(2024, 1, 1), 0) 16 | EMPTY_INVOICE = Invoice( 17 | consumption=0, 18 | amount_origin=0, 19 | days_period="0", 20 | to_date=None, 21 | last_date=None, 22 | amount_paid=0, 23 | amount_to_pay=0, 24 | invoice_id=0, 25 | contract_number=0, 26 | document_id="", 27 | from_date=None, 28 | full_date=None, 29 | has_direct_debit=False, 30 | reading_code=0, 31 | invoice_type=0, 32 | invoice_payment_status=0, 33 | order_number=0, 34 | meter_readings=[ 35 | MeterReading( 36 | reading=0, 37 | reading_code="", 38 | reading_date=EMPTY_DATETIME, 39 | usage="", 40 | serial_number="", 41 | ), 42 | ], 43 | ) 44 | CONF_USER_ID = "user_id" 45 | CONF_TOTP_SECRET = "totp_secret" 46 | CONF_TOTP_TYPE = "otp_type" 47 | CONF_BP_NUMBER = "bp_number" 48 | CONF_SELECTED_CONTRACTS = "selected_contracts" 49 | CONF_AVAILABLE_CONTRACTS = "contracts" 50 | CONF_MAIN_CONTRACT_ID = "main_contract_id" 51 | JWT_DICT_NAME = "jwt" 52 | STATICS_DICT_NAME = "statics" 53 | ATTRIBUTES_DICT_NAME = "entity_attributes" 54 | ESTIMATED_BILL_DICT_NAME = "estimated_bill" 55 | METER_ID_ATTR_NAME = "device_number" 56 | CONTRACT_ID_ATTR_NAME = "contract_id" 57 | IS_SMART_METER_ATTR_NAME = "is_smart_meter" 58 | TOTAL_EST_BILL_ATTR_NAME = "total_estimated_bill" 59 | EST_BILL_DAYS_ATTR_NAME = "total_bill_days" 60 | EST_BILL_CONSUMPTION_PRICE_ATTR_NAME = "consumption_price" 61 | EST_BILL_DELIVERY_PRICE_ATTR_NAME = "delivery_price" 62 | EST_BILL_DISTRIBUTION_PRICE_ATTR_NAME = "distribution_price" 63 | EST_BILL_TOTAL_KVA_PRICE_ATTR_NAME = "total_kva_price" 64 | EST_BILL_KWH_CONSUMPTION_ATTR_NAME = "estimated_kwh_consumption_in_bill" 65 | INVOICE_DICT_NAME = "invoice" 66 | CONTRACT_DICT_NAME = "contract" 67 | DAILY_READINGS_DICT_NAME = "daily_readings" 68 | FUTURE_CONSUMPTIONS_DICT_NAME = "future_consumption" 69 | STATIC_KWH_TARIFF = "kwh_tariff" 70 | STATIC_KVA_TARIFF = "kva_tariff" 71 | STATIC_BP_NUMBER = "bp_number" 72 | ELECTRIC_INVOICE_DOC_ID = "1" 73 | ACCESS_TOKEN_ISSUED_AT = "iat" 74 | ACCESS_TOKEN_EXPIRATION_TIME = "exp" 75 | -------------------------------------------------------------------------------- /custom_components/iec/commons.py: -------------------------------------------------------------------------------- 1 | """IEC common functions.""" 2 | 3 | import pytz 4 | 5 | from datetime import date 6 | from enum import Enum 7 | 8 | from homeassistant.helpers.device_registry import DeviceInfo 9 | from iec_api.models.remote_reading import RemoteReading 10 | 11 | from custom_components.iec import DOMAIN 12 | 13 | TIMEZONE = pytz.timezone("Asia/Jerusalem") 14 | 15 | 16 | def find_reading_by_date(daily_reading: RemoteReading, desired_date: date) -> bool: 17 | """Search for a daily reading matching a specific date. 18 | 19 | Args: 20 | daily_reading (RemoteReading): An object representing a daily reading. 21 | It is assumed to have a `date` attribute of type `datetime`. 22 | desired_date (datetime): The date to search for. 23 | 24 | Returns: 25 | bool: True if a daily reading with the matching date is found, False otherwise. 26 | 27 | Raises: 28 | AttributeError: If the `daily_reading` object is missing a required attribute (e.g., `date`). 29 | TypeError: If the `daily_reading.date` attribute is not of type `datetime`. 30 | 31 | """ 32 | return daily_reading.date.date() == desired_date # Checks if the dates match 33 | 34 | 35 | class IecEntityType(Enum): 36 | """Entity type.""" 37 | 38 | GENERIC = 1 39 | CONTRACT = 2 40 | METER = 3 41 | 42 | 43 | def get_device_info( 44 | contract_id: str, 45 | meter_id: str | None, 46 | iec_entity_type: IecEntityType = IecEntityType.GENERIC, 47 | ) -> DeviceInfo: 48 | """Get device information based on contract ID and optional meter ID. 49 | 50 | Args: 51 | contract_id (str): The contract ID. 52 | meter_id (str, optional): The meter ID, if available. 53 | iec_entity_type (IecEntityType): The Entity Type 54 | 55 | Returns: 56 | DeviceInfo: An object containing device information. 57 | 58 | """ 59 | 60 | name = "IEC" 61 | model = None 62 | serial_number = None 63 | match iec_entity_type: 64 | case IecEntityType.CONTRACT: 65 | contract_id = str(int(contract_id)) 66 | name = f"IEC Contract [{contract_id}]" 67 | model = "Contract: " + contract_id 68 | case IecEntityType.METER: 69 | name = f"IEC Meter [{meter_id}]" 70 | model = "Contract: " + contract_id 71 | serial_number = ("Meter ID: " + meter_id) if meter_id else "" 72 | 73 | identifier: str = contract_id + ( 74 | ("_" + meter_id) 75 | if (iec_entity_type == IecEntityType.METER and meter_id) 76 | else "" 77 | ) 78 | return DeviceInfo( 79 | identifiers={ 80 | # Serial numbers are unique identifiers within a specific domain 81 | (DOMAIN, identifier) 82 | }, 83 | name=name, 84 | manufacturer="Israel Electric Company", 85 | model=model, 86 | serial_number=serial_number, 87 | ) 88 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Israel Electric Corporation (IEC) Custom Component 2 | 3 | [![GitHub Release][releases-shield]][releases] 4 | [![GitHub Activity][commits-shield]][commits] 5 | [![License][license-shield]](LICENSE) 6 | 7 | ![Project Maintenance][maintenance-shield] 8 | [![BuyMeCoffee][buymecoffeebadge]][buymecoffee] 9 | 10 | 11 | _Integration to integrate with [IEC API][iec]._ 12 | 13 | **This integration will set up the following platforms.** 14 | 15 | ![Example Image][exampleimg] 16 | 17 | 18 | Platform | Description 19 | -- | -- 20 | `sensor` | Show info from IEC invoice and meters. 21 | 22 | ## Installation 23 | 24 | Automatic (HACS): 25 | 1. Add this path to HACS: `https://github.com/GuyKh/iec-custom-component` 26 | 2. Install through HACS 27 | 28 | Manual: 29 | 1. Using the tool of choice open the directory (folder) for your HA configuration (where you find `configuration.yaml`). 30 | 1. If you do not have a `custom_components` directory (folder) there, you need to create it. 31 | 1. In the `custom_components` directory (folder) create a new folder called `iec`. 32 | 1. Download _all_ the files from the `custom_components/iec/` directory (folder) in this repository. 33 | 1. Place the files you downloaded in the new directory (folder) you created. 34 | 1. Restart Home Assistant 35 | 1. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "IEC" 36 | 37 | ## Configuration is done in the UI 38 | 39 | ## Logs 40 | To view logs in debug add this to `configuration.yaml`: 41 | 42 | ```yaml 43 | logger: 44 | default: info 45 | logs: 46 | ... 47 | custom_components.iec: debug 48 | iec_api: debug 49 | ``` 50 | 51 | 52 | 53 | ## Frequently Asked Questions 54 | 55 | #### How often is the data fetched? 56 | The component currently fetches data from IEC every hour, but IEC doesn't update the data very often and they don't really have a consistant behavior, it could arrive with 2 days delay. 57 | 58 | #### Can this component be used even if I'm using a private electric company? 59 | **Yes**.
60 | However, the only difference is that in the Invoices you'll see balance = `0` since you're paying to the private company 61 | 62 | #### Should I enter my configuration credentials every time? 63 | **No**.
64 | You should do it only once when configuring the component. 65 | 66 | 67 | #### I have a "dumb" meter, would this component still work for me. 68 | **Yes, But...**.
69 | You would only get a subset of sensors, based on your Invoice information. 70 | 71 | 72 | 73 | ## Examples of usage. 74 | You can head to [usage and examples page](examples.md) to see examples of this components used in cards in HomeAssistant 75 | 76 | 77 | 78 | ## Using Postman Collection for calling the IEC API 79 | 80 | If you want to use Postman to get the API calls - please read the [Postman Collections Guide](https://github.com/GuyKh/py-iec-api/blob/main/POSTMAN.md) 81 | 82 | 83 | 84 | ## Contributions are welcome! 85 | 86 | If you want to contribute to this please read the [Contribution guidelines](CONTRIBUTING.md) 87 | 88 | *** 89 | 90 | [iec]: https://www.iec.co.il/home 91 | [buymecoffee]: https://www.buymeacoffee.com/guykh 92 | [buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=for-the-badge 93 | [commits-shield]: https://img.shields.io/github/commit-activity/y/guykh/iec-custom-component.svg?style=for-the-badge 94 | [commits]: https://github.com/guykh/iec-custom-component/commits/main 95 | [exampleimg]: example.png 96 | [license-shield]: https://img.shields.io/github/license/guykh/iec-custom-component.svg?style=for-the-badge 97 | [maintenance-shield]: https://img.shields.io/badge/maintainer-Guy%20Khmelnitsky%20%40GuyKh-blue.svg?style=for-the-badge 98 | [releases-shield]: https://img.shields.io/github/release/guykh/iec-custom-component.svg?style=for-the-badge 99 | [releases]: https://github.com/guykh/iec-custom-component/releases 100 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "[0-9]+.[0-9]+.[0-9]+" 7 | - "[0-9]+.[0-9]+.[0-9]+.[0-9]+" 8 | - "[0-9]+.[0-9]+.[0-9]+a[0-9]+" 9 | - "[0-9]+.[0-9]+.[0-9]+b[0-9]+" 10 | - "[0-9]+.[0-9]+.[0-9]+rc[0-9]+" 11 | 12 | env: 13 | COMPONENT_NAME: "iec" 14 | 15 | jobs: 16 | details: 17 | runs-on: ubuntu-latest 18 | outputs: 19 | new_version: ${{ steps.release.outputs.new_version }} 20 | suffix: ${{ steps.release.outputs.suffix }} 21 | tag_name: ${{ steps.release.outputs.tag_name }} 22 | steps: 23 | - uses: actions/checkout@v6 24 | 25 | - name: Extract tag and Details 26 | id: release 27 | run: | 28 | if [ "${{ github.ref_type }}" = "tag" ]; then 29 | TAG_NAME=${GITHUB_REF#refs/tags/} 30 | NEW_VERSION=$(echo $TAG_NAME | awk -F'-' '{print $1}') 31 | SUFFIX=$(echo $TAG_NAME | grep -oP '[a-z]+[0-9]+' || echo "") 32 | echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" 33 | echo "suffix=$SUFFIX" >> "$GITHUB_OUTPUT" 34 | echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT" 35 | echo "Version is $NEW_VERSION" 36 | echo "Suffix is $SUFFIX" 37 | echo "Tag name is $TAG_NAME" 38 | else 39 | echo "No tag found" 40 | exit 1 41 | fi 42 | 43 | # check_manifest: 44 | # needs: details 45 | # runs-on: ubuntu-latest 46 | # steps: 47 | # - uses: actions/checkout@v4 48 | # - name: Fetch information from manifest.json 49 | # run: | 50 | # latest_previous_version=$(jq --raw-output '.version' "${{ github.workspace }}/custom_components/${{ env.COMPONENT_NAME }}//manifest.json") 51 | # if [ -z "$latest_previous_version" ]; then 52 | # echo "Package not found on manifest.json." 53 | # latest_previous_version="0.0.0" 54 | # fi 55 | # echo "Latest version: $latest_previous_version" 56 | # echo "latest_previous_version=$latest_previous_version" >> $GITHUB_ENV 57 | # 58 | # - name: Compare versions and exit if not newer 59 | # run: | 60 | # NEW_VERSION=${{ needs.details.outputs.new_version }} 61 | # LATEST_VERSION=$latest_previous_version 62 | # if [ "$(printf '%s\n' "$LATEST_VERSION" "$NEW_VERSION" | sort -rV | head -n 1)" != "$NEW_VERSION" ] || [ "$NEW_VERSION" == "$LATEST_VERSION" ]; then 63 | # echo "The new version $NEW_VERSION is not greater than the latest version $LATEST_VERSION on manifest.json." 64 | # exit 1 65 | # else 66 | # echo "The new version $NEW_VERSION is greater than the latest version $LATEST_VERSION on on manifest.json." 67 | # fi 68 | 69 | setup_and_build: 70 | needs: [details] #, check_manifest] 71 | runs-on: ubuntu-latest 72 | permissions: 73 | contents: write 74 | steps: 75 | - uses: actions/checkout@v6 76 | 77 | - name: Set version to manifest.json 78 | run: | 79 | jq '.version="${{ needs.details.outputs.new_version }}"' "${{ github.workspace }}/custom_components/${{ env.COMPONENT_NAME }}/manifest.json" > "${{ github.workspace }}/custom_components/${{ env.COMPONENT_NAME }}/manifest.json.tmp" 80 | mv "${{ github.workspace }}/custom_components/${{ env.COMPONENT_NAME }}/manifest.json.tmp" "${{ github.workspace }}/custom_components/${{ env.COMPONENT_NAME }}/manifest.json" 81 | 82 | - name: "ZIP the integration directory" 83 | shell: "bash" 84 | run: | 85 | cd "${{ github.workspace }}/custom_components/${{ env.COMPONENT_NAME }}" 86 | zip ${{ env.COMPONENT_NAME }}.zip -r ./ 87 | 88 | - name: "Upload the ZIP file to the release" 89 | uses: softprops/action-gh-release@v2.5.0 90 | with: 91 | files: ${{ github.workspace }}/custom_components/${{ env.COMPONENT_NAME }}/${{ env.COMPONENT_NAME }}.zip 92 | -------------------------------------------------------------------------------- /custom_components/iec/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "entity": { 3 | "binary_sensor": { 4 | "last_iec_invoice_paid": { 5 | "name": "Is last invoice {multi_contract} paid" 6 | } 7 | }, 8 | "sensor": { 9 | "access_token_expiry_time": { 10 | "name": "Access Token Expiry Time" 11 | }, 12 | "access_token_issued_at": { 13 | "name": "Access Token Issued At" 14 | }, 15 | "elec_forecasted_cost": { 16 | "name": "Next electric bill forecasted cost {multi_contract}", 17 | "state_attributes": { 18 | "total_bill_days": { "name": "Total Bill Days" }, 19 | "consumption_price": { "name": "Consumption Price" }, 20 | "delivery_price": { "name": "Delivery Price" }, 21 | "distribution_price": { "name": "Distribution Price" }, 22 | "total_kva_price": { "name": "Total KVA Price" } 23 | } 24 | }, 25 | "elec_today_consumption": { 26 | "name": "Today electric consumption {multi_contract}" 27 | }, 28 | "elec_yesterday_consumption": { 29 | "name": "Yesterday electric consumption {multi_contract}" 30 | }, 31 | "elec_this_month_consumption": { 32 | "name": "This month electric consumption {multi_contract}" 33 | }, 34 | "elec_latest_meter_reading": { 35 | "name": "Latest meter reading {multi_contract}" 36 | }, 37 | "iec_last_elec_usage": { 38 | "name": "Last bill electric usage to date {multi_contract}" 39 | }, 40 | "iec_last_cost": { 41 | "name": "Last bill electric cost {multi_contract}" 42 | }, 43 | "iec_last_bill_remain_to_pay": { 44 | "name": "Last bill amount to pay {multi_contract}" 45 | }, 46 | "iec_last_number_of_days": { 47 | "name": "Last bill length in days {multi_contract}" 48 | }, 49 | "iec_bill_date": { 50 | "name": "Last bill date {multi_contract}" 51 | }, 52 | "iec_bill_last_payment_date": { 53 | "name": "Last bill payment date {multi_contract}" 54 | }, 55 | "iec_last_meter_reading": { 56 | "name": "Last bill meter reading {multi_contract}" 57 | }, 58 | "iec_kwh_tariff": { 59 | "name": "kWh tariff" 60 | } 61 | } 62 | }, 63 | "services": { 64 | "debug_get_coordinator_data": { 65 | "name": "Get IEC Coordinator Data", 66 | "description": "Fetch and return the coordinator data for debugging purposes." 67 | } 68 | }, 69 | "config": { 70 | "step": { 71 | "user": { 72 | "title": "Account ID", 73 | "description": "Enter your User ID (תעודת זהות)", 74 | "data": { 75 | "user_id": "User ID" 76 | } 77 | }, 78 | "mfa": { 79 | "title": "OTP", 80 | "description": "Enter your One Time Password (OTP) sent to your {otp_type}", 81 | "data": { 82 | "totp_secret": "e.g. 123456" 83 | } 84 | }, 85 | "select_contracts": { 86 | "title": "Select Contract", 87 | "description": "Select which contract to use" 88 | }, 89 | "reauth_confirm": { 90 | "title": "[%key:common::config_flow::title::reauth%]", 91 | "description": "Enter your One Time Password (OTP) send to your {otp_type}", 92 | "data": { 93 | "user_id": "User ID", 94 | "totp_secret": "e.g. 123456" 95 | } 96 | } 97 | }, 98 | "error": { 99 | "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", 100 | "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", 101 | "invalid_id": "Invalid Israeli ID", 102 | "no_contracts": "You should select at least one contract", 103 | "no_active_contracts": "No active contracts found" 104 | }, 105 | "abort": { 106 | "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", 107 | "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /custom_components/iec/translations/he.json: -------------------------------------------------------------------------------- 1 | { 2 | "entity": { 3 | "binary_sensor": { 4 | "last_iec_invoice_paid": { 5 | "name": "האם שולמה חשבונית אחרונה {multi_contract}" 6 | } 7 | }, 8 | "sensor": { 9 | "access_token_expiry_time": { 10 | "name": "תאריך פג התוקף של טוקן הגישה" 11 | }, 12 | "access_token_issued_at": { 13 | "name": "תאריך יצירת טוקן הגישה" 14 | }, 15 | "elec_forecasted_usage": { 16 | "name": "סך צריכת החשמל בחשבונית הבאה {multi_contract}" 17 | }, 18 | "elec_forecasted_cost": { 19 | "name": "סך עלות צריכת החשמל בחשבונית הבאה {multi_contract}", 20 | "state_attributes": { 21 | "total_bill_days": { "name": "מס׳ ימים לחיוב" }, 22 | "consumption_price": { "name": "עלות צריכה" }, 23 | "delivery_price": { "name": "עלות אספקה" }, 24 | "distribution_price": { "name": "עלות חלוקה" }, 25 | "total_kva_price": { "name": "עלות קיבולת לKVA" } 26 | } 27 | }, 28 | "elec_today_consumption": { 29 | "name": "סך צריכת החשמל היום {multi_contract}" 30 | }, 31 | "elec_yesterday_consumption": { 32 | "name": "סך צריכת החשמל אתמול {multi_contract}" 33 | }, 34 | "elec_this_month_consumption": { 35 | "name": "סך צריכת החשמל החודש {multi_contract}" 36 | }, 37 | "elec_latest_meter_reading": { 38 | "name": "קריאת מונה אחרונה {multi_contract}" 39 | }, 40 | "iec_last_elec_usage": { 41 | "name": "סך צריכת החשמל בחשבונית האחרונה {multi_contract}" 42 | }, 43 | "iec_last_cost": { 44 | "name": "סכום החיוב מהחשבונית האחרונה {multi_contract}" 45 | }, 46 | "iec_last_bill_remain_to_pay": { 47 | "name": "סכום החיוב הנותר לתשלום מהחשבונית האחרונה {multi_contract}" 48 | }, 49 | "iec_last_number_of_days": { 50 | "name": "משך הזמן בחשבונית האחרונה {multi_contract}" 51 | }, 52 | "iec_bill_date": { 53 | "name": "תאריך חיוב לחשבונית האחרונה {multi_contract}" 54 | }, 55 | "iec_bill_last_payment_date": { 56 | "name": "תאריך אחרון לתשלום החשבונית האחרונה {multi_contract}" 57 | }, 58 | "iec_last_meter_reading": { 59 | "name": "קריאת מונה מהחשבונית האחרונה {multi_contract}" 60 | }, 61 | "iec_kwh_tariff": { 62 | "name": "תעריף החשמל הביתי לקוט\"ש" 63 | } 64 | } 65 | }, 66 | "services": { 67 | "debug_get_coordinator_data": { 68 | "name": "הדפס מידע במערכת מחברת החשמל", 69 | "description": "הדפס מידע שנטען מחב' חשמל לצורך ניפוי שגיאות" 70 | } 71 | }, 72 | "config": { 73 | "step": { 74 | "user": { 75 | "title": "מזהה לקוח חברת החשמל", 76 | "description": "הכניסו את מזהה הלקוח (תעודת זהות)", 77 | "data": { 78 | "user_id": "מזהה הלקוח (תעודת זהות)" 79 | } 80 | }, 81 | "mfa": { 82 | "title": "OTP", 83 | "description": "הכניסו את הקוד החד-פעמי שנשלח אליכם (OTP) שנשלח ל-{otp_type}", 84 | "data": { 85 | "totp_secret": "לדוג' 123456" 86 | } 87 | }, 88 | "select_contracts": { 89 | "title": "בחירת חשבון חוזה", 90 | "description": "בחרו באיזה חשבון חוזה להשתמש" 91 | }, 92 | "reauth_confirm": { 93 | "title": "[%key:common::config_flow::title::reauth%]", 94 | "description": "הכניסו את הקוד החד-פעמי שנשלח אליכם (OTP) שנשלח ל-{otp_type}", 95 | "data": { 96 | "user_id": "מזהה לקוח", 97 | "totp_secret": "לדוג' 123456" 98 | } 99 | } 100 | }, 101 | "error": { 102 | "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", 103 | "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", 104 | "invalid_id": "תעודת זהות לא תקנית", 105 | "no_contracts": "נא לבחור לפחות חוזה אחד", 106 | "no_active_contracts": "לא נמצאו חוזים פעילים" 107 | }, 108 | "abort": { 109 | "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", 110 | "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /custom_components/iec/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Support for IEC Binary sensors.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from collections.abc import Callable 7 | from dataclasses import dataclass 8 | 9 | from homeassistant.components.binary_sensor import ( 10 | BinarySensorEntityDescription, 11 | BinarySensorEntity, 12 | ) 13 | from homeassistant.config_entries import ConfigEntry 14 | from homeassistant.core import HomeAssistant 15 | from homeassistant.helpers.device_registry import DeviceInfo 16 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 17 | 18 | from .commons import get_device_info, IecEntityType 19 | from .const import ( 20 | DOMAIN, 21 | STATICS_DICT_NAME, 22 | INVOICE_DICT_NAME, 23 | JWT_DICT_NAME, 24 | EMPTY_INVOICE, 25 | ATTRIBUTES_DICT_NAME, 26 | METER_ID_ATTR_NAME, 27 | ) 28 | from .coordinator import IecApiCoordinator 29 | from .iec_entity import IecEntity 30 | 31 | _LOGGER = logging.getLogger(__name__) 32 | 33 | 34 | @dataclass(frozen=True, kw_only=True) 35 | class IecBinaryEntityDescriptionMixin: 36 | """Mixin values for required keys.""" 37 | 38 | value_fn: Callable[dict, bool | None] 39 | 40 | 41 | @dataclass(frozen=True, kw_only=True) 42 | class IecBinarySensorEntityDescription( 43 | BinarySensorEntityDescription, IecBinaryEntityDescriptionMixin 44 | ): 45 | """Class describing IEC sensors entities.""" 46 | 47 | 48 | BINARY_SENSORS: tuple[IecBinarySensorEntityDescription, ...] = ( 49 | IecBinarySensorEntityDescription( 50 | key="last_iec_invoice_paid", 51 | translation_key="last_iec_invoice_paid", 52 | value_fn=lambda data: (data[INVOICE_DICT_NAME].amount_to_pay == 0) 53 | if (data[INVOICE_DICT_NAME] != EMPTY_INVOICE) 54 | else None, 55 | ), 56 | ) 57 | 58 | 59 | async def async_setup_entry( 60 | hass: HomeAssistant, 61 | entry: ConfigEntry, 62 | async_add_entities: AddEntitiesCallback, 63 | ) -> None: 64 | """Set up a IEC binary sensors based on a config entry.""" 65 | coordinator = hass.data[DOMAIN][entry.entry_id] 66 | 67 | is_multi_contract = ( 68 | len( 69 | list( 70 | filter( 71 | lambda key: key not in (STATICS_DICT_NAME, JWT_DICT_NAME), 72 | list(coordinator.data.keys()), 73 | ) 74 | ) 75 | ) 76 | > 1 77 | ) 78 | 79 | entities: list[BinarySensorEntity] = [] 80 | for contract_key in coordinator.data: 81 | if contract_key in (STATICS_DICT_NAME, JWT_DICT_NAME): 82 | continue 83 | 84 | for description in BINARY_SENSORS: 85 | entities.append( 86 | IecBinarySensorEntity( 87 | coordinator=coordinator, 88 | entity_description=description, 89 | contract_id=contract_key, 90 | is_multi_contract=is_multi_contract, 91 | attributes_to_add=coordinator.data[contract_key][ 92 | ATTRIBUTES_DICT_NAME 93 | ], 94 | ) 95 | ) 96 | 97 | async_add_entities(entities) 98 | 99 | 100 | class IecBinarySensorEntity(IecEntity, BinarySensorEntity): 101 | """Defines an IEC binary sensor.""" 102 | 103 | entity_description: IecBinarySensorEntityDescription 104 | 105 | def __init__( 106 | self, 107 | coordinator: IecApiCoordinator, 108 | entity_description: IecBinarySensorEntityDescription, 109 | contract_id: str, 110 | is_multi_contract: bool, 111 | attributes_to_add: dict | None = None, 112 | ): 113 | """Initialize the sensor.""" 114 | super().__init__( 115 | coordinator, 116 | str(int(contract_id)), 117 | attributes_to_add.get(METER_ID_ATTR_NAME) if attributes_to_add else None, 118 | IecEntityType.CONTRACT, 119 | ) 120 | self.entity_description = entity_description 121 | self._attr_unique_id = f"{str(contract_id)}_{entity_description.key}" 122 | 123 | attributes = {"contract_id": contract_id} 124 | 125 | if attributes_to_add: 126 | attributes.update(attributes_to_add) 127 | 128 | if is_multi_contract: 129 | attributes["is_multi_contract"] = is_multi_contract 130 | self._attr_translation_placeholders = { 131 | "multi_contract": f" of {contract_id}" 132 | } 133 | else: 134 | self._attr_translation_placeholders = {"multi_contract": ""} 135 | 136 | self._attr_extra_state_attributes = attributes 137 | 138 | @property 139 | def is_on(self) -> bool | None: 140 | """Return the state of the sensor.""" 141 | return self.entity_description.value_fn( 142 | self.coordinator.data.get(self.contract_id) 143 | ) 144 | 145 | @property 146 | def device_info(self) -> DeviceInfo: 147 | """Return the device info.""" 148 | return get_device_info(self.contract_id, None, IecEntityType.CONTRACT) 149 | -------------------------------------------------------------------------------- /examples.md: -------------------------------------------------------------------------------- 1 | # Few examples of cards/dashboards using the component 2 | 3 | 4 | ### Is paid invoice (by Noam Shaharabani) 5 | ![card not paid](assets/is_paid_false.png) 6 | ![card paid](assets/is_paid_true.png) 7 | 8 | ``` 9 | type: custom:vertical-stack-in-card 10 | cards: 11 | - type: custom:button-card 12 | entity: binary_sensor.is_last_iec_invoice_paid 13 | show_state: false 14 | show_icon: false 15 | state: 16 | - value: 'off' 17 | name: החשבונית האחרונה עדיין לא שולמה 18 | styles: 19 | card: 20 | - color: white 21 | - background: '#FF8080' 22 | - value: 'on' 23 | name: החשבונית האחרונה שולמה 24 | styles: 25 | card: 26 | - color: black 27 | - background: '#CDFAD5' 28 | styles: 29 | card: 30 | - border: 0px 31 | - height: 0px 32 | - margin-top: null 33 | - type: horizontal-stack 34 | cards: 35 | - type: horizontal-stack 36 | cards: 37 | - type: custom:button-card 38 | entity: sensor.last_iec_bill_electric_cost 39 | show_state: true 40 | show_name: true 41 | name: לתשלום 42 | show_icon: true 43 | icon: mdi:currency-ils 44 | styles: 45 | card: 46 | - border: 0px 47 | - padding-top: 15px 48 | name: 49 | - font-size: 14px 50 | state: 51 | - font-size: 15px 52 | - type: custom:button-card 53 | entity: sensor.last_iec_bill_length_in_days 54 | show_state: false 55 | show_name: true 56 | name: לתקופה של 57 | show_icon: true 58 | icon: mdi:sun-angle 59 | show_label: true 60 | label: | 61 | [[[ 62 | return entity.state + ' ימים'; 63 | ]]] 64 | styles: 65 | card: 66 | - border: 0px 67 | - padding-top: 15px 68 | name: 69 | - font-size: 14px 70 | label: 71 | - font-size: 15px 72 | - type: custom:button-card 73 | entity: sensor.last_iec_bill_payment_date 74 | show_state: true 75 | show_name: true 76 | name: לתשלום עד 77 | show_icon: true 78 | icon: mdi:calendar 79 | styles: 80 | card: 81 | - border: 0px 82 | - padding-bottom: 15px 83 | - padding-top: 15px 84 | name: 85 | - font-size: 14px 86 | state: 87 | - font-size: 15px 88 | - type: custom:button-card 89 | entity: sensor.last_iec_bill_electric_usage_to_date 90 | show_state: true 91 | show_name: true 92 | name: קילוואט 93 | show_icon: true 94 | icon: mdi:lightning-bolt 95 | styles: 96 | card: 97 | - border: 0px 98 | - padding-bottom: 15px 99 | - padding-top: 15px 100 | name: 101 | - font-size: 14px 102 | state: 103 | - font-size: 15px 104 | 105 | ``` 106 | 107 | 108 | 109 | ### Energy Summary (by Moshiko Peer) 110 | ![Energy Summary](assets/summary.png) 111 | (requires to create a helper `input.is_power` entity) 112 | ``` 113 | type: vertical-stack 114 | cards: 115 | - chart_type: bar 116 | period: day 117 | type: statistics-graph 118 | entities: 119 | - sensor.last_iec_bill_length_in_days 120 | days_to_show: 2 121 | stat_types: 122 | - min 123 | - mean 124 | - max 125 | title: מונה חברת חשמל 126 | hide_legend: true 127 | - show_name: true 128 | show_icon: true 129 | type: button 130 | tap_action: 131 | action: toggle 132 | entity: input_boolean.power 133 | name: צריכת חשמל כללית 134 | icon_height: 60px 135 | - type: conditional 136 | conditions: 137 | - entity: input_boolean.power 138 | state: 'on' 139 | card: 140 | square: false 141 | type: grid 142 | cards: 143 | - square: false 144 | type: grid 145 | cards: 146 | - type: entity 147 | entity: sensor.iec_latest_meter_reading 148 | icon: mdi:transmission-tower 149 | name: צריכה 150 | state_color: true 151 | - type: entity 152 | entity: binary_sensor.iec_contract_347910537_meter_23511627_is_last_iec_invoice_paid 153 | name: תשלום חשבונית 154 | icon: mdi:cash-check 155 | - type: entity 156 | entity: sensor.last_iec_bill_amount_to_pay 157 | name: חשבון קודם 158 | icon: mdi:cash 159 | - type: entity 160 | entity: sensor.next_bill_electric_forecasted_cost 161 | name: סכום לתשלום 162 | icon: mdi:cash-multiple 163 | columns: 2 164 | - type: grid 165 | cards: 166 | - type: entity 167 | entity: sensor.iec_this_month_electric_consumption 168 | name: צריכה חודשית 169 | icon: mdi:timer-sand 170 | state_color: true 171 | - type: entity 172 | entity: sensor.iec_kwh_tariff 173 | name: תעריף 174 | icon: mdi:cash-register 175 | columns: 2 176 | square: false 177 | - type: grid 178 | cards: 179 | - type: entity 180 | entity: sensor.iec_yesterday_electric_consumption 181 | name: צריכה של אתמול 182 | icon: mdi:timer 183 | - type: entity 184 | entity: sensor.last_iec_bill_meter_reading 185 | name: ק.מונה אחרונה 186 | icon: mdi:book-edit 187 | columns: 2 188 | square: false 189 | columns: 1 190 | ``` 191 | 192 | ### Summary (by Noy (Petel)) 193 | ![Energy Summary](assets/noy-summary.png) 194 | 195 | (requires using *Sections* dashboard) 196 | ``` 197 | sections: 198 | - type: grid 199 | cards: 200 | - type: tile 201 | entity: sensor.iec_kwh_tariff 202 | color: green 203 | vertical: true 204 | name: null 205 | - type: tile 206 | entity: sensor.last_iec_bill_date 207 | color: green 208 | vertical: true 209 | name: תאריך חשבונית אחרונה 210 | - type: gauge 211 | entity: >- 212 | sensor.iec_contract_123_meter_456_next_bill_electric_forecasted_cost 213 | min: 0 214 | needle: true 215 | severity: 216 | green: 0 217 | yellow: 1100 218 | red: 1500 219 | max: 2200 220 | - type: gauge 221 | entity: sensor.last_iec_bill_electric_cost 222 | min: 0 223 | needle: true 224 | severity: 225 | green: 0 226 | yellow: 1100 227 | red: 1500 228 | max: 2200 229 | - type: entities 230 | entities: 231 | - entity: sensor.last_iec_bill_length_in_days 232 | - entity: >- 233 | sensor.iec_contract_123_meter_456_iec_today_electric_consumption 234 | - entity: >- 235 | sensor.iec_contract_123_meter_456_iec_yesterday_electric_consumption 236 | - entity: >- 237 | sensor.iec_contract_123_meter_456_iec_this_month_electric_consumption 238 | - entity: sensor.last_iec_bill_electric_usage_to_date 239 | - entity: >- 240 | sensor.iec_contract_123_meter_456_iec_latest_meter_reading 241 | - entity: >- 242 | sensor.iec_contract_123_meter_456_iec_latest_meter_reading 243 | - entity: >- 244 | sensor.iec_contract_123_meter_456_next_bill_electric_forecasted_usage 245 | - entity: sensor.last_iec_bill_payment_date 246 | title: 'נתונים מחברת חשמל' 247 | ``` 248 | -------------------------------------------------------------------------------- /custom_components/iec/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for IEC integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import asyncio 7 | from collections.abc import Mapping 8 | from typing import Any 9 | 10 | import voluptuous as vol 11 | from homeassistant import config_entries 12 | from homeassistant.const import CONF_API_TOKEN 13 | from homeassistant.core import HomeAssistant, callback 14 | from homeassistant.data_entry_flow import FlowResult 15 | from homeassistant.helpers.aiohttp_client import async_create_clientsession 16 | from homeassistant.helpers.config_validation import multi_select 17 | from iec_api.iec_client import IecClient 18 | from iec_api.models.exceptions import IECError 19 | from iec_api.models.jwt import JWT 20 | 21 | from .const import ( 22 | CONF_AVAILABLE_CONTRACTS, 23 | CONF_BP_NUMBER, 24 | CONF_SELECTED_CONTRACTS, 25 | CONF_TOTP_SECRET, 26 | CONF_USER_ID, 27 | DOMAIN, 28 | ) 29 | 30 | _LOGGER = logging.getLogger(__name__) 31 | 32 | STEP_USER_DATA_SCHEMA = vol.Schema( 33 | { 34 | vol.Required(CONF_USER_ID): str, 35 | } 36 | ) 37 | 38 | 39 | async def _validate_login( 40 | hass: HomeAssistant, login_data: dict[str, Any], api: IecClient 41 | ) -> dict[str, str]: 42 | """Validate login data and return any errors.""" 43 | if not login_data or not api: 44 | return {"base": "cannot_connect"} 45 | if not login_data.get(CONF_USER_ID): 46 | return {"base": "invalid_auth"} 47 | if not (login_data.get(CONF_TOTP_SECRET) or login_data.get(CONF_API_TOKEN)): 48 | return {"base": "invalid_auth"} 49 | 50 | if login_data.get(CONF_TOTP_SECRET): 51 | try: 52 | await api.verify_otp(login_data.get(CONF_TOTP_SECRET)) 53 | except asyncio.CancelledError: 54 | return {"base": "cannot_connect"} 55 | except IECError: 56 | return {"base": "invalid_auth"} 57 | 58 | elif login_data.get(CONF_API_TOKEN): 59 | try: 60 | await api.load_jwt_token(JWT.from_dict(login_data.get(CONF_API_TOKEN))) 61 | except asyncio.CancelledError: 62 | return {"base": "cannot_connect"} 63 | except IECError: 64 | return {"base": "invalid_auth"} 65 | 66 | errors: dict[str, str] = {} 67 | try: 68 | await api.check_token() 69 | except asyncio.CancelledError: 70 | errors["base"] = "cannot_connect" 71 | except IECError: 72 | errors["base"] = "invalid_auth" 73 | 74 | return errors 75 | 76 | 77 | class IecConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 78 | """Handle a config flow for IEC.""" 79 | 80 | VERSION = 1 81 | 82 | def __init__(self) -> None: 83 | """Initialize a new IECConfigFlow.""" 84 | self.reauth_entry: config_entries.ConfigEntry | None = None 85 | self.data: dict[str, Any] | None = None 86 | self.client: IecClient | None = None 87 | 88 | async def async_step_user( 89 | self, user_input: dict[str, Any] | None = None 90 | ) -> FlowResult: 91 | """Handle the initial step.""" 92 | errors: dict[str, str] = {} 93 | if user_input is not None: 94 | # self._async_abort_entries_match( 95 | # { 96 | # CONF_USER_ID: user_input[CONF_USER_ID], 97 | # CONF_API_TOKEN: user_input[CONF_API_TOKEN] 98 | # } 99 | # ) 100 | 101 | _LOGGER.debug(f"User input in step_user: {user_input}") 102 | self.data = user_input 103 | try: 104 | self.client = IecClient( 105 | self.data[CONF_USER_ID], async_create_clientsession(self.hass) 106 | ) 107 | except ValueError as err: 108 | errors["base"] = "invalid_id" 109 | _LOGGER.error(f"Error while creating IEC client: {err}") 110 | 111 | if not errors: 112 | return await self.async_step_mfa() 113 | 114 | return self.async_show_form( 115 | step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors 116 | ) 117 | 118 | async def async_step_mfa( 119 | self, user_input: dict[str, Any] | None = None 120 | ) -> FlowResult: 121 | """Handle MFA step.""" 122 | if not self.data or not self.data.get(CONF_USER_ID): 123 | return self.async_show_form( 124 | step_id="user", 125 | data_schema=STEP_USER_DATA_SCHEMA, 126 | errors={"base": "invalid_auth"}, 127 | ) 128 | 129 | client: IecClient = self.client 130 | 131 | errors: dict[str, str] = {} 132 | if user_input is not None and user_input.get(CONF_TOTP_SECRET) is not None: 133 | try: 134 | data = {**self.data, **user_input} 135 | errors = await _validate_login(self.hass, data, client) 136 | if not errors: 137 | data[CONF_API_TOKEN] = client.get_token().to_dict() 138 | 139 | if data.get(CONF_TOTP_SECRET): 140 | data.pop(CONF_TOTP_SECRET) 141 | 142 | try: 143 | customer = await client.get_customer() 144 | data[CONF_BP_NUMBER] = customer.bp_number 145 | 146 | contracts = await client.get_contracts(customer.bp_number) 147 | contract_ids = [ 148 | int(contract.contract_id) 149 | for contract in contracts 150 | if contract.status == 1 151 | ] 152 | except asyncio.CancelledError: 153 | errors["base"] = "cannot_connect" 154 | except IECError: 155 | errors["base"] = "cannot_connect" 156 | except Exception as err: # noqa: BLE001 157 | _LOGGER.exception( 158 | "Unexpected error during contracts fetch: %s", err 159 | ) 160 | errors["base"] = "cannot_connect" 161 | 162 | if not errors: 163 | if len(contract_ids) == 0: 164 | errors["base"] = "no_active_contracts" 165 | elif len(contract_ids) == 1: 166 | data[CONF_SELECTED_CONTRACTS] = [contract_ids[0]] 167 | return self._async_create_iec_entry(data) 168 | else: 169 | data[CONF_AVAILABLE_CONTRACTS] = contract_ids 170 | self.data = data 171 | return await self.async_step_select_contracts() 172 | except asyncio.CancelledError: 173 | errors["base"] = "cannot_connect" 174 | except IECError: 175 | errors["base"] = "cannot_connect" 176 | except Exception as err: # noqa: BLE001 177 | _LOGGER.exception("Unexpected error during MFA step: %s", err) 178 | errors["base"] = "cannot_connect" 179 | 180 | if errors: 181 | schema = {vol.Required(CONF_USER_ID, default=self.data[CONF_USER_ID]): str} 182 | else: 183 | schema = {} 184 | 185 | schema[vol.Required(CONF_TOTP_SECRET)] = str 186 | try: 187 | otp_type = await client.login_with_id() 188 | except asyncio.CancelledError: 189 | errors["base"] = errors.get("base") or "cannot_connect" 190 | otp_type = "OTP" 191 | except IECError: 192 | errors["base"] = errors.get("base") or "cannot_connect" 193 | otp_type = "OTP" 194 | except Exception as err: # noqa: BLE001 195 | _LOGGER.exception("Unexpected error during login_with_id: %s", err) 196 | errors["base"] = errors.get("base") or "cannot_connect" 197 | otp_type = "OTP" 198 | 199 | return self.async_show_form( 200 | step_id="mfa", 201 | data_schema=vol.Schema(schema), 202 | description_placeholders={"otp_type": otp_type}, 203 | errors=errors, 204 | ) 205 | 206 | @callback 207 | def _async_create_iec_entry(self, data: dict[str, Any]) -> FlowResult: 208 | """Create the config entry.""" 209 | return self.async_create_entry( 210 | title=f"IEC Account ({data[CONF_USER_ID]})", 211 | data=data, 212 | ) 213 | 214 | async def async_step_select_contracts( 215 | self, user_input: dict[str, Any] | None = None 216 | ) -> FlowResult: 217 | """Handle Select Contract step.""" 218 | assert self.data is not None 219 | assert self.data.get(CONF_USER_ID) is not None 220 | assert self.data.get(CONF_API_TOKEN) is not None 221 | assert self.data.get(CONF_BP_NUMBER) is not None 222 | 223 | errors: dict[str, str] = {} 224 | if ( 225 | user_input is not None 226 | and user_input.get(CONF_SELECTED_CONTRACTS) is not None 227 | ): 228 | if len(user_input.get(CONF_SELECTED_CONTRACTS)) == 0: 229 | errors["base"] = "no_contracts" 230 | else: 231 | data = {**self.data, **user_input} 232 | if data.get(CONF_AVAILABLE_CONTRACTS): 233 | data.pop(CONF_AVAILABLE_CONTRACTS) 234 | 235 | self.data = data 236 | return self._async_create_iec_entry(data) 237 | 238 | schema = { 239 | vol.Required( 240 | CONF_SELECTED_CONTRACTS, default=self.data.get(CONF_AVAILABLE_CONTRACTS) 241 | ): multi_select(self.data.get(CONF_AVAILABLE_CONTRACTS)) 242 | } 243 | 244 | return self.async_show_form( 245 | step_id="select_contracts", 246 | data_schema=vol.Schema(schema), 247 | errors=errors, 248 | ) 249 | 250 | async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: 251 | """Handle configuration by re-auth.""" 252 | self.reauth_entry = self.hass.config_entries.async_get_entry( 253 | self.context["entry_id"] 254 | ) 255 | return await self.async_step_reauth_confirm() 256 | 257 | async def async_step_reauth_confirm( 258 | self, user_input: dict[str, Any] | None = None 259 | ) -> FlowResult: 260 | """Dialog that informs the user that reauth is required.""" 261 | assert self.reauth_entry 262 | errors: dict[str, str] = {} 263 | 264 | client: IecClient = self.client 265 | 266 | if user_input is not None and user_input[CONF_TOTP_SECRET] is not None: 267 | assert client 268 | data = {**self.reauth_entry.data, **user_input} 269 | errors = await _validate_login(self.hass, data, client) 270 | if not errors: 271 | data[CONF_API_TOKEN] = client.get_token().to_dict() 272 | 273 | if data.get(CONF_TOTP_SECRET): 274 | data.pop(CONF_TOTP_SECRET) 275 | 276 | self.hass.config_entries.async_update_entry( 277 | self.reauth_entry, data=data 278 | ) 279 | await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) 280 | return self.async_abort(reason="reauth_successful") 281 | 282 | if not client: 283 | self.client = IecClient( 284 | self.data[CONF_USER_ID], async_create_clientsession(self.hass) 285 | ) 286 | client = self.client 287 | 288 | try: 289 | otp_type = await client.login_with_id() 290 | except asyncio.CancelledError: 291 | errors["base"] = errors.get("base") or "cannot_connect" 292 | otp_type = "OTP" 293 | except IECError: 294 | errors["base"] = errors.get("base") or "cannot_connect" 295 | otp_type = "OTP" 296 | except Exception as err: # noqa: BLE001 297 | _LOGGER.exception("Unexpected error during reauth login_with_id: %s", err) 298 | errors["base"] = errors.get("base") or "cannot_connect" 299 | otp_type = "OTP" 300 | 301 | schema = { 302 | vol.Required(CONF_USER_ID): self.reauth_entry.data[CONF_USER_ID], 303 | vol.Required(CONF_TOTP_SECRET): str, 304 | } 305 | 306 | return self.async_show_form( 307 | step_id="reauth_confirm", 308 | description_placeholders={"otp_type": otp_type}, 309 | data_schema=vol.Schema(schema), 310 | errors=errors, 311 | ) 312 | -------------------------------------------------------------------------------- /custom_components/iec/sensor.py: -------------------------------------------------------------------------------- 1 | """Support for IEC sensors.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from collections.abc import Callable 7 | from dataclasses import dataclass 8 | from datetime import date, datetime, timedelta 9 | 10 | from homeassistant.components.sensor import ( 11 | SensorDeviceClass, 12 | SensorEntity, 13 | SensorEntityDescription, 14 | SensorStateClass, 15 | ) 16 | from homeassistant.config_entries import ConfigEntry 17 | from homeassistant.const import EntityCategory, UnitOfEnergy, UnitOfTime 18 | from homeassistant.core import HomeAssistant 19 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 20 | from homeassistant.helpers.typing import StateType 21 | from iec_api.models.invoice import Invoice 22 | from iec_api.models.remote_reading import RemoteReading 23 | 24 | from .commons import TIMEZONE, IecEntityType, find_reading_by_date 25 | from .const import ( 26 | ACCESS_TOKEN_EXPIRATION_TIME, 27 | ACCESS_TOKEN_ISSUED_AT, 28 | ATTRIBUTES_DICT_NAME, 29 | CONTRACT_DICT_NAME, 30 | DAILY_READINGS_DICT_NAME, 31 | DOMAIN, 32 | EMPTY_INVOICE, 33 | EMPTY_REMOTE_READING, 34 | EST_BILL_CONSUMPTION_PRICE_ATTR_NAME, 35 | EST_BILL_DAYS_ATTR_NAME, 36 | EST_BILL_DELIVERY_PRICE_ATTR_NAME, 37 | EST_BILL_DISTRIBUTION_PRICE_ATTR_NAME, 38 | EST_BILL_KWH_CONSUMPTION_ATTR_NAME, 39 | EST_BILL_TOTAL_KVA_PRICE_ATTR_NAME, 40 | ESTIMATED_BILL_DICT_NAME, 41 | FUTURE_CONSUMPTIONS_DICT_NAME, 42 | ILS, 43 | ILS_PER_KWH, 44 | INVOICE_DICT_NAME, 45 | JWT_DICT_NAME, 46 | METER_ID_ATTR_NAME, 47 | STATIC_KWH_TARIFF, 48 | STATICS_DICT_NAME, 49 | TOTAL_EST_BILL_ATTR_NAME, 50 | ) 51 | from .coordinator import IecApiCoordinator 52 | from .iec_entity import IecEntity 53 | 54 | _LOGGER = logging.getLogger(__name__) 55 | 56 | 57 | @dataclass(frozen=True, kw_only=True) 58 | class IecEntityDescriptionMixin: 59 | """Mixin values for required keys.""" 60 | 61 | value_fn: Callable[[dict | tuple], str | float | date] | None = None 62 | custom_attrs_fn: ( 63 | Callable[[dict | tuple], dict[str, str | int | float | date]] | None 64 | ) = None 65 | 66 | 67 | @dataclass(frozen=True, kw_only=True) 68 | class IecEntityDescription(SensorEntityDescription, IecEntityDescriptionMixin): 69 | """Class describing IEC sensors entities.""" 70 | 71 | 72 | @dataclass(frozen=True, kw_only=True) 73 | class IecMeterEntityDescription(IecEntityDescription): 74 | """Class describing IEC sensors entities related to specific meter.""" 75 | 76 | 77 | @dataclass(frozen=True, kw_only=True) 78 | class IecContractEntityDescription(IecEntityDescription): 79 | """Class describing IEC sensors entities related to specific contract.""" 80 | 81 | 82 | def get_previous_bill_kwh_price(invoice: Invoice) -> float: 83 | """Calculate the previous bill's kilowatt-hour price by dividing the consumption by the original amount. 84 | 85 | :param invoice: An instance of the Invoice class. 86 | :return: The previous bill's kilowatt-hour price as a float. 87 | """ 88 | 89 | if not invoice.consumption or not invoice.amount_origin: 90 | return 0 91 | return invoice.consumption / invoice.amount_origin 92 | 93 | 94 | def _get_iec_type_by_class(description: IecEntityDescription) -> IecEntityType: 95 | """Get IEC type by class.""" 96 | 97 | if isinstance(description, IecContractEntityDescription): 98 | return IecEntityType.CONTRACT 99 | if isinstance(description, IecMeterEntityDescription): 100 | return IecEntityType.METER 101 | return IecEntityType.GENERIC 102 | 103 | 104 | def _get_reading_by_date( 105 | readings: list[RemoteReading] | None, desired_datetime: datetime 106 | ) -> RemoteReading: 107 | if not readings: 108 | return EMPTY_REMOTE_READING 109 | 110 | desired_date = desired_datetime.date() 111 | try: 112 | reading = next( 113 | reading 114 | for reading in readings 115 | if find_reading_by_date(reading, desired_date) 116 | ) 117 | return reading 118 | 119 | except StopIteration: 120 | _LOGGER.info( 121 | f"Couldn't find daily reading for date: {desired_date.strftime('%Y-%m-%d')}" 122 | ) 123 | return EMPTY_REMOTE_READING 124 | 125 | 126 | DIAGNOSTICS_SENSORS: tuple[IecEntityDescription, ...] = ( 127 | IecEntityDescription( 128 | key="access_token_expiry_time", 129 | device_class=SensorDeviceClass.TIMESTAMP, 130 | entity_category=EntityCategory.DIAGNOSTIC, 131 | value_fn=lambda data: datetime.fromtimestamp( 132 | data[ACCESS_TOKEN_EXPIRATION_TIME], tz=TIMEZONE 133 | ), 134 | ), 135 | IecEntityDescription( 136 | key="access_token_issued_at", 137 | device_class=SensorDeviceClass.TIMESTAMP, 138 | entity_category=EntityCategory.DIAGNOSTIC, 139 | value_fn=lambda data: datetime.fromtimestamp( 140 | data[ACCESS_TOKEN_ISSUED_AT], tz=TIMEZONE 141 | ), 142 | ), 143 | ) 144 | SMART_ELEC_SENSORS: tuple[IecEntityDescription, ...] = ( 145 | IecMeterEntityDescription( 146 | key="elec_forecasted_usage", 147 | device_class=SensorDeviceClass.ENERGY, 148 | native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, 149 | # state_class=SensorStateClass.TOTAL, 150 | suggested_display_precision=3, 151 | value_fn=lambda data: ( 152 | data[ESTIMATED_BILL_DICT_NAME][EST_BILL_KWH_CONSUMPTION_ATTR_NAME] 153 | if data[ESTIMATED_BILL_DICT_NAME] 154 | else 0 155 | ) 156 | if ( 157 | data[ESTIMATED_BILL_DICT_NAME] 158 | and data[ESTIMATED_BILL_DICT_NAME][EST_BILL_KWH_CONSUMPTION_ATTR_NAME] 159 | ) 160 | else None, 161 | ), 162 | IecMeterEntityDescription( 163 | key="elec_forecasted_cost", 164 | device_class=SensorDeviceClass.MONETARY, 165 | native_unit_of_measurement=ILS, 166 | # state_class=SensorStateClass.TOTAL, 167 | suggested_display_precision=2, 168 | # The API doesn't provide future *cost* so we can try to estimate it by the previous consumption 169 | value_fn=lambda data: (data[ESTIMATED_BILL_DICT_NAME][TOTAL_EST_BILL_ATTR_NAME]) 170 | if data[ESTIMATED_BILL_DICT_NAME] 171 | else 0, 172 | custom_attrs_fn=lambda data: { 173 | EST_BILL_DAYS_ATTR_NAME: data[ESTIMATED_BILL_DICT_NAME][ 174 | EST_BILL_DAYS_ATTR_NAME 175 | ], 176 | EST_BILL_CONSUMPTION_PRICE_ATTR_NAME: data[ESTIMATED_BILL_DICT_NAME][ 177 | EST_BILL_CONSUMPTION_PRICE_ATTR_NAME 178 | ], 179 | EST_BILL_DELIVERY_PRICE_ATTR_NAME: data[ESTIMATED_BILL_DICT_NAME][ 180 | EST_BILL_DELIVERY_PRICE_ATTR_NAME 181 | ], 182 | EST_BILL_DISTRIBUTION_PRICE_ATTR_NAME: data[ESTIMATED_BILL_DICT_NAME][ 183 | EST_BILL_DISTRIBUTION_PRICE_ATTR_NAME 184 | ], 185 | EST_BILL_TOTAL_KVA_PRICE_ATTR_NAME: data[ESTIMATED_BILL_DICT_NAME][ 186 | EST_BILL_TOTAL_KVA_PRICE_ATTR_NAME 187 | ], 188 | } 189 | if data[ESTIMATED_BILL_DICT_NAME] 190 | else None, 191 | ), 192 | IecMeterEntityDescription( 193 | key="elec_today_consumption", 194 | device_class=SensorDeviceClass.ENERGY, 195 | native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, 196 | # state_class=SensorStateClass.TOTAL, 197 | suggested_display_precision=3, 198 | value_fn=lambda data: _get_reading_by_date( 199 | data[DAILY_READINGS_DICT_NAME][ 200 | data[ATTRIBUTES_DICT_NAME][METER_ID_ATTR_NAME] 201 | ], 202 | TIMEZONE.localize(datetime.now()), 203 | ).value 204 | if ( 205 | data[DAILY_READINGS_DICT_NAME] 206 | and [data[ATTRIBUTES_DICT_NAME][METER_ID_ATTR_NAME]] 207 | ) 208 | else None, 209 | ), 210 | IecMeterEntityDescription( 211 | key="elec_yesterday_consumption", 212 | device_class=SensorDeviceClass.ENERGY, 213 | native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, 214 | # state_class=SensorStateClass.TOTAL, 215 | suggested_display_precision=3, 216 | value_fn=lambda data: ( 217 | _get_reading_by_date( 218 | data[DAILY_READINGS_DICT_NAME][ 219 | data[ATTRIBUTES_DICT_NAME][METER_ID_ATTR_NAME] 220 | ], 221 | TIMEZONE.localize(datetime.now()) - timedelta(days=1), 222 | ).value 223 | ) 224 | if (data[DAILY_READINGS_DICT_NAME]) 225 | else None, 226 | ), 227 | IecMeterEntityDescription( 228 | key="elec_this_month_consumption", 229 | device_class=SensorDeviceClass.ENERGY, 230 | native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, 231 | # state_class=SensorStateClass.TOTAL, 232 | suggested_display_precision=3, 233 | value_fn=lambda data: ( 234 | sum( 235 | [ 236 | reading.value 237 | for reading in data[DAILY_READINGS_DICT_NAME][ 238 | data[ATTRIBUTES_DICT_NAME][METER_ID_ATTR_NAME] 239 | ] 240 | if reading.date.month == TIMEZONE.localize(datetime.now()).month 241 | ] 242 | ) 243 | ) 244 | if (data[DAILY_READINGS_DICT_NAME]) 245 | else None, 246 | ), 247 | IecMeterEntityDescription( 248 | key="elec_latest_meter_reading", 249 | device_class=SensorDeviceClass.ENERGY, 250 | native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, 251 | state_class=SensorStateClass.TOTAL_INCREASING, 252 | suggested_display_precision=3, 253 | value_fn=lambda data: ( 254 | data[FUTURE_CONSUMPTIONS_DICT_NAME][ 255 | data[ATTRIBUTES_DICT_NAME][METER_ID_ATTR_NAME] 256 | ].total_import 257 | or 0 258 | ) 259 | if (data[FUTURE_CONSUMPTIONS_DICT_NAME]) 260 | else None, 261 | ), 262 | ) 263 | 264 | ELEC_SENSORS: tuple[IecEntityDescription, ...] = ( 265 | IecContractEntityDescription( 266 | key="iec_last_elec_usage", 267 | device_class=SensorDeviceClass.ENERGY, 268 | native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, 269 | state_class=SensorStateClass.TOTAL, 270 | suggested_display_precision=0, 271 | value_fn=lambda data: data[INVOICE_DICT_NAME].consumption 272 | if (data[INVOICE_DICT_NAME] != EMPTY_INVOICE) 273 | else None, 274 | ), 275 | IecContractEntityDescription( 276 | key="iec_last_cost", 277 | device_class=SensorDeviceClass.MONETARY, 278 | native_unit_of_measurement=ILS, 279 | state_class=SensorStateClass.TOTAL, 280 | suggested_display_precision=2, 281 | value_fn=lambda data: data[INVOICE_DICT_NAME].amount_origin 282 | if (data[INVOICE_DICT_NAME] != EMPTY_INVOICE) 283 | else None, 284 | ), 285 | IecContractEntityDescription( 286 | key="iec_last_bill_remain_to_pay", 287 | device_class=SensorDeviceClass.MONETARY, 288 | native_unit_of_measurement=ILS, 289 | suggested_display_precision=2, 290 | value_fn=lambda data: data[INVOICE_DICT_NAME].amount_to_pay 291 | if (data[INVOICE_DICT_NAME] != EMPTY_INVOICE) 292 | else None, 293 | ), 294 | IecContractEntityDescription( 295 | key="iec_last_number_of_days", 296 | device_class=SensorDeviceClass.DURATION, 297 | native_unit_of_measurement=UnitOfTime.DAYS, 298 | state_class=SensorStateClass.MEASUREMENT, 299 | suggested_display_precision=0, 300 | value_fn=lambda data: data[INVOICE_DICT_NAME].days_period 301 | if (data[INVOICE_DICT_NAME] != EMPTY_INVOICE) 302 | else None, 303 | ), 304 | IecContractEntityDescription( 305 | key="iec_bill_date", 306 | device_class=SensorDeviceClass.DATE, 307 | value_fn=lambda data: data[INVOICE_DICT_NAME].to_date.date() 308 | if (data[INVOICE_DICT_NAME] != EMPTY_INVOICE) 309 | else None, 310 | ), 311 | IecContractEntityDescription( 312 | key="iec_bill_last_payment_date", 313 | device_class=SensorDeviceClass.DATE, 314 | value_fn=lambda data: data[INVOICE_DICT_NAME].last_date 315 | if (data[INVOICE_DICT_NAME] != EMPTY_INVOICE) 316 | else None, 317 | ), 318 | IecContractEntityDescription( 319 | key="iec_last_meter_reading", 320 | native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, 321 | device_class=SensorDeviceClass.ENERGY, 322 | state_class=SensorStateClass.TOTAL_INCREASING, 323 | suggested_display_precision=0, 324 | value_fn=lambda data: data[INVOICE_DICT_NAME].meter_readings[0].reading 325 | if ( 326 | data[INVOICE_DICT_NAME] != EMPTY_INVOICE 327 | and data[INVOICE_DICT_NAME].meter_readings 328 | ) 329 | else None, 330 | ), 331 | ) 332 | 333 | STATIC_SENSORS: tuple[IecEntityDescription, ...] = ( 334 | IecEntityDescription( 335 | key="iec_kwh_tariff", 336 | device_class=SensorDeviceClass.MONETARY, 337 | native_unit_of_measurement=ILS_PER_KWH, 338 | suggested_display_precision=4, 339 | value_fn=lambda data: data[STATIC_KWH_TARIFF], 340 | ), 341 | ) 342 | 343 | 344 | async def async_setup_entry( 345 | hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback 346 | ) -> None: 347 | """Set up the IEC sensor.""" 348 | 349 | coordinator: IecApiCoordinator = hass.data[DOMAIN][entry.entry_id] 350 | entities: list[SensorEntity] = [] 351 | 352 | is_multi_contract = ( 353 | len( 354 | list( 355 | filter( 356 | lambda key: key != STATICS_DICT_NAME, list(coordinator.data.keys()) 357 | ) 358 | ) 359 | ) 360 | > 1 361 | ) 362 | 363 | for contract_key in coordinator.data: 364 | if contract_key == STATICS_DICT_NAME: 365 | for sensor_desc in STATIC_SENSORS: 366 | entities.append( 367 | IecSensor( 368 | coordinator, 369 | sensor_desc, 370 | STATICS_DICT_NAME, 371 | is_multi_contract=False, 372 | ) 373 | ) 374 | elif contract_key == JWT_DICT_NAME: 375 | for sensor_desc in DIAGNOSTICS_SENSORS: 376 | entities.append( 377 | IecSensor( 378 | coordinator, 379 | sensor_desc, 380 | JWT_DICT_NAME, 381 | is_multi_contract=False, 382 | ) 383 | ) 384 | else: 385 | if coordinator.data[contract_key][CONTRACT_DICT_NAME].smart_meter: 386 | sensors_desc: tuple[IecEntityDescription, ...] = ( 387 | ELEC_SENSORS + SMART_ELEC_SENSORS 388 | ) 389 | else: 390 | sensors_desc: tuple[IecEntityDescription, ...] = ELEC_SENSORS 391 | # sensors_desc: tuple[IecEntityDescription, ...] = ELEC_SENSORS 392 | 393 | contract_id = coordinator.data[contract_key][CONTRACT_DICT_NAME].contract_id 394 | for sensor_desc in sensors_desc: 395 | entities.append( 396 | IecSensor( 397 | coordinator, 398 | sensor_desc, 399 | contract_id, 400 | is_multi_contract, 401 | coordinator.data[contract_key][ATTRIBUTES_DICT_NAME], 402 | ) 403 | ) 404 | 405 | async_add_entities(entities) 406 | 407 | 408 | class IecSensor(IecEntity, SensorEntity): 409 | """Representation of an IEC sensor.""" 410 | 411 | entity_description: IecEntityDescription 412 | 413 | def __init__( 414 | self, 415 | coordinator: IecApiCoordinator, 416 | description: IecEntityDescription, 417 | contract_id: str, 418 | is_multi_contract: bool, 419 | attributes_to_add: dict | None = None, 420 | ) -> None: 421 | """Initialize the sensor.""" 422 | super().__init__( 423 | coordinator, 424 | contract_id, 425 | attributes_to_add.get(METER_ID_ATTR_NAME) if attributes_to_add else None, 426 | _get_iec_type_by_class(description), 427 | ) 428 | self.entity_description = description 429 | self._attr_unique_id = f"{str(contract_id)}_{description.key}" 430 | self._attr_translation_key = f"{description.key}" 431 | self._attr_translation_placeholders = {"multi_contract": f"of {contract_id}"} 432 | 433 | attributes = {"contract_id": contract_id} 434 | 435 | if attributes_to_add: 436 | attributes.update(attributes_to_add) 437 | 438 | if self.entity_description.custom_attrs_fn: 439 | custom_attr = self.entity_description.custom_attrs_fn( 440 | self.coordinator.data.get(str(int(self.contract_id))) 441 | ) 442 | if custom_attr: 443 | attributes.update(custom_attr) 444 | 445 | if is_multi_contract: 446 | attributes["is_multi_contract"] = is_multi_contract 447 | self._attr_translation_placeholders = { 448 | "multi_contract": f" of {contract_id}" 449 | } 450 | else: 451 | self._attr_translation_placeholders = {"multi_contract": ""} 452 | 453 | self._attr_extra_state_attributes = attributes 454 | 455 | @property 456 | def native_value(self) -> StateType: 457 | """Return the state.""" 458 | if self.coordinator.data is not None: 459 | if self.contract_id in (STATICS_DICT_NAME, JWT_DICT_NAME): 460 | return self.entity_description.value_fn( 461 | self.coordinator.data.get(self.contract_id, self.meter_id) 462 | ) 463 | 464 | # Trim leading 0000 if needed and align with coordinator keys 465 | return self.entity_description.value_fn( 466 | self.coordinator.data.get(str(int(self.contract_id))) 467 | ) 468 | return None 469 | -------------------------------------------------------------------------------- /custom_components/iec/coordinator.py: -------------------------------------------------------------------------------- 1 | """Coordinator to handle IEC connections.""" 2 | 3 | import asyncio 4 | import calendar 5 | import itertools 6 | import logging 7 | import socket 8 | import traceback 9 | from collections import Counter 10 | from datetime import date, datetime, timedelta 11 | from typing import Any, cast # noqa: UP035 12 | from uuid import UUID 13 | 14 | import jwt 15 | from homeassistant.components.recorder import get_instance 16 | from homeassistant.components.recorder.models import ( 17 | StatisticData, 18 | StatisticMeanType, 19 | StatisticMetaData, 20 | ) 21 | from homeassistant.components.recorder.statistics import ( 22 | async_add_external_statistics, 23 | get_last_statistics, 24 | statistics_during_period, 25 | ) 26 | from homeassistant.config_entries import ConfigEntry 27 | from homeassistant.const import CONF_API_TOKEN, UnitOfEnergy 28 | from homeassistant.core import HomeAssistant, callback 29 | from homeassistant.exceptions import ConfigEntryAuthFailed 30 | from homeassistant.helpers import aiohttp_client 31 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 32 | from iec_api.iec_client import IecClient 33 | from iec_api.models.contract import Contract 34 | from iec_api.models.device import Device, Devices 35 | from iec_api.models.exceptions import IECError 36 | from iec_api.models.jwt import JWT 37 | from iec_api.models.meter_reading import MeterReading 38 | from iec_api.models.remote_reading import ( 39 | FutureConsumptionInfo, 40 | ReadingResolution, 41 | RemoteReading, 42 | RemoteReadingResponse, 43 | ) 44 | 45 | from .commons import TIMEZONE, find_reading_by_date 46 | from .const import ( 47 | ACCESS_TOKEN_EXPIRATION_TIME, 48 | ACCESS_TOKEN_ISSUED_AT, 49 | ATTRIBUTES_DICT_NAME, 50 | CONF_BP_NUMBER, 51 | CONF_SELECTED_CONTRACTS, 52 | CONF_USER_ID, 53 | CONTRACT_DICT_NAME, 54 | CONTRACT_ID_ATTR_NAME, 55 | DAILY_READINGS_DICT_NAME, 56 | DOMAIN, 57 | ELECTRIC_INVOICE_DOC_ID, 58 | EMPTY_INVOICE, 59 | EST_BILL_CONSUMPTION_PRICE_ATTR_NAME, 60 | EST_BILL_DAYS_ATTR_NAME, 61 | EST_BILL_DELIVERY_PRICE_ATTR_NAME, 62 | EST_BILL_DISTRIBUTION_PRICE_ATTR_NAME, 63 | EST_BILL_KWH_CONSUMPTION_ATTR_NAME, 64 | EST_BILL_TOTAL_KVA_PRICE_ATTR_NAME, 65 | ESTIMATED_BILL_DICT_NAME, 66 | FUTURE_CONSUMPTIONS_DICT_NAME, 67 | ILS, 68 | INVOICE_DICT_NAME, 69 | IS_SMART_METER_ATTR_NAME, 70 | JWT_DICT_NAME, 71 | METER_ID_ATTR_NAME, 72 | STATIC_BP_NUMBER, 73 | STATIC_KVA_TARIFF, 74 | STATIC_KWH_TARIFF, 75 | STATICS_DICT_NAME, 76 | TOTAL_EST_BILL_ATTR_NAME, 77 | ) 78 | 79 | _LOGGER = logging.getLogger(__name__) 80 | 81 | 82 | class IecApiCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): 83 | """Handle fetching IEC data, updating sensors and inserting statistics.""" 84 | 85 | def __init__( 86 | self, 87 | hass: HomeAssistant, 88 | config_entry: ConfigEntry, 89 | ) -> None: 90 | """Initialize the data handler.""" 91 | super().__init__( 92 | hass, 93 | _LOGGER, 94 | name="Iec", 95 | # Data is updated daily on IEC. 96 | # Refresh every 1h to be at most 5h behind. 97 | update_interval=timedelta(hours=1), 98 | ) 99 | _LOGGER.debug("Initializing IEC Coordinator") 100 | self._config_entry = config_entry 101 | self._bp_number = config_entry.data.get(CONF_BP_NUMBER) 102 | self._contract_ids = config_entry.data.get(CONF_SELECTED_CONTRACTS) 103 | self._entry_data = config_entry.data 104 | self._today_readings = {} 105 | self._devices_by_contract_id = {} 106 | self._last_meter_reading = {} 107 | self._devices_by_meter_id = {} 108 | self._delivery_tariff_by_phase = {} 109 | self._distribution_tariff_by_phase = {} 110 | self._power_size_by_connection_size = {} 111 | self._kwh_tariff: float | None = None 112 | self._kva_tariff: float | None = None 113 | self._readings = {} 114 | self._account_id: str | None = None 115 | self._connection_size: str | None = None 116 | self.api = IecClient( 117 | self._entry_data[CONF_USER_ID], 118 | session=aiohttp_client.async_get_clientsession(hass, family=socket.AF_INET), 119 | ) 120 | self._first_load: bool = True 121 | 122 | @callback 123 | def _dummy_listener() -> None: 124 | pass 125 | 126 | # Force the coordinator to periodically update by registering at least one listener. 127 | # Needed when the _async_update_data below returns {} for utilities that don't provide 128 | # forecast, which results to no sensors added, no registered listeners, and thus 129 | # _async_update_data not periodically getting called which is needed for _insert_statistics. 130 | self.async_add_listener(_dummy_listener) 131 | 132 | async def async_unload(self): 133 | """Unload the coordinator, cancel any pending tasks.""" 134 | _LOGGER.info("Coordinator unloaded successfully.") 135 | 136 | async def _get_devices_by_contract_id(self, contract_id) -> list[Device]: 137 | devices = self._devices_by_contract_id.get(contract_id) 138 | if not devices: 139 | try: 140 | devices = await self.api.get_devices(str(contract_id)) 141 | self._devices_by_contract_id[contract_id] = devices 142 | except IECError as e: 143 | _LOGGER.exception( 144 | f"Failed fetching devices by contract {contract_id}", e 145 | ) 146 | return devices 147 | 148 | async def _get_devices_by_device_id(self, meter_id) -> Devices: 149 | devices = self._devices_by_meter_id.get(meter_id) 150 | if not devices: 151 | try: 152 | devices = await self.api.get_device_by_device_id(str(meter_id)) 153 | self._devices_by_meter_id[meter_id] = devices 154 | except IECError as e: 155 | _LOGGER.exception( 156 | f"Failed fetching device details by meter id {meter_id}", e 157 | ) 158 | return devices 159 | 160 | async def _get_last_meter_reading( 161 | self, bp_number, contract_id, meter_id 162 | ) -> MeterReading: 163 | key = (contract_id, int(meter_id)) 164 | last_meter_reading = self._last_meter_reading.get(key) 165 | if not last_meter_reading: 166 | try: 167 | meter_readings = await self.api.get_last_meter_reading( 168 | bp_number, contract_id 169 | ) 170 | 171 | for reading in meter_readings.last_meters: 172 | reading_meter_id = int(reading.serial_number) 173 | if len(reading.meter_readings) > 0: 174 | readings = reading.meter_readings 175 | readings.sort(key=lambda rdng: rdng.reading_date, reverse=True) 176 | last_meter_reading = readings[0] 177 | _LOGGER.debug( 178 | f"Last Reading for contract {contract_id}, Meter {reading_meter_id}: " 179 | f"{last_meter_reading}" 180 | ) 181 | reading_key = (contract_id, reading_meter_id) 182 | self._last_meter_reading[reading_key] = last_meter_reading 183 | else: 184 | _LOGGER.debug( 185 | f"No Reading found for contract {contract_id}, Meter {reading_meter_id}" 186 | ) 187 | except IECError as e: 188 | _LOGGER.exception( 189 | f"Failed fetching device details by meter id {meter_id}", e 190 | ) 191 | return self._last_meter_reading.get(key) 192 | 193 | async def _get_kwh_tariff(self) -> float: 194 | if not self._kwh_tariff: 195 | try: 196 | self._kwh_tariff = await self.api.get_kwh_tariff() 197 | except asyncio.CancelledError: 198 | _LOGGER.warning( 199 | "Fetching kWh tariff was cancelled; using 0.0 and continuing" 200 | ) 201 | self._kwh_tariff = 0.0 202 | except IECError as e: 203 | _LOGGER.exception("Failed fetching kWh Tariff", e) 204 | except Exception as e: 205 | _LOGGER.exception("Unexpected error fetching kWh Tariff", e) 206 | 207 | # Fallback: try IEC calculators API when main call failed or returned 0.0 208 | if not self._kwh_tariff or self._kwh_tariff == 0.0: 209 | kwh_fallback, _ = await self._fetch_tariffs_from_calculators() 210 | if kwh_fallback and kwh_fallback > 0: 211 | _LOGGER.debug( 212 | "Using fallback kWh tariff from calculators API: %s", 213 | kwh_fallback, 214 | ) 215 | self._kwh_tariff = kwh_fallback 216 | return self._kwh_tariff or 0.0 217 | 218 | async def _get_kva_tariff(self) -> float: 219 | if not self._kva_tariff: 220 | try: 221 | self._kva_tariff = await self.api.get_kva_tariff() 222 | except asyncio.CancelledError: 223 | _LOGGER.warning( 224 | "Fetching kVA tariff was cancelled; using 0.0 and continuing" 225 | ) 226 | self._kva_tariff = 0.0 227 | except IECError as e: 228 | _LOGGER.exception("Failed fetching KVA Tariff from IEC API", e) 229 | except Exception as e: 230 | _LOGGER.exception("Unexpected error fetching KVA Tariff", e) 231 | 232 | # Fallback: try IEC calculators API when main call failed or returned 0.0 233 | if not self._kva_tariff or self._kva_tariff == 0.0: 234 | _, kva_fallback = await self._fetch_tariffs_from_calculators() 235 | if kva_fallback and kva_fallback > 0: 236 | _LOGGER.debug( 237 | "Using fallback kVA tariff from calculators API: %s", 238 | kva_fallback, 239 | ) 240 | self._kva_tariff = kva_fallback 241 | return self._kva_tariff or 0.0 242 | 243 | async def _fetch_tariffs_from_calculators( 244 | self, 245 | ) -> tuple[float | None, float | None]: 246 | """Fetch tariffs from IEC calculators endpoints as a fallback. 247 | 248 | Returns: tuple of (kwh_home_rate, kva_rate), each may be None if not found. 249 | """ 250 | session = aiohttp_client.async_get_clientsession( 251 | self.hass, family=socket.AF_INET 252 | ) 253 | kwh_tariff: float | None = None 254 | kva_tariff: float | None = None 255 | 256 | # Primary fallback: calculators/period (contains both homeRate and kvaRate) 257 | try: 258 | async with session.get( 259 | "https://iecapi.iec.co.il/api/content/he-IL/calculators/period", 260 | timeout=30, 261 | ) as resp: 262 | if resp.status == 200: 263 | data = await resp.json(content_type=None) 264 | rates = data.get("period_Calculator_Rates") or {} 265 | kwh_val = rates.get("homeRate") 266 | kva_val = rates.get("kvaRate") 267 | if isinstance(kwh_val, (int, float)): 268 | kwh_tariff = float(kwh_val) 269 | if isinstance(kva_val, (int, float)): 270 | kva_tariff = float(kva_val) 271 | _LOGGER.debug( 272 | "Fetched fallback tariffs from calculators/period: homeRate=%s, kvaRate=%s", 273 | kwh_tariff, 274 | kva_tariff, 275 | ) 276 | except asyncio.CancelledError: 277 | _LOGGER.debug("Fallback calculators/period fetch was cancelled") 278 | except Exception as err: # noqa: BLE001 279 | _LOGGER.debug( 280 | "Failed fetching fallback tariffs from calculators/period: %s", err 281 | ) 282 | 283 | # Secondary fallback: calculators/gadget (has homeRate only) 284 | if kwh_tariff is None: 285 | try: 286 | async with session.get( 287 | "https://iecapi.iec.co.il/api/content/he-IL/calculators/gadget", 288 | timeout=30, 289 | ) as resp: 290 | if resp.status == 200: 291 | data = await resp.json(content_type=None) 292 | rates = data.get("gadget_Calculator_Rates") or {} 293 | kwh_val = rates.get("homeRate") 294 | if isinstance(kwh_val, (int, float)): 295 | kwh_tariff = float(kwh_val) 296 | _LOGGER.debug( 297 | "Fetched fallback kWh tariff from calculators/gadget: homeRate=%s", 298 | kwh_tariff, 299 | ) 300 | except asyncio.CancelledError: 301 | _LOGGER.debug("Fallback calculators/gadget fetch was cancelled") 302 | except Exception as err: # noqa: BLE001 303 | _LOGGER.debug( 304 | "Failed fetching fallback kWh tariff from calculators/gadget: %s", 305 | err, 306 | ) 307 | 308 | return kwh_tariff, kva_tariff 309 | 310 | async def _get_delivery_tariff(self, phase) -> float: 311 | delivery_tariff = self._delivery_tariff_by_phase.get(phase) 312 | if not delivery_tariff: 313 | try: 314 | delivery_tariff = await self.api.get_delivery_tariff(phase) 315 | self._delivery_tariff_by_phase[phase] = delivery_tariff 316 | except IECError as e: 317 | _LOGGER.exception( 318 | f"Failed fetching Delivery Tariff by phase {phase}", e 319 | ) 320 | return delivery_tariff or 0.0 321 | 322 | async def _get_distribution_tariff(self, phase) -> float: 323 | distribution_tariff = self._distribution_tariff_by_phase.get(phase) 324 | if not distribution_tariff: 325 | try: 326 | distribution_tariff = await self.api.get_distribution_tariff(phase) 327 | self._distribution_tariff_by_phase[phase] = distribution_tariff 328 | except IECError as e: 329 | _LOGGER.exception( 330 | f"Failed fetching Distribution Tariff by phase {phase}", e 331 | ) 332 | return distribution_tariff or 0.0 333 | 334 | async def _get_account_id(self) -> UUID | None: 335 | if not self._account_id: 336 | try: 337 | account = await self.api.get_default_account() 338 | self._account_id = account.id 339 | except IECError as e: 340 | _LOGGER.exception("Failed fetching Account", e) 341 | return self._account_id 342 | 343 | async def _get_connection_size(self, account_id) -> str | None: 344 | if not self._connection_size: 345 | try: 346 | self._connection_size = ( 347 | await self.api.get_masa_connection_size_from_masa(account_id) 348 | ) 349 | except IECError as e: 350 | _LOGGER.exception("Failed fetching Masa Connection Size", e) 351 | return self._connection_size 352 | 353 | async def _get_power_size(self, connection_size) -> float: 354 | power_size = self._power_size_by_connection_size.get(connection_size) 355 | if not power_size: 356 | try: 357 | power_size = await self.api.get_power_size(connection_size) 358 | self._power_size_by_connection_size[connection_size] = power_size 359 | except IECError as e: 360 | _LOGGER.exception( 361 | f"Failed fetching Power Size by Connection Size {connection_size}", 362 | e, 363 | ) 364 | return power_size or 0.0 365 | 366 | async def _get_readings( 367 | self, 368 | contract_id: int, 369 | device_id: str | int, 370 | device_code: str | int, 371 | reading_date: datetime, 372 | resolution: ReadingResolution, 373 | ): 374 | date_key = reading_date.strftime("%Y") 375 | match resolution: 376 | case ReadingResolution.DAILY: 377 | date_key += reading_date.strftime("-%m-%d") 378 | case ReadingResolution.WEEKLY: 379 | date_key += "/" + str(reading_date.isocalendar().week) 380 | case ReadingResolution.MONTHLY: 381 | date_key += reading_date.strftime("-%m") 382 | case _: 383 | _LOGGER.warning("Unexpected resolution value") 384 | date_key += reading_date.strftime("-%m-%d") 385 | 386 | key = (contract_id, int(device_id), date_key) 387 | reading = self._readings.get(key) 388 | if not reading: 389 | try: 390 | reading = await self.api.get_remote_reading( 391 | device_id, 392 | int(device_code), 393 | reading_date, 394 | reading_date, 395 | resolution, 396 | str(contract_id), 397 | ) 398 | self._readings[key] = reading 399 | except IECError as e: 400 | _LOGGER.exception( 401 | f"Failed fetching reading for Contract: {contract_id}," 402 | f"date: {reading_date.strftime('%d-%m-%Y')}, " 403 | f"resolution: {resolution}", 404 | e, 405 | ) 406 | return reading 407 | 408 | async def _verify_daily_readings_exist( 409 | self, 410 | daily_readings: dict[str, list[RemoteReading]], 411 | desired_date: date, 412 | device: Device, 413 | contract_id: int, 414 | prefetched_reading: RemoteReadingResponse | None = None, 415 | ): 416 | if not daily_readings.get(device.device_number): 417 | daily_readings[device.device_number] = [] 418 | 419 | daily_reading = next( 420 | filter( 421 | lambda x: find_reading_by_date(x, desired_date), 422 | daily_readings[device.device_number], 423 | ), 424 | None, 425 | ) 426 | if not daily_reading: 427 | _LOGGER.debug( 428 | f"Daily reading for date: {desired_date.strftime('%Y-%m-%d')} is missing, calculating manually" 429 | ) 430 | readings = prefetched_reading 431 | if not readings: 432 | readings = await self._get_readings( 433 | contract_id, 434 | device.device_number, 435 | device.device_code, 436 | datetime.fromordinal(desired_date.toordinal()), 437 | ReadingResolution.MONTHLY, 438 | ) 439 | else: 440 | _LOGGER.debug( 441 | f"Daily reading for date: {desired_date.strftime('%Y-%m-%d')} - using existing prefetched readings" 442 | ) 443 | 444 | if readings and readings.data: 445 | daily_readings[device.device_number] += readings.data 446 | 447 | # Remove duplicates 448 | daily_readings[device.device_number] = list( 449 | dict.fromkeys(daily_readings[device.device_number]) 450 | ) 451 | 452 | # Sort by Date 453 | daily_readings[device.device_number].sort(key=lambda x: x.date) 454 | 455 | desired_date_reading = next( 456 | filter( 457 | lambda reading: reading.date.date() == desired_date, 458 | readings.data, 459 | ), 460 | None, 461 | ) 462 | if desired_date_reading is None or desired_date_reading.value <= 0: 463 | _LOGGER.debug( 464 | f"Couldn't find daily reading for: {desired_date.strftime('%Y-%m-%d')}" 465 | ) 466 | else: 467 | daily_readings[device.device_number].append( 468 | RemoteReading(0, desired_date, desired_date_reading.value) 469 | ) 470 | else: 471 | _LOGGER.debug( 472 | f"Daily reading for date: {daily_reading.date.strftime('%Y-%m-%d')}" 473 | f" is present: {daily_reading.value}" 474 | ) 475 | 476 | async def _update_data( 477 | self, 478 | ) -> dict[str, dict[str, Any]]: 479 | if not self._bp_number: 480 | try: 481 | customer = await self.api.get_customer() 482 | self._bp_number = customer.bp_number 483 | except asyncio.CancelledError: 484 | _LOGGER.warning( 485 | "Fetching customer was cancelled; using empty BP number and skipping contracts" 486 | ) 487 | self._bp_number = None 488 | except IECError as e: 489 | _LOGGER.exception("Failed fetching customer", e) 490 | self._bp_number = None 491 | 492 | try: 493 | all_contracts: list[Contract] = ( 494 | await self.api.get_contracts(self._bp_number) if self._bp_number else [] 495 | ) 496 | except asyncio.CancelledError: 497 | _LOGGER.warning( 498 | "Fetching contracts was cancelled; continuing with empty contracts" 499 | ) 500 | all_contracts = [] 501 | except IECError as e: 502 | _LOGGER.exception("Failed fetching contracts", e) 503 | all_contracts = [] 504 | if not self._contract_ids: 505 | self._contract_ids = [ 506 | int(contract.contract_id) 507 | for contract in all_contracts 508 | if contract.status == 1 509 | ] 510 | 511 | contracts: dict[int, Contract] = { 512 | int(c.contract_id): c 513 | for c in all_contracts 514 | if c.status == 1 and int(c.contract_id) in self._contract_ids 515 | } 516 | localized_today = TIMEZONE.localize(datetime.now()) 517 | localized_first_of_month = localized_today.replace(day=1) 518 | kwh_tariff = await self._get_kwh_tariff() 519 | kva_tariff = await self._get_kva_tariff() 520 | 521 | access_token = self.api.get_token().access_token 522 | decoded_token = jwt.decode(access_token, options={"verify_signature": False}) 523 | access_token_issued_at = decoded_token["iat"] 524 | access_token_expiration_time = decoded_token["exp"] 525 | 526 | data = { 527 | JWT_DICT_NAME: { 528 | ACCESS_TOKEN_ISSUED_AT: access_token_issued_at, 529 | ACCESS_TOKEN_EXPIRATION_TIME: access_token_expiration_time, 530 | }, 531 | STATICS_DICT_NAME: { 532 | STATIC_KWH_TARIFF: kwh_tariff, 533 | STATIC_KVA_TARIFF: kva_tariff, 534 | STATIC_BP_NUMBER: self._bp_number, 535 | }, 536 | } 537 | 538 | estimated_bill_dict = None 539 | 540 | _LOGGER.debug(f"All Contract Ids: {list(contracts.keys())}") 541 | 542 | for contract_id in self._contract_ids: 543 | # Because IEC API provides historical usage/cost with a delay of a couple of days 544 | # we need to insert data into statistics. 545 | self.hass.async_create_task( 546 | self._insert_statistics( 547 | contract_id, contracts.get(contract_id).smart_meter 548 | ) 549 | ) 550 | 551 | try: 552 | billing_invoices = await self.api.get_billing_invoices( 553 | self._bp_number, contract_id 554 | ) 555 | except asyncio.CancelledError: 556 | _LOGGER.warning( 557 | "Fetching invoices was cancelled; continuing without invoices" 558 | ) 559 | billing_invoices = None 560 | except IECError as e: 561 | _LOGGER.exception("Failed fetching invoices", e) 562 | billing_invoices = None 563 | 564 | if ( 565 | billing_invoices 566 | and billing_invoices.invoices 567 | and len(billing_invoices.invoices) > 0 568 | ): 569 | billing_invoices.invoices = list( 570 | filter( 571 | lambda inv: inv.document_id == ELECTRIC_INVOICE_DOC_ID, 572 | billing_invoices.invoices, 573 | ) 574 | ) 575 | billing_invoices.invoices.sort( 576 | key=lambda inv: inv.full_date, reverse=True 577 | ) 578 | last_invoice = billing_invoices.invoices[0] 579 | else: 580 | last_invoice = EMPTY_INVOICE 581 | 582 | future_consumption: dict[str, FutureConsumptionInfo | None] | None = {} 583 | daily_readings: dict[str, list[RemoteReading] | None] | None = {} 584 | 585 | is_smart_meter = contracts.get(contract_id).smart_meter 586 | is_private_producer = contracts.get(contract_id).from_private_producer 587 | attributes_to_add = { 588 | CONTRACT_ID_ATTR_NAME: str(contract_id), 589 | IS_SMART_METER_ATTR_NAME: is_smart_meter, 590 | METER_ID_ATTR_NAME: None, 591 | } 592 | 593 | if is_smart_meter: 594 | # For some reason, there are differences between sending 2024-03-01 and sending 2024-03-07 (Today) 595 | # So instead of sending the 1st day of the month, just sending today date 596 | 597 | devices = await self._get_devices_by_contract_id(contract_id) 598 | if not devices: 599 | _LOGGER.debug( 600 | f"No devices for contract {contract_id}. Skipping creating devices." 601 | ) 602 | continue 603 | 604 | for device in devices or []: 605 | attributes_to_add[METER_ID_ATTR_NAME] = device.device_number 606 | 607 | reading_type: ReadingResolution | None = None 608 | reading_date: date | None = None 609 | 610 | if localized_today.date() != localized_first_of_month.date(): 611 | reading_type: ReadingResolution | None = ( 612 | ReadingResolution.MONTHLY 613 | ) 614 | reading_date: date | None = localized_first_of_month 615 | elif localized_today.date().isoweekday() != 7: 616 | # If today's the 1st of the month, but not sunday, get weekly from yesterday 617 | yesterday = localized_today - timedelta(days=1) 618 | reading_type: ReadingResolution | None = ( 619 | ReadingResolution.WEEKLY 620 | ) 621 | reading_date: date | None = yesterday 622 | else: 623 | # Today is the 1st and is Monday (since monday.isoweekday==1) 624 | last_month_first_of_the_month = ( 625 | localized_first_of_month - timedelta(days=1) 626 | ).replace(day=1) 627 | 628 | reading_type: ReadingResolution | None = ( 629 | ReadingResolution.MONTHLY 630 | ) 631 | reading_date: date | None = last_month_first_of_the_month 632 | 633 | _LOGGER.debug( 634 | f"Fetching {reading_type.name} readings from {reading_date}" 635 | ) 636 | remote_reading = await self._get_readings( 637 | contract_id, 638 | device.device_number, 639 | device.device_code, 640 | reading_date, 641 | reading_type, 642 | ) 643 | if remote_reading and remote_reading.data: 644 | daily_readings[device.device_number] = remote_reading.data 645 | else: 646 | _LOGGER.warning( 647 | "No %s readings returned for device %s in contract %s on %s", 648 | reading_type.name, 649 | device.device_number, 650 | contract_id, 651 | reading_date, 652 | ) 653 | daily_readings[device.device_number] = [] 654 | 655 | # Verify today's date appears 656 | await self._verify_daily_readings_exist( 657 | daily_readings, 658 | localized_today.date(), 659 | device, 660 | contract_id, 661 | ) 662 | 663 | today_reading_key = str(contract_id) + "-" + device.device_number 664 | today_reading = self._today_readings.get(today_reading_key) 665 | 666 | if not today_reading: 667 | today_reading = await self._get_readings( 668 | contract_id, 669 | device.device_number, 670 | device.device_code, 671 | localized_today, 672 | ReadingResolution.DAILY, 673 | ) 674 | self._today_readings[today_reading_key] = today_reading 675 | 676 | # fallbacks for future consumption since IEC api is broken :/ 677 | if ( 678 | not future_consumption.get(device.device_number) 679 | or not future_consumption[ 680 | device.device_number 681 | ].future_consumption 682 | ): 683 | if ( 684 | self._today_readings.get(today_reading_key) 685 | and self._today_readings.get( 686 | today_reading_key 687 | ).future_consumption_info.future_consumption 688 | ): 689 | future_consumption[device.device_number] = ( 690 | self._today_readings.get( 691 | today_reading_key 692 | ).future_consumption_info 693 | ) 694 | else: 695 | req_date = localized_today - timedelta(days=2) 696 | two_days_ago_reading = await self._get_readings( 697 | contract_id, 698 | device.device_number, 699 | device.device_code, 700 | req_date, 701 | ReadingResolution.DAILY, 702 | ) 703 | 704 | if ( 705 | two_days_ago_reading 706 | and two_days_ago_reading.total_import 707 | ): # use total_import as validation that reading OK: 708 | future_consumption[device.device_number] = ( 709 | two_days_ago_reading.future_consumption_info 710 | ) 711 | else: 712 | _LOGGER.warning( 713 | "Failed fetching FutureConsumption, data in IEC API is corrupted" 714 | ) 715 | 716 | try: 717 | ( 718 | estimated_bill, 719 | fixed_price, 720 | consumption_price, 721 | total_days, 722 | delivery_price, 723 | distribution_price, 724 | total_kva_price, 725 | estimated_kwh_consumption, 726 | ) = await self._estimate_bill( 727 | contract_id, 728 | device.device_number, 729 | is_private_producer, 730 | future_consumption, 731 | kwh_tariff, 732 | kva_tariff, 733 | last_invoice, 734 | ) 735 | except Exception as e: 736 | _LOGGER.warn("Failed to calculate estimated next bill", e) 737 | estimated_bill = 0 738 | consumption_price = 0 739 | total_days = 0 740 | delivery_price = 0 741 | distribution_price = 0 742 | total_kva_price = 0 743 | estimated_kwh_consumption = 0 744 | 745 | estimated_bill_dict = { 746 | TOTAL_EST_BILL_ATTR_NAME: estimated_bill, 747 | EST_BILL_DAYS_ATTR_NAME: total_days, 748 | EST_BILL_CONSUMPTION_PRICE_ATTR_NAME: consumption_price, 749 | EST_BILL_DELIVERY_PRICE_ATTR_NAME: delivery_price, 750 | EST_BILL_DISTRIBUTION_PRICE_ATTR_NAME: distribution_price, 751 | EST_BILL_TOTAL_KVA_PRICE_ATTR_NAME: total_kva_price, 752 | EST_BILL_KWH_CONSUMPTION_ATTR_NAME: estimated_kwh_consumption, 753 | } 754 | 755 | data[str(contract_id)] = { 756 | CONTRACT_DICT_NAME: contracts.get(contract_id), 757 | INVOICE_DICT_NAME: last_invoice, 758 | FUTURE_CONSUMPTIONS_DICT_NAME: future_consumption, 759 | DAILY_READINGS_DICT_NAME: daily_readings, 760 | STATICS_DICT_NAME: {STATIC_KWH_TARIFF: kwh_tariff}, # workaround, 761 | ATTRIBUTES_DICT_NAME: attributes_to_add, 762 | ESTIMATED_BILL_DICT_NAME: estimated_bill_dict, 763 | } 764 | 765 | # Clean up for next cycle 766 | self._today_readings = {} 767 | self._devices_by_contract_id = {} 768 | self._kwh_tariff = None 769 | self._readings = {} 770 | 771 | return data 772 | 773 | async def _async_update_data( 774 | self, 775 | ) -> dict[str, dict[str, Any]]: 776 | """Fetch data from API endpoint.""" 777 | if self._first_load: 778 | _LOGGER.debug("Loading API token from config entry") 779 | await self.api.load_jwt_token( 780 | JWT.from_dict(self._entry_data[CONF_API_TOKEN]) 781 | ) 782 | 783 | self._first_load = False 784 | try: 785 | _LOGGER.debug("Checking if API token needs to be refreshed") 786 | # First thing first, check the token and refresh if needed. 787 | old_token = self.api.get_token() 788 | await self.api.check_token() 789 | new_token = self.api.get_token() 790 | if old_token != new_token: 791 | _LOGGER.debug("Token refreshed") 792 | new_data = {**self._entry_data, CONF_API_TOKEN: new_token.to_dict()} 793 | self.hass.config_entries.async_update_entry( 794 | entry=self._config_entry, data=new_data 795 | ) 796 | except IECError as err: 797 | raise ConfigEntryAuthFailed from err 798 | 799 | try: 800 | return await self._update_data() 801 | except asyncio.CancelledError as err: 802 | _LOGGER.warning( 803 | "Data update was cancelled (network timeout/cancelled); will retry later: %s", 804 | err, 805 | ) 806 | # Return empty data so setup doesn't fail; periodic refresh will try again 807 | return {} 808 | except Exception as err: 809 | _LOGGER.error("Failed updating data. Exception: %s", err) 810 | _LOGGER.error(traceback.format_exc()) 811 | raise UpdateFailed("Failed Updating IEC data", retry_after=60) from err 812 | 813 | async def _insert_statistics(self, contract_id: int, is_smart_meter: bool) -> None: 814 | if not is_smart_meter: 815 | _LOGGER.info( 816 | f"[IEC Statistics] IEC Contract {contract_id} doesn't contain Smart Meters, not adding statistics" 817 | ) 818 | # Support only smart meters at the moment 819 | return 820 | 821 | _LOGGER.debug( 822 | f"[IEC Statistics] Updating statistics for IEC Contract {contract_id}" 823 | ) 824 | devices = await self._get_devices_by_contract_id(contract_id) 825 | kwh_price = await self._get_kwh_tariff() 826 | localized_today = TIMEZONE.localize(datetime.now()) 827 | 828 | if not devices: 829 | _LOGGER.error( 830 | f"[IEC Statistics] Failed fetching devices for IEC Contract {contract_id}" 831 | ) 832 | return 833 | 834 | for device in devices: 835 | id_prefix = f"iec_meter_{device.device_number}" 836 | consumption_statistic_id = f"{DOMAIN}:{id_prefix}_energy_consumption" 837 | cost_statistic_id = f"{DOMAIN}:{id_prefix}_energy_est_cost" 838 | 839 | last_stat = await get_instance(self.hass).async_add_executor_job( 840 | get_last_statistics, self.hass, 1, consumption_statistic_id, True, set() 841 | ) 842 | 843 | if not last_stat: 844 | _LOGGER.debug( 845 | "[IEC Statistics] No statistics found, fetching today's MONTHLY readings to extract field `meterStartDate`" 846 | ) 847 | 848 | month_ago_time = localized_today - timedelta(weeks=4) 849 | readings = await self._get_readings( 850 | contract_id, 851 | device.device_number, 852 | device.device_code, 853 | localized_today, 854 | ReadingResolution.MONTHLY, 855 | ) 856 | 857 | if readings and readings.meter_start_date: 858 | # Fetching the last reading from either the installation date or a month ago 859 | month_ago_time = max( 860 | month_ago_time, 861 | TIMEZONE.localize( 862 | datetime.combine( 863 | readings.meter_start_date, datetime.min.time() 864 | ) 865 | ), 866 | ) 867 | else: 868 | _LOGGER.debug( 869 | "[IEC Statistics] Failed to extract field `meterStartDate`, falling back to a month ago" 870 | ) 871 | 872 | _LOGGER.debug("[IEC Statistics] Updating statistic for the first time") 873 | _LOGGER.debug( 874 | f"[IEC Statistics] Fetching consumption from {month_ago_time.strftime('%Y-%m-%d %H:%M:%S')}" 875 | ) 876 | last_stat_time = 0 877 | readings = await self._get_readings( 878 | contract_id, 879 | device.device_number, 880 | device.device_code, 881 | month_ago_time, 882 | ReadingResolution.DAILY, 883 | ) 884 | 885 | else: 886 | last_stat_time = last_stat[consumption_statistic_id][0]["start"] 887 | # API returns daily data, so need to increase the start date by 4 hrs to get the next day 888 | from_date = datetime.fromtimestamp(last_stat_time) 889 | _LOGGER.debug( 890 | f"[IEC Statistics] Last statistics are from {from_date.strftime('%Y-%m-%d %H:%M:%S')}" 891 | ) 892 | 893 | if from_date.hour == 23: 894 | from_date = from_date + timedelta(hours=2) 895 | 896 | if localized_today.date() == from_date.date(): 897 | _LOGGER.debug( 898 | "[IEC Statistics] The date to fetch is today or later, replacing it with Today at 01:00:00" 899 | ) 900 | from_date = localized_today.replace( 901 | hour=1, minute=0, second=0, microsecond=0 902 | ) 903 | 904 | _LOGGER.debug( 905 | f"[IEC Statistics] Fetching consumption from {from_date.strftime('%Y-%m-%d %H:%M:%S')}" 906 | ) 907 | readings = await self._get_readings( 908 | contract_id, 909 | device.device_number, 910 | device.device_code, 911 | from_date, 912 | ReadingResolution.DAILY, 913 | ) 914 | if from_date.date() == localized_today.date(): 915 | self._today_readings[ 916 | str(contract_id) + "-" + device.device_number 917 | ] = readings 918 | 919 | if not readings or not readings.data: 920 | _LOGGER.debug("[IEC Statistics] No recent usage data. Skipping update") 921 | continue 922 | 923 | last_stat_hour = ( 924 | datetime.fromtimestamp(last_stat_time) 925 | if last_stat_time 926 | else readings.data[0].date 927 | ) 928 | last_stat_req_hour = ( 929 | last_stat_hour 930 | if last_stat_hour.hour > 0 931 | else (last_stat_hour - timedelta(hours=1)) 932 | ) 933 | 934 | _LOGGER.debug( 935 | f"[IEC Statistics] Fetching LongTerm Statistics since {last_stat_req_hour}" 936 | ) 937 | stats = await get_instance(self.hass).async_add_executor_job( 938 | statistics_during_period, 939 | self.hass, 940 | last_stat_req_hour, 941 | None, 942 | {cost_statistic_id, consumption_statistic_id}, 943 | "hour", 944 | None, 945 | {"sum"}, 946 | ) 947 | 948 | if not stats.get(consumption_statistic_id): 949 | _LOGGER.debug("[IEC Statistics] No recent usage data") 950 | consumption_sum = 0 951 | else: 952 | consumption_sum = cast(float, stats[consumption_statistic_id][0]["sum"]) 953 | 954 | if not stats.get(cost_statistic_id): 955 | if not stats.get(consumption_statistic_id): 956 | _LOGGER.debug("[IEC Statistics] No recent cost data") 957 | cost_sum = 0.0 958 | else: 959 | cost_sum = ( 960 | cast(float, stats[consumption_statistic_id][0]["sum"]) 961 | * kwh_price 962 | ) 963 | else: 964 | cost_sum = cast(float, stats[cost_statistic_id][0]["sum"]) 965 | 966 | _LOGGER.debug( 967 | f"[IEC Statistics] Last Consumption Sum for C[{contract_id}] D[{device.device_number}]: {consumption_sum}" 968 | ) 969 | _LOGGER.debug( 970 | f"[IEC Statistics] Last Estimated Cost Sum for C[{contract_id}] D[{device.device_number}]: {cost_sum}" 971 | ) 972 | 973 | new_readings: list[RemoteReading] = filter( 974 | lambda reading: reading.date 975 | >= TIMEZONE.localize(datetime.fromtimestamp(last_stat_time)), 976 | readings.data, 977 | ) 978 | 979 | grouped_new_readings_by_hour = itertools.groupby( 980 | new_readings, 981 | key=lambda reading: reading.date.replace( 982 | minute=0, second=0, microsecond=0 983 | ), 984 | ) 985 | readings_by_hour: dict[datetime, float] = {} 986 | if last_stat_req_hour and last_stat_req_hour.tzinfo is None: 987 | last_stat_req_hour = TIMEZONE.localize(last_stat_req_hour) 988 | 989 | for key, group in grouped_new_readings_by_hour: 990 | group_list = list(group) 991 | if len(group_list) < 4: 992 | _LOGGER.debug( 993 | f"[IEC Statistics] LongTerm Statistics - Skipping {key} since it's partial for the hour" 994 | ) 995 | continue 996 | if key <= last_stat_req_hour: 997 | _LOGGER.debug( 998 | f"[IEC Statistics] LongTerm Statistics - Skipping {key} data since it's already reported" 999 | ) 1000 | continue 1001 | readings_by_hour[key] = sum(reading.value for reading in group_list) 1002 | 1003 | consumption_metadata = StatisticMetaData( 1004 | has_mean=False, 1005 | has_sum=True, 1006 | name=f"IEC Meter {device.device_number} Consumption", 1007 | source=DOMAIN, 1008 | statistic_id=consumption_statistic_id, 1009 | unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, 1010 | mean_type=StatisticMeanType.NONE, 1011 | ) 1012 | 1013 | cost_metadata = StatisticMetaData( 1014 | has_mean=False, 1015 | has_sum=True, 1016 | name=f"IEC Meter {device.device_number} Estimated Cost", 1017 | source=DOMAIN, 1018 | statistic_id=cost_statistic_id, 1019 | unit_of_measurement=ILS, 1020 | mean_type=StatisticMeanType.NONE, 1021 | ) 1022 | 1023 | consumption_statistics = [] 1024 | cost_statistics = [] 1025 | for key, value in sorted(readings_by_hour.items()): 1026 | consumption_sum += value 1027 | cost_sum += value * kwh_price 1028 | 1029 | consumption_statistics.append( 1030 | StatisticData(start=key, sum=consumption_sum, state=value) 1031 | ) 1032 | 1033 | cost_statistics.append( 1034 | StatisticData(start=key, sum=cost_sum, state=value * kwh_price) 1035 | ) 1036 | 1037 | if readings_by_hour: 1038 | _LOGGER.debug( 1039 | f"[IEC Statistics] Last hour fetched for C[{contract_id}] D[{device.device_number}]: " 1040 | f"{max(readings_by_hour, key=lambda k: k)}" 1041 | ) 1042 | _LOGGER.debug( 1043 | f"[IEC Statistics] New Consumption Sum for C[{contract_id}] D[{device.device_number}]: {consumption_sum}" 1044 | ) 1045 | _LOGGER.debug( 1046 | f"[IEC Statistics] New Estimated Cost Sum for C[{contract_id}] D[{device.device_number}]: {cost_sum}" 1047 | ) 1048 | 1049 | async_add_external_statistics( 1050 | self.hass, consumption_metadata, consumption_statistics 1051 | ) 1052 | 1053 | async_add_external_statistics(self.hass, cost_metadata, cost_statistics) 1054 | 1055 | async def _estimate_bill( 1056 | self, 1057 | contract_id, 1058 | device_number, 1059 | is_private_producer, 1060 | future_consumption, 1061 | kwh_tariff, 1062 | kva_tariff, 1063 | last_invoice, 1064 | ): 1065 | last_meter_read: int | None = None 1066 | last_meter_read_date: date | None = None 1067 | phase_count: int | None = None 1068 | connection_size: str | None = None 1069 | devices_by_id: Devices | None = None 1070 | 1071 | if not is_private_producer: 1072 | try: 1073 | devices_by_id: Devices = await self._get_devices_by_device_id( 1074 | device_number 1075 | ) 1076 | 1077 | if ( 1078 | devices_by_id.counter_devices 1079 | and len(devices_by_id.counter_devices) >= 1 1080 | ): 1081 | last_meter_read = int(devices_by_id.counter_devices[0].last_mr) 1082 | last_meter_read_date = devices_by_id.counter_devices[0].last_mr_date 1083 | phase_count = devices_by_id.counter_devices[0].connection_size.phase 1084 | connection_size = devices_by_id.counter_devices[ 1085 | 0 1086 | ].connection_size.representative_connection_size 1087 | else: 1088 | _LOGGER.warning( 1089 | "Failed to get Last Device Meter Reading, trying another way..." 1090 | ) 1091 | 1092 | except Exception as e: 1093 | _LOGGER.warning( 1094 | "Failed to fetch data from devices_by_id, falling back to Masa API", 1095 | e, 1096 | ) 1097 | _LOGGER.debug(f"DevicesById Response: {devices_by_id}") 1098 | last_meter_read = None 1099 | last_meter_read_date = None 1100 | phase_count = None 1101 | connection_size = None 1102 | 1103 | if is_private_producer or not last_meter_read: 1104 | last_meter_reading = await self._get_last_meter_reading( 1105 | self._bp_number, contract_id, device_number 1106 | ) 1107 | 1108 | if not last_meter_reading: 1109 | _LOGGER.warning( 1110 | "Couldn't get Last Meter Read, WILL NOT calculate the usage part in estimated bill." 1111 | ) 1112 | last_meter_read = None 1113 | last_meter_read_date = TIMEZONE.localize(datetime.now()).date() 1114 | last_invoice = EMPTY_INVOICE 1115 | else: 1116 | last_meter_read = last_meter_reading.reading 1117 | last_meter_read_date = last_meter_reading.reading_date.date() 1118 | 1119 | account_id = await self._get_account_id() 1120 | connection_size = await self._get_connection_size(account_id) 1121 | if connection_size: 1122 | phase_count_str = ( 1123 | connection_size.split("X")[0] 1124 | if connection_size.find("X") != -1 1125 | else "1" 1126 | ) 1127 | phase_count = int(phase_count_str) 1128 | 1129 | if connection_size: 1130 | power_size = await self._get_power_size(connection_size) 1131 | else: 1132 | power_size = 0.0 1133 | _LOGGER.warning("Couldn't get Connection Size") 1134 | 1135 | if phase_count: 1136 | distribution_tariff = await self._get_distribution_tariff(phase_count) 1137 | delivery_tariff = await self._get_delivery_tariff(phase_count) 1138 | else: 1139 | distribution_tariff = 0.0 1140 | delivery_tariff = 0.0 1141 | if connection_size: 1142 | _LOGGER.warning("Couldn't get Phase Count") 1143 | 1144 | return self._calculate_estimated_bill( 1145 | device_number, 1146 | future_consumption, 1147 | last_meter_read, 1148 | last_meter_read_date, 1149 | kwh_tariff, 1150 | kva_tariff, 1151 | distribution_tariff, 1152 | delivery_tariff, 1153 | power_size, 1154 | last_invoice, 1155 | ) 1156 | 1157 | @staticmethod 1158 | def _calculate_estimated_bill( 1159 | meter_id, 1160 | future_consumptions: dict[str, FutureConsumptionInfo | None], 1161 | last_meter_read, 1162 | last_meter_read_date, 1163 | kwh_tariff, 1164 | kva_tariff, 1165 | distribution_tariff, 1166 | delivery_tariff, 1167 | power_size, 1168 | last_invoice, 1169 | ): 1170 | future_consumption_info: FutureConsumptionInfo = future_consumptions[meter_id] 1171 | future_consumption = 0 1172 | 1173 | if last_meter_read and future_consumption_info: 1174 | if future_consumption_info.total_import: 1175 | future_consumption = ( 1176 | future_consumption_info.total_import - last_meter_read 1177 | ) 1178 | else: 1179 | _LOGGER.warn( 1180 | f"Failed to calculate Future Consumption, Assuming last meter read \ 1181 | ({last_meter_read}) as full consumption" 1182 | ) 1183 | future_consumption = last_meter_read 1184 | 1185 | kva_price = power_size * kva_tariff / 365 1186 | 1187 | total_kva_price = 0 1188 | distribution_price = 0 1189 | delivery_price = 0 1190 | 1191 | consumption_price = round(future_consumption * kwh_tariff, 2) 1192 | total_days = 0 1193 | 1194 | today = TIMEZONE.localize(datetime.now()) 1195 | 1196 | if last_invoice != EMPTY_INVOICE: 1197 | current_date = last_meter_read_date + timedelta(days=1) 1198 | month_counter = Counter() 1199 | 1200 | while current_date <= today.date(): 1201 | # Use (year, month) as the key for counting 1202 | month_year = (current_date.year, current_date.month) 1203 | month_counter[month_year] += 1 1204 | 1205 | # Move to the next day 1206 | current_date += timedelta(days=1) 1207 | 1208 | for (year, month), days in month_counter.items(): 1209 | days_in_month = calendar.monthrange(year, month)[1] 1210 | total_kva_price += kva_price * days 1211 | distribution_price += (distribution_tariff / days_in_month) * days 1212 | delivery_price += (delivery_tariff / days_in_month) * days 1213 | total_days += days 1214 | else: 1215 | total_days = today.day 1216 | days_in_current_month = calendar.monthrange(today.year, today.month)[1] 1217 | 1218 | consumption_price = round(future_consumption * kwh_tariff, 2) 1219 | total_kva_price = round(kva_price * total_days, 2) 1220 | distribution_price = round( 1221 | (distribution_tariff / days_in_current_month) * total_days, 2 1222 | ) 1223 | delivery_price = (delivery_tariff / days_in_current_month) * total_days 1224 | 1225 | _LOGGER.debug( 1226 | f"Calculated estimated bill: No. of days: {total_days}, total KVA price: {total_kva_price}, " 1227 | f"total distribution price: {distribution_price}, total delivery price: {delivery_price}, " 1228 | f"consumption price: {consumption_price}" 1229 | ) 1230 | 1231 | fixed_price = round(total_kva_price + distribution_price + delivery_price, 2) 1232 | total_estimated_bill = round(consumption_price + fixed_price, 2) 1233 | return ( 1234 | total_estimated_bill, 1235 | fixed_price, 1236 | round(consumption_price, 2), 1237 | total_days, 1238 | round(delivery_price, 2), 1239 | round(distribution_price, 2), 1240 | round(total_kva_price, 2), 1241 | future_consumption, 1242 | ) 1243 | --------------------------------------------------------------------------------