├── config └── configuration.yaml ├── custom_components └── priceanalyzer │ ├── readme.md │ ├── test.md │ ├── manifest.json │ ├── create_template.py │ ├── events.py │ ├── misc.py │ ├── const.py │ ├── translations │ ├── en.json │ ├── sk.json │ ├── et.json │ ├── fi.json │ ├── nb.json │ └── sv.json │ ├── __init__.py │ ├── config_flow.py │ ├── aio_price.py │ └── sensor.py ├── .gitattributes ├── hacs.json ├── priceanalyzer.png ├── FUNDING.yml ├── lovelace_example ├── nordpool.png └── README.md ├── .github ├── FUNDING.yml ├── workflows │ ├── cron.yaml │ ├── pull.yml │ └── push.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── issue.md ├── .vscode ├── settings.json ├── tasks.json └── launch.json ├── .devcontainer ├── configuration.yaml ├── devcontainer.json └── README.md ├── info.md ├── blueprints └── automation │ └── priceanalyzer │ ├── priceanalyzer_vvb.yaml │ └── priceanalyzer.yaml ├── .gitignore ├── test_migration.py └── README.md /config/configuration.yaml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /custom_components/priceanalyzer/readme.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /custom_components/priceanalyzer/test.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "PriceAnalyzer", 3 | "render_readme": true 4 | } 5 | 6 | 7 | -------------------------------------------------------------------------------- /priceanalyzer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erlendsellie/priceanalyzer/HEAD/priceanalyzer.png -------------------------------------------------------------------------------- /FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | custom: http://paypal.me/erlendsellie 4 | -------------------------------------------------------------------------------- /lovelace_example/nordpool.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erlendsellie/priceanalyzer/HEAD/lovelace_example/nordpool.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | patreon: erlendsellie 3 | ko_fi: erlendsellie 4 | custom: http://paypal.me/erlendsellie 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.linting.pylintEnabled": true, 3 | "python.linting.enabled": true, 4 | "python.pythonPath": "/usr/local/bin/python", 5 | "files.associations": { 6 | "*.yaml": "home-assistant" 7 | } 8 | } -------------------------------------------------------------------------------- /.devcontainer/configuration.yaml: -------------------------------------------------------------------------------- 1 | default_config: 2 | 3 | logger: 4 | default: info 5 | logs: 6 | custom_components.nordpool: debug 7 | custom_components.priceanalyzer: debug 8 | 9 | # If you need to debug uncommment the line below (doc: https://www.home-assistant.io/integrations/debugpy/) 10 | debugpy: 11 | -------------------------------------------------------------------------------- /.github/workflows/cron.yaml: -------------------------------------------------------------------------------- 1 | name: Cron actions 2 | 3 | on: 4 | schedule: 5 | - cron: '0 1 * * *' 6 | 7 | jobs: 8 | validate: 9 | runs-on: "ubuntu-latest" 10 | name: Validate 11 | steps: 12 | - uses: "actions/checkout@v2" 13 | 14 | - name: HACS validation 15 | uses: "hacs/action@main" 16 | with: 17 | category: "integration" 18 | ignore: brands 19 | 20 | - name: Hassfest validation 21 | uses: "home-assistant/actions/hassfest@master" 22 | -------------------------------------------------------------------------------- /custom_components/priceanalyzer/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "priceanalyzer", 3 | "name": "PriceAnalyzer", 4 | "after_dependencies": [ 5 | "http" 6 | ], 7 | "codeowners": [ 8 | "@erlendsellie" 9 | ], 10 | "config_flow": true, 11 | "dependencies": [], 12 | "documentation": "https://github.com/erlendsellie/priceanalyzer/", 13 | "iot_class": "cloud_polling", 14 | "issue_tracker": "https://github.com/erlendsellie/priceanalyzer/issues", 15 | "requirements": [ 16 | "nordpool==0.4.2", 17 | "backoff" 18 | ], 19 | "version": "2.4.7" 20 | } 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Run Home Assistant on port 9123", 6 | "type": "shell", 7 | "command": "container start", 8 | "problemMatcher": [] 9 | }, 10 | { 11 | "label": "Run Home Assistant configuration against /config", 12 | "type": "shell", 13 | "command": "container check", 14 | "problemMatcher": [] 15 | }, 16 | { 17 | "label": "Upgrade Home Assistant to latest dev", 18 | "type": "shell", 19 | "command": "container install", 20 | "problemMatcher": [] 21 | }, 22 | { 23 | "label": "Install a specific version of Home Assistant", 24 | "type": "shell", 25 | "command": "container set-version", 26 | "problemMatcher": [] 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | // Example of attaching to local debug server 7 | "name": "Python: Attach Local", 8 | "type": "python", 9 | "request": "attach", 10 | "port": 5678, 11 | "host": "localhost", 12 | "pathMappings": [ 13 | { 14 | "localRoot": "${workspaceFolder}", 15 | "remoteRoot": "." 16 | } 17 | ] 18 | }, 19 | { 20 | // Example of attaching to my production server 21 | "name": "Python: Attach Remote", 22 | "type": "python", 23 | "request": "attach", 24 | "port": 5678, 25 | "host": "homeassistant.local", 26 | "pathMappings": [ 27 | { 28 | "localRoot": "${workspaceFolder}", 29 | "remoteRoot": "/usr/src/homeassistant" 30 | } 31 | ] 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | 16 | 17 | ## Version of the custom_component 18 | 21 | 22 | ## Homeassistant version 23 | 24 | 25 | ## Configuration 26 | 27 | ```yaml 28 | 29 | Add your logs here. 30 | 31 | ``` 32 | 33 | ## Describe the bug 34 | A clear and concise description of what the bug is. 35 | 36 | 37 | ## Debug log 38 | 39 | 40 | 41 | ```text 42 | 43 | Add your logs here. 44 | 45 | ``` 46 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ludeeus/integration_blueprint", 3 | "image": "mcr.microsoft.com/devcontainers/python:3.12", 4 | "forwardPorts": [ 5 | 8123 6 | ], 7 | "portsAttributes": { 8 | "8123": { 9 | "label": "Home Assistant", 10 | "onAutoForward": "notify" 11 | } 12 | }, 13 | "customizations": { 14 | "vscode": { 15 | "extensions": [ 16 | "charliermarsh.ruff", 17 | "github.vscode-pull-request-github", 18 | "ms-python.python", 19 | "ms-python.vscode-pylance", 20 | "ryanluker.vscode-coverage-gutters" 21 | ], 22 | "settings": { 23 | "files.eol": "\n", 24 | "editor.tabSize": 4, 25 | "editor.formatOnPaste": true, 26 | "editor.formatOnSave": true, 27 | "editor.formatOnType": false, 28 | "files.trimTrailingWhitespace": true, 29 | "python.analysis.typeCheckingMode": "basic", 30 | "python.analysis.autoImportCompletions": true, 31 | "python.defaultInterpreterPath": "/usr/local/bin/python", 32 | "[python]": { 33 | "editor.defaultFormatter": "charliermarsh.ruff" 34 | } 35 | } 36 | } 37 | }, 38 | "remoteUser": "vscode", 39 | "features": {} 40 | } -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | ## nordpool custom component for home assistant 2 | 3 | ### Usage 4 | 5 | Set up the sensor using the webui or use a yaml. 6 | 7 | The sensors tries to set some sane default so a minimal setup can be 8 | 9 | ``` 10 | sensor: 11 | - platform: nordpool 12 | region: "Kr.sand" # This can be skipped if you want Kr.sand 13 | ``` 14 | 15 | 16 | 17 | in configuration.yaml 18 | 19 | ``` 20 | nordpool: 21 | 22 | sensor: 23 | - platform: nordpool 24 | 25 | # Should the prices include vat? Default True 26 | VAT: True 27 | 28 | # What currency the api fetches the prices in 29 | # this is only need if you want a sensor in a non local currecy 30 | currency: "EUR" 31 | 32 | # Helper so you can set your "low" price 33 | # low_price = hour_price < average * low_price_cutoff 34 | low_price_cutoff: 0.95 35 | 36 | # What power regions your are interested in. 37 | # Possible values: "DK1", "DK2", "FI", "LT", "LV", "Oslo", "Kr.sand", "Bergen", "Molde", "Tr.heim", "Tromsø", "SE1", "SE2", "SE3","SE4", "SYS" 38 | region: "Kr.sand" 39 | 40 | # How many decimals to use in the display of the price 41 | precision: 3 42 | 43 | # What the price should be displayed in default 44 | # Possible values: MWh, kWh and W 45 | # default: kWh 46 | price_type: kWh 47 | 48 | 49 | ``` 50 | 51 | run the create_template script if you want one sensors for each hour. See the help options with ```python create_template --help``` 52 | -------------------------------------------------------------------------------- /.github/workflows/pull.yml: -------------------------------------------------------------------------------- 1 | name: Pull actions 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | validate: 8 | runs-on: "ubuntu-latest" 9 | name: Validate 10 | steps: 11 | - uses: "actions/checkout@v2" 12 | 13 | - name: HACS validation 14 | uses: "hacs/action@main" 15 | with: 16 | category: "integration" 17 | ignore: brands 18 | 19 | - name: Hassfest validation 20 | uses: "home-assistant/actions/hassfest@master" 21 | 22 | style: 23 | runs-on: "ubuntu-latest" 24 | name: Check style formatting 25 | steps: 26 | - uses: "actions/checkout@v2" 27 | - uses: "actions/setup-python@v1" 28 | with: 29 | python-version: "3.x" 30 | - run: python3 -m pip install black 31 | - run: black . 32 | 33 | # tests: 34 | # runs-on: "ubuntu-latest" 35 | # name: Run tests 36 | # steps: 37 | # - name: Check out code from GitHub 38 | # uses: "actions/checkout@v2" 39 | # - name: Setup Python 40 | # uses: "actions/setup-python@v1" 41 | # with: 42 | # python-version: "3.8" 43 | # - name: Install requirements 44 | # run: python3 -m pip install -r requirements_test.txt 45 | # - name: Run tests 46 | # run: | 47 | # pytest \ 48 | # -qq \ 49 | # --timeout=9 \ 50 | # --durations=10 \ 51 | # -n auto \ 52 | # --cov custom_components.integration_blueprint \ 53 | # -o console_output_style=count \ 54 | # -p no:sugar \ 55 | # tests 56 | -------------------------------------------------------------------------------- /blueprints/automation/priceanalyzer/priceanalyzer_vvb.yaml: -------------------------------------------------------------------------------- 1 | blueprint: 2 | name: PriceAnalyzer - Control Hot Water Tank 3 | description: >- 4 | Control a Hot Water climate entity with PriceAnalyzer(https://github.com/erlendsellie/priceanalyzer) 5 | The Hot Water tank will be controlled based on the Hot water tank target sensor from PriceAnalyzer. 6 | domain: automation 7 | input: 8 | sensor: 9 | name: PriceAnalyzer 10 | description: PriceAnalyzer VVB Sensor 11 | selector: 12 | entity: 13 | domain: sensor 14 | climate: 15 | name: Hot Water Heater 16 | description: Your hot water heater climate entity 17 | selector: 18 | entity: 19 | domain: climate 20 | source_url: https://github.com/erlendsellie/HomeAssistantConfig/blob/master/blueprints/automation/priceanalyzer/priceanalyzer_vvb.yaml 21 | mode: restart 22 | max_exceeded: silent 23 | trigger_variables: 24 | sensor: !input sensor 25 | trigger: 26 | - platform: homeassistant 27 | event: start 28 | - platform: time_pattern 29 | minutes: 2 30 | - platform: state 31 | entity_id: 32 | - !input sensor 33 | for: 34 | minutes: 2 35 | action: 36 | - variables: 37 | sensor: !input sensor 38 | climate: !input climate 39 | - condition: template 40 | value_template: "{{ states(climate) == 'heat' or states(climate) == 'off'}}" 41 | - service: climate.set_temperature 42 | data_template: 43 | entity_id: !input climate 44 | temperature: >- 45 | {%-set priceanalyzer = states(sensor) | float(default=0)%} 46 | {{priceanalyzer}} 47 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: Push actions 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - dev 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | name: Validate 13 | steps: 14 | - uses: "actions/checkout@v2" 15 | 16 | - name: HACS validation 17 | uses: "hacs/action@main" 18 | with: 19 | category: "integration" 20 | ignore: brands 21 | 22 | - name: Hassfest validation 23 | uses: "home-assistant/actions/hassfest@master" 24 | 25 | style: 26 | runs-on: "ubuntu-latest" 27 | name: Check style formatting 28 | steps: 29 | - uses: "actions/checkout@v2" 30 | - uses: "actions/setup-python@v1" 31 | with: 32 | python-version: "3.x" 33 | - run: python3 -m pip install black 34 | - run: black . 35 | 36 | # tests: 37 | # runs-on: "ubuntu-latest" 38 | # name: Run tests 39 | # steps: 40 | # - name: Check out code from GitHub 41 | # uses: "actions/checkout@v2" 42 | # - name: Setup Python 43 | # uses: "actions/setup-python@v1" 44 | # with: 45 | # python-version: "3.8" 46 | # - name: Install requirements 47 | # run: python3 -m pip install -r requirements_test.txt 48 | # - name: Run tests 49 | # run: | 50 | # pytest \ 51 | # -qq \ 52 | # --timeout=9 \ 53 | # --durations=10 \ 54 | # -n auto \ 55 | # --cov custom_components.integration_blueprint \ 56 | # -o console_output_style=count \ 57 | # -p no:sugar \ 58 | # tests -------------------------------------------------------------------------------- /blueprints/automation/priceanalyzer/priceanalyzer.yaml: -------------------------------------------------------------------------------- 1 | blueprint: 2 | name: PriceAnalyzer - Control Climate 3 | description: >- 4 | Control a climate entity with PriceAnalyzer(https://github.com/erlendsellie/priceanalyzer) 5 | and an input number as a target temperature. 6 | The climate target temperature will update whenever input number is changed, or PriceAnalyzer updates. 7 | domain: automation 8 | input: 9 | sensor: 10 | name: PriceAnalyzer 11 | description: PriceAnalyzer Sensor 12 | selector: 13 | entity: 14 | integration: priceanalyzer 15 | domain: sensor 16 | number: 17 | name: Base Setpoint Temperature 18 | description: Input Number Helper for base setpoint temperature 19 | selector: 20 | entity: 21 | domain: input_number 22 | climate: 23 | name: Climate 24 | description: Climate Entity to control 25 | selector: 26 | entity: 27 | domain: climate 28 | source_url: https://github.com/erlendsellie/HomeAssistantConfig/blob/master/blueprints/automation/priceanalyzer/priceanalyzer.yaml 29 | mode: restart 30 | max_exceeded: silent 31 | trigger_variables: 32 | sensor: !input sensor 33 | number: !input number 34 | trigger: 35 | - platform: homeassistant 36 | event: start 37 | - platform: state 38 | entity_id: 39 | - !input sensor 40 | - !input number 41 | action: 42 | - variables: 43 | sensor: !input sensor 44 | number: !input number 45 | climate: !input climate 46 | - condition: template 47 | value_template: "{{ states(climate) == 'heat' or states(climate) == 'off'}}" 48 | - service: climate.set_temperature 49 | data_template: 50 | entity_id: !input climate 51 | temperature: >- 52 | {%-set baseTemp = states(number) | float(default=0)%} 53 | {%-set priceanalyzer = states(sensor) | float(default=0)%} 54 | {{baseTemp + priceanalyzer}} 55 | -------------------------------------------------------------------------------- /custom_components/priceanalyzer/create_template.py: -------------------------------------------------------------------------------- 1 | if __name__ == "__main__": 2 | import yaml 3 | import click 4 | 5 | """ 6 | For usage example do: python create_template --help 7 | """ 8 | 9 | @click.command({"help_option_names": ["-h", "--h"]}) 10 | @click.argument('entity_id') 11 | @click.argument('friendly_name') 12 | @click.option('--icon', default="mdi:cash", help="The icon you want to use.") 13 | @click.option('--unit', default="NOK/kWh", help="the currency the sensor should use") 14 | @click.option('--path', default="result.yaml", help="What path to write the file to.") 15 | def make_sensors(entity_id, friendly_name, icon, unit, path): 16 | """A simple tool to make 48 template sensors, one for each hour.""" 17 | 18 | entity_id = entity_id.replace("sensor.", "") 19 | 20 | head = {"sensor": [{"platform": "template", "sensors": {}}]} 21 | 22 | name = "nordpool_%s_hr_%02d_%02d" 23 | state_attr = '{{ state_attr("sensor.%s", "%s")[%s] }}' 24 | 25 | for i in range(24): 26 | sensor = { 27 | "friendly_name": friendly_name + " today h %s" % (i + 1), 28 | "icon_template": icon, 29 | "unit_of_measurement": unit, 30 | "value_template": state_attr % (entity_id, "today", i), 31 | } 32 | 33 | head["sensor"][0]["sensors"][name % ("today", i, i + 1)] = sensor 34 | 35 | for z in range(24): 36 | sensor = { 37 | "friendly_name": friendly_name + " tomorrow h %s" % (z + 1), 38 | "icon_template": icon, 39 | "unit_of_measurement": unit, 40 | "value_template": state_attr % (entity_id, "tomorrow", z), 41 | } 42 | 43 | head["sensor"][0]["sensors"][name % ("tomorrow", z, z + 1)] = sensor 44 | 45 | with open(path, "w") as yaml_file: 46 | yaml.dump(head, yaml_file, default_flow_style=False) 47 | click.echo("All done, wrote file to %s" % path) 48 | 49 | make_sensors() 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # IPython 79 | profile_default/ 80 | ipython_config.py 81 | 82 | # pyenv 83 | .python-version 84 | 85 | # pipenv 86 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 87 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 88 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 89 | # install all needed dependencies. 90 | #Pipfile.lock 91 | 92 | # celery beat schedule file 93 | celerybeat-schedule 94 | 95 | # SageMath parsed files 96 | *.sage.py 97 | 98 | # Environments 99 | .env 100 | .venv 101 | env/ 102 | venv/ 103 | ENV/ 104 | env.bak/ 105 | venv.bak/ 106 | 107 | # Spyder project settings 108 | .spyderproject 109 | .spyproject 110 | 111 | # Rope project settings 112 | .ropeproject 113 | 114 | # mkdocs documentation 115 | /site 116 | 117 | # mypy 118 | .mypy_cache/ 119 | .dmypy.json 120 | dmypy.json 121 | 122 | # Pyre type checker 123 | .pyre/ 124 | 125 | 126 | #other 127 | hacs 128 | nordpool 129 | -------------------------------------------------------------------------------- /.devcontainer/README.md: -------------------------------------------------------------------------------- 1 | ## Developing with Visual Studio Code + devcontainer 2 | 3 | The easiest way to get started with custom integration development is to use Visual Studio Code with devcontainers. This approach will create a preconfigured development environment with all the tools you need. 4 | 5 | In the container you will have a dedicated Home Assistant core instance running with your custom component code. You can configure this instance by updating the `./devcontainer/configuration.yaml` file. 6 | 7 | **Prerequisites** 8 | 9 | - [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) 10 | - Docker 11 | - For Linux, macOS, or Windows 10 Pro/Enterprise/Education use the [current release version of Docker](https://docs.docker.com/install/) 12 | - Windows 10 Home requires [WSL 2](https://docs.microsoft.com/windows/wsl/wsl2-install) and the current Edge version of Docker Desktop (see instructions [here](https://docs.docker.com/docker-for-windows/wsl-tech-preview/)). This can also be used for Windows Pro/Enterprise/Education. 13 | - [Visual Studio code](https://code.visualstudio.com/) 14 | - [Remote - Containers (VSC Extension)][extension-link] 15 | 16 | [More info about requirements and devcontainer in general](https://code.visualstudio.com/docs/remote/containers#_getting-started) 17 | 18 | [extension-link]: https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers 19 | 20 | **Getting started:** 21 | 22 | 1. Fork the repository. 23 | 2. Clone the repository to your computer. 24 | 3. Open the repository using Visual Studio code. 25 | 26 | When you open this repository with Visual Studio code you are asked to "Reopen in Container", this will start the build of the container. 27 | 28 | _If you don't see this notification, open the command palette and select `Remote-Containers: Reopen Folder in Container`._ 29 | 30 | ### Tasks 31 | 32 | The devcontainer comes with some useful tasks to help you with development, you can start these tasks by opening the command palette and select `Tasks: Run Task` then select the task you want to run. 33 | 34 | When a task is currently running (like `Run Home Assistant on port 9123` for the docs), it can be restarted by opening the command palette and selecting `Tasks: Restart Running Task`, then select the task you want to restart. 35 | 36 | The available tasks are: 37 | 38 | Task | Description 39 | -- | -- 40 | Run Home Assistant on port 9123 | Launch Home Assistant with your custom component code and the configuration defined in `.devcontainer/configuration.yaml`. 41 | Run Home Assistant configuration against /config | Check the configuration. 42 | Upgrade Home Assistant to latest dev | Upgrade the Home Assistant core version in the container to the latest version of the `dev` branch. 43 | Install a specific version of Home Assistant | Install a specific version of Home Assistant core in the container. 44 | 45 | ### Step by Step debugging 46 | 47 | With the development container, 48 | you can test your custom component in Home Assistant with step by step debugging. 49 | 50 | You need to modify the `configuration.yaml` file in `.devcontainer` folder 51 | by uncommenting the line: 52 | 53 | ```yaml 54 | # debugpy: 55 | ``` 56 | 57 | Then launch the task `Run Home Assistant on port 9123`, and launch the debbuger 58 | with the existing debugging configuration `Python: Attach Local`. 59 | 60 | For more information, look at [the Remote Python Debugger integration documentation](https://www.home-assistant.io/integrations/debugpy/). 61 | -------------------------------------------------------------------------------- /custom_components/priceanalyzer/events.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import Any, Optional 3 | from collections.abc import Awaitable, Callable 4 | 5 | # 6 | from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, HassJob 7 | from homeassistant.loader import bind_hass 8 | from homeassistant.util import dt as dt_util 9 | from homeassistant.helpers.event import ( 10 | async_track_time_interval, 11 | async_track_point_in_utc_time, 12 | ) 13 | from pytz import timezone 14 | 15 | # For targeted patching in tests 16 | time_tracker_utcnow = dt_util.utcnow 17 | 18 | 19 | __ALL__ = ["stock", "async_track_time_change_in_tz"] 20 | 21 | 22 | def stock(d): 23 | """convert datetime to stocholm time.""" 24 | return d.astimezone(timezone("Europe/Stockholm")) 25 | 26 | 27 | @callback 28 | @bind_hass 29 | def async_track_utc_time_change( 30 | hass: HomeAssistant, 31 | action: None, 32 | hour: Optional[Any] = None, 33 | minute: Optional[Any] = None, 34 | second: Optional[Any] = None, 35 | tz: Optional[Any] = None, 36 | ) -> CALLBACK_TYPE: 37 | """Add a listener that will fire if time matches a pattern.""" 38 | # This is function is modifies to support timezones. 39 | 40 | # We do not have to wrap the function with time pattern matching logic 41 | # if no pattern given 42 | if all(val is None for val in (hour, minute, second)): 43 | # Previously this relied on EVENT_TIME_FIRED 44 | # which meant it would not fire right away because 45 | # the caller would always be misaligned with the call 46 | # time vs the fire time by < 1s. To preserve this 47 | # misalignment we use async_track_time_interval here 48 | return async_track_time_interval(hass, action, timedelta(seconds=1)) 49 | 50 | job = HassJob(action) 51 | matching_seconds = dt_util.parse_time_expression(second, 0, 59) 52 | matching_minutes = dt_util.parse_time_expression(minute, 0, 59) 53 | matching_hours = dt_util.parse_time_expression(hour, 0, 23) 54 | 55 | def calculate_next(now: datetime) -> datetime: 56 | """Calculate and set the next time the trigger should fire.""" 57 | ts_now = now.astimezone(tz) if tz else now 58 | return dt_util.find_next_time_expression_time( 59 | ts_now, matching_seconds, matching_minutes, matching_hours 60 | ) 61 | 62 | time_listener: CALLBACK_TYPE | None = None 63 | 64 | @callback 65 | def pattern_time_change_listener(_: datetime) -> None: 66 | """Listen for matching time_changed events.""" 67 | nonlocal time_listener 68 | 69 | now = time_tracker_utcnow() 70 | hass.async_run_hass_job(job, now.astimezone(tz) if tz else now) 71 | 72 | time_listener = async_track_point_in_utc_time( 73 | hass, 74 | pattern_time_change_listener, 75 | calculate_next(now + timedelta(seconds=1)), 76 | ) 77 | 78 | time_listener = async_track_point_in_utc_time( 79 | hass, pattern_time_change_listener, calculate_next(dt_util.utcnow()) 80 | ) 81 | 82 | @callback 83 | def unsub_pattern_time_change_listener() -> None: 84 | """Cancel the time listener.""" 85 | assert time_listener is not None 86 | time_listener() 87 | 88 | return unsub_pattern_time_change_listener 89 | 90 | 91 | @callback 92 | @bind_hass 93 | def async_track_time_change_in_tz( 94 | hass: HomeAssistant, 95 | action: None, 96 | # action: Callable[[datetime], Awaitable[None] | None], 97 | hour: Optional[Any] = None, 98 | minute: Optional[Any] = None, 99 | second: Optional[Any] = None, 100 | tz: Optional[Any] = None, 101 | ) -> CALLBACK_TYPE: 102 | """Add a listener that will fire if UTC time matches a pattern.""" 103 | return async_track_utc_time_change(hass, action, hour, minute, second, tz) 104 | -------------------------------------------------------------------------------- /custom_components/priceanalyzer/misc.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections import defaultdict 3 | from operator import itemgetter 4 | from statistics import mean 5 | from decimal import Decimal 6 | 7 | import pytz 8 | from homeassistant.util import dt as dt_util 9 | from pytz import timezone 10 | 11 | UTC = pytz.utc 12 | 13 | __all__ = [ 14 | "is_new", 15 | "has_junk", 16 | "extract_attrs", 17 | "start_of", 18 | "end_of", 19 | "stock", 20 | "add_junk", 21 | ] 22 | 23 | _LOGGER = logging.getLogger(__name__) 24 | 25 | stockholm_tz = timezone("Europe/Stockholm") 26 | 27 | 28 | def exceptions_raiser(): 29 | """Utility to check that all exceptions are raised.""" 30 | import aiohttp 31 | import random 32 | 33 | exs = [KeyError, aiohttp.ClientError, None, None, None] 34 | got = random.choice(exs) 35 | if got is None: 36 | pass 37 | else: 38 | raise got 39 | 40 | 41 | def round_decimal(number, decimal_places=3): 42 | decimal_value = Decimal(number) 43 | return decimal_value.quantize(Decimal(10) ** -decimal_places) 44 | 45 | 46 | def add_junk(d): 47 | for key in ["Average", "Min", "Max", "Off-peak 1", "Off-peak 2", "Peak"]: 48 | d[key] = float("inf") 49 | 50 | return d 51 | 52 | 53 | def stock(d): 54 | """convert datetime to stocholm time.""" 55 | return d.astimezone(stockholm_tz) 56 | 57 | 58 | def start_of(d, typ_="hour"): 59 | if typ_ == "hour": 60 | return d.replace(minute=0, second=0, microsecond=0) 61 | elif typ_ == "quarter": 62 | # Round down to the nearest 15-minute mark 63 | quarter_minute = (d.minute // 15) * 15 64 | return d.replace(minute=quarter_minute, second=0, microsecond=0) 65 | elif typ_ == "day": 66 | return d.replace(hour=0, minute=0, microsecond=0) 67 | 68 | 69 | def time_in_range(start, end, x): 70 | """Return true if x is in the range [start, end]""" 71 | if start <= end: 72 | return start <= x <= end 73 | else: 74 | return start <= x or x <= end 75 | 76 | 77 | def end_of(d, typ_="hour"): 78 | """Return end our hour""" 79 | if typ_ == "hour": 80 | return d.replace(minute=59, second=59, microsecond=999999) 81 | elif typ_ == "day": 82 | return d.replace(hour=23, minute=59, second=59, microsecond=999999) 83 | 84 | 85 | def is_new(date=None, typ="day") -> bool: 86 | """Utility to check if its a new hour or day.""" 87 | # current = pendulum.now() 88 | current = dt_util.now() 89 | if typ == "day": 90 | if date.date() != current.date(): 91 | _LOGGER.debug("Its a new day!") 92 | return True 93 | return False 94 | 95 | elif typ == "hour": 96 | if current.hour != date.hour: 97 | _LOGGER.debug("Its a new hour!") 98 | return True 99 | return False 100 | 101 | 102 | def is_inf(d): 103 | if d == float("inf"): 104 | return True 105 | return False 106 | 107 | 108 | def has_junk(data) -> bool: 109 | """Check if data has some infinity values. 110 | 111 | Args: 112 | data (dict): Holds the data from the api. 113 | 114 | Returns: 115 | TYPE: True if there is any infinity values else False 116 | """ 117 | cp = dict(data) 118 | cp.pop("values", None) 119 | if any(map(is_inf, cp.values())): 120 | return True 121 | return False 122 | 123 | 124 | def extract_attrs(data) -> dict: 125 | """extract attrs""" 126 | d = defaultdict(list) 127 | items = [i.get("value") for i in data if i.get("value") is not None] 128 | 129 | if len(data): 130 | data = sorted(data, key=itemgetter("start")) 131 | offpeak1 = [i.get("value") for i in data[0:8] if i.get("value") is not None] 132 | peak = [i.get("value") for i in data[8:20] if i.get("value") is not None] 133 | offpeak2 = [i.get("value") for i in data[20:] if i.get("value") is not None] 134 | 135 | d["Peak"] = mean(peak) if peak else float("inf") 136 | d["Off-peak 1"] = mean(offpeak1) if offpeak1 else float("inf") 137 | d["Off-peak 2"] = mean(offpeak2) if offpeak2 else float("inf") 138 | d["Average"] = mean(items) if items else float("inf") 139 | d["Min"] = min(items) if items else float("inf") 140 | d["Max"] = max(items) if items else float("inf") 141 | 142 | return d 143 | 144 | return data 145 | -------------------------------------------------------------------------------- /custom_components/priceanalyzer/const.py: -------------------------------------------------------------------------------- 1 | 2 | import logging 3 | import json 4 | 5 | from random import randint 6 | from homeassistant.components.sensor import PLATFORM_SCHEMA 7 | import voluptuous as vol 8 | from homeassistant.const import CONF_REGION 9 | import homeassistant.helpers.config_validation as cv 10 | 11 | 12 | DOMAIN = "priceanalyzer" 13 | DATA = 'priceanalyzer_data' 14 | API_DATA_LOADED = 'priceanalyzer_api_data_loaded' 15 | 16 | RANDOM_MINUTE = randint(0, 5) 17 | RANDOM_SECOND = randint(0, 59) 18 | 19 | EVENT_NEW_DATA = "priceanalyzer_new_day" 20 | EVENT_NEW_HOUR = "priceanalyzer_new_hour" 21 | EVENT_CHECKED_STUFF = 'pricanalyzer_checked_stuff' 22 | _CURRENCY_LIST = ["DKK", "EUR", "NOK", "SEK"] 23 | 24 | PLATFORMS = [ 25 | "sensor", 26 | ] 27 | 28 | CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) 29 | 30 | 31 | NAME = DOMAIN 32 | VERSION = "1.0" 33 | ISSUEURL = "https://github.com/erlendsellie/priceanalyzer/issues" 34 | 35 | STARTUP = f""" 36 | ------------------------------------------------------------------- 37 | {NAME} 38 | Version: {VERSION} 39 | This is a custom component 40 | If you have any issues with this you need to open an issue here: 41 | {ISSUEURL} 42 | ------------------------------------------------------------------- 43 | """ 44 | 45 | 46 | 47 | 48 | _CENT_MULTIPLIER = 100 49 | _PRICE_IN = {"kWh": 1000, "MWh": 0, "Wh": 1000 * 1000} 50 | 51 | _REGIONS = { 52 | "DK1": ["DKK", "Denmark", 0.25], 53 | "DK2": ["DKK", "Denmark", 0.25], 54 | "FI": ["EUR", "Finland", 0.255], 55 | "EE": ["EUR", "Estonia", 0.22], 56 | "LT": ["EUR", "Lithuania", 0.21], 57 | "LV": ["EUR", "Latvia", 0.21], 58 | "NO1": ["NOK", "Norway", 0.25], 59 | "NO2": ["NOK", "Norway", 0.25], 60 | "NO3": ["NOK", "Norway", 0.25], 61 | "NO4": ["NOK", "Norway", 0.25], 62 | "NO5": ["NOK", "Norway", 0.25], 63 | "SE1": ["SEK", "Sweden", 0.25], 64 | "SE2": ["SEK", "Sweden", 0.25], 65 | "SE3": ["SEK", "Sweden", 0.25], 66 | "SE4": ["SEK", "Sweden", 0.25], 67 | # What zone is this? 68 | "SYS": ["EUR", "System zone", 0.25], 69 | "FR": ["EUR", "France", 0.055], 70 | "NL": ["EUR", "Netherlands", 0.21], 71 | "BE": ["EUR", "Belgium", 0.06], 72 | "AT": ["EUR", "Austria", 0.20], 73 | # Unsure about tax rate, correct if wrong 74 | "GER": ["EUR", "Germany", 0.23], 75 | } 76 | # Needed incase a user wants the prices in non local currency 77 | _CURRENCY_TO_LOCAL = {"DKK": "Kr", "NOK": "Kr", "SEK": "Kr", "EUR": "€"} 78 | _CURRENTY_TO_CENTS = {"DKK": "Øre", "NOK": "Øre", "SEK": "Öre", "EUR": "c"} 79 | 80 | DEFAULT_CURRENCY = "NOK" 81 | DEFAULT_REGION = "Kr.sand" 82 | DEFAULT_NAME = "Elspot" 83 | DEFAULT_TIME_RESOLUTION = "hourly" 84 | 85 | 86 | DEFAULT_TEMPLATE = "{{0.01|float}}" 87 | 88 | 89 | #config for hot water temperature. 90 | TEMP_DEFAULT = 'default_temp' 91 | TEMP_FIVE_MOST_EXPENSIVE = 'five_most_expensive' 92 | TEMP_IS_FALLING = 'is_falling' 93 | TEMP_FIVE_CHEAPEST = 'five_cheapest' 94 | TEMP_TEN_CHEAPEST = 'ten_cheapest' 95 | TEMP_LOW_PRICE = 'low_price' 96 | TEMP_NOT_CHEAP_NOT_EXPENSIVE = 'not_cheap_not_expensive' 97 | TEMP_MINIMUM = 'min_price_for_day' 98 | 99 | HOT_WATER_CONFIG = 'hot_water_config' 100 | HOT_WATER_DEFAULT_CONFIG = { 101 | TEMP_DEFAULT : 75, 102 | TEMP_FIVE_MOST_EXPENSIVE : 40, 103 | TEMP_IS_FALLING : 50, 104 | TEMP_FIVE_CHEAPEST : 70, 105 | TEMP_TEN_CHEAPEST : 65, 106 | TEMP_LOW_PRICE : 60, 107 | TEMP_NOT_CHEAP_NOT_EXPENSIVE : 50, 108 | TEMP_MINIMUM : 75 109 | } 110 | 111 | HOT_WATER_DEFAULT_CONFIG_JSON = json.dumps(HOT_WATER_DEFAULT_CONFIG) 112 | 113 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 114 | { 115 | vol.Optional(CONF_REGION, default=DEFAULT_REGION): vol.In( 116 | list(_REGIONS.keys()) 117 | ), 118 | vol.Optional("friendly_name", default=""): cv.string, 119 | vol.Optional("currency", default=""): cv.string, 120 | vol.Optional("VAT", default=True): cv.boolean, 121 | vol.Optional("low_price_cutoff", default=1.0): cv.small_float, 122 | vol.Optional("price_type", default="kWh"): vol.In(list(_PRICE_IN.keys())), 123 | vol.Optional("price_in_cents", default=False): cv.boolean, 124 | vol.Optional("time_resolution", default=DEFAULT_TIME_RESOLUTION): vol.In(["quarterly", "hourly"]), 125 | vol.Optional("additional_costs", default=DEFAULT_TEMPLATE): cv.template, 126 | vol.Optional("multiply_template", default='{{correction * 1}}'): cv.template, 127 | vol.Optional("hours_to_boost", default=2): int, 128 | vol.Optional("hours_to_save", default=2): int, 129 | vol.Optional("pa_price_before_active", default=0.20): float, 130 | vol.Optional("percent_difference", default=20): int, 131 | vol.Optional("price_before_active", default=0.20): float, 132 | vol.Optional(HOT_WATER_CONFIG, default=HOT_WATER_DEFAULT_CONFIG_JSON): cv.string, 133 | } 134 | ) -------------------------------------------------------------------------------- /lovelace_example/README.md: -------------------------------------------------------------------------------- 1 | ### Lovelace card example with sorted price information 2 | In this example we make a simple but useful lovelace card with price information sorted in ascending order. Especially during periods where the price difference is large, it's useful to sort out the cheapest hours throughout the day and day ahead. In addition, to make this work, you need to install the HACS plugins "Flex Table" and "Multiple Entity Row". 3 | 4 | The final result will look like this: 5 | 6 | ![Simple](/lovelace_example/nordpool.png) 7 | 8 | First of all we have to make a sensor for each hour. This can be done using the provided python script or by manually making template sensors in sensors.yaml. In this example we make template sensors. If you live in another price area than "Krsand" your nordpool sensors entity id will be different from mine. So remember to replace "sensor.nordpool_kwh_krsand_nok_2_095_025" with your own. Your "unit_of_measurement" may also be different from mine as well. 9 | Add to your sensors.yaml: 10 | ```` 11 | # TEMPLATE SENSORS 12 | - platform: template 13 | sensors: 14 | # NORDPOOL PRICES 15 | nordpool_today_hr_00_01: 16 | entity_id: sensor.nordpool_kwh_krsand_nok_2_095_025 17 | friendly_name: "Today hour 1" 18 | icon_template: mdi:cash 19 | unit_of_measurement: "øre" 20 | value_template: "{{ states.sensor.nordpool_kwh_krsand_nok_2_095_025.attributes.today[0] }}" 21 | 22 | nordpool_today_hr_01_02: 23 | entity_id: sensor.nordpool_kwh_krsand_nok_2_095_025 24 | friendly_name: "Today hour 2" 25 | icon_template: mdi:cash 26 | unit_of_measurement: "øre" 27 | value_template: "{{ states.sensor.nordpool_kwh_krsand_nok_2_095_025.attributes.today[1] }}" 28 | 29 | ### and so on down to... 30 | 31 | nordpool_today_hr_23_24: 32 | entity_id: sensor.nordpool_kwh_krsand_nok_2_095_025 33 | friendly_name: "Today hour 24" 34 | icon_template: mdi:cash 35 | unit_of_measurement: "øre" 36 | value_template: "{{ states.sensor.nordpool_kwh_krsand_nok_2_095_025.attributes.today[23] }}" 37 | 38 | ### and than for the day ahead... 39 | 40 | nordpool_tomorrow_hr_00_01: 41 | entity_id: sensor.nordpool_kwh_krsand_nok_2_095_025 42 | friendly_name: "Tomorrow hour 1" 43 | icon_template: mdi:cash 44 | unit_of_measurement: "øre" 45 | value_template: "{{ states.sensor.nordpool_kwh_krsand_nok_2_095_025.attributes.tomorrow[0] }}" 46 | 47 | nordpool_tomorrow_hr_01_02: 48 | entity_id: sensor.nordpool_kwh_krsand_nok_2_095_025 49 | friendly_name: "Tomorrow hour 2" 50 | icon_template: mdi:cash 51 | unit_of_measurement: "øre" 52 | value_template: "{{ states.sensor.nordpool_kwh_krsand_nok_2_095_025.attributes.tomorrow[1] }}" 53 | 54 | ### and so on down to... 55 | 56 | nordpool_tomorrow_hr_23_24: 57 | entity_id: sensor.nordpool_kwh_krsand_nok_2_095_025 58 | friendly_name: "Tomorrow hour 24" 59 | icon_template: mdi:cash 60 | unit_of_measurement: "øre" 61 | value_template: "{{ states.sensor.nordpool_kwh_krsand_nok_2_095_025.attributes.tomorrow[23] }}" 62 | 63 | ```` 64 | 65 | Save and restart Home Assistant to see that the new sensors works properly. 66 | 67 | Now we can make the lovelace card. In this example we use ui-lovelace.yaml (mode: yaml # in configuration.yaml) and the card has it's own view called "Energy prices". 68 | Add the following to your ui-lovelace.yaml: 69 | 70 | ```` 71 | title: My Home Assistant 72 | views: 73 | - title: Energy prices 74 | icon: mdi:cash 75 | background: white 76 | cards: 77 | - type: vertical-stack 78 | cards: 79 | - type: entities 80 | title: Energy prices 81 | show_header_toggle: false 82 | entities: 83 | - entity: sensor.nordpool_kwh_krsand_nok_2_095_025 84 | type: custom:multiple-entity-row 85 | name: Todays prices (øre/kWh) 86 | unit: " " 87 | icon: mdi:cash-multiple 88 | show_state: False 89 | entities: 90 | - attribute: min 91 | name: Min 92 | - attribute: max 93 | name: Max 94 | - attribute: current_price 95 | name: Current 96 | secondary_info: 97 | entity: sensor.nordpool_kwh_krsand_nok_2_095_025 98 | attribute: average 99 | name: "Average:" 100 | - type: custom:flex-table-card 101 | sort_by: state+ 102 | entities: 103 | include: sensor.nordpool_today_h* 104 | columns: 105 | - name: Today (sorted ascending) 106 | prop: name 107 | - name: Price (øre/kWh) 108 | prop: state 109 | align: right 110 | - type: custom:flex-table-card 111 | sort_by: state+ 112 | entities: 113 | include: sensor.nordpool_tomorrow_h* 114 | columns: 115 | - name: Tomorrow (sorted ascen.) 116 | prop: name 117 | - name: Price (øre/kWh) 118 | prop: state 119 | align: right 120 | ```` 121 | 122 | Reload the lovelace views and enjoy your new card. 123 | -------------------------------------------------------------------------------- /test_migration.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Test script to verify the hot water config migration from JSON to individual fields. 4 | This script tests the migration logic to ensure no data loss occurs. 5 | """ 6 | 7 | import json 8 | import sys 9 | import os 10 | 11 | # Add the custom_components directory to the path 12 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'custom_components', 'priceanalyzer')) 13 | 14 | from const import ( 15 | HOT_WATER_CONFIG, HOT_WATER_DEFAULT_CONFIG, TEMP_DEFAULT, TEMP_FIVE_MOST_EXPENSIVE, 16 | TEMP_IS_FALLING, TEMP_FIVE_CHEAPEST, TEMP_TEN_CHEAPEST, TEMP_LOW_PRICE, 17 | TEMP_NOT_CHEAP_NOT_EXPENSIVE, TEMP_MINIMUM 18 | ) 19 | 20 | def test_migration_logic(): 21 | """Test the migration logic from config_flow.py""" 22 | 23 | def _migrate_hot_water_config(existing_config): 24 | """Migrate existing JSON hot water config to individual fields""" 25 | if existing_config is None: 26 | return {} 27 | 28 | # If we have individual fields already, return as-is 29 | if any(key.startswith('temp_') for key in existing_config.keys()): 30 | return existing_config 31 | 32 | # Migrate from JSON config 33 | migrated = existing_config.copy() 34 | hot_water_json = existing_config.get(HOT_WATER_CONFIG) 35 | 36 | if hot_water_json: 37 | try: 38 | hot_water_dict = json.loads(hot_water_json) if isinstance(hot_water_json, str) else hot_water_json 39 | 40 | # Map JSON keys to individual config keys 41 | migrated['temp_default'] = hot_water_dict.get(TEMP_DEFAULT, HOT_WATER_DEFAULT_CONFIG[TEMP_DEFAULT]) 42 | migrated['temp_five_most_expensive'] = hot_water_dict.get(TEMP_FIVE_MOST_EXPENSIVE, HOT_WATER_DEFAULT_CONFIG[TEMP_FIVE_MOST_EXPENSIVE]) 43 | migrated['temp_is_falling'] = hot_water_dict.get(TEMP_IS_FALLING, HOT_WATER_DEFAULT_CONFIG[TEMP_IS_FALLING]) 44 | migrated['temp_five_cheapest'] = hot_water_dict.get(TEMP_FIVE_CHEAPEST, HOT_WATER_DEFAULT_CONFIG[TEMP_FIVE_CHEAPEST]) 45 | migrated['temp_ten_cheapest'] = hot_water_dict.get(TEMP_TEN_CHEAPEST, HOT_WATER_DEFAULT_CONFIG[TEMP_TEN_CHEAPEST]) 46 | migrated['temp_low_price'] = hot_water_dict.get(TEMP_LOW_PRICE, HOT_WATER_DEFAULT_CONFIG[TEMP_LOW_PRICE]) 47 | migrated['temp_not_cheap_not_expensive'] = hot_water_dict.get(TEMP_NOT_CHEAP_NOT_EXPENSIVE, HOT_WATER_DEFAULT_CONFIG[TEMP_NOT_CHEAP_NOT_EXPENSIVE]) 48 | migrated['temp_minimum'] = hot_water_dict.get(TEMP_MINIMUM, HOT_WATER_DEFAULT_CONFIG[TEMP_MINIMUM]) 49 | 50 | # Remove old JSON config 51 | migrated.pop(HOT_WATER_CONFIG, None) 52 | except (json.JSONDecodeError, TypeError): 53 | # If JSON parsing fails, use defaults 54 | pass 55 | 56 | return migrated 57 | 58 | def test_get_config_key(config, key): 59 | """Test the get_config_key logic from sensor.py""" 60 | individual_key_map = { 61 | TEMP_DEFAULT: 'temp_default', 62 | TEMP_FIVE_MOST_EXPENSIVE: 'temp_five_most_expensive', 63 | TEMP_IS_FALLING: 'temp_is_falling', 64 | TEMP_FIVE_CHEAPEST: 'temp_five_cheapest', 65 | TEMP_TEN_CHEAPEST: 'temp_ten_cheapest', 66 | TEMP_LOW_PRICE: 'temp_low_price', 67 | TEMP_NOT_CHEAP_NOT_EXPENSIVE: 'temp_not_cheap_not_expensive', 68 | TEMP_MINIMUM: 'temp_minimum' 69 | } 70 | 71 | # Check if we have the individual field (new format) 72 | if key in individual_key_map: 73 | individual_key = individual_key_map[key] 74 | if individual_key in config: 75 | return config[individual_key] 76 | 77 | # Fall back to JSON format (old format) for backward compatibility 78 | hot_water_config = config.get(HOT_WATER_CONFIG, "") 79 | config_dict = {} 80 | if hot_water_config: 81 | try: 82 | config_dict = json.loads(hot_water_config) 83 | except (json.JSONDecodeError, TypeError): 84 | config_dict = HOT_WATER_DEFAULT_CONFIG 85 | else: 86 | config_dict = HOT_WATER_DEFAULT_CONFIG 87 | 88 | if key in config_dict.keys(): 89 | return config_dict[key] 90 | else: 91 | return HOT_WATER_DEFAULT_CONFIG[key] 92 | 93 | print("Testing Hot Water Config Migration...") 94 | print("=" * 50) 95 | 96 | # Test 1: Migration from JSON config 97 | print("\n1. Testing migration from JSON config:") 98 | original_json_config = { 99 | "region": "Kr.sand", 100 | HOT_WATER_CONFIG: json.dumps({ 101 | TEMP_DEFAULT: 80, 102 | TEMP_FIVE_MOST_EXPENSIVE: 35, 103 | TEMP_IS_FALLING: 55, 104 | TEMP_FIVE_CHEAPEST: 75, 105 | TEMP_TEN_CHEAPEST: 70, 106 | TEMP_LOW_PRICE: 65, 107 | TEMP_NOT_CHEAP_NOT_EXPENSIVE: 45, 108 | TEMP_MINIMUM: 80 109 | }) 110 | } 111 | 112 | migrated = _migrate_hot_water_config(original_json_config) 113 | print(f"Original JSON config keys: {list(original_json_config.keys())}") 114 | print(f"Migrated config keys: {list(migrated.keys())}") 115 | 116 | # Verify all individual fields are present 117 | expected_fields = ['temp_default', 'temp_five_most_expensive', 'temp_is_falling', 118 | 'temp_five_cheapest', 'temp_ten_cheapest', 'temp_low_price', 119 | 'temp_not_cheap_not_expensive', 'temp_minimum'] 120 | 121 | all_present = all(field in migrated for field in expected_fields) 122 | print(f"All individual fields present: {all_present}") 123 | print(f"JSON config removed: {HOT_WATER_CONFIG not in migrated}") 124 | 125 | # Test 2: Already migrated config (should remain unchanged) 126 | print("\n2. Testing already migrated config:") 127 | already_migrated = { 128 | "region": "Kr.sand", 129 | "temp_default": 85, 130 | "temp_five_most_expensive": 40 131 | } 132 | 133 | result = _migrate_hot_water_config(already_migrated) 134 | unchanged = result == already_migrated 135 | print(f"Already migrated config unchanged: {unchanged}") 136 | 137 | # Test 3: Test get_config_key with both formats 138 | print("\n3. Testing get_config_key compatibility:") 139 | 140 | # Test with new individual fields 141 | new_config = migrated 142 | temp_default_new = test_get_config_key(new_config, TEMP_DEFAULT) 143 | print(f"temp_default from new format: {temp_default_new}") 144 | 145 | # Test with old JSON format 146 | old_config = original_json_config 147 | temp_default_old = test_get_config_key(old_config, TEMP_DEFAULT) 148 | print(f"temp_default from old format: {temp_default_old}") 149 | 150 | # They should be the same 151 | values_match = temp_default_new == temp_default_old 152 | print(f"Values match between formats: {values_match}") 153 | 154 | # Test 4: Test all temperature keys 155 | print("\n4. Testing all temperature configuration keys:") 156 | test_keys = [TEMP_DEFAULT, TEMP_FIVE_MOST_EXPENSIVE, TEMP_IS_FALLING, 157 | TEMP_FIVE_CHEAPEST, TEMP_TEN_CHEAPEST, TEMP_LOW_PRICE, 158 | TEMP_NOT_CHEAP_NOT_EXPENSIVE, TEMP_MINIMUM] 159 | 160 | all_match = True 161 | for key in test_keys: 162 | old_value = test_get_config_key(original_json_config, key) 163 | new_value = test_get_config_key(migrated, key) 164 | match = old_value == new_value 165 | all_match = all_match and match 166 | print(f" {key}: old={old_value}, new={new_value}, match={match}") 167 | 168 | print(f"\nAll values match: {all_match}") 169 | 170 | # Test 5: Test with missing/invalid JSON 171 | print("\n5. Testing with invalid JSON config:") 172 | invalid_config = { 173 | "region": "Kr.sand", 174 | HOT_WATER_CONFIG: "invalid json" 175 | } 176 | 177 | try: 178 | migrated_invalid = _migrate_hot_water_config(invalid_config) 179 | temp_from_invalid = test_get_config_key(migrated_invalid, TEMP_DEFAULT) 180 | expected_default = HOT_WATER_DEFAULT_CONFIG[TEMP_DEFAULT] 181 | fallback_works = temp_from_invalid == expected_default 182 | print(f"Fallback to defaults works: {fallback_works}") 183 | print(f"Default value: {temp_from_invalid}") 184 | except Exception as e: 185 | print(f"Error handling invalid JSON: {e}") 186 | 187 | print("\n" + "=" * 50) 188 | print("Migration test completed!") 189 | 190 | return all_match and all_present and unchanged and values_match and fallback_works 191 | 192 | if __name__ == "__main__": 193 | success = test_migration_logic() 194 | if success: 195 | print("✅ All tests passed! Migration logic works correctly.") 196 | sys.exit(0) 197 | else: 198 | print("❌ Some tests failed! Please check the migration logic.") 199 | sys.exit(1) 200 | -------------------------------------------------------------------------------- /custom_components/priceanalyzer/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Basic Setup", 6 | "description": "Configure the basic settings for your PriceAnalyzer sensor. Select your region and currency to get started.", 7 | "data": { 8 | "region": "Region", 9 | "currency": "Currency (leave empty for default)", 10 | "VAT": "Include VAT in prices", 11 | "time_resolution": "Time Resolution" 12 | }, 13 | "data_description": { 14 | "region": "Select your electricity price region (e.g., NO1, SE3, DK1)", 15 | "currency": "Override the default currency for your region if needed", 16 | "VAT": "Include Value Added Tax in the displayed prices", 17 | "time_resolution": "Choose between hourly or quarterly (15-minute) price updates" 18 | } 19 | }, 20 | "price_settings": { 21 | "title": "Price Settings", 22 | "description": "Configure how prices are displayed and calculated. You can add additional costs like grid fees and customize the price format.", 23 | "data": { 24 | "price_type": "Price Format", 25 | "price_in_cents": "Display prices in cents/øre", 26 | "low_price_cutoff": "Low Price Threshold", 27 | "additional_costs": "Additional Costs Template" 28 | }, 29 | "data_description": { 30 | "price_type": "Display price per kWh, MWh, or Wh", 31 | "price_in_cents": "Show prices in cents/øre instead of main currency unit", 32 | "low_price_cutoff": "Multiplier for determining low price periods (1.0 = average price)", 33 | "additional_costs": "Jinja2 template to add fixed costs, grid fees, etc. Example: {{0.50|float}} adds 0.50 to each price" 34 | } 35 | }, 36 | "advanced_settings": { 37 | "title": "Advanced Settings", 38 | "description": "Fine-tune the PriceAnalyzer behavior with advanced options for price corrections, boost/save hours, and activation thresholds.", 39 | "data": { 40 | "multiply_template": "Price Correction Template", 41 | "hours_to_boost": "Hours to Boost", 42 | "hours_to_save": "Hours to Save", 43 | "pa_price_before_active": "PriceAnalyzer Activation Threshold", 44 | "percent_difference": "Minimum Daily Price Variation (%)", 45 | "price_before_active": "Hot Water Activation Threshold" 46 | }, 47 | "data_description": { 48 | "multiply_template": "Jinja2 template to multiply/correct prices. Use 'correction' variable. Example: {{correction * 1.25}}", 49 | "hours_to_boost": "Number of cheapest hours to boost consumption", 50 | "hours_to_save": "Number of most expensive hours to reduce consumption", 51 | "pa_price_before_active": "Minimum max daily price before PriceAnalyzer sensor activates (in your currency)", 52 | "percent_difference": "Minimum percentage difference between daily min/max price to activate features", 53 | "price_before_active": "Minimum max daily price before Hot Water optimization activates (in your currency)" 54 | } 55 | }, 56 | "hot_water": { 57 | "title": "Hot Water Temperature Control", 58 | "description": "Configure hot water heater temperatures for different price scenarios. The system will automatically adjust based on electricity prices.", 59 | "data": { 60 | "temp_default": "Default Temperature (°C)", 61 | "temp_five_most_expensive": "5 Most Expensive Hours (°C)", 62 | "temp_is_falling": "Price Falling (°C)", 63 | "temp_five_cheapest": "5 Cheapest Hours (°C)", 64 | "temp_ten_cheapest": "10 Cheapest Hours (°C)", 65 | "temp_low_price": "Low Price Periods (°C)", 66 | "temp_not_cheap_not_expensive": "Normal Price Periods (°C)", 67 | "temp_minimum": "Minimum Daily Price (°C)" 68 | }, 69 | "data_description": { 70 | "temp_default": "Default temperature when no special conditions apply", 71 | "temp_five_most_expensive": "Reduce temperature during the 5 most expensive hours to save energy", 72 | "temp_is_falling": "Temperature when price trend is falling", 73 | "temp_five_cheapest": "Boost temperature during the 5 cheapest hours", 74 | "temp_ten_cheapest": "Temperature during the 10 cheapest hours", 75 | "temp_low_price": "Temperature during low price periods (below threshold)", 76 | "temp_not_cheap_not_expensive": "Temperature during normal price periods", 77 | "temp_minimum": "Maximum temperature at minimum daily price" 78 | } 79 | }, 80 | "config_menu": { 81 | "title": "Configuration Menu", 82 | "description": "Choose which settings to configure. You can navigate between sections and finish when ready.", 83 | "menu_options": { 84 | "price_settings": "Price Settings", 85 | "advanced_settings": "Advanced Settings", 86 | "hot_water": "Hot Water Control" 87 | } 88 | }, 89 | "finish": { 90 | "title": "Configuration Complete", 91 | "description": "Your PriceAnalyzer sensor is ready to use!" 92 | } 93 | }, 94 | "error": { 95 | "name_exists": "Name already exists", 96 | "invalid_template": "The template is invalid, check https://github.com/custom-components/nordpool" 97 | } 98 | }, 99 | "options": { 100 | "step": { 101 | "basic_setup": { 102 | "title": "Basic Setup", 103 | "description": "Configure the basic settings for your PriceAnalyzer sensor. Select your region and currency to get started.", 104 | "data": { 105 | "region": "Region", 106 | "currency": "Currency (leave empty for default)", 107 | "VAT": "Include VAT in prices", 108 | "time_resolution": "Time Resolution" 109 | }, 110 | "data_description": { 111 | "region": "Select your electricity price region (e.g., NO1, SE3, DK1)", 112 | "currency": "Override the default currency for your region if needed", 113 | "VAT": "Include Value Added Tax in the displayed prices", 114 | "time_resolution": "Choose between hourly or quarterly (15-minute) price updates" 115 | } 116 | }, 117 | "price_settings": { 118 | "title": "Price Settings", 119 | "description": "Configure how prices are displayed and calculated. You can add additional costs like grid fees and customize the price format.", 120 | "data": { 121 | "price_type": "Price Format", 122 | "price_in_cents": "Display prices in cents/øre", 123 | "low_price_cutoff": "Low Price Threshold", 124 | "additional_costs": "Additional Costs Template" 125 | }, 126 | "data_description": { 127 | "price_type": "Display price per kWh, MWh, or Wh", 128 | "price_in_cents": "Show prices in cents/øre instead of main currency unit", 129 | "low_price_cutoff": "Multiplier for determining low price periods (1.0 = average price)", 130 | "additional_costs": "Jinja2 template to add fixed costs, grid fees, etc. Example: {{0.50|float}} adds 0.50 to each price" 131 | } 132 | }, 133 | "advanced_settings": { 134 | "title": "Advanced Settings", 135 | "description": "Fine-tune the PriceAnalyzer behavior with advanced options for price corrections, boost/save hours, and activation thresholds.", 136 | "data": { 137 | "multiply_template": "Price Correction Template", 138 | "hours_to_boost": "Hours to Boost", 139 | "hours_to_save": "Hours to Save", 140 | "pa_price_before_active": "PriceAnalyzer Activation Threshold", 141 | "percent_difference": "Minimum Daily Price Variation (%)", 142 | "price_before_active": "Hot Water Activation Threshold" 143 | }, 144 | "data_description": { 145 | "multiply_template": "Jinja2 template to multiply/correct prices. Use 'correction' variable. Example: {{correction * 1.25}}", 146 | "hours_to_boost": "Number of cheapest hours to boost consumption", 147 | "hours_to_save": "Number of most expensive hours to reduce consumption", 148 | "pa_price_before_active": "Minimum max daily price before PriceAnalyzer sensor activates (in your currency)", 149 | "percent_difference": "Minimum percentage difference between daily min/max price to activate features", 150 | "price_before_active": "Minimum max daily price before Hot Water optimization activates (in your currency)" 151 | } 152 | }, 153 | "hot_water": { 154 | "title": "Hot Water Temperature Control", 155 | "description": "Configure hot water heater temperatures for different price scenarios. The system will automatically adjust based on electricity prices.", 156 | "data": { 157 | "temp_default": "Default Temperature (°C)", 158 | "temp_five_most_expensive": "5 Most Expensive Hours (°C)", 159 | "temp_is_falling": "Price Falling (°C)", 160 | "temp_five_cheapest": "5 Cheapest Hours (°C)", 161 | "temp_ten_cheapest": "10 Cheapest Hours (°C)", 162 | "temp_low_price": "Low Price Periods (°C)", 163 | "temp_not_cheap_not_expensive": "Normal Price Periods (°C)", 164 | "temp_minimum": "Minimum Daily Price (°C)" 165 | }, 166 | "data_description": { 167 | "temp_default": "Default temperature when no special conditions apply", 168 | "temp_five_most_expensive": "Reduce temperature during the 5 most expensive hours to save energy", 169 | "temp_is_falling": "Temperature when price trend is falling", 170 | "temp_five_cheapest": "Boost temperature during the 5 cheapest hours", 171 | "temp_ten_cheapest": "Temperature during the 10 cheapest hours", 172 | "temp_low_price": "Temperature during low price periods (below threshold)", 173 | "temp_not_cheap_not_expensive": "Temperature during normal price periods", 174 | "temp_minimum": "Maximum temperature at minimum daily price" 175 | } 176 | }, 177 | "options_menu": { 178 | "title": "Options Menu", 179 | "description": "Choose which settings to modify. You can navigate between sections and save when ready.", 180 | "menu_options": { 181 | "basic_setup": "Basic Setup", 182 | "price_settings": "Price Settings", 183 | "advanced_settings": "Advanced Settings", 184 | "hot_water": "Hot Water Control", 185 | "finish": "Save Changes" 186 | } 187 | }, 188 | "finish": { 189 | "title": "Save Configuration", 190 | "description": "Your changes will be saved." 191 | } 192 | }, 193 | "error": { 194 | "name_exists": "Name already exists", 195 | "invalid_template": "The template is invalid, check https://github.com/custom-components/nordpool" 196 | } 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PriceAnalyzer - Smart Energy Management for Home Assistant 2 | 3 | An intelligent Home Assistant integration that optimizes your energy consumption based on Nordpool electricity prices. Automatically control thermostats and hot water heaters to minimize costs while maintaining comfort. 4 | 5 | Originally based on the excellent [nordpool custom component](https://github.com/custom-components/nordpool). 6 | 7 | ![PriceAnalyzer Screenshot](https://user-images.githubusercontent.com/18458417/197388506-d623cb01-141d-4e44-b384-5a3aa21975a0.png) 8 | 9 | ## What does it do? 10 | 11 | PriceAnalyzer analyzes hourly (or 15-minute) electricity prices from Nordpool and provides smart sensors that help you: 12 | - **Save money** by shifting energy consumption to cheaper hours 13 | - **Optimize comfort** by preheating during low-price periods 14 | - **Reduce environmental impact** by using electricity when it's greenest (often correlates with price) 15 | 16 | ### Support the Project 17 | 18 | If you find this useful: 19 | - [Buy me a beer](http://paypal.me/erlendsellie) or a [Ko-fi](https://ko-fi.com/erlendsellie) 20 | - Support on [Patreon](https://www.patreon.com/erlendsellie) [![Patreon](https://c5.patreon.com/external/logo/become_a_patron_button.png)](https://www.patreon.com/erlendsellie) 21 | - Get [50 EUR off Tibber smart home gadgets](https://invite.tibber.com/yuxfw0uu) (referral link) 22 | - Thinking about a Tesla? Get discounts using my [referral link](https://ts.la/erlend56568) 23 | 24 | --- 25 | 26 | ## Features 27 | 28 | ### 🌡️ Climate Control Sensor (PriceAnalyzerSensor) 29 | Provides intelligent temperature correction recommendations (±1°C) for your thermostats based on current and upcoming electricity prices. 30 | 31 | **How it works:** 32 | - **Pre-heating**: Increases temperature when prices are about to rise 33 | - **Energy saving**: Reduces temperature during price peaks or when prices are falling 34 | - **Smart timing**: Looks ahead at the next few hours to optimize comfort and cost 35 | 36 | **Sensor attributes include:** 37 | - `is_ten_cheapest` / `is_five_cheapest` / `is_two_cheapest` - Boolean flags for cheapest hours 38 | - `ten_cheapest_today` / `five_cheapest_today` / `two_cheapest_today` - Lists of cheapest hours 39 | - `is_gaining` / `is_falling` - Price trend indicators 40 | - `is_over_peak` / `is_over_average` - Price level indicators 41 | - `temperature_correction` - Recommended adjustment for your thermostat 42 | - Full price data for today and tomorrow 43 | 44 | ### 💧 Hot Water Heater Sensor (VVBSensor) 45 | Calculates optimal water heater temperatures based on electricity prices to ensure you always have hot water while minimizing costs. 46 | 47 | **Default temperature strategy:** 48 | - **75°C** - Default and minimum price hours (always hot water) 49 | - **70°C** - Five cheapest hours 50 | - **65°C** - Ten cheapest hours 51 | - **60°C** - Low price hours 52 | - **50°C** - Normal hours and falling prices 53 | - **40°C** - Five most expensive hours (minimum safe temperature) 54 | 55 | You can customize all these temperatures in the integration settings to match your hot water heater capacity, insulation, and household usage patterns. 56 | 57 | **Binary mode:** Can also be configured as simple ON/OFF if you don't have temperature control. 58 | 59 | ### 💰 Price Sensor (PriceSensor) 60 | Displays the current electricity price with your configured additional costs (grid fees, taxes, etc.) applied. 61 | 62 | --- 63 | 64 | ## Installation 65 | 66 | ### Via HACS (Recommended) 67 | 68 | 1. Open HACS in your Home Assistant 69 | 2. Click the three dots (⋮) in the top right corner 70 | 3. Select **Custom repositories** 71 | 4. Add this repository URL: `https://github.com/erlendsellie/priceanalyzer/` 72 | 5. Select **Integration** as the category 73 | 6. Click **Add** 74 | 7. Search for "PriceAnalyzer" and click **Download** 75 | 8. **Restart Home Assistant** 76 | 9. Go to **Settings** → **Devices & Services** → **Add Integration** → Search for "PriceAnalyzer" 77 | 78 | ### Manual Installation 79 | 80 | 1. Copy the `custom_components/priceanalyzer` folder to your Home Assistant `config/custom_components/` directory 81 | 2. Restart Home Assistant 82 | 3. Go to **Settings** → **Devices & Services** → **Add Integration** → Search for "PriceAnalyzer" 83 | 84 | --- 85 | 86 | ## Configuration 87 | 88 | ### Basic Setup 89 | 90 | When adding the integration through the UI, you'll configure: 91 | 92 | **Step 1: Basic Settings** 93 | - **Friendly name** (optional): Custom name to distinguish multiple setups 94 | - **Region**: Your Nordpool price area (e.g., NO1, NO2, SE3, DK1, etc.) 95 | - **Currency**: Your preferred currency (auto-detected based on region) 96 | - **Include VAT**: Whether to include VAT in prices 97 | - **Time resolution**: 98 | - `hourly` - Standard hourly prices (default) 99 | - `quarterly` - 15-minute price intervals (for compatible regions) 100 | 101 | **Step 2: Price Settings** 102 | - **Price type**: Display as kWh, MWh, or Wh 103 | - **Show in cents**: Display prices in cents/øre instead of currency 104 | - **Low price cutoff**: Multiplier for determining "low price" (default: 1.0 = average price) 105 | - **Additional costs template**: Jinja2 template for grid fees, taxes, etc. 106 | - Example: `{{0.50|float}}` adds 0.50 to the price 107 | - Example: `{{current_price * 0.25}}` adds 25% markup 108 | 109 | **Step 3: Advanced Settings** 110 | - **Multiply template**: Adjustment factor for temperature correction 111 | - **Hours to boost/save**: Look-ahead window for price trends 112 | - **Percent difference**: Minimum price variation threshold 113 | - **Price before active**: Minimum price to activate features 114 | 115 | **Step 4: Hot Water Temperature Settings** 116 | Configure target temperatures for different price scenarios: 117 | - **Default temperature**: Normal operating temperature (default: 75°C) 118 | - **Five most expensive hours**: Minimum temperature during peaks (default: 40°C) 119 | - **Price falling**: Temperature when price is declining (default: 50°C) 120 | - **Five cheapest hours**: Maximum temperature for cheap hours (default: 70°C) 121 | - **Ten cheapest hours**: Temperature for top 10 cheap hours (default: 65°C) 122 | - **Low price hours**: Temperature below average price (default: 60°C) 123 | - **Normal hours**: Temperature for average prices (default: 50°C) 124 | - **Minimum price**: Temperature at lowest daily price (default: 75°C) 125 | 126 | **For binary control:** Use values like `1.0` (ON) and `0.0` (OFF) instead of temperatures. 127 | 128 | ### Multiple Setups 129 | 130 | You can create multiple PriceAnalyzer integrations for the same region with different configurations. This is useful for: 131 | - Different additional costs calculations 132 | - Separate hot water heater configurations 133 | - Testing different strategies 134 | - Multiple households/installations 135 | 136 | Each setup is identified by its friendly name and creates its own set of sensors. 137 | 138 | ### Reconfiguring 139 | 140 | To change settings after initial setup: 141 | 1. Go to **Settings** → **Devices & Services** 142 | 2. Find your PriceAnalyzer integration 143 | 3. Click **Configure** 144 | 4. Make your changes in the multi-step menu 145 | 146 | --- 147 | 148 | ## Usage 149 | 150 | ### Automating Climate Control 151 | 152 | Use PriceAnalyzer to automatically adjust your thermostat based on electricity prices: 153 | 154 | **Step 1:** Create an Input Number helper to store your base temperature 155 | - Click here to create: [![Create Input Helper](https://my.home-assistant.io/badges/helpers.svg)](https://my.home-assistant.io/redirect/helpers/) 156 | - Name it something like "Living Room Base Temperature" 157 | - Set min/max values appropriate for your climate (e.g., 18-24°C) 158 | 159 | **Step 2:** Import the Climate Control Blueprint 160 | - Click here: [![Import Climate Control Blueprint](https://my.home-assistant.io/badges/blueprint_import.svg)](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Ferlendsellie%2Fpriceanalyzer%2Fblob%2Fmaster%2Fblueprints%2Fautomation%2Fpriceanalyzer%2Fpriceanalyzer.yaml) 161 | - Select your input number, PriceAnalyzer sensor, and climate entity 162 | - The automation will adjust your thermostat by ±1°C based on price trends 163 | 164 | **How it works:** 165 | - When prices are about to rise → Pre-heats your home 166 | - During price peaks → Reduces temperature slightly 167 | - When prices are falling → Lowers temperature to save energy 168 | - Your base temperature remains in your input number for manual control 169 | 170 | ### Automating Hot Water Heater 171 | 172 | Use PriceAnalyzer to optimize hot water heating costs: 173 | 174 | **Import the Hot Water Blueprint:** 175 | [![Import Hot Water Blueprint](https://my.home-assistant.io/badges/blueprint_import.svg)](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Ferlendsellie%2Fpriceanalyzer%2Fblob%2Fmaster%2Fblueprints%2Fautomation%2Fpriceanalyzer%2Fpriceanalyzer_vvb.yaml) 176 | 177 | - Select your VVBSensor and hot water heater thermostat/switch 178 | - The automation sets the appropriate temperature based on current price conditions 179 | - Ensures hot water availability while minimizing heating costs 180 | 181 | ### Advanced Usage 182 | 183 | **Using cheapest hours attributes in automations:** 184 | ```yaml 185 | # Example: Run dishwasher during cheapest hours 186 | automation: 187 | - trigger: 188 | - platform: template 189 | value_template: > 190 | {{ state_attr('sensor.priceanalyzer_no3', 'current_hour')['is_two_cheapest'] }} 191 | action: 192 | - service: switch.turn_on 193 | target: 194 | entity_id: switch.dishwasher 195 | ``` 196 | 197 | **Custom templates with additional costs:** 198 | The additional costs template receives `current_price` as a variable: 199 | ```jinja 200 | # Add fixed grid fee + 25% tax 201 | {{ (current_price + 0.50) * 1.25 }} 202 | 203 | # Time-based additional costs 204 | {% if now().hour >= 6 and now().hour < 22 %} 205 | {{ current_price + 0.40 }} {# Day tariff #} 206 | {% else %} 207 | {{ current_price + 0.20 }} {# Night tariff #} 208 | {% endif %} 209 | ``` 210 | 211 | --- 212 | 213 | ## Troubleshooting 214 | 215 | ### Enable Debug Logging 216 | 217 | If you're experiencing issues, enable debug logging to see detailed information: 218 | 219 | **Via UI (Recommended):** 220 | 1. Go to **Settings** → **System** → **Logs** 221 | 2. Click **Configure** for `custom_components.priceanalyzer` 222 | 3. Set level to **Debug** 223 | 224 | **Via configuration.yaml:** 225 | ```yaml 226 | logger: 227 | default: info 228 | logs: 229 | custom_components.priceanalyzer: debug 230 | nordpool: debug # For API communication issues 231 | ``` 232 | 233 | ### Getting Help 234 | 235 | - **Wiki**: https://github.com/erlendsellie/priceanalyzer/wiki 236 | - **Issues**: https://github.com/erlendsellie/priceanalyzer/issues 237 | - **Discussions**: Use GitHub Discussions for questions 238 | 239 | --- 240 | 241 | ## Credits 242 | 243 | Originally based on the excellent [nordpool](https://github.com/custom-components/nordpool) custom component. 244 | 245 | ## License 246 | 247 | MIT License - See LICENSE file for details 248 | -------------------------------------------------------------------------------- /custom_components/priceanalyzer/translations/sk.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Základné nastavenie", 6 | "description": "Nakonfigurujte základné nastavenia pre váš PriceAnalyzer snímač. Vyberte váš región a menu pre začiatok.", 7 | "data": { 8 | "region": "Región", 9 | "currency": "Mena (nechajte prázdne pre predvolené)", 10 | "VAT": "Zahrnúť DPH v cenách", 11 | "time_resolution": "Časové rozlíšenie" 12 | }, 13 | "data_description": { 14 | "region": "Vyberte váš región cien elektriny (napr. SK)", 15 | "currency": "Prepíšte predvolenú menu pre váš región ak je potrebné", 16 | "VAT": "Zahrnúť daň z pridanej hodnoty v zobrazených cenách", 17 | "time_resolution": "Vyberte hodinové alebo štvrťhodinové (15-minútové) aktualizácie cien" 18 | } 19 | }, 20 | "price_settings": { 21 | "title": "Nastavenia cien", 22 | "description": "Nakonfigurujte ako sa ceny zobrazujú a počítajú. Môžete pridať dodatočné náklady ako poplatky za sieť a prispôsobiť formát ceny.", 23 | "data": { 24 | "price_type": "Formát ceny", 25 | "price_in_cents": "Zobraziť ceny v centoch", 26 | "low_price_cutoff": "Prah nízkej ceny", 27 | "additional_costs": "Šablóna dodatočných nákladov" 28 | }, 29 | "data_description": { 30 | "price_type": "Zobraziť cenu za kWh, MWh alebo Wh", 31 | "price_in_cents": "Zobraziť ceny v centoch namiesto hlavnej menovej jednotky", 32 | "low_price_cutoff": "Násobiteľ pre určenie období nízkych cien (1.0 = priemerná cena)", 33 | "additional_costs": "Jinja2 šablóna na pridanie fixných nákladov, poplatkov za sieť atď. Príklad: {{0.50|float}} pridá 0.50 ku každej cene" 34 | } 35 | }, 36 | "advanced_settings": { 37 | "title": "Pokročilé nastavenia", 38 | "description": "Dolaďte správanie PriceAnalyzer s pokročilými možnosťami pre opravy cien, hodiny zvýšenia/úspory a prahy aktivácie.", 39 | "data": { 40 | "multiply_template": "Šablóna korekcie ceny", 41 | "hours_to_boost": "Hodiny na zvýšenie", 42 | "hours_to_save": "Hodiny na úsporu", 43 | "pa_price_before_active": "Prah aktivácie PriceAnalyzer", 44 | "percent_difference": "Minimálna denná variácia ceny (%)", 45 | "price_before_active": "Prah aktivácie teplej vody" 46 | }, 47 | "data_description": { 48 | "multiply_template": "Jinja2 šablóna na násobenie/opravu cien. Použite premennú 'correction'. Príklad: {{correction * 1.25}}", 49 | "hours_to_boost": "Počet najlacnejších hodín na zvýšenie spotreby", 50 | "hours_to_save": "Počet najdrahších hodín na zníženie spotreby", 51 | "pa_price_before_active": "Minimálna maximálna denná cena pred aktiváciou snímača PriceAnalyzer (vo vašej mene)", 52 | "percent_difference": "Minimálny percentuálny rozdiel medzi denným min/max cenou na aktiváciu funkcií", 53 | "price_before_active": "Minimálna maximálna denná cena pred optimalizáciou teplej vody (vo vašej mene)" 54 | } 55 | }, 56 | "hot_water": { 57 | "title": "Riadenie teplej vody", 58 | "description": "Nakonfigurujte teploty ohrievača teplej vody pre rôzne cenové scenáre. Systém sa automaticky prispôsobí na základe cien elektriny.", 59 | "data": { 60 | "temp_default": "Predvolená teplota (°C)", 61 | "temp_five_most_expensive": "5 najdrahších hodín (°C)", 62 | "temp_is_falling": "Cena klesá (°C)", 63 | "temp_five_cheapest": "5 najlacnejších hodín (°C)", 64 | "temp_ten_cheapest": "10 najlacnejších hodín (°C)", 65 | "temp_low_price": "Obdobia nízkych cien (°C)", 66 | "temp_not_cheap_not_expensive": "Normálne cenové obdobia (°C)", 67 | "temp_minimum": "Minimálna denná cena (°C)" 68 | }, 69 | "data_description": { 70 | "temp_default": "Predvolená teplota keď neplatia žiadne špeciálne podmienky", 71 | "temp_five_most_expensive": "Znížte teplotu počas 5 najdrahších hodín na úsporu energie", 72 | "temp_is_falling": "Teplota keď cenový trend klesá", 73 | "temp_five_cheapest": "Zvýšte teplotu počas 5 najlacnejších hodín", 74 | "temp_ten_cheapest": "Teplota počas 10 najlacnejších hodín", 75 | "temp_low_price": "Teplota počas období nízkych cien (pod prahom)", 76 | "temp_not_cheap_not_expensive": "Teplota počas normálnych cenových období", 77 | "temp_minimum": "Maximálna teplota pri minimálnej dennej cene" 78 | } 79 | }, 80 | "config_menu": { 81 | "title": "Konfiguračné menu", 82 | "description": "Vyberte, ktoré nastavenia chcete konfigurovať. Môžete prechádzať medzi sekciami a dokončiť, keď ste pripravení.", 83 | "menu_options": { 84 | "price_settings": "Nastavenia cien", 85 | "advanced_settings": "Pokročilé nastavenia", 86 | "hot_water": "Riadenie teplej vody", 87 | "finish": "Dokončiť & Uložiť" 88 | } 89 | }, 90 | "finish": { 91 | "title": "Konfigurácia dokončená", 92 | "description": "Váš PriceAnalyzer snímač je pripravený na použitie!", 93 | "menu_option": "Dokončiť & Uložiť" 94 | } 95 | }, 96 | "error": { 97 | "name_exists": "Názov už existuje", 98 | "invalid_template": "Šablóna je neplatná, skontrolujte https://github.com/custom-components/nordpool" 99 | } 100 | }, 101 | "options": { 102 | "step": { 103 | "basic_setup": { 104 | "title": "Základné nastavenie", 105 | "description": "Nakonfigurujte základné nastavenia pre váš PriceAnalyzer snímač. Vyberte váš región a menu pre začiatok.", 106 | "menu_option": "Základné nastavenie", 107 | "data": { 108 | "region": "Región", 109 | "currency": "Mena (nechajte prázdne pre predvolené)", 110 | "VAT": "Zahrnúť DPH v cenách", 111 | "time_resolution": "Časové rozlíšenie" 112 | }, 113 | "data_description": { 114 | "region": "Vyberte váš región cien elektriny (napr. SK)", 115 | "currency": "Prepíšte predvolenú menu pre váš región ak je potrebné", 116 | "VAT": "Zahrnúť daň z pridanej hodnoty v zobrazených cenách", 117 | "time_resolution": "Vyberte hodinové alebo štvrťhodinové (15-minútové) aktualizácie cien" 118 | } 119 | }, 120 | "price_settings": { 121 | "title": "Nastavenia cien", 122 | "description": "Nakonfigurujte ako sa ceny zobrazujú a počítajú. Môžete pridať dodatočné náklady ako poplatky za sieť a prispôsobiť formát ceny.", 123 | "data": { 124 | "price_type": "Formát ceny", 125 | "price_in_cents": "Zobraziť ceny v centoch", 126 | "low_price_cutoff": "Prah nízkej ceny", 127 | "additional_costs": "Šablóna dodatočných nákladov" 128 | }, 129 | "data_description": { 130 | "price_type": "Zobraziť cenu za kWh, MWh alebo Wh", 131 | "price_in_cents": "Zobraziť ceny v centoch namiesto hlavnej menovej jednotky", 132 | "low_price_cutoff": "Násobiteľ pre určenie období nízkych cien (1.0 = priemerná cena)", 133 | "additional_costs": "Jinja2 šablóna na pridanie fixných nákladov, poplatkov za sieť atď. Príklad: {{0.50|float}} pridá 0.50 ku každej cene" 134 | } 135 | }, 136 | "advanced_settings": { 137 | "title": "Pokročilé nastavenia", 138 | "description": "Dolaďte správanie PriceAnalyzer s pokročilými možnosťami pre opravy cien, hodiny zvýšenia/úspory a prahy aktivácie.", 139 | "data": { 140 | "multiply_template": "Šablóna korekcie ceny", 141 | "hours_to_boost": "Hodiny na zvýšenie", 142 | "hours_to_save": "Hodiny na úsporu", 143 | "pa_price_before_active": "Prah aktivácie PriceAnalyzer", 144 | "percent_difference": "Minimálna denná variácia ceny (%)", 145 | "price_before_active": "Prah aktivácie teplej vody" 146 | }, 147 | "data_description": { 148 | "multiply_template": "Jinja2 šablóna na násobenie/opravu cien. Použite premennú 'correction'. Príklad: {{correction * 1.25}}", 149 | "hours_to_boost": "Počet najlacnejších hodín na zvýšenie spotreby", 150 | "hours_to_save": "Počet najdrahších hodín na zníženie spotreby", 151 | "pa_price_before_active": "Minimálna maximálna denná cena pred aktiváciou snímača PriceAnalyzer (vo vašej mene)", 152 | "percent_difference": "Minimálny percentuálny rozdiel medzi denným min/max cenou na aktiváciu funkcií", 153 | "price_before_active": "Minimálna maximálna denná cena pred optimalizáciou teplej vody (vo vašej mene)" 154 | } 155 | }, 156 | "hot_water": { 157 | "title": "Riadenie teplej vody", 158 | "description": "Nakonfigurujte teploty ohrievača teplej vody pre rôzne cenové scenáre. Systém sa automaticky prispôsobí na základe cien elektriny.", 159 | "data": { 160 | "temp_default": "Predvolená teplota (°C)", 161 | "temp_five_most_expensive": "5 najdrahších hodín (°C)", 162 | "temp_is_falling": "Cena klesá (°C)", 163 | "temp_five_cheapest": "5 najlacnejších hodín (°C)", 164 | "temp_ten_cheapest": "10 najlacnejších hodín (°C)", 165 | "temp_low_price": "Obdobia nízkych cien (°C)", 166 | "temp_not_cheap_not_expensive": "Normálne cenové obdobia (°C)", 167 | "temp_minimum": "Minimálna denná cena (°C)" 168 | }, 169 | "data_description": { 170 | "temp_default": "Predvolená teplota keď neplatia žiadne špeciálne podmienky", 171 | "temp_five_most_expensive": "Znížte teplotu počas 5 najdrahších hodín na úsporu energie", 172 | "temp_is_falling": "Teplota keď cenový trend klesá", 173 | "temp_five_cheapest": "Zvýšte teplotu počas 5 najlacnejších hodín", 174 | "temp_ten_cheapest": "Teplota počas 10 najlacnejších hodín", 175 | "temp_low_price": "Teplota počas období nízkych cien (pod prahom)", 176 | "temp_not_cheap_not_expensive": "Teplota počas normálnych cenových období", 177 | "temp_minimum": "Maximálna teplota pri minimálnej dennej cene" 178 | } 179 | }, 180 | "options_menu": { 181 | "title": "Menu možností", 182 | "description": "Vyberte, ktoré nastavenia chcete zmeniť. Môžete prechádzať medzi sekciami a uložiť, keď ste pripravení.", 183 | "menu_options": { 184 | "basic_setup": "Základné nastavenie", 185 | "price_settings": "Nastavenia cien", 186 | "advanced_settings": "Pokročilé nastavenia", 187 | "hot_water": "Riadenie teplej vody", 188 | "finish": "Uložiť zmeny" 189 | } 190 | }, 191 | "finish": { 192 | "title": "Uložiť konfiguráciu", 193 | "description": "Vaše zmeny budú uložené.", 194 | "menu_option": "Uložiť zmeny" 195 | } 196 | }, 197 | "error": { 198 | "name_exists": "Názov už existuje", 199 | "invalid_template": "Šablóna je neplatná, skontrolujte https://github.com/custom-components/nordpool" 200 | } 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /custom_components/priceanalyzer/translations/et.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Põhiseadistus", 6 | "description": "Seadista PriceAnalyzer anduri põhiseaded. Vali oma piirkond ja valuuta alustamiseks.", 7 | "data": { 8 | "region": "Piirkond", 9 | "currency": "Valuuta (jäta tühjaks vaikimisi jaoks)", 10 | "VAT": "Kaasa käibemaks hindades", 11 | "time_resolution": "Aja resolutsioon" 12 | }, 13 | "data_description": { 14 | "region": "Vali oma elektrihinna piirkond (nt EE, LT, LV)", 15 | "currency": "Tühista piirkonna vaikimisi valuuta vajadusel", 16 | "VAT": "Kaasa käibemaks kuvatud hindades", 17 | "time_resolution": "Vali tunni- või kvartaalipõhine (15-minutiline) hinnavärskendus" 18 | } 19 | }, 20 | "price_settings": { 21 | "title": "Hinnaseaded", 22 | "description": "Seadista hindade kuvamine ja arvutamine. Saad lisada lisatasusid nagu võrgutasud ja kohandada hinnaformaati.", 23 | "data": { 24 | "price_type": "Hinna formaat", 25 | "price_in_cents": "Kuva hinnad sentides", 26 | "low_price_cutoff": "Madala hinna lävi", 27 | "additional_costs": "Lisatasude mall" 28 | }, 29 | "data_description": { 30 | "price_type": "Kuva hind kWh, MWh või Wh kohta", 31 | "price_in_cents": "Kuva hinnad sentides euroe asemel", 32 | "low_price_cutoff": "Kordaja madala hinna perioodide määramiseks (1.0 = keskmine hind)", 33 | "additional_costs": "Jinja2 mall püsikulude, võrgutasude jne lisamiseks. Näide: {{0.50|float}} lisab 0.50 igale hinnale" 34 | } 35 | }, 36 | "advanced_settings": { 37 | "title": "Täpsemad seaded", 38 | "description": "Täpsusta PriceAnalyzer käitumist täpsemate valikutega hinnaparanduste, tõstmis-/säästutundide ja aktiveerimislävedite jaoks.", 39 | "data": { 40 | "multiply_template": "Hinnaparanduse mall", 41 | "hours_to_boost": "Tõstmistunnid", 42 | "hours_to_save": "Säästutunnid", 43 | "pa_price_before_active": "PriceAnalyzer aktiveerimislävi", 44 | "percent_difference": "Minimaalne päevane hinnavahemik (%)", 45 | "price_before_active": "Soojavee aktiveerimislävi" 46 | }, 47 | "data_description": { 48 | "multiply_template": "Jinja2 mall hindade korrutamiseks/parandamiseks. Kasuta 'correction' muutujat. Näide: {{correction * 1.25}}", 49 | "hours_to_boost": "Odavaimate tundide arv tarbimise suurendamiseks", 50 | "hours_to_save": "Kallimate tundide arv tarbimise vähendamiseks", 51 | "pa_price_before_active": "Minimaalne päeva maksimumhind enne PriceAnalyzer anduri aktiveerumist (sinu valuutas)", 52 | "percent_difference": "Minimaalne protsentuaalne erinevus päeva min/max hinna vahel funktsioonide aktiveerimiseks", 53 | "price_before_active": "Minimaalne päeva maksimumhind enne soojavee optimeerimist (sinu valuutas)" 54 | } 55 | }, 56 | "hot_water": { 57 | "title": "Soojavee juhtimine", 58 | "description": "Seadista soojaveesoojendi temperatuurid erinevate hinnastsenaariumide jaoks. Süsteem reguleerib automaatselt elektrihinnade põhjal.", 59 | "data": { 60 | "temp_default": "Vaikimisi temperatuur (°C)", 61 | "temp_five_most_expensive": "5 kallimat tundi (°C)", 62 | "temp_is_falling": "Hind langeb (°C)", 63 | "temp_five_cheapest": "5 odavamat tundi (°C)", 64 | "temp_ten_cheapest": "10 odavamat tundi (°C)", 65 | "temp_low_price": "Madala hinna perioodid (°C)", 66 | "temp_not_cheap_not_expensive": "Normaalsed hinnaperioodid (°C)", 67 | "temp_minimum": "Päeva miinimumhind (°C)" 68 | }, 69 | "data_description": { 70 | "temp_default": "Vaikimisi temperatuur kui eriolukorrad ei kehti", 71 | "temp_five_most_expensive": "Vähenda temperatuuri 5 kallima tunni ajal energia säästmiseks", 72 | "temp_is_falling": "Temperatuur kui hinnatrend langeb", 73 | "temp_five_cheapest": "Tõsta temperatuuri 5 odavama tunni ajal", 74 | "temp_ten_cheapest": "Temperatuur 10 odavama tunni ajal", 75 | "temp_low_price": "Temperatuur madala hinna perioodidel (läve all)", 76 | "temp_not_cheap_not_expensive": "Temperatuur normaalsete hindade ajal", 77 | "temp_minimum": "Maksimaalne temperatuur päeva miinimumhinna ajal" 78 | } 79 | }, 80 | "config_menu": { 81 | "title": "Seadistuse menüü", 82 | "description": "Vali, milliseid seadeid soovid konfigureerida. Saad liikuda sektsioonide vahel ja lõpetada, kui oled valmis.", 83 | "menu_options": { 84 | "price_settings": "Hinnaseaded", 85 | "advanced_settings": "Täpsemad seaded", 86 | "hot_water": "Soojavee juhtimine", 87 | "finish": "Lõpeta & Salvesta" 88 | } 89 | }, 90 | "finish": { 91 | "title": "Seadistus valmis", 92 | "description": "Sinu PriceAnalyzer andur on kasutamiseks valmis!", 93 | "menu_option": "Lõpeta & Salvesta" 94 | } 95 | }, 96 | "error": { 97 | "name_exists": "See nimi on juba kasutusel", 98 | "invalid_template": "Mall on vigane, vaata https://github.com/custom-components/nordpool" 99 | } 100 | }, 101 | "options": { 102 | "step": { 103 | "basic_setup": { 104 | "title": "Põhiseadistus", 105 | "description": "Seadista PriceAnalyzer anduri põhiseaded. Vali oma piirkond ja valuuta alustamiseks.", 106 | "menu_option": "Põhiseadistus", 107 | "data": { 108 | "region": "Piirkond", 109 | "currency": "Valuuta (jäta tühjaks vaikimisi jaoks)", 110 | "VAT": "Kaasa käibemaks hindades", 111 | "time_resolution": "Aja resolutsioon" 112 | }, 113 | "data_description": { 114 | "region": "Vali oma elektrihinna piirkond (nt EE, LT, LV)", 115 | "currency": "Tühista piirkonna vaikimisi valuuta vajadusel", 116 | "VAT": "Kaasa käibemaks kuvatud hindades", 117 | "time_resolution": "Vali tunni- või kvartaalipõhine (15-minutiline) hinnavärskendus" 118 | } 119 | }, 120 | "price_settings": { 121 | "title": "Hinnaseaded", 122 | "description": "Seadista hindade kuvamine ja arvutamine. Saad lisada lisatasusid nagu võrgutasud ja kohandada hinnaformaati.", 123 | "data": { 124 | "price_type": "Hinna formaat", 125 | "price_in_cents": "Kuva hinnad sentides", 126 | "low_price_cutoff": "Madala hinna lävi", 127 | "additional_costs": "Lisatasude mall" 128 | }, 129 | "data_description": { 130 | "price_type": "Kuva hind kWh, MWh või Wh kohta", 131 | "price_in_cents": "Kuva hinnad sentides euroe asemel", 132 | "low_price_cutoff": "Kordaja madala hinna perioodide määramiseks (1.0 = keskmine hind)", 133 | "additional_costs": "Jinja2 mall püsikulude, võrgutasude jne lisamiseks. Näide: {{0.50|float}} lisab 0.50 igale hinnale" 134 | } 135 | }, 136 | "advanced_settings": { 137 | "title": "Täpsemad seaded", 138 | "description": "Täpsusta PriceAnalyzer käitumist täpsemate valikutega hinnaparanduste, tõstmis-/säästutundide ja aktiveerimislävedite jaoks.", 139 | "data": { 140 | "multiply_template": "Hinnaparanduse mall", 141 | "hours_to_boost": "Tõstmistunnid", 142 | "hours_to_save": "Säästutunnid", 143 | "pa_price_before_active": "PriceAnalyzer aktiveerimislävi", 144 | "percent_difference": "Minimaalne päevane hinnavahemik (%)", 145 | "price_before_active": "Soojavee aktiveerimislävi" 146 | }, 147 | "data_description": { 148 | "multiply_template": "Jinja2 mall hindade korrutamiseks/parandamiseks. Kasuta 'correction' muutujat. Näide: {{correction * 1.25}}", 149 | "hours_to_boost": "Odavaimate tundide arv tarbimise suurendamiseks", 150 | "hours_to_save": "Kallimate tundide arv tarbimise vähendamiseks", 151 | "pa_price_before_active": "Minimaalne päeva maksimumhind enne PriceAnalyzer anduri aktiveerumist (sinu valuutas)", 152 | "percent_difference": "Minimaalne protsentuaalne erinevus päeva min/max hinna vahel funktsioonide aktiveerimiseks", 153 | "price_before_active": "Minimaalne päeva maksimumhind enne soojavee optimeerimist (sinu valuutas)" 154 | } 155 | }, 156 | "hot_water": { 157 | "title": "Soojavee juhtimine", 158 | "description": "Seadista soojaveesoojendi temperatuurid erinevate hinnastsenaariumide jaoks. Süsteem reguleerib automaatselt elektrihinnade põhjal.", 159 | "data": { 160 | "temp_default": "Vaikimisi temperatuur (°C)", 161 | "temp_five_most_expensive": "5 kallimat tundi (°C)", 162 | "temp_is_falling": "Hind langeb (°C)", 163 | "temp_five_cheapest": "5 odavamat tundi (°C)", 164 | "temp_ten_cheapest": "10 odavamat tundi (°C)", 165 | "temp_low_price": "Madala hinna perioodid (°C)", 166 | "temp_not_cheap_not_expensive": "Normaalsed hinnaperioodid (°C)", 167 | "temp_minimum": "Päeva miinimumhind (°C)" 168 | }, 169 | "data_description": { 170 | "temp_default": "Vaikimisi temperatuur kui eriolukorrad ei kehti", 171 | "temp_five_most_expensive": "Vähenda temperatuuri 5 kallima tunni ajal energia säästmiseks", 172 | "temp_is_falling": "Temperatuur kui hinnatrend langeb", 173 | "temp_five_cheapest": "Tõsta temperatuuri 5 odavama tunni ajal", 174 | "temp_ten_cheapest": "Temperatuur 10 odavama tunni ajal", 175 | "temp_low_price": "Temperatuur madala hinna perioodidel (läve all)", 176 | "temp_not_cheap_not_expensive": "Temperatuur normaalsete hindade ajal", 177 | "temp_minimum": "Maksimaalne temperatuur päeva miinimumhinna ajal" 178 | } 179 | }, 180 | "options_menu": { 181 | "title": "Seadete menüü", 182 | "description": "Vali, milliseid seadeid soovid muuta. Saad liikuda sektsioonide vahel ja salvestada, kui oled valmis.", 183 | "menu_options": { 184 | "basic_setup": "Põhiseadistus", 185 | "price_settings": "Hinnaseaded", 186 | "advanced_settings": "Täpsemad seaded", 187 | "hot_water": "Soojavee juhtimine", 188 | "finish": "Salvesta muudatused" 189 | } 190 | }, 191 | "finish": { 192 | "title": "Salvesta seadistus", 193 | "description": "Sinu muudatused salvestatakse.", 194 | "menu_option": "Salvesta muudatused" 195 | } 196 | }, 197 | "error": { 198 | "name_exists": "See nimi on juba kasutusel", 199 | "invalid_template": "Mall on vigane, vaata https://github.com/custom-components/nordpool" 200 | } 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /custom_components/priceanalyzer/translations/fi.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Perusasetukset", 6 | "description": "Määritä PriceAnalyzer-sensorin perusasetukset. Valitse alue ja valuutta aloittaaksesi.", 7 | "data": { 8 | "region": "Alue", 9 | "currency": "Valuutta (jätä tyhjäksi oletusarvolle)", 10 | "VAT": "Sisällytä ALV hintoihin", 11 | "time_resolution": "Aikaresoluutio" 12 | }, 13 | "data_description": { 14 | "region": "Valitse sähköhinta-alue (esim. FI)", 15 | "currency": "Ohita alueen oletusvaluutta tarvittaessa", 16 | "VAT": "Sisällytä arvonlisävero näytettyihin hintoihin", 17 | "time_resolution": "Valitse tunti- tai neljännesvuosittainen (15 minuutin) hintapäivitys" 18 | } 19 | }, 20 | "price_settings": { 21 | "title": "Hinta-asetukset", 22 | "description": "Määritä hintojen näyttö ja laskenta. Voit lisätä lisäkustannuksia, kuten verkkomaksuja, ja mukauttaa hintamuotoa.", 23 | "data": { 24 | "price_type": "Hintamuoto", 25 | "price_in_cents": "Näytä hinnat sentteinä", 26 | "low_price_cutoff": "Matalan hinnan kynnys", 27 | "additional_costs": "Lisäkustannusten malli" 28 | }, 29 | "data_description": { 30 | "price_type": "Näytä hinta per kWh, MWh tai Wh", 31 | "price_in_cents": "Näytä hinnat sentteinä eurojen sijaan", 32 | "low_price_cutoff": "Kerroin matalan hinnan jaksojen määrittämiseen (1.0 = keskihinta)", 33 | "additional_costs": "Jinja2-malli kiinteiden kustannusten, verkkomaksujen jne. lisäämiseen. Esimerkki: {{0.50|float}} lisää 0.50 jokaiseen hintaan" 34 | } 35 | }, 36 | "advanced_settings": { 37 | "title": "Lisäasetukset", 38 | "description": "Hienosäädä PriceAnalyzer-toimintaa edistyneillä vaihtoehdoilla hintakorjauksille, tehostus-/säästötunneille ja aktivointikynnysarvoille.", 39 | "data": { 40 | "multiply_template": "Hintakorjausmalli", 41 | "hours_to_boost": "Tehostustunnit", 42 | "hours_to_save": "Säästötunnit", 43 | "pa_price_before_active": "PriceAnalyzer aktivointikynnys", 44 | "percent_difference": "Vähimmäis päivittäinen hintavaihtelu (%)", 45 | "price_before_active": "Lämminvesi aktivointikynnys" 46 | }, 47 | "data_description": { 48 | "multiply_template": "Jinja2-malli hintojen kertomiseen/korjaamiseen. Käytä 'correction'-muuttujaa. Esimerkki: {{correction * 1.25}}", 49 | "hours_to_boost": "Halvimpien tuntien määrä kulutuksen tehostamiseen", 50 | "hours_to_save": "Kalleimpien tuntien määrä kulutuksen vähentämiseen", 51 | "pa_price_before_active": "Vähimmäis päivän enimmäishinta ennen PriceAnalyzer-sensorin aktivoitumista (valuutassasi)", 52 | "percent_difference": "Vähimmäisprosenttiero päivän min/max hinnan välillä toimintojen aktivoimiseksi", 53 | "price_before_active": "Vähimmäis päivän enimmäishinta ennen lämminvesioptimointia (valuutassasi)" 54 | } 55 | }, 56 | "hot_water": { 57 | "title": "Lämminveden ohjaus", 58 | "description": "Määritä lämminvesivaraajan lämpötilat eri hintaskenaarioille. Järjestelmä säätää automaattisesti sähköhintojen perusteella.", 59 | "data": { 60 | "temp_default": "Oletuslämpötila (°C)", 61 | "temp_five_most_expensive": "5 kalleinta tuntia (°C)", 62 | "temp_is_falling": "Hinta laskee (°C)", 63 | "temp_five_cheapest": "5 halvinta tuntia (°C)", 64 | "temp_ten_cheapest": "10 halvinta tuntia (°C)", 65 | "temp_low_price": "Matalan hinnan jaksot (°C)", 66 | "temp_not_cheap_not_expensive": "Normaalit hintajaksot (°C)", 67 | "temp_minimum": "Päivän minimihinta (°C)" 68 | }, 69 | "data_description": { 70 | "temp_default": "Oletuslämpötila kun erityisolosuhteet eivät päde", 71 | "temp_five_most_expensive": "Vähennä lämpötilaa 5 kalleimman tunnin aikana energian säästämiseksi", 72 | "temp_is_falling": "Lämpötila kun hintatrendi laskee", 73 | "temp_five_cheapest": "Nosta lämpötilaa 5 halvimman tunnin aikana", 74 | "temp_ten_cheapest": "Lämpötila 10 halvimman tunnin aikana", 75 | "temp_low_price": "Lämpötila matalan hinnan jaksoilla (kynnysarvon alapuolella)", 76 | "temp_not_cheap_not_expensive": "Lämpötila normaalien hintojen aikana", 77 | "temp_minimum": "Maksimilämpötila päivän minimihinnan aikana" 78 | } 79 | }, 80 | "config_menu": { 81 | "title": "Asetusvalikko", 82 | "description": "Valitse mitkä asetukset haluat määrittää. Voit siirtyä osioiden välillä ja viimeistellä kun olet valmis.", 83 | "menu_options": { 84 | "price_settings": "Hinta-asetukset", 85 | "advanced_settings": "Lisäasetukset", 86 | "hot_water": "Lämminveden ohjaus", 87 | "finish": "Viimeistele & Tallenna" 88 | } 89 | }, 90 | "finish": { 91 | "title": "Määritys valmis", 92 | "description": "PriceAnalyzer-sensorisi on valmis käytettäväksi!", 93 | "menu_option": "Viimeistele & Tallenna" 94 | } 95 | }, 96 | "error": { 97 | "name_exists": "Nimi on jo käytössä", 98 | "invalid_template": "Template on virheellinen, katso https://github.com/custom-components/nordpool" 99 | } 100 | }, 101 | "options": { 102 | "step": { 103 | "basic_setup": { 104 | "title": "Perusasetukset", 105 | "description": "Määritä PriceAnalyzer-sensorin perusasetukset. Valitse alue ja valuutta aloittaaksesi.", 106 | "menu_option": "Perusasetukset", 107 | "data": { 108 | "region": "Alue", 109 | "currency": "Valuutta (jätä tyhjäksi oletusarvolle)", 110 | "VAT": "Sisällytä ALV hintoihin", 111 | "time_resolution": "Aikaresoluutio" 112 | }, 113 | "data_description": { 114 | "region": "Valitse sähköhinta-alue (esim. FI)", 115 | "currency": "Ohita alueen oletusvaluutta tarvittaessa", 116 | "VAT": "Sisällytä arvonlisävero näytettyihin hintoihin", 117 | "time_resolution": "Valitse tunti- tai neljännesvuosittainen (15 minuutin) hintapäivitys" 118 | } 119 | }, 120 | "price_settings": { 121 | "title": "Hinta-asetukset", 122 | "description": "Määritä hintojen näyttö ja laskenta. Voit lisätä lisäkustannuksia, kuten verkkomaksuja, ja mukauttaa hintamuotoa.", 123 | "data": { 124 | "price_type": "Hintamuoto", 125 | "price_in_cents": "Näytä hinnat sentteinä", 126 | "low_price_cutoff": "Matalan hinnan kynnys", 127 | "additional_costs": "Lisäkustannusten malli" 128 | }, 129 | "data_description": { 130 | "price_type": "Näytä hinta per kWh, MWh tai Wh", 131 | "price_in_cents": "Näytä hinnat sentteinä eurojen sijaan", 132 | "low_price_cutoff": "Kerroin matalan hinnan jaksojen määrittämiseen (1.0 = keskihinta)", 133 | "additional_costs": "Jinja2-malli kiinteiden kustannusten, verkkomaksujen jne. lisäämiseen. Esimerkki: {{0.50|float}} lisää 0.50 jokaiseen hintaan" 134 | } 135 | }, 136 | "advanced_settings": { 137 | "title": "Lisäasetukset", 138 | "description": "Hienosäädä PriceAnalyzer-toimintaa edistyneillä vaihtoehdoilla hintakorjauksille, tehostus-/säästötunneille ja aktivointikynnysarvoille.", 139 | "data": { 140 | "multiply_template": "Hintakorjausmalli", 141 | "hours_to_boost": "Tehostustunnit", 142 | "hours_to_save": "Säästötunnit", 143 | "pa_price_before_active": "PriceAnalyzer aktivointikynnys", 144 | "percent_difference": "Vähimmäis päivittäinen hintavaihtelu (%)", 145 | "price_before_active": "Lämminvesi aktivointikynnys" 146 | }, 147 | "data_description": { 148 | "multiply_template": "Jinja2-malli hintojen kertomiseen/korjaamiseen. Käytä 'correction'-muuttujaa. Esimerkki: {{correction * 1.25}}", 149 | "hours_to_boost": "Halvimpien tuntien määrä kulutuksen tehostamiseen", 150 | "hours_to_save": "Kalleimpien tuntien määrä kulutuksen vähentämiseen", 151 | "pa_price_before_active": "Vähimmäis päivän enimmäishinta ennen PriceAnalyzer-sensorin aktivoitumista (valuutassasi)", 152 | "percent_difference": "Vähimmäisprosenttiero päivän min/max hinnan välillä toimintojen aktivoimiseksi", 153 | "price_before_active": "Vähimmäis päivän enimmäishinta ennen lämminvesioptimointia (valuutassasi)" 154 | } 155 | }, 156 | "hot_water": { 157 | "title": "Lämminveden ohjaus", 158 | "description": "Määritä lämminvesivaraajan lämpötilat eri hintaskenaarioille. Järjestelmä säätää automaattisesti sähköhintojen perusteella.", 159 | "data": { 160 | "temp_default": "Oletuslämpötila (°C)", 161 | "temp_five_most_expensive": "5 kalleinta tuntia (°C)", 162 | "temp_is_falling": "Hinta laskee (°C)", 163 | "temp_five_cheapest": "5 halvinta tuntia (°C)", 164 | "temp_ten_cheapest": "10 halvinta tuntia (°C)", 165 | "temp_low_price": "Matalan hinnan jaksot (°C)", 166 | "temp_not_cheap_not_expensive": "Normaalit hintajaksot (°C)", 167 | "temp_minimum": "Päivän minimihinta (°C)" 168 | }, 169 | "data_description": { 170 | "temp_default": "Oletuslämpötila kun erityisolosuhteet eivät päde", 171 | "temp_five_most_expensive": "Vähennä lämpötilaa 5 kalleimman tunnin aikana energian säästämiseksi", 172 | "temp_is_falling": "Lämpötila kun hintatrendi laskee", 173 | "temp_five_cheapest": "Nosta lämpötilaa 5 halvimman tunnin aikana", 174 | "temp_ten_cheapest": "Lämpötila 10 halvimman tunnin aikana", 175 | "temp_low_price": "Lämpötila matalan hinnan jaksoilla (kynnysarvon alapuolella)", 176 | "temp_not_cheap_not_expensive": "Lämpötila normaalien hintojen aikana", 177 | "temp_minimum": "Maksimilämpötila päivän minimihinnan aikana" 178 | } 179 | }, 180 | "options_menu": { 181 | "title": "Asetusvalikko", 182 | "description": "Valitse mitkä asetukset haluat muuttaa. Voit siirtyä osioiden välillä ja tallentaa kun olet valmis.", 183 | "menu_options": { 184 | "basic_setup": "Perusasetukset", 185 | "price_settings": "Hinta-asetukset", 186 | "advanced_settings": "Lisäasetukset", 187 | "hot_water": "Lämminveden ohjaus", 188 | "finish": "Tallenna muutokset" 189 | } 190 | }, 191 | "finish": { 192 | "title": "Tallenna määritys", 193 | "description": "Muutoksesi tallennetaan.", 194 | "menu_option": "Tallenna muutokset" 195 | } 196 | }, 197 | "error": { 198 | "name_exists": "Nimi on jo käytössä", 199 | "invalid_template": "Template on virheellinen, katso https://github.com/custom-components/nordpool" 200 | } 201 | } 202 | } -------------------------------------------------------------------------------- /custom_components/priceanalyzer/translations/nb.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Grunnleggende oppsett", 6 | "description": "Konfigurer de grunnleggende innstillingene for PriceAnalyzer-sensoren. Velg din region og valuta for å komme i gang.", 7 | "data": { 8 | "region": "Region", 9 | "currency": "Valuta (la stå tom for standard)", 10 | "VAT": "Inkluder MVA i priser", 11 | "time_resolution": "Tidsoppløsning" 12 | }, 13 | "data_description": { 14 | "region": "Velg din strømprisregion (f.eks. NO1, NO2, NO3)", 15 | "currency": "Overstyr standard valuta for din region om nødvendig", 16 | "VAT": "Inkluder merverdiavgift i de viste prisene", 17 | "time_resolution": "Velg mellom time- eller kvartersoppløsning (15-minutters) prisoppdateringer" 18 | } 19 | }, 20 | "price_settings": { 21 | "title": "Prisinnstillinger", 22 | "description": "Konfigurer hvordan priser vises og beregnes. Du kan legge til tilleggskostnader som nettleie og tilpasse prisformatet.", 23 | "menu_option": "Prisinnstillinger", 24 | "data": { 25 | "price_type": "Prisformat", 26 | "price_in_cents": "Vis priser i øre", 27 | "low_price_cutoff": "Lavpris-terskel", 28 | "additional_costs": "Mal for tilleggskostnader" 29 | }, 30 | "data_description": { 31 | "price_type": "Vis pris per kWh, MWh eller Wh", 32 | "price_in_cents": "Vis priser i øre i stedet for kroner", 33 | "low_price_cutoff": "Multiplikator for å bestemme lavprisperioder (1.0 = gjennomsnittspris)", 34 | "additional_costs": "Jinja2-mal for å legge til faste kostnader, nettleie, osv. Eksempel: {{0.50|float}} legger til 0.50 til hver pris" 35 | } 36 | }, 37 | "advanced_settings": { 38 | "title": "Avanserte innstillinger", 39 | "description": "Finjuster PriceAnalyzer-oppførselen med avanserte alternativer for priskorrigeringer, boost/spare-timer og aktiveringsterskler.", 40 | "menu_option": "Avanserte innstillinger", 41 | "data": { 42 | "multiply_template": "Priskorreksjonsmal", 43 | "hours_to_boost": "Timer å booste", 44 | "hours_to_save": "Timer å spare", 45 | "pa_price_before_active": "PriceAnalyzer aktiveringsterskel", 46 | "percent_difference": "Minimum daglig prisvariasjon (%)", 47 | "price_before_active": "VVB aktiveringsterskel" 48 | }, 49 | "data_description": { 50 | "multiply_template": "Jinja2-mal for å multiplisere/korrigere priser. Bruk 'correction'-variabelen. Eksempel: {{correction * 1.25}}", 51 | "hours_to_boost": "Antall billigste timer å booste forbruk", 52 | "hours_to_save": "Antall dyreste timer å redusere forbruk", 53 | "pa_price_before_active": "Minimum maks dagspris før PriceAnalyzer-sensor aktiveres (i din valuta)", 54 | "percent_difference": "Minimum prosentvis forskjell mellom daglig min/maks pris for å aktivere funksjoner", 55 | "price_before_active": "Minimum maks dagspris før varmtvannoptimalisering aktiveres (i din valuta)" 56 | } 57 | }, 58 | "hot_water": { 59 | "title": "Varmtvannsstyring", 60 | "description": "Konfigurer varmtvannsberedertempera turer for ulike prisscenarier. Systemet vil automatisk justere basert på strømpriser.", 61 | "menu_option": "Varmtvannsstyring", 62 | "data": { 63 | "temp_default": "Standardtemperatur (°C)", 64 | "temp_five_most_expensive": "5 dyreste timer (°C)", 65 | "temp_is_falling": "Pris faller (°C)", 66 | "temp_five_cheapest": "5 billigste timer (°C)", 67 | "temp_ten_cheapest": "10 billigste timer (°C)", 68 | "temp_low_price": "Lavprisperioder (°C)", 69 | "temp_not_cheap_not_expensive": "Normale prisperioder (°C)", 70 | "temp_minimum": "Minimum dagspris (°C)" 71 | }, 72 | "data_description": { 73 | "temp_default": "Standardtemperatur når ingen spesielle forhold gjelder", 74 | "temp_five_most_expensive": "Reduser temperatur under de 5 dyreste timene for å spare energi", 75 | "temp_is_falling": "Temperatur når pristrenden faller", 76 | "temp_five_cheapest": "Boost temperatur under de 5 billigste timene", 77 | "temp_ten_cheapest": "Temperatur under de 10 billigste timene", 78 | "temp_low_price": "Temperatur under lavprisperioder (under terskel)", 79 | "temp_not_cheap_not_expensive": "Temperatur under normale prisperioder", 80 | "temp_minimum": "Maksimal temperatur ved minimum dagspris" 81 | } 82 | }, 83 | "config_menu": { 84 | "title": "Konfigurasjons meny", 85 | "description": "Velg hvilke innstillinger du vil konfigurere. Du kan navigere mellom seksjoner og fullføre når du er klar.", 86 | "menu_options": { 87 | "price_settings": "Prisinnstillinger", 88 | "advanced_settings": "Avanserte innstillinger", 89 | "hot_water": "Varmtvannsstyring", 90 | "finish": "Fullfør & Lagre" 91 | } 92 | }, 93 | "finish": { 94 | "title": "Konfigurasjon fullført", 95 | "description": "Din PriceAnalyzer-sensor er klar til bruk!", 96 | "menu_option": "Fullfør & Lagre" 97 | } 98 | }, 99 | "error": { 100 | "name_exists": "Navnet eksisterer allerede", 101 | "invalid_template": "Malen inneholder feil, se https://github.com/custom-components/nordpool" 102 | } 103 | }, 104 | "options": { 105 | "step": { 106 | "basic_setup": { 107 | "title": "Grunnleggende oppsett", 108 | "description": "Konfigurer de grunnleggende innstillingene for PriceAnalyzer-sensoren. Velg din region og valuta for å komme i gang.", 109 | "menu_option": "Grunnleggende oppsett", 110 | "data": { 111 | "region": "Region", 112 | "currency": "Valuta (la stå tom for standard)", 113 | "VAT": "Inkluder MVA i priser", 114 | "time_resolution": "Tidsoppløsning" 115 | }, 116 | "data_description": { 117 | "region": "Velg din strømprisregion (f.eks. NO1, NO2, NO3)", 118 | "currency": "Overstyr standard valuta for din region om nødvendig", 119 | "VAT": "Inkluder merverdiavgift i de viste prisene", 120 | "time_resolution": "Velg mellom time- eller kvartersoppløsning (15-minutters) prisoppdateringer" 121 | } 122 | }, 123 | "price_settings": { 124 | "title": "Prisinnstillinger", 125 | "description": "Konfigurer hvordan priser vises og beregnes. Du kan legge til tilleggskostnader som nettleie og tilpasse prisformatet.", 126 | "menu_option": "Prisinnstillinger", 127 | "data": { 128 | "price_type": "Prisformat", 129 | "price_in_cents": "Vis priser i øre", 130 | "low_price_cutoff": "Lavpris-terskel", 131 | "additional_costs": "Mal for tilleggskostnader" 132 | }, 133 | "data_description": { 134 | "price_type": "Vis pris per kWh, MWh eller Wh", 135 | "price_in_cents": "Vis priser i øre i stedet for kroner", 136 | "low_price_cutoff": "Multiplikator for å bestemme lavprisperioder (1.0 = gjennomsnittspris)", 137 | "additional_costs": "Jinja2-mal for å legge til faste kostnader, nettleie, osv. Eksempel: {{0.50|float}} legger til 0.50 til hver pris" 138 | } 139 | }, 140 | "advanced_settings": { 141 | "title": "Avanserte innstillinger", 142 | "description": "Finjuster PriceAnalyzer-oppførselen med avanserte alternativer for priskorrigeringer, boost/spare-timer og aktiveringsterskler.", 143 | "menu_option": "Avanserte innstillinger", 144 | "data": { 145 | "multiply_template": "Priskorreksjonsmal", 146 | "hours_to_boost": "Timer å booste", 147 | "hours_to_save": "Timer å spare", 148 | "pa_price_before_active": "PriceAnalyzer aktiveringsterskel", 149 | "percent_difference": "Minimum daglig prisvariasjon (%)", 150 | "price_before_active": "VVB aktiveringsterskel" 151 | }, 152 | "data_description": { 153 | "multiply_template": "Jinja2-mal for å multiplisere/korrigere priser. Bruk 'correction'-variabelen. Eksempel: {{correction * 1.25}}", 154 | "hours_to_boost": "Antall billigste timer å booste forbruk", 155 | "hours_to_save": "Antall dyreste timer å redusere forbruk", 156 | "pa_price_before_active": "Minimum maks dagspris før PriceAnalyzer-sensor aktiveres (i din valuta)", 157 | "percent_difference": "Minimum prosentvis forskjell mellom daglig min/maks pris for å aktivere funksjoner", 158 | "price_before_active": "Minimum maks dagspris før varmtvannoptimalisering aktiveres (i din valuta)" 159 | } 160 | }, 161 | "hot_water": { 162 | "title": "Varmtvannsstyring", 163 | "description": "Konfigurer varmtvannsberedertempera turer for ulike prisscenarier. Systemet vil automatisk justere basert på strømpriser.", 164 | "menu_option": "Varmtvannsstyring", 165 | "data": { 166 | "temp_default": "Standardtemperatur (°C)", 167 | "temp_five_most_expensive": "5 dyreste timer (°C)", 168 | "temp_is_falling": "Pris faller (°C)", 169 | "temp_five_cheapest": "5 billigste timer (°C)", 170 | "temp_ten_cheapest": "10 billigste timer (°C)", 171 | "temp_low_price": "Lavprisperioder (°C)", 172 | "temp_not_cheap_not_expensive": "Normale prisperioder (°C)", 173 | "temp_minimum": "Minimum dagspris (°C)" 174 | }, 175 | "data_description": { 176 | "temp_default": "Standardtemperatur når ingen spesielle forhold gjelder", 177 | "temp_five_most_expensive": "Reduser temperatur under de 5 dyreste timene for å spare energi", 178 | "temp_is_falling": "Temperatur når pristrenden faller", 179 | "temp_five_cheapest": "Boost temperatur under de 5 billigste timene", 180 | "temp_ten_cheapest": "Temperatur under de 10 billigste timene", 181 | "temp_low_price": "Temperatur under lavprisperioder (under terskel)", 182 | "temp_not_cheap_not_expensive": "Temperatur under normale prisperioder", 183 | "temp_minimum": "Maksimal temperatur ved minimum dagspris" 184 | } 185 | }, 186 | "options_menu": { 187 | "title": "Innstillingsmeny", 188 | "description": "Velg hvilke innstillinger du vil endre. Du kan navigere mellom seksjoner og lagre når du er klar.", 189 | "menu_options": { 190 | "basic_setup": "Grunnleggende oppsett", 191 | "price_settings": "Prisinnstillinger", 192 | "advanced_settings": "Avanserte innstillinger", 193 | "hot_water": "Varmtvannsstyring", 194 | "finish": "Lagre endringer" 195 | } 196 | }, 197 | "finish": { 198 | "title": "Lagre konfigurasjon", 199 | "description": "Dine endringer vil bli lagret.", 200 | "menu_option": "Lagre endringer" 201 | } 202 | }, 203 | "error": { 204 | "name_exists": "Navnet eksisterer allerede", 205 | "invalid_template": "Malen inneholder feil, se https://github.com/custom-components/nordpool" 206 | } 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /custom_components/priceanalyzer/translations/sv.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "basic_setup": { 5 | "title": "Grundläggande inställningar", 6 | "description": "Konfigurera grundläggande inställningar för din PriceAnalyzer-sensor. Välj din region och valuta för att komma igång.", 7 | "menu_option": "Grundläggande inställningar", 8 | "data": { 9 | "region": "Region", 10 | "currency": "Valuta (lämna tom för standard)", 11 | "VAT": "Inkludera moms i priser", 12 | "time_resolution": "Tidsupplösning" 13 | }, 14 | "data_description": { 15 | "region": "Välj din elprisregion (t.ex. SE1, SE2, SE3)", 16 | "currency": "Åsidosätt standardvalutan för din region om det behövs", 17 | "VAT": "Inkludera moms i de visade priserna", 18 | "time_resolution": "Välj mellan tim- eller kvartalsupplösning (15-minuters) prisuppdateringar" 19 | } 20 | }, 21 | "price_settings": { 22 | "title": "Prisinställningar", 23 | "description": "Konfigurera hur priser visas och beräknas. Du kan lägga till extra kostnader som nätavgifter och anpassa prisformatet.", 24 | "menu_option": "Prisinställningar", 25 | "data": { 26 | "price_type": "Prisformat", 27 | "price_in_cents": "Visa priser i ören", 28 | "low_price_cutoff": "Lågpriströskel", 29 | "additional_costs": "Mall för ytterligare kostnader" 30 | }, 31 | "data_description": { 32 | "price_type": "Visa pris per kWh, MWh eller Wh", 33 | "price_in_cents": "Visa priser i ören istället för kronor", 34 | "low_price_cutoff": "Multiplikator för att bestämma lågprisperioder (1.0 = genomsnittspris)", 35 | "additional_costs": "Jinja2-mall för att lägga till fasta kostnader, nätavgifter, osv. Exempel: {{0.50|float}} lägger till 0.50 till varje pris" 36 | } 37 | }, 38 | "advanced_settings": { 39 | "title": "Avancerade inställningar", 40 | "description": "Finjustera PriceAnalyzer-beteendet med avancerade alternativ för priskorrigeringar, boost/spar-timmar och aktiveringströsklar.", 41 | "menu_option": "Avancerade inställningar", 42 | "data": { 43 | "multiply_template": "Priskorrigeringsmall", 44 | "hours_to_boost": "Timmar att boosta", 45 | "hours_to_save": "Timmar att spara", 46 | "pa_price_before_active": "PriceAnalyzer aktiveringströskel", 47 | "percent_difference": "Minsta dagliga prisvariation (%)", 48 | "price_before_active": "Varmvatten aktiveringströskel" 49 | }, 50 | "data_description": { 51 | "multiply_template": "Jinja2-mall för att multiplicera/korrigera priser. Använd 'correction'-variabeln. Exempel: {{correction * 1.25}}", 52 | "hours_to_boost": "Antal billigaste timmar att boosta förbrukning", 53 | "hours_to_save": "Antal dyraste timmar att minska förbrukning", 54 | "pa_price_before_active": "Minsta max dagspris innan PriceAnalyzer-sensor aktiveras (i din valuta)", 55 | "percent_difference": "Minsta procentuell skillnad mellan dagligt min/max pris för att aktivera funktioner", 56 | "price_before_active": "Minsta max dagspris innan varmvattenoptimering aktiveras (i din valuta)" 57 | } 58 | }, 59 | "hot_water": { 60 | "title": "Varmvattenstyrning", 61 | "description": "Konfigurera varmvattenberedare temperaturer för olika prisscenarier. Systemet justerar automatiskt baserat på elpriser.", 62 | "menu_option": "Varmvattenstyrning", 63 | "data": { 64 | "temp_default": "Standardtemperatur (°C)", 65 | "temp_five_most_expensive": "5 dyraste timmarna (°C)", 66 | "temp_is_falling": "Pris faller (°C)", 67 | "temp_five_cheapest": "5 billigaste timmarna (°C)", 68 | "temp_ten_cheapest": "10 billigaste timmarna (°C)", 69 | "temp_low_price": "Lågprisperioder (°C)", 70 | "temp_not_cheap_not_expensive": "Normala prisperioder (°C)", 71 | "temp_minimum": "Minimalt dagspris (°C)" 72 | }, 73 | "data_description": { 74 | "temp_default": "Standardtemperatur när inga speciella förhållanden gäller", 75 | "temp_five_most_expensive": "Minska temperaturen under de 5 dyraste timmarna för att spara energi", 76 | "temp_is_falling": "Temperatur när pristrenden faller", 77 | "temp_five_cheapest": "Boosta temperaturen under de 5 billigaste timmarna", 78 | "temp_ten_cheapest": "Temperatur under de 10 billigaste timmarna", 79 | "temp_low_price": "Temperatur under lågprisperioder (under tröskel)", 80 | "temp_not_cheap_not_expensive": "Temperatur under normala prisperioder", 81 | "temp_minimum": "Maximal temperatur vid minimalt dagspris" 82 | } 83 | }, 84 | "config_menu": { 85 | "title": "Konfigurationsmeny", 86 | "description": "Välj vilka inställningar du vill konfigurera. Du kan navigera mellan sektioner och slutföra när du är klar.", 87 | "menu_options": { 88 | "price_settings": "Prisinställningar", 89 | "advanced_settings": "Avancerade inställningar", 90 | "hot_water": "Varmvattenstyrning", 91 | "finish": "Slutför & Spara" 92 | } 93 | }, 94 | "finish": { 95 | "title": "Konfiguration klar", 96 | "description": "Din PriceAnalyzer-sensor är redo att användas!", 97 | "menu_option": "Slutför & Spara" 98 | } 99 | }, 100 | "error": { 101 | "invalid_template": "Mallen är ogiltig, se https://github.com/custom-components/nordpool" 102 | } 103 | }, 104 | "options": { 105 | "step": { 106 | "basic_setup": { 107 | "title": "Grundläggande inställningar", 108 | "description": "Konfigurera grundläggande inställningar för din PriceAnalyzer-sensor. Välj din region och valuta för att komma igång.", 109 | "menu_option": "Grundläggande inställningar", 110 | "data": { 111 | "region": "Region", 112 | "currency": "Valuta (lämna tom för standard)", 113 | "VAT": "Inkludera moms i priser", 114 | "time_resolution": "Tidsupplösning" 115 | }, 116 | "data_description": { 117 | "region": "Välj din elprisregion (t.ex. SE1, SE2, SE3)", 118 | "currency": "Åsidosätt standardvalutan för din region om det behövs", 119 | "VAT": "Inkludera moms i de visade priserna", 120 | "time_resolution": "Välj mellan tim- eller kvartalsupplösning (15-minuters) prisuppdateringar" 121 | } 122 | }, 123 | "price_settings": { 124 | "title": "Prisinställningar", 125 | "description": "Konfigurera hur priser visas och beräknas. Du kan lägga till extra kostnader som nätavgifter och anpassa prisformatet.", 126 | "menu_option": "Prisinställningar", 127 | "data": { 128 | "price_type": "Prisformat", 129 | "price_in_cents": "Visa priser i ören", 130 | "low_price_cutoff": "Lågpriströskel", 131 | "additional_costs": "Mall för ytterligare kostnader" 132 | }, 133 | "data_description": { 134 | "price_type": "Visa pris per kWh, MWh eller Wh", 135 | "price_in_cents": "Visa priser i ören istället för kronor", 136 | "low_price_cutoff": "Multiplikator för att bestämma lågprisperioder (1.0 = genomsnittspris)", 137 | "additional_costs": "Jinja2-mall för att lägga till fasta kostnader, nätavgifter, osv. Exempel: {{0.50|float}} lägger till 0.50 till varje pris" 138 | } 139 | }, 140 | "advanced_settings": { 141 | "title": "Avancerade inställningar", 142 | "description": "Finjustera PriceAnalyzer-beteendet med avancerade alternativ för priskorrigeringar, boost/spar-timmar och aktiveringströsklar.", 143 | "menu_option": "Avancerade inställningar", 144 | "data": { 145 | "multiply_template": "Priskorrigeringsmall", 146 | "hours_to_boost": "Timmar att boosta", 147 | "hours_to_save": "Timmar att spara", 148 | "pa_price_before_active": "PriceAnalyzer aktiveringströskel", 149 | "percent_difference": "Minsta dagliga prisvariation (%)", 150 | "price_before_active": "Varmvatten aktiveringströskel" 151 | }, 152 | "data_description": { 153 | "multiply_template": "Jinja2-mall för att multiplicera/korrigera priser. Använd 'correction'-variabeln. Exempel: {{correction * 1.25}}", 154 | "hours_to_boost": "Antal billigaste timmar att boosta förbrukning", 155 | "hours_to_save": "Antal dyraste timmar att minska förbrukning", 156 | "pa_price_before_active": "Minsta max dagspris innan PriceAnalyzer-sensor aktiveras (i din valuta)", 157 | "percent_difference": "Minsta procentuell skillnad mellan dagligt min/max pris för att aktivera funktioner", 158 | "price_before_active": "Minsta max dagspris innan varmvattenoptimering aktiveras (i din valuta)" 159 | } 160 | }, 161 | "hot_water": { 162 | "title": "Varmvattenstyrning", 163 | "description": "Konfigurera varmvattenberedare temperaturer för olika prisscenarier. Systemet justerar automatiskt baserat på elpriser.", 164 | "menu_option": "Varmvattenstyrning", 165 | "data": { 166 | "temp_default": "Standardtemperatur (°C)", 167 | "temp_five_most_expensive": "5 dyraste timmarna (°C)", 168 | "temp_is_falling": "Pris faller (°C)", 169 | "temp_five_cheapest": "5 billigaste timmarna (°C)", 170 | "temp_ten_cheapest": "10 billigaste timmarna (°C)", 171 | "temp_low_price": "Lågprisperioder (°C)", 172 | "temp_not_cheap_not_expensive": "Normala prisperioder (°C)", 173 | "temp_minimum": "Minimalt dagspris (°C)" 174 | }, 175 | "data_description": { 176 | "temp_default": "Standardtemperatur när inga speciella förhållanden gäller", 177 | "temp_five_most_expensive": "Minska temperaturen under de 5 dyraste timmarna för att spara energi", 178 | "temp_is_falling": "Temperatur när pristrenden faller", 179 | "temp_five_cheapest": "Boosta temperaturen under de 5 billigaste timmarna", 180 | "temp_ten_cheapest": "Temperatur under de 10 billigaste timmarna", 181 | "temp_low_price": "Temperatur under lågprisperioder (under tröskel)", 182 | "temp_not_cheap_not_expensive": "Temperatur under normala prisperioder", 183 | "temp_minimum": "Maximal temperatur vid minimalt dagspris" 184 | } 185 | }, 186 | "options_menu": { 187 | "title": "Inställningsmeny", 188 | "description": "Välj vilka inställningar du vill ändra. Du kan navigera mellan sektioner och spara när du är klar.", 189 | "menu_options": { 190 | "basic_setup": "Grundläggande inställningar", 191 | "price_settings": "Prisinställningar", 192 | "advanced_settings": "Avancerade inställningar", 193 | "hot_water": "Varmvattenstyrning", 194 | "finish": "Spara ändringar" 195 | } 196 | }, 197 | "finish": { 198 | "title": "Spara konfiguration", 199 | "description": "Dina ändringar kommer att sparas.", 200 | "menu_option": "Spara ändringar" 201 | } 202 | }, 203 | "error": { 204 | "name_exists": "Namnet existerar redan", 205 | "invalid_template": "Mallen är ogiltig, se https://github.com/custom-components/nordpool" 206 | } 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /custom_components/priceanalyzer/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections import defaultdict 3 | from datetime import datetime, timedelta 4 | from functools import partial 5 | from random import randint 6 | 7 | import aiohttp 8 | import backoff 9 | import voluptuous as vol 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.helpers.device_registry import DeviceEntry 12 | 13 | from homeassistant.core import HomeAssistant 14 | from homeassistant.core_config import Config 15 | 16 | from homeassistant.components.sensor import PLATFORM_SCHEMA 17 | from homeassistant.const import CONF_REGION, CONF_ID 18 | import homeassistant.helpers.config_validation as cv 19 | from .data import Data 20 | 21 | 22 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 23 | from homeassistant.helpers.dispatcher import async_dispatcher_send 24 | from homeassistant.helpers.event import async_call_later, async_track_time_change 25 | from homeassistant.util import dt as dt_utils 26 | 27 | from pytz import timezone 28 | 29 | 30 | 31 | from .aio_price import AioPrices, InvalidValueException 32 | from .events import async_track_time_change_in_tz 33 | from .const import ( 34 | DOMAIN, 35 | DATA, 36 | RANDOM_MINUTE, 37 | RANDOM_SECOND, 38 | PLATFORM_SCHEMA, 39 | EVENT_NEW_DATA, 40 | EVENT_NEW_HOUR, 41 | API_DATA_LOADED, 42 | _CURRENCY_LIST, 43 | PLATFORMS, 44 | CONFIG_SCHEMA, 45 | NAME, 46 | VERSION, 47 | ISSUEURL, 48 | STARTUP, 49 | _CENT_MULTIPLIER, 50 | _REGIONS, 51 | _CURRENCY_TO_LOCAL, 52 | _CURRENTY_TO_CENTS, 53 | DEFAULT_CURRENCY, 54 | DEFAULT_REGION, 55 | DEFAULT_NAME, 56 | DEFAULT_TEMPLATE, 57 | PLATFORM_SCHEMA 58 | ) 59 | 60 | EVENT_NEW_DAY = "nordpool_update_day" 61 | EVENT_NEW_PRICE = "nordpool_update_new_price" 62 | SENTINEL = object() 63 | 64 | _LOGGER = logging.getLogger(__name__) 65 | 66 | class NordpoolData: 67 | def __init__(self, hass: HomeAssistant, time_resolution: str = "1hour") -> None: 68 | self._hass = hass 69 | self._last_tick = None 70 | self._data = defaultdict(dict) 71 | self._tomorrow_valid = False 72 | self.currency = [] 73 | self.listeners = [] 74 | self.areas = [] 75 | self.time_resolution = time_resolution # "15min" or "1hour" 76 | 77 | async def _update(self, type_="today", dt=None, areas=None): 78 | _LOGGER.debug("calling _update %s %s for areas %s", type_, dt, areas) 79 | start_time = dt_utils.now() 80 | hass = self._hass 81 | # Get the default client session 82 | client = async_get_clientsession(hass) 83 | 84 | if dt is None: 85 | dt = dt_utils.now() 86 | if areas is not None: 87 | self.areas += [area for area in areas if area not in self.areas] 88 | 89 | # We dont really need today and morrow 90 | # when the region is in another timezone 91 | # as we request data for 3 days anyway. 92 | # Keeping this for now, but this should be changed. 93 | for currency in self.currency: 94 | spot = AioPrices(currency, client, time_resolution=self.time_resolution) 95 | data = await spot.hourly(end_date=dt, areas=self.areas if len(self.areas) > 0 else None) 96 | if data: 97 | self._data[currency][type_] = data["areas"] 98 | elapsed = (dt_utils.now() - start_time).total_seconds() 99 | _LOGGER.debug("Successfully fetched %s data for currency %s in %s seconds", 100 | type_, currency, elapsed) 101 | async_dispatcher_send(hass, API_DATA_LOADED) 102 | else: 103 | elapsed = (dt_utils.now() - start_time).total_seconds() 104 | _LOGGER.warning("Data fetch failed for %s after %s seconds, retrying in 20 seconds. Currency: %s, Areas: %s", 105 | type_, elapsed, currency, self.areas) 106 | async_call_later(hass, 20, partial(self._update, type_=type_, dt=dt)) 107 | 108 | async def update_today(self, areas=None): 109 | """Update today's prices""" 110 | _LOGGER.debug("Updating today's prices.") 111 | if areas is not None: 112 | self.areas += [area for area in areas if area not in self.areas] 113 | await self._update("today", areas=self.areas if len(self.areas) > 0 else None) 114 | 115 | async def update_tomorrow(self, areas=None): 116 | """Update tomorrows prices.""" 117 | _LOGGER.debug("Updating tomorrows prices.") 118 | if areas is not None: 119 | self.areas += [area for area in areas if area not in self.areas] 120 | await self._update(type_="tomorrow", dt=dt_utils.now() + timedelta(hours=24), areas=self.areas if len(self.areas) > 0 else None) 121 | 122 | async def _someday(self, area: str, currency: str, day: str): 123 | """Returns today's or tomorrow's prices in an area in the currency""" 124 | if currency not in _CURRENCY_LIST: 125 | raise ValueError( 126 | "%s is an invalid currency, possible values are %s" 127 | % (currency, ", ".join(_CURRENCY_LIST)) 128 | ) 129 | 130 | if area not in self.areas: 131 | self.areas.append(area); 132 | # This is needed as the currency is 133 | # set in the sensor. 134 | if currency not in self.currency: 135 | self.currency.append(currency) 136 | try: 137 | await self.update_today(areas=self.areas) 138 | except InvalidValueException: 139 | _LOGGER.debug("No data available for today, retrying later") 140 | try: 141 | await self.update_tomorrow(areas=self.areas) 142 | except InvalidValueException: 143 | _LOGGER.debug("No data available for tomorrow, retrying later") 144 | 145 | # Send a new data request after new data is updated for this first run 146 | # This way if the user has multiple sensors they will all update 147 | async_dispatcher_send(self._hass, EVENT_NEW_HOUR) 148 | 149 | return self._data.get(currency, {}).get(day, {}).get(area) 150 | 151 | def tomorrow_valid(self) -> bool: 152 | return self._tomorrow_valid 153 | 154 | async def today(self, area: str, currency: str) -> dict: 155 | """Returns todays prices in a area in the requested currency""" 156 | res = await self._someday(area, currency, "today") 157 | return res 158 | 159 | async def tomorrow(self, area: str, currency: str): 160 | """Returns tomorrows prices in a area in the requested currency""" 161 | 162 | dt = dt_utils.now() 163 | if(dt.hour < 11): 164 | return [] 165 | 166 | # TODO Handle when API returns todays prices for tomorrow. 167 | res = await self._someday(area, currency, "tomorrow") 168 | if res and len(res) > 0 and len(res['values']) > 0: 169 | starttime = res['values'][0].get('start', None) 170 | if starttime: 171 | start = dt_utils.as_local(starttime) 172 | _LOGGER.debug("Fetching tomorrow. Start: %s", starttime) 173 | self._tomorrow_valid = True 174 | _LOGGER.debug("Setting Tomrrow Valid to True. Res: %s", res) 175 | return res 176 | # TODO fix this logic. 177 | # if start > dt: 178 | # _LOGGER.debug('The input date is in the future') 179 | # self._tomorrow_valid = True 180 | # _LOGGER.debug("Setting Tomrrow Valid to True. Res: %s", res) 181 | # return res 182 | # else: 183 | # _LOGGER.debug('The input date is in the past') 184 | # return [] 185 | return [] 186 | 187 | 188 | async def _dry_setup(hass: HomeAssistant, configEntry: Config) -> bool: 189 | """Set up using yaml config file.""" 190 | config = configEntry.data 191 | 192 | # Ensure DATA dictionary exists 193 | if DATA not in hass.data: 194 | hass.data[DATA] = {} 195 | _LOGGER.debug("Initialized DATA dictionary") 196 | 197 | if DOMAIN not in hass.data: 198 | # Initialize the API only once 199 | time_resolution = config.get("time_resolution", "hourly") 200 | api = NordpoolData(hass, time_resolution=time_resolution) 201 | hass.data[DOMAIN] = api 202 | _LOGGER.debug("Initialized API with time_resolution: %s", time_resolution) 203 | 204 | 205 | async def new_day_cb(n): 206 | """Cb to handle some house keeping when it a new day.""" 207 | _LOGGER.debug("Called new_day_cb callback") 208 | api._tomorrow_valid = False 209 | 210 | for curr in api.currency: 211 | if not len(api._data[curr]["tomorrow"]): 212 | api._data[curr]["today"] = await api.update_today(None) 213 | else: 214 | api._data[curr]["today"] = api._data[curr]["tomorrow"] 215 | api._data[curr]["tomorrow"] = {} 216 | 217 | async_dispatcher_send(hass, EVENT_NEW_DATA) 218 | 219 | async def new_hr(n): 220 | """Callback to tell the sensors to update on a new hour.""" 221 | _LOGGER.debug("Called new_hr callback") 222 | async_dispatcher_send(hass, EVENT_NEW_HOUR) 223 | 224 | 225 | @backoff.on_exception( 226 | backoff.constant, 227 | (InvalidValueException), 228 | logger=_LOGGER, interval=600, max_time=7200, jitter=None, 229 | on_backoff=lambda details: _LOGGER.warning( 230 | "Tomorrow data fetch failed, retrying in %s seconds (attempt %s): %s", 231 | details['wait'], details['tries'], details['exception'] 232 | ), 233 | on_giveup=lambda details: _LOGGER.error( 234 | "Tomorrow data fetch failed permanently after %s attempts over %s seconds: %s", 235 | details['tries'], details.get('elapsed', 'unknown'), details['exception'] 236 | )) 237 | async def new_data_cb(_): 238 | """Callback to fetch new data for tomorrows prices at 1300ish CET 239 | and notify any sensors, about the new data 240 | """ 241 | _LOGGER.debug("Called new_data_cb - fetching tomorrow's data") 242 | await api.update_tomorrow() 243 | async_dispatcher_send(hass, EVENT_NEW_PRICE) 244 | _LOGGER.debug("Successfully fetched tomorrow's data and sent update event") 245 | 246 | # Handles futures updates 247 | cb_update_tomorrow = async_track_time_change_in_tz( 248 | hass, 249 | new_data_cb, 250 | hour=13, 251 | minute=RANDOM_MINUTE, 252 | second=RANDOM_SECOND, 253 | tz=timezone("Europe/Stockholm"), 254 | ) 255 | 256 | cb_new_day = async_track_time_change( 257 | hass, new_day_cb, hour=0, minute=0, second=0 258 | ) 259 | 260 | # Update interval depends on time resolution 261 | # For quarterly resolution: update every 15 minutes 262 | # For hourly resolution: update every hour 263 | if time_resolution == "quarterly": 264 | cb_new_hr = async_track_time_change( 265 | hass, new_hr, minute=[0, 15, 30, 45], second=0 266 | ) 267 | else: # hourly 268 | cb_new_hr = async_track_time_change( 269 | hass, new_hr, minute=0, second=0 270 | ) 271 | 272 | api.listeners.append(cb_update_tomorrow) 273 | api.listeners.append(cb_new_hr) 274 | api.listeners.append(cb_new_day) 275 | 276 | pa_config = config 277 | api = hass.data[DOMAIN] # Use the existing API instance 278 | region = pa_config.get(CONF_REGION) 279 | friendly_name = pa_config.get("friendly_name", "") 280 | price_type = pa_config.get("price_type", "kWh") # Default to kWh if not specified 281 | 282 | low_price_cutoff = pa_config.get("low_price_cutoff", 1.0) 283 | currency = pa_config.get("currency", "") 284 | vat = pa_config.get("VAT", True) 285 | use_cents = pa_config.get("price_in_cents", False) 286 | ad_template = pa_config.get("additional_costs", "{{0.01|float}}") 287 | multiply_template = pa_config.get("multiply_template", "{{correction * 1}}") 288 | num_hours_to_boost = pa_config.get("hours_to_boost", 2) 289 | num_hours_to_save = pa_config.get("hours_to_save", 2) 290 | percent_difference = pa_config.get("percent_difference", 20) 291 | 292 | # Get entry_id first so we can pass it to Data constructor 293 | entry_id = getattr(configEntry, "entry_id", None) or pa_config.get(CONF_ID) 294 | if entry_id is None: 295 | entry_id = f"{region}_{len(hass.data[DATA])}" 296 | 297 | data = Data( 298 | friendly_name, 299 | region, 300 | price_type, 301 | low_price_cutoff, 302 | currency, 303 | vat, 304 | use_cents, 305 | api, 306 | ad_template, 307 | multiply_template, 308 | num_hours_to_boost, 309 | num_hours_to_save, 310 | percent_difference, 311 | hass, 312 | pa_config, 313 | entry_id 314 | ) 315 | 316 | if entry_id in hass.data[DATA]: 317 | _LOGGER.debug("Replacing existing data for entry %s (region %s)", entry_id, region) 318 | 319 | try: 320 | hass.data[DATA][entry_id] = data 321 | _LOGGER.debug("Successfully set up entry %s for region %s", entry_id, region) 322 | return True 323 | except Exception as e: 324 | _LOGGER.error("Failed to set up entry %s for region %s: %s", entry_id, region, e) 325 | return False 326 | 327 | 328 | async def async_setup(hass: HomeAssistant, config: Config) -> bool: 329 | """Set up using yaml config file.""" 330 | return True 331 | return await _dry_setup(hass, config) 332 | 333 | async def async_migrate_entry(title, domain) -> bool: 334 | #sorry, we dont support migrate 335 | return True 336 | 337 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 338 | """Set up nordpool as config entry.""" 339 | try: 340 | # Set up the data layer first 341 | setup_ok = await _dry_setup(hass, entry) 342 | if not setup_ok: 343 | _LOGGER.error("Failed to set up data layer for priceanalyzer") 344 | return False 345 | 346 | # Set up the platforms (sensors) - ensure this completes before returning 347 | # Support both old (2024.x) and new (2025.11+) HA versions 348 | if hasattr(hass.config_entries, 'async_forward_entry_setups'): 349 | # HA 2025.11+ - use plural version 350 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 351 | else: 352 | # HA 2024.x and older - use singular version 353 | for platform in PLATFORMS: 354 | await hass.config_entries.async_forward_entry_setup(entry, platform) 355 | 356 | # Add update listener only after successful setup 357 | # Use async_on_unload to ensure the listener is removed when entry is unloaded 358 | entry.async_on_unload(entry.add_update_listener(async_reload_entry)) 359 | 360 | _LOGGER.debug("Successfully set up priceanalyzer entry: %s", entry.entry_id) 361 | return True 362 | except Exception as e: 363 | _LOGGER.error("Failed to set up priceanalyzer: %s", e) 364 | # Clean up on failure using entry_id 365 | if DATA in hass.data and entry.entry_id in hass.data[DATA]: 366 | hass.data[DATA].pop(entry.entry_id, None) 367 | _LOGGER.debug("Cleaned up failed setup for entry %s", entry.entry_id) 368 | return False 369 | 370 | # async def async_update_entry(hass: HomeAssistant, entry: ConfigEntry, options): 371 | # res = await hass.config_entries.async_update_entry(entry,options) 372 | # self.async_reload_entry(hass,entry) 373 | # return res 374 | 375 | 376 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 377 | """Unload a config entry.""" 378 | # Unload all platforms - support both old and new HA versions 379 | if hasattr(hass.config_entries, 'async_forward_entry_unloads'): 380 | # HA 2025.11+ - use plural version 381 | unload_ok = await hass.config_entries.async_forward_entry_unloads(entry, PLATFORMS) 382 | else: 383 | # HA 2024.x and older - use singular version 384 | unload_results = [] 385 | for platform in PLATFORMS: 386 | result = await hass.config_entries.async_forward_entry_unload(entry, platform) 387 | unload_results.append(result) 388 | unload_ok = all(unload_results) 389 | 390 | if unload_ok: 391 | # Clean up the entry-specific data using entry_id (not region) 392 | # This allows multiple setups with the same region 393 | entry_id = entry.entry_id 394 | if DATA in hass.data and entry_id in hass.data[DATA]: 395 | hass.data[DATA].pop(entry_id) 396 | _LOGGER.debug("Cleaned up data for entry %s (region: %s)", entry_id, entry.data.get(CONF_REGION)) 397 | 398 | # Clean up the API data if no more entries are configured 399 | if DATA in hass.data and len(hass.data[DATA]) == 0: 400 | if DOMAIN in hass.data: 401 | for unsub in hass.data[DOMAIN].listeners: 402 | unsub() 403 | hass.data.pop(DOMAIN) 404 | _LOGGER.debug("Cleaned up API data as no entries remain") 405 | 406 | return True 407 | 408 | return False 409 | 410 | async def async_remove_config_entry_device( 411 | hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry 412 | ) -> bool: 413 | res = await device_entry.async_unload_entry(hass,config_entry) 414 | return res 415 | 416 | 417 | async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: 418 | """Reload config entry.""" 419 | # Use HA's built-in reload mechanism which handles serialization 420 | # and prevents race conditions with concurrent reloads 421 | await hass.config_entries.async_reload(entry.entry_id) 422 | -------------------------------------------------------------------------------- /custom_components/priceanalyzer/config_flow.py: -------------------------------------------------------------------------------- 1 | """Adds config flow for nordpool.""" 2 | import logging 3 | import re 4 | 5 | from typing import Optional 6 | 7 | from copy import deepcopy 8 | from types import MappingProxyType 9 | 10 | import voluptuous as vol 11 | from homeassistant import config_entries 12 | from homeassistant.core import callback 13 | from homeassistant.helpers.template import is_template_string, Template 14 | from jinja2 import pass_context 15 | from homeassistant.util import dt as dt_utils 16 | 17 | from . import DOMAIN 18 | from .const import (_PRICE_IN, _REGIONS, DEFAULT_TEMPLATE, DEFAULT_TIME_RESOLUTION, HOT_WATER_CONFIG, HOT_WATER_DEFAULT_CONFIG, 19 | HOT_WATER_DEFAULT_CONFIG_JSON, TEMP_DEFAULT, TEMP_FIVE_MOST_EXPENSIVE, TEMP_IS_FALLING, 20 | TEMP_FIVE_CHEAPEST, TEMP_TEN_CHEAPEST, TEMP_LOW_PRICE, TEMP_NOT_CHEAP_NOT_EXPENSIVE, TEMP_MINIMUM) 21 | 22 | regions = sorted(list(_REGIONS.keys())) 23 | currencys = sorted(list(set(v[0] for k, v in _REGIONS.items()))) 24 | price_types = sorted(list(_PRICE_IN.keys())) 25 | time_resolutions = ["quarterly", "hourly"] 26 | 27 | placeholders = { 28 | "region": regions, 29 | "currency": currencys, 30 | "price_type": price_types, 31 | "additional_costs": "{{0.01|float}}", 32 | } 33 | 34 | _LOGGER = logging.getLogger(__name__) 35 | 36 | 37 | def _migrate_hot_water_config(existing_config): 38 | """Migrate existing JSON hot water config to individual fields""" 39 | if existing_config is None: 40 | return {} 41 | 42 | # If we have individual fields already, return as-is 43 | if any(key.startswith('temp_') for key in existing_config.keys()): 44 | return existing_config 45 | 46 | # Migrate from JSON config 47 | migrated = existing_config.copy() 48 | hot_water_json = existing_config.get(HOT_WATER_CONFIG) 49 | 50 | if hot_water_json: 51 | try: 52 | import json 53 | hot_water_dict = json.loads(hot_water_json) if isinstance(hot_water_json, str) else hot_water_json 54 | 55 | # Map JSON keys to individual config keys 56 | migrated['temp_default'] = hot_water_dict.get(TEMP_DEFAULT, HOT_WATER_DEFAULT_CONFIG[TEMP_DEFAULT]) 57 | migrated['temp_five_most_expensive'] = hot_water_dict.get(TEMP_FIVE_MOST_EXPENSIVE, HOT_WATER_DEFAULT_CONFIG[TEMP_FIVE_MOST_EXPENSIVE]) 58 | migrated['temp_is_falling'] = hot_water_dict.get(TEMP_IS_FALLING, HOT_WATER_DEFAULT_CONFIG[TEMP_IS_FALLING]) 59 | migrated['temp_five_cheapest'] = hot_water_dict.get(TEMP_FIVE_CHEAPEST, HOT_WATER_DEFAULT_CONFIG[TEMP_FIVE_CHEAPEST]) 60 | migrated['temp_ten_cheapest'] = hot_water_dict.get(TEMP_TEN_CHEAPEST, HOT_WATER_DEFAULT_CONFIG[TEMP_TEN_CHEAPEST]) 61 | migrated['temp_low_price'] = hot_water_dict.get(TEMP_LOW_PRICE, HOT_WATER_DEFAULT_CONFIG[TEMP_LOW_PRICE]) 62 | migrated['temp_not_cheap_not_expensive'] = hot_water_dict.get(TEMP_NOT_CHEAP_NOT_EXPENSIVE, HOT_WATER_DEFAULT_CONFIG[TEMP_NOT_CHEAP_NOT_EXPENSIVE]) 63 | migrated['temp_minimum'] = hot_water_dict.get(TEMP_MINIMUM, HOT_WATER_DEFAULT_CONFIG[TEMP_MINIMUM]) 64 | 65 | # Remove old JSON config 66 | migrated.pop(HOT_WATER_CONFIG, None) 67 | except (json.JSONDecodeError, TypeError): 68 | # If JSON parsing fails, use defaults 69 | pass 70 | 71 | return migrated 72 | 73 | def get_basic_schema(existing_config = None) -> dict: 74 | """Schema for basic setup step""" 75 | ec = existing_config if existing_config else {} 76 | return { 77 | vol.Optional("friendly_name", default=ec.get("friendly_name", "")): str, 78 | vol.Required("region", default=ec.get("region", None)): vol.In(regions), 79 | vol.Optional("currency", default=ec.get("currency", "")): vol.In(currencys), 80 | vol.Optional("VAT", default=ec.get("VAT", True)): bool, 81 | vol.Optional("time_resolution", default=ec.get("time_resolution", DEFAULT_TIME_RESOLUTION)): vol.In(time_resolutions), 82 | } 83 | 84 | def get_price_schema(existing_config = None) -> dict: 85 | """Schema for price settings step""" 86 | ec = existing_config if existing_config else {} 87 | return { 88 | vol.Optional("price_type", default=ec.get("price_type", "kWh")): vol.In(price_types), 89 | vol.Optional("price_in_cents", default=ec.get("price_in_cents", False)): bool, 90 | vol.Optional("low_price_cutoff", default=ec.get("low_price_cutoff", 1.0)): vol.Coerce(float), 91 | vol.Optional("additional_costs", default=ec.get("additional_costs", DEFAULT_TEMPLATE)): str, 92 | } 93 | 94 | def get_advanced_schema(existing_config = None) -> dict: 95 | """Schema for advanced settings step""" 96 | ec = existing_config if existing_config else {} 97 | return { 98 | vol.Optional("multiply_template", default=ec.get("multiply_template", '{{correction * 1}}')): str, 99 | vol.Optional("hours_to_boost", default=ec.get("hours_to_boost", 2)): int, 100 | vol.Optional("hours_to_save", default=ec.get("hours_to_save", 2)): int, 101 | vol.Optional("pa_price_before_active", default=ec.get('pa_price_before_active',0.2)): float, 102 | vol.Optional("percent_difference", default=ec.get("percent_difference",20)): int, 103 | vol.Optional("price_before_active", default=ec.get('price_before_active',0.2)): float, 104 | } 105 | 106 | def get_hot_water_schema(existing_config = None) -> dict: 107 | """Schema for hot water temperature settings step""" 108 | ec = _migrate_hot_water_config(existing_config) 109 | if ec is None: 110 | ec = {} 111 | return { 112 | vol.Optional("temp_default", default=ec.get('temp_default', HOT_WATER_DEFAULT_CONFIG[TEMP_DEFAULT])): vol.Coerce(float), 113 | vol.Optional("temp_five_most_expensive", default=ec.get('temp_five_most_expensive', HOT_WATER_DEFAULT_CONFIG[TEMP_FIVE_MOST_EXPENSIVE])): vol.Coerce(float), 114 | vol.Optional("temp_is_falling", default=ec.get('temp_is_falling', HOT_WATER_DEFAULT_CONFIG[TEMP_IS_FALLING])): vol.Coerce(float), 115 | vol.Optional("temp_five_cheapest", default=ec.get('temp_five_cheapest', HOT_WATER_DEFAULT_CONFIG[TEMP_FIVE_CHEAPEST])): vol.Coerce(float), 116 | vol.Optional("temp_ten_cheapest", default=ec.get('temp_ten_cheapest', HOT_WATER_DEFAULT_CONFIG[TEMP_TEN_CHEAPEST])): vol.Coerce(float), 117 | vol.Optional("temp_low_price", default=ec.get('temp_low_price', HOT_WATER_DEFAULT_CONFIG[TEMP_LOW_PRICE])): vol.Coerce(float), 118 | vol.Optional("temp_not_cheap_not_expensive", default=ec.get('temp_not_cheap_not_expensive', HOT_WATER_DEFAULT_CONFIG[TEMP_NOT_CHEAP_NOT_EXPENSIVE])): vol.Coerce(float), 119 | vol.Optional("temp_minimum", default=ec.get('temp_minimum', HOT_WATER_DEFAULT_CONFIG[TEMP_MINIMUM])): vol.Coerce(float), 120 | } 121 | 122 | def get_schema(existing_config = None) -> dict: 123 | """Helper to get complete schema with editable defaults (for backward compatibility)""" 124 | ec = _migrate_hot_water_config(existing_config) 125 | if ec is None: 126 | ec = {} 127 | 128 | # Combine all schemas for backward compatibility 129 | schema = {} 130 | schema.update(get_basic_schema(ec)) 131 | schema.update(get_price_schema(ec)) 132 | schema.update(get_advanced_schema(ec)) 133 | schema.update(get_hot_water_schema(ec)) 134 | return schema 135 | 136 | 137 | class Base: 138 | """Simple helper""" 139 | 140 | async def _valid_template(self, user_template): 141 | try: 142 | def faker(): 143 | def inner(*_, **__): 144 | return dt_utils.now() 145 | return pass_context(inner) 146 | _LOGGER.debug(user_template) 147 | ut = Template(user_template, self.hass).async_render( 148 | current_price=200 149 | ) # Add current price as 0 as we dont know it yet.. 150 | _LOGGER.debug("user_template %s value %s", user_template, ut) 151 | if isinstance(ut, float): 152 | return True 153 | else: 154 | return False 155 | except Exception as e: 156 | _LOGGER.error(e) 157 | 158 | return False 159 | 160 | async def check_settings(self, user_input): 161 | template_ok = False 162 | if user_input is not None: 163 | if user_input["additional_costs"] in (None, ""): 164 | user_input["additional_costs"] = DEFAULT_TEMPLATE 165 | else: 166 | # Lets try to remove the most common mistakes, this will still fail if the template 167 | # was writte in notepad or something like that.. 168 | user_input["additional_costs"] = re.sub( 169 | r"\s{2,}", "", user_input["additional_costs"] 170 | ) 171 | 172 | template_ok = await self._valid_template(user_input["additional_costs"]) 173 | 174 | return template_ok, user_input 175 | 176 | 177 | class PriceAnalyzerFlowHandler(Base, config_entries.ConfigFlow, domain=DOMAIN): 178 | """Config flow for PriceAnalyzer.""" 179 | 180 | VERSION = "1.0" 181 | 182 | def __init__(self): 183 | """Initialize.""" 184 | self._errors = {} 185 | self._data = {} 186 | 187 | async def async_step_user( 188 | self, user_input=None 189 | ): # pylint: disable=dangerous-default-value 190 | """Handle a flow initialized by the user - Step 1: Basic Setup.""" 191 | self._errors = {} 192 | 193 | if user_input is not None: 194 | self._data.update(user_input) 195 | return await self.async_step_config_menu() 196 | 197 | schema = get_basic_schema(self._data) 198 | 199 | return self.async_show_form( 200 | step_id="user", 201 | data_schema=vol.Schema(schema), 202 | description_placeholders=placeholders, 203 | errors=self._errors, 204 | ) 205 | 206 | async def async_step_config_menu(self, user_input=None): 207 | """Show configuration menu.""" 208 | return self.async_show_menu( 209 | step_id="config_menu", 210 | menu_options=["price_settings", "advanced_settings", "hot_water", "finish"], 211 | ) 212 | 213 | async def async_step_price_settings(self, user_input=None): 214 | """Handle Step 2: Price Settings.""" 215 | self._errors = {} 216 | 217 | if user_input is not None: 218 | # Validate template 219 | template_ok, validated_input = await self.check_settings(user_input) 220 | if not template_ok: 221 | self._errors["base"] = "invalid_template" 222 | else: 223 | self._data.update(validated_input) 224 | return await self.async_step_config_menu() 225 | 226 | schema = get_price_schema(self._data) 227 | 228 | return self.async_show_form( 229 | step_id="price_settings", 230 | data_schema=vol.Schema(schema), 231 | description_placeholders=placeholders, 232 | errors=self._errors, 233 | ) 234 | 235 | async def async_step_advanced_settings(self, user_input=None): 236 | """Handle Step 3: Advanced Settings.""" 237 | self._errors = {} 238 | 239 | if user_input is not None: 240 | self._data.update(user_input) 241 | return await self.async_step_config_menu() 242 | 243 | schema = get_advanced_schema(self._data) 244 | 245 | return self.async_show_form( 246 | step_id="advanced_settings", 247 | data_schema=vol.Schema(schema), 248 | description_placeholders=placeholders, 249 | errors=self._errors, 250 | ) 251 | 252 | async def async_step_hot_water(self, user_input=None): 253 | """Handle Step 4: Hot Water Configuration.""" 254 | self._errors = {} 255 | 256 | if user_input is not None: 257 | self._data.update(user_input) 258 | return await self.async_step_config_menu() 259 | 260 | schema = get_hot_water_schema(self._data) 261 | 262 | return self.async_show_form( 263 | step_id="hot_water", 264 | data_schema=vol.Schema(schema), 265 | description_placeholders=placeholders, 266 | errors=self._errors, 267 | ) 268 | 269 | async def async_step_finish(self, user_input=None): 270 | """Finish configuration and create entry.""" 271 | if "region" not in self._data: 272 | return await self.async_step_user() 273 | 274 | # Use friendly_name if provided, otherwise create default title 275 | if self._data.get("friendly_name") and self._data["friendly_name"].strip(): 276 | title = self._data["friendly_name"] 277 | else: 278 | # Create unique title by checking for existing entries with same region 279 | base_title = DOMAIN + " " + self._data["region"] 280 | title = base_title 281 | existing_entries = self._async_current_entries() 282 | region_count = sum(1 for entry in existing_entries if entry.data.get("region") == self._data["region"]) 283 | if region_count > 0: 284 | title = f"{base_title} #{region_count + 1}" 285 | 286 | return self.async_create_entry(title=title, data=self._data) 287 | 288 | 289 | 290 | async def async_step_import(self, user_input): # pylint: disable=unused-argument 291 | """Import a config entry. 292 | Special type of import, we're not actually going to store any data. 293 | Instead, we're going to rely on the values that are in config file. 294 | """ 295 | return self.async_create_entry(title="configuration.yaml", data={}) 296 | 297 | 298 | 299 | 300 | @staticmethod 301 | @callback 302 | def async_get_options_flow(config_entry): 303 | """Get the Options handler""" 304 | return PriceAnalyzerOptionsHandler(config_entry) 305 | 306 | 307 | class PriceAnalyzerOptionsHandler(Base, config_entries.OptionsFlow): 308 | """Handles the options for the component""" 309 | 310 | def __init__(self, config_entry) -> None: 311 | # Note: self.config_entry is automatically set by the parent class 312 | # We dont really care about the options, this component allows all 313 | # settings to be edit after the sensor is created. 314 | # For this to work we need to have a stable entity id. 315 | self.options = dict(config_entry.data) 316 | self._errors = {} 317 | self._data = dict(config_entry.data) 318 | 319 | async def async_step_init(self, user_input=None): # pylint: disable=unused-argument 320 | """Manage the options.""" 321 | return await self.async_step_options_menu(user_input=user_input) 322 | 323 | async def async_step_options_menu(self, user_input=None): 324 | """Show options menu.""" 325 | return self.async_show_menu( 326 | step_id="options_menu", 327 | menu_options=["basic_setup", "price_settings", "advanced_settings", "hot_water", "finish"], 328 | ) 329 | 330 | async def async_step_basic_setup(self, user_input=None): 331 | """Handle Step 1: Basic Setup.""" 332 | self._errors = {} 333 | 334 | if user_input is not None: 335 | self._data.update(user_input) 336 | return await self.async_step_options_menu() 337 | 338 | schema = get_basic_schema(self._data) 339 | 340 | return self.async_show_form( 341 | step_id="basic_setup", 342 | data_schema=vol.Schema(schema), 343 | description_placeholders=placeholders, 344 | errors=self._errors, 345 | ) 346 | 347 | async def async_step_price_settings(self, user_input=None): 348 | """Handle Step 2: Price Settings.""" 349 | self._errors = {} 350 | 351 | if user_input is not None: 352 | # Validate template 353 | template_ok, validated_input = await self.check_settings(user_input) 354 | if not template_ok: 355 | self._errors["base"] = "invalid_template" 356 | else: 357 | self._data.update(validated_input) 358 | return await self.async_step_options_menu() 359 | 360 | schema = get_price_schema(self._data) 361 | 362 | return self.async_show_form( 363 | step_id="price_settings", 364 | data_schema=vol.Schema(schema), 365 | description_placeholders=placeholders, 366 | errors=self._errors, 367 | ) 368 | 369 | async def async_step_advanced_settings(self, user_input=None): 370 | """Handle Step 3: Advanced Settings.""" 371 | self._errors = {} 372 | 373 | if user_input is not None: 374 | self._data.update(user_input) 375 | return await self.async_step_options_menu() 376 | 377 | schema = get_advanced_schema(self._data) 378 | 379 | return self.async_show_form( 380 | step_id="advanced_settings", 381 | data_schema=vol.Schema(schema), 382 | description_placeholders=placeholders, 383 | errors=self._errors, 384 | ) 385 | 386 | async def async_step_hot_water(self, user_input=None): 387 | """Handle Step 4: Hot Water Configuration.""" 388 | self._errors = {} 389 | 390 | if user_input is not None: 391 | self._data.update(user_input) 392 | return await self.async_step_options_menu() 393 | 394 | schema = get_hot_water_schema(self._data) 395 | 396 | return self.async_show_form( 397 | step_id="hot_water", 398 | data_schema=vol.Schema(schema), 399 | description_placeholders=placeholders, 400 | errors=self._errors, 401 | ) 402 | 403 | async def async_step_finish(self, user_input=None): 404 | """Finish configuration and save changes.""" 405 | # Use friendly_name if provided, otherwise keep original title 406 | if self._data.get("friendly_name") and self._data["friendly_name"].strip(): 407 | title = self._data["friendly_name"] 408 | else: 409 | title = self.config_entry.title 410 | 411 | _LOGGER.debug('updating Integration for %s with options: %s', title, self._data) 412 | self.hass.config_entries.async_update_entry( 413 | self.config_entry, data=self._data, title=title, options=self.config_entry.options 414 | ) 415 | return self.async_create_entry(title="", data={}) -------------------------------------------------------------------------------- /custom_components/priceanalyzer/aio_price.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from collections import defaultdict 4 | from datetime import date, datetime, timedelta 5 | 6 | import aiohttp 7 | import backoff 8 | from dateutil.parser import parse as parse_dt 9 | from homeassistant.util import dt as dt_utils 10 | from nordpool.base import CurrencyMismatch 11 | from nordpool.elspot import Prices 12 | from pytz import timezone, utc 13 | 14 | from .misc import add_junk 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | tzs = { 19 | "DK1": "Europe/Copenhagen", 20 | "DK2": "Europe/Copenhagen", 21 | "FI": "Europe/Helsinki", 22 | "EE": "Europe/Tallinn", 23 | "LT": "Europe/Vilnius", 24 | "LV": "Europe/Riga", 25 | "NO1": "Europe/Oslo", 26 | "NO2": "Europe/Oslo", 27 | "NO3": "Europe/Oslo", 28 | "NO4": "Europe/Oslo", 29 | "NO5": "Europe/Oslo", 30 | "SE1": "Europe/Stockholm", 31 | "SE2": "Europe/Stockholm", 32 | "SE3": "Europe/Stockholm", 33 | "SE4": "Europe/Stockholm", 34 | # What zone is this? 35 | "SYS": "Europe/Stockholm", 36 | "FR": "Europe/Paris", 37 | "NL": "Europe/Amsterdam", 38 | "BE": "Europe/Brussels", 39 | "AT": "Europe/Vienna", 40 | "GER": "Europe/Berlin", 41 | } 42 | 43 | # List of page index for hourly data 44 | # Some are disabled as they don't contain the other currencies, NOK etc, 45 | # or there are some issues with data parsing for some ones' DataStartdate. 46 | # Lets come back and fix that later, just need to adjust the self._parser. 47 | # DataEnddate: "2021-02-11T00:00:00" 48 | # DataStartdate: "0001-01-01T00:00:00" 49 | COUNTRY_BASE_PAGE = { 50 | # "SYS": 17, 51 | "NO": 23, 52 | "SE": 29, 53 | "DK": 41, 54 | # "FI": 35, 55 | # "EE": 47, 56 | # "LT": 53, 57 | # "LV": 59, 58 | # "AT": 298578, 59 | # "BE": 298736, 60 | # "DE-LU": 299565, 61 | # "FR": 299568, 62 | # "NL": 299571, 63 | # "PL": 391921, 64 | } 65 | 66 | AREA_TO_COUNTRY = { 67 | "SYS": "SYS", 68 | "SE1": "SE", 69 | "SE2": "SE", 70 | "SE3": "SE", 71 | "SE4": "SE", 72 | "FI": "FI", 73 | "DK1": "DK", 74 | "DK2": "DK", 75 | "OSLO": "NO", 76 | "KR.SAND": "NO", 77 | "BERGEN": "NO", 78 | "MOLDE": "NO", 79 | "TR.HEIM": "NO", 80 | "TROMSØ": "NO", 81 | "EE": "EE", 82 | "LV": "LV", 83 | "LT": "LT", 84 | "AT": "AT", 85 | "BE": "BE", 86 | "DE-LU": "DE-LU", 87 | "FR": "FR", 88 | "NL": "NL", 89 | "PL ": "PL", 90 | } 91 | 92 | INVALID_VALUES = frozenset((None, float("inf"))) 93 | 94 | 95 | class InvalidValueException(ValueError): 96 | pass 97 | 98 | 99 | def aggregate_quarters_to_hours(data): 100 | """Aggregate 15-minute price data into hourly averages. 101 | 102 | Takes data with 15-minute resolution and returns data with hourly resolution. 103 | The hourly price is calculated as the average of the four 15-minute prices. 104 | """ 105 | if not data or "areas" not in data: 106 | return data 107 | 108 | result = {"areas": {}} 109 | 110 | for area_key, area_data in data["areas"].items(): 111 | if "values" not in area_data or not area_data["values"]: 112 | result["areas"][area_key] = area_data 113 | continue 114 | 115 | hourly_values = [] 116 | current_hour_values = [] 117 | current_hour_start = None 118 | 119 | for val in area_data["values"]: 120 | hour_start = val["start"].replace(minute=0, second=0, microsecond=0) 121 | 122 | if current_hour_start is None: 123 | current_hour_start = hour_start 124 | 125 | if hour_start == current_hour_start: 126 | # Same hour, accumulate 127 | current_hour_values.append(val["value"]) 128 | else: 129 | # New hour, finalize previous hour 130 | if current_hour_values: 131 | avg_price = sum(current_hour_values) / len(current_hour_values) 132 | hourly_values.append({ 133 | "start": current_hour_start, 134 | "end": current_hour_start + timedelta(hours=1), 135 | "value": avg_price 136 | }) 137 | 138 | # Start new hour 139 | current_hour_start = hour_start 140 | current_hour_values = [val["value"]] 141 | 142 | # Don't forget the last hour 143 | if current_hour_values: 144 | avg_price = sum(current_hour_values) / len(current_hour_values) 145 | hourly_values.append({ 146 | "start": current_hour_start, 147 | "end": current_hour_start + timedelta(hours=1), 148 | "value": avg_price 149 | }) 150 | 151 | # Copy area_data and replace values 152 | result["areas"][area_key] = area_data.copy() 153 | result["areas"][area_key]["values"] = hourly_values 154 | 155 | return result 156 | 157 | 158 | async def join_result_for_correct_time(results, dt): 159 | """Parse a list of responses from the api 160 | to extract the correct hours in there timezone. 161 | """ 162 | # utc = datetime.utcnow() 163 | fin = defaultdict(dict) 164 | # _LOGGER.debug("join_result_for_correct_time %s", dt) 165 | utc = dt 166 | 167 | for day_ in results: 168 | for key, value in day_.get("areas", {}).items(): 169 | zone = tzs.get(key) 170 | if zone is None: 171 | _LOGGER.debug("Skipping %s", key) 172 | continue 173 | else: 174 | zone = await dt_utils.async_get_time_zone(zone) 175 | 176 | # We add junk here as the peak etc 177 | # from the api is based on cet, not the 178 | # hours in the we want so invalidate them 179 | # its later corrected in the sensor. 180 | value = add_junk(value) 181 | 182 | values = day_["areas"][key].pop("values") 183 | 184 | # We need to check this so we dont overwrite stuff. 185 | if key not in fin["areas"]: 186 | fin["areas"][key] = {} 187 | fin["areas"][key].update(value) 188 | if "values" not in fin["areas"][key]: 189 | fin["areas"][key]["values"] = [] 190 | 191 | start_of_day = utc.astimezone(zone).replace( 192 | hour=0, minute=0, second=0, microsecond=0 193 | ) 194 | end_of_day = utc.astimezone(zone).replace( 195 | hour=23, minute=59, second=59, microsecond=999999 196 | ) 197 | 198 | for val in values: 199 | local = val["start"].astimezone(zone) 200 | local_end = val["end"].astimezone(zone) 201 | if start_of_day <= local <= end_of_day: 202 | if local == local_end: 203 | _LOGGER.info( 204 | "Hour has the same start and end, most likly due to dst change %s exluded this hour", 205 | val, 206 | ) 207 | elif val['value'] in INVALID_VALUES: 208 | raise InvalidValueException(f"Invalid value in {val} for area '{key}'") 209 | else: 210 | fin["areas"][key]["values"].append(val) 211 | 212 | return fin 213 | 214 | 215 | class AioPrices(Prices): 216 | """Interface""" 217 | 218 | def __init__(self, currency, client, timeezone=None, time_resolution="hourly"): 219 | super().__init__(currency) 220 | self.client = client 221 | self.timeezone = timeezone 222 | self.time_resolution = time_resolution # "quarterly" or "hourly" 223 | (self.HOURLY, self.DAILY, self.WEEKLY, self.MONTHLY, self.YEARLY) = ("DayAheadPrices", "AggregatePrices", 224 | "AggregatePrices", "AggregatePrices", 225 | "AggregatePrices") 226 | self.API_URL = "https://dataportal-api.nordpoolgroup.com/api/%s" 227 | 228 | @backoff.on_exception( 229 | backoff.expo, 230 | (aiohttp.ClientError, asyncio.TimeoutError, OSError), 231 | max_tries=3, 232 | max_time=60, 233 | logger=_LOGGER, 234 | on_backoff=lambda details: _LOGGER.warning( 235 | "HTTP request failed, retrying in %s seconds (attempt %s/%s) for URL %s: %s", 236 | details['wait'], details['tries'], details.get('max_tries', 'unknown'), 237 | details.get('args', ['unknown URL'])[0] if details.get('args') else 'unknown URL', 238 | details['exception'] 239 | ), 240 | on_giveup=lambda details: _LOGGER.error( 241 | "HTTP request failed permanently after %s attempts over %s seconds for URL %s: %s", 242 | details['tries'], details.get('elapsed', 'unknown'), 243 | details.get('args', ['unknown URL'])[0] if details.get('args') else 'unknown URL', 244 | details['exception'] 245 | )) 246 | async def _io(self, url, **kwargs): 247 | """HTTP request with retry mechanism""" 248 | resp = await self.client.get(url, params=kwargs) 249 | _LOGGER.debug("requested %s %s", resp.url, kwargs) 250 | 251 | if resp.status == 204: 252 | return None 253 | 254 | # Raise an exception for HTTP error status codes 255 | resp.raise_for_status() 256 | 257 | return await resp.json() 258 | 259 | def _parse_dt(self, time_str): 260 | ''' Parse datetimes to UTC from Stockholm time, which Nord Pool uses. ''' 261 | time = parse_dt(time_str, tzinfos={"Z": timezone("Europe/Stockholm")}) 262 | if time.tzinfo is None: 263 | return timezone('Europe/Stockholm').localize(time).astimezone(utc) 264 | return time.astimezone(utc) 265 | 266 | def _parse_json(self, data, areas=None): 267 | """ 268 | Parse json response from fetcher. 269 | Returns dictionary with 270 | - start time 271 | - end time 272 | - update time 273 | - currency 274 | - dictionary of areas, based on selection 275 | - list of values (dictionary with start and endtime and value) 276 | - possible other values, such as min, max, average for hourly 277 | """ 278 | 279 | if areas is None: 280 | areas = [] 281 | # If areas isn't a list, make it one 282 | 283 | if not isinstance(areas, list): 284 | areas = list(areas) 285 | 286 | if data.get("status", 200) != 200 and "version" not in data: 287 | raise Exception(f"Invalid response from Nordpool API: {data}") 288 | 289 | # Update currency from data 290 | currency = data['currency'] 291 | 292 | # Ensure that the provided currency match the requested one 293 | if currency != self.currency: 294 | raise CurrencyMismatch 295 | 296 | start_time = None 297 | end_time = None 298 | 299 | if len(data['multiAreaEntries']) > 0: 300 | start_time = self._parse_dt(data['multiAreaEntries'][0]['deliveryStart']) 301 | end_time = self._parse_dt(data['multiAreaEntries'][-1]['deliveryEnd']) 302 | updated = self._parse_dt(data['updatedAt']) 303 | 304 | area_data = {} 305 | 306 | # Loop through response rows 307 | for r in data['multiAreaEntries']: 308 | row_start_time = self._parse_dt(r['deliveryStart']) 309 | row_end_time = self._parse_dt(r['deliveryEnd']) 310 | 311 | # Loop through columns 312 | for area_key in r['entryPerArea'].keys(): 313 | area_price = r['entryPerArea'][area_key] 314 | # If areas is defined and name isn't in areas, skip column 315 | if area_key not in areas: 316 | continue 317 | 318 | # If name isn't in area_data, initialize dictionary 319 | if area_key not in area_data: 320 | area_data[area_key] = { 321 | 'values': [], 322 | } 323 | 324 | # Append dictionary to value list 325 | area_data[area_key]['values'].append({ 326 | 'start': row_start_time, 327 | 'end': row_end_time, 328 | 'value': self._conv_to_float(area_price), 329 | }) 330 | 331 | return { 332 | 'start': start_time, 333 | 'end': end_time, 334 | 'updated': updated, 335 | 'currency': currency, 336 | 'areas': area_data 337 | } 338 | 339 | async def _fetch_json(self, data_type, end_date=None, areas=None): 340 | """Fetch JSON from API""" 341 | # If end_date isn't set, default to tomorrow 342 | if areas is None or len(areas) == 0: 343 | raise Exception("Cannot query with empty areas") 344 | if end_date is None: 345 | end_date = date.today() + timedelta(days=1) 346 | # If end_date isn't a date or datetime object, try to parse a string 347 | if not isinstance(end_date, date) and not isinstance(end_date, datetime): 348 | end_date = parse_dt(end_date) 349 | 350 | 351 | 352 | return await self._io( 353 | self.API_URL % data_type, 354 | currency=self.currency, 355 | market="DayAhead", 356 | deliveryArea=",".join(areas), 357 | date=end_date.strftime("%Y-%m-%d"), 358 | ) 359 | 360 | # Add more exceptions as we find them. KeyError is raised when the api return 361 | # junk due to currency not being available in the data. 362 | @backoff.on_exception( 363 | backoff.expo, 364 | (aiohttp.ClientError, asyncio.TimeoutError, OSError, 365 | KeyError, ValueError, TypeError), 366 | max_tries=3, 367 | max_time=120, 368 | logger=_LOGGER, 369 | on_backoff=lambda details: _LOGGER.warning( 370 | "Data fetch failed, retrying in %s seconds (attempt %s/%s) for data_type %s: %s", 371 | details['wait'], details['tries'], details.get('max_tries', 'unknown'), 372 | details.get('args', ['unknown'])[1] if len(details.get('args', [])) > 1 else 'unknown', 373 | details['exception'] 374 | ), 375 | on_giveup=lambda details: _LOGGER.error( 376 | "Data fetch failed permanently after %s attempts over %s seconds for data_type %s: %s", 377 | details['tries'], details.get('elapsed', 'unknown'), 378 | details.get('args', ['unknown'])[1] if len(details.get('args', [])) > 1 else 'unknown', 379 | details['exception'] 380 | )) 381 | async def fetch(self, data_type, end_date=None, areas=None): 382 | """ 383 | Fetch data from API. 384 | Inputs: 385 | - data_type 386 | API page id, one of Prices.HOURLY, Prices.DAILY etc 387 | - end_date 388 | datetime to end the data fetching 389 | defaults to tomorrow 390 | - areas 391 | list of areas to fetch, such as ['SE1', 'SE2', 'FI'] 392 | defaults to all areas 393 | Returns dictionary with 394 | - start time 395 | - end time 396 | - update time 397 | - currency 398 | - dictionary of areas, based on selection 399 | - list of values (dictionary with start and endtime and value) 400 | - possible other values, such as min, max, average for hourly 401 | """ 402 | if areas is None: 403 | areas = [] 404 | 405 | yesterday = datetime.now() - timedelta(days=1) 406 | today = datetime.now() 407 | tomorrow = datetime.now() + timedelta(days=1) 408 | 409 | jobs = [ 410 | self._fetch_json(data_type, yesterday, areas), 411 | self._fetch_json(data_type, today, areas), 412 | self._fetch_json(data_type, tomorrow, areas), 413 | ] 414 | 415 | res = await asyncio.gather(*jobs) 416 | raw = [await self._async_parse_json(i, areas) for i in res if i] 417 | 418 | return await join_result_for_correct_time(raw, end_date) 419 | 420 | async def _async_parse_json(self, data, areas): 421 | """ 422 | Async version of _parse_json to prevent blocking calls inside the event loop. 423 | """ 424 | loop = asyncio.get_running_loop() 425 | return await loop.run_in_executor(None, self._parse_json, data, areas) 426 | 427 | async def hourly(self, end_date=None, areas=None): 428 | """Helper to fetch hourly data, see Prices.fetch()""" 429 | if areas is None: 430 | areas = [] 431 | data = await self.fetch(self.HOURLY, end_date, areas) 432 | 433 | # If time_resolution is "hourly", aggregate 15-minute data to hourly 434 | if data and self.time_resolution == "hourly": 435 | data = aggregate_quarters_to_hours(data) 436 | 437 | return data 438 | 439 | async def daily(self, end_date=None, areas=None): 440 | """Helper to fetch daily data, see Prices.fetch()""" 441 | if areas is None: 442 | areas = [] 443 | return await self.fetch(self.DAILY, end_date, areas) 444 | 445 | async def weekly(self, end_date=None, areas=None): 446 | """Helper to fetch weekly data, see Prices.fetch()""" 447 | if areas is None: 448 | areas = [] 449 | return await self.fetch(self.WEEKLY, end_date, areas) 450 | 451 | async def monthly(self, end_date=None, areas=None): 452 | """Helper to fetch monthly data, see Prices.fetch()""" 453 | if areas is None: 454 | areas = [] 455 | return await self.fetch(self.MONTHLY, end_date, areas) 456 | 457 | async def yearly(self, end_date=None, areas=None): 458 | """Helper to fetch yearly data, see Prices.fetch()""" 459 | if areas is None: 460 | areas = [] 461 | return await self.fetch(self.YEARLY, end_date, areas) 462 | 463 | def _conv_to_float(self, s): 464 | """Convert numbers to float. Return infinity, if conversion fails.""" 465 | # Skip if already float 466 | if isinstance(s, float): 467 | return s 468 | try: 469 | return float(s.replace(",", ".").replace(" ", "")) 470 | except ValueError: 471 | return float("inf") 472 | -------------------------------------------------------------------------------- /custom_components/priceanalyzer/sensor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import math 3 | from datetime import datetime 4 | from operator import itemgetter 5 | from statistics import mean 6 | 7 | import homeassistant.helpers.config_validation as cv 8 | import voluptuous as vol 9 | from homeassistant.const import CONF_REGION 10 | from homeassistant.helpers.dispatcher import async_dispatcher_connect 11 | from homeassistant.helpers.entity import DeviceInfo 12 | import json 13 | from .data import Data 14 | from .const import ( 15 | CONF_REGION, 16 | DATA, 17 | DOMAIN, 18 | TEMP_DEFAULT, 19 | TEMP_FIVE_MOST_EXPENSIVE, 20 | TEMP_IS_FALLING, 21 | TEMP_FIVE_CHEAPEST, 22 | TEMP_TEN_CHEAPEST, 23 | TEMP_LOW_PRICE, 24 | TEMP_NOT_CHEAP_NOT_EXPENSIVE, 25 | TEMP_MINIMUM, 26 | HOT_WATER_CONFIG, 27 | HOT_WATER_DEFAULT_CONFIG, 28 | EVENT_NEW_DATA, 29 | EVENT_NEW_HOUR, 30 | EVENT_CHECKED_STUFF, 31 | API_DATA_LOADED, 32 | DOMAIN, 33 | DATA, 34 | _PRICE_IN 35 | ) 36 | 37 | # Needed incase a user wants the prices in non local currency 38 | _CURRENCY_TO_LOCAL = {"DKK": "Kr", "NOK": "Kr", "SEK": "Kr", "EUR": "€"} 39 | _CURRENTY_TO_CENTS = {"DKK": "Øre", "NOK": "Øre", "SEK": "Öre", "EUR": "c"} 40 | 41 | 42 | 43 | from homeassistant.helpers.template import Template 44 | from homeassistant.util import dt as dt_utils 45 | 46 | 47 | # Import sensor entity and classes. 48 | from homeassistant.components.sensor import ( 49 | SensorDeviceClass, 50 | SensorEntity, 51 | SensorStateClass, 52 | ) 53 | 54 | from jinja2 import pass_context 55 | 56 | from .misc import extract_attrs, has_junk, is_new, start_of 57 | 58 | _LOGGER = logging.getLogger(__name__) 59 | 60 | def _dry_setup(hass, config, add_devices, discovery_info=None, unique_id=None): 61 | region = config.get(CONF_REGION) 62 | entry_key = unique_id or config.get(CONF_ID) or region 63 | data = hass.data[DATA].get(entry_key) 64 | 65 | if data is None: 66 | fallback_key = region 67 | data = hass.data[DATA].get(fallback_key) 68 | if data is None: 69 | raise KeyError(f"Data for entry {entry_key} (region {region}) not found") 70 | pricecorrection = PriceAnalyzerSensor(data, unique_id) 71 | vvbsensor = VVBSensor(data, config, unique_id) 72 | pricesensor = PriceSensor(data, unique_id) 73 | sensors = [ 74 | pricecorrection, 75 | vvbsensor, 76 | pricesensor 77 | ] 78 | #data.set_sensors(sensors) 79 | add_devices(sensors, True) 80 | 81 | #data.check_stuff() 82 | 83 | 84 | async def async_setup_platform(hass, config, add_devices, discovery_info=None) -> None: 85 | _dry_setup(hass, config, add_devices, unique_id=config.entry_id) 86 | return True 87 | 88 | 89 | async def async_setup_entry(hass, config_entry, async_add_devices): 90 | """Setup sensor platform for the ui""" 91 | config = config_entry.data 92 | region = config.get(CONF_REGION) 93 | entry_key = config_entry.entry_id or config.get(CONF_ID) or region 94 | 95 | # Ensure the data is available before setting up sensors 96 | if DATA not in hass.data or entry_key not in hass.data[DATA]: 97 | # Fallback to region key for backward compatibility 98 | fallback_key = region if DATA in hass.data else None 99 | if fallback_key and fallback_key in hass.data[DATA]: 100 | data = hass.data[DATA][fallback_key] 101 | hass.data[DATA][entry_key] = data 102 | else: 103 | _LOGGER.error("Data not available for entry %s (region %s) during sensor setup", entry_key, region) 104 | return False 105 | 106 | _dry_setup(hass, config, async_add_devices, unique_id=entry_key) 107 | return True 108 | 109 | class VVBSensor(SensorEntity): 110 | _attr_device_class = SensorDeviceClass.TEMPERATURE 111 | _attr_state_class = SensorStateClass.MEASUREMENT 112 | 113 | # Exclude large attributes from recorder database 114 | _unrecorded_attributes: frozenset[str] = frozenset({ 115 | "raw_today", 116 | "raw_tomorrow" 117 | }) 118 | 119 | def __init__(self, data, config, unique_id) -> None: 120 | self._data = data 121 | self._hass = self._data.api._hass 122 | self._config = config 123 | self._attr_unique_id = unique_id + '_VVBSensor' 124 | self._unique_id = unique_id + '_VVBSensor' 125 | self._attr_force_update = True 126 | 127 | def getTemp(self, current_hour, is_tomorrow=False, reason=False): 128 | temp = self.get_config_key(TEMP_DEFAULT) 129 | if not isinstance(temp, (int, float)): 130 | if isinstance(temp, (str)) and (temp == 'on' or temp == 'off'): 131 | temp = temp 132 | else: 133 | temp = 75 134 | 135 | reasonText = 'Default temp' 136 | if current_hour: 137 | small_price_difference = self._data.small_price_difference_today is True if is_tomorrow is False else self._data.small_price_difference_tomorrow 138 | is_low_price = current_hour['is_low_price'] 139 | temp_correction_down = float(current_hour['temperature_correction']) < 0 140 | is_five_most_expensive = current_hour['is_five_most_expensive'] is True 141 | is_five_cheapest = current_hour.get("is_five_cheapest", False) 142 | is_ten_cheapest = current_hour.get("is_ten_cheapest", False) 143 | is_min_price = current_hour['is_min'] is True 144 | 145 | max = self._data._max_tomorrow if is_tomorrow else self._data._max 146 | threshold = self._config.get('price_before_active', "") or 0 147 | below_threshold = float(threshold) > max 148 | 149 | is_low_compared_to_tomorrow = current_hour['is_low_compared_to_tomorrow'] 150 | 151 | 152 | is_cheap_compared_to_future = current_hour['is_cheap_compared_to_future'] 153 | #TODO Must tomorrow valid be true before this is true? Was ON from 0000:1300 at 21 feb 154 | # when price was gaining and gaining, and then false. 155 | # which is kinda right, and kinda false. 156 | # right in the case that we keep the water heated until the most expensive periods 157 | # wrong in the case that we keep it on more than necessary maybe, 158 | # as it may very well get cheaper overnight. 159 | 160 | # TODO is gaining the next day, set extra temp. 161 | # TODO if tomorrow is available, 162 | # and is the cheapest 5 hours for the forseeable future, set temp 163 | 164 | # TODO Setting if price is only going down from now as well. Then set minimum temp? 165 | 166 | if small_price_difference or below_threshold: 167 | temp = temp 168 | reasonText = 'Small price difference or below threshold for settings' 169 | elif is_min_price: 170 | temp = self.get_config_key(TEMP_MINIMUM) 171 | reasonText = 'Is minimum price' 172 | elif is_low_compared_to_tomorrow: 173 | temp = self.get_config_key(TEMP_FIVE_CHEAPEST) 174 | reasonText = 'The price is only gaining for today and tomorrow, using config for five cheapest' 175 | elif is_cheap_compared_to_future: 176 | temp = self.get_config_key(TEMP_FIVE_CHEAPEST) 177 | reasonText = 'The price is in the five cheapest hours for the known future, using config for five_cheapest' 178 | elif is_five_most_expensive: 179 | temp = self.get_config_key(TEMP_FIVE_MOST_EXPENSIVE) 180 | reasonText = 'Is five most expensive' 181 | elif is_five_cheapest: 182 | temp = self.get_config_key(TEMP_FIVE_CHEAPEST) 183 | reasonText = 'Is five cheapest' 184 | elif is_ten_cheapest: 185 | temp = self.get_config_key(TEMP_TEN_CHEAPEST) 186 | reasonText = 'Is ten cheapest' 187 | elif temp_correction_down: 188 | temp = self.get_config_key(TEMP_IS_FALLING) 189 | reasonText = 'Is falling' 190 | elif is_low_price: 191 | temp = self.get_config_key(TEMP_LOW_PRICE) 192 | reasonText = 'Is low price' 193 | else: 194 | temp = self.get_config_key(TEMP_NOT_CHEAP_NOT_EXPENSIVE) 195 | reasonText = 'Not cheap, not expensive. ' 196 | 197 | if reason: 198 | return reasonText 199 | if isinstance(temp, float): 200 | return temp 201 | 202 | return temp if (reason is False) else reasonText 203 | 204 | 205 | def get_config_key(self, key=TEMP_DEFAULT): 206 | # First check if we have individual config fields (new format) 207 | config_key_to_field = { 208 | TEMP_DEFAULT: 'temp_default', 209 | TEMP_FIVE_MOST_EXPENSIVE: 'temp_five_most_expensive', 210 | TEMP_IS_FALLING: 'temp_is_falling', 211 | TEMP_FIVE_CHEAPEST: 'temp_five_cheapest', 212 | TEMP_TEN_CHEAPEST: 'temp_ten_cheapest', 213 | TEMP_LOW_PRICE: 'temp_low_price', 214 | TEMP_NOT_CHEAP_NOT_EXPENSIVE: 'temp_not_cheap_not_expensive', 215 | TEMP_MINIMUM: 'temp_minimum' 216 | } 217 | 218 | # Check if we have the individual field (new format) 219 | if key in config_key_to_field: 220 | individual_key = config_key_to_field[key] 221 | if individual_key in self._config: 222 | return self._config[individual_key] 223 | 224 | # Fall back to JSON format (old format) for backward compatibility 225 | config = self._config.get(HOT_WATER_CONFIG, "") 226 | list = {} 227 | if config: 228 | try: 229 | list = json.loads(config) 230 | except (json.JSONDecodeError, TypeError): 231 | list = HOT_WATER_DEFAULT_CONFIG 232 | else: 233 | list = HOT_WATER_DEFAULT_CONFIG 234 | 235 | if key in list.keys(): 236 | return list[key] 237 | else: 238 | return HOT_WATER_DEFAULT_CONFIG[key] 239 | 240 | 241 | 242 | @property 243 | def state(self) -> float: 244 | return self.getTemp(self._data.current_hour) 245 | 246 | 247 | def get_today_calculated(self) -> dict: 248 | today_calculated = [] 249 | today = self._data.today_calculated 250 | for hour in today: 251 | item = { 252 | "start": hour["start"], 253 | "end": hour["end"], 254 | "temp": self.getTemp(hour), 255 | "reason": self.getTemp(hour,False,True) 256 | } 257 | 258 | today_calculated.append(item) 259 | 260 | return today_calculated 261 | 262 | def get_tomorrow_calculated(self) -> dict: 263 | tomorrow_calculated = [] 264 | today = self._data.tomorrow_calculated 265 | for hour in today: 266 | item = { 267 | "start": hour["start"], 268 | "end": hour["end"], 269 | "temp": self.getTemp(hour, True), 270 | "reason": self.getTemp(hour,True,True) 271 | } 272 | 273 | tomorrow_calculated.append(item) 274 | return tomorrow_calculated 275 | 276 | @property 277 | def extra_state_attributes(self) -> dict: 278 | return { 279 | "reason": self.getTemp(self._data.current_hour,False,True), 280 | "raw_today": self.get_today_calculated(), 281 | "raw_tomorrow": self.get_tomorrow_calculated(), 282 | "unique_id": self.unique_id, 283 | } 284 | 285 | 286 | @property 287 | def name(self) -> str: 288 | # Use friendly_name if set, otherwise use region for backward compatibility 289 | if self._data._attr_name and self._data._attr_name.strip(): 290 | return 'VVBSensor_' + self._data._attr_name 291 | return 'VVBSensor_' + self._data._area 292 | 293 | @property 294 | def should_poll(self): 295 | """Think we need to poll this at the current state of code.""" 296 | return False 297 | 298 | @property 299 | def icon(self) -> str: 300 | return "mdi:water-boiler" 301 | 302 | @property 303 | def unit(self) -> str: 304 | return '°C' 305 | 306 | 307 | 308 | @property 309 | def unit_of_measurement(self) -> str: 310 | return self.unit 311 | 312 | 313 | @property 314 | def device_info(self): 315 | return self._data.device_info 316 | 317 | def _update(self, data) -> None: 318 | self._data.update(data) 319 | 320 | 321 | def update_sensor(self): 322 | self.hass.loop.call_soon_threadsafe(self.async_write_ha_state) 323 | #self.async_write_ha_state() 324 | 325 | async def async_added_to_hass(self): 326 | """Connect to dispatcher listening for entity data notifications.""" 327 | await super().async_added_to_hass() 328 | _LOGGER.debug("called async_added_to_hass %s", self.name) 329 | async_dispatcher_connect(self._data.api._hass, EVENT_NEW_DATA, self._data.new_day) 330 | async_dispatcher_connect(self._data.api._hass, EVENT_NEW_HOUR, self._data.new_hr) 331 | async_dispatcher_connect(self._data.api._hass, API_DATA_LOADED, self._data.check_stuff) 332 | async_dispatcher_connect(self._data.api._hass, EVENT_CHECKED_STUFF, self.update_sensor) 333 | await self._data.check_stuff() 334 | 335 | class PriceAnalyzerSensor(SensorEntity): 336 | _attr_device_class = SensorDeviceClass.TEMPERATURE 337 | _attr_state_class = SensorStateClass.MEASUREMENT 338 | 339 | _unrecorded_attributes: frozenset[str] = frozenset({ 340 | "raw_today", 341 | "raw_tomorrow", 342 | "ten_cheapest_today", 343 | "five_cheapest_today", 344 | "two_cheapest_today", 345 | "ten_cheapest_tomorrow", 346 | "five_cheapest_tomorrow", 347 | "two_cheapest_tomorrow", 348 | "current_hour" 349 | }) 350 | 351 | def __init__( 352 | self, 353 | data, 354 | unique_id 355 | ) -> None: 356 | self._data = data 357 | self._hass = self._data.api._hass 358 | self._attr_unique_id = unique_id + '_priceanalyzer' 359 | self._unique_id = unique_id + '_priceanalyzer' 360 | self._attr_force_update = True 361 | 362 | @property 363 | def name(self) -> str: 364 | # Use friendly_name if set, otherwise use region for backward compatibility 365 | if self._data._attr_name and self._data._attr_name.strip(): 366 | return 'Priceanalyzer_' + self._data._attr_name 367 | return 'Priceanalyzer_' + self._data._area 368 | 369 | @property 370 | def should_poll(self): 371 | """No need to poll. Coordinator notifies entity of updates.""" 372 | return False 373 | 374 | @property 375 | def icon(self) -> str: 376 | return "mdi:sine-wave" 377 | 378 | @property 379 | def unit(self) -> str: 380 | return self._data._price_type 381 | 382 | @property 383 | def unit_of_measurement(self) -> str: 384 | return '°C' 385 | 386 | 387 | @property 388 | def device_info(self): 389 | return self._data.device_info 390 | 391 | 392 | @property 393 | def extra_state_attributes(self) -> dict: 394 | return { 395 | "display_name": self._data._attr_name, 396 | "low price": self._data.low_price, 397 | "tomorrow_valid": self._data.tomorrow_valid, 398 | 'max': self._data._max, 399 | 'min': self._data._min, 400 | 'price_difference_is_small': self._data.small_price_difference_today, 401 | 'price_difference_is_small_tomorrow': self._data.small_price_difference_tomorrow, 402 | 'peak': self._data._peak, 403 | 'off_peak_1': self._data._off_peak_1, 404 | 'off_peak_2': self._data._off_peak_2, 405 | 'average': self._data._average, 406 | 'average_tomorrow': self._data._average_tomorrow, 407 | "current_hour": self._data.current_hour, 408 | "raw_today": self._data.today_calculated, 409 | "raw_tomorrow": self._data.tomorrow_calculated, 410 | "ten_cheapest_today": self._data._ten_cheapest_today, 411 | "five_cheapest_today": self._data._five_cheapest_today, 412 | "two_cheapest_today": self._data._two_cheapest_today, 413 | "ten_cheapest_tomorrow": self._data._ten_cheapest_tomorrow, 414 | "five_cheapest_tomorrow": self._data._five_cheapest_tomorrow, 415 | "two_cheapest_tomorrow": self._data._two_cheapest_tomorrow, 416 | } 417 | 418 | 419 | @property 420 | def state(self) -> float: 421 | if self._data.current_hour: 422 | return self._data.current_hour['temperature_correction'] 423 | else: 424 | return None 425 | 426 | def _update(self, data) -> None: 427 | self._data.update(data) 428 | 429 | def update_sensor(self): 430 | self.hass.loop.call_soon_threadsafe(self.async_write_ha_state) 431 | #self.async_write_ha_state() 432 | 433 | async def async_added_to_hass(self): 434 | """Connect to dispatcher listening for entity data notifications.""" 435 | await super().async_added_to_hass() 436 | _LOGGER.debug("called async_added_to_hass %s", self.name) 437 | async_dispatcher_connect(self._data.api._hass, EVENT_NEW_DATA, self._data.new_day) 438 | async_dispatcher_connect(self._data.api._hass, EVENT_NEW_HOUR, self._data.new_hr) 439 | async_dispatcher_connect(self._data.api._hass, API_DATA_LOADED, self._data.check_stuff) 440 | async_dispatcher_connect(self._data.api._hass, EVENT_CHECKED_STUFF, self.update_sensor) 441 | await self._data.check_stuff() 442 | 443 | 444 | class PriceSensor(SensorEntity): 445 | _attr_device_class = SensorDeviceClass.MONETARY 446 | _attr_state_class = SensorStateClass.MEASUREMENT 447 | 448 | def __init__( 449 | self, 450 | data, 451 | unique_id 452 | ) -> None: 453 | self._data = data 454 | self._hass = self._data.api._hass 455 | self._attr_unique_id = unique_id + '_priceanalyzer_price' 456 | self._unique_id = unique_id + '_priceanalyzer_price' 457 | self._attr_force_update = True 458 | 459 | @property 460 | def name(self) -> str: 461 | # Use friendly_name if set, otherwise use region for backward compatibility 462 | if self._data._attr_name and self._data._attr_name.strip(): 463 | return 'Priceanalyzer_Price_' + self._data._attr_name 464 | return 'Priceanalyzer_Price_' + self._data._area 465 | 466 | @property 467 | def should_poll(self): 468 | """No need to poll. Coordinator notifies entity of updates.""" 469 | return False 470 | 471 | @property 472 | def icon(self) -> str: 473 | return "mdi:cash" 474 | 475 | @property 476 | def unit(self) -> str: 477 | return self._data._price_type 478 | 479 | @property 480 | def unit_of_measurement(self) -> str: # FIXME 481 | """Return the unit of measurement this sensor expresses itself in.""" 482 | _currency = self._data._currency 483 | if self._data._use_cents is True: 484 | # Convert unit of measurement to cents based on chosen currency 485 | _currency = _CURRENTY_TO_CENTS[_currency] 486 | return "%s/%s" % (_currency, self._data._price_type) 487 | 488 | 489 | @property 490 | def device_info(self): 491 | return self._data.device_info 492 | 493 | 494 | @property 495 | def extra_state_attributes(self) -> dict: 496 | return { 497 | 498 | } 499 | 500 | @property 501 | def state(self) -> float: 502 | if self._data.current_hour: 503 | return self._data.current_hour['value'] 504 | else: 505 | return None 506 | 507 | def _update(self, data) -> None: 508 | self._data.update(data) 509 | 510 | def update_sensor(self): 511 | self.hass.loop.call_soon_threadsafe(self.async_write_ha_state) 512 | 513 | async def async_added_to_hass(self): 514 | """Connect to dispatcher listening for entity data notifications.""" 515 | await super().async_added_to_hass() 516 | _LOGGER.debug("Price Sensors called async_added_to_hass %s", self.name) 517 | async_dispatcher_connect(self._data.api._hass, EVENT_NEW_DATA, self._data.new_day) 518 | async_dispatcher_connect(self._data.api._hass, EVENT_NEW_HOUR, self._data.new_hr) 519 | async_dispatcher_connect(self._data.api._hass, API_DATA_LOADED, self._data.check_stuff) 520 | async_dispatcher_connect(self._data.api._hass, EVENT_CHECKED_STUFF, self.update_sensor) 521 | await self._data.check_stuff() 522 | --------------------------------------------------------------------------------