├── .devcontainer ├── .gitattributes ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── hacs.yaml │ ├── hassfest.yaml │ ├── release.yaml │ └── tests.yaml ├── .gitignore ├── .vscode └── tasks.json ├── LICENSE ├── README.md ├── blueprints └── automation │ ├── thermostat_fixed.yaml │ └── thermostat_number.yaml ├── config └── configuration.yaml ├── custom_components └── nordpool_planner │ ├── __init__.py │ ├── binary_sensor.py │ ├── button.py │ ├── config_flow.py │ ├── const.py │ ├── diagnostics.py │ ├── helpers.py │ ├── manifest.json │ ├── number.py │ ├── sensor.py │ └── translations │ └── en.json ├── hacs.json ├── planner_evaluation_chart.png ├── planning_example.png ├── requirements.test.txt ├── requirements.txt ├── scripts ├── develop ├── lint └── setup ├── setup.cfg └── tests ├── __init__.py ├── bandit.yaml ├── conftest.py ├── test_config_flow.py └── test_planner.py /.devcontainer: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dala318/nordpool_planner", 3 | "image": "mcr.microsoft.com/devcontainers/python:3.12", 4 | "postCreateCommand": "scripts/setup", 5 | "forwardPorts": [ 6 | 8123 7 | ], 8 | "portsAttributes": { 9 | "8123": { 10 | "label": "Home Assistant", 11 | "onAutoForward": "notify" 12 | } 13 | }, 14 | "customizations": { 15 | "vscode": { 16 | "extensions": [ 17 | "charliermarsh.ruff", 18 | "github.vscode-pull-request-github", 19 | "ms-python.python", 20 | "ms-python.vscode-pylance", 21 | "ryanluker.vscode-coverage-gutters" 22 | ], 23 | "settings": { 24 | "files.eol": "\n", 25 | "editor.tabSize": 4, 26 | "editor.formatOnPaste": true, 27 | "editor.formatOnSave": true, 28 | "editor.formatOnType": false, 29 | "files.trimTrailingWhitespace": true, 30 | "python.analysis.typeCheckingMode": "basic", 31 | "python.analysis.autoImportCompletions": true, 32 | "python.defaultInterpreterPath": "/usr/local/bin/python", 33 | "[python]": { 34 | "editor.defaultFormatter": "charliermarsh.ruff" 35 | } 36 | } 37 | } 38 | }, 39 | "remoteUser": "vscode", 40 | "features": {} 41 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | patreon: Dala318 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/hacs.yaml: -------------------------------------------------------------------------------- 1 | name: HACS Action 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | hacs: 9 | name: HACS Action 10 | runs-on: "ubuntu-latest" 11 | steps: 12 | - name: HACS Action 13 | uses: "hacs/action@main" 14 | with: 15 | category: "integration" -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | # schedule: 7 | # - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: home-assistant/actions/hassfest@master 15 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: "Release" 2 | 3 | on: 4 | release: 5 | types: 6 | - "published" 7 | 8 | permissions: {} 9 | 10 | jobs: 11 | release: 12 | name: "Release" 13 | runs-on: "ubuntu-latest" 14 | permissions: 15 | contents: write 16 | steps: 17 | - name: "Checkout the repository" 18 | uses: "actions/checkout@v4" 19 | 20 | - name: "Adjust version number" 21 | shell: "bash" 22 | run: | 23 | yq -i -o json '.version="${{ github.event.release.tag_name }}"' \ 24 | "${{ github.workspace }}/custom_components/nordpool_planner/manifest.json" 25 | 26 | - name: "ZIP the integration directory" 27 | shell: "bash" 28 | run: | 29 | cd "${{ github.workspace }}/custom_components/nordpool_planner" 30 | zip nordpool_planner.zip -r ./ 31 | 32 | - name: "Upload the ZIP file to the release" 33 | uses: softprops/action-gh-release@v2.0.8 34 | with: 35 | files: ${{ github.workspace }}/custom_components/nordpool_planner/nordpool_planner.zip -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Python tests 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | paths: 7 | - '**.py' 8 | push: 9 | paths: 10 | - '**.py' 11 | 12 | jobs: 13 | tests: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python-version: ['3.12'] 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install -r requirements.test.txt 31 | - name: Full test with pytest 32 | run: pytest --cov=. --cov-config=.coveragerc --cov-report xml:coverage.xml -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | .HA_VERSION 3 | __pycache__ 4 | config_entry-nordpool_planner*.json 5 | home-assistant* 6 | custom_components/.gitignore 7 | /*.yaml 8 | .idea/ 9 | .storage/ 10 | blueprints/ 11 | www/ 12 | repos/ -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Run Home Assistant on port 8123", 6 | "type": "shell", 7 | "command": "scripts/develop", 8 | "problemMatcher": [] 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Joonas Pulakka 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nordpool_planner custom component for Home Assistant 2 | 3 | Requires https://github.com/custom-components/nordpool (not tested with new [HA native Nordpool](https://www.home-assistant.io/integrations/nordpool/), likely the sensor attributes are not same as the custom integration) or https://github.com/JaccoR/hass-entso-e 4 | 5 | > **NOTE**: This is a based on https://github.com/jpulakka/nordpool_diff 6 | 7 | [Nord Pool](https://www.nordpoolgroup.com/) gives you spot prices, but making good use of those prices is not easy. 8 | This component provides various a boolean if now is the right time to activate high consumption based on future prices in a specified range. Given a time-span from now and an number of hours forward it searches for the start-time that has the lowest average price over a specified duration window. 9 | 10 | Apart from potentially saving some money, this kind of temporal shifting of consumption can also save the environment, because expensive peaks are produced by dirtier energy sources. Also helps solving Europe's electricity crisis. 11 | 12 | ## Installation 13 | 14 | ### Option 1: HACS 15 | 1. Install and configure https://github.com/custom-components/nordpool first. 16 | 2. Go to HACS -> Integrations 17 | 3. Click the three dots on the top right and select `Custom Repositories` 18 | 4. Enter `https://github.com/dala318/nordpool_planner` as repository, select the category `Integration` and click Add 19 | 5. A new custom integration shows up for installation (Nordpool Planner) - install it 20 | 6. Restart Home Assistant 21 | 22 | ### Option 2: Manual 23 | 24 | 1. Install and configure https://github.com/custom-components/nordpool first. 25 | 2. Copy the `nordpool_planner` folder to HA `/custom_components/nordpool_planner/` 26 | 3. Restart Home Assistant 27 | 28 | ### Configuration 29 | 30 | > **IMPORTANT NOTE**: With version 2 configuration via `configuration.yaml` is no longer possible. Converting that configuration via "import config" is still not implemented. You wil have to remove the old now broken manual configuration and create a new via the GUI. 31 | 32 | During setup some preconditions and selections are needed: 33 | 34 | * Give a name to the service 35 | * Select type "Moving" or "Static", more about these below (static is still untested) 36 | * Select Prices entity from list to base states on (ENTSO-e are selectable but not as well tested) 37 | * Select which optional features you want activated, more about these below as well 38 | * Submit and set your configuration parameters 39 | 40 | ### Moving 41 | 42 | Two non-optional configuration entities will be created and you need to set these to a value that matches your consumption profile. 43 | 44 | * `search_length` specifies how many hours ahead to search for lowest price. 45 | * `duration` specifies how large window to average when searching for lowest price 46 | 47 | The service will then take `duration` number of consecutive prices from the nordpool sensor starting from `now` and average them, then shift start one hour and repeat, until reaching `search_length` from now. 48 | If no optional features activated the `duration` window in the current range of prices within `search_length` with lowest average is selected as cheapest and the `low_cost` entity will turn on if `now` is within those hours. 49 | 50 | In general you should set `search_length` to a value how long you could wait to activate high-consumption device and `duration` to how long it have to be kept active. But you have to test different settings to find your optimal configuration. 51 | 52 | What should be said is that since the `search_length` window is continuously moving forward for every hour that passes the lowest cost `duration` may change as new prices comes inside range of search. There is also no guarantee that it will keep active for `duration` once activated. 53 | 54 | ### Static 55 | 56 | > **NOT FINISHED**: This version of planner is still not fully functional, need some more work to work properly. For now the planner will search for the remaining duration (duration - spent-hours) in the remaining time-span. This means that as you close in to fulfilling the `duration` it will get smaller and it could be that the active time is aborted for a while since there is easier to find cheaper average further ahead. Normally this should not happen as the price-curve in most cases has a concave shape and once you have found the initial best match for cheap hours it includes both the falling and rising edge of curve (will only get more expensive closer to the `end_hour`) 57 | > 58 | > [Open issues for Static planner](https://github.com/dala318/nordpool_planner/issues?q=is%3Aissue%20state%3Aopen%20label%3Astatic_planner) 59 | 60 | Three non-optional configuration entities will be created and you need to set these to a value that matches your consumption profile. 61 | 62 | * `start_hour` specifies the time of day the searching shall start. 63 | * `end_hour` specifies the time of day the searching shall stop. 64 | * `duration` For now this entity specified how many hours of low-price shall be found inside the search range. 65 | 66 | More to come about the expected behavior once it fully implemented. 67 | 68 | ## Optional features 69 | 70 | ### Accept cost 71 | 72 | Creates a configuration number entity slider that accepts the first price that has an average price below this value, regardless if there are lower prices further ahead. 73 | 74 | ### Accept rate 75 | 76 | Creates a configuration number entity slider that accepts the first price that has an average price-rate to Nordpool average (range / overall) below this value, regardless if there are lower prices further ahead. 77 | 78 | This is more dynamic in the sense that it adapts to overall price level, but there are some consideration you need to think of if If Nordpool-average or range-average happens to be Zero or lower (and extra logic may have to be implemented). 79 | 80 | * If both negative it will activate, makes no sense to compare inverted rates (negative / negative = positive, but then above set rate is wanted) 81 | * If both zero it will activate, rate is infinite (division by zero, but average is low) 82 | * If only Nordpool average is zero the rate will not work (no feasible rate can be calculated) 83 | 84 | In general if you select to have an `accept_rate` active you should also have an `accept_price` set to at least 0 (or quite low) to make it work as expected as the rate can vary quite much when dividing small numbers. 85 | 86 | ### High cost 87 | 88 | This was requested as an extra feature and creates a binary sensor which tell in the current `duration` has the highest cost in the `search_length`. It's to large extent the inverse of the standard `low_cost` entity but without the extra options for `accept_cost` or `accept_rate`. 89 | 90 | ### Starts at 91 | 92 | No extra logic, just creates extra sensor entities that tell in plain values when each of the binary sensors will activate. Same value that is in the extra_attributes of the binary sensor. 93 | 94 | ## Binary sensor attributes 95 | 96 | Apart from the true/false if now is the time to turn on electricity usage the sensor provides some attributes. 97 | 98 | `starts_at` tell when the next low-point starts 99 | 100 | `cost_at` tell what the average cost is at the lowest point identified 101 | 102 | `now_cost_rate` tell a comparison current price / best average. Is just a comparison to how much more expensive the electricity is right now compared to the found slot. E.g. 2 means you could half the cost by waiting for the found slot. It will turn UNAVAILABLE if best average is zero 103 | 104 | ## Automation blueprints 105 | 106 | ### Fixed temp and offset 107 | 108 | Import this blueprint and choose the nordpool_planner low & high cost states, and the climate entity you want to control. 109 | 110 | [![Fixed temp 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%2Fdala318%2Fnordpool_planner%2Fblob%2Fmaster%2Fblueprints%2Fautomation%2Fthermostat_fixed.yaml) 111 | 112 | Now, whenever the price goes up or down, Nordpool Planner will change the temperature based on the price. 113 | 114 | ### Based on input numbers 115 | 116 | First make sure to create two input number entities for `base temperature` and `offset` 117 | 118 | Import this blueprint and choose your newly created input numbers, the nordpool_planner low & high cost states, and the climate entity you want to control. 119 | 120 | [![Dynamic 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%2Fdala318%2Fnordpool_planner%2Fblob%2Fmaster%2Fblueprints%2Fautomation%2Fthermostat_number.yaml) 121 | 122 | Now, whenever you change the input numbers or the price goes up or down, Nordpool Planner will change the temperature based on the price and your set numbers. 123 | 124 | ## Usage 125 | 126 | Some words should be said about the usage of planner and how it behaves. 127 | 128 | The search length variable should be set to to a value within which you could accept no high electricity usage, and the ratio window/search should somewhat correspond to the active/passive time of your main user of electricity. Still, the search window for the optimal spot to electricity is moving along in front of current time, so there might be a longer duration of passive usage than the search length. Therefor keeping the search length low (3-5h) should likely be optimal, unless you have a large storage capacity of electricity/heat that can wait for a longer duration and when cheap electricity draw as much as possible. 129 | 130 | If to explain by an image, first orange is now, second orange is `search_length` ahead in time, width of green is `duration` and placed where it has found the cheapest average price within the orange. 131 | 132 | ![image](planning_example.png) 133 | 134 | Try it and feedback how it works or if there are any improvement to be done! 135 | 136 | ### Tuning your settings 137 | 138 | I found it useful to setup a simple history graph chart comparing the values from `nordpool`, `nordpool_diff` and `nordpool_planner` like this. 139 | 140 | ![image](planner_evaluation_chart.png) 141 | 142 | Where from top to bottom my named entities are: 143 | 144 | * nordpool_planner: duration 3 in search_length 10, accept_cost 2.0 145 | * nordpool_planner: duration 2 in search_length 5, accept_cost 2.0 and accept_rate 0.7 146 | * nordpool average: just a template sensor extracting the nordpool attribute average to an entity for easier tracking and comparisons "{{ state_attr('sensor.nordpool_kwh_se3_sek_3_10_025', 'average') | float }}" 147 | * nordpool 148 | * nordpool_diff: 149 | -------------------------------------------------------------------------------- /blueprints/automation/thermostat_fixed.yaml: -------------------------------------------------------------------------------- 1 | blueprint: 2 | name: Nordpool Planner - Control Climate (fixed) 3 | description: >- 4 | Control a climate entity with Nordpool Planner (https://github.com/dala318/nordpool_planner) 5 | and an input number as a base temperature. 6 | The climate target temperature will update whenever input number is changed, or Nordpool Planner updates. 7 | domain: automation 8 | input: 9 | binary_sensor_low: 10 | name: Low-cost state 11 | description: Nordpool Planner Low cost binary_sensor 12 | selector: 13 | entity: 14 | integration: nordpool_planner 15 | domain: binary_sensor 16 | binary_sensor_high: 17 | name: High-cost state 18 | description: Nordpool Planner High cost binary_sensor 19 | selector: 20 | entity: 21 | integration: nordpool_planner 22 | domain: binary_sensor 23 | base_temp: 24 | name: Base Setpoint Temperature 25 | description: Input Number Helper for base setpoint temperature 26 | selector: 27 | number: 28 | min: 15.0 29 | max: 35.0 30 | step: 0.5 31 | unit_of_measurement: celcius 32 | mode: slider 33 | default: 20 34 | offset_temp: 35 | name: Offset Temperature 36 | description: Input Number Helper for adjusting temperature 37 | selector: 38 | number: 39 | min: 0.5 40 | max: 10.0 41 | step: 0.5 42 | unit_of_measurement: celcius 43 | mode: slider 44 | default: 1 45 | climate: 46 | name: Climate 47 | description: Climate Entity to control 48 | selector: 49 | entity: 50 | domain: climate 51 | source_url: https://github.com/dala318/nordpool_planner/blob/master/blueprints/automation/thermostat_fixed.yaml 52 | mode: restart 53 | max_exceeded: silent 54 | trigger: 55 | - platform: homeassistant 56 | event: start 57 | - platform: state 58 | entity_id: 59 | - !input binary_sensor_low 60 | - !input binary_sensor_high 61 | action: 62 | - variables: 63 | sensor_low: !input binary_sensor_low 64 | sensor_high: !input binary_sensor_high 65 | base_temp: !input base_temp 66 | offset_temp: !input offset_temp 67 | climate: !input climate 68 | - service: climate.set_temperature 69 | data_template: 70 | entity_id: !input climate 71 | temperature: >- 72 | {% set set_temp = base_temp | float(default=0) %} 73 | {% if is_state(sensor_low, "on") %} 74 | {% set set_temp = set_temp + offset_temp | float(default=0) %} 75 | {% elif is_state(sensor_high, "on") %} 76 | {% set set_temp = set_temp - offset_temp | float(default=0) %} 77 | {% endif %} 78 | {{ set_temp }} 79 | -------------------------------------------------------------------------------- /blueprints/automation/thermostat_number.yaml: -------------------------------------------------------------------------------- 1 | blueprint: 2 | name: Nordpool Planner - Control Climate (input number) 3 | description: >- 4 | Control a climate entity with Nordpool Planner (https://github.com/dala318/nordpool_planner) 5 | and an input number as a base temperature. 6 | The climate target temperature will update whenever input number is changed, or Nordpool Planner updates. 7 | domain: automation 8 | input: 9 | binary_sensor_low: 10 | name: Low-cost state 11 | description: Nordpool Planner Low cost binary_sensor 12 | selector: 13 | entity: 14 | integration: nordpool_planner 15 | domain: binary_sensor 16 | binary_sensor_high: 17 | name: High-cost state 18 | description: Nordpool Planner High cost binary_sensor 19 | selector: 20 | entity: 21 | integration: nordpool_planner 22 | domain: binary_sensor 23 | base_temp: 24 | name: Base Setpoint Temperature 25 | description: Input Number Helper for base setpoint temperature 26 | selector: 27 | entity: 28 | domain: input_number 29 | offset_temp: 30 | name: Offset Temperature 31 | description: Input Number Helper for adjusting temperature 32 | selector: 33 | entity: 34 | domain: input_number 35 | climate: 36 | name: Climate 37 | description: Climate Entity to control 38 | selector: 39 | entity: 40 | domain: climate 41 | source_url: https://github.com/dala318/nordpool_planner/blob/master/blueprints/automation/thermostat_number.yaml 42 | mode: restart 43 | max_exceeded: silent 44 | trigger: 45 | - platform: homeassistant 46 | event: start 47 | - platform: state 48 | entity_id: 49 | - !input binary_sensor_low 50 | - !input binary_sensor_high 51 | - !input base_temp 52 | - !input offset_temp 53 | action: 54 | - variables: 55 | sensor_low: !input binary_sensor_low 56 | sensor_high: !input binary_sensor_high 57 | base_temp: !input base_temp 58 | offset_temp: !input offset_temp 59 | climate: !input climate 60 | - service: climate.set_temperature 61 | data_template: 62 | entity_id: !input climate 63 | temperature: >- 64 | {% set set_temp = states(base_temp) | float(default=0) %} 65 | {% if is_state(sensor_low, "on") %} 66 | {% set set_temp = set_temp + states(offset_temp) | float(default=0) %} 67 | {% elif is_state(sensor_high, "on") %} 68 | {% set set_temp = set_temp - states(offset_temp) | float(default=0) %} 69 | {% endif %} 70 | {{ set_temp }} 71 | -------------------------------------------------------------------------------- /config/configuration.yaml: -------------------------------------------------------------------------------- 1 | # https://www.home-assistant.io/integrations/default_config/ 2 | default_config: 3 | 4 | # https://www.home-assistant.io/integrations/homeassistant/ 5 | homeassistant: 6 | debug: true 7 | 8 | # https://www.home-assistant.io/integrations/logger/ 9 | logger: 10 | default: info 11 | logs: 12 | custom_components.nordpool_planner: debug -------------------------------------------------------------------------------- /custom_components/nordpool_planner/__init__.py: -------------------------------------------------------------------------------- 1 | """Main package for planner.""" 2 | 3 | from __future__ import annotations 4 | 5 | import datetime as dt 6 | import logging 7 | 8 | from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry 9 | from homeassistant.const import ( 10 | ATTR_UNIT_OF_MEASUREMENT, 11 | STATE_UNAVAILABLE, 12 | STATE_UNKNOWN, 13 | Platform, 14 | ) 15 | from homeassistant.core import HomeAssistant, HomeAssistantError 16 | from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo 17 | from homeassistant.helpers.entity import Entity 18 | from homeassistant.helpers.event import ( 19 | async_track_state_change_event, 20 | async_track_time_change, 21 | ) 22 | from homeassistant.util import dt as dt_util 23 | 24 | from .config_flow import NordpoolPlannerConfigFlow 25 | from .const import ( 26 | CONF_ACCEPT_COST_ENTITY, 27 | CONF_ACCEPT_RATE_ENTITY, 28 | CONF_DURATION_ENTITY, 29 | CONF_END_TIME_ENTITY, 30 | CONF_HEALTH_ENTITY, 31 | CONF_PRICES_ENTITY, 32 | CONF_SEARCH_LENGTH_ENTITY, 33 | CONF_START_TIME_ENTITY, 34 | CONF_TYPE, 35 | CONF_TYPE_MOVING, 36 | CONF_TYPE_STATIC, 37 | CONF_USED_HOURS_LOW_ENTITY, 38 | DOMAIN, 39 | NAME_FILE_READER, 40 | PATH_FILE_READER, 41 | PlannerStates, 42 | ) 43 | from .helpers import get_np_from_file 44 | 45 | _LOGGER = logging.getLogger(__name__) 46 | 47 | PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.NUMBER, Platform.SENSOR] 48 | 49 | 50 | async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 51 | """Set up this integration using UI.""" 52 | config_entry.async_on_unload(config_entry.add_update_listener(async_reload_entry)) 53 | 54 | if DOMAIN not in hass.data: 55 | hass.data[DOMAIN] = {} 56 | 57 | if config_entry.entry_id not in hass.data[DOMAIN]: 58 | planner = NordpoolPlanner(hass, config_entry) 59 | await planner.async_setup() 60 | hass.data[DOMAIN][config_entry.entry_id] = planner 61 | 62 | if config_entry is not None: 63 | if config_entry.source == SOURCE_IMPORT: 64 | hass.async_create_task( 65 | hass.config_entries.async_remove(config_entry.entry_id) 66 | ) 67 | return False 68 | 69 | await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) 70 | return True 71 | 72 | 73 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 74 | """Unloading a config_flow entry.""" 75 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 76 | if unload_ok: 77 | planner = hass.data[DOMAIN].pop(entry.entry_id) 78 | planner.cleanup() 79 | return unload_ok 80 | 81 | 82 | async def async_reload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None: 83 | """Reload the config entry.""" 84 | await async_unload_entry(hass, config_entry) 85 | await async_setup_entry(hass, config_entry) 86 | 87 | 88 | async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 89 | """Migrate old entry.""" 90 | _LOGGER.debug( 91 | "Attempting migrating configuration from version %s.%s", 92 | config_entry.version, 93 | config_entry.minor_version, 94 | ) 95 | 96 | class MigrateError(HomeAssistantError): 97 | """Error to indicate there is was an error in version migration.""" 98 | 99 | installed_version = NordpoolPlannerConfigFlow.VERSION 100 | installed_minor_version = NordpoolPlannerConfigFlow.MINOR_VERSION 101 | 102 | new_data = {**config_entry.data} 103 | new_options = {**config_entry.options} 104 | 105 | if config_entry.version > installed_version: 106 | _LOGGER.warning( 107 | "Downgrading major version from %s to %s is not allowed", 108 | config_entry.version, 109 | installed_version, 110 | ) 111 | return False 112 | 113 | if ( 114 | config_entry.version == installed_version 115 | and config_entry.minor_version > installed_minor_version 116 | ): 117 | _LOGGER.warning( 118 | "Downgrading minor version from %s.%s to %s.%s is not allowed", 119 | config_entry.version, 120 | config_entry.minor_version, 121 | installed_version, 122 | installed_minor_version, 123 | ) 124 | return False 125 | 126 | def options_1x_to_20(options: dict, data: dict, hass: HomeAssistant): 127 | try: 128 | np_entity = hass.states.get(data[CONF_PRICES_ENTITY]) 129 | uom = np_entity.attributes.get(ATTR_UNIT_OF_MEASUREMENT) 130 | options.pop("currency") 131 | options[ATTR_UNIT_OF_MEASUREMENT] = uom 132 | except (IndexError, KeyError) as err: 133 | _LOGGER.warning("Could not extract currency from Prices entity") 134 | raise MigrateError from err 135 | return options 136 | 137 | def data_20_to_21(data: dict): 138 | if entity_id := data.pop("np_entity"): 139 | data[CONF_PRICES_ENTITY] = entity_id 140 | return data 141 | _LOGGER.warning('Could not find "np_entity" in config_entry') 142 | raise MigrateError('Could not find "np_entity" in config_entry') 143 | 144 | def data_21_to_22(data: dict): 145 | if data[CONF_TYPE] == CONF_TYPE_STATIC: 146 | data[CONF_USED_HOURS_LOW_ENTITY] = True 147 | data[CONF_START_TIME_ENTITY] = True 148 | if CONF_HEALTH_ENTITY not in data: 149 | data[CONF_HEALTH_ENTITY] = True 150 | return data 151 | 152 | if config_entry.version == 1: 153 | try: 154 | # Version 1.x to 2.0 155 | new_options = options_1x_to_20(new_options, new_data, hass) 156 | # Version 2.0 to 2.1 157 | new_data = data_20_to_21(new_data) 158 | # Version 2.1 to 2.2 159 | new_data = data_21_to_22(new_data) 160 | except MigrateError: 161 | _LOGGER.warning("Error while upgrading from version 1.x to 2.1") 162 | return False 163 | 164 | if config_entry.version == 2 and config_entry.minor_version == 0: 165 | try: 166 | # Version 2.0 to 2.1 167 | new_data = data_20_to_21(new_data) 168 | # Version 2.1 to 2.2 169 | new_data = data_21_to_22(new_data) 170 | except MigrateError: 171 | _LOGGER.warning("Error while upgrading from version 2.0 to 2.1") 172 | return False 173 | 174 | if config_entry.version == 2 and config_entry.minor_version == 1: 175 | try: 176 | # Version 2.1 to 2.2 177 | new_data = data_21_to_22(new_data) 178 | except MigrateError: 179 | _LOGGER.warning("Error while upgrading from version 2.1 to 2.2") 180 | return False 181 | 182 | hass.config_entries.async_update_entry( 183 | config_entry, 184 | data=new_data, 185 | options=new_options, 186 | version=installed_version, 187 | minor_version=installed_minor_version, 188 | ) 189 | _LOGGER.info( 190 | "Migration configuration from version %s.%s to %s.%s successful", 191 | config_entry.version, 192 | config_entry.minor_version, 193 | installed_version, 194 | installed_minor_version, 195 | ) 196 | return True 197 | 198 | 199 | class NordpoolPlanner: 200 | """Planner base class.""" 201 | 202 | _hourly_update = None 203 | 204 | def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: 205 | """Initialize my coordinator.""" 206 | self._hass = hass 207 | self._config = config_entry 208 | self._state_change_listeners = [] 209 | 210 | # Input entities 211 | self._prices_entity = PricesEntity(self._config.data[CONF_PRICES_ENTITY]) 212 | # TODO: Remove, likely not needed anymore as async_track_time_change in async_setup() will ensure update every hour 213 | # self._state_change_listeners.append( 214 | # async_track_state_change_event( 215 | # self._hass, 216 | # [self._prices_entity.unique_id], 217 | # self._async_input_changed, 218 | # ) 219 | # ) 220 | 221 | # Configuration entities 222 | self._duration_number_entity = "" 223 | self._accept_cost_number_entity = "" 224 | self._accept_rate_number_entity = "" 225 | self._search_length_number_entity = "" 226 | self._start_time_number_entity = "" 227 | self._end_time_number_entity = "" 228 | # TODO: Make dictionary? 229 | 230 | # Output entities 231 | self._output_listeners: dict[str, NordpoolPlannerEntity] = {} 232 | 233 | # Local state variables 234 | self._last_update = None 235 | self.low_hours = None 236 | self._planner_status = NordpoolPlannerStatus() 237 | 238 | # Output states 239 | self.low_cost_state = NordpoolPlannerState() 240 | self.high_cost_state = NordpoolPlannerState() 241 | 242 | def as_dict(self): 243 | """For diagnostics serialization.""" 244 | res = self.__dict__.copy() 245 | for k, i in res.copy().items(): 246 | if "_number_entity" in k: 247 | res[k] = {"id": i, "value": self.get_number_entity_value(i)} 248 | return res 249 | 250 | async def async_setup(self): 251 | """Post initialization setup.""" 252 | # Ensure an update is done on every hour 253 | self._hourly_update = async_track_time_change( 254 | self._hass, self.scheduled_update, minute=0, second=0 255 | ) 256 | 257 | @property 258 | def name(self) -> str: 259 | """Name of planner.""" 260 | return self._config.data["name"] 261 | 262 | @property 263 | def price_sensor_id(self) -> str: 264 | """Entity id of source sensor.""" 265 | return self._prices_entity.unique_id 266 | 267 | @property 268 | def price_now(self) -> str: 269 | """Current price from source sensor.""" 270 | return self._prices_entity.current_price_attr 271 | 272 | @property 273 | def planner_status(self) -> NordpoolPlannerStatus: 274 | """Current planner status.""" 275 | return self._planner_status 276 | 277 | @property 278 | def _duration(self) -> int: 279 | """Get duration parameter.""" 280 | return self.get_number_entity_value(self._duration_number_entity, integer=True) 281 | 282 | @property 283 | def _is_moving(self) -> bool: 284 | """Get if planner is of type Moving.""" 285 | return self._config.data[CONF_TYPE] == CONF_TYPE_MOVING 286 | 287 | @property 288 | def _is_static(self) -> bool: 289 | """Get if planner is of type Static.""" 290 | return self._config.data[CONF_TYPE] == CONF_TYPE_STATIC 291 | 292 | @property 293 | def _search_length(self) -> int: 294 | """Get search length parameter.""" 295 | return self.get_number_entity_value( 296 | self._search_length_number_entity, integer=True 297 | ) 298 | 299 | @property 300 | def _start_time(self) -> int: 301 | """Get start time parameter.""" 302 | return self.get_number_entity_value( 303 | self._start_time_number_entity, integer=True 304 | ) 305 | 306 | @property 307 | def _end_time(self) -> int: 308 | """Get end time parameter.""" 309 | return self.get_number_entity_value(self._end_time_number_entity, integer=True) 310 | 311 | @property 312 | def _accept_cost(self) -> float: 313 | """Get accept cost parameter.""" 314 | return self.get_number_entity_value(self._accept_cost_number_entity) 315 | 316 | @property 317 | def _accept_rate(self) -> float: 318 | """Get accept rate parameter.""" 319 | return self.get_number_entity_value(self._accept_rate_number_entity) 320 | 321 | def cleanup(self): 322 | """Cleanup by removing event listeners.""" 323 | for lister in self._state_change_listeners: 324 | lister() 325 | 326 | def get_number_entity_value( 327 | self, entity_id: str, integer: bool = False 328 | ) -> float | int | None: 329 | """Get value of generic entity parameter.""" 330 | if entity_id: 331 | try: 332 | entity = self._hass.states.get(entity_id) 333 | state = entity.state 334 | value = float(state) 335 | if integer: 336 | return int(value) 337 | return value # noqa: TRY300 338 | except (TypeError, ValueError): 339 | _LOGGER.warning( 340 | 'Could not convert value "%s" of entity %s to expected format', 341 | state, 342 | entity_id, 343 | ) 344 | except Exception as e: # noqa: BLE001 345 | _LOGGER.error( 346 | 'Unknown error wen reading and converting "%s": %s', 347 | entity_id, 348 | e, 349 | ) 350 | else: 351 | _LOGGER.debug("No entity defined") 352 | return None 353 | 354 | def register_input_entity_id(self, entity_id, conf_key) -> None: 355 | """Register input entity id.""" 356 | # Input numbers 357 | if conf_key == CONF_DURATION_ENTITY: 358 | self._duration_number_entity = entity_id 359 | elif conf_key == CONF_ACCEPT_COST_ENTITY: 360 | self._accept_cost_number_entity = entity_id 361 | elif conf_key == CONF_ACCEPT_RATE_ENTITY: 362 | self._accept_rate_number_entity = entity_id 363 | elif conf_key == CONF_SEARCH_LENGTH_ENTITY: 364 | self._search_length_number_entity = entity_id 365 | elif conf_key == CONF_START_TIME_ENTITY: 366 | self._start_time_number_entity = entity_id 367 | elif conf_key == CONF_END_TIME_ENTITY: 368 | self._end_time_number_entity = entity_id 369 | else: 370 | _LOGGER.warning( 371 | 'An entity "%s" was registered for callback but no match for key "%s"', 372 | entity_id, 373 | conf_key, 374 | ) 375 | self._state_change_listeners.append( 376 | async_track_state_change_event( 377 | self._hass, 378 | [entity_id], 379 | self._async_input_changed, 380 | ) 381 | ) 382 | 383 | def register_output_listener_entity( 384 | self, entity: NordpoolPlannerEntity, conf_key="" 385 | ) -> None: 386 | """Register output entity.""" 387 | if conf_key in self._output_listeners: 388 | _LOGGER.warning( 389 | 'An output listener with key "%s" and unique id "%s" is overriding previous entity "%s"', 390 | conf_key, 391 | self._output_listeners.get(conf_key).entity_id, 392 | entity.entity_id, 393 | ) 394 | self._output_listeners[conf_key] = entity 395 | 396 | def get_device_info(self) -> DeviceInfo: 397 | """Get device info to group entities.""" 398 | return DeviceInfo( 399 | identifiers={(DOMAIN, self._config.entry_id)}, 400 | name=self.name, 401 | manufacturer="Nordpool", 402 | entry_type=DeviceEntryType.SERVICE, 403 | model="Forecast", 404 | ) 405 | 406 | def scheduled_update(self, _): 407 | """Scheduled updates callback.""" 408 | _LOGGER.debug("Scheduled callback") 409 | self.update() 410 | 411 | def input_changed(self, value): 412 | """Input entity callback to initiate a planner update.""" 413 | _LOGGER.debug("Sensor change event from callback: %s", value) 414 | self.update() 415 | 416 | async def _async_input_changed(self, event): 417 | """Input entity change callback from state change event.""" 418 | new_state = event.data.get("new_state") 419 | _LOGGER.debug("Sensor change event from HASS: %s", new_state) 420 | self.update() 421 | 422 | def update(self): 423 | """Planner update call function.""" 424 | _LOGGER.debug("Updating planner") 425 | 426 | # Update inputs 427 | if not self._prices_entity.update(self._hass) and not self._prices_entity.valid: 428 | self.set_unavailable() 429 | self._planner_status.status = PlannerStates.Error 430 | self._planner_status.running_text = "No valid Price data" 431 | return 432 | 433 | if not self._duration: 434 | _LOGGER.warning("Aborting update since no valid Duration") 435 | self._planner_status.status = PlannerStates.Error 436 | self._planner_status.running_text = "No valid Duration data" 437 | return 438 | 439 | if self._is_moving and not self._search_length: 440 | _LOGGER.warning("Aborting update since no valid Search length") 441 | self._planner_status.status = PlannerStates.Error 442 | self._planner_status.running_text = "No valid Search-Length data" 443 | return 444 | 445 | if self._is_static and not (self._start_time and self._end_time): 446 | _LOGGER.warning("Aborting update since no valid Start or end time") 447 | self._planner_status.status = PlannerStates.Error 448 | self._planner_status.running_text = "No valid Start-Time or End-Time" 449 | return 450 | 451 | # If come this far no running error texts relevant (for now...) 452 | self._planner_status.status = PlannerStates.Ok 453 | self._planner_status.running_text = "ok" 454 | self._planner_status.config_text = "ok" 455 | 456 | if self._is_moving and self._search_length < self._duration: 457 | self._planner_status.status = PlannerStates.Warning 458 | self._planner_status.config_text = "Duration is Lager than Search-Length" 459 | 460 | # if self._is_static and (self._end_time - self._start_time) < self._duration: 461 | # self._planner_status.status = PlannerStates.Warning 462 | # self._planner_status.config_text = "Duration is Lager than Search-Window" 463 | 464 | # initialize local variables 465 | now = dt_util.now() 466 | 467 | if self._is_static and self.low_hours is not None: 468 | if self.low_hours >= self._duration: 469 | _LOGGER.debug("No need to update, quota of hours fulfilled") 470 | self.set_done_for_now() 471 | self._planner_status.status = PlannerStates.Idle 472 | self._planner_status.running_text = "Quota of hours fulfilled" 473 | return 474 | duration = dt.timedelta(hours=max(0, self._duration - self.low_hours) - 1) 475 | # TODO: Need to fix this so that the duration amount of hours are found in range for static 476 | # duration = dt.timedelta(hours=1) 477 | else: 478 | duration = dt.timedelta(hours=self._duration - 1) 479 | 480 | # Initiate states and variables for Moving planner 481 | if self._is_moving: 482 | start_time = now 483 | end_time = now + dt.timedelta(hours=self._search_length) 484 | 485 | # Initiate states and variables for Static planner 486 | elif self._is_static: 487 | start_time = now.replace( 488 | hour=self._start_time, minute=0, second=0, microsecond=0 489 | ) 490 | end_time = now.replace( 491 | hour=self._end_time, minute=0, second=0, microsecond=0 492 | ) 493 | # First ensure end is after start (spans over midnight) 494 | if end_time < start_time: 495 | # Have not started range yet 496 | if end_time < now: 497 | end_time += dt.timedelta(days=1) 498 | # Started range "yesterday" 499 | else: 500 | start_time -= dt.timedelta(days=1) 501 | # In active range 502 | if start_time < now and end_time > now: 503 | # Bump up start to now so that prices in the past is not used 504 | start_time = now 505 | 506 | # Invalid planner type 507 | else: 508 | _LOGGER.warning("Aborting update since unknown planner type") 509 | self._planner_status.status = PlannerStates.Error 510 | self._planner_status.config_text = "Bad planner type" 511 | return 512 | 513 | prices_groups: list[NordpoolPricesGroup] = [] 514 | offset = 0 515 | while True: 516 | start_offset = dt.timedelta(hours=offset) 517 | first_time = start_time + start_offset 518 | last_time = first_time + duration 519 | if offset != 0 and last_time > end_time: 520 | break 521 | offset += 1 522 | prices_group = self._prices_entity.get_prices_group(first_time, last_time) 523 | if not prices_group.valid: 524 | continue 525 | # TODO: Should not end up here, why? 526 | prices_groups.append(prices_group) 527 | 528 | if len(prices_groups) == 0: 529 | _LOGGER.warning( 530 | "Aborting update since no prices fetched in range %s to %s with duration %s", 531 | start_time, 532 | end_time, 533 | duration, 534 | ) 535 | self._planner_status.status = PlannerStates.Warning 536 | self._planner_status.running_text = "No prices in active range" 537 | return 538 | 539 | _LOGGER.debug( 540 | "Processing %s prices_groups found in range %s to %s", 541 | len(prices_groups), 542 | start_time, 543 | end_time, 544 | ) 545 | 546 | accept_cost = self._accept_cost 547 | accept_rate = self._accept_rate 548 | lowest_cost_group: NordpoolPricesGroup = prices_groups[0] 549 | for p in prices_groups: 550 | if accept_cost and p.average < accept_cost: 551 | _LOGGER.debug("Accept cost fulfilled") 552 | self.set_lowest_cost_state(p) 553 | break 554 | if accept_rate: 555 | if self._prices_entity.average_attr <= 0: 556 | if p.average <= 0: 557 | _LOGGER.debug( 558 | "Accept rate indirectly fulfilled (NP average & range average <= 0)" 559 | ) 560 | self.set_lowest_cost_state(p) 561 | break 562 | elif (p.average / self._prices_entity.average_attr) <= accept_rate: 563 | _LOGGER.debug("Accept rate fulfilled") 564 | self.set_lowest_cost_state(p) 565 | break 566 | if p.average < lowest_cost_group.average: 567 | lowest_cost_group = p 568 | else: 569 | self.set_lowest_cost_state(lowest_cost_group) 570 | 571 | highest_cost_group: NordpoolPricesGroup = prices_groups[0] 572 | for p in prices_groups: 573 | if p.average > highest_cost_group.average: 574 | highest_cost_group = p 575 | self.set_highest_cost_state(highest_cost_group) 576 | 577 | if not self._last_update: 578 | pass 579 | elif self._last_update.hour != now.hour: 580 | _LOGGER.debug( 581 | "Swapping hour on change from %s to %s", self._last_update, now 582 | ) 583 | if self._is_static: 584 | if self.low_cost_state.on_at(now): 585 | if self.low_hours is None: 586 | self.low_hours = 1 587 | else: 588 | self.low_hours += 1 589 | if end_time.hour == now.hour: 590 | self.low_hours = 0 591 | self._last_update = now 592 | for listener in self._output_listeners.values(): 593 | listener.update_callback() 594 | 595 | def set_lowest_cost_state(self, prices_group: NordpoolPricesGroup) -> None: 596 | """Set the state to output variable.""" 597 | self.low_cost_state.starts_at = prices_group.start_time 598 | self.low_cost_state.cost_at = prices_group.average 599 | if prices_group.average != 0: 600 | self.low_cost_state.now_cost_rate = ( 601 | self._prices_entity.current_price_attr / prices_group.average 602 | ) 603 | else: 604 | self.low_cost_state.now_cost_rate = STATE_UNAVAILABLE 605 | _LOGGER.debug("Wrote lowest cost state: %s", self.low_cost_state) 606 | 607 | def set_highest_cost_state(self, prices_group: NordpoolPricesGroup) -> None: 608 | """Set the state to output variable.""" 609 | self.high_cost_state.starts_at = prices_group.start_time 610 | self.high_cost_state.cost_at = prices_group.average 611 | if prices_group.average != 0: 612 | self.high_cost_state.now_cost_rate = ( 613 | self._prices_entity.current_price_attr / prices_group.average 614 | ) 615 | else: 616 | self.high_cost_state.now_cost_rate = STATE_UNAVAILABLE 617 | _LOGGER.debug("Wrote highest cost state: %s", self.high_cost_state) 618 | 619 | def set_done_for_now(self) -> None: 620 | """Set output state to off.""" 621 | now_hour = dt_util.now().replace(minute=0, second=0, microsecond=0) 622 | start_hour = now_hour.replace(hour=self._start_time) 623 | if start_hour < now_hour: 624 | start_hour += dt.timedelta(days=1) 625 | self.low_cost_state.starts_at = start_hour 626 | self.low_cost_state.cost_at = STATE_UNAVAILABLE 627 | self.low_cost_state.now_cost_rate = STATE_UNAVAILABLE 628 | self.high_cost_state.starts_at = start_hour 629 | self.high_cost_state.cost_at = STATE_UNAVAILABLE 630 | self.high_cost_state.now_cost_rate = STATE_UNAVAILABLE 631 | _LOGGER.debug("Setting output states to unavailable") 632 | for listener in self._output_listeners.values(): 633 | listener.update_callback() 634 | 635 | def set_unavailable(self) -> None: 636 | """Set output state to unavailable.""" 637 | self.low_cost_state.starts_at = STATE_UNAVAILABLE 638 | self.low_cost_state.cost_at = STATE_UNAVAILABLE 639 | self.low_cost_state.now_cost_rate = STATE_UNAVAILABLE 640 | self.high_cost_state.starts_at = STATE_UNAVAILABLE 641 | self.high_cost_state.cost_at = STATE_UNAVAILABLE 642 | self.high_cost_state.now_cost_rate = STATE_UNAVAILABLE 643 | _LOGGER.debug("Setting output states to unavailable") 644 | for listener in self._output_listeners.values(): 645 | listener.update_callback() 646 | 647 | 648 | class PricesEntity: 649 | """Representation for Nordpool state.""" 650 | 651 | def __init__(self, unique_id: str) -> None: 652 | """Initialize state tracker.""" 653 | self._unique_id = unique_id 654 | self._np = None 655 | 656 | def as_dict(self): 657 | """For diagnostics serialization.""" 658 | return self.__dict__ 659 | 660 | @property 661 | def unique_id(self) -> str: 662 | """Get the unique id.""" 663 | return self._unique_id 664 | 665 | @property 666 | def valid(self) -> bool: 667 | """Get if data is valid.""" 668 | # TODO: Add more checks, make function of those in update() 669 | return self._np is not None 670 | 671 | @property 672 | def _all_prices(self): 673 | if np_prices := self._np.attributes.get("raw_today"): 674 | # For Nordpool format 675 | if self._np.attributes["tomorrow_valid"]: 676 | np_prices += self._np.attributes["raw_tomorrow"] 677 | return np_prices 678 | elif e_prices := self._np.attributes.get("prices"): # noqa: RET505 679 | # For ENTSO-e format 680 | e_prices = [ 681 | {"start": dt_util.parse_datetime(ep["time"]), "value": ep["price"]} 682 | for ep in e_prices 683 | ] 684 | return e_prices # noqa: RET504 685 | return [] 686 | 687 | @property 688 | def average_attr(self): 689 | """Get the average price attribute.""" 690 | if self._np is not None: 691 | if "average_electricity_price" in self._np.entity_id: 692 | # For ENTSO-e average 693 | try: 694 | return float(self._np.state) 695 | except ValueError: 696 | _LOGGER.warning( 697 | 'Could not convert "%s" to float for average sensor "%s"', 698 | self._np.state, 699 | self._np.entity_id, 700 | ) 701 | else: 702 | # For Nordpool format 703 | return self._np.attributes["average"] 704 | return None 705 | 706 | @property 707 | def current_price_attr(self): 708 | """Get the current price attribute.""" 709 | if self._np is not None: 710 | if current := self._np.attributes.get("current_price"): 711 | # For Nordpool format 712 | return current 713 | else: # noqa: RET505 714 | # For general, find in list 715 | now = dt_util.now() 716 | for price in self._all_prices: 717 | if ( 718 | price["start"] < now 719 | and price["start"] + dt.timedelta(hours=1) > now 720 | ): 721 | return price["value"] 722 | return None 723 | 724 | def update(self, hass: HomeAssistant) -> bool: 725 | """Update price in storage.""" 726 | if self._unique_id == NAME_FILE_READER: 727 | np = get_np_from_file(PATH_FILE_READER) 728 | else: 729 | np = hass.states.get(self._unique_id) 730 | 731 | if np is None: 732 | _LOGGER.warning("Got empty data from Nordpool entity %s ", self._unique_id) 733 | elif "today" not in np.attributes and "prices_today" not in np.attributes: 734 | _LOGGER.warning( 735 | "No values for today in Nordpool entity %s ", self._unique_id 736 | ) 737 | else: 738 | _LOGGER.debug( 739 | "Nordpool sensor %s was updated successfully", self._unique_id 740 | ) 741 | if self._np is None: 742 | pass 743 | self._np = np 744 | 745 | if self._np is None: 746 | return False 747 | return True 748 | 749 | def get_prices_group( 750 | self, start: dt.datetime, end: dt.datetime 751 | ) -> NordpoolPricesGroup: 752 | """Get a range of prices from NP given the start and end datetimes. 753 | 754 | Ex. If start is 7:05 and end 10:05, a list of 4 prices will be returned, 755 | 7, 8, 9 & 10. 756 | """ 757 | started = False 758 | selected = [] 759 | for p in self._all_prices: 760 | if p["start"] > start - dt.timedelta(hours=1): 761 | started = True 762 | if p["start"] > end: 763 | break 764 | if started: 765 | selected.append(p) 766 | return NordpoolPricesGroup(selected) 767 | 768 | 769 | class NordpoolPricesGroup: 770 | """A slice if Nordpool prices with helper functions.""" 771 | 772 | def __init__(self, prices) -> None: 773 | """Initialize price group.""" 774 | self._prices = prices 775 | 776 | def __str__(self) -> str: 777 | """Get string representation of class.""" 778 | return f"start_time={self.start_time.strftime("%Y-%m-%d %H:%M")} average={self.average} len(_prices)={len(self._prices)}" 779 | 780 | def __repr__(self) -> str: 781 | """Get string representation for debugging.""" 782 | return type(self).__name__ + f" ({self.__str__()})" 783 | 784 | @property 785 | def valid(self) -> bool: 786 | """Is the price group valid.""" 787 | if len(self._prices) == 0: 788 | # _LOGGER.debug("None-valid price range group, len=%s", len(self._prices)) 789 | return False 790 | return True 791 | 792 | @property 793 | def average(self) -> float: 794 | """The average price of the price group.""" 795 | # if not self.valid: 796 | # _LOGGER.warning( 797 | # "Average set to 1 for invalid price group, should not happen" 798 | # ) 799 | # return 1 800 | return sum([p["value"] for p in self._prices]) / len(self._prices) 801 | 802 | @property 803 | def start_time(self) -> dt.datetime: 804 | """The start time of first price in group.""" 805 | # if not self.valid: 806 | # _LOGGER.warning( 807 | # "Start time set to None for invalid price group, should not happen" 808 | # ) 809 | # return None 810 | return self._prices[0]["start"] 811 | 812 | 813 | class NordpoolPlannerState: 814 | """State attribute representation.""" 815 | 816 | def __init__(self) -> None: 817 | """Initiate states.""" 818 | self.starts_at = STATE_UNKNOWN 819 | self.cost_at = STATE_UNKNOWN 820 | self.now_cost_rate = STATE_UNKNOWN 821 | 822 | def __str__(self) -> str: 823 | """Get string representation of class.""" 824 | return f"start_at={self.starts_at} cost_at={self.cost_at:.2} now_cost_rate={self.now_cost_rate:.2}" 825 | 826 | def as_dict(self): 827 | """For diagnostics serialization.""" 828 | return self.__dict__ 829 | 830 | def on_at(self, time: dt.datetime) -> bool: 831 | """Get boolean state if start is before given timestamp.""" 832 | if self.starts_at not in [ 833 | STATE_UNKNOWN, 834 | STATE_UNAVAILABLE, 835 | ]: 836 | return self.starts_at < time 837 | return False 838 | 839 | 840 | class NordpoolPlannerStatus: 841 | """Status for the overall planner.""" 842 | 843 | def __init__(self) -> None: 844 | """Initiate status.""" 845 | self.status = PlannerStates.Unknown 846 | self.running_text = "" 847 | self.config_text = "" 848 | 849 | 850 | class NordpoolPlannerEntity(Entity): 851 | """Base class for nordpool planner entities.""" 852 | 853 | def __init__( 854 | self, 855 | planner: NordpoolPlanner, 856 | ) -> None: 857 | """Initialize entity.""" 858 | # Input configs 859 | self._planner = planner 860 | self._attr_device_info = planner.get_device_info() 861 | 862 | def as_dict(self): 863 | """For diagnostics serialization.""" 864 | return { 865 | k: v 866 | for k, v in self.__dict__.items() 867 | if not ( 868 | k.startswith("_") 869 | or k in ["hass", "platform", "registry_entry", "device_entry"] 870 | ) 871 | } 872 | 873 | @property 874 | def should_poll(self): 875 | """No need to poll. Coordinator notifies entity of updates.""" 876 | return False 877 | 878 | def update_callback(self) -> None: 879 | """Call from planner that new data available.""" 880 | self.schedule_update_ha_state() 881 | -------------------------------------------------------------------------------- /custom_components/nordpool_planner/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Binary sensor definitions.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | 7 | from homeassistant.components.binary_sensor import ( 8 | BinarySensorEntity, 9 | BinarySensorEntityDescription, 10 | ) 11 | from homeassistant.config_entries import ConfigEntry 12 | from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN 13 | from homeassistant.core import HomeAssistant 14 | from homeassistant.util import dt as dt_util 15 | 16 | from . import NordpoolPlanner, NordpoolPlannerEntity 17 | from .const import CONF_HIGH_COST_ENTITY, CONF_LOW_COST_ENTITY, DOMAIN 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | # LOW_COST_ENTITY_DESCRIPTION = BinarySensorEntityDescription( 22 | # key=CONF_LOW_COST_ENTITY, 23 | # # device_class=BinarySensorDeviceClass.???, 24 | # ) 25 | 26 | 27 | async def async_setup_entry( 28 | hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities 29 | ): 30 | """Create state binary sensor entities for platform.""" 31 | 32 | planner: NordpoolPlanner = hass.data[DOMAIN][config_entry.entry_id] 33 | entities = [] 34 | 35 | if config_entry.data.get(CONF_LOW_COST_ENTITY): 36 | entities.append( 37 | NordpoolPlannerBinarySensor( 38 | planner, 39 | entity_description=BinarySensorEntityDescription( 40 | key=CONF_LOW_COST_ENTITY, 41 | # device_class=BinarySensorDeviceClass.???, 42 | ), 43 | ) 44 | ) 45 | 46 | if config_entry.data.get(CONF_HIGH_COST_ENTITY): 47 | entities.append( 48 | NordpoolPlannerBinarySensor( 49 | planner, 50 | entity_description=BinarySensorEntityDescription( 51 | key=CONF_HIGH_COST_ENTITY, 52 | # device_class=BinarySensorDeviceClass.???, 53 | ), 54 | ) 55 | ) 56 | 57 | async_add_entities(entities) 58 | return True 59 | 60 | 61 | class NordpoolPlannerBinarySensor(NordpoolPlannerEntity, BinarySensorEntity): 62 | """Binary state sensor.""" 63 | 64 | _attr_icon = "mdi:flash" 65 | 66 | def __init__( 67 | self, 68 | planner, 69 | entity_description: BinarySensorEntityDescription, 70 | ) -> None: 71 | """Initialize the entity.""" 72 | super().__init__(planner) 73 | self.entity_description = entity_description 74 | self._attr_name = ( 75 | self._planner.name 76 | + " " 77 | + entity_description.key.replace("_entity", "").replace("_", " ") 78 | ) 79 | self._attr_unique_id = ( 80 | ("nordpool_planner_" + self._attr_name) 81 | .lower() 82 | .replace(".", "") 83 | .replace(" ", "_") 84 | ) 85 | 86 | @property 87 | def is_on(self): 88 | """Output state.""" 89 | state = None 90 | # TODO: This can be made nicer to get value from states in dictionary in planner 91 | if self.entity_description.key == CONF_LOW_COST_ENTITY: 92 | state = self._planner.low_cost_state.on_at(dt_util.now()) 93 | # if self._planner.low_cost_state.starts_at not in [ 94 | # STATE_UNKNOWN, 95 | # STATE_UNAVAILABLE, 96 | # ]: 97 | # state = self._planner.low_cost_state.starts_at < dt_util.now() 98 | if self.entity_description.key == CONF_HIGH_COST_ENTITY: 99 | state = self._planner.high_cost_state.on_at(dt_util.now()) 100 | # if self._planner.high_cost_state.starts_at not in [ 101 | # STATE_UNKNOWN, 102 | # STATE_UNAVAILABLE, 103 | # ]: 104 | # state = self._planner.high_cost_state.starts_at < dt_util.now() 105 | _LOGGER.debug( 106 | 'Returning state "%s" of binary sensor "%s"', 107 | state, 108 | self.unique_id, 109 | ) 110 | return state 111 | 112 | @property 113 | def extra_state_attributes(self): 114 | """Extra state attributes.""" 115 | state_attributes = { 116 | "starts_at": STATE_UNKNOWN, 117 | "cost_at": STATE_UNKNOWN, 118 | "current_cost": self._planner.price_now, 119 | "current_cost_rate": STATE_UNKNOWN, 120 | "price_sensor": self._planner.price_sensor_id, 121 | } 122 | # TODO: This can be made nicer to get value from states in dictionary in planner 123 | if self.entity_description.key == CONF_LOW_COST_ENTITY: 124 | state_attributes = { 125 | "starts_at": self._planner.low_cost_state.starts_at, 126 | "cost_at": self._planner.low_cost_state.cost_at, 127 | "current_cost": self._planner.price_now, 128 | "current_cost_rate": self._planner.low_cost_state.now_cost_rate, 129 | "price_sensor": self._planner.price_sensor_id, 130 | } 131 | elif self.entity_description.key == CONF_HIGH_COST_ENTITY: 132 | state_attributes = { 133 | "starts_at": self._planner.high_cost_state.starts_at, 134 | "cost_at": self._planner.high_cost_state.cost_at, 135 | "current_cost": self._planner.price_now, 136 | "current_cost_rate": self._planner.high_cost_state.now_cost_rate, 137 | "price_sensor": self._planner.price_sensor_id, 138 | } 139 | _LOGGER.debug( 140 | 'Returning extra state attributes "%s" of binary sensor "%s"', 141 | state_attributes, 142 | self.unique_id, 143 | ) 144 | return state_attributes 145 | 146 | async def async_added_to_hass(self) -> None: 147 | """Load the last known state when added to hass.""" 148 | await super().async_added_to_hass() 149 | self._planner.register_output_listener_entity(self, self.entity_description.key) 150 | 151 | # async def async_update(self): 152 | # """Called from Home Assistant to update entity value""" 153 | # self._planner.update() 154 | -------------------------------------------------------------------------------- /custom_components/nordpool_planner/button.py: -------------------------------------------------------------------------------- 1 | """Button definitions.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | 7 | from homeassistant.components.button import ( 8 | ButtonDeviceClass, 9 | ButtonEntityDescription, 10 | ButtonEntity, 11 | ) 12 | from homeassistant.config_entries import ConfigEntry 13 | from homeassistant.const import ( 14 | EntityCategory, 15 | ) 16 | from homeassistant.core import HomeAssistant 17 | 18 | from . import NordpoolPlanner, NordpoolPlannerEntity 19 | from .const import ( 20 | CONF_END_TIME_ENTITY, 21 | CONF_USED_TIME_RESET_ENTITY, 22 | DOMAIN, 23 | ) 24 | 25 | _LOGGER = logging.getLogger(__name__) 26 | 27 | CONF_USED_TIME_RESET_ENTITY_DESCRIPTION = ButtonEntityDescription( 28 | key=CONF_USED_TIME_RESET_ENTITY, 29 | device_class=ButtonDeviceClass.RESTART, 30 | entity_category=EntityCategory.DIAGNOSTIC 31 | ) 32 | 33 | 34 | async def async_setup_entry( 35 | hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities 36 | ): 37 | """Create action button entities for platform.""" 38 | 39 | planner: NordpoolPlanner = hass.data[DOMAIN][config_entry.entry_id] 40 | entities = [] 41 | 42 | if config_entry.data.get(CONF_END_TIME_ENTITY): 43 | entities.append( 44 | NordpoolPlannerButton( 45 | planner, 46 | entity_description=CONF_USED_TIME_RESET_ENTITY_DESCRIPTION, 47 | ) 48 | ) 49 | 50 | async_add_entities(entities) 51 | return True 52 | 53 | 54 | class NordpoolPlannerButton(NordpoolPlannerEntity, ButtonEntity): 55 | """Button config entity.""" 56 | 57 | def __init__( 58 | self, 59 | planner, 60 | entity_description: ButtonEntityDescription, 61 | ) -> None: 62 | """Initialize the entity.""" 63 | super().__init__(planner) 64 | self.entity_description = entity_description 65 | self._attr_name = ( 66 | self._planner.name 67 | + " " 68 | + entity_description.key.replace("_entity", "").replace("_", " ") 69 | ) 70 | self._attr_unique_id = ( 71 | ("nordpool_planner_" + self._attr_name) 72 | .lower() 73 | .replace(".", "") 74 | .replace(" ", "_") 75 | ) 76 | 77 | async def async_added_to_hass(self) -> None: 78 | """Load the last known state when added to hass.""" 79 | await super().async_added_to_hass() 80 | self._planner.register_input_entity_id( 81 | self.entity_id, self.entity_description.key 82 | ) 83 | 84 | def press(self) -> None: 85 | """Press the button.""" 86 | self._planner.low_hours = 0 87 | -------------------------------------------------------------------------------- /custom_components/nordpool_planner/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for PoolLab integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any 7 | 8 | import voluptuous as vol 9 | 10 | from homeassistant import config_entries 11 | from homeassistant.const import ATTR_NAME, ATTR_UNIT_OF_MEASUREMENT 12 | from homeassistant.data_entry_flow import FlowResult 13 | from homeassistant.helpers import selector, template 14 | 15 | from .const import ( 16 | CONF_ACCEPT_COST_ENTITY, 17 | CONF_ACCEPT_RATE_ENTITY, 18 | CONF_DURATION_ENTITY, 19 | CONF_END_TIME_ENTITY, 20 | CONF_HEALTH_ENTITY, 21 | CONF_HIGH_COST_ENTITY, 22 | CONF_LOW_COST_ENTITY, 23 | CONF_PRICES_ENTITY, 24 | CONF_SEARCH_LENGTH_ENTITY, 25 | CONF_START_TIME_ENTITY, 26 | CONF_STARTS_AT_ENTITY, 27 | CONF_TYPE, 28 | CONF_TYPE_LIST, 29 | CONF_TYPE_MOVING, 30 | CONF_TYPE_STATIC, 31 | CONF_USED_HOURS_LOW_ENTITY, 32 | DOMAIN, 33 | NAME_FILE_READER, 34 | PATH_FILE_READER, 35 | ) 36 | from .helpers import get_np_from_file 37 | 38 | _LOGGER = logging.getLogger(__name__) 39 | 40 | ENTOSOE_DOMAIN = None 41 | try: 42 | from ..entsoe.const import DOMAIN as ENTOSOE_DOMAIN 43 | except ImportError: 44 | _LOGGER.warning("Could not import ENTSO-e integration") 45 | 46 | NORDPOOL_DOMAIN = None 47 | try: 48 | from ..nordpool import DOMAIN as NORDPOOL_DOMAIN 49 | except ImportError: 50 | _LOGGER.warning("Could not import Nord Pool integration") 51 | 52 | 53 | class NordpoolPlannerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 54 | """Nordpool Planner config flow.""" 55 | 56 | VERSION = 2 57 | MINOR_VERSION = 2 58 | data = None 59 | options = None 60 | _reauth_entry: config_entries.ConfigEntry | None = None 61 | 62 | async def async_step_user( 63 | self, user_input: dict[str, Any] | None = None 64 | ) -> FlowResult: 65 | """Handle initial user step.""" 66 | errors: dict[str, str] = {} 67 | 68 | if user_input is not None: 69 | self.data = user_input 70 | # Add those that are not optional 71 | self.data[CONF_LOW_COST_ENTITY] = True 72 | self.data[CONF_DURATION_ENTITY] = True 73 | if self.data[CONF_TYPE] == CONF_TYPE_MOVING: 74 | self.data[CONF_SEARCH_LENGTH_ENTITY] = True 75 | elif self.data[CONF_TYPE] == CONF_TYPE_STATIC: 76 | self.data[CONF_START_TIME_ENTITY] = True 77 | self.data[CONF_END_TIME_ENTITY] = True 78 | self.data[CONF_USED_HOURS_LOW_ENTITY] = True 79 | 80 | self.options = {} 81 | if self.data[CONF_PRICES_ENTITY] == NAME_FILE_READER: 82 | np_entity = get_np_from_file(PATH_FILE_READER) 83 | else: 84 | np_entity = self.hass.states.get(self.data[CONF_PRICES_ENTITY]) 85 | 86 | try: 87 | self.options[ATTR_UNIT_OF_MEASUREMENT] = np_entity.attributes.get( 88 | ATTR_UNIT_OF_MEASUREMENT 89 | ) 90 | except (IndexError, KeyError): 91 | _LOGGER.warning("Could not extract currency from Nordpool entity") 92 | 93 | await self.async_set_unique_id( 94 | self.data[ATTR_NAME] 95 | + "_" 96 | + self.data[CONF_PRICES_ENTITY] 97 | + "_" 98 | + self.data[CONF_TYPE] 99 | ) 100 | self._abort_if_unique_id_configured() 101 | 102 | _LOGGER.debug( 103 | 'Creating entry "%s" with data "%s"', 104 | self.unique_id, 105 | self.data, 106 | ) 107 | return self.async_create_entry( 108 | title=self.data[ATTR_NAME], data=self.data, options=self.options 109 | ) 110 | 111 | selected_entities = [] 112 | if NORDPOOL_DOMAIN: 113 | selected_entities.extend( 114 | template.integration_entities(self.hass, NORDPOOL_DOMAIN) 115 | ) 116 | if ENTOSOE_DOMAIN: 117 | ent = template.integration_entities(self.hass, ENTOSOE_DOMAIN) 118 | selected_entities.extend([s for s in ent if "average" in s]) 119 | selected_entities.append(NAME_FILE_READER) 120 | 121 | schema = vol.Schema( 122 | { 123 | vol.Required(ATTR_NAME): str, 124 | vol.Required(CONF_TYPE): selector.SelectSelector( 125 | selector.SelectSelectorConfig(options=CONF_TYPE_LIST), 126 | ), 127 | vol.Required(CONF_PRICES_ENTITY): selector.SelectSelector( 128 | selector.SelectSelectorConfig(options=selected_entities), 129 | ), 130 | vol.Required(CONF_ACCEPT_COST_ENTITY, default=False): bool, 131 | vol.Required(CONF_ACCEPT_RATE_ENTITY, default=False): bool, 132 | vol.Required(CONF_HIGH_COST_ENTITY, default=False): bool, 133 | vol.Required(CONF_STARTS_AT_ENTITY, default=False): bool, 134 | vol.Required(CONF_HEALTH_ENTITY, default=True): bool, 135 | } 136 | ) 137 | 138 | placeholders = { 139 | CONF_TYPE: CONF_TYPE_LIST, 140 | CONF_PRICES_ENTITY: selected_entities, 141 | } 142 | 143 | return self.async_show_form( 144 | step_id="user", 145 | data_schema=schema, 146 | description_placeholders=placeholders, 147 | errors=errors, 148 | ) 149 | 150 | # async def async_step_import( 151 | # self, user_input: Optional[Dict[str, Any]] | None = None 152 | # ) -> FlowResult: 153 | # """Import nordpool planner config from configuration.yaml.""" 154 | # return await self.async_step_user(import_data) 155 | -------------------------------------------------------------------------------- /custom_components/nordpool_planner/const.py: -------------------------------------------------------------------------------- 1 | """Common constants for integration.""" 2 | 3 | from enum import Enum 4 | 5 | DOMAIN = "nordpool_planner" 6 | 7 | 8 | class PlannerStates(Enum): 9 | """Standard numeric identifiers for planner states.""" 10 | 11 | Ok = 0 12 | Idle = 1 13 | Warning = 2 14 | Error = 3 15 | Unknown = 4 16 | 17 | 18 | CONF_TYPE = "type" 19 | CONF_TYPE_MOVING = "moving" 20 | CONF_TYPE_STATIC = "static" 21 | CONF_TYPE_LIST = [CONF_TYPE_MOVING, CONF_TYPE_STATIC] 22 | CONF_PRICES_ENTITY = "prices_entity" 23 | CONF_LOW_COST_ENTITY = "low_cost_entity" 24 | CONF_HEALTH_ENTITY = "health_entity" 25 | CONF_HIGH_COST_ENTITY = "high_cost_entity" 26 | CONF_STARTS_AT_ENTITY = "starts_at_entity" 27 | CONF_DURATION_ENTITY = "duration_entity" 28 | CONF_ACCEPT_COST_ENTITY = "accept_cost_entity" 29 | CONF_ACCEPT_RATE_ENTITY = "accept_rate_entity" 30 | CONF_SEARCH_LENGTH_ENTITY = "search_length_entity" 31 | CONF_END_TIME_ENTITY = "end_time_entity" 32 | CONF_USED_TIME_RESET_ENTITY = "used_time_reset_entity" 33 | CONF_START_TIME_ENTITY = "start_time_entity" 34 | CONF_USED_HOURS_LOW_ENTITY = "used_hours_low_entity" 35 | 36 | NAME_FILE_READER = "file_reader" 37 | 38 | PATH_FILE_READER = "config/config_entry-nordpool_planner.json" 39 | -------------------------------------------------------------------------------- /custom_components/nordpool_planner/diagnostics.py: -------------------------------------------------------------------------------- 1 | """Diagnostics support for Nordpool Planner.""" 2 | 3 | from __future__ import annotations 4 | 5 | # import json 6 | import logging 7 | from typing import Any 8 | 9 | # from homeassistant.components.diagnostics import async_redact_data 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.core import HomeAssistant 12 | 13 | from . import NordpoolPlanner 14 | from .const import DOMAIN 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | # TO_REDACT = [] 19 | 20 | 21 | async def async_get_config_entry_diagnostics( 22 | hass: HomeAssistant, config_entry: ConfigEntry 23 | ) -> dict[str, Any]: 24 | """Return diagnostics for a config entry.""" 25 | diag_data = { 26 | # "config_entry": config_entry, # Already included in the planner 27 | "planner": hass.data[DOMAIN][config_entry.entry_id], 28 | } 29 | 30 | return diag_data 31 | -------------------------------------------------------------------------------- /custom_components/nordpool_planner/helpers.py: -------------------------------------------------------------------------------- 1 | """Helper functions package.""" 2 | 3 | import contextlib 4 | import datetime as dt 5 | import json 6 | import pathlib 7 | 8 | from homeassistant.core import State 9 | from homeassistant.util import dt as dt_util 10 | 11 | 12 | def get_np_from_file(data_file: str, set_today: bool = True) -> State | None: 13 | """Fake NP entity from file.""" 14 | diag_data = {} 15 | file_path = pathlib.Path(data_file) 16 | if file_path.is_file(): 17 | with contextlib.suppress(ValueError): 18 | diag_data = json.loads(file_path.read_text(encoding="utf-8")) 19 | 20 | if data := diag_data.get("data"): 21 | if planner := data.get("planner"): 22 | if prices_entity := planner.get("_prices_entity"): 23 | if np := prices_entity.get("_np"): 24 | attr = np.get("attributes") 25 | now = dt_util.now() 26 | if "raw_today" in attr: 27 | for item in attr["raw_today"]: 28 | for key, value in item.items(): 29 | if key in ["start", "end"] and isinstance(value, str): 30 | item[key] = dt_util.parse_datetime(value) 31 | if set_today: 32 | item[key] = item[key].replace( 33 | year=now.year, month=now.month, day=now.day 34 | ) 35 | if "raw_tomorrow" in attr: 36 | for item in attr["raw_tomorrow"]: 37 | for key, value in item.items(): 38 | if key in ["start", "end"] and isinstance(value, str): 39 | item[key] = dt_util.parse_datetime(value) 40 | if set_today: 41 | item[key] = item[key].replace( 42 | year=now.year, month=now.month, day=now.day 43 | ) 44 | item[key] += dt.timedelta(days=1) 45 | if "prices" in attr and set_today: 46 | first_time = None 47 | original_tz = None 48 | for item in attr["prices"]: 49 | for key, value in item.items(): 50 | if key in ["time"] and isinstance(value, str): 51 | fixed_time = dt_util.parse_datetime(value) 52 | if not original_tz: 53 | original_tz = fixed_time.tzinfo 54 | fixed_time = fixed_time.astimezone(now.tzinfo) 55 | if not first_time: 56 | first_time = fixed_time 57 | if fixed_time.day == first_time.day: 58 | fixed_time = fixed_time.replace( 59 | year=now.year, month=now.month, day=now.day 60 | ) 61 | else: 62 | fixed_time = fixed_time.replace( 63 | year=now.year, month=now.month, day=now.day 64 | ) 65 | fixed_time += dt.timedelta(days=1) 66 | item[key] = fixed_time.astimezone( 67 | original_tz 68 | ).strftime("%Y-%m-%d %H:%M:%S%z") 69 | return State( 70 | entity_id=np.get("entity_id"), 71 | state=np.get("state"), 72 | attributes=attr, 73 | # last_changed: datetime.datetime | None = None, 74 | # last_reported: datetime.datetime | None = None, 75 | # last_updated: datetime.datetime | None = None, 76 | # context: Context | None = None, 77 | # validate_entity_id: bool | None = True, 78 | # state_info: StateInfo | None = None, 79 | # last_updated_timestamp: float | None = None, 80 | ) 81 | 82 | return None 83 | -------------------------------------------------------------------------------- /custom_components/nordpool_planner/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "nordpool_planner", 3 | "name": "Nordpool Planner", 4 | "after_dependencies": ["nordpool", "entsoe"], 5 | "codeowners": ["@dala318"], 6 | "config_flow": true, 7 | "dependencies": [], 8 | "documentation": "https://github.com/dala318/nordpool_planner", 9 | "iot_class": "calculated", 10 | "issue_tracker": "https://github.com/dala318/nordpool_planner/issues", 11 | "requirements": [], 12 | "version": "2.2.0" 13 | } 14 | -------------------------------------------------------------------------------- /custom_components/nordpool_planner/number.py: -------------------------------------------------------------------------------- 1 | """Number definitions.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | 7 | from homeassistant.components.number import ( 8 | NumberDeviceClass, 9 | NumberEntityDescription, 10 | RestoreNumber, 11 | ) 12 | from homeassistant.config_entries import ConfigEntry 13 | from homeassistant.const import ( 14 | ATTR_UNIT_OF_MEASUREMENT, 15 | STATE_UNAVAILABLE, 16 | STATE_UNKNOWN, 17 | UnitOfTime, 18 | ) 19 | from homeassistant.core import HomeAssistant 20 | 21 | from . import NordpoolPlanner, NordpoolPlannerEntity 22 | from .const import ( 23 | CONF_ACCEPT_COST_ENTITY, 24 | CONF_ACCEPT_RATE_ENTITY, 25 | CONF_DURATION_ENTITY, 26 | CONF_END_TIME_ENTITY, 27 | CONF_SEARCH_LENGTH_ENTITY, 28 | CONF_START_TIME_ENTITY, 29 | DOMAIN, 30 | ) 31 | 32 | _LOGGER = logging.getLogger(__name__) 33 | 34 | DURATION_ENTITY_DESCRIPTION = NumberEntityDescription( 35 | key=CONF_DURATION_ENTITY, 36 | device_class=NumberDeviceClass.DURATION, 37 | native_min_value=1, 38 | native_max_value=8, 39 | native_step=1, 40 | native_unit_of_measurement=UnitOfTime.HOURS, 41 | ) 42 | ACCEPT_COST_ENTITY_DESCRIPTION = NumberEntityDescription( 43 | key=CONF_ACCEPT_COST_ENTITY, 44 | device_class=NumberDeviceClass.MONETARY, 45 | native_min_value=-20.0, 46 | native_max_value=20.0, 47 | native_step=0.01, 48 | ) 49 | ACCEPT_RATE_ENTITY_DESCRIPTION = NumberEntityDescription( 50 | key=CONF_ACCEPT_RATE_ENTITY, 51 | device_class=NumberDeviceClass.DATA_RATE, 52 | native_min_value=0.1, 53 | native_max_value=1.0, 54 | native_step=0.1, 55 | ) 56 | SEARCH_LENGTH_ENTITY_DESCRIPTION = NumberEntityDescription( 57 | key=CONF_SEARCH_LENGTH_ENTITY, 58 | device_class=NumberDeviceClass.DURATION, 59 | native_min_value=3, 60 | native_max_value=23, # Let's keep it below 24h to not risk wrapping a day. 61 | native_step=1, 62 | native_unit_of_measurement=UnitOfTime.HOURS, 63 | ) 64 | END_TIME_ENTITY_DESCRIPTION = NumberEntityDescription( 65 | key=CONF_END_TIME_ENTITY, 66 | device_class=NumberDeviceClass.DURATION, 67 | native_min_value=0, 68 | native_max_value=23, 69 | native_step=1, 70 | native_unit_of_measurement=UnitOfTime.HOURS, 71 | ) 72 | START_TIME_ENTITY_DESCRIPTION = NumberEntityDescription( 73 | key=CONF_START_TIME_ENTITY, 74 | device_class=NumberDeviceClass.DURATION, 75 | native_min_value=0, 76 | native_max_value=23, 77 | native_step=1, 78 | native_unit_of_measurement=UnitOfTime.HOURS, 79 | ) 80 | 81 | 82 | async def async_setup_entry( 83 | hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities 84 | ): 85 | """Create configuration number entities for platform.""" 86 | 87 | planner: NordpoolPlanner = hass.data[DOMAIN][config_entry.entry_id] 88 | entities = [] 89 | 90 | if config_entry.data.get(CONF_DURATION_ENTITY): 91 | entities.append( 92 | NordpoolPlannerNumber( 93 | planner, 94 | start_val=3, 95 | entity_description=DURATION_ENTITY_DESCRIPTION, 96 | ) 97 | ) 98 | 99 | if config_entry.data.get(CONF_ACCEPT_COST_ENTITY): 100 | entity_description = ACCEPT_COST_ENTITY_DESCRIPTION 101 | # Override if currency option is set 102 | if unit_of_measurement := config_entry.options.get(ATTR_UNIT_OF_MEASUREMENT): 103 | entity_description = NumberEntityDescription( 104 | key=ACCEPT_COST_ENTITY_DESCRIPTION.key, 105 | device_class=ACCEPT_COST_ENTITY_DESCRIPTION.device_class, 106 | native_min_value=ACCEPT_COST_ENTITY_DESCRIPTION.native_min_value, 107 | native_max_value=ACCEPT_COST_ENTITY_DESCRIPTION.native_max_value, 108 | native_step=ACCEPT_COST_ENTITY_DESCRIPTION.native_step, 109 | native_unit_of_measurement=unit_of_measurement, 110 | ) 111 | entities.append( 112 | NordpoolPlannerNumber( 113 | planner, 114 | start_val=0.0, 115 | entity_description=entity_description, 116 | ) 117 | ) 118 | 119 | if config_entry.data.get(CONF_ACCEPT_RATE_ENTITY): 120 | entities.append( 121 | NordpoolPlannerNumber( 122 | planner, 123 | start_val=0.1, 124 | entity_description=ACCEPT_RATE_ENTITY_DESCRIPTION, 125 | ) 126 | ) 127 | 128 | if config_entry.data.get(CONF_SEARCH_LENGTH_ENTITY): 129 | entities.append( 130 | NordpoolPlannerNumber( 131 | planner, 132 | start_val=10, 133 | entity_description=SEARCH_LENGTH_ENTITY_DESCRIPTION, 134 | ) 135 | ) 136 | 137 | if config_entry.data.get(CONF_END_TIME_ENTITY): 138 | entities.append( 139 | NordpoolPlannerNumber( 140 | planner, 141 | start_val=7, 142 | entity_description=END_TIME_ENTITY_DESCRIPTION, 143 | ) 144 | ) 145 | 146 | if config_entry.data.get(CONF_START_TIME_ENTITY): 147 | entities.append( 148 | NordpoolPlannerNumber( 149 | planner, 150 | start_val=18, 151 | entity_description=START_TIME_ENTITY_DESCRIPTION, 152 | ) 153 | ) 154 | 155 | async_add_entities(entities) 156 | return True 157 | 158 | 159 | class NordpoolPlannerNumber(NordpoolPlannerEntity, RestoreNumber): 160 | """Number config entity.""" 161 | 162 | def __init__( 163 | self, 164 | planner, 165 | start_val, 166 | entity_description: NumberEntityDescription, 167 | ) -> None: 168 | """Initialize the entity.""" 169 | super().__init__(planner) 170 | self.entity_description = entity_description 171 | self._default_value = start_val 172 | self._attr_name = ( 173 | self._planner.name 174 | + " " 175 | + entity_description.key.replace("_entity", "").replace("_", " ") 176 | ) 177 | self._attr_unique_id = ( 178 | ("nordpool_planner_" + self._attr_name) 179 | .lower() 180 | .replace(".", "") 181 | .replace(" ", "_") 182 | ) 183 | 184 | async def async_added_to_hass(self) -> None: 185 | """Load the last known state when added to hass.""" 186 | await super().async_added_to_hass() 187 | if (last_state := await self.async_get_last_state()) and ( 188 | last_number_data := await self.async_get_last_number_data() 189 | ): 190 | if last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): 191 | self._attr_native_value = last_number_data.native_value 192 | else: 193 | self._attr_native_value = self._default_value 194 | self._planner.register_input_entity_id( 195 | self.entity_id, self.entity_description.key 196 | ) 197 | 198 | async def async_set_native_value(self, value: float) -> None: 199 | """Update the current value.""" 200 | self._attr_native_value = value 201 | _LOGGER.debug( 202 | "Got new async value %s for %s", 203 | value, 204 | self.name, 205 | ) 206 | self.async_schedule_update_ha_state() 207 | -------------------------------------------------------------------------------- /custom_components/nordpool_planner/sensor.py: -------------------------------------------------------------------------------- 1 | """Binary sensor definitions.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | 7 | from homeassistant.components.sensor import ( 8 | RestoreSensor, 9 | SensorDeviceClass, 10 | SensorEntity, 11 | SensorEntityDescription, 12 | ) 13 | from homeassistant.config_entries import ConfigEntry 14 | from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, EntityCategory 15 | from homeassistant.core import HomeAssistant 16 | 17 | from . import NordpoolPlanner, NordpoolPlannerEntity 18 | from .const import ( 19 | CONF_HEALTH_ENTITY, 20 | CONF_HIGH_COST_ENTITY, 21 | CONF_LOW_COST_ENTITY, 22 | CONF_STARTS_AT_ENTITY, 23 | CONF_USED_HOURS_LOW_ENTITY, 24 | DOMAIN, 25 | PlannerStates, 26 | ) 27 | 28 | _LOGGER = logging.getLogger(__name__) 29 | 30 | CONF_LOW_COST_STARTS_AT_ENTITY = ( 31 | CONF_LOW_COST_ENTITY.replace("_entity", "") + "_" + CONF_STARTS_AT_ENTITY 32 | ) 33 | CONF_HIGH_COST_STARTS_AT_ENTITY = ( 34 | CONF_HIGH_COST_ENTITY.replace("_entity", "") + "_" + CONF_STARTS_AT_ENTITY 35 | ) 36 | 37 | LOW_COST_START_AT_ENTITY_DESCRIPTION = SensorEntityDescription( 38 | key=CONF_LOW_COST_STARTS_AT_ENTITY, 39 | device_class=SensorDeviceClass.TIMESTAMP, 40 | ) 41 | 42 | HIGH_COST_START_AT_ENTITY_DESCRIPTION = SensorEntityDescription( 43 | key=CONF_HIGH_COST_STARTS_AT_ENTITY, 44 | device_class=SensorDeviceClass.TIMESTAMP, 45 | ) 46 | 47 | USED_HOURS_LOW_ENTITY_DESCRIPTION = SensorEntityDescription( 48 | key=CONF_USED_HOURS_LOW_ENTITY, 49 | device_class=SensorDeviceClass.DURATION, 50 | entity_category=EntityCategory.DIAGNOSTIC, 51 | ) 52 | 53 | HEALTH_ENTITY_DESCRIPTION = SensorEntityDescription( 54 | key=CONF_HEALTH_ENTITY, 55 | device_class=SensorDeviceClass.ENUM, 56 | entity_category=EntityCategory.DIAGNOSTIC, 57 | options=[e.name for e in PlannerStates], 58 | ) 59 | 60 | 61 | async def async_setup_entry( 62 | hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities 63 | ): 64 | """Create state sensor entities for platform.""" 65 | 66 | planner: NordpoolPlanner = hass.data[DOMAIN][config_entry.entry_id] 67 | entities = [] 68 | 69 | if config_entry.data.get(CONF_STARTS_AT_ENTITY): 70 | if config_entry.data.get(CONF_LOW_COST_ENTITY): 71 | entities.append( 72 | NordpoolPlannerStartAtSensor( 73 | planner, 74 | entity_description=LOW_COST_START_AT_ENTITY_DESCRIPTION, 75 | ) 76 | ) 77 | 78 | if config_entry.data.get(CONF_HIGH_COST_ENTITY): 79 | entities.append( 80 | NordpoolPlannerStartAtSensor( 81 | planner, 82 | entity_description=HIGH_COST_START_AT_ENTITY_DESCRIPTION, 83 | ) 84 | ) 85 | 86 | if config_entry.data.get(CONF_USED_HOURS_LOW_ENTITY): 87 | entities.append( 88 | NordpoolPlannerUsedHoursSensor( 89 | planner, 90 | entity_description=USED_HOURS_LOW_ENTITY_DESCRIPTION, 91 | ) 92 | ) 93 | 94 | if config_entry.data.get(CONF_HEALTH_ENTITY): 95 | entities.append( 96 | NordpoolPlannerHealthSensor( 97 | planner, 98 | entity_description=HEALTH_ENTITY_DESCRIPTION, 99 | ) 100 | ) 101 | 102 | async_add_entities(entities) 103 | return True 104 | 105 | 106 | class NordpoolPlannerSensor(NordpoolPlannerEntity, SensorEntity): 107 | """Generic state sensor.""" 108 | 109 | _attr_icon = "mdi:flash" 110 | 111 | def __init__( 112 | self, 113 | planner, 114 | entity_description: SensorEntityDescription, 115 | ) -> None: 116 | """Initialize the entity.""" 117 | super().__init__(planner) 118 | self.entity_description = entity_description 119 | self._attr_name = ( 120 | self._planner.name 121 | + " " 122 | + entity_description.key.replace("_entity", "").replace("_", " ") 123 | ) 124 | self._attr_unique_id = ( 125 | ("nordpool_planner_" + self._attr_name) 126 | .lower() 127 | .replace(".", "") 128 | .replace(" ", "_") 129 | ) 130 | 131 | async def async_added_to_hass(self) -> None: 132 | """Load the last known state when added to hass.""" 133 | await super().async_added_to_hass() 134 | self._planner.register_output_listener_entity(self, self.entity_description.key) 135 | 136 | 137 | class NordpoolPlannerStartAtSensor(NordpoolPlannerSensor): 138 | """Start at specific sensor.""" 139 | 140 | @property 141 | def native_value(self): 142 | """Output state.""" 143 | state = STATE_UNKNOWN 144 | # TODO: This can be made nicer to get value from states in dictionary in planner 145 | if self.entity_description.key == CONF_LOW_COST_STARTS_AT_ENTITY: 146 | if self._planner.low_cost_state.starts_at not in [ 147 | STATE_UNKNOWN, 148 | STATE_UNAVAILABLE, 149 | ]: 150 | state = self._planner.low_cost_state.starts_at 151 | if self.entity_description.key == CONF_HIGH_COST_STARTS_AT_ENTITY: 152 | if self._planner.high_cost_state.starts_at not in [ 153 | STATE_UNKNOWN, 154 | STATE_UNAVAILABLE, 155 | ]: 156 | state = self._planner.high_cost_state.starts_at 157 | _LOGGER.debug( 158 | 'Returning state "%s" of sensor "%s"', 159 | state, 160 | self.unique_id, 161 | ) 162 | return state 163 | 164 | # @property 165 | # def extra_state_attributes(self): 166 | # """Extra state attributes.""" 167 | # state_attributes = { 168 | # "cost_at": STATE_UNKNOWN, 169 | # "now_cost_rate": STATE_UNKNOWN, 170 | # } 171 | # # TODO: This can be made nicer to get value from states in dictionary in planner 172 | # if self.entity_description.key == CONF_LOW_COST_STARTS_AT_ENTITY: 173 | # state_attributes = { 174 | # "cost_at": self._planner.low_cost_state.cost_at, 175 | # "now_cost_rate": self._planner.low_cost_state.now_cost_rate, 176 | # } 177 | # _LOGGER.debug( 178 | # 'Returning extra state attributes "%s" of sensor "%s"', 179 | # state_attributes, 180 | # self.unique_id, 181 | # ) 182 | # return state_attributes 183 | 184 | 185 | class NordpoolPlannerUsedHoursSensor(NordpoolPlannerSensor, RestoreSensor): 186 | """Start at specific sensor.""" 187 | 188 | async def async_added_to_hass(self) -> None: 189 | """Restore last state.""" 190 | await super().async_added_to_hass() 191 | if ( 192 | (last_state := await self.async_get_last_state()) is not None 193 | and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) 194 | and last_state.state.isdigit() 195 | # and (extra_data := await self.async_get_last_sensor_data()) is not None 196 | ): 197 | self._planner.low_hours = int(last_state.state) 198 | else: 199 | self._planner.low_hours = 0 200 | 201 | @property 202 | def native_value(self): 203 | """Output state.""" 204 | return self._planner.low_hours 205 | 206 | 207 | class NordpoolPlannerHealthSensor(NordpoolPlannerSensor, RestoreSensor): 208 | """Start at specific sensor.""" 209 | 210 | @property 211 | def native_value(self): 212 | """Output state.""" 213 | return self._planner.planner_status.status.name 214 | 215 | @property 216 | def extra_state_attributes(self): 217 | """Extra state attributes.""" 218 | return { 219 | "running_state": self._planner.planner_status.running_text, 220 | "config_state": self._planner.planner_status.config_text, 221 | } 222 | -------------------------------------------------------------------------------- /custom_components/nordpool_planner/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "description": "Setup a Nordpool Planner", 6 | "data": { 7 | "name": "Name of planner", 8 | "type": "Planner type", 9 | "prices_entity": "Nordpool or ENTSO-e entity", 10 | "duration_entity": "Duration: Creates dynamic configuration parameter", 11 | "search_length_entity": "Search length: Creates dynamic configuration parameter", 12 | "end_time_entity": "End time: Creates dynamic configuration parameter", 13 | "accept_cost_entity": "Accept cost: Creates a configuration parameter that turn on if cost below", 14 | "accept_rate_entity": "Accept rate: Creates a configuration parameter that turn on if cost-rate to daily average below", 15 | "high_cost_entity": "High cost: Creates a binary sensor that tell in it's the highest cost (inverse of normal)", 16 | "starts_at_entity": "Starts at: Creates additional sensors telling when next lowest and highest cost starts", 17 | "health_entity": "Adds a status entity to tell overall health of planner" 18 | } 19 | } 20 | }, 21 | "error": { 22 | "name_exists": "Name already exists", 23 | "invalid_template": "The template is invalid" 24 | }, 25 | "abort": { 26 | "already_configured": "Already configured with the same settings or name" 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Nordpool Planner", 3 | "render_readme": true, 4 | "zip_release": true, 5 | "filename": "nordpool_planner.zip" 6 | } 7 | -------------------------------------------------------------------------------- /planner_evaluation_chart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dala318/nordpool_planner/e2651e3fcb381c0ea7392ef446c3fc58cf382cb9/planner_evaluation_chart.png -------------------------------------------------------------------------------- /planning_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dala318/nordpool_planner/e2651e3fcb381c0ea7392ef446c3fc58cf382cb9/planning_example.png -------------------------------------------------------------------------------- /requirements.test.txt: -------------------------------------------------------------------------------- 1 | # aioresponses 2 | # codecov 3 | # coverage>=5.2.0,<5.3.0 4 | # mypy 5 | 6 | # fuzzywuzzy 7 | # levenshtein 8 | # ruff 9 | # yamllint 10 | 11 | pytest 12 | pytest-asyncio 13 | pytest-cov 14 | pytest-homeassistant-custom-component 15 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | colorlog==6.9.0 2 | homeassistant 3 | pip>=21.3.1 4 | ruff==0.8.6 -------------------------------------------------------------------------------- /scripts/develop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | # Create config dir if not present 8 | if [[ ! -d "${PWD}/config" ]]; then 9 | mkdir -p "${PWD}/config" 10 | hass --config "${PWD}/config" --script ensure_config 11 | fi 12 | 13 | # Set the path to custom_components 14 | ## This let's us have the structure we want /custom_components/nordpool_planner 15 | ## while at the same time have Home Assistant configuration inside /config 16 | ## without resulting to symlinks. 17 | export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" 18 | 19 | # Start Home Assistant 20 | hass --config "${PWD}/config" --debug 21 | 22 | # Should sort of work in GitHub code-space 23 | # /home/codespace/.local/lib/python3.12/site-packages/bin/hass --config "${PWD}/config" --debug -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | ruff format . 8 | ruff check . --fix -------------------------------------------------------------------------------- /scripts/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | python3 -m pip install --requirement requirements.txt -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | source = 3 | custom_components 4 | 5 | [coverage:report] 6 | exclude_lines = 7 | pragma: no cover 8 | raise NotImplemented() 9 | if __name__ == '__main__': 10 | main() 11 | show_missing = true 12 | 13 | [tool:pytest] 14 | testpaths = tests 15 | norecursedirs = .git 16 | asyncio_mode = auto 17 | addopts = 18 | -p syrupy 19 | --strict 20 | --cov=custom_components 21 | 22 | [flake8] 23 | # https://github.com/ambv/black#line-length 24 | max-line-length = 88 25 | # E501: line too long 26 | # W503: Line break occurred before a binary operator 27 | # E203: Whitespace before ':' 28 | # D202 No blank lines allowed after function docstring 29 | # W504 line break after binary operator 30 | ignore = 31 | E501, 32 | W503, 33 | E203, 34 | D202, 35 | W504 36 | 37 | [isort] 38 | # https://github.com/timothycrosley/isort 39 | # https://github.com/timothycrosley/isort/wiki/isort-Settings 40 | # splits long import on multiple lines indented by 4 spaces 41 | multi_line_output = 3 42 | include_trailing_comma=True 43 | force_grid_wrap=0 44 | use_parentheses=True 45 | line_length=88 46 | indent = " " 47 | # by default isort don't check module indexes 48 | not_skip = __init__.py 49 | # will group `import x` and `from x import` of the same module. 50 | force_sort_within_sections = true 51 | sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 52 | default_section = THIRDPARTY 53 | known_first_party = custom_components,tests 54 | forced_separate = tests 55 | combine_as_imports = true 56 | 57 | [mypy] 58 | python_version = 3.7 59 | ignore_errors = true 60 | follow_imports = silent 61 | ignore_missing_imports = true 62 | warn_incomplete_stub = true 63 | warn_redundant_casts = true 64 | warn_unused_configs = true -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for Nordpool Planner.""" 2 | -------------------------------------------------------------------------------- /tests/bandit.yaml: -------------------------------------------------------------------------------- 1 | # https://bandit.readthedocs.io/en/latest/config.html 2 | 3 | tests: 4 | - B108 5 | - B306 6 | - B307 7 | - B313 8 | - B314 9 | - B315 10 | - B316 11 | - B317 12 | - B318 13 | - B319 14 | - B320 15 | - B325 16 | - B602 17 | - B604 -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Fixtures for testing.""" 2 | 3 | import pytest 4 | 5 | 6 | @pytest.fixture(autouse=True) 7 | def auto_enable_custom_integrations(enable_custom_integrations): 8 | """Enable custom integrations.""" 9 | return 10 | -------------------------------------------------------------------------------- /tests/test_config_flow.py: -------------------------------------------------------------------------------- 1 | """config_flow tests.""" 2 | 3 | from unittest import mock 4 | 5 | from custom_components.nordpool_planner import config_flow 6 | from custom_components.nordpool_planner.const import * 7 | import pytest 8 | 9 | # from pytest_homeassistant_custom_component.async_mock import patch 10 | import voluptuous as vol 11 | 12 | from homeassistant import config_entries 13 | from homeassistant.const import ATTR_NAME, ATTR_UNIT_OF_MEASUREMENT 14 | from homeassistant.helpers import selector 15 | 16 | PRICES_ENTITY_NAME = "sensor.nordpool_ent" 17 | 18 | SCHEMA_COPY = vol.Schema( 19 | { 20 | vol.Required(ATTR_NAME): str, 21 | vol.Required(CONF_TYPE): selector.SelectSelector( 22 | selector.SelectSelectorConfig(options=CONF_TYPE_LIST), 23 | ), 24 | vol.Required(CONF_PRICES_ENTITY): selector.SelectSelector( 25 | selector.SelectSelectorConfig(options=[PRICES_ENTITY_NAME]), 26 | ), 27 | vol.Required(CONF_ACCEPT_COST_ENTITY, default=False): bool, 28 | vol.Required(CONF_ACCEPT_RATE_ENTITY, default=False): bool, 29 | vol.Required(CONF_HIGH_COST_ENTITY, default=False): bool, 30 | } 31 | ) 32 | 33 | 34 | # @pytest.mark.asyncio 35 | # async def test_flow_init(hass): 36 | # """Test the initial flow.""" 37 | # result = await hass.config_entries.flow.async_init( 38 | # config_flow.DOMAIN, context={"source": "user"} 39 | # ) 40 | 41 | # expected = { 42 | # "data_schema": SCHEMA_COPY, 43 | # # "data_schema": config_flow.DATA_SCHEMA, 44 | # "description_placeholders": None, 45 | # "errors": {}, 46 | # "flow_id": mock.ANY, 47 | # "handler": "nordpool_planner", 48 | # "step_id": "user", 49 | # "type": "form", 50 | # } 51 | # assert expected == result 52 | -------------------------------------------------------------------------------- /tests/test_planner.py: -------------------------------------------------------------------------------- 1 | """planner tests.""" 2 | 3 | from unittest import mock 4 | 5 | from custom_components.nordpool_planner import NordpoolPlanner 6 | 7 | # from pytest_homeassistant_custom_component.async_mock import patch 8 | # from pytest_homeassistant_custom_component.common import ( 9 | # MockModule, 10 | # MockPlatform, 11 | # mock_integration, 12 | # mock_platform, 13 | # ) 14 | from custom_components.nordpool_planner.const import ( 15 | CONF_DURATION_ENTITY, 16 | CONF_PRICES_ENTITY, 17 | CONF_SEARCH_LENGTH_ENTITY, 18 | CONF_TYPE, 19 | DOMAIN, 20 | ) 21 | import pytest 22 | 23 | from homeassistant import config_entries 24 | from homeassistant.const import ATTR_NAME, ATTR_UNIT_OF_MEASUREMENT 25 | 26 | # from homeassistant.components import sensor 27 | # from homeassistant.core import HomeAssistant 28 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 29 | 30 | NAME = "My planner 1" 31 | TYPE = "moving" 32 | DURATION_ENT = "duration_ent" 33 | SEARCH_LENGTH_ENT = "search_len" 34 | PRICES_ENT = "sensor.np_ent" 35 | CURRENCY = "EUR/kWh" 36 | 37 | CONF_ENTRY = config_entries.ConfigEntry( 38 | data={ 39 | ATTR_NAME: NAME, 40 | CONF_TYPE: TYPE, 41 | CONF_PRICES_ENTITY: PRICES_ENT, 42 | CONF_DURATION_ENTITY: DURATION_ENT, 43 | CONF_SEARCH_LENGTH_ENTITY: SEARCH_LENGTH_ENT, 44 | }, 45 | options={ATTR_UNIT_OF_MEASUREMENT: CURRENCY}, 46 | domain=DOMAIN, 47 | version=2, 48 | minor_version=0, 49 | source="user", 50 | title="Nordpool Planner", 51 | unique_id="123456", 52 | discovery_keys=None, 53 | ) 54 | 55 | 56 | @pytest.mark.asyncio 57 | async def test_planner_init(hass): 58 | """Test the planner initialization.""" 59 | 60 | # async def async_setup_entry_init( 61 | # hass: HomeAssistant, config_entry: config_entries.ConfigEntry 62 | # ) -> bool: 63 | # """Set up test config entry.""" 64 | # await hass.config_entries.async_forward_entry_setups( 65 | # config_entry, [sensor.DOMAIN] 66 | # ) 67 | # return True 68 | 69 | # mock_integration( 70 | # hass, 71 | # MockModule( 72 | # "nordpool", 73 | # async_setup_entry=async_setup_entry_init, 74 | # ), 75 | # ) 76 | 77 | # # Fake nordpool sensor 78 | # np_sensor = sensor.SensorEntity() 79 | # np_sensor.entity_id = NP_ENT 80 | # np_sensor._attr_device_class = sensor.SensorDeviceClass.MONETARY 81 | 82 | # async def async_setup_entry_platform( 83 | # hass: HomeAssistant, 84 | # config_entry: config_entries.ConfigEntry, 85 | # async_add_entities: AddEntitiesCallback, 86 | # ) -> None: 87 | # """Set up test sensor platform via config entry.""" 88 | # async_add_entities([np_sensor]) 89 | 90 | # mock_platform( 91 | # hass, 92 | # f"{"nordpool"}.{sensor.DOMAIN}", 93 | # MockPlatform(async_setup_entry=async_setup_entry_platform), 94 | # ) 95 | 96 | planner = NordpoolPlanner(hass, CONF_ENTRY) 97 | 98 | assert planner.name == NAME 99 | assert planner._is_static == False 100 | assert planner._is_moving == True 101 | --------------------------------------------------------------------------------