├── .github └── workflows │ ├── hacs.yaml │ └── hassfest.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── FUNDING.yaml ├── LICENSE ├── README.md ├── custom_components └── epex_spot │ ├── EPEXSpot │ ├── Awattar │ │ └── __init__.py │ ├── EPEXSpotWeb │ │ └── __init__.py │ ├── Energyforecast │ │ └── __init__.py │ ├── SMARD │ │ └── __init__.py │ ├── Tibber │ │ └── __init__.py │ └── smartENERGY │ │ └── __init__.py │ ├── SourceShell.py │ ├── __init__.py │ ├── config_flow.py │ ├── const.py │ ├── extreme_price_interval.py │ ├── localization.py │ ├── manifest.json │ ├── sensor.py │ ├── services.yaml │ ├── test_awattar.py │ ├── test_energyforecast.py │ ├── test_epex_spot_web.py │ ├── test_smard.py │ ├── test_smartenergy.py │ ├── test_tibber.py │ └── translations │ └── en.json ├── hacs.json ├── images ├── apex_advanced.png ├── apexcharts-entities-example.png ├── apexcharts.png ├── dishwasher-card-examples.png ├── epex-spot-sensor-dishwasher-config-example.png ├── epex-spot-sensor-dishwasher-sensor-example.png └── start_appliance_sensor.png └── tests └── bandit.yaml /.github/workflows/hacs.yaml: -------------------------------------------------------------------------------- 1 | name: HACS Action 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | hacs: 11 | name: HACS Action 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - uses: "actions/checkout@v2" 15 | - name: HACS Action 16 | uses: "hacs/action@main" 17 | with: 18 | category: "integration" 19 | -------------------------------------------------------------------------------- /.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@v2" 14 | - uses: home-assistant/actions/hassfest@master 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.swp 3 | *.swo 4 | .vscode/ 5 | .mypy_cache/ 6 | venv/ 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.0.285 4 | hooks: 5 | - id: ruff 6 | args: 7 | - --fix 8 | - repo: https://github.com/psf/black-pre-commit-mirror 9 | rev: 23.9.0 10 | hooks: 11 | - id: black 12 | args: 13 | - --quiet 14 | files: ^((custom_components|pylint|script|tests)/.+)?[^/]+\.py$ 15 | - repo: https://github.com/codespell-project/codespell 16 | rev: v2.2.2 17 | hooks: 18 | - id: codespell 19 | args: 20 | - --ignore-words-list=additionals,alle,alot,bund,currenty,datas,farenheit,falsy,fo,haa,hass,iif,incomfort,ines,ist,nam,nd,pres,pullrequests,resset,rime,ser,serie,te,technik,ue,unsecure,withing,zar 21 | - --skip="./.*,*.csv,*.json,*.ambr" 22 | - --quiet-level=2 23 | exclude_types: [csv, json] 24 | exclude: ^tests/fixtures/|homeassistant/generated/ 25 | - repo: https://github.com/pre-commit/pre-commit-hooks 26 | rev: v4.4.0 27 | hooks: 28 | - id: check-executables-have-shebangs 29 | stages: [manual] 30 | - id: check-json 31 | exclude: (.vscode|.devcontainer) 32 | - id: no-commit-to-branch 33 | args: 34 | - --branch=dev 35 | - --branch=master 36 | - --branch=rc 37 | - repo: https://github.com/adrienverge/yamllint.git 38 | rev: v1.32.0 39 | hooks: 40 | - id: yamllint 41 | - repo: https://github.com/pre-commit/mirrors-prettier 42 | rev: v2.7.1 43 | hooks: 44 | - id: prettier 45 | - repo: https://github.com/cdce8p/python-typing-update 46 | rev: v0.6.0 47 | hooks: 48 | # Run `python-typing-update` hook manually from time to time 49 | # to update python typing syntax. 50 | # Will require manual work, before submitting changes! 51 | # pre-commit run --hook-stage manual python-typing-update --all-files 52 | - id: python-typing-update 53 | stages: [manual] 54 | args: 55 | - --py311-plus 56 | - --force 57 | - --keep-updates 58 | files: ^(custom_components|tests|script)/.+\.py$ 59 | -------------------------------------------------------------------------------- /FUNDING.yaml: -------------------------------------------------------------------------------- 1 | github: [mampfes] 2 | custom: ["https://www.paypal.me/mampfes"] 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Steffen Zimmermann 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 | # EPEX Spot 2 | 3 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/custom-components/hacs) 4 | 5 | This component adds electricity prices from stock exchange [EPEX Spot](https://www.epexspot.com) to Home Assistant. [EPEX Spot](https://www.epexspot.com) does not provide free access to the data, so this component uses different ways to retrieve the data. 6 | 7 | --- 8 | 9 | There is a companion integration which simplifies the use of EPEX Spot integration to switch on/off an application depending on the energy market prices: 10 | 11 | 12 | 13 | --- 14 | 15 | You can choose between multiple sources: 16 | 17 | 1. Awattar 18 | [Awattar](https://www.awattar.de/services/api) provides a free of charge service for their customers. Market price data is available for Germany and Austria. So far no user identifiation is required. 19 | 20 | 2. EPEX Spot Web Scraper 21 | This source uses web scraping technologies to retrieve publicly available data from its [website](https://www.epexspot.com/en/market-data). 22 | 23 | 3. SMARD.de 24 | [SMARD.de](https://www.smard.de) provides a free of charge API to retrieve a lot of information about electricity market including market prices. SMARD.de is serviced by the Bundesnetzagentur, Germany. 25 | 26 | 4. smartENERGY.at 27 | [smartENERGY.at](https://www.smartenergy.at/api-schnittstellen) provides a free of charge service for their customers. Market price data is available for Austria. So far no user identifiation is required. 28 | 29 | 5. Energyforecast.de 30 | [Energyforecast.de](https://www.energyforecast.de/api-docs/index.html) provides services to get market price data forecasts for Germany up to 96 hours into the future. An API token is required. 31 | 32 | If you like this component, please give it a star on [github](https://github.com/mampfes/hacs_epex_spot). 33 | 34 | ## Installation 35 | 36 | 1. Ensure that [HACS](https://hacs.xyz) is installed. 37 | 38 | 2. Install **EPEX Spot** integration via HACS: 39 | 40 | [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=mampfes&repository=ha_epex_spot) 41 | 42 | 3. Add **EPEX Spot** integration to Home Assistant: 43 | 44 | [![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start?domain=epex_spot) 45 | 46 | In case you would like to install manually: 47 | 48 | 1. Copy the folder `custom_components/epex_spot` to `custom_components` in your Home Assistant `config` folder. 49 | 2. Add **EPEX Spot** integration to Home Assistant: 50 | 51 | [![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start?domain=epex_spot) 52 | 53 | ## Sensors 54 | 55 | This integration provides the following sensors: 56 | 57 | 1. Net market price 58 | 2. Market price 59 | 3. Average market price during the day 60 | 4. Median market price during the day 61 | 5. Lowest market price during the day 62 | 6. Highest market price during the day 63 | 7. Current market price quantile during the day 64 | 8. Rank of the current market price during the day 65 | 66 | The _EPEX Spot Web Scraper_ provides some additional sensors: 67 | 68 | - Buy Volume 69 | - Sell Volume 70 | - Volume 71 | 72 | NOTE: For GB data, the prices will be shown in GBP instead of EUR. The sensor attribute names are adjusted accordingly. 73 | 74 | ### 1. Net Market Price Sensor 75 | 76 | The sensor value reports the net market price in €/£/kWh. The price value will be updated every hour to reflect the current net market price. 77 | 78 | The sensor attributes contains a list of all available net market prices (for today and tomorrow if available) in €/£/kWh. 79 | 80 | ```yaml 81 | data: 82 | - start_time: "2022-12-15T23:00:00+00:00" 83 | end_time: "2022-12-16T00:00:00+00:00" 84 | price_per_kwh: 0.12485 85 | - start_time: "2022-12-16T00:00:00+00:00" 86 | end_time: "2022-12-16T01:00:00+00:00" 87 | price_per_kwh: 0.12235 88 | - start_time: "2022-12-16T01:00:00+00:00" 89 | end_time: "2022-12-16T02:00:00+00:00" 90 | price_per_kwh: 0.12247 91 | ``` 92 | 93 | The net market price will be calculated as follows: 94 | `` = `` + `` + `` 95 | 96 | - Net market price is the price you have to pay at the end, including taxes, surcharges and VAT. 97 | - Market price is the energy price from EPEX Spot excluding taxes, surcharges, VAT. 98 | - 2 different types of surcharges can be adjusted: 99 | 1. Percentage Surcharge, stated in % of the EPEX Spot market price. 100 | 2. Absolute Surcharge, stated in €/£/kWh, excluding VAT. 101 | - Tax, e.g. VAT 102 | 103 | The values for surcharges and tax can be adjusted in the integration configuration. 104 | 105 | Example: 106 | 107 | ```text 108 | Percentage Surchage = 3% 109 | Absolute Surcharge = 0.012 €/£/kWh 110 | Tax = 19% 111 | 112 | Net Price = ((Market Price * 1.03) + 0.012) * 1.19 113 | ``` 114 | 115 | #### Note about smartENERGY.at 116 | 117 | As of Feb 2024, even though smartENERGY says that the prices reported by the API already include 20% tax (meaning users would configure the sensor to add a static €0.0144 to every price value from the API), [this is incorrect, and the API reports pricing without Tax](https://github.com/mampfes/ha_epex_spot/issues/108#issuecomment-1951423366 "this is incorrect, and the API reports pricing without Tax"). 118 | 119 | To get the actual, current Net Price [listed by smartENERGY on their website](https://www.smartenergy.at/smartcontrol#:~:text=Aktueller%20Stundenpreis "listed by smartENERGY on their website"), configure: 120 | 121 | - Absolute surcharge = €0.012 122 | - Tax = 20% 123 | 124 | ### 2. Market Price Sensor 125 | 126 | The sensor value reports the EPEX Spot market price in €/£/kWh. The market price doesn't include taxes, surcharges, VAT. The price value will be updated every hour to reflect the current market price. 127 | 128 | The sensor attributes contains additional values: 129 | 130 | - The market price in €/£/kWh. 131 | - A list of all available market prices (for today and tomorrow if available) in €/£/kWh. 132 | 133 | ```yaml 134 | price_per_kwh: 0.089958 135 | data: 136 | - start_time: "2022-12-15T23:00:00+00:00" 137 | end_time: "2022-12-16T00:00:00+00:00" 138 | price_per_kwh: 0.092042 139 | - start_time: "2022-12-16T00:00:00+00:00" 140 | end_time: "2022-12-16T01:00:00+00:00" 141 | price_per_kwh: 0.090058 142 | - start_time: "2022-12-16T01:00:00+00:00" 143 | end_time: "2022-12-16T02:00:00+00:00" 144 | price_per_kwh: 0.126067 145 | ``` 146 | 147 | ### 3. Average Market Price Sensor 148 | 149 | The sensor value reports the average EPEX Spot market price during the day. The sensor value reports the market price in €/£/kWh. 150 | 151 | 152 | ### 4. Median Market Price Sensor 153 | 154 | The sensor value reports the median EPEX Spot market price during the day. The sensor value reports the market price in €/£/kWh. 155 | 156 | 157 | ### 5. Lowest Market Price Sensor 158 | 159 | The sensor value reports the lowest EPEX Spot market price during the day. The sensor value reports the market price in €/£/kWh. The market price in €/£/kWh is available as sensor attribute. 160 | 161 | The sensor attributes contains the start and endtime of the lowest market price timeframe. 162 | 163 | ```yaml 164 | price_per_kwh: 0.09 165 | start_time: "2023-02-15T22:00:00+00:00" 166 | end_time: "2023-02-15T23:00:00+00:00" 167 | ``` 168 | 169 | ### 6. Highest Market Price Sensor 170 | 171 | The sensor value reports the highest EPEX Spot market price during the day. The sensor value reports the market price in €/£/kWh. The market price in €/£/kWh is available as sensor attribute. 172 | 173 | The sensor attributes contains the start and endtime of the highest market price timeframe. 174 | 175 | ```yaml 176 | price_per_kwh: 0.33 177 | start_time: "2023-02-15T22:00:00+00:00" 178 | end_time: "2023-02-15T23:00:00+00:00" 179 | ``` 180 | 181 | ### 7. Quantile Sensor 182 | 183 | The sensor value reports the quantile between the lowest market price and the highest market price during the day in the range between 0 & 1. 184 | 185 | Examples: 186 | 187 | - The sensor reports 0 if the current market price is the lowest during the day. 188 | - The sensor reports 1 if the current market price is the highest during the day. 189 | - If the sensor reports e.g., 0.25, then the current market price is 25% of the range between the lowest and the highest market price. 190 | 191 | ### 8. Rank Sensor 192 | 193 | The sensor value reports the rank of the current market price during the day. Or in other words: The number of hours in which the price is lower than the current price. 194 | 195 | Examples: 196 | 197 | - The sensor reports 0 if the current market price is the lowest during the day. There is no lower market price during the day. 198 | - The sensor reports 23 if the current market price is the highest during the day (if the market price will be updated hourly). There are 23 hours which are cheaper than the current hour market price. 199 | - The sensor reports 1 if the current market price is the 2nd cheapest during the day. There is 1 one which is cheaper than the current hour market price. 200 | 201 | ## Service Calls 202 | 203 | List of Service Calls: 204 | 205 | - Get Lowest Price Interval 206 | - Get Highest Price Interval 207 | - Fetch Data 208 | 209 | ### 1. Get Lowest and Highest Price Interval 210 | 211 | **Requires Release >= 2.0.0** 212 | 213 | Get the time interval during which the price is at its lowest/highest point. 214 | 215 | Knowing the hours with the lowest / highest consecutive prices during the day could be an interesting use case. This might be of value when looking for the most optimum time to start your washing machine, dishwasher, dryer, etc. 216 | 217 | With this service call, you can let the integration calculate the optimal start time. The only mandatory attribute is the duration of your appliance. Optionally you can limit start- and end-time, e.g. to start your appliance only during night hours. 218 | 219 | ```yaml 220 | epex_spot.get_lowest_price_interval 221 | epex_spot.get_highest_price_interval 222 | ``` 223 | 224 | | Service data attribute | Optional | Description | Example | 225 | | ---------------------- | -------- | ------------------------------------------------------------------------------- | -------------------------------- | 226 | | `device_id` | yes | A EPEX Spot service instance ID. In case you have multiple EPEX Spot instances. | 9d44d8ce9b19e0863cf574c2763749ac | 227 | | `earliest_start` | yes | Earliest time to start the appliance. | "14:00:00" | 228 | | `earliest_start_post` | yes | Postponement of `earliest_start` in days: 0 = today (default), 1= tomorrow | 0 | 229 | | `latest_end` | yes | Latest time to end the appliance. | "16:00:00" | 230 | | `latest_end_post` | yes | Postponement of `latest_end` in days: 0 = today (default), 1= tomorrow | 0 | 231 | | `duration` | no | Required duration to complete appliance. | See below... | 232 | 233 | Notes: 234 | 235 | - If `earliest_start` is omitted, the current time is used instead. 236 | - If `latest_end` is omitted, the end of all available market data is used. 237 | - `earliest_start` refers to today if `earliest_start_post` is omitted or set to 0. 238 | - `latest_end` will be automatically trimmed to the available market area. 239 | - If `earliest_start` and `latest_end` are present _and_ `latest_end` is earlier than (or equal to) `earliest_start`, then `latest_end` refers to tomorrow. 240 | - `device_id` is only required if have have setup multiple EPEX Spot instances. The easiest way to get the unique device id, is to use the _Developer Tools -> Services_. 241 | 242 | Service Call Examples: 243 | 244 | ```yaml 245 | service: epex_spot.get_lowest_price_interval 246 | data: 247 | device_id: 9d44d8ce9b19e0863cf574c2763749ac 248 | earliest_start: "14:00:00" 249 | latest_end: "16:00:00" 250 | duration: 251 | hours: 1 252 | minutes: 0 253 | seconds: 0 254 | ``` 255 | 256 | ```yaml 257 | service: epex_spot.get_lowest_price_interval 258 | data: 259 | earliest_start: "14:00:00" 260 | latest_end: "16:00:00" 261 | duration: "00:30:00" # 30 minutes 262 | ``` 263 | 264 | ```yaml 265 | service: epex_spot.get_lowest_price_interval 266 | data: 267 | duration: "00:30" # 30 minutes 268 | ``` 269 | 270 | ```yaml 271 | service: epex_spot.get_lowest_price_interval 272 | data: 273 | duration: 120 # in seconds -> 2 minutes 274 | ``` 275 | 276 | ```yaml 277 | # get the lowest price all day tomorrow: 278 | service: epex_spot.get_lowest_price_interval 279 | data: 280 | earliest_start: "00:00:00" 281 | earliest_start_post: 1 282 | latest_end: "00:00:00" 283 | latest_end_post: 2 284 | duration: "01:30:00" # 1h, 30 minutes 285 | ``` 286 | 287 | #### Response 288 | 289 | The response contains the calculated start and end-time and the average price per kWh. 290 | 291 | Example: 292 | 293 | ```yaml 294 | start: "2024-11-04T23:00:00+01:00" 295 | end: "2024-11-05T00:00:00+01:00" 296 | price_per_kwh: 0.098192 297 | net_price_per_kwh: 0.13223 298 | ``` 299 | 300 | With Home Assistant release >= 2023.9 you can use the [Template Integration](https://www.home-assistant.io/integrations/template/) to create a sensor (in your `configuration.yaml` file) that shows the start time: 301 | 302 | ![Start Appliance Sensor](/images/start_appliance_sensor.png) 303 | 304 | ```yaml 305 | template: 306 | - trigger: 307 | - platform: time 308 | at: "00:00:00" 309 | action: 310 | - service: epex_spot.get_lowest_price_interval 311 | data: 312 | earliest_start: "20:00:00" 313 | latest_end: "23:00:00" 314 | duration: 315 | hours: 1 316 | minutes: 5 317 | response_variable: resp 318 | sensor: 319 | - name: Start Appliance 320 | device_class: timestamp 321 | state: "{{ resp.start is defined and resp.start }}" 322 | ``` 323 | 324 | This sensor can be used to trigger automations: 325 | 326 | ```yaml 327 | trigger: 328 | - platform: time 329 | at: sensor.start_appliance 330 | condition: [] 331 | action: [] 332 | ``` 333 | 334 | ### 2. Fetch Data 335 | 336 | **Requires Release >= 2.1.0** 337 | 338 | Fetch data from all services or a specific service. 339 | 340 | ```yaml 341 | epex_spot.fetch_data 342 | ``` 343 | 344 | | Service data attribute | Optional | Description | Example | 345 | | ---------------------- | -------- | ------------------------------------------------------------------------------- | -------------------------------- | 346 | | `device_id` | yes | A EPEX Spot service instance ID. In case you have multiple EPEX Spot instances. | 9d44d8ce9b19e0863cf574c2763749ac | 347 | 348 | ### 3. The EPEX Spot Sensor Integration 349 | 350 | A significantly easier, GUI-based method to achieve some of the results listed above is to install the [EPEX Spot Sensor](https://github.com/mampfes/ha_epex_spot_sensor "EPEX Spot Sensor") integration (via HACS) and configure helpers with it. An example for this method is covered in FAQ 2 below. 351 | 352 | ## FAQ 353 | 354 | ### 1. How can I show a chart of the next hours? 355 | 356 | With [ApexCharts](https://github.com/RomRider/apexcharts-card), you can easily show a chart like this to see the hourly net prices for today: 357 | 358 | ![apexchart](/images/apexcharts.png) 359 | 360 | You just have to install [ApexCharts](https://github.com/RomRider/apexcharts-card) (via HACS) and enter the following data in the card configuration: 361 | 362 | ```yaml 363 | type: custom:apexcharts-card 364 | header: 365 | show: true 366 | title: Electricity Prices 367 | graph_span: 48h 368 | span: 369 | start: day 370 | now: 371 | show: true 372 | label: Now 373 | series: 374 | - entity: sensor.epex_spot_data_net_price 375 | name: Electricity Price 376 | type: column 377 | extend_to: end 378 | data_generator: | 379 | return entity.attributes.data.map((entry) => { 380 | return [new Date(entry.start_time), entry.price_per_kwh]; 381 | }); 382 | ``` 383 | 384 | See [this Show & Tell post](https://github.com/mampfes/ha_epex_spot/discussions/110) for a fancier, more elaborate version of this card that can auto-hide the next day's prices when they aren't available, colour the hourly bars depending on the price, etc. 385 | 386 | **Assumptions:** 387 | 388 | This example assumes that you are using smartENERGY.at as a source and want to display the Net Price in €/kWh for the next 48 hours. The value for `entity` and the `entry` being processed by the `data_generator` are specific to this data source: 389 | 390 | ![Apex Chart Data Source Example](/images/apexcharts-entities-example.png) 391 | 392 | If you are using a different source, you will need to first update `sensor.epex_spot_data_net_price` to use the correct sensor for your configuration (check which Entities you have available under Devices → Integrations → EPEX Spot → `#` Entities) and then change `entry.price_per_kwh` to the attribute that you want to use from your sensor of choice. If your data source does not report prices for the next day, you can change the `graph_span` to `24h` to get rid of the empty space that this configuration would create. 393 | 394 | ### 2. How can I optimise the best moment to start appliances? 395 | 396 | It might be an interesting use case to know what the hours with lowest consecutive prices during the day are. This might be of value when looking for the most optimum time to start your washing machine, dishwasher, dryer, etc. The most convenient way to do this would be to install and configure the [EPEX Spot Sensor](https://github.com/mampfes/ha_epex_spot_sensor "EPEX Spot Sensor") (via HACS). 397 | 398 | #### Example 399 | 400 | - Your dishwasher cycle takes 3 hours and 15 minutes to run 401 | - You want to run a full, continuous cycle in the time-window when power is the cheapest for those 3 hours & 15 minutes 402 | - You don't care at what exact time the dishwasher cycle starts or finishes 403 | 404 | #### Create & Configure a Helper 405 | 406 | Create a Helper by going to Settings → Devices & Services → Helpers → Create Helper → EPEX Spot Sensor and configure it like so: 407 | 408 | ![Dishwasher Config Example](/images/epex-spot-sensor-dishwasher-config-example.png) 409 | 410 | This creates a binary sensor `binary_sensor.dishwasher_cheapest_window` with the Friendly Name "Dishwasher: Cheapest Window". The sensor turns on at the start of the cheapest time-window, off at the end of the time-window, and reports the `start_time` & `end_time` for this time-window in its attributes. 411 | 412 | ![Dishwasher Sensor Example](/images/epex-spot-sensor-dishwasher-sensor-example.png) 413 | 414 | Depending on your implementation use-case, there are two ways to proceed: 415 | 416 | **Case 1: Automating the dishwasher** 417 | If the dishwasher can be controlled via Home Assistant, or you can use some kind of smart-device to start the dishwasher cycle, you could create an automations that triggers when `binary_sensor.dishwasher_cheapest_window` turns on. 418 | 419 | **Case 2: Manually starting / scheduling the dishwasher** 420 | If your dishwasher cannot be automated, you can create a card on your dashboard that tells you either what time, or in how much time you should should manually start your dishwasher or schedule it to start. 421 | 422 | _What time should I start the dishwasher?_ 423 | Create a Template Sensor by going to Settings → Devices & Services → Helpers → Create Helper → Template → Template a sensor. Give it a Friendly name, for example "Next Dishwasher Start (Time)" and under "State Template", enter 424 | 425 | ```yaml 426 | {% set data = state_attr('binary_sensor.dishwasher_cheapest_window', 'data') %} 427 | {% set now = now() %} 428 | {% set future_windows = data | selectattr('start_time', '>', now.timestamp() | timestamp_local) | list %} 429 | {% if future_windows %} 430 | {% set next_window = future_windows | first %} 431 | {% set start_time = strptime(next_window['start_time'], '%Y-%m-%dT%H:%M:%S%z') %} 432 | {{ start_time.strftime('%H:%M on %d/%m/%y') }} 433 | {% else %} 434 | Waiting for new data 435 | {% endif %} 436 | ``` 437 | 438 | This Template Sensors uses the data from the attributes of the "Dishwasher: Cheapest Window" binary sensor created earlier with the EPEX Spot Sensor integration, checks whether the `start_time` is in the future, and displays the `start_time` as `H:M` on `d/m/y`. 439 | 440 | _In how much time from now should I start the dishwasher?_ 441 | Create a Template Sensor by going to Settings → Devices & Services → Helpers → Create Helper → Template → Template a sensor. Give it a Friendly name, for example "Next Dishwasher Start (Duration)" and under "State Template", enter 442 | 443 | ```yaml 444 | {% set data = state_attr('binary_sensor.dishwasher_cheapest_window', 'data') %} 445 | {% set now = now() %} 446 | {% set future_windows = data | selectattr('start_time', '>', now.timestamp() | timestamp_local) | list %} 447 | {% if future_windows %} 448 | {% set next_window = future_windows | first %} 449 | {% set start_time = strptime(next_window['start_time'], '%Y-%m-%dT%H:%M:%S%z') %} 450 | {% set time_to_start = (start_time - now).total_seconds() %} 451 | {% set hours = (time_to_start // 3600) | int %} 452 | {% set minutes = ((time_to_start % 3600) // 60) | int %} 453 | {% set time_str = '{:02}:{:02}'.format(hours, minutes) %} 454 | {{ time_str }} 455 | {% else %} 456 | Waiting for new data 457 | {% endif %} 458 | ``` 459 | 460 | In addition to what the previous sensor does, this one calculates how long it is from `now` till the `start_time`, and displays the result in `H:M`. 461 | 462 | In both cases, if the `start_time` has already passed, the sensors display `Waiting for new data`. 463 | 464 | Finally, create Entity Cards on your dashboard with the sensors you want to display. 465 | 466 | ![Dishwasher Card Examples](/images/dishwasher-card-examples.png) 467 | 468 | See [this Show & Tell post](https://github.com/mampfes/ha_epex_spot/discussions/111) for a fancier, more elaborate version of this card that can show several appliances at once, auto hide ones that don't have data, and even hide itself when there is no data at all. 469 | 470 | ### 3. I want to combine and view everything 471 | 472 | Here's another [ApexCharts](https://github.com/RomRider/apexcharts-card) example. 473 | It shows the price for the current day, the next day and the `min/max` value for each day. 474 | Furthermore, it also fills the hours during which prices are lowest (see 2.) 475 | 476 | ![apexchart](/images/apex_advanced.png) 477 | 478 | ```yaml 479 | type: custom:apexcharts-card 480 | header: 481 | show: false 482 | graph_span: 48h 483 | span: 484 | start: day 485 | now: 486 | show: true 487 | label: Now 488 | color_list: 489 | - var(--primary-color) 490 | series: 491 | - entity: sensor.epex_spot_data_price 492 | yaxis_id: uurprijs 493 | float_precision: 2 494 | type: line 495 | curve: stepline 496 | extend_to: false 497 | show: 498 | extremas: true 499 | data_generator: > 500 | return entity.attributes.data.map((entry, index) => { return [new 501 | Date(entry.start_time).getTime(), entry.price_per_kwh]; }).slice(0,24); 502 | color_threshold: 503 | - value: 0 504 | color: "#186ddc" 505 | - value: 0.155 506 | color: "#04822e" 507 | - value: 0.2 508 | color: "#12A141" 509 | - value: 0.25 510 | color: "#79B92C" 511 | - value: 0.3 512 | color: "#C4D81D" 513 | - value: 0.35 514 | color: "#F3DC0C" 515 | - value: 0.4 516 | color: red 517 | - value: 0.5 518 | color: magenta 519 | - entity: sensor.epex_spot_data_price 520 | yaxis_id: uurprijs 521 | float_precision: 2 522 | type: line 523 | curve: stepline 524 | extend_to: end 525 | show: 526 | extremas: true 527 | data_generator: > 528 | return entity.attributes.data.map((entry, index) => { return [new 529 | Date(entry.start_time).getTime(), entry.price_per_kwh]; }).slice(23,47); 530 | color_threshold: 531 | - value: 0 532 | color: "#186ddc" 533 | - value: 0.155 534 | color: "#04822e" 535 | - value: 0.2 536 | color: "#12A141" 537 | - value: 0.25 538 | color: "#79B92C" 539 | - value: 0.3 540 | color: "#C4D81D" 541 | - value: 0.35 542 | color: "#F3DC0C" 543 | - value: 0.4 544 | color: red 545 | - value: 0.5 546 | color: magenta 547 | - entity: sensor.epex_spot_data_price 548 | yaxis_id: uurprijs 549 | type: area 550 | color: green 551 | float_precision: 2 552 | curve: stepline 553 | extend_to: false 554 | data_generator: > 555 | return entity.attributes.data.map((entry, index) => { return [new 556 | Date(entry.start_time).getTime(), entry.price_per_kwh];}).slice(parseInt(hass.states['sensor.epex_start_low_period'].state.substring(0,2)),parseInt(hass.states['sensor.epex_start_low_period'].state.substring(0,2))+4); 557 | 558 | experimental: 559 | color_threshold: true 560 | yaxis: 561 | - id: uurprijs 562 | decimals: 2 563 | apex_config: 564 | title: 565 | text: €/kWh 566 | tickAmount: 4 567 | apex_config: 568 | legend: 569 | show: false 570 | tooltip: 571 | x: 572 | show: true 573 | format: HH:00 - HH:59 574 | ``` 575 | 576 | **Assumptions:** 577 | 578 | This example assumes that you are using the EPEX Spot Web Scraper as a source and want to display the Price in €/kWh for the next 48 hours, and highlight a 4-hour block where the electricity price is the lowest. As with the previous example, the `entity` and the `entry` being processed by the `data_generator` are specific to this data source, and you should update them to match your configuration. 579 | 580 | In case the electricity pricing in your market results in the entire sparkline having one static colour (for example, the line always appears magenta), you will need to fine-tune the `color_threshold` entries . You can do this by either editing the `value` entries in the example above, or you can also add more `value` and `color` pairs if you want additional colours. 581 | 582 | To change the colour of the highlighted cheapest time-period, update the `color` entry under `type: area`, and to change the length of the time-period, change the `+4` at the end of the `data_generator`. 583 | -------------------------------------------------------------------------------- /custom_components/epex_spot/EPEXSpot/Awattar/__init__.py: -------------------------------------------------------------------------------- 1 | """Awattar API.""" 2 | 3 | from datetime import datetime, timedelta, timezone 4 | import logging 5 | 6 | import aiohttp 7 | 8 | from homeassistant.util import dt as dt_util 9 | 10 | from ...const import EUR_PER_MWH, UOM_EUR_PER_KWH 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | class Marketprice: 16 | """Marketprice class for Awattar.""" 17 | 18 | def __init__(self, data): 19 | assert data["unit"].lower() == EUR_PER_MWH.lower() 20 | self._start_time = datetime.fromtimestamp( 21 | data["start_timestamp"] / 1000, tz=timezone.utc 22 | ) 23 | self._end_time = datetime.fromtimestamp( 24 | data["end_timestamp"] / 1000, tz=timezone.utc 25 | ) 26 | self._price_per_kwh = round(float(data["marketprice"]) / 1000.0, 6) 27 | 28 | def __repr__(self): 29 | return f"{self.__class__.__name__}(start: {self._start_time.isoformat()}, end: {self._end_time.isoformat()}, marketprice: {self._price_per_kwh} {UOM_EUR_PER_KWH})" # noqa: E501 30 | 31 | @property 32 | def start_time(self): 33 | return self._start_time 34 | 35 | @property 36 | def end_time(self): 37 | return self._end_time 38 | 39 | @property 40 | def price_per_kwh(self): 41 | return self._price_per_kwh 42 | 43 | 44 | def toEpochMilliSec(dt: datetime) -> int: 45 | return int(dt.timestamp() * 1000) 46 | 47 | 48 | class Awattar: 49 | URL = "https://api.awattar.{market_area}/v1/marketdata" 50 | 51 | MARKET_AREAS = ("at", "de") 52 | 53 | def __init__(self, market_area, session: aiohttp.ClientSession): 54 | self._session = session 55 | self._market_area = market_area 56 | self._url = self.URL.format(market_area=market_area) 57 | self._marketdata = [] 58 | 59 | @property 60 | def name(self): 61 | return "Awattar API V1" 62 | 63 | @property 64 | def market_area(self): 65 | return self._market_area 66 | 67 | @property 68 | def duration(self): 69 | return 60 70 | 71 | @property 72 | def currency(self): 73 | return "EUR" 74 | 75 | @property 76 | def marketdata(self): 77 | return self._marketdata 78 | 79 | async def fetch(self): 80 | data = await self._fetch_data(self._url) 81 | self._marketdata = self._extract_marketdata(data["data"]) 82 | 83 | async def _fetch_data(self, url): 84 | start = dt_util.now().replace( 85 | hour=0, minute=0, second=0, microsecond=0 86 | ) - timedelta(days=1) 87 | end = start + timedelta(days=3) 88 | async with self._session.get( 89 | url, params={"start": toEpochMilliSec(start), "end": toEpochMilliSec(end)} 90 | ) as resp: 91 | resp.raise_for_status() 92 | return await resp.json() 93 | 94 | def _extract_marketdata(self, data): 95 | entries = [] 96 | for entry in data: 97 | entries.append(Marketprice(entry)) 98 | return entries 99 | -------------------------------------------------------------------------------- /custom_components/epex_spot/EPEXSpot/EPEXSpotWeb/__init__.py: -------------------------------------------------------------------------------- 1 | """EPEX Spot Web Scraper.""" 2 | 3 | from datetime import datetime, timedelta, timezone 4 | import logging 5 | from zoneinfo import ZoneInfo 6 | 7 | import aiohttp 8 | from bs4 import BeautifulSoup 9 | 10 | from ...const import UOM_EUR_PER_KWH, UOM_MWH 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | def _to_float(v): 16 | return float(v.replace(",", "")) 17 | 18 | 19 | def _as_date(v): 20 | return v.strftime("%Y-%m-%d") 21 | 22 | 23 | MARKET_AREA_MAP = { 24 | "GB-30": {"auction": "30-call-GB", "market_area": "GB", "duration": 30}, 25 | "GB": {"auction": "GB", "market_area": "GB", "duration": 60}, 26 | "CH": {"auction": "CH", "market_area": "CH", "duration": 60}, 27 | } 28 | 29 | 30 | class Marketprice: 31 | """Marketprice class for EPEX Spot Web.""" 32 | 33 | def __init__( 34 | self, start_time, end_time, buy_volume_mwh, sell_volume_mwh, volume_mwh, price 35 | ): 36 | self._start_time = start_time 37 | self._end_time = end_time 38 | self._buy_volume_mwh = _to_float(buy_volume_mwh) 39 | self._sell_volume_mwh = _to_float(sell_volume_mwh) 40 | self._volume_mwh = _to_float(volume_mwh) 41 | self._price_per_kwh = round(_to_float(price) / 1000.0, 6) 42 | 43 | def __repr__(self): 44 | return f"{self.__class__.__name__}(start: {self._start_time.isoformat()}, end: {self._end_time.isoformat()}, buy_volume_mwh: {self._buy_volume_mwh} {UOM_MWH}, sell_volume_mwh: {self._sell_volume_mwh} {UOM_MWH}, volume_mwh: {self._volume_mwh} {UOM_MWH}, marketprice: {self._price_per_kwh} {UOM_EUR_PER_KWH})" # noqa: E501 45 | 46 | @property 47 | def start_time(self): 48 | return self._start_time 49 | 50 | @property 51 | def end_time(self): 52 | return self._end_time 53 | 54 | @property 55 | def price_per_kwh(self): 56 | return self._price_per_kwh 57 | 58 | @property 59 | def buy_volume_mwh(self): 60 | return self._buy_volume_mwh 61 | 62 | @property 63 | def sell_volume_mwh(self): 64 | return self._sell_volume_mwh 65 | 66 | @property 67 | def volume_mwh(self): 68 | return self._volume_mwh 69 | 70 | 71 | class EPEXSpotWeb: 72 | URL = "https://www.epexspot.com/en/market-results" 73 | 74 | MARKET_AREAS = ( 75 | "AT", 76 | "BE", 77 | "CH", 78 | "DE-LU", 79 | "DK1", 80 | "DK2", 81 | "FI", 82 | "FR", 83 | "GB", 84 | "GB-30", 85 | "NL", 86 | "NO1", 87 | "NO2", 88 | "NO3", 89 | "NO4", 90 | "NO5", 91 | "PL", 92 | "SE1", 93 | "SE2", 94 | "SE3", 95 | "SE4", 96 | ) 97 | 98 | def __init__(self, market_area, session: aiohttp.ClientSession): 99 | self._session = session 100 | 101 | self._market_area = market_area 102 | item = MARKET_AREA_MAP.get(market_area) 103 | if item is None: 104 | self._int_market_area = market_area 105 | self._duration = 60 106 | self._auction = "MRC" 107 | else: 108 | self._int_market_area = item["market_area"] 109 | self._duration = item["duration"] 110 | self._auction = item["auction"] 111 | 112 | self._marketdata = [] 113 | 114 | @property 115 | def name(self): 116 | return "EPEX Spot Web Scraper" 117 | 118 | @property 119 | def market_area(self): 120 | return self._market_area 121 | 122 | @property 123 | def duration(self): 124 | return self._duration 125 | 126 | @property 127 | def currency(self): 128 | return "GBP" if self._market_area.startswith("GB") else "EUR" 129 | 130 | @property 131 | def marketdata(self): 132 | return self._marketdata 133 | 134 | async def fetch(self): 135 | delivery_date = datetime.now(ZoneInfo("Europe/Berlin")) 136 | # get data for remaining day and upcoming day 137 | # Data for the upcoming day is typically available at 12:45 138 | marketdata = await self._fetch_day(delivery_date) + await self._fetch_day( 139 | delivery_date + timedelta(days=1) 140 | ) 141 | # overwrite cached marketdata only on success 142 | self._marketdata = marketdata 143 | 144 | async def _fetch_day(self, delivery_date): 145 | data = await self._fetch_data(delivery_date) 146 | invokes = self._extract_invokes(data) 147 | 148 | # check if there is an invoke command with selector ".js-md-widget" 149 | # because this contains the table with the results 150 | table_data = invokes.get(".js-md-widget") 151 | if table_data is None: 152 | # no data for this day 153 | return [] 154 | return self._extract_table_data(delivery_date, table_data) 155 | 156 | async def _fetch_data(self, delivery_date): 157 | trading_date = delivery_date - timedelta(days=1) 158 | headers = { 159 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0" # noqa 160 | } 161 | params = { 162 | "market_area": self._int_market_area, 163 | "trading_date": _as_date(trading_date), 164 | "delivery_date": _as_date(delivery_date), 165 | "auction": self._auction, 166 | # "underlying_year": None, 167 | "modality": "Auction", 168 | "sub_modality": "DayAhead", 169 | # "technology": None, 170 | "product": self._duration, 171 | "data_mode": "table", 172 | # "period": None, 173 | # "production_period": None, 174 | "ajax_form": 1, 175 | # "_wrapper_format": "html", 176 | # "_wrapper_format": "drupal_ajax", 177 | } 178 | data = { 179 | # "filters[modality]": "Auction", # optional 180 | # "filters[sub_modality]": "DayAhead", # optional 181 | # "filters[trading_date]": None, 182 | # "filters[delivery_date]": None, 183 | # "filters[product]": 60, 184 | # "filters[data_mode]": "table", 185 | # "filters[market_area]": "AT", 186 | # "triggered_element": "filters[market_area]", 187 | # "first_triggered_date": None, 188 | # "form_build_id": "form-fwlBrltLn1Oh2ak-YdbDNeXBpEPle4M8hmu0omAd4nU", # noqa: E501 189 | "form_id": "market_data_filters_form", 190 | "_triggering_element_name": "submit_js", 191 | # "_triggering_element_value": None, 192 | # "_drupal_ajax": 1, 193 | # "ajax_page_state[theme]": "epex", 194 | # "ajax_page_state[theme_token]": None, 195 | # "ajax_page_state[libraries]": "bootstrap/popover,bootstrap/tooltip,core/html5shiv,core/jquery.form,epex/global-scripts,epex/global-styling,epex/highcharts,epex_core/data-disclaimer,epex_market_data/filters,epex_market_data/tables,eu_cookie_compliance/eu_cookie_compliance_default,statistics/drupal.statistics,system/base", # noqa: E501 196 | } 197 | 198 | async with self._session.post( 199 | self.URL, headers=headers, params=params, data=data, verify_ssl=True 200 | ) as resp: 201 | resp.raise_for_status() 202 | return await resp.json() 203 | 204 | def _extract_invokes(self, data): 205 | """Extract invoke commands from JSON response. 206 | 207 | The returned JSON data consist of a list of commands. A command can be 208 | either an invoke or an insert. This method returns a dictionary with 209 | all invoke commands. The key is the so called selector, which is 210 | basically a kind of target. 211 | """ 212 | invokes = {} 213 | for entry in data: 214 | if entry["command"] == "invoke": 215 | invokes[entry["selector"]] = entry 216 | return invokes 217 | 218 | def _extract_table_data(self, delivery_date, data): 219 | """Extract table with results from response. 220 | 221 | The response contains HTML data. The wanted information is stored in 222 | a table. Each line is an 1 hour window. 223 | """ 224 | soup = BeautifulSoup(data["args"][0], features="html.parser") 225 | 226 | # the headline contains the current date 227 | # example: Auction > Day-Ahead > 60min > AT > 24 December 2022 228 | # headline = soup.find("div", class_="table-container").h2.string 229 | 230 | try: 231 | table = soup.find("table", class_="table-01 table-length-1") 232 | body = table.tbody 233 | rows = body.find_all_next("tr") 234 | except AttributeError: 235 | return [] # no data available 236 | 237 | start_time = delivery_date.replace(hour=0, minute=0, second=0, microsecond=0) 238 | 239 | # convert timezone to UTC (and adjust timestamp) 240 | start_time = start_time.astimezone(timezone.utc) 241 | 242 | marketdata = [] 243 | for row in rows: 244 | end_time = start_time + timedelta(minutes=self._duration) 245 | buy_volume_col = row.td 246 | sell_volume_col = buy_volume_col.find_next_sibling("td") 247 | volume_col = sell_volume_col.find_next_sibling("td") 248 | price_col = volume_col.find_next_sibling("td") 249 | marketdata.append( 250 | Marketprice( 251 | start_time=start_time, 252 | end_time=end_time, 253 | buy_volume_mwh=buy_volume_col.string, 254 | sell_volume_mwh=sell_volume_col.string, 255 | volume_mwh=volume_col.string, 256 | price=price_col.string, 257 | ) 258 | ) 259 | start_time = end_time 260 | 261 | return marketdata 262 | -------------------------------------------------------------------------------- /custom_components/epex_spot/EPEXSpot/Energyforecast/__init__.py: -------------------------------------------------------------------------------- 1 | """Energyforecast.de""" 2 | 3 | from datetime import datetime 4 | import logging 5 | 6 | import aiohttp 7 | 8 | 9 | from ...const import UOM_EUR_PER_KWH 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | 14 | class Marketprice: 15 | """Marketprice class for Energyforecast.""" 16 | 17 | def __init__(self, data): 18 | self._start_time = datetime.fromisoformat(data["start"]) 19 | self._end_time = datetime.fromisoformat(data["end"]) 20 | self._price_per_kwh = round(float(data["price"]), 6) 21 | 22 | def __repr__(self): 23 | return f"{self.__class__.__name__}(start: {self._start_time.isoformat()}, end: {self._end_time.isoformat()}, marketprice: {self._price_per_kwh} {UOM_EUR_PER_KWH})" # noqa: E501 24 | 25 | @property 26 | def start_time(self): 27 | return self._start_time 28 | 29 | @property 30 | def end_time(self): 31 | return self._end_time 32 | 33 | @property 34 | def price_per_kwh(self): 35 | return self._price_per_kwh 36 | 37 | class Energyforecast: 38 | URL = "https://www.energyforecast.de/api/v1/predictions/prices_for_ha" 39 | 40 | MARKET_AREAS = ("de",) 41 | 42 | def __init__(self, market_area, token: str, session: aiohttp.ClientSession): 43 | self._token = token 44 | self._session = session 45 | self._market_area = market_area 46 | self._marketdata = [] 47 | 48 | @property 49 | def name(self): 50 | return "Energyforecast API V1" 51 | 52 | @property 53 | def market_area(self): 54 | return self._market_area 55 | 56 | @property 57 | def duration(self): 58 | return 60 59 | 60 | @property 61 | def currency(self): 62 | return "EUR" 63 | 64 | @property 65 | def marketdata(self): 66 | return self._marketdata 67 | 68 | async def fetch(self): 69 | data = await self._fetch_data(self.URL) 70 | self._marketdata = self._extract_marketdata(data["forecast"]["data"]) 71 | 72 | async def _fetch_data(self, url): 73 | async with self._session.get( 74 | url, params={"token": self._token, "fixed_cost_cent": 0, "vat": 0} 75 | ) as resp: 76 | resp.raise_for_status() 77 | return await resp.json() 78 | 79 | def _extract_marketdata(self, data): 80 | return [Marketprice(entry) for entry in data] 81 | -------------------------------------------------------------------------------- /custom_components/epex_spot/EPEXSpot/SMARD/__init__.py: -------------------------------------------------------------------------------- 1 | """SMARD.de API.""" 2 | 3 | from datetime import datetime, timedelta, timezone 4 | import logging 5 | 6 | import aiohttp 7 | 8 | from ...const import UOM_EUR_PER_KWH 9 | 10 | # from homeassistant.util import dt 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | MARKET_AREA_MAP = { 15 | "DE-LU": 4169, 16 | "Anrainer DE-LU": 5078, 17 | "BE": 4996, 18 | "NO2": 4997, 19 | "AT": 4170, 20 | "DK1": 252, 21 | "DK2": 253, 22 | "FR": 254, 23 | "IT (North)": 255, 24 | "NL": 256, 25 | "PL": 257, 26 | "CH": 259, 27 | "SI": 260, 28 | "CZ": 261, 29 | "HU": 262, 30 | } 31 | 32 | 33 | class Marketprice: 34 | """Marketprice class for SMARD.de.""" 35 | 36 | def __init__(self, data): 37 | self._start_time = datetime.fromtimestamp(data[0] / 1000, tz=timezone.utc) 38 | self._end_time = self._start_time + timedelta( 39 | hours=1 40 | ) # TODO: this will not work for 1/2h updates 41 | 42 | self._price_per_kwh = round(float(data[1]) / 1000.0, 6) 43 | 44 | def __repr__(self): 45 | return f"{self.__class__.__name__}(start: {self._start_time.isoformat()}, end: {self._end_time.isoformat()}, marketprice: {self._price_per_kwh} {UOM_EUR_PER_KWH})" # noqa: E501 46 | 47 | @property 48 | def start_time(self): 49 | return self._start_time 50 | 51 | @property 52 | def end_time(self): 53 | return self._end_time 54 | 55 | @property 56 | def price_per_kwh(self): 57 | return self._price_per_kwh 58 | 59 | 60 | class SMARD: 61 | URL = "https://www.smard.de/app/chart_data" 62 | 63 | MARKET_AREAS = MARKET_AREA_MAP.keys() 64 | 65 | def __init__(self, market_area, session: aiohttp.ClientSession): 66 | self._session = session 67 | self._market_area = market_area 68 | self._marketdata = [] 69 | 70 | @property 71 | def name(self): 72 | return "SMARD.de" 73 | 74 | @property 75 | def market_area(self): 76 | return self._market_area 77 | 78 | @property 79 | def duration(self): 80 | return 60 81 | 82 | @property 83 | def currency(self): 84 | return "EUR" 85 | 86 | @property 87 | def marketdata(self): 88 | return self._marketdata 89 | 90 | async def fetch(self): 91 | smard_filter = MARKET_AREA_MAP[self._market_area] 92 | smard_region = self._market_area 93 | smard_resolution = "hour" 94 | 95 | # get available timestamps for given market area 96 | url = f"{self.URL}/{smard_filter}/{smard_region}/index_{smard_resolution}.json" 97 | async with self._session.get(url) as resp: 98 | resp.raise_for_status() 99 | j = await resp.json() 100 | 101 | # fetch last 2 data-series, because on sunday noon starts a new series 102 | # and then some data is missing 103 | latest_timestamp = j["timestamps"][-2:] 104 | 105 | entries = [] 106 | 107 | for lt in latest_timestamp: 108 | # get available data 109 | data = await self._fetch_data( 110 | lt, smard_filter, smard_region, smard_resolution 111 | ) 112 | 113 | for entry in data["series"]: 114 | if entry[1] is not None: 115 | entries.append(Marketprice(entry)) 116 | 117 | if entries[-1].start_time.date() == datetime.today().date(): 118 | # latest data is on the same day, only return 48 entries 119 | # thats yesterday and today 120 | self._marketdata = entries[ 121 | -48: 122 | ] # limit number of entries to protect HA recorder 123 | else: 124 | # latest data is tomorrow, return 72 entries 125 | # thats yesterday, today and tomorrow 126 | self._marketdata = entries[ 127 | -72: 128 | ] # limit number of entries to protect HA recorder 129 | 130 | async def _fetch_data( 131 | self, latest_timestamp, smard_filter, smard_region, smard_resolution 132 | ): 133 | # get available data 134 | url = f"{self.URL}/{smard_filter}/{smard_region}/{smard_filter}_{smard_region}_{smard_resolution}_{latest_timestamp}.json" # noqa: E501 135 | async with self._session.get(url) as resp: 136 | resp.raise_for_status() 137 | return await resp.json() 138 | -------------------------------------------------------------------------------- /custom_components/epex_spot/EPEXSpot/Tibber/__init__.py: -------------------------------------------------------------------------------- 1 | """Tibber API.""" 2 | 3 | from datetime import datetime, timedelta 4 | 5 | import aiohttp 6 | 7 | from ...const import UOM_EUR_PER_KWH 8 | 9 | TIBBER_QUERY = """ 10 | { 11 | viewer { 12 | homes { 13 | currentSubscription{ 14 | priceInfo{ 15 | today { 16 | total 17 | energy 18 | tax 19 | startsAt 20 | currency 21 | } 22 | tomorrow { 23 | total 24 | energy 25 | tax 26 | startsAt 27 | currency 28 | } 29 | } 30 | } 31 | } 32 | } 33 | } 34 | """ 35 | 36 | 37 | class Marketprice: 38 | """Marketprice class for Tibber.""" 39 | 40 | def __init__(self, data): 41 | self._start_time = datetime.fromisoformat(data["startsAt"]) 42 | self._end_time = self._start_time + timedelta(hours=1) 43 | # Tibber already returns the actual net price for the customer 44 | # so we can use that 45 | self._price_per_kwh = round(float(data["total"]), 6) 46 | 47 | def __repr__(self): 48 | return f"{self.__class__.__name__}(start: {self._start_time.isoformat()}, end: {self._end_time.isoformat()}, marketprice: {self._price_per_kwh} {UOM_EUR_PER_KWH})" # noqa: E501 49 | 50 | @property 51 | def start_time(self): 52 | return self._start_time 53 | 54 | @property 55 | def end_time(self): 56 | return self._end_time 57 | 58 | @property 59 | def price_per_kwh(self): 60 | return self._price_per_kwh 61 | 62 | 63 | class Tibber: 64 | # DEMO_TOKEN = "5K4MVS-OjfWhK_4yrjOlFe1F6kJXPVf7eQYggo8ebAE" 65 | # 123456789.123456789.123456789.123456789.123 66 | URL = "https://api.tibber.com/v1-beta/gql" 67 | 68 | MARKET_AREAS = ("de", "nl", "no", "se") 69 | 70 | def __init__(self, market_area, token: str, session: aiohttp.ClientSession): 71 | self._session = session 72 | self._token = token 73 | self._market_area = market_area 74 | self._duration = 60 75 | self._marketdata = [] 76 | 77 | @property 78 | def name(self): 79 | return "Tibber API v1-beta" 80 | 81 | @property 82 | def market_area(self): 83 | return self._market_area 84 | 85 | @property 86 | def duration(self): 87 | return self._duration 88 | 89 | @property 90 | def currency(self): 91 | return "EUR" 92 | 93 | @property 94 | def marketdata(self): 95 | return self._marketdata 96 | 97 | async def fetch(self): 98 | data = await self._fetch_data(self.URL) 99 | self._marketdata = self._extract_marketdata( 100 | data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceInfo"] 101 | ) 102 | 103 | async def _fetch_data(self, url): 104 | async with self._session.post( 105 | self.URL, 106 | data={"query": TIBBER_QUERY}, 107 | headers={"Authorization": "Bearer {}".format(self._token)}, 108 | ) as resp: 109 | resp.raise_for_status() 110 | return await resp.json() 111 | 112 | def _extract_marketdata(self, data): 113 | entries = [] 114 | for entry in data["today"]: 115 | entries.append(Marketprice(entry)) 116 | for entry in data["tomorrow"]: 117 | entries.append(Marketprice(entry)) 118 | return entries 119 | -------------------------------------------------------------------------------- /custom_components/epex_spot/EPEXSpot/smartENERGY/__init__.py: -------------------------------------------------------------------------------- 1 | """smartENERGY API.""" 2 | 3 | from datetime import datetime, timedelta 4 | import logging 5 | 6 | import aiohttp 7 | 8 | from ...const import CT_PER_KWH 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | 13 | class Marketprice: 14 | """Marketprice class for smartENERGY.""" 15 | 16 | def __init__(self, duration, data): 17 | self._start_time = datetime.fromisoformat(data["date"]) 18 | self._end_time = self._start_time + timedelta(minutes=duration) 19 | # price includes austrian vat (20%) -> remove to be consistent with other data sources 20 | self._price_per_kwh = round(float(data["value"]) / 100.0 / 1.2, 6) 21 | 22 | def __repr__(self): 23 | return f"{self.__class__.__name__}(start: {self._start_time.isoformat()}, end: {self._end_time.isoformat()}, marketprice: {self._price_per_kwh} {CT_PER_KWH})" # noqa: E501 24 | 25 | @property 26 | def start_time(self): 27 | return self._start_time 28 | 29 | @property 30 | def end_time(self): 31 | return self._end_time 32 | 33 | def set_end_time(self, end_time): 34 | self._end_time = end_time 35 | 36 | @property 37 | def price_per_kwh(self): 38 | return self._price_per_kwh 39 | 40 | 41 | class smartENERGY: 42 | URL = "https://apis.smartenergy.at/market/v1/price" 43 | 44 | MARKET_AREAS = ("at",) 45 | 46 | def __init__(self, market_area, session: aiohttp.ClientSession): 47 | self._session = session 48 | self._market_area = market_area 49 | self._duration = 15 # default value, can be overwritten by API response 50 | self._marketdata = [] 51 | 52 | @property 53 | def name(self): 54 | return "smartENERGY API V1" 55 | 56 | @property 57 | def market_area(self): 58 | return self._market_area 59 | 60 | @property 61 | def duration(self): 62 | return self._duration 63 | 64 | @property 65 | def currency(self): 66 | return "EUR" 67 | 68 | @property 69 | def marketdata(self): 70 | return self._marketdata 71 | 72 | async def fetch(self): 73 | data = await self._fetch_data(self.URL) 74 | self._duration = data["interval"] 75 | assert data["unit"].lower() == CT_PER_KWH.lower() 76 | marketdata = self._extract_marketdata(data["data"]) 77 | # override duration and compress data 78 | self._duration = 60 79 | self._marketdata = self._compress_marketdata(marketdata) 80 | 81 | async def _fetch_data(self, url): 82 | async with self._session.get(url) as resp: 83 | resp.raise_for_status() 84 | return await resp.json() 85 | 86 | def _extract_marketdata(self, data): 87 | entries = [] 88 | for entry in data: 89 | entries.append(Marketprice(self._duration, entry)) 90 | return entries 91 | 92 | def _compress_marketdata(self, data): 93 | entries = [] 94 | start = None 95 | for entry in data: 96 | if start == None: 97 | start = entry 98 | continue 99 | is_price_equal = start.price_per_kwh == entry.price_per_kwh 100 | is_continuation = start.end_time == entry.start_time 101 | max_start_time = start.start_time + timedelta(minutes=self._duration) 102 | is_same_hour = entry.start_time < max_start_time 103 | 104 | if is_price_equal & is_continuation & is_same_hour: 105 | start.set_end_time(entry.end_time) 106 | else: 107 | entries.append(start) 108 | start = entry 109 | if start != None: 110 | entries.append(start) 111 | return entries 112 | -------------------------------------------------------------------------------- /custom_components/epex_spot/SourceShell.py: -------------------------------------------------------------------------------- 1 | """SourceShell""" 2 | 3 | from datetime import timedelta 4 | import logging 5 | from typing import Any 6 | 7 | import aiohttp 8 | 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.util import dt 11 | 12 | from .const import ( 13 | CONF_DURATION, 14 | CONF_EARLIEST_START_POST, 15 | CONF_EARLIEST_START_TIME, 16 | CONF_LATEST_END_POST, 17 | CONF_LATEST_END_TIME, 18 | CONF_MARKET_AREA, 19 | CONF_SOURCE, 20 | CONF_SOURCE_AWATTAR, 21 | CONF_SOURCE_ENERGYFORECAST, 22 | CONF_SOURCE_EPEX_SPOT_WEB, 23 | CONF_SOURCE_SMARD_DE, 24 | CONF_SOURCE_SMARTENERGY, 25 | CONF_SOURCE_TIBBER, 26 | CONF_SURCHARGE_ABS, 27 | CONF_SURCHARGE_PERC, 28 | CONF_TAX, 29 | CONF_TOKEN, 30 | DEFAULT_SURCHARGE_ABS, 31 | DEFAULT_SURCHARGE_PERC, 32 | DEFAULT_TAX, 33 | EMPTY_EXTREME_PRICE_INTERVAL_RESP, 34 | ) 35 | from .EPEXSpot import SMARD, Awattar, Energyforecast, EPEXSpotWeb, Tibber, smartENERGY 36 | from .extreme_price_interval import find_extreme_price_interval, get_start_times 37 | 38 | _LOGGER = logging.getLogger(__name__) 39 | 40 | 41 | class SourceShell: 42 | def __init__(self, config_entry: ConfigEntry, session: aiohttp.ClientSession): 43 | self._config_entry = config_entry 44 | self._marketdata_now = None 45 | self._sorted_marketdata_today = [] 46 | self._cheapest_sorted_marketdata_today = None 47 | self._most_expensive_sorted_marketdata_today = None 48 | 49 | # create source object 50 | if config_entry.data[CONF_SOURCE] == CONF_SOURCE_AWATTAR: 51 | self._source = Awattar.Awattar( 52 | market_area=config_entry.data[CONF_MARKET_AREA], session=session 53 | ) 54 | elif config_entry.data[CONF_SOURCE] == CONF_SOURCE_EPEX_SPOT_WEB: 55 | self._source = EPEXSpotWeb.EPEXSpotWeb( 56 | market_area=config_entry.data[CONF_MARKET_AREA], session=session 57 | ) 58 | elif config_entry.data[CONF_SOURCE] == CONF_SOURCE_SMARD_DE: 59 | self._source = SMARD.SMARD( 60 | market_area=config_entry.data[CONF_MARKET_AREA], session=session 61 | ) 62 | elif config_entry.data[CONF_SOURCE] == CONF_SOURCE_SMARTENERGY: 63 | self._source = smartENERGY.smartENERGY( 64 | market_area=config_entry.data[CONF_MARKET_AREA], session=session 65 | ) 66 | elif config_entry.data[CONF_SOURCE] == CONF_SOURCE_TIBBER: 67 | self._source = Tibber.Tibber( 68 | market_area=config_entry.data[CONF_MARKET_AREA], 69 | token=self._config_entry.data[CONF_TOKEN], 70 | session=session, 71 | ) 72 | elif config_entry.data[CONF_SOURCE] == CONF_SOURCE_ENERGYFORECAST: 73 | self._source = Energyforecast.Energyforecast( 74 | market_area=config_entry.data[CONF_MARKET_AREA], 75 | token=self._config_entry.data[CONF_TOKEN], 76 | session=session, 77 | ) 78 | 79 | @property 80 | def unique_id(self): 81 | return self._config_entry.unique_id 82 | 83 | @property 84 | def name(self): 85 | return self._source.name 86 | 87 | @property 88 | def market_area(self): 89 | return self._source.market_area 90 | 91 | @property 92 | def duration(self): 93 | return self._source.duration 94 | 95 | @property 96 | def currency(self): 97 | return self._source.currency 98 | 99 | @property 100 | def marketdata(self): 101 | return self._source.marketdata 102 | 103 | @property 104 | def marketdata_now(self): 105 | return self._marketdata_now 106 | 107 | @property 108 | def sorted_marketdata_today(self): 109 | """Sorted by price.""" 110 | return self._sorted_marketdata_today 111 | 112 | async def fetch(self, *args: Any): 113 | await self._source.fetch() 114 | 115 | def update_time(self): 116 | if (len(self.marketdata)) == 0: 117 | self._marketdata_now = None 118 | self._sorted_marketdata_today = [] 119 | return 120 | 121 | now = dt.now() 122 | 123 | # find current entry in marketdata list 124 | try: 125 | self._marketdata_now = next( 126 | filter( 127 | lambda e: e.start_time <= now and e.end_time > now, self.marketdata 128 | ) 129 | ) 130 | except StopIteration: 131 | _LOGGER.error(f"no data found for {self._source}") 132 | self._marketdata_now = None 133 | self._sorted_marketdata_today = [] 134 | 135 | # get list of entries for today 136 | start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0) 137 | end_of_day = start_of_day + timedelta(days=1) 138 | 139 | sorted_marketdata_today = filter( 140 | lambda e: e.start_time >= start_of_day and e.end_time <= end_of_day, 141 | self.marketdata, 142 | ) 143 | sorted_sorted_marketdata_today = sorted( 144 | sorted_marketdata_today, key=lambda e: e.price_per_kwh 145 | ) 146 | self._sorted_marketdata_today = sorted_sorted_marketdata_today 147 | 148 | def to_net_price(self, price_per_kwh): 149 | net_p = price_per_kwh 150 | 151 | # Retrieve tax and surcharge values from config 152 | surcharge_abs = self._config_entry.options.get( 153 | CONF_SURCHARGE_ABS, DEFAULT_SURCHARGE_ABS 154 | ) 155 | tax = self._config_entry.options.get(CONF_TAX, DEFAULT_TAX) 156 | 157 | surcharge_pct = self._config_entry.options.get( 158 | CONF_SURCHARGE_PERC, DEFAULT_SURCHARGE_PERC 159 | ) 160 | 161 | # Custom calculation for SMARTENERGY 162 | if self.name == "smartENERGY API V1": 163 | net_p *= 1.0 + (tax / 100.0) 164 | net_p += surcharge_abs 165 | net_p *= 1.0 + (surcharge_pct / 100.0) 166 | # Standard calculation for other cases 167 | elif "Tibber API" not in self.name: 168 | net_p = net_p + abs(net_p) * surcharge_pct / 100 169 | net_p += surcharge_abs 170 | net_p *= 1 + (tax / 100.0) 171 | 172 | return round(net_p, 6) 173 | 174 | def find_extreme_price_interval(self, call_data, cmp): 175 | duration: timedelta = call_data[CONF_DURATION] 176 | 177 | start_times = get_start_times( 178 | marketdata=self.marketdata, 179 | earliest_start_time=call_data.get(CONF_EARLIEST_START_TIME), 180 | earliest_start_post=call_data.get(CONF_EARLIEST_START_POST), 181 | latest_end_time=call_data.get(CONF_LATEST_END_TIME), 182 | latest_end_post=call_data.get(CONF_LATEST_END_POST), 183 | latest_market_datetime=self.marketdata[-1].end_time, 184 | duration=duration, 185 | ) 186 | 187 | result = find_extreme_price_interval( 188 | self.marketdata, start_times, duration, cmp 189 | ) 190 | 191 | if result is None: 192 | return EMPTY_EXTREME_PRICE_INTERVAL_RESP 193 | 194 | return { 195 | "start": result["start"], 196 | "end": result["start"] + duration, 197 | "price_per_kwh": round(result["price_per_hour"], 6), 198 | "net_price_per_kwh": self.to_net_price(result["price_per_hour"]), 199 | } 200 | -------------------------------------------------------------------------------- /custom_components/epex_spot/__init__.py: -------------------------------------------------------------------------------- 1 | """Component for EPEX Spot support.""" 2 | 3 | import asyncio 4 | import logging 5 | import random 6 | from typing import Any, Callable 7 | 8 | import voluptuous as vol 9 | 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.const import ATTR_DEVICE_ID, Platform 12 | from homeassistant.core import ( 13 | HomeAssistant, 14 | ServiceCall, 15 | ServiceResponse, 16 | SupportsResponse, 17 | ) 18 | from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError 19 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 20 | import homeassistant.helpers.config_validation as cv 21 | from homeassistant.helpers.device_registry import ( 22 | DeviceEntryType, 23 | DeviceInfo, 24 | async_get as dr_async_get, 25 | ) 26 | from homeassistant.helpers.entity import Entity, EntityDescription 27 | from homeassistant.helpers.event import async_track_time_change 28 | from homeassistant.helpers.update_coordinator import ( 29 | CoordinatorEntity, 30 | DataUpdateCoordinator, 31 | ) 32 | 33 | from .const import ( 34 | ATTR_DATA, 35 | CONF_DURATION, 36 | CONF_EARLIEST_START_POST, 37 | CONF_EARLIEST_START_TIME, 38 | CONF_LATEST_END_POST, 39 | CONF_LATEST_END_TIME, 40 | CONF_SURCHARGE_ABS, 41 | CONFIG_VERSION, 42 | DOMAIN, 43 | ) 44 | from .localization import CURRENCY_MAPPING 45 | from .SourceShell import SourceShell 46 | 47 | _LOGGER = logging.getLogger(__name__) 48 | 49 | PLATFORMS = [Platform.SENSOR] 50 | 51 | GET_EXTREME_PRICE_INTERVAL_SCHEMA = vol.Schema( 52 | { 53 | **cv.ENTITY_SERVICE_FIELDS, # for device_id 54 | vol.Optional(CONF_EARLIEST_START_TIME): cv.time, 55 | vol.Optional(CONF_EARLIEST_START_POST): cv.positive_int, 56 | vol.Optional(CONF_LATEST_END_TIME): cv.time, 57 | vol.Optional(CONF_LATEST_END_POST): cv.positive_int, 58 | vol.Required(CONF_DURATION): cv.positive_time_period, 59 | } 60 | ) 61 | FETCH_DATA_SCHEMA = vol.Schema( 62 | { 63 | **cv.ENTITY_SERVICE_FIELDS, # for device_id 64 | } 65 | ) 66 | 67 | 68 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 69 | """Set up component from a config entry.""" 70 | 71 | source = SourceShell(entry, async_get_clientsession(hass)) 72 | 73 | try: 74 | await source.fetch() 75 | source.update_time() 76 | except Exception as err: # pylint: disable=broad-except 77 | ex = ConfigEntryNotReady() 78 | ex.__cause__ = err 79 | raise ex 80 | 81 | coordinator = EpexSpotDataUpdateCoordinator(hass, source=source) 82 | await coordinator.async_config_entry_first_refresh() 83 | 84 | hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator 85 | 86 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 87 | 88 | entry.async_on_unload(entry.add_update_listener(on_update_options_listener)) 89 | 90 | entry.async_on_unload( 91 | async_track_time_change( 92 | hass, coordinator.on_refresh, hour=None, minute=0, second=0 93 | ) 94 | ) 95 | if source.duration == 30 or source.duration == 15: 96 | entry.async_on_unload( 97 | async_track_time_change( 98 | hass, coordinator.on_refresh, hour=None, minute=30, second=0 99 | ) 100 | ) 101 | if source.duration == 15: 102 | entry.async_on_unload( 103 | async_track_time_change( 104 | hass, coordinator.on_refresh, hour=None, minute=15, second=0 105 | ) 106 | ) 107 | entry.async_on_unload( 108 | async_track_time_change( 109 | hass, coordinator.on_refresh, hour=None, minute=45, second=0 110 | ) 111 | ) 112 | 113 | entry.async_on_unload( 114 | async_track_time_change( 115 | hass, coordinator.fetch_source, hour=None, minute=50, second=0 116 | ) 117 | ) 118 | 119 | # service call handling 120 | async def get_lowest_price_interval(call: ServiceCall) -> ServiceResponse: 121 | """Get the time interval during which the price is at its lowest point.""" 122 | return _find_extreme_price_interval(call, lambda a, b: a < b) 123 | 124 | async def get_highest_price_interval(call: ServiceCall) -> ServiceResponse: 125 | """Get the time interval during which the price is at its highest point.""" 126 | return _find_extreme_price_interval(call, lambda a, b: a > b) 127 | 128 | async def fetch_data(call: ServiceCall) -> None: 129 | entries = hass.data[DOMAIN] 130 | if ATTR_DEVICE_ID in call.data: 131 | device_id = call.data[ATTR_DEVICE_ID][0] 132 | device_registry = dr_async_get(hass) 133 | if not (device_entry := device_registry.async_get(device_id)): 134 | raise HomeAssistantError(f"No device found for device id: {device_id}") 135 | coordinators = [entries[next(iter(device_entry.config_entries))]] 136 | else: 137 | coordinators = entries.values() 138 | 139 | for c in coordinators: 140 | await c.source.fetch() 141 | await c.on_refresh() 142 | 143 | def _find_extreme_price_interval( 144 | call: ServiceCall, cmp: Callable[[float, float], bool] 145 | ) -> ServiceResponse: 146 | entries = hass.data[DOMAIN] 147 | if ATTR_DEVICE_ID in call.data: 148 | device_id = call.data[ATTR_DEVICE_ID][0] 149 | device_registry = dr_async_get(hass) 150 | if not (device_entry := device_registry.async_get(device_id)): 151 | raise HomeAssistantError(f"No device found for device id: {device_id}") 152 | coordinator = entries[next(iter(device_entry.config_entries))] 153 | else: 154 | coordinator = next(iter(entries.values())) 155 | 156 | if coordinator is None: 157 | return None 158 | 159 | return coordinator.source.find_extreme_price_interval( 160 | call_data=call.data, cmp=cmp 161 | ) 162 | 163 | hass.services.async_register( 164 | DOMAIN, 165 | "get_lowest_price_interval", 166 | get_lowest_price_interval, 167 | schema=GET_EXTREME_PRICE_INTERVAL_SCHEMA, 168 | supports_response=SupportsResponse.ONLY, 169 | ) 170 | hass.services.async_register( 171 | DOMAIN, 172 | "get_highest_price_interval", 173 | get_highest_price_interval, 174 | schema=GET_EXTREME_PRICE_INTERVAL_SCHEMA, 175 | supports_response=SupportsResponse.ONLY, 176 | ) 177 | hass.services.async_register( 178 | DOMAIN, "fetch_data", fetch_data, schema=FETCH_DATA_SCHEMA 179 | ) 180 | 181 | return True 182 | 183 | 184 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): 185 | """Unload a config entry.""" 186 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 187 | 188 | if unload_ok: 189 | hass.data[DOMAIN].pop(entry.entry_id) 190 | return unload_ok 191 | 192 | 193 | async def on_update_options_listener(hass, entry): 194 | """Handle options update.""" 195 | # update all sensors immediately 196 | coordinator = hass.data[DOMAIN][entry.entry_id] 197 | await coordinator.async_request_refresh() 198 | 199 | 200 | async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 201 | """Migrate old entry data to the new entry schema.""" 202 | 203 | data = config_entry.data.copy() 204 | 205 | current_version = data.get("version", 1) 206 | 207 | if current_version != CONFIG_VERSION: 208 | _LOGGER.info( 209 | "Migrating entry %s to version %s", config_entry.entry_id, CONFIG_VERSION 210 | ) 211 | new_options = {**config_entry.options} 212 | 213 | if ( 214 | CONF_SURCHARGE_ABS in config_entry.options 215 | and config_entry.options[CONF_SURCHARGE_ABS] is not None 216 | ): 217 | new_options[CONF_SURCHARGE_ABS] = ( 218 | config_entry.options[CONF_SURCHARGE_ABS] * 0.01 219 | ) 220 | 221 | hass.config_entries.async_update_entry( 222 | config_entry, options=new_options, version=CONFIG_VERSION 223 | ) 224 | 225 | _LOGGER.info( 226 | "Migration of entry %s to version %s successful", 227 | config_entry.entry_id, 228 | CONFIG_VERSION, 229 | ) 230 | 231 | return True 232 | 233 | 234 | class EpexSpotDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): 235 | """Class to manage fetching AccuWeather data API.""" 236 | 237 | source: SourceShell 238 | 239 | def __init__( 240 | self, 241 | hass: HomeAssistant, 242 | source: SourceShell, 243 | ) -> None: 244 | """Initialize.""" 245 | self.source = source 246 | self._error_count = 0 247 | 248 | super().__init__(hass, _LOGGER, name=DOMAIN) 249 | 250 | async def _async_update_data(self) -> dict[str, Any]: 251 | """Update data via library.""" 252 | self.source.update_time() 253 | 254 | async def on_refresh(self, *args: Any): 255 | await self.async_refresh() 256 | 257 | async def fetch_source(self, *args: Any): 258 | # spread fetch over 9 minutes to reduce peak load on servers 259 | await asyncio.sleep(random.uniform(0, 9 * 60)) 260 | try: 261 | await self.source.fetch() 262 | self._error_count = 0 263 | except Exception: # pylint: disable=broad-except 264 | self._error_count += 1 265 | if self._error_count >= 3: 266 | raise 267 | 268 | 269 | class EpexSpotEntity(CoordinatorEntity, Entity): 270 | """A entity implementation for EPEX Spot service.""" 271 | 272 | _coordinator: EpexSpotDataUpdateCoordinator 273 | _source: SourceShell 274 | _attr_has_entity_name = True 275 | _unrecorded_attributes = frozenset({ATTR_DATA}) 276 | 277 | def __init__( 278 | self, coordinator: EpexSpotDataUpdateCoordinator, description: EntityDescription 279 | ): 280 | super().__init__(coordinator) 281 | self._coordinator = coordinator 282 | self._source = coordinator.source 283 | self._localized = CURRENCY_MAPPING[coordinator.source.currency] 284 | self._attr_unique_id = f"{self._source.unique_id} {description.key}" 285 | self._attr_device_info = DeviceInfo( 286 | identifiers={(DOMAIN, f"{self._source.name} {self._source.market_area}")}, 287 | name="EPEX Spot Data", 288 | manufacturer=self._source.name, 289 | model=self._source.market_area, 290 | entry_type=DeviceEntryType.SERVICE, 291 | ) 292 | 293 | @property 294 | def available(self) -> bool: 295 | return super().available and self._source._marketdata_now is not None 296 | -------------------------------------------------------------------------------- /custom_components/epex_spot/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for EPEXSpot component. 2 | 3 | Used by UI to setup integration. 4 | """ 5 | 6 | import voluptuous as vol 7 | 8 | from homeassistant import config_entries 9 | from homeassistant.core import callback 10 | 11 | from .const import ( 12 | CONF_MARKET_AREA, 13 | CONF_SOURCE, 14 | CONF_SOURCE_AWATTAR, 15 | CONF_SOURCE_EPEX_SPOT_WEB, 16 | CONF_SOURCE_SMARD_DE, 17 | CONF_SOURCE_SMARTENERGY, 18 | CONF_SOURCE_TIBBER, 19 | CONF_SOURCE_ENERGYFORECAST, 20 | CONF_SURCHARGE_ABS, 21 | CONF_SURCHARGE_PERC, 22 | CONF_TAX, 23 | CONF_TOKEN, 24 | CONFIG_VERSION, 25 | DEFAULT_SURCHARGE_ABS, 26 | DEFAULT_SURCHARGE_PERC, 27 | DEFAULT_TAX, 28 | DOMAIN, 29 | ) 30 | from .EPEXSpot import SMARD, Awattar, EPEXSpotWeb, Tibber, smartENERGY, Energyforecast 31 | 32 | CONF_SOURCE_LIST = ( 33 | CONF_SOURCE_AWATTAR, 34 | CONF_SOURCE_EPEX_SPOT_WEB, 35 | CONF_SOURCE_SMARD_DE, 36 | CONF_SOURCE_SMARTENERGY, 37 | CONF_SOURCE_TIBBER, 38 | CONF_SOURCE_ENERGYFORECAST, 39 | ) 40 | 41 | 42 | class EpexSpotConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # type: ignore 43 | """Component config flow.""" 44 | 45 | VERSION = CONFIG_VERSION 46 | 47 | def __init__(self): 48 | self._source_name = None 49 | 50 | async def async_step_user(self, user_input=None): 51 | """Handle the start of the config flow. 52 | 53 | Called after integration has been selected in the 'add integration 54 | UI'. The user_input is set to None in this case. We will open a config 55 | flow form then. 56 | This function is also called if the form has been submitted. user_input 57 | contains a dict with the user entered values then. 58 | """ 59 | # query top level source 60 | data_schema = vol.Schema( 61 | { 62 | vol.Required(CONF_SOURCE): vol.In( 63 | sorted(CONF_SOURCE_LIST, key=lambda s: s.casefold()) 64 | ) 65 | } 66 | ) 67 | 68 | return self.async_show_form( 69 | step_id="source", data_schema=data_schema, last_step=False 70 | ) 71 | 72 | async def async_step_source(self, user_input=None): 73 | self._source_name = user_input[CONF_SOURCE] 74 | 75 | # Tibber API requires a token 76 | if self._source_name == CONF_SOURCE_TIBBER: 77 | areas = Tibber.Tibber.MARKET_AREAS 78 | data_schema = vol.Schema( 79 | { 80 | vol.Required(CONF_MARKET_AREA): vol.In(sorted(areas)), 81 | vol.Required(CONF_TOKEN): vol.Coerce(str), 82 | } 83 | ) 84 | # Energyforecast API requires a token 85 | elif self._source_name == CONF_SOURCE_ENERGYFORECAST: 86 | areas = Energyforecast.Energyforecast.MARKET_AREAS 87 | data_schema = vol.Schema( 88 | { 89 | vol.Required(CONF_MARKET_AREA): vol.In(sorted(areas)), 90 | vol.Required(CONF_TOKEN): vol.Coerce(str), 91 | } 92 | ) 93 | else: 94 | if self._source_name == CONF_SOURCE_AWATTAR: 95 | areas = Awattar.Awattar.MARKET_AREAS 96 | elif self._source_name == CONF_SOURCE_EPEX_SPOT_WEB: 97 | areas = EPEXSpotWeb.EPEXSpotWeb.MARKET_AREAS 98 | elif self._source_name == CONF_SOURCE_SMARD_DE: 99 | areas = SMARD.SMARD.MARKET_AREAS 100 | elif self._source_name == CONF_SOURCE_SMARTENERGY: 101 | areas = smartENERGY.smartENERGY.MARKET_AREAS 102 | 103 | data_schema = vol.Schema( 104 | {vol.Required(CONF_MARKET_AREA): vol.In(sorted(areas))} 105 | ) 106 | 107 | return self.async_show_form(step_id="market_area", data_schema=data_schema) 108 | 109 | async def async_step_market_area(self, user_input=None): 110 | if user_input is not None: 111 | # create an entry for this configuration 112 | market_area = user_input[CONF_MARKET_AREA] 113 | title = f"{self._source_name} ({market_area})" 114 | 115 | unique_id = f"{DOMAIN} {self._source_name} {market_area}" 116 | await self.async_set_unique_id(unique_id) 117 | self._abort_if_unique_id_configured() 118 | 119 | data = {CONF_SOURCE: self._source_name, CONF_MARKET_AREA: market_area} 120 | if CONF_TOKEN in user_input: 121 | data[CONF_TOKEN] = user_input[CONF_TOKEN] 122 | 123 | return self.async_create_entry( 124 | title=title, 125 | data=data, 126 | ) 127 | return None 128 | 129 | @staticmethod 130 | @callback 131 | def async_get_options_flow( 132 | config_entry: config_entries.ConfigEntry, 133 | ) -> config_entries.OptionsFlow: 134 | """Create the options flow.""" 135 | return EpexSpotOptionsFlow(config_entry) 136 | 137 | 138 | class EpexSpotOptionsFlow(config_entries.OptionsFlow): 139 | """Handle the start of the option flow.""" 140 | 141 | def __init__(self, config_entry: config_entries.ConfigEntry) -> None: 142 | """Initialize options flow.""" 143 | self.config_entry = config_entry 144 | 145 | async def async_step_init(self, user_input=None): 146 | """Manage the options.""" 147 | if user_input is not None: 148 | return self.async_create_entry(title="", data=user_input) 149 | 150 | return self.async_show_form( 151 | step_id="init", 152 | data_schema=vol.Schema( 153 | { 154 | vol.Optional( 155 | CONF_SURCHARGE_PERC, 156 | default=self.config_entry.options.get( 157 | CONF_SURCHARGE_PERC, DEFAULT_SURCHARGE_PERC 158 | ), 159 | ): vol.Coerce(float), 160 | vol.Optional( 161 | CONF_SURCHARGE_ABS, 162 | default=self.config_entry.options.get( 163 | CONF_SURCHARGE_ABS, DEFAULT_SURCHARGE_ABS 164 | ), 165 | ): vol.Coerce(float), 166 | vol.Optional( 167 | CONF_TAX, 168 | default=self.config_entry.options.get(CONF_TAX, DEFAULT_TAX), 169 | ): vol.Coerce(float), 170 | } 171 | ), 172 | ) 173 | -------------------------------------------------------------------------------- /custom_components/epex_spot/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the component.""" 2 | 3 | # Component domain, used to store component data in hass data. 4 | DOMAIN = "epex_spot" 5 | 6 | ATTR_DATA = "data" 7 | ATTR_START_TIME = "start_time" 8 | ATTR_END_TIME = "end_time" 9 | ATTR_BUY_VOLUME_MWH = "buy_volume_mwh" 10 | ATTR_SELL_VOLUME_MWH = "sell_volume_mwh" 11 | ATTR_VOLUME_MWH = "volume_mwh" 12 | ATTR_RANK = "rank" 13 | ATTR_QUANTILE = "quantile" 14 | ATTR_PRICE_PER_KWH = "price_per_kwh" 15 | 16 | CONFIG_VERSION = 2 17 | CONF_SOURCE = "source" 18 | CONF_MARKET_AREA = "market_area" 19 | CONF_TOKEN = "token" 20 | 21 | # possible values for CONF_SOURCE 22 | CONF_SOURCE_AWATTAR = "Awattar" 23 | CONF_SOURCE_EPEX_SPOT_WEB = "EPEX Spot Web Scraper" 24 | CONF_SOURCE_SMARD_DE = "SMARD.de" 25 | CONF_SOURCE_SMARTENERGY = "smartENERGY.at" 26 | CONF_SOURCE_TIBBER = "Tibber" 27 | CONF_SOURCE_ENERGYFORECAST = "Energyforecast.de" 28 | 29 | # configuration options for net price calculation 30 | CONF_SURCHARGE_PERC = "percentage_surcharge" 31 | CONF_SURCHARGE_ABS = "absolute_surcharge" 32 | CONF_TAX = "tax" 33 | 34 | # service call 35 | CONF_EARLIEST_START_TIME = "earliest_start" 36 | CONF_EARLIEST_START_POST = "earliest_start_post" 37 | CONF_LATEST_END_TIME = "latest_end" 38 | CONF_LATEST_END_POST = "latest_end_post" 39 | CONF_DURATION = "duration" 40 | 41 | DEFAULT_SURCHARGE_PERC = 3.0 42 | DEFAULT_SURCHARGE_ABS = 0.1193 43 | DEFAULT_TAX = 19.0 44 | 45 | EMPTY_EXTREME_PRICE_INTERVAL_RESP = { 46 | "start": None, 47 | "end": None, 48 | "price_per_kwh": None, 49 | "net_price_per_kwh": None, 50 | } 51 | 52 | UOM_EUR_PER_KWH = "€/kWh" 53 | UOM_MWH = "MWh" 54 | 55 | EUR_PER_MWH = "EUR/MWh" 56 | CT_PER_KWH = "ct/kWh" 57 | -------------------------------------------------------------------------------- /custom_components/epex_spot/extreme_price_interval.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime, time, timedelta 3 | 4 | import homeassistant.util.dt as dt_util 5 | 6 | _LOGGER = logging.getLogger(__name__) 7 | 8 | SECONDS_PER_HOUR = 60 * 60 9 | 10 | 11 | def _find_market_price(marketdata, dt: datetime): 12 | for mp in marketdata: 13 | if dt >= mp.start_time and dt < mp.end_time: 14 | return mp 15 | 16 | return None 17 | 18 | 19 | def _calc_interval_price(marketdata, start_time: datetime, duration: timedelta): 20 | """Calculate price for given start time and duration.""" 21 | total_price = 0 22 | stop_time = start_time + duration 23 | 24 | while start_time < stop_time: 25 | mp = _find_market_price(marketdata, start_time) 26 | 27 | if mp.end_time > stop_time: 28 | active_duration_in_this_segment = stop_time - start_time 29 | else: 30 | active_duration_in_this_segment = mp.end_time - start_time 31 | 32 | total_price += ( 33 | mp.price_per_kwh 34 | * active_duration_in_this_segment.total_seconds() 35 | / SECONDS_PER_HOUR 36 | ) 37 | 38 | start_time = mp.end_time 39 | 40 | return round(total_price, 6) 41 | 42 | 43 | def _calc_start_times( 44 | marketdata, earliest_start: datetime, latest_end: datetime, duration: timedelta 45 | ): 46 | """Calculate list of meaningful start times.""" 47 | start_times = set() 48 | start_time = earliest_start 49 | 50 | # add earliest possible start (if duration matches) 51 | if earliest_start + duration <= latest_end: 52 | start_times.add(earliest_start) 53 | 54 | for md in marketdata: 55 | # add start times for market data segment start 56 | if md.start_time >= earliest_start and md.start_time + duration <= latest_end: 57 | start_times.add(earliest_start) 58 | 59 | # add start times for market data segment end 60 | start_time = md.end_time - duration 61 | if md.end_time <= latest_end and earliest_start <= start_time: 62 | start_times.add(start_time) 63 | 64 | # add latest possible start (if duration matches) 65 | start_time = latest_end - duration 66 | if earliest_start <= start_time: 67 | start_times.add(start_time) 68 | 69 | return sorted(start_times) 70 | 71 | 72 | def find_extreme_price_interval(marketdata, start_times, duration: timedelta, cmp): 73 | """Find the lowest/highest price for all given start times. 74 | 75 | The argument cmp is a lambda which is used to differentiate between 76 | lowest and highest price. 77 | """ 78 | interval_price: float | None = None 79 | interval_start_time: timedelta | None = None 80 | 81 | for start_time in start_times: 82 | ip = _calc_interval_price(marketdata, start_time, duration) 83 | 84 | if ip is None: 85 | return None 86 | 87 | if interval_price is None or cmp(ip, interval_price): 88 | interval_price = ip 89 | interval_start_time = start_time 90 | 91 | if interval_start_time is None: 92 | return None 93 | 94 | interval_price = round(interval_price, 6) 95 | 96 | return { 97 | "start": dt_util.as_local(interval_start_time), 98 | "end": dt_util.as_local(interval_start_time + duration), 99 | "interval_price": interval_price, 100 | "price_per_hour": round( 101 | interval_price * SECONDS_PER_HOUR / duration.total_seconds(), 6 102 | ), 103 | } 104 | 105 | 106 | def get_start_times( 107 | marketdata, 108 | earliest_start_time: time, 109 | earliest_start_post: int, 110 | latest_end_time: time, 111 | latest_end_post: int, 112 | latest_market_datetime: datetime, 113 | duration: timedelta, 114 | ): 115 | # first calculate start and end datetime 116 | now = dt_util.now() 117 | 118 | earliest_start: datetime = ( 119 | now 120 | if earliest_start_time is None 121 | else now.replace( 122 | hour=earliest_start_time.hour, 123 | minute=earliest_start_time.minute, 124 | second=earliest_start_time.second, 125 | microsecond=earliest_start_time.microsecond, 126 | ) 127 | ) 128 | if earliest_start_post is not None: 129 | earliest_start += timedelta(days=earliest_start_post) 130 | 131 | if latest_end_time is None: 132 | latest_end = latest_market_datetime 133 | else: 134 | latest_end: datetime = now.replace( 135 | hour=latest_end_time.hour, 136 | minute=latest_end_time.minute, 137 | second=latest_end_time.second, 138 | microsecond=latest_end_time.microsecond, 139 | ) 140 | 141 | if latest_end_post is not None: 142 | latest_end += timedelta(days=latest_end_post) 143 | elif latest_end <= earliest_start: 144 | latest_end += timedelta(days=1) 145 | 146 | if latest_end > latest_market_datetime: 147 | if latest_market_datetime <= earliest_start: 148 | # no data available, return immediately to avoid exception 149 | _LOGGER.debug( 150 | f"no data available yet: earliest_start={earliest_start}, latest_end={latest_end}" # noqa: E501 151 | ) 152 | return [] 153 | 154 | latest_end = latest_market_datetime 155 | 156 | if latest_end <= earliest_start: 157 | raise ValueError( 158 | f"latest_end {latest_end} is earlier or equal to earliest_start {earliest_start}" # noqa: E501 159 | ) 160 | 161 | _LOGGER.debug( 162 | f"extreme price service call: earliest_start={earliest_start}, latest_end={latest_end}" # noqa: E501 163 | ) 164 | 165 | return _calc_start_times( 166 | marketdata, 167 | earliest_start=dt_util.as_utc(earliest_start), 168 | latest_end=dt_util.as_utc(latest_end), 169 | duration=duration, 170 | ) 171 | -------------------------------------------------------------------------------- /custom_components/epex_spot/localization.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from .const import ATTR_PRICE_PER_KWH 4 | 5 | 6 | @dataclass(frozen=True, slots=True) 7 | class Localize: 8 | uom_per_kwh: str 9 | icon: str 10 | attr_name_per_kwh: str 11 | 12 | 13 | CURRENCY_MAPPING = { 14 | "EUR": Localize( 15 | uom_per_kwh="€/kWh", 16 | icon="mdi:currency-eur", 17 | attr_name_per_kwh=ATTR_PRICE_PER_KWH, 18 | ), 19 | "GBP": Localize( 20 | uom_per_kwh="£/kWh", 21 | icon="mdi:currency-gbp", 22 | attr_name_per_kwh=ATTR_PRICE_PER_KWH, 23 | ), 24 | } 25 | -------------------------------------------------------------------------------- /custom_components/epex_spot/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "epex_spot", 3 | "name": "EPEX Spot", 4 | "codeowners": ["@mampfes"], 5 | "config_flow": true, 6 | "dependencies": [], 7 | "documentation": "https://github.com/mampfes/ha_epex_spot", 8 | "iot_class": "cloud_polling", 9 | "issue_tracker": "https://github.com/mampfes/ha_epex_spot/issues", 10 | "requirements": ["beautifulsoup4"], 11 | "version": "3.0.0" 12 | } 13 | -------------------------------------------------------------------------------- /custom_components/epex_spot/sensor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from statistics import median 3 | 4 | import homeassistant.util.dt as dt_util 5 | from homeassistant.components.sensor import ( 6 | SensorEntity, 7 | SensorEntityDescription, 8 | SensorStateClass, 9 | ) 10 | from homeassistant.helpers.typing import StateType 11 | 12 | from .const import ( 13 | ATTR_BUY_VOLUME_MWH, 14 | ATTR_DATA, 15 | ATTR_END_TIME, 16 | ATTR_QUANTILE, 17 | ATTR_RANK, 18 | ATTR_SELL_VOLUME_MWH, 19 | ATTR_START_TIME, 20 | ATTR_VOLUME_MWH, 21 | CONF_SOURCE, 22 | CONF_SOURCE_EPEX_SPOT_WEB, 23 | DOMAIN, 24 | ) 25 | from . import EpexSpotEntity, EpexSpotDataUpdateCoordinator as DataUpdateCoordinator 26 | 27 | _LOGGER = logging.getLogger(__name__) 28 | 29 | 30 | async def async_setup_entry(hass, config_entry, async_add_entities): 31 | """Set up platform for a new integration. 32 | 33 | Called by the HA framework after async_setup_platforms has been called 34 | during initialization of a new integration. 35 | """ 36 | coordinator: DataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] 37 | entities = [ 38 | EpexSpotPriceSensorEntity(coordinator), 39 | EpexSpotNetPriceSensorEntity(coordinator), 40 | EpexSpotRankSensorEntity(coordinator), 41 | EpexSpotQuantileSensorEntity(coordinator), 42 | EpexSpotLowestPriceSensorEntity(coordinator), 43 | EpexSpotHighestPriceSensorEntity(coordinator), 44 | EpexSpotAveragePriceSensorEntity(coordinator), 45 | EpexSpotMedianPriceSensorEntity(coordinator), 46 | ] 47 | 48 | if config_entry.data[CONF_SOURCE] == CONF_SOURCE_EPEX_SPOT_WEB: 49 | entities.extend( 50 | [ 51 | EpexSpotBuyVolumeSensorEntity(coordinator), 52 | EpexSpotSellVolumeSensorEntity(coordinator), 53 | EpexSpotVolumeSensorEntity(coordinator), 54 | ] 55 | ) 56 | 57 | async_add_entities(entities) 58 | 59 | 60 | class EpexSpotPriceSensorEntity(EpexSpotEntity, SensorEntity): 61 | """Home Assistant sensor containing all EPEX spot data.""" 62 | 63 | entity_description = SensorEntityDescription( 64 | key="Price", 65 | name="Price", 66 | state_class=SensorStateClass.MEASUREMENT, 67 | ) 68 | 69 | def __init__(self, coordinator: DataUpdateCoordinator): 70 | super().__init__(coordinator, self.entity_description) 71 | self._attr_icon = self._localized.icon 72 | self._attr_native_unit_of_measurement = self._localized.uom_per_kwh 73 | 74 | @property 75 | def native_value(self) -> StateType: 76 | return self._source.marketdata_now.price_per_kwh 77 | 78 | @property 79 | def extra_state_attributes(self): 80 | data = [ 81 | { 82 | ATTR_START_TIME: dt_util.as_local(e.start_time).isoformat(), 83 | ATTR_END_TIME: dt_util.as_local(e.end_time).isoformat(), 84 | self._localized.attr_name_per_kwh: e.price_per_kwh, 85 | } 86 | for e in self._source.marketdata 87 | ] 88 | 89 | return { 90 | ATTR_DATA: data, 91 | self._localized.attr_name_per_kwh: self.native_value, 92 | } 93 | 94 | 95 | class EpexSpotNetPriceSensorEntity(EpexSpotEntity, SensorEntity): 96 | """Home Assistant sensor containing all EPEX spot data.""" 97 | 98 | entity_description = SensorEntityDescription( 99 | key="Net Price", 100 | name="Net Price", 101 | suggested_display_precision=6, 102 | state_class=SensorStateClass.MEASUREMENT, 103 | ) 104 | 105 | def __init__(self, coordinator: DataUpdateCoordinator): 106 | super().__init__(coordinator, self.entity_description) 107 | self._attr_icon = self._localized.icon 108 | self._attr_native_unit_of_measurement = self._localized.uom_per_kwh 109 | 110 | @property 111 | def native_value(self) -> StateType: 112 | return self._source.to_net_price(self._source.marketdata_now.price_per_kwh) 113 | 114 | @property 115 | def extra_state_attributes(self): 116 | data = [ 117 | { 118 | ATTR_START_TIME: dt_util.as_local(e.start_time).isoformat(), 119 | ATTR_END_TIME: dt_util.as_local(e.end_time).isoformat(), 120 | self._localized.attr_name_per_kwh: self._source.to_net_price( 121 | e.price_per_kwh 122 | ), 123 | } 124 | for e in self._source.marketdata 125 | ] 126 | 127 | return {ATTR_DATA: data} 128 | 129 | 130 | class EpexSpotBuyVolumeSensorEntity(EpexSpotEntity, SensorEntity): 131 | """Home Assistant sensor containing all EPEX spot data.""" 132 | 133 | entity_description = SensorEntityDescription( 134 | key="Buy Volume", 135 | name="Buy Volume", 136 | icon="mdi:lightning-bolt", 137 | native_unit_of_measurement="MWh", 138 | state_class=SensorStateClass.MEASUREMENT, 139 | ) 140 | 141 | def __init__(self, coordinator: DataUpdateCoordinator): 142 | super().__init__(coordinator, self.entity_description) 143 | 144 | @property 145 | def native_value(self) -> StateType: 146 | return self._source.marketdata_now.buy_volume_mwh 147 | 148 | @property 149 | def extra_state_attributes(self): 150 | data = [ 151 | { 152 | ATTR_START_TIME: dt_util.as_local(e.start_time).isoformat(), 153 | ATTR_END_TIME: dt_util.as_local(e.end_time).isoformat(), 154 | ATTR_BUY_VOLUME_MWH: e.buy_volume_mwh, 155 | } 156 | for e in self._source.marketdata 157 | ] 158 | 159 | return {ATTR_DATA: data} 160 | 161 | 162 | class EpexSpotSellVolumeSensorEntity(EpexSpotEntity, SensorEntity): 163 | """Home Assistant sensor containing all EPEX spot data.""" 164 | 165 | entity_description = SensorEntityDescription( 166 | key="Sell Volume", 167 | name="Sell Volume", 168 | icon="mdi:lightning-bolt", 169 | native_unit_of_measurement="MWh", 170 | state_class=SensorStateClass.MEASUREMENT, 171 | ) 172 | 173 | def __init__(self, coordinator: DataUpdateCoordinator): 174 | super().__init__(coordinator, self.entity_description) 175 | 176 | @property 177 | def native_value(self) -> StateType: 178 | return self._source.marketdata_now.sell_volume_mwh 179 | 180 | @property 181 | def extra_state_attributes(self): 182 | data = [ 183 | { 184 | ATTR_START_TIME: dt_util.as_local(e.start_time).isoformat(), 185 | ATTR_END_TIME: dt_util.as_local(e.end_time).isoformat(), 186 | ATTR_SELL_VOLUME_MWH: e.sell_volume_mwh, 187 | } 188 | for e in self._source.marketdata 189 | ] 190 | 191 | return {ATTR_DATA: data} 192 | 193 | 194 | class EpexSpotVolumeSensorEntity(EpexSpotEntity, SensorEntity): 195 | """Home Assistant sensor containing all EPEX spot data.""" 196 | 197 | entity_description = SensorEntityDescription( 198 | key="Volume", 199 | name="Volume", 200 | icon="mdi:lightning-bolt", 201 | native_unit_of_measurement="MWh", 202 | state_class=SensorStateClass.MEASUREMENT, 203 | ) 204 | 205 | def __init__(self, coordinator: DataUpdateCoordinator): 206 | super().__init__(coordinator, self.entity_description) 207 | 208 | @property 209 | def native_value(self) -> StateType: 210 | return self._source.marketdata_now.volume_mwh 211 | 212 | @property 213 | def extra_state_attributes(self): 214 | data = [ 215 | { 216 | ATTR_START_TIME: dt_util.as_local(e.start_time).isoformat(), 217 | ATTR_END_TIME: dt_util.as_local(e.end_time).isoformat(), 218 | ATTR_VOLUME_MWH: e.volume_mwh, 219 | } 220 | for e in self._source.marketdata 221 | ] 222 | 223 | return {ATTR_DATA: data} 224 | 225 | 226 | class EpexSpotRankSensorEntity(EpexSpotEntity, SensorEntity): 227 | """Home Assistant sensor containing all EPEX spot data.""" 228 | 229 | entity_description = SensorEntityDescription( 230 | key="Rank", 231 | name="Rank", 232 | native_unit_of_measurement="", 233 | suggested_display_precision=0, 234 | state_class=SensorStateClass.MEASUREMENT, 235 | ) 236 | 237 | def __init__(self, coordinator: DataUpdateCoordinator): 238 | super().__init__(coordinator, self.entity_description) 239 | 240 | @property 241 | def native_value(self) -> StateType: 242 | return [e.price_per_kwh for e in self._source.sorted_marketdata_today].index( 243 | self._source.marketdata_now.price_per_kwh 244 | ) 245 | 246 | @property 247 | def extra_state_attributes(self): 248 | sorted_prices = [e.price_per_kwh for e in self._source.sorted_marketdata_today] 249 | data = [ 250 | { 251 | ATTR_START_TIME: dt_util.as_local(e.start_time).isoformat(), 252 | ATTR_END_TIME: dt_util.as_local(e.end_time).isoformat(), 253 | ATTR_RANK: sorted_prices.index(e.price_per_kwh), 254 | } 255 | for e in self._source.sorted_marketdata_today 256 | ] 257 | 258 | return {ATTR_DATA: data} 259 | 260 | 261 | class EpexSpotQuantileSensorEntity(EpexSpotEntity, SensorEntity): 262 | """Home Assistant sensor containing all EPEX spot data.""" 263 | 264 | entity_description = SensorEntityDescription( 265 | key="Quantile", 266 | name="Quantile", 267 | native_unit_of_measurement="", 268 | suggested_display_precision=2, 269 | state_class=SensorStateClass.MEASUREMENT, 270 | ) 271 | 272 | def __init__(self, coordinator: DataUpdateCoordinator): 273 | super().__init__(coordinator, self.entity_description) 274 | 275 | @property 276 | def native_value(self) -> StateType: 277 | current_price = self._source.marketdata_now.price_per_kwh 278 | min_price = self._source.sorted_marketdata_today[0].price_per_kwh 279 | max_price = self._source.sorted_marketdata_today[-1].price_per_kwh 280 | return (current_price - min_price) / (max_price - min_price) 281 | 282 | @property 283 | def extra_state_attributes(self): 284 | min_price = self._source.sorted_marketdata_today[0].price_per_kwh 285 | max_price = self._source.sorted_marketdata_today[-1].price_per_kwh 286 | data = [ 287 | { 288 | ATTR_START_TIME: dt_util.as_local(e.start_time).isoformat(), 289 | ATTR_END_TIME: dt_util.as_local(e.end_time).isoformat(), 290 | ATTR_QUANTILE: (e.price_per_kwh - min_price) / (max_price - min_price), 291 | } 292 | for e in self._source.sorted_marketdata_today 293 | ] 294 | 295 | return {ATTR_DATA: data} 296 | 297 | 298 | class EpexSpotLowestPriceSensorEntity(EpexSpotEntity, SensorEntity): 299 | """Home Assistant sensor containing all EPEX spot data.""" 300 | 301 | entity_description = SensorEntityDescription( 302 | key="Lowest Price", 303 | name="Lowest Price", 304 | suggested_display_precision=6, 305 | state_class=SensorStateClass.MEASUREMENT, 306 | ) 307 | 308 | def __init__(self, coordinator: DataUpdateCoordinator): 309 | super().__init__(coordinator, self.entity_description) 310 | self._attr_icon = self._localized.icon 311 | self._attr_native_unit_of_measurement = self._localized.uom_per_kwh 312 | 313 | @property 314 | def native_value(self) -> StateType: 315 | min = self._source.sorted_marketdata_today[0] 316 | return min.price_per_kwh 317 | 318 | @property 319 | def extra_state_attributes(self): 320 | min = self._source.sorted_marketdata_today[0] 321 | return { 322 | ATTR_START_TIME: dt_util.as_local(min.start_time).isoformat(), 323 | ATTR_END_TIME: dt_util.as_local(min.end_time).isoformat(), 324 | self._localized.attr_name_per_kwh: self.native_value, 325 | } 326 | 327 | 328 | class EpexSpotHighestPriceSensorEntity(EpexSpotEntity, SensorEntity): 329 | """Home Assistant sensor containing all EPEX spot data.""" 330 | 331 | entity_description = SensorEntityDescription( 332 | key="Highest Price", 333 | name="Highest Price", 334 | suggested_display_precision=6, 335 | state_class=SensorStateClass.MEASUREMENT, 336 | ) 337 | 338 | def __init__(self, coordinator: DataUpdateCoordinator): 339 | super().__init__(coordinator, self.entity_description) 340 | self._attr_icon = self._localized.icon 341 | self._attr_native_unit_of_measurement = self._localized.uom_per_kwh 342 | 343 | @property 344 | def native_value(self) -> StateType: 345 | max = self._source.sorted_marketdata_today[-1] 346 | return max.price_per_kwh 347 | 348 | @property 349 | def extra_state_attributes(self): 350 | max = self._source.sorted_marketdata_today[-1] 351 | return { 352 | ATTR_START_TIME: dt_util.as_local(max.start_time).isoformat(), 353 | ATTR_END_TIME: dt_util.as_local(max.end_time).isoformat(), 354 | self._localized.attr_name_per_kwh: self.native_value, 355 | } 356 | 357 | 358 | class EpexSpotAveragePriceSensorEntity(EpexSpotEntity, SensorEntity): 359 | """Home Assistant sensor containing all EPEX spot data.""" 360 | 361 | entity_description = SensorEntityDescription( 362 | key="Average Price", 363 | name="Average Price", 364 | suggested_display_precision=6, 365 | state_class=SensorStateClass.MEASUREMENT, 366 | ) 367 | 368 | def __init__(self, coordinator: DataUpdateCoordinator): 369 | super().__init__(coordinator, self.entity_description) 370 | self._attr_icon = self._localized.icon 371 | self._attr_native_unit_of_measurement = self._localized.uom_per_kwh 372 | 373 | @property 374 | def native_value(self) -> StateType: 375 | s = sum(e.price_per_kwh for e in self._source.sorted_marketdata_today) 376 | return s / len(self._source.sorted_marketdata_today) 377 | 378 | @property 379 | def extra_state_attributes(self): 380 | return { 381 | self._localized.attr_name_per_kwh: self.native_value, 382 | } 383 | 384 | 385 | class EpexSpotMedianPriceSensorEntity(EpexSpotEntity, SensorEntity): 386 | """Home Assistant sensor containing all EPEX spot data.""" 387 | 388 | entity_description = SensorEntityDescription( 389 | key="Median Price", 390 | name="Median Price", 391 | suggested_display_precision=6, 392 | state_class=SensorStateClass.MEASUREMENT, 393 | ) 394 | 395 | def __init__(self, coordinator: DataUpdateCoordinator): 396 | super().__init__(coordinator, self.entity_description) 397 | self._attr_icon = self._localized.icon 398 | self._attr_native_unit_of_measurement = self._localized.uom_per_kwh 399 | 400 | @property 401 | def native_value(self) -> StateType: 402 | return median([e.price_per_kwh for e in self._source.sorted_marketdata_today]) 403 | 404 | @property 405 | def extra_state_attributes(self): 406 | return { 407 | self._localized.attr_name_per_kwh: self.native_value, 408 | } 409 | -------------------------------------------------------------------------------- /custom_components/epex_spot/services.yaml: -------------------------------------------------------------------------------- 1 | get_lowest_price_interval: 2 | fields: 3 | device_id: 4 | required: false 5 | selector: 6 | device: 7 | integration: epex_spot 8 | earliest_start: 9 | required: false 10 | selector: 11 | time: 12 | earliest_start_post: 13 | required: false 14 | selector: 15 | number: 16 | min: 0 17 | max: 2 18 | step: 1 19 | unit_of_measurement: days 20 | mode: box 21 | latest_end: 22 | required: false 23 | selector: 24 | time: 25 | latest_end_post: 26 | required: false 27 | selector: 28 | number: 29 | min: 0 30 | max: 2 31 | step: 1 32 | unit_of_measurement: days 33 | mode: box 34 | duration: 35 | required: true 36 | example: 01:00:00 37 | selector: 38 | duration: 39 | get_highest_price_interval: 40 | fields: 41 | device_id: 42 | required: false 43 | selector: 44 | device: 45 | integration: epex_spot 46 | earliest_start: 47 | required: false 48 | selector: 49 | time: 50 | earliest_start_post: 51 | required: false 52 | selector: 53 | number: 54 | min: 0 55 | max: 2 56 | step: 1 57 | unit_of_measurement: days 58 | mode: box 59 | latest_end: 60 | required: false 61 | selector: 62 | time: 63 | latest_end_post: 64 | required: false 65 | selector: 66 | number: 67 | min: 0 68 | max: 2 69 | step: 1 70 | unit_of_measurement: days 71 | mode: box 72 | duration: 73 | required: true 74 | example: 01:00:00 75 | selector: 76 | duration: 77 | fetch_data: 78 | fields: 79 | device_id: 80 | required: false 81 | selector: 82 | device: 83 | integration: epex_spot 84 | -------------------------------------------------------------------------------- /custom_components/epex_spot/test_awattar.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import aiohttp 4 | 5 | import asyncio 6 | 7 | from .EPEXSpot import Awattar 8 | from .const import UOM_EUR_PER_KWH 9 | 10 | 11 | async def main(): 12 | async with aiohttp.ClientSession() as session: 13 | service = Awattar.Awattar(market_area="de", session=session) 14 | print(service.MARKET_AREAS) 15 | 16 | await service.fetch() 17 | print(f"count = {len(service.marketdata)}") 18 | for e in service.marketdata: 19 | print(f"{e.start_time}: {e.price_per_kwh} {UOM_EUR_PER_KWH}") 20 | 21 | 22 | asyncio.run(main()) 23 | -------------------------------------------------------------------------------- /custom_components/epex_spot/test_energyforecast.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import aiohttp 4 | import asyncio 5 | 6 | from .EPEXSpot import Energyforecast 7 | from .const import UOM_EUR_PER_KWH 8 | 9 | DEMO_TOKEN = "demo_token" # The "demo_token" token only provides up to 24 hours of forecast data into the future. 10 | 11 | async def main(): 12 | async with aiohttp.ClientSession() as session: 13 | service = Energyforecast.Energyforecast(market_area="DE-LU", token=DEMO_TOKEN, session=session) 14 | 15 | await service.fetch() 16 | print(f"count = {len(service.marketdata)}") 17 | for e in service.marketdata: 18 | print(f"{e.start_time}: {e.price_per_kwh} {UOM_EUR_PER_KWH}") 19 | 20 | 21 | asyncio.run(main()) 22 | -------------------------------------------------------------------------------- /custom_components/epex_spot/test_epex_spot_web.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import asyncio 4 | 5 | import aiohttp 6 | 7 | from .const import UOM_EUR_PER_KWH 8 | from .EPEXSpot import EPEXSpotWeb 9 | 10 | 11 | async def main(): 12 | async with aiohttp.ClientSession() as session: 13 | service = EPEXSpotWeb.EPEXSpotWeb(market_area="DE-LU", session=session) 14 | print(service.MARKET_AREAS) 15 | 16 | await service.fetch() 17 | print(f"count = {len(service.marketdata)}") 18 | for e in service.marketdata: 19 | print( 20 | f"{e.start_time}-{e.end_time}: {e.price_per_kwh} {UOM_EUR_PER_KWH}" # noqa 21 | ) 22 | 23 | 24 | asyncio.run(main()) 25 | -------------------------------------------------------------------------------- /custom_components/epex_spot/test_smard.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import asyncio 4 | 5 | import aiohttp 6 | 7 | from .const import UOM_EUR_PER_KWH 8 | from .EPEXSpot import SMARD 9 | 10 | 11 | async def main(): 12 | async with aiohttp.ClientSession() as session: 13 | service = SMARD.SMARD(market_area="DE-LU", session=session) 14 | # print(service.MARKET_AREAS) 15 | 16 | await service.fetch() 17 | print(f"count = {len(service.marketdata)}") 18 | for e in service.marketdata: 19 | print(f"{e.start_time}: {e.price_per_kwh} {UOM_EUR_PER_KWH}") 20 | 21 | 22 | asyncio.run(main()) 23 | -------------------------------------------------------------------------------- /custom_components/epex_spot/test_smartenergy.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import asyncio 4 | 5 | import aiohttp 6 | 7 | from .const import UOM_EUR_PER_KWH 8 | from .EPEXSpot import smartENERGY 9 | 10 | 11 | async def main(): 12 | async with aiohttp.ClientSession() as session: 13 | service = smartENERGY.smartENERGY(market_area="at", session=session) 14 | 15 | await service.fetch() 16 | print(f"count = {len(service.marketdata)}") 17 | for e in service.marketdata: 18 | print(f"{e.start_time}: {e.price_per_kwh} {UOM_EUR_PER_KWH}") 19 | 20 | 21 | asyncio.run(main()) 22 | -------------------------------------------------------------------------------- /custom_components/epex_spot/test_tibber.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import aiohttp 4 | import asyncio 5 | 6 | from .EPEXSpot import Tibber 7 | from .const import UOM_EUR_PER_KWH 8 | 9 | DEMO_TOKEN = "5K4MVS-OjfWhK_4yrjOlFe1F6kJXPVf7eQYggo8ebAE" 10 | 11 | 12 | async def main(): 13 | async with aiohttp.ClientSession() as session: 14 | service = Tibber.Tibber(market_area="de", token=DEMO_TOKEN, session=session) 15 | print(service.MARKET_AREAS) 16 | 17 | await service.fetch() 18 | print(f"count = {len(service.marketdata)}") 19 | for e in service.marketdata: 20 | print(f"{e.start_time}: {e.price_per_kwh} {UOM_EUR_PER_KWH}") 21 | 22 | 23 | asyncio.run(main()) 24 | -------------------------------------------------------------------------------- /custom_components/epex_spot/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "This market area is already configured.", 5 | "fetch_failed": "Failed to fetch data." 6 | }, 7 | "step": { 8 | "source": { 9 | "data": { 10 | "source": "Select data source" 11 | } 12 | }, 13 | "market_area": { 14 | "data": { 15 | "market_area": "Select market area", 16 | "token": "API Token" 17 | } 18 | }, 19 | "options": { 20 | "title": "Net Price Options", 21 | "description": "Configure surcharges and tax to calculate net price per kWh.", 22 | "data": { 23 | "percentage_surcharge": "Percentage Surcharge (%)", 24 | "absolute_surcharge": "Absolute Surcharge (€/£ per kWh)", 25 | "tax": "Tax (%)" 26 | }, 27 | "data_description": { 28 | "tax": "Like Value Added Tax (VAT)" 29 | } 30 | } 31 | } 32 | }, 33 | "options": { 34 | "step": { 35 | "init": { 36 | "title": "Net Price Options", 37 | "description": "Configure surcharges and tax to calculate net price per kWh.", 38 | "data": { 39 | "percentage_surcharge": "Percentage Surcharge (%)", 40 | "absolute_surcharge": "Absolute Surcharge (€/£ per kWh)", 41 | "tax": "Tax (%)" 42 | }, 43 | "data_description": { 44 | "tax": "Like Value Added Tax (VAT)" 45 | } 46 | } 47 | } 48 | }, 49 | "services": { 50 | "get_lowest_price_interval": { 51 | "description": "Get the time interval during which the price is at its lowest point.", 52 | "fields": { 53 | "device_id": { 54 | "description": "An EPEX Spot service instance ID. In case you have multiple EPEX Spot instances.", 55 | "name": "EPEX Spot Service" 56 | }, 57 | "earliest_start": { 58 | "description": "Earliest time to start the appliance. If omitted, the current time is used. Refers to today if `Earliest Start Offset` is not set or 0, or tomorrow if offset is 1.", 59 | "name": "Earliest Start Time", 60 | "example": "14:00:00" 61 | }, 62 | "earliest_start_post": { 63 | "description": "Postponement of `Earliest Start Time` in days: 0 = today (default), 1 = tomorrow", 64 | "name": "Postponement of Earliest Start", 65 | "example": "0" 66 | }, 67 | "latest_end": { 68 | "description": "Latest time to end the appliance. If omitted, the end of the available market data is used. Refers to today if `Latest End Offset` is not set or 0, or tomorrow if offset is 1.", 69 | "name": "Latest End Time", 70 | "example": "20:00:00" 71 | }, 72 | "latest_end_post": { 73 | "description": "Postponement of `Latest End Time` in days: 0 = today (default), 1 = tomorrow.", 74 | "name": "Postponement of Latest End", 75 | "example": "0" 76 | }, 77 | "duration": { 78 | "description": "Required duration to complete appliance.", 79 | "name": "Duration", 80 | "example": "01:30:00" 81 | } 82 | }, 83 | "name": "Get lowest price interval" 84 | }, 85 | "get_highest_price_interval": { 86 | "description": "Get the time interval during which the price is at its highest point.", 87 | "fields": { 88 | "device_id": { 89 | "description": "An EPEX Spot service instance ID. In case you have multiple EPEX Spot instances.", 90 | "name": "EPEX Spot Service" 91 | }, 92 | "earliest_start": { 93 | "description": "Earliest time to start the appliance. If omitted, the current time is used. Refers to today if `Earliest Start Offset` is not set or 0, or tomorrow if offset is 1.", 94 | "name": "Earliest Start Time", 95 | "example": "14:00:00" 96 | }, 97 | "earliest_start_post": { 98 | "description": "Postponement of `Earliest Start Time` in days: 0 = today (default), 1 = tomorrow", 99 | "name": "Postponement of Earliest Start", 100 | "example": "0" 101 | }, 102 | "latest_end": { 103 | "description": "Latest time to end the appliance. If omitted, the end of the available market data is used. Refers to today if `Latest End Offset` is not set or 0, or tomorrow if offset is 1.", 104 | "name": "Latest End Time", 105 | "example": "20:00:00" 106 | }, 107 | "latest_end_post": { 108 | "description": "Postponement of `Latest End Time` in days: 0 = today (default), 1 = tomorrow.", 109 | "name": "Postponement of Latest End", 110 | "example": "0" 111 | }, 112 | "duration": { 113 | "description": "Required duration to complete appliance.", 114 | "name": "Duration", 115 | "example": "01:30:00" 116 | } 117 | }, 118 | "name": "Get highest price interval" 119 | }, 120 | "fetch_data": { 121 | "description": "Fetch data now", 122 | "fields": { 123 | "device_id": { 124 | "description": "An EPEX Spot service instance ID. In case you have multiple EPEX Spot instances.", 125 | "name": "EPEX Spot Service" 126 | } 127 | }, 128 | "name": "Fetch data from all services or a specific service." 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "EPEX Spot", 3 | "render_readme": true, 4 | "homeassistant": "2023.9.0" 5 | } 6 | -------------------------------------------------------------------------------- /images/apex_advanced.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mampfes/ha_epex_spot/8615c8be6235ece2334ba293d751140628a508ba/images/apex_advanced.png -------------------------------------------------------------------------------- /images/apexcharts-entities-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mampfes/ha_epex_spot/8615c8be6235ece2334ba293d751140628a508ba/images/apexcharts-entities-example.png -------------------------------------------------------------------------------- /images/apexcharts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mampfes/ha_epex_spot/8615c8be6235ece2334ba293d751140628a508ba/images/apexcharts.png -------------------------------------------------------------------------------- /images/dishwasher-card-examples.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mampfes/ha_epex_spot/8615c8be6235ece2334ba293d751140628a508ba/images/dishwasher-card-examples.png -------------------------------------------------------------------------------- /images/epex-spot-sensor-dishwasher-config-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mampfes/ha_epex_spot/8615c8be6235ece2334ba293d751140628a508ba/images/epex-spot-sensor-dishwasher-config-example.png -------------------------------------------------------------------------------- /images/epex-spot-sensor-dishwasher-sensor-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mampfes/ha_epex_spot/8615c8be6235ece2334ba293d751140628a508ba/images/epex-spot-sensor-dishwasher-sensor-example.png -------------------------------------------------------------------------------- /images/start_appliance_sensor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mampfes/ha_epex_spot/8615c8be6235ece2334ba293d751140628a508ba/images/start_appliance_sensor.png -------------------------------------------------------------------------------- /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 18 | --------------------------------------------------------------------------------